@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
@@ -1,13 +1,11 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
- import { toError } from '../utils/errors.js';
4
3
  import { pathExists, readText } from '../utils/fs.js';
5
- import { verbose } from '../utils/logger.js';
6
- import { hasRawCssColors, stripJsComments } from '../utils/regex.js';
7
- import { loadFurnaceConfig } from './furnace-config.js';
4
+ import { stripJsComments } from '../utils/regex.js';
8
5
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
9
6
  import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
10
7
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
+ import { lintPatchedCss } from './patch-lint-css.js';
11
9
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
12
10
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
13
11
  import { hasRelativeImport } from './patch-lint-imports.js';
@@ -25,6 +23,10 @@ import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch
25
23
  // public surface from `patch-lint-reexports.ts` so callers continue to
26
24
  // import from a single module.
27
25
  export * from './patch-lint-reexports.js';
26
+ // The CSS rule bodies live in `patch-lint-css.ts` (same per-file-budget
27
+ // split as the other rule families); re-export the imported binding so
28
+ // callers and tests keep importing `lintPatchedCss` from this module.
29
+ export { lintPatchedCss };
28
30
  // ---------------------------------------------------------------------------
29
31
  // Helpers
30
32
  // ---------------------------------------------------------------------------
@@ -205,158 +207,6 @@ export function commentStyleForFile(file) {
205
207
  return 'js';
206
208
  return null;
207
209
  }
