@hominis/fireforge 0.50.0 → 0.52.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 (212) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/status-output.js +5 -1
  63. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  64. package/dist/src/commands/test-build-furnace.js +8 -2
  65. package/dist/src/commands/test-build-phase.d.ts +2 -0
  66. package/dist/src/commands/test-build-phase.js +34 -17
  67. package/dist/src/commands/test-checkjs.js +3 -2
  68. package/dist/src/commands/test-dispatch.d.ts +39 -0
  69. package/dist/src/commands/test-dispatch.js +220 -0
  70. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  71. package/dist/src/commands/test-furnace-reach.js +132 -0
  72. package/dist/src/commands/test-orchestrate.d.ts +14 -0
  73. package/dist/src/commands/test-orchestrate.js +25 -4
  74. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  75. package/dist/src/commands/test-perf-scope.js +55 -0
  76. package/dist/src/commands/test-prelock.js +100 -4
  77. package/dist/src/commands/test-register.js +18 -3
  78. package/dist/src/commands/test-results.d.ts +62 -0
  79. package/dist/src/commands/test-results.js +62 -0
  80. package/dist/src/commands/test-run.d.ts +13 -55
  81. package/dist/src/commands/test-run.js +73 -101
  82. package/dist/src/commands/test-shards.d.ts +110 -0
  83. package/dist/src/commands/test-shards.js +255 -0
  84. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  85. package/dist/src/commands/test-stale-gate.js +11 -2
  86. package/dist/src/commands/test-verdict.d.ts +2 -1
  87. package/dist/src/commands/test.d.ts +1 -1
  88. package/dist/src/commands/test.js +68 -75
  89. package/dist/src/commands/token-lint.js +48 -8
  90. package/dist/src/commands/verify.js +9 -2
  91. package/dist/src/core/build-baseline-types.d.ts +13 -0
  92. package/dist/src/core/build-baseline-validate.js +9 -0
  93. package/dist/src/core/build-baseline.js +7 -0
  94. package/dist/src/core/build-prepare.d.ts +6 -0
  95. package/dist/src/core/build-prepare.js +29 -3
  96. package/dist/src/core/config-paths.d.ts +1 -1
  97. package/dist/src/core/config-paths.js +2 -0
  98. package/dist/src/core/config-validate-check.js +83 -7
  99. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  100. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  101. package/dist/src/core/config-validate-token-scales.js +47 -5
  102. package/dist/src/core/config-validate.js +6 -0
  103. package/dist/src/core/coverage-extend.js +2 -2
  104. package/dist/src/core/coverage-records.d.ts +11 -0
  105. package/dist/src/core/coverage-records.js +34 -0
  106. package/dist/src/core/engine-session-lock.d.ts +6 -0
  107. package/dist/src/core/engine-session-lock.js +10 -2
  108. package/dist/src/core/file-lock-shared.d.ts +57 -0
  109. package/dist/src/core/file-lock-shared.js +117 -0
  110. package/dist/src/core/file-lock.d.ts +9 -0
  111. package/dist/src/core/file-lock.js +58 -2
  112. package/dist/src/core/furnace-apply.js +10 -10
  113. package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
  114. package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
  115. package/dist/src/core/furnace-config-validate.js +7 -1
  116. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  117. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  118. package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
  119. package/dist/src/core/furnace-validate-accessibility.js +75 -30
  120. package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
  121. package/dist/src/core/furnace-validate-exceptions.js +59 -4
  122. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  123. package/dist/src/core/history-log-file.js +8 -1
  124. package/dist/src/core/line-prefixer.d.ts +18 -0
  125. package/dist/src/core/line-prefixer.js +34 -0
  126. package/dist/src/core/mach.d.ts +7 -0
  127. package/dist/src/core/mach.js +24 -9
  128. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  129. package/dist/src/core/module-resolution-preflight.js +24 -6
  130. package/dist/src/core/negative-control-compiled.d.ts +36 -0
  131. package/dist/src/core/negative-control-compiled.js +87 -0
  132. package/dist/src/core/negative-control-diff.d.ts +14 -2
  133. package/dist/src/core/negative-control-diff.js +49 -6
  134. package/dist/src/core/negative-control.d.ts +12 -0
  135. package/dist/src/core/negative-control.js +48 -5
  136. package/dist/src/core/park-store.d.ts +63 -0
  137. package/dist/src/core/park-store.js +216 -0
  138. package/dist/src/core/patch-export-update.d.ts +4 -0
  139. package/dist/src/core/patch-export-update.js +12 -3
  140. package/dist/src/core/patch-export.d.ts +3 -2
  141. package/dist/src/core/patch-export.js +21 -14
  142. package/dist/src/core/patch-files.js +26 -8
  143. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  144. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  145. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  146. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  147. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  148. package/dist/src/core/patch-lint-checkjs.js +13 -3
  149. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  150. package/dist/src/core/patch-lint-cross.js +5 -0
  151. package/dist/src/core/patch-lint-css.js +14 -10
  152. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  153. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  154. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  155. package/dist/src/core/patch-lint-ignore.js +173 -43
  156. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  157. package/dist/src/core/patch-lint-ownership.js +26 -0
  158. package/dist/src/core/patch-lint-size.js +9 -1
  159. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  160. package/dist/src/core/patch-lint-token-scale.js +206 -29
  161. package/dist/src/core/patch-lint.js +2 -2
  162. package/dist/src/core/patch-manifest-io.js +8 -1
  163. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  164. package/dist/src/core/patch-manifest-key-order.js +61 -0
  165. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  166. package/dist/src/core/patch-manifest-stage.js +96 -0
  167. package/dist/src/core/patch-manifest-validate.js +27 -1
  168. package/dist/src/core/patch-order-legal.d.ts +2 -1
  169. package/dist/src/core/patch-order-legal.js +7 -2
  170. package/dist/src/core/patch-policy.d.ts +6 -2
  171. package/dist/src/core/patch-policy.js +14 -7
  172. package/dist/src/core/patch-retired.d.ts +26 -4
  173. package/dist/src/core/patch-retired.js +40 -2
  174. package/dist/src/core/run-log.d.ts +6 -0
  175. package/dist/src/core/run-log.js +17 -1
  176. package/dist/src/core/test-harness-crash.d.ts +2 -1
  177. package/dist/src/core/test-harness-crash.js +5 -8
  178. package/dist/src/core/test-harness-signature.d.ts +16 -0
  179. package/dist/src/core/test-harness-signature.js +19 -1
  180. package/dist/src/core/test-raw-log.d.ts +66 -0
  181. package/dist/src/core/test-raw-log.js +122 -0
  182. package/dist/src/core/test-run-completeness.d.ts +47 -7
  183. package/dist/src/core/test-run-completeness.js +70 -23
  184. package/dist/src/core/test-stale-check.d.ts +10 -1
  185. package/dist/src/core/test-stale-check.js +15 -1
  186. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  187. package/dist/src/core/test-xpcshell-retry.js +2 -1
  188. package/dist/src/core/token-lint.d.ts +5 -2
  189. package/dist/src/core/token-lint.js +8 -3
  190. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  191. package/dist/src/core/token-sibling-sheets.js +119 -0
  192. package/dist/src/core/tree-guard.js +2 -0
  193. package/dist/src/core/typecheck-shim.d.ts +6 -1
  194. package/dist/src/core/typecheck-shim.js +139 -2
  195. package/dist/src/core/typecheck.js +3 -1
  196. package/dist/src/index.d.ts +9 -3
  197. package/dist/src/index.js +4 -1
  198. package/dist/src/types/commands/index.d.ts +2 -2
  199. package/dist/src/types/commands/options.d.ts +68 -7
  200. package/dist/src/types/commands/patches.d.ts +43 -0
  201. package/dist/src/types/config.d.ts +74 -2
  202. package/dist/src/types/furnace.d.ts +29 -0
  203. package/dist/src/types/index.d.ts +1 -1
  204. package/dist/src/utils/options.d.ts +3 -2
  205. package/dist/src/utils/options.js +9 -4
  206. package/docs/configuration.md +110 -9
  207. package/docs/furnace.md +61 -1
  208. package/docs/lifecycle-invariants.md +13 -11
  209. package/docs/machine-output.md +68 -18
  210. package/docs/patch-workflow.md +174 -13
  211. package/docs/testing.md +89 -5
  212. package/package.json +3 -3
