@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.
- package/CHANGELOG.md +76 -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 +47 -8
- 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-mutate.js +23 -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/patch-parse.d.ts +18 -7
- package/dist/src/core/patch-parse.js +24 -2
- package/dist/src/core/patch-transform.js +4 -1
- 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,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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
132
|
-
|
|
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,
|
|
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
|
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
|
}
|
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|