@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
@@ -10,35 +10,46 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
10
10
  import { toError } from '../../utils/errors.js';
11
11
  import { info, intro, outro, warn } from '../../utils/logger.js';
12
12
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
13
+ /** Joins the names of the missing flags for error attribution (FORGE G12). */
14
+ function missingFlagList(pairs) {
15
+ return pairs
16
+ .filter(([, value]) => value === undefined || value === '')
17
+ .map(([flag]) => flag)
18
+ .join(', ');
19
+ }
13
20
  function modeFromOptions(options) {
14
21
  const adding = options.add === true;
15
22
  const removing = options.remove === true;
16
23
  const clearing = options.clear === true;
17
24
  const modeCount = [adding, removing, clearing].filter(Boolean).length;
18
25
  if (modeCount > 1) {
19
- throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch staged-dependency');
26
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', '--add/--remove/--clear');
20
27
  }
21
28
  if (modeCount === 0) {
22
- throw new InvalidArgumentError('Specify --add, --remove, or --clear.', 'patch staged-dependency');
29
+ throw new InvalidArgumentError('Specify --add, --remove, or --clear.', '--add/--remove/--clear');
23
30
  }
24
31
  return adding ? 'add' : removing ? 'remove' : 'clear';
25
32
  }
26
33
  function kindFromOptions(options) {
27
34
  const kind = options.kind ?? 'import';
28
35
  if (kind !== 'import' && kind !== 'registration') {
29
- throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, 'patch staged-dependency');
36
+ throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, '--kind');
30
37
  }
31
38
  if (kind === 'import' && options.line !== undefined) {
32
- throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', 'patch staged-dependency');
39
+ throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', '--line');
33
40
  }
34
41
  if (kind === 'registration' && options.specifier !== undefined) {
35
- throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', 'patch staged-dependency');
42
+ throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', '--specifier');
36
43
  }
37
44
  return kind;
38
45
  }
