@hominis/fireforge 0.48.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +16 -28
  4. package/dist/src/commands/build.js +30 -4
  5. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  6. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  7. package/dist/src/commands/doctor-macos-sdk.js +7 -5
  8. package/dist/src/commands/doctor-source-pin.js +9 -2
  9. package/dist/src/commands/doctor.js +2 -0
  10. package/dist/src/commands/furnace/apply.js +10 -4
  11. package/dist/src/commands/furnace/create-templates.js +0 -3
  12. package/dist/src/commands/lint.js +4 -0
  13. package/dist/src/commands/manifest.d.ts +1 -2
  14. package/dist/src/commands/patch/delete.js +18 -4
  15. package/dist/src/commands/re-export-options.js +7 -0
  16. package/dist/src/commands/re-export-register.js +1 -0
  17. package/dist/src/commands/re-export-removed.d.ts +4 -0
  18. package/dist/src/commands/re-export-removed.js +49 -0
  19. package/dist/src/commands/re-export-single.js +4 -1
  20. package/dist/src/commands/re-export.js +4 -0
  21. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  22. package/dist/src/commands/test-browser-preflight.js +181 -0
  23. package/dist/src/commands/test-build-phase.js +30 -7
  24. package/dist/src/commands/test-checkjs.d.ts +3 -0
  25. package/dist/src/commands/test-checkjs.js +27 -0
  26. package/dist/src/commands/test-diagnose.js +3 -0
  27. package/dist/src/commands/test-harness-env.d.ts +5 -6
  28. package/dist/src/commands/test-harness-env.js +27 -7
  29. package/dist/src/commands/test-modes.d.ts +2 -4
  30. package/dist/src/commands/test-register.js +11 -5
  31. package/dist/src/commands/test-run.d.ts +2 -0
  32. package/dist/src/commands/test-run.js +21 -1
  33. package/dist/src/commands/test-verdict.d.ts +2 -2
  34. package/dist/src/commands/test.js +83 -211
  35. package/dist/src/core/build-baseline-types.d.ts +12 -14
  36. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  37. package/dist/src/core/build-baseline-validate.js +53 -0
  38. package/dist/src/core/build-baseline.d.ts +4 -3
  39. package/dist/src/core/build-baseline.js +23 -11
  40. package/dist/src/core/component-source-guard.d.ts +20 -0
  41. package/dist/src/core/component-source-guard.js +68 -0
  42. package/dist/src/core/coverage-extend.d.ts +5 -34
  43. package/dist/src/core/coverage-extend.js +26 -37
  44. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  45. package/dist/src/core/engine-write-boundary.js +41 -0
  46. package/dist/src/core/furnace-apply-helpers.js +8 -2
  47. package/dist/src/core/furnace-apply.js +7 -0
  48. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  49. package/dist/src/core/furnace-validate.js +0 -43
  50. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  51. package/dist/src/core/gecko-dom-types.js +17 -0
  52. package/dist/src/core/lit-template-regions.d.ts +4 -0
  53. package/dist/src/core/lit-template-regions.js +50 -0
  54. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  55. package/dist/src/core/mach-build-artifacts.js +13 -7
  56. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  57. package/dist/src/core/mach-resource-shim.js +2 -0
  58. package/dist/src/core/marionette-port.d.ts +2 -0
  59. package/dist/src/core/marionette-port.js +38 -53
  60. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  61. package/dist/src/core/mochitest-server-port.js +20 -17
  62. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  63. package/dist/src/core/objdir-maintenance.js +167 -0
  64. package/dist/src/core/patch-lint-checkjs.js +2 -1
  65. package/dist/src/core/patch-parse.js +8 -6
  66. package/dist/src/core/preflight-wait.d.ts +2 -0
  67. package/dist/src/core/preflight-wait.js +24 -0
  68. package/dist/src/core/process-owner.d.ts +9 -0
  69. package/dist/src/core/process-owner.js +43 -0
  70. package/dist/src/core/test-harness-crash.d.ts +1 -1
  71. package/dist/src/core/test-harness-crash.js +5 -5
  72. package/dist/src/core/test-host-state.d.ts +14 -0
  73. package/dist/src/core/test-host-state.js +80 -0
  74. package/dist/src/core/test-profile-files.d.ts +17 -0
  75. package/dist/src/core/test-profile-files.js +75 -0
  76. package/dist/src/core/test-stale-check.d.ts +6 -7
  77. package/dist/src/core/test-stale-check.js +9 -32
  78. package/dist/src/core/token-category.d.ts +7 -26
  79. package/dist/src/core/token-category.js +91 -117
  80. package/dist/src/core/token-inventory.js +12 -7
  81. package/dist/src/core/typecheck.js +6 -6
  82. package/dist/src/types/commands/options.d.ts +14 -3
  83. package/dist/src/utils/assert.js +2 -7
  84. package/dist/src/utils/child-env.d.ts +0 -3
  85. package/dist/src/utils/child-env.js +0 -3
  86. package/dist/src/utils/concurrency.d.ts +2 -2
  87. package/dist/src/utils/concurrency.js +15 -4
  88. package/dist/src/utils/fs.js +11 -1
  89. package/dist/src/utils/options.d.ts +16 -1
  90. package/dist/src/utils/options.js +5 -3
  91. package/dist/src/utils/paths.js +3 -2
  92. package/dist/src/utils/process-group.d.ts +1 -2
  93. package/dist/src/utils/process-group.js +1 -2
  94. package/dist/src/utils/process-input.js +0 -3
  95. package/dist/src/utils/process.d.ts +3 -2
  96. package/dist/src/utils/process.js +19 -7
  97. package/docs/configuration.md +12 -11
  98. package/docs/lifecycle-invariants.md +9 -5
  99. package/docs/machine-output.md +36 -19
  100. package/docs/patch-workflow.md +15 -0
  101. package/docs/testing.md +77 -12
  102. package/package.json +6 -5
