@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
|
-
import { readBuildBaseline } from '../core/build-baseline.js';
|
|
3
|
+
import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
|
|
4
4
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
|
|
@@ -10,12 +10,13 @@ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionette
|
|
|
10
10
|
import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
|
|
11
11
|
import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
|
|
12
12
|
import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
|
|
13
|
-
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
13
|
+
import { checkStaleBuildForTest, findUncoveredRequestPaths, formatStaleBuildWarning, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
|
|
14
14
|
import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
|
|
15
15
|
import { GeneralError } from '../errors/base.js';
|
|
16
16
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
17
|
+
import { toError } from '../utils/errors.js';
|
|
17
18
|
import { pathExists } from '../utils/fs.js';
|
|
18
|
-
import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
|
|
19
|
+
import { info, intro, outro, spinner, success, verbose, warn } from '../utils/logger.js';
|
|
19
20
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
20
21
|
import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
|
|
21
22
|
import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
|
|
@@ -103,7 +104,7 @@ async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
|
|
|
103
104
|
}
|
|
104
105
|
return bundleCheck.expectedPath;
|
|
105
106
|
}
|
|
106
|
-
async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries) {
|
|
107
|
+
async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage) {
|
|
107
108
|
await withBuildLock(projectRoot, async () => {
|
|
108
109
|
// Pass the previous baseline exactly like `fireforge build` does, so
|
|
109
110
|
// auto-configure runs under the same conditions on both paths. The
|
|
@@ -128,6 +129,19 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
|
|
|
128
129
|
});
|
|
129
130
|
if (result.exitCode === 0) {
|
|
130
131
|
s.stop('Build complete');
|
|
132
|
+
// Same record, same failure tolerance as `fireforge build` /
|
|
133
|
+
// `build --ui` (build.ts): a green pre-test build refreshes the
|
|
134
|
+
// stale-build baseline so a later plain `fireforge test` over the
|
|
135
|
+
// same files — in any invocation shape — is not refused. A failed
|
|
136
|
+
// write never fails the run. The coverage claim is scoped to the
|
|
137
|
+
// requested test paths, since a file-scoped `test --build` only
|
|
138
|
+
// guarantees packaging for those manifests.
|
|
139
|
+
try {
|
|
140
|
+
await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage);
|
|
141
|
+
}
|
|
142
|
+
catch (baselineError) {
|
|
143
|
+
verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
|
|
144
|
+
}
|
|
131
145
|
return;
|
|
132
146
|
}
|
|
133
147
|
s.error('Pre-test build failed');
|
|
@@ -136,6 +150,51 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
|
|
|
136
150
|
: 'Pre-test build failed', 'mach build faster');
|
|
137
151
|
});
|
|
138
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Stale-build preflight — when `--build` was NOT requested, detect
|
|
155
|
+
* packageable engine edits since the last successful build and fail
|
|
156
|
+
* UP-FRONT unless the operator explicitly accepts the stale package risk.
|
|
157
|
+
*
|
|
158
|
+
* Packaging COVERAGE is checked first, on EVERY non-`--build` run and
|
|
159
|
+
* regardless of staleness or `--allow-stale-build`: a runtime packaged by
|
|
160
|
+
* a file-scoped `test --build` can lack support fixtures for OTHER
|
|
161
|
+
* manifests even when nothing changed since — dispatching such a run
|
|
162
|
+
* hangs on missing fixtures rather than failing, so the flag (which only
|
|
163
|
+
* accepts stale CONTENT) must not be the trigger. Field incident: a
|
|
164
|
+
* three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
|
|
165
|
+
* later run over different files timed out twice at 45s waiting on
|
|
166
|
+
* `DOMAudioPlaybackStarted`.
|
|
167
|
+
*
|
|
168
|
+
* Exception: a path-less `test --doctor` stops at the Marionette health
|
|
169
|
+
* check (`runDoctorPreflight` returns 'stop' when no test paths were
|
|
170
|
+
* given) and never dispatches a test, so it needs no packaging coverage —
|
|
171
|
+
* treating it as a full-suite request would refuse a probe that touches
|
|
172
|
+
* no fixtures. The stale-content refusal still applies to it unchanged.
|
|
173
|
+
*/
|
|
174
|
+
async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
|
|
175
|
+
const stale = await checkStaleBuildForTest(projectRoot, engineDir);
|
|
176
|
+
const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
|
|
177
|
+
if (!dispatchesNoTests) {
|
|
178
|
+
const recordedCoverage = stale.baseline?.testPackagingCoverage;
|
|
179
|
+
const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
|
|
180
|
+
if (uncovered.length > 0) {
|
|
181
|
+
throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const staleMessage = stale.stale
|
|
185
|
+
? `${formatStaleBuildWarning(stale)}\n\n` +
|
|
186
|
+
'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
|
|
187
|
+
'`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
|
|
188
|
+
: undefined;
|
|
189
|
+
if (staleMessage !== undefined) {
|
|
190
|
+
if (options.allowStaleBuild === true) {
|
|
191
|
+
warn(staleMessage);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
throw new GeneralError(staleMessage);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
139
198
|
function logTestSelection(scopes) {
|
|
140
199
|
if (scopes.length > 0) {
|
|
141
200
|
const labels = scopes.map((scope) => scope.isDirectory && scope.testFileCount > 0
|
|
@@ -297,28 +356,23 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
297
356
|
// during spawn (exit code 1, signal none). stderr tail: (empty)`.
|
|
298
357
|
const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
299
358
|
const harnessRetries = options.harnessRetries ?? DEFAULT_HARNESS_RETRIES;
|
|
359
|
+
// Normalized engine-relative request paths, hoisted above the build/stale
|
|
360
|
+
// gate: the pre-test build records them as the packaging-coverage claim,
|
|
361
|
+
// and the --allow-stale-build path checks the request against the
|
|
362
|
+
// recorded coverage. (Existence is still asserted later, after the gate —
|
|
363
|
+
// stale/coverage refusals keep precedence over missing-path errors.)
|
|
364
|
+
const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
|
|
365
|
+
const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
|
|
300
366
|
// Run incremental build if requested
|
|
301
367
|
if (options.build) {
|
|
302
|
-
|
|
368
|
+
// A path-less `test --build` runs (and packages for) the full suite;
|
|
369
|
+
// a scoped invocation only vouches for the requested paths.
|
|
370
|
+
const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
|
|
371
|
+
await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, coverage);
|
|
303
372
|
info('');
|
|
304
373
|
}
|
|
305
374
|
else {
|
|
306
|
-
|
|
307
|
-
// packageable engine edits since the last successful `fireforge build`
|
|
308
|
-
// and fail UP-FRONT unless the operator explicitly accepts the stale
|
|
309
|
-
// package risk.
|
|
310
|
-
const stale = await checkStaleBuildForTest(projectRoot, paths.engine);
|
|
311
|
-
if (stale.stale) {
|
|
312
|
-
const message = `${formatStaleBuildWarning(stale)}\n\n` +
|
|
313
|
-
'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
|
|
314
|
-
'`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.';
|
|
315
|
-
if (options.allowStaleBuild === true) {
|
|
316
|
-
warn(message);
|
|
317
|
-
}
|
|
318
|
-
else {
|
|
319
|
-
throw new GeneralError(message);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
375
|
+
await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths);
|
|
322
376
|
}
|
|
323
377
|
// Resolve the effective Marionette port. Operator precedence:
|
|
324
378
|
// 1. `--marionette-port` (first-class option, parsed at the CLI layer)
|
|
@@ -355,8 +409,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
355
409
|
if (doctorOutcome === 'stop')
|
|
356
410
|
return;
|
|
357
411
|
}
|
|
358
|
-
const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
|
|
359
|
-
const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
|
|
360
412
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
361
413
|
const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
|
|
362
414
|
if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
|
|
@@ -35,4 +35,28 @@ export interface BuildBaseline {
|
|
|
35
35
|
* was recorded.
|
|
36
36
|
*/
|
|
37
37
|
packageableFingerprints?: Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* What the packaged test runtime produced by the recorded build covers.
|
|
40
|
+
*
|
|
41
|
+
* - `'full'`: the build packaged the full test set — written by
|
|
42
|
+
* `fireforge build` / `build --ui` and by a path-less
|
|
43
|
+
* `fireforge test --build` (full-suite run).
|
|
44
|
+
* - `string[]`: engine-relative POSIX request paths of the file/directory-
|
|
45
|
+
* scoped `fireforge test --build` invocation that produced the runtime.
|
|
46
|
+
* A directory entry covers everything beneath it. Support fixtures for
|
|
47
|
+
* manifests outside this list may be missing from `obj-*`/`_tests/`, so
|
|
48
|
+
* an `--allow-stale-build` run over uncovered paths is refused rather
|
|
49
|
+
* than dispatched into a hang.
|
|
50
|
+
*
|
|
51
|
+
* Missing on baselines written before 0.37.0 — only `fireforge build`
|
|
52
|
+
* wrote baselines then, so "full" is the honest historical value and the
|
|
53
|
+
* stale-check treats an absent field as full coverage.
|
|
54
|
+
*
|
|
55
|
+
* The record is project-scoped, which is also per-obj-dir: multi-objdir
|
|
56
|
+
* checkouts are refused up-front (`AmbiguousBuildArtifactsError`), so at
|
|
57
|
+
* most one obj dir exists per project.
|
|
58
|
+
*/
|
|
59
|
+
testPackagingCoverage?: TestPackagingCoverage;
|
|
38
60
|
}
|
|
61
|
+
/** Coverage claim of the packaged test runtime. See {@link BuildBaseline.testPackagingCoverage}. */
|
|
62
|
+
export type TestPackagingCoverage = 'full' | string[];
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* on successful build completion; a failed build does not update it, so a
|
|
16
16
|
* subsequent run still audits against the last known-good tree.
|
|
17
17
|
*/
|
|
18
|
-
import type { BuildBaseline } from './build-baseline-types.js';
|
|
18
|
+
import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
|
|
19
19
|
/** Name of the last-build marker file under `.fireforge/`. */
|
|
20
20
|
export declare const BUILD_BASELINE_FILENAME = "last-build.json";
|
|
21
21
|
/**
|
|
@@ -39,5 +39,8 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
|
|
|
39
39
|
* @param projectRoot - Root directory of the project
|
|
40
40
|
* @param engineDir - Path to the engine directory
|
|
41
41
|
* @param binaryName - Current `binaryName` from fireforge.json
|
|
42
|
+
* @param testPackagingCoverage - Coverage claim of the packaged test
|
|
43
|
+
* runtime this build produced (`'full'`, or the scoped request paths of
|
|
44
|
+
* a `test --build` invocation). Omitted → field left off the marker.
|
|
42
45
|
*/
|
|
43
|
-
export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string): Promise<void>;
|
|
46
|
+
export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage): Promise<void>;
|
|
@@ -65,8 +65,11 @@ export async function readBuildBaseline(projectRoot) {
|
|
|
65
65
|
* @param projectRoot - Root directory of the project
|
|
66
66
|
* @param engineDir - Path to the engine directory
|
|
67
67
|
* @param binaryName - Current `binaryName` from fireforge.json
|
|
68
|
+
* @param testPackagingCoverage - Coverage claim of the packaged test
|
|
69
|
+
* runtime this build produced (`'full'`, or the scoped request paths of
|
|
70
|
+
* a `test --build` invocation). Omitted → field left off the marker.
|
|
68
71
|
*/
|
|
69
|
-
export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
|
|
72
|
+
export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage) {
|
|
70
73
|
let engineHeadSha = '';
|
|
71
74
|
try {
|
|
72
75
|
engineHeadSha = await getHead(engineDir);
|
|
@@ -86,6 +89,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
|
|
|
86
89
|
builtAt: new Date().toISOString(),
|
|
87
90
|
binaryName,
|
|
88
91
|
...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
|
|
92
|
+
...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
|
|
89
93
|
};
|
|
90
94
|
await writeJson(getBuildBaselinePath(projectRoot), baseline);
|
|
91
95
|
}
|
|
@@ -35,6 +35,13 @@ export function parseCustomConfig(data, name) {
|
|
|
35
35
|
if (!isBoolean(data['localized'])) {
|
|
36
36
|
throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
|
|
37
37
|
}
|
|
38
|
+
if (data['kind'] !== undefined && data['kind'] !== 'element' && data['kind'] !== 'library') {
|
|
39
|
+
throw new FurnaceError(`Furnace config: custom "${name}.kind" must be "element" or "library" when set`);
|
|
40
|
+
}
|
|
41
|
+
if (data['kind'] === 'library' && data['register']) {
|
|
42
|
+
throw new FurnaceError(`Furnace config: custom "${name}" has kind "library" but register: true — ` +
|
|
43
|
+
'a library exports no custom element; set register: false');
|
|
44
|
+
}
|
|
38
45
|
if (data['composes'] !== undefined) {
|
|
39
46
|
parseStringArray(data['composes'], `${name}.composes`);
|
|
40
47
|
}
|
|
@@ -58,6 +65,9 @@ export function parseCustomConfig(data, name) {
|
|
|
58
65
|
? { composes: parseStringArray(data['composes'], `${name}.composes`) }
|
|
59
66
|
: {}),
|
|
60
67
|
...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
|
|
68
|
+
// Explicit `kind: "element"` is the default; normalize it away so the
|
|
69
|
+
// stored config carries the field only when it changes behavior.
|
|
70
|
+
...(data['kind'] === 'library' ? { kind: 'library' } : {}),
|
|
61
71
|
...(sharedFtl !== undefined ? { sharedFtl } : {}),
|
|
62
72
|
};
|
|
63
73
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-furnace-source gate for `fireforge patch export` / `re-export`.
|
|
3
|
+
*
|
|
4
|
+
* Exports capture the DEPLOYED engine copies of furnace-managed files, not
|
|
5
|
+
* the `components/` sources. Editing a component source and re-exporting
|
|
6
|
+
* its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
|
|
7
|
+
* exports the stale deployed copy — per-patch lint then flags the old line
|
|
8
|
+
* count and the patch body lags the source. This gate detects that
|
|
9
|
+
* sequence by comparing component source directories against the checksums
|
|
10
|
+
* recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
|
|
11
|
+
* signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
|
|
12
|
+
* unless the operator passes `--allow-stale-furnace`.
|
|
13
|
+
*
|
|
14
|
+
* Checksum-based on purpose: git checkouts and `furnace refresh` churn
|
|
15
|
+
* mtimes without content changes, so an mtime comparison would misfire.
|
|
16
|
+
*
|
|
17
|
+
* Probe failures (broken furnace config, missing state) degrade to a
|
|
18
|
+
* verbose log and an empty result — a broken furnace setup must never
|
|
19
|
+
* block non-furnace patch work.
|
|
20
|
+
*/
|
|
21
|
+
/** One furnace component whose source drifted from its deployed copy. */
|
|
22
|
+
export interface StaleFurnaceComponent {
|
|
23
|
+
/** Component name (furnace.json key). */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Component flavor — resolves the source directory root. */
|
|
26
|
+
type: 'custom' | 'override';
|
|
27
|
+
/** Engine-relative deployed prefix (`/`-terminated) the component owns. */
|
|
28
|
+
prefix: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the furnace components whose deployed engine copies the given
|
|
32
|
+
* files fall under AND whose `components/` sources have changed since the
|
|
33
|
+
* last apply. Empty when furnace is not configured, never applied, or the
|
|
34
|
+
* probe fails.
|
|
35
|
+
*
|
|
36
|
+
* Files are attributed per component: the custom `targetPath` / override
|
|
37
|
+
* `basePath` prefixes, plus — for a localized non-sharedFtl custom
|
|
38
|
+
* component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
|
|
39
|
+
* FTL dir as a whole stays unclaimed; sibling files there belong to other
|
|
40
|
+
* components). `sharedFtl` bundles are NOT furnace-deployed (apply only
|
|
41
|
+
* prunes a dangling per-widget jar.mn line), so they have no deployed copy
|
|
42
|
+
* to go stale and get no candidate by design. The storybook stories prefix
|
|
43
|
+
* is likewise skipped.
|
|
44
|
+
*
|
|
45
|
+
* @param projectRoot - Project root directory
|
|
46
|
+
* @param files - Engine-relative paths the export will capture
|
|
47
|
+
*/
|
|
48
|
+
export declare function findStaleFurnaceComponentsForFiles(projectRoot: string, files: readonly string[]): Promise<StaleFurnaceComponent[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Gate helper for export/re-export: refuses (or, with `allow`, warns) when
|
|
51
|
+
* any exported file belongs to a furnace component whose source changed
|
|
52
|
+
* since the last apply — the export would capture the stale deployed copy.
|
|
53
|
+
*
|
|
54
|
+
* @param projectRoot - Project root directory
|
|
55
|
+
* @param files - Engine-relative paths the export will capture
|
|
56
|
+
* @param allow - True when the operator passed `--allow-stale-furnace`
|
|
57
|
+
* @param command - Which command runs the gate (message wording only)
|
|
58
|
+
*/
|
|
59
|
+
export declare function enforceFreshFurnaceSources(projectRoot: string, files: readonly string[], allow: boolean, command: 'export' | 're-export'): Promise<void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Stale-furnace-source gate for `fireforge patch export` / `re-export`.
|
|
4
|
+
*
|
|
5
|
+
* Exports capture the DEPLOYED engine copies of furnace-managed files, not
|
|
6
|
+
* the `components/` sources. Editing a component source and re-exporting
|
|
7
|
+
* its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
|
|
8
|
+
* exports the stale deployed copy — per-patch lint then flags the old line
|
|
9
|
+
* count and the patch body lags the source. This gate detects that
|
|
10
|
+
* sequence by comparing component source directories against the checksums
|
|
11
|
+
* recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
|
|
12
|
+
* signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
|
|
13
|
+
* unless the operator passes `--allow-stale-furnace`.
|
|
14
|
+
*
|
|
15
|
+
* Checksum-based on purpose: git checkouts and `furnace refresh` churn
|
|
16
|
+
* mtimes without content changes, so an mtime comparison would misfire.
|
|
17
|
+
*
|
|
18
|
+
* Probe failures (broken furnace config, missing state) degrade to a
|
|
19
|
+
* verbose log and an empty result — a broken furnace setup must never
|
|
20
|
+
* block non-furnace patch work.
|
|
21
|
+
*/
|
|
22
|
+
import { GeneralError } from '../errors/base.js';
|
|
23
|
+
import { pathExists } from '../utils/fs.js';
|
|
24
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
25
|
+
import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
|
|
26
|
+
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
|
|
27
|
+
import { resolveFtlDir } from './furnace-constants.js';
|
|
28
|
+
/**
|
|
29
|
+
* Returns the furnace components whose deployed engine copies the given
|
|
30
|
+
* files fall under AND whose `components/` sources have changed since the
|
|
31
|
+
* last apply. Empty when furnace is not configured, never applied, or the
|
|
32
|
+
* probe fails.
|
|
33
|
+
*
|
|
34
|
+
* Files are attributed per component: the custom `targetPath` / override
|
|
35
|
+
* `basePath` prefixes, plus — for a localized non-sharedFtl custom
|
|
36
|
+
* component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
|
|
37
|
+
* FTL dir as a whole stays unclaimed; sibling files there belong to other
|
|
38
|
+
* components). `sharedFtl` bundles are NOT furnace-deployed (apply only
|
|
39
|
+
* prunes a dangling per-widget jar.mn line), so they have no deployed copy
|
|
40
|
+
* to go stale and get no candidate by design. The storybook stories prefix
|
|
41
|
+
* is likewise skipped.
|
|
42
|
+
*
|
|
43
|
+
* @param projectRoot - Project root directory
|
|
44
|
+
* @param files - Engine-relative paths the export will capture
|
|
45
|
+
*/
|
|
46
|
+
export async function findStaleFurnaceComponentsForFiles(projectRoot, files) {
|
|
47
|
+
try {
|
|
48
|
+
if (files.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
if (!(await furnaceConfigExists(projectRoot)))
|
|
51
|
+
return [];
|
|
52
|
+
const config = await loadFurnaceConfig(projectRoot);
|
|
53
|
+
const state = await loadFurnaceState(projectRoot);
|
|
54
|
+
if (!state.appliedChecksums)
|
|
55
|
+
return [];
|
|
56
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
57
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
58
|
+
const ftlPrefix = ftlDir.endsWith('/') ? ftlDir : `${ftlDir}/`;
|
|
59
|
+
const candidates = [
|
|
60
|
+
...Object.entries(config.overrides).map(([name, cfg]) => ({
|
|
61
|
+
name,
|
|
62
|
+
type: 'override',
|
|
63
|
+
prefix: cfg.basePath.endsWith('/') ? cfg.basePath : `${cfg.basePath}/`,
|
|
64
|
+
exactFiles: [],
|
|
65
|
+
})),
|
|
66
|
+
...Object.entries(config.custom).map(([name, cfg]) => ({
|
|
67
|
+
name,
|
|
68
|
+
type: 'custom',
|
|
69
|
+
prefix: cfg.targetPath.endsWith('/') ? cfg.targetPath : `${cfg.targetPath}/`,
|
|
70
|
+
// A localized non-sharedFtl component also owns exactly one
|
|
71
|
+
// deployed file in the shared FTL dir (applyCustomFtlFile copies
|
|
72
|
+
// `<name>.ftl` there). sharedFtl bundles are not furnace-deployed.
|
|
73
|
+
exactFiles: cfg.localized && cfg.sharedFtl === undefined ? [`${ftlPrefix}${name}.ftl`] : [],
|
|
74
|
+
})),
|
|
75
|
+
];
|
|
76
|
+
const stale = [];
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (!files.some((file) => file.startsWith(candidate.prefix) || candidate.exactFiles.includes(file)))
|
|
79
|
+
continue;
|
|
80
|
+
const sourceRoot = candidate.type === 'override' ? furnacePaths.overridesDir : furnacePaths.customDir;
|
|
81
|
+
const sourceDir = `${sourceRoot}/${candidate.name}`;
|
|
82
|
+
if (!(await pathExists(sourceDir)))
|
|
83
|
+
continue;
|
|
84
|
+
const previous = extractComponentChecksums(state.appliedChecksums, candidate.type, candidate.name);
|
|
85
|
+
if (await hasComponentChanged(sourceDir, previous)) {
|
|
86
|
+
stale.push({ name: candidate.name, type: candidate.type, prefix: candidate.prefix });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return stale;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
verbose(`Stale-furnace export gate skipped due to an error: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Gate helper for export/re-export: refuses (or, with `allow`, warns) when
|
|
98
|
+
* any exported file belongs to a furnace component whose source changed
|
|
99
|
+
* since the last apply — the export would capture the stale deployed copy.
|
|
100
|
+
*
|
|
101
|
+
* @param projectRoot - Project root directory
|
|
102
|
+
* @param files - Engine-relative paths the export will capture
|
|
103
|
+
* @param allow - True when the operator passed `--allow-stale-furnace`
|
|
104
|
+
* @param command - Which command runs the gate (message wording only)
|
|
105
|
+
*/
|
|
106
|
+
export async function enforceFreshFurnaceSources(projectRoot, files, allow, command) {
|
|
107
|
+
const stale = await findStaleFurnaceComponentsForFiles(projectRoot, files);
|
|
108
|
+
if (stale.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
const list = stale
|
|
111
|
+
.map((component) => `${component.name} (components/${component.type === 'custom' ? 'custom' : 'overrides'}/${component.name}/ → ${component.prefix})`)
|
|
112
|
+
.join(', ');
|
|
113
|
+
const message = `Component source${stale.length === 1 ? '' : 's'} changed since the last furnace apply: ${list}. ` +
|
|
114
|
+
`The deployed engine cop${stale.length === 1 ? 'y is' : 'ies are'} stale, so this ${command} would ` +
|
|
115
|
+
'capture the OLD component content into the patch. Run "fireforge furnace deploy" (or ' +
|
|
116
|
+
'"fireforge furnace apply") first so the export captures the current source, or pass ' +
|
|
117
|
+
'--allow-stale-furnace to export the deployed copy anyway.';
|
|
118
|
+
if (allow) {
|
|
119
|
+
warn(message);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
throw new GeneralError(message);
|
|
123
|
+
}
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { pathExists, readText } from '../utils/fs.js';
|
|
4
4
|
import { hasRawCssColors } from '../utils/regex.js';
|
|
5
5
|
import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
|
|
6
|
-
async function validateMjsCompatibility(mjsPath, tagName) {
|
|
6
|
+
async function validateMjsCompatibility(mjsPath, tagName, isLibrary) {
|
|
7
7
|
if (!(await pathExists(mjsPath)))
|
|
8
8
|
return [];
|
|
9
9
|
const mjsContent = await readText(mjsPath);
|
|
@@ -11,6 +11,12 @@ async function validateMjsCompatibility(mjsPath, tagName) {
|
|
|
11
11
|
if (hasRelativeModuleImport(mjsContent)) {
|
|
12
12
|
issues.push(createIssue(tagName, 'error', 'relative-import', 'Imports must use chrome:// URIs, not relative paths.'));
|
|
13
13
|
}
|
|
14
|
+
// A library component (kind: "library") deliberately defines no element:
|
|
15
|
+
// it exports a base class + helpers for other components to extend, so
|
|
16
|
+
// the define/extends requirements do not apply.
|
|
17
|
+
if (isLibrary) {
|
|
18
|
+
return issues;
|
|
19
|
+
}
|
|
14
20
|
if (!hasCustomElementDefineCall(mjsContent)) {
|
|
15
21
|
issues.push(createIssue(tagName, 'error', 'no-custom-element-define', 'Missing customElements.define() call. Component will not be registered.'));
|
|
16
22
|
}
|
|
@@ -124,7 +130,8 @@ export async function validateCompatibility(componentDir, tagName, type, config,
|
|
|
124
130
|
const issues = [];
|
|
125
131
|
const mjsPath = join(componentDir, `${tagName}.mjs`);
|
|
126
132
|
const cssPath = join(componentDir, `${tagName}.css`);
|
|
127
|
-
const
|
|
133
|
+
const isLibrary = type === 'custom' && config?.custom[tagName]?.kind === 'library';
|
|
134
|
+
const mjsIssues = await validateMjsCompatibility(mjsPath, tagName, isLibrary);
|
|
128
135
|
issues.push(...mjsIssues);
|
|
129
136
|
const cssIssues = await validateCssCompatibility(cssPath, tagName, type, config, root);
|
|
130
137
|
issues.push(...cssIssues);
|
|
@@ -37,8 +37,9 @@ export async function validateStructure(componentDir, tagName, type, customConfi
|
|
|
37
37
|
message: `Required file ${tagName}.mjs not found.`,
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
-
// .css should exist
|
|
41
|
-
|
|
40
|
+
// .css should exist — except for library components (base class + helpers,
|
|
41
|
+
// no element of their own), which render nothing and need no stylesheet.
|
|
42
|
+
if (!(await pathExists(cssPath)) && customConfig?.kind !== 'library') {
|
|
42
43
|
issues.push({
|
|
43
44
|
component: tagName,
|
|
44
45
|
severity: 'warning',
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-echo filter for the KNOWN upstream mozsystemmonitor teardown
|
|
3
|
+
* traceback (0.37.0 item 8).
|
|
4
|
+
*
|
|
5
|
+
* Every headless test run against the 153-beta engine ends with an
|
|
6
|
+
* `AttributeError: 'SystemResourceMonitor' object has no attribute
|
|
7
|
+
* 'stop_time'` traceback at harness teardown — upstream noise that sits
|
|
8
|
+
* exactly where a reader looks for the failure summary. 0.36.0 already made
|
|
9
|
+
* real failure lines beat this traceback in CLASSIFICATION; this filter
|
|
10
|
+
* closes the PRESENTATION gap by collapsing the echoed traceback to one
|
|
11
|
+
* labeled line.
|
|
12
|
+
*
|
|
13
|
+
* Scope is deliberately narrow:
|
|
14
|
+
* - Only the terminal ECHO is filtered. The captured stdout/stderr strings
|
|
15
|
+
* stay raw — the classifier (`test-harness-crash.ts`) depends on the
|
|
16
|
+
* raw traceback for its green-summary override and secondary-noise
|
|
17
|
+
* detection.
|
|
18
|
+
* - Only the exact documented incident is collapsed, and every condition
|
|
19
|
+
* must hold: an `AttributeError` on `SystemResourceMonitor` naming one
|
|
20
|
+
* of the two known attributes (`stop_time`, `poll_interval` — the
|
|
21
|
+
* 0.34.0 guard family), AND a `mozsystemmonitor/resourcemonitor.py`
|
|
22
|
+
* stack frame, AND a previously-seen SUITE_END shutdown marker (shared
|
|
23
|
+
* across the run's stdout/stderr filter instances — the marker usually
|
|
24
|
+
* lands on stdout while the traceback lands on stderr). A novel
|
|
25
|
+
* attribute, a novel exception type in resourcemonitor.py, or a
|
|
26
|
+
* pre-shutdown occurrence is echoed verbatim, always.
|
|
27
|
+
* - The hold buffer is bounded; on overflow the block is flushed verbatim
|
|
28
|
+
* and the filter returns to pass-through, so output is never lost.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Shared across the stdout and stderr filter instances of ONE mach run:
|
|
32
|
+
* SUITE_END typically arrives on stdout while the teardown traceback lands
|
|
33
|
+
* on stderr, so the shutdown-seen flag must be visible to both. The two
|
|
34
|
+
* pipes are independent, so a traceback can theoretically beat the marker
|
|
35
|
+
* through — the block then prints verbatim, the correct failure direction.
|
|
36
|
+
*/
|
|
37
|
+
export interface TeardownNoiseContext {
|
|
38
|
+
shutdownSeen: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
41
|
+
export declare function createTeardownNoiseContext(): TeardownNoiseContext;
|
|
42
|
+
/** One line replaces the whole recognized traceback block in the echo. */
|
|
43
|
+
export declare const KNOWN_TEARDOWN_NOISE_ANNOTATION: string;
|
|
44
|
+
/** Chunk-safe, line-buffered echo filter. See module doc. */
|
|
45
|
+
export interface KnownTeardownNoiseFilter {
|
|
46
|
+
/** Feed a raw chunk; returns the text to echo now (possibly empty). */
|
|
47
|
+
transform(chunk: string): string;
|
|
48
|
+
/** Flush any buffered residue verbatim (call after the stream closes). */
|
|
49
|
+
flush(): string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates a stateful filter for one output stream. Complete lines outside a
|
|
53
|
+
* traceback pass straight through (only the trailing partial line is held
|
|
54
|
+
* back); a `Traceback (most recent call last)` header switches to hold mode
|
|
55
|
+
* until the block ends, then either the one-line annotation (recognized
|
|
56
|
+
* signature after a seen shutdown marker) or the verbatim block (anything
|
|
57
|
+
* else) is emitted. Pass the run's shared {@link TeardownNoiseContext} so
|
|
58
|
+
* both stream filters see the same shutdown flag.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createKnownTeardownNoiseFilter(context?: TeardownNoiseContext): KnownTeardownNoiseFilter;
|