@hominis/fireforge 0.44.0 → 0.44.1

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 (33) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/README.md +2 -11
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  5. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  6. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  7. package/dist/src/commands/doctor.d.ts +1 -1
  8. package/dist/src/commands/doctor.js +48 -62
  9. package/dist/src/commands/furnace/diff.js +6 -1
  10. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  11. package/dist/src/commands/import.js +6 -2
  12. package/dist/src/core/build-audit-resolve.js +12 -3
  13. package/dist/src/core/config-mutate.js +20 -19
  14. package/dist/src/core/furnace-apply-ftl.js +4 -1
  15. package/dist/src/core/furnace-apply-helpers.js +11 -5
  16. package/dist/src/core/git-base.d.ts +14 -0
  17. package/dist/src/core/git-base.js +17 -0
  18. package/dist/src/core/git.js +3 -1
  19. package/dist/src/core/patch-lint-checkjs.js +25 -4
  20. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  21. package/dist/src/core/patch-manifest-consistency.js +96 -23
  22. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  23. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  24. package/dist/src/core/patch-manifest.d.ts +2 -1
  25. package/dist/src/core/patch-manifest.js +2 -1
  26. package/dist/src/core/token-category.js +61 -7
  27. package/dist/src/core/token-dark-mode.js +20 -2
  28. package/dist/src/core/token-docs.js +1 -1
  29. package/dist/src/core/token-variant.js +24 -2
  30. package/dist/src/core/tree-store.js +5 -1
  31. package/dist/src/core/typecheck.js +7 -1
  32. package/dist/src/types/commands/options.d.ts +23 -0
  33. package/package.json +2 -2
@@ -18,21 +18,16 @@ function cloneConfigDocument(config) {
18
18
  return cloned;
19
19
  }
20
20
  /**
21
- * Key segments that would walk into or rewrite the object prototype chain
22
- * if used as plain property names. Blocked up-front so the descent in
23
- * {@link mutateConfig} cannot be weaponized to mutate `Object.prototype`
24
- * process-wide — e.g. `fireforge config __proto__.polluted 1 --force`
25
- * would otherwise land in `getOrCreateChildRecord(raw, "__proto__")`.
21
+ * The error every sentinel-segment refusal raises. Only the MESSAGE lives
22
+ * here: the comparison itself is spelled inline at each write site, because
23
+ * that is what both a reader and CodeQL's `js/prototype-pollution-utility`
24
+ * barrier detection need — the latter is not interprocedural, so a check
25
+ * hidden behind a helper leaves the descent below looking unguarded.
26
26
  */
27
- const SENTINEL_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
28
- function assertNoSentinelSegments(key, parts) {
29
- for (const part of parts) {
30
- if (SENTINEL_KEY_SEGMENTS.has(part)) {
31
- throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
32
- 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
33
- 'because they would mutate the object prototype chain.');
34
- }
35
- }
27
+ function sentinelSegmentError(key, part) {
28
+ return new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
29
+ 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
30
+ 'because they would mutate the object prototype chain.');
36
31
  }
