@hominis/fireforge 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +79 -26
  3. package/dist/src/commands/bootstrap-checks.d.ts +16 -0
  4. package/dist/src/commands/bootstrap-checks.js +66 -0
  5. package/dist/src/commands/bootstrap.js +27 -9
  6. package/dist/src/commands/doctor.d.ts +8 -0
  7. package/dist/src/commands/doctor.js +7 -1
  8. package/dist/src/commands/export-flow.js +3 -11
  9. package/dist/src/commands/export-shared.d.ts +2 -1
  10. package/dist/src/commands/export-shared.js +7 -2
  11. package/dist/src/commands/furnace/create.js +1 -1
  12. package/dist/src/commands/furnace/deploy.js +1 -1
  13. package/dist/src/commands/furnace/override.js +1 -1
  14. package/dist/src/commands/furnace/refresh.js +1 -1
  15. package/dist/src/commands/furnace/remove.js +1 -1
  16. package/dist/src/commands/furnace/rename.js +1 -1
  17. package/dist/src/commands/furnace/scan.js +1 -1
  18. package/dist/src/commands/lint.js +12 -3
  19. package/dist/src/commands/patch/delete.js +1 -15
  20. package/dist/src/commands/patch/reorder.js +1 -9
  21. package/dist/src/commands/re-export.js +1 -17
  22. package/dist/src/commands/verify.js +2 -2
  23. package/dist/src/core/ast-utils.d.ts +10 -0
  24. package/dist/src/core/ast-utils.js +18 -0
  25. package/dist/src/core/config-paths.d.ts +2 -2
  26. package/dist/src/core/config-paths.js +3 -0
  27. package/dist/src/core/config-validate.js +21 -3
  28. package/dist/src/core/file-lock.js +39 -2
  29. package/dist/src/core/furnace-apply.js +2 -1
  30. package/dist/src/core/furnace-config.js +6 -2
  31. package/dist/src/core/patch-apply.js +26 -4
  32. package/dist/src/core/patch-lint-checkjs.d.ts +21 -0
  33. package/dist/src/core/patch-lint-checkjs.js +225 -0
  34. package/dist/src/core/patch-lint-cross.d.ts +1 -0
  35. package/dist/src/core/patch-lint-cross.js +7 -0
  36. package/dist/src/core/patch-lint-jsdoc.d.ts +21 -0
  37. package/dist/src/core/patch-lint-jsdoc.js +259 -0
  38. package/dist/src/core/patch-lint-ownership.d.ts +25 -0
  39. package/dist/src/core/patch-lint-ownership.js +43 -0
  40. package/dist/src/core/patch-lint.d.ts +14 -3
  41. package/dist/src/core/patch-lint.js +116 -47
  42. package/dist/src/core/patch-manifest-resolve.d.ts +5 -0
  43. package/dist/src/core/patch-manifest-resolve.js +12 -0
  44. package/dist/src/core/patch-manifest.d.ts +1 -0
  45. package/dist/src/core/patch-manifest.js +1 -0
  46. package/dist/src/types/commands/patches.d.ts +2 -2
  47. package/dist/src/types/config.d.ts +11 -0
  48. package/dist/src/utils/paths.js +3 -1
  49. package/package.json +1 -1
