@hominis/fireforge 0.21.2 → 0.21.4

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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,8 @@
11
11
  ### Hardening
12
12
 
13
13
  - **Eval 0.21.0 release-gate fixes.** `export --dry-run` now performs the same supersede and cross-patch ownership checks as real export before calling a plan safe; `furnace deploy --dry-run` validates successful custom-component plans against projected jar.mn registrations; generated Furnace components and browser-chrome test scaffolds are strict-checkJs and lazy-custom-element ready; chrome-doc packaging xpcshell tests no longer trip component-orphan validation; supported optional config keys such as `firefox.sha256` print `(not set)` when absent; and Furnace manifest writes preserve existing top-level/component ordering while appending new entries predictably.
14
+ - **Sparse export insertion before reserved ranges.** `fireforge export --order <N>` now creates the new patch at that exact unused order without renumbering later patches, so policy-owned queues can add `241-ui-new-feature.patch` while preserving exact reserved exceptions such as `900-infra-bindgen-basic-string-workaround.patch`. Positional `--before` / `--after` insertion still renumbers, but now refuses with a sparse `--order` suggestion when it would move a reserved patch.
15
+ - **V1 readiness audit follow-ups.** `fireforge token coverage` now validates dirty/untracked Furnace token CSS as a token source file instead of ignoring it or counting its expected literal values as raw-color coverage debt. `furnace create --compose <tag>` auto-registers discovered engine widgets into `furnace.json#stock` in the same transaction as the new custom component, so a prior non-interactive `furnace scan` report is enough for compose authoring. `fireforge lint --max-warnings <n>` lets release gates enforce a warning budget (for example `fireforge lint --per-patch --max-warnings 0`) while keeping warnings advisory by default. README guidance now covers first-module `browser/modules/<binaryName>/moz.build` setup, direct `fireforge` binary equivalents for shells without `npx`, Watchman PATH expectations, and the intentionally narrow `patch tier --tier branding` surface.
14
16
  - **Override removal demotes back to stock.** Removing a Furnace override restores engine files, deletes the override workspace, clears override checksums, and re-adds the component to `stock` tracking instead of dropping it from `furnace.json`. Optional Furnace config fields, including `platformPrefixes`, are preserved across the write.
15
17
  - **Rename updates browser-chrome test bodies.** `furnace rename` now rewrites generated browser-chrome mochitest contents as well as filenames and `browser.toml`, preventing stale `waitForElement("<old>")` references after a component rename.
16
18
  - **UI build preflight is stricter.** `fireforge build --ui` now refuses before `mach build faster` when the current objdir lacks a completed launchable bundle, guiding fresh imports and partial builds through a full `fireforge build` first.
@@ -64,7 +66,7 @@
64
66
 
65
67
  ### Documentation
66
68
 
67
- - **README — mochitest timeouts vs Marionette.** The Test harness section documents long idle timeouts (~370s, `TEST_END: TIMEOUT`) on fork custom chrome, `--marionette-port` behaviour with xpcshell flavor, and pointers to fork-side prefs and investigation (for example Hominis `AGENT_RULES.md`).
69
+ - **README — mochitest timeouts vs Marionette.** The Test harness section documents long idle timeouts (~370s, `TEST_END: TIMEOUT`) on fork custom chrome, `--marionette-port` behaviour with xpcshell flavor, and pointers to fork-side prefs and investigation.
68
70
  - **README — default test harness and macOS mochitest-chrome.** The README sections “Picking a test harness for `furnace create`”, “Test harness options”, and “Known upstream build issues” describe the browser-chrome default, macOS single-process idle timeout, explicit `--test-style=mochikit`, and `--with-tests` + `--xpcshell` resolution.
69
71
 
70
72
  ## 0.18.0
package/README.md CHANGED
@@ -36,7 +36,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
36
36
  - **Python 3** (required by Firefox's `mach` build system).
37
37
  - **Git**
38
38
  - Platform build tools: Xcode on macOS, `build-essential` on Linux, Visual Studio Build Tools on Windows.
39
- - **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site. `fireforge watch` resolves watchman's absolute path via `which` / `where` and prepends its directory to the subprocess `PATH` it hands mach, so a homebrew-installed watchman at `/opt/homebrew/bin/watchman` (absent from the Node subprocess's default `PATH` on macOS) is still visible to `mach watch` without the operator having to re-export `PATH` manually. If `mach watch` reports `Operation not permitted` / `EPERM` on macOS, FireForge points at the usual privacy fix: grant Full Disk Access or Files and Folders access to the terminal/Codex app and watchman, then restart watchman with `watchman shutdown-server`.
39
+ - **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH`; build, run, package, and test workflows still work without Watchman. `fireforge watch` resolves watchman's absolute path via `which` / `where` and prepends its directory to the subprocess `PATH` it hands mach, so a homebrew-installed watchman at `/opt/homebrew/bin/watchman` (absent from the Node subprocess's default `PATH` on macOS) is still visible to `mach watch` without the operator having to re-export `PATH` manually. If `watchman` is installed but `fireforge watch` still refuses, make sure the same shell or automation environment that launches FireForge has Watchman on `PATH`. If `mach watch` reports `Operation not permitted` / `EPERM` on macOS, FireForge points at the usual privacy fix: grant Full Disk Access or Files and Folders access to the terminal/Codex app and watchman, then restart watchman with `watchman shutdown-server`.
40
40
 
41
41
  ### Setup
42
42
 
@@ -53,6 +53,8 @@ npx fireforge build # build the browser
53
53
  npx fireforge run # launch it
54
54
  ```
