@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Harness-crash classification for `fireforge test` (field reports C1/C2).
4
+ *
5
+ * The wrapped mach harness exhibits flaky non-test failures that an exit
6
+ * code (or a "did it print a summary" grep) cannot distinguish from real
7
+ * test results:
8
+ *
9
+ * - startup crashes from the mozlog resource monitor on macOS
10
+ * (`AttributeError: 'SystemResourceMonitor' object has no attribute
11
+ * 'poll_interval'`, `host_statistics64(HOST_VM_INFO64) syscall failed`,
12
+ * `(ipc/mig) array not large enough` — a psutil/macOS mismatch) which
13
+ * abort the run before any test executes;
14
+ * - hangs after browser startup that die at the no-output timeout yet
15
+ * still emit a `Passed: 0` summary;
16
+ * - post-green shutdown re-entry, where a fully green run stalls on
17
+ * "must wait for focus" and records "Application shut down (without
18
+ * crashing) in the middle of a test!" as the only unexpected failure.
19
+ *
20
+ * Classification therefore keys on `TEST-START` presence — summary lines
21
+ * never count as proof that tests ran — and recognizes the crash shapes
22
+ * above so the command layer can retry them with a bounded budget instead
23
+ * of reporting phantom test failures (or phantom passes).
24
+ */
25
+ const TEST_START_PATTERN = /\bTEST-START\b/;
26
+ const UNEXPECTED_LINE_PATTERN = /^.*\bTEST-UNEXPECTED-[A-Z-]+\b.*$/gm;
27
+ const SHUTDOWN_REENTRY_PATTERN = /Application shut down \(without crashing\) in the middle of a test/i;
28
+ const FOCUS_STALL_PATTERN = /must wait for focus/i;
29
+ const TRACEBACK_PATTERN = /Traceback \(most recent call last\)/;
30
+ const NO_OUTPUT_TIMEOUT_PATTERN = /timed out after \d+ seconds with no output/i;
31
+ /**
32
+ * Startup-traceback fingerprints from the mozlog resource monitor / psutil
33
+ * on macOS. Each is matched per-line so the evidence line in the report is
34
+ * the concrete failure, not the whole traceback.
35
+ */
36
+ const STARTUP_TRACEBACK_SIGNALS = [
37
+ /AttributeError:.*SystemResourceMonitor/,
38
+ /'SystemResourceMonitor' object has no attribute/,
39
+ /poll_interval/,
40
+ /host_statistics64/,
41
+ /HOST_VM_INFO64/,
42
+ /\(ipc\/mig\) array not large enough/,
43
+ /psutil\.[A-Za-z]*Error/,
44
+ ];
45
+ function findLine(output, patterns) {
46
+ for (const line of output.split(/\r?\n/)) {
47
+ if (patterns.some((p) => p.test(line)))
48
+ return line.trim();
49
+ }
50
+ return undefined;
51
+ }
52
+ /** Unexpected-failure lines that are NOT the shutdown re-entry artifact. */
53
+ function realUnexpectedFailureLines(output) {
54
+ const matches = output.match(UNEXPECTED_LINE_PATTERN) ?? [];
55
+ return matches.filter((line) => !SHUTDOWN_REENTRY_PATTERN.test(line));
56
+ }
57
+ /**
58
+ * Detects the known harness-crash shapes in captured mach output.
59
+ * Returns undefined for anything that looks like a genuine test result.
60
+ */
61
+ export function detectHarnessCrashSignature(output) {
62
+ const hasTestStart = TEST_START_PATTERN.test(output);
63
+ const realFailures = realUnexpectedFailureLines(output);
64
+ // Startup traceback cluster (resource monitor / psutil). Real test
65
+ // failures take precedence: a traceback printed during teardown of a
66
+ // genuinely failing run must not get the whole run retried.
67
+ if (TRACEBACK_PATTERN.test(output) && realFailures.length === 0) {
68
+ const signalLine = findLine(output, STARTUP_TRACEBACK_SIGNALS);
69
+ if (signalLine) {
70
+ return { reason: 'harness startup traceback (resource monitor/psutil)', line: signalLine };
71
+ }
72
+ }
73
+ // Post-browser-startup hang: no test ever started, the harness died at
74
+ // the no-output timeout. A trailing "Passed: 0" summary is part of this
75
+ // shape and must not be read as a result.
76
+ if (!hasTestStart) {
77
+ const timeoutLine = findLine(output, [NO_OUTPUT_TIMEOUT_PATTERN]);
78
+ if (timeoutLine) {
79
+ return { reason: 'no-output timeout before any test started', line: timeoutLine };
80
+ }
81
+ return undefined;
82
+ }
83
+ // Post-green shutdown re-entry: every unexpected line is the
84
+ // shutdown-mid-test artifact, the run stalled on focus, and at least one
85
+ // such artifact exists — an otherwise green log.
86
+ const shutdownLine = findLine(output, [SHUTDOWN_REENTRY_PATTERN]);
87
+ if (shutdownLine && realFailures.length === 0 && FOCUS_STALL_PATTERN.test(output)) {
88
+ return { reason: 'post-green shutdown re-entry during harness teardown', line: shutdownLine };
89
+ }
90
+ return undefined;
91
+ }
92
+ /**
93
+ * Classifies a completed harness run. The decision tree, in order:
94
+ *
95
+ * 1. A recognized crash signature wins regardless of exit code (the
96
+ * shutdown re-entry shape exits non-zero on an otherwise green run;
97
+ * 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.
101
+ * 3. Exit code zero with tests started is a pass; anything else is a
102
+ * test failure for the regular diagnosis chain.
103
+ */
104
+ export function classifyHarnessRun(exitCode, output, requestedPaths) {
105
+ const signature = detectHarnessCrashSignature(output);
106
+ if (signature) {
107
+ return { kind: 'harness-crash', signature };
108
+ }
109
+ if (!TEST_START_PATTERN.test(output) && requestedPaths.length > 0) {
110
+ return { kind: 'no-tests' };
111
+ }
112
+ return exitCode === 0 ? { kind: 'tests-ran-ok' } : { kind: 'test-failures' };
113
+ }
114
+ /** Builds the operator-facing failure message after retries are exhausted. */
115
+ export function buildHarnessCrashMessage(signature, attempts) {
116
+ return (`mach test crashed in the harness itself (not in your tests) on all ${attempts} attempt(s).\n\n` +
117
+ `Detected shape: ${signature.reason}\n` +
118
+ `Evidence line: ${signature.line}\n\n` +
119
+ 'This failure mode is environmental (mozlog resource monitor / psutil on macOS, focus-stall ' +
120
+ 'shutdown re-entry, or a pre-test hang) rather than a test regression. Re-run the command, ' +
121
+ 'raise the retry budget with --harness-retries <n>, or run the file in isolation. ' +
122
+ 'If it persists across many runs, inspect the mach virtualenv (mach resyncs psutil on its own; ' +
123
+ 'patching it manually does not stick).');
124
+ }
125
+ /**
126
+ * Builds the message for a run that produced no `TEST-START` despite
127
+ * requesting paths — including exit-code-zero runs whose `Passed: 0`
128
+ * summary would otherwise read as a silent false green.
129
+ */
130
+ export function buildNoTestsRanMessage(exitCode, requestedPaths) {
131
+ const exitNote = exitCode === 0
132
+ ? 'The harness exited 0 and may have printed a summary line, but a summary without a single TEST-START is not a test result.'
133
+ : `The harness exited ${exitCode} before any TEST-START line.`;
134
+ return ('mach test finished without starting any of the requested tests.\n\n' +
135
+ `${exitNote}\n\n` +
136
+ `Requested paths: ${requestedPaths.join(', ')}\n\n` +
137
+ 'Check that the paths are registered in their test manifest (browser.toml / xpcshell.toml) ' +
138
+ 'and that the manifest is reachable from moz.build, then retry.');
139
+ }
140
+ //# sourceMappingURL=test-harness-crash.js.map
@@ -1,4 +1,4 @@
1
- import type { BuildBaseline } from './build-baseline.js';
1
+ import type { BuildBaseline } from './build-baseline-types.js';
2
2
  /** Result of the stale-build preflight probe. */