@@ -0,0 +1,255 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Sharded `fireforge test` runs: one harness invocation per shard, run in
4
+ * order on one harness instance, or on a pool of `--jobs <n>` instances.
5
+ *
6
+ * Each finished shard prints a `FIREFORGE-SHARD:` line, and the aggregate
7
+ * verdict names the shards that did not pass as `failed-shards=`. Before,
8
+ * the failing shards were named only in the prose of the aggregate error
9
+ * and the human summary box, so a wrapper that reruns failed shards had to
10
+ * parse a sentence that a reworded message would silently break.
11
+ *
12
+ * Under `--shard-size <n>` a shard is a chunk of n files, and the run is
13
+ * judged per file from each dispatch's raw log: `shards=` then counts files
14
+ * and `failed-shards=` names the files that failed.
15
+ */
16
+ import { getActiveRunLogPath, writeToActiveRunLog } from '../core/run-log.js';
17
+ import { buildHarnessCrashMessage, formatFireforgeVerdictLine, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
18
+ import { TestFailureError } from '../errors/build.js';
19
+ import { info, note, warn } from '../utils/logger.js';
20
+ import { getPlatform } from '../utils/platform.js';
21
+ import { runTestsWithRetries } from './test-run.js';
22
+ import { emitHarnessVerdict } from './test-verdict.js';
23
+ const SHARD_STATUS_LABEL = {
24
+ 'tests-ran-ok': 'PASS',
25
+ 'test-failures': 'FAIL',
26
+ 'harness-crash': 'CRASH',
27
+ 'no-tests': 'NO-TESTS',
28
+ 'harness-arguments': 'ARGUMENTS',
29
+ };
30
+ /**
31
+ * Derives the aggregate classification for a sharded run: all-green is
32
+ * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
33
+ * shard makes the whole run `harness-crash`, then `no-tests`, then plain
34
+ * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
35
+ * contract instead of collapsing every failure to `test-failures`.
36
+ */
37
+ export function deriveAggregateShardVerdict(shards) {
38
+ const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
39
+ if (kinds.has('harness-arguments'))
40
+ return { kind: 'harness-arguments' };
41
+ if (kinds.has('harness-crash'))
42
+ return { kind: 'harness-crash' };
43
+ if (kinds.has('no-tests'))
44
+ return { kind: 'no-tests' };
45
+ if (kinds.has('test-failures'))
46
+ return { kind: 'test-failures' };
47
+ return { kind: 'tests-ran-ok' };
48
+ }
49
+ /**
50
+ * The `FIREFORGE-SHARD:` line for one finished shard: the verdict it would
51
+ * have printed alone, plus `shard=<i>/<n>` and `path=<label>`. Never a
52
+ * `FIREFORGE-VERDICT:` line, so the run keeps exactly one.
53
+ */
54
+ export function formatShardLine(shard, index, total, failedFiles) {
55
+ const body = formatFireforgeVerdictLine(shard.outcome.verdict).slice('FIREFORGE-VERDICT: '.length);
56
+ const files = failedFiles !== undefined && failedFiles.length > 0
57
+ ? ` failed-files=${failedFiles.join(',')}`
58
+ : '';
59
+ return `FIREFORGE-SHARD: ${body} shard=${index + 1}/${total} path=${shard.label.replace(/\s+/g, '_')}${files}`;
60
+ }
61
+ function writeShardLine(line) {
62
+ process.stdout.write(`${line}\n`);
63
+ writeToActiveRunLog(`${line}\n`);
64
+ }
65
+ /** True when a raw-log test id names the dispatched path. */
66
+ function namesPath(test, path) {
67
+ return test === path || test.endsWith(`/${path}`) || path.endsWith(`/${test}`);
68
+ }
69
+ /**
70
+ * The files of one shard that did not pass, judged from its raw log. A file
71
+ * with no `test_end` failed (it never started, or never finished). Returns
72
+ * undefined when the shard wrote no raw log, so the caller judges the
73
+ * shard as a whole.
74
+ */
75
+ export function failedFilesOf(shard) {
76
+ const results = shard.outcome.fileResults;
77
+ if (results === undefined || shard.paths === undefined)
78
+ return undefined;
79
+ return shard.paths.filter((path) => {
80
+ const result = results.find((entry) => namesPath(entry.test, path));
81
+ return result === undefined || result.unexpected;
82
+ });
83
+ }
84
+ function reportShardFailure(ctx, outcome, label, diagnoseShardFailure) {
85
+ if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
86
+ const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
87
+ const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
88
+ headless: ctx.headless,
89
+ platform: getPlatform(),
90
+ ...(outcome.displayState !== undefined ? { displayState: outcome.displayState } : {}),
91
+ });
92
+ warn(hint ? `${base}\n\n${hint}` : base);
93
+ }
94
+ else if (outcome.verdict.kind !== 'tests-ran-ok') {
95
+ const diagnosis = diagnoseShardFailure(outcome, label);
96
+ if (diagnosis)
97
+ warn(diagnosis);
98
+ }
99
+ }
100
+ /**
101
+ * Runs `count` items on up to `jobs` workers at once. Worker `w` handles
102
+ * the items it takes in order, and results keep item order.
103
+ */
104
+ async function runPool(count, jobs, worker) {
105
+ const results = new Array(count);
106
+ let next = 0;
107
+ const workers = Array.from({ length: Math.max(1, Math.min(jobs, count)) }, async (_, w) => {
108
+ while (next < count) {
109
+ const item = next;
110
+ next += 1;
111
+ results[item] = await worker(item, w);
112
+ }
113
+ });
114
+ await Promise.all(workers);
115
+ return results;
116
+ }
117
+ /**
118
+ * Runs each shard as its own harness invocation and prints an aggregate
119
+ * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
120
+ * (which receives the throwing diagnosis chain from the command layer)
121
+ * but downgraded to warnings so every shard runs. The aggregate verdict
122
+ * line and the aggregate error are not produced here: the caller runs
123
+ * the engine-generation integrity check first and then calls
124
+ * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
125
+ * `engine/` mutation can never print `PASS` before the check fails.
126
+ */
127
+ export async function runShardedTests(ctx, groups, diagnoseShardFailure, options = {}) {
128
+ const jobs = options.jobs ?? 1;
129
+ const contexts = new Map([[0, ctx]]);
130
+ const contextFor = async (index) => {
131
+ const known = contexts.get(index);
132
+ if (known !== undefined)
133
+ return known;
134
+ const made = options.contextFor === undefined ? ctx : await options.contextFor(index);
135
+ contexts.set(index, made);
136
+ return made;
137
+ };
138
+ let finished = 0;
139
+ const shards = await runPool(groups.length, jobs, async (index, workerIndex) => {
140
+ const group = groups[index];
141
+ const instance = await contextFor(workerIndex);
142
+ info(`— Shard ${index + 1}/${groups.length}${jobs > 1 ? ` [job ${workerIndex + 1}]` : ''}: ${group.label}`);
143
+ const outcome = await runTestsWithRetries(instance, group.paths, group.label);
144
+ // Perf power-source changes are recorded on the instance's context.
145
+ if (instance.powerChanged === true)
146
+ ctx.powerChanged = true;
147
+ const shard = { label: group.label, outcome, paths: group.paths };
148
+ reportShardFailure(instance, outcome, group.label, diagnoseShardFailure);
149
+ finished += 1;
150
+ writeShardLine(formatShardLine(shard, index, groups.length, options.perFile === true ? failedFilesOf(shard) : undefined));
151
+ reportProgress(finished, groups.length, jobs);
152
+ return shard;
153
+ });
154
+ return summarizeShards(shards, options.perFile === true);
155
+ }
156
+ function reportProgress(finished, total, jobs) {
157
+ if (jobs > 1)
158
+ info(`${finished}/${total} shard(s) finished.`);
159
+ }
160
+ /**
161
+ * Builds the summary box and the counts. Per-file judging counts every
162
+ * dispatched file of a shard that wrote a raw log; a shard without one
163
+ * counts as a whole.
164
+ */
165
+ function summarizeShards(shards, perFile) {
166
+ const lines = shards.map(({ label, outcome }) => `${SHARD_STATUS_LABEL[outcome.verdict.kind].padEnd(8)} ${label}` +
167
+ (outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : ''));
168
+ const failingShards = shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
169
+ let passed = shards.length - failingShards.length;
170
+ let total = shards.length;
171
+ let failed = failingShards.map(({ label }) => label);
172
+ if (perFile) {
173
+ passed = 0;
174
+ total = 0;
175
+ failed = [];
176
+ for (const shard of shards) {
177
+ const files = failedFilesOf(shard);
178
+ const ok = shard.outcome.verdict.kind === 'tests-ran-ok';
179
+ if (files === undefined) {
180
+ total += 1;
181
+ if (ok)
182
+ passed += 1;
183
+ else
184
+ failed.push(shard.label);
185
+ continue;
186
+ }
187
+ const dispatched = shard.paths ?? [];
188
+ total += dispatched.length;
189
+ // A red shard whose log names no failing file still failed: count
190
+ // every file of it, so a crash cannot read as all-green.
191
+ const red = files.length === 0 && !ok ? dispatched : files;
192
+ passed += dispatched.length - red.length;
193
+ failed.push(...red);
194
+ }
195
+ }
196
+ note(`${lines.join('\n')}\n\n${passed}/${total} ${perFile ? 'file(s)' : 'shard(s)'} passed`, 'Sharded Test Summary');
197
+ return { shards, passed, total, failed, aggregate: deriveAggregateShardVerdict(shards) };
198
+ }
199
+ /**
200
+ * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
201
+ * throws the aggregate error when any shard did not pass, the same
202
+ * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
203
+ * the engine-generation check succeeded.
204
+ */
205
+ export function finalizeShardedOutcome(summary) {
206
+ emitHarnessVerdict(summary.aggregate, {
207
+ passed: summary.passed,
208
+ total: summary.total,
209
+ failed: summary.failed.map((label) => label.replace(/\s+/g, '_')),
210
+ });
211
+ if (summary.passed < summary.total) {
212
+ throw new TestFailureError(`${summary.failed.length} of ${summary.total} sharded test run(s) did not pass: ` +
213
+ `${summary.failed.join(', ')}. ` +
214
+ 'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test', undefined, getActiveRunLogPath());
215
+ }
216
+ }
217
+ /**
218
+ * Splits the dispatch paths of every group into consecutive chunks of at
219
+ * most `size` files (`--shard-size`). Order is kept, and a chunk never
220
+ * mixes groups' order: it is the flattened request, cut every `size`
221
+ * files.
222
+ */
223
+ export function chunkShardGroups(groups, size) {
224
+ const paths = groups.flatMap((group) => group.paths);
225
+ const chunks = [];
226
+ for (let start = 0; start < paths.length; start += size) {
227
+ const chunk = paths.slice(start, start + size);
228
+ const first = chunk[0] ?? '';
229
+ chunks.push({
230
+ label: chunk.length === 1 ? first : `${first}+${chunk.length - 1}`,
231
+ paths: chunk,
232
+ });
233
+ }
234
+ return chunks;
235
+ }
236
+ /**
237
+ * Splits one combined request into `parts` contiguous chunks of near-equal
238
+ * size, in order (`--jobs` with `--no-shard`).
239
+ */
240
+ export function splitContiguous(paths, parts) {
241
+ const count = Math.max(1, Math.min(parts, paths.length));
242
+ const groups = [];
243
+ let start = 0;
244
+ for (let index = 0; index < count; index += 1) {
245
+ const size = Math.ceil((paths.length - start) / (count - index));
246
+ const chunk = paths.slice(start, start + size);
247
+ start += size;
248
+ const first = chunk[0] ?? '';
249
+ groups.push({
250
+ label: chunk.length === 1 ? first : `${first}+${chunk.length - 1}`,
251
+ paths: chunk,
252
+ });
253
+ }
254
+ return groups;
255
+ }
@@ -28,7 +28,11 @@ import type { TestOptions } from '../types/commands/index.js';
28
28
  * a full-suite request would refuse a probe that touches no fixtures. The
