@hominis/fireforge 0.38.0 → 0.40.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 (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/build.js +8 -3
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/export-flow.d.ts +1 -1
  7. package/dist/src/commands/export-flow.js +21 -10
  8. package/dist/src/commands/export-placement-gate.js +1 -1
  9. package/dist/src/commands/export-shared.js +31 -5
  10. package/dist/src/commands/import.js +5 -0
  11. package/dist/src/commands/lint-per-patch.js +97 -18
  12. package/dist/src/commands/lint-report.d.ts +21 -0
  13. package/dist/src/commands/lint-report.js +62 -0
  14. package/dist/src/commands/lint.js +23 -19
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/patch/lint-ignore.js +20 -1
  17. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  18. package/dist/src/commands/patch/move-files-into.js +245 -0
  19. package/dist/src/commands/patch/move-files.js +27 -13
  20. package/dist/src/commands/patch/rename.js +5 -2
  21. package/dist/src/commands/patch/staged-dependency.js +102 -14
  22. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  23. package/dist/src/commands/re-export-adjacent.js +106 -0
  24. package/dist/src/commands/re-export-options.js +3 -0
  25. package/dist/src/commands/re-export.js +42 -53
  26. package/dist/src/commands/reset.js +5 -0
  27. package/dist/src/commands/run.js +18 -2
  28. package/dist/src/commands/status-check.d.ts +35 -0
  29. package/dist/src/commands/status-check.js +88 -0
  30. package/dist/src/commands/status-output.d.ts +9 -0
  31. package/dist/src/commands/status-output.js +33 -1
  32. package/dist/src/commands/status.js +33 -8
  33. package/dist/src/commands/test-diagnose.d.ts +1 -1
  34. package/dist/src/commands/test-diagnose.js +9 -3
  35. package/dist/src/commands/test-modes.d.ts +21 -0
  36. package/dist/src/commands/test-modes.js +42 -0
  37. package/dist/src/commands/test-run.d.ts +2 -0
  38. package/dist/src/commands/test-run.js +26 -5
  39. package/dist/src/commands/test.js +38 -32
  40. package/dist/src/commands/token.js +4 -1
  41. package/dist/src/commands/tree.d.ts +16 -0
  42. package/dist/src/commands/tree.js +174 -0
  43. package/dist/src/core/build-baseline-types.d.ts +7 -0
  44. package/dist/src/core/build-baseline.d.ts +1 -1
  45. package/dist/src/core/build-baseline.js +2 -1
  46. package/dist/src/core/build-prepare.d.ts +9 -0
  47. package/dist/src/core/build-prepare.js +24 -0
  48. package/dist/src/core/config-validate.js +25 -0
  49. package/dist/src/core/discard-baseline.d.ts +60 -0
  50. package/dist/src/core/discard-baseline.js +232 -0
  51. package/dist/src/core/license-headers.d.ts +12 -0
  52. package/dist/src/core/license-headers.js +27 -0
  53. package/dist/src/core/lint-cache.d.ts +15 -5
  54. package/dist/src/core/lint-cache.js +17 -5
  55. package/dist/src/core/patch-export.d.ts +12 -3
  56. package/dist/src/core/patch-export.js +13 -3
  57. package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
  58. package/dist/src/core/patch-lint-checkjs.js +85 -16
  59. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  60. package/dist/src/core/patch-lint-cross.js +69 -9
  61. package/dist/src/core/patch-lint-css.js +64 -16
  62. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  63. package/dist/src/core/patch-lint-diff.js +31 -0
  64. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  65. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  66. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  67. package/dist/src/core/patch-lint-ownership.js +30 -0
  68. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  69. package/dist/src/core/patch-lint.d.ts +23 -0
  70. package/dist/src/core/patch-lint.js +34 -3
  71. package/dist/src/core/patch-manifest.d.ts +1 -1
  72. package/dist/src/core/patch-policy.d.ts +14 -1
  73. package/dist/src/core/patch-policy.js +13 -2
  74. package/dist/src/core/smoke-patterns.d.ts +24 -8
  75. package/dist/src/core/smoke-patterns.js +23 -12
  76. package/dist/src/core/status-classify.d.ts +16 -0
  77. package/dist/src/core/status-classify.js +20 -6
  78. package/dist/src/core/test-harness-crash.d.ts +10 -0
  79. package/dist/src/core/test-harness-crash.js +16 -0
  80. package/dist/src/core/test-stale-check.d.ts +15 -0
  81. package/dist/src/core/test-stale-check.js +37 -1
  82. package/dist/src/core/token-category.d.ts +40 -0
  83. package/dist/src/core/token-category.js +264 -0
  84. package/dist/src/core/token-manager.d.ts +7 -0
  85. package/dist/src/core/token-manager.js +82 -193
  86. package/dist/src/core/token-variant.d.ts +7 -0
  87. package/dist/src/core/token-variant.js +9 -0
  88. package/dist/src/core/tree-cow.d.ts +18 -0
  89. package/dist/src/core/tree-cow.js +71 -0
  90. package/dist/src/core/tree-guard.d.ts +35 -0
  91. package/dist/src/core/tree-guard.js +124 -0
  92. package/dist/src/core/tree-store.d.ts +53 -0
  93. package/dist/src/core/tree-store.js +214 -0
  94. package/dist/src/core/typecheck-shim.d.ts +27 -0
  95. package/dist/src/core/typecheck-shim.js +58 -4
  96. package/dist/src/core/typecheck.js +14 -3
  97. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  98. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  99. package/dist/src/errors/git.d.ts +8 -0
  100. package/dist/src/errors/git.js +17 -0
  101. package/dist/src/index.d.ts +2 -0
  102. package/dist/src/index.js +1 -0
  103. package/dist/src/types/commands/options.d.ts +45 -2
  104. package/dist/src/types/config.d.ts +34 -0
  105. package/dist/src/utils/validation.d.ts +10 -0
  106. package/dist/src/utils/validation.js +29 -0
  107. package/package.json +6 -6
@@ -0,0 +1,245 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Transactional file-ownership move into an EXISTING patch (FORGE F4).
4
+ *
5
+ * Before 0.39.0, `patch move-files <from> <to>` with an existing target was
6
+ * preview-only: it printed two `re-export --files` commands whose real
7
+ * execution cost three further refusals (`--allow-shrink`, `--yes`, and a
8
+ * projected `duplicate-new-file-creation` unless the SHRINK landed first —
9
+ * while the printed plan listed the grow first). This module performs the
10
+ * same shrink→grow as one transaction under the patch-directory lock with
11
+ * rollback, mirroring `patch split` / `move-files --create`.
12
+ *
13
+ * Preconditions match `re-export`: the engine worktree must currently
14
+ * reflect both patches' content, because both bodies are regenerated from
15
+ * the worktree.
16
+ */
17
+ import { join } from 'node:path';
18
+ import { appendHistory, confirmDestructive } from '../../core/destructive.js';
19
+ import { computeProjectedLintRegressions } from '../../core/lint-projection.js';
20
+ import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
21
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, detectNewFilesInDiff, lintPatchQueue, } from '../../core/patch-lint.js';
22
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
23
+ import { loadPatchesManifest, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
24
+ import { extractNewFileContentFromDiff } from '../../core/patch-transform.js';
25
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
26
+ import { toError } from '../../utils/errors.js';
27
+ import { readText, writeText } from '../../utils/fs.js';
28
+ import { info, outro, success, warn } from '../../utils/logger.js';
29
+ import { runPatchLint } from '../export-shared.js';
30
+ import { buildSplitDiff, findOwnerRewriteHolders, mergeStagedForwardImports, rewriteSplitOwners, } from './split-plan.js';
31
+ function projectEntryBody(diff) {
32
+ const newFiles = new Map();
33
+ for (const path of detectNewFilesInDiff(diff)) {
34
+ newFiles.set(path, extractNewFileContentFromDiff(diff, path));
35
+ }
36
+ return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
37
+ }
38
+ /**
39
+ * Projects the post-move queue through cross-patch lint, auto-declaring the
40
+ * forward edges the move introduces into the target (an importer that used
41
+ * to live in the same patch as the moved file gains a forward edge when the
42
+ * target is later-ordered). Reports only regressions the move itself adds.
43
+ */
44
+ async function runProjectedMoveLint(patchesDir, plan) {
45
+ const movedSet = new Set(plan.movedFiles);
46
+ const baseCtx = await buildPatchQueueContext(patchesDir);
47
+ const entries = baseCtx.entries.map((entry) => {
48
+ let metadata = entry.metadata;
49
+ if (metadata) {
50
+ metadata = rewriteSplitOwners(metadata, plan.source.filename, movedSet, plan.target.filename);
51
+ }
52
+ if (entry.filename === plan.source.filename) {
53
+ return { ...entry, metadata, ...projectEntryBody(plan.sourceDiff) };
54
+ }
55
+ if (entry.filename === plan.target.filename) {
56
+ return { ...entry, metadata, ...projectEntryBody(plan.targetDiff) };
57
+ }
58
+ return { ...entry, metadata };
59
+ });
60
+ const stagedDependencyAdditions = new Map();
61
+ for (const edge of collectForwardImportEdges({ entries })) {
62
+ if (edge.owner !== plan.target.filename || !movedSet.has(edge.creates))
63
+ continue;
64
+ const decl = {
65
+ file: edge.sitePath,
66
+ specifier: edge.specifier,
67
+ creates: edge.creates,
68
+ owner: plan.target.filename,
69
+ };
70
+ const list = stagedDependencyAdditions.get(edge.entry) ?? [];
71
+ if (!list.some((d) => d.file === decl.file && d.specifier === decl.specifier && d.creates === decl.creates)) {
72
+ list.push(decl);
73
+ }
74
+ stagedDependencyAdditions.set(edge.entry, list);
75
+ }
76
+ for (const entry of entries) {
77
+ const decls = stagedDependencyAdditions.get(entry.filename);
78
+ if (decls?.length && entry.metadata) {
79
+ entry.metadata = mergeStagedForwardImports(entry.metadata, decls);
80
+ }
81
+ }
82
+ const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
83
+ const projectedIssues = lintPatchQueue({
84
+ entries,
85
+ ...(baseCtx.patchPolicy ? { patchPolicy: baseCtx.patchPolicy } : {}),
86
+ }).filter((i) => i.severity === 'error');
87
+ const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
88
+ if (baselineIssues.length > 0 && regressions.length === 0) {
89
+ warn(`Note: projected queue still has ${baselineIssues.length} pre-existing cross-patch ` +
90
+ 'error(s) unrelated to this move. Run "fireforge verify" to list them.');
91
+ }
92
+ const conflicts = regressions.length === 0
93
+ ? null
94
+ : {
95
+ reason: `move would introduce ${regressions.length} cross-patch lint error(s)`,
96
+ details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
97
+ };
98
+ return { conflicts, stagedDependencyAdditions };
99
+ }
100
+ /**
101
+ * Commits a confirmed move under the patch-directory lock: write both
102
+ * rewritten bodies, then one manifest rewrite (filesAffected on both rows +
103
+ * owner rewrites + auto-declared forward imports). Rolled back in reverse
104
+ * order on any failure.
105
+ */
106
+ async function commitMoveInto(patchesDir, plan) {
107
+ await withPatchDirectoryLock(patchesDir, async () => {
108
+ const manifest = await loadPatchesManifest(patchesDir);
109
+ if (!manifest)
110
+ throw new GeneralError('Manifest disappeared while waiting for lock.');
111
+ const currentSource = manifest.patches.find((p) => p.filename === plan.source.filename);
112
+ const currentTarget = manifest.patches.find((p) => p.filename === plan.target.filename);
113
+ if (!currentSource ||
114
+ !currentTarget ||
115
+ currentSource.filesAffected.join('\n') !== plan.source.filesAffected.join('\n') ||
116
+ currentTarget.filesAffected.join('\n') !== plan.target.filesAffected.join('\n')) {
117
+ throw new InvalidArgumentError('Patch queue changed while waiting for move confirmation. Re-run the command.', 'patch move-files');
118
+ }
119
+ const movedSet = new Set(plan.movedFiles);
120
+ const sourcePath = join(patchesDir, plan.source.filename);
121
+ const targetPath = join(patchesDir, plan.target.filename);
122
+ const originalSourceBody = await readText(sourcePath);
123
+ const originalTargetBody = await readText(targetPath);
124
+ let sourceWritten = false;
125
+ let targetWritten = false;
126
+ try {
127
+ await writeText(sourcePath, normalizePatchArtifact(plan.sourceDiff));
128
+ sourceWritten = true;
129
+ await writeText(targetPath, normalizePatchArtifact(plan.targetDiff));
130
+ targetWritten = true;
131
+ const updatedPatches = manifest.patches.map((patch) => {
132
+ let updated = rewriteSplitOwners(patch, plan.source.filename, movedSet, plan.target.filename);
133
+ const decls = plan.stagedDependencyAdditions.get(updated.filename);
134
+ if (decls?.length)
135
+ updated = mergeStagedForwardImports(updated, decls);
136
+ if (updated.filename === plan.source.filename) {
137
+ return { ...updated, filesAffected: plan.sourceAfter };
138
+ }
139
+ if (updated.filename === plan.target.filename) {
140
+ return { ...updated, filesAffected: plan.targetAfter };
141
+ }
142
+ return updated;
143
+ });
144
+ const updated = validatePatchesManifest({ ...manifest, patches: updatedPatches });
145
+ await savePatchesManifest(patchesDir, updated);
146
+ try {
147
+ await appendHistory(patchesDir, {
148
+ operation: 'patch-move-files',
149
+ args: {
150
+ source: plan.source.filename,
151
+ target: plan.target.filename,
152
+ files: plan.movedFiles,
153
+ ownerRewrites: plan.ownerRewrites,
154
+ },
155
+ result: 'ok',
156
+ });
157
+ }
158
+ catch (historyError) {
159
+ warn(`History log append failed after patch move-files committed: ${toError(historyError).message}`);
160
+ }
161
+ }
162
+ catch (error) {
163
+ if (targetWritten) {
164
+ try {
165
+ await writeText(targetPath, originalTargetBody);
166
+ }
167
+ catch (rollbackError) {
168
+ warn(`Rollback warning: could not restore target body: ${toError(rollbackError).message}`);
169
+ }
170
+ }
171
+ if (sourceWritten) {
172
+ try {
173
+ await writeText(sourcePath, originalSourceBody);
174
+ }
175
+ catch (rollbackError) {
176
+ warn(`Rollback warning: could not restore source body: ${toError(rollbackError).message}`);
177
+ }
178
+ }
179
+ try {
180
+ await savePatchesManifest(patchesDir, manifest);
181
+ }
182
+ catch (rollbackError) {
183
+ warn(`Rollback warning: could not restore manifest: ${toError(rollbackError).message}`);
184
+ }
185
+ throw error;
186
+ }
187
+ });
188
+ }
189
+ /**
190
+ * Plans, lints, confirms, and commits a file-ownership move into an
191
+ * existing patch. Called by `patchMoveFilesCommand` after its ownership
192
+ * pre-checks resolved and validated both patches.
193
+ */
194
+ export async function runMoveFilesInto(args) {
195
+ const { enginePath, patchesDir, source, target, files, sourceAfter, targetAfter, options } = args;
196
+ const sourceDiff = await buildSplitDiff(enginePath, sourceAfter, 'remaining', source.filename);
197
+ const targetDiff = await buildSplitDiff(enginePath, targetAfter, 'moved', target.filename);
198
+ // Per-patch lint both projected bodies with each patch's own waivers.
199
+ await runPatchLint(enginePath, sourceAfter, sourceDiff, args.config, options.skipLint, undefined, source.lintIgnore ? new Set(source.lintIgnore) : undefined, source.tier);
200
+ await runPatchLint(enginePath, targetAfter, targetDiff, args.config, options.skipLint, undefined, target.lintIgnore ? new Set(target.lintIgnore) : undefined, target.tier);
201
+ const manifest = await loadPatchesManifest(patchesDir);
202
+ const plan = {
203
+ source,
204
+ target,
205
+ movedFiles: files,
206
+ sourceAfter,
207
+ targetAfter,
208
+ sourceDiff,
209
+ targetDiff,
210
+ ownerRewrites: findOwnerRewriteHolders(manifest?.patches ?? [], source.filename, new Set(files)),
211
+ stagedDependencyAdditions: new Map(),
212
+ };
213
+ const { conflicts, stagedDependencyAdditions } = await runProjectedMoveLint(patchesDir, plan);
214
+ plan.stagedDependencyAdditions = stagedDependencyAdditions;
215
+ const decision = await confirmDestructive({
216
+ operation: 'patch-move-files',
217
+ title: `Move ${files.length} file(s) from ${source.filename} to ${target.filename}`,
218
+ summary: [
219
+ `moved files (${files.length}): ${files.join(', ')}`,
220
+ `${source.filename} keeps (${sourceAfter.length}): ${sourceAfter.join(', ')}`,
221
+ `${target.filename} grows to (${targetAfter.length}): ${targetAfter.join(', ')}`,
222
+ ...(plan.ownerRewrites.length > 0
223
+ ? [`staged-dependency owners re-pointed in: ${plan.ownerRewrites.join(', ')}`]
224
+ : []),
225
+ ],
226
+ yes: options.yes === true,
227
+ dryRun: options.dryRun === true,
228
+ unsafeOverride: options.forceUnsafe === true,
229
+ conflicts,
230
+ });
231
+ if (decision === 'dry-run') {
232
+ outro('Dry run complete — no changes made');
233
+ return;
234
+ }
235
+ if (decision === 'cancelled') {
236
+ outro('Move cancelled');
237
+ return;
238
+ }
239
+ await commitMoveInto(patchesDir, plan);
240
+ success(`Moved ${files.length} file(s) from ${source.filename} to ${target.filename}`);
241
+ if (plan.ownerRewrites.length > 0) {
242
+ info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
243
+ }
244
+ outro('Move complete');
245
+ }
@@ -7,14 +7,15 @@
7
7
  */
8
8
  import { relative } from 'node:path';
9
9
  import { Command } from 'commander';
10
- import { getProjectPaths } from '../../core/config.js';
10
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
11
11
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
12
12
  import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
13
13
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
14
14
  import { pathExists } from '../../utils/fs.js';
15
- import { info, intro, note, outro, warn } from '../../utils/logger.js';
15
+ import { info, intro, note, warn } from '../../utils/logger.js';
16
16
  import { commanderArgParser, pickDefined } from '../../utils/options.js';
17
17
  import { patchMoveFilesCreateCommand } from './move-files-create.js';
18
+ import { runMoveFilesInto } from './move-files-into.js';
18
19
  function collectOption(value, previous) {
19
20
  previous.push(value);
20
21
  return previous;
@@ -123,21 +124,34 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
123
124
  }
124
125
  }
