@hominis/fireforge 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
- import { dirname } from 'node:path';
2
+ import { chmod, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
4
  import { FurnaceError } from '../errors/furnace.js';
5
5
  import { toError } from '../utils/errors.js';
6
6
  import { pathExists } from '../utils/fs.js';
@@ -9,12 +9,40 @@ export function createRollbackJournal() {
9
9
  return {
10
10
  files: new Map(),
11
11
  createdDirs: new Set(),
12
+ skippedSymlinks: new Set(),
12
13
  };
13
14
  }
14
15
  /** Records a directory that should be removed if the operation later rolls back. */
15
16
  export function recordCreatedDir(journal, dirPath) {
16
17
  journal.createdDirs.add(dirPath);
17
18
  }
19
+ /**
20
+ * Recursively snapshots every file under a directory tree so a later rollback
21
+ * can restore deleted files. Skips symlinks to avoid following them out of the
22
+ * tree. The directory itself is not recorded as "created" — callers that
23
+ * intend to delete and restore the directory should record it explicitly.
24
+ *
25
+ * Safe to call on a missing path: it returns without recording anything.
26
+ */
27
+ export async function snapshotDir(journal, dirPath) {
28
+ if (!(await pathExists(dirPath))) {
29
+ return;
30
+ }
31
+ const entries = await readdir(dirPath, { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ if (entry.isSymbolicLink()) {
34
+ journal.skippedSymlinks.add(join(dirPath, entry.name));
35
+ continue;
36
+ }
37
+ const childPath = join(dirPath, entry.name);
38
+ if (entry.isDirectory()) {
39
+ await snapshotDir(journal, childPath);
40
+ }
41
+ else if (entry.isFile()) {
42
+ await snapshotFile(journal, childPath);
43
+ }
44
+ }
45
+ }
18
46
  /** Snapshots a file once so rollback can restore its previous contents or absence. */
19
47
  export async function snapshotFile(journal, filePath) {
20
48
  if (journal.files.has(filePath)) {
@@ -37,16 +65,59 @@ async function restoreFile(filePath, snapshot) {
37
65
  return;
38
66
  }
39
67
  await mkdir(dirname(filePath), { recursive: true });
40
- await writeFile(filePath, snapshot.content ?? new Uint8Array());
41
- if (snapshot.mode !== undefined) {
42
- await chmod(filePath, snapshot.mode);
68
+ // Write to a sibling temp file and atomically rename it over the target.
69
+ // A direct writeFile would race with any in-flight write by the body (e.g. a
70
+ // signal-handler-driven rollback landing on top of a still-running
71
+ // `writeFile('corrupted')` from the body), producing interleaved byte
72
+ // sequences like `"pristined"` where the first 8 bytes come from the
73
+ // rollback write and the trailing byte from the body write. rename(2) is
74
+ // atomic within a filesystem, so either the body's write or the rollback's
75
+ // rename wins outright and the target is never left in a hybrid state.
76
+ const tempPath = `${filePath}.rollback-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
77
+ try {
78
+ await writeFile(tempPath, snapshot.content ?? new Uint8Array());
79
+ if (snapshot.mode !== undefined) {
80
+ await chmod(tempPath, snapshot.mode);
81
+ }
82
+ await rename(tempPath, filePath);
83
+ }
84
+ catch (error) {
85
+ await rm(tempPath, { force: true }).catch(() => undefined);
86
+ throw error;
43
87
  }
44
88
  }
89
+ /** Maximum number of concurrent file restorations during rollback. */
90
+ const RESTORE_CONCURRENCY = 8;
45
91
  /** Restores all snapshotted files and removes directories created during the operation. */
46
92
  export async function restoreRollbackJournal(journal) {
47
93
  const fileEntries = [...journal.files.entries()].sort(([left], [right]) => right.length - left.length);
48
- for (const [filePath, snapshot] of fileEntries) {
49
- await restoreFile(filePath, snapshot);
94
+ // Restore files in parallel with bounded concurrency. Each restoreFile uses
95
+ // atomic rename, so concurrent restorations to different paths are safe.
96
+ const errors = [];
97
+ let index = 0;
98
+ async function worker() {
99
+ while (index < fileEntries.length) {
100
+ const current = index++;
101
+ const entry = fileEntries[current];
102
+ if (!entry)
103
+ break;
104
+ const [filePath, snapshot] = entry;
105
+ try {
106
+ await restoreFile(filePath, snapshot);
107
+ }
108
+ catch (error) {
109
+ errors.push({
110
+ path: filePath,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ });
113
+ }
114
+ }
115
+ }
116
+ const workers = Array.from({ length: Math.min(RESTORE_CONCURRENCY, fileEntries.length) }, () => worker());
117
+ await Promise.all(workers);
118
+ if (errors.length > 0) {
119
+ const summary = errors.map((e) => `${e.path}: ${e.error}`).join('; ');
120
+ throw new FurnaceError(`Rollback failed to restore ${errors.length} file(s): ${summary}`);
50
121
  }
51
122
  const createdDirs = [...journal.createdDirs].sort((left, right) => right.length - left.length);
52
123
  for (const dirPath of createdDirs) {
@@ -1,4 +1,10 @@
1
1
  import type { ScannedComponent } from '../types/furnace.js';
2
+ /**
3
+ * Additional Firefox source directories known to contain MozLitElement
4
+ * components. Used by `--deep` scan mode to discover components beyond
5
+ * the primary widgets directory.
6
+ */
7
+ export declare const DEEP_SCAN_PATHS: readonly string[];
2
8
  /**
3
9
  * Parses customElements.js to extract tag-to-module mappings.
4
10
  *
@@ -23,14 +29,14 @@ export declare function scanCustomElementsRegistrations(engineDir: string): Prom
23
29
  * @param engineDir - Path to the Firefox engine source root
24
30
  * @returns Array of discovered components
25
31
  */
26
- export declare function scanWidgetsDirectory(engineDir: string): Promise<ScannedComponent[]>;
32
+ export declare function scanWidgetsDirectory(engineDir: string, ftlDir?: string, extraPaths?: string[], componentPrefix?: string): Promise<ScannedComponent[]>;
27
33
  /**
28
34
  * Gets detailed information about a single component by tag name.
29
35
  * @param engineDir - Path to the Firefox engine source root
30
36
  * @param tagName - Component tag name (e.g., "moz-button")
31
37
  * @returns Component details, or null if not found in the source tree
32
38
  */
33
- export declare function getComponentDetails(engineDir: string, tagName: string): Promise<ScannedComponent | null>;
39
+ export declare function getComponentDetails(engineDir: string, tagName: string, ftlDir?: string): Promise<ScannedComponent | null>;
34
40
  /**
35
41
  * Checks whether a component directory exists in the engine source tree.
36
42
  * @param engineDir - Path to the Firefox engine source root
@@ -1,12 +1,33 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { createHash } from 'node:crypto';
2
3
  import { readdir } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { pathExists, readText } from '../utils/fs.js';
5
- import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
6
+ import { verbose, warn } from '../utils/logger.js';
7
+ import { asEstree, getNodeSource, parseScript, walkAST } from './ast-utils.js';
8
+ import { CUSTOM_ELEMENTS_JS, FTL_DIR } from './furnace-constants.js';
6
9
  /** Path to the widgets directory within the engine source tree */
7
10
  const WIDGETS_DIR = 'toolkit/content/widgets';
8
- /** Path to the Fluent localization directory for toolkit global components */
9
- const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
11
+ /**
12
+ * Additional Firefox source directories known to contain MozLitElement
13
+ * components. Used by `--deep` scan mode to discover components beyond
14
+ * the primary widgets directory.
15
+ */
16
+ export const DEEP_SCAN_PATHS = [
17
+ 'browser/components/shopping',
18
+ 'browser/components/migration',
19
+ 'browser/components/firefoxview',
20
+ 'browser/components/sidebar',
21
+ 'browser/components/backup',
22
+ 'toolkit/components/aboutprocesses',
23
+ 'toolkit/components/printing',
24
+ ];
25
+ /**
26
+ * Module-level cache for parsed customElements.js registrations, keyed by
27
+ * SHA-256 of the file content. Avoids re-parsing the same file within a
28
+ * single process lifetime (scan → status → apply chain).
29
+ */
30
+ let registrationCache;
10
31
  /**
11
32
  * Parses customElements.js to extract tag-to-module mappings.
12
33
  *
@@ -27,34 +48,104 @@ export async function scanCustomElementsRegistrations(engineDir) {
27
48
  return registrations;
28
49
  }
29
50
  const content = await readText(filePath);
30
- const lines = content.split('\n');
31
- for (let i = 0; i < lines.length; i++) {
32
- const line = lines[i];
33
- if (line === undefined)
34
- continue;
35
- const callbackMatch = /setElementCreationCallback\(\s*"([^"]+)"/.exec(line);
36
- if (!callbackMatch?.[1])
37
- continue;
38
- const tagName = callbackMatch[1];
39
- let modulePath = '';
40
- // Search the following lines for an import() statement
41
- const searchEnd = Math.min(i + 5, lines.length);
42
- for (let j = i + 1; j < searchEnd; j++) {
43
- const importLine = lines[j];
44
- if (importLine === undefined)
51
+ const contentHash = createHash('sha256').update(content).digest('hex');
52
+ // Return cached result if file hasn't changed since last parse.
53
+ if (registrationCache && registrationCache.hash === contentHash) {
54
+ return new Map(registrationCache.registrations);
55
+ }
56
+ try {
57
+ const ast = parseScript(content);
58
+ const recordRegistration = (tagName, modulePath = '') => {
59
+ const existing = registrations.get(tagName);
60
+ if (existing && existing.length > 0 && modulePath.length === 0) {
61
+ return;
62
+ }
63
+ registrations.set(tagName, modulePath);
64
+ };
65
+ walkAST(ast, {
66
+ enter(node) {
67
+ if (node.type === 'ForOfStatement') {
68
+ const forOf = asEstree(node);
69
+ if (forOf.right.type !== 'ArrayExpression') {
70
+ return;
71
+ }
72
+ const bodySource = getNodeSource(content, asEstree(forOf.body));
73
+ if (!bodySource.includes('setElementCreationCallback')) {
74
+ return;
75
+ }
76
+ for (const element of forOf.right.elements) {
77
+ if (!element || element.type !== 'ArrayExpression')
78
+ continue;
79
+ const [tagNode, moduleNode] = element.elements;
80
+ if (!tagNode || !moduleNode)
81
+ continue;
82
+ if (tagNode.type !== 'Literal' || moduleNode.type !== 'Literal')
83
+ continue;
84
+ if (typeof tagNode.value !== 'string' || typeof moduleNode.value !== 'string')
85
+ continue;
86
+ recordRegistration(tagNode.value, moduleNode.value);
87
+ }
88
+ return;
89
+ }
90
+ if (node.type !== 'CallExpression') {
91
+ return;
92
+ }
93
+ const call = asEstree(node);
94
+ if (call.callee.type !== 'MemberExpression') {
95
+ return;
96
+ }
97
+ const property = call.callee.property;
98
+ if (property.type !== 'Identifier' || property.name !== 'setElementCreationCallback') {
99
+ return;
100
+ }
101
+ const [tagArg] = call.arguments;
102
+ if (!tagArg || tagArg.type !== 'Literal' || typeof tagArg.value !== 'string') {
103
+ return;
104
+ }
105
+ const callSource = getNodeSource(content, call);
106
+ const moduleMatch = /(?:import|importESModule|loadSubScript)\(\s*"([^"]+)"/.exec(callSource) ??
107
+ /(?:import|importESModule|loadSubScript)\(\s*'([^']+)'/.exec(callSource);
108
+ if (!moduleMatch?.[1]) {
109
+ return;
110
+ }
111
+ recordRegistration(tagArg.value, moduleMatch[1]);
112
+ },
113
+ });
114
+ }
115
+ catch (parseError) {
116
+ // Best-effort scanner: if upstream syntax changes or the file is damaged,
117
+ // fall back to the old literal callback heuristic instead of failing the
118
+ // whole scan command.
119
+ const reason = parseError instanceof Error ? parseError.message : String(parseError);
120
+ warn(`AST parsing of customElements.js failed (${reason}). Falling back to regex-based heuristic. ` +
121
+ 'Results may be incomplete — run "fireforge furnace validate" to verify registration consistency.');
122
+ verbose(`Scanner regex fallback activated due to parse error: ${reason}`);
123
+ const lines = content.split('\n');
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i];
126
+ if (line === undefined)
45
127
  continue;
46
- const importMatch = /import\(\s*"([^"]+)"/.exec(importLine);
47
- if (importMatch?.[1]) {
48
- modulePath = importMatch[1];
49
- break;
128
+ const callbackMatch = /setElementCreationCallback\(\s*"([^"]+)"/.exec(line);
129
+ if (!callbackMatch?.[1])
130
+ continue;
131
+ const tagName = callbackMatch[1];
132
+ let modulePath = '';
133
+ const searchEnd = Math.min(i + 15, lines.length);
134
+ for (let j = i + 1; j < searchEnd; j++) {
135
+ const importLine = lines[j];
136
+ if (importLine === undefined)
137
+ continue;
138
+ const importMatch = /(?:import|importESModule|loadSubScript)\(\s*"([^"]+)"/.exec(importLine);
139
+ if (importMatch?.[1]) {
140
+ modulePath = importMatch[1];
141
+ break;
142
+ }
50
143
  }
144
+ registrations.set(tagName, modulePath);
51
145
  }
52
- if (!modulePath) {
53
- // No module path found in the lookahead lines; skip this entry
54
- continue;
55
- }
56
- registrations.set(tagName, modulePath);
57
146
  }
147
+ // Cache results for subsequent calls within the same process.
148
+ registrationCache = { hash: contentHash, registrations: new Map(registrations) };
58
149
  return registrations;
59
150
  }
60
151
  /**
@@ -67,37 +158,43 @@ export async function scanCustomElementsRegistrations(engineDir) {
67
158
  * @param engineDir - Path to the Firefox engine source root
68
159
  * @returns Array of discovered components
69
160
  */
70
- export async function scanWidgetsDirectory(engineDir) {
71
- const widgetsPath = join(engineDir, WIDGETS_DIR);
72
- if (!(await pathExists(widgetsPath))) {
73
- return [];
74
- }
75
- const entries = await readdir(widgetsPath, { withFileTypes: true });
161
+ export async function scanWidgetsDirectory(engineDir, ftlDir, extraPaths, componentPrefix) {
162
+ const searchPaths = [WIDGETS_DIR, ...(extraPaths ?? [])];
76
163
  const registrations = await scanCustomElementsRegistrations(engineDir);
77
164
  const components = [];
78
- for (const entry of entries) {
79
- if (!entry.isDirectory() || !entry.name.startsWith('moz-')) {
165
+ const seen = new Set();
166
+ const prefix = componentPrefix ?? 'moz-';
167
+ for (const searchDir of searchPaths) {
168
+ const dirPath = join(engineDir, searchDir);
169
+ if (!(await pathExists(dirPath))) {
80
170
  continue;
81
171
  }
82
- const tagName = entry.name;
83
- const componentDir = join(widgetsPath, tagName);
84
- const componentEntries = await readdir(componentDir, { withFileTypes: true });
85
- // Only include directories that contain a .mjs file
86
- const hasMjs = componentEntries.some((e) => e.isFile() && e.name.endsWith('.mjs'));
87
- if (!hasMjs) {
88
- continue;
172
+ const entries = await readdir(dirPath, { withFileTypes: true });
173
+ for (const entry of entries) {
174
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix) || seen.has(entry.name)) {
175
+ continue;
176
+ }
177
+ const tagName = entry.name;
178
+ seen.add(tagName);
179
+ const componentDir = join(dirPath, tagName);
180
+ const componentEntries = await readdir(componentDir, { withFileTypes: true });
181
+ // Only include directories that contain a .mjs file
182
+ const hasMjs = componentEntries.some((e) => e.isFile() && e.name.endsWith('.mjs'));
183
+ if (!hasMjs) {
184
+ continue;
185
+ }
186
+ const hasCSS = componentEntries.some((e) => e.isFile() && e.name.endsWith('.css'));
187
+ const ftlPath = join(engineDir, ftlDir ?? FTL_DIR, `${tagName}.ftl`);
188
+ const hasFTL = await pathExists(ftlPath);
189
+ const isRegistered = registrations.has(tagName);
190
+ components.push({
191
+ tagName,
192
+ sourcePath: join(searchDir, tagName),
193
+ hasCSS,
194
+ hasFTL,
195
+ isRegistered,
196
+ });
89
197
  }
90
- const hasCSS = componentEntries.some((e) => e.isFile() && e.name.endsWith('.css'));
91
- const ftlPath = join(engineDir, FTL_DIR, `${tagName}.ftl`);
92
- const hasFTL = await pathExists(ftlPath);
93
- const isRegistered = registrations.has(tagName);
94
- components.push({
95
- tagName,
96
- sourcePath: join(WIDGETS_DIR, tagName),
97
- hasCSS,
98
- hasFTL,
99
- isRegistered,
100
- });
101
198
  }
102
199
  return components;
103
200
  }
@@ -107,7 +204,7 @@ export async function scanWidgetsDirectory(engineDir) {
107
204
  * @param tagName - Component tag name (e.g., "moz-button")
108
205
  * @returns Component details, or null if not found in the source tree
109
206
  */
110
- export async function getComponentDetails(engineDir, tagName) {
207
+ export async function getComponentDetails(engineDir, tagName, ftlDir) {
111
208
  const componentDir = join(engineDir, WIDGETS_DIR, tagName);
112
209
  if (!(await pathExists(componentDir))) {
113
210
  return null;
@@ -118,7 +215,7 @@ export async function getComponentDetails(engineDir, tagName) {
118
215
  return null;
119
216
  }
120
217
  const hasCSS = entries.some((e) => e.isFile() && e.name.endsWith('.css'));
121
- const ftlPath = join(engineDir, FTL_DIR, `${tagName}.ftl`);
218
+ const ftlPath = join(engineDir, ftlDir ?? FTL_DIR, `${tagName}.ftl`);
122
219
  const hasFTL = await pathExists(ftlPath);
123
220
  const registrations = await scanCustomElementsRegistrations(engineDir);
124
221
  const isRegistered = registrations.has(tagName);
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { ensureDir, pathExists, removeDir, removeFile, writeText } from '../utils/fs.js';
5
5
  import { getProjectPaths, loadConfig } from './config.js';
6
6
  import { loadFurnaceConfig } from './furnace-config.js';
7
+ import { stripComponentPrefix } from './furnace-constants.js';
7
8
  import { DEFAULT_LICENSE, getLicenseHeader } from './license-headers.js';
8
9
  /** MPL-2.0 license header used in generated story files for Firefox-derived components */
9
10
  const MPL_LICENSE_HEADER = getLicenseHeader('MPL-2.0', 'js');
@@ -26,8 +27,8 @@ const TITLE_CATEGORIES = {
26
27
  * @param tagName - Component tag name (e.g. "moz-button")
27
28
  * @returns Display name (e.g. "Button")
28
29
  */
29
- function generateDisplayName(tagName) {
30
- const withoutPrefix = tagName.replace(/^moz-/, '');
30
+ function generateDisplayName(tagName, componentPrefix) {
31
+ const withoutPrefix = stripComponentPrefix(tagName, componentPrefix);
31
32
  return withoutPrefix
32
33
  .split('-')
33
34
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -108,6 +109,7 @@ export async function syncStories(root) {
108
109
  await ensureDir(storiesDir);
109
110
  const result = { created: [], updated: [], removed: [] };
110
111
  const expectedFiles = new Set();
112
+ const componentPrefix = config.componentPrefix;
111
113
  // --- Stock components (only create if missing) ---
112
114
  for (const tagName of config.stock) {
113
115
  const filename = `${tagName}.stories.mjs`;
@@ -116,7 +118,7 @@ export async function syncStories(root) {
116
118
  if (await pathExists(filePath)) {
117
119
  continue;
118
120
  }
119
- const displayName = generateDisplayName(tagName);
121
+ const displayName = generateDisplayName(tagName, componentPrefix);
120
122
  const content = generateStoryContent(tagName, displayName, 'stock');
121
123
  await writeText(filePath, content);
122
124
  result.created.push(filename);
@@ -127,7 +129,7 @@ export async function syncStories(root) {
127
129
  expectedFiles.add(filename);
128
130
  const filePath = join(storiesDir, filename);
129
131
  const existed = await pathExists(filePath);
130
- const displayName = generateDisplayName(name);
132
+ const displayName = generateDisplayName(name, componentPrefix);
131
133
  const content = generateStoryContent(name, displayName, 'override', customLicenseHeader);
132
134
  await writeText(filePath, content);
133
135
  if (existed) {
@@ -143,7 +145,7 @@ export async function syncStories(root) {
143
145
  expectedFiles.add(filename);
144
146
  const filePath = join(storiesDir, filename);
145
147
  const existed = await pathExists(filePath);
146
- const displayName = generateDisplayName(name);
148
+ const displayName = generateDisplayName(name, componentPrefix);
147
149
  const content = generateStoryContent(name, displayName, 'custom', customLicenseHeader);
148
150
  await writeText(filePath, content);
149
151
  if (existed) {
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasTemplateClickHandler, hasTemplateKeyboardHandler, } from './furnace-validate-helpers.js';
4
+ import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateKeyboardHandler, hasUnlabelledFormInput, } from './furnace-validate-helpers.js';
5
5
  /**
6
6
  * Validates accessibility patterns in a component's .mjs file.
7
7
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
@@ -27,6 +27,12 @@ export async function validateAccessibility(componentDir, tagName) {
27
27
  if (isInteractive && !hasDelegatesFocusEnabled(content)) {
28
28
  issues.push(createIssue(tagName, 'warning', 'no-delegates-focus', 'Interactive component without delegatesFocus in shadowRootOptions. Focus may not delegate to inner elements.'));
29
29
  }
30
+ if (hasPositiveTabindex(content)) {
31
+ issues.push(createIssue(tagName, 'warning', 'positive-tabindex', 'Positive tabindex disrupts natural tab order. Use tabindex="0" for focusable elements or tabindex="-1" for programmatic focus only.'));
32
+ }
33
+ if (hasUnlabelledFormInput(content)) {
34
+ issues.push(createIssue(tagName, 'warning', 'unlabelled-form-input', 'Form input without an accessible label. Add aria-label, aria-labelledby, or an associated <label> element.'));
35
+ }
30
36
  return issues;
31
37
  }
32
38
  //# sourceMappingURL=furnace-validate-accessibility.js.map
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Validates compatibility patterns in a component's .mjs and .css files.
4
- * Checks imports, class hierarchy, registration, and design tokens.
4
+ * Checks imports, class hierarchy, registration, design tokens, and compose references.
5
5
  */
6
6
  export declare function validateCompatibility(componentDir: string, tagName: string, type: ComponentType, config?: FurnaceConfig, root?: string): Promise<ValidationIssue[]>;
@@ -38,11 +38,76 @@ async function validateCssCompatibility(cssPath, tagName, type, config, root) {
38
38
  }
39
39
  }
40
40
  }
41
+ // Flag excessive !important usage
42
+ const importantCount = (cssContent.match(/!important/g) ?? []).length;
43
+ if (importantCount > 3) {
44
+ issues.push(createIssue(tagName, 'warning', 'excessive-important', `Found ${importantCount} uses of !important. Minimize !important to avoid specificity issues; prefer structural CSS changes.`));
45
+ }
46
+ // Check for animations without prefers-reduced-motion
47
+ if (/(?:animation|transition)\s*:/m.test(cssContent) &&
48
+ !/@media\s*\(\s*prefers-reduced-motion/m.test(rawCss)) {
49
+ issues.push(createIssue(tagName, 'warning', 'missing-reduced-motion', 'CSS uses animation or transition without a prefers-reduced-motion media query. Add one for accessibility.'));
50
+ }
51
+ // Check for prefers-color-scheme without design token usage
52
+ if (/@media\s*\(\s*prefers-color-scheme/m.test(rawCss)) {
53
+ issues.push(createIssue(tagName, 'warning', 'prefers-color-scheme', 'CSS contains a prefers-color-scheme media query. Prefer using design tokens (CSS custom properties) for theming consistency.'));
54
+ }
55
+ return issues;
56
+ }
57
+ /**
58
+ * Checks whether a tag name appears in an HTML template context (as an element
59
+ * tag in a template literal) or as a CSS tag selector. Substring matches in
60
+ * string literals, comments, or variable names are excluded.
61
+ */
62
+ function isReferencedAsElement(source, tagName) {
63
+ // Match as an HTML element: <tagName or </tagName
64
+ const htmlPattern = new RegExp(`</?${escapeForValidation(tagName)}[\\s/>]`);
65
+ if (htmlPattern.test(source))
66
+ return true;
67
+ // Match as a CSS tag selector: standalone word at start of line or after combinators
68
+ const cssPattern = new RegExp(`(?:^|[\\s,>+~])${escapeForValidation(tagName)}(?:[\\s,{:>+~.[#]|$)`, 'm');
69
+ if (cssPattern.test(source))
70
+ return true;
71
+ // Also accept querySelector/querySelectorAll('tagName') as intentional usage
72
+ const querySelectorPattern = new RegExp(`querySelector(?:All)?\\(\\s*["'\`]${escapeForValidation(tagName)}(?:[\\s"'\`.,>+~:[#])`);
73
+ if (querySelectorPattern.test(source))
74
+ return true;
75
+ return false;
76
+ }
77
+ function escapeForValidation(str) {
78
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ }
80
+ /**
81
+ * Validates that composed tags declared in furnace.json are actually referenced
82
+ * in the component's source (.mjs and .css) in an HTML template or CSS context.
83
+ * A compose entry that is not referenced as an element tag is likely a metadata
84
+ * error.
85
+ */
86
+ async function validateComposeReferences(componentDir, tagName, composes) {
87
+ if (composes.length === 0)
88
+ return [];
89
+ const issues = [];
90
+ const mjsPath = join(componentDir, `${tagName}.mjs`);
91
+ const cssPath = join(componentDir, `${tagName}.css`);
92
+ let mjsContent = '';
93
+ let cssContent = '';
94
+ if (await pathExists(mjsPath)) {
95
+ mjsContent = await readText(mjsPath);
96
+ }
97
+ if (await pathExists(cssPath)) {
98
+ cssContent = await readText(cssPath);
99
+ }
100
+ const combinedSource = mjsContent + cssContent;
101
+ for (const composed of composes) {
102
+ if (!isReferencedAsElement(combinedSource, composed)) {
103
+ issues.push(createIssue(tagName, 'warning', 'compose-not-referenced', `Declares composition of "${composed}" but no HTML template or CSS selector reference found in source files. Remove the entry from composes or add a reference.`));
104
+ }
105
+ }
41
106
  return issues;
42
107
  }
43
108
  /**
44
109
  * Validates compatibility patterns in a component's .mjs and .css files.
45
- * Checks imports, class hierarchy, registration, and design tokens.
110
+ * Checks imports, class hierarchy, registration, design tokens, and compose references.
46
111
  */
47
112
  export async function validateCompatibility(componentDir, tagName, type, config, root) {
48
113
  const issues = [];
@@ -52,6 +117,25 @@ export async function validateCompatibility(componentDir, tagName, type, config,
52
117
  issues.push(...mjsIssues);
53
118
  const cssIssues = await validateCssCompatibility(cssPath, tagName, type, config, root);
54
119
  issues.push(...cssIssues);
120
+ // Compose reference validation (custom components only)
121
+ if (type === 'custom' && config) {
122
+ const customConfig = config.custom[tagName];
123
+ if (customConfig?.composes && customConfig.composes.length > 0) {
124
+ issues.push(...(await validateComposeReferences(componentDir, tagName, customConfig.composes)));
125
+ // Warn when a composed component is not registered in furnace.json
126
+ const allKnown = new Set([
127
+ ...config.stock,
128
+ ...Object.keys(config.overrides),
129
+ ...Object.keys(config.custom),
130
+ ]);
131
+ for (const composed of customConfig.composes) {
132
+ if (!allKnown.has(composed)) {
133
+ issues.push(createIssue(tagName, 'warning', 'compose-not-registered', `Composes "${composed}" which is not registered in furnace.json. ` +
134
+ 'The dependency may be missing at runtime. Add it as stock, override, or custom.'));
135
+ }
136
+ }
137
+ }
138
+ }
55
139
  return issues;
56
140
  }
57
141
  //# sourceMappingURL=furnace-validate-compatibility.js.map
@@ -5,6 +5,10 @@ export declare function createIssue(component: string, severity: ValidationIssue
5
5
  export declare function hasAriaRole(content: string): boolean;
6
6
  /** Detects generic elements being used as custom interactive controls. */
7
7
  export declare function hasGenericInteractiveElement(content: string): boolean;
8
+ /** Detects a positive tabindex value, which disrupts natural tab order. */
9
+ export declare function hasPositiveTabindex(content: string): boolean;
10
+ /** Detects form inputs without associated labels. */
11
+ export declare function hasUnlabelledFormInput(content: string): boolean;
8
12
  /** Detects Lit-style template click handlers. */
9
13
  export declare function hasTemplateClickHandler(content: string): boolean;
10
14
  /** Detects Lit-style template keyboard handlers. */
@@ -16,6 +16,34 @@ export function hasAriaRole(content) {
16
16
  export function hasGenericInteractiveElement(content) {
17
17
  return /<(div|span)\b(?=[^>]*(?:@click|@key(?:down|press|up)|\btabindex\s*=|\.onclick\s*=))/i.test(content);
18
18
  }
19
+ /** Detects a positive tabindex value, which disrupts natural tab order. */
20
+ export function hasPositiveTabindex(content) {
21
+ const match = content.match(/tabindex\s*=\s*["']?(\d+)/g);
22
+ if (!match)
23
+ return false;
24
+ return match.some((m) => {
25
+ const value = /(\d+)/.exec(m)?.[1];
26
+ return value !== undefined && parseInt(value, 10) > 0;
27
+ });
28
+ }
29
+ /** Detects form inputs without associated labels. */
30
+ export function hasUnlabelledFormInput(content) {
31
+ // Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
32
+ // (id implies an external <label for="..."> could exist)
33
+ const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
34
+ let inputMatch;
35
+ while ((inputMatch = inputPattern.exec(content)) !== null) {
36
+ const attrs = inputMatch[2] ?? '';
37
+ if (/aria-label\s*=/.test(attrs) ||
38
+ /aria-labelledby\s*=/.test(attrs) ||
39
+ /\bid\s*=/.test(attrs) ||
40
+ /type\s*=\s*["']hidden["']/i.test(attrs)) {
41
+ continue;
42
+ }
43
+ return true;
44
+ }
45
+ return false;
46
+ }
19
47
  /** Detects Lit-style template click handlers. */
20
48
  export function hasTemplateClickHandler(content) {
21
49
  return /@click\s*=\s*\$\{/.test(content);
@@ -85,6 +113,9 @@ export function hasCustomElementDefineCall(mjsContent) {
85
113
  export function classExtendsMozLitElement(mjsContent) {
86
114
  const hasClassDeclaration = /class\s+\w+\s+extends\s+/.test(mjsContent);
87
115
  if (!hasClassDeclaration) {
116
+ // No class declaration — skip this check since the component may use a
117
+ // different pattern (e.g. function-based). Other validators will catch
118
+ // structural issues.
88
119
  return true;
89
120
  }
90
121
  return /class\s+\w+\s+extends\s+MozLitElement\b/.test(mjsContent);