@hominis/fireforge 0.37.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 (99) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +13 -8
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/download.js +5 -2
  7. package/dist/src/commands/export-flow.d.ts +6 -2
  8. package/dist/src/commands/export-flow.js +17 -11
  9. package/dist/src/commands/export-placement-gate.js +4 -6
  10. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  11. package/dist/src/commands/export-placement-policy.js +32 -29
  12. package/dist/src/commands/export-shared.js +17 -2
  13. package/dist/src/commands/export.js +5 -5
  14. package/dist/src/commands/furnace/index.js +23 -18
  15. package/dist/src/commands/import.js +5 -0
  16. package/dist/src/commands/lint-per-patch.js +15 -5
  17. package/dist/src/commands/lint.js +1 -1
  18. package/dist/src/commands/patch/lint-ignore.js +20 -1
  19. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  20. package/dist/src/commands/patch/move-files-create.js +116 -0
  21. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  22. package/dist/src/commands/patch/move-files-into.js +245 -0
  23. package/dist/src/commands/patch/move-files.d.ts +3 -1
  24. package/dist/src/commands/patch/move-files.js +74 -15
  25. package/dist/src/commands/patch/rename.js +4 -1
  26. package/dist/src/commands/patch/split.d.ts +11 -1
  27. package/dist/src/commands/patch/split.js +6 -5
  28. package/dist/src/commands/re-export-scan.d.ts +17 -0
  29. package/dist/src/commands/re-export-scan.js +51 -0
  30. package/dist/src/commands/re-export.js +17 -6
  31. package/dist/src/commands/reset.js +5 -0
  32. package/dist/src/commands/source.js +28 -3
  33. package/dist/src/commands/status-output.d.ts +9 -0
  34. package/dist/src/commands/status-output.js +33 -1
  35. package/dist/src/commands/status.js +17 -3
  36. package/dist/src/commands/test-diagnose.d.ts +1 -1
  37. package/dist/src/commands/test-diagnose.js +9 -3
  38. package/dist/src/commands/test-modes.d.ts +21 -0
  39. package/dist/src/commands/test-modes.js +42 -0
  40. package/dist/src/commands/test-register.js +6 -5
  41. package/dist/src/commands/test-run.d.ts +2 -0
  42. package/dist/src/commands/test-run.js +8 -2
  43. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  44. package/dist/src/commands/test-stale-gate.js +81 -0
  45. package/dist/src/commands/test.js +52 -81
  46. package/dist/src/core/build-audit.d.ts +12 -0
  47. package/dist/src/core/build-audit.js +14 -0
  48. package/dist/src/core/build-baseline-types.d.ts +41 -0
  49. package/dist/src/core/build-baseline.d.ts +6 -1
  50. package/dist/src/core/build-baseline.js +48 -7
  51. package/dist/src/core/build-prepare.d.ts +9 -0
  52. package/dist/src/core/build-prepare.js +24 -0
  53. package/dist/src/core/config-paths.d.ts +1 -1
  54. package/dist/src/core/config-paths.js +1 -0
  55. package/dist/src/core/config-validate.js +14 -1
  56. package/dist/src/core/discard-baseline.d.ts +60 -0
  57. package/dist/src/core/discard-baseline.js +232 -0
  58. package/dist/src/core/engine-session-lock.d.ts +10 -1
  59. package/dist/src/core/engine-session-lock.js +28 -2
  60. package/dist/src/core/file-lock.d.ts +33 -0
  61. package/dist/src/core/file-lock.js +32 -6
  62. package/dist/src/core/firefox-archive.d.ts +2 -1
  63. package/dist/src/core/firefox-archive.js +32 -8
  64. package/dist/src/core/firefox.d.ts +8 -3
  65. package/dist/src/core/firefox.js +11 -6
  66. package/dist/src/core/furnace-validate-helpers.js +28 -1
  67. package/dist/src/core/license-headers.d.ts +24 -0
  68. package/dist/src/core/license-headers.js +103 -1
  69. package/dist/src/core/patch-export.d.ts +10 -1
  70. package/dist/src/core/patch-export.js +12 -2
  71. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  72. package/dist/src/core/patch-lint-checkjs.js +30 -14
  73. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  74. package/dist/src/core/patch-lint-cross.js +69 -9
  75. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  76. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  77. package/dist/src/core/patch-lint.d.ts +3 -0
  78. package/dist/src/core/patch-lint.js +29 -8
  79. package/dist/src/core/patch-policy.d.ts +14 -1
  80. package/dist/src/core/patch-policy.js +13 -2
  81. package/dist/src/core/status-classify.d.ts +11 -2
  82. package/dist/src/core/status-classify.js +49 -25
  83. package/dist/src/core/test-harness-crash.d.ts +10 -0
  84. package/dist/src/core/test-harness-crash.js +16 -0
  85. package/dist/src/core/test-stale-check.d.ts +56 -1
  86. package/dist/src/core/test-stale-check.js +114 -4
  87. package/dist/src/core/token-manager.js +39 -2
  88. package/dist/src/core/token-variant.d.ts +7 -0
  89. package/dist/src/core/token-variant.js +9 -0
  90. package/dist/src/core/typecheck-shim.d.ts +13 -0
  91. package/dist/src/core/typecheck-shim.js +17 -2
  92. package/dist/src/core/typecheck.js +14 -3
  93. package/dist/src/types/commands/options.d.ts +69 -3
  94. package/dist/src/types/config.d.ts +22 -0
  95. package/dist/src/utils/options.d.ts +20 -0
  96. package/dist/src/utils/options.js +39 -0
  97. package/dist/src/utils/validation.d.ts +5 -0
  98. package/dist/src/utils/validation.js +7 -0
  99. package/package.json +3 -3
