@hominis/fireforge 0.44.2 → 0.44.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/build.js +9 -4
  4. package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
  5. package/dist/src/commands/furnace/create-browser-test.js +6 -6
  6. package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
  7. package/dist/src/commands/furnace/create-dry-run.js +16 -11
  8. package/dist/src/commands/furnace/create-xpcshell.js +2 -2
  9. package/dist/src/commands/furnace/create.js +5 -0
  10. package/dist/src/commands/test-build-phase.js +1 -1
  11. package/dist/src/commands/token.js +3 -1
  12. package/dist/src/core/build-audit.d.ts +20 -0
  13. package/dist/src/core/build-audit.js +25 -1
  14. package/dist/src/core/build-baseline-types.d.ts +28 -0
  15. package/dist/src/core/build-baseline.d.ts +16 -1
  16. package/dist/src/core/build-baseline.js +38 -2
  17. package/dist/src/core/build-prepare.js +32 -3
  18. package/dist/src/core/engine-changes.d.ts +18 -0
  19. package/dist/src/core/engine-changes.js +34 -0
  20. package/dist/src/core/furnace-constants.d.ts +21 -0
  21. package/dist/src/core/furnace-constants.js +26 -1
  22. package/dist/src/core/license-headers.d.ts +9 -5
  23. package/dist/src/core/license-headers.js +33 -7
  24. package/dist/src/core/test-stale-check.js +2 -13
  25. package/dist/src/core/token-variant.d.ts +12 -2
  26. package/dist/src/core/token-variant.js +106 -17
  27. package/docs/README.md +35 -0
  28. package/docs/configuration.md +116 -0
  29. package/docs/exit-codes.md +63 -0
  30. package/docs/furnace.md +57 -0
  31. package/docs/lifecycle-invariants.md +309 -0
  32. package/docs/machine-output.md +94 -0
  33. package/docs/patch-workflow.md +119 -0
  34. package/docs/run-logs.md +60 -0
  35. package/docs/testing.md +123 -0
  36. package/docs/verification-trees.md +48 -0
  37. package/package.json +2 -1
@@ -20,3 +20,21 @@ import type { BuildBaseline } from './build-baseline-types.js';
20
20
  * @returns Sorted engine-relative POSIX paths
21
21
  */
22
22
  export declare function collectChangedEnginePaths(engineDir: string, baseline: BuildBaseline | undefined, contextLabel: string): Promise<string[]>;
23
+ /**
24
+ * Drops every path whose live content still matches the fingerprint the
25
+ * last successful build recorded for it, keeping the rest. Shared by the
26
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
27
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
28
+ *
29
+ * A path with no recorded entry, an unreadable file, or a differing hash
30
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
31
+ * With no fingerprint set at all (a baseline written before the field
32
+ * existed) every path is kept, which is the path-only comparison the
33
+ * fingerprints refine.
34
+ *
35
+ * @param engineDir - Path to the engine directory
36
+ * @param paths - Candidate engine-relative POSIX paths
37
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
38
+ * @returns The subset of `paths` that cannot be proven unchanged
39
+ */
40
+ export declare function dropPathsMatchingFingerprints(engineDir: string, paths: readonly string[], fingerprints: Record<string, string> | undefined): Promise<string[]>;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { toError } from '../utils/errors.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { hashEngineFile } from './coverage-extend.js';
9
10
  import { hasChanges, isMissingHeadError } from './git.js';
10
11
  import { git } from './git-base.js';
11
12
  import { getUntrackedFiles } from './git-status.js';
@@ -59,3 +60,36 @@ export async function collectChangedEnginePaths(engineDir, baseline, contextLabe
59
60
  }
60
61
  return [...collected].sort();
61
62
  }
