@hominis/fireforge 0.44.1 → 0.44.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 (54) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +18 -110
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/furnace/apply.js +2 -2
  6. package/dist/src/commands/furnace/deploy.js +2 -2
  7. package/dist/src/commands/furnace/index.js +12 -6
  8. package/dist/src/commands/furnace/init.js +5 -2
  9. package/dist/src/commands/furnace/sync.js +2 -2
  10. package/dist/src/commands/re-export-files.js +63 -23
  11. package/dist/src/commands/re-export-scan.js +18 -2
  12. package/dist/src/commands/re-export-single.js +19 -7
  13. package/dist/src/commands/test-register.js +5 -1
  14. package/dist/src/commands/test-run.d.ts +5 -0
  15. package/dist/src/commands/test-run.js +11 -4
  16. package/dist/src/commands/test-verdict.js +10 -1
  17. package/dist/src/commands/test.js +9 -0
  18. package/dist/src/commands/token.js +36 -12
  19. package/dist/src/core/build-audit.js +6 -2
  20. package/dist/src/core/furnace-jsconfig.js +5 -3
  21. package/dist/src/core/furnace-operation.d.ts +25 -2
  22. package/dist/src/core/furnace-operation.js +27 -2
  23. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  24. package/dist/src/core/mach-known-noise-filter.js +19 -0
  25. package/dist/src/core/mach.d.ts +8 -3
  26. package/dist/src/core/mach.js +47 -3
  27. package/dist/src/core/run-log.d.ts +60 -0
  28. package/dist/src/core/run-log.js +183 -0
  29. package/dist/src/core/test-harness-output.d.ts +24 -1
  30. package/dist/src/core/test-harness-output.js +46 -15
  31. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  32. package/dist/src/core/test-xpcshell-retry.js +6 -1
  33. package/dist/src/core/token-manager.d.ts +7 -2
  34. package/dist/src/core/token-manager.js +74 -20
  35. package/dist/src/core/token-variant.d.ts +46 -2
  36. package/dist/src/core/token-variant.js +190 -33
  37. package/dist/src/types/commands/options.d.ts +30 -1
  38. package/dist/src/utils/child-env.d.ts +30 -0
  39. package/dist/src/utils/child-env.js +30 -0
  40. package/dist/src/utils/options.d.ts +28 -0
  41. package/dist/src/utils/options.js +32 -1
  42. package/dist/src/utils/process.d.ts +22 -0
  43. package/dist/src/utils/process.js +8 -7
  44. package/docs/README.md +35 -0
  45. package/docs/configuration.md +116 -0
  46. package/docs/exit-codes.md +63 -0
  47. package/docs/furnace.md +57 -0
  48. package/docs/lifecycle-invariants.md +309 -0
  49. package/docs/machine-output.md +94 -0
  50. package/docs/patch-workflow.md +119 -0
  51. package/docs/run-logs.md +60 -0
  52. package/docs/testing.md +92 -0
  53. package/docs/verification-trees.md +48 -0
  54. package/package.json +2 -1
@@ -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
@@ -22,10 +23,20 @@ export type VariantValidation = {
22
23
  reason: string;
23
24
  };
24
25
  /**
25
- * Validates a `--variant` attribute selector fragment and normalizes any
26
- * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
26
+ * Validates a `--variant` selector fragment and normalizes any `=value`
27
+ * form to the double-quoted `="value"` shape (Mozilla convention).
27
28
  * Boolean-attribute fragments are returned unchanged.
28
29
  *
30
+ * Structure is decided by {@link rootAttributeSelector} — the matcher's own
31
+ * parse — rather than by a second regex. That is the point: 0.44.2 widened
32
+ * the matcher to consume consecutive `[…]` groups and stop at a pseudo-class
33
+ * tail, and the validator did not move with it, so
34
+ * `:root[data-skin="humanist"]:not([data-private])` was a block the tool
35
+ * could FIND but would not let you NAME. Running both halves off one parse
36
+ * is what stops them drifting apart again. Character-level safety stays a
37
+ * separate, per-group check, because the matcher finds bracket boundaries
38
+ * and validates no characters at all.
39
+ *
29
40
  * @param raw - Raw `--variant` value from the CLI / programmatic caller.
30
41
  */