37
32
  function getOrCreateChildRecord(parent, key) {
38
33
  const existing = parent[key];
@@ -45,21 +40,27 @@ function getOrCreateChildRecord(parent, key) {
45
40
  }
46
41
  export function mutateConfig(config, key, value, skipValidation = false) {
47
42
  const parts = key.split('.');
48
- // Reject prototype-chain sentinel segments before any write so
49
- // `--force` cannot be used to mutate Object.prototype. This guard must
50
- // run against the original key parts, not any subset — the final leaf
51
- // assignment `current[lastPart] = value` would otherwise stay vulnerable.
52
- assertNoSentinelSegments(key, parts);
53
43
  const raw = cloneConfigDocument(config);
44
+ // Every segment is checked at the point it is USED as a property name —
45
+ // the descent below and the leaf assignment — rather than once up front,
46
+ // so `--force` cannot be used to mutate Object.prototype and neither write
47
+ // can drift out from behind the guard. `raw` is a private clone, so a
48
+ // refusal here has mutated nothing the caller can observe.
54
49
  let current = raw;
55
50
  for (let i = 0; i < parts.length - 1; i++) {
56
51
  const part = parts[i];
57
52
  if (part === undefined)
58
53
  continue;
54
+ if (part === '__proto__' || part === 'constructor' || part === 'prototype') {
55
+ throw sentinelSegmentError(key, part);
56
+ }
59
57
  current = getOrCreateChildRecord(current, part);
60
58
  }
61
59
  const lastPart = parts[parts.length - 1];
62
60
  if (lastPart !== undefined) {
61
+ if (lastPart === '__proto__' || lastPart === 'constructor' || lastPart === 'prototype') {
62
+ throw sentinelSegmentError(key, lastPart);
63
+ }
63
64
  current[lastPart] = value;
64
65
  }
65
66
  if (!skipValidation) {
@@ -14,6 +14,7 @@
14
14
  import { join, relative } from 'node:path';
15
15
  import { toError } from '../utils/errors.js';
16
16
  import { copyFile, pathExists, readText } from '../utils/fs.js';
17
+ import { normalizePathSlashes } from '../utils/paths.js';
17
18
  import { escapeRegex } from '../utils/regex.js';
18
19
  import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
19
20
  import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
@@ -133,7 +134,9 @@ export async function applyCustomFtlFile(ctx, affectedPaths, stepErrors, rollbac
133
134
  await snapshotFile(rollbackJournal, ftlDest);
134
135
  }
135
136
  await copyFile(ftlSrc, ftlDest);
136
- affectedPaths.push(relative(engineDir, ftlDest));
137
+ // Sibling pushes on this list are POSIX literals (`localeJarRel`); keep
138
+ // the computed one in the same spelling.
139
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, ftlDest)));
137
140
  const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
138
141
  const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
139
142
  if (chromeSubPath === undefined || localeJarRel === undefined)
@@ -6,6 +6,7 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { normalizePathSlashes } from '../utils/paths.js';
9
10
  import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
10
11
  import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
11
12
  import { normalizeForChecksum } from './furnace-checksum-utils.js';
@@ -70,7 +71,12 @@ export function getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir)
70
71
  * caller should treat that as a successful no-op rather than an error.
71
72
  */
72
73
  export async function restoreOverrideFileToBaseline(engineDir, enginePath, journal) {
73
- const relPath = relative(engineDir, enginePath);
74
+ // Engine-relative paths are IDENTIFIERS here, not filesystem arguments:
75
+ // they are handed to git, recorded in furnace state, and compared against
76
+ // the POSIX paths furnace.json and jar.mn already carry. `relative()`
77
+ // returns backslashes on Windows, so without this the same file has two
78
+ // spellings and every cross-check misses.
79
+ const relPath = normalizePathSlashes(relative(engineDir, enginePath));
74
80
  // Snapshot before mutation so a later rollback can undo both restoration
75
81
  // (writes whatever content we removed back) and deletion (recreates the
76
82
  // file). Snapshotting a missing path records `{ existed: false }`, which
@@ -129,7 +135,7 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
129
135
  }
130
136
  if (await pathExists(enginePath)) {
131
137
  await removeFile(enginePath);
132
- removed.push(relative(engineDir, enginePath));
138
+ removed.push(normalizePathSlashes(relative(engineDir, enginePath)));
133
139
  }
134
140
  // When an `.ftl` is deleted from the workspace the corresponding locale
135
141
  // jar.mn entry must also be dropped — otherwise the chrome URI points at
@@ -173,7 +179,7 @@ export async function undeployOverrideFiles(engineDir, config, deletedFiles, ftl
173
179
  for (const fileName of deletedFiles) {
174
180
  const enginePath = getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir);
175
181
  const action = await restoreOverrideFileToBaseline(engineDir, enginePath, rollbackJournal);
176
- const relPath = relative(engineDir, enginePath);
182
+ const relPath = normalizePathSlashes(relative(engineDir, enginePath));
177
183
  if (action === 'restored')
178
184
  restored.push(relPath);
179
185
  else if (action === 'removed')
@@ -332,7 +338,7 @@ export async function applyCustomComponent(ctx, config, dryRun = false, rollback
332
338
  const src = join(componentDir, entry.name);
333
339
  const dest = join(targetDir, entry.name);
334
340
  await deployFileWithFragments(src, dest, sharedDir);
335
- affectedPaths.push(relative(engineDir, dest));
341
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
336
342
  copiedFileNames.push(entry.name);
337
343
  }));
