@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.
- package/CHANGELOG.md +41 -0
- package/README.md +25 -1
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/build.js +8 -3
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/export-flow.d.ts +1 -1
- package/dist/src/commands/export-flow.js +21 -10
- package/dist/src/commands/export-placement-gate.js +1 -1
- package/dist/src/commands/export-shared.js +31 -5
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +97 -18
- package/dist/src/commands/lint-report.d.ts +21 -0
- package/dist/src/commands/lint-report.js +62 -0
- package/dist/src/commands/lint.js +23 -19
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.js +27 -13
- package/dist/src/commands/patch/rename.js +5 -2
- package/dist/src/commands/patch/staged-dependency.js +102 -14
- package/dist/src/commands/re-export-adjacent.d.ts +34 -0
- package/dist/src/commands/re-export-adjacent.js +106 -0
- package/dist/src/commands/re-export-options.js +3 -0
- package/dist/src/commands/re-export.js +42 -53
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/run.js +18 -2
- package/dist/src/commands/status-check.d.ts +35 -0
- package/dist/src/commands/status-check.js +88 -0
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +33 -8
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +26 -5
- package/dist/src/commands/test.js +38 -32
- package/dist/src/commands/token.js +4 -1
- package/dist/src/commands/tree.d.ts +16 -0
- package/dist/src/commands/tree.js +174 -0
- package/dist/src/core/build-baseline-types.d.ts +7 -0
- package/dist/src/core/build-baseline.d.ts +1 -1
- package/dist/src/core/build-baseline.js +2 -1
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-validate.js +25 -0
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +27 -0
- package/dist/src/core/lint-cache.d.ts +15 -5
- package/dist/src/core/lint-cache.js +17 -5
- package/dist/src/core/patch-export.d.ts +12 -3
- package/dist/src/core/patch-export.js +13 -3
- package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
- package/dist/src/core/patch-lint-checkjs.js +85 -16
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-css.js +64 -16
- package/dist/src/core/patch-lint-diff.d.ts +8 -0
- package/dist/src/core/patch-lint-diff.js +31 -0
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint-ownership.d.ts +21 -0
- package/dist/src/core/patch-lint-ownership.js +30 -0
- package/dist/src/core/patch-lint-reexports.d.ts +1 -1
- package/dist/src/core/patch-lint.d.ts +23 -0
- package/dist/src/core/patch-lint.js +34 -3
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/smoke-patterns.d.ts +24 -8
- package/dist/src/core/smoke-patterns.js +23 -12
- package/dist/src/core/status-classify.d.ts +16 -0
- package/dist/src/core/status-classify.js +20 -6
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +15 -0
- package/dist/src/core/test-stale-check.js +37 -1
- package/dist/src/core/token-category.d.ts +40 -0
- package/dist/src/core/token-category.js +264 -0
- package/dist/src/core/token-manager.d.ts +7 -0
- package/dist/src/core/token-manager.js +82 -193
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/tree-cow.d.ts +18 -0
- package/dist/src/core/tree-cow.js +71 -0
- package/dist/src/core/tree-guard.d.ts +35 -0
- package/dist/src/core/tree-guard.js +124 -0
- package/dist/src/core/tree-store.d.ts +53 -0
- package/dist/src/core/tree-store.js +214 -0
- package/dist/src/core/typecheck-shim.d.ts +27 -0
- package/dist/src/core/typecheck-shim.js +58 -4
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
- package/dist/src/core/xpcshell-profile-dir.js +45 -0
- package/dist/src/errors/git.d.ts +8 -0
- package/dist/src/errors/git.js +17 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/commands/options.d.ts +45 -2
- package/dist/src/types/config.d.ts +34 -0
- package/dist/src/utils/validation.d.ts +10 -0
- package/dist/src/utils/validation.js +29 -0
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
119
|
-
//
|
|
120
|
-
|
|
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.
|
|
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(
|
|
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 ??=
|
|
279
|
-
getGlobal: async () => (groupedPromise
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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>;
|