@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +20 -121
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  6. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  7. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  8. package/dist/src/commands/doctor.d.ts +1 -1
  9. package/dist/src/commands/doctor.js +48 -62
  10. package/dist/src/commands/furnace/apply.js +2 -2
  11. package/dist/src/commands/furnace/deploy.js +2 -2
  12. package/dist/src/commands/furnace/diff.js +6 -1
  13. package/dist/src/commands/furnace/index.js +12 -6
  14. package/dist/src/commands/furnace/init.js +5 -2
  15. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  16. package/dist/src/commands/furnace/sync.js +2 -2
  17. package/dist/src/commands/import.js +6 -2
  18. package/dist/src/commands/re-export-files.js +63 -23
  19. package/dist/src/commands/re-export-scan.js +18 -2
  20. package/dist/src/commands/re-export-single.js +19 -7
  21. package/dist/src/commands/test-register.js +5 -1
  22. package/dist/src/commands/test-run.d.ts +5 -0
  23. package/dist/src/commands/test-run.js +11 -4
  24. package/dist/src/commands/test-verdict.js +10 -1
  25. package/dist/src/commands/test.js +9 -0
  26. package/dist/src/commands/token.js +33 -11
  27. package/dist/src/core/build-audit-resolve.js +12 -3
  28. package/dist/src/core/build-audit.js +6 -2
  29. package/dist/src/core/config-mutate.js +20 -19
  30. package/dist/src/core/furnace-apply-ftl.js +4 -1
  31. package/dist/src/core/furnace-apply-helpers.js +11 -5
  32. package/dist/src/core/furnace-jsconfig.js +5 -3
  33. package/dist/src/core/furnace-operation.d.ts +25 -2
  34. package/dist/src/core/furnace-operation.js +27 -2
  35. package/dist/src/core/git-base.d.ts +14 -0
  36. package/dist/src/core/git-base.js +17 -0
  37. package/dist/src/core/git.js +3 -1
  38. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  39. package/dist/src/core/mach-known-noise-filter.js +19 -0
  40. package/dist/src/core/mach.d.ts +8 -3
  41. package/dist/src/core/mach.js +47 -3
  42. package/dist/src/core/patch-lint-checkjs.js +25 -4
  43. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  44. package/dist/src/core/patch-manifest-consistency.js +96 -23
  45. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  46. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  47. package/dist/src/core/patch-manifest.d.ts +2 -1
  48. package/dist/src/core/patch-manifest.js +2 -1
  49. package/dist/src/core/run-log.d.ts +60 -0
  50. package/dist/src/core/run-log.js +183 -0
  51. package/dist/src/core/test-harness-output.d.ts +24 -1
  52. package/dist/src/core/test-harness-output.js +46 -15
  53. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  54. package/dist/src/core/test-xpcshell-retry.js +6 -1
  55. package/dist/src/core/token-category.js +61 -7
  56. package/dist/src/core/token-dark-mode.js +20 -2
  57. package/dist/src/core/token-docs.js +1 -1
  58. package/dist/src/core/token-manager.d.ts +7 -2
  59. package/dist/src/core/token-manager.js +74 -20
  60. package/dist/src/core/token-variant.d.ts +34 -0
  61. package/dist/src/core/token-variant.js +98 -8
  62. package/dist/src/core/tree-store.js +5 -1
  63. package/dist/src/core/typecheck.js +7 -1
  64. package/dist/src/types/commands/options.d.ts +53 -1
  65. package/dist/src/utils/child-env.d.ts +30 -0
  66. package/dist/src/utils/child-env.js +30 -0
  67. package/dist/src/utils/options.d.ts +28 -0
  68. package/dist/src/utils/options.js +32 -1
  69. package/dist/src/utils/process.d.ts +22 -0
  70. package/dist/src/utils/process.js +8 -7
  71. package/package.json +2 -2
@@ -11,7 +11,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
11
11
  import { assertTokenCategoryExists, categoryHeaderExists, declareCategoryBanner, findCategorySection, findTokenDeclarationInRoot, } from './token-category.js';
12
12
  import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
13
  import { addTokenToDocs } from './token-docs.js';
