@hominis/fireforge 0.16.0 → 0.16.2

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 (36) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +4 -2
  3. package/dist/src/commands/config.js +16 -5
  4. package/dist/src/commands/download.js +22 -4
  5. package/dist/src/commands/export-all.js +50 -9
  6. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  7. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  8. package/dist/src/commands/furnace/create.js +21 -3
  9. package/dist/src/commands/furnace/index.js +1 -0
  10. package/dist/src/commands/furnace/init.js +76 -2
  11. package/dist/src/commands/furnace/preview.js +15 -2
  12. package/dist/src/commands/lint.js +16 -1
  13. package/dist/src/commands/rebase/patch-loop.js +19 -0
  14. package/dist/src/commands/status.js +17 -5
  15. package/dist/src/commands/wire.js +47 -8
  16. package/dist/src/core/build-baseline.d.ts +14 -0
  17. package/dist/src/core/build-baseline.js +61 -1
  18. package/dist/src/core/config-mutate.d.ts +1 -1
  19. package/dist/src/core/config-mutate.js +23 -1
  20. package/dist/src/core/config.d.ts +17 -0
  21. package/dist/src/core/config.js +35 -0
  22. package/dist/src/core/firefox.d.ts +16 -2
  23. package/dist/src/core/firefox.js +7 -2
  24. package/dist/src/core/furnace-config.d.ts +23 -0
  25. package/dist/src/core/furnace-config.js +38 -0
  26. package/dist/src/core/mach-error-hints.js +23 -0
  27. package/dist/src/core/patch-lint.js +43 -20
  28. package/dist/src/core/patch-parse.d.ts +18 -7
  29. package/dist/src/core/patch-parse.js +24 -2
  30. package/dist/src/core/patch-transform.js +4 -1
  31. package/dist/src/core/test-stale-check.js +46 -1
  32. package/dist/src/core/token-manager.js +57 -4
  33. package/dist/src/core/token-scaffold.d.ts +36 -0
  34. package/dist/src/core/token-scaffold.js +74 -0
  35. package/dist/src/types/commands/options.d.ts +10 -0
  36. package/package.json +1 -1
@@ -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,64 @@ 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
+ // `pathExists` resolves relative paths against CWD, so an engine-
139
+ // relative `domCandidate` (e.g. `browser/base/content/foo.inc.xhtml`)
140
+ // would be probed inside the operator's shell directory rather than
141
+ // the engine root and fail "DOM fragment file not found" even when
142
+ // the file is sitting at engine/<path>. Mirror `register.ts`: probe
143
+ // the absolute path as-is, otherwise join with `paths.engine` first.
144
+ // The `isPathInsideRoot` / `toRootRelativePath` calls below keep
145
+ // operating on `domCandidate` because they internally resolve
146
+ // relative candidates against the engine root, which matches the
147
+ // probe path we just built.
148
+ const domProbePath = isExplicitAbsolutePath(domCandidate)
149
+ ? domCandidate
150
+ : join(paths.engine, domCandidate);
151
+ if (!(await pathExists(domProbePath))) {
121
152
  throw new InvalidArgumentError(`DOM fragment file not found: ${options.dom}`, 'dom');
122
153
  }
123
- if (!isPathInsideRoot(paths.engine, options.dom)) {
154
+ if (!isPathInsideRoot(paths.engine, domCandidate)) {
124
155
  throw new InvalidArgumentError(`DOM fragment file must stay within engine/: ${options.dom}`, 'dom');
125
156
  }
126
- domFilePath = toRootRelativePath(paths.engine, options.dom);
157
+ domFilePath = toRootRelativePath(paths.engine, domCandidate);
127
158
  }
128
159
  // Resolve the chrome document the `#include` directive will land in.
129
160
  // Only consulted when `--dom` is supplied — we still resolve it here so
130
161
  // 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');
162
+ //
163
+ // `stripEnginePrefix` is applied so `--target engine/browser/base/browser.xhtml`
164
+ // and `--target browser/base/browser.xhtml` are treated identically,
165
+ // matching the `--dom` normalization above. Absolute `--target` paths
166
+ // stay absolute (the containment check downstream rejects them).
167
+ const normalizedTarget = options.target !== undefined && !isExplicitAbsolutePath(options.target)
168
+ ? stripEnginePrefix(options.target)
169
+ : options.target;
170
+ if (normalizedTarget !== undefined && !isContainedRelativePath(normalizedTarget)) {
171
+ throw new InvalidArgumentError(`Target chrome document must stay within engine/: ${options.target ?? ''}`, 'target');
133
172
  }
134
- const domTargetPath = await resolveDomTargetPath(projectRoot, options.target);
173
+ const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
135
174
  if (domFilePath) {
136
175
  const paths = getProjectPaths(projectRoot);
137
176
  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>;
@@ -14,6 +14,23 @@ function cloneConfigDocument(config) {
14
14
  }
15
15
  return cloned;
16
16
  }
