@hominis/fireforge 0.36.0 → 0.38.0

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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +6 -6
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  6. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  7. package/dist/src/commands/doctor.js +2 -0
  8. package/dist/src/commands/download.js +5 -2
  9. package/dist/src/commands/export-flow.d.ts +5 -1
  10. package/dist/src/commands/export-flow.js +10 -7
  11. package/dist/src/commands/export-placement-gate.js +3 -5
  12. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  13. package/dist/src/commands/export-placement-policy.js +32 -29
  14. package/dist/src/commands/export.js +12 -5
  15. package/dist/src/commands/furnace/index.js +23 -18
  16. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  17. package/dist/src/commands/patch/move-files-create.js +116 -0
  18. package/dist/src/commands/patch/move-files.d.ts +3 -1
  19. package/dist/src/commands/patch/move-files.js +49 -4
  20. package/dist/src/commands/patch/split.d.ts +11 -1
  21. package/dist/src/commands/patch/split.js +6 -5
  22. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  23. package/dist/src/commands/patch/staged-dependency.js +113 -51
  24. package/dist/src/commands/re-export-files.js +4 -0
  25. package/dist/src/commands/re-export-scan.d.ts +17 -0
  26. package/dist/src/commands/re-export-scan.js +51 -0
  27. package/dist/src/commands/re-export.js +25 -6
  28. package/dist/src/commands/source.js +28 -3
  29. package/dist/src/commands/test-diagnose.js +20 -1
  30. package/dist/src/commands/test-register.js +6 -5
  31. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  32. package/dist/src/commands/test-stale-gate.js +81 -0
  33. package/dist/src/commands/test.js +40 -23
  34. package/dist/src/core/build-audit.d.ts +12 -0
  35. package/dist/src/core/build-audit.js +14 -0
  36. package/dist/src/core/build-baseline-types.d.ts +58 -0
  37. package/dist/src/core/build-baseline.d.ts +10 -2
  38. package/dist/src/core/build-baseline.js +51 -7
  39. package/dist/src/core/config-paths.d.ts +1 -1
  40. package/dist/src/core/config-paths.js +1 -0
  41. package/dist/src/core/config-validate.js +6 -1
  42. package/dist/src/core/engine-session-lock.d.ts +10 -1
  43. package/dist/src/core/engine-session-lock.js +28 -2
  44. package/dist/src/core/file-lock.d.ts +33 -0
  45. package/dist/src/core/file-lock.js +32 -6
  46. package/dist/src/core/firefox-archive.d.ts +2 -1
  47. package/dist/src/core/firefox-archive.js +32 -8
  48. package/dist/src/core/firefox.d.ts +8 -3
  49. package/dist/src/core/firefox.js +11 -6
  50. package/dist/src/core/furnace-config-custom.js +10 -0
  51. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  52. package/dist/src/core/furnace-stale-export.js +123 -0
  53. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  54. package/dist/src/core/furnace-validate-helpers.js +28 -1
  55. package/dist/src/core/furnace-validate-structure.js +3 -2
  56. package/dist/src/core/license-headers.d.ts +12 -0
  57. package/dist/src/core/license-headers.js +76 -1
  58. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  59. package/dist/src/core/mach-known-noise-filter.js +193 -0
  60. package/dist/src/core/mach.d.ts +8 -0
  61. package/dist/src/core/mach.js +36 -3
  62. package/dist/src/core/patch-lint-cross.js +80 -2
  63. package/dist/src/core/patch-lint.d.ts +3 -0
  64. package/dist/src/core/patch-lint.js +25 -7
  65. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  66. package/dist/src/core/patch-manifest-io.js +27 -16
  67. package/dist/src/core/patch-manifest-validate.js +24 -0
  68. package/dist/src/core/status-classify.d.ts +11 -2
  69. package/dist/src/core/status-classify.js +49 -25
  70. package/dist/src/core/test-harness-crash.d.ts +16 -0
  71. package/dist/src/core/test-harness-crash.js +55 -0
  72. package/dist/src/core/test-stale-check.d.ts +69 -1
  73. package/dist/src/core/test-stale-check.js +134 -1
  74. package/dist/src/types/commands/index.d.ts +1 -1
  75. package/dist/src/types/commands/options.d.ts +84 -7
  76. package/dist/src/types/commands/patches.d.ts +29 -0
  77. package/dist/src/types/config.d.ts +7 -0
  78. package/dist/src/types/furnace.d.ts +13 -0
  79. package/dist/src/utils/options.d.ts +20 -0
  80. package/dist/src/utils/options.js +39 -0
  81. package/dist/src/utils/process-group.d.ts +33 -0
  82. package/dist/src/utils/process-group.js +161 -0
  83. package/dist/src/utils/process.d.ts +15 -0
  84. package/dist/src/utils/process.js +88 -44
  85. package/dist/src/utils/validation.d.ts +5 -0
  86. package/dist/src/utils/validation.js +7 -0
  87. package/package.json +3 -3
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Stale-furnace-source gate for `fireforge patch export` / `re-export`.
3
+ *
4
+ * Exports capture the DEPLOYED engine copies of furnace-managed files, not
5
+ * the `components/` sources. Editing a component source and re-exporting
6
+ * its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
7
+ * exports the stale deployed copy — per-patch lint then flags the old line
8
+ * count and the patch body lags the source. This gate detects that
9
+ * sequence by comparing component source directories against the checksums
10
+ * recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
11
+ * signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
12
+ * unless the operator passes `--allow-stale-furnace`.
13
+ *
14
+ * Checksum-based on purpose: git checkouts and `furnace refresh` churn
15
+ * mtimes without content changes, so an mtime comparison would misfire.
16
+ *
17
+ * Probe failures (broken furnace config, missing state) degrade to a
18
+ * verbose log and an empty result — a broken furnace setup must never
19
+ * block non-furnace patch work.
20
+ */
21
+ /** One furnace component whose source drifted from its deployed copy. */
22
+ export interface StaleFurnaceComponent {
23
+ /** Component name (furnace.json key). */
24
+ name: string;
25
+ /** Component flavor — resolves the source directory root. */
26
+ type: 'custom' | 'override';
27
+ /** Engine-relative deployed prefix (`/`-terminated) the component owns. */
28
+ prefix: string;
29
+ }
30
+ /**
31
+ * Returns the furnace components whose deployed engine copies the given
32
+ * files fall under AND whose `components/` sources have changed since the
33
+ * last apply. Empty when furnace is not configured, never applied, or the
34
+ * probe fails.
35
+ *
36
+ * Files are attributed per component: the custom `targetPath` / override
37
+ * `basePath` prefixes, plus — for a localized non-sharedFtl custom
38
+ * component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
39
+ * FTL dir as a whole stays unclaimed; sibling files there belong to other
40
+ * components). `sharedFtl` bundles are NOT furnace-deployed (apply only
41
+ * prunes a dangling per-widget jar.mn line), so they have no deployed copy
42
+ * to go stale and get no candidate by design. The storybook stories prefix
43
+ * is likewise skipped.
44
+ *
45
+ * @param projectRoot - Project root directory
46
+ * @param files - Engine-relative paths the export will capture
47
+ */
48
+ export declare function findStaleFurnaceComponentsForFiles(projectRoot: string, files: readonly string[]): Promise<StaleFurnaceComponent[]>;
49
+ /**
50
+ * Gate helper for export/re-export: refuses (or, with `allow`, warns) when
51
+ * any exported file belongs to a furnace component whose source changed
52
+ * since the last apply — the export would capture the stale deployed copy.
53
+ *
54
+ * @param projectRoot - Project root directory
55
+ * @param files - Engine-relative paths the export will capture
56
+ * @param allow - True when the operator passed `--allow-stale-furnace`
57
+ * @param command - Which command runs the gate (message wording only)
58
+ */
59
+ export declare function enforceFreshFurnaceSources(projectRoot: string, files: readonly string[], allow: boolean, command: 'export' | 're-export'): Promise<void>;
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Stale-furnace-source gate for `fireforge patch export` / `re-export`.
4
+ *
5
+ * Exports capture the DEPLOYED engine copies of furnace-managed files, not
6
+ * the `components/` sources. Editing a component source and re-exporting
7
+ * its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
8
+ * exports the stale deployed copy — per-patch lint then flags the old line
9
+ * count and the patch body lags the source. This gate detects that
10
+ * sequence by comparing component source directories against the checksums
11
+ * recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
12
+ * signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
13
+ * unless the operator passes `--allow-stale-furnace`.
14
+ *
15
+ * Checksum-based on purpose: git checkouts and `furnace refresh` churn
16
+ * mtimes without content changes, so an mtime comparison would misfire.
17
+ *
18
+ * Probe failures (broken furnace config, missing state) degrade to a
19
+ * verbose log and an empty result — a broken furnace setup must never
20
+ * block non-furnace patch work.
21
+ */
22
+ import { GeneralError } from '../errors/base.js';
23
+ import { pathExists } from '../utils/fs.js';
24
+ import { verbose, warn } from '../utils/logger.js';
25
+ import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
26
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
27
+ import { resolveFtlDir } from './furnace-constants.js';
28
+ /**
29
+ * Returns the furnace components whose deployed engine copies the given
30
+ * files fall under AND whose `components/` sources have changed since the
31
+ * last apply. Empty when furnace is not configured, never applied, or the
32
+ * probe fails.
33
+ *
34
+ * Files are attributed per component: the custom `targetPath` / override
35
+ * `basePath` prefixes, plus — for a localized non-sharedFtl custom
36
+ * component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
37
+ * FTL dir as a whole stays unclaimed; sibling files there belong to other
38
+ * components). `sharedFtl` bundles are NOT furnace-deployed (apply only
39
+ * prunes a dangling per-widget jar.mn line), so they have no deployed copy
40
+ * to go stale and get no candidate by design. The storybook stories prefix
41
+ * is likewise skipped.
42
+ *
43
+ * @param projectRoot - Project root directory
44
+ * @param files - Engine-relative paths the export will capture
45
+ */
46
+ export async function findStaleFurnaceComponentsForFiles(projectRoot, files) {
47
+ try {
48
+ if (files.length === 0)
49
+ return [];
50
+ if (!(await furnaceConfigExists(projectRoot)))
51
+ return [];
52
+ const config = await loadFurnaceConfig(projectRoot);
53
+ const state = await loadFurnaceState(projectRoot);
54
+ if (!state.appliedChecksums)
55
+ return [];
56
+ const furnacePaths = getFurnacePaths(projectRoot);
57
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
58
+ const ftlPrefix = ftlDir.endsWith('/') ? ftlDir : `${ftlDir}/`;
59
+ const candidates = [
60
+ ...Object.entries(config.overrides).map(([name, cfg]) => ({
61
+ name,
62
+ type: 'override',
63
+ prefix: cfg.basePath.endsWith('/') ? cfg.basePath : `${cfg.basePath}/`,
64
+ exactFiles: [],
65
+ })),
66
+ ...Object.entries(config.custom).map(([name, cfg]) => ({
67
+ name,
68
+ type: 'custom',
69
+ prefix: cfg.targetPath.endsWith('/') ? cfg.targetPath : `${cfg.targetPath}/`,
70
+ // A localized non-sharedFtl component also owns exactly one
71
+ // deployed file in the shared FTL dir (applyCustomFtlFile copies
72
+ // `<name>.ftl` there). sharedFtl bundles are not furnace-deployed.
73
+ exactFiles: cfg.localized && cfg.sharedFtl === undefined ? [`${ftlPrefix}${name}.ftl`] : [],
74
+ })),
75
+ ];
76
+ const stale = [];
77
+ for (const candidate of candidates) {
78
+ if (!files.some((file) => file.startsWith(candidate.prefix) || candidate.exactFiles.includes(file)))
79
+ continue;
80
+ const sourceRoot = candidate.type === 'override' ? furnacePaths.overridesDir : furnacePaths.customDir;
81
+ const sourceDir = `${sourceRoot}/${candidate.name}`;
82
+ if (!(await pathExists(sourceDir)))
83
+ continue;
84
+ const previous = extractComponentChecksums(state.appliedChecksums, candidate.type, candidate.name);
85
+ if (await hasComponentChanged(sourceDir, previous)) {
86
+ stale.push({ name: candidate.name, type: candidate.type, prefix: candidate.prefix });
87
+ }
88
+ }
89
+ return stale;
90
+ }
91
+ catch (error) {
92
+ verbose(`Stale-furnace export gate skipped due to an error: ${error instanceof Error ? error.message : String(error)}`);
93
+ return [];
94
+ }
95
+ }
96
+ /**
97
+ * Gate helper for export/re-export: refuses (or, with `allow`, warns) when
98
+ * any exported file belongs to a furnace component whose source changed
99
+ * since the last apply — the export would capture the stale deployed copy.
100
+ *
101
+ * @param projectRoot - Project root directory
102
+ * @param files - Engine-relative paths the export will capture
103
+ * @param allow - True when the operator passed `--allow-stale-furnace`
104
+ * @param command - Which command runs the gate (message wording only)
105
+ */
106
+ export async function enforceFreshFurnaceSources(projectRoot, files, allow, command) {
107
+ const stale = await findStaleFurnaceComponentsForFiles(projectRoot, files);
108
+ if (stale.length === 0)
109
+ return;
110
+ const list = stale
111
+ .map((component) => `${component.name} (components/${component.type === 'custom' ? 'custom' : 'overrides'}/${component.name}/ → ${component.prefix})`)
112
+ .join(', ');
113
+ const message = `Component source${stale.length === 1 ? '' : 's'} changed since the last furnace apply: ${list}. ` +
114
+ `The deployed engine cop${stale.length === 1 ? 'y is' : 'ies are'} stale, so this ${command} would ` +
115
+ 'capture the OLD component content into the patch. Run "fireforge furnace deploy" (or ' +
116
+ '"fireforge furnace apply") first so the export captures the current source, or pass ' +
117
+ '--allow-stale-furnace to export the deployed copy anyway.';
118
+ if (allow) {
119
+ warn(message);
120
+ return;
121
+ }
122
+ throw new GeneralError(message);
123
+ }
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { hasRawCssColors } from '../utils/regex.js';
5
5
  import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
