@hominis/fireforge 0.11.1 → 0.12.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 (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +56 -3
  3. package/dist/src/commands/export-shared.d.ts +2 -1
  4. package/dist/src/commands/export-shared.js +3 -2
  5. package/dist/src/commands/furnace/create.js +1 -1
  6. package/dist/src/commands/furnace/deploy.js +1 -1
  7. package/dist/src/commands/furnace/override.js +1 -1
  8. package/dist/src/commands/furnace/refresh.js +1 -1
  9. package/dist/src/commands/furnace/remove.js +1 -1
  10. package/dist/src/commands/furnace/rename.js +1 -1
  11. package/dist/src/commands/furnace/scan.js +1 -1
  12. package/dist/src/commands/lint.js +8 -3
  13. package/dist/src/commands/setup-support.js +5 -0
  14. package/dist/src/core/ast-utils.d.ts +10 -0
  15. package/dist/src/core/ast-utils.js +18 -0
  16. package/dist/src/core/branding.js +5 -5
  17. package/dist/src/core/config-paths.d.ts +2 -2
  18. package/dist/src/core/config-paths.js +3 -0
  19. package/dist/src/core/config-validate.js +21 -3
  20. package/dist/src/core/file-lock.js +39 -2
  21. package/dist/src/core/furnace-apply.js +2 -1
  22. package/dist/src/core/furnace-config.js +6 -2
  23. package/dist/src/core/patch-apply.js +26 -4
  24. package/dist/src/core/patch-lint-checkjs.d.ts +21 -0
  25. package/dist/src/core/patch-lint-checkjs.js +225 -0
  26. package/dist/src/core/patch-lint-cross.d.ts +1 -0
  27. package/dist/src/core/patch-lint-cross.js +7 -0
  28. package/dist/src/core/patch-lint-jsdoc.d.ts +21 -0
  29. package/dist/src/core/patch-lint-jsdoc.js +259 -0
  30. package/dist/src/core/patch-lint-ownership.d.ts +25 -0
  31. package/dist/src/core/patch-lint-ownership.js +43 -0
  32. package/dist/src/core/patch-lint.d.ts +7 -2
  33. package/dist/src/core/patch-lint.js +30 -30
  34. package/dist/src/types/config.d.ts +9 -0
  35. package/dist/src/utils/fs.d.ts +8 -0
  36. package/dist/src/utils/fs.js +17 -0
  37. package/dist/src/utils/paths.js +3 -1
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### JSDoc validation (breaking)
6
+
7
+ - **JSDoc enforcement is now AST-based and severity `error`.** The previous heuristic (walk backwards from `export` to find `*/`) has been replaced with Acorn-based AST analysis. Exported functions must have a JSDoc block with `@param` for each parameter (names must match) and `@returns` when returning a value. Exported classes require a JSDoc block. Exported constants require `@type`. This is a breaking change: projects that previously passed with incomplete JSDoc will now see lint errors.
8
+ - **Patch-owned scope.** JSDoc enforcement now applies to all patch-owned `.sys.mjs` files, not just files new in the current diff. A file is patch-owned if it was created by the current diff or by any existing patch in the queue.
9
+ - New check: **`jsdoc-param-mismatch`** (error) — flags `@param` tags that are missing or have the wrong name.
10
+ - New check: **`jsdoc-missing-returns`** (error) — flags functions that return a value but lack `@returns`.
11
+ - Exported constants and classes require a JSDoc block but do not require specific tags like `@type`.
12
+
13
+ ### Optional checkJs pass
14
+
15
+ - **`patchLint.checkJs`** — new opt-in config field in `fireforge.json`. When enabled, runs TypeScript's `checkJs` pass (`allowJs + checkJs + noEmit`) on patch-owned `.sys.mjs` files only. Firefox globals are shimmed automatically. Diagnostics are filtered to patch-owned files so upstream noise is suppressed.
16
+ - New check: **`checkjs-type-error`** (error/warning) — surfaces type errors from the TypeScript compiler.
17
+
18
+ ### Hardening
19
+
20
+ - **Path validation.** `binaryName` in `fireforge.json` now rejects null bytes and absolute paths (including Windows drive letters). `isContainedRelativePath` and `isPathInsideRoot` reject null bytes. Furnace custom component `targetPath` rejects null bytes and absolute paths.
21
+ - **Symlink traversal protection.** Patch target validation now checks whether existing paths are symlinks resolving outside the engine tree before applying.
22
+ - **PID-aware stale lock recovery.** The file lock writes the owning PID into the lock directory. Stale lock recovery checks whether the PID is still alive before removing, preventing premature removal when a slow operation legitimately holds the lock.
23
+ - **Forward-import detection** now catches `ChromeUtils.importESModule()` calls in addition to static/dynamic ES imports and `defineESModuleGetters`.
24
+ - **Furnace rollback failure markers** now include the component name and operation context, improving diagnostics in `fireforge doctor`.
25
+ - New lint check in README: **`modified-file-missing-header`** (warning) was implemented but not documented; now listed in the lint checks table.
26
+
3
27
  ## 0.11.0
4
28
 
5
29
  ### New commands
@@ -53,6 +77,7 @@
53
77
  - Override `baseVersion` drift now blocks `apply` and `deploy` by default instead of warning and continuing. Pass `--force` to override, or use `furnace refresh` to update the baseline.
54
78
  - Post-apply consistency check verifies that `customElements.js` and `jar.mn` entries match what was deployed.
55
79
  - Engine-side content hashes are cached in the furnace state file, making drift detection faster for the common no-change case.
80
+ - Branding file writes are now content-aware: re-running setup with the same configuration no longer bumps file timestamps, avoiding unnecessary `config.status` reconfiguration during incremental builds.
56
81
  - `build` and `test --build` now share the same preparation pipeline including Furnace apply, so incremental test builds no longer run against stale component state.
57
82
  - `furnace remove` on an override now restores every overridden engine file to its Firefox baseline instead of leaving deployed files behind.
58
83
  - Scanner results are cached by content hash within a process, avoiding redundant parsing during scan-status-apply sequences.
@@ -63,6 +88,9 @@
63
88
 
64
89
  ### Bug fixes
65
90
 
91
+ - `fireforge setup` now writes an initial `patches/patches.json` (with `version: 1`) when creating a new project. Previously, setup created the `patches/` directory but not the manifest, causing `fireforge doctor` to fail the "Patch manifest consistency" check on a fresh project. Re-running `setup --force` on an existing project preserves the current manifest.
92
+ - The full Firefox integration test script (`scripts/run-full-firefox-integration.mjs`) now uses `--yes` instead of `--force` when invoking `fireforge discard`, matching the actual CLI flag. This was the sole cause of integration test failures in the discard and recovery workflow steps.
93
+ - The integration test's cleanup loop now uses direct git operations (`git checkout` for tracked files, `git clean` for untracked) instead of routing through `fireforge discard`, which could not handle untracked branding files introduced by the build.
66
94
  - `furnace refresh` now correctly advances the per-override `baseCommit` to the engine HEAD after a successful merge, preventing phantom conflicts on subsequent refreshes.
67
95
  - `furnace rename` uses the correct file-removal function for FTL files.
68
96
  - `furnace remove` now parses browser.toml sections properly, cleaning up metadata keys below the section header instead of leaving stale fragments.
@@ -77,6 +105,10 @@
77
105
 
78
106
  ### Internal
79
107
 
108
+ - The full Firefox integration script now accepts `FIREFORGE_FULL_FIREFOX_VERSION` to override the Firefox version used during the test run, decoupling the test from the version baked into `fireforge.json`.
109
+ - The integration test now verifies that `obj-*/dist/bin/` exists after a build reports success, detecting cases where mach masks a build failure with exit code 0.
110
+ - The integration test cleanup loop now uses direct git operations (`checkout` / `clean`) instead of routing through `fireforge discard`, correctly handling untracked branding files introduced by the build.
111
+ - New unit tests from a full local Firefox integration run: Python version resolution skips candidates above mach's `MAX_PYTHON_VERSION_TO_CONSIDER` and falls through to a compatible version; fresh-project manifest consistency returns zero issues for the empty manifest that `setup` now writes; bootstrap soft-failure detection catches the `urllib.error.HTTPError: HTTP Error 403` pattern observed in real `mach bootstrap` output.
80
112
  - CLI command registration is now driven by a declarative manifest instead of hand-listed calls.
81
113
  - Doctor checks are a declarative registry with per-check `run`, `skipIf`, and `fix` fields.
82
114
  - New shared destructive-op framework handles confirmation, `--dry-run`, `--yes`/`--force-unsafe`, and audit logging for the patch mutation commands.
package/README.md CHANGED
@@ -26,7 +26,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
26
26
 
27
27
  - **Quality checks** `fireforge lint` catches fork-specific issues (raw colours, missing licence headers, relative imports, large patches, cross-patch ordering problems) before you export. `fireforge verify` runs a read-only integrity check over the whole patch queue. `fireforge doctor` diagnoses project health including Furnace component validation.
28
28
 
29
- - **Built and validated against real Firefox code** Developed by editing a real Firefox ESR codebase, learning from existing patch tools, observing the breakages and edge cases that surfaced, and turning those findings into a realistic test suite. In-repo tests are thus grounded in actual development scenarios. Yes, we we mock quite a bit, but when building a tool the modifies a separate, far larger code base, I think it's a valid compromise for the time being. Full end-to-end runs are currently run locally, as they require about 30 GB of disk and significant compute for multiple full builds. Full end-to-end via Github Actions will be added soonishlyTM.
29
+ - **Built and validated against real Firefox code** Developed by editing a real Firefox ESR codebase, learning from existing patch tools, observing the breakages and edge cases that surfaced, and turning those findings into a realistic test suite. In-repo tests are thus grounded in actual development scenarios. Yes, we mock quite a bit, but when building a tool that modifies a separate code base, I think it's a solid compromise for the time being. Full end-to-end runs are currently run locally, as they require about 30 GB of disk and significant compute for multiple full builds. Full end-to-end via Github Actions will be added soonishlyTM.
30
30
 
31
31
  ## Quick Start
32
32
 
@@ -52,7 +52,7 @@ npx fireforge build # build the browser
52
52
  npx fireforge run # launch it
53
53
  ```
54
54
 
55
- Your project now has `fireforge.json`, an `engine/` directory with Firefox source, and a `patches/` directory ready for your first customisation.
55
+ Your project now has `fireforge.json`, an `engine/` directory with Firefox source, and a `patches/` directory with an empty `patches.json` manifest ready for your first customisation.
56
56
 
57
57
  ### Workflow Overview
58
58
 
@@ -196,13 +196,21 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
196
196
  | `raw-color-value` | Introduced CSS color values | error |
197
197
  | `duplicate-new-file-creation` | Same path created by multiple patches | error |
198
198
  | `forward-import` | Patch imports from a later-patch file | error |
199
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
200
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
201
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
202
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
199
203
  | `missing-modification-comment` | Modified upstream JS/MJS | warning |
204
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
200
205
  | `file-too-large` | New files >650 lines | warning |
201
- | `missing-jsdoc` | Exports in new `.sys.mjs` | warning |
202
206
  | `observer-topic-naming` | Observer topics with binaryName | warning |
203
207
  | `large-patch-files` | Patches affecting >5 files | warning |
204
208
  | `large-patch-lines` | Patches >300 lines | warning |
205
209
 
210
+ **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
211
+
212
+ **Optional `checkJs` pass.** Enable TypeScript-based type checking for patch-owned `.sys.mjs` files by adding `"patchLint": { "checkJs": true }` to `fireforge.json`. This uses the TypeScript compiler API with `allowJs + checkJs + noEmit`, scoped only to patch-owned files. Firefox globals (`Services`, `ChromeUtils`, `lazy`, etc.) are shimmed automatically. Module-resolution errors from Firefox's `resource://` and `chrome://` URL schemes are suppressed since TypeScript cannot follow these — the pass focuses on type errors within the patch-owned code itself (mismatched JSDoc types, wrong argument counts, unreachable code, etc.).
213
+
206
214
  The two cross-patch rules (`duplicate-new-file-creation` and `forward-import`) run over the whole patch queue rather than a single diff, catching ordering issues that only surface during `import`. Forward-import detection compares leaf filenames, so a false positive is theoretically possible when two patches create files with the same basename in different directories. Suppress with an inline `// fireforge-ignore: forward-import` comment on or above the import line. This is currently the only lint rule that supports inline suppression.
207
215
 
208
216
  </details>
@@ -291,6 +299,51 @@ fireforge furnace diff moz-button # unified diff against baseli
291
299
 
292
300
  `furnace deploy` validates components before applying — errors block, warnings are advisory. `fireforge build` and `fireforge test --build` run apply automatically. Use `fireforge doctor --repair-furnace` if the engine gets out of sync.
293
301
 
302
+ ## Additional Commands
303
+
304
+ The commands below cover project configuration, patch queue management, build packaging, and development utilities. Run `fireforge <command> --help` for full option details.
305
+
306
+ ### Configuration
307
+
308
+ ```bash
309
+ # Read a config value
310
+ fireforge config firefox.version
311
+
312
+ # Set a config value
313
+ fireforge config firefox.version 145.0.0esr
314
+
315
+ # Set a value at a non-standard path (requires --force)
316
+ fireforge config customKey "value" --force
317
+ ```
318
+
319
+ ### Patch queue management
320
+
321
+ ```bash
322
+ # Delete a patch from the queue
323
+ fireforge patch delete 003-ui-sidebar-tweaks.patch
324
+
325
+ # Reorder a patch to a new position
326
+ fireforge patch reorder 003-ui-sidebar-tweaks.patch --to 1
327
+
328
+ # Move a patch before or after another
329
+ fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
330
+ ```
331
+
332
+ Both subcommands support `--dry-run` and `--yes`.
333
+
334
+ ### Additional workflow commands
335
+
336
+ ```bash
337
+ # Package the built browser for distribution
338
+ fireforge package
339
+
340
+ # Watch for file changes and auto-rebuild
341
+ fireforge watch
342
+
343
+ # Add a CSS design token
344
+ fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
345
+ ```
346
+
294
347
  ## Configuration
295
348
 
296
349
  `fireforge.json` at your project root:
@@ -10,8 +10,9 @@ import type { SpinnerHandle } from '../utils/logger.js';
10
10
  * @param diffContent - Raw unified diff string
11
11
  * @param config - Project configuration
12
12
  * @param skipLint - If true, downgrade errors to warnings
13
+ * @param patchQueueCtx - Optional cross-patch context for ownership resolution
13
14
  */
14
- export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean): Promise<void>;
15
+ export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext): Promise<void>;
15
16
  /**
16
17
  * Resolves patch metadata interactively or from flags, with shared validation.
17
18
  * @param options - Export command options
@@ -17,9 +17,10 @@ import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../ut
17
17
  * @param diffContent - Raw unified diff string
18
18
  * @param config - Project configuration
19
19
  * @param skipLint - If true, downgrade errors to warnings
20
+ * @param patchQueueCtx - Optional cross-patch context for ownership resolution
20
21
  */
