@hominis/fireforge 0.16.5 → 0.18.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 (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. package/package.json +1 -1
@@ -8,11 +8,13 @@
8
8
  * config-mutate.ts — immutable config mutation
9
9
  * config-state.ts — state file management
10
10
  */
11
+ import { basename } from 'node:path';
11
12
  import { ConfigError, ConfigNotFoundError } from '../errors/config.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
14
15
  import { getProjectPaths } from './config-paths.js';
15
16
  import { validateConfig } from './config-validate.js';
17
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
16
18
  // ---- re-exports ----
17
19
  export { mutateConfig } from './config-mutate.js';
18
20
  export { CONFIG_FILENAME, CONFIGS_DIR, ENGINE_DIR, FIREFORGE_DIR, getProjectPaths, PATCHES_DIR, SRC_DIR, STATE_FILENAME, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, } from './config-paths.js';
@@ -97,9 +99,50 @@ export async function writeConfig(root, config) {
97
99
  * Writes a raw config document to fireforge.json.
98
100
  * This is used by CLI `config --force`, where callers may intentionally write
99
101
  * keys or value shapes outside the validated FireForgeConfig schema.
102
+ *
103
+ * Individual writes are atomic via {@link writeJson} (temp file + rename),
104
+ * but atomicity alone does not prevent lost updates across concurrent
105
+ * writers: each writer reads an old copy, mutates its own in-memory view,
106
+ * and writes it back, so the second writer's rename clobbers the first
107
+ * writer's changes. Callers that do read → mutate → write must hold
108
+ * {@link withConfigFileLock} for the full round-trip to serialise
109
+ * against other writers.
100
110
  */
101
111
  export async function writeConfigDocument(root, config) {
102
112
  const paths = getProjectPaths(root);
103
113
  await writeJson(paths.config, config);
104
114
  }
115
+ /**
116
+ * Runs an operation while holding a sidecar lock on `fireforge.json`.
117
+ *
118
+ * Motivating case (2026-04-21 eval): two concurrent `fireforge config
119
+ * <key> <value>` invocations each ran load → mutate → writeJson against
120
+ * the same on-disk fireforge.json. The second rename landed after the
121
+ * first, silently dropping the first writer's key — both commands exited
122
+ * `0`, but only one change survived. This helper turns the same
123
+ * read-modify-write sequence into a serialised operation so a concurrent
124
+ * writer now waits for the lock rather than racing on the document.
125
+ *
126
+ * Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
127
+ * always use `writeJson`'s atomic temp-file + rename, so a reader observes
128
+ * either the pre- or post-write document but never a torn file. The lock
129
+ * only serialises writers against other writers.
130
+ *
131
+ * The lock is a sidecar directory `${config}.fireforge-config.lock`, and
132
+ * `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
133
+ * fallback) — a crashed writer does not permanently block future writes.
134
+ *
135
+ * @param root - Root directory of the project
136
+ * @param operation - Async function to run while holding the lock
137
+ * @returns Whatever the operation returns
138
+ */
139
+ export async function withConfigFileLock(root, operation) {
140
+ const paths = getProjectPaths(root);
141
+ return withFileLock(createSiblingLockPath(paths.config, '.fireforge-config.lock'), operation, {
142
+ onTimeoutMessage: `Timed out waiting to update ${basename(paths.config)}. ` +
143
+ 'If no other fireforge process is running, remove the stale lock directory and retry.',
144
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge config lock for ${basename(paths.config)} ` +
145
+ `(age: ${Math.round(ageMs / 1000)}s). A previous fireforge process may have crashed.`,
146
+ });
147
+ }
105
148
  //# sourceMappingURL=config.js.map
@@ -111,9 +111,30 @@ export declare function writeFurnaceConfig(root: string, config: FurnaceConfig):
111
111
  export declare function stampFurnaceOverrideBaseVersions(root: string, version: string): Promise<number>;
112
112
  /**
113
113
  * Creates a default furnace configuration.
114
- * @returns A valid empty FurnaceConfig
114
+ *
115
+ * When a `binaryName` is provided, the default config carries a
116
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
117
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
118
+ * labels every custom-property reference as `unknown` — the scan has
119
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
120
+ * state (`furnace init` → `token add` → `token coverage` → zero
121
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
122
+ * the prefix from the binary name matches the convention the scaffolded
123
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
124
+ *
125
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
126
+ * on the legacy no-arg call shape (existing tests, programmatic callers
127
+ * bootstrapping from a not-yet-loaded config) still get a valid config
128
+ * without a prefix; the CLI init path always has a `binaryName` from
129
+ * `fireforge.json` and always sets one.
130
+ *
131
+ * @param options - Optional init context; pass `{ binaryName }` to
132
+ * derive the token prefix.
133
+ * @returns A valid FurnaceConfig
115
134
  */
116
- export declare function createDefaultFurnaceConfig(): FurnaceConfig;
135
+ export declare function createDefaultFurnaceConfig(options?: {
136
+ binaryName?: string;
137
+ }): FurnaceConfig;
117
138
  /**
118
139
  * Loads furnace config if it exists, or creates and writes a default config.
119
140
  * @param root - Root directory of the project
@@ -460,16 +460,39 @@ export async function stampFurnaceOverrideBaseVersions(root, version) {
460
460
  }
461
461
  /**
462
462
  * Creates a default furnace configuration.
463
- * @returns A valid empty FurnaceConfig
463
+ *
464
+ * When a `binaryName` is provided, the default config carries a
465
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
466
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
467
+ * labels every custom-property reference as `unknown` — the scan has
468
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
469
+ * state (`furnace init` → `token add` → `token coverage` → zero
470
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
471
+ * the prefix from the binary name matches the convention the scaffolded
472
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
473
+ *
474
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
475
+ * on the legacy no-arg call shape (existing tests, programmatic callers
476
+ * bootstrapping from a not-yet-loaded config) still get a valid config
477
+ * without a prefix; the CLI init path always has a `binaryName` from
478
+ * `fireforge.json` and always sets one.
479
+ *
480
+ * @param options - Optional init context; pass `{ binaryName }` to
481
+ * derive the token prefix.
482
+ * @returns A valid FurnaceConfig
464
483
  */
465
- export function createDefaultFurnaceConfig() {
466
- return {
484
+ export function createDefaultFurnaceConfig(options = {}) {
485
+ const config = {
467
486
  version: 1,
468
487
  componentPrefix: 'moz-',
469
488
  stock: [],
470
489
  overrides: {},
471
490
  custom: {},
472
491
  };
492
+ if (options.binaryName && options.binaryName.length > 0) {
493
+ config.tokenPrefix = `--${options.binaryName}-`;
494
+ }
495
+ return config;
473
496
  }
474
497
  /**
475
498
  * Loads furnace config if it exists, or creates and writes a default config.
@@ -9,7 +9,7 @@ import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
10
  import { ensureGit, git } from './git-base.js';
11
11
  import { fileExistsInHead } from './git-file-ops.js';
12
- import { getUntrackedFiles } from './git-status.js';
12
+ import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
13
13
  async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
14
14
  const result = await exec('git', args, { cwd: repoDir });
15
15
  if (allowedExitCodes.includes(result.exitCode)) {
@@ -183,7 +183,26 @@ export async function getAllDiff(repoDir) {
183
183
  */
184
184
  export async function getDiffForFilesAgainstHead(repoDir, files) {
185
185
  await ensureGit();
186
- const uniqueFiles = [...new Set(files)].sort();
186
+ // Expand any directory entries (paths ending with `/`) into their
187
+ // individual untracked files before diffing. `git status --porcelain=v1`
188
+ // reports collapsed untracked directories as `?? dir/`, and every caller
189
+ // that feeds the aggregate working-tree state into this function must
190
+ // not trigger an EISDIR when the diff pass reads `dir/` as if it were a
191
+ // file. Belt-and-suspenders: the caller-side expansion in `lint.ts`
192
+ // and `export-all.ts` covers the common path, but a single bad call
193
+ // site re-introduced the bug in 0.17.0 — guarding here makes the
194
+ // regression impossible at this layer.
195
+ const expandedFiles = [];
196
+ for (const file of files) {
197
+ if (file.endsWith('/')) {
198
+ const inner = await getUntrackedFilesInDir(repoDir, file);
199
+ for (const entry of inner)
200
+ expandedFiles.push(entry);
201
+ continue;
202
+ }
203
+ expandedFiles.push(file);
204
+ }
205
+ const uniqueFiles = [...new Set(expandedFiles)].sort();
187
206
  const diffs = [];
188
207
  for (const file of uniqueFiles) {
189
208
  if (await fileExistsInHead(repoDir, file)) {
@@ -60,19 +60,56 @@ export declare function bootstrapWithOutput(engineDir: string): Promise<MachComm
60
60
  /**
61
61
  * Runs a full mach build. On a non-zero exit, any matched error hints are
62
62
  * surfaced on top of the raw mach output so operators get an actionable
63
- * nudge alongside the cryptic mozbuild traceback.
63
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
64
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
65
+ * for post-build diagnostics that mach prints AFTER "Your build was
66
+ * successful!" — notably the stale `config.status is out of date`
67
+ * notice that mach emits when a tool-managed edit landed on
68
+ * `moz.configure` before the build.
64
69
  * @param engineDir - Path to the engine directory
65
70
  * @param jobs - Number of parallel jobs (optional)
66
- * @returns Exit code
71
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
67
72
  */
68
- export declare function build(engineDir: string, jobs?: number): Promise<number>;
73
+ export declare function build(engineDir: string, jobs?: number): Promise<MachCommandResult>;
69
74
  /**
70
75
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
71
- * surfaced on top of the raw mach output.
76
+ * surfaced on top of the raw mach output. See {@link build} for why the
77
+ * full captured result is returned rather than just the exit code.
72
78
  * @param engineDir - Path to the engine directory
73
- * @returns Exit code
79
+ * @returns Captured mach result
80
+ */
81
+ export declare function buildUI(engineDir: string): Promise<MachCommandResult>;
82
+ /**
83
+ * Runs an operation while holding a sidecar build lock keyed on the
84
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
85
+ * invocations against the same tree serialise instead of racing through
86
+ * the mach obj-dir.
87
+ *
88
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
89
+ * kicked off while a full `fireforge build` was still in flight against
90
+ * the same engine tree accepted the command and handed off to `mach
91
+ * build faster`, which failed almost immediately with `No rule to make
92
+ * target 'XUL'`. The real problem is that the first build had not yet
93
+ * materialised the full backend; the operator was left staring at a
94
+ * low-level make error with no link to the actual cause (a concurrent
95
+ * build in flight). The lock intercepts the second invocation before
96
+ * it touches mach, and the refusal message names the PID currently
97
+ * holding the lock so the operator can decide whether to wait or
98
+ * investigate a hung process.
99
+ *
100
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
101
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
102
+ * not the owning process, and `withFileLock` removes the lock on the
103
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
104
+ *
105
+ * The project-root variant is the right granularity: a single machine
106
+ * may have several FireForge projects side by side, and nothing says
107
+ * they cannot build in parallel. The lock serialises *within* one
108
+ * project, not across unrelated ones.
109
+ *
110
+ * Returns whatever the inner operation returns.
74
111
  */
75
- export declare function buildUI(engineDir: string): Promise<number>;
112
+ export declare function withBuildLock<T>(projectRoot: string, operation: () => Promise<T>): Promise<T>;
76
113
  /**
77
114
  * Runs the built browser.
78
115
  * @param engineDir - Path to the engine directory
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
7
8
  import { explainMachError } from './mach-error-hints.js';
8
9
  import { getPython } from './mach-python.js';
9
10
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
@@ -136,10 +137,15 @@ function surfaceMachErrorHints(result) {
136
137
  /**
137
138
  * Runs a full mach build. On a non-zero exit, any matched error hints are
138
139
  * surfaced on top of the raw mach output so operators get an actionable
139
- * nudge alongside the cryptic mozbuild traceback.
140
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
141
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
142
+ * for post-build diagnostics that mach prints AFTER "Your build was
143
+ * successful!" — notably the stale `config.status is out of date`
144
+ * notice that mach emits when a tool-managed edit landed on
145
+ * `moz.configure` before the build.
140
146
  * @param engineDir - Path to the engine directory
141
147
  * @param jobs - Number of parallel jobs (optional)
142
- * @returns Exit code
148
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
143
149
  */
144
150
  export async function build(engineDir, jobs) {
145
151
  const args = ['build'];
@@ -150,20 +156,64 @@ export async function build(engineDir, jobs) {
150
156
  if (result.exitCode !== 0) {
151
157
  surfaceMachErrorHints(result);
152
158
  }
153
- return result.exitCode;
159
+ return result;
154
160
  }
155
161
  /**
156
162
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
157
- * surfaced on top of the raw mach output.
163
+ * surfaced on top of the raw mach output. See {@link build} for why the
164
+ * full captured result is returned rather than just the exit code.
158
165
  * @param engineDir - Path to the engine directory
159
- * @returns Exit code
166
+ * @returns Captured mach result
160
167
  */
161
168
  export async function buildUI(engineDir) {
162
169
  const result = await runMachInheritCapture(['build', 'faster'], engineDir);
163
170
  if (result.exitCode !== 0) {
164
171
  surfaceMachErrorHints(result);
165
172
  }
166
- return result.exitCode;
173
+ return result;
174
+ }
175
+ /**
176
+ * Runs an operation while holding a sidecar build lock keyed on the
177
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
178
+ * invocations against the same tree serialise instead of racing through
179
+ * the mach obj-dir.
180
+ *
181
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
182
+ * kicked off while a full `fireforge build` was still in flight against
183
+ * the same engine tree accepted the command and handed off to `mach
184
+ * build faster`, which failed almost immediately with `No rule to make
185
+ * target 'XUL'`. The real problem is that the first build had not yet
186
+ * materialised the full backend; the operator was left staring at a
187
+ * low-level make error with no link to the actual cause (a concurrent
188
+ * build in flight). The lock intercepts the second invocation before
189
+ * it touches mach, and the refusal message names the PID currently
190
+ * holding the lock so the operator can decide whether to wait or
191
+ * investigate a hung process.
192
+ *
193
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
194
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
195
+ * not the owning process, and `withFileLock` removes the lock on the
196
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
197
+ *
198
+ * The project-root variant is the right granularity: a single machine
199
+ * may have several FireForge projects side by side, and nothing says
200
+ * they cannot build in parallel. The lock serialises *within* one
201
+ * project, not across unrelated ones.
202
+ *
203
+ * Returns whatever the inner operation returns.
204
+ */
205
+ export async function withBuildLock(projectRoot, operation) {
206
+ const lockPath = createSiblingLockPath(join(projectRoot, '.fireforge-build'), '.lock');
207
+ return withFileLock(lockPath, operation, {
208
+ // Default lock timeout is 30s; bump to 24h so a slow full build does
209
+ // not trip the timeout while the second invocation waits. A real
210
+ // operator will ^C long before 24h elapses; the ceiling is there
211
+ // purely so a forgotten lock cannot wedge the command forever.
212
+ timeoutMs: 24 * 60 * 60 * 1000,
213
+ onTimeoutMessage: `Timed out waiting for the FireForge build lock at ${lockPath}. ` +
214
+ 'If no other `fireforge build` is running, remove the lock directory and retry.',
215
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge build lock ${basename(lockPath)} (age: ${Math.round(ageMs / 1000)}s). A previous build process may have crashed.`,
216
+ });
167
217
  }
168
218
  /**
169
219
  * Runs the built browser.
@@ -24,7 +24,16 @@ export function getRules(binaryName) {
24
24
  // proposed a bogus jar.mn entry. The lookahead blocks the match so
25
25
  // `getUnregistrableAdvice` gets a chance to emit the correct
26
26
  // guidance for the `.inc.xhtml` case.
27
- pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(.+\.(?:js|mjs|xhtml|css))$/,
27
+ //
28
+ // Test implementation files under `browser/base/content/test/` are
29
+ // also excluded: they belong in the nearest `browser.toml` manifest,
30
+ // not in jar.mn. 2026-04-23 eval 2: `status --unmanaged` proposed
31
+ // `fireforge register browser/base/content/test/<dir>/browser_*.js`
32
+ // which would have clutter-registered a test file as browser
33
+ // chrome content. The negative lookahead routes those paths to
34
+ // `getUnregistrableAdvice`, which returns the correct
35
+ // browser.toml-centric guidance.
36
+ pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(?!test\/)(.+\.(?:js|mjs|xhtml|css))$/,
28
37
  isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
29
38
  register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
30
39
  extractArgs: (m) => [m[1] ?? ''],
@@ -26,6 +26,12 @@ export declare function tokenizeJarMn(lines: string[]): JarMnToken[];
26
26
  /**
27
27
  * Tokenizes a moz.build Python list block, returning the tokens and their
28
28
  * line range within the file.
29
+ *
30
+ * Supports both multi-line lists (the common shape) and single-line
31
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
32
+ * case where a freshly-scaffolded module directory's `moz.build`
33
+ * started with an empty list and the tokenizer returned `null`,
34
+ * leaving `register` unable to add the first entry.
29
35
  */
30
36
  export declare function tokenizeMozBuildList(lines: string[], listPattern: RegExp): {
31
37
  tokens: MozBuildToken[];
@@ -44,6 +44,12 @@ export function tokenizeJarMn(lines) {
44
44
  /**
45
45
  * Tokenizes a moz.build Python list block, returning the tokens and their
46
46
  * line range within the file.
47
+ *
48
+ * Supports both multi-line lists (the common shape) and single-line
49
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
50
+ * case where a freshly-scaffolded module directory's `moz.build`
51
+ * started with an empty list and the tokenizer returned `null`,
52
+ * leaving `register` unable to add the first entry.
47
53
  */
48
54
  export function tokenizeMozBuildList(lines, listPattern) {
49
55
  const tokens = [];
@@ -53,6 +59,28 @@ export function tokenizeMozBuildList(lines, listPattern) {
53
59
  const raw = lines[i] ?? '';
54
60
  if (startLine === -1) {
55
61
  if (listPattern.test(raw)) {
62
+ // Single-line empty-list handling: a fresh scaffold sometimes
63
+ // writes `EXTRA_JS_MODULES += []` on one line. The pre-fix
64
+ // tokenizer returned `null` because it never saw a line
65
+ // starting with `]`, which stranded `register` with a "Could
66
+ // not find module list section" error against the documented
67
+ // browser/modules/<fork>/ scaffold (eval 2).
68
+ //
69
+ // The in-place split rewrites the single-line form into the
70
+ // canonical multi-line shape so the caller's
71
+ // `lines.splice(insertIndex, 0, entry)` lands inside the list
72
+ // body. The tokens are emitted to mirror the new structure.
73
+ const singleLineMatch = /^([^[]*\[)\s*\]\s*$/.exec(raw);
74
+ if (singleLineMatch) {
75
+ const openPart = singleLineMatch[1] ?? '';
76
+ lines[i] = openPart;
77
+ lines.splice(i + 1, 0, ']');
78
+ startLine = i;
79
+ endLine = i + 1;
80
+ tokens.push({ type: 'list-open', raw: openPart, lineIndex: i });
81
+ tokens.push({ type: 'list-close', raw: ']', lineIndex: i + 1 });
82
+ break;
83
+ }
56
84
  startLine = i;
57
85
  tokens.push({ type: 'list-open', raw, lineIndex: i });
58
86
  }
@@ -0,0 +1,50 @@
1
+ /** Default Marionette control port set by `-marionette`. */
2
+ export declare const DEFAULT_MARIONETTE_PORT = 2828;
3
+ /**
4
+ * Information about a process holding the Marionette port.
5
+ */
6
+ export interface MarionettePortHolder {
7
+ /** OS process ID. */
8
+ pid: number;
9
+ /** Process basename (e.g. `forgefresh`, `firefox`). */
10
+ command: string;
11
+ /**
12
+ * Full command line the holder was launched with, when the probe
13
+ * can recover it. `lsof` by itself only returns the basename, so
14
+ * POSIX callers see `command === commandLine`; Windows callers
15
+ * recover the full command line via `Get-Process`. Used to detect
16
+ * the `-marionette` flag, which positively identifies a stale
17
+ * browser rather than an unrelated listener.
18
+ */
19
+ commandLine: string;
20
+ }
21
+ /**
22
+ * Result of a Marionette port probe.
23
+ */
24
+ export interface MarionettePortProbeResult {
25
+ /** True when something is listening on the probed port. */
26
+ inUse: boolean;
27
+ /** Details about the holder, when the probe recovered them. */
28
+ holder?: MarionettePortHolder;
29
+ }
30
+ /**
31
+ * Probes whether the Marionette port is currently bound by a
32
+ * listener. The probe is best-effort: missing tooling returns
33
+ * `{ inUse: false }` without failing.
34
+ *
35
+ * @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
36
+ */
37
+ export declare function probeMarionettePort(port?: number): Promise<MarionettePortProbeResult>;
38
+ /**
39
+ * Raises a targeted {@link GeneralError} when the Marionette port
40
+ * is held by a browser process; raises a softer warning-shaped
41
+ * error when the holder is unrelated (so the operator still sees
42
+ * a useful signal but can decide whether to wait it out).
43
+ *
44
+ * @param port - Port to probe (default {@link DEFAULT_MARIONETTE_PORT}).
45
+ * @param options - Extra context for the error message (the project's
46
+ * `binaryName` is used to recognise fork-branded browser binaries).
47
+ */
48
+ export declare function assertMarionettePortAvailable(port?: number, options?: {
49
+ binaryName?: string;
50
+ }): Promise<void>;