@hominis/fireforge 0.44.4 → 0.45.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 +40 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/doctor-source-pin.d.ts +29 -0
- package/dist/src/commands/doctor-source-pin.js +81 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export-flow.js +3 -18
- package/dist/src/commands/patch/move-files-into.js +3 -6
- package/dist/src/commands/patch/split-plan.js +3 -6
- package/dist/src/commands/re-export-adjacent.d.ts +8 -0
- package/dist/src/commands/re-export-adjacent.js +26 -1
- package/dist/src/commands/re-export-drift.d.ts +8 -0
- package/dist/src/commands/re-export-drift.js +61 -0
- package/dist/src/commands/re-export-files.js +3 -6
- package/dist/src/commands/re-export-options.js +19 -5
- package/dist/src/commands/re-export-register.js +5 -1
- package/dist/src/commands/re-export-scan.js +3 -6
- package/dist/src/commands/re-export.js +16 -16
- package/dist/src/commands/test-build-phase.js +2 -1
- package/dist/src/commands/test-diagnose.js +14 -5
- package/dist/src/commands/test-register.js +1 -0
- package/dist/src/commands/test-run.js +4 -3
- package/dist/src/commands/test-stale-gate.js +5 -2
- package/dist/src/commands/test.js +9 -2
- package/dist/src/core/build-audit.js +44 -2
- package/dist/src/core/build-jar-escalation.d.ts +64 -0
- package/dist/src/core/build-jar-escalation.js +127 -0
- package/dist/src/core/build-prepare.d.ts +12 -1
- package/dist/src/core/build-prepare.js +36 -3
- package/dist/src/core/config-validate-file-size.d.ts +12 -0
- package/dist/src/core/config-validate-file-size.js +65 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/harness-orphans.d.ts +73 -0
- package/dist/src/core/harness-orphans.js +186 -0
- package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
- package/dist/src/core/mach-known-noise-filter.js +24 -1
- package/dist/src/core/mach.d.ts +7 -0
- package/dist/src/core/mach.js +18 -2
- package/dist/src/core/patch-apply.js +49 -20
- package/dist/src/core/patch-lint-checkjs.js +6 -1
- package/dist/src/core/patch-lint-file-size.d.ts +52 -0
- package/dist/src/core/patch-lint-file-size.js +53 -0
- package/dist/src/core/patch-lint-prettier.d.ts +36 -0
- package/dist/src/core/patch-lint-prettier.js +126 -0
- package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
- package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
- package/dist/src/core/patch-lint.js +23 -24
- package/dist/src/core/patch-transform.d.ts +21 -0
- package/dist/src/core/patch-transform.js +38 -0
- package/dist/src/core/smoke-patterns.js +12 -0
- package/dist/src/core/test-harness-crash.js +61 -48
- package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
- package/dist/src/core/test-harness-verdict-notes.js +48 -0
- package/dist/src/core/test-stale-check.d.ts +23 -6
- package/dist/src/core/test-stale-check.js +75 -2
- package/dist/src/core/test-stall-triage.js +37 -19
- package/dist/src/errors/build.d.ts +28 -0
- package/dist/src/errors/build.js +49 -0
- package/dist/src/types/commands/options.d.ts +19 -0
- package/dist/src/types/commands/patches.d.ts +17 -6
- package/dist/src/types/config.d.ts +41 -0
- package/dist/src/utils/fs.d.ts +4 -2
- package/dist/src/utils/fs.js +3 -1
- package/docs/configuration.md +47 -0
- package/docs/exit-codes.md +4 -2
- package/docs/testing.md +62 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.45.0
|
|
4
|
+
|
|
5
|
+
### Patch bodies
|
|
6
|
+
|
|
7
|
+
- **A NEW binary file could not be vendored into a patch at all.** Adopting eight WOFF2 faces through `re-export --scan --scan-file` failed, after a clean lint pass, with `Cannot extract text content from binary patch section for …` — and the faces stayed unmanaged, so a `fireforge reset` left the patch's `@font-face` and `jar.inc.mn` lines pointing at missing files. The export half was never at fault: `generateBinaryFilePatch` wrote a correct, replayable `GIT binary patch`. The refusal came from the FORWARD-IMPORT PROJECTION downstream of it. `detectNewFilesInDiff` keys purely on `new file mode`, so binaries entered the loop, and every one of the five projection sites then handed each detected new file to `extractNewFileContentFromDiff`, which refuses binary sections — correctly, since the base85 alphabet includes `+` and "extract the + lines" would write garbage over a binary target.
|
|
8
|
+
The projection never needed the bytes. It feeds one rule, which scans content for import statements, and a binary blob authors none — so skipping a binary contributes exactly what including it would. The asymmetry was the tell: `buildPatchQueueContext` already tolerated binary new files for REAL queue entries (a per-file try/catch), and only the projections did not. The five duplicated loops are now one `buildNewFileTextProjection`, which skips binary sections and parses the diff once instead of once per new file. `extractNewFileContentFromDiff` still refuses a binary section named explicitly: a caller that asked for THIS file's text must be told no, not silently handed nothing. Nothing is given up by skipping — binary bodies are covered independently by the `binary-body-not-reconstructable` queue lint.
|
|
9
|
+
Worth recording that the reported symptom was narrower than stated in one direction and wider in another. Plain `fireforge export` of a new binary always worked; the export half fails only behind a placement flag (`--order`/`--before`/`--after`), which is what routes it through `projectPlacementForLint`. But the same projection sits under `patch move-files-into` and `patch split`, so a binary could not be moved between patches or split out either.
|
|
10
|
+
- **The new-file conflict recovery could not handle a binary target, and restored text where it should have restored bytes.** When `git apply` fails because a patch creates a file that is already on disk, FireForge resolves the conflict by putting the patch's intended content in place and retrying. For a binary there is nothing to extract — but nothing needs to be: `git apply` decodes the payload itself, so the resolution is to REMOVE the blocking file and let the retry create it. A `Binary files … differ` body, which records that the bytes changed and carries none of them, is refused instead of deleted, because nothing could put the file back. The saved originals are now raw `Buffer`s rather than utf-8 text, which also closes a latent corruption on the pre-existing path: a binary file overwritten by this recovery was previously restored through a `readText`/`writeText` round-trip that mangles it.
|
|
11
|
+
|
|
12
|
+
### Refusals
|
|
13
|
+
|
|
14
|
+
- **`--expect <path>` warned "showed no drift" over a path that did drift and was captured.** The notice was the only feedback that the belt looked at the right files, and none of the causes it named held; the reporter's workaround was `grep -c` on the written body. The measurement was never stated, and stating it is the diagnosis: the comparison is CONTENT, per patch, on the body about to be written and never against post-write state — and it is ONE-DIRECTIONAL, reporting only the payload lines the refreshed body carries that the old body did not. Removing a line from an engine file makes the refreshed body DROP that file's `+` line, so nothing is absorbed and by design there is no drift to see. The notice now says what it compares and when, leads with that one-directional cause, and adds the `--scan-file` adoption case as a named cause (adopting a file also captures that patch's other drifted files, so a scripted scan-less re-export afterwards legitimately sees nothing left — informational at exit 0, and a chain treating the warning as an error will false-red on it). Widening the comparison was considered and rejected: it would turn every ordinary line removal into a refusal, and the belt exists to catch a concurrent session's edits being swallowed INTO a body.
|
|
15
|
+
- **The coverage refusal left its own triage step to the reader.** Adding a test to `xpcshell.toml` while the packaged runtime came from a browser-chrome `test --build` was refused correctly, naming the covered paths, the requested paths, the rebuild command and `--extend-coverage`. What it did not say is WHY this run needed new coverage: a MANIFEST gained an entry, and a browser-chrome build claim never covers xpcshell. FireForge can see both facts. The refusal now names the test manifests changed since the recorded build that sit in an uncovered request path's directory. Behaviour is unchanged; the probe is best-effort and a failure renders exactly the previous text.
|
|
16
|
+
- **`--refuse-adjacent-unmanaged` was all-or-nothing against a recorded exception.** A project with a reviewed, deliberately unmanaged path beside a patch's ownership had no way to say so, so every re-export of every adjacent patch had to drop the flag entirely and substitute a hand-read of the notice — a weaker belt than the one being disarmed, recurring on every touch. `--expect-unmanaged <path>` (repeatable, requires `--refuse-adjacent-unmanaged`) names an approved exception: the belt stays armed for everything else. Approved paths are still LISTED, as an explicit note that they were admitted rather than dropped, because a carve-out nobody can see is how one quietly widens. A path that is never met is reported at the end of the run, for the same reason `--expect` reports its unseen entries: a typo'd carve-out silently leaves the refusal armed for the path it was meant to admit, while reading as an approval that is in force. Deliberately a flag and not a config list — a standing exception nobody re-reads is exactly what this refusal exists to prevent.
|
|
17
|
+
|
|
18
|
+
### Diagnostics
|
|
19
|
+
|
|
20
|
+
- **`run --smoke-exit` gave a clean window over the one line that predicts a crash under automation.** The 0.44.1 discriminator (`run --smoke-exit 60 --headless --capture-console <file>`) was run against a build whose startpage pointed at a nonexistent `chrome://` image. It exited 0 with `Unallowed errors: 0` — the "startup is healthy" answer — for a build that hung EVERY browser-chrome run at "Waiting for browser…", while the capture it wrote held the discriminating line: `Missing chrome or resource URL: chrome://…`. That is Gecko's non-automation spelling (`printf_stderr` in `CheckForBrokenChromeURL`, `netwerk/base/nsNetUtil.cpp`); under `xpc::IsInAutomation()` the identical condition is `MOZ_CRASH_UNSAFE_PRINTF("Missing chrome or resource URLs: %s")`. So the probe did discriminate — but only for a reader who grepped the capture, and the exit code said the opposite. `^Missing chrome or resource URLs?:` is now a smoke error pattern, allowlistable through `--console-allow` like any other.
|
|
21
|
+
- **The no-output-stall triage told operators to read a log that structurally cannot carry the evidence.** Cause (3) was recorded as an un-root-caused correlation with a broken `chrome://` image URI. It is now root-caused, and the mechanism explains why the log is empty: under automation the `MOZ_CRASH` lands in whichever process opened the channel, and in the downstream reproduction that was a CONTENT process (`plugin-container`) with the crash reporter compiled out — so the harness log carried neither the message nor a crash line, only the 370 s timeout, `Ran 0 checks`, and mochitest's "Can't trigger Breakpad, just killing process". The only artefact was `~/Library/Logs/DiagnosticReports/plugin-container-*.ips`, faulting frame `CheckForBrokenChromeURL`, which is the symbolicated diagnosis the census bar asks for. The census now names that artefact and the smoke probe together, and states that the log naming nothing is the expected shape rather than an absence of evidence. Detection by log line is still deliberately not specified, and the module doc now records why.
|
|
22
|
+
- **The green-suite teardown belt could reject a run without saying which condition rejected it.** On a composed gate's tiles step: exit 1, 201 `TEST_END`, zero `Test FAIL`, zero `TEST-UNEXPECTED`, only the recognized `mozsystemmonitor` teardown traceback in the tail, FireForge's own "Known upstream …" note printed — and `FIREFORGE-VERDICT: FAIL reason=test-failures … unexpected=0`. The re-run was green and the log had rotated. Every condition in the belt is all-or-nothing, so from outside the failing verdict looks identical whichever one rejected it, and diagnosis reduced to guessing. A rejected run that DID carry the recognized teardown noise now names the first failing condition on the verdict line — `green-teardown override rejected: 2 matched failure line(s), first: …`. A run with no teardown noise was never a candidate for the belt and says nothing, because naming a rejecting condition there would be pure noise. Advisory only: the status and `reason` keys the verdict contract's consumers parse are unchanged.
|
|
23
|
+
- **The undefined-identifier hint named the shim when the cause was an unmanaged companion — and taking the hint would have masked a real bug.** Hoisting duplicated test helpers into a new `head_settings.js` that a managed `head.js` loads via `loadSubScript` leaves the companion owned by no patch until export, so the per-patch program reports every helper use as `Cannot find name` (157 warnings, in the reported case) with a hint offering to add the globals to the extra shim. That remedy makes all 157 disappear AND makes a genuinely missing helper undetectable forever after; the actual fix, `re-export <patch> --scan --scan-file <file>`, the hint never mentioned. When EVERY undefined name in a file is declared at the top level of an unmanaged file that a managed head loads, the hint now names that file, its loader and the adoption command, and rules the shim out explicitly. One unresolved name and the generic hint stays — the file may genuinely be missing a helper, which is the bug this detection exists not to hide.
|
|
24
|
+
- **A test-suite red rendered a build-shaped remedy list.** A failing assertion exited through `BuildError`, so the operator was told to "check the build output above", run `fireforge bootstrap`, and delete `obj-*` directories. None of the three applies to a failing test, and the first points at the wrong half of the log — the half a `tail` keeps. `TestFailureError` now carries remedies that name the failing tests (the `TEST-UNEXPECTED-*` blocks the verdict already collected and, until now, only logged), the `FIREFORGE-VERDICT:` line as ground truth, and the run log path, which is the one artifact that survives the pipe that caused the confusion. It subclasses `BuildError` and **keeps exit 5** deliberately: `docs/exit-codes.md` documents 5 as "a suite that FAILED" and consumers key CI on it, so only the message changes. The `--json` error code does move, from `build` to `test-failure`, because `machineErrorCode` derives it from the class name. `'Test process failed to start'` stays a `BuildError` — a harness that never spawned genuinely can be a build or environment problem, and the build remedies are apt there.
|
|
25
|
+
- **The known-teardown-noise filter covered the test path and not the build path — and could not simply be extended to it.** The `SystemResourceMonitor` `AttributeError` at teardown is recognized, documented upstream noise, and `mach build` surfaced it unannotated, where it reached one consumer as an unexplained build-log traceback during a 153.2.0esr drill. Threading the existing `annotateKnownTeardownNoise` option through the protected build dispatch was the obvious fix and is the wrong one, twice over. It would be a silent NO-OP: recognition requires a previously-seen `SUITE_END`, and a build never prints one. And relaxing that gate would be wrong on its own terms — the marker is what separates teardown from work still in progress, a build has no equivalent boundary, so FireForge cannot tell the known-cosmetic case from a real build-time traceback and must not withhold the block. Separately, the inherit path has no per-chunk hook, only a mirror that feeds the terminal and the run log the same string, so filtering there would strip the traceback from the raw log — the opposite of the "raw into the log, filtered into the echo" invariant the filter was built around.
|
|
26
|
+
So the build path RECOGNIZES rather than collapses: the traceback prints verbatim and one labeled line names it, using the same closed allowlist (two attributes, plus a `resourcemonitor.py` frame). A novel attribute stays unannotated, because it is a new upstream defect. The filter's module doc now states the test-only scope and both reasons for it, `MachOptions.annotateKnownTeardownNoise` documents that the inherit path ignores it (it type-checked and did nothing, which is how this reads as an oversight from outside), and the signature is documented in `docs/testing.md` — resolving the annotation's dangling "See FireForge docs.", which pointed at documentation that did not exist.
|
|
27
|
+
- **`fireforge source set` wrote a pin that a routine revert silently lost.** The pin lands in `fireforge.json` beside hand-maintained policy sections, so `git checkout -- fireforge.json` — undoing an accidental whole-file reformat, say — also reverts an uncommitted pin, and nothing afterwards reported that the pin and the engine checkout had diverged. The field tell was a gate flipping green with no change that should have made it green. `fireforge doctor` now reports the configured pin beside what the checkout actually is — the engine's own `browser/config/version.txt` and the version the last download recorded into state — and warns when they disagree. A report, not a lock and not a new refusal: a tree may legitimately be mid-migration, and the only thing missing was visibility. Scope is limited by what is actually recorded: `product`, `sha256` and `candidate` have no counterpart on disk (state holds only `downloadedVersion` and `baseCommit`), so the check says nothing about them rather than implying it verified them. A blank or whitespace-only `version.txt` — a truncated write, a partial extraction — reads as absent rather than as a mismatch against the empty string. The check reuses `getFirefoxVersion`, which had been sitting in `firefox-extract.ts` with no production caller.
|
|
28
|
+
|
|
29
|
+
### Builds
|
|
30
|
+
|
|
31
|
+
- **The jar-only full-build escalation was over-broad.** `requiresFullBuildForIncrementalTest` was exactly `isJarManifestPath`, so ANY changed jar manifest escalated the pre-test build to a full `mach build` — ~10 minutes against ~20 s for a chrome-only slice, paid twice per green/no-change pair on a Furnace-touching slice. A downstream experiment (two clean runs against 0.44.4) settled the common case: a jar-only registration of a NEW content file added to an EXISTING `dist/bin` manifest was installed by a plain `fireforge build --ui` both times — `faster/install_dist_bin_browser` named the destination, the file landed in `dist/bin/…/content/browser/` and in the `.app` bundle, and a plain `fireforge test` fetched it over `chrome://` at runtime. Neither run escalated. The escalation is now narrowed to the two halves nobody has run: a NEW `jar.mn` (untracked in the engine repo — no install manifest exists for it yet) and a manifest whose jar declaration carries a bracketed base-directory prefix, which redirects the install destination away from the default chrome root. Every probe failure still escalates, so the error direction stays slow-but-correct, and the notice now names the manifest AND the reason instead of saying "a jar.mn registration changed".
|
|
32
|
+
- **Post-build packaging notices could not be triaged on a shared checkout.** The missing-artifact notice keys on "was touched", which on a checkout several sessions share means any of their dirty files — so an operator got a registration-shaped warning about a file they never edited and had to census ownership by hand to dismiss it. Each notice now carries the owning patch, or an explicit `unmanaged` mark. Resolution is best-effort and shares `status --ownership`'s source (the manifest's `filesAffected`); with no queue or an unreadable manifest the notice renders exactly as before, because "unmanaged" has to mean "checked and unowned" rather than "could not check".
|
|
33
|
+
|
|
34
|
+
### Test harness
|
|
35
|
+
|
|
36
|
+
- **Nothing could see or reap orphaned harness processes.** Stopping a 45-minute tiles run mid-flight left `xpcshell` (the harness httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2` alive for an hour. Later runs crawled — a three-second suite took six minutes of wall clock, audio-start waits timed out — and nothing in the output connected the slowness to the survivors; the hour went into mis-attributing it to the change under test. Every `fireforge test` dispatch now runs a census of harness helper processes anchored to this project's objdir, naming each with its elapsed time and the `kill` command. It runs at PREFLIGHT, before this run spawns anything, so every hit is by construction a survivor and no age heuristic is needed. A match must be objdir-anchored: `xpcshell` and `server.js` are far too generic to report on their own, the same rule the mochitest server-port preflight already applies. Report-only by default; `--reap-orphans` opts into terminating them. It never refuses a run — a survivor degrades performance rather than making the run dispatch against the wrong server, so the right response is to say so loudly, not to stop.
|
|
37
|
+
|
|
38
|
+
### Patch lint
|
|
39
|
+
|
|
40
|
+
- **Per-patch lint ran no Prettier on `.sys.mjs`, so module formatting drift was invisible to every gate** — which is how it survived to a human reviewer. `patchLint.prettier` (`'off'` by default) runs the project's configured Prettier over patch-owned `.sys.mjs` modules. It runs with `cwd` set to `engine/`, which is the half that matters: a fork's ROOT `.prettierignore` typically excludes `engine/`, so the same `prettier --check --config engine/.prettierrc.js <engine files>` run from the repo root reports "all files use Prettier code style" for files that fail the identical check from inside `engine/` — a root-level spot-check falsely refuted a correct reviewer finding on exactly this. Prettier is resolved from `engine/node_modules` first, then the project's, then `npx --no-install`. Exit 2 is reported as prettier failing rather than as formatting drift, since `--write` cannot fix it. Left off, formatting is explicitly out of scope for the per-patch tier — that is now the stated contract rather than an accident.
|
|
41
|
+
- **The `file-too-large` thresholds were module constants, and the soft tier was effectively hard.** Nothing in `fireforge.json` could move `500/750/900` (general) or `1200/1400/1600` (test), and because the recommended gate posture is `--max-warnings 0`, the 750 "soft limit" is a hard failure — a controller sitting at 751 lines failed a gate with a message calling 750 soft, at exactly the moment someone is deciding whether to restructure a file. `patchLint.fileSizeThresholds.{general,test}.{notice,warning,error}` now tunes them (each field optional, merged over the defaults, validated as positive integers ordered `notice <= warning <= error` against the MERGED triple). The message drops the soft/hard vocabulary for what the band actually is — a warning — and names the consequence: a gate running `--max-warnings 0` treats it as a failure. The patch-SIZE thresholds keep their existing wording; only the file-size rule was reported.
|
|
42
|
+
|
|
3
43
|
## 0.44.0
|
|
4
44
|
|
|
5
45
|
### Gates
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"shortCommit": "
|
|
3
|
+
"version": "0.45.0",
|
|
4
|
+
"commit": "19d461eb43b89962970510f9a2e655704e9fb952",
|
|
5
|
+
"shortCommit": "19d461eb43b8",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-
|
|
8
|
+
"buildTime": "2026-09-01T10:47:47.465Z"
|
|
9
9
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check comparing the CONFIGURED Firefox source pin against what the
|
|
3
|
+
* engine checkout actually is.
|
|
4
|
+
*
|
|
5
|
+
* `fireforge source set` lands `firefox.version` inside `fireforge.json`,
|
|
6
|
+
* beside hand-maintained policy sections. That is convenient and it is also
|
|
7
|
+
* the whole problem: a routine `git checkout -- fireforge.json` — undoing an
|
|
8
|
+
* accidental reformat, say — silently reverts an uncommitted pin along with
|
|
9
|
+
* it, and nothing afterwards reports that the pin and the checkout have
|
|
10
|
+
* diverged. The tell in the field was a gate flipping green with no change
|
|
11
|
+
* that should have made it green.
|
|
12
|
+
*
|
|
13
|
+
* This is deliberately not a lock and not a refusal: a tree may legitimately
|
|
14
|
+
* be mid-migration between two versions. It only makes a mismatched tree
|
|
15
|
+
* VISIBLE, which is all that was missing.
|
|
16
|
+
*
|
|
17
|
+
* Scope is limited by what is actually recorded. `firefox.product`,
|
|
18
|
+
* `firefox.sha256` and `firefox.candidate` have NO counterpart anywhere on
|
|
19
|
+
* disk — `.fireforge/state.json` records only `downloadedVersion` and
|
|
20
|
+
* `baseCommit` — so there is nothing to compare them against and the check
|
|
21
|
+
* says nothing about them rather than implying it verified them.
|
|
22
|
+
*/
|
|
23
|
+
import type { DoctorCheckDefinition } from './doctor-check-core.js';
|
|
24
|
+
/**
|
|
25
|
+
* Pin-vs-checkout reporting. Needs the loaded config, so it declares the
|
|
26
|
+
* dependency on the config check that populates `ctx.config`; skipped
|
|
27
|
+
* entirely without an engine, where there is nothing to compare.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SOURCE_PIN_DOCTOR_CHECK: DoctorCheckDefinition;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Doctor check comparing the CONFIGURED Firefox source pin against what the
|
|
4
|
+
* engine checkout actually is.
|
|
5
|
+
*
|
|
6
|
+
* `fireforge source set` lands `firefox.version` inside `fireforge.json`,
|
|
7
|
+
* beside hand-maintained policy sections. That is convenient and it is also
|
|
8
|
+
* the whole problem: a routine `git checkout -- fireforge.json` — undoing an
|
|
9
|
+
* accidental reformat, say — silently reverts an uncommitted pin along with
|
|
10
|
+
* it, and nothing afterwards reports that the pin and the checkout have
|
|
11
|
+
* diverged. The tell in the field was a gate flipping green with no change
|
|
12
|
+
* that should have made it green.
|
|
13
|
+
*
|
|
14
|
+
* This is deliberately not a lock and not a refusal: a tree may legitimately
|
|
15
|
+
* be mid-migration between two versions. It only makes a mismatched tree
|
|
16
|
+
* VISIBLE, which is all that was missing.
|
|
17
|
+
*
|
|
18
|
+
* Scope is limited by what is actually recorded. `firefox.product`,
|
|
19
|
+
* `firefox.sha256` and `firefox.candidate` have NO counterpart anywhere on
|
|
20
|
+
* disk — `.fireforge/state.json` records only `downloadedVersion` and
|
|
21
|
+
* `baseCommit` — so there is nothing to compare them against and the check
|
|
22
|
+
* says nothing about them rather than implying it verified them.
|
|
23
|
+
*/
|
|
24
|
+
import { getFirefoxVersion } from '../core/firefox.js';
|
|
25
|
+
import { toError } from '../utils/errors.js';
|
|
26
|
+
import { ok, warning } from './doctor-check-core.js';
|
|
27
|
+
const CHECK_NAME = 'Source pin matches engine';
|
|
28
|
+
/**
|
|
29
|
+
* Reports the configured pin beside the two facts that can contradict it:
|
|
30
|
+
* the engine's own `browser/config/version.txt`, and the version the last
|
|
31
|
+
* download recorded into state.
|
|
32
|
+
*/
|
|
33
|
+
async function runSourcePinCheck(ctx) {
|
|
34
|
+
const pinned = ctx.config?.firefox.version;
|
|
35
|
+
if (pinned === undefined) {
|
|
36
|
+
// The config failed to load (its own check already reported that), so
|
|
37
|
+
// there is no pin to compare. Silence beats a second complaint.
|
|
38
|
+
return ok(CHECK_NAME, 'Skipped: no configured source version');
|
|
39
|
+
}
|
|
40
|
+
let actual;
|
|
41
|
+
try {
|
|
42
|
+
// A blank or whitespace-only version.txt (a truncated write, a partial
|
|
43
|
+
// extraction) carries no version to compare — treat it as absent rather
|
|
44
|
+
// than reporting the engine as being at version "".
|
|
45
|
+
actual = (await getFirefoxVersion(ctx.paths.engine))?.trim() || undefined;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Best-effort: an unreadable version.txt is not a pin mismatch, and a
|
|
49
|
+
// diagnostic must never turn into a failure of its own.
|
|
50
|
+
return ok(CHECK_NAME, `Configured ${pinned}; engine version could not be read (${toError(error).message})`);
|
|
51
|
+
}
|
|
52
|
+
const downloaded = ctx.state.downloadedVersion;
|
|
53
|
+
const mismatches = [];
|
|
54
|
+
if (actual !== undefined && actual !== pinned) {
|
|
55
|
+
mismatches.push(`engine/browser/config/version.txt reads ${actual}`);
|
|
56
|
+
}
|
|
57
|
+
if (downloaded !== undefined && downloaded !== pinned) {
|
|
58
|
+
mismatches.push(`the last download recorded ${downloaded}`);
|
|
59
|
+
}
|
|
60
|
+
if (mismatches.length === 0) {
|
|
61
|
+
const observed = actual ?? downloaded;
|
|
62
|
+
return ok(CHECK_NAME, observed === undefined
|
|
63
|
+
? `Configured ${pinned}; no engine version to compare against yet`
|
|
64
|
+
: `Configured ${pinned}, engine ${observed}`);
|
|
65
|
+
}
|
|
66
|
+
return warning(CHECK_NAME, `fireforge.json pins Firefox ${pinned}, but ${mismatches.join(' and ')}.`, 'If the pin is right, run "fireforge download" to bring the checkout to it. If the ' +
|
|
67
|
+
'CHECKOUT is right, run "fireforge source set --version <version>" to record it — a ' +
|
|
68
|
+
'pin set but not committed is lost by any revert of fireforge.json, which is the most ' +
|
|
69
|
+
'common way these diverge.');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Pin-vs-checkout reporting. Needs the loaded config, so it declares the
|
|
73
|
+
* dependency on the config check that populates `ctx.config`; skipped
|
|
74
|
+
* entirely without an engine, where there is nothing to compare.
|
|
75
|
+
*/
|
|
76
|
+
export const SOURCE_PIN_DOCTOR_CHECK = {
|
|
77
|
+
name: CHECK_NAME,
|
|
78
|
+
skipIf: (ctx) => !ctx.engineExists,
|
|
79
|
+
dependsOn: ['fireforge.json is valid'],
|
|
80
|
+
run: runSourcePinCheck,
|
|
81
|
+
};
|
|
@@ -20,6 +20,7 @@ import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
|
20
20
|
import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
|
|
21
21
|
import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
|
|
22
22
|
import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
|
|
23
|
+
import { SOURCE_PIN_DOCTOR_CHECK } from './doctor-source-pin.js';
|
|
23
24
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
24
25
|
import { collectPatchQueueHealth } from './verify.js';
|
|
25
26
|
/**
|
|
@@ -211,6 +212,7 @@ const DOCTOR_CHECKS = [
|
|
|
211
212
|
return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed. If the queue now verifies cleanly, run "fireforge doctor --clear-resolution" to discard the stale marker.');
|
|
212
213
|
},
|
|
213
214
|
},
|
|
215
|
+
SOURCE_PIN_DOCTOR_CHECK,
|
|
214
216
|
{
|
|
215
217
|
name: 'Engine is git repository',
|
|
216
218
|
skipIf: (ctx) => !ctx.engineExists,
|
|
@@ -12,12 +12,12 @@ import {} from '../core/destructive.js';
|
|
|
12
12
|
import { detectMovedCodeOverlaps, formatAdoptThenSplitRemedy, } from '../core/export-moved-code-shape.js';
|
|
13
13
|
import { normalizePatchArtifact } from '../core/patch-artifact-normalize.js';
|
|
14
14
|
import { findAllPatchesForFilesWithDetails, patchNameSlug, planExport, } from '../core/patch-export.js';
|
|
15
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext,
|
|
15
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, lintPatchQueue, } from '../core/patch-lint.js';
|
|
16
16
|
import { withPatchDirectoryLock } from '../core/patch-lock.js';
|
|
17
17
|
import { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
|
|
18
18
|
import { requirePatchOrder } from '../core/patch-parse.js';
|
|
19
19
|
import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
|
|
20
|
-
import {
|
|
20
|
+
import { buildNewFileTextProjection } from '../core/patch-transform.js';
|
|
21
21
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
22
22
|
import { toError } from '../utils/errors.js';
|
|
23
23
|
import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
|
|
@@ -166,21 +166,6 @@ export async function resolvePlacementPlan(patchesDir, options, category, name,
|
|
|
166
166
|
assertPlacementAvoidsReservedRanges(plan, existingPatches, config);
|
|
167
167
|
return plan;
|
|
168
168
|
}
|
|
169
|
-
/**
|
|
170
|
-
* Extracts the newly-created files a diff would produce and builds the
|
|
171
|
-
* `newFiles` map in the shape expected by {@link PatchQueueEntry}. Used
|
|
172
|
-
* to build a faithful synthetic entry for the pending patch when
|
|
173
|
-
* projecting through cross-patch lint — without this the forward-import
|
|
174
|
-
* rule cannot see imports authored by the new patch itself.
|
|
175
|
-
*/
|
|
176
|
-
function buildNewFilesFromDiff(diff) {
|
|
177
|
-
const newFiles = new Map();
|
|
178
|
-
const newFilePaths = detectNewFilesInDiff(diff);
|
|
179
|
-
for (const path of newFilePaths) {
|
|
180
|
-
newFiles.set(path, extractNewFileContentFromDiff(diff, path));
|
|
181
|
-
}
|
|
182
|
-
return newFiles;
|
|
183
|
-
}
|
|
184
169
|
/**
|
|
185
170
|
* Projects the placement through cross-patch lint to detect forward-imports
|
|
186
171
|
* the renumber would introduce *or* that the new patch itself would
|
|
@@ -206,7 +191,7 @@ export async function projectPlacementForLint(patchesDir, plan, diff, config) {
|
|
|
206
191
|
order: plan.insertionOrder,
|
|
207
192
|
metadata: null,
|
|
208
193
|
diff,
|
|
209
|
-
newFiles:
|
|
194
|
+
newFiles: buildNewFileTextProjection(diff),
|
|
210
195
|
modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff),
|
|
211
196
|
});
|
|
212
197
|
projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { appendHistory, assertConfirmationAvailable, confirmDestructive, } from '../../core/destructive.js';
|
|
19
19
|
import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
|
|
20
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges,
|
|
20
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
21
21
|
import { computeProjectedLintRegressions } from '../../core/patch-lint-projection.js';
|
|
22
22
|
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
23
23
|
import { loadPatchesManifest, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
|
|
24
|
-
import {
|
|
24
|
+
import { buildNewFileTextProjection } from '../../core/patch-transform.js';
|
|
25
25
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
26
26
|
import { toError } from '../../utils/errors.js';
|
|
27
27
|
import { readText, writeText } from '../../utils/fs.js';
|
|
@@ -30,10 +30,7 @@ import { resolveWaitLockSeconds } from '../../utils/options.js';
|
|
|
30
30
|
import { runPatchLint } from '../export-shared.js';
|
|
31
31
|
import { buildSplitDiff, findOwnerRewriteHolders, mergeStagedForwardImports, rewriteSplitOwners, } from './split-plan.js';
|
|
32
32
|
function projectEntryBody(diff) {
|
|
33
|
-
const newFiles =
|
|
34
|
-
for (const path of detectNewFilesInDiff(diff)) {
|
|
35
|
-
newFiles.set(path, extractNewFileContentFromDiff(diff, path));
|
|
36
|
-
}
|
|
33
|
+
const newFiles = buildNewFileTextProjection(diff);
|
|
37
34
|
return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
|
|
38
35
|
}
|
|
39
36
|
/**
|
|
@@ -10,12 +10,12 @@ import { join } from 'node:path';
|
|
|
10
10
|
import {} from '../../core/destructive.js';
|
|
11
11
|
import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
|
|
12
12
|
import { extractAffectedFiles } from '../../core/patch-apply.js';
|
|
13
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges,
|
|
13
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
14
14
|
import { computeProjectedLintRegressions } from '../../core/patch-lint-projection.js';
|
|
15
15
|
import { rewriteStagedDependencyOwners } from '../../core/patch-manifest.js';
|
|
16
16
|
import { applyRenameMapToManifest, buildProjectedManifest } from '../../core/patch-policy.js';
|
|
17
17
|
import { buildPatchSourceMetadata } from '../../core/patch-source-metadata.js';
|
|
18
|
-
import {
|
|
18
|
+
import { buildNewFileTextProjection } from '../../core/patch-transform.js';
|
|
19
19
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
20
20
|
import { pathExists } from '../../utils/fs.js';
|
|
21
21
|
import { warn } from '../../utils/logger.js';
|
|
@@ -82,10 +82,7 @@ export function rewriteSplitOwners(patch, sourceFilename, movedSet, newFilename)
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
function buildEntryProjection(diff) {
|
|
85
|
-
const newFiles =
|
|
86
|
-
for (const path of detectNewFilesInDiff(diff)) {
|
|
87
|
-
newFiles.set(path, extractNewFileContentFromDiff(diff, path));
|
|
88
|
-
}
|
|
85
|
+
const newFiles = buildNewFileTextProjection(diff);
|
|
89
86
|
return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
|
|
90
87
|
}
|
|
91
88
|
/** Builds the projected post-split queue entries (renumber + shrunken source + new patch). */
|
|
@@ -10,6 +10,14 @@ export interface AdjacentUnmanagedContext {
|
|
|
10
10
|
binaryName: string;
|
|
11
11
|
furnacePrefixes: ReadonlySet<string>;
|
|
12
12
|
refuseAdjacentUnmanaged: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Engine-relative paths named by `--expect-unmanaged`: reviewed, recorded
|
|
15
|
+
* exceptions that are reported but never refuse. Normalized once by the
|
|
16
|
+
* caller so the comparison matches git's engine-relative output.
|
|
17
|
+
*/
|
|
18
|
+
approvedUnmanaged: ReadonlySet<string>;
|
|
19
|
+
/** Approved paths actually met this run, so unused ones can be surfaced. */
|
|
20
|
+
approvedSeen: Set<string>;
|
|
13
21
|
refusals: {
|
|
14
22
|
patchFilename: string;
|
|
15
23
|
files: string[];
|
|
@@ -64,6 +64,26 @@ async function findAdjacentUnmanagedFiles(args) {
|
|
|
64
64
|
}
|
|
65
65
|
return [...candidates].sort();
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Splits the candidates into the ones that refuse and the ones a
|
|
69
|
+
* `--expect-unmanaged` carve-out admits. Approved paths are recorded as seen
|
|
70
|
+
* so an unused carve-out can be reported, and are RETURNED rather than
|
|
71
|
+
* dropped: an exception nobody can see is how a carve-out quietly widens.
|
|
72
|
+
*/
|
|
73
|
+
function partitionApproved(files, ctx) {
|
|
74
|
+
const refusing = [];
|
|
75
|
+
const approved = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (ctx.approvedUnmanaged.has(file)) {
|
|
78
|
+
ctx.approvedSeen.add(file);
|
|
79
|
+
approved.push(file);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
refusing.push(file);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { refusing, approved };
|
|
86
|
+
}
|
|
67
87
|
/**
|
|
68
88
|
* Emits the scan-less advisory for one patch: missing manifest files and
|
|
69
89
|
* unmanaged files adjacent to the patch's ownership. When
|
|
@@ -83,13 +103,18 @@ export async function reportAdjacentUnmanagedFiles(args) {
|
|
|
83
103
|
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
84
104
|
`or pass --files <paths> to set the list explicitly.`);
|
|
85
105
|
}
|
|
86
|
-
const
|
|
106
|
+
const allUnmanaged = await findAdjacentUnmanagedFiles({
|
|
87
107
|
currentFilesAffected,
|
|
88
108
|
engineDir: paths.engine,
|
|
89
109
|
manifest,
|
|
90
110
|
patchFilename: patch.filename,
|
|
91
111
|
ctx,
|
|
92
112
|
});
|
|
113
|
+
const { refusing: unmanagedFiles, approved } = partitionApproved(allUnmanaged, ctx);
|
|
114
|
+
if (approved.length > 0) {
|
|
115
|
+
info(`${patch.filename}: ${String(approved.length)} adjacent unmanaged file(s) admitted by ` +
|
|
116
|
+
`--expect-unmanaged (${approved.join(', ')}) — reported, not refused.`);
|
|
117
|
+
}
|
|
93
118
|
if (unmanagedFiles.length === 0)
|
|
94
119
|
return false;
|
|
95
120
|
const firstFew = unmanagedFiles.slice(0, 3).join(', ');
|
|
@@ -72,3 +72,11 @@ export declare function reportForeignDrift(args: {
|
|
|
72
72
|
newDiffContent: string;
|
|
73
73
|
ctx: ForeignDriftContext;
|
|
74
74
|
}): Promise<boolean>;
|
|
75
|
+
/**
|
|
76
|
+
* Names `--expect` paths that never drifted this run. A typo'd
|
|
77
|
+
* `--expect` path silently degrades the flag back to refusing the slice it
|
|
78
|
+
* was meant to admit, so surface the mismatch — but only as a warning: an
|
|
79
|
+
* expected file legitimately shows no drift in several shapes, all named
|
|
80
|
+
* below.
|
|
81
|
+
*/
|
|
82
|
+
export declare function warnUnseenExpectedDrift(driftCtx: ForeignDriftContext): void;
|
|
@@ -271,3 +271,64 @@ function refuseUnreadableBaseline(ctx, patchFilename, detail) {
|
|
|
271
271
|
warn(`${patchFilename}: skipped (--refuse-foreign-drift) — ${detail}; the drift comparison cannot run, refusing fail-closed.`);
|
|
272
272
|
return true;
|
|
273
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* States, verbatim in the notice, WHAT the `--expect` whitelist is compared
|
|
276
|
+
* against and WHEN. This notice is the only feedback an operator gets that
|
|
277
|
+
* the drift belt looked at the files they named, and a downstream report
|
|
278
|
+
* showed the previous copy actively misleading: it warned "showed no drift"
|
|
279
|
+
* over an edit that WAS captured correctly, and none of the causes it named
|
|
280
|
+
* held, so the operator's only recourse was `grep -c` on the written body.
|
|
281
|
+
*
|
|
282
|
+
* The measurement, stated exactly:
|
|
283
|
+
*
|
|
284
|
+
* - COMPARED: for each file already in the patch's `filesAffected`, the
|
|
285
|
+
* `+`/`-` payload lines the REFRESHED body carries that the OLD patch
|
|
286
|
+
* body did not (`computeForeignDrift`). It is a content comparison, and
|
|
287
|
+
* it is ONE-DIRECTIONAL: excess in the new body only.
|
|
288
|
+
* - WHEN: per patch, on the body about to be written, BEFORE the write —
|
|
289
|
+
* never against post-write state.
|
|
290
|
+
*
|
|
291
|
+
* The one-directional half is what the earlier copy hid, and it is the
|
|
292
|
+
* cause the report's first shape actually hit: deleting a line from an
|
|
293
|
+
* engine file makes the refreshed body DROP that file's `+` line. Nothing
|
|
294
|
+
* is absorbed, so by design there is no drift to see — the belt exists to
|
|
295
|
+
* catch a concurrent session's edits being silently swallowed INTO a body,
|
|
296
|
+
* not every difference between two bodies. Naming it is the fix; widening
|
|
297
|
+
* the comparison would turn every ordinary line removal into a refusal.
|
|
298
|
+
*/
|
|
299
|
+
const EXPECT_MEASUREMENT_NOTE = '--expect is evaluated against CONTENT, before the write: for each file already in the ' +
|
|
300
|
+
"patch's filesAffected, the payload lines the refreshed body carries that the old patch " +
|
|
301
|
+
'body did not. It never looks at post-write state.';
|
|
302
|
+
/**
|
|
303
|
+
* Names `--expect` paths that never drifted this run. A typo'd
|
|
304
|
+
* `--expect` path silently degrades the flag back to refusing the slice it
|
|
305
|
+
* was meant to admit, so surface the mismatch — but only as a warning: an
|
|
306
|
+
* expected file legitimately shows no drift in several shapes, all named
|
|
307
|
+
* below.
|
|
308
|
+
*/
|
|
309
|
+
export function warnUnseenExpectedDrift(driftCtx) {
|
|
310
|
+
const unseen = [...driftCtx.expectedDriftFiles].filter((file) => !driftCtx.expectedSeen.has(file));
|
|
311
|
+
if (unseen.length === 0)
|
|
312
|
+
return;
|
|
313
|
+
if (driftCtx.evaluationRuns === 0) {
|
|
314
|
+
warn(`--expect path(s) were not evaluated because no selected patch reached the drift check (for example, an earlier refusal): ${unseen.join(', ')}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
warn('--expect path(s) showed no drift this run: ' +
|
|
318
|
+
`${unseen.join(', ')}.\n` +
|
|
319
|
+
`${EXPECT_MEASUREMENT_NOTE}\n` +
|
|
320
|
+
'Causes, in rough order of likelihood:\n' +
|
|
321
|
+
' 1. Your edit only REMOVED lines from the body (you deleted a line from the engine ' +
|
|
322
|
+
'file). The comparison is one-directional — it reports lines ABSORBED into the body — ' +
|
|
323
|
+
'so a correctly captured deletion is not drift and never will be. Confirm the capture ' +
|
|
324
|
+
'with "grep -c" on the written patch body.\n' +
|
|
325
|
+
" 2. A typo in the path, or a path not in the patch's filesAffected (files adopted " +
|
|
326
|
+
'this run via --scan/--scan-file are excluded from the comparison by design).\n' +
|
|
327
|
+
' 3. Adoption already captured it: a "re-export <p> --scan --scan-file <f>" run also ' +
|
|
328
|
+
"captures that patch's other drifted files, so a scan-less re-export afterwards " +
|
|
329
|
+
'legitimately sees nothing left to absorb. Informational — a chain that treats this ' +
|
|
330
|
+
'warning as an error will false-red on it.\n' +
|
|
331
|
+
' 4. The slice was already captured by an earlier export in this session.\n' +
|
|
332
|
+
' 5. The engine content already matches the patch body — a concurrent session may have ' +
|
|
333
|
+
'exported the same change, so the drift converged before this run looked.');
|
|
334
|
+
}
|
|
@@ -7,11 +7,11 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
|
7
7
|
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
8
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
9
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
10
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext,
|
|
10
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
|
|
11
11
|
import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
|
|
12
12
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
13
13
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
14
|
-
import {
|
|
14
|
+
import { buildNewFileTextProjection } from '../core/patch-transform.js';
|
|
15
15
|
import { InvalidArgumentError } from '../errors/base.js';
|
|
16
16
|
import { pathExists } from '../utils/fs.js';
|
|
17
17
|
import { info, outro, success, warn } from '../utils/logger.js';
|
|
@@ -42,10 +42,7 @@ function resolveEffectiveTierAndLintIgnore(target, options) {
|
|
|
42
42
|
*/
|
|
43
43
|
async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedDiff) {
|
|
44
44
|
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
45
|
-
const projectedNewFiles =
|
|
46
|
-
for (const path of detectNewFilesInDiff(projectedDiff)) {
|
|
47
|
-
projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
|
|
48
|
-
}
|
|
45
|
+
const projectedNewFiles = buildNewFileTextProjection(projectedDiff);
|
|
49
46
|
const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
|
|
50
47
|
const projectedEntries = baseCtx.entries.map((entry) => {
|
|
51
48
|
if (entry.filename !== targetFilename)
|
|
@@ -45,6 +45,24 @@ export function applyReExportFilesPositionalFolding(patches, options) {
|
|
|
45
45
|
}
|
|
46
46
|
return { patches: normalized.patches, options: normalized.options };
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Each carve-out flag is inert without the refusal it modifies, and a
|
|
50
|
+
* silently-inert whitelist is the worst shape: the operator believes an
|
|
51
|
+
* exception is in force while the refusal it was meant to relax was never
|
|
52
|
+
* armed. Refuse the combination instead of ignoring it.
|
|
53
|
+
*/
|
|
54
|
+
function validateWhitelistFlagDependencies(options) {
|
|
55
|
+
if (options.expect !== undefined &&
|
|
56
|
+
options.expect.length > 0 &&
|
|
57
|
+
options.refuseForeignDrift !== true) {
|
|
58
|
+
throw new InvalidArgumentError('--expect names files whose drift is expected under --refuse-foreign-drift and has no effect without it. Pass --refuse-foreign-drift, or drop --expect.', '--expect');
|
|
59
|
+
}
|
|
60
|
+
if (options.expectUnmanaged !== undefined &&
|
|
61
|
+
options.expectUnmanaged.length > 0 &&
|
|
62
|
+
options.refuseAdjacentUnmanaged !== true) {
|
|
63
|
+
throw new InvalidArgumentError('--expect-unmanaged names approved exceptions to --refuse-adjacent-unmanaged and has no effect without it. Pass --refuse-adjacent-unmanaged, or drop --expect-unmanaged.', '--expect-unmanaged');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
48
66
|
/** Validates mutually exclusive `re-export` targeting and metadata options. */
|
|
49
67
|
export function validateReExportOptionCombinations(patches, options) {
|
|
50
68
|
if (options.files !== undefined) {
|
|
@@ -83,11 +101,7 @@ export function validateReExportOptionCombinations(patches, options) {
|
|
|
83
101
|
if (options.refuseForeignDrift === true && (options.scan || options.files !== undefined)) {
|
|
84
102
|
throw new InvalidArgumentError('--refuse-foreign-drift applies to the scan-less path only and cannot be combined with --scan or --files (those explicitly capture the current engine state).', '--refuse-foreign-drift');
|
|
85
103
|
}
|
|
86
|
-
|
|
87
|
-
options.expect.length > 0 &&
|
|
88
|
-
options.refuseForeignDrift !== true) {
|
|
89
|
-
throw new InvalidArgumentError('--expect names files whose drift is expected under --refuse-foreign-drift and has no effect without it. Pass --refuse-foreign-drift, or drop --expect.', '--expect');
|
|
90
|
-
}
|
|
104
|
+
validateWhitelistFlagDependencies(options);
|
|
91
105
|
const usingTierFlag = options.tier !== undefined;
|
|
92
106
|
const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
|
|
93
107
|
if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
|
|
@@ -29,6 +29,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
29
29
|
.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.')
|
|
30
30
|
.option('--refuse-foreign-drift', "Refuse a scan-less re-export (non-zero exit, patch not written) when the refreshed body would absorb engine lines not present in the old patch body — protects multi-session checkouts from silently capturing another session's uncommitted edits. Mutually exclusive with --scan and --files.")
|
|
31
31
|
.option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable, and the whitelist applies across EVERY patch in this invocation, not per patch — so pass all the patches and all their --expect paths in one call rather than looping one patch at a time.", ...stringListOption())
|
|
32
|
+
.option('--expect-unmanaged <path>', 'With --refuse-adjacent-unmanaged, name an engine-relative file that is a REVIEWED, recorded exception to the adjacency refusal: an approved unmanaged path is still listed, but no longer refuses. Repeatable, and the carve-out applies across EVERY patch in this invocation, not per patch. Use it to keep the belt armed for everything else instead of dropping --refuse-adjacent-unmanaged entirely.', ...stringListOption())
|
|
32
33
|
.option('--dry-run', 'Show what would change without writing')
|
|
33
34
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
34
35
|
.option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
|
|
@@ -40,7 +41,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
40
41
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
41
42
|
.option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', ...stringListOption());
|
|
42
43
|
addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
|
|
43
|
-
const { tier, lintIgnore, scanFile, scanFiles, cache, expect, ...rest } = options;
|
|
44
|
+
const { tier, lintIgnore, scanFile, scanFiles, cache, expect, expectUnmanaged, ...rest } = options;
|
|
44
45
|
const projectRoot = getProjectRoot();
|
|
45
46
|
await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
|
|
46
47
|
...pickDefined(rest),
|
|
@@ -49,6 +50,9 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
49
50
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
50
51
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
51
52
|
...(expect !== undefined && expect.length > 0 ? { expect } : {}),
|
|
53
|
+
...(expectUnmanaged !== undefined && expectUnmanaged.length > 0
|
|
54
|
+
? { expectUnmanaged }
|
|
55
|
+
: {}),
|
|
52
56
|
...(cache === false ? { noCache: true } : {}),
|
|
53
57
|
}), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
54
58
|
}));
|
|
@@ -5,10 +5,10 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
|
5
5
|
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
6
6
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
7
7
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
8
|
-
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext,
|
|
8
|
+
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, lintPatchQueue, } from '../core/patch-lint.js';
|
|
9
9
|
import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
|
|
10
10
|
import { getClaimedFiles } from '../core/patch-manifest.js';
|
|
11
|
-
import {
|
|
11
|
+
import { buildNewFileTextProjection } from '../core/patch-transform.js';
|
|
12
12
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
13
13
|
import { mapWithConcurrency } from '../utils/concurrency.js';
|
|
14
14
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -176,10 +176,7 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
|
|
|
176
176
|
const candidateDiff = await getDiffForFilesAgainstHead(engineDir, [...added]);
|
|
177
177
|
if (!candidateDiff.trim())
|
|
178
178
|
return;
|
|
179
|
-
const candidateNewFiles =
|
|
180
|
-
for (const path of detectNewFilesInDiff(candidateDiff)) {
|
|
181
|
-
candidateNewFiles.set(path, extractNewFileContentFromDiff(candidateDiff, path));
|
|
182
|
-
}
|
|
179
|
+
const candidateNewFiles = buildNewFileTextProjection(candidateDiff);
|
|
183
180
|
const candidateAdditions = buildModifiedFileAdditionsFromDiff(candidateDiff);
|
|
184
181
|
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
185
182
|
const projectedEntries = baseCtx.entries.map((entry) => {
|