@webjsdev/server 0.7.2

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/src/check.js ADDED
@@ -0,0 +1,878 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative, sep, basename, dirname } from 'node:path';
3
+ import { walk } from './fs-walk.js';
4
+
5
+ /**
6
+ * Convention validator for webjs apps.
7
+ *
8
+ * Scans an app directory and reports deviations from the conventions
9
+ * documented in AGENTS.md. Designed to be run by AI agents, CI pipelines,
10
+ * or `webjs lint` to catch structural mistakes early.
11
+ *
12
+ * **How AI agents should use the output:**
13
+ * Each violation includes a machine-readable `rule` identifier, the offending
14
+ * `file` (relative to appDir), a human-readable `message`, and a suggested
15
+ * `fix`. Agents should iterate the array and apply (or propose) the fixes.
16
+ * Rules can be disabled per-project via the
17
+ * `"webjs": { "conventions": { … } }` key in `package.json`. That is
18
+ * the only supported config surface. If the key is absent, every
19
+ * rule defaults to enabled.
20
+ *
21
+ * @module check
22
+ */
23
+
24
+ /**
25
+ * @typedef {{
26
+ * rule: string,
27
+ * file: string,
28
+ * message: string,
29
+ * fix: string,
30
+ * }} Violation
31
+ */
32
+
33
+ /**
34
+ * @typedef {{
35
+ * name: string,
36
+ * description: string,
37
+ * }} RuleDescriptor
38
+ */
39
+
40
+ /**
41
+ * All available rule names with descriptions. Useful for help text and
42
+ * documentation generators.
43
+ *
44
+ * @type {RuleDescriptor[]}
45
+ */
46
+ export const RULES = [
47
+ {
48
+ name: 'actions-in-modules',
49
+ description:
50
+ 'Server action files (*.server.{js,ts} or \'use server\') should live under modules/*/actions/ or modules/*/queries/, not loose in the app root. Files under lib/ are exempt: lib/ is the documented home for cross-cutting server infrastructure (prisma client, session helpers, auth config). Skipped when no modules/ directory exists.',
51
+ },
52
+ {
53
+ name: 'one-function-per-action',
54
+ description:
55
+ 'Each .server.{js,ts} file under modules/*/actions/ or modules/*/queries/ should export exactly one async function (one-function-per-file convention). Files outside those two directories: lib/ infrastructure modules, route handlers: are exempt; this rule is specifically about the action/query file pattern.',
56
+ },
57
+ {
58
+ name: 'components-have-register',
59
+ description:
60
+ 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path at boot.',
61
+ },
62
+ {
63
+ name: 'no-server-env-in-components',
64
+ description:
65
+ 'Component files (under components/ or modules/*/components/) must not read non-public environment variables. process.env.X is allowed when X starts with WEBJS_PUBLIC_ (exposed to the browser via the SSR shim) or equals NODE_ENV (also defined in the browser). Any other process.env read in a component would leak the server-side value into the SSR\'d HTML, then read as undefined after hydration. Read server-only env vars in a page function, server action, or middleware (which never reach the browser as source) and pass derived values to the component as attributes.',
66
+ },
67
+ {
68
+ name: 'tests-exist',
69
+ description:
70
+ 'Each modules/<feature>/ directory should have corresponding test files under test/unit/ or test/e2e/.',
71
+ },
72
+ {
73
+ name: 'tag-name-has-hyphen',
74
+ description:
75
+ 'Static tag = \'...\' in component files must contain a hyphen (HTML custom element spec).',
76
+ },
77
+ {
78
+ name: 'reactive-props-use-declare',
79
+ description:
80
+ 'Reactive properties listed in `static properties = { … }` must be typed with `declare propName: Type` (no value), and have their default set in `constructor()`. Plain class-field initializers (`prop = value` or `prop: Type = value`) compile to Object.defineProperty *after* super() under modern class-field semantics, clobbering the framework\'s reactive accessor and silently breaking re-renders.',
81
+ },
82
+ {
83
+ name: 'no-json-data-files',
84
+ description:
85
+ 'Apps must use Prisma + SQLite (already wired up in every scaffold) for persisted data, not JSON files. Flags JSON files that look like a fake database: top-level data/ JSON files (data/todos.json, data/posts.json…), or DB-shaped names (db.json, database.json, store.json, *-db.json) anywhere outside node_modules/, prisma/, .next/, dist/, build/, public/. Read-only seed data and config JSON (package.json, tsconfig.json, etc.) are exempt.',
86
+ },
87
+ {
88
+ name: 'shell-in-non-root-layout',
89
+ description:
90
+ 'Only the root layout (app/layout.{js,ts}) may write a <!doctype>/<html>/<head>/<body> shell to override default <html lang>, <body class>, etc. Non-root layouts (app/<segment>/layout.{js,ts}) and pages (app/**/page.{js,ts}) must not: the framework auto-emits the wrapper around the whole composition, so a nested shell ends up nested inside <body> where browsers drop it. Triggers on any of <!doctype>, <html, <head, <body in a non-root layout or page.',
91
+ },
92
+ {
93
+ name: 'erasable-typescript-only',
94
+ description:
95
+ 'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fall back to esbuild + inline sourcemap, which is supported as a safety net for third-party deps but should not be the path your own code takes. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.',
96
+ },
97
+ {
98
+ name: 'use-server-needs-extension',
99
+ description:
100
+ 'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
101
+ },
102
+ ];
103
+
104
+ /** Set of all known rule names for fast lookup. */
105
+ const RULE_NAMES = new Set(RULES.map((r) => r.name));
106
+
107
+ /**
108
+ * Check whether a file has the `'use server'` directive in its first
109
+ * five lines. Used by the `use-server-needs-extension` rule, and by
110
+ * `isServerActionFile` below.
111
+ * @param {string} content file content (already read)
112
+ * @returns {boolean}
113
+ */
114
+ function hasUseServerDirective(content) {
115
+ const head = content.split('\n').slice(0, 5).join('\n');
116
+ return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
117
+ }
118
+
119
+ /**
120
+ * Check whether a file is a server action. A server action requires
121
+ * BOTH the `.server.{js,ts,mts,mjs}` extension AND the `'use server'`
122
+ * directive in the file head. Either alone is not enough: bare `.server.ts`
123
+ * is a server-only utility (no RPC), and bare `'use server'` is a lint
124
+ * violation (use-server-needs-extension).
125
+ * @param {string} filePath absolute path
126
+ * @param {string} content file content (already read)
127
+ * @returns {boolean}
128
+ */
129
+ function isServerActionFile(filePath, content) {
130
+ if (!/\.server\.m?[jt]s$/.test(filePath)) return false;
131
+ return hasUseServerDirective(content);
132
+ }
133
+
134
+ /**
135
+ * Check whether a file resides under a components/ directory (shared or
136
+ * module-scoped).
137
+ * @param {string} relPath - path relative to appDir
138
+ * @returns {boolean}
139
+ */
140
+ function isComponentFile(relPath) {
141
+ const segments = relPath.split(sep);
142
+ return segments.includes('components');
143
+ }
144
+
145
+ /**
146
+ * Public wrapper around `loadOverrides` for callers (CLI, docs tools)
147
+ * that want to inspect what's disabled in a project without running
148
+ * the full check pipeline.
149
+ *
150
+ * @param {string} appDir
151
+ * @returns {Promise<Record<string, boolean>>}
152
+ */
153
+ export async function loadConventionOverrides(appDir) {
154
+ return loadOverrides(appDir);
155
+ }
156
+
157
+ /**
158
+ * Load overrides from the `"webjs": { "conventions": { … } }` key in
159
+ * `package.json`. Returns a map of rule name to boolean (true =
160
+ * enabled, false = disabled). Missing rules default to true.
161
+ *
162
+ * @param {string} appDir
163
+ * @returns {Promise<Record<string, boolean>>}
164
+ */
165
+ async function loadOverrides(appDir) {
166
+ try {
167
+ const pkgPath = join(appDir, 'package.json');
168
+ const pkgText = await readFile(pkgPath, 'utf8');
169
+ const pkg = JSON.parse(pkgText);
170
+ if (pkg.webjs && typeof pkg.webjs === 'object'
171
+ && pkg.webjs.conventions && typeof pkg.webjs.conventions === 'object') {
172
+ return pkg.webjs.conventions;
173
+ }
174
+ } catch {
175
+ // No package.json: every rule defaults to enabled.
176
+ }
177
+ return {};
178
+ }
179
+
180
+ /**
181
+ * Check whether a rule is enabled given the overrides.
182
+ * @param {string} ruleName
183
+ * @param {Record<string, boolean>} overrides
184
+ * @returns {boolean}
185
+ */
186
+ function isRuleEnabled(ruleName, overrides) {
187
+ if (ruleName in overrides) return overrides[ruleName] !== false;
188
+ return true;
189
+ }
190
+
191
+ /**
192
+ * Guess a module name from a loose server action file path. Used for the
193
+ * `fix` suggestion in `actions-in-modules`.
194
+ * @param {string} relPath
195
+ * @returns {string}
196
+ */
197
+ function guessModuleName(relPath) {
198
+ const segments = relPath.split(sep);
199
+ // Try to infer from the parent directory name
200
+ // e.g. app/api/users/create.server.ts -> "users"
201
+ for (let i = segments.length - 2; i >= 0; i--) {
202
+ const seg = segments[i];
203
+ if (seg !== 'app' && seg !== 'api' && !seg.startsWith('[') && !seg.startsWith('(') && !seg.startsWith('_')) {
204
+ return seg;
205
+ }
206
+ }
207
+ // Fall back to the file stem
208
+ const base = basename(relPath).replace(/\.server\.m?[jt]s$/, '').replace(/\.m?[jt]s$/, '');
209
+ return base;
210
+ }
211
+
212
+ /**
213
+ * Count the number of named exported async functions in source text using
214
+ * regex heuristics (no AST: intentionally fast and loose).
215
+ *
216
+ * Looks for patterns like:
217
+ * export async function name(...)
218
+ * export const name = async (...)
219
+ * export const name = async function(...)
220
+ * export default async function(...)
221
+ *
222
+ * @param {string} content
223
+ * @returns {number}
224
+ */
225
+ function countExportedFunctions(content) {
226
+ const patterns = [
227
+ /export\s+async\s+function\s+\w+/g,
228
+ /export\s+const\s+\w+\s*=\s*async\s/g,
229
+ /export\s+default\s+async\s+function/g,
230
+ /export\s+function\s+\w+/g,
231
+ /export\s+const\s+\w+\s*=\s*(?:async\s*)?\(/g,
232
+ /export\s+const\s+\w+\s*=\s*(?:async\s*)?function/g,
233
+ ];
234
+ const seen = new Set();
235
+ for (const pat of patterns) {
236
+ let m;
237
+ while ((m = pat.exec(content)) !== null) {
238
+ seen.add(m.index);
239
+ }
240
+ }
241
+ return seen.size;
242
+ }
243
+
244
+ /**
245
+ * Extract the body of every `class … extends WebComponent { … }` block.
246
+ * Brace-counts to handle nested template literals, methods, and arrow
247
+ * functions. String state is tracked so braces inside strings/templates
248
+ * don't shift depth.
249
+ *
250
+ * @param {string} content
251
+ * @returns {string[]}
252
+ */
253
+ function extractWebComponentClassBodies(content) {
254
+ const bodies = [];
255
+ const re = /class\s+\w+\s+extends\s+WebComponent\s*\{/g;
256
+ let m;
257
+ while ((m = re.exec(content)) !== null) {
258
+ const bodyStart = m.index + m[0].length;
259
+ const end = matchClosingBrace(content, bodyStart);
260
+ if (end !== -1) bodies.push(content.slice(bodyStart, end));
261
+ }
262
+ return bodies;
263
+ }
264
+
265
+ /**
266
+ * Walk forward from `start` (just after an opening `{`) and return the
267
+ * index of the matching `}`. Tracks string/template-literal state so
268
+ * `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
269
+ * Returns -1 if no balanced brace is found.
270
+ *
271
+ * @param {string} s
272
+ * @param {number} start
273
+ */
274
+ function matchClosingBrace(s, start) {
275
+ let depth = 1;
276
+ let i = start;
277
+ let str = ''; // '', "'", '"', or '`'
278
+ while (i < s.length) {
279
+ const c = s[i];
280
+ if (str) {
281
+ if (c === '\\') { i += 2; continue; }
282
+ if (c === str) str = '';
283
+ else if (str === '`' && c === '$' && s[i + 1] === '{') {
284
+ // template hole: count its closing `}` toward our brace depth.
285
+ depth++;
286
+ i += 2;
287
+ continue;
288
+ }
289
+ i++;
290
+ continue;
291
+ }
292
+ if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
293
+ if (c === '/' && s[i + 1] === '/') { // line comment
294
+ while (i < s.length && s[i] !== '\n') i++;
295
+ continue;
296
+ }
297
+ if (c === '/' && s[i + 1] === '*') { // block comment
298
+ i += 2;
299
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
300
+ i += 2;
301
+ continue;
302
+ }
303
+ if (c === '{') depth++;
304
+ else if (c === '}') { depth--; if (depth === 0) return i; }
305
+ i++;
306
+ }
307
+ return -1;
308
+ }
309
+
310
+ /**
311
+ * Find every `<key>:` entry inside the first `static properties = { … }`
312
+ * literal in `classBody`. Returns the bare property names: the keys
313
+ * we'll then look up as class fields.
314
+ *
315
+ * @param {string} classBody
316
+ * @returns {Set<string>}
317
+ */
318
+ function extractStaticPropertyNames(classBody) {
319
+ /** @type {Set<string>} */
320
+ const names = new Set();
321
+ const m = /static\s+properties\s*=\s*\{/.exec(classBody);
322
+ if (!m) return names;
323
+ const objStart = m.index + m[0].length;
324
+ const objEnd = matchClosingBrace(classBody, objStart);
325
+ if (objEnd === -1) return names;
326
+ const obj = classBody.slice(objStart, objEnd);
327
+ // Match keys at the top level of the object literal. A nested `{ … }`
328
+ // (the per-property declaration) is skipped via brace counting.
329
+ let i = 0;
330
+ while (i < obj.length) {
331
+ // Skip whitespace and commas.
332
+ while (i < obj.length && /[\s,]/.test(obj[i])) i++;
333
+ if (i >= obj.length) break;
334
+ // Read the identifier or string-literal key.
335
+ let key = '';
336
+ if (obj[i] === '"' || obj[i] === "'") {
337
+ const quote = obj[i++];
338
+ while (i < obj.length && obj[i] !== quote) { key += obj[i++]; }
339
+ i++; // closing quote
340
+ } else {
341
+ while (i < obj.length && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
342
+ }
343
+ // Skip whitespace, then expect `:`.
344
+ while (i < obj.length && /\s/.test(obj[i])) i++;
345
+ if (obj[i] !== ':') break;
346
+ i++;
347
+ // Skip whitespace, then skip the value (either a `{ … }` literal or
348
+ // a single token like `String`).
349
+ while (i < obj.length && /\s/.test(obj[i])) i++;
350
+ if (obj[i] === '{') {
351
+ const valEnd = matchClosingBrace(obj, i + 1);
352
+ if (valEnd === -1) break;
353
+ i = valEnd + 1;
354
+ } else {
355
+ while (i < obj.length && obj[i] !== ',' && obj[i] !== '}') i++;
356
+ }
357
+ if (key) names.add(key);
358
+ }
359
+ return names;
360
+ }
361
+
362
+ /**
363
+ * Scan a class body for class-field initializers naming any of `props`.
364
+ * "Class-field" means: at the top of the class body (brace depth 0
365
+ * relative to the body), at the start of a line, NOT prefixed with
366
+ * `declare`, `static`, or `this.`.
367
+ *
368
+ * Returns the offending property names. The caller maps these to
369
+ * Violation objects.
370
+ *
371
+ * @param {string} classBody
372
+ * @param {Set<string>} props
373
+ * @returns {string[]}
374
+ */
375
+ function findFieldInitializers(classBody, props) {
376
+ /** @type {string[]} */
377
+ const out = [];
378
+ // Walk the body, tracking brace depth. At depth 0, look for
379
+ // class-field-shaped lines.
380
+ let depth = 0;
381
+ let i = 0;
382
+ let lineStart = 0;
383
+ let str = '';
384
+ while (i < classBody.length) {
385
+ const c = classBody[i];
386
+ if (str) {
387
+ if (c === '\\') { i += 2; continue; }
388
+ if (c === str) str = '';
389
+ else if (str === '`' && c === '$' && classBody[i + 1] === '{') {
390
+ depth++;
391
+ i += 2;
392
+ continue;
393
+ }
394
+ i++;
395
+ continue;
396
+ }
397
+ if (c === '\n') {
398
+ lineStart = i + 1;
399
+ i++;
400
+ continue;
401
+ }
402
+ if (c === '/' && classBody[i + 1] === '/') {
403
+ while (i < classBody.length && classBody[i] !== '\n') i++;
404
+ continue;
405
+ }
406
+ if (c === '/' && classBody[i + 1] === '*') {
407
+ i += 2;
408
+ while (i < classBody.length && !(classBody[i] === '*' && classBody[i + 1] === '/')) i++;
409
+ i += 2;
410
+ continue;
411
+ }
412
+ if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
413
+ if (c === '{') { depth++; i++; continue; }
414
+ if (c === '}') { depth--; i++; continue; }
415
+ // At class-body top level, examine candidate lines starting at lineStart.
416
+ if (depth === 0 && i === lineStart || (depth === 0 && /\s/.test(c) && i === lineStart)) {
417
+ // Take the rest of the line up to a newline.
418
+ let j = lineStart;
419
+ while (j < classBody.length && classBody[j] !== '\n') j++;
420
+ const line = classBody.slice(lineStart, j);
421
+ // Match: optional whitespace, optional `public/private/protected/readonly`,
422
+ // an identifier, optional `: <type>`, then `=`.
423
+ const fieldRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
424
+ const m = fieldRe.exec(line);
425
+ if (m) {
426
+ const name = m[2];
427
+ // `declare`, `static`, and `this.` patterns shouldn't reach here
428
+ // (declare/static start with their keyword, this.x has the dot in
429
+ // the regex group), but guard against matching keywords as names:
430
+ if (name !== 'declare' && name !== 'static' && props.has(name)) {
431
+ out.push(name);
432
+ }
433
+ }
434
+ // Advance past this line so we don't re-match.
435
+ i = j;
436
+ continue;
437
+ }
438
+ i++;
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * Scan a webjs app directory and report convention violations.
445
+ *
446
+ * @param {string} appDir - absolute path to the app root (the directory
447
+ * containing `app/`, `modules/`, `components/`, etc.)
448
+ * @param {{ rules?: Record<string, boolean> }} [opts] - programmatic
449
+ * overrides. Merged on top of file-based overrides loaded from
450
+ * `package.json` `"webjs"."conventions"`. Set a rule to `false` to
451
+ * skip it.
452
+ * @returns {Promise<Violation[]>}
453
+ *
454
+ * @example
455
+ * ```js
456
+ * import { checkConventions } from '@webjsdev/server';
457
+ * const violations = await checkConventions('/path/to/myapp');
458
+ * for (const v of violations) {
459
+ * console.warn(`[${v.rule}] ${v.file}: ${v.message}`);
460
+ * }
461
+ * ```
462
+ */
463
+ export async function checkConventions(appDir, opts) {
464
+ const fileOverrides = await loadOverrides(appDir);
465
+ const overrides = { ...fileOverrides, ...(opts?.rules || {}) };
466
+
467
+ /** @type {Violation[]} */
468
+ const violations = [];
469
+
470
+ // Determine if modules/ directory exists (small apps exempt from some rules)
471
+ let hasModulesDir = false;
472
+ try {
473
+ const s = await stat(join(appDir, 'modules'));
474
+ hasModulesDir = s.isDirectory();
475
+ } catch {
476
+ // no modules/ dir
477
+ }
478
+
479
+ // Determine which module feature names exist
480
+ /** @type {string[]} */
481
+ const moduleNames = [];
482
+ if (hasModulesDir) {
483
+ try {
484
+ const entries = await readdir(join(appDir, 'modules'), { withFileTypes: true });
485
+ for (const e of entries) {
486
+ if (e.isDirectory() && !e.name.startsWith('.')) {
487
+ moduleNames.push(e.name);
488
+ }
489
+ }
490
+ } catch {
491
+ // could not read modules/
492
+ }
493
+ }
494
+
495
+ // Collect all JS/TS files in the app directory
496
+ /** @type {{ abs: string, rel: string, content: string }[]} */
497
+ const files = [];
498
+ for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
499
+ const rel = relative(appDir, abs);
500
+ let content;
501
+ try {
502
+ content = await readFile(abs, 'utf8');
503
+ } catch {
504
+ continue;
505
+ }
506
+ files.push({ abs, rel, content });
507
+ }
508
+
509
+ // --- Rule: actions-in-modules ---
510
+ if (hasModulesDir && isRuleEnabled('actions-in-modules', overrides)) {
511
+ for (const { abs, rel, content } of files) {
512
+ if (!isServerActionFile(abs, content)) continue;
513
+ const normRel = rel.split(sep).join('/');
514
+ // OK: action / query files inside modules/<feature>/{actions,queries}/
515
+ if (/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
516
+ // OK: module-scoped components/utils (utils may use 'use server' too)
517
+ if (/^modules\/[^/]+\/(components|utils)\//.test(normRel)) continue;
518
+ // OK: cross-cutting server infrastructure under lib/. The documented
519
+ // pattern puts the Prisma singleton, session helpers, auth config,
520
+ // password hashing, etc. in lib/: those files are intentionally
521
+ // multi-export 'use server' modules, not one-function actions.
522
+ if (/^lib\//.test(normRel)) continue;
523
+ // Anything else (loose at the root, under app/, etc.) is flagged.
524
+ const moduleName = guessModuleName(rel);
525
+ const fileBase = basename(rel);
526
+ violations.push({
527
+ rule: 'actions-in-modules',
528
+ file: rel,
529
+ message: `Server action should be in modules/${moduleName}/actions/`,
530
+ fix: `Move to modules/${moduleName}/actions/${fileBase}`,
531
+ });
532
+ }
533
+ }
534
+
535
+ // --- Rule: one-function-per-action ---
536
+ // Apply ONLY to files inside modules/<feature>/{actions,queries}/: that
537
+ // is where the one-function-per-file convention lives. lib/ infra modules
538
+ // and any other 'use server' file outside the action/query dirs are
539
+ // intentional multi-export utility modules and are exempt.
540
+ if (isRuleEnabled('one-function-per-action', overrides)) {
541
+ for (const { abs, rel, content } of files) {
542
+ if (!isServerActionFile(abs, content)) continue;
543
+ const normRel = rel.split(sep).join('/');
544
+ if (!/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
545
+ const count = countExportedFunctions(content);
546
+ if (count > 1) {
547
+ violations.push({
548
+ rule: 'one-function-per-action',
549
+ file: rel,
550
+ message: `Server action file exports ${count} functions; convention is one per file`,
551
+ fix: 'Split into separate .server.{js,ts} files, one exported function each',
552
+ });
553
+ }
554
+ }
555
+ }
556
+
557
+ // --- Rule: components-have-register ---
558
+ if (isRuleEnabled('components-have-register', overrides)) {
559
+ for (const { rel, content } of files) {
560
+ if (!isComponentFile(rel)) continue;
561
+ // Check if it defines a class extending WebComponent
562
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
563
+ // Accept either registration pattern:
564
+ // Counter.register('tag') (webjs idiom)
565
+ // customElements.define('tag', Counter) (native)
566
+ if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"]/.test(content)) continue;
567
+ if (/\bcustomElements\.define\s*\(/.test(content)) continue;
568
+ violations.push({
569
+ rule: 'components-have-register',
570
+ file: rel,
571
+ message: "Component extends WebComponent but is never registered. Call ClassName.register('tag-name') at the bottom of the file.",
572
+ fix: "Add `ClassName.register('tag-name')` after the class definition",
573
+ });
574
+ }
575
+ }
576
+
577
+ // --- Rule: reactive-props-use-declare ---
578
+ if (isRuleEnabled('reactive-props-use-declare', overrides)) {
579
+ for (const { rel, content } of files) {
580
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
581
+ for (const body of extractWebComponentClassBodies(content)) {
582
+ const propNames = extractStaticPropertyNames(body);
583
+ if (propNames.size === 0) continue;
584
+ for (const bad of findFieldInitializers(body, propNames)) {
585
+ violations.push({
586
+ rule: 'reactive-props-use-declare',
587
+ file: rel,
588
+ message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
589
+ fix: `Replace with \`declare ${bad}: <Type>;\` and set the default inside \`constructor()\` after \`super()\`.`,
590
+ });
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ // --- Rule: no-server-env-in-components ---
597
+ // Catches `process.env.X` reads in component files where X is not a
598
+ // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
599
+ // two categories to the browser; any other read either leaks a secret
600
+ // into the SSR'd HTML or reads as undefined after hydration.
601
+ if (isRuleEnabled('no-server-env-in-components', overrides)) {
602
+ for (const { abs, rel, content } of files) {
603
+ if (!isComponentFile(rel)) continue;
604
+ if (isServerActionFile(abs, content)) continue;
605
+
606
+ const re = /\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g;
607
+ const seen = new Set();
608
+ let m;
609
+ while ((m = re.exec(content)) !== null) {
610
+ const name = m[1];
611
+ if (name.startsWith('WEBJS_PUBLIC_')) continue;
612
+ if (name === 'NODE_ENV') continue;
613
+ if (seen.has(name)) continue;
614
+ seen.add(name);
615
+ violations.push({
616
+ rule: 'no-server-env-in-components',
617
+ file: rel,
618
+ message: `Component reads process.env.${name}; server-only env vars must not be read in components (would leak into SSR'd HTML and read as undefined after hydration)`,
619
+ fix: `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
620
+ });
621
+ }
622
+ }
623
+ }
624
+
625
+ // --- Rule: tests-exist ---
626
+ if (hasModulesDir && isRuleEnabled('tests-exist', overrides)) {
627
+ for (const mod of moduleNames) {
628
+ // Look for test files that reference this module
629
+ let hasTest = false;
630
+
631
+ // Check test/unit/ and test/e2e/
632
+ for (const testDir of ['test/unit', 'test/e2e', 'test']) {
633
+ try {
634
+ const testDirAbs = join(appDir, testDir);
635
+ for await (const testFile of walk(testDirAbs, (p) => /\.(test|spec)\.m?[jt]sx?$/.test(p))) {
636
+ const testRel = relative(appDir, testFile);
637
+ // Check if test file name contains the module name
638
+ if (testRel.toLowerCase().includes(mod.toLowerCase())) {
639
+ hasTest = true;
640
+ break;
641
+ }
642
+ }
643
+ } catch {
644
+ // test directory doesn't exist
645
+ }
646
+ if (hasTest) break;
647
+ }
648
+
649
+ if (!hasTest) {
650
+ violations.push({
651
+ rule: 'tests-exist',
652
+ file: `modules/${mod}`,
653
+ message: `No test files found for module "${mod}"`,
654
+ fix: `Add test files under test/unit/${mod}.test.js or test/e2e/${mod}.test.js`,
655
+ });
656
+ }
657
+ }
658
+ }
659
+
660
+ // --- Rule: no-json-data-files ---
661
+ // Catch AI agents (or hurried humans) using JSON files as a substitute for
662
+ // the real database. Every scaffold ships Prisma + SQLite ready to go, so
663
+ // there is never a good reason to invent `data/todos.json`, `db.json`,
664
+ // etc. The rule is intentionally narrow: we only flag JSON files that
665
+ // *look like* a database: by location (top-level `data/` directory) or by
666
+ // name (db/database/store/*-db). Config and read-only seed JSON elsewhere
667
+ // is left alone.
668
+ if (isRuleEnabled('no-json-data-files', overrides)) {
669
+ /** @type {Array<{rel: string, why: string}>} */
670
+ const suspects = [];
671
+ /**
672
+ * @param {string} dir absolute
673
+ * @param {string} relBase relative to appDir
674
+ */
675
+ async function scanDir(dir, relBase) {
676
+ /** @type {import('node:fs').Dirent[]} */
677
+ let entries;
678
+ try {
679
+ entries = await readdir(dir, { withFileTypes: true });
680
+ } catch {
681
+ return;
682
+ }
683
+ for (const e of entries) {
684
+ const name = e.name;
685
+ if (name.startsWith('.')) continue;
686
+ // Skip directories we know are not the user's data dir.
687
+ if (e.isDirectory()) {
688
+ if (
689
+ name === 'node_modules' ||
690
+ name === 'prisma' ||
691
+ name === 'dist' ||
692
+ name === 'build' ||
693
+ name === '.next' ||
694
+ name === 'coverage' ||
695
+ name === 'public'
696
+ ) continue;
697
+ await scanDir(join(dir, name), relBase ? `${relBase}/${name}` : name);
698
+ continue;
699
+ }
700
+ if (!e.isFile()) continue;
701
+ if (!name.endsWith('.json')) continue;
702
+ const rel = relBase ? `${relBase}/${name}` : name;
703
+
704
+ // Skip well-known config / tooling JSON.
705
+ const configNames = new Set([
706
+ 'package.json', 'package-lock.json', 'tsconfig.json',
707
+ 'jsconfig.json', 'manifest.json', 'site.webmanifest',
708
+ '.eslintrc.json', '.prettierrc.json', 'compose.json',
709
+ 'turbo.json', 'lerna.json', 'nx.json', 'biome.json',
710
+ 'renovate.json', 'vercel.json', 'now.json', 'fly.json',
711
+ ]);
712
+ if (configNames.has(name)) continue;
713
+
714
+ // Trigger 1: any JSON under a top-level `data/` directory.
715
+ if (rel.startsWith('data/')) {
716
+ suspects.push({ rel, why: `JSON file in top-level data/ directory (likely a fake database)` });
717
+ continue;
718
+ }
719
+
720
+ // Trigger 2: file name looks like a database.
721
+ const lower = name.toLowerCase();
722
+ const dbShapedName =
723
+ lower === 'db.json' ||
724
+ lower === 'database.json' ||
725
+ lower === 'store.json' ||
726
+ lower === 'storage.json' ||
727
+ /-db\.json$/.test(lower) ||
728
+ /\.db\.json$/.test(lower);
729
+ if (dbShapedName) {
730
+ suspects.push({ rel, why: `file name "${name}" suggests it is being used as a database` });
731
+ }
732
+ }
733
+ }
734
+ await scanDir(appDir, '');
735
+
736
+ for (const s of suspects) {
737
+ violations.push({
738
+ rule: 'no-json-data-files',
739
+ file: s.rel,
740
+ message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.ts), not JSON files.`,
741
+ fix: `Define a Prisma model in prisma/schema.prisma for this data, run \`webjs db migrate <name>\` to create the migration, then read/write via \`import { prisma } from 'lib/prisma.ts'\`. Delete ${s.rel} once the data has moved.`,
742
+ });
743
+ }
744
+ }
745
+
746
+ // --- Rule: shell-in-non-root-layout ---
747
+ // Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
748
+ // framework auto-emits the shell around the whole composition; a nested
749
+ // shell ends up duplicated and silently dropped by the HTML parser.
750
+ if (isRuleEnabled('shell-in-non-root-layout', overrides)) {
751
+ // Root layout = exactly "app/layout.js" or "app/layout.ts".
752
+ const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
753
+ // Any other layout or page under app/ (including pages, nested layouts).
754
+ const LAYOUT_OR_PAGE = /^app\/(?:.+\/)?(?:layout|page)\.(?:js|mjs|ts|mts)$/;
755
+ // Shell tags. Case-insensitive, allow whitespace, allow attributes for <html>/<body>.
756
+ const SHELL_RE = /<!doctype\b|<html\b|<head\b|<body\b/i;
757
+ for (const { rel, content } of files) {
758
+ if (ROOT_LAYOUT.test(rel)) continue;
759
+ if (!LAYOUT_OR_PAGE.test(rel)) continue;
760
+ // Strip line comments + /* … */ block comments + ` ` string-template
761
+ // tag content is fine: we're looking at the literal HTML in the
762
+ // returned `html` template, which won't be inside a code comment.
763
+ // A naive substring scan is good enough; false positives only when
764
+ // someone genuinely embeds `<html>` inside a string literal that
765
+ // isn't a layout shell (rare and probably an honest code smell).
766
+ const stripped = content
767
+ .replace(/\/\/.*$/gm, '')
768
+ .replace(/\/\*[\s\S]*?\*\//g, '');
769
+ const m = stripped.match(SHELL_RE);
770
+ if (m) {
771
+ violations.push({
772
+ rule: 'shell-in-non-root-layout',
773
+ file: rel,
774
+ message:
775
+ `Non-root layout/page contains ${m[0]}: only the root layout (app/layout.{js,ts}) may write the shell. The framework auto-emits <!doctype>/<html>/<head>/<body> around the whole composition; a nested shell ends up duplicated and dropped by the HTML parser.`,
776
+ fix:
777
+ 'Remove the <!doctype>/<html>/<head>/<body> wrapper from this file. Use the `metadata` export for <title>/<meta>/og/twitter, return inline <link>/<style>/<script> for head-bound resources (they auto-hoist), and put any `<html lang>` / `<body class>` overrides in app/layout.{js,ts} instead.',
778
+ });
779
+ }
780
+ }
781
+ }
782
+
783
+ // --- Rule: erasable-typescript-only ---
784
+ // The dev server's primary type-stripper is Node's built-in
785
+ // module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
786
+ // namespace with values, constructor parameter properties, legacy
787
+ // decorators, `import = require`). The fallback path is esbuild +
788
+ // inline sourcemap, which is a real ~3x wire-byte hit on every .ts
789
+ // request that takes it. Enforce TS-side rejection of those patterns
790
+ // via `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json so
791
+ // violations surface as red squiggles in the editor before they ever
792
+ // hit the dev server.
793
+ if (isRuleEnabled('erasable-typescript-only', overrides)) {
794
+ let tsconfigContent = null;
795
+ try {
796
+ tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
797
+ } catch {
798
+ // No tsconfig.json (pure JS app). Skip the rule.
799
+ }
800
+ if (tsconfigContent != null) {
801
+ let parsed = null;
802
+ try {
803
+ const stripped = tsconfigContent
804
+ .replace(/\/\/.*$/gm, '')
805
+ .replace(/\/\*[\s\S]*?\*\//g, '')
806
+ .replace(/,(\s*[}\]])/g, '$1');
807
+ parsed = JSON.parse(stripped);
808
+ } catch {
809
+ parsed = null;
810
+ }
811
+ const compilerOptions = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
812
+ const flag = compilerOptions && typeof compilerOptions === 'object' ? compilerOptions.erasableSyntaxOnly : undefined;
813
+ if (flag !== true) {
814
+ violations.push({
815
+ rule: 'erasable-typescript-only',
816
+ file: 'tsconfig.json',
817
+ message:
818
+ flag === false
819
+ ? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) falls back to esbuild + inline sourcemap on every request, costing ~3x wire bytes and losing byte-exact stack-trace positions.'
820
+ : '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a slower runtime fallback.',
821
+ fix:
822
+ 'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.',
823
+ });
824
+ }
825
+ }
826
+ }
827
+
828
+ // --- Rule: use-server-needs-extension ---
829
+ // Catch files that declare `'use server'` at the top but lack the
830
+ // `.server.{js,ts}` extension. Under the two-marker convention the
831
+ // directive alone does nothing (the file is served to the browser as
832
+ // plain source and exports are not registered as RPC), which is a
833
+ // silent footgun. The fix is mechanical: rename the file.
834
+ if (isRuleEnabled('use-server-needs-extension', overrides)) {
835
+ for (const { rel, content } of files) {
836
+ if (!hasUseServerDirective(content)) continue;
837
+ if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
838
+ const fileBase = basename(rel);
839
+ const renamedBase = fileBase.replace(/\.(m?[jt]sx?)$/, '.server.$1');
840
+ violations.push({
841
+ rule: 'use-server-needs-extension',
842
+ file: rel,
843
+ message:
844
+ "File declares `'use server'` but its name does not match `.server.{js,ts,mts,mjs}`. The directive is silently ignored: the file is served to the browser as plain source and its exports are not RPC-callable. Code the developer expects to run on the server actually runs in the browser.",
845
+ fix: `Rename to ${renamedBase} (add the .server. infix before the extension)`,
846
+ });
847
+ }
848
+ }
849
+
850
+ // --- Rule: tag-name-has-hyphen ---
851
+ if (isRuleEnabled('tag-name-has-hyphen', overrides)) {
852
+ for (const { rel, content } of files) {
853
+ if (!isComponentFile(rel)) continue;
854
+ const patterns = [
855
+ // Class.register('tag')
856
+ /\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"])([^'"]+)\1/g,
857
+ // customElements.define('tag', Class)
858
+ /\bcustomElements\.define\s*\(\s*(['"])([^'"]+)\1/g,
859
+ ];
860
+ for (const re of patterns) {
861
+ let match;
862
+ while ((match = re.exec(content)) !== null) {
863
+ const tagName = match[2];
864
+ if (!tagName.includes('-')) {
865
+ violations.push({
866
+ rule: 'tag-name-has-hyphen',
867
+ file: rel,
868
+ message: `Custom element tag "${tagName}" must contain a hyphen`,
869
+ fix: `Rename to a hyphenated tag name, e.g. "app-${tagName}" or "${tagName}-element"`,
870
+ });
871
+ }
872
+ }
873
+ }
874
+ }
875
+ }
876
+
877
+ return violations;
878
+ }