@@ -0,0 +1,259 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * AST-based JSDoc validation for exported declarations in `.sys.mjs`
4
+ * modules. Uses Acorn (already a runtime dependency) to parse the
5
+ * module and inspects JSDoc comments via the `onComment` callback.
6
+ *
7
+ * Separated from `patch-lint.ts` to keep both files within the
8
+ * project's per-file line budget.
9
+ */
10
+ import { parseModule } from './ast-utils.js';
11
+ // ---------------------------------------------------------------------------
12
+ // JSDoc comment helpers
13
+ // ---------------------------------------------------------------------------
14
+ function isJsDocComment(comment) {
15
+ return comment.type === 'Block' && comment.value.startsWith('*');
16
+ }
17
+ /**
18
+ * Finds the JSDoc comment immediately preceding `declStart` in the
19
+ * source. "Immediately" means only whitespace and newlines may appear
20
+ * between the comment's closing delimiter and the declaration.
21
+ */
22
+ function findAttachedJsDoc(comments, declStart, source) {
23
+ for (let i = comments.length - 1; i >= 0; i--) {
24
+ const c = comments[i];
25
+ if (!c || !isJsDocComment(c))
26
+ continue;
27
+ const commentEnd = c.end;
28
+ if (commentEnd > declStart)
29
+ continue;
30
+ const between = source.slice(commentEnd, declStart);
31
+ if (/^\s*$/.test(between))
32
+ return c;
33
+ break;
34
+ }
35
+ return undefined;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // JSDoc tag parsing
39
+ // ---------------------------------------------------------------------------
40
+ function extractParamNames(jsDoc) {
41
+ const names = [];
42
+ const paramPattern = /@param\s+(?:\{[^}]*\}\s+)?(\w+)/g;
43
+ let m;
44
+ while ((m = paramPattern.exec(jsDoc)) !== null) {
45
+ if (m[1])
46
+ names.push(m[1]);
47
+ }
48
+ return names;
49
+ }
50
+ function hasReturnsTag(jsDoc) {
51
+ return /@returns?\b/.test(jsDoc);
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Return-statement detection
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Returns true if any direct `ReturnStatement` in the function body
58
+ * has a non-null argument. Does not recurse into nested functions or
59
+ * arrow expressions which have their own return semantics.
60
+ */
61
+ function functionReturnsValue(node) {
62
+ return walkForReturn(node.body);
63
+ }
64
+ function walkForReturn(node) {
65
+ if (node.type === 'ReturnStatement') {
66
+ return node.argument != null;
67
+ }
68
+ if (node.type === 'FunctionDeclaration' ||
69
+ node.type === 'FunctionExpression' ||
70
+ node.type === 'ArrowFunctionExpression') {
71
+ return false;
72
+ }
73
+ for (const key of Object.keys(node)) {
74
+ if (key === 'type')
75
+ continue;
76
+ const val = node[key];
77
+ if (val && typeof val === 'object') {
78
+ if (Array.isArray(val)) {
79
+ for (const child of val) {
80
+ if (child && typeof child === 'object' && 'type' in child) {
81
+ if (walkForReturn(child))
82
+ return true;
83
+ }
84
+ }
85
+ }
86
+ else if ('type' in val) {
87
+ if (walkForReturn(val))
88
+ return true;
89
+ }
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+ function findLocalDeclaration(body, name) {
95
+ for (const stmt of body) {
96
+ if (stmt.type === 'FunctionDeclaration') {
97
+ const fn = stmt;
98
+ if (fn.id.name === name)
99
+ return stmt;
100
+ }
101
+ else if (stmt.type === 'ClassDeclaration') {
102
+ const cls = stmt;
103
+ if (cls.id.name === name)
104
+ return stmt;
105
+ }
106
+ else if (stmt.type === 'VariableDeclaration') {
107
+ const varDecl = stmt;
108
+ for (const d of varDecl.declarations) {
109
+ if (d.id.type === 'Identifier' && d.id.name === name) {
110
+ return varDecl;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ return undefined;
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Line number helpers
119
+ // ---------------------------------------------------------------------------
120
+ function lineAt(source, offset) {
121
+ let line = 1;
122
+ for (let i = 0; i < offset && i < source.length; i++) {
123
+ if (source[i] === '\n')
124
+ line++;
125
+ }
126
+ return line;
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Core validation
130
+ // ---------------------------------------------------------------------------
131
+ function validateFunctionDecl(fn, comments, source, issues, lookupStart) {
132
+ const name = fn.id.name;
133
+ const start = lookupStart !== undefined ? lookupStart : fn.start;
134
+ const line = lineAt(source, start);
135
+ const jsDoc = findAttachedJsDoc(comments, start, source);
136
+ if (!jsDoc) {
137
+ issues.push({
138
+ line,
139
+ check: 'missing-jsdoc',
140
+ message: `Exported function "${name}" at line ${line} is missing a JSDoc comment.`,
141
+ });
142
+ return;
143
+ }
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
+ }
167
+ }
168
+ function validateClassDecl(cls, comments, source, issues, lookupStart) {
169
+ const name = cls.id.name;
170
+ const start = lookupStart !== undefined ? lookupStart : cls.start;
171
+ const line = lineAt(source, start);
172
+ const jsDoc = findAttachedJsDoc(comments, start, source);
173
+ if (!jsDoc) {
174
+ issues.push({
175
+ line,
176
+ check: 'missing-jsdoc',
177
+ message: `Exported class "${name}" at line ${line} is missing a JSDoc comment.`,
178
+ });
179
+ }
180
+ }
181
+ function validateVariableDecl(varDecl, comments, source, issues, lookupStart) {
182
+ const start = lookupStart !== undefined ? lookupStart : varDecl.start;
183
+ const jsDoc = findAttachedJsDoc(comments, start, source);
184
+ if (jsDoc)
185
+ return; // has a JSDoc block — sufficient for constants
186
+ for (const decl of varDecl.declarations) {
187
+ const name = decl.id.type === 'Identifier' ? decl.id.name : '<destructured>';
188
+ const line = lineAt(source, start);
189
+ issues.push({
190
+ line,
191
+ check: 'missing-jsdoc',
192
+ message: `Exported constant "${name}" at line ${line} is missing a JSDoc comment.`,
193
+ });
194
+ }
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Public API
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * Validates JSDoc on exported declarations in a `.sys.mjs` source file.
201
+ *
202
+ * @param source - File content
203
+ * @returns Array of JSDoc issues found
204
+ */
205
+ export function validateExportJsDoc(source) {
206
+ const comments = [];
207
+ let ast;
208
+ try {
209
+ ast = parseModule(source, comments);
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const issues = [];
215
+ const body = ast.body;
216
+ for (const node of body) {
217
+ if (node.type !== 'ExportNamedDeclaration')
218
+ continue;
219
+ const exportNode = node;
220
+ // Case 1: inline export declaration — JSDoc attaches to `export`
221
+ if (exportNode.declaration) {
222
+ const decl = exportNode.declaration;
223
+ const exportStart = exportNode.start;
224
+ if (decl.type === 'FunctionDeclaration') {
225
+ validateFunctionDecl(decl, comments, source, issues, exportStart);
226
+ }
227
+ else if (decl.type === 'ClassDeclaration') {
228
+ validateClassDecl(decl, comments, source, issues, exportStart);
229
+ }
230
+ else if (decl.type === 'VariableDeclaration') {
231
+ validateVariableDecl(decl, comments, source, issues, exportStart);
232
+ }
233
+ continue;
234
+ }
235
+ // Case 2: `export { foo, Bar }` — resolve back to local declarations
236
+ if (exportNode.specifiers.length > 0 && !exportNode.source) {
237
+ for (const spec of exportNode.specifiers) {
238
+ const local = spec.local;
239
+ if (local.type !== 'Identifier')
240
+ continue;
241
+ const localName = local.name;
242
+ const localDecl = findLocalDeclaration(body, localName);
243
+ if (!localDecl)
244
+ continue;
245
+ if (localDecl.type === 'FunctionDeclaration') {
246
+ validateFunctionDecl(localDecl, comments, source, issues);
247
+ }
248
+ else if (localDecl.type === 'ClassDeclaration') {
249
+ validateClassDecl(localDecl, comments, source, issues);
250
+ }
251
+ else {
252
+ validateVariableDecl(localDecl, comments, source, issues);
253
+ }
254
+ }
255
+ }
256
+ }
257
+ return issues;
258
+ }
259
+ //# sourceMappingURL=patch-lint-jsdoc.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Patch ownership resolution for `.sys.mjs` files.
3
+ *
4
+ * A file is "patch-owned" when it was created by the project's patch
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.
8
+ */
9
+ import type { PatchQueueContext } from './patch-lint-cross.js';
10
+ /**
11
+ * Returns the set of file paths that are patch-owned `.sys.mjs` files.
12
+ *
13
+ * A file is patch-owned if:
14
+ * 1. It is newly created in the current diff, OR
15
+ * 2. It was created by an existing patch already in the queue.
16
+ *
17
+ * When no queue context is provided the result is limited to (1),
18
+ * which matches the pre-ownership behavior and keeps callers that
19
+ * do not have access to the patches directory working correctly.
20
+ *
21
+ * @param currentNewFiles - Files newly created in the current diff
22
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
23
+ * @returns Set of patch-owned `.sys.mjs` file paths
24
+ */
25
+ export declare function resolvePatchOwnedSysMjs(currentNewFiles: Set<string>, patchQueueCtx?: PatchQueueContext): Set<string>;
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch ownership resolution for `.sys.mjs` files.
4
+ *
5
+ * A file is "patch-owned" when it was created by the project's patch
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.
9
+ */
10
+ import { collectNewFileCreatorsByPath } from './patch-lint-cross.js';
11
+ /**
12
+ * Returns the set of file paths that are patch-owned `.sys.mjs` files.
13
+ *
14
+ * A file is patch-owned if:
15
+ * 1. It is newly created in the current diff, OR
16
+ * 2. It was created by an existing patch already in the queue.
17
+ *
18
+ * When no queue context is provided the result is limited to (1),
19
+ * which matches the pre-ownership behavior and keeps callers that
20
+ * do not have access to the patches directory working correctly.
21
+ *
22
+ * @param currentNewFiles - Files newly created in the current diff
23
+ * @param patchQueueCtx - Optional cross-patch context for queue-wide ownership
24
+ * @returns Set of patch-owned `.sys.mjs` file paths
25
+ */
26
+ 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;
42
+ }
43
+ //# sourceMappingURL=patch-lint-ownership.js.map
@@ -1,8 +1,17 @@
1
1
  import type { PatchLintIssue } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type CommentStyle } from './license-headers.js';
4
+ export { runCheckJs } from './patch-lint-checkjs.js';
4
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';
5
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
7
+ export { type JsDocCheck, type JsDocIssue, validateExportJsDoc } from './patch-lint-jsdoc.js';
8
+ export { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
9
+ /**
10
+ * Returns true if the file path looks like a test file.
11
+ * Matches paths containing `/test/` or filenames starting with
12
+ * `browser_`, `test_`, or `xpcshell_` (all `.js`).
13
+ */
14
+ export declare function isTestFile(file: string): boolean;
6
15
  /**
7
16
  * Detects comment style from file extension for license header checks.
8
17
  */
@@ -16,7 +25,7 @@ export declare function commentStyleForFile(file: string): CommentStyle | null;
16
25
  * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
17
26
  * @returns Array of lint issues found
18
27
  */
19
- export declare function lintPatchedCss(repoDir: string, affectedFiles: string[], diffContent?: string): Promise<PatchLintIssue[]>;
28
+ export declare function lintPatchedCss(repoDir: string, affectedFiles: string[], diffContent?: string, config?: FireForgeConfig): Promise<PatchLintIssue[]>;
20
29
  /**
21
30
  * Checks new files for required license headers.
22
31
  *
@@ -34,9 +43,10 @@ export declare function lintNewFileHeaders(repoDir: string, newFiles: string[],
34
43
  * @param affectedFiles - File paths (relative to repoDir)
35
44
  * @param newFiles - Set of files that are newly created in this patch
36
45
  * @param config - Project configuration
46
+ * @param patchOwnedFiles - Optional set of patch-owned `.sys.mjs` paths for scoped JSDoc enforcement
37
47
  * @returns Array of lint issues
38
48
  */
39
- export declare function lintPatchedJs(repoDir: string, affectedFiles: string[], newFiles: Set<string>, config: FireForgeConfig): Promise<PatchLintIssue[]>;
49
+ export declare function lintPatchedJs(repoDir: string, affectedFiles: string[], newFiles: Set<string>, config: FireForgeConfig, patchOwnedFiles?: Set<string>): Promise<PatchLintIssue[]>;
40
50
  /**
41
51
  * Checks that modifications to existing (non-new) JS/MJS files include at
42
52
  * least one `// BINARYNAME:` comment in the added lines.
@@ -67,6 +77,7 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
67
77
  * @param affectedFiles - File paths (relative to repoDir) affected by the patch
68
78
  * @param diffContent - Raw unified diff string
69
79
  * @param config - Project configuration
80
+ * @param patchQueueCtx - Optional cross-patch context for ownership resolution
70
81
  * @returns Array of all lint issues found
71
82
  */
72
- export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig): Promise<PatchLintIssue[]>;
83
+ export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext): Promise<PatchLintIssue[]>;
@@ -6,7 +6,10 @@ import { verbose } from '../utils/logger.js';
6
6
  import { hasRawCssColors, stripJsComments } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
8
  import { getLicenseHeader, hasAnyLicenseHeader } from './license-headers.js';
9
+ import { runCheckJs } from './patch-lint-checkjs.js';
9
10
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
11
+ import { validateExportJsDoc } from './patch-lint-jsdoc.js';
12
+ import { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
10
13
  // ---------------------------------------------------------------------------
11
14
  // Cross-patch lint re-exports
12
15
  // ---------------------------------------------------------------------------
@@ -16,12 +19,23 @@ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-dif
16
19
  // `patch-lint-cross.ts` so the per-patch and cross-patch rule bodies can
17
20
  // each stay within the project's per-file line budget. Re-export the
18
21
  // public surface so callers continue to import from a single module.
22
+ export { runCheckJs } from './patch-lint-checkjs.js';
19
23
  export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
20
24
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
25
+ export { validateExportJsDoc } from './patch-lint-jsdoc.js';
26
+ export { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
21
27
  // ---------------------------------------------------------------------------
22
28
  // Helpers
23
29
  // ---------------------------------------------------------------------------
24
30
  const JS_EXTENSIONS = ['.js', '.mjs', '.jsm'];
31
+ const FILE_SIZE_THRESHOLDS = {
32
+ general: { notice: 500, warning: 750, error: 900 },
33
+ test: { notice: 1200, warning: 1400, error: 1600 },
34
+ };
35
+ const PATCH_LINE_THRESHOLDS = {
36
+ general: { notice: 800, warning: 1500, error: 3000 },
37
+ test: { notice: 1500, warning: 3000, error: 6000 },
38
+ };
25
39
  /**
26
40
  * Returns true if the filename looks like a JS/MJS/JSM file.
27
41
  * Handles `.sys.mjs` as well.
@@ -29,6 +43,17 @@ const JS_EXTENSIONS = ['.js', '.mjs', '.jsm'];
29
43
  function isJsFile(file) {
30
44
  return JS_EXTENSIONS.some((ext) => file.endsWith(ext));
31
45
  }
46
+ /**
47
+ * Returns true if the file path looks like a test file.
48
+ * Matches paths containing `/test/` or filenames starting with
49
+ * `browser_`, `test_`, or `xpcshell_` (all `.js`).
50
+ */
51
+ export function isTestFile(file) {
52
+ if (file.includes('/test/'))
53
+ return true;
54
+ const basename = file.split('/').pop() ?? '';
55
+ return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
56
+ }
32
57
  /**
33
58
  * Detects comment style from file extension for license header checks.
34
59
  */
@@ -53,7 +78,7 @@ export function commentStyleForFile(file) {
53
78
  * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
54
79
  * @returns Array of lint issues found
55
80
  */
56
- export async function lintPatchedCss(repoDir, affectedFiles, diffContent) {
81
+ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config) {
57
82
  const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
58
83
  if (cssFiles.length === 0)
59
84
  return [];
@@ -80,17 +105,29 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent) {
80
105
  const rawCss = await readText(filePath);
81
106
  // Strip block comments before scanning
82
107
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
83
- const rawColorContent = addedLinesByFile
84
- ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
85
- : cssContent;
86
108
  // Check only introduced raw color values when diff context is available.
87
- if (hasRawCssColors(rawColorContent)) {
88
- issues.push({
89
- file,
90
- check: 'raw-color-value',
91
- message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
92
- severity: 'error',
93
- });
109
+ // Skip files on the raw-color allowlist (exact path or basename match).
110
+ const allowlist = config?.patchLint?.rawColorAllowlist;
111
+ const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
112
+ if (!isAllowlisted) {
113
+ // Strip lines with inline fireforge-ignore: raw-color-value suppression.
114
+ // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
115
+ const sourceForSuppression = addedLinesByFile
116
+ ? (addedLinesByFile.get(file) ?? []).join('\n')
117
+ : rawCss;
118
+ const suppressedContent = sourceForSuppression
119
+ .split('\n')
120
+ .filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
121
+ .join('\n')
122
+ .replace(/\/\*[\s\S]*?\*\//g, '');
123
+ if (hasRawCssColors(suppressedContent)) {
124
+ issues.push({
125
+ file,
126
+ check: 'raw-color-value',
127
+ message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
128
+ severity: 'error',
129
+ });
130
+ }
94
131
  }
95
132
  // Check for non-tokenized custom properties
96
133
  if (tokenPrefix) {
@@ -156,9 +193,10 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
156
193
  * @param affectedFiles - File paths (relative to repoDir)
157
194
  * @param newFiles - Set of files that are newly created in this patch
158
195
  * @param config - Project configuration
196
+ * @param patchOwnedFiles - Optional set of patch-owned `.sys.mjs` paths for scoped JSDoc enforcement
159
197
  * @returns Array of lint issues
160
198
  */
161
- export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config) {
199
+ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles) {
162
200
  const jsFiles = affectedFiles.filter(isJsFile);
163
201
  if (jsFiles.length === 0)
164
202
  return [];
@@ -186,45 +224,50 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config) {
186
224
  // 2. File size check (new files only)
187
225
  if (isNew) {
188
226
  const lineCount = content.split('\n').length;
189
- if (lineCount > 650) {
227
+ const isTest = isTestFile(file);
228
+ const thresholds = isTest ? FILE_SIZE_THRESHOLDS.test : FILE_SIZE_THRESHOLDS.general;
229
+ const label = isTest ? 'Test file' : 'New file';
230
+ const verb = isTest ? 'splitting' : 'decomposing';
231
+ if (lineCount >= thresholds.error) {
190
232
  issues.push({
191
233
  file,
192
234
  check: 'file-too-large',
193
- message: `New file has ${lineCount} lines (recommended max: 650). Consider decomposing.`,
235
+ message: `${label} has ${lineCount} lines (hard limit: ${thresholds.error}). Consider ${verb}.`,
236
+ severity: 'error',
237
+ });
238
+ }
239
+ else if (lineCount >= thresholds.warning) {
240
+ issues.push({
241
+ file,
242
+ check: 'file-too-large',
243
+ message: `${label} has ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider ${verb}.`,
194
244
  severity: 'warning',
195
245
  });
196
246
  }
247
+ else if (lineCount >= thresholds.notice) {
248
+ issues.push({
249
+ file,
250
+ check: 'file-too-large',
251
+ message: `${label} has ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider ${verb}.`,
252
+ severity: 'notice',
253
+ });
254
+ }
197
255
  }
198
- // 3. JSDoc on exports (new .sys.mjs files only)
199
- if (isNew && isSysMjs) {
200
- const lines = content.split('\n');
201
- for (let i = 0; i < lines.length; i++) {
202
- const line = lines[i] ?? '';
203
- if (/^export\s+(function|class|const|let|var)\s/.test(line)) {
204
- // Walk backwards to find JSDoc
205
- let hasJsDoc = false;
206
- for (let j = i - 1; j >= 0; j--) {
207
- const prev = (lines[j] ?? '').trim();
208
- if (prev === '')
209
- continue;
210
- if (prev.endsWith('*/')) {
211
- hasJsDoc = true;
212
- }
213
- break;
214
- }
215
- if (!hasJsDoc) {
216
- issues.push({
217
- file,
218
- check: 'missing-jsdoc',
219
- message: `Export at line ${i + 1} is missing a JSDoc comment with @param/@returns.`,
220
- severity: 'warning',
221
- });
222
- }
223
- }
256
+ // 3. JSDoc on exports (patch-owned .sys.mjs files)
257
+ const isOwned = patchOwnedFiles ? patchOwnedFiles.has(file) : isNew;
258
+ if (isOwned && isSysMjs) {
259
+ const jsdocIssues = validateExportJsDoc(content);
260
+ for (const jsdocIssue of jsdocIssues) {
261
+ issues.push({
262
+ file,
263
+ check: jsdocIssue.check,
264
+ message: jsdocIssue.message,
265
+ severity: 'error',
266
+ });
224
267
  }
225
268
  }
226
269
  // 4. Observer topic naming
227
- const topicPattern = /(?:addObserver|removeObserver|notifyObservers)\s*\([^)]*["']([^"']+)["']/g;
270
+ const topicPattern = /(?:addObserver|removeObserver|notifyObservers)\s*\([^)\n]*["']([^"']+)["']/g;
228
271
  let topicMatch;
229
272
  while ((topicMatch = topicPattern.exec(strippedContent)) !== null) {
230
273
  const topic = topicMatch[1];
@@ -291,14 +334,32 @@ export function lintPatchSize(filesAffected, lineCount) {
291
334
  severity: 'warning',
292
335
  });
293
336
  }
294
- if (lineCount > 300) {
337
+ const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
338
+ const thresholds = allTests ? PATCH_LINE_THRESHOLDS.test : PATCH_LINE_THRESHOLDS.general;
339
+ if (lineCount >= thresholds.error) {
340
+ issues.push({
341
+ file: '(patch)',
342
+ check: 'large-patch-lines',
343
+ message: `Patch is ${lineCount} lines (hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
344
+ severity: 'error',
345
+ });
346
+ }
347
+ else if (lineCount >= thresholds.warning) {
295
348
  issues.push({
296
349
  file: '(patch)',
297
350
  check: 'large-patch-lines',
298
- message: `Patch is ${lineCount} lines (recommended: ≤300). Consider splitting into smaller, focused patches.`,
351
+ message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
299
352
  severity: 'warning',
300
353
  });
301
354
  }
355
+ else if (lineCount >= thresholds.notice) {
356
+ issues.push({
357
+ file: '(patch)',
358
+ check: 'large-patch-lines',
359
+ message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
360
+ severity: 'notice',
361
+ });
362
+ }
302
363
  return issues;
303
364
  }
304
365
  // ---------------------------------------------------------------------------
@@ -346,20 +407,22 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
346
407
  * @param affectedFiles - File paths (relative to repoDir) affected by the patch
347
408
  * @param diffContent - Raw unified diff string
348
409
  * @param config - Project configuration
410
+ * @param patchQueueCtx - Optional cross-patch context for ownership resolution
349
411
  * @returns Array of all lint issues found
350
412
  */
351
- export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config) {
413
+ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx) {
352
414
  const newFiles = detectNewFilesInDiff(diffContent);
353
415
  const lineCount = diffContent.split('\n').length;
416
+ const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
354
417
  const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
355
- lintPatchedCss(repoDir, affectedFiles, diffContent),
418
+ lintPatchedCss(repoDir, affectedFiles, diffContent, config),
356
419
  lintNewFileHeaders(repoDir, [...newFiles], config),
357
- lintPatchedJs(repoDir, affectedFiles, newFiles, config),
420
+ lintPatchedJs(repoDir, affectedFiles, newFiles, config, patchOwnedFiles),
358
421
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
359
422
  ]);
360
423
  const modCommentIssues = lintModificationComments(diffContent, config);
361
424
  const sizeIssues = lintPatchSize(affectedFiles, lineCount);
362
- return [
425
+ const issues = [
363
426
  ...sizeIssues,
364
427
  ...cssIssues,
365
428
  ...headerIssues,
@@ -367,5 +430,11 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
367
430
  ...jsIssues,
368
431
  ...modCommentIssues,
369
432
  ];
433
+ // Optional checkJs pass — only when explicitly enabled in config
434
+ if (config.patchLint?.checkJs) {
435
+ const checkJsIssues = await runCheckJs(repoDir, patchOwnedFiles);
436
+ issues.push(...checkJsIssues);
437
+ }
438
+ return issues;
370
439
  }
371
440
  //# sourceMappingURL=patch-lint.js.map
@@ -0,0 +1,5 @@
1
+ import type { PatchMetadata } from '../types/commands/index.js';
2
+ /**
3
+ * Resolves a patch identifier (ordinal number or filename) to its manifest entry.
4
+ */
5
+ export declare function resolvePatchIdentifier(identifier: string, patches: PatchMetadata[]): PatchMetadata | null;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolves a patch identifier (ordinal number or filename) to its manifest entry.
3
+ */
4
+ export function resolvePatchIdentifier(identifier, patches) {
5
+ if (/^\d+$/.test(identifier)) {
6
+ const order = parseInt(identifier, 10);
7
+ return patches.find((p) => p.order === order) ?? null;
8
+ }
9
+ const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
10
+ return patches.find((p) => p.filename === normalized) ?? null;
11
+ }
12
+ //# sourceMappingURL=patch-manifest-resolve.js.map
@@ -8,4 +8,5 @@ export type { PatchManifestConsistencyIssue } from './patch-manifest-consistency
8
8
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
9
9
  export { addPatchToManifest, loadPatchesManifest, PatchDeleteRollbackError, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, removePatchFromManifest, renumberPatchesInManifest, savePatchesManifest, } from './patch-manifest-io.js';
10
10
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
11
+ export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
11
12
  export { validatePatchesManifest } from './patch-manifest-validate.js';