@hominis/fireforge 0.15.5 → 0.15.7

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +70 -5
  3. package/dist/src/commands/build.js +60 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
  6. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
  7. package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
  8. package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
  9. package/dist/src/commands/furnace/chrome-doc.js +37 -4
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +31 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +95 -0
  12. package/dist/src/commands/furnace/create-templates.js +14 -0
  13. package/dist/src/commands/furnace/create.js +28 -24
  14. package/dist/src/commands/furnace/index.js +3 -1
  15. package/dist/src/commands/lint.d.ts +17 -2
  16. package/dist/src/commands/lint.js +25 -2
  17. package/dist/src/commands/register.d.ts +1 -1
  18. package/dist/src/commands/register.js +30 -7
  19. package/dist/src/commands/test.js +16 -1
  20. package/dist/src/core/build-audit-platform.d.ts +3 -1
  21. package/dist/src/core/build-audit-platform.js +87 -20
  22. package/dist/src/core/build-audit-registration.d.ts +80 -0
  23. package/dist/src/core/build-audit-registration.js +187 -0
  24. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  25. package/dist/src/core/build-audit-transforms.js +94 -0
  26. package/dist/src/core/build-audit.js +210 -3
  27. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  28. package/dist/src/core/furnace-validate-registration.js +66 -6
  29. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  30. package/dist/src/core/mach-build-artifacts.js +104 -3
  31. package/dist/src/core/mach.d.ts +1 -1
  32. package/dist/src/core/mach.js +1 -1
  33. package/dist/src/core/test-stale-check.d.ts +42 -0
  34. package/dist/src/core/test-stale-check.js +114 -0
  35. package/dist/src/types/commands/options.d.ts +16 -0
  36. package/package.json +1 -1
@@ -39,7 +39,9 @@ import { toError } from '../utils/errors.js';
39
39
  import { pathExists } from '../utils/fs.js';
40
40
  import { info, verbose, warn } from '../utils/logger.js';
41
41
  import { detectPlatformGate } from './build-audit-platform.js';
42
- import { isTestPath, resolveBestArtifact } from './build-audit-resolve.js';
42
+ import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
43
+ import { countTrailingSegmentMatches, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
44
+ import { resolveArtifactByKnownTransform } from './build-audit-transforms.js';
43
45
  import { hasChanges, isMissingHeadError } from './git.js';
44
46
  import { git } from './git-base.js';
45
47
  import { getUntrackedFiles } from './git-status.js';
@@ -190,6 +192,27 @@ async function resolveTestsRoot(engineDir) {
190
192
  }
191
193
  return undefined;
192
194
  }
