@hominis/fireforge 0.15.5 → 0.15.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +70 -5
  3. package/dist/src/commands/build.js +60 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
  6. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
  7. package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
  8. package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
  9. package/dist/src/commands/furnace/chrome-doc.js +37 -4
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +31 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +95 -0
  12. package/dist/src/commands/furnace/create-templates.js +14 -0
  13. package/dist/src/commands/furnace/create.js +28 -24
  14. package/dist/src/commands/furnace/index.js +3 -1
  15. package/dist/src/commands/lint.d.ts +17 -2
  16. package/dist/src/commands/lint.js +25 -2
  17. package/dist/src/commands/register.d.ts +1 -1
  18. package/dist/src/commands/register.js +30 -7
  19. package/dist/src/commands/test.js +16 -1
  20. package/dist/src/core/build-audit-platform.d.ts +3 -1
  21. package/dist/src/core/build-audit-platform.js +87 -20
  22. package/dist/src/core/build-audit-registration.d.ts +80 -0
  23. package/dist/src/core/build-audit-registration.js +187 -0
  24. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  25. package/dist/src/core/build-audit-transforms.js +94 -0
  26. package/dist/src/core/build-audit.js +210 -3
  27. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  28. package/dist/src/core/furnace-validate-registration.js +66 -6
  29. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  30. package/dist/src/core/mach-build-artifacts.js +104 -3
  31. package/dist/src/core/mach.d.ts +1 -1
  32. package/dist/src/core/mach.js +1 -1
  33. package/dist/src/core/test-stale-check.d.ts +42 -0
  34. package/dist/src/core/test-stale-check.js +114 -0
  35. package/dist/src/types/commands/options.d.ts +16 -0
  36. package/package.json +1 -1
@@ -26,6 +26,7 @@ import { FurnaceError } from '../../errors/furnace.js';
26
26
  import { pathExists, readText, writeText } from '../../utils/fs.js';
27
27
  import { intro, note, outro } from '../../utils/logger.js';
28
28
  import { generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, } from './chrome-doc-templates.js';
29
+ import { chromeDocPackagingTestFileName, generateChromeDocPackagingManifest, generateChromeDocPackagingTest, } from './chrome-doc-tests.js';
29
30
  /** Chrome-doc name shape: lowercase ASCII, optional hyphens, no leading digit. */
30
31
  const CHROME_DOC_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