21
- export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint) {
22
- const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config);
22
+ export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx) {
23
+ const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx);
23
24
  if (issues.length === 0)
24
25
  return;
25
26
  const errors = issues.filter((i) => i.severity === 'error');
@@ -315,7 +315,7 @@ async function performCreateMutations(args) {
315
315
  await restoreRollbackJournalOrThrow(journal, `Failed to create custom component "${args.componentName}"`);
316
316
  }
317
317
  catch (rollbackError) {
318
- await recordFurnaceRollbackFailure(args.projectRoot, 'create-rollback', toError(rollbackError).message);
318
+ await recordFurnaceRollbackFailure(args.projectRoot, 'create-rollback', `component "${args.componentName}": ${toError(rollbackError).message}`);
319
319
  throw rollbackError;
320
320
  }
321
321
  throw error;
@@ -125,7 +125,7 @@ async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
125
125
  }
126
126
  catch (rollbackError) {
127
127
  if (projectRoot) {
128
- await recordFurnaceRollbackFailure(projectRoot, 'deploy-rollback', toError(rollbackError).message);
128
+ await recordFurnaceRollbackFailure(projectRoot, 'deploy-rollback', `component "${name}": ${toError(rollbackError).message}`);
129
129
  }
130
130
  throw rollbackError;
131
131
  }