29
29
  * stale-content refusal still applies to it unchanged.
30
30
  */
31
- export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[]): Promise<void>;
31
+ export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[],
32
+ /** `quiet`: refuse only, without the exemption note (the pre-lock pass). */
33
+ gate?: {
34
+ quiet?: boolean;
35
+ }): Promise<void>;
32
36
  /**
33
37
  * Compiled-StaticComponents gate: refuses runs whose child process would
34
38
  * resolve a stale compiled component table. `components.conf` entries bake
@@ -11,6 +11,7 @@ import { filterByCarriedFullCoverage } from '../core/coverage-dispatch.js';
11
11
  import { checkStaleBuildForTest, checkStaticComponentsStale, findChangedTestManifestsForPaths, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, FULL_SUITE_REQUEST, } from '../core/test-stale-check.js';
12
12
  import { formatStaleExemptions } from '../core/test-stale-exempt.js';
13
13
  import { PreflightRefusalError } from '../errors/base.js';
14
+ import { isProcessAlive } from '../utils/errors.js';
14
15
  import { info, warn } from '../utils/logger.js';
15
16
  import { describePerfStaleImpact } from './test-perf-scope.js';
16
17
  /**
@@ -33,7 +34,9 @@ import { describePerfStaleImpact } from './test-perf-scope.js';
33
34
  * a full-suite request would refuse a probe that touches no fixtures. The
34
35
  * stale-content refusal still applies to it unchanged.
35
36
  */
