@hominis/fireforge 0.44.1 → 0.44.2
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 +36 -0
- package/README.md +18 -110
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +33 -11
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +34 -0
- package/dist/src/core/token-variant.js +84 -16
- package/dist/src/types/commands/options.d.ts +30 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
|
|
|
11
11
|
import { assertTokenCategoryExists, categoryHeaderExists, declareCategoryBanner, findCategorySection, findTokenDeclarationInRoot, } from './token-category.js';
|
|
12
12
|
import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
|
|
13
13
|
import { addTokenToDocs } from './token-docs.js';
|
|
14
|
-
import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
|
|
14
|
+
import { findVariantBlockDeclaration, insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, variantBlockQualifier, } from './token-variant.js';
|
|
15
15
|
/**
|
|
16
16
|
* Dark mode behaviour for a token.
|
|
17
17
|
*
|
|
@@ -95,7 +95,9 @@ function normalizeVariantOption(options) {
|
|
|
95
95
|
}
|
|
96
96
|
if (options.createCategory === true) {
|
|
97
97
|
throw new InvalidArgumentError('--create-category cannot be combined with --variant; variant declarations are routed ' +
|
|
98
|
-
'into a :root<selector> block, not a category section.'
|
|
98
|
+
'into a :root<selector> block, not a category section. Add the BASE token first ' +
|
|
99
|
+
'(with --create-category) to author the category, then add each variant with ' +
|
|
100
|
+
'--variant.', 'variant');
|
|
99
101
|
}
|
|
100
102
|
const result = validateVariantSelector(options.variant);
|
|
101
103
|
if (!result.ok) {
|
|
@@ -103,6 +105,30 @@ function normalizeVariantOption(options) {
|
|
|
103
105
|
}
|
|
104
106
|
return result.value;
|
|
105
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Narrows {@link AddTokenOptions.category} for the base-declaration path,
|
|
110
|
+
* which cannot proceed without one.
|
|
111
|
+
*
|
|
112
|
+
* `--category` used to be unconditionally required, INCLUDING under
|
|
113
|
+
* `--variant`, where the declaration is routed into a `:root<selector>`
|
|
114
|
+
* block and the category names nothing about where the token lands. The
|
|
115
|
+
* requirement existed so the argument would not be silently discarded —
|
|
116
|
+
* a fair goal that produced a mandatory argument describing nothing. Under
|
|
117
|
+
* a variant it is now optional; everywhere else this states the reason
|
|
118
|
+
* rather than leaving commander to print a bare "required option not
|
|
119
|
+
* specified".
|
|
120
|
+
*
|
|
121
|
+
* @param options - Token options
|
|
122
|
+
* @returns The category
|
|
123
|
+
*/
|
|
124
|
+
function requireCategory(options) {
|
|
125
|
+
if (options.category === undefined || options.category.trim() === '') {
|
|
126
|
+
throw new InvalidArgumentError('A base token declaration lands in a category section, so --category is required. ' +
|
|
127
|
+
'Pass --variant <selector> instead if this declaration belongs in a ' +
|
|
128
|
+
':root<selector> block, where no category applies.', 'category');
|
|
129
|
+
}
|
|
130
|
+
return options.category;
|
|
131
|
+
}
|
|
106
132
|
/** Throws when the tokens CSS file is missing (variant mode skips category checks). */
|
|
107
133
|
async function assertTokensCssExists(engineDir, tokensCssPath) {
|
|
108
134
|
if (!(await pathExists(join(engineDir, tokensCssPath)))) {
|
|
@@ -112,15 +138,22 @@ async function assertTokensCssExists(engineDir, tokensCssPath) {
|
|
|
112
138
|
/**
|
|
113
139
|
* Routes a declaration into the top-level `:root<variant>` block — creating
|
|
114
140
|
* the block after the base `:root` block if absent, or appending to it if
|
|
115
|
-
* present. Idempotent within the block.
|
|
116
|
-
*
|
|
141
|
+
* present. Idempotent within the block.
|
|
142
|
+
*
|
|
143
|
+
* A skip carries the LOCATION of the existing declaration, mirroring
|
|
144
|
+
* {@link evaluateBaseTokenIdempotency}. The bare `{ added: false }` it used
|
|
145
|
+
* to return told the caller nothing, so a re-run intending to CHANGE a
|
|
146
|
+
* value exited 0 having changed nothing and said only "already exists" —
|
|
147
|
+
* while the base path had reported the category and line for the same
|
|
148
|
+
* situation all along.
|
|
117
149
|
*/
|
|
118
150
|
async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant) {
|
|
119
151
|
await assertTokensCssExists(engineDir, tokensCssPath);
|
|
120
152
|
const filePath = join(engineDir, tokensCssPath);
|
|
121
153
|
const lines = (await readText(filePath)).split('\n');
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
const declared = findVariantBlockDeclaration(lines, variant, options.tokenName);
|
|
155
|
+
if (declared !== undefined)
|
|
156
|
+
return { added: false, existing: declared };
|
|
124
157
|
const annotation = getModeAnnotation(options.mode, options.value);
|
|
125
158
|
insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${options.value}; /* ${annotation} */`);
|
|
126
159
|
await writeText(filePath, lines.join('\n'));
|
|
@@ -158,14 +191,17 @@ export async function validateTokenAdd(root, options) {
|
|
|
158
191
|
validateTokenNameSyntax(options.tokenName);
|
|
159
192
|
await validateTokenPrefix(root, options);
|
|
160
193
|
validateDarkValue(options);
|
|
161
|
-
// Variant mode targets a `:root<attr>` block, not a category section
|
|
162
|
-
//
|
|
163
|
-
//
|
|
194
|
+
// Variant mode targets a `:root<attr>` block, not a category section, so
|
|
195
|
+
// no category is consulted — and none is required. When one IS passed it
|
|
196
|
+
// is still checked, so a typo'd category is not silently accepted just
|
|
197
|
+
// because the flag happens to be inert here.
|
|
164
198
|
if (normalizeVariantOption(options) !== undefined) {
|
|
165
|
-
|
|
199
|
+
if (options.category !== undefined && options.category.trim() !== '') {
|
|
200
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
|
|
201
|
+
}
|
|
166
202
|
return;
|
|
167
203
|
}
|
|
168
|
-
await assertTokenCategoryExists(engineDir, tokensCssPath, options
|
|
204
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, requireCategory(options), options.createCategory === true);
|
|
169
205
|
}
|
|
170
206
|
/**
|
|
171
207
|
* Adds a design token to the CSS file and documentation.
|
|
@@ -209,16 +245,19 @@ export async function addToken(root, options) {
|
|
|
209
245
|
// docs row). Done before the base-CSS path so an override of an existing
|
|
210
246
|
// base token is not short-circuited by the global idempotency check.
|
|
211
247
|
if (normalizedVariant !== undefined) {
|
|
212
|
-
//
|
|
213
|
-
// declaration
|
|
214
|
-
|
|
215
|
-
|
|
248
|
+
// A category is optional here and names nothing about where the
|
|
249
|
+
// declaration lands, but a category that was passed is still verified.
|
|
250
|
+
if (options.category !== undefined && options.category.trim() !== '') {
|
|
251
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
|
|
252
|
+
}
|
|
253
|
+
const { added, existing } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
|
|
216
254
|
return {
|
|
217
255
|
cssAdded: added,
|
|
218
256
|
docsAdded: false,
|
|
219
257
|
unmappedAdded: false,
|
|
220
258
|
countUpdated: false,
|
|
221
259
|
skipped: !added,
|
|
260
|
+
...(existing !== undefined ? { skippedExisting: existing } : {}),
|
|
222
261
|
};
|
|
223
262
|
}
|
|
224
263
|
// --- CSS file ---
|
|
@@ -234,7 +273,11 @@ export async function addToken(root, options) {
|
|
|
234
273
|
};
|
|
235
274
|
}
|
|
236
275
|
// --- Documentation ---
|
|
237
|
-
|
|
276
|
+
// Only the base path reaches here (variant declarations returned above),
|
|
277
|
+
// so a category is guaranteed — `requireCategory` already ran inside
|
|
278
|
+
// `addTokenToCSS`. Re-narrowing rather than asserting keeps the docs
|
|
279
|
+
// input's required `category` honest.
|
|
280
|
+
const docsResult = await addTokenToDocs(engineDir, { ...options, category: requireCategory(options) }, getModeAnnotation(options.mode, options.value));
|
|
238
281
|
return {
|
|
239
282
|
cssAdded,
|
|
240
283
|
docsAdded: docsResult.docsAdded,
|
|
@@ -274,6 +317,16 @@ function insertThemeAttributeOverrides(lines, options) {
|
|
|
274
317
|
const value = pick === 'dark' ? options.darkValue : options.value;
|
|
275
318
|
insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${value};`);
|
|
276
319
|
written.push(`:root${variant}`);
|
|
320
|
+
// A qualified block is matched — skipping it is the worse failure, and
|
|
321
|
+
// the one that shipped — but the qualifier narrows where the token
|
|
322
|
+
// applies, so say which selector was written through rather than
|
|
323
|
+
// letting the operator meet the narrowing as a theme bug later.
|
|
324
|
+
const qualifier = variantBlockQualifier(lines, variant);
|
|
325
|
+
if (qualifier !== undefined && qualifier.length > 0) {
|
|
326
|
+
warn(`Wrote ${options.tokenName} into the qualified block ":root${variant}${qualifier}". ` +
|
|
327
|
+
`The "${qualifier}" qualifier narrows where this override applies; ` +
|
|
328
|
+
'add an unqualified block if it should apply to every case.');
|
|
329
|
+
}
|
|
277
330
|
}
|
|
278
331
|
return written;
|
|
279
332
|
}
|
|
@@ -304,7 +357,8 @@ function insertDarkModeOverride(lines, options) {
|
|
|
304
357
|
*/
|
|
305
358
|
async function addTokenToCSS(engineDir, options, tokensCssPath) {
|
|
306
359
|
const filePath = join(engineDir, tokensCssPath);
|
|
307
|
-
|
|
360
|
+
const category = requireCategory(options);
|
|
361
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, category, options.createCategory === true);
|
|
308
362
|
const content = await readText(filePath);
|
|
309
363
|
const lines = content.split('\n');
|
|
310
364
|
// Per-category idempotency: a declaration in the TARGET
|
|
@@ -324,11 +378,11 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
|
|
|
324
378
|
// token insertion — the file is written exactly once, so a failure
|
|
325
379
|
// between "banner declared" and "token inserted" cannot occur.
|
|
326
380
|
let categoryCreated = false;
|
|
327
|
-
if (options.createCategory === true && !categoryHeaderExists(lines,
|
|
328
|
-
declareCategoryBanner(lines,
|
|
381
|
+
if (options.createCategory === true && !categoryHeaderExists(lines, category)) {
|
|
382
|
+
declareCategoryBanner(lines, category, tokensCssPath);
|
|
329
383
|
categoryCreated = true;
|
|
330
384
|
}
|
|
331
|
-
const { categoryLine, sectionEnd } = findCategorySection(lines,
|
|
385
|
+
const { categoryLine, sectionEnd } = findCategorySection(lines, category, tokensCssPath);
|
|
332
386
|
// Build the insertion lines
|
|
333
387
|
const insertLines = [];
|
|
334
388
|
if (options.description) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* `:root<variant>` block so `token-manager.ts` can splice a declaration into
|
|
10
10
|
* it, keeping all token authoring in the CLI.
|
|
11
11
|
*/
|
|
12
|
+
import type { TokenDeclarationLocation } from './token-category.js';
|
|
12
13
|
/**
|
|
13
14
|
* Outcome of {@link validateVariantSelector}. `ok: true` carries the
|
|
14
15
|
* normalized (quoted) selector fragment; `ok: false` carries a
|
|
@@ -36,8 +37,41 @@ export declare function validateVariantSelector(raw: unknown): VariantValidation
|
|
|
36
37
|
* the block, which is only wanted for the explicit `--variant` flow.
|
|
37
38
|
*/
|
|
38
39
|
export declare function variantBlockExists(lines: string[], variant: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The pseudo-class qualifier on the matched `:root<variant>` block, or
|
|
42
|
+
* `undefined` when no block matched. `''` for an unqualified block.
|
|
43
|
+
*
|
|
44
|
+
* A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
|
|
45
|
+
* writing into that block scopes the declaration to the qualified case, and
|
|
46
|
+
* that may not be what the operator meant. Matching it is still the right
|
|
47
|
+
* call — silently skipping it is the worse failure, and the one that
|
|
48
|
+
* shipped — but the override path uses this to say out loud which selector
|
|
49
|
+
* it wrote through, so the narrowing is a visible decision rather than a
|
|
50
|
+
* theme bug discovered later.
|
|
51
|
+
*
|
|
52
|
+
* @param lines - Tokens CSS split into lines
|
|
53
|
+
* @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
|
|
54
|
+
* @returns The qualifier, or undefined when no block matched
|
|
55
|
+
*/
|
|
56
|
+
export declare function variantBlockQualifier(lines: string[], variant: string): string | undefined;
|
|
39
57
|
/** True when the `:root<variant>` block already declares `tokenName`. */
|
|
40
58
|
export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Locates an existing declaration of `tokenName` inside the
|
|
61
|
+
* `:root<variant>` block, or `undefined` when the block has none (or does
|
|
62
|
+
* not exist).
|
|
63
|
+
*
|
|
64
|
+
* The located form of {@link variantBlockHasToken}: the variant add path
|
|
65
|
+
* reports a skip the way the base path does — naming the block and the
|
|
66
|
+
* line — instead of returning a bare boolean that leaves a no-op re-run
|
|
67
|
+
* indistinguishable from a write.
|
|
68
|
+
*
|
|
69
|
+
* @param lines - Tokens CSS split into lines
|
|
70
|
+
* @param variant - Attribute selector fragment
|
|
71
|
+
* @param tokenName - Full token name including prefix
|
|
72
|
+
* @returns The declaration's 1-based line, or undefined
|
|
73
|
+
*/
|
|
74
|
+
export declare function findVariantBlockDeclaration(lines: string[], variant: string, tokenName: string): TokenDeclarationLocation | undefined;
|
|
41
75
|
/**
|
|
42
76
|
* Splices `declLine` into the `:root<variant>` block, creating the block
|
|
43
77
|
* (after the base `:root` block) when absent or appending after the last
|
|
@@ -53,15 +53,27 @@ function canonicalSelector(selector) {
|
|
|
53
53
|
return selector.replace(/\s+/g, '').replace(/["']/g, '');
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Splits the `:root[…]` selector on `line` into its attribute run and any
|
|
57
|
+
* trailing qualifier, or `null` when the line carries none.
|
|
58
58
|
*
|
|
59
59
|
* Index arithmetic rather than `/:root\[[^{]*\]/`: `[^{]` also matches `]`,
|
|
60
60
|
* so that pattern backtracks quadratically on a line repeating `:root[`
|
|
61
|
-
* (CodeQL `js/polynomial-redos`).
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* (CodeQL `js/polynomial-redos`). This walk is linear and allocation-free
|
|
62
|
+
* per group.
|
|
63
|
+
*
|
|
64
|
+
* It consumes CONSECUTIVE `[…]` groups and stops at the first character that
|
|
65
|
+
* is not `[`. The previous implementation instead sliced to the LAST `]`
|
|
66
|
+
* before the brace, which kept multi-attribute selectors comparing as a
|
|
67
|
+
* whole — that property is preserved here, since consecutive groups are all
|
|
68
|
+
* consumed — but it also swallowed a pseudo-class tail. On
|
|
69
|
+
* `:root[data-theme="light"]:not([data-private]) {` the last `]` is the one
|
|
70
|
+
* inside `:not(…)`, so the computed fragment was
|
|
71
|
+
* `:root[data-theme="light"]:not([data-private]` and the canonical
|
|
72
|
+
* comparison could never match. The unqualified
|
|
73
|
+
* `:root[data-theme="dark"] { }` beside it matched fine, and that asymmetry
|
|
74
|
+
* WAS the bug: an override add mirrored itself into the dark block and
|
|
75
|
+
* silently skipped the light one, which is precisely the half-finished
|
|
76
|
+
* themed edit `THEME_ATTRIBUTE_VARIANTS` exists to prevent.
|
|
65
77
|
*/
|
|
66
78
|
function rootAttributeSelector(line) {
|
|
67
79
|
const start = line.indexOf(':root[');
|
|
@@ -69,10 +81,18 @@ function rootAttributeSelector(line) {
|
|
|
69
81
|
return null;
|
|
70
82
|
const brace = line.indexOf('{', start);
|
|
71
83
|
const limit = brace === -1 ? line.length : brace;
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
let cursor = start + ':root'.length;
|
|
85
|
+
let end = cursor;
|
|
86
|
+
while (cursor < limit && line[cursor] === '[') {
|
|
87
|
+
const close = line.indexOf(']', cursor);
|
|
88
|
+
if (close === -1 || close >= limit)
|
|
89
|
+
break;
|
|
90
|
+
cursor = close + 1;
|
|
91
|
+
end = cursor;
|
|
92
|
+
}
|
|
93
|
+
if (end === start + ':root'.length)
|
|
74
94
|
return null;
|
|
75
|
-
return line.slice(start,
|
|
95
|
+
return { attributes: line.slice(start, end), qualifier: line.slice(end, limit).trim() };
|
|
76
96
|
}
|
|
77
97
|
/**
|
|
78
98
|
* Finds the top-level `:root<variant>` block whose attribute selector
|
|
@@ -89,7 +109,7 @@ function findVariantBlock(lines, variant) {
|
|
|
89
109
|
for (let i = 0; i < stripped.length; i++) {
|
|
90
110
|
const line = stripped[i] ?? '';
|
|
91
111
|
const selector = rootAttributeSelector(line);
|
|
92
|
-
if (selector === null || canonicalSelector(selector) !== want)
|
|
112
|
+
if (selector === null || canonicalSelector(selector.attributes) !== want)
|
|
93
113
|
continue;
|
|
94
114
|
let openLine = /\{/.test(line) ? i : -1;
|
|
95
115
|
if (openLine === -1) {
|
|
@@ -108,7 +128,7 @@ function findVariantBlock(lines, variant) {
|
|
|
108
128
|
const close = findBlockCloseIndex(stripped, openLine);
|
|
109
129
|
if (close === -1)
|
|
110
130
|
continue;
|
|
111
|
-
return { open: openLine, close };
|
|
131
|
+
return { open: openLine, close, qualifier: selector.qualifier };
|
|
112
132
|
}
|
|
113
133
|
return null;
|
|
114
134
|
}
|
|
@@ -138,16 +158,64 @@ function findVariantBlockInsertionPoint(lines) {
|
|
|
138
158
|
export function variantBlockExists(lines, variant) {
|
|
139
159
|
return findVariantBlock(lines, variant) !== null;
|
|
140
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The pseudo-class qualifier on the matched `:root<variant>` block, or
|
|
163
|
+
* `undefined` when no block matched. `''` for an unqualified block.
|
|
164
|
+
*
|
|
165
|
+
* A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
|
|
166
|
+
* writing into that block scopes the declaration to the qualified case, and
|
|
167
|
+
* that may not be what the operator meant. Matching it is still the right
|
|
168
|
+
* call — silently skipping it is the worse failure, and the one that
|
|
169
|
+
* shipped — but the override path uses this to say out loud which selector
|
|
170
|
+
* it wrote through, so the narrowing is a visible decision rather than a
|
|
171
|
+
* theme bug discovered later.
|
|
172
|
+
*
|
|
173
|
+
* @param lines - Tokens CSS split into lines
|
|
174
|
+
* @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
|
|
175
|
+
* @returns The qualifier, or undefined when no block matched
|
|
176
|
+
*/
|
|
177
|
+
export function variantBlockQualifier(lines, variant) {
|
|
178
|
+
return findVariantBlock(lines, variant)?.qualifier;
|
|
179
|
+
}
|
|
141
180
|
/** True when the `:root<variant>` block already declares `tokenName`. */
|
|
142
181
|
export function variantBlockHasToken(lines, variant, tokenName) {
|
|
182
|
+
return findVariantBlockDeclaration(lines, variant, tokenName) !== undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Locates an existing declaration of `tokenName` inside the
|
|
186
|
+
* `:root<variant>` block, or `undefined` when the block has none (or does
|
|
187
|
+
* not exist).
|
|
188
|
+
*
|
|
189
|
+
* The located form of {@link variantBlockHasToken}: the variant add path
|
|
190
|
+
* reports a skip the way the base path does — naming the block and the
|
|
191
|
+
* line — instead of returning a bare boolean that leaves a no-op re-run
|
|
192
|
+
* indistinguishable from a write.
|
|
193
|
+
*
|
|
194
|
+
* @param lines - Tokens CSS split into lines
|
|
195
|
+
* @param variant - Attribute selector fragment
|
|
196
|
+
* @param tokenName - Full token name including prefix
|
|
197
|
+
* @returns The declaration's 1-based line, or undefined
|
|
198
|
+
*/
|
|
199
|
+
export function findVariantBlockDeclaration(lines, variant, tokenName) {
|
|
143
200
|
const block = findVariantBlock(lines, variant);
|
|
144
201
|
if (!block)
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
|
|
202
|
+
return undefined;
|
|
203
|
+
// Comments are stripped across the WHOLE block before scanning, matching
|
|
204
|
+
// what the boolean form always did: a `/* … */` spanning several lines
|
|
205
|
+
// must not leave its tail looking like a declaration.
|
|
206
|
+
const blockLines = lines.slice(block.open, block.close + 1);
|
|
207
|
+
// Blank the comment's CHARACTERS rather than deleting it: removing a
|
|
208
|
+
// multi-line comment outright collapses its lines and shifts every line
|
|
209
|
+
// number after it, which is the one thing this function exists to report.
|
|
210
|
+
const stripped = blockLines
|
|
148
211
|
.join('\n')
|
|
149
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
150
|
-
|
|
212
|
+
.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
|
|
213
|
+
.split('\n');
|
|
214
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
215
|
+
if ((stripped[i] ?? '').includes(`${tokenName}:`))
|
|
216
|
+
return { line: block.open + i + 1 };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
151
219
|
}
|
|
152
220
|
/**
|
|
153
221
|
* Splices `declLine` into the `:root<variant>` block, creating the block
|
|
@@ -539,6 +539,12 @@ export interface RunOptions {
|
|
|
539
539
|
export interface TestOptions {
|
|
540
540
|
/** Run tests in headless mode */
|
|
541
541
|
headless?: boolean;
|
|
542
|
+
/**
|
|
543
|
+
* Pass mozbuild's full verbosity through for the TEST phase by unsetting
|
|
544
|
+
* the coding-agent markers it quiets output on. Off by default, because
|
|
545
|
+
* that quieting is worth keeping for builds.
|
|
546
|
+
*/
|
|
547
|
+
fullOutput?: boolean;
|
|
542
548
|
/** Run incremental UI build before testing */
|
|
543
549
|
build?: boolean;
|
|
544
550
|
/**
|
|
@@ -652,6 +658,12 @@ export interface FurnaceApplyOptions {
|
|
|
652
658
|
force?: boolean;
|
|
653
659
|
/** Watch component directories and re-apply on changes */
|
|
654
660
|
watch?: boolean;
|
|
661
|
+
/**
|
|
662
|
+
* Resolved `--wait-lock` budget in seconds, or undefined for the default.
|
|
663
|
+
* Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
|
|
664
|
+
* engine session lock, so one flag governs both.
|
|
665
|
+
*/
|
|
666
|
+
waitLockSeconds?: number;
|
|
655
667
|
}
|
|
656
668
|
/**
|
|
657
669
|
* Options for the furnace preview command.
|
|
@@ -670,6 +682,12 @@ export interface FurnaceDeployOptions {
|
|
|
670
682
|
force?: boolean;
|
|
671
683
|
/** Skip the validation step (apply only, no accessibility/compatibility checks) */
|
|
672
684
|
skipValidate?: boolean;
|
|
685
|
+
/**
|
|
686
|
+
* Resolved `--wait-lock` budget in seconds, or undefined for the default.
|
|
687
|
+
* Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
|
|
688
|
+
* engine session lock, so one flag governs both.
|
|
689
|
+
*/
|
|
690
|
+
waitLockSeconds?: number;
|
|
673
691
|
}
|
|
674
692
|
/**
|
|
675
693
|
* Options for the furnace refresh command.
|
|
@@ -692,6 +710,12 @@ export interface FurnaceSyncOptions {
|
|
|
692
710
|
dryRun?: boolean;
|
|
693
711
|
/** Conflict resolution strategy for three-way merge (ours = keep local, theirs = accept upstream) */
|
|
694
712
|
strategy?: 'ours' | 'theirs';
|
|
713
|
+
/**
|
|
714
|
+
* Resolved `--wait-lock` budget in seconds, or undefined for the default.
|
|
715
|
+
* Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
|
|
716
|
+
* engine session lock, so one flag governs both.
|
|
717
|
+
*/
|
|
718
|
+
waitLockSeconds?: number;
|
|
695
719
|
}
|
|
696
720
|
/**
|
|
697
721
|
* Options for the furnace validate command.
|
|
@@ -965,7 +989,12 @@ export interface StatusOptions {
|
|
|
965
989
|
* Options for the token add command.
|
|
966
990
|
*/
|
|
967
991
|
export interface TokenAddOptions {
|
|
968
|
-
|
|
992
|
+
/**
|
|
993
|
+
* Required for a base declaration; optional under
|
|
994
|
+
* {@link TokenAddOptions.variant}, which routes into a `:root<selector>`
|
|
995
|
+
* block where no category applies.
|
|
996
|
+
*/
|
|
997
|
+
category?: string;
|
|
969
998
|
mode: string;
|
|
970
999
|
description?: string;
|
|
971
1000
|
darkValue?: string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment construction for spawned children.
|
|
3
|
+
*
|
|
4
|
+
* Its own module rather than a helper inside `process.ts` only because that
|
|
5
|
+
* file sits on a 500-line budget; the logic belongs to the exec layer.
|
|
6
|
+
*/
|
|
7
|
+
/** The `env` / `envUnset` slice of exec options this module needs. */
|
|
8
|
+
export interface ChildEnvOptions {
|
|
9
|
+
/** Variables to add or overwrite on top of `process.env`. */
|
|
10
|
+
env?: Record<string, string> | undefined;
|
|
11
|
+
/** Variables to remove after the merge. */
|
|
12
|
+
envUnset?: readonly string[] | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The child's environment: `process.env`, with `env` merged over it, then
|
|
16
|
+
* every `envUnset` key removed.
|
|
17
|
+
*
|
|
18
|
+
* One helper rather than five inline spreads — the removal step is easy to
|
|
19
|
+
* add at four of the five spawn sites and forget at the fifth, which would
|
|
20
|
+
* make the option silently inert exactly where it was not tested.
|
|
21
|
+
*
|
|
22
|
+
* Removal is a real removal, not an assignment to `''`: mozbuild's
|
|
23
|
+
* `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
|
|
24
|
+
* an empty value would not unset it. `Reflect.deleteProperty` rather than
|
|
25
|
+
* `delete obj[key]` because the key is computed.
|
|
26
|
+
*
|
|
27
|
+
* @param options - Options carrying `env` / `envUnset`
|
|
28
|
+
* @returns The environment to hand `spawn`
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildChildEnv(options: ChildEnvOptions): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Environment construction for spawned children.
|
|
4
|
+
*
|
|
5
|
+
* Its own module rather than a helper inside `process.ts` only because that
|
|
6
|
+
* file sits on a 500-line budget; the logic belongs to the exec layer.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The child's environment: `process.env`, with `env` merged over it, then
|
|
10
|
+
* every `envUnset` key removed.
|
|
11
|
+
*
|
|
12
|
+
* One helper rather than five inline spreads — the removal step is easy to
|
|
13
|
+
* add at four of the five spawn sites and forget at the fifth, which would
|
|
14
|
+
* make the option silently inert exactly where it was not tested.
|
|
15
|
+
*
|
|
16
|
+
* Removal is a real removal, not an assignment to `''`: mozbuild's
|
|
17
|
+
* `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
|
|
18
|
+
* an empty value would not unset it. `Reflect.deleteProperty` rather than
|
|
19
|
+
* `delete obj[key]` because the key is computed.
|
|
20
|
+
*
|
|
21
|
+
* @param options - Options carrying `env` / `envUnset`
|
|
22
|
+
* @returns The environment to hand `spawn`
|
|
23
|
+
*/
|
|
24
|
+
export function buildChildEnv(options) {
|
|
25
|
+
const merged = { ...process.env, ...options.env };
|
|
26
|
+
for (const key of options.envUnset ?? []) {
|
|
27
|
+
Reflect.deleteProperty(merged, key);
|
|
28
|
+
}
|
|
29
|
+
return merged;
|
|
30
|
+
}
|
|
@@ -66,6 +66,28 @@ export declare const WAIT_LOCK_ENV_VAR = "FIREFORGE_WAIT_LOCK";
|
|
|
66
66
|
* ABSENCE, which is where it belongs.
|
|
67
67
|
*/
|
|
68
68
|
export declare function resolveWaitLockSeconds(value: string | number | boolean | undefined): number | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* The session-wide wait budget in MILLISECONDS, or undefined when none is
|
|
71
|
+
* set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
|
|
72
|
+
*
|
|
73
|
+
* Exists for the SECONDARY locks a command takes after the one its
|
|
74
|
+
* `--wait-lock` flag was threaded to. A furnace mutation takes the engine
|
|
75
|
+
* session lock and then `.fireforge/furnace.lock`; only the first had a
|
|
76
|
+
* budget, so a contended deploy died on the file lock's fixed 30 s while
|
|
77
|
+
* its operator believed a much larger budget was in force. Threading the
|
|
78
|
+
* flag reaches the three furnace commands that declare it — this reaches
|
|
79
|
+
* the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
|
|
80
|
+
* `override`, `chrome-doc`, …), which have no flag to thread but are just
|
|
81
|
+
* as contended.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately env-only: a flag belongs to one invocation and is passed
|
|
84
|
+
* explicitly where it applies, while the environment is exactly the place a
|
|
85
|
+
* session-wide default is expressed. An explicit budget at a call site
|
|
86
|
+
* always wins over this.
|
|
87
|
+
*
|
|
88
|
+
* @returns The session budget in milliseconds, or undefined
|
|
89
|
+
*/
|
|
90
|
+
export declare function sessionWaitLockMs(): number | undefined;
|
|
69
91
|
/**
|
|
70
92
|
* True when `command` already declares `--wait-lock` itself. Used by the
|
|
71
93
|
* CLI wiring to add the accept-and-ignore variant only where the honoring
|
|
@@ -85,5 +107,11 @@ export declare function ensureWaitLockOptionEverywhere(command: Command): void;
|
|
|
85
107
|
* for the bare flag or a validated integer; feed it through
|
|
86
108
|
* {@link resolveWaitLockSeconds} at the call site to obtain the
|
|
87
109
|
* `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
|
|
110
|
+
*
|
|
111
|
+
* A command can take MORE than one lock — a furnace mutation takes the
|
|
112
|
+
* engine session lock and then `.fireforge/furnace.lock` — and the budget
|
|
113
|
+
* has to reach all of them or the flag is a half-truth. The secondary locks
|
|
114
|
+
* read the session budget through {@link sessionWaitLockMs} rather than
|
|
115
|
+
* having it threaded to every call site.
|
|
88
116
|
*/
|
|
89
117
|
export declare function addWaitLockOption(command: Command): Command;
|
|
@@ -144,6 +144,31 @@ export function resolveWaitLockSeconds(value) {
|
|
|
144
144
|
}
|
|
145
145
|
return parsed;
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The session-wide wait budget in MILLISECONDS, or undefined when none is
|
|
149
|
+
* set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
|
|
150
|
+
*
|
|
151
|
+
* Exists for the SECONDARY locks a command takes after the one its
|
|
152
|
+
* `--wait-lock` flag was threaded to. A furnace mutation takes the engine
|
|
153
|
+
* session lock and then `.fireforge/furnace.lock`; only the first had a
|
|
154
|
+
* budget, so a contended deploy died on the file lock's fixed 30 s while
|
|
155
|
+
* its operator believed a much larger budget was in force. Threading the
|
|
156
|
+
* flag reaches the three furnace commands that declare it — this reaches
|
|
157
|
+
* the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
|
|
158
|
+
* `override`, `chrome-doc`, …), which have no flag to thread but are just
|
|
159
|
+
* as contended.
|
|
160
|
+
*
|
|
161
|
+
* Deliberately env-only: a flag belongs to one invocation and is passed
|
|
162
|
+
* explicitly where it applies, while the environment is exactly the place a
|
|
163
|
+
* session-wide default is expressed. An explicit budget at a call site
|
|
164
|
+
* always wins over this.
|
|
165
|
+
*
|
|
166
|
+
* @returns The session budget in milliseconds, or undefined
|
|
167
|
+
*/
|
|
168
|
+
export function sessionWaitLockMs() {
|
|
169
|
+
const seconds = waitLockSecondsFromEnv();
|
|
170
|
+
return seconds === undefined ? undefined : seconds * 1000;
|
|
171
|
+
}
|
|
147
172
|
/**
|
|
148
173
|
* Registers `--wait-lock [seconds]` on a command that takes NO lock, so a
|
|
149
174
|
* scripted sequence can blanket-append the flag without hitting a usage
|
|
@@ -188,7 +213,13 @@ export function ensureWaitLockOptionEverywhere(command) {
|
|
|
188
213
|
* for the bare flag or a validated integer; feed it through
|
|
189
214
|
* {@link resolveWaitLockSeconds} at the call site to obtain the
|
|
190
215
|
* `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
|
|
216
|
+
*
|
|
217
|
+
* A command can take MORE than one lock — a furnace mutation takes the
|
|
218
|
+
* engine session lock and then `.fireforge/furnace.lock` — and the budget
|
|
219
|
+
* has to reach all of them or the flag is a half-truth. The secondary locks
|
|
220
|
+
* read the session budget through {@link sessionWaitLockMs} rather than
|
|
221
|
+
* having it threaded to every call site.
|
|
191
222
|
*/
|
|
192
223
|
export function addWaitLockOption(command) {
|
|
193
|
-
return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release
|
|
224
|
+
return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release EVERY lock this command takes, instead of failing on the default budget. Set ${WAIT_LOCK_ENV_VAR} to apply a budget to every command in a session`, commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
|
|
194
225
|
}
|
|
@@ -28,6 +28,28 @@ export interface ExecOptions {
|
|
|
28
28
|
cwd?: string;
|
|
29
29
|
/** Environment variables */
|
|
30
30
|
env?: Record<string, string>;
|
|
31
|
+
/**
|
|
32
|
+
* Environment variables to REMOVE from the child's inherited environment,
|
|
33
|
+
* applied after {@link ExecOptions.env} is merged over `process.env`.
|
|
34
|
+
*
|
|
35
|
+
* Needed because `env` can only add or overwrite, and some variables are
|
|
36
|
+
* read for their PRESENCE — mozbuild's `is_running_under_coding_agent()`
|
|
37
|
+
* keys on `CLAUDECODE`, so setting it empty would not unset it. An
|
|
38
|
+
* explicit delete says what it means; relying on Node dropping
|
|
39
|
+
* `undefined`-valued env entries would be a semantics gamble at a layer
|
|
40
|
+
* that must not have any.
|
|
41
|
+
*/
|
|
42
|
+
envUnset?: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* Streams to mirror the child's output into, replacing the default
|
|
45
|
+
* `process.stdout` / `process.stderr` on the stdio-inheriting capture
|
|
46
|
+
* path. Lets a caller tee the live output somewhere else (a run log)
|
|
47
|
+
* without a second per-chunk hook.
|
|
48
|
+
*/
|
|
49
|
+
mirror?: {
|
|
50
|
+
stdout?: NodeJS.WritableStream;
|
|
51
|
+
stderr?: NodeJS.WritableStream;
|
|
52
|
+
};
|
|
31
53
|
/** Timeout in milliseconds */
|
|
32
54
|
timeout?: number;
|
|
33
55
|
/**
|