@hominis/fireforge 0.30.1 → 0.31.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 (141) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +5 -15
  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 +36 -0
  10. package/dist/src/commands/export.js +47 -112
  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 +1 -1
  20. package/dist/src/commands/lint-per-patch.js +110 -81
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  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-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +0 -13
  94. package/dist/src/core/lint-cache.js +5 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
@@ -0,0 +1,171 @@
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
+ const sourcePath = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
55
+ entries[`${CHROME_ELEMENTS_URL_PREFIX}${file}`] = [sourcePath];
56
+ }
57
+ }
58
+ return entries;
59
+ }
60
+ /** True when `key`/`value` is a Furnace-managed chrome-elements mapping. */
61
+ function isManagedEntry(key, value, jsconfigDir, customDir) {
62
+ if (!key.startsWith(CHROME_ELEMENTS_URL_PREFIX))
63
+ return false;
64
+ if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string')
65
+ return false;
66
+ const target = resolve(jsconfigDir, value[0]);
67
+ return target.startsWith(resolve(customDir) + '/') || target === resolve(customDir);
68
+ }
69
+ /**
70
+ * Reconciles the managed `compilerOptions.paths` entries of the configured
71
+ * jsconfig against the current Furnace workspace. Idempotent; writes only
72
+ * when something actually changes; dry-run returns the diff without
73
+ * writing.
74
+ *
75
+ * The consumer owns the jsconfig file: a missing file is an error with
76
+ * guidance rather than a silent scaffold, and JSONC (comments/trailing
77
+ * commas) is unsupported for the managed file — `readJson` is a strict
78
+ * JSON parser, so the error message says so explicitly.
79
+ *
80
+ * @param root - Project root directory
81
+ * @param config - Loaded Furnace configuration (must carry `typecheckJsconfig`)
82
+ * @param options - `dryRun` skips the write but still reports the diff
83
+ */
84
+ export async function syncFurnaceJsconfigPaths(root, config, options) {
85
+ const result = { added: [], updated: [], pruned: [], changed: false };
86
+ const jsconfigRel = config.typecheckJsconfig;
87
+ if (!jsconfigRel)
88
+ return result;
89
+ const jsconfigAbs = resolve(root, jsconfigRel);
90
+ if (!(await pathExists(jsconfigAbs))) {
91
+ throw new FurnaceError(`furnace.json sets "typecheckJsconfig": "${jsconfigRel}", but the file does not exist. ` +
92
+ 'Create the jsconfig (it stays consumer-owned; Furnace only maintains the ' +
93
+ `"compilerOptions.paths" entries under ${CHROME_ELEMENTS_URL_PREFIX}*), ` +
94
+ 'or remove the setting.');
95
+ }
96
+ let jsconfig;
97
+ try {
98
+ jsconfig = await readJson(jsconfigAbs);
99
+ }
100
+ catch (error) {
101
+ throw new FurnaceError(`Could not parse ${jsconfigRel} as JSON: ${error instanceof Error ? error.message : String(error)}. ` +
102
+ 'Furnace manages paths entries only in plain-JSON jsconfig files — JSONC comments ' +
103
+ 'and trailing commas are not supported for the managed file.');
104
+ }
105
+ const furnacePaths = getFurnacePaths(root);
106
+ const desired = await computeDesiredChromePathEntries(config, furnacePaths.customDir, jsconfigAbs);
107
+ const jsconfigDir = dirname(jsconfigAbs);
108
+ const currentPaths = { ...(jsconfig.compilerOptions?.paths ?? {}) };
109
+ const nextPaths = {};
110
+ // Preserve every unmanaged entry verbatim; prune managed entries that are
111
+ // no longer desired; update managed entries whose target moved.
112
+ for (const [key, value] of Object.entries(currentPaths)) {
113
+ if (!isManagedEntry(key, value, jsconfigDir, furnacePaths.customDir)) {
114
+ nextPaths[key] = value;
115
+ continue;
116
+ }
117
+ const want = desired[key];
118
+ if (want === undefined) {
119
+ result.pruned.push(key);
120
+ continue;
121
+ }
122
+ if (value[0] !== want[0]) {
123
+ result.updated.push(key);
124
+ nextPaths[key] = want;
125
+ }
126
+ else {
127
+ nextPaths[key] = value;
128
+ }
129
+ }
130
+ for (const [key, want] of Object.entries(desired)) {
131
+ if (!(key in nextPaths)) {
132
+ result.added.push(key);
133
+ nextPaths[key] = want;
134
+ }
135
+ }
136
+ result.changed = result.added.length > 0 || result.updated.length > 0 || result.pruned.length > 0;
137
+ if (!result.changed || options?.dryRun === true)
138
+ return result;
139
+ const nextJsconfig = {
140
+ ...jsconfig,
141
+ compilerOptions: {
142
+ ...(jsconfig.compilerOptions ?? {}),
143
+ paths: nextPaths,
144
+ },
145
+ };
146
+ await writeJson(jsconfigAbs, nextJsconfig);
147
+ return result;
148
+ }
149
+ /**
150
+ * Computes jsconfig `paths` drift for `furnace validate`: managed entries
151
+ * that are missing or stale relative to the current workspace. Read-only —
152
+ * delegates to {@link syncFurnaceJsconfigPaths} in dry-run mode.
153
+ */
154
+ export async function findJsconfigPathsDrift(root, config) {
155
+ return syncFurnaceJsconfigPaths(root, config, { dryRun: true });
156
+ }
157
+ /**
158
+ * Runs the jsconfig paths sync after a successful deploy/sync and reports
159
+ * the diff. No-op when `typecheckJsconfig` is unset. Shared by
160
+ * `furnace deploy` and `furnace sync` so both report identically.
161
+ */
162
+ export async function reportJsconfigPathsSync(root, config, dryRun) {
163
+ if (!config.typecheckJsconfig)
164
+ return;
165
+ const sync = await syncFurnaceJsconfigPaths(root, config, { dryRun });
166
+ if (!sync.changed)
167
+ return;
168
+ info(`${dryRun ? '[dry-run] Would update' : 'Updated'} ${config.typecheckJsconfig} chrome-module paths: ` +
169
+ `+${sync.added.length} added, ~${sync.updated.length} updated, -${sync.pruned.length} pruned`);
170
+ }
171
+ //# 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
  /**
@@ -5,18 +5,6 @@ import type { GitStatusEntry } from './git-base.js';
5
5
  * @param filePath - Path to the file (relative to repo)
6
6
  */
