@hominis/fireforge 0.32.0 → 0.34.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 (81) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/src/commands/build.js +9 -1
  3. package/dist/src/commands/doctor-furnace-jar.d.ts +16 -0
  4. package/dist/src/commands/doctor-furnace-jar.js +47 -0
  5. package/dist/src/commands/doctor-furnace.js +2 -0
  6. package/dist/src/commands/export-flow.js +4 -1
  7. package/dist/src/commands/export-placement-gate.js +4 -1
  8. package/dist/src/commands/export.js +61 -4
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +20 -0
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +52 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +10 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +31 -4
  13. package/dist/src/commands/furnace/create-browser-test.d.ts +30 -0
  14. package/dist/src/commands/furnace/create-browser-test.js +180 -0
  15. package/dist/src/commands/furnace/create-mochikit.js +11 -4
  16. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  17. package/dist/src/commands/furnace/create-xpcshell.js +38 -12
  18. package/dist/src/commands/furnace/create.js +7 -114
  19. package/dist/src/commands/furnace/index.js +6 -1
  20. package/dist/src/commands/furnace/scan.d.ts +1 -0
  21. package/dist/src/commands/furnace/scan.js +57 -27
  22. package/dist/src/commands/furnace/validate.js +17 -1
  23. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  24. package/dist/src/commands/patch/split-plan.js +90 -16
  25. package/dist/src/commands/patch/split.js +12 -3
  26. package/dist/src/commands/re-export-options.d.ts +26 -0
  27. package/dist/src/commands/re-export-options.js +48 -1
  28. package/dist/src/commands/re-export.js +4 -1
  29. package/dist/src/commands/register.js +10 -1
  30. package/dist/src/commands/test-diagnose.js +12 -0
  31. package/dist/src/commands/test.js +29 -22
  32. package/dist/src/commands/token.js +12 -1
  33. package/dist/src/commands/typecheck.js +35 -0
  34. package/dist/src/core/build-prepare.js +23 -3
  35. package/dist/src/core/config-validate.js +26 -0
  36. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  37. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  38. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  39. package/dist/src/core/furnace-apply-ftl.js +97 -1
  40. package/dist/src/core/furnace-apply-helpers.js +10 -80
  41. package/dist/src/core/furnace-registration.d.ts +24 -0
  42. package/dist/src/core/furnace-registration.js +56 -3
  43. package/dist/src/core/furnace-validate-registration.js +18 -0
  44. package/dist/src/core/mach-resource-shim.d.ts +58 -0
  45. package/dist/src/core/mach-resource-shim.js +290 -0
  46. package/dist/src/core/mach.d.ts +51 -10
  47. package/dist/src/core/mach.js +76 -25
  48. package/dist/src/core/manifest-helpers.d.ts +13 -0
  49. package/dist/src/core/manifest-helpers.js +29 -4
  50. package/dist/src/core/manifest-rules.d.ts +13 -3
  51. package/dist/src/core/manifest-rules.js +44 -17
  52. package/dist/src/core/patch-export.d.ts +10 -0
  53. package/dist/src/core/patch-export.js +23 -2
  54. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  55. package/dist/src/core/patch-lint-cross.js +83 -63
  56. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  57. package/dist/src/core/patch-lint-reexports.js +1 -1
  58. package/dist/src/core/register-module.d.ts +1 -1
  59. package/dist/src/core/register-module.js +15 -2
  60. package/dist/src/core/register-result.d.ts +9 -0
  61. package/dist/src/core/register-scaffold.d.ts +55 -0
  62. package/dist/src/core/register-scaffold.js +146 -0
  63. package/dist/src/core/register-test-manifest.js +3 -1
  64. package/dist/src/core/register-xpcshell-test.d.ts +30 -0
  65. package/dist/src/core/register-xpcshell-test.js +96 -0
  66. package/dist/src/core/test-harness-crash.d.ts +32 -4
  67. package/dist/src/core/test-harness-crash.js +152 -14
  68. package/dist/src/core/token-dark-mode.d.ts +9 -0
  69. package/dist/src/core/token-dark-mode.js +1 -1
  70. package/dist/src/core/token-docs.d.ts +32 -0
  71. package/dist/src/core/token-docs.js +101 -0
  72. package/dist/src/core/token-manager.d.ts +8 -0
  73. package/dist/src/core/token-manager.js +77 -95
  74. package/dist/src/core/token-variant.d.ts +39 -0
  75. package/dist/src/core/token-variant.js +141 -0
  76. package/dist/src/core/typecheck.js +56 -28
  77. package/dist/src/core/xpcshell-appdir.d.ts +7 -0
  78. package/dist/src/core/xpcshell-appdir.js +12 -3
  79. package/dist/src/types/commands/options.d.ts +17 -0
  80. package/dist/src/types/config.d.ts +13 -0
  81. package/package.json +4 -4