39
- function requireForwardImportOptions(options, mode) {
46
+ function requireForwardImportOptions(options) {
40
47
  if (!options.file || !options.specifier || !options.creates) {
41
- throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
48
+ throw new InvalidArgumentError('--add requires --file, --specifier, and --creates.', missingFlagList([
49
+ ['--file', options.file],
50
+ ['--specifier', options.specifier],
51
+ ['--creates', options.creates],
52
+ ]));
42
53
  }
43
54
  const dependency = {
44
55
  file: options.file,
@@ -51,9 +62,13 @@ function requireForwardImportOptions(options, mode) {
51
62
  dependency.reason = options.reason;
52
63
  return dependency;
53
64
  }
54
- function requireRegistrationOptions(options, mode) {
65
+ function requireRegistrationOptions(options) {
55
66
  if (!options.file || !options.line || !options.creates) {
56
- throw new InvalidArgumentError(`--${mode} --kind registration requires --file, --line, and --creates.`, 'patch staged-dependency');
67
+ throw new InvalidArgumentError('--add --kind registration requires --file, --line, and --creates.', missingFlagList([
68
+ ['--file', options.file],
69
+ ['--line', options.line],
70
+ ['--creates', options.creates],
71
+ ]));
57
72
  }
58
73
  const dependency = {
59
74
  file: options.file,
@@ -66,6 +81,70 @@ function requireRegistrationOptions(options, mode) {
66
81
  dependency.reason = options.reason;
67
82
  return dependency;
68
83
  }
84
+ /**
85
+ * Resolves the `--remove` target for forward-imports (FORGE G12):
86
+ * `--file` + `--specifier` suffice when they identify at most one staged
87
+ * entry — `--creates` is inferred from a unique match, an ambiguous match
88
+ * refuses with the candidate list, and no match falls through to the
89
+ * honest "no staged forward-import matched" summary.
90
+ */
91
+ function resolveImportRemovalTarget(options, staged) {
92
+ if (!options.file || !options.specifier) {
93
+ throw new InvalidArgumentError('--remove requires --file and --specifier; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
94
+ ['--file', options.file],
95
+ ['--specifier', options.specifier],
96
+ ]));
97
+ }
98
+ if (options.creates) {
99
+ return requireForwardImportOptions(options);
100
+ }
101
+ const { file, specifier } = options;
102
+ const candidates = (staged?.forwardImports ?? []).filter((entry) => entry.file === file &&
103
+ entry.specifier === specifier &&
104
+ (options.owner === undefined || entry.owner === options.owner));
105
+ const single = candidates.length === 1 ? candidates[0] : undefined;
106
+ if (single !== undefined) {
107
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
108
+ return { ...single };
109
+ }
110
+ if (candidates.length > 1) {
111
+ const list = candidates
112
+ .map((entry) => ` - ${dependencyLabel('import', importView(entry))}`)
113
+ .join('\n');
114
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged forward-imports on this patch; ` +
115
+ `pass --creates to pick one:\n${list}`);
116
+ }
117
+ return { file, specifier, creates: '(no match)' };
118
+ }
119
+ /** Registration twin of {@link resolveImportRemovalTarget}, keyed on `--line`. */
120
+ function resolveRegistrationRemovalTarget(options, staged) {
121
+ if (!options.file || !options.line) {
122
+ throw new InvalidArgumentError('--remove --kind registration requires --file and --line; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
123
+ ['--file', options.file],
124
+ ['--line', options.line],
125
+ ]));
126
+ }
127
+ if (options.creates) {
128
+ return requireRegistrationOptions(options);
129
+ }
130
+ const { file, line } = options;
131
+ const candidates = (staged?.registrations ?? []).filter((entry) => entry.file === file &&
132
+ entry.line === line &&
133
+ (options.owner === undefined || entry.owner === options.owner));
134
+ const single = candidates.length === 1 ? candidates[0] : undefined;
135
+ if (single !== undefined) {
136
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
137
+ return { ...single };
138
+ }
139
+ if (candidates.length > 1) {
140
+ const list = candidates
141
+ .map((entry) => ` - ${dependencyLabel('registration', registrationView(entry))}`)
142
+ .join('\n');
143
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged registrations on this patch; ` +
144
+ `pass --creates to pick one:\n${list}`);
145
+ }
146
+ return { file, line, creates: '(no match)' };
147
+ }
69
148
  function importView(dependency) {
70
149
  return { ...dependency, matcher: dependency.specifier };
71
150
  }
@@ -137,17 +216,26 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
137
216
  intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
138
217
  const mode = modeFromOptions(options);
139
218
  const kind = kindFromOptions(options);
140
- const importDependency = mode === 'clear' || kind !== 'import' ? undefined : requireForwardImportOptions(options, mode);
219
+ // The patch queue loads BEFORE the declaration fields resolve so
220
+ // `--remove` can infer `--creates` from the target patch's staged
221
+ // entries (FORGE G12).
222
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
223
+ const targetPatch = requirePatchTarget(identifier, manifest.patches);
224
+ const importDependency = mode === 'clear' || kind !== 'import'
225
+ ? undefined
226
+ : mode === 'add'
227
+ ? requireForwardImportOptions(options)
228
+ : resolveImportRemovalTarget(options, targetPatch.stagedDependencies);
141
229
  const registrationDependency = mode === 'clear' || kind !== 'registration'
142
230
  ? undefined
143
- : requireRegistrationOptions(options, mode);
231
+ : mode === 'add'
232
+ ? requireRegistrationOptions(options)
233
+ : resolveRegistrationRemovalTarget(options, targetPatch.stagedDependencies);
144
234
  const target = importDependency !== undefined
145
235
  ? importView(importDependency)
146
236
  : registrationDependency !== undefined
147
237
  ? registrationView(registrationDependency)
148
238
  : undefined;
149
- const { paths, manifest } = await requirePatchQueue(projectRoot);
150
- const targetPatch = requirePatchTarget(identifier, manifest.patches);
151
239
  const applyToStaged = (staged) => {
152
240
  if (mode === 'clear')
153
241
  return {};
@@ -219,7 +307,7 @@ export function registerPatchStagedDependency(parent, context) {
219
307
  .option('--owner <patch>', 'Exact later patch filename expected to create --creates')
220
308
  .option('--reason <text>', 'Human-readable rationale stored with the declaration')
221
309
  .option('--dry-run', 'Show what would change without writing')
222
- .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
310
+ .option('-y, --yes', 'Record scripted consent in the destructive-operation history log. This command never prompts; the flag exists for workflow uniformity with commands that do.')
223
311
  .action(withErrorHandling(async (name, options) => {
224
312
  await patchStagedDependencyCommand(getProjectRoot(), name, options);
225
313
  }));
@@ -0,0 +1,34 @@
1
+ import type { getProjectPaths } from '../core/config.js';
2
+ import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
3
+ /**
4
+ * Shared per-run context for the adjacency advisory: the classification
5
+ * inputs computed once by `reExportCommand`, plus the collection of
6
+ * patches refused under `--refuse-adjacent-unmanaged` (collected across
7
+ * the loop so a multi-patch run reports every offender in one refusal).
8
+ */
9
+ export interface AdjacentUnmanagedContext {
10
+ binaryName: string;
11
+ furnacePrefixes: ReadonlySet<string>;
12
+ refuseAdjacentUnmanaged: boolean;
13
+ refusals: {
14
+ patchFilename: string;
15
+ files: string[];
16
+ }[];
17
+ }
18
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
19
+ export declare function findMissingFiles(engineDir: string, files: readonly string[]): Promise<string[]>;
20
+ /**
21
+ * Emits the scan-less advisory for one patch: missing manifest files and
22
+ * unmanaged files adjacent to the patch's ownership. When
23
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
24
+ * exists, records the patch into `ctx.refusals` and returns true — the
25
+ * caller then skips the write for this patch and converts the collection
26
+ * into one run-level non-zero refusal after the loop.
27
+ */
28
+ export declare function reportAdjacentUnmanagedFiles(args: {
29
+ patch: PatchMetadata;
30
+ paths: ReturnType<typeof getProjectPaths>;
31
+ manifest: PatchesManifest;
32
+ currentFilesAffected: readonly string[];
33
+ ctx: AdjacentUnmanagedContext;
34
+ }): Promise<boolean>;
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Scan-less re-export adjacency advisory (FORGE G2). A plain `re-export`
4
+ * keeps the manifest's filesAffected unchanged, so a brand-new file an
5
+ * author just created beside a patch's owned files is silently left out
6
+ * of the refreshed body. This module detects such adjacent files, filters
7
+ * out tool-managed paths (branding, furnace) so the notice names only
8
+ * genuinely unmanaged candidates, and — under
9
+ * `--refuse-adjacent-unmanaged` — lets the caller turn the notice into a
10
+ * non-zero refusal.
11
+ *
12
+ * Split out of re-export.ts to keep that file inside the max-lines
13
+ * budget; exports helpers consumed by re-export.ts, no registrar.
14
+ */
15
+ import { dirname, join } from 'node:path';
16
+ import { isBrandingManagedPath } from '../core/branding.js';
17
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
18
+ import { getClaimedFiles } from '../core/patch-manifest.js';
19
+ import { isGeneratedBrandingPath } from '../core/status-classify.js';
20
+ import { pathExists } from '../utils/fs.js';
21
+ import { info, warn } from '../utils/logger.js';
22
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
23
+ export async function findMissingFiles(engineDir, files) {
24
+ const missingFiles = [];
25
+ for (const file of files) {
26
+ if (!(await pathExists(join(engineDir, file))))
27
+ missingFiles.push(file);
28
+ }
29
+ return missingFiles;
30
+ }
31
+ function isToolManagedPath(file, isNewFile, ctx) {
32
+ // Mirrors status-classify: a generated branding path is tool-managed
33
+ // outright; a brand-new unowned file under the branding root is still a
34
+ // patch candidate (the Assets.car precedent) and must stay listed.
35
+ if (isBrandingManagedPath(file, ctx.binaryName) &&
36
+ (!isNewFile || isGeneratedBrandingPath(file, ctx.binaryName))) {
37
+ return true;
38
+ }
39
+ for (const prefix of ctx.furnacePrefixes) {
40
+ if (file.startsWith(prefix))
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ async function findAdjacentUnmanagedFiles(args) {
46
+ const { currentFilesAffected, engineDir, manifest, patchFilename, ctx } = args;
47
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
48
+ const currentSet = new Set(currentFilesAffected);
49
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
50
+ const candidates = new Set();
51
+ for (const dir of parentDirs) {
52
+ const [modifiedFiles, untrackedFiles] = await Promise.all([
53
+ getModifiedFilesInDir(engineDir, dir),
54
+ getUntrackedFilesInDir(engineDir, dir),
55
+ ]);
56
+ const untrackedSet = new Set(untrackedFiles);
57
+ for (const file of [...modifiedFiles, ...untrackedFiles]) {
58
+ if (currentSet.has(file) || claimedByOthers.has(file))
59
+ continue;
60
+ if (isToolManagedPath(file, untrackedSet.has(file), ctx))
61
+ continue;
62
+ candidates.add(file);
63
+ }
64
+ }
65
+ return [...candidates].sort();
66
+ }
67
+ /**
68
+ * Emits the scan-less advisory for one patch: missing manifest files and
69
+ * unmanaged files adjacent to the patch's ownership. When
70
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
71
+ * exists, records the patch into `ctx.refusals` and returns true — the
72
+ * caller then skips the write for this patch and converts the collection
73
+ * into one run-level non-zero refusal after the loop.
74
+ */
75
+ export async function reportAdjacentUnmanagedFiles(args) {
76
+ const { patch, paths, manifest, currentFilesAffected, ctx } = args;
77
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
78
+ if (missingFiles.length > 0) {
79
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
80
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
81
+ `filesAffected unchanged and the missing entries will be preserved — ` +
82
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
83
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
84
+ `or pass --files <paths> to set the list explicitly.`);
85
+ }
86
+ const unmanagedFiles = await findAdjacentUnmanagedFiles({
87
+ currentFilesAffected,
88
+ engineDir: paths.engine,
89
+ manifest,
90
+ patchFilename: patch.filename,
91
+ ctx,
92
+ });
93
+ if (unmanagedFiles.length === 0)
94
+ return false;
95
+ const firstFew = unmanagedFiles.slice(0, 3).join(', ');
96
+ const more = unmanagedFiles.length > 3 ? `, +${String(unmanagedFiles.length - 3)} more` : '';
97
+ warn(`${patch.filename}: found ${String(unmanagedFiles.length)} unmanaged file(s) adjacent to this patch's ownership (${firstFew}${more}); plain re-export keeps filesAffected unchanged — use --scan-file to include reviewed files.`);
98
+ for (const file of unmanagedFiles) {
99
+ info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
100
+ }
101
+ if (ctx.refuseAdjacentUnmanaged) {
102
+ ctx.refusals.push({ patchFilename: patch.filename, files: unmanagedFiles });
103
+ return true;
104
+ }
105
+ return false;
106
+ }
@@ -77,6 +77,9 @@ export function validateReExportOptionCombinations(patches, options) {
77
77
  throw new InvalidArgumentError('--scan-files selects patches from its manifest and cannot be combined with positional patches or --all.', '--scan-files');
78
78
  }
79
79
  }
80
+ if (options.refuseAdjacentUnmanaged === true && (options.scan || options.files !== undefined)) {
81
+ throw new InvalidArgumentError('--refuse-adjacent-unmanaged applies to the scan-less path only and cannot be combined with --scan or --files (those set filesAffected explicitly).', '--refuse-adjacent-unmanaged');
82
+ }
80
83
  const usingTierFlag = options.tier !== undefined;
81
84
  const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
82
85
  if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
@@ -1,81 +1,52 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /* eslint-disable max-lines -- Re-export is at the guardrail; splitting register plumbing is unrelated to this fix. */
3
- import { dirname, join } from 'node:path';
4
3
  import { multiselect } from '@clack/prompts';
5
4
  import { Command, Option } from 'commander';
6
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
7
6
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
7
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
8
8
  import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
9
9
  import { isGitRepository } from '../core/git.js';
10
10
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
- import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
12
11
  import { updatePatchAndMetadata } from '../core/patch-export.js';
13
12
  import { buildPatchQueueContext } from '../core/patch-lint.js';
14
- import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
13
+ import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
15
14
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
16
15
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
16
+ import { isGitIndexLockConflict } from '../errors/git.js';
17
17
  import { elapsedSince } from '../utils/elapsed.js';
18
18
  import { toError } from '../utils/errors.js';
19
19
  import { pathExists } from '../utils/fs.js';
20
20
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
21
21
  import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../utils/options.js';
22
22
  import { runPatchLint } from './export-shared.js';
23
+ import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
23
24
  import { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
24
25
  import { reExportFilesInPlace } from './re-export-files.js';
25
26
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
26
27
  import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
27
- async function findMissingFiles(engineDir, files) {
28
- const missingFiles = [];
29
- for (const file of files) {
30
- if (!(await pathExists(join(engineDir, file))))
31
- missingFiles.push(file);
28
+ const GIT_INDEX_LOCK_RETRY_MS = 300;
29
+ /**
30
+ * Retries a single patch re-export exactly once when the failure is
31
+ * transient git `index.lock` contention (an external git process holding
32
+ * the engine repo's index). The retry re-enters `reExportSinglePatch`
33
+ * from the top, so it re-reads clean state; a second lock failure
34
+ * propagates to the loop's honest "N of M" accounting.
35
+ */
36
+ async function reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, options, isDryRun, config, adjacentCtx) {
37
+ try {
38
+ return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx);
32
39
  }
33
- return missingFiles;
34
- }
35
- async function findLikelyNewSiblingFiles(args) {
36
- const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
37
- const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
38
- const currentSet = new Set(currentFilesAffected);
39
- const claimedByOthers = getClaimedFiles(manifest, patchFilename);
40
- const candidates = new Set();
41
- for (const dir of parentDirs) {
42
- const [modifiedFiles, untrackedFiles] = await Promise.all([
43
- getModifiedFilesInDir(engineDir, dir),
44
- getUntrackedFilesInDir(engineDir, dir),
45
- ]);
46
- for (const file of [...modifiedFiles, ...untrackedFiles]) {
47
- if (currentSet.has(file) || claimedByOthers.has(file))
48
- continue;
49
- candidates.add(file);
40
+ catch (error) {
41
+ if (!isGitIndexLockConflict(error)) {
42
+ throw error;
50
43
  }
44
+ warn(`${patch.filename}: git index.lock contention detected — retrying once after ${String(GIT_INDEX_LOCK_RETRY_MS)} ms...`);
45
+ await new Promise((resolve) => setTimeout(resolve, GIT_INDEX_LOCK_RETRY_MS));
46
+ return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx);
51
47
  }
52
- return [...candidates].sort();
53
48
  }
54
- async function warnPlainReExportFileDrift(args) {
55
- const { patch, paths, manifest, currentFilesAffected } = args;
56
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
57
- if (missingFiles.length > 0) {
58
- warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
59
- `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
60
- `filesAffected unchanged and the missing entries will be preserved — ` +
61
- `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
62
- ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
63
- `or pass --files <paths> to set the list explicitly.`);
64
- }
65
- const likelyNewFiles = await findLikelyNewSiblingFiles({
66
- currentFilesAffected,
67
- engineDir: paths.engine,
68
- manifest,
69
- patchFilename: patch.filename,
70
- });
71
- if (likelyNewFiles.length === 0)
72
- return;
73
- warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
74
- for (const file of likelyNewFiles) {
75
- info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
76
- }
77
- }
78
- async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
49
+ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx) {
79
50
  let currentFilesAffected = [...patch.filesAffected];
80
51
  // --- Scan for new/removed files ---
81
52
  if (options.scan) {
@@ -123,7 +94,9 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
123
94
  // warning up-front when we can detect the drift cheaply, so the
124
95
  // operator has a chance to re-run with `--scan` or `--files`
125
96
  // before the stale filesAffected lands in patches.json.
126
- await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
97
+ const args = { patch, paths, manifest, currentFilesAffected, ctx: adjacentCtx };
98
+ if (await reportAdjacentUnmanagedFiles(args))
99
+ return false;
127
100
  }
128
101
  // --- Explicit file-subset path ---
129
102
  // When --files is given, the target filesAffected is authoritative — drop
@@ -359,6 +332,14 @@ export async function reExportCommand(projectRoot, patches, options) {
359
332
  return;
360
333
  }
361
334
  const config = await loadConfig(projectRoot);
335
+ // Classification inputs for the scan-less adjacency advisory, computed
336
+ // once for the whole run (FORGE G2).
337
+ const adjacentCtx = {
338
+ binaryName: config.binaryName,
339
+ furnacePrefixes: await collectFurnaceManagedPrefixes(paths.root),
340
+ refuseAdjacentUnmanaged: options.refuseAdjacentUnmanaged === true,
341
+ refusals: [],
342
+ };
362
343
  let reExported = 0;
363
344
  const reExportedFilenames = [];
364
345
  const progress = spinner('Preparing re-export...');
@@ -369,7 +350,7 @@ export async function reExportCommand(projectRoot, patches, options) {
369
350
  const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
370
351
  progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
371
352
  try {
372
- const exported = await reExportSinglePatch(patch, paths, manifest, patchOptions, isDryRun, config);
353
+ const exported = await reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, patchOptions, isDryRun, config, adjacentCtx);
373
354
  if (exported) {
374
355
  reExported++;
375
356
  reExportedFilenames.push(patch.filename);
@@ -381,6 +362,13 @@ export async function reExportCommand(projectRoot, patches, options) {
381
362
  }
382
363
  }
383
364
  });
365
+ if (adjacentCtx.refusals.length > 0) {
366
+ progress.error('Re-export refused');
367
+ const names = adjacentCtx.refusals.map((r) => r.patchFilename).join(', ');
368
+ throw new GeneralError(`Refused ${String(adjacentCtx.refusals.length)} patch(es) with adjacent unmanaged files ` +
369
+ `(--refuse-adjacent-unmanaged): ${names}. Adopt reviewed files with --scan --scan-file, ` +
370
+ `or export them as a new patch.`);
371
+ }
384
372
  if (reExported === 0 && selectedPatches.length > 0) {
385
373
  progress.error('Re-export failed');
386
374
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
@@ -431,6 +419,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
431
419
  .split(',')
432
420
  .map((v) => v.trim())
433
421
  .filter((v) => v.length > 0))
422
+ .option('--refuse-adjacent-unmanaged', 'Refuse a scan-less re-export (non-zero exit, patch not written) when unmanaged files exist adjacent to the patch ownership, instead of warning. Mutually exclusive with --scan and --files.')
434
423
  .option('--dry-run', 'Show what would change without writing')
435
424
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
436
425
  .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
@@ -6,7 +6,7 @@ import { Command } from 'commander';
6
6
  import { getProjectPaths, loadConfig } from '../core/config.js';
7
7
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
8
8
  import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
9
- import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
9
+ import { compileAllowlistFromFile, compileAllowlistFromStrings, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
10
10
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
12
12
  import { ExitCode } from '../errors/codes.js';
@@ -186,6 +186,7 @@ async function runSmokeExit(engineDir, options) {
186
186
  const findings = [];
187
187
  let allowlistedErrorHits = 0;
188
188
  let allowlistedTotalHits = 0;
189
+ const allowlistHits = new Array(allowlist.length).fill(0);
189
190
  const handleLine = (stream, line) => {
190
191
  // Mirror raw output to the terminal so operators watching the smoke
191
192
  // run still see what the browser is printing. Stream selection on the
@@ -201,9 +202,11 @@ async function runSmokeExit(engineDir, options) {
201
202
  // total set of allowlisted lines (what the operator sees in the
202
203
  // console) and the subset that were error-class (what the smoke
203
204
  // exit contract cares about). The exit contract itself is unchanged.
204
- const isAllowlisted = allowlist.length > 0 && matchesAllowlist(line, allowlist);
205
+ const matchIndex = allowlist.length > 0 ? matchAllowlist(line, allowlist) : -1;
206
+ const isAllowlisted = matchIndex !== -1;
205
207
  if (isAllowlisted) {
206
208
  allowlistedTotalHits += 1;
209
+ allowlistHits[matchIndex] = (allowlistHits[matchIndex] ?? 0) + 1;
207
210
  }
208
211
  if (!matchesSmokeError(line))
209
212
  return;
@@ -249,6 +252,8 @@ async function runSmokeExit(engineDir, options) {
249
252
  timedOut: result.timedOut,
250
253
  allowlistedErrorHits,
251
254
  allowlistedTotalHits,
255
+ allowlist,
256
+ allowlistHits,
252
257
  findings,
253
258
  exitCode: result.exitCode,
254
259
  });
@@ -314,6 +319,17 @@ function reportSmokeSummary(args) {
314
319
  info(` Allowlisted error hits (suppressed): ${String(args.allowlistedErrorHits)}`);
315
320
  info(` Allowlisted lines total: ${String(args.allowlistedTotalHits)}`);
316
321
  info(` Child exit code: ${String(args.exitCode)}`);
322
+ // Per-entry attribution (FORGE G8): first-match credit per line, with
323
+ // zero-hit entries always visible — an allowlist entry whose suppressed
324
+ // shape changed upstream is only detectable as a 0× row.
325
+ if (args.allowlist.length > 0) {
326
+ info(' Allowlist attribution (first matching entry per line):');
327
+ args.allowlist.forEach((entry, index) => {
328
+ const hits = args.allowlistHits[index] ?? 0;
329
+ const zeroSuffix = hits === 0 ? ' (never matched — candidate for removal)' : '';
330
+ info(` ${String(hits)}× ${entry.origin} ${entry.source}${zeroSuffix}`);
331
+ });
332
+ }
317
333
  if (args.findings.length === 0)
318
334
  return;
319
335
  warn('');
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `status --check` enforcement policy (FORGE G1). Turns the classified
3
+ * status view into a CI-enforceable non-zero exit: the default policy
4
+ * fails on any classification outside {patch-backed, branding, furnace},
5
+ * and `--fail-on <class,...>` replaces that set for finer control.
6
+ *
7
+ * Helper module consumed by status.ts (which sits at the max-lines
8
+ * budget); no top-level registrar is exported and none is wanted.
9
+ */
10
+ import type { ClassifiedFile, FileClassification } from '../core/status-classify.js';
11
+ /** Resolved `--check`/`--fail-on` policy for one status invocation. */
12
+ export interface StatusCheckPolicy {
13
+ checkEnabled: boolean;
14
+ failOn: readonly FileClassification[];
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 declare function resolveStatusCheckPolicy(options: {
22
+ check?: boolean;
23
+ failOn?: string;
24
+ raw?: boolean;
25
+ unmanaged?: boolean;
26
+ ownership?: boolean;
27
+ testCoverage?: boolean;
28
+ }): StatusCheckPolicy;
29
+ /**
30
+ * Throws a GeneralError (exit 1) when the policy is enabled and any of
31
+ * its classifications is non-empty, naming each offending classification
32
+ * with its first few files. A disabled policy or a fully tool-covered
33
+ * tree passes silently.
34
+ */
35
+ export declare function runStatusCheck(classified: readonly ClassifiedFile[], policy: StatusCheckPolicy): void;