@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -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/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Validators for the two `furnace validate` policy fields of furnace.json:
4
- * `validationExceptions[]` and `optInChecks[]`.
3
+ * Validators for the `furnace validate` and `token lint` policy fields of
4
+ * furnace.json: `validationExceptions[]`, `optInChecks[]`, `minComponents`
5
+ * and `tokenSiblingSheets`.
5
6
  *
6
7
  * Strict on purpose, like `buildAudit.unpackaged`: every exception is a
7
8
  * standing suppression of a finding, so a malformed one must fail at load
@@ -10,7 +11,7 @@
10
11
  import { FurnaceError } from '../errors/furnace.js';
11
12
  import { FURNACE_OPT_IN_CHECKS, } from '../types/furnace.js';
12
13
  import { isArray, isObject, isString } from '../utils/validation.js';
13
- const EXCEPTION_KEYS = new Set(['component', 'check', 'reason']);
14
+ const EXCEPTION_KEYS = new Set(['component', 'check', 'tag', 'reason']);
14
15
  function requireNonEmptyString(entry, key, field) {
15
16
  const value = entry[key];
16
17
  if (!isString(value) || value.trim().length === 0) {
@@ -19,9 +20,9 @@ function requireNonEmptyString(entry, key, field) {
19
20
  return value;
20
21
  }
21
22
  /**
22
- * Parses `validationExceptions[]`: `{ component, check, reason }` objects,
23
- * no other keys, every field a non-empty string, and no two entries for the
24
- * same component and check.
23
+ * Parses `validationExceptions[]`: `{ component, check, tag?, reason }`
24
+ * objects, no other keys, every field a non-empty string, and no two
25
+ * entries for the same component, check and tag.
25
26
  */
26
27
  export function parseValidationExceptions(raw) {
27
28
  if (!isArray(raw)) {
@@ -43,9 +44,12 @@ export function parseValidationExceptions(raw) {
43
44
  check: requireNonEmptyString(entry, 'check', field),
44
45
  reason: requireNonEmptyString(entry, 'reason', field),
45
46
  };
46
- const identity = `${exception.component}\u0000${exception.check}`;
47
+ if (entry['tag'] !== undefined)
48
+ exception.tag = requireNonEmptyString(entry, 'tag', field);
49
+ const identity = `${exception.component}\u0000${exception.check}\u0000${exception.tag ?? ''}`;
47
50
  if (seen.has(identity)) {
48
- throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]`);
51
+ const on = exception.tag !== undefined ? ` on <${exception.tag}>` : '';
52
+ throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]${on}`);
49
53
  }
50
54
  seen.add(identity);
51
55
  return exception;
@@ -54,6 +58,33 @@ export function parseValidationExceptions(raw) {
54
58
  function isOptInCheck(value) {
55
59
  return FURNACE_OPT_IN_CHECKS.includes(value);
56
60
  }
61
+ /**
62
+ * Parses `tokenSiblingSheets`: an object from a sheet glob to a non-empty
63
+ * list of partner paths or globs.
64
+ */
65
+ export function parseTokenSiblingSheets(raw) {
66
+ if (!isObject(raw)) {
67
+ throw new FurnaceError('Furnace config: "tokenSiblingSheets" must be an object');
68
+ }
69
+ const out = {};
70
+ for (const [pattern, partners] of Object.entries(raw)) {
71
+ const valid = isArray(partners) &&
72
+ partners.length > 0 &&
73
+ partners.every((partner) => isString(partner) && partner.trim() !== '');
74
+ if (pattern.includes('..') || !valid || partners.some((p) => String(p).includes('..'))) {
75
+ throw new FurnaceError(`Furnace config: "tokenSiblingSheets.${pattern}" must be a non-empty list of engine-relative paths or globs (no "..")`);
76
+ }
77
+ out[pattern] = partners;
78
+ }
79
+ return out;
80
+ }
81
+ /** Parses `minComponents`: a positive integer. */
82
+ export function parseMinComponents(raw) {
83
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) {
84
+ throw new FurnaceError('Furnace config: "minComponents" must be a positive integer');
85
+ }
86
+ return raw;
87
+ }
57
88
  /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
58
89
  export function parseOptInChecks(raw) {
59
90
  if (!isArray(raw)) {
@@ -8,7 +8,7 @@
8
8
  import { FurnaceError } from '../errors/furnace.js';
9
9
  import { isJsonObject, isString } from '../utils/validation.js';
10
10
  import { parseStringArray } from './furnace-config-array-utils.js';
11
- import { parseOptInChecks, parseValidationExceptions, } from './furnace-config-validate-exceptions.js';
11
+ import { parseMinComponents, parseOptInChecks, parseTokenSiblingSheets, parseValidationExceptions, } from './furnace-config-validate-exceptions.js';
12
12
  /**
13
13
  * Validates an override component config object.
14
14
  * @param data - Raw data to validate
@@ -94,6 +94,9 @@ export function applyOptionalFurnaceFields(migrated, config) {
94
94
  if (migrated['platformPrefixes'] !== undefined) {
95
95
  config.platformPrefixes = parseStringArray(migrated['platformPrefixes'], 'platformPrefixes');
96
96
  }
97
+ if (migrated['tokenSiblingSheets'] !== undefined) {
98
+ config.tokenSiblingSheets = parseTokenSiblingSheets(migrated['tokenSiblingSheets']);
99
+ }
97
100
  if (migrated['runtimeVariables'] !== undefined) {
98
101
  config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
99
102
  }
@@ -137,4 +140,7 @@ export function applyOptionalFurnaceFields(migrated, config) {
137
140
  if (migrated['optInChecks'] !== undefined) {
138
141
  config.optInChecks = parseOptInChecks(migrated['optInChecks']);
139
142
  }
143
+ if (migrated['minComponents'] !== undefined) {
144
+ config.minComponents = parseMinComponents(migrated['minComponents']);
145
+ }
140
146
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Removes the lines registering `files` for `tagName`, leaving every other
3
+ * line of the block where it is. Returns how many lines were removed.
4
+ */
5
+ export declare function removeJarMnFileEntries(engineDir: string, tagName: string, files: readonly string[]): Promise<number>;
6
+ /**
7
+ * Rewrites `oldTag`'s lines in place as `newTag`'s. A line whose renamed
8
+ * file is in `liveFiles` keeps its position and layout; one whose file is
9
+ * gone is dropped. Returns the renamed files that already had a line; the
10
+ * caller's `addJarMnEntries` pass then inserts only the rest.
11
+ */
12
+ export declare function renameJarMnEntriesInPlace(engineDir: string, oldTag: string, newTag: string, liveFiles: readonly string[], renameFile: (file: string) => string): Promise<string[]>;
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * In-place edits to a widget's `toolkit/content/jar.mn` block.
4
+ *
5
+ * Undeploying one file and renaming a component used to drop every line of
6
+ * the widget and re-add the live ones, which re-sorted the whole block and
7
+ * left the owning patch carrying reorder hunks. These helpers touch only
8
+ * the lines that change: a deleted file's line goes, a renamed component's
9
+ * lines are rewritten where they stand, and anything still missing is left
10
+ * for `addJarMnEntries` to insert.
11
+ */
12
+ import { join } from 'node:path';
13
+ import { pathExists, readText, writeText } from '../utils/fs.js';
14
+ import { escapeRegex } from '../utils/regex.js';
15
+ import { JAR_MN } from './furnace-constants.js';
16
+ /**
17
+ * The file a widget jar.mn line registers: the `(widgets/<tag>/<file>)`
18
+ * source mapping, or the legacy `content/global/elements/<tag>.<ext>`
19
+ * target written before the mapping existed. Undefined when the line is
20
+ * not one of this tag's.
21
+ */
22
+ function registeredFile(line, tagName) {
23
+ const tag = escapeRegex(tagName);
24
+ const source = new RegExp(`\\(widgets/${tag}/([^)]+)\\)`).exec(line);
25
+ if (source)
26
+ return source[1];
27
+ const legacy = new RegExp(`content/global/elements/(${tag}\\.[^\\s]+)`).exec(line);
28
+ return legacy?.[1];
29
+ }
30
+ /**
31
+ * Removes the lines registering `files` for `tagName`, leaving every other
32
+ * line of the block where it is. Returns how many lines were removed.
33
+ */
34
+ export async function removeJarMnFileEntries(engineDir, tagName, files) {
35
+ const filePath = join(engineDir, JAR_MN);
36
+ if (files.length === 0 || !(await pathExists(filePath)))
37
+ return 0;
38
+ const doomed = new Set(files);
39
+ const lines = (await readText(filePath)).split('\n');
40
+ const kept = lines.filter((line) => {
41
+ const file = registeredFile(line, tagName);
42
+ return file === undefined || !doomed.has(file);
43
+ });
44
+ const removed = lines.length - kept.length;
45
+ if (removed > 0)
46
+ await writeText(filePath, kept.join('\n'));
47
+ return removed;
48
+ }
49
+ /**
50
+ * Rewrites `oldTag`'s lines in place as `newTag`'s. A line whose renamed
51
+ * file is in `liveFiles` keeps its position and layout; one whose file is
52
+ * gone is dropped. Returns the renamed files that already had a line; the
53
+ * caller's `addJarMnEntries` pass then inserts only the rest.
54
+ */
55
+ export async function renameJarMnEntriesInPlace(engineDir, oldTag, newTag, liveFiles, renameFile) {
56
+ const filePath = join(engineDir, JAR_MN);
57
+ if (!(await pathExists(filePath)))
58
+ return [];
59
+ const live = new Set(liveFiles);
60
+ const covered = [];
61
+ const out = [];
62
+ for (const line of (await readText(filePath)).split('\n')) {
63
+ const file = registeredFile(line, oldTag);
64
+ if (file === undefined) {
65
+ out.push(line);
66
+ continue;
67
+ }
68
+ const renamed = renameFile(file);
69
+ if (!live.has(renamed) || covered.includes(renamed))
70
+ continue;
71
+ // Textual substitution keeps the indent, column spacing and any
72
+ // trailing comment the line carried.
73
+ out.push(line
74
+ .replace(`content/global/elements/${file}`, `content/global/elements/${renamed}`)
75
+ .replace(`(widgets/${oldTag}/${file})`, `(widgets/${newTag}/${renamed})`));
76
+ covered.push(renamed);
77
+ }
78
+ await writeText(filePath, out.join('\n'));
79
+ return covered;
80
+ }
@@ -1,7 +1,9 @@
1
1
  import type { CustomComponentConfig, FurnaceOptInCheck, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
- * Validates accessibility patterns in a component's .mjs file.
4
- * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
3
+ * Validates accessibility patterns in a component's composed source: its
4
+ * host module and every companion module (see
5
+ * {@link readComposedComponentSource}). Checks for ARIA roles, keyboard
6
+ * handlers, l10n, and focus delegation.
5
7
  *
6
8
  * @param componentDir - Directory holding the component's sources
7
9
  * @param tagName - Custom element tag name, used to locate `<tagName>.mjs`
@@ -1,12 +1,36 @@
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 readComposedComponentSource(componentDir, tagName) {
15
+ const hostPath = join(componentDir, `${tagName}.mjs`);
16
+ if (!(await pathExists(hostPath)))
17
+ return undefined;
18
+ const entries = await readdir(componentDir).catch(() => []);
19
+ const companions = entries
20
+ .filter((name) => name.startsWith(`${tagName}-`) &&
21
+ name.endsWith('.mjs') &&
22
+ !/\.(test|spec|stories)\./.test(name))
23
+ .sort();
24
+ const sources = [await readText(hostPath)];
25
+ for (const name of companions)
26
+ sources.push(await readText(join(componentDir, name)));
27
+ return sources.join('\n');
28
+ }
29
+ /**
30
+ * Validates accessibility patterns in a component's composed source: its
31
+ * host module and every companion module (see
32
+ * {@link readComposedComponentSource}). Checks for ARIA roles, keyboard
33
+ * handlers, l10n, and focus delegation.
10
34
  *
11
35
  * @param componentDir - Directory holding the component's sources
12
36
  * @param tagName - Custom element tag name, used to locate `<tagName>.mjs`
@@ -20,10 +44,9 @@ import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-templa
20
44
  * adds the icon-only `<moz-button>` naming check.
21
45
  */
22
46
  export async function validateAccessibility(componentDir, tagName, customConfig, optInChecks = []) {
23
- const mjsPath = join(componentDir, `${tagName}.mjs`);
24
- if (!(await pathExists(mjsPath)))
47
+ const content = await readComposedComponentSource(componentDir, tagName);
48
+ if (content === undefined)
25
49
  return [];
26
- const content = await readText(mjsPath);
27
50
  const issues = [];
28
51
  if (!hasAriaRole(content) && hasGenericInteractiveElement(content)) {
29
52
  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,10 +64,13 @@ export async function validateAccessibility(componentDir, tagName, customConfig,
41
64
  // activation flows through the inner element and a duplicate handler on
42
65
  // the wrapper would either no-op or fire twice alongside the child's
43
66
  // built-in keyboard path.
44
- const hasClickOnSynthetic = hasTemplateClickOnSyntheticInteractive(content);
67
+ const syntheticTags = syntheticClickTags(content);
45
68
  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).'));
69
+ if (syntheticTags.length > 0 && !hasKeyboardHandler && !keyboardCovered) {
70
+ issues.push({
71
+ ...createIssue(tagName, 'warning', 'no-keyboard-handler', `Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup), on ${syntheticTags.map((tag) => `<${tag}>`).join(', ')}.`),
72
+ tags: syntheticTags,
73
+ });
48
74
  }
49
75
  if (containsHardcodedTemplateText(content)) {
50
76
  issues.push(createIssue(tagName, 'warning', 'hardcoded-text', 'Possible hardcoded string found. Use data-l10n-id for localization.'));
@@ -164,6 +190,9 @@ const NATIVE_CLICK_INTERACTIVE_TAGS = new Set([
164
190
  'moz-radio',
165
191
  'moz-radio-group',
166
192
  'moz-menulist',
193
+ // Toolkit's panel-list row renders a button that takes Enter and Space
194
+ // and dispatches click.
195
+ 'panel-item',
167
196
  ]);
168
197
  /**
169
198
  * Returns true when `customConfig` declares that the component's keyboard
@@ -185,28 +214,33 @@ function isKeyboardCoveredByComposition(customConfig) {
185
214
  return composes.some((tag) => NATIVE_CLICK_INTERACTIVE_TAGS.has(tag));
186
215
  }
187
216
  /**
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.
217
+ * Returns the tags (in first-seen order) of every *synthetic* interactive
218
+ * element carrying an `@click=${...}` handler (e.g. `<div @click>`), which
219
+ * lacks native keyboard activation and therefore needs an explicit key
220
+ * handler for Enter/Space. Empty when every `@click` handler sits on a
221
+ * native interactive element. Those already fire `click` on keyboard
222
+ * activation.
193
223
  */
194
- function hasTemplateClickOnSyntheticInteractive(content) {
224
+ function syntheticClickTags(content) {
195
225
  const pattern = /@click\s*=\s*\$\{/g;
226
+ const tags = [];
196
227
  let match;
197
228
  while ((match = pattern.exec(content)) !== null) {
198
- if (isClickOnSyntheticInteractive(content, match.index)) {
199
- return true;
200
- }
229
+ const tag = syntheticClickTag(content, match.index);
230
+ if (tag !== undefined && !tags.includes(tag))
231
+ tags.push(tag);
201
232
  }
202
- return false;
233
+ return tags;
203
234
  }
235
+ /** The tag a synthetic-element `@click` sits on, `?` when it cannot be told. */
236
+ const UNKNOWN_TAG = '?';
204
237
  /**
205
238
  * 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).
239
+ * the opening `<tag` that owns it and returns that tag when it is a
240
+ * synthetic interactive element (warning needed), or undefined when it is a
241
+ * native one (no warning needed).
208
242
  */
209
- function isClickOnSyntheticInteractive(content, clickIndex) {
243
+ function syntheticClickTag(content, clickIndex) {
210
244
  // Find the nearest preceding `<` that starts a tag (skip `</` closers).
211
245
  let i = clickIndex - 1;
212
246
  let tagOpenIndex = -1;
@@ -219,15 +253,15 @@ function isClickOnSyntheticInteractive(content, clickIndex) {
219
253
  // which shouldn't happen for a well-formed template but we defensively
220
254
  // treat it as synthetic to preserve the prior-behaviour warning.
221
255
  if (content[i] === '>') {
222
- return true;
256
+ return UNKNOWN_TAG;
223
257
  }
224
258
  i--;
225
259
  }
226
260
  if (tagOpenIndex < 0)
227
- return true;
261
+ return UNKNOWN_TAG;
228
262
  const tagMatch = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(content.slice(tagOpenIndex));
229
263
  if (!tagMatch?.[1])
230
- return true;
264
+ return UNKNOWN_TAG;
231
265
  const tagName = tagMatch[1].toLowerCase();
232
266
  if (tagName === 'a') {
233
267
  // Bare <a> (no href) is non-interactive, so require a keyboard handler.
@@ -235,11 +269,11 @@ function isClickOnSyntheticInteractive(content, clickIndex) {
235
269
  // attribute text in between for an href attribute.
236
270
  const tagEnd = content.indexOf('>', tagOpenIndex);
237
271
  if (tagEnd < 0)
238
- return true;
272
+ return 'a';
239
273
  const attrs = content.slice(tagOpenIndex, tagEnd);
240
- return !/\shref\s*=/.test(attrs);
274
+ return /\shref\s*=/.test(attrs) ? undefined : 'a';
241
275
  }
242
- return !NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName);
276
+ return NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName) ? undefined : tagName;
243
277
  }
244
278
  /** ASCII punctuation that reads as decoration rather than localizable text. */
245
279
  const SYMBOL_ONLY_ASCII = '+-*=<>|/\\^~@#&!?%';
@@ -1,14 +1,22 @@
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. One that suppresses nothing is stale: the
6
+ * finding was fixed (or renamed) and the review record outlived it, so it
7
+ * is reported as a warning naming the entry to delete, which keeps the
8
+ * list honest under `--max-warnings 0`.
9
+ *
10
+ * An exception never suppresses an error: an error blocks apply, and a
11
+ * review record cannot make a broken component deployable. The error stays
12
+ * counted and the matching entry is reported as
13
+ * `validation-exception-on-error`.
8
14
  */
9
15
  import type { FurnaceValidationException, ValidationIssue } from '../types/furnace.js';
10
16
  /** The check name of the warning a stale exception produces. */
11
17
  export declare const STALE_VALIDATION_EXCEPTION_CHECK = "stale-validation-exception";
18
+ /** The check name of the error an exception matching an error produces. */
19
+ export declare const EXCEPTION_ON_ERROR_CHECK = "validation-exception-on-error";
12
20
  /** A finding an exception suppressed, with the exception's reason. */
13
21
  export interface ExceptedValidationIssue extends ValidationIssue {
14
22
  reason: string;
@@ -1,9 +1,25 @@
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
+ /**
10
+ * The exceptions that suppress a warning: an untagged one alone, or tagged
11
+ * ones that together cover every tag the finding names.
12
+ */
13
+ function coveringExceptions(issue, candidates) {
14
+ const untagged = candidates.find((entry) => entry.tag === undefined);
15
+ if (untagged !== undefined)
16
+ return [untagged];
17
+ const tags = issue.tags ?? [];
18
+ if (tags.length === 0)
19
+ return [];
20
+ const covering = tags.map((tag) => candidates.find((entry) => entry.tag === tag));
21
+ return covering.every((entry) => entry !== undefined) ? [...new Set(covering)] : [];
22
+ }
7
23
  /**
8
24
  * Splits findings into counted and excepted, and reports stale exceptions.
9
25
  *
@@ -18,14 +34,35 @@ export function applyValidationExceptions(issues, exceptions, judged) {
18
34
  const outcome = { issues: [], excepted: [], stale: [] };
19
35
  const list = exceptions ?? [];
20
36
  const used = new Set();
37
+ const onError = new Set();
21
38
  for (const issue of issues) {
22
- const exception = list.find((entry) => matches(entry, issue));
23
- if (exception === undefined) {
39
+ const candidates = list.filter((entry) => matches(entry, issue));
40
+ if (issue.severity === 'error') {
41
+ for (const entry of candidates) {
42
+ used.add(entry);
43
+ onError.add(entry);
44
+ }
24
45
  outcome.issues.push(issue);
25
46
  continue;
26
47
  }
27
- used.add(exception);
28
- outcome.excepted.push({ ...issue, reason: exception.reason });
48
+ const covering = coveringExceptions(issue, candidates);
49
+ if (covering.length === 0) {
50
+ // A tagged exception that covers some of the tags is not stale: its
51
+ // element still carries the finding, beside one it does not cover.
52
+ for (const entry of candidates) {
53
+ if (entry.tag !== undefined && issue.tags?.includes(entry.tag) === true)
54
+ used.add(entry);
55
+ }
56
+ outcome.issues.push(issue);
57
+ continue;
58
+ }
59
+ for (const entry of covering)
60
+ used.add(entry);
61
+ outcome.excepted.push({ ...issue, reason: covering.map((entry) => entry.reason).join('; ') });
62
+ }
63
+ for (const exception of onError) {
64
+ outcome.issues.push(createIssue(exception.component, 'error', EXCEPTION_ON_ERROR_CHECK, `furnace.json validationExceptions entry for [${exception.check}] matches an error, ` +
65
+ 'which an exception cannot suppress. Fix the error, then delete the entry.'));
29
66
  }
30
67
  for (const exception of list) {
31
68
  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