@hominis/fireforge 0.38.0 → 0.39.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 (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/commands/build.js +8 -3
  3. package/dist/src/commands/discard.js +75 -50
  4. package/dist/src/commands/export-flow.d.ts +1 -1
  5. package/dist/src/commands/export-flow.js +7 -4
  6. package/dist/src/commands/export-placement-gate.js +1 -1
  7. package/dist/src/commands/export-shared.js +17 -2
  8. package/dist/src/commands/import.js +5 -0
  9. package/dist/src/commands/lint-per-patch.js +15 -5
  10. package/dist/src/commands/lint.js +1 -1
  11. package/dist/src/commands/patch/lint-ignore.js +20 -1
  12. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  13. package/dist/src/commands/patch/move-files-into.js +245 -0
  14. package/dist/src/commands/patch/move-files.js +27 -13
  15. package/dist/src/commands/patch/rename.js +4 -1
  16. package/dist/src/commands/reset.js +5 -0
  17. package/dist/src/commands/status-output.d.ts +9 -0
  18. package/dist/src/commands/status-output.js +33 -1
  19. package/dist/src/commands/status.js +17 -3
  20. package/dist/src/commands/test-diagnose.d.ts +1 -1
  21. package/dist/src/commands/test-diagnose.js +9 -3
  22. package/dist/src/commands/test-modes.d.ts +21 -0
  23. package/dist/src/commands/test-modes.js +42 -0
  24. package/dist/src/commands/test-run.d.ts +2 -0
  25. package/dist/src/commands/test-run.js +8 -2
  26. package/dist/src/commands/test.js +38 -32
  27. package/dist/src/core/build-baseline-types.d.ts +7 -0
  28. package/dist/src/core/build-baseline.d.ts +1 -1
  29. package/dist/src/core/build-baseline.js +2 -1
  30. package/dist/src/core/build-prepare.d.ts +9 -0
  31. package/dist/src/core/build-prepare.js +24 -0
  32. package/dist/src/core/config-validate.js +8 -0
  33. package/dist/src/core/discard-baseline.d.ts +60 -0
  34. package/dist/src/core/discard-baseline.js +232 -0
  35. package/dist/src/core/license-headers.d.ts +12 -0
  36. package/dist/src/core/license-headers.js +27 -0
  37. package/dist/src/core/patch-export.d.ts +10 -1
  38. package/dist/src/core/patch-export.js +12 -2
  39. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  40. package/dist/src/core/patch-lint-checkjs.js +30 -14
  41. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  42. package/dist/src/core/patch-lint-cross.js +69 -9
  43. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  44. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  45. package/dist/src/core/patch-lint.js +4 -1
  46. package/dist/src/core/patch-policy.d.ts +14 -1
  47. package/dist/src/core/patch-policy.js +13 -2
  48. package/dist/src/core/test-harness-crash.d.ts +10 -0
  49. package/dist/src/core/test-harness-crash.js +16 -0
  50. package/dist/src/core/test-stale-check.d.ts +15 -0
  51. package/dist/src/core/test-stale-check.js +37 -1
  52. package/dist/src/core/token-manager.js +39 -2
  53. package/dist/src/core/token-variant.d.ts +7 -0
  54. package/dist/src/core/token-variant.js +9 -0
  55. package/dist/src/core/typecheck-shim.d.ts +13 -0
  56. package/dist/src/core/typecheck-shim.js +17 -2
  57. package/dist/src/core/typecheck.js +14 -3
  58. package/dist/src/types/commands/options.d.ts +12 -0
  59. package/dist/src/types/config.d.ts +15 -0
  60. package/package.json +1 -1
@@ -10,7 +10,6 @@ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionette
10
10
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
11
11
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
12
12
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
13
- import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
14
13
  import { GeneralError } from '../errors/base.js';
15
14
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
16
15
  import { toError } from '../utils/errors.js';
@@ -18,7 +17,7 @@ import { pathExists } from '../utils/fs.js';
18
17
  import { info, intro, outro, spinner, success, verbose } from '../utils/logger.js';
19
18
  import { stripEnginePrefix } from '../utils/paths.js';
20
19
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
21
- import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
20
+ import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
22
21
  import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
23
22
  import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
24
23
  async function assertTestPathsExist(engineDir, testPaths) {
@@ -34,19 +33,6 @@ async function assertTestPathsExist(engineDir, testPaths) {
34
33
  throw new GeneralError(`Test path${missingPaths.length === 1 ? '' : 's'} not found under engine/: ${missingPaths.join(', ')}\n\n` +
35
34
  'If you expected these files to come from your patch stack, run "fireforge import" first.');
36
35
  }
37
- async function classifyTestHarnesses(engineDir, normalizedPaths) {
38
- const result = { xpcshell: [], nonXpcshell: [] };
39
- for (const testPath of normalizedPaths) {
40
- const manifest = await findNearestXpcshellManifest(engineDir, testPath);
41
- if (manifest) {
42
- result.xpcshell.push(testPath);
43
- }
44
- else {
45
- result.nonXpcshell.push(testPath);
46
- }
47
- }
48
- return result;
49
- }
50
36
  /**
51
37
  * Picks the mach dispatch target for a (non-mixed) run. A single-suite run
52
38
  * auto-routes to the suite-specific command (`mach xpcshell-test` /
@@ -66,12 +52,6 @@ function resolveTestSuite(classification, forceGeneric) {
66
52
  }
67
53
  return 'generic';
68
54
  }
69
- function buildMixedHarnessMessage(classification) {
70
- return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
71
- 'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
72
- ` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
73
- ` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
74
- }
75
55
  function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
76
56
  if (classification.xpcshell.length === 0 || classification.nonXpcshell.length > 0) {
77
57
  return [...machArgs];
@@ -140,7 +120,9 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
140
120
  // anchor forward (a `mach build faster` does not rebake
141
121
  // components.conf into the compiled table).
142
122
  try {
143
- await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline);
123
+ await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline, testPackagingCoverage === 'full'
124
+ ? 'fireforge test --build'
125
+ : `fireforge test --build ${testPackagingCoverage.join(' ')}`);
144
126
  }
145
127
  catch (baselineError) {
146
128
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -239,7 +221,7 @@ async function runDoctorPreflight(args) {
239
221
  * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
240
222
  * Mutates `extraArgs` in place.
241
223
  */
242
- function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
224
+ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly = false) {
243
225
  // Auto-forward the Marionette port to mach when `--marionette-port` is
244
226
  // set. `--setpref=marionette.port=<n>` configures where the browser
245
227
  // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
@@ -258,6 +240,14 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
258
240
  // `--marionette=...` (or two-token `--marionette host:port`).
259
241
  if (options.marionettePort === undefined)
260
242
  return;
243
+ if (xpcshellOnly) {
244
+ // Manifest classification says every requested path is xpcshell —
245
+ // xpcshell ignores the browser Marionette path entirely, and the
246
+ // mochitest client flags previously forwarded here made mach reject
247
+ // the dispatch (FORGE F10).
248
+ info(`--marionette-port=${options.marionettePort} applied to the preflight probe only: the requested paths are xpcshell-only, and xpcshell ignores the browser Marionette port. Not forwarding --setpref=marionette.port or --marionette to mach.`);
249
+ return;
250
+ }
261
251
  {
262
252
  const operatorAlreadyForwarded = forwardedPort !== undefined;
263
253
  const machArgs = options.machArg ?? [];
@@ -276,8 +266,23 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
276
266
  }
277
267
  }
278
268
  }