17
+ /**
18
+ * Key segments that would walk into or rewrite the object prototype chain
19
+ * if used as plain property names. Blocked up-front so the descent in
20
+ * {@link mutateConfig} cannot be weaponized to mutate `Object.prototype`
21
+ * process-wide — e.g. `fireforge config __proto__.polluted 1 --force`
22
+ * would otherwise land in `getOrCreateChildRecord(raw, "__proto__")`.
23
+ */
24
+ const SENTINEL_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
25
+ function assertNoSentinelSegments(key, parts) {
26
+ for (const part of parts) {
27
+ if (SENTINEL_KEY_SEGMENTS.has(part)) {
28
+ throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
29
+ 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
30
+ 'because they would mutate the object prototype chain.');
31
+ }
32
+ }
33
+ }
17
34
  function getOrCreateChildRecord(parent, key) {
18
35
  const existing = parent[key];
19
36
  if (isObject(existing)) {
@@ -24,8 +41,13 @@ function getOrCreateChildRecord(parent, key) {
24
41
  return child;
25
42
  }
26
43
  export function mutateConfig(config, key, value, skipValidation = false) {
27
- const raw = cloneConfigDocument(config);
28
44
  const parts = key.split('.');
45
+ // Reject prototype-chain sentinel segments before any write so
46
+ // `--force` cannot be used to mutate Object.prototype. This guard must
47
+ // run against the original key parts, not any subset — the final leaf
48
+ // assignment `current[lastPart] = value` would otherwise stay vulnerable.
49
+ assertNoSentinelSegments(key, parts);
50
+ const raw = cloneConfigDocument(config);
29
51
  let current = raw;
30
52
  for (let i = 0; i < parts.length - 1; i++) {
31
53
  const part = parts[i];
@@ -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
  }
@@ -24,19 +24,30 @@ export declare function isNewFileInPatch(patchContent: string, targetFile: strin
24
24
  */
25
25
  export declare function extractAffectedFiles(diffContent: string): string[];
26
26
  /**
27
- * Parses hunks from a patch file for a specific target file.
28
- * @param patchContent - The full patch content
29
- * @param targetFile - The file path to extract hunks for
30
- * @returns Array of hunk objects with line info and changes
27
+ * A single parsed hunk. `noNewlineAtEndOld` / `noNewlineAtEndNew` track the
28
+ * `` marker per side the marker is a trailing
29
+ * annotation on the immediately preceding body line, and a `-` precedent
30
+ * sets only the old-side flag, a `+` sets only the new-side flag, and a
31
+ * context ` ` line sets both. Collapsing the two into one boolean makes the
32
+ * projection disagree with `git apply` on asymmetric trailing-newline
33
+ * changes (e.g. removing a newline on one side but not the other).
31
34
  */
32
- export declare function parseHunksForFile(patchContent: string, targetFile: string): Array<{
35
+ export interface ParsedHunk {
33
36
  oldStart: number;
34
37
  oldCount: number;
35
38
  newStart: number;
36
39
  newCount: number;
37
40
  lines: string[];
38
- noNewlineAtEnd: boolean;
39
- }>;
41
+ noNewlineAtEndOld: boolean;
42
+ noNewlineAtEndNew: boolean;
43
+ }
44
+ /**
45
+ * Parses hunks from a patch file for a specific target file.
46
+ * @param patchContent - The full patch content
47
+ * @param targetFile - The file path to extract hunks for
48
+ * @returns Array of hunk objects with line info and changes
49
+ */
50
+ export declare function parseHunksForFile(patchContent: string, targetFile: string): ParsedHunk[];
40
51
  /**
41
52
  * Extracts conflicting file paths from git apply error message.
42
53
  */
@@ -104,14 +104,36 @@ export function parseHunksForFile(patchContent, targetFile) {
104
104
  newStart: parseInt(hunkMatch[3] ?? '0', 10),
105
105
  newCount: parseInt(hunkMatch[4] ?? '1', 10),
106
106
  lines: [],
107
- noNewlineAtEnd: false,
107
+ noNewlineAtEndOld: false,
108
+ noNewlineAtEndNew: false,
108
109
  };
109
110
  continue;
110
111
  }
111
112
  // Collect hunk lines
112
113
  if (currentHunk) {
113
114
  if (line === '\') {
114
- currentHunk.noNewlineAtEnd = true;
115
+ // The marker is an annotation on the immediately preceding body
116
+ // line. Peek the last collected line to decide which side(s) the
117
+ // annotation applies to — a single boolean cannot represent the
118
+ // asymmetric case where only one side lacks the trailing newline.
119
+ const previous = currentHunk.lines[currentHunk.lines.length - 1] ?? '';
120
+ if (previous.startsWith('-')) {
121
+ currentHunk.noNewlineAtEndOld = true;
122
+ }
123
+ else if (previous.startsWith('+')) {
124
+ currentHunk.noNewlineAtEndNew = true;
125
+ }
126
+ else if (previous.startsWith(' ')) {
127
+ // Context line: present in both sides, so the trailing-newline
128
+ // absence applies to both. This is rare (it only happens when
129
+ // the hunk ends on an unchanged line that itself is the last
130
+ // line of the file) but real — git emits it.
131
+ currentHunk.noNewlineAtEndOld = true;
132
+ currentHunk.noNewlineAtEndNew = true;
133
+ }
134
+ // If the marker appears with no preceding body line (malformed
135
+ // diff), leave both flags false — the downstream apply logic
136
+ // will still produce a defined result.
115
137
  }
116
138
  else if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
117
139
  currentHunk.lines.push(line);
@@ -105,7 +105,10 @@ export async function applyPatchToContent(content, patchPath, targetFile) {
105
105
  const sortedHunks = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
106
106
  // The "no newline at end" marker applies to the last hunk in file order
107
107
  // (highest oldStart), which is the *first* hunk in our reverse-sorted array.
108
- const lastHunkNoNewline = sortedHunks[0]?.noNewlineAtEnd ?? false;
108
+ // We read the new-side flag because the output we produce corresponds to
109
+ // the new side; asymmetric diffs (old lacks newline, new has one — or
110
+ // vice versa) would otherwise disagree with `git apply`.
111
+ const lastHunkNoNewline = sortedHunks[0]?.noNewlineAtEndNew ?? false;
109
112
  for (const hunk of sortedHunks) {
110
113
  const newLines = [];
111
114
  // Compute actual old-line count from hunk body for cross-check