@hominis/fireforge 0.30.1 → 0.32.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 +36 -0
- package/README.md +22 -0
- package/dist/src/commands/export-all.js +9 -16
- package/dist/src/commands/export-flow.d.ts +6 -0
- package/dist/src/commands/export-flow.js +6 -1
- package/dist/src/commands/export-placement-gate.d.ts +38 -0
- package/dist/src/commands/export-placement-gate.js +105 -0
- package/dist/src/commands/export-shared.d.ts +28 -0
- package/dist/src/commands/export-shared.js +46 -1
- package/dist/src/commands/export.js +52 -113
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
- package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
- package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
- package/dist/src/commands/furnace/create.d.ts +1 -2
- package/dist/src/commands/furnace/deploy.js +36 -114
- package/dist/src/commands/furnace/refresh.js +52 -32
- package/dist/src/commands/furnace/sync.js +2 -0
- package/dist/src/commands/import.js +108 -73
- package/dist/src/commands/lint-per-patch.d.ts +3 -1
- package/dist/src/commands/lint-per-patch.js +265 -74
- package/dist/src/commands/lint.d.ts +1 -58
- package/dist/src/commands/lint.js +193 -88
- package/dist/src/commands/patch/compact.d.ts +5 -2
- package/dist/src/commands/patch/compact.js +85 -25
- package/dist/src/commands/patch/delete.js +17 -17
- package/dist/src/commands/patch/index.js +2 -0
- package/dist/src/commands/patch/lint-ignore.js +3 -16
- package/dist/src/commands/patch/move-files.js +2 -0
- package/dist/src/commands/patch/patch-context.d.ts +41 -0
- package/dist/src/commands/patch/patch-context.js +53 -0
- package/dist/src/commands/patch/rename.js +10 -15
- package/dist/src/commands/patch/reorder.d.ts +0 -2
- package/dist/src/commands/patch/reorder.js +18 -19
- package/dist/src/commands/patch/split-plan.d.ts +66 -0
- package/dist/src/commands/patch/split-plan.js +178 -0
- package/dist/src/commands/patch/split.d.ts +30 -0
- package/dist/src/commands/patch/split.js +283 -0
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
- package/dist/src/commands/patch/staged-dependency.js +4 -17
- package/dist/src/commands/patch/tier.js +4 -17
- package/dist/src/commands/re-export-files.js +4 -1
- package/dist/src/commands/re-export-scan.js +8 -1
- package/dist/src/commands/re-export.js +8 -1
- package/dist/src/commands/rebase/summary.d.ts +1 -5
- package/dist/src/commands/rebase/summary.js +1 -1
- package/dist/src/commands/status-output.js +77 -68
- package/dist/src/commands/test-diagnose.d.ts +23 -0
- package/dist/src/commands/test-diagnose.js +210 -0
- package/dist/src/commands/test-run.d.ts +68 -0
- package/dist/src/commands/test-run.js +97 -0
- package/dist/src/commands/test.js +214 -263
- package/dist/src/commands/token.js +15 -1
- package/dist/src/commands/wire.js +109 -78
- package/dist/src/core/build-audit.d.ts +1 -1
- package/dist/src/core/build-audit.js +2 -46
- package/dist/src/core/build-baseline-types.d.ts +38 -0
- package/dist/src/core/build-baseline-types.js +10 -0
- package/dist/src/core/build-baseline.d.ts +1 -31
- package/dist/src/core/build-prepare.d.ts +1 -1
- package/dist/src/core/build-prepare.js +2 -45
- package/dist/src/core/config-paths.d.ts +0 -8
- package/dist/src/core/config-paths.js +4 -4
- package/dist/src/core/config-state.d.ts +0 -6
- package/dist/src/core/config-state.js +1 -1
- package/dist/src/core/config-validate-patch-policy.js +12 -13
- package/dist/src/core/config-validate.js +74 -28
- package/dist/src/core/engine-changes.d.ts +24 -0
- package/dist/src/core/engine-changes.js +64 -0
- package/dist/src/core/firefox-cache.d.ts +0 -5
- package/dist/src/core/firefox-cache.js +1 -1
- package/dist/src/core/firefox-download.d.ts +0 -6
- package/dist/src/core/firefox-download.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
- package/dist/src/core/furnace-apply-helpers.js +11 -20
- package/dist/src/core/furnace-apply.d.ts +1 -1
- package/dist/src/core/furnace-apply.js +1 -1
- package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
- package/dist/src/core/furnace-checksum-utils.js +15 -0
- package/dist/src/core/furnace-config-validate.d.ts +31 -0
- package/dist/src/core/furnace-config-validate.js +133 -0
- package/dist/src/core/furnace-config.d.ts +4 -32
- package/dist/src/core/furnace-config.js +15 -111
- package/dist/src/core/furnace-constants.d.ts +0 -10
- package/dist/src/core/furnace-constants.js +2 -2
- package/dist/src/core/furnace-css-fragments.d.ts +79 -0
- package/dist/src/core/furnace-css-fragments.js +243 -0
- package/dist/src/core/furnace-jsconfig.d.ts +63 -0
- package/dist/src/core/furnace-jsconfig.js +191 -0
- package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
- package/dist/src/core/furnace-validate-helpers.js +40 -1
- package/dist/src/core/furnace-validate-registration.js +16 -1
- package/dist/src/core/furnace-validate.js +54 -2
- package/dist/src/core/git-base.d.ts +15 -0
- package/dist/src/core/git-base.js +32 -0
- package/dist/src/core/git-diff.d.ts +8 -0
- package/dist/src/core/git-diff.js +224 -59
- package/dist/src/core/git-file-ops.d.ts +39 -12
- package/dist/src/core/git-file-ops.js +84 -3
- package/dist/src/core/lint-cache.d.ts +0 -13
- package/dist/src/core/lint-cache.js +5 -5
- package/dist/src/core/mach.d.ts +22 -1
- package/dist/src/core/mach.js +27 -2
- package/dist/src/core/manifest-register.d.ts +5 -16
- package/dist/src/core/manifest-register.js +3 -1
- package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
- package/dist/src/core/patch-lint-checkjs.js +263 -71
- package/dist/src/core/patch-lint-css.d.ts +23 -0
- package/dist/src/core/patch-lint-css.js +172 -0
- package/dist/src/core/patch-lint-jsdoc.js +63 -4
- package/dist/src/core/patch-lint-observer.d.ts +37 -0
- package/dist/src/core/patch-lint-observer.js +168 -0
- package/dist/src/core/patch-lint.d.ts +34 -11
- package/dist/src/core/patch-lint.js +24 -161
- package/dist/src/core/patch-manifest-io.d.ts +16 -0
- package/dist/src/core/patch-manifest-io.js +44 -2
- package/dist/src/core/patch-manifest-validate.d.ts +1 -8
- package/dist/src/core/patch-manifest-validate.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-policy.d.ts +0 -4
- package/dist/src/core/patch-policy.js +10 -4
- package/dist/src/core/register-browser-content.d.ts +1 -1
- package/dist/src/core/register-module.d.ts +1 -1
- package/dist/src/core/register-result.d.ts +21 -0
- package/dist/src/core/register-result.js +9 -0
- package/dist/src/core/register-shared-css.d.ts +1 -1
- package/dist/src/core/register-test-manifest.d.ts +1 -1
- package/dist/src/core/test-harness-crash.d.ts +61 -0
- package/dist/src/core/test-harness-crash.js +140 -0
- package/dist/src/core/test-stale-check.d.ts +1 -1
- package/dist/src/core/test-stale-check.js +2 -46
- package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
- package/dist/src/core/test-xpcshell-retry.js +10 -3
- package/dist/src/core/token-dark-mode.js +14 -26
- package/dist/src/core/token-manager.d.ts +4 -0
- package/dist/src/core/token-manager.js +70 -16
- package/dist/src/core/typecheck-shim.d.ts +3 -22
- package/dist/src/core/typecheck-shim.js +69 -7
- package/dist/src/core/wire-utils.js +37 -44
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +122 -0
- package/dist/src/types/config.d.ts +11 -2
- package/dist/src/types/furnace.d.ts +12 -1
- package/dist/src/utils/elapsed.d.ts +0 -2
- package/dist/src/utils/elapsed.js +1 -1
- package/dist/src/utils/fs.d.ts +0 -5
- package/dist/src/utils/fs.js +1 -1
- package/dist/src/utils/regex.d.ts +0 -6
- package/dist/src/utils/regex.js +3 -3
- package/dist/src/utils/validation.d.ts +0 -8
- package/dist/src/utils/validation.js +2 -2
- package/package.json +6 -4
|
@@ -200,6 +200,108 @@ function buildUntilFilenameSet(patches, until) {
|
|
|
200
200
|
}
|
|
201
201
|
return set;
|
|
202
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Runs the manifest consistency check, scoped to the `--until` subset:
|
|
205
|
+
* global (manifest-level) issues always block, per-patch issues only
|
|
206
|
+
* block when the patch is in scope. Throws GeneralError with the repair
|
|
207
|
+
* hint when anything in scope is broken.
|
|
208
|
+
*/
|
|
209
|
+
async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, until) {
|
|
210
|
+
const manifestConsistencyIssues = await validatePatchesManifestConsistency(patchesDir);
|
|
211
|
+
const scopedManifestIssues = until !== undefined
|
|
212
|
+
? manifestConsistencyIssues.filter((issue) =>
|
|
213
|
+
// Global (manifest-level) issues have no specific filename to scope
|
|
214
|
+
// against — a missing or unparseable patches.json blocks any
|
|
215
|
+
// import. Per-patch issues only block when the patch is in scope.
|
|
216
|
+
issue.code === 'manifest-missing' ||
|
|
217
|
+
issue.code === 'manifest-invalid' ||
|
|
218
|
+
untilFilenameSet.has(issue.filename))
|
|
219
|
+
: manifestConsistencyIssues;
|
|
220
|
+
if (scopedManifestIssues.length > 0) {
|
|
221
|
+
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
222
|
+
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
223
|
+
` ${issueSummary}\n\n` +
|
|
224
|
+
'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Prints advisory version-compatibility warnings for every in-scope patch
|
|
229
|
+
* whose recorded source version differs meaningfully from the configured
|
|
230
|
+
* Firefox version. Advisory only — never blocks the import.
|
|
231
|
+
*/
|
|
232
|
+
async function warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, until) {
|
|
233
|
+
if (!manifest)
|
|
234
|
+
return;
|
|
235
|
+
const config = await loadConfig(projectRoot);
|
|
236
|
+
const currentVersion = config.firefox.version;
|
|
237
|
+
for (const patch of manifest.patches) {
|
|
238
|
+
// Scope the advisory warnings too: an operator running with --until
|
|
239
|
+
// doesn't need to see version warnings for patches outside the range.
|
|
240
|
+
if (until !== undefined && !untilFilenameSet.has(patch.filename))
|
|
241
|
+
continue;
|
|
242
|
+
const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
|
|
243
|
+
if (warning) {
|
|
244
|
+
warn(`${patch.filename}: ${warning}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Patch-integrity gate: surfaces orphaned-modification issues scoped to
|
|
250
|
+
* the `--until` range and decides whether the import may proceed —
|
|
251
|
+
* `--force` continues with a warning, non-TTY refuses loudly, and an
|
|
252
|
+
* interactive operator is prompted. Returns false when the import should
|
|
253
|
+
* stop (the cancel outro has been printed).
|
|
254
|
+
*/
|
|
255
|
+
async function gateImportIntegrity(paths, untilFilenameSet, until, forceImport) {
|
|
256
|
+
const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
|
|
257
|
+
const integrityIssues = until !== undefined
|
|
258
|
+
? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
|
|
259
|
+
: allIntegrityIssues;
|
|
260
|
+
if (integrityIssues.length > 0) {
|
|
261
|
+
warn('\nPatch integrity issues detected:');
|
|
262
|
+
for (const issue of integrityIssues) {
|
|
263
|
+
warn(` ${issue.filename}: ${issue.message}`);
|
|
264
|
+
}
|
|
265
|
+
info('Run "fireforge doctor" for more details.');
|
|
266
|
+
if (forceImport) {
|
|
267
|
+
warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
|
|
268
|
+
}
|
|
269
|
+
else if (!process.stdin.isTTY) {
|
|
270
|
+
throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
|
|
271
|
+
`Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const shouldContinue = await confirm({
|
|
275
|
+
message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
|
|
276
|
+
initialValue: false,
|
|
277
|
+
});
|
|
278
|
+
if (isCancel(shouldContinue) || !shouldContinue) {
|
|
279
|
+
outro('Import cancelled — fix the integrity issues and re-run');
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Dry-run rendering: lists the in-scope patches (or the bare count when no
|
|
288
|
+
* manifest exists) and prints the dry-run outro.
|
|
289
|
+
*/
|
|
290
|
+
function renderImportDryRun(manifest, untilFilenameSet, until, patchCount) {
|
|
291
|
+
if (manifest) {
|
|
292
|
+
const patches = until !== undefined
|
|
293
|
+
? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
|
|
294
|
+
: manifest.patches;
|
|
295
|
+
info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
|
|
296
|
+
for (const patch of patches) {
|
|
297
|
+
info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
|
|
302
|
+
}
|
|
303
|
+
outro('Dry run complete — no changes made');
|
|
304
|
+
}
|
|
203
305
|
/**
|
|
204
306
|
* Runs the import command to apply patches.
|
|
205
307
|
* @param projectRoot - Root directory of the project
|
|
@@ -245,37 +347,8 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
245
347
|
const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
|
|
246
348
|
const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
|
|
247
349
|
info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
? manifestConsistencyIssues.filter((issue) =>
|
|
251
|
-
// Global (manifest-level) issues have no specific filename to scope
|
|
252
|
-
// against — a missing or unparseable patches.json blocks any
|
|
253
|
-
// import. Per-patch issues only block when the patch is in scope.
|
|
254
|
-
issue.code === 'manifest-missing' ||
|
|
255
|
-
issue.code === 'manifest-invalid' ||
|
|
256
|
-
untilFilenameSet.has(issue.filename))
|
|
257
|
-
: manifestConsistencyIssues;
|
|
258
|
-
if (scopedManifestIssues.length > 0) {
|
|
259
|
-
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
260
|
-
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
261
|
-
` ${issueSummary}\n\n` +
|
|
262
|
-
'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
|
|
263
|
-
}
|
|
264
|
-
// Version compatibility warnings (advisory only)
|
|
265
|
-
if (manifest) {
|
|
266
|
-
const config = await loadConfig(projectRoot);
|
|
267
|
-
const currentVersion = config.firefox.version;
|
|
268
|
-
for (const patch of manifest.patches) {
|
|
269
|
-
// Scope the advisory warnings too: an operator running with --until
|
|
270
|
-
// doesn't need to see version warnings for patches outside the range.
|
|
271
|
-
if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
|
|
272
|
-
continue;
|
|
273
|
-
const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
|
|
274
|
-
if (warning) {
|
|
275
|
-
warn(`${patch.filename}: ${warning}`);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
350
|
+
await assertScopedManifestConsistency(paths.patches, untilFilenameSet, options.until);
|
|
351
|
+
await warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, options.until);
|
|
279
352
|
// Validate patch integrity (detect orphaned modification patches). Warn
|
|
280
353
|
// and prompt the operator to confirm before proceeding — the legacy
|
|
281
354
|
// warn-and-continue behaviour hid the real root cause because import
|
|
@@ -286,49 +359,11 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
286
359
|
// integrity problems should not block importing an earlier good subset,
|
|
287
360
|
// which is exactly what operators reach for when the tail of the queue
|
|
288
361
|
// is broken and they want to keep working against an earlier checkpoint.
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
: allIntegrityIssues;
|
|
293
|
-
if (integrityIssues.length > 0) {
|
|
294
|
-
warn('\nPatch integrity issues detected:');
|
|
295
|
-
for (const issue of integrityIssues) {
|
|
296
|
-
warn(` ${issue.filename}: ${issue.message}`);
|
|
297
|
-
}
|
|
298
|
-
info('Run "fireforge doctor" for more details.');
|
|
299
|
-
if (forceImport) {
|
|
300
|
-
warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
|
|
301
|
-
}
|
|
302
|
-
else if (!process.stdin.isTTY) {
|
|
303
|
-
throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
|
|
304
|
-
`Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
const shouldContinue = await confirm({
|
|
308
|
-
message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
|
|
309
|
-
initialValue: false,
|
|
310
|
-
});
|
|
311
|
-
if (isCancel(shouldContinue) || !shouldContinue) {
|
|
312
|
-
outro('Import cancelled — fix the integrity issues and re-run');
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
// Dry-run: list patches that would be applied and exit
|
|
362
|
+
const integrityOk = await gateImportIntegrity(paths, untilFilenameSet, options.until, forceImport);
|
|
363
|
+
if (!integrityOk)
|
|
364
|
+
return;
|
|
318
365
|
if (isDryRun) {
|
|
319
|
-
|
|
320
|
-
const patches = options.until !== undefined
|
|
321
|
-
? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
|
|
322
|
-
: manifest.patches;
|
|
323
|
-
info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
|
|
324
|
-
for (const patch of patches) {
|
|
325
|
-
info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
|
|
330
|
-
}
|
|
331
|
-
outro('Dry run complete — no changes made');
|
|
366
|
+
renderImportDryRun(manifest, untilFilenameSet, options.until, patchCount);
|
|
332
367
|
return;
|
|
333
368
|
}
|
|
334
369
|
await checkUncommittedPatchFiles(paths.engine, paths.patches, forceImport);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { getProjectPaths } from '../core/config.js';
|
|
2
|
-
import type { LintCommandOptions } from '
|
|
2
|
+
import type { LintCommandOptions } from '../types/commands/index.js';
|
|
3
3
|
/**
|
|
4
4
|
* Lints each patch in the queue as its own isolated diff, honouring
|
|
5
5
|
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
6
6
|
* the whole queue so queue-level findings are not lost by the rescoping.
|
|
7
|
+
* With `options.patches` set, only the named subset is linted (and the
|
|
8
|
+
* queue-level findings are scoped to files those patches touch).
|
|
7
9
|
*/
|
|
8
10
|
export declare function lintPerPatch(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, options?: LintCommandOptions): Promise<void>;
|
|
@@ -4,6 +4,8 @@ import { loadConfig } from '../core/config.js';
|
|
|
4
4
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
5
5
|
import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
|
|
6
6
|
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
7
|
+
import { invokePatchLintCheckJsGrouped, } from '../core/patch-lint-checkjs.js';
|
|
8
|
+
import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
|
|
7
9
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
8
10
|
import { evaluatePatchPolicy } from '../core/patch-policy.js';
|
|
9
11
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -22,94 +24,150 @@ function emitTierNotice(filename, files, tier) {
|
|
|
22
24
|
: `${filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
|
|
23
25
|
}
|
|
24
26
|
/**
|
|
25
|
-
* Lints
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* Lints one queued patch against its own isolated diff, reusing the cache entry
|
|
28
|
+
* when the cache key matches. Returns the outcome and the patch's (unprefixed)
|
|
29
|
+
* issues without touching shared state — the orchestrator applies the tier
|
|
30
|
+
* notice, issue prefixing, and cache write in patch order after the pool
|
|
31
|
+
* drains, so the bounded concurrency cannot reorder output. Returns `skipped`
|
|
32
|
+
* (no files present / empty diff), `cached`, or `linted`.
|
|
28
33
|
*/
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
async function lintQueuedPatch(patch, lintCtx) {
|
|
35
|
+
const { projectRoot, paths, config, ctx, cache, engineHeadSha } = lintCtx;
|
|
36
|
+
const existing = [];
|
|
37
|
+
for (const f of patch.filesAffected) {
|
|
38
|
+
if (await pathExists(join(paths.engine, f)))
|
|
39
|
+
existing.push(f);
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
if (existing.length === 0) {
|
|
42
|
+
return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
|
|
43
|
+
}
|
|
44
|
+
const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
45
|
+
let cacheKey;
|
|
46
|
+
if (cache) {
|
|
47
|
+
cacheKey = await buildPerPatchLintCacheKey({
|
|
48
|
+
projectRoot,
|
|
49
|
+
engineDir: paths.engine,
|
|
50
|
+
patchesDir: paths.patches,
|
|
51
|
+
patch,
|
|
52
|
+
existingFiles: existing,
|
|
53
|
+
config,
|
|
54
|
+
queueContext: ctx,
|
|
55
|
+
...(engineHeadSha === undefined ? {} : { engineHeadSha }),
|
|
49
56
|
});
|
|
57
|
+
const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
|
|
58
|
+
if (cached) {
|
|
59
|
+
return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
|
|
63
|
+
if (!diff.trim()) {
|
|
64
|
+
return { status: 'skipped', existingFiles: [], rawIssues: [], usedCheckJs: false };
|
|
50
65
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// checkJs: instead of rebuilding the program per patch, slice this patch's
|
|
67
|
+
// findings out of the one queue-wide program (built lazily on first miss).
|
|
68
|
+
let lintOptions;
|
|
69
|
+
let usedCheckJs = false;
|
|
70
|
+
if (lintCtx.checkJs) {
|
|
71
|
+
const grouped = await lintCtx.checkJs.getGrouped();
|
|
72
|
+
usedCheckJs = true;
|
|
73
|
+
const owned = lintCtx.checkJs.ownedByPatch.get(patch.filename);
|
|
74
|
+
const precomputedCheckJs = [];
|
|
75
|
+
if (owned) {
|
|
76
|
+
for (const rel of owned)
|
|
77
|
+
precomputedCheckJs.push(...(grouped.byFile.get(rel) ?? []));
|
|
58
78
|
}
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
lintOptions = { precomputedCheckJs };
|
|
80
|
+
}
|
|
81
|
+
const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier, lintOptions);
|
|
82
|
+
const result = {
|
|
83
|
+
status: 'linted',
|
|
84
|
+
existingFiles: existing,
|
|
85
|
+
rawIssues: patchIssues,
|
|
86
|
+
usedCheckJs,
|
|
87
|
+
};
|
|
88
|
+
if (cache && cacheKey) {
|
|
89
|
+
result.cacheWrite = { key: cacheKey, issues: patchIssues };
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Applies the per-patch results in patch order so the bounded concurrency
|
|
95
|
+
* cannot reorder output: emits each tier notice, the once-only run-level
|
|
96
|
+
* checkJs errors (before the first freshly linted patch's issues), the
|
|
97
|
+
* filename-prefixed issue rows, and the cache writes — all in the same sequence
|
|
98
|
+
* a serial run produced. Returns the run tallies.
|
|
99
|
+
*/
|
|
100
|
+
async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
|
|
101
|
+
const totals = {
|
|
102
|
+
linted: 0,
|
|
103
|
+
skipped: 0,
|
|
104
|
+
cacheDirty: false,
|
|
105
|
+
reusedCacheEntries: 0,
|
|
106
|
+
};
|
|
107
|
+
let globalCheckJsEmitted = false;
|
|
108
|
+
for (let i = 0; i < subset.length; i++) {
|
|
109
|
+
const patch = subset[i];
|
|
110
|
+
const result = results[i];
|
|
111
|
+
if (!patch || !result)
|
|
61
112
|
continue;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let patchIssues;
|
|
65
|
-
let cacheKey;
|
|
66
|
-
if (cache) {
|
|
67
|
-
cacheKey = await buildPerPatchLintCacheKey({
|
|
68
|
-
projectRoot,
|
|
69
|
-
engineDir: paths.engine,
|
|
70
|
-
patchesDir: paths.patches,
|
|
71
|
-
patch,
|
|
72
|
-
existingFiles: existing,
|
|
73
|
-
config,
|
|
74
|
-
queueContext: ctx,
|
|
75
|
-
...(engineHeadSha === undefined ? {} : { engineHeadSha }),
|
|
76
|
-
});
|
|
77
|
-
patchIssues = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
|
|
78
|
-
if (patchIssues) {
|
|
79
|
-
reusedCacheEntries++;
|
|
80
|
-
emitTierNotice(patch.filename, existing, patch.tier);
|
|
81
|
-
for (const issue of patchIssues) {
|
|
82
|
-
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
83
|
-
}
|
|
84
|
-
linted++;
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
|
|
89
|
-
if (!diff.trim()) {
|
|
90
|
-
skipped++;
|
|
113
|
+
if (result.status === 'skipped') {
|
|
114
|
+
totals.skipped++;
|
|
91
115
|
continue;
|
|
92
116
|
}
|
|
93
|
-
emitTierNotice(patch.filename,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
emitTierNotice(patch.filename, result.existingFiles, patch.tier);
|
|
118
|
+
// Run-level checkJs errors are emitted once, before the first freshly
|
|
119
|
+
// linted patch's own issues — matching the serial emit point.
|
|
120
|
+
if (result.usedCheckJs && checkJs && !globalCheckJsEmitted) {
|
|
121
|
+
globalCheckJsEmitted = true;
|
|
122
|
+
issues.push(...(await checkJs.getGlobal()));
|
|
98
123
|
}
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
if (result.cacheWrite && cache) {
|
|
125
|
+
setCachedPerPatchLintIssues(cache, patch.filename, result.cacheWrite.key, result.cacheWrite.issues);
|
|
126
|
+
totals.cacheDirty = true;
|
|
101
127
|
}
|
|
102
|
-
for (const issue of
|
|
128
|
+
for (const issue of result.rawIssues) {
|
|
103
129
|
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
104
130
|
}
|
|
105
|
-
|
|
131
|
+
if (result.status === 'cached')
|
|
132
|
+
totals.reusedCacheEntries++;
|
|
133
|
+
totals.linted++;
|
|
106
134
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
135
|
+
return totals;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Maximum patches linted concurrently. After the per-file→batched git change,
|
|
139
|
+
* each patch is only a handful of git spawns, so a small pool overlaps their
|
|
140
|
+
* I/O without oversubscribing git on the shared repository.
|
|
141
|
+
*/
|
|
142
|
+
const PER_PATCH_LINT_CONCURRENCY = 8;
|
|
143
|
+
/**
|
|
144
|
+
* Lints every patch in `subset` with bounded concurrency, returning results in
|
|
145
|
+
* patch order (each slot index matches `subset`). Mirrors the worker-pool idiom
|
|
146
|
+
* used by the rollback restore path. Side effects are deferred to the caller so
|
|
147
|
+
* issue ordering and cache writes stay deterministic.
|
|
148
|
+
*/
|
|
149
|
+
async function lintSubsetConcurrently(subset, lintCtx) {
|
|
150
|
+
const results = new Array(subset.length);
|
|
151
|
+
let index = 0;
|
|
152
|
+
async function worker() {
|
|
153
|
+
while (index < subset.length) {
|
|
154
|
+
const current = index++;
|
|
155
|
+
const patch = subset[current];
|
|
156
|
+
if (!patch)
|
|
157
|
+
break;
|
|
158
|
+
results[current] = await lintQueuedPatch(patch, lintCtx);
|
|
159
|
+
}
|
|
112
160
|
}
|
|
161
|
+
const workers = Array.from({ length: Math.min(PER_PATCH_LINT_CONCURRENCY, subset.length) }, () => worker());
|
|
162
|
+
await Promise.all(workers);
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reporting + exit phase of per-patch lint: renders every issue row,
|
|
167
|
+
* prints the per-patch summary, and applies the failure criteria
|
|
168
|
+
* (errors, `--max-warnings`) by throwing GeneralError.
|
|
169
|
+
*/
|
|
170
|
+
function reportPerPatchOutcome(issues, linted, skipped, options) {
|
|
113
171
|
if (issues.length === 0) {
|
|
114
172
|
if (linted === 0 && skipped > 0) {
|
|
115
173
|
info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
|
|
@@ -149,4 +207,137 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
|
149
207
|
outro('Lint passed');
|
|
150
208
|
}
|
|
151
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Resolves the `--patches <name…>` subset filter against the manifest,
|
|
212
|
+
* matching each requested name tolerantly (exact filename, filename ±
|
|
213
|
+
* `.patch`, or the manifest `name` field). Throws listing the available
|
|
214
|
+
* patches when a requested name matches none, so a typo fails loud rather
|
|
215
|
+
* than silently linting nothing.
|
|
216
|
+
*/
|
|
217
|
+
function selectPatchSubset(manifest, requested) {
|
|
218
|
+
const matches = (p, name) => p.filename === name ||
|
|
219
|
+
p.filename === `${name}.patch` ||
|
|
220
|
+
p.filename.replace(/\.patch$/, '') === name.replace(/\.patch$/, '') ||
|
|
221
|
+
p.name === name;
|
|
222
|
+
const selected = [];
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
for (const name of requested) {
|
|
225
|
+
const found = manifest.patches.filter((p) => matches(p, name));
|
|
226
|
+
if (found.length === 0) {
|
|
227
|
+
const available = manifest.patches.map((p) => p.filename).join(', ');
|
|
228
|
+
throw new GeneralError(`--patches: no patch in the queue matches "${name}". Available patches: ${available}`);
|
|
229
|
+
}
|
|
230
|
+
for (const p of found) {
|
|
231
|
+
if (!seen.has(p.filename)) {
|
|
232
|
+
seen.add(p.filename);
|
|
233
|
+
selected.push(p);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return selected;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Builds the per-run checkJs program controller when `patchLint.checkJs` is
|
|
241
|
+
* enabled, or returns undefined. The program is built lazily on the first
|
|
242
|
+
* cache miss (so an all-warm run never pays for it) and reused for every
|
|
243
|
+
* subsequent patch in the run.
|
|
244
|
+
*/
|
|
245
|
+
function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
|
|
246
|
+
const patchLint = config.patchLint;
|
|
247
|
+
if (!patchLint?.checkJs)
|
|
248
|
+
return undefined;
|
|
249
|
+
const ownedByPatch = new Map();
|
|
250
|
+
for (const entry of ctx.entries) {
|
|
251
|
+
const owned = new Set();
|
|
252
|
+
for (const f of entry.newFiles.keys()) {
|
|
253
|
+
if (f.endsWith('.sys.mjs'))
|
|
254
|
+
owned.add(f);
|
|
255
|
+
}
|
|
256
|
+
if (owned.size > 0)
|
|
257
|
+
ownedByPatch.set(entry.filename, owned);
|
|
258
|
+
}
|
|
259
|
+
// Memoise the *promise*, not the resolved value: under the bounded pool
|
|
260
|
+
// several patches can reach `getGrouped` before the first build resolves, and
|
|
261
|
+
// `??=` on the promise (a synchronous expression) guarantees a single build.
|
|
262
|
+
let groupedPromise;
|
|
263
|
+
return {
|
|
264
|
+
ownedByPatch,
|
|
265
|
+
getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
|
|
266
|
+
getGlobal: async () => (groupedPromise ? (await groupedPromise).global : []),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Lints each patch in the queue as its own isolated diff, honouring
|
|
271
|
+
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
272
|
+
* the whole queue so queue-level findings are not lost by the rescoping.
|
|
273
|
+
* With `options.patches` set, only the named subset is linted (and the
|
|
274
|
+
* queue-level findings are scoped to files those patches touch).
|
|
275
|
+
*/
|
|
276
|
+
export async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
277
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
278
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
279
|
+
info('No patches in manifest — nothing to lint per-patch.');
|
|
280
|
+
outro('Nothing to lint');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const subset = options.patches && options.patches.length > 0
|
|
284
|
+
? selectPatchSubset(manifest, options.patches)
|
|
285
|
+
: manifest.patches;
|
|
286
|
+
const subsetNames = new Set(subset.map((p) => p.filename));
|
|
287
|
+
const isSubset = subset.length !== manifest.patches.length;
|
|
288
|
+
const config = await loadConfig(projectRoot);
|
|
289
|
+
const ctx = await buildPatchQueueContext(paths.patches);
|
|
290
|
+
// Queue-level findings (policy, cross-patch) are scoped to the requested
|
|
291
|
+
// subset: a 5-patch slice should not fail on a policy or forward-import
|
|
292
|
+
// problem owned entirely by patches the operator did not target.
|
|
293
|
+
const subsetTouchedFiles = new Set();
|
|
294
|
+
if (isSubset) {
|
|
295
|
+
for (const entry of ctx.entries) {
|
|
296
|
+
if (!subsetNames.has(entry.filename))
|
|
297
|
+
continue;
|
|
298
|
+
for (const f of entry.newFiles.keys())
|
|
299
|
+
subsetTouchedFiles.add(f);
|
|
300
|
+
for (const f of entry.modifiedFileAdditions.keys())
|
|
301
|
+
subsetTouchedFiles.add(f);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const cache = options.noCache === true ? undefined : await loadPerPatchLintCache(projectRoot);
|
|
305
|
+
const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
|
|
306
|
+
const issues = [];
|
|
307
|
+
for (const issue of evaluatePatchPolicy(config, manifest)) {
|
|
308
|
+
if (isSubset && !subsetNames.has(issue.filename))
|
|
309
|
+
continue;
|
|
310
|
+
issues.push({
|
|
311
|
+
file: issue.filename,
|
|
312
|
+
check: `patch-policy/${issue.code}`,
|
|
313
|
+
message: issue.message,
|
|
314
|
+
severity: issue.severity,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx);
|
|
318
|
+
// Lint patches concurrently, then apply every side effect in patch order so
|
|
319
|
+
// the issue rows, the run-level checkJs errors, and the saved cache are
|
|
320
|
+
// identical to a serial run.
|
|
321
|
+
const results = await lintSubsetConcurrently(subset, {
|
|
322
|
+
projectRoot,
|
|
323
|
+
paths,
|
|
324
|
+
config,
|
|
325
|
+
ctx,
|
|
326
|
+
cache,
|
|
327
|
+
engineHeadSha,
|
|
328
|
+
checkJs,
|
|
329
|
+
});
|
|
330
|
+
const { linted, skipped, cacheDirty, reusedCacheEntries } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
|
|
331
|
+
for (const issue of lintPatchQueue(ctx)) {
|
|
332
|
+
if (isSubset && !subsetTouchedFiles.has(issue.file))
|
|
333
|
+
continue;
|
|
334
|
+
issues.push(issue);
|
|
335
|
+
}
|
|
336
|
+
if (cache && cacheDirty)
|
|
337
|
+
await savePerPatchLintCache(projectRoot, cache);
|
|
338
|
+
if (reusedCacheEntries > 0) {
|
|
339
|
+
info(`Reused lint cache for ${reusedCacheEntries} patch${reusedCacheEntries === 1 ? '' : 'es'}.`);
|
|
340
|
+
}
|
|
341
|
+
reportPerPatchOutcome(issues, linted, skipped, options);
|
|
342
|
+
}
|
|
152
343
|
//# sourceMappingURL=lint-per-patch.js.map
|
|
@@ -1,63 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import type { CommandContext } from '../types/cli.js';
|
|
3
|
-
import type { PatchLintIssue } from '../types/commands/index.js';
|
|
4
|
-
/** Options controlling how the lint command filters and tags its output. */
|
|
5
|
-
export interface LintCommandOptions {
|
|
6
|
-
/**
|
|
7
|
-
* When set, tag each issue as `introduced` or `cumulative` based on
|
|
8
|
-
* whether its file changed since this git revision (e.g. `HEAD`, a
|
|
9
|
-
* branch name, or a SHA). Issues are not filtered — the full set still
|
|
10
|
-
* prints — but a diff-scoped summary makes it trivial to see which
|
|
11
|
-
* errors the current task introduced.
|
|
12
|
-
*/
|
|
13
|
-
since?: string;
|
|
14
|
-
/**
|
|
15
|
-
* When set together with {@link since}, scope the exit code to issues
|
|
16
|
-
* tagged `introduced`. Cumulative pre-existing errors still print (so
|
|
17
|
-
* the operator can still see the full queue state) but do not fail
|
|
18
|
-
* lint. Motivating case: a branch whose diff is clean but whose repo
|
|
19
|
-
* already carries unrelated `raw-color` / license-header errors from
|
|
20
|
-
* older patches. Without this flag, CI treats the clean branch as
|
|
21
|
-
* failing; with it, a branch "breaks the build" only when its own diff
|
|
22
|
-
* introduced a new error.
|
|
23
|
-
*
|
|
24
|
-
* Requires {@link since}: without a revision to diff against there is
|
|
25
|
-
* no distinction between introduced and cumulative, so the flag is
|
|
26
|
-
* rejected up-front rather than silently ignored.
|
|
27
|
-
*/
|
|
28
|
-
onlyIntroduced?: boolean;
|
|
29
|
-
/**
|
|
30
|
-
* Lint each patch in the queue as its own isolated diff, rather than
|
|
31
|
-
* the aggregate `git diff HEAD` across all applied patches.
|
|
32
|
-
*
|
|
33
|
-
* Motivating case: running `fireforge lint` (no args) on a repo where
|
|
34
|
-
* `fireforge import` or `fireforge rebase` has just applied the full
|
|
35
|
-
* patch queue produces an aggregate diff (every patch's changes
|
|
36
|
-
* summed). The patch-size advisory rules (`large-patch-lines`,
|
|
37
|
-
* `large-patch-files`) then fire against the sum — e.g. "Patch is
|
|
38
|
-
* 37529 lines" on a queue of 22 individually-fine patches — which
|
|
39
|
-
* reads as a task-specific regression when it is really an artefact
|
|
40
|
-
* of the aggregation. `--per-patch` rescopes the diff to each patch's
|
|
41
|
-
* own `filesAffected`, honours the patch's own `lintIgnore`, and runs
|
|
42
|
-
* the cross-patch rules once over the whole queue so queue-level
|
|
43
|
-
* findings (duplicate creations, forward imports) still surface.
|
|
44
|
-
*
|
|
45
|
-
* Mutually exclusive with passing explicit file paths — the two
|
|
46
|
-
* scope contracts are different.
|
|
47
|
-
*/
|
|
48
|
-
perPatch?: boolean;
|
|
49
|
-
/**
|
|
50
|
-
* Maximum warning count tolerated before lint exits non-zero. Mirrors
|
|
51
|
-
* ESLint's `--max-warnings` shape for release gates that want advisory
|
|
52
|
-
* findings to become blocking without changing default CLI behavior.
|
|
53
|
-
*/
|
|
54
|
-
maxWarnings?: number;
|
|
55
|
-
/**
|
|
56
|
-
* Bypass per-patch lint cache reads and writes. Accepted in aggregate mode
|
|
57
|
-
* for CLI consistency, but only `--per-patch` currently uses the cache.
|
|
58
|
-
*/
|
|
59
|
-
noCache?: boolean;
|
|
60
|
-
}
|
|
3
|
+
import type { LintCommandOptions, PatchLintIssue } from '../types/commands/index.js';
|
|
61
4
|
/**
|
|
62
5
|
* Result of {@link applyAggregateLintIgnoreSuppression}.
|
|
63
6
|
*/
|