7
7
  export declare function restoreTrackedPath(repoDir: string, filePath: string): Promise<void>;
8
- /**
9
- * Removes an untracked path from disk.
10
- * @param repoDir - Repository directory
11
- * @param filePath - Path to the file (relative to repo)
12
- */
13
- export declare function removeUntrackedPath(repoDir: string, filePath: string): Promise<void>;
14
- /**
15
- * Removes a path that is present only in the index/worktree and not in HEAD.
16
- * @param repoDir - Repository directory
17
- * @param filePath - Path to remove
18
- */
19
- export declare function removeAddedPath(repoDir: string, filePath: string): Promise<void>;
20
8
  /**
21
9
  * Discards a status entry according to its git state.
22
10
  * @param repoDir - Repository directory
@@ -19,7 +19,7 @@ export async function restoreTrackedPath(repoDir, filePath) {
19
19
  * @param repoDir - Repository directory
20
20
  * @param filePath - Path to the file (relative to repo)
21
21
  */
22
- export async function removeUntrackedPath(repoDir, filePath) {
22
+ async function removeUntrackedPath(repoDir, filePath) {
23
23
  const fullPath = join(repoDir, filePath);
24
24
  await removeFile(fullPath);
25
25
  }
@@ -28,7 +28,7 @@ export async function removeUntrackedPath(repoDir, filePath) {
28
28
  * @param repoDir - Repository directory
29
29
  * @param filePath - Path to remove
30
30
  */
31
- export async function removeAddedPath(repoDir, filePath) {
31
+ async function removeAddedPath(repoDir, filePath) {
32
32
  await ensureGit();
33
33
  await git(['reset', 'HEAD', '--', filePath], repoDir);
34
34
  await removeUntrackedPath(repoDir, filePath);
@@ -2,7 +2,6 @@ import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type PatchQueueContext } from './patch-lint.js';
4
4
  export declare const LINT_CACHE_SCHEMA_VERSION = 1;
5
- export declare const LINT_IMPLEMENTATION_VERSION = 1;
6
5
  export interface PerPatchLintCacheEntry {
7
6
  key: string;
8
7
  patchFilename: string;
@@ -24,15 +23,6 @@ export interface PerPatchLintCacheKeyInput {
24
23
  engineHeadSha?: string;
25
24
  packageVersion?: string;
26
25
  }
27
- type JsonValue = string | number | boolean | null | JsonValue[] | {
28
- [key: string]: JsonValue | undefined;
29
- };
30
- /** Computes a SHA-256 hex digest for text or binary content. */
31
- export declare function sha256Hex(content: string | Buffer): string;
32
- /** Computes a stable SHA-256 digest for JSON-compatible data. */
33
- export declare function stableHash(value: JsonValue): string;
34
- /** Returns the repo-local per-patch lint cache file path. */
35
- export declare function getPerPatchLintCachePath(projectRoot: string): string;
36
26
  /** Returns the engine git HEAD identity used to guard diff-derived cache hits. */
37
27
  export declare function getPerPatchLintCacheHeadSha(engineDir: string): Promise<string>;
38
28
  /**
@@ -40,8 +30,6 @@ export declare function getPerPatchLintCacheHeadSha(engineDir: string): Promise<
40
30
  * The key includes source, metadata, config, engine state, and ownership inputs.
41
31
  */
42
32
  export declare function buildPerPatchLintCacheKey(input: PerPatchLintCacheKeyInput): Promise<string>;
43
- /** Creates an empty cache document using the current cache schema. */
44
- export declare function createEmptyPerPatchLintCache(): PerPatchLintCacheFile;
45
33
  /** Loads the per-patch lint cache, treating missing or invalid files as empty. */
46
34
  export declare function loadPerPatchLintCache(projectRoot: string): Promise<PerPatchLintCacheFile>;
47
35
  /** Persists the per-patch lint cache atomically through the shared JSON writer. */
@@ -52,4 +40,3 @@ export declare function clearPerPatchLintCache(projectRoot: string): Promise<voi
52
40
  export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): PatchLintIssue[] | undefined;
53
41
  /** Stores per-patch lint issues after a successful lint calculation. */
54
42
  export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[]): void;
55
- export {};
@@ -8,7 +8,7 @@ import { getFurnacePaths } from './furnace-config.js';
8
8
  import { git } from './git-base.js';
9
9
  import { collectNewFileCreatorsByPath } from './patch-lint.js';
10
10
  export const LINT_CACHE_SCHEMA_VERSION = 1;
11
- export const LINT_IMPLEMENTATION_VERSION = 1;
11
+ const LINT_IMPLEMENTATION_VERSION = 1;
12
12
  const LINT_CACHE_DIRNAME = 'lint-cache';
13
13
  const PER_PATCH_CACHE_FILENAME = 'per-patch-v1.json';
14
14
  function stableJson(value) {
@@ -26,15 +26,15 @@ function stableJson(value) {
26
26
  .join(',')}}`;
27
27
  }
28
28
  /** Computes a SHA-256 hex digest for text or binary content. */
29
- export function sha256Hex(content) {
29
+ function sha256Hex(content) {
30
30
  return createHash('sha256').update(content).digest('hex');
31
31
  }
32
32
  /** Computes a stable SHA-256 digest for JSON-compatible data. */
33
- export function stableHash(value) {
33
+ function stableHash(value) {
34
34
  return sha256Hex(stableJson(value));
35
35
  }
36
36
  /** Returns the repo-local per-patch lint cache file path. */
37
- export function getPerPatchLintCachePath(projectRoot) {
37
+ function getPerPatchLintCachePath(projectRoot) {
38
38
  return join(projectRoot, '.fireforge', LINT_CACHE_DIRNAME, PER_PATCH_CACHE_FILENAME);
39
39
  }
40
40
  /** Returns the engine git HEAD identity used to guard diff-derived cache hits. */
@@ -108,7 +108,7 @@ export async function buildPerPatchLintCacheKey(input) {
108
108
  });
109
109
  }
110
110
  /** Creates an empty cache document using the current cache schema. */
111
- export function createEmptyPerPatchLintCache() {
111
+ function createEmptyPerPatchLintCache() {
112
112
  return { schemaVersion: LINT_CACHE_SCHEMA_VERSION, entries: {} };
113
113
  }
114
114
  function isCacheEntry(value) {
@@ -193,5 +193,9 @@ export declare function watchWithOutput(engineDir: string, options?: {
193
193
  export declare function test(engineDir: string, testPaths?: string[], args?: string[]): Promise<number>;
194
194
  /**
195
195
  * Runs mach test while capturing streamed output for better diagnostics.
196
+ *
197
+ * @param env - Optional extra environment variables for the mach process
198
+ * (merged over `process.env` by the exec layer). Used by
199
+ * `fireforge test --perf-samples` to publish the artifact-path contract.
196
200
  */
197
- export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[]): Promise<MachCommandResult>;
201
+ export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
@@ -312,8 +312,12 @@ export async function test(engineDir, testPaths = [], args = []) {
312
312
  }
313
313
  /**
314
314
  * Runs mach test while capturing streamed output for better diagnostics.
315
+ *
316
+ * @param env - Optional extra environment variables for the mach process
317
+ * (merged over `process.env` by the exec layer). Used by
318
+ * `fireforge test --perf-samples` to publish the artifact-path contract.
315
319
  */
316
- export async function testWithOutput(engineDir, testPaths = [], args = []) {
317
- return runMachCapture(['test', ...testPaths, ...args], engineDir);
320
+ export async function testWithOutput(engineDir, testPaths = [], args = [], env) {
321
+ return runMachCapture(['test', ...testPaths, ...args], engineDir, env ? { env } : {});
318
322
  }
319
323
  //# sourceMappingURL=mach.js.map
@@ -1,22 +1,11 @@
1
1
  /**
2
2
  * Manifest registration barrel — re-exports all registration targets
3
- * and provides the shared RegisterResult interface.
3
+ * and the shared RegisterResult interface (which lives in
4
+ * `register-result.ts` so the leaf modules can import it without
5
+ * creating a cycle through this barrel).
4
6
  */
5
- /**
6
- * Result of a manifest registration operation.
7
- */
8
- export interface RegisterResult {
9
- /** The manifest file that was modified */
10
- manifest: string;
11
- /** The entry that was inserted */
12
- entry: string;
13
- /** The entry after which the new entry was inserted (for user display) */
14
- previousEntry?: string | undefined;
15
- /** Whether the entry already existed (skipped) */
16
- skipped: boolean;
17
- /** Whether --after target was not found and fell back to alphabetical */
18
- afterFallback?: boolean | undefined;
19
- }
7
+ import type { RegisterResult } from './register-result.js';
8
+ export type { RegisterResult } from './register-result.js';
20
9
  export { registerBrowserContent } from './register-browser-content.js';
21
10
  export { registerFireForgeModule } from './register-module.js';
22
11
  export { registerSharedCSS } from './register-shared-css.js';
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Manifest registration barrel — re-exports all registration targets
4
- * and provides the shared RegisterResult interface.
4
+ * and the shared RegisterResult interface (which lives in
5
+ * `register-result.ts` so the leaf modules can import it without
6
+ * creating a cycle through this barrel).
5
7
  */
6
8
  import { join } from 'node:path';
7
9
  import { GeneralError } from '../errors/base.js';
@@ -17,13 +17,51 @@
17
17
  * and optional allowlisted `checkJsCompilerOptions`; it does not change
18
18
  * shim composition or suppressed diagnostic codes.
19
19
  */
20
- import { resolve } from 'node:path';
20
+ import { basename, resolve } from 'node:path';
21
21
  import { pathExists } from '../utils/fs.js';
22
22
  import { verbose } from '../utils/logger.js';
23
23
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES } from './typecheck-shim.js';
24
24
  // ---------------------------------------------------------------------------
25
25
  // Public API
26
26
  // ---------------------------------------------------------------------------
27
+ /**
28
+ * Builds the host-side module resolver for the checkJs pass: maps an import
29
+ * specifier to a patch-owned absolute path when the specifier's final
30
+ * segment uniquely matches an owned file. URL specifiers
31
+ * (chrome://browser/content/Foo.sys.mjs, resource:///modules/Foo.sys.mjs)
32
+ * are matched by basename, with a `.mjs` → `.sys.mjs` fallback for deployed
33
+ * widget URLs. Ambiguous or unknown basenames stay unresolved — loose
34
+ * wildcard typing beats guessing the wrong module — and relative specifiers
35
+ * are left to fail resolution (the relative-import lint rule bans them).
36
+ */
37
+ function createOwnedSpecifierResolver(ts, ownedAbsolute) {
38
+ const ownedByBasename = new Map();
39
+ for (const abs of ownedAbsolute) {
40
+ const base = basename(abs);
41
+ const list = ownedByBasename.get(base) ?? [];
42
+ list.push(abs);
43
+ ownedByBasename.set(base, list);
44
+ }
45
+ return (specifier) => {
46
+ if (specifier.startsWith('.'))
47
+ return undefined;
48
+ const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
49
+ const segment = cleaned.slice(cleaned.lastIndexOf('/') + 1);
50
+ if (!segment)
51
+ return undefined;
52
+ const candidates = [...(ownedByBasename.get(segment) ?? [])];
53
+ if (segment.endsWith('.mjs') && !segment.endsWith('.sys.mjs')) {
54
+ candidates.push(...(ownedByBasename.get(segment.replace(/\.mjs$/, '.sys.mjs')) ?? []));
55
+ }
56
+ if (candidates.length !== 1)
57
+ return undefined;
58
+ return {
59
+ resolvedFileName: candidates[0],
60
+ extension: ts.Extension.Mjs,
61
+ isExternalLibraryImport: false,
62
+ };
63
+ };
64
+ }
27
65
  /**
28
66
  * Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
29
67
  *
@@ -126,15 +164,18 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
126
164
  module: ts.ModuleKind.ESNext,
127
165
  moduleResolution: ts.ModuleResolutionKind.Bundler,
128
166
  skipLibCheck: true,
129
- // Do not follow import/reference directives into the Firefox tree.
130
- // We only want to check the patch-owned files themselves.
131
- // Without this, TS would try (and fail) to resolve every
132
- // resource:// and chrome:// import, flooding the output with
133
- // "Cannot find module" errors for upstream Firefox modules.
134
- noResolve: true,
167
+ // Module resolution is host-controlled (see resolveOwnedSpecifier
168
+ // below): imports that match a patch-owned file resolve to the real
169
+ // source so JSDoc type-guard predicates and @template generics
170
+ // survive the module boundary; everything else deliberately fails
171
+ // resolution, falling back to the chrome:*/resource:* ambient
172
+ // wildcards plus the suppressed "cannot find module" codes. The
173
+ // host resolver is authoritative — TS never crawls the Firefox
174
+ // tree looking for upstream modules.
135
175
  ...strictness,
136
176
  ...overrides,
137
177
  };
178
+ const resolveOwnedSpecifier = createOwnedSpecifierResolver(ts, ownedAbsolute);
138
179
  // Custom compiler host: reads patch-owned files from disk, returns
139
180
  // the shim for the shim path, and returns empty content for
140
181
  // anything else to avoid reading the full Firefox tree.
@@ -169,6 +210,11 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
169
210
  return shimSource;
170
211
  return defaultHost.readFile(fileName);
171
212
  },
213
+ resolveModuleNameLiterals(moduleLiterals) {
214
+ return moduleLiterals.map((literal) => ({
215
+ resolvedModule: resolveOwnedSpecifier(literal.text),
216
+ }));
217
+ },
172
218
  };
173
219
  const program = ts.createProgram(rootFiles, options, host);
174
220
  const allDiagnostics = [