195
+ /**
196
+ * Marker file the `package-tests` make target writes after copying the
197
+ * full test-source tree under `_tests/`. Its presence is the most reliable
198
+ * signal that test packaging has actually run for the current obj-dir —
199
+ * plain `mach build` populates a partial `_tests/` subtree and then stops,
200
+ * so registered tests are absent even when registration is correct.
201
+ */
202
+ const PACKAGED_TESTS_MARKER = 'all-tests.json';
203
+ /**
204
+ * Returns true when the full test-package step has actually run for the
205
+ * active obj-dir. Without this marker the `_tests/` walk produces false
206
+ * positives for every correctly-registered mochitest / xpcshell source
207
+ * on the common "built but tests not packaged" path.
208
+ *
209
+ * @param testsRoot Absolute path to the obj-*`/_tests/` tree, or undefined.
210
+ */
211
+ async function hasPackagedTestsMarker(testsRoot) {
212
+ if (!testsRoot)
213
+ return false;
214
+ return pathExists(join(testsRoot, PACKAGED_TESTS_MARKER));
215
+ }
193
216
  /**
194
217
  * Resolves the search roots an individual source path should be looked
195
218
  * up under. Test-shaped paths get `_tests/`; everything else gets `dist/`.
@@ -200,6 +223,68 @@ function searchRootsFor(source, distRoot, testsRoot) {
200
223
  }
201
224
  return [distRoot];
202
225
  }
226
+ /**
227
+ * Minimum trailing-segment overlap required for a same-basename dist/
228
+ * candidate to count as "the packaged artifact" of a source. The
229
+ * basename always trail-matches (count 1), so a threshold of 2 requires
230
+ * the immediate parent directory to also agree. Candidates that only
231
+ * share the basename are classified as missing — warning the operator
232
+ * to check registration — rather than emitting a misleading stale
233
+ * comparison against an unrelated file of the same name.
234
+ *
235
+ * Cross-tree re-rooting cases (e.g. `branding/<name>/content/foo.css`
236
+ * landing at `chrome/<area>/content/branding/foo.css`) bypass this
237
+ * floor because `scoreCandidate` awards a non-generic-segment bonus
238
+ * that lifts the confidence regardless of trailing overlap; those are
239
+ * detected below in `isConfidentMatch`.
240
+ */
241
+ const MIN_TRAILING_SEGMENT_OVERLAP = 2;
242
+ /**
243
+ * Returns true when the chosen artifact is structurally related to the
244
+ * source path — either its immediate parent directory trail-matches, or
245
+ * a non-generic intermediate source segment appears in the candidate
246
+ * path (the branding-re-root signal already used by the scorer).
247
+ *
248
+ * Used to avoid emitting `stale` warnings that point at an unrelated
249
+ * same-basename file picked up by the basename walker — a class of
250
+ * warning that is worse than `missing` because it reads as "your build
251
+ * dropped this file" when in fact the match is spurious.
252
+ */
253
+ function isConfidentMatch(source, candidate) {
254
+ if (countTrailingSegmentMatches(source, candidate) >= MIN_TRAILING_SEGMENT_OVERLAP) {
255
+ return true;
256
+ }
257
+ const sourceSegs = source.split('/').filter(Boolean);
258
+ const candSegs = candidate.split('/').filter(Boolean);
259
+ const generic = new Set([
260
+ 'content',
261
+ 'chrome',
262
+ 'bin',
263
+ 'browser',
264
+ 'toolkit',
265
+ 'modules',
266
+ 'base',
267
+ 'app',
268
+ 'profile',
269
+ 'shared',
270
+ 'themes',
271
+ 'test',
272
+ 'tests',
273
+ 'unit',
274
+ 'common',
275
+ 'xpcshell',
276
+ 'mochitest',
277
+ ]);
278
+ // Skip the basename itself (which trail-matches by definition).
279
+ for (let i = 0; i < sourceSegs.length - 1; i += 1) {
280
+ const seg = sourceSegs[i];
281
+ if (!seg || seg.length <= 2 || generic.has(seg))
282
+ continue;
283
+ if (candSegs.includes(seg))
284
+ return true;
285
+ }
286
+ return false;
287
+ }
203
288
  /**
204
289
  * Audits one engine source path and returns its entry. Pure orchestration
205
290
  * helper kept separate so `auditBuildArtifacts` stays under the per-function
@@ -211,7 +296,16 @@ async function auditSinglePath(source, ctx) {
211
296
  }
212
297
  const gate = await detectPlatformGate(ctx.engineDir, source);
213
298
  if (gate.gatedOff) {
214
- verbose(`Audit: skipping engine/${source} — gated off by moz.build "${gate.gateExpression ?? '?'}".`);
299
+ verbose(`Audit: skipping engine/${source} — gated off by "${gate.gateExpression ?? '?'}".`);
300
+ return { source, artifact: undefined, status: 'skipped' };
301
+ }
302
+ // Tests only end up under `_tests/` after `mach package-tests` (or a
303
+ // test-run that invokes the target) has executed. A plain `mach build`
304
+ // populates a partial subtree and stops, so every correctly-registered
305
+ // mochitest / xpcshell source appears "missing" on that common path.
306
+ // Skip audit for test sources when no packaged-tests marker is present.
307
+ if (isTestPath(source) && !ctx.testsPackaged) {
308
+ verbose(`Audit: skipping engine/${source} — _tests/${PACKAGED_TESTS_MARKER} not present; full test packaging has not run for this build.`);
215
309
  return { source, artifact: undefined, status: 'skipped' };
216
310
  }
217
311
  const sourcePath = join(ctx.engineDir, source);
@@ -225,6 +319,40 @@ async function auditSinglePath(source, ctx) {
225
319
  return { source, artifact: undefined, status: 'skipped' };
226
320
  }
227
321
  const roots = searchRootsFor(source, ctx.distRoot, ctx.testsRoot);
322
+ // Registration-aware resolution first: a `jar.mn` entry whose `(source)`
323
+ // references this file is authoritative over the basename-similarity
324
+ // heuristic. The motivating case is a fork that adds `content/foo.js`
325
+ // in `browser/base/jar.mn` while an unrelated patch registers a pref
326
+ // file of the same basename elsewhere — the heuristic cannot
327
+ // distinguish them, so the audit falsely reports "missing" against the
328
+ // correctly-packaged file.
329
+ const registered = await resolveArtifactByRegistration(ctx.engineDir, source, roots);
330
+ if (registered) {
331
+ return evaluateArtifactMtime(source, registered.artifact, sourceMtime, { registered: true });
332
+ }
333
+ // Registration exists but no matching dist entry: explicit miss,
334
+ // distinct from an unregistered source. This surfaces in the warning so
335
+ // the operator knows the jar.mn entry is intact and packaging is the
336
+ // bug, not the source registration.
337
+ const registrationMissed = await reportRegistrationMiss(ctx.engineDir, source, roots);
338
+ if (registrationMissed)
339
+ return registrationMissed;
340
+ // Known-transform resolution comes before the similarity heuristic so a
341
+ // source under `browser/base/content/` (or another prefix whose chrome
342
+ // target is stable across forks) is matched against its expected
343
+ // `chrome/...` suffix rather than whichever same-basename candidate the
344
+ // directory walk happened to hit first. Motivating case: a source at
345
+ // `engine/browser/base/content/foo.js` whose correctly-packaged artifact
346
+ // lives at `chrome/browser/content/browser/foo.js` but which an unrelated
347
+ // patch also placed under `browser/defaults/preferences/foo.js`; every
348
+ // intermediate segment of the source is in the scorer's generic list,
349
+ // so `resolveBestArtifact` picks whichever hit first and `isConfidentMatch`
350
+ // rejects every candidate, classifying the correctly-packaged file as
351
+ // "missing" even though packaging had landed it.
352
+ const byTransform = await resolveArtifactByKnownTransform(source, roots);
353
+ if (byTransform) {
354
+ return evaluateArtifactMtime(source, byTransform, sourceMtime, { registered: true });
355
+ }
228
356
  const artifact = await resolveBestArtifact(source, roots);
229
357
  if (!artifact) {
230
358
  const where = isTestPath(source) ? '_tests/' : 'dist/';
@@ -235,6 +363,42 @@ async function auditSinglePath(source, ctx) {
235
363
  warning: `Audit: engine/${source} was touched but no packaged artifact with basename "${basename(source)}" was found under ${where}. Missing moz.build / jar.mn / package-manifest.in registration?`,
236
364
  };
237
365
  }
366
+ return evaluateArtifactMtime(source, artifact, sourceMtime, { registered: false, roots });
367
+ }
368
+ /**
369
+ * Short-circuits the audit for sources that are registered in a jar.mn
370
+ * but whose target path is absent from every search root. Returns the
371
+ * miss entry when the registration lookup saw a `(source)` claim but
372
+ * no dist candidate endswith the target; undefined otherwise.
373
+ */
374
+ async function reportRegistrationMiss(engineDir, source, roots) {
375
+ const hit = await findRegisteredTarget(engineDir, source);
376
+ if (!hit)
377
+ return undefined;
378
+ const where = isTestPath(source) ? '_tests/' : 'dist/';
379
+ // Name every same-basename hit so the operator sees what did land in
380
+ // dist, rather than guessing from a single "nearest" pick.
381
+ const candidates = await collectSameBasenameCandidates(source, roots);
382
+ const nearHits = describeCandidates(candidates);
383
+ const manifest = relativeManifestPath(engineDir, hit.jarManifest);
384
+ return {
385
+ source,
386
+ artifact: undefined,
387
+ status: 'missing',
388
+ warning: `Audit: engine/${source} is registered in ${manifest} as ` +
389
+ `"${hit.target} (${hit.source})" but no packaged artifact ending in "/${hit.target}" ` +
390
+ `was found under ${where}. Build reported success but the file's path did not ` +
391
+ `flow through packaging${nearHits ? ` — same-basename hits: ${nearHits}` : ''}.`,
392
+ };
393
+ }
394
+ /**
395
+ * Renders packaged-artifact mtime classification (updated / stale / missing-
396
+ * via-disappearance) for a resolved candidate. Shared by the registration-
397
+ * anchored path (confident by construction) and the heuristic fallback
398
+ * (which still applies the structural-relatedness check before claiming
399
+ * `stale`).
400
+ */
401
+ async function evaluateArtifactMtime(source, artifact, sourceMtime, mode) {
238
402
  let artifactMtime;
239
403
  try {
240
404
  const artifactStat = await stat(artifact);
@@ -249,6 +413,20 @@ async function auditSinglePath(source, ctx) {
249
413
  };
250
414
  }
251
415
  if (artifactMtime + 1 < sourceMtime) {
416
+ if (!mode.registered && !isConfidentMatch(source, artifact)) {
417
+ const where = isTestPath(source) ? '_tests/' : 'dist/';
418
+ const candidates = await collectSameBasenameCandidates(source, mode.roots);
419
+ const nearHits = describeCandidates(candidates);
420
+ return {
421
+ source,
422
+ artifact: undefined,
423
+ status: 'missing',
424
+ warning: `Audit: engine/${source} was touched but no related packaged artifact with ` +
425
+ `basename "${basename(source)}" was found under ${where}` +
426
+ (nearHits ? `. Same-basename hits in unrelated subtrees: ${nearHits}` : '') +
427
+ `. Missing moz.build / jar.mn / package-manifest.in registration?`,
428
+ };
429
+ }
252
430
  return {
253
431
  source,
254
432
  artifact,
@@ -258,6 +436,34 @@ async function auditSinglePath(source, ctx) {
258
436
  }
259
437
  return { source, artifact, status: 'updated' };
260
438
  }
439
+ /** Cap on candidate list rendering before truncating with `(+N more)`. */
440
+ const CANDIDATE_LIST_LIMIT = 5;
441
+ /**
442
+ * Renders a comma-separated list of same-basename hits for inclusion in a
443
+ * warning, truncated at {@link CANDIDATE_LIST_LIMIT} with a `(+N more)`
444
+ * tail. Returns the empty string when no candidates are supplied so
445
+ * callers can omit the parenthetical entirely rather than render a stub.
446
+ */
447
+ function describeCandidates(candidates) {
448
+ if (candidates.length === 0)
449
+ return '';
450
+ const head = candidates.slice(0, CANDIDATE_LIST_LIMIT).join(', ');
451
+ if (candidates.length <= CANDIDATE_LIST_LIMIT)
452
+ return head;
453
+ return `${head}, … (+${candidates.length - CANDIDATE_LIST_LIMIT} more)`;
454
+ }
455
+ /**
456
+ * Formats a manifest path relative to the engine root when it lives
457
+ * underneath, falling back to the absolute path otherwise. Keeps warning
458
+ * text short and anchored to `engine/…` when the manifest is in-tree.
459
+ */
460
+ function relativeManifestPath(engineDir, manifest) {
461
+ const root = engineDir.replace(/[/\\]+$/, '');
462
+ if (manifest.startsWith(`${root}/`)) {
463
+ return `engine/${manifest.slice(root.length + 1)}`;
464
+ }
465
+ return manifest;
466
+ }
261
467
  /**
262
468
  * Runs the post-build audit. Emits per-file warnings for missing or
263
469
  * stale artifacts and a summary info line at the end. Always returns
@@ -283,11 +489,12 @@ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
283
489
  return summary;
284
490
  }
285
491
  const testsRoot = await resolveTestsRoot(engineDir);
492
+ const testsPackaged = await hasPackagedTestsMarker(testsRoot);
286
493
  const changed = await collectChangedFiles(engineDir, baseline);
287
494
  if (changed.length === 0) {
288
495
  return summary;
289
496
  }
290
- const ctx = { engineDir, distRoot, testsRoot };
497
+ const ctx = { engineDir, distRoot, testsRoot, testsPackaged };
291
498
  for (const source of changed) {
292
499
  const result = await auditSinglePath(source, ctx);
293
500
  summary[result.status] += 1;
@@ -35,10 +35,12 @@ export declare function validateJarMnEntries(root: string, config: FurnaceConfig
35
35
  * linked in at least one chrome host document. Without the link, tokens
36
36
  * silently resolve to nothing at runtime.
37
37
  *
38
- * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
39
- * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
40
- * furnace.json; the warning fires only when NONE of the configured
41
- * documents link the tokens CSS.
38
+ * Scan set is the union of (a) the configured `tokenHostDocuments` (or
39
+ * the upstream default when unset) and (b) any `browser/base/content/*.xhtml`
40
+ * document that references `tagName` the auto-detection path catches
41
+ * forks that mount components from a replacement chrome document without
42
+ * having configured `tokenHostDocuments`. The warning fires only when
43
+ * NONE of the documents in the final scan set link the tokens CSS.
42
44
  */
43
45
  export declare function validateTokenLink(componentDir: string, tagName: string, root: string, tokenPrefix?: string, tokenHostDocuments?: string[]): Promise<ValidationIssue[]>;
44
46
  /**
@@ -211,15 +211,73 @@ export async function validateJarMnEntries(root, config) {
211
211
  * `tokenHostDocuments` is not configured in furnace.json.
212
212
  */
213
213
  const DEFAULT_TOKEN_HOST_DOCUMENTS = ['browser/base/content/browser.xhtml'];
214
+ /**
215
+ * Directory scanned for additional chrome host documents that mount the
216
+ * component under audit. Kept narrow (top-level `browser/base/content/`)
217
+ * so the auto-detection stays cheap and only triggers on the well-known
218
+ * location forks use for replacement chrome documents.
219
+ */
220
+ const AUTO_DETECT_HOST_DIR = 'browser/base/content';
221
+ /**
222
+ * Scans `browser/base/content/*.xhtml` for chrome documents that reference
223
+ * `tagName`. Returned paths are engine-relative and deduplicated against
224
+ * `already`, so callers can merge them with the caller-configured set
225
+ * without producing double entries in warning output.
226
+ *
227
+ * Motivating case: a fork that mounts a custom element from its own
228
+ * top-level chrome document (e.g. `mybrowser.xhtml`) without setting
229
+ * `tokenHostDocuments`. The stock `browser.xhtml` was the only thing
230
+ * scanned, so the tokens CSS link in the ACTUAL host document went
231
+ * unnoticed and the warning false-fired.
232
+ *
233
+ * @param engineDir Absolute engine root.
234
+ * @param tagName Custom element tag the CSS belongs to.
235
+ * @param already Paths already in the scan set (POSIX, engine-relative).
236
+ */
237
+ async function autoDetectTokenHostDocuments(engineDir, tagName, already) {
238
+ const contentDir = join(engineDir, AUTO_DETECT_HOST_DIR);
239
+ if (!(await pathExists(contentDir)))
240
+ return [];
241
+ let entries;
242
+ try {
243
+ entries = await readdir(contentDir);
244
+ }
245
+ catch {
246
+ return [];
247
+ }
248
+ const alreadySet = new Set(already);
249
+ const detected = [];
250
+ for (const entry of entries) {
251
+ if (!entry.endsWith('.xhtml'))
252
+ continue;
253
+ const relPath = `${AUTO_DETECT_HOST_DIR}/${entry}`;
254
+ if (alreadySet.has(relPath))
255
+ continue;
256
+ const absPath = join(contentDir, entry);
257
+ let content;
258
+ try {
259
+ content = await readText(absPath);
260
+ }
261
+ catch {
262
+ continue;
263
+ }
264
+ if (content.includes(tagName)) {
265
+ detected.push(relPath);
266
+ }
267
+ }
268
+ return detected;
269
+ }
214
270
  /**
215
271
  * Validates that components using design tokens have the tokens CSS
216
272
  * linked in at least one chrome host document. Without the link, tokens
217
273
  * silently resolve to nothing at runtime.
218
274
  *
219
- * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
220
- * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
221
- * furnace.json; the warning fires only when NONE of the configured
222
- * documents link the tokens CSS.
275
+ * Scan set is the union of (a) the configured `tokenHostDocuments` (or
276
+ * the upstream default when unset) and (b) any `browser/base/content/*.xhtml`
277
+ * document that references `tagName` the auto-detection path catches
278
+ * forks that mount components from a replacement chrome document without
279
+ * having configured `tokenHostDocuments`. The warning fires only when
280
+ * NONE of the documents in the final scan set link the tokens CSS.
223
281
  */
224
282
  export async function validateTokenLink(componentDir, tagName, root, tokenPrefix, tokenHostDocuments) {
225
283
  const issues = [];
@@ -233,7 +291,7 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
233
291
  if (!cssContent.includes(tokenPrefix))
234
292
  return issues;
235
293
  const { engine: engineDir } = getProjectPaths(root);
236
- const hostDocuments = tokenHostDocuments && tokenHostDocuments.length > 0
294
+ const configuredHosts = tokenHostDocuments && tokenHostDocuments.length > 0
237
295
  ? tokenHostDocuments
238
296
  : DEFAULT_TOKEN_HOST_DOCUMENTS;
239
297
  let tokensCssFile;
@@ -247,6 +305,8 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
247
305
  warn(`Could not resolve token CSS link target for ${tagName} during validation: ${reason}`);
248
306
  return issues;
249
307
  }
308
+ const autoDetected = await autoDetectTokenHostDocuments(engineDir, tagName, configuredHosts);
309
+ const hostDocuments = [...configuredHosts, ...autoDetected];
250
310
  const checkedDocuments = [];
251
311
  let anyLinks = false;
252
312
  for (const relDocPath of hostDocuments) {
@@ -268,7 +328,7 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
268
328
  component: tagName,
269
329
  severity: 'warning',
270
330
  check: 'missing-token-link',
271
- message: `Component uses ${tokenPrefix}* tokens but none of the configured chrome host documents (${docsList}) link ${tokensCssFile}. Tokens will silently resolve to nothing. Configure additional hosts via furnace.json "tokenHostDocuments" if needed.`,
331
+ message: `Component uses ${tokenPrefix}* tokens but none of the scanned chrome host documents (${docsList}) link ${tokensCssFile}. Tokens will silently resolve to nothing. Configure additional hosts via furnace.json "tokenHostDocuments" if needed.`,
272
332
  });
273
333
  }
