@hominis/fireforge 0.18.5 → 0.18.8

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>
@@ -66,10 +66,15 @@ function formatValue(value) {
66
66
  if (value === undefined) {
67
67
  return '(not set)';
68
68
  }
69
- if (value === null || typeof value === 'object' || typeof value === 'function') {
70
- return JSON.stringify(value, null, 2);
69
+ if (typeof value === 'string')
70
+ return value;
71
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
72
+ return String(value);
73
+ }
74
+ if (typeof value === 'symbol') {
75
+ return value.toString();
71
76
  }
72
- return String(value);
77
+ return JSON.stringify(value, null, 2);
73
78
  }
74
79
  /**
75
80
  * Runs the config command to get or set configuration values.
@@ -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", "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,8 @@ export const SUPPORTED_CONFIG_PATHS = [
47
47
  'patchLint',
48
48
  'patchLint.checkJs',
49
49
  'patchLint.rawColorAllowlist',
50
+ 'patchLint.jsdocClassMethods',
51
+ 'patchLint.testAssertionFloor',
50
52
  'markerComment',
51
53
  ];
52
54
  /**
@@ -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,40 @@ 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
+ return out;
233
+ }
213
234
  //# sourceMappingURL=config-validate.js.map
@@ -70,9 +70,7 @@ function isInsideDOMContentLoaded(ancestors, content) {
70
70
  call.callee.property.type === 'Identifier' &&
71
71
  call.callee.property.name === 'addEventListener') {
72
72
  const firstArg = call.arguments[0];
73
- if (firstArg &&
74
- firstArg.type === 'Literal' &&
75
- firstArg.value === 'DOMContentLoaded') {
73
+ if (firstArg && firstArg.type === 'Literal' && firstArg.value === 'DOMContentLoaded') {
76
74
  return true;
77
75
  }
78
76
  // Check if "DOMContentLoaded" appears in the call's source (handles edge cases)
@@ -6,16 +6,24 @@
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
+ 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
10
  export interface JsDocIssue {
11
11
  line: number;
12
12
  check: JsDocCheck;
13
13
  message: string;
14
+ /** Optional severity hint. When undefined, callers default to 'error'. */
15
+ severity?: 'error' | 'warning';
16
+ }
17
+ export type ClassMethodMode = 'off' | 'warning' | 'error';
18
+ export interface ValidateExportJsDocOptions {
19
+ /** Gate for class-method JSDoc enforcement. Default 'off' (no walking). */
20
+ classMethodMode?: ClassMethodMode;
14
21
  }
15
22
  /**
16
23
  * Validates JSDoc on exported declarations in a `.sys.mjs` source file.
17
24
  *
18
25
  * @param source - File content
26
+ * @param options - Optional gates for opt-in checks (e.g. class-method JSDoc)
19
27
  * @returns Array of JSDoc issues found
20
28
  */
21
- export declare function validateExportJsDoc(source: string): JsDocIssue[];
29
+ 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,8 +128,43 @@ 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
169
  function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
132
170
  const name = fn.id.name;
@@ -141,29 +179,12 @@ function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
141
179
  });
142
180
  return;
143
181
  }
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
- }
182
+ validateParamsAndReturns(fn, jsDoc, issues, {
183
+ label: `function "${name}"`,
184
+ line,
185
+ paramCheck: 'jsdoc-param-mismatch',
186
+ returnsCheck: 'jsdoc-missing-returns',
187
+ });
167
188
  }
168
189
  function validateClassDecl(cls, comments, source, issues, lookupStart) {
169
190
  const name = cls.id.name;
@@ -194,15 +215,102 @@ function validateVariableDecl(varDecl, comments, source, issues, lookupStart) {
194
215
  }
195
216
  }
196
217
  // ---------------------------------------------------------------------------
