@hominis/fireforge 0.44.3 → 0.44.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -86,6 +86,8 @@
86
86
  - **`token add --variant` required a flag that described nothing, rejected its companion, and reported a no-op as success.** Three separate frictions, all in the variant path. `--category` was an unconditional `requiredOption` that had to name a REAL category even under `--variant`, where the declaration is routed into a `:root<selector>` block and never into a category section — the requirement existed so the argument would not be "silently discarded", a fair goal whose effect was a mandatory argument saying nothing about where the token lands. It is now optional there (still validated when supplied, so a typo is not accepted just because the flag is inert), and its absence on a BASE declaration is refused with the REASON stated rather than commander's bare "required option not specified".
87
87
  `--create-category` is still rejected with `--variant` — the two really are incompatible — but the working create-then-variant order (author the category with the base token, then add each variant) was discoverable only by failing twice. It is now in both the error and `--variant`'s help text.
88
88
  And a token already present in the target variant block was skipped with a bare `{ added: false }`, so a re-run intending to CHANGE a value exited 0 having changed nothing, while the base path had been reporting the category and line for the same situation all along. The variant path now returns the location too, and the skip reads `already exists in :root[…] (line N), unchanged` — the block being the location, since a variant declaration has no category to name.
89
+ - **The CC0 public-domain dedication Mozilla puts on its test files was not a recognized license header.** `containsUpstreamLicenseText` knew MPL, Apache, MIT, GPL and a bare SPDX tag; nothing knew `Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/`. A modified upstream test whose notice was UNCHANGED therefore warned `modified-file-missing-header`, and the downstream workaround — two identifier-only edits, an `SPDX-License-Identifier: CC0-1.0` line above each untouched notice on patch 905 — was editing Mozilla's notice to satisfy FireForge's recognizer. Worse than filed: `hasThirdPartyPermissiveBanner` did not know it either, so a NEW file copied from the upstream test tree was neither "acceptable" nor "vendored", and `export -y` would have prepended the PROJECT's license header onto code Mozilla dedicated to the public domain — precisely the mislicensing the vendored carve-out (0.38.0) exists to prevent.
90
+ Both predicates now recognize the dedication in every spelling the tree carries — the prose, the `creativecommons.org/publicdomain/zero/1.0/` URL with either scheme, and the `CC0-1.0` SPDX identifier — through one shared matcher, so the two cannot drift. Deliberately narrow, with the negative controls pinned: a different Creative Commons license (`licenses/by/4.0/`, `CC-BY-4.0`), a made-up `publicdomain/zero/2.0/`, a bare "public domain" mention, and a dedication below the scanned head are all still refused.
89
91
 
90
92
  ### Security
91
93
 
@@ -108,6 +110,10 @@
108
110
  ### Performance
109
111
 
110
112
  - **`getAllDiff` spawned one `git hash-object` per untracked file, uncapped.** Its sibling `getDiffForFilesAgainstHead` was reshaped into classify → one batched hash → render for exactly this reason; this path kept the per-file form, and it is the only path behind `tree create`, `tree list`, `tree remove --all`, `lint` and `export-all` — against a Firefox tree where "untracked" can mean a very large number of files. Now batched. Everything observable is deliberately unchanged (renames, `ls-files` ordering, tracked-block-first emission, the `'\n'` empty sentinel) because this output is SHA-256'd into every tree fingerprint and written verbatim into `.patch` files; a new real-git test pins those bytes, which nothing did before.
