@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +54 -1
  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/core/ast-utils.d.ts +10 -0
  14. package/dist/src/core/ast-utils.js +18 -0
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +3 -0
  17. package/dist/src/core/config-validate.js +21 -3
  18. package/dist/src/core/file-lock.js +39 -2
  19. package/dist/src/core/furnace-apply.js +2 -1
  20. package/dist/src/core/furnace-config.js +6 -2
  21. package/dist/src/core/patch-apply.js +26 -4
  22. package/dist/src/core/patch-lint-checkjs.d.ts +21 -0
  23. package/dist/src/core/patch-lint-checkjs.js +225 -0
  24. package/dist/src/core/patch-lint-cross.d.ts +1 -0
  25. package/dist/src/core/patch-lint-cross.js +7 -0
  26. package/dist/src/core/patch-lint-jsdoc.d.ts +21 -0
  27. package/dist/src/core/patch-lint-jsdoc.js +259 -0
  28. package/dist/src/core/patch-lint-ownership.d.ts +25 -0
  29. package/dist/src/core/patch-lint-ownership.js +43 -0
  30. package/dist/src/core/patch-lint.d.ts +7 -2
  31. package/dist/src/core/patch-lint.js +30 -30
  32. package/dist/src/types/config.d.ts +9 -0
  33. package/dist/src/utils/paths.js +3 -1
  34. 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 (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) {
@@ -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('..') || 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
  /**
@@ -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[]>;