3
3
  export interface StaleBuildResult {
4
4
  /** True when at least one packageable engine file changed since the baseline. */
@@ -30,53 +30,9 @@ import { toError } from '../utils/errors.js';
30
30
  import { verbose } from '../utils/logger.js';
31
31
  import { isPackageablePath } from './build-audit.js';
32
32
  import { readBuildBaseline } from './build-baseline.js';
33
- import { hasChanges, isMissingHeadError } from './git.js';
34
- import { git } from './git-base.js';
35
- import { getUntrackedFiles } from './git-status.js';
33
+ import { collectChangedEnginePaths } from './engine-changes.js';
36
34
  /** Cap on the number of changed paths rendered inline. */
37
35
  const STALE_PATHS_LIMIT = 10;
38
- /**
39
- * Collects engine paths that changed since the baseline SHA plus any
40
- * workdir modifications. Mirrors the helper inside `build-prepare.ts` but
41
- * is kept separate so the test-side preflight does not need to pull in
42
- * the full build-prepare dependency graph (mozconfig generation, furnace
43
- * apply hooks, …).
44
- */
45
- async function collectChangedEnginePaths(engineDir, baseline) {
46
- const collected = new Set();
47
- if (baseline.engineHeadSha) {
48
- try {
49
- const diff = await git(['diff', '--name-only', `${baseline.engineHeadSha}..HEAD`], engineDir);
50
- for (const line of diff.split('\n')) {
51
- const trimmed = line.trim();
52
- if (trimmed)
53
- collected.add(trimmed);
54
- }
55
- }
56
- catch (error) {
57
- if (!isMissingHeadError(error)) {
58
- verbose(`Stale-build preflight: could not diff engine against baseline — ${toError(error).message}`);
59
- }
60
- }
61
- }
62
- try {
63
- if (await hasChanges(engineDir)) {
64
- const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
65
- for (const line of worktreeDiff.split('\n')) {
66
- const trimmed = line.trim();
67
- if (trimmed)
68
- collected.add(trimmed);
69
- }
70
- for (const untracked of await getUntrackedFiles(engineDir)) {
71
- collected.add(untracked);
72
- }
73
- }
74
- }
75
- catch (error) {
76
- verbose(`Stale-build preflight: could not enumerate workdir changes — ${toError(error).message}`);
77
- }
78
- return [...collected];
79
- }
80
36
  /**
81
37
  * Probes the engine tree for packageable changes since the last successful
82
38
  * `fireforge build`. Returns a summary the `fireforge test` handler renders
@@ -92,7 +48,7 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
92
48
  if (!baseline) {
93
49
  return { stale: false, changedPaths: [], truncated: 0, baseline: undefined };
94
50
  }
95
- const changed = await collectChangedEnginePaths(engineDir, baseline);
51
+ const changed = await collectChangedEnginePaths(engineDir, baseline, 'Stale-build preflight');
96
52
  let packageable = changed.filter((path) => isPackageablePath(path)).sort();
97
53
  // Content-hash comparison: when the baseline carries a fingerprint set,
98
54
  // fold each candidate path through a live re-hash and drop paths whose
@@ -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[]): 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,14 +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) {
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
- return testWithOutput(engineDir, normalizedPaths, extraArgs);
16
+ return env
17
+ ? dispatch(engineDir, normalizedPaths, extraArgs, env)
18
+ : dispatch(engineDir, normalizedPaths, extraArgs);
12
19
  }
13
20
  }
14
21
  return result;
@@ -111,13 +111,22 @@ export function findDarkRootInsertionIndex(lines) {
111
111
  }
112
112
  if (rootOpenLine === -1)
113
113
  return -1;
114
- // Depth-count starting from the `:root` opener. The first `{`
115
- // encountered sets the entry depth to the initial counter value; the
116
- // closing brace that returns to that depth terminates the block.
114
+ // Depth-count starting from the `:root` opener; see findBlockCloseIndex.
115
+ return findBlockCloseIndex(stripped, rootOpenLine);
116
+ }
117
+ /**
118
+ * Depth-counts braces from `startLine` (whose lines must already have
119
+ * block comments stripped), returning the index of the line on which the
120
+ * block opened there returns to its entry depth — i.e. the line carrying
121
+ * the block's closing `}` — or -1 when the block never closes. The first
122
+ * `{` encountered sets the entry depth, so the scan may start on the
123
+ * selector/at-rule line itself rather than on the opener.
124
+ */
125
+ function findBlockCloseIndex(stripped, startLine) {
117
126
  let depth = 0;
118
127
  let entryDepth = 0;
119
128
  let enteredBlock = false;
120
- for (let i = rootOpenLine; i < stripped.length; i++) {
129
+ for (let i = startLine; i < stripped.length; i++) {
121
130
  const line = stripped[i] ?? '';
122
131
  for (const ch of line) {
123
132
  if (ch === '{') {
@@ -156,27 +165,6 @@ export function findDarkMediaCloseIndex(lines) {
156
165
  }
157
166
  if (darkMediaLine === -1)
158
167
  return -1;
159
- let depth = 0;
160
- let entryDepth = 0;
161
- let enteredBlock = false;
162
- for (let i = darkMediaLine; i < stripped.length; i++) {
163
- const line = stripped[i] ?? '';
164
- for (const ch of line) {
165
- if (ch === '{') {
166
- depth++;
167
- if (!enteredBlock) {
168
- entryDepth = depth - 1;
169
- enteredBlock = true;
170
- }
171
- }
172
- else if (ch === '}') {
173
- depth--;
174
- }
175
- }
176
- if (enteredBlock && depth === entryDepth) {
177
- return i;
178
- }
179
- }
180
- return -1;
168
+ return findBlockCloseIndex(stripped, darkMediaLine);
181
169
  }
182
170
  //# sourceMappingURL=token-dark-mode.js.map
@@ -20,6 +20,8 @@ export interface AddTokenOptions {
20
20
  darkValue?: string | undefined;
21
21
  /** Dry run mode */
22
22
  dryRun?: boolean | undefined;
23
+ /** Declare the category banner in the tokens CSS when it does not exist yet. */
24
+ createCategory?: boolean | undefined;
23
25
  }
24
26
  /**
25
27
  * Result of adding a token.
@@ -35,6 +37,8 @@ export interface AddTokenResult {
35
37
  countUpdated: boolean;
36
38
  /** Whether the operation was skipped (already exists) */
37
39
  skipped: boolean;
40
+ /** Whether a new category banner was declared by this add. */
41
+ categoryCreated?: boolean;
38
42
  }
39
43
  /** Returns the token CSS path relative to engine root for a given binary name. */
40
44
  export declare function getTokensCssPath(binaryName: string): string;
@@ -69,39 +69,54 @@ function validateDarkValue(options) {
69
69
  throw new InvalidArgumentError('Override mode requires --dark-value to be specified.', 'darkValue');
70
70
  }
71
71
  }
72
- async function assertTokenCategoryExists(engineDir, tokensCssPath, category) {
73
- const filePath = join(engineDir, tokensCssPath);
74
- if (!(await pathExists(filePath))) {
75
- throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
76
- }
77
- const content = await readText(filePath);
78
- const lines = content.split('\n');
72
+ /**
73
+ * True when `lines` contain a category header (single-line or multi-line
74
+ * banner shape) naming `category`. Shared by the pre-add assertion and the
75
+ * in-memory banner creation path so both agree on what "exists" means.
76
+ */
77
+ function categoryHeaderExists(lines, category) {
79
78
  const escapedCategory = escapeRegex(category);
80
79
  const singleLinePattern = new RegExp(`\\/\\*\\s*=.*${escapedCategory}.*=\\s*\\*\\/`);
81
80
  for (let i = 0; i < lines.length; i++) {
82
81
  const line = lines[i] ?? '';
83
82
  if (singleLinePattern.test(line)) {
84
- return;
83
+ return true;
85
84
  }
86
85
  if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
87
86
  for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
88
87
  const blockLine = lines[j] ?? '';
89
88
  if (new RegExp(escapedCategory).test(blockLine)) {
90
- return;
89
+ return true;
91
90
  }
92
91
  if (/\*\//.test(blockLine))
93
92
  break;
94
93
  }
95
94
  }
96
95
  }
96
+ return false;
97
+ }
98
+ async function assertTokenCategoryExists(engineDir, tokensCssPath, category, createCategory = false) {
99
+ const filePath = join(engineDir, tokensCssPath);
100
+ if (!(await pathExists(filePath))) {
101
+ throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
102
+ }
103
+ const content = await readText(filePath);
104
+ const lines = content.split('\n');
105
+ if (categoryHeaderExists(lines, category))
106
+ return;
107
+ // The write path declares the banner in the same edit as the token
108
+ // insertion, so a missing category is fine when creation was requested.
109
+ if (createCategory)
110
+ return;
97
111
  const discoveredCategories = discoverCategoryHeaders(lines);
98
112
  const available = discoveredCategories.length > 0
99
113
  ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
100
- : 'The file currently has no category headers. Add one by hand near the top of the :root { … } block — the format is "/* = My Category = */" — or run "fireforge furnace init --force" to re-scaffold the default seed set.';
114
+ : 'The file currently has no category headers.';
101
115
  throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
102
116
  `${available}\n\n` +
103
117
  'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
104
- 'Multi-line shape: /* =============\\n * My Category\\n * ============= */.');
118
+ 'Multi-line shape: /* =============\\n * My Category\\n * ============= */.\n\n' +
119
+ 'Re-run with --create-category to declare the banner and insert the token in one step.');
105
120
  }
106
121
  /**
107
122
  * Scans a tokens CSS file for category header comments and returns the
@@ -156,7 +171,7 @@ export async function validateTokenAdd(root, options) {
156
171
  validateTokenNameSyntax(options.tokenName);
157
172
  await validateTokenPrefix(root, options);
158
173
  validateDarkValue(options);
159
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category);
174
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
160
175
  }
161
176
  /**
162
177
  * Adds a design token to the CSS file and documentation.
@@ -185,7 +200,7 @@ export async function addToken(root, options) {
185
200
  };
186
201
  }
187
202
  // --- CSS file ---
188
- const cssAdded = await addTokenToCSS(engineDir, options, tokensCssPath);
203
+ const { added: cssAdded, categoryCreated } = await addTokenToCSS(engineDir, options, tokensCssPath);
189
204
  if (!cssAdded) {
190
205
  return {
191
206
  cssAdded: false,
@@ -203,8 +218,39 @@ export async function addToken(root, options) {
203
218
  unmappedAdded: docsResult.unmappedAdded,
204
219
  countUpdated: docsResult.countUpdated,
205
220
  skipped: false,
221
+ categoryCreated,
206
222
  };
207
223
  }
224
+ /**
225
+ * Splices a new single-line category banner ("= Name =" comment shape, the
226
+ * same format `discoverCategoryHeaders` recognises) just before the closing
227
+ * brace of the `:root` block, making the new category the last section.
228
+ * Mutates `lines` in place.
229
+ */
230
+ function declareCategoryBanner(lines, category, tokensCssPath) {
231
+ let rootOpen = -1;
232
+ for (let i = 0; i < lines.length; i++) {
233
+ if (/:root\s*\{/.test(lines[i] ?? '')) {
234
+ rootOpen = i;
235
+ break;
236
+ }
237
+ }
238
+ if (rootOpen === -1) {
239
+ throw new GeneralError(`Cannot create category "${category}": no :root block found in ${tokensCssPath}. ` +
240
+ 'Run "fireforge furnace init --force" to re-scaffold the tokens CSS file.');
241
+ }
242
+ let rootClose = -1;
243
+ for (let i = rootOpen + 1; i < lines.length; i++) {
244
+ if (/^\s*\}/.test(lines[i] ?? '')) {
245
+ rootClose = i;
246
+ break;
247
+ }
248
+ }
249
+ if (rootClose === -1) {
250
+ throw new GeneralError(`Cannot create category "${category}": the :root block in ${tokensCssPath} never closes.`);
251
+ }
252
+ lines.splice(rootClose, 0, '', ` /* = ${category} = */`);
253
+ }
208
254
  function findCategorySection(lines, category, tokensCssPath) {
209
255
  const escapedCategory = escapeRegex(category);
210
256
  const singleLinePattern = new RegExp(`\\/\\*\\s*=.*${escapedCategory}.*=\\s*\\*\\/`);
@@ -296,15 +342,23 @@ function insertDarkModeOverride(lines, options) {
296
342
  */
297
343
  async function addTokenToCSS(engineDir, options, tokensCssPath) {
298
344
  const filePath = join(engineDir, tokensCssPath);
299
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category);
345
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
300
346
  let content = await readText(filePath);
301
347
  // Idempotency check — strip CSS block comments so we don't match inside them
302
348
  const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
303
349
  if (stripped.includes(options.tokenName + ':')) {
304
- return false;
350
+ return { added: false, categoryCreated: false };
305
351
  }
306
352
  const lines = content.split('\n');
307
353
  const annotation = getModeAnnotation(options.mode, options.value);
354
+ // Declare a missing category banner in the same in-memory edit as the
355
+ // token insertion — the file is written exactly once, so a failure
356
+ // between "banner declared" and "token inserted" cannot occur.
357
+ let categoryCreated = false;
358
+ if (options.createCategory === true && !categoryHeaderExists(lines, options.category)) {
359
+ declareCategoryBanner(lines, options.category, tokensCssPath);
360
+ categoryCreated = true;
361
+ }
308
362
  const { categoryLine, sectionEnd } = findCategorySection(lines, options.category, tokensCssPath);
309
363
  // Build the insertion lines
310
364
  const insertLines = [];
@@ -325,7 +379,7 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
325
379
  insertDarkModeOverride(lines, options);
326
380
  content = lines.join('\n');
327
381
  await writeText(filePath, content);
328
- return true;
382
+ return { added: true, categoryCreated };
329
383
  }
330
384
  /**
331
385
  * Strips surrounding backticks from a cell, if present. Token cells are
@@ -11,27 +11,6 @@
11
11
  */
12
12
  /** Filename used for the synthetic Firefox-globals shim source file. */
13
13
  export declare const SHIM_FILENAME = "__fireforge_firefox_globals.d.ts";
14
- /**
15
- * Minimal `.d.ts` shim for Firefox privileged-scope globals.
16
- *
17
- * Firefox source is plain JS — no TypeScript allowed. The shim lets
18
- * TS-driven type checking run without reporting "cannot find name"
19
- * for the most common Mozilla APIs. Types are intentionally loose
20
- * (`any`) because full Firefox type coverage is out of scope.
21
- *
22
- * Notable patterns that require shimming:
23
- * - `const lazy = {};` + `ChromeUtils.defineESModuleGetters(lazy, { ... })`
24
- * populates `lazy` at runtime; we declare it as `Record<string, any>`.
25
- * - `Services.obs`, `Services.prefs`, etc. are XPCOM service accessors.
26
- * - `Ci`, `Cc`, `Cr`, `Cu` are XPCOM component shortcuts.
27
- * - Browser chrome globals like `gBrowser`, `gURLBar` are common in
28
- * content scripts wired via `browser.js`.
29
- * - Dynamic `import("resource:-…")` / `import("chrome:-…")` under patch
30
- * checkJs: the compiler sees empty stubs (`noResolve`); without URL
31
- * ambient modules namespaces degrade to unusable typings. Wildcards
32
- * keep Firefox URL imports pragmatically loose, same posture as globals.
33
- */
34
- export declare const FIREFOX_GLOBALS_SHIM = "\ndeclare var Services: any;\ndeclare var ChromeUtils: {\n defineESModuleGetters(target: any, modules: Record<string, string>): void;\n importESModule(specifier: string): any;\n import(specifier: string): any;\n defineModuleGetter(target: any, name: string, specifier: string): void;\n generateQI(interfaces: any[]): Function;\n isClassInfo(obj: any): boolean;\n};\ndeclare var Cu: any;\ndeclare var Ci: any;\ndeclare var Cc: any;\ndeclare var Cr: any;\ndeclare var Components: any;\ndeclare var XPCOMUtils: any;\ndeclare var lazy: Record<string, any>;\ndeclare var PathUtils: any;\ndeclare var IOUtils: any;\ndeclare var FileUtils: any;\ndeclare var gBrowser: any;\ndeclare var gURLBar: any;\ndeclare var gNavigatorBundle: any;\ndeclare var AppConstants: any;\n\n// Shorthand ambient modules \u2014 exports from matching URL imports are loosely typed,\n// avoiding noResolve empty-graph namespaces. (Named member access broke when we tried\n// export= Record under moduleResolution Bundler.)\ndeclare module 'resource:*';\ndeclare module 'chrome:*';\n\n";
35
14
  /**
36
15
  * TS diagnostic codes suppressed by both the patch-lint checkJs pass
37
16
  * and the whole-project typecheck command. Each is a known false
@@ -61,7 +40,9 @@ export interface ComposedShim {
61
40
  * direction is intentional (declarations later in concat order
62
41
  * augment earlier ones), so a project that wants to refine `Services`
63
42
  * with a more specific type can do so by declaring it in the extra
64
- * shim.
43
+ * shim. Any triple-slash `/// <reference path="…">` directives inside the
44
+ * extra shim are inlined (resolved against the extra shim's own directory)
45
+ * so they are not silently dropped at the synthetic shim path.
65
46
  *
66
47
  * Missing extra-shim files raise a clear error rather than failing
67
48
  * silently with a confusing "type not found" downstream — this is the
@@ -10,7 +10,7 @@
10
10
  * still fail `fireforge typecheck`, or vice versa, for reasons the
11
11
  * operator could not infer from the rule names.
12
12
  */
13
- import { resolve } from 'node:path';
13
+ import { dirname, resolve } from 'node:path';
14
14
  import { pathExists, readText } from '../utils/fs.js';
15
15
  /** Filename used for the synthetic Firefox-globals shim source file. */
16
16
  export const SHIM_FILENAME = '__fireforge_firefox_globals.d.ts';
@@ -30,18 +30,21 @@ export const SHIM_FILENAME = '__fireforge_firefox_globals.d.ts';
30
30
  * - Browser chrome globals like `gBrowser`, `gURLBar` are common in
31
31
  * content scripts wired via `browser.js`.
32
32
  * - Dynamic `import("resource:-…")` / `import("chrome:-…")` under patch
33
- * checkJs: the compiler sees empty stubs (`noResolve`); without URL
34
- * ambient modules namespaces degrade to unusable typings. Wildcards
35
- * keep Firefox URL imports pragmatically loose, same posture as globals.
33
+ * checkJs: imports of *patch-owned* modules resolve to their real
34
+ * sources (see `patch-lint-checkjs.ts`); everything else fails host
35
+ * resolution and lands on these URL ambient wildcards, keeping
36
+ * upstream Firefox imports pragmatically loose, same posture as globals.
36
37
  */
37
- export const FIREFOX_GLOBALS_SHIM = `
38
+ const FIREFOX_GLOBALS_SHIM = `
38
39
  declare var Services: any;
39
40
  declare var ChromeUtils: {
40
41
  defineESModuleGetters(target: any, modules: Record<string, string>): void;
41
42
  importESModule(specifier: string): any;
42
43
  import(specifier: string): any;
43
44
  defineModuleGetter(target: any, name: string, specifier: string): void;
45
+ defineLazyGetter(target: any, name: string, getter: () => any): void;
44
46
  generateQI(interfaces: any[]): Function;
47
+ getClassName(obj: any, unwrap?: boolean): string;
45
48
  isClassInfo(obj: any): boolean;
46
49
  };
47
50
  declare var Cu: any;
@@ -58,6 +61,25 @@ declare var gBrowser: any;
58
61
  declare var gURLBar: any;
59
62
  declare var gNavigatorBundle: any;
60
63
  declare var AppConstants: any;
64
+ // Fluent localization — a stable chrome global. Members stay loose (any),
65
+ // but the constructor shape is declared so "new Localization([...])" and
66
+ // "new Localization([...], true)" typecheck without a local cast.
67
+ declare var Localization: {
68
+ new (
69
+ resourceIds: Array<string | { path: string; optional?: boolean }>,
70
+ sync?: boolean
71
+ ): {
72
+ formatValue(id: string, args?: Record<string, unknown>): any;
73
+ formatValues(keys: any[]): any;
74
+ formatMessages(keys: any[]): any;
75
+ formatValueSync(id: string, args?: Record<string, unknown>): any;
76
+ formatValuesSync(keys: any[]): any;
77
+ formatMessagesSync(keys: any[]): any;
78
+ addResourceIds(ids: Array<string | { path: string; optional?: boolean }>): void;
79
+ removeResourceIds(ids: string[]): number;
80
+ setAsync(): void;
81
+ };
82
+ };
61
83
 
62
84
  // Shorthand ambient modules — exports from matching URL imports are loosely typed,
63
85
  // avoiding noResolve empty-graph namespaces. (Named member access broke when we tried
@@ -88,6 +110,43 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
88
110
  2580, // Cannot find name '{0}'. Do you need to install type definitions...
89
111
  7016, // Could not find a declaration file for module '{0}'.
90
112
  ]);
