@hominis/fireforge 0.31.0 → 0.32.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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/re-export-files.js +4 -1
  9. package/dist/src/commands/re-export.js +8 -1
  10. package/dist/src/commands/test-run.d.ts +10 -0
  11. package/dist/src/commands/test-run.js +13 -4
  12. package/dist/src/commands/test.js +46 -7
  13. package/dist/src/core/config-validate.js +26 -0
  14. package/dist/src/core/furnace-jsconfig.js +22 -2
  15. package/dist/src/core/git-base.d.ts +15 -0
  16. package/dist/src/core/git-base.js +32 -0
  17. package/dist/src/core/git-diff.d.ts +8 -0
  18. package/dist/src/core/git-diff.js +224 -59
  19. package/dist/src/core/git-file-ops.d.ts +39 -0
  20. package/dist/src/core/git-file-ops.js +82 -1
  21. package/dist/src/core/mach.d.ts +17 -0
  22. package/dist/src/core/mach.js +21 -0
  23. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  24. package/dist/src/core/patch-lint-checkjs.js +213 -67
  25. package/dist/src/core/patch-lint-css.d.ts +23 -0
  26. package/dist/src/core/patch-lint-css.js +172 -0
  27. package/dist/src/core/patch-lint.d.ts +34 -11
  28. package/dist/src/core/patch-lint.js +19 -163
  29. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  30. package/dist/src/core/test-xpcshell-retry.js +9 -4
  31. package/dist/src/core/typecheck-shim.d.ts +3 -1
  32. package/dist/src/core/typecheck-shim.js +43 -3
  33. package/dist/src/types/commands/options.d.ts +17 -0
  34. package/dist/src/types/config.d.ts +11 -2
  35. package/package.json +1 -1
@@ -16,6 +16,14 @@
16
16
  * `patchLint.checkJsStrict` only tightens `strict` / `noImplicitAny`
17
17
  * and optional allowlisted `checkJsCompilerOptions`; it does not change
18
18
  * shim composition or suppressed diagnostic codes.
19
+ *
20
+ * Resolution scope vs reporting scope: the TS program is built over a
21
+ * *resolution* set (every patch-owned `.sys.mjs` the run cares about, so
22
+ * cross-patch `resource:///` imports resolve to their real sources), while
23
+ * diagnostics are emitted only for files in the *report* scope. Splitting
24
+ * the two lets per-patch lint build one queue-wide program and attribute
25
+ * findings per patch, and lets export/re-export resolve cross-patch imports
26
+ * while reporting only the patch under export.
19
27
  */
20
28
  import { basename, resolve } from 'node:path';
21
29
  import { pathExists } from '../utils/fs.js';
@@ -62,57 +70,121 @@ function createOwnedSpecifierResolver(ts, ownedAbsolute) {
62
70
  };
63
71
  };
64
72
  }