338
344
  // See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
@@ -425,7 +431,7 @@ export async function applyOverrideComponent(ctx, config, dryRun = false, rollba
425
431
  const src = join(componentDir, entry.name);
426
432
  const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
427
433
  await copyFile(src, dest);
428
- affectedPaths.push(relative(engineDir, dest));
434
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
429
435
  }));
430
436
  if (affectedPaths.length === 0) {
431
437
  throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
@@ -83,3 +83,17 @@ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): s
83
83
  * repositories with hundreds of thousands of files.
84
84
  */
85
85
  export declare function configureGitPerformance(repoDir: string): Promise<void>;
86
+ /**
87
+ * Pins the engine repository's line-ending policy to "store and check out
88
+ * exactly what upstream shipped".
89
+ *
90
+ * Git's Windows installer defaults to a global `core.autocrlf=true`, which
91
+ * rewrites LF to CRLF on checkout and back on staging. FireForge cannot
92
+ * tolerate that: patch bodies are byte diffs of the working tree, and
93
+ * `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
94
+ * `core.autocrlf` DO apply to the hashes `status` compares against. Under the
95
+ * global default the same tree hashes differently on Windows than everywhere
96
+ * else, so exported patches and drift detection disagree across machines.
97
+ * Setting it per-repository leaves the developer's global config alone.
98
+ */
99
+ export declare function configureGitLineEndings(repoDir: string): Promise<void>;
@@ -120,3 +120,20 @@ export async function configureGitPerformance(repoDir) {
120
120
  await git(['config', 'core.fsmonitor', 'false'], repoDir);
121
121
  await git(['config', 'feature.manyFiles', 'true'], repoDir);
122
122
  }
123
+ /**
124
+ * Pins the engine repository's line-ending policy to "store and check out
125
+ * exactly what upstream shipped".
126
+ *
127
+ * Git's Windows installer defaults to a global `core.autocrlf=true`, which
128
+ * rewrites LF to CRLF on checkout and back on staging. FireForge cannot
129
+ * tolerate that: patch bodies are byte diffs of the working tree, and
130
+ * `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
131
+ * `core.autocrlf` DO apply to the hashes `status` compares against. Under the
132
+ * global default the same tree hashes differently on Windows than everywhere
133
+ * else, so exported patches and drift detection disagree across machines.
134
+ * Setting it per-repository leaves the developer's global config alone.
135
+ */
136
+ export async function configureGitLineEndings(repoDir) {
137
+ await git(['config', 'core.autocrlf', 'false'], repoDir);
138
+ await git(['config', 'core.eol', 'lf'], repoDir);
139
+ }
@@ -9,7 +9,7 @@ import { pathExists, pathExistsStrict, removeFile } from '../utils/fs.js';
9
9
  import { verbose } from '../utils/logger.js';
10
10
  import { exec } from '../utils/process.js';
