@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.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -2
- package/dist/src/commands/config.js +16 -5
- package/dist/src/commands/download.js +22 -4
- package/dist/src/commands/export-all.js +50 -9
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
- package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
- package/dist/src/commands/furnace/create.js +21 -3
- package/dist/src/commands/furnace/index.js +1 -0
- package/dist/src/commands/furnace/init.js +76 -2
- package/dist/src/commands/furnace/preview.js +15 -2
- package/dist/src/commands/lint.js +16 -1
- package/dist/src/commands/rebase/patch-loop.js +19 -0
- package/dist/src/commands/status.js +17 -5
- package/dist/src/commands/wire.js +35 -9
- package/dist/src/core/build-baseline.d.ts +14 -0
- package/dist/src/core/build-baseline.js +61 -1
- package/dist/src/core/config-mutate.d.ts +1 -1
- package/dist/src/core/config.d.ts +17 -0
- package/dist/src/core/config.js +35 -0
- package/dist/src/core/firefox.d.ts +16 -2
- package/dist/src/core/firefox.js +7 -2
- package/dist/src/core/furnace-config.d.ts +23 -0
- package/dist/src/core/furnace-config.js +38 -0
- package/dist/src/core/mach-error-hints.js +23 -0
- package/dist/src/core/patch-lint.js +43 -20
- package/dist/src/core/test-stale-check.js +46 -1
- package/dist/src/core/token-manager.js +57 -4
- package/dist/src/core/token-scaffold.d.ts +36 -0
- package/dist/src/core/token-scaffold.js +74 -0
- package/dist/src/types/commands/options.d.ts +10 -0
- 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,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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
package/dist/src/core/config.js
CHANGED
|
@@ -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>;
|
package/dist/src/core/firefox.js
CHANGED
|
@@ -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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|