@githolon/dsl 0.49.0 → 0.51.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/check_package.mjs +46 -0
- package/dart/example/nomos_secure_key_store.dart +82 -0
- package/dart/lib/src/webview_bridge.dart +442 -6
- package/package.json +3 -2
- package/src/check_package_main.ts +194 -0
- package/src/codegen_dart_client.ts +124 -8
- package/src/codegen_proof.ts +1 -1
- package/src/codegen_ts.ts +470 -14
- package/src/compile_package_main.ts +19 -0
- package/src/evolve_preview.ts +466 -0
- package/src/law_check.ts +777 -0
package/src/law_check.ts
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
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
|
|
4
|
+
// QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE AUTHOR-TIME GATE — `githolon check` (architecture/author_time_gate.md, slice 1).
|
|
8
|
+
*
|
|
9
|
+
* Every DX bug the tenant teams reported was the same disease: the kernel gate refused
|
|
10
|
+
* something at RUNTIME that was STATICALLY VISIBLE at compile. The cure is to pre-fire
|
|
11
|
+
* the refusal at author time, with the FIX inside the message. This module is the
|
|
12
|
+
* unified surface: it runs the STATIC TWINS of the gate's refusal vocabulary over the
|
|
13
|
+
* composed domain modules and returns typed {@link Finding}s.
|
|
14
|
+
*
|
|
15
|
+
* Three verdict levels:
|
|
16
|
+
* - **refuse** — the fault is PAYLOAD-INDEPENDENT (return shape, schema/driver mismatch),
|
|
17
|
+
* so the runtime gate WILL refuse it. `githolon check` exits non-zero; `compile` refuses.
|
|
18
|
+
* - **warn** — the probe SAW something suspicious but can't PROVE it (a plan that threw on
|
|
19
|
+
* the one synthesized sample — a conditional/payload-dependent path). Names the payload.
|
|
20
|
+
* - **note** — an auto-repair was applied (e.g. the auto-declared secondary ensure).
|
|
21
|
+
*
|
|
22
|
+
* FALSE-POSITIVE DISCIPLINE (the hard rule, from the design doc): the probe runs ONE
|
|
23
|
+
* synthesized payload — a conditional branch it doesn't take proves nothing. A twin may
|
|
24
|
+
* REFUSE only when the fault is payload-independent; everything else is a WARN naming the
|
|
25
|
+
* payload that exhibited it. The runtime gate remains the arbiter; `check` never claims
|
|
26
|
+
* completeness, only that what it flags is real.
|
|
27
|
+
*/
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
|
|
30
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
31
|
+
import type { Directive } from "./directive.js";
|
|
32
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
33
|
+
import type { Field } from "./fields.js";
|
|
34
|
+
import type { FieldOp, PlannedOp } from "./ops.js";
|
|
35
|
+
import { deterministicPorts } from "./ctx.js";
|
|
36
|
+
import { synthesizePayload, withBirthProbeGlobals, type SynthesizedPayload } from "./codegen_proof.js";
|
|
37
|
+
import { __drainAuthoring, __resetAuthoring } from "./authoring.js";
|
|
38
|
+
import { __drainReads, __resetReads } from "./read.js";
|
|
39
|
+
import { __drainBirths, __resetBirths } from "./births.js";
|
|
40
|
+
import { proofLegsIndex } from "./codegen_proof.js";
|
|
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";
|
|
46
|
+
|
|
47
|
+
/** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
|
|
48
|
+
export interface Finding {
|
|
49
|
+
/** The domain key the directive lives in. */
|
|
50
|
+
readonly domain: string;
|
|
51
|
+
/** The directive the finding is about (absent for a domain-wide note). */
|
|
52
|
+
readonly directiveId?: string;
|
|
53
|
+
/** `refuse` — provably refuses at the gate · `warn` — suspicious, can't prove · `note` — auto-repair. */
|
|
54
|
+
readonly level: "refuse" | "warn" | "note";
|
|
55
|
+
/** What is wrong (or, for a note, what was auto-repaired). */
|
|
56
|
+
readonly what: string;
|
|
57
|
+
/** How to fix it (or, for a note, how the repair keeps the law valid). */
|
|
58
|
+
readonly fix: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Payload field names the FRAMEWORK consumes off the intent (never the plan) — excluded from the
|
|
62
|
+
* unread-payload-field warn so a birth/timestamp convention field is never falsely flagged as dead.
|
|
63
|
+
* Sources: `autoStampFields` (the `…At` clock convention) + `birthMeterSubject`/`birthMeterBornAt`. */
|
|
64
|
+
const FRAMEWORK_CONSUMED_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
65
|
+
"principal",
|
|
66
|
+
"owner",
|
|
67
|
+
"ownerSubject",
|
|
68
|
+
"bornAt",
|
|
69
|
+
"requestedAt",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** The outcome of running one directive's plan on its synthesized sample under the probe globals. */
|
|
73
|
+
interface ProbeResult {
|
|
74
|
+
readonly synth: SynthesizedPayload;
|
|
75
|
+
/** The stamped-and-filled sample the plan was run with (what a WARN names). */
|
|
76
|
+
readonly probePayload: Record<string, unknown>;
|
|
77
|
+
/** The plan's RETURN value (whatever it was — checked for array-ness by the return twin). */
|
|
78
|
+
readonly returned: unknown;
|
|
79
|
+
/** True when the plan THREW on the sample (payload-dependent — everything degrades to a WARN). */
|
|
80
|
+
readonly threw: boolean;
|
|
81
|
+
readonly error?: unknown;
|
|
82
|
+
/** The FIELD ops the plan emitted (authoring sink + returned ops), when it didn't throw. */
|
|
83
|
+
readonly ops: FieldOp[];
|
|
84
|
+
/** The top-level payload keys the plan actually READ (via the tracking proxy). */
|
|
85
|
+
readonly readFields: ReadonlySet<string>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run a directive's plan ONCE on a tracking-proxy sample under the birth-probe globals, capturing the
|
|
89
|
+
* return value, whether it threw, the emitted field ops, and which payload fields it read. `undefined`
|
|
90
|
+
* ⇒ the payload is unsynthesizable (an exotic zod shape) — the directive can't be probed at all. */
|
|
91
|
+
function probeDirective(dir: Directive<unknown>, where: string): ProbeResult | undefined {
|
|
92
|
+
let synth: SynthesizedPayload;
|
|
93
|
+
try {
|
|
94
|
+
synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
// Build the sample: required non-stamped fields from the synth, plus the auto-stamped `…At` fields
|
|
99
|
+
// filled with a fixed ISO stamp (the client stamps them at dispatch; the plan may read them here).
|
|
100
|
+
const probePayload: Record<string, unknown> = { ...synth.value };
|
|
101
|
+
for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
|
|
102
|
+
|
|
103
|
+
const shapeKeys = new Set(Object.keys(safeShape(dir.payloadSchema as z.ZodTypeAny)));
|
|
104
|
+
const readFields = new Set<string>();
|
|
105
|
+
const tracked = new Proxy(probePayload, {
|
|
106
|
+
get(target, prop, recv) {
|
|
107
|
+
if (typeof prop === "string" && shapeKeys.has(prop)) readFields.add(prop);
|
|
108
|
+
return Reflect.get(target, prop, recv);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let returned: unknown;
|
|
113
|
+
let threw = false;
|
|
114
|
+
let error: unknown;
|
|
115
|
+
let ops: FieldOp[] = [];
|
|
116
|
+
withBirthProbeGlobals(() => {
|
|
117
|
+
__resetAuthoring();
|
|
118
|
+
__resetReads();
|
|
119
|
+
__resetBirths();
|
|
120
|
+
try {
|
|
121
|
+
returned = dir.plan(tracked as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
|
|
122
|
+
const drained: PlannedOp[] = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
|
|
123
|
+
ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
|
|
124
|
+
} catch (e) {
|
|
125
|
+
threw = true;
|
|
126
|
+
error = e;
|
|
127
|
+
} finally {
|
|
128
|
+
// Drain the sinks on every path so a throwing probe never leaks state into the next.
|
|
129
|
+
try { __drainAuthoring(); } catch { /* drained */ }
|
|
130
|
+
try { __drainReads(); } catch { /* drained */ }
|
|
131
|
+
try { __drainBirths(); } catch { /* drained */ }
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return { synth, probePayload, returned, threw, error, ops, readFields };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `zodObjectShape` with a swallow — a non-object payload yields `{}` (the return twin handles it). */
|
|
138
|
+
function safeShape(schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
|
|
139
|
+
try {
|
|
140
|
+
if (zodKind(schema) !== "object") return {};
|
|
141
|
+
return zodObjectShape(schema);
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The REQUIRED top-level payload fields (optional/defaulted omitted — an unread optional proves nothing). */
|
|
148
|
+
function requiredPayloadFields(schema: z.ZodTypeAny): string[] {
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const [name, f] of Object.entries(safeShape(schema))) {
|
|
151
|
+
const k = zodKind(f);
|
|
152
|
+
if (k === "optional" || k === "default") continue;
|
|
153
|
+
out.push(name);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A short, safe render of the sample payload a WARN names (bounded so a huge payload doesn't flood). */
|
|
159
|
+
function samplePayloadText(payload: Record<string, unknown>): string {
|
|
160
|
+
let s: string;
|
|
161
|
+
try {
|
|
162
|
+
s = JSON.stringify(payload);
|
|
163
|
+
} catch {
|
|
164
|
+
s = "<unserializable>";
|
|
165
|
+
}
|
|
166
|
+
return s.length > 200 ? `${s.slice(0, 197)}…` : s;
|
|
167
|
+
}
|
|
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
|
+
|
|
327
|
+
// ── TWIN A — the UNIVERSAL plan-return probe ──────────────────────────────────────
|
|
328
|
+
// The engine assembles a plan's output with `[...returned]` (wire_encode.ts). A plan whose
|
|
329
|
+
// `plan` returns a NON-ITERABLE (undefined — a forgotten `return`; a builder — a forgotten
|
|
330
|
+
// `return []` after `create(...).set(...)`; a plain object) throws "not iterable" at the FIRST
|
|
331
|
+
// dispatch on every peer. This is payload-INDEPENDENT (the return TYPE is fixed) ⇒ REFUSE.
|
|
332
|
+
// A plan that THREW on the sample is payload-DEPENDENT (can't prove) ⇒ WARN naming the sample.
|
|
333
|
+
function planReturnFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
|
|
334
|
+
if (probe.threw) {
|
|
335
|
+
return [
|
|
336
|
+
{
|
|
337
|
+
domain,
|
|
338
|
+
directiveId: dir.id,
|
|
339
|
+
level: "warn",
|
|
340
|
+
what: `the plan THREW on the synthesized sample (${(probe.error as Error)?.message ?? String(probe.error)}) — can't prove the return shape`,
|
|
341
|
+
fix: `run it on the sample ${samplePayloadText(probe.probePayload)} — if the throw is real, the runtime gate refuses; if it's a payload-dependent branch, ignore`,
|
|
342
|
+
},
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
if (!Array.isArray(probe.returned)) {
|
|
346
|
+
const got =
|
|
347
|
+
probe.returned === undefined
|
|
348
|
+
? "undefined (a missing `return`)"
|
|
349
|
+
: typeof probe.returned === "object" && probe.returned !== null
|
|
350
|
+
? "an object/builder (a forgotten `return []`)"
|
|
351
|
+
: `a ${typeof probe.returned}`;
|
|
352
|
+
return [
|
|
353
|
+
{
|
|
354
|
+
domain,
|
|
355
|
+
directiveId: dir.id,
|
|
356
|
+
level: "refuse",
|
|
357
|
+
what: `the plan returns ${got}, not an array of ops — the engine assembles plan output with [...returned] and would throw "not iterable" at the first dispatch`,
|
|
358
|
+
fix: `return an array of ops — the fluent surface (create/set/add) records ops as a side-effect, so end the plan with \`return [];\` (or return the typed ops directly)`,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── TWIN B — the UNREAD-PAYLOAD-FIELD warn ────────────────────────────────────────
|
|
366
|
+
// A REQUIRED payload field the plan never reads on the sample path is usually a bug (dead field
|
|
367
|
+
// or missing logic). WARN only (a conditional read the sample didn't reach proves nothing), and
|
|
368
|
+
// only for a plan that ran cleanly (a throwing plan read nothing — twin A already flagged it).
|
|
369
|
+
// Framework-consumed convention fields (auto-stamped `…At`, birth subject/time) are excluded.
|
|
370
|
+
function unreadPayloadFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
|
|
371
|
+
if (probe.threw) return [];
|
|
372
|
+
const stamped = new Set(autoStampFields(dir.payloadSchema as z.ZodTypeAny));
|
|
373
|
+
// The GATE reads some payload fields the PLAN never does: `.requires(rel, { objectFrom })` derives
|
|
374
|
+
// the gate object from a named payload id field (per-record authz). Excluding it keeps a real
|
|
375
|
+
// gate-consumed field from being flagged dead (the todo-pro `shareTodo.listId` case).
|
|
376
|
+
const gateConsumed = new Set<string>();
|
|
377
|
+
const objectFromField = (dir as { requiresRelationGate?: { objectFrom?: { idField?: string } } })
|
|
378
|
+
.requiresRelationGate?.objectFrom?.idField;
|
|
379
|
+
if (typeof objectFromField === "string") gateConsumed.add(objectFromField);
|
|
380
|
+
const out: Finding[] = [];
|
|
381
|
+
for (const f of requiredPayloadFields(dir.payloadSchema as z.ZodTypeAny)) {
|
|
382
|
+
if (probe.readFields.has(f)) continue;
|
|
383
|
+
if (stamped.has(f)) continue; // the client stamps it; the plan needn't read it
|
|
384
|
+
if (FRAMEWORK_CONSUMED_PAYLOAD_FIELDS.has(f)) continue; // the meter reads it off the intent
|
|
385
|
+
if (gateConsumed.has(f)) continue; // the authz gate derives its object from it
|
|
386
|
+
out.push({
|
|
387
|
+
domain,
|
|
388
|
+
directiveId: dir.id,
|
|
389
|
+
level: "warn",
|
|
390
|
+
what: `payload field '${f}' is never read by the plan (on the sampled path) — dead field or missing logic?`,
|
|
391
|
+
fix: `read it in the plan (e.g. \`.set("${f}", p.${f})\`), make it \`.optional()\` if it is genuinely optional, or drop it from the payload`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── TWIN C — the OP-VS-SCHEMA / driver twin ───────────────────────────────────────
|
|
398
|
+
// Walk the probed ops against the target aggregate's schema. An op on a field the aggregate
|
|
399
|
+
// does NOT declare is a hard mismatch (REFUSE, naming the real fields). A `set` on an additive
|
|
400
|
+
// set (AddWins/RemoveWins) OVERWRITES the CRDT instead of merging — REFUSE naming addToSet/
|
|
401
|
+
// removeFromSet. A `setEntry` on a non-map field is likewise a driver mismatch. Conservative:
|
|
402
|
+
// only ops targeting an aggregate THIS domain declares are judged (a foreign/framework tuple is
|
|
403
|
+
// left to the runtime gate); every refusal here is a combination the engine provably rejects.
|
|
404
|
+
function opVsSchemaFindings(
|
|
405
|
+
domain: string,
|
|
406
|
+
dir: Directive<unknown>,
|
|
407
|
+
probe: ProbeResult,
|
|
408
|
+
aggById: ReadonlyMap<string, AggregateHandle>,
|
|
409
|
+
): Finding[] {
|
|
410
|
+
if (probe.threw) return [];
|
|
411
|
+
const out: Finding[] = [];
|
|
412
|
+
const seen = new Set<string>(); // dedupe identical (agg, field, kind) findings
|
|
413
|
+
for (const op of probe.ops) {
|
|
414
|
+
const aggId = op.aggregateType;
|
|
415
|
+
if (typeof aggId !== "string") continue;
|
|
416
|
+
const agg = aggById.get(aggId);
|
|
417
|
+
if (agg === undefined) continue; // foreign / framework aggregate — the runtime gate arbitrates
|
|
418
|
+
const key = `${aggId}.${op.field}.${op.kind}`;
|
|
419
|
+
if (seen.has(key)) continue;
|
|
420
|
+
seen.add(key);
|
|
421
|
+
const field = agg.fields[op.field] as Field | undefined;
|
|
422
|
+
if (field === undefined) {
|
|
423
|
+
const real = Object.keys(agg.fields).sort();
|
|
424
|
+
out.push({
|
|
425
|
+
domain,
|
|
426
|
+
directiveId: dir.id,
|
|
427
|
+
level: "refuse",
|
|
428
|
+
what: `the plan writes '${op.field}' on '${aggId}', which is not a declared field of that aggregate`,
|
|
429
|
+
fix: `write one of ${aggId}'s fields — ${real.length ? real.join(", ") : "(none)"} — or add '${op.field}' to the aggregate`,
|
|
430
|
+
});
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const driverKind = (field.driver as { kind?: string }).kind;
|
|
434
|
+
if (op.kind === "set" && driverKind === "AddWins") {
|
|
435
|
+
out.push({
|
|
436
|
+
domain,
|
|
437
|
+
directiveId: dir.id,
|
|
438
|
+
level: "refuse",
|
|
439
|
+
what: `the plan \`set\`s '${op.field}' on '${aggId}', an AddWins set — set() OVERWRITES the merge (concurrent adds would be lost)`,
|
|
440
|
+
fix: `use \`addToSet(instance(${aggId}, id), "${op.field}", values)\` — the additive write that commutes`,
|
|
441
|
+
});
|
|
442
|
+
} else if (op.kind === "set" && driverKind === "RemoveWins") {
|
|
443
|
+
out.push({
|
|
444
|
+
domain,
|
|
445
|
+
directiveId: dir.id,
|
|
446
|
+
level: "refuse",
|
|
447
|
+
what: `the plan \`set\`s '${op.field}' on '${aggId}', a RemoveWins set — set() OVERWRITES the 2P-set CRDT`,
|
|
448
|
+
fix: `use \`addToSet(...)\` / \`removeFromSet(instance(${aggId}, id), "${op.field}", values)\` — the additive/tombstone writes`,
|
|
449
|
+
});
|
|
450
|
+
} else if (op.kind === "setEntry" && field.kind !== "map") {
|
|
451
|
+
out.push({
|
|
452
|
+
domain,
|
|
453
|
+
directiveId: dir.id,
|
|
454
|
+
level: "refuse",
|
|
455
|
+
what: `the plan \`setEntry\`s '${op.field}' on '${aggId}', a '${field.kind}' field — setEntry targets a map (MapOf) field only`,
|
|
456
|
+
fix: `write a t.map(...) field with setEntry, or use \`set(...)\` for the '${field.kind}' field '${op.field}'`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
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
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Run every author-time twin over the composed domain modules and return the findings, ordered
|
|
691
|
+
* refuse → warn → note (stable within a level, by domain/directive appearance). This is the ONE
|
|
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.
|
|
695
|
+
*/
|
|
696
|
+
export function checkDomainModules(
|
|
697
|
+
modules: readonly DomainModule[],
|
|
698
|
+
opts?: { portReadingDomains?: ReadonlySet<string> },
|
|
699
|
+
): Finding[] {
|
|
700
|
+
const findings: Finding[] = [];
|
|
701
|
+
const portReadingDomains = opts?.portReadingDomains ?? new Set<string>();
|
|
702
|
+
|
|
703
|
+
for (const mod of modules) {
|
|
704
|
+
const domain = mod.domain ?? mod.name;
|
|
705
|
+
const aggById = new Map<string, AggregateHandle>();
|
|
706
|
+
for (const a of mod.aggregates) aggById.set(a.id, a);
|
|
707
|
+
|
|
708
|
+
// Slice-2 twin: the module top level read the captured ports (pristine-pool ineligibility).
|
|
709
|
+
findings.push(...topLevelPortReadFindings(mod, portReadingDomains));
|
|
710
|
+
|
|
711
|
+
// The NOTE twin (unify): the auto-declared secondary ensures composeDomainModule injected.
|
|
712
|
+
for (const ty of mod.autoDeclaredEnsures ?? []) {
|
|
713
|
+
findings.push({
|
|
714
|
+
domain,
|
|
715
|
+
level: "note",
|
|
716
|
+
what: `auto-declared Ensure→${ty} from a plan's secondary ensure(...) — the id-mint gate was opened for it`,
|
|
717
|
+
fix: `no action needed; to make it explicit, export a directive that \`.ensures(${ty})\``,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
for (const dir of mod.directives as Directive<unknown>[]) {
|
|
722
|
+
const where = `${domain}/${dir.id}`;
|
|
723
|
+
const probe = probeDirective(dir, where);
|
|
724
|
+
if (probe === undefined) continue; // unsynthesizable payload — proven by hand, not by check
|
|
725
|
+
findings.push(...planReturnFindings(domain, dir, probe));
|
|
726
|
+
findings.push(...unreadPayloadFindings(domain, dir, probe));
|
|
727
|
+
findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
|
|
728
|
+
findings.push(...deletedAmbientFindings(domain, dir, probe));
|
|
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));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
|
|
737
|
+
// one report as WARNs (a discoverable-but-suspect recipe — missing seed, uncomposed child domain).
|
|
738
|
+
try {
|
|
739
|
+
const idx = proofLegsIndex(modules as DomainModule[]);
|
|
740
|
+
if (idx !== null) {
|
|
741
|
+
for (const [domain, legs] of Object.entries(idx.domains)) {
|
|
742
|
+
for (const diag of legs.birthDiagnostics ?? []) {
|
|
743
|
+
findings.push({
|
|
744
|
+
domain,
|
|
745
|
+
level: "warn",
|
|
746
|
+
what: `birth recipe: ${diag}`,
|
|
747
|
+
fix: `see architecture/g3_kernel_fulfils_birth.md / the births DX — the recipe must install both framework + child law and seed genesis`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
} catch {
|
|
753
|
+
/* birth discovery is best-effort — its absence never blocks the check */
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const rank = { refuse: 0, warn: 1, note: 2 } as const;
|
|
757
|
+
return findings
|
|
758
|
+
.map((f, i) => ({ f, i }))
|
|
759
|
+
.sort((a, b) => rank[a.f.level] - rank[b.f.level] || a.i - b.i)
|
|
760
|
+
.map(({ f }) => f);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Does the finding set carry a hard refusal (⇒ `githolon check` exits non-zero, `compile` refuses)? */
|
|
764
|
+
export function hasRefusal(findings: readonly Finding[]): boolean {
|
|
765
|
+
return findings.some((f) => f.level === "refuse");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Format one finding as the unified report line: `<domain>/<directive>: <LEVEL> — <what> — fix: <fix>`. */
|
|
769
|
+
export function formatFinding(f: Finding): string {
|
|
770
|
+
const subject = f.directiveId !== undefined ? `${f.domain}/${f.directiveId}` : f.domain;
|
|
771
|
+
return `${subject}: ${f.level.toUpperCase()} — ${f.what} — fix: ${f.fix}`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The full `check` report body (one line per finding), ordered refuse → warn → note. */
|
|
775
|
+
export function formatFindings(findings: readonly Finding[]): string[] {
|
|
776
|
+
return findings.map(formatFinding);
|
|
777
|
+
}
|