@hominis/fireforge 0.15.1 → 0.15.3

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 (56) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +76 -3
  3. package/dist/src/commands/build.js +41 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +49 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +151 -0
  6. package/dist/src/commands/furnace/chrome-doc.d.ts +34 -0
  7. package/dist/src/commands/furnace/chrome-doc.js +168 -0
  8. package/dist/src/commands/furnace/create-mochikit.d.ts +30 -0
  9. package/dist/src/commands/furnace/create-mochikit.js +70 -0
  10. package/dist/src/commands/furnace/create-templates.d.ts +53 -0
  11. package/dist/src/commands/furnace/create-templates.js +118 -0
  12. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  13. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  14. package/dist/src/commands/furnace/create.d.ts +17 -0
  15. package/dist/src/commands/furnace/create.js +59 -12
  16. package/dist/src/commands/furnace/index.d.ts +2 -1
  17. package/dist/src/commands/furnace/index.js +20 -2
  18. package/dist/src/commands/lint.d.ts +13 -1
  19. package/dist/src/commands/lint.js +33 -7
  20. package/dist/src/commands/setup.d.ts +1 -1
  21. package/dist/src/commands/setup.js +3 -2
  22. package/dist/src/core/build-audit.d.ts +46 -0
  23. package/dist/src/core/build-audit.js +251 -0
  24. package/dist/src/core/build-baseline.d.ts +59 -0
  25. package/dist/src/core/build-baseline.js +83 -0
  26. package/dist/src/core/build-prepare.d.ts +20 -1
  27. package/dist/src/core/build-prepare.js +94 -4
  28. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  29. package/dist/src/core/furnace-config-tokens.d.ts +6 -0
  30. package/dist/src/core/furnace-config-tokens.js +15 -0
  31. package/dist/src/core/furnace-config.js +10 -4
  32. package/dist/src/core/furnace-operation.d.ts +2 -1
  33. package/dist/src/core/furnace-operation.js +13 -7
  34. package/dist/src/core/furnace-registration-ast.d.ts +2 -2
  35. package/dist/src/core/furnace-registration-ast.js +1 -1
  36. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  37. package/dist/src/core/furnace-validate-helpers.d.ts +31 -1
  38. package/dist/src/core/furnace-validate-helpers.js +101 -18
  39. package/dist/src/core/furnace-validate-registration.d.ts +1 -1
  40. package/dist/src/core/furnace-validate-registration.js +1 -1
  41. package/dist/src/core/mach-error-hints.d.ts +29 -0
  42. package/dist/src/core/mach-error-hints.js +43 -0
  43. package/dist/src/core/mach.d.ts +5 -2
  44. package/dist/src/core/mach.js +31 -4
  45. package/dist/src/core/marionette-preflight.d.ts +14 -7
  46. package/dist/src/core/marionette-preflight.js +94 -44
  47. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  48. package/dist/src/core/patch-lint-cross.js +1 -1
  49. package/dist/src/core/patch-lint-diff-tag.d.ts +33 -0
  50. package/dist/src/core/patch-lint-diff-tag.js +83 -0
  51. package/dist/src/core/patch-lint.js +29 -9
  52. package/dist/src/types/commands/options.d.ts +25 -0
  53. package/dist/src/types/commands/patches.d.ts +9 -0
  54. package/dist/src/types/config.d.ts +1 -1
  55. package/dist/src/types/furnace.d.ts +13 -2
  56. package/package.json +1 -1
