@human-synthesis/norns 0.0.8 → 0.0.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/bin/norns.js +33 -0
- package/package.json +2 -2
- package/src/auto-import.js +229 -52
- package/src/diag.js +38 -0
- package/src/lint.js +294 -0
package/bin/norns.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
applyMigrations,
|
|
12
12
|
createMigration
|
|
13
13
|
} from '../src/migrate.js';
|
|
14
|
+
import { nornsLint, printFindings } from '../src/lint.js';
|
|
15
|
+
import { nornsDiag } from '../src/diag.js';
|
|
14
16
|
|
|
15
17
|
const FRAMEWORK_PKGS = ['@human-synthesis/norns-core', '@human-synthesis/norns'];
|
|
16
18
|
|
|
@@ -271,6 +273,29 @@ function openTargetDb(cwd) {
|
|
|
271
273
|
return openSqliteDb(cwd, target.path);
|
|
272
274
|
}
|
|
273
275
|
|
|
276
|
+
function lintCommand() {
|
|
277
|
+
const findings = nornsLint(process.cwd());
|
|
278
|
+
const { errors } = printFindings(findings);
|
|
279
|
+
process.exit(errors > 0 ? 1 : 0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function diagCommand(rest) {
|
|
283
|
+
const file = rest[0];
|
|
284
|
+
if (!file) {
|
|
285
|
+
console.error('Usage: norns diag <file.c | file.civet | file.n>');
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const js = await nornsDiag(file);
|
|
290
|
+
process.stdout.write(js);
|
|
291
|
+
if (!js.endsWith('\n')) process.stdout.write('\n');
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(`norns diag: ${err.message}`);
|
|
294
|
+
if (err.stack) console.error(err.stack);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
274
299
|
const [, , cmd = 'dev', ...rest] = process.argv;
|
|
275
300
|
|
|
276
301
|
switch (cmd) {
|
|
@@ -284,6 +309,12 @@ switch (cmd) {
|
|
|
284
309
|
case 'migrate':
|
|
285
310
|
migrateCommand(rest);
|
|
286
311
|
break;
|
|
312
|
+
case 'lint':
|
|
313
|
+
lintCommand();
|
|
314
|
+
break;
|
|
315
|
+
case 'diag':
|
|
316
|
+
diagCommand(rest);
|
|
317
|
+
break;
|
|
287
318
|
case '-h':
|
|
288
319
|
case '--help':
|
|
289
320
|
console.log(`norns <command>
|
|
@@ -295,6 +326,8 @@ Commands:
|
|
|
295
326
|
migrate status list applied + pending migrations
|
|
296
327
|
migrate up apply pending migrations
|
|
297
328
|
migrate create <feature>/<name> scaffold a new SQL migration
|
|
329
|
+
lint scan .c/.civet/.n + vite.config for known AI pitfalls
|
|
330
|
+
diag <file> print the compiled JS for a .c/.civet/.n file
|
|
298
331
|
|
|
299
332
|
Migration db is read from \$DATABASE_URL (default: file:./data/app.db).
|
|
300
333
|
Only file: (better-sqlite3) is supported in v1; for D1 use \`wrangler d1 migrations apply\`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Norns — SvelteKit with Civet, Pug, and the .n / .civet / .c file extensions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Teodoroiu (https://humansynthesis.ai)",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@danielx/civet": "^0.11.0",
|
|
39
|
-
"@human-synthesis/norns-core": "^0.0.
|
|
39
|
+
"@human-synthesis/norns-core": "^0.0.9"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"node": ">=18"
|
package/src/auto-import.js
CHANGED
|
@@ -6,8 +6,18 @@ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
|
6
6
|
// distinct client / server variants — most importantly `page`, which is
|
|
7
7
|
// exported by both `$app/state` (client) and `@human-synthesis/norns/server`
|
|
8
8
|
// (server) with completely different shapes.
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
//
|
|
10
|
+
// The same predicate is reused (`isServerPath`) to classify project-utility
|
|
11
|
+
// exports: an export from a server-path file is invisible to client importers.
|
|
12
|
+
// That's the structural guarantee that prevents `db` / `bcrypt` / `repo.c`
|
|
13
|
+
// internals from being silently auto-imported into `.n` components and
|
|
14
|
+
// dragged into the client bundle.
|
|
15
|
+
const SERVER_PATH_RE = /(\.server\.|\/server\/|\+server\.|hooks\.server\.)/;
|
|
16
|
+
const NON_SERVER_PATH_RE = /^(?!.*(?:\.server\.|\/server\/|\+server\.|hooks\.server\.))/;
|
|
17
|
+
|
|
18
|
+
function isServerPath(file) {
|
|
19
|
+
return SERVER_PATH_RE.test(file.replace(/\\/g, '/'));
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
const DEFAULT_HELPERS = [
|
|
13
23
|
{
|
|
@@ -216,39 +226,158 @@ function extractExports(source) {
|
|
|
216
226
|
// auto-import a random route's load function. Excluded by basename.
|
|
217
227
|
const ROUTE_FILE_RE = /^(\+|hooks\.)/;
|
|
218
228
|
|
|
229
|
+
// Glob → regex. Supports `**`, `*`, `?`, and brace alternation `{a,b}`.
|
|
230
|
+
// Patterns are matched against project-relative paths (POSIX-style separators).
|
|
231
|
+
//
|
|
232
|
+
// src/lib/**/public.{c,civet} → src/lib/(?:.*/)?public\.(?:c|civet)
|
|
233
|
+
// src/**/store.c → src/(?:.*/)?store\.c
|
|
234
|
+
//
|
|
235
|
+
// Substitutions cascade: an early `**/` → `(?:.*/)?` expansion contains `*`,
|
|
236
|
+
// which a later single-`*` rule would clobber. Every regex expansion is
|
|
237
|
+
// stashed in a multi-char placeholder first; placeholders are swapped for
|
|
238
|
+
// their final regex form once all glob meta has been consumed.
|
|
239
|
+
function compileGlob(pattern) {
|
|
240
|
+
const PH_Q = '__NORNS_GLOB_Q__';
|
|
241
|
+
const PH_AS = '__NORNS_GLOB_AS__';
|
|
242
|
+
const PH_NS = '__NORNS_GLOB_NS__';
|
|
243
|
+
|
|
244
|
+
let p = pattern.replace(/[.+()^$|]/g, '\\$&');
|
|
245
|
+
p = p.replace(/\?/g, PH_Q);
|
|
246
|
+
p = p.replace(/\{([^}]+)\}/g, (_, inner) =>
|
|
247
|
+
'(?:' +
|
|
248
|
+
inner
|
|
249
|
+
.split(',')
|
|
250
|
+
.map((s) => s.trim().replace(/[.+()^$|?]/g, '\\$&'))
|
|
251
|
+
.join('|') +
|
|
252
|
+
')'
|
|
253
|
+
);
|
|
254
|
+
p = p.replace(/\*\*\//g, `(?:${PH_AS}/)?`);
|
|
255
|
+
p = p.replace(/\/\*\*/g, `(?:/${PH_AS})?`);
|
|
256
|
+
p = p.replace(/\*\*/g, PH_AS);
|
|
257
|
+
p = p.replace(/\*/g, PH_NS);
|
|
258
|
+
p = p.replace(new RegExp(PH_AS, 'g'), '.*');
|
|
259
|
+
p = p.replace(new RegExp(PH_NS, 'g'), '[^/]*');
|
|
260
|
+
p = p.replace(new RegExp(PH_Q, 'g'), '[^/]');
|
|
261
|
+
return new RegExp('^' + p + '$');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const EXPORT_WALK_SKIP = new Set([
|
|
265
|
+
'node_modules',
|
|
266
|
+
'.svelte-kit',
|
|
267
|
+
'.git',
|
|
268
|
+
'build',
|
|
269
|
+
'dist',
|
|
270
|
+
'.cache',
|
|
271
|
+
'.turbo'
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
/** Walk `root` and return absolute paths of files matching any of `globs`. */
|
|
275
|
+
function walkGlobs(root, globs) {
|
|
276
|
+
if (globs.length === 0) return [];
|
|
277
|
+
const regexes = globs.map(compileGlob);
|
|
278
|
+
/** @type {string[]} */
|
|
279
|
+
const out = [];
|
|
280
|
+
const stack = [root];
|
|
281
|
+
while (stack.length > 0) {
|
|
282
|
+
const cur = /** @type {string} */ (stack.pop());
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
291
|
+
if (EXPORT_WALK_SKIP.has(entry.name)) continue;
|
|
292
|
+
const full = join(cur, entry.name);
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
stack.push(full);
|
|
295
|
+
} else if (entry.isFile()) {
|
|
296
|
+
const rel = relative(root, full).replace(/\\/g, '/');
|
|
297
|
+
if (regexes.some((re) => re.test(rel))) out.push(full);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
219
304
|
/**
|
|
220
|
-
* Walk `
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
305
|
+
* Walk files matching `globs` and build a name → candidate map.
|
|
306
|
+
*
|
|
307
|
+
* Each candidate carries its `isServer` classification. Resolution at
|
|
308
|
+
* import time picks the right candidate based on the importer's scope.
|
|
309
|
+
* SvelteKit route/hook files are excluded by basename so framework-consumed
|
|
310
|
+
* exports (`load`, `actions`, `handle`) don't enter the map.
|
|
225
311
|
*
|
|
226
312
|
* @param {string} root
|
|
227
|
-
* @param {string[]}
|
|
228
|
-
* @param {string[]} exts
|
|
229
|
-
* @returns {Map<string, string
|
|
313
|
+
* @param {string[]} globs project-relative glob patterns
|
|
314
|
+
* @param {string[]} exts file extensions accepted (defence-in-depth)
|
|
315
|
+
* @returns {Map<string, Array<{ file: string; isServer: boolean }>>}
|
|
230
316
|
*/
|
|
231
|
-
function buildExportMap(root,
|
|
232
|
-
/** @type {Map<string, string
|
|
317
|
+
function buildExportMap(root, globs, exts) {
|
|
318
|
+
/** @type {Map<string, Array<{ file: string; isServer: boolean }>>} */
|
|
233
319
|
const map = new Map();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
320
|
+
const files = walkGlobs(root, globs);
|
|
321
|
+
for (const file of files) {
|
|
322
|
+
if (ROUTE_FILE_RE.test(basename(file))) continue;
|
|
323
|
+
if (!exts.includes(extname(file))) continue;
|
|
324
|
+
let source;
|
|
325
|
+
try {
|
|
326
|
+
source = readFileSync(file, 'utf8');
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const rel = relative(root, file).replace(/\\/g, '/');
|
|
331
|
+
const isServer = isServerPath(rel);
|
|
332
|
+
for (const name of extractExports(source)) {
|
|
333
|
+
let list = map.get(name);
|
|
334
|
+
if (!list) {
|
|
335
|
+
list = [];
|
|
336
|
+
map.set(name, list);
|
|
246
337
|
}
|
|
338
|
+
list.push({ file, isServer });
|
|
247
339
|
}
|
|
248
340
|
}
|
|
249
341
|
return map;
|
|
250
342
|
}
|
|
251
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Collapse a raw candidate map into one server-scope and one client-scope
|
|
346
|
+
* candidate per name. If a single scope has multiple candidates, that's a
|
|
347
|
+
* conflict — log it and exclude that scope. Mixed scopes (one server + one
|
|
348
|
+
* client) are kept and resolved dynamically by importer scope.
|
|
349
|
+
*
|
|
350
|
+
* @param {Map<string, Array<{ file: string; isServer: boolean }>>} raw
|
|
351
|
+
* @param {(msg: string) => void} [log]
|
|
352
|
+
* @returns {Map<string, { server?: string; client?: string }>}
|
|
353
|
+
*/
|
|
354
|
+
function resolveExportConflicts(raw, log = console.warn) {
|
|
355
|
+
/** @type {Map<string, { server?: string; client?: string }>} */
|
|
356
|
+
const out = new Map();
|
|
357
|
+
for (const [name, candidates] of raw) {
|
|
358
|
+
const servers = candidates.filter((c) => c.isServer);
|
|
359
|
+
const clients = candidates.filter((c) => !c.isServer);
|
|
360
|
+
/** @type {{ server?: string; client?: string }} */
|
|
361
|
+
const entry = {};
|
|
362
|
+
if (servers.length === 1) entry.server = servers[0].file;
|
|
363
|
+
else if (servers.length > 1) {
|
|
364
|
+
log(
|
|
365
|
+
`[norns-auto-import] conflict: \`${name}\` exported from multiple server files — not auto-imported, use explicit imports:`
|
|
366
|
+
);
|
|
367
|
+
for (const c of servers) log(` - ${c.file}`);
|
|
368
|
+
}
|
|
369
|
+
if (clients.length === 1) entry.client = clients[0].file;
|
|
370
|
+
else if (clients.length > 1) {
|
|
371
|
+
log(
|
|
372
|
+
`[norns-auto-import] conflict: \`${name}\` exported from multiple client files — not auto-imported, use explicit imports:`
|
|
373
|
+
);
|
|
374
|
+
for (const c of clients) log(` - ${c.file}`);
|
|
375
|
+
}
|
|
376
|
+
if (entry.server || entry.client) out.set(name, entry);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
252
381
|
/**
|
|
253
382
|
* Resolve the import specifier for a project-utility file. Same path logic
|
|
254
383
|
* as `resolveComponentPath`, but strips the file extension so imports use
|
|
@@ -347,8 +476,8 @@ function collectDeclared(script) {
|
|
|
347
476
|
* @param {string} [filename]
|
|
348
477
|
* @param {{ root?: string, libRoot?: string, libAlias?: string }} [ctx]
|
|
349
478
|
* @param {Record<string, string> | null} [componentSpecs] name → bare specifier (from user `components` map). Resolved AFTER the dir-scan map so user folders override silently.
|
|
350
|
-
* @param {Map<string, string> | null} [exports]
|
|
351
|
-
* @returns {Array<{ name: string, from: string, kind: 'named' | 'default' }>}
|
|
479
|
+
* @param {Map<string, { server?: string; client?: string }> | null} [exports] name → scoped candidate map. Resolved LAST and gated by importer scope (`isServerPath`).
|
|
480
|
+
* @returns {Array<{ name: string, from: string, kind: 'named' | 'default', annotate?: boolean }>}
|
|
352
481
|
*/
|
|
353
482
|
function computeImports(
|
|
354
483
|
referenced,
|
|
@@ -404,16 +533,24 @@ function computeImports(
|
|
|
404
533
|
}
|
|
405
534
|
}
|
|
406
535
|
|
|
407
|
-
// 4. Project-utility named exports — `notes` from `$lib/notes/public`,
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
536
|
+
// 4. Project-utility named exports — `notes` from `$lib/notes/public`, etc.
|
|
537
|
+
// Importer scope decides which candidate is allowed:
|
|
538
|
+
// - Server importer → server candidate preferred, falls back to client.
|
|
539
|
+
// - Client importer → ONLY a client-safe candidate; server-only exports
|
|
540
|
+
// are invisible (prevents bundling server code into the client).
|
|
541
|
+
// Auto-injected imports get an `annotate` flag so `renderImports` can mark
|
|
542
|
+
// them with a `// auto-import` comment.
|
|
411
543
|
if (exports) {
|
|
412
|
-
|
|
544
|
+
const importerIsServer = isServerPath(filename);
|
|
545
|
+
for (const [name, scoped] of exports) {
|
|
413
546
|
if (!wants(name)) continue;
|
|
414
|
-
const
|
|
547
|
+
const chosenFile = importerIsServer
|
|
548
|
+
? (scoped.server ?? scoped.client)
|
|
549
|
+
: scoped.client;
|
|
550
|
+
if (!chosenFile) continue;
|
|
551
|
+
const from = resolveExportPath(chosenFile, filename, root, libRoot, libAlias);
|
|
415
552
|
if (from) {
|
|
416
|
-
out.push({ name, from, kind: 'named' });
|
|
553
|
+
out.push({ name, from, kind: 'named', annotate: true });
|
|
417
554
|
added.add(name);
|
|
418
555
|
}
|
|
419
556
|
}
|
|
@@ -423,27 +560,29 @@ function computeImports(
|
|
|
423
560
|
}
|
|
424
561
|
|
|
425
562
|
/**
|
|
426
|
-
* @param {Array<{ name: string, from: string, kind: 'named' | 'default' }>} entries
|
|
563
|
+
* @param {Array<{ name: string, from: string, kind: 'named' | 'default', annotate?: boolean }>} entries
|
|
427
564
|
* @returns {string}
|
|
428
565
|
*/
|
|
429
566
|
function renderImports(entries) {
|
|
430
|
-
/** @type {Map<string, { default: string | null, named: string[] }>} */
|
|
567
|
+
/** @type {Map<string, { default: string | null, named: string[], annotate: boolean }>} */
|
|
431
568
|
const byFrom = new Map();
|
|
432
|
-
for (const { name, from, kind } of entries) {
|
|
569
|
+
for (const { name, from, kind, annotate } of entries) {
|
|
433
570
|
let g = byFrom.get(from);
|
|
434
571
|
if (!g) {
|
|
435
|
-
g = { default: null, named: [] };
|
|
572
|
+
g = { default: null, named: [], annotate: false };
|
|
436
573
|
byFrom.set(from, g);
|
|
437
574
|
}
|
|
438
575
|
if (kind === 'default') g.default = name;
|
|
439
576
|
else g.named.push(name);
|
|
577
|
+
if (annotate) g.annotate = true;
|
|
440
578
|
}
|
|
441
579
|
const lines = [];
|
|
442
|
-
for (const [from, { default: def, named }] of byFrom) {
|
|
580
|
+
for (const [from, { default: def, named, annotate }] of byFrom) {
|
|
443
581
|
const parts = [];
|
|
444
582
|
if (def) parts.push(def);
|
|
445
583
|
if (named.length > 0) parts.push(`{ ${named.join(', ')} }`);
|
|
446
|
-
|
|
584
|
+
const stmt = `import ${parts.join(', ')} from '${from}';`;
|
|
585
|
+
lines.push(annotate ? `${stmt} // auto-import` : stmt);
|
|
447
586
|
}
|
|
448
587
|
return lines.join('\n');
|
|
449
588
|
}
|
|
@@ -500,19 +639,36 @@ function renderImports(entries) {
|
|
|
500
639
|
* the library's `Btn` silently (first-match-wins). The string is used as
|
|
501
640
|
* the import source verbatim — no `$lib` aliasing or relative-path
|
|
502
641
|
* computation.
|
|
642
|
+
* @param {string[] | false} [options.exportGlobs]
|
|
643
|
+
* Glob patterns (project-relative, POSIX separators) matched against files
|
|
644
|
+
* to scan for named-value exports. The recommended convention is barrel-file
|
|
645
|
+
* scope — `['src/lib/**\/public.c']` exposes only each feature's intentional
|
|
646
|
+
* API surface and leaves repo/service/module internals invisible to
|
|
647
|
+
* auto-import. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
648
|
+
*
|
|
649
|
+
* Path-based safety is enforced at resolution time: a file under
|
|
650
|
+
* `/server/` / `*.server.*` / `+server.*` / `hooks.server.*` is classified
|
|
651
|
+
* server-only and is NEVER auto-imported into a client (non-server) file.
|
|
652
|
+
* Name collisions inside the same scope are detected at startup, logged,
|
|
653
|
+
* and excluded from auto-import — forcing an explicit import to disambiguate.
|
|
654
|
+
*
|
|
655
|
+
* Default `[]` (off; explicit imports for service-layer code).
|
|
503
656
|
* @param {string[] | false} [options.exportDirs]
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
657
|
+
* DEPRECATED. Equivalent to `exportGlobs: dirs.map(d => '${d}/**\/*.{c,civet,js}')`,
|
|
658
|
+
* which scans every file under those dirs. Path-scoping is still applied,
|
|
659
|
+
* so server exports won't leak into client files — but the broad scan
|
|
660
|
+
* surfaces every internal export as a potential auto-import. Migrate to
|
|
661
|
+
* `exportGlobs: ['src/lib/**\/public.c']` for an intentional API surface.
|
|
509
662
|
* @param {string[]} [options.exportExtensions]
|
|
510
|
-
* File extensions
|
|
511
|
-
*
|
|
512
|
-
* distinguish value
|
|
663
|
+
* File extensions accepted for exports (defence-in-depth on top of the
|
|
664
|
+
* glob). Default `['.c', '.civet', '.js']` — `.ts` excluded because
|
|
665
|
+
* regex-scanned `.ts` can't reliably distinguish value vs type-only exports.
|
|
513
666
|
* @param {string} [options.libRoot] Default `'src/lib'`.
|
|
514
667
|
* @param {string} [options.libAlias] Default `'$lib'`.
|
|
515
668
|
* @param {string} [options.root] Default `process.cwd()`.
|
|
669
|
+
* @param {(msg: string) => void} [options.log]
|
|
670
|
+
* Channel for conflict / deprecation warnings. Default `console.warn`.
|
|
671
|
+
* Tests pass a stub to assert behavior without polluting output.
|
|
516
672
|
*/
|
|
517
673
|
export function nornsAutoImport(options = {}) {
|
|
518
674
|
const root = options.root ?? process.cwd();
|
|
@@ -520,19 +676,37 @@ export function nornsAutoImport(options = {}) {
|
|
|
520
676
|
const componentDirs =
|
|
521
677
|
options.componentDirs === false ? [] : (options.componentDirs ?? DEFAULT_COMPONENT_DIRS);
|
|
522
678
|
const componentExts = options.componentExtensions ?? DEFAULT_COMPONENT_EXTS;
|
|
523
|
-
const exportDirs =
|
|
524
|
-
options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
|
|
525
679
|
const exportExts = options.exportExtensions ?? DEFAULT_EXPORT_EXTS;
|
|
526
680
|
const libRoot = options.libRoot ?? DEFAULT_LIB_ROOT;
|
|
527
681
|
const libAlias = options.libAlias ?? DEFAULT_LIB_ALIAS;
|
|
528
682
|
const componentSpecs = options.components ?? null;
|
|
683
|
+
const log = options.log ?? console.warn;
|
|
684
|
+
|
|
685
|
+
// Build the effective glob list. `exportGlobs` is the new API;
|
|
686
|
+
// `exportDirs` is shimmed in for backward compatibility with a one-time
|
|
687
|
+
// deprecation notice per init.
|
|
688
|
+
const explicitGlobs =
|
|
689
|
+
options.exportGlobs === false || options.exportGlobs == null ? [] : options.exportGlobs;
|
|
690
|
+
const legacyDirs =
|
|
691
|
+
options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
|
|
692
|
+
let effectiveGlobs = [...explicitGlobs];
|
|
693
|
+
if (legacyDirs.length > 0) {
|
|
694
|
+
log(
|
|
695
|
+
'[norns-auto-import] `exportDirs` is deprecated. Migrate to `exportGlobs`, e.g. ' +
|
|
696
|
+
"`exportGlobs: ['src/lib/**/public.c']` — barrel-file scope is the safe default."
|
|
697
|
+
);
|
|
698
|
+
effectiveGlobs = effectiveGlobs.concat(
|
|
699
|
+
legacyDirs.map((d) => `${d.replace(/\\/g, '/').replace(/\/+$/, '')}/**/*.{c,civet,js}`)
|
|
700
|
+
);
|
|
701
|
+
}
|
|
529
702
|
|
|
530
703
|
const components =
|
|
531
704
|
componentDirs.length === 0
|
|
532
705
|
? new Map()
|
|
533
706
|
: buildComponentMap(root, componentDirs, componentExts);
|
|
534
|
-
const
|
|
535
|
-
|
|
707
|
+
const rawExports =
|
|
708
|
+
effectiveGlobs.length === 0 ? null : buildExportMap(root, effectiveGlobs, exportExts);
|
|
709
|
+
const exportsMap = rawExports ? resolveExportConflicts(rawExports, log) : null;
|
|
536
710
|
const componentCtx = { root, libRoot, libAlias };
|
|
537
711
|
|
|
538
712
|
/** @type {Map<string, string>} */
|
|
@@ -642,7 +816,10 @@ export {
|
|
|
642
816
|
buildExportMap as _buildExportMap,
|
|
643
817
|
collectDeclared as _collectDeclared,
|
|
644
818
|
collectIdentifiers as _collectIdentifiers,
|
|
819
|
+
compileGlob as _compileGlob,
|
|
645
820
|
computeImports as _computeImports,
|
|
646
821
|
extractExports as _extractExports,
|
|
647
|
-
|
|
822
|
+
isServerPath as _isServerPath,
|
|
823
|
+
renderImports as _renderImports,
|
|
824
|
+
resolveExportConflicts as _resolveExportConflicts
|
|
648
825
|
};
|
package/src/diag.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { compile as compileCivet } from '@danielx/civet';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compile a `.c` / `.civet` file (or the `<script lang="civet">` block of a
|
|
7
|
+
* `.n` / `.svelte` file) to plain JS so callers can inspect what Civet
|
|
8
|
+
* actually produced. The diagnosis recipe is:
|
|
9
|
+
*
|
|
10
|
+
* bun norns diag path/to/file.c
|
|
11
|
+
*
|
|
12
|
+
* Use it when a Civet error message is unhelpful — the compiled output
|
|
13
|
+
* proves whether the source is correct and the bug is downstream.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} file path (relative or absolute)
|
|
16
|
+
* @returns {Promise<string>} compiled JS
|
|
17
|
+
*/
|
|
18
|
+
export async function nornsDiag(file) {
|
|
19
|
+
const abs = resolve(file);
|
|
20
|
+
if (!existsSync(abs)) throw new Error(`No such file: ${file}`);
|
|
21
|
+
|
|
22
|
+
const content = readFileSync(abs, 'utf8');
|
|
23
|
+
let source = content;
|
|
24
|
+
|
|
25
|
+
if (abs.endsWith('.n') || abs.endsWith('.svelte')) {
|
|
26
|
+
const m = content.match(/<script\b[^>]*>([\s\S]*?)<\/script>/i);
|
|
27
|
+
if (!m) throw new Error(`No <script> block in ${file}`);
|
|
28
|
+
source = m[1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await compileCivet(source, {
|
|
32
|
+
js: true,
|
|
33
|
+
filename: abs
|
|
34
|
+
});
|
|
35
|
+
// Civet returns a plain string when no sourceMap option is supplied,
|
|
36
|
+
// otherwise an object with `.code`. Handle both.
|
|
37
|
+
return typeof result === 'string' ? result : result.code;
|
|
38
|
+
}
|
package/src/lint.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'.svelte-kit',
|
|
7
|
+
'.git',
|
|
8
|
+
'build',
|
|
9
|
+
'dist',
|
|
10
|
+
'static',
|
|
11
|
+
'.next',
|
|
12
|
+
'.cache',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'data',
|
|
15
|
+
'coverage'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ file: string; line: number; severity: 'error' | 'warning'; rule: string; msg: string }} Finding
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function walk(dir, filter, out = []) {
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
} catch {
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
34
|
+
walk(full, filter, out);
|
|
35
|
+
} else if (entry.isFile() && filter(entry.name)) {
|
|
36
|
+
out.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove string and template literals from a line so regexes don't match inside them. */
|
|
43
|
+
function stripStrings(line) {
|
|
44
|
+
let out = '';
|
|
45
|
+
let mode = 0; // 0=code, 1=', 2=", 3=`
|
|
46
|
+
for (let i = 0; i < line.length; i++) {
|
|
47
|
+
const c = line[i];
|
|
48
|
+
const prev = line[i - 1];
|
|
49
|
+
if (mode === 0) {
|
|
50
|
+
if (c === "'") mode = 1;
|
|
51
|
+
else if (c === '"') mode = 2;
|
|
52
|
+
else if (c === '`') mode = 3;
|
|
53
|
+
else out += c;
|
|
54
|
+
} else if (mode === 1 && c === "'" && prev !== '\\') mode = 0;
|
|
55
|
+
else if (mode === 2 && c === '"' && prev !== '\\') mode = 0;
|
|
56
|
+
else if (mode === 3 && c === '`' && prev !== '\\') mode = 0;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} file
|
|
63
|
+
* @param {string} content
|
|
64
|
+
* @returns {Finding[]}
|
|
65
|
+
*/
|
|
66
|
+
function lintCivetFile(file, content) {
|
|
67
|
+
/** @type {Finding[]} */
|
|
68
|
+
const out = [];
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const ln = lines[i];
|
|
73
|
+
const lineNo = i + 1;
|
|
74
|
+
const trimmed = ln.trim();
|
|
75
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
|
|
76
|
+
|
|
77
|
+
const codeOnly = stripStrings(ln);
|
|
78
|
+
|
|
79
|
+
// `isnt` compiles to an undefined identifier reference at runtime.
|
|
80
|
+
if (/\bisnt\b/.test(codeOnly)) {
|
|
81
|
+
out.push({
|
|
82
|
+
file,
|
|
83
|
+
line: lineNo,
|
|
84
|
+
severity: 'error',
|
|
85
|
+
rule: 'civet/no-isnt',
|
|
86
|
+
msg: '`isnt` compiles to a bare identifier reference. Use `!==`.'
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `async *name(` as class method shorthand — Civet parser rejects it.
|
|
91
|
+
// Match indented lines (likely inside a class) where the next token after
|
|
92
|
+
// `async *` is an identifier followed by `(`.
|
|
93
|
+
if (/^\s+async\s*\*\s*\w+\s*\(/.test(ln)) {
|
|
94
|
+
out.push({
|
|
95
|
+
file,
|
|
96
|
+
line: lineNo,
|
|
97
|
+
severity: 'error',
|
|
98
|
+
rule: 'civet/no-async-generator-method',
|
|
99
|
+
msg: 'Civet rejects `async *name()` as class method shorthand. Use a callback API or top-level `async function*`.'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `:= $state` (const) then later reassignment of the same name.
|
|
104
|
+
const stateConst = codeOnly.match(/(?:^|[\s,({[])(\w+)\s*:=\s*\$state\b/);
|
|
105
|
+
if (stateConst) {
|
|
106
|
+
const name = stateConst[1];
|
|
107
|
+
const reassignRe = new RegExp(`^\\s*${name}\\s*=(?!=|>)`);
|
|
108
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
109
|
+
if (reassignRe.test(lines[j])) {
|
|
110
|
+
out.push({
|
|
111
|
+
file,
|
|
112
|
+
line: lineNo,
|
|
113
|
+
severity: 'error',
|
|
114
|
+
rule: 'civet/state-const-reassign',
|
|
115
|
+
msg: `\`${name}\` uses \`:=\` ($state const) but is reassigned at line ${j + 1}. Use \`.=\` for $state values you reassign.`
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} file
|
|
129
|
+
* @param {string} content
|
|
130
|
+
* @returns {Finding[]}
|
|
131
|
+
*/
|
|
132
|
+
function lintNornFile(file, content) {
|
|
133
|
+
/** @type {Finding[]} */
|
|
134
|
+
const out = [];
|
|
135
|
+
|
|
136
|
+
// Identify <script> / <style> ranges so we lint only template lines.
|
|
137
|
+
const blockRanges = [];
|
|
138
|
+
const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
139
|
+
let m;
|
|
140
|
+
while ((m = blockRe.exec(content)) !== null) {
|
|
141
|
+
blockRanges.push([m.index, m.index + m[0].length]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Map line index → starting offset
|
|
145
|
+
const lineStart = [0];
|
|
146
|
+
for (let i = 0; i < content.length; i++) {
|
|
147
|
+
if (content[i] === '\n') lineStart.push(i + 1);
|
|
148
|
+
}
|
|
149
|
+
const inBlock = (lineNo) => {
|
|
150
|
+
const s = lineStart[lineNo - 1];
|
|
151
|
+
return blockRanges.some(([a, b]) => s >= a && s < b);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const ln = lines[i];
|
|
157
|
+
const lineNo = i + 1;
|
|
158
|
+
if (inBlock(lineNo)) continue;
|
|
159
|
+
const trimmed = ln.trim();
|
|
160
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
161
|
+
|
|
162
|
+
// `{@html ...}` / `{#each}` etc. at start of pug line without `| ` prefix.
|
|
163
|
+
if (/^\s*\{[@#:/]/.test(ln)) {
|
|
164
|
+
out.push({
|
|
165
|
+
file,
|
|
166
|
+
line: lineNo,
|
|
167
|
+
severity: 'error',
|
|
168
|
+
rule: 'pug/svelte-block-needs-pipe',
|
|
169
|
+
msg: 'Leading `{` is parsed by Pug as a tag. Prefix with `| ` to emit as text.'
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `#{expr}` Pug interpolation — evaluates at preprocess time, not runtime.
|
|
174
|
+
// Allow `\#{` escaped form.
|
|
175
|
+
if (/(^|[^\\])#\{/.test(ln)) {
|
|
176
|
+
out.push({
|
|
177
|
+
file,
|
|
178
|
+
line: lineNo,
|
|
179
|
+
severity: 'error',
|
|
180
|
+
rule: 'pug/no-pug-interpolation',
|
|
181
|
+
msg: 'Pug `#{expr}` evaluates at preprocess time. Use Svelte `{expr}` for runtime data.'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} file
|
|
192
|
+
* @param {string} content
|
|
193
|
+
* @returns {Finding[]}
|
|
194
|
+
*/
|
|
195
|
+
function lintViteConfig(file, content) {
|
|
196
|
+
/** @type {Finding[]} */
|
|
197
|
+
const out = [];
|
|
198
|
+
if (!/allowedHosts\s*:\s*(true|\[)/.test(content)) {
|
|
199
|
+
out.push({
|
|
200
|
+
file,
|
|
201
|
+
line: 1,
|
|
202
|
+
severity: 'warning',
|
|
203
|
+
rule: 'vite/allowed-hosts',
|
|
204
|
+
msg: 'Set `server.allowedHosts: true` (or an explicit list) so Vite accepts reverse-proxied Host headers in dev.'
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} cwd
|
|
212
|
+
* @returns {Finding[]}
|
|
213
|
+
*/
|
|
214
|
+
export function nornsLint(cwd) {
|
|
215
|
+
/** @type {Finding[]} */
|
|
216
|
+
const findings = [];
|
|
217
|
+
|
|
218
|
+
const srcDir = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
|
|
219
|
+
const civetFiles = walk(
|
|
220
|
+
srcDir,
|
|
221
|
+
(n) => n.endsWith('.c') || n.endsWith('.civet')
|
|
222
|
+
);
|
|
223
|
+
const nornFiles = walk(srcDir, (n) => n.endsWith('.n'));
|
|
224
|
+
|
|
225
|
+
for (const f of civetFiles) {
|
|
226
|
+
try {
|
|
227
|
+
findings.push(...lintCivetFile(f, readFileSync(f, 'utf8')));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
findings.push({
|
|
230
|
+
file: f,
|
|
231
|
+
line: 1,
|
|
232
|
+
severity: 'warning',
|
|
233
|
+
rule: 'lint/read-error',
|
|
234
|
+
msg: `Could not read: ${e.message}`
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const f of nornFiles) {
|
|
239
|
+
try {
|
|
240
|
+
findings.push(...lintNornFile(f, readFileSync(f, 'utf8')));
|
|
241
|
+
} catch (e) {
|
|
242
|
+
findings.push({
|
|
243
|
+
file: f,
|
|
244
|
+
line: 1,
|
|
245
|
+
severity: 'warning',
|
|
246
|
+
rule: 'lint/read-error',
|
|
247
|
+
msg: `Could not read: ${e.message}`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const viteCfg = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']
|
|
253
|
+
.map((n) => join(cwd, n))
|
|
254
|
+
.find(existsSync);
|
|
255
|
+
if (viteCfg) {
|
|
256
|
+
findings.push(...lintViteConfig(viteCfg, readFileSync(viteCfg, 'utf8')));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return findings.map((f) => ({ ...f, file: relative(cwd, f.file) }));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Pretty-print findings. Returns the number of errors.
|
|
264
|
+
* @param {Finding[]} findings
|
|
265
|
+
* @returns {{ errors: number; warnings: number }}
|
|
266
|
+
*/
|
|
267
|
+
export function printFindings(findings) {
|
|
268
|
+
let errors = 0;
|
|
269
|
+
let warnings = 0;
|
|
270
|
+
if (findings.length === 0) {
|
|
271
|
+
console.log('norns lint: no issues found.');
|
|
272
|
+
return { errors: 0, warnings: 0 };
|
|
273
|
+
}
|
|
274
|
+
// Group by file for readability.
|
|
275
|
+
/** @type {Map<string, Finding[]>} */
|
|
276
|
+
const byFile = new Map();
|
|
277
|
+
for (const f of findings) {
|
|
278
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
279
|
+
byFile.get(f.file).push(f);
|
|
280
|
+
}
|
|
281
|
+
for (const [file, items] of byFile) {
|
|
282
|
+
console.log(`\n${file}`);
|
|
283
|
+
for (const it of items) {
|
|
284
|
+
const tag = it.severity === 'error' ? 'error' : 'warn ';
|
|
285
|
+
if (it.severity === 'error') errors++;
|
|
286
|
+
else warnings++;
|
|
287
|
+
console.log(` ${it.line.toString().padStart(4)} ${tag} ${it.rule} ${it.msg}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
console.log(
|
|
291
|
+
`\nnorns lint: ${errors} error(s), ${warnings} warning(s) across ${byFile.size} file(s).`
|
|
292
|
+
);
|
|
293
|
+
return { errors, warnings };
|
|
294
|
+
}
|