@hominis/fireforge 0.15.6 → 0.15.8

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 (64) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +158 -15
  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 +38 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +100 -0
  12. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  13. package/dist/src/commands/furnace/create-features.js +56 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  15. package/dist/src/commands/furnace/create-templates.js +28 -6
  16. package/dist/src/commands/furnace/create.js +62 -63
  17. package/dist/src/commands/furnace/index.js +4 -1
  18. package/dist/src/commands/lint.d.ts +17 -2
  19. package/dist/src/commands/lint.js +25 -2
  20. package/dist/src/commands/register.d.ts +1 -1
  21. package/dist/src/commands/register.js +30 -7
  22. package/dist/src/commands/run.d.ts +15 -1
  23. package/dist/src/commands/run.js +202 -7
  24. package/dist/src/commands/test.js +113 -3
  25. package/dist/src/core/build-audit-registration.d.ts +80 -0
  26. package/dist/src/core/build-audit-registration.js +187 -0
  27. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  28. package/dist/src/core/build-audit-transforms.js +94 -0
  29. package/dist/src/core/build-audit.js +107 -7
  30. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  31. package/dist/src/core/furnace-apply-ftl.js +6 -2
  32. package/dist/src/core/furnace-apply-helpers.js +14 -4
  33. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  34. package/dist/src/core/furnace-config-custom.js +64 -0
  35. package/dist/src/core/furnace-config.js +2 -39
  36. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  37. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  38. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  39. package/dist/src/core/furnace-validate-helpers.js +19 -0
  40. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  41. package/dist/src/core/furnace-validate-registration.js +66 -6
  42. package/dist/src/core/furnace-validate-structure.js +6 -2
  43. package/dist/src/core/furnace-validate.js +6 -3
  44. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  45. package/dist/src/core/mach-build-artifacts.js +104 -3
  46. package/dist/src/core/mach.d.ts +27 -1
  47. package/dist/src/core/mach.js +26 -2
  48. package/dist/src/core/shared-ftl.d.ts +28 -0
  49. package/dist/src/core/shared-ftl.js +42 -0
  50. package/dist/src/core/smoke-patterns.d.ts +45 -0
  51. package/dist/src/core/smoke-patterns.js +100 -0
  52. package/dist/src/core/test-stale-check.d.ts +42 -0
  53. package/dist/src/core/test-stale-check.js +114 -0
  54. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  55. package/dist/src/core/xpcshell-appdir.js +273 -0
  56. package/dist/src/errors/codes.d.ts +13 -0
  57. package/dist/src/errors/codes.js +13 -0
  58. package/dist/src/errors/run.d.ts +16 -0
  59. package/dist/src/errors/run.js +22 -0
  60. package/dist/src/types/commands/options.d.ts +64 -0
  61. package/dist/src/types/furnace.d.ts +39 -0
  62. package/dist/src/utils/process.d.ts +63 -0
  63. package/dist/src/utils/process.js +122 -0
  64. 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
@@ -8,7 +8,7 @@
8
8
  * aborting the whole command. Missing jar.mn on a fork without a locale
9
9
  * package should not block a working `.mjs`/`.css` from shipping.
10
10
  */
11
- import type { DryRunAction, StepError } from '../types/furnace.js';
11
+ import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
12
  import { type RollbackJournal } from './furnace-rollback.js';
13
13
  /**
14
14
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
@@ -28,6 +28,8 @@ export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir:
28
28
  /**
29
29
  * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
30
30
  * source workspace file has been deleted. Idempotent — absent entries are a
31
- * no-op.
31
+ * no-op. Early-returns for `sharedFtl` components: the shared bundle is
32
+ * owned elsewhere, and dropping its jar.mn line on our component's delete
33
+ * would orphan every other participant.
32
34
  */
33
- export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;
35
+ export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, config: CustomComponentConfig, rollbackJournal?: RollbackJournal): Promise<void>;
@@ -81,9 +81,13 @@ export function describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile) {
81
81
  /**
82
82
  * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
83
83
  * source workspace file has been deleted. Idempotent — absent entries are a
84
- * no-op.
84
+ * no-op. Early-returns for `sharedFtl` components: the shared bundle is
85
+ * owned elsewhere, and dropping its jar.mn line on our component's delete
86
+ * would orphan every other participant.
85
87
  */
86
- export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal) {
88
+ export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal) {
89
+ if (config.sharedFtl)
90
+ return;
87
91
  if (!fileName.endsWith('.ftl'))
88
92
  return;
89
93
  const tagName = fileName.slice(0, -'.ftl'.length);
@@ -139,10 +139,12 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
139
139
  await removeFile(enginePath);
140
140
  removed.push(relative(engineDir, enginePath));
141
141
  }
142
- // When an `.ftl` is deleted from the workspace, the corresponding locale
142
+ // When an `.ftl` is deleted from the workspace the corresponding locale
143
143
  // jar.mn entry must also be dropped — otherwise the chrome URI points at