@@ -5,8 +5,15 @@ import { pathExists, readText } from '../utils/fs.js';
5
5
  import { escapeRegex } from '../utils/regex.js';
6
6
  import { getProjectPaths, loadConfig } from './config.js';
7
7
  import { registerBrowserContent, registerFireForgeModule, registerSharedCSS, registerTestManifest, registerToolkitWidget, } from './manifest-register.js';
8
- /** Returns manifest registration rules for the supported engine file patterns. */
9
- export function getRules(binaryName) {
8
+ import { isXpcshellTestRegistered, registerXpcshellTest } from './register-xpcshell-test.js';
9
+ /**
10
+ * Returns manifest registration rules for the supported engine file
11
+ * patterns. `createManifest` (from `register --create-manifest`) lets the
12
+ * module and xpcshell-test rules scaffold a missing manifest (directory
13
+ * moz.build / xpcshell.toml) and wire the parent chain instead of failing
14
+ * with "Manifest not found".
15
+ */
16
+ export function getRules(binaryName, createManifest = false) {
10
17
  const moduleDir = `browser/modules/${binaryName}`;
11
18
  return [
12
19
  {
@@ -39,7 +46,10 @@ export function getRules(binaryName) {
39
46
  extractArgs: (m) => [m[1] ?? ''],
40
47
  },
41
48
  {
42
- pattern: /^browser\/base\/content\/test\/([^/]+)\/browser\.toml$/,
49
+ // Fork-owned browser.toml manifests at ARBITRARY depth under
50
+ // browser/base/content/test/ (0.34.0 field report: only one level
51
+ // was supported, rejecting nested browser-chrome manifests).
52
+ pattern: /^browser\/base\/content\/test\/((?:[^/]+\/)*[^/]+)\/browser\.toml$/,
43
53
  isRegistered: (_engineDir, testDir) => isTestManifestRegistered(_engineDir, testDir),
44
54
  register: (_engineDir, _after, dryRun, testDir) => registerTestManifest(_engineDir, testDir, dryRun),
45
55
  extractArgs: (m) => [m[1] ?? ''],
@@ -47,7 +57,7 @@ export function getRules(binaryName) {
47
57
  {
48
58
  pattern: new RegExp(`^browser/modules/${escapeRegex(binaryName)}/(.+\\.sys\\.mjs)$`),
49
59
  isRegistered: (_engineDir, fileName) => isFireForgeModuleRegistered(_engineDir, fileName, moduleDir),
50
- register: (_engineDir, _after, dryRun, fileName) => registerFireForgeModule(_engineDir, fileName, moduleDir, dryRun),
60
+ register: (_engineDir, _after, dryRun, fileName) => registerFireForgeModule(_engineDir, fileName, moduleDir, dryRun, createManifest),
51
61
  extractArgs: (m) => [m[1] ?? ''],
52
62
  },
53
63
  {
@@ -56,6 +66,20 @@ export function getRules(binaryName) {
56
66
  register: (_engineDir, _after, dryRun, tagName, fileName) => registerToolkitWidget(_engineDir, tagName, fileName, dryRun),
57
67
  extractArgs: (m) => [m[1] ?? '', m[2] ?? ''],
58
68
  },
69
+ {
70
+ // xpcshell test files (0.34.0 field report: rejected as "Unknown
71
+ // file pattern"). Any `test_*.js` outside browser/base/content/test/
72
+ // (which stays browser-chrome territory with its browser.toml
73
+ // guidance) registers into the directory's xpcshell.toml; with
74
+ // --create-manifest a missing manifest is scaffolded and wired via
75
+ // XPCSHELL_TESTS_MANIFESTS.
76
+ pattern: /^(?!browser\/base\/content\/test\/)(.+)\/(test_[^/]+\.js)$/,
77
+ isRegistered: (engineDir, dirRel, fileName) => isXpcshellTestRegistered(engineDir, dirRel, fileName),
78
+ register: (engineDir, _after, dryRun, dirRel, fileName) => registerXpcshellTest(engineDir, dirRel, fileName, dryRun, createManifest),
79
+ // Both groups are guaranteed by the pattern; slice avoids dead
80
+ // fallback branches.
81
+ extractArgs: (m) => m.slice(1, 3),
82
+ },
59
83
  ];
60
84
  }
61
85
  async function isSharedCSSRegistered(engineDir, fileName) {
@@ -149,7 +173,8 @@ function getUnregistrableAdvice(filePath) {
149
173
  return (`xpcshell.toml manifests are not auto-registered by "fireforge register". ` +
150
174
  `Add "XPCSHELL_TESTS_MANIFESTS += ['${basename(filePath)}']" to the ` +
151
175
  `moz.build that covers ${filePath.replace(/\/xpcshell\.toml$/, '/')}, ` +
152
- 'or let `furnace create --test-style xpcshell` print the warning that names the canonical wiring location. ' +
176
+ 'or register one of its test files with "fireforge register <path>/test_*.js --create-manifest", ' +
177
+ 'which scaffolds the manifest and wires XPCSHELL_TESTS_MANIFESTS automatically. ' +
153
178
  'Browser-chrome tests (browser.toml) are auto-registered via "fireforge register <path>/browser.toml".');
154
179
  }
155
180
  const testMatch = filePath.match(/^browser\/base\/content\/test\/([^/]+)\/(?!browser\.toml$).+$/);
@@ -183,12 +208,17 @@ export async function isFileRegistered(root, filePath) {
183
208
  if (advice) {
184
209
  throw new InvalidArgumentError(advice, 'path');
185
210
  }
186
- throw new InvalidArgumentError(`Unknown file pattern: "${normalizedPath}". Supported patterns:\n` +
211
+ throw new InvalidArgumentError(buildUnknownPatternMessage(normalizedPath, config.binaryName), 'path');
212
+ }
213
+ /** Shared "Unknown file pattern" message naming every supported pattern. */
214
+ function buildUnknownPatternMessage(normalizedPath, binaryName) {
215
+ return (`Unknown file pattern: "${normalizedPath}". Supported patterns:\n` +
187
216
  ' browser/themes/shared/*.css\n' +
188
217
  ' browser/base/content/*.{js,mjs,xhtml,css}\n' +
189
- ' browser/base/content/test/*/browser.toml\n' +
190
- ` browser/modules/${config.binaryName}/*.sys.mjs\n` +
191
- ' toolkit/content/widgets/*/*.{mjs,css}', 'path');
218
+ ' browser/base/content/test/**/browser.toml\n' +
219
+ ` browser/modules/${binaryName}/*.sys.mjs\n` +
220
+ ' toolkit/content/widgets/*/*.{mjs,css}\n' +
221
+ ' **/test_*.js (xpcshell test files; registers into the nearest xpcshell.toml)');
192
222
  }
193
223
  /**
194
224
  * Registers a file in the appropriate build manifest.
@@ -197,12 +227,14 @@ export async function isFileRegistered(root, filePath) {
197
227
  * @param filePath - Path relative to engine/
198
228
  * @param dryRun - If true, return what would be done without writing
199
229
  * @param after - Optional substring to place entry after (instead of alphabetical)
230
+ * @param options - `createManifest` scaffolds a missing manifest (directory
231
+ * moz.build / xpcshell.toml) and wires the parent chain instead of failing
200
232
  * @returns Registration result
201
233
  */
202
- export async function registerFile(root, filePath, dryRun = false, after) {
234
+ export async function registerFile(root, filePath, dryRun = false, after, options = {}) {
203
235
  const { engine: engineDir } = getProjectPaths(root);
204
236
  const config = await loadConfig(root);
205
- const rules = getRules(config.binaryName);
237
+ const rules = getRules(config.binaryName, options.createManifest === true);
206
238
  // Normalize path separators
207
239
  const normalizedPath = filePath.replace(/\\/g, '/');
208
240
  for (const rule of rules) {
@@ -216,11 +248,6 @@ export async function registerFile(root, filePath, dryRun = false, after) {
216
248
  if (advice) {
217
249
  throw new InvalidArgumentError(advice, 'path');
218
250
  }
219
- throw new InvalidArgumentError(`Unknown file pattern: "${normalizedPath}". Supported patterns:\n` +
220
- ' browser/themes/shared/*.css\n' +
221
- ' browser/base/content/*.{js,mjs,xhtml,css}\n' +
222
- ' browser/base/content/test/*/browser.toml\n' +
223
- ` browser/modules/${config.binaryName}/*.sys.mjs\n` +
224
- ' toolkit/content/widgets/*/*.{mjs,css}', 'path');
251
+ throw new InvalidArgumentError(buildUnknownPatternMessage(normalizedPath, config.binaryName), 'path');
225
252
  }
226
253
  //# sourceMappingURL=manifest-rules.js.map
@@ -20,6 +20,16 @@ export declare function getNextPatchNumber(patchesDir: string): Promise<string>;
20
20
  * to a slug `export` could never reach.
21
21
  */
22
22
  export declare function sanitizeName(name: string): string;
23
+ /**
24
+ * Strips a leading `NNN-<category>-` prefix from a sanitized name slug.
25
+ * Operators frequently pass the DESIRED FILENAME stem to `--name`
26
+ * (`--name 203-ui-foo --category ui`), which 0.33.0 double-prefixed into
27
+ * `203-ui-203-ui-foo.patch`. The filename builders prepend the order and
28
+ * category themselves, so a matching prefix in the name is always
29
+ * redundant. Applied repeatedly so a twice-prefixed slug (from a previous
30
+ * double-prefix incident) also collapses. Exported for direct testing.
31
+ */
32
+ export declare function stripRedundantCategoryPrefix(sanitizedName: string, category: string): string;
23
33
  /**
24
34
  * Generates the next patch filename with category.
25
35
  * @param patchesDir - Path to the patches directory
@@ -46,6 +46,27 @@ export function sanitizeName(name) {
46
46
  .replace(/^-+|-+$/g, '')
47
47
  .slice(0, 50);
48
48
  }
49
+ /**
50
+ * Strips a leading `NNN-<category>-` prefix from a sanitized name slug.
51
+ * Operators frequently pass the DESIRED FILENAME stem to `--name`
52
+ * (`--name 203-ui-foo --category ui`), which 0.33.0 double-prefixed into
53
+ * `203-ui-203-ui-foo.patch`. The filename builders prepend the order and
54
+ * category themselves, so a matching prefix in the name is always
55
+ * redundant. Applied repeatedly so a twice-prefixed slug (from a previous
56
+ * double-prefix incident) also collapses. Exported for direct testing.
57
+ */
58
+ export function stripRedundantCategoryPrefix(sanitizedName, category) {
59
+ const prefix = new RegExp(`^\\d+-${category}-`);
60
+ let stripped = sanitizedName;
61
+ while (prefix.test(stripped)) {
62
+ stripped = stripped.replace(prefix, '');
63
+ }
64
+ return stripped.length > 0 ? stripped : sanitizedName;
65
+ }
66
+ /** Sanitizes a patch name and drops a redundant `NNN-<category>-` prefix. */
67
+ function patchNameSlug(name, category) {
68
+ return stripRedundantCategoryPrefix(sanitizeName(name), category);
69
+ }
49
70
  /**
50
71
  * Generates the next patch filename with category.
51
72
  * @param patchesDir - Path to the patches directory
@@ -55,7 +76,7 @@ export function sanitizeName(name) {
55
76
  */
56
77
  export async function getNextPatchFilename(patchesDir, category, name) {
57
78
  const patchNumber = await getNextPatchNumber(patchesDir);
58
- const sanitizedName = sanitizeName(name);
79
+ const sanitizedName = patchNameSlug(name, category);
59
80
  return `${patchNumber}-${category}-${sanitizedName}.patch`;
60
81
  }
61
82
  /**
@@ -253,7 +274,7 @@ async function computeExportPlanUnderLock(input) {
253
274
  ? allocatePolicyOrder(input.config, manifestBefore?.patches ?? [], input.category)
254
275
  : null;
255
276
  const patchFilename = policyOrder !== null
256
- ? `${String(policyOrder).padStart(3, '0')}-${input.category}-${sanitizeName(input.name)}.patch`
277
+ ? `${String(policyOrder).padStart(3, '0')}-${input.category}-${patchNameSlug(input.name, input.category)}.patch`
257
278
  : await getNextPatchFilename(input.patchesDir, input.category, input.name);
258
279
  const patchPath = join(input.patchesDir, patchFilename);
259
280
  const metadata = {
@@ -180,6 +180,37 @@ export declare function findForwardImportIgnoreLines(source: string): Set<number
180
180
  * engine file — not our concern).
181
181
  * - Imports on a line suppressed by the ignore marker.
182
182
  */
183
+ /** One discovered forward-import edge: a site importing a later-created file. */
184
+ export interface ForwardImportEdge {
185
+ /** Importing patch filename. */
186
+ entry: string;
187
+ /** Importing file path relative to engine/. */
188
+ sitePath: string;
189
+ /** Exact import specifier as it appears in source. */
190
+ specifier: string;
191
+ /** Specifier with any query/hash stripped (used for fingerprints). */
192
+ cleaned: string;
193
+ /** Later-ordered patch creating the imported file. */
194
+ owner: string;
195
+ /** Path of the later-created file (relative to engine/). */
196
+ creates: string;
197
+ }
198
+ /**
199
+ * Returns every forward-import edge in the queue, one per (site, later
200
+ * creator) pair, regardless of any staged-dependency declarations. Used by
201
+ * `patch split` to discover the forward edges it introduces into the new
202
+ * patch so it can auto-declare them (and so its projected lint matches the
203
+ * real per-patch gate).
204
+ */
205
+ export declare function collectForwardImportEdges(ctx: PatchQueueContext): ForwardImportEdge[];
206
+ /**
207
+ * Cross-patch lint rule: flag imports of a module a later-ordered patch is
208
+ * responsible for creating, unless the patch declares an exact staged
209
+ * forward-import for it. Also warns on staged-dependency declarations that
210
+ * never matched (stale). Matching is conservative — by basename, not by
211
+ * resolving `resource://`/`chrome://` URLs — with an inline ignore marker as
212
+ * an escape hatch for unrelated basename collisions.
213
+ */
183
214
  export declare function lintPatchQueueForwardImports(ctx: PatchQueueContext): PatchLintIssue[];
184
215
  /**
185
216
  * Cross-patch lint orchestrator. Runs every cross-patch rule against the
@@ -343,27 +343,8 @@ function findMatchingStagedDependency(entry, sitePath, specifier, laterOwners) {
343
343
  laterOwners.some((owner) => owner.fullPath === dependency.creates &&
344
344
  (dependency.owner === undefined || dependency.owner === owner.filename)));
345
345
  }
346
- /**
347
- * Cross-patch lint rule: a patch imports a module that a later patch is
348
- * responsible for creating.
349
- *
350
- * Approach is deliberately conservative — we do not resolve `resource://`
351
- * URLs to engine file paths. Instead we build a cross-queue index of
352
- * newly-created files keyed by their basename, and flag imports whose leaf
353
- * matches an entry owned by a later-ordered patch. False positives from
354
- * unrelated basename collisions (two different directories happening to
355
- * create files named `Helper.sys.mjs`) are possible; the README documents
356
- * the limitation and the inline ignore marker above provides an escape
357
- * hatch.
358
- *
359
- * Rules out:
360
- * - Imports whose leaf matches a newly-created file in the *same* or an
361
- * *earlier* patch (legitimate use).
362
- * - Imports whose leaf is not in the new-file index at all (pre-existing
363
- * engine file — not our concern).
364
- * - Imports on a line suppressed by the ignore marker.
365
- */
366
- export function lintPatchQueueForwardImports(ctx) {
346
+ /** Builds the basename → later-creators index used by the forward-import scan. */
347
+ function buildNewFileIndex(ctx) {
367
348
  const newFileIndex = new Map();
368
349
  for (const entry of ctx.entries) {
369
350
  for (const fullPath of entry.newFiles.keys()) {
@@ -376,23 +357,24 @@ export function lintPatchQueueForwardImports(ctx) {
376
357
  owners.push({ filename: entry.filename, order: entry.order, fullPath });
377
358
  }
378
359
  }
379
- const issues = [];
380
- const usedStagedDeclarations = new Set();
381
- // Runs the forward-import check against one source site — either a file
382
- // the patch creates (`content` = full file) or a file the patch modifies
383
- // (`content` = concatenated added lines only). We deliberately scan added
384
- // lines rather than the full resulting file for modifications: we only
385
- // want to flag imports *this patch introduces*, not imports that already
386
- // exist on HEAD and happen to match a later-created file by coincidence.
360
+ return newFileIndex;
361
+ }
362
+ /**
363
+ * Visits every forward-import site (an import whose leaf is created by a
364
+ * later-ordered patch). Shared by {@link lintPatchQueueForwardImports} (which
365
+ * resolves/reports each) and {@link collectForwardImportEdges} (which returns
366
+ * them structurally). We scan a created file's full body and a modified
367
+ * file's added lines only flagging imports *this patch introduces*, not
368
+ * pre-existing HEAD imports that happen to collide with a later-created file.
369
+ */
370
+ function eachForwardImportSite(ctx, newFileIndex, visit) {
387
371
  const checkSite = (entry, sitePath, content) => {
388
372
  if (!isForwardImportableFile(sitePath))
389
373
  return;
390
374
  const ignoreLines = findForwardImportIgnoreLines(content);
391
- const extracted = extractImportSpecifiersWithLines(content);
392
- for (const { specifier, line } of extracted) {
375
+ for (const { specifier, line } of extractImportSpecifiersWithLines(content)) {
393
376
  if (ignoreLines.has(line))
394
377
  continue;
395
- // Take the leaf and strip query/hash if any.
396
378
  const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
397
379
  const leaf = basename(cleaned);
398
380
  if (!leaf || !isForwardImportableFile(leaf))
@@ -400,41 +382,11 @@ export function lintPatchQueueForwardImports(ctx) {
400
382
  const owners = newFileIndex.get(leaf);
401
383
  if (!owners)
402
384
  continue;
403
- // Is the owner a later-ordered patch (or one ordered equal but
404
- // lexicographically later as a tiebreaker)?
405
385
  const laterOwners = owners.filter((owner) => owner.order > entry.order ||
406
386
  (owner.order === entry.order && owner.filename > entry.filename));
407
387
  if (laterOwners.length === 0)
408
388
  continue;
409
- const stagedDependency = findMatchingStagedDependency(entry, sitePath, specifier, laterOwners);
410
- if (stagedDependency) {
411
- usedStagedDeclarations.add(stagedDependencyKey(entry, stagedDependency));
412
- continue;
413
- }
414
- const ownersSummary = laterOwners
415
- .map((o) => `${o.filename} (creates ${o.fullPath})`)
416
- .join(', ');
417
- const fingerprintOwners = [...laterOwners]
418
- .map((o) => `${o.filename}:${o.fullPath}`)
419
- .sort((a, b) => a.localeCompare(b))
420
- .join(',');
421
- // Lowest ordinal that lands AFTER every later-ordered creator —
422
- // turns the operator's "guess and re-run" loop into a single shot
423
- // when the only fix is reordering.
424
- const suggestedOrder = Math.max(...laterOwners.map((o) => o.order)) + 1;
425
- issues.push({
426
- file: sitePath,
427
- check: 'forward-import',
428
- fingerprint: `forward-import|${sitePath}|${cleaned}|${fingerprintOwners}`,
429
- message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
430
- `but the matching new file is created by a later patch: ${ownersSummary}. ` +
431
- 'Reorder the patches so the dependency is created first, move the import ' +
432
- 'into the later patch, declare the intentional staged dependency with ' +
433
- '"fireforge patch staged-dependency --add", or mark the import with ' +
434
- `"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive. ` +
435
- `Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`,
436
- severity: 'error',
437
- });
389
+ visit(entry, sitePath, specifier, cleaned, laterOwners);
438
390
  }
439
391
  };
440
392
  for (const entry of ctx.entries) {
@@ -443,6 +395,74 @@ export function lintPatchQueueForwardImports(ctx) {
443
395
  for (const [path, added] of entry.modifiedFileAdditions)
444
396
  checkSite(entry, path, added);
445
397
  }
398
+ }
399
+ /**
400
+ * Returns every forward-import edge in the queue, one per (site, later
401
+ * creator) pair, regardless of any staged-dependency declarations. Used by
402
+ * `patch split` to discover the forward edges it introduces into the new
403
+ * patch so it can auto-declare them (and so its projected lint matches the
404
+ * real per-patch gate).
405
+ */
406
+ export function collectForwardImportEdges(ctx) {
407
+ const newFileIndex = buildNewFileIndex(ctx);
408
+ const edges = [];
409
+ eachForwardImportSite(ctx, newFileIndex, (entry, sitePath, specifier, cleaned, laterOwners) => {
410
+ for (const owner of laterOwners) {
411
+ edges.push({
412
+ entry: entry.filename,
413
+ sitePath,
414
+ specifier,
415
+ cleaned,
416
+ owner: owner.filename,
417
+ creates: owner.fullPath,
418
+ });
419
+ }
420
+ });
421
+ return edges;
422
+ }
423
+ /**
424
+ * Cross-patch lint rule: flag imports of a module a later-ordered patch is
425
+ * responsible for creating, unless the patch declares an exact staged
426
+ * forward-import for it. Also warns on staged-dependency declarations that
427
+ * never matched (stale). Matching is conservative — by basename, not by
428
+ * resolving `resource://`/`chrome://` URLs — with an inline ignore marker as
429
+ * an escape hatch for unrelated basename collisions.
430
+ */
431
+ export function lintPatchQueueForwardImports(ctx) {
432
+ const newFileIndex = buildNewFileIndex(ctx);
433
+ const issues = [];
434
+ const usedStagedDeclarations = new Set();
435
+ eachForwardImportSite(ctx, newFileIndex, (entry, sitePath, specifier, cleaned, laterOwners) => {
436
+ const stagedDependency = findMatchingStagedDependency(entry, sitePath, specifier, laterOwners);
437
+ if (stagedDependency) {
438
+ usedStagedDeclarations.add(stagedDependencyKey(entry, stagedDependency));
439
+ return;
440
+ }
441
+ const ownersSummary = laterOwners
442
+ .map((o) => `${o.filename} (creates ${o.fullPath})`)
443
+ .join(', ');
444
+ const fingerprintOwners = [...laterOwners]
445
+ .map((o) => `${o.filename}:${o.fullPath}`)
446
+ .sort((a, b) => a.localeCompare(b))
447
+ .join(',');
448
+ // Lowest ordinal that lands AFTER every later-ordered creator —
449
+ // turns the operator's "guess and re-run" loop into a single shot
450
+ // when the only fix is reordering.
451
+ const suggestedOrder = Math.max(...laterOwners.map((o) => o.order)) + 1;
452
+ issues.push({
453
+ file: sitePath,
454
+ check: 'forward-import',
455
+ fingerprint: `forward-import|${sitePath}|${cleaned}|${fingerprintOwners}`,
456
+ message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
457
+ `but the matching new file is created by a later patch: ${ownersSummary}. ` +
458
+ 'Reorder the patches so the dependency is created first, move the import ' +
459
+ 'into the later patch, declare the intentional staged dependency with ' +
460
+ '"fireforge patch staged-dependency --add", or mark the import with ' +
461
+ `"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive. ` +
462
+ `Closest legal ordinal that satisfies this dependency: ${suggestedOrder}.`,
463
+ severity: 'error',
464
+ });
465
+ });
446
466
  for (const entry of ctx.entries) {
447
467
  for (const dependency of entry.metadata?.stagedDependencies?.forwardImports ?? []) {
448
468
  if (usedStagedDeclarations.has(stagedDependencyKey(entry, dependency)))
@@ -2,5 +2,5 @@
2
2
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
3
3
  * orchestrator stays within the ESLint `max-lines` budget.
4
4
  */
5
- export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
5
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, type ForwardImportEdge, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
@@ -3,6 +3,6 @@
3
3
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
4
4
  * orchestrator stays within the ESLint `max-lines` budget.
5
5
  */
6
- export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
6
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
7
7
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
8
8
  //# sourceMappingURL=patch-lint-reexports.js.map
@@ -8,4 +8,4 @@ import type { RegisterResult } from './register-result.js';
8
8
  * Entry format:
9
9
  * "{name}.sys.mjs",
10
10
  */
11
- export declare function registerFireForgeModule(engineDir: string, fileName: string, moduleDir: string, dryRun?: boolean): Promise<RegisterResult>;
11
+ export declare function registerFireForgeModule(engineDir: string, fileName: string, moduleDir: string, dryRun?: boolean, createManifest?: boolean): Promise<RegisterResult>;
@@ -8,6 +8,7 @@ import { pathExists, readText, writeText } from '../utils/fs.js';
8
8
  import { findAlphabeticalMozBuildPosition, findAlphabeticalPosition } from './manifest-helpers.js';
9
9
  import { tokenizeMozBuildList } from './manifest-tokenizers.js';
10
10
  import { withParserFallback } from './parser-fallback.js';
11
+ import { scaffoldModuleMozBuild } from './register-scaffold.js';
11
12
  /**
12
13
  * Tokenizer-based implementation for module registration.
13
14
  */
@@ -55,11 +56,23 @@ function legacyRegisterFireForgeModule(content, fileName, entry, moduleDir) {
55
56
  * Entry format:
56
57
  * "{name}.sys.mjs",
57
58
  */
58
- export async function registerFireForgeModule(engineDir, fileName, moduleDir, dryRun = false) {
59
+ export async function registerFireForgeModule(engineDir, fileName, moduleDir, dryRun = false, createManifest = false) {
59
60
  const manifest = `${moduleDir}/moz.build`;
60
61
  const manifestPath = join(engineDir, manifest);
61
62
  if (!(await pathExists(manifestPath))) {
62
- throw new GeneralError(`Manifest not found: ${manifest}`);
63
+ if (createManifest) {
64
+ // 0.34.0 field report: registering a module under a directory with
65
+ // no moz.build failed with "Manifest not found". Scaffold the
66
+ // directory manifest and wire the parent DIRS chain.
67
+ const scaffoldActions = await scaffoldModuleMozBuild(engineDir, moduleDir, fileName, dryRun);
68
+ return {
69
+ manifest,
70
+ entry: ` "${fileName}",`,
71
+ skipped: false,
72
+ scaffoldActions,
73
+ };
74
+ }
75
+ throw new GeneralError(`Manifest not found: ${manifest}. Pass --create-manifest to scaffold it and wire the parent DIRS entry.`);
63
76
  }
64
77
  const entry = ` "${fileName}",`.replace(/\\/g, '/');
65
78
  const content = await readText(manifestPath);
@@ -18,4 +18,13 @@ export interface RegisterResult {
18
18
  skipped: boolean;
19
19
  /** Whether --after target was not found and fell back to alphabetical */
20
20
  afterFallback?: boolean | undefined;
21
+ /**
22
+ * Manifests created or wired by `--create-manifest` scaffolding
23
+ * (directory moz.build, parent DIRS chain, XPCSHELL_TESTS_MANIFESTS),
24
+ * for user display.
25
+ */
26
+ scaffoldActions?: {
27
+ manifest: string;
28
+ change: string;
29
+ }[] | undefined;
21
30
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Manifest scaffolding for `fireforge register --create-manifest`
3
+ * (0.34.0 field report): registering a module under a directory with no
4
+ * moz.build used to fail with "Manifest not found" because `register`
5
+ * only inserts into EXISTING manifests. This module owns the two
6
+ * scaffolding primitives:
7
+ *
8
+ * - creating a directory `moz.build` (license header + the requested
9
+ * list directive) and wiring the parent chain's `DIRS` entries up to
10
+ * the nearest existing moz.build;
11
+ * - wiring an `XPCSHELL_TESTS_MANIFESTS` entry for a (possibly freshly
12
+ * created) `xpcshell.toml` into the nearest existing moz.build.
13
+ *
14
+ * All writers reuse the tokenizer + alphabetical-position helpers the
15
+ * existing register writers use, so inserted entries respect mozbuild's
16
+ * case-insensitive StrictOrderingOnAppendList rule.
17
+ */
18
+ /** One manifest mutation performed (or planned, on dry-run) by a scaffold. */
19
+ export interface ScaffoldAction {
20
+ /** Engine-relative manifest path that was created or modified. */
21
+ manifest: string;
22
+ /** Human-readable description of the change. */
23
+ change: string;
24
+ }
25
+ /**
26
+ * Inserts `value` into the named moz.build list directive, creating the
27
+ * `NAME += [ ... ]` block at the end of the file when the directive does
28
+ * not exist yet. Returns the updated content, or null when the value is
29
+ * already present.
30
+ */
31
+ export declare function upsertMozBuildListEntry(content: string, directive: string, value: string): string | null;
32
+ /**
33
+ * Ensures every parent directory between `childRelDir` and the nearest
34
+ * existing moz.build carries a `DIRS` entry pointing at the next segment
35
+ * down, creating intermediate moz.build files as needed. Walks upward
36
+ * from the child's parent; throws when no moz.build exists anywhere up
37
+ * to the engine root (an engine checkout always has one at the root, so
38
+ * this indicates a bogus path).
39
+ */
40
+ export declare function ensureParentDirsWiring(engineDir: string, childRelDir: string, dryRun: boolean): Promise<ScaffoldAction[]>;
41
+ /**
42
+ * Creates `<moduleDir>/moz.build` with an `EXTRA_JS_MODULES.<namespace>`
43
+ * list containing `fileName`, and wires the parent DIRS chain. The
44
+ * namespace is the module directory's basename (the fork's binary name
45
+ * for `browser/modules/<binaryName>/`), matching Firefox's convention of
46
+ * mapping `EXTRA_JS_MODULES.<ns>` into `resource:///modules/<ns>/`.
47
+ */
48
+ export declare function scaffoldModuleMozBuild(engineDir: string, moduleDir: string, fileName: string, dryRun: boolean): Promise<ScaffoldAction[]>;
49
+ /**
50
+ * Wires an `XPCSHELL_TESTS_MANIFESTS` entry for `manifestRelPath` (an
51
+ * engine-relative `.../xpcshell.toml`) into the nearest EXISTING
52
+ * moz.build above it. Returns the performed actions (empty when the
53
+ * entry already exists).
54
+ */
55
+ export declare function ensureXpcshellManifestWiring(engineDir: string, manifestRelPath: string, dryRun: boolean): Promise<ScaffoldAction[]>;