@hominis/fireforge 0.32.0 → 0.34.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 (81) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/src/commands/build.js +9 -1
  3. package/dist/src/commands/doctor-furnace-jar.d.ts +16 -0
  4. package/dist/src/commands/doctor-furnace-jar.js +47 -0
  5. package/dist/src/commands/doctor-furnace.js +2 -0
  6. package/dist/src/commands/export-flow.js +4 -1
  7. package/dist/src/commands/export-placement-gate.js +4 -1
  8. package/dist/src/commands/export.js +61 -4
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +20 -0
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +52 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +10 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +31 -4
  13. package/dist/src/commands/furnace/create-browser-test.d.ts +30 -0
  14. package/dist/src/commands/furnace/create-browser-test.js +180 -0
  15. package/dist/src/commands/furnace/create-mochikit.js +11 -4
  16. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  17. package/dist/src/commands/furnace/create-xpcshell.js +38 -12
  18. package/dist/src/commands/furnace/create.js +7 -114
  19. package/dist/src/commands/furnace/index.js +6 -1
  20. package/dist/src/commands/furnace/scan.d.ts +1 -0
  21. package/dist/src/commands/furnace/scan.js +57 -27
  22. package/dist/src/commands/furnace/validate.js +17 -1
  23. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  24. package/dist/src/commands/patch/split-plan.js +90 -16
  25. package/dist/src/commands/patch/split.js +12 -3
  26. package/dist/src/commands/re-export-options.d.ts +26 -0
  27. package/dist/src/commands/re-export-options.js +48 -1
  28. package/dist/src/commands/re-export.js +4 -1
  29. package/dist/src/commands/register.js +10 -1
  30. package/dist/src/commands/test-diagnose.js +12 -0
  31. package/dist/src/commands/test.js +29 -22
  32. package/dist/src/commands/token.js +12 -1
  33. package/dist/src/commands/typecheck.js +35 -0
  34. package/dist/src/core/build-prepare.js +23 -3
  35. package/dist/src/core/config-validate.js +26 -0
  36. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  37. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  38. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  39. package/dist/src/core/furnace-apply-ftl.js +97 -1
  40. package/dist/src/core/furnace-apply-helpers.js +10 -80
  41. package/dist/src/core/furnace-registration.d.ts +24 -0
  42. package/dist/src/core/furnace-registration.js +56 -3
  43. package/dist/src/core/furnace-validate-registration.js +18 -0
  44. package/dist/src/core/mach-resource-shim.d.ts +58 -0
  45. package/dist/src/core/mach-resource-shim.js +290 -0
  46. package/dist/src/core/mach.d.ts +51 -10
  47. package/dist/src/core/mach.js +76 -25
  48. package/dist/src/core/manifest-helpers.d.ts +13 -0
  49. package/dist/src/core/manifest-helpers.js +29 -4
  50. package/dist/src/core/manifest-rules.d.ts +13 -3
  51. package/dist/src/core/manifest-rules.js +44 -17
  52. package/dist/src/core/patch-export.d.ts +10 -0
  53. package/dist/src/core/patch-export.js +23 -2
  54. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  55. package/dist/src/core/patch-lint-cross.js +83 -63
  56. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  57. package/dist/src/core/patch-lint-reexports.js +1 -1
  58. package/dist/src/core/register-module.d.ts +1 -1
  59. package/dist/src/core/register-module.js +15 -2
  60. package/dist/src/core/register-result.d.ts +9 -0
  61. package/dist/src/core/register-scaffold.d.ts +55 -0
  62. package/dist/src/core/register-scaffold.js +146 -0
  63. package/dist/src/core/register-test-manifest.js +3 -1
  64. package/dist/src/core/register-xpcshell-test.d.ts +30 -0
  65. package/dist/src/core/register-xpcshell-test.js +96 -0
  66. package/dist/src/core/test-harness-crash.d.ts +32 -4
  67. package/dist/src/core/test-harness-crash.js +152 -14
  68. package/dist/src/core/token-dark-mode.d.ts +9 -0
  69. package/dist/src/core/token-dark-mode.js +1 -1
  70. package/dist/src/core/token-docs.d.ts +32 -0
  71. package/dist/src/core/token-docs.js +101 -0
  72. package/dist/src/core/token-manager.d.ts +8 -0
  73. package/dist/src/core/token-manager.js +77 -95
  74. package/dist/src/core/token-variant.d.ts +39 -0
  75. package/dist/src/core/token-variant.js +141 -0
  76. package/dist/src/core/typecheck.js +56 -28
  77. package/dist/src/core/xpcshell-appdir.d.ts +7 -0
  78. package/dist/src/core/xpcshell-appdir.js +12 -3
  79. package/dist/src/types/commands/options.d.ts +17 -0
  80. package/dist/src/types/config.d.ts +13 -0
  81. package/package.json +4 -4
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Documentation-table updates for `fireforge token add`. Extracted from
4
+ * `token-manager.ts` so the CSS-mutation path and the Markdown-table path
5
+ * each stay within the per-file line budget. Consumed only by token-manager.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { pathExists, readText, writeText } from '../utils/fs.js';
9
+ import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
10
+ const TOKENS_DOC = 'docs/design/SRC_TOKENS.md';
11
+ /**
12
+ * Strips surrounding backticks from a cell, if present. Token cells are
13
+ * usually wrapped in inline code fences (`` `--foo` ``) and the parser
14
+ * returns them verbatim.
15
+ */
16
+ function stripInlineCode(cell) {
17
+ const trimmed = cell.trim();
18
+ if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
19
+ return trimmed.slice(1, -1);
20
+ }
21
+ return trimmed;
22
+ }
23
+ /**
24
+ * Adds a token row to the main token table, the unmapped table (for
25
+ * literal values), and bumps the mode count table. Each sub-update runs
26
+ * against a freshly parsed view of the document so that splice indices
27
+ * stay valid as rewrites are layered.
28
+ *
29
+ * @param annotation - The mode annotation string the caller already computed
30
+ * (kept here as a parameter so this module needs no dependency on
31
+ * token-manager's `getModeAnnotation`).
32
+ */
33
+ export async function addTokenToDocs(engineDir, options, annotation) {
34
+ const filePath = join(engineDir, '..', TOKENS_DOC);
35
+ if (!(await pathExists(filePath))) {
36
+ // Docs file is optional
37
+ return { docsAdded: false, unmappedAdded: false, countUpdated: false };
38
+ }
39
+ const originalContent = await readText(filePath);
40
+ let lines = originalContent.split('\n');
41
+ let docsAdded = false;
42
+ let unmappedAdded = false;
43
+ let countUpdated = false;
44
+ const isLiteral = !options.value.startsWith('var(');
45
+ const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
46
+ const tokenCell = `\`${options.tokenName}\``;
47
+ const valueCell = `\`${options.value}\``;
48
+ // --- Main token table: Category | Token | Value | Maps to | Mode ---
49
+ const mainTable = findTableByColumns(lines, ['Category', 'Token', 'Value', 'Mode']);
50
+ if (mainTable) {
51
+ // The doc convention allows the Category cell to be blank on
52
+ // continuation rows that belong to the previous category. Group rows
53
+ // by carrying the last non-empty Category value forward.
54
+ let lastGroupRowIndex = -1;
55
+ let currentCategory = '';
56
+ for (let i = 0; i < mainTable.rows.length; i++) {
57
+ const row = mainTable.rows[i];
58
+ if (!row)
59
+ continue;
60
+ const cell = row[0]?.trim() ?? '';
61
+ if (cell) {
62
+ currentCategory = cell;
63
+ }
64
+ if (currentCategory === options.category) {
65
+ lastGroupRowIndex = i;
66
+ }
67
+ }
68
+ if (lastGroupRowIndex !== -1) {
69
+ insertRow(mainTable, ['', tokenCell, valueCell, mapsTo, annotation], lastGroupRowIndex + 1);
70
+ lines = rewriteTableRows(lines, mainTable);
71
+ docsAdded = true;
72
+ }
73
+ }
74
+ // --- Unmapped table: populated for literal (non-var()) values only ---
75
+ if (isLiteral) {
76
+ const unmappedTable = findTableAfterHeading(lines, /not yet mapped|unmapped/i);
77
+ if (unmappedTable) {
78
+ insertRow(unmappedTable, [tokenCell, valueCell, options.description ?? ''], unmappedTable.rows.length);
79
+ lines = rewriteTableRows(lines, unmappedTable);
80
+ unmappedAdded = true;
81
+ }
82
+ }
83
+ // --- Mode behavior count table: Mode | Count ---
84
+ const modeTable = findTableByColumns(lines, ['Mode', 'Count']);
85
+ if (modeTable) {
86
+ const modeIndex = modeTable.headers.indexOf('Mode');
87
+ const countIndex = modeTable.headers.indexOf('Count');
88
+ const existing = modeTable.rows.find((row) => stripInlineCode(row[modeIndex] ?? '') === options.mode);
89
+ if (existing) {
90
+ const oldCount = parseInt(existing[countIndex] ?? '0', 10);
91
+ const updated = updateCellByKey(modeTable, 'Mode', existing[modeIndex] ?? options.mode, 'Count', String((Number.isNaN(oldCount) ? 0 : oldCount) + 1));
92
+ if (updated) {
93
+ lines = rewriteTableRows(lines, modeTable);
94
+ countUpdated = true;
95
+ }
96
+ }
97
+ }
98
+ await writeText(filePath, lines.join('\n'));
99
+ return { docsAdded, unmappedAdded, countUpdated };
100
+ }
101
+ //# sourceMappingURL=token-docs.js.map
@@ -22,6 +22,14 @@ export interface AddTokenOptions {
22
22
  dryRun?: boolean | undefined;
23
23
  /** Declare the category banner in the tokens CSS when it does not exist yet. */
24
24
  createCategory?: boolean | undefined;
25
+ /**
26
+ * Attribute selector fragment (e.g. `[data-skin="precision"]` or
27
+ * `[data-private]`) that routes the declaration into a top-level
28
+ * `:root<variant>` block instead of the base `:root` / category section.
29
+ * The block is created if absent and appended to if present. Variant
30
+ * overrides are CSS-only — the base token already owns its docs row.
31
+ */
32
+ variant?: string | undefined;
25
33
  }
26
34
  /**
27
35
  * Result of adding a token.
@@ -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
@@ -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);
@@ -77,6 +77,13 @@ export declare function parseAppdirFromToml(tomlText: string, key: string): {
77
77
  *
78
78
  * Special-cases `startPath` itself when it already ends with
79
79
  * `xpcshell.toml` — operators sometimes pass a manifest path directly.
80
+ *
81
+ * When `startPath` is a DIRECTORY, the walk starts at the directory itself
82
+ * (checking `<dir>/xpcshell.toml` first) rather than at its parent. Field
83
+ * report (0.34.0 cycle): a directory whose own manifest is an
84
+ * `xpcshell.toml` was classified non-xpcshell — the walk began at the
85
+ * parent, missed the manifest, and `fireforge test <dir>` dispatched to
86
+ * the mochitest runner, which found no mochitests.
80
87
  */
81
88
  export declare function findNearestXpcshellManifest(engineDir: string, startPath: string): Promise<string | null>;
82
89
  /**