73
+ /** Maps a resolved file path to the TS extension enum the host must report. */
74
+ function extensionForFile(ts, file) {
75
+ if (file.endsWith('.d.ts'))
76
+ return ts.Extension.Dts;
77
+ if (file.endsWith('.ts'))
78
+ return ts.Extension.Ts;
79
+ if (file.endsWith('.tsx'))
80
+ return ts.Extension.Tsx;
81
+ if (file.endsWith('.cjs'))
82
+ return ts.Extension.Cjs;
83
+ if (file.endsWith('.jsx'))
84
+ return ts.Extension.Jsx;
85
+ if (file.endsWith('.json'))
86
+ return ts.Extension.Json;
87
+ return ts.Extension.Mjs;
88
+ }
65
89
  /**
66
- * Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
90
+ * Builds a resolver for a reviewed `paths` mapping (route 2 of the
91
+ * cross-patch resolution work). Each pattern may contain a single `*`;
92
+ * matching targets are resolved relative to `baseDir` (the engine dir, like
93
+ * the rest of `patchLint` which is engine-relative). Resolved files are
94
+ * recorded via `onResolved` so the compiler host knows to read them from
95
+ * disk rather than returning empty content. No `baseUrl` is set, so this is
96
+ * TS5090-safe: `paths` resolution is host-driven here, not config-driven.
97
+ */
98
+ function createPathsResolver(ts, paths, baseDir, fileExists, onResolved) {
99
+ const entries = Object.entries(paths);
100
+ return (specifier) => {
101
+ for (const [pattern, targets] of entries) {
102
+ const star = pattern.indexOf('*');
103
+ let captured;
104
+ if (star === -1) {
105
+ if (specifier !== pattern)
106
+ continue;
107
+ captured = '';
108
+ }
109
+ else {
110
+ const prefix = pattern.slice(0, star);
111
+ const suffix = pattern.slice(star + 1);
112
+ if (specifier.length < prefix.length + suffix.length)
113
+ continue;
114
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix))
115
+ continue;
116
+ captured = specifier.slice(prefix.length, specifier.length - suffix.length);
117
+ }
118
+ for (const target of targets) {
119
+ const rel = target.includes('*') ? target.replace('*', captured) : target;
120
+ const abs = resolve(baseDir, rel);
121
+ if (!fileExists(abs))
122
+ continue;
123
+ onResolved(abs);
124
+ return {
125
+ resolvedFileName: abs,
126
+ extension: extensionForFile(ts, abs),
127
+ isExternalLibraryImport: false,
128
+ };
129
+ }
130
+ }
131
+ return undefined;
132
+ };
133
+ }
134
+ /**
135
+ * Builds the checkJs program **once** over `resolutionOwned` and returns its
136
+ * diagnostics grouped by originating file. Callers slice the result by their
137
+ * own report scope — per-patch lint attributes each file to its owning
138
+ * patch, export/re-export keeps only the patch under export. Resolution
139
+ * always spans every file in `resolutionOwned`, so cross-patch
140
+ * `resource:///`/`chrome://` imports resolve to real sources.
67
141
  *
68
- * @param repoDir - Absolute path to the engine (repository) directory
69
- * @param patchOwnedFiles - Set of patch-owned `.sys.mjs` file paths (relative to repoDir)
70
- * @param extraShimPath - Optional project-relative path to an additional
71
- * `.d.ts` file whose contents are concatenated to the built-in
72
- * Firefox-globals shim. Sourced from `patchLint.checkJsExtraShim`.
73
- * Resolved against `projectRoot` (one level up from `repoDir` is the
74
- * wrong root patches sit inside `engine/` while the shim lives at
75
- * the project root, so the caller passes both).
76
- * @param projectRoot - Absolute project root for resolving `extraShimPath`.
77
- * Defaults to `repoDir` for back-compat with callers that don't
78
- * pass an extra shim (no resolution actually happens in that case).
79
- * @param mode - When `strict` is true, enables `strict` and `noImplicitAny`
80
- * (CI-style). Optional `compilerOptions` merges allowlisted boolean
81
- * overrides after that preset (from `patchLint.checkJsCompilerOptions`).
82
- * Omitted or `{ strict: false }` preserves the historical loose preset.
83
- * @returns Array of lint issues from TS diagnostics
142
+ * @param repoDir - Absolute engine (repository) directory
143
+ * @param resolutionOwned - Patch-owned `.sys.mjs` paths (relative to repoDir)
144
+ * the program should see and resolve against
145
+ * @param extraShimPath - Optional project-relative extra `.d.ts` appended to
146
+ * the built-in Firefox-globals shim (from `patchLint.checkJsExtraShim`)
147
+ * @param projectRoot - Absolute project root for resolving `extraShimPath`
148
+ * @param mode - Strictness preset plus allowlisted compiler-option overrides
149
+ * @returns Diagnostics grouped per owning file plus run-level errors
84
150
  */
