@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 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
- function devCommand(passthrough) {
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 with framework-source watching (default)
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.16",
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.0",
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"
@@ -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 libAlias = options.libAlias ?? DEFAULT_LIB_ALIAS;
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, ['src/hooks.server.c', 'src/hooks.server.civet']),
39
- client: userHooks.client ?? findHook(cwd, ['src/hooks.client.c', 'src/hooks.client.civet']),
40
- universal: userHooks.universal ?? findHook(cwd, ['src/hooks.c', 'src/hooks.civet'])
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
+ }