14
- import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
14
+ import { findVariantBlockDeclaration, insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, variantBlockQualifier, } from './token-variant.js';
15
15
  /**
16
16
  * Dark mode behaviour for a token.
17
17
  *
@@ -95,7 +95,9 @@ function normalizeVariantOption(options) {
95
95
  }
96
96
  if (options.createCategory === true) {
97
97
  throw new InvalidArgumentError('--create-category cannot be combined with --variant; variant declarations are routed ' +
98
- 'into a :root<selector> block, not a category section.', 'variant');
98
+ 'into a :root<selector> block, not a category section. Add the BASE token first ' +
99
+ '(with --create-category) to author the category, then add each variant with ' +
100
+ '--variant.', 'variant');
99
101
  }
100
102
  const result = validateVariantSelector(options.variant);
101
103
  if (!result.ok) {
@@ -103,6 +105,30 @@ function normalizeVariantOption(options) {
103
105
  }
104
106
  return result.value;
105
107
  }
108
+ /**
109
+ * Narrows {@link AddTokenOptions.category} for the base-declaration path,
110
+ * which cannot proceed without one.
111
+ *
112
+ * `--category` used to be unconditionally required, INCLUDING under
113
+ * `--variant`, where the declaration is routed into a `:root<selector>`
114
+ * block and the category names nothing about where the token lands. The
115
+ * requirement existed so the argument would not be silently discarded —
116
+ * a fair goal that produced a mandatory argument describing nothing. Under
117
+ * a variant it is now optional; everywhere else this states the reason
118
+ * rather than leaving commander to print a bare "required option not
119
+ * specified".
120
+ *
121
+ * @param options - Token options
122
+ * @returns The category
123
+ */
124
+ function requireCategory(options) {
125
+ if (options.category === undefined || options.category.trim() === '') {
126
+ throw new InvalidArgumentError('A base token declaration lands in a category section, so --category is required. ' +
127
+ 'Pass --variant <selector> instead if this declaration belongs in a ' +
128
+ ':root<selector> block, where no category applies.', 'category');
129
+ }
130
+ return options.category;
131
+ }
106
132
  /** Throws when the tokens CSS file is missing (variant mode skips category checks). */
107
133
  async function assertTokensCssExists(engineDir, tokensCssPath) {
108
134
  if (!(await pathExists(join(engineDir, tokensCssPath)))) {
@@ -112,15 +138,22 @@ async function assertTokensCssExists(engineDir, tokensCssPath) {
112
138
  /**
113
139
  * Routes a declaration into the top-level `:root<variant>` block — creating
114
140
  * the block after the base `:root` block if absent, or appending to it if
115
- * present. Idempotent within the block. Returns `{ added: false }` when the
116
- * token already lives in that block.
141
+ * present. Idempotent within the block.
142
+ *
143
+ * A skip carries the LOCATION of the existing declaration, mirroring
144
+ * {@link evaluateBaseTokenIdempotency}. The bare `{ added: false }` it used
145
+ * to return told the caller nothing, so a re-run intending to CHANGE a
146
+ * value exited 0 having changed nothing and said only "already exists" —
147
+ * while the base path had reported the category and line for the same
148
+ * situation all along.
117
149
  */
118
150
  async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant) {
119
151
  await assertTokensCssExists(engineDir, tokensCssPath);
120
152
  const filePath = join(engineDir, tokensCssPath);
121
153
  const lines = (await readText(filePath)).split('\n');
122
- if (variantBlockHasToken(lines, variant, options.tokenName))
123
- return { added: false };
154
+ const declared = findVariantBlockDeclaration(lines, variant, options.tokenName);
155
+ if (declared !== undefined)
156
+ return { added: false, existing: declared };
124
157
  const annotation = getModeAnnotation(options.mode, options.value);
125
158
  insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${options.value}; /* ${annotation} */`);
126
159
  await writeText(filePath, lines.join('\n'));
@@ -158,14 +191,17 @@ export async function validateTokenAdd(root, options) {
158
191
  validateTokenNameSyntax(options.tokenName);
159
192
  await validateTokenPrefix(root, options);
160
193
  validateDarkValue(options);
161
- // Variant mode targets a `:root<attr>` block, not a category section —
162
- // but the required --category must still name a REAL category rather
163
- // than being silently discarded (bypass 2).
194
+ // Variant mode targets a `:root<attr>` block, not a category section, so
195
+ // no category is consulted — and none is required. When one IS passed it
196
+ // is still checked, so a typo'd category is not silently accepted just
197
+ // because the flag happens to be inert here.
164
198
  if (normalizeVariantOption(options) !== undefined) {
165
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
199
+ if (options.category !== undefined && options.category.trim() !== '') {
200
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
201
+ }
166
202
  return;
167
203
  }
168
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
204
+ await assertTokenCategoryExists(engineDir, tokensCssPath, requireCategory(options), options.createCategory === true);
169
205
  }
170
206
  /**
171
207
  * Adds a design token to the CSS file and documentation.
@@ -209,16 +245,19 @@ export async function addToken(root, options) {
209
245
  // docs row). Done before the base-CSS path so an override of an existing
210
246
  // base token is not short-circuited by the global idempotency check.
211
247
  if (normalizedVariant !== undefined) {
212
- // The required --category must name a real category even though the
213
- // declaration routes into the variant block (bypass 2).
214
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
215
- const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
248
+ // A category is optional here and names nothing about where the
249
+ // declaration lands, but a category that was passed is still verified.
250
+ if (options.category !== undefined && options.category.trim() !== '') {
251
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
252
+ }
253
+ const { added, existing } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
216
254
  return {
217
255
  cssAdded: added,
218
256
  docsAdded: false,
219
257
  unmappedAdded: false,
220
258
  countUpdated: false,
221
259
  skipped: !added,
260
+ ...(existing !== undefined ? { skippedExisting: existing } : {}),
222
261
  };
223
262
  }
224
263
  // --- CSS file ---
@@ -234,7 +273,11 @@ export async function addToken(root, options) {
234
273
  };
235
274
  }
236
275
  // --- Documentation ---
237
- const docsResult = await addTokenToDocs(engineDir, options, getModeAnnotation(options.mode, options.value));
276
+ // Only the base path reaches here (variant declarations returned above),
277
+ // so a category is guaranteed — `requireCategory` already ran inside
278
+ // `addTokenToCSS`. Re-narrowing rather than asserting keeps the docs
279
+ // input's required `category` honest.
280
+ const docsResult = await addTokenToDocs(engineDir, { ...options, category: requireCategory(options) }, getModeAnnotation(options.mode, options.value));
238
281
  return {
239
282
  cssAdded,
240
283
  docsAdded: docsResult.docsAdded,
@@ -274,6 +317,16 @@ function insertThemeAttributeOverrides(lines, options) {
274
317
  const value = pick === 'dark' ? options.darkValue : options.value;
275
318
  insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${value};`);
276
319
  written.push(`:root${variant}`);
320
+ // A qualified block is matched — skipping it is the worse failure, and
321
+ // the one that shipped — but the qualifier narrows where the token
322
+ // applies, so say which selector was written through rather than
323
+ // letting the operator meet the narrowing as a theme bug later.
324
+ const qualifier = variantBlockQualifier(lines, variant);
325
+ if (qualifier !== undefined && qualifier.length > 0) {
326
+ warn(`Wrote ${options.tokenName} into the qualified block ":root${variant}${qualifier}". ` +
327
+ `The "${qualifier}" qualifier narrows where this override applies; ` +
328
+ 'add an unqualified block if it should apply to every case.');
329
+ }
277
330
  }
278
331
  return written;
279
332
  }
