@hominis/fireforge 0.30.0 → 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 +26 -1
  2. package/README.md +22 -5
  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 +119 -78
  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 +3 -13
  94. package/dist/src/core/lint-cache.js +11 -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
@@ -10,20 +10,23 @@ import { parseStringArray } from './furnace-config-array-utils.js';
10
10
  import { parseCustomConfig } from './furnace-config-custom.js';
11
11
  import { orderFurnaceConfigForWrite } from './furnace-config-order.js';
12
12
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
13
+ import { applyOptionalFurnaceFields, parseNamedComponentMap, parseOverrideConfig, parseStockList, } from './furnace-config-validate.js';
13
14
  import { resolveFtlDir } from './furnace-constants.js';
14
15
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
15
16
  import { quarantineStateFile, withStateFileLock } from './state-file.js';
16
17
  export { detectComposesCycles };
17
18
  /** Name of the furnace configuration file */
18
- export const FURNACE_CONFIG_FILENAME = 'furnace.json';
19
+ const FURNACE_CONFIG_FILENAME = 'furnace.json';
19
20
  /** Name of the furnace state file */
20
- export const FURNACE_STATE_FILENAME = 'furnace-state.json';
21
+ const FURNACE_STATE_FILENAME = 'furnace-state.json';
21
22
  /** Name of the components directory */
22
- export const COMPONENTS_DIR = 'components';
23
+ const COMPONENTS_DIR = 'components';
23
24
  /** Name of the overrides subdirectory */
24
- export const OVERRIDES_DIR = 'overrides';
25
+ const OVERRIDES_DIR = 'overrides';
25
26
  /** Name of the custom subdirectory */
26
- export const CUSTOM_DIR = 'custom';
27
+ const CUSTOM_DIR = 'custom';
28
+ /** Directory name for shared CSS fragments within components/ */
29
+ export const SHARED_FRAGMENTS_DIR = 'shared';
27
30
  /**
28
31
  * Gets all furnace-related paths based on a root directory.
29
32
  * @param root - Root directory of the project
@@ -36,6 +39,7 @@ export function getFurnacePaths(root) {
36
39
  componentsDir,
37
40
  overridesDir: join(componentsDir, OVERRIDES_DIR),
38
41
  customDir: join(componentsDir, CUSTOM_DIR),
42
+ sharedDir: join(componentsDir, SHARED_FRAGMENTS_DIR),
39
43
  furnaceState: join(root, FIREFORGE_DIR, FURNACE_STATE_FILENAME),
40
44
  };
41
45
  }
@@ -48,36 +52,6 @@ export async function furnaceConfigExists(root) {
48
52
  const paths = getFurnacePaths(root);
49
53
  return pathExists(paths.furnaceConfig);
50
54
  }
51
- /**
52
- * Validates an override component config object.
53
- * @param data - Raw data to validate
54
- * @param name - Component name for error messages
55
- */
56
- function parseOverrideConfig(data, name) {
57
- const validTypes = ['css-only', 'full'];
58
- if (!isString(data['type']) || !validTypes.includes(data['type'])) {
59
- throw new FurnaceError(`Furnace config: override "${name}.type" must be one of: ${validTypes.join(', ')}`);
60
- }
61
- if (!isString(data['description'])) {
62
- throw new FurnaceError(`Furnace config: override "${name}.description" must be a string`);
63
- }
64
- if (!isString(data['basePath'])) {
65
- throw new FurnaceError(`Furnace config: override "${name}.basePath" must be a string`);
66
- }
67
- if (data['basePath'].includes('..')) {
68
- throw new FurnaceError(`Furnace config: override "${name}.basePath" must not contain ".." (path traversal)`);
69
- }
70
- if (!isString(data['baseVersion'])) {
71
- throw new FurnaceError(`Furnace config: override "${name}.baseVersion" must be a string`);
72
- }
73
- return {
74
- type: data['type'] === 'css-only' ? 'css-only' : 'full',
75
- description: data['description'],
76
- basePath: data['basePath'],
77
- baseVersion: data['baseVersion'],
78
- ...(isString(data['baseCommit']) ? { baseCommit: data['baseCommit'] } : {}),
79
- };
80
- }
81
55
  /** The current (and only) config schema version. */