279
- async function ensureTestMarionettePortAvailable(port, binaryName, killStaleBrowser) {
280
- if (killStaleBrowser) {
269
+ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip) {
270
+ if (skip.xpcshellOnly && !skip.doctor) {
271
+ // xpcshell does not bind the browser Marionette port, so a developer's
272
+ // interactive browser holding 2828 must not kill an xpcshell run
273
+ // (FORGE F10). --doctor keeps the preflight: its probe launches a
274
+ // Marionette browser regardless of the requested harness.
275
+ const message = 'Skipping the Marionette stale-port preflight: all requested paths are xpcshell ' +
276
+ '(xpcshell does not bind the browser Marionette port).';
277
+ if (options.marionettePort !== undefined || options.killStaleMarionette === true) {
278
+ info(message);
279
+ }
280
+ else {
281
+ verbose(message);
282
+ }
283
+ return;
284
+ }
285
+ if (options.killStaleMarionette === true) {
281
286
  await ensureMarionettePortAvailable(port, { binaryName, killStaleBrowser: true });
282
287
  return;
283
288
  }
@@ -321,6 +326,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
321
326
  // stale/coverage refusals keep precedence over missing-path errors.)
322
327
  const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
323
328
  const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
329
+ const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths);
324
330
  // Run incremental build if requested
325
331
  if (options.build) {
326
332
  // A path-less `test --build` runs (and packages for) the full suite;
@@ -361,7 +367,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
361
367
  // generic bind failure. 2026-04-21 eval (Finding #20): a stale
362
368
  // `-marionette` process from `fresh/` poisoned a later test run in
363
369
  // the sibling `mybrowser/` workspace.
364
- await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options.killStaleMarionette === true);
370
+ await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
371
+ xpcshellOnly,
372
+ doctor: options.doctor === true,
373
+ });
365
374
  if (options.doctor) {
366
375
  const doctorOutcome = await runDoctorPreflight({
367
376
  engineDir: paths.engine,
@@ -375,10 +384,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
375
384
  return;
376
385
  }
377
386
  await assertTestPathsExist(paths.engine, normalizedPaths);
378
- const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
379
- if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
380
- throw new GeneralError(buildMixedHarnessMessage(classification));
381
- }
382
387
  const suite = resolveTestSuite(classification, options.genericMachTest === true);
383
388
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
384
389
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
@@ -401,7 +406,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
401
406
  if (forwardedMachArgs.length > 0) {
402
407
  extraArgs.push(...forwardedMachArgs);
403
408
  }
404
- appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
409
+ appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
405
410
  // Directory arguments mean EXACTLY that directory: mozbuild's test
406
411
  // resolver matches paths by string prefix, so a bare directory arg
407
412
  // silently swept in prefix-named siblings (152.0b7 → 153.0b8 drill:
@@ -437,6 +442,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
437
442
  suite,
438
443
  baseExtraArgs: extraArgs,
439
444
  harnessRetries,
445
+ headless: options.headless === true,
440
446
  ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
441
447
  };
