@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Maintains `compilerOptions.paths` entries in a consumer-owned jsconfig
4
+ * so typed cross-module imports of multi-file Furnace components work
5
+ * (field report D3).
6
+ *
7
+ * When a main widget imports a sibling helper via its deployed chrome URL
8
+ * (`chrome://global/content/elements/<helper>.mjs`), a wildcard module
9
+ * shim swallows the import: value imports degrade to `any` and
10
+ * `import(...).SomeType` typedefs fail with TS2694. The fix is a `paths`
11
+ * mapping from the chrome URL to the real workspace source. Furnace
12
+ * already owns the jar.mn side of that mapping, so it can maintain the
13
+ * jsconfig side automatically on every deploy.
14
+ *
15
+ * Ownership contract: only entries whose key starts with
16
+ * `chrome://global/content/elements/` AND whose mapped path resolves into
17
+ * the Furnace custom-components workspace are managed (added, updated,
18
+ * pruned). Everything else in the jsconfig — including hand-written
19
+ * `paths` entries pointing elsewhere — is preserved verbatim. No
20
+ * `baseUrl` is required or written: relative `paths` resolve against the
21
+ * config file's directory.
22
+ */
23
+ import { readdir } from 'node:fs/promises';
24
+ import { dirname, join, relative, resolve } from 'node:path';
25
+ import { FurnaceError } from '../errors/furnace.js';
26
+ import { pathExists, readJson, writeJson } from '../utils/fs.js';
27
+ import { info } from '../utils/logger.js';
28
+ import { normalizePathSlashes } from '../utils/paths.js';
29
+ import { getFurnacePaths } from './furnace-config.js';
30
+ /** Chrome URL prefix under which registered custom-element files deploy. */
31
+ const CHROME_ELEMENTS_URL_PREFIX = 'chrome://global/content/elements/';
32
+ /**
33
+ * Computes the desired managed `paths` entries: one per `.mjs` file of
34
+ * every registered custom component, keyed by its deployed chrome URL and
35
+ * mapped to the workspace source relative to the jsconfig directory.
36
+ *
37
+ * Workspace sources (not deployed engine copies) are the mapping target —
38
+ * they are the files developers edit, and they exist even when the engine
39
+ * has not been deployed yet.
40
+ */
41
+ async function computeDesiredChromePathEntries(config, customDir, jsconfigAbsPath) {
42
+ const jsconfigDir = dirname(jsconfigAbsPath);
43
+ const entries = {};
44
+ for (const [name, customConfig] of Object.entries(config.custom)) {
45
+ if (!customConfig.register)
46
+ continue;
47
+ const componentDir = join(customDir, name);
48
+ if (!(await pathExists(componentDir)))
49
+ continue;
50
+ const files = await readdir(componentDir);
51
+ for (const file of files.sort()) {
52
+ if (!file.endsWith('.mjs'))
53
+ continue;
54
+ // Emit a `./`-prefixed relative value. TypeScript treats a bare
55
+ // `paths` value (`moz-widget/moz-widget.mjs`) as non-relative and
56
+ // rejects it without `baseUrl` (TS5090); a `./`-prefixed value
57
+ // resolves against the jsconfig directory with no `baseUrl` (which
58
+ // TS6 deprecates, TS5101). `../`-prefixed paths are already relative
59
+ // and left untouched.
60
+ const rel = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
61
+ const sourcePath = rel.startsWith('.') ? rel : `./${rel}`;
62
+ entries[`${CHROME_ELEMENTS_URL_PREFIX}${file}`] = [sourcePath];
63
+ }
64
+ }
65
+ return entries;
66
+ }
67
+ /**
68
+ * Compares two `paths` values treating a leading `./` as insignificant, so
69
+ * the reconciler does not churn between `./x` and bare `x` forms (either
70
+ * direction). Used to decide whether a managed entry is stale.
71
+ */
72
+ function samePathValue(a, b) {
73
+ const strip = (p) => (p.startsWith('./') ? p.slice(2) : p);
74
+ return strip(a) === strip(b);
75
+ }
76
+ /** True when `key`/`value` is a Furnace-managed chrome-elements mapping. */
77
+ function isManagedEntry(key, value, jsconfigDir, customDir) {
78
+ if (!key.startsWith(CHROME_ELEMENTS_URL_PREFIX))
79
+ return false;
80
+ if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string')
81
+ return false;
82
+ const target = resolve(jsconfigDir, value[0]);
83
+ return target.startsWith(resolve(customDir) + '/') || target === resolve(customDir);
84
+ }
85
+ /**
86
+ * Reconciles the managed `compilerOptions.paths` entries of the configured
87
+ * jsconfig against the current Furnace workspace. Idempotent; writes only
88
+ * when something actually changes; dry-run returns the diff without
89
+ * writing.
90
+ *
91
+ * The consumer owns the jsconfig file: a missing file is an error with
92
+ * guidance rather than a silent scaffold, and JSONC (comments/trailing
93
+ * commas) is unsupported for the managed file — `readJson` is a strict
94
+ * JSON parser, so the error message says so explicitly.
95
+ *
96
+ * @param root - Project root directory
97
+ * @param config - Loaded Furnace configuration (must carry `typecheckJsconfig`)
98
+ * @param options - `dryRun` skips the write but still reports the diff
99
+ */
100
+ export async function syncFurnaceJsconfigPaths(root, config, options) {
101
+ const result = { added: [], updated: [], pruned: [], changed: false };
102
+ const jsconfigRel = config.typecheckJsconfig;
103
+ if (!jsconfigRel)
104
+ return result;
105
+ const jsconfigAbs = resolve(root, jsconfigRel);
106
+ if (!(await pathExists(jsconfigAbs))) {
107
+ throw new FurnaceError(`furnace.json sets "typecheckJsconfig": "${jsconfigRel}", but the file does not exist. ` +
108
+ 'Create the jsconfig (it stays consumer-owned; Furnace only maintains the ' +
109
+ `"compilerOptions.paths" entries under ${CHROME_ELEMENTS_URL_PREFIX}*), ` +
110
+ 'or remove the setting.');
111
+ }
112
+ let jsconfig;
113
+ try {
114
+ jsconfig = await readJson(jsconfigAbs);
115
+ }
116
+ catch (error) {
117
+ throw new FurnaceError(`Could not parse ${jsconfigRel} as JSON: ${error instanceof Error ? error.message : String(error)}. ` +
118
+ 'Furnace manages paths entries only in plain-JSON jsconfig files — JSONC comments ' +
119
+ 'and trailing commas are not supported for the managed file.');
120
+ }
121
+ const furnacePaths = getFurnacePaths(root);
122
+ const desired = await computeDesiredChromePathEntries(config, furnacePaths.customDir, jsconfigAbs);
123
+ const jsconfigDir = dirname(jsconfigAbs);
124
+ const currentPaths = { ...(jsconfig.compilerOptions?.paths ?? {}) };
125
+ const nextPaths = {};
126
+ // Preserve every unmanaged entry verbatim; prune managed entries that are
127
+ // no longer desired; update managed entries whose target moved.
128
+ for (const [key, value] of Object.entries(currentPaths)) {
129
+ if (!isManagedEntry(key, value, jsconfigDir, furnacePaths.customDir)) {
130
+ nextPaths[key] = value;
131
+ continue;
132
+ }
133
+ const want = desired[key];
134
+ if (want === undefined) {
135
+ result.pruned.push(key);
136
+ continue;
137
+ }
138
+ // Treat `./x` and bare `x` as equal so a previously-synced bare value (or
139
+ // a hand-written `./` prefix) is not rewritten as "stale" on every run.
140
+ // The existing value is kept verbatim when equivalent — no churn either
141
+ // way; only a genuinely different target updates (to the `./` form).
142
+ if (!samePathValue(value[0] ?? '', want[0] ?? '')) {
143
+ result.updated.push(key);
144
+ nextPaths[key] = want;
145
+ }
146
+ else {
147
+ nextPaths[key] = value;
148
+ }
149
+ }
150
+ for (const [key, want] of Object.entries(desired)) {
151
+ if (!(key in nextPaths)) {
152
+ result.added.push(key);
153
+ nextPaths[key] = want;
154
+ }
155
+ }
156
+ result.changed = result.added.length > 0 || result.updated.length > 0 || result.pruned.length > 0;
157
+ if (!result.changed || options?.dryRun === true)
158
+ return result;
159
+ const nextJsconfig = {
160
+ ...jsconfig,
161
+ compilerOptions: {
162
+ ...(jsconfig.compilerOptions ?? {}),
163
+ paths: nextPaths,
164
+ },
165
+ };
166
+ await writeJson(jsconfigAbs, nextJsconfig);
167
+ return result;
168
+ }
169
+ /**
170
+ * Computes jsconfig `paths` drift for `furnace validate`: managed entries
171
+ * that are missing or stale relative to the current workspace. Read-only —
172
+ * delegates to {@link syncFurnaceJsconfigPaths} in dry-run mode.
173
+ */
174
+ export async function findJsconfigPathsDrift(root, config) {
175
+ return syncFurnaceJsconfigPaths(root, config, { dryRun: true });
176
+ }
177
+ /**
178
+ * Runs the jsconfig paths sync after a successful deploy/sync and reports
179
+ * the diff. No-op when `typecheckJsconfig` is unset. Shared by
180
+ * `furnace deploy` and `furnace sync` so both report identically.
181
+ */
182
+ export async function reportJsconfigPathsSync(root, config, dryRun) {
183
+ if (!config.typecheckJsconfig)
184
+ return;
185
+ const sync = await syncFurnaceJsconfigPaths(root, config, { dryRun });
186
+ if (!sync.changed)
187
+ return;
188
+ info(`${dryRun ? '[dry-run] Would update' : 'Updated'} ${config.typecheckJsconfig} chrome-module paths: ` +
189
+ `+${sync.added.length} added, ~${sync.updated.length} updated, -${sync.pruned.length} pruned`);
190
+ }
191
+ //# sourceMappingURL=furnace-jsconfig.js.map
@@ -61,20 +61,6 @@ export declare function stripCssBlockComments(content: string): string;
61
61
  export declare function hasRelativeModuleImport(mjsContent: string): boolean;
