@hominis/fireforge 0.38.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 (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/build.js +8 -3
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/export-flow.d.ts +1 -1
  7. package/dist/src/commands/export-flow.js +21 -10
  8. package/dist/src/commands/export-placement-gate.js +1 -1
  9. package/dist/src/commands/export-shared.js +31 -5
  10. package/dist/src/commands/import.js +5 -0
  11. package/dist/src/commands/lint-per-patch.js +97 -18
  12. package/dist/src/commands/lint-report.d.ts +21 -0
  13. package/dist/src/commands/lint-report.js +62 -0
  14. package/dist/src/commands/lint.js +23 -19
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/patch/lint-ignore.js +20 -1
  17. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  18. package/dist/src/commands/patch/move-files-into.js +245 -0
  19. package/dist/src/commands/patch/move-files.js +27 -13
  20. package/dist/src/commands/patch/rename.js +5 -2
  21. package/dist/src/commands/patch/staged-dependency.js +102 -14
  22. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  23. package/dist/src/commands/re-export-adjacent.js +106 -0
  24. package/dist/src/commands/re-export-options.js +3 -0
  25. package/dist/src/commands/re-export.js +42 -53
  26. package/dist/src/commands/reset.js +5 -0
  27. package/dist/src/commands/run.js +18 -2
  28. package/dist/src/commands/status-check.d.ts +35 -0
  29. package/dist/src/commands/status-check.js +88 -0
  30. package/dist/src/commands/status-output.d.ts +9 -0
  31. package/dist/src/commands/status-output.js +33 -1
  32. package/dist/src/commands/status.js +33 -8
  33. package/dist/src/commands/test-diagnose.d.ts +1 -1
  34. package/dist/src/commands/test-diagnose.js +9 -3
  35. package/dist/src/commands/test-modes.d.ts +21 -0
  36. package/dist/src/commands/test-modes.js +42 -0
  37. package/dist/src/commands/test-run.d.ts +2 -0
  38. package/dist/src/commands/test-run.js +26 -5
  39. package/dist/src/commands/test.js +38 -32
  40. package/dist/src/commands/token.js +4 -1
  41. package/dist/src/commands/tree.d.ts +16 -0
  42. package/dist/src/commands/tree.js +174 -0
  43. package/dist/src/core/build-baseline-types.d.ts +7 -0
  44. package/dist/src/core/build-baseline.d.ts +1 -1
  45. package/dist/src/core/build-baseline.js +2 -1
  46. package/dist/src/core/build-prepare.d.ts +9 -0
  47. package/dist/src/core/build-prepare.js +24 -0
  48. package/dist/src/core/config-validate.js +25 -0
  49. package/dist/src/core/discard-baseline.d.ts +60 -0
  50. package/dist/src/core/discard-baseline.js +232 -0
  51. package/dist/src/core/license-headers.d.ts +12 -0
  52. package/dist/src/core/license-headers.js +27 -0
  53. package/dist/src/core/lint-cache.d.ts +15 -5
  54. package/dist/src/core/lint-cache.js +17 -5
  55. package/dist/src/core/patch-export.d.ts +12 -3
  56. package/dist/src/core/patch-export.js +13 -3
  57. package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
  58. package/dist/src/core/patch-lint-checkjs.js +85 -16
  59. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  60. package/dist/src/core/patch-lint-cross.js +69 -9
  61. package/dist/src/core/patch-lint-css.js +64 -16
  62. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  63. package/dist/src/core/patch-lint-diff.js +31 -0
  64. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  65. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  66. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  67. package/dist/src/core/patch-lint-ownership.js +30 -0
  68. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  69. package/dist/src/core/patch-lint.d.ts +23 -0
  70. package/dist/src/core/patch-lint.js +34 -3
  71. package/dist/src/core/patch-manifest.d.ts +1 -1
  72. package/dist/src/core/patch-policy.d.ts +14 -1
  73. package/dist/src/core/patch-policy.js +13 -2
  74. package/dist/src/core/smoke-patterns.d.ts +24 -8
  75. package/dist/src/core/smoke-patterns.js +23 -12
  76. package/dist/src/core/status-classify.d.ts +16 -0
  77. package/dist/src/core/status-classify.js +20 -6
  78. package/dist/src/core/test-harness-crash.d.ts +10 -0
  79. package/dist/src/core/test-harness-crash.js +16 -0
  80. package/dist/src/core/test-stale-check.d.ts +15 -0
  81. package/dist/src/core/test-stale-check.js +37 -1
  82. package/dist/src/core/token-category.d.ts +40 -0
  83. package/dist/src/core/token-category.js +264 -0
  84. package/dist/src/core/token-manager.d.ts +7 -0
  85. package/dist/src/core/token-manager.js +82 -193
  86. package/dist/src/core/token-variant.d.ts +7 -0
  87. package/dist/src/core/token-variant.js +9 -0
  88. package/dist/src/core/tree-cow.d.ts +18 -0
  89. package/dist/src/core/tree-cow.js +71 -0
  90. package/dist/src/core/tree-guard.d.ts +35 -0
  91. package/dist/src/core/tree-guard.js +124 -0
  92. package/dist/src/core/tree-store.d.ts +53 -0
  93. package/dist/src/core/tree-store.js +214 -0
  94. package/dist/src/core/typecheck-shim.d.ts +27 -0
  95. package/dist/src/core/typecheck-shim.js +58 -4
  96. package/dist/src/core/typecheck.js +14 -3
  97. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  98. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  99. package/dist/src/errors/git.d.ts +8 -0
  100. package/dist/src/errors/git.js +17 -0
  101. package/dist/src/index.d.ts +2 -0
  102. package/dist/src/index.js +1 -0
  103. package/dist/src/types/commands/options.d.ts +45 -2
  104. package/dist/src/types/config.d.ts +34 -0
  105. package/dist/src/utils/validation.d.ts +10 -0
  106. package/dist/src/utils/validation.js +29 -0
  107. package/package.json +6 -6
@@ -3,14 +3,15 @@ import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
5
  import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
6
- import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
- import { invokePatchLintCheckJsGrouped, } from '../core/patch-lint-checkjs.js';
8
- import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
6
+ import { buildPatchQueueContext, countNonBinaryDiffLines, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
+ import { invokePatchLintCheckJsGrouped, runCheckJsTestFilesGrouped, } from '../core/patch-lint-checkjs.js';
8
+ import { isTestScriptFile, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from '../core/patch-lint-ownership.js';
9
9
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
10
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
11
11
  import { GeneralError } from '../errors/base.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
13
  import { info, outro, success, warn } from '../utils/logger.js';
14
+ import { writePerPatchLintReport } from './lint-report.js';
14
15
  function buildPerPatchMaxWarningsMessage(count, maxWarnings, linted) {
15
16
  return (`Patch lint found ${count} warning(s) across ${linted} patch(es), exceeding --max-warnings ${maxWarnings}.` +
16
17
  ' 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.');
@@ -39,7 +40,14 @@ async function lintQueuedPatch(patch, lintCtx) {
39
40
  existing.push(f);
40
41
  }
41
42
  if (existing.length === 0) {
42
- return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
43
+ return {
44
+ status: 'skipped',
45
+ existingFiles: [],
46
+ rawIssues: [],
47
+ suppressedIssues: [],
48
+ lineCount: 0,
49
+ usedCheckJs: false,
50
+ };
43
51
  }
44
52
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
45
53
  let cacheKey;
@@ -56,16 +64,38 @@ async function lintQueuedPatch(patch, lintCtx) {
56
64
  });
57
65
  const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
58
66
  if (cached) {
59
- return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
67
+ // Returning before the empty-diff probe below is safe: the cache key
68
+ // hashes every affected engine file's content plus engineHeadSha, so
69
+ // an engine-side revert that would empty the diff always changes the
70
+ // key and misses the cache (pinned by the "engine-side content
71
+ // revert invalidates the per-patch cache" test — FORGE F5).
72
+ return {
73
+ status: 'cached',
74
+ existingFiles: existing,
75
+ rawIssues: cached.issues,
76
+ suppressedIssues: cached.suppressed,
77
+ lineCount: cached.lineCount,
78
+ usedCheckJs: false,
79
+ };
60
80
  }
61
81
  }
62
82
  const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
63
83
  if (!diff.trim()) {
64
- return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
84
+ return {
85
+ status: 'skipped',
86
+ existingFiles: [],
87
+ rawIssues: [],
88
+ suppressedIssues: [],
89
+ lineCount: 0,
90
+ usedCheckJs: false,
91
+ };
65
92
  }
66
93
  // checkJs: instead of rebuilding the program per patch, slice this patch's
67
94
  // findings out of the one queue-wide program (built lazily on first miss).
68
- let lintOptions;
95
+ const suppressedIssues = [];
96
+ const lintOptions = {
97
+ onSuppressed: (suppressed) => suppressedIssues.push(...suppressed),
98
+ };
69
99
  let usedCheckJs = false;
70
100
  if (lintCtx.checkJs) {
71
101
  const grouped = await lintCtx.checkJs.getGrouped();
@@ -76,20 +106,28 @@ async function lintQueuedPatch(patch, lintCtx) {
76
106
  for (const rel of owned)
77
107
  precomputedCheckJs.push(...(grouped.byFile.get(rel) ?? []));
78
108
  }
79
- lintOptions = { precomputedCheckJs };
109
+ lintOptions.precomputedCheckJs = precomputedCheckJs;
80
110
  }
81
111
  const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier, lintOptions);
82
112
  const result = {
83
113
  status: 'linted',
84
114
  existingFiles: existing,
85
115
  rawIssues: patchIssues,
116
+ suppressedIssues,
117
+ lineCount: countNonBinaryDiffLines(diff).textLines,
86
118
  usedCheckJs,
87
119
  };
88
120
  if (cache && cacheKey) {
89
- result.cacheWrite = { key: cacheKey, issues: patchIssues };
121
+ result.cacheWrite = { key: cacheKey };
90
122
  }
91
123
  return result;
92
124
  }
