@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
@@ -1,12 +1,38 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { readdir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { pathExists, readText } from '../utils/fs.js';
4
5
  import { createIssue } from './furnace-validate-helpers.js';
5
6
  import { countUnlabelledIconButtons } from './furnace-validate-icon-button.js';
6
7
  import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-template-regions.js';
7
8
  /**
8
- * Validates accessibility patterns in a component's .mjs file.
9
- * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
9
+ * Reads the composed component: `<tagName>.mjs` followed by its companion
10
+ * modules (`<tagName>-*.mjs`, tests and stories left out), so a template's
11
+ * findings do not depend on which of the component's modules it lives in.
12
+ * Returns undefined when the host module is missing.
13
+ */
14
+ async function readComposedComponentModules(componentDir, tagName) {
15
+ const hostName = `${tagName}.mjs`;
16
+ const hostPath = join(componentDir, hostName);
17
+ if (!(await pathExists(hostPath)))
18
+ return undefined;
19
+ const entries = await readdir(componentDir).catch(() => []);
20
+ const companions = entries
21
+ .filter((name) => name.startsWith(`${tagName}-`) &&
22
+ name.endsWith('.mjs') &&
23
+ !/\.(test|spec|stories)\./.test(name))
24
+ .sort();
25
+ const modules = [{ name: hostName, content: await readText(hostPath) }];
26
+ for (const name of companions) {
27
+ modules.push({ name, content: await readText(join(componentDir, name)) });
28
+ }
29
+ return modules;
30
+ }
31
+ /**
32
+ * Validates accessibility patterns in a component's composed source: its
33
+ * host module and every companion module (see
34
+ * {@link readComposedComponentSource}). Checks for ARIA roles, keyboard
35
+ * handlers, l10n, and focus delegation.
10
36
  *
11
37
  * @param componentDir - Directory holding the component's sources
12
38
  * @param tagName - Custom element tag name, used to locate `<tagName>.mjs`
@@ -20,10 +46,10 @@ import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-templa
20
46
  * adds the icon-only `<moz-button>` naming check.
21
47
  */
22
48
  export async function validateAccessibility(componentDir, tagName, customConfig, optInChecks = []) {
23
- const mjsPath = join(componentDir, `${tagName}.mjs`);
24
- if (!(await pathExists(mjsPath)))
49
+ const modules = await readComposedComponentModules(componentDir, tagName);
50
+ if (modules === undefined)
25
51
  return [];
26
- const content = await readText(mjsPath);
52
+ const content = modules.map((module) => module.content).join('\n');
27
53
  const issues = [];
28
54
  if (!hasAriaRole(content) && hasGenericInteractiveElement(content)) {
29
55
  issues.push(createIssue(tagName, 'warning', 'no-aria-role', 'Generic interactive markup has no native semantics. Prefer native elements, or add role= when native markup cannot provide the semantics.'));
@@ -41,13 +67,24 @@ export async function validateAccessibility(componentDir, tagName, customConfig,
41
67
  // activation flows through the inner element and a duplicate handler on
42
68
  // the wrapper would either no-op or fire twice alongside the child's
43
69
  // built-in keyboard path.
44
- const hasClickOnSynthetic = hasTemplateClickOnSyntheticInteractive(content);
70
+ const syntheticTags = syntheticClickTags(content);
45
71
  const keyboardCovered = isKeyboardCoveredByComposition(customConfig);
46
- if (hasClickOnSynthetic && !hasKeyboardHandler && !keyboardCovered) {
47
- issues.push(createIssue(tagName, 'warning', 'no-keyboard-handler', 'Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup).'));
72
+ if (syntheticTags.length > 0 && !hasKeyboardHandler && !keyboardCovered) {
73
+ issues.push({
74
+ ...createIssue(tagName, 'warning', 'no-keyboard-handler', `Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup), on ${syntheticTags.map((tag) => `<${tag}>`).join(', ')}.`),
75
+ tags: syntheticTags,
76
+ });
48
77
  }
49
- if (containsHardcodedTemplateText(content)) {
50
- issues.push(createIssue(tagName, 'warning', 'hardcoded-text', 'Possible hardcoded string found. Use data-l10n-id for localization.'));
78
+ // Judged per module, so the finding names its files and an exception can
79
+ // accept one module (a vendored bundle) without muting the others.
80
+ const textFiles = modules
81
+ .filter((module) => containsHardcodedTemplateText(module.content))
82
+ .map((module) => module.name);
83
+ if (textFiles.length > 0) {
84
+ issues.push({
85
+ ...createIssue(tagName, 'warning', 'hardcoded-text', `Possible hardcoded string found in ${textFiles.join(', ')}. Use data-l10n-id for localization.`),
86
+ files: textFiles,
87
+ });
51
88
  }
52
89
  const isInteractive = hasClick || hasKeyboardHandler;
53
90
  if (isInteractive && !hasDelegatesFocusEnabled(content)) {
@@ -164,6 +201,9 @@ const NATIVE_CLICK_INTERACTIVE_TAGS = new Set([
164
201
  'moz-radio',
165
202
  'moz-radio-group',
166
203
  'moz-menulist',
204
+ // Toolkit's panel-list row renders a button that takes Enter and Space
205
+ // and dispatches click.
206
+ 'panel-item',
167
207
  ]);
168
208
  /**
169
209
  * Returns true when `customConfig` declares that the component's keyboard
@@ -185,28 +225,33 @@ function isKeyboardCoveredByComposition(customConfig) {
185
225
  return composes.some((tag) => NATIVE_CLICK_INTERACTIVE_TAGS.has(tag));
186
226
  }
187
227
  /**
188
- * Returns true when `content` has at least one `@click=${...}` handler on a
189
- * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
190
- * keyboard activation and therefore needs an explicit key handler for
191
- * Enter/Space. Returns false when every `@click` handler sits on a native
192
- * interactive element. Those already fire `click` on keyboard activation.
228
+ * Returns the tags (in first-seen order) of every *synthetic* interactive
229
+ * element carrying an `@click=${...}` handler (e.g. `<div @click>`), which
230
+ * lacks native keyboard activation and therefore needs an explicit key
231
+ * handler for Enter/Space. Empty when every `@click` handler sits on a
232
+ * native interactive element. Those already fire `click` on keyboard
233
+ * activation.
193
234
  */
194
- function hasTemplateClickOnSyntheticInteractive(content) {
235
+ function syntheticClickTags(content) {
195
236
  const pattern = /@click\s*=\s*\$\{/g;
237
+ const tags = [];
196
238
  let match;
197
239
  while ((match = pattern.exec(content)) !== null) {
198
- if (isClickOnSyntheticInteractive(content, match.index)) {
199
- return true;
200
- }
240
+ const tag = syntheticClickTag(content, match.index);
241
+ if (tag !== undefined && !tags.includes(tag))
242
+ tags.push(tag);
201
243
  }
202
- return false;
244
+ return tags;
203
245
  }
246
+ /** The tag a synthetic-element `@click` sits on, `?` when it cannot be told. */
247
+ const UNKNOWN_TAG = '?';
204
248
  /**
205
249
  * Given the offset of an `@click=${...}` occurrence, walks backwards to find
206
- * the opening `<tag` that owns it and decides whether that tag is a native
207
- * interactive element (no warning needed) or a synthetic one (warning needed).
250
+ * the opening `<tag` that owns it and returns that tag when it is a
251
+ * synthetic interactive element (warning needed), or undefined when it is a
252
+ * native one (no warning needed).
208
253
  */
209
- function isClickOnSyntheticInteractive(content, clickIndex) {
254
+ function syntheticClickTag(content, clickIndex) {
210
255
  // Find the nearest preceding `<` that starts a tag (skip `</` closers).
211
256
  let i = clickIndex - 1;
212
257
  let tagOpenIndex = -1;
@@ -219,15 +264,15 @@ function isClickOnSyntheticInteractive(content, clickIndex) {
219
264
  // which shouldn't happen for a well-formed template but we defensively
220
265
  // treat it as synthetic to preserve the prior-behaviour warning.
221
266
  if (content[i] === '>') {
222
- return true;
267
+ return UNKNOWN_TAG;
223
268
  }
224
269
  i--;
225
270
  }
226
271
  if (tagOpenIndex < 0)
227
- return true;
272
+ return UNKNOWN_TAG;
228
273
  const tagMatch = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(content.slice(tagOpenIndex));
229
274
  if (!tagMatch?.[1])
230
- return true;
275
+ return UNKNOWN_TAG;
231
276
  const tagName = tagMatch[1].toLowerCase();
232
277
  if (tagName === 'a') {
233
278
  // Bare <a> (no href) is non-interactive, so require a keyboard handler.
@@ -235,11 +280,11 @@ function isClickOnSyntheticInteractive(content, clickIndex) {
235
280
  // attribute text in between for an href attribute.
236
281
  const tagEnd = content.indexOf('>', tagOpenIndex);
237
282
  if (tagEnd < 0)
238
- return true;
283
+ return 'a';
239
284
  const attrs = content.slice(tagOpenIndex, tagEnd);
240
- return !/\shref\s*=/.test(attrs);
285
+ return /\shref\s*=/.test(attrs) ? undefined : 'a';
241
286
  }
242
- return !NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName);
287
+ return NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName) ? undefined : tagName;
243
288
  }
244
289
  /** ASCII punctuation that reads as decoration rather than localizable text. */
245
290
  const SYMBOL_ONLY_ASCII = '+-*=<>|/\\^~@#&!?%';
@@ -303,7 +348,7 @@ function isWithinLocalizedElement(content, matchIndex) {
303
348
  * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
304
349
  *
305
350
  * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
306
- * findings (matches the pre-existing escape hatch).
351
+ * findings in that file (matches the pre-existing escape hatch).
307
352
  */
308
353
  export function containsHardcodedTemplateText(content) {
309
354
  if (/furnace-ignore:\s*hardcoded-text/.test(content)) {
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * Applies `furnace.json#validationExceptions[]` to a `furnace validate`
3
3
  * result. An exception suppresses every finding of its check on its
4
- * component. One that suppresses nothing is stale: the finding was fixed
5
- * (or renamed) and the review record outlived it, so it is reported as a
6
- * warning naming the entry to delete, which keeps the list honest under
7
- * `--max-warnings 0`.
4
+ * component, or with `tag`, a finding whose element tags it (and its
5
+ * sibling tag entries) cover, or with `file`, a finding whose module files
6
+ * it (and its sibling file entries) cover. One that suppresses nothing is stale: the
7
+ * finding was fixed (or renamed) and the review record outlived it, so it
8
+ * is reported as a warning naming the entry to delete, which keeps the
9
+ * list honest under `--max-warnings 0`.
10
+ *
11
+ * An exception never suppresses an error: an error blocks apply, and a
12
+ * review record cannot make a broken component deployable. The error stays
13
+ * counted and the matching entry is reported as
14
+ * `validation-exception-on-error`.
8
15
  */
9
16
  import type { FurnaceValidationException, ValidationIssue } from '../types/furnace.js';
10
17
  /** The check name of the warning a stale exception produces. */
11
18
  export declare const STALE_VALIDATION_EXCEPTION_CHECK = "stale-validation-exception";
19
+ /** The check name of the error an exception matching an error produces. */
20
+ export declare const EXCEPTION_ON_ERROR_CHECK = "validation-exception-on-error";
12
21
  /** A finding an exception suppressed, with the exception's reason. */
13
22
  export interface ExceptedValidationIssue extends ValidationIssue {
14
23
  reason: string;
@@ -1,9 +1,40 @@
1
1
  import { createIssue } from './furnace-validate-helpers.js';
2
2
  /** The check name of the warning a stale exception produces. */
3
3
  export const STALE_VALIDATION_EXCEPTION_CHECK = 'stale-validation-exception';
4
+ /** The check name of the error an exception matching an error produces. */
5
+ export const EXCEPTION_ON_ERROR_CHECK = 'validation-exception-on-error';
4
6
  function matches(exception, issue) {
5
7
  return exception.component === issue.component && exception.check === issue.check;
6
8
  }
9
+ /** An exception's narrowing: the finding field it reads and its value. */
10
+ function scopeOf(entry) {
11
+ if (entry.tag !== undefined)
12
+ return { key: 'tags', value: entry.tag };
13
+ if (entry.file !== undefined)
14
+ return { key: 'files', value: entry.file };
15
+ return undefined;
16
+ }
17
+ /**
18
+ * The exceptions that suppress a warning: an unscoped one alone, or scoped
19
+ * ones that together cover every tag (or every file) the finding names.
20
+ */
21
+ function coveringExceptions(issue, candidates) {
22
+ const unscoped = candidates.find((entry) => scopeOf(entry) === undefined);
23
+ if (unscoped !== undefined)
24
+ return [unscoped];
25
+ for (const key of ['tags', 'files']) {
26
+ const named = issue[key] ?? [];
27
+ if (named.length === 0)
28
+ continue;
29
+ const covering = named.map((value) => candidates.find((entry) => {
30
+ const scope = scopeOf(entry);
31
+ return scope?.key === key && scope.value === value;
32
+ }));
33
+ if (covering.every((entry) => entry !== undefined))
34
+ return [...new Set(covering)];
35
+ }
36
+ return [];
37
+ }
7
38
  /**
8
39
  * Splits findings into counted and excepted, and reports stale exceptions.
9
40
  *
@@ -18,14 +49,38 @@ export function applyValidationExceptions(issues, exceptions, judged) {
18
49
  const outcome = { issues: [], excepted: [], stale: [] };
19
50
  const list = exceptions ?? [];
20
51
  const used = new Set();
52
+ const onError = new Set();
21
53
  for (const issue of issues) {
22
- const exception = list.find((entry) => matches(entry, issue));
23
- if (exception === undefined) {
54
+ const candidates = list.filter((entry) => matches(entry, issue));
55
+ if (issue.severity === 'error') {
56
+ for (const entry of candidates) {
57
+ used.add(entry);
58
+ onError.add(entry);
59
+ }
24
60
  outcome.issues.push(issue);
25
61
  continue;
26
62
  }
27
- used.add(exception);
28
- outcome.excepted.push({ ...issue, reason: exception.reason });
63
+ const covering = coveringExceptions(issue, candidates);
64
+ if (covering.length === 0) {
65
+ // A scoped exception that covers some of the tags (or files) is not
66
+ // stale: its element still carries the finding, beside one it does
67
+ // not cover.
68
+ for (const entry of candidates) {
69
+ const scope = scopeOf(entry);
70
+ if (scope !== undefined && issue[scope.key]?.includes(scope.value) === true) {
71
+ used.add(entry);
72
+ }
73
+ }
74
+ outcome.issues.push(issue);
75
+ continue;
76
+ }
77
+ for (const entry of covering)
78
+ used.add(entry);
79
+ outcome.excepted.push({ ...issue, reason: covering.map((entry) => entry.reason).join('; ') });
80
+ }
81
+ for (const exception of onError) {
82
+ outcome.issues.push(createIssue(exception.component, 'error', EXCEPTION_ON_ERROR_CHECK, `furnace.json validationExceptions entry for [${exception.check}] matches an error, ` +
83
+ 'which an exception cannot suppress. Fix the error, then delete the entry.'));
29
84
  }
30
85
  for (const exception of list) {
31
86
  if (used.has(exception) || !judged(exception.component))
@@ -18,10 +18,39 @@ import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-templa
18
18
  const NAMING_ATTRIBUTES = ['data-l10n-id', 'aria-label', '.arialabel'];
19
19
  /** Attributes that give the button a visible label, so it is not icon-only. */
20
20
  const LABEL_ATTRIBUTES = ['label', '.label'];
21
+ /**
22
+ * Whether the button's content has a non-blank character outside tags.
23
+ * Scans instead of stripping tags, so nested or unbalanced markup cannot
24
+ * leave a fragment behind that reads as text, and a `>` inside a quoted
25
+ * attribute value does not end its tag.
26
+ */
27
+ function hasVisibleText(markup) {
28
+ let inTag = false;
29
+ let quote;
30
+ for (const char of markup) {
31
+ if (quote !== undefined) {
32
+ if (char === quote)
33
+ quote = undefined;
34
+ }
35
+ else if (inTag) {
36
+ if (char === '"' || char === "'")
37
+ quote = char;
38
+ else if (char === '>')
39
+ inTag = false;
40
+ }
41
+ else if (char === '<') {
42
+ inTag = true;
43
+ }
44
+ else if (char.trim() !== '') {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
21
50
  function isIconOnly(attrs, innerText) {
22
51
  if (LABEL_ATTRIBUTES.some((name) => attrs.has(name)))
23
52
  return false;
24
- if (innerText.replace(/<[^>]*>/g, '').trim().length > 0)
53
+ if (hasVisibleText(innerText))
25
54
  return false;
26
55
  const type = attrs.get('type') ?? '';
27
56
  return attrs.has('iconsrc') || attrs.has('.iconsrc') || /(^|\s)icon(\s|$)/.test(type);
@@ -17,7 +17,7 @@ import { randomUUID } from 'node:crypto';
17
17
  import { appendFile, link, readFile, rename, rm, stat } from 'node:fs/promises';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { getNodeErrorCode, toError } from '../utils/errors.js';
20
- import { ensureDir } from '../utils/fs.js';
20
+ import { ensureDir, pathExists } from '../utils/fs.js';
21
21
  import { info, verbose } from '../utils/logger.js';
22
22
  import { FIREFORGE_DIR } from './config-paths.js';
23
23
  import { isValidHistoryPath } from './config-validate-history.js';
@@ -131,6 +131,13 @@ export async function migrateLegacyHistoryLog(patchesDir, target) {
131
131
  await link(claimed, target);
132
132
  }
133
133
  catch (error) {
134
+ // Windows can report two racing renames of one file as both
135
+ // succeeding, the file ending under the other claimant's name. That
136
+ // claimant holds the records and moves them, so this one stands down.
137
+ if (getNodeErrorCode(error) === 'ENOENT' && !(await pathExists(claimed))) {
138
+ verbose('History log migration lost the claim to a concurrent command');
139
+ return;
140
+ }
134
141
  // EEXIST: both logs exist. Anything else (a filesystem without hard
135
142
  // links, a target on another device): the append below creates the
136
143
  // target when it is missing, so it covers that case too.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Prefixes every line of a chunked stream, for output from several
3
+ * concurrent harness instances (`fireforge test --jobs`) that share one
4
+ * terminal and one run log. Without a prefix, interleaved lines from two
5
+ * browsers cannot be attributed after the fact.
6
+ */
7
+ /** A stateful line prefixer for one stream. */
8
+ export interface LinePrefixer {
9
+ /** Returns `chunk` with the prefix inserted at the start of each line. */
10
+ transform(chunk: string): string;
11
+ }
12
+ /**
13
+ * Creates a prefixer. Line state carries across chunks, so a line split
14
+ * between two chunks gets the prefix exactly once.
15
+ *
16
+ * @param prefix - Text to insert, e.g. `[job 2] `
17
+ */
18
+ export declare function createLinePrefixer(prefix: string): LinePrefixer;
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Prefixes every line of a chunked stream, for output from several
4
+ * concurrent harness instances (`fireforge test --jobs`) that share one
5
+ * terminal and one run log. Without a prefix, interleaved lines from two
6
+ * browsers cannot be attributed after the fact.
7
+ */
8
+ /**
9
+ * Creates a prefixer. Line state carries across chunks, so a line split
10
+ * between two chunks gets the prefix exactly once.
11
+ *
12
+ * @param prefix - Text to insert, e.g. `[job 2] `
13
+ */
14
+ export function createLinePrefixer(prefix) {
15
+ let atLineStart = true;
16
+ return {
17
+ transform(chunk) {
18
+ if (chunk.length === 0)
19
+ return chunk;
20
+ let out = '';
21
+ let start = 0;
22
+ for (let index = chunk.indexOf('\n'); index !== -1; index = chunk.indexOf('\n', start)) {
23
+ out += `${atLineStart ? prefix : ''}${chunk.slice(start, index + 1)}`;
24
+ atLineStart = true;
25
+ start = index + 1;
26
+ }
27
+ if (start < chunk.length) {
28
+ out += `${atLineStart ? prefix : ''}${chunk.slice(start)}`;
29
+ atLineStart = false;
30
+ }
31
+ return out;
32
+ },
33
+ };
34
+ }
@@ -47,6 +47,11 @@ export interface MachOptions {
47
47
  onProcessGroup?: (pgid: number) => void;
48
48
  /** Exec-layer hook, see {@link ProcessGroupOptions.postCloseSweep}. Capture-only. */
49
49
  postCloseSweep?: () => Promise<void>;
50
+ /**
51
+ * Prefix for every echoed and logged line (concurrent `test --jobs`
52
+ * instances). The capture and `onOutputChunk` stay raw. Capture-only.
53
+ */
54
+ outputPrefix?: string | undefined;
50
55
  }
51
56
  /**
52
57
  * Result of running a mach command while capturing streamed output.
@@ -263,6 +268,8 @@ export interface MachTestSuiteOptions {
263
268
  * the harness launched. See {@link MachOptions}.
264
269
  */
265
270
  teardown?: Pick<MachOptions, 'onOutputChunk' | 'onProcessGroup' | 'postCloseSweep'> | undefined;
271
+ /** Line prefix for echo and run log, see {@link MachOptions.outputPrefix}. */
272
+ outputPrefix?: string | undefined;
266
273
  }
267
274
  /**
268
275
  * Runs one mach test command (`mach test` / `mach xpcshell-test` / `mach
@@ -7,6 +7,7 @@ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from
7
7
  import { withBuildRunRecord } from './build-run-record.js';
8
8
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
9
9
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
10
+ import { createLinePrefixer } from './line-prefixer.js';
10
11
  import { explainMachError } from './mach-error-hints.js';
11
12
  import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, hasKnownTeardownNoise, KNOWN_TEARDOWN_NOISE_BUILD_NOTE, } from './mach-known-noise-filter.js';
12
13
  import { getPython } from './mach-python.js';
@@ -89,6 +90,15 @@ export async function runMachCapture(args, engineDir, options = {}) {
89
90
  const noiseContext = options.annotateKnownTeardownNoise === true ? createTeardownNoiseContext() : undefined;
90
91
  const stdoutFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
91
92
  const stderrFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
93
+ const prefix = options.outputPrefix;
94
+ const prefixers = prefix === undefined
95
+ ? undefined
96
+ : {
97
+ stdoutLog: createLinePrefixer(prefix),
98
+ stdoutEcho: createLinePrefixer(prefix),
99
+ stderrLog: createLinePrefixer(prefix),
100
+ stderrEcho: createLinePrefixer(prefix),
101
+ };
92
102
  const exitCode = await execStream(python, [machPath, ...args], {
93
103
  cwd: engineDir,
94
104
  ...(options.env ? { env: options.env } : {}),
@@ -110,8 +120,9 @@ export async function runMachCapture(args, engineDir, options = {}) {
110
120
  // echo filter only exists to shorten what a human scrolls past.
111
121
  // Collapsing a traceback in the artifact would reintroduce the loss
112
122
  // this log exists to prevent.
113
- writeToActiveRunLog(data);
114
- process.stdout.write(stdoutFilter ? stdoutFilter.transform(data) : data);
123
+ writeToActiveRunLog(prefixers ? prefixers.stdoutLog.transform(data) : data);
124
+ const echo = stdoutFilter ? stdoutFilter.transform(data) : data;
125
+ process.stdout.write(prefixers ? prefixers.stdoutEcho.transform(echo) : echo);
115
126
  },
116
127
  onStderr: (data) => {
117
128
  options.onOutputChunk?.('stderr', data);
@@ -119,19 +130,22 @@ export async function runMachCapture(args, engineDir, options = {}) {
119
130
  if (stderr.length > CAPTURE_TAIL_LIMIT) {
120
131
  stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
121
132
  }
122
- writeToActiveRunLog(data);
123
- process.stderr.write(stderrFilter ? stderrFilter.transform(data) : data);
133
+ writeToActiveRunLog(prefixers ? prefixers.stderrLog.transform(data) : data);
134
+ const echo = stderrFilter ? stderrFilter.transform(data) : data;
135
+ process.stderr.write(prefixers ? prefixers.stderrEcho.transform(echo) : echo);
124
136
  },
125
137
  });
126
138
  if (stdoutFilter) {
127
139
  const residue = stdoutFilter.flush();
128
- if (residue.length > 0)
129
- process.stdout.write(residue);
140
+ if (residue.length > 0) {
141
+ process.stdout.write(prefixers ? prefixers.stdoutEcho.transform(residue) : residue);
142
+ }
130
143
  }
131
144
  if (stderrFilter) {
132
145
  const residue = stderrFilter.flush();
133
- if (residue.length > 0)
134
- process.stderr.write(residue);
146
+ if (residue.length > 0) {
147
+ process.stderr.write(prefixers ? prefixers.stderrEcho.transform(residue) : residue);
148
+ }
135
149
  }
136
150
  return { stdout, stderr, exitCode };
137
151
  }
@@ -418,13 +432,14 @@ function testVerbosityEnvUnset(fullOutput) {
418
432
  * @param options - Engine checkout, test paths, extra args, env, verbosity
419
433
  */
420
434
  export async function runMachTestSuite(kind, options) {
421
- const { engineDir, testPaths = [], args = [], env, fullOutput, teardown } = options;
435
+ const { engineDir, testPaths = [], args = [], env, fullOutput, teardown, outputPrefix } = options;
422
436
  const guard = await installMachResourceGuard(engineDir);
423
437
  const envUnset = testVerbosityEnvUnset(fullOutput);
424
438
  return runMachCapture([kind, ...testPaths, ...args], engineDir, {
425
439
  env: { ...guard.env, ...env },
426
440
  ...(envUnset ? { envUnset } : {}),
427
441
  ...teardown,
442
+ ...(outputPrefix === undefined ? {} : { outputPrefix }),
428
443
  annotateKnownTeardownNoise: true,
429
444
  });
430
445
  }
@@ -18,6 +18,12 @@
18
18
  * no owned file is an upstream Firefox module, which FireForge does not
19
19
  * police and cannot cheaply enumerate. Policing only what the fork owns is
20
20
  * both the actionable set and the one that produces real incidents.
21
+ *
22
+ * An unscoped `verify` widens that set with the engine worktree: changed
23
+ * files import, and untracked files are targets. A chrome script's lazy
24
+ * getter for a new, not yet exported `.sys.mjs` with no moz.build line
25
+ * SIGSEGVed every start of a shared objdir, and neither file was in the
26
+ * queue yet, so the queue-only scan could not see it.
21
27
  */
22
28
  /** Why a queue-owned module import does not resolve to a packaged module. */
23
29
  export type UnresolvedModuleReason = 'missing-file' | 'unregistered';
@@ -31,16 +37,26 @@ export interface UnresolvedModuleImport {
31
37
  importers: string[];
32
38
  reason: UnresolvedModuleReason;
33
39
  }
40
+ /** Engine worktree files the preflight also judges, beside the queue's. */
41
+ export interface WorktreeModuleFiles {
42
+ /** Changed or untracked files: scanned for imports. */
43
+ changed: readonly string[];
44
+ /** Untracked files: new modules the fork has not exported yet. */
45
+ untracked: readonly string[];
46
+ }
47
+ /** Lists the engine's changed and untracked files for the worktree scan. */
48
+ export declare function collectWorktreeModuleFiles(engineDir: string): Promise<WorktreeModuleFiles>;
34
49
  /**
35
50
  * Resolves every `resource://` system-module import made by the
36
- * queue-owned files and reports the ones a packaged browser could not
37
- * load.
51
+ * queue-owned files (and, given `worktree`, by changed engine files) and
52
+ * reports the ones a packaged browser could not load.
38
53
  *
39
54
  * @param engineDir - Path to the engine checkout
40
55
  * @param ownedFiles - Engine-relative paths the patch queue owns
56
+ * @param worktree - Changed and untracked engine files to judge as well
41
57
  * @returns Findings sorted by module path. Empty when everything resolves
42
58
  */
43
- export declare function findUnresolvedSystemModuleImports(engineDir: string, ownedFiles: readonly string[]): Promise<UnresolvedModuleImport[]>;
59
+ export declare function findUnresolvedSystemModuleImports(engineDir: string, ownedFiles: readonly string[], worktree?: WorktreeModuleFiles): Promise<UnresolvedModuleImport[]>;
44
60
  /**
45
61
  * Renders findings as operator-facing lines, naming the unregistered or
46
62
  * unresolvable module, so the failure is a sentence instead of a bare
@@ -19,12 +19,19 @@
19
19
  * no owned file is an upstream Firefox module, which FireForge does not
20
20
  * police and cannot cheaply enumerate. Policing only what the fork owns is
21
21
  * both the actionable set and the one that produces real incidents.
22
+ *
23
+ * An unscoped `verify` widens that set with the engine worktree: changed
24
+ * files import, and untracked files are targets. A chrome script's lazy
25
+ * getter for a new, not yet exported `.sys.mjs` with no moz.build line
26
+ * SIGSEGVed every start of a shared objdir, and neither file was in the
27
+ * queue yet, so the queue-only scan could not see it.
22
28
  */
23
29
  import { dirname, join } from 'node:path';
24
30
  import { toError } from '../utils/errors.js';
25
31
  import { pathExists, readText } from '../utils/fs.js';
26
32
  import { verbose } from '../utils/logger.js';
27
33
  import { escapeRegex } from '../utils/regex.js';
34
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from './git-status.js';
28
35
  import { extractResourceModuleSpecifiers, moduleSourceSuffix, } from './patch-lint-module-registration.js';
29
36
  /** Extensions whose contents are scanned for resource-module imports. */
30
37
  const IMPORTABLE_EXTENSIONS = ['.mjs', '.sys.mjs', '.js', '.jsm'];
@@ -79,20 +86,31 @@ async function isRegisteredInAncestorMozBuild(engineDir, moduleRelPath, mozBuild
79
86
  dir = parent;
80
87
  }
81
88
  }
89
+ /** Lists the engine's changed and untracked files for the worktree scan. */
90
+ export async function collectWorktreeModuleFiles(engineDir) {
91
+ const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
92
+ const present = entries.filter((entry) => entry.worktreeStatus !== 'D');
93
+ return {
94
+ changed: present.map((entry) => entry.file),
95
+ untracked: present.filter((entry) => entry.status === '??').map((entry) => entry.file),
96
+ };
97
+ }
82
98
  /**
83
99
  * Resolves every `resource://` system-module import made by the
84
- * queue-owned files and reports the ones a packaged browser could not
85
- * load.
100
+ * queue-owned files (and, given `worktree`, by changed engine files) and
101
+ * reports the ones a packaged browser could not load.
86
102
  *
87
103
  * @param engineDir - Path to the engine checkout
88
104
  * @param ownedFiles - Engine-relative paths the patch queue owns
105
+ * @param worktree - Changed and untracked engine files to judge as well
89
106
  * @returns Findings sorted by module path. Empty when everything resolves
90
107
  */
91
- export async function findUnresolvedSystemModuleImports(engineDir, ownedFiles) {
92
- const owned = [...new Set(ownedFiles)];
93
- /** specifier → importing owned files. */
108
+ export async function findUnresolvedSystemModuleImports(engineDir, ownedFiles, worktree) {
109
+ const owned = [...new Set([...ownedFiles, ...(worktree?.untracked ?? [])])];
110
+ const importing = [...new Set([...owned, ...(worktree?.changed ?? [])])];
111
+ /** specifier → importing files. */
94
112
  const importersBySpecifier = new Map();
95
- for (const file of owned) {
113
+ for (const file of importing) {
96
114
  if (!IMPORTABLE_EXTENSIONS.some((extension) => file.endsWith(extension)))
97
115
  continue;
98
116
  const content = await readIfPresent(join(engineDir, file));