@@ -116,7 +116,7 @@ async function performOverrideMutations(args) {
116
116
  await restoreRollbackJournalOrThrow(journal, `Failed to override component "${args.componentName}"`);
117
117
  }
118
118
  catch (rollbackError) {
119
- await recordFurnaceRollbackFailure(args.projectRoot, 'override-rollback', toError(rollbackError).message);
119
+ await recordFurnaceRollbackFailure(args.projectRoot, 'override-rollback', `component "${args.componentName}": ${toError(rollbackError).message}`);
120
120
  throw rollbackError;
121
121
  }
122
122
  throw error;
@@ -149,7 +149,7 @@ async function refreshSingleOverride(projectRoot, name, options = {}) {
149
149
  await restoreRollbackJournalOrThrow(journal, `Failed to refresh override "${name}"`);
150
150
  }
151
151
  catch (rollbackError) {
152
- await recordFurnaceRollbackFailure(projectRoot, 'refresh-rollback', toError(rollbackError).message);
152
+ await recordFurnaceRollbackFailure(projectRoot, 'refresh-rollback', `override "${name}": ${toError(rollbackError).message}`);
153
153
  throw rollbackError;
154
154
  }
155
155
  }
@@ -343,7 +343,7 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
343
343
  await restoreRollbackJournalOrThrow(journal, `Failed to remove component "${name}"`);