63
+ /**
64
+ * Drops every path whose live content still matches the fingerprint the
65
+ * last successful build recorded for it, keeping the rest. Shared by the
66
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
67
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
68
+ *
69
+ * A path with no recorded entry, an unreadable file, or a differing hash
70
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
71
+ * With no fingerprint set at all (a baseline written before the field
72
+ * existed) every path is kept, which is the path-only comparison the
73
+ * fingerprints refine.
74
+ *
75
+ * @param engineDir - Path to the engine directory
76
+ * @param paths - Candidate engine-relative POSIX paths
77
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
78
+ * @returns The subset of `paths` that cannot be proven unchanged
79
+ */
80
+ export async function dropPathsMatchingFingerprints(engineDir, paths, fingerprints) {
81
+ if (!fingerprints)
82
+ return [...paths];
83
+ const kept = [];
84
+ for (const path of paths) {
85
+ const recorded = fingerprints[path];
86
+ if (recorded === undefined) {
87
+ kept.push(path);
88
+ continue;
89
+ }
90
+ const live = await hashEngineFile(engineDir, path);
91
+ if (live === undefined || live !== recorded)
92
+ kept.push(path);
93
+ }
94
+ return kept;
95
+ }
@@ -12,12 +12,33 @@ export declare const JAR_MN = "toolkit/content/jar.mn";
12
12
  export declare const WIDGETS_DIR = "toolkit/content/widgets";
13
13
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
14
14
  export declare const FTL_DIR = "toolkit/locales/en-US/toolkit/global";
15
+ /**
16
+ * Engine-relative root every browser-chrome / xpcshell test scaffold lives
17
+ * under, trailing slash included. A `--test-dir` override must stay below
18
+ * it so the `browser/base/moz.build` manifest registration keeps working.
19
+ */
20
+ export declare const BROWSER_TEST_SCAFFOLD_ROOT = "browser/base/content/test/";
15
21
  /**
16
22
  * Returns the engine-relative directory that holds xpcshell scaffolds for
17
23
  * a given binary. Matches the form `create-xpcshell.ts` writes and the
18
24
  * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
19
25
  */
20
26
  export declare function xpcshellTestParentDir(binaryName: string): string;
27
+ /**
28
+ * Resolves the engine-relative directory a browser-chrome scaffold is
29
+ * written to: the `--test-dir` override when given, else
30
+ * `browser/base/content/test/<binaryName>`. The scaffolder AND the
31
+ * dry-run / success formatters resolve through this one function, so the
32
+ * printed plan cannot disagree with the files that land on disk.
33
+ */
34
+ export declare function resolveBrowserChromeTestDir(binaryName: string, override?: string): string;
35
+ /**
36
+ * Resolves the engine-relative directory an xpcshell scaffold is written
37
+ * to. A `--test-dir` override names the FINAL directory (no per-component
38
+ * segment is appended); the default is
39
+ * `browser/base/content/test/<binaryName>-xpcshell/<componentName>`.
40
+ */
41
+ export declare function resolveXpcshellTestDir(binaryName: string, componentName: string, override?: string): string;
21
42
  /** Returns true when `fileName` has one of the standard component file extensions. */
22
43
  export declare function isComponentSourceFile(fileName: string): boolean;
23
44
  /**
@@ -13,6 +13,12 @@ export const JAR_MN = 'toolkit/content/jar.mn';
13
13
  export const WIDGETS_DIR = 'toolkit/content/widgets';
14
14
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
15
15
  export const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
16
+ /**
17
+ * Engine-relative root every browser-chrome / xpcshell test scaffold lives
18
+ * under, trailing slash included. A `--test-dir` override must stay below
19
+ * it so the `browser/base/moz.build` manifest registration keeps working.
20
+ */
21
+ export const BROWSER_TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
16
22
  /**
17
23
  * Suffix for the per-binary xpcshell scaffold parent directory. Components
18
24
  * created with `furnace create --with-tests --xpcshell` land at
@@ -27,7 +33,26 @@ const XPCSHELL_TEST_DIR_SUFFIX = '-xpcshell';
27
33
  * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
28
34
  */