36
- export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
37
+ export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths,
38
+ /** `quiet`: refuse only, without the exemption note (the pre-lock pass). */
39
+ gate = {}) {
37
40
  const stale = await checkStaleBuildForTest(projectRoot, engineDir);
38
41
  const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
39
42
  if (!dispatchesNoTests) {
@@ -41,7 +44,7 @@ export async function enforceStaleBuildGate(projectRoot, engineDir, options, nor
41
44
  }
42
45
  await enforceStaticComponentsGate(engineDir, stale.baseline, options);
43
46
  const exempt = stale.exempt ?? [];
44
- if (!stale.stale && exempt.length > 0) {
47
+ if (!stale.stale && exempt.length > 0 && gate.quiet !== true) {
45
48
  info(`Stale-build preflight: ${exempt.length} edit(s) since the last build need no rebuild: ${formatStaleExemptions(exempt)}.`);
46
49
  }
47
50
  if (!stale.stale)
@@ -91,6 +94,12 @@ async function enforceCoverageGate(engineDir, baseline, normalizedPaths) {
91
94
  throw new PreflightRefusalError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : [], changedManifests, {
92
95
  changedInputs: Object.fromEntries(remaining.map((request) => [request.path, request.changed])),
93
96
  carriesFullAnchor: baseline?.fullCoverageAnchor !== undefined,
97
+ recordedBy: baseline?.recordedBy,
98
+ builtAt: baseline?.builtAt,
99
+ recorder: baseline?.recorder,
100
+ recorderAlive: baseline?.recorder !== undefined &&
101
+ baseline.recorder.pid !== process.pid &&
102
+ isProcessAlive(baseline.recorder.pid),
94
103
  }), 'coverage-replaced');
95
104
  }
96
105
  /**
@@ -13,7 +13,7 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
13
13
  */
14
14
  export type FireforgeVerdictReason = 'crash' | 'harness-arguments' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
15
  /** Additive keys a run can stamp on its verdict line. */
16
- export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply';
16
+ export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply' | 'furnace-skipped' | 'control-build' | 'drift-tolerated' | 'lock' | 'jobs' | 'results';
17
17
  /** Records an additive attribute for this run's verdict line. */
18
18
  export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
19
19
  /**
@@ -46,6 +46,7 @@ export declare function emitAggregateVerdict(body: string): void;
46
46
  export declare function emitHarnessVerdict(verdict: HarnessRunVerdict, shards?: {
47
47
  passed: number;
48
48
  total: number;
49
+ failed?: readonly string[];
49
50
  }): void;
50
51
  /** Emits the pathless `--doctor` PASS verdict. */
51
52
  export declare function emitPassVerdict(): void;
@@ -19,4 +19,4 @@ import type { TestOptions } from '../types/commands/index.js';
19
19
  * @param options - Test options
20
20
  * @param underLock - Runs the rest of the command under the engine session lock
21
21
  */
22
- export declare function testCommand(projectRoot: string, testPaths: string[], options?: TestOptions, underLock?: (body: () => Promise<void>) => Promise<void>): Promise<void>;
22
+ export declare function testCommand(projectRoot: string, testPaths: string[], options?: TestOptions, underLock?: (body: () => Promise<void>, mode?: 'exclusive' | 'shared') => Promise<void>): Promise<void>;
@@ -1,8 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { assertEngineExists } from '../core/engine-precondition.js';
5
- import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
6
5
  import { hasBuildArtifacts } from '../core/mach.js';
7
6
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
8
7
  import { ensureLaunchableBrowserNotRunning } from '../core/marionette-port.js';
@@ -13,23 +12,22 @@ import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-sco
13
12
  import { cleanupProfileFiles, stageProfileFiles, } from '../core/test-profile-files.js';
14
13
  import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
15
14
  import { FireForgeError, GeneralError, LockContentionError, PreflightRefusalError, } from '../errors/base.js';
16
- import { BuildError } from '../errors/build.js';
17
15
  import { toError } from '../utils/errors.js';
18
16
  import { pathExists } from '../utils/fs.js';
19
- import { info, intro, notice, warn } from '../utils/logger.js';
17
+ import { info, intro, warn } from '../utils/logger.js';
20
18
  import { resolveWaitLockSeconds } from '../utils/options.js';
21
19
  import { stripEnginePrefix } from '../utils/paths.js';
22
20
  import { appendMarionetteForwardingArgs, ensureTestBrowserEnvironment, runDoctorPreflight, } from './test-browser-preflight.js';
23
21
  import { runTestBuildPhase } from './test-build-phase.js';
24
- import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
25
- import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed, shuffleTestGroups, } from './test-harness-env.js';
22
+ import { assertParallelTestUsage, dispatchTests } from './test-dispatch.js';
23
+ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
26
24
  import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