31
42
  export declare function validateVariantSelector(raw: unknown): VariantValidation;
@@ -36,8 +47,41 @@ export declare function validateVariantSelector(raw: unknown): VariantValidation
36
47
  * the block, which is only wanted for the explicit `--variant` flow.
37
48
  */
38
49
  export declare function variantBlockExists(lines: string[], variant: string): boolean;
50
+ /**
51
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
52
+ * `undefined` when no block matched. `''` for an unqualified block.
53
+ *
54
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
55
+ * writing into that block scopes the declaration to the qualified case, and
56
+ * that may not be what the operator meant. Matching it is still the right
57
+ * call — silently skipping it is the worse failure, and the one that
58
+ * shipped — but the override path uses this to say out loud which selector
59
+ * it wrote through, so the narrowing is a visible decision rather than a
60
+ * theme bug discovered later.
61
+ *
62
+ * @param lines - Tokens CSS split into lines
63
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
64
+ * @returns The qualifier, or undefined when no block matched
65
+ */
66
+ export declare function variantBlockQualifier(lines: string[], variant: string): string | undefined;
39
67
  /** True when the `:root<variant>` block already declares `tokenName`. */
40
68
  export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
69
+ /**
70
+ * Locates an existing declaration of `tokenName` inside the
71
+ * `:root<variant>` block, or `undefined` when the block has none (or does
72
+ * not exist).
73
+ *
74
+ * The located form of {@link variantBlockHasToken}: the variant add path
75
+ * reports a skip the way the base path does — naming the block and the
76
+ * line — instead of returning a bare boolean that leaves a no-op re-run
77
+ * indistinguishable from a write.
78
+ *
79
+ * @param lines - Tokens CSS split into lines
80
+ * @param variant - Attribute selector fragment
81
+ * @param tokenName - Full token name including prefix
82
+ * @returns The declaration's 1-based line, or undefined
83
+ */
84
+ export declare function findVariantBlockDeclaration(lines: string[], variant: string, tokenName: string): TokenDeclarationLocation | undefined;
41
85
  /**
42
86
  * Splices `declLine` into the `:root<variant>` block, creating the block
43
87
  * (after the base `:root` block) when absent or appending after the last
@@ -12,36 +12,113 @@
12
12
  */
13
13
  import { findBlockCloseIndex, stripBlockCommentsInLines } from './token-dark-mode.js';
14
14
  /**
15
- * Accepts a single attribute selector fragment: `[data-private]` (boolean
16
- * attribute) or `[data-skin=precision]` / `[data-skin="precision"]`
17
- * (attribute with value). The value half is restricted to identifier-safe
15
+ * ONE attribute selector fragment: `[data-private]` (boolean attribute) or
16
+ * `[data-skin=precision]` / `[data-skin="precision"]` (attribute with
17
+ * value). The name and value halves are restricted to identifier-safe
18
18
  * characters so the fragment can be spliced into CSS verbatim without
19
19
  * escaping concerns.
20
+ *
21
+ * A multi-fragment variant is checked one group at a time (see
22
+ * {@link splitAttributeGroups}) rather than by wrapping this in a `+`: the
23
+ * repeated form is the shape CodeQL flags as `js/polynomial-redos`, and the
24
+ * split the matcher already performs is available for free.
25
+ */
26
+ const ATTRIBUTE_FRAGMENT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
27
+ /**
28
+ * A pseudo-class tail: `:not(…)`, `:hover`, and friends. The parenthesised
29
+ * argument is captured rather than validated here — it is a fragment run of
30
+ * its own and goes through the same per-group check.
31
+ */
32
+ const QUALIFIER_PATTERN = /^:[a-zA-Z][a-zA-Z0-9-]*(?:\(([^()]*)\))?$/;
33
+ /**
34
+ * Splits a run of consecutive attribute fragments into its groups, or
35
+ * returns `null` when the run is not exactly that — a group that does not
36
+ * open with `[`, an unclosed group, an empty run, or a group whose name or
37
+ * value is not identifier-safe.
38
+ *
39
+ * @param run - The `[…][…]` text with no `:root` prefix
40
+ * @returns The individual fragments, or null when the run is not valid
20
41
  */
