@hominis/fireforge 0.18.6 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,29 +230,37 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
230
230
 
231
231
  By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
232
232
 
233
- | Check | Scope | Severity |
234
- | ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------ |
235
- | `missing-license-header` | New files (JS/CSS/FTL) | error |
236
- | `relative-import` | JS/MJS files | error |
237
- | `token-prefix-violation` | CSS files (with furnace) | error |
238
- | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
239
- | `duplicate-new-file-creation` | Same path created by multiple patches | error |
240
- | `forward-import` | Patch imports from a later-patch file | error |
241
- | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
242
- | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
243
- | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
244
- | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
245
- | `missing-modification-comment` | Modified upstream JS/MJS | warning |
246
- | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
247
- | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
248
- | `observer-topic-naming` | Observer topics with binaryName | warning |
249
- | `large-patch-files` | Patches affecting many files (tiered: >5 general, >5 test, >60 branding) | warning |
250
- | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 8000/18000/30000 branding) | notice / warning / error |
233
+ | Check | Scope | Severity |
234
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------ |
235
+ | `missing-license-header` | New files (JS/CSS/FTL) | error |
236
+ | `relative-import` | JS/MJS files | error |
237
+ | `token-prefix-violation` | CSS files (with furnace) | error |
238
+ | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
239
+ | `duplicate-new-file-creation` | Same path created by multiple patches | error |
240
+ | `forward-import` | Patch imports from a later-patch file | error |
241
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
242
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
243
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
244
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
245
+ | `missing-jsdoc-class-method` | Class-method exports in patch-owned `.sys.mjs` (opt-in) | configurable |
246
+ | `jsdoc-class-method-param-mismatch` | Class-method exports in patch-owned `.sys.mjs` (opt-in) | configurable |
247
+ | `jsdoc-class-method-missing-returns` | Class-method exports in patch-owned `.sys.mjs` (opt-in) | configurable |
248
+ | `test-needs-assertion` | Patch-introduced `browser_*.js` test files (opt-in) | configurable |
249
+ | `missing-modification-comment` | Modified upstream JS/MJS | warning |
250
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
251
+ | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
252
+ | `observer-topic-naming` | Observer topics with binaryName | warning |
253
+ | `large-patch-files` | Patches affecting many files (tiered: >5 general, >5 test, >60 branding) | warning |
254
+ | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 8000/18000/30000 branding) | notice / warning / error |
251
255
 
252
256
  **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
253
257
 
254
258
  **Optional `checkJs` pass.** Enable a TypeScript-esque bastardization of type checking for patch-owned `.sys.mjs` files by adding `"patchLint": { "checkJs": true }` to `fireforge.json`. This uses the TypeScript compiler API with `allowJs + checkJs + noEmit`, scoped only to patch-owned files. Firefox globals (`Services`, `ChromeUtils`, `lazy`, etc.) are shimmed automatically. Module-resolution errors from Firefox's `resource://` and `chrome://` URL schemes are suppressed since TypeScript cannot follow these. This pass solely focuses on type errors within the patch-owned code itself (mismatched JSDoc types, wrong argument counts, unreachable code, etc.).
255
259
 
260
+ **Optional `jsdocClassMethods` enforcement.** Set `"patchLint": { "jsdocClassMethods": "warning" | "error" }` in `fireforge.json` to extend JSDoc validation to class-method exports inside patch-owned `.sys.mjs` files. Every public method (instance and static), parameter-bearing constructor, getter, and setter must carry a leading JSDoc block; `@param` names must match the parameter list, and `@returns` is required when a method returns a value (getters and setters are exempt from `@returns`). Methods whose name starts with `_` or `#`, methods carrying `@private` or `@internal` in their JSDoc, and zero-parameter constructors are exempt. Defaults to `"off"`, so upgrading is a no-op until the knob is set.
261
+
262
+ **Optional `testAssertionFloor` enforcement.** Set `"patchLint": { "testAssertionFloor": "warning" | "error" }` to require that every `browser_*.js` test file introduced by the current patch contains at least one assertion (`Assert.*`, `ok()`, `is()`, `isnot()`, or `isDeeply()`). Smoke-only tests that load the script and exit without asserting any user-visible behavior are flagged as `test-needs-assertion`. Comment-only assertions do not count — comments are stripped before scanning. `head.js` and `head_*.js` test helpers are exempt; modified upstream tests are out of scope (V1 only flags newly-introduced files). Defaults to `"off"`.
263
+
256
264
  The two cross-patch rules (`duplicate-new-file-creation` and `forward-import`) run over the whole patch queue rather than a single diff, catching ordering issues that only surface during `import`. Forward-import detection compares leaf filenames, so a false positive is theoretically possible when two patches create files with the same basename in different directories. Suppress with an inline `// fireforge-ignore: forward-import` comment on or above the import line. Both `forward-import` and `raw-color-value` support inline suppression comments (`// fireforge-ignore: forward-import` and `/* fireforge-ignore: raw-color-value */` respectively).
257
265
 
258
266
  </details>
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
6
- import { assertMarionettePortAvailable } from '../core/marionette-port.js';
6
+ import { assertMarionettePortAvailable, extractForwardedMarionettePort, isMarionetteFlavor, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
@@ -238,6 +238,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
238
238
  warn(formatStaleBuildWarning(stale));
239
239
  }
240
240
  }
241
+ // Resolve the effective Marionette port. Operator precedence:
242
+ // 1. `--marionette-port` (first-class option, parsed at the CLI layer)
243
+ // 2. forwarded `--mach-arg --marionette-port=NNNN` /
244
+ // `--mach-arg --setpref=marionette.port=NNNN`
245
+ // 3. fall back to `DEFAULT_MARIONETTE_PORT` semantics inside the probes
246
+ // (passed as `undefined`).
247
+ // Without (2), an operator working around a stale listener via the
248
+ // documented `--mach-arg --marionette-port=NNNN` workaround would still
249
+ // hit the wrapper preflight refusing on 2828 before the forwarded arg
250
+ // ever reached mach.
251
+ const forwardedPort = options.machArg
252
+ ? extractForwardedMarionettePort(options.machArg)
253
+ : undefined;
254
+ const effectivePort = options.marionettePort ?? forwardedPort;
241
255
  // Stale-browser probe: an interrupted earlier test run can leave a
242
256
  // Firefox/ForgeFresh/Hominis instance listening on the Marionette
243
257
  // control port, which breaks the next mach test launch with a
@@ -246,7 +260,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
246
260
  // generic bind failure. 2026-04-21 eval (Finding #20): a stale
247
261
  // `-marionette` process from `fresh/` poisoned a later test run in
248
262
  // the sibling `hominis/` workspace.
249
- await assertMarionettePortAvailable(undefined, { binaryName: projectConfig.binaryName });
263
+ await assertMarionettePortAvailable(effectivePort, { binaryName: projectConfig.binaryName });
250
264
  // `--doctor` runs a short marionette handshake probe. When test paths are
251
265
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
252
266
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -259,7 +273,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
259
273
  // clack box-drawing framing.
260
274
  process.stdout.write('Running marionette preflight...\n');
261
275
  info('Running marionette preflight...');
262
- const preflight = await runMarionettePreflight(paths.engine);
276
+ const preflight = effectivePort !== undefined
277
+ ? await runMarionettePreflight(paths.engine, { port: effectivePort })
278
+ : await runMarionettePreflight(paths.engine);
263
279
  // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
264
280
  // `success()` + `outro()` + a direct `process.stdout.write` as a
265
281
  // belt-and-suspenders but still reproducibly dropped the PASS summary
@@ -303,6 +319,30 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
303
319
  if (options.machArg && options.machArg.length > 0) {
304
320
  extraArgs.push(...options.machArg);
305
321
  }
