@hominis/fireforge 0.16.3 → 0.17.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 +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- 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/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- 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/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -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-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.js +18 -5
- 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.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff
|
|
|
10
10
|
import { isBinaryFile } from '../core/git-file-ops.js';
|
|
11
11
|
import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
12
12
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
13
|
-
import { commitExportedPatch } from '../core/patch-export.js';
|
|
13
|
+
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
14
14
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
15
15
|
import { toError } from '../utils/errors.js';
|
|
16
16
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
@@ -19,7 +19,7 @@ import { pickDefined } from '../utils/options.js';
|
|
|
19
19
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
20
20
|
import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
|
|
21
21
|
import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
|
|
22
|
-
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
22
|
+
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
23
23
|
async function collectExportFiles(paths, files) {
|
|
24
24
|
const collectedFiles = new Set();
|
|
25
25
|
let fileStatuses;
|
|
@@ -286,6 +286,26 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
286
286
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
287
287
|
if (!shouldProceed)
|
|
288
288
|
return;
|
|
289
|
+
// Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
|
|
290
|
+
// supersedes, so a second export targeting a shared file like
|
|
291
|
+
// `browser/themes/shared/jar.inc.mn` happily created a queue where
|
|
292
|
+
// two patches both listed the same file in `filesAffected`. `verify`
|
|
293
|
+
// then failed immediately on "cross-patch filesAffected conflicts".
|
|
294
|
+
// `confirmSupersedePatches` might already have confirmed full
|
|
295
|
+
// supersedes above; pass their filenames through so we do not flag
|
|
296
|
+
// a file claimed by a patch that is about to be removed.
|
|
297
|
+
const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
|
|
298
|
+
const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
|
|
299
|
+
const shouldProceedPastOverlap = await guardOwnershipOverlap({
|
|
300
|
+
patchesDir: paths.patches,
|
|
301
|
+
filesAffected,
|
|
302
|
+
supersedingFilenames,
|
|
303
|
+
allowOverlap: options.allowOverlap === true,
|
|
304
|
+
isInteractive,
|
|
305
|
+
s,
|
|
306
|
+
});
|
|
307
|
+
if (!shouldProceedPastOverlap)
|
|
308
|
+
return;
|
|
289
309
|
const { patchFilename, superseded } = await commitExportedPatch({
|
|
290
310
|
patchesDir: paths.patches,
|
|
291
311
|
category: selectedCategory,
|
|
@@ -327,6 +347,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
327
347
|
.option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
|
|
328
348
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
|
|
329
349
|
.option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
|
|
350
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
|
|
330
351
|
.action(withErrorHandling(async (paths, options) => {
|
|
331
352
|
const { category, ...rest } = options;
|
|
332
353
|
await exportCommand(getProjectRoot(), paths, {
|
|
@@ -117,9 +117,16 @@ add_task(async function test_${taskSuffix}_files_packaged() {
|
|
|
117
117
|
["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
|
|
118
118
|
"${name}.xhtml",
|
|
119
119
|
);
|
|
120
|
+
// The scoped CSS is registered through jar.inc.mn under
|
|
121
|
+
// \`content/browser/<name>-chrome.css\` (see \`chromeDocJarIncMnCssEntry\`
|
|
122
|
+
// in \`src/commands/furnace/chrome-doc-templates.ts\`), so the packaged
|
|
123
|
+
// file lands under \`chrome/browser/content/browser/\`, not under
|
|
124
|
+
// \`skin/classic/browser/\`. The 2026-04-21 eval's first
|
|
125
|
+
// \`fireforge test --build\` against a scaffolded chrome-doc reported
|
|
126
|
+
// a false failure because the probe was looking at the skin layout.
|
|
120
127
|
probeEither(
|
|
121
|
-
["chrome", "browser", "
|
|
122
|
-
["browser", "chrome", "browser", "
|
|
128
|
+
["chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
129
|
+
["browser", "chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
123
130
|
"${name}-chrome.css",
|
|
124
131
|
);
|
|
125
132
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive read-back helper for `furnace create`. Extracted from
|
|
3
|
+
* `create.ts` so the authoring command stays under the per-file LOC
|
|
4
|
+
* budget.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Asserts that the just-written furnace.json contains the expected
|
|
8
|
+
* custom component entry. The eval run's finding #9 observed a
|
|
9
|
+
* scenario where `furnace create --allow-prefix-mismatch` reported
|
|
10
|
+
* success and wrote the component files, but the subsequent
|
|
11
|
+
* `furnace status` found `custom: {}` in furnace.json — an invariant
|
|
12
|
+
* violation with no clear smoking gun in the code path. Local repros
|
|
13
|
+
* do not trigger it, so the defensive readback is the safest recovery
|
|
14
|
+
* contract we can offer: if the new entry is not visible on the next
|
|
15
|
+
* load, throw a `FurnaceError` so the rollback journal restores the
|
|
16
|
+
* pre-command state and the operator sees the failure instead of a
|
|
17
|
+
* phantom success.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot - Root of the FireForge project
|
|
20
|
+
* @param componentName - Custom-element tag name that must be present
|
|
21
|
+
* in `config.custom` after the write. Throws when absent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertCustomEntryPersisted(projectRoot: string, componentName: string): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Defensive read-back helper for `furnace create`. Extracted from
|
|
4
|
+
* `create.ts` so the authoring command stays under the per-file LOC
|
|
5
|
+
* budget.
|
|
6
|
+
*/
|
|
7
|
+
import { loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
8
|
+
import { FurnaceError } from '../../errors/furnace.js';
|
|
9
|
+
/**
|
|
10
|
+
* Asserts that the just-written furnace.json contains the expected
|
|
11
|
+
* custom component entry. The eval run's finding #9 observed a
|
|
12
|
+
* scenario where `furnace create --allow-prefix-mismatch` reported
|
|
13
|
+
* success and wrote the component files, but the subsequent
|
|
14
|
+
* `furnace status` found `custom: {}` in furnace.json — an invariant
|
|
15
|
+
* violation with no clear smoking gun in the code path. Local repros
|
|
16
|
+
* do not trigger it, so the defensive readback is the safest recovery
|
|
17
|
+
* contract we can offer: if the new entry is not visible on the next
|
|
18
|
+
* load, throw a `FurnaceError` so the rollback journal restores the
|
|
19
|
+
* pre-command state and the operator sees the failure instead of a
|
|
20
|
+
* phantom success.
|
|
21
|
+
*
|
|
22
|
+
* @param projectRoot - Root of the FireForge project
|
|
23
|
+
* @param componentName - Custom-element tag name that must be present
|
|
24
|
+
* in `config.custom` after the write. Throws when absent.
|
|
25
|
+
*/
|
|
26
|
+
export async function assertCustomEntryPersisted(projectRoot, componentName) {
|
|
27
|
+
const persisted = await loadFurnaceConfig(projectRoot);
|
|
28
|
+
if (!(componentName in persisted.custom)) {
|
|
29
|
+
throw new FurnaceError(`Wrote furnace.json but "${componentName}" is missing from config.custom on read-back. ` +
|
|
30
|
+
'This should not happen — please report the issue. As a workaround, ' +
|
|
31
|
+
're-run the command, or add the entry to furnace.json by hand.', componentName);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=create-readback.js.map
|
|
@@ -76,6 +76,17 @@ export declare function mochikitTestFileName(name: string): string;
|
|
|
76
76
|
* depend on the component's shape; operators can extend the test using
|
|
77
77
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
78
78
|
* rely on.
|
|
79
|
+
*
|
|
80
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
81
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
82
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
83
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
84
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
85
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
86
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
87
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
88
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
89
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
79
90
|
*/
|
|
80
91
|
export declare function generateMochikitTestContent(name: string): string;
|
|
81
92
|
/**
|
|
@@ -227,6 +227,17 @@ export function mochikitTestFileName(name) {
|
|
|
227
227
|
* depend on the component's shape; operators can extend the test using
|
|
228
228
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
229
229
|
* rely on.
|
|
230
|
+
*
|
|
231
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
232
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
233
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
234
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
235
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
236
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
237
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
238
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
239
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
240
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
230
241
|
*/
|
|
231
242
|
export function generateMochikitTestContent(name) {
|
|
232
243
|
return `<!DOCTYPE html>
|
|
@@ -244,8 +255,6 @@ export function generateMochikitTestContent(name) {
|
|
|
244
255
|
<script type="module">
|
|
245
256
|
import "chrome://global/content/elements/${name}.mjs";
|
|
246
257
|
|
|
247
|
-
SimpleTest.waitForExplicitFinish();
|
|
248
|
-
|
|
249
258
|
add_task(async function test_${name.replace(/-/g, '_')}_defined() {
|
|
250
259
|
const ctor = await customElements.whenDefined("${name}");
|
|
251
260
|
ok(ctor, "${name} custom element should be defined");
|
|
@@ -19,6 +19,7 @@ import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils
|
|
|
19
19
|
import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
|
|
20
20
|
import { resolveCreateFeatures } from './create-features.js';
|
|
21
21
|
import { scaffoldMochikitTestFiles } from './create-mochikit.js';
|
|
22
|
+
import { assertCustomEntryPersisted } from './create-readback.js';
|
|
22
23
|
import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
|
|
23
24
|
import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
|
|
24
25
|
async function loadAuthoringFurnaceConfig(projectRoot) {
|
|
@@ -266,6 +267,7 @@ async function performCreateMutations(args) {
|
|
|
266
267
|
args.config.custom[args.componentName] = customEntry;
|
|
267
268
|
await snapshotFile(journal, args.furnacePaths.furnaceConfig);
|
|
268
269
|
await writeFurnaceConfig(args.projectRoot, args.config);
|
|
270
|
+
await assertCustomEntryPersisted(args.projectRoot, args.componentName);
|
|
269
271
|
if (args.testStyle === 'browser-chrome') {
|
|
270
272
|
const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
|
|
271
273
|
testFiles.push(...scafFiles);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
4
|
import { text } from '@clack/prompts';
|
|
4
5
|
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
5
6
|
import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
@@ -11,12 +12,46 @@ import { toError } from '../../utils/errors.js';
|
|
|
11
12
|
import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
|
|
12
13
|
import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* File extensions that are definitely FTL resources (not locale
|
|
16
|
+
* directories). A value ending in one of these is almost certainly the
|
|
17
|
+
* result of the operator pointing at a single FTL file instead of the
|
|
18
|
+
* locale directory that contains it.
|
|
19
|
+
*
|
|
20
|
+
* 2026-04-21 eval: `furnace init --ftl-base-path browser/forgefresh.ftl`
|
|
21
|
+
* produced a misleading success path — the subsequent
|
|
22
|
+
* `furnace create --localized` scaffolded an `.mjs` referencing
|
|
23
|
+
* `insertFTLIfNeeded("<name>.ftl")` while furnace.json had no component
|
|
24
|
+
* entry, leaving the scaffold orphaned. Switching to a locale directory
|
|
25
|
+
* (`toolkit/locales/en-US/toolkit/global`) fixed the downstream path.
|
|
26
|
+
* Rejecting file-shaped values up-front keeps the operator on the
|
|
27
|
+
* correct path before any partial state is written.
|
|
28
|
+
*/
|
|
29
|
+
const FTL_FILE_EXTENSIONS = new Set(['.ftl', '.properties', '.dtd']);
|
|
30
|
+
function hasFtlFileExtension(value) {
|
|
31
|
+
const lower = value.toLowerCase();
|
|
32
|
+
const dotIdx = lower.lastIndexOf('.');
|
|
33
|
+
const slashIdx = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
|
|
34
|
+
if (dotIdx <= slashIdx)
|
|
35
|
+
return false; // No extension in the basename.
|
|
36
|
+
return FTL_FILE_EXTENSIONS.has(lower.slice(dotIdx));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validates an FTL base path before writing it to furnace.json.
|
|
40
|
+
* Rejects:
|
|
41
|
+
* - empty values and null bytes;
|
|
42
|
+
* - absolute paths (POSIX or Windows-drive) that escape the engine;
|
|
43
|
+
* - `..` segments that escape the engine;
|
|
44
|
+
* - file-shaped values ending in `.ftl` / `.properties` / `.dtd`
|
|
45
|
+
* (these are locale resources, not directories — the operator
|
|
46
|
+
* almost certainly meant to name the parent directory).
|
|
47
|
+
*
|
|
48
|
+
* When {@link engineDir} is provided and exists on disk, the resolved
|
|
49
|
+
* `engine/${value}` path is probed: if it exists but is not a
|
|
50
|
+
* directory, the same file-shape error fires; if it does not exist yet,
|
|
51
|
+
* a non-blocking warning is logged (a fresh project that has not
|
|
52
|
+
* `fireforge download`-ed yet is the legitimate pre-existence case).
|
|
18
53
|
*/
|
|
19
|
-
function validateFtlBasePath(value) {
|
|
54
|
+
async function validateFtlBasePath(value, engineDir) {
|
|
20
55
|
if (value.length === 0) {
|
|
21
56
|
throw new FurnaceError('ftlBasePath must not be empty.');
|
|
22
57
|
}
|
|
@@ -30,6 +65,40 @@ function validateFtlBasePath(value) {
|
|
|
30
65
|
if (normalized === '..' || normalized.startsWith('../')) {
|
|
31
66
|
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
32
67
|
}
|
|
68
|
+
if (hasFtlFileExtension(value)) {
|
|
69
|
+
throw new FurnaceError(`ftlBasePath "${value}" looks like a file (basename "${basename(value)}" ends in .ftl/.properties/.dtd), but FireForge expects a locale directory such as toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser. Use the parent directory instead.`);
|
|
70
|
+
}
|
|
71
|
+
// Shape probe against the real filesystem when we have an engine
|
|
72
|
+
// directory to anchor against. The probe is best-effort: a missing
|
|
73
|
+
// engine directory or a not-yet-extracted locale tree is
|
|
74
|
+
// legitimate (an operator may `furnace init` before `fireforge
|
|
75
|
+
// download`), so we emit a warning rather than refusing.
|
|
76
|
+
if (engineDir) {
|
|
77
|
+
const resolved = join(engineDir, value);
|
|
78
|
+
try {
|
|
79
|
+
const info = await stat(resolved);
|
|
80
|
+
if (!info.isDirectory()) {
|
|
81
|
+
throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// FurnaceError (from the `isDirectory()` branch above) is a real
|
|
86
|
+
// shape failure — re-throw so the operator sees it.
|
|
87
|
+
if (error instanceof FurnaceError)
|
|
88
|
+
throw error;
|
|
89
|
+
// ENOENT is expected on a fresh project before `fireforge
|
|
90
|
+
// download` has populated engine/; only warn.
|
|
91
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
92
|
+
? error.code
|
|
93
|
+
: undefined;
|
|
94
|
+
if (code === 'ENOENT') {
|
|
95
|
+
warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
|
|
96
|
+
}
|
|
97
|
+
// Any other stat error is also best-effort ignored here — a
|
|
98
|
+
// permission issue or malformed engine checkout will surface on
|
|
99
|
+
// the next command that actually reads the FTL tree.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
33
102
|
}
|
|
34
103
|
/**
|
|
35
104
|
* Runs the furnace init command to create a default furnace.json with
|
|
@@ -42,8 +111,27 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
42
111
|
if ((await furnaceConfigExists(projectRoot)) && !options.force) {
|
|
43
112
|
throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
|
|
44
113
|
}
|
|
45
|
-
const
|
|
114
|
+
const paths = getProjectPaths(projectRoot);
|
|
115
|
+
// Seed the default furnace config with a tokenPrefix derived from
|
|
116
|
+
// fireforge.json's binaryName so `token coverage` sees real tokens on
|
|
117
|
+
// the very first run. The 2026-04-21 eval initialised Furnace, added
|
|
118
|
+
// tokens, ran coverage, and got `0 tokens / N unknown` — the prefix
|
|
119
|
+
// default was absent and the scan had nothing to key off. Loading
|
|
120
|
+
// fireforge.json here is best-effort: a project without one (e.g.
|
|
121
|
+
// mid-setup) falls through to the prefix-less default, and
|
|
122
|
+
// `token coverage` emits the existing "no tokenPrefix" warning.
|
|
123
|
+
let derivedBinaryName;
|
|
124
|
+
try {
|
|
125
|
+
const fireForgeConfig = await loadConfig(projectRoot);
|
|
126
|
+
derivedBinaryName = fireForgeConfig.binaryName;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best-effort only: initialising furnace without a fireforge.json is
|
|
130
|
+
// rare but not forbidden. Skip the prefix default in that case.
|
|
131
|
+
}
|
|
132
|
+
const config = createDefaultFurnaceConfig(derivedBinaryName ? { binaryName: derivedBinaryName } : {});
|
|
46
133
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
134
|
+
const engineForValidation = (await pathExists(paths.engine)) ? paths.engine : undefined;
|
|
47
135
|
// Resolve componentPrefix
|
|
48
136
|
if (options.prefix !== undefined) {
|
|
49
137
|
config.componentPrefix = options.prefix;
|
|
@@ -66,7 +154,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
66
154
|
}
|
|
67
155
|
// Resolve ftlBasePath
|
|
68
156
|
if (options.ftlBasePath !== undefined) {
|
|
69
|
-
validateFtlBasePath(options.ftlBasePath);
|
|
157
|
+
await validateFtlBasePath(options.ftlBasePath, engineForValidation);
|
|
70
158
|
config.ftlBasePath = options.ftlBasePath;
|
|
71
159
|
}
|
|
72
160
|
else if (isInteractive) {
|
|
@@ -80,7 +168,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
80
168
|
}
|
|
81
169
|
const ftlValue = ftlResult.trim();
|
|
82
170
|
if (ftlValue) {
|
|
83
|
-
validateFtlBasePath(ftlValue);
|
|
171
|
+
await validateFtlBasePath(ftlValue, engineForValidation);
|
|
84
172
|
config.ftlBasePath = ftlValue;
|
|
85
173
|
}
|
|
86
174
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { FurnacePreviewOptions } from '../../types/commands/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a targeted Storybook failure message from captured mach output.
|
|
4
|
+
*
|
|
5
|
+
* Exported for the test suite: the heuristic has three branches (backend
|
|
6
|
+
* artifact missing, Storybook dep missing, generic) and regression
|
|
7
|
+
* testing each is easier when the classifier is addressable directly.
|
|
8
|
+
*
|
|
9
|
+
* @param output - Combined stdout and stderr from the Storybook command
|
|
10
|
+
* @param installRequested - Whether the caller requested a dependency reinstall first
|
|
11
|
+
* @returns User-facing guidance for the specific failure mode
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildStorybookFailureMessage(output: string, installRequested: boolean): string;
|
|
2
14
|
/**
|
|
3
15
|
* Runs the furnace preview command to start Storybook for component preview.
|
|
4
16
|
* @param projectRoot - Root directory of the project
|
|
@@ -72,17 +72,49 @@ function reportPreviewStagingFailures(stageResult) {
|
|
|
72
72
|
const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
|
|
73
73
|
throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Filenames emitted by the Firefox build backend (not by Storybook's npm
|
|
77
|
+
* package set) — their absence means `mach build` has not produced its
|
|
78
|
+
* post-configure artifacts, which is a different failure mode from a
|
|
79
|
+
* missing Storybook workspace dependency tree. The eval log for finding
|
|
80
|
+
* #11 reported `FileNotFoundError: [...] chrome-map.json` *after* a
|
|
81
|
+
* successful Storybook `npm install`, and the pre-0.16 heuristic
|
|
82
|
+
* misdiagnosed it as a dep failure and sent the operator back to
|
|
83
|
+
* `--install`. Pattern list is narrow on purpose so we only surface the
|
|
84
|
+
* backend-rebuild hint when we are confident.
|
|
85
|
+
*/
|
|
86
|
+
const BACKEND_ARTIFACT_PATTERNS = [
|
|
87
|
+
/chrome-map\.json/i,
|
|
88
|
+
/config\.status/i,
|
|
89
|
+
/obj-[^\s/]+\/dist\/bin\/\.lldbinit/i,
|
|
90
|
+
];
|
|
75
91
|
/**
|
|
76
92
|
* Builds a targeted Storybook failure message from captured mach output.
|
|
93
|
+
*
|
|
94
|
+
* Exported for the test suite: the heuristic has three branches (backend
|
|
95
|
+
* artifact missing, Storybook dep missing, generic) and regression
|
|
96
|
+
* testing each is easier when the classifier is addressable directly.
|
|
97
|
+
*
|
|
77
98
|
* @param output - Combined stdout and stderr from the Storybook command
|
|
78
99
|
* @param installRequested - Whether the caller requested a dependency reinstall first
|
|
79
100
|
* @returns User-facing guidance for the specific failure mode
|
|
80
101
|
*/
|
|
81
|
-
function buildStorybookFailureMessage(output, installRequested) {
|
|
102
|
+
export function buildStorybookFailureMessage(output, installRequested) {
|
|
82
103
|
const installHint = installRequested
|
|
83
104
|
? 'Try running "python3 ./mach storybook upgrade" manually in the engine directory.'
|
|
84
105
|
: 'Run "fireforge furnace preview --install" to bootstrap Storybook dependencies, or run "python3 ./mach storybook upgrade" manually in engine/.';
|
|
85
|
-
|
|
106
|
+
const hasFileNotFoundSignal = /(ENOENT|No such file or directory|FileNotFoundError)/i.test(output);
|
|
107
|
+
// Check backend-artifact signal first — a missing chrome-map.json looks
|
|
108
|
+
// like any other "No such file" error to a naïve regex, but the fix is
|
|
109
|
+
// to rerun `fireforge build`, not to reinstall Storybook dependencies.
|
|
110
|
+
if (hasFileNotFoundSignal && BACKEND_ARTIFACT_PATTERNS.some((p) => p.test(output))) {
|
|
111
|
+
return ('Storybook failed because the Firefox build backend artifacts are missing or stale ' +
|
|
112
|
+
'(chrome-map.json / config.status / obj-*/dist/bin/.lldbinit). ' +
|
|
113
|
+
'This is a Firefox-build completeness issue, not a Storybook dependency issue.\n\n' +
|
|
114
|
+
'Rerun "fireforge build" and let it finish, then retry "fireforge furnace preview". ' +
|
|
115
|
+
'A full rebuild regenerates the backend artifacts Storybook reads.');
|
|
116
|
+
}
|
|
117
|
+
if (hasFileNotFoundSignal && /storybook|backend/i.test(output)) {
|
|
86
118
|
return ('Storybook failed because the Firefox checkout appears to be missing Storybook workspace files or backend dependencies.\n\n' +
|
|
87
119
|
installHint);
|
|
88
120
|
}
|
|
@@ -122,6 +122,94 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Removes the deployed custom-widget directory at the old target path so
|
|
127
|
+
* a subsequent `furnace apply` is the single writer of the new name's
|
|
128
|
+
* deployment. Best-effort: logs a warning but never blocks the rename.
|
|
129
|
+
*
|
|
130
|
+
* 2026-04-21 eval: renaming `ff-chip-row` → `ff-chip-stack` registered
|
|
131
|
+
* and deployed the new name correctly but left `engine/toolkit/content/
|
|
132
|
+
* widgets/ff-chip-row/` in place. Subsequent `furnace sync` runs could
|
|
133
|
+
* not clear the stale widget, and packaging would have pulled in both
|
|
134
|
+
* copies. The snapshot is taken before the remove so the rollback
|
|
135
|
+
* journal restores the old directory if any later step in
|
|
136
|
+
* `performRenameMutations` fails.
|
|
137
|
+
*/
|
|
138
|
+
async function removeStaleDeployedComponentDir(engineDir, oldTargetPath, journal) {
|
|
139
|
+
const oldDeployed = join(engineDir, oldTargetPath);
|
|
140
|
+
if (!(await pathExists(oldDeployed)))
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
await snapshotDir(journal, oldDeployed);
|
|
144
|
+
await removeDir(oldDeployed);
|
|
145
|
+
info(`Removed stale deployed widget directory: ${oldTargetPath}`);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
warn(`Could not remove stale deployed widget directory at ${oldTargetPath}: ${toError(error).message}. Remove it manually if needed.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renames the mochikit test scaffold produced by `furnace create
|
|
153
|
+
* --with-tests` when the default test style is used. The scaffold lives
|
|
154
|
+
* at `engine/toolkit/content/tests/widgets/test_<name>.html`, and the
|
|
155
|
+
* accompanying `chrome.toml` entry names the same file. Neither piece
|
|
156
|
+
* was handled by the pre-0.16.0 rename, so operators were left with a
|
|
157
|
+
* `test_<old>.html` file that still imported `chrome://global/content/
|
|
158
|
+
* elements/<old>.mjs` and referenced `customElements.whenDefined("<old>")`
|
|
159
|
+
* — the test ran against a component that no longer existed under that
|
|
160
|
+
* name and either failed or (if the old component was still deployed)
|
|
161
|
+
* passed for the wrong reason.
|
|
162
|
+
*
|
|
163
|
+
* Best-effort: individual failures log a warning. The same journal used
|
|
164
|
+
* for the rest of the rename snapshots every touched file so a later
|
|
165
|
+
* failure rolls the pair back together.
|
|
166
|
+
*/
|
|
167
|
+
async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
|
|
168
|
+
const testDir = join(engineDir, 'toolkit/content/tests/widgets');
|
|
169
|
+
if (!(await pathExists(testDir)))
|
|
170
|
+
return;
|
|
171
|
+
const oldTestFileName = `test_${oldName}.html`;
|
|
172
|
+
const newTestFileName = `test_${newName}.html`;
|
|
173
|
+
const oldTestPath = join(testDir, oldTestFileName);
|
|
174
|
+
const newTestPath = join(testDir, newTestFileName);
|
|
175
|
+
if (await pathExists(oldTestPath)) {
|
|
176
|
+
try {
|
|
177
|
+
await snapshotFile(journal, oldTestPath);
|
|
178
|
+
const content = await readText(oldTestPath);
|
|
179
|
+
const updatedContent = content
|
|
180
|
+
.replace(new RegExp(`chrome://global/content/elements/${escapeRegex(oldName)}\\.mjs`, 'g'), `chrome://global/content/elements/${newName}.mjs`)
|
|
181
|
+
.replace(new RegExp(`customElements\\.whenDefined\\("${escapeRegex(oldName)}"\\)`, 'g'), `customElements.whenDefined("${newName}")`)
|
|
182
|
+
.replace(new RegExp(`Test the ${escapeRegex(oldName)} `, 'g'), `Test the ${newName} `)
|
|
183
|
+
.replace(new RegExp(`add_task\\(async function test_${escapeRegex(oldName.replace(/-/g, '_'))}_defined\\(`, 'g'), `add_task(async function test_${newName.replace(/-/g, '_')}_defined(`)
|
|
184
|
+
.replace(new RegExp(`"${escapeRegex(oldName)} custom element`, 'g'), `"${newName} custom element`);
|
|
185
|
+
await writeText(newTestPath, updatedContent);
|
|
186
|
+
await removeFile(oldTestPath);
|
|
187
|
+
info(`Renamed mochikit test: ${oldTestFileName} → ${newTestFileName}`);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
warn(`Could not rename mochikit test file — ${toError(error).message}. Rename it manually if needed.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Update `chrome.toml` entry if present. The file may live in the
|
|
194
|
+
// same widgets/tests directory as the test file itself; upstream
|
|
195
|
+
// convention places exactly one `chrome.toml` there for all widget
|
|
196
|
+
// scaffolds.
|
|
197
|
+
const chromeTomlPath = join(testDir, 'chrome.toml');
|
|
198
|
+
if (await pathExists(chromeTomlPath)) {
|
|
199
|
+
try {
|
|
200
|
+
const toml = await readText(chromeTomlPath);
|
|
201
|
+
if (toml.includes(`["${oldTestFileName}"]`)) {
|
|
202
|
+
await snapshotFile(journal, chromeTomlPath);
|
|
203
|
+
const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
|
|
204
|
+
await writeText(chromeTomlPath, updated);
|
|
205
|
+
info(`Updated chrome.toml: ${oldTestFileName} → ${newTestFileName}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
warn(`Could not update widgets chrome.toml — ${toError(error).message}. Update it manually if needed.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
125
213
|
/**
|
|
126
214
|
* Performs the transactional rename mutation inside a furnace lock.
|
|
127
215
|
*/
|
|
@@ -129,6 +217,11 @@ async function performRenameMutations(args) {
|
|
|
129
217
|
const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
|
|
130
218
|
const oldClassName = tagNameToClassName(oldName);
|
|
131
219
|
const newClassName = tagNameToClassName(newName);
|
|
220
|
+
// Capture the pre-rename deployed target path so we know what to
|
|
221
|
+
// clean up in the engine tree. `updateConfigForCustomRename` rewrites
|
|
222
|
+
// `targetPath` in-place once the mutation enters phase 2, so we read
|
|
223
|
+
// it here while it still points at the old name's deployment.
|
|
224
|
+
const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
|
|
132
225
|
await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
|
|
133
226
|
const journal = createRollbackJournal();
|
|
134
227
|
ctx.registerJournal(journal);
|
|
@@ -197,6 +290,23 @@ async function performRenameMutations(args) {
|
|
|
197
290
|
// 7. Rename test files created by `furnace create --with-tests` (custom only).
|
|
198
291
|
if (isCustom && (await pathExists(args.engineDir))) {
|
|
199
292
|
await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
|
|
293
|
+
// Mochikit scaffold + widgets/chrome.toml live in a different
|
|
294
|
+
// tree than browser.toml-registered browser-chrome tests, so
|
|
295
|
+
// renameTestFiles doesn't reach them. 2026-04-21 eval: a rename
|
|
296
|
+
// left `engine/toolkit/content/tests/widgets/test_<old>.html`
|
|
297
|
+
// and its `chrome.toml` entry pointing at the old name, which
|
|
298
|
+
// either failed the test run outright or (worse) passed for the
|
|
299
|
+
// wrong component.
|
|
300
|
+
await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
|
|
301
|
+
// Clear the stale deployed component directory so the next
|
|
302
|
+
// `furnace apply` is the single writer of the new name's
|
|
303
|
+
// deployment. Without this, eval runs showed the old widget
|
|
304
|
+
// still living at `engine/toolkit/content/widgets/<old>/`
|
|
305
|
+
// alongside the newly-deployed `engine/toolkit/content/
|
|
306
|
+
// widgets/<new>/`, with no signal to `status` / `verify`.
|
|
307
|
+
if (oldCustomTargetPath) {
|
|
308
|
+
await removeStaleDeployedComponentDir(args.engineDir, oldCustomTargetPath, journal);
|
|
309
|
+
}
|
|
200
310
|
}
|
|
201
311
|
info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
|
|
202
312
|
}
|
|
@@ -196,7 +196,7 @@ export async function furnaceStatusCommand(projectRoot, name) {
|
|
|
196
196
|
warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
|
|
199
|
+
info('Tip: run `fireforge furnace status <name>` for detailed component info, or `fireforge furnace --help` for all subcommands.');
|
|
200
200
|
outro('Status complete');
|
|
201
201
|
}
|
|
202
202
|
//# sourceMappingURL=status.js.map
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
6
|
import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
|
|
6
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
|
-
import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
8
|
+
import { getModifiedFiles, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
8
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
9
10
|
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
|
|
10
11
|
import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
|
|
@@ -22,8 +23,19 @@ import { stripEnginePrefix } from '../utils/paths.js';
|
|
|
22
23
|
* per-function LOC budget as the command grows; the two file-mode and
|
|
23
24
|
* aggregate-mode branches share no state with the post-lint reporting
|
|
24
25
|
* pipeline, so the split is a pure rename rather than a refactor.
|
|
26
|
+
*
|
|
27
|
+
* When `binaryName` is provided, the aggregate-mode branch (no
|
|
28
|
+
* explicit file list) excludes paths under `browser/branding/<binaryName>/`
|
|
29
|
+
* from the diff. `status` classifies those paths as `branding` —
|
|
30
|
+
* tool-managed material the operator did not author directly — and
|
|
31
|
+
* the 2026-04-21 eval (Finding #2) reported that `fireforge lint` on
|
|
32
|
+
* a fresh project immediately failed `large-patch-lines` /
|
|
33
|
+
* `large-patch-files` / `missing-license-header` on the generated
|
|
34
|
+
* branding tree. File-list mode (explicit paths) preserves the
|
|
35
|
+
* previous behaviour: passing a branding file explicitly still lints
|
|
36
|
+
* it, so operators who need to audit branding content can do so.
|
|
25
37
|
*/
|
|
26
|
-
async function resolveLintDiff(engineDir, files) {
|
|
38
|
+
async function resolveLintDiff(engineDir, files, binaryName) {
|
|
27
39
|
if (files.length > 0) {
|
|
28
40
|
const collectedFiles = new Set();
|
|
29
41
|
let fileStatuses;
|
|
@@ -83,6 +95,40 @@ async function resolveLintDiff(engineDir, files) {
|
|
|
83
95
|
outro('Nothing to lint');
|
|
84
96
|
return null;
|
|
85
97
|
}
|
|
98
|
+
// Aggregate-mode branding exclusion. A fresh-setup workspace (after
|
|
99
|
+
// `fireforge setup` + `download` + `bootstrap` + `build`) carries a
|
|
100
|
+
// large tool-managed branding diff that the operator did not
|
|
101
|
+
// author; running the default lint against it fires size and
|
|
102
|
+
// license-header rules on content that was never intended to
|
|
103
|
+
// survive in the patch queue as-is. The exclusion mirrors the
|
|
104
|
+
// `branding` bucket in `fireforge status` so the two views stay
|
|
105
|
+
// consistent.
|
|
106
|
+
if (binaryName) {
|
|
107
|
+
const modified = await getModifiedFiles(engineDir);
|
|
108
|
+
const untracked = await getUntrackedFiles(engineDir);
|
|
109
|
+
const allPaths = [...new Set([...modified, ...untracked])];
|
|
110
|
+
const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
|
|
111
|
+
const excludedCount = allPaths.length - nonBrandingPaths.length;
|
|
112
|
+
if (excludedCount > 0) {
|
|
113
|
+
info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
|
|
114
|
+
}
|
|
115
|
+
if (nonBrandingPaths.length === 0) {
|
|
116
|
+
info('No non-branding changes to lint.');
|
|
117
|
+
outro('Nothing to lint');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
|
|
121
|
+
if (!diff.trim()) {
|
|
122
|
+
info('No diff content to lint.');
|
|
123
|
+
outro('Nothing to lint');
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return diff;
|
|
127
|
+
}
|
|
128
|
+
// Fallback path: no binaryName available (e.g. a legacy caller
|
|
129
|
+
// without a loaded config). Retain the pre-0.16.0 behaviour of
|
|
130
|
+
// linting the full diff so the lint surface is at least as broad
|
|
131
|
+
// as before.
|
|
86
132
|
const diff = await getAllDiff(engineDir);
|
|
87
133
|
if (!diff.trim()) {
|
|
88
134
|
info('No diff content to lint.');
|
|
@@ -126,10 +172,15 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
126
172
|
await lintPerPatch(projectRoot, paths);
|
|
127
173
|
return;
|
|
128
174
|
}
|
|
129
|
-
|
|
175
|
+
// Load the config before resolving the diff so we can pass
|
|
176
|
+
// `binaryName` into the aggregate-mode branding exclusion in
|
|
177
|
+
// `resolveLintDiff`. The config was previously loaded only after
|
|
178
|
+
// the diff was resolved; hoisting it is cheap and keeps the two
|
|
179
|
+
// call sites close together.
|
|
180
|
+
const config = await loadConfig(projectRoot);
|
|
181
|
+
const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
|
|
130
182
|
if (diff === null)
|
|
131
183
|
return;
|
|
132
|
-
const config = await loadConfig(projectRoot);
|
|
133
184
|
const filesAffected = extractAffectedFiles(diff);
|
|
134
185
|
// Build patch queue context once so it can be shared between the
|
|
135
186
|
// per-patch ownership resolver and the cross-patch rules.
|