@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/src/commands/build.js +1 -1
  3. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  4. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  5. package/dist/src/commands/doctor.js +2 -0
  6. package/dist/src/commands/export.js +7 -0
  7. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  8. package/dist/src/commands/patch/staged-dependency.js +113 -51
  9. package/dist/src/commands/re-export-files.js +4 -0
  10. package/dist/src/commands/re-export.js +8 -0
  11. package/dist/src/commands/test-diagnose.js +20 -1
  12. package/dist/src/commands/test.js +75 -23
  13. package/dist/src/core/build-baseline-types.d.ts +24 -0
  14. package/dist/src/core/build-baseline.d.ts +5 -2
  15. package/dist/src/core/build-baseline.js +5 -1
  16. package/dist/src/core/furnace-config-custom.js +10 -0
  17. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  18. package/dist/src/core/furnace-stale-export.js +123 -0
  19. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  20. package/dist/src/core/furnace-validate-structure.js +3 -2
  21. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  22. package/dist/src/core/mach-known-noise-filter.js +193 -0
  23. package/dist/src/core/mach.d.ts +8 -0
  24. package/dist/src/core/mach.js +36 -3
  25. package/dist/src/core/patch-lint-cross.js +80 -2
  26. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  27. package/dist/src/core/patch-manifest-io.js +27 -16
  28. package/dist/src/core/patch-manifest-validate.js +24 -0
  29. package/dist/src/core/test-harness-crash.d.ts +16 -0
  30. package/dist/src/core/test-harness-crash.js +55 -0
  31. package/dist/src/core/test-stale-check.d.ts +29 -1
  32. package/dist/src/core/test-stale-check.js +59 -0
  33. package/dist/src/types/commands/index.d.ts +1 -1
  34. package/dist/src/types/commands/options.d.ts +27 -4
  35. package/dist/src/types/commands/patches.d.ts +29 -0
  36. package/dist/src/types/furnace.d.ts +13 -0
  37. package/dist/src/utils/process-group.d.ts +33 -0
  38. package/dist/src/utils/process-group.js +161 -0
  39. package/dist/src/utils/process.d.ts +15 -0
  40. package/dist/src/utils/process.js +88 -44
  41. 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
- await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries);
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
- // Stale-build preflight — when --build was NOT requested, detect
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 mjsIssues = await validateMjsCompatibility(mjsPath, tagName);
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
- if (!(await pathExists(cssPath))) {
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;