@hominis/fireforge 0.18.0 → 0.18.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 +18 -2
- package/README.md +55 -34
- package/dist/src/commands/doctor.js +13 -1
- package/dist/src/commands/export-all.js +63 -1
- package/dist/src/commands/export-flow.d.ts +4 -0
- package/dist/src/commands/export-flow.js +8 -0
- package/dist/src/commands/export.js +26 -2
- package/dist/src/commands/furnace/create-xpcshell.js +4 -2
- package/dist/src/commands/furnace/preview.js +38 -0
- package/dist/src/commands/furnace/remove.js +67 -1
- package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
- package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
- package/dist/src/commands/furnace/rename.js +9 -0
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +10 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
- package/dist/src/commands/patch/lint-ignore.js +200 -0
- package/dist/src/commands/patch/tier.d.ts +34 -0
- package/dist/src/commands/patch/tier.js +134 -0
- package/dist/src/commands/re-export-files.js +88 -45
- package/dist/src/commands/re-export.js +49 -6
- package/dist/src/commands/rebase/index.js +19 -1
- package/dist/src/commands/status.js +44 -5
- package/dist/src/commands/test.js +27 -16
- package/dist/src/commands/verify.js +81 -6
- package/dist/src/commands/watch.js +43 -7
- package/dist/src/core/furnace-constants.d.ts +14 -0
- package/dist/src/core/furnace-constants.js +16 -0
- package/dist/src/core/furnace-validate.js +67 -1
- package/dist/src/core/git-base.d.ts +27 -2
- package/dist/src/core/git-base.js +41 -3
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/git.js +53 -14
- package/dist/src/core/mach.d.ts +14 -2
- package/dist/src/core/mach.js +12 -2
- package/dist/src/core/marionette-preflight.d.ts +16 -0
- package/dist/src/core/marionette-preflight.js +19 -0
- package/dist/src/core/patch-export.d.ts +77 -2
- package/dist/src/core/patch-export.js +82 -3
- package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
- package/dist/src/core/patch-lint-diff-tag.js +25 -0
- package/dist/src/core/patch-lint.js +82 -32
- package/dist/src/core/patch-registration-refs.d.ts +42 -0
- package/dist/src/core/patch-registration-refs.js +117 -0
- package/dist/src/core/xpcshell-appdir.d.ts +19 -5
- package/dist/src/core/xpcshell-appdir.js +46 -20
- package/dist/src/errors/git.d.ts +20 -0
- package/dist/src/errors/git.js +39 -0
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +67 -0
- package/dist/src/types/commands/patches.d.ts +6 -5
- package/package.json +1 -1
|
@@ -44,3 +44,19 @@ export interface MarionettePreflightOptions {
|
|
|
44
44
|
export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
|
|
45
45
|
/** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
|
|
46
46
|
export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;
|
|
47
|
+
/**
|
|
48
|
+
* Formats the PASS/FAIL banner as a plain string for direct
|
|
49
|
+
* `process.stdout.write` use — bypasses the clack logger entirely so
|
|
50
|
+
* operators running `fireforge test --doctor` under a non-TTY (pipe,
|
|
51
|
+
* CI, `tee`-wrapped capture) always see the final line even when the
|
|
52
|
+
* clack renderer swallows trailing log output just before process exit.
|
|
53
|
+
*
|
|
54
|
+
* 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
|
|
55
|
+
* marionette preflight..."` intro and no PASS line at all — the
|
|
56
|
+
* `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
|
|
57
|
+
* we used to ship still lost the summary under some non-TTY flush
|
|
58
|
+
* races. Returning the raw string here lets the caller compose a single
|
|
59
|
+
* authoritative write without any clack layer between the probe and
|
|
60
|
+
* the captured log.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatMarionettePreflightLine(result: MarionettePreflightResult): string;
|
|
@@ -288,4 +288,23 @@ export function reportMarionettePreflight(result) {
|
|
|
288
288
|
warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Formats the PASS/FAIL banner as a plain string for direct
|
|
293
|
+
* `process.stdout.write` use — bypasses the clack logger entirely so
|
|
294
|
+
* operators running `fireforge test --doctor` under a non-TTY (pipe,
|
|
295
|
+
* CI, `tee`-wrapped capture) always see the final line even when the
|
|
296
|
+
* clack renderer swallows trailing log output just before process exit.
|
|
297
|
+
*
|
|
298
|
+
* 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
|
|
299
|
+
* marionette preflight..."` intro and no PASS line at all — the
|
|
300
|
+
* `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
|
|
301
|
+
* we used to ship still lost the summary under some non-TTY flush
|
|
302
|
+
* races. Returning the raw string here lets the caller compose a single
|
|
303
|
+
* authoritative write without any clack layer between the probe and
|
|
304
|
+
* the captured log.
|
|
305
|
+
*/
|
|
306
|
+
export function formatMarionettePreflightLine(result) {
|
|
307
|
+
const status = result.ok ? 'PASS' : 'FAIL';
|
|
308
|
+
return `Marionette preflight: ${status} (${result.durationMs}ms) — ${result.detail}`;
|
|
309
|
+
}
|
|
291
310
|
//# sourceMappingURL=marionette-preflight.js.map
|
|
@@ -21,6 +21,10 @@ export interface CommitExportedPatchInput {
|
|
|
21
21
|
diff: string;
|
|
22
22
|
filesAffected: string[];
|
|
23
23
|
sourceEsrVersion: string;
|
|
24
|
+
/** Optional `PatchMetadata.tier` opt-in (only `"branding"` recognised). */
|
|
25
|
+
tier?: 'branding';
|
|
26
|
+
/** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
|
|
27
|
+
lintIgnore?: string[];
|
|
24
28
|
}
|
|
25
29
|
export interface CommitExportedPatchResult {
|
|
26
30
|
patchFilename: string;
|
|
@@ -88,13 +92,71 @@ export type UpdatePatchCommittedHook = () => Promise<void>;
|
|
|
88
92
|
* the mutation succeeds. See {@link UpdatePatchCommittedHook}.
|
|
89
93
|
*/
|
|
90
94
|
export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional `PatchMetadata` keys safe to clear via the helpers below.
|
|
97
|
+
* Required keys (filename, order, etc.) are excluded by construction so
|
|
98
|
+
* an over-eager `unsetFields: ['filename']` cannot delete a field the
|
|
99
|
+
* manifest validator requires. Add new keys here only when they become
|
|
100
|
+
* optional on the type.
|
|
101
|
+
*/
|
|
102
|
+
export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
|
|
91
103
|
/**
|
|
92
104
|
* Updates metadata for a patch in the manifest.
|
|
105
|
+
*
|
|
106
|
+
* Required-field updates go through the `updates` partial. Clearing an
|
|
107
|
+
* optional field (e.g. removing the `tier` override) goes through
|
|
108
|
+
* `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
|
|
109
|
+
* not let `Partial<PatchMetadata>` carry an explicit `undefined` value
|
|
110
|
+
* for fields whose declared type does not include `undefined`. The
|
|
111
|
+
* implementation deletes the listed keys from the merged record before
|
|
112
|
+
* writing, so the on-disk JSON omits them and the validator's
|
|
113
|
+
* "preserve only when present" contract is preserved.
|
|
114
|
+
*
|
|
93
115
|
* @param patchesDir - Path to the patches directory
|
|
94
116
|
* @param filename - Patch filename
|
|
95
|
-
* @param updates -
|
|
117
|
+
* @param updates - Field values to set. Pass an empty object when only
|
|
118
|
+
* clearing fields.
|
|
119
|
+
* @param unsetFields - Optional fields to remove from the entry (so
|
|
120
|
+
* serialization drops them).
|
|
121
|
+
*/
|
|
122
|
+
export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Return shape from a {@link mutatePatchMetadata} mutator.
|
|
125
|
+
*/
|
|
126
|
+
export interface PatchMetadataMutation {
|
|
127
|
+
/** Field values to set on the entry. */
|
|
128
|
+
set?: Partial<PatchMetadata>;
|
|
129
|
+
/** Optional fields to remove from the entry entirely. */
|
|
130
|
+
unset?: ReadonlyArray<ClearablePatchMetadataField>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Result of a successful {@link mutatePatchMetadata} call.
|
|
96
134
|
*/
|
|
97
|
-
export
|
|
135
|
+
export interface PatchMetadataMutationResult {
|
|
136
|
+
/** Pre-mutation snapshot of the patch's metadata. */
|
|
137
|
+
before: PatchMetadata;
|
|
138
|
+
/** Post-mutation state of the patch's metadata. */
|
|
139
|
+
after: PatchMetadata;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Reads a patch's metadata under the directory lock, applies a mutator
|
|
143
|
+
* function to compute the update, and writes the result back — all
|
|
144
|
+
* under a single lock so a concurrent writer cannot interleave a
|
|
145
|
+
* read-modify-write cycle. Useful for operations that need to compute
|
|
146
|
+
* the new value from the old (e.g. unioning a `lintIgnore` list,
|
|
147
|
+
* removing a specific entry), which {@link updatePatchMetadata}'s flat
|
|
148
|
+
* merge cannot express on its own.
|
|
149
|
+
*
|
|
150
|
+
* The mutator returns `{ set, unset }` so it can both write fields
|
|
151
|
+
* and drop optional ones. `set` and `unset` are merged before write:
|
|
152
|
+
* `set` runs first via spread, then `unset` deletes the listed keys.
|
|
153
|
+
*
|
|
154
|
+
* @returns The pre/post metadata pair when the patch is found and the
|
|
155
|
+
* write succeeds; `null` when the manifest is missing or the named
|
|
156
|
+
* patch is not in it. Callers should treat `null` as "no-op, nothing
|
|
157
|
+
* to log".
|
|
158
|
+
*/
|
|
159
|
+
export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
|
|
98
160
|
/**
|
|
99
161
|
* Finds patches that are completely superseded by newer patches.
|
|
100
162
|
* A patch is superseded if all its affected files are covered by newer patches.
|
|
@@ -196,6 +258,19 @@ export interface PlanExportInput {
|
|
|
196
258
|
description: string;
|
|
197
259
|
filesAffected: string[];
|
|
198
260
|
sourceEsrVersion: string;
|
|
261
|
+
/**
|
|
262
|
+
* Optional `PatchMetadata.tier` opt-in carried from the CLI flag.
|
|
263
|
+
* Only `"branding"` is currently recognised. When provided the field
|
|
264
|
+
* is written into the new patch's metadata; when absent the field
|
|
265
|
+
* stays unset and tier resolution falls back to auto-detection.
|
|
266
|
+
*/
|
|
267
|
+
tier?: 'branding';
|
|
268
|
+
/**
|
|
269
|
+
* Optional `PatchMetadata.lintIgnore` carried from the CLI flag.
|
|
270
|
+
* Empty arrays are treated as "field absent" — the validator only
|
|
271
|
+
* preserves the field when it has at least one entry.
|
|
272
|
+
*/
|
|
273
|
+
lintIgnore?: string[];
|
|
199
274
|
}
|
|
200
275
|
/**
|
|
201
276
|
* Read-only planning function — computes everything a real export would
|
|
@@ -63,6 +63,8 @@ export async function commitExportedPatch(input) {
|
|
|
63
63
|
description: input.description,
|
|
64
64
|
filesAffected: input.filesAffected,
|
|
65
65
|
sourceEsrVersion: input.sourceEsrVersion,
|
|
66
|
+
...(input.tier !== undefined ? { tier: input.tier } : {}),
|
|
67
|
+
...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
|
|
66
68
|
});
|
|
67
69
|
const patchPath = plan.patchPath;
|
|
68
70
|
const originalPatchContent = (await pathExists(patchPath)) ? await readText(patchPath) : null;
|
|
@@ -236,13 +238,50 @@ export async function updatePatchAndMetadata(patchesDir, filename, newContent, u
|
|
|
236
238
|
}
|
|
237
239
|
});
|
|
238
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Merges `updates` onto `existing` and removes the listed `unset`
|
|
243
|
+
* fields. The unset path is an explicit switch over the
|
|
244
|
+
* {@link ClearablePatchMetadataField} union rather than a dynamic
|
|
245
|
+
* `delete obj[k]` so the typecheck-time guarantee that only optional
|
|
246
|
+
* fields can be cleared survives the runtime erasure — and so the lint
|
|
247
|
+
* rule against dynamic deletes does not have to be silenced. Adding a
|
|
248
|
+
* new clearable field requires extending both the union and this
|
|
249
|
+
* switch in lockstep, which is exactly the constraint we want.
|
|
250
|
+
*/
|
|
251
|
+
function applyMetadataUpdate(existing, updates, unset) {
|
|
252
|
+
const next = { ...existing, ...updates };
|
|
253
|
+
for (const field of unset) {
|
|
254
|
+
switch (field) {
|
|
255
|
+
case 'tier':
|
|
256
|
+
delete next.tier;
|
|
257
|
+
break;
|
|
258
|
+
case 'lintIgnore':
|
|
259
|
+
delete next.lintIgnore;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
239
265
|
/**
|
|
240
266
|
* Updates metadata for a patch in the manifest.
|
|
267
|
+
*
|
|
268
|
+
* Required-field updates go through the `updates` partial. Clearing an
|
|
269
|
+
* optional field (e.g. removing the `tier` override) goes through
|
|
270
|
+
* `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
|
|
271
|
+
* not let `Partial<PatchMetadata>` carry an explicit `undefined` value
|
|
272
|
+
* for fields whose declared type does not include `undefined`. The
|
|
273
|
+
* implementation deletes the listed keys from the merged record before
|
|
274
|
+
* writing, so the on-disk JSON omits them and the validator's
|
|
275
|
+
* "preserve only when present" contract is preserved.
|
|
276
|
+
*
|
|
241
277
|
* @param patchesDir - Path to the patches directory
|
|
242
278
|
* @param filename - Patch filename
|
|
243
|
-
* @param updates -
|
|
279
|
+
* @param updates - Field values to set. Pass an empty object when only
|
|
280
|
+
* clearing fields.
|
|
281
|
+
* @param unsetFields - Optional fields to remove from the entry (so
|
|
282
|
+
* serialization drops them).
|
|
244
283
|
*/
|
|
245
|
-
export async function updatePatchMetadata(patchesDir, filename, updates) {
|
|
284
|
+
export async function updatePatchMetadata(patchesDir, filename, updates, unsetFields = []) {
|
|
246
285
|
await withPatchDirectoryLock(patchesDir, async () => {
|
|
247
286
|
const manifest = await loadPatchesManifest(patchesDir);
|
|
248
287
|
if (!manifest)
|
|
@@ -252,11 +291,47 @@ export async function updatePatchMetadata(patchesDir, filename, updates) {
|
|
|
252
291
|
return;
|
|
253
292
|
const existingPatch = manifest.patches[patchIndex];
|
|
254
293
|
if (existingPatch) {
|
|
255
|
-
manifest.patches[patchIndex] =
|
|
294
|
+
manifest.patches[patchIndex] = applyMetadataUpdate(existingPatch, updates, unsetFields);
|
|
256
295
|
await savePatchesManifest(patchesDir, manifest);
|
|
257
296
|
}
|
|
258
297
|
});
|
|
259
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Reads a patch's metadata under the directory lock, applies a mutator
|
|
301
|
+
* function to compute the update, and writes the result back — all
|
|
302
|
+
* under a single lock so a concurrent writer cannot interleave a
|
|
303
|
+
* read-modify-write cycle. Useful for operations that need to compute
|
|
304
|
+
* the new value from the old (e.g. unioning a `lintIgnore` list,
|
|
305
|
+
* removing a specific entry), which {@link updatePatchMetadata}'s flat
|
|
306
|
+
* merge cannot express on its own.
|
|
307
|
+
*
|
|
308
|
+
* The mutator returns `{ set, unset }` so it can both write fields
|
|
309
|
+
* and drop optional ones. `set` and `unset` are merged before write:
|
|
310
|
+
* `set` runs first via spread, then `unset` deletes the listed keys.
|
|
311
|
+
*
|
|
312
|
+
* @returns The pre/post metadata pair when the patch is found and the
|
|
313
|
+
* write succeeds; `null` when the manifest is missing or the named
|
|
314
|
+
* patch is not in it. Callers should treat `null` as "no-op, nothing
|
|
315
|
+
* to log".
|
|
316
|
+
*/
|
|
317
|
+
export async function mutatePatchMetadata(patchesDir, filename, mutator) {
|
|
318
|
+
return await withPatchDirectoryLock(patchesDir, async () => {
|
|
319
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
320
|
+
if (!manifest)
|
|
321
|
+
return null;
|
|
322
|
+
const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
|
|
323
|
+
if (patchIndex === -1)
|
|
324
|
+
return null;
|
|
325
|
+
const existingPatch = manifest.patches[patchIndex];
|
|
326
|
+
if (!existingPatch)
|
|
327
|
+
return null;
|
|
328
|
+
const { set = {}, unset = [] } = mutator(existingPatch);
|
|
329
|
+
const updatedPatch = applyMetadataUpdate(existingPatch, set, unset);
|
|
330
|
+
manifest.patches[patchIndex] = updatedPatch;
|
|
331
|
+
await savePatchesManifest(patchesDir, manifest);
|
|
332
|
+
return { before: existingPatch, after: updatedPatch };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
260
335
|
/**
|
|
261
336
|
* Finds patches that are completely superseded by newer patches.
|
|
262
337
|
* A patch is superseded if all its affected files are covered by newer patches.
|
|
@@ -419,6 +494,10 @@ async function computeExportPlanUnderLock(input) {
|
|
|
419
494
|
createdAt: new Date().toISOString(),
|
|
420
495
|
sourceEsrVersion: input.sourceEsrVersion,
|
|
421
496
|
filesAffected: input.filesAffected,
|
|
497
|
+
...(input.tier !== undefined ? { tier: input.tier } : {}),
|
|
498
|
+
...(input.lintIgnore !== undefined && input.lintIgnore.length > 0
|
|
499
|
+
? { lintIgnore: input.lintIgnore }
|
|
500
|
+
: {}),
|
|
422
501
|
};
|
|
423
502
|
const supersedeMatches = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected, patchFilename);
|
|
424
503
|
const supersededDetails = supersedeMatches.map((m) => ({
|
|
@@ -18,6 +18,13 @@ import type { PatchLintIssue } from '../types/commands/index.js';
|
|
|
18
18
|
* @param rev Git revision to diff against (e.g. `HEAD`, a branch, a SHA).
|
|
19
19
|
*/
|
|
20
20
|
export declare function collectDiffFilePaths(engineDir: string, rev: string): Promise<Set<string>>;
|
|
21
|
+
/**
|
|
22
|
+
* Synthetic "file" value used by aggregate patch-size rules
|
|
23
|
+
* (`large-patch-files` / `large-patch-lines`) to flag that a finding
|
|
24
|
+
* describes the whole diff rather than a single path. Exported so callers
|
|
25
|
+
* can keep the tagging contract visible in one place.
|
|
26
|
+
*/
|
|
27
|
+
export declare const AGGREGATE_PATCH_FILE = "(patch)";
|
|
21
28
|
/**
|
|
22
29
|
* Annotates a list of lint issues with `introduced` / `cumulative` tags
|
|
23
30
|
* based on whether the issue's file is part of the supplied diff set.
|
|
@@ -27,6 +34,19 @@ export declare function collectDiffFilePaths(engineDir: string, rev: string): Pr
|
|
|
27
34
|
* describe queue-wide state — are always `cumulative` under `--since`
|
|
28
35
|
* because they describe drift accumulated across many commits, not a
|
|
29
36
|
* single current-task edit.
|
|
37
|
+
*
|
|
38
|
+
* Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
|
|
39
|
+
* which is a synthetic placeholder that will never appear in a real
|
|
40
|
+
* `diffFiles` set. Without special-casing, `large-patch-files` /
|
|
41
|
+
* `large-patch-lines` were always tagged `[cumulative]` under
|
|
42
|
+
* `--only-introduced` even when the diff WAS the aggregate the rules
|
|
43
|
+
* measured — the eval (Finding #4) reported a stack of 20+ imported
|
|
44
|
+
* patches whose aggregate-size warnings printed as `[cumulative]` under
|
|
45
|
+
* `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
|
|
46
|
+
* to an operator asking "what did this diff introduce?" We promote the
|
|
47
|
+
* aggregate tag to `introduced` whenever the diff set has any content —
|
|
48
|
+
* non-empty `diffFiles` means the operator asked about a specific diff
|
|
49
|
+
* scope and the aggregate-rule finding describes exactly that scope.
|
|
30
50
|
* @param issues Issues returned by the lint orchestrator.
|
|
31
51
|
* @param diffFiles File paths touched since the user's revision.
|
|
32
52
|
*/
|
|
@@ -58,6 +58,13 @@ export async function collectDiffFilePaths(engineDir, rev) {
|
|
|
58
58
|
}
|
|
59
59
|
return files;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Synthetic "file" value used by aggregate patch-size rules
|
|
63
|
+
* (`large-patch-files` / `large-patch-lines`) to flag that a finding
|
|
64
|
+
* describes the whole diff rather than a single path. Exported so callers
|
|
65
|
+
* can keep the tagging contract visible in one place.
|
|
66
|
+
*/
|
|
67
|
+
export const AGGREGATE_PATCH_FILE = '(patch)';
|
|
61
68
|
/**
|
|
62
69
|
* Annotates a list of lint issues with `introduced` / `cumulative` tags
|
|
63
70
|
* based on whether the issue's file is part of the supplied diff set.
|
|
@@ -67,15 +74,33 @@ export async function collectDiffFilePaths(engineDir, rev) {
|
|
|
67
74
|
* describe queue-wide state — are always `cumulative` under `--since`
|
|
68
75
|
* because they describe drift accumulated across many commits, not a
|
|
69
76
|
* single current-task edit.
|
|
77
|
+
*
|
|
78
|
+
* Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
|
|
79
|
+
* which is a synthetic placeholder that will never appear in a real
|
|
80
|
+
* `diffFiles` set. Without special-casing, `large-patch-files` /
|
|
81
|
+
* `large-patch-lines` were always tagged `[cumulative]` under
|
|
82
|
+
* `--only-introduced` even when the diff WAS the aggregate the rules
|
|
83
|
+
* measured — the eval (Finding #4) reported a stack of 20+ imported
|
|
84
|
+
* patches whose aggregate-size warnings printed as `[cumulative]` under
|
|
85
|
+
* `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
|
|
86
|
+
* to an operator asking "what did this diff introduce?" We promote the
|
|
87
|
+
* aggregate tag to `introduced` whenever the diff set has any content —
|
|
88
|
+
* non-empty `diffFiles` means the operator asked about a specific diff
|
|
89
|
+
* scope and the aggregate-rule finding describes exactly that scope.
|
|
70
90
|
* @param issues Issues returned by the lint orchestrator.
|
|
71
91
|
* @param diffFiles File paths touched since the user's revision.
|
|
72
92
|
*/
|
|
73
93
|
export function tagLintIssues(issues, diffFiles) {
|
|
94
|
+
const hasDiffContent = diffFiles.size > 0;
|
|
74
95
|
for (const issue of issues) {
|
|
75
96
|
if (!issue.file) {
|
|
76
97
|
issue.tag = 'cumulative';
|
|
77
98
|
continue;
|
|
78
99
|
}
|
|
100
|
+
if (issue.file === AGGREGATE_PATCH_FILE) {
|
|
101
|
+
issue.tag = hasDiffContent ? 'introduced' : 'cumulative';
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
79
104
|
issue.tag = diffFiles.has(issue.file) ? 'introduced' : 'cumulative';
|
|
80
105
|
}
|
|
81
106
|
return issues;
|
|
@@ -8,6 +8,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
|
|
|
8
8
|
import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
|
|
9
9
|
import { runCheckJs } from './patch-lint-checkjs.js';
|
|
10
10
|
import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
|
|
11
|
+
import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
|
|
11
12
|
import { validateExportJsDoc } from './patch-lint-jsdoc.js';
|
|
12
13
|
import { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -64,14 +65,55 @@ const PATCH_LINE_THRESHOLDS = {
|
|
|
64
65
|
* Branding patches have a legitimate reason to be large: they include
|
|
65
66
|
* every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
|
|
66
67
|
* fork-specific `configure.sh` / `brand.properties` under a single
|
|
67
|
-
* `browser/branding/<name>/` subtree.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
68
|
+
* `browser/branding/<name>/` subtree. Calibrated against:
|
|
69
|
+
*
|
|
70
|
+
* - The 2026-04-21 eval baseline: a fresh-fork branding export landed
|
|
71
|
+
* at 15904 lines (localized brand.ftl across many locales + SVG path
|
|
72
|
+
* data + copied upstream CSS).
|
|
73
|
+
* - The 2026-04-25 operator data point: a freshly setup branding patch
|
|
74
|
+
* (post-binary-exclusion, after Phase 1+2 patch splits) landed at
|
|
75
|
+
* 15650 lines — within 2% of the eval baseline.
|
|
76
|
+
*
|
|
77
|
+
* Both data points need to surface as a soft `notice` rather than a
|
|
78
|
+
* `warning`, since they represent the *minimum* branding diff. The
|
|
79
|
+
* pre-2026-04-25 calibration {3000/8000/20000} put 15904 firmly in the
|
|
80
|
+
* `warning` band, contradicting the docstring's "loud but not
|
|
81
|
+
* actionable" intent. The current calibration moves the warning band
|
|
82
|
+
* above the eval baseline (with ~13% headroom) and the error band to
|
|
83
|
+
* roughly 2× the baseline — reaching `error` strongly suggests
|
|
84
|
+
* non-branding work is bundled in.
|
|
85
|
+
*
|
|
86
|
+
* Permissive thresholds are safe because the *gate* into this tier is
|
|
87
|
+
* narrow (auto-detect requires every file under `browser/branding/`
|
|
88
|
+
* plus a tight registration allowlist, or an explicit
|
|
89
|
+
* `PatchMetadata.tier: "branding"` opt-in). A non-branding patch
|
|
90
|
+
* cannot accidentally land here.
|
|
73
91
|
*/
|
|
74
|
-
branding: { notice:
|
|
92
|
+
branding: { notice: 8000, warning: 18000, error: 30000 },
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* File-count thresholds for the `large-patch-files` rule, mirroring the
|
|
96
|
+
* tier shape of {@link PATCH_LINE_THRESHOLDS}. A single warning-only
|
|
97
|
+
* threshold per tier is intentional — file count expresses scope, not
|
|
98
|
+
* blast radius, and there is no error band that would block export.
|
|
99
|
+
*
|
|
100
|
+
* The branding tier sits well above the typical floor because branding
|
|
101
|
+
* patches inherently span many files: PNG/ICO icon assets in 7+ sizes,
|
|
102
|
+
* MSIX manifests, channel-specific configs, locale `.ftl` files,
|
|
103
|
+
* Windows/macOS launcher resources. The 2026-04-25 operator data point
|
|
104
|
+
* reported a 56-file fresh-fork branding bundle as the minimum shape;
|
|
105
|
+
* 60 leaves headroom for additional channels/locales while still firing
|
|
106
|
+
* on a genuinely bloated patch.
|
|
107
|
+
*
|
|
108
|
+
* Test tier matches general because a test-only patch rarely touches
|
|
109
|
+
* many files (a single regression test usually adds 1–3 fixtures); the
|
|
110
|
+
* elevation in {@link PATCH_LINE_THRESHOLDS.test} addresses big
|
|
111
|
+
* table-driven test bodies, not file fan-out.
|
|
112
|
+
*/
|
|
113
|
+
const PATCH_FILES_THRESHOLDS = {
|
|
114
|
+
general: 5,
|
|
115
|
+
test: 5,
|
|
116
|
+
branding: 60,
|
|
75
117
|
};
|
|
76
118
|
/**
|
|
77
119
|
* Fixed allowlist of non-branding sibling paths that real-world Firefox
|
|
@@ -501,50 +543,58 @@ export function resolvePatchSizeTier(filesAffected, patchTier) {
|
|
|
501
543
|
*/
|
|
502
544
|
export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
503
545
|
const issues = [];
|
|
504
|
-
if (filesAffected.length > 5) {
|
|
505
|
-
issues.push({
|
|
506
|
-
file: '(patch)',
|
|
507
|
-
check: 'large-patch-files',
|
|
508
|
-
message: `Patch affects ${filesAffected.length} files (recommended: ≤5). Consider splitting into smaller, focused patches.`,
|
|
509
|
-
severity: 'warning',
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
546
|
// Tier selection: test > branding > general. Tests keep their elevated
|
|
513
547
|
// thresholds because a big regression test is legitimate (table-driven
|
|
514
|
-
// harnesses run into the thousands of lines). Branding patches get
|
|
515
|
-
// own tier so a first-export of setup-generated branding doesn't
|
|
516
|
-
// the general hard limit — see `PATCH_LINE_THRESHOLDS.branding`
|
|
517
|
-
// for the eval data
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
548
|
+
// harnesses run into the thousands of lines). Branding patches get
|
|
549
|
+
// their own tier so a first-export of setup-generated branding doesn't
|
|
550
|
+
// fire the general hard limit — see `PATCH_LINE_THRESHOLDS.branding`
|
|
551
|
+
// and `PATCH_FILES_THRESHOLDS.branding` above for the eval data
|
|
552
|
+
// motivating this tier. An explicit `patchTier` opt-in forces branding
|
|
553
|
+
// even when `isBrandingOnlyPatch` cannot reach the patch's actual
|
|
554
|
+
// shape (a branding patch that also touches a non-allowlisted sibling
|
|
555
|
+
// like a vendor-specific icon resource). Both checks read off the
|
|
556
|
+
// same decision so the file-count and line-count rules cannot
|
|
557
|
+
// disagree about which tier applies.
|
|
521
558
|
const decision = resolvePatchSizeTier(filesAffected, patchTier);
|
|
522
|
-
const
|
|
559
|
+
const fileThreshold = decision.tier === 'test'
|
|
560
|
+
? PATCH_FILES_THRESHOLDS.test
|
|
561
|
+
: decision.tier === 'branding'
|
|
562
|
+
? PATCH_FILES_THRESHOLDS.branding
|
|
563
|
+
: PATCH_FILES_THRESHOLDS.general;
|
|
564
|
+
const lineThresholds = decision.tier === 'test'
|
|
523
565
|
? PATCH_LINE_THRESHOLDS.test
|
|
524
566
|
: decision.tier === 'branding'
|
|
525
567
|
? PATCH_LINE_THRESHOLDS.branding
|
|
526
568
|
: PATCH_LINE_THRESHOLDS.general;
|
|
527
|
-
if (
|
|
569
|
+
if (filesAffected.length > fileThreshold) {
|
|
570
|
+
issues.push({
|
|
571
|
+
file: AGGREGATE_PATCH_FILE,
|
|
572
|
+
check: 'large-patch-files',
|
|
573
|
+
message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). Consider splitting into smaller, focused patches.`,
|
|
574
|
+
severity: 'warning',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (lineCount >= lineThresholds.error) {
|
|
528
578
|
issues.push({
|
|
529
|
-
file:
|
|
579
|
+
file: AGGREGATE_PATCH_FILE,
|
|
530
580
|
check: 'large-patch-lines',
|
|
531
|
-
message: `Patch is ${lineCount} lines (hard limit: ${
|
|
581
|
+
message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
532
582
|
severity: 'error',
|
|
533
583
|
});
|
|
534
584
|
}
|
|
535
|
-
else if (lineCount >=
|
|
585
|
+
else if (lineCount >= lineThresholds.warning) {
|
|
536
586
|
issues.push({
|
|
537
|
-
file:
|
|
587
|
+
file: AGGREGATE_PATCH_FILE,
|
|
538
588
|
check: 'large-patch-lines',
|
|
539
|
-
message: `Patch is ${lineCount} lines (soft limit: ${
|
|
589
|
+
message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
540
590
|
severity: 'warning',
|
|
541
591
|
});
|
|
542
592
|
}
|
|
543
|
-
else if (lineCount >=
|
|
593
|
+
else if (lineCount >= lineThresholds.notice) {
|
|
544
594
|
issues.push({
|
|
545
|
-
file:
|
|
595
|
+
file: AGGREGATE_PATCH_FILE,
|
|
546
596
|
check: 'large-patch-lines',
|
|
547
|
-
message: `Patch is ${lineCount} lines (soft limit: ${
|
|
597
|
+
message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
548
598
|
severity: 'notice',
|
|
549
599
|
});
|
|
550
600
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts furnace-shaped registration references from a patch body.
|
|
3
|
+
*
|
|
4
|
+
* 2026-04-24 eval Finding 1: `export-all --exclude-furnace` can land a
|
|
5
|
+
* patch that registers a furnace component (via edits to
|
|
6
|
+
* `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, or
|
|
7
|
+
* `toolkit/locales/jar.mn`) without including the component's source
|
|
8
|
+
* files in the patch. `fireforge verify` then reports "Verify clean" for
|
|
9
|
+
* the broken queue. This module provides a pattern-scoped scan so
|
|
10
|
+
* `verify` can cross-check registrations against available file bodies.
|
|
11
|
+
*
|
|
12
|
+
* The scan is deliberately narrow: it only matches component-shaped
|
|
13
|
+
* references (widget tag names, locale fluent names). Unrelated jar.mn
|
|
14
|
+
* or customElements.js edits pass through without spurious warnings.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A referenced engine path extracted from a registration hunk, together
|
|
18
|
+
* with where it came from. The `source` field lets `verify` point
|
|
19
|
+
* operators at the specific consequence file whose hunk introduced the
|
|
20
|
+
* reference.
|
|
21
|
+
*/
|
|
22
|
+
export interface PatchRegistrationReference {
|
|
23
|
+
/** Engine-relative path that the registration hunk adds a reference to. */
|
|
24
|
+
targetPath: string;
|
|
25
|
+
/** The registration file that contained the added hunk. */
|
|
26
|
+
source: string;
|
|
27
|
+
/** Raw hunk line that produced the reference, for diagnostic context. */
|
|
28
|
+
lineText: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Walks a unified-diff patch body and returns the set of
|
|
32
|
+
* component-shaped engine paths that the patch ADDS a registration for.
|
|
33
|
+
*
|
|
34
|
+
* Returns the empty array when no registration hunks are present OR
|
|
35
|
+
* when the registration hunks do not mention any component-shaped
|
|
36
|
+
* paths — that leaves the scan silent on the vast majority of patches
|
|
37
|
+
* (branding tweaks, behavioural fixes, module additions) so it only
|
|
38
|
+
* fires when a furnace-managed component is being newly registered.
|
|
39
|
+
*
|
|
40
|
+
* @param patchBody - Full unified-diff body of the patch file.
|
|
41
|
+
*/
|
|
42
|
+
export declare function collectPatchRegistrationReferences(patchBody: string): PatchRegistrationReference[];
|