208
- /**
209
- * Loads the furnace token-prefix lint inputs gracefully — returns
210
- * undefined (skipping the token-prefix check) when furnace.json cannot
211
- * be loaded or no tokenPrefix is configured.
212
- */
213
- async function loadCssTokenContext(repoDir) {
214
- try {
215
- const root = join(repoDir, '..');
216
- const furnaceConfig = await loadFurnaceConfig(root);
217
- if (furnaceConfig.tokenPrefix) {
218
- return {
219
- tokenPrefix: furnaceConfig.tokenPrefix,
220
- tokenAllowlist: new Set(furnaceConfig.tokenAllowlist ?? []),
221
- runtimeVariables: new Set(furnaceConfig.runtimeVariables ?? []),
222
- };
223
- }
224
- }
225
- catch (error) {
226
- verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
227
- }
228
- return undefined;
229
- }
230
- /**
231
- * Raw-color check for one patched CSS file, scoped to introduced lines
232
- * when diff context is available. Pushes onto `issues`.
233
- */
234
- function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
235
- // Check only introduced raw color values when diff context is available.
236
- // Skip files on the raw-color allowlist (exact path or basename match) and
237
- // auto-exempt files under `browser/branding/` — those are the fork's
238
- // visual identity assets (app-about dialogs, installer pages, branded
239
- // CSS copied from Firefox's `unofficial` template) and belong to the
240
- // design-decision layer the design-token system does not govern.
241
- // Without this auto-exemption, every first-time setup's copied CSS
242
- // failed `raw-color-value` with no actionable fix other than manually
243
- // listing each path in `rawColorAllowlist`.
244
- const allowlist = config?.patchLint?.rawColorAllowlist;
245
- const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
246
- const isBranding = file.startsWith('browser/branding/');
247
- if (!isAllowlisted && !isBranding) {
248
- // Strip lines with inline fireforge-ignore: raw-color-value suppression.
249
- // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
250
- const sourceForSuppression = addedLinesByFile
251
- ? (addedLinesByFile.get(file) ?? []).join('\n')
252
- : rawCss;
253
- const suppressedContent = sourceForSuppression
254
- .split('\n')
255
- .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
256
- .join('\n')
257
- .replace(/\/\*[\s\S]*?\*\//g, '');
258
- if (hasRawCssColors(suppressedContent)) {
259
- issues.push({
260
- file,
261
- check: 'raw-color-value',
262
- message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
263
- severity: 'error',
264
- });
265
- }
266
- }
267
- }
268
- /**
269
- * Token-prefix check for one patched CSS file: flags `var(--x)` references
270
- * that match neither the configured prefix, the allowlist, the runtime
271
- * variables, nor a same-file declaration. Pushes onto `issues`.
272
- */
273
- function checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues) {
274
- // Check for non-tokenized custom properties. A variable that is both
275
- // declared and consumed inside the same file is auto-exempted as a
276
- // runtime state channel (see furnace.json → runtimeVariables).
277
- //
278
- // When diff context is available, scope the `var(...)` scan to
279
- // added/modified lines only. `cssContent` (full-file) is still the
280
- // source of `localDeclarations` so vars declared anywhere in the file
281
- // are recognised as same-file refs regardless of where the consuming
282
- // `var(...)` appears. Before this scoping change, a small edit to a
283
- // Furnace override of a stock component (e.g. moz-card) produced a
284
- // `token-prefix-violation` for every stock `var(--moz-card-*)` the
285
- // upstream file already carried, because the scanner saw the full
286
- // applied file and flagged each inherited reference as if the fork
287
- // had introduced it.
288
- if (tokenContext) {
289
- const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
290
- const localDeclarations = new Set();
291
- let declMatch;
292
- while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
293
- const name = declMatch[1];
294
- if (name)
295
- localDeclarations.add(name);
296
- }
297
- const prefixScanSource = addedLinesByFile
298
- ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
299
- : cssContent;
300
- if (prefixScanSource.length > 0) {
301
- const varPattern = /var\(\s*(--[\w-]+)/g;
302
- const flaggedProps = new Set();
303
- let match;
304
- while ((match = varPattern.exec(prefixScanSource)) !== null) {
305
- const prop = match[1];
306
- if (!prop)
307
- continue;
308
- if (prop.startsWith(tokenContext.tokenPrefix))
309
- continue;
310
- if (tokenContext.tokenAllowlist.has(prop))
311
- continue;
312
- if (tokenContext.runtimeVariables.has(prop))
313
- continue;
314
- if (localDeclarations.has(prop))
315
- continue;
316
- // De-duplicate per (file, prop) pair so the same introduced var
317
- // used five times in the added hunk doesn't produce five
318
- // identical issue entries.
319
- if (flaggedProps.has(prop))
320
- continue;
321
- flaggedProps.add(prop);
322
- issues.push({
323
- file,
324
- check: 'token-prefix-violation',
325
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenContext.tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
326
- severity: 'error',
327
- });
328
- }
329
- }
330
- }
331
- }
332
- /**
333
- * Lints patched CSS files for introduced raw color values and non-tokenized
334
- * custom properties.
335
- *
336
- * @param repoDir - Absolute path to the engine (repository) directory
337
- * @param affectedFiles - File paths (relative to repoDir) affected by the patch
338
- * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
339
- * @returns Array of lint issues found
340
- */
341
- export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config) {
342
- const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
343
- if (cssFiles.length === 0)
344
- return [];
345
- const tokenContext = await loadCssTokenContext(repoDir);
346
- const issues = [];
347
- const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
348
- for (const file of cssFiles) {
349
- const filePath = join(repoDir, file);
350
- if (!(await pathExists(filePath)))
351
- continue;
352
- const rawCss = await readText(filePath);
353
- // Strip block comments before scanning
354
- const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
355
- checkRawColorValues(file, rawCss, addedLinesByFile, config, issues);
356
- checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues);
357
- }
358
- return issues;
359
- }
360
210
  // ---------------------------------------------------------------------------
361
211
  // License header lint
362
212
  // ---------------------------------------------------------------------------
@@ -695,9 +545,6 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
695
545
  }
696
546
  return issues;
697
547
  }
698
- // ---------------------------------------------------------------------------
699
- // Orchestrator
700
- // ---------------------------------------------------------------------------
701
548
  /**
702
549
  * Runs all patch lint checks and returns combined issues.
703
550
  *
@@ -717,9 +564,11 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
717
564
  * per-patch manifest context (re-export, per-patch lint) should
718
565
  * pass this; aggregate-mode callers without a specific patch
719
566
  * context skip it and fall through to auto-detection.
567
+ * @param options - Optional behaviour switches; see
568
+ * {@link LintExportedPatchOptions}.
720
569
  * @returns Array of all lint issues found
721
570
  */
