@hominis/fireforge 0.44.0 → 0.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -2
- package/README.md +20 -121
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/doctor-check-core.d.ts +9 -0
- package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
- package/dist/src/commands/doctor-patch-manifest.js +155 -0
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +48 -62
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/diff.js +6 -1
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/remove-mutations.js +5 -1
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/import.js +6 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +33 -11
- package/dist/src/core/build-audit-resolve.js +12 -3
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/config-mutate.js +20 -19
- package/dist/src/core/furnace-apply-ftl.js +4 -1
- package/dist/src/core/furnace-apply-helpers.js +11 -5
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/git-base.d.ts +14 -0
- package/dist/src/core/git-base.js +17 -0
- package/dist/src/core/git.js +3 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/patch-lint-checkjs.js +25 -4
- package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
- package/dist/src/core/patch-manifest-consistency.js +96 -23
- package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
- package/dist/src/core/patch-manifest-files-affected.js +77 -0
- package/dist/src/core/patch-manifest.d.ts +2 -1
- package/dist/src/core/patch-manifest.js +2 -1
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-category.js +61 -7
- package/dist/src/core/token-dark-mode.js +20 -2
- package/dist/src/core/token-docs.js +1 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +34 -0
- package/dist/src/core/token-variant.js +98 -8
- package/dist/src/core/tree-store.js +5 -1
- package/dist/src/core/typecheck.js +7 -1
- package/dist/src/types/commands/options.d.ts +53 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/package.json +2 -2
|
@@ -6,17 +6,19 @@ import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '
|
|
|
6
6
|
import { ensureGit } from '../core/git-base.js';
|
|
7
7
|
import { ensureMach, ensurePython } from '../core/mach.js';
|
|
8
8
|
import { countPatches } from '../core/patch-apply.js';
|
|
9
|
-
import {
|
|
9
|
+
import { validatePatchIntegrity } from '../core/patch-manifest.js';
|
|
10
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
10
11
|
import { ExitCode } from '../errors/codes.js';
|
|
11
12
|
import { toError } from '../utils/errors.js';
|
|
12
13
|
import { pathExists } from '../utils/fs.js';
|
|
13
14
|
import { error, info, intro, outro, success, warn } from '../utils/logger.js';
|
|
14
|
-
import { addWaitLockOption
|
|
15
|
+
import { addWaitLockOption } from '../utils/options.js';
|
|
15
16
|
import { findExecutable } from '../utils/process.js';
|
|
16
17
|
import { failure, ok, resolveDoctorSeverity, warning } from './doctor-check-core.js';
|
|
17
18
|
import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
|
|
18
19
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
19
20
|
import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
|
|
21
|
+
import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
|
|
20
22
|
import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
|
|
21
23
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
22
24
|
import { collectPatchQueueHealth } from './verify.js';
|
|
@@ -274,63 +276,7 @@ const DOCTOR_CHECKS = [
|
|
|
274
276
|
return ok('Patches found', `${patchCount} patch${patchCount === 1 ? '' : 'es'} found`);
|
|
275
277
|
},
|
|
276
278
|
},
|
|
277
|
-
|
|
278
|
-
name: 'Patch manifest consistency',
|
|
279
|
-
dependsOn: ['fireforge.json is valid'],
|
|
280
|
-
run: async (ctx) => {
|
|
281
|
-
if (!(await pathExists(ctx.paths.patches))) {
|
|
282
|
-
return [];
|
|
283
|
-
}
|
|
284
|
-
const manifestConsistencyIssues = await validatePatchesManifestConsistency(ctx.paths.patches);
|
|
285
|
-
if (manifestConsistencyIssues.length === 0) {
|
|
286
|
-
return ok('Patch manifest consistency');
|
|
287
|
-
}
|
|
288
|
-
if (!ctx.options.repairPatchesManifest) {
|
|
289
|
-
return failure('Patch manifest consistency', manifestConsistencyIssues.map((issue) => issue.message).join(' '), 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from patch files.');
|
|
290
|
-
}
|
|
291
|
-
// Repair stamps sourceEsrVersion into every recovered entry. If the
|
|
292
|
-
// earlier "fireforge.json is valid" check failed, ctx.config is
|
|
293
|
-
// undefined and we must refuse rather than fabricate a fallback —
|
|
294
|
-
// persisting 'unknown' into manifest metadata is hard to reverse
|
|
295
|
-
// and would mislead every later command that reads it.
|
|
296
|
-
if (!ctx.config) {
|
|
297
|
-
return failure('Patch manifest consistency', 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest".');
|
|
298
|
-
}
|
|
299
|
-
try {
|
|
300
|
-
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version, {
|
|
301
|
-
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
302
|
-
command: 'doctor --repair-patches-manifest',
|
|
303
|
-
});
|
|
304
|
-
// The repair path must not silently overwrite human-written
|
|
305
|
-
// descriptions on recovered entries, which leaves the queue less
|
|
306
|
-
// trustworthy as an audit trail. The rebuilder returns the list of
|
|
307
|
-
// filenames whose metadata was entirely invented, named explicitly
|
|
308
|
-
// here so the operator knows which patches to review. Entries that
|
|
309
|
-
// WERE preserved (only `filesAffected` / ordering drifted) are not
|
|
310
|
-
// flagged.
|
|
311
|
-
if (repaired.recoveredFilenames.length > 0) {
|
|
312
|
-
for (const filename of repaired.recoveredFilenames) {
|
|
313
|
-
// Telling the operator to hand-edit patches.json contradicts the
|
|
314
|
-
// README and downstream docs, which treat the manifest as
|
|
315
|
-
// FireForge-owned. Point at the existing `re-export` / `export`
|
|
316
|
-
// workflow so the fix stays inside the tool: re-exporting the
|
|
317
|
-
// same files with an explicit `--description` overwrites the
|
|
318
|
-
// recovered entry with operator-supplied metadata and supersedes
|
|
319
|
-
// the mtime-based createdAt stamp.
|
|
320
|
-
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
|
|
321
|
-
'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
|
|
322
|
-
'(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
|
|
323
|
-
'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
|
|
324
|
-
'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
},
|
|
279
|
+
PATCH_MANIFEST_CONSISTENCY_CHECK,
|
|
334
280
|
{
|
|
335
281
|
name: 'Patch integrity',
|
|
336
282
|
skipIf: (ctx) => !ctx.engineExists,
|
|
@@ -382,7 +328,7 @@ export const DOCTOR_CHECK_ORDER = DOCTOR_CHECKS.map((check) => check.name);
|
|
|
382
328
|
* @param checks - The check results to display
|
|
383
329
|
* @returns The exit code reflecting the overall result
|
|
384
330
|
*/
|
|
385
|
-
export function reportDoctorResults(checks) {
|
|
331
|
+
export function reportDoctorResults(checks, mutations = []) {
|
|
386
332
|
info('');
|
|
387
333
|
let passedCount = 0;
|
|
388
334
|
let warningCount = 0;
|
|
@@ -409,6 +355,16 @@ export function reportDoctorResults(checks) {
|
|
|
409
355
|
}
|
|
410
356
|
}
|
|
411
357
|
info('');
|
|
358
|
+
// Printed in every branch, including the failing one: a repair that wrote
|
|
359
|
+
// and then met an unrelated failing check must not be reported as a run
|
|
360
|
+
// where nothing happened.
|
|
361
|
+
if (mutations.length > 0) {
|
|
362
|
+
warn('Repairs applied this run:');
|
|
363
|
+
for (const mutation of mutations) {
|
|
364
|
+
warn(` ${mutation}`);
|
|
365
|
+
}
|
|
366
|
+
info('');
|
|
367
|
+
}
|
|
412
368
|
if (failedCount === 0 && warningCount === 0) {
|
|
413
369
|
outro(`All ${passedCount} checks passed!`);
|
|
414
370
|
}
|
|
@@ -421,12 +377,38 @@ export function reportDoctorResults(checks) {
|
|
|
421
377
|
}
|
|
422
378
|
return ExitCode.SUCCESS;
|
|
423
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Rejects repair-flag combinations that cannot mean what they say.
|
|
382
|
+
*
|
|
383
|
+
* The two manifest repairs are different operations on the same file, and
|
|
384
|
+
* `--dry-run` with nothing to project is a flag that silently does nothing —
|
|
385
|
+
* the shape most likely to be mistaken for a preview that reported "no
|
|
386
|
+
* changes".
|
|
387
|
+
*/
|
|
388
|
+
function assertRepairOptions(options) {
|
|
389
|
+
if (options.repairPatchesManifest === true && options.repairFilesAffected === true) {
|
|
390
|
+
throw new InvalidArgumentError('--repair-patches-manifest and --repair-files-affected are mutually exclusive. ' +
|
|
391
|
+
'Use --repair-files-affected for filesAffected drift; use --repair-patches-manifest ' +
|
|
392
|
+
'when rows are missing, untracked or duplicated.', '--repair-files-affected');
|
|
393
|
+
}
|
|
394
|
+
if (options.allowMetadataLoss === true && options.repairPatchesManifest !== true) {
|
|
395
|
+
throw new InvalidArgumentError('--allow-metadata-loss only applies to --repair-patches-manifest.', '--allow-metadata-loss');
|
|
396
|
+
}
|
|
397
|
+
if (options.dryRun === true &&
|
|
398
|
+
options.repairPatchesManifest !== true &&
|
|
399
|
+
options.repairFilesAffected !== true) {
|
|
400
|
+
throw new InvalidArgumentError('--dry-run needs a manifest repair to project. Pass it with ' +
|
|
401
|
+
'--repair-patches-manifest or --repair-files-affected. ' +
|
|
402
|
+
'(--repair-furnace has no dry-run: it reconciles engine state, not a manifest.)', '--dry-run');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
424
405
|
/**
|
|
425
406
|
* Runs the doctor command to diagnose issues.
|
|
426
407
|
* @param projectRoot - Root directory of the project
|
|
427
408
|
*/
|
|
428
409
|
export async function doctorCommand(projectRoot, options = {}) {
|
|
429
410
|
intro('FireForge Doctor');
|
|
411
|
+
assertRepairOptions(options);
|
|
430
412
|
const paths = getProjectPaths(projectRoot);
|
|
431
413
|
const state = await loadState(projectRoot);
|
|
432
414
|
const engineExists = await pathExists(paths.engine);
|
|
@@ -440,12 +422,13 @@ export async function doctorCommand(projectRoot, options = {}) {
|
|
|
440
422
|
config: undefined,
|
|
441
423
|
furnaceConfigExists: furnaceConfigExistsFlag,
|
|
442
424
|
furnaceConfig: undefined,
|
|
425
|
+
mutations: [],
|
|
443
426
|
};
|
|
444
427
|
const checks = [];
|
|
445
428
|
for (const definition of DOCTOR_CHECKS) {
|
|
446
429
|
checks.push(...(await executeCheck(definition, ctx)));
|
|
447
430
|
}
|
|
448
|
-
const exitCode = reportDoctorResults(checks);
|
|
431
|
+
const exitCode = reportDoctorResults(checks, ctx.mutations);
|
|
449
432
|
return { checks, exitCode };
|
|
450
433
|
}
|
|
451
434
|
/** Registers the doctor command on the CLI program. */
|
|
@@ -453,7 +436,10 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
|
|
|
453
436
|
const command = program
|
|
454
437
|
.command('doctor')
|
|
455
438
|
.description('Diagnose project issues')
|
|
456
|
-
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
|
|
439
|
+
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results. Only filesAffected and order are recomputed; every other field on an existing entry is preserved')
|
|
440
|
+
.option('--repair-files-affected', 'Repair only the filesAffected lists that disagree with their patch body, leaving every other manifest field untouched')
|
|
441
|
+
.option('--allow-metadata-loss', 'With --repair-patches-manifest, rebuild even when patches.json cannot be parsed — every descriptive field on every entry is then reinvented')
|
|
442
|
+
.option('--dry-run', 'Show what a requested repair would change without writing anything')
|
|
457
443
|
.option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
|
|
458
444
|
.option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
|
|
459
445
|
.option('--post-rebase-audit', 'Check common registration surfaces after a Firefox source rebase');
|
|
@@ -5,7 +5,7 @@ import { applyAllComponents, computeComponentChecksums } from '../../core/furnac
|
|
|
5
5
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
6
6
|
import { getFurnacePaths } from '../../core/furnace-config.js';
|
|
7
7
|
import { isComponentSourceFile } from '../../core/furnace-constants.js';
|
|
8
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
8
|
+
import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
|
|
9
9
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
10
10
|
import { getPersistableAppliedEntry, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
|
|
11
11
|
import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
@@ -294,7 +294,7 @@ export async function furnaceApplyCommand(projectRoot, name, options = {}) {
|
|
|
294
294
|
await persistSingleComponentState(projectRoot, getPersistableAppliedEntry('Apply', name, applyResult.applied[0]), getFurnacePaths(projectRoot));
|
|
295
295
|
}
|
|
296
296
|
return applyResult;
|
|
297
|
-
}, { dryRun });
|
|
297
|
+
}, { dryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
298
298
|
if (applySpinner) {
|
|
299
299
|
applySpinner.stop('Components applied');
|
|
300
300
|
}
|
|
@@ -4,7 +4,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
|
|
|
4
4
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
5
5
|
import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
6
6
|
import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
|
|
7
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
7
|
+
import { runFurnaceMutation, waitLockMutationOptions, } from '../../core/furnace-operation.js';
|
|
8
8
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
9
9
|
import { getPersistableAppliedEntry, getStepFailureCount, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
|
|
10
10
|
import { hasBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
@@ -177,7 +177,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
|
|
|
177
177
|
operationContext: ctx,
|
|
178
178
|
});
|
|
179
179
|
return { kind: 'result', result: allResult };
|
|
180
|
-
}, { dryRun: isDryRun });
|
|
180
|
+
}, { dryRun: isDryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
181
181
|
if (applyOutcome.kind === 'stock') {
|
|
182
182
|
applySpinner.stop('Apply skipped');
|
|
183
183
|
warn(`"${name}" is a stock component. Stock components are not applied locally.`);
|
|
@@ -11,6 +11,7 @@ import { FurnaceError } from '../../errors/furnace.js';
|
|
|
11
11
|
import { toError } from '../../utils/errors.js';
|
|
12
12
|
import { pathExists, readText } from '../../utils/fs.js';
|
|
13
13
|
import { formatErrorText, formatSuccessText, info, intro, outro } from '../../utils/logger.js';
|
|
14
|
+
import { normalizePathSlashes } from '../../utils/paths.js';
|
|
14
15
|
/**
|
|
15
16
|
* Renders a multi-hunk unified diff between the two strings and returns a
|
|
16
17
|
* flat list of display-ready lines. Each line has already had its marker
|
|
@@ -64,7 +65,11 @@ async function diffOverride(name, projectRoot, config) {
|
|
|
64
65
|
if (!isComponentSourceFile(entry.name))
|
|
65
66
|
continue;
|
|
66
67
|
// git show takes a repo-relative path. paths.engine IS the repo root.
|
|
67
|
-
|
|
68
|
+
// The slice leaves the host's separators behind, and `git show <ref>:<path>`
|
|
69
|
+
// only understands forward slashes — on Windows the unnormalized form makes
|
|
70
|
+
// every baseline read miss, so every overridden file is reported as a new
|
|
71
|
+
// file and `furnace diff` shows nothing.
|
|
72
|
+
const enginePath = normalizePathSlashes(getOverrideEngineTargetPath(paths.engine, overrideConfig, entry.name, ftlDir).slice(paths.engine.length + 1));
|
|
68
73
|
const modifiedPath = join(overrideDir, entry.name);
|
|
69
74
|
const baselineDisplayPath = enginePath;
|
|
70
75
|
let originalContent;
|
|
@@ -35,13 +35,17 @@ function registerFurnaceApplyCommand(furnace, context) {
|
|
|
35
35
|
.option('-w, --watch', 'Watch component directories and re-apply on changes');
|
|
36
36
|
addWaitLockOption(apply).action(withErrorHandling(async (name, options) => {
|
|
37
37
|
const { waitLock, ...rest } = options ?? {};
|
|
38
|
-
|
|
38
|
+
// Resolved ONCE and used twice: the engine session lock below, and
|
|
39
|
+
// the furnace lock the mutation takes inside it. Resolving twice
|
|
40
|
+
// would be the same value today and a drift risk tomorrow.
|
|
41
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
42
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
39
43
|
const run = (projectRoot) => furnaceApplyCommand(projectRoot, name, parsed);
|
|
40
44
|
if (parsed.dryRun === true) {
|
|
41
45
|
await run(getProjectRoot());
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
|
-
await runEngineLockedFurnaceCommand(context, 'furnace apply', run,
|
|
48
|
+
await runEngineLockedFurnaceCommand(context, 'furnace apply', run, waitLockSeconds);
|
|
45
49
|
}));
|
|
46
50
|
}
|
|
47
51
|
function registerFurnaceDeployCommand(furnace, context) {
|
|
@@ -54,13 +58,14 @@ function registerFurnaceDeployCommand(furnace, context) {
|
|
|
54
58
|
.option('--skip-validate', 'Skip the validation step (apply only)');
|
|
55
59
|
addWaitLockOption(deploy).action(withErrorHandling(async (name, options) => {
|
|
56
60
|
const { waitLock, ...rest } = options ?? {};
|
|
57
|
-
const
|
|
61
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
62
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
58
63
|
const run = (projectRoot) => furnaceDeployCommand(projectRoot, name, parsed);
|
|
59
64
|
if (parsed.dryRun === true) {
|
|
60
65
|
await run(getProjectRoot());
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
|
-
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run,
|
|
68
|
+
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, waitLockSeconds);
|
|
64
69
|
}));
|
|
65
70
|
}
|
|
66
71
|
function registerFurnaceSyncCommand(furnace, context) {
|
|
@@ -72,13 +77,14 @@ function registerFurnaceSyncCommand(furnace, context) {
|
|
|
72
77
|
.addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']));
|
|
73
78
|
addWaitLockOption(sync).action(withErrorHandling(async (options) => {
|
|
74
79
|
const { waitLock, ...rest } = options;
|
|
75
|
-
const
|
|
80
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
81
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
76
82
|
const run = (projectRoot) => furnaceSyncCommand(projectRoot, parsed);
|
|
77
83
|
if (parsed.dryRun === true) {
|
|
78
84
|
await run(getProjectRoot());
|
|
79
85
|
return;
|
|
80
86
|
}
|
|
81
|
-
await runEngineLockedFurnaceCommand(context, 'furnace sync', run,
|
|
87
|
+
await runEngineLockedFurnaceCommand(context, 'furnace sync', run, waitLockSeconds);
|
|
82
88
|
}));
|
|
83
89
|
}
|
|
84
90
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, isAbsolute, join,
|
|
3
|
+
import { basename, dirname, isAbsolute, join, posix } from 'node:path';
|
|
4
4
|
import { text } from '@clack/prompts';
|
|
5
5
|
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
6
6
|
import { stdioIsInteractive } from '../../core/destructive.js';
|
|
@@ -61,7 +61,10 @@ async function validateFtlBasePath(value, engineDir) {
|
|
|
61
61
|
if (isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
62
62
|
throw new FurnaceError(`ftlBasePath "${value}" must be a relative path inside the engine checkout, not absolute.`);
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
// Normalize with the POSIX rules regardless of host: `normalize` on Windows
|
|
65
|
+
// emits backslashes, so a `../`-prefix test against the platform `normalize`
|
|
66
|
+
// silently passes every traversal attempt there.
|
|
67
|
+
const normalized = posix.normalize(value.replace(/\\/g, '/'));
|
|
65
68
|
if (normalized === '..' || normalized.startsWith('../')) {
|
|
66
69
|
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
67
70
|
}
|
|
@@ -23,6 +23,7 @@ import { isGitRepository } from '../../core/git.js';
|
|
|
23
23
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
24
24
|
import { pathExists, removeDir, removeFile } from '../../utils/fs.js';
|
|
25
25
|
import { info } from '../../utils/logger.js';
|
|
26
|
+
import { normalizePathSlashes } from '../../utils/paths.js';
|
|
26
27
|
import { removeDeployedCustomFiles } from './remove-state.js';
|
|
27
28
|
/**
|
|
28
29
|
* Restores every override-deployed file in `engine/` to its pristine HEAD
|
|
@@ -153,7 +154,10 @@ export async function performCustomRemovalMutations(args) {
|
|
|
153
154
|
if (await pathExists(ftlPath)) {
|
|
154
155
|
await snapshotFile(journal, ftlPath);
|
|
155
156
|
await removeFile(ftlPath);
|
|
156
|
-
|
|
157
|
+
// `ftlRel` is a native join, so on Windows an interpolated
|
|
158
|
+
// `engine/${ftlRel}` renders as `engine/toolkit\locales\...` — one
|
|
159
|
+
// message with both separators. Print the engine-relative form.
|
|
160
|
+
info(`Deleted localized file engine/${normalizePathSlashes(ftlRel)}`);
|
|
157
161
|
}
|
|
158
162
|
// Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
|
|
159
163
|
// wrote during deploy — otherwise the engine is left with a
|
|
@@ -6,7 +6,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
|
|
|
6
6
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
7
7
|
import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
8
8
|
import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
|
|
9
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
9
|
+
import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
|
|
10
10
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
11
11
|
import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
12
12
|
import { containsMergeConflictMarkers } from '../../core/furnace-validate-structure.js';
|
|
@@ -127,7 +127,7 @@ export async function furnaceSyncCommand(projectRoot, options = {}) {
|
|
|
127
127
|
if (!options.dryRun) {
|
|
128
128
|
info('\nApplying all components to engine...');
|
|
129
129
|
const applySpinner = spinner('Applying components...');
|
|
130
|
-
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false });
|
|
130
|
+
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
131
131
|
applySpinner.stop('Components applied');
|
|
132
132
|
logApplyResult(result, false);
|
|
133
133
|
const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
|
|
@@ -8,7 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
|
|
|
8
8
|
import { getHead } from '../core/git.js';
|
|
9
9
|
import { getDirtyFiles } from '../core/git-status.js';
|
|
10
10
|
import { applyPatchesWithContinue, countPatches, createPatchedContentContext, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
|
|
11
|
-
import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
11
|
+
import { checkVersionCompatibility, loadPatchesManifest, recommendManifestRepair, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
12
12
|
import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
|
|
13
13
|
import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
|
|
14
14
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -239,7 +239,11 @@ async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, unt
|
|
|
239
239
|
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
240
240
|
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
241
241
|
` ${issueSummary}\n\n` +
|
|
242
|
-
|
|
242
|
+
// Naming the whole-manifest rebuild for drift that is only in
|
|
243
|
+
// `filesAffected` puts the operator one keystroke from rewriting every
|
|
244
|
+
// row to correct one derived list. The narrow repair is the remedy the
|
|
245
|
+
// failure actually calls for.
|
|
246
|
+
recommendManifestRepair(scopedManifestIssues));
|
|
243
247
|
}
|
|
244
248
|
}
|
|
245
249
|
/**
|
|
@@ -4,6 +4,7 @@ import { getProjectPaths } from '../core/config.js';
|
|
|
4
4
|
import { appendHistory, confirmDestructive, } from '../core/destructive.js';
|
|
5
5
|
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
7
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
8
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
10
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
|
|
@@ -88,7 +89,7 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
88
89
|
return updates;
|
|
89
90
|
}
|
|
90
91
|
async function confirmFilesModeProjection(args) {
|
|
91
|
-
const { target, retained, removed, added, actualProjectedFiles,
|
|
92
|
+
const { target, retained, removed, added, actualProjectedFiles, deletedFiles, options, conflicts, } = args;
|
|
92
93
|
const isDryRun = options.dryRun === true;
|
|
93
94
|
const summary = [
|
|
94
95
|
`re-export ${target.filename} with --files scope`,
|
|
@@ -102,8 +103,8 @@ async function confirmFilesModeProjection(args) {
|
|
|
102
103
|
if (added.length > 0) {
|
|
103
104
|
summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
|
|
104
105
|
}
|
|
105
|
-
if (
|
|
106
|
-
summary.push(`
|
|
106
|
+
if (deletedFiles.length > 0) {
|
|
107
|
+
summary.push(`deleted on disk (captured as deletions the patch will replay): ${deletedFiles.join(', ')}`);
|
|
107
108
|
}
|
|
108
109
|
if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
|
|
109
110
|
warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
|
|
@@ -118,12 +119,60 @@ async function confirmFilesModeProjection(args) {
|
|
|
118
119
|
operation: 're-export-files',
|
|
119
120
|
title: `Re-export ${target.filename} with --files`,
|
|
120
121
|
summary,
|
|
121
|
-
|
|
122
|
+
// A captured deletion still needs the confirmation: the patch will now
|
|
123
|
+
// REMOVE those files wherever it is applied, which is the same class of
|
|
124
|
+
// consequence as shrinking the scope.
|
|
125
|
+
yes: removed.length === 0 && deletedFiles.length === 0 ? true : options.yes === true,
|
|
122
126
|
dryRun: isDryRun,
|
|
123
127
|
unsafeOverride: options.forceUnsafe === true,
|
|
124
128
|
conflicts,
|
|
125
129
|
});
|
|
126
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Splits the requested paths into what can be diffed and what is a deletion.
|
|
133
|
+
*
|
|
134
|
+
* A path absent from disk is one of two very different things, and
|
|
135
|
+
* collapsing them is what made a DELETION impossible to capture.
|
|
136
|
+
*
|
|
137
|
+
* Tracked in HEAD: a deletion. `git diff HEAD -- <path>` renders it as a
|
|
138
|
+
* proper `deleted file mode` section, so it STAYS in the diff scope. (The
|
|
139
|
+
* comment this replaces claimed the diff "would fail" on any missing path —
|
|
140
|
+
* true only for the never-tracked case.) Dropping these produced the
|
|
141
|
+
* dangerous shape: a re-export that reported success while writing a patch
|
|
142
|
+
* whose own file list claimed a path it did not restore, so a fresh clone
|
|
143
|
+
* would apply it and resurrect exactly what the change meant to remove.
|
|
144
|
+
*
|
|
145
|
+
* Never tracked in HEAD: nothing on disk and nothing to diff against, so
|
|
146
|
+
* there is no hunk to be had. Refused rather than warned about, because the
|
|
147
|
+
* patch cannot express it either way.
|
|
148
|
+
*
|
|
149
|
+
* @param requested - The `--files` scope
|
|
150
|
+
* @param engineDir - Absolute path to the engine checkout
|
|
151
|
+
* @param patchFilename - Target patch, for the refusal message
|
|
152
|
+
* @returns The diffable scope and the subset captured as deletions
|
|
153
|
+
*/
|
|
154
|
+
async function classifyRequestedPaths(requested, engineDir, patchFilename) {
|
|
155
|
+
const absentFiles = [];
|
|
156
|
+
for (const file of requested) {
|
|
157
|
+
if (!(await pathExists(join(engineDir, file)))) {
|
|
158
|
+
absentFiles.push(file);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const trackedAbsent = await listTrackedInHead(engineDir, absentFiles);
|
|
162
|
+
const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
|
|
163
|
+
const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
|
|
164
|
+
if (untrackedAbsent.length > 0) {
|
|
165
|
+
throw new InvalidArgumentError(`Refusing to re-export ${patchFilename} with --files because ${untrackedAbsent.length} requested path${untrackedAbsent.length === 1 ? ' is' : 's are'} ` +
|
|
166
|
+
`neither on disk nor tracked in engine HEAD (${untrackedAbsent.join(', ')}). ` +
|
|
167
|
+
'There is no content to diff and no deletion to record, so the patch could not ' +
|
|
168
|
+
'honour the file list it would claim. Remove those paths from --files.', '--files');
|
|
169
|
+
}
|
|
170
|
+
for (const file of deletedFiles) {
|
|
171
|
+
info(`${patchFilename}: capturing deletion of ${file}`);
|
|
172
|
+
}
|
|
173
|
+
const untrackedSet = new Set(untrackedAbsent);
|
|
174
|
+
return { diffableFiles: requested.filter((f) => !untrackedSet.has(f)), deletedFiles };
|
|
175
|
+
}
|
|
127
176
|
/**
|
|
128
177
|
* Handles `re-export --files` end-to-end: computes the projected diff, runs
|
|
129
178
|
* the per-patch and cross-patch lint against a context in which the target
|
|
@@ -152,22 +201,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
152
201
|
const removed = target.filesAffected.filter((f) => !requested.includes(f));
|
|
153
202
|
const added = requested.filter((f) => !target.filesAffected.includes(f));
|
|
154
203
|
const retained = target.filesAffected.filter((f) => requested.includes(f));
|
|
155
|
-
|
|
156
|
-
// them in the new diff because getDiffForFilesAgainstHead would fail.
|
|
157
|
-
// Missing files are still dropped from the manifest so the resulting
|
|
158
|
-
// filesAffected reflects reality.
|
|
159
|
-
const missingFiles = [];
|
|
160
|
-
for (const file of requested) {
|
|
161
|
-
const filePath = join(paths.engine, file);
|
|
162
|
-
if (!(await pathExists(filePath))) {
|
|
163
|
-
missingFiles.push(file);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const missingSet = new Set(missingFiles);
|
|
167
|
-
const diffableFiles = requested.filter((f) => !missingSet.has(f));
|
|
168
|
-
for (const file of missingFiles) {
|
|
169
|
-
warn(`${target.filename}: requested file is missing on disk and will be dropped: ${file}`);
|
|
170
|
-
}
|
|
204
|
+
const { diffableFiles, deletedFiles } = await classifyRequestedPaths(requested, paths.engine, target.filename);
|
|
171
205
|
// Compute the projected diff up front. This is the same diff the real
|
|
172
206
|
// write would produce, so we get an exact preview through the lint
|
|
173
207
|
// gate and avoid computing it twice.
|
|
@@ -179,7 +213,13 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
179
213
|
}
|
|
180
214
|
const actualProjectedFiles = extractAffectedFiles(projectedDiff);
|
|
181
215
|
const actualProjectedSet = new Set(actualProjectedFiles);
|
|
182
|
-
|
|
216
|
+
// Domain is the FULL requested list, not the diffable subset. When the
|
|
217
|
+
// subset was used, any path filtered out upstream was invisible to this
|
|
218
|
+
// check by construction — which is exactly how a dropped deletion slipped
|
|
219
|
+
// through while the run reported success. A patch that does not do what
|
|
220
|
+
// its own file list claims is the one property a manifest must never get
|
|
221
|
+
// wrong, so an explicitly named path that produced no hunk is a refusal.
|
|
222
|
+
const noDiffFiles = requested.filter((file) => !actualProjectedSet.has(file));
|
|
183
223
|
if (noDiffFiles.length > 0) {
|
|
184
224
|
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because ${noDiffFiles.length} requested path${noDiffFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffFiles.join(', ')}). ` +
|
|
185
225
|
'Keeping them in filesAffected would desync patches.json from the patch body. ' +
|
|
@@ -216,7 +256,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
216
256
|
removed,
|
|
217
257
|
added,
|
|
218
258
|
actualProjectedFiles,
|
|
219
|
-
|
|
259
|
+
deletedFiles,
|
|
220
260
|
options,
|
|
221
261
|
conflicts,
|
|
222
262
|
});
|
|
@@ -243,7 +283,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
243
283
|
filename: target.filename,
|
|
244
284
|
files: actualProjectedFiles,
|
|
245
285
|
previousFiles: target.filesAffected,
|
|
246
|
-
|
|
286
|
+
deletionsCaptured: deletedFiles,
|
|
247
287
|
},
|
|
248
288
|
...(options.yes === true ? { yes: true } : {}),
|
|
249
289
|
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
4
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
5
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
5
6
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
6
7
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
7
8
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
@@ -85,7 +86,10 @@ async function scanPatchFilesTargeted(args) {
|
|
|
85
86
|
const exists = await mapWithConcurrency(scanFiles, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
|
|
86
87
|
for (const [index, file] of scanFiles.entries()) {
|
|
87
88
|
if (exists[index] !== true) {
|
|
88
|
-
throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}
|
|
89
|
+
throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}. ` +
|
|
90
|
+
'--scan-file brings a path INTO patch ownership, which needs content to diff. ' +
|
|
91
|
+
'If the path was deleted and the patch already owns it, a plain re-export now ' +
|
|
92
|
+
'captures the deletion — no scan flag is needed.', '--scan-file');
|
|
89
93
|
}
|
|
90
94
|
if (!currentSet.has(file))
|
|
91
95
|
added.push(file);
|
|
@@ -93,9 +97,21 @@ async function scanPatchFilesTargeted(args) {
|
|
|
93
97
|
const removed = await findRemovedFiles(currentFilesAffected, engineDir);
|
|
94
98
|
return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
|
|
95
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Paths that have genuinely left the patch's ownership: absent from disk AND
|
|
102
|
+
* untracked in engine HEAD.
|
|
103
|
+
*
|
|
104
|
+
* An absent path that IS tracked in HEAD is a DELETION, not a de-ownership.
|
|
105
|
+
* The diff carries it as a `deleted file mode` section, so it must stay in
|
|
106
|
+
* `filesAffected` — pruning it desynchronises the manifest from the patch
|
|
107
|
+
* body in the direction that hurts: the body says "delete this file", the
|
|
108
|
+
* file list says the patch has nothing to do with it.
|
|
109
|
+
*/
|
|
96
110
|
async function findRemovedFiles(files, engineDir) {
|
|
97
111
|
const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
|
|
98
|
-
|
|
112
|
+
const absent = files.filter((_, index) => exists[index] !== true);
|
|
113
|
+
const trackedAbsent = await listTrackedInHead(engineDir, absent);
|
|
114
|
+
return absent.filter((file) => !trackedAbsent.has(file)).sort();
|
|
99
115
|
}
|
|
100
116
|
function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
|
|
101
117
|
for (const f of added)
|
|
@@ -9,6 +9,7 @@ import { getProjectPaths } from '../core/config.js';
|
|
|
9
9
|
import { stdioIsInteractive } from '../core/destructive.js';
|
|
10
10
|
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
11
11
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
12
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
12
13
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
13
14
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
14
15
|
import { isGitIndexLockConflict } from '../errors/git.js';
|
|
@@ -111,17 +112,28 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
111
112
|
// --allow-stale-furnace) before diffing. Runs in dry-run too so the failure
|
|
112
113
|
// surfaces early.
|
|
113
114
|
await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
|
|
114
|
-
const
|
|
115
|
-
|
|
115
|
+
const absentFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
116
|
+
// A path absent from disk but tracked in engine HEAD is a DELETION, and
|
|
117
|
+
// `git diff HEAD` renders it as a real `deleted file mode` section. Only
|
|
118
|
+
// a never-tracked path has nothing to diff. Skipping both alike meant a
|
|
119
|
+
// retired file quietly left the patch body while staying in its file
|
|
120
|
+
// list — the patch then claimed a file it could not restore.
|
|
121
|
+
const trackedAbsent = await listTrackedInHead(paths.engine, absentFiles);
|
|
122
|
+
const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
|
|
123
|
+
const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
|
|
124
|
+
if (untrackedAbsent.length === currentFilesAffected.length) {
|
|
116
125
|
warn(`Skipped ${patch.filename}: all affected files missing`);
|
|
117
|
-
warn(`Missing files: ${
|
|
126
|
+
warn(`Missing files: ${untrackedAbsent.join(', ')}`);
|
|
118
127
|
return false;
|
|
119
128
|
}
|
|
120
|
-
if (
|
|
121
|
-
warn(`${patch.filename}: missing files will be skipped: ${
|
|
129
|
+
if (untrackedAbsent.length > 0) {
|
|
130
|
+
warn(`${patch.filename}: missing files will be skipped: ${untrackedAbsent.join(', ')}`);
|
|
122
131
|
}
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
if (deletedFiles.length > 0) {
|
|
133
|
+
info(`${patch.filename}: capturing deletion of ${deletedFiles.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
const untrackedSet = new Set(untrackedAbsent);
|
|
136
|
+
const existingFiles = currentFilesAffected.filter((f) => !untrackedSet.has(f));
|
|
125
137
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
126
138
|
assertScanFileAdditionsHaveDiffHunks({
|
|
127
139
|
diffContent,
|
|
@@ -29,7 +29,10 @@ const TEST_HELP_TEXT = [
|
|
|
29
29
|
'--doctor) ends with exactly one raw stdout line',
|
|
30
30
|
' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
|
|
31
31
|
' no-tests|preflight|inconclusive|lock-timeout] [checks=<n>]',
|
|
32
|
-
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)]',
|
|
32
|
+
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [log=<path>]',
|
|
33
|
+
"log=<path> names this run's complete output, written to",
|
|
34
|
+
'.fireforge/logs/ as the run streams, so a piped or truncated run',
|
|
35
|
+
'still leaves a re-readable artifact.',
|
|
33
36
|
'The status follows the harness classifier, not the raw exit code:',
|
|
34
37
|
'a crash-classified run says FAIL reason=crash even at exit 0, and',
|
|
35
38
|
'a green-summary-override pass says PASS despite a non-zero mach',
|
|
@@ -55,6 +58,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
55
58
|
.command('test [paths...]')
|
|
56
59
|
.description('Run tests via mach test')
|
|
57
60
|
.option('--headless', 'Run tests in headless mode')
|
|
61
|
+
.option('--full-output', "Pass mozbuild's full verbosity through for the TEST phase. mozbuild quiets terminal output to warnings and errors when it detects a coding agent, which removes TEST_START and console INFO — the lines a hang or stall diagnosis needs. This unsets that detection for test dispatches only; the build phase stays quiet.")
|
|
58
62
|
.option('--build', 'Run incremental UI build before testing')
|
|
59
63
|
.option('--build-only', 'Run the scoped pre-test build (packaging exactly [paths...], mixed harnesses allowed) and exit without dispatching tests — one build covers both harness halves, then run each half without --build')
|
|
60
64
|
.option('--extend-coverage', 'With --build/--build-only and explicit paths, union those paths into the recorded test-packaging coverage instead of replacing it, so an earlier scoped build stays covered. Refused when the build anchor moved (engine HEAD, mozconfig, or a previously fingerprinted packageable file changed).')
|