@hominis/fireforge 0.39.0 → 0.40.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 (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/export-flow.js +14 -6
  5. package/dist/src/commands/export-shared.js +14 -3
  6. package/dist/src/commands/lint-per-patch.js +83 -14
  7. package/dist/src/commands/lint-report.d.ts +21 -0
  8. package/dist/src/commands/lint-report.js +62 -0
  9. package/dist/src/commands/lint.js +22 -18
  10. package/dist/src/commands/manifest.js +2 -0
  11. package/dist/src/commands/patch/rename.js +1 -1
  12. package/dist/src/commands/patch/staged-dependency.js +102 -14
  13. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  14. package/dist/src/commands/re-export-adjacent.js +106 -0
  15. package/dist/src/commands/re-export-options.js +3 -0
  16. package/dist/src/commands/re-export.js +42 -53
  17. package/dist/src/commands/run.js +18 -2
  18. package/dist/src/commands/status-check.d.ts +35 -0
  19. package/dist/src/commands/status-check.js +88 -0
  20. package/dist/src/commands/status.js +16 -5
  21. package/dist/src/commands/test-run.js +18 -3
  22. package/dist/src/commands/token.js +4 -1
  23. package/dist/src/commands/tree.d.ts +16 -0
  24. package/dist/src/commands/tree.js +174 -0
  25. package/dist/src/core/config-validate.js +17 -0
  26. package/dist/src/core/lint-cache.d.ts +15 -5
  27. package/dist/src/core/lint-cache.js +17 -5
  28. package/dist/src/core/patch-export.d.ts +2 -2
  29. package/dist/src/core/patch-export.js +1 -1
  30. package/dist/src/core/patch-lint-checkjs.d.ts +19 -1
  31. package/dist/src/core/patch-lint-checkjs.js +56 -3
  32. package/dist/src/core/patch-lint-css.js +64 -16
  33. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  34. package/dist/src/core/patch-lint-diff.js +31 -0
  35. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  36. package/dist/src/core/patch-lint-ownership.js +30 -0
  37. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  38. package/dist/src/core/patch-lint.d.ts +23 -0
  39. package/dist/src/core/patch-lint.js +30 -2
  40. package/dist/src/core/patch-manifest.d.ts +1 -1
  41. package/dist/src/core/smoke-patterns.d.ts +24 -8
  42. package/dist/src/core/smoke-patterns.js +23 -12
  43. package/dist/src/core/status-classify.d.ts +16 -0
  44. package/dist/src/core/status-classify.js +20 -6
  45. package/dist/src/core/token-category.d.ts +40 -0
  46. package/dist/src/core/token-category.js +264 -0
  47. package/dist/src/core/token-manager.d.ts +7 -0
  48. package/dist/src/core/token-manager.js +48 -196
  49. package/dist/src/core/tree-cow.d.ts +18 -0
  50. package/dist/src/core/tree-cow.js +71 -0
  51. package/dist/src/core/tree-guard.d.ts +35 -0
  52. package/dist/src/core/tree-guard.js +124 -0
  53. package/dist/src/core/tree-store.d.ts +53 -0
  54. package/dist/src/core/tree-store.js +214 -0
  55. package/dist/src/core/typecheck-shim.d.ts +14 -0
  56. package/dist/src/core/typecheck-shim.js +41 -2
  57. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  58. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  59. package/dist/src/errors/git.d.ts +8 -0
  60. package/dist/src/errors/git.js +17 -0
  61. package/dist/src/index.d.ts +2 -0
  62. package/dist/src/index.js +1 -0
  63. package/dist/src/types/commands/options.d.ts +33 -2
  64. package/dist/src/types/config.d.ts +19 -0
  65. package/dist/src/utils/validation.d.ts +10 -0
  66. package/dist/src/utils/validation.js +29 -0
  67. package/package.json +6 -6
@@ -28,7 +28,7 @@
28
28
  import { basename, resolve } from 'node:path';
29
29
  import { pathExists } from '../utils/fs.js';
30
30
  import { verbose } from '../utils/logger.js';
31
- import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
31
+ import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
32
32
  // ---------------------------------------------------------------------------
33
33
  // Public API
34
34
  // ---------------------------------------------------------------------------
@@ -148,7 +148,7 @@ function createPathsResolver(ts, paths, baseDir, fileExists, onResolved) {
148
148
  * @param mode - Strictness preset plus allowlisted compiler-option overrides
149
149
  * @returns Diagnostics grouped per owning file plus run-level errors
150
150
  */
151
- export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath, projectRoot, mode) {
151
+ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath, projectRoot, mode, builtinShimSuffix) {
152
152
  const empty = { byFile: new Map(), global: [] };
153
153
  if (resolutionOwned.size === 0)
154
154
  return empty;
@@ -193,7 +193,11 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
193
193
  let shimSource;
194
194
  try {
195
195
  const composed = await composeShimSource(projectRoot ?? repoDir, extraShimPath);
196
- shimSource = composed.source;
196
+ // The suffix (e.g. the loose test-harness baseline, FORGE G5) goes
197
+ // AFTER the consumer's extra shim: TypeScript resolves conflicting
198
+ // `declare var` redeclarations to the FIRST declaration, so a typed
199
+ // consumer declaration must precede the loose fallback to win.
200
+ shimSource = builtinShimSuffix ? `${composed.source}\n${builtinShimSuffix}` : composed.source;
197
201
  if (composed.extraShimAppended) {
198
202
  verbose(`checkJs: extra shim ${extraShimPath ?? ''} appended to Firefox globals shim`);
199
203
  }
@@ -402,6 +406,55 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
402
406
  export async function invokePatchLintCheckJs(repoDir, patchOwnedFiles, patchLint, projectRoot, reportScope) {
403
407
  return runCheckJs(repoDir, patchOwnedFiles, patchLint.checkJsExtraShim, projectRoot, modeFromPatchLintConfig(patchLint), reportScope);
404
408
  }
409
+ /**
410
+ * Runs the checkJs pass over patch-owned test `.js` files (FORGE G5,
411
+ * `patchLint.checkJsTestFiles`). Each test file gets its OWN small
412
+ * program — mochitest scripts share top-level scope only with their
413
+ * directory's `head*.js` helpers, so one multi-script program would emit
414
+ * false cross-file redeclaration errors — with roots = the test file plus
415
+ * any same-directory patch-owned `head*.js`, checked against the built-in
416
+ * Firefox shim + {@link TEST_HARNESS_SHIM} + the optional consumer
417
+ * `checkJsTestShim`. Only the target file's diagnostics are reported per
418
+ * program (head helpers report from their own program), and run-level
419
+ * errors are de-duplicated across programs.
420
+ *
421
+ * @param repoDir - Absolute engine (repository) directory
422
+ * @param testFiles - Patch-owned test-script paths (repo-relative)
423
+ * @param patchLint - The resolved `patchLint` config block
424
+ * @param projectRoot - FireForge project root for shim resolution
425
+ */
426
+ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint, projectRoot) {
427
+ const merged = { byFile: new Map(), global: [] };
428
+ if (testFiles.size === 0)
429
+ return merged;
430
+ const mode = modeFromPatchLintConfig(patchLint);
431
+ const seenGlobal = new Set();
432
+ const files = [...testFiles].sort((a, b) => a.localeCompare(b));
433
+ for (const file of files) {
434
+ const dir = file.slice(0, file.lastIndexOf('/') + 1);
435
+ const roots = new Set([file]);
436
+ for (const candidate of files) {
437
+ if (candidate === file)
438
+ continue;
439
+ const base = candidate.split('/').pop() ?? '';
440
+ if (candidate.startsWith(dir) && /^head(?:_.*)?\.js$/.test(base)) {
441
+ roots.add(candidate);
442
+ }
443
+ }
444
+ const result = await runCheckJsGrouped(repoDir, roots, patchLint.checkJsTestShim, projectRoot, mode, TEST_HARNESS_SHIM);
445
+ const own = result.byFile.get(file);
446
+ if (own && own.length > 0) {
447
+ merged.byFile.set(file, [...(merged.byFile.get(file) ?? []), ...own]);
448
+ }
449
+ for (const globalIssue of result.global) {
450
+ if (seenGlobal.has(globalIssue.message))
451
+ continue;
452
+ seenGlobal.add(globalIssue.message);
453
+ merged.global.push(globalIssue);
454
+ }
455
+ }
456
+ return merged;
457
+ }
405
458
  /** Derives the {@link CheckJsMode} from a `patchLint` config block. */
406
459
  function modeFromPatchLintConfig(patchLint) {
407
460
  const strict = patchLint.checkJsStrict === true;
@@ -15,7 +15,7 @@ import { pathExists, readText } from '../utils/fs.js';
15
15
  import { verbose } from '../utils/logger.js';
16
16
  import { hasRawCssColors } from '../utils/regex.js';
17
17
  import { loadFurnaceConfig } from './furnace-config.js';
18
- import { extractAddedLinesPerFile } from './patch-lint-diff.js';
18
+ import { extractAddedLineNumbersPerFile, extractAddedLinesPerFile } from './patch-lint-diff.js';
19
19
  /**
20
20
  * Loads the furnace token-prefix lint inputs gracefully — returns
21
21
  * undefined (skipping the token-prefix check) when furnace.json cannot
@@ -38,11 +38,48 @@ async function loadCssTokenContext(repoDir) {
38
38
  }
39
39
  return undefined;
40
40
  }
41
+ /**
42
+ * Masks CSS block comments with spaces, preserving newlines so line
43
+ * numbers stay stable. An unclosed trailing `/*` is masked to EOF —
44
+ * matching how a CSS parser treats it (FORGE G4).
45
+ */
46
+ function maskCssComments(source) {
47
+ const masked = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
48
+ const openerIndex = masked.indexOf('/*');
49
+ if (openerIndex === -1)
50
+ return masked;
51
+ return (masked.slice(0, openerIndex) +
52
+ masked
53
+ .slice(openerIndex)
54
+ .split('\n')
55
+ .map((line) => ' '.repeat(line.length))
56
+ .join('\n'));
57
+ }
58
+ const RAW_COLOR_IGNORE_MARKER = 'fireforge-ignore: raw-color-value';
59
+ /**
60
+ * Builds the scan source for the diff branch with full-file comment
61
+ * awareness: comments are masked across the whole post-patch file so a
62
+ * comment spanning the context/added boundary suppresses its hex, then
63
+ * only the patch's added line numbers are scanned. Returns undefined
64
+ * when any added line number falls outside the on-disk file (patch not
65
+ * applied / drifted) — the caller then falls back to the legacy
66
+ * joined-added-lines scan rather than misattributing lines.
67
+ */
68
+ function buildAddedLinesScanSource(rawCss, addedNumbers) {
69
+ const rawLines = rawCss.split('\n');
70
+ if (!addedNumbers.every((n) => n >= 1 && n <= rawLines.length))
71
+ return undefined;
72
+ const maskedLines = maskCssComments(rawCss).split('\n');
73
+ return addedNumbers
74
+ .filter((n) => !(rawLines[n - 1] ?? '').includes(RAW_COLOR_IGNORE_MARKER))
75
+ .map((n) => maskedLines[n - 1] ?? '')
76
+ .join('\n');
77
+ }
41
78
  /**
42
79
  * Raw-color check for one patched CSS file, scoped to introduced lines
43
80
  * when diff context is available. Pushes onto `issues`.
44
81
  */
45
- function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
82
+ function checkRawColorValues(file, rawCss, addedLinesByFile, addedLineNumbersByFile, config, issues) {
46
83
  // Check only introduced raw color values when diff context is available.
47
84
  // Skip files on the raw-color allowlist (exact path or basename match) and
48
85
  // auto-exempt files under `browser/branding/` — those are the fork's
@@ -55,25 +92,33 @@ function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
55
92
  const allowlist = config?.patchLint?.rawColorAllowlist;
56
93
  const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
57
94
  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.
95
+ if (isAllowlisted || isBranding)
96
+ return;
97
+ let suppressedContent;
98
+ const addedNumbers = addedLineNumbersByFile?.get(file);
99
+ if (addedNumbers !== undefined) {
100
+ suppressedContent = buildAddedLinesScanSource(rawCss, addedNumbers);
101
+ }
102
+ if (suppressedContent === undefined) {
103
+ // Legacy scan: full file (no diff), or the joined added lines when
104
+ // the diff and on-disk file disagree. Waiver lines are filtered
105
+ // against the pre-strip text so the CSS comment marker is present.
61
106
  const sourceForSuppression = addedLinesByFile
62
107
  ? (addedLinesByFile.get(file) ?? []).join('\n')
63
108
  : rawCss;
64
- const suppressedContent = sourceForSuppression
109
+ suppressedContent = sourceForSuppression
65
110
  .split('\n')
66
- .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
111
+ .filter((line) => !line.includes(RAW_COLOR_IGNORE_MARKER))
67
112
  .join('\n')
68
113
  .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
- }
114
+ }
115
+ if (hasRawCssColors(suppressedContent)) {
116
+ issues.push({
117
+ file,
118
+ check: 'raw-color-value',
119
+ message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
120
+ severity: 'error',
121
+ });
77
122
  }
78
123
  }
79
124
  /**
@@ -157,6 +202,9 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
157
202
  const tokenContext = await loadCssTokenContext(repoDir);
158
203
  const issues = [];
159
204
  const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
205
+ const addedLineNumbersByFile = diffContent
206
+ ? extractAddedLineNumbersPerFile(diffContent)
207
+ : undefined;
160
208
  for (const file of cssFiles) {
161
209
  const filePath = join(repoDir, file);
162
210
  if (!(await pathExists(filePath)))
@@ -164,7 +212,7 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
164
212
  const rawCss = await readText(filePath);
165
213
  // Strip block comments before scanning
166
214
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
167
- checkRawColorValues(file, rawCss, addedLinesByFile, config, issues);
215
+ checkRawColorValues(file, rawCss, addedLinesByFile, addedLineNumbersByFile, config, issues);
168
216
  checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues);
169
217
  }
170
218
  return issues;
@@ -18,6 +18,14 @@ export declare function detectNewFilesInDiff(diffContent: string): Set<string>;
18
18
  * Returns a map of file path → array of added line contents (without the leading `+`).
19
19
  */
20
20
  export declare function extractAddedLinesPerFile(diffContent: string): Map<string, string[]>;
21
+ /**
22
+ * Extracts 1-based post-patch line numbers of added lines per file from a
23
+ * unified diff. Walking each hunk with a new-file line counter (`+` and
24
+ * context lines advance it, `-` lines do not) lets rules that need
25
+ * full-file context — e.g. comment masking across the context/added
26
+ * boundary (FORGE G4) — map the patch's additions onto the applied file.
27
+ */
28
+ export declare function extractAddedLineNumbersPerFile(diffContent: string): Map<string, number[]>;
21
29
  /**
22
30
  * Builds the `modifiedFileAdditions` map the cross-patch lint expects for
23
31
  * a given unified diff. Exposed so callers that construct synthetic /
@@ -45,6 +45,37 @@ export function extractAddedLinesPerFile(diffContent) {
45
45
  }
46
46
  return result;
47
47
  }
48
+ /**
49
+ * Extracts 1-based post-patch line numbers of added lines per file from a
50
+ * unified diff. Walking each hunk with a new-file line counter (`+` and
51
+ * context lines advance it, `-` lines do not) lets rules that need
52
+ * full-file context — e.g. comment masking across the context/added
53
+ * boundary (FORGE G4) — map the patch's additions onto the applied file.
54
+ */
55
+ export function extractAddedLineNumbersPerFile(diffContent) {
56
+ const result = new Map();
57
+ for (const section of parseDiffSections(diffContent)) {
58
+ for (const hunk of section.hunks) {
59
+ let newLine = hunk.newStart;
60
+ for (const line of hunk.lines) {
61
+ if (line.startsWith('\\'))
62
+ continue; // ""
63
+ if (line.startsWith('-'))
64
+ continue;
65
+ if (line.startsWith('+')) {
66
+ let arr = result.get(section.targetPath);
67
+ if (!arr) {
68
+ arr = [];
69
+ result.set(section.targetPath, arr);
70
+ }
71
+ arr.push(newLine);
72
+ }
73
+ newLine++;
74
+ }
75
+ }
76
+ }
77
+ return result;
78
+ }
48
79
  /**
49
80
  * Builds the `modifiedFileAdditions` map the cross-patch lint expects for
50
81
  * a given unified diff. Exposed so callers that construct synthetic /
@@ -41,3 +41,24 @@ export declare function resolvePatchOwnedSysMjs(currentNewFiles: Set<string>, pa
41
41
  * @returns Set of patch-owned chrome-subscript file paths
42
42
  */
43
43
  export declare function resolvePatchOwnedChromeScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
44
+ /**
45
+ * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
46
+ * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
47
+ * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
48
+ * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
49
+ * patch-lint.ts imports this module, so the reverse edge would create an
50
+ * import cycle (dpdm gate). The agreement is pinned by
51
+ * `patch-lint-ownership.test.ts`.
52
+ */
53
+ export declare function isTestScriptFile(file: string): boolean;
54
+ /**
55
+ * Returns the set of patch-owned test `.js` files (FORGE G5). Same
56
+ * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
57
+ * `patchLint.checkJsTestFiles` pass, which checks each as its own small
58
+ * script-scope program.
59
+ *
60
+ * @param currentNewFiles - Files newly created in the current diff
61
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
62
+ * @returns Set of patch-owned test-script file paths
63
+ */
64
+ export declare function resolvePatchOwnedTestScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
@@ -67,3 +67,33 @@ export function resolvePatchOwnedSysMjs(currentNewFiles, patchQueueCtx) {
67
67
  export function resolvePatchOwnedChromeScripts(currentNewFiles, patchQueueCtx) {
68
68
  return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.js') && !file.endsWith('.sys.mjs'));
69
69
  }
70
+ /**
71
+ * Test-script predicate for the `checkJsTestFiles` pass (FORGE G5):
72
+ * plain `.js` files (not `.sys.mjs` modules) that live under a `/test/`
73
+ * path or carry a `browser_` / `test_` / `xpcshell_` basename. Duplicates
74
+ * `patch-lint.ts`'s `isTestFile` shape rather than importing it —
75
+ * patch-lint.ts imports this module, so the reverse edge would create an
76
+ * import cycle (dpdm gate). The agreement is pinned by
77
+ * `patch-lint-ownership.test.ts`.
78
+ */
79
+ export function isTestScriptFile(file) {
80
+ if (!file.endsWith('.js') || file.endsWith('.sys.mjs'))
81
+ return false;
82
+ if (file.includes('/test/'))
83
+ return true;
84
+ const basename = file.split('/').pop() ?? '';
85
+ return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
86
+ }
87
+ /**
88
+ * Returns the set of patch-owned test `.js` files (FORGE G5). Same
89
+ * ownership semantics as {@link resolvePatchOwnedSysMjs}; consumed by the
90
+ * `patchLint.checkJsTestFiles` pass, which checks each as its own small
91
+ * script-scope program.
92
+ *
93
+ * @param currentNewFiles - Files newly created in the current diff
94
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
95
+ * @returns Set of patch-owned test-script file paths
96
+ */
97
+ export function resolvePatchOwnedTestScripts(currentNewFiles, patchQueueCtx) {
98
+ return resolveOwned(currentNewFiles, patchQueueCtx, isTestScriptFile);
99
+ }
@@ -2,5 +2,5 @@
2
2
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
3
3
  * orchestrator stays within the ESLint `max-lines` budget.
4
4
  */
5
- export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, type ForwardImportEdge, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
5
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
@@ -128,6 +128,21 @@ export type PatchSizeTierDecision = {
128
128
  * defensive-for-tests choice.
129
129
  */
130
130
  export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
131
+ /**
132
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
133
+ * Public-API companion to {@link resolvePatchSizeTier} /
134
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
135
+ * checks against the SAME numbers `large-patch-lines` /
136
+ * `large-patch-files` fire on instead of mirroring them.
137
+ */
138
+ export declare function getPatchSizeThresholds(tier: 'general' | 'test' | 'branding'): {
139
+ lines: {
140
+ notice: number;
141
+ warning: number;
142
+ error: number;
143
+ };
144
+ maxFiles: number;
145
+ };
131
146
  /**
132
147
  * Checks patch size and emits advisory warnings.
133
148
  *
@@ -179,6 +194,14 @@ export interface LintExportedPatchOptions {
179
194
  * attributes findings per patch instead of rebuilding per patch.
180
195
  */
181
196
  precomputedCheckJs?: readonly PatchLintIssue[];
197
+ /**
198
+ * Invoked with the issues DROPPED by the `ignoreChecks` waiver filter,
199
+ * when any were (FORGE G10). Suppression semantics are unchanged — the
200
+ * returned issue list still excludes them — but the caller can report
201
+ * the measurement a waived size finding carried (per-patch NOTICE lines
202
+ * and the `--report` JSON) instead of losing it entirely.
203
+ */
204
+ onSuppressed?: (suppressed: PatchLintIssue[]) => void;
182
205
  }
183
206
  /**
184
207
  * Runs all patch lint checks and returns combined issues.
@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { stripJsComments } from '../utils/regex.js';
5
5
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, hasUpstreamMplBlockHeader, } from './license-headers.js';
6
- import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
6
+ import { invokePatchLintCheckJs, runCheckJsTestFilesGrouped } from './patch-lint-checkjs.js';
7
7
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
8
  import { lintPatchedCss } from './patch-lint-css.js';
9
9
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
@@ -12,7 +12,7 @@ import { hasRelativeImport } from './patch-lint-imports.js';
12
12
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
13
13
  import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
14
14
  import { lintObserverTopics } from './patch-lint-observer.js';
15
- import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
15
+ import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
16
16
  // ---------------------------------------------------------------------------
17
17
  // Cross-patch lint re-exports
18
18
  // ---------------------------------------------------------------------------
@@ -481,6 +481,19 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
481
481
  return { tier: 'branding', source: 'auto' };
482
482
  return { tier: 'general' };
483
483
  }
484
+ /**
485
+ * Read-only view of the size thresholds a tier enforces (FORGE G9).
486
+ * Public-API companion to {@link resolvePatchSizeTier} /
487
+ * {@link countNonBinaryDiffLines} so consumers can build waiver-freshness
488
+ * checks against the SAME numbers `large-patch-lines` /
489
+ * `large-patch-files` fire on instead of mirroring them.
490
+ */
491
+ export function getPatchSizeThresholds(tier) {
492
+ return {
493
+ lines: { ...PATCH_LINE_THRESHOLDS[tier] },
494
+ maxFiles: PATCH_FILES_THRESHOLDS[tier],
495
+ };
496
+ }
484
497
  /**
485
498
  * Checks patch size and emits advisory warnings.
486
499
  *
@@ -644,6 +657,17 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
644
657
  }
645
658
  else if (config.patchLint?.checkJs) {
646
659
  issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir), options?.checkJsReportScope)));
660
+ // Export-time twin of per-patch lint's test-file pass (FORGE G5), so
661
+ // export/re-export and `lint --per-patch` agree on the checked surface.
662
+ if (config.patchLint.checkJsTestFiles === true) {
663
+ const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir));
664
+ issues.push(...testGrouped.global);
665
+ for (const [rel, list] of testGrouped.byFile) {
666
+ if (options?.checkJsReportScope && !options.checkJsReportScope.has(rel))
667
+ continue;
668
+ issues.push(...list);
669
+ }
670
+ }
647
671
  }
648
672
  // Filter out ignored checks last so every rule still runs (keeps the
649
673
  // implementation uniform) but suppressed rules do not surface. We do not
@@ -651,6 +675,10 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
651
675
  // inline `fireforge-ignore: <check>` markers work in the CSS and
652
676
  // forward-import rules.
653
677
  if (ignoreChecks && ignoreChecks.size > 0) {
678
+ const suppressed = issues.filter((issue) => ignoreChecks.has(issue.check));
679
+ if (suppressed.length > 0) {
680
+ options?.onSuppressed?.(suppressed);
681
+ }
654
682
  return issues.filter((issue) => !ignoreChecks.has(issue.check));
655
683
  }
656
684
  return issues;
@@ -5,7 +5,7 @@
5
5
  * is an implementation detail.
6
6
  */
7
7
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
8
- export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchManifestRowMutation, type PatchManifestRowMutationResult, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
8
+ export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
9
9
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
10
10
  export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
11
11
  export { validatePatchesManifest } from './patch-manifest-validate.js';
@@ -22,24 +22,40 @@ export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
22
22
  /**
23
23
  * Returns `true` when `line` matches any pattern in
24
24
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
25
- * lives in {@link matchesAllowlist}, so the smoke runner can count
25
+ * lives in {@link matchAllowlist}, so the smoke runner can count
26
26
  * allowlisted hits separately from raw error matches for its summary.
27
27
  */
28
28
  export declare function matchesSmokeError(line: string): boolean;
29
29
  /**
30
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
31
- * with an empty allowlist (always returns `false`).
30
+ * One compiled allowlist entry with its provenance retained so the smoke
31
+ * summary can attribute hits per entry (FORGE G8) — an entry that silently
32
+ * stops matching (its suppressed shape changed upstream) is only
33
+ * detectable when zero-hit entries are visible.
32
34
  */
33
- export declare function matchesAllowlist(line: string, allow: readonly RegExp[]): boolean;
35
+ export interface CompiledAllowlistEntry {
36
+ pattern: RegExp;
37
+ /** Verbatim source text of the entry. */
38
+ source: string;
39
+ /** Human-readable origin: `allow.txt:12` or `--console-allow #2`. */
40
+ origin: string;
41
+ }
42
+ /**
43
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
44
+ * First-match attribution is deterministic and cheap; a line matching
45
+ * several entries credits the earliest one. Safe to call with an empty
46
+ * allowlist (always returns -1).
47
+ */
48
+ export declare function matchAllowlist(line: string, allow: readonly CompiledAllowlistEntry[]): number;
34
49
  /**
35
50
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
36
51
  * lines and `#`-prefixed comments are skipped. Each remaining line is
37
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
38
- * fast at CLI parse time than to silently let a typo match nothing.
52
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
53
+ * pattern throws immediately — better to fail fast at CLI parse time than
54
+ * to silently let a typo match nothing.
39
55
  */
40
- export declare function compileAllowlistFromFile(body: string, sourcePath: string): RegExp[];
56
+ export declare function compileAllowlistFromFile(body: string, sourcePath: string): CompiledAllowlistEntry[];
41
57
  /**
42
58
  * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
43
59
  * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
44
60
  */
45
- export declare function compileAllowlistFromStrings(sources: readonly string[]): RegExp[];
61
+ export declare function compileAllowlistFromStrings(sources: readonly string[]): CompiledAllowlistEntry[];
@@ -32,7 +32,7 @@ export const SMOKE_ERROR_PATTERNS = [
32
32
  /**
33
33
  * Returns `true` when `line` matches any pattern in
34
34
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
35
- * lives in {@link matchesAllowlist}, so the smoke runner can count
35
+ * lives in {@link matchAllowlist}, so the smoke runner can count
36
36
  * allowlisted hits separately from raw error matches for its summary.
37
37
  */
38
38
  export function matchesSmokeError(line) {
@@ -44,22 +44,25 @@ export function matchesSmokeError(line) {
44
44
  return false;
45
45
  }
46
46
  /**
47
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
48
- * with an empty allowlist (always returns `false`).
47
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
48
+ * First-match attribution is deterministic and cheap; a line matching
49
+ * several entries credits the earliest one. Safe to call with an empty
50
+ * allowlist (always returns -1).
49
51
  */
50
- export function matchesAllowlist(line, allow) {
51
- for (const pattern of allow) {
52
- if (pattern.test(line)) {
53
- return true;
52
+ export function matchAllowlist(line, allow) {
53
+ for (let i = 0; i < allow.length; i++) {
54
+ if (allow[i]?.pattern.test(line)) {
55
+ return i;
54
56
  }
55
57
  }
56
- return false;
58
+ return -1;
57
59
  }
58
60
  /**
59
61
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
60
62
  * lines and `#`-prefixed comments are skipped. Each remaining line is
61
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
62
- * fast at CLI parse time than to silently let a typo match nothing.
63
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
64
+ * pattern throws immediately — better to fail fast at CLI parse time than
65
+ * to silently let a typo match nothing.
63
66
  */
64
67
  export function compileAllowlistFromFile(body, sourcePath) {
65
68
  const lines = body.split(/\r?\n/);
@@ -69,7 +72,11 @@ export function compileAllowlistFromFile(body, sourcePath) {
69
72
  if (!line || line.startsWith('#'))
70
73
  return;
71
74
  try {
72
- compiled.push(new RegExp(line));
75
+ compiled.push({
76
+ pattern: new RegExp(line),
77
+ source: line,
78
+ origin: `${sourcePath}:${String(index + 1)}`,
79
+ });
73
80
  }
74
81
  catch (error) {
75
82
  const message = error instanceof Error ? error.message : String(error);
@@ -88,7 +95,11 @@ export function compileAllowlistFromStrings(sources) {
88
95
  const compiled = [];
89
96
  sources.forEach((source, index) => {
90
97
  try {
91
- compiled.push(new RegExp(source));
98
+ compiled.push({
99
+ pattern: new RegExp(source),
100
+ source,
101
+ origin: `--console-allow #${String(index + 1)}`,
102
+ });
92
103
  }
93
104
  catch (error) {
94
105
  const message = error instanceof Error ? error.message : String(error);
@@ -49,6 +49,14 @@ export interface ClassifiedFile extends StatusFile {
49
49
  * single claim is fully captured by the classification itself.
50
50
  */
51
51
  claimedBy?: string[];
52
+ /**
53
+ * Owning patch filename when exactly one patch claims this path
54
+ * (patch-backed, patch-owned-drift, and single-owner furnace entries).
55
+ * Unset for unowned, branding-generated, unowned-furnace, and conflict
56
+ * entries — conflicts carry `claimedBy` instead. Exposed through
57
+ * `status --json` as the `patch` field (FORGE G11).
58
+ */
59
+ owner?: string;
52
60
  }
53
61
  /**
54
62
  * Builds the file → owning-patch-filenames multimap from manifest rows.
@@ -66,6 +74,14 @@ export declare function buildPatchClaims(manifestPatches: ReadonlyArray<{
66
74
  * Single source of truth — status-output.ts renders from the same logic.
67
75
  */
68
76
  export declare function getPrimaryStatusCode(status: string): string;
77
+ /**
78
+ * True for the branding paths whose content FireForge generates itself
79
+ * (configure.sh, brand.properties/ftl, browser/moz.configure). Unlike the
80
+ * broader branding-root test, a brand-new unowned file under the branding
81
+ * directory is NOT generated — it is a patch candidate and must stay
82
+ * visible as unmanaged (the Assets.car precedent).
83
+ */
84
+ export declare function isGeneratedBrandingPath(file: string, binaryName: string): boolean;
69
85
  /**
70
86
  * Classifies files into patch-backed, unmanaged, branding, furnace, or
71
87
  * conflict buckets.