125
+ /** Size-rule check IDs whose waived measurement is still reported (FORGE G10). */
126
+ const SUPPRESSED_SIZE_CHECKS = new Set([
127
+ 'large-patch-lines',
128
+ 'large-patch-files',
129
+ 'file-too-large',
130
+ ]);
93
131
  /**
94
132
  * Applies the per-patch results in patch order so the bounded concurrency
95
133
  * cannot reorder output: emits each tier notice, the once-only run-level
@@ -103,6 +141,7 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
103
141
  skipped: 0,
104
142
  cacheDirty: false,
105
143
  reusedCacheEntries: 0,
144
+ suppressed: 0,
106
145
  };
107
146
  let globalCheckJsEmitted = false;
108
147
  for (let i = 0; i < subset.length; i++) {
@@ -115,19 +154,34 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
115
154
  continue;
116
155
  }
117
156
  emitTierNotice(patch.filename, result.existingFiles, patch.tier);
118
- // Run-level checkJs errors are emitted once, before the first freshly
119
- // linted patch's own issues — matching the serial emit point.
120
- if (result.usedCheckJs && checkJs && !globalCheckJsEmitted) {
157
+ // Run-level checkJs errors are emitted once, before the first
158
+ // non-skipped patch's own issues — matching the serial emit point.
159
+ // Deliberately NOT gated on `result.usedCheckJs`: global findings are
160
+ // run-level, never cached, and an all-cache-hit run previously dropped
161
+ // them entirely, letting a warm run report fewer errors than a cold
162
+ // one (FORGE F5 hardening). PerRunCheckJs builds its program lazily,
163
+ // so the cost only materialises when checkJs is configured.
164
+ if (checkJs && !globalCheckJsEmitted) {
121
165
  globalCheckJsEmitted = true;
122
166
  issues.push(...(await checkJs.getGlobal()));
123
167
  }
124
168
  if (result.cacheWrite && cache) {
125
- setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.cacheWrite.issues);
169
+ setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.rawIssues, result.suppressedIssues, result.lineCount);
126
170
  totals.cacheDirty = true;
127
171
  }
128
172
  for (const issue of result.rawIssues) {
129
173
  issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
130
174
  }
175
+ // A waived size finding still reports its MEASUREMENT (FORGE G10):
176
+ // the finding stays suppressed (no exit-code / --max-warnings effect)
177
+ // but the current count is readable from the tool that enforces it,
178
+ // so a waiver's cited size can be calibrated without hand-measuring.
179
+ for (const suppressedIssue of result.suppressedIssues) {
180
+ if (!SUPPRESSED_SIZE_CHECKS.has(suppressedIssue.check))
181
+ continue;
182
+ info(`NOTICE [${suppressedIssue.check}] ${patch.filename}: suppressed by lintIgnore — ${suppressedIssue.message}`);
183
+ }
184
+ totals.suppressed += result.suppressedIssues.length;
131
185
  if (result.status === 'cached')
132
186
  totals.reusedCacheEntries++;
133
187
  totals.linted++;
@@ -238,7 +292,9 @@ function selectPatchSubset(manifest, requested) {
238
292
  const found = manifest.patches.filter((p) => matches(p, name));
239
293
  if (found.length === 0) {
240
294
  const available = manifest.patches.map((p) => p.filename).join(', ');
241
- throw new GeneralError(`--patches: no patch in the queue matches "${name}". Available patches: ${available}`);
295
+ throw new GeneralError(`No patch in the queue matches "${name}". In --per-patch mode, positional arguments ` +
296
+ `and --patches select patches (filename, stem, manifest name, or slug) — not engine ` +
297
+ `files; drop --per-patch to lint engine paths. Available patches: ${available}`);
242
298
  }
243
299
  for (const p of found) {
244
300
  if (!seen.has(p.filename)) {
@@ -259,24 +315,41 @@ function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
259
315
  const patchLint = config.patchLint;
260
316
  if (!patchLint?.checkJs)
261
317
  return undefined;
318
+ const testFilesEnabled = patchLint.checkJsTestFiles === true;
262
319
  const ownedByPatch = new Map();
263
320
  for (const entry of ctx.entries) {
264
321
  const owned = new Set();
265
322
  for (const f of entry.newFiles.keys()) {
266
323
  if (f.endsWith('.sys.mjs'))
267
324
  owned.add(f);
325
+ else if (testFilesEnabled && isTestScriptFile(f))
326
+ owned.add(f);
268
327
  }
269
328
  if (owned.size > 0)
270
329
  ownedByPatch.set(entry.filename, owned);
271
330
  }
331
+ // One build for the whole run: the queue-wide `.sys.mjs` program plus —
332
+ // when `patchLint.checkJsTestFiles` is on (FORGE G5) — one small
333
+ // script-scope program per patch-owned test file, merged by file.
334
+ const buildAll = async () => {
335
+ const sys = await invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot);
336
+ if (!testFilesEnabled)
337
+ return sys;
338
+ const tests = await runCheckJsTestFilesGrouped(paths.engine, resolvePatchOwnedTestScripts(new Set(), ctx), patchLint, projectRoot);
339
+ const byFile = new Map(sys.byFile);
340
+ for (const [rel, list] of tests.byFile) {
341
+ byFile.set(rel, [...(byFile.get(rel) ?? []), ...list]);
342
+ }
343
+ return { byFile, global: [...sys.global, ...tests.global] };
344
+ };
272
345
  // Memoise the *promise*, not the resolved value: under the bounded pool
273
346
  // several patches can reach `getGrouped` before the first build resolves, and
274
347
  // `??=` on the promise (a synchronous expression) guarantees a single build.
275
348
  let groupedPromise;
276
349
  return {
277
350
  ownedByPatch,
278
- getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
279
- getGlobal: async () => (groupedPromise ? (await groupedPromise).global : []),
351
+ getGrouped: () => (groupedPromise ??= buildAll()),
352
+ getGlobal: async () => (await (groupedPromise ??= buildAll())).global,
280
353
  };
281
354
  }
282
355
  /**
@@ -299,7 +372,7 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
299
372
  const subsetNames = new Set(subset.map((p) => p.filename));
300
373
  const isSubset = subset.length !== manifest.patches.length;
301
374
  const config = await loadConfig(projectRoot);
302
- const ctx = await buildPatchQueueContext(paths.patches);
375
+ const ctx = await buildPatchQueueContext(paths.patches, config);
303
376
  // Queue-level findings (policy, cross-patch) are scoped to the requested
304
377
  // subset: a 5-patch slice should not fail on a policy or forward-import
305
378
  // problem owned entirely by patches the operator did not target.
@@ -340,7 +413,7 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
340
413
  engineHeadSha,
341
414
  checkJs,
342
415
  });
343
- const { linted, skipped, cacheDirty, reusedCacheEntries } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
416
+ const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
344
417
  for (const issue of lintPatchQueue(ctx)) {
345
418
  if (isSubset && !subsetTouchedFiles.has(issue.file))
346
419
  continue;
@@ -351,5 +424,11 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
351
424
  if (reusedCacheEntries > 0) {
352
425
  info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
353
426
  }
427
+ if (suppressed > 0) {
428
+ info(`Suppressed ${suppressed} issue(s) via per-patch lintIgnore.`);
429
+ }
430
+ if (options.report !== undefined) {
431
+ await writePerPatchLintReport(options.report, subset, results);
432
+ }
354
433
  reportPerPatchOutcome(issues, linted, skipped, options);
355
434
  }
@@ -0,0 +1,21 @@
1
+ import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
+ /**
3
+ * Structural view of one per-patch lint outcome. Mirrors the relevant
4
+ * fields of lint-per-patch's QueuedPatchResult without importing it —
5
+ * that module imports this one, and a type back-edge would trip the
6
+ * dpdm cycle gate.
7
+ */
8
+ export interface PerPatchReportResult {
9
+ status: 'skipped' | 'cached' | 'linted';
10
+ existingFiles: string[];
11
+ rawIssues: PatchLintIssue[];
12
+ suppressedIssues: PatchLintIssue[];
13
+ lineCount: number;
14
+ }
15
+ /**
16
+ * Writes the per-patch lint report JSON. Called after the per-patch
17
+ * results are assembled in patch order; queue-level and policy findings
18
+ * are patch-attributed by their `file`/`filename` fields upstream and
19
+ * are NOT duplicated here — the report is the per-patch view.
20
+ */
21
+ export declare function writePerPatchLintReport(reportPath: string, subset: readonly PatchMetadata[], results: readonly PerPatchReportResult[]): Promise<void>;
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Machine-readable per-patch lint report (`lint --per-patch --report
4
+ * <path>`, FORGE G9 + G10). One mechanism serves both observability
5
+ * gaps: consumers get each patch's size metrics against the SAME
6
+ * thresholds the size rules fire on (no more mirroring
7
+ * `countNonBinaryDiffLines`), and lintIgnore-suppressed issues stay
8
+ * visible with their measurements instead of vanishing.
9
+ *
10
+ * Helper module consumed by lint-per-patch.ts; no registrar is exported
11
+ * and none is wanted.
12
+ */
13
+ import { getPatchSizeThresholds, resolvePatchSizeTier, } from '../core/patch-lint.js';
14
+ import { writeJson } from '../utils/fs.js';
15
+ import { info } from '../utils/logger.js';
16
+ const LINT_REPORT_SCHEMA_VERSION = 1;
17
+ /**
18
+ * Writes the per-patch lint report JSON. Called after the per-patch
19
+ * results are assembled in patch order; queue-level and policy findings
20
+ * are patch-attributed by their `file`/`filename` fields upstream and
21
+ * are NOT duplicated here — the report is the per-patch view.
22
+ */
23
+ export async function writePerPatchLintReport(reportPath, subset, results) {
24
+ const patches = [];
25
+ let errors = 0;
26
+ let warnings = 0;
27
+ let suppressed = 0;
28
+ let linted = 0;
29
+ let skipped = 0;
30
+ for (let i = 0; i < subset.length; i++) {
31
+ const patch = subset[i];
32
+ const result = results[i];
33
+ if (!patch || !result)
34
+ continue;
35
+ const tier = resolvePatchSizeTier(result.existingFiles, patch.tier);
36
+ patches.push({
37
+ filename: patch.filename,
38
+ name: patch.name,
39
+ status: result.status,
40
+ lineCount: result.lineCount,
41
+ filesAffected: patch.filesAffected.length,
42
+ tier,
43
+ thresholds: getPatchSizeThresholds(tier.tier),
44
+ issues: result.rawIssues,
45
+ suppressedIssues: result.suppressedIssues,
46
+ });
47
+ if (result.status === 'skipped')
48
+ skipped++;
49
+ else
50
+ linted++;
51
+ errors += result.rawIssues.filter((issue) => issue.severity === 'error').length;
52
+ warnings += result.rawIssues.filter((issue) => issue.severity === 'warning').length;
53
+ suppressed += result.suppressedIssues.length;
54
+ }
55
+ await writeJson(reportPath, {
56
+ schemaVersion: LINT_REPORT_SCHEMA_VERSION,
57
+ generatedAt: new Date().toISOString(),
58
+ patches,
59
+ totals: { linted, skipped, errors, warnings, suppressed },
60
+ });
61
+ info(`Per-patch lint report written to ${reportPath}.`);
62
+ }
@@ -217,11 +217,12 @@ export function applyAggregateLintIgnoreSuppression(issues, ctx) {
217
217
  }
