@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.
- package/CHANGELOG.md +32 -0
- package/README.md +56 -3
- package/dist/src/commands/export-shared.d.ts +2 -1
- package/dist/src/commands/export-shared.js +3 -2
- package/dist/src/commands/furnace/create.js +1 -1
- package/dist/src/commands/furnace/deploy.js +1 -1
- package/dist/src/commands/furnace/override.js +1 -1
- package/dist/src/commands/furnace/refresh.js +1 -1
- package/dist/src/commands/furnace/remove.js +1 -1
- package/dist/src/commands/furnace/rename.js +1 -1
- package/dist/src/commands/furnace/scan.js +1 -1
- package/dist/src/commands/lint.js +8 -3
- package/dist/src/commands/setup-support.js +5 -0
- package/dist/src/core/ast-utils.d.ts +10 -0
- package/dist/src/core/ast-utils.js +18 -0
- package/dist/src/core/branding.js +5 -5
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +3 -0
- package/dist/src/core/config-validate.js +21 -3
- package/dist/src/core/file-lock.js +39 -2
- package/dist/src/core/furnace-apply.js +2 -1
- package/dist/src/core/furnace-config.js +6 -2
- package/dist/src/core/patch-apply.js +26 -4
- package/dist/src/core/patch-lint-checkjs.d.ts +21 -0
- package/dist/src/core/patch-lint-checkjs.js +225 -0
- package/dist/src/core/patch-lint-cross.d.ts +1 -0
- package/dist/src/core/patch-lint-cross.js +7 -0
- package/dist/src/core/patch-lint-jsdoc.d.ts +21 -0
- package/dist/src/core/patch-lint-jsdoc.js +259 -0
- package/dist/src/core/patch-lint-ownership.d.ts +25 -0
- package/dist/src/core/patch-lint-ownership.js +43 -0
- package/dist/src/core/patch-lint.d.ts +7 -2
- package/dist/src/core/patch-lint.js +30 -30
- package/dist/src/types/config.d.ts +9 -0
- package/dist/src/utils/fs.d.ts +8 -0
- package/dist/src/utils/fs.js +17 -0
- package/dist/src/utils/paths.js +3 -1
- 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
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('..') ||
|
|
31
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
/**
|