@hominis/fireforge 0.44.3 → 0.45.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 (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/build.js +9 -4
  4. package/dist/src/commands/doctor-source-pin.d.ts +29 -0
  5. package/dist/src/commands/doctor-source-pin.js +81 -0
  6. package/dist/src/commands/doctor.js +2 -0
  7. package/dist/src/commands/export-flow.js +3 -18
  8. package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
  9. package/dist/src/commands/furnace/create-browser-test.js +6 -6
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +16 -11
  12. package/dist/src/commands/furnace/create-xpcshell.js +2 -2
  13. package/dist/src/commands/furnace/create.js +5 -0
  14. package/dist/src/commands/patch/move-files-into.js +3 -6
  15. package/dist/src/commands/patch/split-plan.js +3 -6
  16. package/dist/src/commands/re-export-adjacent.d.ts +8 -0
  17. package/dist/src/commands/re-export-adjacent.js +26 -1
  18. package/dist/src/commands/re-export-drift.d.ts +8 -0
  19. package/dist/src/commands/re-export-drift.js +61 -0
  20. package/dist/src/commands/re-export-files.js +3 -6
  21. package/dist/src/commands/re-export-options.js +19 -5
  22. package/dist/src/commands/re-export-register.js +5 -1
  23. package/dist/src/commands/re-export-scan.js +3 -6
  24. package/dist/src/commands/re-export.js +16 -16
  25. package/dist/src/commands/test-build-phase.js +3 -2
  26. package/dist/src/commands/test-diagnose.js +14 -5
  27. package/dist/src/commands/test-register.js +1 -0
  28. package/dist/src/commands/test-run.js +4 -3
  29. package/dist/src/commands/test-stale-gate.js +5 -2
  30. package/dist/src/commands/test.js +9 -2
  31. package/dist/src/core/build-audit.d.ts +20 -0
  32. package/dist/src/core/build-audit.js +69 -3
  33. package/dist/src/core/build-baseline-types.d.ts +28 -0
  34. package/dist/src/core/build-baseline.d.ts +16 -1
  35. package/dist/src/core/build-baseline.js +38 -2
  36. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  37. package/dist/src/core/build-jar-escalation.js +127 -0
  38. package/dist/src/core/build-prepare.d.ts +12 -1
  39. package/dist/src/core/build-prepare.js +68 -6
  40. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  41. package/dist/src/core/config-validate-file-size.js +65 -0
  42. package/dist/src/core/config-validate.js +6 -0
  43. package/dist/src/core/engine-changes.d.ts +18 -0
  44. package/dist/src/core/engine-changes.js +34 -0
  45. package/dist/src/core/furnace-constants.d.ts +21 -0
  46. package/dist/src/core/furnace-constants.js +26 -1
  47. package/dist/src/core/harness-orphans.d.ts +73 -0
  48. package/dist/src/core/harness-orphans.js +186 -0
  49. package/dist/src/core/license-headers.d.ts +9 -5
  50. package/dist/src/core/license-headers.js +33 -7
  51. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  52. package/dist/src/core/mach-known-noise-filter.js +24 -1
  53. package/dist/src/core/mach.d.ts +7 -0
  54. package/dist/src/core/mach.js +18 -2
  55. package/dist/src/core/patch-apply.js +49 -20
  56. package/dist/src/core/patch-lint-checkjs.js +6 -1
  57. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  58. package/dist/src/core/patch-lint-file-size.js +53 -0
  59. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  60. package/dist/src/core/patch-lint-prettier.js +126 -0
  61. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  62. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  63. package/dist/src/core/patch-lint.js +23 -24
  64. package/dist/src/core/patch-transform.d.ts +21 -0
  65. package/dist/src/core/patch-transform.js +38 -0
  66. package/dist/src/core/smoke-patterns.js +12 -0
  67. package/dist/src/core/test-harness-crash.js +61 -48
  68. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  69. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  70. package/dist/src/core/test-stale-check.d.ts +23 -6
  71. package/dist/src/core/test-stale-check.js +77 -15
  72. package/dist/src/core/test-stall-triage.js +37 -19
  73. package/dist/src/errors/build.d.ts +28 -0
  74. package/dist/src/errors/build.js +49 -0
  75. package/dist/src/types/commands/options.d.ts +19 -0
  76. package/dist/src/types/commands/patches.d.ts +17 -6
  77. package/dist/src/types/config.d.ts +41 -0
  78. package/dist/src/utils/fs.d.ts +4 -2
  79. package/dist/src/utils/fs.js +3 -1
  80. package/docs/configuration.md +47 -0
  81. package/docs/exit-codes.md +4 -2
  82. package/docs/testing.md +83 -0
  83. package/package.json +1 -1
@@ -3,19 +3,19 @@
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 { lstat, readlink, realpath } from 'node:fs/promises';
6
+ import { lstat, readFile, readlink, realpath, rm } from 'node:fs/promises';
7
7
  import { basename, dirname, join, resolve, sep } from 'node:path';
8
8
  import { PatchError } from '../errors/patch.js';
9
9
  import { toError } from '../utils/errors.js';
10
- import { pathExists, readText, writeText } from '../utils/fs.js';
10
+ import { pathExists, readText, writeFileAtomic, writeText } from '../utils/fs.js';
11
11
  import { verbose } from '../utils/logger.js';
12
12
  import { isContainedRelativePath } from '../utils/paths.js';
13
13
  import { applyPatchIdempotent, reversePatch } from './git.js';
14
14
  import { getFileContentFromHead } from './git-file-ops.js';
15
15
  import { discoverPatches } from './patch-files.js';
16
16
  import { loadPatchesManifest } from './patch-manifest.js';
17
- import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch } from './patch-parse.js';
18
- import { applyPatchTextToContent, extractNewFileContent } from './patch-transform.js';
17
+ import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch, parseDiffSections, } from './patch-parse.js';
18
+ import { applyPatchTextToContent, extractNewFileContentFromDiff } from './patch-transform.js';
19
19
  // Re-export from split modules so existing import sites continue working
