@hominis/fireforge 0.16.5 → 0.18.0

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 (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Status classifier: partitions engine file changes into
3
+ * patch-backed / unmanaged / branding / furnace / conflict buckets.
4
+ *
5
+ * Extracted from `src/commands/status.ts` so that command file stays
6
+ * under the per-file line budget as the number of buckets grows.
7
+ */
8
+ /**
9
+ * Classification buckets for engine file changes:
10
+ * - `patch-backed`: content matches the expected post-patch state —
11
+ * normal after `fireforge import`.
12
+ * - `unmanaged`: edits not explained by any patch or tool — local
13
+ * drift to export or discard.
14
+ * - `branding`: files under tool-managed branding paths, written by
15
+ * FireForge's branding pipeline.
16
+ * - `furnace`: files under Furnace-managed component prefixes.
17
+ * - `conflict`: the file is claimed by two or more patches in
18
+ * `patches.json`. The human `--ownership` mode already surfaces
19
+ * this bucket as `CONFLICT`; the classification is carried through
20
+ * the JSON pipeline so machine consumers can detect the same
21
+ * ownership breakage the human output shows. Before 0.16.0,
22
+ * cross-patch conflicts silently rolled into the `unmanaged` bucket
23
+ * in `--json`, which misled scripts built on top of the JSON view
24
+ * into treating the file as routine local drift.
25
+ */
26
+ export type FileClassification = 'patch-backed' | 'unmanaged' | 'branding' | 'furnace' | 'conflict';
27
+ export interface StatusFile {
28
+ status: string;
29
+ file: string;
30
+ }
31
+ export interface ClassifiedFile extends StatusFile {
32
+ classification: FileClassification;
33
+ /**
34
+ * Names of patch files that claim this path in `patches.json`.
35
+ * Populated only when `classification === 'conflict'` — single-claim
36
+ * patch-backed entries don't need to expose their owner because the
37
+ * single claim is fully captured by the classification itself.
38
+ */
39
+ claimedBy?: string[];
40
+ }
41
+ /**
42
+ * Classifies files into patch-backed, unmanaged, branding, furnace, or
43
+ * conflict buckets.
44
+ *
45
+ * Tracks patch ownership as a `Map<file, patchFilename[]>` rather than
46
+ * a plain `Set<file>` so the classifier can surface cross-patch
47
+ * ownership conflicts the same way the human `--ownership` mode does.
48
+ * The 2026-04-21 eval's `status --json` run reported
49
+ * `classification: "unmanaged"` on two files (`browser/base/jar.mn`,
50
+ * `browser/themes/shared/jar.inc.mn`) that `--ownership` correctly
51
+ * flagged as `CONFLICT`; the JSON output was effectively lying to
52
+ * machine consumers about the nature of the drift.
53
+ */
54
+ export declare function classifyFiles(files: StatusFile[], engineDir: string, patchesDir: string, binaryName: string, furnacePrefixes: Set<string>): Promise<ClassifiedFile[]>;
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Status classifier: partitions engine file changes into
4
+ * patch-backed / unmanaged / branding / furnace / conflict buckets.
5
+ *
6
+ * Extracted from `src/commands/status.ts` so that command file stays
7
+ * under the per-file line budget as the number of buckets grows.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { toError } from '../utils/errors.js';
11
+ import { readText } from '../utils/fs.js';
12
+ import { verbose } from '../utils/logger.js';
13
+ import { isBrandingManagedPath } from './branding.js';
14
+ import { computePatchedContent } from './patch-apply.js';
15
+ import { loadPatchesManifest } from './patch-manifest.js';
16
+ function getPrimaryStatusCode(status) {
17
+ if (status.includes('?'))
18
+ return '?';
19
+ if (status.includes('!'))
20
+ return '!';
21
+ for (const code of status) {
22
+ if (code !== ' ') {
23
+ return code;
24
+ }
25
+ }
26
+ return status;
27
+ }
28
+ /**
29
+ * Classifies files into patch-backed, unmanaged, branding, furnace, or
30
+ * conflict buckets.
31
+ *
32
+ * Tracks patch ownership as a `Map<file, patchFilename[]>` rather than
33
+ * a plain `Set<file>` so the classifier can surface cross-patch
34
+ * ownership conflicts the same way the human `--ownership` mode does.
35
+ * The 2026-04-21 eval's `status --json` run reported
36
+ * `classification: "unmanaged"` on two files (`browser/base/jar.mn`,
37
+ * `browser/themes/shared/jar.inc.mn`) that `--ownership` correctly
38
+ * flagged as `CONFLICT`; the JSON output was effectively lying to
39
+ * machine consumers about the nature of the drift.
40
+ */
41
+ export async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
42
+ const manifest = await loadPatchesManifest(patchesDir);
43
+ // Build a multimap from file path → list of claiming patch
44
+ // filenames so we can detect cross-patch ownership conflicts. The
45
+ // previous `Set<string>` captured only whether a path was claimed,
46
+ // not by whom, and collapsed multi-owner files into the single-owner
47
+ // branch where the expected-vs-actual content comparison then routed
48
+ // them into `unmanaged` when the content didn't match either owner's
49
+ // expectation.
50
+ const patchClaims = new Map();
51
+ if (manifest) {
52
+ for (const patch of manifest.patches) {
53
+ for (const f of patch.filesAffected) {
54
+ const owners = patchClaims.get(f);
55
+ if (owners) {
56
+ owners.push(patch.filename);
57
+ }
58
+ else {
59
+ patchClaims.set(f, [patch.filename]);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ const results = [];
65
+ for (const entry of files) {
66
+ // Branding check first
67
+ if (isBrandingManagedPath(entry.file, binaryName)) {
68
+ results.push({ ...entry, classification: 'branding' });
69
+ continue;
70
+ }
71
+ // Furnace-managed component paths
72
+ if (furnacePrefixes.size > 0) {
73
+ let isFurnace = false;
74
+ for (const prefix of furnacePrefixes) {
75
+ if (entry.file.startsWith(prefix)) {
76
+ isFurnace = true;
77
+ break;
78
+ }
79
+ }
80
+ if (isFurnace) {
81
+ results.push({ ...entry, classification: 'furnace' });
82
+ continue;
83
+ }
84
+ }
85
+ const owners = patchClaims.get(entry.file);
86
+ // Multiple patches claim this file — surface the cross-patch
87
+ // ownership conflict regardless of whether the current content
88
+ // matches any single claim. `--ownership` reports the same state
89
+ // as `CONFLICT`; `--json` must agree so machine consumers of the
90
+ // two views see the same truth.
91
+ if (owners && owners.length >= 2) {
92
+ results.push({
93
+ ...entry,
94
+ classification: 'conflict',
95
+ claimedBy: [...owners],
96
+ });
97
+ continue;
98
+ }
99
+ // Not in any patch → unmanaged
100
+ if (!owners) {
101
+ results.push({ ...entry, classification: 'unmanaged' });
102
+ continue;
103
+ }
104
+ // File is claimed by exactly one patch — compare content.
105
+ const primaryCode = getPrimaryStatusCode(entry.status);
106
+ if (primaryCode === 'D') {
107
+ // Deleted file: patch-backed only if patch expects deletion
108
+ const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
109
+ results.push({
110
+ ...entry,
111
+ classification: expected === null ? 'patch-backed' : 'unmanaged',
112
+ });
113
+ continue;
114
+ }
115
+ // File exists on disk — compare actual vs expected
116
+ try {
117
+ const [expected, actual] = await Promise.all([
118
+ computePatchedContent(patchesDir, engineDir, entry.file),
119
+ readText(join(engineDir, entry.file)),
120
+ ]);
121
+ results.push({
122
+ ...entry,
123
+ classification: actual === expected ? 'patch-backed' : 'unmanaged',
124
+ });
125
+ }
126
+ catch (error) {
127
+ verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
128
+ // If we can't read the file, treat as unmanaged
129
+ results.push({ ...entry, classification: 'unmanaged' });
130
+ }
131
+ }
132
+ return results;
133
+ }
134
+ //# sourceMappingURL=status-classify.js.map
@@ -5,6 +5,21 @@ import { pathExists, readText } from '../utils/fs.js';
5
5
  import { verbose } from '../utils/logger.js';
6
6
  import { countRawCssColors } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
+ /**
9
+ * Default platform prefixes treated as allowlisted upstream vars. Any
10
+ * `var(--moz-*)` usage in a fork's CSS is a Firefox platform variable
11
+ * that the fork does not own and should not be counted as an unknown.
12
+ * 2026-04-21 eval (Finding #5): a `furnace override moz-button -t
13
+ * css-only` + one fork token produced 1% coverage because the 84
14
+ * upstream `--moz-*` vars in the copied baseline counted as unknown.
15
+ *
16
+ * Forks that want to opt out can override this via
17
+ * `furnace.json.platformPrefixes = []`; forks that want more can
18
+ * extend it (e.g. `['--moz-', '--in-content-']`). The config is
19
+ * additive — nothing is removed from the defaults unless the operator
20
+ * explicitly writes a shorter list.
21
+ */
22
+ const DEFAULT_PLATFORM_PREFIXES = ['--moz-'];
8
23
  /**
9
24
  * Measures design token coverage across CSS files.
10
25
  *
@@ -19,6 +34,7 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
19
34
  // Load furnace config gracefully
20
35
  let tokenPrefix;
21
36
  let tokenAllowlist;
37
+ let platformPrefixes = DEFAULT_PLATFORM_PREFIXES;
22
38
  try {
23
39
  const root = projectRoot ?? join(repoDir, '..');
24
40
  const config = await loadFurnaceConfig(root);
@@ -26,6 +42,9 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
26
42
  tokenPrefix = config.tokenPrefix;
27
43
  tokenAllowlist = new Set(config.tokenAllowlist ?? []);
28
44
  }
45
+ if (config.platformPrefixes !== undefined) {
46
+ platformPrefixes = config.platformPrefixes;
47
+ }
29
48
  }
30
49
  catch (error) {
31
50
  verbose(`Proceeding without furnace token metadata because furnace.json could not be loaded: ${toError(error).message}`);
@@ -56,6 +75,11 @@ export async function measureTokenCoverage(repoDir, cssFiles, projectRoot) {
56
75
  else if (tokenAllowlist?.has(prop)) {
57
76
  allowlisted++;
58
77
  }
78
+ else if (platformPrefixes.some((prefix) => prop.startsWith(prefix))) {
79
+ // Platform vars (upstream `--moz-*`) are counted as allowlisted
80
+ // so they don't drag the fork-owned coverage percentage down.
81
+ allowlisted++;
82
+ }
59
83
  else {
60
84
  unknownVars++;
61
85
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Dark-mode insertion helpers for the tokens CSS scaffold.
3
+ *
4
+ * The 2026-04-21 eval reproduced a bug where `fireforge token add
5
+ * --mode override --dark-value ...` landed the dark declaration
6
+ * AFTER the nested `:root { }` inside the
7
+ * `@media (prefers-color-scheme: dark)` block had already closed,
8
+ * producing a declaration outside any rule block. The helpers here
9
+ * scan the comment-stripped source lines to find the *inner* `:root`
10
+ * block's closing `}` and return a line index the caller can splice
11
+ * into. When the inner `:root` is missing (a scaffold that drifted
12
+ * from the default), the fallback helper returns the outer `@media`
13
+ * block's close so the caller can materialise a fresh `:root` wrapper
14
+ * rather than dropping the dark value.
15
+ */
16
+ /**
17
+ * Strips the content of `/* ... *\/` block comments from an array of
18
+ * CSS source lines while preserving each line's length. Indexed scans
19
+ * over the returned mirror line up with the original, so callers that
20
+ * compute an insertion index against the stripped array can splice
21
+ * into the original array at the same index.
22
+ *
23
+ * We blank the comment body with spaces (rather than removing it) so
24
+ * any downstream consumer that indexes by column — or derives an
25
+ * insertion index as a line number in the original array — still
26
+ * agrees on line numbers.
27
+ */
28
+ export declare function stripBlockCommentsInLines(lines: string[]): string[];
29
+ /**
30
+ * Finds the closing `}` line of the nested `:root { ... }` block inside
31
+ * a `@media (prefers-color-scheme: dark)` block. Returns `-1` when the
32
+ * media block exists but the nested `:root` block is missing; returns
33
+ * `null` when the `@media` block itself is absent.
34
+ *
35
+ * Runs the scan over a comment-stripped mirror of the source lines so
36
+ * braces inside CSS comments (`/* before { after *\/`) do not offset
37
+ * the depth counter. The scan is deliberately line-indexed so callers
38
+ * can splice into the original `lines` array at the returned index.
39
+ */
40
+ export declare function findDarkRootInsertionIndex(lines: string[]): number | null;
41
+ /**
42
+ * Finds the closing `}` of the outermost
43
+ * `@media (prefers-color-scheme: dark)` block. Used as the fallback
44
+ * landing site when the scaffold has no nested `:root { }` — the
45
+ * insertion helper uses this index to splice a brand-new `:root`
46
+ * wrapper containing the dark declaration, rather than dropping the
47
+ * value.
48
+ */
49
+ export declare function findDarkMediaCloseIndex(lines: string[]): number;
@@ -0,0 +1,182 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Dark-mode insertion helpers for the tokens CSS scaffold.
4
+ *
5
+ * The 2026-04-21 eval reproduced a bug where `fireforge token add
6
+ * --mode override --dark-value ...` landed the dark declaration
7
+ * AFTER the nested `:root { }` inside the
8
+ * `@media (prefers-color-scheme: dark)` block had already closed,
9
+ * producing a declaration outside any rule block. The helpers here
10
+ * scan the comment-stripped source lines to find the *inner* `:root`
11
+ * block's closing `}` and return a line index the caller can splice
12
+ * into. When the inner `:root` is missing (a scaffold that drifted
13
+ * from the default), the fallback helper returns the outer `@media`
14
+ * block's close so the caller can materialise a fresh `:root` wrapper
15
+ * rather than dropping the dark value.
16
+ */
17
+ /**
18
+ * Strips the content of `/* ... *\/` block comments from an array of
19
+ * CSS source lines while preserving each line's length. Indexed scans
20
+ * over the returned mirror line up with the original, so callers that
21
+ * compute an insertion index against the stripped array can splice
22
+ * into the original array at the same index.
23
+ *
24
+ * We blank the comment body with spaces (rather than removing it) so
25
+ * any downstream consumer that indexes by column — or derives an
26
+ * insertion index as a line number in the original array — still
27
+ * agrees on line numbers.
28
+ */
29
+ export function stripBlockCommentsInLines(lines) {
30
+ const out = [];
31
+ let inBlockComment = false;
32
+ for (const original of lines) {
33
+ let line = '';
34
+ for (let i = 0; i < original.length; i++) {
35
+ if (inBlockComment) {
36
+ if (original[i] === '*' && original[i + 1] === '/') {
37
+ line += ' ';
38
+ i += 1;
39
+ inBlockComment = false;
40
+ }
41
+ else {
42
+ line += ' ';
43
+ }
44
+ }
45
+ else if (original[i] === '/' && original[i + 1] === '*') {
46
+ line += ' ';
47
+ i += 1;
48
+ inBlockComment = true;
49
+ }
50
+ else {
51
+ // `original[i]` is provably defined here (the bounds check is
52
+ // the loop condition), but TS narrows it to `string | undefined`.
53
+ // Default to empty string so the concat stays well-typed.
54
+ line += original[i] ?? '';
55
+ }
56
+ }
57
+ out.push(line);
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Finds the closing `}` line of the nested `:root { ... }` block inside
63
+ * a `@media (prefers-color-scheme: dark)` block. Returns `-1` when the
64
+ * media block exists but the nested `:root` block is missing; returns
65
+ * `null` when the `@media` block itself is absent.
66
+ *
67
+ * Runs the scan over a comment-stripped mirror of the source lines so
68
+ * braces inside CSS comments (`/* before { after *\/`) do not offset
69
+ * the depth counter. The scan is deliberately line-indexed so callers
70
+ * can splice into the original `lines` array at the returned index.
71
+ */
72
+ export function findDarkRootInsertionIndex(lines) {
73
+ const stripped = stripBlockCommentsInLines(lines);
74
+ let darkMediaLine = -1;
75
+ for (let i = 0; i < stripped.length; i++) {
76
+ if (/prefers-color-scheme:\s*dark/.test(stripped[i] ?? '')) {
77
+ darkMediaLine = i;
78
+ break;
79
+ }
80
+ }
81
+ if (darkMediaLine === -1)
82
+ return null;
83
+ // Walk the comment-stripped lines after the @media header and find
84
+ // the first `:root {` opener inside the block. The opening brace of
85
+ // the selector may live on the same line as the selector name or on
86
+ // the following line; either shape is tolerated.
87
+ let rootOpenLine = -1;
88
+ for (let i = darkMediaLine; i < stripped.length; i++) {
89
+ const line = stripped[i] ?? '';
90
+ if (/(^|[\s,{])\s*:root\b/.test(line)) {
91
+ // Brace on the same line?
92
+ if (/:root[^{}]*\{/.test(line)) {
93
+ rootOpenLine = i;
94
+ break;
95
+ }
96
+ // Otherwise scan forward for the opening brace, stopping at the
97
+ // first `}` or second selector that would mean the `:root`
98
+ // declaration never opened a block.
99
+ for (let j = i + 1; j < stripped.length; j++) {
100
+ const next = stripped[j] ?? '';
101
+ if (/\{/.test(next)) {
102
+ rootOpenLine = j;
103
+ break;
104
+ }
105
+ if (/[};]/.test(next))
106
+ break;
107
+ }
108
+ if (rootOpenLine !== -1)
109
+ break;
110
+ }
111
+ }
112
+ if (rootOpenLine === -1)
113
+ return -1;
114
+ // Depth-count starting from the `:root` opener. The first `{`
115
+ // encountered sets the entry depth to the initial counter value; the
116
+ // closing brace that returns to that depth terminates the block.
117
+ let depth = 0;
118
+ let entryDepth = 0;
119
+ let enteredBlock = false;
120
+ for (let i = rootOpenLine; i < stripped.length; i++) {
121
+ const line = stripped[i] ?? '';
122
+ for (const ch of line) {
123
+ if (ch === '{') {
124
+ depth++;
125
+ if (!enteredBlock) {
126
+ entryDepth = depth - 1;
127
+ enteredBlock = true;
128
+ }
129
+ }
130
+ else if (ch === '}') {
131
+ depth--;
132
+ }
133
+ }
134
+ if (enteredBlock && depth === entryDepth) {
135
+ return i;
136
+ }
137
+ }
138
+ return -1;
139
+ }
140
+ /**
141
+ * Finds the closing `}` of the outermost
142
+ * `@media (prefers-color-scheme: dark)` block. Used as the fallback
143
+ * landing site when the scaffold has no nested `:root { }` — the
144
+ * insertion helper uses this index to splice a brand-new `:root`
145
+ * wrapper containing the dark declaration, rather than dropping the
146
+ * value.
147
+ */
148
+ export function findDarkMediaCloseIndex(lines) {
149
+ const stripped = stripBlockCommentsInLines(lines);
150
+ let darkMediaLine = -1;
151
+ for (let i = 0; i < stripped.length; i++) {
152
+ if (/prefers-color-scheme:\s*dark/.test(stripped[i] ?? '')) {
153
+ darkMediaLine = i;
154
+ break;
155
+ }
156
+ }
157
+ if (darkMediaLine === -1)
158
+ return -1;
159
+ let depth = 0;
160
+ let entryDepth = 0;
161
+ let enteredBlock = false;
162
+ for (let i = darkMediaLine; i < stripped.length; i++) {
163
+ const line = stripped[i] ?? '';
164
+ for (const ch of line) {
165
+ if (ch === '{') {
166
+ depth++;
167
+ if (!enteredBlock) {
168
+ entryDepth = depth - 1;
169
+ enteredBlock = true;
170
+ }
171
+ }
172
+ else if (ch === '}') {
173
+ depth--;
174
+ }
175
+ }
176
+ if (enteredBlock && depth === entryDepth) {
177
+ return i;
178
+ }
179
+ }
180
+ return -1;
181
+ }
182
+ //# sourceMappingURL=token-dark-mode.js.map
@@ -10,6 +10,7 @@ import { validateTokenName } from '../utils/validation.js';
10
10
  import { getProjectPaths, loadConfig } from './config.js';
11
11
  import { loadFurnaceConfig } from './furnace-config.js';
12
12
  import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
13
+ import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
14
  /** Returns the token CSS path relative to engine root for a given binary name. */
14
15
  export function getTokensCssPath(binaryName) {
15
16
  return `browser/themes/shared/${binaryName}-tokens.css`;
@@ -271,41 +272,24 @@ function findCategorySection(lines, category, tokensCssPath) {
271
272
  function insertDarkModeOverride(lines, options) {
272
273
  if (options.mode !== 'override' || !options.darkValue)
273
274
  return;
274
- let darkMediaLine = -1;
275
- for (let i = 0; i < lines.length; i++) {
276
- if (/prefers-color-scheme:\s*dark/.test(lines[i] ?? '')) {
277
- darkMediaLine = i;
278
- break;
279
- }
280
- }
281
- if (darkMediaLine === -1)
275
+ const insertionIndex = findDarkRootInsertionIndex(lines);
276
+ if (insertionIndex === null)
277
+ return; // No @media block at all.
278
+ const darkEntry = ` ${options.tokenName}: ${options.darkValue};`;
279
+ if (insertionIndex === -1) {
280
+ // @media block exists but has no nested :root { } — the scaffold
281
+ // drifted. Warn and fall back to appending a fresh nested :root
282
+ // block right before the @media block's closing brace so the
283
+ // generated CSS still parses, rather than dropping the dark value
284
+ // on the floor or producing a declaration outside any rule.
285
+ warn(`Dark-mode override block for "${options.tokenName}" could not find a nested ":root { }" inside @media (prefers-color-scheme: dark). Appending a fresh ":root { }" block — review the tokens CSS scaffold.`);
286
+ const outerCloseIndex = findDarkMediaCloseIndex(lines);
287
+ if (outerCloseIndex === -1)
288
+ return;
289
+ lines.splice(outerCloseIndex, 0, ' :root {', darkEntry, ' }');
282
290
  return;
283
- // Find the closing } of the @media block
284
- let darkBlockEnd = lines.length;
285
- let depth = 0;
286
- let entryDepth = 0;
287
- let enteredBlock = false;
288
- for (let i = darkMediaLine; i < lines.length; i++) {
289
- const line = lines[i] ?? '';
290
- for (const ch of line) {
291
- if (ch === '{') {
292
- depth++;
293
- if (!enteredBlock) {
294
- entryDepth = depth - 1;
295
- enteredBlock = true;
296
- }
297
- }
298
- if (ch === '}')
299
- depth--;
300
- }
301
- if (enteredBlock && depth === entryDepth) {
302
- darkBlockEnd = i;
303
- break;
304
- }
305
291
  }
306
- // Insert the dark value before the closing }
307
- const darkEntry = ` ${options.tokenName}: ${options.darkValue};`;
308
- lines.splice(darkBlockEnd, 0, darkEntry);
292
+ lines.splice(insertionIndex, 0, darkEntry);
309
293
  }
310
294
  /**
311
295
  * Adds a token declaration to the CSS file in the correct category section.
@@ -4,12 +4,16 @@
4
4
  /**
5
5
  * AST-based implementation: finds onUnload()/uninit() method body and
6
6
  * inserts the destroy block at the top (LIFO ordering).
7
+ *
8
+ * `marker` is prefixed to the generated comment so wire-generated
9
+ * edits carry the patch-lint `// <MARKER>:` signature
10
+ * `lintModificationComments` looks for (eval 1 Finding #9).
7
11
  */
8
- export declare function addDestroyAST(content: string, expression: string): string;
12
+ export declare function addDestroyAST(content: string, expression: string, marker?: string): string;
9
13
  /**
10
14
  * Legacy regex/line-based implementation preserved as fallback.
11
15
  */
12
- export declare function legacyAddDestroy(content: string, expression: string): string;
16
+ export declare function legacyAddDestroy(content: string, expression: string, marker?: string): string;
13
17
  /**
14
18
  * Adds a destroy expression to the top of onUnload() or uninit() in
15
19
  * browser-init.js (LIFO ordering — newest first).
@@ -18,4 +22,4 @@ export declare function legacyAddDestroy(content: string, expression: string): s
18
22
  * @param expression - The destroy expression (e.g., "MyComponent.destroy()")
19
23
  * @returns true if added, false if already present
20
24
  */
21
- export declare function addDestroyToBrowserInit(engineDir: string, expression: string): Promise<boolean>;
25
+ export declare function addDestroyToBrowserInit(engineDir: string, expression: string, marker?: string): Promise<boolean>;
@@ -12,11 +12,16 @@ import { detectIndent, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
13
  import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
+ const DEFAULT_MARKER = 'FIREFORGE:';
15
16
  /**
16
17
  * AST-based implementation: finds onUnload()/uninit() method body and
17
18
  * inserts the destroy block at the top (LIFO ordering).
19
+ *
20
+ * `marker` is prefixed to the generated comment so wire-generated
21
+ * edits carry the patch-lint `// <MARKER>:` signature
22
+ * `lintModificationComments` looks for (eval 1 Finding #9).
18
23
  */
19
- export function addDestroyAST(content, expression) {
24
+ export function addDestroyAST(content, expression, marker = DEFAULT_MARKER) {
20
25
  const name = extractNameFromExpression(expression);
21
26
  // See wire-init.ts for the rationale: the template interpolates the
22
27
  // expression verbatim, so a bare `Foo.bar` compiled to `Foo.bar;`
@@ -44,7 +49,7 @@ export function addDestroyAST(content, expression) {
44
49
  indent = ' ';
45
50
  }
46
51
  const block = [
47
- `${indent}// ${name} destroy`,
52
+ `${indent}// ${marker} wire-destroy ${name}`,
48
53
  `${indent}try {`,
49
54
  `${indent} if (typeof ${name} !== "undefined") {`,
50
55
  `${indent} ${callExpression};`,
@@ -59,7 +64,7 @@ export function addDestroyAST(content, expression) {
59
64
  /**
60
65
  * Legacy regex/line-based implementation preserved as fallback.
61
66
  */
62
- export function legacyAddDestroy(content, expression) {
67
+ export function legacyAddDestroy(content, expression, marker = DEFAULT_MARKER) {
63
68
  const name = extractNameFromExpression(expression);
64
69
  // Match the AST path on the call-coercion contract so fallback vs AST
65
70
  // emits identical blocks (see wire-init.ts).
@@ -73,7 +78,7 @@ export function legacyAddDestroy(content, expression) {
73
78
  }
74
79
  const insertIndex = found.braceIndex + 1;
75
80
  const block = [
76
- ` // ${name} destroy`,
81
+ ` // ${marker} wire-destroy ${name}`,
77
82
  ` try {`,
78
83
  ` if (typeof ${name} !== "undefined") {`,
79
84
  ` ${callExpression};`,
@@ -93,7 +98,7 @@ export function legacyAddDestroy(content, expression) {
93
98
  * @param expression - The destroy expression (e.g., "MyComponent.destroy()")
94
99
  * @returns true if added, false if already present
95
100
  */
96
- export async function addDestroyToBrowserInit(engineDir, expression) {
101
+ export async function addDestroyToBrowserInit(engineDir, expression, marker = DEFAULT_MARKER) {
97
102
  validateWireName(expression, 'destroy expression');
98
103
  const filePath = join(engineDir, BROWSER_INIT_JS);
99
104
  if (!(await pathExists(filePath))) {
@@ -109,7 +114,7 @@ export async function addDestroyToBrowserInit(engineDir, expression) {
109
114
  if (destroyPattern.test(content)) {
110
115
  return false;
111
116
  }
112
- const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression), () => legacyAddDestroy(content, expression), BROWSER_INIT_JS);
117
+ const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression, marker), () => legacyAddDestroy(content, expression, marker), BROWSER_INIT_JS);
113
118
  if (usedFallback) {
114
119
  assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
115
120
  }
@@ -16,6 +16,23 @@ export declare function addDomFragmentTokenized(content: string, includeDirectiv
16
16
  * Legacy line-based implementation preserved as fallback.
17
17
  */
18
18
  export declare function legacyAddDomFragment(content: string, includeDirective: string): string;
19
+ /**
20
+ * Dry-run precheck for `addDomFragment`. Reads the resolved chrome
21
+ * document and verifies it either already contains the `#include`
22
+ * directive (the idempotent-skip case) OR offers a locatable insertion
23
+ * point via {@link addDomFragmentTokenized} / {@link legacyAddDomFragment}.
24
+ * Throws the same `Could not find insertion point in chrome document`
25
+ * error the real run would throw when neither condition holds.
26
+ *
27
+ * Motivating case (2026-04-21 eval, Finding #12): `fireforge wire ...
28
+ * --dry-run` previewed a plausible mutation plan against
29
+ * `tokenHostDocuments[0]`, then `fireforge wire ...` without
30
+ * `--dry-run` threw `Could not find insertion point in chrome document`
31
+ * on the same arguments. The real run had always called the insertion
32
+ * helpers; dry-run did not. This helper runs the same check in the
33
+ * preview pass so plan and execution disagree less.
34
+ */
35
+ export declare function probeDomFragmentInsertionPoint(engineDir: string, domFilePath: string, targetPath?: string): Promise<void>;
19
36
  /**
20
37
  * Inserts a `#include` directive for an `.inc.xhtml` file into the top-level
21
38
  * chrome document (default: `browser/base/content/browser.xhtml`), before