11
11
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
12
- import { chunkPathspecs, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
12
+ import { chunkPathspecs, configureGitLineEndings, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
13
13
  import { getWorkingTreeStatus } from './git-status.js';
14
14
  // ── Functions that remain in this file ──
15
15
  /**
@@ -278,6 +278,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
278
278
  // Enable performance settings for large trees
279
279
  reportProgress('Configuring git performance settings...');
280
280
  await configureGitPerformance(dir);
281
+ await configureGitLineEndings(dir);
281
282
  // Add a local-only origin remote so that Firefox's mach bootstrap and
282
283
  // build scripts (which shell out to `git remote get-url origin`) don't
283
284
  // fail. Nothing is ever fetched from or pushed to this remote.
@@ -318,6 +319,7 @@ export async function resumeRepository(dir, options = {}) {
318
319
  // Ensure performance settings are in place (may not have been set)
319
320
  reportProgress('Configuring git performance settings...');
320
321
  await configureGitPerformance(dir);
322
+ await configureGitLineEndings(dir);
321
323
  // Clean up any stale index lock left by the killed process
322
324
  await cleanupIndexLock(dir);
323
325
  // Ensure origin remote exists (may have been added before the interrupt)
@@ -29,10 +29,25 @@ import { basename, resolve } from 'node:path';
29
29
  import { toError } from '../utils/errors.js';
30
30
  import { pathExists } from '../utils/fs.js';
31
31
  import { verbose } from '../utils/logger.js';
32
+ import { normalizePathSlashes } from '../utils/paths.js';
32
33
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
33
34
  // ---------------------------------------------------------------------------
34
35
  // Public API
35
36
  // ---------------------------------------------------------------------------
37
+ /**
38
+ * Converts a native path into the form TypeScript uses for `fileName`.
39
+ *
40
+ * TypeScript normalizes every path it hands a `CompilerHost` to forward
41
+ * slashes on ALL platforms, while `resolve()` here yields the platform
42
+ * separator. On Windows that mismatch made every `ownedAbsolute.has(fileName)`
43
+ * and `relByAbsolute.get(fileName)` lookup miss, so the host served an empty
44
+ * source file for each owned file and the whole pass reported zero findings —
45
+ * silently, because "no diagnostics" is indistinguishable from "clean code".
46
+ * Every path that crosses the TS boundary goes through here.
47
+ */
48
+ function toTsPath(path) {
49
+ return normalizePathSlashes(path);
50
+ }
36
51
  /**
37
52
  * Builds the host-side module resolver for the checkJs pass: maps an import
38
53
  * specifier to a patch-owned absolute path when the specifier's final
@@ -117,8 +132,14 @@ function createPathsResolver(ts, paths, baseDir, fileExists, onResolved) {
117
132
  captured = specifier.slice(prefix.length, specifier.length - suffix.length);
118
133
  }
119
134
  for (const target of targets) {
120
- const rel = target.includes('*') ? target.replace('*', captured) : target;
121
- const abs = resolve(baseDir, rel);
135
+ // A `paths` target carries at most one `*`, and TypeScript substitutes
136
+ // that one wildcard — spelled as index arithmetic (mirroring the
137
+ // prefix/suffix split on the pattern side above) rather than
138
+ // `replace('*', …)`, whose first-occurrence-only behaviour reads as an
139
+ // incomplete rewrite (CodeQL `js/incomplete-sanitization`).
140
+ const star = target.indexOf('*');
141
+ const rel = star === -1 ? target : target.slice(0, star) + captured + target.slice(star + 1);
142
+ const abs = toTsPath(resolve(baseDir, rel));
122
143
  if (!fileExists(abs))
123
144
  continue;
124
145
  onResolved(abs);
@@ -188,7 +209,7 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
188
209
  const ownedAbsolute = new Set();
189
210
  const relByAbsolute = new Map();
190
211
  for (const rel of resolutionOwned) {
191
- const abs = resolve(repoDir, rel);
212
+ const abs = toTsPath(resolve(repoDir, rel));
192
213
  if (await pathExists(abs)) {
193
214
  if (rootScope === undefined || rootScope.has(rel))
194
215
  rootFiles.push(abs);
@@ -228,7 +249,7 @@ export async function runCheckJsGrouped(repoDir, resolutionOwned, extraShimPath,
228
249
  ],
229
250
  };
230
251
  }
231
- const shimPath = resolve(repoDir, SHIM_FILENAME);
252
+ const shimPath = toTsPath(resolve(repoDir, SHIM_FILENAME));
232
253
  rootFiles.push(shimPath);
233
254
  const strict = mode?.strict === true;
234
255
  const strictness = strict
@@ -15,6 +15,16 @@ export interface PatchManifestConsistencyIssue {
15
15
  * @returns Consistency issues between manifest metadata and on-disk patch files
16
16
  */
17
17
  export declare function validatePatchesManifestConsistency(patchesDir: string): Promise<PatchManifestConsistencyIssue[]>;
18
+ /**
19
+ * Recovery hint for an unrepaired consistency failure.
20
+ *
21
+ * Drift that is only in `filesAffected` is a derived value disagreeing with
22
+ * the diff it describes, and the narrow repair fixes exactly that. Naming the
23
+ * whole-manifest rebuild for it — as this hint used to, unconditionally — puts
24
+ * an operator one keystroke from rewriting every row in the manifest to
25
+ * correct one list.
26
+ */
27
+ export declare function recommendManifestRepair(issues: readonly PatchManifestConsistencyIssue[]): string;
18
28
  /**
19
29
  * Summary of a {@link rebuildPatchesManifest} run. `recoveredFilenames`
20
30
  * lists patches whose manifest entry was reconstructed from filename,
@@ -25,21 +35,61 @@ export declare function validatePatchesManifestConsistency(patchesDir: string):
25
35
  * invented vs which was restored.
26
36
  */
27
37
  export interface RebuildPatchesManifestResult {
28
- /** Rebuilt manifest, ready to be re-applied by callers (already persisted). */
38
+ /**
39
+ * Rebuilt manifest, ready to be re-applied by callers. Persisted unless
40
+ * the run was a dry run — see {@link RebuildPatchesManifestResult.written}.
41
+ */
29
42
  manifest: PatchesManifest;
30
43
  /**
31
44
  * Filenames whose manifest entry had no pre-existing metadata to
32
45
  * preserve — every descriptive field on these entries is inferred.
33
46
  */
34
47
  recoveredFilenames: string[];
48
+ /**
49
+ * Filenames that had a manifest entry but no patch file on disk, so the
50
+ * row was dropped. Discarding these silently is how a manifest quietly
51
+ * loses rows an operator still expected to be there.
52
+ */
53
+ droppedFilenames: string[];
54
+ /** False when the run was a dry run and patches.json was left untouched. */
55
+ written: boolean;
56
+ }
57
+ /** Options for {@link rebuildPatchesManifest}. */
58
+ export interface RebuildPatchesManifestOptions extends PatchDirectoryLockOptions {
59
+ /**
60
+ * Proceed even when patches.json exists but cannot be parsed, in which case
61
+ * there is no existing metadata to preserve and every descriptive field on
62
+ * every entry is invented. Refused without this flag — see
63
+ * {@link rebuildPatchesManifest}.
64
+ */
65
+ allowMetadataLoss?: boolean;
66
+ /** Compute the rebuilt manifest without writing it. */
67
+ dryRun?: boolean;
35
68
  }
36
69
  /**
37
70
  * Rebuilds patches.json from the patch files currently present on disk.
38
- * Existing metadata is preserved when possible; missing entries are recovered
39
- * from filename structure, patch contents, and file mtimes.
71
+ *
72
+ * This is a MERGE, not a from-scratch rebuild: only `filesAffected` and
73
+ * `order` are recomputed from the patch files, and every other field on an
74
+ * existing entry is carried forward untouched. Entries with no pre-existing
75
+ * metadata are recovered from filename structure, patch contents, and file
76
+ * mtimes.
77
+ *
78
+ * Refuses when patches.json exists but cannot be parsed, because that is the
79
+ * one case where nothing can be carried forward and the "merge" silently
80
+ * becomes a whole-manifest reinvention. Pass `allowMetadataLoss` to accept it.
81
+ *
40
82
  * @param patchesDir - Path to the patches directory
41
83
  * @param fallbackSourceEsrVersion - source version to use for recovered legacy entries
42
- * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
43
- * plus the filenames that were reconstructed from generic defaults.
84
+ * @param options - Lock options plus `allowMetadataLoss` / `dryRun`
85
+ * @returns {@link RebuildPatchesManifestResult} — the rebuilt manifest, the
86
+ * filenames that were reconstructed from generic defaults, and the rows
87
+ * dropped because their patch file is gone.
88
+ */
89
+ export declare function rebuildPatchesManifest(patchesDir: string, fallbackSourceEsrVersion: string, options?: RebuildPatchesManifestOptions): Promise<RebuildPatchesManifestResult>;
90
+ /**
91
+ * Canonical form of a `filesAffected` list: deduplicated and sorted, so a
92
+ * comparison between a declared list and one extracted from a patch body is
93
+ * about membership rather than ordering.
44
94
  */
45
- export declare function rebuildPatchesManifest(patchesDir: string, fallbackSourceEsrVersion: string, lockOptions?: PatchDirectoryLockOptions): Promise<RebuildPatchesManifestResult>;
95
+ export declare function normalizeAffectedFiles(files: string[]): string[];
@@ -3,6 +3,7 @@
3
3
  * Manifest consistency checks and rebuild/recovery operations.
4
4
  */
5
5
  import { stat } from 'node:fs/promises';
6
+ import { GeneralError } from '../errors/base.js';
6
7
  import { discoverPatches, getAllTargetFilesFromPatch } from './patch-files.js';
7
8
  import { withPatchDirectoryLock } from './patch-lock.js';
8
9
  import { loadPatchesManifestState, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest-io.js';
@@ -59,8 +60,8 @@ export async function validatePatchesManifestConsistency(patchesDir) {
59
60
  });
60
61
  continue;
61
62
  }
62
- const declaredFiles = normalizeFiles(metadata.filesAffected);
63
- const actualFiles = normalizeFiles(await getAllTargetFilesFromPatch(patch.path));
63
+ const declaredFiles = normalizeAffectedFiles(metadata.filesAffected);
64
+ const actualFiles = normalizeAffectedFiles(await getAllTargetFilesFromPatch(patch.path));
64
65
  if (!sameStringArray(declaredFiles, actualFiles)) {
65
66
  issues.push({
66
67
  code: 'files-affected-mismatch',
@@ -80,26 +81,78 @@ export async function validatePatchesManifestConsistency(patchesDir) {
80
81
  }
81
82
  return issues;
82
83
  }
84
+ /**
85
+ * Recovery hint for an unrepaired consistency failure.
86
+ *
87
+ * Drift that is only in `filesAffected` is a derived value disagreeing with
88
+ * the diff it describes, and the narrow repair fixes exactly that. Naming the
89
+ * whole-manifest rebuild for it — as this hint used to, unconditionally — puts
90
+ * an operator one keystroke from rewriting every row in the manifest to
91
+ * correct one list.
92
+ */
93
+ export function recommendManifestRepair(issues) {
94
+ const filesAffectedOnly = issues.length > 0 && issues.every((issue) => issue.code === 'files-affected-mismatch');
95
+ return filesAffectedOnly
96
+ ? 'Run "fireforge doctor --repair-files-affected" to recompute filesAffected from the patch ' +
97
+ 'bodies (add --dry-run to preview). It rewrites only the drifted lists and leaves every ' +
98
+ 'other manifest field untouched.'
99
+ : 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from the patch ' +
100
+ 'files (add --dry-run to preview). Only filesAffected and order are recomputed; every ' +
101
+ 'other field on an existing entry is preserved.';
102
+ }
83
103
  /**
84
104
  * Rebuilds patches.json from the patch files currently present on disk.
85
- * Existing metadata is preserved when possible; missing entries are recovered
86
- * from filename structure, patch contents, and file mtimes.
105
+ *
106
+ * This is a MERGE, not a from-scratch rebuild: only `filesAffected` and
107
+ * `order` are recomputed from the patch files, and every other field on an
108
+ * existing entry is carried forward untouched. Entries with no pre-existing
109
+ * metadata are recovered from filename structure, patch contents, and file
110
+ * mtimes.
111
+ *
112
+ * Refuses when patches.json exists but cannot be parsed, because that is the
113
+ * one case where nothing can be carried forward and the "merge" silently
114
+ * becomes a whole-manifest reinvention. Pass `allowMetadataLoss` to accept it.
115
+ *
87
116
  * @param patchesDir - Path to the patches directory
88
117
  * @param fallbackSourceEsrVersion - source version to use for recovered legacy entries
89
- * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
90
- * plus the filenames that were reconstructed from generic defaults.
118
+ * @param options - Lock options plus `allowMetadataLoss` / `dryRun`
119
+ * @returns {@link RebuildPatchesManifestResult} — the rebuilt manifest, the
120
+ * filenames that were reconstructed from generic defaults, and the rows
121
+ * dropped because their patch file is gone.
91
122
  */
92
- export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersion, lockOptions = {}) {
123
+ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersion, options = {}) {
93
124
  // The whole read → discover → rebuild → save cycle runs under the shared
94
125
  // patch-directory lock (invariant 2, docs/lifecycle-invariants.md):
95
126
  // manifest writes serialize on this lock, so without it a repair racing a
96
127
  // concurrent export/reorder can clobber the other writer's manifest. Not
97
128
  // reentrant — callers must not already hold it.
98
- return withPatchDirectoryLock(patchesDir, () => rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVersion), lockOptions);
129
+ //
130
+ // A dry run takes the lock too: it reads the same manifest and patch bodies
131
+ // a real run would, and a projection computed against a queue being
132
+ // rewritten underneath is worth less than no projection at all.
133
+ return withPatchDirectoryLock(patchesDir, () => rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVersion, options), options);
99
134
  }
