@hominis/fireforge 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ import { toError } from '../utils/errors.js';
10
10
  import { pathExists } from '../utils/fs.js';
11
11
  import { info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  import { pickDefined } from '../utils/options.js';
13
- import { isContainedRelativePath, isPathInsideRoot, toRootRelativePath } from '../utils/paths.js';
13
+ import { isContainedRelativePath, isExplicitAbsolutePath, isPathInsideRoot, stripEnginePrefix, toRootRelativePath, } from '../utils/paths.js';
14
14
  const BROWSER_BASE_DIR = 'browser/base';
15
15
  function printWireDryRun(engineDir, name, subscriptDir, domFilePath, domTargetPath, options) {
16
16
  info('[dry-run] Would wire subscript:');
@@ -113,25 +113,51 @@ export async function wireCommand(projectRoot, name, options = {}) {
113
113
  }
114
114
  subscriptDir = options.subscriptDir;
115
115
  }
116
- // Validate DOM fragment file exists and compute path relative to engine root
116
+ // Validate DOM fragment file exists and compute path relative to engine root.
117
+ //
118
+ // Accepts three shapes:
119
+ // - Absolute paths (`/project/engine/browser/base/content/foo.inc.xhtml`)
120
+ // - Repo-root-relative forms (`engine/browser/base/content/foo.inc.xhtml`)
121
+ // - Engine-relative forms (`browser/base/content/foo.inc.xhtml`)
122
+ //
123
+ // Before the engine-prefix normalization, passing an `engine/…`-prefixed
124
+ // relative path from the repo root double-rooted through
125
+ // `toRootRelativePath(engineDir, …)` — `resolve(engineDir, 'engine/…')`
126
+ // landed at `engineDir/engine/…`, which is still "inside" engineDir but
127
+ // named as a second-level `engine/…` entry. The computed `#include`
128
+ // then read `../../../engine/browser/base/content/foo.inc.xhtml`,
129
+ // packaging-breaking nonsense. For absolute inputs this pre-existing
130
+ // contract was fine — `toRootRelativePath` handles absolute candidates
131
+ // correctly — so we only strip the prefix when the input is relative.
117
132
  let domFilePath;
118
133
  if (options.dom) {
119
134
  const paths = getProjectPaths(projectRoot);
120
- if (!(await pathExists(options.dom))) {
135
+ const domCandidate = isExplicitAbsolutePath(options.dom)
136
+ ? options.dom
137
+ : stripEnginePrefix(options.dom);
138
+ if (!(await pathExists(domCandidate))) {
121
139
  throw new InvalidArgumentError(`DOM fragment file not found: ${options.dom}`, 'dom');
122
140
  }
123
- if (!isPathInsideRoot(paths.engine, options.dom)) {
141
+ if (!isPathInsideRoot(paths.engine, domCandidate)) {
124
142
  throw new InvalidArgumentError(`DOM fragment file must stay within engine/: ${options.dom}`, 'dom');
125
143
  }
126
- domFilePath = toRootRelativePath(paths.engine, options.dom);
144
+ domFilePath = toRootRelativePath(paths.engine, domCandidate);
127
145
  }
128
146
  // Resolve the chrome document the `#include` directive will land in.
129
147
  // Only consulted when `--dom` is supplied — we still resolve it here so
130
148
  // the dry-run plan can print the target accurately.
131
- if (options.target !== undefined && !isContainedRelativePath(options.target)) {
132
- throw new InvalidArgumentError(`Target chrome document must stay within engine/: ${options.target}`, 'target');
133
- }
134
- const domTargetPath = await resolveDomTargetPath(projectRoot, options.target);
149
+ //
150
+ // `stripEnginePrefix` is applied so `--target engine/browser/base/browser.xhtml`
151
+ // and `--target browser/base/browser.xhtml` are treated identically,
152
+ // matching the `--dom` normalization above. Absolute `--target` paths
153
+ // stay absolute (the containment check downstream rejects them).
154
+ const normalizedTarget = options.target !== undefined && !isExplicitAbsolutePath(options.target)
155
+ ? stripEnginePrefix(options.target)
156
+ : options.target;
157
+ if (normalizedTarget !== undefined && !isContainedRelativePath(normalizedTarget)) {
158
+ throw new InvalidArgumentError(`Target chrome document must stay within engine/: ${options.target ?? ''}`, 'target');
159
+ }
160
+ const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
135
161
  if (domFilePath) {
136
162
  const paths = getProjectPaths(projectRoot);
137
163
  if (!options.dryRun && !(await pathExists(join(paths.engine, domTargetPath)))) {
@@ -31,6 +31,20 @@ export interface BuildBaseline {
31
31
  * the project has since been renamed.
32
32
  */
33
33
  binaryName: string;
34
+ /**
35
+ * Content hash per packageable engine path that was dirty at build
36
+ * time (modified-against-HEAD or untracked). Used by
37
+ * `checkStaleBuildForTest` to distinguish "this file's content was
38
+ * already in `dist/` when the build completed" from "this file has
39
+ * been edited since". Missing on baselines written before 0.16.0; the
40
+ * stale-check falls back to the path-only comparison in that case,
41
+ * so older baselines retain their existing behavior.
42
+ *
43
+ * Keys are engine-relative POSIX paths. Values are hex-encoded
44
+ * SHA-256 digests of the file contents at the moment the baseline
45
+ * was recorded.
46
+ */
47
+ packageableFingerprints?: Record<string, string>;
34
48
  }
35
49
  /** Name of the last-build marker file under `.fireforge/`. */
36
50
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
@@ -16,10 +16,17 @@
16
16
  * on successful build completion; a failed build does not update it, so a
17
17
  * subsequent run still audits against the last known-good tree.
18
18
  */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFile } from 'node:fs/promises';
19
21
  import { join } from 'node:path';
22
+ import { toError } from '../utils/errors.js';
20
23
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
24
+ import { verbose } from '../utils/logger.js';
25
+ import { isPackageablePath } from './build-audit.js';
21
26
  import { FIREFORGE_DIR } from './config-paths.js';
22
- import { getHead, isMissingHeadError } from './git.js';
27
+ import { getHead, hasChanges, isMissingHeadError } from './git.js';
28
+ import { git } from './git-base.js';
29
+ import { getUntrackedFiles } from './git-status.js';
23
30
  /** Name of the last-build marker file under `.fireforge/`. */
24
31
  export const BUILD_BASELINE_FILENAME = 'last-build.json';
25
32
  /**
@@ -73,11 +80,64 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
73
80
  throw error;
74
81
  }
75
82
  }
83
+ const packageableFingerprints = await collectPackageableFingerprints(engineDir);
76
84
  const baseline = {
77
85
  engineHeadSha,
78
86
  builtAt: new Date().toISOString(),
79
87
  binaryName,
88
+ ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
80
89
  };
81
90
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
82
91
  }
92
+ /**
93
+ * Reads the current engine workdir and computes a SHA-256 fingerprint
94
+ * for every packageable path that is either modified against HEAD or
95
+ * untracked. The stale-build preflight (`checkStaleBuildForTest`)
96
+ * compares the live fingerprint for each packageable-dirty file to
97
+ * the baseline's entry — paths where the hash matches are "the build
98
+ * already saw this exact content", paths where it differs (or that
99
+ * are new since the baseline) are genuinely stale.
100
+ *
101
+ * Returns `undefined` on any git failure so a broken probe never
102
+ * corrupts the on-disk baseline with `{}`; the stale-check then falls
103
+ * back to the pre-0.16.0 "path-only" behavior on the next test run.
104
+ */
105
+ async function collectPackageableFingerprints(engineDir) {
106
+ try {
107
+ const dirtyPaths = new Set();
108
+ if (await hasChanges(engineDir)) {
109
+ const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
110
+ for (const line of worktreeDiff.split('\n')) {
111
+ const trimmed = line.trim();
112
+ if (trimmed)
113
+ dirtyPaths.add(trimmed);
114
+ }
115
+ for (const untracked of await getUntrackedFiles(engineDir)) {
116
+ dirtyPaths.add(untracked);
117
+ }
118
+ }
119
+ const packageable = [...dirtyPaths].filter(isPackageablePath);
120
+ if (packageable.length === 0) {
121
+ return {};
122
+ }
123
+ const fingerprints = {};
124
+ for (const relPath of packageable) {
125
+ try {
126
+ const buffer = await readFile(join(engineDir, relPath));
127
+ fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
128
+ }
129
+ catch (fileError) {
130
+ // A file that disappeared between status probe and hash is
131
+ // expected in concurrent scenarios; skip it without failing the
132
+ // whole baseline write.
133
+ verbose(`Build baseline: skipping fingerprint for ${relPath} — ${toError(fileError).message}`);
134
+ }
135
+ }
136
+ return fingerprints;
137
+ }
138
+ catch (error) {
139
+ verbose(`Build baseline: packageable fingerprint probe failed — ${toError(error).message}`);
140
+ return undefined;
141
+ }
142
+ }
83
143
  //# sourceMappingURL=build-baseline.js.map
@@ -12,4 +12,4 @@ import type { FireForgeConfig } from '../types/config.js';
12
12
  * @returns The mutated config
13
13
  */
14
14
  export declare function mutateConfig(config: FireForgeConfig, key: string, value: unknown, skipValidation?: false): FireForgeConfig;
15
- export declare function mutateConfig(config: FireForgeConfig, key: string, value: unknown, skipValidation: true): Record<string, unknown>;
15
+ export declare function mutateConfig(config: FireForgeConfig | Record<string, unknown>, key: string, value: unknown, skipValidation: true): Record<string, unknown>;
@@ -25,6 +25,23 @@ export declare function configExists(root: string): Promise<boolean>;
25
25
  * @throws Error if config doesn't exist or is invalid
26
26
  */
27
27
  export declare function loadConfig(root: string): Promise<FireForgeConfig>;
28
+ /**
29
+ * Reads the raw `fireforge.json` document without running it through
30
+ * {@link validateConfig}. Returns every persisted key — including keys
31
+ * written via `fireforge config <key> --force` that `validateConfig`
32
+ * would strip from the typed result.
33
+ *
34
+ * Callers that need the validated, typed shape must still use
35
+ * {@link loadConfig}; this helper exists specifically for the `config`
36
+ * read path so `fireforge config <key>` can surface keys the write path
37
+ * accepted under `--force`.
38
+ *
39
+ * @param root - Root directory of the project
40
+ * @returns Raw config object as persisted on disk
41
+ * @throws ConfigNotFoundError when fireforge.json is missing
42
+ * @throws ConfigError when the file is not valid JSON
43
+ */
44
+ export declare function loadRawConfigDocument(root: string): Promise<Record<string, unknown>>;
28
45
  /**
29
46
  * Writes a configuration to fireforge.json.
30
47
  * @param root - Root directory of the project
@@ -50,6 +50,41 @@ export async function loadConfig(root) {
50
50
  throw new ConfigError(`Invalid fireforge.json at ${paths.config}: ${toError(error).message}`);
51
51
  }
52
52
  }
53
+ /**
54
+ * Reads the raw `fireforge.json` document without running it through
55
+ * {@link validateConfig}. Returns every persisted key — including keys
56
+ * written via `fireforge config <key> --force` that `validateConfig`
57
+ * would strip from the typed result.
58
+ *
59
+ * Callers that need the validated, typed shape must still use
60
+ * {@link loadConfig}; this helper exists specifically for the `config`
61
+ * read path so `fireforge config <key>` can surface keys the write path
62
+ * accepted under `--force`.
63
+ *
64
+ * @param root - Root directory of the project
65
+ * @returns Raw config object as persisted on disk
66
+ * @throws ConfigNotFoundError when fireforge.json is missing
67
+ * @throws ConfigError when the file is not valid JSON
68
+ */
69
+ export async function loadRawConfigDocument(root) {
70
+ const paths = getProjectPaths(root);
71
+ if (!(await pathExists(paths.config))) {
72
+ throw new ConfigNotFoundError(paths.config);
73
+ }
74
+ try {
75
+ const data = await readJson(paths.config);
76
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
77
+ throw new ConfigError(`Invalid fireforge.json at ${paths.config}: expected an object`);
78
+ }
79
+ return data;
80
+ }
81
+ catch (error) {
82
+ if (error instanceof ConfigError || error instanceof ConfigNotFoundError) {
83
+ throw error;
84
+ }
85
+ throw new ConfigError(`Invalid fireforge.json at ${paths.config}: ${toError(error).message}`);
86
+ }
87
+ }
53
88
  /**
54
89
  * Writes a configuration to fireforge.json.
55
90
  * @param root - Root directory of the project
@@ -23,12 +23,26 @@ export declare function getDownloadUrl(version: string, product?: FirefoxProduct
23
23
  * @returns Tarball filename
24
24
  */
25
25
  export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
26
+ /**
27
+ * Lifecycle phase reported by {@link downloadFirefoxSource}. The download
28
+ * CLI command uses this to swap spinners between the bytes-on-the-wire
29
+ * phase and the silent tar-xz decompression phase that follows — before
30
+ * this, a single spinner stuck at "Downloading Firefox … 100%" covered
31
+ * both phases, making the first-run setup look hung precisely when the
32
+ * archive was already on disk and `tar` was the long pole.
33
+ */
34
+ export type FirefoxSourcePhase = 'download' | 'extract';
35
+ /** Callback fired at phase transitions during {@link downloadFirefoxSource}. */
36
+ export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
26
37
  /**
27
38
  * Downloads and extracts Firefox source.
28
39
  * @param version - Firefox version to download
29
40
  * @param product - Firefox product type
30
41
  * @param destDir - Destination directory for extracted source
31
42
  * @param cacheDir - Directory to store downloaded tarball
32
- * @param onProgress - Optional progress callback
43
+ * @param onProgress - Optional progress callback for the download byte stream
44
+ * @param onPhase - Optional callback fired when the function transitions
45
+ * between phases (`'download'` → `'extract'`). Fires exactly once per
46
+ * phase even if the cached archive path skips the wire entirely.
33
47
  */
34
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback): Promise<void>;
48
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback): Promise<void>;
@@ -39,16 +39,21 @@ export function getTarballFilename(version, product = 'firefox') {
39
39
  * @param product - Firefox product type
40
40
  * @param destDir - Destination directory for extracted source
41
41
  * @param cacheDir - Directory to store downloaded tarball
42
- * @param onProgress - Optional progress callback
42
+ * @param onProgress - Optional progress callback for the download byte stream
43
+ * @param onPhase - Optional callback fired when the function transitions
44
+ * between phases (`'download'` → `'extract'`). Fires exactly once per
45
+ * phase even if the cached archive path skips the wire entirely.
43
46
  */
44
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress) {
47
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase) {
45
48
  const archive = resolveArchive(version, product);
46
49
  const tarballPath = join(cacheDir, archive.filename);
47
50
  // Ensure cache directory exists
48
51
  await ensureDir(cacheDir);
52
+ onPhase?.('download');
49
53
  await ensureCachedArchive(archive, cacheDir, onProgress);
50
54
  // Extract to a unique temporary directory so concurrent downloads for
51
55
  // the same destination do not clobber each other.
56
+ onPhase?.('extract');
52
57
  const tempDir = `${destDir}.tmp-${randomUUID()}`;
53
58
  try {
54
59
  await extractTarXz(tarballPath, tempDir);
@@ -86,6 +86,29 @@ export declare function loadFurnaceConfig(root: string): Promise<FurnaceConfig>;
86
86
  * @param config - Configuration to write
87
87
  */
88
88
  export declare function writeFurnaceConfig(root: string, config: FurnaceConfig): Promise<void>;
89
+ /**
90
+ * Stamps every override's `baseVersion` to the supplied version. Used by
91
+ * `fireforge rebase` after a successful patch re-export so a successful
92
+ * ESR bump does not leave Furnace overrides in a doctor-failing drift
93
+ * state. Returns the number of overrides stamped (zero if furnace.json
94
+ * has no overrides, or if the file is missing).
95
+ *
96
+ * Motivating case: a 140.9.0esr → 140.9.1esr rebase stamps patch
97
+ * `sourceEsrVersion` via `stampPatchVersions`, but before 0.16.0 no
98
+ * equivalent stamping ran for Furnace override `baseVersion`. `doctor`
99
+ * then immediately failed Furnace component validation on every
100
+ * override. The stamp is deliberately unconditional — `fireforge
101
+ * furnace validate` is the right tool for "does this override still
102
+ * apply", and rebase already attested that the patch layer re-validated
103
+ * against the new ESR; the per-override health check belongs in a
104
+ * separate pass, not inline with the stamp.
105
+ *
106
+ * @param root - Root directory of the project
107
+ * @param version - Firefox version string to stamp onto every override
108
+ * @returns Number of overrides whose `baseVersion` was updated (either
109
+ * because it was missing or because it differed from `version`).
110
+ */
111
+ export declare function stampFurnaceOverrideBaseVersions(root: string, version: string): Promise<number>;
89
112
  /**
90
113
  * Creates a default furnace configuration.
91
114
  * @returns A valid empty FurnaceConfig
@@ -420,6 +420,44 @@ export async function writeFurnaceConfig(root, config) {
420
420
  const paths = getFurnacePaths(root);
421
421
  await writeJson(paths.furnaceConfig, config);
422
422
  }
423
+ /**
424
+ * Stamps every override's `baseVersion` to the supplied version. Used by
425
+ * `fireforge rebase` after a successful patch re-export so a successful
426
+ * ESR bump does not leave Furnace overrides in a doctor-failing drift
427
+ * state. Returns the number of overrides stamped (zero if furnace.json
428
+ * has no overrides, or if the file is missing).
429
+ *
430
+ * Motivating case: a 140.9.0esr → 140.9.1esr rebase stamps patch
431
+ * `sourceEsrVersion` via `stampPatchVersions`, but before 0.16.0 no
432
+ * equivalent stamping ran for Furnace override `baseVersion`. `doctor`
433
+ * then immediately failed Furnace component validation on every
434
+ * override. The stamp is deliberately unconditional — `fireforge
435
+ * furnace validate` is the right tool for "does this override still
436
+ * apply", and rebase already attested that the patch layer re-validated
437
+ * against the new ESR; the per-override health check belongs in a
438
+ * separate pass, not inline with the stamp.
439
+ *
440
+ * @param root - Root directory of the project
441
+ * @param version - Firefox version string to stamp onto every override
442
+ * @returns Number of overrides whose `baseVersion` was updated (either
443
+ * because it was missing or because it differed from `version`).
444
+ */
445
+ export async function stampFurnaceOverrideBaseVersions(root, version) {
446
+ if (!(await furnaceConfigExists(root)))
447
+ return 0;
448
+ const config = await loadFurnaceConfig(root);
449
+ let changed = 0;
450
+ for (const override of Object.values(config.overrides)) {
451
+ if (override.baseVersion !== version) {
452
+ override.baseVersion = version;
453
+ changed++;
454
+ }
455
+ }
456
+ if (changed > 0) {
457
+ await writeFurnaceConfig(root, config);
458
+ }
459
+ return changed;
460
+ }
423
461
  /**
424
462
  * Creates a default furnace configuration.
425
463
  * @returns A valid empty FurnaceConfig
@@ -34,6 +34,29 @@ export const MACH_ERROR_HINTS = [
34
34
  'after a build that failed late. Re-run "fireforge build" to completion, confirm the app ' +
35
35
  'bundle exists under `obj-*/dist/`, and rerun "fireforge package".',
36
36
  },
37
+ {
38
+ // Upstream bindgen on some macOS libc++ SDK versions emits
39
+ // `pub type basic_string___self_view = root::std::__1::basic_string_view<_CharT>;`
40
+ // inside gecko-profiler's generated `bindings.rs`, but `_CharT` is
41
+ // not in scope where the alias lands — so the Rust compile fails
42
+ // with "cannot find type `_CharT`". The symptom is obscure and the
43
+ // fix is external: Hominis ships
44
+ // `990-infra-bindgen-basic-string-workaround.patch` in its patch
45
+ // queue, which strips the offending alias line post-generation.
46
+ // This hint surfaces the workaround pointer alongside the raw
47
+ // bindgen output so operators don't have to reverse-engineer the
48
+ // failure.
49
+ pattern: /cannot find type `_CharT` in this scope[\s\S]*?gecko-profiler-|gecko-profiler-[\s\S]*?cannot find type `_CharT` in this scope/,
50
+ hint: 'The Rust compile failed on a bindgen-generated `basic_string___self_view` alias in ' +
51
+ 'gecko-profiler/bindings.rs. This is an upstream bindgen output bug against some ' +
52
+ 'macOS libc++ SDK versions and needs a post-generation patch to strip the alias. ' +
53
+ 'The known-working workaround is the `990-infra-bindgen-basic-string-workaround.patch` ' +
54
+ "Hominis ships in its patch queue — import the equivalent into your fork's patches/, " +
55
+ 'then re-run "fireforge import" + "fireforge build". If you do not use Hominis\' queue, ' +
56
+ 'apply the following post-process to the generated file before the Rust compile: ' +
57
+ 'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
58
+ '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
59
+ },
37
60
  ];
38
61
  /**
39
62
  * Scans captured stderr for known mach errors and returns matching hints.
@@ -159,6 +159,17 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
159
159
  // Check for non-tokenized custom properties. A variable that is both
160
160
  // declared and consumed inside the same file is auto-exempted as a
161
161
  // runtime state channel (see furnace.json → runtimeVariables).
162
+ //
163
+ // When diff context is available, scope the `var(...)` scan to
164
+ // added/modified lines only. `cssContent` (full-file) is still the
165
+ // source of `localDeclarations` so vars declared anywhere in the file
166
+ // are recognised as same-file refs regardless of where the consuming
167
+ // `var(...)` appears. Before this scoping change, a small edit to a
168
+ // Furnace override of a stock component (e.g. moz-card) produced a
169
+ // `token-prefix-violation` for every stock `var(--moz-card-*)` the
170
+ // upstream file already carried, because the scanner saw the full
171
+ // applied file and flagged each inherited reference as if the fork
172
+ // had introduced it.
162
173
  if (tokenPrefix) {
163
174
  const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
164
175
  const localDeclarations = new Set();
@@ -168,26 +179,38 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
168
179
  if (name)
169
180
  localDeclarations.add(name);
170
181
  }
171
- const varPattern = /var\(\s*(--[\w-]+)/g;
172
- let match;
173
- while ((match = varPattern.exec(cssContent)) !== null) {
174
- const prop = match[1];
175
- if (!prop)
176
- continue;
177
- if (prop.startsWith(tokenPrefix))
178
- continue;
179
- if (tokenAllowlist?.has(prop))
180
- continue;
181
- if (runtimeVariables?.has(prop))
182
- continue;
183
- if (localDeclarations.has(prop))
184
- continue;
185
- issues.push({
186
- file,
187
- check: 'token-prefix-violation',
188
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
189
- severity: 'error',
190
- });
182
+ const prefixScanSource = addedLinesByFile
183
+ ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
184
+ : cssContent;
185
+ if (prefixScanSource.length > 0) {
186
+ const varPattern = /var\(\s*(--[\w-]+)/g;
187
+ const flaggedProps = new Set();
188
+ let match;
189
+ while ((match = varPattern.exec(prefixScanSource)) !== null) {
190
+ const prop = match[1];
191
+ if (!prop)
192
+ continue;
193
+ if (prop.startsWith(tokenPrefix))
194
+ continue;
195
+ if (tokenAllowlist?.has(prop))
196
+ continue;
197
+ if (runtimeVariables?.has(prop))
198
+ continue;
199
+ if (localDeclarations.has(prop))
200
+ continue;
201
+ // De-duplicate per (file, prop) pair so the same introduced var
202
+ // used five times in the added hunk doesn't produce five
203
+ // identical issue entries.
204
+ if (flaggedProps.has(prop))
205
+ continue;
206
+ flaggedProps.add(prop);
207
+ issues.push({
208
+ file,
209
+ check: 'token-prefix-violation',
210
+ message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
211
+ severity: 'error',
212
+ });
213
+ }
191
214
  }
192
215
  }
193
216
  }
@@ -23,6 +23,9 @@
23
23
  * plugin, etc.) can legitimately have a fresh `dist/` with no
24
24
  * FireForge-recorded baseline update.
25
25
  */
26
+ import { createHash } from 'node:crypto';
27
+ import { readFile } from 'node:fs/promises';
28
+ import { join } from 'node:path';
26
29
  import { toError } from '../utils/errors.js';
27
30
  import { verbose } from '../utils/logger.js';
28
31
  import { isPackageablePath } from './build-audit.js';
@@ -90,7 +93,32 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
90
93
  return { stale: false, changedPaths: [], truncated: 0, baseline: undefined };
91
94
  }
92
95
  const changed = await collectChangedEnginePaths(engineDir, baseline);
93
- const packageable = changed.filter((path) => isPackageablePath(path)).sort();
96
+ let packageable = changed.filter((path) => isPackageablePath(path)).sort();
97
+ // Content-hash comparison: when the baseline carries a fingerprint set,
98
+ // fold each candidate path through a live re-hash and drop paths whose
99
+ // current content matches the baseline. Pre-0.16.0 baselines have no
100
+ // `packageableFingerprints` field; those fall through and the
101
+ // path-only comparison behaves as before (every workdir-dirty
102
+ // packageable path is reported as stale). The concrete motivating
103
+ // case: a project with imported patches + Furnace-applied components
104
+ // always has a persistent workdir diff against HEAD. Before the
105
+ // fingerprint layer, `git diff --name-only HEAD` returned that diff
106
+ // on every build, so the stale check fired immediately after a
107
+ // successful build even though nothing had actually changed. The
108
+ // fingerprints capture "these files had this content when the build
109
+ // ran"; a path stays stale only when its live hash diverges.
110
+ const fingerprints = baseline.packageableFingerprints;
111
+ if (fingerprints) {
112
+ const staleAfterHashCheck = [];
113
+ for (const path of packageable) {
114
+ const recorded = fingerprints[path];
115
+ const live = await hashEngineFile(engineDir, path);
116
+ if (recorded === undefined || live === undefined || recorded !== live) {
117
+ staleAfterHashCheck.push(path);
118
+ }
119
+ }
120
+ packageable = staleAfterHashCheck;
121
+ }
94
122
  if (packageable.length === 0) {
95
123
  return { stale: false, changedPaths: [], truncated: 0, baseline };
96
124
  }
@@ -98,6 +126,23 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
98
126
  const truncated = Math.max(0, packageable.length - head.length);
99
127
  return { stale: true, changedPaths: head, truncated, baseline };
100
128
  }
129
+ /**
130
+ * Reads a file under the engine directory and returns a hex-encoded
131
+ * SHA-256 of its contents, matching the hash the baseline writer
132
+ * produces. Returns `undefined` on any IO error (missing file,
133
+ * permission denied, etc.) so the caller can treat the path as still
134
+ * stale rather than crashing the preflight.
135
+ */
136
+ async function hashEngineFile(engineDir, relPath) {
137
+ try {
138
+ const buffer = await readFile(join(engineDir, relPath));
139
+ return createHash('sha256').update(buffer).digest('hex');
140
+ }
141
+ catch (error) {
142
+ verbose(`Stale-build preflight: could not hash ${relPath} for baseline comparison — ${toError(error).message}`);
143
+ return undefined;
144
+ }
145
+ }
101
146
  /**
102
147
  * Formats a human-readable warning body from a {@link StaleBuildResult}.
103
148
  * Kept separate from the probe so test code can assert on the structured
@@ -93,8 +93,56 @@ async function assertTokenCategoryExists(engineDir, tokensCssPath, category) {
93
93
  }
94
94
  }
95
95
  }
96
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}. ` +
97
- 'Available categories are defined by /* =... category ...= */ comment headers.');
96
+ const discoveredCategories = discoverCategoryHeaders(lines);
97
+ const available = discoveredCategories.length > 0
98
+ ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
99
+ : 'The file currently has no category headers. Add one by hand near the top of the :root { … } block — the format is "/* = My Category = */" — or run "fireforge furnace init --force" to re-scaffold the default seed set.';
100
+ throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
101
+ `${available}\n\n` +
102
+ 'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
103
+ 'Multi-line shape: /* =============\\n * My Category\\n * ============= */.');
104
+ }
105
+ /**
106
+ * Scans a tokens CSS file for category header comments and returns the
107
+ * category names in document order. Used to enrich the "category not
108
+ * found" error body with concrete alternatives the operator can copy.
109
+ *
110
+ * Mirrors the shapes `findCategorySection` already recognises:
111
+ * - Single-line: `/* = Foo = *\/`
112
+ * - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
113
+ * closing `*\/`.
114
+ *
115
+ * This helper exists as a pure inspector; it never throws on malformed
116
+ * headers and silently skips shapes it cannot parse.
117
+ */
118
+ function discoverCategoryHeaders(lines) {
119
+ const categories = new Set();
120
+ const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i] ?? '';
123
+ const singleMatch = singleLinePattern.exec(line);
124
+ if (singleMatch?.[1]) {
125
+ const extracted = singleMatch[1].trim();
126
+ if (extracted.length > 0)
127
+ categories.add(extracted);
128
+ continue;
129
+ }
130
+ if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
131
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
132
+ const blockLine = lines[j] ?? '';
133
+ if (/\*\//.test(blockLine))
134
+ break;
135
+ const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
136
+ if (trimmed.length === 0)
137
+ continue;
138
+ if (/^=+$/.test(trimmed))
139
+ continue;
140
+ categories.add(trimmed);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ return [...categories];
98
146
  }
99
147
  /**
100
148
  * Validates token-add inputs without mutating files.
@@ -187,8 +235,13 @@ function findCategorySection(lines, category, tokensCssPath) {
187
235
  }
188
236
  }
189
237
  if (categoryLine === -1) {
190
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}. ` +
191
- 'Available categories are defined by /* =... category ...= */ comment headers.');
238
+ const discoveredCategories = discoverCategoryHeaders(lines);
239
+ const available = discoveredCategories.length > 0
240
+ ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
241
+ : 'The file currently has no category headers.';
242
+ throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
243
+ `${available}\n\n` +
244
+ 'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
192
245
  }
193
246
  // Find the end of this category section (next section header or closing })
194
247
  // Handles both single-line (/* =...= */) and multi-line (/* ===...) section delimiters