@hominis/fireforge 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +76 -3
  3. package/dist/src/commands/build.js +41 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +49 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +151 -0
  6. package/dist/src/commands/furnace/chrome-doc.d.ts +34 -0
  7. package/dist/src/commands/furnace/chrome-doc.js +168 -0
  8. package/dist/src/commands/furnace/create-mochikit.d.ts +30 -0
  9. package/dist/src/commands/furnace/create-mochikit.js +70 -0
  10. package/dist/src/commands/furnace/create-templates.d.ts +53 -0
  11. package/dist/src/commands/furnace/create-templates.js +118 -0
  12. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  13. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  14. package/dist/src/commands/furnace/create.d.ts +17 -0
  15. package/dist/src/commands/furnace/create.js +59 -12
  16. package/dist/src/commands/furnace/index.d.ts +2 -1
  17. package/dist/src/commands/furnace/index.js +20 -2
  18. package/dist/src/commands/lint.d.ts +13 -1
  19. package/dist/src/commands/lint.js +33 -7
  20. package/dist/src/commands/setup.d.ts +1 -1
  21. package/dist/src/commands/setup.js +3 -2
  22. package/dist/src/core/build-audit.d.ts +46 -0
  23. package/dist/src/core/build-audit.js +251 -0
  24. package/dist/src/core/build-baseline.d.ts +59 -0
  25. package/dist/src/core/build-baseline.js +83 -0
  26. package/dist/src/core/build-prepare.d.ts +20 -1
  27. package/dist/src/core/build-prepare.js +94 -4
  28. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  29. package/dist/src/core/furnace-config-tokens.d.ts +6 -0
  30. package/dist/src/core/furnace-config-tokens.js +15 -0
  31. package/dist/src/core/furnace-config.js +10 -4
  32. package/dist/src/core/furnace-operation.d.ts +2 -1
  33. package/dist/src/core/furnace-operation.js +13 -7
  34. package/dist/src/core/furnace-registration-ast.d.ts +2 -2
  35. package/dist/src/core/furnace-registration-ast.js +1 -1
  36. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  37. package/dist/src/core/furnace-validate-helpers.d.ts +31 -1
  38. package/dist/src/core/furnace-validate-helpers.js +101 -18
  39. package/dist/src/core/furnace-validate-registration.d.ts +1 -1
  40. package/dist/src/core/furnace-validate-registration.js +1 -1
  41. package/dist/src/core/mach-error-hints.d.ts +29 -0
  42. package/dist/src/core/mach-error-hints.js +43 -0
  43. package/dist/src/core/mach.d.ts +5 -2
  44. package/dist/src/core/mach.js +31 -4
  45. package/dist/src/core/marionette-preflight.d.ts +14 -7
  46. package/dist/src/core/marionette-preflight.js +94 -44
  47. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  48. package/dist/src/core/patch-lint-cross.js +1 -1
  49. package/dist/src/core/patch-lint-diff-tag.d.ts +33 -0
  50. package/dist/src/core/patch-lint-diff-tag.js +83 -0
  51. package/dist/src/core/patch-lint.js +29 -9
  52. package/dist/src/types/commands/options.d.ts +25 -0
  53. package/dist/src/types/commands/patches.d.ts +9 -0
  54. package/dist/src/types/config.d.ts +1 -1
  55. package/dist/src/types/furnace.d.ts +13 -2
  56. package/package.json +1 -1
@@ -148,28 +148,88 @@ function isWithinLocalizedElement(content, matchIndex) {
148
148
  const tagContent = contentBefore.slice(lastTagOpen, matchIndex + 1);
149
149
  return /data-l10n-id\s*=/.test(tagContent);
150
150
  }
