@hominis/fireforge 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +5 -15
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +36 -0
  10. package/dist/src/commands/export.js +47 -112
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +1 -1
  20. package/dist/src/commands/lint-per-patch.js +110 -81
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +0 -13
  94. package/dist/src/core/lint-cache.js +5 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
@@ -37,13 +37,72 @@ function findAttachedJsDoc(comments, declStart, source) {
37
37
  // ---------------------------------------------------------------------------
38
38
  // JSDoc tag parsing
39
39
  // ---------------------------------------------------------------------------
40
+ /**
41
+ * Skips a balanced `{ … }` JSDoc type expression starting at `start`
42
+ * (which must point at the opening brace). Braces nest in inline object
43
+ * types (`{{ id: string, args?: Record<string, boolean> }}`), so a flat
44
+ * "anything but `}`" regex truncates at the first inner close brace and
45
+ * loses the param name. String literal types may contain braces too, so
46
+ * quoted runs are skipped verbatim.
47
+ *
48
+ * @returns Index just past the matching close brace, or -1 when the type
49
+ * expression never closes (malformed doc — caller skips the tag).
50
+ */
51
+ function skipBalancedTypeBraces(jsDoc, start) {
52
+ let depth = 0;
53
+ let quote = null;
54
+ for (let i = start; i < jsDoc.length; i++) {
55
+ const ch = jsDoc[i];
56
+ if (quote !== null) {
57
+ if (ch === '\\')
58
+ i++;
59
+ else if (ch === quote)
60
+ quote = null;
61
+ continue;
62
+ }
63
+ if (ch === "'" || ch === '"' || ch === '`') {
64
+ quote = ch;
65
+ continue;
66
+ }
67
+ if (ch === '{')
68
+ depth++;
69
+ else if (ch === '}') {
70
+ depth--;
71
+ if (depth === 0)
72
+ return i + 1;
73
+ }
74
+ }
75
+ return -1;
76
+ }
40
77
  function extractParamNames(jsDoc) {
41
78
  const names = [];
42
- const paramPattern = /@param\s+(?:\{[^}]*\}\s+)?(\w+)/g;
79
+ const tagPattern = /@param\b/g;
43
80
  let m;
44
- while ((m = paramPattern.exec(jsDoc)) !== null) {
45
- if (m[1])
46
- names.push(m[1]);
81
+ while ((m = tagPattern.exec(jsDoc)) !== null) {
82
+ let i = m.index + m[0].length;
83
+ while (i < jsDoc.length && /\s/.test(jsDoc[i] ?? ''))
84
+ i++;
85
+ // Optional `{type}` expression — depth-aware so nested braces inside
86
+ // inline object types or Record<…> generics don't truncate the scan.
87
+ if (jsDoc[i] === '{') {
88
+ const afterType = skipBalancedTypeBraces(jsDoc, i);
89
+ if (afterType === -1)
90
+ continue;
91
+ i = afterType;
92
+ while (i < jsDoc.length && /\s/.test(jsDoc[i] ?? ''))
93
+ i++;
94
+ }
95
+ // Name token: bare `name`, optional `[name]`, or defaulted `[name=x]`.
96
+ // Dotted property docs (`opts.id`) record the base object name once.
97
+ const optional = jsDoc[i] === '[';
98
+ if (optional)
99
+ i++;
100
+ const nameMatch = /^[A-Za-z_$][\w$]*/.exec(jsDoc.slice(i));
101
+ if (!nameMatch)
102
+ continue;
103
+ const name = nameMatch[0];
104
+ if (!names.includes(name))
105
+ names.push(name);
47
106
  }
48
107
  return names;
49
108
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `observer-topic-naming` rule body, extracted from `patch-lint.ts`.
3
+ *
4
+ * The historical implementation captured the first string literal after
5
+ * the call's opening paren on a single line. That mis-attributed string
6
+ * literals in complex subject arguments and silently skipped multi-line
7
+ * call sites. This version scans the balanced argument list (across
8
+ * newlines, string-aware), takes the *topic* argument by position, and
9
+ * allowlists well-known Firefox-owned topics so tests that simulate
10
+ * upstream notifications are not pushed toward renaming real topics.
11
+ */
12
+ import type { PatchLintIssue } from '../types/commands/index.js';
13
+ /**
14
+ * Firefox-owned observer topics a fork legitimately observes or simulates.
15
+ * These must never be flagged for fork-prefix naming, even when a fork's
16
+ * binaryName happens to be a substring of one. `quit-application*` is
17
+ * handled as a prefix family in {@link isKnownFirefoxTopic}.
18
+ *
19
+ * The list is deliberately conservative: it only needs to cover topics
20
+ * whose text could plausibly contain a fork's binaryName, plus the
21
+ * high-traffic lifecycle topics seen in downstream test simulations.
22
+ */
23
+ export declare const KNOWN_FIREFOX_OBSERVER_TOPICS: ReadonlySet<string>;
24
+ /** True for allowlisted Firefox topics, including the quit-application family. */
25
+ export declare function isKnownFirefoxTopic(topic: string): boolean;
26
+ /**
27
+ * Lints observer-service call sites in `strippedContent` (comments already
28
+ * removed) for fork topic naming. Only topics that embed `binaryName` and
29
+ * do not follow the `<binary>-<noun>-<verb>` convention are flagged;
30
+ * allowlisted Firefox topics and constant-named topics are skipped.
31
+ *
32
+ * @param strippedContent - Source with comments stripped
33
+ * @param file - File path for issue attribution
34
+ * @param binaryName - Lowercased fork binary name
35
+ * @returns Observer-topic naming issues
36
+ */
37
+ export declare function lintObserverTopics(strippedContent: string, file: string, binaryName: string): PatchLintIssue[];
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `observer-topic-naming` rule body, extracted from `patch-lint.ts`.
4
+ *
5
+ * The historical implementation captured the first string literal after
6
+ * the call's opening paren on a single line. That mis-attributed string
7
+ * literals in complex subject arguments and silently skipped multi-line
8
+ * call sites. This version scans the balanced argument list (across
9
+ * newlines, string-aware), takes the *topic* argument by position, and
10
+ * allowlists well-known Firefox-owned topics so tests that simulate
11
+ * upstream notifications are not pushed toward renaming real topics.
12
+ */
13
+ /**
14
+ * Firefox-owned observer topics a fork legitimately observes or simulates.
15
+ * These must never be flagged for fork-prefix naming, even when a fork's
16
+ * binaryName happens to be a substring of one. `quit-application*` is
17
+ * handled as a prefix family in {@link isKnownFirefoxTopic}.
18
+ *
19
+ * The list is deliberately conservative: it only needs to cover topics
20
+ * whose text could plausibly contain a fork's binaryName, plus the
21
+ * high-traffic lifecycle topics seen in downstream test simulations.
22
+ */
23
+ export const KNOWN_FIREFOX_OBSERVER_TOPICS = new Set([
24
+ 'idle-daily',
25
+ 'profile-after-change',
26
+ 'profile-before-change',
27
+ 'xpcom-shutdown',
28
+ 'xpcom-will-shutdown',
29
+ 'final-ui-startup',
30
+ 'browser-delayed-startup-finished',
31
+ 'sessionstore-windows-restored',
32
+ 'document-element-inserted',
33
+ 'content-document-global-created',
34
+ 'http-on-modify-request',
35
+ 'http-on-examine-response',
36
+ 'nsPref:changed',
37
+ 'browser-window-before-show',
38
+ 'domwindowopened',
39
+ 'domwindowclosed',
40
+ ]);
41
+ /** True for allowlisted Firefox topics, including the quit-application family. */
42
+ export function isKnownFirefoxTopic(topic) {
43
+ return KNOWN_FIREFOX_OBSERVER_TOPICS.has(topic) || topic.startsWith('quit-application');
44
+ }
45
+ /**
46
+ * Scans a balanced `( … )` argument span starting at `openParen` (which
47
+ * must point at the opening paren) and splits it into top-level argument
48
+ * strings. Tracks paren/brace/bracket depth and skips quoted runs, so
49
+ * commas inside nested calls, object literals, or strings do not split.
50
+ *
51
+ * @returns The argument texts, or null when the span never closes within
52
+ * `maxLength` characters (malformed or truncated source — caller skips).
53
+ */
54
+ function extractCallArguments(content, openParen, maxLength = 2000) {
55
+ const args = [];
56
+ let current = '';
57
+ let depth = 0;
58
+ let quote = null;
59
+ const end = Math.min(content.length, openParen + maxLength);
60
+ for (let i = openParen; i < end; i++) {
61
+ const ch = content[i] ?? '';
62
+ if (quote !== null) {
63
+ current += ch;
64
+ if (ch === '\\') {
65
+ current += content[i + 1] ?? '';
66
+ i++;
67
+ }
68
+ else if (ch === quote) {
69
+ quote = null;
70
+ }
71
+ continue;
72
+ }
73
+ if (ch === "'" || ch === '"' || ch === '`') {
74
+ quote = ch;
75
+ current += ch;
76
+ continue;
77
+ }
78
+ if (ch === '(' || ch === '{' || ch === '[') {
79
+ depth++;
80
+ if (depth === 1 && ch === '(')
81
+ continue; // the call's own paren
82
+ current += ch;
83
+ continue;
84
+ }
85
+ if (ch === ')' || ch === '}' || ch === ']') {
86
+ depth--;
87
+ if (depth === 0 && ch === ')') {
88
+ args.push(current.trim());
89
+ return args;
90
+ }
91
+ current += ch;
92
+ continue;
93
+ }
94
+ if (ch === ',' && depth === 1) {
95
+ args.push(current.trim());
96
+ current = '';
97
+ continue;
98
+ }
99
+ current += ch;
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * Returns the literal string value when `arg` is exactly one plain string
105
+ * literal (no concatenation, no `${}` interpolation), otherwise null.
106
+ * Constant-named topics (identifiers, member expressions) intentionally
107
+ * return null — hoisting a literal into a named constant is a supported
108
+ * way to mark a topic as deliberate.
109
+ */
110
+ function asStringLiteral(arg) {
111
+ const trimmed = arg.trim();
112
+ if (trimmed.length < 2)
113
+ return null;
114
+ const quoteChar = trimmed[0];
115
+ if (quoteChar !== "'" && quoteChar !== '"' && quoteChar !== '`')
116
+ return null;
117
+ if (trimmed[trimmed.length - 1] !== quoteChar)
118
+ return null;
119
+ const inner = trimmed.slice(1, -1);
120
+ if (inner.includes(quoteChar))
121
+ return null; // concatenation like "a" + "b"
122
+ if (quoteChar === '`' && inner.includes('${'))
123
+ return null;
124
+ return inner;
125
+ }
126
+ /**
127
+ * Lints observer-service call sites in `strippedContent` (comments already
128
+ * removed) for fork topic naming. Only topics that embed `binaryName` and
129
+ * do not follow the `<binary>-<noun>-<verb>` convention are flagged;
130
+ * allowlisted Firefox topics and constant-named topics are skipped.
131
+ *
132
+ * @param strippedContent - Source with comments stripped
133
+ * @param file - File path for issue attribution
134
+ * @param binaryName - Lowercased fork binary name
135
+ * @returns Observer-topic naming issues
136
+ */
137
+ export function lintObserverTopics(strippedContent, file, binaryName) {
138
+ const issues = [];
139
+ const callPattern = /\b(?:addObserver|removeObserver|notifyObservers)\s*\(/g;
140
+ let callMatch;
141
+ while ((callMatch = callPattern.exec(strippedContent)) !== null) {
142
+ const openParen = callMatch.index + callMatch[0].length - 1;
143
+ const args = extractCallArguments(strippedContent, openParen);
144
+ if (!args)
145
+ continue;
146
+ // Topic is the second argument for all three observer-service methods:
147
+ // addObserver(observer, topic[, weak]), removeObserver(observer, topic),
148
+ // notifyObservers(subject, topic[, data]).
149
+ const topicArg = args[1];
150
+ if (topicArg === undefined)
151
+ continue;
152
+ const topic = asStringLiteral(topicArg);
153
+ if (topic === null)
154
+ continue;
155
+ if (isKnownFirefoxTopic(topic))
156
+ continue;
157
+ if (topic.toLowerCase().includes(binaryName) && !/^[\w]+-[a-z]+-[a-z]+/.test(topic)) {
158
+ issues.push({
159
+ file,
160
+ check: 'observer-topic-naming',
161
+ message: `Observer topic "${topic}" should follow "${binaryName}-<noun>-<verb>" naming convention.`,
162
+ severity: 'warning',
163
+ });
164
+ }
165
+ }
166
+ return issues;
167
+ }
168
+ //# sourceMappingURL=patch-lint-observer.js.map
@@ -12,6 +12,7 @@ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-dif
12
12
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
13
13
  import { hasRelativeImport } from './patch-lint-imports.js';
14
14
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
15
+ import { lintObserverTopics } from './patch-lint-observer.js';
15
16
  import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
16
17
  // ---------------------------------------------------------------------------
17
18
  // Cross-patch lint re-exports
@@ -204,9 +205,130 @@ export function commentStyleForFile(file) {
204
205
  return 'js';
205
206
  return null;
206
207
  }
207
- // ---------------------------------------------------------------------------
208
- // CSS lint
209
- // ---------------------------------------------------------------------------
208
+ /**
209
+ * Loads the furnace token-prefix lint inputs gracefully — returns
210
+ * undefined (skipping the token-prefix check) when furnace.json cannot
211
+ * be loaded or no tokenPrefix is configured.
212
+ */
213
+ async function loadCssTokenContext(repoDir) {
214
+ try {
215
+ const root = join(repoDir, '..');
216
+ const furnaceConfig = await loadFurnaceConfig(root);
217
+ if (furnaceConfig.tokenPrefix) {
218
+ return {
219
+ tokenPrefix: furnaceConfig.tokenPrefix,
220
+ tokenAllowlist: new Set(furnaceConfig.tokenAllowlist ?? []),
221
+ runtimeVariables: new Set(furnaceConfig.runtimeVariables ?? []),
222
+ };
223
+ }
224
+ }
225
+ catch (error) {
226
+ verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
227
+ }
228
+ return undefined;
229
+ }
230
+ /**
231
+ * Raw-color check for one patched CSS file, scoped to introduced lines
232
+ * when diff context is available. Pushes onto `issues`.
233
+ */
234
+ function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
235
+ // Check only introduced raw color values when diff context is available.
236
+ // Skip files on the raw-color allowlist (exact path or basename match) and
237
+ // auto-exempt files under `browser/branding/` — those are the fork's
238
+ // visual identity assets (app-about dialogs, installer pages, branded
239
+ // CSS copied from Firefox's `unofficial` template) and belong to the
240
+ // design-decision layer the design-token system does not govern.
241
+ // Without this auto-exemption, every first-time setup's copied CSS
242
+ // failed `raw-color-value` with no actionable fix other than manually
243
+ // listing each path in `rawColorAllowlist`.
244
+ const allowlist = config?.patchLint?.rawColorAllowlist;
245
+ const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
246
+ const isBranding = file.startsWith('browser/branding/');
247
+ if (!isAllowlisted && !isBranding) {
248
+ // Strip lines with inline fireforge-ignore: raw-color-value suppression.
249
+ // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
250
+ const sourceForSuppression = addedLinesByFile
251
+ ? (addedLinesByFile.get(file) ?? []).join('\n')
252
+ : rawCss;
253
+ const suppressedContent = sourceForSuppression
254
+ .split('\n')
255
+ .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
256
+ .join('\n')
257
+ .replace(/\/\*[\s\S]*?\*\//g, '');
258
+ if (hasRawCssColors(suppressedContent)) {
259
+ issues.push({
260
+ file,
261
+ check: 'raw-color-value',
262
+ message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
263
+ severity: 'error',
264
+ });
265
+ }
266
+ }
267
+ }
268
+ /**
269
+ * Token-prefix check for one patched CSS file: flags `var(--x)` references
270
+ * that match neither the configured prefix, the allowlist, the runtime
271
+ * variables, nor a same-file declaration. Pushes onto `issues`.
272
+ */
273
+ function checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues) {
274
+ // Check for non-tokenized custom properties. A variable that is both
275
+ // declared and consumed inside the same file is auto-exempted as a
276
+ // runtime state channel (see furnace.json → runtimeVariables).
277
+ //
278
+ // When diff context is available, scope the `var(...)` scan to
279
+ // added/modified lines only. `cssContent` (full-file) is still the
280
+ // source of `localDeclarations` so vars declared anywhere in the file
281
+ // are recognised as same-file refs regardless of where the consuming
282
+ // `var(...)` appears. Before this scoping change, a small edit to a
283
+ // Furnace override of a stock component (e.g. moz-card) produced a
284
+ // `token-prefix-violation` for every stock `var(--moz-card-*)` the
285
+ // upstream file already carried, because the scanner saw the full
286
+ // applied file and flagged each inherited reference as if the fork
287
+ // had introduced it.
288
+ if (tokenContext) {
289
+ const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
290
+ const localDeclarations = new Set();
291
+ let declMatch;
292
+ while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
293
+ const name = declMatch[1];
294
+ if (name)
295
+ localDeclarations.add(name);
296
+ }
297
+ const prefixScanSource = addedLinesByFile
298
+ ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
299
+ : cssContent;
300
+ if (prefixScanSource.length > 0) {
301
+ const varPattern = /var\(\s*(--[\w-]+)/g;
302
+ const flaggedProps = new Set();
303
+ let match;
304
+ while ((match = varPattern.exec(prefixScanSource)) !== null) {
305
+ const prop = match[1];
306
+ if (!prop)
307
+ continue;
308
+ if (prop.startsWith(tokenContext.tokenPrefix))
309
+ continue;
310
+ if (tokenContext.tokenAllowlist.has(prop))
311
+ continue;
312
+ if (tokenContext.runtimeVariables.has(prop))
313
+ continue;
314
+ if (localDeclarations.has(prop))
315
+ continue;
316
+ // De-duplicate per (file, prop) pair so the same introduced var
317
+ // used five times in the added hunk doesn't produce five
318
+ // identical issue entries.
319
+ if (flaggedProps.has(prop))
320
+ continue;
321
+ flaggedProps.add(prop);
322
+ issues.push({
323
+ file,
324
+ check: 'token-prefix-violation',
325
+ message: `CSS references var(${prop}) which does not match the required token prefix "${tokenContext.tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
326
+ severity: 'error',
327
+ });
328
+ }
329
+ }
330
+ }
331
+ }
210
332
  /**
211
333
  * Lints patched CSS files for introduced raw color values and non-tokenized
212
334
  * custom properties.
@@ -220,22 +342,7 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
220
342
  const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
221
343
  if (cssFiles.length === 0)
222
344
  return [];
223
- // Load furnace config gracefully — skip token-prefix check if unavailable
224
- let tokenPrefix;
225
- let tokenAllowlist;
226
- let runtimeVariables;
227
- try {
228
- const root = join(repoDir, '..');
229
- const furnaceConfig = await loadFurnaceConfig(root);
230
- if (furnaceConfig.tokenPrefix) {
231
- tokenPrefix = furnaceConfig.tokenPrefix;
232
- tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
233
- runtimeVariables = new Set(furnaceConfig.runtimeVariables ?? []);
234
- }
235
- }
236
- catch (error) {
237
- verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
238
- }
345
+ const tokenContext = await loadCssTokenContext(repoDir);
239
346
  const issues = [];
240
347
  const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
241
348
  for (const file of cssFiles) {
@@ -245,95 +352,8 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
245
352
  const rawCss = await readText(filePath);
246
353
  // Strip block comments before scanning
247
354
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
248
- // Check only introduced raw color values when diff context is available.
249
- // Skip files on the raw-color allowlist (exact path or basename match) and
250
- // auto-exempt files under `browser/branding/` — those are the fork's
251
- // visual identity assets (app-about dialogs, installer pages, branded
252
- // CSS copied from Firefox's `unofficial` template) and belong to the
253
- // design-decision layer the design-token system does not govern.
254
- // Without this auto-exemption, every first-time setup's copied CSS
255
- // failed `raw-color-value` with no actionable fix other than manually
256
- // listing each path in `rawColorAllowlist`.
257
- const allowlist = config?.patchLint?.rawColorAllowlist;
258
- const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
259
- const isBranding = file.startsWith('browser/branding/');
260
- if (!isAllowlisted && !isBranding) {
261
- // Strip lines with inline fireforge-ignore: raw-color-value suppression.
262
- // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
263
- const sourceForSuppression = addedLinesByFile
264
- ? (addedLinesByFile.get(file) ?? []).join('\n')
265
- : rawCss;
266
- const suppressedContent = sourceForSuppression
267
- .split('\n')
268
- .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
269
- .join('\n')
270
- .replace(/\/\*[\s\S]*?\*\//g, '');
271
- if (hasRawCssColors(suppressedContent)) {
272
- issues.push({
273
- file,
274
- check: 'raw-color-value',
275
- message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
276
- severity: 'error',
277
- });
278
- }
279
- }
280
- // Check for non-tokenized custom properties. A variable that is both
281
- // declared and consumed inside the same file is auto-exempted as a
282
- // runtime state channel (see furnace.json → runtimeVariables).
283
- //
284
- // When diff context is available, scope the `var(...)` scan to
285
- // added/modified lines only. `cssContent` (full-file) is still the
286
- // source of `localDeclarations` so vars declared anywhere in the file
287
- // are recognised as same-file refs regardless of where the consuming
288
- // `var(...)` appears. Before this scoping change, a small edit to a
289
- // Furnace override of a stock component (e.g. moz-card) produced a
290
- // `token-prefix-violation` for every stock `var(--moz-card-*)` the
291
- // upstream file already carried, because the scanner saw the full
292
- // applied file and flagged each inherited reference as if the fork
293
- // had introduced it.
294
- if (tokenPrefix) {
295
- const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
296
- const localDeclarations = new Set();
297
- let declMatch;
298
- while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
299
- const name = declMatch[1];
300
- if (name)
301
- localDeclarations.add(name);
302
- }
303
- const prefixScanSource = addedLinesByFile
304
- ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
305
- : cssContent;
306
- if (prefixScanSource.length > 0) {
307
- const varPattern = /var\(\s*(--[\w-]+)/g;
308
- const flaggedProps = new Set();
309
- let match;
310
- while ((match = varPattern.exec(prefixScanSource)) !== null) {
311
- const prop = match[1];
312
- if (!prop)
313
- continue;
314
- if (prop.startsWith(tokenPrefix))
315
- continue;
316
- if (tokenAllowlist?.has(prop))
317
- continue;
318
- if (runtimeVariables?.has(prop))
319
- continue;
320
- if (localDeclarations.has(prop))
321
- continue;
322
- // De-duplicate per (file, prop) pair so the same introduced var
323
- // used five times in the added hunk doesn't produce five
324
- // identical issue entries.
325
- if (flaggedProps.has(prop))
326
- continue;
327
- flaggedProps.add(prop);
328
- issues.push({
329
- file,
330
- check: 'token-prefix-violation',
331
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
332
- severity: 'error',
333
- });
334
- }
335
- }
336
- }
355
+ checkRawColorValues(file, rawCss, addedLinesByFile, config, issues);
356
+ checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues);
337
357
  }
338
358
  return issues;
339
359
  }
@@ -503,23 +523,10 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
503
523
  });
504
524
  }
505
525
  }
506
- // 4. Observer topic naming
507
- const topicPattern = /(?:addObserver|removeObserver|notifyObservers)\s*\([^)\n]*["']([^"']+)["']/g;
508
- let topicMatch;
509
- while ((topicMatch = topicPattern.exec(strippedContent)) !== null) {
510
- const topic = topicMatch[1];
511
- if (!topic)
512
- continue;
513
- // Only flag topics that contain the binaryName but don't follow convention
514
- if (topic.toLowerCase().includes(binaryName) && !/^[\w]+-[a-z]+-[a-z]+/.test(topic)) {
515
- issues.push({
516
- file,
517
- check: 'observer-topic-naming',
518
- message: `Observer topic "${topic}" should follow "${binaryName}-<noun>-<verb>" naming convention.`,
519
- severity: 'warning',
520
- });
521
- }
522
- }
526
+ // 4. Observer topic naming. Rule body lives in `patch-lint-observer.ts`:
527
+ // argument-position-aware, multi-line-safe, and allowlists Firefox-owned
528
+ // topics so simulated upstream notifications are not flagged.
529
+ issues.push(...lintObserverTopics(strippedContent, file, binaryName));
523
530
  }
524
531
  return issues;
525
532
  }
@@ -85,6 +85,22 @@ export interface PatchRenameEntry {
85
85
  /** New numeric order — must match the prefix of `newFilename`. */
86
86
  newOrder: number;
87
87
  }
88
+ /**
89
+ * Rewrites `stagedDependencies.forwardImports[].owner` references on one
90
+ * patch through a rename lookup. Owners embed exact patch filenames, so any
91
+ * renumber (compact, reorder, placement export, rename) that does not remap
92
+ * them leaves dangling references that surface as false forward-import
93
+ * errors on the next lint.
94
+ *
95
+ * Pure and allocation-conservative: returns the input object unchanged when
96
+ * no owner matches the lookup, so callers can map whole manifests cheaply.
97
+ *
98
+ * @param patch - Manifest row to rewrite
99
+ * @param renameLookup - Maps an old patch filename to its new filename, or
100
+ * undefined when the filename is not being renamed
101
+ * @returns The same row, or a copy with remapped owners
102
+ */
103
+ export declare function rewriteStagedDependencyOwners(patch: PatchMetadata, renameLookup: (oldFilename: string) => string | undefined): PatchMetadata;
88
104
  /**
89
105
  * Renames patch files on disk and rewrites the corresponding manifest rows
90
106
  * atomically-ish: file renames use a two-phase staging strategy (rename each
@@ -167,6 +167,44 @@ export async function removePatchFromManifest(patchesDir, filename) {
167
167
  await savePatchesManifest(patchesDir, manifest);
168
168
  return true;
169
169
  }
170
+ /**
171
+ * Rewrites `stagedDependencies.forwardImports[].owner` references on one
172
+ * patch through a rename lookup. Owners embed exact patch filenames, so any
173
+ * renumber (compact, reorder, placement export, rename) that does not remap
174
+ * them leaves dangling references that surface as false forward-import
175
+ * errors on the next lint.
176
+ *
177
+ * Pure and allocation-conservative: returns the input object unchanged when
178
+ * no owner matches the lookup, so callers can map whole manifests cheaply.
179
+ *
180
+ * @param patch - Manifest row to rewrite
181
+ * @param renameLookup - Maps an old patch filename to its new filename, or
182
+ * undefined when the filename is not being renamed
183
+ * @returns The same row, or a copy with remapped owners
184
+ */
185
+ export function rewriteStagedDependencyOwners(patch, renameLookup) {
186
+ const forwardImports = patch.stagedDependencies?.forwardImports;
187
+ if (!forwardImports || forwardImports.length === 0)
188
+ return patch;
189
+ const rewritten = forwardImports.map((fi) => {
190
+ if (!fi.owner)
191
+ return fi;
192
+ const newOwner = renameLookup(fi.owner);
193
+ if (newOwner === undefined || newOwner === fi.owner)
194
+ return fi;
195
+ return { ...fi, owner: newOwner };
196
+ });
197
+ const changed = rewritten.some((fi, index) => fi !== forwardImports[index]);
198
+ if (!changed)
199
+ return patch;
200
+ return {
201
+ ...patch,
202
+ stagedDependencies: {
203
+ ...patch.stagedDependencies,
204
+ forwardImports: rewritten,
205
+ },
206
+ };
207
+ }
170
208
  /**
171
209
  * Renames patch files on disk and rewrites the corresponding manifest rows
172
210
  * atomically-ish: file renames use a two-phase staging strategy (rename each
@@ -300,12 +338,16 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
300
338
  for (const [oldFilename, entry] of renameMap) {
301
339
  filenameUpdates.set(oldFilename, entry);
302
340
  }
341
+ // Owner references live on *other* patches than the renamed ones, so every
342
+ // row is passed through the staged-dependency rewrite, not just renamed rows.
343
+ const ownerLookup = (oldFilename) => filenameUpdates.get(oldFilename)?.newFilename;
303
344
  const updatedPatches = manifest.patches.map((p) => {
304
345
  const update = filenameUpdates.get(p.filename);
346
+ const withOwners = rewriteStagedDependencyOwners(p, ownerLookup);
305
347
  if (!update)
306
- return p;
348
+ return withOwners;
307
349
  return {
308
- ...p,
350
+ ...withOwners,
309
351
  filename: update.newFilename,
310
352
  order: update.newOrder,
311
353
  };