218
+ // Class-method validation (opt-in via classMethodMode)
219
+ // ---------------------------------------------------------------------------
220
+ /**
221
+ * Returns the method's identifier name when statically resolvable, or
222
+ * undefined for computed keys (e.g. `[Symbol.iterator]()`). Private fields
223
+ * (`#name`) are treated as private-by-syntax and surface as undefined here
224
+ * so the walker skips them up front.
225
+ */
226
+ function staticMethodName(method) {
227
+ if (method.computed)
228
+ return undefined;
229
+ const key = method.key;
230
+ if (key.type !== 'Identifier')
231
+ return undefined;
232
+ return key.name;
233
+ }
234
+ function isPrivateMethodKey(method) {
235
+ return method.key.type === 'PrivateIdentifier';
236
+ }
237
+ function classMethodLabel(className, method, name) {
238
+ if (method.kind === 'constructor')
239
+ return `constructor of class "${className}"`;
240
+ const prefix = method.static ? 'static ' : '';
241
+ if (method.kind === 'get')
242
+ return `${prefix}getter "${className}.${name}"`;
243
+ if (method.kind === 'set')
244
+ return `${prefix}setter "${className}.${name}"`;
245
+ return `${prefix}method "${className}.${name}"`;
246
+ }
247
+ /**
248
+ * Walks an exported class body and emits class-method JSDoc issues per
249
+ * the configured severity. Skip rules (in evaluation order):
250
+ * 1. private syntax (`#foo`) and underscore-prefixed names
251
+ * 2. zero-parameter constructors
252
+ * 3. methods whose JSDoc carries `@private` or `@internal`
253
+ *
254
+ * Pure-override skip (`super.method(...args)`-only bodies bypassing the
255
+ * @returns check) is deferred — V1 keeps the rule simple.
256
+ */
257
+ function validateClassMethods(cls, comments, source, issues, severity) {
258
+ const className = cls.id.name;
259
+ for (const member of cls.body.body) {
260
+ if (member.type !== 'MethodDefinition')
261
+ continue;
262
+ const method = member;
263
+ if (isPrivateMethodKey(method))
264
+ continue;
265
+ const name = staticMethodName(method);
266
+ if (name === undefined)
267
+ continue;
268
+ if (method.kind !== 'constructor' && name.startsWith('_'))
269
+ continue;
270
+ if (method.kind === 'constructor' && method.value.params.length === 0)
271
+ continue;
272
+ const methodStart = method.start;
273
+ const line = lineAt(source, methodStart);
274
+ const jsDoc = findAttachedJsDoc(comments, methodStart, source);
275
+ if (jsDoc && hasPrivateOrInternalTag(jsDoc.value))
276
+ continue;
277
+ const label = classMethodLabel(className, method, name);
278
+ if (!jsDoc) {
279
+ issues.push({
280
+ line,
281
+ check: 'missing-jsdoc-class-method',
282
+ message: `Exported ${label} at line ${line} is missing a JSDoc comment.`,
283
+ severity,
284
+ });
285
+ continue;
286
+ }
287
+ if (method.kind === 'get') {
288
+ // Presence already verified; getter expression is the contract.
289
+ continue;
290
+ }
291
+ const skipReturns = method.kind === 'constructor' || method.kind === 'set';
292
+ validateParamsAndReturns(method.value, jsDoc, issues, {
293
+ label,
294
+ line,
295
+ paramCheck: 'jsdoc-class-method-param-mismatch',
296
+ returnsCheck: 'jsdoc-class-method-missing-returns',
297
+ severity,
298
+ skipReturns,
299
+ });
300
+ }
301
+ }
302
+ // ---------------------------------------------------------------------------
197
303
  // Public API
198
304
  // ---------------------------------------------------------------------------
199
305
  /**
200
306
  * Validates JSDoc on exported declarations in a `.sys.mjs` source file.
201
307
  *
202
308
  * @param source - File content
309
+ * @param options - Optional gates for opt-in checks (e.g. class-method JSDoc)
203
310
  * @returns Array of JSDoc issues found
204
311
  */
