@hominis/fireforge 0.46.2 → 0.47.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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/config.js +1 -2
  4. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
  5. package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
  6. package/dist/src/commands/furnace/deploy.js +36 -3
  7. package/dist/src/commands/furnace/validation-output.d.ts +5 -0
  8. package/dist/src/commands/furnace/validation-output.js +35 -9
  9. package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
  10. package/dist/src/commands/patch/delete-gap-notice.js +46 -0
  11. package/dist/src/commands/patch/delete.js +11 -0
  12. package/dist/src/commands/re-export-lint.d.ts +26 -1
  13. package/dist/src/commands/re-export-lint.js +60 -20
  14. package/dist/src/commands/re-export-scan.d.ts +8 -0
  15. package/dist/src/commands/re-export-scan.js +12 -2
  16. package/dist/src/commands/re-export-single.js +24 -5
  17. package/dist/src/commands/run.js +42 -17
  18. package/dist/src/commands/test-harness-env.d.ts +24 -0
  19. package/dist/src/commands/test-harness-env.js +61 -0
  20. package/dist/src/commands/test-register.js +6 -2
  21. package/dist/src/commands/test-verdict.d.ts +3 -1
  22. package/dist/src/commands/test-verdict.js +17 -2
  23. package/dist/src/commands/test.js +9 -16
  24. package/dist/src/core/build-jar-escalation.d.ts +1 -4
  25. package/dist/src/core/build-jar-escalation.js +1 -4
  26. package/dist/src/core/build-prepare.js +72 -55
  27. package/dist/src/core/firefox-cache.js +1 -2
  28. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  29. package/dist/src/core/furnace-apply-output.js +19 -4
  30. package/dist/src/core/furnace-css-fragments.d.ts +7 -0
  31. package/dist/src/core/furnace-css-fragments.js +12 -3
  32. package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
  33. package/dist/src/core/furnace-fragment-includers.js +122 -0
  34. package/dist/src/core/git-diff-new-file.js +1 -2
  35. package/dist/src/core/git-file-ops.js +1 -2
  36. package/dist/src/core/git.js +1 -2
  37. package/dist/src/core/mach-build-artifacts.js +1 -2
  38. package/dist/src/core/mochitest-server-port.d.ts +43 -4
  39. package/dist/src/core/mochitest-server-port.js +64 -11
  40. package/dist/src/core/patch-lint-imports.js +4 -4
  41. package/dist/src/core/smoke-patterns.d.ts +24 -0
  42. package/dist/src/core/smoke-patterns.js +50 -5
  43. package/dist/src/types/commands/options.d.ts +6 -0
  44. package/docs/configuration.md +8 -0
  45. package/docs/exit-codes.md +19 -19
  46. package/docs/machine-output.md +22 -10
  47. package/docs/testing.md +13 -1
  48. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.47.0