344
344
  }
345
345
  catch (rollbackError) {
346
- await recordFurnaceRollbackFailure(projectRoot, 'remove-rollback', toError(rollbackError).message);
346
+ await recordFurnaceRollbackFailure(projectRoot, 'remove-rollback', `component "${name}": ${toError(rollbackError).message}`);
347
347
  throw rollbackError;
348
348
  }
349
349
  throw error;
@@ -185,7 +185,7 @@ async function performRenameMutations(args) {
185
185
  await restoreRollbackJournalOrThrow(journal, `Failed to rename component "${oldName}" to "${newName}"`);
186
186
  }
187
187
  catch (rollbackError) {
188
- await recordFurnaceRollbackFailure(projectRoot, 'rename-rollback', toError(rollbackError).message);
188
+ await recordFurnaceRollbackFailure(projectRoot, 'rename-rollback', `rename "${oldName}" → "${newName}": ${toError(rollbackError).message}`);
189
189
  throw rollbackError;
190
190
  }
191
191
  throw error;
@@ -67,7 +67,7 @@ async function promptAddComponents(components, tracked, projectRoot) {
67
67
  await restoreRollbackJournalOrThrow(journal, 'Failed to update furnace.json during scan');
68
68
  }
69
69
  catch (rollbackError) {
70
- await recordFurnaceRollbackFailure(projectRoot, 'scan-rollback', toError(rollbackError).message);
70
+ await recordFurnaceRollbackFailure(projectRoot, 'scan-rollback', `furnace.json update during scan: ${toError(rollbackError).message}`);
71
71
  throw rollbackError;
72
72
  }
