@hominis/fireforge 0.17.0 → 0.18.1

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 (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +60 -33
  3. package/dist/src/commands/build.js +18 -4
  4. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  5. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  6. package/dist/src/commands/doctor-furnace.js +2 -0
  7. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  8. package/dist/src/commands/doctor-working-tree.js +93 -0
  9. package/dist/src/commands/doctor.js +22 -12
  10. package/dist/src/commands/export-all.js +74 -4
  11. package/dist/src/commands/export-shared.d.ts +7 -1
  12. package/dist/src/commands/export-shared.js +21 -3
  13. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  14. package/dist/src/commands/furnace/override.js +23 -13
  15. package/dist/src/commands/furnace/preview.js +38 -0
  16. package/dist/src/commands/furnace/remove.js +75 -1
  17. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  18. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  19. package/dist/src/commands/furnace/rename.js +32 -4
  20. package/dist/src/commands/lint.js +19 -6
  21. package/dist/src/commands/patch/delete.js +4 -1
  22. package/dist/src/commands/patch/reorder.js +4 -1
  23. package/dist/src/commands/re-export-files.js +3 -1
  24. package/dist/src/commands/re-export.js +4 -1
  25. package/dist/src/commands/rebase/index.js +19 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/status.js +44 -5
  28. package/dist/src/commands/test.js +68 -16
  29. package/dist/src/commands/token-coverage.js +10 -3
  30. package/dist/src/commands/verify.js +81 -6
  31. package/dist/src/commands/watch.js +43 -7
  32. package/dist/src/commands/wire.js +16 -0
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/furnace-constants.d.ts +14 -0
  36. package/dist/src/core/furnace-constants.js +16 -0
  37. package/dist/src/core/furnace-validate.js +67 -1
  38. package/dist/src/core/git-base.d.ts +27 -2
  39. package/dist/src/core/git-base.js +41 -3
  40. package/dist/src/core/git-diff.js +21 -2
  41. package/dist/src/core/git.js +53 -14
  42. package/dist/src/core/mach.d.ts +26 -8
  43. package/dist/src/core/mach.js +24 -8
  44. package/dist/src/core/manifest-rules.js +10 -1
  45. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  46. package/dist/src/core/manifest-tokenizers.js +28 -0
  47. package/dist/src/core/marionette-preflight.d.ts +16 -0
  48. package/dist/src/core/marionette-preflight.js +19 -0
  49. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  50. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  51. package/dist/src/core/patch-lint.d.ts +47 -2
  52. package/dist/src/core/patch-lint.js +94 -18
  53. package/dist/src/core/patch-manifest-consistency.js +15 -2
  54. package/dist/src/core/patch-manifest-io.js +10 -0
  55. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  56. package/dist/src/core/patch-manifest-resolve.js +29 -2
  57. package/dist/src/core/patch-manifest-validate.js +25 -1
  58. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  59. package/dist/src/core/patch-registration-refs.js +117 -0
  60. package/dist/src/core/token-coverage.js +24 -0
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-init.d.ts +9 -3
  64. package/dist/src/core/wire-init.js +18 -6
  65. package/dist/src/core/wire-subscript.d.ts +7 -3
  66. package/dist/src/core/wire-subscript.js +11 -4
  67. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  68. package/dist/src/core/xpcshell-appdir.js +46 -20
  69. package/dist/src/errors/git.d.ts +20 -0
  70. package/dist/src/errors/git.js +39 -0
  71. package/dist/src/types/commands/patches.d.ts +23 -0
  72. package/dist/src/types/furnace.d.ts +9 -0
  73. package/dist/src/utils/parse.d.ts +7 -0
  74. package/dist/src/utils/parse.js +15 -0
  75. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { GitError, GitIndexLockError, PatchApplyError } from '../errors/git.js';
4
+ import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
5
5
  import { toError } from '../utils/errors.js';
6
6
  import { pathExists, removeFile } from '../utils/fs.js';
7
7
  import { verbose } from '../utils/logger.js';
8
8
  import { exec } from '../utils/process.js';
9
- import { configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
9
+ import { configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
10
10
  import { getWorkingTreeStatus } from './git-status.js';
11
11
  // ── Functions that remain in this file ──
12
12
  /**
@@ -38,12 +38,22 @@ export async function ensureOriginRemote(dir) {
38
38
  const GIT_ADD_ENV = { GIT_INDEX_THREADS: '0' };
39
39
  /**
40
40
  * Returns true when the error looks like a process killed by the spawn timeout
41
- * (SIGTERM → exit code 143).
41
+ * (SIGTERM → exit code 143) OR an AbortError raised by
42
+ * `AbortSignal.timeout`. The AbortSignal path is the one observed during
43
+ * the 2026-04-24 eval (Finding 10): Node's `child_process` layer
44
+ * rejects with an AbortError when the signal fires, so the timeout
45
+ * detection here needs to recognise that shape too.
42
46
  */
43
47
  function isTimeoutError(error) {
48
+ if (error instanceof Error && error.name === 'AbortError')
49
+ return true;
44
50
  if (!(error instanceof GitError))
45
51
  return false;
46
- return /SIGTERM|timed out|exit code 143/i.test(error.message);
52
+ if (/SIGTERM|timed out|exit code 143/i.test(error.message))
53
+ return true;
54
+ if (error.cause instanceof Error && error.cause.name === 'AbortError')
55
+ return true;
56
+ return false;
47
57
  }
48
58
  /**
49
59
  * Removes `.git/index.lock` left behind by a killed git process.
@@ -59,6 +69,12 @@ async function cleanupIndexLock(dir) {
59
69
  * Stages every file by walking top-level directories one at a time.
60
70
  * This avoids a single monolithic `git add -A` that may time out on
61
71
  * very large (~300 K file) trees like Firefox.
72
+ *
73
+ * 2026-04-24 eval Finding 10: a chunked pass that hits its own timeout
74
+ * now raises a typed {@link GitIndexingTimeoutError} rather than the
75
+ * opaque `AbortError: The operation was aborted` the caller otherwise
76
+ * saw. The typed error carries the environment-variable override so the
77
+ * operator can extend the budget and re-run.
62
78
  */
63
79
  async function stageAllFilesChunked(dir, options = {}) {
64
80
  const entries = await readdir(dir, { withFileTypes: true });
@@ -66,21 +82,30 @@ async function stageAllFilesChunked(dir, options = {}) {
66
82
  .filter((e) => e.isDirectory() && e.name !== '.git')
67
83
  .map((e) => e.name)
68
84
  .sort();
85
+ async function runChunk(args, label) {
86
+ try {
87
+ await git(args, dir, {
88
+ timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
89
+ env: GIT_ADD_ENV,
90
+ });
91
+ }
92
+ catch (error) {
93
+ if (isTimeoutError(error)) {
94
+ throw new GitIndexingTimeoutError('chunked', GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, error instanceof Error ? error : undefined);
95
+ }
96
+ verbose(`Chunked staging failed on ${label}: ${toError(error).message}`);
97
+ throw error;
98
+ }
99
+ }
69
100
  for (const dirName of directories) {
70
101
  options.onProgress?.(`Staging directory: ${dirName}/...`);
71
- await git(['add', '--', dirName], dir, {
72
- timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
73
- env: GIT_ADD_ENV,
74
- });
102
+ await runChunk(['add', '--', dirName], dirName);
75
103
  }
76
104
  // Stage any top-level files
77
105
  const topLevelFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
78
106
  if (topLevelFiles.length > 0) {
79
107
  options.onProgress?.('Staging top-level files...');
80
- await git(['add', '--', ...topLevelFiles], dir, {
81
- timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
82
- env: GIT_ADD_ENV,
83
- });
108
+ await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
84
109
  }
85
110
  }
86
111
  /**
@@ -122,11 +147,25 @@ export async function stageAllFiles(dir, options = {}) {
122
147
  if (!isTimeoutError(error)) {
123
148
  throw await maybeWrapIndexLockError(dir, error);
124
149
  }
125
- options.onProgress?.('Monolithic git add timed out; falling back to chunked staging...');
150
+ // 2026-04-24 eval Finding 10: the fallback transition used to be
151
+ // an implementation detail invisible to operators watching the
152
+ // spinner. Emit a loud, one-line banner so non-TTY log scrapers
153
+ // and TTY operators both see that the monolithic attempt lost and
154
+ // the chunked pass is starting. This was the missing signal in
155
+ // the eval log where the heartbeat went quiet for ~600s between
156
+ // the monolithic timeout and the chunked-pass failure.
157
+ options.onProgress?.(`Monolithic git add reached the ${Math.round(timeout / 1000)}s timeout; falling back to chunked staging. This pass may take several more minutes on a large tree.`);
126
158
  }
127
159
  // The killed process may have left an index lock
128
160
  await cleanupIndexLock(dir);
129
- await stageAllFilesChunked(dir, options);
161
+ try {
162
+ await stageAllFilesChunked(dir, options);
163
+ }
164
+ catch (error) {
165
+ if (error instanceof GitIndexingTimeoutError)
166
+ throw error;
167
+ throw error;
168
+ }
130
169
  }
131
170
  finally {
132
171
  if (heartbeatTimer)
@@ -60,19 +60,25 @@ export declare function bootstrapWithOutput(engineDir: string): Promise<MachComm
60
60
  /**
61
61
  * Runs a full mach build. On a non-zero exit, any matched error hints are
62
62
  * surfaced on top of the raw mach output so operators get an actionable
63
- * nudge alongside the cryptic mozbuild traceback.
63
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
64
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
65
+ * for post-build diagnostics that mach prints AFTER "Your build was
66
+ * successful!" — notably the stale `config.status is out of date`
67
+ * notice that mach emits when a tool-managed edit landed on
68
+ * `moz.configure` before the build.
64
69
  * @param engineDir - Path to the engine directory
65
70
  * @param jobs - Number of parallel jobs (optional)
66
- * @returns Exit code
71
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
67
72
  */
68
- export declare function build(engineDir: string, jobs?: number): Promise<number>;
73
+ export declare function build(engineDir: string, jobs?: number): Promise<MachCommandResult>;
69
74
  /**
70
75
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
71
- * surfaced on top of the raw mach output.
76
+ * surfaced on top of the raw mach output. See {@link build} for why the
77
+ * full captured result is returned rather than just the exit code.
72
78
  * @param engineDir - Path to the engine directory
73
- * @returns Exit code
79
+ * @returns Captured mach result
74
80
  */
75
- export declare function buildUI(engineDir: string): Promise<number>;
81
+ export declare function buildUI(engineDir: string): Promise<MachCommandResult>;
76
82
  /**
77
83
  * Runs an operation while holding a sidecar build lock keyed on the
78
84
  * project root. Concurrent `fireforge build` / `fireforge build --ui`
@@ -162,9 +168,21 @@ export declare function watch(engineDir: string): Promise<number>;
162
168
  /**
163
169
  * Runs mach watch while preserving stdin and capturing emitted output.
164
170
  * @param engineDir - Path to the engine directory
171
+ * @param options - Optional environment overrides merged into the mach subprocess env
165
172
  * @returns Captured output and exit code
166
- */
167
- export declare function watchWithOutput(engineDir: string): Promise<MachCommandResult>;
173
+ *
174
+ * 2026-04-24 eval Finding 12: the pre-0.18.1 shape accepted no options
175
+ * and so never forwarded the detected watchman path into the mach
176
+ * subprocess env. `fireforge watch` could locate `watchman` via PATH
177
+ * (the probe's `which` succeeded) but the mach subprocess spawned with
178
+ * the parent's PATH only — on macOS that typically omits
179
+ * `/opt/homebrew/bin`, so `mach watch` failed at the `watch-project`
180
+ * subscription step. Accepting `env` here lets the caller prepend the
181
+ * resolved watchman directory to PATH in a way mach inherits.
182
+ */
183
+ export declare function watchWithOutput(engineDir: string, options?: {
184
+ env?: Record<string, string>;
185
+ }): Promise<MachCommandResult>;
168
186
  /**
169
187
  * Runs mach test with the given test paths.
170
188
  * @param engineDir - Path to the engine directory
@@ -137,10 +137,15 @@ function surfaceMachErrorHints(result) {
137
137
  /**
138
138
  * Runs a full mach build. On a non-zero exit, any matched error hints are
139
139
  * surfaced on top of the raw mach output so operators get an actionable
140
- * nudge alongside the cryptic mozbuild traceback.
140
+ * nudge alongside the cryptic mozbuild traceback. Returns the captured
141
+ * result so the caller (e.g. `fireforge build`) can inspect the tail
142
+ * for post-build diagnostics that mach prints AFTER "Your build was
143
+ * successful!" — notably the stale `config.status is out of date`
144
+ * notice that mach emits when a tool-managed edit landed on
145
+ * `moz.configure` before the build.
141
146
  * @param engineDir - Path to the engine directory
142
147
  * @param jobs - Number of parallel jobs (optional)
143
- * @returns Exit code
148
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
144
149
  */
145
150
  export async function build(engineDir, jobs) {
146
151
  const args = ['build'];
@@ -151,20 +156,21 @@ export async function build(engineDir, jobs) {
151
156
  if (result.exitCode !== 0) {
152
157
  surfaceMachErrorHints(result);
153
158
  }
154
- return result.exitCode;
159
+ return result;
155
160
  }
156
161
  /**
157
162
  * Runs a fast UI-only build. On a non-zero exit, any matched error hints are
158
- * surfaced on top of the raw mach output.
163
+ * surfaced on top of the raw mach output. See {@link build} for why the
164
+ * full captured result is returned rather than just the exit code.
159
165
  * @param engineDir - Path to the engine directory
160
- * @returns Exit code
166
+ * @returns Captured mach result
161
167
  */
162
168
  export async function buildUI(engineDir) {
163
169
  const result = await runMachInheritCapture(['build', 'faster'], engineDir);
164
170
  if (result.exitCode !== 0) {
165
171
  surfaceMachErrorHints(result);
166
172
  }
167
- return result.exitCode;
173
+ return result;
168
174
  }
169
175
  /**
170
176
  * Runs an operation while holding a sidecar build lock keyed on the
@@ -274,10 +280,20 @@ export async function watch(engineDir) {
274
280
  /**
275
281
  * Runs mach watch while preserving stdin and capturing emitted output.
276
282
  * @param engineDir - Path to the engine directory
283
+ * @param options - Optional environment overrides merged into the mach subprocess env
277
284
  * @returns Captured output and exit code
285
+ *
286
+ * 2026-04-24 eval Finding 12: the pre-0.18.1 shape accepted no options
287
+ * and so never forwarded the detected watchman path into the mach
288
+ * subprocess env. `fireforge watch` could locate `watchman` via PATH
289
+ * (the probe's `which` succeeded) but the mach subprocess spawned with
290
+ * the parent's PATH only — on macOS that typically omits
291
+ * `/opt/homebrew/bin`, so `mach watch` failed at the `watch-project`
292
+ * subscription step. Accepting `env` here lets the caller prepend the
293
+ * resolved watchman directory to PATH in a way mach inherits.
278
294
  */
279
- export async function watchWithOutput(engineDir) {
280
- return runMachInheritCapture(['watch'], engineDir);
295
+ export async function watchWithOutput(engineDir, options = {}) {
296
+ return runMachInheritCapture(['watch'], engineDir, options.env ? { env: options.env } : {});
281
297
  }
282
298
  /**
283
299
  * Runs mach test with the given test paths.
@@ -24,7 +24,16 @@ export function getRules(binaryName) {
24
24
  // proposed a bogus jar.mn entry. The lookahead blocks the match so
25
25
  // `getUnregistrableAdvice` gets a chance to emit the correct
26
26
  // guidance for the `.inc.xhtml` case.
27
- pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(.+\.(?:js|mjs|xhtml|css))$/,
27
+ //
28
+ // Test implementation files under `browser/base/content/test/` are
29
+ // also excluded: they belong in the nearest `browser.toml` manifest,
30
+ // not in jar.mn. 2026-04-23 eval 2: `status --unmanaged` proposed
31
+ // `fireforge register browser/base/content/test/<dir>/browser_*.js`
32
+ // which would have clutter-registered a test file as browser
33
+ // chrome content. The negative lookahead routes those paths to
34
+ // `getUnregistrableAdvice`, which returns the correct
35
+ // browser.toml-centric guidance.
36
+ pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(?!test\/)(.+\.(?:js|mjs|xhtml|css))$/,
28
37
  isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
29
38
  register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
30
39
  extractArgs: (m) => [m[1] ?? ''],
@@ -26,6 +26,12 @@ export declare function tokenizeJarMn(lines: string[]): JarMnToken[];
26
26
  /**
27
27
  * Tokenizes a moz.build Python list block, returning the tokens and their
28
28
  * line range within the file.
29
+ *
30
+ * Supports both multi-line lists (the common shape) and single-line
31
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
32
+ * case where a freshly-scaffolded module directory's `moz.build`
33
+ * started with an empty list and the tokenizer returned `null`,
34
+ * leaving `register` unable to add the first entry.
29
35
  */
30
36
  export declare function tokenizeMozBuildList(lines: string[], listPattern: RegExp): {
31
37
  tokens: MozBuildToken[];
@@ -44,6 +44,12 @@ export function tokenizeJarMn(lines) {
44
44
  /**
45
45
  * Tokenizes a moz.build Python list block, returning the tokens and their
46
46
  * line range within the file.
47
+ *
48
+ * Supports both multi-line lists (the common shape) and single-line
49
+ * empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
50
+ * case where a freshly-scaffolded module directory's `moz.build`
51
+ * started with an empty list and the tokenizer returned `null`,
52
+ * leaving `register` unable to add the first entry.
47
53
  */
48
54
  export function tokenizeMozBuildList(lines, listPattern) {
49
55
  const tokens = [];
@@ -53,6 +59,28 @@ export function tokenizeMozBuildList(lines, listPattern) {
53
59
  const raw = lines[i] ?? '';
54
60
  if (startLine === -1) {
55
61
  if (listPattern.test(raw)) {
62
+ // Single-line empty-list handling: a fresh scaffold sometimes
63
+ // writes `EXTRA_JS_MODULES += []` on one line. The pre-fix
64
+ // tokenizer returned `null` because it never saw a line
65
+ // starting with `]`, which stranded `register` with a "Could
66
+ // not find module list section" error against the documented
67
+ // browser/modules/<fork>/ scaffold (eval 2).
68
+ //
69
+ // The in-place split rewrites the single-line form into the
70
+ // canonical multi-line shape so the caller's
71
+ // `lines.splice(insertIndex, 0, entry)` lands inside the list
72
+ // body. The tokens are emitted to mirror the new structure.
73
+ const singleLineMatch = /^([^[]*\[)\s*\]\s*$/.exec(raw);
74
+ if (singleLineMatch) {
75
+ const openPart = singleLineMatch[1] ?? '';
76
+ lines[i] = openPart;
77
+ lines.splice(i + 1, 0, ']');
78
+ startLine = i;
79
+ endLine = i + 1;
80
+ tokens.push({ type: 'list-open', raw: openPart, lineIndex: i });
81
+ tokens.push({ type: 'list-close', raw: ']', lineIndex: i + 1 });
82
+ break;
83
+ }
56
84
  startLine = i;
57
85
  tokens.push({ type: 'list-open', raw, lineIndex: i });
58
86
  }
@@ -44,3 +44,19 @@ export interface MarionettePreflightOptions {
44
44
  export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
45
45
  /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
46
46
  export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;
47
+ /**
48
+ * Formats the PASS/FAIL banner as a plain string for direct
49
+ * `process.stdout.write` use — bypasses the clack logger entirely so
50
+ * operators running `fireforge test --doctor` under a non-TTY (pipe,
51
+ * CI, `tee`-wrapped capture) always see the final line even when the
52
+ * clack renderer swallows trailing log output just before process exit.
53
+ *
54
+ * 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
55
+ * marionette preflight..."` intro and no PASS line at all — the
56
+ * `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
57
+ * we used to ship still lost the summary under some non-TTY flush
58
+ * races. Returning the raw string here lets the caller compose a single
59
+ * authoritative write without any clack layer between the probe and
60
+ * the captured log.
61
+ */
62
+ export declare function formatMarionettePreflightLine(result: MarionettePreflightResult): string;
@@ -288,4 +288,23 @@ export function reportMarionettePreflight(result) {
288
288
  warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
289
289
  }
290
290
  }
291
+ /**
292
+ * Formats the PASS/FAIL banner as a plain string for direct
293
+ * `process.stdout.write` use — bypasses the clack logger entirely so
294
+ * operators running `fireforge test --doctor` under a non-TTY (pipe,
295
+ * CI, `tee`-wrapped capture) always see the final line even when the
296
+ * clack renderer swallows trailing log output just before process exit.
297
+ *
298
+ * 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
299
+ * marionette preflight..."` intro and no PASS line at all — the
300
+ * `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
301
+ * we used to ship still lost the summary under some non-TTY flush
302
+ * races. Returning the raw string here lets the caller compose a single
303
+ * authoritative write without any clack layer between the probe and
304
+ * the captured log.
305
+ */
306
+ export function formatMarionettePreflightLine(result) {
307
+ const status = result.ok ? 'PASS' : 'FAIL';
308
+ return `Marionette preflight: ${status} (${result.durationMs}ms) — ${result.detail}`;
309
+ }
291
310
  //# sourceMappingURL=marionette-preflight.js.map
@@ -18,6 +18,13 @@ import type { PatchLintIssue } from '../types/commands/index.js';
18
18
  * @param rev Git revision to diff against (e.g. `HEAD`, a branch, a SHA).
19
19
  */
20
20
  export declare function collectDiffFilePaths(engineDir: string, rev: string): Promise<Set<string>>;
21
+ /**
22
+ * Synthetic "file" value used by aggregate patch-size rules
23
+ * (`large-patch-files` / `large-patch-lines`) to flag that a finding
24
+ * describes the whole diff rather than a single path. Exported so callers
25
+ * can keep the tagging contract visible in one place.
26
+ */
27
+ export declare const AGGREGATE_PATCH_FILE = "(patch)";
21
28
  /**
22
29
  * Annotates a list of lint issues with `introduced` / `cumulative` tags
23
30
  * based on whether the issue's file is part of the supplied diff set.
@@ -27,6 +34,19 @@ export declare function collectDiffFilePaths(engineDir: string, rev: string): Pr
27
34
  * describe queue-wide state — are always `cumulative` under `--since`
28
35
  * because they describe drift accumulated across many commits, not a
29
36
  * single current-task edit.
37
+ *
38
+ * Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
39
+ * which is a synthetic placeholder that will never appear in a real
40
+ * `diffFiles` set. Without special-casing, `large-patch-files` /
41
+ * `large-patch-lines` were always tagged `[cumulative]` under
42
+ * `--only-introduced` even when the diff WAS the aggregate the rules
43
+ * measured — the eval (Finding #4) reported a stack of 20+ imported
44
+ * patches whose aggregate-size warnings printed as `[cumulative]` under
45
+ * `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
46
+ * to an operator asking "what did this diff introduce?" We promote the
47
+ * aggregate tag to `introduced` whenever the diff set has any content —
48
+ * non-empty `diffFiles` means the operator asked about a specific diff
49
+ * scope and the aggregate-rule finding describes exactly that scope.
30
50
  * @param issues Issues returned by the lint orchestrator.
31
51
  * @param diffFiles File paths touched since the user's revision.
32
52
  */
@@ -58,6 +58,13 @@ export async function collectDiffFilePaths(engineDir, rev) {
58
58
  }
59
59
  return files;
60
60
  }
61
+ /**
62
+ * Synthetic "file" value used by aggregate patch-size rules
63
+ * (`large-patch-files` / `large-patch-lines`) to flag that a finding
64
+ * describes the whole diff rather than a single path. Exported so callers
65
+ * can keep the tagging contract visible in one place.
66
+ */
67
+ export const AGGREGATE_PATCH_FILE = '(patch)';
61
68
  /**
62
69
  * Annotates a list of lint issues with `introduced` / `cumulative` tags
63
70
  * based on whether the issue's file is part of the supplied diff set.
@@ -67,15 +74,33 @@ export async function collectDiffFilePaths(engineDir, rev) {
67
74
  * describe queue-wide state — are always `cumulative` under `--since`
68
75
  * because they describe drift accumulated across many commits, not a
69
76
  * single current-task edit.
77
+ *
78
+ * Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
79
+ * which is a synthetic placeholder that will never appear in a real
80
+ * `diffFiles` set. Without special-casing, `large-patch-files` /
81
+ * `large-patch-lines` were always tagged `[cumulative]` under
82
+ * `--only-introduced` even when the diff WAS the aggregate the rules
83
+ * measured — the eval (Finding #4) reported a stack of 20+ imported
84
+ * patches whose aggregate-size warnings printed as `[cumulative]` under
85
+ * `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
86
+ * to an operator asking "what did this diff introduce?" We promote the
87
+ * aggregate tag to `introduced` whenever the diff set has any content —
88
+ * non-empty `diffFiles` means the operator asked about a specific diff
89
+ * scope and the aggregate-rule finding describes exactly that scope.
70
90
  * @param issues Issues returned by the lint orchestrator.
71
91
  * @param diffFiles File paths touched since the user's revision.
72
92
  */
73
93
  export function tagLintIssues(issues, diffFiles) {
94
+ const hasDiffContent = diffFiles.size > 0;
74
95
  for (const issue of issues) {
75
96
  if (!issue.file) {
76
97
  issue.tag = 'cumulative';
77
98
  continue;
78
99
  }
100
+ if (issue.file === AGGREGATE_PATCH_FILE) {
101
+ issue.tag = hasDiffContent ? 'introduced' : 'cumulative';
102
+ continue;
103
+ }
79
104
  issue.tag = diffFiles.has(issue.file) ? 'introduced' : 'cumulative';
80
105
  }
81
106
  return issues;
@@ -67,10 +67,49 @@ export declare function lintPatchedJs(repoDir: string, affectedFiles: string[],
67
67
  * @returns Array of lint issues
68
68
  */
69
69
  export declare function lintModificationComments(diffContent: string, config: FireForgeConfig): PatchLintIssue[];
70
+ /**
71
+ * Describes which tier `resolvePatchSizeTier` selected and why.
72
+ * Consumers that want to surface the tier choice to the operator
73
+ * (e.g. a one-line `info()` when branding thresholds kick in) read
74
+ * this alongside the issues array from `lintPatchSize`.
75
+ */
76
+ export type PatchSizeTierDecision = {
77
+ tier: 'general';
78
+ } | {
79
+ tier: 'test';
80
+ } | {
81
+ tier: 'branding';
82
+ source: 'auto' | 'explicit';
83
+ };
84
+ /**
85
+ * Decides which `large-patch-lines` threshold tier applies to a patch.
86
+ * Exported so `runPatchLint` and the per-patch `lint` command can
87
+ * surface the tier choice to the operator *without* depending on
88
+ * `lintPatchSize`'s internal return shape — the rule itself stays a
89
+ * pure issues-array API, and the decision is computed separately for
90
+ * the sole purpose of reporting.
91
+ *
92
+ * Precedence: test > branding (explicit) > branding (auto) > general.
93
+ * The test tier beats branding because a table-driven regression test
94
+ * is legitimately large independent of whether the patch also claims
95
+ * branding shape, and the test-tier thresholds are already more
96
+ * permissive than branding — so "tests beat branding" is the
97
+ * defensive-for-tests choice.
98
+ */
99
+ export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
70
100
  /**
71
101
  * Checks patch size and emits advisory warnings.
102
+ *
103
+ * @param filesAffected - Files touched by the patch
104
+ * @param lineCount - Non-binary line count of the unified diff
105
+ * @param patchTier - Optional explicit tier override declared on
106
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
107
+ * thresholds regardless of `filesAffected`. Tests still win over
108
+ * branding (precedence `test > branding > general`) because the
109
+ * test-tier thresholds are already more permissive and an all-tests
110
+ * patch that is also branding-shaped is vanishingly rare.
72
111
  */
73
- export declare function lintPatchSize(filesAffected: string[], lineCount: number): PatchLintIssue[];
112
+ export declare function lintPatchSize(filesAffected: string[], lineCount: number, patchTier?: 'branding'): PatchLintIssue[];
74
113
  /**
75
114
  * Checks that modified (non-new) files with a supported extension still
76
115
  * start with a recognized license header.
@@ -94,6 +133,12 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
94
133
  * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
95
134
  * manifest, etc.) can opt out of a specific rule without reaching for the
96
135
  * blunt `--skip-lint` hammer. Not mutated by this function.
136
+ * @param patchTier - Optional explicit tier override, threaded from
137
+ * `PatchMetadata.tier`. When `"branding"` forces the branding
138
+ * thresholds on the `large-patch-lines` rule. Callers with a
139
+ * per-patch manifest context (re-export, per-patch lint) should
140
+ * pass this; aggregate-mode callers without a specific patch
141
+ * context skip it and fall through to auto-detection.
97
142
  * @returns Array of all lint issues found
98
143
  */
99
- export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
144
+ export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<PatchLintIssue[]>;