@hominis/fireforge 0.44.2 → 0.44.3
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 +6 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/token.js +3 -1
- package/dist/src/core/token-variant.d.ts +12 -2
- package/dist/src/core/token-variant.js +106 -17
- package/docs/README.md +35 -0
- package/docs/configuration.md +116 -0
- package/docs/exit-codes.md +63 -0
- package/docs/furnace.md +57 -0
- package/docs/lifecycle-invariants.md +309 -0
- package/docs/machine-output.md +94 -0
- package/docs/patch-workflow.md +119 -0
- package/docs/run-logs.md +60 -0
- package/docs/testing.md +92 -0
- package/docs/verification-trees.md +48 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -149,6 +149,9 @@
|
|
|
149
149
|
- **`fireforge test --doctor` printed the same preflight line three times** on a terminal: a 2026-04-24 fix added an emission for non-TTY capture without removing the two it superseded. The raw writes are gated on non-TTY, which is where the capture requirement applies; the clack framing stays for terminals.
|
|
150
150
|
- **All seven** `--wait-lock` commands that take the patch-directory lock now honour it: `patch tier`, `patch lint-ignore`, `patch staged-dependency`, `export-all`, `resolve`, `doctor` and `rebase`. Each routes through a different lock entry point, so the budget is threaded through `updatePatchMetadata`, `mutatePatchMetadata`, `commitExportedPatch`, `updatePatchAndMetadata`, `rebuildPatchesManifest` and the rebase patch loop.
|
|
151
151
|
- `status --json` outside a project wrote a clack-styled block to **stdout** and exited 2, because the config error is raised before the command body engages machine mode. The boundary now engages it from the flag, so stdout carries exactly one JSON envelope and the human guidance goes to stderr — rule 3 of `docs/machine-output.md`, which was aspirational until now. A payload that already owns stdout (`status --json --fail-on`) suppresses the envelope, so the "exactly one document" half of the contract holds too.
|
|
152
|
+
- **`--variant` refused selectors the matcher had just learned to find.** 0.44.2 widened `rootAttributeSelector` to consume consecutive `[…]` groups and to stop at a pseudo-class tail, so `:root[data-skin="humanist"][data-theme="dark"]` and `:root[data-theme="light"]:not([data-private])` became blocks FireForge can locate and write through. The CLI validator did not move with it: its own regex still accepted exactly one `[attr]` fragment, so `token add --variant '[data-skin=humanist]:not([data-private])'` was refused as "must be a single attribute selector" — a block the tool can FIND was one it would not let you NAME, and the private-exclusion selectors the widening exists for are precisely that shape. Downstream the values were maintained by hand in the allowlisted block instead.
|
|
153
|
+
The validator now runs the MATCHER'S parse and requires it to consume the whole fragment, rather than carrying a second grammar that can drift from the first again. Character safety stays a separate check, because that parse finds bracket boundaries and validates no characters at all — it is applied one bracket group at a time rather than as a repeated pattern, which is the shape that backtracks (`js/polynomial-redos`), and a linearity test pins that alongside the matcher's own. Normalization to `="value"` now covers every fragment in a run and the `:not(…)` argument, not just a trailing one.
|
|
154
|
+
`findVariantBlock` had to move with it: it compared a request against a block's ATTRIBUTE run only, so a variant carrying its own qualifier would have matched nothing and spliced a fresh duplicate block on every add. Request and candidate go through the same parse and are compared tail-to-tail. An UNqualified request still matches a qualified block — that is the 0.44.2 behaviour `variantBlockQualifier` exists to report out loud, since silently skipping the block is the worse failure — but a request that names a qualifier means it.
|
|
152
155
|
- `tree list --json` gained the failure envelope `status --json` already emitted, so a scripted consumer no longer gets a parseable refusal from one and a bare non-zero exit from the other. The contract is written down in `docs/machine-output.md`.
|
|
153
156
|
|
|
154
157
|
### More correctness
|
|
@@ -180,6 +183,8 @@ On the test side: path expectations and mock keys are rendered through a new `na
|
|
|
180
183
|
- **`test.test.ts` was 2,943 lines under one `describe`.** What made it unsplittable was its own 190-line `vi.mock` header: any split either duplicated the header into each new file or did not happen. The header moved to `test-command-mocks.ts` as one factory per mocked module, called from inside each file's own `vi.mock` callback — hoisting is unaffected and every suite still gets fresh `vi.fn()`s rather than shared call history. The file is now six, largest 952 lines, split on the seams the tests already formed: discovery and failure rewriting, staleness and packaging coverage, xpcshell/mochitest hints, Marionette forwarding, harness resilience, verdict contract.
|
|
181
184
|
The split exposed a real test-isolation bug: `keeps the Marionette preflight for xpcshell-only --doctor runs` had never set a preflight result, and passed only because an `it` roughly 1,000 lines earlier had left one on the shared mock with `mockResolvedValue`. It sets its own now. Coverage of `src/commands/test.ts` is byte-identical before and after the split (95.12 / 94.28 / 100 / 86.23), which is the check that the mock rewiring did not quietly stop exercising anything.
|
|
182
185
|
- Five rollback tests moved from asserting a module-level spy to asserting the persisted pending-repair marker. The spy stopped working when the sequence moved into `completeJournalRollback` (its call to the recorder is intra-module), and the outcome is the better assertion anyway.
|
|
186
|
+
- **Fourteen verdict assertions passed only because writing to a filesystem root fails.** The command suites pass `/project` as the project root and assert the `FIREFORGE-VERDICT:` line by exact string — which holds only while no run log is open, since the line carries an open log's path as a ` log=<path>` key. On POSIX `mkdir('/project/.fireforge/logs')` fails at the root, the best-effort open degrades to "no log", and the assertions passed for a reason none of them stated. On Windows the same path resolves against the CURRENT DRIVE, the mkdir succeeds, and all fourteen failed — while the run also created real `C:\project\.fireforge\logs` directories on the runner. `createRunLogMock()` joins the two factories in `module-mocks.ts` and the five suites state the precondition instead of inheriting it from a permission error. Because mocking the module away removes the suffix's only coverage, `test-verdict.test.ts` gains the opposite assertion: with a log open, the path rides the line, once, as the run's last stdout write.
|
|
187
|
+
- **A lock-wait test decided its result on a `readdir`.** `a wait whose queue position keeps improving > survives a drain longer than its budget` runs on real timers and a real filesystem, and gave each probe about 20 ms of slack inside a 100 ms budget; it also retired its simulated queue with un-awaited `rm` calls, so a retirement could land after the probe meant to observe it and a release could leave `mkdir` seeing `EEXIST` past the intended free. On a Windows runner the second probe missed the deadline, no extension was ever granted, and the wait expired still three from the head — the exact refusal the test exists to prove does NOT happen. The retirement and release are synchronous now (the callback returns `void` and cannot await), and the constants are scaled to ~100 ms of slack per probe while keeping the two invariants that fix them: the budget must span two probes, and the drain must outlast the budget or the sibling test would acquire instead of expiring. No production change — `withFileLock` checking its deadline before probing is correct, since a wait must not extend itself on a probe taken after it expired.
|
|
183
188
|
- `git-readonly-index.test.ts` carried a latent racy-clean flake: it edited `const b = 2;` to `const b = 3;` — the same byte length, within the same second as the index entry — without invalidating the stat cache, so git could report the file unmodified. Its sibling test already guarded for this.
|
|
184
189
|
|
|
185
190
|
### Documentation
|
|
@@ -187,6 +192,7 @@ On the test side: path expectations and mock keys are rendered through a new `na
|
|
|
187
192
|
- **README.md had become the reference manual.** Its "Regular Workflow" section had grown into fourteen paragraphs of flag semantics — scan manifests, shard behaviour, packaging-coverage anchors, tree CoW, shim configuration — read by nobody starting out and found by nobody looking for a specific rule, because prose in a getting-started page has no index. The reference material moves to `docs/`, split by the thing a reader is actually doing: `patch-workflow.md`, `testing.md`, `furnace.md`, `verification-trees.md`, `configuration.md`, plus `docs/README.md` as the index. README keeps installation, the loop's shape, rebasing, Furnace's premise, the roadmap and the licence — the parts that are project statement rather than manual — and links out for the rest.
|
|
188
193
|
- **`docs/run-logs.md`** states the `.fireforge/logs/` contract that previously existed only as a table cell in `machine-output.md`: raw bytes rather than the echo filter's form, opened before any preflight, retained 20 deep per command kind, best-effort throughout, and the tee sitting at the two mach capture funnels rather than at the commands.
|
|
189
194
|
- Two documents had gone stale against the code. `machine-output.md`'s surface table omitted `status --raw`, which engages the same stdout discipline through the same argv check in `cli.ts` — a machine surface to which rules 3 and 4 apply and rules 1 and 2 do not — and omitted `build`'s direct log announcement, which exists precisely because `build` prints no verdict line to carry a `log=` key. `lifecycle-invariants.md` still described `--wait-lock` as the engine-session lock's budget alone, which stopped being true when the budget was threaded into the furnace lock as well; both lock rows now state the resolution order (`lockTimeoutMs` → `FIREFORGE_WAIT_LOCK` → the 30 s default) and which mutators each covers.
|
|
195
|
+
- **The package did not ship the `docs/` tree its own README links to.** The `files` allowlist was `dist/`, `templates/` and the three root markdown files, so `node_modules/@hominis/fireforge/docs` did not exist and all eleven relative `docs/…` links dead-ended for a consumer — against a doctrine that names the INSTALLED package as the authority, precisely so an agent never has to leave its repo to answer a tool question. The links resolved on GitHub and on the registry page, which rewrites them, which is why nothing caught it. `docs/` is now published (~100 KB of markdown against a package that already ships `dist/`), and `wrapper-smoke.test.ts` asserts the tree is both packed and present after install, the way it already did for `templates/` — so a future `files` edit that drops it fails the gate rather than the reader.
|
|
190
196
|
- `FIREFORGE_WAIT_LOCK` and the three other environment variables FireForge reads were documented only in help text and JSDoc. They are tabulated in `configuration.md` with their defaults and their behaviour on a malformed value.
|
|
191
197
|
|
|
192
198
|
## 0.43.0
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.44.
|
|
4
|
-
"commit": "
|
|
5
|
-
"shortCommit": "
|
|
3
|
+
"version": "0.44.3",
|
|
4
|
+
"commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
|
|
5
|
+
"shortCommit": "760172104f6b",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-08-
|
|
8
|
+
"buildTime": "2026-08-28T15:34:58.794Z"
|
|
9
9
|
}
|
|
@@ -180,7 +180,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
180
180
|
.option('--description <desc>', 'Comment description for the CSS file')
|
|
181
181
|
.option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
|
|
182
182
|
.option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
|
|
183
|
-
"(e.g. '[data-skin=precision]'
|
|
183
|
+
"(e.g. '[data-skin=precision]', '[data-private]', a run such as " +
|
|
184
|
+
"'[data-skin=precision][data-theme=dark]', or one with a pseudo-class tail such as " +
|
|
185
|
+
"'[data-skin=precision]:not([data-private])'); creates or updates the block. " +
|
|
184
186
|
'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
|
|
185
187
|
'a new category and its variants, add the BASE token first (with --create-category), ' +
|
|
186
188
|
'then add each variant with --variant.')
|
|
@@ -23,10 +23,20 @@ export type VariantValidation = {
|
|
|
23
23
|
reason: string;
|
|
24
24
|
};
|
|
25
25
|
/**
|
|
26
|
-
* Validates a `--variant`
|
|
27
|
-
*
|
|
26
|
+
* Validates a `--variant` selector fragment and normalizes any `=value`
|
|
27
|
+
* form to the double-quoted `="value"` shape (Mozilla convention).
|
|
28
28
|
* Boolean-attribute fragments are returned unchanged.
|
|
29
29
|
*
|
|
30
|
+
* Structure is decided by {@link rootAttributeSelector} — the matcher's own
|
|
31
|
+
* parse — rather than by a second regex. That is the point: 0.44.2 widened
|
|
32
|
+
* the matcher to consume consecutive `[…]` groups and stop at a pseudo-class
|
|
33
|
+
* tail, and the validator did not move with it, so
|
|
34
|
+
* `:root[data-skin="humanist"]:not([data-private])` was a block the tool
|
|
35
|
+
* could FIND but would not let you NAME. Running both halves off one parse
|
|
36
|
+
* is what stops them drifting apart again. Character-level safety stays a
|
|
37
|
+
* separate, per-group check, because the matcher finds bracket boundaries
|
|
38
|
+
* and validates no characters at all.
|
|
39
|
+
*
|
|
30
40
|
* @param raw - Raw `--variant` value from the CLI / programmatic caller.
|
|
31
41
|
*/
|
|
32
42
|
export declare function validateVariantSelector(raw: unknown): VariantValidation;
|
|
@@ -12,36 +12,113 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { findBlockCloseIndex, stripBlockCommentsInLines } from './token-dark-mode.js';
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* ONE attribute selector fragment: `[data-private]` (boolean attribute) or
|
|
16
|
+
* `[data-skin=precision]` / `[data-skin="precision"]` (attribute with
|
|
17
|
+
* value). The name and value halves are restricted to identifier-safe
|
|
18
18
|
* characters so the fragment can be spliced into CSS verbatim without
|
|
19
19
|
* escaping concerns.
|
|
20
|
+
*
|
|
21
|
+
* A multi-fragment variant is checked one group at a time (see
|
|
22
|
+
* {@link splitAttributeGroups}) rather than by wrapping this in a `+`: the
|
|
23
|
+
* repeated form is the shape CodeQL flags as `js/polynomial-redos`, and the
|
|
24
|
+
* split the matcher already performs is available for free.
|
|
25
|
+
*/
|
|
26
|
+
const ATTRIBUTE_FRAGMENT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
|
|
27
|
+
/**
|
|
28
|
+
* A pseudo-class tail: `:not(…)`, `:hover`, and friends. The parenthesised
|
|
29
|
+
* argument is captured rather than validated here — it is a fragment run of
|
|
30
|
+
* its own and goes through the same per-group check.
|
|
20
31
|
*/
|
|
21
|
-
const
|
|
32
|
+
const QUALIFIER_PATTERN = /^:[a-zA-Z][a-zA-Z0-9-]*(?:\(([^()]*)\))?$/;
|
|
22
33
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
34
|
+
* Splits a run of consecutive attribute fragments into its groups, or
|
|
35
|
+
* returns `null` when the run is not exactly that — a group that does not
|
|
36
|
+
* open with `[`, an unclosed group, an empty run, or a group whose name or
|
|
37
|
+
* value is not identifier-safe.
|
|
38
|
+
*
|
|
39
|
+
* @param run - The `[…][…]` text with no `:root` prefix
|
|
40
|
+
* @returns The individual fragments, or null when the run is not valid
|
|
41
|
+
*/
|
|
42
|
+
function splitAttributeGroups(run) {
|
|
43
|
+
const groups = [];
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
while (cursor < run.length) {
|
|
46
|
+
if (run[cursor] !== '[')
|
|
47
|
+
return null;
|
|
48
|
+
const close = run.indexOf(']', cursor);
|
|
49
|
+
if (close === -1)
|
|
50
|
+
return null;
|
|
51
|
+
const group = run.slice(cursor, close + 1);
|
|
52
|
+
if (!ATTRIBUTE_FRAGMENT_PATTERN.test(group))
|
|
53
|
+
return null;
|
|
54
|
+
groups.push(group);
|
|
55
|
+
cursor = close + 1;
|
|
56
|
+
}
|
|
57
|
+
return groups.length > 0 ? groups : null;
|
|
58
|
+
}
|
|
59
|
+
/** Rewrites `=value` / `='value'` to the double-quoted Mozilla convention. */
|
|
60
|
+
function normalizeAttributeFragment(group) {
|
|
61
|
+
return group
|
|
62
|
+
.replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
|
|
63
|
+
.replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Validates a `--variant` selector fragment and normalizes any `=value`
|
|
67
|
+
* form to the double-quoted `="value"` shape (Mozilla convention).
|
|
25
68
|
* Boolean-attribute fragments are returned unchanged.
|
|
26
69
|
*
|
|
70
|
+
* Structure is decided by {@link rootAttributeSelector} — the matcher's own
|
|
71
|
+
* parse — rather than by a second regex. That is the point: 0.44.2 widened
|
|
72
|
+
* the matcher to consume consecutive `[…]` groups and stop at a pseudo-class
|
|
73
|
+
* tail, and the validator did not move with it, so
|
|
74
|
+
* `:root[data-skin="humanist"]:not([data-private])` was a block the tool
|
|
75
|
+
* could FIND but would not let you NAME. Running both halves off one parse
|
|
76
|
+
* is what stops them drifting apart again. Character-level safety stays a
|
|
77
|
+
* separate, per-group check, because the matcher finds bracket boundaries
|
|
78
|
+
* and validates no characters at all.
|
|
79
|
+
*
|
|
27
80
|
* @param raw - Raw `--variant` value from the CLI / programmatic caller.
|
|
28
81
|
*/
|
|
29
82
|
export function validateVariantSelector(raw) {
|
|
30
83
|
if (typeof raw !== 'string') {
|
|
31
84
|
return { ok: false, reason: 'must be a string when set' };
|
|
32
85
|
}
|
|
86
|
+
const reject = {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'must be one or more attribute selectors like [data-private], [data-skin=precision] ' +
|
|
89
|
+
'or [data-skin=precision][data-theme=dark], optionally followed by a pseudo-class ' +
|
|
90
|
+
'such as :not([data-private]) (identifier-safe attribute names and values only)',
|
|
91
|
+
};
|
|
33
92
|
const value = raw.trim();
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
93
|
+
if (value === '')
|
|
94
|
+
return reject;
|
|
95
|
+
const parts = rootAttributeSelector(`:root${value} {`);
|
|
96
|
+
// The parse must have consumed the WHOLE fragment: anything it declined to
|
|
97
|
+
// read (a stray space, a trailing combinator) is text the matcher would
|
|
98
|
+
// not see either, so accepting it would author a block nothing can find.
|
|
99
|
+
if (parts === null || `${parts.attributes}${parts.qualifier}` !== `:root${value}`)
|
|
100
|
+
return reject;
|
|
101
|
+
const groups = splitAttributeGroups(parts.attributes.slice(':root'.length));
|
|
102
|
+
if (groups === null)
|
|
103
|
+
return reject;
|
|
104
|
+
let qualifier = '';
|
|
105
|
+
if (parts.qualifier !== '') {
|
|
106
|
+
const match = QUALIFIER_PATTERN.exec(parts.qualifier);
|
|
107
|
+
if (match === null)
|
|
108
|
+
return reject;
|
|
109
|
+
const inner = match[1];
|
|
110
|
+
if (inner === undefined) {
|
|
111
|
+
qualifier = parts.qualifier;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const innerGroups = splitAttributeGroups(inner);
|
|
115
|
+
if (innerGroups === null)
|
|
116
|
+
return reject;
|
|
117
|
+
const name = parts.qualifier.slice(0, parts.qualifier.indexOf('('));
|
|
118
|
+
qualifier = `${name}(${innerGroups.map(normalizeAttributeFragment).join('')})`;
|
|
119
|
+
}
|
|
40
120
|
}
|
|
41
|
-
|
|
42
|
-
.replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
|
|
43
|
-
.replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
|
|
44
|
-
return { ok: true, value: normalized };
|
|
121
|
+
return { ok: true, value: `${groups.map(normalizeAttributeFragment).join('')}${qualifier}` };
|
|
45
122
|
}
|
|
46
123
|
/**
|
|
47
124
|
* Reduces a `:root<attr>` selector to a quote- and whitespace-insensitive
|
|
@@ -105,12 +182,24 @@ function rootAttributeSelector(line) {
|
|
|
105
182
|
*/
|
|
106
183
|
function findVariantBlock(lines, variant) {
|
|
107
184
|
const stripped = stripBlockCommentsInLines(lines);
|
|
108
|
-
|
|
185
|
+
// The request goes through the same parse as the candidate lines, so a
|
|
186
|
+
// variant carrying its own `:not(…)` tail is compared tail-to-tail rather
|
|
187
|
+
// than being folded into the attribute run — where it would match nothing
|
|
188
|
+
// and every add would splice a fresh duplicate block.
|
|
189
|
+
const requested = rootAttributeSelector(`:root${variant} {`);
|
|
190
|
+
const want = canonicalSelector(requested?.attributes ?? `:root${variant}`);
|
|
191
|
+
const wantQualifier = canonicalSelector(requested?.qualifier ?? '');
|
|
109
192
|
for (let i = 0; i < stripped.length; i++) {
|
|
110
193
|
const line = stripped[i] ?? '';
|
|
111
194
|
const selector = rootAttributeSelector(line);
|
|
112
195
|
if (selector === null || canonicalSelector(selector.attributes) !== want)
|
|
113
196
|
continue;
|
|
197
|
+
// An UNqualified request still matches a qualified block: that is the
|
|
198
|
+
// 0.44.2 behaviour `variantBlockQualifier` exists to report, since
|
|
199
|
+
// silently skipping the block is the worse failure. A request that names
|
|
200
|
+
// a qualifier, though, means it — so it must match.
|
|
201
|
+
if (wantQualifier !== '' && canonicalSelector(selector.qualifier) !== wantQualifier)
|
|
202
|
+
continue;
|
|
114
203
|
let openLine = /\{/.test(line) ? i : -1;
|
|
115
204
|
if (openLine === -1) {
|
|
116
205
|
for (let j = i + 1; j < stripped.length; j++) {
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: EUPL-1.2 -->
|
|
2
|
+
|
|
3
|
+
# FireForge documentation
|
|
4
|
+
|
|
5
|
+
[`../README.md`](../README.md) is the getting-started page. These files are
|
|
6
|
+
the reference material behind it. `fireforge --help` (and
|
|
7
|
+
`fireforge <command> --help`) stays the authority on flags; the docs cover
|
|
8
|
+
what a flag list cannot state.
|
|
9
|
+
|
|
10
|
+
## Using FireForge
|
|
11
|
+
|
|
12
|
+
| File | Covers |
|
|
13
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
14
|
+
| [patch-workflow.md](patch-workflow.md) | Export, re-export, deletions, queue maintenance, per-patch lint, CI gating, rebasing |
|
|
15
|
+
| [testing.md](testing.md) | `fireforge test`: scope, modes, build freshness, the verdict line, verbosity |
|
|
16
|
+
| [furnace.md](furnace.md) | Components: overrides, shared CSS fragments, typed cross-module imports |
|
|
17
|
+
| [verification-trees.md](verification-trees.md) | CoW snapshots for concurrent read-mostly verification |
|
|
18
|
+
| [configuration.md](configuration.md) | `fireforge.json` settings, design tokens, lock waits, environment variables |
|
|
19
|
+
|
|
20
|
+
## Contracts for scripts and CI
|
|
21
|
+
|
|
22
|
+
| File | Covers |
|
|
23
|
+
| -------------------------------------- | --------------------------------------------------------------------------- |
|
|
24
|
+
| [exit-codes.md](exit-codes.md) | Every exit code, which error class produces it, and how CI should branch |
|
|
25
|
+
| [machine-output.md](machine-output.md) | The `--json` / `--raw` / verdict-line output contract and its versioning |
|
|
26
|
+
| [run-logs.md](run-logs.md) | `.fireforge/logs/`: what `test` and `build` write, and what survives a pipe |
|
|
27
|
+
|
|
28
|
+
## Internals
|
|
29
|
+
|
|
30
|
+
| File | Covers |
|
|
31
|
+
| -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
32
|
+
| [lifecycle-invariants.md](lifecycle-invariants.md) | Locking, rollback, signal handling, and the decision table for new mutations |
|
|
33
|
+
|
|
34
|
+
Release history and the reasoning behind individual changes live in
|
|
35
|
+
[`../CHANGELOG.md`](../CHANGELOG.md).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: EUPL-1.2 -->
|
|
2
|
+
|
|
3
|
+
# Configuration
|
|
4
|
+
|
|
5
|
+
`fireforge.json` at the project root is the project's configuration;
|
|
6
|
+
`furnace.json` holds component configuration. This file covers the settings
|
|
7
|
+
whose behaviour needs more than a schema line, plus the environment variables
|
|
8
|
+
FireForge reads.
|
|
9
|
+
|
|
10
|
+
## External toolchains
|
|
11
|
+
|
|
12
|
+
Projects with generated asset prerequisites can declare opt-in
|
|
13
|
+
`externalToolchains`. `fireforge doctor` reports missing **required** tools as
|
|
14
|
+
errors and missing **optional** tools as warnings.
|
|
15
|
+
|
|
16
|
+
Seasonal branding on macOS, for example, can declare Apple's Icon Composer
|
|
17
|
+
tool at
|
|
18
|
+
`/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool`,
|
|
19
|
+
plus `actool` via `xcrun`, `sips`, and `iconutil`.
|
|
20
|
+
|
|
21
|
+
## `patchLint`
|
|
22
|
+
|
|
23
|
+
Per-patch lint type-checks plain Firefox JS against a built-in Firefox-globals
|
|
24
|
+
shim that tracks upstream WebIDL additions per release.
|
|
25
|
+
|
|
26
|
+
- `patchLint.checkJsExtraShim` adds members to the structured shim globals via
|
|
27
|
+
TypeScript interface merging, e.g.
|
|
28
|
+
`interface ChromeUtilsShim { newApi(): any }`.
|
|
29
|
+
- `patchLint.checkJsTestFiles: true` extends the pass to patch-owned test
|
|
30
|
+
`.js` files (`browser_*` / `test_*` / `xpcshell_*` and `/test/` paths). Each
|
|
31
|
+
is checked as its own small script-scope program against a loose built-in
|
|
32
|
+
harness shim.
|
|
33
|
+
- `patchLint.checkJsTestShim` points at a project `.d.ts` whose typed
|
|
34
|
+
declarations (e.g. a real `TestUtils` interface) override the loose
|
|
35
|
+
baseline, so calls to nonexistent harness members fail at export time.
|
|
36
|
+
|
|
37
|
+
## `patchPolicy`
|
|
38
|
+
|
|
39
|
+
`patchPolicy.ranges` makes `patch compact` range-aware, so closing ordinal
|
|
40
|
+
gaps does not move a patch out of the band its category owns.
|
|
41
|
+
|
|
42
|
+
## `test`
|
|
43
|
+
|
|
44
|
+
`test.canaryPath` and `test.canaryTimeoutSeconds` supply the defaults for
|
|
45
|
+
`fireforge test --canary`. See [`testing.md`](testing.md).
|
|
46
|
+
|
|
47
|
+
## Design tokens
|
|
48
|
+
|
|
49
|
+
Tokens are managed with `fireforge token add`.
|
|
50
|
+
|
|
51
|
+
- `--create-category` declares a new category banner and inserts the token in
|
|
52
|
+
one step. It is incompatible with `--variant`; author the category with the
|
|
53
|
+
base token first, then add each variant.
|
|
54
|
+
- `--category` is required for a base declaration and optional under
|
|
55
|
+
`--variant`, where the declaration is routed into a `:root<selector>` block
|
|
56
|
+
and never into a category section. It is still validated when supplied.
|
|
57
|
+
- `--variant` takes the same selector grammar the block matcher understands:
|
|
58
|
+
one attribute fragment (`[data-private]`, `[data-skin=precision]`), a run of
|
|
59
|
+
them (`[data-skin=precision][data-theme=dark]`), and an optional pseudo-class
|
|
60
|
+
tail (`[data-skin=precision]:not([data-private])`). Names and values must be
|
|
61
|
+
identifier-safe, and `=value` is normalized to `="value"`. A variant naming a
|
|
62
|
+
qualifier matches only a block carrying that qualifier; one without a
|
|
63
|
+
qualifier still matches a qualified block, and the write says so.
|
|
64
|
+
- `--mode override` mirrors into both `:root[data-theme="dark"]` and
|
|
65
|
+
`:root[data-theme="light"]`. A qualified block such as
|
|
66
|
+
`:root[data-theme="light"]:not([data-private])` is matched and written
|
|
67
|
+
through, with a warning naming the selector — the qualifier is
|
|
68
|
+
semantically load-bearing, but silently skipping half the mirror is the
|
|
69
|
+
worse failure.
|
|
70
|
+
- A token already present in the target block is reported with its location
|
|
71
|
+
(`already exists in :root[…] (line N), unchanged`) rather than a bare
|
|
72
|
+
no-op.
|
|
73
|
+
|
|
74
|
+
## Smoke runs
|
|
75
|
+
|
|
76
|
+
For unattended smoke checks, `fireforge run --smoke-exit <s> --headless`
|
|
77
|
+
launches the browser headless. A headed smoke window on a shared desktop
|
|
78
|
+
absorbs live input, which contaminates the console capture — headed non-CI
|
|
79
|
+
launches print a warning saying so. Exit codes 12 and 13 separate a console
|
|
80
|
+
regression from a launch failure; see [`exit-codes.md`](exit-codes.md).
|
|
81
|
+
|
|
82
|
+
## Lock waits
|
|
83
|
+
|
|
84
|
+
Every FireForge command that mutates the engine takes the engine-session lock,
|
|
85
|
+
and some take a second subsystem lock as well. Contention fails fast after
|
|
86
|
+
about a second by default.
|
|
87
|
+
|
|
88
|
+
`--wait-lock [seconds]` (bare flag = 60, valid 1–3600) replaces that with a
|
|
89
|
+
bounded wait, and the budget covers **every** lock the command takes — for
|
|
90
|
+
`furnace apply`/`deploy`/`sync` that means the furnace lock as well as the
|
|
91
|
+
engine-session lock. While waiting, a progress line roughly every 5 seconds
|
|
92
|
+
identifies the holder by PID, command and start time.
|
|
93
|
+
|
|
94
|
+
Commands that take no lock accept `--wait-lock` and ignore it, so a scripted
|
|
95
|
+
sequence can blanket-append the flag without a usage error killing the run.
|
|
96
|
+
|
|
97
|
+
`FIREFORGE_WAIT_LOCK=<seconds>` applies the same budget to every command in a
|
|
98
|
+
session, using the same bounds as the flag. It is the only way to give a
|
|
99
|
+
budget to the furnace mutators that declare no flag (`create`, `remove`,
|
|
100
|
+
`rename`, `refresh`, `scan`, `override`, `chrome-doc`, …). An explicit
|
|
101
|
+
`--wait-lock` always wins.
|
|
102
|
+
|
|
103
|
+
`fireforge status --lock` reports the current holder and queue depth.
|
|
104
|
+
|
|
105
|
+
## Environment variables
|
|
106
|
+
|
|
107
|
+
| Variable | Effect |
|
|
108
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| `FIREFORGE_WAIT_LOCK` | Session-wide lock wait budget in seconds (1–3600). See above. |
|
|
110
|
+
| `FIREFORGE_MAX_UNTRACKED_FILES` | Per-directory cap when expanding collapsed untracked directories in `status` (default 5000). A non-positive value warns and is ignored. |
|
|
111
|
+
| `FIREFORGE_GIT_ADD_TIMEOUT_MS` | Timeout for the monolithic `git add -A` baseline pass (default 10 min). For slow or loaded filesystems. |
|
|
112
|
+
| `FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS` | Timeout for the chunked `git add -- <dir>` fallback (default 30 min). Paired with the above. |
|
|
113
|
+
|
|
114
|
+
`CLAUDECODE` is read by mozbuild, not by FireForge, and `fireforge test
|
|
115
|
+
--full-output` unsets it for test dispatches only — see
|
|
116
|
+
[`testing.md`](testing.md#output-verbosity).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: EUPL-1.2 -->
|
|
2
|
+
|
|
3
|
+
# Exit codes (internal)
|
|
4
|
+
|
|
5
|
+
The exit code is the machine-readable half of the CLI's contract: a script
|
|
6
|
+
branches on it, and CI decides whether to retry or escalate. The values live
|
|
7
|
+
in `src/errors/codes.ts`; this file is the operator-facing statement of what
|
|
8
|
+
they mean and which class produces each.
|
|
9
|
+
|
|
10
|
+
| Code | Name | Meaning | Produced by |
|
|
11
|
+
| ---: | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
|
|
12
|
+
| 0 | `SUCCESS` | Completed. Also a run that prompted and the operator declined — declining is a successful run that chose not to proceed. | — |
|
|
13
|
+
| 1 | `GENERAL_ERROR` | Unspecified failure the operator must act on. | `GeneralError`, `ParserFallbackError` |
|
|
14
|
+
| 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid. | `ConfigError` and subclasses |
|
|
15
|
+
| 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
|
|
16
|
+
| 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses |
|
|
17
|
+
| 5 | `BUILD_ERROR` | `mach build` failed. | `BuildError` and subclasses |
|
|
18
|
+
| 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError` |
|
|
19
|
+
| 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
|
|
20
|
+
| 8 | `INVALID_ARGUMENT` | The flags were wrong. Usage problem, not an environment one. | `InvalidArgumentError` |
|
|
21
|
+
| 9 | `FURNACE_ERROR` | A Furnace component operation failed. | `FurnaceError` |
|
|
22
|
+
| 10 | `RESOLUTION_ERROR` | Conflict resolution failed. | `ResolutionError` |
|
|
23
|
+
| 11 | `INTERNAL_ERROR` | A FireForge invariant did not hold. **A bug in FireForge**, not something the operator can fix. | `InternalInvariantError` |
|
|
24
|
+
| 12 | `SMOKE_EXIT_FAILURE` | `run --smoke-exit` saw unallowed console errors in the smoke window. | `SmokeRunError` |
|
|
25
|
+
| 13 | `SMOKE_LAUNCH_FAILURE` | `run --smoke-exit` saw a non-clean browser exit before the window elapsed. | `SmokeRunError` |
|
|
26
|
+
| 14 | `INCONCLUSIVE` | A test run's verdict was thrown away: `engine/` moved while the harness ran. **Not red** — re-run it. | `InconclusiveVerdictError` |
|
|
27
|
+
| 15 | `LOCK_TIMEOUT` | A lock wait expired, so the run never started. Re-queue it, with a larger `--wait-lock`. | `LockContentionError` |
|
|
28
|
+
| 130 | `USER_CANCELLED` | The operator INTERRUPTED a prompt (Esc / Ctrl+C). 128 + SIGINT. | `CancellationError` |
|
|
29
|
+
|
|
30
|
+
## The three distinctions that matter for CI
|
|
31
|
+
|
|
32
|
+
**Retry vs escalate.** `GENERAL_ERROR` usually means the operator must change
|
|
33
|
+
something and a retry may help. `INTERNAL_ERROR` means nothing they could
|
|
34
|
+
change would have helped — escalate it, do not retry.
|
|
35
|
+
|
|
36
|
+
**Red vs never-ran vs thrown-away.** These are three different facts and,
|
|
37
|
+
before 0.44.0, two of them shared exit 1. `BUILD_ERROR` (5) is a suite that
|
|
38
|
+
FAILED. `LOCK_TIMEOUT` (15) is a run that never started, because another
|
|
39
|
+
FireForge process held the lock for the whole budget — nothing about the
|
|
40
|
+
request was wrong, so re-queue it. `INCONCLUSIVE` (14) is a suite whose
|
|
41
|
+
result was DISCARDED, because `engine/` moved underneath it — the code may
|
|
42
|
+
be perfectly green, and reporting it as red is wrong. A summary line reading
|
|
43
|
+
`FAIL — exit 1` beside `FAIL — exit 5` invited treating opposites as the same
|
|
44
|
+
kind of fact; the codes now separate them mechanically. The
|
|
45
|
+
`FIREFORGE-VERDICT:` line's `reason=` key carries the same distinction for
|
|
46
|
+
humans.
|
|
47
|
+
|
|
48
|
+
Note that `LockContentionError` backs every lock in FireForge — engine
|
|
49
|
+
session, patch directory, build, furnace — so all four now exit 15. That is
|
|
50
|
+
deliberate: the fact is the same whichever lock was contended.
|
|
51
|
+
|
|
52
|
+
**Interrupted vs declined.** Before 0.44.0 both a Ctrl+C and a deliberate
|
|
53
|
+
"no" at a confirmation returned the same value and exited 0, so a script
|
|
54
|
+
could not tell them apart. They are now distinct: an interrupt raises
|
|
55
|
+
`CancellationError` and exits **130**; a declined confirmation returns
|
|
56
|
+
normally and exits **0**, because the run did what it was asked and the
|
|
57
|
+
answer was no.
|
|
58
|
+
|
|
59
|
+
## Adding a code
|
|
60
|
+
|
|
61
|
+
New codes go in `src/errors/codes.ts` with a comment saying what a consumer
|
|
62
|
+
should DO differently on seeing it — if the answer is "nothing different from
|
|
63
|
+
an existing code", it does not need one. Add a row here in the same change.
|
package/docs/furnace.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: EUPL-1.2 -->
|
|
2
|
+
|
|
3
|
+
# Furnace components
|
|
4
|
+
|
|
5
|
+
Furnace is FireForge's answer to UI components. It tracks stock widgets,
|
|
6
|
+
creates new fork-owned widgets, and overrides existing ones. The files still
|
|
7
|
+
land inside the Firefox source directory and end up as part of the regular
|
|
8
|
+
patches to export.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx fireforge furnace scan
|
|
12
|
+
npx fireforge furnace override moz-button -t css-only
|
|
13
|
+
npx fireforge furnace create moz-my-widget
|
|
14
|
+
npx fireforge furnace deploy
|
|
15
|
+
npx fireforge furnace status
|
|
16
|
+
npx fireforge furnace preview
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`fireforge furnace --help` lists every subcommand.
|
|
20
|
+
|
|
21
|
+
## Shared CSS fragments
|
|
22
|
+
|
|
23
|
+
Cross-widget CSS can be single-sourced. Place the fragment in
|
|
24
|
+
`components/shared/` and reference it from a widget stylesheet with a
|
|
25
|
+
comment:
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
/* @fireforge-include base-button.css */
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`deploy` expands it into the deployed copy only, so editing the fragment
|
|
32
|
+
surfaces as component drift until the next deploy.
|
|
33
|
+
|
|
34
|
+
## Typed cross-module imports
|
|
35
|
+
|
|
36
|
+
For multi-file components, set `furnace.json#typecheckJsconfig` to a
|
|
37
|
+
consumer-owned jsconfig. `deploy` then maintains `compilerOptions.paths`
|
|
38
|
+
entries mapping each deployed
|
|
39
|
+
`chrome://global/content/elements/<file>.mjs` URL to its workspace source.
|
|
40
|
+
|
|
41
|
+
Entries are recognized as Furnace-managed by containment in the components
|
|
42
|
+
workspace, so a hand-written entry is preserved and a managed entry whose
|
|
43
|
+
helper was deleted is pruned.
|
|
44
|
+
|
|
45
|
+
## Safety
|
|
46
|
+
|
|
47
|
+
Every furnace mutation runs through `runFurnaceMutation`: a per-root lock, a
|
|
48
|
+
rollback journal restored on both a signal and a thrown error, and a
|
|
49
|
+
`pendingRepair` marker that blocks further mutations when a rollback was
|
|
50
|
+
incomplete — cleared with `fireforge doctor --repair-furnace`. The full
|
|
51
|
+
contract, including which commands take which locks, is in
|
|
52
|
+
[`lifecycle-invariants.md`](lifecycle-invariants.md).
|
|
53
|
+
|
|
54
|
+
`apply`, `deploy` and `sync` accept `--wait-lock [seconds]`, whose budget
|
|
55
|
+
covers **both** locks they take. The twelve mutators without the flag pick up
|
|
56
|
+
a session budget from `FIREFORGE_WAIT_LOCK`; see
|
|
57
|
+
[`configuration.md`](configuration.md#lock-waits).
|