82
56
  const CURRENT_CONFIG_VERSION = 1;
83
57
  /**
@@ -95,7 +69,7 @@ const CURRENT_CONFIG_VERSION = 1;
95
69
  * break;
96
70
  * ```
97
71
  */
98
- export function migrateFurnaceConfig(data) {
72
+ function migrateFurnaceConfig(data) {
99
73
  const version = data['version'];
100
74
  if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
101
75
  throw new FurnaceError(`Furnace config: "version" must be a positive integer (got ${JSON.stringify(version)}). ` +
@@ -141,45 +115,9 @@ export function validateFurnaceConfig(data) {
141
115
  // Validate optional tokenHostDocuments — list of chrome XHTMLs that the
142
116
  // `missing-token-link` validator scans for the tokens CSS link.
143
117
  validateTokenHostDocuments(migrated['tokenHostDocuments']);
144
- const stock = parseStringArray(migrated['stock'], 'stock');
145
- const stockSet = new Set();
146
- for (const name of stock) {
147
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
148
- throw new FurnaceError(`Furnace config: stock entry "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
149
- }
150
- if (stockSet.has(name)) {
151
- throw new FurnaceError(`Furnace config: duplicate stock entry "${name}"`);
152
- }
153
- stockSet.add(name);
154
- }
155
- // Validate overrides
156
- if (!isObject(migrated['overrides'])) {
157
- throw new FurnaceError('Furnace config: "overrides" must be an object');
158
- }
159
- const overrides = {};
160
- for (const [name, value] of Object.entries(migrated['overrides'])) {
161
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
162
- throw new FurnaceError(`Furnace config: override name "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
163
- }
164
- if (!isObject(value)) {
165
- throw new FurnaceError(`Furnace config: override "${name}" must be an object`);
166
- }
167
- overrides[name] = parseOverrideConfig(value, name);
168
- }
169
- // Validate custom
170
- if (!isObject(migrated['custom'])) {
171
- throw new FurnaceError('Furnace config: "custom" must be an object');
172
- }
173
- const custom = {};
174
- for (const [name, value] of Object.entries(migrated['custom'])) {
175
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
176
- throw new FurnaceError(`Furnace config: custom name "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
177
- }
178
- if (!isObject(value)) {
179
- throw new FurnaceError(`Furnace config: custom "${name}" must be an object`);
180
- }
181
- custom[name] = parseCustomConfig(value, name);
182
- }
118
+ const stock = parseStockList(migrated['stock']);
119
+ const overrides = parseNamedComponentMap(migrated['overrides'], 'override', 'overrides', parseOverrideConfig);
120
+ const custom = parseNamedComponentMap(migrated['custom'], 'custom', 'custom', parseCustomConfig);
183
121
  // Detect circular composes references among custom components.
184
122
  detectComposesCycles(custom);
185
123
  // Validate that every composes reference points to a known component.
@@ -191,41 +129,7 @@ export function validateFurnaceConfig(data) {
191
129
  overrides,
192
130
  custom,
193
131
  };
194
- if (migrated['tokenPrefix'] !== undefined)
195
- config.tokenPrefix = migrated['tokenPrefix'];
196
- if (migrated['tokenAllowlist'] !== undefined) {
197
- config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
198
- }
199
- if (migrated['platformPrefixes'] !== undefined) {
200
- config.platformPrefixes = parseStringArray(migrated['platformPrefixes'], 'platformPrefixes');
201
- }
202
- if (migrated['runtimeVariables'] !== undefined) {
203
- config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
204
- }
205
- if (migrated['tokenHostDocuments'] !== undefined) {
206
- const docs = parseStringArray(migrated['tokenHostDocuments'], 'tokenHostDocuments');
207
- config.tokenHostDocuments = docs;
208
- }
209
- // Validate optional ftlBasePath
210
- if (migrated['ftlBasePath'] !== undefined) {
211
- if (!isString(migrated['ftlBasePath'])) {
212
- throw new FurnaceError('Furnace config: "ftlBasePath" must be a string if provided');
213
- }
214
- if (migrated['ftlBasePath'].includes('..')) {
215
- throw new FurnaceError('Furnace config: "ftlBasePath" must not contain ".." (path traversal)');
216
- }
217
- config.ftlBasePath = migrated['ftlBasePath'];
218
- }
219
- // Validate optional scanPaths
220
- if (migrated['scanPaths'] !== undefined) {
221
- const paths = parseStringArray(migrated['scanPaths'], 'scanPaths');
222
- for (const p of paths) {
223
- if (p.includes('..')) {
224
- throw new FurnaceError('Furnace config: "scanPaths" entries must not contain ".." (path traversal)');
225
- }
226
- }
227
- config.scanPaths = paths;
228
- }
132
+ applyOptionalFurnaceFields(migrated, config);
229
133
  return config;
230
134
  }
231
135
  /**
@@ -233,7 +137,7 @@ export function validateFurnaceConfig(data) {
233
137
  * @param data - Parsed JSON state data
234
138
  * @returns Validated FurnaceState
235
139
  */
236
- export function validateFurnaceState(data) {
140
+ function validateFurnaceState(data) {
237
141
  const result = sanitizeFurnaceState(data);
238
142
  if (result.issues.length > 0) {
239
143
  throw new FurnaceError(`Invalid furnace state: ${result.issues.join('; ')}`);
@@ -4,22 +4,12 @@ export declare const CUSTOM_ELEMENTS_JS = "toolkit/content/customElements.js";
4
4
  export declare const JAR_MN = "toolkit/content/jar.mn";
5
5
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
6
6
  export declare const FTL_DIR = "toolkit/locales/en-US/toolkit/global";
7
- /**
8
- * Suffix for the per-binary xpcshell scaffold parent directory. Components
9
- * created with `furnace create --with-tests --xpcshell` land at
10
- * `browser/base/content/test/<binaryName>${XPCSHELL_TEST_DIR_SUFFIX}/<component>/`.
11
- * Centralised so `create` / `remove` / `rename` / `validate` all agree on
12
- * the path template (2026-04-24 eval Finding 5).
13
- */
14
- export declare const XPCSHELL_TEST_DIR_SUFFIX = "-xpcshell";
15
7
  /**
16
8
  * Returns the engine-relative directory that holds xpcshell scaffolds for
17
9
  * a given binary. Matches the form `create-xpcshell.ts` writes and the
18
10
  * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
19
11
  */
20
12
  export declare function xpcshellTestParentDir(binaryName: string): string;
21
- /** File extensions that constitute a Furnace component's source files. */
22
- export declare const COMPONENT_FILE_EXTENSIONS: readonly [".mjs", ".css", ".ftl"];
23
13
  /** Returns true when `fileName` has one of the standard component file extensions. */
24
14
  export declare function isComponentSourceFile(fileName: string): boolean;
25
15
  /**
@@ -12,7 +12,7 @@ export const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
12
12
  * Centralised so `create` / `remove` / `rename` / `validate` all agree on
13
13
  * the path template (2026-04-24 eval Finding 5).
14
14
  */
15
- export const XPCSHELL_TEST_DIR_SUFFIX = '-xpcshell';
15
+ const XPCSHELL_TEST_DIR_SUFFIX = '-xpcshell';
16
16
  /**
17
17
  * Returns the engine-relative directory that holds xpcshell scaffolds for
18
18
  * a given binary. Matches the form `create-xpcshell.ts` writes and the
@@ -22,7 +22,7 @@ export function xpcshellTestParentDir(binaryName) {
22
22
  return `browser/base/content/test/${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
23
23
  }
24
24
  /** File extensions that constitute a Furnace component's source files. */
25
- export const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
25
+ const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
26
26
  /** Returns true when `fileName` has one of the standard component file extensions. */
27
27
  export function isComponentSourceFile(fileName) {
28
28
  return COMPONENT_FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shared CSS fragments for Furnace widgets (field report D2).
3
+ *
4
+ * Shadow-DOM isolation forces each widget stylesheet to carry its own copy
5
+ * of genuinely shared CSS (keyframes, resets). Hand-syncing those copies
6
+ * drifts. Instead, a workspace stylesheet declares an include directive
7
+ * (a CSS block comment on its own line):
8
+ *
9
+ * @fireforge-include shared-anims.css
10
+ *
11
+ * naming a fragment file in `components/shared/`. `furnace deploy` expands
12
+ * the fragment into the *deployed* copy only — the workspace source stays
13
+ * DRY — fencing the expansion between the directive line and a matching
14
+ * `@fireforge-end-include` marker so re-deploys can refresh it idempotently.
15
+ *
16
+ * Drift contract: the apply fast-path and `furnace validate` compare the
17
+ * *expanded* workspace source against the engine copy, so editing a
18
+ * fragment surfaces as ordinary component drift and the next deploy
19
+ * refreshes every consuming widget.
20
+ */
21
+ import type { ValidationIssue } from '../types/furnace.js';
22
+ export { SHARED_FRAGMENTS_DIR } from './furnace-config.js';
23
+ /** Returns the fragment names referenced by `@fireforge-include` directives. */
24
+ export declare function listFragmentIncludes(css: string): string[];
25
+ /**
26
+ * Collapses fenced fragment expansions back to their bare directives.
27
+ * Inverse of {@link expandCssFragments}; used to compare a deployed file
28
+ * against its workspace source and to re-expand idempotently.
29
+ */
30
+ export declare function stripExpandedFragments(css: string): string;
31
+ /**
32
+ * Expands every `@fireforge-include` directive in `css` with the current
33
+ * content of its fragment file from `sharedDir`, fencing each expansion
34
+ * with an end marker. Existing expansions are stripped first, so the
35
+ * operation is idempotent and refreshes stale content.
36
+ *
37
+ * @param css - Stylesheet source (workspace or previously expanded)
38
+ * @param sharedDir - Absolute path to the shared fragments directory
39
+ * @returns Expanded stylesheet and the fragment names it consumed
40
+ * @throws FurnaceError when a fragment is missing or itself contains an
41
+ * include directive (nesting is not supported)
42
+ */
43
+ export declare function expandCssFragments(css: string, sharedDir: string): Promise<{
44
+ expanded: string;
45
+ includes: string[];
46
+ }>;
47
+ /**
48
+ * Extracts the fenced expansion bodies of a previously expanded stylesheet,
49
+ * keyed by fragment name. Used by validate to compare a deployed expansion
50
+ * against the current fragment source without re-deploying.
51
+ */
52
+ export declare function extractExpandedFragmentBodies(css: string): Map<string, string>;
53
+ /**
54
+ * Deploys one component file: CSS sources carrying include directives are
55
+ * written as their fragment-expanded form; everything else is a plain
56
+ * copy. Extracted here so `applyCustomComponent` stays inside the
57
+ * per-file line budget.
58
+ *
59
+ * @returns True when fragment expansion was applied
60
+ */
61
+ export declare function deployFileWithFragments(src: string, dest: string, sharedDir: string): Promise<boolean>;
62
+ /**
63
+ * Builds the dry-run description suffix for a component file copy,
64
+ * naming the fragments an expansion would inline. Empty for plain copies.
65
+ */
66
+ export declare function describeFragmentExpansion(src: string): Promise<string>;
67
+ /**
68
+ * Validates fragment usage for one custom component: every directive must
69
+ * name an existing fragment (`missing-fragment`, error), and a deployed
70
+ * stylesheet's fenced expansion must match the current fragment source
71
+ * (`stale-fragment-expansion`, warning → redeploy refreshes it).
72
+ *
73
+ * @param componentDir - Workspace directory of the component
74
+ * @param tagName - Component tag name for issue attribution
75
+ * @param sharedDir - Shared fragments directory
76
+ * @param engineTargetDir - Deployed directory in the engine (optional —
77
+ * pre-deploy validation skips the staleness check)
78
+ */
79
+ export declare function validateCssFragments(componentDir: string, tagName: string, sharedDir: string, engineTargetDir?: string): Promise<ValidationIssue[]>;
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared CSS fragments for Furnace widgets (field report D2).
4
+ *
5
+ * Shadow-DOM isolation forces each widget stylesheet to carry its own copy
6
+ * of genuinely shared CSS (keyframes, resets). Hand-syncing those copies
7
+ * drifts. Instead, a workspace stylesheet declares an include directive
8
+ * (a CSS block comment on its own line):
9
+ *
10
+ * @fireforge-include shared-anims.css
11
+ *
12
+ * naming a fragment file in `components/shared/`. `furnace deploy` expands
13
+ * the fragment into the *deployed* copy only — the workspace source stays
14
+ * DRY — fencing the expansion between the directive line and a matching
15
+ * `@fireforge-end-include` marker so re-deploys can refresh it idempotently.
16
+ *
17
+ * Drift contract: the apply fast-path and `furnace validate` compare the
18
+ * *expanded* workspace source against the engine copy, so editing a
19
+ * fragment surfaces as ordinary component drift and the next deploy
20
+ * refreshes every consuming widget.
21
+ */
22
+ import { readdir } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+ import { FurnaceError } from '../errors/furnace.js';
25
+ import { copyFile, pathExists, readText, writeText } from '../utils/fs.js';
26
+ export { SHARED_FRAGMENTS_DIR } from './furnace-config.js';
27
+ // Local copy of the directory name for message text — importing the
28
+ // binding for value use keeps a single source of truth.
29
+ import { SHARED_FRAGMENTS_DIR } from './furnace-config.js';
30
+ const INCLUDE_PATTERN = /^\s*\/\*\s*@fireforge-include\s+([\w./-]+)\s*\*\/\s*$/;
31
+ const END_INCLUDE_PATTERN = /^\s*\/\*\s*@fireforge-end-include\s+([\w./-]+)\s*\*\/\s*$/;
32
+ /** Returns the fragment names referenced by `@fireforge-include` directives. */
33
+ export function listFragmentIncludes(css) {
34
+ const names = [];
35
+ for (const line of css.split('\n')) {
36
+ const m = INCLUDE_PATTERN.exec(line);
37
+ if (m?.[1] && !names.includes(m[1]))
38
+ names.push(m[1]);
39
+ }
40
+ return names;
41
+ }
42
+ /**
43
+ * Collapses fenced fragment expansions back to their bare directives.
44
+ * Inverse of {@link expandCssFragments}; used to compare a deployed file
45
+ * against its workspace source and to re-expand idempotently.
46
+ */
47
+ export function stripExpandedFragments(css) {
48
+ const lines = css.split('\n');
49
+ const out = [];
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const line = lines[i] ?? '';
52
+ out.push(line);
53
+ const inc = INCLUDE_PATTERN.exec(line);
54
+ if (!inc?.[1])
55
+ continue;
56
+ // A bare directive (workspace file) has no fence to strip. Only skip
57
+ // the expansion body when a matching end marker actually follows —
58
+ // otherwise an unterminated fence would silently eat the rest of the
59
+ // file.
60
+ let endIndex = -1;
61
+ for (let j = i + 1; j < lines.length; j++) {
62
+ if (END_INCLUDE_PATTERN.exec(lines[j] ?? '')?.[1] === inc[1]) {
63
+ endIndex = j;
64
+ break;
65
+ }
66
+ }
67
+ if (endIndex !== -1)
68
+ i = endIndex;
69
+ }
70
+ return out.join('\n');
71
+ }
72
+ /**
73
+ * Expands every `@fireforge-include` directive in `css` with the current
74
+ * content of its fragment file from `sharedDir`, fencing each expansion
75
+ * with an end marker. Existing expansions are stripped first, so the
76
+ * operation is idempotent and refreshes stale content.
77
+ *
78
+ * @param css - Stylesheet source (workspace or previously expanded)
79
+ * @param sharedDir - Absolute path to the shared fragments directory
80
+ * @returns Expanded stylesheet and the fragment names it consumed
81
+ * @throws FurnaceError when a fragment is missing or itself contains an
82
+ * include directive (nesting is not supported)
83
+ */
84
+ export async function expandCssFragments(css, sharedDir) {
85
+ const stripped = stripExpandedFragments(css);
86
+ const includes = listFragmentIncludes(stripped);
87
+ if (includes.length === 0)
88
+ return { expanded: stripped, includes };
89
+ const fragments = new Map();
90
+ for (const name of includes) {
91
+ const fragmentPath = join(sharedDir, name);
92
+ if (!(await pathExists(fragmentPath))) {
93
+ throw new FurnaceError(`CSS fragment "${name}" not found in components/${SHARED_FRAGMENTS_DIR}/. ` +
94
+ 'Create the fragment file or remove the @fireforge-include directive.');
95
+ }
96
+ const content = await readText(fragmentPath);
97
+ if (listFragmentIncludes(content).length > 0) {
98
+ throw new FurnaceError(`CSS fragment "${name}" contains an @fireforge-include directive of its own; ` +
99
+ 'nested fragment includes are not supported.');
100
+ }
101
+ fragments.set(name, content.replace(/\n$/, ''));
102
+ }
103
+ const out = [];
104
+ for (const line of stripped.split('\n')) {
105
+ out.push(line);
106
+ const inc = INCLUDE_PATTERN.exec(line);
107
+ if (inc?.[1]) {
108
+ out.push(fragments.get(inc[1]) ?? '');
109
+ out.push(`/* @fireforge-end-include ${inc[1]} */`);
110
+ }
111
+ }
112
+ return { expanded: out.join('\n'), includes };
113
+ }
114
+ /**
115
+ * Extracts the fenced expansion bodies of a previously expanded stylesheet,
116
+ * keyed by fragment name. Used by validate to compare a deployed expansion
117
+ * against the current fragment source without re-deploying.
118
+ */
119
+ export function extractExpandedFragmentBodies(css) {
120
+ const bodies = new Map();
121
+ const lines = css.split('\n');
122
+ let current = null;
123
+ let buffer = [];
124
+ for (const line of lines) {
125
+ if (current !== null) {
126
+ if (END_INCLUDE_PATTERN.exec(line)?.[1] === current) {
127
+ bodies.set(current, buffer.join('\n'));
128
+ current = null;
129
+ buffer = [];
130
+ continue;
131
+ }
132
+ buffer.push(line);
133
+ continue;
134
+ }
135
+ const inc = INCLUDE_PATTERN.exec(line);
136
+ if (inc?.[1]) {
137
+ current = inc[1];
138
+ }
139
+ }
140
+ return bodies;
141
+ }
142
+ /**
143
+ * Deploys one component file: CSS sources carrying include directives are
144
+ * written as their fragment-expanded form; everything else is a plain
145
+ * copy. Extracted here so `applyCustomComponent` stays inside the
146
+ * per-file line budget.
147
+ *
148
+ * @returns True when fragment expansion was applied
149
+ */
150
+ export async function deployFileWithFragments(src, dest, sharedDir) {
151
+ if (src.endsWith('.css')) {
152
+ const content = await readText(src);
153
+ if (listFragmentIncludes(content).length > 0) {
154
+ const { expanded } = await expandCssFragments(content, sharedDir);
155
+ await writeText(dest, expanded);
156
+ return true;
157
+ }
158
+ }
159
+ await copyFile(src, dest);
160
+ return false;
161
+ }
162
+ /**
163
+ * Builds the dry-run description suffix for a component file copy,
164
+ * naming the fragments an expansion would inline. Empty for plain copies.
165
+ */
166
+ export async function describeFragmentExpansion(src) {
167
+ if (!src.endsWith('.css'))
168
+ return '';
169
+ const includes = listFragmentIncludes(await readText(src));
170
+ if (includes.length === 0)
171
+ return '';
172
+ return ` (expanding fragment${includes.length === 1 ? '' : 's'}: ${includes.join(', ')})`;
173
+ }
174
+ /**
175
+ * Validates fragment usage for one custom component: every directive must
176
+ * name an existing fragment (`missing-fragment`, error), and a deployed
177
+ * stylesheet's fenced expansion must match the current fragment source
178
+ * (`stale-fragment-expansion`, warning → redeploy refreshes it).
179
+ *
180
+ * @param componentDir - Workspace directory of the component
181
+ * @param tagName - Component tag name for issue attribution
182
+ * @param sharedDir - Shared fragments directory
183
+ * @param engineTargetDir - Deployed directory in the engine (optional —
184
+ * pre-deploy validation skips the staleness check)
185
+ */
186
+ export async function validateCssFragments(componentDir, tagName, sharedDir, engineTargetDir) {
187
+ const issues = [];
188
+ if (!(await pathExists(componentDir)))
189
+ return issues;
190
+ // Graceful degradation like the other validators: an unreadable
191
+ // component directory must not cascade into a validation crash.
192
+ let entries;
193
+ try {
194
+ entries = await readdir(componentDir);
195
+ }
196
+ catch {
197
+ return issues;
198
+ }
199
+ for (const fileName of entries) {
200
+ if (!fileName.endsWith('.css'))
201
+ continue;
202
+ const source = await readText(join(componentDir, fileName));
203
+ const includes = listFragmentIncludes(source);
204
+ if (includes.length === 0)
205
+ continue;
206
+ let deployedBodies = null;
207
+ if (engineTargetDir) {
208
+ const destPath = join(engineTargetDir, fileName);
209
+ if (await pathExists(destPath)) {
210
+ deployedBodies = extractExpandedFragmentBodies(await readText(destPath));
211
+ }
212
+ }
213
+ for (const include of includes) {
214
+ const fragmentPath = join(sharedDir, include);
215
+ if (!(await pathExists(fragmentPath))) {
216
+ issues.push({
217
+ component: tagName,
218
+ severity: 'error',
219
+ check: 'missing-fragment',
220
+ message: `${fileName} includes CSS fragment "${include}", but components/${SHARED_FRAGMENTS_DIR}/${include} does not exist. ` +
221
+ 'Create the fragment or remove the @fireforge-include directive.',
222
+ });
223
+ continue;
224
+ }
225
+ if (deployedBodies === null)
226
+ continue;
227
+ const fragmentContent = (await readText(fragmentPath)).replace(/\n$/, '');
228
+ const deployed = deployedBodies.get(include);
229
+ if (deployed === undefined || deployed !== fragmentContent) {
230
+ issues.push({
231
+ component: tagName,
232
+ severity: 'warning',
233
+ check: 'stale-fragment-expansion',
234
+ message: deployed === undefined
235
+ ? `Deployed ${fileName} has no expansion for fragment "${include}". Run "fireforge furnace deploy ${tagName}".`
236
+ : `Deployed ${fileName} carries a stale expansion of fragment "${include}" — the fragment source changed since the last deploy. Run "fireforge furnace deploy ${tagName}".`,
237
+ });
238
+ }
239
+ }
240
+ }
241
+ return issues;
242
+ }
243
+ //# sourceMappingURL=furnace-css-fragments.js.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Maintains `compilerOptions.paths` entries in a consumer-owned jsconfig
3
+ * so typed cross-module imports of multi-file Furnace components work
4
+ * (field report D3).
5
+ *
6
+ * When a main widget imports a sibling helper via its deployed chrome URL
7
+ * (`chrome://global/content/elements/<helper>.mjs`), a wildcard module
8
+ * shim swallows the import: value imports degrade to `any` and
9
+ * `import(...).SomeType` typedefs fail with TS2694. The fix is a `paths`
10
+ * mapping from the chrome URL to the real workspace source. Furnace
11
+ * already owns the jar.mn side of that mapping, so it can maintain the
12
+ * jsconfig side automatically on every deploy.
13
+ *
14
+ * Ownership contract: only entries whose key starts with
15
+ * `chrome://global/content/elements/` AND whose mapped path resolves into
16
+ * the Furnace custom-components workspace are managed (added, updated,
17
+ * pruned). Everything else in the jsconfig — including hand-written
18
+ * `paths` entries pointing elsewhere — is preserved verbatim. No
19
+ * `baseUrl` is required or written: relative `paths` resolve against the
20
+ * config file's directory.
21
+ */
22
+ import type { FurnaceConfig } from '../types/furnace.js';
23
+ /** Result summary of a jsconfig paths sync. */
24
+ export interface JsconfigSyncResult {
25
+ /** Keys newly added to compilerOptions.paths. */
26
+ added: string[];
27
+ /** Managed keys whose mapped path changed. */
28
+ updated: string[];
29
+ /** Managed keys removed because their component/file is gone. */
30
+ pruned: string[];
31
+ /** True when the file was (or would be) rewritten. */
32
+ changed: boolean;
33
+ }
34
+ /**
35
+ * Reconciles the managed `compilerOptions.paths` entries of the configured
36
+ * jsconfig against the current Furnace workspace. Idempotent; writes only
37
+ * when something actually changes; dry-run returns the diff without
38
+ * writing.
39
+ *
40
+ * The consumer owns the jsconfig file: a missing file is an error with
41
+ * guidance rather than a silent scaffold, and JSONC (comments/trailing
42
+ * commas) is unsupported for the managed file — `readJson` is a strict
43
+ * JSON parser, so the error message says so explicitly.
44
+ *
45
+ * @param root - Project root directory
46
+ * @param config - Loaded Furnace configuration (must carry `typecheckJsconfig`)
47
+ * @param options - `dryRun` skips the write but still reports the diff
48
+ */
49
+ export declare function syncFurnaceJsconfigPaths(root: string, config: FurnaceConfig, options?: {
50
+ dryRun?: boolean;
51
+ }): Promise<JsconfigSyncResult>;
52
+ /**
53
+ * Computes jsconfig `paths` drift for `furnace validate`: managed entries
54
+ * that are missing or stale relative to the current workspace. Read-only —
55
+ * delegates to {@link syncFurnaceJsconfigPaths} in dry-run mode.
56
+ */
57
+ export declare function findJsconfigPathsDrift(root: string, config: FurnaceConfig): Promise<JsconfigSyncResult>;
58
+ /**
59
+ * Runs the jsconfig paths sync after a successful deploy/sync and reports
60
+ * the diff. No-op when `typecheckJsconfig` is unset. Shared by
61
+ * `furnace deploy` and `furnace sync` so both report identically.
62
+ */
63
+ export declare function reportJsconfigPathsSync(root: string, config: FurnaceConfig, dryRun: boolean): Promise<void>;