113
+ - **A dirty `jar.mn` escalated EVERY `test --build` to a full `mach build`, and a dirty `moz.build` re-ran `mach configure` every time — including immediately after the full build that had consumed exactly that content.** A downstream session measured it: 15:00 for the first full build, 14:03 for the next `test --build` because "`jar.mn` changed relative to its own successful-build record", and 17 s for the CSS-only fix that followed. Two of those three builds were the same work twice.
114
+ The cause is what "changed" meant. `collectChangedEnginePaths` unions the baseline-to-HEAD diff with every WORKTREE modification, and a fork's worktree is permanently dirty — imported patches plus Furnace-applied components are a standing diff against HEAD — so a patch-touched `jar.mn` was "changed" on every run for as long as the patch existed. The baseline already solved this for packageable files (`packageableFingerprints`, 0.40.0) and for `components.conf` (the static-components anchor), and both consumers compare content, not dirtiness; the build inputs were the one family with no fingerprint, because `isPackageablePath` excludes them by design and nothing else recorded them.
115
+ `last-build.json` now carries `buildInputFingerprints` — a content hash per dirty `jar.mn` / `moz.build` / `moz.configure` / `Makefile.in` / `mozbuild.in` — and `build-prepare` drops any input whose live hash still matches before deciding either escalation. The two halves refresh on different rules, and the difference is load-bearing: backend inputs are refreshed on every successful write, because every baseline-writing path ran the auto-configure preflight against the previous record first; `jar.mn` entries are refreshed only by a FULL build and carried forward verbatim on a `faster` one, because `fireforge build --ui` is a `mach build faster` that never escalates, and recording a `jar.mn` no full build has installed would silence the next escalation. `build --ui` therefore now says which kind of build it was (`writeBuildBaseline`'s new `buildKind`), and passes the previous baseline it was already holding. An unreadable file, a missing entry, or a marker written before the field existed all count as changed — "cannot prove unchanged" never becomes "unchanged". The stale-build preflight's hash loop became the shared `dropPathsMatchingFingerprints` so both consumers compare the same way.
116
+ **On the escalation itself** — the downstream ask was a scoped install-manifest refresh for jar-only registrations, or a reason full compilation is necessary. Read against mozbuild: every `mach build`, `faster` included, first runs `backend_out_of_date` and regenerates the backend through `config.status` when any `backend.*Backend.in` dependency is newer, and `jar.mn` files are such dependencies; the FasterMake backend consumes `JARManifest` objects for `dist/bin` targets into its own install manifests. So for an entry added to an EXISTING `jar.mn` under `dist/bin` — the Furnace widget case — a `faster` build likely installs the destination, and the 0.41.0 rule ("full build for new `jar.mn` registrations") looks over-broad. It is NOT relaxed here: the rule came from an observed failure whose specifics were not recorded, and the claim needs a real objdir to prove, which a source reading is not. The seam is already available to prove it with: `fireforge build --ui` never escalates, so a jar-only registration followed by `build --ui`, a check that `obj-*/faster/install_dist_bin` names the new destination and the file landed under `dist/bin`, then a plain `fireforge test`, is the experiment; two clean runs of it make narrowing the escalation (to new `jar.mn` files and non-`dist/bin` targets) a safe change. Recorded in `docs/testing.md` so the next reader inherits the analysis rather than the guess. Also recorded, not fixed: `build --ui` ignores `fullBuildRequired` and `package` runs `prepareBuildEnvironment` with no baseline at all, so neither auto-configures nor escalates.
111
117
 
112
118
  ### Drift collapsed
113
119
 
@@ -161,6 +167,7 @@
161
167
  - `RebasePatchEntry` became a discriminated union on `status`, so a status flip can no longer strand the previous status's payload — which `rebase --continue` did, persisting `{ status: 'resolved', error, conflictingFiles }` to the session file. The on-disk validator is deliberately NOT tightened to match: the session file carries no schema version, so a stricter validator would refuse a 0.43.x file mid-rebase, where the only remedies discard the operator's conflict resolution. Entries are normalized on read instead, and two tests pin that an older session still loads.
162
168
  - **A `patches.json` from a newer FireForge was reported as malformed.** `patches.json version must be 1` sent the operator looking for damage in a file that only needed a newer build to read it, and the manifest had no migration hook at all. It now refuses by name — naming the found and supported schema versions and telling the operator not to delete the file — while a version below 1 or non-integer keeps the plain malformed message, because that one really is malformed.
163
169
  - The three JSDoc walkers in `patch-lint-jsdoc.ts` return their issues instead of pushing into an out-param, matching the ~30 other patch-lint entry points. The new `jsdoc/check-param-names` rule immediately caught the stale `@param issues` the change left behind — which is the point of enabling it.
170
+ - **`furnace create --test-dir` was honoured by the scaffolder and ignored by everything that told the operator where the files went.** `create.ts` resolved the override once and passed it to the scaffolders, but `formatDryRunPlan` and `formatSuccessNote` received only `binaryName` and recomputed the DEFAULT from it — so `--dry-run` planned `test/hominis/` plus `hominis/browser.toml` in `moz.build` for a command that would write to `test/hominis-tiles/`, and the success note after the real run named `hominis/` while the files sat in `hominis-tiles/`. The one message an operator gets as confirmation was the one thing that could not be trusted. Both formatters now take the resolved directory, and the default lives in one place: `resolveBrowserChromeTestDir` / `resolveXpcshellTestDir` in `furnace-constants.ts`, called by the scaffolders AND the formatters, so the two cannot disagree again. The moz.build registration line follows the override too (it is the path below `browser/base/content/test/`, which under `--test-dir` is not the binary name), and a test scaffolds for real and asserts the printed directory is the one on disk — default and overridden, browser-chrome and xpcshell.
164
171
 
165
172
  ### Windows
166
173
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.3",
4
- "commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
5
- "shortCommit": "760172104f6b",
3
+ "version": "0.44.4",
4
+ "commit": "b8742ddc056124f81624d9c13d182d0b0c57c86c",
5
+ "shortCommit": "b8742ddc0561",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-28T15:34:58.794Z"
8
+ "buildTime": "2026-08-28T23:48:06.918Z"
9
9
  }
@@ -342,13 +342,18 @@ async function runBuildCommandBody(projectRoot, options) {
342
342
  // Record a fresh baseline only on clean success so the next run audits
343
343
  // against this build's HEAD. A failed build keeps the prior baseline so
344
344
  // the next attempt still catches long-standing packaging drops.
345
- await recordFullBuildBaseline(projectRoot, paths.engine, config.binaryName, options.ui === true);
345
+ await recordFullBuildBaseline(projectRoot, paths.engine, config.binaryName, options.ui === true, previousBaseline);
346
346
  outro(`Build completed in ${timeStr}!`);
347
347
  }