274
334
  return issues;
@@ -27,3 +27,47 @@ export interface BuildArtifactCheck {
27
27
  export declare function hasBuildArtifacts(engineDir: string): Promise<BuildArtifactCheck>;
28
28
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
29
29
  export declare function buildArtifactMismatchMessage(engineDir: string, buildCheck: BuildArtifactCheck, commandName: string): string | undefined;
30
+ /**
31
+ * Outcome of an in-place mozinfo.json rewrite attempt. A successful rewrite
32
+ * returns the paths written; a refused rewrite returns a human-readable
33
+ * reason so the build flow can surface it alongside the original mismatch
34
+ * message before falling back to the clean-rebuild instruction.
35
+ */
36
+ export interface MozinfoRewriteResult {
37
+ /** Whether mozinfo.json was patched in place. */
38
+ rewritten: boolean;
39
+ /** Reason the rewrite was refused (populated when `rewritten === false`). */
40
+ reason?: string;
41
+ /** New `topsrcdir` value written to disk (populated on success). */
42
+ newTopsrcdir?: string;
43
+ /** New `topobjdir` value written to disk (populated on success). */
44
+ newTopobjdir?: string;
45
+ /** New `mozconfig` value written to disk (populated on success when it lived inside topsrcdir). */
46
+ newMozconfig?: string;
47
+ }
48
+ /**
49
+ * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
50
+ *
51
+ * Firefox build artefacts bake the topsrcdir into many generated files
52
+ * (Makefiles, config.status, backend.mk, .deps dependency files — anything
53
+ * produced by `mach configure`). A fresh `mach configure` rebuilds those
54
+ * from the top, so the rewriter only needs to patch the one file `mach`
55
+ * reads to learn where its checkout actually lives. Once mozinfo.json
56
+ * agrees with the on-disk layout, `mach configure` regenerates the rest.
57
+ *
58
+ * Safety rules — the rewrite is refused when any of them are violated:
59
+ * - `topsrcdir` and `topobjdir` must both be present and non-empty.
60
+ * - `topobjdir` must resolve to `<topsrcdir>/<objDir>`; a non-in-tree
61
+ * objdir means the previous workspace was configured differently,
62
+ * so a blind prefix-rewrite could point mach at the wrong tree.
63
+ * - The computed new `topobjdir` must be `<engineDir>/<objDir>`; if it
64
+ * is not, the objDir name itself changed and we cannot prove safety.
65
+ *
66
+ * When any rule trips, the caller should fall back to the clean-rebuild
67
+ * instruction — that's always a correct (if expensive) recovery path.
68
+ *
69
+ * @param engineDir Absolute path to the current engine checkout.
70
+ * @param objDir Name of the obj-* directory to rewrite against.
71
+ * @returns Result object; callers inspect `rewritten` and surface `reason`.
72
+ */
73
+ export declare function attemptMozinfoRewrite(engineDir: string, objDir: string): Promise<MozinfoRewriteResult>;
@@ -1,8 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir } from 'node:fs/promises';
3
- import { join, resolve } from 'node:path';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
4
  import { toError } from '../utils/errors.js';