85
- export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projectRoot, mode) {
86
- if (patchOwnedFiles.size === 0)
87
- return [];
151
+ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath, projectRoot, mode) {
152
+ const empty = { byFile: new Map(), global: [] };
153
+ if (resolutionOwned.size === 0)
154
+ return empty;
88
155
  // Dynamic import — typescript stays as a dev dependency
89
156
  let ts;
90
157
  try {
91
158
  ts = await import('typescript');
92
159
  }
93
160
  catch {
94
- return [
95
- {
96
- file: '(checkJs)',
97
- check: 'checkjs-type-error',
98
- message: 'patchLint.checkJs is enabled but the "typescript" package is not installed. ' +
99
- 'Run "npm install typescript" to enable type checking.',
100
- severity: 'error',
101
- },
102
- ];
161
+ return {
162
+ byFile: new Map(),
163
+ global: [
164
+ {
165
+ file: '(checkJs)',
166
+ check: 'checkjs-type-error',
167
+ message: 'patchLint.checkJs is enabled but the "typescript" package is not installed. ' +
168
+ 'Run "npm install typescript" to enable type checking.',
169
+ severity: 'error',
170
+ },
171
+ ],
172
+ };
103
173
  }
104
174
  // Resolve absolute paths for root files, filtering to files that exist
105
175
  const rootFiles = [];
106
176
  const ownedAbsolute = new Set();
107
- for (const rel of patchOwnedFiles) {
177
+ const relByAbsolute = new Map();
178
+ for (const rel of resolutionOwned) {
108
179
  const abs = resolve(repoDir, rel);
109
180
  if (await pathExists(abs)) {
110
181
  rootFiles.push(abs);
111
182
  ownedAbsolute.add(abs);
183
+ relByAbsolute.set(abs, rel);
112
184
  }
113
185
  }
114
186
  if (rootFiles.length === 0)
115
- return [];
187
+ return empty;
116
188
  // Compose the shim. `extraShimPath` is project-relative (validated
117
189
  // by config-validate); resolve it against `projectRoot`. When the
118
190
  // caller passes neither, fall back to `repoDir` — the only way the
@@ -127,14 +199,17 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
127
199
  }
128
200
  }
129
201
  catch (err) {
130
- return [
131
- {
132
- file: extraShimPath ?? '(checkJs)',
133
- check: 'checkjs-type-error',
134
- message: err instanceof Error ? err.message : String(err),
135
- severity: 'error',
136
- },
137
- ];
202
+ return {
203
+ byFile: new Map(),
204
+ global: [
205
+ {
206
+ file: extraShimPath ?? '(checkJs)',
207
+ check: 'checkjs-type-error',
208
+ message: err instanceof Error ? err.message : String(err),
209
+ severity: 'error',
210
+ },
211
+ ],
212
+ };
138
213
  }
139
214
  const shimPath = resolve(repoDir, SHIM_FILENAME);
140
215
  rootFiles.push(shimPath);
@@ -146,12 +221,23 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
146
221
  strict: false,
147
222
  noImplicitAny: false,
148
223
  };
224
+ // Allowlisted overrides. Booleans merge directly; a reviewed `paths`
225
+ // mapping is applied to the compiler options AND wired into the host
226
+ // resolver below so patch-owned modules can be typed from their real
227
+ // sources without a hand-generated ambient stub shim.
149
228
  const overrides = {};
229
+ let pathsMapping;
150
230
  const co = mode?.compilerOptions;