29
35
  export function xpcshellTestParentDir(binaryName) {
30
- return `browser/base/content/test/${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
36
+ return `${BROWSER_TEST_SCAFFOLD_ROOT}${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
37
+ }
38
+ /**
39
+ * Resolves the engine-relative directory a browser-chrome scaffold is
40
+ * written to: the `--test-dir` override when given, else
41
+ * `browser/base/content/test/<binaryName>`. The scaffolder AND the
42
+ * dry-run / success formatters resolve through this one function, so the
43
+ * printed plan cannot disagree with the files that land on disk.
44
+ */
45
+ export function resolveBrowserChromeTestDir(binaryName, override) {
46
+ return override ?? `${BROWSER_TEST_SCAFFOLD_ROOT}${binaryName}`;
47
+ }
48
+ /**
49
+ * Resolves the engine-relative directory an xpcshell scaffold is written
50
+ * to. A `--test-dir` override names the FINAL directory (no per-component
51
+ * segment is appended); the default is
52
+ * `browser/base/content/test/<binaryName>-xpcshell/<componentName>`.
53
+ */
54
+ export function resolveXpcshellTestDir(binaryName, componentName, override) {
55
+ return override ?? `${xpcshellTestParentDir(binaryName)}/${componentName}`;
31
56
  }
32
57
  /** File extensions that constitute a Furnace component's source files. */
33
58
  const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
@@ -69,7 +69,9 @@ export declare function hasUpstreamMplBlockHeader(content: string): boolean;
69
69
  export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
70
70
  /**
71
71
  * Returns true if the first few lines of `content` contain a recognized
72
- * upstream license identifier string.
72
+ * upstream license identifier string — Mozilla's MPL boilerplate, an SPDX
73
+ * tag, the common permissive/copyleft license names, or the CC0
74
+ * public-domain dedication upstream test files carry.
73
75
  *
74
76
  * @param content - File content to check
75
77
  * @param maxLines - Number of leading lines to inspect (default 10)
@@ -77,10 +79,12 @@ export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
77
79
  export declare function containsUpstreamLicenseText(content: string, maxLines?: number): boolean;
78
80
  /**
79
81
  * Returns true when the head of `content` carries a recognized THIRD-PARTY
80
- * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
81
- * comment style. Used by `export` to treat such files as vendored: offering
82
- * to prepend the project's own license header onto a byte-identity-pinned
83
- * upstream bundle would mislicense third-party code.
82
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0) or the
83
+ * CC0 public-domain dedication, in any comment style. Used by `export` to
84
+ * treat such files as vendored: offering to prepend the project's own
85
+ * license header onto a byte-identity-pinned upstream bundle — or onto a
86
+ * test file Mozilla dedicated to the public domain — would mislicense code
87
+ * the project does not own.
84
88
  *
85
89
  * @param content - File content to check
86
90
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -227,9 +227,31 @@ function normalizeLicenseHeadForScan(head) {
227
227
  s = s.replace(/\n[ \t]*\*[ \t]*/g, ' ');
228
228
  return s.replace(/\s+/g, ' ').trim();
229
229
  }