100
- async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVersion) {
135
+ async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVersion, options) {
101
136
  const manifestState = await loadPatchesManifestState(patchesDir);
102
137
  const existingEntries = new Map();
138
+ // An unparseable manifest yields `manifest: null`, which the merge below
139
+ // cannot distinguish from "no manifest at all" — every entry would be
140
+ // treated as new and every descriptive field reinvented. `filesAffected`
141
+ // is the only field a .patch body can yield; `stagedDependencies`,
142
+ // `lintIgnore`, `tier`, `description`, `category`, `createdAt` and the
143
+ // source version cannot be reconstructed from a diff at all. Refuse rather
144
+ // than write that, since the write is what makes it unrecoverable on a
145
+ // queue that is not under version control.
146
+ if (manifestState.exists && manifestState.parseError && options.allowMetadataLoss !== true) {
147
+ throw new GeneralError(`Refusing to rebuild ${PATCHES_MANIFEST}: it exists but could not be parsed ` +
148
+ `(${manifestState.parseError.message}). None of its metadata can be carried ` +
149
+ 'forward, so every entry would be rebuilt with an invented description, an ' +
150
+ 'mtime-based createdAt and an inferred category, and any stagedDependencies, ' +
151
+ 'lintIgnore or tier declarations would be lost — a patch body carries none of ' +
152
+ 'them.\n' +
153
+ `Fix the JSON in ${PATCHES_MANIFEST} (restore it from version control if you ` +
154
+ 'can) and re-run, or pass --allow-metadata-loss to accept the reinvention.');
155
+ }
103
156
  if (manifestState.manifest) {
104
157
  for (const entry of manifestState.manifest.patches) {
105
158
  existingEntries.set(entry.filename, entry);
@@ -118,7 +171,7 @@ async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVers
118
171
  // sequential because `nextRecoveredOrder` is loop-carried: parallelising
119
172
  // the assignment would scramble the recovered ordinals.
120
173
  const prefetched = await Promise.all(patches.map(async (patch) => ({
121
- filesAffected: normalizeFiles(await getAllTargetFilesFromPatch(patch.path)),
174
+ filesAffected: normalizeAffectedFiles(await getAllTargetFilesFromPatch(patch.path)),
122
175
  patchStats: await stat(patch.path),
123
176
  })));
124
177
  for (const [index, patch] of patches.entries()) {
@@ -140,15 +193,17 @@ async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVers
140
193
  // avoid.
141
194
  recoveredFilenames.push(patch.filename);
142
195
  }
143
- // Preserve optional fields the operator declared on the existing entry —
144
- // `lintIgnore` (per-patch lint suppression) and `tier` (explicit
145
- // branding-threshold override). Without this, a
146
- // `doctor --repair-patches-manifest` run silently strips both from every
147
- // entry that had them, and the next `lint` or `re-export` pass fires
148
- // rules the operator had intentionally quieted. Mirrors how other
149
- // descriptive fields fall back to existing values when the entry is
150
- // known.
196
+ // The rebuild OWNS exactly two fields — `filesAffected` (recomputed from
197
+ // the patch body) and `order` (from the filename ordinal). Everything
198
+ // else on an existing entry is carried forward by the spread, so a field
199
+ // a .patch cannot express — `stagedDependencies`, `lintIgnore`, `tier`,
200
+ // and whatever is added to PatchMetadata next — survives a repair by
201
+ // construction rather than by someone remembering to list it here. It was
202
+ // an enumerated literal until `stagedDependencies` was dropped from every
203
+ // entry that had one, which is the failure this shape prevents from
204
+ // recurring.
151
205
  const rebuilt = {
206
+ ...(existing ?? {}),
152
207
  filename: patch.filename,
153
208
  order: recoveredOrder,
154
209
  category: existing?.category ?? inferred.category,
@@ -158,24 +213,42 @@ async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVers
158
213
  createdAt: existing?.createdAt ?? new Date(patchStats.mtimeMs).toISOString(),
159
214
  sourceEsrVersion: existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
160
215
  sourceVersion: existing?.sourceVersion ?? existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
161
- ...(existing?.sourceProduct !== undefined ? { sourceProduct: existing.sourceProduct } : {}),
162
216
  filesAffected,
163
217
  };
218
+ // Copy the arrays rather than aliasing the loaded manifest's, so a caller
219
+ // mutating the rebuilt entry cannot reach back into `manifestState`.
164
220
  if (existing?.lintIgnore !== undefined)
165
221
  rebuilt.lintIgnore = [...existing.lintIgnore];
166
- if (existing?.tier !== undefined)
167
- rebuilt.tier = existing.tier;
168
222
  rebuiltPatches.push(rebuilt);
223
+ existingEntries.delete(patch.filename);
169
224
  }
170
225
  rebuiltPatches.sort((left, right) => left.order - right.order || left.filename.localeCompare(right.filename));
171
226
  const rebuiltManifest = {
172
227
  version: 1,
173
228
  patches: rebuiltPatches,
174
229
  };
230
+ // Whatever is left in the map had a manifest row but no patch file, so the
231
+ // rebuild drops it. That is the correct outcome — the file is the source of
232
+ // truth — but dropping rows without naming them is indistinguishable from
233
+ // never having had them.
234
+ const droppedFilenames = [...existingEntries.keys()].sort((left, right) => left.localeCompare(right));
235
+ if (options.dryRun === true) {
236
+ return {
237
+ manifest: rebuiltManifest,
238
+ recoveredFilenames,
239
+ droppedFilenames,
240
+ written: false,
241
+ };
242
+ }
175
243
  await savePatchesManifest(patchesDir, rebuiltManifest);
176
- return { manifest: rebuiltManifest, recoveredFilenames };
244
+ return { manifest: rebuiltManifest, recoveredFilenames, droppedFilenames, written: true };
177
245
  }
178
- function normalizeFiles(files) {
246
+ /**
247
+ * Canonical form of a `filesAffected` list: deduplicated and sorted, so a
248
+ * comparison between a declared list and one extracted from a patch body is
249
+ * about membership rather than ordering.
250
+ */
251
+ export function normalizeAffectedFiles(files) {
179
252
  return Array.from(new Set(files)).sort((left, right) => left.localeCompare(right));
180
253
  }
181
254
  function sameStringArray(left, right) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Narrow repair for `filesAffected` — the one manifest field a `.patch` body
3
+ * can actually yield.
4
+ *
5
+ * A `files-affected-mismatch` is pure derived-value drift: the manifest's list
6
+ * disagrees with the diff it describes, and the diff is right by definition.
7
+ * Rebuilding the whole manifest to fix it is a much larger operation than the
8
+ * failure calls for, so this repair recomputes one field on the drifted rows
9
+ * and leaves every other row byte-identical.
10
+ */
11
+ import type { PatchDirectoryLockOptions } from './patch-lock.js';
12
+ /** One row whose `filesAffected` list disagreed with its patch body. */
13
+ export interface FilesAffectedRepair {
14
+ /** Patch filename. */
15
+ filename: string;
16
+ /** The list the manifest declared, normalized for comparison. */
17
+ before: string[];
18
+ /** The list the patch body actually targets. */
19
+ after: string[];
20
+ }
21
+ /** Summary of a {@link repairPatchesFilesAffected} run. */
22
+ export interface RepairFilesAffectedResult {
23
+ /** Rows that drifted and were (or would be) corrected. */
24
+ repairs: FilesAffectedRepair[];
25
+ /** Requested filenames with no manifest row, or no patch file on disk. */
26
+ skippedFilenames: string[];
27
+ /** False when the run was a dry run and patches.json was left untouched. */
28
+ written: boolean;
29
+ }
30
+ /** Options for {@link repairPatchesFilesAffected}. */
31
+ export interface RepairFilesAffectedOptions extends PatchDirectoryLockOptions {
32
+ /** Compute the corrections without writing them. */
33
+ dryRun?: boolean;
34
+ }
35
+ /**
36
+ * Recomputes `filesAffected` from the patch body for the named patches.
37
+ *
38
+ * @param patchesDir - Path to the patches directory
39
+ * @param filenames - Patch filenames to repair
40
+ * @param options - Lock options plus `dryRun`
41
+ * @returns The corrections applied (or projected) and the filenames skipped
42
+ */
43
+ export declare function repairPatchesFilesAffected(patchesDir: string, filenames: readonly string[], options?: RepairFilesAffectedOptions): Promise<RepairFilesAffectedResult>;