@webjsdev/server 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/auth.js CHANGED
@@ -49,8 +49,13 @@ async function unsign(input, secret) {
49
49
  const idx = input.lastIndexOf('.');
50
50
  if (idx < 1) return null;
51
51
  const value = input.slice(0, idx);
52
- const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(input.slice(idx + 1)), enc.encode(value));
53
- return ok ? value : null;
52
+ // `unb64url` -> `atob` throws on non-base64 input. A malformed signature
53
+ // (a corrupted or attacker-supplied cookie) must read as "not signed by
54
+ // us", not crash the request. Mirrors the guard in `decodeJwt`.
55
+ try {
56
+ const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(input.slice(idx + 1)), enc.encode(value));
57
+ return ok ? value : null;
58
+ } catch { return null; }
54
59
  }
55
60
 
56
61
  function randomId() {
package/src/broadcast.js CHANGED
@@ -55,7 +55,11 @@ export function broadcast(path, data, opts) {
55
55
  const msg = typeof data === 'string' ? data : data.toString();
56
56
  for (const ws of clients) {
57
57
  if (opts?.except && ws === opts.except) continue;
58
- if (ws.readyState === 1) ws.send(msg);
58
+ if (ws.readyState !== 1) continue;
59
+ // A socket can die between the readyState check and the send (or send
60
+ // can throw for other reasons). Isolate each send so one dead client
61
+ // cannot abort the fan-out to everyone after it in the set.
62
+ try { ws.send(msg); } catch { /* drop this client's frame; close handler removes it */ }
59
63
  }
60
64
  }
61
65
 
package/src/check.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import { join, relative, sep, basename, dirname } from 'node:path';
3
3
  import { walk } from './fs-walk.js';
4
4
  import {
@@ -10,18 +10,17 @@ import {
10
10
  /**
11
11
  * Convention validator for webjs apps.
12
12
  *
13
- * Scans an app directory and reports deviations from the conventions
14
- * documented in AGENTS.md. Designed to be run by AI agents, CI pipelines,
15
- * or `webjs lint` to catch structural mistakes early.
13
+ * Scans an app directory and reports correctness violations: things that
14
+ * crash the app, leak a secret, or fail the build / type-strip. Designed to be
15
+ * run by AI agents, CI pipelines, or `webjs check` to catch real breakage
16
+ * early. Every rule is unconditional (no per-project disabling): project
17
+ * conventions (layout, style, process) are guidance in CONVENTIONS.md, not
18
+ * rules in this tool.
16
19
  *
17
20
  * **How AI agents should use the output:**
18
21
  * Each violation includes a machine-readable `rule` identifier, the offending
19
22
  * `file` (relative to appDir), a human-readable `message`, and a suggested
20
23
  * `fix`. Agents should iterate the array and apply (or propose) the fixes.
21
- * Rules can be disabled per-project via the
22
- * `"webjs": { "conventions": { … } }` key in `package.json`. That is
23
- * the only supported config surface. If the key is absent, every
24
- * rule defaults to enabled.
25
24
  *
26
25
  * @module check
27
26
  */
@@ -49,16 +48,6 @@ import {
49
48
  * @type {RuleDescriptor[]}
50
49
  */
51
50
  export const RULES = [
52
- {
53
- name: 'actions-in-modules',
54
- description:
55
- '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.',
56
- },
57
- {
58
- name: 'one-function-per-action',
59
- description:
60
- '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.',
61
- },
62
51
  {
63
52
  name: 'components-have-register',
64
53
  description:
@@ -69,11 +58,6 @@ export const RULES = [
69
58
  description:
70
59
  '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.',
71
60
  },
72
- {
73
- name: 'tests-exist',
74
- description:
75
- 'Each modules/<feature>/ directory should have corresponding test files under test/unit/ or test/e2e/.',
76
- },
77
61
  {
78
62
  name: 'tag-name-has-hyphen',
79
63
  description:
@@ -84,11 +68,6 @@ export const RULES = [
84
68
  description:
85
69
  '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.',
86
70
  },
87
- {
88
- name: 'no-json-data-files',
89
- description:
90
- '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.',
91
- },
92
71
  {
93
72
  name: 'shell-in-non-root-layout',
94
73
  description:
@@ -117,7 +96,7 @@ export const RULES = [
117
96
  {
118
97
  name: 'no-browser-globals-in-render',
119
98
  description:
120
- 'Flags browser-only APIs used in a WebComponent constructor or render() method. The SSR pipeline instantiates the component (running the constructor) and calls render() to produce HTML, on a bare server-side class with no DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an HTMLElement instance member on `this` (attachShadow, shadowRoot, setAttribute, getAttribute, removeAttribute, dispatchEvent, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). These belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes, props). Conservative: only the constructor and render bodies are scanned (the methods SSR actually runs), and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
99
+ 'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
121
100
  },
122
101
  ];
123
102
 
@@ -162,105 +141,6 @@ function isComponentFile(relPath) {
162
141
  return segments.includes('components');
163
142
  }
164
143
 
165
- /**
166
- * Public wrapper around `loadOverrides` for callers (CLI, docs tools)
167
- * that want to inspect what's disabled in a project without running
168
- * the full check pipeline.
169
- *
170
- * @param {string} appDir
171
- * @returns {Promise<Record<string, boolean>>}
172
- */
173
- export async function loadConventionOverrides(appDir) {
174
- return loadOverrides(appDir);
175
- }
176
-
177
- /**
178
- * Load overrides from the `"webjs": { "conventions": { … } }` key in
179
- * `package.json`. Returns a map of rule name to boolean (true =
180
- * enabled, false = disabled). Missing rules default to true.
181
- *
182
- * @param {string} appDir
183
- * @returns {Promise<Record<string, boolean>>}
184
- */
185
- async function loadOverrides(appDir) {
186
- try {
187
- const pkgPath = join(appDir, 'package.json');
188
- const pkgText = await readFile(pkgPath, 'utf8');
189
- const pkg = JSON.parse(pkgText);
190
- if (pkg.webjs && typeof pkg.webjs === 'object'
191
- && pkg.webjs.conventions && typeof pkg.webjs.conventions === 'object') {
192
- return pkg.webjs.conventions;
193
- }
194
- } catch {
195
- // No package.json: every rule defaults to enabled.
196
- }
197
- return {};
198
- }
199
-
200
- /**
201
- * Check whether a rule is enabled given the overrides.
202
- * @param {string} ruleName
203
- * @param {Record<string, boolean>} overrides
204
- * @returns {boolean}
205
- */
206
- function isRuleEnabled(ruleName, overrides) {
207
- if (ruleName in overrides) return overrides[ruleName] !== false;
208
- return true;
209
- }
210
-
211
- /**
212
- * Guess a module name from a loose server action file path. Used for the
213
- * `fix` suggestion in `actions-in-modules`.
214
- * @param {string} relPath
215
- * @returns {string}
216
- */
217
- function guessModuleName(relPath) {
218
- const segments = relPath.split(sep);
219
- // Try to infer from the parent directory name
220
- // e.g. app/api/users/create.server.ts -> "users"
221
- for (let i = segments.length - 2; i >= 0; i--) {
222
- const seg = segments[i];
223
- if (seg !== 'app' && seg !== 'api' && !seg.startsWith('[') && !seg.startsWith('(') && !seg.startsWith('_')) {
224
- return seg;
225
- }
226
- }
227
- // Fall back to the file stem
228
- const base = basename(relPath).replace(/\.server\.m?[jt]s$/, '').replace(/\.m?[jt]s$/, '');
229
- return base;
230
- }
231
-
232
- /**
233
- * Count the number of named exported async functions in source text using
234
- * regex heuristics (no AST: intentionally fast and loose).
235
- *
236
- * Looks for patterns like:
237
- * export async function name(...)
238
- * export const name = async (...)
239
- * export const name = async function(...)
240
- * export default async function(...)
241
- *
242
- * @param {string} content
243
- * @returns {number}
244
- */
245
- function countExportedFunctions(content) {
246
- const patterns = [
247
- /export\s+async\s+function\s+\w+/g,
248
- /export\s+const\s+\w+\s*=\s*async\s/g,
249
- /export\s+default\s+async\s+function/g,
250
- /export\s+function\s+\w+/g,
251
- /export\s+const\s+\w+\s*=\s*(?:async\s*)?\(/g,
252
- /export\s+const\s+\w+\s*=\s*(?:async\s*)?function/g,
253
- ];
254
- const seen = new Set();
255
- for (const pat of patterns) {
256
- let m;
257
- while ((m = pat.exec(content)) !== null) {
258
- seen.add(m.index);
259
- }
260
- }
261
- return seen.size;
262
- }
263
-
264
144
  /**
265
145
  * Find every `<key>:` entry inside the first `static properties = { … }`
266
146
  * literal in `classBody`. Returns the bare property names: the keys
@@ -402,11 +282,16 @@ const BROWSER_GLOBALS = [
402
282
  'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
403
283
  'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
404
284
  ];
405
- // HTMLElement instance members that do not exist on the bare server class, so
406
- // `this.<member>` throws (a method call) or is `undefined` (a property) at SSR.
285
+ // HTMLElement instance members that do not exist on the server element shim,
286
+ // so `this.<member>` throws (a method call) or is `undefined` (a property) at
287
+ // SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
288
+ // methods (add/removeEventListener, dispatchEvent), and attachInternals are
289
+ // backed by the shim and run server-side, so they are intentionally NOT
290
+ // flagged: a component may read attributes in render and reflect properties
291
+ // during the SSR update cycle. What stays is the genuinely browser-only
292
+ // surface (DOM querying, layout reads, shadow construction, focus).
407
293
  const HTMLELEMENT_MEMBERS = [
408
- 'attachShadow', 'shadowRoot', 'setAttribute', 'getAttribute',
409
- 'removeAttribute', 'hasAttribute', 'dispatchEvent', 'classList',
294
+ 'attachShadow', 'shadowRoot', 'classList',
410
295
  'querySelector', 'querySelectorAll', 'getBoundingClientRect',
411
296
  'focus', 'blur', 'scrollIntoView',
412
297
  ];
@@ -463,12 +348,13 @@ function findBrowserMemberUses(code) {
463
348
  /**
464
349
  * Scan a webjs app directory and report convention violations.
465
350
  *
466
- * @param {string} appDir - absolute path to the app root (the directory
351
+ * Every rule is a correctness check (a crash, a security leak, or a
352
+ * build/type-strip failure), so they all run unconditionally. There is no
353
+ * per-project disabling: project conventions (layout, style, process) live in
354
+ * CONVENTIONS.md as guidance, not in this tool.
355
+ *
356
+ * @param {string} appDir absolute path to the app root (the directory
467
357
  * containing `app/`, `modules/`, `components/`, etc.)
468
- * @param {{ rules?: Record<string, boolean> }} [opts] - programmatic
469
- * overrides. Merged on top of file-based overrides loaded from
470
- * `package.json` `"webjs"."conventions"`. Set a rule to `false` to
471
- * skip it.
472
358
  * @returns {Promise<Violation[]>}
473
359
  *
474
360
  * @example
@@ -480,38 +366,10 @@ function findBrowserMemberUses(code) {
480
366
  * }
481
367
  * ```
482
368
  */
483
- export async function checkConventions(appDir, opts) {
484
- const fileOverrides = await loadOverrides(appDir);
485
- const overrides = { ...fileOverrides, ...(opts?.rules || {}) };
486
-
369
+ export async function checkConventions(appDir) {
487
370
  /** @type {Violation[]} */
488
371
  const violations = [];
489
372
 
490
- // Determine if modules/ directory exists (small apps exempt from some rules)
491
- let hasModulesDir = false;
492
- try {
493
- const s = await stat(join(appDir, 'modules'));
494
- hasModulesDir = s.isDirectory();
495
- } catch {
496
- // no modules/ dir
497
- }
498
-
499
- // Determine which module feature names exist
500
- /** @type {string[]} */
501
- const moduleNames = [];
502
- if (hasModulesDir) {
503
- try {
504
- const entries = await readdir(join(appDir, 'modules'), { withFileTypes: true });
505
- for (const e of entries) {
506
- if (e.isDirectory() && !e.name.startsWith('.')) {
507
- moduleNames.push(e.name);
508
- }
509
- }
510
- } catch {
511
- // could not read modules/
512
- }
513
- }
514
-
515
373
  // Collect all JS/TS files in the app directory. Each entry carries
516
374
  // both the raw `content` (for rules that need verbatim source: the
517
375
  // `'use server'` directive detector, the `.gitignore` reader, etc.)
@@ -533,56 +391,8 @@ export async function checkConventions(appDir, opts) {
533
391
  files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
534
392
  }
535
393
 
536
- // --- Rule: actions-in-modules ---
537
- if (hasModulesDir && isRuleEnabled('actions-in-modules', overrides)) {
538
- for (const { abs, rel, content } of files) {
539
- if (!isServerActionFile(abs, content)) continue;
540
- const normRel = rel.split(sep).join('/');
541
- // OK: action / query files inside modules/<feature>/{actions,queries}/
542
- if (/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
543
- // OK: module-scoped components/utils (utils may use 'use server' too)
544
- if (/^modules\/[^/]+\/(components|utils)\//.test(normRel)) continue;
545
- // OK: cross-cutting server infrastructure under lib/. The documented
546
- // pattern puts the Prisma singleton, session helpers, auth config,
547
- // password hashing, etc. in lib/: those files are intentionally
548
- // multi-export 'use server' modules, not one-function actions.
549
- if (/^lib\//.test(normRel)) continue;
550
- // Anything else (loose at the root, under app/, etc.) is flagged.
551
- const moduleName = guessModuleName(rel);
552
- const fileBase = basename(rel);
553
- violations.push({
554
- rule: 'actions-in-modules',
555
- file: rel,
556
- message: `Server action should be in modules/${moduleName}/actions/`,
557
- fix: `Move to modules/${moduleName}/actions/${fileBase}`,
558
- });
559
- }
560
- }
561
-
562
- // --- Rule: one-function-per-action ---
563
- // Apply ONLY to files inside modules/<feature>/{actions,queries}/: that
564
- // is where the one-function-per-file convention lives. lib/ infra modules
565
- // and any other 'use server' file outside the action/query dirs are
566
- // intentional multi-export utility modules and are exempt.
567
- if (isRuleEnabled('one-function-per-action', overrides)) {
568
- for (const { abs, rel, content } of files) {
569
- if (!isServerActionFile(abs, content)) continue;
570
- const normRel = rel.split(sep).join('/');
571
- if (!/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
572
- const count = countExportedFunctions(content);
573
- if (count > 1) {
574
- violations.push({
575
- rule: 'one-function-per-action',
576
- file: rel,
577
- message: `Server action file exports ${count} functions; convention is one per file`,
578
- fix: 'Split into separate .server.{js,ts} files, one exported function each',
579
- });
580
- }
581
- }
582
- }
583
-
584
394
  // --- Rule: components-have-register ---
585
- if (isRuleEnabled('components-have-register', overrides)) {
395
+ {
586
396
  for (const { rel, scan } of files) {
587
397
  if (!isComponentFile(rel)) continue;
588
398
  // Use redacted source so a code-example string like
@@ -606,7 +416,7 @@ export async function checkConventions(appDir, opts) {
606
416
  }
607
417
 
608
418
  // --- Rule: reactive-props-use-declare ---
609
- if (isRuleEnabled('reactive-props-use-declare', overrides)) {
419
+ {
610
420
  for (const { rel, scan } of files) {
611
421
  // Use redacted source so test-fixture-style strings like
612
422
  // `class X extends WebComponent { x = 0 }` inside template
@@ -629,15 +439,17 @@ export async function checkConventions(appDir, opts) {
629
439
  }
630
440
 
631
441
  // --- Rule: no-browser-globals-in-render ---
632
- // The SSR pipeline runs the constructor (`new Cls()`) and calls `render()`
633
- // on a bare server-side class with no DOM. A browser global or an
634
- // HTMLElement member on `this` touched there throws at SSR time. Those
635
- // belong in connectedCallback / lifecycle hooks, which SSR never calls.
636
- if (isRuleEnabled('no-browser-globals-in-render', overrides)) {
442
+ // The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
443
+ // render() on the server element shim (attribute methods backed, but no real
444
+ // DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
445
+ // `this` touched in any of those throws at SSR time. Those belong in
446
+ // connectedCallback / post-render hooks, which SSR never calls. willUpdate is
447
+ // scanned because it now runs at SSR (issue #217).
448
+ {
637
449
  for (const { rel, scan } of files) {
638
450
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
639
451
  for (const body of extractWebComponentClassBodies(scan)) {
640
- for (const method of ['constructor', 'render']) {
452
+ for (const method of ['constructor', 'willUpdate', 'render']) {
641
453
  const code = methodBodyOf(body, method);
642
454
  if (!code) continue;
643
455
  for (const { member, kind } of findBrowserMemberUses(code)) {
@@ -658,7 +470,7 @@ export async function checkConventions(appDir, opts) {
658
470
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
659
471
  // two categories to the browser; any other read either leaks a secret
660
472
  // into the SSR'd HTML or reads as undefined after hydration.
661
- if (isRuleEnabled('no-server-env-in-components', overrides)) {
473
+ {
662
474
  for (const { abs, rel, content } of files) {
663
475
  if (!isComponentFile(rel)) continue;
664
476
  if (isServerActionFile(abs, content)) continue;
@@ -682,132 +494,11 @@ export async function checkConventions(appDir, opts) {
682
494
  }
683
495
  }
684
496
 
685
- // --- Rule: tests-exist ---
686
- if (hasModulesDir && isRuleEnabled('tests-exist', overrides)) {
687
- for (const mod of moduleNames) {
688
- // Look for test files that reference this module
689
- let hasTest = false;
690
-
691
- // Check test/unit/ and test/e2e/
692
- for (const testDir of ['test/unit', 'test/e2e', 'test']) {
693
- try {
694
- const testDirAbs = join(appDir, testDir);
695
- for await (const testFile of walk(testDirAbs, (p) => /\.(test|spec)\.m?[jt]sx?$/.test(p))) {
696
- const testRel = relative(appDir, testFile);
697
- // Check if test file name contains the module name
698
- if (testRel.toLowerCase().includes(mod.toLowerCase())) {
699
- hasTest = true;
700
- break;
701
- }
702
- }
703
- } catch {
704
- // test directory doesn't exist
705
- }
706
- if (hasTest) break;
707
- }
708
-
709
- if (!hasTest) {
710
- violations.push({
711
- rule: 'tests-exist',
712
- file: `modules/${mod}`,
713
- message: `No test files found for module "${mod}"`,
714
- fix: `Add test files under test/unit/${mod}.test.js or test/e2e/${mod}.test.js`,
715
- });
716
- }
717
- }
718
- }
719
-
720
- // --- Rule: no-json-data-files ---
721
- // Catch AI agents (or hurried humans) using JSON files as a substitute for
722
- // the real database. Every scaffold ships Prisma + SQLite ready to go, so
723
- // there is never a good reason to invent `data/todos.json`, `db.json`,
724
- // etc. The rule is intentionally narrow: we only flag JSON files that
725
- // *look like* a database: by location (top-level `data/` directory) or by
726
- // name (db/database/store/*-db). Config and read-only seed JSON elsewhere
727
- // is left alone.
728
- if (isRuleEnabled('no-json-data-files', overrides)) {
729
- /** @type {Array<{rel: string, why: string}>} */
730
- const suspects = [];
731
- /**
732
- * @param {string} dir absolute
733
- * @param {string} relBase relative to appDir
734
- */
735
- async function scanDir(dir, relBase) {
736
- /** @type {import('node:fs').Dirent[]} */
737
- let entries;
738
- try {
739
- entries = await readdir(dir, { withFileTypes: true });
740
- } catch {
741
- return;
742
- }
743
- for (const e of entries) {
744
- const name = e.name;
745
- if (name.startsWith('.')) continue;
746
- // Skip directories we know are not the user's data dir.
747
- if (e.isDirectory()) {
748
- if (
749
- name === 'node_modules' ||
750
- name === 'prisma' ||
751
- name === 'dist' ||
752
- name === 'build' ||
753
- name === '.next' ||
754
- name === 'coverage' ||
755
- name === 'public'
756
- ) continue;
757
- await scanDir(join(dir, name), relBase ? `${relBase}/${name}` : name);
758
- continue;
759
- }
760
- if (!e.isFile()) continue;
761
- if (!name.endsWith('.json')) continue;
762
- const rel = relBase ? `${relBase}/${name}` : name;
763
-
764
- // Skip well-known config / tooling JSON.
765
- const configNames = new Set([
766
- 'package.json', 'package-lock.json', 'tsconfig.json',
767
- 'jsconfig.json', 'manifest.json', 'site.webmanifest',
768
- '.eslintrc.json', '.prettierrc.json', 'compose.json',
769
- 'turbo.json', 'lerna.json', 'nx.json', 'biome.json',
770
- 'renovate.json', 'vercel.json', 'now.json', 'fly.json',
771
- ]);
772
- if (configNames.has(name)) continue;
773
-
774
- // Trigger 1: any JSON under a top-level `data/` directory.
775
- if (rel.startsWith('data/')) {
776
- suspects.push({ rel, why: `JSON file in top-level data/ directory (likely a fake database)` });
777
- continue;
778
- }
779
-
780
- // Trigger 2: file name looks like a database.
781
- const lower = name.toLowerCase();
782
- const dbShapedName =
783
- lower === 'db.json' ||
784
- lower === 'database.json' ||
785
- lower === 'store.json' ||
786
- lower === 'storage.json' ||
787
- /-db\.json$/.test(lower) ||
788
- /\.db\.json$/.test(lower);
789
- if (dbShapedName) {
790
- suspects.push({ rel, why: `file name "${name}" suggests it is being used as a database` });
791
- }
792
- }
793
- }
794
- await scanDir(appDir, '');
795
-
796
- for (const s of suspects) {
797
- violations.push({
798
- rule: 'no-json-data-files',
799
- file: s.rel,
800
- message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.server.ts), not JSON files.`,
801
- 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.server.ts'\`. Delete ${s.rel} once the data has moved.`,
802
- });
803
- }
804
- }
805
-
806
497
  // --- Rule: shell-in-non-root-layout ---
807
498
  // Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
808
499
  // framework auto-emits the shell around the whole composition; a nested
809
500
  // shell ends up duplicated and silently dropped by the HTML parser.
810
- if (isRuleEnabled('shell-in-non-root-layout', overrides)) {
501
+ {
811
502
  // Root layout = exactly "app/layout.js" or "app/layout.ts".
812
503
  const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
813
504
  // Any other layout or page under app/ (including pages, nested layouts).
@@ -851,7 +542,7 @@ export async function checkConventions(appDir, opts) {
851
542
  // the editor before they ever hit the dev server. The companion
852
543
  // no-non-erasable-typescript rule (below) catches violations even if
853
544
  // the tsconfig flag is unset.
854
- if (isRuleEnabled('erasable-typescript-only', overrides)) {
545
+ {
855
546
  let tsconfigContent = null;
856
547
  try {
857
548
  tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
@@ -894,7 +585,7 @@ export async function checkConventions(appDir, opts) {
894
585
  // case where the flag is missing OR the user has bypassed it and
895
586
  // written offending syntax anyway. Both rules ship enabled by
896
587
  // default so violators get the strongest signal possible.
897
- if (isRuleEnabled('no-non-erasable-typescript', overrides)) {
588
+ {
898
589
  /** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
899
590
  const NON_ERASABLE_PATTERNS = [
900
591
  {
@@ -985,7 +676,7 @@ export async function checkConventions(appDir, opts) {
985
676
  // directive alone does nothing (the file is served to the browser as
986
677
  // plain source and exports are not registered as RPC), which is a
987
678
  // silent footgun. The fix is mechanical: rename the file.
988
- if (isRuleEnabled('use-server-needs-extension', overrides)) {
679
+ {
989
680
  for (const { rel, content } of files) {
990
681
  if (!hasUseServerDirective(content)) continue;
991
682
  if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
@@ -1002,7 +693,7 @@ export async function checkConventions(appDir, opts) {
1002
693
  }
1003
694
 
1004
695
  // --- Rule: tag-name-has-hyphen ---
1005
- if (isRuleEnabled('tag-name-has-hyphen', overrides)) {
696
+ {
1006
697
  for (const { rel, scan } of files) {
1007
698
  if (!isComponentFile(rel)) continue;
1008
699
  // Use redacted source. A `register('tag')` call inside a
@@ -1048,7 +739,7 @@ export async function checkConventions(appDir, opts) {
1048
739
  //
1049
740
  // Skipped when the directory isn't a git repo or has no .gitignore
1050
741
  // (the user hasn't opted into version control yet).
1051
- if (isRuleEnabled('gitignore-vendor-not-ignored', overrides)) {
742
+ {
1052
743
  const hasGit = await pathExists(join(appDir, '.git'));
1053
744
  const hasGitignore = await pathExists(join(appDir, '.gitignore'));
1054
745
  if (hasGit && hasGitignore) {