151
- /** Detects hardcoded user-visible template text that should usually be localized. */
151
+ /**
152
+ * Detects hardcoded user-visible template text that should usually be
153
+ * localized.
154
+ *
155
+ * Scoped to three positive contexts rather than scanning the whole file,
156
+ * because a bare `>…<` regex catches JS comparisons (`if (x > 0 && y <
157
+ * 100)`), diagnostic strings (`console.error("Failed <id> lookup")`), and
158
+ * identifier literals that are never shown to a user. Only matches that
159
+ * actually enter a UI render path count:
160
+ *
161
+ * 1. Content inside a Lit `` html`…` `` tagged template literal.
162
+ * 2. The string literal on the RHS of `.textContent = "…"` or
163
+ * `.innerHTML = "…"`.
164
+ * 3. The string literal assigned to an XUL-widget `label=`,
165
+ * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
166
+ *
167
+ * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
168
+ * findings (matches the pre-existing escape hatch).
169
+ */
152
170
  export function containsHardcodedTemplateText(content) {
153
171
  if (/furnace-ignore:\s*hardcoded-text/.test(content)) {
154
172
  return false;
155
173
  }
156
- const textPattern = />([^<$\s][^<$]*)</g;
157
- let textMatch;
158
- while ((textMatch = textPattern.exec(content)) !== null) {
159
- const text = textMatch[1]?.trim() ?? '';
160
- if (/\$\{/.test(text)) {
161
- continue;
162
- }
163
- if (Array.from(text).length <= 1) {
164
- continue;
165
- }
166
- if (isSymbolOnlyText(text)) {
167
- continue;
168
- }
169
- if (isWithinLocalizedElement(content, textMatch.index)) {
170
- continue;
174
+ return (hasFlaggedTextInLitTemplates(content) ||
175
+ hasFlaggedTextInDomAssignment(content) ||
176
+ hasFlaggedTextInXulAttribute(content));
177
+ }
178
+ function isFlaggableText(text) {
179
+ const trimmed = text.trim();
180
+ if (!trimmed)
181
+ return false;
182
+ if (/\$\{/.test(trimmed))
183
+ return false;
184
+ if (Array.from(trimmed).length <= 1)
185
+ return false;
186
+ if (isSymbolOnlyText(trimmed))
187
+ return false;
188
+ return true;
189
+ }
190
+ function hasFlaggedTextInLitTemplates(content) {
191
+ // Match `html\`…\`` regions, anchored on a non-identifier char before `html`
192
+ // so substrings like `otherhtml` do not spuriously open a template.
193
+ const htmlPattern = /(?:^|[^a-zA-Z0-9_$])html`([\s\S]*?)`/g;
194
+ let litMatch;
195
+ while ((litMatch = htmlPattern.exec(content)) !== null) {
196
+ const region = litMatch[1] ?? '';
197
+ const textPattern = />([^<$\s][^<$]*)</g;
198
+ let textMatch;
199
+ while ((textMatch = textPattern.exec(region)) !== null) {
200
+ const text = textMatch[1] ?? '';
201
+ if (!isFlaggableText(text))
202
+ continue;
203
+ if (isWithinLocalizedElement(region, textMatch.index))
204
+ continue;
205
+ return true;
171
206
  }
172
- return true;
207
+ }
208
+ return false;
209
+ }
210
+ function hasFlaggedTextInDomAssignment(content) {
211
+ // `<expr>.textContent = "abc"` and `<expr>.innerHTML = "abc"` — these are
212
+ // user-visible render paths. Template-literal RHS is excluded (usually
213
+ // dynamic), matching the `${` guard used elsewhere in this helper.
214
+ const assignPattern = /\.(?:textContent|innerHTML)\s*=\s*(["'])((?:\\.|(?!\1).)*)\1/g;
215
+ let match;
216
+ while ((match = assignPattern.exec(content)) !== null) {
217
+ const text = match[2] ?? '';
218
+ if (isFlaggableText(text))
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ function hasFlaggedTextInXulAttribute(content) {
224
+ // Assignments like `node.setAttribute("label", "Save")` or JS-built XUL
225
+ // attributes `label="…"` / `title="…"` / `tooltiptext="…"` in template
226
+ // literals outside Lit blocks. Covers DOM built via createXULElement.
227
+ const setAttrPattern = /setAttribute\s*\(\s*["'](?:label|title|tooltiptext)["']\s*,\s*(["'])((?:\\.|(?!\1).)*)\1/g;
228
+ let setAttrMatch;
229
+ while ((setAttrMatch = setAttrPattern.exec(content)) !== null) {
230
+ const text = setAttrMatch[2] ?? '';
231
+ if (isFlaggableText(text))
232
+ return true;
173
233
  }
174
234
  return false;
175
235
  }
@@ -214,6 +274,27 @@ export function collectCssVariableReferences(cssContent) {
214
274
  }
215
275
  return referencedVariables;
216
276
  }
277
+ /**
278
+ * Collects CSS custom property *declarations* — names appearing on the
279
+ * left-hand side of a `--name:` declaration. Used to auto-exempt
280
+ * component-local runtime variables from the token-prefix check: if the
281
+ * component both declares and consumes a variable in its own CSS file, it
282
+ * is a local runtime channel, not a design-token reference.
283
+ *
284
+ * The anchor `(?:^|[{;,\s])` rules out `var(--name)` occurrences (which are
285
+ * always preceded by `(`), so references are not mistaken for declarations.
286
+ */
287
+ export function collectCssVariableDeclarations(cssContent) {
288
+ const declared = new Set();
289
+ const pattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
290
+ let match;
291
+ while ((match = pattern.exec(cssContent)) !== null) {
292
+ const name = match[1];
293
+ if (name)
294
+ declared.add(name);
295
+ }
296
+ return declared;
297
+ }
217
298
  async function collectInheritedOverrideVariables(tagName, config, root) {
218
299
  const inheritedVariables = new Set();
219
300
  const basePath = config.overrides[tagName]?.basePath;
@@ -234,12 +315,14 @@ async function collectInheritedOverrideVariables(tagName, config, root) {
234
315
  /** Builds token-validation context from the config allowlist and inherited override CSS. */
235
316
  export async function getTokenPrefixContext(tagName, type, config, root) {
236
317
  const allowlist = new Set(config.tokenAllowlist ?? []);
318
+ const runtimeVariables = new Set(config.runtimeVariables ?? []);
237
319
  if (type !== 'override' || !root) {
238
- return { allowlist, inheritedOverrideVars: new Set() };
320
+ return { allowlist, inheritedOverrideVars: new Set(), runtimeVariables };
239
321
  }
240
322
  return {
241
323
  allowlist,
242
324
  inheritedOverrideVars: await collectInheritedOverrideVariables(tagName, config, root),
325
+ runtimeVariables,
243
326
  };
244
327
  }
245
328
  //# sourceMappingURL=furnace-validate-helpers.js.map
@@ -35,7 +35,7 @@ export declare function validateJarMnEntries(root: string, config: FurnaceConfig
35
35
  * linked in at least one chrome host document. Without the link, tokens
36
36
  * silently resolve to nothing at runtime.
37
37
  *
38
- * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
38
+ * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
39
39
  * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
40
40
  * furnace.json; the warning fires only when NONE of the configured
41
41
  * documents link the tokens CSS.
@@ -216,7 +216,7 @@ const DEFAULT_TOKEN_HOST_DOCUMENTS = ['browser/base/content/browser.xhtml'];
216
216
  * linked in at least one chrome host document. Without the link, tokens
217
217
  * silently resolve to nothing at runtime.
218
218
  *
219
- * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
219
+ * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
220
220
  * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
221
221
  * furnace.json; the warning fires only when NONE of the configured
222
222
  * documents link the tokens CSS.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pattern-based translator for cryptic mozbuild / mach errors.
3
+ *
4
+ * Each entry maps a stderr regex to an actionable hint. The goal is not to
5
+ * parse every mach failure — it's to convert the handful of errors whose
6
+ * message is non-obvious into a one-line "here's what to change". New
7
+ * entries should only be added when a concrete diagnosis of the cryptic
8
+ * output has been established; low-confidence hints would train operators
9
+ * to ignore the translator.
10
+ */
11
+ /** A single translator entry. */
12
+ export interface MachErrorHint {
13
+ /** Pattern to search within the captured mach stderr. */
14
+ pattern: RegExp;
15
+ /** Actionable, one-line hint to surface alongside the raw mach output. */
16
+ hint: string;
17
+ }
18
+ /**
19
+ * Registered hint patterns. Order-sensitive: the first match wins per
20
+ * pattern, but multiple distinct patterns may fire for the same stderr.
21
+ */
22
+ export declare const MACH_ERROR_HINTS: MachErrorHint[];
23
+ /**
24
+ * Scans captured stderr for known mach errors and returns matching hints.
25
+ * Pure function — safe to call on any string; never throws.
26
+ * @param stderr Captured mach stderr.
27
+ * @returns Ordered, de-duplicated list of hint strings. Empty when nothing matches.
28
+ */
29
+ export declare function explainMachError(stderr: string): string[];
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Pattern-based translator for cryptic mozbuild / mach errors.
4
+ *
5
+ * Each entry maps a stderr regex to an actionable hint. The goal is not to
6
+ * parse every mach failure — it's to convert the handful of errors whose
7
+ * message is non-obvious into a one-line "here's what to change". New
8
+ * entries should only be added when a concrete diagnosis of the cryptic
9
+ * output has been established; low-confidence hints would train operators
10
+ * to ignore the translator.
11
+ */
12
+ /**
13
+ * Registered hint patterns. Order-sensitive: the first match wins per
14
+ * pattern, but multiple distinct patterns may fire for the same stderr.
15
+ */
16
+ export const MACH_ERROR_HINTS = [
17
+ {
18
+ pattern: /mozbuild\.preprocessor\.Preprocessor\.Error[\s\S]*?no preprocessor directives found/,
19
+ hint: 'A file registered under JS_PREFERENCE_PP_FILES contains no preprocessor directives. ' +
20
+ 'Use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.',
21
+ },
22
+ ];
23
+ /**
24
+ * Scans captured stderr for known mach errors and returns matching hints.
25
+ * Pure function — safe to call on any string; never throws.
26
+ * @param stderr Captured mach stderr.
27
+ * @returns Ordered, de-duplicated list of hint strings. Empty when nothing matches.
28
+ */
29
+ export function explainMachError(stderr) {
30
+ if (!stderr) {
31
+ return [];
32
+ }
33
+ const hits = [];
34
+ const seen = new Set();
35
+ for (const { pattern, hint } of MACH_ERROR_HINTS) {
36
+ if (pattern.test(stderr) && !seen.has(hint)) {
37
+ seen.add(hint);
38
+ hits.push(hint);
39
+ }
40
+ }
41
+ return hits;
42
+ }
43
+ //# sourceMappingURL=mach-error-hints.js.map
@@ -57,14 +57,17 @@ export declare function bootstrap(engineDir: string): Promise<number>;
57
57
  */
58
58
  export declare function bootstrapWithOutput(engineDir: string): Promise<MachCommandResult>;
59
59
  /**
60
- * Runs a full mach build.
60
+ * Runs a full mach build. On a non-zero exit, any matched error hints are
61
+ * surfaced on top of the raw mach output so operators get an actionable
62
+ * nudge alongside the cryptic mozbuild traceback.
61
63
  * @param engineDir - Path to the engine directory
62
64
  * @param jobs - Number of parallel jobs (optional)
63
65
  * @returns Exit code
64
66
  */
65
67
  export declare function build(engineDir: string, jobs?: number): Promise<number>;
66
68
  /**
67
- * Runs a fast UI-only build.
69
+ * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
70
+ * surfaced on top of the raw mach output.
68
71
  * @param engineDir - Path to the engine directory
69
72
  * @returns Exit code
70
73
  */
@@ -2,7 +2,9 @@
2
2
  import { join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
+ import { warn } from '../utils/logger.js';
5
6
  import { exec, execInherit, execInheritCapture, execStream } from '../utils/process.js';
7
+ import { explainMachError } from './mach-error-hints.js';
6
8
  import { getPython } from './mach-python.js';
7
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
8
10
  export { buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
@@ -109,7 +111,23 @@ export async function bootstrapWithOutput(engineDir) {
109
111
  return runMachInheritCapture(['bootstrap', '--application-choice', 'browser'], engineDir);
110
112
  }
111
113
  /**
112
- * Runs a full mach build.
114
+ * Prints any matched {@link MachErrorHint} hints for the captured stderr.
115
+ * No-op when nothing matches. Always called before a non-zero exit propagates
116
+ * so the hint sits immediately below the raw mach error in the operator's
117
+ * terminal.
118
+ */
119
+ function surfaceMachErrorHints(stderr) {
120
+ const hints = explainMachError(stderr);
121
+ if (hints.length === 0)
122
+ return;
123
+ for (const hint of hints) {
124
+ warn(`Hint: ${hint}`);
125
+ }
126
+ }
127
+ /**
128
+ * Runs a full mach build. On a non-zero exit, any matched error hints are
129
+ * surfaced on top of the raw mach output so operators get an actionable
130
+ * nudge alongside the cryptic mozbuild traceback.
113
131
  * @param engineDir - Path to the engine directory
114
132
  * @param jobs - Number of parallel jobs (optional)
115
133
  * @returns Exit code
@@ -119,15 +137,24 @@ export async function build(engineDir, jobs) {
119
137
  if (jobs !== undefined) {
120
138
  args.push('-j', String(jobs));
121
139
  }
122
- return runMach(args, engineDir, { inherit: true });
140
+ const result = await runMachInheritCapture(args, engineDir);
141
+ if (result.exitCode !== 0) {
142
+ surfaceMachErrorHints(result.stderr);
143
+ }
144
+ return result.exitCode;
123
145
  }
124
146
  /**
125
- * Runs a fast UI-only build.
147
+ * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
148
+ * surfaced on top of the raw mach output.
126
149
  * @param engineDir - Path to the engine directory
127
150
  * @returns Exit code
128
151
  */
129
152
  export async function buildUI(engineDir) {
130
- return runMach(['build', 'faster'], engineDir, { inherit: true });
153
+ const result = await runMachInheritCapture(['build', 'faster'], engineDir);
154
+ if (result.exitCode !== 0) {
155
+ surfaceMachErrorHints(result.stderr);
156
+ }
157
+ return result.exitCode;
131
158
  }
132
159
  /**
133
160
  * Runs the built browser.
@@ -6,18 +6,17 @@
6
6
  * to discover"; this helper surfaces the failure in under a minute with a
7
7
  * clear PASS/FAIL line and the tail of the browser's stderr.
8
8
  *
9
- * The probe is intentionally narrow it does not replace mach test or try
10
- * to execute anything via marionette. It spawns `mach run --marionette
11
- * --headless` (plus a throwaway profile) and waits for the marionette server
12
- * to accept a TCP connection on the conventional port. Any byte read from
13
- * the socket proves a handshake payload is being produced.
9
+ * The probe is a cascade of layered checks (engine mach python
10
+ * profile spawn handshake). Each layer has a tight per-attempt budget
11
+ * so a broken earlier layer fails fast with a specific diagnosis rather
12
+ * than blocking on the final socket poll for the full overall budget.
14
13
  */
15
14
  import { spawn } from 'node:child_process';
16
15
  import net from 'node:net';
17
16
  export interface MarionettePreflightResult {
18
17
  ok: boolean;
19
18
  durationMs: number;
20
- /** Human-readable summary. */
19
+ /** Human-readable summary. On FAIL, prefixed with `[layer N/6: <name>]`. */
21
20
  detail: string;
22
21
  }
23
22
  export interface MarionettePreflightOptions {
@@ -25,13 +24,21 @@ export interface MarionettePreflightOptions {
25
24
  timeoutMs?: number;
26
25
  /** Overrides marionette TCP port — primarily used in tests. */
27
26
  port?: number;
27
+ /**
28
+ * Grace window after spawn() before the browser is considered "running
29
+ * OK." Catches immediate crashes (missing dylib, wrong CPU arch, corrupt
30
+ * profile) at the spawn layer rather than the handshake layer. Default:
31
+ * {@link SPAWN_SETTLE_MS}. Tests may set this to 0 to skip the settle.
32
+ */
33
+ spawnSettleMs?: number;
28
34
  /** Test seam: spawn and socket connect factories. */
29
35
  spawner?: typeof spawn;
30
36
  connect?: typeof net.createConnection;
31
37
  }
32
38
  /**
33
39
  * Runs the marionette preflight. Returns PASS on first byte read from the
34
- * marionette socket within the budget; FAIL otherwise. Always tears down the
40
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
41
+ * identifying which layer of the cascade broke. Always tears down the
35
42
  * spawned browser before returning.
36
43
  */
37
44
  export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
@@ -7,11 +7,10 @@
7
7
  * to discover"; this helper surfaces the failure in under a minute with a
8
8
  * clear PASS/FAIL line and the tail of the browser's stderr.
9
9
  *
10
- * The probe is intentionally narrow it does not replace mach test or try
11
- * to execute anything via marionette. It spawns `mach run --marionette
12
- * --headless` (plus a throwaway profile) and waits for the marionette server
13
- * to accept a TCP connection on the conventional port. Any byte read from
14
- * the socket proves a handshake payload is being produced.
10
+ * The probe is a cascade of layered checks (engine mach python
11
+ * profile spawn handshake). Each layer has a tight per-attempt budget
12
+ * so a broken earlier layer fails fast with a specific diagnosis rather
13
+ * than blocking on the final socket poll for the full overall budget.
15
14
  */
16
15
  import { spawn } from 'node:child_process';
17
16
  import { mkdtemp, rm } from 'node:fs/promises';
@@ -28,60 +27,112 @@ const MARIONETTE_PORT = 2828;
28
27
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
29
28
  /** Per-attempt socket connect timeout. Polling continues until the overall budget expires. */
30
29
  const SOCKET_ATTEMPT_TIMEOUT_MS = 2_000;
30
+ /**
31
+ * Grace window after spawn() returns before we accept the child as
32
+ * "spawned OK". A browser binary that exits immediately (missing dylib,
33
+ * wrong CPU arch, corrupt profile) must be caught here — not 30 seconds
34
+ * later at the socket layer.
35
+ */
36
+ const SPAWN_SETTLE_MS = 750;
31
37
  /** Tail of stderr preserved for FAIL diagnostics. */
32
38
  const STDERR_TAIL_LIMIT = 8 * 1024;
39
+ /**
40
+ * Layer names, ordered by the probe sequence. Surfaced in `detail` so the
41
+ * operator sees which layer failed without having to guess.
42
+ */
43
+ const LAYER_NAMES = [
44
+ 'engine-present',
45
+ 'mach-available',
46
+ 'python-available',
47
+ 'profile-creatable',
48
+ 'browser-spawns',
49
+ 'marionette-handshake',
50
+ ];
51
+ function layerTag(name) {
52
+ const index = LAYER_NAMES.indexOf(name) + 1;
53
+ return `[layer ${index}/${LAYER_NAMES.length}: ${name}]`;
54
+ }
33
55
  /**
34
56
  * Runs the marionette preflight. Returns PASS on first byte read from the
35
- * marionette socket within the budget; FAIL otherwise. Always tears down the
57
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
58
+ * identifying which layer of the cascade broke. Always tears down the
36
59
  * spawned browser before returning.
37
60
  */
38
61
  export async function runMarionettePreflight(engineDir, options = {}) {
39
62
  const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
63
+ const spawnSettleMs = options.spawnSettleMs ?? SPAWN_SETTLE_MS;
40
64
  const port = options.port ?? MARIONETTE_PORT;
41
65
  const spawnerFn = options.spawner ?? spawn;
42
66
  const connectFn = options.connect ?? net.createConnection;
43
67
  const startedAt = Date.now();
44
68
  const elapsed = () => Date.now() - startedAt;
69
+ // Layer 1: engine directory exists.
45
70
  if (!(await pathExists(engineDir))) {
46
- return {
47
- ok: false,
48
- durationMs: elapsed(),
49
- detail: 'Engine directory not found — run "fireforge download" first.',
50
- };
71
+ return fail('engine-present', 'Engine directory not found — run "fireforge download" first.', elapsed());
51
72
  }
73
+ // Layer 2: mach binary resolves in the engine.
52
74
  try {
53
75
  await ensureMach(engineDir);
54
76
  }
55
77
  catch (error) {
56
- return {
57
- ok: false,
58
- durationMs: elapsed(),
59
- detail: `mach not available in engine: ${error.message}`,
60
- };
78
+ return fail('mach-available', `mach not available in engine: ${error.message}`, elapsed());
79
+ }
80
+ // Layer 3: Python that mach requires is discoverable.
81
+ let python;
82
+ try {
83
+ python = await getPython(engineDir);
84
+ }
85
+ catch (error) {
86
+ return fail('python-available', `Python interpreter required by mach is not available: ${error.message}`, elapsed());
87
+ }
88
+ // Layer 4: throwaway browser profile directory is creatable.
89
+ let profileDir;
90
+ try {
91
+ profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
92
+ }
93
+ catch (error) {
94
+ return fail('profile-creatable', `Could not create a throwaway browser profile in ${tmpdir()}: ${error.message}`, elapsed());
61
95
  }
62
- const python = await getPython(engineDir);
63
- const profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
64
96
  let child;
65
97
  let stderrTail = '';
66
98
  try {
67
- child = spawnerFn(python, [
68
- join(engineDir, 'mach'),
69
- 'run',
70
- '--marionette',
71
- '--headless',
72
- '--no-remote',
73
- '-profile',
74
- profileDir,
75
- ], {
76
- cwd: engineDir,
77
- env: { ...process.env, MOZ_HEADLESS: '1' },
78
- stdio: ['ignore', 'ignore', 'pipe'],
79
- });
99
+ // Layer 5: browser spawns and does not crash within the settle window.
100
+ try {
101
+ child = spawnerFn(python, [
102
+ join(engineDir, 'mach'),
103
+ 'run',
104
+ '--marionette',
105
+ '--headless',
106
+ '--no-remote',
107
+ '-profile',
108
+ profileDir,
109
+ ], {
110
+ cwd: engineDir,
111
+ env: { ...process.env, MOZ_HEADLESS: '1' },
112
+ stdio: ['ignore', 'ignore', 'pipe'],
113
+ });
114
+ }
115
+ catch (error) {
116
+ return fail('browser-spawns', `Could not spawn mach run: ${error.message}`, elapsed());
117
+ }
118
+ const spawnedChild = child;
80
119
  child.stderr?.on('data', (data) => {
81
120
  const chunk = data.toString();
82
121
  stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT);
83
122
  });
84
- const spawnedChild = child;
123
+ // Short settle window — catches "binary exits immediately" failures
124
+ // (missing dylib, wrong CPU arch, corrupt profile) before the socket
125
+ // poll swallows the full overall budget waiting for bytes that will
126
+ // never come.
127
+ const settleDeadline = Math.min(spawnSettleMs, Math.max(0, timeoutMs - elapsed()));
128
+ if (settleDeadline > 0) {
129
+ await delay(settleDeadline);
130
+ }
131
+ if (hasChildExited(spawnedChild)) {
132
+ return fail('browser-spawns', `Browser process exited during spawn (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
133
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
134
+ }
135
+ // Layer 6: marionette handshake within the remaining budget.
85
136
  const socketResult = await waitForMarionetteSocket(port, connectFn, () => {
86
137
  return elapsed() < timeoutMs && !hasChildExited(spawnedChild);
87
138
  });
@@ -95,19 +146,11 @@ export async function runMarionettePreflight(engineDir, options = {}) {
95
146
  // Child may have exited before the socket was ever ready — surface that
96
147
  // distinctly from "socket never answered" so the operator has a lead.
97
148
  if (hasChildExited(spawnedChild)) {
98
- return {
99
- ok: false,
100
- durationMs: elapsed(),
101
- detail: `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
102
- `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
103
- };
149
+ return fail('marionette-handshake', `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
150
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
104
151
  }
105
- return {
106
- ok: false,
107
- durationMs: elapsed(),
108
- detail: `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
109
- `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
110
- };
152
+ return fail('marionette-handshake', `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
153
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
111
154
  }
112
155
  finally {
113
156
  if (child && !hasChildExited(child)) {
@@ -137,6 +180,13 @@ export async function runMarionettePreflight(engineDir, options = {}) {
137
180
  }
138
181
  }
139
182
  }
183
+ function fail(layer, message, durationMs) {
184
+ return {
185
+ ok: false,
186
+ durationMs,
187
+ detail: `${layerTag(layer)} ${message}`,
188
+ };
189
+ }
140
190
  /** Returns true when the child process has exited (normal or signaled). */
141
191
  function hasChildExited(child) {
142
192
  return child.exitCode !== null || child.signalCode !== null;
@@ -91,7 +91,7 @@ export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Ma
91
91
  /**
92
92
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
93
93
  * +++ b/path`) by more than one patch. This is the failure mode that
94
- * motivated the rule — Hominis landed three patches each trying to create
94
+ * motivated the rule — a fork landed three patches each trying to create
95
95
  * the same file, and the error surfaced only when import rolled back
96
96
  * mid-apply.
97
97
  *
@@ -114,7 +114,7 @@ export function collectNewFileCreatorsByPath(ctx) {
114
114
  /**
115
115
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
116
116
  * +++ b/path`) by more than one patch. This is the failure mode that
117
- * motivated the rule — Hominis landed three patches each trying to create
117
+ * motivated the rule — a fork landed three patches each trying to create
118
118
  * the same file, and the error surfaced only when import rolled back
119
119
  * mid-apply.
120
120
  *
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Diff-scoping for `fireforge lint`.
3
+ *
4
+ * Pre-existing patch-state errors and errors introduced by the current task
5
+ * print identically today, so triaging "is the diff I just produced clean?"
6
+ * requires mentally subtracting the pre-existing noise. This module
7
+ * classifies each lint issue as either `introduced` (the file was touched
8
+ * since the user-supplied git revision) or `cumulative` (pre-existing
9
+ * drift), without changing what the underlying rules emit.
10
+ */
11
+ import type { PatchLintIssue } from '../types/commands/index.js';
12
+ /**
13
+ * Collects engine-relative paths that changed between `rev` and `HEAD`,
14
+ * plus any workdir modifications and untracked files. An empty set means
15
+ * "no diff we can prove" — downstream treats every issue as `cumulative`
16
+ * in that case (operator ran `lint --since HEAD` with no pending work).
17
+ * @param engineDir Path to the engine git repository.
18
+ * @param rev Git revision to diff against (e.g. `HEAD`, a branch, a SHA).
19
+ */
20
+ export declare function collectDiffFilePaths(engineDir: string, rev: string): Promise<Set<string>>;
21
+ /**
22
+ * Annotates a list of lint issues with `introduced` / `cumulative` tags
23
+ * based on whether the issue's file is part of the supplied diff set.
24
+ * Mutates each issue in place AND returns the list for chaining.
25
+ *
26
+ * Issues with no file (`issue.file === ''`) — e.g. cross-patch rules that
27
+ * describe queue-wide state — are always `cumulative` under `--since`
28
+ * because they describe drift accumulated across many commits, not a
29
+ * single current-task edit.
30
+ * @param issues Issues returned by the lint orchestrator.
31
+ * @param diffFiles File paths touched since the user's revision.
32
+ */
33
+ export declare function tagLintIssues(issues: PatchLintIssue[], diffFiles: Set<string>): PatchLintIssue[];