218
218
  /**
219
219
  * Up-front flag validation for `lintCommand`: rejects `--only-introduced`
220
- * without `--since`, non-integer `--max-warnings`, and `--per-patch`
221
- * combined with explicit file paths — each a misconfiguration that should
222
- * fail loud rather than silently narrow the result.
220
+ * without `--since` and non-integer `--max-warnings` — each a
221
+ * misconfiguration that should fail loud rather than silently narrow the
222
+ * result. Positional arguments in per-patch mode are patch selectors
223
+ * (FORGE G14) and are validated downstream by `selectPatchSubset`.
223
224
  */
224
- function validateLintFlags(options, files) {
225
+ function validateLintFlags(options) {
225
226
  // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
226
227
  // without a revision to anchor the diff there is no "introduced" subset
227
228
  // to scope to — reject the combination up-front so a misconfigured CI
@@ -234,22 +235,16 @@ function validateLintFlags(options, files) {
234
235
  (!Number.isInteger(options.maxWarnings) || options.maxWarnings < 0)) {
235
236
  throw new GeneralError('--max-warnings must be a non-negative integer.');
236
237
  }
237
- // `--per-patch` rescopes the diff from "aggregate engine state" to "each
238
- // patch's own filesAffected". Mixing in explicit engine file paths would
239
- // produce an ambiguous set — is the file list an additional filter, or
240
- // does it replace the per-patch scope? Reject up-front, but point at the
241
- // first-class subset filter so an operator who wanted to target patches
242
- // (not engine files) knows the supported syntax.
243
- if (options.perPatch && files.length > 0) {
244
- throw new GeneralError('--per-patch cannot be combined with explicit engine file paths. ' +
245
- 'To lint a subset of patches, use `--per-patch --patches <name…>`; ' +
246
- 'to lint specific engine files, drop --per-patch.');
247
- }
248
238
  // `--patches` only means something in per-patch mode (it filters the
249
239
  // queue); in aggregate/file-list mode there is no patch loop to narrow.
250
240
  if (options.patches !== undefined && !options.perPatch) {
251
241
  throw new GeneralError('--patches requires --per-patch.');
252
242
  }
243
+ // The JSON report is a per-patch artifact (per-patch size metrics and
244
+ // suppressed issues); aggregate mode has no per-patch rows to report.
245
+ if (options.report !== undefined && !options.perPatch) {
246
+ throw new GeneralError('--report requires --per-patch.');
247
+ }
253
248
  }
254
249
  /**
255
250
  * Aggregate-mode patch-size softening: when the linted diff is every
@@ -429,7 +424,7 @@ async function reportLintOutcome(engineDir, issues, options) {
429
424
  */
430
425
  export async function lintCommand(projectRoot, files, options = {}) {
431
426
  intro('FireForge Lint');
432
- validateLintFlags(options, files);
427
+ validateLintFlags(options);
433
428
  const paths = getProjectPaths(projectRoot);
434
429
  if (!(await pathExists(paths.engine))) {
435
430
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
@@ -438,7 +433,12 @@ export async function lintCommand(projectRoot, files, options = {}) {
438
433
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
439
434
  }
440
435
  if (options.perPatch) {
441
- await lintPerPatch(projectRoot, paths, options);
436
+ // Positional arguments in per-patch mode select PATCHES through the
437
+ // same alias resolution as --patches (FORGE G14) — a non-matching
438
+ // positional fails loud against the queue listing, so an engine path
439
+ // can never silently lint. Explicit --patches entries come first.
440
+ const perPatchOptions = files.length > 0 ? { ...options, patches: [...(options.patches ?? []), ...files] } : options;
441
+ await lintPerPatch(projectRoot, paths, perPatchOptions);
442
442
  return;
443
443
  }
444
444
  // Load the config before resolving the diff so we can pass
@@ -461,7 +461,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
461
461
  // per-patch ownership resolver and the cross-patch rules.
462
462
  let ctx;
463
463
  if (await pathExists(paths.patches)) {
464
- ctx = await buildPatchQueueContext(paths.patches);
464
+ ctx = await buildPatchQueueContext(paths.patches, config);
465
465
  }
466
466
  // Ad-hoc explicit-file-list mode evaluates the patch-size rules per
467
467
  // owning patch (see `lintOwningPatchSizes`), so suppress the synthetic
@@ -511,9 +511,10 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
511
511
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
512
512
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
513
513
  .option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
514
- .option('--patches <names...>', 'With --per-patch, lint only the named patches. Accepts repeated flags, comma lists, full filenames/stems, manifest names, category-prefixed slugs, or bare slugs.')
514
+ .option('--patches <names...>', 'With --per-patch, lint only the named patches. Accepts repeated flags, comma lists, full filenames/stems, manifest names, category-prefixed slugs, or bare slugs. Positional arguments after --per-patch are treated the same way.')
515
515
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
516
516
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
517
+ .option('--report <path>', 'With --per-patch, write a machine-readable JSON report (per-patch size metrics, tier, thresholds, issues, and lintIgnore-suppressed issues) to <path>.')
517
518
  .action(withErrorHandling(async (paths, options) => {
518
519
  const lintOptions = {};
519
520
  if (options.since !== undefined) {
@@ -538,6 +539,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
538
539
  if (options.cache === false) {
539
540
  lintOptions.noCache = true;
540
541
  }
542
+ if (options.report !== undefined) {
543
+ lintOptions.report = options.report;
544
+ }
541
545
  await lintCommand(getProjectRoot(), paths, lintOptions);
542
546
  }));
543
547
  const lintCache = lint
@@ -22,6 +22,7 @@ import { registerSource } from './source.js';
22
22
  import { registerStatus } from './status.js';
23
23
  import { registerTest } from './test-register.js';
24
24
  import { registerToken } from './token.js';
25
+ import { registerTree } from './tree.js';
25
26
  import { registerTypecheck } from './typecheck.js';
26
27
  import { registerVerify } from './verify.js';
27
28
  import { registerWatch } from './watch.js';
@@ -50,6 +51,7 @@ export const COMMAND_MANIFEST = [
50
51
  { name: 'package', group: 'workflow', register: registerPackage },
51
52
  { name: 'watch', group: 'workflow', register: registerWatch },
52
53
  { name: 'test', group: 'workflow', register: registerTest },
54
+ { name: 'tree', group: 'workflow', register: registerTree },
53
55
  { name: 'config', group: 'project', register: registerConfig },
54
56
  { name: 'doctor', group: 'diagnostics', register: registerDoctor },
55
57
  { name: 'register', group: 'workflow', register: registerRegister },
@@ -26,6 +26,16 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
26
26
  import { toError } from '../../utils/errors.js';
27
27
  import { info, intro, outro, warn } from '../../utils/logger.js';
28
28
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
29
+ /**
30
+ * Printed whenever `--add` lands a new check id. The waiver this command
31
+ * writes turns per-patch lint green immediately, but consumer projects
32
+ * commonly audit lintIgnore lists against a reviewed allow-map — the
33
+ * discovery that the map also needs updating otherwise costs a full
34
+ * downstream gate run (FORGE F6).
35
+ */
36
+ const LINT_IGNORE_REVIEW_WARNING = "This lint waiver is subject to the project's patch-policy review process. " +
37
+ 'Downstream policy audits that mirror lintIgnore lists in a reviewed allow-map ' +
38
+ 'will refuse this patch until the map is updated to include the new ID(s).';
29
39
  /**
30
40
  * Computes the post-mutation `lintIgnore` list for a given mode.
31
41
  * Returns `undefined` when the result should drop the field from the
@@ -114,7 +124,10 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
114
124
  // the flag only appeared in the history record — and suppressing lint
115
125
  // findings is precisely the mutation an operator should consciously
116
126
  // approve.
117
- const projectedSummary = describeChange(target.lintIgnore ?? [], applyMode(target.lintIgnore ?? [], mode, values) ?? [], mode, values);
127
+ const currentList = target.lintIgnore ?? [];
128
+ const projectedList = applyMode(currentList, mode, values) ?? [];
129
+ const addsNewIds = mode === 'add' && projectedList.length > currentList.length;
130
+ const projectedSummary = describeChange(currentList, projectedList, mode, values);
118
131
  const decision = await confirmDestructive({
119
132
  operation: 'patch-lint-ignore',
120
133
  title: `Patch lint-ignore: ${target.filename}`,
@@ -123,6 +136,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
123
136
  dryRun: isDryRun,
124
137
  });
125
138
  if (decision === 'dry-run') {
139
+ if (addsNewIds) {
140
+ warn(LINT_IGNORE_REVIEW_WARNING);
141
+ }
126
142
  outro('Dry run complete — no changes made');
127
143
  return;
128
144
  }
@@ -148,6 +164,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
148
164
  const existing = result.before.lintIgnore ?? [];
149
165
  const projected = result.after.lintIgnore ?? [];
150
166
  info(`${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
167
+ if (mode === 'add' && projected.length > existing.length) {
168
+ warn(LINT_IGNORE_REVIEW_WARNING);
169
+ }
151
170
  try {
152
171
  await appendHistory(paths.patches, {
153
172
  operation: 'patch-lint-ignore',
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Transactional file-ownership move into an EXISTING patch (FORGE F4).
3
+ *
4
+ * Before 0.39.0, `patch move-files <from> <to>` with an existing target was
5
+ * preview-only: it printed two `re-export --files` commands whose real
6
+ * execution cost three further refusals (`--allow-shrink`, `--yes`, and a
7
+ * projected `duplicate-new-file-creation` unless the SHRINK landed first —
8
+ * while the printed plan listed the grow first). This module performs the
9
+ * same shrink→grow as one transaction under the patch-directory lock with
10
+ * rollback, mirroring `patch split` / `move-files --create`.
11
+ *
12
+ * Preconditions match `re-export`: the engine worktree must currently
13
+ * reflect both patches' content, because both bodies are regenerated from
14
+ * the worktree.
15
+ */
16
+ import type { PatchMetadata, PatchMoveFilesOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
17
+ import type { FireForgeConfig } from '../../types/config.js';
18
+ /** Everything the commit step needs, computed and confirmed up front. */
19
+ export interface MoveIntoPlan {
20
+ source: PatchMetadata;
21
+ target: PatchMetadata;
22
+ movedFiles: string[];
23
+ sourceAfter: string[];
24
+ targetAfter: string[];
25
+ sourceDiff: string;
26
+ targetDiff: string;
27
+ /** Patches (by filename) whose staged-dependency owners re-point to the target. */
28
+ ownerRewrites: string[];
29
+ /** Forward-import declarations the move introduces, keyed by importing patch. */
30
+ stagedDependencyAdditions: Map<string, PatchStagedForwardImport[]>;
31
+ }
32
+ /**
33
+ * Plans, lints, confirms, and commits a file-ownership move into an
34
+ * existing patch. Called by `patchMoveFilesCommand` after its ownership
35
+ * pre-checks resolved and validated both patches.
36
+ */
37
+ export declare function runMoveFilesInto(args: {
38
+ enginePath: string;
39
+ patchesDir: string;
40
+ source: PatchMetadata;
41
+ target: PatchMetadata;
42
+ files: string[];
43
+ sourceAfter: string[];
44
+ targetAfter: string[];
45
+ options: PatchMoveFilesOptions;
46
+ config: FireForgeConfig;
47
+ }): Promise<void>;