21
- const VARIANT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
42
+ function splitAttributeGroups(run) {
43
+ const groups = [];
44
+ let cursor = 0;
45
+ while (cursor < run.length) {
46
+ if (run[cursor] !== '[')
47
+ return null;
48
+ const close = run.indexOf(']', cursor);
49
+ if (close === -1)
50
+ return null;
51
+ const group = run.slice(cursor, close + 1);
52
+ if (!ATTRIBUTE_FRAGMENT_PATTERN.test(group))
53
+ return null;
54
+ groups.push(group);
55
+ cursor = close + 1;
56
+ }
57
+ return groups.length > 0 ? groups : null;
58
+ }
59
+ /** Rewrites `=value` / `='value'` to the double-quoted Mozilla convention. */
60
+ function normalizeAttributeFragment(group) {
61
+ return group
62
+ .replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
63
+ .replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
64
+ }
22
65
  /**
23
- * Validates a `--variant` attribute selector fragment and normalizes any
24
- * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
66
+ * Validates a `--variant` selector fragment and normalizes any `=value`
67
+ * form to the double-quoted `="value"` shape (Mozilla convention).
25
68
  * Boolean-attribute fragments are returned unchanged.
26
69
  *
70
+ * Structure is decided by {@link rootAttributeSelector} — the matcher's own
71
+ * parse — rather than by a second regex. That is the point: 0.44.2 widened
72
+ * the matcher to consume consecutive `[…]` groups and stop at a pseudo-class
73
+ * tail, and the validator did not move with it, so
74
+ * `:root[data-skin="humanist"]:not([data-private])` was a block the tool
75
+ * could FIND but would not let you NAME. Running both halves off one parse
76
+ * is what stops them drifting apart again. Character-level safety stays a
77
+ * separate, per-group check, because the matcher finds bracket boundaries
78
+ * and validates no characters at all.
79
+ *
27
80
  * @param raw - Raw `--variant` value from the CLI / programmatic caller.
28
81
  */