@@ -14,12 +14,6 @@ import { join } from 'node:path';
14
14
  import { GeneralError } from '../errors/base.js';
15
15
  import { pathExists, readText } from '../utils/fs.js';
16
16
  import { escapeRegex } from '../utils/regex.js';
17
- function singleLineBannerPattern(category) {
18
- return new RegExp(`\\/\\*\\s*=+\\s*${escapeRegex(category)}\\s*=+\\s*\\*\\/`);
19
- }
20
- function multiLineBlockNameMatches(blockLine, category) {
21
- return blockLine.replace(/^\s*\*\s*/, '').trim() === category;
22
- }
23
17
  /**
24
18
  * Body of the first `/* … *\/` comment on `line`, or `undefined` when the
25
19
  * line carries no closed block comment.
@@ -85,37 +79,8 @@ function singleLineBannerName(line) {
85
79
  * agree on what "exists" means.
86
80
  */
87
81
  export function categoryHeaderExists(lines, category) {
88
- const singleLinePattern = singleLineBannerPattern(category);
89
- for (const [i, line] of lines.entries()) {
90
- if (singleLinePattern.test(line)) {
91
- return true;
92
- }
93
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
94
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
95
- const blockLine = lines[j] ?? '';
96
- if (multiLineBlockNameMatches(blockLine, category)) {
97
- return true;
98
- }
99
- if (/\*\//.test(blockLine))
100
- break;
101
- }
102
- }
103
- }
104
- return false;
82
+ return parseTokenCategorySections(lines).some((section) => section.name === category);
105
83
  }
106
- /**
107
- * Scans a tokens CSS file for category header comments and returns the
108
- * category names in document order. Used to enrich the "category not
109
- * found" error body with concrete alternatives the operator can copy.
110
- *
111
- * Mirrors the shapes `findCategorySection` recognises:
112
- * - Single-line: `/* = Foo = *\/`
113
- * - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
114
- * closing `*\/`.
115
- *
116
- * This helper exists as a pure inspector. It never throws on malformed
117
- * headers and silently skips shapes it cannot parse.
118
- */
119
84
  /**
120
85
  * Category name declared by the banner starting at `index`, or `undefined`
121
86
  * when that line opens no banner.
@@ -128,7 +93,7 @@ export function categoryHeaderExists(lines, category) {
128
93
  * @param index - 0-based line index to test
129
94
  * @returns The declared category name, or undefined
130
95
  */
