yay-layer 1.0.0-rc.1

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/src/verify.js ADDED
@@ -0,0 +1,560 @@
1
+ 'use strict';
2
+ // The gate. For every Cell it answers two orthogonal questions:
3
+ // TRUST — is this spec covered by a valid signature in the lock? (who signed)
4
+ // VERIFY — does the code match the spec? (static checks)
5
+ // and combines them into GREEN / YELLOW / RED / UNSIGNED.
6
+ //
7
+ // MVP scope: VERIFY runs *static-lite* checks (unit exists, declared purity
8
+ // holds, effects are declared). Deep behavioural proof — property tests generated
9
+ // from `ensures`, real AST effect analysis, mutation scoring — is the per-language
10
+ // adapter milestone on the roadmap (see standard/STANDARD.md §Verification tiers).
11
+
12
+ const { canonical, pubKeysOf, isJsLang, normLangName } = require('./util');
13
+ const { verify: sigVerify } = require('./crypto');
14
+ const { proveManifest } = require('./prove');
15
+ const { requiredSigners, inertLevel, ignoreAllowed, coverageRequired, predicateRequired } = require('./policy');
16
+ const { deriveRoster } = require('./roster');
17
+ const G = require('./grants');
18
+ const F = require('./foundation');
19
+
20
+ // Shallow side-effect signals used for the MVP purity / minimality checks — per
21
+ // LANGUAGE, so a `pure: yes` claim is policed in every language we can run, not just
22
+ // JS. (Signal-based, not a proof of purity: an evader can dodge a regex; the typical
23
+ // AI-written effect gets caught.)
24
+ const EFFECT_SIGNALS = [
25
+ ['localStorage', /\blocalStorage\b/], ['sessionStorage', /\bsessionStorage\b/],
26
+ ['console', /\bconsole\s*\./], ['network', /\bfetch\s*\(|\bXMLHttpRequest\b/],
27
+ ['require', /\brequire\s*\(/], ['process', /\bprocess\s*\./],
28
+ ['dom', /\bdocument\b|\bwindow\b/], ['filesystem', /\bfs\s*\./],
29
+ ['nondeterminism', /\bMath\.random\b|\bDate\.now\b|\bnew Date\b/],
30
+ ];
31
+ const EFFECT_SIGNALS_PY = [
32
+ ['stdout', /\bprint\s*\(/], ['stdin', /\binput\s*\(/],
33
+ ['filesystem', /\bopen\s*\(|\bshutil\s*\.|\bpathlib\b.*\.(write|unlink|mkdir)/],
34
+ ['os', /\bos\s*\./], ['sys', /\bsys\s*\./], ['subprocess', /\bsubprocess\s*\./],
35
+ ['network', /\bsocket\s*\.|\brequests\s*\.|\burllib\b|\bhttpx\s*\./],
36
+ ['nondeterminism', /\brandom\s*\.|\btime\s*\.\s*time\s*\(|\bdatetime\s*\.\s*(datetime\s*\.\s*)?now\s*\(/],
37
+ ['global-state', /^\s*global\s+[A-Za-z_]/],
38
+ ['import', /\b__import__\s*\(/],
39
+ ];
40
+ // Per-language nets for the five P1.5 languages, so a `pure: yes` claim is policed in each with
41
+ // its OWN effect idioms (before this, non-JS/Python fell through to no net — a missed Red).
42
+ // Conservative: match common effect calls, not every token (a false Red is worse than a gap).
43
+ const EFFECT_SIGNALS_RUBY = [
44
+ ['stdout', /\b(puts|print|pp)\b|\$stdout\b|\bSTDOUT\b/],
45
+ ['filesystem', /\bFile\s*\.|\bIO\s*\.|\bDir\s*\.|\bFileUtils\b/],
46
+ ['network', /\bNet::HTTP\b|\bURI\s*\.\s*open\b|\bopen-uri\b|\bTCPSocket\b|\bSocket\b|\bHTTParty\b/],
47
+ ['exec', /\bsystem\s*\(|\bexec\s*\(|\bIO\.popen\b|\bProcess\s*\.|`[^`]*`|%x[({[]/],
48
+ ['env', /\bENV\b/],
49
+ ['nondeterminism', /\brand\b|\bTime\s*\.\s*now\b|\bDateTime\b|\bSecureRandom\b/],
50
+ ['global-state', /(^|[^@\w])\$[a-zA-Z_]\w*/],
51
+ ];
52
+ const EFFECT_SIGNALS_PHP = [
53
+ ['stdout', /\becho\b|\bprint\b|\bprintf\s*\(|\bvar_dump\s*\(/],
54
+ ['filesystem', /\bfile_get_contents\s*\(|\bfile_put_contents\s*\(|\bfopen\s*\(|\bunlink\s*\(|\bmkdir\s*\(|\brename\s*\(/],
55
+ ['network', /\bcurl_exec\s*\(|\bfsockopen\s*\(|\bstream_socket_client\s*\(/],
56
+ ['exec', /\bexec\s*\(|\bshell_exec\s*\(|\bsystem\s*\(|\bpassthru\s*\(|\bproc_open\s*\(|`[^`]*`/],
57
+ ['superglobal', /\$_(GET|POST|REQUEST|SESSION|COOKIE|SERVER|ENV|FILES)\b/],
58
+ ['db', /\bnew\s+PDO\b|\bmysqli?_(query|connect)\b/],
59
+ ['nondeterminism', /\brand\s*\(|\bmt_rand\s*\(|\brandom_int\s*\(|\btime\s*\(|\bmicrotime\s*\(|\bdate\s*\(|\buniqid\s*\(/],
60
+ ];
61
+ const EFFECT_SIGNALS_SOLIDITY = [
62
+ ['state', /\bstorage\b|\bselfdestruct\s*\(/],
63
+ ['event', /\bemit\s+\w/],
64
+ ['external-call', /\.\s*call\s*[({]|\.\s*delegatecall\s*\(|\.\s*transfer\s*\(|\.\s*send\s*\(/],
65
+ ['context', /\bblock\s*\.|\bmsg\s*\.|\btx\s*\.|\bblockhash\s*\(|\bnow\b/],
66
+ ];
67
+ const EFFECT_SIGNALS_RUST = [
68
+ ['stdout', /\bprintln!|\bprint!|\beprintln!|\beprint!|\bio::stdout\b/],
69
+ ['filesystem', /\bstd::fs\b|\bFile::(open|create)\b|\bfs::(read|write|remove|create)/],
70
+ ['network', /\bTcpStream\b|\bTcpListener\b|\bstd::net\b|\breqwest\b/],
71
+ ['process', /\bstd::process\b|\bCommand::new\b/],
72
+ ['nondeterminism', /\brand::|\bInstant::now\b|\bSystemTime::now\b|\bthread_rng\b/],
73
+ ['global-mut', /\bstatic\s+mut\b/],
74
+ ['unsafe', /\bunsafe\b/],
75
+ ];
76
+ const EFFECT_SIGNALS_CSHARP = [
77
+ ['stdout', /\bConsole\s*\.\s*(Write|WriteLine|Read|ReadLine)\b/],
78
+ ['filesystem', /\bFile\s*\.|\bDirectory\s*\.|\bStreamReader\b|\bStreamWriter\b|\bSystem\s*\.\s*IO\b/],
79
+ ['network', /\bHttpClient\b|\bWebClient\b|\bSocket\b|\bSystem\s*\.\s*Net\b/],
80
+ ['process', /\bProcess\s*\.\s*Start\b|\bSystem\s*\.\s*Diagnostics\s*\.\s*Process\b/],
81
+ ['env', /\bEnvironment\s*\./],
82
+ ['nondeterminism', /\bnew\s+Random\b|\bDateTime\s*\.\s*(Now|UtcNow|Today)\b|\bGuid\s*\.\s*NewGuid\b|\bStopwatch\b/],
83
+ ];
84
+ const isPyLang = (l) => /^py(thon)?w?$/i.test(String(l || ''));
85
+ // A machine-readable descriptor of the per-language effect nets: { lang: [signal labels] }. Used by
86
+ // the verifier-capability fingerprint (src/capability.js) so adding/removing a signal or a whole net
87
+ // changes the fingerprint — forcing a capability-version bump instead of a silent expansion.
88
+ function effectNetDescriptor() {
89
+ const labels = (arr) => arr.map((s) => s[0]).sort();
90
+ return {
91
+ js: labels(EFFECT_SIGNALS), python: labels(EFFECT_SIGNALS_PY), ruby: labels(EFFECT_SIGNALS_RUBY),
92
+ php: labels(EFFECT_SIGNALS_PHP), solidity: labels(EFFECT_SIGNALS_SOLIDITY), rust: labels(EFFECT_SIGNALS_RUST),
93
+ csharp: labels(EFFECT_SIGNALS_CSHARP),
94
+ };
95
+ }
96
+
97
+ function effectSignalsFor(lang) {
98
+ if (!lang || isJsLang(lang)) return EFFECT_SIGNALS;
99
+ if (isPyLang(lang)) return EFFECT_SIGNALS_PY;
100
+ switch (normLangName(lang)) {
101
+ case 'ruby': return EFFECT_SIGNALS_RUBY;
102
+ case 'php': return EFFECT_SIGNALS_PHP;
103
+ case 'solidity': return EFFECT_SIGNALS_SOLIDITY;
104
+ case 'rust': return EFFECT_SIGNALS_RUST;
105
+ case 'csharp': return EFFECT_SIGNALS_CSHARP;
106
+ default: return null; // languages with no net yet — don't guess (a false Red is worse)
107
+ }
108
+ }
109
+
110
+ const SEV = { GREEN: 0, YELLOW: 1, UNSIGNED: 2, RED: 3 };
111
+ const worst = (a, b) => (SEV[a] >= SEV[b] ? a : b);
112
+
113
+ function trustOf(cell, lock, roster, grants, policy, rejections) {
114
+ let match = null;
115
+ let violation = null; // a delegated approval whose grant-key signature is real but broke its envelope
116
+ let firstAt = null; // earliest approval that ever covered this Cell → "created in the system"
117
+ for (const ap of lock.approvals || []) {
118
+ if (!ap.items || !(cell.id in ap.items)) continue;
119
+ if (ap.at && (!firstAt || Date.parse(ap.at) < Date.parse(firstAt))) firstAt = ap.at;
120
+ if (ap.items[cell.id] !== cell.specHash) continue;
121
+ const { signature, ...rest } = ap;
122
+ if (ap.autoApproved && ap.grant) {
123
+ // Autopilot: signed by the machine-held GRANT key, valid only if a good grant
124
+ // covers it (owner-signed, unexpired, unrevoked-before-this, in-envelope, in-count).
125
+ const g = (grants || {})[ap.grant];
126
+ const chk = g && G.autoApprovalOk(g, ap, cell.id, cell, { policy });
127
+ const sigOk = g && sigVerify(canonical(rest), signature, g.grantPub);
128
+ if (g && chk.ok && sigOk) {
129
+ match = { signed: true, signer: ap.signer || null, auto: true, grant: ap.grant, at: ap.at || null };
130
+ } else if (sigOk && g && g.ownerOk) {
131
+ // BACKSTOP (P3): the grant is a VALID, owner-authorized delegation and its key really signed
132
+ // this — but the target is OUTSIDE the envelope (wrong path/cell/risk, or an owner-signed
133
+ // non-delegable area). That's an attempt to widen scope, not a lapsed grant, so we don't drop
134
+ // it silently: flag a GRANT VIOLATION that blocks the gate loudly, agent behaviour aside.
135
+ // (A merely expired/revoked/count-exhausted grant is a lapse → falls through to UNSIGNED.)
136
+ const cov = G.grantCoversCellR(g, cell.id, cell, { policy });
137
+ if (!cov.ok) violation = { grant: ap.grant, reason: cov.reason };
138
+ }
139
+ continue; // a human ratification (a later normal approval) will supersede this
140
+ }
141
+ const pubs = pubKeysOf(roster[ap.signer]); // an identity may hold several keys
142
+ if (!pubs.length) continue;
143
+ if (pubs.some((pub) => sigVerify(canonical(rest), signature, pub))) {
144
+ // Keep the most recent valid signature over the current spec as "signed at".
145
+ match = { signed: true, signer: ap.signer, auto: false, grant: null, at: ap.at || null };
146
+ }
147
+ }
148
+ // Rejection withdraws approval (P3). A human turning down delegated work is like removing a
149
+ // signature: the Cell drops behind the gate until it's reworked and re-approved. A reject targets
150
+ // an EXACT specHash, so reworking the code (which changes the specHash) naturally clears the old
151
+ // reject; and a later HUMAN approval over that same spec — the human changing their mind — also
152
+ // supersedes it. A delegated (grant-key) re-approval never clears a human rejection. Forging a
153
+ // rejection can only BLOCK a Cell (fail-safe), never approve one, so a signature check isn't
154
+ // required for soundness here.
155
+ let rejected = null;
156
+ for (const ev of (rejections && rejections.events) || []) {
157
+ if (!ev || ev.type !== 'reject' || !ev.cells || !ev.cells.includes(cell.id)) continue;
158
+ if (!ev.specHashes || ev.specHashes[cell.id] !== cell.specHash) continue; // only the exact reviewed spec
159
+ if (!rejected || Date.parse(ev.at || 0) > Date.parse(rejected.at || 0)) rejected = ev;
160
+ }
161
+ if (rejected) {
162
+ const humanAfter = match && match.auto === false && match.at && Date.parse(match.at) > Date.parse(rejected.at || 0);
163
+ if (!humanAfter) {
164
+ return { signed: false, rejected: true, rejectReason: rejected.reason || '', rejectBy: rejected.signer || rejected.by || null, firstAt, violation };
165
+ }
166
+ }
167
+ if (match) return { ...match, firstAt };
168
+ return { signed: false, firstAt, violation };
169
+ }
170
+
171
+ function staticChecks(cell) {
172
+ const notes = [];
173
+ let red = false, yellow = false;
174
+ const spec = cell.spec || {};
175
+ const isModule = !!(cell.contains && cell.contains.length);
176
+
177
+ // A module (container Cell) governs composition, not a code unit — so it is not
178
+ // expected to have a function body or the machine fields a leaf Cell needs.
179
+ if (!isModule && cell.unitName && !cell.unitFound) {
180
+ red = true; notes.push({ level: 'red', text: `code missing: no unit "${cell.unitName}" found below the spec` });
181
+ }
182
+
183
+ const declaredEffects = (spec.effects || '').toLowerCase();
184
+ const pure = /^yes\b/i.test(spec.pure || '');
185
+ let badLines = [];
186
+ // Effect / purity signals per LANGUAGE (JS tokens for JS/TS, Python tokens for
187
+ // Python). Languages with no reliable net get none — a false red on a function
188
+ // that merely shares a name would be worse than an honest gap.
189
+ // (Missing lang ⇒ JS, so legacy/synthetic cells behave exactly as before.)
190
+ const sigSet = effectSignalsFor(cell.langName || cell.lang);
191
+ if (cell.unitBody && sigSet) {
192
+ const base = cell.unitBodyStart || 0;
193
+ const found = []; // { signal, line, text } — the exact offending source lines
194
+ cell.unitBody.split('\n').forEach((ln, k) => {
195
+ for (const [sig, re] of sigSet) {
196
+ if (re.test(ln)) { found.push({ signal: sig, line: base + k + 1, text: ln.trim() }); break; }
197
+ }
198
+ });
199
+ const signals = [...new Set(found.map((f) => f.signal))];
200
+ const renders = /^yes\b/i.test(spec.renders || '');
201
+ if (pure && found.length && renders) {
202
+ // A component's inline handlers (onClick={() => fetch(…)}) run AFTER render, so a
203
+ // signal here isn't proof the RENDER is impure — a line net can't tell handler from
204
+ // body. Honest middle: Yellow, with the fix spelled out (never a silent pass, never
205
+ // a false Red on a legitimately pure render that attaches effectful handlers).
206
+ yellow = true; badLines = found.map((f) => f.text);
207
+ for (const f of found) notes.push({ level: 'yellow', text: `effect signal in a \`renders:\` component (line ${f.line}): ${f.signal} → ${f.text} — if it's in a handler, declare it in \`effects:\` (handlers run outside the render) or drop \`pure: yes\`; if it runs during render, the render is not pure` });
208
+ } else if (pure && found.length) {
209
+ red = true;
210
+ badLines = found.map((f) => f.text);
211
+ for (const f of found) notes.push({ level: 'red', text: `purity violated (line ${f.line}): \`pure: yes\` but uses ${f.signal} → ${f.text}` });
212
+ } else if (!pure && found.length) {
213
+ const undeclared = found.filter((f) => !declaredEffects.includes(f.signal) && !declaredEffects.includes('any'));
214
+ if (declaredEffects && undeclared.length) {
215
+ yellow = true; badLines = undeclared.map((f) => f.text);
216
+ for (const f of undeclared) notes.push({ level: 'yellow', text: `undeclared effect (line ${f.line}): ${f.signal} → ${f.text}` });
217
+ } else if (!declaredEffects) {
218
+ yellow = true; badLines = found.map((f) => f.text);
219
+ notes.push({ level: 'yellow', text: `has effects (${signals.join(', ')}) but none declared in \`effects:\`` });
220
+ }
221
+ }
222
+ }
223
+
224
+ // NOTE the non-JS honesty cap ("signed-only, capped at Yellow") no longer lives here:
225
+ // some non-JS Cells ARE machine-provable now (pure Python via the subprocess prover),
226
+ // so the cap is applied in verifyManifest AFTER the prover pass — only Cells the
227
+ // prover did NOT prove get capped. Same honesty, without punishing a real proof.
228
+
229
+ // vague / prose-only spec caps at YELLOW: a leaf Cell needs at least one machine field.
230
+ const machineFields = ['in', 'out', 'ensures', 'pure', 'throws'].some((k) => spec[k]);
231
+ if (!isModule && !machineFields) { yellow = true; notes.push({ level: 'yellow', text: 'prose-only spec (no in/out/ensures/pure/throws) — capped at Yellow' }); }
232
+ if (!spec.intent) { yellow = true; notes.push({ level: 'yellow', text: 'no `intent:` line' }); }
233
+
234
+ // unit-name mismatch: the spec's `unit:` names a different function than the code
235
+ // actually defines below it (a rename, or the block attached to the wrong function).
236
+ const short = (n) => String(n || '').split('.').pop();
237
+ if (!isModule && spec.unit && cell.detectedUnit && short(spec.unit) !== short(cell.detectedUnit)) {
238
+ yellow = true;
239
+ notes.push({ level: 'yellow', text: `unit name mismatch: spec says \`unit: ${spec.unit}\` but the code defines \`${cell.detectedUnit}\` — update the spec, or the block may be on the wrong function` });
240
+ }
241
+
242
+ // dangling references: this Cell calls a bare name defined nowhere in the project
243
+ // (renamed/removed function, typo, or an import the analyzer couldn't see).
244
+ if (cell.unresolved && cell.unresolved.length) {
245
+ yellow = true;
246
+ const names = cell.unresolved.map((n) => '`' + n + '`').join(', ');
247
+ notes.push({ level: 'yellow', text: `calls undefined name${cell.unresolved.length > 1 ? 's' : ''} ${names} — not defined anywhere in the project (renamed/removed/typo, or an unlisted import)` });
248
+ }
249
+
250
+ return { red, yellow, notes, badLines };
251
+ }
252
+
253
+ function verifyManifest(manifest, lock, config, opts) {
254
+ opts = opts || {};
255
+ // Trusted signers come from the SIGNED roster log when present (authoritative),
256
+ // not from the plain config file — so an unsigned edit to who-can-sign has no
257
+ // effect. Legacy projects with no signed log fall back to config.signers.
258
+ let roster, rosterProblems = [], rootFp = null, rosterOk = true, signedRoster = false;
259
+ let ownerPubs = [];
260
+ let rosterPolicy = { rules: [] }; // the ENFORCED policy comes from the owner-signed roster (tamper-evident)
261
+ let foundationSeal = null, foundationMode = 'off', rootMeta = null;
262
+ if (opts.roster && opts.roster.events) {
263
+ const d = deriveRoster(opts.roster, { root: opts.root });
264
+ roster = d.roster; rosterProblems = d.problems; rootFp = d.rootFp; rosterOk = d.ok; signedRoster = true;
265
+ rosterPolicy = d.policy || { rules: [] };
266
+ foundationSeal = d.foundation || null; foundationMode = d.foundationMode || 'off'; rootMeta = d.rootMeta || null;
267
+ ownerPubs = Object.keys(d.roles || {}).filter((n) => d.roles[n] === 'owner').reduce((a, n) => a.concat(roster[n] || []), []);
268
+ } else {
269
+ roster = (config && config.signers) || {};
270
+ ownerPubs = ((config && config.owners) || []).reduce((a, n) => a.concat(pubKeysOf(roster[n])), []);
271
+ }
272
+ // Autopilot: validated delegation grants (empty when the project doesn't use them).
273
+ const grants = opts.grants ? G.deriveGrants(opts.grants, ownerPubs, lock.approvals) : {};
274
+ const results = {};
275
+
276
+ for (const id of Object.keys(manifest.cells)) {
277
+ const cell = manifest.cells[id];
278
+ const trust = trustOf(cell, lock, roster, grants, rosterPolicy, opts.rejections);
279
+ const sc = staticChecks(cell);
280
+
281
+ let state;
282
+ // assumeSigned: reverify re-judges a reconstructed HISTORICAL (already-approved) tree on code⇔spec
283
+ // alone — there's no lock in the temp dir, so we skip the UNSIGNED gate and report the machine verdict.
284
+ if (trust.violation) state = 'RED'; // grant-envelope violation → gate-blocking backstop
285
+ else if (!trust.signed && !opts.assumeSigned) state = 'UNSIGNED';
286
+ else if (sc.red) state = 'RED';
287
+ else if (sc.yellow) state = 'YELLOW';
288
+ else state = 'GREEN';
289
+
290
+ const isModule = !!(cell.contains && cell.contains.length);
291
+ // Influence overlay (computed from the call graph, not asserted). It does not
292
+ // change color — it's an oversight signal — except the honest bloat *note*.
293
+ if (!isModule && cell.bloat) {
294
+ sc.notes.push({ level: 'info', text: 'no static callers found — possible dead code / bloat candidate (or an entry point called dynamically)' });
295
+ }
296
+ if (trust.auto) {
297
+ sc.notes.push({ level: 'info', text: `DELEGATED under grant ${trust.grant} (Autopilot) — awaiting ratification, not human-reviewed. Run \`yay ratify\` to sign it for real.` });
298
+ }
299
+ if (trust.violation) {
300
+ sc.notes.push({ level: 'red', text: `GRANT VIOLATION — a delegated (Autopilot) approval under grant ${trust.violation.grant} covered this Cell, but it is outside that grant's envelope: ${trust.violation.reason}. The agent cannot widen its own grant; this needs a real human signature. (verifier backstop)` });
301
+ }
302
+ if (trust.rejected) {
303
+ sc.notes.push({ level: 'red', text: `🚫 rejected by ${trust.rejectBy || 'a human'}${trust.rejectReason ? ` — ${String(trust.rejectReason).replace(/[.\s]+$/, '')}` : ''}. The delegated approval was withdrawn (like removing a signature); rework and re-approve to clear it. (yay ratify --reject)` });
304
+ }
305
+
306
+ results[id] = {
307
+ id, state, trust, notes: sc.notes, badLines: sc.badLines || [], file: cell.file, line: cell.line,
308
+ blast: cell.blast || 0, dependents: cell.directCallers || 0, isEntry: !!cell.isEntry, bloat: !!cell.bloat,
309
+ // legibility: is the promise actually machine-proven, or just signed?
310
+ hasEnsures: !!(cell.spec && cell.spec.ensures), proven: false,
311
+ };
312
+ }
313
+
314
+ // Behavioural proof: run each pure Cell against its `ensures` (spec-derived
315
+ // tests). A counterexample ⇒ Red (code contradicts its promise); a claim we
316
+ // can't check ⇒ Yellow (unproven), never a fake pass.
317
+ const proofs = proveManifest(manifest, opts);
318
+ for (const id of Object.keys(proofs)) {
319
+ if (!results[id]) continue;
320
+ const pr = proofs[id];
321
+ if (pr.status === 'fail') {
322
+ results[id].state = worst(results[id].state, 'RED');
323
+ results[id].notes.push({ level: 'red', text: 'ensures FAILED — ' + pr.counterexample });
324
+ const body = manifest.cells[id] && manifest.cells[id].unitBody;
325
+ if (body) results[id].badLines = body.split('\n').map((l) => l.trim()).filter((l) => /\breturn\b/.test(l));
326
+ } else if (pr.status === 'pass') {
327
+ results[id].proven = true;
328
+ const mu = pr.mutation;
329
+ let text = `ensures proven over ${pr.cases} generated case(s)`;
330
+ // Honesty: a proven non-JS Cell (Python subprocess prover) isn't yet graded by
331
+ // mutation testing or the inertness check — say so, so its green reads correctly.
332
+ const prLang = manifest.cells[id] && manifest.cells[id].lang;
333
+ if ((!mu || !mu.total) && prLang && !isJsLang(prLang)) text += ` (mutation grading + inertness check not yet available for ${prLang})`;
334
+ if (mu && mu.total) {
335
+ text += `; mutation score ${Math.round(mu.score * 100)}% (${mu.killed}/${mu.total} killed)`;
336
+ if (mu.score < 0.5) {
337
+ results[id].state = worst(results[id].state, 'YELLOW');
338
+ results[id].notes.push({ level: 'yellow', text: `weak ensures — ${mu.survived} mutant(s) survived (e.g. ${mu.survivor || 'a code change'}); the promise passes even when the code is broken` });
339
+ }
340
+ }
341
+ results[id].notes.push({ level: 'info', text });
342
+ // Branch-exercise honesty: show the coverage boundary of the proof instead of hiding it.
343
+ // A badge only — it never downgrades the green here (a legit `throws:` guard is often
344
+ // unexercised on purpose); policy (`coverage: full`) decides if that's gate-blocking.
345
+ const cov = pr.coverage;
346
+ if (cov && cov.total) {
347
+ results[id].coverage = cov;
348
+ if (cov.missed && cov.missed.length) {
349
+ const where = cov.missed.slice(0, 3).map((m) => 'line ' + m.line).join(', ') + (cov.missed.length > 3 ? '…' : '');
350
+ results[id].notes.push({ level: 'info', text: `proven — but ${cov.exercised}/${cov.total} branches exercised by spec-derived inputs; unexercised: ${where}. A dormant branch is an unexercised branch — check these are intended (guards/edge cases), spec them, or require full exercise via policy.` });
351
+ } else {
352
+ results[id].notes.push({ level: 'info', text: `proven — all ${cov.total} branches exercised by spec-derived inputs (full branch coverage)` });
353
+ }
354
+ }
355
+ } else if (pr.status === 'skip') {
356
+ // Couldn't check it (prose, exotic type, won't load) — say so, but don't
357
+ // punish honest code for the prover's limits. Only a real contradiction is Red.
358
+ results[id].notes.push({ level: 'info', text: 'ensures not machine-verified — ' + pr.reason });
359
+ }
360
+ }
361
+
362
+ // Honesty cap for non-JS Cells, applied AFTER the prover so a genuinely proven Cell
363
+ // (e.g. a pure Python function proved by the subprocess prover) keeps its Green.
364
+ // Everything non-JS the prover did NOT prove stays capped at Yellow — no silent
365
+ // false green for signed-but-unverified code in other languages.
366
+ for (const id of Object.keys(manifest.cells)) {
367
+ const cell = manifest.cells[id];
368
+ if (!results[id]) continue;
369
+ const isModule = !!(cell.contains && cell.contains.length);
370
+ if (isModule || !cell.lang || isJsLang(cell.lang)) continue;
371
+ if (results[id].proven) continue; // machine-proven out-of-VM → the proof stands
372
+ results[id].state = worst(results[id].state, 'YELLOW');
373
+ results[id].notes.push({ level: 'yellow', text: `signed, but not machine-verified — ${cell.lang} code⇔spec isn't checked yet (pure Python functions with an ensures ARE provable); capped at Yellow` });
374
+ }
375
+
376
+ // Higher-order: broken feeds edges, and roll-up color for container Cells.
377
+ for (const id of Object.keys(manifest.cells)) {
378
+ const cell = manifest.cells[id];
379
+ for (const t of cell.feeds || []) {
380
+ if (!manifest.cells[t]) {
381
+ results[id].notes.push({ level: 'yellow', text: `broken edge: feeds → ${t} (no such Cell)` });
382
+ results[id].state = worst(results[id].state, 'YELLOW');
383
+ }
384
+ }
385
+ }
386
+ // ── Signing policy (optional; neutral when there are no rules) ──
387
+ // A Cell matching a rule MUST be signed by a required signer with a real (non-AUTO)
388
+ // seal, else it can't ship: downgrade it to RED so it blocks the gate and shows on the
389
+ // map, with a note naming who must sign. No policy / no match → unconstrained, as before.
390
+ const policy = opts.policy || rosterPolicy || { rules: [] };
391
+ let policyBlocked = 0;
392
+ for (const id of Object.keys(manifest.cells)) {
393
+ const cell = manifest.cells[id];
394
+ const r = results[id];
395
+ if (!r || (cell.contains && cell.contains.length)) continue; // leaves only
396
+ const req = requiredSigners(policy, cell);
397
+ if (!req.length) continue;
398
+ const okBy = r.trust && r.trust.signed && !r.trust.auto && req.includes(r.trust.signer);
399
+ if (!okBy) {
400
+ r.state = worst(r.state, 'RED');
401
+ r.policyOk = false;
402
+ r.notes.push({ level: 'red', text: `policy: requires ${req.join(' or ')} to sign — ${r.trust && r.trust.signed ? 'currently signed by ' + (r.trust.signer || '?') : 'not yet signed by them'}` });
403
+ policyBlocked++;
404
+ } else { r.policyOk = true; }
405
+ }
406
+
407
+ // ── Inertness verdicts (built-in security feature) ──
408
+ // The prover flags branches removable with every spec-derived test still passing —
409
+ // unpromised behaviour riding under a signature (dead weight, ahead-of-spec
410
+ // scaffolding, or a dormant payload). Default: Yellow cap. Policy escalates
411
+ // (inert: block → gate-blocking) or relaxes (inert: note) per path/tag/module.
412
+ for (const id of Object.keys(proofs)) {
413
+ const pr = proofs[id];
414
+ const r = results[id];
415
+ if (!r || !pr || !pr.inertness) continue;
416
+ const inert = pr.inertness;
417
+ if (inert.exempt) { r.notes.push({ level: 'info', text: `inertness: Cell exempt — ${inert.exempt} (signed declaration)` }); continue; }
418
+ if (!inert.flagged || !inert.flagged.length) continue;
419
+ const level = inertLevel(policy, manifest.cells[id]);
420
+ const ex = inert.flagged[0];
421
+ // Sharpen with branch-exercise data: an inert branch that was ALSO never exercised by any
422
+ // spec-derived input is the highest-confidence dormancy signal — call it out explicitly.
423
+ const missedLines = new Set(((r.coverage && r.coverage.missed) || []).map((m) => m.line));
424
+ const alsoUnexercised = (inert.flagged || []).some((f) => missedLines.has(f.line));
425
+ const msg = `inert code — ${inert.flagged.length} branch(es) removable with every spec-derived test still passing (e.g. line ${ex.line}: \`${ex.snippet}\`)${alsoUnexercised ? ' — and never exercised by any spec-derived input (strongest dormancy signal)' : ''}: unpromised behaviour riding under the signature. Prune it, spec it (add the ensures case or its own Cell), or declare it (\`throws:\` for guards, \`perf:\` for optimizations).`;
426
+ if (level === 'note') {
427
+ r.notes.push({ level: 'info', text: msg + ' (policy: inert → note)' });
428
+ } else if (level === 'block') {
429
+ r.state = worst(r.state, 'RED');
430
+ r.notes.push({ level: 'red', text: msg + ' (policy: inert → block — this Cell is gate-blocked until resolved)' });
431
+ } else {
432
+ r.state = worst(r.state, 'YELLOW');
433
+ r.notes.push({ level: 'yellow', text: msg });
434
+ }
435
+ }
436
+
437
+ // ── Coverage strictness (owner-signed policy `coverage: full`) ──
438
+ // Default is a badge, never a downgrade. But for crown-jewel scopes a policy rule can DEMAND that
439
+ // every branch of a proven Cell was exercised — an unexercised branch (where a dormant payload
440
+ // hides) then blocks the gate until it's exercised, spec'd, or pruned.
441
+ for (const id of Object.keys(manifest.cells)) {
442
+ const cell = manifest.cells[id];
443
+ const r = results[id];
444
+ if (!r || (cell.contains && cell.contains.length) || !r.coverage) continue;
445
+ if (!coverageRequired(policy, cell)) continue;
446
+ const missed = (r.coverage.missed || []);
447
+ if (missed.length) {
448
+ r.state = worst(r.state, 'RED');
449
+ r.notes.push({ level: 'red', text: `policy: coverage full — ${missed.length} branch(es) never exercised by spec-derived inputs (${missed.slice(0, 3).map((m) => 'line ' + m.line).join(', ')}${missed.length > 3 ? '…' : ''}); this scope requires every branch exercised. Exercise them (strengthen the ensures/inputs), spec them, or prune them.` });
450
+ }
451
+ }
452
+
453
+ // ── Undeclared-input predicate provenance (built-in; the 3rd prong of the pincer) ──
454
+ // A branch that keys off a parameter the Cell's `in:` never declares — inertness (branch does
455
+ // nothing), coverage (was it exercised?), and literal-seeding (magic constant) all miss this shape.
456
+ // Default Yellow (advisory, non-blocking, like inertness); owner-signed policy (`predicate: declared`)
457
+ // escalates it to gate-blocking for sensitive scopes. The fix is to DECLARE the input in `in:` (or a
458
+ // `throws:`/`ensures` case) — the spec-strengthening loop — after which nothing flags; or prune the branch.
459
+ for (const id of Object.keys(proofs)) {
460
+ const pr = proofs[id];
461
+ const r = results[id];
462
+ if (!r || !pr || !pr.predicate || !pr.predicate.findings || !pr.predicate.findings.length) continue;
463
+ const f = pr.predicate.findings;
464
+ const ex = f[0];
465
+ const names = Array.from(new Set(f.map((x) => x.name)));
466
+ const msg = `undeclared-input predicate — ${f.length} branch(es) key off ${names.map((n) => '\`' + n + '\`').join(', ')}, which this Cell's \`in:\` never declares (e.g. line ${ex.line}: \`${ex.snippet}\`). This branch's behaviour depends on an input the spec never signed. Declare it in \`in:\` (or add a \`throws:\`/\`ensures\` case), or prune the branch.`;
467
+ r.predicate = { findings: f, undeclared: pr.predicate.undeclared };
468
+ if (predicateRequired(policy, manifest.cells[id])) {
469
+ r.state = worst(r.state, 'RED');
470
+ r.notes.push({ level: 'red', text: msg + ' (policy: predicate → declared — gate-blocked until every branch predicate traces to the spec)' });
471
+ } else {
472
+ r.state = worst(r.state, 'YELLOW');
473
+ r.notes.push({ level: 'yellow', text: msg });
474
+ }
475
+ }
476
+
477
+ for (const id of Object.keys(manifest.cells)) {
478
+ const cell = manifest.cells[id];
479
+ if (cell.contains && cell.contains.length) {
480
+ let rolled = results[id].state;
481
+ for (const child of cell.contains) {
482
+ if (results[child]) rolled = worst(rolled, results[child].state);
483
+ }
484
+ if (rolled !== results[id].state) results[id].notes.push({ level: 'info', text: `rolled up to ${rolled} from contained Cells` });
485
+ results[id].state = rolled;
486
+ results[id].isModule = true;
487
+ }
488
+ }
489
+
490
+ // PINK: code with no spec block at all — untracked, never described or signed.
491
+ // The most dangerous state, so it BLOCKS the gate: everything must be covered.
492
+ for (const u of manifest.untracked || []) {
493
+ let id = `«${u.name}»`;
494
+ if (results[id]) id += ` @${u.file}:${u.line}`;
495
+ const wrapHint = isJsLang(u.lang)
496
+ ? 'wrap it in an IIFE around a spec\'d function — `(function(){ function init(){…} init(); })()` — or run `yay adopt`'
497
+ : 'move it under an entry guard (e.g. `if __name__ == "__main__":`) or into a function an explicit caller runs';
498
+ const text = u.kind === 'loose'
499
+ ? `top-level code runs at load with no spec (${u.count || 1} statement${(u.count || 1) > 1 ? 's' : ''}): ${wrapHint}`
500
+ : `no formal specification (${u.kind || 'unit'}) — never described or signed (run \`yay adopt\`)`;
501
+ results[id] = {
502
+ id, state: 'PINK', trust: { signed: false }, untracked: true,
503
+ name: u.name, file: u.file, line: u.line, lang: u.lang, module: u.module, group: u.group,
504
+ notes: [{ level: 'red', text }],
505
+ badLines: [],
506
+ };
507
+ }
508
+
509
+ // Source code hidden from the gate by .yaylayerignore → gate-blocking Pink, unless an
510
+ // OWNER-SIGNED policy `ignore: source` rule authorises it. Closes the bypass where a
511
+ // bare .yaylayerignore line removes a file from judgment entirely.
512
+ for (const ig of manifest.ignoredSource || []) {
513
+ if (ignoreAllowed(policy, ig.file)) continue; // whitelisted (vendored/generated), signed
514
+ const id = `«ignored: ${ig.file}»`;
515
+ results[id] = {
516
+ id, state: 'PINK', trust: { signed: false }, untracked: true, ignoredSource: true,
517
+ name: 'ignored source', file: ig.file, line: 1, lang: ig.lang, module: ig.file, group: 'hidden',
518
+ notes: [{ level: 'red', text: `SOURCE file hidden from the gate by .yaylayerignore — code here is never scanned, signed, or verified. Un-ignore it, or authorise it with an owner-signed policy rule { "match": { "path": "${ig.file}" }, "ignore": "source" } (yay policy → --set).` }],
519
+ badLines: [],
520
+ };
521
+ }
522
+
523
+ const counts = { GREEN: 0, YELLOW: 0, RED: 0, UNSIGNED: 0, PINK: 0 };
524
+ for (const r of Object.values(results)) counts[r.state]++;
525
+ counts.policyBlocked = policyBlocked; // Cells blocked by the signing policy (already RED)
526
+ // Legibility: of the GREEN Cells, how many are machine-PROVEN vs signed-but-unproven
527
+ // (a promise stated but never machine-checked — the specs to strengthen next).
528
+ let proven = 0, unproven = 0;
529
+ for (const r of Object.values(results)) if (r.state === 'GREEN') { if (r.proven) proven++; else if (r.hasEnsures) unproven++; }
530
+ counts.proven = proven; counts.unproven = unproven;
531
+ // Autopilot: of the signed Cells, how many are AUTO (delegated, awaiting ratification).
532
+ let auto = 0;
533
+ for (const r of Object.values(results)) if (r.trust && r.trust.auto && r.state !== 'UNSIGNED') auto++;
534
+ counts.auto = auto;
535
+ // Foundation seal (owner-signed baseline of the fixed core files) — reveal any drift. The
536
+ // posture is authoritative from the signed roster event; config.foundation mirrors it and
537
+ // survives a reroot, so if it EXPECTS a seal but none exists under the current root, that's
538
+ // itself flagged (e.g. after a reroot, until the new owner re-seals).
539
+ let foundation = null;
540
+ const expectMode = foundationMode !== 'off' ? foundationMode
541
+ : ((config && config.foundation && config.foundation !== 'off') ? config.foundation : 'off');
542
+ if (expectMode !== 'off') {
543
+ if (!foundationSeal) {
544
+ foundation = { mode: expectMode, clean: false, expectedButMissing: true, changed: [], missing: [], addedFiles: [], removedFiles: [] };
545
+ } else {
546
+ const cmp = F.compareSeal(manifest.root, foundationSeal, Array.isArray(opts.tracked) ? opts.tracked : null);
547
+ foundation = { mode: expectMode, expectedButMissing: false, ...cmp };
548
+ }
549
+ }
550
+ // Strict foundation posture blocks the gate on drift; Guarded reveals (warns) without blocking.
551
+ const foundationBlocks = !!(foundation && foundation.mode === 'strict' && !foundation.clean);
552
+ // A tampered / unauthorized / root-mismatched roster blocks the gate: if we can't
553
+ // trust WHO may sign, we can't trust any signature.
554
+ const passed = counts.RED === 0 && counts.UNSIGNED === 0 && counts.PINK === 0 && rosterOk && !foundationBlocks;
555
+ // `policy` is the EFFECTIVE ruleset the verdicts were produced under — exposed so an attestation
556
+ // (P2) can hash exactly what the verifier used (rulesetHash), not re-guess it.
557
+ return { results, counts, passed, grants, rosterProblems, rootFp, rosterOk, signedRoster, policy, foundation, rootMeta };
558
+ }
559
+
560
+ module.exports = { verifyManifest, worst, effectNetDescriptor };