205
- export function validateExportJsDoc(source) {
312
+ export function validateExportJsDoc(source, options) {
313
+ const classMethodMode = options?.classMethodMode ?? 'off';
206
314
  const comments = [];
207
315
  let ast;
208
316
  try {
@@ -226,6 +334,9 @@ export function validateExportJsDoc(source) {
226
334
  }
227
335
  else if (decl.type === 'ClassDeclaration') {
228
336
  validateClassDecl(decl, comments, source, issues, exportStart);
337
+ if (classMethodMode !== 'off') {
338
+ validateClassMethods(decl, comments, source, issues, classMethodMode);
339
+ }
229
340
  }
230
341
  else if (decl.type === 'VariableDeclaration') {
231
342
  validateVariableDecl(decl, comments, source, issues, exportStart);
@@ -247,6 +358,9 @@ export function validateExportJsDoc(source) {
247
358
  }
248
359
  else if (localDecl.type === 'ClassDeclaration') {
249
360
  validateClassDecl(localDecl, comments, source, issues);
361
+ if (classMethodMode !== 'off') {
362
+ validateClassMethods(localDecl, comments, source, issues, classMethodMode);
363
+ }
250
364
  }
251
365
  else {
252
366
  validateVariableDecl(localDecl, comments, source, issues);
@@ -178,6 +178,21 @@ export function isTestFile(file) {
178
178
  const basename = file.split('/').pop() ?? '';
179
179
  return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
180
180
  }
181
+ /**
182
+ * Narrower scope than `isTestFile` — only browser-chrome test files
183
+ * (`browser_*.js` under a `/test/` or `/tests/` directory). Excludes
184
+ * `head.js` and `head_*.js` test helpers.
185
+ */
186
+ function isBrowserChromeTestFile(file) {
187
+ if (!file.endsWith('.js'))
188
+ return false;
189
+ if (!/\/(?:test|tests)\//.test(file))
190
+ return false;
191
+ const basename = file.split('/').pop() ?? '';
192
+ if (basename === 'head.js' || /^head_.*\.js$/.test(basename))
193
+ return false;
194
+ return basename.startsWith('browser_');
195
+ }
181
196
  /**
182
197
  * Detects comment style from file extension for license header checks.
183
198
  */
@@ -450,13 +465,28 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
450
465
  // 3. JSDoc on exports (patch-owned .sys.mjs files)
451
466
  const isOwned = patchOwnedFiles ? patchOwnedFiles.has(file) : isNew;
452
467
  if (isOwned && isSysMjs) {
453
- const jsdocIssues = validateExportJsDoc(content);
468
+ const classMethodMode = config.patchLint?.jsdocClassMethods;
469
+ const jsdocIssues = validateExportJsDoc(content, classMethodMode ? { classMethodMode } : undefined);
454
470
  for (const jsdocIssue of jsdocIssues) {
455
471
  issues.push({
456
472
  file,
457
473
  check: jsdocIssue.check,
458
474
  message: jsdocIssue.message,
459
- severity: 'error',
475
+ severity: jsdocIssue.severity ?? 'error',
476
+ });
477
+ }
478
+ }
479
+ // 3b. Assertion floor for patch-introduced browser-chrome tests
480
+ const assertionFloor = config.patchLint?.testAssertionFloor;
481
+ if (assertionFloor && assertionFloor !== 'off' && isNew && isBrowserChromeTestFile(file)) {
482
+ const ASSERTION_TOKENS = ['Assert.', 'ok(', 'is(', 'isnot(', 'isDeeply('];
483
+ const hasAssertion = ASSERTION_TOKENS.some((tok) => strippedContent.includes(tok));
484
+ if (!hasAssertion) {
485
+ issues.push({
486
+ file,
487
+ check: 'test-needs-assertion',
488
+ 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.`,
489
+ severity: assertionFloor,
460
490
  });
461
491
  }
462
492
  }
@@ -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,10 @@ 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 patch-introduced browser_*.js test files. Default: 'off'. */
78
+ testAssertionFloor?: PatchLintSeverityGate;
70
79
  }
71
80
  /**
72
81
  * Build mode for mach.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.18.5",
3
+ "version": "0.18.8",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",