125
126
  const relativePatchesDir = relative(projectRoot, paths.patches) || 'patches';
126
- info(`Planning ownership move in ${relativePatchesDir}/patches.json.`);
127
+ info(`Moving ownership in ${relativePatchesDir}/patches.json.`);
127
128
  info(`Move ${files.length} file(s) from ${source.filename} to ${target.filename}:`);
128
129
  note(formatFiles(files), 'Files');
129
130
  note(formatFiles(sourceAfter), `${source.filename} files after move`);
130
131
  note(formatFiles(targetAfter), `${target.filename} files after move`);
131
- const dryRunSource = formatReExportCommand(source.filename, sourceAfter, ['--dry-run']);
132
- const dryRunTarget = formatReExportCommand(target.filename, targetAfter, ['--dry-run']);
133
- const applySource = formatReExportCommand(source.filename, sourceAfter, ['--yes']);
134
- const applyTarget = formatReExportCommand(target.filename, targetAfter, []);
135
- note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
136
- note(`${applySource}\n${applyTarget}`, 'Apply commands');
137
- info('Tip: to move files into a brand-new patch in one transaction (including ' +
138
- 'staged-dependency owner rewrites), re-run with --create --order <n> to create ' +
139
- 'the target patch and move the files in one step, or use "fireforge patch split".');
140
- outro('Move plan complete - no changes made');
132
+ // Manual fallback (e.g. when the worktree cannot carry both patches'
133
+ // content right now): the equivalent re-export choreography, in
134
+ // EXECUTABLE order — the shrink must land before the grow, or the
135
+ // projected cross-patch lint refuses the pair with
136
+ // duplicate-new-file-creation (FORGE F4).
137
+ const applySource = formatReExportCommand(source.filename, sourceAfter, [
138
+ '--allow-shrink',
139
+ '--yes',
140
+ ]);
141
+ const applyTarget = formatReExportCommand(target.filename, targetAfter, ['--yes']);
142
+ note(`1. ${applySource}\n2. ${applyTarget}\n(the shrink must land first)`, 'Equivalent manual commands');
143
+ const config = await loadConfig(projectRoot);
144
+ await runMoveFilesInto({
145
+ enginePath: paths.engine,
146
+ patchesDir: paths.patches,
147
+ source,
148
+ target,
149
+ files,
150
+ sourceAfter,
151
+ targetAfter,
152
+ options,
153
+ config,
154
+ });
141
155
  }