20
20
  export { PatchError } from '../errors/patch.js';
21
21
  export { countPatches, discoverPatches, getAllTargetFilesFromPatch, getTargetFileFromPatch, isNewFilePatch, } from './patch-files.js';
@@ -53,28 +53,57 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
53
53
  const applyError = toError(error);
54
54
  // Check if this is a resolvable "new file" conflict
55
55
  let resolvedNewFiles = false;
56
- // Save original content for files we might overwrite, so we can restore on failure
56
+ // Save original BYTES of files we might overwrite, so we can restore on
57
+ // failure. Raw buffers rather than decoded text: the same snapshot has to
58
+ // restore a binary target byte-for-byte, and a Buffer round-trips text
59
+ // losslessly where a utf-8 decode of a binary file does not.
57
60
  const savedContents = new Map();
61
+ // New-file sections keyed by target, so the binary discrimination below
62
+ // reads the ALREADY-parsed section instead of re-scanning the body per
63
+ // file.
64
+ const newFileSections = new Map(parseDiffSections(patchContent)
65
+ .filter((section) => section.isNewFile)
66
+ .map((section) => [section.targetPath, section]));
58
67
  try {
59
68
  for (const file of affectedFiles) {
60
- if (isNewFileInPatch(patchContent, file)) {
61
- const targetPath = join(engineDir, file);
62
- if (await pathExists(targetPath)) {
63
- savedContents.set(file, await readText(targetPath));
64
- const content = await extractNewFileContent(patch.path, file);
65
- await writeText(targetPath, content);
66
- resolvedNewFiles = true;
69
+ if (!isNewFileInPatch(patchContent, file))
70
+ continue;
71
+ const targetPath = join(engineDir, file);
72
+ if (!(await pathExists(targetPath)))
73
+ continue;
74
+ savedContents.set(file, await readFile(targetPath));
75
+ const section = newFileSections.get(file);
76
+ if (section?.isBinary === true) {
77
+ // A binary new file cannot be resolved by writing extracted text —
78
+ // the payload is base85, whose alphabet includes '+'. It does not
79
+ // need to be: `git apply` decodes a `GIT binary patch` itself. So
80
+ // the resolution is to REMOVE the blocking file and let the retry
81
+ // below create it from the payload, which is exactly what the
82
+ // text branch achieves by overwriting.
83
+ if (!section.hasBinaryDelta) {
84
+ // `Binary files … differ` records THAT the bytes changed and
85
+ // carries none of them, so nothing can recreate the file. Fail
86
+ // honestly rather than deleting a file we cannot put back.
87
+ throw new PatchError(`Cannot resolve the new-file conflict for ${file}: the patch's binary section ` +
88
+ 'carries no replayable payload (`Binary files … differ` rather than `GIT binary ' +
89
+ 'patch`), so the file cannot be recreated. Re-export the patch with a binary-aware ' +
90
+ 'diff.', file);
67
91
  }
92
+ await rm(targetPath, { force: true });
68
93
  }
94
+ else {
95
+ await writeText(targetPath, extractNewFileContentFromDiff(patchContent, file));
96
+ }
97
+ resolvedNewFiles = true;
69
98
  }
70
99
  }
71
100
  catch (extractError) {
72
- // extractNewFileContent threw for a LATER target (e.g. a binary
73
- // section) after earlier targets were already overwritten — restore
74
- // them before reporting failure, instead of letting the exception
75
- // skip the restore loop entirely and crash the whole import.
101
+ // Resolution threw for a LATER target after earlier targets were
102
+ // already overwritten or removed — restore them before reporting
103
+ // failure, instead of letting the exception skip the restore loop
104
+ // entirely and crash the whole import.
76
105
  for (const [file, originalContent] of savedContents) {
77
- await writeText(join(engineDir, file), originalContent);
106
+ await writeFileAtomic(join(engineDir, file), originalContent);
78
107
  }
79
108
  return { patch, success: false, error: toError(extractError).message };
80
109
  }
@@ -91,9 +120,9 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
91
120
  }
92
121
  catch (retryError) {
93
122
  verbose(`Auto-resolved new-file retry failed for ${patch.filename}: ${toError(retryError).message}`);
94
- // Restore original file content before falling through to --reject
123
+ // Restore original file bytes before falling through to --reject
95
124
  for (const [file, originalContent] of savedContents) {
96
- await writeText(join(engineDir, file), originalContent);
125
+ await writeFileAtomic(join(engineDir, file), originalContent);
97
126
  }
98
127
  }
99
128
  }