55
55
 
56
+ If your shell does not provide `npm` / `npx`, run the installed binary directly instead: `fireforge setup`, `fireforge download`, and so on when `fireforge` is on `PATH`, or `./node_modules/.bin/fireforge setup` from a project-local install.
57
+
56
58
  Your project now has `fireforge.json`, an `engine/` directory with Firefox source and a `patches/` directory with an empty `patches.json` manifest ready for your first customisation.
57
59
 
58
60
  #### Known upstream build issues
@@ -68,21 +70,23 @@ Your project now has `fireforge.json`, an `engine/` directory with Firefox sourc
68
70
 
69
71
  ```bash
70
72
  npx fireforge export browser/base/content/browser.js --name "custom-toolbar" --category ui
73
+ # Direct binary equivalent:
74
+ fireforge export browser/base/content/browser.js --name "custom-toolbar" --category ui
71
75
  ```
72
76
 
73
77
  3. Your patch is now in `patches/`.
74
78
  4. Reset and import to verify everything applies cleanly:
75
79
 
76
80
  ```bash
77
- npx fireforge reset --yes
78
- npx fireforge import # --dry-run to preview without applying
81
+ npx fireforge reset --yes # or: fireforge reset --yes
82
+ npx fireforge import # or: fireforge import --dry-run
79
83
  ```
80
84
 
81
85
  5. When Mozilla releases a new version, update fireforge.json, re-download and rebase:
82
86
 
83
87
  ```bash
84
- npx fireforge download --force
85
- npx fireforge rebase
88
+ npx fireforge download --force # or: fireforge download --force
89
+ npx fireforge rebase # or: fireforge rebase
86
90
  ```
87
91
 
88
92
  `fireforge download` indexes the extracted Firefox source into a fresh git repository — a one-time 1–3 minute pass on a cold SSD, longer on slow or loaded disks. The monolithic `git add -A` is capped at 10 minutes by default and falls back to a per-directory chunked pass (30 minutes per chunk) when the cap hits. If indexing still times out, the command now raises `GitIndexingTimeoutError` with recovery guidance: extend the cap via `FIREFORGE_GIT_ADD_TIMEOUT_MS` (monolithic) and/or `FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS` (chunked) in milliseconds, e.g. `FIREFORGE_GIT_ADD_TIMEOUT_MS=1800000 fireforge download --force` for a 30-minute monolithic budget, then re-run `fireforge download --force` — the resume path picks up from the partial git state so the repeat is not wasted work.
@@ -146,6 +150,11 @@ Policy ranges are category-owned: a `ui` patch in the example must use `200-299`
146
150
  allowlist. `filenamePattern` must expose named captures `order`, `category`, and `slug`.
147
151
  `mutationMode` controls mutating commands: `"error"` refuses, `"warn"` prints warnings and
148
152
  continues, and `"force"` refuses unless the command supports and receives `--force-unsafe`.
153
+ When a policy-owned queue has sparse category ranges before reserved exceptions, use
154
+ `fireforge export <paths> --order 241 --category ui --name new-ui-feature` to create the exact
155
+ unused order without renumbering later patches. Positional insertion with `--before` / `--after`
156
+ still renumbers following patches, and policy enforcement refuses the operation if that would move a
157
+ reserved exact exception such as `900-infra-bootstrap-workaround.patch`.
149
158
  Without `patchPolicy`, existing repositories keep the broad category and numeric ordering behavior.
150
159
 
151
160
  ### Importing patches
@@ -191,8 +200,10 @@ fireforge export browser/base/content/browser.js --dry-run
191
200
  # Same preview surface for the aggregate path
192
201
  fireforge export-all --name "all-changes" --category ui --dry-run
193
202
 
194
- # Insert a new patch at a specific position
195
- fireforge export browser/base/content/browser.js --order 3 --name "inserted" --category ui
203
+ # Create a sparse patch at an exact unused order without renumbering later patches
204
+ fireforge export browser/base/content/browser.js --order 241 --name "new-ui-feature" --category ui
205
+
206
+ # Insert a new patch at a positional anchor, renumbering later patches
196
207
  fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch --name "prelim"
197
208
 
198
209
  # Restrict a re-export to a specific file subset
@@ -273,7 +284,7 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
273
284
 
274
285
  `fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
275
286
 
276
- By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
287
+ By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive. Warnings stay advisory by default; release gates that require a warning-clean queue should use `fireforge lint --per-patch --max-warnings 0`.
277
288
 
278
289
  | Check | Scope | Severity |
279
290
  | ------------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------ |
@@ -365,6 +376,16 @@ fireforge register engine/browser/modules/mybrowser/MyStore.sys.mjs
365
376
  # Both support --dry-run to preview changes
366
377
  ```
367
378
 
379
+ For the first module in a new `browser/modules/<binaryName>/` namespace, create `engine/browser/modules/<binaryName>/moz.build` before running `fireforge register`. The minimal manifest is:
380
+
381
+ ```python
382
+ # SPDX-License-Identifier: MPL-2.0
383
+
384
+ EXTRA_JS_MODULES += []
385
+ ```
386
+
387
+ Also make sure `browser/modules/moz.build` references the namespace directory with `DIRS += ["<binaryName>"]`; `register` adds the module entry inside the namespace manifest, not the parent `DIRS` entry.
388
+
368
389
  <details>
