@hominis/fireforge 0.32.0 → 0.33.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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  3. package/dist/src/commands/patch/split-plan.js +90 -16
  4. package/dist/src/commands/patch/split.js +12 -3
  5. package/dist/src/commands/token.js +12 -1
  6. package/dist/src/commands/typecheck.js +35 -0
  7. package/dist/src/core/build-prepare.js +23 -3
  8. package/dist/src/core/config-validate.js +26 -0
  9. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  10. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  11. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  12. package/dist/src/core/furnace-apply-ftl.js +97 -1
  13. package/dist/src/core/furnace-apply-helpers.js +10 -80
  14. package/dist/src/core/mach-resource-shim.d.ts +21 -0
  15. package/dist/src/core/mach-resource-shim.js +92 -0
  16. package/dist/src/core/mach.js +9 -2
  17. package/dist/src/core/manifest-helpers.js +29 -4
  18. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  19. package/dist/src/core/patch-lint-cross.js +83 -63
  20. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  21. package/dist/src/core/patch-lint-reexports.js +1 -1
  22. package/dist/src/core/test-harness-crash.d.ts +6 -3
  23. package/dist/src/core/test-harness-crash.js +32 -4
  24. package/dist/src/core/token-dark-mode.d.ts +9 -0
  25. package/dist/src/core/token-dark-mode.js +1 -1
  26. package/dist/src/core/token-docs.d.ts +32 -0
  27. package/dist/src/core/token-docs.js +101 -0
  28. package/dist/src/core/token-manager.d.ts +8 -0
  29. package/dist/src/core/token-manager.js +77 -95
  30. package/dist/src/core/token-variant.d.ts +39 -0
  31. package/dist/src/core/token-variant.js +141 -0
  32. package/dist/src/core/typecheck.js +56 -28
  33. package/dist/src/types/commands/options.d.ts +5 -0
  34. package/dist/src/types/config.d.ts +13 -0
  35. package/package.json +3 -3
@@ -11,10 +11,106 @@
11
11
  */
12
12
  import { join, relative } from 'node:path';
13
13
  import { toError } from '../utils/errors.js';
14
- import { copyFile, pathExists } from '../utils/fs.js';
14
+ import { copyFile, pathExists, readText } from '../utils/fs.js';
15
+ import { escapeRegex } from '../utils/regex.js';
15
16
  import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
16
17
  import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
17
18
  import { snapshotFile } from './furnace-rollback.js';
