@hominis/fireforge 0.44.0 → 0.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +20 -121
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  6. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  7. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  8. package/dist/src/commands/doctor.d.ts +1 -1
  9. package/dist/src/commands/doctor.js +48 -62
  10. package/dist/src/commands/furnace/apply.js +2 -2
  11. package/dist/src/commands/furnace/deploy.js +2 -2
  12. package/dist/src/commands/furnace/diff.js +6 -1
  13. package/dist/src/commands/furnace/index.js +12 -6
  14. package/dist/src/commands/furnace/init.js +5 -2
  15. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  16. package/dist/src/commands/furnace/sync.js +2 -2
  17. package/dist/src/commands/import.js +6 -2
  18. package/dist/src/commands/re-export-files.js +63 -23
  19. package/dist/src/commands/re-export-scan.js +18 -2
  20. package/dist/src/commands/re-export-single.js +19 -7
  21. package/dist/src/commands/test-register.js +5 -1
  22. package/dist/src/commands/test-run.d.ts +5 -0
  23. package/dist/src/commands/test-run.js +11 -4
  24. package/dist/src/commands/test-verdict.js +10 -1
  25. package/dist/src/commands/test.js +9 -0
  26. package/dist/src/commands/token.js +33 -11
  27. package/dist/src/core/build-audit-resolve.js +12 -3
  28. package/dist/src/core/build-audit.js +6 -2
  29. package/dist/src/core/config-mutate.js +20 -19
  30. package/dist/src/core/furnace-apply-ftl.js +4 -1
  31. package/dist/src/core/furnace-apply-helpers.js +11 -5
  32. package/dist/src/core/furnace-jsconfig.js +5 -3
  33. package/dist/src/core/furnace-operation.d.ts +25 -2
  34. package/dist/src/core/furnace-operation.js +27 -2
  35. package/dist/src/core/git-base.d.ts +14 -0
  36. package/dist/src/core/git-base.js +17 -0
  37. package/dist/src/core/git.js +3 -1
  38. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  39. package/dist/src/core/mach-known-noise-filter.js +19 -0
  40. package/dist/src/core/mach.d.ts +8 -3
  41. package/dist/src/core/mach.js +47 -3
  42. package/dist/src/core/patch-lint-checkjs.js +25 -4
  43. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  44. package/dist/src/core/patch-manifest-consistency.js +96 -23
  45. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  46. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  47. package/dist/src/core/patch-manifest.d.ts +2 -1
  48. package/dist/src/core/patch-manifest.js +2 -1
  49. package/dist/src/core/run-log.d.ts +60 -0
  50. package/dist/src/core/run-log.js +183 -0
  51. package/dist/src/core/test-harness-output.d.ts +24 -1
  52. package/dist/src/core/test-harness-output.js +46 -15
  53. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  54. package/dist/src/core/test-xpcshell-retry.js +6 -1
  55. package/dist/src/core/token-category.js +61 -7
  56. package/dist/src/core/token-dark-mode.js +20 -2
  57. package/dist/src/core/token-docs.js +1 -1
  58. package/dist/src/core/token-manager.d.ts +7 -2
  59. package/dist/src/core/token-manager.js +74 -20
  60. package/dist/src/core/token-variant.d.ts +34 -0
  61. package/dist/src/core/token-variant.js +98 -8
  62. package/dist/src/core/tree-store.js +5 -1
  63. package/dist/src/core/typecheck.js +7 -1
  64. package/dist/src/types/commands/options.d.ts +53 -1
  65. package/dist/src/utils/child-env.d.ts +30 -0
  66. package/dist/src/utils/child-env.js +30 -0
  67. package/dist/src/utils/options.d.ts +28 -0
  68. package/dist/src/utils/options.js +32 -1
  69. package/dist/src/utils/process.d.ts +22 -0
  70. package/dist/src/utils/process.js +8 -7
  71. package/package.json +2 -2
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-run output logs under `.fireforge/logs/`.
3
+ *
4
+ * A run's diagnosis used to exist in exactly one place: the terminal. Piping
5
+ * through `tail`/`grep` — the ergonomic default when output is long — keeps
6
+ * the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
7
+ * broke, and it launders the exit code besides. The operator rule ("never
8
+ * pipe a run") was written down three times downstream and broken after each
9
+ * writing, which is the tell that it is not an operator problem: the tool is
10
+ * the only party that can make the log survive the mistake.
11
+ *
12
+ * So `test` and `build` write their own complete copy as they stream, and the
13
+ * `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
14
+ * backgrounded run then still leaves a re-readable artifact.
15
+ *
16
+ * Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
17
+ * a run must never fail because one could not be opened, written, or pruned.
18
+ * Every entry point swallows its errors and degrades to "no log", which is
19
+ * exactly the behaviour that existed before this module.
20
+ */
21
+ /** A run log sink. `write` never throws. */
22
+ export interface RunLog {
23
+ /** Absolute path to the log file. */
24
+ path: string;
25
+ /** Appends raw output. Silently drops on any stream error. */
26
+ write: (chunk: string) => void;
27
+ /** Flushes and closes. Resolves even when the stream already failed. */
28
+ close: () => Promise<void>;
29
+ }
30
+ /** `.fireforge/logs` under `projectRoot`. */
31
+ export declare function getRunLogDir(projectRoot: string): string;
32
+ /**
33
+ * Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
34
+ * logs of the same kind first.
35
+ *
36
+ * @param projectRoot - Project root directory
37
+ * @param command - Command kind, e.g. `test` or `build`
38
+ * @param now - Clock injection point for deterministic tests
39
+ * @returns The sink, or undefined when no log could be opened
40
+ */
41
+ export declare function openRunLog(projectRoot: string, command: string, now?: Date): Promise<RunLog | undefined>;
42
+ /** Installs (or clears, with `undefined`) the run log for this process. */
43
+ export declare function setActiveRunLog(log: RunLog | undefined): void;
44
+ /** Absolute path of the active run log, or undefined when none is open. */
45
+ export declare function getActiveRunLogPath(): string | undefined;
46
+ /** Appends to the active run log. A no-op when none is open. */
47
+ export declare function writeToActiveRunLog(chunk: string): void;
48
+ /** Closes and clears the active run log. Resolves even when none is open. */
49
+ export declare function closeActiveRunLog(): Promise<void>;
50
+ /**
51
+ * A writable that forwards to `base` AND to the active run log.
52
+ *
53
+ * Used by the stdio-inheriting capture path, whose collectors take a mirror
54
+ * stream rather than per-chunk callbacks — so the tee reuses the mirror the
55
+ * exec layer already supports instead of growing a second hook.
56
+ *
57
+ * @param base - The stream to forward to (normally `process.stdout`)
58
+ * @returns A writable teeing into `base` and the run log
59
+ */
60
+ export declare function teeToRunLog(base: NodeJS.WritableStream): NodeJS.WritableStream;
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Per-run output logs under `.fireforge/logs/`.
4
+ *
5
+ * A run's diagnosis used to exist in exactly one place: the terminal. Piping
6
+ * through `tail`/`grep` — the ergonomic default when output is long — keeps
7
+ * the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
8
+ * broke, and it launders the exit code besides. The operator rule ("never
9
+ * pipe a run") was written down three times downstream and broken after each
10
+ * writing, which is the tell that it is not an operator problem: the tool is
11
+ * the only party that can make the log survive the mistake.
12
+ *
13
+ * So `test` and `build` write their own complete copy as they stream, and the
14
+ * `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
15
+ * backgrounded run then still leaves a re-readable artifact.
16
+ *
17
+ * Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
18
+ * a run must never fail because one could not be opened, written, or pruned.
19
+ * Every entry point swallows its errors and degrades to "no log", which is
20
+ * exactly the behaviour that existed before this module.
21
+ */
22
+ import { createWriteStream } from 'node:fs';
23
+ import { mkdir, readdir, unlink } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+ import { Writable } from 'node:stream';
26
+ import { verbose } from '../utils/logger.js';
27
+ import { FIREFORGE_DIR } from './config-paths.js';
28
+ /** Directory under `.fireforge/` holding run logs. */
29
+ const LOGS_DIRNAME = 'logs';
30
+ /**
31
+ * How many logs to keep per command kind. Retention is per-kind so a busy
32
+ * `test` loop cannot evict every `build` log, which is the pair an operator
33
+ * most often needs to read together.
34
+ */
35
+ const RETAINED_LOGS_PER_COMMAND = 20;
36
+ /** `.fireforge/logs` under `projectRoot`. */
37
+ export function getRunLogDir(projectRoot) {
38
+ return join(projectRoot, FIREFORGE_DIR, LOGS_DIRNAME);
39
+ }
40
+ /**
41
+ * A filesystem-safe, sortable timestamp: `2026-08-28T14-32-05-123Z`.
42
+ *
43
+ * Colons are not portable in filenames (Windows rejects them outright), so
44
+ * the ISO form is punctuated with dashes only. Lexical order still equals
45
+ * chronological order, which is what the pruning below relies on.
46
+ */
47
+ function logTimestamp(now) {
48
+ return now.toISOString().replace(/[:.]/g, '-');
49
+ }
50
+ /**
51
+ * Makes room for one new log by deleting the oldest, so that at most
52
+ * {@link RETAINED_LOGS_PER_COMMAND} logs for `command` exist once the new
53
+ * one is created. Pruning runs BEFORE the file is opened — hence the
54
+ * `- 1`, without which the directory settles at N + 1.
55
+ *
56
+ * Best-effort: a failure here must not stop the run that is about to be
57
+ * logged.
58
+ */
59
+ async function pruneRunLogs(dir, command) {
60
+ const keep = RETAINED_LOGS_PER_COMMAND - 1;
61
+ try {
62
+ const entries = await readdir(dir);
63
+ const mine = entries.filter((name) => name.startsWith(`${command}-`) && name.endsWith('.log'));
64
+ if (mine.length <= keep)
65
+ return;
66
+ // Names embed a lexically-sortable timestamp, so sorting is ordering by
67
+ // age without stat-ing every file.
68
+ const stale = mine.sort().slice(0, mine.length - keep);
69
+ await Promise.all(stale.map(async (name) => {
70
+ try {
71
+ await unlink(join(dir, name));
72
+ }
73
+ catch {
74
+ // A concurrent run may have pruned it already.
75
+ }
76
+ }));
77
+ }
78
+ catch (error) {
79
+ verbose(`Run-log pruning skipped: ${String(error)}`);
80
+ }
81
+ }
82
+ /**
83
+ * Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
84
+ * logs of the same kind first.
85
+ *
86
+ * @param projectRoot - Project root directory
87
+ * @param command - Command kind, e.g. `test` or `build`
88
+ * @param now - Clock injection point for deterministic tests
89
+ * @returns The sink, or undefined when no log could be opened
90
+ */
91
+ export async function openRunLog(projectRoot, command, now = new Date()) {
92
+ const dir = getRunLogDir(projectRoot);
93
+ let stream;
94
+ const path = join(dir, `${command}-${logTimestamp(now)}.log`);
95
+ try {
96
+ await mkdir(dir, { recursive: true });
97
+ await pruneRunLogs(dir, command);
98
+ stream = createWriteStream(path, { flags: 'a' });
99
+ }
100
+ catch (error) {
101
+ verbose(`Run log unavailable (${path}): ${String(error)}`);
102
+ return undefined;
103
+ }
104
+ // A stream error after open (disk full, a removed directory) must not
105
+ // become an unhandled 'error' event and kill the run the log exists to
106
+ // describe. Latch it and drop every later write.
107
+ let broken = false;
108
+ stream.on('error', (error) => {
109
+ broken = true;
110
+ verbose(`Run log write failed (${path}): ${error.message}`);
111
+ });
112
+ return {
113
+ path,
114
+ write: (chunk) => {
115
+ if (broken)
116
+ return;
117
+ try {
118
+ stream.write(chunk);
119
+ }
120
+ catch {
121
+ broken = true;
122
+ }
123
+ },
124
+ close: async () => {
125
+ await new Promise((resolve) => {
126
+ stream.end(() => {
127
+ resolve();
128
+ });
129
+ });
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * The run log for the command currently executing, if any.
135
+ *
136
+ * Module state rather than a threaded parameter, for the same reason
137
+ * `test-verdict.ts` keeps its emitted-once flag here: the sink has to be
138
+ * reachable from `mach.ts`'s stream callbacks, which sit five frames below
139
+ * the command that opened it, behind dispatch signatures that exist to
140
+ * describe TESTS rather than logging. Threading it would have touched every
141
+ * dispatcher, the retry wrapper and the shard loop to deliver one pointer.
142
+ *
143
+ * One command runs per process, so a single slot is the whole lifetime.
144
+ */
145
+ let activeRunLog;
146
+ /** Installs (or clears, with `undefined`) the run log for this process. */
147
+ export function setActiveRunLog(log) {
148
+ activeRunLog = log;
149
+ }
150
+ /** Absolute path of the active run log, or undefined when none is open. */
151
+ export function getActiveRunLogPath() {
152
+ return activeRunLog?.path;
153
+ }
154
+ /** Appends to the active run log. A no-op when none is open. */
155
+ export function writeToActiveRunLog(chunk) {
156
+ activeRunLog?.write(chunk);
157
+ }
158
+ /** Closes and clears the active run log. Resolves even when none is open. */
159
+ export async function closeActiveRunLog() {
160
+ const log = activeRunLog;
161
+ activeRunLog = undefined;
162
+ await log?.close();
163
+ }
164
+ /**
165
+ * A writable that forwards to `base` AND to the active run log.
166
+ *
167
+ * Used by the stdio-inheriting capture path, whose collectors take a mirror
168
+ * stream rather than per-chunk callbacks — so the tee reuses the mirror the
169
+ * exec layer already supports instead of growing a second hook.
170
+ *
171
+ * @param base - The stream to forward to (normally `process.stdout`)
172
+ * @returns A writable teeing into `base` and the run log
173
+ */
174
+ export function teeToRunLog(base) {
175
+ return new Writable({
176
+ write(chunk, _encoding, callback) {
177
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
178
+ base.write(text);
179
+ writeToActiveRunLog(text);
180
+ callback();
181
+ },
182
+ });
183
+ }
@@ -8,7 +8,30 @@ export interface PostRebuildFailureContext {
8
8
  requestedPaths: readonly string[];
9
9
  firstFailureLine?: string;
10
10
  }
11
- /** Finds the first high-signal failure line from captured mach test output. */
11
+ /**
12
+ * Finds the first high-signal failure line from captured mach test output.
13
+ *
14
+ * Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
15
+ * `AttributeError:` arm above has exactly the same standing as
16
+ * `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
17
+ * is how a run whose real defect was an export shard's file-count assertion
18
+ * got diagnosed as the recognized mozsystemmonitor teardown crash: the
19
+ * information was present and the SELECTION was wrong, which is the
20
+ * expensive kind of wrong, because the named cause is a real, documented,
21
+ * unrelated upstream defect and therefore reads as an answer.
22
+ *
23
+ * So a capture carrying the recognized teardown incident gets two passes:
24
+ * candidates excluding its `AttributeError` header first, and only if that
25
+ * finds nothing does the full set apply. The narrowness the rest of the
26
+ * codebase maintains is preserved exactly — the exclusion is gated on
27
+ * `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
28
+ * closed attribute allowlist and a `resourcemonitor.py` frame. A novel
29
+ * attribute, or a traceback from anywhere else, stays a real failure and is
30
+ * still eligible to be reported.
31
+ *
32
+ * @param output - Raw captured stdout/stderr from the run
33
+ * @returns The chosen failure line, or the first non-empty line
34
+ */
12
35
  export declare function findFirstUsefulFailureLine(output: string): string | undefined;
13
36
  /** Starts a post-rebuild context block for a focused test failure. */
14
37
  export declare function createPostRebuildFailureContext(rebuildCommand: string, requestedPaths: readonly string[]): PostRebuildFailureContext;
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { hasKnownTeardownNoise, isKnownTeardownNoiseLine } from './mach-known-noise-filter.js';
2
3
  function getNonEmptyOutputLines(output) {
3
4
  return output
4
5
  .split(/\r?\n/)
@@ -8,23 +9,53 @@ function getNonEmptyOutputLines(output) {
8
9
  function findFirstMatchingLine(lines, patterns) {
9
10
  return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
10
11
  }
11
- /** Finds the first high-signal failure line from captured mach test output. */
12
+ const FAILURE_LINE_PATTERNS = [
13
+ /\bTEST-UNEXPECTED-[A-Z-]+\b/,
14
+ /\bPROCESS-CRASH\b/i,
15
+ /\bTIMEOUT\b/i,
16
+ /timed out/i,
17
+ /HominisBrowserUnavailableError/i,
18
+ /Marionette.*(?:session|startup|start).*fail/i,
19
+ /(?:failed|unable) to (?:start|create|open).*Marionette/i,
20
+ /SessionNotCreatedException/i,
21
+ /Browser process exited during spawn/i,
22
+ /Failed to load (?:resource|chrome):\/\//i,
23
+ /\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
24
+ /AttributeError:\s+/,
25
+ ];
26
+ /**
27
+ * Finds the first high-signal failure line from captured mach test output.
28
+ *
29
+ * Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
30
+ * `AttributeError:` arm above has exactly the same standing as
31
+ * `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
32
+ * is how a run whose real defect was an export shard's file-count assertion
33
+ * got diagnosed as the recognized mozsystemmonitor teardown crash: the
34
+ * information was present and the SELECTION was wrong, which is the
35
+ * expensive kind of wrong, because the named cause is a real, documented,
36
+ * unrelated upstream defect and therefore reads as an answer.
37
+ *
38
+ * So a capture carrying the recognized teardown incident gets two passes:
39
+ * candidates excluding its `AttributeError` header first, and only if that
40
+ * finds nothing does the full set apply. The narrowness the rest of the
41
+ * codebase maintains is preserved exactly — the exclusion is gated on
42
+ * `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
43
+ * closed attribute allowlist and a `resourcemonitor.py` frame. A novel
44
+ * attribute, or a traceback from anywhere else, stays a real failure and is
45
+ * still eligible to be reported.
46
+ *
47
+ * @param output - Raw captured stdout/stderr from the run
48
+ * @returns The chosen failure line, or the first non-empty line
49
+ */
12
50
  export function findFirstUsefulFailureLine(output) {
13
51
  const lines = getNonEmptyOutputLines(output);
14
- const matched = findFirstMatchingLine(lines, [
15
- /\bTEST-UNEXPECTED-[A-Z-]+\b/,
16
- /\bPROCESS-CRASH\b/i,
17
- /\bTIMEOUT\b/i,
18
- /timed out/i,
19
- /HominisBrowserUnavailableError/i,
20
- /Marionette.*(?:session|startup|start).*fail/i,
21
- /(?:failed|unable) to (?:start|create|open).*Marionette/i,
22
- /SessionNotCreatedException/i,
23
- /Browser process exited during spawn/i,
24
- /Failed to load (?:resource|chrome):\/\//i,
25
- /\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
26
- /AttributeError:\s+/,
27
- ]);
52
+ if (hasKnownTeardownNoise(output)) {
53
+ const withoutNoise = lines.filter((line) => !isKnownTeardownNoiseLine(line));
54
+ const preferred = findFirstMatchingLine(withoutNoise, FAILURE_LINE_PATTERNS);
55
+ if (preferred !== undefined)
56
+ return preferred;
57
+ }
58
+ const matched = findFirstMatchingLine(lines, FAILURE_LINE_PATTERNS);
28
59
  return matched ?? lines[0];
29
60
  }
30
61
  /** Starts a post-rebuild context block for a focused test failure. */
@@ -4,11 +4,11 @@ export interface XpcshellRetryClassification {
4
4
  nonXpcshell: readonly string[];
5
5
  }
6
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>;
7
+ export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string>, fullOutput?: boolean) => Promise<MachCommandResult>;
8
8
  /**
9
9
  * Removes a stale xpcshell install symlink and retries the focused mach test
10
10
  * once. The retry uses the same `dispatch` (suite-specific or generic) the
11
11
  * caller is already running on, so an xpcshell-suite run repairs and re-runs
12
12
  * via `mach xpcshell-test` rather than falling back to the generic command.
13
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>;
14
+ export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch, fullOutput?: boolean): Promise<MachCommandResult>;
@@ -7,12 +7,17 @@ import { tryRepairStaleXpcshellTestSymlink } from './test-stale-symlink.js';
7
7
  * caller is already running on, so an xpcshell-suite run repairs and re-runs
8
8
  * via `mach xpcshell-test` rather than falling back to the generic command.
9
9
  */
10
- export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput) {
10
+ export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput, fullOutput) {
11
11
  if (result.exitCode !== 0 &&
12
12
  classification.xpcshell.length > 0 &&
13
13
  classification.nonXpcshell.length === 0) {
14
14
  const repaired = await tryRepairStaleXpcshellTestSymlink(engineDir, objDir, `${result.stdout}\n${result.stderr}`);
15
15
  if (repaired) {
16
+ // The repaired re-run is the same logical invocation, so it keeps
17
+ // the verbosity the run was started with.
18
+ if (fullOutput === true) {
19
+ return dispatch(engineDir, normalizedPaths, extraArgs, env, true);
20
+ }
16
21
  return env
17
22
  ? dispatch(engineDir, normalizedPaths, extraArgs, env)
18
23
  : dispatch(engineDir, normalizedPaths, extraArgs);
@@ -19,6 +19,63 @@ function singleLineBannerPattern(category) {
19
19
  function multiLineBlockNameMatches(blockLine, category) {
20
20
  return blockLine.replace(/^\s*\*\s*/, '').trim() === category;
21
21
  }
22
+ /**
23
+ * Body of the first `/* … *\/` comment on `line`, or `null` when the line
24
+ * carries no closed block comment.
25
+ *
26
+ * Deliberately index arithmetic rather than a regex: every regex spelling of
27
+ * "banner comment" this module used to carry was super-linear on a line that
28
+ * repeats the opening shape (CodeQL `js/polynomial-redos`), and `tokens.css`
29
+ * is a file FireForge reads out of a consumer's engine tree, so a
30
+ * pathological line needs no attacker to arrive.
31
+ */
32
+ function blockCommentBody(line) {
33
+ const open = line.indexOf('/*');
34
+ if (open === -1)
35
+ return null;
36
+ const close = line.indexOf('*/', open + 2);
37
+ if (close === -1)
38
+ return null;
39
+ return line.slice(open + 2, close);
40
+ }
41
+ /**
42
+ * True when `line` carries a single-line banner comment — a closed block
43
+ * comment whose body both opens and closes with `=`. Recognises the decorative
44
+ * all-`=` rule as well as a named `= Foo =` banner, which is what the section
45
+ * scan wants: either shape ends the preceding section.
46
+ */
47
+ function isSingleLineBannerLine(line) {
48
+ const body = blockCommentBody(line)?.trim();
49
+ return body !== undefined && body.length >= 2 && body.startsWith('=') && body.endsWith('=');
50
+ }
51
+ /**
52
+ * Name declared by a single-line banner (`/* = Foo = *\/` → `Foo`), or `null`
53
+ * when the line is not a named banner. The `=` runs on both sides are
54
+ * required; a decorative all-`=` rule carries no name and yields `null`.
55
+ */
56
+ function singleLineBannerName(line) {
57
+ const body = blockCommentBody(line);
58
+ if (body === null)
59
+ return null;
60
+ let start = 0;
61
+ let end = body.length;
62
+ while (start < end && /\s/.test(body[start] ?? ''))
63
+ start++;
64
+ const leadingEqStart = start;
65
+ while (start < end && body[start] === '=')
66
+ start++;
67
+ if (start === leadingEqStart)
68
+ return null;
69
+ while (end > start && /\s/.test(body[end - 1] ?? ''))
70
+ end--;
71
+ const trailingEqEnd = end;
72
+ while (end > start && body[end - 1] === '=')
73
+ end--;
74
+ if (end === trailingEqEnd)
75
+ return null;
76
+ const name = body.slice(start, end).trim();
77
+ return name.length > 0 ? name : null;
78
+ }
22
79
  /**
23
80
  * True when `lines` contain a category header (single-line or multi-line
24
81
  * banner shape) whose name EQUALS `category`. Shared by the pre-add
@@ -60,14 +117,11 @@ export function categoryHeaderExists(lines, category) {
60
117
  */
61
118
  function discoverCategoryHeaders(lines) {
62
119
  const categories = new Set();
63
- const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
64
120
  for (let i = 0; i < lines.length; i++) {
65
121
  const line = lines[i] ?? '';
66
- const singleMatch = singleLinePattern.exec(line);
67
- if (singleMatch?.[1]) {
68
- const extracted = singleMatch[1].trim();
69
- if (extracted.length > 0)
70
- categories.add(extracted);
122
+ const extracted = singleLineBannerName(line);
123
+ if (extracted !== null) {
124
+ categories.add(extracted);
71
125
  continue;
72
126
  }
73
127
  if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
@@ -181,7 +235,7 @@ export function findCategorySection(lines, category, tokensCssPath) {
181
235
  let sectionEnd = lines.length;
182
236
  for (let i = scanStart; i < lines.length; i++) {
183
237
  const line = lines[i] ?? '';
184
- if (/\/\*\s*=.*=\s*\*\//.test(line) ||
238
+ if (isSingleLineBannerLine(line) ||
185
239
  (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
186
240
  /^\s*\}/.test(line)) {
187
241
  sectionEnd = i;
@@ -12,6 +12,24 @@
12
12
  * fallback helper returns the outer `@media` block's close so the caller can
13
13
  * materialise a fresh `:root` wrapper rather than dropping the dark value.
14
14
  */
15
+ /**
16
+ * True when `line` carries a `:root` selector whose opening brace is on the
17
+ * same line, with no intervening brace of either kind.
18
+ *
19
+ * Index arithmetic rather than `/:root[^{}]*\{/`: that pattern restarts at
20
+ * every `:root` in the line, which is quadratic on a line repeating the
21
+ * selector (CodeQL `js/polynomial-redos`). `tokens.css` comes from a
22
+ * consumer's engine tree, so such a line needs no attacker to arrive.
23
+ */
24
+ function rootOpensBraceOnLine(line) {
25
+ const brace = line.indexOf('{');
26
+ if (brace === -1)
27
+ return false;
28
+ const root = line.lastIndexOf(':root', brace - ':root'.length);
29
+ if (root === -1 || root + ':root'.length > brace)
30
+ return false;
31
+ return !line.slice(root + ':root'.length, brace).includes('}');
32
+ }
15
33
  /**
16
34
  * Strips the content of `/* ... *\/` block comments from an array of
17
35
  * CSS source lines while preserving each line's length. Indexed scans
@@ -85,9 +103,9 @@ export function findDarkRootInsertionIndex(lines) {
85
103
  let rootOpenLine = -1;
86
104
  for (let i = darkMediaLine; i < stripped.length; i++) {
87
105
  const line = stripped[i] ?? '';
88
- if (/(^|[\s,{])\s*:root\b/.test(line)) {
106
+ if (/(?:^|[\s,{]):root\b/.test(line)) {
89
107
  // Brace on the same line?
90
- if (/:root[^{}]*\{/.test(line)) {
108
+ if (rootOpensBraceOnLine(line)) {
91
109
  rootOpenLine = i;
92
110
  break;
93
111
  }
@@ -44,7 +44,7 @@ export async function addTokenToDocs(engineDir, options, annotation) {
44
44
  let unmappedAdded = false;
45
45
  let countUpdated = false;
46
46
  const isLiteral = !options.value.startsWith('var(');
47
- const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
47
+ const mapsTo = isLiteral ? '—' : options.value.replace(/^var\(([^)]+)\)/, '$1');
48
48
  const tokenCell = `\`${options.tokenName}\``;
49
49
  const valueCell = `\`${options.value}\``;
50
50
  // --- Main token table: Category | Token | Value | Maps to | Mode ---
@@ -19,8 +19,13 @@ export interface AddTokenOptions {
19
19
  tokenName: string;
20
20
  /** CSS value (e.g., "1px", "var(--space-small)", "light-dark(#fff, #000)") */
21
21
  value: string;
22
- /** Token category matching section headers in the CSS file */
23
- category: string;
22
+ /**
23
+ * Token category matching section headers in the CSS file. Required for a
24
+ * base declaration; ignored — and no longer required — under
25
+ * {@link AddTokenOptions.variant}, which routes into a `:root<selector>`
26
+ * block and never touches a category section.
27
+ */
28
+ category?: string | undefined;
24
29
  /** Dark mode behavior */
25
30
  mode: TokenMode;
26
31
  /** Comment description for the CSS file */