@hominis/fireforge 0.17.0 → 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 +37 -0
- package/README.md +40 -20
- package/dist/src/commands/build.js +18 -4
- 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 +9 -11
- 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/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +23 -4
- package/dist/src/commands/lint.js +19 -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/test.js +53 -12
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +16 -0
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- package/dist/src/core/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +12 -6
- package/dist/src/core/mach.js +12 -6
- 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/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.js +15 -2
- 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/token-coverage.js +24 -0
- package/dist/src/core/wire-destroy.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- 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
|
@@ -9,7 +9,7 @@ import { verbose } from '../utils/logger.js';
|
|
|
9
9
|
import { exec } from '../utils/process.js';
|
|
10
10
|
import { ensureGit, git } from './git-base.js';
|
|
11
11
|
import { fileExistsInHead } from './git-file-ops.js';
|
|
12
|
-
import { getUntrackedFiles } from './git-status.js';
|
|
12
|
+
import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
|
|
13
13
|
async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
|
|
14
14
|
const result = await exec('git', args, { cwd: repoDir });
|
|
15
15
|
if (allowedExitCodes.includes(result.exitCode)) {
|
|
@@ -183,7 +183,26 @@ export async function getAllDiff(repoDir) {
|
|
|
183
183
|
*/
|
|
184
184
|
export async function getDiffForFilesAgainstHead(repoDir, files) {
|
|
185
185
|
await ensureGit();
|
|
186
|
-
|
|
186
|
+
// Expand any directory entries (paths ending with `/`) into their
|
|
187
|
+
// individual untracked files before diffing. `git status --porcelain=v1`
|
|
188
|
+
// reports collapsed untracked directories as `?? dir/`, and every caller
|
|
189
|
+
// that feeds the aggregate working-tree state into this function must
|
|
190
|
+
// not trigger an EISDIR when the diff pass reads `dir/` as if it were a
|
|
191
|
+
// file. Belt-and-suspenders: the caller-side expansion in `lint.ts`
|
|
192
|
+
// and `export-all.ts` covers the common path, but a single bad call
|
|
193
|
+
// site re-introduced the bug in 0.17.0 — guarding here makes the
|
|
194
|
+
// regression impossible at this layer.
|
|
195
|
+
const expandedFiles = [];
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
if (file.endsWith('/')) {
|
|
198
|
+
const inner = await getUntrackedFilesInDir(repoDir, file);
|
|
199
|
+
for (const entry of inner)
|
|
200
|
+
expandedFiles.push(entry);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
expandedFiles.push(file);
|
|
204
|
+
}
|
|
205
|
+
const uniqueFiles = [...new Set(expandedFiles)].sort();
|
|
187
206
|
const diffs = [];
|
|
188
207
|
for (const file of uniqueFiles) {
|
|
189
208
|
if (await fileExistsInHead(repoDir, file)) {
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -60,19 +60,25 @@ export declare function bootstrapWithOutput(engineDir: string): Promise<MachComm
|
|
|
60
60
|
/**
|
|
61
61
|
* Runs a full mach build. On a non-zero exit, any matched error hints are
|
|
62
62
|
* surfaced on top of the raw mach output so operators get an actionable
|
|
63
|
-
* nudge alongside the cryptic mozbuild traceback.
|
|
63
|
+
* nudge alongside the cryptic mozbuild traceback. Returns the captured
|
|
64
|
+
* result so the caller (e.g. `fireforge build`) can inspect the tail
|
|
65
|
+
* for post-build diagnostics that mach prints AFTER "Your build was
|
|
66
|
+
* successful!" — notably the stale `config.status is out of date`
|
|
67
|
+
* notice that mach emits when a tool-managed edit landed on
|
|
68
|
+
* `moz.configure` before the build.
|
|
64
69
|
* @param engineDir - Path to the engine directory
|
|
65
70
|
* @param jobs - Number of parallel jobs (optional)
|
|
66
|
-
* @returns
|
|
71
|
+
* @returns Captured mach result (stdout tail, stderr tail, exit code)
|
|
67
72
|
*/
|
|
68
|
-
export declare function build(engineDir: string, jobs?: number): Promise<
|
|
73
|
+
export declare function build(engineDir: string, jobs?: number): Promise<MachCommandResult>;
|
|
69
74
|
/**
|
|
70
75
|
* Runs a fast UI-only build. On a non-zero exit, any matched error hints are
|
|
71
|
-
* surfaced on top of the raw mach output.
|
|
76
|
+
* surfaced on top of the raw mach output. See {@link build} for why the
|
|
77
|
+
* full captured result is returned rather than just the exit code.
|
|
72
78
|
* @param engineDir - Path to the engine directory
|
|
73
|
-
* @returns
|
|
79
|
+
* @returns Captured mach result
|
|
74
80
|
*/
|
|
75
|
-
export declare function buildUI(engineDir: string): Promise<
|
|
81
|
+
export declare function buildUI(engineDir: string): Promise<MachCommandResult>;
|
|
76
82
|
/**
|
|
77
83
|
* Runs an operation while holding a sidecar build lock keyed on the
|
|
78
84
|
* project root. Concurrent `fireforge build` / `fireforge build --ui`
|
package/dist/src/core/mach.js
CHANGED
|
@@ -137,10 +137,15 @@ function surfaceMachErrorHints(result) {
|
|
|
137
137
|
/**
|
|
138
138
|
* Runs a full mach build. On a non-zero exit, any matched error hints are
|
|
139
139
|
* surfaced on top of the raw mach output so operators get an actionable
|
|
140
|
-
* nudge alongside the cryptic mozbuild traceback.
|
|
140
|
+
* nudge alongside the cryptic mozbuild traceback. Returns the captured
|
|
141
|
+
* result so the caller (e.g. `fireforge build`) can inspect the tail
|
|
142
|
+
* for post-build diagnostics that mach prints AFTER "Your build was
|
|
143
|
+
* successful!" — notably the stale `config.status is out of date`
|
|
144
|
+
* notice that mach emits when a tool-managed edit landed on
|
|
145
|
+
* `moz.configure` before the build.
|
|
141
146
|
* @param engineDir - Path to the engine directory
|
|
142
147
|
* @param jobs - Number of parallel jobs (optional)
|
|
143
|
-
* @returns
|
|
148
|
+
* @returns Captured mach result (stdout tail, stderr tail, exit code)
|
|
144
149
|
*/
|
|
145
150
|
export async function build(engineDir, jobs) {
|
|
146
151
|
const args = ['build'];
|
|
@@ -151,20 +156,21 @@ export async function build(engineDir, jobs) {
|
|
|
151
156
|
if (result.exitCode !== 0) {
|
|
152
157
|
surfaceMachErrorHints(result);
|
|
153
158
|
}
|
|
154
|
-
return result
|
|
159
|
+
return result;
|
|
155
160
|
}
|
|
156
161
|
/**
|
|
157
162
|
* Runs a fast UI-only build. On a non-zero exit, any matched error hints are
|
|
158
|
-
* surfaced on top of the raw mach output.
|
|
163
|
+
* surfaced on top of the raw mach output. See {@link build} for why the
|
|
164
|
+
* full captured result is returned rather than just the exit code.
|
|
159
165
|
* @param engineDir - Path to the engine directory
|
|
160
|
-
* @returns
|
|
166
|
+
* @returns Captured mach result
|
|
161
167
|
*/
|
|
162
168
|
export async function buildUI(engineDir) {
|
|
163
169
|
const result = await runMachInheritCapture(['build', 'faster'], engineDir);
|
|
164
170
|
if (result.exitCode !== 0) {
|
|
165
171
|
surfaceMachErrorHints(result);
|
|
166
172
|
}
|
|
167
|
-
return result
|
|
173
|
+
return result;
|
|
168
174
|
}
|
|
169
175
|
/**
|
|
170
176
|
* Runs an operation while holding a sidecar build lock keyed on the
|
|
@@ -24,7 +24,16 @@ export function getRules(binaryName) {
|
|
|
24
24
|
// proposed a bogus jar.mn entry. The lookahead blocks the match so
|
|
25
25
|
// `getUnregistrableAdvice` gets a chance to emit the correct
|
|
26
26
|
// guidance for the `.inc.xhtml` case.
|
|
27
|
-
|
|
27
|
+
//
|
|
28
|
+
// Test implementation files under `browser/base/content/test/` are
|
|
29
|
+
// also excluded: they belong in the nearest `browser.toml` manifest,
|
|
30
|
+
// not in jar.mn. 2026-04-23 eval 2: `status --unmanaged` proposed
|
|
31
|
+
// `fireforge register browser/base/content/test/<dir>/browser_*.js`
|
|
32
|
+
// which would have clutter-registered a test file as browser
|
|
33
|
+
// chrome content. The negative lookahead routes those paths to
|
|
34
|
+
// `getUnregistrableAdvice`, which returns the correct
|
|
35
|
+
// browser.toml-centric guidance.
|
|
36
|
+
pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(?!test\/)(.+\.(?:js|mjs|xhtml|css))$/,
|
|
28
37
|
isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
|
|
29
38
|
register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
|
|
30
39
|
extractArgs: (m) => [m[1] ?? ''],
|
|
@@ -26,6 +26,12 @@ export declare function tokenizeJarMn(lines: string[]): JarMnToken[];
|
|
|
26
26
|
/**
|
|
27
27
|
* Tokenizes a moz.build Python list block, returning the tokens and their
|
|
28
28
|
* line range within the file.
|
|
29
|
+
*
|
|
30
|
+
* Supports both multi-line lists (the common shape) and single-line
|
|
31
|
+
* empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
|
|
32
|
+
* case where a freshly-scaffolded module directory's `moz.build`
|
|
33
|
+
* started with an empty list and the tokenizer returned `null`,
|
|
34
|
+
* leaving `register` unable to add the first entry.
|
|
29
35
|
*/
|
|
30
36
|
export declare function tokenizeMozBuildList(lines: string[], listPattern: RegExp): {
|
|
31
37
|
tokens: MozBuildToken[];
|
|
@@ -44,6 +44,12 @@ export function tokenizeJarMn(lines) {
|
|
|
44
44
|
/**
|
|
45
45
|
* Tokenizes a moz.build Python list block, returning the tokens and their
|
|
46
46
|
* line range within the file.
|
|
47
|
+
*
|
|
48
|
+
* Supports both multi-line lists (the common shape) and single-line
|
|
49
|
+
* empty lists of the form `EXTRA_JS_MODULES += []` — the eval-2 finding
|
|
50
|
+
* case where a freshly-scaffolded module directory's `moz.build`
|
|
51
|
+
* started with an empty list and the tokenizer returned `null`,
|
|
52
|
+
* leaving `register` unable to add the first entry.
|
|
47
53
|
*/
|
|
48
54
|
export function tokenizeMozBuildList(lines, listPattern) {
|
|
49
55
|
const tokens = [];
|
|
@@ -53,6 +59,28 @@ export function tokenizeMozBuildList(lines, listPattern) {
|
|
|
53
59
|
const raw = lines[i] ?? '';
|
|
54
60
|
if (startLine === -1) {
|
|
55
61
|
if (listPattern.test(raw)) {
|
|
62
|
+
// Single-line empty-list handling: a fresh scaffold sometimes
|
|
63
|
+
// writes `EXTRA_JS_MODULES += []` on one line. The pre-fix
|
|
64
|
+
// tokenizer returned `null` because it never saw a line
|
|
65
|
+
// starting with `]`, which stranded `register` with a "Could
|
|
66
|
+
// not find module list section" error against the documented
|
|
67
|
+
// browser/modules/<fork>/ scaffold (eval 2).
|
|
68
|
+
//
|
|
69
|
+
// The in-place split rewrites the single-line form into the
|
|
70
|
+
// canonical multi-line shape so the caller's
|
|
71
|
+
// `lines.splice(insertIndex, 0, entry)` lands inside the list
|
|
72
|
+
// body. The tokens are emitted to mirror the new structure.
|
|
73
|
+
const singleLineMatch = /^([^[]*\[)\s*\]\s*$/.exec(raw);
|
|
74
|
+
if (singleLineMatch) {
|
|
75
|
+
const openPart = singleLineMatch[1] ?? '';
|
|
76
|
+
lines[i] = openPart;
|
|
77
|
+
lines.splice(i + 1, 0, ']');
|
|
78
|
+
startLine = i;
|
|
79
|
+
endLine = i + 1;
|
|
80
|
+
tokens.push({ type: 'list-open', raw: openPart, lineIndex: i });
|
|
81
|
+
tokens.push({ type: 'list-close', raw: ']', lineIndex: i + 1 });
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
56
84
|
startLine = i;
|
|
57
85
|
tokens.push({ type: 'list-open', raw, lineIndex: i });
|
|
58
86
|
}
|
|
@@ -67,10 +67,49 @@ export declare function lintPatchedJs(repoDir: string, affectedFiles: string[],
|
|
|
67
67
|
* @returns Array of lint issues
|
|
68
68
|
*/
|
|
69
69
|
export declare function lintModificationComments(diffContent: string, config: FireForgeConfig): PatchLintIssue[];
|
|
70
|
+
/**
|
|
71
|
+
* Describes which tier `resolvePatchSizeTier` selected and why.
|
|
72
|
+
* Consumers that want to surface the tier choice to the operator
|
|
73
|
+
* (e.g. a one-line `info()` when branding thresholds kick in) read
|
|
74
|
+
* this alongside the issues array from `lintPatchSize`.
|
|
75
|
+
*/
|
|
76
|
+
export type PatchSizeTierDecision = {
|
|
77
|
+
tier: 'general';
|
|
78
|
+
} | {
|
|
79
|
+
tier: 'test';
|
|
80
|
+
} | {
|
|
81
|
+
tier: 'branding';
|
|
82
|
+
source: 'auto' | 'explicit';
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Decides which `large-patch-lines` threshold tier applies to a patch.
|
|
86
|
+
* Exported so `runPatchLint` and the per-patch `lint` command can
|
|
87
|
+
* surface the tier choice to the operator *without* depending on
|
|
88
|
+
* `lintPatchSize`'s internal return shape — the rule itself stays a
|
|
89
|
+
* pure issues-array API, and the decision is computed separately for
|
|
90
|
+
* the sole purpose of reporting.
|
|
91
|
+
*
|
|
92
|
+
* Precedence: test > branding (explicit) > branding (auto) > general.
|
|
93
|
+
* The test tier beats branding because a table-driven regression test
|
|
94
|
+
* is legitimately large independent of whether the patch also claims
|
|
95
|
+
* branding shape, and the test-tier thresholds are already more
|
|
96
|
+
* permissive than branding — so "tests beat branding" is the
|
|
97
|
+
* defensive-for-tests choice.
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
|
|
70
100
|
/**
|
|
71
101
|
* Checks patch size and emits advisory warnings.
|
|
102
|
+
*
|
|
103
|
+
* @param filesAffected - Files touched by the patch
|
|
104
|
+
* @param lineCount - Non-binary line count of the unified diff
|
|
105
|
+
* @param patchTier - Optional explicit tier override declared on
|
|
106
|
+
* `PatchMetadata.tier`. When `"branding"`, forces the branding
|
|
107
|
+
* thresholds regardless of `filesAffected`. Tests still win over
|
|
108
|
+
* branding (precedence `test > branding > general`) because the
|
|
109
|
+
* test-tier thresholds are already more permissive and an all-tests
|
|
110
|
+
* patch that is also branding-shaped is vanishingly rare.
|
|
72
111
|
*/
|
|
73
|
-
export declare function lintPatchSize(filesAffected: string[], lineCount: number): PatchLintIssue[];
|
|
112
|
+
export declare function lintPatchSize(filesAffected: string[], lineCount: number, patchTier?: 'branding'): PatchLintIssue[];
|
|
74
113
|
/**
|
|
75
114
|
* Checks that modified (non-new) files with a supported extension still
|
|
76
115
|
* start with a recognized license header.
|
|
@@ -94,6 +133,12 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
|
|
|
94
133
|
* is advisory-noisy by nature (a cohesive branding bundle, auto-generated
|
|
95
134
|
* manifest, etc.) can opt out of a specific rule without reaching for the
|
|
96
135
|
* blunt `--skip-lint` hammer. Not mutated by this function.
|
|
136
|
+
* @param patchTier - Optional explicit tier override, threaded from
|
|
137
|
+
* `PatchMetadata.tier`. When `"branding"` forces the branding
|
|
138
|
+
* thresholds on the `large-patch-lines` rule. Callers with a
|
|
139
|
+
* per-patch manifest context (re-export, per-patch lint) should
|
|
140
|
+
* pass this; aggregate-mode callers without a specific patch
|
|
141
|
+
* context skip it and fall through to auto-detection.
|
|
97
142
|
* @returns Array of all lint issues found
|
|
98
143
|
*/
|
|
99
|
-
export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string
|
|
144
|
+
export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<PatchLintIssue[]>;
|
|
@@ -74,13 +74,49 @@ const PATCH_LINE_THRESHOLDS = {
|
|
|
74
74
|
branding: { notice: 3000, warning: 8000, error: 20000 },
|
|
75
75
|
};
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
77
|
+
* Fixed allowlist of non-branding sibling paths that real-world Firefox
|
|
78
|
+
* branding patches legitimately need to touch to register the new
|
|
79
|
+
* branding flavor with the top-level configure. The 2026-04-21
|
|
80
|
+
* external eval showed that a branding patch which also touches
|
|
81
|
+
* `browser/moz.configure` (the canonical registration point) fell
|
|
82
|
+
* through to the general lint tier because the original predicate
|
|
83
|
+
* required every file to live under `browser/branding/`. This
|
|
84
|
+
* allowlist stays intentionally narrow — additions require a real
|
|
85
|
+
* operator data point, not a speculative expansion. Add new entries
|
|
86
|
+
* only when a genuine branding patch cannot be expressed without a
|
|
87
|
+
* specific registration sibling.
|
|
88
|
+
*
|
|
89
|
+
* Pinned against ESR 140.x conventions at time of writing.
|
|
90
|
+
*/
|
|
91
|
+
const BRANDING_REGISTRATION_FILES = new Set([
|
|
92
|
+
'browser/moz.configure',
|
|
93
|
+
'browser/confvars.sh',
|
|
94
|
+
]);
|
|
95
|
+
/**
|
|
96
|
+
* Returns true when a patch qualifies for the branding threshold tier:
|
|
97
|
+
* every file lives either under `browser/branding/` or in the narrow
|
|
98
|
+
* registration allowlist, AND the patch contains at least one file
|
|
99
|
+
* under `browser/branding/` (guard against a config-only patch
|
|
100
|
+
* accidentally qualifying as branding).
|
|
101
|
+
*
|
|
102
|
+
* Used by `lintPatchSize` to pick the branding threshold tier. The
|
|
103
|
+
* explicit `tier: "branding"` field on `PatchMetadata` bypasses this
|
|
104
|
+
* heuristic and forces the branding tier directly.
|
|
79
105
|
*/
|
|
80
106
|
function isBrandingOnlyPatch(files) {
|
|
81
107
|
if (files.length === 0)
|
|
82
108
|
return false;
|
|
83
|
-
|
|
109
|
+
let hasBrandingFile = false;
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
if (file.startsWith('browser/branding/')) {
|
|
112
|
+
hasBrandingFile = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (BRANDING_REGISTRATION_FILES.has(file))
|
|
116
|
+
continue;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return hasBrandingFile;
|
|
84
120
|
}
|
|
85
121
|
/**
|
|
86
122
|
* Returns true if the filename looks like a JS/MJS/JSM file.
|
|
@@ -426,13 +462,44 @@ export function lintModificationComments(diffContent, config) {
|
|
|
426
462
|
}
|
|
427
463
|
return issues;
|
|
428
464
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
465
|
+
/**
|
|
466
|
+
* Decides which `large-patch-lines` threshold tier applies to a patch.
|
|
467
|
+
* Exported so `runPatchLint` and the per-patch `lint` command can
|
|
468
|
+
* surface the tier choice to the operator *without* depending on
|
|
469
|
+
* `lintPatchSize`'s internal return shape — the rule itself stays a
|
|
470
|
+
* pure issues-array API, and the decision is computed separately for
|
|
471
|
+
* the sole purpose of reporting.
|
|
472
|
+
*
|
|
473
|
+
* Precedence: test > branding (explicit) > branding (auto) > general.
|
|
474
|
+
* The test tier beats branding because a table-driven regression test
|
|
475
|
+
* is legitimately large independent of whether the patch also claims
|
|
476
|
+
* branding shape, and the test-tier thresholds are already more
|
|
477
|
+
* permissive than branding — so "tests beat branding" is the
|
|
478
|
+
* defensive-for-tests choice.
|
|
479
|
+
*/
|
|
480
|
+
export function resolvePatchSizeTier(filesAffected, patchTier) {
|
|
481
|
+
const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
|
|
482
|
+
if (allTests)
|
|
483
|
+
return { tier: 'test' };
|
|
484
|
+
if (patchTier === 'branding')
|
|
485
|
+
return { tier: 'branding', source: 'explicit' };
|
|
486
|
+
if (isBrandingOnlyPatch(filesAffected))
|
|
487
|
+
return { tier: 'branding', source: 'auto' };
|
|
488
|
+
return { tier: 'general' };
|
|
489
|
+
}
|
|
432
490
|
/**
|
|
433
491
|
* Checks patch size and emits advisory warnings.
|
|
492
|
+
*
|
|
493
|
+
* @param filesAffected - Files touched by the patch
|
|
494
|
+
* @param lineCount - Non-binary line count of the unified diff
|
|
495
|
+
* @param patchTier - Optional explicit tier override declared on
|
|
496
|
+
* `PatchMetadata.tier`. When `"branding"`, forces the branding
|
|
497
|
+
* thresholds regardless of `filesAffected`. Tests still win over
|
|
498
|
+
* branding (precedence `test > branding > general`) because the
|
|
499
|
+
* test-tier thresholds are already more permissive and an all-tests
|
|
500
|
+
* patch that is also branding-shaped is vanishingly rare.
|
|
434
501
|
*/
|
|
435
|
-
export function lintPatchSize(filesAffected, lineCount) {
|
|
502
|
+
export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
436
503
|
const issues = [];
|
|
437
504
|
if (filesAffected.length > 5) {
|
|
438
505
|
issues.push({
|
|
@@ -447,12 +514,14 @@ export function lintPatchSize(filesAffected, lineCount) {
|
|
|
447
514
|
// harnesses run into the thousands of lines). Branding patches get their
|
|
448
515
|
// own tier so a first-export of setup-generated branding doesn't fire
|
|
449
516
|
// the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
|
|
450
|
-
// for the eval data motivating this tier.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
517
|
+
// for the eval data motivating this tier. An explicit `patchTier`
|
|
518
|
+
// opt-in forces branding even when `isBrandingOnlyPatch` cannot reach
|
|
519
|
+
// the patch's actual shape (a branding patch that also touches a
|
|
520
|
+
// non-allowlisted sibling like a vendor-specific icon resource).
|
|
521
|
+
const decision = resolvePatchSizeTier(filesAffected, patchTier);
|
|
522
|
+
const thresholds = decision.tier === 'test'
|
|
454
523
|
? PATCH_LINE_THRESHOLDS.test
|
|
455
|
-
: branding
|
|
524
|
+
: decision.tier === 'branding'
|
|
456
525
|
? PATCH_LINE_THRESHOLDS.branding
|
|
457
526
|
: PATCH_LINE_THRESHOLDS.general;
|
|
458
527
|
if (lineCount >= thresholds.error) {
|
|
@@ -534,9 +603,15 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
|
|
|
534
603
|
* is advisory-noisy by nature (a cohesive branding bundle, auto-generated
|
|
535
604
|
* manifest, etc.) can opt out of a specific rule without reaching for the
|
|
536
605
|
* blunt `--skip-lint` hammer. Not mutated by this function.
|
|
606
|
+
* @param patchTier - Optional explicit tier override, threaded from
|
|
607
|
+
* `PatchMetadata.tier`. When `"branding"` forces the branding
|
|
608
|
+
* thresholds on the `large-patch-lines` rule. Callers with a
|
|
609
|
+
* per-patch manifest context (re-export, per-patch lint) should
|
|
610
|
+
* pass this; aggregate-mode callers without a specific patch
|
|
611
|
+
* context skip it and fall through to auto-detection.
|
|
537
612
|
* @returns Array of all lint issues found
|
|
538
613
|
*/
|
|
539
|
-
export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks) {
|
|
614
|
+
export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks, patchTier) {
|
|
540
615
|
const newFiles = detectNewFilesInDiff(diffContent);
|
|
541
616
|
const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
|
|
542
617
|
const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
|
|
@@ -547,7 +622,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
|
|
|
547
622
|
lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
|
|
548
623
|
]);
|
|
549
624
|
const modCommentIssues = lintModificationComments(diffContent, config);
|
|
550
|
-
const sizeIssues = lintPatchSize(affectedFiles, lineCount);
|
|
625
|
+
const sizeIssues = lintPatchSize(affectedFiles, lineCount, patchTier);
|
|
551
626
|
const issues = [
|
|
552
627
|
...sizeIssues,
|
|
553
628
|
...cssIssues,
|
|
@@ -121,7 +121,15 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
|
|
|
121
121
|
// human-written descriptions during a recovery run.
|
|
122
122
|
recoveredFilenames.push(patch.filename);
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// Preserve optional fields the operator declared on the existing
|
|
125
|
+
// entry — `lintIgnore` (per-patch lint suppression) and `tier`
|
|
126
|
+
// (explicit branding-threshold override). Without this, a
|
|
127
|
+
// `doctor --repair-patches-manifest` run silently strips both
|
|
128
|
+
// fields from every entry that had them, and the next `lint`
|
|
129
|
+
// or `re-export` pass fires rules the operator had intentionally
|
|
130
|
+
// quieted. Mirrors how other descriptive fields fall back to
|
|
131
|
+
// existing values when the entry is known.
|
|
132
|
+
const rebuilt = {
|
|
125
133
|
filename: patch.filename,
|
|
126
134
|
order: recoveredOrder,
|
|
127
135
|
category: existing?.category ?? inferred.category,
|
|
@@ -131,7 +139,12 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
|
|
|
131
139
|
createdAt: existing?.createdAt ?? new Date(patchStats.mtimeMs).toISOString(),
|
|
132
140
|
sourceEsrVersion: existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
|
|
133
141
|
filesAffected,
|
|
134
|
-
}
|
|
142
|
+
};
|
|
143
|
+
if (existing?.lintIgnore !== undefined)
|
|
144
|
+
rebuilt.lintIgnore = [...existing.lintIgnore];
|
|
145
|
+
if (existing?.tier !== undefined)
|
|
146
|
+
rebuilt.tier = existing.tier;
|
|
147
|
+
rebuiltPatches.push(rebuilt);
|
|
135
148
|
}
|
|
136
149
|
rebuiltPatches.sort((left, right) => left.order - right.order || left.filename.localeCompare(right.filename));
|
|
137
150
|
const rebuiltManifest = {
|
|
@@ -189,6 +189,16 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
|
|
|
189
189
|
throw new Error(`Cannot renumber: target patch filename already exists on disk: ${toEntry.newFilename}`);
|
|
190
190
|
}
|
|
191
191
|
await rename(join(patchesDir, staged), targetPath);
|
|
192
|
+
// Postcondition assert: confirm the target actually exists on
|
|
193
|
+
// disk before we mark the rename complete. A silent rename
|
|
194
|
+
// failure would leave the manifest and the filesystem
|
|
195
|
+
// disagreeing — exactly what the eval 1 Finding #7 report
|
|
196
|
+
// described: manifest rewrote to new filenames while the old
|
|
197
|
+
// files stayed on disk. If the assert ever fires, the Phase 2
|
|
198
|
+
// rollback will undo prior moves before re-throwing.
|
|
199
|
+
if (!(await pathExists(targetPath))) {
|
|
200
|
+
throw new Error(`Rename postcondition failed: expected ${toEntry.newFilename} to exist after rename, but it was not found on disk.`);
|
|
201
|
+
}
|
|
192
202
|
completedFinalRenames.push(stagedEntry);
|
|
193
203
|
}
|
|
194
204
|
}
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { PatchMetadata } from '../types/commands/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Resolves a patch identifier
|
|
3
|
+
* Resolves a patch identifier to its manifest entry. Accepts:
|
|
4
|
+
*
|
|
5
|
+
* 1. An ordinal number (e.g. `2`) — matches `PatchMetadata.order`.
|
|
6
|
+
* 2. A full filename with `.patch` suffix (e.g. `002-ui-foo.patch`) —
|
|
7
|
+
* matches `PatchMetadata.filename`.
|
|
8
|
+
* 3. A filename without the `.patch` suffix — the command appends it
|
|
9
|
+
* before matching (e.g. `002-ui-foo`).
|
|
10
|
+
* 4. The manifest `name` field (e.g. `eval-furnace-token-override`) —
|
|
11
|
+
* matches `PatchMetadata.name`. This is the short logical handle
|
|
12
|
+
* the export workflow stamps onto the patch and the natural
|
|
13
|
+
* identifier an operator keeps in their notes. 2026-04-21 eval
|
|
14
|
+
* (Finding #6): `patch reorder`/`delete` rejected the `name`
|
|
15
|
+
* even though the CLI help said `<name>`, forcing the operator
|
|
16
|
+
* to copy the full filename from `patches.json` before every
|
|
17
|
+
* queue mutation.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order is strict: numeric ordinals first, then filename
|
|
20
|
+
* lookup (with + without `.patch` suffix), then name-field lookup.
|
|
21
|
+
* The filename lookup beats the name lookup when the two happen to
|
|
22
|
+
* collide so legacy scripts that pass filenames keep working.
|
|
4
23
|
*/
|
|
5
24
|
export declare function resolvePatchIdentifier(identifier: string, patches: PatchMetadata[]): PatchMetadata | null;
|
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolves a patch identifier
|
|
2
|
+
* Resolves a patch identifier to its manifest entry. Accepts:
|
|
3
|
+
*
|
|
4
|
+
* 1. An ordinal number (e.g. `2`) — matches `PatchMetadata.order`.
|
|
5
|
+
* 2. A full filename with `.patch` suffix (e.g. `002-ui-foo.patch`) —
|
|
6
|
+
* matches `PatchMetadata.filename`.
|
|
7
|
+
* 3. A filename without the `.patch` suffix — the command appends it
|
|
8
|
+
* before matching (e.g. `002-ui-foo`).
|
|
9
|
+
* 4. The manifest `name` field (e.g. `eval-furnace-token-override`) —
|
|
10
|
+
* matches `PatchMetadata.name`. This is the short logical handle
|
|
11
|
+
* the export workflow stamps onto the patch and the natural
|
|
12
|
+
* identifier an operator keeps in their notes. 2026-04-21 eval
|
|
13
|
+
* (Finding #6): `patch reorder`/`delete` rejected the `name`
|
|
14
|
+
* even though the CLI help said `<name>`, forcing the operator
|
|
15
|
+
* to copy the full filename from `patches.json` before every
|
|
16
|
+
* queue mutation.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order is strict: numeric ordinals first, then filename
|
|
19
|
+
* lookup (with + without `.patch` suffix), then name-field lookup.
|
|
20
|
+
* The filename lookup beats the name lookup when the two happen to
|
|
21
|
+
* collide so legacy scripts that pass filenames keep working.
|
|
3
22
|
*/
|
|
4
23
|
export function resolvePatchIdentifier(identifier, patches) {
|
|
5
24
|
if (/^\d+$/.test(identifier)) {
|
|
6
25
|
const order = parseInt(identifier, 10);
|
|
7
26
|
return patches.find((p) => p.order === order) ?? null;
|
|
8
27
|
}
|
|
28
|
+
// Filename lookup — try the input as-is first (covers both the
|
|
29
|
+
// full `.patch` form and a bare name, because `endsWith` treats the
|
|
30
|
+
// bare form as a miss and falls through to the appended variant).
|
|
9
31
|
const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
|
|
10
|
-
|
|
32
|
+
const byFilename = patches.find((p) => p.filename === normalized || p.filename === identifier);
|
|
33
|
+
if (byFilename)
|
|
34
|
+
return byFilename;
|
|
35
|
+
// Name-field lookup — the short logical handle stamped into the
|
|
36
|
+
// manifest at export time. See function docstring.
|
|
37
|
+
return patches.find((p) => p.name === identifier) ?? null;
|
|
11
38
|
}
|
|
12
39
|
//# sourceMappingURL=patch-manifest-resolve.js.map
|
|
@@ -23,7 +23,26 @@ export function validatePatchMetadata(data, index) {
|
|
|
23
23
|
throw new Error(`patches[${index}].sourceEsrVersion must be a valid Firefox version string`);
|
|
24
24
|
}
|
|
25
25
|
const filesAffected = rec.stringArray('filesAffected');
|
|
26
|
-
|
|
26
|
+
// Optional fields. These were silently stripped before the 0.17.0
|
|
27
|
+
// branding-tier work reached in and audited the loader — the 0.16.0
|
|
28
|
+
// `lintIgnore` escape hatch demonstrably round-tripped only through
|
|
29
|
+
// test fixtures that mocked `loadPatchesManifest` directly. Real
|
|
30
|
+
// operator edits to `patches.json` were dropped on every subsequent
|
|
31
|
+
// load, so any patch that relied on `lintIgnore` to suppress a
|
|
32
|
+
// specific lint rule was quietly re-tripped the next time the
|
|
33
|
+
// manifest validated. Preserve both the pre-existing `lintIgnore`
|
|
34
|
+
// and the new `tier` field here so future-added optional fields
|
|
35
|
+
// have a ready template to follow.
|
|
36
|
+
const lintIgnore = rec.optionalStringArray('lintIgnore');
|
|
37
|
+
const rawTier = rec.raw('tier');
|
|
38
|
+
let tier;
|
|
39
|
+
if (rawTier !== undefined) {
|
|
40
|
+
if (rawTier !== 'branding') {
|
|
41
|
+
throw new Error(`patches[${index}].tier must be "branding" when present (unknown tier values are rejected, not silently ignored).`);
|
|
42
|
+
}
|
|
43
|
+
tier = 'branding';
|
|
44
|
+
}
|
|
45
|
+
const result = {
|
|
27
46
|
filename,
|
|
28
47
|
order,
|
|
29
48
|
category,
|
|
@@ -33,6 +52,11 @@ export function validatePatchMetadata(data, index) {
|
|
|
33
52
|
sourceEsrVersion,
|
|
34
53
|
filesAffected,
|
|
35
54
|
};
|
|
55
|
+
if (lintIgnore !== undefined)
|
|
56
|
+
result.lintIgnore = lintIgnore;
|
|
57
|
+
if (tier !== undefined)
|
|
58
|
+
result.tier = tier;
|
|
59
|
+
return result;
|
|
36
60
|
}
|
|
37
61
|
/** Validates raw patches.json data and returns the typed manifest shape. */
|
|
38
62
|
export function validatePatchesManifest(data) {
|
|
@@ -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
|
}
|
|
@@ -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>;
|