@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +79 -26
  3. package/dist/src/commands/bootstrap-checks.d.ts +16 -0
  4. package/dist/src/commands/bootstrap-checks.js +66 -0
  5. package/dist/src/commands/bootstrap.js +27 -9
  6. package/dist/src/commands/doctor.d.ts +8 -0
  7. package/dist/src/commands/doctor.js +7 -1
  8. package/dist/src/commands/export-flow.js +3 -11
  9. package/dist/src/commands/export-shared.d.ts +2 -1
  10. package/dist/src/commands/export-shared.js +7 -2
  11. package/dist/src/commands/furnace/create.js +1 -1
  12. package/dist/src/commands/furnace/deploy.js +1 -1
  13. package/dist/src/commands/furnace/override.js +1 -1
  14. package/dist/src/commands/furnace/refresh.js +1 -1
  15. package/dist/src/commands/furnace/remove.js +1 -1
  16. package/dist/src/commands/furnace/rename.js +1 -1
  17. package/dist/src/commands/furnace/scan.js +1 -1
  18. package/dist/src/commands/lint.js +12 -3
  19. package/dist/src/commands/patch/delete.js +1 -15
  20. package/dist/src/commands/patch/reorder.js +1 -9
  21. package/dist/src/commands/re-export.js +1 -17
  22. package/dist/src/commands/verify.js +2 -2
  23. package/dist/src/core/ast-utils.d.ts +10 -0
  24. package/dist/src/core/ast-utils.js +18 -0
  25. package/dist/src/core/config-paths.d.ts +2 -2
  26. package/dist/src/core/config-paths.js +3 -0
  27. package/dist/src/core/config-validate.js +21 -3
  28. package/dist/src/core/file-lock.js +39 -2
  29. package/dist/src/core/furnace-apply.js +2 -1
  30. package/dist/src/core/furnace-config.js +6 -2
  31. package/dist/src/core/patch-apply.js +26 -4
  32. package/dist/src/core/patch-lint-checkjs.d.ts +21 -0
  33. package/dist/src/core/patch-lint-checkjs.js +225 -0
  34. package/dist/src/core/patch-lint-cross.d.ts +1 -0
  35. package/dist/src/core/patch-lint-cross.js +7 -0
  36. package/dist/src/core/patch-lint-jsdoc.d.ts +21 -0
  37. package/dist/src/core/patch-lint-jsdoc.js +259 -0
  38. package/dist/src/core/patch-lint-ownership.d.ts +25 -0
  39. package/dist/src/core/patch-lint-ownership.js +43 -0
  40. package/dist/src/core/patch-lint.d.ts +14 -3
  41. package/dist/src/core/patch-lint.js +116 -47
  42. package/dist/src/core/patch-manifest-resolve.d.ts +5 -0
  43. package/dist/src/core/patch-manifest-resolve.js +12 -0
  44. package/dist/src/core/patch-manifest.d.ts +1 -0
  45. package/dist/src/core/patch-manifest.js +1 -0
  46. package/dist/src/types/commands/patches.d.ts +2 -2
  47. package/dist/src/types/config.d.ts +11 -0
  48. package/dist/src/utils/paths.js +3 -1
  49. 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('..') || 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[]>;
@@ -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[];