4
+
5
+ ### Test preflight
6
+
7
+ - The mochitest httpd preflight no longer calls another checkout's live harness "debris from an interrupted run" and prints its `kill -9`. It recognized the harness by `server.js` plus any objdir path, never asking whose objdir; a peer's suite in a sibling worktree matched and both printed remedies would have killed it. The holder's command line is now classified against this project's engine directory: only a `server.js` under it is offered for termination or reached by `--kill-stale-marionette`. One from another checkout is refused with that checkout's objdir named and no kill hint at all.
8
+
9
+ ### Builds
10
+
11
+ - The pre-build Furnace sync prints the patch-owned overwrite warnings it computes. `applyAllComponents` already recorded "overwriting deployed X, its engine content differs from the components/ source" on every apply, and `furnace apply` printed it, but `fireforge build` and `test --build` read only the applied and error lists, so an engine-side edit to a Furnace-managed file (a negative control, say) was replaced silently and the control passed against the shipped code. The warnings now print on both paths, followed by one NOTICE saying how many engine files were REPLACED before the build and that the edit did not run.
12
+
13
+ ### Patch queue
14
+
15
+ - A plain `re-export` (unchanged file set, new content) projects the rewritten body through the cross-patch queue rules before writing and refuses on new errors, the way `--files` and `--scan` already did. An earlier owner that gained a forward import of a later patch's module used to re-export green and surface a slice later as `errors already present in the queue` at the next `export` placement. The refusal prints the same `patch staged-dependency --add` remedy as the scan gate; `--force-unsafe` downgrades it to a warning; `--dry-run` refuses identically. The `--all` loop's in-memory queue projection now also refreshes `createdFiles`, which it left stale.
16
+ - `patch delete` under `patchPolicy.allowGaps: false` names the numeric gap it is about to open, in `--dry-run` and at the prompt, with both remedies: `export --order <n>` to refill or `patch compact` to renumber. It had the manifest and the policy in hand and reported nothing; the operator learned the consequence from the next unrelated command's `numeric-gap` error.
17
+
18
+ ### Smoke runs
19
+
20
+ - `run --smoke-exit` honours a `# max-hits: N` comment directly above a `--console-allow-file` entry: an allowlisted shape that fires more often than its ceiling is reported next to its attribution row (`hits×/N`) and fails the run with exit 12 like an unallowed error. The allowlist was count-blind, so an allowlisted error firing a thousand times in one boot exited 0. A directive that no entry follows is refused rather than dropped.
21
+ - For the record: `Missing chrome or resource URL:` has been a smoke error since 0.45.0.
22
+
23
+ ### Furnace
24
+
25
+ - A targeted `furnace deploy <tag>` refreshes every other deployed includer of a shared CSS fragment the target expands, when their expansion is stale against the fragment source, and prints a NOTICE naming them. It used to refresh only the named component, so a fragment edit followed by a targeted deploy left the remaining includers carrying the old expansion with no output saying so, and a re-export at that point would have shipped it. An includer whose refresh fails is listed as still stale with the deploy-all remedy, and fails the deploy: the engine must end consistent. `furnace apply <name>` has the same gap and is not changed here.
26
+
27
+ ### Test harness
28
+
29
+ - New `fireforge test --shuffle [seed]` runs a mochitest selection in a seeded random file order: it forwards mach's `--shuffle`, exports `FIREFORGE_SHUFFLE_SEED=<seed>` to the harness (a fresh seed when omitted), prints the seed, and stamps `shuffle=<seed>` on the `FIREFORGE-VERDICT` line as an additive key. Mach's own shuffle is an unseeded `Math.random` pass, so a red found by shuffling could not be replayed. Reordering tasks inside a file is harness code that reads the exported seed. Mochitest-only: an xpcshell-only or generic dispatch is refused.
30
+
3
31
  ## 0.46.0
4
32
 
5
33
  ### Packaging audit
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.46.2",
4
- "commit": "533adc217a82c2a17735e8b38e25d8f41f8bfad8",
5
- "shortCommit": "533adc217a82",
3
+ "version": "0.47.0",
4
+ "commit": "2bffcb5fee31d398a521874851af09a62134ae10",
5
+ "shortCommit": "2bffcb5fee31",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-09-05T23:00:12.026Z"
8
+ "buildTime": "2026-09-12T14:05:35.561Z"
9
9
  }
@@ -56,8 +56,7 @@ function parseValue(value, key) {
56
56
  }
57
57
  return parsed;
58
58
  }