322
+ // Auto-forward the Marionette port to mach when `--marionette-port` is
323
+ // set. We use `--setpref=marionette.port=<n>` because the marionette
324
+ // listener reads that pref before binding (browser-chrome / mochitest
325
+ // path); xpcshell never reads it, so the pref is a no-op there.
326
+ //
327
+ // Skip forwarding when the operator already supplied an equivalent arg
328
+ // via `--mach-arg` — duplicates would be confusing without changing
329
+ // semantics. Skip with a notice for clearly-non-marionette flavours
330
+ // (xpcshell, or paths that don't look browser-chrome/mochitest) so the
331
+ // operator knows the preflight took the override but mach was not
332
+ // auto-configured. Same escape valve applies: any mach arg can still
333
+ // be supplied via `--mach-arg`.
334
+ if (options.marionettePort !== undefined) {
335
+ const operatorAlreadyForwarded = forwardedPort !== undefined;
336
+ if (operatorAlreadyForwarded) {
337
+ info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
338
+ }
339
+ else if (isMarionetteFlavor(normalizedPaths, options.machArg ?? [])) {
340
+ extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
341
+ }
342
+ else {
343
+ info(`--marionette-port=${options.marionettePort} applied to the preflight probe, but the test paths do not look browser-chrome/mochitest — mach is not auto-configured. Pass --mach-arg --setpref=marionette.port=${options.marionettePort} explicitly if mach should also use this port.`);
344
+ }
345
+ }
306
346
  // xpcshell appdir auto-injection — see src/core/xpcshell-appdir.ts for the
307
347
  // full motivation. On rebranded forks (appname != "firefox") the upstream
308
348
  // harness silently ignores `firefox-appdir = "browser"` directives in the
@@ -368,6 +408,13 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
368
408
  acc.push(value);
369
409
  return acc;
370
410
  }, [])
411
+ .option('--marionette-port <port>', 'Override the Marionette control port (default 2828) for the stale-browser probe, the --doctor preflight, and the auto-forwarded --setpref=marionette.port=<n> arg passed to mach. Use this when a stale process holds 2828 or a CI runner reserves a different port.', (raw) => {
412
+ const n = Number.parseInt(raw, 10);
413
+ if (!Number.isFinite(n) || n < 1 || n > 65535) {
414
+ throw new GeneralError(`--marionette-port must be an integer in 1..65535 (got "${raw}")`);
415
+ }
416
+ return n;
417
+ })
371
418
  .action(withErrorHandling(async (paths, options) => {
372
419
  await testCommand(getProjectRoot(), paths, pickDefined(options));
373
420
  }));