27
- import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
25
+ import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
28
26
  import { orchestrateTestRuns } from './test-orchestrate.js';
29
- import { perfGenerationTolerance } from './test-perf-scope.js';
30
27
  import { resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
31
- import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
32
- import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
28
+ import { resetTestResults, withRawLogDir, writeTestResults } from './test-results.js';
29
+ import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
30
+ import { emitFailVerdict, resetVerdictEmission, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
33
31
  async function assertTestPathsExist(engineDir, testPaths) {
34
32
  const missingPaths = [];
35
33
  for (const testPath of testPaths) {
@@ -126,12 +124,35 @@ export async function testCommand(projectRoot, testPaths, options = {}, underLoc
126
124
  staged = await stageProfileFiles(projectRoot, options.profileFile);
127
125
  options = { ...options, machArg: [...(options.machArg ?? []), ...staged.args] };
128
126
  }
127
+ assertParallelTestUsage(testPaths, options);
129
128
  const lockedOptions = await runTestPreLockPreflight(projectRoot, testPaths, options);
130
129
  const profileEnv = staged?.env;
131
- await underLock(() => orchestrateTestRuns(projectRoot, testPaths, lockedOptions, (paths, runOptions, hooks) => runTestCommandBody(projectRoot, paths, runOptions, profileEnv, hooks), (error) => {
130
+ // The pre-lock pass resolved `sharedLock` against config and the run's shape.
131
+ const lockMode = lockedOptions.sharedLock === true ? 'shared' : 'exclusive';
132
+ if (lockMode === 'shared')
133
+ setVerdictRunAttribute('lock', 'shared');
134
+ resetTestResults();
135
+ if (options.results !== undefined) {
136
+ setVerdictRunAttribute('results', resolve(projectRoot, options.results));
137
+ }
138
+ const orchestrate = (rawLogDir) => orchestrateTestRuns(projectRoot, testPaths, lockedOptions, (paths, runOptions, hooks) => runTestCommandBody(projectRoot, paths, runOptions, profileEnv, {
139
+ ...hooks,
140
+ ...(rawLogDir === undefined ? {} : { rawLogDir }),
141
+ }), (error) => {
132
142
  renderPreflightRefusal(error);
133
143
  emitFailVerdict('preflight', refusalNote(error));
134
- }));
144
+ });
145
+ // `--jobs` always reads each dispatch's raw log, so a shard that ran
146
+ // files other than its own fails instead of passing on their results.
147
+ const collectsResults = options.results !== undefined || options.shardSize !== undefined || (options.jobs ?? 1) > 1;
148
+ try {
149
+ await underLock(() => (collectsResults ? withRawLogDir((dir) => orchestrate(dir)) : orchestrate()), lockMode);
150
+ }
151
+ finally {
152
+ if (options.results !== undefined) {
153
+ await writeResultsFile(resolve(projectRoot, options.results));
154
+ }
155
+ }
135
156
  }
136
157
  catch (error) {
137
158
  if (!verdictEmitted()) {
@@ -164,6 +185,15 @@ export async function testCommand(projectRoot, testPaths, options = {}, underLoc
164
185
  }
165
186
  }
166
187
  }
188
+ /** Writes `--results`; a failure warns, since the run's verdict is already out. */
189
+ async function writeResultsFile(path) {
190
+ try {
191
+ await writeTestResults(path);
192
+ }
193
+ catch (error) {
194
+ warn(`Could not write --results ${path}: ${toError(error).message}`);
195
+ }
196
+ }
167
197
  /** The refusal class a preflight gate named, when it named one. */
168
198
  function refusalNote(error) {
169
199
  return error instanceof PreflightRefusalError ? error.note : undefined;
@@ -198,26 +228,6 @@ function renderPreflightRefusal(error) {
198
228
  process.stdout.write(block);
199
229
  writeToActiveRunLog(block);
200
230
  }
201
- /**
202
- * Verifies `engine/` did not change under the run before any PASS verdict
203
- * may print. On failure the run's verdict is `FAIL reason=inconclusive`,
204
- * because the harness result exists but cannot be trusted (`preflight` would
205
- * misdescribe a run whose harness already executed). It is emitted here so the
206
- * guard's throw can never leave a stale or missing verdict line behind.
207
- */
208
- async function verifyEngineGenerationOrEmitInconclusive(engineDir, before, powerChanged = false, perfSamples) {
209
- try {
210
- await (perfSamples === undefined
211
- ? assertEngineGenerationUnchanged(engineDir, before)
212
- : assertEngineGenerationUnchanged(engineDir, before, perfGenerationTolerance(engineDir)));
213
- if (powerChanged)
214
- throw new GeneralError('Perf power source changed during the run; repeat this sitting on a stable power source.');
215
- }
216
- catch (error) {
217
- emitFailVerdict('inconclusive');
218
- throw error;
219
- }
220
- }
221
231
  async function guardBuildBrowser(engineDir, launchablePath, options) {
222
232
  if ((options.build || options.buildOnly) && launchablePath) {
223
233
  await waitForPreflight(() => ensureLaunchableBrowserNotRunning(join(engineDir, launchablePath), {
@@ -254,7 +264,12 @@ function prepareDispatchArguments(classification, options, canaryPath, projectCo
254
264
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
255
265
  : [];
256
266
  const extraArgs = prepareExtraMachArgs(options, shuffleSeed, canaryPath, projectConfig, forwardedMachArgs);
257
- extraArgs.push(...restartBetweenTestsArgs(options, suite, forwardedMachArgs));
267
+ // A `--shard-size` chunk runs several files in one browser; mochitest
268
+ // restarts it between them so a chunk judges each file as a shard would.
269
+ const restart = options.shardSize !== undefined && suite === 'mochitest'
270
+ ? { ...options, restartBetweenTests: true }
271
+ : options;
272
+ extraArgs.push(...restartBetweenTestsArgs(restart, suite, forwardedMachArgs));
258
273
  return { suite, shuffleSeed, extraArgs };
259
274
  }
260
275
  /**
@@ -327,9 +342,13 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
327
342
  // documented `--mach-arg --marionette-port=NNNN` route still hits the
328
343
  // wrapper preflight refusing on 2828 before the forwarded arg reaches
329
344
  // mach.
345
+ // A negative control on compiled source goes in here, so the build
346
+ // compiles it (see negative-control-compiled.ts).
347
+ await hooks.beforeBuild?.();
330
348
  if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
331
349
  classification,
332
350
  normalizedPaths: hooks.coveragePaths ?? normalizedPaths,
351
+ ...(hooks.fullBuildReason === undefined ? {} : { fullBuildReason: hooks.fullBuildReason }),
333
352
  })) {
334
353
  return;
335
354
  }
@@ -364,6 +383,8 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
364
383
  ...(options.fullOutput === true ? { fullOutput: true } : {}),
365
384
  ...(harnessEnv ? { env: harnessEnv } : {}),
366
385
  pgidFile: options.pgidFile,
386
+ ...(hooks.rawLogDir === undefined ? {} : { rawLogDir: hooks.rawLogDir }),
387
+ ...(hooks.repetition === undefined ? {} : { repetition: hooks.repetition }),
367
388
  };
368
389
  const postRebuildContext = options.build
369
390
  ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
@@ -376,46 +397,18 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
376
397
  // into later files. Sharding is per path argument: a directory argument
377
398
  // keeps its enumerated files together in one invocation, preserving the
378
399
  // one-browser-instance semantics of a directory run. `--no-shard` restores
379
- // the combined invocation. The default must not be silent: a cross-file
380
- // pollution repro otherwise "passes" because the comparison run was
381
- // sharded without saying so, misattributing a suite-context bug upstream.
382
- // Hence the one-line notice stating what sharding does and does not
383
- // exercise.
384
- if (canaryPath === undefined && dispatchGroups.length > 1 && options.shard !== false) {
385
- notice(`Sharding: running ${dispatchGroups.length} test path arguments in isolated browser instances ` +
386
- '(one mach invocation per argument; a directory argument keeps its files in one instance). ' +
387
- 'Cross-argument state is NOT exercised — pass --no-shard for a combined single-instance run.');
388
- const generationBefore = await snapshotEngineGeneration(paths.engine);
389
- let summary;
390
- try {
391
- const orderedGroups = shuffleSeed === undefined ? dispatchGroups : shuffleTestGroups(dispatchGroups, shuffleSeed);
392
- summary = await runShardedTests(runCtx, orderedGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
393
- }
394
- finally {
395
- // Runs before the aggregate verdict below: a mutated engine/ emits
396
- // `FAIL reason=inconclusive` and throws, so an invalidated run can
397
- // never print `PASS shards=N/N` first. (A throw here masks an
398
- // in-flight shard error, as the plain assert always did.)
399
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged, options.perfSamples);
400
- }
401
- finalizeShardedOutcome(summary);
402
- return;
403
- }
404
- const combinedDispatchPaths = dispatchGroups.flatMap((group) => group.paths);
405
- let outcome;
406
- const generationBefore = await snapshotEngineGeneration(paths.engine);
407
- try {
408
- outcome = await runTestsWithRetries(runCtx, combinedDispatchPaths);
409
- }
410
- catch (error) {
411
- throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
412
- }
413
- finally {
414
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged, options.perfSamples);
415
- }
416
- if (canaryPath !== undefined) {
417
- reportCanaryOutcome(outcome);
418
- return;
419
- }
420
- finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
400
+ // the combined invocation; `--shard-size` and `--jobs` reshape it (see
401
+ // test-dispatch.ts).
402
+ await dispatchTests({
403
+ engineDir: paths.engine,
404
+ runCtx,
405
+ dispatchGroups,
406
+ options,
407
+ projectConfig,
408
+ canaryPath,
409
+ shuffleSeed,
410
+ normalizedPaths,
411
+ xpcshellOnly,
412
+ postRebuildContext,
413
+ });
421
414
  }
@@ -8,15 +8,18 @@
8
8
  import { join } from 'node:path';
9
9
  import { getProjectPaths, loadConfig } from '../core/config.js';
10
10
  import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
11
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
12
+ import { buildJarTargetIndex } from '../core/patch-lint-forward-registration-extended.js';
11
13
  import { loadPatchesManifest } from '../core/patch-manifest.js';
12
14
  import { collectDeclaredTokenNames, lintTokenLockstep, lintUndeclaredTokenReferences, } from '../core/token-lint.js';
13
15
  import { getTokensCssPath } from '../core/token-manager.js';
16
+ import { buildSiblingSheetMap } from '../core/token-sibling-sheets.js';
14
17
  import { GeneralError } from '../errors/base.js';
15
18
  import { toError } from '../utils/errors.js';
16
19
  import { pathExists, readText } from '../utils/fs.js';
17
20
  import { info, intro, outro, setMachineOutputMode, setStdoutSealed, success, warn, } from '../utils/logger.js';
18
21
  import { emitMachineError, MACHINE_OUTPUT_SCHEMA_VERSION } from '../utils/machine-output.js';
19
- /** Token prefix and exempt names from furnace.json, with the init default. */
22
+ /** Token prefix, exempt names and sibling sheets from furnace.json, with the init default. */
20
23
  async function loadTokenSettings(projectRoot, binaryName) {
21
24
  const fallback = { tokenPrefix: `--${binaryName}-`, exempt: new Set() };
22
25
  if (!(await furnaceConfigExists(projectRoot)))
@@ -26,6 +29,7 @@ async function loadTokenSettings(projectRoot, binaryName) {
26
29
  return {
27
30
  tokenPrefix: furnace.tokenPrefix ?? fallback.tokenPrefix,
28
31
  exempt: new Set([...(furnace.tokenAllowlist ?? []), ...(furnace.runtimeVariables ?? [])]),
32
+ siblingSheets: furnace.tokenSiblingSheets,
29
33
  };
30
34
  }
31
35
  catch (error) {
@@ -41,7 +45,7 @@ async function collectTokenLint(projectRoot) {
41
45
  if (!(await pathExists(tokensFile))) {
42
46
  throw new GeneralError(`Token CSS file not found: ${tokensCssPath}. Run "fireforge furnace init" to seed it.`);
43
47
  }
44
- const { tokenPrefix, exempt } = await loadTokenSettings(projectRoot, config.binaryName);
48
+ const { tokenPrefix, exempt, siblingSheets } = await loadTokenSettings(projectRoot, config.binaryName);
45
49
  const tokensCss = await readText(tokensFile);
46
50
  const lines = tokensCss.split('\n');
47
51
  const declared = collectDeclaredTokenNames(lines);
@@ -52,19 +56,55 @@ async function collectTokenLint(projectRoot) {
52
56
  // Every CSS file the patch queue touches: the fork's own stylesheets and
53
57
  // the upstream ones it edits.
54
58
  const manifest = await loadPatchesManifest(paths.patches);
55
- const cssFiles = [
56
- ...new Set((manifest?.patches ?? []).flatMap((patch) => patch.filesAffected.filter((file) => file.endsWith('.css') && file !== tokensCssPath))),
57
- ].sort();
59
+ const touched = [...new Set((manifest?.patches ?? []).flatMap((patch) => patch.filesAffected))];
60
+ const cssFiles = touched.filter((file) => file.endsWith('.css') && file !== tokensCssPath).sort();
61
+ const contents = await readEngineFiles(paths.engine, touched.filter((file) => LINKING_FILE.test(file)));
62
+ const siblings = buildSiblingSheetMap({
63
+ documents: contents,
64
+ jarTargets: buildJarTargetIndex((await buildPatchQueueContext(paths.patches)).entries),
65
+ sheets: cssFiles,
66
+ explicit: siblingSheets,
67
+ });
58
68
  let filesScanned = 1;
59
69
  for (const file of cssFiles) {
60
- const absolute = join(paths.engine, file);
61
- if (!(await pathExists(absolute)))
70
+ const css = contents.get(file);
71
+ if (css === undefined)
62
72
  continue;
63
73
  filesScanned++;
64
- findings.push(...lintUndeclaredTokenReferences(file, await readText(absolute), tokenPrefix, declared, exempt));
74
+ findings.push(...lintUndeclaredTokenReferences(file, css, tokenPrefix, await withSiblingDeclarations(paths.engine, declared, siblings.get(file), contents), exempt));
65
75
  }
66
76
  return { tokensCssPath, tokenPrefix, filesScanned, findings };
67
77
  }
78
+ /** Files whose content can link a stylesheet: markup, scripts, and sheets (`@import`). */
79
+ const LINKING_FILE = /\.(?:css|xhtml|html|xml|mjs|js)$/;
80
+ /** Reads the engine files that exist, keyed by engine-relative path. */
81
+ async function readEngineFiles(engineDir, files) {
82
+ const contents = new Map();
83
+ for (const file of files) {
84
+ const absolute = join(engineDir, file);
85
+ if (await pathExists(absolute))
86
+ contents.set(file, await readText(absolute));
87
+ }
88
+ return contents;
89
+ }
90
+ /** The tokens CSS declarations plus every declaration of a sheet's siblings. */
91
+ async function withSiblingDeclarations(engineDir, declared, siblings, contents) {
92
+ if (siblings === undefined || siblings.size === 0)
93
+ return declared;
94
+ const merged = new Set(declared);
95
+ for (const sibling of siblings) {
96
+ if (!contents.has(sibling)) {
97
+ const absolute = join(engineDir, sibling);
98
+ if (!(await pathExists(absolute)))
99
+ continue;
100
+ contents.set(sibling, await readText(absolute));
101
+ }
102
+ const css = contents.get(sibling) ?? '';
103
+ for (const name of collectDeclaredTokenNames(css.split('\n')))
104
+ merged.add(name);
105
+ }
106
+ return merged;
107
+ }
68
108
  function failureMessage(count) {
69
109
  return `token lint found ${String(count)} finding(s).`;
70
110
  }