369
390
  <summary>Wire options</summary>
370
391
 
@@ -412,6 +433,8 @@ fireforge furnace status # workspace vs engine drift
412
433
  fireforge furnace diff moz-button # unified diff against baseline
413
434
  ```
414
435
 
436
+ `furnace scan` is read-only in non-interactive shells and reports which discovered widgets are already tracked. In interactive shells it can add selected discoveries to `furnace.json#stock`. `furnace create --compose <tag>` also consults the scan inventory: if `<tag>` is a discovered engine widget that is not yet in `stock`, create auto-adds it to `stock` in the same `furnace.json` write as the new custom component. Unknown compose targets still fail.
437
+
415
438
  `furnace deploy` validates components before applying. As always, errors block, warnings are advisory. `fireforge build` and `fireforge test --build` run apply automatically — when apply wrote files during a build, the build prints a `Furnace: source → engine sync wrote N component(s) …` banner naming every component that was synced, so it is obvious whether engine/ was freshly updated. Use `fireforge doctor --repair-furnace` if the engine gets out of sync.
416
439
 
417
440
  ### Scaffolding top-level chrome documents
@@ -560,7 +583,7 @@ fireforge token add --category 'Colors — General' --mode static -- --my-color
560
583
  fireforge token add --category 'Colors — General' --mode static my-color '#fff' # bare-name form
