@hominis/fireforge 0.44.0 → 0.44.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 +65 -2
- package/README.md +20 -121
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/doctor-check-core.d.ts +9 -0
- package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
- package/dist/src/commands/doctor-patch-manifest.js +155 -0
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +48 -62
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/diff.js +6 -1
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/remove-mutations.js +5 -1
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/import.js +6 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +33 -11
- package/dist/src/core/build-audit-resolve.js +12 -3
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/config-mutate.js +20 -19
- package/dist/src/core/furnace-apply-ftl.js +4 -1
- package/dist/src/core/furnace-apply-helpers.js +11 -5
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/git-base.d.ts +14 -0
- package/dist/src/core/git-base.js +17 -0
- package/dist/src/core/git.js +3 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/patch-lint-checkjs.js +25 -4
- package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
- package/dist/src/core/patch-manifest-consistency.js +96 -23
- package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
- package/dist/src/core/patch-manifest-files-affected.js +77 -0
- package/dist/src/core/patch-manifest.d.ts +2 -1
- package/dist/src/core/patch-manifest.js +2 -1
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-category.js +61 -7
- package/dist/src/core/token-dark-mode.js +20 -2
- package/dist/src/core/token-docs.js +1 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +34 -0
- package/dist/src/core/token-variant.js +98 -8
- package/dist/src/core/tree-store.js +5 -1
- package/dist/src/core/typecheck.js +7 -1
- package/dist/src/types/commands/options.d.ts +53 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/package.json +2 -2
|
@@ -40,6 +40,11 @@ export interface TestRunContext {
|
|
|
40
40
|
headless: boolean;
|
|
41
41
|
/** Extra environment variables for the mach process. */
|
|
42
42
|
env?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Pass full mozbuild verbosity through for the TEST phase, by unsetting
|
|
45
|
+
* the coding-agent markers that quiet it. Off by default.
|
|
46
|
+
*/
|
|
47
|
+
fullOutput?: boolean;
|
|
43
48
|
}
|
|
44
49
|
/** Outcome of one (possibly retried) harness invocation. */
|
|
45
50
|
export interface TestRunOutcome {
|
|
@@ -60,10 +60,17 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
|
|
|
60
60
|
let verdict;
|
|
61
61
|
for (;;) {
|
|
62
62
|
attempts += 1;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Arity is kept minimal when there is nothing extra to pass: the
|
|
64
|
+
// dispatchers' trailing parameters are optional, and appending explicit
|
|
65
|
+
// `undefined`s would only make every call site's shape depend on
|
|
66
|
+
// features the run is not using.
|
|
67
|
+
result =
|
|
68
|
+
ctx.fullOutput === true
|
|
69
|
+
? await dispatch(ctx.engineDir, paths, extraArgs, env, true)
|
|
70
|
+
: env
|
|
71
|
+
? await dispatch(ctx.engineDir, paths, extraArgs, env)
|
|
72
|
+
: await dispatch(ctx.engineDir, paths, extraArgs);
|
|
73
|
+
result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch, ctx.fullOutput);
|
|
67
74
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
68
75
|
verdict = classifyHarnessRun(result.exitCode, combined, paths);
|
|
69
76
|
if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { getActiveRunLogPath } from '../core/run-log.js';
|
|
1
3
|
import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
|
|
2
4
|
import { setStdoutSealed } from '../utils/logger.js';
|
|
3
5
|
let emitted = false;
|
|
@@ -38,7 +40,14 @@ function writeVerdictLine(line) {
|
|
|
38
40
|
if (emitted)
|
|
39
41
|
return;
|
|
40
42
|
emitted = true;
|
|
41
|
-
|
|
43
|
+
// The run log's path rides the verdict line because the verdict must stay
|
|
44
|
+
// the run's LAST stdout write — a separate announcement after it would
|
|
45
|
+
// break that contract, and one before it is the first thing a `tail` cuts.
|
|
46
|
+
// Appended as an additive `key=value`, so consumers that tokenize the line
|
|
47
|
+
// are unaffected and a truncated tail still says where the full output is.
|
|
48
|
+
const logPath = getActiveRunLogPath();
|
|
49
|
+
const suffix = logPath === undefined ? '' : ` log=${logPath}`;
|
|
50
|
+
process.stdout.write(`${line}${suffix}\n`);
|
|
42
51
|
setStdoutSealed(true);
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
@@ -8,6 +8,7 @@ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
|
|
|
8
8
|
import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
|
|
9
9
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
10
10
|
import { ensureMochitestServerPortAvailable } from '../core/mochitest-server-port.js';
|
|
11
|
+
import { closeActiveRunLog, openRunLog, setActiveRunLog } from '../core/run-log.js';
|
|
11
12
|
import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
|
|
12
13
|
import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
|
|
13
14
|
import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
|
|
@@ -276,6 +277,10 @@ const TEST_BUILD_PREFLIGHT = {
|
|
|
276
277
|
export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
277
278
|
intro('FireForge Test');
|
|
278
279
|
resetVerdictEmission();
|
|
280
|
+
// Opened before ANY work so a preflight refusal is logged too — those are
|
|
281
|
+
// exactly the runs whose only output a `tail` throws away. Closed in
|
|
282
|
+
// `finally`, after the verdict line has already read the path.
|
|
283
|
+
setActiveRunLog(await openRunLog(projectRoot, 'test'));
|
|
279
284
|
try {
|
|
280
285
|
await runTestCommandBody(projectRoot, testPaths, options);
|
|
281
286
|
}
|
|
@@ -284,6 +289,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
284
289
|
emitFailVerdict('preflight');
|
|
285
290
|
throw error;
|
|
286
291
|
}
|
|
292
|
+
finally {
|
|
293
|
+
await closeActiveRunLog();
|
|
294
|
+
}
|
|
287
295
|
}
|
|
288
296
|
/**
|
|
289
297
|
* Verifies `engine/` did not change under the run before any PASS verdict
|
|
@@ -422,6 +430,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
422
430
|
baseExtraArgs: extraArgs,
|
|
423
431
|
harnessRetries,
|
|
424
432
|
headless: options.headless === true,
|
|
433
|
+
...(options.fullOutput === true ? { fullOutput: true } : {}),
|
|
425
434
|
...(perfSampleEnv ? { env: perfSampleEnv } : {}),
|
|
426
435
|
};
|
|
427
436
|
const postRebuildContext = options.build
|
|
@@ -35,6 +35,27 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
|
|
|
35
35
|
}
|
|
36
36
|
return normalizeTokenName(rawTokenName);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Reports a no-op add, naming WHERE the existing declaration lives.
|
|
40
|
+
*
|
|
41
|
+
* A variant skip has no category to name — the declaration lives in a
|
|
42
|
+
* `:root<selector>` block — so the location reads as that block. The
|
|
43
|
+
* variant path used to report no location at all, so a re-run meant to
|
|
44
|
+
* CHANGE a value exited 0 having silently changed nothing.
|
|
45
|
+
*
|
|
46
|
+
* @param tokenName - Full token name including prefix
|
|
47
|
+
* @param result - Result carrying the skip location, when known
|
|
48
|
+
* @param options - The invocation's options
|
|
49
|
+
*/
|
|
50
|
+
function reportSkippedToken(tokenName, result, options) {
|
|
51
|
+
const scope = options.variant !== undefined
|
|
52
|
+
? `:root${options.variant}`
|
|
53
|
+
: `category "${result.skippedExisting?.category ?? options.category ?? ''}"`;
|
|
54
|
+
const where = result.skippedExisting
|
|
55
|
+
? ` in ${scope} (line ${String(result.skippedExisting.line)}), unchanged`
|
|
56
|
+
: '';
|
|
57
|
+
info(`Token ${tokenName} already exists${where} (skipped)`);
|
|
58
|
+
}
|
|
38
59
|
/**
|
|
39
60
|
* Adds a design token to the CSS file and documentation.
|
|
40
61
|
*
|
|
@@ -68,8 +89,8 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
68
89
|
await validateTokenAdd(projectRoot, {
|
|
69
90
|
tokenName,
|
|
70
91
|
value,
|
|
71
|
-
category: options.category,
|
|
72
92
|
mode: options.mode,
|
|
93
|
+
...(options.category !== undefined ? { category: options.category } : {}),
|
|
73
94
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
74
95
|
...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
|
|
75
96
|
...(options.createCategory === true ? { createCategory: true } : {}),
|
|
@@ -83,7 +104,7 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
83
104
|
info(` Variant: :root${options.variant}`);
|
|
84
105
|
}
|
|
85
106
|
else {
|
|
86
|
-
info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
|
|
107
|
+
info(` Category: ${options.category ?? '(none)'}${options.createCategory === true ? ' (created if missing)' : ''}`);
|
|
87
108
|
}
|
|
88
109
|
info(` Mode: ${options.mode}`);
|
|
89
110
|
if (options.description)
|
|
@@ -96,24 +117,21 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
96
117
|
const result = await addToken(projectRoot, {
|
|
97
118
|
tokenName,
|
|
98
119
|
value,
|
|
99
|
-
category: options.category,
|
|
100
120
|
mode: options.mode,
|
|
121
|
+
...(options.category !== undefined ? { category: options.category } : {}),
|
|
101
122
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
102
123
|
...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
|
|
103
124
|
...(options.createCategory === true ? { createCategory: true } : {}),
|
|
104
125
|
...(options.variant !== undefined ? { variant: options.variant } : {}),
|
|
105
126
|
});
|
|
106
127
|
if (result.skipped) {
|
|
107
|
-
|
|
108
|
-
? ` in category "${result.skippedExisting.category ?? options.category}" (line ${String(result.skippedExisting.line)})`
|
|
109
|
-
: '';
|
|
110
|
-
info(`Token ${tokenName} already exists${where} (skipped)`);
|
|
128
|
+
reportSkippedToken(tokenName, result, options);
|
|
111
129
|
}
|
|
112
130
|
else {
|
|
113
131
|
const forgeConfig = await loadConfig(projectRoot);
|
|
114
132
|
const tokensCssFile = getTokensCssPath(forgeConfig.binaryName).split('/').pop();
|
|
115
133
|
if (result.categoryCreated)
|
|
116
|
-
success(`Created category "${options.category}"`);
|
|
134
|
+
success(`Created category "${options.category ?? ''}"`);
|
|
117
135
|
if (result.cssAdded)
|
|
118
136
|
success(`Added ${tokenName} to ${tokensCssFile}`);
|
|
119
137
|
if (result.docsAdded)
|
|
@@ -141,7 +159,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
141
159
|
token
|
|
142
160
|
.command('add <token-name> <value>')
|
|
143
161
|
.description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
|
|
144
|
-
.
|
|
162
|
+
.option('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing"). Required for a base declaration; ' +
|
|
163
|
+
'not required with --variant, which routes into a :root<selector> block where no ' +
|
|
164
|
+
'category applies')
|
|
145
165
|
.addOption(
|
|
146
166
|
// Use Commander's .choices() so invalid --mode values are rejected with
|
|
147
167
|
// the built-in "argument must be one of …" message and --help lists the
|
|
@@ -161,14 +181,16 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
161
181
|
.option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
|
|
162
182
|
.option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
|
|
163
183
|
"(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
|
|
164
|
-
'CSS-only — cannot be combined with --mode override.'
|
|
184
|
+
'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
|
|
185
|
+
'a new category and its variants, add the BASE token first (with --create-category), ' +
|
|
186
|
+
'then add each variant with --variant.')
|
|
165
187
|
.option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
|
|
166
188
|
.option('--dry-run', 'Show what would be changed without writing')
|
|
167
189
|
.action(withErrorHandling(async (tokenName, value, options) => {
|
|
168
190
|
await tokenAddCommand(getProjectRoot(), tokenName, value, {
|
|
169
|
-
category: options.category,
|
|
170
191
|
mode: options.mode,
|
|
171
192
|
...pickDefined({
|
|
193
|
+
category: options.category,
|
|
172
194
|
description: options.description,
|
|
173
195
|
darkValue: options.darkValue,
|
|
174
196
|
dryRun: options.dryRun,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readdir } from 'node:fs/promises';
|
|
21
21
|
import { basename, join } from 'node:path';
|
|
22
22
|
import { pathExists } from '../utils/fs.js';
|
|
23
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
23
24
|
/** Maximum directory depth to traverse when scanning a tree root. */
|
|
24
25
|
const MAX_SCAN_DEPTH = 12;
|
|
25
26
|
/**
|
|
@@ -54,11 +55,19 @@ export function isTestPath(sourcePath) {
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
57
|
-
* Splits a
|
|
58
|
-
*
|
|
58
|
+
* Splits a path into segments, dropping empties.
|
|
59
|
+
*
|
|
60
|
+
* Separators are normalized first because the candidates come from `join()`
|
|
61
|
+
* over a `readdir` walk of the dist tree: on Windows those carry backslashes,
|
|
62
|
+
* which a `/`-only split collapses into a SINGLE segment. Every candidate then
|
|
63
|
+
* scores the same, so the trailing-overlap heuristic that disambiguates
|
|
64
|
+
* same-basename artifacts (`branding/…/aboutDialog.css` versus
|
|
65
|
+
* `browser/…/aboutDialog.css`) silently picks an arbitrary one.
|
|
66
|
+
*
|
|
67
|
+
* @param path Path in either separator style
|
|
59
68
|
*/
|
|
60
69
|
function pathSegments(path) {
|
|
61
|
-
return path.split('/').filter(Boolean);
|
|
70
|
+
return normalizePathSlashes(path).split('/').filter(Boolean);
|
|
62
71
|
}
|
|
63
72
|
/**
|
|
64
73
|
* Counts how many trailing segments two paths share. Used to score
|
|
@@ -37,6 +37,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
37
37
|
import { basename, join } from 'node:path';
|
|
38
38
|
import { pathExists } from '../utils/fs.js';
|
|
39
39
|
import { info, verbose, warn } from '../utils/logger.js';
|
|
40
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
40
41
|
import { detectPlatformGate } from './build-audit-platform.js';
|
|
41
42
|
import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
|
|
42
43
|
import { countTrailingSegmentMatches, GENERIC_PATH_SEGMENTS, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
|
|
@@ -229,8 +230,11 @@ function isConfidentMatch(source, candidate) {
|
|
|
229
230
|
if (countTrailingSegmentMatches(source, candidate) >= MIN_TRAILING_SEGMENT_OVERLAP) {
|
|
230
231
|
return true;
|
|
231
232
|
}
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
// The candidate is an absolute path built by `join` over a dist/ walk, so on
|
|
234
|
+
// Windows it arrives backslash-separated; a `/`-only split collapses it into
|
|
235
|
+
// one segment and the non-generic-segment bonus can never fire.
|
|
236
|
+
const sourceSegs = normalizePathSlashes(source).split('/').filter(Boolean);
|
|
237
|
+
const candSegs = normalizePathSlashes(candidate).split('/').filter(Boolean);
|
|
234
238
|
const generic = GENERIC_PATH_SEGMENTS;
|
|
235
239
|
// Skip the basename itself (which trail-matches by definition).
|
|
236
240
|
for (let i = 0; i < sourceSegs.length - 1; i += 1) {
|
|
@@ -18,21 +18,16 @@ function cloneConfigDocument(config) {
|
|
|
18
18
|
return cloned;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* The error every sentinel-segment refusal raises. Only the MESSAGE lives
|
|
22
|
+
* here: the comparison itself is spelled inline at each write site, because
|
|
23
|
+
* that is what both a reader and CodeQL's `js/prototype-pollution-utility`
|
|
24
|
+
* barrier detection need — the latter is not interprocedural, so a check
|
|
25
|
+
* hidden behind a helper leaves the descent below looking unguarded.
|
|
26
26
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
|
|
32
|
-
'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
|
|
33
|
-
'because they would mutate the object prototype chain.');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
27
|
+
function sentinelSegmentError(key, part) {
|
|
28
|
+
return 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.');
|
|
36
31
|
}
|
|
37
32
|
function getOrCreateChildRecord(parent, key) {
|
|
38
33
|
const existing = parent[key];
|
|
@@ -45,21 +40,27 @@ function getOrCreateChildRecord(parent, key) {
|
|
|
45
40
|
}
|
|
46
41
|
export function mutateConfig(config, key, value, skipValidation = false) {
|
|
47
42
|
const parts = key.split('.');
|
|
48
|
-
// Reject prototype-chain sentinel segments before any write so
|
|
49
|
-
// `--force` cannot be used to mutate Object.prototype. This guard must
|
|
50
|
-
// run against the original key parts, not any subset — the final leaf
|
|
51
|
-
// assignment `current[lastPart] = value` would otherwise stay vulnerable.
|
|
52
|
-
assertNoSentinelSegments(key, parts);
|
|
53
43
|
const raw = cloneConfigDocument(config);
|
|
44
|
+
// Every segment is checked at the point it is USED as a property name —
|
|
45
|
+
// the descent below and the leaf assignment — rather than once up front,
|
|
46
|
+
// so `--force` cannot be used to mutate Object.prototype and neither write
|
|
47
|
+
// can drift out from behind the guard. `raw` is a private clone, so a
|
|
48
|
+
// refusal here has mutated nothing the caller can observe.
|
|
54
49
|
let current = raw;
|
|
55
50
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
56
51
|
const part = parts[i];
|
|
57
52
|
if (part === undefined)
|
|
58
53
|
continue;
|
|
54
|
+
if (part === '__proto__' || part === 'constructor' || part === 'prototype') {
|
|
55
|
+
throw sentinelSegmentError(key, part);
|
|
56
|
+
}
|
|
59
57
|
current = getOrCreateChildRecord(current, part);
|
|
60
58
|
}
|
|
61
59
|
const lastPart = parts[parts.length - 1];
|
|
62
60
|
if (lastPart !== undefined) {
|
|
61
|
+
if (lastPart === '__proto__' || lastPart === 'constructor' || lastPart === 'prototype') {
|
|
62
|
+
throw sentinelSegmentError(key, lastPart);
|
|
63
|
+
}
|
|
63
64
|
current[lastPart] = value;
|
|
64
65
|
}
|
|
65
66
|
if (!skipValidation) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { toError } from '../utils/errors.js';
|
|
16
16
|
import { copyFile, pathExists, readText } from '../utils/fs.js';
|
|
17
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
17
18
|
import { escapeRegex } from '../utils/regex.js';
|
|
18
19
|
import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
|
|
19
20
|
import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
|
|
@@ -133,7 +134,9 @@ export async function applyCustomFtlFile(ctx, affectedPaths, stepErrors, rollbac
|
|
|
133
134
|
await snapshotFile(rollbackJournal, ftlDest);
|
|
134
135
|
}
|
|
135
136
|
await copyFile(ftlSrc, ftlDest);
|
|
136
|
-
|
|
137
|
+
// Sibling pushes on this list are POSIX literals (`localeJarRel`); keep
|
|
138
|
+
// the computed one in the same spelling.
|
|
139
|
+
affectedPaths.push(normalizePathSlashes(relative(engineDir, ftlDest)));
|
|
137
140
|
const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
|
|
138
141
|
const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
|
|
139
142
|
if (chromeSubPath === undefined || localeJarRel === undefined)
|
|
@@ -6,6 +6,7 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
6
6
|
import { toError } from '../utils/errors.js';
|
|
7
7
|
import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
|
|
8
8
|
import { verbose } from '../utils/logger.js';
|
|
9
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
9
10
|
import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
|
|
10
11
|
import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
|
|
11
12
|
import { normalizeForChecksum } from './furnace-checksum-utils.js';
|
|
@@ -70,7 +71,12 @@ export function getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir)
|
|
|
70
71
|
* caller should treat that as a successful no-op rather than an error.
|
|
71
72
|
*/
|
|
72
73
|
export async function restoreOverrideFileToBaseline(engineDir, enginePath, journal) {
|
|
73
|
-
|
|
74
|
+
// Engine-relative paths are IDENTIFIERS here, not filesystem arguments:
|
|
75
|
+
// they are handed to git, recorded in furnace state, and compared against
|
|
76
|
+
// the POSIX paths furnace.json and jar.mn already carry. `relative()`
|
|
77
|
+
// returns backslashes on Windows, so without this the same file has two
|
|
78
|
+
// spellings and every cross-check misses.
|
|
79
|
+
const relPath = normalizePathSlashes(relative(engineDir, enginePath));
|
|
74
80
|
// Snapshot before mutation so a later rollback can undo both restoration
|
|
75
81
|
// (writes whatever content we removed back) and deletion (recreates the
|
|
76
82
|
// file). Snapshotting a missing path records `{ existed: false }`, which
|
|
@@ -129,7 +135,7 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
|
|
|
129
135
|
}
|
|
130
136
|
if (await pathExists(enginePath)) {
|
|
131
137
|
await removeFile(enginePath);
|
|
132
|
-
removed.push(relative(engineDir, enginePath));
|
|
138
|
+
removed.push(normalizePathSlashes(relative(engineDir, enginePath)));
|
|
133
139
|
}
|
|
134
140
|
// When an `.ftl` is deleted from the workspace the corresponding locale
|
|
135
141
|
// jar.mn entry must also be dropped — otherwise the chrome URI points at
|
|
@@ -173,7 +179,7 @@ export async function undeployOverrideFiles(engineDir, config, deletedFiles, ftl
|
|
|
173
179
|
for (const fileName of deletedFiles) {
|
|
174
180
|
const enginePath = getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir);
|
|
175
181
|
const action = await restoreOverrideFileToBaseline(engineDir, enginePath, rollbackJournal);
|
|
176
|
-
const relPath = relative(engineDir, enginePath);
|
|
182
|
+
const relPath = normalizePathSlashes(relative(engineDir, enginePath));
|
|
177
183
|
if (action === 'restored')
|
|
178
184
|
restored.push(relPath);
|
|
179
185
|
else if (action === 'removed')
|
|
@@ -332,7 +338,7 @@ export async function applyCustomComponent(ctx, config, dryRun = false, rollback
|
|
|
332
338
|
const src = join(componentDir, entry.name);
|
|
333
339
|
const dest = join(targetDir, entry.name);
|
|
334
340
|
await deployFileWithFragments(src, dest, sharedDir);
|
|
335
|
-
affectedPaths.push(relative(engineDir, dest));
|
|
341
|
+
affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
|
|
336
342
|
copiedFileNames.push(entry.name);
|
|
337
343
|
}));
|
|
338
344
|
// See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
|
|
@@ -425,7 +431,7 @@ export async function applyOverrideComponent(ctx, config, dryRun = false, rollba
|
|
|
425
431
|
const src = join(componentDir, entry.name);
|
|
426
432
|
const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
|
|
427
433
|
await copyFile(src, dest);
|
|
428
|
-
affectedPaths.push(relative(engineDir, dest));
|
|
434
|
+
affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
|
|
429
435
|
}));
|
|
430
436
|
if (affectedPaths.length === 0) {
|
|
431
437
|
throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
|
|
@@ -26,7 +26,7 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
26
26
|
import { toError } from '../utils/errors.js';
|
|
27
27
|
import { pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
|
|
28
28
|
import { info } from '../utils/logger.js';
|
|
29
|
-
import { normalizePathSlashes } from '../utils/paths.js';
|
|
29
|
+
import { isPathInsideRoot, normalizePathSlashes } from '../utils/paths.js';
|
|
30
30
|
import { getFurnacePaths } from './furnace-config.js';
|
|
31
31
|
/** Chrome URL prefix under which registered custom-element files deploy. */
|
|
32
32
|
const CHROME_ELEMENTS_URL_PREFIX = 'chrome://global/content/elements/';
|
|
@@ -84,8 +84,10 @@ function isManagedEntry(key, value, jsconfigDir, customDir) {
|
|
|
84
84
|
return false;
|
|
85
85
|
if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string')
|
|
86
86
|
return false;
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Containment via `relative`, not a string prefix: a hard-coded `'/'`
|
|
88
|
+
// separator never matches the backslashes `resolve` emits on Windows, which
|
|
89
|
+
// demoted every managed entry to "unmanaged" there — no pruning, no updates.
|
|
90
|
+
return isPathInsideRoot(customDir, resolve(jsconfigDir, value[0]));
|
|
89
91
|
}
|
|
90
92
|
/**
|
|
91
93
|
* Reconciles the managed `compilerOptions.paths` entries of the configured
|
|
@@ -56,8 +56,16 @@ export interface RunFurnaceMutationOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
dryRun?: boolean;
|
|
58
58
|
/**
|
|
59
|
-
* Override the default
|
|
60
|
-
*
|
|
59
|
+
* Override the default 30 s furnace-lock timeout.
|
|
60
|
+
*
|
|
61
|
+
* Set from the caller's resolved `--wait-lock` budget, so ONE flag governs
|
|
62
|
+
* both locks a furnace mutation takes. Before that it was declared and
|
|
63
|
+
* never passed: `furnace deploy --wait-lock 1800` waited 1800 s for the
|
|
64
|
+
* engine session lock and then met `.fireforge/furnace.lock` with a fixed
|
|
65
|
+
* 30 s, which is the worst shape a budget can have — it pays the whole
|
|
66
|
+
* wait and still refuses. When absent, {@link sessionWaitLockMs} supplies
|
|
67
|
+
* the session-wide budget so the twelve furnace mutators that declare no
|
|
68
|
+
* flag are covered too.
|
|
61
69
|
*/
|
|
62
70
|
lockTimeoutMs?: number;
|
|
63
71
|
/**
|
|
@@ -67,6 +75,21 @@ export interface RunFurnaceMutationOptions {
|
|
|
67
75
|
*/
|
|
68
76
|
skipPendingRepairCheck?: boolean;
|
|
69
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Converts a resolved `--wait-lock` budget in seconds into the
|
|
80
|
+
* {@link RunFurnaceMutationOptions} slice that applies it to the furnace
|
|
81
|
+
* lock. Undefined in, empty out — so the caller can spread it
|
|
82
|
+
* unconditionally without assigning `undefined` under
|
|
83
|
+
* `exactOptionalPropertyTypes`.
|
|
84
|
+
*
|
|
85
|
+
* A helper rather than three inline spreads: the seconds-to-milliseconds
|
|
86
|
+
* conversion is exactly the kind of thing that gets copied once correctly
|
|
87
|
+
* and once with the factor dropped.
|
|
88
|
+
*
|
|
89
|
+
* @param waitLockSeconds - Resolved budget in seconds, or undefined
|
|
90
|
+
* @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
|
|
91
|
+
*/
|
|
92
|
+
export declare function waitLockMutationOptions(waitLockSeconds: number | undefined): Pick<RunFurnaceMutationOptions, 'lockTimeoutMs'>;
|
|
70
93
|
/**
|
|
71
94
|
* Returns true while a signal-driven rollback is in progress. The bin entry
|
|
72
95
|
* point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
|
|
@@ -4,12 +4,30 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
4
4
|
import { assert } from '../utils/assert.js';
|
|
5
5
|
import { toError } from '../utils/errors.js';
|
|
6
6
|
import { verbose, warn } from '../utils/logger.js';
|
|
7
|
+
import { sessionWaitLockMs } from '../utils/options.js';
|
|
7
8
|
import { FIREFORGE_DIR } from './config-paths.js';
|
|
8
9
|
import { readLockStatus, releaseLockIfOwned, withFileLock } from './file-lock.js';
|
|
9
10
|
import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
|
|
10
11
|
import { restoreRollbackJournal, restoreRollbackJournalOrThrow, } from './furnace-rollback.js';
|
|
11
12
|
/** Sidecar lock filename used to serialize concurrent furnace mutations. */
|
|
12
13
|
const FURNACE_LOCK_FILENAME = 'furnace.lock';
|
|
14
|
+
/**
|
|
15
|
+
* Converts a resolved `--wait-lock` budget in seconds into the
|
|
16
|
+
* {@link RunFurnaceMutationOptions} slice that applies it to the furnace
|
|
17
|
+
* lock. Undefined in, empty out — so the caller can spread it
|
|
18
|
+
* unconditionally without assigning `undefined` under
|
|
19
|
+
* `exactOptionalPropertyTypes`.
|
|
20
|
+
*
|
|
21
|
+
* A helper rather than three inline spreads: the seconds-to-milliseconds
|
|
22
|
+
* conversion is exactly the kind of thing that gets copied once correctly
|
|
23
|
+
* and once with the factor dropped.
|
|
24
|
+
*
|
|
25
|
+
* @param waitLockSeconds - Resolved budget in seconds, or undefined
|
|
26
|
+
* @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
|
|
27
|
+
*/
|
|
28
|
+
export function waitLockMutationOptions(waitLockSeconds) {
|
|
29
|
+
return waitLockSeconds === undefined ? {} : { lockTimeoutMs: waitLockSeconds * 1000 };
|
|
30
|
+
}
|
|
13
31
|
/**
|
|
14
32
|
* Claims the rollback for one operation, or reports that another path
|
|
15
33
|
* already has it.
|
|
@@ -338,10 +356,17 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
|
|
|
338
356
|
const token = nextOperationToken++;
|
|
339
357
|
const operation = { root, kind, cleanups: [] };
|
|
340
358
|
const lockPath = getFurnaceLockPath(root);
|
|
359
|
+
// An explicit budget wins; otherwise the session-wide one applies. Naming
|
|
360
|
+
// the lock in the refusal is load-bearing: a mutation takes two, and a
|
|
361
|
+
// message that says only "timed out" sends the operator to raise a budget
|
|
362
|
+
// that was already large enough for the OTHER lock.
|
|
363
|
+
const timeoutMs = options.lockTimeoutMs ?? sessionWaitLockMs();
|
|
341
364
|
const lockOptions = {
|
|
342
|
-
...(
|
|
343
|
-
onTimeoutMessage: `Timed out waiting for the
|
|
365
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
366
|
+
onTimeoutMessage: `Timed out waiting for the FURNACE lock at ${lockPath} ` +
|
|
367
|
+
`(this is the second of the two locks a furnace mutation takes; the first is the engine session lock). ` +
|
|
344
368
|
'Another fireforge furnace command may be running. ' +
|
|
369
|
+
'Raise the budget with --wait-lock <seconds> or FIREFORGE_WAIT_LOCK, which governs both. ' +
|
|
345
370
|
'If no other process is running, remove the stale lock directory and retry.',
|
|
346
371
|
onStaleLockMessage: (ageMs) => `Removing stale furnace lock (age: ${Math.round(ageMs / 1000)}s). ` +
|
|
347
372
|
'A previous fireforge process may have crashed.',
|
|
@@ -83,3 +83,17 @@ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): s
|
|
|
83
83
|
* repositories with hundreds of thousands of files.
|
|
84
84
|
*/
|
|
85
85
|
export declare function configureGitPerformance(repoDir: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Pins the engine repository's line-ending policy to "store and check out
|
|
88
|
+
* exactly what upstream shipped".
|
|
89
|
+
*
|
|
90
|
+
* Git's Windows installer defaults to a global `core.autocrlf=true`, which
|
|
91
|
+
* rewrites LF to CRLF on checkout and back on staging. FireForge cannot
|
|
92
|
+
* tolerate that: patch bodies are byte diffs of the working tree, and
|
|
93
|
+
* `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
|
|
94
|
+
* `core.autocrlf` DO apply to the hashes `status` compares against. Under the
|
|
95
|
+
* global default the same tree hashes differently on Windows than everywhere
|
|
96
|
+
* else, so exported patches and drift detection disagree across machines.
|
|
97
|
+
* Setting it per-repository leaves the developer's global config alone.
|
|
98
|
+
*/
|
|
99
|
+
export declare function configureGitLineEndings(repoDir: string): Promise<void>;
|
|
@@ -120,3 +120,20 @@ export async function configureGitPerformance(repoDir) {
|
|
|
120
120
|
await git(['config', 'core.fsmonitor', 'false'], repoDir);
|
|
121
121
|
await git(['config', 'feature.manyFiles', 'true'], repoDir);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Pins the engine repository's line-ending policy to "store and check out
|
|
125
|
+
* exactly what upstream shipped".
|
|
126
|
+
*
|
|
127
|
+
* Git's Windows installer defaults to a global `core.autocrlf=true`, which
|
|
128
|
+
* rewrites LF to CRLF on checkout and back on staging. FireForge cannot
|
|
129
|
+
* tolerate that: patch bodies are byte diffs of the working tree, and
|
|
130
|
+
* `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
|
|
131
|
+
* `core.autocrlf` DO apply to the hashes `status` compares against. Under the
|
|
132
|
+
* global default the same tree hashes differently on Windows than everywhere
|
|
133
|
+
* else, so exported patches and drift detection disagree across machines.
|
|
134
|
+
* Setting it per-repository leaves the developer's global config alone.
|
|
135
|
+
*/
|
|
136
|
+
export async function configureGitLineEndings(repoDir) {
|
|
137
|
+
await git(['config', 'core.autocrlf', 'false'], repoDir);
|
|
138
|
+
await git(['config', 'core.eol', 'lf'], repoDir);
|
|
139
|
+
}
|
package/dist/src/core/git.js
CHANGED
|
@@ -9,7 +9,7 @@ import { pathExists, pathExistsStrict, removeFile } from '../utils/fs.js';
|
|
|
9
9
|
import { verbose } from '../utils/logger.js';
|
|
10
10
|
import { exec } from '../utils/process.js';
|
|
11
11
|
import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
|
|
12
|
-
import { chunkPathspecs, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
|
|
12
|
+
import { chunkPathspecs, configureGitLineEndings, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
|
|
13
13
|
import { getWorkingTreeStatus } from './git-status.js';
|
|
14
14
|
// ── Functions that remain in this file ──
|
|
15
15
|
/**
|
|
@@ -278,6 +278,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
|
|
|
278
278
|
// Enable performance settings for large trees
|
|
279
279
|
reportProgress('Configuring git performance settings...');
|
|
280
280
|
await configureGitPerformance(dir);
|
|
281
|
+
await configureGitLineEndings(dir);
|
|
281
282
|
// Add a local-only origin remote so that Firefox's mach bootstrap and
|
|
282
283
|
// build scripts (which shell out to `git remote get-url origin`) don't
|
|
283
284
|
// fail. Nothing is ever fetched from or pushed to this remote.
|
|
@@ -318,6 +319,7 @@ export async function resumeRepository(dir, options = {}) {
|
|
|
318
319
|
// Ensure performance settings are in place (may not have been set)
|
|
319
320
|
reportProgress('Configuring git performance settings...');
|
|
320
321
|
await configureGitPerformance(dir);
|
|
322
|
+
await configureGitLineEndings(dir);
|
|
321
323
|
// Clean up any stale index lock left by the killed process
|
|
322
324
|
await cleanupIndexLock(dir);
|
|
323
325
|
// Ensure origin remote exists (may have been added before the interrupt)
|
|
@@ -47,6 +47,23 @@
|
|
|
47
47
|
* @returns True when the recognized teardown traceback is present
|
|
48
48
|
*/
|
|
49
49
|
export declare function hasKnownTeardownNoise(output: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True when a SINGLE line is the documented teardown family's
|
|
52
|
+
* `AttributeError`.
|
|
53
|
+
*
|
|
54
|
+
* The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
|
|
55
|
+
* lives on a neighbouring line, so a per-line test cannot apply the
|
|
56
|
+
* two-signal rule by itself. Callers pair this with
|
|
57
|
+
* `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
|
|
58
|
+
* "this capture carries the incident, and THIS line is its header" — which
|
|
59
|
+
* keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
|
|
60
|
+
* the same work it does everywhere else. A novel attribute is not this
|
|
61
|
+
* incident and must stay a candidate failure.
|
|
62
|
+
*
|
|
63
|
+
* @param line - A single output line
|
|
64
|
+
* @returns True when the line is the recognized teardown AttributeError
|
|
65
|
+
*/
|
|
66
|
+
export declare function isKnownTeardownNoiseLine(line: string): boolean;
|
|
50
67
|
/**
|
|
51
68
|
* Shared across the stdout and stderr filter instances of ONE mach run:
|
|
52
69
|
* SUITE_END typically arrives on stdout while the teardown traceback lands
|
|
@@ -70,6 +70,25 @@ function isRecognizedTeardownNoise(block) {
|
|
|
70
70
|
export function hasKnownTeardownNoise(output) {
|
|
71
71
|
return isRecognizedTeardownNoise(output);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* True when a SINGLE line is the documented teardown family's
|
|
75
|
+
* `AttributeError`.
|
|
76
|
+
*
|
|
77
|
+
* The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
|
|
78
|
+
* lives on a neighbouring line, so a per-line test cannot apply the
|
|
79
|
+
* two-signal rule by itself. Callers pair this with
|
|
80
|
+
* `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
|
|
81
|
+
* "this capture carries the incident, and THIS line is its header" — which
|
|
82
|
+
* keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
|
|
83
|
+
* the same work it does everywhere else. A novel attribute is not this
|
|
84
|
+
* incident and must stay a candidate failure.
|
|
85
|
+
*
|
|
86
|
+
* @param line - A single output line
|
|
87
|
+
* @returns True when the line is the recognized teardown AttributeError
|
|
88
|
+
*/
|
|
89
|
+
export function isKnownTeardownNoiseLine(line) {
|
|
90
|
+
return KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN.test(line);
|
|
91
|
+
}
|
|
73
92
|
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
74
93
|
export function createTeardownNoiseContext() {
|
|
75
94
|
return { shutdownSeen: false };
|