@hominis/fireforge 0.30.0 → 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 +26 -1
  2. package/README.md +22 -5
  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 +119 -78
  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 +3 -13
  94. package/dist/src/core/lint-cache.js +11 -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
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { loadConfig } from '../core/config.js';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
- import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
5
+ import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
6
6
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
8
8
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
@@ -13,91 +13,75 @@ function buildPerPatchMaxWarningsMessage(count, maxWarnings, linted) {
13
13
  return (`Patch lint found ${count} warning(s) across ${linted} patch(es), exceeding --max-warnings ${maxWarnings}.` +
14
14
  ' If this is a release gate, run with --per-patch to identify the owning patch. For intentional staged imports, use patch staged-dependency; for ownership repairs, preview patch move-files, patch reorder --dry-run, or re-export --files --dry-run; add scoped lintIgnore only after review.');
15
15
  }
16
+ function emitTierNotice(filename, files, tier) {
17
+ const decision = resolvePatchSizeTier(files, tier);
18
+ if (decision.tier !== 'branding')
19
+ return;
20
+ info(decision.source === 'explicit'
21
+ ? `${filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
22
+ : `${filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
23
+ }
16
24
  /**
17
- * Lints each patch in the queue as its own isolated diff, honouring
18
- * per-patch `lintIgnore` entries. Cross-patch rules still run once over
19
- * the whole queue so queue-level findings are not lost by the rescoping.
25
+ * Lints one queued patch against its own isolated diff, reusing the cache
26
+ * entry when the cache key matches. Pushes the patch's issues (prefixed
27
+ * with its filename) onto `ctx.issues`. Returns whether the patch was
28
+ * skipped (no files present / empty diff), served from cache, or linted
29
+ * fresh — and whether a fresh result was written to the cache.
20
30
  */
21
- export async function lintPerPatch(projectRoot, paths, options = {}) {
22
- const manifest = await loadPatchesManifest(paths.patches);
23
- if (!manifest || manifest.patches.length === 0) {
24
- info('No patches in manifest — nothing to lint per-patch.');
25
- outro('Nothing to lint');
26
- return;
31
+ async function lintQueuedPatch(patch, lintCtx) {
32
+ const { projectRoot, paths, config, ctx, cache, engineHeadSha, issues } = lintCtx;
33
+ const existing = [];
34
+ for (const f of patch.filesAffected) {
35
+ if (await pathExists(join(paths.engine, f)))
36
+ existing.push(f);
27
37
  }
28
- const config = await loadConfig(projectRoot);
29
- const ctx = await buildPatchQueueContext(paths.patches);
30
- const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
31
- let cacheDirty = false;
32
- let reusedCacheEntries = 0;
33
- const issues = [];
34
- for (const issue of evaluatePatchPolicy(config, manifest)) {
35
- issues.push({
36
- file: issue.filename,
37
- check: `patch-policy/${issue.code}`,
38
- message: issue.message,
39
- severity: issue.severity,
40
- });
38
+ if (existing.length === 0) {
39
+ return { status: 'skipped', wroteCache: false };
41
40
  }
42
- let linted = 0;
43
- let skipped = 0;
44
- for (const patch of manifest.patches) {
45
- const existing = [];
46
- for (const f of patch.filesAffected) {
47
- if (await pathExists(join(paths.engine, f)))
48
- existing.push(f);
49
- }
50
- if (existing.length === 0) {
51
- skipped++;
52
- continue;
53
- }
54
- const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
55
- if (!diff.trim()) {
56
- skipped++;
57
- continue;
58
- }
59
- const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
60
- const decision = resolvePatchSizeTier(existing, patch.tier);
61
- if (decision.tier === 'branding') {
62
- info(decision.source === 'explicit'
63
- ? `${patch.filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
64
- : `${patch.filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
65
- }
66
- let patchIssues;
67
- if (cache) {
68
- const cacheKey = await buildPerPatchLintCacheKey({
69
- projectRoot,
70
- engineDir: paths.engine,
71
- patchesDir: paths.patches,
72
- patch,
73
- existingFiles: existing,
74
- config,
75
- queueContext: ctx,
76
- });
77
- patchIssues = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
78
- if (patchIssues) {
79
- reusedCacheEntries++;
80
- }
81
- else {
82
- patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
83
- setCachedPerPatchLintIssues(cache, patch.filename, cacheKey, patchIssues);
84
- cacheDirty = true;
41
+ const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
42
+ let cacheKey;
43
+ if (cache) {
44
+ cacheKey = await buildPerPatchLintCacheKey({
45
+ projectRoot,
46
+ engineDir: paths.engine,
47
+ patchesDir: paths.patches,
48
+ patch,
49
+ existingFiles: existing,
50
+ config,
51
+ queueContext: ctx,
52
+ ...(engineHeadSha === undefined ? {} : { engineHeadSha }),
53
+ });
54
+ const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
55
+ if (cached) {
56
+ emitTierNotice(patch.filename, existing, patch.tier);
57
+ for (const issue of cached) {
58
+ issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
85
59
  }
60
+ return { status: 'cached', wroteCache: false };
86
61
  }
87
- else {
88
- patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
89
- }
90
- for (const issue of patchIssues) {
91
- issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
92
- }
93
- linted++;
94
62
  }
95
- issues.push(...lintPatchQueue(ctx));
96
- if (cache && cacheDirty)
97
- await savePerPatchLintCache(projectRoot, cache);
98
- if (reusedCacheEntries > 0) {
99
- info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
63
+ const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
64
+ if (!diff.trim()) {
65
+ return { status: 'skipped', wroteCache: false };
66
+ }
67
+ emitTierNotice(patch.filename, existing, patch.tier);
68
+ const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
69
+ let wroteCache = false;
70
+ if (cache && cacheKey) {
71
+ setCachedPerPatchLintIssues(cache, patch.filename, cacheKey, patchIssues);
72
+ wroteCache = true;
100
73
  }
74
+ for (const issue of patchIssues) {
75
+ issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
76
+ }
77
+ return { status: 'linted', wroteCache };
78
+ }
79
+ /**
80
+ * Reporting + exit phase of per-patch lint: renders every issue row,
81
+ * prints the per-patch summary, and applies the failure criteria
82
+ * (errors, `--max-warnings`) by throwing GeneralError.
83
+ */
84
+ function reportPerPatchOutcome(issues, linted, skipped, options) {
101
85
  if (issues.length === 0) {
102
86
  if (linted === 0 && skipped > 0) {
103
87
  info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
@@ -137,4 +121,61 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
137
121
  outro('Lint passed');
138
122
  }
139
123
  }
124
+ /**
125
+ * Lints each patch in the queue as its own isolated diff, honouring
126
+ * per-patch `lintIgnore` entries. Cross-patch rules still run once over
127
+ * the whole queue so queue-level findings are not lost by the rescoping.
128
+ */
129
+ export async function lintPerPatch(projectRoot, paths, options = {}) {
130
+ const manifest = await loadPatchesManifest(paths.patches);
131
+ if (!manifest || manifest.patches.length === 0) {
132
+ info('No patches in manifest — nothing to lint per-patch.');
133
+ outro('Nothing to lint');
134
+ return;
135
+ }
136
+ const config = await loadConfig(projectRoot);
137
+ const ctx = await buildPatchQueueContext(paths.patches);
138
+ const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
139
+ const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
140
+ const issues = [];
141
+ for (const issue of evaluatePatchPolicy(config, manifest)) {
142
+ issues.push({
143
+ file: issue.filename,
144
+ check: `patch-policy/${issue.code}`,
145
+ message: issue.message,
146
+ severity: issue.severity,
147
+ });
148
+ }
149
+ let linted = 0;
150
+ let skipped = 0;
151
+ let cacheDirty = false;
152
+ let reusedCacheEntries = 0;
153
+ for (const patch of manifest.patches) {
154
+ const result = await lintQueuedPatch(patch, {
155
+ projectRoot,
156
+ paths,
157
+ config,
158
+ ctx,
159
+ cache,
160
+ engineHeadSha,
161
+ issues,
162
+ });
163
+ if (result.status === 'skipped') {
164
+ skipped++;
165
+ continue;
166
+ }
167
+ if (result.status === 'cached')
168
+ reusedCacheEntries++;
169
+ if (result.wroteCache)
170
+ cacheDirty = true;
171
+ linted++;
172
+ }
173
+ issues.push(...lintPatchQueue(ctx));
174
+ if (cache && cacheDirty)
175
+ await savePerPatchLintCache(projectRoot, cache);
176
+ if (reusedCacheEntries > 0) {
177
+ info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
178
+ }
179
+ reportPerPatchOutcome(issues, linted, skipped, options);
180
+ }
140
181
  //# sourceMappingURL=lint-per-patch.js.map
@@ -1,63 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
- import type { PatchLintIssue } from '../types/commands/index.js';
4
- /** Options controlling how the lint command filters and tags its output. */
5
- export interface LintCommandOptions {
6
- /**
7
- * When set, tag each issue as `introduced` or `cumulative` based on
8
- * whether its file changed since this git revision (e.g. `HEAD`, a
9
- * branch name, or a SHA). Issues are not filtered — the full set still
10
- * prints — but a diff-scoped summary makes it trivial to see which
11
- * errors the current task introduced.
12
- */
13
- since?: string;
14
- /**
15
- * When set together with {@link since}, scope the exit code to issues
16
- * tagged `introduced`. Cumulative pre-existing errors still print (so
17
- * the operator can still see the full queue state) but do not fail
18
- * lint. Motivating case: a branch whose diff is clean but whose repo
19
- * already carries unrelated `raw-color` / license-header errors from
20
- * older patches. Without this flag, CI treats the clean branch as
21
- * failing; with it, a branch "breaks the build" only when its own diff
22
- * introduced a new error.
23
- *
24
- * Requires {@link since}: without a revision to diff against there is
25
- * no distinction between introduced and cumulative, so the flag is
26
- * rejected up-front rather than silently ignored.
27
- */
28
- onlyIntroduced?: boolean;
29
- /**
30
- * Lint each patch in the queue as its own isolated diff, rather than
31
- * the aggregate `git diff HEAD` across all applied patches.
32
- *
33
- * Motivating case: running `fireforge lint` (no args) on a repo where
34
- * `fireforge import` or `fireforge rebase` has just applied the full
35
- * patch queue produces an aggregate diff (every patch's changes
36
- * summed). The patch-size advisory rules (`large-patch-lines`,
37
- * `large-patch-files`) then fire against the sum — e.g. "Patch is
38
- * 37529 lines" on a queue of 22 individually-fine patches — which
39
- * reads as a task-specific regression when it is really an artefact
40
- * of the aggregation. `--per-patch` rescopes the diff to each patch's
41
- * own `filesAffected`, honours the patch's own `lintIgnore`, and runs
42
- * the cross-patch rules once over the whole queue so queue-level
43
- * findings (duplicate creations, forward imports) still surface.
44
- *
45
- * Mutually exclusive with passing explicit file paths — the two
46
- * scope contracts are different.
47
- */
48
- perPatch?: boolean;
49
- /**
50
- * Maximum warning count tolerated before lint exits non-zero. Mirrors
51
- * ESLint's `--max-warnings` shape for release gates that want advisory
52
- * findings to become blocking without changing default CLI behavior.
53
- */
54
- maxWarnings?: number;
55
- /**
56
- * Bypass per-patch lint cache reads and writes. Accepted in aggregate mode
57
- * for CLI consistency, but only `--per-patch` currently uses the cache.
58
- */
59
- noCache?: boolean;
60
- }
3
+ import type { LintCommandOptions, PatchLintIssue } from '../types/commands/index.js';
61
4
  /**
62
5
  * Result of {@link applyAggregateLintIgnoreSuppression}.
63
6
  */
@@ -215,13 +215,12 @@ export function applyAggregateLintIgnoreSuppression(issues, ctx) {
215
215
  return { issues: filtered, dropped: issues.length - filtered.length };
216
216
  }
217
217
  /**
218
- * Runs the lint command to check engine changes against patch quality rules.
219
- * @param projectRoot - Root directory of the project
220
- * @param files - Optional file/directory paths to lint (relative to engine/)
221
- * @param options - Additional lint options such as `--since` diff-scoping
218
+ * Up-front flag validation for `lintCommand`: rejects `--only-introduced`
219
+ * without `--since`, non-integer `--max-warnings`, and `--per-patch`
220
+ * combined with explicit file paths each a misconfiguration that should
221
+ * fail loud rather than silently narrow the result.
222
222
  */
223
- export async function lintCommand(projectRoot, files, options = {}) {
224
- intro('FireForge Lint');
223
+ function validateLintFlags(options, files) {
225
224
  // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
226
225
  // without a revision to anchor the diff there is no "introduced" subset
227
226
  // to scope to — reject the combination up-front so a misconfigured CI
@@ -242,78 +241,16 @@ export async function lintCommand(projectRoot, files, options = {}) {
242
241
  if (options.perPatch && files.length > 0) {
243
242
  throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
244
243
  }
245
- const paths = getProjectPaths(projectRoot);
246
- if (!(await pathExists(paths.engine))) {
247
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
248
- }
249
- if (!(await isGitRepository(paths.engine))) {
250
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
251
- }
252
- if (options.perPatch) {
253
- await lintPerPatch(projectRoot, paths, options);
254
- return;
255
- }
256
- // Load the config before resolving the diff so we can pass
257
- // `binaryName` into the aggregate-mode branding exclusion in
258
- // `resolveLintDiff`. The config was previously loaded only after
259
- // the diff was resolved; hoisting it is cheap and keeps the two
260
- // call sites close together.
261
- const config = await loadConfig(projectRoot);
262
- // Pull the Furnace-managed prefix set up-front so aggregate lint can
263
- // mirror the branding exclusion for Furnace material — without it,
264
- // preview-generated stories under `browser/components/storybook/
265
- // stories/furnace/` show up as license-header errors on every
266
- // post-preview lint run.
267
- const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
268
- const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
269
- if (diff === null)
270
- return;
271
- const filesAffected = extractAffectedFiles(diff);
272
- // Build patch queue context once so it can be shared between the
273
- // per-patch ownership resolver and the cross-patch rules.
274
- let ctx;
275
- if (await pathExists(paths.patches)) {
276
- ctx = await buildPatchQueueContext(paths.patches);
277
- }
278
- let issues = [
279
- ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
280
- ];
281
- // Cross-patch rules operate over the whole queue, so run them whenever a
282
- // patches directory exists — they surface duplicate /dev/null creations
283
- // and forward-import chains that the per-patch orchestrator cannot see.
284
- if (ctx) {
285
- issues.push(...lintPatchQueue(ctx));
286
- }
287
- // Honor per-patch `lintIgnore` in aggregate mode by attributing each
288
- // issue's file to its owning patches via the manifest's
289
- // `filesAffected`. Per-patch mode threads `lintIgnore` directly into
290
- // `lintExportedPatch`; aggregate mode previously had no patch-level
291
- // scope to consult, so a check an operator had explicitly waived in
292
- // `patches.json` re-surfaced on every `--since` run (CI default).
293
- if (ctx) {
294
- const result = applyAggregateLintIgnoreSuppression(issues, ctx);
295
- issues = result.issues;
296
- if (result.dropped > 0) {
297
- info(`Suppressed ${result.dropped} issue(s) via per-patch lintIgnore (aggregate mode).`);
298
- }
299
- }
300
- // When a queue manifest exists AND files were NOT scoped explicitly, the
301
- // "diff" we just linted is every applied patch summed together. Patch-
302
- // size rules (`large-patch-lines`, `large-patch-files`) then fire against
303
- // the aggregate rather than any individual patch, producing counts like
304
- // "Patch is 37529 lines" that read as a task-specific regression but are
305
- // really an artefact of aggregation. Surface a one-line note pointing at
306
- // `--per-patch` so the operator knows the per-patch scope exists before
307
- // they read the error message as "my queue is broken".
308
- //
309
- // In aggregate mode over a multi-patch queue we also downgrade the two
310
- // size rules from `error` to `warning`. Before this downgrade, a
311
- // fresh-imported patch stack of 20+ patches hard-failed `fireforge lint`
312
- // on lines-per-aggregate counts that are mathematically impossible to
313
- // satisfy without splitting patches that were already split — the
314
- // actionable unit is the individual patch, and `--per-patch` is the
315
- // mode that matches. Per-patch mode keeps errors as errors (see
316
- // `lintPerPatch` below).
244
+ }
245
+ /**
246
+ * Aggregate-mode patch-size softening: when the linted diff is every
247
+ * applied patch summed (no explicit file scope, multi-patch queue), the
248
+ * `large-patch-lines` / `large-patch-files` counts are an artefact of
249
+ * aggregation rather than a property of any one patch. Surface the
250
+ * `--per-patch` hint and downgrade those two rules to warnings; per-patch
251
+ * mode keeps them as errors.
252
+ */
253
+ function downgradeAggregateSizeRules(issues, files, ctx) {
317
254
  const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
318
255
  if (aggregateHintApplicable &&
319
256
  issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
@@ -325,18 +262,21 @@ export async function lintCommand(projectRoot, files, options = {}) {
325
262
  }
326
263
  }
327
264
  }
328
- if (issues.length === 0) {
329
- success('No lint issues found.');
330
- outro('Lint passed');
331
- return;
332
- }
265
+ }
266
+ /**
267
+ * Reporting + exit phase of `lintCommand`: tags issues against `--since`,
268
+ * renders every notice/warning/error row, prints the summary, and applies
269
+ * the failure criteria (`--only-introduced` scoping, `--max-warnings`)
270
+ * by throwing GeneralError. Issues must be non-empty.
271
+ */
272
+ async function reportLintOutcome(engineDir, issues, options) {
333
273
  // Diff-scoping: tag each issue as introduced-in-current-task vs
334
274
  // cumulative-pre-existing-drift. Never filters — full set still prints
335
275
  // and exit code semantics are unchanged — but the per-line prefix and
336
276
  // summary make triage trivial on a large patch series.
337
277
  const sinceActive = Boolean(options.since);
338
278
  if (options.since) {
339
- const diffFiles = await collectDiffFilePaths(paths.engine, options.since);
279
+ const diffFiles = await collectDiffFilePaths(engineDir, options.since);
340
280
  tagLintIssues(issues, diffFiles);
341
281
  }
342
282
  const errors = issues.filter((i) => i.severity === 'error');
@@ -399,6 +339,78 @@ export async function lintCommand(projectRoot, files, options = {}) {
399
339
  outro('Lint passed');
400
340
  }
401
341
  }
342
+ /**
343
+ * Runs the lint command to check engine changes against patch quality rules.
344
+ * @param projectRoot - Root directory of the project
345
+ * @param files - Optional file/directory paths to lint (relative to engine/)
346
+ * @param options - Additional lint options such as `--since` diff-scoping
347
+ */
348
+ export async function lintCommand(projectRoot, files, options = {}) {
349
+ intro('FireForge Lint');
350
+ validateLintFlags(options, files);
351
+ const paths = getProjectPaths(projectRoot);
352
+ if (!(await pathExists(paths.engine))) {
353
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
354
+ }
355
+ if (!(await isGitRepository(paths.engine))) {
356
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
357
+ }
358
+ if (options.perPatch) {
359
+ await lintPerPatch(projectRoot, paths, options);
360
+ return;
361
+ }
362
+ // Load the config before resolving the diff so we can pass
363
+ // `binaryName` into the aggregate-mode branding exclusion in
364
+ // `resolveLintDiff`. The config was previously loaded only after
365
+ // the diff was resolved; hoisting it is cheap and keeps the two
366
+ // call sites close together.
367
+ const config = await loadConfig(projectRoot);
368
+ // Pull the Furnace-managed prefix set up-front so aggregate lint can
369
+ // mirror the branding exclusion for Furnace material — without it,
370
+ // preview-generated stories under `browser/components/storybook/
371
+ // stories/furnace/` show up as license-header errors on every
372
+ // post-preview lint run.
373
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
374
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
375
+ if (diff === null)
376
+ return;
377
+ const filesAffected = extractAffectedFiles(diff);
378
+ // Build patch queue context once so it can be shared between the
379
+ // per-patch ownership resolver and the cross-patch rules.
380
+ let ctx;
381
+ if (await pathExists(paths.patches)) {
382
+ ctx = await buildPatchQueueContext(paths.patches);
383
+ }
384
+ let issues = [
385
+ ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
386
+ ];
387
+ // Cross-patch rules operate over the whole queue, so run them whenever a
388
+ // patches directory exists — they surface duplicate /dev/null creations
389
+ // and forward-import chains that the per-patch orchestrator cannot see.
390
+ if (ctx) {
391
+ issues.push(...lintPatchQueue(ctx));
392
+ }
393
+ // Honor per-patch `lintIgnore` in aggregate mode by attributing each
394
+ // issue's file to its owning patches via the manifest's
395
+ // `filesAffected`. Per-patch mode threads `lintIgnore` directly into
396
+ // `lintExportedPatch`; aggregate mode previously had no patch-level
397
+ // scope to consult, so a check an operator had explicitly waived in
398
+ // `patches.json` re-surfaced on every `--since` run (CI default).
399
+ if (ctx) {
400
+ const result = applyAggregateLintIgnoreSuppression(issues, ctx);
401
+ issues = result.issues;
402
+ if (result.dropped > 0) {
403
+ info(`Suppressed ${result.dropped} issue(s) via per-patch lintIgnore (aggregate mode).`);
404
+ }
405
+ }
406
+ downgradeAggregateSizeRules(issues, files, ctx);
407
+ if (issues.length === 0) {
408
+ success('No lint issues found.');
409
+ outro('Lint passed');
410
+ return;
411
+ }
412
+ await reportLintOutcome(paths.engine, issues, options);
413
+ }
402
414
  /** Registers the lint command on the CLI program. */
403
415
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
404
416
  const lint = program
@@ -2,8 +2,11 @@
2
2
  * `fireforge patch compact` — closes ordinal gaps in the patch queue.
3
3
  *
4
4
  * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
5
- * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
6
- * in a single atomic operation, preserving relative order.
5
+ * This command renumbers patches to close those gaps in a single atomic
6
+ * operation, preserving relative order. Without a patch policy the whole
7
+ * queue is renumbered from 1; with `patchPolicy.ranges` configured the
8
+ * compaction is range-aware (each category range compacts independently,
9
+ * reserved ranges and out-of-range strays are left untouched).
7
10
  */
8
11
  import { Command } from 'commander';
9
12
  import type { CommandContext } from '../../types/cli.js';