@hominis/fireforge 0.31.0 → 0.33.0

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 (64) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  9. package/dist/src/commands/patch/split-plan.js +90 -16
  10. package/dist/src/commands/patch/split.js +12 -3
  11. package/dist/src/commands/re-export-files.js +4 -1
  12. package/dist/src/commands/re-export.js +8 -1
  13. package/dist/src/commands/test-run.d.ts +10 -0
  14. package/dist/src/commands/test-run.js +13 -4
  15. package/dist/src/commands/test.js +46 -7
  16. package/dist/src/commands/token.js +12 -1
  17. package/dist/src/commands/typecheck.js +35 -0
  18. package/dist/src/core/build-prepare.js +23 -3
  19. package/dist/src/core/config-validate.js +52 -0
  20. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  21. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  22. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  23. package/dist/src/core/furnace-apply-ftl.js +97 -1
  24. package/dist/src/core/furnace-apply-helpers.js +10 -80
  25. package/dist/src/core/furnace-jsconfig.js +22 -2
  26. package/dist/src/core/git-base.d.ts +15 -0
  27. package/dist/src/core/git-base.js +32 -0
  28. package/dist/src/core/git-diff.d.ts +8 -0
  29. package/dist/src/core/git-diff.js +224 -59
  30. package/dist/src/core/git-file-ops.d.ts +39 -0
  31. package/dist/src/core/git-file-ops.js +82 -1
  32. package/dist/src/core/mach-resource-shim.d.ts +21 -0
  33. package/dist/src/core/mach-resource-shim.js +92 -0
  34. package/dist/src/core/mach.d.ts +17 -0
  35. package/dist/src/core/mach.js +30 -2
  36. package/dist/src/core/manifest-helpers.js +29 -4
  37. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  38. package/dist/src/core/patch-lint-checkjs.js +213 -67
  39. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  40. package/dist/src/core/patch-lint-cross.js +83 -63
  41. package/dist/src/core/patch-lint-css.d.ts +23 -0
  42. package/dist/src/core/patch-lint-css.js +172 -0
  43. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  44. package/dist/src/core/patch-lint-reexports.js +1 -1
  45. package/dist/src/core/patch-lint.d.ts +34 -11
  46. package/dist/src/core/patch-lint.js +19 -163
  47. package/dist/src/core/test-harness-crash.d.ts +6 -3
  48. package/dist/src/core/test-harness-crash.js +32 -4
  49. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  50. package/dist/src/core/test-xpcshell-retry.js +9 -4
  51. package/dist/src/core/token-dark-mode.d.ts +9 -0
  52. package/dist/src/core/token-dark-mode.js +1 -1
  53. package/dist/src/core/token-docs.d.ts +32 -0
  54. package/dist/src/core/token-docs.js +101 -0
  55. package/dist/src/core/token-manager.d.ts +8 -0
  56. package/dist/src/core/token-manager.js +77 -95
  57. package/dist/src/core/token-variant.d.ts +39 -0
  58. package/dist/src/core/token-variant.js +141 -0
  59. package/dist/src/core/typecheck-shim.d.ts +3 -1
  60. package/dist/src/core/typecheck-shim.js +43 -3
  61. package/dist/src/core/typecheck.js +56 -28
  62. package/dist/src/types/commands/options.d.ts +22 -0
  63. package/dist/src/types/config.d.ts +24 -2
  64. package/package.json +3 -3
@@ -9,13 +9,13 @@ import { escapeRegex } from '../utils/regex.js';
9
9
  import { validateTokenName } from '../utils/validation.js';
10
10
  import { getProjectPaths, loadConfig } from './config.js';
11
11
  import { loadFurnaceConfig } from './furnace-config.js';
12
- import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
13
12
  import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
+ import { addTokenToDocs } from './token-docs.js';
14
+ import { insertVariantDeclaration, validateVariantSelector, variantBlockHasToken, } from './token-variant.js';
14
15
  /** Returns the token CSS path relative to engine root for a given binary name. */
15
16
  export function getTokensCssPath(binaryName) {
16
17
  return `browser/themes/shared/${binaryName}-tokens.css`;
17
18
  }