230
+ /**
231
+ * Recognizes the CC0 public-domain dedication Mozilla puts on most of its
232
+ * test files, in every spelling the tree carries:
233
+ *
234
+ * - the prose (`Any copyright is dedicated to the Public Domain.`), which
235
+ * the block, `//` and `#` comment forms all reduce to after
236
+ * {@link normalizeLicenseHeadForScan};
237
+ * - the dedication URL (`creativecommons.org/publicdomain/zero/1.0/`, with
238
+ * either scheme);
239
+ * - the SPDX identifier `CC0-1.0`.
240
+ *
241
+ * Deliberately narrow: a bare "public domain" mention, a different Creative
242
+ * Commons license (`licenses/by/4.0/`, `CC-BY-4.0`) or a made-up
243
+ * `publicdomain/zero/2.0/` is NOT a CC0 dedication and must not be
244
+ * accepted as one. `scanText` must already be normalized (and may be
245
+ * lowercased — the match is case-insensitive).
246
+ */
247
+ function hasCc0Dedication(scanText) {
248
+ return /dedicated to the public domain|creativecommons\.org\/publicdomain\/zero\/1\.0|spdx-license-identifier:\s*cc0-1\.0\b/i.test(scanText);
249
+ }
230
250
  /**
231
251
  * Returns true if the first few lines of `content` contain a recognized
232
- * upstream license identifier string.
252
+ * upstream license identifier string — Mozilla's MPL boilerplate, an SPDX
253
+ * tag, the common permissive/copyleft license names, or the CC0
254
+ * public-domain dedication upstream test files carry.
233
255
  *
234
256
  * @param content - File content to check
235
257
  * @param maxLines - Number of leading lines to inspect (default 10)
@@ -244,14 +266,16 @@ export function containsUpstreamLicenseText(content, maxLines = 10) {
244
266
  'MIT License',
245
267
  'GNU General Public License',
246
268
  ];
247
- return markers.some((marker) => scanText.includes(marker));
269
+ return markers.some((marker) => scanText.includes(marker)) || hasCc0Dedication(scanText);
248
270
  }
249
271
  /**
250
272
  * Returns true when the head of `content` carries a recognized THIRD-PARTY
251
- * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
252
- * comment style. Used by `export` to treat such files as vendored: offering
253
- * to prepend the project's own license header onto a byte-identity-pinned
254
- * upstream bundle would mislicense third-party code.
273
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0) or the
274
+ * CC0 public-domain dedication, in any comment style. Used by `export` to
275
+ * treat such files as vendored: offering to prepend the project's own
276
+ * license header onto a byte-identity-pinned upstream bundle — or onto a
277
+ * test file Mozilla dedicated to the public domain — would mislicense code
278
+ * the project does not own.
255
279
  *
256
280
  * @param content - File content to check
257
281
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -271,7 +295,9 @@ export function hasThirdPartyPermissiveBanner(content, maxLines = 30) {
271
295
  ];
272
296
  if (markers.some((marker) => scanText.includes(marker)))
273
297
  return true;
274
- return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0)\b/.test(scanText);
298
+ if (hasCc0Dedication(scanText))
299
+ return true;
300
+ return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0|cc0-1\.0)\b/.test(scanText);
275
301
  }
276
302
  /**
277
303
  * Prepends the license header to a file on disk if it is not already present.
@@ -27,7 +27,7 @@ import { verbose, warn } from '../utils/logger.js';
27
27
  import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
28
28
  import { readBuildBaseline } from './build-baseline.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
30
- import { collectChangedEnginePaths } from './engine-changes.js';
30
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
31
31
  export { isXpcomManifestPath };
32
32
  /** Cap on the number of changed paths rendered inline. */
33
33
  const STALE_PATHS_LIMIT = 10;
@@ -60,18 +60,7 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
60
60
  // and the stale check fires immediately after a successful one. The
61
61
  // fingerprints capture "these files had this content when the build ran";
62
62
  // a path stays stale only when its live hash diverges.
63
- const fingerprints = baseline.packageableFingerprints;
64
- if (fingerprints) {
65
- const staleAfterHashCheck = [];
66
- for (const path of packageable) {
67
- const recorded = fingerprints[path];
68
- const live = await hashEngineFile(engineDir, path);
69
- if (recorded === undefined || live === undefined || recorded !== live) {
70
- staleAfterHashCheck.push(path);
71
- }
72
- }
73
- packageable = staleAfterHashCheck;
74
- }
63
+ packageable = await dropPathsMatchingFingerprints(engineDir, packageable, baseline.packageableFingerprints);
75
64
  if (packageable.length === 0) {
76
65
  return { stale: false, changedPaths: [], truncated: 0, baseline };
77
66
  }
@@ -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).