@@ -304,7 +357,8 @@ function insertDarkModeOverride(lines, options) {
304
357
  */
305
358
  async function addTokenToCSS(engineDir, options, tokensCssPath) {
306
359
  const filePath = join(engineDir, tokensCssPath);
307
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
360
+ const category = requireCategory(options);
361
+ await assertTokenCategoryExists(engineDir, tokensCssPath, category, options.createCategory === true);
308
362
  const content = await readText(filePath);
309
363
  const lines = content.split('\n');
310
364
  // Per-category idempotency: a declaration in the TARGET
@@ -324,11 +378,11 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
324
378
  // token insertion — the file is written exactly once, so a failure
325
379
  // between "banner declared" and "token inserted" cannot occur.
326
380
  let categoryCreated = false;
327
- if (options.createCategory === true && !categoryHeaderExists(lines, options.category)) {
328
- declareCategoryBanner(lines, options.category, tokensCssPath);
381
+ if (options.createCategory === true && !categoryHeaderExists(lines, category)) {
382
+ declareCategoryBanner(lines, category, tokensCssPath);
329
383
  categoryCreated = true;
330
384
  }
331
- const { categoryLine, sectionEnd } = findCategorySection(lines, options.category, tokensCssPath);
385
+ const { categoryLine, sectionEnd } = findCategorySection(lines, category, tokensCssPath);
332
386
  // Build the insertion lines
333
387
  const insertLines = [];
334
388
  if (options.description) {
@@ -9,6 +9,7 @@
9
9
  * `:root<variant>` block so `token-manager.ts` can splice a declaration into
10
10
  * it, keeping all token authoring in the CLI.
11
11
  */
12
+ import type { TokenDeclarationLocation } from './token-category.js';
12
13
  /**
13
14
  * Outcome of {@link validateVariantSelector}. `ok: true` carries the
14
15
  * normalized (quoted) selector fragment; `ok: false` carries a
@@ -36,8 +37,41 @@ export declare function validateVariantSelector(raw: unknown): VariantValidation
36
37
  * the block, which is only wanted for the explicit `--variant` flow.
37
38
  */
38
39
  export declare function variantBlockExists(lines: string[], variant: string): boolean;
40
+ /**
41
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
42
+ * `undefined` when no block matched. `''` for an unqualified block.
43
+ *
44
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
45
+ * writing into that block scopes the declaration to the qualified case, and
46
+ * that may not be what the operator meant. Matching it is still the right
47
+ * call — silently skipping it is the worse failure, and the one that
48
+ * shipped — but the override path uses this to say out loud which selector
49
+ * it wrote through, so the narrowing is a visible decision rather than a
50
+ * theme bug discovered later.
51
+ *
52
+ * @param lines - Tokens CSS split into lines
53
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
54
+ * @returns The qualifier, or undefined when no block matched
55
+ */
56
+ export declare function variantBlockQualifier(lines: string[], variant: string): string | undefined;
39
57
  /** True when the `:root<variant>` block already declares `tokenName`. */
40
58
  export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
59
+ /**
60
+ * Locates an existing declaration of `tokenName` inside the
61
+ * `:root<variant>` block, or `undefined` when the block has none (or does
62
+ * not exist).
63
+ *
64
+ * The located form of {@link variantBlockHasToken}: the variant add path
65
+ * reports a skip the way the base path does — naming the block and the
66
+ * line — instead of returning a bare boolean that leaves a no-op re-run
67
+ * indistinguishable from a write.
68
+ *
69
+ * @param lines - Tokens CSS split into lines
70
+ * @param variant - Attribute selector fragment
71
+ * @param tokenName - Full token name including prefix
72
+ * @returns The declaration's 1-based line, or undefined
73
+ */
74
+ export declare function findVariantBlockDeclaration(lines: string[], variant: string, tokenName: string): TokenDeclarationLocation | undefined;
41
75
  /**
42
76
  * Splices `declLine` into the `:root<variant>` block, creating the block
43
77
  * (after the base `:root` block) when absent or appending after the last
@@ -52,6 +52,48 @@ export function validateVariantSelector(raw) {
52
52
  function canonicalSelector(selector) {
53
53
  return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
54
  }
55
+ /**
56
+ * Splits the `:root[…]` selector on `line` into its attribute run and any
57
+ * trailing qualifier, or `null` when the line carries none.
58
+ *
59
+ * Index arithmetic rather than `/:root\[[^{]*\]/`: `[^{]` also matches `]`,
60
+ * so that pattern backtracks quadratically on a line repeating `:root[`
61
+ * (CodeQL `js/polynomial-redos`). This walk is linear and allocation-free
62
+ * per group.
63
+ *
64
+ * It consumes CONSECUTIVE `[…]` groups and stops at the first character that
65
+ * is not `[`. The previous implementation instead sliced to the LAST `]`
66
+ * before the brace, which kept multi-attribute selectors comparing as a
67
+ * whole — that property is preserved here, since consecutive groups are all
68
+ * consumed — but it also swallowed a pseudo-class tail. On
69
+ * `:root[data-theme="light"]:not([data-private]) {` the last `]` is the one
70
+ * inside `:not(…)`, so the computed fragment was
71
+ * `:root[data-theme="light"]:not([data-private]` and the canonical
72
+ * comparison could never match. The unqualified
73
+ * `:root[data-theme="dark"] { }` beside it matched fine, and that asymmetry
74
+ * WAS the bug: an override add mirrored itself into the dark block and
75
+ * silently skipped the light one, which is precisely the half-finished
76
+ * themed edit `THEME_ATTRIBUTE_VARIANTS` exists to prevent.
77
+ */
78
+ function rootAttributeSelector(line) {
79
+ const start = line.indexOf(':root[');
80
+ if (start === -1)
81
+ return null;
82
+ const brace = line.indexOf('{', start);
83
+ const limit = brace === -1 ? line.length : brace;
84
+ let cursor = start + ':root'.length;
85
+ let end = cursor;
86
+ while (cursor < limit && line[cursor] === '[') {
87
+ const close = line.indexOf(']', cursor);
88
+ if (close === -1 || close >= limit)
89
+ break;
90
+ cursor = close + 1;
91
+ end = cursor;
92
+ }
93
+ if (end === start + ':root'.length)
94
+ return null;
95
+ return { attributes: line.slice(start, end), qualifier: line.slice(end, limit).trim() };
96
+ }
55
97
  /**
56
98
  * Finds the top-level `:root<variant>` block whose attribute selector
57
99
  * matches `variant` (compared canonically). Returns the opening-brace and
@@ -66,8 +108,8 @@ function findVariantBlock(lines, variant) {
66
108
  const want = canonicalSelector(`:root${variant}`);
67
109
  for (let i = 0; i < stripped.length; i++) {
68
110
  const line = stripped[i] ?? '';
69
- const match = /:root\[[^{]*\]/.exec(line);
70
- if (!match || canonicalSelector(match[0]) !== want)
111
+ const selector = rootAttributeSelector(line);
112
+ if (selector === null || canonicalSelector(selector.attributes) !== want)
71
113
  continue;
72
114
  let openLine = /\{/.test(line) ? i : -1;
73
115
  if (openLine === -1) {
@@ -86,7 +128,7 @@ function findVariantBlock(lines, variant) {
86
128
  const close = findBlockCloseIndex(stripped, openLine);
87
129
  if (close === -1)
88
130
  continue;
89
- return { open: openLine, close };
131
+ return { open: openLine, close, qualifier: selector.qualifier };
90
132
  }
91
133
  return null;
92
134
  }
@@ -116,16 +158,64 @@ function findVariantBlockInsertionPoint(lines) {
116
158
  export function variantBlockExists(lines, variant) {
117
159
  return findVariantBlock(lines, variant) !== null;
118
160
  }
161
+ /**
162
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
163
+ * `undefined` when no block matched. `''` for an unqualified block.
164
+ *
165
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
166
+ * writing into that block scopes the declaration to the qualified case, and
167
+ * that may not be what the operator meant. Matching it is still the right
168
+ * call — silently skipping it is the worse failure, and the one that
169
+ * shipped — but the override path uses this to say out loud which selector
170
+ * it wrote through, so the narrowing is a visible decision rather than a
171
+ * theme bug discovered later.
172
+ *
173
+ * @param lines - Tokens CSS split into lines
174
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
175
+ * @returns The qualifier, or undefined when no block matched
176
+ */
177
+ export function variantBlockQualifier(lines, variant) {
178
+ return findVariantBlock(lines, variant)?.qualifier;
179
+ }
119
180
  /** True when the `:root<variant>` block already declares `tokenName`. */
120
181
  export function variantBlockHasToken(lines, variant, tokenName) {
182
+ return findVariantBlockDeclaration(lines, variant, tokenName) !== undefined;
183
+ }
184
+ /**
185
+ * Locates an existing declaration of `tokenName` inside the
186
+ * `:root<variant>` block, or `undefined` when the block has none (or does
187
+ * not exist).
188
+ *
189
+ * The located form of {@link variantBlockHasToken}: the variant add path
190
+ * reports a skip the way the base path does — naming the block and the
191
+ * line — instead of returning a bare boolean that leaves a no-op re-run
192
+ * indistinguishable from a write.
193
+ *
194
+ * @param lines - Tokens CSS split into lines
195
+ * @param variant - Attribute selector fragment
196
+ * @param tokenName - Full token name including prefix
197
+ * @returns The declaration's 1-based line, or undefined
198
+ */
199
+ export function findVariantBlockDeclaration(lines, variant, tokenName) {
121
200
  const block = findVariantBlock(lines, variant);
122
201
  if (!block)
123
- return false;
124
- const blockText = lines
125
- .slice(block.open, block.close + 1)
202
+ return undefined;
203
+ // Comments are stripped across the WHOLE block before scanning, matching
204
+ // what the boolean form always did: a `/* … */` spanning several lines
205
+ // must not leave its tail looking like a declaration.
206
+ const blockLines = lines.slice(block.open, block.close + 1);
207
+ // Blank the comment's CHARACTERS rather than deleting it: removing a
208
+ // multi-line comment outright collapses its lines and shifts every line
209
+ // number after it, which is the one thing this function exists to report.
210
+ const stripped = blockLines
126
211
  .join('\n')
127
- .replace(/\/\*[\s\S]*?\*\//g, '');
128
- return blockText.includes(`${tokenName}:`);
212
+ .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
213
+ .split('\n');
214
+ for (let i = 0; i < stripped.length; i++) {
215
+ if ((stripped[i] ?? '').includes(`${tokenName}:`))
216
+ return { line: block.open + i + 1 };
217
+ }
218
+ return undefined;
129
219
  }
130
220
  /**
131
221
  * Splices `declLine` into the `:root<variant>` block, creating the block
@@ -443,7 +443,11 @@ async function inspectTreeLock(lockPath) {
443
443
  */
444
444
  export async function removeTree(primaryRoot, name, options = {}) {
445
445
  assertValidTreeName(name);
446
- const treesDir = getTreesDir(primaryRoot);
446
+ // Both sides resolved: the containment prefix has to be in the same form as
447
+ // the path it guards. `getTreesDir` is a `join`, so on Windows it yields a
448
+ // drive-less path while `resolve` below adds the drive letter — the prefix
449
+ // test then failed for EVERY tree and refused each removal as an escape.
450
+ const treesDir = resolve(getTreesDir(primaryRoot));
447
451
  const treeRoot = resolve(treesDir, name);
448
452
  if (!treeRoot.startsWith(treesDir + sep) || basename(treeRoot) !== name) {
449
453
  throw new GeneralError(`Refusing to remove "${name}": resolved path escapes the trees directory.`);
@@ -22,6 +22,7 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path';
22
22
  import { toError } from '../utils/errors.js';
23
23
  import { pathExists } from '../utils/fs.js';
24
24
  import { verbose } from '../utils/logger.js';
25
+ import { normalizePathSlashes } from '../utils/paths.js';
25
26
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
26
27
  /**
27
28
  * Sentinel string surfaced as a `TypecheckIssue.message` when the
@@ -239,7 +240,12 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
239
240
  // file — and never write it to disk. The CompilerHost below serves
240
241
  // it from `shimSource` for `fileExists`/`readFile`/`getSourceFile`.
241
242
  const projectDir = dirname(absConfig);
242
- const shimPath = resolve(projectDir, `.fireforge-${SHIM_FILENAME}`);
243
+ // Forward slashes deliberately: TypeScript normalizes every `fileName` it
244
+ // hands a CompilerHost, on every platform. A shim path carrying Windows
245
+ // separators never matches those callbacks, so the shim is not served (its
246
+ // globals report as undefined identifiers) and its own diagnostics are not
247
+ // filtered out below — the pass reports failures that do not exist.
248
+ const shimPath = normalizePathSlashes(resolve(projectDir, `.fireforge-${SHIM_FILENAME}`));
243
249
  const rootFiles = [...parsed.fileNames, shimPath];
244
250
  const defaultHost = ts.createCompilerHost(options);
245
251
  const host = {
@@ -539,6 +539,12 @@ export interface RunOptions {
539
539
  export interface TestOptions {
540
540
  /** Run tests in headless mode */
541
541
  headless?: boolean;
542
+ /**
543
+ * Pass mozbuild's full verbosity through for the TEST phase by unsetting
544
+ * the coding-agent markers it quiets output on. Off by default, because
545
+ * that quieting is worth keeping for builds.
546
+ */
547
+ fullOutput?: boolean;
542
548
  /** Run incremental UI build before testing */
543
549
  build?: boolean;
544
550
  /**
@@ -652,6 +658,12 @@ export interface FurnaceApplyOptions {
652
658
  force?: boolean;
653
659
  /** Watch component directories and re-apply on changes */
654
660
  watch?: boolean;
661
+ /**
662
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
663
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
664
+ * engine session lock, so one flag governs both.
665
+ */
666
+ waitLockSeconds?: number;
655
667
  }
656
668
  /**
657
669
  * Options for the furnace preview command.
@@ -670,6 +682,12 @@ export interface FurnaceDeployOptions {
670
682
  force?: boolean;
671
683
  /** Skip the validation step (apply only, no accessibility/compatibility checks) */
672
684
  skipValidate?: boolean;
685
+ /**
686
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
687
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
688
+ * engine session lock, so one flag governs both.
689
+ */
690
+ waitLockSeconds?: number;
673
691
  }
674
692
  /**
675
693
  * Options for the furnace refresh command.
@@ -692,6 +710,12 @@ export interface FurnaceSyncOptions {
692
710
  dryRun?: boolean;
693
711
  /** Conflict resolution strategy for three-way merge (ours = keep local, theirs = accept upstream) */
694
712
  strategy?: 'ours' | 'theirs';
713
+ /**
714
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
715
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
716
+ * engine session lock, so one flag governs both.
717
+ */
718
+ waitLockSeconds?: number;
695
719
  }
696
720
  /**
697
721
  * Options for the furnace validate command.
@@ -965,7 +989,12 @@ export interface StatusOptions {
965
989
  * Options for the token add command.
966
990
  */
967
991
  export interface TokenAddOptions {
968
- category: string;
992
+ /**
993
+ * Required for a base declaration; optional under
994
+ * {@link TokenAddOptions.variant}, which routes into a `:root<selector>`
995
+ * block where no category applies.
996
+ */
997
+ category?: string;
969
998
  mode: string;
970
999
  description?: string;
971
1000
  darkValue?: string;
@@ -987,7 +1016,30 @@ export interface DoctorOptions {
987
1016
  * the patch manifest under that lock.
988
1017
  */
989
1018
  waitLock?: number | boolean;
1019
+ /**
1020
+ * Rebuild patches.json from the on-disk patch files, preserving every
1021
+ * field the patch bodies cannot express. Refused when patches.json exists
1022
+ * but cannot be parsed, unless `allowMetadataLoss` is also set.
1023
+ */
990
1024
  repairPatchesManifest?: boolean;
1025
+ /**
1026
+ * Repair only the `filesAffected` lists that disagree with their patch
1027
+ * body. Narrower than `repairPatchesManifest`: it rewrites one field on the
1028
+ * drifted rows and leaves every other row byte-identical, and it needs no
1029
+ * project config, so it works on a project whose fireforge.json is broken.
1030
+ */
1031
+ repairFilesAffected?: boolean;
1032
+ /**
1033
+ * Accept the whole-manifest metadata reinvention that rebuilding an
1034
+ * unparseable patches.json implies. Only meaningful with
1035
+ * `repairPatchesManifest`.
1036
+ */
1037
+ allowMetadataLoss?: boolean;
1038
+ /**
1039
+ * Project what a requested repair would change and write nothing. Refused
1040
+ * without a repair flag rather than accepted as a silent no-op.
1041
+ */
1042
+ dryRun?: boolean;
991
1043
  /**
992
1044
  * Clear a stale `pendingResolution` marker, but only after the same
993
1045
  * read-only queue health checks used by `fireforge verify` report no
@@ -0,0 +1,30 @@
1
+ /**
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
+ */
7
+ /** The `env` / `envUnset` slice of exec options this module needs. */
8
+ export interface ChildEnvOptions {
9
+ /** Variables to add or overwrite on top of `process.env`. */
10
+ env?: Record<string, string> | undefined;
11
+ /** Variables to remove after the merge. */
12
+ envUnset?: readonly string[] | undefined;
13
+ }
14
+ /**
15
+ * The child's environment: `process.env`, with `env` merged over it, then
16
+ * every `envUnset` key removed.
17
+ *
18
+ * One helper rather than five inline spreads — the removal step is easy to
19
+ * add at four of the five spawn sites and forget at the fifth, which would
20
+ * make the option silently inert exactly where it was not tested.
21
+ *
22
+ * Removal is a real removal, not an assignment to `''`: mozbuild's
23
+ * `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
24
+ * an empty value would not unset it. `Reflect.deleteProperty` rather than
25
+ * `delete obj[key]` because the key is computed.
26
+ *
27
+ * @param options - Options carrying `env` / `envUnset`
28
+ * @returns The environment to hand `spawn`
29
+ */
30
+ export declare function buildChildEnv(options: ChildEnvOptions): NodeJS.ProcessEnv;
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
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
+ */
8
+ /**
9
+ * The child's environment: `process.env`, with `env` merged over it, then
10
+ * every `envUnset` key removed.
11
+ *
12
+ * One helper rather than five inline spreads — the removal step is easy to
13
+ * add at four of the five spawn sites and forget at the fifth, which would
14
+ * make the option silently inert exactly where it was not tested.
15
+ *
16
+ * Removal is a real removal, not an assignment to `''`: mozbuild's
17
+ * `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
18
+ * an empty value would not unset it. `Reflect.deleteProperty` rather than
19
+ * `delete obj[key]` because the key is computed.
20
+ *
21
+ * @param options - Options carrying `env` / `envUnset`
22
+ * @returns The environment to hand `spawn`
23
+ */
24
+ export function buildChildEnv(options) {
25
+ const merged = { ...process.env, ...options.env };
26
+ for (const key of options.envUnset ?? []) {
27
+ Reflect.deleteProperty(merged, key);
28
+ }
29
+ return merged;
30
+ }
@@ -66,6 +66,28 @@ export declare const WAIT_LOCK_ENV_VAR = "FIREFORGE_WAIT_LOCK";
66
66
  * ABSENCE, which is where it belongs.
67
67
  */
68
68
  export declare function resolveWaitLockSeconds(value: string | number | boolean | undefined): number | undefined;
69
+ /**
70
+ * The session-wide wait budget in MILLISECONDS, or undefined when none is
71
+ * set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
72
+ *
73
+ * Exists for the SECONDARY locks a command takes after the one its
74
+ * `--wait-lock` flag was threaded to. A furnace mutation takes the engine
75
+ * session lock and then `.fireforge/furnace.lock`; only the first had a
76
+ * budget, so a contended deploy died on the file lock's fixed 30 s while
77
+ * its operator believed a much larger budget was in force. Threading the
78
+ * flag reaches the three furnace commands that declare it — this reaches
79
+ * the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
80
+ * `override`, `chrome-doc`, …), which have no flag to thread but are just
81
+ * as contended.
82
+ *
83
+ * Deliberately env-only: a flag belongs to one invocation and is passed
84
+ * explicitly where it applies, while the environment is exactly the place a
85
+ * session-wide default is expressed. An explicit budget at a call site
86
+ * always wins over this.
87
+ *
88
+ * @returns The session budget in milliseconds, or undefined
89
+ */
90
+ export declare function sessionWaitLockMs(): number | undefined;
69
91
  /**
70
92
  * True when `command` already declares `--wait-lock` itself. Used by the
71
93
  * CLI wiring to add the accept-and-ignore variant only where the honoring
@@ -85,5 +107,11 @@ export declare function ensureWaitLockOptionEverywhere(command: Command): void;
85
107
  * for the bare flag or a validated integer; feed it through
86
108
  * {@link resolveWaitLockSeconds} at the call site to obtain the
87
109
  * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
110
+ *
111
+ * A command can take MORE than one lock — a furnace mutation takes the
112
+ * engine session lock and then `.fireforge/furnace.lock` — and the budget
113
+ * has to reach all of them or the flag is a half-truth. The secondary locks
114
+ * read the session budget through {@link sessionWaitLockMs} rather than
115
+ * having it threaded to every call site.
88
116
  */
89
117
  export declare function addWaitLockOption(command: Command): Command;
@@ -144,6 +144,31 @@ export function resolveWaitLockSeconds(value) {
144
144
  }
145
145
  return parsed;
146
146
  }
147
+ /**
148
+ * The session-wide wait budget in MILLISECONDS, or undefined when none is
149
+ * set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
150
+ *
151
+ * Exists for the SECONDARY locks a command takes after the one its
152
+ * `--wait-lock` flag was threaded to. A furnace mutation takes the engine
153
+ * session lock and then `.fireforge/furnace.lock`; only the first had a
154
+ * budget, so a contended deploy died on the file lock's fixed 30 s while
155
+ * its operator believed a much larger budget was in force. Threading the
156
+ * flag reaches the three furnace commands that declare it — this reaches
157
+ * the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
158
+ * `override`, `chrome-doc`, …), which have no flag to thread but are just
159
+ * as contended.
160
+ *
161
+ * Deliberately env-only: a flag belongs to one invocation and is passed
162
+ * explicitly where it applies, while the environment is exactly the place a
163
+ * session-wide default is expressed. An explicit budget at a call site
164
+ * always wins over this.
165
+ *
166
+ * @returns The session budget in milliseconds, or undefined
167
+ */
168
+ export function sessionWaitLockMs() {
169
+ const seconds = waitLockSecondsFromEnv();
170
+ return seconds === undefined ? undefined : seconds * 1000;
171
+ }
147
172
  /**
148
173
  * Registers `--wait-lock [seconds]` on a command that takes NO lock, so a
149
174
  * scripted sequence can blanket-append the flag without hitting a usage
@@ -188,7 +213,13 @@ export function ensureWaitLockOptionEverywhere(command) {
188
213
  * for the bare flag or a validated integer; feed it through
189
214
  * {@link resolveWaitLockSeconds} at the call site to obtain the
190
215
  * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
216
+ *
217
+ * A command can take MORE than one lock — a furnace mutation takes the
218
+ * engine session lock and then `.fireforge/furnace.lock` — and the budget
219
+ * has to reach all of them or the flag is a half-truth. The secondary locks
220
+ * read the session budget through {@link sessionWaitLockMs} rather than
221
+ * having it threaded to every call site.
191
222
  */
192
223
  export function addWaitLockOption(command) {
193
- return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release the contended lock, instead of failing on the default budget. Set ${WAIT_LOCK_ENV_VAR} to apply a budget to every command in a session`, commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
224
+ return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release EVERY lock this command takes, instead of failing on the default budget. Set ${WAIT_LOCK_ENV_VAR} to apply a budget to every command in a session`, commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
194
225
  }