18
- const TOKENS_DOC = 'docs/design/SRC_TOKENS.md';
19
19
  /**
20
20
  * Determines the mode annotation string for the CSS comment.
21
21
  */
@@ -69,6 +69,49 @@ function validateDarkValue(options) {
69
69
  throw new InvalidArgumentError('Override mode requires --dark-value to be specified.', 'darkValue');
70
70
  }
71
71
  }
72
+ /**
73
+ * Validates and normalizes the `--variant` attribute selector. Returns the
74
+ * normalized (quoted) selector when set, or `undefined` when no variant was
75
+ * requested. Rejects combining `--variant` with `--mode override`: an
76
+ * override authors a dark `@media :root` block, which variant routing
77
+ * bypasses, so the combination would silently drop the dark value.
78
+ */
79
+ function normalizeVariantOption(options) {
80
+ if (options.variant === undefined)
81
+ return undefined;
82
+ if (options.mode === 'override') {
83
+ throw new InvalidArgumentError('Cannot combine --variant with --mode override; author the variant declaration with ' +
84
+ '--mode auto/static instead.', 'variant');
85
+ }
86
+ const result = validateVariantSelector(options.variant);
87
+ if (!result.ok) {
88
+ throw new InvalidArgumentError(`--variant ${result.reason}.`, 'variant');
89
+ }
90
+ return result.value;
91
+ }
92
+ /** Throws when the tokens CSS file is missing (variant mode skips category checks). */
93
+ async function assertTokensCssExists(engineDir, tokensCssPath) {
94
+ if (!(await pathExists(join(engineDir, tokensCssPath)))) {
95
+ throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
96
+ }
97
+ }
98
+ /**
99
+ * Routes a declaration into the top-level `:root<variant>` block — creating
100
+ * the block after the base `:root` block if absent, or appending to it if
101
+ * present. Idempotent within the block. Returns `{ added: false }` when the
102
+ * token already lives in that block.
103
+ */
104
+ async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant) {
105
+ await assertTokensCssExists(engineDir, tokensCssPath);
106
+ const filePath = join(engineDir, tokensCssPath);
107
+ const lines = (await readText(filePath)).split('\n');
108
+ if (variantBlockHasToken(lines, variant, options.tokenName))
109
+ return { added: false };
110
+ const annotation = getModeAnnotation(options.mode, options.value);
111
+ insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${options.value}; /* ${annotation} */`);
112
+ await writeText(filePath, lines.join('\n'));
113
+ return { added: true };
114
+ }
72
115
  /**
73
116
  * True when `lines` contain a category header (single-line or multi-line
74
117
  * banner shape) naming `category`. Shared by the pre-add assertion and the
@@ -171,6 +214,12 @@ export async function validateTokenAdd(root, options) {
171
214
  validateTokenNameSyntax(options.tokenName);
172
215
  await validateTokenPrefix(root, options);
173
216
  validateDarkValue(options);
217
+ // Variant mode targets a `:root<attr>` block, not a category section, so it
218
+ // only needs the tokens CSS file to exist — category checks do not apply.
219
+ if (normalizeVariantOption(options) !== undefined) {
220
+ await assertTokensCssExists(engineDir, tokensCssPath);
221
+ return;
222
+ }
174
223
  await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
175
224
  }
176
225
  /**
@@ -185,10 +234,21 @@ export async function addToken(root, options) {
185
234
  validateTokenNameSyntax(options.tokenName);
186
235
  await validateTokenPrefix(root, options);
187
236
  validateDarkValue(options);
237
+ const normalizedVariant = normalizeVariantOption(options);
188
238
  if (options.dryRun) {
189
239
  await validateTokenAdd(root, options);
190
240
  const filePath = join(engineDir, tokensCssPath);
191
241
  const content = await readText(filePath);
242
+ if (normalizedVariant !== undefined) {
243
+ const skipped = variantBlockHasToken(content.split('\n'), normalizedVariant, options.tokenName);
244
+ return {
245
+ cssAdded: !skipped,
246
+ docsAdded: false,
247
+ unmappedAdded: false,
248
+ countUpdated: false,
249
+ skipped,
250
+ };
251
+ }
192
252
  const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
193
253
  const skipped = stripped.includes(options.tokenName + ':');
194
254
  return {
@@ -199,6 +259,20 @@ export async function addToken(root, options) {
199
259
  skipped,
200
260
  };
201
261
  }
262
+ // Variant overrides are CSS-only: route the declaration into the
263
+ // `:root<attr>` block and leave docs untouched (the base token owns its
264
+ // docs row). Done before the base-CSS path so an override of an existing
265
+ // base token is not short-circuited by the global idempotency check.
266
+ if (normalizedVariant !== undefined) {
267
+ const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
268
+ return {
269
+ cssAdded: added,
270
+ docsAdded: false,
271
+ unmappedAdded: false,
272
+ countUpdated: false,
273
+ skipped: !added,
274
+ };
275
+ }
202
276
  // --- CSS file ---
203
277
  const { added: cssAdded, categoryCreated } = await addTokenToCSS(engineDir, options, tokensCssPath);
204
278
  if (!cssAdded) {
@@ -211,7 +285,7 @@ export async function addToken(root, options) {
211
285
  };
212
286
  }
213
287
  // --- Documentation ---
214
- const docsResult = await addTokenToDocs(engineDir, options);
288
+ const docsResult = await addTokenToDocs(engineDir, options, getModeAnnotation(options.mode, options.value));
215
289
  return {
216
290
  cssAdded,
217
291
  docsAdded: docsResult.docsAdded,
@@ -381,96 +455,4 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
381
455
  await writeText(filePath, content);
382
456
  return { added: true, categoryCreated };
383
457
  }
384
- /**
385
- * Strips surrounding backticks from a cell, if present. Token cells are
386
- * usually wrapped in inline code fences (`` `--foo` ``) and the parser
387
- * returns them verbatim.
388
- */
389
- function stripInlineCode(cell) {
390
- const trimmed = cell.trim();
391
- if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
392
- return trimmed.slice(1, -1);
393
- }
394
- return trimmed;
395
- }
396
- /**
397
- * Adds a token row to the main token table, the unmapped table (for
398
- * literal values), and bumps the mode count table. Each sub-update runs
399
- * against a freshly parsed view of the document so that splice indices
400
- * stay valid as rewrites are layered.
401
- *
402
- * The old implementation walked `split('\n')` by hand, detected rows by
403
- * literal `|`-prefix, and used a whitespace-sensitive regex to increment
404
- * the mode count. Switching to {@link findTableByColumns} and
405
- * {@link updateCellByKey} removes those formatting traps.
406
- */
407
- async function addTokenToDocs(engineDir, options) {
408
- const filePath = join(engineDir, '..', TOKENS_DOC);
409
- if (!(await pathExists(filePath))) {
410
- // Docs file is optional
411
- return { docsAdded: false, unmappedAdded: false, countUpdated: false };
412
- }
413
- const originalContent = await readText(filePath);
414
- let lines = originalContent.split('\n');
415
- let docsAdded = false;
416
- let unmappedAdded = false;
417
- let countUpdated = false;
418
- const annotation = getModeAnnotation(options.mode, options.value);
419
- const isLiteral = !options.value.startsWith('var(');
420
- const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
421
- const tokenCell = `\`${options.tokenName}\``;
422
- const valueCell = `\`${options.value}\``;
423
- // --- Main token table: Category | Token | Value | Maps to | Mode ---
424
- const mainTable = findTableByColumns(lines, ['Category', 'Token', 'Value', 'Mode']);
425
- if (mainTable) {
426
- // The doc convention allows the Category cell to be blank on
427
- // continuation rows that belong to the previous category. Group rows
428
- // by carrying the last non-empty Category value forward.
429
- let lastGroupRowIndex = -1;
430
- let currentCategory = '';
431
- for (let i = 0; i < mainTable.rows.length; i++) {
432
- const row = mainTable.rows[i];
433
- if (!row)
434
- continue;
435
- const cell = row[0]?.trim() ?? '';
436
- if (cell) {
437
- currentCategory = cell;
438
- }
439
- if (currentCategory === options.category) {
440
- lastGroupRowIndex = i;
441
- }
442
- }
443
- if (lastGroupRowIndex !== -1) {
444
- insertRow(mainTable, ['', tokenCell, valueCell, mapsTo, annotation], lastGroupRowIndex + 1);
445
- lines = rewriteTableRows(lines, mainTable);
446
- docsAdded = true;
447
- }
448
- }
449
- // --- Unmapped table: populated for literal (non-var()) values only ---
450
- if (isLiteral) {
451
- const unmappedTable = findTableAfterHeading(lines, /not yet mapped|unmapped/i);
452
- if (unmappedTable) {
453
- insertRow(unmappedTable, [tokenCell, valueCell, options.description ?? ''], unmappedTable.rows.length);
454
- lines = rewriteTableRows(lines, unmappedTable);
455
- unmappedAdded = true;
456
- }
457
- }
458
- // --- Mode behavior count table: Mode | Count ---
459
- const modeTable = findTableByColumns(lines, ['Mode', 'Count']);
460
- if (modeTable) {
461
- const modeIndex = modeTable.headers.indexOf('Mode');
462
- const countIndex = modeTable.headers.indexOf('Count');
463
- const existing = modeTable.rows.find((row) => stripInlineCode(row[modeIndex] ?? '') === options.mode);
464
- if (existing) {
465
- const oldCount = parseInt(existing[countIndex] ?? '0', 10);
466
- const updated = updateCellByKey(modeTable, 'Mode', existing[modeIndex] ?? options.mode, 'Count', String((Number.isNaN(oldCount) ? 0 : oldCount) + 1));
467
- if (updated) {
468
- lines = rewriteTableRows(lines, modeTable);
469
- countUpdated = true;
470
- }
471
- }
472
- }
473
- await writeText(filePath, lines.join('\n'));
474
- return { docsAdded, unmappedAdded, countUpdated };
475
- }
476
458
  //# sourceMappingURL=token-manager.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Attribute-variant block helpers for the tokens CSS scaffold.