31
32
  /**
@@ -123,6 +124,28 @@ async function performChromeDocMutations(args) {
123
124
  const localeJarMnPath = join(args.engineDir, 'browser/locales/jar.mn');
124
125
  await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
125
126
  written.push('browser/locales/jar.mn');
127
+ // --with-tests scaffolds an xpcshell packaging verification. All writes
128
+ // go through the same rollback journal so a SIGINT here restores the
129
+ // source files and jar.mn edits above alongside the test scaffold.
130
+ if (args.withTests) {
131
+ const testParentDir = `${args.binaryName}-xpcshell`;
132
+ const testDir = join(args.engineDir, 'browser/base/content/test', testParentDir, args.name);
133
+ const { ensureDir } = await import('../../utils/fs.js');
134
+ if (!(await pathExists(testDir))) {
135
+ recordCreatedDir(journal, testDir);
136
+ }
137
+ await ensureDir(testDir);
138
+ const hashHeader = getLicenseHeader(args.license, 'hash');
139
+ const testFileName = chromeDocPackagingTestFileName(args.name);
140
+ const testFilePath = join(testDir, testFileName);
141
+ await snapshotFile(journal, testFilePath);
142
+ await writeText(testFilePath, generateChromeDocPackagingTest(args.name, jsHeader));
143
+ written.push(`browser/base/content/test/${testParentDir}/${args.name}/${testFileName}`);
144
+ const manifestPath = join(testDir, 'xpcshell.toml');
145
+ await snapshotFile(journal, manifestPath);
146
+ await writeText(manifestPath, generateChromeDocPackagingManifest(args.name, hashHeader));
147
+ written.push(`browser/base/content/test/${testParentDir}/${args.name}/xpcshell.toml`);
148
+ }
126
149
  }
127
150
  catch (error) {
128
151
  await restoreRollbackJournalOrThrow(journal, `Failed to scaffold chrome-doc "${args.name}"`);
@@ -146,22 +169,32 @@ export async function furnaceChromeDocCreateCommand(projectRoot, name, options =
146
169
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first to scaffold a chrome-doc.');
147
170
  }
148
171
  const withTitlebar = options.titlebar ?? true;
172
+ const withTests = options.withTests ?? false;
149
173
  const written = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocMutations({
150
174
  name,
151
175
  license,
152
176
  engineDir,
153
177
  withTitlebar,
178
+ withTests,
179
+ binaryName: forgeConfig.binaryName,
154
180
  operationContext: ctx,
155
181
  }));
182
+ const nextSteps = [
183
+ ` 1. Edit engine/browser/base/content/${name}.xhtml and fill in the body.`,
184
+ ` 2. Localize strings in engine/browser/locales/en-US/browser/${name}.ftl.`,
185
+ ` 3. Run "fireforge build" to validate packaging (post-build audit will flag`,
186
+ ' any entry whose file does not land in the dist bundle).',
187
+ ];
188
+ if (withTests) {
189
+ nextSteps.push(` 4. Register the xpcshell test directory in the nearest moz.build under`, ` XPCSHELL_TESTS_MANIFESTS, then run "fireforge test browser/base/content/test/${forgeConfig.binaryName}-xpcshell/${name}/xpcshell.toml".`);
190
+ }
191
+ nextSteps.push('', 'Platform-module compatibility: this chrome document carries the', ` data-furnace-chrome-doc="${name}" sentinel on its root element. Upstream`, ' platform modules (DevToolsStartup, PageActions, SessionStore, …) observe', ' "browser-delayed-startup-finished" and walk INTO the window assuming the', ' browser.xhtml DOM; use the sentinel attribute as a guard in fork-side', ' patches to those modules. See README "Platform module compatibility".');
156
192
  note([
157
193
  `Chrome document "${name}" scaffolded:`,
158
194
  ...written.map((f) => ` engine/${f}`),
159
195
  '',
160
196
  'Next steps:',
161
- ` 1. Edit engine/browser/base/content/${name}.xhtml and fill in the body.`,
162
- ` 2. Localize strings in engine/browser/locales/en-US/browser/${name}.ftl.`,
163
- ` 3. Run "fireforge build" to validate packaging (post-build audit will flag`,
164
- ' any entry whose file does not land in the dist bundle).',
197
+ ...nextSteps,
165
198
  ].join('\n'), name);
166
199
  outro('Chrome document created');
167
200
  }
@@ -0,0 +1,31 @@
1
+ import type { ResolvedTestStyle } from './create.js';
2
+ export interface DryRunPlanInput {
3
+ componentName: string;
4
+ localized: boolean;
5
+ register: boolean;
6
+ composes: string[] | undefined;
7
+ testStyle: ResolvedTestStyle;
8
+ description: string;
9
+ binaryName: string;
10
+ }
11
+ /**
12
+ * Builds the success-note body printed after `furnace create` has applied
13
+ * its mutations. Lives beside the dry-run formatter so the two renderings
14
+ * stay in lock-step when the scaffolded layout changes.
15
+ */
16
+ export declare function formatSuccessNote(args: {
17
+ componentName: string;
18
+ files: string[];
19
+ testFiles: string[];
20
+ testStyle: ResolvedTestStyle;
21
+ binaryName: string;
22
+ }): string;
23
+ /**
24
+ * Builds the planned component + test file list for a dry-run preview.
25
+ *
26
+ * Mirrors the order `writeComponentFiles` and the test-style scaffolders
27
+ * would produce so the dry-run output matches what a real run prints on
28
+ * success. The component directory path is rendered relative to
29
+ * `components/custom/` to match the wording of the real success note.
30
+ */
31
+ export declare function formatDryRunPlan(args: DryRunPlanInput): string;
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /*
3
+ * Dry-run plan formatter for `furnace create`.
4
+ *
5
+ * Lives outside `create.ts` so the authoring command stays under the
6
+ * per-file LOC budget. The formatter is pure — all inputs are already
7
+ * resolved by the command's validation phase — so it can be exercised
8
+ * independently of the mutation plumbing.
9
+ */
10
+ /**
11
+ * Builds the test-section fragment of the dry-run plan for a given
12
+ * harness choice. Kept separate from the top-level formatter so the
13
+ * switch over `testStyle` does not push the caller over the per-function
14
+ * complexity budget.
15
+ */
16
+ function formatTestSection(args) {
17
+ const { testStyle, componentName, binaryName } = args;
18
+ if (testStyle === 'none')
19
+ return '';
20
+ const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
21
+ const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
22
+ ? strippedName.slice(binaryName.length + 1)
23
+ : strippedName;
24
+ const underscored = withoutBinaryPrefix.replace(/-/g, '_');
25
+ if (testStyle === 'browser-chrome') {
26
+ const testRoot = `engine/browser/base/content/test/${binaryName}/`;
27
+ return (`\n\nWould create test files in ${testRoot}:\n` +
28
+ ` browser.toml\n head.js\n browser_${binaryName}_${underscored}.js` +
29
+ `\n\nWould register ${binaryName}/browser.toml in engine/browser/base/moz.build`);
30
+ }
31
+ if (testStyle === 'xpcshell') {
32
+ const testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
33
+ return `\n\nWould create xpcshell test files in ${testRoot}`;
34
+ }
35
+ // testStyle === 'mochikit' (last remaining branch in ResolvedTestStyle).
36
+ const testRoot = 'engine/toolkit/content/tests/widgets/';
37
+ return `\n\nWould create mochikit test file in ${testRoot}`;
38
+ }
39
+ /**
40
+ * Builds the success-note body printed after `furnace create` has applied
41
+ * its mutations. Lives beside the dry-run formatter so the two renderings
42
+ * stay in lock-step when the scaffolded layout changes.
43
+ */
44
+ export function formatSuccessNote(args) {
45
+ const { componentName, files, testFiles, testStyle, binaryName } = args;
46
+ let note = `Files created in components/custom/${componentName}/:\n` +
47
+ files.map((f) => ` ${f}`).join('\n');
48
+ if (testFiles.length > 0) {
49
+ let testRoot;
50
+ if (testStyle === 'xpcshell') {
51
+ testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
52
+ }
53
+ else if (testStyle === 'mochikit') {
54
+ testRoot = 'engine/toolkit/content/tests/widgets/';
55
+ }
56
+ else {
57
+ testRoot = `engine/browser/base/content/test/${binaryName}/`;
58
+ }
59
+ note += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
60
+ }
61
+ note +=
62
+ '\n\n' +
63
+ 'Next steps:\n' +
64
+ ` 1. Edit component files in components/custom/${componentName}/\n` +
65
+ ' 2. Run "fireforge furnace preview" to see it\n' +
66
+ ' 3. Run "fireforge build" to apply and build';
67
+ return note;
68
+ }
69
+ /**
70
+ * Builds the planned component + test file list for a dry-run preview.
71
+ *
72
+ * Mirrors the order `writeComponentFiles` and the test-style scaffolders
73
+ * would produce so the dry-run output matches what a real run prints on
74
+ * success. The component directory path is rendered relative to
75
+ * `components/custom/` to match the wording of the real success note.
76
+ */
77
+ export function formatDryRunPlan(args) {
78
+ const { componentName, localized, register, composes, testStyle, description, binaryName } = args;
79
+ const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
+ if (localized)
81
+ componentFiles.push(`${componentName}.ftl`);
82
+ let plan = `Would create files in components/custom/${componentName}/:\n` +
83
+ componentFiles.map((f) => ` ${f}`).join('\n');
84
+ plan += formatTestSection({ testStyle, componentName, binaryName });
85
+ plan += `\n\nWould add custom entry to furnace.json:`;
86
+ plan += `\n name: ${componentName}`;
87
+ plan += `\n description: ${description || '(empty)'}`;
88
+ plan += `\n register: ${register}`;
89
+ plan += `\n localized: ${localized}`;
90
+ if (composes && composes.length > 0) {
91
+ plan += `\n composes: ${composes.join(', ')}`;
92
+ }
93
+ return plan;
94
+ }
95
+ //# sourceMappingURL=create-dry-run.js.map
@@ -103,6 +103,20 @@ export function generateXpcshellTestContent(name, header) {
103
103
 
104
104
  "use strict";
105
105
 
106
+ // Chrome-URI access from xpcshell:
107
+ // Toolkit chrome (chrome://global/*) IS registered and resolvable from
108
+ // this harness — that is what the smoke assertion below uses.
109
+ //
110
+ // Browser chrome (chrome://browser/*) is NOT registered unless the
111
+ // xpcshell.toml sets firefox-appdir = "browser" AND the built app bundle
112
+ // has landed every packaged chrome manifest. Even then, the set of
113
+ // manifests xpcshell loads lags what the real browser loads, so
114
+ // NetUtil.asyncFetch("chrome://browser/content/…") can still fail with
115
+ // NS_ERROR_FILE_NOT_FOUND against an artifact that IS present in
116
+ // obj-*/dist/. Assertions that need browser chrome URIs belong in a
117
+ // browser-chrome mochitest (fireforge furnace create --test-style=browser-chrome),
118
+ // not xpcshell.
119
+
106
120
  add_task(async function test_${name.replace(/-/g, '_')}_module_loads() {
107
121
  // Module-load smoke check: resolves the ESM at its registered chrome URI.
108
122
  // Replace or extend with storage-layer assertions as the component grows
@@ -15,6 +15,7 @@ import { FurnaceError } from '../../errors/furnace.js';
15
15
  import { toError } from '../../utils/errors.js';
16
16
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
17
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
18
+ import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
18
19
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
19
20
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
20
21
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
@@ -374,7 +375,8 @@ function validateComposesTargets(config, componentName, composes) {
374
375
  */
375
376
  export async function furnaceCreateCommand(projectRoot, name, options = {}) {
376
377
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
377
- intro('Furnace Create');
378
+ const isDryRun = options.dryRun ?? false;
379
+ intro(isDryRun ? 'Furnace Create (dry run)' : 'Furnace Create');
378
380
  // --- Resolve component name ---
379
381
  // Validation runs before we load/create any persisted furnace config so a
380
382
  // failed authoring command never auto-creates furnace.json in a fresh
@@ -453,6 +455,24 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
453
455
  // does not strand component files behind.
454
456
  const composes = options.compose;
455
457
  validateComposesTargets(config, componentName, composes);
458
+ // Dry-run exits here — every validation that does not need a write has
459
+ // already run, so the plan we render reflects exactly what the real
460
+ // command would do. The mutation phase and its rollback journal are
461
+ // intentionally skipped so no furnace.json/engine state is touched.
462
+ if (isDryRun) {
463
+ const plan = formatDryRunPlan({
464
+ componentName,
465
+ localized,
466
+ register,
467
+ composes,
468
+ testStyle,
469
+ description,
470
+ binaryName: forgeConfig.binaryName,
471
+ });
472
+ note(plan, componentName);
473
+ outro('Dry run complete (no files modified)');
474
+ return;
475
+ }
456
476
  // All validation is done. Hand off to the transactional mutation helper
457
477
  // so any failure restores the workspace and engine to their pre-command
458
478
  // state via the shared rollback journal. The mutation runs under the
@@ -480,29 +500,13 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
480
500
  ftlChromeSubPath,
481
501
  operationContext: ctx,
482
502
  }));
483
- // --- Success ---
484
- let noteParts = `Files created in components/custom/${componentName}/:\n` +
485
- files.map((f) => ` ${f}`).join('\n');
486
- if (testFiles.length > 0) {
487
- let testRoot;
488
- if (testStyle === 'xpcshell') {
489
- testRoot = `engine/browser/base/content/test/${forgeConfig.binaryName}-xpcshell/${componentName}/`;
490
- }
491
- else if (testStyle === 'mochikit') {
492
- testRoot = 'engine/toolkit/content/tests/widgets/';
493
- }
494
- else {
495
- testRoot = `engine/browser/base/content/test/${forgeConfig.binaryName}/`;
496
- }
497
- noteParts += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
498
- }
499
- noteParts +=
500
- '\n\n' +
501
- 'Next steps:\n' +
502
- ` 1. Edit component files in components/custom/${componentName}/\n` +
503
- ' 2. Run "fireforge furnace preview" to see it\n' +
504
- ' 3. Run "fireforge build" to apply and build';
505
- note(noteParts, componentName);
503
+ note(formatSuccessNote({
504
+ componentName,
505
+ files,
506
+ testFiles,
507
+ testStyle,
508
+ binaryName: forgeConfig.binaryName,
509
+ }), componentName);
506
510
  outro('Component created');
507
511
  }
508
512
  //# sourceMappingURL=create.js.map
@@ -73,7 +73,7 @@ function registerFurnaceInfoCommands(furnace, context) {
73
73
  .option('--localized', 'Include Fluent l10n support')
74
74
  .option('--no-register', 'Skip customElements.js registration')
75
75
  .option('--with-tests', 'Scaffold a test harness (defaults to MochiKit; see --test-style)')
76
- .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser); equivalent to --test-style=xpcshell')
76
+ .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser); equivalent to --test-style=xpcshell. Note: xpcshell resolves chrome://global/* URIs but not chrome://browser/* — use --test-style=browser-chrome for browser-chrome-dependent tests.')
77
77
  .option('--test-style <style>', "Override the harness written by --with-tests: mochikit (default, runs against non-tabbrowser chrome), browser-chrome (today's scaffold, needs tabbrowser), or xpcshell (headless)", (value) => {
78
78
  if (value !== 'mochikit' && value !== 'browser-chrome' && value !== 'xpcshell') {
79
79
  throw new Error(`--test-style must be one of: mochikit, browser-chrome, xpcshell. Got: "${value}".`);
@@ -81,6 +81,7 @@ function registerFurnaceInfoCommands(furnace, context) {
81
81
  return value;
82
82
  })
83
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
84
+ .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
84
85
  .action(withErrorHandling(async (name, options) => {
85
86
  await furnaceCreateCommand(getProjectRoot(), name, options);
86
87
  }));
@@ -91,6 +92,7 @@ function registerFurnaceInfoCommands(furnace, context) {
91
92
  .command('create <name>')
92
93
  .description('Scaffold a new top-level chrome document')
93
94
  .option('--no-titlebar', 'Frameless overlay-style document (omits titlebar-buttonbox)')
95
+ .option('--with-tests', 'Scaffold an xpcshell packaging-verification test that probes XCurProcD/chrome/browser/... directly (bypasses the xpcshell chrome:// URI limitation).')
94
96
  .action(withErrorHandling(async (name, options) => {
95
97
  await furnaceChromeDocCreateCommand(getProjectRoot(), name, pickDefined(options));
96
98
  }));
@@ -6,10 +6,25 @@ export interface LintCommandOptions {
6
6
  * When set, tag each issue as `introduced` or `cumulative` based on
7
7
  * whether its file changed since this git revision (e.g. `HEAD`, a
8
8
  * branch name, or a SHA). Issues are not filtered — the full set still
9
- * prints and the exit code is unchanged — but a diff-scoped summary
10
- * makes it trivial to see which errors the current task introduced.
9
+ * prints — but a diff-scoped summary makes it trivial to see which
10
+ * errors the current task introduced.
11
11
  */
12
12
  since?: string;
13
+ /**
14
+ * When set together with {@link since}, scope the exit code to issues
15
+ * tagged `introduced`. Cumulative pre-existing errors still print (so
16
+ * the operator can still see the full queue state) but do not fail
17
+ * lint. Motivating case: a branch whose diff is clean but whose repo
18
+ * already carries unrelated `raw-color` / license-header errors from
19
+ * older patches. Without this flag, CI treats the clean branch as
20
+ * failing; with it, a branch "breaks the build" only when its own diff
21
+ * introduced a new error.
22
+ *
23
+ * Requires {@link since}: without a revision to diff against there is
24
+ * no distinction between introduced and cumulative, so the flag is
25
+ * rejected up-front rather than silently ignored.
26
+ */
27
+ onlyIntroduced?: boolean;
13
28
  }
14
29
  /**
15
30
  * Runs the lint command to check engine changes against patch quality rules.
@@ -19,6 +19,14 @@ import { info, intro, outro, success, warn } from '../utils/logger.js';
19
19
  */
20
20
  export async function lintCommand(projectRoot, files, options = {}) {
21
21
  intro('FireForge Lint');
22
+ // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
23
+ // without a revision to anchor the diff there is no "introduced" subset
24
+ // to scope to — reject the combination up-front so a misconfigured CI
25
+ // invocation fails loud instead of silently treating every error as
26
+ // cumulative and passing.
27
+ if (options.onlyIntroduced && !options.since) {
28
+ throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
29
+ }
22
30
  const paths = getProjectPaths(projectRoot);
23
31
  if (!(await pathExists(paths.engine))) {
24
32
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
@@ -139,9 +147,20 @@ export async function lintCommand(projectRoot, files, options = {}) {
139
147
  else {
140
148
  info(`\nLint: ${errors.length} error(s), ${warnings.length} warning(s)`);
141
149
  }
142
- if (errors.length > 0) {
150
+ // Exit-code scope: `--only-introduced` narrows the failure criterion to
151
+ // issues tagged `introduced`. Cumulative errors still print so the
152
+ // operator sees the full queue state, but do not fail lint — the
153
+ // motivating case is a branch whose own diff is clean but whose repo
154
+ // already carries pre-existing queue errors from older patches.
155
+ const failingErrors = options.onlyIntroduced
156
+ ? errors.filter((i) => i.tag === 'introduced')
157
+ : errors;
158
+ if (failingErrors.length > 0) {
143
159
  outro('Lint failed');
144
- throw new GeneralError(`Patch lint found ${errors.length} error(s). Fix these before exporting.`);
160
+ const cumulativeSuppressed = options.onlyIntroduced && errors.length > failingErrors.length
161
+ ? ` (${errors.length - failingErrors.length} cumulative error(s) suppressed by --only-introduced)`
162
+ : '';
163
+ throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
145
164
  }
146
165
  outro('Lint passed with warnings');
147
166
  }
@@ -151,11 +170,15 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
151
170
  .command('lint [paths...]')
152
171
  .description('Lint engine changes against patch quality rules')
153
172
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
173
+ .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
154
174
  .action(withErrorHandling(async (paths, options) => {
155
175
  const lintOptions = {};
156
176
  if (options.since !== undefined) {
157
177
  lintOptions.since = options.since;
158
178
  }
179
+ if (options.onlyIntroduced !== undefined) {
180
+ lintOptions.onlyIntroduced = options.onlyIntroduced;
181
+ }
159
182
  await lintCommand(getProjectRoot(), paths, lintOptions);
160
183
  }));
161
184
  }
@@ -5,7 +5,7 @@ import type { RegisterOptions } from '../types/commands/index.js';
5
5
  * Registers a file in the appropriate build manifest.
6
6
  *
7
7
  * @param projectRoot - Root directory of the project
8
- * @param filePath - Path relative to engine/
8
+ * @param filePath - Path relative to engine/ (a leading `engine/` segment is stripped)
9
9
  * @param options - Command options
10
10
  */
11
11
  export declare function registerCommand(projectRoot: string, filePath: string, options?: RegisterOptions): Promise<void>;
@@ -6,11 +6,28 @@ import { InvalidArgumentError } from '../errors/base.js';
6
6
  import { pathExists } from '../utils/fs.js';
7
7
  import { info, intro, outro, success, warn } from '../utils/logger.js';
8
8
  import { pickDefined } from '../utils/options.js';
9
+ /**
10
+ * Strips a leading `engine/` segment (either separator flavour) from a
11
+ * user-supplied path so operators can pass either a repo-root-relative
12
+ * path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
13
+ * path (`browser/base/content/foo.xhtml`). The engine-relative form is
14
+ * what the manifest writers expect; without this normalisation, the
15
+ * former failed with a misleading "File not found in engine" pointing
16
+ * at a doubled path like `engine/engine/browser/...` that operators
17
+ * had no way to spot from the error message alone.
18
+ */
19
+ function normalizeEngineRelativePath(filePath) {
20
+ if (filePath.startsWith('engine/'))
21
+ return filePath.slice('engine/'.length);
22
+ if (filePath.startsWith('engine\\'))
23
+ return filePath.slice('engine\\'.length);
24
+ return filePath;
25
+ }
9
26
  /**
10
27
  * Registers a file in the appropriate build manifest.
11
28
  *
12
29
  * @param projectRoot - Root directory of the project
13
- * @param filePath - Path relative to engine/
30
+ * @param filePath - Path relative to engine/ (a leading `engine/` segment is stripped)
14
31
  * @param options - Command options
15
32
  */
16
33
  export async function registerCommand(projectRoot, filePath, options = {}) {
@@ -28,17 +45,23 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
28
45
  throw new InvalidArgumentError('--after must be a non-empty substring without control characters or line terminators.', 'after');
29
46
  }
30
47
  }
48
+ // Accept either repo-root-relative (`engine/browser/...`) or
49
+ // engine-relative (`browser/...`) inputs — operators frequently paste
50
+ // the former from the output of tab completion or `git status`, and
51
+ // the mismatch used to produce a "File not found" error that named
52
+ // the original path with no hint that dropping `engine/` would fix it.
53
+ const engineRelativePath = normalizeEngineRelativePath(filePath);
31
54
  // Verify the file exists in engine/ (skip for dry-run)
32
55
  if (!options.dryRun) {
33
56
  const paths = getProjectPaths(projectRoot);
34
- const fullPath = join(paths.engine, filePath);
57
+ const fullPath = join(paths.engine, engineRelativePath);
35
58
  if (!(await pathExists(fullPath))) {
36
- throw new InvalidArgumentError(`File not found in engine: ${filePath}`, 'path');
59
+ throw new InvalidArgumentError(`File not found in engine: ${engineRelativePath}`, 'path');
37
60
  }
38
61
  }
39
- const result = await registerFile(projectRoot, filePath, options.dryRun, options.after);
62
+ const result = await registerFile(projectRoot, engineRelativePath, options.dryRun, options.after);
40
63
  if (options.dryRun) {
41
- info(`[dry-run] Would register ${filePath}`);
64
+ info(`[dry-run] Would register ${engineRelativePath}`);
42
65
  info(` manifest: ${result.manifest}`);
43
66
  info(` entry: ${result.entry}`);
44
67
  if (result.previousEntry) {
@@ -54,14 +77,14 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
54
77
  return;
55
78
  }
56
79
  if (result.skipped) {
57
- info(`Already registered: ${filePath} in ${result.manifest}`);
80
+ info(`Already registered: ${engineRelativePath} in ${result.manifest}`);
58
81
  }
59
82
  else {
60
83
  if (result.afterFallback) {
61
84
  warn(`--after target "${options.after}" not found, falling back to alphabetical order`);
62
85
  }
63
86
  const position = result.previousEntry ? ` (after ${result.previousEntry})` : '';
64
- success(`Registered ${filePath} in ${result.manifest}${position}`);
87
+ success(`Registered ${engineRelativePath} in ${result.manifest}${position}`);
65
88
  info("hint: Run 'fireforge build --ui' to make the new module available at runtime");
66
89
  }
67
90
  outro('Done');
@@ -4,10 +4,11 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
6
6
  import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
+ import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
7
8
  import { GeneralError } from '../errors/base.js';
8
9
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
9
10
  import { pathExists } from '../utils/fs.js';
10
- import { info, intro, spinner } from '../utils/logger.js';
11
+ import { info, intro, spinner, warn } from '../utils/logger.js';
11
12
  import { pickDefined } from '../utils/options.js';
12
13
  /**
13
14
  * Strips a leading "engine/" or "engine\\" prefix from a path if present.
@@ -118,6 +119,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
118
119
  s.stop('Build complete');
119
120
  info('');
120
121
  }
122
+ else {
123
+ // Stale-build preflight — when --build was NOT requested, detect
124
+ // packageable engine edits since the last successful `fireforge build`
125
+ // and warn UP-FRONT. Without this, edits to chrome / packaged resources
126
+ // surface only as a cryptic `NS_ERROR_FILE_NOT_FOUND` inside xpcshell
127
+ // after mach test has already launched (see motivating case in
128
+ // `core/test-stale-check.ts`). The check is warn-only so a fork that
129
+ // rebuilt out-of-band (no FireForge-recorded baseline update) is not
130
+ // blocked from running tests.
131
+ const stale = await checkStaleBuildForTest(projectRoot, paths.engine);
132
+ if (stale.stale) {
133
+ warn(formatStaleBuildWarning(stale));
134
+ }
135
+ }
121
136
  // `--doctor` runs a short marionette handshake probe. When test paths are
122
137
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
123
138
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -24,7 +24,9 @@ export interface PlatformGateResult {
24
24
  export declare function findEnclosingGate(content: string, basename: string): string | undefined;
25
25
  /**
26
26
  * Determines whether the given source file is gated off on the current
27
- * host by an enclosing `if CONFIG[...]:` block in its owning moz.build.
27
+ * host by an enclosing `if CONFIG[...]:` block in its owning moz.build,
28
+ * OR by a path-convention rule for installer-tree subdirectories that
29
+ * are packaged via Makefile.in recipes the audit does not parse.
28
30
  * Returns `gatedOff: false` and no expression when no gate is found —
29
31
  * the file is not platform-restricted, so the caller should audit it
30
32
  * normally.