@@ -10,44 +10,47 @@ function findReservedRange(config, order) {
10
10
  return (config.patchPolicy?.reservedRanges?.find((range) => order >= range.from && order <= range.to) ??
11
11
  null);
12
12
  }
13
- function suggestSparseOrder(config, patches, category, insertionOrder) {
14
- const ranges = (config.patchPolicy?.ranges ?? [])
15
- .filter((range) => range.category === category)
16
- .sort((a, b) => a.from - b.from || a.to - b.to);
13
+ /**
14
+ * Scans downward from just below the reserved block for the first order
15
+ * not occupied by an existing patch. Returns null when every positive
16
+ * order below the block is taken.
17
+ */
18
+ function firstFreeOrderBelowReservedRange(patches, range) {
17
19
  const occupied = new Set(patches.map((patch) => patch.order));
18
- for (const range of ranges) {
19
- for (let order = Math.max(insertionOrder, range.from); order <= range.to; order++) {
20
- if (!occupied.has(order) && findReservedRange(config, order) === null)
21
- return order;
22
- }
23
- }
24
- for (const range of ranges) {
25
- for (let order = range.from; order <= range.to; order++) {
26
- if (!occupied.has(order) && findReservedRange(config, order) === null)
27
- return order;
28
- }
20
+ for (let order = range.from - 1; order >= 1; order--) {
21
+ if (!occupied.has(order))
22
+ return order;
29
23
  }
30
24
  return null;
31
25
  }
32
- /** Refuses positional export plans that would renumber exact reserved patches. */
33
- export function assertPlacementPreservesReservedRanges(plan, manifestPatches, config, category) {
26
+ /**
27
+ * Refuses positional placement plans whose renumber would touch a
28
+ * `patchPolicy.reservedRanges` block — either by moving a patch INTO a
29
+ * reserved range or by moving a patch that currently sits inside one.
30
+ * Throws a single up-front error for the first reserved range hit (the
31
+ * per-patch alternative surfaced one confusing finding per shifted
32
+ * patch), suggesting the first free `--order` below the block when one
33
+ * exists. Exact `--order` plans have an empty rename map and pass.
34
+ */
35
+ export function assertPlacementAvoidsReservedRanges(plan, manifestPatches, config) {
34
36
  if (config?.patchPolicy === undefined || plan.renameMap.size === 0)
35
37
  return;
36
38
  const byFilename = new Map(manifestPatches.map((patch) => [patch.filename, patch]));
37
39
  for (const [filename, rename] of plan.renameMap) {
38
- const patch = byFilename.get(filename);
39
- if (!patch)
40
- continue;
41
- const reserved = findReservedRange(config, patch.order);
40
+ const oldOrder = byFilename.get(filename)?.order;
41
+ const reserved = findReservedRange(config, rename.newOrder) ??
42
+ (oldOrder !== undefined ? findReservedRange(config, oldOrder) : null);
42
43
  if (reserved === null)
43
44
  continue;
44
- const suggestion = suggestSparseOrder(config, manifestPatches, category, plan.insertionOrder);
45
- const suggestionText = suggestion !== null
46
- ? ` Use --order ${String(suggestion).padStart(3, '0')} to create the new patch in an unused ${category} slot without renumbering reserved patches.`
47
- : ` Choose an unused order in the ${category} policy range or adjust patchPolicy.`;
48
- throw new InvalidArgumentError(`Positional export would renumber reserved patch ${patch.filename} ` +
49
- `from ${String(patch.order).padStart(3, '0')} to ${rename.newFilename} ` +
50
- `(reserved range ${reservedRangeLabel(reserved)}).` +
51
- suggestionText, 'export placement');
45
+ const label = reservedRangeLabel(reserved);
46
+ const freeOrder = firstFreeOrderBelowReservedRange(manifestPatches, reserved);
47
+ if (freeOrder !== null) {
48
+ throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
49
+ `pass --order ${String(freeOrder).padStart(3, '0')} (first free order below the ` +
50
+ 'reserved block) to place the new patch without renumbering reserved patches.', 'export placement');
51
+ }
52
+ throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
53
+ 'no free order exists below the reserved block. Choose an unused --order outside ' +
54
+ 'the reserved range or adjust patchPolicy.reservedRanges.', 'export placement');
52
55
  }
53
56
  }
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { confirm, select, text } from '@clack/prompts';
4
- import { addLicenseHeaderToFile } from '../core/license-headers.js';
4
+ import { addLicenseHeaderToFile, hasThirdPartyPermissiveBanner } from '../core/license-headers.js';
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
6
  import { commentStyleForFile, detectNewFilesInDiff, isAcceptableNewFileHeader, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
7
  import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
@@ -218,6 +218,7 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
218
218
  if (newFiles.size === 0)
219
219
  return false;
220
220
  const filesToFix = [];
221
+ const vendoredFiles = [];
221
222
  for (const file of newFiles) {
222
223
  const style = commentStyleForFile(file);
223
224
  if (!style)
@@ -231,9 +232,23 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
231
232
  // MPL block header on a derived JS/CSS file) would stack a second
232
233
  // header on top of a legitimate one.
233
234
  if (!isAcceptableNewFileHeader(file, content, style, license)) {
234
- filesToFix.push(file);
235
+ // A recognized third-party permissive banner marks the file as
236
+ // vendored: prepending the project's own license header would break
237
+ // byte-identity pins and mislicense upstream code (FORGE F15).
238
+ if (hasThirdPartyPermissiveBanner(content)) {
239
+ vendoredFiles.push(file);
240
+ }
241
+ else {
242
+ filesToFix.push(file);
243
+ }
235
244
  }
236
245
  }
246
+ if (vendoredFiles.length > 0) {
247
+ const vendoredList = vendoredFiles.map((f) => ` - ${f}`).join('\n');
248
+ info(`${vendoredFiles.length} new file(s) carry a third-party permissive license banner and ` +
249
+ 'were left untouched (vendored); if the missing-license-header lint check flags them, ' +
250
+ `waive it with --lint-ignore missing-license-header:\n${vendoredList}`);
251
+ }
237
252
  if (filesToFix.length === 0)
238
253
  return false;
239
254
  if (dryRun) {
@@ -20,7 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
20
20
  import { toError } from '../utils/errors.js';
21
21
  import { ensureDir, pathExists } from '../utils/fs.js';
22
22
  import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
23
- import { commanderArgParser, pickDefined } from '../utils/options.js';
23
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
24
24
  import { stripEnginePrefix } from '../utils/paths.js';
25
25
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
26
26
  import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
@@ -400,7 +400,7 @@ export async function exportCommand(projectRoot, files, options) {
400
400
  }
401
401
  /** Registers the export command on the CLI program. */
402
402
  export function registerExport(program, { getProjectRoot, withErrorHandling }) {
403
- program
403
+ const exportCmd = program
404
404
  .command('export <paths...>')
405
405
  .description('Export new changes as a patch (use re-export to update existing patches)')
406
406
  .option('-n, --name <name>', 'Name for the patch')
@@ -418,8 +418,8 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
418
418
  .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
419
419
  .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
420
420
  .addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
421
- .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
422
- .action(withErrorHandling(async (paths, options) => {
421
+ .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], []);
422
+ addWaitLockOption(exportCmd).action(withErrorHandling(async (paths, options) => {
423
423
  const { category, tier, lintIgnore, ...rest } = options;
424
424
  const projectRoot = getProjectRoot();
425
425
  await withEngineSessionLock(projectRoot, 'export', () => exportCommand(projectRoot, paths, {
@@ -427,6 +427,6 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
427
427
  ...(category !== undefined ? { category } : {}),
428
428
  ...(tier !== undefined ? { tier: tier } : {}),
429
429
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
430
- }));
430
+ }), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
431
431
  }));
432
432
  }
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { Command, Option } from 'commander';
3
3
  import { withEngineSessionLock } from '../../core/engine-session-lock.js';
4
- import { pickDefined } from '../../utils/options.js';
4
+ import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
5
5
  import { furnaceApplyCommand } from './apply.js';
6
6
  import { furnaceChromeDocCreateCommand } from './chrome-doc.js';
7
7
  import { furnaceChromeDocRemoveCommand } from './chrome-doc-remove.js';
@@ -19,61 +19,66 @@ import { furnaceScanCommand } from './scan.js';
19
19
  import { furnaceStatusCommand } from './status.js';
20
20
  import { furnaceSyncCommand } from './sync.js';
21
21
  import { furnaceValidateCommand } from './validate.js';
22
- async function runEngineLockedFurnaceCommand(context, command, operation) {
22
+ async function runEngineLockedFurnaceCommand(context, command, operation, waitLockSeconds) {
23
23
  const projectRoot = context.getProjectRoot();
24
- await withEngineSessionLock(projectRoot, command, () => operation(projectRoot));
24
+ await withEngineSessionLock(projectRoot, command, () => operation(projectRoot), {
25
+ waitLockSeconds,
26
+ });
25
27
  }
26
28
  function registerFurnaceApplyCommand(furnace, context) {
27
29
  const { getProjectRoot, withErrorHandling } = context;
28
- furnace
30
+ const apply = furnace
29
31
  .command('apply [name]')
30
32
  .description('Apply components to the engine (optionally a single component)')
31
33
  .option('--dry-run', 'Show what would be changed without writing (reads may overlap concurrent mutations)')
32
34
  .option('--force', 'Proceed despite baseVersion drift (stale overrides)')
33
- .option('-w, --watch', 'Watch component directories and re-apply on changes')
34
- .action(withErrorHandling(async (name, options) => {
35
- const parsed = pickDefined(options ?? {});
35
+ .option('-w, --watch', 'Watch component directories and re-apply on changes');
36
+ addWaitLockOption(apply).action(withErrorHandling(async (name, options) => {
37
+ const { waitLock, ...rest } = options ?? {};
38
+ const parsed = pickDefined(rest);
36
39
  const run = (projectRoot) => furnaceApplyCommand(projectRoot, name, parsed);
37
40
  if (parsed.dryRun === true) {
38
41
  await run(getProjectRoot());
39
42
  return;
40
43
  }
41
- await runEngineLockedFurnaceCommand(context, 'furnace apply', run);
44
+ await runEngineLockedFurnaceCommand(context, 'furnace apply', run, resolveWaitLockSeconds(waitLock));
42
45
  }));
43
46
  }
44
47
  function registerFurnaceDeployCommand(furnace, context) {
45
48
  const { getProjectRoot, withErrorHandling } = context;
46
- furnace
49
+ const deploy = furnace
47
50
  .command('deploy [name]')
48
51
  .description('Apply components and validate in one step')
49
52
  .option('--dry-run', 'Show what would be changed without writing (reads may overlap concurrent mutations)')
50
53
  .option('--force', 'Proceed despite baseVersion drift (stale overrides)')
51
- .option('--skip-validate', 'Skip the validation step (apply only)')
52
- .action(withErrorHandling(async (name, options) => {
53
- const parsed = pickDefined(options ?? {});
54
+ .option('--skip-validate', 'Skip the validation step (apply only)');
55
+ addWaitLockOption(deploy).action(withErrorHandling(async (name, options) => {
56
+ const { waitLock, ...rest } = options ?? {};
57
+ const parsed = pickDefined(rest);
54
58
  const run = (projectRoot) => furnaceDeployCommand(projectRoot, name, parsed);
55
59
  if (parsed.dryRun === true) {
56
60
  await run(getProjectRoot());
57
61
  return;
58
62
  }
59
- await runEngineLockedFurnaceCommand(context, 'furnace deploy', run);
63
+ await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, resolveWaitLockSeconds(waitLock));
60
64
  }));
61
65
  }
62
66
  function registerFurnaceSyncCommand(furnace, context) {
63
67
  const { getProjectRoot, withErrorHandling } = context;
64
- furnace
68
+ const sync = furnace
65
69
  .command('sync')
66
70
  .description('Refresh drifted overrides and re-apply all components (recommended after fireforge download)')
67
71
  .option('--dry-run', 'Show what would change without modifying files (reads may overlap concurrent mutations)')
68
- .addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']))
69
- .action(withErrorHandling(async (options) => {
70
- const parsed = pickDefined(options);
72
+ .addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']));
73
+ addWaitLockOption(sync).action(withErrorHandling(async (options) => {
74
+ const { waitLock, ...rest } = options;
75
+ const parsed = pickDefined(rest);
71
76
  const run = (projectRoot) => furnaceSyncCommand(projectRoot, parsed);
72
77
  if (parsed.dryRun === true) {
73
78
  await run(getProjectRoot());
74
79
  return;
75
80
  }
76
- await runEngineLockedFurnaceCommand(context, 'furnace sync', run);
81
+ await runEngineLockedFurnaceCommand(context, 'furnace sync', run, resolveWaitLockSeconds(waitLock));
77
82
  }));
78
83
  }
79
84
  /**
@@ -8,6 +8,7 @@ import { getDirtyFiles } from '../core/git-status.js';
8
8
  import { applyPatchesWithContinue, countPatches, createPatchedContentComputer, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
9
9
  import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
10
10
  import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
11
+ import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
11
12
  import { GeneralError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists, readText } from '../utils/fs.js';
@@ -419,6 +420,10 @@ export async function importCommand(projectRoot, options = {}) {
419
420
  const suffix = result.autoResolved ? ' (auto-resolved)' : '';
420
421
  success(` ${result.patch.filename}${suffix}`);
421
422
  }
423
+ // The re-applied queue may have moved components.conf away from what
424
+ // the last full build compiled in — surface that now instead of at the
425
+ // next test refusal (FORGE F13).
426
+ await warnIfStaticComponentsStale(projectRoot, paths.engine);
422
427
  outro('All patches applied successfully!');
423
428
  }
424
429
  catch (error) {
@@ -56,6 +56,11 @@ async function lintQueuedPatch(patch, lintCtx) {
56
56
  });
57
57
  const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
58
58
  if (cached) {
59
+ // Returning before the empty-diff probe below is safe: the cache key
60
+ // hashes every affected engine file's content plus engineHeadSha, so
61
+ // an engine-side revert that would empty the diff always changes the
62
+ // key and misses the cache (pinned by the "engine-side content
63
+ // revert invalidates the per-patch cache" test — FORGE F5).
59
64
  return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
60
65
  }
61
66
  }
@@ -115,9 +120,14 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
115
120
  continue;
116
121
  }
117
122
  emitTierNotice(patch.filename, result.existingFiles, patch.tier);
118
- // Run-level checkJs errors are emitted once, before the first freshly
119
- // linted patch's own issues — matching the serial emit point.
120
- if (result.usedCheckJs && checkJs && !globalCheckJsEmitted) {
123
+ // Run-level checkJs errors are emitted once, before the first
124
+ // non-skipped patch's own issues — matching the serial emit point.
125
+ // Deliberately NOT gated on `result.usedCheckJs`: global findings are
126
+ // run-level, never cached, and an all-cache-hit run previously dropped
127
+ // them entirely, letting a warm run report fewer errors than a cold
128
+ // one (FORGE F5 hardening). PerRunCheckJs builds its program lazily,
129
+ // so the cost only materialises when checkJs is configured.
130
+ if (checkJs && !globalCheckJsEmitted) {
121
131
  globalCheckJsEmitted = true;
122
132
  issues.push(...(await checkJs.getGlobal()));
123
133
  }
@@ -276,7 +286,7 @@ function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
276
286
  return {
277
287
  ownedByPatch,
278
288
  getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
279
- getGlobal: async () => (groupedPromise ? (await groupedPromise).global : []),
289
+ getGlobal: async () => (await (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot))).global,
280
290
  };
281
291
  }
282
292
  /**
@@ -299,7 +309,7 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
299
309
  const subsetNames = new Set(subset.map((p) => p.filename));
300
310
  const isSubset = subset.length !== manifest.patches.length;
301
311
  const config = await loadConfig(projectRoot);
302
- const ctx = await buildPatchQueueContext(paths.patches);
312
+ const ctx = await buildPatchQueueContext(paths.patches, config);
303
313
  // Queue-level findings (policy, cross-patch) are scoped to the requested
304
314
  // subset: a 5-patch slice should not fail on a policy or forward-import
305
315
  // problem owned entirely by patches the operator did not target.
@@ -461,7 +461,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
461
461
  // per-patch ownership resolver and the cross-patch rules.
462
462
  let ctx;
463
463
  if (await pathExists(paths.patches)) {
464
- ctx = await buildPatchQueueContext(paths.patches);
464
+ ctx = await buildPatchQueueContext(paths.patches, config);
465
465
  }
466
466
  // Ad-hoc explicit-file-list mode evaluates the patch-size rules per
467
467
  // owning patch (see `lintOwningPatchSizes`), so suppress the synthetic
@@ -26,6 +26,16 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
26
26
  import { toError } from '../../utils/errors.js';
27
27
  import { info, intro, outro, warn } from '../../utils/logger.js';
28
28
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
29
+ /**
30
+ * Printed whenever `--add` lands a new check id. The waiver this command
31
+ * writes turns per-patch lint green immediately, but consumer projects
32
+ * commonly audit lintIgnore lists against a reviewed allow-map — the
33
+ * discovery that the map also needs updating otherwise costs a full
34
+ * downstream gate run (FORGE F6).
35
+ */
36
+ const LINT_IGNORE_REVIEW_WARNING = "This lint waiver is subject to the project's patch-policy review process. " +
37
+ 'Downstream policy audits that mirror lintIgnore lists in a reviewed allow-map ' +
38
+ 'will refuse this patch until the map is updated to include the new ID(s).';
29
39
  /**
30
40
  * Computes the post-mutation `lintIgnore` list for a given mode.
31
41
  * Returns `undefined` when the result should drop the field from the
@@ -114,7 +124,10 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
114
124
  // the flag only appeared in the history record — and suppressing lint
115
125
  // findings is precisely the mutation an operator should consciously
116
126
  // approve.
117
- const projectedSummary = describeChange(target.lintIgnore ?? [], applyMode(target.lintIgnore ?? [], mode, values) ?? [], mode, values);
127
+ const currentList = target.lintIgnore ?? [];
128
+ const projectedList = applyMode(currentList, mode, values) ?? [];
129
+ const addsNewIds = mode === 'add' && projectedList.length > currentList.length;
130
+ const projectedSummary = describeChange(currentList, projectedList, mode, values);
118
131
  const decision = await confirmDestructive({
119
132
  operation: 'patch-lint-ignore',
120
133
  title: `Patch lint-ignore: ${target.filename}`,
@@ -123,6 +136,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
123
136
  dryRun: isDryRun,
124
137
  });
125
138
  if (decision === 'dry-run') {
139
+ if (addsNewIds) {
140
+ warn(LINT_IGNORE_REVIEW_WARNING);
141
+ }
126
142
  outro('Dry run complete — no changes made');
127
143
  return;
128
144
  }
@@ -148,6 +164,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
148
164
  const existing = result.before.lintIgnore ?? [];
149
165
  const projected = result.after.lintIgnore ?? [];
150
166
  info(`${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
167
+ if (mode === 'add' && projected.length > existing.length) {
168
+ warn(LINT_IGNORE_REVIEW_WARNING);
169
+ }
151
170
  try {
152
171
  await appendHistory(paths.patches, {
153
172
  operation: 'patch-lint-ignore',
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `fireforge patch move-files <from> <to> --create --order <n>` — creates
3
+ * the target patch at the requested sparse order and moves the files into
4
+ * it as one transaction. This is the transactional bootstrap of a split:
5
+ * without it, moving files into a not-yet-existing patch required a manual
6
+ * shrink-then-export dance with hand-repointed staged-dependency owners.
7
+ *
8
+ * Mirrors `patch split` end-to-end (same planning, projection lint,
9
+ * policy enforcement, and locked commit); the `<to>` argument becomes the
10
+ * new patch's name/slug the way `split --name` does.
11
+ */
12
+ import type { PatchMoveFilesOptions } from '../../types/commands/index.js';
13
+ /**
14
+ * Runs `patch move-files --create --order <n>`: plans the create+move as a
15
+ * split, lints the projection, confirms, and commits transactionally.
16
+ */
17
+ export declare function patchMoveFilesCreateCommand(projectRoot: string, fromIdentifier: string, newPatchName: string, options: PatchMoveFilesOptions & {
18
+ order: number;
19
+ }): Promise<void>;
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch move-files <from> <to> --create --order <n>` — creates
4
+ * the target patch at the requested sparse order and moves the files into
5
+ * it as one transaction. This is the transactional bootstrap of a split:
6
+ * without it, moving files into a not-yet-existing patch required a manual
7
+ * shrink-then-export dance with hand-repointed staged-dependency owners.
8
+ *
9
+ * Mirrors `patch split` end-to-end (same planning, projection lint,
10
+ * policy enforcement, and locked commit); the `<to>` argument becomes the
11
+ * new patch's name/slug the way `split --name` does.
12
+ */
13
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
14
+ import { confirmDestructive } from '../../core/destructive.js';
15
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
16
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
17
+ import { enforcePatchPolicy } from '../../core/patch-policy.js';
18
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
19
+ import { info, intro, outro, success } from '../../utils/logger.js';
20
+ import { resolvePlacementPlan } from '../export-flow.js';
21
+ import { runPatchLint } from '../export-shared.js';
22
+ import { commitPatchSplit } from './split.js';
23
+ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, projectSplitManifest, runProjectedSplitLint, } from './split-plan.js';
24
+ /**
25
+ * Runs `patch move-files --create --order <n>`: plans the create+move as a
26
+ * split, lints the projection, confirms, and commits transactionally.
27
+ */
28
+ export async function patchMoveFilesCreateCommand(projectRoot, fromIdentifier, newPatchName, options) {
29
+ intro(options.dryRun === true
30
+ ? 'FireForge patch move-files --create (dry run)'
31
+ : 'FireForge patch move-files --create');
32
+ const paths = getProjectPaths(projectRoot);
33
+ const config = await loadConfig(projectRoot);
34
+ const manifest = await loadPatchesManifest(paths.patches);
35
+ if (!manifest || manifest.patches.length === 0) {
36
+ throw new GeneralError('No patches in manifest.');
37
+ }
38
+ const source = resolvePatchIdentifier(fromIdentifier, manifest.patches);
39
+ if (!source) {
40
+ throw new InvalidArgumentError(formatPatchNotFoundError(fromIdentifier, manifest.patches), 'patch move-files');
41
+ }
42
+ const existingTarget = resolvePatchIdentifier(newPatchName, manifest.patches);
43
+ if (existingTarget) {
44
+ throw new InvalidArgumentError(`--create target "${newPatchName}" already exists as ${existingTarget.filename}. ` +
45
+ 'Omit --create to preview a move into the existing patch, or pick a new patch name.', '--create');
46
+ }
47
+ const movedFiles = [...new Set((options.file ?? []).map((f) => f.trim()).filter(Boolean))].sort();
48
+ if (movedFiles.length === 0) {
49
+ throw new InvalidArgumentError('Specify at least one --file path to move.', '--file');
50
+ }
51
+ assertSourceOwnsFiles(source, movedFiles);
52
+ const movedSet = new Set(movedFiles);
53
+ const remainingFiles = source.filesAffected.filter((f) => !movedSet.has(f));
54
+ if (remainingFiles.length === 0) {
55
+ throw new InvalidArgumentError(`Moving every file out of ${source.filename} would leave it empty. ` +
56
+ 'Use "fireforge patch rename" / "fireforge patch reorder" to repurpose or move the whole patch instead.', '--file');
57
+ }
58
+ const movedDiff = await buildSplitDiff(paths.engine, movedFiles, 'moved', source.filename);
59
+ const remainingDiff = await buildSplitDiff(paths.engine, remainingFiles, 'remaining', source.filename);
60
+ const category = options.category ?? source.category;
61
+ const placementOptions = { order: options.order };
62
+ const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, newPatchName, config);
63
+ const plan = {
64
+ source,
65
+ movedFiles,
66
+ remainingFiles,
67
+ movedDiff,
68
+ remainingDiff,
69
+ placement,
70
+ placementOptions,
71
+ category,
72
+ name: newPatchName,
73
+ description: options.description ?? '',
74
+ ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
75
+ // Populated by runProjectedSplitLint below (forward edges into the new patch).
76
+ stagedDependencyAdditions: new Map(),
77
+ };
78
+ // Per-patch lint both projected bodies, threading the source patch's
79
+ // tier/lintIgnore so an intentional-advisory patch can still move files.
80
+ const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
81
+ await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
82
+ await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
83
+ const { conflicts, stagedDependencyAdditions } = await runProjectedSplitLint(paths.patches, plan);
84
+ plan.stagedDependencyAdditions = stagedDependencyAdditions;
85
+ const newMetadata = buildNewPatchMetadata(plan, config);
86
+ enforcePatchPolicy({
87
+ config,
88
+ manifest: projectSplitManifest(manifest, plan, newMetadata),
89
+ command: 'patch move-files --create',
90
+ forceUnsafe: options.forceUnsafe === true,
91
+ });
92
+ const decision = await confirmDestructive({
93
+ operation: 'patch-move-files-create',
94
+ title: `Create ${placement.newFilename} (order ${placement.insertionOrder}) and move ${movedFiles.length} file(s) out of ${source.filename}`,
95
+ summary: buildSplitSummary(plan),
96
+ yes: options.yes === true,
97
+ dryRun: options.dryRun === true,
98
+ unsafeOverride: options.forceUnsafe === true,
99
+ conflicts,
100
+ });
101
+ if (decision === 'dry-run') {
102
+ outro('Dry run complete — no changes made');
103
+ return;
104
+ }
105
+ if (decision === 'cancelled') {
106
+ outro('Move cancelled');
107
+ return;
108
+ }
109
+ await commitPatchSplit(paths.patches, plan, newMetadata, options, config);
110
+ success(`Created ${placement.newFilename} (order ${String(placement.insertionOrder).padStart(3, '0')}) ` +
111
+ `and moved ${plan.movedFiles.length} file(s) out of ${source.filename}`);
112
+ if (plan.ownerRewrites.length > 0) {
113
+ info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
114
+ }
115
+ outro('Move complete');
116
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Transactional file-ownership move into an EXISTING patch (FORGE F4).
3
+ *
4
+ * Before 0.39.0, `patch move-files <from> <to>` with an existing target was
5
+ * preview-only: it printed two `re-export --files` commands whose real
6
+ * execution cost three further refusals (`--allow-shrink`, `--yes`, and a
7
+ * projected `duplicate-new-file-creation` unless the SHRINK landed first —
8
+ * while the printed plan listed the grow first). This module performs the
9
+ * same shrink→grow as one transaction under the patch-directory lock with
10
+ * rollback, mirroring `patch split` / `move-files --create`.
11
+ *
12
+ * Preconditions match `re-export`: the engine worktree must currently
13
+ * reflect both patches' content, because both bodies are regenerated from
14
+ * the worktree.
15
+ */
16
+ import type { PatchMetadata, PatchMoveFilesOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
17
+ import type { FireForgeConfig } from '../../types/config.js';
18
+ /** Everything the commit step needs, computed and confirmed up front. */
19
+ export interface MoveIntoPlan {
20
+ source: PatchMetadata;
21
+ target: PatchMetadata;
22
+ movedFiles: string[];
23
+ sourceAfter: string[];
24
+ targetAfter: string[];
25
+ sourceDiff: string;
26
+ targetDiff: string;
27
+ /** Patches (by filename) whose staged-dependency owners re-point to the target. */
28
+ ownerRewrites: string[];
29
+ /** Forward-import declarations the move introduces, keyed by importing patch. */
30
+ stagedDependencyAdditions: Map<string, PatchStagedForwardImport[]>;
31
+ }
32
+ /**
33
+ * Plans, lints, confirms, and commits a file-ownership move into an
34
+ * existing patch. Called by `patchMoveFilesCommand` after its ownership
35
+ * pre-checks resolved and validated both patches.
36
+ */
37
+ export declare function runMoveFilesInto(args: {
38
+ enginePath: string;
39
+ patchesDir: string;
40
+ source: PatchMetadata;
41
+ target: PatchMetadata;
42
+ files: string[];
43
+ sourceAfter: string[];
44
+ targetAfter: string[];
45
+ options: PatchMoveFilesOptions;
46
+ config: FireForgeConfig;
47
+ }): Promise<void>;