73
73
  throw error;
@@ -85,14 +85,19 @@ export async function lintCommand(projectRoot, files) {
85
85
  }
86
86
  const config = await loadConfig(projectRoot);
87
87
  const filesAffected = extractAffectedFiles(diff);
88
+ // Build patch queue context once so it can be shared between the
89
+ // per-patch ownership resolver and the cross-patch rules.
90
+ let ctx;
91
+ if (await pathExists(paths.patches)) {
92
+ ctx = await buildPatchQueueContext(paths.patches);
93
+ }
88
94
  const issues = [
89
- ...(await lintExportedPatch(paths.engine, filesAffected, diff, config)),
95
+ ...(await lintExportedPatch(paths.engine, filesAffected, diff, config, ctx)),
90
96
  ];
91
97
  // Cross-patch rules operate over the whole queue, so run them whenever a
92
98
  // patches directory exists — they surface duplicate /dev/null creations
93
99
  // and forward-import chains that the per-patch orchestrator cannot see.
94
- if (await pathExists(paths.patches)) {
95
- const ctx = await buildPatchQueueContext(paths.patches);
100
+ if (ctx) {
96
101
  issues.push(...lintPatchQueue(ctx));
97
102
  }
98
103
  if (issues.length === 0) {
@@ -3,6 +3,7 @@ import { cpus } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { group, select, text } from '@clack/prompts';
5
5
  import { getProjectPaths, writeConfig } from '../core/config.js';
6
+ import { PATCHES_MANIFEST, savePatchesManifest } from '../core/patch-manifest-io.js';
6
7
  import { CancellationError, InvalidArgumentError } from '../errors/base.js';
7
8
  import { ensureDir, pathExists, readText, writeText } from '../utils/fs.js';
8
9
  import { cancel } from '../utils/logger.js';
@@ -251,6 +252,10 @@ export async function writeSetupProjectFiles(projectRoot, config) {
251
252
  await ensureDir(paths.configs);
252
253
  await ensureDir(paths.fireforgeDir);
253
254
  await writeConfig(projectRoot, config);
255
+ const manifestPath = join(paths.patches, PATCHES_MANIFEST);
256
+ if (!(await pathExists(manifestPath))) {
257
+ await savePatchesManifest(paths.patches, { version: 1, patches: [] });
258
+ }
254
259
  const gitignorePath = join(projectRoot, '.gitignore');
255
260
  const requiredIgnores = ['node_modules/', 'dist/', 'engine/', '.fireforge/'];
256
261
  if (await pathExists(gitignorePath)) {
@@ -1,3 +1,4 @@
1
+ import * as acorn from 'acorn';
1
2
  import type * as estree from 'estree';
2
3
  import { walk } from 'estree-walker';
3
4
  /**
@@ -16,6 +17,15 @@ export type AcornESTreeNode<T extends estree.Node = estree.Node> = T & {
16
17
  * `customElements.js`, etc.) are scripts that run in a privileged scope.
17
18
  */
18
19
  export declare function parseScript(content: string): AcornESTreeNode<estree.Program>;
20
+ /**
21
+ * Parse JavaScript source as an **ES module**.
22
+ * Used for `.sys.mjs` files which use static import/export syntax.
23
+ *
24
+ * @param content - Source text to parse
25
+ * @param onComment - Optional array that acorn fills with comment nodes
26
+ * @returns Parsed program AST with character-offset positions
27
+ */
28
+ export declare function parseModule(content: string, onComment?: acorn.Comment[]): AcornESTreeNode<estree.Program>;
19
29
  /**
20
30
  * Convenience cast from `acorn.Node` (or the generic ESTree union returned
21
31
  * by estree-walker callbacks) to a positioned, narrowly-typed node.
@@ -12,6 +12,24 @@ export function parseScript(content) {
12
12
  ecmaVersion: 'latest',
13
13
  });
14
14
  }
15
+ /**
16
+ * Parse JavaScript source as an **ES module**.
17
+ * Used for `.sys.mjs` files which use static import/export syntax.
18
+ *
19
+ * @param content - Source text to parse
20
+ * @param onComment - Optional array that acorn fills with comment nodes
21
+ * @returns Parsed program AST with character-offset positions
22
+ */
23
+ export function parseModule(content, onComment) {
24
+ const opts = {
25
+ sourceType: 'module',
26
+ ecmaVersion: 'latest',
27
+ locations: true,
28
+ };
29
+ if (onComment)
30
+ opts.onComment = onComment;
31
+ return acorn.parse(content, opts);
32
+ }
15
33
  /**
16
34
  * Convenience cast from `acorn.Node` (or the generic ESTree union returned
17
35
  * by estree-walker callbacks) to a positioned, narrowly-typed node.
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { FireForgeError } from '../errors/base.js';
4
4
  import { ExitCode } from '../errors/codes.js';
5
- import { copyDir, pathExists, readText, writeText } from '../utils/fs.js';
5
+ import { copyDir, pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
7
  /**
8
8
  * Error thrown when branding operations fail.
@@ -49,7 +49,7 @@ export async function setupBranding(engineDir, config) {
49
49
  */
50
50
  async function createConfigureScript(brandingDir, config) {
51
51
  const configureShPath = join(brandingDir, 'configure.sh');
52
- await writeText(configureShPath, buildConfigureScriptContent(config));
52
+ await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config));
53
53
  }
54
54
  function buildConfigureScriptContent(config) {
55
55
  return `# This Source Code Form is subject to the terms of the Mozilla Public
@@ -69,7 +69,7 @@ async function updateBrandProperties(brandingDir, config) {
69
69
  warn('brand.properties not found in branding directory — browser will use default strings');
70
70
  return;
71
71
  }
72
- await writeText(propsPath, buildBrandPropertiesContent(config));
72
+ await writeTextIfChanged(propsPath, buildBrandPropertiesContent(config));
73
73
  }
74
74
  function buildBrandPropertiesContent(config) {
75
75
  return `# This Source Code Form is subject to the terms of the Mozilla Public
@@ -90,7 +90,7 @@ async function updateBrandFtl(brandingDir, config) {
90
90
  warn('brand.ftl not found in branding directory — browser will use default strings');
91
91
  return;
92
92
  }
93
- await writeText(ftlPath, buildBrandFtlContent(config));
93
+ await writeTextIfChanged(ftlPath, buildBrandFtlContent(config));
94
94
  }
95
95
  function buildBrandFtlContent(config) {
96
96
  return `# This Source Code Form is subject to the terms of the Mozilla Public
@@ -128,7 +128,7 @@ async function patchMozConfigure(engineDir, config) {
128
128
  throw new BrandingError('Could not find MOZ_APP_VENDOR imply_option in browser/moz.configure');
129
129
  }
130
130
  content = content.replace(vendorRegex, buildMozConfigureVendorLine(config));
131
- await writeText(mozConfigurePath, content);
131
+ await writeTextIfChanged(mozConfigurePath, content);
132
132
  }
133
133
  function buildMozConfigureVendorLine(config) {
134
134
  return `imply_option("MOZ_APP_VENDOR", "${escapeString(config.vendor)}")`;
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
17
17
  /** Name of the source directory */
18
18
  export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
- export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire"];
20
+ export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -27,6 +27,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
27
27
  'build',
28
28
  'license',
29
29
  'wire',
30
+ 'patchLint',
30
31
  ];
31
32
  /** Supported config paths that can be read or set without --force. */
32
33
  export const SUPPORTED_CONFIG_PATHS = [
@@ -42,6 +43,8 @@ export const SUPPORTED_CONFIG_PATHS = [
42
43
  'build.jobs',
43
44
  'wire',
44
45
  'wire.subscriptDir',
46
+ 'patchLint',
47
+ 'patchLint.checkJs',
45
48
  ];
46
49
  /**
47
50
  * Gets all project paths based on a root directory.
@@ -5,7 +5,7 @@
5
5
  import { ConfigError } from '../errors/config.js';
6
6
  import { verbose } from '../utils/logger.js';
7
7
  import { parseObject } from '../utils/parse.js';
8
- import { isContainedRelativePath } from '../utils/paths.js';
8
+ import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
9
  import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
11
  /**
@@ -27,8 +27,14 @@ export function validateConfig(data) {
27
27
  const vendor = requireConfigString(rec, 'vendor');
28
28
  const appId = requireConfigString(rec, 'appId');
29
29
  const binaryName = requireConfigString(rec, 'binaryName');
30
- if (binaryName.includes('..') || binaryName.includes('/') || binaryName.includes('\\')) {
31
- throw new ConfigError('Config field "binaryName" must not contain path separators or ".."');
30
+ if (binaryName.includes('..') ||
31
+ binaryName.includes('/') ||
32
+ binaryName.includes('\\') ||
33
+ binaryName.includes('\0')) {
34
+ throw new ConfigError('Config field "binaryName" must not contain path separators, "..", or null bytes');
35
+ }
36
+ if (isExplicitAbsolutePath(binaryName)) {
37
+ throw new ConfigError('Config field "binaryName" must not be an absolute path');
32
38
  }
33
39
  if (!isValidAppId(appId)) {
34
40
  throw new ConfigError('Config field "appId" must be a valid reverse-domain identifier (e.g., "org.example.browser")');
@@ -101,6 +107,18 @@ export function validateConfig(data) {
101
107
  }
102
108
  config.license = licenseRaw;
103
109
  }
110
+ // PatchLint
111
+ const patchLintRec = optionalConfigObject(rec, 'patchLint');
112
+ if (patchLintRec) {
113
+ config.patchLint = {};
114
+ const checkJs = patchLintRec.raw('checkJs');
115
+ if (checkJs !== undefined) {
116
+ if (typeof checkJs !== 'boolean') {
117
+ throw new ConfigError('Config field "patchLint.checkJs" must be a boolean');
118
+ }
119
+ config.patchLint.checkJs = checkJs;
120
+ }
121
+ }
104
122
  // Warn on unknown root keys
105
123
  const knownRootKeys = new Set(SUPPORTED_CONFIG_ROOT_KEYS);
106
124
  for (const key of rec.keys()) {
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { mkdir, rm, stat } from 'node:fs/promises';
3
- import { dirname } from 'node:path';
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { ensureDir } from '../utils/fs.js';
6
6
  import { verbose, warn } from '../utils/logger.js';
@@ -25,6 +25,20 @@ function getNodeErrorCode(error) {
25
25
  export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
26
26
  return `${filePath}${suffix}`;
27
27
  }
28
+ const LOCK_PID_FILE = 'pid';
29
+ /**
30
+ * Checks whether a process with the given PID is still running.
31
+ * Uses `kill(pid, 0)` which sends no signal but checks existence.
32
+ */
33
+ function isProcessAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
28
42
  async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
29
43
  try {
30
44
  const lockStat = await stat(lockPath);
@@ -32,6 +46,21 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
32
46
  if (ageMs <= staleMs) {
33
47
  return false;
34
48
  }
49
+ // If the lock directory contains a PID file, check whether the owning
50
+ // process is still running before removing. This prevents premature
51
+ // removal when a slow operation (e.g. mach build) legitimately holds
52
+ // the lock past the stale threshold.
53
+ try {
54
+ const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
55
+ const pid = parseInt(pidContent.trim(), 10);
56
+ if (Number.isFinite(pid) && isProcessAlive(pid)) {
57
+ verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pid} is still running — not removing`);
58
+ return false;
59
+ }
60
+ }
61
+ catch {
62
+ // No PID file or unreadable — fall through to stale removal
63
+ }
35
64
  const staleMessage = onStaleLockMessage?.(ageMs);
