@human-synthesis/norns 0.0.16 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +224 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +587 -0
- package/src/kernel/emit-wrangler.js +114 -0
- package/src/kernel/expr-compile.js +188 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/generate.js +397 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +71 -0
- package/src/kernel/meta.js +237 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +199 -0
- package/src/kernel/trace.js +277 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +72 -0
- package/src/server/boot.js +61 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +61 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +12 -1
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +162 -0
- package/src/server/storage.js +97 -0
package/bin/norns.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { watch, realpathSync, readFileSync, lstatSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { watch, realpathSync, readFileSync, lstatSync, readdirSync, rmSync, existsSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import {
|
|
@@ -100,7 +100,31 @@ function findViteBin(root) {
|
|
|
100
100
|
return join(dirname(pkgPath), binEntry);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Spec-first regeneration hook. Returns null when the app has no `specs/`
|
|
105
|
+
* dir; otherwise a closure that (re)generates `.norns/generated/` and
|
|
106
|
+
* reports refusals without throwing.
|
|
107
|
+
*/
|
|
108
|
+
async function makeSpecRegen(cwd) {
|
|
109
|
+
if (!existsSync(join(cwd, 'specs'))) return null;
|
|
110
|
+
const { generateApp } = await import('../src/kernel/index.js');
|
|
111
|
+
return () => {
|
|
112
|
+
try {
|
|
113
|
+
const result = generateApp(join(cwd, 'specs'));
|
|
114
|
+
if (result.written.length > 0) {
|
|
115
|
+
console.log(
|
|
116
|
+
`[norns] generated ${result.written.length} file(s) (spec ${result.version.slice(0, 12)})`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(`[norns] generate refused:\n${err.message}`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function devCommand(passthrough) {
|
|
104
128
|
const cwd = process.cwd();
|
|
105
129
|
const cleaned = cleanShadowedFrameworkPkgs(cwd);
|
|
106
130
|
if (cleaned.length > 0) {
|
|
@@ -109,6 +133,25 @@ function devCommand(passthrough) {
|
|
|
109
133
|
`— the workspace symlinks at the parent will be used instead.`
|
|
110
134
|
);
|
|
111
135
|
}
|
|
136
|
+
|
|
137
|
+
const regen = await makeSpecRegen(cwd);
|
|
138
|
+
if (regen) {
|
|
139
|
+
regen();
|
|
140
|
+
const specsDir = join(cwd, 'specs');
|
|
141
|
+
let specDebounce = null;
|
|
142
|
+
try {
|
|
143
|
+
watch(specsDir, { recursive: true }, () => {
|
|
144
|
+
clearTimeout(specDebounce);
|
|
145
|
+
// Vite HMR picks up the rewritten files in .norns/generated/ itself;
|
|
146
|
+
// a failed regenerate keeps serving the last good tree.
|
|
147
|
+
specDebounce = setTimeout(() => regen(), 100);
|
|
148
|
+
});
|
|
149
|
+
console.log(`[norns] watching specs: ${specsDir}`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.warn(`[norns] could not watch ${specsDir}: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
112
155
|
const viteBin = findViteBin(cwd);
|
|
113
156
|
const watchSrcs = resolveWorkspaceFrameworkSrcs(cwd);
|
|
114
157
|
|
|
@@ -177,8 +220,14 @@ function devCommand(passthrough) {
|
|
|
177
220
|
spawnVite();
|
|
178
221
|
}
|
|
179
222
|
|
|
180
|
-
function passthroughCommand(name, passthrough) {
|
|
223
|
+
async function passthroughCommand(name, passthrough) {
|
|
181
224
|
const cwd = process.cwd();
|
|
225
|
+
|
|
226
|
+
if (name === 'build') {
|
|
227
|
+
const regen = await makeSpecRegen(cwd);
|
|
228
|
+
if (regen && !regen()) process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
182
231
|
const cleaned = cleanShadowedFrameworkPkgs(cwd);
|
|
183
232
|
if (cleaned.length > 0) {
|
|
184
233
|
console.log(
|
|
@@ -205,6 +254,8 @@ function migrateCommand(rest) {
|
|
|
205
254
|
}
|
|
206
255
|
try {
|
|
207
256
|
switch (sub) {
|
|
257
|
+
case 'gen':
|
|
258
|
+
return runMigrateGen(rest.slice(1));
|
|
208
259
|
case 'status':
|
|
209
260
|
return runMigrateStatus(cwd);
|
|
210
261
|
case 'up':
|
|
@@ -216,7 +267,7 @@ function migrateCommand(rest) {
|
|
|
216
267
|
}
|
|
217
268
|
default:
|
|
218
269
|
console.error(`norns migrate: unknown subcommand "${sub}"`);
|
|
219
|
-
console.error('Usage: norns migrate <status|up|create <feature>/<name>>');
|
|
270
|
+
console.error('Usage: norns migrate <gen|status|up|create <feature>/<name>>');
|
|
220
271
|
process.exit(1);
|
|
221
272
|
}
|
|
222
273
|
} catch (err) {
|
|
@@ -273,6 +324,82 @@ function openTargetDb(cwd) {
|
|
|
273
324
|
return openSqliteDb(cwd, target.path);
|
|
274
325
|
}
|
|
275
326
|
|
|
327
|
+
async function validateCommand(rest) {
|
|
328
|
+
try {
|
|
329
|
+
const { validateSpecs } = await import('../src/kernel/index.js');
|
|
330
|
+
const result = validateSpecs(rest[0]);
|
|
331
|
+
for (const issue of result.issues) {
|
|
332
|
+
console.log(`[${issue.level}] ${issue.address}: ${issue.message}`);
|
|
333
|
+
}
|
|
334
|
+
if (result.ok) {
|
|
335
|
+
console.log(
|
|
336
|
+
`spec ok — ${result.modules.length} module(s), version ${result.version.slice(0, 12)}`
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error(err.message);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function runMigrateGen(rest) {
|
|
348
|
+
try {
|
|
349
|
+
const { migrateApp } = await import('../src/kernel/index.js');
|
|
350
|
+
const force = rest.includes('--force');
|
|
351
|
+
const dir = rest.find((a) => !a.startsWith('--'));
|
|
352
|
+
const result = await migrateApp(dir, { force });
|
|
353
|
+
const modules = Object.keys(result.created);
|
|
354
|
+
if (modules.length === 0) {
|
|
355
|
+
console.log('migrations up to date — no schema changes');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
for (const m of modules) {
|
|
359
|
+
for (const f of result.created[m]) console.log(`created migrations/${m}/${f}`);
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
console.error(err.message);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function generateCommand(rest) {
|
|
368
|
+
try {
|
|
369
|
+
const { generateApp } = await import('../src/kernel/index.js');
|
|
370
|
+
const result = generateApp(rest[0]);
|
|
371
|
+
console.log(`generated ${result.written.length} file(s) at spec version ${result.version}`);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
console.error(err.message);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function traceCommand(rest) {
|
|
379
|
+
try {
|
|
380
|
+
const { traceApp } = await import('../src/kernel/index.js');
|
|
381
|
+
const report = await traceApp(rest[0]);
|
|
382
|
+
for (const c of report.cases) {
|
|
383
|
+
const mark = c.pass ? '✓' : '✗';
|
|
384
|
+
console.log(`${mark} ${c.address} #${c.index}`);
|
|
385
|
+
if (!c.pass) {
|
|
386
|
+
if (c.error) console.log(` error: ${c.error}${c.status ? ` (${c.status})` : ''}`);
|
|
387
|
+
console.log(` input: ${JSON.stringify(c.input)}`);
|
|
388
|
+
console.log(` expect: ${JSON.stringify(c.expect)}`);
|
|
389
|
+
if (c.row) console.log(` row: ${JSON.stringify(c.row)}`);
|
|
390
|
+
if (c.result !== undefined) console.log(` result: ${JSON.stringify(c.result)}`);
|
|
391
|
+
}
|
|
392
|
+
for (const e of c.events) console.log(` emit ${e.name}`);
|
|
393
|
+
for (const call of c.calls) console.log(` call ${call.name}`);
|
|
394
|
+
}
|
|
395
|
+
console.log(`${report.pass} pass, ${report.fail} fail (spec ${report.version.slice(0, 12)})`);
|
|
396
|
+
process.exit(report.fail > 0 ? 1 : 0);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(err.message);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
276
403
|
function lintCommand() {
|
|
277
404
|
const findings = nornsLint(process.cwd());
|
|
278
405
|
const { errors } = printFindings(findings);
|
|
@@ -312,6 +439,15 @@ switch (cmd) {
|
|
|
312
439
|
case 'lint':
|
|
313
440
|
lintCommand();
|
|
314
441
|
break;
|
|
442
|
+
case 'validate':
|
|
443
|
+
validateCommand(rest);
|
|
444
|
+
break;
|
|
445
|
+
case 'generate':
|
|
446
|
+
generateCommand(rest);
|
|
447
|
+
break;
|
|
448
|
+
case 'trace':
|
|
449
|
+
traceCommand(rest);
|
|
450
|
+
break;
|
|
315
451
|
case 'diag':
|
|
316
452
|
diagCommand(rest);
|
|
317
453
|
break;
|
|
@@ -320,12 +456,16 @@ switch (cmd) {
|
|
|
320
456
|
console.log(`norns <command>
|
|
321
457
|
|
|
322
458
|
Commands:
|
|
323
|
-
dev start vite dev
|
|
324
|
-
build run vite build
|
|
459
|
+
dev start vite dev; watches specs/ → regenerate (spec-first) and framework src (default)
|
|
460
|
+
build generate from specs/ (spec-first), then run vite build
|
|
325
461
|
preview run vite preview
|
|
462
|
+
migrate gen [dir] [--force] diff specs against migrations/ via drizzle-kit (additive-only)
|
|
326
463
|
migrate status list applied + pending migrations
|
|
327
464
|
migrate up apply pending migrations
|
|
328
465
|
migrate create <feature>/<name> scaffold a new SQL migration
|
|
466
|
+
validate [dir] validate specs/*.tron (default dir: ./specs)
|
|
467
|
+
generate [dir] validate specs and generate code into .norns/generated/
|
|
468
|
+
trace [dir] run Action examples against a sandboxed in-memory SQLite
|
|
329
469
|
lint scan .c/.civet/.n + vite.config for known AI pitfalls
|
|
330
470
|
diag <file> print the compiled JS for a .c/.civet/.n file
|
|
331
471
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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)",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"./vite": "./src/vite.js",
|
|
28
28
|
"./preprocess": "./src/preprocess.js",
|
|
29
29
|
"./server": "./src/server/index.js",
|
|
30
|
+
"./live-client": "./src/live-client.js",
|
|
31
|
+
"./kernel": "./src/kernel/index.js",
|
|
30
32
|
"./package.json": "./package.json"
|
|
31
33
|
},
|
|
32
34
|
"peerDependencies": {
|
|
@@ -35,8 +37,12 @@
|
|
|
35
37
|
"vite": "^5.0.0 || ^6.0.0"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
|
-
"@danielx/civet": "^0.11.
|
|
39
|
-
"@human-synthesis/norns-core": "^0.0.10"
|
|
40
|
+
"@danielx/civet": "^0.11.16",
|
|
41
|
+
"@human-synthesis/norns-core": "^0.0.10",
|
|
42
|
+
"@human-synthesis/norns-tron": "^0.1.0",
|
|
43
|
+
"drizzle-kit": "0.31.10",
|
|
44
|
+
"drizzle-orm": "0.45.2",
|
|
45
|
+
"valibot": "^1.4.0"
|
|
40
46
|
},
|
|
41
47
|
"engines": {
|
|
42
48
|
"node": ">=18"
|
package/src/auto-import.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
// `match`: optional regex tested against the filename. When set, the helper
|
|
@@ -658,7 +658,10 @@ function renderImports(entries) {
|
|
|
658
658
|
* glob). Default `['.c', '.civet', '.js']` — `.ts` excluded because
|
|
659
659
|
* regex-scanned `.ts` can't reliably distinguish value vs type-only exports.
|
|
660
660
|
* @param {string} [options.libRoot] Default `'src/lib'`.
|
|
661
|
-
* @param {string} [options.libAlias] Default `'$lib'
|
|
661
|
+
* @param {string} [options.libAlias] Default `'$lib'` — except in spec-first
|
|
662
|
+
* projects (a `specs/` dir at the root), where `nornsConfig` points `$lib`
|
|
663
|
+
* at `.norns/generated/lib`, so hand-written components under `src/lib`
|
|
664
|
+
* default to the `$custom/lib` alias (`$custom` → `src`) instead.
|
|
662
665
|
* @param {string} [options.root] Default `process.cwd()`.
|
|
663
666
|
* @param {(msg: string) => void} [options.log]
|
|
664
667
|
* Channel for conflict warnings. Default `console.warn`. Tests pass a
|
|
@@ -672,7 +675,8 @@ export function nornsAutoImport(options = {}) {
|
|
|
672
675
|
const componentExts = options.componentExtensions ?? DEFAULT_COMPONENT_EXTS;
|
|
673
676
|
const exportExts = options.exportExtensions ?? DEFAULT_EXPORT_EXTS;
|
|
674
677
|
const libRoot = options.libRoot ?? DEFAULT_LIB_ROOT;
|
|
675
|
-
const
|
|
678
|
+
const specFirst = existsSync(join(root, 'specs'));
|
|
679
|
+
const libAlias = options.libAlias ?? (specFirst ? '$custom/lib' : DEFAULT_LIB_ALIAS);
|
|
676
680
|
const componentSpecs = options.components ?? null;
|
|
677
681
|
const log = options.log ?? console.warn;
|
|
678
682
|
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,13 @@ import { nornsPreprocess } from '@human-synthesis/norns-core/preprocess';
|
|
|
15
15
|
* path is the non-invasive way to make `.c`/`.civet` hooks discoverable.
|
|
16
16
|
* Same for the client and universal counterparts.
|
|
17
17
|
* - `preprocess: nornsPreprocess()` — Pug + Civet
|
|
18
|
+
* - `kit.alias.$custom` → `src` — generated Level-2 shells import their
|
|
19
|
+
* hand-written bodies through this alias
|
|
20
|
+
* - Spec-first mode: when a `specs/` dir exists, `kit.files.routes` and
|
|
21
|
+
* `kit.files.lib` point into `.norns/generated/` (the emitted tree owns the
|
|
22
|
+
* app; `src/` holds custom bodies only), and hook detection also searches
|
|
23
|
+
* the generated tree (`src/` wins — a hand-written hook is a deliberate
|
|
24
|
+
* override).
|
|
18
25
|
*
|
|
19
26
|
* Spread your own overrides at the call site to extend or replace defaults.
|
|
20
27
|
*
|
|
@@ -33,11 +40,16 @@ export function nornsConfig(overrides = {}) {
|
|
|
33
40
|
const userFiles = kitOverrides.files ?? {};
|
|
34
41
|
const userHooks = userFiles.hooks ?? {};
|
|
35
42
|
|
|
43
|
+
const specFirst = existsSync(join(cwd, 'specs'));
|
|
44
|
+
const gen = join('.norns', 'generated');
|
|
45
|
+
const hookDirs = specFirst ? ['src', gen] : ['src'];
|
|
46
|
+
const candidates = (name) => hookDirs.flatMap((d) => [join(d, `${name}.c`), join(d, `${name}.civet`)]);
|
|
47
|
+
|
|
36
48
|
const hooks = {
|
|
37
49
|
...userHooks,
|
|
38
|
-
server: userHooks.server ?? findHook(cwd,
|
|
39
|
-
client: userHooks.client ?? findHook(cwd,
|
|
40
|
-
universal: userHooks.universal ?? findHook(cwd,
|
|
50
|
+
server: userHooks.server ?? findHook(cwd, candidates('hooks.server')),
|
|
51
|
+
client: userHooks.client ?? findHook(cwd, candidates('hooks.client')),
|
|
52
|
+
universal: userHooks.universal ?? findHook(cwd, candidates('hooks'))
|
|
41
53
|
};
|
|
42
54
|
// Drop keys whose value is undefined so SvelteKit applies its defaults
|
|
43
55
|
for (const k of /** @type {const} */ (['server', 'client', 'universal'])) {
|
|
@@ -52,7 +64,9 @@ export function nornsConfig(overrides = {}) {
|
|
|
52
64
|
kit: {
|
|
53
65
|
moduleExtensions: ['.js', '.ts', '.c', '.civet'],
|
|
54
66
|
...kitRest,
|
|
67
|
+
alias: { $custom: 'src', ...kitRest.alias },
|
|
55
68
|
files: {
|
|
69
|
+
...(specFirst && { routes: join(gen, 'routes'), lib: join(gen, 'lib') }),
|
|
56
70
|
...userFiles,
|
|
57
71
|
hooks
|
|
58
72
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absorb — propose replacing custom bodies with spec (K-19, PLAN §268).
|
|
3
|
+
*
|
|
4
|
+
* `absorbUnit` statically analyses a Level-2 (`impl: custom`) action body
|
|
5
|
+
* and, when every statement is expressible in the vetted step vocabulary
|
|
6
|
+
* (`set` with literal fields, `emit`), returns spec ops that `spec.apply`
|
|
7
|
+
* can run to make the unit generated again. The analyser is deliberately
|
|
8
|
+
* conservative: any statement, import, or expression it cannot prove
|
|
9
|
+
* equivalent makes the unit non-absorbable with a reason — absorb never
|
|
10
|
+
* guesses. Custom ratio per module is the companion health metric,
|
|
11
|
+
* surfaced past ~25% (PLAN: remedied by absorb suggestions, never refusal).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { listUnits, parseAddress } from './address.js';
|
|
15
|
+
import { actionEntity } from './emit-units.js';
|
|
16
|
+
|
|
17
|
+
export const CUSTOM_RATIO_THRESHOLD = 0.25;
|
|
18
|
+
|
|
19
|
+
/** Every `impl: custom` unit across the app, as address records. */
|
|
20
|
+
export function customUnits(specs) {
|
|
21
|
+
const units = [];
|
|
22
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules ?? {})) {
|
|
23
|
+
for (const unit of listUnits(moduleName, moduleSpec)) {
|
|
24
|
+
if (unit.value?.impl === 'custom') units.push(unit);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return units;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Per-module custom-body health: { modules: { m: { custom, total, ratio, surfaced } }, app }. */
|
|
31
|
+
export function customRatio(specs, { threshold = CUSTOM_RATIO_THRESHOLD } = {}) {
|
|
32
|
+
const modules = {};
|
|
33
|
+
let custom = 0;
|
|
34
|
+
let total = 0;
|
|
35
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules ?? {})) {
|
|
36
|
+
const units = listUnits(moduleName, moduleSpec);
|
|
37
|
+
const own = units.filter((u) => u.value?.impl === 'custom').length;
|
|
38
|
+
const ratio = units.length === 0 ? 0 : own / units.length;
|
|
39
|
+
modules[moduleName] = { custom: own, total: units.length, ratio, surfaced: ratio > threshold };
|
|
40
|
+
custom += own;
|
|
41
|
+
total += units.length;
|
|
42
|
+
}
|
|
43
|
+
const ratio = total === 0 ? 0 : custom / total;
|
|
44
|
+
return { modules, app: { custom, total, ratio, surfaced: ratio > threshold } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where a custom body lives, relative to the project root. */
|
|
48
|
+
export function customBodyPath(address) {
|
|
49
|
+
const { module, kind, name } = parseAddress(address);
|
|
50
|
+
if (kind === 'Action') return `src/${module}/actions/${name}.c`;
|
|
51
|
+
if (kind === 'Page') return `src/${module}/pages/${name}.n`;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripComments(source) {
|
|
56
|
+
return source
|
|
57
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
58
|
+
.replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Merge lines into statements by bracket balance. Strings that unbalance
|
|
62
|
+
* brackets just make the statement unrecognisable — a safe failure. */
|
|
63
|
+
function splitStatements(text) {
|
|
64
|
+
const out = [];
|
|
65
|
+
let buf = '';
|
|
66
|
+
let depth = 0;
|
|
67
|
+
for (const raw of text.split('\n')) {
|
|
68
|
+
const line = raw.trim();
|
|
69
|
+
if (line === '') continue;
|
|
70
|
+
buf = buf === '' ? line : `${buf} ${line}`;
|
|
71
|
+
for (const ch of line) {
|
|
72
|
+
if (ch === '(' || ch === '[' || ch === '{') depth += 1;
|
|
73
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth -= 1;
|
|
74
|
+
}
|
|
75
|
+
if (depth <= 0) {
|
|
76
|
+
out.push(buf);
|
|
77
|
+
buf = '';
|
|
78
|
+
depth = 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (buf !== '') out.push(buf);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitTopCommas(text) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let buf = '';
|
|
89
|
+
for (const ch of text) {
|
|
90
|
+
if (ch === '(' || ch === '[' || ch === '{') depth += 1;
|
|
91
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth -= 1;
|
|
92
|
+
if (ch === ',' && depth === 0) {
|
|
93
|
+
parts.push(buf);
|
|
94
|
+
buf = '';
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
buf += ch;
|
|
98
|
+
}
|
|
99
|
+
if (buf.trim() !== '') parts.push(buf);
|
|
100
|
+
return parts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseLiteral(text) {
|
|
104
|
+
const t = text.trim().replace(/;$/, '').trim();
|
|
105
|
+
if (/^'(?:[^'\\]|\\.)*'$/.test(t)) return { ok: true, value: t.slice(1, -1).replace(/\\(.)/g, '$1') };
|
|
106
|
+
try {
|
|
107
|
+
const value = JSON.parse(t);
|
|
108
|
+
const type = typeof value;
|
|
109
|
+
if (value === null || type === 'string' || type === 'number' || type === 'boolean') {
|
|
110
|
+
return { ok: true, value };
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// fall through — not a literal
|
|
114
|
+
}
|
|
115
|
+
return { ok: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `{ field: literal, ... }` inner text → { ok, fields } */
|
|
119
|
+
function parseLiteralObject(inner) {
|
|
120
|
+
const fields = {};
|
|
121
|
+
for (const part of splitTopCommas(inner)) {
|
|
122
|
+
const m = part.match(/^\s*([A-Za-z_$][\w$]*)\s*:\s*([\s\S]+)$/);
|
|
123
|
+
if (!m) return { ok: false, at: part.trim() };
|
|
124
|
+
const lit = parseLiteral(m[2]);
|
|
125
|
+
if (!lit.ok) return { ok: false, at: part.trim() };
|
|
126
|
+
fields[m[1]] = lit.value;
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, fields };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const DB_RESOLVE_RE = /^(?:const\s+db\s*=|db\s*:=)\s*container\s*\.\s*resolve\(\s*(['"])db\1\s*\)\s*;?$/;
|
|
132
|
+
const UPDATE_RE =
|
|
133
|
+
/^await\s+db\s*\.\s*update\(\s*([A-Za-z_$][\w$]*)\s*\)\s*\.\s*set\(\s*\{([\s\S]*)\}\s*\)\s*\.\s*where\(\s*eq\(\s*([A-Za-z_$][\w$]*)\s*\.\s*id\s*,\s*input\s*\.\s*([\w$]+)\s*\)\s*\)\s*;?$/;
|
|
134
|
+
const EMIT_RE =
|
|
135
|
+
/^await\s+container\s*\.\s*resolve\(\s*(['"])events\1\s*\)\s*\.\s*emit\(\s*(['"])([\w.-]+)\2\s*(?:,\s*\{\s*row\s*,\s*input\s*,\s*user\s*\}\s*)?\)\s*;?$/;
|
|
136
|
+
const RETURN_RE = /^return(?:\s*(\{[\s\S]*\}))?\s*;?$/;
|
|
137
|
+
const CONTRACT_RE = /export\s+default\s+(?:async\s+)?\(\s*\{([^}]*)\}\s*\)\s*(?:=>|->)\s*\{/;
|
|
138
|
+
const IMPORT_RE = /^import\s*\{\s*([^}]+)\}\s*from\s*(['"])([^'"]+)\2\s*;?$/;
|
|
139
|
+
|
|
140
|
+
const CONTRACT_PARAMS = new Set(['row', 'input', 'container', 'user']);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Analyse one custom unit. Returns
|
|
144
|
+
* `{ address, absorbable: false, reason }` or
|
|
145
|
+
* `{ address, absorbable: true, ops, steps, notes }`.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ app: object|null, modules: Record<string, object> }} specs
|
|
148
|
+
* @param {string} address e.g. `orders.Action.price`
|
|
149
|
+
* @param {string|null} source the custom body's source text
|
|
150
|
+
*/
|
|
151
|
+
export function absorbUnit(specs, address, source) {
|
|
152
|
+
const { module, kind, name } = parseAddress(address);
|
|
153
|
+
const no = (reason) => ({ address, absorbable: false, reason });
|
|
154
|
+
|
|
155
|
+
const unit = specs.modules?.[module]?.[kind === 'Action' ? 'actions' : kind === 'Page' ? 'pages' : '']?.[name];
|
|
156
|
+
if (!unit) return no(`no ${kind} unit at ${address}`);
|
|
157
|
+
if (unit.impl !== 'custom') return no('unit is already generated (impl is not custom)');
|
|
158
|
+
if (kind === 'Page') {
|
|
159
|
+
return no('page bodies are not analysed yet — the component vocabulary does not cover arbitrary markup');
|
|
160
|
+
}
|
|
161
|
+
if (kind !== 'Action') return no(`${kind} units cannot be custom`);
|
|
162
|
+
if (typeof source !== 'string' || source.trim() === '') {
|
|
163
|
+
return no(`custom body not found (expected ${customBodyPath(address)})`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const target = actionEntity(module, unit, specs);
|
|
167
|
+
if (!target) return no('action has no resolvable target entity — nothing to express steps against');
|
|
168
|
+
const idKey =
|
|
169
|
+
Object.keys(unit.input ?? {})
|
|
170
|
+
.sort()
|
|
171
|
+
.find((k) => unit.input[k] === `${target.entity}.id`) ?? null;
|
|
172
|
+
|
|
173
|
+
const clean = stripComments(source);
|
|
174
|
+
const contract = clean.match(CONTRACT_RE);
|
|
175
|
+
if (!contract) return no('body is not a `export default async ({ … }) => { … }` block');
|
|
176
|
+
const params = contract[1]
|
|
177
|
+
.split(',')
|
|
178
|
+
.map((p) => p.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
for (const p of params) {
|
|
181
|
+
if (!CONTRACT_PARAMS.has(p)) return no(`contract parameter "${p}" is outside { row, input, container, user }`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Imports: only drizzle's `eq` and the target entity's schema are vocabulary.
|
|
185
|
+
const head = clean.slice(0, contract.index);
|
|
186
|
+
for (const stmt of splitStatements(head)) {
|
|
187
|
+
const m = stmt.match(IMPORT_RE);
|
|
188
|
+
if (!m) return no(`unrecognised statement before the body: \`${stmt}\``);
|
|
189
|
+
const names = m[1].split(',').map((n) => n.trim());
|
|
190
|
+
const from = m[3];
|
|
191
|
+
if (from === 'drizzle-orm') {
|
|
192
|
+
if (names.every((n) => n === 'eq')) continue;
|
|
193
|
+
return no(`import { ${names.join(', ')} } from 'drizzle-orm' — only \`eq\` is in vocabulary`);
|
|
194
|
+
}
|
|
195
|
+
if (/(^|\/)schema\.c$/.test(from) && names.length === 1 && names[0] === target.entity) continue;
|
|
196
|
+
return no(`import from '${from}' is outside the generated vocabulary`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const bodyStart = contract.index + contract[0].length;
|
|
200
|
+
const bodyEnd = clean.lastIndexOf('}');
|
|
201
|
+
if (bodyEnd <= bodyStart) return no('could not find the end of the body block');
|
|
202
|
+
const body = clean.slice(bodyStart, bodyEnd);
|
|
203
|
+
|
|
204
|
+
const steps = [];
|
|
205
|
+
const notes = [];
|
|
206
|
+
const setFields = {};
|
|
207
|
+
for (const stmt of splitStatements(body)) {
|
|
208
|
+
if (DB_RESOLVE_RE.test(stmt)) continue; // the generated shell resolves db itself
|
|
209
|
+
|
|
210
|
+
const update = stmt.match(UPDATE_RE);
|
|
211
|
+
if (update) {
|
|
212
|
+
const [, entity, inner, whereEntity, whereKey] = update;
|
|
213
|
+
if (entity !== target.entity || whereEntity !== target.entity) {
|
|
214
|
+
return no(`update targets ${entity} but the action's entity is ${target.entity}`);
|
|
215
|
+
}
|
|
216
|
+
if (idKey === null || whereKey !== idKey) {
|
|
217
|
+
return no(`where clause uses input.${whereKey}, not the action's ${target.entity}.id input`);
|
|
218
|
+
}
|
|
219
|
+
const parsed = parseLiteralObject(inner);
|
|
220
|
+
if (!parsed.ok) {
|
|
221
|
+
return no(`set field \`${parsed.at}\` is not a literal — the step vocabulary only takes literal values`);
|
|
222
|
+
}
|
|
223
|
+
Object.assign(setFields, parsed.fields);
|
|
224
|
+
steps.push({ set: { entity: target.entity, ...parsed.fields } });
|
|
225
|
+
if ('status' in parsed.fields) {
|
|
226
|
+
notes.push('the generated shell adds the machine-edge guard for status writes (K-17)');
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const emit = stmt.match(EMIT_RE);
|
|
232
|
+
if (emit) {
|
|
233
|
+
steps.push({ emit: emit[3] });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ret = stmt.match(RETURN_RE);
|
|
238
|
+
if (ret) {
|
|
239
|
+
if (ret[1] === undefined) continue;
|
|
240
|
+
const parsed = parseLiteralObject(ret[1].slice(1, -1));
|
|
241
|
+
if (!parsed.ok) return no(`return value \`${ret[1]}\` is not a literal object`);
|
|
242
|
+
const entries = Object.entries(parsed.fields);
|
|
243
|
+
const redundant = entries.every(([k, v]) => (k === 'ok' && v === true) || setFields[k] === v);
|
|
244
|
+
if (!redundant) {
|
|
245
|
+
return no('return value carries data the generated shell would not (it returns { ok: true })');
|
|
246
|
+
}
|
|
247
|
+
if (entries.some(([k]) => k !== 'ok')) {
|
|
248
|
+
notes.push('the custom return of row fields is dropped — generated actions return { ok: true }; read fresh values through a query');
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return no(`statement is outside the step vocabulary: \`${stmt}\``);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const base = `${module}.Action.${name}`;
|
|
257
|
+
const ops = [];
|
|
258
|
+
if (steps.length > 0) ops.push({ op: 'set', path: `${base}.steps`, value: steps });
|
|
259
|
+
ops.push({ op: 'remove', path: `${base}.impl` });
|
|
260
|
+
notes.push(`delete ${customBodyPath(address)} after applying — the generated body replaces it`);
|
|
261
|
+
|
|
262
|
+
return { address, absorbable: true, ops, steps, notes };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Scan the whole app: every custom unit gets an absorb verdict, plus the
|
|
267
|
+
* custom-ratio health metric.
|
|
268
|
+
*
|
|
269
|
+
* @param {{ app: object|null, modules: Record<string, object> }} specs
|
|
270
|
+
* @param {(relPath: string) => string|null} readSource
|
|
271
|
+
*/
|
|
272
|
+
export function absorbApp(specs, readSource) {
|
|
273
|
+
const candidates = customUnits(specs).map((unit) => {
|
|
274
|
+
const rel = customBodyPath(unit.address);
|
|
275
|
+
const source = rel === null ? null : readSource(rel);
|
|
276
|
+
return absorbUnit(specs, unit.address, source);
|
|
277
|
+
});
|
|
278
|
+
return { candidates, ratio: customRatio(specs) };
|
|
279
|
+
}
|