59
- catch (error) {
60
- void error;
59
+ catch {
61
60
  // Fall back to string
62
61
  return value;
63
62
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Includer follow-up for a targeted `furnace deploy <tag>`.
3
+ *
4
+ * When `<tag>` includes a shared CSS fragment that changed, every other
5
+ * deployed includer of that fragment still carries the old expansion after
6
+ * the targeted deploy. Deploy-all refreshes all of them as a side effect of
7
+ * deploying everything; the targeted form used to refresh exactly the named
8
+ * component and say nothing about the rest. This module refreshes the other
9
+ * stale includers through the same pipeline and reports them, so a stale
10
+ * expansion is never left behind silently.
11
+ */
12
+ import { type ApplyAllComponentsResult } from '../../core/furnace-apply.js';
13
+ import type { FurnacePaths } from '../../core/furnace-config.js';
14
+ import { type StaleFragmentIncluder } from '../../core/furnace-fragment-includers.js';
15
+ import type { FurnaceOperationContext } from '../../core/furnace-operation.js';
16
+ import type { FurnaceConfig } from '../../types/furnace.js';
17
+ /** Outcome of the includer follow-up. */
18
+ export interface FragmentIncluderRefresh {
19
+ /** Fragments `<tag>` includes that other deployed includers had stale. */
20
+ fragments: string[];
21
+ /** Includers applied cleanly (dry-run: the includers that would be). */
22
+ refreshed: string[];
23
+ /** Includers whose refresh reported errors or step failures. */
24
+ failed: StaleFragmentIncluder[];
25
+ /** One apply result per includer attempted (empty in dry-run). */
26
+ results: ApplyAllComponentsResult[];
27
+ }
28
+ /**
29
+ * Refreshes the other deployed includers of the fragments `name` includes
30
+ * whose engine sheets are stale. Each includer is its own atomic unit:
31
+ * `applyAllComponents` restores its own journal on failure, and its state
32
+ * is persisted right after it succeeds, before the next includer starts.
33
+ * Must be called only after the primary component's state is persisted,
34
+ * so a signal mid-loop leaves every earlier unit committed and consistent.
35
+ *
36
+ * Dry-run computes the would-be set and applies nothing.
37
+ */
38
+ export declare function refreshStaleFragmentIncluders(args: {
39
+ projectRoot: string;
40
+ name: string;
41
+ config: FurnaceConfig;
42
+ furnacePaths: FurnacePaths;
43
+ isDryRun: boolean;
44
+ operationContext: FurnaceOperationContext;
45
+ }): Promise<FragmentIncluderRefresh>;
46
+ /**
47
+ * Folds includer apply results into the primary result so the deploy
48
+ * summary, the jsconfig sync gate and the failed-component set all see
49
+ * them. An includer failure is a real apply error of this deploy: the
50
+ * operator asked for a consistent engine. `rolledBack` stays the
51
+ * primary's; an includer's rollback is already visible through its errors.
52
+ */
53
+ export declare function mergeApplyResults(primary: ApplyAllComponentsResult, extras: readonly ApplyAllComponentsResult[]): ApplyAllComponentsResult;
54
+ /**
55
+ * Operator-facing lines for the includer follow-up: zero, one or two.
56
+ * The second is the fallback for includers that could not be refreshed and
57
+ * names the command that refreshes everything.
58
+ */
59
+ export declare function formatFragmentIncluderNotice(refresh: FragmentIncluderRefresh, isDryRun: boolean): string[];
@@ -0,0 +1,120 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Includer follow-up for a targeted `furnace deploy <tag>`.
4
+ *
5
+ * When `<tag>` includes a shared CSS fragment that changed, every other
6
+ * deployed includer of that fragment still carries the old expansion after
7
+ * the targeted deploy. Deploy-all refreshes all of them as a side effect of
8
+ * deploying everything; the targeted form used to refresh exactly the named
9
+ * component and say nothing about the rest. This module refreshes the other
10
+ * stale includers through the same pipeline and reports them, so a stale
11
+ * expansion is never left behind silently.
12
+ */
13
+ import { getProjectPaths } from '../../core/config.js';
14
+ import { applyAllComponents } from '../../core/furnace-apply.js';
15
+ import { findStaleIncludersOfTargetFragments, } from '../../core/furnace-fragment-includers.js';
16
+ import { getPersistableAppliedEntry, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
17
+ const EMPTY_REFRESH = {
18
+ fragments: [],
19
+ refreshed: [],
20
+ failed: [],
21
+ results: [],
22
+ };
23
+ /**
24
+ * Refreshes the other deployed includers of the fragments `name` includes
25
+ * whose engine sheets are stale. Each includer is its own atomic unit:
26
+ * `applyAllComponents` restores its own journal on failure, and its state
27
+ * is persisted right after it succeeds, before the next includer starts.
28
+ * Must be called only after the primary component's state is persisted,
29
+ * so a signal mid-loop leaves every earlier unit committed and consistent.
30
+ *
31
+ * Dry-run computes the would-be set and applies nothing.
32
+ */
33
+ export async function refreshStaleFragmentIncluders(args) {
34
+ const { projectRoot, name, config, furnacePaths, isDryRun, operationContext } = args;
35
+ if (!(name in config.custom))
36
+ return EMPTY_REFRESH;
37
+ const { stale } = await findStaleIncludersOfTargetFragments({
38
+ targetTag: name,
39
+ custom: config.custom,
40
+ furnacePaths,
41
+ engineDir: getProjectPaths(projectRoot).engine,
42
+ });
43
+ if (stale.length === 0)
44
+ return EMPTY_REFRESH;
45
+ const fragmentsWithStaleIncluders = [...new Set(stale.flatMap((s) => s.fragments))].sort();
46
+ if (isDryRun) {
47
+ return {
48
+ fragments: fragmentsWithStaleIncluders,
49
+ refreshed: stale.map((s) => s.tag),
50
+ failed: [],
51
+ results: [],
52
+ };
53
+ }
54
+ const refreshed = [];
55
+ const failed = [];
56
+ const results = [];
57
+ for (const includer of stale) {
58
+ const result = await applyAllComponents(projectRoot, false, {
59
+ componentName: includer.tag,
60
+ persistState: false,
61
+ operationContext,
62
+ });
63
+ results.push(result);
64
+ if (shouldPersistSingleComponentState(result, false)) {
65
+ await persistSingleComponentState(projectRoot, getPersistableAppliedEntry('Deploy', includer.tag, result.applied[0]), furnacePaths);
66
+ refreshed.push(includer.tag);
67
+ }
68
+ else {
69
+ failed.push(includer);
70
+ }
71
+ }
72
+ return { fragments: fragmentsWithStaleIncluders, refreshed, failed, results };
73
+ }
74
+ /**
75
+ * Folds includer apply results into the primary result so the deploy
76
+ * summary, the jsconfig sync gate and the failed-component set all see
77
+ * them. An includer failure is a real apply error of this deploy: the
78
+ * operator asked for a consistent engine. `rolledBack` stays the
79
+ * primary's; an includer's rollback is already visible through its errors.
80
+ */
81
+ export function mergeApplyResults(primary, extras) {
82
+ if (extras.length === 0)
83
+ return primary;
84
+ const warnings = [primary, ...extras].flatMap((r) => r.warnings ?? []);
85
+ const actions = [primary, ...extras].flatMap((r) => r.actions ?? []);
86
+ const anyWarnings = [primary, ...extras].some((r) => r.warnings !== undefined);
87
+ const anyActions = [primary, ...extras].some((r) => r.actions !== undefined);
88
+ return {
89
+ ...primary,
90
+ applied: [...primary.applied, ...extras.flatMap((r) => r.applied)],
91
+ skipped: [...primary.skipped, ...extras.flatMap((r) => r.skipped)],
92
+ errors: [...primary.errors, ...extras.flatMap((r) => r.errors)],
93
+ ...(anyWarnings ? { warnings } : {}),
94
+ ...(anyActions ? { actions } : {}),
95
+ };
96
+ }
97
+ function quoteList(items) {
98
+ return items.map((item) => `"${item}"`).join(', ');
99
+ }
100
+ /**
101
+ * Operator-facing lines for the includer follow-up: zero, one or two.
102
+ * The second is the fallback for includers that could not be refreshed and
103
+ * names the command that refreshes everything.
104
+ */
105
+ export function formatFragmentIncluderNotice(refresh, isDryRun) {
106
+ const lines = [];
107
+ if (refresh.refreshed.length > 0) {
108
+ const n = refresh.refreshed.length;
109
+ lines.push(`${isDryRun ? 'Would also refresh' : 'Also refreshed'} ${n} other includer${n === 1 ? '' : 's'} ` +
110
+ `of ${quoteList(refresh.fragments)}: ${refresh.refreshed.join(', ')}.`);
111
+ }
112
+ if (refresh.failed.length > 0) {
113
+ const n = refresh.failed.length;
114
+ const fragments = [...new Set(refresh.failed.flatMap((f) => f.fragments))].sort();
115
+ lines.push(`${n} other includer${n === 1 ? '' : 's'} of ${quoteList(fragments)} ` +
116
+ `remain stale: ${refresh.failed.map((f) => f.tag).join(', ')}. ` +
117
+ 'Run `fireforge furnace deploy` (all).');
118
+ }
119
+ return lines;
120
+ }
@@ -10,7 +10,8 @@ import { getPersistableAppliedEntry, getStepFailureCount, persistSingleComponent
10
10
  import { hasBlockingStepErrors } from '../../core/furnace-step-errors.js';
11
11
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
12
12
  import { FurnaceError } from '../../errors/furnace.js';
13
- import { info, intro, note, outro, spinner, warn } from '../../utils/logger.js';
13
+ import { info, intro, note, notice, outro, spinner, warn } from '../../utils/logger.js';
14
+ import { formatFragmentIncluderNotice, mergeApplyResults, refreshStaleFragmentIncluders, } from './deploy-fragment-includers.js';
14
15
  import { runDeployValidation } from './validation-output.js';
15
16
  /**
16
17
  * Builds the final deploy failure summary from apply and validation error counts.
@@ -57,6 +58,13 @@ function getFailedComponentNames(result) {
57
58
  * site. Rollback on failure happens inside `applyAllComponents`. The journal
58
59
  * returned on success is ignored (the deploy keeps its files).
59
60
  *
61
+ * The caller then runs the fragment-includer follow-up
62
+ * ({@link refreshStaleFragmentIncluders}) ONLY after this component's state
63
+ * is persisted: each includer is applied and persisted as its own unit, so
64
+ * a signal mid-loop restores at most the includer in flight and leaves every
65
+ * earlier unit committed. Moving the persist after the includer loop would
66
+ * silently break that atomicity.
67
+ *
60
68
  * @param name - Component name to apply
61
69
  * @param config - Loaded Furnace configuration
62
70
  * @param isDryRun - Whether file writes should be skipped
@@ -168,10 +176,29 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
168
176
  // already restored the engine to its pre-deploy state, so persisting
169
177
  // partial checksums here would mis-report the next status/apply run
170
178
  // against a workspace that was never actually deployed.
171
- if (shouldPersistSingleComponentState(namedApplyResult, isDryRun)) {
179
+ const primaryClean = shouldPersistSingleComponentState(namedApplyResult, isDryRun);
180
+ if (primaryClean) {
172
181
  await persistSingleComponentState(projectRoot, getPersistableAppliedEntry('Deploy', name, namedApplyResult.applied[0]), furnacePaths);
173
182
  }
174
- return { kind: 'result', result: namedApplyResult };
183
+ // Other deployed includers of a fragment this component includes
184
+ // would otherwise keep a stale expansion. Only after a clean,
185
+ // persisted primary apply (dry-run always previews).
186
+ if (!primaryClean && !isDryRun) {
187
+ return { kind: 'result', result: namedApplyResult };
188
+ }
189
+ const includers = await refreshStaleFragmentIncluders({
190
+ projectRoot,
191
+ name,
192
+ config,
193
+ furnacePaths,
194
+ isDryRun,
195
+ operationContext: ctx,
196
+ });
197
+ return {
198
+ kind: 'result',
199
+ result: mergeApplyResults(namedApplyResult, includers.results),
200
+ includers,
201
+ };
175
202
  }
176
203
  const allResult = await applyAllComponents(projectRoot, isDryRun, {
177
204
  operationContext: ctx,
@@ -187,6 +214,11 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
187
214
  const result = applyOutcome.result;
188
215
  applySpinner.stop(isDryRun ? 'Planned actions calculated' : 'Components applied');
189
216
  logApplyResult(result, isDryRun);
217
+ if (applyOutcome.includers) {
218
+ for (const line of formatFragmentIncluderNotice(applyOutcome.includers, isDryRun)) {
219
+ notice(line);
220
+ }
221
+ }
190
222
  // Keep the consumer jsconfig's chrome-module `paths` in step with the
191
223
  // deployed module set. Only after a clean apply: a rolled-back deploy
192
224
  // must not advance the typecheck mapping either.
@@ -212,6 +244,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
212
244
  isDryRun,
213
245
  projectRoot,
214
246
  dryRunActions: result.actions,
247
+ alsoValidate: applyOutcome.includers?.refreshed ?? [],
215
248
  });
216
249
  if (validation.done)
217
250
  return;
@@ -33,6 +33,11 @@ export interface RunDeployValidationOptions {
33
33
  projectRoot: string;
34
34
  /** Projected actions of a dry run, used to filter projected issues. */
35
35
  dryRunActions?: DryRunAction[] | undefined;
36
+ /**
37
+ * Extra components a named deploy also touched (fragment includers it
38
+ * refreshed). Validated alongside `name`; ignored when `name` is unset.
39
+ */
40
+ alsoValidate?: string[] | undefined;
36
41
  }
37
42
  /**
38
43
  * Runs the validation phase of a furnace deploy, checking all or a single component.
@@ -54,13 +54,33 @@ function resolveNamedValidationTarget(name, config, furnacePaths) {
54
54
  }
55
55
  throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
56
56
  }
57
+ /**
58
+ * Validates one named target and prints its outcome.
59
+ * @returns `[errors, warnings]` for the target
60
+ */
61
+ async function validateNamedTarget(args) {
62
+ const { name, config, furnacePaths, isDryRun, projectRoot, dryRunActions } = args;
63
+ const target = resolveNamedValidationTarget(name, config, furnacePaths);
64
+ if (target === 'stock')
65
+ return [0, 0];
66
+ if (!(await pathExists(target.componentDir))) {
67
+ throw new FurnaceError(`Component directory not found for "${name}".`, name);
68
+ }
69
+ const rawIssues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
70
+ const issues = isDryRun ? filterProjectedDryRunIssues(rawIssues, dryRunActions) : rawIssues;
71
+ if (issues.length === 0) {
72
+ success(`${name} — all checks passed`);
73
+ return [0, 0];
74
+ }
75
+ return displayValidationIssues(issues);
76
+ }
57
77
  /**
58
78
  * Runs the validation phase of a furnace deploy, checking all or a single component.
59
79
  * @param options - See {@link RunDeployValidationOptions}
60
80
  * @returns Validation counts, or `done: true` if the caller should early-return
61
81
  */
62
82
  export async function runDeployValidation(options) {
63
- const { validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, dryRunActions, } = options;
83
+ const { validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, dryRunActions, alsoValidate, } = options;
64
84
  let totalErrors = 0;
65
85
  let totalWarnings = 0;
66
86
  let componentCount = 0;
@@ -82,15 +102,21 @@ export async function runDeployValidation(options) {
82
102
  validateSpinner.stop('Validation failed');
83
103
  throw new FurnaceError(`Component directory not found for "${name}".`, name);
84
104
  }
85
- const rawIssues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
86
- const issues = isDryRun ? filterProjectedDryRunIssues(rawIssues, dryRunActions) : rawIssues;
87
- componentCount = 1;
88
105
  validateSpinner.stop('Validation complete');
89
- if (issues.length === 0) {
90
- success(`${name} — all checks passed`);
91
- }
92
- else {
93
- const [errors, warnings] = displayValidationIssues(issues);
106
+ // The named target first, then every includer the deploy refreshed on
107
+ // its behalf: those engine files changed in this deploy too, so this
108
+ // deploy's validation covers them.
109
+ const extra = (alsoValidate ?? []).filter((t) => t !== name && !failedComponents.has(t));
110
+ for (const tag of [name, ...extra]) {
111
+ const [errors, warnings] = await validateNamedTarget({
112
+ name: tag,
113
+ config,
114
+ furnacePaths,
115
+ isDryRun,
116
+ projectRoot,
117
+ dryRunActions,
118
+ });
119
+ componentCount++;
94
120
  totalErrors += errors;
95
121
  totalWarnings += warnings;
96
122
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Projects the queue after a `patch delete` and names any numeric gap the
3
+ * removal opens under `patchPolicy.allowGaps: false`.
4
+ *
5
+ * The delete itself succeeds either way; the gap only surfaces on the next
6
+ * `lint --per-patch`, `re-export` or `verify`, which is the wrong moment to
7
+ * learn that the queue needs a refill or a `patch compact`. Diffing the
8
+ * policy evaluation of the current manifest against the projected one keeps
9
+ * the arithmetic in `evaluatePatchPolicy`, so this module never re-derives
10
+ * what counts as a gap.
11
+ */
12
+ import type { PatchesManifest, PatchMetadata } from '../../types/commands/index.js';
13
+ import type { FireForgeConfig } from '../../types/config.js';
14
+ /**
15
+ * Describes each `numeric-gap` issue the queue would gain once `target` is
16
+ * removed. Returns one operator-facing line per newly gapped range, or an
17
+ * empty array when the policy allows gaps or the removal opens none (for
18
+ * example deleting the last patch of a range).
19
+ *
20
+ * @param config - Loaded project configuration
21
+ * @param manifest - Current patch manifest
22
+ * @param target - Patch about to be deleted
23
+ */
24
+ export declare function describeProjectedGaps(config: FireForgeConfig, manifest: PatchesManifest, target: PatchMetadata): string[];
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Projects the queue after a `patch delete` and names any numeric gap the
4
+ * removal opens under `patchPolicy.allowGaps: false`.
5
+ *
6
+ * The delete itself succeeds either way; the gap only surfaces on the next
7
+ * `lint --per-patch`, `re-export` or `verify`, which is the wrong moment to
8
+ * learn that the queue needs a refill or a `patch compact`. Diffing the
9
+ * policy evaluation of the current manifest against the projected one keeps
10
+ * the arithmetic in `evaluatePatchPolicy`, so this module never re-derives
11
+ * what counts as a gap.
12
+ */
13
+ import { buildProjectedManifest, evaluatePatchPolicy } from '../../core/patch-policy.js';
14
+ const GAP_MESSAGE_PATTERN = /^(?<range>.+?) has numeric gap\(s\): (?<orders>[^.]+)\./;
15
+ /**
16
+ * Describes each `numeric-gap` issue the queue would gain once `target` is
17
+ * removed. Returns one operator-facing line per newly gapped range, or an
18
+ * empty array when the policy allows gaps or the removal opens none (for
19
+ * example deleting the last patch of a range).
20
+ *
21
+ * @param config - Loaded project configuration
22
+ * @param manifest - Current patch manifest
23
+ * @param target - Patch about to be deleted
24
+ */
25
+ export function describeProjectedGaps(config, manifest, target) {
26
+ if (config.patchPolicy?.allowGaps !== false)
27
+ return [];
28
+ const projected = buildProjectedManifest(manifest, manifest.patches.filter((patch) => patch.filename !== target.filename));
29
+ const gapKey = (issue) => `${issue.filename}|${issue.message}`;
30
+ const existing = new Set(evaluatePatchPolicy(config, manifest)
31
+ .filter((issue) => issue.code === 'numeric-gap')
32
+ .map(gapKey));
33
+ return evaluatePatchPolicy(config, projected)
34
+ .filter((issue) => issue.code === 'numeric-gap' && !existing.has(gapKey(issue)))
35
+ .map((issue) => formatGapNotice(target.filename, issue.message));
36
+ }
37
+ function formatGapNotice(targetFilename, message) {
38
+ const match = GAP_MESSAGE_PATTERN.exec(message);
39
+ const range = match?.groups?.['range'] ?? message;
40
+ const orders = match?.groups?.['orders'] ?? '';
41
+ const fill = orders.split(',')[0]?.trim() ?? '<n>';
42
+ const where = orders ? `a gap at ${orders}` : 'a numeric gap';
43
+ return (`deleting ${targetFilename} leaves ${range} with ${where}; the queue will fail ` +
44
+ 'numeric-gap (patchPolicy.allowGaps is false) until ' +
45
+ `"fireforge export --order ${fill}" fills it or "fireforge patch compact" renumbers.`);
46
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { basename } from 'node:path';
11
11
  import { Command } from 'commander';
12
+ import { loadConfig } from '../../core/config.js';
12
13
  import { confirmDestructive } from '../../core/destructive.js';
13
14
  import { appendHistoryBestEffort } from '../../core/history-log.js';
14
15
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
@@ -17,6 +18,7 @@ import { removePatchFileAndManifest } from '../../core/patch-manifest.js';
17
18
  import { info, intro, outro, warn } from '../../utils/logger.js';
18
19
  import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
19
20
  import { proceedAfterDecision } from '../destructive-decision.js';
21
+ import { describeProjectedGaps } from './delete-gap-notice.js';
20
22
  import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
21
23
  /**
22
24
  * Runs the `patch delete` command: removes a patch file and its manifest
@@ -108,6 +110,14 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
108
110
  `after the delete, update it via "fireforge patch staged-dependency ${holder.filename} --remove ..." ` +
109
111
  'or re-point the owner at the patch that will create the file.');
110
112
  }
113
+ // Under `patchPolicy.allowGaps: false` the removal can open a numeric gap
114
+ // that every later lint and re-export refuses on. Naming it here, in
115
+ // dry-run and at the prompt, puts the refill/compact choice in front of
116
+ // the operator before the gap exists rather than after the next command.
117
+ const gapNotices = describeProjectedGaps(await loadConfig(projectRoot), manifest, target);
118
+ for (const line of gapNotices) {
119
+ warn(line);
120
+ }
111
121
  const conflicts = dependents.length > 0
112
122
  ? {
113
123
  // The wording spells out the runtime impact: `git apply`
@@ -130,6 +140,7 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
130
140
  for (const file of target.filesAffected) {
131
141
  summary.push(` ${file} → will become unmanaged`);
132
142
  }
143
+ summary.push(...gapNotices);
133
144
  const decision = await confirmDestructive({
134
145
  operation: 'patch-delete',
135
146
  title: `Delete ${target.filename}`,
@@ -41,6 +41,8 @@ export interface ReExportLintContext {
41
41
  reusedCacheEntries: number;
42
42
  /** Run-level checkJs globals are emitted once per invocation. */
43
43
  globalsEmitted: boolean;
44
+ /** The pre-existing-queue-errors note is printed once per invocation. */
45
+ queueBaselineWarned: boolean;
44
46
  }
45
47
  /** Builds the once-per-invocation lint context. */
46
48
  export declare function buildReExportLintContext(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, noCache: boolean): Promise<ReExportLintContext>;
@@ -85,10 +87,33 @@ export declare function lintReExportedPatch(args: {
85
87
  * {@link refreshQueueCtxEntry} first).
86
88
  */
87
89
  export declare function storeReExportLintResult(lintCtx: ReExportLintContext, patchFilename: string, projectedMetadata: PatchMetadata, existingFiles: string[], result: ReExportLintResult): Promise<void>;
90
+ /**
91
+ * Projects the rewritten body into the hoisted queue context and refuses
92
+ * when the projection introduces error-severity cross-patch findings the
93
+ * current queue does not have (a forward import an earlier owner gained,
94
+ * say). Mirrors `re-export --files` (its projected-state conflict) and the
95
+ * `--scan` adoption gate, which only covered files a scan adopted: a plain
96
+ * re-export of an unchanged file set with new CONTENT used to pass, and the
97
+ * queue carried the error until the next `export` placement refused it.
98
+ *
99
+ * Runs in dry-run. `--force-unsafe` downgrades the refusal to warnings;
100
+ * `--skip-lint` does not bypass it, same as `--files`. The baseline is the
101
+ * hoisted context, which {@link refreshQueueCtxEntry} keeps equal to the
102
+ * on-disk queue across an `--all` loop, so patch N+1 is projected against
103
+ * the queue as it will exist.
104
+ */
105
+ export declare function assertRefreshedBodyHasNoNewQueueErrors(args: {
106
+ lintCtx: ReExportLintContext;
107
+ patchFilename: string;
108
+ diffContent: string;
109
+ forceUnsafe: boolean;
110
+ }): void;
88
111
  /**
89
112
  * Refreshes the in-memory queue entry for a just-rewritten patch so later
90
113
  * iterations (and later cache keys) lint against the new body instead of
91
- * the stale one. The on-disk write is the authority. This mirrors it.
114
+ * the stale one. The on-disk write is the authority. This mirrors it, via
115
+ * the same projection `buildPatchQueueContext` derives an entry from, so
116
+ * `createdFiles` and `modifiedFileAdditions` stay in step with a rebuild.
92
117
  */
93
118
  export declare function refreshQueueCtxEntry(lintCtx: ReExportLintContext, patchFilename: string, diffContent: string): void;
94
119
  /** Persists cache writes and reports reuse, once after the loop. */