@hominis/fireforge 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/export-flow.js +14 -6
  5. package/dist/src/commands/export-shared.js +14 -3
  6. package/dist/src/commands/lint-per-patch.js +83 -14
  7. package/dist/src/commands/lint-report.d.ts +21 -0
  8. package/dist/src/commands/lint-report.js +62 -0
  9. package/dist/src/commands/lint.js +22 -18
  10. package/dist/src/commands/manifest.js +2 -0
  11. package/dist/src/commands/patch/rename.js +1 -1
  12. package/dist/src/commands/patch/staged-dependency.js +102 -14
  13. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  14. package/dist/src/commands/re-export-adjacent.js +106 -0
  15. package/dist/src/commands/re-export-options.js +3 -0
  16. package/dist/src/commands/re-export.js +42 -53
  17. package/dist/src/commands/run.js +18 -2
  18. package/dist/src/commands/status-check.d.ts +35 -0
  19. package/dist/src/commands/status-check.js +88 -0
  20. package/dist/src/commands/status.js +16 -5
  21. package/dist/src/commands/test-run.js +18 -3
  22. package/dist/src/commands/token.js +4 -1
  23. package/dist/src/commands/tree.d.ts +16 -0
  24. package/dist/src/commands/tree.js +174 -0
  25. package/dist/src/core/config-validate.js +17 -0
  26. package/dist/src/core/lint-cache.d.ts +15 -5
  27. package/dist/src/core/lint-cache.js +17 -5
  28. package/dist/src/core/patch-export.d.ts +2 -2
  29. package/dist/src/core/patch-export.js +1 -1
  30. package/dist/src/core/patch-lint-checkjs.d.ts +19 -1
  31. package/dist/src/core/patch-lint-checkjs.js +56 -3
  32. package/dist/src/core/patch-lint-css.js +64 -16
  33. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  34. package/dist/src/core/patch-lint-diff.js +31 -0
  35. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  36. package/dist/src/core/patch-lint-ownership.js +30 -0
  37. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  38. package/dist/src/core/patch-lint.d.ts +23 -0
  39. package/dist/src/core/patch-lint.js +30 -2
  40. package/dist/src/core/patch-manifest.d.ts +1 -1
  41. package/dist/src/core/smoke-patterns.d.ts +24 -8
  42. package/dist/src/core/smoke-patterns.js +23 -12
  43. package/dist/src/core/status-classify.d.ts +16 -0
  44. package/dist/src/core/status-classify.js +20 -6
  45. package/dist/src/core/token-category.d.ts +40 -0
  46. package/dist/src/core/token-category.js +264 -0
  47. package/dist/src/core/token-manager.d.ts +7 -0
  48. package/dist/src/core/token-manager.js +48 -196
  49. package/dist/src/core/tree-cow.d.ts +18 -0
  50. package/dist/src/core/tree-cow.js +71 -0
  51. package/dist/src/core/tree-guard.d.ts +35 -0
  52. package/dist/src/core/tree-guard.js +124 -0
  53. package/dist/src/core/tree-store.d.ts +53 -0
  54. package/dist/src/core/tree-store.js +214 -0
  55. package/dist/src/core/typecheck-shim.d.ts +14 -0
  56. package/dist/src/core/typecheck-shim.js +41 -2
  57. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  58. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  59. package/dist/src/errors/git.d.ts +8 -0
  60. package/dist/src/errors/git.js +17 -0
  61. package/dist/src/index.d.ts +2 -0
  62. package/dist/src/index.js +1 -0
  63. package/dist/src/types/commands/options.d.ts +33 -2
  64. package/dist/src/types/config.d.ts +19 -0
  65. package/dist/src/utils/validation.d.ts +10 -0
  66. package/dist/src/utils/validation.js +29 -0
  67. package/package.json +6 -6
@@ -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 renderJsonStatus(files, paths, projectRoot, binaryName) {
90
+ async function classifyStatusFiles(files, paths, projectRoot, binaryName) {
90
91
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
91
- const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
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 renderJsonStatus(files, paths, projectRoot, config.binaryName);
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 furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
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 = ctx.env
47
- ? await dispatch(ctx.engineDir, paths, extraArgs, ctx.env)
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, ctx.env, dispatch);
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
- info(`Token ${tokenName} already exists (skipped)`);
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 = 1;
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
- /** Returns cached issues for a patch when the stored key still matches. */
40
- export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): PatchLintIssue[] | undefined;
41
- /** Stores per-patch lint issues after a successful lint calculation. */
42
- export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[]): void;
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
- export const LINT_CACHE_SCHEMA_VERSION = 1;
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 issues for a patch when the stored key still matches. */
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 entry.issues.map((issue) => ({ ...issue }));
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 issues after a successful lint calculation. */
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 PatchCoverage, type SupersedeCoverageDetail, } from './patch-export-coverage.js';
5
- export { type ClearablePatchMetadataField, mutatePatchMetadata, type PatchMetadataMutation, type PatchMetadataMutationResult, updatePatchMetadata, } from './patch-export-metadata.js';
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, } from './patch-export-metadata.js';
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