19
+ /**
20
+ * Builds the presence regex for a per-widget locale jar.mn line
21
+ * (`locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl`). Shared by the prune
22
+ * helper and its dry-run describer so both agree on what "dangling" means.
23
+ */
24
+ function perWidgetLocaleEntryPattern(chromeSubPath, tagName) {
25
+ return new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`, 'm');
26
+ }
27
+ /**
28
+ * Resolves the engine-relative locale jar.mn and the per-widget entry regex
29
+ * for a `sharedFtl` widget, or `undefined` when the FTL tree exposes no
30
+ * locale jar.mn we can confidently name.
31
+ */
32
+ function resolveSharedFtlPruneTarget(name, ftlDir) {
33
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
34
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
35
+ if (chromeSubPath === undefined || localeJarRel === undefined)
36
+ return undefined;
37
+ return { localeJarRel, pattern: perWidgetLocaleEntryPattern(chromeSubPath, name) };
38
+ }
39
+ /**
40
+ * Removes a dangling per-widget locale jar.mn entry for a `sharedFtl` widget.
41
+ *
42
+ * A `localized: true` widget that opts into a feature-scoped `sharedFtl`
43
+ * bundle (its strings live under `browser/...` and load via
44
+ * `insertFTLIfNeeded`) must NOT carry a per-widget
45
+ * `locale/@AB_CD@/<chromeSubPath>/<name>.ftl` line. Such a line — written by
46
+ * an older FireForge before the sharedFtl apply guard, or by a layout
47
+ * migration — points at a `.ftl` that does not exist, so `mach build` fails
48
+ * hard (`Cannot find <chromeSubPath>/<name>.ftl`) and blocks every build.
49
+ *
50
+ * The pruned line is the per-widget toolkit entry only; the shared bundle's
51
+ * own line (a different chrome sub-path / base name) is never matched, so
52
+ * pruning one widget cannot orphan the shared bundle. Idempotent: when no
53
+ * dangling entry exists the file is left untouched (no journal churn).
54
+ * Returns the engine-relative jar.mn path when a line was removed, else
55
+ * `undefined`.
56
+ */
57
+ async function pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal) {
58
+ if (!config.sharedFtl)
59
+ return undefined;
60
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
61
+ if (!target)
62
+ return undefined;
63
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
64
+ if (chromeSubPath === undefined)
65
+ return undefined;
66
+ const localeJarAbs = join(engineDir, target.localeJarRel);
67
+ if (!(await pathExists(localeJarAbs)))
68
+ return undefined;
69
+ if (!target.pattern.test(await readText(localeJarAbs)))
70
+ return undefined;
71
+ if (rollbackJournal) {
72
+ await snapshotFile(rollbackJournal, localeJarAbs);
73
+ }
74
+ await removeLocaleFtlJarMnEntry(engineDir, target.localeJarRel, name, chromeSubPath);
75
+ return target.localeJarRel;
76
+ }
77
+ /**
78
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
79
+ * records the affected path / step error in the caller's collectors, mirroring
80
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
81
+ */
82
+ export async function applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal) {
83
+ try {
84
+ const prunedPath = await pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal);
85
+ if (prunedPath)
86
+ affectedPaths.push(prunedPath);
87
+ }
88
+ catch (error) {
89
+ stepErrors.push({ step: 'locale jar.mn prune', error: toError(error).message });
90
+ }
91
+ }
92
+ /**
93
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
94
+ * returns an action when a dangling per-widget locale entry exists for a
95
+ * `sharedFtl` widget, else `undefined`.
96
+ */
97
+ export async function describeSharedFtlPrune(engineDir, name, ftlDir, config) {
98
+ if (!config.sharedFtl)
99
+ return undefined;
100
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
101
+ if (!target)
102
+ return undefined;
103
+ const localeJarAbs = join(engineDir, target.localeJarRel);
104
+ if (!(await pathExists(localeJarAbs)))
105
+ return undefined;
106
+ if (!target.pattern.test(await readText(localeJarAbs)))
107
+ return undefined;
108
+ return {
109
+ component: name,
110
+ action: 'register-jar',
111
+ description: `Remove dangling per-widget locale entry for ${name} from ${target.localeJarRel} (sharedFtl bundle owns its strings)`,
112
+ };
113
+ }
18
114
  /**
19
115
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
20
116
  * in the locale jar.mn.
@@ -6,10 +6,11 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
- import { applyCustomFtlFile, describeLocaleFtlJarMnRegistration, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
9
+ import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
10
+ import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
10
11
  import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
11
- import { deployFileWithFragments, describeFragmentExpansion, SHARED_FRAGMENTS_DIR, } from './furnace-css-fragments.js';
12
- import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
12
+ import { deployFileWithFragments, SHARED_FRAGMENTS_DIR } from './furnace-css-fragments.js';
13
+ import { addCustomElementRegistration, addJarMnEntries } from './furnace-registration.js';
13
14
  import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
14
15
  import { checkRegistrationConsistency } from './furnace-validate-registration.js';
15
16
  import { isGitRepository } from './git.js';
@@ -278,83 +279,6 @@ export async function hasCustomEngineDrift(root, name, componentDir, config, ftl
278
279
  }
279
280
  return false;
280
281
  }
281
- async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
282
- const actions = [];
283
- const stepErrors = [];
284
- for (const entry of entries) {
285
- if (!isRegularFile(entry))
286
- continue;
287
- if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
288
- continue;
289
- const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
290
- actions.push({
291
- component: name,
292
- action: fragmentNote ? 'expand-fragments' : 'copy',
293
- source: join(componentDir, entry.name),
294
- target: join(targetDir, entry.name),
295
- description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
296
- });
297
- }
298
- // Per-component .ftl handling is skipped when the component opts into a
299
- // shared feature-scoped bundle via `sharedFtl`. The shared file is
300
- // registered (and copied) by whoever owns the feature bundle, so
301
- // emitting a copy-ftl / register-jar action here would duplicate (or
302
- // later orphan) the entry.
303
- if (config.localized && !config.sharedFtl) {
304
- const ftlFile = `${name}.ftl`;
305
- const ftlSrc = join(componentDir, ftlFile);
306
- if (await pathExists(ftlSrc)) {
307
- actions.push({
308
- component: name,
309
- action: 'copy-ftl',
310
- source: ftlSrc,
311
- target: join(engineDir, ftlDir, ftlFile),
312
- description: `Copy ${ftlFile} to ${ftlDir}`,
313
- });
314
- const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
315
- if (localeAction) {
316
- actions.push(localeAction);
317
- }
318
- }
319
- }
320
- if (config.register) {
321
- try {
322
- const modulePath = `chrome://global/content/elements/${name}.mjs`;
323
- await validateCustomElementRegistration(engineDir, name, modulePath);
324
- }
325
- catch (error) {
326
- stepErrors.push({
327
- step: 'customElements.js registration',
328
- error: toError(error).message,
329
- });
330
- }
331
- actions.push({
332
- component: name,
333
- action: 'register-ce',
334
- description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
335
- });
336
- }
337
- const copiedFileNames = entries
338
- .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
339
- .map((entry) => entry.name);
340
- if (copiedFileNames.length > 0) {
341
- try {
342
- await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
343
- }
344
- catch (error) {
345
- stepErrors.push({
346
- step: 'jar.mn registration',
347
- error: toError(error).message,
348
- });
349
- }
350
- actions.push({
351
- component: name,
352
- action: 'register-jar',
353
- description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
354
- });
355
- }
356
- return { actions, stepErrors };
357
- }
358
282
  /** Applies a custom component into the engine tree and captures registration step errors. */