722
- export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks, patchTier) {
571
+ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks, patchTier, options) {
723
572
  const newFiles = detectNewFilesInDiff(diffContent);
724
573
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
725
574
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
@@ -731,7 +580,9 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
731
580
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
732
581
  ]);
733
582
  const modCommentIssues = lintModificationComments(diffContent, config);
734
- const sizeIssues = lintPatchSize(affectedFiles, lineCount, patchTier);
583
+ const sizeIssues = options?.skipPatchSize
584
+ ? []
585
+ : lintPatchSize(affectedFiles, lineCount, patchTier);
735
586
  const issues = [
736
587
  ...sizeIssues,
737
588
  ...cssIssues,
@@ -740,8 +591,13 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
740
591
  ...jsIssues,
741
592
  ...modCommentIssues,
742
593
  ];
743
- if (config.patchLint?.checkJs) {
744
- issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir))));
594
+ if (options?.precomputedCheckJs) {
595
+ // Per-patch lint built one queue-wide program and already attributed
596
+ // this patch's findings — append them instead of rebuilding the program.
597
+ issues.push(...options.precomputedCheckJs);
598
+ }
599
+ else if (config.patchLint?.checkJs) {
600
+ issues.push(...(await invokePatchLintCheckJs(repoDir, patchOwnedFiles, config.patchLint, dirname(repoDir), options?.checkJsReportScope)));
745
601
  }
746
602
  // Filter out ignored checks last so every rule still runs (keeps the
747
603
  // implementation uniform) but suppressed rules do not surface. We do not
@@ -44,9 +44,12 @@ export declare function detectHarnessCrashSignature(output: string): HarnessCras
44
44
  * 1. A recognized crash signature wins regardless of exit code (the
45
45
  * shutdown re-entry shape exits non-zero on an otherwise green run;
46
46
  * the hang shape can even exit zero with a `Passed: 0` summary).
47
- * 2. No `TEST-START` with explicit paths requested means no test ran —
48
- * `no-tests`, even when the exit code is zero. Summary lines are not
49
- * trusted as evidence of execution.
47
+ * 2. No execution signal with explicit paths requested means no test ran —
48
+ * `no-tests`, even when the exit code is zero. The execution signal is
49
+ * a `TEST-START` line (generic `mach test` / browser-chrome dispatch)
50
+ * OR the suite-specific xpcshell result-summary block (the xpcshell
51
+ * dispatch prints no `TEST-START`). Bare `Passed:`/`Failed:` summary
52
+ * lines are still not trusted as evidence of execution.
50
53
  * 3. Exit code zero with tests started is a pass; anything else is a
51
54
  * test failure for the regular diagnosis chain.
52
55
  */
@@ -23,6 +23,20 @@
23
23
  * of reporting phantom test failures (or phantom passes).
24
24
  */
25
25
  const TEST_START_PATTERN = /\bTEST-START\b/;
26
+ /**
27
+ * Execution signals emitted by the suite-specific xpcshell dispatch
28
+ * (`mach xpcshell-test`), which does NOT print `TEST-START` lines the way
29
+ * the generic `mach test` / browser-chrome dispatch does. A passing
30
+ * single-file xpcshell run prints a result-summary block instead
31
+ * (`TEST_END: Test PASS`, `Ran 16 checks`, `Unexpected results: 0`), so
32
+ * keying execution purely on `TEST-START` mis-reads a green xpcshell run as
33
+ * "no tests started" (field report: a single-file xpcshell pass exited 1).
34
+ *
35
+ * These markers are xpcshell-specific on purpose: the bare
36
+ * `Passed: 0` / `Failed: 0` summary that the no-output hang shape prints is
37
+ * deliberately NOT matched here — that case must still read as `no-tests`.
38
+ */
39
+ const XPCSHELL_RESULT_SUMMARY_PATTERN = /\bTEST_END\b|\bRan \d+ checks?\b|\bResult summary:/i;
26
40
  const UNEXPECTED_LINE_PATTERN = /^.*\bTEST-UNEXPECTED-[A-Z-]+\b.*$/gm;