@@ -15,7 +15,9 @@ 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 { scaffoldMochikitTestFiles } from './create-mochikit.js';
18
19
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
20
+ import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
19
21
  async function loadAuthoringFurnaceConfig(projectRoot) {
20
22
  if (await furnaceConfigExists(projectRoot)) {
21
23
  return loadFurnaceConfig(projectRoot);
@@ -223,6 +225,35 @@ async function writeComponentFiles(componentDir, componentName, className, descr
223
225
  }
224
226
  return files;
225
227
  }
228
+ /**
229
+ * Collapses `--with-tests`, `--xpcshell`, and `--test-style` into the single
230
+ * scaffold dispatch used inside the mutation phase.
231
+ *
232
+ * Backwards-compat invariants:
233
+ * - `--xpcshell` alone is equivalent to `--test-style=xpcshell`.
234
+ * - `--with-tests` alone (no `--test-style`) now defaults to `mochikit`
235
+ * (previously it defaulted to browser-chrome; the dogfooding pass
236
+ * flagged browser-chrome as unrunnable against non-tabbrowser chrome).
237
+ * Operators who need the old behavior can pass
238
+ * `--with-tests --test-style=browser-chrome`.
239
+ * - `--xpcshell --with-tests` is rejected as ambiguous.
240
+ * @throws InvalidArgumentError when flags conflict.
241
+ */
242
+ export function resolveTestStyle(options) {
243
+ const xpcshellFlag = options.xpcshell ?? false;
244
+ const withTests = options.withTests ?? false;
245
+ const explicit = options.testStyle;
246
+ if (xpcshellFlag && explicit && explicit !== 'xpcshell') {
247
+ throw new InvalidArgumentError(`--xpcshell cannot be combined with --test-style=${explicit}; choose one.`, 'testStyle');
248
+ }
249
+ if (explicit)
250
+ return explicit;
251
+ if (xpcshellFlag)
252
+ return 'xpcshell';
253
+ if (withTests)
254
+ return 'mochikit';
255
+ return 'none';
256
+ }
226
257
  /**
227
258
  * Performs the transactional mutation phase of furnace create. All file
228
259
  * writes and the config update are recorded in a rollback journal so a
@@ -258,10 +289,18 @@ async function performCreateMutations(args) {
258
289
  args.config.custom[args.componentName] = customEntry;
259
290
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
260
291
  await writeFurnaceConfig(args.projectRoot, args.config);
261
- if (args.withTests) {
292
+ if (args.testStyle === 'browser-chrome') {
262
293
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
263
294
  testFiles.push(...scafFiles);
264
295
  }
296
+ else if (args.testStyle === 'xpcshell') {
297
+ const xpcshellFiles = await scaffoldXpcshellTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
298
+ testFiles.push(...xpcshellFiles);
299
+ }
300
+ else if (args.testStyle === 'mochikit') {
301
+ const mochikitFiles = await scaffoldMochikitTestFiles(args.componentName, args.license, args.paths, journal);
302
+ testFiles.push(...mochikitFiles);
303
+ }
265
304
  }
266
305
  catch (error) {
267
306
  try {
@@ -395,13 +434,13 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
395
434
  return;
396
435
  }
397
436
  const { localized, register } = featureSelection;
398
- // --with-tests writes files under engine/browser/base/content/test/ and
399
- // registers them in moz.build. Guard against a missing engine now rather
400
- // than letting scaffoldTestFiles fabricate a partial engine tree with
401
- // ensureDir.
402
- const withTests = options.withTests ?? false;
403
- if (withTests && !(await pathExists(paths.engine))) {
404
- throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests.', componentName);
437
+ // Collapse --with-tests / --xpcshell / --test-style into the single
438
+ // scaffold selection used by the mutation phase. The resolver validates
439
+ // incompatible combinations up-front so a bad flag set never strands a
440
+ // partial mutation behind.
441
+ const testStyle = resolveTestStyle(options);
442
+ if (testStyle !== 'none' && !(await pathExists(paths.engine))) {
443
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests, --xpcshell, or --test-style.', componentName);
405
444
  }
406
445
  // --- Generate component files ---
407
446
  const className = tagNameToClassName(componentName);
@@ -437,7 +476,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
437
476
  forgeConfig,
438
477
  paths,
439
478
  license,
440
- withTests,
479
+ testStyle,
441
480
  ftlChromeSubPath,
442
481
  operationContext: ctx,
443
482
  }));
@@ -445,9 +484,17 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
445
484
  let noteParts = `Files created in components/custom/${componentName}/:\n` +
446
485
  files.map((f) => ` ${f}`).join('\n');
447
486
  if (testFiles.length > 0) {
448
- noteParts +=
449
- `\n\nTest files in engine/browser/base/content/test/${forgeConfig.binaryName}/:\n` +
450
- testFiles.map((f) => ` ${f}`).join('\n');
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');
451
498
  }
452
499
  noteParts +=
453
500
  '\n\n' +
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../../types/cli.js';
3
3
  import { furnaceApplyCommand } from './apply.js';
4
+ import { furnaceChromeDocCreateCommand } from './chrome-doc.js';
4
5
  import { furnaceCreateCommand } from './create.js';
5
6
  import { furnaceDeployCommand } from './deploy.js';
6
7
  import { furnaceDiffCommand } from './diff.js';
@@ -15,6 +16,6 @@ import { furnaceScanCommand } from './scan.js';
15
16
  import { furnaceStatusCommand } from './status.js';
16
17
  import { furnaceSyncCommand } from './sync.js';
17
18
  import { furnaceValidateCommand } from './validate.js';
18
- export { furnaceApplyCommand, furnaceBatchOverrideCommand, furnaceCreateCommand, furnaceDeployCommand, furnaceDiffCommand, furnaceInitCommand, furnaceListCommand, furnaceOverrideCommand, furnacePreviewCommand, furnaceRefreshCommand, furnaceRemoveCommand, furnaceRenameCommand, furnaceScanCommand, furnaceStatusCommand, furnaceSyncCommand, furnaceValidateCommand, };
19
+ export { furnaceApplyCommand, furnaceBatchOverrideCommand, furnaceChromeDocCreateCommand, furnaceCreateCommand, furnaceDeployCommand, furnaceDiffCommand, furnaceInitCommand, furnaceListCommand, furnaceOverrideCommand, furnacePreviewCommand, furnaceRefreshCommand, furnaceRemoveCommand, furnaceRenameCommand, furnaceScanCommand, furnaceStatusCommand, furnaceSyncCommand, furnaceValidateCommand, };
19
20
  /** Registers the furnace command on the CLI program. */
20
21
  export declare function registerFurnace(program: Command, context: CommandContext): void;
@@ -2,6 +2,7 @@
2
2
  import { Option } from 'commander';
3
3
  import { pickDefined } from '../../utils/options.js';
4
4
  import { furnaceApplyCommand } from './apply.js';
5
+ import { furnaceChromeDocCreateCommand } from './chrome-doc.js';
5
6
  import { furnaceCreateCommand } from './create.js';
6
7
  import { furnaceDeployCommand } from './deploy.js';
7
8
  import { furnaceDiffCommand } from './diff.js';
@@ -16,7 +17,7 @@ import { furnaceScanCommand } from './scan.js';
16
17
  import { furnaceStatusCommand } from './status.js';
17
18
  import { furnaceSyncCommand } from './sync.js';
18
19
  import { furnaceValidateCommand } from './validate.js';
19
- export { furnaceApplyCommand, furnaceBatchOverrideCommand, furnaceCreateCommand, furnaceDeployCommand, furnaceDiffCommand, furnaceInitCommand, furnaceListCommand, furnaceOverrideCommand, furnacePreviewCommand, furnaceRefreshCommand, furnaceRemoveCommand, furnaceRenameCommand, furnaceScanCommand, furnaceStatusCommand, furnaceSyncCommand, furnaceValidateCommand, };
20
+ export { furnaceApplyCommand, furnaceBatchOverrideCommand, furnaceChromeDocCreateCommand, furnaceCreateCommand, furnaceDeployCommand, furnaceDiffCommand, furnaceInitCommand, furnaceListCommand, furnaceOverrideCommand, furnacePreviewCommand, furnaceRefreshCommand, furnaceRemoveCommand, furnaceRenameCommand, furnaceScanCommand, furnaceStatusCommand, furnaceSyncCommand, furnaceValidateCommand, };
20
21
  /**
21
22
  * Registers Furnace commands for querying component state: status, scan,
22
23
  * and action commands like apply, deploy, and create.
@@ -71,11 +72,28 @@ function registerFurnaceInfoCommands(furnace, context) {
71
72
  .option('-d, --description <desc>', 'Component description')
72
73
  .option('--localized', 'Include Fluent l10n support')
73
74
  .option('--no-register', 'Skip customElements.js registration')
74
- .option('--with-tests', 'Scaffold Mochitest directory and register in moz.build')
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')
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
+ if (value !== 'mochikit' && value !== 'browser-chrome' && value !== 'xpcshell') {
79
+ throw new Error(`--test-style must be one of: mochikit, browser-chrome, xpcshell. Got: "${value}".`);
80
+ }
81
+ return value;
82
+ })
75
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
76
84
  .action(withErrorHandling(async (name, options) => {
77
85
  await furnaceCreateCommand(getProjectRoot(), name, options);
78
86
  }));
87
+ const chromeDoc = furnace
88
+ .command('chrome-doc')
89
+ .description('Scaffold top-level chrome documents (xhtml + js + css + ftl + jar.mn)');
90
+ chromeDoc
91
+ .command('create <name>')
92
+ .description('Scaffold a new top-level chrome document')
93
+ .option('--no-titlebar', 'Frameless overlay-style document (omits titlebar-buttonbox)')
94
+ .action(withErrorHandling(async (name, options) => {
95
+ await furnaceChromeDocCreateCommand(getProjectRoot(), name, pickDefined(options));
96
+ }));
79
97
  }
80
98
  /**
81
99
  * Registers Furnace commands for authoring, inspection, and maintenance:
@@ -1,10 +1,22 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ /** Options controlling how the lint command filters and tags its output. */
4
+ export interface LintCommandOptions {
5
+ /**
6
+ * When set, tag each issue as `introduced` or `cumulative` based on
7
+ * whether its file changed since this git revision (e.g. `HEAD`, a
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.
11
+ */
12
+ since?: string;
13
+ }
3
14
  /**
4
15
  * Runs the lint command to check engine changes against patch quality rules.
5
16
  * @param projectRoot - Root directory of the project
6
17
  * @param files - Optional file/directory paths to lint (relative to engine/)
18
+ * @param options - Additional lint options such as `--since` diff-scoping
7
19
  */
8
- export declare function lintCommand(projectRoot: string, files: string[]): Promise<void>;
20
+ export declare function lintCommand(projectRoot: string, files: string[], options?: LintCommandOptions): Promise<void>;
9
21
  /** Registers the lint command on the CLI program. */
10
22
  export declare function registerLint(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -7,6 +7,7 @@ import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
+ import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
10
11
  import { GeneralError } from '../errors/base.js';
11
12
  import { pathExists } from '../utils/fs.js';
12
13
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -14,8 +15,9 @@ import { info, intro, outro, success, warn } from '../utils/logger.js';
14
15
  * Runs the lint command to check engine changes against patch quality rules.
15
16
  * @param projectRoot - Root directory of the project
16
17
  * @param files - Optional file/directory paths to lint (relative to engine/)
18
+ * @param options - Additional lint options such as `--since` diff-scoping
17
19
  */
18
- export async function lintCommand(projectRoot, files) {
20
+ export async function lintCommand(projectRoot, files, options = {}) {
19
21
  intro('FireForge Lint');
20
22
  const paths = getProjectPaths(projectRoot);
21
23
  if (!(await pathExists(paths.engine))) {
@@ -105,19 +107,38 @@ export async function lintCommand(projectRoot, files) {
105
107
  outro('Lint passed');
106
108
  return;
107
109
  }
110
+ // Diff-scoping: tag each issue as introduced-in-current-task vs
111
+ // cumulative-pre-existing-drift. Never filters — full set still prints
112
+ // and exit code semantics are unchanged — but the per-line prefix and
113
+ // summary make triage trivial on a large patch series.
114
+ const sinceActive = Boolean(options.since);
115
+ if (options.since) {
116
+ const diffFiles = await collectDiffFilePaths(paths.engine, options.since);
117
+ tagLintIssues(issues, diffFiles);
118
+ }
108
119
  const errors = issues.filter((i) => i.severity === 'error');
109
120
  const warnings = issues.filter((i) => i.severity === 'warning');
110
121
  const notices = issues.filter((i) => i.severity === 'notice');
122
+ const tagPrefix = (issue) => sinceActive && issue.tag ? `[${issue.tag}] ` : '';
111
123
  for (const issue of notices) {
112
- info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
124
+ info(`${tagPrefix(issue)}NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
113
125
  }
114
126
  for (const issue of warnings) {
115
- warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
127
+ warn(`${tagPrefix(issue)}[${issue.check}] ${issue.file}: ${issue.message}`);
116
128
  }
117
129
  for (const issue of errors) {
118
- warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
130
+ warn(`${tagPrefix(issue)}ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
131
+ }
132
+ if (sinceActive) {
133
+ const introducedErrors = errors.filter((i) => i.tag === 'introduced').length;
134
+ const introducedWarnings = warnings.filter((i) => i.tag === 'introduced').length;
135
+ const cumulativeErrors = errors.length - introducedErrors;
136
+ const cumulativeWarnings = warnings.length - introducedWarnings;
137
+ info(`\nLint: ${introducedErrors} introduced error(s), ${introducedWarnings} introduced warning(s); ${cumulativeErrors} cumulative error(s), ${cumulativeWarnings} cumulative warning(s)`);
138
+ }
139
+ else {
140
+ info(`\nLint: ${errors.length} error(s), ${warnings.length} warning(s)`);
119
141
  }
120
- info(`\nLint: ${errors.length} error(s), ${warnings.length} warning(s)`);
121
142
  if (errors.length > 0) {
122
143
  outro('Lint failed');
123
144
  throw new GeneralError(`Patch lint found ${errors.length} error(s). Fix these before exporting.`);
@@ -129,8 +150,13 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
129
150
  program
130
151
  .command('lint [paths...]')
131
152
  .description('Lint engine changes against patch quality rules')
132
- .action(withErrorHandling(async (paths) => {
133
- await lintCommand(getProjectRoot(), paths);
153
+ .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)')
154
+ .action(withErrorHandling(async (paths, options) => {
155
+ const lintOptions = {};
156
+ if (options.since !== undefined) {
157
+ lintOptions.since = options.since;
158
+ }
159
+ await lintCommand(getProjectRoot(), paths, lintOptions);
134
160
  }));
135
161
  }
136
162
  //# sourceMappingURL=lint.js.map
@@ -8,4 +8,4 @@ import type { SetupOptions } from '../types/commands/index.js';
8
8
  */
9
9
  export declare function setupCommand(projectRoot: string, options?: SetupOptions): Promise<void>;
10
10
  /** Registers the setup command on the CLI program. */
11
- export declare function registerSetup(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
11
+ export declare function registerSetup(program: Command, { withErrorHandling }: CommandContext): void;
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { resolve } from 'node:path';
2
3
  import { confirm } from '@clack/prompts';
3
4
  import { Option } from 'commander';
4
5
  import { configExists } from '../core/config.js';
@@ -57,7 +58,7 @@ export async function setupCommand(projectRoot, options = {}) {
57
58
  }
58
59
  }
59
60
  /** Registers the setup command on the CLI program. */
60
- export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
61
+ export function registerSetup(program, { withErrorHandling }) {
61
62
  program
62
63
  .command('setup')
63
64
  .description('Initialize a new FireForge project')
@@ -88,7 +89,7 @@ export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
88
89
  setupOptions.license = parsedLicense;
89
90
  }
90
91
  }
91
- await setupCommand(getProjectRoot(), setupOptions);
92
+ await setupCommand(resolve(process.cwd()), setupOptions);
92
93
  }));
93
94
  }
94
95
  //# sourceMappingURL=setup.js.map
@@ -0,0 +1,46 @@
1
+ import type { BuildBaseline } from './build-baseline.js';
2
+ /** Result of a single artifact lookup. */
3
+ export interface AuditEntry {
4
+ /** Engine-relative source file path (POSIX separators). */
5
+ source: string;
6
+ /**
7
+ * Resolved artifact path inside the dist tree, or undefined when no
8
+ * candidate bundle location was found. An entry with an undefined path
9
+ * and status "missing" means the source was packageable but nothing
10
+ * that looked like its artifact showed up in the bundle.
11
+ */
12
+ artifact: string | undefined;
13
+ /**
14
+ * updated: an artifact exists and is at least as new as the source.
15
+ * stale: artifact exists but is older than the source (probable packaging drop).
16
+ * missing: no artifact with a matching basename was found anywhere under dist/.
17
+ * skipped: the file extension / path does not imply packaging; not counted.
18
+ */
19
+ status: 'updated' | 'stale' | 'missing' | 'skipped';
20
+ }
21
+ /** Summary counts for the "Packaged:" end-of-build line. */
22
+ export interface AuditSummary {
23
+ updated: number;
24
+ stale: number;
25
+ missing: number;
26
+ skipped: number;
27
+ entries: AuditEntry[];
28
+ }
29
+ /**
30
+ * Decides whether a source path should be packaged. Returns true for paths
31
+ * whose extension or directory fragment matches a known-packaged pattern.
32
+ * @param sourcePath Engine-relative POSIX path (for example browser/app/profile/pref.js).
33
+ * @returns True when the path implies packaging.
34
+ */
35
+ export declare function isPackageablePath(sourcePath: string): boolean;
36
+ /**
37
+ * Runs the post-build audit. Emits per-file warnings for missing or
38
+ * stale artifacts and a summary info line at the end. Always returns
39
+ * the summary; never throws on audit failure (the audit itself must
40
+ * never fail a successful build).
41
+ * @param projectRoot Root of the project (reserved for future fork-specific rules).
42
+ * @param engineDir Path to the engine directory.
43
+ * @param baseline Optional previous-build baseline marker.
44
+ * @returns Summary of artifact status counts.
45
+ */
46
+ export declare function auditBuildArtifacts(projectRoot: string, engineDir: string, baseline: BuildBaseline | undefined): Promise<AuditSummary>;
@@ -0,0 +1,251 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /*
3
+ * Post-build dist-tree audit.
4
+ *
5
+ * Purpose: catch the class of bug where a file under engine/ was edited
6
+ * but never registered in moz.build, jar.mn, or package-manifest.in, so
7
+ * the mach build reports success but the packaged bundle carries stale
8
+ * or missing content. A fork-specific pref file that was never registered
9
+ * for packaging is the motivating case.
10
+ *
11
+ * The audit is best-effort and warn-only:
12
+ * - It enumerates engine files changed since the previous build baseline
13
+ * (git-tracked diff + workdir modifications).
14
+ * - For each file whose path pattern implies packaging, it resolves
15
+ * the expected dist artifact under obj-star/dist/binary-name-star.
16
+ * - A warning fires when the expected artifact is missing OR when its
17
+ * mtime is older than the engine source (the build was reported
18
+ * successful but that file's path never flowed through packaging).
19
+ * - False positives are acceptable at this stage: fork-specific packaging
20
+ * tricks FireForge doesn't know about will surface as warnings an
21
+ * operator can investigate. The audit never fails the build.
22
+ */
23
+ import { stat } from 'node:fs/promises';
24
+ import { basename, join } from 'node:path';
25
+ import { toError } from '../utils/errors.js';
26
+ import { pathExists } from '../utils/fs.js';
27
+ import { info, verbose, warn } from '../utils/logger.js';
28
+ import { hasChanges, isMissingHeadError } from './git.js';
29
+ import { git } from './git-base.js';
30
+ import { getUntrackedFiles } from './git-status.js';
31
+ /** Path extensions that are conventionally packaged into the Firefox bundle. */
32
+ const PACKAGEABLE_EXTENSIONS = [
33
+ '.js',
34
+ '.mjs',
35
+ '.jsm',
36
+ '.css',
37
+ '.ftl',
38
+ '.xhtml',
39
+ '.xul',
40
+ '.html',
41
+ '.properties',
42
+ ];
43
+ /** Path fragments whose contents are packaged regardless of extension. */
44
+ const PACKAGEABLE_PATH_FRAGMENTS = ['/app/profile/', '/chrome/', '/locales/'];
45
+ /** Directories that are build artifacts, not source — never audited. */
46
+ const IGNORE_PATH_FRAGMENTS = ['obj-', 'node_modules/', '.git/', '.cargo/', '.mozbuild/'];
47
+ /*
48
+ * Finds the first file with the given basename anywhere under the dist
49
+ * bundle. Scans the darwin Contents/Resources layout and the linux/win
50
+ * top-level layout with a depth-limited traversal so deeply-nested
51
+ * node_modules in the dist copy do not dominate the audit wall clock.
52
+ */
53
+ async function findArtifactByBasename(distRoot, name, maxDepth = 10) {
54
+ const { readdir } = await import('node:fs/promises');
55
+ const stack = [{ dir: distRoot, depth: 0 }];
56
+ while (stack.length > 0) {
57
+ const entry = stack.pop();
58
+ if (!entry)
59
+ break;
60
+ if (entry.depth > maxDepth)
61
+ continue;
62
+ let children;
63
+ try {
64
+ children = await readdir(entry.dir, { withFileTypes: true });
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ for (const child of children) {
70
+ const fullPath = join(entry.dir, child.name);
71
+ if (child.isDirectory()) {
72
+ // Skip the symlinked mozbuild cache tree which contains full copies
73
+ // and would dominate the scan on macOS.
74
+ if (child.name.startsWith('.'))
75
+ continue;
76
+ stack.push({ dir: fullPath, depth: entry.depth + 1 });
77
+ continue;
78
+ }
79
+ if (child.name === name) {
80
+ return fullPath;
81
+ }
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Decides whether a source path should be packaged. Returns true for paths
88
+ * whose extension or directory fragment matches a known-packaged pattern.
89
+ * @param sourcePath Engine-relative POSIX path (for example browser/app/profile/pref.js).
90
+ * @returns True when the path implies packaging.
91
+ */
92
+ export function isPackageablePath(sourcePath) {
93
+ for (const fragment of IGNORE_PATH_FRAGMENTS) {
94
+ if (sourcePath.includes(fragment))
95
+ return false;
96
+ }
97
+ for (const ext of PACKAGEABLE_EXTENSIONS) {
98
+ if (sourcePath.endsWith(ext))
99
+ return true;
100
+ }
101
+ for (const fragment of PACKAGEABLE_PATH_FRAGMENTS) {
102
+ if (sourcePath.includes(fragment))
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Collects engine-relative paths changed since the baseline's HEAD SHA.
109
+ * Always includes modified + untracked workdir paths. When the baseline is
110
+ * missing or the engine has no HEAD yet, falls back to workdir-only diffs.
111
+ */
112
+ async function collectChangedFiles(engineDir, baseline) {
113
+ const collected = new Set();
114
+ if (baseline?.engineHeadSha) {
115
+ try {
116
+ const output = await git(['diff', '--name-only', `${baseline.engineHeadSha}..HEAD`], engineDir);
117
+ for (const line of output.split('\n')) {
118
+ const trimmed = line.trim();
119
+ if (trimmed)
120
+ collected.add(trimmed);
121
+ }
122
+ }
123
+ catch (error) {
124
+ if (!isMissingHeadError(error)) {
125
+ verbose(`Audit: could not diff against baseline SHA — ${toError(error).message}`);
126
+ }
127
+ }
128
+ }
129
+ try {
130
+ if (await hasChanges(engineDir)) {
131
+ const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
132
+ for (const line of worktreeDiff.split('\n')) {
133
+ const trimmed = line.trim();
134
+ if (trimmed)
135
+ collected.add(trimmed);
136
+ }
137
+ const untracked = await getUntrackedFiles(engineDir);
138
+ for (const file of untracked) {
139
+ collected.add(file);
140
+ }
141
+ }
142
+ }
143
+ catch (error) {
144
+ verbose(`Audit: could not enumerate workdir changes — ${toError(error).message}`);
145
+ }
146
+ return [...collected].sort();
147
+ }
148
+ /*
149
+ * Finds the unique obj-star directory with a dist subtree, or undefined
150
+ * when zero or multiple match. The ambiguous case is already rejected
151
+ * by pre-flight in build.ts, so the auditor only has to handle
152
+ * one-or-none.
153
+ */
154
+ async function resolveDistRoot(engineDir) {
155
+ const { readdir } = await import('node:fs/promises');
156
+ let entries;
157
+ try {
158
+ entries = await readdir(engineDir);
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ const objDirs = entries.filter((e) => e.startsWith('obj-'));
164
+ for (const objDir of objDirs) {
165
+ const distPath = join(engineDir, objDir, 'dist');
166
+ if (await pathExists(distPath)) {
167
+ return distPath;
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Runs the post-build audit. Emits per-file warnings for missing or
174
+ * stale artifacts and a summary info line at the end. Always returns
175
+ * the summary; never throws on audit failure (the audit itself must
176
+ * never fail a successful build).
177
+ * @param projectRoot Root of the project (reserved for future fork-specific rules).
178
+ * @param engineDir Path to the engine directory.
179
+ * @param baseline Optional previous-build baseline marker.
180
+ * @returns Summary of artifact status counts.
181
+ */
182
+ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
183
+ void projectRoot;
184
+ const summary = {
185
+ updated: 0,
186
+ stale: 0,
187
+ missing: 0,
188
+ skipped: 0,
189
+ entries: [],
190
+ };
191
+ const distRoot = await resolveDistRoot(engineDir);
192
+ if (!distRoot) {
193
+ verbose('Audit skipped: no dist tree found under obj-*/dist/.');
194
+ return summary;
195
+ }
196
+ const changed = await collectChangedFiles(engineDir, baseline);
197
+ if (changed.length === 0) {
198
+ return summary;
199
+ }
200
+ for (const source of changed) {
201
+ if (!isPackageablePath(source)) {
202
+ summary.skipped += 1;
203
+ summary.entries.push({ source, artifact: undefined, status: 'skipped' });
204
+ continue;
205
+ }
206
+ const sourcePath = join(engineDir, source);
207
+ let sourceMtime;
208
+ try {
209
+ const sourceStat = await stat(sourcePath);
210
+ sourceMtime = sourceStat.mtimeMs;
211
+ }
212
+ catch {
213
+ // File was deleted since the diff was computed. Skip — a deletion
214
+ // that didn't propagate to the dist tree is a distinct class of bug
215
+ // we don't audit yet.
216
+ summary.skipped += 1;
217
+ summary.entries.push({ source, artifact: undefined, status: 'skipped' });
218
+ continue;
219
+ }
220
+ const artifact = await findArtifactByBasename(distRoot, basename(source));
221
+ if (!artifact) {
222
+ summary.missing += 1;
223
+ summary.entries.push({ source, artifact: undefined, status: 'missing' });
224
+ warn(`Audit: engine/${source} was touched but no packaged artifact with basename "${basename(source)}" was found under ${distRoot}. Missing moz.build / jar.mn / package-manifest.in registration?`);
225
+ continue;
226
+ }
227
+ let artifactMtime;
228
+ try {
229
+ const artifactStat = await stat(artifact);
230
+ artifactMtime = artifactStat.mtimeMs;
231
+ }
232
+ catch {
233
+ // Disappeared after the directory scan; treat as missing.
234
+ summary.missing += 1;
235
+ summary.entries.push({ source, artifact, status: 'missing' });
236
+ warn(`Audit: engine/${source} has no readable packaged artifact at ${artifact} (disappeared during audit).`);
237
+ continue;
238
+ }
239
+ if (artifactMtime + 1 < sourceMtime) {
240
+ summary.stale += 1;
241
+ summary.entries.push({ source, artifact, status: 'stale' });
242
+ warn(`Audit: engine/${source} is newer than its packaged artifact ${artifact}. Build reported success but the file's path may not flow through packaging — check moz.build / jar.mn entries.`);
243
+ continue;
244
+ }
245
+ summary.updated += 1;
246
+ summary.entries.push({ source, artifact, status: 'updated' });
247
+ }
248
+ info(`Packaged: ${summary.updated} updated, ${summary.stale} stale, ${summary.missing} missing, ${summary.skipped} skipped`);
249
+ return summary;
250
+ }
251
+ //# sourceMappingURL=build-audit.js.map