151
231
  if (co) {
152
232
  for (const key of Object.keys(co)) {
153
233
  const v = co[key];
154
- if (v !== undefined) {
234
+ if (v === undefined)
235
+ continue;
236
+ if (key === 'paths') {
237
+ pathsMapping = v;
238
+ overrides.paths = pathsMapping;
239
+ }
240
+ else {
155
241
  overrides[key] = v;
156
242
  }
157
243
  }
@@ -180,13 +266,19 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
180
266
  // the shim for the shim path, and returns empty content for
181
267
  // anything else to avoid reading the full Firefox tree.
182
268
  const defaultHost = ts.createCompilerHost(options);
269
+ // Files pulled in via a reviewed `paths` mapping — outside the owned set
270
+ // but read from disk so the resolver's targets actually type-check.
271
+ const pathsResolved = new Set();
272
+ const resolveViaPaths = pathsMapping
273
+ ? createPathsResolver(ts, pathsMapping, repoDir, (f) => defaultHost.fileExists(f), (abs) => pathsResolved.add(abs))
274
+ : undefined;
183
275
  const host = {
184
276
  ...defaultHost,
185
277
  getSourceFile(fileName, languageVersion, onError) {
186
278
  if (fileName === shimPath) {
187
279
  return ts.createSourceFile(fileName, shimSource, languageVersion, true);
188
280
  }
189
- if (ownedAbsolute.has(fileName)) {
281
+ if (ownedAbsolute.has(fileName) || pathsResolved.has(fileName)) {
190
282
  return defaultHost.getSourceFile(fileName, languageVersion, onError);
191
283
  }
192
284
  // For lib files (lib.es*.d.ts) delegate to the default host
@@ -201,7 +293,7 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
201
293
  fileExists(fileName) {
202
294
  if (fileName === shimPath)
203
295
  return true;
204
- if (ownedAbsolute.has(fileName))
296
+ if (ownedAbsolute.has(fileName) || pathsResolved.has(fileName))
205
297
  return true;
206
298
  return defaultHost.fileExists(fileName);
207
299
  },
@@ -211,61 +303,115 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
211
303
  return defaultHost.readFile(fileName);
212
304
  },
213
305
  resolveModuleNameLiterals(moduleLiterals) {
214
- return moduleLiterals.map((literal) => ({
215
- resolvedModule: resolveOwnedSpecifier(literal.text),
216
- }));
306
+ return moduleLiterals.map((literal) => {
307
+ const owned = resolveOwnedSpecifier(literal.text);
308
+ if (owned)
309
+ return { resolvedModule: owned };
310
+ return { resolvedModule: resolveViaPaths?.(literal.text) };
311
+ });
217
312
  },
218
313
  };
219
314
  const program = ts.createProgram(rootFiles, options, host);
220
- const allDiagnostics = [
221
- ...program.getSemanticDiagnostics(),
222
- ...program.getSyntacticDiagnostics(),
223
- ];
224
- // Filter to diagnostics originating in patch-owned files only,
225
- // and suppress module-resolution / unknown-name noise that is
226
- // inherent to checking Firefox JS outside Mozilla's build system.
227
- const issues = [];
228
- for (const diag of allDiagnostics) {
315
+ const byFile = groupOwnedDiagnostics(ts, [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()], relByAbsolute);
316
+ verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s) across ${byFile.size} owning file(s)`);
317
+ return { byFile, global: [] };
318
+ }
319
+ /**
320
+ * Groups TS diagnostics by the patch-owned file they originate in,
321
+ * suppressing module-resolution / unknown-name noise inherent to checking
322
+ * Firefox JS outside Mozilla's build system. Diagnostics from `paths`-resolved
323
+ * or shim files are dropped — only owned files (in `relByAbsolute`) carry
324
+ * findings.
325
+ */
326
+ function groupOwnedDiagnostics(ts, diagnostics, relByAbsolute) {
327
+ const byFile = new Map();
328
+ for (const diag of diagnostics) {
229
329
  if (SUPPRESSED_DIAGNOSTIC_CODES.has(diag.code))
230
330
  continue;
231
331
  const sourceFile = diag.file;
232
332
  if (!sourceFile)
233
333
  continue;
234
- if (!ownedAbsolute.has(sourceFile.fileName))
334
+ const relPath = relByAbsolute.get(sourceFile.fileName);
335
+ if (relPath === undefined)
235
336
  continue;
236
337
  const lineInfo = sourceFile.getLineAndCharacterOfPosition(diag.start ?? 0);
237
338
  const line = lineInfo.line + 1;
238
339
  const messageText = typeof diag.messageText === 'string'
239
340
  ? diag.messageText
240
341
  : ts.flattenDiagnosticMessageText(diag.messageText, '\n');
241
- // Find the relative path for the issue
242
- let relPath = sourceFile.fileName;
243
- for (const [rel, abs] of [...patchOwnedFiles].map((r) => [r, resolve(repoDir, r)])) {
244
- if (abs === sourceFile.fileName) {
245
- relPath = rel;
246
- break;
247
- }
248
- }
249
342
  const severity = diag.category === ts.DiagnosticCategory.Error ? 'error' : 'warning';
250
- issues.push({
343
+ const bucket = byFile.get(relPath) ?? [];
344
+ bucket.push({
251
345
  file: relPath,
252
346
  check: 'checkjs-type-error',
253
347
  message: `Line ${line}: ${messageText}`,
254
348
  severity,
255
349
  });
350
+ byFile.set(relPath, bucket);
351
+ }
352
+ return byFile;
353
+ }
354
+ /**
355
+ * Flattens a {@link runCheckJsGrouped} run into a single issue list. When
356
+ * `reportScope` is supplied, only diagnostics from files in that set are
357
+ * returned (resolution still spans every owned file); omitting it reports
358
+ * every owned file's diagnostics — the historical whole-set behaviour.
359
+ *
360
+ * @param repoDir - Absolute engine (repository) directory
361
+ * @param patchOwnedFiles - Patch-owned `.sys.mjs` paths to resolve against
362
+ * @param extraShimPath - Optional project-relative extra `.d.ts`
363
+ * @param projectRoot - Absolute project root for resolving `extraShimPath`
364
+ * @param mode - Strictness preset plus allowlisted compiler-option overrides
365
+ * @param reportScope - When set, restrict reported diagnostics to these
366
+ * repo-relative files
367
+ * @returns Array of lint issues from TS diagnostics
368
+ */
369
+ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projectRoot, mode, reportScope) {
370
+ const { byFile, global } = await runCheckJsGrouped(repoDir, patchOwnedFiles, extraShimPath, projectRoot, mode);
371
+ const issues = [...global];
372
+ for (const [rel, list] of byFile) {
373
+ if (reportScope && !reportScope.has(rel))
374
+ continue;
375
+ issues.push(...list);
256
376
  }
257
- verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s), found ${issues.length} issue(s)`);
258
377
  return issues;
259
378
  }
260
379
  /**
261
380
  * Invokes {@link runCheckJs} for a `patchLint` block with `checkJs: true`.
262
381
  * `projectRoot` is the FireForge project root (`dirname(engine)`).
382
+ *
383
+ * @param repoDir - Absolute engine (repository) directory
384
+ * @param patchOwnedFiles - Patch-owned `.sys.mjs` paths to resolve against
385
+ * @param patchLint - The resolved `patchLint` config block
386
+ * @param projectRoot - FireForge project root for shim resolution
387
+ * @param reportScope - Optional repo-relative files to report on (export /
388
+ * re-export passes the patch under export so cross-patch resolution does
389
+ * not surface other patches' diagnostics)
390
+ */
391
+ export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint, projectRoot, reportScope) {
392
+ const strict = patchLint.checkJsStrict === true;
393
+ const mode = strict && patchLint.checkJsCompilerOptions
394
+ ? { strict, compilerOptions: patchLint.checkJsCompilerOptions }
395
+ : { strict };
396
+ return runCheckJs(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, mode, reportScope);
397
+ }
398
+ /**
399
+ * Grouped variant of {@link invokePatchLintCheckJs}: builds one queue-wide
400
+ * checkJs program over `patchOwnedFiles` and returns its findings grouped by
401
+ * owning file. The per-patch lint orchestrator calls this **once per run**
402
+ * and attributes each file's findings to its owning patch, instead of
403
+ * rebuilding the same program for every patch in the queue.
404
+ *
405
+ * @param repoDir - Absolute engine (repository) directory
406
+ * @param patchOwnedFiles - Every patch-owned `.sys.mjs` in the queue
407
+ * @param patchLint - The resolved `patchLint` config block
408
+ * @param projectRoot - FireForge project root for shim resolution
263
409
  */
