@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
|
@@ -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.', '
|
|
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.', '
|
|
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}").`, '
|
|
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.', '
|
|
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.', '
|
|
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
|
|
46
|
+
function requireForwardImportOptions(options) {
|
|
40
47
|
if (!options.file || !options.specifier || !options.creates) {
|
|
41
|
-
throw new InvalidArgumentError(
|
|
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
|
|
65
|
+
function requireRegistrationOptions(options) {
|
|
55
66
|
if (!options.file || !options.line || !options.creates) {
|
|
56
|
-
throw new InvalidArgumentError(
|
|
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
|
-
|
|
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
|
-
:
|
|
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', '
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|
package/dist/src/commands/run.js
CHANGED
|
@@ -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,
|
|
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
|
|
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;
|