442
448
  const postRebuildContext = options.build
@@ -482,7 +488,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
482
488
  reportCanaryOutcome(outcome);
483
489
  return;
484
490
  }
485
- finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext);
491
+ finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
486
492
  }
487
493
  /**
488
494
  * Builds the perf-sample env contract for the harness run (field report
@@ -76,6 +76,13 @@ export interface BuildBaseline {
76
76
  * static-components stale check degrades to "fresh" in that case.
77
77
  */
78
78
  staticComponentsBaseline?: StaticComponentsBaseline;
79
+ /**
80
+ * Invocation shape that recorded this baseline (`'fireforge build'`,
81
+ * `'fireforge build --ui'`, `'fireforge test --build [paths]'`).
82
+ * Informational — surfaced by `fireforge status --test-coverage`.
83
+ * Missing on pre-0.39.0 baselines; render as "unknown".
84
+ */
85
+ recordedBy?: string;
79
86
  }
80
87
  /**
81
88
  * State of the engine at the last FULL build, as far as compiled-in XPCOM
@@ -48,4 +48,4 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
48
48
  * does not rebake `components.conf` registrations, so the last FULL
49
49
  * build stays the honest anchor for the compiled StaticComponents table.
50
50
  */
51
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline): Promise<void>;
51
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string): Promise<void>;
@@ -74,7 +74,7 @@ export async function readBuildBaseline(projectRoot) {
74
74
  * does not rebake `components.conf` registrations, so the last FULL
75
75
  * build stays the honest anchor for the compiled StaticComponents table.
76
76
  */
77
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline) {
77
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy) {
78
78
  let engineHeadSha = '';
79
79
  try {
80
80
  engineHeadSha = await getHead(engineDir);
@@ -103,6 +103,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
103
103
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
104
104
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
105
105
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
106
+ ...(recordedBy !== undefined ? { recordedBy } : {}),
106
107
  };
107
108
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
108
109
  }
@@ -24,6 +24,15 @@ export interface PrepareBuildOptions {
24
24
  */
25
25
  previousBaseline?: BuildBaseline | undefined;
26
26
  }