36
65
  if (staleMessage) {
37
66
  warn(staleMessage);
@@ -84,6 +113,14 @@ export async function withFileLock(lockPath, operation, options = {}) {
84
113
  await sleep(pollMs);
85
114
  }
86
115
  }
116
+ // Write PID into the lock directory so stale-lock recovery can check
117
+ // whether the owning process is still alive before removing.
118
+ try {
119
+ await writeFile(join(lockPath, LOCK_PID_FILE), String(process.pid), 'utf-8');
120
+ }
121
+ catch {
122
+ // Non-fatal: stale recovery falls back to age-only heuristic
123
+ }
87
124
  try {
88
125
  return await operation();
89
126
  }
@@ -349,7 +349,8 @@ export async function applyAllComponents(root, dryRun = false, options) {
349
349
  // Rollback itself failed: the engine is in a partially restored
350
350
  // state. Persist a pending-repair marker so the next `fireforge
351
351
  // doctor --repair-furnace` run knows to reconcile.
352
- await recordFurnaceRollbackFailure(root, 'apply-rollback', toError(rollbackError).message);
352
+ const failedComponents = result.errors.map((e) => e.name).join(', ');
353
+ await recordFurnaceRollbackFailure(root, 'apply-rollback', `failed component(s): ${failedComponents || '(unknown)'}: ${toError(rollbackError).message}`);
353
354
  throw rollbackError;
354
355
  }
