@hominis/fireforge 0.10.1 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { CustomComponentConfig, FurnaceConfig, RegistrationStatus, ValidationIssue } from '../types/furnace.js';
1
+ import type { CustomComponentConfig, FurnaceConfig, RegistrationStatus, StepError, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Validates that all Furnace-managed .mjs components are registered in the
4
4
  * DOMContentLoaded/importESModule block (Pattern B), not the loadSubScript
@@ -20,7 +20,7 @@ export declare function validateRegistrationPatterns(root: string, config: Furna
20
20
  * @param config - Custom component configuration
21
21
  * @returns Registration status with per-check booleans and drift info
22
22
  */
23
- export declare function checkRegistrationConsistency(root: string, name: string, config: CustomComponentConfig): Promise<RegistrationStatus>;
23
+ export declare function checkRegistrationConsistency(root: string, name: string, config: CustomComponentConfig, ftlDir?: string): Promise<RegistrationStatus>;
24
24
  /**
25
25
  * Validates that each custom component with `register: true` has its .mjs and
26
26
  * .css entries in jar.mn.
@@ -35,3 +35,18 @@ export declare function validateJarMnEntries(root: string, config: FurnaceConfig
35
35
  * linked in browser.xhtml. Without the link, tokens silently resolve to nothing.
36
36
  */
37
37
  export declare function validateTokenLink(componentDir: string, tagName: string, root: string, tokenPrefix?: string): Promise<ValidationIssue[]>;
38
+ /**
39
+ * Post-apply registration consistency check for custom components.
40
+ *
41
+ * Detects customElements.js / jar.mn inconsistencies caused by a partial
42
+ * apply. Errors are surfaced as step-level warnings on the affected
43
+ * component rather than blocking the entire apply.
44
+ */
45
+ export declare function runPostApplyConsistencyChecks(root: string, config: {
46
+ custom: Record<string, CustomComponentConfig>;
47
+ }, result: {
48
+ applied: Array<{
49
+ name: string;
50
+ stepErrors?: StepError[];
51
+ }>;
52
+ }, ftlDir: string): Promise<void>;
@@ -8,7 +8,7 @@ import { warn } from '../utils/logger.js';
8
8
  import { stripJsComments } from '../utils/regex.js';
9
9
  import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
- import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
11
+ import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
12
  import { getTokensCssPath } from './token-manager.js';
13
13
  /**
14
14
  * Validates that all Furnace-managed .mjs components are registered in the
@@ -62,7 +62,7 @@ export async function validateRegistrationPatterns(root, config) {
62
62
  * @param config - Custom component configuration
63
63
  * @returns Registration status with per-check booleans and drift info
64
64
  */
65
- export async function checkRegistrationConsistency(root, name, config) {
65
+ export async function checkRegistrationConsistency(root, name, config, ftlDir) {
66
66
  const { engine: engineDir } = getProjectPaths(root);
67
67
  const furnacePaths = getFurnacePaths(root);
68
68
  const componentDir = join(furnacePaths.customDir, name);
@@ -112,6 +112,30 @@ export async function checkRegistrationConsistency(root, name, config) {
112
112
  else {
113
113
  status.filesInSync = false;
114
114
  }
115
+ // Localized components deploy a .ftl file outside `targetDir` (into the
116
+ // shared Fluent tree). The .mjs/.css loop above cannot see it, so drift
117
+ // there would otherwise be invisible to apply's fast-path and to `status`.
118
+ if (config.localized) {
119
+ const ftlName = `${name}.ftl`;
120
+ const ftlSrc = join(componentDir, ftlName);
121
+ if (await pathExists(ftlSrc)) {
122
+ const ftlDest = join(engineDir, ftlDir ?? FTL_DIR, ftlName);
123
+ if (!(await pathExists(ftlDest))) {
124
+ status.missingTargetFiles.push(ftlName);
125
+ status.filesInSync = false;
126
+ }
127
+ else {
128
+ const srcContent = await readText(ftlSrc);
129
+ const destContent = await readText(ftlDest);
130
+ const srcHash = createHash('sha256').update(srcContent).digest('hex');
131
+ const destHash = createHash('sha256').update(destContent).digest('hex');
132
+ if (srcHash !== destHash) {
133
+ status.driftedFiles.push(ftlName);
134
+ status.filesInSync = false;
135
+ }
136
+ }
137
+ }
138
+ }
115
139
  // Check jar.mn entries
116
140
  const jarMnPath = join(engineDir, JAR_MN);
117
141
  if (await pathExists(jarMnPath)) {
@@ -153,6 +177,7 @@ export async function validateJarMnEntries(root, config) {
153
177
  return issues;
154
178
  }
155
179
  const jarContent = await readText(jarMnPath);
180
+ const furnacePaths = getFurnacePaths(root);
156
181
  for (const [name, customConfig] of Object.entries(config.custom)) {
157
182
  if (!customConfig.register)
158
183
  continue;
@@ -164,7 +189,13 @@ export async function validateJarMnEntries(root, config) {
164
189
  message: `${name}.mjs is not registered in jar.mn. Run "fireforge furnace deploy" to register.`,
165
190
  });
166
191
  }
167
- if (!jarContent.includes(`content/global/elements/${name}.css`)) {
192
+ // Only complain about a missing CSS entry when the source actually
193
+ // ships a CSS file. Components that intentionally have no CSS would
194
+ // otherwise generate a permanent false-positive that trains developers
195
+ // to ignore validator output.
196
+ const cssSourcePath = join(furnacePaths.customDir, name, `${name}.css`);
197
+ if ((await pathExists(cssSourcePath)) &&
198
+ !jarContent.includes(`content/global/elements/${name}.css`)) {
168
199
  issues.push({
169
200
  component: name,
170
201
  severity: 'warning',
@@ -217,4 +248,43 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
217
248
  }
218
249
  return issues;
219
250
  }
251
+ /**
252
+ * Post-apply registration consistency check for custom components.
253
+ *
254
+ * Detects customElements.js / jar.mn inconsistencies caused by a partial
255
+ * apply. Errors are surfaced as step-level warnings on the affected
256
+ * component rather than blocking the entire apply.
257
+ */
258
+ export async function runPostApplyConsistencyChecks(root, config, result, ftlDir) {
259
+ for (const [name, customConfig] of Object.entries(config.custom)) {
260
+ if (!customConfig.register)
261
+ continue;
262
+ if (!result.applied.some((a) => a.name === name))
263
+ continue;
264
+ try {
265
+ const status = await checkRegistrationConsistency(root, name, customConfig, ftlDir);
266
+ const issues = [];
267
+ if (!status.customElementsPresent) {
268
+ issues.push('missing customElements.js registration');
269
+ }
270
+ if (!status.jarMnMjs && status.sourceExists) {
271
+ issues.push('missing jar.mn .mjs entry');
272
+ }
273
+ if (issues.length > 0) {
274
+ const entry = result.applied.find((a) => a.name === name);
275
+ if (entry) {
276
+ const stepErrors = entry.stepErrors ?? [];
277
+ stepErrors.push({
278
+ step: 'post-apply consistency',
279
+ error: `Registration inconsistency: ${issues.join(', ')}`,
280
+ });
281
+ entry.stepErrors = stepErrors;
282
+ }
283
+ }
284
+ }
285
+ catch {
286
+ // Consistency check is best-effort; failures here should not block apply
287
+ }
288
+ }
289
+ }
220
290
  //# sourceMappingURL=furnace-validate-registration.js.map
@@ -1,6 +1,14 @@
1
- import type { ComponentType, ValidationIssue } from '../types/furnace.js';
1
+ import type { ComponentType, CustomComponentConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Validates the file structure of a component directory.
4
4
  * Checks for required files and naming conventions.
5
+ *
6
+ * @param componentDir - Component source directory
7
+ * @param tagName - Component tag name
8
+ * @param type - Component type (stock, override, custom)
9
+ * @param customConfig - When `type === 'custom'`, the matching config from
10
+ * furnace.json. Used to derive `localized`, which gates the `.ftl`
11
+ * requirement. Optional so existing callers without config in scope (e.g.
12
+ * the structure-only test fixtures) can keep calling without changes.
5
13
  */
6
- export declare function validateStructure(componentDir: string, tagName: string, type: ComponentType): Promise<ValidationIssue[]>;
14
+ export declare function validateStructure(componentDir: string, tagName: string, type: ComponentType, customConfig?: CustomComponentConfig): Promise<ValidationIssue[]>;
@@ -1,12 +1,20 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { pathExists } from '../utils/fs.js';
4
+ import { pathExists, readText } from '../utils/fs.js';
5
5
  /**
6
6
  * Validates the file structure of a component directory.
7
7
  * Checks for required files and naming conventions.
8
+ *
9
+ * @param componentDir - Component source directory
10
+ * @param tagName - Component tag name
11
+ * @param type - Component type (stock, override, custom)
12
+ * @param customConfig - When `type === 'custom'`, the matching config from
13
+ * furnace.json. Used to derive `localized`, which gates the `.ftl`
14
+ * requirement. Optional so existing callers without config in scope (e.g.
15
+ * the structure-only test fixtures) can keep calling without changes.
8
16
  */
9
- export async function validateStructure(componentDir, tagName, type) {
17
+ export async function validateStructure(componentDir, tagName, type, customConfig) {
10
18
  const issues = [];
11
19
  const mjsPath = join(componentDir, `${tagName}.mjs`);
12
20
  const cssPath = join(componentDir, `${tagName}.css`);
@@ -28,8 +36,42 @@ export async function validateStructure(componentDir, tagName, type) {
28
36
  message: `No ${tagName}.css found. Consider adding styles.`,
29
37
  });
30
38
  }
39
+ // Localized custom components must have a {tag}.ftl file. Without one,
40
+ // apply silently deploys nothing for the locale and the runtime
41
+ // localization payload is empty, which is hard to spot in review.
42
+ if (type === 'custom' && customConfig?.localized) {
43
+ const ftlPath = join(componentDir, `${tagName}.ftl`);
44
+ if (!(await pathExists(ftlPath))) {
45
+ issues.push({
46
+ component: tagName,
47
+ severity: 'error',
48
+ check: 'missing-ftl',
49
+ message: `Component is marked localized: true but ${tagName}.ftl is missing. Create the file or set localized: false in furnace.json.`,
50
+ });
51
+ }
52
+ }
53
+ // Conflict markers left by furnace refresh (three-way merge) must be
54
+ // resolved before the component can be applied or deployed.
55
+ const dirEntries = await readdir(componentDir, { withFileTypes: true });
56
+ for (const entry of dirEntries) {
57
+ if (!entry.isFile())
58
+ continue;
59
+ if (!entry.name.endsWith('.mjs') &&
60
+ !entry.name.endsWith('.css') &&
61
+ !entry.name.endsWith('.ftl'))
62
+ continue;
63
+ const content = await readText(join(componentDir, entry.name));
64
+ if (/^<{7}\s/m.test(content) || /^>{7}\s/m.test(content) || /^={7}$/m.test(content)) {
65
+ issues.push({
66
+ component: tagName,
67
+ severity: 'error',
68
+ check: 'conflict-markers',
69
+ message: `File "${entry.name}" contains unresolved merge conflict markers. Resolve conflicts before applying.`,
70
+ });
71
+ }
72
+ }
31
73
  // File names should match tag name
32
- const entries = await readdir(componentDir, { withFileTypes: true });
74
+ const entries = dirEntries;
33
75
  for (const entry of entries) {
34
76
  if (!entry.isFile())
35
77
  continue;
@@ -1,12 +1,21 @@
1
1
  import type { ComponentType, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Runs all validation checks on a single component.
4
+ *
4
5
  * @param componentDir - Path to the component directory
5
6
  * @param tagName - Component tag name
6
7
  * @param type - Component type (stock, override, custom)
8
+ * @param config - Optional furnace config for cross-component checks
9
+ * @param root - Optional project root for checks that read outside componentDir
10
+ * @param options - Optional behavior flags. `skipAggregateChecks` suppresses the
11
+ * per-component registration/jar.mn scan so that an outer caller
12
+ * (e.g. validateAllComponents) can run the aggregate versions once
13
+ * without double-reporting the same issues.
7
14
  * @returns Combined list of validation issues
8
15
  */
9
- export declare function validateComponent(componentDir: string, tagName: string, type: ComponentType, config?: FurnaceConfig, root?: string): Promise<ValidationIssue[]>;
16
+ export declare function validateComponent(componentDir: string, tagName: string, type: ComponentType, config?: FurnaceConfig, root?: string, options?: {
17
+ skipAggregateChecks?: boolean;
18
+ }): Promise<ValidationIssue[]>;
10
19
  /**
11
20
  * Validates all components registered in furnace.json.
12
21
  * Stock components are skipped (no local files to validate).
@@ -1,30 +1,60 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists } from '../utils/fs.js';
4
+ import { loadConfig } from './config.js';
4
5
  import { getFurnacePaths, loadFurnaceConfig } from './furnace-config.js';
6
+ import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
5
7
  import { validateAccessibility, validateCompatibility, validateJarMnEntries, validateRegistrationPatterns, validateStructure, validateTokenLink, } from './furnace-validate-checks.js';
8
+ import { findOverrideBaseVersionDrift, } from './furnace-version-drift.js';
9
+ function buildOverrideVersionDriftIssues(config, currentVersion, tagName) {
10
+ return findOverrideBaseVersionDrift(config, currentVersion)
11
+ .filter((entry) => tagName === undefined || entry.name === tagName)
12
+ .map((entry) => ({
13
+ component: entry.name,
14
+ severity: 'error',
15
+ check: 'override-base-version-drift',
16
+ message: `Override targets Firefox ${entry.baseVersion}, but fireforge.json records ${entry.currentVersion}. ` +
17
+ 'Refresh the override if upstream changed, or update baseVersion in furnace.json to acknowledge the new baseline.',
18
+ }));
19
+ }
6
20
  // ---------------------------------------------------------------------------
7
21
  // Aggregate validators
8
22
  // ---------------------------------------------------------------------------
9
23
  /**
10
24
  * Runs all validation checks on a single component.
25
+ *
11
26
  * @param componentDir - Path to the component directory
12
27
  * @param tagName - Component tag name
13
28
  * @param type - Component type (stock, override, custom)
29
+ * @param config - Optional furnace config for cross-component checks
30
+ * @param root - Optional project root for checks that read outside componentDir
31
+ * @param options - Optional behavior flags. `skipAggregateChecks` suppresses the
32
+ * per-component registration/jar.mn scan so that an outer caller
33
+ * (e.g. validateAllComponents) can run the aggregate versions once
34
+ * without double-reporting the same issues.
14
35
  * @returns Combined list of validation issues
15
36
  */
16
- export async function validateComponent(componentDir, tagName, type, config, root) {
37
+ export async function validateComponent(componentDir, tagName, type, config, root, options) {
17
38
  const issues = [];
18
- issues.push(...(await validateStructure(componentDir, tagName, type)));
39
+ // Pass the matching custom config so structure validation can enforce
40
+ // the .ftl-when-localized invariant. Non-custom validations ignore the
41
+ // parameter, so this is a no-op for stock and override components.
42
+ issues.push(...(await validateStructure(componentDir, tagName, type, type === 'custom' ? config?.custom[tagName] : undefined)));
19
43
  issues.push(...(await validateAccessibility(componentDir, tagName)));
20
44
  issues.push(...(await validateCompatibility(componentDir, tagName, type, config, root)));
45
+ if (root && config && type === 'override') {
46
+ const forgeConfig = await loadConfig(root);
47
+ issues.push(...buildOverrideVersionDriftIssues(config, forgeConfig.firefox.version, tagName));
48
+ }
21
49
  // Check for missing token link in browser.xhtml
22
50
  if (root) {
23
51
  issues.push(...(await validateTokenLink(componentDir, tagName, root, config?.tokenPrefix)));
24
52
  }
25
53
  // When root is provided and this is a custom component with registration,
26
54
  // also run registration pattern and jar.mn validation for this component.
27
- if (root && config && type === 'custom') {
55
+ // Skipped when an outer orchestrator (validateAllComponents) will run the
56
+ // aggregate versions itself; otherwise the same issues are reported twice.
57
+ if (root && config && type === 'custom' && !options?.skipAggregateChecks) {
28
58
  const customConfig = config.custom[tagName];
29
59
  if (customConfig?.register) {
30
60
  const singleConfig = {
@@ -47,6 +77,47 @@ export async function validateAllComponents(root) {
47
77
  const config = await loadFurnaceConfig(root);
48
78
  const furnacePaths = getFurnacePaths(root);
49
79
  const results = new Map();
80
+ // Validate composition graph integrity (dangling references and cycles)
81
+ try {
82
+ validateComposesReferences(config.stock, config.overrides, config.custom);
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ // Attribute the issue to the first custom component with a bad composes reference
87
+ for (const [name, cfg] of Object.entries(config.custom)) {
88
+ if (cfg.composes) {
89
+ const existing = results.get(name) ?? [];
90
+ existing.push({
91
+ component: name,
92
+ severity: 'error',
93
+ check: 'composes-dangling-reference',
94
+ message,
95
+ });
96
+ results.set(name, existing);
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ try {
102
+ detectComposesCycles(config.custom);
103
+ }
104
+ catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ // Attribute the cycle issue to the first custom component in the cycle
107
+ for (const name of Object.keys(config.custom)) {
108
+ if (config.custom[name]?.composes) {
109
+ const existing = results.get(name) ?? [];
110
+ existing.push({
111
+ component: name,
112
+ severity: 'error',
113
+ check: 'composes-cycle',
114
+ message,
115
+ });
116
+ results.set(name, existing);
117
+ break;
118
+ }
119
+ }
120
+ }
50
121
  // Override components
51
122
  for (const name of Object.keys(config.overrides)) {
52
123
  const componentDir = join(furnacePaths.overridesDir, name);
@@ -79,9 +150,12 @@ export async function validateAllComponents(root) {
79
150
  continue;
80
151
  }
81
152
  // Pass root so that per-component token link validation runs.
82
- // Per-component registration/jar.mn checks are also included, but that's
83
- // acceptable as the aggregate validators below deduplicate by component name.
84
- const issues = await validateComponent(componentDir, name, 'custom', config, root);
153
+ // Skip registration/jar.mn checks inside validateComponent the aggregate
154
+ // validators below run them exactly once across all components, which both
155
+ // surfaces cross-component issues and avoids double-counting.
156
+ const issues = await validateComponent(componentDir, name, 'custom', config, root, {
157
+ skipAggregateChecks: true,
158
+ });
85
159
  results.set(name, issues);
86
160
  }
87
161
  // Registration pattern validation (customElements.js Pattern A vs B)
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Detects drift between an override's stored `baseVersion` and the current
3
+ * Firefox version recorded in `fireforge.json`.
4
+ *
5
+ * Overrides are forks of Firefox source files taken at a specific point in
6
+ * time. If Firefox moves forward and the override's `baseVersion` is not
7
+ * refreshed, the override may apply against a file whose upstream shape has
8
+ * changed — which is the single biggest silent failure mode for furnace.
9
+ *
10
+ * This module is deliberately pure and string-only: it does no I/O and does
11
+ * not parse Firefox version components. Comparing by string equality is
12
+ * sufficient because `fireforge.json` stores a canonical version string
13
+ * (e.g. `"146.0esr"`) and overrides are created with exactly that string
14
+ * copied from `forgeConfig.firefox.version`. Any string mismatch is worth
15
+ * surfacing — even "140.0" vs "146.0esr" is a real drift signal.
16
+ *
17
+ * The result is advisory: apply/deploy emit warnings but do not fail, and
18
+ * status reports drift alongside the component overview. Nothing here
19
+ * should be wired into a blocking code path without an operator prompt.
20
+ */
21
+ import type { FurnaceConfig } from '../types/furnace.js';
22
+ /** Severity of the version drift between an override's base and the current Firefox version. */
23
+ export type DriftSeverity = 'major' | 'minor' | 'patch';
24
+ export interface OverrideVersionDrift {
25
+ name: string;
26
+ /** The version the override was originally created against. */
27
+ baseVersion: string;
28
+ /** The Firefox version currently recorded in `fireforge.json`. */
29
+ currentVersion: string;
30
+ /** How severe the drift is, based on comparing version components. */
31
+ severity: DriftSeverity;
32
+ }
33
+ /**
34
+ * Classifies how severe a version drift is by comparing the numeric
35
+ * components of the two version strings. Falls back to `'major'` when
36
+ * either version is unparseable — this ensures that unusual version
37
+ * formats surface with the highest visibility rather than being silently
38
+ * downgraded.
39
+ */
40
+ export declare function classifyDriftSeverity(baseVersion: string, currentVersion: string): DriftSeverity;
41
+ /**
42
+ * Returns every override whose recorded `baseVersion` does not match the
43
+ * current Firefox version. Returns an empty array when everything is in
44
+ * sync, when there are no overrides, or when `currentVersion` is empty
45
+ * (the caller should surface config problems via a different path).
46
+ */
47
+ export declare function findOverrideBaseVersionDrift(config: FurnaceConfig, currentVersion: string): OverrideVersionDrift[];
48
+ /**
49
+ * Formats a single drift entry into a one-line human-readable warning.
50
+ * Kept alongside the detector so the same wording is reused by every
51
+ * command that surfaces drift.
52
+ */
53
+ export declare function formatOverrideBaseVersionDriftWarning(entry: OverrideVersionDrift): string;
54
+ /** Formats a blocking preflight error for one or more stale overrides. */
55
+ export declare function formatOverrideBaseVersionDriftError(entries: OverrideVersionDrift[]): string;
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Detects drift between an override's stored `baseVersion` and the current
4
+ * Firefox version recorded in `fireforge.json`.
5
+ *
6
+ * Overrides are forks of Firefox source files taken at a specific point in
7
+ * time. If Firefox moves forward and the override's `baseVersion` is not
8
+ * refreshed, the override may apply against a file whose upstream shape has
9
+ * changed — which is the single biggest silent failure mode for furnace.
10
+ *
11
+ * This module is deliberately pure and string-only: it does no I/O and does
12
+ * not parse Firefox version components. Comparing by string equality is
13
+ * sufficient because `fireforge.json` stores a canonical version string
14
+ * (e.g. `"146.0esr"`) and overrides are created with exactly that string
15
+ * copied from `forgeConfig.firefox.version`. Any string mismatch is worth
16
+ * surfacing — even "140.0" vs "146.0esr" is a real drift signal.
17
+ *
18
+ * The result is advisory: apply/deploy emit warnings but do not fail, and
19
+ * status reports drift alongside the component overview. Nothing here
20
+ * should be wired into a blocking code path without an operator prompt.
21
+ */
22
+ /**
23
+ * Parses a version string into its major, minor, and patch numeric
24
+ * components. Non-numeric suffixes (e.g. "esr") are stripped. Returns
25
+ * `[NaN, NaN, NaN]` for unparseable strings.
26
+ */
27
+ function parseVersionComponents(version) {
28
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
29
+ if (!match)
30
+ return [NaN, NaN, NaN];
31
+ return [
32
+ Number(match[1]),
33
+ match[2] !== undefined ? Number(match[2]) : 0,
34
+ match[3] !== undefined ? Number(match[3]) : 0,
35
+ ];
36
+ }
37
+ /**
38
+ * Classifies how severe a version drift is by comparing the numeric
39
+ * components of the two version strings. Falls back to `'major'` when
40
+ * either version is unparseable — this ensures that unusual version
41
+ * formats surface with the highest visibility rather than being silently
42
+ * downgraded.
43
+ */
44
+ export function classifyDriftSeverity(baseVersion, currentVersion) {
45
+ const [baseMajor, baseMinor] = parseVersionComponents(baseVersion);
46
+ const [curMajor, curMinor] = parseVersionComponents(currentVersion);
47
+ if (isNaN(baseMajor) || isNaN(curMajor))
48
+ return 'major';
49
+ if (baseMajor !== curMajor)
50
+ return 'major';
51
+ if (baseMinor !== curMinor)
52
+ return 'minor';
53
+ return 'patch';
54
+ }
55
+ /**
56
+ * Returns every override whose recorded `baseVersion` does not match the
57
+ * current Firefox version. Returns an empty array when everything is in
58
+ * sync, when there are no overrides, or when `currentVersion` is empty
59
+ * (the caller should surface config problems via a different path).
60
+ */
61
+ export function findOverrideBaseVersionDrift(config, currentVersion) {
62
+ if (!currentVersion)
63
+ return [];
64
+ const drift = [];
65
+ for (const [name, override] of Object.entries(config.overrides)) {
66
+ if (override.baseVersion && override.baseVersion !== currentVersion) {
67
+ drift.push({
68
+ name,
69
+ baseVersion: override.baseVersion,
70
+ currentVersion,
71
+ severity: classifyDriftSeverity(override.baseVersion, currentVersion),
72
+ });
73
+ }
74
+ }
75
+ return drift;
76
+ }
77
+ /**
78
+ * Formats a single drift entry into a one-line human-readable warning.
79
+ * Kept alongside the detector so the same wording is reused by every
80
+ * command that surfaces drift.
81
+ */
82
+ export function formatOverrideBaseVersionDriftWarning(entry) {
83
+ const severityLabel = entry.severity === 'major'
84
+ ? ' (major version jump)'
85
+ : entry.severity === 'minor'
86
+ ? ' (minor version change)'
87
+ : ' (patch-level change)';
88
+ return `Override "${entry.name}" was created against Firefox ${entry.baseVersion}, but fireforge.json records ${entry.currentVersion}${severityLabel}. The upstream component may have changed — re-run "fireforge furnace validate ${entry.name}" and refresh the override if needed.`;
89
+ }
90
+ /** Formats a blocking preflight error for one or more stale overrides. */
91
+ export function formatOverrideBaseVersionDriftError(entries) {
92
+ const names = entries.map((entry) => entry.name).sort();
93
+ const summary = entries.length === 1
94
+ ? `Override "${names[0]}" is stale against the Firefox version recorded in fireforge.json.`
95
+ : `${entries.length} overrides are stale against the Firefox version recorded in fireforge.json (${names.join(', ')}).`;
96
+ return (`${summary}\n\n` +
97
+ 'Run "fireforge furnace refresh <name>" to merge upstream changes, ' +
98
+ 'update baseVersion in furnace.json to acknowledge the new baseline, ' +
99
+ 'or pass --force to proceed despite the drift.');
100
+ }
101
+ //# sourceMappingURL=furnace-version-drift.js.map
@@ -48,6 +48,14 @@ export declare function unstageFiles(repoDir: string, files: string[]): Promise<
48
48
  * @returns true if file exists in HEAD
49
49
  */
50
50
  export declare function fileExistsInHead(repoDir: string, filePath: string): Promise<boolean>;
51
+ /**
52
+ * Gets the content of a file at a specific git ref (HEAD by default).
53
+ * @param repoDir - Repository directory
54
+ * @param filePath - Path to the file (relative to repo)
55
+ * @param ref - Git ref to read from (commit hash, branch, tag). Defaults to HEAD.
56
+ * @returns File content or null if file doesn't exist at that ref
57
+ */
58
+ export declare function getFileContentAtRef(repoDir: string, filePath: string, ref?: string): Promise<string | null>;
51
59
  /**
52
60
  * Gets the content of a file from HEAD commit.
53
61
  * @param repoDir - Repository directory
@@ -96,23 +96,36 @@ export async function fileExistsInHead(repoDir, filePath) {
96
96
  return (await git(['ls-tree', 'HEAD', '--', filePath], repoDir)).trim().length > 0;
97
97
  }
98
98
  /**
99
- * Gets the content of a file from HEAD commit.
99
+ * Gets the content of a file at a specific git ref (HEAD by default).
100
100
  * @param repoDir - Repository directory
101
101
  * @param filePath - Path to the file (relative to repo)
102
- * @returns File content or null if file doesn't exist in HEAD
102
+ * @param ref - Git ref to read from (commit hash, branch, tag). Defaults to HEAD.
103
+ * @returns File content or null if file doesn't exist at that ref
103
104
  */
104
- export async function getFileContentFromHead(repoDir, filePath) {
105
+ export async function getFileContentAtRef(repoDir, filePath, ref = 'HEAD') {
105
106
  await ensureGit();
106
- const result = await exec('git', ['show', `HEAD:${filePath}`], { cwd: repoDir });
107
+ const result = await exec('git', ['show', `${ref}:${filePath}`], { cwd: repoDir });
107
108
  if (result.exitCode !== 0) {
108
109
  const stderr = result.stderr.trim();
109
- if (/exists on disk, but not in 'HEAD'|path '.*' exists, but not '.*'|path '.*' does not exist in 'HEAD'/i.test(stderr)) {
110
+ // Recognise the "file does not exist at this ref" variants across git versions.
111
+ // The ref name in quotes varies with what was passed (HEAD, a SHA, a tag), so
112
+ // match loosely rather than interpolating ref into a regex.
113
+ if (/exists on disk, but not in '[^']*'|path '[^']*' exists, but not '[^']*'|path '[^']*' does not exist in '[^']*'/i.test(stderr)) {
110
114
  return null;
111
115
  }
112
- throw new GitError(stderr || 'Git command failed', `show HEAD:${filePath}`);
116
+ throw new GitError(stderr || 'Git command failed', `show ${ref}:${filePath}`);
113
117
  }
114
118
  return result.stdout;
115
119
  }
120
+ /**
121
+ * Gets the content of a file from HEAD commit.
122
+ * @param repoDir - Repository directory
123
+ * @param filePath - Path to the file (relative to repo)
124
+ * @returns File content or null if file doesn't exist in HEAD
125
+ */
126
+ export async function getFileContentFromHead(repoDir, filePath) {
127
+ return getFileContentAtRef(repoDir, filePath, 'HEAD');
128
+ }
116
129
  /**
117
130
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
118
131
  * Uses the same heuristic as git.
@@ -0,0 +1,25 @@
1
+ import type { PatchLintIssue } from '../types/commands/index.js';
2
+ /**
3
+ * Diffs projected lint issues against the baseline and returns those
4
+ * considered "new" regressions.
5
+ *
6
+ * The equality key is the issue fingerprint when present, otherwise the
7
+ * full `(check, file, message)` triple. Fingerprints are emitted by rules
8
+ * whose message text can drift between otherwise-equivalent runs (for
9
+ * example because the message embeds later-owner filenames or positional
10
+ * detail). Falling back to the full tuple keeps the helper conservative
11
+ * for older/non-fingerprinted rules: if their message changes, we would
12
+ * rather surface a potential regression than silently swallow it.
13
+ *
14
+ * Consumption order within the projected list is stable (the input
15
+ * order is preserved), so when baseline has N issues for a key and
16
+ * projected has N+M for the same key, the *last* M projected issues on
17
+ * that key are reported as regressions. That keeps the reporting
18
+ * deterministic and gives the operator at least one concrete message
19
+ * per regression even when only counts differ.
20
+ *
21
+ * @param baseline - Error-severity issues from the current queue
22
+ * @param projected - Error-severity issues from the projected queue
23
+ * @returns Subset of `projected` not matched by a baseline counterpart
24
+ */
25
+ export declare function computeProjectedLintRegressions(baseline: PatchLintIssue[], projected: PatchLintIssue[]): PatchLintIssue[];