264
- export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint, projectRoot) {
410
+ export async function invokePatchLintCheckJsGrouped(repoDir, patchOwnedFiles, patchLint, projectRoot) {
265
411
  const strict = patchLint.checkJsStrict === true;
266
412
  const mode = strict && patchLint.checkJsCompilerOptions
267
413
  ? { strict, compilerOptions: patchLint.checkJsCompilerOptions }
268
414
  : { strict };
269
- return runCheckJs(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, mode);
415
+ return runCheckJsGrouped(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, mode);
270
416
  }
271
417
  //# sourceMappingURL=patch-lint-checkjs.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * CSS patch-lint rules: introduced raw color values and non-tokenized
3
+ * custom-property references.
4
+ *
5
+ * Split out of `patch-lint.ts` so the per-patch and CSS rule bodies each
6
+ * stay within the project's per-file line budget — the same separation
7
+ * already applied to the JSDoc, observer, import, ownership, checkJs, and
8
+ * cross-patch rule families. `patch-lint.ts` re-exports `lintPatchedCss`
9
+ * so existing callers keep importing from the single module.
10
+ */
11
+ import type { PatchLintIssue } from '../types/commands/index.js';
12
+ import type { FireForgeConfig } from '../types/config.js';
13
+ /**
14
+ * Lints patched CSS files for introduced raw color values and non-tokenized
15
+ * custom properties.
16
+ *
17
+ * @param repoDir - Absolute path to the engine (repository) directory
18
+ * @param affectedFiles - File paths (relative to repoDir) affected by the patch
19
+ * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
20
+ * @param config - Project configuration
21
+ * @returns Array of lint issues found
22
+ */
23
+ export declare function lintPatchedCss(repoDir: string, affectedFiles: string[], diffContent?: string, config?: FireForgeConfig): Promise<PatchLintIssue[]>;
@@ -0,0 +1,172 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * CSS patch-lint rules: introduced raw color values and non-tokenized
4
+ * custom-property references.
5
+ *
6
+ * Split out of `patch-lint.ts` so the per-patch and CSS rule bodies each
7
+ * stay within the project's per-file line budget — the same separation
8
+ * already applied to the JSDoc, observer, import, ownership, checkJs, and
9
+ * cross-patch rule families. `patch-lint.ts` re-exports `lintPatchedCss`
10
+ * so existing callers keep importing from the single module.
11
+ */
12
+ import { join } from 'node:path';
13
+ import { toError } from '../utils/errors.js';
14
+ import { pathExists, readText } from '../utils/fs.js';
15
+ import { verbose } from '../utils/logger.js';
16
+ import { hasRawCssColors } from '../utils/regex.js';
17
+ import { loadFurnaceConfig } from './furnace-config.js';
18
+ import { extractAddedLinesPerFile } from './patch-lint-diff.js';
19
+ /**
20
+ * Loads the furnace token-prefix lint inputs gracefully — returns
21
+ * undefined (skipping the token-prefix check) when furnace.json cannot
22
+ * be loaded or no tokenPrefix is configured.
23
+ */
24
+ async function loadCssTokenContext(repoDir) {
25
+ try {
26
+ const root = join(repoDir, '..');
27
+ const furnaceConfig = await loadFurnaceConfig(root);
28
+ if (furnaceConfig.tokenPrefix) {
29
+ return {
30
+ tokenPrefix: furnaceConfig.tokenPrefix,
31
+ tokenAllowlist: new Set(furnaceConfig.tokenAllowlist ?? []),
32
+ runtimeVariables: new Set(furnaceConfig.runtimeVariables ?? []),
33
+ };
34
+ }
35
+ }
36
+ catch (error) {
37
+ verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Raw-color check for one patched CSS file, scoped to introduced lines
43
+ * when diff context is available. Pushes onto `issues`.
44
+ */
45
+ function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
46
+ // Check only introduced raw color values when diff context is available.
47
+ // Skip files on the raw-color allowlist (exact path or basename match) and
48
+ // auto-exempt files under `browser/branding/` — those are the fork's
49
+ // visual identity assets (app-about dialogs, installer pages, branded
50
+ // CSS copied from Firefox's `unofficial` template) and belong to the
51
+ // design-decision layer the design-token system does not govern.
52
+ // Without this auto-exemption, every first-time setup's copied CSS
53
+ // failed `raw-color-value` with no actionable fix other than manually
54
+ // listing each path in `rawColorAllowlist`.
55
+ const allowlist = config?.patchLint?.rawColorAllowlist;
56
+ const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
57
+ const isBranding = file.startsWith('browser/branding/');
58
+ if (!isAllowlisted && !isBranding) {
59
+ // Strip lines with inline fireforge-ignore: raw-color-value suppression.
60
+ // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
61
+ const sourceForSuppression = addedLinesByFile
62
+ ? (addedLinesByFile.get(file) ?? []).join('\n')
63
+ : rawCss;
64
+ const suppressedContent = sourceForSuppression
65
+ .split('\n')
66
+ .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
67
+ .join('\n')
68
+ .replace(/\/\*[\s\S]*?\*\//g, '');
69
+ if (hasRawCssColors(suppressedContent)) {
70
+ issues.push({
71
+ file,
72
+ check: 'raw-color-value',
73
+ message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
74
+ severity: 'error',
75
+ });
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Token-prefix check for one patched CSS file: flags `var(--x)` references
81
+ * that match neither the configured prefix, the allowlist, the runtime
82
+ * variables, nor a same-file declaration. Pushes onto `issues`.
83
+ */
84
+ function checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues) {
85
+ // Check for non-tokenized custom properties. A variable that is both
86
+ // declared and consumed inside the same file is auto-exempted as a
87
+ // runtime state channel (see furnace.json → runtimeVariables).
88
+ //
89
+ // When diff context is available, scope the `var(...)` scan to
90
+ // added/modified lines only. `cssContent` (full-file) is still the
91
+ // source of `localDeclarations` so vars declared anywhere in the file
92
+ // are recognised as same-file refs regardless of where the consuming
93
+ // `var(...)` appears. Before this scoping change, a small edit to a
94
+ // Furnace override of a stock component (e.g. moz-card) produced a
95
+ // `token-prefix-violation` for every stock `var(--moz-card-*)` the
96
+ // upstream file already carried, because the scanner saw the full
97
+ // applied file and flagged each inherited reference as if the fork
98
+ // had introduced it.
99
+ if (tokenContext) {
100
+ const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
101
+ const localDeclarations = new Set();
102
+ let declMatch;
103
+ while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
104
+ const name = declMatch[1];
105
+ if (name)
106
+ localDeclarations.add(name);
107
+ }
108
+ const prefixScanSource = addedLinesByFile
109
+ ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
110
+ : cssContent;
111
+ if (prefixScanSource.length > 0) {
112
+ const varPattern = /var\(\s*(--[\w-]+)/g;
113
+ const flaggedProps = new Set();
114
+ let match;
115
+ while ((match = varPattern.exec(prefixScanSource)) !== null) {
116
+ const prop = match[1];
117
+ if (!prop)
118
+ continue;
119
+ if (prop.startsWith(tokenContext.tokenPrefix))
120
+ continue;
121
+ if (tokenContext.tokenAllowlist.has(prop))
122
+ continue;
123
+ if (tokenContext.runtimeVariables.has(prop))
124
+ continue;
125
+ if (localDeclarations.has(prop))
126
+ continue;
127
+ // De-duplicate per (file, prop) pair so the same introduced var
128
+ // used five times in the added hunk doesn't produce five
129
+ // identical issue entries.
130
+ if (flaggedProps.has(prop))
131
+ continue;
132
+ flaggedProps.add(prop);
133
+ issues.push({
134
+ file,
135
+ check: 'token-prefix-violation',
136
+ 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.`,
137
+ severity: 'error',
138
+ });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Lints patched CSS files for introduced raw color values and non-tokenized
145
+ * custom properties.
146
+ *
147
+ * @param repoDir - Absolute path to the engine (repository) directory
148
+ * @param affectedFiles - File paths (relative to repoDir) affected by the patch
149
+ * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
150
+ * @param config - Project configuration
151
+ * @returns Array of lint issues found
152
+ */
153
+ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config) {
154
+ const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
155
+ if (cssFiles.length === 0)
156
+ return [];
157
+ const tokenContext = await loadCssTokenContext(repoDir);
158
+ const issues = [];
159
+ const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
160
+ for (const file of cssFiles) {
161
+ const filePath = join(repoDir, file);
162
+ if (!(await pathExists(filePath)))
163
+ continue;
164
+ const rawCss = await readText(filePath);
165
+ // Strip block comments before scanning
166
+ const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
167
+ checkRawColorValues(file, rawCss, addedLinesByFile, config, issues);
168
+ checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues);
169
+ }
170
+ return issues;
171
+ }
172
+ //# sourceMappingURL=patch-lint-css.js.map
@@ -1,7 +1,9 @@
1
1
  import type { PatchLintIssue } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type CommentStyle } from './license-headers.js';
4
+ import { lintPatchedCss } from './patch-lint-css.js';
4
5
  export * from './patch-lint-reexports.js';
6
+ export { lintPatchedCss };
5
7
  /**
6
8
  * Counts the total lines in a unified diff and the number of non-binary
7
9
  * text lines, so binary hunks do not inflate patch size checks.
@@ -23,16 +25,6 @@ export declare function isTestFile(file: string): boolean;
23
25
  * Detects comment style from file extension for license header checks.
24
26
  */
25
27
  export declare function commentStyleForFile(file: string): CommentStyle | null;
26
- /**
27
- * Lints patched CSS files for introduced raw color values and non-tokenized
28
- * custom properties.
29
- *
30
- * @param repoDir - Absolute path to the engine (repository) directory
31
- * @param affectedFiles - File paths (relative to repoDir) affected by the patch
32
- * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
33
- * @returns Array of lint issues found
34
- */
35
- export declare function lintPatchedCss(repoDir: string, affectedFiles: string[], diffContent?: string, config?: FireForgeConfig): Promise<PatchLintIssue[]>;
36
28
  /**
37
29
  * Checks new files for required license headers.
38
30
  *
@@ -121,6 +113,35 @@ export declare function lintPatchSize(filesAffected: string[], lineCount: number
121
113
  * @returns Warning-level lint issues for files missing any recognized header
122
114
  */
123
115
  export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles: string[], newFiles: Set<string>): Promise<PatchLintIssue[]>;
116
+ /**
117
+ * Optional behaviour switches for {@link lintExportedPatch}.
118
+ */
119
+ export interface LintExportedPatchOptions {
120
+ /**
121
+ * Skip the patch-size rules (`large-patch-files` / `large-patch-lines`).
122
+ * The ad-hoc `fireforge lint <files>` path passes a cross-patch file
123
+ * list that does not correspond to a single patch, so it suppresses the
124
+ * synthetic combined-list size check here and re-evaluates the size
125
+ * rules per owning patch instead — never synthesising a phantom
126
+ * oversized patch from the operator's file selection.
127
+ */
128
+ skipPatchSize?: boolean;
129
+ /**
130
+ * Restrict checkJs diagnostics to these repo-relative files; module
131
+ * resolution still spans every owned file in `patchQueueCtx`. Export and
132
+ * re-export pass the patch under export so cross-patch `resource:///`
133
+ * imports resolve against the whole queue while only that patch's
134
+ * findings surface.
135
+ */
136
+ checkJsReportScope?: ReadonlySet<string>;
137
+ /**
138
+ * Pre-computed checkJs issues for this patch. When provided, the internal
139
+ * checkJs invocation is skipped and these are appended verbatim — the
140
+ * per-patch lint path builds one queue-wide checkJs program and
141
+ * attributes findings per patch instead of rebuilding per patch.
142
+ */
143
+ precomputedCheckJs?: readonly PatchLintIssue[];
144
+ }
124
145
  /**
125
146
  * Runs all patch lint checks and returns combined issues.
126
147
  *
@@ -140,6 +161,8 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
140
161
  * per-patch manifest context (re-export, per-patch lint) should
141
162
  * pass this; aggregate-mode callers without a specific patch
142
163
  * context skip it and fall through to auto-detection.
164
+ * @param options - Optional behaviour switches; see
165
+ * {@link LintExportedPatchOptions}.
143
166
  * @returns Array of all lint issues found
144
167
  */
145
- export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<PatchLintIssue[]>;
168
+ export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding', options?: LintExportedPatchOptions): Promise<PatchLintIssue[]>;