From 159dec073b4ae0af83085f64dd0e8de6c9a397d2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 18 Mar 2026 08:31:07 -0700 Subject: [PATCH 1/5] Add lint rule to catch environment catalog URLs --- packages/eslint-plugin-boxel/index.js | 1 + .../lib/recommended-rules.js | 1 + .../lib/rules/no-literal-realm-urls.js | 149 ++++++++++++++++++ .../lib/rules/no-literal-realm-urls-test.js | 99 ++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js create mode 100644 packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js diff --git a/packages/eslint-plugin-boxel/index.js b/packages/eslint-plugin-boxel/index.js index ba8c2700ca2..f1f9756a431 100644 --- a/packages/eslint-plugin-boxel/index.js +++ b/packages/eslint-plugin-boxel/index.js @@ -5,6 +5,7 @@ module.exports = { 'missing-card-api-import': require('./lib/rules/missing-card-api-import'), 'no-duplicate-imports': require('./lib/rules/no-duplicate-imports'), 'no-percy-direct-import': require('./lib/rules/no-percy-direct-import'), + 'no-literal-realm-urls': require('./lib/rules/no-literal-realm-urls'), // Add other rules here }, diff --git a/packages/eslint-plugin-boxel/lib/recommended-rules.js b/packages/eslint-plugin-boxel/lib/recommended-rules.js index dbe32e5cc98..7e962d0f030 100644 --- a/packages/eslint-plugin-boxel/lib/recommended-rules.js +++ b/packages/eslint-plugin-boxel/lib/recommended-rules.js @@ -7,6 +7,7 @@ module.exports = { "@cardstack/boxel/missing-card-api-import": "error", "@cardstack/boxel/no-duplicate-imports": "error", + "@cardstack/boxel/no-literal-realm-urls": "error", "@cardstack/boxel/no-percy-direct-import": "error", "@cardstack/boxel/template-missing-invokable": "error" } \ No newline at end of file diff --git a/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js new file mode 100644 index 00000000000..d1e7003f880 --- /dev/null +++ b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js @@ -0,0 +1,149 @@ +'use strict'; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +// Maps each registered prefix to the set of environment-specific base URLs +// that should be replaced by it. Order matters: longer/more-specific URLs +// should come first if there is any overlap. +const DEFAULT_REALM_MAPPINGS = [ + { + prefix: '@cardstack/catalog/', + urls: [ + 'http://localhost:4201/catalog/', + 'https://realms-staging.stack.cards/catalog/', + 'https://app.boxel.ai/catalog/', + ], + }, + // Future entries: + // { + // prefix: '@cardstack/skills/', + // urls: [ + // 'http://localhost:4201/skills/', + // 'https://realms-staging.stack.cards/skills/', + // 'https://app.boxel.ai/skills/', + // ], + // }, + // { + // prefix: '@cardstack/base/', + // urls: [ + // 'https://cardstack.com/base/', + // ], + // }, +]; + +/** + * Build a flat list of { url, prefix } pairs from the realm mappings, + * sorted longest-URL-first so that replacements are unambiguous. + */ +function buildReplacements(mappings) { + let pairs = []; + for (let mapping of mappings) { + for (let url of mapping.urls) { + pairs.push({ url, prefix: mapping.prefix }); + } + } + // Sort longest first so we don't accidentally match a shorter prefix + pairs.sort((a, b) => b.url.length - a.url.length); + return pairs; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow environment-specific realm URLs in code; use portable prefixes like @cardstack/catalog/ instead', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + realmMappings: { + type: 'array', + items: { + type: 'object', + properties: { + prefix: { type: 'string' }, + urls: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['prefix', 'urls'], + }, + }, + }, + additionalProperties: false, + }, + ], + messages: { + noLiteralRealmUrl: + 'Use "{{prefix}}" instead of the environment-specific URL "{{url}}".', + }, + }, + + create(context) { + let options = context.options[0] || {}; + let mappings = options.realmMappings || DEFAULT_REALM_MAPPINGS; + let replacements = buildReplacements(mappings); + + /** + * Check a string value for environment-specific realm URLs and report/fix. + * @param {import('estree').Node} node - The Literal or TemplateLiteral node + * @param {string} value - The string content to check + */ + function checkStringValue(node, value) { + for (let { url, prefix } of replacements) { + if (value.includes(url)) { + context.report({ + node, + messageId: 'noLiteralRealmUrl', + data: { prefix, url }, + fix(fixer) { + let raw = context.sourceCode.getText(node); + let fixed = raw.split(url).join(prefix); + return fixer.replaceText(node, fixed); + }, + }); + // Report only the first match per node to keep fixes simple + return; + } + } + } + + return { + Literal(node) { + if (typeof node.value === 'string') { + checkStringValue(node, node.value); + } + }, + TemplateLiteral(node) { + // Check each quasi (static part) of the template literal + for (let quasi of node.quasis) { + let value = quasi.value.cooked || quasi.value.raw; + for (let { url, prefix } of replacements) { + if (value.includes(url)) { + context.report({ + node, + messageId: 'noLiteralRealmUrl', + data: { prefix, url }, + fix(fixer) { + let raw = context.sourceCode.getText(node); + let fixed = raw.split(url).join(prefix); + return fixer.replaceText(node, fixed); + }, + }); + return; + } + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js new file mode 100644 index 00000000000..f2aa0a2bbeb --- /dev/null +++ b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js @@ -0,0 +1,99 @@ +'use strict'; + +const rule = require('../../../lib/rules/no-literal-realm-urls'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +ruleTester.run('no-literal-realm-urls', rule, { + valid: [ + // Already using prefix form + { + code: `let id = '@cardstack/catalog/PersonCard';`, + }, + // Unrelated URLs + { + code: `let url = 'https://example.com/something';`, + }, + // Partial match that isn't a realm URL + { + code: `let url = 'http://localhost:4201/other-realm/foo';`, + }, + // Template literal with prefix form + { + code: 'let id = `@cardstack/catalog/${name}`;', + }, + ], + + invalid: [ + // localhost catalog URL in a string literal + { + code: `let id = 'http://localhost:4201/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // staging catalog URL + { + code: `let id = 'https://realms-staging.stack.cards/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // production catalog URL + { + code: `let id = 'https://app.boxel.ai/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // URL with deeper path + { + code: `let ref = 'http://localhost:4201/catalog/fields/SkillCard.json';`, + output: `let ref = '@cardstack/catalog/fields/SkillCard.json';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Import statement with catalog URL + { + code: `import { PersonCard } from 'http://localhost:4201/catalog/person-card';`, + output: `import { PersonCard } from '@cardstack/catalog/person-card';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Template literal containing a catalog URL + { + code: 'let url = `http://localhost:4201/catalog/cards/${id}`;', + output: 'let url = `@cardstack/catalog/cards/${id}`;', + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Double-quoted string + { + code: `let id = "https://app.boxel.ai/catalog/FancyCard";`, + output: `let id = "@cardstack/catalog/FancyCard";`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Just the base URL (no trailing path beyond the realm) + { + code: `let base = 'http://localhost:4201/catalog/';`, + output: `let base = '@cardstack/catalog/';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Custom realm mappings via options + { + code: `let id = 'https://cardstack.com/base/card-api';`, + output: `let id = '@cardstack/base/card-api';`, + options: [ + { + realmMappings: [ + { + prefix: '@cardstack/base/', + urls: ['https://cardstack.com/base/'], + }, + ], + }, + ], + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + ], +}); From fd3778c8c123f27b17d6f4a8d34824614fa1c12f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 18 Mar 2026 08:36:09 -0700 Subject: [PATCH 2/5] Add handling for environment mode --- .../lib/rules/no-literal-realm-urls.js | 114 +++++++++++------- .../lib/rules/no-literal-realm-urls-test.js | 44 ++++++- 2 files changed, 116 insertions(+), 42 deletions(-) diff --git a/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js index d1e7003f880..3831072e7f0 100644 --- a/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js +++ b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js @@ -5,8 +5,8 @@ //------------------------------------------------------------------------------ // Maps each registered prefix to the set of environment-specific base URLs -// that should be replaced by it. Order matters: longer/more-specific URLs -// should come first if there is any overlap. +// that should be replaced by it, plus optional regex patterns for dynamic +// URLs (e.g. environment-mode *.localhost subdomains). const DEFAULT_REALM_MAPPINGS = [ { prefix: '@cardstack/catalog/', @@ -15,6 +15,8 @@ const DEFAULT_REALM_MAPPINGS = [ 'https://realms-staging.stack.cards/catalog/', 'https://app.boxel.ai/catalog/', ], + // Catches environment-mode URLs like http://realm-server.linty.localhost/catalog/ + patterns: ['https?://realm-server\\.[^.]+\\.localhost[^/]*/catalog/'], }, // Future entries: // { @@ -24,6 +26,7 @@ const DEFAULT_REALM_MAPPINGS = [ // 'https://realms-staging.stack.cards/skills/', // 'https://app.boxel.ai/skills/', // ], + // patterns: ['https?://[^/]+\\.localhost[^/]*/skills/'], // }, // { // prefix: '@cardstack/base/', @@ -37,7 +40,7 @@ const DEFAULT_REALM_MAPPINGS = [ * Build a flat list of { url, prefix } pairs from the realm mappings, * sorted longest-URL-first so that replacements are unambiguous. */ -function buildReplacements(mappings) { +function buildExactReplacements(mappings) { let pairs = []; for (let mapping of mappings) { for (let url of mapping.urls) { @@ -49,6 +52,27 @@ function buildReplacements(mappings) { return pairs; } +/** + * Build a list of { regex, prefix } from pattern-based mappings. + * Each pattern should match the full base URL up to and including + * the realm path segment (e.g. the catalog/ portion). + */ +function buildPatternReplacements(mappings) { + let result = []; + for (let mapping of mappings) { + if (!mapping.patterns) { + continue; + } + for (let pattern of mapping.patterns) { + result.push({ + regex: new RegExp(pattern), + prefix: mapping.prefix, + }); + } + } + return result; +} + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -74,6 +98,10 @@ module.exports = { type: 'array', items: { type: 'string' }, }, + patterns: { + type: 'array', + items: { type: 'string' }, + }, }, required: ['prefix', 'urls'], }, @@ -91,58 +119,62 @@ module.exports = { create(context) { let options = context.options[0] || {}; let mappings = options.realmMappings || DEFAULT_REALM_MAPPINGS; - let replacements = buildReplacements(mappings); + let exactReplacements = buildExactReplacements(mappings); + let patternReplacements = buildPatternReplacements(mappings); /** - * Check a string value for environment-specific realm URLs and report/fix. - * @param {import('estree').Node} node - The Literal or TemplateLiteral node - * @param {string} value - The string content to check + * Try exact URL matches first, then regex patterns. + * Returns { matchedUrl, prefix } or null. */ - function checkStringValue(node, value) { - for (let { url, prefix } of replacements) { + function findMatch(value) { + // Exact matches take priority + for (let { url, prefix } of exactReplacements) { if (value.includes(url)) { - context.report({ - node, - messageId: 'noLiteralRealmUrl', - data: { prefix, url }, - fix(fixer) { - let raw = context.sourceCode.getText(node); - let fixed = raw.split(url).join(prefix); - return fixer.replaceText(node, fixed); - }, - }); - // Report only the first match per node to keep fixes simple - return; + return { matchedUrl: url, prefix }; + } + } + // Then try regex patterns + for (let { regex, prefix } of patternReplacements) { + let match = regex.exec(value); + if (match) { + return { matchedUrl: match[0], prefix }; } } + return null; + } + + /** + * Check a string value for environment-specific realm URLs and report/fix. + */ + function checkAndReport(node, value) { + let result = findMatch(value); + if (!result) { + return; + } + let { matchedUrl, prefix } = result; + context.report({ + node, + messageId: 'noLiteralRealmUrl', + data: { prefix, url: matchedUrl }, + fix(fixer) { + let raw = context.sourceCode.getText(node); + let fixed = raw.split(matchedUrl).join(prefix); + return fixer.replaceText(node, fixed); + }, + }); } return { Literal(node) { if (typeof node.value === 'string') { - checkStringValue(node, node.value); + checkAndReport(node, node.value); } }, TemplateLiteral(node) { - // Check each quasi (static part) of the template literal - for (let quasi of node.quasis) { - let value = quasi.value.cooked || quasi.value.raw; - for (let { url, prefix } of replacements) { - if (value.includes(url)) { - context.report({ - node, - messageId: 'noLiteralRealmUrl', - data: { prefix, url }, - fix(fixer) { - let raw = context.sourceCode.getText(node); - let fixed = raw.split(url).join(prefix); - return fixer.replaceText(node, fixed); - }, - }); - return; - } - } - } + // For template literals, check the full source text so we catch + // URLs that span a single quasi segment + let fullText = context.sourceCode.getText(node); + checkAndReport(node, fullText); }, }; }, diff --git a/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js index f2aa0a2bbeb..08be65e79bf 100644 --- a/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js +++ b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js @@ -28,9 +28,23 @@ ruleTester.run('no-literal-realm-urls', rule, { { code: 'let id = `@cardstack/catalog/${name}`;', }, + // .localhost but not a catalog path + { + code: `let url = 'http://realm-server.linty.localhost/other/foo';`, + }, + // .localhost with catalog path but not realm-server host + { + code: `let url = 'http://boxel.linty.localhost/catalog/foo';`, + }, + // localhost without subdomain doesn't match the pattern (matched by exact URL instead, but not /other/) + { + code: `let url = 'http://localhost:4201/other/foo';`, + }, ], invalid: [ + // --- Exact URL matches --- + // localhost catalog URL in a string literal { code: `let id = 'http://localhost:4201/catalog/PersonCard';`, @@ -79,7 +93,35 @@ ruleTester.run('no-literal-realm-urls', rule, { output: `let base = '@cardstack/catalog/';`, errors: [{ messageId: 'noLiteralRealmUrl' }], }, - // Custom realm mappings via options + + // --- Pattern-based matches (environment-mode *.localhost) --- + + // Environment mode with subdomain + { + code: `let id = 'http://realm-server.linty.localhost/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode with port + { + code: `let id = 'http://realm-server.linty.localhost:4201/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode with https + { + code: `let id = 'https://realm-server.foo.localhost/catalog/deep/path/Card';`, + output: `let id = '@cardstack/catalog/deep/path/Card';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode — just the base URL + { + code: `let base = 'http://realm-server.linty.localhost/catalog/';`, + output: `let base = '@cardstack/catalog/';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + + // --- Custom realm mappings via options --- { code: `let id = 'https://cardstack.com/base/card-api';`, output: `let id = '@cardstack/base/card-api';`, From f4ada9bb1551e44fe918f96059f87b1aed1426fb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 18 Mar 2026 11:14:23 -0700 Subject: [PATCH 3/5] Add rule to lint tasks --- packages/runtime-common/tasks/lint.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime-common/tasks/lint.ts b/packages/runtime-common/tasks/lint.ts index 4fe6a184ee4..2e40e23f0cf 100644 --- a/packages/runtime-common/tasks/lint.ts +++ b/packages/runtime-common/tasks/lint.ts @@ -80,6 +80,7 @@ async function lintFix({ '@cardstack/boxel/no-duplicate-imports': 'error', '@cardstack/boxel/no-css-position-fixed': 'warn', '@cardstack/boxel/no-forbidden-head-tags': 'warn', + '@cardstack/boxel/no-literal-realm-urls': 'error', }; const eslintJsModule = await import(/* webpackIgnore: true */ '@eslint/js'); From 007cbb25b89d08b123fbc27373c5198a0fc6ebf7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 18 Mar 2026 12:19:54 -0700 Subject: [PATCH 4/5] Add lint rule exception --- packages/host/config/environment.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index ea2f440182f..65791170e16 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -26,6 +26,7 @@ function environmentDefaults() { realmHost: 'localhost:4201', iconsURL: 'http://localhost:4206', baseRealmURL: 'http://localhost:4201/base/', + // eslint-disable-next-line @cardstack/boxel/no-literal-realm-urls catalogRealmURL: 'http://localhost:4201/catalog/', externalCatalogRealmURL: 'http://localhost:4201/external-catalog/', skillsRealmURL: 'http://localhost:4201/skills/', From 5119abb8589f2ff7524585c40e0fd0ba9362fb2c Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 18 Mar 2026 12:50:00 -0700 Subject: [PATCH 5/5] Remove rule from recommended list --- packages/eslint-plugin-boxel/lib/recommended-rules.js | 1 - packages/host/config/environment.js | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/eslint-plugin-boxel/lib/recommended-rules.js b/packages/eslint-plugin-boxel/lib/recommended-rules.js index 7e962d0f030..dbe32e5cc98 100644 --- a/packages/eslint-plugin-boxel/lib/recommended-rules.js +++ b/packages/eslint-plugin-boxel/lib/recommended-rules.js @@ -7,7 +7,6 @@ module.exports = { "@cardstack/boxel/missing-card-api-import": "error", "@cardstack/boxel/no-duplicate-imports": "error", - "@cardstack/boxel/no-literal-realm-urls": "error", "@cardstack/boxel/no-percy-direct-import": "error", "@cardstack/boxel/template-missing-invokable": "error" } \ No newline at end of file diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index 65791170e16..ea2f440182f 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -26,7 +26,6 @@ function environmentDefaults() { realmHost: 'localhost:4201', iconsURL: 'http://localhost:4206', baseRealmURL: 'http://localhost:4201/base/', - // eslint-disable-next-line @cardstack/boxel/no-literal-realm-urls catalogRealmURL: 'http://localhost:4201/catalog/', externalCatalogRealmURL: 'http://localhost:4201/external-catalog/', skillsRealmURL: 'http://localhost:4201/skills/',