@hominis/fireforge 0.36.0 → 0.37.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 +13 -0
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export.js +7 -0
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- package/dist/src/commands/re-export.js +8 -0
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test.js +75 -23
- package/dist/src/core/build-baseline-types.d.ts +24 -0
- package/dist/src/core/build-baseline.d.ts +5 -2
- package/dist/src/core/build-baseline.js +5 -1
- package/dist/src/core/furnace-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- package/dist/src/core/test-harness-crash.d.ts +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +29 -1
- package/dist/src/core/test-stale-check.js +59 -0
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +27 -4
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
- **`fireforge test --build` now records the stale-build baseline** (friction-log item 1, inefficiency — "stale-build guard re-arms across invocation shapes"): a green pre-test build refreshes `.fireforge/last-build.json` through the same `writeBuildBaseline` path as `fireforge build`/`build --ui`, so after one `test --build` over a set of edited files, a subsequent plain `fireforge test` over the same files — per-file, per-directory, or full suite — passes the gate without a second redundant `--build` round trip. The baseline is content-fingerprint-based and project-scoped, which is also per-obj-dir (multi-objdir checkouts are already refused up front), so no per-invocation-shape keying is needed; both directions (file-scoped rebuild → full-suite run, and full/directory rebuild → file run) are covered by tests.
|
|
6
|
+
- **The stale-build gate no longer refuses a plain `test` right after a green `test --build`** (friction-log item 2, bug — "test --build must update last-build.json"): this was the same defect — `runPreTestBuild` ran the identical protected `mach build faster` as `build --ui` but never wrote the baseline, so the immediately following plain `fireforge test` was refused listing the very files the embedded build had just rebuilt (workaround was a redundant ~1-min `fireforge build --ui`). The write happens only on build exit 0, mirrors `fireforge build`'s ordering and failure tolerance exactly, and a failed pre-test build keeps the prior baseline.
|
|
7
|
+
- **Every non-`--build` test run now tracks packaged-runtime COVERAGE, not just tree state** (friction-log item 3, gotcha — file-scoped `--build` + `--allow-stale-build` over other manifests hangs on unpackaged fixtures): the baseline gains `testPackagingCoverage` (`'full'` from `fireforge build`/`build --ui`/path-less `test --build`; the normalized requested paths from a scoped `test --build`). Any non-`--build` run whose requested paths fall outside the recorded coverage is refused up front — with or without `--allow-stale-build` (the flag only accepts stale CONTENT; missing coverage is a hang risk either way), and regardless of whether the tree is stale, since the hang reproduces with an unchanged tree — with a message naming the uncovered manifests, the recorded coverage, and both remediations (`test --build <paths>` or `fireforge build`); a covered re-run still proceeds, with the usual stale warning under the flag. A path-less `test --doctor` is exempt from the coverage gate — it stops at the Marionette health check and dispatches no test, so it needs no packaging coverage (the stale-content refusal still applies to it, and `--doctor` combined with test paths is checked like any other run). Baselines written before 0.37.0 lack the field and are treated as full coverage, so existing flows are unchanged.
|
|
8
|
+
- **`export`/`re-export` refuse to capture a stale furnace deploy** (friction-log item 4, inefficiency — re-export after editing `components/custom/` without `furnace deploy` exports the old deployed copy): when a patch's exported files fall under a furnace component's deployed prefix (custom `targetPath` / override `basePath`, plus a localized component's exact deployed `<ftlDir>/<name>.ftl` in the shared FTL directory; `sharedFtl` bundles are not furnace-deployed and stay unattributed by design) and that component's `components/` source has changed since the last `furnace apply`/`deploy` (checksum comparison against `appliedChecksums` — mtime is deliberately not trusted), the export is refused with a message naming the component and suggesting `fireforge furnace deploy` first; the new `--allow-stale-furnace` flag on both commands downgrades the refusal to a warning. A deploy-then-re-export sequence is untouched, and a broken/absent furnace setup never blocks non-furnace patch work.
|
|
9
|
+
- **`stagedDependencies` gains a registration-kind entry shape** (friction-log item 5, missing-feature — jar.mn packaging lines flagged `staged-dependency-unused`): `PatchStagedDependencies.registrations` entries (`{file, line, creates, owner?, reason?}`) declare a packaging/registration LINE — a jar.mn entry, a customElements registration, an actor registration — that references a later-created file. Lint validates them by matching the declared line (whitespace-trimmed) against the patch's added content in the declaring file instead of looking for an import specifier — and, mirroring forward-import validation, by requiring `creates` to resolve to a file a later-ordered patch actually creates and `owner`, when set, to name that patch (each failure kind gets its own message) — so mid-queue buildability facts for registration files (e.g. patch 200's jar.mn line packaging `hominis-history-ui.js` owned by patch 248) are machine-recordable: a matching entry lints clean, an import-kind entry still requires a real import, and a registration entry whose line is absent still flags `staged-dependency-unused`. CLI: `fireforge patch staged-dependency --add/--remove --kind registration --file … --line … --creates …`; owner-rename rewriting covers the new array.
|
|
10
|
+
- **`furnace validate` accepts `kind: "library"` components** (friction-log item 6, missing-feature — define-less base-class modules failed `no-custom-element-define`): a custom component manifest may declare `kind: "library"` (base class + helpers, no element of its own — e.g. a shared `moz-hominis-shared` module), which skips the `no-custom-element-define` / `not-moz-lit-element` compatibility errors and the `missing-css` structural advisory while keeping headers, module shape, `relative-import`, FTL rules, and the CSS compatibility rules when a stylesheet exists. `kind: "library"` requires `register: false` (rejected at config parse otherwise); a default-kind component without a define still errors, so the inert-element workaround can be retired.
|
|
11
|
+
- **The failure summary echoes the failing TEST-UNEXPECTED lines with their assertion text** (friction-log item 7, papercut — "First real test failure: Unexpected results: 1" was undiagnosable after the fact): `test-failures` verdicts now carry the first 5 `TEST-UNEXPECTED-*` lines verbatim, each with its trailing assertion/diff context (`Got …`/`Expected …`/Assert message/stack head), echoed under the `First real test failure:` headline in single runs and prepended to shard diagnoses; more than 5 append a `…(+N more)` note, and the shutdown-reentry artifact is excluded. A one-off chrome-suite failure that does not reproduce is now diagnosable from the summary block alone.
|
|
12
|
+
- **The known mozsystemmonitor teardown traceback is collapsed to one labeled line in the output tail** (friction-log item 8, bug/upstream — every headless run ended with the raw `AttributeError: 'SystemResourceMonitor' object has no attribute 'stop_time'` traceback sitting where the summary is read): the test dispatchers now route their terminal echo through a line-buffered filter that recognizes the exact documented signature — an `AttributeError` naming `stop_time` or `poll_interval` on `SystemResourceMonitor` AND a `mozsystemmonitor/resourcemonitor.py` frame AND a previously-seen `SUITE_END` shutdown marker (the flag is shared across the run's stdout/stderr filter pair, since the marker and the traceback usually land on different streams), including the chained-exception pair — and replaces the block with `[FireForge] Known upstream mozsystemmonitor teardown noise … — not a test failure`. Unrecognized tracebacks always print in full, the captured output stays raw for the 0.36.0 classifier (real-failure precedence and green-summary override are untouched), and oversized or truncated blocks flush verbatim rather than being withheld.
|
|
13
|
+
- **Mach dispatches reap their whole process GROUP, and `doctor` detects pre-existing orphaned harness workers** (friction-log item 9, severe — a mach that died at harness startup stranded a Python `multiprocessing` spawn worker that busy-spun at 100% CPU for ~26 days): (a) every mach-wrapping dispatch (test suites, protected builds, bootstrap, watch, package, run) now spawns mach as a POSIX process-group leader, forwards parent SIGINT/SIGTERM to the group (with the existing grace→SIGKILL escalation), group-kills on abort, and sweeps the group after every exit — survivors are named (multiprocessing workers highlighted), group-SIGTERMed, and SIGKILLed after a 2s grace, so a startup death leaves zero surviving multiprocessing children; a healthy run costs one `pgrep`. Windows stays best-effort tree-kill via `taskkill /T /F`. (b) A new `doctor` check ("Orphaned harness workers") scans `ps` for the detection recipe that found the incident — PPID 1 + ≥10 min accumulated CPU time + a `multiprocessing.spawn`/`forkserver`/`resource_tracker` command line, parsing both linux `dd-hh:mm:ss` and darwin `mm:ss.cc` TIME dialects — and reports each match with its PID, CPU time, command line, and a `kill <pid>` suggestion; report-only by design (FireForge never kills pre-existing processes), warning severity so it cannot fail doctor, skipped on Windows.
|
|
14
|
+
- **Follow-up findings** (pre-release review of the items above; the behavioral corrections are folded into their bullets): the packaging-coverage refusal now guards EVERY non-`--build` run, not just `--allow-stale-build` ones, with a path-less `test --doctor` exempted since it dispatches no test (item 3 — a plain run over an unchanged tree after a scoped `test --build` sailed through the gate into the very fixture hang the feature targets, while the widened gate would otherwise have refused a doctor-only Marionette probe as an uncovered full-suite request); the stale-furnace export gate attributes a localized component's deployed `<ftlDir>/<name>.ftl` (item 4 — exporting only the deployed FTL file never probed its component source); registration-kind staged dependencies validate `creates`/`owner`/ordering like the import kind (item 5 — metadata naming a nonexistent file or wrong owner linted clean); the teardown-noise echo filter is narrowed to the exact `stop_time`/`poll_interval` signature with a resourcemonitor.py frame after a seen `SUITE_END` (item 8 — it previously collapsed ANY traceback mentioning resourcemonitor.py, hiding novel upstream defects from live output); and the process-group reaper's grace timer is no longer `unref()`'d (item 9a — the timer was awaited from a child `close` handler after the signal forwarder was disposed, so Node could exit mid-grace and skip the SIGKILL escalation; a regression test pins the timer's ref-ness).
|
|
15
|
+
|
|
3
16
|
## 0.36.0
|
|
4
17
|
|
|
5
18
|
- **Review-findings remediation for the 0.36.0 release gate**: `fireforge test` no longer has a pathless dead-end — no-path runs now require `--auto`, `--doctor`, or `--canary`, `--auto` forwards mach's own selection mode, stale packaged-engine drift fails fast unless `--build` or `--allow-stale-build` is used, `--kill-stale-marionette` can terminate a recognized stale browser listener, and `--canary [path]` / `test.canaryPath` provides a short green/hang/crash harness probe. Harness verdicts now let real failure lines (`FAIL`, `TEST-UNEXPECTED-*`, nonzero unexpected summaries, assertions) beat resource-monitor traceback classification, while still noting the traceback as secondary noise. Patch naming is normalized consistently for `export --name` and `patch rename --to` (bare, category-prefixed, and full stems converge), and `lint --per-patch --patches` accepts repeated flags, comma lists, filenames, stems, manifest names, category-prefixed slugs, and bare slugs. Furnace-managed jsconfig edits are JSONC-preserving via `jsonc-parser`, the Firefox globals shim now includes JSWindowActor bases and `ChromeUtils.registerWindowActor`, `doctor` can validate opt-in `externalToolchains`, and CLI engine-mutating commands take a `.fireforge/engine-session.lock` plus test-run tree-generation checks to catch concurrent engine mutation.
|
|
@@ -327,7 +327,7 @@ export async function buildCommand(projectRoot, options) {
|
|
|
327
327
|
// against this build's HEAD. A failed build keeps the prior baseline so
|
|
328
328
|
// the next attempt still catches long-standing packaging drops.
|
|
329
329
|
try {
|
|
330
|
-
await writeBuildBaseline(projectRoot, paths.engine, config.binaryName);
|
|
330
|
+
await writeBuildBaseline(projectRoot, paths.engine, config.binaryName, 'full');
|
|
331
331
|
}
|
|
332
332
|
catch (baselineError) {
|
|
333
333
|
verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check for PRE-EXISTING orphaned test-harness workers (0.37.0
|
|
3
|
+
* item 9b).
|
|
4
|
+
*
|
|
5
|
+
* Field incident: a mach invocation that died at harness startup stranded a
|
|
6
|
+
* Python 3.14 `multiprocessing` spawn worker plus its resource tracker; the
|
|
7
|
+
* worker reparented to launchd (PPID 1) and busy-spun at 100% CPU for ~26
|
|
8
|
+
* days (~415 CPU-hours) undetected. The dispatch-side fix is the
|
|
9
|
+
* process-group reaping in `utils/process.ts`; this check covers orphans
|
|
10
|
+
* that predate it (or leaked from non-FireForge invocations), using the
|
|
11
|
+
* detection recipe that found the incident process: PPID 1 + large
|
|
12
|
+
* accumulated CPU TIME + a `multiprocessing.spawn`/`resource_tracker`
|
|
13
|
+
* command line.
|
|
14
|
+
*
|
|
15
|
+
* Report-only by design: FireForge never kills pre-existing processes it
|
|
16
|
+
* did not spawn — the check names each candidate and suggests the kill.
|
|
17
|
+
*
|
|
18
|
+
* Canary integration was deliberately skipped: the friction log asked for
|
|
19
|
+
* doctor AND/OR `test --canary`, and adding a system-wide process scan to
|
|
20
|
+
* the canary hot path buys no extra coverage over the doctor check.
|
|
21
|
+
*/
|
|
22
|
+
import type { DoctorCheckDefinition } from './doctor-check-core.js';
|
|
23
|
+
/** One process matching the orphaned-harness-worker shape. */
|
|
24
|
+
export interface OrphanedHarnessWorker {
|
|
25
|
+
pid: number;
|
|
26
|
+
ppid: number;
|
|
27
|
+
/** Raw TIME column as `ps` printed it. */
|
|
28
|
+
cpuTime: string;
|
|
29
|
+
/** Parsed accumulated CPU seconds. */
|
|
30
|
+
cpuSeconds: number;
|
|
31
|
+
command: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parses a `ps` TIME value in either dialect:
|
|
35
|
+
* - linux `[dd-]hh:mm:ss` (e.g. `26-03:14:12`, `03:14:12`)
|
|
36
|
+
* - darwin/BSD `mm:ss.cc` with cumulative minutes (e.g. `38412:07.55`)
|
|
37
|
+
* Returns NaN for unrecognized shapes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseCpuTime(time: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
|
|
42
|
+
* workers: PPID 1 (reparented to init/launchd), a multiprocessing
|
|
43
|
+
* worker/tracker command line, and at least `minCpuSeconds` of accumulated
|
|
44
|
+
* CPU time. Pure — fixture-testable without spawning anything.
|
|
45
|
+
*/
|
|
46
|
+
export declare function findOrphanedHarnessWorkers(psOutput: string, minCpuSeconds?: number): OrphanedHarnessWorker[];
|
|
47
|
+
/**
|
|
48
|
+
* Doctor check reporting orphaned harness workers. Windows has no `ps`;
|
|
49
|
+
* the check is skipped there (best-effort platform gap, matching the
|
|
50
|
+
* process-group reaper's POSIX-only sweep).
|
|
51
|
+
*/
|
|
52
|
+
export declare const ORPHANED_HARNESS_DOCTOR_CHECK: DoctorCheckDefinition;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Doctor check for PRE-EXISTING orphaned test-harness workers (0.37.0
|
|
4
|
+
* item 9b).
|
|
5
|
+
*
|
|
6
|
+
* Field incident: a mach invocation that died at harness startup stranded a
|
|
7
|
+
* Python 3.14 `multiprocessing` spawn worker plus its resource tracker; the
|
|
8
|
+
* worker reparented to launchd (PPID 1) and busy-spun at 100% CPU for ~26
|
|
9
|
+
* days (~415 CPU-hours) undetected. The dispatch-side fix is the
|
|
10
|
+
* process-group reaping in `utils/process.ts`; this check covers orphans
|
|
11
|
+
* that predate it (or leaked from non-FireForge invocations), using the
|
|
12
|
+
* detection recipe that found the incident process: PPID 1 + large
|
|
13
|
+
* accumulated CPU TIME + a `multiprocessing.spawn`/`resource_tracker`
|
|
14
|
+
* command line.
|
|
15
|
+
*
|
|
16
|
+
* Report-only by design: FireForge never kills pre-existing processes it
|
|
17
|
+
* did not spawn — the check names each candidate and suggests the kill.
|
|
18
|
+
*
|
|
19
|
+
* Canary integration was deliberately skipped: the friction log asked for
|
|
20
|
+
* doctor AND/OR `test --canary`, and adding a system-wide process scan to
|
|
21
|
+
* the canary hot path buys no extra coverage over the doctor check.
|
|
22
|
+
*/
|
|
23
|
+
import { exec } from '../utils/process.js';
|
|
24
|
+
import { ok, warning } from './doctor-check-core.js';
|
|
25
|
+
/**
|
|
26
|
+
* Command-line shapes of Python multiprocessing helper processes: the
|
|
27
|
+
* spawn/forkserver worker bootstrap (`from multiprocessing.spawn import
|
|
28
|
+
* spawn_main; spawn_main(...)`) and the resource tracker.
|
|
29
|
+
*/
|
|
30
|
+
const HARNESS_WORKER_COMMAND_PATTERN = /multiprocessing\.(?:spawn|forkserver)\b|multiprocessing\.resource_tracker|from multiprocessing\.\w+ import/;
|
|
31
|
+
/** Minimum accumulated CPU time before a match is reported (10 minutes). */
|
|
32
|
+
const DEFAULT_MIN_CPU_SECONDS = 600;
|
|
33
|
+
/**
|
|
34
|
+
* Parses a `ps` TIME value in either dialect:
|
|
35
|
+
* - linux `[dd-]hh:mm:ss` (e.g. `26-03:14:12`, `03:14:12`)
|
|
36
|
+
* - darwin/BSD `mm:ss.cc` with cumulative minutes (e.g. `38412:07.55`)
|
|
37
|
+
* Returns NaN for unrecognized shapes.
|
|
38
|
+
*/
|
|
39
|
+
export function parseCpuTime(time) {
|
|
40
|
+
const trimmed = time.trim();
|
|
41
|
+
let days = 0;
|
|
42
|
+
let rest = trimmed;
|
|
43
|
+
const dayMatch = /^(\d+)-(.*)$/.exec(trimmed);
|
|
44
|
+
if (dayMatch) {
|
|
45
|
+
days = Number(dayMatch[1]);
|
|
46
|
+
rest = dayMatch[2] ?? '';
|
|
47
|
+
}
|
|
48
|
+
const parts = rest.split(':');
|
|
49
|
+
if (parts.length === 3) {
|
|
50
|
+
// hh:mm:ss (linux; seconds may not carry fractions but tolerate them)
|
|
51
|
+
const [h, m, s] = parts.map(Number);
|
|
52
|
+
if ([h, m, s].some((n) => Number.isNaN(n)))
|
|
53
|
+
return NaN;
|
|
54
|
+
return days * 86400 + (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0);
|
|
55
|
+
}
|
|
56
|
+
if (parts.length === 2) {
|
|
57
|
+
// mm:ss.cc (darwin — the minutes field accumulates without wrapping)
|
|
58
|
+
const [m, s] = parts.map(Number);
|
|
59
|
+
if ([m, s].some((n) => Number.isNaN(n)))
|
|
60
|
+
return NaN;
|
|
61
|
+
return days * 86400 + (m ?? 0) * 60 + (s ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return NaN;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
|
|
67
|
+
* workers: PPID 1 (reparented to init/launchd), a multiprocessing
|
|
68
|
+
* worker/tracker command line, and at least `minCpuSeconds` of accumulated
|
|
69
|
+
* CPU time. Pure — fixture-testable without spawning anything.
|
|
70
|
+
*/
|
|
71
|
+
export function findOrphanedHarnessWorkers(psOutput, minCpuSeconds = DEFAULT_MIN_CPU_SECONDS) {
|
|
72
|
+
const workers = [];
|
|
73
|
+
for (const line of psOutput.split('\n')) {
|
|
74
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
75
|
+
if (!match)
|
|
76
|
+
continue;
|
|
77
|
+
const pid = Number(match[1]);
|
|
78
|
+
const ppid = Number(match[2]);
|
|
79
|
+
const cpuTime = match[3] ?? '';
|
|
80
|
+
const command = (match[4] ?? '').trim();
|
|
81
|
+
if (ppid !== 1)
|
|
82
|
+
continue;
|
|
83
|
+
if (!HARNESS_WORKER_COMMAND_PATTERN.test(command))
|
|
84
|
+
continue;
|
|
85
|
+
const cpuSeconds = parseCpuTime(cpuTime);
|
|
86
|
+
if (Number.isNaN(cpuSeconds) || cpuSeconds < minCpuSeconds)
|
|
87
|
+
continue;
|
|
88
|
+
workers.push({ pid, ppid, cpuTime, cpuSeconds, command });
|
|
89
|
+
}
|
|
90
|
+
return workers;
|
|
91
|
+
}
|
|
92
|
+
/** Thin exec wrapper for the process listing (tests mock `exec` instead). */
|
|
93
|
+
async function listSystemProcesses() {
|
|
94
|
+
const result = await exec('ps', ['-axo', 'pid=,ppid=,time=,command='], { timeout: 10000 });
|
|
95
|
+
if (result.exitCode !== 0) {
|
|
96
|
+
throw new Error(`ps exited ${String(result.exitCode)}`);
|
|
97
|
+
}
|
|
98
|
+
return result.stdout;
|
|
99
|
+
}
|
|
100
|
+
const CHECK_NAME = 'Orphaned harness workers';
|
|
101
|
+
async function runOrphanedHarnessCheck() {
|
|
102
|
+
let psOutput;
|
|
103
|
+
try {
|
|
104
|
+
psOutput = await listSystemProcesses();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return warning(CHECK_NAME, `Could not scan system processes (${error instanceof Error ? error.message : String(error)}); skipping the orphaned-worker check.`);
|
|
108
|
+
}
|
|
109
|
+
const orphans = findOrphanedHarnessWorkers(psOutput);
|
|
110
|
+
if (orphans.length === 0) {
|
|
111
|
+
return ok(CHECK_NAME);
|
|
112
|
+
}
|
|
113
|
+
const rows = orphans
|
|
114
|
+
.map((w) => `PID ${String(w.pid)} (CPU time ${w.cpuTime}): ${w.command.slice(0, 160)}`)
|
|
115
|
+
.join('; ');
|
|
116
|
+
const pids = orphans.map((w) => String(w.pid)).join(' ');
|
|
117
|
+
return warning(CHECK_NAME, `Found ${String(orphans.length)} orphaned Python multiprocessing worker(s) — PPID 1 with ` +
|
|
118
|
+
`high accumulated CPU time, the shape a test harness that died at startup leaves behind ` +
|
|
119
|
+
`(field incident: one such worker busy-spun for ~26 days). ${rows}`, `These look like workers orphaned by a crashed test harness. Verify each command line, ` +
|
|
120
|
+
`then terminate with: kill ${pids} (or kill -9 if a process survives). ` +
|
|
121
|
+
'FireForge never kills pre-existing processes automatically.');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Doctor check reporting orphaned harness workers. Windows has no `ps`;
|
|
125
|
+
* the check is skipped there (best-effort platform gap, matching the
|
|
126
|
+
* process-group reaper's POSIX-only sweep).
|
|
127
|
+
*/
|
|
128
|
+
export const ORPHANED_HARNESS_DOCTOR_CHECK = {
|
|
129
|
+
name: CHECK_NAME,
|
|
130
|
+
skipIf: () => process.platform === 'win32',
|
|
131
|
+
run: runOrphanedHarnessCheck,
|
|
132
|
+
};
|
|
@@ -16,6 +16,7 @@ import { POST_REBASE_AUDIT_CHECK } from './doctor/post-rebase-audit.js';
|
|
|
16
16
|
import { failure, ok, warning } from './doctor-check-core.js';
|
|
17
17
|
import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
|
|
18
18
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
19
|
+
import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
|
|
19
20
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
20
21
|
import { collectPatchQueueHealth } from './verify.js';
|
|
21
22
|
/**
|
|
@@ -264,6 +265,7 @@ const DOCTOR_CHECKS = [
|
|
|
264
265
|
return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
|
|
265
266
|
},
|
|
266
267
|
},
|
|
268
|
+
ORPHANED_HARNESS_DOCTOR_CHECK,
|
|
267
269
|
{
|
|
268
270
|
name: 'Patches directory exists',
|
|
269
271
|
run: async (ctx) => {
|
|
@@ -6,6 +6,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
6
6
|
import { appendHistory } from '../core/destructive.js';
|
|
7
7
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
8
8
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
9
|
+
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
9
10
|
import { getStatusWithCodes, isGitRepository } from '../core/git.js';
|
|
10
11
|
import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
|
|
11
12
|
import { isBinaryFile } from '../core/git-file-ops.js';
|
|
@@ -201,6 +202,11 @@ async function prepareExport(projectRoot, files, options) {
|
|
|
201
202
|
throw new GeneralError(`Paths "${pathList}" have no changes to export.\n\n` +
|
|
202
203
|
'Run "fireforge status" to see modified files.');
|
|
203
204
|
}
|
|
205
|
+
// Stale-furnace-source gate (0.37.0 item 4): the export captures deployed
|
|
206
|
+
// engine copies; refuse (or warn under --allow-stale-furnace) when a
|
|
207
|
+
// covered component's source changed since the last furnace apply. Runs
|
|
208
|
+
// after --exclude-furnace so excluded furnace files never trigger it.
|
|
209
|
+
await enforceFreshFurnaceSources(projectRoot, allFiles, options.allowStaleFurnace === true, 'export');
|
|
204
210
|
let diff = await generatePatchDiff(paths.engine, allFiles);
|
|
205
211
|
if (!diff.trim()) {
|
|
206
212
|
if (options.skipLint) {
|
|
@@ -409,6 +415,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
409
415
|
.option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
|
|
410
416
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
|
|
411
417
|
.option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
|
|
418
|
+
.option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
|
|
412
419
|
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
|
|
413
420
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
|
|
414
421
|
.option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
|
|
@@ -10,7 +10,7 @@ import type { PatchStagedDependencyOptions } from '../../types/commands/index.js
|
|
|
10
10
|
*
|
|
11
11
|
* @param projectRoot - Project root directory
|
|
12
12
|
* @param identifier - Patch filename, ordinal, or manifest name
|
|
13
|
-
* @param options - Mutation mode and
|
|
13
|
+
* @param options - Mutation mode, entry kind, and declaration fields
|
|
14
14
|
*/
|
|
15
15
|
export declare function patchStagedDependencyCommand(projectRoot: string, identifier: string, options?: PatchStagedDependencyOptions): Promise<void>;
|
|
16
16
|
/**
|
|
@@ -23,6 +23,19 @@ function modeFromOptions(options) {
|
|
|
23
23
|
}
|
|
24
24
|
return adding ? 'add' : removing ? 'remove' : 'clear';
|
|
25
25
|
}
|
|
26
|
+
function kindFromOptions(options) {
|
|
27
|
+
const kind = options.kind ?? 'import';
|
|
28
|
+
if (kind !== 'import' && kind !== 'registration') {
|
|
29
|
+
throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, 'patch staged-dependency');
|
|
30
|
+
}
|
|
31
|
+
if (kind === 'import' && options.line !== undefined) {
|
|
32
|
+
throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', 'patch staged-dependency');
|
|
33
|
+
}
|
|
34
|
+
if (kind === 'registration' && options.specifier !== undefined) {
|
|
35
|
+
throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', 'patch staged-dependency');
|
|
36
|
+
}
|
|
37
|
+
return kind;
|
|
38
|
+
}
|
|
26
39
|
function requireForwardImportOptions(options, mode) {
|
|
27
40
|
if (!options.file || !options.specifier || !options.creates) {
|
|
28
41
|
throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
|
|
@@ -38,102 +51,149 @@ function requireForwardImportOptions(options, mode) {
|
|
|
38
51
|
dependency.reason = options.reason;
|
|
39
52
|
return dependency;
|
|
40
53
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
54
|
+
function requireRegistrationOptions(options, mode) {
|
|
55
|
+
if (!options.file || !options.line || !options.creates) {
|
|
56
|
+
throw new InvalidArgumentError(`--${mode} --kind registration requires --file, --line, and --creates.`, 'patch staged-dependency');
|
|
57
|
+
}
|
|
58
|
+
const dependency = {
|
|
59
|
+
file: options.file,
|
|
60
|
+
line: options.line,
|
|
61
|
+
creates: options.creates,
|
|
62
|
+
};
|
|
63
|
+
if (options.owner !== undefined)
|
|
64
|
+
dependency.owner = options.owner;
|
|
65
|
+
if (options.reason !== undefined)
|
|
66
|
+
dependency.reason = options.reason;
|
|
67
|
+
return dependency;
|
|
68
|
+
}
|
|
69
|
+
function importView(dependency) {
|
|
70
|
+
return { ...dependency, matcher: dependency.specifier };
|
|
43
71
|
}
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
const reason = dependency.reason ? ` reason="${dependency.reason}"` : '';
|
|
47
|
-
return `${dependency.file} imports "${dependency.specifier}" from ${dependency.creates}${owner}${reason}`;
|
|
72
|
+
function registrationView(dependency) {
|
|
73
|
+
return { ...dependency, matcher: dependency.line };
|
|
48
74
|
}
|
|
49
|
-
function
|
|
50
|
-
return
|
|
51
|
-
dependency.specifier !== target.specifier ||
|
|
52
|
-
dependency.creates !== target.creates ||
|
|
53
|
-
(target.owner !== undefined && dependency.owner !== target.owner));
|
|
75
|
+
function dependencyKey(view) {
|
|
76
|
+
return [view.file, view.matcher, view.creates, view.owner ?? ''].join('\0');
|
|
54
77
|
}
|
|
55
|
-
function
|
|
78
|
+
function dependencyLabel(kind, view) {
|
|
79
|
+
const owner = view.owner ? ` owner=${view.owner}` : '';
|
|
80
|
+
const reason = view.reason ? ` reason="${view.reason}"` : '';
|
|
81
|
+
if (kind === 'import') {
|
|
82
|
+
return `${view.file} imports "${view.matcher}" from ${view.creates}${owner}${reason}`;
|
|
83
|
+
}
|
|
84
|
+
return `${view.file} registers "${view.matcher}" for ${view.creates}${owner}${reason}`;
|
|
85
|
+
}
|
|
86
|
+
function matchesTarget(view, target) {
|
|
87
|
+
return (view.file === target.file &&
|
|
88
|
+
view.matcher === target.matcher &&
|
|
89
|
+
view.creates === target.creates &&
|
|
90
|
+
(target.owner === undefined || view.owner === target.owner));
|
|
91
|
+
}
|
|
92
|
+
function applyMode(existing, mode, target, toView, addition) {
|
|
56
93
|
if (mode === 'clear')
|
|
57
94
|
return [];
|
|
58
|
-
if (!
|
|
95
|
+
if (!target)
|
|
59
96
|
return [...existing];
|
|
60
97
|
if (mode === 'remove')
|
|
61
|
-
return
|
|
62
|
-
const seen = new Set(existing.map(dependencyKey));
|
|
63
|
-
if (seen.has(dependencyKey(
|
|
98
|
+
return existing.filter((entry) => !matchesTarget(toView(entry), target));
|
|
99
|
+
const seen = new Set(existing.map((entry) => dependencyKey(toView(entry))));
|
|
100
|
+
if (seen.has(dependencyKey(target)) || addition === undefined)
|
|
64
101
|
return [...existing];
|
|
65
|
-
return [...existing,
|
|
102
|
+
return [...existing, addition];
|
|
66
103
|
}
|
|
67
104
|
/**
|
|
68
105
|
* Renders a one-line summary of a staged-dependency metadata change.
|
|
69
106
|
*/
|
|
70
|
-
function describeStagedDependencyChange(
|
|
107
|
+
function describeStagedDependencyChange(beforeCount, afterCount, mode, kind, target) {
|
|
108
|
+
const noun = kind === 'import' ? 'staged forward-import' : 'staged registration';
|
|
71
109
|
if (mode === 'clear') {
|
|
72
|
-
return
|
|
110
|
+
return beforeCount === 0
|
|
73
111
|
? 'stagedDependencies was already empty — no change'
|
|
74
|
-
: `cleared ${
|
|
112
|
+
: `cleared ${beforeCount} staged dependency declaration(s)`;
|
|
75
113
|
}
|
|
76
|
-
if (!
|
|
114
|
+
if (!target)
|
|
77
115
|
return 'stagedDependencies unchanged';
|
|
78
116
|
if (mode === 'add') {
|
|
79
|
-
return
|
|
80
|
-
?
|
|
81
|
-
: `added
|
|
117
|
+
return afterCount === beforeCount
|
|
118
|
+
? `${noun} already present: ${dependencyLabel(kind, target)}`
|
|
119
|
+
: `added ${noun}: ${dependencyLabel(kind, target)}`;
|
|
82
120
|
}
|
|
83
|
-
return
|
|
84
|
-
? `no
|
|
85
|
-
: `removed ${
|
|
121
|
+
return afterCount === beforeCount
|
|
122
|
+
? `no ${noun} matched: ${dependencyLabel(kind, target)}`
|
|
123
|
+
: `removed ${beforeCount - afterCount} ${noun} declaration(s): ${dependencyLabel(kind, target)}`;
|
|
124
|
+
}
|
|
125
|
+
function countStagedEntries(staged) {
|
|
126
|
+
return (staged?.forwardImports?.length ?? 0) + (staged?.registrations?.length ?? 0);
|
|
86
127
|
}
|
|
87
128
|
/**
|
|
88
129
|
* Runs the metadata-only staged-dependency mutation command.
|
|
89
130
|
*
|
|
90
131
|
* @param projectRoot - Project root directory
|
|
91
132
|
* @param identifier - Patch filename, ordinal, or manifest name
|
|
92
|
-
* @param options - Mutation mode and
|
|
133
|
+
* @param options - Mutation mode, entry kind, and declaration fields
|
|
93
134
|
*/
|
|
94
135
|
export async function patchStagedDependencyCommand(projectRoot, identifier, options = {}) {
|
|
95
136
|
const isDryRun = options.dryRun === true;
|
|
96
137
|
intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
|
|
97
138
|
const mode = modeFromOptions(options);
|
|
98
|
-
const
|
|
139
|
+
const kind = kindFromOptions(options);
|
|
140
|
+
const importDependency = mode === 'clear' || kind !== 'import' ? undefined : requireForwardImportOptions(options, mode);
|
|
141
|
+
const registrationDependency = mode === 'clear' || kind !== 'registration'
|
|
142
|
+
? undefined
|
|
143
|
+
: requireRegistrationOptions(options, mode);
|
|
144
|
+
const target = importDependency !== undefined
|
|
145
|
+
? importView(importDependency)
|
|
146
|
+
: registrationDependency !== undefined
|
|
147
|
+
? registrationView(registrationDependency)
|
|
148
|
+
: undefined;
|
|
99
149
|
const { paths, manifest } = await requirePatchQueue(projectRoot);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
150
|
+
const targetPatch = requirePatchTarget(identifier, manifest.patches);
|
|
151
|
+
const applyToStaged = (staged) => {
|
|
152
|
+
if (mode === 'clear')
|
|
153
|
+
return {};
|
|
154
|
+
const forwardImports = applyMode(staged?.forwardImports ?? [], kind === 'import' ? mode : 'add', kind === 'import' ? target : undefined, importView, importDependency);
|
|
155
|
+
const registrations = applyMode(staged?.registrations ?? [], kind === 'registration' ? mode : 'add', kind === 'registration' ? target : undefined, registrationView, registrationDependency);
|
|
156
|
+
return {
|
|
157
|
+
...(forwardImports.length > 0 ? { forwardImports } : {}),
|
|
158
|
+
...(registrations.length > 0 ? { registrations } : {}),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const existing = targetPatch.stagedDependencies;
|
|
162
|
+
const projected = applyToStaged(existing);
|
|
163
|
+
const summary = describeStagedDependencyChange(countStagedEntries(existing), countStagedEntries(projected), mode, kind, target);
|
|
104
164
|
if (isDryRun) {
|
|
105
|
-
info(`[dry-run] ${
|
|
165
|
+
info(`[dry-run] ${targetPatch.filename}: ${summary}.`);
|
|
106
166
|
outro('Dry run complete — no changes made');
|
|
107
167
|
return;
|
|
108
168
|
}
|
|
109
|
-
const result = await mutatePatchMetadata(paths.patches,
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
if (after.length === 0)
|
|
169
|
+
const result = await mutatePatchMetadata(paths.patches, targetPatch.filename, (current) => {
|
|
170
|
+
const after = applyToStaged(current.stagedDependencies);
|
|
171
|
+
if (countStagedEntries(after) === 0)
|
|
113
172
|
return { unset: ['stagedDependencies'] };
|
|
114
|
-
return { set: { stagedDependencies:
|
|
173
|
+
return { set: { stagedDependencies: after } };
|
|
115
174
|
});
|
|
116
175
|
if (!result) {
|
|
117
|
-
throw new GeneralError(`Patch ${
|
|
176
|
+
throw new GeneralError(`Patch ${targetPatch.filename} disappeared from the manifest during the update. Re-run after investigating.`);
|
|
118
177
|
}
|
|
119
|
-
const before = result.before.stagedDependencies
|
|
120
|
-
const after = result.after.stagedDependencies
|
|
121
|
-
info(`${
|
|
178
|
+
const before = result.before.stagedDependencies;
|
|
179
|
+
const after = result.after.stagedDependencies;
|
|
180
|
+
info(`${targetPatch.filename}: ${describeStagedDependencyChange(countStagedEntries(before), countStagedEntries(after), mode, kind, target)}.`);
|
|
122
181
|
try {
|
|
123
182
|
await appendHistory(paths.patches, {
|
|
124
183
|
operation: 'patch-staged-dependency',
|
|
125
184
|
args: {
|
|
126
|
-
filename:
|
|
185
|
+
filename: targetPatch.filename,
|
|
127
186
|
mode,
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
kind,
|
|
188
|
+
before: before ?? {},
|
|
189
|
+
after: after ?? {},
|
|
130
190
|
},
|
|
131
191
|
...(options.yes === true ? { yes: true } : {}),
|
|
132
192
|
result: 'ok',
|
|
133
193
|
});
|
|
134
194
|
}
|
|
135
195
|
catch (historyError) {
|
|
136
|
-
warn(`History log append failed after patch staged-dependency committed (${
|
|
196
|
+
warn(`History log append failed after patch staged-dependency committed (${targetPatch.filename}): ${toError(historyError).message}`);
|
|
137
197
|
}
|
|
138
198
|
outro('Patch staged-dependency complete');
|
|
139
199
|
}
|
|
@@ -148,11 +208,13 @@ export function registerPatchStagedDependency(parent, context) {
|
|
|
148
208
|
parent
|
|
149
209
|
.command('staged-dependency <name>')
|
|
150
210
|
.description('Edit PatchMetadata.stagedDependencies on a single patch (no .patch body rewrite).')
|
|
151
|
-
.option('--add', 'Add a staged
|
|
152
|
-
.option('--remove', 'Remove matching staged
|
|
211
|
+
.option('--add', 'Add a staged dependency declaration')
|
|
212
|
+
.option('--remove', 'Remove matching staged dependency declaration(s)')
|
|
153
213
|
.option('--clear', 'Drop the stagedDependencies field entirely')
|
|
154
|
-
.option('--
|
|
155
|
-
.option('--
|
|
214
|
+
.option('--kind <kind>', 'Declaration shape: "import" (forward import, the default) or "registration" (jar.mn packaging line, customElements or actor registration)')
|
|
215
|
+
.option('--file <path>', 'Declaring file path relative to engine/')
|
|
216
|
+
.option('--specifier <specifier>', 'Exact import specifier as it appears in source (--kind import)')
|
|
217
|
+
.option('--line <text>', 'Registration/packaging line as the patch adds it, compared whitespace-trimmed (--kind registration)')
|
|
156
218
|
.option('--creates <path>', 'Later-created file path relative to engine/')
|
|
157
219
|
.option('--owner <patch>', 'Exact later patch filename expected to create --creates')
|
|
158
220
|
.option('--reason <text>', 'Human-readable rationale stored with the declaration')
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths } from '../core/config.js';
|
|
4
4
|
import { appendHistory, confirmDestructive } from '../core/destructive.js';
|
|
5
|
+
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
5
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
6
7
|
import { computeProjectedLintRegressions } from '../core/lint-projection.js';
|
|
7
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
@@ -151,6 +152,9 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
151
152
|
throw new InvalidArgumentError('reExportFilesInPlace called with no --files.', '--files');
|
|
152
153
|
}
|
|
153
154
|
const requested = [...new Set(filesOption)].sort();
|
|
155
|
+
// Stale-furnace-source gate (0.37.0 item 4): same refusal as the generic
|
|
156
|
+
// re-export path — the projected diff would capture stale deployed copies.
|
|
157
|
+
await enforceFreshFurnaceSources(paths.root, requested, options.allowStaleFurnace === true, 're-export');
|
|
154
158
|
const removed = target.filesAffected.filter((f) => !requested.includes(f));
|
|
155
159
|
const added = requested.filter((f) => !target.filesAffected.includes(f));
|
|
156
160
|
const retained = target.filesAffected.filter((f) => requested.includes(f));
|
|
@@ -5,6 +5,7 @@ import { multiselect } from '@clack/prompts';
|
|
|
5
5
|
import { Command, Option } from 'commander';
|
|
6
6
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
7
7
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
8
|
+
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
8
9
|
import { isGitRepository } from '../core/git.js';
|
|
9
10
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
10
11
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
@@ -129,6 +130,12 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
129
130
|
for (const f of removed)
|
|
130
131
|
info(` - ${f}`);
|
|
131
132
|
}
|
|
133
|
+
// Stale-furnace-source gate (0.37.0 item 4): re-export captures deployed
|
|
134
|
+
// engine copies, so a component source edited after the last furnace
|
|
135
|
+
// apply would land in the patch as its OLD deployed content. Refuse (or
|
|
136
|
+
// warn under --allow-stale-furnace) before diffing. Runs in dry-run too
|
|
137
|
+
// so the failure surfaces early.
|
|
138
|
+
await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
|
|
132
139
|
const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
133
140
|
if (missingFiles.length === currentFilesAffected.length) {
|
|
134
141
|
warn(`Skipped ${patch.filename}: all affected files missing`);
|
|
@@ -415,6 +422,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
415
422
|
.filter((v) => v.length > 0))
|
|
416
423
|
.option('--dry-run', 'Show what would change without writing')
|
|
417
424
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
425
|
+
.option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
|
|
418
426
|
.option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
|
|
419
427
|
.option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
|
|
420
428
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
@@ -209,6 +209,10 @@ export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, p
|
|
|
209
209
|
}
|
|
210
210
|
if (outcome.verdict.realFailureLine !== undefined) {
|
|
211
211
|
info(`First real test failure: ${outcome.verdict.realFailureLine}`);
|
|
212
|
+
const blocks = formatFailureBlocks(outcome.verdict.realFailureBlocks);
|
|
213
|
+
if (blocks !== undefined) {
|
|
214
|
+
info(blocks);
|
|
215
|
+
}
|
|
212
216
|
if (outcome.verdict.secondaryHarnessSignature !== undefined) {
|
|
213
217
|
info(`Secondary harness noise also present: ${outcome.verdict.secondaryHarnessSignature.reason}; ` +
|
|
214
218
|
outcome.verdict.secondaryHarnessSignature.line);
|
|
@@ -233,6 +237,21 @@ export function diagnoseShardOutcome(outcome, path, binaryName, postRebuildConte
|
|
|
233
237
|
return undefined;
|
|
234
238
|
}
|
|
235
239
|
catch (error) {
|
|
236
|
-
|
|
240
|
+
const diagnosis = error instanceof Error ? error.message : String(error);
|
|
241
|
+
const blocks = formatFailureBlocks(outcome.verdict.realFailureBlocks);
|
|
242
|
+
return blocks !== undefined ? `${blocks}\n${diagnosis}` : diagnosis;
|
|
237
243
|
}
|
|
238
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Renders the verdict's TEST-UNEXPECTED blocks (verbatim failing lines +
|
|
247
|
+
* their assertion/diff context) for the failure summary. Returns undefined
|
|
248
|
+
* when the verdict carries none, so callers can skip the section entirely.
|
|
249
|
+
*/
|
|
250
|
+
function formatFailureBlocks(blocks) {
|
|
251
|
+
if (blocks === undefined || blocks.length === 0)
|
|
252
|
+
return undefined;
|
|
253
|
+
// The collector appends a `…(+N more …)` note when it truncated; that
|
|
254
|
+
// note is not a failure block, so it does not count toward the header.
|
|
255
|
+
const shown = blocks.filter((block) => !block.startsWith('…(')).length;
|
|
256
|
+
return `Unexpected test failures (first ${shown}):\n${blocks.join('\n\n')}`;
|
|
257
|
+
}
|