131
- export function categoryBannerNameAt(lines, index) {
96
+ function categoryBannerNameAt(lines, index) {
132
97
  const line = lines[index] ?? '';
133
98
  const extracted = singleLineBannerName(line);
134
99
  if (extracted !== undefined)
@@ -149,13 +114,40 @@ export function categoryBannerNameAt(lines, index) {
149
114
  return undefined;
150
115
  }
151
116
  function discoverCategoryHeaders(lines) {
152
- const categories = new Set();
153
- for (let i = 0; i < lines.length; i++) {
154
- const name = categoryBannerNameAt(lines, i);
155
- if (name !== undefined)
156
- categories.add(name);
117
+ return [...new Set(parseTokenCategorySections(lines).map((section) => section.name))];
118
+ }
119
+ /** Parses category regions owned by the base root, including nested CSS rules. */
120
+ export function parseTokenCategorySections(lines) {
121
+ const bounds = findBaseRootBounds(lines);
122
+ if (bounds === undefined || bounds.close === -1)
123
+ return [];
124
+ const masked = maskCommentLines(lines);
125
+ const sections = [];
126
+ let current;
127
+ let bodyStarted = false;
128
+ for (let index = bounds.open + 1; index < bounds.close; index++) {
129
+ const line = lines[index] ?? '';
130
+ const name = categoryBannerNameAt(lines, index);
131
+ const boundary = name !== undefined || isSingleLineBannerLine(line);
132
+ if (!boundary) {
133
+ if ((masked[index] ?? '').trim())
134
+ bodyStarted = true;
135
+ continue;
136
+ }
137
+ // A decorative rule immediately after a named header belongs to that
138
+ // header. A later rule, after declarations, ends its section.
139
+ if (name === undefined && current && !bodyStarted)
140
+ continue;
141
+ if (current)
142
+ current.sectionEnd = index;
143
+ current = undefined;
144
+ if (name !== undefined) {
145
+ current = { name, categoryLine: index, sectionEnd: bounds.close };
146
+ bodyStarted = false;
147
+ sections.push(current);
148
+ }
157
149
  }
158
- return [...categories];
150
+ return sections;
159
151
  }
160
152
  /**
161
153
  * Asserts the category banner exists (or that creation was requested).
@@ -204,30 +196,9 @@ export function declareCategoryBanner(lines, category, tokensCssPath) {
204
196
  }
205
197
  /** Locates and bounds the named category section. Throws when absent. */
206
198
  export function findCategorySection(lines, category, tokensCssPath) {
207
- const singleLinePattern = singleLineBannerPattern(category);
208
- let categoryLine = -1;
209
- for (const [i, line] of lines.entries()) {
210
- // Check single-line format: /* = Category = */
211
- if (singleLinePattern.test(line)) {
212
- categoryLine = i;
213
- break;
214
- }
215
- // Check multi-line format: line opens a block comment with === but does not close it
216
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
217
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
218
- const blockLine = lines[j] ?? '';
219
- if (multiLineBlockNameMatches(blockLine, category)) {
220
- categoryLine = i;
221
- break;
222
- }
223
- if (/\*\//.test(blockLine))
224
- break;
225
- }
226
- if (categoryLine !== -1)
227
- break;
228
- }
229
- }
230
- if (categoryLine === -1) {
199
+ const sections = parseTokenCategorySections(lines);
200
+ const section = sections.find((entry) => entry.name === category);
201
+ if (section === undefined) {
231
202
  const discoveredCategories = discoverCategoryHeaders(lines);
232
203
  const available = discoveredCategories.length > 0
233
204
  ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
@@ -236,45 +207,73 @@ export function findCategorySection(lines, category, tokensCssPath) {
236
207
  `${available}\n\n` +
237
208
  'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
238
209
  }
239
- // Find the end of this category section (next section header or closing })
240
- // Skip past the current header block first
241
- let scanStart = categoryLine + 1;
242
- for (let i = categoryLine + 1; i < lines.length; i++) {
243
- const line = lines[i] ?? '';
244
- if (/^\s*\/\*\s*=/.test(line) || /^\s*\*\s*=/.test(line) || /^\s*\*\//.test(line)) {
245
- scanStart = i + 1;
210
+ return { categoryLine: section.categoryLine, sectionEnd: section.sectionEnd };
211
+ }
212
+ /**
213
+ * Drops complete quoted strings from one CSS line in linear time. An
214
+ * unterminated quote is kept, and once one fails to close every later quote
215
+ * of that kind fails too, so no start position is ever rescanned.
216
+ */
217
+ function stripCssStrings(line) {
218
+ let out = '';
219
+ const unterminated = new Set();
220
+ for (let i = 0; i < line.length; i++) {
221
+ const quote = line.charAt(i);
222
+ if ((quote !== '"' && quote !== "'") || unterminated.has(quote)) {
223
+ out += quote;
246
224
  continue;
247
225
  }
248
- break;
249
- }
250
- let sectionEnd = lines.length;
251
- for (let i = scanStart; i < lines.length; i++) {
252
- const line = lines[i] ?? '';
253
- if (isSingleLineBannerLine(line) ||
254
- (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
255
- /^\s*\}/.test(line)) {
256
- sectionEnd = i;
257
- break;
226
+ let end = i + 1;
227
+ while (end < line.length && line.charAt(end) !== quote)
228
+ end += line.charAt(end) === '\\' ? 2 : 1;
229
+ if (end >= line.length) {
230
+ unterminated.add(quote);
231
+ out += quote;
232
+ }
233
+ else {
234
+ i = end;
258
235
  }
259
236
  }
260
- return { categoryLine, sectionEnd };
237
+ return out;
261
238
  }
262
239
  /** 0-based open/close line indices of the base `:root {` block. */
263
240
  export function findBaseRootBounds(lines) {
264
241
  const open = lines.findIndex((line) => /:root\s*\{/.test(line));
265
242
  if (open === -1)
266
243
  return undefined;
267
- for (let i = open + 1; i < lines.length; i++) {
268
- if (/^\s*\}/.test(lines[i] ?? '')) {
269
- return { open, close: i };
244
+ const masked = maskCommentLines(lines);
245
+ let depth = 0;
246
+ for (let i = open; i < lines.length; i++) {
247
+ // Ignore braces in CSS strings (including data URLs).
248
+ const code = stripCssStrings(masked[i] ?? '');
249
+ for (const char of code) {
250
+ if (char === '{')
251
+ depth++;
252
+ if (char === '}' && --depth === 0)
253
+ return { open, close: i };
270
254
  }
271
255
  }
272
256
  return { open, close: -1 };
273
257
  }
274
258
  /** Masks block-comment content per line so declarations inside comments never match. */
275
259
  export function maskCommentLines(lines) {
276
- const masked = lines.join('\n').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
277
- return masked.split('\n');
260
+ const source = lines.join('\n');
261
+ const parts = [];
262
+ let cursor = 0;
263
+ for (;;) {
264
+ const open = source.indexOf('/*', cursor);
265
+ if (open === -1)
266
+ break;
267
+ const close = source.indexOf('*/', open + 2);
268
+ // An unfinished edit has no complete comment to mask. Searching only
269
+ // once prevents restarting a regex at every repeated comment opener.
270
+ if (close === -1)
271
+ break;
272
+ parts.push(source.slice(cursor, open), source.slice(open, close + 2).replace(/[^\n]/g, ' '));
273
+ cursor = close + 2;
274
+ }
275
+ parts.push(source.slice(cursor));
276
+ return parts.join('').split('\n');
278
277
  }
279
278
  /**
280
279
  * Finds an existing declaration of `tokenName` inside the base `:root`
@@ -288,38 +287,13 @@ export function findTokenDeclarationInRoot(lines, tokenName) {
288
287
  if (bounds === undefined || bounds.close === -1)
289
288
  return undefined;
290
289
  const masked = maskCommentLines(lines);
290
+ const sections = parseTokenCategorySections(lines);
291
291
  const declPattern = new RegExp(`^\\s*${escapeRegex(tokenName)}\\s*:`);
292
292
  for (let i = bounds.open + 1; i < bounds.close; i++) {
293
293
  if (!declPattern.test(masked[i] ?? ''))
294
294
  continue;
295
- const category = findSectionNameAbove(lines, i);
295
+ const category = sections.find((section) => i > section.categoryLine && i < section.sectionEnd)?.name;
296
296
  return category === undefined ? { line: i + 1 } : { line: i + 1, category };
297
297
  }
298
298
  return undefined;
299
299
  }
300
- /** Nearest category banner name above `lineIndex`, if any. */
301
- function findSectionNameAbove(lines, lineIndex) {
302
- const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
303
- for (let i = lineIndex - 1; i >= 0; i--) {
304
- const line = lines[i] ?? '';
305
- const singleMatch = singleLinePattern.exec(line);
306
- if (singleMatch?.[1]) {
307
- const extracted = singleMatch[1].trim();
308
- if (extracted.length > 0 && !/^=+$/.test(extracted))
309
- return extracted;
310
- continue;
311
- }
312
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
313
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
314
- const blockLine = lines[j] ?? '';
315
- if (/\*\//.test(blockLine))
316
- break;
317
- const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
318
- if (trimmed.length === 0 || /^=+$/.test(trimmed))
319
- continue;
320
- return trimmed;
321
- }
322
- }
323
- }
324
- return undefined;
325
- }
@@ -8,12 +8,12 @@
8
8
  * categories a project actually has, so the only way to name one correctly
9
9
  * was to hand-parse a neighbouring `= Category =` banner out of the file.
10
10
  * This module is that report. It reuses the banner and
11
- * `:root`-bounds primitives from `token-category.ts` rather than parsing
11
+ * category-region model from `token-category.ts` rather than parsing
12
12
  * tokens CSS a second way: two readers that disagree on what a banner is
13
13
  * would put `token list` and `token add` in different sections of the same
14
14
  * file.
15
15
  */
16
- import { categoryBannerNameAt, findBaseRootBounds, maskCommentLines } from './token-category.js';
16
+ import { findBaseRootBounds, maskCommentLines, parseTokenCategorySections, } from './token-category.js';
17
17
  /** Matches a custom-property declaration and captures its name and value. */
18
18
  const DECLARATION_PATTERN = /^\s*(--[A-Za-z0-9_-]+)\s*:\s*([^;]*);?/;
19
19
  /**
@@ -32,14 +32,18 @@ export function collectTokenInventory(lines) {
32
32
  if (bounds === undefined || bounds.close === -1)
33
33
  return [];
34
34
  const masked = maskCommentLines(lines);
35
+ const sections = parseTokenCategorySections(lines);
36
+ const headers = new Map(sections.map((section) => [section.categoryLine, section]));
35
37
  const groups = [];
36
38
  let current;
39
+ let sectionEnd = bounds.close;
37
40
  for (let i = bounds.open + 1; i < bounds.close; i++) {
38
- // Banners are read from the raw lines: `maskCommentLines` blanks comment
39
- // bodies, which is exactly what a banner is.
40
- const banner = categoryBannerNameAt(lines, i);
41
- if (banner !== undefined) {
42
- current = { category: banner, tokens: [] };
41
+ if (i >= sectionEnd)
42
+ current = undefined;
43
+ const section = headers.get(i);
44
+ if (section !== undefined) {
45
+ current = { category: section.name, tokens: [] };
46
+ sectionEnd = section.sectionEnd;
43
47
  groups.push(current);
44
48
  continue;
45
49
  }
@@ -48,6 +52,7 @@ export function collectTokenInventory(lines) {
48
52
  continue;
49
53
  if (current === undefined) {
50
54
  current = { category: null, tokens: [] };
55
+ sectionEnd = bounds.close;
51
56
  groups.push(current);
52
57
  }
53
58
  current.tokens.push({ name: match[1], line: i + 1, value: (match[2] ?? '').trim() });
@@ -26,6 +26,7 @@ import { sha256Hex } from '../utils/hash.js';
26
26
  import { verbose } from '../utils/logger.js';
27
27
  import { getPackageVersion } from '../utils/package-root.js';
28
28
  import { normalizePathSlashes } from '../utils/paths.js';
29
+ import { withGeckoDomTypes } from './gecko-dom-types.js';
29
30
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
30
31
  /**
31
32
  * Sentinel string surfaced as a `TypecheckIssue.message` when the
@@ -252,7 +253,7 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
252
253
  // filtered out below, so the pass reports failures that do not exist.
253
254
  const shimPath = normalizePathSlashes(resolve(projectDir, `.fireforge-${SHIM_FILENAME}`));
254
255
  const rootFiles = [...parsed.fileNames, shimPath];
255
- const defaultHost = ts.createCompilerHost(options);
256
+ const defaultHost = withGeckoDomTypes(ts, ts.createCompilerHost(options));
256
257
  const host = {
257
258
  ...defaultHost,
258
259
  getSourceFile(fileName, languageVersion, onError, shouldCreate) {
@@ -293,11 +294,10 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
293
294
  program = ts.createProgram(rootFiles, options, host);
294
295
  }
295
296
  else {
296
- const builder = ts.createIncrementalProgram({
297
- rootNames: rootFiles,
298
- options: { ...options, incremental: true, tsBuildInfoFile: buildInfoFile },
299
- host: withSourceFileVersions(host),
300
- });
297
+ // Always check the entire project in root order. Restoring TypeScript's
298
+ // per-file semantic cache makes checkJs inference depend on the first
299
+ // invalidated importer and can both invent and hide diagnostics.
300
+ const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram(rootFiles, { ...options, incremental: true, tsBuildInfoFile: buildInfoFile }, withSourceFileVersions(host));
301
301
  program = builder;
302
302
  // Under noEmit, emit() writes the build info and nothing else.
303
303
  writeBuildInfo = () => {
@@ -109,6 +109,8 @@ export interface SourceSetOptions {
109
109
  * Options for the build command.
110
110
  */
111
111
  export interface BuildOptions extends EngineLockWaitable {
112
+ /** Wait for the objdir browser before deploying/building. */
113
+ waitBrowser?: number | boolean;
112
114
  /** Fast UI-only rebuild */
113
115
  ui?: boolean;
114
116
  /** Number of parallel jobs */
@@ -368,6 +370,8 @@ export interface ReExportOptions extends EngineLockWaitable {
368
370
  * rather than hidden.
369
371
  */
370
372
  expectUnmanaged?: string[];
373
+ /** Explicitly retire named absent patch-created files in a scan-less export. */
374
+ expectRemoved?: string[];
371
375
  }
372
376
  /**
373
377
  * Options for the `fireforge patch tier` subcommand. Sets or clears the
@@ -551,6 +555,12 @@ export interface RunOptions {
551
555
  * Options for the test command.
552
556
  */
553
557
  export interface TestOptions extends EngineLockWaitable {
558
+ /** Queue behind a browser using this objdir; bare flag means 60 seconds. */
559
+ waitBrowser?: number | boolean;
560
+ /** Queue behind the mochitest/Marionette port holder. */
561
+ waitPort?: number | boolean;
562
+ /** Copy a file into the profile at a relative destination (repeatable source=destination). */
563
+ profileFile?: string[];
554
564
  /** Run tests in headless mode */
555
565
  headless?: boolean;
556
566
  /**
@@ -675,9 +685,10 @@ export interface TestOptions extends EngineLockWaitable {
675
685
  */
676
686
  perfSamples?: string;
677
687
  /**
678
- * Seeded file-order shuffle for mochitest runs. `true` draws a fresh seed;
679
- * a number replays one. Forwarded as mach `--shuffle` with the seed
680
- * exported as `FIREFORGE_SHUFFLE_SEED` and stamped on the verdict line.
688
+ * Seeded isolated path-argument order for mochitest runs. `true` draws a
689
+ * fresh seed; a number replays the shard permutation. Native mach
690
+ * `--shuffle` within a shard remains unseeded. Also exported as
691
+ * `FIREFORGE_SHUFFLE_SEED` for custom task code and recorded on the verdict.
681
692
  */
682
693
  shuffle?: number | boolean;
683
694
  }
@@ -11,13 +11,8 @@
11
11
  * says the opposite: nothing the operator could have done would have
12
12
  * prevented it.
13
13
  *
14
- * Every check routes through a helper here rather than being open-coded
15
- * as `if (!condition) throw`. An inline throw adds a
16
- * permanently-uncovered branch to every function it appears in, and the
17
- * per-module coverage floors in `scripts/check-coverage-thresholds.mjs`
18
- * would pay for it in ~40 places. Routed through a call, the branch lives
19
- * here once and is covered here once, and the `complexity` ceiling in
20
- * `eslint.config.js` does not see it at all.
14
+ * Shared helpers provide consistent classification and TypeScript narrowing,
15
+ * and keep expensive failure descriptions lazy on successful paths.
21
16
  *
22
17
  * This module is a leaf: it imports the error class and nothing else, per
23
18
  * the dependency-direction note in `src/utils/errors.ts`.
@@ -1,8 +1,5 @@
1
1
  /**
2
2
  * Environment construction for spawned children.
3
- *
4
- * Its own module rather than a helper inside `process.ts` only because that
5
- * file sits on a 500-line budget. The logic belongs to the exec layer.
6
3
  */
7
4
  /** The `env` / `envUnset` slice of exec options this module needs. */
8
5
  interface ChildEnvOptions {
@@ -1,9 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Environment construction for spawned children.
4
- *
5
- * Its own module rather than a helper inside `process.ts` only because that
6
- * file sits on a 500-line budget. The logic belongs to the exec layer.
7
4
  */
8
5
  /**
9
6
  * The child's environment: `process.env`, with `env` merged over it, then
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
3
  * (status classification, import's unmanaged-dirty guard, re-export scans,
4
- * the dry-run purity guard). Kept tiny: one order-preserving mapper, no
5
- * queues, no cancellation.
4
+ * the dry-run purity guard). On failure, stop scheduling new items and await
5
+ * every started operation before rejecting, so callers can safely clean up.
6
6
  */
7
7
  /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
8
  export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
3
  * (status classification, import's unmanaged-dirty guard, re-export scans,
4
- * the dry-run purity guard). Kept tiny: one order-preserving mapper, no
5
- * queues, no cancellation.
4
+ * the dry-run purity guard). On failure, stop scheduling new items and await
5
+ * every started operation before rejecting, so callers can safely clean up.
6
6
  */
7
7
  /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
8
  export async function mapWithConcurrency(items, limit, fn) {
9
+ if (!Number.isSafeInteger(limit) || limit < 1) {
10
+ throw new RangeError('Concurrency limit must be a positive integer');
11
+ }
9
12
  const results = new Array(items.length);
10
13
  let next = 0;
14
+ const state = {};
11
15
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
12
- for (;;) {
16
+ while (!state.failure) {
13
17
  const index = next++;
14
18
  if (index >= items.length)
15
19
  return;
16
- results[index] = await fn(items[index], index);
20
+ try {
21
+ results[index] = await fn(items[index], index);
22
+ }
23
+ catch (error) {
24
+ state.failure ??= { error };
25
+ }
17
26
  }
18
27
  });
19
28
  await Promise.all(workers);
29
+ if (state.failure)
30
+ throw state.failure.error;
20
31
  return results;
21
32
  }
@@ -99,7 +99,17 @@ export async function isSymlink(path) {
99
99
  */
100
100
  export async function copyFile(src, dest) {
101
101
  await ensureParentDir(dest);
102
- await fsCopyFile(src, dest);
102
+ // Never follow a destination symlink or truncate a shared hardlink:
103
+ // build/deploy copies must not write through into components/ or a peer.
104
+ const tempPath = createAtomicTempPath(dest);
105
+ try {
106
+ await fsCopyFile(src, tempPath);
107
+ await renameWithRetries(tempPath, dest);
108
+ await syncParentDir(dest);
109
+ }
110
+ finally {
111
+ await rm(tempPath, { force: true });
112
+ }
103
113
  }
104
114
  /**
105
115
  * Reads a JSON file and parses it.
@@ -89,7 +89,22 @@ export declare function resolveWaitLockSeconds(value: string | number | boolean
89
89
  */
90
90
  export declare function sessionWaitLockMs(): number | undefined;
91
91
  /**
92
- * Recursively gives every command in the tree a `--wait-lock` flag: the
92
+ * Registers `--wait-lock [seconds]` on a command that takes no lock, so a
93
+ * scripted sequence can blanket-append the flag without hitting a usage
94
+ * error. A command that rejects it with "unknown option" kills the whole
95
+ * sequence with a usage error instead of a lock message.
96
+ *
97
+ * The flag is accepted and ignored here, never silently repurposed, and
98
+ * the help text says so, so nobody reads its presence as evidence that this
99
+ * command waits for anything.
100
+ *
101
+ * The value is parsed and validated exactly as the honoring variant does:
102
+ * `--wait-lock nonsense` must still be a usage error everywhere, or the
103
+ * uniformity would be a lie.
104
+ */
105
+ export declare function addAcceptedWaitLockOption(command: Command): Command;
106
+ /**
107
+ * Recursively gives every leaf command in the tree a `--wait-lock` flag: the
93
108
  * honoring registration where one already exists, the accept-and-ignore
94
109
  * one everywhere else.
95
110
  */
@@ -182,7 +182,7 @@ export function sessionWaitLockMs() {
182
182
  * `--wait-lock nonsense` must still be a usage error everywhere, or the
183
183
  * uniformity would be a lie.
184
184
  */
185
- function addAcceptedWaitLockOption(command) {
185
+ export function addAcceptedWaitLockOption(command) {
186
186
  return command.option('--wait-lock [seconds]', 'Accepted for scripting uniformity and ignored: this command takes no FireForge lock', commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
187
187
  }
188
188
  /**
@@ -194,13 +194,15 @@ function hasWaitLockOption(command) {
194
194
  return command.options.some((option) => option.long === '--wait-lock');
195
195
  }
196
196
  /**
197
- * Recursively gives every command in the tree a `--wait-lock` flag: the
197
+ * Recursively gives every leaf command in the tree a `--wait-lock` flag: the
198
198
  * honoring registration where one already exists, the accept-and-ignore
199
199
  * one everywhere else.
200
200
  */
201
201
  export function ensureWaitLockOptionEverywhere(command) {
202
202
  for (const sub of command.commands) {
203
- if (!hasWaitLockOption(sub))
203
+ // Commander groups otherwise capture a trailing leaf option before the
204
+ // action can see it. Register the uniform flag only on actionable leaves.
205
+ if (sub.commands.length === 0 && !hasWaitLockOption(sub))
204
206
  addAcceptedWaitLockOption(sub);
205
207
  ensureWaitLockOptionEverywhere(sub);
206
208
  }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/;
4
4
  const RELATIVE_PATH_ROOT = resolve('/__fireforge_path_root__');
5
5
  /**
@@ -55,7 +55,8 @@ export function isPathInsideRoot(root, candidate) {
55
55
  : resolve(resolvedRoot, candidate);
56
56
  const relativePath = relative(resolvedRoot, resolvedCandidate);
57
57
  return (relativePath === '' ||
58
- (!relativePath.startsWith('..') &&
58
+ (relativePath !== '..' &&
59
+ !relativePath.startsWith(`..${sep}`) &&
59
60
  !isAbsolute(relativePath) &&
60
61
  !WINDOWS_ABSOLUTE_PATH.test(relativePath)));
61
62
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Process-group kill and post-run sweep helpers for the exec layer. Split
3
- * out of `process.ts` to keep that file within the per-file line budget.
2
+ * Process-group kill and post-run sweep helpers for the exec layer.
4
3
  * Spawn-based rather than `exec`-based so the two modules do not import
5
4
  * each other cyclically.
6
5
  */
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Process-group kill and post-run sweep helpers for the exec layer. Split
4
- * out of `process.ts` to keep that file within the per-file line budget.
3
+ * Process-group kill and post-run sweep helpers for the exec layer.
5
4
  * Spawn-based rather than `exec`-based so the two modules do not import
6
5
  * each other cyclically.
7
6
  */
@@ -1,9 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Stdin-fed command execution with byte-exact stdout.
4
- *
5
- * Its own module rather than a function in `process.ts` only because that
6
- * file sits on a 500-line budget. The logic belongs to the exec layer.
7
4
  */
8
5
  import { spawn } from 'node:child_process';
9
6
  import { buildChildEnv } from './child-env.js';
@@ -150,8 +150,9 @@ export declare function execStream(command: string, args: string[], options?: St
150
150
  * Registers a child whose shutdown {@link waitForActiveChildShutdown} must
151
151
  * wait for, and returns the handle that deregisters it.
152
152
  *
153
- * @returns `settle()`. Call from both the child's `close` and `error`
154
- * handlers so a dead child never holds the shutdown wait open
153
+ * @returns `settle()`. Call after child closure and required teardown finish.
154
+ * An abort's `error` event does not prove closure; do not settle there.
155
+ * A failed start may settle when no live child or teardown remains.
155
156
  */
156
157
  export declare function trackChildClosure(): {
157
158
  settle: () => void;