@githolon/dsl 0.50.0 → 0.52.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/dart/example/nomos_secure_key_store.dart +82 -0
- package/dart/lib/src/webview_bridge.dart +282 -0
- package/lsp_server.mjs +43 -0
- package/package.json +5 -2
- package/src/check_package_main.ts +159 -8
- package/src/codegen_dart_client.ts +22 -1
- package/src/codegen_ts.ts +181 -0
- package/src/evolve_preview.ts +466 -0
- package/src/law_check.ts +405 -3
- package/src/law_source_map.ts +146 -0
- package/src/lsp_main.ts +14 -0
- package/src/lsp_server.ts +404 -0
package/src/law_check.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* payload that exhibited it. The runtime gate remains the arbiter; `check` never claims
|
|
26
26
|
* completeness, only that what it flags is real.
|
|
27
27
|
*/
|
|
28
|
-
import
|
|
28
|
+
import { z } from "zod";
|
|
29
29
|
|
|
30
30
|
import type { AggregateHandle } from "./aggregate.js";
|
|
31
31
|
import type { Directive } from "./directive.js";
|
|
@@ -39,6 +39,10 @@ import { __drainReads, __resetReads } from "./read.js";
|
|
|
39
39
|
import { __drainBirths, __resetBirths } from "./births.js";
|
|
40
40
|
import { proofLegsIndex } from "./codegen_proof.js";
|
|
41
41
|
import { autoStampFields, zodKind, zodObjectShape } from "./codegen_ts.js";
|
|
42
|
+
import { finishCount } from "./count.js";
|
|
43
|
+
import { finishSum } from "./sum.js";
|
|
44
|
+
import { finishExtremum } from "./extremum.js";
|
|
45
|
+
import { finishExists } from "./exists.js";
|
|
42
46
|
|
|
43
47
|
/** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
|
|
44
48
|
export interface Finding {
|
|
@@ -162,6 +166,164 @@ function samplePayloadText(payload: Record<string, unknown>): string {
|
|
|
162
166
|
return s.length > 200 ? `${s.slice(0, 197)}…` : s;
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
// ── SLICE 2 — the DELETED-AMBIENT twin (crypto/fetch/setTimeout/… → refuse) ────────
|
|
170
|
+
// The sealed sandbox HARDENs `globalThis` by ALLOWLIST-DELETE (deterministic-rquickjs/src/lib.rs
|
|
171
|
+
// `HARDEN`): it keeps ONLY the pure ECMAScript surface + `__ports`, and DELETES everything else.
|
|
172
|
+
// A plan that references one of those deleted ambients runs fine in NODE (where the probe lives —
|
|
173
|
+
// `crypto`/`fetch`/`setTimeout`/… all exist), so the ordinary probe never sees it, but throws
|
|
174
|
+
// "'<X>' is not defined" at the FIRST dispatch on every peer. So we run the plan a SECOND time with
|
|
175
|
+
// those globals shadowed by THROWING stubs and catch the marker — a reference UNCONDITIONAL on the
|
|
176
|
+
// sample path is payload-independent ⇒ REFUSE (a branch the sample doesn't take never fires the
|
|
177
|
+
// stub ⇒ no finding, the hard rule). Date/Math stay REAL: `Date.now()`/`new Date()`/`Math.random()`
|
|
178
|
+
// are LEGAL (shimmed to the captured ports — deterministic by design), NOT deleted.
|
|
179
|
+
//
|
|
180
|
+
// The list is DERIVED from HARDEN's excluded-by-omission set (the `HARDEN` doc-comment enumerates
|
|
181
|
+
// it), restricted to the names that (a) EXIST in Node — so the ordinary probe can't catch them —
|
|
182
|
+
// and (b) a plan could plausibly reach for. `console` is deliberately EXCLUDED: internal DSL/probe
|
|
183
|
+
// code may log, which would be a false REFUSE, and the doc's list doesn't name it. So are the names
|
|
184
|
+
// Node/undici touch INTERNALLY (`queueMicrotask`/`setImmediate`/`Intl`/`structuredClone`/`Text*`) —
|
|
185
|
+
// stubbing them risks perturbing Node's own machinery. QuickJS-only globals (`os`/`std`/`print`/
|
|
186
|
+
// `scriptArgs`) are absent in Node too, so a plan using them already throws in the ordinary probe
|
|
187
|
+
// (twin A WARNs it) — no stub needed.
|
|
188
|
+
const DELETED_AMBIENT_ALTERNATIVE: Readonly<Record<string, string>> = {
|
|
189
|
+
crypto:
|
|
190
|
+
"randomness rides the captured ports — `Math.random()` is shimmed to the deterministic captured draws; a random id comes from Nomos minting it (`create(Agg)` mints)",
|
|
191
|
+
performance:
|
|
192
|
+
"time rides the captured ports — `Date.now()` / `new Date()` are shimmed to the deterministic captured clock; use those, or carry an ISO timestamp IN the payload",
|
|
193
|
+
fetch:
|
|
194
|
+
"network never happens in a plan (it must be a pure function of (payload, ports)) — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
|
|
195
|
+
XMLHttpRequest:
|
|
196
|
+
"network never happens in a plan — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
|
|
197
|
+
WebSocket:
|
|
198
|
+
"network never happens in a plan — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
|
|
199
|
+
setTimeout: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
|
|
200
|
+
clearTimeout: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
|
|
201
|
+
setInterval: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
|
|
202
|
+
clearInterval: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
|
|
203
|
+
process: "there is no host/OS ambient in the sandbox — carry any config IN the payload",
|
|
204
|
+
require: "there are no modules in the sandbox — the plan is a self-contained pure function",
|
|
205
|
+
};
|
|
206
|
+
const DELETED_AMBIENTS: readonly string[] = Object.keys(DELETED_AMBIENT_ALTERNATIVE);
|
|
207
|
+
|
|
208
|
+
/** The marker a shadowed ambient throws when the plan USES it — carries the name so the twin can name
|
|
209
|
+
* it (and its captured alternative). */
|
|
210
|
+
function deletedAmbientMarker(name: string): Error {
|
|
211
|
+
return Object.assign(new Error(`the sealed sandbox removes '${name}'`), { nomosDeletedAmbient: name });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A stub VALUE for a deleted ambient: a Proxy that is INERT when merely held or read as a binding
|
|
215
|
+
* (so Node/DSL internals that pass the reference around stay functional) but throws its marker the
|
|
216
|
+
* instant a PLAN actually USES it — a property access (`crypto.randomUUID`, `performance.now`,
|
|
217
|
+
* `process.env`), a call (`fetch(...)`, `setTimeout(...)`, `require(...)`), or a construct
|
|
218
|
+
* (`new WebSocket(...)`). This is the whole realistic surface by which a plan reaches an ambient;
|
|
219
|
+
* a bare unused `const c = crypto` is under-detected on purpose (the runtime gate stays the arbiter),
|
|
220
|
+
* the safe trade for never poisoning Node internals. Symbol reads return `undefined` so util.inspect /
|
|
221
|
+
* thenable probing can't trip it. */
|
|
222
|
+
function deletedAmbientStub(name: string): unknown {
|
|
223
|
+
const target = function () {} as unknown as object;
|
|
224
|
+
return new Proxy(target, {
|
|
225
|
+
get(_t, prop) {
|
|
226
|
+
if (typeof prop === "symbol") return undefined;
|
|
227
|
+
throw deletedAmbientMarker(name);
|
|
228
|
+
},
|
|
229
|
+
apply() {
|
|
230
|
+
throw deletedAmbientMarker(name);
|
|
231
|
+
},
|
|
232
|
+
construct() {
|
|
233
|
+
throw deletedAmbientMarker(name);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run `run()` (the SYNCHRONOUS plan call only) with every {@link DELETED_AMBIENTS} name shadowed by a
|
|
239
|
+
* {@link deletedAmbientStub} data-property on `globalThis`, restored in a `finally` on the SAME tick —
|
|
240
|
+
* never across an await/module-load boundary (the teammate's bug: a stub visible during Node's lazy
|
|
241
|
+
* undici load). Because the stub is inert unless USED, and this window is one synchronous plan call,
|
|
242
|
+
* Node internals never trip it. Restores the exact prior descriptors on every path. */
|
|
243
|
+
function withDeletedAmbientStubs<T>(run: () => T): T {
|
|
244
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
245
|
+
// PRE-MATERIALIZE (with NO stub installed yet — so nothing here can throw a marker). Node defines
|
|
246
|
+
// `fetch`/`performance`/`crypto` as LAZY globals that materialize — and pull in undici, which reads
|
|
247
|
+
// `performance` — on first access, AND (Node 25) even on a `getOwnPropertyDescriptor` query. Forcing
|
|
248
|
+
// each to a concrete value NOW, before any shadow, means the descriptor-capture + the sync plan run
|
|
249
|
+
// below never trigger a lazy load: the stub window stays synchronous and load-free (the teammate's
|
|
250
|
+
// bug was a stub visible during that undici load — this keeps the load strictly before the window).
|
|
251
|
+
for (const name of DELETED_AMBIENTS) {
|
|
252
|
+
try {
|
|
253
|
+
void g[name];
|
|
254
|
+
} catch {
|
|
255
|
+
/* a global that throws on plain read — nothing to pre-materialize */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const prior = new Map<string, PropertyDescriptor | undefined>();
|
|
259
|
+
for (const name of DELETED_AMBIENTS) {
|
|
260
|
+
prior.set(name, Object.getOwnPropertyDescriptor(g, name));
|
|
261
|
+
try {
|
|
262
|
+
Object.defineProperty(g, name, { configurable: true, writable: true, value: deletedAmbientStub(name) });
|
|
263
|
+
} catch {
|
|
264
|
+
// A non-configurable global can't be shadowed — leave it (the ordinary probe still runs).
|
|
265
|
+
prior.delete(name);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
return run();
|
|
270
|
+
} finally {
|
|
271
|
+
for (const [name, desc] of prior) {
|
|
272
|
+
try {
|
|
273
|
+
if (desc === undefined) delete g[name];
|
|
274
|
+
else Object.defineProperty(g, name, desc);
|
|
275
|
+
} catch {
|
|
276
|
+
/* best-effort restore */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Re-run the directive's plan on its synthesized sample with the deleted ambients shadowed; return the
|
|
283
|
+
* ambient name the plan READ on the sample path, or `undefined`. Inconclusive when the ordinary probe
|
|
284
|
+
* already threw (a broken sample path proves nothing about the ambient reference). */
|
|
285
|
+
function probeDeletedAmbient(dir: Directive<unknown>, probe: ProbeResult): string | undefined {
|
|
286
|
+
if (probe.threw) return undefined;
|
|
287
|
+
let hit: string | undefined;
|
|
288
|
+
withBirthProbeGlobals(() => {
|
|
289
|
+
withDeletedAmbientStubs(() => {
|
|
290
|
+
__resetAuthoring();
|
|
291
|
+
__resetReads();
|
|
292
|
+
__resetBirths();
|
|
293
|
+
try {
|
|
294
|
+
dir.plan(
|
|
295
|
+
probe.probePayload as never,
|
|
296
|
+
deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }),
|
|
297
|
+
);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const name = (e as { nomosDeletedAmbient?: unknown } | null)?.nomosDeletedAmbient;
|
|
300
|
+
if (typeof name === "string") hit = name;
|
|
301
|
+
// A non-marker throw is a payload-dependent branch (twin A already WARNs it) — ignore.
|
|
302
|
+
} finally {
|
|
303
|
+
try { __drainAuthoring(); } catch { /* drained */ }
|
|
304
|
+
try { __drainReads(); } catch { /* drained */ }
|
|
305
|
+
try { __drainBirths(); } catch { /* drained */ }
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
return hit;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── TWIN 1 (slice 2) — the DELETED-AMBIENT refusal ────────────────────────────────
|
|
313
|
+
function deletedAmbientFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
|
|
314
|
+
const name = probeDeletedAmbient(dir, probe);
|
|
315
|
+
if (name === undefined) return [];
|
|
316
|
+
return [
|
|
317
|
+
{
|
|
318
|
+
domain,
|
|
319
|
+
directiveId: dir.id,
|
|
320
|
+
level: "refuse",
|
|
321
|
+
what: `the plan references '${name}', which the sealed sandbox REMOVES (HARDEN allowlist-delete) — it would throw "'${name}' is not defined" at the first dispatch on every peer`,
|
|
322
|
+
fix: DELETED_AMBIENT_ALTERNATIVE[name] ?? "the sandbox keeps only the pure ECMAScript surface + the captured ports — carry data IN the payload or via declared reads",
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
|
|
165
327
|
// ── TWIN A — the UNIVERSAL plan-return probe ──────────────────────────────────────
|
|
166
328
|
// The engine assembles a plan's output with `[...returned]` (wire_encode.ts). A plan whose
|
|
167
329
|
// `plan` returns a NON-ITERABLE (undefined — a forgotten `return`; a builder — a forgotten
|
|
@@ -298,19 +460,254 @@ function opVsSchemaFindings(
|
|
|
298
460
|
return out;
|
|
299
461
|
}
|
|
300
462
|
|
|
463
|
+
// ── TWIN 4 (sealing slice 3) — GROUP/KEY BY an .encrypted() field REFUSES ──────────
|
|
464
|
+
// An `.encrypted()` field is a ciphertext envelope on the wire; grouping or keying a read BY it
|
|
465
|
+
// makes the PLAINTEXT public (the group key / index value leaks the very thing you sealed —
|
|
466
|
+
// e2e_encryption.md's "METADATA IS PUBLIC" footgun). Every read decl that GROUPS or KEYS by a
|
|
467
|
+
// field is checked against the target aggregate's `encryptedFields`: a hit is payload-independent
|
|
468
|
+
// (a static declaration) ⇒ REFUSE. (Twin 4b — an `.encrypted()` field on a non-`requires()`
|
|
469
|
+
// aggregate — is REFUSED earlier by `aggregate()` itself, before compose; a defensive re-check is
|
|
470
|
+
// folded in below so the unified report names it too if the state is ever reached.)
|
|
471
|
+
function encryptionGroupingFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
|
|
472
|
+
const domain = mod.domain ?? mod.name;
|
|
473
|
+
const out: Finding[] = [];
|
|
474
|
+
const seen = new Set<string>();
|
|
475
|
+
const encryptedOf = (aggId: string): ReadonlySet<string> =>
|
|
476
|
+
new Set(aggById.get(aggId)?.encryptedFields ?? []);
|
|
477
|
+
const flag = (readKind: string, readId: string, aggId: string, field: string, verb: string): void => {
|
|
478
|
+
if (!encryptedOf(aggId).has(field)) return;
|
|
479
|
+
const key = `${readKind}:${readId}:${aggId}.${field}`;
|
|
480
|
+
if (seen.has(key)) return;
|
|
481
|
+
seen.add(key);
|
|
482
|
+
out.push({
|
|
483
|
+
domain,
|
|
484
|
+
directiveId: readId,
|
|
485
|
+
level: "refuse",
|
|
486
|
+
what: `the ${readKind} '${readId}' ${verb} by '${field}' on '${aggId}', an .encrypted() field — ${verb === "keys" ? "keying" : "grouping"} by a sealed field makes its plaintext PUBLIC (the ${verb === "keys" ? "index key" : "group key"} leaks what you encrypted)`,
|
|
487
|
+
fix: `${verb} by a PLAINTEXT field instead, or drop .encrypted() from '${field}' — you can't both seal a value and expose it as a ${verb === "keys" ? "read key" : "group"}`,
|
|
488
|
+
});
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
for (const q of mod.queries ?? []) for (const k of q.key) flag("query", q.id, q.returns, k, "keys");
|
|
492
|
+
for (const c of mod.counts ?? []) {
|
|
493
|
+
const d = finishCount(c);
|
|
494
|
+
if (d.by !== undefined) flag("count", d.id, d.of, d.by, "groups");
|
|
495
|
+
}
|
|
496
|
+
for (const s of mod.sums ?? []) {
|
|
497
|
+
const d = finishSum(s);
|
|
498
|
+
if (d.by !== undefined) flag("sum", d.id, d.of, d.by, "groups");
|
|
499
|
+
}
|
|
500
|
+
for (const m of mod.mins ?? []) {
|
|
501
|
+
const d = finishExtremum(m);
|
|
502
|
+
if (d.by !== undefined) flag("min", d.id, d.of, d.by, "groups");
|
|
503
|
+
}
|
|
504
|
+
for (const m of mod.maxes ?? []) {
|
|
505
|
+
const d = finishExtremum(m);
|
|
506
|
+
if (d.by !== undefined) flag("max", d.id, d.of, d.by, "groups");
|
|
507
|
+
}
|
|
508
|
+
for (const e of mod.exists_ ?? []) {
|
|
509
|
+
const d = finishExists(e);
|
|
510
|
+
if (d.by !== undefined) flag("exists", d.id, d.of, d.by, "groups");
|
|
511
|
+
}
|
|
512
|
+
for (const sp of mod.spatials ?? []) flag("spatial", sp.id, sp.of, sp.on, "keys");
|
|
513
|
+
|
|
514
|
+
// Twin 4b (defensive unify — normally pre-empted by aggregate()'s throw): an encrypted field on a
|
|
515
|
+
// non-private aggregate. Unreachable in practice (the aggregate never composes), kept for completeness.
|
|
516
|
+
for (const agg of mod.aggregates) {
|
|
517
|
+
if ((agg.encryptedFields?.length ?? 0) > 0 && agg.visibility === undefined) {
|
|
518
|
+
out.push({
|
|
519
|
+
domain,
|
|
520
|
+
level: "refuse",
|
|
521
|
+
what: `aggregate '${agg.id}' has .encrypted() field(s) ${(agg.encryptedFields ?? []).join(", ")} but is not private — the encryption scope IS the read scope`,
|
|
522
|
+
fix: `add { visibility: requires("<cap>") } to '${agg.id}' (or drop .encrypted())`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── TWIN 3 (slice 2) — INVARIANT SAMPLE-PROBE (aggregate + workspace invariants) ────
|
|
530
|
+
// An aggregate invariant (`aggregate(id, fields, { invariant })`) runs on every touching event; a
|
|
531
|
+
// workspaceInvariant(id).on(dir).reads(...).assert(...) runs at the gate. We probe each like a plan:
|
|
532
|
+
// synthesize a sample (the aggregate's fields for an aggregate invariant; the `on`-directive's payload
|
|
533
|
+
// + a synthesized snapshot per declared ref for a workspace invariant) and run the predicate. A THROW
|
|
534
|
+
// (not a `reject` verdict — a reject is legitimate law) is a predicate BUG ⇒ WARN naming the exact
|
|
535
|
+
// sample serialized. NEVER a REFUSE (sample-dependent — a different input may not throw, the hard rule).
|
|
536
|
+
|
|
537
|
+
/** Synthesize a plausible folded-snapshot for an aggregate from its stored fields' zod schemas.
|
|
538
|
+
* `undefined` when the shape is unsynthesizable (an exotic field) — the predicate can't be probed. */
|
|
539
|
+
function synthesizeSnapshot(agg: AggregateHandle, where: string): Record<string, unknown> | undefined {
|
|
540
|
+
try {
|
|
541
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
542
|
+
for (const [name, f] of Object.entries(agg.fields)) shape[name] = (f as Field).zod as z.ZodTypeAny;
|
|
543
|
+
return synthesizePayload(z.object(shape), where).value as Record<string, unknown>;
|
|
544
|
+
} catch {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function invariantFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
|
|
550
|
+
const domain = mod.domain ?? mod.name;
|
|
551
|
+
const out: Finding[] = [];
|
|
552
|
+
|
|
553
|
+
// Aggregate invariants — probe the composed `invariant` fn on a synthesized snapshot.
|
|
554
|
+
for (const agg of mod.aggregates) {
|
|
555
|
+
if (typeof agg.invariant !== "function") continue;
|
|
556
|
+
const snap = synthesizeSnapshot(agg, `${domain}/${agg.id}#invariant`);
|
|
557
|
+
if (snap === undefined) continue; // unsynthesizable — proven by hand, not by check
|
|
558
|
+
try {
|
|
559
|
+
withBirthProbeGlobals(() => agg.invariant!(snap));
|
|
560
|
+
} catch (e) {
|
|
561
|
+
out.push({
|
|
562
|
+
domain,
|
|
563
|
+
level: "warn",
|
|
564
|
+
what: `aggregate '${agg.id}' invariant THREW on the synthesized snapshot (${(e as Error)?.message ?? String(e)}) — a predicate bug, or a payload-dependent path the sample happened to hit`,
|
|
565
|
+
fix: `run the invariant on the snapshot ${samplePayloadText(snap)} — if the throw is real, every touching event refuses; if it's a shape the real data never has, guard the predicate`,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Workspace invariants — resolve the ref-set from a synthesized intent, synth a snapshot per ref,
|
|
571
|
+
// then run the predicate. A throw at either step (reads() or assert()) → WARN naming the input.
|
|
572
|
+
const dirById = new Map<string, Directive<unknown>>();
|
|
573
|
+
for (const d of mod.directives as Directive<unknown>[]) dirById.set(d.id, d);
|
|
574
|
+
for (const wi of mod.workspaceInvariants ?? []) {
|
|
575
|
+
// The `on` may be `<domain>.<Directive>` or a bare id — match the suffix.
|
|
576
|
+
const onId = wi.on.includes(".") ? wi.on.slice(wi.on.lastIndexOf(".") + 1) : wi.on;
|
|
577
|
+
const dir = dirById.get(wi.on) ?? dirById.get(onId);
|
|
578
|
+
let intent: Record<string, unknown> = {};
|
|
579
|
+
if (dir !== undefined) {
|
|
580
|
+
const probe = probeDirective(dir, `${domain}/${wi.id}#intent`);
|
|
581
|
+
if (probe !== undefined) intent = probe.probePayload;
|
|
582
|
+
}
|
|
583
|
+
const ctx = { intent, principal: "principal:probe", principalVerified: true } as const;
|
|
584
|
+
let threwAt: "reads" | "assert" | undefined;
|
|
585
|
+
let err: unknown;
|
|
586
|
+
let snapshots: Record<string, Record<string, unknown>> = {};
|
|
587
|
+
withBirthProbeGlobals(() => {
|
|
588
|
+
try {
|
|
589
|
+
const refs = wi.reads(ctx);
|
|
590
|
+
for (const r of refs) {
|
|
591
|
+
const targetAgg = aggById.get(r.aggregate);
|
|
592
|
+
const snap = targetAgg !== undefined ? synthesizeSnapshot(targetAgg, `${domain}/${wi.id}#${r.name}`) : undefined;
|
|
593
|
+
snapshots[r.name] = snap ?? {};
|
|
594
|
+
}
|
|
595
|
+
} catch (e) {
|
|
596
|
+
threwAt = "reads";
|
|
597
|
+
err = e;
|
|
598
|
+
}
|
|
599
|
+
if (threwAt === undefined) {
|
|
600
|
+
try {
|
|
601
|
+
wi.assert(snapshots, { ...ctx, reads: [] });
|
|
602
|
+
} catch (e) {
|
|
603
|
+
threwAt = "assert";
|
|
604
|
+
err = e;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
if (threwAt !== undefined) {
|
|
609
|
+
out.push({
|
|
610
|
+
domain,
|
|
611
|
+
directiveId: wi.on,
|
|
612
|
+
level: "warn",
|
|
613
|
+
what: `workspace invariant '${wi.id}' ${threwAt === "reads" ? "reads()" : "assert()"} THREW on the synthesized sample (${(err as Error)?.message ?? String(err)}) — a predicate bug, or a payload-dependent path the sample hit`,
|
|
614
|
+
fix: `run it on intent=${samplePayloadText(intent)} snapshots=${samplePayloadText(snapshots)} — if the throw is real, the gate refuses on '${wi.on}'; if the sample's shape never occurs, guard the predicate`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── TWIN 2 (slice 2) — TOP-LEVEL PORT-READ pool-eligibility WARN ───────────────────
|
|
622
|
+
// A tenant MODULE whose TOP LEVEL reads the captured ports (`Date.now()`/`new Date()`/`Math.random()`
|
|
623
|
+
// at module scope, not inside a plan) makes the built engine lump PRISTINE-POOL-INELIGIBLE
|
|
624
|
+
// (deterministic-rquickjs's "boot-reads hole"): the resident-sandbox pool can't reuse it, so EVERY
|
|
625
|
+
// dispatch pays a full boot. The tenant modules are imported at compose time — their top level runs
|
|
626
|
+
// in Node BEFORE any probe — so this is caught by INSTRUMENTING the import: {@link recordTopLevelPortReads}
|
|
627
|
+
// shims those three ambients to set a flag during the import and reports which module top levels read
|
|
628
|
+
// them (the honest, reliable mechanism — a cache-busted re-eval or a built-lump scan would be heavier
|
|
629
|
+
// and less faithful). `check_package_main` / `compile` wrap each tenant `import()` in it and pass the
|
|
630
|
+
// offending DOMAIN keys here; the DomainModule itself carries no source, so absent that wiring (unit
|
|
631
|
+
// tests) the twin is simply silent. WARN only — a boot cost, never a correctness fault.
|
|
632
|
+
|
|
633
|
+
/** True iff `run()` (a tenant-module import) READS the captured ports at top level. Shims `Date.now`/
|
|
634
|
+
* the no-arg `Date` ctor / `Math.random` to set a flag, then restores them. Call it AROUND each tenant
|
|
635
|
+
* `await import(...)` in the check/compile lane; map a true result to the domain key and feed it to
|
|
636
|
+
* {@link checkDomainModules}'s `portReadingDomains`. (Exported so the module-load lane can wrap it with
|
|
637
|
+
* ONE additive call — no restructuring of the loader.) */
|
|
638
|
+
export async function recordTopLevelPortReads<T>(run: () => Promise<T>): Promise<{ value: T; read: boolean }> {
|
|
639
|
+
let read = false;
|
|
640
|
+
const realNow = Date.now;
|
|
641
|
+
const realRandom = Math.random;
|
|
642
|
+
const RealDate = Date;
|
|
643
|
+
const ShimDate = function (this: unknown, ...args: unknown[]): unknown {
|
|
644
|
+
if (args.length === 0) read = true; // no-arg `new Date()` reads the wall clock == a port read
|
|
645
|
+
// Delegate to the real Date so the module still evaluates correctly.
|
|
646
|
+
return new (RealDate as unknown as new (...a: unknown[]) => object)(...args);
|
|
647
|
+
} as unknown as DateConstructor;
|
|
648
|
+
ShimDate.now = () => {
|
|
649
|
+
read = true;
|
|
650
|
+
return realNow.call(Date);
|
|
651
|
+
};
|
|
652
|
+
ShimDate.parse = RealDate.parse;
|
|
653
|
+
ShimDate.UTC = RealDate.UTC;
|
|
654
|
+
// `new ShimDate(...)` delegates to RealDate, so instances already carry RealDate.prototype; no
|
|
655
|
+
// need to reassign the (read-only) `prototype` slot on the function object.
|
|
656
|
+
try {
|
|
657
|
+
// eslint-disable-next-line no-global-assign
|
|
658
|
+
(globalThis as { Date: DateConstructor }).Date = ShimDate;
|
|
659
|
+
Date.now = () => {
|
|
660
|
+
read = true;
|
|
661
|
+
return realNow.call(RealDate);
|
|
662
|
+
};
|
|
663
|
+
Math.random = () => {
|
|
664
|
+
read = true;
|
|
665
|
+
return realRandom.call(Math);
|
|
666
|
+
};
|
|
667
|
+
const value = await run();
|
|
668
|
+
return { value, read };
|
|
669
|
+
} finally {
|
|
670
|
+
(globalThis as { Date: DateConstructor }).Date = RealDate;
|
|
671
|
+
Date.now = realNow;
|
|
672
|
+
Math.random = realRandom;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function topLevelPortReadFindings(mod: DomainModule, portReadingDomains: ReadonlySet<string>): Finding[] {
|
|
677
|
+
const domain = mod.domain ?? mod.name;
|
|
678
|
+
if (!portReadingDomains.has(domain) && !portReadingDomains.has(mod.name)) return [];
|
|
679
|
+
return [
|
|
680
|
+
{
|
|
681
|
+
domain,
|
|
682
|
+
level: "warn",
|
|
683
|
+
what: `this module's TOP LEVEL reads the captured ports (Date.now()/new Date()/Math.random() at module scope) — the built law loses sandbox POOLING (the pristine-pool "boot-reads hole"), so every write pays a full boot`,
|
|
684
|
+
fix: `move the read INSIDE the plan (a plan is where the captured ports belong); a module-level constant must not read the clock/rng`,
|
|
685
|
+
},
|
|
686
|
+
];
|
|
687
|
+
}
|
|
688
|
+
|
|
301
689
|
/**
|
|
302
690
|
* Run every author-time twin over the composed domain modules and return the findings, ordered
|
|
303
691
|
* refuse → warn → note (stable within a level, by domain/directive appearance). This is the ONE
|
|
304
|
-
* entry `githolon check` and `compile`'s summary both call.
|
|
692
|
+
* entry `githolon check` and `compile`'s summary both call. `opts.portReadingDomains` (optional) is
|
|
693
|
+
* the set of domain keys whose module top level read the captured ports at import — collected by the
|
|
694
|
+
* module-load lane via {@link recordTopLevelPortReads}; absent ⇒ the top-level-port-read twin is silent.
|
|
305
695
|
*/
|
|
306
|
-
export function checkDomainModules(
|
|
696
|
+
export function checkDomainModules(
|
|
697
|
+
modules: readonly DomainModule[],
|
|
698
|
+
opts?: { portReadingDomains?: ReadonlySet<string> },
|
|
699
|
+
): Finding[] {
|
|
307
700
|
const findings: Finding[] = [];
|
|
701
|
+
const portReadingDomains = opts?.portReadingDomains ?? new Set<string>();
|
|
308
702
|
|
|
309
703
|
for (const mod of modules) {
|
|
310
704
|
const domain = mod.domain ?? mod.name;
|
|
311
705
|
const aggById = new Map<string, AggregateHandle>();
|
|
312
706
|
for (const a of mod.aggregates) aggById.set(a.id, a);
|
|
313
707
|
|
|
708
|
+
// Slice-2 twin: the module top level read the captured ports (pristine-pool ineligibility).
|
|
709
|
+
findings.push(...topLevelPortReadFindings(mod, portReadingDomains));
|
|
710
|
+
|
|
314
711
|
// The NOTE twin (unify): the auto-declared secondary ensures composeDomainModule injected.
|
|
315
712
|
for (const ty of mod.autoDeclaredEnsures ?? []) {
|
|
316
713
|
findings.push({
|
|
@@ -328,7 +725,12 @@ export function checkDomainModules(modules: readonly DomainModule[]): Finding[]
|
|
|
328
725
|
findings.push(...planReturnFindings(domain, dir, probe));
|
|
329
726
|
findings.push(...unreadPayloadFindings(domain, dir, probe));
|
|
330
727
|
findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
|
|
728
|
+
findings.push(...deletedAmbientFindings(domain, dir, probe));
|
|
331
729
|
}
|
|
730
|
+
|
|
731
|
+
// Slice-2/slice-3 twins over the whole module: invariant sample-probes + encryption grouping.
|
|
732
|
+
findings.push(...invariantFindings(mod, aggById));
|
|
733
|
+
findings.push(...encryptionGroupingFindings(mod, aggById));
|
|
332
734
|
}
|
|
333
735
|
|
|
334
736
|
// The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SOURCE MAPPING for `githolon check` findings (architecture/author_time_gate.md, slice 3 — the LSP).
|
|
8
|
+
*
|
|
9
|
+
* A {@link Finding} carries `{domain, directiveId?}` but no source LOCATION — the probe knows WHICH
|
|
10
|
+
* directive/aggregate/read is at fault, not WHERE it is written. This module recovers the location by
|
|
11
|
+
* scanning the domain's own TS module source text for the DECLARATION of the named entity:
|
|
12
|
+
* `directive("<id>")`, `aggregate("<Id>", …)`, `query("<id>")`, `count("<id>")`, `sum(...)`, … — a
|
|
13
|
+
* regex on the STRING LITERAL, robust across formatting (multi-line, spacing, quote style).
|
|
14
|
+
*
|
|
15
|
+
* The id in a finding may name a directive, a read decl (query/count/sum/min/max/exists/spatial), a
|
|
16
|
+
* workspace invariant, or — for a domain-wide finding with no `directiveId` — an aggregate. We do NOT
|
|
17
|
+
* know which kind, so we scan for the id as the FIRST string argument of ANY declaration call. When
|
|
18
|
+
* nothing resolves (a domain-wide finding, an id we can't find, an exotic layout) we fall back to
|
|
19
|
+
* line 1 of the domain's FIRST module — a squiggle at the top of the file beats no squiggle.
|
|
20
|
+
*
|
|
21
|
+
* Pure + synchronous: the caller supplies the domain→module-file-paths map (the config knows it — the
|
|
22
|
+
* composed DomainModule does not retain source paths, so the check/LSP lane threads them through) and
|
|
23
|
+
* the file READS are memoized per source map instance.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
/** A resolved 1-based source location (LSP-friendly: `line`/`column` are 1-based, editors 0-base them). */
|
|
28
|
+
export interface SourceLocation {
|
|
29
|
+
/** Absolute path of the module file the declaration lives in. */
|
|
30
|
+
readonly file: string;
|
|
31
|
+
/** 1-based line of the declaration's id string literal (or 1 on fallback). */
|
|
32
|
+
readonly line: number;
|
|
33
|
+
/** 1-based column of the id string literal's opening quote (or 1 on fallback). */
|
|
34
|
+
readonly column: number;
|
|
35
|
+
/** True when this is the top-of-file fallback (the id could not be located precisely). */
|
|
36
|
+
readonly fallback: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The declaration VERBS whose first string argument names an entity a finding can point at. */
|
|
40
|
+
const DECL_VERBS = [
|
|
41
|
+
"directive",
|
|
42
|
+
"aggregate",
|
|
43
|
+
"query",
|
|
44
|
+
"count",
|
|
45
|
+
"sum",
|
|
46
|
+
"min",
|
|
47
|
+
"max",
|
|
48
|
+
"exists",
|
|
49
|
+
"spatial",
|
|
50
|
+
"workspaceInvariant",
|
|
51
|
+
"valueObject",
|
|
52
|
+
"derived",
|
|
53
|
+
"combined",
|
|
54
|
+
"first",
|
|
55
|
+
"take",
|
|
56
|
+
"relation",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
/** Escape a string for literal use inside a RegExp. */
|
|
60
|
+
function escapeRe(s: string): string {
|
|
61
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compute the 1-based {line, column} of a character offset in `text` (column = chars into the line + 1). */
|
|
65
|
+
function offsetToLineCol(text: string, offset: number): { line: number; column: number } {
|
|
66
|
+
let line = 1;
|
|
67
|
+
let lineStart = 0;
|
|
68
|
+
for (let i = 0; i < offset; i++) {
|
|
69
|
+
if (text.charCodeAt(i) === 10 /* \n */) {
|
|
70
|
+
line++;
|
|
71
|
+
lineStart = i + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { line, column: offset - lineStart + 1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A source map over a single domain's module files. Reads + memoizes the source text lazily and
|
|
79
|
+
* resolves an entity id (or an aggregate/whole-domain fallback) to a {@link SourceLocation}.
|
|
80
|
+
*/
|
|
81
|
+
export class DomainSourceMap {
|
|
82
|
+
private readonly texts = new Map<string, string | null>();
|
|
83
|
+
|
|
84
|
+
/** `files` — the ORDERED module paths composing the domain (config order; first is the fallback anchor). */
|
|
85
|
+
constructor(private readonly files: readonly string[]) {}
|
|
86
|
+
|
|
87
|
+
private text(file: string): string | null {
|
|
88
|
+
if (!this.texts.has(file)) {
|
|
89
|
+
try {
|
|
90
|
+
this.texts.set(file, readFileSync(file, "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
this.texts.set(file, null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return this.texts.get(file) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The top-of-file fallback: line 1, column 1 of the domain's first readable module. */
|
|
99
|
+
private fallbackLocation(): SourceLocation | undefined {
|
|
100
|
+
const file = this.files[0];
|
|
101
|
+
if (file === undefined) return undefined;
|
|
102
|
+
return { file, line: 1, column: 1, fallback: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Locate the declaration of `id` across the domain's modules — the first `<verb>("<id>"…)` /
|
|
107
|
+
* `<verb>('<id>'…)` / <verb>(`<id>`…) match, scanned in config module order. Falls back to the top
|
|
108
|
+
* of the first module when `id` is undefined (a domain-wide finding) or cannot be located.
|
|
109
|
+
*/
|
|
110
|
+
locate(id: string | undefined): SourceLocation | undefined {
|
|
111
|
+
if (id !== undefined) {
|
|
112
|
+
const idRe = escapeRe(id);
|
|
113
|
+
// <verb> ( <optional ws> <quote> id <same quote> — the id as the first string arg of a decl call.
|
|
114
|
+
const verbs = DECL_VERBS.join("|");
|
|
115
|
+
const re = new RegExp(`\\b(?:${verbs})\\s*\\(\\s*(["'\`])${idRe}\\1`, "g");
|
|
116
|
+
for (const file of this.files) {
|
|
117
|
+
const text = this.text(file);
|
|
118
|
+
if (text === null) continue;
|
|
119
|
+
const m = re.exec(text);
|
|
120
|
+
if (m !== null) {
|
|
121
|
+
// Column points at the opening QUOTE of the id literal (m.index is at the verb).
|
|
122
|
+
const quoteOffset = m.index + m[0].length - id.length - 1;
|
|
123
|
+
const { line, column } = offsetToLineCol(text, quoteOffset);
|
|
124
|
+
return { file, line, column, fallback: false };
|
|
125
|
+
}
|
|
126
|
+
re.lastIndex = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this.fallbackLocation();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A source map over a whole package — one {@link DomainSourceMap} per domain key. */
|
|
134
|
+
export class LawSourceMap {
|
|
135
|
+
private readonly perDomain = new Map<string, DomainSourceMap>();
|
|
136
|
+
|
|
137
|
+
/** `domainFiles` — domain key → its ORDERED module file paths (absolute), as the config declares them. */
|
|
138
|
+
constructor(domainFiles: ReadonlyMap<string, readonly string[]>) {
|
|
139
|
+
for (const [domain, files] of domainFiles) this.perDomain.set(domain, new DomainSourceMap([...files]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve a finding's `{domain, directiveId?}` to a source location, or `undefined` when unmappable. */
|
|
143
|
+
locate(domain: string, id?: string): SourceLocation | undefined {
|
|
144
|
+
return this.perDomain.get(domain)?.locate(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/lsp_main.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `githolon lsp` (stdio) — the language-server entry. Starts the author-time-gate server
|
|
8
|
+
* ({@link startServer}); it speaks LSP over stdin/stdout to any editor (VS Code, Neovim, …).
|
|
9
|
+
* Run under tsx (the launcher `lsp_server.mjs`) so it can import the tenant's TS domain modules
|
|
10
|
+
* in-process. See architecture/author_time_gate.md (slice 3).
|
|
11
|
+
*/
|
|
12
|
+
import { startServer } from "./lsp_server.js";
|
|
13
|
+
|
|
14
|
+
startServer();
|