@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
@@ -7,26 +7,44 @@
7
7
  * or referencing foreign custom properties, but say nothing about
8
8
  * `gap: 7px`, `font-size: 13px` or `z-index: 9999`. Those are the values a
9
9
  * design scale exists to remove, and without a rule they drift back one
10
- * literal at a time. A scale names its properties, the token prefix that
11
- * defines its steps, and the literals that are not steps at all (`0`,
12
- * `auto`, a percentage).
10
+ * literal at a time. A scale names its properties, the token prefix (or
11
+ * prefixes) that defines its steps, and the literals that are not steps at
12
+ * all (`0`, `auto`, a percentage).
13
13
  *
14
14
  * Judged per value atom, after peeling `var()` innermost-first so a fallback
15
15
  * is held to the same scale as the token it backs: `var(--x-gap, 7px)` is a
16
16
  * literal in disguise. `calc()`, `min()`, `max()` and `clamp()` are
17
17
  * transparent, so a value built from tokens passes and one that adds a raw
18
- * length does not. Unitless multipliers inside them are literals too and
19
- * need an `allowLiterals` entry.
18
+ * length does not. Inside them, `allowInCalc` names literals legal only
19
+ * there (a unitless multiplier), and `offsetCap` lets a declaration
20
+ * subtract a few px from a token (a border inset) without allowing the px
21
+ * value anywhere else.
22
+ *
23
+ * Declarations inside descriptor at-rules (`@font-face`, `@counter-style`,
24
+ * `@property`, `@page`, …) are not property declarations and are not
25
+ * judged: `font-weight` in `@font-face` names a face, not a type step.
20
26
  *
21
27
  * Runs over CSS files the patch creates, not files it modifies: an upstream
22
- * stylesheet is not the fork's to put on a scale. A line carrying
23
- * `fireforge-ignore: token-scale` is skipped.
28
+ * stylesheet is not the fork's to put on a scale. A file a scale `exclude`s
29
+ * is not judged against that scale. A line carrying
30
+ * `fireforge-ignore: token-scale` is skipped. A scale with `minFiles` also
31
+ * fails the queue (`token-scale-vacuous`) when fewer created files carry
32
+ * its properties, so an emptied scan set cannot pass by judging nothing.
24
33
  */
34
+ import { matchesAnyGlob } from '../utils/path-glob.js';
25
35
  import { escapeRegex } from '../utils/regex.js';
26
36
  const IGNORE_MARKER = 'fireforge-ignore: token-scale';
27
37
  /** CSS-wide keywords: never a step on any scale, always legal. */
28
38
  const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
29
39
  const TOKEN_PLACEHOLDER = '\u0000token\u0000';
40
+ const MATH_FUNCTION = /^(?:calc|min|max|clamp)$/i;
41
+ const OPERATORS = new Set(['+', '-', '*', '/']);
42
+ /**
43
+ * At-rules whose blocks hold descriptors, not property declarations. A
44
+ * block nested in one (an `@page` margin box, a `@font-feature-values`
45
+ * feature block) is a descriptor block too.
46
+ */
47
+ const DESCRIPTOR_AT_RULE = /^@(?:font-face|counter-style|property|font-feature-values|font-palette-values|page|view-transition)\b/i;
30
48
  /** Exact name, or a `*`-suffixed prefix. */