144
144
  // a missing file and runtime Fluent resolution breaks silently.
145
- await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal);
145
+ // `removeCustomFtlJarMnEntry` early-returns for `sharedFtl` components
146
+ // (the shared bundle is owned elsewhere).
147
+ await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal);
146
148
  }
147
149
  return removed;
148
150
  }
@@ -306,7 +308,12 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
306
308
  description: `Copy ${entry.name} to ${config.targetPath}`,
307
309
  });
308
310
  }
309
- if (config.localized) {
311
+ // Per-component .ftl handling is skipped when the component opts into a
312
+ // shared feature-scoped bundle via `sharedFtl`. The shared file is
313
+ // registered (and copied) by whoever owns the feature bundle, so
314
+ // emitting a copy-ftl / register-jar action here would duplicate (or
315
+ // later orphan) the entry.
316
+ if (config.localized && !config.sharedFtl) {
310
317
  const ftlFile = `${name}.ftl`;
311
318
  const ftlSrc = join(componentDir, ftlFile);
312
319
  if (await pathExists(ftlSrc)) {
@@ -402,7 +409,10 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
402
409
  affectedPaths.push(relative(engineDir, dest));
403
410
  copiedFileNames.push(entry.name);
404
411
  }));
405
- if (config.localized) {
412
+ // See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
413
+ // the shared bundle is owned elsewhere and FireForge must not copy or
414
+ // register a per-component `.ftl` on its behalf.
415
+ if (config.localized && !config.sharedFtl) {
406
416
  await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
407
417
  }
408
418
  if (config.register) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parser for the `custom` entries in furnace.json. Extracted from
3
+ * `furnace-config.ts` so the main config module stays under the
4
+ * per-file LOC budget — the custom-component schema has grown to
5
+ * carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
6
+ * each add their own validation branch.
7
+ */
8
+ import type { CustomComponentConfig } from '../types/furnace.js';
9
+ /**
10
+ * Validates a custom component config object.
11
+ * @param data - Raw data to validate
12
+ * @param name - Component name for error messages
13
+ */
14
+ export declare function parseCustomConfig(data: Record<string, unknown>, name: string): CustomComponentConfig;
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Parser for the `custom` entries in furnace.json. Extracted from
4
+ * `furnace-config.ts` so the main config module stays under the
5
+ * per-file LOC budget — the custom-component schema has grown to
6
+ * carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
7
+ * each add their own validation branch.
8
+ */
9
+ import { FurnaceError } from '../errors/furnace.js';
10
+ import { isExplicitAbsolutePath } from '../utils/paths.js';
11
+ import { isBoolean, isString } from '../utils/validation.js';
12
+ import { parseStringArray } from './furnace-config.js';
13
+ import { validateSharedFtl } from './shared-ftl.js';
14
+ /**
15
+ * Validates a custom component config object.
16
+ * @param data - Raw data to validate
17
+ * @param name - Component name for error messages
18
+ */
19
+ export function parseCustomConfig(data, name) {
20
+ if (!isString(data['description'])) {
21
+ throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
22
+ }
23
+ if (!isString(data['targetPath'])) {
24
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
25
+ }
26
+ if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
27
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
28
+ }
29
+ if (isExplicitAbsolutePath(data['targetPath'])) {
30
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
31
+ }
32
+ if (!isBoolean(data['register'])) {
33
+ throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
34
+ }
35
+ if (!isBoolean(data['localized'])) {
36
+ throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
37
+ }
38
+ if (data['composes'] !== undefined) {
39
+ parseStringArray(data['composes'], `${name}.composes`);
40
+ }
41
+ if (data['keyboardCovered'] !== undefined && !isBoolean(data['keyboardCovered'])) {
42
+ throw new FurnaceError(`Furnace config: custom "${name}.keyboardCovered" must be a boolean when set`);
43
+ }
44
+ let sharedFtl;
45
+ if (data['sharedFtl'] !== undefined) {
46
+ const result = validateSharedFtl(data['sharedFtl'], { localized: data['localized'] });
47
+ if (!result.ok) {
48
+ throw new FurnaceError(`Furnace config: custom "${name}.sharedFtl" ${result.reason}`);
49
+ }
50
+ sharedFtl = result.value;
51
+ }
52
+ return {
53
+ description: data['description'],
54
+ targetPath: data['targetPath'],
55
+ register: data['register'],
56
+ localized: data['localized'],
57
+ ...(data['composes'] !== undefined
58
+ ? { composes: parseStringArray(data['composes'], `${name}.composes`) }
59
+ : {}),
60
+ ...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
61
+ ...(sharedFtl !== undefined ? { sharedFtl } : {}),
62
+ };
63
+ }
64
+ //# sourceMappingURL=furnace-config-custom.js.map