113
+ /** Matches a lone triple-slash `/// <reference path="…" />` directive line. */
114
+ const TRIPLE_SLASH_REFERENCE = /^\s*\/\/\/\s*<reference\s+path\s*=\s*["']([^"']+)["']\s*\/?>\s*$/;
115
+ /**
116
+ * Inlines triple-slash `/// <reference path="…">` directives in shim source.
117
+ *
118
+ * Both shim consumers feed the text to the compiler at a *synthetic* path
119
+ * (an in-memory source file, not the extra shim's real location), so TS
120
+ * resolves a relative `/// <reference>` against that synthetic directory and
121
+ * silently drops it. Inlining the referenced file's contents (recursively,
122
+ * resolved against the *referencing* file's directory, deduped by absolute
123
+ * path) makes the directives self-contained so their declarations survive.
124
+ *
125
+ * @param source - Shim source possibly containing reference directives
126
+ * @param baseDir - Directory the directives' relative paths resolve against
127
+ * @param seen - Absolute paths already inlined (cycle / duplicate guard)
128
+ */
129
+ async function inlineTripleSlashReferences(source, baseDir, seen) {
130
+ const out = [];
131
+ for (const line of source.split('\n')) {
132
+ const match = TRIPLE_SLASH_REFERENCE.exec(line);
133
+ if (!match?.[1]) {
134
+ out.push(line);
135
+ continue;
136
+ }
137
+ const absolute = resolve(baseDir, match[1]);
138
+ if (seen.has(absolute))
139
+ continue;
140
+ seen.add(absolute);
141
+ if (!(await pathExists(absolute))) {
142
+ out.push(`// (fireforge: unresolved /// <reference path="${match[1]}">)`);
143
+ continue;
144
+ }
145
+ const referenced = await readText(absolute);
146
+ out.push(await inlineTripleSlashReferences(referenced, dirname(absolute), seen));
147
+ }
148
+ return out.join('\n');
149
+ }
91
150
  /**
92
151
  * Composes the synthetic shim source by concatenating the built-in
93
152
  * Firefox globals shim with the contents of an optional user-supplied
@@ -95,7 +154,9 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
95
154
  * direction is intentional (declarations later in concat order
96
155
  * augment earlier ones), so a project that wants to refine `Services`
97
156
  * with a more specific type can do so by declaring it in the extra
98
- * shim.
157
+ * shim. Any triple-slash `/// <reference path="…">` directives inside the
158
+ * extra shim are inlined (resolved against the extra shim's own directory)
159
+ * so they are not silently dropped at the synthetic shim path.
99
160
  *
100
161
  * Missing extra-shim files raise a clear error rather than failing
101
162
  * silently with a confusing "type not found" downstream — this is the
@@ -115,8 +176,9 @@ export async function composeShimSource(projectRoot, extraShimPath) {
115
176
  'Check the path in fireforge.json or create the file.');
116
177
  }
117
178
  const extra = await readText(absoluteShim);
179
+ const inlinedExtra = await inlineTripleSlashReferences(extra, dirname(absoluteShim), new Set([absoluteShim]));
118
180
  return {
119
- source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${extra}`,
181
+ source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${inlinedExtra}`,
120
182
  extraShimAppended: true,
121
183
  };
122
184
  }