@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.
- package/CHANGELOG.md +29 -2
- package/README.md +2 -11
- package/dist/build-info.json +4 -4
- package/dist/src/commands/doctor-check-core.d.ts +9 -0
- package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
- package/dist/src/commands/doctor-patch-manifest.js +155 -0
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +48 -62
- package/dist/src/commands/furnace/diff.js +6 -1
- package/dist/src/commands/furnace/remove-mutations.js +5 -1
- package/dist/src/commands/import.js +6 -2
- package/dist/src/core/build-audit-resolve.js +12 -3
- package/dist/src/core/config-mutate.js +20 -19
- package/dist/src/core/furnace-apply-ftl.js +4 -1
- package/dist/src/core/furnace-apply-helpers.js +11 -5
- package/dist/src/core/git-base.d.ts +14 -0
- package/dist/src/core/git-base.js +17 -0
- package/dist/src/core/git.js +3 -1
- package/dist/src/core/patch-lint-checkjs.js +25 -4
- package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
- package/dist/src/core/patch-manifest-consistency.js +96 -23
- package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
- package/dist/src/core/patch-manifest-files-affected.js +77 -0
- package/dist/src/core/patch-manifest.d.ts +2 -1
- package/dist/src/core/patch-manifest.js +2 -1
- package/dist/src/core/token-category.js +61 -7
- package/dist/src/core/token-dark-mode.js +20 -2
- package/dist/src/core/token-docs.js +1 -1
- package/dist/src/core/token-variant.js +24 -2
- package/dist/src/core/tree-store.js +5 -1
- package/dist/src/core/typecheck.js +7 -1
- package/dist/src/types/commands/options.d.ts +23 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
- **`release:check` ran twice per release**, because `npm publish` re-triggers it through `prepublishOnly`. The explicit step is gone; `prepublishOnly` is the single gate. The redundant third `npm pack` (`pack:dry-run`, already run by `pack:verify`) was dropped from the gate.
|
|
8
8
|
- Branch-protection contexts are stable: the smoke job is `smoke (${{ matrix.os }})` rather than embedding the Node version — the same trap already fixed for `quality (ubuntu)` — and `setup-rulesets.sh` reconciles (GET by name, then PUT or POST) instead of POSTing unconditionally, which is why the live ruleset had drifted to requiring nothing.
|
|
9
|
+
- **CI ran the lint toolchain on a Node version that toolchain excludes.** `eslint-plugin-jsdoc` and its two dependencies (`@es-joy/jsdoccomment`, `jsdoc-type-pratt-parser`) declare `node: ^22.22.2 || >=24.15.0`, and every workflow pinned **22.22.1** — one patch below the floor. `npm ci` only WARNS on an engine mismatch (`npm warn EBADENGINE`), so the gate stayed green while `lint:ci` ran on a combination the plugin's author had ruled out; the next symptom of that is an unexplained eslint crash rather than a version complaint. The pin moves to **22.23.2** (newest on the 22 line). The eight hardcoded `22.22.1` literals across four workflows are gone: all six setup-node steps now read `node-version-file: '.nvmrc'`, so the version is derived exactly the way the npm pin already is, and the drift that let the lockfile's floor climb past the pin cannot recur. `ci.yml`'s smoke matrix loses its now-uniform `node-version` column; the check contexts are unaffected, since `name:` pins them explicitly for the reason recorded above. `engines.node` deliberately moves only to `>=22.22.2`, NOT to the pin: the `^22.22.2` requirement comes from devDependencies, so a consumer of the published package must not be pushed to 22.23.2 for a lint plugin they never install. `.nvmrc` sitting above `engines` is the intended shape, not a number someone should reconcile.
|
|
10
|
+
- **A test repo committed without a committer name, so the gate passed on every developer machine and failed on the runner.** `apply-patch-idempotent.test.ts` was the only repo-creating test that configured `user.email` without `user.name` — the other twenty set both. Git falls back to the OS gecos field for a missing name, which is populated on a workstation and EMPTY for GitHub's `runner` user, so there `git commit -m baseline` died with `fatal: empty ident name` and left HEAD unborn. The helper used `exec`, which returns a non-zero `exitCode` rather than throwing, so the dead commit was discarded silently and the run continued against a repo with no commits; the failure surfaced six steps later inside `restoreFilesToHead` as `fatal: Not a valid object name HEAD`, wearing a `PatchApplyError` whose remedies are all about Firefox version drift. Six of the file's seven tests failed that way and pointed at the wrong subsystem. The helper now sets `user.name` and routes every git call through `runGit` (`src/test-utils/index.ts`), which is `execFile`-backed and REJECTS on non-zero — so a future setup command that fails is reported where it failed rather than as a corrupted repo later. An environment with no ambient git identity (`user.useConfigOnly`) reproduces the old failure exactly and passes on the new code.
|
|
9
11
|
|
|
10
12
|
### Multi-session contention
|
|
11
13
|
|
|
12
|
-
A downstream fork runs this checkout with up to five concurrent agent sessions, where a held lock is the normal state rather than an exceptional one. Four findings from that workload:
|
|
13
|
-
|
|
14
14
|
- **A `--wait-lock` budget expired one position from the head of the queue** — the worst possible outcome, because it pays the entire wait and gets nothing. `--wait-lock 300` died still-queued where `3600` was needed. The timeout is now a budget against a STALLED queue rather than against wall-clock: each time the observed queue position improves, the wait is granted a fresh budget, up to four times the requested one (ceiling one hour, matching the flag's own bound). A queue that stops moving still starves on exactly the budget you asked for, which is what the timeout is for. The refusal also names the position reached — "you were still 2 from the head of a queue of 3" — so raising the budget is an informed decision instead of a guess. Only `--wait-lock` runs extend; the no-flag ~1 s fail-fast path is untouched.
|
|
15
15
|
- **A wait budget could not be expressed once for a session.** `FIREFORGE_WAIT_LOCK=<seconds>` now supplies the budget for every lock-taking command that was not given an explicit `--wait-lock` (which always wins). An unattended loop threading the flag through every invocation was the shape most likely to be forgotten, and the omission only surfaced as a tail of refusals after the loop had already run. An unusable value is refused rather than silently falling back to fail-fast. Note that `test`, `export` and `re-export` never differed here: all three fail fast at ~1 s without the flag.
|
|
16
16
|
- **A killed run wrote no terminal line**, so a log tail could not distinguish "killed" from "still running" from "never started" — recovery meant checking `ps` and the lock file by hand, at exactly the moment (a mass kill under a usage limit, mid-drain) when the tree's state most needs to be readable. SIGTERM/SIGINT now emit `FIREFORGE-VERDICT: FAIL reason=killed signal=…` before the shutdown drain, so the line survives a drain that times out. It is a no-op unless a test run was actually in flight: a Ctrl+C during `status` must not claim a test run happened.
|
|
@@ -63,6 +63,24 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
63
63
|
- The signal-time furnace lock sweeper removed lock directories without checking ownership, so it could delete a lock another process had acquired in the window between our operation dying and the sweep running. It now uses `releaseLockIfOwned`.
|
|
64
64
|
- `killProcessTree`'s POSIX branch swallowed `ESRCH`/`EPERM` from the group kill without falling through to a direct `child.kill`, so a failed group kill left the child running.
|
|
65
65
|
|
|
66
|
+
### Security
|
|
67
|
+
|
|
68
|
+
- **Six scanners over `tokens.css` were super-linear in the length of a single line** (`js/polynomial-redos`). Worth stating plainly what the input is: `tokens.css` and `docs/design/SRC_TOKENS.md` are files FireForge reads out of a CONSUMER's engine tree, so "attacker-controlled" is the wrong frame — a minified, generated or merge-mangled line that happens to repeat `:root`, `:root[`, `var(` or `/*=` is enough to wedge `token add` with nobody doing anything hostile. Measured on the witnesses now pinned as tests: 4 000 `=` after a `/*` cost the category scanner **8.2 s**, and 20 000-40 000 repetitions cost the other five between **0.8 s and 3.6 s** each. Two distinct causes, and they want different remedies:
|
|
69
|
+
- _Ambiguous adjacent quantifiers_, where two atoms match the same character so the engine re-partitions the run once per length: `\s*=+\s*(.+?)\s*=+\s*` in the banner reader (`.` matches both `\s` and `=`), `\/\*\s*=.*=\s*\*\/` in the section-end scan, `(^|[\s,{])\s*:root\b` in the dark-mode scan (`[\s,{]` and `\s*` both match a tab), and `:root\[[^{]*\]` in the variant scan (`[^{]` also matches `]`). The dark-mode one is a one-token fix — `(?:^|[\s,{]):root\b` is exactly equivalent, since the last whitespace character of any run satisfies the class on its own. The other three became index arithmetic (`blockCommentBody`, `singleLineBannerName`, `rootAttributeSelector`), which is also where the duplicated "is this a banner comment?" spelling collapsed into one predicate.
|
|
70
|
+
- _An unanchored prefix that can start at many positions_, which is quadratic even when the pattern itself is unambiguous: `:root[^{}]*\{` restarts at every `:root` in the line, and `value.replace(/var\(([^)]+)\)/, …)` at every `var(`. The docs one is a one-character fix — the branch it sits on has already established `value.startsWith('var(')`, so anchoring is both linear and exact. The other became `rootOpensBraceOnLine`.
|
|
71
|
+
- The variant scanner's replacement deliberately spans to the LAST `]` before the opening brace rather than the first. Taking the first would have been the shorter fix and a silent behaviour change: `:root[data-skin=precision][data-private]` would then satisfy a request for the `[data-skin=precision]` variant it merely starts with, and the declaration would land in the wrong block. A test pins that, and it passes on both implementations — which is the point of it.
|
|
72
|
+
- The regression tests assert wall-clock, because the defect IS a running time; every one of these calls returns the same answer before and after. Each witness fails the old implementation by an order of magnitude or more, and the whole file now runs in ~26 ms.
|
|
73
|
+
- **The prototype-chain guard on `config <key> <value> --force` was correct and invisible** (`js/prototype-pollution-utility`). `mutateConfig` rejected `__proto__`, `constructor` and `prototype` — but once, up front, in a helper, against a `Set`; the descent and the leaf assignment two dozen lines below therefore read as unguarded to anything that is not interprocedural, CodeQL included. The check now happens at the point each segment is USED as a property name, spelled as explicit comparisons, with only the message factored out. That is not just to satisfy the query: a guard that lives at a distance from its write is one refactor of its caller away from being skipped, and the existing tests keep passing untouched because nothing about the behaviour moved.
|
|
74
|
+
- **Two `replace` calls read as incomplete escaping** (`js/incomplete-sanitization`), and both were in fact correct — which is exactly why they are rewritten rather than dismissed, since "correct, trust me" is not a property a scanner can re-derive next quarter. `patch-lint-checkjs` substituted a TypeScript `paths` wildcard with `target.replace('*', captured)`; a target carries at most one `*` and first-occurrence replacement IS the contract, now spelled as index arithmetic mirroring the prefix/suffix split already used on the pattern side, with a test pinning both the wildcard and wildcard-free target shapes. The pack smoke test hand-escaped a version string with `.replace(/\./g, '\\.')`; it now uses `escapeRegex` from `src/utils/regex.ts`, the helper ~40 other call sites already use.
|
|
75
|
+
|
|
76
|
+
### Manifest repair
|
|
77
|
+
|
|
78
|
+
- **The rebuild dropped `stagedDependencies` from every entry that had one.** `doctor --repair-patches-manifest` builds each row as an explicit object literal, field by field, from the existing entry — so it preserves exactly the fields someone remembered to list, and silently drops the rest. `stagedDependencies` was never listed, which on a queue that uses it means the `registrations` block that lets a branding `distribution/` directory land with a later patch is gone, and the next `lint` fires on a dependency the operator had declared. A patch body cannot express it, so nothing recomputes it either. The row is now built by spreading the existing entry and overriding only the two fields the rebuild OWNS — `filesAffected` and `order` — so a field a `.patch` cannot carry survives by construction, including whatever is added to `PatchMetadata` next. This is the same failure the `lintIgnore`/`tier` lines were added to fix, one field later; the enumeration was the defect, not the omission.
|
|
79
|
+
- **An unparseable `patches.json` was rebuilt into a wholly invented one, and the report did not see this because it is not what happened to it.** An existing-but-invalid manifest loads as `null`, which the merge cannot tell apart from "no manifest at all" — so every entry was treated as new, and every entry's `description`, `createdAt`, `category` and source version was reinvented from filename, mtime and fallback. `manifest-invalid` is one of the consistency issues that triggers the repair, so `doctor --repair-patches-manifest` reached it directly. It now refuses, naming the parse error and what would be lost, and the escape hatch is explicit (`--allow-metadata-loss`) rather than the default.
|
|
80
|
+
- **A repair that wrote was reported as a run where nothing happened.** The repair runs inside the check loop; the exit code is computed after every check. The repair itself returns a warning, so it never fails the run — but any later failing check makes doctor exit 1 with the manifest already rewritten, and a non-zero exit reads as "the command refused". Doctor now prints what it wrote, immediately before the summary, in every branch including the failing one. The write is not rolled back: the repairs are independent of the checks that follow them, and undoing a successful repair because something unrelated is also wrong would be the larger surprise.
|
|
81
|
+
- **`--repair-patches-manifest` was named as the fix for a one-field drift, by both `import` and doctor's own hint.** `files-affected-mismatch` is a derived list disagreeing with the diff that defines it, and a tool that names a whole-manifest rewrite as the remedy for that will have it run. There is now a narrow repair — `doctor --repair-files-affected` — which recomputes only the drifted lists through `mutatePatchRowsInManifest`, so every untouched row keeps its exact JSON, and which needs no project config, so unlike the rebuild it works on a project whose `fireforge.json` is also broken. The refusals now offer whichever repair fits the issue codes actually present. Both repairs gained `--dry-run`, which `patch lint-ignore` has had all along; neither gained a confirmation prompt, because `confirmDestructive` refuses a non-TTY run and these are run from scripts — the repair flag is itself the explicit opt-in. Manifest rows the rebuild drops because their patch file is gone are now named too.
|
|
82
|
+
- **Two of the report's claims do not hold against this code, and are recorded rather than "fixed".** `lintIgnore`, `tier`, `description`, `category`, `createdAt` and `sourceEsrVersion` were all already preserved on an existing entry — the rebuild has been a merge, not a from-scratch rebuild, since the branding tier landed. Most of the 1823 deletions were `filesAffected` lists being re-sorted (the rebuild normalises them; the narrow repair does too, but only on rows that were already wrong) plus the `stagedDependencies` blocks above. And the three binary-asset patches the report flagged as possible mismatches were an artifact of its own scan: `getAllTargetFilesFromPatch` reads `diff --git` headers, so `GIT binary patch` sections with no `+++ b/` line have been counted correctly since the fix that put them there. Naming what the code actually does is how the next occurrence gets attributed quickly instead of inheriting a guess.
|
|
83
|
+
|
|
66
84
|
### Performance
|
|
67
85
|
|
|
68
86
|
- **`getAllDiff` spawned one `git hash-object` per untracked file, uncapped.** Its sibling `getDiffForFilesAgainstHead` was reshaped into classify → one batched hash → render for exactly this reason; this path kept the per-file form, and it is the only path behind `tree create`, `tree list`, `tree remove --all`, `lint` and `export-all` — against a Firefox tree where "untracked" can mean a very large number of files. Now batched. Everything observable is deliberately unchanged (renames, `ls-files` ordering, tracked-block-first emission, the `'\n'` empty sentinel) because this output is SHA-256'd into every tree fingerprint and written verbatim into `.patch` files; a new real-git test pins those bytes, which nothing did before.
|
|
@@ -117,6 +135,15 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
117
135
|
- **A `patches.json` from a newer FireForge was reported as malformed.** `patches.json version must be 1` sent the operator looking for damage in a file that only needed a newer build to read it, and the manifest had no migration hook at all. It now refuses by name — naming the found and supported schema versions and telling the operator not to delete the file — while a version below 1 or non-integer keeps the plain malformed message, because that one really is malformed.
|
|
118
136
|
- The three JSDoc walkers in `patch-lint-jsdoc.ts` return their issues instead of pushing into an out-param, matching the ~30 other patch-lint entry points. The new `jsdoc/check-param-names` rule immediately caught the stale `@param issues` the change left behind — which is the point of enabling it.
|
|
119
137
|
|
|
138
|
+
### Windows
|
|
139
|
+
|
|
140
|
+
- **`fireforge lint`'s checkJs pass reported ZERO findings on Windows, silently.** `patch-lint-checkjs.ts` keys `ownedAbsolute` / `relByAbsolute` / `pathsResolved` on `resolve(repoDir, rel)` — backslashes — but TypeScript's `CompilerHost` hands `getSourceFile`/`fileExists` a `/`-normalized `fileName` on every platform. Every lookup missed, so the host served an empty synthetic source file for each owned file and the whole pass came back clean. "No diagnostics" is indistinguishable from "no problems", which is why nothing caught it. The maps are now keyed through one `toTsPath()` helper in the TypeScript spelling. `typecheck.ts` had the same defect on its synthetic shim: never served, and its own diagnostics never filtered out.
|
|
141
|
+
- **`furnace diff` reported every overridden file as a new file.** The engine-relative path it hands `git show <ref>:<path>` came from slicing a native path, and git's `ref:path` form only understands forward slashes — so every baseline read missed and the command showed nothing to diff.
|
|
142
|
+
- **Engine-relative paths were recorded in two spellings at once.** `furnace-apply-helpers.ts` and `furnace-apply-ftl.ts` pushed `relative(engineDir, …)` results — backslashes on Windows — into lists whose sibling entries are POSIX literals, and those lists are identifiers: they go to git, into furnace state, and are compared against the POSIX paths `furnace.json` and `jar.mn` already carry. Normalized at the five producing sites.
|
|
143
|
+
- **`build-audit` collapsed to one path segment**, so the same-basename collision heuristic and the stale/missing classification stopped discriminating; **`removeTree` refused every removal** as an escape from the trees directory, because it compared a `join`ed prefix against a `resolve`d root and only the latter carries a drive letter; and **`initRepository` never pinned `core.autocrlf`**, so on a host with git's Windows default the checkout rewrote LF to CRLF — shifting the blob hashes `hashObjectBatch` delegates to git, and with them the bytes patches are exported from and applied to.
|
|
144
|
+
|
|
145
|
+
On the test side: path expectations and mock keys are rendered through a new `nativePath()` test helper rather than hard-coded POSIX literals; fixture repos pin `core.autocrlf=false` / `core.eol=lf`; the three modules that branch on `process.platform` directly now have that branch forced rather than inherited from the runner; and the doctor summary count is derived rather than pinned at 16, since the orphaned-harness check is `skipIf` win32. The genuinely POSIX-only suites — `tree`, `tree exec`, `run --smoke-exit`, and the `chmod`-as-refusal tests — are skipped explicitly, and each skip is paired with a new platform-stubbed test that pins the win32 refusal itself, which nothing did before.
|
|
146
|
+
|
|
120
147
|
### Test infrastructure
|
|
121
148
|
|
|
122
149
|
- **`createLoggerMock()` and `createFsMock()`** in `src/test-utils/module-mocks.ts` replace hand-rolled `vi.mock` factories at 110 sites. `utils/logger.js` was mocked in 118 files by 113 full-replacement factories across 55 distinct key-sets, 34 appearing exactly once; `utils/fs.js` had 89 more. Each enumerated a SUBSET of the real module, so adding one export turned it `undefined` everywhere the factory omitted it — which happened twice while writing this release, 106 files each time.
|
package/README.md
CHANGED
|
@@ -20,10 +20,10 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
|
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
|
-
- Node.js 22.22.
|
|
23
|
+
- Node.js 22.22.2+
|
|
24
24
|
- Python 3
|
|
25
25
|
- Git
|
|
26
|
-
- The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows
|
|
26
|
+
- The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows.
|
|
27
27
|
- Watchman, if you want `fireforge watch` (optional)
|
|
28
28
|
|
|
29
29
|
## Getting Started
|
|
@@ -89,15 +89,6 @@ new one as a single transaction — including staged-dependency owner rewrites
|
|
|
89
89
|
`--dry-run` support. `patch rename` also supports `--category` and `--order`; every
|
|
90
90
|
queue-mutating patch command accepts `--wait-lock [seconds]`.
|
|
91
91
|
|
|
92
|
-
Under several concurrent sessions a held lock is the normal state, not an exceptional
|
|
93
|
-
one, so a wait budget is a property of the session rather than of the invocation: set
|
|
94
|
-
`FIREFORGE_WAIT_LOCK=<seconds>` and every lock-taking command that was not given an
|
|
95
|
-
explicit `--wait-lock` uses it. A wait whose queue position keeps improving is granted a
|
|
96
|
-
fresh budget on each advance (up to four times the requested one, capped at an hour), so
|
|
97
|
-
a long-but-moving queue no longer expires one position from the head; a queue that stops
|
|
98
|
-
moving still starves on the budget you asked for, and the refusal names the position you
|
|
99
|
-
reached.
|
|
100
|
-
|
|
101
92
|
`fireforge test <directory>` runs exactly that directory: FireForge enumerates the
|
|
102
93
|
directory's test files and passes the explicit file list to mach in one invocation, so
|
|
103
94
|
mach's prefix-based path matching cannot silently sweep in sibling directories sharing the
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.44.
|
|
4
|
-
"commit": "
|
|
5
|
-
"shortCommit": "
|
|
3
|
+
"version": "0.44.1",
|
|
4
|
+
"commit": "b77a037425f19e19c43a64a5bb1ff79d949af92e",
|
|
5
|
+
"shortCommit": "b77a037425f1",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-08-
|
|
8
|
+
"buildTime": "2026-08-27T22:08:53.928Z"
|
|
9
9
|
}
|
|
@@ -40,6 +40,15 @@ export interface DoctorCheckContext {
|
|
|
40
40
|
* re-parse the file; undefined when the config could not be loaded.
|
|
41
41
|
*/
|
|
42
42
|
furnaceConfig: FurnaceConfig | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* State this run actually mutated, one human-readable line per write.
|
|
45
|
+
* Repairs run inside the check loop while the exit code is computed only
|
|
46
|
+
* after every check, so a repair can land and the run still exit non-zero
|
|
47
|
+
* on an unrelated check — and a non-zero exit reads as "nothing happened".
|
|
48
|
+
* The runner prints these before the summary in every branch so a write is
|
|
49
|
+
* never invisible.
|
|
50
|
+
*/
|
|
51
|
+
mutations: string[];
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
45
54
|
* Result a check may return. A single object is the common case; an array
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The patch-manifest consistency check and its two repair paths, split out of
|
|
3
|
+
* `doctor.ts` for the line budget. Exports a single `DoctorCheckDefinition`
|
|
4
|
+
* consumed by the doctor registry; no registrar is exported and none is wanted.
|
|
5
|
+
*/
|
|
6
|
+
import type { DoctorCheckDefinition } from './doctor-check-core.js';
|
|
7
|
+
/** Patch manifest consistency check, with its opt-in repair paths. */
|
|
8
|
+
export declare const PATCH_MANIFEST_CONSISTENCY_CHECK: DoctorCheckDefinition;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* The patch-manifest consistency check and its two repair paths, split out of
|
|
4
|
+
* `doctor.ts` for the line budget. Exports a single `DoctorCheckDefinition`
|
|
5
|
+
* consumed by the doctor registry; no registrar is exported and none is wanted.
|
|
6
|
+
*/
|
|
7
|
+
import { rebuildPatchesManifest, recommendManifestRepair, repairPatchesFilesAffected, validatePatchesManifestConsistency, } from '../core/patch-manifest.js';
|
|
8
|
+
import { FireForgeError } from '../errors/base.js';
|
|
9
|
+
import { toError } from '../utils/errors.js';
|
|
10
|
+
import { pathExists } from '../utils/fs.js';
|
|
11
|
+
import { info, warn } from '../utils/logger.js';
|
|
12
|
+
import { resolveWaitLockSeconds } from '../utils/options.js';
|
|
13
|
+
import { failure, ok, warning } from './doctor-check-core.js';
|
|
14
|
+
const CHECK_NAME = 'Patch manifest consistency';
|
|
15
|
+
/** Runs the narrow `filesAffected` repair over the drifted rows. */
|
|
16
|
+
async function runFilesAffectedRepair(ctx, issues) {
|
|
17
|
+
const drifted = issues.filter((issue) => issue.code === 'files-affected-mismatch');
|
|
18
|
+
const otherCodes = [
|
|
19
|
+
...new Set(issues.filter((issue) => issue.code !== 'files-affected-mismatch').map((issue) => issue.code)),
|
|
20
|
+
].sort((left, right) => left.localeCompare(right));
|
|
21
|
+
if (drifted.length === 0) {
|
|
22
|
+
return failure(CHECK_NAME, `--repair-files-affected has nothing to repair: no filesAffected drift, but ${issues.length} ` +
|
|
23
|
+
`other issue(s) remain (${otherCodes.join(', ')}).`, recommendManifestRepair(issues));
|
|
24
|
+
}
|
|
25
|
+
const dryRun = ctx.options.dryRun === true;
|
|
26
|
+
const result = await repairPatchesFilesAffected(ctx.paths.patches, drifted.map((issue) => issue.filename), {
|
|
27
|
+
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
28
|
+
command: 'doctor --repair-files-affected',
|
|
29
|
+
dryRun,
|
|
30
|
+
});
|
|
31
|
+
for (const repair of result.repairs) {
|
|
32
|
+
info(` ${describeFilesAffectedRepair(repair, dryRun)}`);
|
|
33
|
+
}
|
|
34
|
+
for (const filename of result.skippedFilenames) {
|
|
35
|
+
warn(`Skipped ${filename}: it has no manifest row, or no patch file on disk. ` +
|
|
36
|
+
'That is a different consistency issue — re-run doctor without --repair-files-affected ' +
|
|
37
|
+
'to see it reported.');
|
|
38
|
+
}
|
|
39
|
+
if (result.written) {
|
|
40
|
+
ctx.mutations.push(`patches/patches.json — filesAffected corrected on ${describeCount(result.repairs.length)} (--repair-files-affected)`);
|
|
41
|
+
}
|
|
42
|
+
const remaining = otherCodes.length > 0
|
|
43
|
+
? ` ${otherCodes.length} other issue type(s) remain: ${otherCodes.join(', ')}.`
|
|
44
|
+
: '';
|
|
45
|
+
if (otherCodes.length > 0) {
|
|
46
|
+
return failure(CHECK_NAME, `${summarizeFilesAffected(result.repairs.length, dryRun)}${remaining}`, recommendManifestRepair(issues.filter((issue) => issue.code !== 'files-affected-mismatch')));
|
|
47
|
+
}
|
|
48
|
+
return warning(CHECK_NAME, summarizeFilesAffected(result.repairs.length, dryRun));
|
|
49
|
+
}
|
|
50
|
+
function describeFilesAffectedRepair(repair, dryRun) {
|
|
51
|
+
const verb = dryRun ? 'would set' : 'set';
|
|
52
|
+
return `${repair.filename}: ${verb} filesAffected to ${repair.after.length} file(s) (was ${repair.before.length}).`;
|
|
53
|
+
}
|
|
54
|
+
function summarizeFilesAffected(count, dryRun) {
|
|
55
|
+
if (count === 0)
|
|
56
|
+
return 'No filesAffected drift to repair.';
|
|
57
|
+
return dryRun
|
|
58
|
+
? `Dry run — would correct filesAffected on ${describeCount(count)}. Nothing was written.`
|
|
59
|
+
: `Corrected filesAffected on ${describeCount(count)}. No other manifest field was touched.`;
|
|
60
|
+
}
|
|
61
|
+
function describeCount(count) {
|
|
62
|
+
return `${count} patch${count === 1 ? '' : 'es'}`;
|
|
63
|
+
}
|
|
64
|
+
/** Runs the whole-manifest rebuild. */
|
|
65
|
+
async function runManifestRebuild(ctx) {
|
|
66
|
+
// Repair stamps sourceEsrVersion into every recovered entry. If the
|
|
67
|
+
// earlier "fireforge.json is valid" check failed, ctx.config is
|
|
68
|
+
// undefined and we must refuse rather than fabricate a fallback —
|
|
69
|
+
// persisting 'unknown' into manifest metadata is hard to reverse
|
|
70
|
+
// and would mislead every later command that reads it.
|
|
71
|
+
if (!ctx.config) {
|
|
72
|
+
return failure(CHECK_NAME, 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest". ' +
|
|
73
|
+
'If the drift is only in filesAffected, "fireforge doctor --repair-files-affected" needs no config.');
|
|
74
|
+
}
|
|
75
|
+
const dryRun = ctx.options.dryRun === true;
|
|
76
|
+
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version, {
|
|
77
|
+
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
78
|
+
command: 'doctor --repair-patches-manifest',
|
|
79
|
+
dryRun,
|
|
80
|
+
...(ctx.options.allowMetadataLoss === true ? { allowMetadataLoss: true } : {}),
|
|
81
|
+
});
|
|
82
|
+
// The repair path must not silently overwrite human-written
|
|
83
|
+
// descriptions on recovered entries, which leaves the queue less
|
|
84
|
+
// trustworthy as an audit trail. The rebuilder returns the list of
|
|
85
|
+
// filenames whose metadata was entirely invented, named explicitly
|
|
86
|
+
// here so the operator knows which patches to review. Entries that
|
|
87
|
+
// WERE preserved (only `filesAffected` / ordering drifted) are not
|
|
88
|
+
// flagged.
|
|
89
|
+
for (const filename of repaired.recoveredFilenames) {
|
|
90
|
+
// Telling the operator to hand-edit patches.json contradicts the
|
|
91
|
+
// README and downstream docs, which treat the manifest as
|
|
92
|
+
// FireForge-owned. Point at the existing `re-export` / `export`
|
|
93
|
+
// workflow so the fix stays inside the tool: re-exporting the
|
|
94
|
+
// same files with an explicit `--description` overwrites the
|
|
95
|
+
// recovered entry with operator-supplied metadata and supersedes
|
|
96
|
+
// the mtime-based createdAt stamp.
|
|
97
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
|
|
98
|
+
'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
|
|
99
|
+
'(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
|
|
100
|
+
'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
|
|
101
|
+
'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
|
|
102
|
+
}
|
|
103
|
+
// A row whose patch file is gone is dropped by the rebuild. That is
|
|
104
|
+
// correct — the patch file is the source of truth — but a manifest that
|
|
105
|
+
// quietly loses rows is exactly what makes a repair hard to audit
|
|
106
|
+
// afterwards, so each one is named.
|
|
107
|
+
for (const filename of repaired.droppedFilenames) {
|
|
108
|
+
warn(`Dropped the manifest entry for ${filename}: no such patch file on disk. ` +
|
|
109
|
+
'Restore the .patch file and re-run the repair if the entry should have survived.');
|
|
110
|
+
}
|
|
111
|
+
if (repaired.written) {
|
|
112
|
+
ctx.mutations.push('patches/patches.json — rebuilt from the on-disk patch files (--repair-patches-manifest)');
|
|
113
|
+
}
|
|
114
|
+
const patchCount = repaired.manifest.patches.length;
|
|
115
|
+
const recovered = repaired.recoveredFilenames.length > 0
|
|
116
|
+
? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)`
|
|
117
|
+
: '';
|
|
118
|
+
return warning(CHECK_NAME, dryRun
|
|
119
|
+
? `Dry run — would rebuild patches.json from ${describeCount(patchCount)}${recovered}. Nothing was written.`
|
|
120
|
+
: `Rebuilt patches.json from ${describeCount(patchCount)}${recovered}. Review recovered metadata before release.`);
|
|
121
|
+
}
|
|
122
|
+
/** Patch manifest consistency check, with its opt-in repair paths. */
|
|
123
|
+
export const PATCH_MANIFEST_CONSISTENCY_CHECK = {
|
|
124
|
+
name: CHECK_NAME,
|
|
125
|
+
dependsOn: ['fireforge.json is valid'],
|
|
126
|
+
run: async (ctx) => {
|
|
127
|
+
if (!(await pathExists(ctx.paths.patches))) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const issues = await validatePatchesManifestConsistency(ctx.paths.patches);
|
|
131
|
+
if (issues.length === 0) {
|
|
132
|
+
return ok(CHECK_NAME);
|
|
133
|
+
}
|
|
134
|
+
const wantsFilesAffected = ctx.options.repairFilesAffected === true;
|
|
135
|
+
const wantsRebuild = ctx.options.repairPatchesManifest === true;
|
|
136
|
+
if (!wantsFilesAffected && !wantsRebuild) {
|
|
137
|
+
return failure(CHECK_NAME, issues.map((issue) => issue.message).join(' '), recommendManifestRepair(issues));
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return wantsFilesAffected
|
|
141
|
+
? await runFilesAffectedRepair(ctx, issues)
|
|
142
|
+
: await runManifestRebuild(ctx);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const error = toError(err);
|
|
146
|
+
// A refusal from the repair itself already carries the remedy that
|
|
147
|
+
// fits it (which flag, which file to fix). Appending the generic hint
|
|
148
|
+
// under it buries the specific one and reads as a second, vaguer
|
|
149
|
+
// instruction. Only an unexpected throw gets the generic hint.
|
|
150
|
+
return error instanceof FireForgeError
|
|
151
|
+
? failure(CHECK_NAME, error.message)
|
|
152
|
+
: failure(CHECK_NAME, error.message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -29,7 +29,7 @@ export declare const DOCTOR_CHECK_ORDER: readonly string[];
|
|
|
29
29
|
* @param checks - The check results to display
|
|
30
30
|
* @returns The exit code reflecting the overall result
|
|
31
31
|
*/
|
|
32
|
-
export declare function reportDoctorResults(checks: DoctorCheck[]): ExitCode;
|
|
32
|
+
export declare function reportDoctorResults(checks: DoctorCheck[], mutations?: readonly string[]): ExitCode;
|
|
33
33
|
/**
|
|
34
34
|
* Result of the doctor command, carrying the exit code so the caller
|
|
35
35
|
* (or test) can inspect it without relying on process.exitCode.
|
|
@@ -6,17 +6,19 @@ import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '
|
|
|
6
6
|
import { ensureGit } from '../core/git-base.js';
|
|
7
7
|
import { ensureMach, ensurePython } from '../core/mach.js';
|
|
8
8
|
import { countPatches } from '../core/patch-apply.js';
|
|
9
|
-
import {
|
|
9
|
+
import { validatePatchIntegrity } from '../core/patch-manifest.js';
|
|
10
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
10
11
|
import { ExitCode } from '../errors/codes.js';
|
|
11
12
|
import { toError } from '../utils/errors.js';
|
|
12
13
|
import { pathExists } from '../utils/fs.js';
|
|
13
14
|
import { error, info, intro, outro, success, warn } from '../utils/logger.js';
|
|
14
|
-
import { addWaitLockOption
|
|
15
|
+
import { addWaitLockOption } from '../utils/options.js';
|
|
15
16
|
import { findExecutable } from '../utils/process.js';
|
|
16
17
|
import { failure, ok, resolveDoctorSeverity, warning } from './doctor-check-core.js';
|
|
17
18
|
import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
|
|
18
19
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
19
20
|
import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
|
|
21
|
+
import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
|
|
20
22
|
import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
|
|
21
23
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
22
24
|
import { collectPatchQueueHealth } from './verify.js';
|
|
@@ -274,63 +276,7 @@ const DOCTOR_CHECKS = [
|
|
|
274
276
|
return ok('Patches found', `${patchCount} patch${patchCount === 1 ? '' : 'es'} found`);
|
|
275
277
|
},
|
|
276
278
|
},
|
|
277
|
-
|
|
278
|
-
name: 'Patch manifest consistency',
|
|
279
|
-
dependsOn: ['fireforge.json is valid'],
|
|
280
|
-
run: async (ctx) => {
|
|
281
|
-
if (!(await pathExists(ctx.paths.patches))) {
|
|
282
|
-
return [];
|
|
283
|
-
}
|
|
284
|
-
const manifestConsistencyIssues = await validatePatchesManifestConsistency(ctx.paths.patches);
|
|
285
|
-
if (manifestConsistencyIssues.length === 0) {
|
|
286
|
-
return ok('Patch manifest consistency');
|
|
287
|
-
}
|
|
288
|
-
if (!ctx.options.repairPatchesManifest) {
|
|
289
|
-
return failure('Patch manifest consistency', manifestConsistencyIssues.map((issue) => issue.message).join(' '), 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from patch files.');
|
|
290
|
-
}
|
|
291
|
-
// Repair stamps sourceEsrVersion into every recovered entry. If the
|
|
292
|
-
// earlier "fireforge.json is valid" check failed, ctx.config is
|
|
293
|
-
// undefined and we must refuse rather than fabricate a fallback —
|
|
294
|
-
// persisting 'unknown' into manifest metadata is hard to reverse
|
|
295
|
-
// and would mislead every later command that reads it.
|
|
296
|
-
if (!ctx.config) {
|
|
297
|
-
return failure('Patch manifest consistency', 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest".');
|
|
298
|
-
}
|
|
299
|
-
try {
|
|
300
|
-
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version, {
|
|
301
|
-
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
302
|
-
command: 'doctor --repair-patches-manifest',
|
|
303
|
-
});
|
|
304
|
-
// The repair path must not silently overwrite human-written
|
|
305
|
-
// descriptions on recovered entries, which leaves the queue less
|
|
306
|
-
// trustworthy as an audit trail. The rebuilder returns the list of
|
|
307
|
-
// filenames whose metadata was entirely invented, named explicitly
|
|
308
|
-
// here so the operator knows which patches to review. Entries that
|
|
309
|
-
// WERE preserved (only `filesAffected` / ordering drifted) are not
|
|
310
|
-
// flagged.
|
|
311
|
-
if (repaired.recoveredFilenames.length > 0) {
|
|
312
|
-
for (const filename of repaired.recoveredFilenames) {
|
|
313
|
-
// Telling the operator to hand-edit patches.json contradicts the
|
|
314
|
-
// README and downstream docs, which treat the manifest as
|
|
315
|
-
// FireForge-owned. Point at the existing `re-export` / `export`
|
|
316
|
-
// workflow so the fix stays inside the tool: re-exporting the
|
|
317
|
-
// same files with an explicit `--description` overwrites the
|
|
318
|
-
// recovered entry with operator-supplied metadata and supersedes
|
|
319
|
-
// the mtime-based createdAt stamp.
|
|
320
|
-
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
|
|
321
|
-
'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
|
|
322
|
-
'(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
|
|
323
|
-
'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
|
|
324
|
-
'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
},
|
|
279
|
+
PATCH_MANIFEST_CONSISTENCY_CHECK,
|
|
334
280
|
{
|
|
335
281
|
name: 'Patch integrity',
|
|
336
282
|
skipIf: (ctx) => !ctx.engineExists,
|
|
@@ -382,7 +328,7 @@ export const DOCTOR_CHECK_ORDER = DOCTOR_CHECKS.map((check) => check.name);
|
|
|
382
328
|
* @param checks - The check results to display
|
|
383
329
|
* @returns The exit code reflecting the overall result
|
|
384
330
|
*/
|
|
385
|
-
export function reportDoctorResults(checks) {
|
|
331
|
+
export function reportDoctorResults(checks, mutations = []) {
|
|
386
332
|
info('');
|
|
387
333
|
let passedCount = 0;
|
|
388
334
|
let warningCount = 0;
|
|
@@ -409,6 +355,16 @@ export function reportDoctorResults(checks) {
|
|
|
409
355
|
}
|
|
410
356
|
}
|
|
411
357
|
info('');
|
|
358
|
+
// Printed in every branch, including the failing one: a repair that wrote
|
|
359
|
+
// and then met an unrelated failing check must not be reported as a run
|
|
360
|
+
// where nothing happened.
|
|
361
|
+
if (mutations.length > 0) {
|
|
362
|
+
warn('Repairs applied this run:');
|
|
363
|
+
for (const mutation of mutations) {
|
|
364
|
+
warn(` ${mutation}`);
|
|
365
|
+
}
|
|
366
|
+
info('');
|
|
367
|
+
}
|
|
412
368
|
if (failedCount === 0 && warningCount === 0) {
|
|
413
369
|
outro(`All ${passedCount} checks passed!`);
|
|
414
370
|
}
|
|
@@ -421,12 +377,38 @@ export function reportDoctorResults(checks) {
|
|
|
421
377
|
}
|
|
422
378
|
return ExitCode.SUCCESS;
|
|
423
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Rejects repair-flag combinations that cannot mean what they say.
|
|
382
|
+
*
|
|
383
|
+
* The two manifest repairs are different operations on the same file, and
|
|
384
|
+
* `--dry-run` with nothing to project is a flag that silently does nothing —
|
|
385
|
+
* the shape most likely to be mistaken for a preview that reported "no
|
|
386
|
+
* changes".
|
|
387
|
+
*/
|
|
388
|
+
function assertRepairOptions(options) {
|
|
389
|
+
if (options.repairPatchesManifest === true && options.repairFilesAffected === true) {
|
|
390
|
+
throw new InvalidArgumentError('--repair-patches-manifest and --repair-files-affected are mutually exclusive. ' +
|
|
391
|
+
'Use --repair-files-affected for filesAffected drift; use --repair-patches-manifest ' +
|
|
392
|
+
'when rows are missing, untracked or duplicated.', '--repair-files-affected');
|
|
393
|
+
}
|
|
394
|
+
if (options.allowMetadataLoss === true && options.repairPatchesManifest !== true) {
|
|
395
|
+
throw new InvalidArgumentError('--allow-metadata-loss only applies to --repair-patches-manifest.', '--allow-metadata-loss');
|
|
396
|
+
}
|
|
397
|
+
if (options.dryRun === true &&
|
|
398
|
+
options.repairPatchesManifest !== true &&
|
|
399
|
+
options.repairFilesAffected !== true) {
|
|
400
|
+
throw new InvalidArgumentError('--dry-run needs a manifest repair to project. Pass it with ' +
|
|
401
|
+
'--repair-patches-manifest or --repair-files-affected. ' +
|
|
402
|
+
'(--repair-furnace has no dry-run: it reconciles engine state, not a manifest.)', '--dry-run');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
424
405
|
/**
|
|
425
406
|
* Runs the doctor command to diagnose issues.
|
|
426
407
|
* @param projectRoot - Root directory of the project
|
|
427
408
|
*/
|
|
428
409
|
export async function doctorCommand(projectRoot, options = {}) {
|
|
429
410
|
intro('FireForge Doctor');
|
|
411
|
+
assertRepairOptions(options);
|
|
430
412
|
const paths = getProjectPaths(projectRoot);
|
|
431
413
|
const state = await loadState(projectRoot);
|
|
432
414
|
const engineExists = await pathExists(paths.engine);
|
|
@@ -440,12 +422,13 @@ export async function doctorCommand(projectRoot, options = {}) {
|
|
|
440
422
|
config: undefined,
|
|
441
423
|
furnaceConfigExists: furnaceConfigExistsFlag,
|
|
442
424
|
furnaceConfig: undefined,
|
|
425
|
+
mutations: [],
|
|
443
426
|
};
|
|
444
427
|
const checks = [];
|
|
445
428
|
for (const definition of DOCTOR_CHECKS) {
|
|
446
429
|
checks.push(...(await executeCheck(definition, ctx)));
|
|
447
430
|
}
|
|
448
|
-
const exitCode = reportDoctorResults(checks);
|
|
431
|
+
const exitCode = reportDoctorResults(checks, ctx.mutations);
|
|
449
432
|
return { checks, exitCode };
|
|
450
433
|
}
|
|
451
434
|
/** Registers the doctor command on the CLI program. */
|
|
@@ -453,7 +436,10 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
|
|
|
453
436
|
const command = program
|
|
454
437
|
.command('doctor')
|
|
455
438
|
.description('Diagnose project issues')
|
|
456
|
-
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
|
|
439
|
+
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results. Only filesAffected and order are recomputed; every other field on an existing entry is preserved')
|
|
440
|
+
.option('--repair-files-affected', 'Repair only the filesAffected lists that disagree with their patch body, leaving every other manifest field untouched')
|
|
441
|
+
.option('--allow-metadata-loss', 'With --repair-patches-manifest, rebuild even when patches.json cannot be parsed — every descriptive field on every entry is then reinvented')
|
|
442
|
+
.option('--dry-run', 'Show what a requested repair would change without writing anything')
|
|
457
443
|
.option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
|
|
458
444
|
.option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
|
|
459
445
|
.option('--post-rebase-audit', 'Check common registration surfaces after a Firefox source rebase');
|
|
@@ -11,6 +11,7 @@ import { FurnaceError } from '../../errors/furnace.js';
|
|
|
11
11
|
import { toError } from '../../utils/errors.js';
|
|
12
12
|
import { pathExists, readText } from '../../utils/fs.js';
|
|
13
13
|
import { formatErrorText, formatSuccessText, info, intro, outro } from '../../utils/logger.js';
|
|
14
|
+
import { normalizePathSlashes } from '../../utils/paths.js';
|
|
14
15
|
/**
|
|
15
16
|
* Renders a multi-hunk unified diff between the two strings and returns a
|
|
16
17
|
* flat list of display-ready lines. Each line has already had its marker
|
|
@@ -64,7 +65,11 @@ async function diffOverride(name, projectRoot, config) {
|
|
|
64
65
|
if (!isComponentSourceFile(entry.name))
|
|
65
66
|
continue;
|
|
66
67
|
// git show takes a repo-relative path. paths.engine IS the repo root.
|
|
67
|
-
|
|
68
|
+
// The slice leaves the host's separators behind, and `git show <ref>:<path>`
|
|
69
|
+
// only understands forward slashes — on Windows the unnormalized form makes
|
|
70
|
+
// every baseline read miss, so every overridden file is reported as a new
|
|
71
|
+
// file and `furnace diff` shows nothing.
|
|
72
|
+
const enginePath = normalizePathSlashes(getOverrideEngineTargetPath(paths.engine, overrideConfig, entry.name, ftlDir).slice(paths.engine.length + 1));
|
|
68
73
|
const modifiedPath = join(overrideDir, entry.name);
|
|
69
74
|
const baselineDisplayPath = enginePath;
|
|
70
75
|
let originalContent;
|
|
@@ -23,6 +23,7 @@ import { isGitRepository } from '../../core/git.js';
|
|
|
23
23
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
24
24
|
import { pathExists, removeDir, removeFile } from '../../utils/fs.js';
|
|
25
25
|
import { info } from '../../utils/logger.js';
|
|
26
|
+
import { normalizePathSlashes } from '../../utils/paths.js';
|
|
26
27
|
import { removeDeployedCustomFiles } from './remove-state.js';
|
|
27
28
|
/**
|
|
28
29
|
* Restores every override-deployed file in `engine/` to its pristine HEAD
|
|
@@ -153,7 +154,10 @@ export async function performCustomRemovalMutations(args) {
|
|
|
153
154
|
if (await pathExists(ftlPath)) {
|
|
154
155
|
await snapshotFile(journal, ftlPath);
|
|
155
156
|
await removeFile(ftlPath);
|
|
156
|
-
|
|
157
|
+
// `ftlRel` is a native join, so on Windows an interpolated
|
|
158
|
+
// `engine/${ftlRel}` renders as `engine/toolkit\locales\...` — one
|
|
159
|
+
// message with both separators. Print the engine-relative form.
|
|
160
|
+
info(`Deleted localized file engine/${normalizePathSlashes(ftlRel)}`);
|
|
157
161
|
}
|
|
158
162
|
// Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
|
|
159
163
|
// wrote during deploy — otherwise the engine is left with a
|
|
@@ -8,7 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
|
|
|
8
8
|
import { getHead } from '../core/git.js';
|
|
9
9
|
import { getDirtyFiles } from '../core/git-status.js';
|
|
10
10
|
import { applyPatchesWithContinue, countPatches, createPatchedContentContext, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
|
|
11
|
-
import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
11
|
+
import { checkVersionCompatibility, loadPatchesManifest, recommendManifestRepair, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
12
12
|
import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
|
|
13
13
|
import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
|
|
14
14
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -239,7 +239,11 @@ async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, unt
|
|
|
239
239
|
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
240
240
|
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
241
241
|
` ${issueSummary}\n\n` +
|
|
242
|
-
|
|
242
|
+
// Naming the whole-manifest rebuild for drift that is only in
|
|
243
|
+
// `filesAffected` puts the operator one keystroke from rewriting every
|
|
244
|
+
// row to correct one derived list. The narrow repair is the remedy the
|
|
245
|
+
// failure actually calls for.
|
|
246
|
+
recommendManifestRepair(scopedManifestIssues));
|
|
243
247
|
}
|
|
244
248
|
}
|
|
245
249
|
/**
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readdir } from 'node:fs/promises';
|
|
21
21
|
import { basename, join } from 'node:path';
|
|
22
22
|
import { pathExists } from '../utils/fs.js';
|
|
23
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
23
24
|
/** Maximum directory depth to traverse when scanning a tree root. */
|
|
24
25
|
const MAX_SCAN_DEPTH = 12;
|
|
25
26
|
/**
|
|
@@ -54,11 +55,19 @@ export function isTestPath(sourcePath) {
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
57
|
-
* Splits a
|
|
58
|
-
*
|
|
58
|
+
* Splits a path into segments, dropping empties.
|
|
59
|
+
*
|
|
60
|
+
* Separators are normalized first because the candidates come from `join()`
|
|
61
|
+
* over a `readdir` walk of the dist tree: on Windows those carry backslashes,
|
|
62
|
+
* which a `/`-only split collapses into a SINGLE segment. Every candidate then
|
|
63
|
+
* scores the same, so the trailing-overlap heuristic that disambiguates
|
|
64
|
+
* same-basename artifacts (`branding/…/aboutDialog.css` versus
|
|
65
|
+
* `browser/…/aboutDialog.css`) silently picks an arbitrary one.
|
|
66
|
+
*
|
|
67
|
+
* @param path Path in either separator style
|
|
59
68
|
*/
|
|
60
69
|
function pathSegments(path) {
|
|
61
|
-
return path.split('/').filter(Boolean);
|
|
70
|
+
return normalizePathSlashes(path).split('/').filter(Boolean);
|
|
62
71
|
}
|
|
63
72
|
/**
|
|
64
73
|
* Counts how many trailing segments two paths share. Used to score
|