@hominis/fireforge 0.11.2 → 0.13.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 +41 -0
- package/README.md +79 -26
- package/dist/src/commands/bootstrap-checks.d.ts +16 -0
- package/dist/src/commands/bootstrap-checks.js +66 -0
- package/dist/src/commands/bootstrap.js +27 -9
- package/dist/src/commands/doctor.d.ts +8 -0
- package/dist/src/commands/doctor.js +7 -1
- package/dist/src/commands/export-flow.js +3 -11
- package/dist/src/commands/export-shared.d.ts +2 -1
- package/dist/src/commands/export-shared.js +7 -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 +12 -3
- package/dist/src/commands/patch/delete.js +1 -15
- package/dist/src/commands/patch/reorder.js +1 -9
- package/dist/src/commands/re-export.js +1 -17
- package/dist/src/commands/verify.js +2 -2
- 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 +14 -3
- package/dist/src/core/patch-lint.js +116 -47
- package/dist/src/core/patch-manifest-resolve.d.ts +5 -0
- package/dist/src/core/patch-manifest-resolve.js +12 -0
- package/dist/src/core/patch-manifest.d.ts +1 -0
- package/dist/src/core/patch-manifest.js +1 -0
- package/dist/src/types/commands/patches.d.ts +2 -2
- package/dist/src/types/config.d.ts +11 -0
- package/dist/src/utils/paths.js +3 -1
- package/package.json +1 -1
|
@@ -13,21 +13,13 @@ import { getProjectPaths } from '../../core/config.js';
|
|
|
13
13
|
import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
|
|
14
14
|
import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
15
15
|
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
16
|
-
import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
|
|
16
|
+
import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
|
|
17
17
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
18
18
|
import { toError } from '../../utils/errors.js';
|
|
19
19
|
import { pathExists } from '../../utils/fs.js';
|
|
20
20
|
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
21
21
|
import { pickDefined } from '../../utils/options.js';
|
|
22
22
|
import { parsePositiveIntegerFlag } from '../../utils/validation.js';
|
|
23
|
-
function resolvePatchIdentifier(identifier, patches) {
|
|
24
|
-
if (/^\d+$/.test(identifier)) {
|
|
25
|
-
const order = parseInt(identifier, 10);
|
|
26
|
-
return patches.find((p) => p.order === order) ?? null;
|
|
27
|
-
}
|
|
28
|
-
const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
|
|
29
|
-
return patches.find((p) => p.filename === normalized) ?? null;
|
|
30
|
-
}
|
|
31
23
|
function padOrder(value, width) {
|
|
32
24
|
return String(value).padStart(width, '0');
|
|
33
25
|
}
|
|
@@ -6,7 +6,7 @@ import { isGitRepository } from '../core/git.js';
|
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
7
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
8
|
import { updatePatch, updatePatchMetadata } from '../core/patch-export.js';
|
|
9
|
-
import { getClaimedFiles, loadPatchesManifest } from '../core/patch-manifest.js';
|
|
9
|
+
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -14,22 +14,6 @@ import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '..
|
|
|
14
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
15
|
import { runPatchLint } from './export-shared.js';
|
|
16
16
|
import { reExportFilesInPlace } from './re-export-files.js';
|
|
17
|
-
/**
|
|
18
|
-
* Resolves patch identifiers (numbers or filenames) to manifest entries.
|
|
19
|
-
* @param identifier - Patch number (e.g. "005") or filename (e.g. "005-ui-storage-modules.patch")
|
|
20
|
-
* @param patches - All patches from the manifest
|
|
21
|
-
* @returns Matching patch metadata
|
|
22
|
-
*/
|
|
23
|
-
function resolvePatchIdentifier(identifier, patches) {
|
|
24
|
-
// If all digits, match by order number
|
|
25
|
-
if (/^\d+$/.test(identifier)) {
|
|
26
|
-
const order = parseInt(identifier, 10);
|
|
27
|
-
return patches.find((p) => p.order === order) ?? null;
|
|
28
|
-
}
|
|
29
|
-
// Match by filename (with or without .patch suffix)
|
|
30
|
-
const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
|
|
31
|
-
return patches.find((p) => p.filename === normalized) ?? null;
|
|
32
|
-
}
|
|
33
17
|
async function scanPatchFiles(currentFilesAffected, engineDir, manifest, patchFilename, isDryRun) {
|
|
34
18
|
const parentDirs = [...new Set(currentFilesAffected.map((f) => dirname(f)))];
|
|
35
19
|
const claimedByOthers = getClaimedFiles(manifest, patchFilename);
|
|
@@ -89,11 +89,11 @@ export async function verifyCommand(projectRoot) {
|
|
|
89
89
|
if (lintIssues.length > 0) {
|
|
90
90
|
warn(`Cross-patch lint issues (${lintIssues.length}):`);
|
|
91
91
|
for (const issue of lintIssues) {
|
|
92
|
-
const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
|
|
92
|
+
const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARN' : 'NOTICE';
|
|
93
93
|
warn(` ${label} [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
94
94
|
if (issue.severity === 'error')
|
|
95
95
|
errorCount += 1;
|
|
96
|
-
else
|
|
96
|
+
else if (issue.severity === 'warning')
|
|
97
97
|
warningCount += 1;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -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[]>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Optional TypeScript `checkJs` pass for patch-owned `.sys.mjs` files.
|
|
4
|
+
*
|
|
5
|
+
* Loads the TypeScript compiler API via dynamic import so it is only
|
|
6
|
+
* required when `patchLint.checkJs` is enabled in `fireforge.json`.
|
|
7
|
+
* TypeScript remains a dev-dependency — if a user enables checkJs
|
|
8
|
+
* without installing it, the pass emits a clear error explaining
|
|
9
|
+
* how to fix it.
|
|
10
|
+
*
|
|
11
|
+
* Separated from `patch-lint.ts` to keep both files within the
|
|
12
|
+
* project's per-file line budget.
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import { pathExists } from '../utils/fs.js';
|
|
16
|
+
import { verbose } from '../utils/logger.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Firefox globals shim
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const SHIM_FILENAME = '__fireforge_firefox_globals.d.ts';
|
|
21
|
+
/**
|
|
22
|
+
* Minimal `.d.ts` shim for Firefox privileged-scope globals.
|
|
23
|
+
*
|
|
24
|
+
* Firefox source is plain JS — no TypeScript allowed. The shim lets
|
|
25
|
+
* `checkJs` run without reporting "cannot find name" for the most
|
|
26
|
+
* common Mozilla APIs. Types are intentionally loose (`any`) because
|
|
27
|
+
* full Firefox type coverage is out of scope.
|
|
28
|
+
*
|
|
29
|
+
* Notable patterns that require shimming:
|
|
30
|
+
* - `const lazy = {};` + `ChromeUtils.defineESModuleGetters(lazy, { ... })`
|
|
31
|
+
* populates `lazy` at runtime; we declare it as `Record<string, any>`.
|
|
32
|
+
* - `Services.obs`, `Services.prefs`, etc. are XPCOM service accessors.
|
|
33
|
+
* - `Ci`, `Cc`, `Cr`, `Cu` are XPCOM component shortcuts.
|
|
34
|
+
* - Browser chrome globals like `gBrowser`, `gURLBar` are common in
|
|
35
|
+
* content scripts wired via `browser.js`.
|
|
36
|
+
*/
|
|
37
|
+
const FIREFOX_GLOBALS_SHIM = `
|
|
38
|
+
declare var Services: any;
|
|
39
|
+
declare var ChromeUtils: {
|
|
40
|
+
defineESModuleGetters(target: any, modules: Record<string, string>): void;
|
|
41
|
+
importESModule(specifier: string): any;
|
|
42
|
+
import(specifier: string): any;
|
|
43
|
+
defineModuleGetter(target: any, name: string, specifier: string): void;
|
|
44
|
+
generateQI(interfaces: any[]): Function;
|
|
45
|
+
isClassInfo(obj: any): boolean;
|
|
46
|
+
};
|
|
47
|
+
declare var Cu: any;
|
|
48
|
+
declare var Ci: any;
|
|
49
|
+
declare var Cc: any;
|
|
50
|
+
declare var Cr: any;
|
|
51
|
+
declare var Components: any;
|
|
52
|
+
declare var XPCOMUtils: any;
|
|
53
|
+
declare var lazy: Record<string, any>;
|
|
54
|
+
declare var PathUtils: any;
|
|
55
|
+
declare var IOUtils: any;
|
|
56
|
+
declare var FileUtils: any;
|
|
57
|
+
declare var gBrowser: any;
|
|
58
|
+
declare var gURLBar: any;
|
|
59
|
+
declare var gNavigatorBundle: any;
|
|
60
|
+
declare var AppConstants: any;
|
|
61
|
+
`;
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Diagnostic filtering
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
/**
|
|
66
|
+
* TS diagnostic codes to suppress because they are inherent to
|
|
67
|
+
* checking Firefox JS files outside of Mozilla's own build system.
|
|
68
|
+
*
|
|
69
|
+
* Firefox uses `resource://` and `chrome://` URL schemes for module
|
|
70
|
+
* imports. TypeScript's module resolver cannot follow these, so every
|
|
71
|
+
* import from an upstream Firefox module produces a spurious
|
|
72
|
+
* "Cannot find module" error. Filtering these out is essential to
|
|
73
|
+
* keep the checkJs pass usable — otherwise every file with an import
|
|
74
|
+
* would be buried in false positives.
|
|
75
|
+
*/
|
|
76
|
+
const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
|
|
77
|
+
2307, // Cannot find module '{0}' or its corresponding type declarations.
|
|
78
|
+
2306, // File '{0}' is not a module.
|
|
79
|
+
2305, // Module '{0}' has no exported member '{1}'.
|
|
80
|
+
2792, // Cannot find module '{0}'. Did you mean to set the 'moduleResolution' option...
|
|
81
|
+
2304, // Cannot find name '{0}'. (for globals we missed in the shim)
|
|
82
|
+
2552, // Cannot find name '{0}'. Did you mean '{1}'?
|
|
83
|
+
2580, // Cannot find name '{0}'. Do you need to install type definitions...
|
|
84
|
+
7016, // Could not find a declaration file for module '{0}'.
|
|
85
|
+
]);
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Public API
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
|
|
91
|
+
*
|
|
92
|
+
* @param repoDir - Absolute path to the engine (repository) directory
|
|
93
|
+
* @param patchOwnedFiles - Set of patch-owned `.sys.mjs` file paths (relative to repoDir)
|
|
94
|
+
* @returns Array of lint issues from TS diagnostics
|
|
95
|
+
*/
|
|
96
|
+
export async function runCheckJs(repoDir, patchOwnedFiles) {
|
|
97
|
+
if (patchOwnedFiles.size === 0)
|
|
98
|
+
return [];
|
|
99
|
+
// Dynamic import — typescript stays as a dev dependency
|
|
100
|
+
let ts;
|
|
101
|
+
try {
|
|
102
|
+
ts = await import('typescript');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
file: '(checkJs)',
|
|
108
|
+
check: 'checkjs-type-error',
|
|
109
|
+
message: 'patchLint.checkJs is enabled but the "typescript" package is not installed. ' +
|
|
110
|
+
'Run "npm install typescript" to enable type checking.',
|
|
111
|
+
severity: 'error',
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
// Resolve absolute paths for root files, filtering to files that exist
|
|
116
|
+
const rootFiles = [];
|
|
117
|
+
const ownedAbsolute = new Set();
|
|
118
|
+
for (const rel of patchOwnedFiles) {
|
|
119
|
+
const abs = resolve(repoDir, rel);
|
|
120
|
+
if (await pathExists(abs)) {
|
|
121
|
+
rootFiles.push(abs);
|
|
122
|
+
ownedAbsolute.add(abs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (rootFiles.length === 0)
|
|
126
|
+
return [];
|
|
127
|
+
const shimPath = resolve(repoDir, SHIM_FILENAME);
|
|
128
|
+
rootFiles.push(shimPath);
|
|
129
|
+
const options = {
|
|
130
|
+
allowJs: true,
|
|
131
|
+
checkJs: true,
|
|
132
|
+
noEmit: true,
|
|
133
|
+
strict: false,
|
|
134
|
+
target: ts.ScriptTarget.ESNext,
|
|
135
|
+
module: ts.ModuleKind.ESNext,
|
|
136
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
137
|
+
skipLibCheck: true,
|
|
138
|
+
// Do not follow import/reference directives into the Firefox tree.
|
|
139
|
+
// We only want to check the patch-owned files themselves.
|
|
140
|
+
// Without this, TS would try (and fail) to resolve every
|
|
141
|
+
// resource:// and chrome:// import, flooding the output with
|
|
142
|
+
// "Cannot find module" errors for upstream Firefox modules.
|
|
143
|
+
noResolve: true,
|
|
144
|
+
// Suppress implicit-any noise — Firefox code rarely has full type
|
|
145
|
+
// annotations and drowning users in thousands of implicit-any
|
|
146
|
+
// errors defeats the purpose of a focused check.
|
|
147
|
+
noImplicitAny: false,
|
|
148
|
+
};
|
|
149
|
+
// Custom compiler host: reads patch-owned files from disk, returns
|
|
150
|
+
// the shim for the shim path, and returns empty content for
|
|
151
|
+
// anything else to avoid reading the full Firefox tree.
|
|
152
|
+
const defaultHost = ts.createCompilerHost(options);
|
|
153
|
+
const host = {
|
|
154
|
+
...defaultHost,
|
|
155
|
+
getSourceFile(fileName, languageVersion, onError) {
|
|
156
|
+
if (fileName === shimPath) {
|
|
157
|
+
return ts.createSourceFile(fileName, FIREFOX_GLOBALS_SHIM, languageVersion, true);
|
|
158
|
+
}
|
|
159
|
+
if (ownedAbsolute.has(fileName)) {
|
|
160
|
+
return defaultHost.getSourceFile(fileName, languageVersion, onError);
|
|
161
|
+
}
|
|
162
|
+
// For lib files (lib.es*.d.ts) delegate to the default host
|
|
163
|
+
// so built-in types like Promise, Array, etc. are available.
|
|
164
|
+
if (fileName.includes('lib.') && fileName.endsWith('.d.ts')) {
|
|
165
|
+
return defaultHost.getSourceFile(fileName, languageVersion, onError);
|
|
166
|
+
}
|
|
167
|
+
// Return an empty source file for anything else to avoid
|
|
168
|
+
// reading unrelated Firefox source files.
|
|
169
|
+
return ts.createSourceFile(fileName, '', languageVersion, true);
|
|
170
|
+
},
|
|
171
|
+
fileExists(fileName) {
|
|
172
|
+
if (fileName === shimPath)
|
|
173
|
+
return true;
|
|
174
|
+
if (ownedAbsolute.has(fileName))
|
|
175
|
+
return true;
|
|
176
|
+
return defaultHost.fileExists(fileName);
|
|
177
|
+
},
|
|
178
|
+
readFile(fileName) {
|
|
179
|
+
if (fileName === shimPath)
|
|
180
|
+
return FIREFOX_GLOBALS_SHIM;
|
|
181
|
+
return defaultHost.readFile(fileName);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const program = ts.createProgram(rootFiles, options, host);
|
|
185
|
+
const allDiagnostics = [
|
|
186
|
+
...program.getSemanticDiagnostics(),
|
|
187
|
+
...program.getSyntacticDiagnostics(),
|
|
188
|
+
];
|
|
189
|
+
// Filter to diagnostics originating in patch-owned files only,
|
|
190
|
+
// and suppress module-resolution / unknown-name noise that is
|
|
191
|
+
// inherent to checking Firefox JS outside Mozilla's build system.
|
|
192
|
+
const issues = [];
|
|
193
|
+
for (const diag of allDiagnostics) {
|
|
194
|
+
if (SUPPRESSED_DIAGNOSTIC_CODES.has(diag.code))
|
|
195
|
+
continue;
|
|
196
|
+
const sourceFile = diag.file;
|
|
197
|
+
if (!sourceFile)
|
|
198
|
+
continue;
|
|
199
|
+
if (!ownedAbsolute.has(sourceFile.fileName))
|
|
200
|
+
continue;
|
|
201
|
+
const lineInfo = sourceFile.getLineAndCharacterOfPosition(diag.start ?? 0);
|
|
202
|
+
const line = lineInfo.line + 1;
|
|
203
|
+
const messageText = typeof diag.messageText === 'string'
|
|
204
|
+
? diag.messageText
|
|
205
|
+
: ts.flattenDiagnosticMessageText(diag.messageText, '\n');
|
|
206
|
+
// Find the relative path for the issue
|
|
207
|
+
let relPath = sourceFile.fileName;
|
|
208
|
+
for (const [rel, abs] of [...patchOwnedFiles].map((r) => [r, resolve(repoDir, r)])) {
|
|
209
|
+
if (abs === sourceFile.fileName) {
|
|
210
|
+
relPath = rel;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const severity = diag.category === ts.DiagnosticCategory.Error ? 'error' : 'warning';
|
|
215
|
+
issues.push({
|
|
216
|
+
file: relPath,
|
|
217
|
+
check: 'checkjs-type-error',
|
|
218
|
+
message: `Line ${line}: ${messageText}`,
|
|
219
|
+
severity,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s), found ${issues.length} issue(s)`);
|
|
223
|
+
return issues;
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=patch-lint-checkjs.js.map
|
|
@@ -113,6 +113,7 @@ export declare function isForwardImportableFile(path: string): boolean;
|
|
|
113
113
|
* - `import "specifier"` (side-effect imports — the `from`
|
|
114
114
|
* clause is optional in the regex)
|
|
115
115
|
* - `import("specifier")` (dynamic imports)
|
|
116
|
+
* - ChromeUtils.importESModule("specifier")
|
|
116
117
|
* - ChromeUtils.defineESModuleGetters(obj, { Name: "specifier", ... })
|
|
117
118
|
*
|
|
118
119
|
* Returns the raw specifier strings — callers should take the leaf basename
|
|
@@ -156,6 +156,7 @@ export function isForwardImportableFile(path) {
|
|
|
156
156
|
* - `import "specifier"` (side-effect imports — the `from`
|
|
157
157
|
* clause is optional in the regex)
|
|
158
158
|
* - `import("specifier")` (dynamic imports)
|
|
159
|
+
* - ChromeUtils.importESModule("specifier")
|
|
159
160
|
* - ChromeUtils.defineESModuleGetters(obj, { Name: "specifier", ... })
|
|
160
161
|
*
|
|
161
162
|
* Returns the raw specifier strings — callers should take the leaf basename
|
|
@@ -282,6 +283,12 @@ export function extractImportSpecifiersWithLines(source) {
|
|
|
282
283
|
if (match[1])
|
|
283
284
|
results.push({ specifier: match[1], line: offsetToLine(match.index) });
|
|
284
285
|
}
|
|
286
|
+
// ChromeUtils.importESModule("resource://...") — Firefox single-module import
|
|
287
|
+
const chromeUtilsPattern = /ChromeUtils\.importESModule\s*\(\s*["']([^"']+)["']/g;
|
|
288
|
+
while ((match = chromeUtilsPattern.exec(stripped)) !== null) {
|
|
289
|
+
if (match[1])
|
|
290
|
+
results.push({ specifier: match[1], line: offsetToLine(match.index) });
|
|
291
|
+
}
|
|
285
292
|
collectGetterSpecifiers(stripped, results, offsetToLine);
|
|
286
293
|
return results;
|
|
287
294
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-based JSDoc validation for exported declarations in `.sys.mjs`
|
|
3
|
+
* modules. Uses Acorn (already a runtime dependency) to parse the
|
|
4
|
+
* module and inspects JSDoc comments via the `onComment` callback.
|
|
5
|
+
*
|
|
6
|
+
* Separated from `patch-lint.ts` to keep both files within the
|
|
7
|
+
* project's per-file line budget.
|
|
8
|
+
*/
|
|
9
|
+
export type JsDocCheck = 'missing-jsdoc' | 'jsdoc-param-mismatch' | 'jsdoc-missing-returns';
|
|
10
|
+
export interface JsDocIssue {
|
|
11
|
+
line: number;
|
|
12
|
+
check: JsDocCheck;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validates JSDoc on exported declarations in a `.sys.mjs` source file.
|
|
17
|
+
*
|
|
18
|
+
* @param source - File content
|
|
19
|
+
* @returns Array of JSDoc issues found
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateExportJsDoc(source: string): JsDocIssue[];
|