359
283
  export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal, applyOptions = {}) {
360
284
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -406,6 +330,12 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
406
330
  if (config.localized && !config.sharedFtl) {
407
331
  await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
408
332
  }
333
+ else if (config.localized && config.sharedFtl) {
334
+ // Drop any dangling per-widget locale jar.mn entry that would point at a
335
+ // non-existent `<chromeSubPath>/<name>.ftl` and fail `mach build`. The
336
+ // shared bundle (a different chrome path/base name) is never touched.
337
+ await applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal);
338
+ }
409
339
  if (config.register) {
410
340
  try {
411
341
  const modulePath = `chrome://global/content/elements/${name}.mjs`;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resource-monitor degrade shim for `mach build`.
3
+ *
4
+ * mozbuild's build resource monitor calls `psutil.virtual_memory()` at
5
+ * startup (`start_resource_recording`). On some hosts (psutil vs Darwin 27)
6
+ * that raises `RuntimeError: host_statistics64(HOST_VM_INFO64) syscall
7
+ * failed`, which aborts `mach build` / `mach build faster` before any work
8
+ * happens — so a raw `./mach build faster` dies while the build never
9
+ * reaches the compiler. The suite-specific test commands sidestep the
10
+ * monitor entirely, but the build commands have no such escape.
11
+ *
12
+ * FireForge writes a tiny `sitecustomize.py` and points the mach
13
+ * subprocess's `PYTHONPATH` at it. Python imports `sitecustomize` at
14
+ * interpreter startup (before mach runs), so the shim wraps
15
+ * `psutil.virtual_memory` / `swap_memory` to downgrade the host syscall
16
+ * failure into a non-fatal `UserWarning` and return a zeroed reading. The
17
+ * build then proceeds regardless of which mach entry point spawned it. When
18
+ * psutil works normally the wrapper is a transparent pass-through.
19
+ */
20
+ /** Ensures the shim exists and returns the mach `env` overlay that enables it. */
21
+ export declare function resolveMachBuildEnv(): Promise<Record<string, string>>;
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Resource-monitor degrade shim for `mach build`.
4
+ *
5
+ * mozbuild's build resource monitor calls `psutil.virtual_memory()` at
6
+ * startup (`start_resource_recording`). On some hosts (psutil vs Darwin 27)
7
+ * that raises `RuntimeError: host_statistics64(HOST_VM_INFO64) syscall
8
+ * failed`, which aborts `mach build` / `mach build faster` before any work
9
+ * happens — so a raw `./mach build faster` dies while the build never
10
+ * reaches the compiler. The suite-specific test commands sidestep the
11
+ * monitor entirely, but the build commands have no such escape.
12
+ *
13
+ * FireForge writes a tiny `sitecustomize.py` and points the mach
14
+ * subprocess's `PYTHONPATH` at it. Python imports `sitecustomize` at
15
+ * interpreter startup (before mach runs), so the shim wraps
16
+ * `psutil.virtual_memory` / `swap_memory` to downgrade the host syscall
17
+ * failure into a non-fatal `UserWarning` and return a zeroed reading. The
18
+ * build then proceeds regardless of which mach entry point spawned it. When
19
+ * psutil works normally the wrapper is a transparent pass-through.
20
+ */
21
+ import { tmpdir } from 'node:os';
22
+ import { delimiter, join } from 'node:path';
23
+ import { ensureDir, writeText } from '../utils/fs.js';
24
+ /** Directory name (under the OS temp dir) holding the generated shim. */
25
+ const RESOURCE_SHIM_DIRNAME = 'fireforge-mach-resource-shim';
26
+ /**
27
+ * `sitecustomize.py` body. Defensive: a missing/broken psutil leaves mach
28
+ * untouched, and the wrapper only intercepts the failure path.
29
+ */
30
+ const SITECUSTOMIZE_SOURCE = `# Generated by FireForge — do not edit.
31
+ # Degrades a broken host resource monitor (psutil vs Darwin) from a fatal
32
+ # RuntimeError in start_resource_recording into a non-fatal warning, so
33
+ # \`mach build\` does not abort before the build runs. Loaded via PYTHONPATH
34
+ # so it patches psutil before mach's resource monitor starts.
35
+ import warnings
36
+
37
+ try:
38
+ import psutil
39
+ except Exception:
40
+ psutil = None
41
+
42
+ if psutil is not None:
43
+ class _DegradedReading(object):
44
+ total = available = used = free = active = inactive = wired = 0
45
+ percent = 0.0
46
+
47
+ def __getattr__(self, _name):
48
+ return 0
49
+
50
+ def _guard(orig):
51
+ def wrapper(*args, **kwargs):
52
+ try:
53
+ return orig(*args, **kwargs)
54
+ except Exception as exc: # noqa: BLE001
55
+ warnings.warn(
56
+ "FireForge: host resource monitor degraded (%s); build continues." % exc
57
+ )
58
+ return _DegradedReading()
59
+
60
+ return wrapper
61
+
62
+ for _name in ("virtual_memory", "swap_memory"):
63
+ _orig = getattr(psutil, _name, None)
64
+ if _orig is not None:
65
+ setattr(psutil, _name, _guard(_orig))
66
+ `;
67
+ /**
68
+ * Writes the `sitecustomize.py` degrade shim into a stable FireForge-owned
69
+ * temp directory and returns that directory. Idempotent — overwriting the
70
+ * file each call keeps it in sync with this source if FireForge upgrades.
71
+ */
72
+ async function ensureMachResourceShim() {
73
+ const dir = join(tmpdir(), RESOURCE_SHIM_DIRNAME);
74
+ await ensureDir(dir);
75
+ await writeText(join(dir, 'sitecustomize.py'), SITECUSTOMIZE_SOURCE);
76
+ return dir;
77
+ }
78
+ /**
79
+ * Builds the `env` overlay (merged over `process.env` by the exec layer)
80
+ * that prepends the shim directory to `PYTHONPATH`, so the mach subprocess
81
+ * imports the degrade `sitecustomize` without clobbering an existing
82
+ * `PYTHONPATH`.
83
+ */
84
+ function machResourceShimEnv(shimDir, baseEnv = process.env) {
85
+ const existing = baseEnv['PYTHONPATH'];
86
+ return { PYTHONPATH: existing ? `${shimDir}${delimiter}${existing}` : shimDir };
87
+ }
88
+ /** Ensures the shim exists and returns the mach `env` overlay that enables it. */
89
+ export async function resolveMachBuildEnv() {
90
+ return machResourceShimEnv(await ensureMachResourceShim());
91
+ }
92
+ //# sourceMappingURL=mach-resource-shim.js.map
@@ -8,6 +8,7 @@ import { createSiblingLockPath, withFileLock } from './file-lock.js';
8
8
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
9
9
  import { explainMachError } from './mach-error-hints.js';
10
10
  import { getPython } from './mach-python.js';
11
+ import { resolveMachBuildEnv } from './mach-resource-shim.js';
11
12
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
12
13
  export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, } from './mach-build-artifacts.js';
13
14
  export { generateMozconfig } from './mach-mozconfig.js';
@@ -156,7 +157,10 @@ export async function build(engineDir, jobs) {
156
157
  if (jobs !== undefined) {
157
158
  args.push('-j', String(jobs));
158
159
  }
159
- const result = await runMachInheritCapture(args, engineDir);
160
+ // Inject the resource-monitor degrade shim so a host psutil failure does
161
+ // not abort the build regardless of which mach entry FireForge spawns.
162
+ const env = await resolveMachBuildEnv();
163
+ const result = await runMachInheritCapture(args, engineDir, { env });
160
164
  if (result.exitCode !== 0) {
161
165
  surfaceMachErrorHints(result);
162
166
  }
@@ -170,7 +174,10 @@ export async function build(engineDir, jobs) {
170
174
  * @returns Captured mach result
171
175
  */
172
176
  export async function buildUI(engineDir) {
173
- const result = await runMachInheritCapture(['build', 'faster'], engineDir);
177
+ // Same resource-monitor degrade shim as the full build: raw
178
+ // `./mach build faster` aborts on the host psutil monitor without it.
179
+ const env = await resolveMachBuildEnv();
180
+ const result = await runMachInheritCapture(['build', 'faster'], engineDir, { env });
174
181
  if (result.exitCode !== 0) {
175
182
  surfaceMachErrorHints(result);
176
183
  }
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Ordering comparator matching mozbuild's `StrictOrderingOnAppendList`
3
+ * (`mozbuild.util.UnsortedError`): entries are compared **case-insensitively**,
4
+ * so `HominisAppearanceController` (`appe`) sorts before
5
+ * `HominisAppMenuIntegration` (`appm`) even though a raw byte comparison
6
+ * places the uppercase `M` (0x4D) before the lowercase `e` (0x65).
7
+ *
8
+ * A naive `a > b` insertion landed the new entry in byte order, which
9
+ * `mach configure` then rejected with `UnsortedError`, aborting the build.
10
+ * Ties on the lower-cased key fall back to byte order so the comparison is
11
+ * total and stable.
12
+ */
13
+ function mozbuildSortCompare(a, b) {
14
+ const la = a.toLowerCase();
15
+ const lb = b.toLowerCase();
16
+ if (la < lb)
17
+ return -1;
18
+ if (la > lb)
19
+ return 1;
20
+ if (a < b)
21
+ return -1;
22
+ if (a > b)
23
+ return 1;
24
+ return 0;
25
+ }
1
26
  /**
2
27
  * Inserts a line into an array of lines in alphabetical order within a
3
28
  * specified range. The comparison key is extracted from each line.
@@ -16,7 +41,7 @@ export function findAlphabeticalPosition(lines, startLine, endLine, newKey, extr
16
41
  existingKeys.push(key);
17
42
  }
18
43
  }
19
- const isSorted = existingKeys.every((k, idx) => idx === 0 || k.localeCompare(existingKeys[idx - 1] ?? '') >= 0);
44
+ const isSorted = existingKeys.every((k, idx) => idx === 0 || mozbuildSortCompare(k, existingKeys[idx - 1] ?? '') >= 0);
20
45
  if (!isSorted) {
21
46
  return { insertIndex: endLine, previousEntry: undefined };
22
47
  }
@@ -29,7 +54,7 @@ export function findAlphabeticalPosition(lines, startLine, endLine, newKey, extr
29
54
  const key = extractKey(line);
30
55
  if (key === undefined)
31
56
  continue;
32
- if (key > newKey) {
57
+ if (mozbuildSortCompare(key, newKey) > 0) {
33
58
  insertIndex = i;
34
59
  break;
35
60
  }
@@ -61,7 +86,7 @@ export function findAlphabeticalTokenPosition(tokens, sectionTargetPattern, newK
61
86
  continue;
62
87
  const match = sectionTargetPattern.exec(entry.parsed.target);
63
88
  const key = match?.[1] ?? entry.parsed.target;
64
- if (key > newKey) {
89
+ if (mozbuildSortCompare(key, newKey) > 0) {
65
90
  insertLineIndex = entry.lineIndex;
66
91
  break;
67
92
  }
@@ -84,7 +109,7 @@ export function findAlphabeticalMozBuildPosition(tokens, newKey) {
84
109
  let previousEntry;
85
110
  for (const item of items) {
86
111
  const key = item.parsed?.value ?? '';
87
- if (key > newKey) {
112
+ if (mozbuildSortCompare(key, newKey) > 0) {
88
113
  insertLineIndex = item.lineIndex;
89
114
  break;
90
115
  }
@@ -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';