355
356
  }
@@ -4,6 +4,7 @@ import { FurnaceError } from '../errors/furnace.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
+ import { isExplicitAbsolutePath } from '../utils/paths.js';
7
8
  import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
8
9
  import { FIREFORGE_DIR } from './config.js';
9
10
  import { resolveFtlDir } from './furnace-constants.js';
@@ -99,8 +100,11 @@ function parseCustomConfig(data, name) {
99
100
  if (!isString(data['targetPath'])) {
100
101
  throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
101
102
  }
102
- if (data['targetPath'].includes('..')) {
103
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." (path traversal)`);
103
+ if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
104
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
105
+ }
106
+ if (isExplicitAbsolutePath(data['targetPath'])) {
107
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
104
108
  }
105
109
  if (!isBoolean(data['register'])) {
106
110
  throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
@@ -3,7 +3,8 @@
3
3
  * Patch orchestration — coordinates patch discovery, application, and validation.
4
4
  * Pure parsing, content transformation, and lock management are in separate modules.
5
5
  */
6
- import { join } from 'node:path';
6
+ import { lstat } from 'node:fs/promises';
7
+ import { join, resolve } from 'node:path';
7
8
  import { PatchError } from '../errors/patch.js';
8
9
  import { toError } from '../utils/errors.js';
9
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
@@ -34,7 +35,7 @@ async function applySinglePatch(patch, engineDir) {
34
35
  try {
35
36
  patchContent = await readText(patch.path);
36
37
  affectedFiles = extractAffectedFiles(patchContent);
37
- validatePatchTargets(patch, affectedFiles);
38
+ await validatePatchTargets(patch, affectedFiles, engineDir);
38
39
  await applyPatchIdempotent(patch.path, engineDir);
39
40
  return { patch, success: true };
40
41
  }
@@ -143,7 +144,7 @@ export async function validatePatches(patchesDir, engineDir) {
143
144
  for (const patch of patches) {
144
145
  try {
145
146
  const patchContent = await readText(patch.path);
146
- validatePatchTargets(patch, extractAffectedFiles(patchContent));
147
+ await validatePatchTargets(patch, extractAffectedFiles(patchContent), engineDir);
147
148
  }
148
149
  catch (error) {
149
150
  errors.push(`${patch.filename}: ${toError(error).message}`);
@@ -157,11 +158,32 @@ export async function validatePatches(patchesDir, engineDir) {
157
158
  }
158
159
  return { valid: errors.length === 0, errors };
159
160
  }
160
- function validatePatchTargets(patch, affectedFiles) {
161
+ async function validatePatchTargets(patch, affectedFiles, engineDir) {
161
162
  for (const file of affectedFiles) {
162
163
  if (!isContainedRelativePath(file)) {
163
164
  throw new PatchError(`Patch targets a path outside engine/: ${file}`, patch.filename);
164
165
  }
166
+ // When the engine directory is known, verify that existing target paths
167
+ // are not symlinks pointing outside the engine tree. A crafted patch
168
+ // could otherwise write through a symlink to an arbitrary location.
169
+ if (engineDir) {
170
+ const targetPath = join(engineDir, file);
171
+ try {
172
+ const stats = await lstat(targetPath);
173
+ if (stats.isSymbolicLink()) {
174
+ const realPath = resolve(engineDir, file);
175
+ const resolvedEngine = resolve(engineDir);
176
+ if (!realPath.startsWith(resolvedEngine + '/') && realPath !== resolvedEngine) {
177
+ throw new PatchError(`Patch targets a symlink that resolves outside engine/: ${file}`, patch.filename);
178
+ }
179
+ }
180
+ }
181
+ catch (error) {
182
+ // File doesn't exist yet (new file) or stat fails — skip check
183
+ if (error instanceof PatchError)
184
+ throw error;
185
+ }
186
+ }
165
187
  }
166
188
  }
167
189
  /**