31
49
  function matchesName(name, patterns) {
32
50
  return patterns.some((pattern) => pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
@@ -34,16 +52,70 @@ function matchesName(name, patterns) {
34
52
  function matchesLiteral(atom, patterns) {
35
53
  return patterns.some((pattern) => new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`).test(atom));
36
54
  }
55
+ /** The scale's token prefixes, whether configured as one or a list. */
56
+ function scalePrefixes(scale) {
57
+ return typeof scale.prefix === 'string' ? [scale.prefix] : scale.prefix;
58
+ }
59
+ /** The scale as a message names it: `--x-space-*`, or `--x-space-* / --x-gap-*`. */
60
+ function scaleLabel(scale) {
61
+ return scalePrefixes(scale)
62
+ .map((prefix) => `${prefix}*`)
63
+ .join(' / ');
64
+ }
65
+ /**
66
+ * Splits a value into atoms, keeping operators as atoms and noting which
67
+ * atoms sit inside a math function. A math function's name is dropped; any
68
+ * other function's name stays an atom, judged like a literal.
69
+ */
70
+ function valueAtoms(value) {
71
+ const atoms = [];
72
+ const mathStack = [];
73
+ let current = '';
74
+ const flush = () => {
75
+ if (current !== '')
76
+ atoms.push({ text: current, inMath: mathStack.includes(true) });
77
+ current = '';
78
+ };
79
+ for (const char of value) {
80
+ if (char === '(') {
81
+ const isMath = MATH_FUNCTION.test(current);
82
+ if (isMath)
83
+ current = '';
84
+ else
85
+ flush();
86
+ mathStack.push(isMath);
87
+ }
88
+ else if (char === ')') {
89
+ flush();
90
+ mathStack.pop();
91
+ }
92
+ else if (char === '*' || char === '/') {
93
+ flush();
94
+ atoms.push({ text: char, inMath: mathStack.includes(true) });
95
+ }
96
+ else if (char === ',' || /\s/.test(char)) {
97
+ flush();
98
+ }
99
+ else {
100
+ current += char;
101
+ }
102
+ }
103
+ flush();
104
+ return atoms;
105
+ }
106
+ const PX_LITERAL = /^(\d*\.?\d+)px$/;
37
107
  /**
38
108
  * Returns the first atom of `value` that is off `scale`, or undefined when
39
109
  * every atom resolves to the scale.
40
110
  */
41
111
  export function findOffScaleAtom(value, scale) {
42
112
  let rest = value.replace(/!\s*important\s*$/i, '');
113
+ const prefixes = scalePrefixes(scale);
43
114
  const varPattern = /var\(\s*(--[\w-]+)\s*(?:,([^()]*))?\)/;
44
115
  for (let match = varPattern.exec(rest); match !== null; match = varPattern.exec(rest)) {
45
116
  const name = match[1] ?? '';
46
- if (!name.startsWith(scale.prefix) && !matchesName(name, scale.allow ?? [])) {
117
+ const onScale = prefixes.some((prefix) => name.startsWith(prefix));
118
+ if (!onScale && !matchesName(name, scale.allow ?? [])) {
47
119
  return `var(${name})`;
48
120
  }
49
121
  const fallback = match[2];
@@ -54,54 +126,159 @@ export function findOffScaleAtom(value, scale) {
54
126
  }
55
127
  rest = `${rest.slice(0, match.index)} ${TOKEN_PLACEHOLDER} ${rest.slice(match.index + match[0].length)}`;
56
128
  }
57
- const atoms = rest
58
- .replace(/\b(?:calc|min|max|clamp)\(/gi, ' ')
59
- .replace(/[(),]/g, ' ')
60
- .split(/\s+/)
61
- .filter((atom) => atom !== '' && !['+', '-', '*', '/'].includes(atom));
62
- return atoms.find((atom) => atom !== TOKEN_PLACEHOLDER &&
63
- !CSS_WIDE_KEYWORDS.has(atom.toLowerCase()) &&
64
- !matchesLiteral(atom, scale.allowLiterals ?? []));
129
+ const atoms = valueAtoms(rest);
130
+ // A px offset subtracted from a token (`token - 1px`, `token - 1px - 1px`)
131
+ // counts toward `offsetCap`, summed over the declaration.
132
+ let offsetTotal = 0;
133
+ let lastOffsetIndex = -1;
134
+ for (const [index, atom] of atoms.entries()) {
135
+ if (OPERATORS.has(atom.text) || atom.text === TOKEN_PLACEHOLDER)
136
+ continue;
137
+ if (CSS_WIDE_KEYWORDS.has(atom.text.toLowerCase()))
138
+ continue;
139
+ if (matchesLiteral(atom.text, scale.allowLiterals ?? []))
140
+ continue;
141
+ if (atom.inMath && matchesLiteral(atom.text, scale.allowInCalc ?? []))
142
+ continue;
143
+ const px = PX_LITERAL.exec(atom.text);
144
+ if (atom.inMath && px !== null && scale.offsetCap !== undefined) {
145
+ const subtrahendOf = atoms[index - 2]?.text;
146
+ const subtracted = atoms[index - 1]?.text === '-' &&
147
+ (subtrahendOf === TOKEN_PLACEHOLDER || lastOffsetIndex === index - 2);
148
+ if (subtracted) {
149
+ offsetTotal += Number(px[1]);
150
+ lastOffsetIndex = index;
151
+ if (offsetTotal <= scale.offsetCap)
152
+ continue;
153
+ }
154
+ }
155
+ return atom.text;
156
+ }
157
+ return undefined;
65
158
  }
66
159
  /** Blanks block comments, keeping newlines so offsets map to lines. */
67
160
  function maskComments(css) {
68
161
  return css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' '));
69
162
  }
163
+ /**
164
+ * Blanks the contents of descriptor at-rule blocks (keeping braces and
165
+ * newlines), so the declaration scan never sees their descriptors.
166
+ */
167
+ function maskDescriptorBlocks(css) {
168
+ const out = css.split('');
169
+ const stack = [];
170
+ let preludeStart = 0;
171
+ for (let i = 0; i < css.length; i++) {
172
+ const char = css.charAt(i);
173
+ const inDescriptor = stack[stack.length - 1] === true;
174
+ if (char === '{') {
175
+ const prelude = css.slice(preludeStart, i).trim();
176
+ stack.push(inDescriptor || DESCRIPTOR_AT_RULE.test(prelude));
177
+ preludeStart = i + 1;
178
+ }
179
+ else if (char === '}') {
180
+ stack.pop();
181
+ preludeStart = i + 1;
182
+ }
183
+ else if (char === ';') {
184
+ preludeStart = i + 1;
185
+ }
186
+ if (inDescriptor && char !== '\n' && char !== '{' && char !== '}')
187
+ out[i] = ' ';
188
+ }
189
+ return out.join('');
190
+ }
191
+ /** Every property declaration of a stylesheet, descriptors and comments left out. */
192
+ function scanDeclarations(css) {
193
+ const masked = maskDescriptorBlocks(maskComments(css));
194
+ const declarations = [];
195
+ const pattern = /(^|[{;\s])([a-zA-Z][a-zA-Z-]*)\s*:\s*([^;{}]*)/g;
196
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
197
+ const start = match.index + (match[1] ?? '').length;
198
+ declarations.push({
199
+ property: (match[2] ?? '').toLowerCase(),
200
+ value: (match[3] ?? '').trim(),
201
+ line: masked.slice(0, start).split('\n').length,
202
+ });
203
+ }
204
+ return declarations;
205
+ }
206
+ /** The scales that govern `file`: those that do not `exclude` it. */
207
+ function scalesFor(file, scales) {
208
+ return scales.filter((scale) => !matchesAnyGlob(file, scale.exclude ?? []));
209
+ }
70
210
  /**
71
211
  * Lints one patch-created CSS file against the configured scales.
72
212
  *
73
- * @param file - Engine-relative path, for the issue
213
+ * @param file - Engine-relative path, for the issue and the `exclude` globs
74
214
  * @param css - The file's full content
75
215
  * @param scales - `patchLint.tokenScales`
76
216
  * @returns One `token-scale` error per off-scale declaration
77
217
  */
78
218
  export function lintTokenScales(file, css, scales) {
79
- if (scales.length === 0)
219
+ const governing = scalesFor(file, scales);
220
+ if (governing.length === 0)
80
221
  return [];
81
222
  const rawLines = css.split('\n');
82
- const masked = maskComments(css);
83
223
  const issues = [];
84
- const declaration = /(^|[{;\s])([a-zA-Z][a-zA-Z-]*)\s*:\s*([^;{}]*)/g;
85
- for (let match = declaration.exec(masked); match !== null; match = declaration.exec(masked)) {
86
- const property = (match[2] ?? '').toLowerCase();
87
- const value = (match[3] ?? '').trim();
88
- const scale = scales.find((candidate) => matchesName(property, candidate.properties));
224
+ for (const { property, value, line } of scanDeclarations(css)) {
225
+ const scale = governing.find((candidate) => matchesName(property, candidate.properties));
89
226
  if (scale === undefined || value === '')
90
227
  continue;
91
- const start = match.index + (match[1] ?? '').length;
92
- const line = masked.slice(0, start).split('\n').length;
93
228
  if ((rawLines[line - 1] ?? '').includes(IGNORE_MARKER))
94
229
  continue;
95
230
  const atom = findOffScaleAtom(value, scale);
96
231
  if (atom === undefined)
97
232
  continue;
233
+ const label = scaleLabel(scale);
98
234
  issues.push({
99
235
  file,
100
236
  check: 'token-scale',
101
237
  fingerprint: `token-scale:${file}:${property}:${value}`,
102
- message: `Line ${String(line)}: ${property}: ${value} is off the ${scale.prefix}* scale ` +
103
- `("${atom}"). Use a ${scale.prefix}* token, or list a reviewed exception in ` +
104
- `patchLint.tokenScales (allowLiterals / allow) or mark the line "${IGNORE_MARKER}".`,
238
+ message: `Line ${String(line)}: ${property}: ${value} is off the ${label} scale ` +
239
+ `("${atom}"). Use a ${label} token, or list a reviewed exception in ` +
240
+ `patchLint.tokenScales (allowLiterals / allowInCalc / allow) or mark the line "${IGNORE_MARKER}".`,
241
+ severity: 'error',
242
+ });
243
+ }
244
+ return issues;
245
+ }
246
+ /**
247
+ * The `minFiles` floor of every scale over the queue's created stylesheets:
248
+ * one `token-scale-vacuous` error per scale that fewer created CSS files
249
+ * (outside its `exclude`) carry a declaration of.
250
+ *
251
+ * @param createdFiles - Every patch-created file's path and content
252
+ * @param scales - `patchLint.tokenScales`
253
+ */
254
+ export function lintTokenScaleFloors(createdFiles, scales) {
255
+ const floored = scales.filter((scale) => scale.minFiles !== undefined);
256
+ if (floored.length === 0)
257
+ return [];
258
+ const counts = new Map(floored.map((scale) => [scale, 0]));
259
+ for (const [file, content] of createdFiles) {
260
+ if (!file.endsWith('.css'))
261
+ continue;
262
+ const properties = new Set(scanDeclarations(content).map((entry) => entry.property));
263
+ for (const scale of scalesFor(file, floored)) {
264
+ if ([...properties].some((property) => matchesName(property, scale.properties))) {
265
+ counts.set(scale, (counts.get(scale) ?? 0) + 1);
266
+ }
267
+ }
268
+ }
269
+ const issues = [];
270
+ for (const [scale, count] of counts) {
271
+ const floor = scale.minFiles ?? 0;
272
+ if (count >= floor)
273
+ continue;
274
+ const label = scaleLabel(scale);
275
+ issues.push({
276
+ file: 'fireforge.json',
277
+ check: 'token-scale-vacuous',
278
+ fingerprint: `token-scale-vacuous:${label}`,
279
+ message: `The ${label} scale governs ${String(count)} patch-created stylesheet(s), under its ` +
280
+ `minFiles ${String(floor)}. A scale that judges nothing passes vacuously: check its ` +
281
+ 'properties and exclude globs, or lower minFiles after a reviewed move.',
105
282
  severity: 'error',
106
283
  });
107
284
  }
@@ -14,7 +14,7 @@ import { hasRelativeImport } from './patch-lint-imports.js';
14
14
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
15
15
  import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
16
16
  import { lintObserverTopics } from './patch-lint-observer.js';
17
- import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
17
+ import { resolvableModulesForTests, resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
18
18
  import { invokePatchLintPrettier } from './patch-lint-prettier.js';
19
19
  import { isTestFile, lintPatchSize, measurePatchSize, } from './patch-lint-size.js';
20
20
  // ---------------------------------------------------------------------------
@@ -495,7 +495,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
495
495
  // Export-time twin of per-patch lint's test-file pass, so
496
496
  // export/re-export and `lint --per-patch` agree on the checked surface.
497
497
  if (config.patchLint.checkJsTestFiles === true) {
498
- const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir));
498
+ const testGrouped = await runCheckJsTestFilesGrouped(repoDir, resolvePatchOwnedTestScripts(newFiles, patchQueueCtx), config.patchLint, dirname(repoDir), undefined, patchQueueCtx ? resolvableModulesForTests(patchQueueCtx) : undefined);
499
499
  issues.push(...testGrouped.global);
500
500
  for (const [rel, list] of testGrouped.byFile) {
501
501
  if (options?.checkJsReportScope && !options.checkJsReportScope.has(rel))
@@ -19,6 +19,7 @@ import { pathExistsStrict, readJson, removeFile, writeJson } from '../utils/fs.j
19
19
  import { warn } from '../utils/logger.js';
20
20
  import { isArray, isObject } from '../utils/validation.js';
21
21
  import { assertPatchDirectoryLockHeld } from './patch-lock.js';
22
+ import { canonicalizeManifest, canonicalizePatchRow } from './patch-manifest-key-order.js';
22
23
  import { validatePatchesManifest } from './patch-manifest-validate.js';
23
24
  import { manifestWithRetiredSlots } from './patch-retired.js';
24
25
  /** Filename for the patches manifest */
@@ -86,7 +87,7 @@ export async function loadPatchesManifestForWrite(patchesDir) {
86
87
  */
87
88
  export async function savePatchesManifest(patchesDir, manifest) {
88
89
  const manifestPath = join(patchesDir, PATCHES_MANIFEST);
89
- await writeJson(manifestPath, manifest);
90
+ await writeJson(manifestPath, canonicalizeManifest(manifest));
90
91
  }
91
92
  /**
92
93
  * Mutates selected manifest rows while preserving the raw JSON shape of every
@@ -139,6 +140,12 @@ export async function mutatePatchRowsInManifest(patchesDir, filenames, mutator)
139
140
  }
140
141
  if (changedIndexes.length === 0)
141
142
  return [];
143
+ // Only the rows this call changed are put in canonical order, so a key
144
+ // it sets lands where every other writer puts it; untouched rows keep
145
+ // their raw shape.
146
+ for (const index of changedIndexes) {
147
+ rawPatches[index] = canonicalizePatchRow(rawPatches[index]);
148
+ }
142
149
  const nextRawManifest = {
143
150
  ...rawManifest,
144
151
  patches: rawPatches,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The one key order every `patches.json` writer uses for a patch row and
3
+ * for the manifest itself. Writers used to disagree: `export` spread
4
+ * `sourceProduct` before `sourceVersion` while a full-manifest save put it
5
+ * after `filesAffected`, so a no-op rewrite left a key-order-only hunk in
6
+ * the manifest.
7
+ */
8
+ /**
9
+ * Returns a copy of a patch row with its keys in canonical order.
10
+ *
11
+ * @param row - A `PatchMetadata` row, typed or raw
12
+ * @returns The same fields in canonical key order
13
+ */
14
+ export declare function canonicalizePatchRow<T extends object>(row: T): T;
15
+ /**
16
+ * Returns a copy of a manifest with its top-level keys and every row in
17
+ * canonical order.
18
+ *
19
+ * @param manifest - A `PatchesManifest`, typed or raw
20
+ * @returns The manifest in canonical key order
21
+ */
22
+ export declare function canonicalizeManifest<T extends {
23
+ patches: readonly object[];
24
+ }>(manifest: T): T;
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The one key order every `patches.json` writer uses for a patch row and
4
+ * for the manifest itself. Writers used to disagree: `export` spread
5
+ * `sourceProduct` before `sourceVersion` while a full-manifest save put it
6
+ * after `filesAffected`, so a no-op rewrite left a key-order-only hunk in
7
+ * the manifest.
8
+ */
9
+ /** Patch-row keys in the order of the `PatchMetadata` interface. */
10
+ const PATCH_ROW_KEY_ORDER = [
11
+ 'filename',
12
+ 'order',
13
+ 'category',
14
+ 'name',
15
+ 'description',
16
+ 'createdAt',
17
+ 'sourceEsrVersion',
18
+ 'sourceProduct',
19
+ 'sourceVersion',
20
+ 'filesAffected',
21
+ 'lintIgnore',
22
+ 'tier',
23
+ 'stagedDependencies',
24
+ ];
25
+ /** Manifest keys in the order of the `PatchesManifest` interface. */
26
+ const MANIFEST_KEY_ORDER = ['version', 'patches', 'retired', 'reserved'];
27
+ function reorder(value, order) {
28
+ const source = value;
29
+ const out = {};
30
+ for (const key of order) {
31
+ if (Object.hasOwn(source, key))
32
+ out[key] = source[key];
33
+ }
34
+ // Keys this build does not know keep their relative order after the
35
+ // known ones, so a newer writer's fields survive an older rewrite.
36
+ for (const key of Object.keys(source)) {
37
+ if (!Object.hasOwn(out, key))
38
+ out[key] = source[key];
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Returns a copy of a patch row with its keys in canonical order.
44
+ *
45
+ * @param row - A `PatchMetadata` row, typed or raw
46
+ * @returns The same fields in canonical key order
47
+ */
48
+ export function canonicalizePatchRow(row) {
49
+ return reorder(row, PATCH_ROW_KEY_ORDER);
50
+ }
51
+ /**
52
+ * Returns a copy of a manifest with its top-level keys and every row in
53
+ * canonical order.
54
+ *
55
+ * @param manifest - A `PatchesManifest`, typed or raw
56
+ * @returns The manifest in canonical key order
57
+ */
58
+ export function canonicalizeManifest(manifest) {
59
+ const ordered = reorder(manifest, MANIFEST_KEY_ORDER);
60
+ return { ...ordered, patches: manifest.patches.map((row) => canonicalizePatchRow(row)) };
61
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The manifest `patch stage` writes into the git index: HEAD's
3
+ * `patches.json` with only the named patches' rows (and their retired and
4
+ * reserved slots) taken from the working copy.
5
+ *
6
+ * Sessions sharing one checkout each touch their own rows of the shared
7
+ * manifest. Staging the whole file commits every session's rows under one
8
+ * message; staging hunks with `git apply --cached --unidiff-zero` lands a
9
+ * kept insertion at its new-side line number once a dropped hunk above it
10
+ * shifted the file, and committed invalid JSON. Merging rows from the
11
+ * parsed manifests has neither failure: the result is built from structure,
12
+ * then validated before anything is staged.
13
+ */
14
+ import type { PatchesManifest } from '../types/commands/index.js';
15
+ /** What staging does to one named patch's row. */
16
+ export type StagedRowChange = 'added' | 'updated' | 'removed' | 'unchanged';
17
+ /** The staged manifest and the per-patch row changes it carries. */
18
+ export interface StagedManifest {
19
+ manifest: PatchesManifest;
20
+ changes: Array<{
21
+ filename: string;
22
+ change: StagedRowChange;
23
+ }>;
24
+ }
25
+ /**
26
+ * Merges the named rows of `working` into `head`.
27
+ *
28
+ * Rows: every unnamed row comes from HEAD; a named row comes from the
29
+ * working copy, or leaves when the working copy no longer has it.
30
+ * Retired slots: a slot whose `filename` is named follows the working
31
+ * copy, the rest follow HEAD. Reserved slots follow HEAD. A slot whose
32
+ * order a staged row now fills is dropped, as every manifest write does.
33
+ *
34
+ * @param head - HEAD's manifest, or null when HEAD has none
35
+ * @param working - The working-copy manifest
36
+ * @param filenames - Patch filenames whose rows to take from the working copy
37
+ * @returns The validated manifest and the row changes
38
+ * @throws Error when two rows would hold one order, or the merge does not
39
+ * validate
40
+ */
41
+ export declare function mergeStagedManifest(head: PatchesManifest | null, working: PatchesManifest, filenames: ReadonlySet<string>): StagedManifest;
42
+ /**
43
+ * Serializes a staged manifest exactly as the manifest writers do, so the
44
+ * staged blob and a later full save of the same content are byte-identical.
45
+ */
46
+ export declare function serializeStagedManifest(manifest: PatchesManifest): string;
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The manifest `patch stage` writes into the git index: HEAD's
4
+ * `patches.json` with only the named patches' rows (and their retired and
5
+ * reserved slots) taken from the working copy.
6
+ *
7
+ * Sessions sharing one checkout each touch their own rows of the shared
8
+ * manifest. Staging the whole file commits every session's rows under one
9
+ * message; staging hunks with `git apply --cached --unidiff-zero` lands a
10
+ * kept insertion at its new-side line number once a dropped hunk above it
11
+ * shifted the file, and committed invalid JSON. Merging rows from the
12
+ * parsed manifests has neither failure: the result is built from structure,
13
+ * then validated before anything is staged.
14
+ */
15
+ import { toError } from '../utils/errors.js';
16
+ import { canonicalizeManifest } from './patch-manifest-key-order.js';
17
+ import { validatePatchesManifest } from './patch-manifest-validate.js';
18
+ import { carryReservedSlots, carryRetiredSlots } from './patch-retired.js';
19
+ /**
20
+ * Merges the named rows of `working` into `head`.
21
+ *
22
+ * Rows: every unnamed row comes from HEAD; a named row comes from the
23
+ * working copy, or leaves when the working copy no longer has it.
24
+ * Retired slots: a slot whose `filename` is named follows the working
25
+ * copy, the rest follow HEAD. Reserved slots follow HEAD. A slot whose
26
+ * order a staged row now fills is dropped, as every manifest write does.
27
+ *
28
+ * @param head - HEAD's manifest, or null when HEAD has none
29
+ * @param working - The working-copy manifest
30
+ * @param filenames - Patch filenames whose rows to take from the working copy
31
+ * @returns The validated manifest and the row changes
32
+ * @throws Error when two rows would hold one order, or the merge does not
33
+ * validate
34
+ */
35
+ export function mergeStagedManifest(head, working, filenames) {
36
+ const headRows = head?.patches ?? [];
37
+ const changes = [...filenames].sort().map((filename) => {
38
+ const before = headRows.find((row) => row.filename === filename);
39
+ const after = working.patches.find((row) => row.filename === filename);
40
+ const change = before === undefined
41
+ ? 'added'
42
+ : after === undefined
43
+ ? 'removed'
44
+ : JSON.stringify(canonicalRow(before)) === JSON.stringify(canonicalRow(after))
45
+ ? 'unchanged'
46
+ : 'updated';
47
+ return { filename, change };
48
+ });
49
+ const rows = [
50
+ ...headRows.filter((row) => !filenames.has(row.filename)),
51
+ ...working.patches.filter((row) => filenames.has(row.filename)),
52
+ ].sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
53
+ assertNoCollision(rows);
54
+ const retiredSource = [
55
+ ...(head?.retired ?? []).filter((slot) => !filenames.has(slot.filename)),
56
+ ...(working.retired ?? []).filter((slot) => filenames.has(slot.filename)),
57
+ ];
58
+ const retired = carryRetiredSlots({ retired: retiredSource }, rows);
59
+ const reserved = carryReservedSlots(head, rows);
60
+ const merged = {
61
+ version: 1,
62
+ patches: rows,
63
+ ...(retired !== undefined ? { retired } : {}),
64
+ ...(reserved !== undefined ? { reserved } : {}),
65
+ };
66
+ try {
67
+ validatePatchesManifest(JSON.parse(JSON.stringify(merged)));
68
+ }
69
+ catch (error) {
70
+ throw new Error(`the staged manifest would not validate: ${toError(error).message}`, {
71
+ cause: error,
72
+ });
73
+ }
74
+ return { manifest: canonicalizeManifest(merged), changes };
75
+ }
76
+ function canonicalRow(row) {
77
+ return canonicalizeManifest({ patches: [row] }).patches[0];
78
+ }
79
+ function assertNoCollision(rows) {
80
+ const seen = new Map();
81
+ for (const row of rows) {
82
+ const holder = seen.get(row.order);
83
+ if (holder !== undefined) {
84
+ throw new Error(`order ${String(row.order)} would be held by both ${holder} and ${row.filename}; ` +
85
+ 'stage the patch that moved off that order as well');
86
+ }
87
+ seen.set(row.order, row.filename);
88
+ }
89
+ }
90
+ /**
91
+ * Serializes a staged manifest exactly as the manifest writers do, so the
92
+ * staged blob and a later full save of the same content are byte-identical.
93
+ */
94
+ export function serializeStagedManifest(manifest) {
95
+ return `${JSON.stringify(manifest, null, 2)}\n`;
96
+ }
@@ -5,6 +5,7 @@
5
5
  import { parseObject } from '../utils/parse.js';
6
6
  import { isArray, isObject, isValidFirefoxProduct, isValidFirefoxVersion, PATCH_CATEGORIES, } from '../utils/validation.js';
7
7
  import { parseLintIgnoreEntries } from './patch-lint-ignore.js';
8
+ import { canonicalizePatchRow } from './patch-manifest-key-order.js';
8
9
  import { checkDocumentVersion, describeNewerDocument } from './state-file.js';
9
10
  function parseForwardImports(data, label) {
10
11
  if (!isArray(data)) {
@@ -134,7 +135,7 @@ function validatePatchMetadata(data, index) {
134
135
  if (rawStagedDependencies !== undefined) {
135
136
  result.stagedDependencies = parseStagedDependencies(rawStagedDependencies, `patches[${index}].stagedDependencies`);
136
137
  }
137
- return result;
138
+ return canonicalizePatchRow(result);
138
139
  }
139
140
  /**
140
141
  * Validates the `retired` placeholders `patch retire` leaves behind. Kept on
@@ -160,6 +161,29 @@ function parseRetiredSlots(data) {
160
161
  return slot;
161
162
  });
162
163
  }
164
+ /**
165
+ * Validates the `reserved` placeholders `patch reserve` writes. Same
166
+ * survival rule as {@link parseRetiredSlots}: dropping one on a round trip
167
+ * would reopen the gap a peer reserved the order to close.
168
+ */
169
+ function parseReservedSlots(data) {
170
+ if (!isArray(data)) {
171
+ throw new Error('patches.json field "reserved" must be an array');
172
+ }
173
+ return data.map((entry, index) => {
174
+ const rec = parseObject(entry, `reserved[${index}]`);
175
+ const slot = {
176
+ order: rec.nonNegativeInteger('order'),
177
+ category: rec.validatedString('category', (value) => /^[a-z][a-z0-9-]*$/.test(value), 'a lowercase category identifier (letters, numbers, hyphens)'),
178
+ owner: rec.validatedString('owner', (value) => value.trim() !== '', 'a non-empty label'),
179
+ reservedAt: rec.string('reservedAt'),
180
+ };
181
+ const reason = rec.optionalString('reason');
182
+ if (reason !== undefined)
183
+ slot.reason = reason;
184
+ return slot;
185
+ });
186
+ }
163
187
  /** Manifest schema version this build writes and understands. */
164
188
  const PATCHES_MANIFEST_VERSION = 1;
165
189
  /**
@@ -191,6 +215,8 @@ export function validatePatchesManifest(data) {
191
215
  };
192
216
  if (data['retired'] !== undefined)
193
217
  manifest.retired = parseRetiredSlots(data['retired']);
218
+ if (data['reserved'] !== undefined)
219
+ manifest.reserved = parseReservedSlots(data['reserved']);
194
220
  return manifest;
195
221
  }
196
222
  /**
@@ -14,7 +14,8 @@ import type { FireForgeConfig } from '../types/config.js';
14
14
  * Smallest order strictly greater than `after` that no patch occupies and
15
15
  * that adds no order-related `patchPolicy` finding (collision, category
16
16
  * range, reserved range, numeric gap) for a `category` patch. A retired
17
- * slot counts as free, since a patch may fill it. Returns null when no such
17
+ * slot counts as free, since a patch may fill it; a reservation does not,
18
+ * since it is held for its owner's export. Returns null when no such
18
19
  * order exists inside the category's configured ranges.
19
20
  *
20
21
  * @param config - Project config (only `patchPolicy` is read)