@hominis/fireforge 0.11.2 → 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 +24 -0
- package/README.md +54 -1
- 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/core/ast-utils.d.ts +10 -0
- package/dist/src/core/ast-utils.js +18 -0
- 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/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
|
package/README.md
CHANGED
|
@@ -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) {
|
|
@@ -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.
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional TypeScript `checkJs` pass for patch-owned `.sys.mjs` files.
|
|
3
|
+
*
|
|
4
|
+
* Loads the TypeScript compiler API via dynamic import so it is only
|
|
5
|
+
* required when `patchLint.checkJs` is enabled in `fireforge.json`.
|
|
6
|
+
* TypeScript remains a dev-dependency — if a user enables checkJs
|
|
7
|
+
* without installing it, the pass emits a clear error explaining
|
|
8
|
+
* how to fix it.
|
|
9
|
+
*
|
|
10
|
+
* Separated from `patch-lint.ts` to keep both files within the
|
|
11
|
+
* project's per-file line budget.
|
|
12
|
+
*/
|
|
13
|
+
import type { PatchLintIssue } from '../types/commands/index.js';
|
|
14
|
+
/**
|
|
15
|
+
* Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
|
|
16
|
+
*
|
|
17
|
+
* @param repoDir - Absolute path to the engine (repository) directory
|
|
18
|
+
* @param patchOwnedFiles - Set of patch-owned `.sys.mjs` file paths (relative to repoDir)
|
|
19
|
+
* @returns Array of lint issues from TS diagnostics
|
|
20
|
+
*/
|
|
21
|
+
export declare function runCheckJs(repoDir: string, patchOwnedFiles: Set<string>): Promise<PatchLintIssue[]>;
|