62
62
  /** Detects whether a module defines a custom element at runtime. */
63
63
  export declare function hasCustomElementDefineCall(mjsContent: string): boolean;
64
- /**
65
- * Detects whether the module's `customElements.define(...)` call includes a
66
- * literal `extends:` option (third argument). That shape is the marker for
67
- * a customized built-in element — the class extends a specific
68
- * `HTMLxxxElement` rather than the autonomous `MozLitElement` path.
69
- *
70
- * Firefox's own widgets use this pattern for toolkit anchors (e.g.
71
- * `moz-support-link` extends `HTMLAnchorElement` with
72
- * `customElements.define("moz-support-link", ..., { extends: "a" })`), and
73
- * the validator's `not-moz-lit-element` check must allow them through or
74
- * `furnace override` of a valid upstream component fails its own
75
- * `furnace validate` pass with nothing the operator can fix.
76
- */
77
- export declare function hasCustomElementExtendsOption(mjsContent: string): boolean;
78
64
  /**
79
65
  * Checks whether a declared component class extends a valid element base.
80
66
  *
@@ -114,3 +100,19 @@ export declare function getTokenPrefixContext(tagName: string, type: ComponentTy
114
100
  inheritedOverrideVars: Set<string>;
115
101
  runtimeVariables: Set<string>;
116
102
  }>;
103
+ /**
104
+ * Flags engine-side files that a previous deploy of `tagName` left behind
105
+ * after their workspace source was renamed or removed (field report D1).
106
+ *
107
+ * Detection keys on the furnace state file: every `appliedChecksums` entry
108
+ * under `custom/<tagName>/` whose workspace source no longer exists but
109
+ * whose engine target is still present is an orphan — the next deploy will
110
+ * prune it, but until then jar.mn and the deployed directory disagree with
111
+ * the workspace, and a re-export could capture the stale state into a patch.
112
+ *
113
+ * Custom components only: override undeploys restore the upstream baseline
114
+ * rather than deleting files, so "orphan" has no meaning there.
115
+ */
116
+ export declare function findOrphanedEngineFiles(root: string, config: FurnaceConfig, tagName: string, state: {
117
+ appliedChecksums?: Record<string, string>;
118
+ }, ftlDir: string): Promise<ValidationIssue[]>;
@@ -2,6 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { getProjectPaths } from './config.js';
5
+ import { extractComponentChecksums } from './furnace-checksum-utils.js';
5
6
  /** Creates a normalized validation issue object. */