348
- /** Persists the full-coverage baseline; a failed write never fails the build. */
349
- async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui) {
348
+ /**
349
+ * Persists the full-coverage baseline; a failed write never fails the build.
350
+ * `--ui` is a `mach build faster`, so it is recorded as such: its `jar.mn`
351
+ * fingerprints are carried forward from the previous baseline rather than
352
+ * refreshed, which is why that baseline is threaded through.
353
+ */
354
+ async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui, previousBaseline) {
350
355
  try {
351
- await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', undefined, ui ? 'fireforge build --ui' : 'fireforge build');
356
+ await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', previousBaseline, ui ? 'fireforge build --ui' : 'fireforge build', 'auto', ui ? 'faster' : 'full');
352
357
  }
353
358
  catch (baselineError) {
354
359
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -14,6 +14,15 @@ import type { ResolvedTestStyle } from '../../types/furnace.js';
14
14
  * tree, so a redirect makes no sense there.
15
15
  */
16
16
  export declare function resolveValidatedTestDir(rawTestDir: string | undefined, testStyle: ResolvedTestStyle): string | undefined;
17
+ /**
18
+ * Scaffolds browser mochitest files for a newly created custom component.
19
+ * @param componentName - Custom element tag name
20
+ * @param license - Project license used for generated headers
21
+ * @param forgeConfig - Project config fields needed for test naming
22
+ * @param paths - Resolved project paths used to place test files
23
+ * @param journal - Optional rollback journal that snapshots files before writes
24
+ * @returns Relative test filenames created or updated for the component
25
+ */
17
26
  /**
18
27
  * Normalizes and validates a `--test-dir` override: engine-relative,
19
28
  * under `browser/base/content/test/` (so manifest registration keeps
@@ -7,6 +7,7 @@
7
7
  * the per-file line budget.
8
8
  */
9
9
  import { join } from 'node:path';
10
+ import { BROWSER_TEST_SCAFFOLD_ROOT, resolveBrowserChromeTestDir, } from '../../core/furnace-constants.js';
10
11
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
11
12
  import { getLicenseHeader } from '../../core/license-headers.js';
12
13
  import { registerTestManifest } from '../../core/moz-manifest-register.js';
@@ -40,7 +41,6 @@ export function resolveValidatedTestDir(rawTestDir, testStyle) {
40
41
  * @param journal - Optional rollback journal that snapshots files before writes
41
42
  * @returns Relative test filenames created or updated for the component
42
43
  */
43
- const TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
44
44
  /**
45
45
  * Normalizes and validates a `--test-dir` override: engine-relative,
46
46
  * under `browser/base/content/test/` (so manifest registration keeps
@@ -48,9 +48,9 @@ const TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
48
48
  */
49
49
  export function resolveTestDirOverride(raw) {
50
50
  const normalized = stripEnginePrefix(raw).replace(/\/+$/, '');
51
- if (!normalized.startsWith(TEST_SCAFFOLD_ROOT) ||
52
- normalized === TEST_SCAFFOLD_ROOT.slice(0, -1)) {
53
- throw new InvalidArgumentError(`--test-dir must be an engine-relative directory under ${TEST_SCAFFOLD_ROOT} (got "${raw}").`, 'testDir');
51
+ if (!normalized.startsWith(BROWSER_TEST_SCAFFOLD_ROOT) ||
52
+ normalized === BROWSER_TEST_SCAFFOLD_ROOT.slice(0, -1)) {
53
+ throw new InvalidArgumentError(`--test-dir must be an engine-relative directory under ${BROWSER_TEST_SCAFFOLD_ROOT} (got "${raw}").`, 'testDir');
54
54
  }
55
55
  return normalized;
56
56
  }
@@ -78,8 +78,8 @@ export async function scaffoldTestFiles(componentName, license, forgeConfig, pat
78
78
  // `.../test/<binaryName>/` target can collide with a test suite owned by a
79
79
  // different patch. The manifest-registration name is the path below
80
80
  // browser/base/content/test/ (nested manifests are supported).
81
- const testDirRel = testDirOverride ?? `${TEST_SCAFFOLD_ROOT}${binaryName}`;
82
- const testDirName = testDirRel.slice(TEST_SCAFFOLD_ROOT.length);
81
+ const testDirRel = resolveBrowserChromeTestDir(binaryName, testDirOverride);
82
+ const testDirName = testDirRel.slice(BROWSER_TEST_SCAFFOLD_ROOT.length);
83
83
  const testDir = join(paths.engine, testDirRel);
84
84
  if (journal && !(await pathExists(testDir))) {
85
85
  recordCreatedDir(journal, testDir);
@@ -15,6 +15,14 @@ export interface DryRunPlanInput {
15
15
  testStyle: ResolvedTestStyle;
16
16
  description: string;
17
17
  binaryName: string;
18
+ /**
19
+ * Resolved `--test-dir` override (engine-relative, already validated by
20
+ * `resolveValidatedTestDir`). Omit for the default `<binaryName>`-derived
21
+ * scaffold directory. The formatters MUST receive the same value the
22
+ * scaffolders get — the plan used to recompute the default from
23
+ * `binaryName` and named a directory the real run never wrote to.
24
+ */
25
+ testDir?: string;
18
26
  }
19
27
  /**
20
28
  * Builds the success-note body printed after `furnace create` has applied
@@ -27,6 +35,8 @@ export declare function formatSuccessNote(args: {
27
35
  testFiles: string[];
28
36
  testStyle: ResolvedTestStyle;
29
37
  binaryName: string;
38
+ /** Resolved `--test-dir` override; see {@link DryRunPlanInput.testDir}. */
39
+ testDir?: string | undefined;
30
40
  }): string;
31
41
  /**
32
42
  * Builds the planned component + test file list for a dry-run preview.
@@ -7,6 +7,7 @@
7
7
  * resolved by the command's validation phase — so it can be exercised
8
8
  * independently of the mutation plumbing.
9
9
  */
10
+ import { BROWSER_TEST_SCAFFOLD_ROOT, resolveBrowserChromeTestDir, resolveXpcshellTestDir, } from '../../core/furnace-constants.js';
10
11
  /**
11
12
  * Builds the test-section fragment of the dry-run plan for a given
12
13
  * harness choice. Kept separate from the top-level formatter so the
@@ -14,7 +15,7 @@
14
15
  * complexity budget.
15
16
  */
16
17
  function formatTestSection(args) {
17
- const { testStyle, componentName, binaryName } = args;
18
+ const { testStyle, componentName, binaryName, testDir } = args;
18
19
  if (testStyle === 'none')
19
20
  return '';
20
21
  const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
@@ -23,14 +24,18 @@ function formatTestSection(args) {
23
24
  : strippedName;
24
25
  const underscored = withoutBinaryPrefix.replace(/-/g, '_');
25
26
  if (testStyle === 'browser-chrome') {
26
- const testRoot = `engine/browser/base/content/test/${binaryName}/`;
27
- return (`\n\nWould create test files in ${testRoot}:\n` +
27
+ // Same resolver as scaffoldTestFiles: the registration name is the
28
+ // path below browser/base/content/test/, which under --test-dir is
29
+ // NOT the binary name.
30
+ const testDirRel = resolveBrowserChromeTestDir(binaryName, testDir);
31
+ const manifestName = testDirRel.slice(BROWSER_TEST_SCAFFOLD_ROOT.length);
32
+ return (`\n\nWould create test files in engine/${testDirRel}/:\n` +
28
33
  ` browser.toml\n head.js\n browser_${binaryName}_${underscored}.js` +
29
- `\n\nWould register ${binaryName}/browser.toml in engine/browser/base/moz.build`);
34
+ `\n\nWould register ${manifestName}/browser.toml in engine/browser/base/moz.build`);
30
35
  }
31
36
  if (testStyle === 'xpcshell') {
32
- const testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
33
- return `\n\nWould create xpcshell test files in ${testRoot}`;
37
+ const testDirRel = resolveXpcshellTestDir(binaryName, componentName, testDir);
38
+ return `\n\nWould create xpcshell test files in engine/${testDirRel}/`;
34
39
  }
35
40
  // testStyle === 'mochikit' (last remaining branch in ResolvedTestStyle).
36
41
  const testRoot = 'engine/toolkit/content/tests/widgets/';
@@ -42,19 +47,19 @@ function formatTestSection(args) {
42
47
  * stay in lock-step when the scaffolded layout changes.
43
48
  */
44
49
  export function formatSuccessNote(args) {
45
- const { componentName, files, testFiles, testStyle, binaryName } = args;
50
+ const { componentName, files, testFiles, testStyle, binaryName, testDir } = args;
46
51
  let note = `Files created in components/custom/${componentName}/:\n` +
47
52
  files.map((f) => ` ${f}`).join('\n');
48
53
  if (testFiles.length > 0) {
49
54
  let testRoot;
50
55
  if (testStyle === 'xpcshell') {
51
- testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
56
+ testRoot = `engine/${resolveXpcshellTestDir(binaryName, componentName, testDir)}/`;
52
57
  }
53
58
  else if (testStyle === 'mochikit') {
54
59
  testRoot = 'engine/toolkit/content/tests/widgets/';
55
60
  }
56
61
  else {
57
- testRoot = `engine/browser/base/content/test/${binaryName}/`;
62
+ testRoot = `engine/${resolveBrowserChromeTestDir(binaryName, testDir)}/`;
58
63
  }
59
64
  note += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
60
65
  }
@@ -75,7 +80,7 @@ export function formatSuccessNote(args) {
75
80
  * `components/custom/` to match the wording of the real success note.
76
81
  */
77
82
  export function formatDryRunPlan(args) {
78
- const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, } = args;
83
+ const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, testDir, } = args;
79
84
  const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
85
  // A per-component .ftl is scaffolded only when the component does NOT
81
86
  // opt into a shared feature-scoped bundle. Mirrors writeComponentFiles.
@@ -83,7 +88,7 @@ export function formatDryRunPlan(args) {
83
88
  componentFiles.push(`${componentName}.ftl`);
84
89
  let plan = `Would create files in components/custom/${componentName}/:\n` +
85
90
  componentFiles.map((f) => ` ${f}`).join('\n');
86
- plan += formatTestSection({ testStyle, componentName, binaryName });
91
+ plan += formatTestSection({ testStyle, componentName, binaryName, testDir });
87
92
  plan += `\n\nWould add custom entry to furnace.json:`;
88
93
  plan += `\n name: ${componentName}`;
89
94
  plan += `\n description: ${description || '(empty)'}`;
@@ -5,7 +5,7 @@
5
5
  * per-file LOC budget and the scaffolder is unit-testable in isolation.
6
6
  */
7
7
  import { join } from 'node:path';
8
- import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
8
+ import { resolveXpcshellTestDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
9
9
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
10
10
  import { getLicenseHeader } from '../../core/license-headers.js';
11
11
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
@@ -31,7 +31,7 @@ export async function scaffoldXpcshellTestFiles(componentName, license, forgeCon
31
31
  const parentDirName = parentRelDir.split('/').slice(-1)[0] ?? `${forgeConfig.binaryName}-xpcshell`;
32
32
  // --test-dir names the FINAL directory (no per-component segment is
33
33
  // appended) so the operator controls the exact scaffold target.
34
- const testDirRel = testDirOverride ?? `${parentRelDir}/${componentName}`;
34
+ const testDirRel = resolveXpcshellTestDir(forgeConfig.binaryName, componentName, testDirOverride);
35
35
  const testDir = join(paths.engine, testDirRel);
36
36
  if (journal && !(await pathExists(testDir))) {
37
37
  recordCreatedDir(journal, testDir);
@@ -356,6 +356,10 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
356
356
  testStyle,
357
357
  description,
358
358
  binaryName: forgeConfig.binaryName,
359
+ // The plan must name the directory the scaffolder will actually use:
360
+ // an omitted override here is how the printed path came to disagree
361
+ // with the files a real run wrote.
362
+ ...(testDir !== undefined ? { testDir } : {}),
359
363
  });
360
364
  note(plan, componentName);
361
365
  outro('Dry run complete (no files modified)');
@@ -391,6 +395,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
391
395
  testFiles,
392
396
  testStyle,
393
397
  binaryName: forgeConfig.binaryName,
398
+ ...(testDir !== undefined ? { testDir } : {}),
394
399
  }), componentName);
395
400
  outro('Component created');
396
401
  }
@@ -84,7 +84,7 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
84
84
  ? 'refresh'
85
85
  : extend !== undefined
86
86
  ? 'carry-forward'
87
- : 'auto');
87
+ : 'auto', buildKind);
88
88
  }
89
89
  catch (baselineError) {
90
90
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -1,4 +1,24 @@
1
1
  import type { BuildBaseline } from './build-baseline-types.js';
2
+ /**
3
+ * True for the build-input manifests the build consumes but never packages
4
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
5
+ * Excluded from the packaging audit and from `packageableFingerprints`;
6
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
7
+ * can tell "dirty against HEAD" from "changed since the last successful
8
+ * build".
9
+ *
10
+ * @param sourcePath Engine-relative POSIX path.
11
+ */
12
+ export declare function isBuildInputPath(sourcePath: string): boolean;
13
+ /**
14
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
15
+ * destinations are the one build input whose change escalates a pre-test
16
+ * `mach build faster` to a full build, and whose fingerprint is therefore
17
+ * refreshed only by a full build.
18
+ *
19
+ * @param sourcePath Engine-relative POSIX path.
20
+ */
21
+ export declare function isJarManifestPath(sourcePath: string): boolean;
2
22
  /** Result of a single artifact lookup. */
3
23
  export interface AuditEntry {
4
24
  /** Engine-relative source file path (POSIX separators). */
@@ -74,6 +74,30 @@ const BUILD_INPUT_BASENAMES = new Set([
74
74
  'Makefile.in',
75
75
  'mozbuild.in',
76
76
  ]);
77
+ /**
78
+ * True for the build-input manifests the build consumes but never packages
79
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
80
+ * Excluded from the packaging audit and from `packageableFingerprints`;
81
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
82
+ * can tell "dirty against HEAD" from "changed since the last successful
83
+ * build".
84
+ *
85
+ * @param sourcePath Engine-relative POSIX path.
86
+ */
87
+ export function isBuildInputPath(sourcePath) {
88
+ return BUILD_INPUT_BASENAMES.has(basename(sourcePath));
89
+ }
90
+ /**
91
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
92
+ * destinations are the one build input whose change escalates a pre-test
93
+ * `mach build faster` to a full build, and whose fingerprint is therefore
94
+ * refreshed only by a full build.
95
+ *
96
+ * @param sourcePath Engine-relative POSIX path.
97
+ */
98
+ export function isJarManifestPath(sourcePath) {
99
+ return sourcePath === 'jar.mn' || sourcePath.endsWith('/jar.mn');
100
+ }
77
101
  /**
78
102
  * Decides whether a source path should be packaged. Returns true for paths
79
103
  * whose extension or directory fragment matches a known-packaged pattern,
@@ -88,7 +112,7 @@ export function isPackageablePath(sourcePath) {
88
112
  if (sourcePath.includes(fragment))
89
113
  return false;
90
114
  }
91
- if (BUILD_INPUT_BASENAMES.has(basename(sourcePath)))
115
+ if (isBuildInputPath(sourcePath))
92
116
  return false;
93
117
  // `.inc.xhtml` fragments are consumed via `#include` from a registered
94
118
  // chrome document and resolved at packaging time — they never ship as a
@@ -35,6 +35,34 @@ export interface BuildBaseline {
35
35
  * a tracked deletion.
36
36
  */
37
37
  packageableFingerprints?: Record<string, string>;
38
+ /**
39
+ * Content hash per build-input manifest (`jar.mn`, `moz.build`,
40
+ * `moz.configure`, `Makefile.in`, `mozbuild.in`) that was dirty
41
+ * (modified-against-HEAD or untracked) when the recorded build succeeded.
42
+ * Same key/value shape as {@link BuildBaseline.packageableFingerprints}.
43
+ *
44
+ * Consumed by `build-prepare`: a dirty build input whose live hash still
45
+ * matches its entry was already consumed by the recorded build, so it
46
+ * neither re-runs `mach configure` nor escalates a pre-test build to a
47
+ * full `mach build`. Without this the persistent worktree diff every
48
+ * fork carries (imported patches + Furnace-applied components) made a
49
+ * patch-touched `jar.mn` "changed" on EVERY run, and each `test --build`
50
+ * after a full build paid the full build again.
51
+ *
52
+ * Refresh rules, per entry kind:
53
+ * - backend inputs (`moz.build` & co) are refreshed on every successful
54
+ * write — every baseline-writing path ran the auto-configure preflight
55
+ * against the previous baseline, so a changed one was reconfigured;
56
+ * - `jar.mn` entries are refreshed only by a FULL build and carried
57
+ * forward verbatim from the previous record on a `faster` write, since
58
+ * `fireforge build --ui` never escalates and recording a `jar.mn` no
59
+ * full build has installed would hide the next escalation.
60
+ *
61
+ * Absent on baselines written before the field existed; `build-prepare`
62
+ * then falls back to the path-only comparison (every dirty build input
63
+ * counts as changed).
64
+ */
65
+ buildInputFingerprints?: Record<string, string>;
38
66
  /**
39
67
  * What the packaged test runtime produced by the recorded build covers.
40
68
  *
@@ -16,6 +16,15 @@
16
16
  * subsequent run still audits against the last known-good tree.
17
17
  */
18
18
  import { type BuildBaseline, type TestPackagingCoverage } from './build-baseline-types.js';
19
+ /**
20
+ * Which mach build produced the baseline being written. Decides whether
21
+ * the `jar.mn` half of {@link BuildBaseline.buildInputFingerprints} is
22
+ * refreshed (`'full'`) or carried forward from the previous record
23
+ * (`'faster'`). `fireforge build --ui` and a non-escalated `test --build`
24
+ * are `'faster'`; `fireforge build` and an escalated `test --build` are
25
+ * `'full'`.
26
+ */
27
+ export type BaselineBuildKind = 'full' | 'faster';
19
28
  /** Name of the last-build marker file under `.fireforge/`. */
20
29
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
21
30
  /**
@@ -56,5 +65,11 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
56
65
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
57
66
  * can EVALUATE to `'full'` while the build that produced it was still a
58
67
  * scoped `mach build faster` that did not rebake the compiled table.
68
+ * @param buildKind - Which mach build produced this baseline (default
69
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
70
+ * fingerprints forward instead of refreshing them — see
71
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
72
+ * `mach build faster` MUST say so, or the next pre-test build skips an
73
+ * escalation no full build has honoured.
59
74
  */
60
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward'): Promise<void>;
75
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward', buildKind?: BaselineBuildKind): Promise<void>;
@@ -23,7 +23,7 @@ import { mapWithConcurrency } from '../utils/concurrency.js';
23
23
  import { getNodeErrorCode, toError } from '../utils/errors.js';
24
24
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
25
25
  import { verbose } from '../utils/logger.js';
26
- import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
26
+ import { isBuildInputPath, isJarManifestPath, isPackageablePath, isXpcomManifestPath, } from './build-audit.js';
27
27
  import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
28
28
  import { FIREFORGE_DIR } from './config-paths.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
@@ -90,8 +90,14 @@ export async function readBuildBaseline(projectRoot) {
90
90
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
91
91
  * can EVALUATE to `'full'` while the build that produced it was still a
92
92
  * scoped `mach build faster` that did not rebake the compiled table.
93
+ * @param buildKind - Which mach build produced this baseline (default
94
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
95
+ * fingerprints forward instead of refreshing them — see
96
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
97
+ * `mach build faster` MUST say so, or the next pre-test build skips an
98
+ * escalation no full build has honoured.
93
99
  */
94
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto') {
100
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto', buildKind = 'full') {
95
101
  let engineHeadSha = '';
96
102
  try {
97
103
  engineHeadSha = await getHead(engineDir);
@@ -106,6 +112,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
106
112
  }
107
113
  }
108
114
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
115
+ const buildInputFingerprints = await collectBuildInputFingerprints(engineDir, previousBaseline, buildKind);
109
116
  // A full-coverage write (and the legacy no-claim `fireforge build` shape)
110
117
  // just recompiled the StaticComponents table, so it anchors the table to
111
118
  // the current engine state. A scoped write did not — carry the previous
@@ -121,6 +128,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
121
128
  builtAt: new Date().toISOString(),
122
129
  binaryName,
123
130
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
131
+ ...(buildInputFingerprints !== undefined ? { buildInputFingerprints } : {}),
124
132
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
125
133
  ...(mozconfigHash !== undefined ? { mozconfigHash } : {}),
126
134
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
@@ -143,6 +151,34 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
143
151
  async function collectPackageableFingerprints(engineDir) {
144
152
  return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
145
153
  }
154
+ /**
155
+ * Content fingerprints of the dirty build-input manifests
156
+ * ({@link isBuildInputPath}) this build consumed. Backend inputs
157
+ * (`moz.build` & co) are always taken from the live tree: the
158
+ * auto-configure preflight reconfigured against them before this build
159
+ * ran. `jar.mn` entries are taken from the live tree only after a FULL
160
+ * build; a `faster` write carries the previous record's `jar.mn` entries
161
+ * forward verbatim, because `mach build faster` is exactly the build the
162
+ * `jar.mn` escalation exists to bypass, and recording a `jar.mn` it did
163
+ * not install would silence the next escalation. Returns `undefined` on
164
+ * probe failure so the field is omitted rather than written as `{}`.
165
+ */
166
+ async function collectBuildInputFingerprints(engineDir, previousBaseline, buildKind) {
167
+ const live = await collectDirtyFingerprints(engineDir, isBuildInputPath, 'build-input fingerprint');
168
+ if (live === undefined || buildKind === 'full') {
169
+ return live;
170
+ }
171
+ const fingerprints = {};
172
+ for (const [path, hash] of Object.entries(live)) {
173
+ if (!isJarManifestPath(path))
174
+ fingerprints[path] = hash;
175
+ }
176
+ for (const [path, hash] of Object.entries(previousBaseline?.buildInputFingerprints ?? {})) {
177
+ if (isJarManifestPath(path))
178
+ fingerprints[path] = hash;
179
+ }
180
+ return fingerprints;
181
+ }
146
182
  /**
147
183
  * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
148
184
  * this (full) build plus content fingerprints of the `components.conf`
@@ -11,8 +11,9 @@ import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, notice, spinner, verbose, warn } from '../utils/logger.js';
13
13
  import { isBrandingSetup, setupBranding } from './branding.js';
14
+ import { isBuildInputPath, isJarManifestPath } from './build-audit.js';
14
15
  import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
15
- import { collectChangedEnginePaths } from './engine-changes.js';
16
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
16
17
  import { applyAllComponents } from './furnace-apply.js';
17
18
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
18
19
  import { runFurnaceMutation } from './furnace-operation.js';
@@ -24,7 +25,35 @@ import { explainMachError } from './mach-error-hints.js';
24
25
  const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
25
26
  /** Packaging manifests whose graph/destination directories need a full build. */
26
27
  export function requiresFullBuildForIncrementalTest(path) {
27
- return path === 'jar.mn' || path.endsWith('/jar.mn');
28
+ return isJarManifestPath(path);
29
+ }
30
+ /**
31
+ * Build-input manifests changed since the last SUCCESSFUL build — not
32
+ * merely dirty against HEAD. `collectChangedEnginePaths` unions the
33
+ * baseline-to-HEAD diff with every worktree modification, and a fork's
34
+ * worktree is permanently dirty (imported patches, Furnace-applied
35
+ * components), so on its own it reports a patch-touched `jar.mn` or
36
+ * `moz.build` as changed on every run: `mach configure` re-ran and the
37
+ * pre-test build escalated to a full `mach build` after each full build
38
+ * that had already consumed exactly this content. The baseline's
39
+ * `buildInputFingerprints` say what the last successful build saw;
40
+ * anything byte-identical to that is dropped here. A baseline without the
41
+ * field (written before it existed) keeps every dirty input, which is the
42
+ * old behaviour.
43
+ *
44
+ * @param engineDir - Path to the engine directory
45
+ * @param baseline - Last successful build's baseline
46
+ * @returns Engine-relative build-input paths that still need acting on
47
+ */
48
+ async function collectChangedBuildInputs(engineDir, baseline) {
49
+ const changed = await collectChangedEnginePaths(engineDir, baseline, 'Auto-configure');
50
+ const dirtyInputs = changed.filter(isBuildInputPath);
51
+ const stillChanged = await dropPathsMatchingFingerprints(engineDir, dirtyInputs, baseline.buildInputFingerprints);
52
+ const unchangedSinceBuild = dirtyInputs.filter((path) => !stillChanged.includes(path));
53
+ if (unchangedSinceBuild.length > 0) {
54
+ verbose(`Auto-configure: ${unchangedSinceBuild.length} dirty build input(s) byte-identical to the last successful build, not treated as changed: ${unchangedSinceBuild.join(', ')}`);
55
+ }
56
+ return stillChanged;
28
57
  }
29
58
  /**
30
59
  * Extracts the tail of captured `mach configure` output so the underlying
@@ -149,7 +178,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
149
178
  let reconfigured = false;
150
179
  let fullBuildRequired = false;
151
180
  if (options.previousBaseline) {
152
- const changed = await collectChangedEnginePaths(paths.engine, options.previousBaseline, 'Auto-configure');
181
+ const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
153
182
  const invalidating = changed.filter(isBackendInvalidatingFile);
154
183
  fullBuildRequired = changed.some(requiresFullBuildForIncrementalTest);
155
184
  if (invalidating.length > 0) {
@@ -20,3 +20,21 @@ import type { BuildBaseline } from './build-baseline-types.js';
20
20
  * @returns Sorted engine-relative POSIX paths
21
21
  */
22
22
  export declare function collectChangedEnginePaths(engineDir: string, baseline: BuildBaseline | undefined, contextLabel: string): Promise<string[]>;
23
+ /**
24
+ * Drops every path whose live content still matches the fingerprint the
25
+ * last successful build recorded for it, keeping the rest. Shared by the
26
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
27
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
28
+ *
29
+ * A path with no recorded entry, an unreadable file, or a differing hash
30
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
31
+ * With no fingerprint set at all (a baseline written before the field
32
+ * existed) every path is kept, which is the path-only comparison the
33
+ * fingerprints refine.
34
+ *
35
+ * @param engineDir - Path to the engine directory
36
+ * @param paths - Candidate engine-relative POSIX paths
37
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
38
+ * @returns The subset of `paths` that cannot be proven unchanged
39
+ */
40
+ export declare function dropPathsMatchingFingerprints(engineDir: string, paths: readonly string[], fingerprints: Record<string, string> | undefined): Promise<string[]>;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { toError } from '../utils/errors.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { hashEngineFile } from './coverage-extend.js';
9
10
  import { hasChanges, isMissingHeadError } from './git.js';
10
11
  import { git } from './git-base.js';
11
12
  import { getUntrackedFiles } from './git-status.js';
@@ -59,3 +60,36 @@ export async function collectChangedEnginePaths(engineDir, baseline, contextLabe
59
60
  }
60
61
  return [...collected].sort();
61
62
  }
63
+ /**
64
+ * Drops every path whose live content still matches the fingerprint the
65
+ * last successful build recorded for it, keeping the rest. Shared by the
66
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
67
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
68
+ *
69
+ * A path with no recorded entry, an unreadable file, or a differing hash
70
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
71
+ * With no fingerprint set at all (a baseline written before the field
72
+ * existed) every path is kept, which is the path-only comparison the
73
+ * fingerprints refine.
74
+ *
75
+ * @param engineDir - Path to the engine directory
76
+ * @param paths - Candidate engine-relative POSIX paths
77
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
78
+ * @returns The subset of `paths` that cannot be proven unchanged
79
+ */
80
+ export async function dropPathsMatchingFingerprints(engineDir, paths, fingerprints) {
81
+ if (!fingerprints)
82
+ return [...paths];
83
+ const kept = [];
84
+ for (const path of paths) {
85
+ const recorded = fingerprints[path];
86
+ if (recorded === undefined) {
87
+ kept.push(path);
88
+ continue;
89
+ }
90
+ const live = await hashEngineFile(engineDir, path);
91
+ if (live === undefined || live !== recorded)
92
+ kept.push(path);
93
+ }
94
+ return kept;
95
+ }
@@ -12,12 +12,33 @@ export declare const JAR_MN = "toolkit/content/jar.mn";
12
12
  export declare const WIDGETS_DIR = "toolkit/content/widgets";
13
13
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
14
14
  export declare const FTL_DIR = "toolkit/locales/en-US/toolkit/global";
15
+ /**
16
+ * Engine-relative root every browser-chrome / xpcshell test scaffold lives
17
+ * under, trailing slash included. A `--test-dir` override must stay below
18
+ * it so the `browser/base/moz.build` manifest registration keeps working.
19
+ */
20
+ export declare const BROWSER_TEST_SCAFFOLD_ROOT = "browser/base/content/test/";
15
21
  /**
16
22
  * Returns the engine-relative directory that holds xpcshell scaffolds for
17
23
  * a given binary. Matches the form `create-xpcshell.ts` writes and the
18
24
  * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
19
25
  */
20
26
  export declare function xpcshellTestParentDir(binaryName: string): string;
27
+ /**
28
+ * Resolves the engine-relative directory a browser-chrome scaffold is
29
+ * written to: the `--test-dir` override when given, else
30
+ * `browser/base/content/test/<binaryName>`. The scaffolder AND the
31
+ * dry-run / success formatters resolve through this one function, so the
32
+ * printed plan cannot disagree with the files that land on disk.
33
+ */
34
+ export declare function resolveBrowserChromeTestDir(binaryName: string, override?: string): string;
35
+ /**
36
+ * Resolves the engine-relative directory an xpcshell scaffold is written
37
+ * to. A `--test-dir` override names the FINAL directory (no per-component
38
+ * segment is appended); the default is
39
+ * `browser/base/content/test/<binaryName>-xpcshell/<componentName>`.
40
+ */
41
+ export declare function resolveXpcshellTestDir(binaryName: string, componentName: string, override?: string): string;
21
42
  /** Returns true when `fileName` has one of the standard component file extensions. */
22
43
  export declare function isComponentSourceFile(fileName: string): boolean;
23
44
  /**
@@ -13,6 +13,12 @@ export const JAR_MN = 'toolkit/content/jar.mn';
13
13
  export const WIDGETS_DIR = 'toolkit/content/widgets';
14
14
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
15
15
  export const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
16
+ /**
17
+ * Engine-relative root every browser-chrome / xpcshell test scaffold lives
18
+ * under, trailing slash included. A `--test-dir` override must stay below
19
+ * it so the `browser/base/moz.build` manifest registration keeps working.
20
+ */
21
+ export const BROWSER_TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
16
22
  /**
17
23
  * Suffix for the per-binary xpcshell scaffold parent directory. Components
18
24
  * created with `furnace create --with-tests --xpcshell` land at
@@ -27,7 +33,26 @@ const XPCSHELL_TEST_DIR_SUFFIX = '-xpcshell';
27
33
  * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
28
34
  */
29
35
  export function xpcshellTestParentDir(binaryName) {
30
- return `browser/base/content/test/${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
36
+ return `${BROWSER_TEST_SCAFFOLD_ROOT}${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
37
+ }
38
+ /**
39
+ * Resolves the engine-relative directory a browser-chrome scaffold is
40
+ * written to: the `--test-dir` override when given, else
41
+ * `browser/base/content/test/<binaryName>`. The scaffolder AND the
42
+ * dry-run / success formatters resolve through this one function, so the
43
+ * printed plan cannot disagree with the files that land on disk.
44
+ */
45
+ export function resolveBrowserChromeTestDir(binaryName, override) {
46
+ return override ?? `${BROWSER_TEST_SCAFFOLD_ROOT}${binaryName}`;
47
+ }
48
+ /**
49
+ * Resolves the engine-relative directory an xpcshell scaffold is written
50
+ * to. A `--test-dir` override names the FINAL directory (no per-component
51
+ * segment is appended); the default is
52
+ * `browser/base/content/test/<binaryName>-xpcshell/<componentName>`.
53
+ */
54
+ export function resolveXpcshellTestDir(binaryName, componentName, override) {
55
+ return override ?? `${xpcshellTestParentDir(binaryName)}/${componentName}`;
31
56
  }
32
57
  /** File extensions that constitute a Furnace component's source files. */
33
58
  const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
@@ -69,7 +69,9 @@ export declare function hasUpstreamMplBlockHeader(content: string): boolean;
69
69
  export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
70
70
  /**
71
71
  * Returns true if the first few lines of `content` contain a recognized
72
- * upstream license identifier string.
72
+ * upstream license identifier string — Mozilla's MPL boilerplate, an SPDX
73
+ * tag, the common permissive/copyleft license names, or the CC0
74
+ * public-domain dedication upstream test files carry.
73
75
  *
74
76
  * @param content - File content to check
75
77
  * @param maxLines - Number of leading lines to inspect (default 10)
@@ -77,10 +79,12 @@ export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
77
79
  export declare function containsUpstreamLicenseText(content: string, maxLines?: number): boolean;
78
80
  /**
79
81
  * Returns true when the head of `content` carries a recognized THIRD-PARTY
80
- * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
81
- * comment style. Used by `export` to treat such files as vendored: offering
82
- * to prepend the project's own license header onto a byte-identity-pinned
83
- * upstream bundle would mislicense third-party code.
82
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0) or the
83
+ * CC0 public-domain dedication, in any comment style. Used by `export` to
84
+ * treat such files as vendored: offering to prepend the project's own
85
+ * license header onto a byte-identity-pinned upstream bundle — or onto a
86
+ * test file Mozilla dedicated to the public domain — would mislicense code
87
+ * the project does not own.
84
88
  *
85
89
  * @param content - File content to check
86
90
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -227,9 +227,31 @@ function normalizeLicenseHeadForScan(head) {
227
227
  s = s.replace(/\n[ \t]*\*[ \t]*/g, ' ');
228
228
  return s.replace(/\s+/g, ' ').trim();
229
229
  }
230
+ /**
231
+ * Recognizes the CC0 public-domain dedication Mozilla puts on most of its
232
+ * test files, in every spelling the tree carries:
233
+ *
234
+ * - the prose (`Any copyright is dedicated to the Public Domain.`), which
235
+ * the block, `//` and `#` comment forms all reduce to after
236
+ * {@link normalizeLicenseHeadForScan};
237
+ * - the dedication URL (`creativecommons.org/publicdomain/zero/1.0/`, with
238
+ * either scheme);
239
+ * - the SPDX identifier `CC0-1.0`.
240
+ *
241
+ * Deliberately narrow: a bare "public domain" mention, a different Creative
242
+ * Commons license (`licenses/by/4.0/`, `CC-BY-4.0`) or a made-up
243
+ * `publicdomain/zero/2.0/` is NOT a CC0 dedication and must not be
244
+ * accepted as one. `scanText` must already be normalized (and may be
245
+ * lowercased — the match is case-insensitive).
246
+ */
247
+ function hasCc0Dedication(scanText) {
248
+ return /dedicated to the public domain|creativecommons\.org\/publicdomain\/zero\/1\.0|spdx-license-identifier:\s*cc0-1\.0\b/i.test(scanText);
249
+ }
230
250
  /**
231
251
  * Returns true if the first few lines of `content` contain a recognized
232
- * upstream license identifier string.
252
+ * upstream license identifier string — Mozilla's MPL boilerplate, an SPDX
253
+ * tag, the common permissive/copyleft license names, or the CC0
254
+ * public-domain dedication upstream test files carry.
233
255
  *
234
256
  * @param content - File content to check
235
257
  * @param maxLines - Number of leading lines to inspect (default 10)
@@ -244,14 +266,16 @@ export function containsUpstreamLicenseText(content, maxLines = 10) {
244
266
  'MIT License',
245
267
  'GNU General Public License',
246
268
  ];
247
- return markers.some((marker) => scanText.includes(marker));
269
+ return markers.some((marker) => scanText.includes(marker)) || hasCc0Dedication(scanText);
248
270
  }
249
271
  /**
250
272
  * Returns true when the head of `content` carries a recognized THIRD-PARTY
251
- * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
252
- * comment style. Used by `export` to treat such files as vendored: offering
253
- * to prepend the project's own license header onto a byte-identity-pinned
254
- * upstream bundle would mislicense third-party code.
273
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0) or the
274
+ * CC0 public-domain dedication, in any comment style. Used by `export` to
275
+ * treat such files as vendored: offering to prepend the project's own
276
+ * license header onto a byte-identity-pinned upstream bundle — or onto a
277
+ * test file Mozilla dedicated to the public domain — would mislicense code
278
+ * the project does not own.
255
279
  *
256
280
  * @param content - File content to check
257
281
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -271,7 +295,9 @@ export function hasThirdPartyPermissiveBanner(content, maxLines = 30) {
271
295
  ];
272
296
  if (markers.some((marker) => scanText.includes(marker)))
273
297
  return true;
274
- return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0)\b/.test(scanText);
298
+ if (hasCc0Dedication(scanText))
299
+ return true;
300
+ return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0|cc0-1\.0)\b/.test(scanText);
275
301
  }
276
302
  /**
277
303
  * Prepends the license header to a file on disk if it is not already present.
@@ -27,7 +27,7 @@ import { verbose, warn } from '../utils/logger.js';
27
27
  import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
28
28
  import { readBuildBaseline } from './build-baseline.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
30
- import { collectChangedEnginePaths } from './engine-changes.js';
30
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
31
31
  export { isXpcomManifestPath };
32
32
  /** Cap on the number of changed paths rendered inline. */
33
33
  const STALE_PATHS_LIMIT = 10;
@@ -60,18 +60,7 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
60
60
  // and the stale check fires immediately after a successful one. The
61
61
  // fingerprints capture "these files had this content when the build ran";
62
62
  // a path stays stale only when its live hash diverges.
63
- const fingerprints = baseline.packageableFingerprints;
64
- if (fingerprints) {
65
- const staleAfterHashCheck = [];
66
- for (const path of packageable) {
67
- const recorded = fingerprints[path];
68
- const live = await hashEngineFile(engineDir, path);
69
- if (recorded === undefined || live === undefined || recorded !== live) {
70
- staleAfterHashCheck.push(path);
71
- }
72
- }
73
- packageable = staleAfterHashCheck;
74
- }
63
+ packageable = await dropPathsMatchingFingerprints(engineDir, packageable, baseline.packageableFingerprints);
75
64
  if (packageable.length === 0) {
76
65
  return { stale: false, changedPaths: [], truncated: 0, baseline };
77
66
  }
package/docs/testing.md CHANGED
@@ -38,6 +38,37 @@ coverage instead of replacing it; it is refused when the build anchor moved
38
38
  (engine HEAD, `engine/mozconfig`, or a previously fingerprinted packageable
39
39
  file).
40
40
 
41
+ ### What a pre-test build costs
42
+
43
+ `test --build` runs `mach build faster`, with two escalations decided by
44
+ comparing build inputs against the **last successful build** recorded in
45
+ `.fireforge/last-build.json` — not against engine HEAD, since a fork's
46
+ worktree is permanently dirty (imported patches, Furnace-applied
47
+ components):
48
+
49
+ - a changed `moz.build` / `moz.configure` / `Makefile.in` runs
50
+ `mach configure` first;
51
+ - a changed `jar.mn` escalates the whole build to a full `mach build`
52
+ (minutes rather than seconds), so that new install-manifest destinations
53
+ exist before the run.
54
+
55
+ Each escalation is paid once per actual content change: a `jar.mn` that is
56
+ dirty against HEAD but byte-identical to what the last full build consumed
57
+ does not escalate again. `fireforge build --ui` is itself a
58
+ `mach build faster` and never escalates; the `jar.mn` record it writes is
59
+ carried forward from the previous full build, so a registration made
60
+ between a `build --ui` and the next `test --build` still triggers the full
61
+ build once.
62
+
63
+ Whether a full build is really required for an entry added to an existing
64
+ `jar.mn` under `dist/bin` is an open question, recorded in the 0.44.0
65
+ changelog: mozbuild regenerates its backend from a newer `jar.mn` on any
66
+ `mach build`, `faster` included, and the FasterMake backend consumes
67
+ `dist/bin` jar manifests. The escalation stays until that is demonstrated
68
+ end to end against a real objdir (`build --ui` after a jar-only
69
+ registration, then a plain `test`), because relaxing a stale-artifact guard
70
+ on source reading alone trades a slow build for a silently wrong test.
71
+
41
72
  ## Resilience
42
73
 
43
74
  - Recognized harness crashes retry up to `--harness-retries <n>` times
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.44.3",
3
+ "version": "0.44.4",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",