@hominis/fireforge 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +25 -1
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/export-flow.js +14 -6
- package/dist/src/commands/export-shared.js +14 -3
- package/dist/src/commands/lint-per-patch.js +83 -14
- 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 +22 -18
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/patch/rename.js +1 -1
- 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/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.js +16 -5
- package/dist/src/commands/test-run.js +18 -3
- 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/config-validate.js +17 -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 +2 -2
- package/dist/src/core/patch-export.js +1 -1
- package/dist/src/core/patch-lint-checkjs.d.ts +19 -1
- package/dist/src/core/patch-lint-checkjs.js +56 -3
- 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-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 +30 -2
- package/dist/src/core/patch-manifest.d.ts +1 -1
- 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/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 +48 -196
- 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 +14 -0
- package/dist/src/core/typecheck-shim.js +41 -2
- 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 +33 -2
- package/dist/src/types/config.d.ts +19 -0
- package/dist/src/utils/validation.d.ts +10 -0
- package/dist/src/utils/validation.js +29 -0
- package/package.json +6 -6
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
2
|
+
const ALL_CLASSIFICATIONS = [
|
|
3
|
+
'patch-backed',
|
|
4
|
+
'patch-owned-drift',
|
|
5
|
+
'unmanaged',
|
|
6
|
+
'branding',
|
|
7
|
+
'furnace',
|
|
8
|
+
'conflict',
|
|
9
|
+
];
|
|
10
|
+
/** Default `--check` policy: everything outside {patch-backed, branding, furnace} fails. */
|
|
11
|
+
const DEFAULT_FAIL_CLASSIFICATIONS = [
|
|
12
|
+
'unmanaged',
|
|
13
|
+
'patch-owned-drift',
|
|
14
|
+
'conflict',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the enforcement policy from the raw flags: `--fail-on` implies
|
|
18
|
+
* `--check` and replaces the default set; both are refused alongside the
|
|
19
|
+
* modes that skip (or elide) classification.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveStatusCheckPolicy(options) {
|
|
22
|
+
const failOnList = parseFailOnClassifications(options.failOn);
|
|
23
|
+
const checkEnabled = options.check === true || failOnList !== undefined;
|
|
24
|
+
if (checkEnabled &&
|
|
25
|
+
(options.raw === true ||
|
|
26
|
+
options.unmanaged === true ||
|
|
27
|
+
options.ownership === true ||
|
|
28
|
+
options.testCoverage === true)) {
|
|
29
|
+
throw new InvalidArgumentError('--check cannot be combined with --raw, --unmanaged, --ownership, or --test-coverage. Use the default view or --json.', '--check');
|
|
30
|
+
}
|
|
31
|
+
return { checkEnabled, failOn: failOnList ?? DEFAULT_FAIL_CLASSIFICATIONS };
|
|
32
|
+
}
|
|
33
|
+
function isClassification(value) {
|
|
34
|
+
return ALL_CLASSIFICATIONS.includes(value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parses a comma-separated `--fail-on` value into a classification list.
|
|
38
|
+
* Returns undefined when the flag was not given (the caller then applies
|
|
39
|
+
* the default policy only under `--check`).
|
|
40
|
+
*/
|
|
41
|
+
function parseFailOnClassifications(raw) {
|
|
42
|
+
if (raw === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
const parts = raw
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((part) => part.trim())
|
|
47
|
+
.filter((part) => part.length > 0);
|
|
48
|
+
if (parts.length === 0) {
|
|
49
|
+
throw new InvalidArgumentError(`--fail-on requires at least one classification. Valid: ${ALL_CLASSIFICATIONS.join(', ')}.`, '--fail-on');
|
|
50
|
+
}
|
|
51
|
+
const classifications = [];
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
if (!isClassification(part)) {
|
|
54
|
+
throw new InvalidArgumentError(`Unknown --fail-on classification "${part}". Valid: ${ALL_CLASSIFICATIONS.join(', ')}.`, '--fail-on');
|
|
55
|
+
}
|
|
56
|
+
if (!classifications.includes(part))
|
|
57
|
+
classifications.push(part);
|
|
58
|
+
}
|
|
59
|
+
return classifications;
|
|
60
|
+
}
|
|
61
|
+
const CHECK_FILE_PREVIEW_MAX = 3;
|
|
62
|
+
/**
|
|
63
|
+
* Throws a GeneralError (exit 1) when the policy is enabled and any of
|
|
64
|
+
* its classifications is non-empty, naming each offending classification
|
|
65
|
+
* with its first few files. A disabled policy or a fully tool-covered
|
|
66
|
+
* tree passes silently.
|
|
67
|
+
*/
|
|
68
|
+
export function runStatusCheck(classified, policy) {
|
|
69
|
+
if (!policy.checkEnabled)
|
|
70
|
+
return;
|
|
71
|
+
const offending = [];
|
|
72
|
+
for (const classification of policy.failOn) {
|
|
73
|
+
const files = classified
|
|
74
|
+
.filter((entry) => entry.classification === classification)
|
|
75
|
+
.map((entry) => entry.file);
|
|
76
|
+
if (files.length === 0)
|
|
77
|
+
continue;
|
|
78
|
+
const preview = files.slice(0, CHECK_FILE_PREVIEW_MAX).join(', ');
|
|
79
|
+
const more = files.length > CHECK_FILE_PREVIEW_MAX
|
|
80
|
+
? `, +${String(files.length - CHECK_FILE_PREVIEW_MAX)} more`
|
|
81
|
+
: '';
|
|
82
|
+
offending.push(`${String(files.length)} ${classification} (${preview}${more})`);
|
|
83
|
+
}
|
|
84
|
+
if (offending.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
throw new GeneralError(`status --check failed: ${offending.join(', ')}. Export or adopt unmanaged files, ` +
|
|
87
|
+
`re-export drifted patches, and run "fireforge verify" for conflict details.`);
|
|
88
|
+
}
|
|
@@ -13,6 +13,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
|
|
|
13
13
|
import { ExitCode } from '../errors/codes.js';
|
|
14
14
|
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
15
15
|
import { info, intro, isMachineOutputMode, outro, setMachineOutputMode, warn, } from '../utils/logger.js';
|
|
16
|
+
import { resolveStatusCheckPolicy, runStatusCheck } from './status-check.js';
|
|
16
17
|
import { renderDefaultStatus, renderTestCoverageStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
17
18
|
/**
|
|
18
19
|
* Renders raw worktree status as machine-parseable porcelain-style output.
|
|
@@ -86,14 +87,17 @@ function filterFireForgeTempFiles(files) {
|
|
|
86
87
|
/**
|
|
87
88
|
* Renders classified file status as machine-readable JSON to stdout.
|
|
88
89
|
*/
|
|
89
|
-
async function
|
|
90
|
+
async function classifyStatusFiles(files, paths, projectRoot, binaryName) {
|
|
90
91
|
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
91
|
-
|
|
92
|
+
return classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
|
|
93
|
+
}
|
|
94
|
+
function renderJsonStatus(classified) {
|
|
92
95
|
const outputFiles = classified.map((f) => {
|
|
93
96
|
const entry = {
|
|
94
97
|
file: f.file,
|
|
95
98
|
status: f.status.trim(),
|
|
96
99
|
classification: f.classification,
|
|
100
|
+
patch: f.owner ?? null,
|
|
97
101
|
};
|
|
98
102
|
if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
|
|
99
103
|
entry.claimedBy = [...f.claimedBy];
|
|
@@ -181,6 +185,9 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
181
185
|
if (modeCount > 1) {
|
|
182
186
|
throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
|
|
183
187
|
}
|
|
188
|
+
// --check / --fail-on enforcement policy (FORGE G1). Applies only
|
|
189
|
+
// where classification runs (the default view and --json).
|
|
190
|
+
const checkPolicy = resolveStatusCheckPolicy(options);
|
|
184
191
|
if (!options.raw && !options.json) {
|
|
185
192
|
intro('FireForge Status');
|
|
186
193
|
}
|
|
@@ -270,7 +277,9 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
270
277
|
// the output through a JSON parser broke precisely when there was
|
|
271
278
|
// nothing to report. Emit `[]` here and return before the human fallback.
|
|
272
279
|
if (options.json) {
|
|
273
|
-
await
|
|
280
|
+
const classified = await classifyStatusFiles(files, paths, projectRoot, config.binaryName);
|
|
281
|
+
renderJsonStatus(classified);
|
|
282
|
+
runStatusCheck(classified, checkPolicy);
|
|
274
283
|
return;
|
|
275
284
|
}
|
|
276
285
|
// `--raw` consumers parse the native `git status --porcelain` output
|
|
@@ -291,8 +300,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
291
300
|
return;
|
|
292
301
|
}
|
|
293
302
|
// Patch-aware classification
|
|
294
|
-
const
|
|
295
|
-
const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
|
|
303
|
+
const classified = await classifyStatusFiles(files, paths, projectRoot, config.binaryName);
|
|
296
304
|
const buckets = {
|
|
297
305
|
conflict: classified.filter((f) => f.classification === 'conflict'),
|
|
298
306
|
unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
|
|
@@ -307,6 +315,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
307
315
|
return;
|
|
308
316
|
}
|
|
309
317
|
await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
|
|
318
|
+
runStatusCheck(classified, checkPolicy);
|
|
310
319
|
}
|
|
311
320
|
finally {
|
|
312
321
|
if (isMachineOutputMode() !== previousMachineOutputMode) {
|
|
@@ -324,6 +333,8 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
|
|
|
324
333
|
.option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
|
|
325
334
|
.option('--test-coverage', 'Show what the last recorded build covers for test packaging (full or scoped paths)')
|
|
326
335
|
.option('--json', 'Output classified file status as JSON')
|
|
336
|
+
.option('--check', 'Exit non-zero when any unmanaged, patch-owned-drift, or conflict file exists (composes with --json; combine with --fail-on for finer policy)')
|
|
337
|
+
.option('--fail-on <classifications>', 'Comma-separated classification list that fails --check, replacing the default set (implies --check). Valid: patch-backed, patch-owned-drift, unmanaged, branding, furnace, conflict')
|
|
327
338
|
.action(withErrorHandling(async (options) => {
|
|
328
339
|
await statusCommand(getProjectRoot(), options);
|
|
329
340
|
}));
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
|
|
15
15
|
import { buildHarnessCrashMessage, classifyHarnessRun, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
16
16
|
import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
|
|
17
|
+
import { withXpcshellProfileDir } from '../core/xpcshell-profile-dir.js';
|
|
17
18
|
import { BuildError } from '../errors/build.js';
|
|
18
19
|
import { info, note, warn } from '../utils/logger.js';
|
|
19
20
|
import { getPlatform } from '../utils/platform.js';
|
|
@@ -34,6 +35,20 @@ function dispatchForSuite(suite) {
|
|
|
34
35
|
* stale-xpcshell-symlink repair path the single-run flow already used.
|
|
35
36
|
*/
|
|
36
37
|
export async function runTestsWithRetries(ctx, paths) {
|
|
38
|
+
// Pure mochitest dispatches keep their env byte-identical — the profile
|
|
39
|
+
// variable is xpcshell-harness-specific (FORGE G15b). Generic `mach
|
|
40
|
+
// test` runs may include xpcshell paths, so they get the isolation too.
|
|
41
|
+
if (ctx.suite === 'mochitest') {
|
|
42
|
+
return runTestsWithRetriesInner(ctx, paths, ctx.env);
|
|
43
|
+
}
|
|
44
|
+
// Fresh profile dir per harness invocation: shards isolate from each
|
|
45
|
+
// other and from concurrent `fireforge test` processes; the crash /
|
|
46
|
+
// symlink-repair retries inside one invocation deliberately REUSE the
|
|
47
|
+
// same directory (they are the same logical run and the harness
|
|
48
|
+
// re-initialises the profile contents itself).
|
|
49
|
+
return withXpcshellProfileDir(ctx.env, (env) => runTestsWithRetriesInner(ctx, paths, env));
|
|
50
|
+
}
|
|
51
|
+
async function runTestsWithRetriesInner(ctx, paths, env) {
|
|
37
52
|
const extraArgs = [...ctx.baseExtraArgs];
|
|
38
53
|
const appdirInjectionAttempted = await maybeInjectAppdirArg(ctx.engineDir, paths, ctx.objDir, extraArgs);
|
|
39
54
|
const dispatch = dispatchForSuite(ctx.suite);
|
|
@@ -43,10 +58,10 @@ export async function runTestsWithRetries(ctx, paths) {
|
|
|
43
58
|
let verdict;
|
|
44
59
|
for (;;) {
|
|
45
60
|
attempts += 1;
|
|
46
|
-
result =
|
|
47
|
-
? await dispatch(ctx.engineDir, paths, extraArgs,
|
|
61
|
+
result = env
|
|
62
|
+
? await dispatch(ctx.engineDir, paths, extraArgs, env)
|
|
48
63
|
: await dispatch(ctx.engineDir, paths, extraArgs);
|
|
49
|
-
result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs,
|
|
64
|
+
result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch);
|
|
50
65
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
51
66
|
verdict = classifyHarnessRun(result.exitCode, combined, paths);
|
|
52
67
|
if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
|
|
@@ -105,7 +105,10 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
105
105
|
...(options.variant !== undefined ? { variant: options.variant } : {}),
|
|
106
106
|
});
|
|
107
107
|
if (result.skipped) {
|
|
108
|
-
|
|
108
|
+
const where = result.skippedExisting
|
|
109
|
+
? ` in category "${result.skippedExisting.category ?? options.category}" (line ${String(result.skippedExisting.line)})`
|
|
110
|
+
: '';
|
|
111
|
+
info(`Token ${tokenName} already exists${where} (skipped)`);
|
|
109
112
|
}
|
|
110
113
|
else {
|
|
111
114
|
const forgeConfig = await loadConfig(projectRoot);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { CommandContext } from '../types/cli.js';
|
|
3
|
+
/** Creates a verification tree under `.fireforge/trees/<name>`. */
|
|
4
|
+
export declare function treeCreateCommand(projectRoot: string, name: string, options?: {
|
|
5
|
+
forceCopy?: boolean;
|
|
6
|
+
}): Promise<void>;
|
|
7
|
+
/** Prints the tree list with staleness against the current primary state. */
|
|
8
|
+
export declare function treeListCommand(projectRoot: string, options?: {
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}): Promise<void>;
|
|
11
|
+
/** Removes one tree (or all of them with `--all`). */
|
|
12
|
+
export declare function treeRemoveCommand(projectRoot: string, name: string | undefined, options?: {
|
|
13
|
+
all?: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
/** Registers the tree command group on the CLI program. */
|
|
16
|
+
export declare function registerTree(program: Command, context: CommandContext): void;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge tree` — copy-on-write verification clones (FORGE G15).
|
|
4
|
+
*
|
|
5
|
+
* Trees enable concurrent READ-MOSTLY verification (lint, typecheck,
|
|
6
|
+
* status, verify, doctor, export dry-runs) beside a busy primary tree.
|
|
7
|
+
* Exports and every other mutation stay strictly serial in the primary —
|
|
8
|
+
* a tree's patches/components are snapshots with no merge model, and the
|
|
9
|
+
* tree guard (cli.ts preAction + core/tree-guard.ts) refuses mutating
|
|
10
|
+
* commands inside a tree. In-tree `test` and objdir cloning are deferred:
|
|
11
|
+
* mach objdirs embed absolute source paths, so a cloned objdir would
|
|
12
|
+
* silently operate against the primary tree.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { Command } from 'commander';
|
|
17
|
+
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
18
|
+
import { detectCowSupport } from '../core/tree-cow.js';
|
|
19
|
+
import { cloneTree, listTrees, removeTree } from '../core/tree-store.js';
|
|
20
|
+
import { assertValidTreeName, computePrimaryFingerprint, getTreesDir, readTreeMarker, withTreeLifecycleLock, } from '../core/tree-store.js';
|
|
21
|
+
import { GeneralError } from '../errors/base.js';
|
|
22
|
+
import { pathExists } from '../utils/fs.js';
|
|
23
|
+
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
24
|
+
function assertPosix() {
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
throw new GeneralError('fireforge tree is POSIX-only; copy-on-write cloning relies on clonefile/reflink, which are not available on Windows.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Creates a verification tree under `.fireforge/trees/<name>`. */
|
|
30
|
+
export async function treeCreateCommand(projectRoot, name, options = {}) {
|
|
31
|
+
assertPosix();
|
|
32
|
+
intro('FireForge Tree Create');
|
|
33
|
+
assertValidTreeName(name);
|
|
34
|
+
if (await readTreeMarker(projectRoot)) {
|
|
35
|
+
throw new GeneralError('Verification trees cannot be nested: run tree create in the primary tree.');
|
|
36
|
+
}
|
|
37
|
+
const treesDir = getTreesDir(projectRoot);
|
|
38
|
+
const treeRoot = join(treesDir, name);
|
|
39
|
+
await withTreeLifecycleLock(projectRoot, async () => {
|
|
40
|
+
if (await pathExists(treeRoot)) {
|
|
41
|
+
throw new GeneralError(`A tree named "${name}" already exists. Remove it first (fireforge tree remove ${name}) — refresh is remove + create.`);
|
|
42
|
+
}
|
|
43
|
+
const { mkdir } = await import('node:fs/promises');
|
|
44
|
+
await mkdir(treesDir, { recursive: true });
|
|
45
|
+
const capability = await detectCowSupport(treesDir);
|
|
46
|
+
if (capability === 'none' && options.forceCopy !== true) {
|
|
47
|
+
throw new GeneralError('This filesystem cannot copy-on-write (APFS clonefile or btrfs/XFS reflink required), so a tree ' +
|
|
48
|
+
'would be a full physical copy of the applied engine tree — potentially tens of gigabytes. ' +
|
|
49
|
+
'Re-run with --force-copy to accept that explicitly.');
|
|
50
|
+
}
|
|
51
|
+
if (capability === 'none') {
|
|
52
|
+
warn('No copy-on-write support detected — performing a full physical copy (--force-copy).');
|
|
53
|
+
}
|
|
54
|
+
// Snapshot under the PRIMARY engine-session lock so a mid-mutation
|
|
55
|
+
// state (half-applied export, furnace deploy) is never captured.
|
|
56
|
+
const marker = await withEngineSessionLock(projectRoot, 'tree create', () => cloneTree({
|
|
57
|
+
primaryRoot: projectRoot,
|
|
58
|
+
treeRoot,
|
|
59
|
+
name,
|
|
60
|
+
capability,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
}));
|
|
63
|
+
success(`Created verification tree "${name}" at ${treeRoot}`);
|
|
64
|
+
info(` Engine HEAD: ${marker.engineHead ?? '(no engine)'}`);
|
|
65
|
+
info(' Read-only: status, lint, typecheck, verify, doctor, and export --dry-run run inside it; mutation commands are refused.');
|
|
66
|
+
});
|
|
67
|
+
outro('Tree created');
|
|
68
|
+
}
|
|
69
|
+
/** Prints the tree list with staleness against the current primary state. */
|
|
70
|
+
export async function treeListCommand(projectRoot, options = {}) {
|
|
71
|
+
const trees = await listTrees(projectRoot);
|
|
72
|
+
if (options.json === true) {
|
|
73
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, trees }, null, 2)}\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
intro('FireForge Trees');
|
|
77
|
+
if (trees.length === 0) {
|
|
78
|
+
info('No verification trees. Create one with: fireforge tree create <name>');
|
|
79
|
+
outro('Done');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const tree of trees) {
|
|
83
|
+
info(`${tree.name.padEnd(20)} ${tree.staleness.padEnd(24)} created ${tree.createdAt}`);
|
|
84
|
+
}
|
|
85
|
+
outro(`${trees.length} tree(s)`);
|
|
86
|
+
}
|
|
87
|
+
/** Removes one tree (or all of them with `--all`). */
|
|
88
|
+
export async function treeRemoveCommand(projectRoot, name, options = {}) {
|
|
89
|
+
assertPosix();
|
|
90
|
+
intro('FireForge Tree Remove');
|
|
91
|
+
if (options.all !== true && name === undefined) {
|
|
92
|
+
throw new GeneralError('Pass a tree name, or --all to remove every tree.');
|
|
93
|
+
}
|
|
94
|
+
await withTreeLifecycleLock(projectRoot, async () => {
|
|
95
|
+
const targets = options.all === true ? (await listTrees(projectRoot)).map((t) => t.name) : [name ?? ''];
|
|
96
|
+
if (targets.length === 0) {
|
|
97
|
+
info('No verification trees to remove.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const target of targets) {
|
|
101
|
+
await removeTree(projectRoot, target);
|
|
102
|
+
success(`Removed verification tree "${target}"`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
outro('Tree removal complete');
|
|
106
|
+
}
|
|
107
|
+
/** Spawns `fireforge <args>` with the tree as its working directory. */
|
|
108
|
+
async function treeExecCommand(projectRoot, name, args) {
|
|
109
|
+
assertPosix();
|
|
110
|
+
assertValidTreeName(name);
|
|
111
|
+
const treeRoot = join(getTreesDir(projectRoot), name);
|
|
112
|
+
const marker = await readTreeMarker(treeRoot);
|
|
113
|
+
if (!marker) {
|
|
114
|
+
throw new GeneralError(`No verification tree named "${name}". List trees with: fireforge tree list`);
|
|
115
|
+
}
|
|
116
|
+
const current = await computePrimaryFingerprint(projectRoot);
|
|
117
|
+
if (marker.engineHead !== current.engineHead) {
|
|
118
|
+
warn(`Tree "${name}" is stale (primary engine advanced since the snapshot).`);
|
|
119
|
+
}
|
|
120
|
+
else if (marker.patchesFingerprint !== current.patchesFingerprint) {
|
|
121
|
+
warn(`Tree "${name}" is stale (primary patches changed since the snapshot).`);
|
|
122
|
+
}
|
|
123
|
+
const cliEntry = process.argv[1];
|
|
124
|
+
if (cliEntry === undefined) {
|
|
125
|
+
throw new GeneralError('Cannot resolve the fireforge CLI entry point for tree exec.');
|
|
126
|
+
}
|
|
127
|
+
const exitCode = await new Promise((resolvePromise, rejectPromise) => {
|
|
128
|
+
const child = spawn(process.execPath, [cliEntry, ...args], {
|
|
129
|
+
cwd: treeRoot,
|
|
130
|
+
stdio: 'inherit',
|
|
131
|
+
});
|
|
132
|
+
child.on('error', rejectPromise);
|
|
133
|
+
child.on('exit', (code) => {
|
|
134
|
+
resolvePromise(code ?? 1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
if (exitCode !== 0) {
|
|
138
|
+
throw new GeneralError(`tree exec: fireforge ${args.join(' ')} exited with code ${String(exitCode)}.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Registers the tree command group on the CLI program. */
|
|
142
|
+
export function registerTree(program, context) {
|
|
143
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
144
|
+
const tree = program
|
|
145
|
+
.command('tree')
|
|
146
|
+
.description('Manage copy-on-write verification clones of this project for concurrent read-mostly work (lint, typecheck, status, verify, export --dry-run). Mutation commands are refused inside a tree.');
|
|
147
|
+
tree
|
|
148
|
+
.command('create <name>')
|
|
149
|
+
.description('Clone the project (engine included, obj-* excluded) into .fireforge/trees/<name> using filesystem copy-on-write (APFS clonefile / btrfs-XFS reflink). Refuses on filesystems without CoW support unless --force-copy explicitly accepts a full physical copy.')
|
|
150
|
+
.option('--force-copy', 'Allow a full physical copy when the filesystem cannot copy-on-write. This can copy tens of GB; never implied.')
|
|
151
|
+
.action(withErrorHandling(async (name, options) => {
|
|
152
|
+
await treeCreateCommand(getProjectRoot(), name, options);
|
|
153
|
+
}));
|
|
154
|
+
tree
|
|
155
|
+
.command('list')
|
|
156
|
+
.description('List verification trees with creation time and staleness vs the primary tree (engine HEAD and patches.json fingerprint).')
|
|
157
|
+
.option('--json', 'Output the tree list as JSON')
|
|
158
|
+
.action(withErrorHandling(async (options) => {
|
|
159
|
+
await treeListCommand(getProjectRoot(), options);
|
|
160
|
+
}));
|
|
161
|
+
tree
|
|
162
|
+
.command('remove [name]')
|
|
163
|
+
.description("Delete a verification tree. Refuses while a live process holds the tree's build or engine-session lock.")
|
|
164
|
+
.option('--all', 'Remove every verification tree')
|
|
165
|
+
.action(withErrorHandling(async (name, options) => {
|
|
166
|
+
await treeRemoveCommand(getProjectRoot(), name, options);
|
|
167
|
+
}));
|
|
168
|
+
tree
|
|
169
|
+
.command('exec <name> [args...]')
|
|
170
|
+
.description('Run a fireforge command inside the named tree (equivalent to cd-ing into it). Example: fireforge tree exec shard-a -- lint --per-patch')
|
|
171
|
+
.action(withErrorHandling(async (name, args) => {
|
|
172
|
+
await treeExecCommand(getProjectRoot(), name, args);
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
@@ -328,6 +328,17 @@ function parsePatchLintBlock(rec) {
|
|
|
328
328
|
if (checkJsExtraShim !== undefined) {
|
|
329
329
|
out.checkJsExtraShim = parseShimPath(checkJsExtraShim, 'patchLint.checkJsExtraShim');
|
|
330
330
|
}
|
|
331
|
+
const checkJsTestFiles = rec.raw('checkJsTestFiles');
|
|
332
|
+
if (checkJsTestFiles !== undefined) {
|
|
333
|
+
if (typeof checkJsTestFiles !== 'boolean') {
|
|
334
|
+
throw new ConfigError('Config field "patchLint.checkJsTestFiles" must be a boolean');
|
|
335
|
+
}
|
|
336
|
+
out.checkJsTestFiles = checkJsTestFiles;
|
|
337
|
+
}
|
|
338
|
+
const checkJsTestShim = rec.raw('checkJsTestShim');
|
|
339
|
+
if (checkJsTestShim !== undefined) {
|
|
340
|
+
out.checkJsTestShim = parseShimPath(checkJsTestShim, 'patchLint.checkJsTestShim');
|
|
341
|
+
}
|
|
331
342
|
const rawColorAllowlist = rec.raw('rawColorAllowlist');
|
|
332
343
|
if (rawColorAllowlist !== undefined) {
|
|
333
344
|
if (!Array.isArray(rawColorAllowlist) ||
|
|
@@ -358,6 +369,12 @@ function parsePatchLintBlock(rec) {
|
|
|
358
369
|
if (out.checkJsCompilerOptions !== undefined && out.checkJsStrict !== true) {
|
|
359
370
|
throw new ConfigError('Config field "patchLint.checkJsCompilerOptions" requires "patchLint.checkJsStrict": true');
|
|
360
371
|
}
|
|
372
|
+
if (out.checkJsTestFiles === true && out.checkJs !== true) {
|
|
373
|
+
throw new ConfigError('Config field "patchLint.checkJsTestFiles" requires "patchLint.checkJs": true');
|
|
374
|
+
}
|
|
375
|
+
if (out.checkJsTestShim !== undefined && out.checkJsTestFiles !== true) {
|
|
376
|
+
throw new ConfigError('Config field "patchLint.checkJsTestShim" requires "patchLint.checkJsTestFiles": true');
|
|
377
|
+
}
|
|
361
378
|
return out;
|
|
362
379
|
}
|
|
363
380
|
/**
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
|
|
2
2
|
import type { FireForgeConfig } from '../types/config.js';
|
|
3
3
|
import { type PatchQueueContext } from './patch-lint.js';
|
|
4
|
-
export declare const LINT_CACHE_SCHEMA_VERSION =
|
|
4
|
+
export declare const LINT_CACHE_SCHEMA_VERSION = 2;
|
|
5
5
|
export interface PerPatchLintCacheEntry {
|
|
6
6
|
key: string;
|
|
7
7
|
patchFilename: string;
|
|
8
8
|
issues: PatchLintIssue[];
|
|
9
|
+
/** Issues dropped by the patch's lintIgnore waivers (FORGE G10). */
|
|
10
|
+
suppressed: PatchLintIssue[];
|
|
11
|
+
/** Non-binary diff line count measured by the cached lint run. */
|
|
12
|
+
lineCount: number;
|
|
9
13
|
updatedAt: string;
|
|
10
14
|
}
|
|
11
15
|
export interface PerPatchLintCacheFile {
|
|
@@ -36,7 +40,13 @@ export declare function loadPerPatchLintCache(projectRoot: string): Promise<PerP
|
|
|
36
40
|
export declare function savePerPatchLintCache(projectRoot: string, cache: PerPatchLintCacheFile): Promise<void>;
|
|
37
41
|
/** Clears the per-patch lint cache by replacing it with an empty document. */
|
|
38
42
|
export declare function clearPerPatchLintCache(projectRoot: string): Promise<void>;
|
|
39
|
-
/**
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/** Cached per-patch lint payload returned on a key match. */
|
|
44
|
+
export interface CachedPerPatchLint {
|
|
45
|
+
issues: PatchLintIssue[];
|
|
46
|
+
suppressed: PatchLintIssue[];
|
|
47
|
+
lineCount: number;
|
|
48
|
+
}
|
|
49
|
+
/** Returns the cached lint payload for a patch when the stored key still matches. */
|
|
50
|
+
export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): CachedPerPatchLint | undefined;
|
|
51
|
+
/** Stores the per-patch lint payload after a successful lint calculation. */
|
|
52
|
+
export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[], suppressed: PatchLintIssue[], lineCount: number): void;
|
|
@@ -7,7 +7,11 @@ import { getPackageVersion } from '../utils/package-root.js';
|
|
|
7
7
|
import { getFurnacePaths } from './furnace-config.js';
|
|
8
8
|
import { git } from './git-base.js';
|
|
9
9
|
import { collectNewFileCreatorsByPath } from './patch-lint.js';
|
|
10
|
-
|
|
10
|
+
// Schema 2 (FORGE G10): entries additionally carry the lintIgnore-suppressed
|
|
11
|
+
// issues and the measured non-binary line count, so a warm run can report
|
|
12
|
+
// waived size measurements identically to a cold one (the F5-class hazard:
|
|
13
|
+
// a cache hit must never surface LESS than a fresh lint).
|
|
14
|
+
export const LINT_CACHE_SCHEMA_VERSION = 2;
|
|
11
15
|
const LINT_IMPLEMENTATION_VERSION = 1;
|
|
12
16
|
const LINT_CACHE_DIRNAME = 'lint-cache';
|
|
13
17
|
const PER_PATCH_CACHE_FILENAME = 'per-patch-v1.json';
|
|
@@ -118,6 +122,8 @@ function isCacheEntry(value) {
|
|
|
118
122
|
return (typeof entry.key === 'string' &&
|
|
119
123
|
typeof entry.patchFilename === 'string' &&
|
|
120
124
|
Array.isArray(entry.issues) &&
|
|
125
|
+
Array.isArray(entry.suppressed) &&
|
|
126
|
+
typeof entry.lineCount === 'number' &&
|
|
121
127
|
typeof entry.updatedAt === 'string');
|
|
122
128
|
}
|
|
123
129
|
/** Loads the per-patch lint cache, treating missing or invalid files as empty. */
|
|
@@ -158,19 +164,25 @@ export async function savePerPatchLintCache(projectRoot, cache) {
|
|
|
158
164
|
export async function clearPerPatchLintCache(projectRoot) {
|
|
159
165
|
await writeJson(getPerPatchLintCachePath(projectRoot), createEmptyPerPatchLintCache());
|
|
160
166
|
}
|
|
161
|
-
/** Returns cached
|
|
167
|
+
/** Returns the cached lint payload for a patch when the stored key still matches. */
|
|
162
168
|
export function getCachedPerPatchLintIssues(cache, patchFilename, key) {
|
|
163
169
|
const entry = cache.entries[patchFilename];
|
|
164
170
|
if (!entry || entry.key !== key)
|
|
165
171
|
return undefined;
|
|
166
|
-
return
|
|
172
|
+
return {
|
|
173
|
+
issues: entry.issues.map((issue) => ({ ...issue })),
|
|
174
|
+
suppressed: entry.suppressed.map((issue) => ({ ...issue })),
|
|
175
|
+
lineCount: entry.lineCount,
|
|
176
|
+
};
|
|
167
177
|
}
|
|
168
|
-
/** Stores per-patch lint
|
|
169
|
-
export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues) {
|
|
178
|
+
/** Stores the per-patch lint payload after a successful lint calculation. */
|
|
179
|
+
export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues, suppressed, lineCount) {
|
|
170
180
|
cache.entries[patchFilename] = {
|
|
171
181
|
key,
|
|
172
182
|
patchFilename,
|
|
173
183
|
issues: issues.map((issue) => ({ ...issue })),
|
|
184
|
+
suppressed: suppressed.map((issue) => ({ ...issue })),
|
|
185
|
+
lineCount,
|
|
174
186
|
updatedAt: new Date().toISOString(),
|
|
175
187
|
};
|
|
176
188
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { PatchCategory, PatchesManifest, PatchInfo, PatchMetadata } from '../types/commands/index.js';
|
|
2
2
|
import type { FireForgeConfig } from '../types/config.js';
|
|
3
3
|
import { type SupersedeCoverageDetail } from './patch-export-coverage.js';
|
|
4
|
-
export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, type
|
|
5
|
-
export {
|
|
4
|
+
export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, type SupersedeCoverageDetail, } from './patch-export-coverage.js';
|
|
5
|
+
export { mutatePatchMetadata, updatePatchMetadata } from './patch-export-metadata.js';
|
|
6
6
|
export { updatePatchAndMetadata } from './patch-export-update.js';
|
|
7
7
|
/**
|
|
8
8
|
* Gets the next patch number for a new patch.
|
|
@@ -10,7 +10,7 @@ import { findAllPatchesForFilesWithDetails, } from './patch-export-coverage.js';
|
|
|
10
10
|
import { addPatchToManifest, loadPatchesManifestForWrite, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest.js';
|
|
11
11
|
import { allocatePolicyOrder, enforcePatchPolicy } from './patch-policy.js';
|
|
12
12
|
export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, } from './patch-export-coverage.js';
|
|
13
|
-
export { mutatePatchMetadata, updatePatchMetadata
|
|
13
|
+
export { mutatePatchMetadata, updatePatchMetadata } from './patch-export-metadata.js';
|
|
14
14
|
export { updatePatchAndMetadata } from './patch-export-update.js';
|
|
15
15
|
/**
|
|
16
16
|
* Gets the next patch number for a new patch.
|
|
@@ -63,7 +63,7 @@ export interface GroupedCheckJsResult {
|
|
|
63
63
|
* @param mode - Strictness preset plus allowlisted compiler-option overrides
|
|
64
64
|
* @returns Diagnostics grouped per owning file plus run-level errors
|
|
65
65
|
*/
|
|
66
|
-
export declare function runCheckJsGrouped(repoDir: string, resolutionOwned: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode): Promise<GroupedCheckJsResult>;
|
|
66
|
+
export declare function runCheckJsGrouped(repoDir: string, resolutionOwned: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode, builtinShimSuffix?: string): Promise<GroupedCheckJsResult>;
|
|
67
67
|
/**
|
|
68
68
|
* Flattens a {@link runCheckJsGrouped} run into a single issue list. When
|
|
69
69
|
* `reportScope` is supplied, only diagnostics from files in that set are
|
|
@@ -93,6 +93,24 @@ export declare function runCheckJs(repoDir: string, patchOwnedFiles: Set<string>
|
|
|
93
93
|
* not surface other patches' diagnostics)
|
|
94
94
|
*/
|
|
95
95
|
export declare function invokePatchLintCheckJs(repoDir: string, patchOwnedFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string, reportScope?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
|
|
96
|
+
/**
|
|
97
|
+
* Runs the checkJs pass over patch-owned test `.js` files (FORGE G5,
|
|
98
|
+
* `patchLint.checkJsTestFiles`). Each test file gets its OWN small
|
|
99
|
+
* program — mochitest scripts share top-level scope only with their
|
|
100
|
+
* directory's `head*.js` helpers, so one multi-script program would emit
|
|
101
|
+
* false cross-file redeclaration errors — with roots = the test file plus
|
|
102
|
+
* any same-directory patch-owned `head*.js`, checked against the built-in
|
|
103
|
+
* Firefox shim + {@link TEST_HARNESS_SHIM} + the optional consumer
|
|
104
|
+
* `checkJsTestShim`. Only the target file's diagnostics are reported per
|
|
105
|
+
* program (head helpers report from their own program), and run-level
|
|
106
|
+
* errors are de-duplicated across programs.
|
|
107
|
+
*
|
|
108
|
+
* @param repoDir - Absolute engine (repository) directory
|
|
109
|
+
* @param testFiles - Patch-owned test-script paths (repo-relative)
|
|
110
|
+
* @param patchLint - The resolved `patchLint` config block
|
|
111
|
+
* @param projectRoot - FireForge project root for shim resolution
|
|
112
|
+
*/
|
|
113
|
+
export declare function runCheckJsTestFilesGrouped(repoDir: string, testFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string): Promise<GroupedCheckJsResult>;
|
|
96
114
|
/**
|
|
97
115
|
* Grouped variant of {@link invokePatchLintCheckJs}: builds one queue-wide
|
|
98
116
|
* checkJs program over `patchOwnedFiles` and returns its findings grouped by
|