29
82
  export function validateVariantSelector(raw) {
30
83
  if (typeof raw !== 'string') {
31
84
  return { ok: false, reason: 'must be a string when set' };
32
85
  }
86
+ const reject = {
87
+ ok: false,
88
+ reason: 'must be one or more attribute selectors like [data-private], [data-skin=precision] ' +
89
+ 'or [data-skin=precision][data-theme=dark], optionally followed by a pseudo-class ' +
90
+ 'such as :not([data-private]) (identifier-safe attribute names and values only)',
91
+ };
33
92
  const value = raw.trim();
34
- if (!VARIANT_PATTERN.test(value)) {
35
- return {
36
- ok: false,
37
- reason: 'must be a single attribute selector like [data-private] or [data-skin=precision] ' +
38
- '(identifier-safe attribute name and value only)',
39
- };
93
+ if (value === '')
94
+ return reject;
95
+ const parts = rootAttributeSelector(`:root${value} {`);
96
+ // The parse must have consumed the WHOLE fragment: anything it declined to
97
+ // read (a stray space, a trailing combinator) is text the matcher would
98
+ // not see either, so accepting it would author a block nothing can find.
99
+ if (parts === null || `${parts.attributes}${parts.qualifier}` !== `:root${value}`)
100
+ return reject;
101
+ const groups = splitAttributeGroups(parts.attributes.slice(':root'.length));
102
+ if (groups === null)
103
+ return reject;
104
+ let qualifier = '';
105
+ if (parts.qualifier !== '') {
106
+ const match = QUALIFIER_PATTERN.exec(parts.qualifier);
107
+ if (match === null)
108
+ return reject;
109
+ const inner = match[1];
110
+ if (inner === undefined) {
111
+ qualifier = parts.qualifier;
112
+ }
113
+ else {
114
+ const innerGroups = splitAttributeGroups(inner);
115
+ if (innerGroups === null)
116
+ return reject;
117
+ const name = parts.qualifier.slice(0, parts.qualifier.indexOf('('));
118
+ qualifier = `${name}(${innerGroups.map(normalizeAttributeFragment).join('')})`;
119
+ }
40
120
  }
41
- const normalized = value
42
- .replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
43
- .replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
44
- return { ok: true, value: normalized };
121
+ return { ok: true, value: `${groups.map(normalizeAttributeFragment).join('')}${qualifier}` };
45
122
  }
46
123
  /**
47
124
  * Reduces a `:root<attr>` selector to a quote- and whitespace-insensitive
@@ -53,15 +130,27 @@ function canonicalSelector(selector) {
53
130
  return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
131
  }
55
132
  /**
56
- * The `:root[…]` selector fragment on `line`, spanning to the LAST `]` that
57
- * precedes the block's opening brace, or `null` when the line carries none.
133
+ * Splits the `:root[…]` selector on `line` into its attribute run and any
134
+ * trailing qualifier, or `null` when the line carries none.
58
135
  *
59
136
  * Index arithmetic rather than `/:root\[[^{]*\]/`: `[^{]` also matches `]`,
60
137
  * so that pattern backtracks quadratically on a line repeating `:root[`
61
- * (CodeQL `js/polynomial-redos`). Taking the last `]` — not the first —
62
- * preserves the greedy regex's reach, so a multi-attribute selector still
63
- * compares as the whole fragment and does not collide with a single-attribute
64
- * variant that happens to be its prefix.
138
+ * (CodeQL `js/polynomial-redos`). This walk is linear and allocation-free
139
+ * per group.
140
+ *
141
+ * It consumes CONSECUTIVE `[…]` groups and stops at the first character that
142
+ * is not `[`. The previous implementation instead sliced to the LAST `]`
143
+ * before the brace, which kept multi-attribute selectors comparing as a
144
+ * whole — that property is preserved here, since consecutive groups are all
145
+ * consumed — but it also swallowed a pseudo-class tail. On
146
+ * `:root[data-theme="light"]:not([data-private]) {` the last `]` is the one
147
+ * inside `:not(…)`, so the computed fragment was
148
+ * `:root[data-theme="light"]:not([data-private]` and the canonical
149
+ * comparison could never match. The unqualified
150
+ * `:root[data-theme="dark"] { }` beside it matched fine, and that asymmetry
151
+ * WAS the bug: an override add mirrored itself into the dark block and
152
+ * silently skipped the light one, which is precisely the half-finished
153
+ * themed edit `THEME_ATTRIBUTE_VARIANTS` exists to prevent.
65
154
  */
66
155
  function rootAttributeSelector(line) {
67
156
  const start = line.indexOf(':root[');
@@ -69,10 +158,18 @@ function rootAttributeSelector(line) {
69
158
  return null;
70
159
  const brace = line.indexOf('{', start);
71
160
  const limit = brace === -1 ? line.length : brace;
72
- const close = line.lastIndexOf(']', limit - 1);
73
- if (close <= start + ':root['.length - 1)
161
+ let cursor = start + ':root'.length;
162
+ let end = cursor;
163
+ while (cursor < limit && line[cursor] === '[') {
164
+ const close = line.indexOf(']', cursor);
165
+ if (close === -1 || close >= limit)
166
+ break;
167
+ cursor = close + 1;
168
+ end = cursor;
169
+ }
170
+ if (end === start + ':root'.length)
74
171
  return null;
75
- return line.slice(start, close + 1);
172
+ return { attributes: line.slice(start, end), qualifier: line.slice(end, limit).trim() };
76
173
  }
77
174
  /**
78
175
  * Finds the top-level `:root<variant>` block whose attribute selector
@@ -85,11 +182,23 @@ function rootAttributeSelector(line) {
85
182
  */
86
183
  function findVariantBlock(lines, variant) {
87
184
  const stripped = stripBlockCommentsInLines(lines);
88
- const want = canonicalSelector(`:root${variant}`);
185
+ // The request goes through the same parse as the candidate lines, so a
186
+ // variant carrying its own `:not(…)` tail is compared tail-to-tail rather
187
+ // than being folded into the attribute run — where it would match nothing
188
+ // and every add would splice a fresh duplicate block.
189
+ const requested = rootAttributeSelector(`:root${variant} {`);
190
+ const want = canonicalSelector(requested?.attributes ?? `:root${variant}`);
191
+ const wantQualifier = canonicalSelector(requested?.qualifier ?? '');
89
192
  for (let i = 0; i < stripped.length; i++) {
90
193
  const line = stripped[i] ?? '';
91
194
  const selector = rootAttributeSelector(line);
92
- if (selector === null || canonicalSelector(selector) !== want)
195
+ if (selector === null || canonicalSelector(selector.attributes) !== want)
196
+ continue;
197
+ // An UNqualified request still matches a qualified block: that is the
198
+ // 0.44.2 behaviour `variantBlockQualifier` exists to report, since
199
+ // silently skipping the block is the worse failure. A request that names
200
+ // a qualifier, though, means it — so it must match.
201
+ if (wantQualifier !== '' && canonicalSelector(selector.qualifier) !== wantQualifier)
93
202
  continue;
94
203
  let openLine = /\{/.test(line) ? i : -1;
95
204
  if (openLine === -1) {
@@ -108,7 +217,7 @@ function findVariantBlock(lines, variant) {
108
217
  const close = findBlockCloseIndex(stripped, openLine);
109
218
  if (close === -1)
110
219
  continue;
111
- return { open: openLine, close };
220
+ return { open: openLine, close, qualifier: selector.qualifier };
112
221
  }
113
222
  return null;
114
223
  }
@@ -138,16 +247,64 @@ function findVariantBlockInsertionPoint(lines) {
138
247
  export function variantBlockExists(lines, variant) {
139
248
  return findVariantBlock(lines, variant) !== null;
140
249
  }
250
+ /**
251
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
252
+ * `undefined` when no block matched. `''` for an unqualified block.
253
+ *
254
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
255
+ * writing into that block scopes the declaration to the qualified case, and
256
+ * that may not be what the operator meant. Matching it is still the right
257
+ * call — silently skipping it is the worse failure, and the one that
258
+ * shipped — but the override path uses this to say out loud which selector
259
+ * it wrote through, so the narrowing is a visible decision rather than a
260
+ * theme bug discovered later.
261
+ *
262
+ * @param lines - Tokens CSS split into lines
263
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
264
+ * @returns The qualifier, or undefined when no block matched
265
+ */
266
+ export function variantBlockQualifier(lines, variant) {
267
+ return findVariantBlock(lines, variant)?.qualifier;
268
+ }
141
269
  /** True when the `:root<variant>` block already declares `tokenName`. */
142
270
  export function variantBlockHasToken(lines, variant, tokenName) {
271
+ return findVariantBlockDeclaration(lines, variant, tokenName) !== undefined;
272
+ }
273
+ /**
274
+ * Locates an existing declaration of `tokenName` inside the
275
+ * `:root<variant>` block, or `undefined` when the block has none (or does
276
+ * not exist).
277
+ *
278
+ * The located form of {@link variantBlockHasToken}: the variant add path
279
+ * reports a skip the way the base path does — naming the block and the
280
+ * line — instead of returning a bare boolean that leaves a no-op re-run
281
+ * indistinguishable from a write.
282
+ *
283
+ * @param lines - Tokens CSS split into lines
284
+ * @param variant - Attribute selector fragment
285
+ * @param tokenName - Full token name including prefix
286
+ * @returns The declaration's 1-based line, or undefined
287
+ */
288
+ export function findVariantBlockDeclaration(lines, variant, tokenName) {
143
289
  const block = findVariantBlock(lines, variant);
144
290
  if (!block)
145
- return false;
146
- const blockText = lines
147
- .slice(block.open, block.close + 1)
291
+ return undefined;
292
+ // Comments are stripped across the WHOLE block before scanning, matching
293
+ // what the boolean form always did: a `/* … */` spanning several lines
294
+ // must not leave its tail looking like a declaration.
295
+ const blockLines = lines.slice(block.open, block.close + 1);
296
+ // Blank the comment's CHARACTERS rather than deleting it: removing a
297
+ // multi-line comment outright collapses its lines and shifts every line
298
+ // number after it, which is the one thing this function exists to report.
299
+ const stripped = blockLines
148
300
  .join('\n')
149
- .replace(/\/\*[\s\S]*?\*\//g, '');
150
- return blockText.includes(`${tokenName}:`);
301
+ .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
302
+ .split('\n');
303
+ for (let i = 0; i < stripped.length; i++) {
304
+ if ((stripped[i] ?? '').includes(`${tokenName}:`))
305
+ return { line: block.open + i + 1 };
306
+ }
307
+ return undefined;
151
308
  }
152
309
  /**
153
310
  * Splices `declLine` into the `:root<variant>` block, creating the block
@@ -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;
@@ -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;