561
584
  ```
562
585
 
563
- Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` does three more things in the same step so the file is owned end-to-end by tooling: it registers the tokens CSS path in `patchLint.rawColorAllowlist` (so raw color literals inside it are not flagged by `fireforge lint`); it adds the matching `skin/classic/browser/<binaryName>-tokens.css (../shared/<binaryName>-tokens.css)` entry to `browser/themes/shared/jar.inc.mn` (so `fireforge status` does not flag the file as unmanaged or unregistered); and it derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
586
+ Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` does three more things in the same step so the file is owned end-to-end by tooling: it registers the tokens CSS path in `patchLint.rawColorAllowlist` (so raw color literals inside it are not flagged by `fireforge lint`); it adds the matching `skin/classic/browser/<binaryName>-tokens.css (../shared/<binaryName>-tokens.css)` entry to `browser/themes/shared/jar.inc.mn` (so `fireforge status` does not flag the file as unmanaged or unregistered); and it derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init. When only the tokens CSS file is dirty or untracked, `fireforge token coverage` validates it as a token source file and does not count its expected literal token values as raw-color coverage failures.
564
587
 
565
588
  ### Diff-scoped lint (`lint --since`)
566
589
 
@@ -269,7 +269,7 @@ export async function downloadCommand(projectRoot, options) {
269
269
  // CI job notes the expected duration. The progress callbacks below
270
270
  // still fire as usual; this is an additional up-front signal, not a
271
271
  // replacement.
272
- info('Indexing downloaded source into git (one-time; typically 13 minutes on a ~600 MB Firefox tree)...');
272
+ info('Indexing downloaded source into git (one-time; typically 35 minutes on a ~600 MB Firefox tree)...');
273
273
  // Initialize git repository
274
274
  const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
275
275
  let baseCommit;
@@ -24,6 +24,13 @@ export interface PlacementPlan {
24
24
  * slot to make room for a new patch at `requestedOrder`.
25
25
  */
26
26
  export declare function computePlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
27
+ /**
28
+ * Computes an exact sparse placement plan for `--order <N>`. Unlike
29
+ * positional insertion, this never renumbers existing patches: the order
30
+ * must be unused, and policy validation decides whether the requested
31
+ * category/order is allowed.
32
+ */
33
+ export declare function computeExactPlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
27
34
  /**
28
35
  * Resolves a placement plan from CLI flags against the current manifest.
29
36
  */
@@ -18,11 +18,18 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
18
18
  import { toError } from '../utils/errors.js';
19
19
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
20
20
  import { info, warn } from '../utils/logger.js';
21
+ import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
21
22
  import { findPartialOwnershipOverlap } from './export-shared.js';
22
23
  function buildFilenameForPlacement(category, name, order, width) {
23
24
  const padded = String(order).padStart(Math.max(3, width), '0');
24
25
  return `${padded}-${category}-${sanitizeName(name)}.patch`;
25
26
  }
27
+ function prefixWidthForPatches(manifestPatches, requestedOrder) {
28
+ return manifestPatches.reduce((width, patch) => {
29
+ const match = /^(\d+)-/.exec(patch.filename);
30
+ return Math.max(width, match?.[1]?.length ?? 3, String(requestedOrder).length);
31
+ }, 3);
32
+ }
26
33
  function getSortedRenameEntries(renameMap) {
27
34
  return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
28
35
  }
@@ -59,12 +66,7 @@ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatch
59
66
  }
60
67
  const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
61
68
  const renameMap = new Map();
62
- // Decide the canonical prefix width by inspecting the widest existing
63
- // filename (falling back to 3). Keeps zero-padding consistent post-shift.
64
- const prefixWidth = sorted.reduce((w, p) => {
65
- const match = /^(\d+)-/.exec(p.filename);
66
- return match ? Math.max(w, match[1]?.length ?? 3) : w;
67
- }, 3);
69
+ const prefixWidth = prefixWidthForPatches(sorted, requestedOrder);
68
70
  // Every existing patch at requestedOrder or later shifts up by one.
69
71
  for (const patch of sorted) {
70
72
  if (patch.order >= requestedOrder) {
@@ -81,6 +83,27 @@ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatch
81
83
  renameMap,
82
84
  };
83
85
  }
86
+ /**
87
+ * Computes an exact sparse placement plan for `--order <N>`. Unlike
88
+ * positional insertion, this never renumbers existing patches: the order
89
+ * must be unused, and policy validation decides whether the requested
90
+ * category/order is allowed.
91
+ */
92
+ export function computeExactPlacementPlan(manifestPatches, newPatchCategory, newPatchName, requestedOrder) {
93
+ if (!Number.isInteger(requestedOrder) || requestedOrder <= 0) {
94
+ throw new InvalidArgumentError(`--order must be a positive integer, got ${String(requestedOrder)}.`, '--order');
95
+ }
96
+ const occupied = manifestPatches.find((patch) => patch.order === requestedOrder);
97
+ if (occupied) {
98
+ throw new InvalidArgumentError(`--order ${String(requestedOrder)} is already occupied by ${occupied.filename}. ` +
99
+ 'Choose an unused order or use --before/--after for positional insertion.', '--order');
100
+ }
101
+ return {
102
+ insertionOrder: requestedOrder,
103
+ newFilename: buildFilenameForPlacement(newPatchCategory, newPatchName, requestedOrder, prefixWidthForPatches(manifestPatches, requestedOrder)),
104
+ renameMap: new Map(),
105
+ };
106
+ }
84
107
  /**
85
108
  * Resolves a placement plan from CLI flags against the current manifest.
86
109
  */
@@ -95,7 +118,7 @@ export async function resolvePlacementPlan(patchesDir, options, category, name)
95
118
  if (!Number.isInteger(options.order) || options.order <= 0) {
96
119
  throw new InvalidArgumentError(`--order must be a positive integer, got ${String(options.order)}.`, '--order');
97
120
  }
98
- targetOrder = options.order;
121
+ return computeExactPlacementPlan(existingPatches, category, name, options.order);
99
122
  }
100
123
  else if (options.before !== undefined) {
101
124
  const anchor = resolvePatchIdentifier(options.before, existingPatches);
@@ -202,6 +225,9 @@ export async function commitPlacementExport(input) {
202
225
  throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
203
226
  }
204
227
  const originalManifest = await loadPatchesManifest(input.patchesDir);
228
+ if (originalManifest !== null) {
229
+ assertPlacementPreservesReservedRanges(currentPlan, originalManifest.patches, input.config, input.category);
230
+ }
205
231
  if (input.config !== undefined) {
206
232
  const renamed = originalManifest !== null
207
233
  ? applyRenameMapToManifest(originalManifest, currentPlan.renameMap)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Policy-aware checks for export placement plans.
3
+ */
4
+ import type { PatchCategory, PatchMetadata } from '../types/commands/index.js';
5
+ import type { FireForgeConfig } from '../types/config.js';
6
+ export interface PlacementPolicyPlan {
7
+ insertionOrder: number;
8
+ renameMap: ReadonlyMap<string, {
9
+ newFilename: string;
10
+ newOrder: number;
11
+ }>;
12
+ }
13
+ /** Refuses positional export plans that would renumber exact reserved patches. */
14
+ export declare function assertPlacementPreservesReservedRanges(plan: PlacementPolicyPlan, manifestPatches: readonly PatchMetadata[], config: FireForgeConfig | undefined, category: PatchCategory): void;
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Policy-aware checks for export placement plans.
4
+ */
5
+ import { InvalidArgumentError } from '../errors/base.js';
6
+ function reservedRangeLabel(range) {
7
+ return `${String(range.from).padStart(3, '0')}-${String(range.to).padStart(3, '0')}`;
8
+ }
9
+ function findReservedRange(config, order) {
10
+ return (config.patchPolicy?.reservedRanges?.find((range) => order >= range.from && order <= range.to) ??
11
+ null);
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);
17
+ 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
+ }
29
+ }
30
+ return null;
31
+ }
32
+ /** Refuses positional export plans that would renumber exact reserved patches. */
33
+ export function assertPlacementPreservesReservedRanges(plan, manifestPatches, config, category) {
34
+ if (config?.patchPolicy === undefined || plan.renameMap.size === 0)
35
+ return;
36
+ const byFilename = new Map(manifestPatches.map((patch) => [patch.filename, patch]));
37
+ 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);
42
+ if (reserved === null)
43
+ 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');
52
+ }
53
+ }
54
+ //# sourceMappingURL=export-placement-policy.js.map
@@ -21,6 +21,7 @@ import { pickDefined } from '../utils/options.js';
21
21
  import { stripEnginePrefix } from '../utils/paths.js';
22
22
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
23
23
  import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
24
+ import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
24
25
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
25
26
  async function collectExportFiles(paths, files) {
26
27
  const collectedFiles = new Set();
@@ -191,8 +192,11 @@ export async function exportCommand(projectRoot, files, options) {
191
192
  throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
192
193
  }
193
194
  placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
194
- const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
195
195
  const currentManifest = await loadPatchesManifest(paths.patches);
196
+ if (currentManifest !== null) {
197
+ assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
198
+ }
199
+ const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
196
200
  const renamed = currentManifest !== null
197
201
  ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
198
202
  : buildProjectedManifest(null, []);
@@ -397,7 +401,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
397
401
  .option('--supersede', 'Allow superseding multiple existing patches')
398
402
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
399
403
  .option('--dry-run', 'Print the export plan (including supersede preview) without writing')
400
- .addOption(new Option('--order <N>', 'Place the new patch at this ordinal, shifting subsequent patches up').argParser((v) => parsePositiveIntegerFlag('--order', v)))
404
+ .addOption(new Option('--order <N>', 'Place the new patch at this exact unused order without renumbering existing patches').argParser((v) => parsePositiveIntegerFlag('--order', v)))
401
405
  .option('--before <anchor>', 'Place the new patch immediately before <anchor>')
402
406
  .option('--after <anchor>', 'Place the new patch immediately after <anchor>')
403
407
  .option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
@@ -4,6 +4,7 @@ export interface DryRunPlanInput {
4
4
  localized: boolean;
5
5
  register: boolean;
6
6
  composes: string[] | undefined;
7
+ stockAdditions?: string[];
7
8
  /**
8
9
  * Feature-scoped Fluent bundle the component participates in (the same
9
10
  * value that will be written to `furnace.json`'s `sharedFtl`). When set,
@@ -75,7 +75,7 @@ export function formatSuccessNote(args) {
75
75
  * `components/custom/` to match the wording of the real success note.
76
76
  */
77
77
  export function formatDryRunPlan(args) {
78
- const { componentName, localized, register, composes, sharedFtl, testStyle, description, binaryName, } = args;
78
+ const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, } = args;
79
79
  const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
80
  // A per-component .ftl is scaffolded only when the component does NOT
81
81
  // opt into a shared feature-scoped bundle. Mirrors writeComponentFiles.
@@ -92,6 +92,12 @@ export function formatDryRunPlan(args) {
92
92
  if (composes && composes.length > 0) {
93
93
  plan += `\n composes: ${composes.join(', ')}`;
94
94
  }
95
+ if (stockAdditions && stockAdditions.length > 0) {
96
+ plan += `\n\nWould add discovered stock to furnace.json:`;
97
+ for (const name of stockAdditions) {
98
+ plan += `\n ${name}`;
99
+ }
100
+ }
95
101
  if (sharedFtl) {
96
102
  plan += `\n sharedFtl: ${sharedFtl}`;
97
103
  }
@@ -3,4 +3,4 @@ import type { FurnaceConfig } from '../../types/furnace.js';
3
3
  /**
4
4
  * Validates a proposed custom component against the current furnace config.
5
5
  */
6
- export declare function validateCreateAgainstConfig(config: FurnaceConfig, componentName: string, allowPrefixMismatch: FurnaceCreateOptions['allowPrefixMismatch'], composes: string[] | undefined): void;
6
+ export declare function validateCreateAgainstConfig(config: FurnaceConfig, componentName: string, allowPrefixMismatch: FurnaceCreateOptions['allowPrefixMismatch'], composes: string[] | undefined, stockAdditions?: string[]): void;
@@ -11,11 +11,12 @@ function checkNameConflict(config, name) {
11
11
  }
12
12
  return undefined;
13
13
  }
14
- function validateComposesTargets(config, componentName, composes) {
14
+ function validateComposesTargets(config, componentName, composes, stockAdditions = []) {
15
15
  if (!composes || composes.length === 0)
16
16
  return;
17
17
  const known = new Set([
18
18
  ...config.stock,
19
+ ...stockAdditions,
19
20
  ...Object.keys(config.overrides),
20
21
  ...Object.keys(config.custom),
21
22
  ]);
@@ -42,7 +43,7 @@ function validateComposesTargets(config, componentName, composes) {
42
43
  /**
43
44
  * Validates a proposed custom component against the current furnace config.
44
45
  */
45
- export function validateCreateAgainstConfig(config, componentName, allowPrefixMismatch, composes) {
46
+ export function validateCreateAgainstConfig(config, componentName, allowPrefixMismatch, composes, stockAdditions = []) {
46
47
  const conflict = checkNameConflict(config, componentName);
47
48
  if (conflict) {
48
49
  throw new FurnaceError(conflict, componentName);
@@ -54,6 +55,6 @@ export function validateCreateAgainstConfig(config, componentName, allowPrefixMi
54
55
  `Use a prefixed name (e.g. "${config.componentPrefix}${componentName}"), update ` +
55
56
  '`componentPrefix` in furnace.json, or pass --allow-prefix-mismatch to create the component anyway.', 'name');
56
57
  }
57
- validateComposesTargets(config, componentName, composes);
58
+ validateComposesTargets(config, componentName, composes, stockAdditions);
58
59
  }
59
60
  //# sourceMappingURL=create-validation.js.map
@@ -7,7 +7,7 @@ import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
8
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
9
9
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
10
- import { isComponentInEngine } from '../../core/furnace-scanner.js';
10
+ import { isComponentInEngine, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
11
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
12
12
  import { registerTestManifest } from '../../core/manifest-register.js';
13
13
  import { validateSharedFtl } from '../../core/shared-ftl.js';
@@ -29,6 +29,26 @@ async function loadAuthoringFurnaceConfig(projectRoot) {
29
29
  }
30
30
  return createDefaultFurnaceConfig();
31
31
  }
32
+ function knownComponentSet(config) {
33
+ return new Set([
34
+ ...config.stock,
35
+ ...Object.keys(config.overrides),
36
+ ...Object.keys(config.custom),
37
+ ]);
38
+ }
39
+ async function resolveComposeStockAdditions(args) {
40
+ const { engineDir, config, componentName, composes } = args;
41
+ if (!composes || composes.length === 0)
42
+ return [];
43
+ const known = knownComponentSet(config);
44
+ const unresolved = composes.filter((tag) => tag !== componentName && !known.has(tag));
45
+ if (unresolved.length === 0 || !(await pathExists(engineDir)))
46
+ return [];
47
+ const scanPaths = config.scanPaths && config.scanPaths.length > 0 ? config.scanPaths : undefined;
48
+ const discovered = await scanWidgetsDirectory(engineDir, undefined, scanPaths);
49
+ const discoveredTags = new Set(discovered.map((component) => component.tagName));
50
+ return unresolved.filter((tag, index) => discoveredTags.has(tag) && unresolved.indexOf(tag) === index);
51
+ }
32
52
  /**
33
53
  * Validates a custom element tag name.
34
54
  * @returns Error message if invalid, undefined if valid
@@ -238,7 +258,13 @@ async function performCreateMutations(args) {
238
258
  let files;
239
259
  try {
240
260
  const freshConfig = await loadAuthoringFurnaceConfig(args.projectRoot);
241
- validateCreateAgainstConfig(freshConfig, args.componentName, args.allowPrefixMismatch, args.composes);
261
+ const freshStockAdditions = await resolveComposeStockAdditions({
262
+ engineDir: args.paths.engine,
263
+ config: freshConfig,
264
+ componentName: args.componentName,
265
+ composes: args.composes,
266
+ });
267
+ validateCreateAgainstConfig(freshConfig, args.componentName, args.allowPrefixMismatch, args.composes, freshStockAdditions);
242
268
  if (await pathExists(args.componentDir)) {
243
269
  throw new FurnaceError(`Directory already exists: components/custom/${args.componentName}`, args.componentName);
244
270
  }
@@ -259,6 +285,11 @@ async function performCreateMutations(args) {
259
285
  if (args.sharedFtl) {
260
286
  customEntry.sharedFtl = args.sharedFtl;
261
287
  }
288
+ for (const name of freshStockAdditions) {
289
+ if (!freshConfig.stock.includes(name)) {
290
+ freshConfig.stock.push(name);
291
+ }
292
+ }
262
293
  freshConfig.custom[args.componentName] = customEntry;
263
294
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
264
295
  await writeFurnaceConfig(args.projectRoot, freshConfig);
@@ -352,7 +383,13 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
352
383
  // furnace.json behind.
353
384
  const config = await loadAuthoringFurnaceConfig(projectRoot);
354
385
  const composes = options.compose;
355
- validateCreateAgainstConfig(config, componentName, options.allowPrefixMismatch, composes);
386
+ const stockAdditions = await resolveComposeStockAdditions({
387
+ engineDir: paths.engine,
388
+ config,
389
+ componentName,
390
+ composes,
391
+ });
392
+ validateCreateAgainstConfig(config, componentName, options.allowPrefixMismatch, composes, stockAdditions);
356
393
  // Check if it already exists in the engine source tree
357
394
  if (await pathExists(paths.engine)) {
358
395
  if (await isComponentInEngine(paths.engine, componentName)) {
@@ -407,6 +444,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
407
444
  localized,
408
445
  register,
409
446
  composes,
447
+ stockAdditions,
410
448
  // Spread rather than assign so the key is absent when sharedFtl is
411
449
  // undefined — the DryRunPlanInput type uses strict-optional shape.
412
450
  ...(sharedFtl !== undefined ? { sharedFtl } : {}),
@@ -46,6 +46,12 @@ export interface LintCommandOptions {
46
46
  * scope contracts are different.
47
47
  */
48
48
  perPatch?: boolean;
49
+ /**
50
+ * Maximum warning count tolerated before lint exits non-zero. Mirrors
51
+ * ESLint's `--max-warnings` shape for release gates that want advisory
52
+ * findings to become blocking without changing default CLI behavior.
53
+ */
54
+ maxWarnings?: number;
49
55
  }
50
56
  /**
51
57
  * Result of {@link applyAggregateLintIgnoreSuppression}.
@@ -224,6 +224,10 @@ export async function lintCommand(projectRoot, files, options = {}) {
224
224
  if (options.onlyIntroduced && !options.since) {
225
225
  throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
226
226
  }
227
+ if (options.maxWarnings !== undefined &&
228
+ (!Number.isInteger(options.maxWarnings) || options.maxWarnings < 0)) {
229
+ throw new GeneralError('--max-warnings must be a non-negative integer.');
230
+ }
227
231
  // `--per-patch` rescopes the diff from "aggregate engine state" to "each
228
232
  // patch's own filesAffected". Mixing in explicit file paths would produce
229
233
  // an ambiguous set — is the file list an additional filter, or does it
@@ -240,7 +244,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
240
244
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
241
245
  }
242
246
  if (options.perPatch) {
243
- await lintPerPatch(projectRoot, paths);
247
+ await lintPerPatch(projectRoot, paths, options);
244
248
  return;
245
249
  }
246
250
  // Load the config before resolving the diff so we can pass
@@ -367,6 +371,10 @@ export async function lintCommand(projectRoot, files, options = {}) {
367
371
  : '';
368
372
  throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
369
373
  }
374
+ if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
375
+ outro('Lint failed');
376
+ throw new GeneralError(`Patch lint found ${warnings.length} warning(s), exceeding --max-warnings ${options.maxWarnings}.`);
377
+ }
370
378
  // Notices are advisory and don't count as warnings — emitting "passed
371
379
  // with warnings" when only notices fired contradicts the preceding
372
380
  // `0 warning(s)` summary line and reads as a regression. Distinguish
@@ -398,7 +406,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
398
406
  * Sharing a loop would hide the distinction and force the caller to
399
407
  * decide semantics mid-function.
400
408
  */
401
- async function lintPerPatch(projectRoot, paths) {
409
+ async function lintPerPatch(projectRoot, paths, options = {}) {
402
410
  const manifest = await loadPatchesManifest(paths.patches);
403
411
  if (!manifest || manifest.patches.length === 0) {
404
412
  info('No patches in manifest — nothing to lint per-patch.');
@@ -483,6 +491,10 @@ async function lintPerPatch(projectRoot, paths) {
483
491
  outro('Lint failed');
484
492
  throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
485
493
  }
494
+ if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
495
+ outro('Lint failed');
496
+ throw new GeneralError(`Patch lint found ${warnings.length} warning(s) across ${linted} patch(es), exceeding --max-warnings ${options.maxWarnings}.`);
497
+ }
486
498
  if (warnings.length > 0) {
487
499
  outro('Lint passed with warnings');
488
500
  }
@@ -503,6 +515,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
503
515
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
504
516
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
505
517
  .option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
518
+ .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
506
519
  .action(withErrorHandling(async (paths, options) => {
507
520
  const lintOptions = {};
508
521
  if (options.since !== undefined) {
@@ -514,6 +527,13 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
514
527
  if (options.perPatch !== undefined) {
515
528
  lintOptions.perPatch = options.perPatch;
516
529
  }
530
+ if (options.maxWarnings !== undefined) {
531
+ const maxWarnings = Number(options.maxWarnings);
532
+ if (!Number.isInteger(maxWarnings) || maxWarnings < 0) {
533
+ throw new GeneralError('--max-warnings must be a non-negative integer.');
534
+ }
535
+ lintOptions.maxWarnings = maxWarnings;
536
+ }
517
537
  await lintCommand(getProjectRoot(), paths, lintOptions);
518
538
  }));
519
539
  }
@@ -7,7 +7,7 @@ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/g
7
7
  import { measureTokenCoverage } from '../core/token-coverage.js';
8
8
  import { getTokensCssPath } from '../core/token-manager.js';
9
9
  import { GeneralError } from '../errors/base.js';
10
- import { pathExists } from '../utils/fs.js';
10
+ import { pathExists, readText } from '../utils/fs.js';
11
11
  import { info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  /**
13
13
  * Measures design token coverage across modified CSS files.
@@ -31,6 +31,9 @@ export async function tokenCoverageCommand(projectRoot) {
31
31
  // and the file-extension filter could not see the .css inside.
32
32
  const rawStatus = await getWorkingTreeStatus(paths.engine);
33
33
  const expandedStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
34
+ const statusTokenCssFiles = expandedStatus
35
+ .filter((f) => f.file === tokensCssPath)
36
+ .map((f) => f.file);
34
37
  const statusCssFiles = expandedStatus
35
38
  .filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
36
39
  .map((f) => f.file);
@@ -44,11 +47,27 @@ export async function tokenCoverageCommand(projectRoot) {
44
47
  // De-dupe so a file that is both a custom deploy target AND modified is
45
48
  // scanned exactly once.
46
49
  const cssFiles = [...new Set([...statusCssFiles, ...furnaceCssFiles])];
47
- if (cssFiles.length === 0) {
50
+ const tokenSourceFiles = [...new Set(statusTokenCssFiles)];
51
+ if (cssFiles.length === 0 && tokenSourceFiles.length === 0) {
48
52
  info('No modified CSS files');
49
53
  outro('Nothing to measure');
50
54
  return;
51
55
  }
56
+ const tokenPrefix = await resolveTokenPrefix(projectRoot, config.binaryName);
57
+ const tokenSourceResults = await validateTokenSourceFiles(paths.engine, tokenSourceFiles, tokenPrefix);
58
+ for (const result of tokenSourceResults) {
59
+ const detail = `${result.tokenDeclarations} token declaration${result.tokenDeclarations === 1 ? '' : 's'}`;
60
+ if (result.unknownDeclarations.length === 0) {
61
+ success(`${result.file} token source valid (${detail})`);
62
+ }
63
+ else {
64
+ warn(`${result.file} token source has ${result.unknownDeclarations.length} declaration${result.unknownDeclarations.length === 1 ? '' : 's'} outside prefix ${tokenPrefix}: ${result.unknownDeclarations.join(', ')}`);
65
+ }
66
+ }
67
+ if (cssFiles.length === 0) {
68
+ outro(`${tokenSourceResults.length} token source file${tokenSourceResults.length === 1 ? '' : 's'} validated`);
69
+ return;
70
+ }
52
71
  const report = await measureTokenCoverage(paths.engine, cssFiles);
53
72
  // Per-file breakdown
54
73
  for (const entry of report.files) {
@@ -73,6 +92,46 @@ export async function tokenCoverageCommand(projectRoot) {
73
92
  }
74
93
  outro(`${report.filesScanned} CSS file${report.filesScanned === 1 ? '' : 's'} scanned`);
75
94
  }
95
+ async function resolveTokenPrefix(projectRoot, binaryName) {
96
+ try {
97
+ const furnaceConfig = await loadFurnaceConfig(projectRoot);
98
+ if (furnaceConfig.tokenPrefix) {
99
+ return furnaceConfig.tokenPrefix;
100
+ }
101
+ }
102
+ catch {
103
+ // Fall through to the convention used by furnace init. A broken
104
+ // furnace.json is already surfaced by collectFurnaceCustomCssFiles.
105
+ }
106
+ return `--${binaryName}-`;
107
+ }
108
+ async function validateTokenSourceFiles(engineDir, tokenSourceFiles, tokenPrefix) {
109
+ const results = [];
110
+ for (const file of tokenSourceFiles) {
111
+ const filePath = join(engineDir, file);
112
+ if (!(await pathExists(filePath)))
113
+ continue;
114
+ const css = (await readText(filePath)).replace(/\/\*[\s\S]*?\*\//g, '');
115
+ const declarations = new Set();
116
+ const declarationPattern = /(^|[;{\s])(--[\w-]+)\s*:/g;
117
+ let match;
118
+ while ((match = declarationPattern.exec(css)) !== null) {
119
+ const declaration = match[2];
120
+ if (declaration)
121
+ declarations.add(declaration);
122
+ }
123
+ const tokenDeclarations = [...declarations].filter((name) => name.startsWith(tokenPrefix));
124
+ const unknownDeclarations = [...declarations]
125
+ .filter((name) => !name.startsWith(tokenPrefix))
126
+ .sort();
127
+ results.push({
128
+ file,
129
+ tokenDeclarations: tokenDeclarations.length,
130
+ unknownDeclarations,
131
+ });
132
+ }
133
+ return results;
134
+ }
76
135
  /**
77
136
  * Returns engine-relative `.css` paths deployed by every Furnace custom
78
137
  * component registered in `furnace.json`. Only files that actually exist
@@ -11,7 +11,7 @@ import type { FireForgeConfig } from '../types/config.js';
11
11
  /** Default patch filename contract used when a policy omits `filenamePattern`. */
12
12
  export declare const DEFAULT_PATCH_POLICY_FILENAME_PATTERN = "^(?<order>\\d{3})-(?<category>[a-z][a-z0-9-]*)-(?<slug>[a-z0-9-]+)\\.patch$";
13
13
  /** Stable issue codes returned by patch policy evaluation. */
14
- export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
14
+ export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'order-collision' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
15
15
  /** A single patch policy validation finding. */
16
16
  export interface PatchPolicyIssue {
17
17
  code: PatchPolicyIssueCode;
@@ -260,6 +260,28 @@ function evaluateGaps(cfg, patches, severity) {
260
260
  }
261
261
  return issues;
262
262
  }
263
+ function evaluateOrderCollisions(patches, severity) {
264
+ const byOrder = new Map();
265
+ for (const patch of patches) {
266
+ const matches = byOrder.get(patch.order) ?? [];
267
+ matches.push(patch);
268
+ byOrder.set(patch.order, matches);
269
+ }
270
+ const issues = [];
271
+ for (const [order, matches] of [...byOrder.entries()].sort((a, b) => a[0] - b[0])) {
272
+ if (matches.length <= 1)
273
+ continue;
274
+ const filenames = matches.map((patch) => patch.filename).sort((a, b) => a.localeCompare(b));
275
+ issues.push({
276
+ code: 'order-collision',
277
+ filename: String(order).padStart(3, '0'),
278
+ severity,
279
+ message: `patchPolicy requires unique numeric orders; order ${String(order).padStart(3, '0')} ` +
280
+ `is used by: ${filenames.join(', ')}.`,
281
+ });
282
+ }
283
+ return issues;
284
+ }
263
285
  /** Evaluates an entire patch manifest against the configured policy. */
264
286
  export function evaluatePatchPolicy(config, manifest) {
265
287
  const cfg = policy(config);
@@ -267,6 +289,7 @@ export function evaluatePatchPolicy(config, manifest) {
267
289
  return [];
268
290
  const severity = issueSeverity(config);
269
291
  const issues = manifest.patches.flatMap((patch) => evaluatePatchMetadata(cfg, patch, severity));
292
+ issues.push(...evaluateOrderCollisions(manifest.patches, severity));
270
293
  issues.push(...evaluateGaps(cfg, manifest.patches, severity));
271
294
  return issues;
272
295
  }
@@ -70,7 +70,7 @@ export interface ExportOptions {
70
70
  * be superseded and which files caused the coverage.
71
71
  */
72
72
  dryRun?: boolean;
73
- /** Place the new patch at a specific ordinal, shifting subsequent patches. */
73
+ /** Place the new patch at this exact unused order without renumbering existing patches. */
74
74
  order?: number;
75
75
  /** Place the new patch immediately before the named patch. */
76
76
  before?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",