6
7
  export function createIssue(component, severity, check, message) {
7
8
  return { component, severity, check, message };
@@ -282,7 +283,7 @@ export function hasCustomElementDefineCall(mjsContent) {
282
283
  * `furnace override` of a valid upstream component fails its own
283
284
  * `furnace validate` pass with nothing the operator can fix.
284
285
  */
285
- export function hasCustomElementExtendsOption(mjsContent) {
286
+ function hasCustomElementExtendsOption(mjsContent) {
286
287
  // Match `customElements.define(..., { ..., extends: "..." })`. Tolerant of
287
288
  // whitespace, line breaks, and other object properties. The `[^)]*` stops
288
289
  // the inner greedy match at the closing define call paren so a later
@@ -395,4 +396,42 @@ export async function getTokenPrefixContext(tagName, type, config, root) {
395
396
  runtimeVariables,
396
397
  };
397
398
  }
399
+ /**
400
+ * Flags engine-side files that a previous deploy of `tagName` left behind
401
+ * after their workspace source was renamed or removed (field report D1).
402
+ *
403
+ * Detection keys on the furnace state file: every `appliedChecksums` entry
404
+ * under `custom/<tagName>/` whose workspace source no longer exists but
405
+ * whose engine target is still present is an orphan — the next deploy will
406
+ * prune it, but until then jar.mn and the deployed directory disagree with
407
+ * the workspace, and a re-export could capture the stale state into a patch.
408
+ *
409
+ * Custom components only: override undeploys restore the upstream baseline
410
+ * rather than deleting files, so "orphan" has no meaning there.
411
+ */
412
+ export async function findOrphanedEngineFiles(root, config, tagName, state, ftlDir) {
413
+ const customConfig = config.custom[tagName];
414
+ if (!customConfig)
415
+ return [];
416
+ const previous = extractComponentChecksums(state.appliedChecksums, 'custom', tagName);
417
+ const fileNames = Object.keys(previous);
418
+ if (fileNames.length === 0)
419
+ return [];
420
+ const { engine: engineDir, componentsDir } = getProjectPaths(root);
421
+ const componentDir = join(componentsDir, 'custom', tagName);
422
+ const issues = [];
423
+ for (const fileName of fileNames) {
424
+ if (await pathExists(join(componentDir, fileName)))
425
+ continue;
426
+ const enginePath = fileName.endsWith('.ftl')
427
+ ? join(engineDir, ftlDir, fileName)
428
+ : join(engineDir, customConfig.targetPath, fileName);
429
+ if (!(await pathExists(enginePath)))
430
+ continue;
431
+ issues.push(createIssue(tagName, 'warning', 'orphaned-engine-file', `Engine file ${fileName} was deployed by a previous apply but its workspace source ` +
432
+ `is gone (renamed or deleted). The deployed copy${customConfig.register ? ' and any stale jar.mn entry' : ''} ` +
433
+ `will linger until the next deploy prunes it. Run "fireforge furnace deploy ${tagName}".`));
434
+ }
435
+ return issues;
436
+ }
398
437
  //# sourceMappingURL=furnace-validate-helpers.js.map
@@ -9,6 +9,7 @@ import { stripJsComments } from '../utils/regex.js';
9
9
  import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
11
  import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
+ import { expandCssFragments, listFragmentIncludes } from './furnace-css-fragments.js';
12
13
  import { isTagInCorrectCustomElementsPlacement } from './furnace-registration-validate.js';
13
14
  import { getTokensCssPath } from './token-manager.js';
14
15
  /**
@@ -96,7 +97,21 @@ export async function checkRegistrationConsistency(root, name, config, ftlDir) {
96
97
  status.filesInSync = false;
97
98
  continue;
98
99
  }
99
- const srcContent = await readText(srcPath);
100
+ // Deploy writes CSS-with-include-directives in fragment-expanded form,
101
+ // so the drift oracle must compare the *expanded* source — otherwise a
102
+ // freshly deployed component would read as permanently drifted, and a
103
+ // fragment edit would never read as drifted at all.
104
+ let srcContent = await readText(srcPath);
105
+ if (entry.name.endsWith('.css') && listFragmentIncludes(srcContent).length > 0) {
106
+ try {
107
+ srcContent = (await expandCssFragments(srcContent, furnacePaths.sharedDir)).expanded;
108
+ }
109
+ catch {
110
+ // Missing fragment: validate reports it as `missing-fragment`;
111
+ // for drift purposes fall back to the raw source so the compare
112
+ // still happens deterministically.
113
+ }
114
+ }
100
115
  const destContent = await readText(destPath);
101
116
  const srcHash = createHash('sha256').update(srcContent).digest('hex');
102
117
  const destHash = createHash('sha256').update(destContent).digest('hex');
@@ -3,10 +3,13 @@ import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { getProjectPaths, loadConfig } from './config.js';
6
- import { getFurnacePaths, loadFurnaceConfig } from './furnace-config.js';
7
- import { xpcshellTestParentDir } from './furnace-constants.js';
6
+ import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState } from './furnace-config.js';
7
+ import { resolveFtlDir, xpcshellTestParentDir } from './furnace-constants.js';
8
+ import { validateCssFragments } from './furnace-css-fragments.js';
8
9
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
10
+ import { findJsconfigPathsDrift } from './furnace-jsconfig.js';
9
11
  import { validateAccessibility, validateCompatibility, validateJarMnEntries, validateRegistrationPatterns, validateStructure, validateTokenLink, } from './furnace-validate-checks.js';
12
+ import { findOrphanedEngineFiles } from './furnace-validate-helpers.js';
10
13
  import { findOverrideBaseVersionDrift, } from './furnace-version-drift.js';
11
14
  function buildOverrideVersionDriftIssues(config, currentVersion, tagName) {
12
15
  return findOverrideBaseVersionDrift(config, currentVersion)
@@ -55,6 +58,25 @@ export async function validateComponent(componentDir, tagName, type, config, roo
55
58
  if (root) {
56
59
  issues.push(...(await validateTokenLink(componentDir, tagName, root, config?.tokenPrefix, config?.tokenHostDocuments)));
57
60
  }
61
+ // CSS fragment checks (field report D2): missing fragment files are
62
+ // structural errors; stale deployed expansions are drift the next deploy
63
+ // refreshes.
64
+ if (type === 'custom') {
65
+ const furnacePaths = getFurnacePaths(root ?? join(componentDir, '..', '..', '..'));
66
+ const engineTargetDir = root && config?.custom[tagName]
67
+ ? join(getProjectPaths(root).engine, config.custom[tagName].targetPath)
68
+ : undefined;
69
+ issues.push(...(await validateCssFragments(componentDir, tagName, furnacePaths.sharedDir, engineTargetDir)));
70
+ }
71
+ // Engine-side orphan detection (field report D1): files a previous deploy
72
+ // placed in the engine whose workspace source has since been renamed or
73
+ // deleted. Surfaces as drift even when every current workspace file is
74
+ // in sync, which is exactly the gap that let stale jar.mn lines reach a
75
+ // later re-export.
76
+ if (root && config && type === 'custom') {
77
+ const state = await loadFurnaceState(root);
78
+ issues.push(...(await findOrphanedEngineFiles(root, config, tagName, state, resolveFtlDir(config.ftlBasePath))));
79
+ }
58
80
  // When root is provided and this is a custom component with registration,
59
81
  // also run registration pattern and jar.mn validation for this component.
60
82
  // Skipped when an outer orchestrator (validateAllComponents) will run the
@@ -202,6 +224,36 @@ export async function validateAllComponents(root) {
202
224
  // other transient fs issue should never cascade into false
203
225
  // "orphan" reports.
204
226
  }
227
+ // jsconfig chrome-module paths drift (field report D3): when
228
+ // `typecheckJsconfig` is configured, deploy maintains a paths mapping per
229
+ // deployed module file; missing or stale entries mean typed cross-module
230
+ // imports are silently degrading to `any` in the consumer's typecheck.
231
+ if (config.typecheckJsconfig) {
232
+ try {
233
+ const drift = await findJsconfigPathsDrift(root, config);
234
+ if (drift.changed) {
235
+ const detail = [
236
+ ...drift.added.map((key) => `missing: ${key}`),
237
+ ...drift.updated.map((key) => `stale: ${key}`),
238
+ ...drift.pruned.map((key) => `orphaned: ${key}`),
239
+ ].join('; ');
240
+ const issue = {
241
+ component: 'furnace',
242
+ severity: 'warning',
243
+ check: 'jsconfig-paths-drift',
244
+ message: `${config.typecheckJsconfig} chrome-module paths are out of sync with the workspace (${detail}). ` +
245
+ 'Run "fireforge furnace deploy" to update them.',
246
+ };
247
+ const existing = results.get(issue.component) ?? [];
248
+ existing.push(issue);
249
+ results.set(issue.component, existing);
250
+ }
251
+ }
252
+ catch {
253
+ // Drift detection must not break validation when the jsconfig is
254
+ // missing or unparsable — deploy reports those cases with guidance.
255
+ }
256
+ }
205
257
  return results;
206
258
  }
207
259
  /**
@@ -63,6 +63,21 @@ export declare function git(args: string[], cwd: string, options?: {
63
63
  timeout?: number;
64
64
  env?: Record<string, string>;
65
65
  }): Promise<string>;
66
+ /**
67
+ * Splits a pathspec list into chunks whose joined byte length stays well under
68
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
69
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
70
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
71
+ * leaves room for the fixed git arguments plus the inherited environment.
72
+ *
73
+ * Chunk boundaries are output-neutral for every batched caller here: each
74
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
75
+ * how the paths are grouped across invocations never affects the result.
76
+ * @param paths - Pathspecs to chunk
77
+ * @param budgetBytes - Maximum joined byte length per chunk
78
+ * @returns Path chunks, each safe to pass as a single argv tail
79
+ */
80
+ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): string[][];
66
81
  /**
67
82
  * Configures git performance settings for large trees.
68
83
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -72,6 +72,38 @@ export async function git(args, cwd, options) {
72
72
  }
73
73
  return result.stdout;
74
74
  }
75
+ /**
76
+ * Splits a pathspec list into chunks whose joined byte length stays well under
77
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
78
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
79
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
80
+ * leaves room for the fixed git arguments plus the inherited environment.
81
+ *
82
+ * Chunk boundaries are output-neutral for every batched caller here: each
83
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
84
+ * how the paths are grouped across invocations never affects the result.
85
+ * @param paths - Pathspecs to chunk
86
+ * @param budgetBytes - Maximum joined byte length per chunk
87
+ * @returns Path chunks, each safe to pass as a single argv tail
88
+ */
89
+ export function chunkPathspecs(paths, budgetBytes = 96_000) {
90
+ const chunks = [];
91
+ let current = [];
92
+ let used = 0;
93
+ for (const path of paths) {
94
+ const cost = Buffer.byteLength(path) + 1;
95
+ if (current.length > 0 && used + cost > budgetBytes) {
96
+ chunks.push(current);
97
+ current = [];
98
+ used = 0;
99
+ }
100
+ current.push(path);
101
+ used += cost;
102
+ }
103
+ if (current.length > 0)
104
+ chunks.push(current);
105
+ return chunks;
106
+ }
75
107
  /**
76
108
  * Configures git performance settings for large trees.
77
109
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -40,6 +40,14 @@ export declare function getAllDiff(repoDir: string): Promise<string>;
40
40
  * Builds a combined diff against HEAD for the provided files without touching
41
41
  * the real git index. Tracked files use `git diff HEAD`; untracked files use
42
42
  * synthesized new-file diffs.
43
+ *
44
+ * Performance: the work is batched into a handful of `git` invocations
45
+ * (one `ls-tree` to classify, one `diff` over all tracked files, one
46
+ * `hash-object` over all new text files) rather than the ~2 spawns per file the
47
+ * previous per-file loop issued — that fan-out dominated the cold-run cost on a
48
+ * Firefox-sized checkout (~700 serial spawns, ~99s). Binary, directory, and
49
+ * recursion paths stay per-file because they are rare and (for binary) mutate
50
+ * the index.
43
51
  * @param repoDir - Repository directory
44
52
  * @param files - File paths to diff (relative to repo root)
45
53
  * @returns Combined diff content