@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 +3 -1
- package/README.md +32 -9
- package/dist/src/commands/download.js +1 -1
- package/dist/src/commands/export-flow.d.ts +7 -0
- package/dist/src/commands/export-flow.js +33 -7
- package/dist/src/commands/export-placement-policy.d.ts +14 -0
- package/dist/src/commands/export-placement-policy.js +54 -0
- package/dist/src/commands/export.js +6 -2
- package/dist/src/commands/furnace/create-dry-run.d.ts +1 -0
- package/dist/src/commands/furnace/create-dry-run.js +7 -1
- package/dist/src/commands/furnace/create-validation.d.ts +1 -1
- package/dist/src/commands/furnace/create-validation.js +4 -3
- package/dist/src/commands/furnace/create.js +41 -3
- package/dist/src/commands/lint.d.ts +6 -0
- package/dist/src/commands/lint.js +22 -2
- package/dist/src/commands/token-coverage.js +61 -2
- package/dist/src/core/patch-policy.d.ts +1 -1
- package/dist/src/core/patch-policy.js +23 -0
- package/dist/src/types/commands/options.d.ts +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
195
|
-
fireforge export browser/base/content/browser.js --order
|
|
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
|
|
272
|
+
info('Indexing downloaded source into git (one-time; typically 3–5 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|