142
156
  /**
143
157
  * Registers the preview-only `patch move-files` subcommand.
@@ -173,7 +173,10 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
173
173
  const nameChanging = options.to !== target.name;
174
174
  const descriptionChanging = options.description !== undefined && options.description !== target.description;
175
175
  if (!filenameChanging && !nameChanging && !descriptionChanging) {
176
- info(`${target.filename}: name and description already match — nothing to change.`);
176
+ info(`${target.filename}: nothing to change — filename already "${target.filename}" ` +
177
+ `(slug of "${options.to}" is "${newSlug}"), name already "${target.name}"` +
178
+ (options.description !== undefined ? ', description already matches' : '') +
179
+ '.');
177
180
  outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch rename (no-op)');
178
181
  return;
179
182
  }
@@ -260,7 +263,7 @@ export function registerPatchRename(parent, context) {
260
263
  .command('rename <name>')
261
264
  .description('Rename a patch: filename + manifest name (and optional description) update without rewriting the .patch body.')
262
265
  .requiredOption('--to <new-name>', 'New human-readable name, category-prefixed slug, or full filename stem (normalised into the filename slug)')
263
- .option('--description <text>', 'Replacement description (omit to leave description unchanged)')
266
+ .option('-d, --description <text>', 'Replacement description (omit to leave description unchanged)')
264
267
  .option('--dry-run', 'Show what would change without writing')
265
268
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
266
269
  .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
@@ -10,35 +10,46 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
10
10
  import { toError } from '../../utils/errors.js';
11
11
  import { info, intro, outro, warn } from '../../utils/logger.js';
12
12
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
13
+ /** Joins the names of the missing flags for error attribution (FORGE G12). */
14
+ function missingFlagList(pairs) {
15
+ return pairs
16
+ .filter(([, value]) => value === undefined || value === '')
17
+ .map(([flag]) => flag)
18
+ .join(', ');
19
+ }
13
20
  function modeFromOptions(options) {
14
21
  const adding = options.add === true;
15
22
  const removing = options.remove === true;
16
23
  const clearing = options.clear === true;
17
24
  const modeCount = [adding, removing, clearing].filter(Boolean).length;
18
25
  if (modeCount > 1) {
19
- throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch staged-dependency');
26
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', '--add/--remove/--clear');
20
27
  }
21
28
  if (modeCount === 0) {
22
- throw new InvalidArgumentError('Specify --add, --remove, or --clear.', 'patch staged-dependency');
29
+ throw new InvalidArgumentError('Specify --add, --remove, or --clear.', '--add/--remove/--clear');
23
30
  }
24
31
  return adding ? 'add' : removing ? 'remove' : 'clear';
25
32
  }