27
41
  const SHUTDOWN_REENTRY_PATTERN = /Application shut down \(without crashing\) in the middle of a test/i;
28
42
  const FOCUS_STALL_PATTERN = /must wait for focus/i;
@@ -49,6 +63,16 @@ function findLine(output, patterns) {
49
63
  }
50
64
  return undefined;
51
65
  }
66
+ /**
67
+ * True when the captured output carries the suite-specific xpcshell
68
+ * result-summary block, which proves tests executed even though the
69
+ * xpcshell dispatch emits no `TEST-START` line. Used alongside
70
+ * `TEST_START_PATTERN` so a green single-file xpcshell run is not
71
+ * mis-classified as `no-tests`. Exported for direct unit testing.
72
+ */
73
+ function hasXpcshellResultSummary(output) {
74
+ return XPCSHELL_RESULT_SUMMARY_PATTERN.test(output);
75
+ }
52
76
  /** Unexpected-failure lines that are NOT the shutdown re-entry artifact. */
53
77
  function realUnexpectedFailureLines(output) {
54
78
  const matches = output.match(UNEXPECTED_LINE_PATTERN) ?? [];
@@ -95,9 +119,12 @@ export function detectHarnessCrashSignature(output) {
95
119
  * 1. A recognized crash signature wins regardless of exit code (the
96
120
  * shutdown re-entry shape exits non-zero on an otherwise green run;
97
121
  * the hang shape can even exit zero with a `Passed: 0` summary).
98
- * 2. No `TEST-START` with explicit paths requested means no test ran —
99
- * `no-tests`, even when the exit code is zero. Summary lines are not
100
- * trusted as evidence of execution.
122
+ * 2. No execution signal with explicit paths requested means no test ran —
123
+ * `no-tests`, even when the exit code is zero. The execution signal is
124
+ * a `TEST-START` line (generic `mach test` / browser-chrome dispatch)
125
+ * OR the suite-specific xpcshell result-summary block (the xpcshell
126
+ * dispatch prints no `TEST-START`). Bare `Passed:`/`Failed:` summary
127
+ * lines are still not trusted as evidence of execution.
101
128
  * 3. Exit code zero with tests started is a pass; anything else is a
102
129
  * test failure for the regular diagnosis chain.
103
130
  */
@@ -106,7 +133,8 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
106
133
  if (signature) {
107
134
  return { kind: 'harness-crash', signature };
108
135
  }
109
- if (!TEST_START_PATTERN.test(output) && requestedPaths.length > 0) {
136
+ const ranTests = TEST_START_PATTERN.test(output) || hasXpcshellResultSummary(output);
137
+ if (!ranTests && requestedPaths.length > 0) {
110
138
  return { kind: 'no-tests' };
111
139
  }
112
140
  return exitCode === 0 ? { kind: 'tests-ran-ok' } : { kind: 'test-failures' };
@@ -3,5 +3,12 @@ export interface XpcshellRetryClassification {
3
3
  xpcshell: readonly string[];
4
4
  nonXpcshell: readonly string[];
5
5
  }
6
- /** Removes a stale xpcshell install symlink and retries the focused mach test once. */
7
- export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>): Promise<MachCommandResult>;
6
+ /** Dispatches a (possibly suite-specific) mach test run, mirroring `testWithOutput`. */
7
+ export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string>) => Promise<MachCommandResult>;
8
+ /**
9
+ * Removes a stale xpcshell install symlink and retries the focused mach test
10
+ * once. The retry uses the same `dispatch` (suite-specific or generic) the
11
+ * caller is already running on, so an xpcshell-suite run repairs and re-runs
12
+ * via `mach xpcshell-test` rather than falling back to the generic command.
13
+ */
14
+ export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch): Promise<MachCommandResult>;
@@ -1,16 +1,21 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { testWithOutput } from './mach.js';
3
3
  import { tryRepairStaleXpcshellTestSymlink } from './test-stale-symlink.js';