6
- async function validateMjsCompatibility(mjsPath, tagName) {
6
+ async function validateMjsCompatibility(mjsPath, tagName, isLibrary) {
7
7
  if (!(await pathExists(mjsPath)))
8
8
  return [];
9
9
  const mjsContent = await readText(mjsPath);
@@ -11,6 +11,12 @@ async function validateMjsCompatibility(mjsPath, tagName) {
11
11
  if (hasRelativeModuleImport(mjsContent)) {
12
12
  issues.push(createIssue(tagName, 'error', 'relative-import', 'Imports must use chrome:// URIs, not relative paths.'));
13
13
  }
14
+ // A library component (kind: "library") deliberately defines no element:
15
+ // it exports a base class + helpers for other components to extend, so
16
+ // the define/extends requirements do not apply.
17
+ if (isLibrary) {
18
+ return issues;
19
+ }
14
20
  if (!hasCustomElementDefineCall(mjsContent)) {
15
21
  issues.push(createIssue(tagName, 'error', 'no-custom-element-define', 'Missing customElements.define() call. Component will not be registered.'));
16
22
  }
@@ -124,7 +130,8 @@ export async function validateCompatibility(componentDir, tagName, type, config,
124
130
  const issues = [];
125
131
  const mjsPath = join(componentDir, `${tagName}.mjs`);
126
132
  const cssPath = join(componentDir, `${tagName}.css`);
127
- const mjsIssues = await validateMjsCompatibility(mjsPath, tagName);
133
+ const isLibrary = type === 'custom' && config?.custom[tagName]?.kind === 'library';
134
+ const mjsIssues = await validateMjsCompatibility(mjsPath, tagName, isLibrary);
128
135
  issues.push(...mjsIssues);
129
136
  const cssIssues = await validateCssCompatibility(cssPath, tagName, type, config, root);
130
137
  issues.push(...cssIssues);
@@ -27,10 +27,33 @@ export function hasPositiveTabindex(content) {
27
27
  return value !== undefined && parseInt(value, 10) > 0;
28
28
  });
29
29
  }
30
+ /**
31
+ * Collects `[start, end)` spans of `<label>…</label>` elements whose content
32
+ * includes actual label text. Labels cannot nest per HTML, so the non-greedy
33
+ * inner match is sound. A span qualifies only when the inner content minus
34
+ * tags still contains non-whitespace — an empty or tag-only wrapper provides
35
+ * no accessible name. A `${…}` Lit binding counts as label text: dynamic
36
+ * (usually localized) text is a legitimate accessible name.
37
+ */
38
+ function collectLabelledSpans(content) {
39
+ const spans = [];
40
+ const labelPattern = /<label\b[^>]*>([\s\S]*?)<\/label>/gi;
41
+ let labelMatch;
42
+ while ((labelMatch = labelPattern.exec(content)) !== null) {
43
+ const inner = labelMatch[1] ?? '';
44
+ if (/\S/.test(inner.replace(/<[^>]*>/g, ' '))) {
45
+ spans.push({ start: labelMatch.index, end: labelMatch.index + labelMatch[0].length });
46
+ }
47
+ }
48
+ return spans;
49
+ }
30
50
  /** Detects form inputs without associated labels. */
31
51
  export function hasUnlabelledFormInput(content) {
32
52
  // Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
33
- // (id implies an external <label for="..."> could exist)
53
+ // (id implies an external <label for="..."> could exist). A control wrapped
54
+ // in a <label> that carries actual text is implicitly associated and is
55
+ // exempt as well.
56
+ const labelledSpans = collectLabelledSpans(content);
34
57
  const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
35
58
  let inputMatch;
36
59
  while ((inputMatch = inputPattern.exec(content)) !== null) {
@@ -41,6 +64,10 @@ export function hasUnlabelledFormInput(content) {
41
64
  /type\s*=\s*["']hidden["']/i.test(attrs)) {
42
65
  continue;
43
66
  }
67
+ const index = inputMatch.index;
68
+ if (labelledSpans.some((span) => index >= span.start && index < span.end)) {
69
+ continue;
70
+ }
44
71
  return true;
45
72
  }
46
73
  return false;
@@ -37,8 +37,9 @@ export async function validateStructure(componentDir, tagName, type, customConfi
37
37
  message: `Required file ${tagName}.mjs not found.`,
38
38
  });
39
39
  }
40
- // .css should exist
41
- if (!(await pathExists(cssPath))) {
40
+ // .css should exist — except for library components (base class + helpers,
41
+ // no element of their own), which render nothing and need no stylesheet.
42
+ if (!(await pathExists(cssPath)) && customConfig?.kind !== 'library') {
42
43
  issues.push({
43
44
  component: tagName,
44
45
  severity: 'warning',
@@ -34,6 +34,11 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
34
34
  * on lines 1–2 with the MPL header on lines 3+, which the raw
35
35
  * `startsWith` check would otherwise miss.
36
36
  *
37
+ * The MPL-2.0 header is additionally matched on normalized whitespace
38
+ * (see {@link normalizeCommentHead}) so upstream files using the older
39
+ * Mozilla wrap — breaking after "file," instead of "with this" — are
40
+ * accepted too; only the wrap position differs, never the wording.
41
+ *
37
42
  * @param content - File content to check
38
43
  * @param style - Comment syntax of the file
39
44
  */
@@ -51,6 +56,13 @@ export declare function hasAnyLicenseHeader(content: string, style: CommentStyle
51
56
  * patch-lint, making it dead code for every other project license and
52
57
  * forcing repo-side audit workarounds.
53
58
  *
59
+ * Matching is wrap-agnostic: after the exact `startsWith` fast path, the
60
+ * leading block comment is compared on normalized whitespace (see
61
+ * {@link normalizeCommentHead}) so the older upstream wrap — breaking
62
+ * after "file," instead of "with this", as `ext-browser.js` ships —
63
+ * passes too. Only the line-break position may differ; altered wording
64
+ * still rejects.
65
+ *
54
66
  * @param content - File content to check
55
67
  */
56
68
  export declare function hasUpstreamMplBlockHeader(content: string): boolean;
@@ -86,6 +86,59 @@ function stripLeadingEditorDirectives(content) {
86
86
  } while (result !== prev);
87
87
  return result;
88
88
  }
89
+ /**
90
+ * Collapses all whitespace runs to single spaces and trims, so header
91
+ * comparisons ignore the exact line-break/wrap positions.
92
+ */
93
+ function normalizeText(text) {
94
+ return text.replace(/\s+/g, ' ').trim();
95
+ }
96
+ /**
97
+ * The canonical upstream MPL-2.0 notice as a single normalized sentence
98
+ * (including the `http://mozilla.org/MPL/2.0/.` URL). Wrap-agnostic
99
+ * header checks match against this instead of an exact rendering.
100
+ */
101
+ const MPL_HEADER_NORMALIZED = normalizeText(HEADER_LINES['MPL-2.0'].join(' '));
102
+ /**
103
+ * Extracts ONLY the leading comment span of `content` for the given
104
+ * comment style and returns it whitespace-normalized:
105
+ *
106
+ * - `js`: a leading run of `//` lines (markers stripped), or — when the
107
+ * file opens with a block comment instead — the first
108
+ * `/* … *\/` block;
109
+ * - `css`: the first `/* … *\/` block, with the ` * ` continuation-line
110
+ * prefixes stripped (same collapsing
111
+ * {@link normalizeLicenseHeadForScan} does);
112
+ * - `hash`: a leading run of `#` lines (markers stripped).
113
+ *
114
+ * A non-comment first line yields `''`. This lets callers accept a known
115
+ * header text regardless of where upstream wrapped its lines (e.g. the
116
+ * older Mozilla wrap that breaks after "file,"), without ever matching
117
+ * text beyond the leading comment.
118
+ */
119
+ function normalizeCommentHead(content, style) {
120
+ const src = content.replace(/\r\n?/g, '\n');
121
+ const collectLineRun = (marker) => {
122
+ const parts = [];
123
+ for (const line of src.split('\n')) {
124
+ const m = marker.exec(line);
125
+ if (!m)
126
+ break;
127
+ parts.push(m[1] ?? '');
128
+ }
129
+ return normalizeText(parts.join(' '));
130
+ };
131
+ if (style === 'hash') {
132
+ return collectLineRun(/^[ \t]*#[ \t]?(.*)$/);
133
+ }
134
+ if (style === 'js' && /^[ \t]*\/\//.test(src)) {
135
+ return collectLineRun(/^[ \t]*\/\/[ \t]?(.*)$/);
136
+ }
137
+ const block = /^[ \t]*\/\*([\s\S]*?)\*\//.exec(src);
138
+ if (!block)
139
+ return '';
140
+ return normalizeText((block[1] ?? '').replace(/\n[ \t]*\*[ \t]*/g, ' '));
141
+ }
89
142
  /**
90
143
  * Returns true if `content` starts with any known license header for the
91
144
  * given comment style.
@@ -103,6 +156,11 @@ function stripLeadingEditorDirectives(content) {
103
156
  * on lines 1–2 with the MPL header on lines 3+, which the raw
104
157
  * `startsWith` check would otherwise miss.
105
158
  *
159
+ * The MPL-2.0 header is additionally matched on normalized whitespace
160
+ * (see {@link normalizeCommentHead}) so upstream files using the older
161
+ * Mozilla wrap — breaking after "file," instead of "with this" — are
162
+ * accepted too; only the wrap position differs, never the wording.
163
+ *
106
164
  * @param content - File content to check
107
165
  * @param style - Comment syntax of the file
108
166
  */
@@ -116,6 +174,9 @@ export function hasAnyLicenseHeader(content, style) {
116
174
  if (style === 'js' && candidate.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
117
175
  return true;
118
176
  }
177
+ if (normalizeCommentHead(candidate, style).startsWith(MPL_HEADER_NORMALIZED)) {
178
+ return true;
179
+ }
119
180
  }
120
181
  return false;
121
182
  }
@@ -132,11 +193,25 @@ export function hasAnyLicenseHeader(content, style) {
132
193
  * patch-lint, making it dead code for every other project license and
133
194
  * forcing repo-side audit workarounds.
134
195
  *
196
+ * Matching is wrap-agnostic: after the exact `startsWith` fast path, the
197
+ * leading block comment is compared on normalized whitespace (see
198
+ * {@link normalizeCommentHead}) so the older upstream wrap — breaking
199
+ * after "file," instead of "with this", as `ext-browser.js` ships —
200
+ * passes too. Only the line-break position may differ; altered wording
201
+ * still rejects.
202
+ *
135
203
  * @param content - File content to check
136
204
  */
137
205
  export function hasUpstreamMplBlockHeader(content) {
138
206
  const blockHeader = getLicenseHeader('MPL-2.0', 'css');
139
- return (content.startsWith(blockHeader) || stripLeadingEditorDirectives(content).startsWith(blockHeader));
207
+ const candidates = [content, stripLeadingEditorDirectives(content)];
208
+ for (const candidate of candidates) {
209
+ if (candidate.startsWith(blockHeader))
210
+ return true;
211
+ if (normalizeCommentHead(candidate, 'css').startsWith(MPL_HEADER_NORMALIZED))
212
+ return true;
213
+ }
214
+ return false;
140
215
  }
141
216
  /**
142
217
  * Returns true if `content` starts with any known license header in any
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Terminal-echo filter for the KNOWN upstream mozsystemmonitor teardown
3
+ * traceback (0.37.0 item 8).
4
+ *
5
+ * Every headless test run against the 153-beta engine ends with an
6
+ * `AttributeError: 'SystemResourceMonitor' object has no attribute
7
+ * 'stop_time'` traceback at harness teardown — upstream noise that sits
8
+ * exactly where a reader looks for the failure summary. 0.36.0 already made
9
+ * real failure lines beat this traceback in CLASSIFICATION; this filter
10
+ * closes the PRESENTATION gap by collapsing the echoed traceback to one
11
+ * labeled line.
12
+ *
13
+ * Scope is deliberately narrow:
14
+ * - Only the terminal ECHO is filtered. The captured stdout/stderr strings
15
+ * stay raw — the classifier (`test-harness-crash.ts`) depends on the
16
+ * raw traceback for its green-summary override and secondary-noise
17
+ * detection.
18
+ * - Only the exact documented incident is collapsed, and every condition
19
+ * must hold: an `AttributeError` on `SystemResourceMonitor` naming one
20
+ * of the two known attributes (`stop_time`, `poll_interval` — the
21
+ * 0.34.0 guard family), AND a `mozsystemmonitor/resourcemonitor.py`
22
+ * stack frame, AND a previously-seen SUITE_END shutdown marker (shared
23
+ * across the run's stdout/stderr filter instances — the marker usually
24
+ * lands on stdout while the traceback lands on stderr). A novel
25
+ * attribute, a novel exception type in resourcemonitor.py, or a
26
+ * pre-shutdown occurrence is echoed verbatim, always.
27
+ * - The hold buffer is bounded; on overflow the block is flushed verbatim
28
+ * and the filter returns to pass-through, so output is never lost.
29
+ */
30
+ /**
31
+ * Shared across the stdout and stderr filter instances of ONE mach run:
32
+ * SUITE_END typically arrives on stdout while the teardown traceback lands
33
+ * on stderr, so the shutdown-seen flag must be visible to both. The two
34
+ * pipes are independent, so a traceback can theoretically beat the marker
35
+ * through — the block then prints verbatim, the correct failure direction.
36
+ */
37
+ export interface TeardownNoiseContext {
38
+ shutdownSeen: boolean;
39
+ }
40
+ /** Fresh per-run context — hand the same instance to both stream filters. */
41
+ export declare function createTeardownNoiseContext(): TeardownNoiseContext;
42
+ /** One line replaces the whole recognized traceback block in the echo. */
43
+ export declare const KNOWN_TEARDOWN_NOISE_ANNOTATION: string;
44
+ /** Chunk-safe, line-buffered echo filter. See module doc. */
45
+ export interface KnownTeardownNoiseFilter {
46
+ /** Feed a raw chunk; returns the text to echo now (possibly empty). */
47
+ transform(chunk: string): string;
48
+ /** Flush any buffered residue verbatim (call after the stream closes). */
49
+ flush(): string;
50
+ }
51
+ /**
52
+ * Creates a stateful filter for one output stream. Complete lines outside a
53
+ * traceback pass straight through (only the trailing partial line is held
54
+ * back); a `Traceback (most recent call last)` header switches to hold mode
55
+ * until the block ends, then either the one-line annotation (recognized
56
+ * signature after a seen shutdown marker) or the verbatim block (anything
57
+ * else) is emitted. Pass the run's shared {@link TeardownNoiseContext} so
58
+ * both stream filters see the same shutdown flag.
59
+ */
60
+ export declare function createKnownTeardownNoiseFilter(context?: TeardownNoiseContext): KnownTeardownNoiseFilter;
@@ -0,0 +1,193 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Terminal-echo filter for the KNOWN upstream mozsystemmonitor teardown
4
+ * traceback (0.37.0 item 8).
5
+ *
6
+ * Every headless test run against the 153-beta engine ends with an
7
+ * `AttributeError: 'SystemResourceMonitor' object has no attribute
8
+ * 'stop_time'` traceback at harness teardown — upstream noise that sits
9
+ * exactly where a reader looks for the failure summary. 0.36.0 already made
10
+ * real failure lines beat this traceback in CLASSIFICATION; this filter
11
+ * closes the PRESENTATION gap by collapsing the echoed traceback to one
12
+ * labeled line.
13
+ *
14
+ * Scope is deliberately narrow:
15
+ * - Only the terminal ECHO is filtered. The captured stdout/stderr strings
16
+ * stay raw — the classifier (`test-harness-crash.ts`) depends on the
17
+ * raw traceback for its green-summary override and secondary-noise
18
+ * detection.
19
+ * - Only the exact documented incident is collapsed, and every condition
20
+ * must hold: an `AttributeError` on `SystemResourceMonitor` naming one
21
+ * of the two known attributes (`stop_time`, `poll_interval` — the
22
+ * 0.34.0 guard family), AND a `mozsystemmonitor/resourcemonitor.py`
23
+ * stack frame, AND a previously-seen SUITE_END shutdown marker (shared
24
+ * across the run's stdout/stderr filter instances — the marker usually
25
+ * lands on stdout while the traceback lands on stderr). A novel
26
+ * attribute, a novel exception type in resourcemonitor.py, or a
27
+ * pre-shutdown occurrence is echoed verbatim, always.
28
+ * - The hold buffer is bounded; on overflow the block is flushed verbatim
29
+ * and the filter returns to pass-through, so output is never lost.
30
+ */
31
+ const TRACEBACK_HEADER_PATTERN = /^Traceback \(most recent call last\)/;
32
+ const CHAINED_EXCEPTION_CONNECTOR_PATTERN = /^(?:During handling of the above exception, another exception occurred:|The above exception was the direct cause of the following exception:)\s*$/;
33
+ /**
34
+ * Closed allowlist of the documented teardown family's attributes:
35
+ * `stop_time` (the 0.37.0 item-8 incident) and `poll_interval` (the same
36
+ * mozsystemmonitor init failure the 0.34.0 resource-guard family covers —
37
+ * `test-harness-crash.ts` already classifies it as recognized noise). Any
38
+ * other missing attribute is a NEW upstream defect and must print verbatim.
39
+ */
40
+ const KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN = /AttributeError: 'SystemResourceMonitor' object has no attribute '(?:stop_time|poll_interval)'/;
41
+ const RESOURCEMONITOR_FRAME_PATTERN = /mozsystemmonitor[/\\]resourcemonitor\.py/;
42
+ /**
43
+ * Shutdown marker: the harness's SUITE_END line. Matches the shape
44
+ * `test-harness-crash.ts` keys its summary parsing on (module-private
45
+ * there; duplicated here with this cross-reference rather than exported).
46
+ */
47
+ const SHUTDOWN_MARKER_PATTERN = /\bSUITE_END\b/;
48
+ /** Whole-block recognition — every signal must hold (see module doc). */
49
+ function isRecognizedTeardownNoise(block) {
50
+ return (KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN.test(block) && RESOURCEMONITOR_FRAME_PATTERN.test(block));
51
+ }
52
+ /** Fresh per-run context — hand the same instance to both stream filters. */
53
+ export function createTeardownNoiseContext() {
54
+ return { shutdownSeen: false };
55
+ }
56
+ /** One line replaces the whole recognized traceback block in the echo. */
57
+ export const KNOWN_TEARDOWN_NOISE_ANNOTATION = '[FireForge] Known upstream mozsystemmonitor teardown noise (SystemResourceMonitor ' +
58
+ 'AttributeError at harness shutdown) — not a test failure. See FireForge docs.\n';
59
+ /** Bounds on the held traceback block before flushing verbatim. */
60
+ const HOLD_LINE_LIMIT = 100;
61
+ const HOLD_BYTE_LIMIT = 16 * 1024;
62
+ /**
63
+ * Creates a stateful filter for one output stream. Complete lines outside a
64
+ * traceback pass straight through (only the trailing partial line is held
65
+ * back); a `Traceback (most recent call last)` header switches to hold mode
66
+ * until the block ends, then either the one-line annotation (recognized
67
+ * signature after a seen shutdown marker) or the verbatim block (anything
68
+ * else) is emitted. Pass the run's shared {@link TeardownNoiseContext} so
69
+ * both stream filters see the same shutdown flag.
70
+ */
71
+ export function createKnownTeardownNoiseFilter(context = createTeardownNoiseContext()) {
72
+ /** Partial (no trailing newline yet) input line. */
73
+ let partial = '';
74
+ /** Held traceback lines (each WITH its newline) while in hold mode. */
75
+ let held = [];
76
+ let heldBytes = 0;
77
+ /**
78
+ * State machine:
79
+ * 'pass' — outside any traceback; lines echo through.
80
+ * 'inside' — between a Traceback header and its closing exception line.
81
+ * 'closed' — saw the closing `SomeError: …` line; still holding in
82
+ * case a chained-exception connector continues the block
83
+ * (the real fixture chains two tracebacks — the whole
84
+ * chain must be evaluated as ONE block or the second half
85
+ * would print raw after the annotation).
86
+ */
87
+ let state = 'pass';
88
+ const resetHold = () => {
89
+ const block = held.join('');
90
+ held = [];
91
+ heldBytes = 0;
92
+ state = 'pass';
93
+ return block;
94
+ };
95
+ const releaseHeld = () => {
96
+ const block = resetHold();
97
+ if (block.length === 0)
98
+ return '';
99
+ return context.shutdownSeen && isRecognizedTeardownNoise(block)
100
+ ? KNOWN_TEARDOWN_NOISE_ANNOTATION
101
+ : block;
102
+ };
103
+ const hold = (line) => {
104
+ held.push(line);
105
+ heldBytes += line.length;
106
+ if (held.length > HOLD_LINE_LIMIT || heldBytes > HOLD_BYTE_LIMIT) {
107
+ // Pathological block: flush verbatim rather than risk withholding
108
+ // output; do not attempt recognition on oversized blocks.
109
+ return { out: resetHold(), overflowed: true };
110
+ }
111
+ return { out: '', overflowed: false };
112
+ };
113
+ const processLine = (line) => {
114
+ const content = line.replace(/\r?\n$/, '');
115
+ if (state === 'pass') {
116
+ if (TRACEBACK_HEADER_PATTERN.test(content)) {
117
+ state = 'inside';
118
+ return hold(line).out;
119
+ }
120
+ if (SHUTDOWN_MARKER_PATTERN.test(content))
121
+ context.shutdownSeen = true;
122
+ return line;
123
+ }
124
+ if (state === 'inside') {
125
+ const isContinuation = content.trim().length === 0 ||
126
+ /^[ \t]/.test(content) ||
127
+ TRACEBACK_HEADER_PATTERN.test(content);
128
+ const { out, overflowed } = hold(line);
129
+ // The first unindented line is the closing `SomeError: …` line; keep
130
+ // holding in case a chained connector extends the block.
131
+ if (!overflowed && !isContinuation) {
132
+ state = 'closed';
133
+ }
134
+ return out;
135
+ }
136
+ // state === 'closed': only a blank line, a chained-exception connector,
137
+ // or another Traceback header continues the block.
138
+ if (content.trim().length === 0 || CHAINED_EXCEPTION_CONNECTOR_PATTERN.test(content)) {
139
+ return hold(line).out;
140
+ }
141
+ if (TRACEBACK_HEADER_PATTERN.test(content)) {
142
+ const { out, overflowed } = hold(line);
143
+ if (!overflowed)
144
+ state = 'inside';
145
+ return out;
146
+ }
147
+ return releaseHeld() + processLineInPass(line);
148
+ };
149
+ // Re-dispatch a line through pass-mode handling after a block release —
150
+ // the line that ended the block may itself start a new traceback.
151
+ const processLineInPass = (line) => {
152
+ const content = line.replace(/\r?\n$/, '');
153
+ if (TRACEBACK_HEADER_PATTERN.test(content)) {
154
+ state = 'inside';
155
+ return hold(line).out;
156
+ }
157
+ if (SHUTDOWN_MARKER_PATTERN.test(content))
158
+ context.shutdownSeen = true;
159
+ return line;
160
+ };
161
+ return {
162
+ transform(chunk) {
163
+ let out = '';
164
+ let buffer = partial + chunk;
165
+ partial = '';
166
+ let newlineIndex = buffer.indexOf('\n');
167
+ while (newlineIndex !== -1) {
168
+ const line = buffer.slice(0, newlineIndex + 1);
169
+ buffer = buffer.slice(newlineIndex + 1);
170
+ out += processLine(line);
171
+ newlineIndex = buffer.indexOf('\n');
172
+ }
173
+ partial = buffer;
174
+ return out;
175
+ },
176
+ flush() {
177
+ let out = '';
178
+ if (state !== 'pass') {
179
+ // A stream that closes mid-block: fold the trailing partial line in
180
+ // and evaluate — the final exception line is often the last thing
181
+ // printed, without a trailing newline.
182
+ if (partial.length > 0) {
183
+ held.push(partial);
184
+ partial = '';
185
+ }
186
+ out += releaseHeld();
187
+ }
188
+ out += partial;
189
+ partial = '';
190
+ return out;
191
+ },
192
+ };
193
+ }