5
- import { pathExists, readJson } from '../utils/fs.js';
5
+ import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { verbose } from '../utils/logger.js';
7
7
  import { isObject, isString } from '../utils/validation.js';
8
8
  function validateBuildMozinfo(data) {
@@ -112,6 +112,107 @@ export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName)
112
112
  }
113
113
  return (`${commandName} cannot use copied or relocated build artifacts whose metadata still points at a different Firefox workspace.\n\n` +
114
114
  `${details.join('\n')}\n\n` +
115
- 'Delete the stale obj-* directory in this workspace and run "fireforge build" again so mach regenerates build metadata for the current checkout.');
115
+ 'Delete the stale obj-* directory in this workspace and run "fireforge build" again so mach regenerates build metadata for the current checkout.\n' +
116
+ 'If the workspace was simply moved (same tree, different prefix), "fireforge build --rewrite-mozinfo" will patch mozinfo.json paths in place and run mach configure instead of scrubbing the whole tree.');
117
+ }
118
+ /**
119
+ * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
120
+ *
121
+ * Firefox build artefacts bake the topsrcdir into many generated files
122
+ * (Makefiles, config.status, backend.mk, .deps dependency files — anything
123
+ * produced by `mach configure`). A fresh `mach configure` rebuilds those
124
+ * from the top, so the rewriter only needs to patch the one file `mach`
125
+ * reads to learn where its checkout actually lives. Once mozinfo.json
126
+ * agrees with the on-disk layout, `mach configure` regenerates the rest.
127
+ *
128
+ * Safety rules — the rewrite is refused when any of them are violated:
129
+ * - `topsrcdir` and `topobjdir` must both be present and non-empty.
130
+ * - `topobjdir` must resolve to `<topsrcdir>/<objDir>`; a non-in-tree
131
+ * objdir means the previous workspace was configured differently,
132
+ * so a blind prefix-rewrite could point mach at the wrong tree.
133
+ * - The computed new `topobjdir` must be `<engineDir>/<objDir>`; if it
134
+ * is not, the objDir name itself changed and we cannot prove safety.
135
+ *
136
+ * When any rule trips, the caller should fall back to the clean-rebuild
137
+ * instruction — that's always a correct (if expensive) recovery path.
138
+ *
139
+ * @param engineDir Absolute path to the current engine checkout.
140
+ * @param objDir Name of the obj-* directory to rewrite against.
141
+ * @returns Result object; callers inspect `rewritten` and surface `reason`.
142
+ */
143
+ export async function attemptMozinfoRewrite(engineDir, objDir) {
144
+ const mozinfoPath = join(engineDir, objDir, 'mozinfo.json');
145
+ if (!(await pathExists(mozinfoPath))) {
146
+ return { rewritten: false, reason: 'mozinfo.json not found in obj directory' };
147
+ }
148
+ let raw;
149
+ try {
150
+ raw = await readJson(mozinfoPath);
151
+ }
152
+ catch (error) {
153
+ return { rewritten: false, reason: `mozinfo.json is unreadable: ${toError(error).message}` };
154
+ }
155
+ if (!isObject(raw)) {
156
+ return { rewritten: false, reason: 'mozinfo.json is not a JSON object' };
157
+ }
158
+ let mozinfo;
159
+ try {
160
+ mozinfo = validateBuildMozinfo(raw);
161
+ }
162
+ catch (error) {
163
+ return { rewritten: false, reason: toError(error).message };
164
+ }
165
+ const oldSrc = mozinfo.topsrcdir;
166
+ const oldObj = mozinfo.topobjdir;
167
+ if (!oldSrc || !oldObj) {
168
+ return {
169
+ rewritten: false,
170
+ reason: 'mozinfo.json is missing topsrcdir or topobjdir; cannot rewrite safely',
171
+ };
172
+ }
173
+ const oldSrcResolved = resolve(oldSrc);
174
+ const oldObjResolved = resolve(oldObj);
175
+ const insideTree = oldObjResolved === oldSrcResolved ||
176
+ oldObjResolved.startsWith(oldSrcResolved + sep) ||
177
+ oldObjResolved.startsWith(oldSrcResolved + '/');
178
+ if (!insideTree) {
179
+ return {
180
+ rewritten: false,
181
+ reason: `topobjdir (${oldObjResolved}) is not inside topsrcdir (${oldSrcResolved}) — rewrite would change workspace layout`,
182
+ };
183
+ }
184
+ const relativeObj = relative(oldSrcResolved, oldObjResolved).split(sep).join('/');
185
+ if (relativeObj !== objDir) {
186
+ return {
187
+ rewritten: false,
188
+ reason: `mozinfo objdir "${relativeObj}" does not match detected objdir "${objDir}" — rewrite would change the obj directory name`,
189
+ };
190
+ }
191
+ const newSrc = resolve(engineDir);
192
+ const newObj = resolve(engineDir, objDir);
193
+ const patched = { ...raw, topsrcdir: newSrc, topobjdir: newObj };
194
+ let newMozconfig;
195
+ if (mozinfo.mozconfig) {
196
+ const oldMozconfigResolved = resolve(mozinfo.mozconfig);
197
+ if (oldMozconfigResolved === oldSrcResolved ||
198
+ oldMozconfigResolved.startsWith(oldSrcResolved + sep) ||
199
+ oldMozconfigResolved.startsWith(oldSrcResolved + '/')) {
200
+ const rel = relative(oldSrcResolved, oldMozconfigResolved);
201
+ newMozconfig = resolve(newSrc, rel);
202
+ patched['mozconfig'] = newMozconfig;
203
+ }
204
+ // A mozconfig living outside the old topsrcdir is left as-is — it
205
+ // probably points at a shared configuration file the user kept in
206
+ // place across the relocation. A relocated checkout that also moved
207
+ // its mozconfig will still fail configure; operator can re-point
208
+ // with `MOZCONFIG=…` or run a full clean rebuild.
209
+ }
210
+ await writeJson(mozinfoPath, patched);
211
+ return {
212
+ rewritten: true,
213
+ newTopsrcdir: newSrc,
214
+ newTopobjdir: newObj,
215
+ ...(newMozconfig ? { newMozconfig } : {}),
216
+ };
116
217
  }