4
- /** Removes a stale xpcshell install symlink and retries the focused mach test once. */
5
- export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env) {
4
+ /**
5
+ * Removes a stale xpcshell install symlink and retries the focused mach test
6
+ * once. The retry uses the same `dispatch` (suite-specific or generic) the
7
+ * caller is already running on, so an xpcshell-suite run repairs and re-runs
8
+ * via `mach xpcshell-test` rather than falling back to the generic command.
9
+ */
10
+ export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput) {
6
11
  if (result.exitCode !== 0 &&
7
12
  classification.xpcshell.length > 0 &&
8
13
  classification.nonXpcshell.length === 0) {
9
14
  const repaired = await tryRepairStaleXpcshellTestSymlink(engineDir, objDir, `${result.stdout}\n${result.stderr}`);
10
15
  if (repaired) {
11
16
  return env
12
- ? testWithOutput(engineDir, normalizedPaths, extraArgs, env)
13
- : testWithOutput(engineDir, normalizedPaths, extraArgs);
17
+ ? dispatch(engineDir, normalizedPaths, extraArgs, env)
18
+ : dispatch(engineDir, normalizedPaths, extraArgs);
14
19
  }
15
20
  }
16
21
  return result;
@@ -38,6 +38,15 @@ export declare function stripBlockCommentsInLines(lines: string[]): string[];
38
38
  * can splice into the original `lines` array at the returned index.
39
39
  */
40
40
  export declare function findDarkRootInsertionIndex(lines: string[]): number | null;
41
+ /**
42
+ * Depth-counts braces from `startLine` (whose lines must already have
43
+ * block comments stripped), returning the index of the line on which the
44
+ * block opened there returns to its entry depth — i.e. the line carrying
45
+ * the block's closing `}` — or -1 when the block never closes. The first
46
+ * `{` encountered sets the entry depth, so the scan may start on the
47
+ * selector/at-rule line itself rather than on the opener.
48
+ */
49
+ export declare function findBlockCloseIndex(stripped: string[], startLine: number): number;
41
50
  /**
42
51
  * Finds the closing `}` of the outermost
43
52
  * `@media (prefers-color-scheme: dark)` block. Used as the fallback
@@ -122,7 +122,7 @@ export function findDarkRootInsertionIndex(lines) {
122
122
  * `{` encountered sets the entry depth, so the scan may start on the
123
123
  * selector/at-rule line itself rather than on the opener.
124
124
  */
125
- function findBlockCloseIndex(stripped, startLine) {
125
+ export function findBlockCloseIndex(stripped, startLine) {
126
126
  let depth = 0;
127
127
  let entryDepth = 0;
128
128
  let enteredBlock = false;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Documentation-table updates for `fireforge token add`. Extracted from
3
+ * `token-manager.ts` so the CSS-mutation path and the Markdown-table path
4
+ * each stay within the per-file line budget. Consumed only by token-manager.
5
+ */
6
+ /**
7
+ * Minimal token shape the docs updater needs. Declared locally (rather than
8
+ * importing `AddTokenOptions`) so this module has no edge back to
9
+ * `token-manager.ts` — `AddTokenOptions` is structurally compatible.
10
+ */
11
+ export interface TokenDocInput {
12
+ tokenName: string;
13
+ value: string;
14
+ category: string;
15
+ mode: string;
16
+ description?: string | undefined;
17
+ }
18
+ /**
19
+ * Adds a token row to the main token table, the unmapped table (for
20
+ * literal values), and bumps the mode count table. Each sub-update runs
21
+ * against a freshly parsed view of the document so that splice indices
22
+ * stay valid as rewrites are layered.
23
+ *
24
+ * @param annotation - The mode annotation string the caller already computed
25
+ * (kept here as a parameter so this module needs no dependency on
26
+ * token-manager's `getModeAnnotation`).
27
+ */
28
+ export declare function addTokenToDocs(engineDir: string, options: TokenDocInput, annotation: string): Promise<{
29
+ docsAdded: boolean;
30
+ unmappedAdded: boolean;
31
+ countUpdated: boolean;
32
+ }>;
@@ -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.