@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.
- package/CHANGELOG.md +56 -0
- package/README.md +46 -24
- package/dist/src/commands/build.js +33 -10
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
- package/dist/src/commands/doctor-furnace.js +2 -0
- package/dist/src/commands/doctor-working-tree.d.ts +29 -0
- package/dist/src/commands/doctor-working-tree.js +93 -0
- package/dist/src/commands/doctor.js +23 -12
- package/dist/src/commands/export-all.js +11 -3
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +133 -4
- package/dist/src/commands/lint.js +70 -6
- package/dist/src/commands/patch/delete.js +4 -1
- package/dist/src/commands/patch/reorder.js +4 -1
- package/dist/src/commands/re-export-files.js +3 -1
- package/dist/src/commands/re-export.js +4 -1
- package/dist/src/commands/register.js +11 -0
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +68 -14
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +50 -8
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +43 -6
- package/dist/src/core/mach.js +57 -7
- package/dist/src/core/manifest-rules.js +10 -1
- package/dist/src/core/manifest-tokenizers.d.ts +6 -0
- package/dist/src/core/manifest-tokenizers.js +28 -0
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +31 -3
- package/dist/src/core/patch-manifest-io.js +10 -0
- package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
- package/dist/src/core/patch-manifest-resolve.js +29 -2
- package/dist/src/core/patch-manifest-validate.js +25 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-coverage.js +24 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.d.ts +9 -3
- package/dist/src/core/wire-init.js +18 -6
- package/dist/src/core/wire-subscript.d.ts +7 -3
- package/dist/src/core/wire-subscript.js +11 -4
- package/dist/src/types/commands/patches.d.ts +23 -0
- package/dist/src/types/furnace.d.ts +9 -0
- package/dist/src/utils/parse.d.ts +7 -0
- package/dist/src/utils/parse.js +15 -0
- 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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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}// ${
|
|
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
|
-
` // ${
|
|
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
|