@@ -138,7 +167,7 @@ async function rollbackPatches(results, engineDir) {
138
167
  if (result.autoResolvedOriginals) {
139
168
  for (const [file, originalContent] of result.autoResolvedOriginals) {
140
169
  try {
141
- await writeText(join(engineDir, file), originalContent);
170
+ await writeFileAtomic(join(engineDir, file), originalContent);
142
171
  verbose(`Restored pre-existing content of ${file} after rollback`);
143
172
  }
144
173
  catch (restoreError) {
@@ -30,6 +30,7 @@ import { toError } from '../utils/errors.js';
30
30
  import { pathExists } from '../utils/fs.js';
31
31
  import { verbose } from '../utils/logger.js';
32
32
  import { normalizePathSlashes } from '../utils/paths.js';
33
+ import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
33
34
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
34
35
  // ---------------------------------------------------------------------------
35
36
  // Public API
@@ -464,6 +465,7 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
464
465
  const mode = modeFromPatchLintConfig(patchLint);
465
466
  const seenGlobal = new Set();
466
467
  const files = [...testFiles].sort((a, b) => a.localeCompare(b));
468
+ const companions = await collectUnmanagedCompanions(repoDir, files, testFiles);
467
469
  for (const file of files) {
468
470
  // Under a rootScope only the scoped files get their own program, but
469
471
  // head.js helper discovery still spans the full owned set so a scoped
@@ -483,7 +485,10 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
483
485
  const result = await runCheckJsGrouped(repoDir, roots, patchLint.checkJsTestShim, projectRoot, mode, TEST_HARNESS_SHIM);
484
486
  const own = result.byFile.get(file);
485
487
  if (own && own.length > 0) {
486
- merged.byFile.set(file, [...(merged.byFile.get(file) ?? []), ...own]);
488
+ merged.byFile.set(file, [
489
+ ...(merged.byFile.get(file) ?? []),
490
+ ...retargetUnmanagedCompanionHints(own, companions),
491
+ ]);
487
492
  }
488
493
  for (const globalIssue of result.global) {
489
494
  if (seenGlobal.has(globalIssue.message))
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `file-too-large` thresholds and their rendered message.
3
+ *
4
+ * Its own module for two reasons: the config validator needs the DEFAULTS
5
+ * to validate an override triple against the merged result, and importing
6
+ * `patch-lint.ts` from `config-validate.ts` for a constant would draw an
7
+ * import edge (and a cycle) between the rule bodies and config parsing.
8
+ *
9
+ * On the vocabulary: the bands were `notice` / `warning` / `error` with the
10
+ * middle one described as a "soft limit". That description is false under
11
+ * the posture FireForge itself recommends for a release gate,
12
+ * `--max-warnings 0`, where a warning is a hard failure — so a file at 751
13
+ * lines failed a gate with a message calling 750 soft. The message below
14
+ * says what the band IS (a warning) and what a zero-warning gate does with
15
+ * it, and the thresholds are now tunable, so a project whose file-size
16
+ * policy differs has a dial instead of an argument.
17
+ */
18
+ import type { PatchLintFileSizeThresholds } from '../types/config.js';
19
+ /** A fully resolved threshold triple. */
20
+ export interface ResolvedFileSizeTier {
21
+ notice: number;
22
+ warning: number;
23
+ error: number;
24
+ }
25
+ /** Built-in defaults, per file class. */
26
+ export declare const DEFAULT_FILE_SIZE_THRESHOLDS: {
27
+ general: ResolvedFileSizeTier;
28
+ test: ResolvedFileSizeTier;
29
+ };
30
+ /**
31
+ * Merges a configured override over the defaults for one file class.
32
+ *
33
+ * @param overrides - The `patchLint.fileSizeThresholds` block, if any
34
+ * @param isTest - Whether the file counts as a test file
35
+ * @returns The effective triple for this file
36
+ */
37
+ export declare function resolveFileSizeThresholds(overrides: PatchLintFileSizeThresholds | undefined, isTest: boolean): ResolvedFileSizeTier;
38
+ /**
39
+ * Renders the `file-too-large` message for a given band.
40
+ *
41
+ * The `warning` and `notice` bands name the gate consequence explicitly:
42
+ * the number is advisory to patch-lint and blocking to a `--max-warnings 0`
43
+ * gate, and an operator deciding whether to decompose a file needs to know
44
+ * which of those they are looking at.
45
+ */
46
+ export declare function formatFileTooLargeMessage(args: {
47
+ label: string;
48
+ lineCount: number;
49
+ thresholds: ResolvedFileSizeTier;
50
+ band: 'notice' | 'warning' | 'error';
51
+ verb: string;
52
+ }): string;
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The `file-too-large` thresholds and their rendered message.
4
+ *
5
+ * Its own module for two reasons: the config validator needs the DEFAULTS
6
+ * to validate an override triple against the merged result, and importing
7
+ * `patch-lint.ts` from `config-validate.ts` for a constant would draw an
8
+ * import edge (and a cycle) between the rule bodies and config parsing.
9
+ *
10
+ * On the vocabulary: the bands were `notice` / `warning` / `error` with the
11
+ * middle one described as a "soft limit". That description is false under
12
+ * the posture FireForge itself recommends for a release gate,
13
+ * `--max-warnings 0`, where a warning is a hard failure — so a file at 751
14
+ * lines failed a gate with a message calling 750 soft. The message below
15
+ * says what the band IS (a warning) and what a zero-warning gate does with
16
+ * it, and the thresholds are now tunable, so a project whose file-size
17
+ * policy differs has a dial instead of an argument.
18
+ */
19
+ /** Built-in defaults, per file class. */
20
+ export const DEFAULT_FILE_SIZE_THRESHOLDS = {
21
+ general: { notice: 500, warning: 750, error: 900 },
22
+ test: { notice: 1200, warning: 1400, error: 1600 },
23
+ };
24
+ /**
25
+ * Merges a configured override over the defaults for one file class.
26
+ *
27
+ * @param overrides - The `patchLint.fileSizeThresholds` block, if any
28
+ * @param isTest - Whether the file counts as a test file
29
+ * @returns The effective triple for this file
30
+ */
31
+ export function resolveFileSizeThresholds(overrides, isTest) {
32
+ const key = isTest ? 'test' : 'general';
33
+ const configured = overrides?.[key] ?? {};
34
+ return { ...DEFAULT_FILE_SIZE_THRESHOLDS[key], ...configured };
35
+ }
36
+ /**
37
+ * Renders the `file-too-large` message for a given band.
38
+ *
39
+ * The `warning` and `notice` bands name the gate consequence explicitly:
40
+ * the number is advisory to patch-lint and blocking to a `--max-warnings 0`
41
+ * gate, and an operator deciding whether to decompose a file needs to know
42
+ * which of those they are looking at.
43
+ */
44
+ export function formatFileTooLargeMessage(args) {
45
+ const { label, lineCount, thresholds, band, verb } = args;
46
+ if (band === 'error') {
47
+ return `${label} has ${String(lineCount)} lines (error threshold: ${String(thresholds.error)}). Consider ${verb}.`;
48
+ }
49
+ return (`${label} has ${String(lineCount)} lines (warning threshold: ${String(thresholds.warning)}, ` +
50
+ `error threshold: ${String(thresholds.error)}). Consider ${verb}. ` +
51
+ `Reported as a ${band}, which a gate running --max-warnings 0 treats as a failure; ` +
52
+ `tune "patchLint.fileSizeThresholds" if these limits are wrong for this project.`);
53
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Optional Prettier pass over patch-owned `.sys.mjs` modules.
3
+ *
4
+ * Until 0.45.0 no tier of the chain ran Prettier on `.sys.mjs` at all, so
5
+ * module formatting drift was caught by nothing and survived to a human
6
+ * reviewer. Worse, the obvious manual check was misleading in the same
7
+ * direction: a fork's ROOT `.prettierignore` typically excludes `engine/`,
8
+ * so `prettier --check --config engine/.prettierrc.js <engine files>` run
9
+ * from the repo root reports "all files use Prettier code style" for files
10
+ * that fail the identical check run from inside `engine/`. A root-level
11
+ * spot-check falsely refuted a correct reviewer finding on exactly this.
12
+ *
13
+ * This pass therefore runs prettier with `cwd` set to the ENGINE directory,
14
+ * so the engine's own `.prettierrc*` and `.prettierignore` resolve the way
15
+ * they do for anyone working in that tree, and the FireForge result agrees
16
+ * with the operator's own command.
17
+ *
18
+ * Opt-in (`patchLint.prettier`, default `'off'`): it spawns a process per
19
+ * lint and is a new failure surface on queues that never had one. When it
20
+ * is off, formatting is explicitly out of scope for the per-patch tier —
21
+ * that is the contract, not an accident.
22
+ */
23
+ import type { PatchLintIssue } from '../types/commands/index.js';
24
+ import type { PatchLintSeverityGate } from '../types/config.js';
25
+ /**
26
+ * Runs `prettier --check` over `files` from inside the engine directory.
27
+ *
28
+ * @param engineDir - Absolute engine directory; also the `cwd`, which is
29
+ * what makes the engine's own config and ignore file authoritative
30
+ * @param projectRoot - FireForge project root, for the fallback binary
31
+ * @param files - Engine-relative patch-owned `.sys.mjs` paths
32
+ * @param gate - Severity for reported issues; `'off'` skips the pass
33
+ * @returns One issue per unformatted file, or a single run-level issue when
34
+ * prettier could not be run at all
35
+ */
36
+ export declare function invokePatchLintPrettier(engineDir: string, projectRoot: string, files: readonly string[], gate: PatchLintSeverityGate): Promise<PatchLintIssue[]>;
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Optional Prettier pass over patch-owned `.sys.mjs` modules.
4
+ *
5
+ * Until 0.45.0 no tier of the chain ran Prettier on `.sys.mjs` at all, so
6
+ * module formatting drift was caught by nothing and survived to a human
7
+ * reviewer. Worse, the obvious manual check was misleading in the same
8
+ * direction: a fork's ROOT `.prettierignore` typically excludes `engine/`,
9
+ * so `prettier --check --config engine/.prettierrc.js <engine files>` run
10
+ * from the repo root reports "all files use Prettier code style" for files
11
+ * that fail the identical check run from inside `engine/`. A root-level
12
+ * spot-check falsely refuted a correct reviewer finding on exactly this.
13
+ *
14
+ * This pass therefore runs prettier with `cwd` set to the ENGINE directory,
15
+ * so the engine's own `.prettierrc*` and `.prettierignore` resolve the way
16
+ * they do for anyone working in that tree, and the FireForge result agrees
17
+ * with the operator's own command.
18
+ *
19
+ * Opt-in (`patchLint.prettier`, default `'off'`): it spawns a process per
20
+ * lint and is a new failure surface on queues that never had one. When it
21
+ * is off, formatting is explicitly out of scope for the per-patch tier —
22
+ * that is the contract, not an accident.
23
+ */
24
+ import { join } from 'node:path';
25
+ import { toError } from '../utils/errors.js';
26
+ import { pathExists } from '../utils/fs.js';
27
+ import { verbose } from '../utils/logger.js';
28
+ import { exec } from '../utils/process.js';
29
+ /** How long prettier gets before the pass gives up (ms). */
30
+ const PRETTIER_TIMEOUT_MS = 120_000;
31
+ /** Prettier's per-file `--check` failure line. */
32
+ const PRETTIER_WARN_LINE = /^\[warn]\s+(.+)$/;
33
+ /** Summary lines prettier prints alongside the per-file ones. */
34
+ const PRETTIER_SUMMARY_LINE = /^\[warn]\s+(?:Code style issues|Forgot to run)/;
35
+ /**
36
+ * Finds prettier, preferring a binary installed in the tree being checked.
37
+ *
38
+ * Order matters: the engine's own `node_modules` is the install whose
39
+ * version matches the engine's config, the project's is the next best
40
+ * guess, and `npx` is the last resort (it can download, so it is never
41
+ * preferred over something already present).
42
+ */
43
+ async function resolvePrettier(engineDir, projectRoot) {
44
+ for (const root of [engineDir, projectRoot]) {
45
+ const candidate = join(root, 'node_modules', '.bin', 'prettier');
46
+ if (await pathExists(candidate)) {
47
+ return { command: candidate, prefixArgs: [] };
48
+ }
49
+ }
50
+ return { command: 'npx', prefixArgs: ['--no-install', 'prettier'] };
51
+ }
52
+ /**
53
+ * Runs `prettier --check` over `files` from inside the engine directory.
54
+ *
55
+ * @param engineDir - Absolute engine directory; also the `cwd`, which is
56
+ * what makes the engine's own config and ignore file authoritative
57
+ * @param projectRoot - FireForge project root, for the fallback binary
58
+ * @param files - Engine-relative patch-owned `.sys.mjs` paths
59
+ * @param gate - Severity for reported issues; `'off'` skips the pass
60
+ * @returns One issue per unformatted file, or a single run-level issue when
61
+ * prettier could not be run at all
62
+ */
63
+ export async function invokePatchLintPrettier(engineDir, projectRoot, files, gate) {
64
+ if (gate === 'off' || files.length === 0)
65
+ return [];
66
+ const severity = gate === 'error' ? 'error' : 'warning';
67
+ const invocation = await resolvePrettier(engineDir, projectRoot);
68
+ let result;
69
+ try {
70
+ result = await exec(invocation.command, [...invocation.prefixArgs, '--check', ...files], {
71
+ cwd: engineDir,
72
+ timeout: PRETTIER_TIMEOUT_MS,
73
+ });
74
+ }
75
+ catch (error) {
76
+ return [
77
+ {
78
+ file: '(prettier)',
79
+ check: 'prettier-format',
80
+ message: `patchLint.prettier is enabled but prettier could not be run (${toError(error).message}). ` +
81
+ 'Install prettier in engine/ or the project root, or set "patchLint.prettier": "off".',
82
+ severity,
83
+ },
84
+ ];
85
+ }
86
+ // Exit 0: everything formatted. Exit 1: at least one file differs. Any
87
+ // other code is prettier failing (bad config, unparseable file), which is
88
+ // a different finding and must not be reported as formatting drift.
89
+ if (result.exitCode === 0)
90
+ return [];
91
+ const output = `${result.stderr}\n${result.stdout}`;
92
+ if (result.exitCode !== 1) {
93
+ return [
94
+ {
95
+ file: '(prettier)',
96
+ check: 'prettier-format',
97
+ message: `prettier exited ${String(result.exitCode)} without completing the check: ` +
98
+ output.trim().split('\n').slice(-5).join(' ').slice(0, 400),
99
+ severity,
100
+ },
101
+ ];
102
+ }
103
+ const unformatted = new Set();
104
+ for (const line of output.split(/\r?\n/)) {
105
+ const trimmed = line.trim();
106
+ if (PRETTIER_SUMMARY_LINE.test(trimmed))
107
+ continue;
108
+ const match = PRETTIER_WARN_LINE.exec(trimmed);
109
+ const file = match?.[1]?.trim();
110
+ if (file !== undefined && file.length > 0)
111
+ unformatted.add(file);
112
+ }
113
+ if (unformatted.size === 0) {
114
+ verbose('patchLint.prettier: prettier exited 1 but named no file; reporting nothing.');
115
+ return [];
116
+ }
117
+ return [...unformatted].sort().map((file) => ({
118
+ file,
119
+ check: 'prettier-format',
120
+ message: `Not formatted per the project's Prettier configuration. Checked from inside engine/, ` +
121
+ `so engine/.prettierrc* and engine/.prettierignore apply — the same command run from the ` +
122
+ `repo root can report a false pass when the root .prettierignore excludes engine/. ` +
123
+ `Fix with "prettier --write ${file}" run from engine/.`,
124
+ severity,
125
+ }));
126
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Recognizes the one shape where the undefined-identifier hint sends the
3
+ * operator at the wrong remedy — and where taking that remedy would MASK a
4
+ * real bug.
5
+ *
6
+ * The shape: helpers are hoisted out of duplicated test files into a new
7
+ * `head_settings.js` that a managed `head.js` pulls in with
8
+ * `loadSubScript`. Until that companion is exported it belongs to no patch,
9
+ * so the per-patch checkJs program — which only ever loads patch-owned
10
+ * roots — cannot see any of its declarations. Every use of every hoisted
11
+ * helper is then reported as `Cannot find name`, and the generic hint
12
+ * offers three remedies of which the shim is the easiest: adding the
13
+ * globals to `extraShim` makes all of the warnings disappear AND makes a
14
+ * genuinely missing helper undetectable forever after.
15
+ *
16
+ * The warnings themselves are correct — the per-patch program's honest view
17
+ * of a file that is in no patch. Only the advice is wrong. FireForge
18
+ * already knows ownership, so when every undefined name in a file is
19
+ * declared at the top level of an UNMANAGED file that a managed head loads,
20
+ * it can name that file and the adoption command instead.
21
+ *
22
+ * Detection is deliberately conservative on both halves: a companion must
23
+ * be reachable from an actual `loadSubScript` call, and EVERY undefined
24
+ * name must resolve inside it. One unresolved name means the file may
25
+ * genuinely be missing a helper, and the generic hint stays.
26
+ */
27
+ import type { PatchLintIssue } from '../types/commands/index.js';
28
+ /**
29
+ * String literals passed anywhere inside a `loadSubScript(...)` call.
30
+ *
31
+ * The mochitest idiom is
32
+ * `Services.scriptloader.loadSubScript(getRootDirectory(gTestPath) + "head_settings.js", this)`,
33
+ * so the useful signal is the `.js` literal inside the call, not a
34
+ * resolvable URL — the path half is computed at runtime.
35
+ */
36
+ export declare function findLoadSubScriptTargets(source: string): string[];
37
+ /**
38
+ * Top-level declaration names in a script-scope file.
39
+ *
40
+ * Regex rather than a TS program on purpose: this runs only to decide the
41
+ * WORDING of a hint, the file is by definition outside every program the
42
+ * pass built, and a missed declaration degrades to the generic hint rather
43
+ * than to a wrong answer. Matching is anchored at column zero so a nested
44
+ * declaration inside a function body is not mistaken for a global.
45
+ */
46
+ export declare function collectTopLevelDeclarations(source: string): Set<string>;
47
+ /** An unmanaged companion a managed head loads, plus what it declares. */
48
+ export interface UnmanagedCompanion {
49
+ /** Repo-relative path of the companion. */
50
+ file: string;
51
+ /** Repo-relative path of the managed head that loads it. */
52
+ loadedBy: string;
53
+ /** Top-level names the companion declares. */
54
+ declarations: Set<string>;
55
+ }
56
+ /**
57
+ * Resolves the unmanaged companions reachable from a set of managed head
58
+ * files, given a reader for repo-relative file content.
59
+ *
60
+ * A candidate qualifies only when it is NOT in `ownedFiles` and its content
61
+ * can be read — an unreadable or absent target is simply not a companion,
62
+ * and the generic hint applies.
63
+ *
64
+ * @param heads - Repo-relative managed head files, with their sources
65
+ * @param ownedFiles - Every patch-owned repo-relative path
66
+ * @param readFile - Reads a repo-relative file, or returns undefined
67
+ */
68
+ export declare function resolveUnmanagedCompanions(heads: ReadonlyArray<{
69
+ file: string;
70
+ source: string;
71
+ }>, ownedFiles: ReadonlySet<string>, readFile: (repoRelative: string) => Promise<string | undefined>): Promise<UnmanagedCompanion[]>;
72
+ /**
73
+ * The replacement hint, when every undefined name resolves in one
74
+ * unmanaged companion.
75
+ *
76
+ * States the ownership fact first (that is the diagnosis), then the
77
+ * adoption command, and explicitly rules the shim OUT — the shim is what an
78
+ * operator reaches for by default here, and it is the one remedy that
79
+ * permanently hides a genuinely missing helper.
80
+ */
81
+ export declare function formatUnmanagedCompanionHint(companion: UnmanagedCompanion): string;
82
+ /**
83
+ * Picks the companion that explains EVERY undefined identifier in
84
+ * `messages`, or undefined when none does.
85
+ *
86
+ * @param messages - Diagnostic messages reported for one file
87
+ * @param companions - Candidates from {@link resolveUnmanagedCompanions}
88
+ */
89
+ export declare function explainUndefinedIdentifiers(messages: readonly string[], companions: readonly UnmanagedCompanion[]): UnmanagedCompanion | undefined;
90
+ /**
91
+ * Reads the head files in the owned test set and resolves the UNMANAGED
92
+ * companions they pull in with `loadSubScript`. Hoisting shared helpers
93
+ * into such a companion leaves it unowned until export, and every use of
94
+ * every helper then reports `Cannot find name` from an honest per-patch
95
+ * program — see `patch-lint-unmanaged-companion.ts` for why the generic
96
+ * hint is actively harmful there.
97
+ *
98
+ * Best-effort: an unreadable head simply contributes no companions.
99
+ */
100
+ export declare function collectUnmanagedCompanions(repoDir: string, files: readonly string[], ownedFiles: ReadonlySet<string>): Promise<UnmanagedCompanion[]>;
101
+ /**
102
+ * Swaps the generic undefined-identifier hint for the ownership hint when
103
+ * one unmanaged companion explains EVERY undefined name in the file. Any
104
+ * other issue in the list is returned untouched.
105
+ */
106
+ export declare function retargetUnmanagedCompanionHints(issues: readonly PatchLintIssue[], companions: readonly UnmanagedCompanion[]): PatchLintIssue[];