27
+ /**
28
+ * Describes an exit code in the shell's 128+N signal convention. Truncated
29
+ * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
30
+ * on macOS) are environmental interruptions, not compiler failures — naming
31
+ * that in the failure text saves the operator a fruitless log hunt
32
+ * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
33
+ * for codes past the conventional signal range, so callers append nothing.
34
+ */
35
+ export declare function describeSignalShapedExit(exitCode: number): string | undefined;
27
36
  /**
28
37
  * Returns true when the file path matches a pattern that forces
29
38
  * `mach configure` to regenerate the backend. Exported for testing.
@@ -3,6 +3,7 @@
3
3
  * Shared pre-flight logic for build and package commands:
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
+ import { constants as osConstants } from 'node:os';
6
7
  import { BuildError } from '../errors/build.js';
7
8
  import { FurnaceError } from '../errors/furnace.js';
8
9
  import { toError } from '../utils/errors.js';
@@ -33,6 +34,27 @@ function extractMachConfigureError(result) {
33
34
  return '';
34
35
  return combined.split('\n').slice(-40).join('\n').trim();
35
36
  }
37
+ /**
38
+ * Describes an exit code in the shell's 128+N signal convention. Truncated
39
+ * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
40
+ * on macOS) are environmental interruptions, not compiler failures — naming
41
+ * that in the failure text saves the operator a fruitless log hunt
42
+ * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
43
+ * for codes past the conventional signal range, so callers append nothing.
44
+ */
45
+ export function describeSignalShapedExit(exitCode) {
46
+ if (exitCode <= 128 || exitCode > 192)
47
+ return undefined;
48
+ const signalNumber = exitCode - 128;
49
+ const names = Object.entries(osConstants.signals)
50
+ .filter(([, value]) => value === signalNumber)
51
+ .map(([name]) => name);
52
+ const label = names.length > 0 ? names.join('/') : `signal ${signalNumber}`;
53
+ return (`Exit ${exitCode} is signal-shaped (${exitCode} - 128 = ${signalNumber}, ${label} on this host): ` +
54
+ 'the process was likely interrupted externally (OOM-killer, terminal disconnect, ' +
55
+ 'display/session teardown) rather than failing on its own, and the log may be ' +
56
+ 'truncated mid-step.');
57
+ }
36
58
  /**
37
59
  * Builds the `BuildError` for a non-zero auto-configure exit: the output
38
60
  * tail (so the underlying mozbuild failure survives), any matched
@@ -43,8 +65,10 @@ function extractMachConfigureError(result) {
43
65
  function buildConfigureFailureError(captured) {
44
66
  const detail = extractMachConfigureError(captured);
45
67
  const hints = explainMachError(`${captured.stderr}\n${captured.stdout}`);
68
+ const signalNote = describeSignalShapedExit(captured.exitCode);
46
69
  return new BuildError(`Backend regeneration failed: mach configure exited with code ${captured.exitCode}.` +
47
70
  (detail ? `\n\nmach configure output (tail):\n${detail}` : '') +
71
+ (signalNote ? `\n\n${signalNote}` : '') +
48
72
  (hints.length > 0 ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join('\n')}` : '') +
49
73
  '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
50
74
  }
@@ -348,6 +348,10 @@ function parsePatchLintBlock(rec) {
348
348
  if (chromeScriptJsDoc !== undefined) {
349
349
  out.chromeScriptJsDoc = chromeScriptJsDoc;
350
350
  }
351
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'patchLint.undefinedIdentifiers');
352
+ if (undefinedIdentifiers !== undefined) {
353
+ out.undefinedIdentifiers = undefinedIdentifiers;
354
+ }
351
355
  if (out.checkJsStrict === true && out.checkJs !== true) {
352
356
  throw new ConfigError('Config field "patchLint.checkJsStrict" requires "patchLint.checkJs": true');
353
357
  }
@@ -413,6 +417,10 @@ function parseTypecheckBlock(rec) {
413
417
  if (overrides) {
414
418
  out.projectOverrides = overrides;
415
419
  }
420
+ const undefinedIdentifiers = parseSeverityGate(rec.raw('undefinedIdentifiers'), 'typecheck.undefinedIdentifiers');
421
+ if (undefinedIdentifiers !== undefined) {
422
+ out.undefinedIdentifiers = undefinedIdentifiers;
423
+ }
416
424
  return out;
417
425
  }
418
426
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
3
+ *
4
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
5
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
6
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
7
+ * modifications, patch-created files are untracked). The pre-0.39.0
8
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
9
+ * untracked files, silently reverting patch-backed files PAST their owning
10
+ * patch and deleting patch-created files outright.
11
+ *
12
+ * This module classifies each status entry against the patch manifest's
13
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
14
+ * baseline (HEAD content folded through every affecting patch in order) for
15
+ * claimed files, and the legacy git mechanics for unmanaged ones.
16
+ */
17
+ import type { GitStatusEntry } from './git-base.js';
18
+ /** How one status entry's canonical path will be restored. */
19
+ export type DiscardKind = 'unmanaged' | 'patch-backed' | 'patch-created' | 'patch-deleted';
20
+ /** Restore plan for one git status entry. */
21
+ export interface DiscardBaselinePlan {
22
+ entry: GitStatusEntry;
23
+ kind: DiscardKind;
24
+ /** Claiming patch filenames for `entry.file` ∪ `entry.originalPath`. */
25
+ owners: string[];
26
+ /** True when more than one patch claims the path (ownership conflict). */
27
+ conflicted: boolean;
28
+ /** Baseline content for `entry.file`; null = absent at baseline. Unset for 'unmanaged'. */
29
+ expectedContent?: string | null;
30
+ /** Rename/copy only: baseline content for `originalPath` when that side is claimed. */
31
+ expectedOriginalContent?: string | null;
32
+ }
33
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
34
+ export declare function planUpstreamDiscards(entries: readonly GitStatusEntry[]): DiscardBaselinePlan[];
35
+ /**
36
+ * Plans the restore target for each status entry. Entries claimed by the
37
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
38
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
39
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
40
+ *
41
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
42
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
43
+ * HEAD is exactly the data-loss path this module exists to close.
44
+ */
45
+ export declare function planDiscardBaselines(patchesDir: string, engineDir: string, entries: readonly GitStatusEntry[]): Promise<DiscardBaselinePlan[]>;
46
+ /**
47
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
48
+ * leaves them: patched content as an unstaged worktree state (tracked files
49
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
50
+ * to the legacy {@link discardStatusEntry} mechanics.
51
+ */
52
+ export declare function applyDiscardBaseline(engineDir: string, plan: DiscardBaselinePlan): Promise<void>;
53
+ /** One-line annotation for dry-run listings and confirmation summaries. */
54
+ export declare function describeDiscardBaseline(plan: DiscardBaselinePlan): string;
55
+ /** Single-file outro naming the baseline that was restored. */
56
+ export declare function describeDiscardOutcome(plan: DiscardBaselinePlan, toUpstream: boolean): string;
57
+ /** Batch outro summarising how many files restored to which baseline. */
58
+ export declare function summarizeDiscardBaselines(plans: readonly DiscardBaselinePlan[], succeeded: number): string;
59
+ /** Warn text for a multi-owner (conflicted) restore. */
60
+ export declare function describeConflictWarning(plan: DiscardBaselinePlan): string;
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
4
+ *
5
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
6
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
7
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
8
+ * modifications, patch-created files are untracked). The pre-0.39.0
9
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
10
+ * untracked files, silently reverting patch-backed files PAST their owning
11
+ * patch and deleting patch-created files outright.
12
+ *
13
+ * This module classifies each status entry against the patch manifest's
14
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
15
+ * baseline (HEAD content folded through every affecting patch in order) for
16
+ * claimed files, and the legacy git mechanics for unmanaged ones.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { GeneralError } from '../errors/base.js';
20
+ import { toError } from '../utils/errors.js';
21
+ import { readText, removeFile, writeText } from '../utils/fs.js';
22
+ import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
23
+ import { createPatchedContentComputer } from './patch-apply.js';
24
+ import { loadPatchesManifestState } from './patch-manifest-io.js';
25
+ import { parseDiffSections } from './patch-parse.js';
26
+ import { buildPatchClaims } from './status-classify.js';
27
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
28
+ export function planUpstreamDiscards(entries) {
29
+ return entries.map((entry) => ({ entry, kind: 'unmanaged', owners: [], conflicted: false }));
30
+ }
31
+ /**
32
+ * Plans the restore target for each status entry. Entries claimed by the
33
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
34
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
35
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
36
+ *
37
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
38
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
39
+ * HEAD is exactly the data-loss path this module exists to close.
40
+ */
41
+ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
42
+ const state = await loadPatchesManifestState(patchesDir);
43
+ if (state.exists && state.parseError) {
44
+ throw new GeneralError('patches/patches.json is unreadable — refusing to discard while patch ownership cannot be ' +
45
+ 'determined. Fix the manifest, or pass --to-upstream to explicitly revert to pristine ' +
46
+ 'upstream (HEAD).');
47
+ }
48
+ const claims = state.manifest
49
+ ? buildPatchClaims(state.manifest.patches)
50
+ : new Map();
51
+ const plans = [];
52
+ const claimedEntries = entries.filter((entry) => claims.has(entry.file) || (entry.originalPath !== undefined && claims.has(entry.originalPath)));
53
+ if (claimedEntries.length === 0) {
54
+ return planUpstreamDiscards(entries);
55
+ }
56
+ const computePatched = await createPatchedContentComputer(patchesDir, engineDir);
57
+ const claimedFiles = [
58
+ ...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
59
+ ];
60
+ const trackedInHead = await listTrackedInHead(engineDir, claimedFiles);
61
+ const computeExpected = async (path) => {
62
+ try {
63
+ return await computePatched(path);
64
+ }
65
+ catch (error) {
66
+ throw new GeneralError(`Cannot reconstruct the patch baseline for ${path} (${toError(error).message}). ` +
67
+ 'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
68
+ 'upstream (HEAD).');
69
+ }
70
+ };
71
+ // `computePatchedContent` folds a deletion into an empty string (an
72
+ // apply-to-content limitation), which is indistinguishable from a
73
+ // legitimately empty file. Consult the owning patches' diff sections
74
+ // directly: the LAST section (highest-ordered owner) naming the path
75
+ // decides whether the baseline is "absent".
76
+ const sectionCache = new Map();
77
+ const isDeletedAtBaseline = async (path, owners) => {
78
+ for (let i = owners.length - 1; i >= 0; i--) {
79
+ const owner = owners[i];
80
+ if (owner === undefined)
81
+ continue;
82
+ let sections = sectionCache.get(owner);
83
+ if (!sections) {
84
+ try {
85
+ sections = parseDiffSections(await readText(join(patchesDir, owner)));
86
+ }
87
+ catch {
88
+ sections = [];
89
+ }
90
+ sectionCache.set(owner, sections);
91
+ }
92
+ for (const section of sections) {
93
+ if (section.targetPath === path || section.sourcePath === path) {
94
+ return section.isDeletedFile;
95
+ }
96
+ }
97
+ }
98
+ return false;
99
+ };
100
+ for (const entry of entries) {
101
+ const owners = [
102
+ ...new Set([
103
+ ...(claims.get(entry.file) ?? []),
104
+ ...(entry.originalPath !== undefined ? (claims.get(entry.originalPath) ?? []) : []),
105
+ ]),
106
+ ];
107
+ if (owners.length === 0) {
108
+ plans.push({ entry, kind: 'unmanaged', owners, conflicted: false });
109
+ continue;
110
+ }
111
+ let expectedContent = claims.has(entry.file) ? await computeExpected(entry.file) : null;
112
+ if (expectedContent !== null &&
113
+ (await isDeletedAtBaseline(entry.file, claims.get(entry.file) ?? []))) {
114
+ expectedContent = null;
115
+ }
116
+ const expectedOriginalContent = entry.originalPath !== undefined && claims.has(entry.originalPath)
117
+ ? await computeExpected(entry.originalPath)
118
+ : undefined;
119
+ const kind = expectedContent === null
120
+ ? 'patch-deleted'
121
+ : trackedInHead.has(entry.file)
122
+ ? 'patch-backed'
123
+ : 'patch-created';
124
+ plans.push({
125
+ entry,
126
+ kind,
127
+ owners,
128
+ conflicted: owners.length > 1,
129
+ expectedContent,
130
+ ...(expectedOriginalContent !== undefined ? { expectedOriginalContent } : {}),
131
+ });
132
+ }
133
+ return plans;
134
+ }
135
+ /**
136
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
137
+ * leaves them: patched content as an unstaged worktree state (tracked files
138
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
139
+ * to the legacy {@link discardStatusEntry} mechanics.
140
+ */
141
+ export async function applyDiscardBaseline(engineDir, plan) {
142
+ if (plan.kind === 'unmanaged') {
143
+ await discardStatusEntry(engineDir, plan.entry);
144
+ return;
145
+ }
146
+ const { entry } = plan;
147
+ // Drop any staged edits/adds/renames first so the rewritten content is a
148
+ // plain worktree state (harmless when nothing is staged; skipped for
149
+ // purely-untracked entries which have no index footprint).
150
+ if (!entry.isUntracked) {
151
+ const indexPaths = entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file];
152
+ await unstageFiles(engineDir, indexPaths);
153
+ }
154
+ if (plan.expectedContent === null || plan.expectedContent === undefined) {
155
+ await removeFile(join(engineDir, entry.file));
156
+ }
157
+ else {
158
+ await writeText(join(engineDir, entry.file), plan.expectedContent);
159
+ }
160
+ // A rename/copy's original side restores to ITS baseline too: the patch
161
+ // baseline when claimed, else the legacy HEAD restore (matching what
162
+ // discardStatusEntry does for that side).
163
+ if (entry.originalPath !== undefined) {
164
+ if (plan.expectedOriginalContent !== undefined) {
165
+ if (plan.expectedOriginalContent === null) {
166
+ await removeFile(join(engineDir, entry.originalPath));
167
+ }
168
+ else {
169
+ await writeText(join(engineDir, entry.originalPath), plan.expectedOriginalContent);
170
+ }
171
+ }
172
+ else if (await fileExistsInHead(engineDir, entry.originalPath)) {
173
+ await restoreTrackedPath(engineDir, entry.originalPath);
174
+ }
175
+ }
176
+ }
177
+ /** One-line annotation for dry-run listings and confirmation summaries. */
178
+ export function describeDiscardBaseline(plan) {
179
+ const owners = plan.owners.join(' + ');
180
+ switch (plan.kind) {
181
+ case 'unmanaged':
182
+ return plan.entry.isUntracked ? 'unmanaged — delete' : 'unmanaged — revert to upstream';
183
+ case 'patch-backed':
184
+ return `patch baseline: ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
185
+ case 'patch-created':
186
+ return `re-materialize from ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
187
+ case 'patch-deleted':
188
+ return `delete — ${owners} removes it`;
189
+ }
190
+ }
191
+ /** Single-file outro naming the baseline that was restored. */
192
+ export function describeDiscardOutcome(plan, toUpstream) {
193
+ if (toUpstream)
194
+ return 'File restored to pristine upstream (HEAD)';
195
+ const owners = plan.owners.join(' + ');
196
+ switch (plan.kind) {
197
+ case 'unmanaged':
198
+ return 'File restored to original state';
199
+ case 'patch-backed':
200
+ return `File restored to patch baseline (${owners})`;
201
+ case 'patch-created':
202
+ return `File re-materialized from patch baseline (${owners})`;
203
+ case 'patch-deleted':
204
+ return `File removed to match patch baseline (${owners} deletes it)`;
205
+ }
206
+ }
207
+ /** Batch outro summarising how many files restored to which baseline. */
208
+ export function summarizeDiscardBaselines(plans, succeeded) {
209
+ const patchBacked = plans.filter((p) => p.kind === 'patch-backed').length;
210
+ const created = plans.filter((p) => p.kind === 'patch-created').length;
211
+ const deleted = plans.filter((p) => p.kind === 'patch-deleted').length;
212
+ const unmanaged = plans.filter((p) => p.kind === 'unmanaged').length;
213
+ if (patchBacked + created + deleted === 0) {
214
+ return `${String(succeeded)} file(s) restored to upstream state`;
215
+ }
216
+ const parts = [];
217
+ if (patchBacked > 0)
218
+ parts.push(`${String(patchBacked)} to patch baseline`);
219
+ if (created > 0)
220
+ parts.push(`${String(created)} re-materialized`);
221
+ if (deleted > 0)
222
+ parts.push(`${String(deleted)} removed per patch baseline`);
223
+ if (unmanaged > 0)
224
+ parts.push(`${String(unmanaged)} to upstream state`);
225
+ return `${String(succeeded)} file(s) restored: ${parts.join(', ')}`;
226
+ }
227
+ /** Warn text for a multi-owner (conflicted) restore. */
228
+ export function describeConflictWarning(plan) {
229
+ return (`${plan.entry.file} is claimed by ${String(plan.owners.length)} patches ` +
230
+ `(${plan.owners.join(', ')}). Restored the cumulative baseline; run ` +
231
+ '"fireforge status --ownership" to resolve ownership.');
232
+ }
@@ -81,6 +81,18 @@ export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
81
81
  * @param maxLines - Number of leading lines to inspect (default 10)