3
+ *
4
+ * `fireforge token add --mode` can author the base `:root { }` block and the
5
+ * dark `@media (prefers-color-scheme: dark)` block, but there was no way to
6
+ * target an attribute-keyed selector such as `:root[data-skin="precision"]`
7
+ * or `:root[data-private]` — forcing those override blocks to be hand-edited.
8
+ * These helpers locate (or compute the insertion point for) a top-level
9
+ * `:root<variant>` block so `token-manager.ts` can splice a declaration into
10
+ * it, keeping all token authoring in the CLI.
11
+ */
12
+ /**
13
+ * Outcome of {@link validateVariantSelector}. `ok: true` carries the
14
+ * normalized (quoted) selector fragment; `ok: false` carries a
15
+ * human-readable reason suitable for throwing as an `InvalidArgumentError`.
16
+ */
17
+ export type VariantValidation = {
18
+ ok: true;
19
+ value: string;
20
+ } | {
21
+ ok: false;
22
+ reason: string;
23
+ };
24
+ /**
25
+ * Validates a `--variant` attribute selector fragment and normalizes any
26
+ * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
27
+ * Boolean-attribute fragments are returned unchanged.
28
+ *
29
+ * @param raw - Raw `--variant` value from the CLI / programmatic caller.
30
+ */
31
+ export declare function validateVariantSelector(raw: unknown): VariantValidation;
32
+ /** True when the `:root<variant>` block already declares `tokenName`. */
33
+ export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
34
+ /**
35
+ * Splices `declLine` into the `:root<variant>` block, creating the block
36
+ * (after the base `:root` block) when absent or appending after the last
37
+ * non-blank line when present. Mutates `lines` in place.
38
+ */
39
+ export declare function insertVariantDeclaration(lines: string[], variant: string, declLine: string): void;
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Attribute-variant block helpers for the tokens CSS scaffold.
4
+ *
5
+ * `fireforge token add --mode` can author the base `:root { }` block and the
6
+ * dark `@media (prefers-color-scheme: dark)` block, but there was no way to
7
+ * target an attribute-keyed selector such as `:root[data-skin="precision"]`
8
+ * or `:root[data-private]` — forcing those override blocks to be hand-edited.
9
+ * These helpers locate (or compute the insertion point for) a top-level
10
+ * `:root<variant>` block so `token-manager.ts` can splice a declaration into
11
+ * it, keeping all token authoring in the CLI.
12
+ */
13
+ import { findBlockCloseIndex, stripBlockCommentsInLines } from './token-dark-mode.js';
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
18
+ * characters so the fragment can be spliced into CSS verbatim without
19
+ * escaping concerns.
20
+ */
21
+ const VARIANT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
22
+ /**
23
+ * Validates a `--variant` attribute selector fragment and normalizes any
24
+ * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
25
+ * Boolean-attribute fragments are returned unchanged.
26
+ *
27
+ * @param raw - Raw `--variant` value from the CLI / programmatic caller.
28
+ */
29
+ export function validateVariantSelector(raw) {
30
+ if (typeof raw !== 'string') {
31
+ return { ok: false, reason: 'must be a string when set' };
32
+ }
33
+ 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
+ };
40
+ }
41
+ const normalized = value
42
+ .replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
43
+ .replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
44
+ return { ok: true, value: normalized };
45
+ }
46
+ /**
47
+ * Reduces a `:root<attr>` selector to a quote- and whitespace-insensitive
48
+ * canonical form so `[data-skin="precision"]`, `[data-skin='precision']`,
49
+ * and `[data-skin=precision]` all compare equal when matching an existing
50
+ * block against the requested variant.
51
+ */
52
+ function canonicalSelector(selector) {
53
+ return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
+ }
55
+ /**
56
+ * Finds the top-level `:root<variant>` block whose attribute selector
57
+ * matches `variant` (compared canonically). Returns the opening-brace and
58
+ * closing-brace line indices, or `null` when no such block exists.
59
+ *
60
+ * Scans a comment-stripped mirror of `lines` so braces inside comments do
61
+ * not offset the depth counter; the returned indices line up with the
62
+ * original array.
63
+ */
64
+ function findVariantBlock(lines, variant) {
65
+ const stripped = stripBlockCommentsInLines(lines);
66
+ const want = canonicalSelector(`:root${variant}`);
67
+ for (let i = 0; i < stripped.length; i++) {
68
+ const line = stripped[i] ?? '';
69
+ const match = /:root\[[^{]*\]/.exec(line);
70
+ if (!match || canonicalSelector(match[0]) !== want)
71
+ continue;
72
+ let openLine = /\{/.test(line) ? i : -1;
73
+ if (openLine === -1) {
74
+ for (let j = i + 1; j < stripped.length; j++) {
75
+ const next = stripped[j] ?? '';
76
+ if (/\{/.test(next)) {
77
+ openLine = j;
78
+ break;
79
+ }
80
+ if (/[};]/.test(next))
81
+ break;
82
+ }
83
+ }
84
+ if (openLine === -1)
85
+ continue;
86
+ const close = findBlockCloseIndex(stripped, openLine);
87
+ if (close === -1)
88
+ continue;
89
+ return { open: openLine, close };
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Returns the line index at which a brand-new `:root<variant>` block should
95
+ * be spliced: immediately after the base `:root { }` block's closing brace
96
+ * (so attribute variants sit between the base block and any dark `@media`
97
+ * block). Falls back to end-of-file when no base `:root` block is found.
98
+ */
99
+ function findVariantBlockInsertionPoint(lines) {
100
+ const stripped = stripBlockCommentsInLines(lines);
101
+ for (let i = 0; i < stripped.length; i++) {
102
+ if (/^\s*:root\s*\{/.test(stripped[i] ?? '')) {
103
+ const close = findBlockCloseIndex(stripped, i);
104
+ if (close !== -1)
105
+ return close + 1;
106
+ }
107
+ }
108
+ return lines.length;
109
+ }
110
+ /** True when the `:root<variant>` block already declares `tokenName`. */
111
+ export function variantBlockHasToken(lines, variant, tokenName) {
112
+ const block = findVariantBlock(lines, variant);
113
+ if (!block)
114
+ return false;
115
+ const blockText = lines
116
+ .slice(block.open, block.close + 1)
117
+ .join('\n')
118
+ .replace(/\/\*[\s\S]*?\*\//g, '');
119
+ return blockText.includes(`${tokenName}:`);
120
+ }
121
+ /**
122
+ * Splices `declLine` into the `:root<variant>` block, creating the block
123
+ * (after the base `:root` block) when absent or appending after the last
124
+ * non-blank line when present. Mutates `lines` in place.
125
+ */
126
+ export function insertVariantDeclaration(lines, variant, declLine) {
127
+ const block = findVariantBlock(lines, variant);
128
+ if (block) {
129
+ let insertIndex = block.close;
130
+ for (let i = block.close - 1; i > block.open; i--) {
131
+ if ((lines[i] ?? '').trim()) {
132
+ insertIndex = i + 1;
133
+ break;
134
+ }
135
+ }
136
+ lines.splice(insertIndex, 0, declLine);
137
+ return;
138
+ }
139
+ lines.splice(findVariantBlockInsertionPoint(lines), 0, '', `:root${variant} {`, declLine, '}');
140
+ }
141
+ //# sourceMappingURL=token-variant.js.map
@@ -40,7 +40,9 @@ export interface ComposedShim {
40
40
  * direction is intentional (declarations later in concat order
41
41
  * augment earlier ones), so a project that wants to refine `Services`
42
42
  * with a more specific type can do so by declaring it in the extra
43
- * shim.
43
+ * shim. Any triple-slash `/// <reference path="…">` directives inside the
44
+ * extra shim are inlined (resolved against the extra shim's own directory)
45
+ * so they are not silently dropped at the synthetic shim path.
44
46
  *
45
47
  * Missing extra-shim files raise a clear error rather than failing
46
48
  * silently with a confusing "type not found" downstream — this is the
@@ -10,7 +10,7 @@
10
10
  * still fail `fireforge typecheck`, or vice versa, for reasons the
11
11
  * operator could not infer from the rule names.
12
12
  */
13
- import { resolve } from 'node:path';
13
+ import { dirname, resolve } from 'node:path';
14
14
  import { pathExists, readText } from '../utils/fs.js';
15
15
  /** Filename used for the synthetic Firefox-globals shim source file. */
16
16
  export const SHIM_FILENAME = '__fireforge_firefox_globals.d.ts';
@@ -110,6 +110,43 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
110
110
  2580, // Cannot find name '{0}'. Do you need to install type definitions...
111
111
  7016, // Could not find a declaration file for module '{0}'.
112
112
  ]);
113
+ /** Matches a lone triple-slash `/// <reference path="…" />` directive line. */
114
+ const TRIPLE_SLASH_REFERENCE = /^\s*\/\/\/\s*<reference\s+path\s*=\s*["']([^"']+)["']\s*\/?>\s*$/;
115
+ /**
116
+ * Inlines triple-slash `/// <reference path="…">` directives in shim source.
117
+ *
118
+ * Both shim consumers feed the text to the compiler at a *synthetic* path
119
+ * (an in-memory source file, not the extra shim's real location), so TS
120
+ * resolves a relative `/// <reference>` against that synthetic directory and
121
+ * silently drops it. Inlining the referenced file's contents (recursively,
122
+ * resolved against the *referencing* file's directory, deduped by absolute
123
+ * path) makes the directives self-contained so their declarations survive.
124
+ *
125
+ * @param source - Shim source possibly containing reference directives
126
+ * @param baseDir - Directory the directives' relative paths resolve against
127
+ * @param seen - Absolute paths already inlined (cycle / duplicate guard)
128
+ */
129
+ async function inlineTripleSlashReferences(source, baseDir, seen) {
130
+ const out = [];
131
+ for (const line of source.split('\n')) {
132
+ const match = TRIPLE_SLASH_REFERENCE.exec(line);
133
+ if (!match?.[1]) {
134
+ out.push(line);
135
+ continue;
136
+ }
137
+ const absolute = resolve(baseDir, match[1]);
138
+ if (seen.has(absolute))
139
+ continue;
140
+ seen.add(absolute);
141
+ if (!(await pathExists(absolute))) {
142
+ out.push(`// (fireforge: unresolved /// <reference path="${match[1]}">)`);
143
+ continue;
144
+ }
145
+ const referenced = await readText(absolute);
146
+ out.push(await inlineTripleSlashReferences(referenced, dirname(absolute), seen));
147
+ }
148
+ return out.join('\n');
149
+ }
113
150
  /**
114
151
  * Composes the synthetic shim source by concatenating the built-in
115
152
  * Firefox globals shim with the contents of an optional user-supplied
@@ -117,7 +154,9 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
117
154
  * direction is intentional (declarations later in concat order
118
155
  * augment earlier ones), so a project that wants to refine `Services`
119
156
  * with a more specific type can do so by declaring it in the extra
120
- * shim.
157
+ * shim. Any triple-slash `/// <reference path="…">` directives inside the
158
+ * extra shim are inlined (resolved against the extra shim's own directory)
159
+ * so they are not silently dropped at the synthetic shim path.
121
160
  *
122
161
  * Missing extra-shim files raise a clear error rather than failing
123
162
  * silently with a confusing "type not found" downstream — this is the
@@ -137,8 +176,9 @@ export async function composeShimSource(projectRoot, extraShimPath) {
137
176
  'Check the path in fireforge.json or create the file.');
138
177
  }
139
178
  const extra = await readText(absoluteShim);
179
+ const inlinedExtra = await inlineTripleSlashReferences(extra, dirname(absoluteShim), new Set([absoluteShim]));
140
180
  return {
141
- source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${extra}`,
181
+ source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${inlinedExtra}`,
142
182
  extraShimAppended: true,
143
183
  };
144
184
  }
@@ -84,42 +84,70 @@ export async function runTypecheck(projectRoot, cfg) {
84
84
  filesChecked: 0,
85
85
  }));
86
86
  }
87
- // Compose the shim once extraShim is shared across all projects.
88
- // A missing or unreadable shim is a project-wide failure, so we
89
- // surface it as one issue per project rather than letting one
90
- // project's read failure silently affect the others' results.
91
- let shimSource;
92
- try {
93
- const composed = await composeShimSource(projectRoot, cfg.extraShim);
94
- shimSource = composed.source;
87
+ // Compose the shim PER project: the effective extraShim is the per-project
88
+ // override (a path, or `null` to opt out) when present, else the shared
89
+ // top-level extraShim. A project that narrows `lib`/`types` can opt out of
90
+ // a Gecko-lib shim hub that another project needs, so the composed shim is
91
+ // no longer injected identically everywhere. Compositions are cached by the
92
+ // resolved extraShim path so projects sharing a shim don't recompose it.
93
+ const shimCache = new Map();
94
+ const composeForProject = async (extraShim) => {
95
+ const key = extraShim ?? '';
96
+ const cached = shimCache.get(key);
97
+ if (cached !== undefined)
98
+ return cached;
99
+ const composed = await composeShimSource(projectRoot, extraShim);
95
100
  if (composed.extraShimAppended) {
96
- verbose(`typecheck: extra shim ${cfg.extraShim ?? ''} appended to Firefox globals shim`);
101
+ verbose(`typecheck: extra shim ${extraShim ?? ''} appended to Firefox globals shim`);
97
102
  }
98
- }
99
- catch (err) {
100
- const message = err instanceof Error ? err.message : String(err);
101
- return cfg.projects.map((project) => ({
102
- project,
103
- issues: [
104
- {
105
- file: cfg.extraShim ?? '(typecheck)',
106
- line: 1,
107
- column: 1,
108
- code: 0,
109
- category: 'error',
110
- message,
111
- project,
112
- },
113
- ],
114
- filesChecked: 0,
115
- }));
116
- }
103
+ shimCache.set(key, composed.source);
104
+ return composed.source;
105
+ };
117
106
  const results = [];
118
107
  for (const projectPath of cfg.projects) {
108
+ const extraShim = resolveProjectExtraShim(cfg, projectPath);
109
+ let shimSource;
110
+ try {
111
+ shimSource = await composeForProject(extraShim);
112
+ }
113
+ catch (err) {
114
+ // A missing or unreadable shim fails only the project(s) that use it,
115
+ // not the whole run — projects with a different (or no) shim still run.
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ results.push({
118
+ project: projectPath,
119
+ issues: [
120
+ {
121
+ file: extraShim ?? '(typecheck)',
122
+ line: 1,
123
+ column: 1,
124
+ code: 0,
125
+ category: 'error',
126
+ message,
127
+ project: projectPath,
128
+ },
129
+ ],
130
+ filesChecked: 0,
131
+ });
132
+ continue;
133
+ }
119
134
  results.push(await runTypecheckForProject(ts, projectRoot, projectPath, shimSource));
120
135
  }
121
136
  return results;
122
137
  }
138
+ /**
139
+ * Resolves the effective extra shim for a single project: a `projectOverrides`
140
+ * entry wins (a string path overrides; `null` opts out → `undefined`), else
141
+ * the shared top-level `extraShim` applies.
142
+ */
143
+ function resolveProjectExtraShim(cfg, projectPath) {
144
+ const overrides = cfg.projectOverrides;
145
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, projectPath)) {
146
+ const value = overrides[projectPath];
147
+ return value === null ? undefined : value;
148
+ }
149
+ return cfg.extraShim;
150
+ }
123
151
  /** Runs typecheck for a single jsconfig path, isolating its failures. */
124
152
  async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource) {
125
153
  const absConfig = resolve(projectRoot, projectPath);
@@ -428,6 +428,15 @@ export interface TestOptions {
428
428
  * command layer.
429
429
  */
430
430
  harnessRetries?: number;
431
+ /**
432
+ * Force dispatch through the generic `mach test` command instead of the
433
+ * suite-specific `mach xpcshell-test` / `mach mochitest` commands a
434
+ * single-suite run auto-selects. Escape hatch for the rare case where a
435
+ * suite-specific command misbehaves; on a healthy host the generic command
436
+ * is equivalent. The default (auto suite dispatch) skips the mozlog
437
+ * resource monitor that crashes `mach test` on a broken host (E1).
438
+ */
439
+ genericMachTest?: boolean;
431
440
  /**
432
441
  * Commander negation flag for `--no-shard`. When false, multiple test
433
442
  * paths run in one combined mach invocation; by default they shard into
@@ -696,6 +705,11 @@ export interface TokenAddOptions {
696
705
  dryRun?: boolean;
697
706
  /** Declare the category banner in the tokens CSS when it does not exist yet. */
698
707
  createCategory?: boolean;
708
+ /**
709
+ * Attribute selector fragment (e.g. `[data-skin=precision]` or
710
+ * `[data-private]`) routing the declaration into a `:root<variant>` block.
711
+ */
712
+ variant?: string;
699
713
  }
700
714
  /**
701
715
  * Options for the doctor command.
@@ -774,6 +788,14 @@ export interface LintCommandOptions {
774
788
  * scope contracts are different.
775
789
  */
776
790
  perPatch?: boolean;
791
+ /**
792
+ * Restrict `--per-patch` to a named subset of the queue (by filename,
793
+ * filename ± `.patch`, or manifest `name`). Lets a change that touches a
794
+ * handful of patches run the per-patch gate over just those instead of
795
+ * the full ~90-patch queue. Only valid with {@link perPatch}; queue-level
796
+ * findings (policy, cross-patch) are scoped to files the subset touches.
797
+ */
798
+ patches?: string[];
777
799
  /**
778
800
  * Maximum warning count tolerated before lint exits non-zero. Mirrors
779
801
  * ESLint's `--max-warnings` shape for release gates that want advisory