26
33
  function kindFromOptions(options) {
27
34
  const kind = options.kind ?? 'import';
28
35
  if (kind !== 'import' && kind !== 'registration') {
29
- throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, 'patch staged-dependency');
36
+ throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, '--kind');
30
37
  }
31
38
  if (kind === 'import' && options.line !== undefined) {
32
- throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', 'patch staged-dependency');
39
+ throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', '--line');
33
40
  }
34
41
  if (kind === 'registration' && options.specifier !== undefined) {
35
- throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', 'patch staged-dependency');
42
+ throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', '--specifier');
36
43
  }
37
44
  return kind;
38
45
  }
39
- function requireForwardImportOptions(options, mode) {
46
+ function requireForwardImportOptions(options) {
40
47
  if (!options.file || !options.specifier || !options.creates) {
41
- throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
48
+ throw new InvalidArgumentError('--add requires --file, --specifier, and --creates.', missingFlagList([
49
+ ['--file', options.file],
50
+ ['--specifier', options.specifier],
51
+ ['--creates', options.creates],
52
+ ]));
42
53
  }
43
54
  const dependency = {
44
55
  file: options.file,
@@ -51,9 +62,13 @@ function requireForwardImportOptions(options, mode) {
51
62
  dependency.reason = options.reason;
52
63
  return dependency;
53
64
  }
54
- function requireRegistrationOptions(options, mode) {
65
+ function requireRegistrationOptions(options) {
55
66
  if (!options.file || !options.line || !options.creates) {
56
- throw new InvalidArgumentError(`--${mode} --kind registration requires --file, --line, and --creates.`, 'patch staged-dependency');
67
+ throw new InvalidArgumentError('--add --kind registration requires --file, --line, and --creates.', missingFlagList([
68
+ ['--file', options.file],
69
+ ['--line', options.line],
70
+ ['--creates', options.creates],
71
+ ]));
57
72
  }
58
73
  const dependency = {
59
74
  file: options.file,
@@ -66,6 +81,70 @@ function requireRegistrationOptions(options, mode) {
66
81
  dependency.reason = options.reason;
67
82
  return dependency;
68
83
  }
84
+ /**
85
+ * Resolves the `--remove` target for forward-imports (FORGE G12):
86
+ * `--file` + `--specifier` suffice when they identify at most one staged
87
+ * entry — `--creates` is inferred from a unique match, an ambiguous match
88
+ * refuses with the candidate list, and no match falls through to the
89
+ * honest "no staged forward-import matched" summary.
90
+ */
91
+ function resolveImportRemovalTarget(options, staged) {
92
+ if (!options.file || !options.specifier) {
93
+ throw new InvalidArgumentError('--remove requires --file and --specifier; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
94
+ ['--file', options.file],
95
+ ['--specifier', options.specifier],
96
+ ]));
97
+ }
98
+ if (options.creates) {
99
+ return requireForwardImportOptions(options);
100
+ }
101
+ const { file, specifier } = options;
102
+ const candidates = (staged?.forwardImports ?? []).filter((entry) => entry.file === file &&
103
+ entry.specifier === specifier &&
104
+ (options.owner === undefined || entry.owner === options.owner));
105
+ const single = candidates.length === 1 ? candidates[0] : undefined;
106
+ if (single !== undefined) {
107
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
108
+ return { ...single };
109
+ }
110
+ if (candidates.length > 1) {
111
+ const list = candidates
112
+ .map((entry) => ` - ${dependencyLabel('import', importView(entry))}`)
113
+ .join('\n');
114
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged forward-imports on this patch; ` +
115
+ `pass --creates to pick one:\n${list}`);
116
+ }
117
+ return { file, specifier, creates: '(no match)' };
118
+ }
119
+ /** Registration twin of {@link resolveImportRemovalTarget}, keyed on `--line`. */
120
+ function resolveRegistrationRemovalTarget(options, staged) {
121
+ if (!options.file || !options.line) {
122
+ throw new InvalidArgumentError('--remove --kind registration requires --file and --line; --creates is only needed to disambiguate when several staged entries share them.', missingFlagList([
123
+ ['--file', options.file],
124
+ ['--line', options.line],
125
+ ]));
126
+ }
127
+ if (options.creates) {
128
+ return requireRegistrationOptions(options);
129
+ }
130
+ const { file, line } = options;
131
+ const candidates = (staged?.registrations ?? []).filter((entry) => entry.file === file &&
132
+ entry.line === line &&
133
+ (options.owner === undefined || entry.owner === options.owner));
134
+ const single = candidates.length === 1 ? candidates[0] : undefined;
135
+ if (single !== undefined) {
136
+ info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
137
+ return { ...single };
138
+ }
139
+ if (candidates.length > 1) {
140
+ const list = candidates
141
+ .map((entry) => ` - ${dependencyLabel('registration', registrationView(entry))}`)
142
+ .join('\n');
143
+ throw new GeneralError(`--remove matches ${String(candidates.length)} staged registrations on this patch; ` +
144
+ `pass --creates to pick one:\n${list}`);
145
+ }
146
+ return { file, line, creates: '(no match)' };
147
+ }
69
148
  function importView(dependency) {
70
149
  return { ...dependency, matcher: dependency.specifier };
71
150
  }
@@ -137,17 +216,26 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
137
216
  intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
138
217
  const mode = modeFromOptions(options);
139
218
  const kind = kindFromOptions(options);
140
- const importDependency = mode === 'clear' || kind !== 'import' ? undefined : requireForwardImportOptions(options, mode);
219
+ // The patch queue loads BEFORE the declaration fields resolve so
220
+ // `--remove` can infer `--creates` from the target patch's staged
221
+ // entries (FORGE G12).
222
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
223
+ const targetPatch = requirePatchTarget(identifier, manifest.patches);
224
+ const importDependency = mode === 'clear' || kind !== 'import'
225
+ ? undefined
226
+ : mode === 'add'
227
+ ? requireForwardImportOptions(options)
228
+ : resolveImportRemovalTarget(options, targetPatch.stagedDependencies);
141
229
  const registrationDependency = mode === 'clear' || kind !== 'registration'
142
230
  ? undefined
143
- : requireRegistrationOptions(options, mode);
231
+ : mode === 'add'
232
+ ? requireRegistrationOptions(options)
233
+ : resolveRegistrationRemovalTarget(options, targetPatch.stagedDependencies);
144
234
  const target = importDependency !== undefined
145
235
  ? importView(importDependency)
146
236
  : registrationDependency !== undefined
147
237
  ? registrationView(registrationDependency)
148
238
  : undefined;
149
- const { paths, manifest } = await requirePatchQueue(projectRoot);
150
- const targetPatch = requirePatchTarget(identifier, manifest.patches);
151
239
  const applyToStaged = (staged) => {
152
240
  if (mode === 'clear')
153
241
  return {};
@@ -219,7 +307,7 @@ export function registerPatchStagedDependency(parent, context) {
219
307
  .option('--owner <patch>', 'Exact later patch filename expected to create --creates')
220
308
  .option('--reason <text>', 'Human-readable rationale stored with the declaration')
221
309
  .option('--dry-run', 'Show what would change without writing')
222
- .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
310
+ .option('-y, --yes', 'Record scripted consent in the destructive-operation history log. This command never prompts; the flag exists for workflow uniformity with commands that do.')
223
311
  .action(withErrorHandling(async (name, options) => {
224
312
  await patchStagedDependencyCommand(getProjectRoot(), name, options);
225
313
  }));
@@ -0,0 +1,34 @@
1
+ import type { getProjectPaths } from '../core/config.js';
2
+ import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
3
+ /**
4
+ * Shared per-run context for the adjacency advisory: the classification
5
+ * inputs computed once by `reExportCommand`, plus the collection of
6
+ * patches refused under `--refuse-adjacent-unmanaged` (collected across
7
+ * the loop so a multi-patch run reports every offender in one refusal).
8
+ */
9
+ export interface AdjacentUnmanagedContext {
10
+ binaryName: string;
11
+ furnacePrefixes: ReadonlySet<string>;
12
+ refuseAdjacentUnmanaged: boolean;
13
+ refusals: {
14
+ patchFilename: string;
15
+ files: string[];
16
+ }[];
17
+ }
18
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
19
+ export declare function findMissingFiles(engineDir: string, files: readonly string[]): Promise<string[]>;
20
+ /**
21
+ * Emits the scan-less advisory for one patch: missing manifest files and
22
+ * unmanaged files adjacent to the patch's ownership. When
23
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
24
+ * exists, records the patch into `ctx.refusals` and returns true — the
25
+ * caller then skips the write for this patch and converts the collection
26
+ * into one run-level non-zero refusal after the loop.
27
+ */
28
+ export declare function reportAdjacentUnmanagedFiles(args: {
29
+ patch: PatchMetadata;
30
+ paths: ReturnType<typeof getProjectPaths>;
31
+ manifest: PatchesManifest;
32
+ currentFilesAffected: readonly string[];
33
+ ctx: AdjacentUnmanagedContext;
34
+ }): Promise<boolean>;
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Scan-less re-export adjacency advisory (FORGE G2). A plain `re-export`
4
+ * keeps the manifest's filesAffected unchanged, so a brand-new file an
5
+ * author just created beside a patch's owned files is silently left out
6
+ * of the refreshed body. This module detects such adjacent files, filters
7
+ * out tool-managed paths (branding, furnace) so the notice names only
8
+ * genuinely unmanaged candidates, and — under
9
+ * `--refuse-adjacent-unmanaged` — lets the caller turn the notice into a
10
+ * non-zero refusal.
11
+ *
12
+ * Split out of re-export.ts to keep that file inside the max-lines
13
+ * budget; exports helpers consumed by re-export.ts, no registrar.
14
+ */
15
+ import { dirname, join } from 'node:path';
16
+ import { isBrandingManagedPath } from '../core/branding.js';
17
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
18
+ import { getClaimedFiles } from '../core/patch-manifest.js';
19
+ import { isGeneratedBrandingPath } from '../core/status-classify.js';
20
+ import { pathExists } from '../utils/fs.js';
21
+ import { info, warn } from '../utils/logger.js';
22
+ /** Returns the subset of `files` that no longer exist under `engineDir`. */
23
+ export async function findMissingFiles(engineDir, files) {
24
+ const missingFiles = [];
25
+ for (const file of files) {
26
+ if (!(await pathExists(join(engineDir, file))))
27
+ missingFiles.push(file);
28
+ }
29
+ return missingFiles;
30
+ }
31
+ function isToolManagedPath(file, isNewFile, ctx) {
32
+ // Mirrors status-classify: a generated branding path is tool-managed
33
+ // outright; a brand-new unowned file under the branding root is still a
34
+ // patch candidate (the Assets.car precedent) and must stay listed.
35
+ if (isBrandingManagedPath(file, ctx.binaryName) &&
36
+ (!isNewFile || isGeneratedBrandingPath(file, ctx.binaryName))) {
37
+ return true;
38
+ }
39
+ for (const prefix of ctx.furnacePrefixes) {
40
+ if (file.startsWith(prefix))
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ async function findAdjacentUnmanagedFiles(args) {
46
+ const { currentFilesAffected, engineDir, manifest, patchFilename, ctx } = args;
47
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
48
+ const currentSet = new Set(currentFilesAffected);
49
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
50
+ const candidates = new Set();
51
+ for (const dir of parentDirs) {
52
+ const [modifiedFiles, untrackedFiles] = await Promise.all([
53
+ getModifiedFilesInDir(engineDir, dir),
54
+ getUntrackedFilesInDir(engineDir, dir),
55
+ ]);
56
+ const untrackedSet = new Set(untrackedFiles);
57
+ for (const file of [...modifiedFiles, ...untrackedFiles]) {
58
+ if (currentSet.has(file) || claimedByOthers.has(file))
59
+ continue;
60
+ if (isToolManagedPath(file, untrackedSet.has(file), ctx))
61
+ continue;
62
+ candidates.add(file);
63
+ }
64
+ }
65
+ return [...candidates].sort();
66
+ }
67
+ /**
68
+ * Emits the scan-less advisory for one patch: missing manifest files and
69
+ * unmanaged files adjacent to the patch's ownership. When
70
+ * `--refuse-adjacent-unmanaged` is set and any unmanaged adjacent file
71
+ * exists, records the patch into `ctx.refusals` and returns true — the
72
+ * caller then skips the write for this patch and converts the collection
73
+ * into one run-level non-zero refusal after the loop.
74
+ */
75
+ export async function reportAdjacentUnmanagedFiles(args) {
76
+ const { patch, paths, manifest, currentFilesAffected, ctx } = args;
77
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
78
+ if (missingFiles.length > 0) {
79
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
80
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
81
+ `filesAffected unchanged and the missing entries will be preserved — ` +
82
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
83
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
84
+ `or pass --files <paths> to set the list explicitly.`);
85
+ }
86
+ const unmanagedFiles = await findAdjacentUnmanagedFiles({
87
+ currentFilesAffected,
88
+ engineDir: paths.engine,
89
+ manifest,
90
+ patchFilename: patch.filename,
91
+ ctx,
92
+ });
93
+ if (unmanagedFiles.length === 0)
94
+ return false;
95
+ const firstFew = unmanagedFiles.slice(0, 3).join(', ');
96
+ const more = unmanagedFiles.length > 3 ? `, +${String(unmanagedFiles.length - 3)} more` : '';
97
+ warn(`${patch.filename}: found ${String(unmanagedFiles.length)} unmanaged file(s) adjacent to this patch's ownership (${firstFew}${more}); plain re-export keeps filesAffected unchanged — use --scan-file to include reviewed files.`);
98
+ for (const file of unmanagedFiles) {
99
+ info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
100
+ }
101
+ if (ctx.refuseAdjacentUnmanaged) {
102
+ ctx.refusals.push({ patchFilename: patch.filename, files: unmanagedFiles });
103
+ return true;
104
+ }
105
+ return false;
106
+ }