@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 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
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.2",
4
- "commit": "d653751d6c8a1be154647943dc346e81cc62b390",
5
- "shortCommit": "d653751d6c8a",
3
+ "version": "0.44.3",
4
+ "commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
5
+ "shortCommit": "760172104f6b",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-28T13:39:36.337Z"
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]' or '[data-private]'); creates or updates the block. " +
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` attribute selector fragment and normalizes any
27
- * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
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
- * Accepts a single attribute selector fragment: `[data-private]` (boolean
16
- * attribute) or `[data-skin=precision]` / `[data-skin="precision"]`
17
- * (attribute with value). The value half is restricted to identifier-safe
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 VARIANT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
32
+ const QUALIFIER_PATTERN = /^:[a-zA-Z][a-zA-Z0-9-]*(?:\(([^()]*)\))?$/;
22
33
  /**
23
- * Validates a `--variant` attribute selector fragment and normalizes any
24
- * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
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 (!VARIANT_PATTERN.test(value)) {
35
- return {
36
- ok: false,
37
- reason: 'must be a single attribute selector like [data-private] or [data-skin=precision] ' +
38
- '(identifier-safe attribute name and value only)',
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
- const normalized = value
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
- const want = canonicalSelector(`:root${variant}`);
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.
@@ -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).