117
218
  //# sourceMappingURL=mach-build-artifacts.js.map
@@ -1,4 +1,4 @@
1
- export { type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
1
+ export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, type MozinfoRewriteResult, } from './mach-build-artifacts.js';
2
2
  export { generateMozconfig, type MozconfigVariables } from './mach-mozconfig.js';
3
3
  export { ensurePython, resetResolvedPython } from './mach-python.js';
4
4
  /**
@@ -7,7 +7,7 @@ import { exec, execInherit, execInheritCapture, execStream } from '../utils/proc
7
7
  import { explainMachError } from './mach-error-hints.js';
8
8
  import { getPython } from './mach-python.js';
9
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
10
- export { buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
10
+ export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
11
11
  export { generateMozconfig } from './mach-mozconfig.js';
12
12
  export { ensurePython, resetResolvedPython } from './mach-python.js';
13
13
  /**
@@ -0,0 +1,42 @@
1
+ import type { BuildBaseline } from './build-baseline.js';
2
+ /** Result of the stale-build preflight probe. */
3
+ export interface StaleBuildResult {
4
+ /** True when at least one packageable engine file changed since the baseline. */
5
+ stale: boolean;
6
+ /**
7
+ * Engine-relative paths that would have been packaged but appear to have
8
+ * changed since the baseline. Sorted and deduplicated. Truncated at
9
+ * {@link STALE_PATHS_LIMIT} entries for rendering; consult
10
+ * {@link StaleBuildResult.truncated} to know when to append a `(+N more)`
11
+ * tail to the warning.
12
+ */
13
+ changedPaths: string[];
14
+ /**
15
+ * How many paths were dropped from `changedPaths` due to the render cap.
16
+ * Callers render this as `(+N more)` in the warning body.
17
+ */
18
+ truncated: number;
19
+ /**
20
+ * The baseline that anchored the diff, or undefined when no previous
21
+ * successful build exists. A missing baseline is treated as "not stale"
22
+ * — we have nothing to compare against and a warning would mislead.
23
+ */
24
+ baseline: BuildBaseline | undefined;
25
+ }
26
+ /**
27
+ * Probes the engine tree for packageable changes since the last successful
28
+ * `fireforge build`. Returns a summary the `fireforge test` handler renders
29
+ * as an up-front warning when `--build` was NOT passed. The probe never
30
+ * throws; git failures and a missing baseline both degrade to `stale: false`
31
+ * so a broken probe cannot block a test run.
32
+ *
33
+ * @param projectRoot Root directory of the project.
34
+ * @param engineDir Path to the engine directory.
35
+ */
36
+ export declare function checkStaleBuildForTest(projectRoot: string, engineDir: string): Promise<StaleBuildResult>;
37
+ /**
38
+ * Formats a human-readable warning body from a {@link StaleBuildResult}.
39
+ * Kept separate from the probe so test code can assert on the structured
40
+ * result without matching the rendered copy.
41
+ */
42
+ export declare function formatStaleBuildWarning(result: StaleBuildResult): string;