@hominis/fireforge 0.15.6 → 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 (34) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +68 -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-registration.d.ts +80 -0
  21. package/dist/src/core/build-audit-registration.js +187 -0
  22. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  23. package/dist/src/core/build-audit-transforms.js +94 -0
  24. package/dist/src/core/build-audit.js +107 -7
  25. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  26. package/dist/src/core/furnace-validate-registration.js +66 -6
  27. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  28. package/dist/src/core/mach-build-artifacts.js +104 -3
  29. package/dist/src/core/mach.d.ts +1 -1
  30. package/dist/src/core/mach.js +1 -1
  31. package/dist/src/core/test-stale-check.d.ts +42 -0
  32. package/dist/src/core/test-stale-check.js +114 -0
  33. package/dist/src/types/commands/options.d.ts +16 -0
  34. package/package.json +1 -1
@@ -0,0 +1,187 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /*
3
+ * Registration-aware artifact resolution for the post-build dist-tree audit.
4
+ *
5
+ * The basename-plus-similarity heuristic in `build-audit-resolve.ts` cannot
6
+ * distinguish two unrelated files that share a source basename. Motivating
7
+ * case: one fork registers `content/mybrowser.js` in `browser/base/jar.mn`
8
+ * (packaged under `chrome/browser/content/browser/mybrowser.js`) while an
9
+ * unrelated patch registers a `mybrowser.js` pref file under
10
+ * `browser/defaults/preferences/`. The basename walker surfaces both
11
+ * candidates, `scoreCandidate` awards them an equal trailing-overlap score
12
+ * (basename only, no meaningful mid-path match), and whichever the
13
+ * directory walk hits first wins — frequently the wrong one.
14
+ *
15
+ * This module anchors resolution to the `(source)` reference inside
16
+ * `jar.mn`. For a source under audit we walk its ancestor directories for
17
+ * an owning `jar.mn`, find the entry whose `(source)` resolves to our
18
+ * path, and expose both the target path recorded in the entry and the
19
+ * manifest that owns it. Callers in `build-audit.ts` prefer candidates
20
+ * whose absolute dist-tree path ends with the registered target, and
21
+ * report an unambiguous "registered but not packaged" miss when no such
22
+ * candidate exists — rather than falling through to the heuristic which
23
+ * would pick an unrelated same-basename file.
24
+ *
25
+ * Fallback semantics: when no jar.mn registration is found, the caller
26
+ * is expected to use the similarity heuristic. Registration wins when it
27
+ * exists; the heuristic fills the gap for sources registered through
28
+ * moz.build (`FINAL_TARGET_FILES`, `JS_PREFERENCE_FILES`, etc.) or
29
+ * `package-manifest.in` entries, which this module intentionally does
30
+ * not parse — supporting every Firefox registration surface would bloat
31
+ * the audit, and the heuristic's remaining weak case (unrelated same-
32
+ * basename hits) is surfaced to the operator in the warning copy.
33
+ */
34
+ import { basename, dirname, join, relative, sep } from 'node:path';
35
+ import { pathExists, readText } from '../utils/fs.js';
36
+ import { findAllByBasename } from './build-audit-resolve.js';
37
+ /** Ceiling on ancestor-directory hops when searching for an owning jar.mn. */
38
+ const MAX_JAR_MN_SCAN_DEPTH = 8;
39
+ /**
40
+ * Parses a single jar.mn line into `{ target, source }` when the line is a
41
+ * content entry with an explicit `(source)` reference. Returns undefined
42
+ * for comments, headers (`browser.jar:`), `%` manifest directives, blank
43
+ * lines, and entries without a source reference.
44
+ *
45
+ * Accepted entry shapes:
46
+ * ` content/browser/foo.js (content/foo.js)` bare
47
+ * `* content/browser/foo.js (content/foo.js)` `*` = preprocessed
48
+ * `en-US.jar: content/foo.js (content/foo.js)` locale-prefixed
49
+ */
50
+ export function parseJarMnEntry(line) {
51
+ if (!line)
52
+ return undefined;
53
+ const trimmed = line.trim();
54
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('%'))
55
+ return undefined;
56
+ // Leading `*` (preprocessed) and optional `locale.jar:` prefix are dropped
57
+ // before matching the target/(source) pair. Both are whitespace-separated
58
+ // from the target entry.
59
+ const stripped = trimmed.replace(/^\*\s+/, '').replace(/^[A-Za-z0-9.\-_]+\.jar:\s+/, '');
60
+ const match = /^(\S+)\s+\(([^)]+)\)\s*$/.exec(stripped);
61
+ if (!match)
62
+ return undefined;
63
+ const target = (match[1] ?? '').trim();
64
+ const source = (match[2] ?? '').trim();
65
+ if (!target || !source)
66
+ return undefined;
67
+ return { target, source };
68
+ }
69
+ /**
70
+ * Scans a jar.mn file's contents for an entry whose source reference
71
+ * matches `relativeSource` (POSIX, relative to the jar.mn directory).
72
+ * Returns the first match; jar.mn enforces uniqueness of `(source)` in
73
+ * practice, so a first-match wins behaviour is adequate.
74
+ */
75
+ export function findJarMnEntryForSource(content, relativeSource) {
76
+ const normalized = relativeSource.replace(/\\/g, '/');
77
+ for (const line of content.split('\n')) {
78
+ const parsed = parseJarMnEntry(line);
79
+ if (!parsed)
80
+ continue;
81
+ if (parsed.source === normalized)
82
+ return parsed;
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Walks from the source's directory upward to the engine root, returning
88
+ * the first jar.mn entry that registers the given source. Returns undefined
89
+ * when no ancestor jar.mn claims the source.
90
+ *
91
+ * @param engineDir Absolute engine root; walk halts here.
92
+ * @param source Engine-relative POSIX source path.
93
+ */
94
+ export async function findRegisteredTarget(engineDir, source) {
95
+ const sourceAbs = join(engineDir, source);
96
+ const root = engineDir.replace(/[/\\]+$/, '');
97
+ let current = dirname(sourceAbs);
98
+ let depth = 0;
99
+ while (depth <= MAX_JAR_MN_SCAN_DEPTH &&
100
+ (current === root || current.startsWith(`${root}/`) || current.startsWith(`${root}\\`))) {
101
+ const jarMn = join(current, 'jar.mn');
102
+ if (await pathExists(jarMn)) {
103
+ let content;
104
+ try {
105
+ content = await readText(jarMn);
106
+ }
107
+ catch {
108
+ content = '';
109
+ }
110
+ const rel = relative(current, sourceAbs).split(sep).join('/');
111
+ const entry = findJarMnEntryForSource(content, rel);
112
+ if (entry) {
113
+ return { target: entry.target, source: entry.source, jarManifest: jarMn };
114
+ }
115
+ }
116
+ const parent = dirname(current);
117
+ if (parent === current)
118
+ break;
119
+ current = parent;
120
+ depth += 1;
121
+ }
122
+ return undefined;
123
+ }
124
+ /**
125
+ * Normalises an absolute filesystem path to POSIX separators so the
126
+ * suffix comparison against a jar.mn target (always POSIX) is platform-
127
+ * independent.
128
+ */
129
+ function toPosix(path) {
130
+ return path.split(sep).join('/');
131
+ }
132
+ /**
133
+ * Probes the dist tree for the artifact registered against the given
134
+ * source. Returns the matched candidate and the registration hit that
135
+ * anchored it; undefined when the source has no owning jar.mn or when
136
+ * no same-basename candidate under the search roots ends with the
137
+ * registered target path.
138
+ *
139
+ * Suffix-matching against the target path is intentional: jar.mn targets
140
+ * are relative to the jar root (`browser.jar:`, `toolkit.jar:`), but the
141
+ * dist tree prefixes every entry with a jar-specific directory
142
+ * (`.../chrome/browser/content/browser/…`). The source basename plus the
143
+ * target suffix are unambiguous across every packaging convention we
144
+ * care about.
145
+ *
146
+ * @param engineDir Absolute engine root.
147
+ * @param source Engine-relative POSIX source path.
148
+ * @param searchRoots Absolute roots to probe (dist/, _tests/).
149
+ */
150
+ export async function resolveArtifactByRegistration(engineDir, source, searchRoots) {
151
+ const hit = await findRegisteredTarget(engineDir, source);
152
+ if (!hit)
153
+ return undefined;
154
+ const name = basename(source);
155
+ const targetSuffix = `/${hit.target.replace(/^\/+/, '')}`;
156
+ const candidates = [];
157
+ for (const root of searchRoots) {
158
+ const found = await findAllByBasename(root, name);
159
+ candidates.push(...found);
160
+ }
161
+ for (const candidate of candidates) {
162
+ if (toPosix(candidate).endsWith(targetSuffix)) {
163
+ return { artifact: candidate, hit };
164
+ }
165
+ }
166
+ return undefined;
167
+ }
168
+ /**
169
+ * Returns the absolute paths of every same-basename candidate under the
170
+ * given search roots. Used by the audit to enumerate ALL false-match
171
+ * candidates when the heuristic fallback downgrades to "missing" — the
172
+ * operator needs to see the full set, not just the scorer's pick, to
173
+ * distinguish a registration bug from a genuine packaging drop.
174
+ *
175
+ * @param source Engine-relative POSIX source path.
176
+ * @param searchRoots Absolute roots to scan.
177
+ */
178
+ export async function collectSameBasenameCandidates(source, searchRoots) {
179
+ const name = basename(source);
180
+ const out = [];
181
+ for (const root of searchRoots) {
182
+ const found = await findAllByBasename(root, name);
183
+ out.push(...found);
184
+ }
185
+ return out;
186
+ }
187
+ //# sourceMappingURL=build-audit-registration.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Returns the expected chrome-tree suffix for an engine-relative POSIX
3
+ * source path when the path falls under a known transform prefix;
4
+ * undefined otherwise.
5
+ *
6
+ * @param source Engine-relative POSIX source path.
7
+ */
8
+ export declare function expectedChromeSuffix(source: string): string | undefined;
9
+ /**
10
+ * Probes the dist tree for the artifact implied by a known
11
+ * source→chrome transform. Returns the first absolute candidate whose
12
+ * POSIX path ends with the expected chrome suffix, or undefined when
13
+ * no transform applies or no candidate matches.
14
+ *
15
+ * The transform check is treated as high-confidence by `build-audit.ts`
16
+ * (callers pass `{ registered: true }` to `evaluateArtifactMtime`), so
17
+ * a match bypasses the structural-relation check that rejects generic
18
+ * basename collisions.
19
+ *
20
+ * @param source Engine-relative POSIX source path.
21
+ * @param searchRoots Absolute roots to probe (dist/, _tests/).
22
+ */
23
+ export declare function resolveArtifactByKnownTransform(source: string, searchRoots: readonly string[]): Promise<string | undefined>;
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /*
3
+ * Known source→packaging path transforms for the post-build dist-tree audit.
4
+ *
5
+ * Motivating case: a source at `engine/browser/base/content/foo.js` ships
6
+ * under `chrome/browser/content/browser/foo.js`. If an unrelated patch
7
+ * registers a different `foo.js` elsewhere in the tree (e.g. a pref file
8
+ * under `browser/defaults/preferences/foo.js`), the basename walker
9
+ * surfaces both candidates, `scoreCandidate` awards them an identical
10
+ * trailing-overlap score (basename only; every intermediate segment is
11
+ * in the generic list), and whichever the directory walk hits first wins.
12
+ * The heuristic then declares the chosen candidate "not structurally
13
+ * related" and reports the correctly-packaged chrome resource as missing.
14
+ *
15
+ * The transforms below anchor resolution to the well-known subtree→chrome
16
+ * conventions used by upstream mozilla-central jar.mn. When a source
17
+ * matches one of these prefixes, a candidate whose absolute path ends
18
+ * with the implied chrome suffix is treated as a confident match — the
19
+ * scorer never runs and the structural-relation check is bypassed.
20
+ *
21
+ * Scope is intentionally narrow: only subtrees whose packaging target is
22
+ * stable across every fork we know about. A fork that reroutes a known
23
+ * subtree can still win by adding `(source)` annotations in its own
24
+ * `jar.mn`, which `resolveArtifactByRegistration` consults first.
25
+ */
26
+ import { basename, sep } from 'node:path';
27
+ import { findAllByBasename } from './build-audit-resolve.js';
28
+ /**
29
+ * Table of `prefix → chrome-suffix` transforms. Each rule names an
30
+ * engine-relative subtree prefix and produces the expected dist-tree
31
+ * suffix for a file under it. Rules are evaluated in array order and
32
+ * the first-matching prefix wins, so `toolkit/content/widgets/` must
33
+ * precede the looser `toolkit/content/`.
34
+ */
35
+ const KNOWN_TRANSFORMS = [
36
+ {
37
+ prefix: 'browser/base/content/',
38
+ build: (rest) => `chrome/browser/content/browser/${rest}`,
39
+ },
40
+ {
41
+ prefix: 'toolkit/content/widgets/',
42
+ build: (rest) => `chrome/toolkit/content/global/elements/${rest}`,
43
+ },
44
+ {
45
+ prefix: 'toolkit/content/',
46
+ build: (rest) => `chrome/toolkit/content/global/${rest}`,
47
+ },
48
+ ];
49
+ /**
50
+ * Returns the expected chrome-tree suffix for an engine-relative POSIX
51
+ * source path when the path falls under a known transform prefix;
52
+ * undefined otherwise.
53
+ *
54
+ * @param source Engine-relative POSIX source path.
55
+ */
56
+ export function expectedChromeSuffix(source) {
57
+ for (const rule of KNOWN_TRANSFORMS) {
58
+ if (source.startsWith(rule.prefix)) {
59
+ return rule.build(source.slice(rule.prefix.length));
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Probes the dist tree for the artifact implied by a known
66
+ * source→chrome transform. Returns the first absolute candidate whose
67
+ * POSIX path ends with the expected chrome suffix, or undefined when
68
+ * no transform applies or no candidate matches.
69
+ *
70
+ * The transform check is treated as high-confidence by `build-audit.ts`
71
+ * (callers pass `{ registered: true }` to `evaluateArtifactMtime`), so
72
+ * a match bypasses the structural-relation check that rejects generic
73
+ * basename collisions.
74
+ *
75
+ * @param source Engine-relative POSIX source path.
76
+ * @param searchRoots Absolute roots to probe (dist/, _tests/).
77
+ */
78
+ export async function resolveArtifactByKnownTransform(source, searchRoots) {
79
+ const suffix = expectedChromeSuffix(source);
80
+ if (!suffix)
81
+ return undefined;
82
+ const name = basename(source);
83
+ const suffixWithSlash = `/${suffix}`;
84
+ for (const root of searchRoots) {
85
+ const candidates = await findAllByBasename(root, name);
86
+ for (const candidate of candidates) {
87
+ if (candidate.split(sep).join('/').endsWith(suffixWithSlash)) {
88
+ return candidate;
89
+ }
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ //# sourceMappingURL=build-audit-transforms.js.map
@@ -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 { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
42
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';
@@ -317,6 +319,40 @@ async function auditSinglePath(source, ctx) {
317
319
  return { source, artifact: undefined, status: 'skipped' };
318
320
  }
319
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
+ }
320
356
  const artifact = await resolveBestArtifact(source, roots);
321
357
  if (!artifact) {
322
358
  const where = isTestPath(source) ? '_tests/' : 'dist/';
@@ -327,6 +363,42 @@ async function auditSinglePath(source, ctx) {
327
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?`,
328
364
  };
329
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) {
330
402
  let artifactMtime;
331
403
  try {
332
404
  const artifactStat = await stat(artifact);
@@ -341,18 +413,18 @@ async function auditSinglePath(source, ctx) {
341
413
  };
342
414
  }
343
415
  if (artifactMtime + 1 < sourceMtime) {
344
- // Before claiming "stale", verify the match is structurally related.
345
- // A same-basename hit in an unrelated subtree (e.g. `head.js` in a
346
- // completely different upstream test helper) should be reported as
347
- // missing — the operator needs to check registration, not puzzle
348
- // over why an unrelated file appears "older than the source".
349
- if (!isConfidentMatch(source, artifact)) {
416
+ if (!mode.registered && !isConfidentMatch(source, artifact)) {
350
417
  const where = isTestPath(source) ? '_tests/' : 'dist/';
418
+ const candidates = await collectSameBasenameCandidates(source, mode.roots);
419
+ const nearHits = describeCandidates(candidates);
351
420
  return {
352
421
  source,
353
422
  artifact: undefined,
354
423
  status: 'missing',
355
- warning: `Audit: engine/${source} was touched but no related packaged artifact with basename "${basename(source)}" was found under ${where} (nearest same-basename file ${artifact} lives in an unrelated subtree). Missing moz.build / jar.mn / package-manifest.in registration?`,
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?`,
356
428
  };
357
429
  }
358
430
  return {
@@ -364,6 +436,34 @@ async function auditSinglePath(source, ctx) {
364
436
  }
365
437
  return { source, artifact, status: 'updated' };
366
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
+ }
367
467
  /**
368
468
  * Runs the post-build audit. Emits per-file warnings for missing or
369
469
  * stale artifacts and a summary info line at the end. Always returns
@@ -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>;