82
82
  */
83
83
  export declare function containsUpstreamLicenseText(content: string, maxLines?: number): boolean;
84
+ /**
85
+ * Returns true when the head of `content` carries a recognized THIRD-PARTY
86
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
87
+ * comment style. Used by `export` to treat such files as vendored: offering
88
+ * to prepend the project's own license header onto a byte-identity-pinned
89
+ * upstream bundle would mislicense third-party code (FORGE F15).
90
+ *
91
+ * @param content - File content to check
92
+ * @param maxLines - Number of leading lines to inspect (default 30 — full
93
+ * MIT/BSD license texts run longer than the 10-line project-header scan)
94
+ */
95
+ export declare function hasThirdPartyPermissiveBanner(content: string, maxLines?: number): boolean;
84
96
  /**
85
97
  * Prepends the license header to a file on disk if it is not already present.
86
98
  *
@@ -252,6 +252,33 @@ export function containsUpstreamLicenseText(content, maxLines = 10) {
252
252
  ];
253
253
  return markers.some((marker) => scanText.includes(marker));
254
254
  }
255
+ /**
256
+ * Returns true when the head of `content` carries a recognized THIRD-PARTY
257
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
258
+ * comment style. Used by `export` to treat such files as vendored: offering
259
+ * to prepend the project's own license header onto a byte-identity-pinned
260
+ * upstream bundle would mislicense third-party code (FORGE F15).
261
+ *
262
+ * @param content - File content to check
263
+ * @param maxLines - Number of leading lines to inspect (default 30 — full
264
+ * MIT/BSD license texts run longer than the 10-line project-header scan)
265
+ */
266
+ export function hasThirdPartyPermissiveBanner(content, maxLines = 30) {
267
+ const head = content.split('\n').slice(0, maxLines).join('\n');
268
+ const scanText = normalizeLicenseHeadForScan(head).toLowerCase();
269
+ const markers = [
270
+ 'mit license',
271
+ 'permission is hereby granted, free of charge',
272
+ 'isc license',
273
+ 'permission to use, copy, modify',
274
+ 'redistribution and use in source and binary forms',
275
+ 'apache license',
276
+ 'licensed under the apache license',
277
+ ];
278
+ if (markers.some((marker) => scanText.includes(marker)))
279
+ return true;
280
+ return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0)\b/.test(scanText);
281
+ }
255
282
  /**
256
283
  * Prepends the license header to a file on disk if it is not already present.
257
284
  *
@@ -30,7 +30,16 @@ export declare function sanitizeName(name: string): string;
30
30
  * double-prefix incident) also collapses. Exported for direct testing.
31
31
  */
32
32
  export declare function stripRedundantCategoryPrefix(sanitizedName: string, category: string): string;
33
- /** Sanitizes a patch name and drops redundant category/full-stem prefixes. */
33
+ /**
34
+ * Sanitizes a patch name and drops redundant category/full-stem prefixes.
35
+ *
36
+ * A single trailing `.patch` extension is stripped before sanitisation:
37
+ * operators frequently pass a full patch FILENAME where a name is expected
38
+ * (`move-files <from> 348-ui-foo.patch --create`, `rename --to foo.patch`),
39
+ * and 0.38.0 slugged the extension into `-patch`, producing double-suffixed
40
+ * `...-patch.patch` files. A name that deliberately ends in a literal
41
+ * `-patch` slug segment must now be written without the dot form.
42
+ */
34
43
  export declare function patchNameSlug(name: string, category: string): string;
35
44
  /**
36
45
  * Generates the next patch filename with category.