@@ -15,8 +15,12 @@ export type AcornESTreeNode<T extends estree.Node = estree.Node> = T & {
15
15
  * Parse JavaScript source as a **script** (not an ES module).
16
16
  * All Mozilla chrome JS files (`browser-main.js`, `browser-init.js`,
17
17
  * `customElements.js`, etc.) are scripts that run in a privileged scope.
18
+ *
19
+ * @param content - Source text to parse
20
+ * @param onComment - Optional array that acorn fills with comment nodes
21
+ * @returns Parsed program AST with character-offset positions
18
22
  */
19
- export declare function parseScript(content: string): AcornESTreeNode<estree.Program>;
23
+ export declare function parseScript(content: string, onComment?: acorn.Comment[]): AcornESTreeNode<estree.Program>;
20
24
  /**
21
25
  * Parse JavaScript source as an **ES module**.
22
26
  * Used for `.sys.mjs` files which use static import/export syntax.
@@ -5,12 +5,19 @@ import { walk } from 'estree-walker';
5
5
  * Parse JavaScript source as a **script** (not an ES module).
6
6
  * All Mozilla chrome JS files (`browser-main.js`, `browser-init.js`,
7
7
  * `customElements.js`, etc.) are scripts that run in a privileged scope.
8
+ *
9
+ * @param content - Source text to parse
10
+ * @param onComment - Optional array that acorn fills with comment nodes
11
+ * @returns Parsed program AST with character-offset positions
8
12
  */
9
- export function parseScript(content) {
10
- return acorn.parse(content, {
13
+ export function parseScript(content, onComment) {
14
+ const opts = {
11
15
  sourceType: 'script',
12
16
  ecmaVersion: 'latest',
13
- });
17
+ };
18
+ if (onComment)
19
+ opts.onComment = onComment;
20
+ return acorn.parse(content, opts);
14
21
  }
15
22
  /**
16
23
  * Parse JavaScript source as an **ES module**.
@@ -2,9 +2,11 @@
2
2
  * Heuristic test for "this looks like a packaged-test source file" — the
3
3
  * audit routes such paths to `_tests/` instead of `dist/`. Matches
4
4
  * mochitest / xpcshell / browser-chrome conventions: any source under a
5
- * `/test/` or `/tests/` directory, or with a `browser_` / `test_` prefix
6
- * on a `.js`/`.toml` basename. Test manifests (`*.toml`, `*.list`,
7
- * `*.ini`) under those directories also qualify.
5
+ * `/test/` or `/tests/` directory, anywhere under a `testing/` subtree
6
+ * (which holds mochitest / marionette / xpcshell harness sources), or
7
+ * with a `browser_` / `test_` prefix on a `.js`/`.toml` basename. Test
8
+ * manifests (`*.toml`, `*.list`, `*.ini`) under those directories also
9
+ * qualify.
8
10
  *
9
11
  * @param sourcePath Engine-relative POSIX path
10
12
  * @returns True when the file belongs to the test tree, not the bundle
@@ -30,9 +30,11 @@ const MAX_SCAN_DEPTH = 12;
30
30
  * Heuristic test for "this looks like a packaged-test source file" — the
31
31
  * audit routes such paths to `_tests/` instead of `dist/`. Matches
32
32
  * mochitest / xpcshell / browser-chrome conventions: any source under a
33
- * `/test/` or `/tests/` directory, or with a `browser_` / `test_` prefix
34
- * on a `.js`/`.toml` basename. Test manifests (`*.toml`, `*.list`,
35
- * `*.ini`) under those directories also qualify.
33
+ * `/test/` or `/tests/` directory, anywhere under a `testing/` subtree
34
+ * (which holds mochitest / marionette / xpcshell harness sources), or
35
+ * with a `browser_` / `test_` prefix on a `.js`/`.toml` basename. Test
36
+ * manifests (`*.toml`, `*.list`, `*.ini`) under those directories also
37
+ * qualify.
36
38
  *
37
39
  * @param sourcePath Engine-relative POSIX path
38
40
  * @returns True when the file belongs to the test tree, not the bundle
@@ -41,6 +43,13 @@ export function isTestPath(sourcePath) {
41
43
  if (sourcePath.includes('/test/') || sourcePath.includes('/tests/')) {
42
44
  return true;
43
45
  }
46
+ // `testing/{mochitest,marionette,xpcshell,...}` are test-infrastructure
47
+ // trees that ship under `_tests/`, not `dist/`. Match both as a root
48
+ // segment (e.g. `testing/mochitest/api.js`) and as an interior segment
49
+ // (e.g. a vendored harness under `third_party/.../testing/...`).
50
+ if (sourcePath.startsWith('testing/') || sourcePath.includes('/testing/')) {
51
+ return true;
52
+ }
44
53
  const name = basename(sourcePath);
45
54
  if (/^browser_.+\.(js|toml|ini)$/.test(name))
46
55
  return true;
@@ -19,7 +19,7 @@ export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
20
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "markerComment"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "markerComment"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "markerComment"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -47,6 +47,9 @@ export const SUPPORTED_CONFIG_PATHS = [
47
47
  'patchLint',
48
48
  'patchLint.checkJs',
49
49
  'patchLint.rawColorAllowlist',
50
+ 'patchLint.jsdocClassMethods',
51
+ 'patchLint.testAssertionFloor',
52
+ 'patchLint.chromeScriptJsDoc',
50
53
  'markerComment',
51
54
  ];
52
55
  /**
@@ -129,22 +129,7 @@ export function validateConfig(data) {
129
129
  // PatchLint
130
130
  const patchLintRec = optionalConfigObject(rec, 'patchLint');
131
131
  if (patchLintRec) {
132
- config.patchLint = {};
133
- const checkJs = patchLintRec.raw('checkJs');
134
- if (checkJs !== undefined) {
135
- if (typeof checkJs !== 'boolean') {
136
- throw new ConfigError('Config field "patchLint.checkJs" must be a boolean');
137
- }
138
- config.patchLint.checkJs = checkJs;
139
- }
140
- const rawColorAllowlist = patchLintRec.raw('rawColorAllowlist');
141
- if (rawColorAllowlist !== undefined) {
142
- if (!Array.isArray(rawColorAllowlist) ||
143
- rawColorAllowlist.some((v) => typeof v !== 'string')) {
144
- throw new ConfigError('Config field "patchLint.rawColorAllowlist" must be an array of strings');
145
- }
146
- config.patchLint.rawColorAllowlist = rawColorAllowlist;
147
- }
132
+ config.patchLint = parsePatchLintBlock(patchLintRec);
148
133
  }
149
134
  // Warn on unknown root keys
150
135
  const knownRootKeys = new Set(SUPPORTED_CONFIG_ROOT_KEYS);
@@ -210,4 +195,44 @@ function optionalConfigObject(rec, key) {
210
195
  throw new ConfigError(`Config field "${key}" must be an object`);
211
196
  }
212
197
  }
198
+ const SEVERITY_GATE_VALUES = ['off', 'warning', 'error'];
199
+ function parseSeverityGate(raw, label) {
200
+ if (raw === undefined)
201
+ return undefined;
202
+ if (typeof raw !== 'string' || !SEVERITY_GATE_VALUES.includes(raw)) {
203
+ throw new ConfigError(`Config field "${label}" must be one of: ${SEVERITY_GATE_VALUES.join(', ')}`);
204
+ }
205
+ return raw;
206
+ }
207
+ function parsePatchLintBlock(rec) {
208
+ const out = {};
209
+ const checkJs = rec.raw('checkJs');
210
+ if (checkJs !== undefined) {
211
+ if (typeof checkJs !== 'boolean') {
212
+ throw new ConfigError('Config field "patchLint.checkJs" must be a boolean');
213
+ }
214
+ out.checkJs = checkJs;
215
+ }
216
+ const rawColorAllowlist = rec.raw('rawColorAllowlist');
217
+ if (rawColorAllowlist !== undefined) {
218
+ if (!Array.isArray(rawColorAllowlist) ||
219
+ rawColorAllowlist.some((v) => typeof v !== 'string')) {
220
+ throw new ConfigError('Config field "patchLint.rawColorAllowlist" must be an array of strings');
221
+ }
222
+ out.rawColorAllowlist = rawColorAllowlist;
223
+ }
224
+ const jsdocClassMethods = parseSeverityGate(rec.raw('jsdocClassMethods'), 'patchLint.jsdocClassMethods');
225
+ if (jsdocClassMethods !== undefined) {
226
+ out.jsdocClassMethods = jsdocClassMethods;
227
+ }
228
+ const testAssertionFloor = parseSeverityGate(rec.raw('testAssertionFloor'), 'patchLint.testAssertionFloor');
229
+ if (testAssertionFloor !== undefined) {
230
+ out.testAssertionFloor = testAssertionFloor;
231
+ }
232
+ const chromeScriptJsDoc = parseSeverityGate(rec.raw('chromeScriptJsDoc'), 'patchLint.chromeScriptJsDoc');
233
+ if (chromeScriptJsDoc !== undefined) {
234
+ out.chromeScriptJsDoc = chromeScriptJsDoc;
235
+ }
236
+ return out;
237
+ }
213
238
  //# sourceMappingURL=config-validate.js.map
@@ -48,3 +48,32 @@ export declare function probeMarionettePort(port?: number): Promise<MarionettePo
48
48
  export declare function assertMarionettePortAvailable(port?: number, options?: {
49
49
  binaryName?: string;
50
50
  }): Promise<void>;
51
+ /**
52
+ * Extracts a `--marionette-port=N` (or `--marionette-port N`) argument from
53
+ * a list of forwarded mach args, if present. Used so an operator passing the
54
+ * port via `--mach-arg --marionette-port=NNNN` gets the same preflight
55
+ * override they would from a first-class `--marionette-port` option, rather
56
+ * than the wrapper probing the default port and refusing.
57
+ *
58
+ * Also recognises `--setpref=marionette.port=NNNN` since that is the path
59
+ * the test command auto-forwards to mach.
60
+ *
61
+ * @param machArgs - Forwarded mach args as they would appear on the command
62
+ * line (one element per token; `--foo=bar` and `--foo bar` both supported).
63
+ * @returns The integer port if a recognised arg is present and parses; else
64
+ * `undefined`.
65
+ */
66
+ export declare function extractForwardedMarionettePort(machArgs: string[]): number | undefined;
67
+ /**
68
+ * Heuristic: do the test paths or forwarded mach args indicate a flavour
69
+ * that actually launches a Marionette-driven browser? Browser-chrome and
70
+ * mochitest do; xpcshell does not. Used to decide whether to auto-forward
71
+ * `--setpref=marionette.port=<n>` to mach when the operator passed
72
+ * `--marionette-port`. A no-paths invocation (the default "run all tests"
73
+ * shape) is treated as marionette-relevant since it includes browser-chrome.
74
+ *
75
+ * @param testPaths - Engine-relative paths after `stripEnginePrefix`.
76
+ * @param machArgs - Forwarded mach args (post-`--mach-arg`).
77
+ * @returns `true` when the run is likely to bind a Marionette listener.
78
+ */
79
+ export declare function isMarionetteFlavor(testPaths: string[], machArgs: string[]): boolean;
@@ -212,4 +212,86 @@ export async function assertMarionettePortAvailable(port = DEFAULT_MARIONETTE_PO
212
212
  throw new GeneralError(`Marionette port ${port} is already in use by ${holder.command} (PID ${holder.pid}). ` +
213
213
  `This is not a FireForge-launched browser; stop the holder process or free the port before rerunning.`);
214
214
  }
215
+ /**
216
+ * Extracts a `--marionette-port=N` (or `--marionette-port N`) argument from
217
+ * a list of forwarded mach args, if present. Used so an operator passing the
218
+ * port via `--mach-arg --marionette-port=NNNN` gets the same preflight
219
+ * override they would from a first-class `--marionette-port` option, rather
220
+ * than the wrapper probing the default port and refusing.
221
+ *
222
+ * Also recognises `--setpref=marionette.port=NNNN` since that is the path
223
+ * the test command auto-forwards to mach.
224
+ *
225
+ * @param machArgs - Forwarded mach args as they would appear on the command
226
+ * line (one element per token; `--foo=bar` and `--foo bar` both supported).
227
+ * @returns The integer port if a recognised arg is present and parses; else
228
+ * `undefined`.
229
+ */
230
+ export function extractForwardedMarionettePort(machArgs) {
231
+ for (let i = 0; i < machArgs.length; i++) {
232
+ const arg = machArgs[i];
233
+ if (arg === undefined)
234
+ continue;
235
+ // `--marionette-port=NNNN`
236
+ let match = /^--marionette-port=(\d+)$/.exec(arg);
237
+ if (match?.[1]) {
238
+ const n = Number.parseInt(match[1], 10);
239
+ if (Number.isFinite(n))
240
+ return n;
241
+ }
242
+ // `--marionette-port NNNN` (two tokens)
243
+ if (arg === '--marionette-port') {
244
+ const next = machArgs[i + 1];
245
+ if (next !== undefined) {
246
+ const n = Number.parseInt(next, 10);
247
+ if (Number.isFinite(n))
248
+ return n;
249
+ }
250
+ }
251
+ // `--setpref=marionette.port=NNNN` — the auto-forward shape; recognised
252
+ // here so a duplicate check at the call site can spot operator-supplied
253
+ // setprefs without re-implementing the parse.
254
+ match = /^--setpref=marionette\.port=(\d+)$/.exec(arg);
255
+ if (match?.[1]) {
256
+ const n = Number.parseInt(match[1], 10);
257
+ if (Number.isFinite(n))
258
+ return n;
259
+ }
260
+ }
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Heuristic: do the test paths or forwarded mach args indicate a flavour
265
+ * that actually launches a Marionette-driven browser? Browser-chrome and
266
+ * mochitest do; xpcshell does not. Used to decide whether to auto-forward
267
+ * `--setpref=marionette.port=<n>` to mach when the operator passed
268
+ * `--marionette-port`. A no-paths invocation (the default "run all tests"
269
+ * shape) is treated as marionette-relevant since it includes browser-chrome.
270
+ *
271
+ * @param testPaths - Engine-relative paths after `stripEnginePrefix`.
272
+ * @param machArgs - Forwarded mach args (post-`--mach-arg`).
273
+ * @returns `true` when the run is likely to bind a Marionette listener.
274
+ */
275
+ export function isMarionetteFlavor(testPaths, machArgs) {
276
+ for (const arg of machArgs) {
277
+ if (/^--flavor=xpcshell\b/.test(arg) || arg === '--flavor=xpcshell-tests')
278
+ return false;
279
+ }
280
+ for (const arg of machArgs) {
281
+ if (/^--flavor=(browser-chrome|mochitest|chrome|a11y)\b/.test(arg))
282
+ return true;
283
+ }
284
+ if (testPaths.length === 0)
285
+ return true;
286
+ for (const path of testPaths) {
287
+ const base = path.split('/').pop() ?? path;
288
+ if (/^browser_.+\.(js|ini|toml)$/.test(base))
289
+ return true;
290
+ if (path.includes('/mochitest/') || path.startsWith('mochitest/'))
291
+ return true;
292
+ if (path.includes('/browser-chrome/') || path.startsWith('browser-chrome/'))
293
+ return true;
294
+ }
295
+ return false;
296
+ }
215
297
  //# sourceMappingURL=marionette-port.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * AST-based JSDoc validation for top-level declarations in patch-owned
3
+ * chrome subscripts (`browser/base/content/<binaryName>*.js` and similar
4
+ * `.js` files loaded via `Services.scriptloader.loadSubScript`).
5
+ *
6
+ * Why a separate module from {@link ./patch-lint-jsdoc.ts}: chrome
7
+ * subscripts are NOT ES modules. They are parsed as scripts (no static
8
+ * `export`) and their top-level `class`/`function` declarations are
9
+ * exposed to the loading window as globals rather than declared exports.
10
+ * The export-walker in `patch-lint-jsdoc.ts` would never visit them.
11
+ *
12
+ * The rule shape — "every top-level class needs a JSDoc, every method
13
+ * needs one when `chromeScriptJsDoc` is at `warning`/`error`, every
14
+ * top-level function needs a matching @param/@returns block" — is
15
+ * identical to the `.sys.mjs` rule once you remove the `export` framing,
16
+ * so the per-declaration validators are reused verbatim from the export
17
+ * module.
18
+ */
19
+ import type { PatchLintIssue } from '../types/commands/index.js';
20
+ import type { PatchLintSeverityGate } from '../types/config.js';
21
+ import type { JsDocIssue, ValidateExportJsDocOptions } from './patch-lint-jsdoc.js';
22
+ /**
23
+ * Validates JSDoc on top-level declarations in a chrome-subscript `.js`
24
+ * source file. A parse failure returns an empty issue list — chrome
25
+ * subscripts that use module-only syntax (rare) silently disable the
26
+ * rule rather than emitting confusing parse-error issues.
27
+ *
28
+ * @param source - File content
29
+ * @param options - Optional gates (e.g. class-method JSDoc severity).
30
+ * `classMethodMode` defaults to `'off'` — the orchestrator passes the
31
+ * `chromeScriptJsDoc` severity here so a single knob controls both
32
+ * class-level and method-level enforcement.
33
+ * @returns Array of JSDoc issues found
34
+ */
35
+ export declare function validateChromeScriptJsDoc(source: string, options?: ValidateExportJsDocOptions): JsDocIssue[];
36
+ /**
37
+ * Per-file dispatch: applies the chrome-subscript JSDoc rule to one file
38
+ * if it qualifies, mapping {@link JsDocIssue} into {@link PatchLintIssue}.
39
+ * Extracted so the orchestrator in `patch-lint.ts` stays under the
40
+ * project's per-file line budget — `patch-lint.ts` invokes this helper
41
+ * inline once per affected JS/MJS file. Returns an empty array when the
42
+ * file does not qualify (not a chrome subscript, not patch-owned, or the
43
+ * mode is `'off'` / unset). The orchestrator pre-computes `isChromeOwned`
44
+ * (true iff the file is a patch-owned `.js` non-`.sys.mjs`) so the call
45
+ * site fits on a single line.
46
+ */
47
+ export declare function lintChromeScriptJsDocForFile(file: string, content: string, isChromeOwned: boolean, mode: PatchLintSeverityGate | undefined): PatchLintIssue[];
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * AST-based JSDoc validation for top-level declarations in patch-owned
4
+ * chrome subscripts (`browser/base/content/<binaryName>*.js` and similar
5
+ * `.js` files loaded via `Services.scriptloader.loadSubScript`).
6
+ *
7
+ * Why a separate module from {@link ./patch-lint-jsdoc.ts}: chrome
8
+ * subscripts are NOT ES modules. They are parsed as scripts (no static
9
+ * `export`) and their top-level `class`/`function` declarations are
10
+ * exposed to the loading window as globals rather than declared exports.
11
+ * The export-walker in `patch-lint-jsdoc.ts` would never visit them.
12
+ *
13
+ * The rule shape — "every top-level class needs a JSDoc, every method
14
+ * needs one when `chromeScriptJsDoc` is at `warning`/`error`, every
15
+ * top-level function needs a matching @param/@returns block" — is
16
+ * identical to the `.sys.mjs` rule once you remove the `export` framing,
17
+ * so the per-declaration validators are reused verbatim from the export
18
+ * module.
19
+ */
20
+ import { parseScript } from './ast-utils.js';
21
+ import { validateClassDecl, validateClassMethods, validateFunctionDecl, } from './patch-lint-jsdoc.js';
22
+ /**
23
+ * Validates JSDoc on top-level declarations in a chrome-subscript `.js`
24
+ * source file. A parse failure returns an empty issue list — chrome
25
+ * subscripts that use module-only syntax (rare) silently disable the
26
+ * rule rather than emitting confusing parse-error issues.
27
+ *
28
+ * @param source - File content
29
+ * @param options - Optional gates (e.g. class-method JSDoc severity).
30
+ * `classMethodMode` defaults to `'off'` — the orchestrator passes the
31
+ * `chromeScriptJsDoc` severity here so a single knob controls both
32
+ * class-level and method-level enforcement.
33
+ * @returns Array of JSDoc issues found
34
+ */
35
+ export function validateChromeScriptJsDoc(source, options) {
36
+ const classMethodMode = options?.classMethodMode ?? 'off';
37
+ const comments = [];
38
+ let ast;
39
+ try {
40
+ ast = parseScript(source, comments);
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ const issues = [];
46
+ const body = ast.body;
47
+ for (const node of body) {
48
+ if (node.type === 'FunctionDeclaration') {
49
+ validateFunctionDecl(node, comments, source, issues);
50
+ }
51
+ else if (node.type === 'ClassDeclaration') {
52
+ validateClassDecl(node, comments, source, issues);
53
+ if (classMethodMode !== 'off') {
54
+ validateClassMethods(node, comments, source, issues, classMethodMode);
55
+ }
56
+ }
57
+ // Top-level `var Foo = class {...}` / `let Foo = function() {...}`
58
+ // patterns are intentionally out of scope for V1 — chrome subscripts
59
+ // overwhelmingly use bare `class`/`function` declarations and the
60
+ // variable-init form would require unwrapping the initializer. Add
61
+ // it later if a real chrome subscript uses that shape.
62
+ }
63
+ return issues;
64
+ }
65
+ /**
66
+ * Per-file dispatch: applies the chrome-subscript JSDoc rule to one file
67
+ * if it qualifies, mapping {@link JsDocIssue} into {@link PatchLintIssue}.
68
+ * Extracted so the orchestrator in `patch-lint.ts` stays under the
69
+ * project's per-file line budget — `patch-lint.ts` invokes this helper
70
+ * inline once per affected JS/MJS file. Returns an empty array when the
71
+ * file does not qualify (not a chrome subscript, not patch-owned, or the
72
+ * mode is `'off'` / unset). The orchestrator pre-computes `isChromeOwned`
73
+ * (true iff the file is a patch-owned `.js` non-`.sys.mjs`) so the call
74
+ * site fits on a single line.
75
+ */
76
+ export function lintChromeScriptJsDocForFile(file, content, isChromeOwned, mode) {
77
+ if (!isChromeOwned || !mode || mode === 'off')
78
+ return [];
79
+ const jsdocIssues = validateChromeScriptJsDoc(content, { classMethodMode: mode });
80
+ return jsdocIssues.map((issue) => ({
81
+ file,
82
+ check: issue.check,
83
+ message: issue.message,
84
+ severity: issue.severity ?? mode,
85
+ }));
86
+ }
87
+ //# sourceMappingURL=patch-lint-chrome-jsdoc.js.map
@@ -6,16 +6,61 @@
6
6
  * Separated from `patch-lint.ts` to keep both files within the
7
7
  * project's per-file line budget.
8
8
  */
9
- export type JsDocCheck = 'missing-jsdoc' | 'jsdoc-param-mismatch' | 'jsdoc-missing-returns';
9
+ import type * as acorn from 'acorn';
10
+ import type { ClassDeclaration, FunctionDeclaration } from 'estree';
11
+ import type { AcornESTreeNode } from './ast-utils.js';
12
+ export type JsDocCheck = 'missing-jsdoc' | 'jsdoc-param-mismatch' | 'jsdoc-missing-returns' | 'missing-jsdoc-class-method' | 'jsdoc-class-method-param-mismatch' | 'jsdoc-class-method-missing-returns';
10
13
  export interface JsDocIssue {
11
14
  line: number;
12
15
  check: JsDocCheck;
13
16
  message: string;
17
+ /** Optional severity hint. When undefined, callers default to 'error'. */
18
+ severity?: 'error' | 'warning';
14
19
  }
20
+ export type ClassMethodMode = 'off' | 'warning' | 'error';
21
+ export interface ValidateExportJsDocOptions {
22
+ /** Gate for class-method JSDoc enforcement. Default 'off' (no walking). */
23
+ classMethodMode?: ClassMethodMode;
24
+ }
25
+ /**
26
+ * Validates a top-level function declaration: requires an attached JSDoc
27
+ * comment, then checks @param name matching and @returns presence. Exported
28
+ * so the chrome-subscript validator (`patch-lint-chrome-jsdoc.ts`) can reuse
29
+ * it on `parseScript`-produced declarations — the rule shape is identical
30
+ * between ES-module exports and chrome-subscript top-level declarations.
31
+ *
32
+ * @param fn - FunctionDeclaration AST node
33
+ * @param comments - All Acorn comments collected from the source
34
+ * @param source - Original source text (for line-number resolution)
35
+ * @param issues - Output sink for JSDoc issues
36
+ * @param lookupStart - Optional offset to use when locating the attached
37
+ * JSDoc (defaults to `fn.start`). Used by the export-walker so the JSDoc
38
+ * is found relative to the `export` keyword rather than the inner decl.
39
+ */
40
+ export declare function validateFunctionDecl(fn: AcornESTreeNode<FunctionDeclaration>, comments: acorn.Comment[], source: string, issues: JsDocIssue[], lookupStart?: number): void;
41
+ /**
42
+ * Validates a top-level class declaration: requires an attached JSDoc
43
+ * comment on the class itself. Method-level checks live in
44
+ * {@link validateClassMethods}. Exported for reuse in the chrome-subscript
45
+ * validator.
46
+ */
47
+ export declare function validateClassDecl(cls: AcornESTreeNode<ClassDeclaration>, comments: acorn.Comment[], source: string, issues: JsDocIssue[], lookupStart?: number): void;
48
+ /**
49
+ * Walks an exported class body and emits class-method JSDoc issues per
50
+ * the configured severity. Skip rules (in evaluation order):
51
+ * 1. private syntax (`#foo`) and underscore-prefixed names
52
+ * 2. zero-parameter constructors
53
+ * 3. methods whose JSDoc carries `@private` or `@internal`
54
+ *
55
+ * Pure-override skip (`super.method(...args)`-only bodies bypassing the
56
+ * @returns check) is deferred — V1 keeps the rule simple.
57
+ */
58
+ export declare function validateClassMethods(cls: AcornESTreeNode<ClassDeclaration>, comments: acorn.Comment[], source: string, issues: JsDocIssue[], severity: 'warning' | 'error'): void;
15
59
  /**
16
60
  * Validates JSDoc on exported declarations in a `.sys.mjs` source file.
17
61
  *
18
62
  * @param source - File content
63
+ * @param options - Optional gates for opt-in checks (e.g. class-method JSDoc)
19
64
  * @returns Array of JSDoc issues found
20
65
  */
21
- export declare function validateExportJsDoc(source: string): JsDocIssue[];
66
+ export declare function validateExportJsDoc(source: string, options?: ValidateExportJsDocOptions): JsDocIssue[];
@@ -50,6 +50,9 @@ function extractParamNames(jsDoc) {
50
50
  function hasReturnsTag(jsDoc) {
51
51
  return /@returns?\b/.test(jsDoc);
52
52
  }
53
+ function hasPrivateOrInternalTag(jsDoc) {
54
+ return /@(?:private|internal)\b/.test(jsDoc);
55
+ }
53
56
  // ---------------------------------------------------------------------------
54
57
  // Return-statement detection
55
58
  // ---------------------------------------------------------------------------
@@ -125,10 +128,60 @@ function lineAt(source, offset) {
125
128
  }
126
129
  return line;
127
130
  }
131
+ /**
132
+ * Validates @param name matching and @returns presence on a function-like
133
+ * node that already has an attached JSDoc comment. Destructured, default-
134
+ * valued, and rest params are silently skipped to match historical behavior
135
+ * of the top-level export check.
136
+ */
137
+ function validateParamsAndReturns(fnNode, jsDoc, issues, ctx) {
138
+ const docText = jsDoc.value;
139
+ if (!ctx.skipParams) {
140
+ const actualParams = fnNode.params
141
+ .map((p) => (p.type === 'Identifier' ? p.name : null))
142
+ .filter((n) => n !== null);
143
+ if (actualParams.length > 0) {
144
+ const docParams = extractParamNames(docText);
145
+ for (const param of actualParams) {
146
+ if (!docParams.includes(param)) {
147
+ issues.push({
148
+ line: ctx.line,
149
+ check: ctx.paramCheck,
150
+ message: `Exported ${ctx.label} at line ${ctx.line}: @param "${param}" is missing or misnamed in JSDoc.`,
151
+ ...(ctx.severity ? { severity: ctx.severity } : {}),
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+ if (!ctx.skipReturns && functionReturnsValue(fnNode) && !hasReturnsTag(docText)) {
158
+ issues.push({
159
+ line: ctx.line,
160
+ check: ctx.returnsCheck,
161
+ message: `Exported ${ctx.label} at line ${ctx.line} returns a value but JSDoc is missing @returns.`,
162
+ ...(ctx.severity ? { severity: ctx.severity } : {}),
163
+ });
164
+ }
165
+ }
128
166
  // ---------------------------------------------------------------------------
129
- // Core validation
167
+ // Top-level export validation
130
168
  // ---------------------------------------------------------------------------
131
- function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
169
+ /**
170
+ * Validates a top-level function declaration: requires an attached JSDoc
171
+ * comment, then checks @param name matching and @returns presence. Exported
172
+ * so the chrome-subscript validator (`patch-lint-chrome-jsdoc.ts`) can reuse
173
+ * it on `parseScript`-produced declarations — the rule shape is identical
174
+ * between ES-module exports and chrome-subscript top-level declarations.
175
+ *
176
+ * @param fn - FunctionDeclaration AST node
177
+ * @param comments - All Acorn comments collected from the source
178
+ * @param source - Original source text (for line-number resolution)
179
+ * @param issues - Output sink for JSDoc issues
180
+ * @param lookupStart - Optional offset to use when locating the attached
181
+ * JSDoc (defaults to `fn.start`). Used by the export-walker so the JSDoc
182
+ * is found relative to the `export` keyword rather than the inner decl.
183
+ */
184
+ export function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
132
185
  const name = fn.id.name;
133
186
  const start = lookupStart !== undefined ? lookupStart : fn.start;
134
187
  const line = lineAt(source, start);
@@ -141,31 +194,20 @@ function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
141
194
  });
142
195
  return;
143
196
  }
144
- const docText = jsDoc.value;
145
- const actualParams = fn.params
146
- .map((p) => (p.type === 'Identifier' ? p.name : null))
147
- .filter((n) => n !== null);
148
- if (actualParams.length > 0) {
149
- const docParams = extractParamNames(docText);
150
- for (const param of actualParams) {
151
- if (!docParams.includes(param)) {
152
- issues.push({
153
- line,
154
- check: 'jsdoc-param-mismatch',
155
- message: `Exported function "${name}" at line ${line}: @param "${param}" is missing or misnamed in JSDoc.`,
156
- });
157
- }
158
- }
159
- }
160
- if (functionReturnsValue(fn) && !hasReturnsTag(docText)) {
161
- issues.push({
162
- line,
163
- check: 'jsdoc-missing-returns',
164
- message: `Exported function "${name}" at line ${line} returns a value but JSDoc is missing @returns.`,
165
- });
166
- }
197
+ validateParamsAndReturns(fn, jsDoc, issues, {
198
+ label: `function "${name}"`,
199
+ line,
200
+ paramCheck: 'jsdoc-param-mismatch',
201
+ returnsCheck: 'jsdoc-missing-returns',
202
+ });
167
203
  }
168
- function validateClassDecl(cls, comments, source, issues, lookupStart) {
204
+ /**
205
+ * Validates a top-level class declaration: requires an attached JSDoc
206
+ * comment on the class itself. Method-level checks live in
207
+ * {@link validateClassMethods}. Exported for reuse in the chrome-subscript
208
+ * validator.
209
+ */
210
+ export function validateClassDecl(cls, comments, source, issues, lookupStart) {
169
211
  const name = cls.id.name;
170
212
  const start = lookupStart !== undefined ? lookupStart : cls.start;
171
213
  const line = lineAt(source, start);
@@ -194,15 +236,102 @@ function validateVariableDecl(varDecl, comments, source, issues, lookupStart) {
194
236
  }
195
237
  }
196
238
  // ---------------------------------------------------------------------------
239
+ // Class-method validation (opt-in via classMethodMode)
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * Returns the method's identifier name when statically resolvable, or
243
+ * undefined for computed keys (e.g. `[Symbol.iterator]()`). Private fields
244
+ * (`#name`) are treated as private-by-syntax and surface as undefined here
245
+ * so the walker skips them up front.
246
+ */
247
+ function staticMethodName(method) {
248
+ if (method.computed)
249
+ return undefined;
250
+ const key = method.key;
251
+ if (key.type !== 'Identifier')
252
+ return undefined;
253
+ return key.name;
254
+ }
255
+ function isPrivateMethodKey(method) {
256
+ return method.key.type === 'PrivateIdentifier';
257
+ }
258
+ function classMethodLabel(className, method, name) {
259
+ if (method.kind === 'constructor')
260
+ return `constructor of class "${className}"`;
261
+ const prefix = method.static ? 'static ' : '';
262
+ if (method.kind === 'get')
263
+ return `${prefix}getter "${className}.${name}"`;
264
+ if (method.kind === 'set')
265
+ return `${prefix}setter "${className}.${name}"`;
266
+ return `${prefix}method "${className}.${name}"`;
267
+ }
268
+ /**
269
+ * Walks an exported class body and emits class-method JSDoc issues per
270
+ * the configured severity. Skip rules (in evaluation order):
271
+ * 1. private syntax (`#foo`) and underscore-prefixed names
272
+ * 2. zero-parameter constructors
273
+ * 3. methods whose JSDoc carries `@private` or `@internal`
274
+ *
275
+ * Pure-override skip (`super.method(...args)`-only bodies bypassing the
276
+ * @returns check) is deferred — V1 keeps the rule simple.
277
+ */
278
+ export function validateClassMethods(cls, comments, source, issues, severity) {
279
+ const className = cls.id.name;
280
+ for (const member of cls.body.body) {
281
+ if (member.type !== 'MethodDefinition')
282
+ continue;
283
+ const method = member;
284
+ if (isPrivateMethodKey(method))
285
+ continue;
286
+ const name = staticMethodName(method);
287
+ if (name === undefined)
288
+ continue;
289
+ if (method.kind !== 'constructor' && name.startsWith('_'))
290
+ continue;
291
+ if (method.kind === 'constructor' && method.value.params.length === 0)
292
+ continue;
293
+ const methodStart = method.start;
294
+ const line = lineAt(source, methodStart);
295
+ const jsDoc = findAttachedJsDoc(comments, methodStart, source);
296
+ if (jsDoc && hasPrivateOrInternalTag(jsDoc.value))
297
+ continue;
298
+ const label = classMethodLabel(className, method, name);
299
+ if (!jsDoc) {
300
+ issues.push({
301
+ line,
302
+ check: 'missing-jsdoc-class-method',
303
+ message: `Exported ${label} at line ${line} is missing a JSDoc comment.`,
304
+ severity,
305
+ });
306
+ continue;
307
+ }
308
+ if (method.kind === 'get') {
309
+ // Presence already verified; getter expression is the contract.
310
+ continue;
311
+ }
312
+ const skipReturns = method.kind === 'constructor' || method.kind === 'set';
313
+ validateParamsAndReturns(method.value, jsDoc, issues, {
314
+ label,
315
+ line,
316
+ paramCheck: 'jsdoc-class-method-param-mismatch',
317
+ returnsCheck: 'jsdoc-class-method-missing-returns',
318
+ severity,
319
+ skipReturns,
320
+ });
321
+ }
322
+ }
323
+ // ---------------------------------------------------------------------------
197
324
  // Public API
198
325
  // ---------------------------------------------------------------------------
199
326
  /**
200
327
  * Validates JSDoc on exported declarations in a `.sys.mjs` source file.
201
328
  *
202
329
  * @param source - File content
330
+ * @param options - Optional gates for opt-in checks (e.g. class-method JSDoc)
203
331
  * @returns Array of JSDoc issues found
204
332
  */
205
- export function validateExportJsDoc(source) {
333
+ export function validateExportJsDoc(source, options) {
334
+ const classMethodMode = options?.classMethodMode ?? 'off';
206
335
  const comments = [];
207
336
  let ast;
208
337
  try {
@@ -226,6 +355,9 @@ export function validateExportJsDoc(source) {
226
355
  }
227
356
  else if (decl.type === 'ClassDeclaration') {
228
357
  validateClassDecl(decl, comments, source, issues, exportStart);
358
+ if (classMethodMode !== 'off') {
359
+ validateClassMethods(decl, comments, source, issues, classMethodMode);
360
+ }
229
361
  }
230
362
  else if (decl.type === 'VariableDeclaration') {
231
363
  validateVariableDecl(decl, comments, source, issues, exportStart);
@@ -247,6 +379,9 @@ export function validateExportJsDoc(source) {
247
379
  }
248
380
  else if (localDecl.type === 'ClassDeclaration') {
249
381
  validateClassDecl(localDecl, comments, source, issues);
382
+ if (classMethodMode !== 'off') {
383
+ validateClassMethods(localDecl, comments, source, issues, classMethodMode);
384
+ }
250
385
  }
251
386
  else {
252
387
  validateVariableDecl(localDecl, comments, source, issues);
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Patch ownership resolution for `.sys.mjs` files.
2
+ * Patch ownership resolution for JS-shaped files.
3
3
  *
4
4
  * A file is "patch-owned" when it was created by the project's patch
5
5
  * queue rather than being an upstream Firefox file that happens to be
6
- * modified. This module computes the set of patch-owned `.sys.mjs`
7
- * paths so lint rules can scope enforcement to project code only.
6
+ * modified. This module computes the set of patch-owned paths so lint
7
+ * rules can scope enforcement to project code only.
8
+ *
9
+ * The two resolvers are kept separate (one per extension predicate)
10
+ * because the downstream rules differ: `.sys.mjs` files go through
11
+ * `runCheckJs` (TypeScript checkJs) and the export-walker JSDoc rule;
12
+ * chrome subscripts (`.js` non-`.sys.mjs`) only get the script-walker
13
+ * JSDoc rule. Mixing them in a single set would silently broaden
14
+ * `runCheckJs` to chrome subscripts, which it is not designed for.
8
15
  */
9
16
  import type { PatchQueueContext } from './patch-lint-cross.js';
10
17
  /**
@@ -23,3 +30,14 @@ import type { PatchQueueContext } from './patch-lint-cross.js';
23
30
  * @returns Set of patch-owned `.sys.mjs` file paths
24
31
  */
25
32
  export declare function resolvePatchOwnedSysMjs(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
33
+ /**
34
+ * Returns the set of file paths that are patch-owned chrome subscripts
35
+ * (`.js` files that are not `.sys.mjs` modules — typically
36
+ * `browser/base/content/<binaryName>*.js` and similar). Same ownership
37
+ * semantics as {@link resolvePatchOwnedSysMjs}.
38
+ *
39
+ * @param currentNewFiles - Files newly created in the current diff
40
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
41
+ * @returns Set of patch-owned chrome-subscript file paths
42
+ */
43
+ export declare function resolvePatchOwnedChromeScripts(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
@@ -1,13 +1,41 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Patch ownership resolution for `.sys.mjs` files.
3
+ * Patch ownership resolution for JS-shaped files.
4
4
  *
5
5
  * A file is "patch-owned" when it was created by the project's patch
6
6
  * queue rather than being an upstream Firefox file that happens to be
7
- * modified. This module computes the set of patch-owned `.sys.mjs`
8
- * paths so lint rules can scope enforcement to project code only.
7
+ * modified. This module computes the set of patch-owned paths so lint
8
+ * rules can scope enforcement to project code only.
9
+ *
10
+ * The two resolvers are kept separate (one per extension predicate)
11
+ * because the downstream rules differ: `.sys.mjs` files go through
12
+ * `runCheckJs` (TypeScript checkJs) and the export-walker JSDoc rule;
13
+ * chrome subscripts (`.js` non-`.sys.mjs`) only get the script-walker
14
+ * JSDoc rule. Mixing them in a single set would silently broaden
15
+ * `runCheckJs` to chrome subscripts, which it is not designed for.
9
16
  */
10
17
  import { collectNewFileCreatorsByPath } from './patch-lint-cross.js';
18
+ /**
19
+ * Returns the set of patch-owned files matching `predicate`. Internal
20
+ * helper shared by the per-extension resolvers below.
21
+ */
22
+ function resolveOwned(currentNewFiles, patchQueueCtx, predicate) {
23
+ const owned = new Set();
24
+ for (const file of currentNewFiles) {
25
+ if (predicate(file)) {
26
+ owned.add(file);
27
+ }
28
+ }
29
+ if (patchQueueCtx) {
30
+ const creators = collectNewFileCreatorsByPath(patchQueueCtx);
31
+ for (const [file, owners] of creators) {
32
+ if (predicate(file) && owners.length > 0) {
33
+ owned.add(file);
34
+ }
35
+ }
36
+ }
37
+ return owned;
38
+ }
11
39
  /**
12
40
  * Returns the set of file paths that are patch-owned `.sys.mjs` files.
13
41
  *
@@ -24,20 +52,19 @@ import { collectNewFileCreatorsByPath } from './patch-lint-cross.js';
24
52
  * @returns Set of patch-owned `.sys.mjs` file paths
25
53
  */
26
54
  export function resolvePatchOwnedSysMjs(currentNewFiles, patchQueueCtx) {
27
- const owned = new Set();
28
- for (const file of currentNewFiles) {
29
- if (file.endsWith('.sys.mjs')) {
30
- owned.add(file);
31
- }
32
- }
33
- if (patchQueueCtx) {
34
- const creators = collectNewFileCreatorsByPath(patchQueueCtx);
35
- for (const [file, owners] of creators) {
36
- if (file.endsWith('.sys.mjs') && owners.length > 0) {
37
- owned.add(file);
38
- }
39
- }
40
- }
41
- return owned;
55
+ return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.sys.mjs'));
56
+ }
57
+ /**
58
+ * Returns the set of file paths that are patch-owned chrome subscripts
59
+ * (`.js` files that are not `.sys.mjs` modules — typically
60
+ * `browser/base/content/<binaryName>*.js` and similar). Same ownership
61
+ * semantics as {@link resolvePatchOwnedSysMjs}.
62
+ *
63
+ * @param currentNewFiles - Files newly created in the current diff
64
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
65
+ * @returns Set of patch-owned chrome-subscript file paths
66
+ */
67
+ export function resolvePatchOwnedChromeScripts(currentNewFiles, patchQueueCtx) {
68
+ return resolveOwned(currentNewFiles, patchQueueCtx, (file) => file.endsWith('.js') && !file.endsWith('.sys.mjs'));
42
69
  }
43
70
  //# sourceMappingURL=patch-lint-ownership.js.map
@@ -5,7 +5,7 @@ export { runCheckJs } from './patch-lint-checkjs.js';
5
5
  export { buildPatchQueueContext, collectNewFileCreatorsByPath, type ExtractedSpecifier, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
7
7
  export { type JsDocCheck, type JsDocIssue, validateExportJsDoc } from './patch-lint-jsdoc.js';
8
- export { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
8
+ export { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
9
9
  /**
10
10
  * Counts the total lines in a unified diff and the number of non-binary
11
11
  * text lines, so binary hunks do not inflate patch size checks.
@@ -55,9 +55,14 @@ export declare function lintNewFileHeaders(repoDir: string, newFiles: string[],
55
55
  * @param newFiles - Set of files that are newly created in this patch
56
56
  * @param config - Project configuration
57
57
  * @param patchOwnedFiles - Optional set of patch-owned `.sys.mjs` paths for scoped JSDoc enforcement
58
+ * @param patchOwnedChromeScripts - Optional set of patch-owned chrome
59
+ * subscripts (`.js` non-`.sys.mjs`) for scoped chrome-script JSDoc
60
+ * enforcement. Built by {@link resolvePatchOwnedChromeScripts}; passed
61
+ * separately from `patchOwnedFiles` so the `runCheckJs` consumer (which
62
+ * only accepts `.sys.mjs`) is not silently broadened.
58
63
  * @returns Array of lint issues
59
64
  */
60
- export declare function lintPatchedJs(repoDir: string, affectedFiles: string[], newFiles: Set<string>, config: FireForgeConfig, patchOwnedFiles?: Set<string>): Promise<PatchLintIssue[]>;
65
+ export declare function lintPatchedJs(repoDir: string, affectedFiles: string[], newFiles: Set<string>, config: FireForgeConfig, patchOwnedFiles?: Set<string>, patchOwnedChromeScripts?: Set<string>): Promise<PatchLintIssue[]>;
61
66
  /**
62
67
  * Checks that modifications to existing (non-new) JS/MJS files include at
63
68
  * least one `// BINARYNAME:` comment in the added lines.
@@ -7,10 +7,11 @@ import { hasRawCssColors, stripJsComments } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
8
  import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
9
9
  import { runCheckJs } from './patch-lint-checkjs.js';
10
+ import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
10
11
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
11
12
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
12
13
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
13
- import { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
14
+ import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Cross-patch lint re-exports
16
17
  // ---------------------------------------------------------------------------
@@ -24,7 +25,7 @@ export { runCheckJs } from './patch-lint-checkjs.js';
24
25
  export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
25
26
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
26
27
  export { validateExportJsDoc } from './patch-lint-jsdoc.js';
27
- export { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
28
+ export { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
28
29
  // ---------------------------------------------------------------------------
29
30
  // Helpers
30
31
  // ---------------------------------------------------------------------------
@@ -178,6 +179,21 @@ export function isTestFile(file) {
178
179
  const basename = file.split('/').pop() ?? '';
179
180
  return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
180
181
  }
182
+ /**
183
+ * Narrower scope than `isTestFile` — only browser-chrome test files
184
+ * (`browser_*.js` under a `/test/` or `/tests/` directory). Excludes
185
+ * `head.js` and `head_*.js` test helpers.
186
+ */
187
+ function isBrowserChromeTestFile(file) {
188
+ if (!file.endsWith('.js'))
189
+ return false;
190
+ if (!/\/(?:test|tests)\//.test(file))
191
+ return false;
192
+ const basename = file.split('/').pop() ?? '';
193
+ if (basename === 'head.js' || /^head_.*\.js$/.test(basename))
194
+ return false;
195
+ return basename.startsWith('browser_');
196
+ }
181
197
  /**
182
198
  * Detects comment style from file extension for license header checks.
183
199
  */
@@ -388,9 +404,14 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
388
404
  * @param newFiles - Set of files that are newly created in this patch
389
405
  * @param config - Project configuration
390
406
  * @param patchOwnedFiles - Optional set of patch-owned `.sys.mjs` paths for scoped JSDoc enforcement
407
+ * @param patchOwnedChromeScripts - Optional set of patch-owned chrome
408
+ * subscripts (`.js` non-`.sys.mjs`) for scoped chrome-script JSDoc
409
+ * enforcement. Built by {@link resolvePatchOwnedChromeScripts}; passed
410
+ * separately from `patchOwnedFiles` so the `runCheckJs` consumer (which
411
+ * only accepts `.sys.mjs`) is not silently broadened.
391
412
  * @returns Array of lint issues
392
413
  */
393
- export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles) {
414
+ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles, patchOwnedChromeScripts) {
394
415
  const jsFiles = affectedFiles.filter(isJsFile);
395
416
  if (jsFiles.length === 0)
396
417
  return [];
@@ -450,13 +471,39 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
450
471
  // 3. JSDoc on exports (patch-owned .sys.mjs files)
451
472
  const isOwned = patchOwnedFiles ? patchOwnedFiles.has(file) : isNew;
452
473
  if (isOwned && isSysMjs) {
453
- const jsdocIssues = validateExportJsDoc(content);
474
+ const classMethodMode = config.patchLint?.jsdocClassMethods;
475
+ const jsdocIssues = validateExportJsDoc(content, classMethodMode ? { classMethodMode } : undefined);
454
476
  for (const jsdocIssue of jsdocIssues) {
455
477
  issues.push({
456
478
  file,
457
479
  check: jsdocIssue.check,
458
480
  message: jsdocIssue.message,
459
- severity: 'error',
481
+ severity: jsdocIssue.severity ?? 'error',
482
+ });
483
+ }
484
+ }
485
+ // 3a. JSDoc on top-level declarations in patch-owned chrome subscripts.
486
+ // Dispatched out-of-line to keep this file under the per-file line
487
+ // budget. See `patch-lint-chrome-jsdoc.ts` for the rule body.
488
+ const isChromeOwned = file.endsWith('.js') &&
489
+ !isSysMjs &&
490
+ (patchOwnedChromeScripts ? patchOwnedChromeScripts.has(file) : isNew);
491
+ const chromeMode = config.patchLint?.chromeScriptJsDoc;
492
+ issues.push(...lintChromeScriptJsDocForFile(file, content, isChromeOwned, chromeMode));
493
+ // 3b. Assertion floor for browser-chrome tests touched by this patch
494
+ // (covers both newly introduced files and modified upstream tests —
495
+ // a patch that strips the last `Assert.equal` from an existing
496
+ // browser_*.js silently passed under the old `isNew` gate).
497
+ const assertionFloor = config.patchLint?.testAssertionFloor;
498
+ if (assertionFloor && assertionFloor !== 'off' && isBrowserChromeTestFile(file)) {
499
+ const ASSERTION_TOKENS = ['Assert.', 'ok(', 'is(', 'isnot(', 'isDeeply('];
500
+ const hasAssertion = ASSERTION_TOKENS.some((tok) => strippedContent.includes(tok));
501
+ if (!hasAssertion) {
502
+ issues.push({
503
+ file,
504
+ check: 'test-needs-assertion',
505
+ message: `Test file ${file} contains no assertions (Assert.*, ok(), is(), isnot(), isDeeply()). Smoke-only tests do not count as coverage. Add at least one assertion that pins user-visible behavior, or remove the file.`,
506
+ severity: assertionFloor,
460
507
  });
461
508
  }
462
509
  }
@@ -673,10 +720,11 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
673
720
  const newFiles = detectNewFilesInDiff(diffContent);
674
721
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
675
722
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
723
+ const patchOwnedChromeScripts = resolvePatchOwnedChromeScripts(newFiles, patchQueueCtx);
676
724
  const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
677
725
  lintPatchedCss(repoDir, affectedFiles, diffContent, config),
678
726
  lintNewFileHeaders(repoDir, [...newFiles], config),
679
- lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles),
727
+ lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles, patchOwnedChromeScripts),
680
728
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
681
729
  ]);
682
730
  const modCommentIssues = lintModificationComments(diffContent, config);
@@ -316,6 +316,14 @@ export interface TestOptions {
316
316
  * values appear after `--headless` if both are set.
317
317
  */
318
318
  machArg?: string[];
319
+ /**
320
+ * Override the Marionette control port (default 2828) used by the
321
+ * stale-browser probe, the `--doctor` preflight, and the auto-forwarded
322
+ * `--setpref=marionette.port=<n>` arg passed to mach. Set this when a
323
+ * stale process holds the default port and `kill` is not an option, or
324
+ * when a CI runner reserves a different port for parallel test runs.
325
+ */
326
+ marionettePort?: number;
319
327
  }
320
328
  /**
321
329
  * Options for the furnace apply command.
@@ -59,6 +59,11 @@ export interface WireConfig {
59
59
  /** Subscript directory relative to engine/. Default: "browser/base/content" */
60
60
  subscriptDir?: string;
61
61
  }
62
+ /**
63
+ * Severity gate for opt-in patch-lint rules. `'off'` disables the rule;
64
+ * `'warning'` and `'error'` emit issues at the matching severity.
65
+ */
66
+ export type PatchLintSeverityGate = 'off' | 'warning' | 'error';
62
67
  /**
63
68
  * Configuration for patch lint rules.
64
69
  */
@@ -67,6 +72,21 @@ export interface PatchLintConfig {
67
72
  checkJs?: boolean;
68
73
  /** File paths exempt from the raw-color-value check (exact or basename match) */
69
74
  rawColorAllowlist?: string[];
75
+ /** Enforce JSDoc on class-method exports in patch-owned .sys.mjs files. Default: 'off'. */
76
+ jsdocClassMethods?: PatchLintSeverityGate;
77
+ /** Require ≥1 assertion in any patch-touched browser_*.js test file (new or modified). Default: 'off'. */
78
+ testAssertionFloor?: PatchLintSeverityGate;
79
+ /**
80
+ * Enforce JSDoc on top-level classes (and their methods) and functions
81
+ * in patch-owned chrome subscripts (`.js` files loaded via
82
+ * `Services.scriptloader.loadSubScript`, e.g.
83
+ * `browser/base/content/<binaryName>*.js`). Distinct from
84
+ * `jsdocClassMethods` because chrome subscripts are parsed as scripts,
85
+ * not ES modules — using one flag for both would silently disable the
86
+ * rule when a chrome subscript was fed to the module parser. Default:
87
+ * 'off'.
88
+ */
89
+ chromeScriptJsDoc?: PatchLintSeverityGate;
70
90
  }
71
91
  /**
72
92
  * Build mode for mach.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.18.6",
3
+ "version": "0.18.9",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",