dsh-vibe-math 2.3.1 → 2.3.3
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/AUDIT-CHECKLIST.md +59 -0
- package/README.md +16 -6
- package/RELEASE-NOTES-2.3.2.md +145 -0
- package/RELEASE-NOTES-2.3.3.md +115 -0
- package/audit-formal-sensitivity.mjs +11 -2
- package/audit-prompt-invariants.mjs +459 -0
- package/audit-spec-traceability.mjs +193 -0
- package/docs/formal-verification.md +37 -13
- package/docs/generate_framework_diagram_v5.mjs +2 -1
- package/docs/test-timing.md +32 -10
- package/formal-verify-v2.test.mjs +288 -2
- package/formal-verify-v3.test.mjs +230 -4
- package/formal-verify-v4.test.mjs +205 -5
- package/formal-verify-v5.test.mjs +114 -4
- package/installer.js +3 -1
- package/package.json +6 -2
- package/prompt-corpus-persona/persona-corpus.json +2 -2
- package/prompt-corpus-persona/persona-corpus.md +6 -2
- package/prompt-corpus-v2/formal-verify-v2.json +164 -44
- package/prompt-corpus-v2/formal-verify-v2.md +1374 -44
- package/prompt-corpus-v3/formal-verify-v3.json +236 -128
- package/prompt-corpus-v3/formal-verify-v3.md +1509 -400
- package/prompt-corpus-v4/formal-verify-v4.json +8 -3
- package/prompt-corpus-v4/formal-verify-v4.md +38 -10
- package/prompt-corpus-v5/prompt-corpus-v5.json +175 -246
- package/prompt-corpus-v5/prompt-corpus-v5.md +341 -781
- package/prompt-v5-integrity.test.mjs +136 -22
- package/run-tests.mjs +30 -11
- package/vibe-math-v2/vibe-math-v2.js +163 -38
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +58 -6
- package/vibe-math-v3/vibe-math-v3.js +95 -25
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +8 -7
- package/vibe-math-v4/vibe-math-v4.js +103 -24
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +37 -14
- package/vibe-math-v5/agent.cordis.yml +6 -2
- package/vibe-math-v5/vibe-math-v5.js +56 -10
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +45 -14
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +16 -2
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +6 -5
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PROMPT/INTERACTION INVARIANTS — the specific defect CLASSES this project has actually shipped,
|
|
4
|
+
* encoded as static invariants over all four presets. This is the mechanical answer to
|
|
5
|
+
* "are the prompt defects really fixed, and can they come back silently?".
|
|
6
|
+
*
|
|
7
|
+
* Every check below exists because the class was found in a real audit round:
|
|
8
|
+
* I1 abbreviated tool names in agent-facing text (lean_archive is not a registered tool)
|
|
9
|
+
* I2 a fidelity defect expressed as a 0 vote ("偏离 → 0" records 命题为假)
|
|
10
|
+
* I3 the defect rule missing from the injected text (不要投 0 + decision:'defect')
|
|
11
|
+
* I4 `defect` advertised but not handled by code (the v2 dead-channel class)
|
|
12
|
+
* I5 the reply contract not offering `defect` (a defect would be unrecordable)
|
|
13
|
+
* I6 a defect accepted without a reason (silent, unauditable decisions)
|
|
14
|
+
* I7 the wrong reply field name in the fidelity text (`verdict` where the parser reads `Result`)
|
|
15
|
+
* I8 the `formal` reply channel live in `off` mode (off must be a TRUE no-op)
|
|
16
|
+
* I9 corpus non-determinism / machine-path leaks / missing mode coverage
|
|
17
|
+
* I10 the prompt-rule sensitivity probes going missing (a guard that is not proven to go red)
|
|
18
|
+
* I11 the "no Lean toolchain" guidance naming only one of the two failure codes
|
|
19
|
+
* I12 the fidelity branch promising a hold that `encourage` cannot enforce
|
|
20
|
+
* I13 the Lean switch unreachable THROUGH the closed tool schema (v3 2.3.1: all four params
|
|
21
|
+
* missing from vibe_math_set_params, invisible to every suite because suites call handlers)
|
|
22
|
+
* I14 a parameter the tool schema advertises but the parameter layer silently drops
|
|
23
|
+
*
|
|
24
|
+
* Run: node audit-prompt-invariants.mjs (add --json for a machine-readable report)
|
|
25
|
+
* node audit-prompt-invariants.mjs --self-probe
|
|
26
|
+
* prove the guard is a guard: re-run itself on mutated sources and require the matching
|
|
27
|
+
* invariant to go RED (control run must stay green)
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
|
|
34
|
+
const HERE = fileURLToPath(new URL('./', import.meta.url))
|
|
35
|
+
/**
|
|
36
|
+
* `PROMPT_INVARIANTS_MUTATE` carries a JSON `[rel, from, to]` triple: a single file is mutated IN
|
|
37
|
+
* MEMORY for one child run, so `--self-probe` can demonstrate that the invariant keyed to it really
|
|
38
|
+
* turns red. (Not a NUL-separated string: env values may not contain NUL bytes.)
|
|
39
|
+
*/
|
|
40
|
+
function readRaw(rel) {
|
|
41
|
+
const p = join(HERE, rel)
|
|
42
|
+
if (!existsSync(p)) return null
|
|
43
|
+
let text = readFileSync(p, 'utf8')
|
|
44
|
+
const mut = process.env.PROMPT_INVARIANTS_MUTATE
|
|
45
|
+
if (mut) {
|
|
46
|
+
try {
|
|
47
|
+
const [rel2, from, to] = JSON.parse(mut)
|
|
48
|
+
if (rel2 === rel && from) text = text.replace(from, to)
|
|
49
|
+
} catch (e) { /* a malformed mutation is a harness error, not an invariant failure */ }
|
|
50
|
+
}
|
|
51
|
+
return text
|
|
52
|
+
}
|
|
53
|
+
const read = readRaw
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Each mutation is a real defect shape from this project's history. `expect` is a substring that
|
|
57
|
+
* MUST appear in the failing run; `control: true` marks the unmutated run, which must stay green.
|
|
58
|
+
*/
|
|
59
|
+
const SELF_PROBE_MUTATIONS = [
|
|
60
|
+
{ name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
|
|
61
|
+
{
|
|
62
|
+
name: 'v3: one set_params registration loses the Lean params (I13 — the v2.3.1 shipped defect)',
|
|
63
|
+
rel: 'vibe-math-v3/vibe-math-v3.js',
|
|
64
|
+
from: 'formalVerify: { type: \'string\'',
|
|
65
|
+
to: 'formalVerifyDISABLED: { type: \'string\'',
|
|
66
|
+
expect: 'v3 I13: every vibe_math_set_params schema advertises',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'v3: objParams stops closing the schema (I13 premise)',
|
|
70
|
+
rel: 'vibe-math-v3/vibe-math-v3.js',
|
|
71
|
+
from: "additionalProperties: false, required: required || [] }",
|
|
72
|
+
to: 'required: required || [] }',
|
|
73
|
+
expect: 'v3 I13: every objParams definition closes tool schemas',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'v4: the schema advertises a knob the parameter layer drops (I14)',
|
|
77
|
+
rel: 'vibe-math-v4/vibe-math-v4.js',
|
|
78
|
+
from: "leanTimeoutMs:{type:'integer'}",
|
|
79
|
+
to: "leanTimeoutMs:{type:'integer'},bogusKnob:{type:'string'}",
|
|
80
|
+
expect: 'v4 I14: every key vibe_v4_set advertises is actually accepted',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'v5: normalizeParams stops accepting an advertised key (I14)',
|
|
84
|
+
rel: 'vibe-math-v5/vibe-math-v5.js',
|
|
85
|
+
from: "'meetingKeepEvery', 'leanTimeoutMs']",
|
|
86
|
+
to: "'meetingKeepEvery']",
|
|
87
|
+
expect: 'advertised but dropped: [leanTimeoutMs]',
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if (process.argv.includes('--self-probe')) {
|
|
92
|
+
const bad = []
|
|
93
|
+
for (const mut of SELF_PROBE_MUTATIONS) {
|
|
94
|
+
const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
|
|
95
|
+
cwd: HERE,
|
|
96
|
+
env: Object.assign({}, process.env, mut.control ? {} : { PROMPT_INVARIANTS_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
})
|
|
99
|
+
let parsed = null
|
|
100
|
+
try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
|
|
101
|
+
const text = (r.stdout || '') + (r.stderr || '')
|
|
102
|
+
if (mut.control) {
|
|
103
|
+
const ok = r.status === 0 && parsed && parsed.failed === 0
|
|
104
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' failures' : 'unparsable output') + ')')
|
|
105
|
+
if (!ok) bad.push('control run was not green')
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const hit = text.includes(mut.expect)
|
|
109
|
+
const red = r.status === 1 && parsed && parsed.failed > 0
|
|
110
|
+
const ok = red && hit
|
|
111
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit))
|
|
112
|
+
if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
|
|
113
|
+
}
|
|
114
|
+
console.log('')
|
|
115
|
+
console.log('PROMPT INVARIANT SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
|
|
116
|
+
for (const b of bad) console.error(' FAIL ' + b)
|
|
117
|
+
process.exit(bad.length === 0 ? 0 : 1)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Blank out comments (line + block) while preserving string/template REGEX literals, so invariants
|
|
122
|
+
* about "text shown to an agent" do not fire on a COMMENT that quotes an anti-pattern. A naive regex
|
|
123
|
+
* would both miss block comments and mangle strings containing `//` (URLs), so this walks the
|
|
124
|
+
* source as a tiny scanner. Newlines are preserved to keep any line-based diagnostics aligned.
|
|
125
|
+
*
|
|
126
|
+
* Regex literals matter: all four presets contain `/[\\/:*?"<>|\u0000-\u001f]+/` — a regex whose
|
|
127
|
+
* character class contains a DOUBLE QUOTE. Without regex handling the scanner treats that quote as
|
|
128
|
+
* the start of a string, keeps the state machine wrong for the rest of the file, and then either
|
|
129
|
+
* leaves a comment in the "code" stream (a spurious invariant failure) or blanks real code (a
|
|
130
|
+
* missed defect). X5/X6 below are self-checks for exactly that.
|
|
131
|
+
*/
|
|
132
|
+
function stripComments(src) {
|
|
133
|
+
const out = []
|
|
134
|
+
let i = 0
|
|
135
|
+
let state = 'code' // code | line | block | sq | dq | tpl | regex
|
|
136
|
+
let prev = '' // last significant code char, to tell a regex literal from division
|
|
137
|
+
while (i < src.length) {
|
|
138
|
+
const c = src[i]
|
|
139
|
+
const c2 = src[i + 1]
|
|
140
|
+
if (state === 'code') {
|
|
141
|
+
if (c === '/' && c2 === '/') { state = 'line'; out.push(' '); i += 2; continue }
|
|
142
|
+
if (c === '/' && c2 === '*') { state = 'block'; out.push(' '); i += 2; continue }
|
|
143
|
+
if (c === '/' && /[(,=:[!&|?{};+\-*%~^<>]|^$/.test(prev)) { state = 'regex'; out.push(c); i++; continue }
|
|
144
|
+
if (c === "'") state = 'sq'
|
|
145
|
+
else if (c === '"') state = 'dq'
|
|
146
|
+
else if (c === '`') state = 'tpl'
|
|
147
|
+
if (!/\s/.test(c)) prev = c
|
|
148
|
+
out.push(c); i++; continue
|
|
149
|
+
}
|
|
150
|
+
if (state === 'line') {
|
|
151
|
+
if (c === '\n') { state = 'code'; out.push(c) } else out.push(' ')
|
|
152
|
+
i++; continue
|
|
153
|
+
}
|
|
154
|
+
if (state === 'block') {
|
|
155
|
+
if (c === '*' && c2 === '/') { state = 'code'; out.push(' '); i += 2; continue }
|
|
156
|
+
out.push(c === '\n' ? c : ' '); i++; continue
|
|
157
|
+
}
|
|
158
|
+
if (state === 'regex') {
|
|
159
|
+
if (c === '\\') { out.push(c, c2 === undefined ? '' : c2); i += 2; continue }
|
|
160
|
+
if (c === '[') { state = 'regexClass'; out.push(c); i++; continue }
|
|
161
|
+
if (c === '/') { // end of the literal: copy it plus any flags
|
|
162
|
+
out.push(c); i++
|
|
163
|
+
while (i < src.length && /[a-z]/i.test(src[i])) { out.push(src[i]); i++ }
|
|
164
|
+
state = 'code'; prev = ')'
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
out.push(c); i++; continue
|
|
168
|
+
}
|
|
169
|
+
if (state === 'regexClass') {
|
|
170
|
+
if (c === '\\') { out.push(c, c2 === undefined ? '' : c2); i += 2; continue }
|
|
171
|
+
if (c === ']') state = 'regex'
|
|
172
|
+
out.push(c); i++; continue
|
|
173
|
+
}
|
|
174
|
+
// inside a string/template: copy verbatim, honouring escapes and the closing quote
|
|
175
|
+
if (c === '\\') { out.push(c, c2 === undefined ? '' : c2); i += 2; continue }
|
|
176
|
+
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"') || (state === 'tpl' && c === '`')) state = 'code'
|
|
177
|
+
out.push(c); i++
|
|
178
|
+
}
|
|
179
|
+
return out.join('')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Balanced-bracket slice starting at src[openIdx] (one of ( [ {), string/comment aware.
|
|
184
|
+
* Used by the TOOL-SURFACE invariants (I13/I14) to read a real parameter schema out of the source.
|
|
185
|
+
*/
|
|
186
|
+
function balanced(src, openIdx) {
|
|
187
|
+
let depth = 0
|
|
188
|
+
let i = openIdx
|
|
189
|
+
let state = 'code'
|
|
190
|
+
while (i < src.length) {
|
|
191
|
+
const c = src[i]
|
|
192
|
+
const c2 = src[i + 1]
|
|
193
|
+
if (state === 'code') {
|
|
194
|
+
if (c === "'" || c === '"' || c === '`') { state = c; i++; continue }
|
|
195
|
+
if (c === '(' || c === '[' || c === '{') depth++
|
|
196
|
+
else if (c === ')' || c === ']' || c === '}') { depth--; if (depth === 0) return src.slice(openIdx, i + 1) }
|
|
197
|
+
i++; continue
|
|
198
|
+
}
|
|
199
|
+
if (c === '\\') { i += 2; continue }
|
|
200
|
+
if (c === state) state = 'code'
|
|
201
|
+
i++
|
|
202
|
+
}
|
|
203
|
+
return src.slice(openIdx)
|
|
204
|
+
}
|
|
205
|
+
/** Split an object-body on TOP-LEVEL commas only (brackets and strings respected). */
|
|
206
|
+
function splitTopLevel(body) {
|
|
207
|
+
const parts = []
|
|
208
|
+
let cur = ''
|
|
209
|
+
let depth = 0
|
|
210
|
+
let state = 'code'
|
|
211
|
+
for (let i = 0; i < body.length; i++) {
|
|
212
|
+
const c = body[i]
|
|
213
|
+
const c2 = body[i + 1]
|
|
214
|
+
if (state === 'code') {
|
|
215
|
+
if (c === "'" || c === '"' || c === '`') { state = c; cur += c; continue }
|
|
216
|
+
if (c === '{' || c === '[' || c === '(') depth++
|
|
217
|
+
else if (c === '}' || c === ']' || c === ')') depth--
|
|
218
|
+
if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue }
|
|
219
|
+
cur += c; continue
|
|
220
|
+
}
|
|
221
|
+
if (c === '\\') { cur += c + (c2 || ''); i++; continue }
|
|
222
|
+
if (c === state) state = 'code'
|
|
223
|
+
cur += c
|
|
224
|
+
}
|
|
225
|
+
parts.push(cur)
|
|
226
|
+
return parts.map((p) => p.trim()).filter(Boolean)
|
|
227
|
+
}
|
|
228
|
+
/** Property keys of the object literal at/after `at` (null when there is no object literal there). */
|
|
229
|
+
function objectKeys(src, at) {
|
|
230
|
+
const open = src.indexOf('{', at)
|
|
231
|
+
if (open < 0) return null
|
|
232
|
+
const region = balanced(src, open)
|
|
233
|
+
return splitTopLevel(region.slice(1, -1))
|
|
234
|
+
.map((p) => { const m = p.match(/^['"]?([A-Za-z_$][\w$]*)['"]?\s*:/); return m ? m[1] : null })
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const PRESETS = [
|
|
239
|
+
{ tag: 'v2', js: 'vibe-math-v2/vibe-math-v2.js', suite: 'formal-verify-v2.test.mjs', corpus: 'prompt-corpus-v2/formal-verify-v2.md', valueField: 'Result', prefix: 'vibe_math_', setTool: 'vibe_math_set_params' },
|
|
240
|
+
{ tag: 'v3', js: 'vibe-math-v3/vibe-math-v3.js', suite: 'formal-verify-v3.test.mjs', corpus: 'prompt-corpus-v3/formal-verify-v3.md', valueField: 'Result', prefix: 'vibe_math_', setTool: 'vibe_math_set_params' },
|
|
241
|
+
{ tag: 'v4', js: 'vibe-math-v4/vibe-math-v4.js', suite: 'formal-verify-v4.test.mjs', corpus: 'prompt-corpus-v4/formal-verify-v4.md', valueField: 'verdict', prefix: 'vibe_v4_', setTool: 'vibe_v4_set' },
|
|
242
|
+
{ tag: 'v5', js: 'vibe-math-v5/vibe-math-v5.js', suite: 'formal-verify-v5.test.mjs', corpus: 'prompt-corpus-v5/prompt-corpus-v5.md', valueField: 'verdict', prefix: 'vibe_v5_', setTool: 'vibe_v5_set' },
|
|
243
|
+
]
|
|
244
|
+
const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']
|
|
245
|
+
|
|
246
|
+
let passed = 0
|
|
247
|
+
const failures = []
|
|
248
|
+
const notes = []
|
|
249
|
+
function check(cond, label, detail) {
|
|
250
|
+
if (cond) { passed++; return true }
|
|
251
|
+
failures.push(label + (detail ? ' — ' + detail : ''))
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const P of PRESETS) {
|
|
256
|
+
const js = read(P.js)
|
|
257
|
+
const suite = read(P.suite)
|
|
258
|
+
const corpus = read(P.corpus)
|
|
259
|
+
if (!check(js !== null, P.tag + ': plugin source readable', P.js)) continue
|
|
260
|
+
if (!check(suite !== null, P.tag + ': suite readable', P.suite)) continue
|
|
261
|
+
if (!check(corpus !== null, P.tag + ': prompt corpus shipped', P.corpus)) continue
|
|
262
|
+
|
|
263
|
+
// I1/I2 run on the source with comments blanked: a comment may legitimately quote an
|
|
264
|
+
// anti-pattern as documentation, but any STRING can reach an agent.
|
|
265
|
+
const code = stripComments(js)
|
|
266
|
+
|
|
267
|
+
// I1 — no abbreviated tool name in code/strings (code identifiers are leanArchive / leanRunTool,
|
|
268
|
+
// so a bare lean_* token is always a string that can be shown to an agent).
|
|
269
|
+
const bare = code.match(/(^|[^A-Za-z0-9_])lean_(run|archive|lib)\b/g) || []
|
|
270
|
+
check(bare.length === 0, P.tag + ' I1: no abbreviated Lean tool name in agent-facing text', 'found ' + JSON.stringify(bare.slice(0, 3)))
|
|
271
|
+
|
|
272
|
+
// I2 — never tell a voter to answer 0 for a faithfulness defect (comments excluded).
|
|
273
|
+
check(!/偏离\s*(?:→|->|=>)\s*0/.test(code), P.tag + ' I2: no "偏离 → 0" instruction')
|
|
274
|
+
|
|
275
|
+
// I3 — the defect rule is in the injected text.
|
|
276
|
+
check(js.includes('不要投 0'), P.tag + ' I3: injected text forbids a 0 vote on a defect')
|
|
277
|
+
check(/'defect'/.test(js) || /"defect"/.test(js), P.tag + ' I3: injected text names decision=\'defect\'')
|
|
278
|
+
|
|
279
|
+
// I4 — the defect decision is actually HANDLED (comparison + a downgrade to `attempted`).
|
|
280
|
+
const comparesDefect = /(?:===|==)\s*'defect'/.test(js) || /'defect'\s*(?:===|==)/.test(js)
|
|
281
|
+
check(comparesDefect, P.tag + ' I4: the code compares decision against \'defect\'')
|
|
282
|
+
const downgrades = /status:\s*'attempted'/.test(js) || /status='attempted'/.test(js) || /status:\s*"attempted"/.test(js)
|
|
283
|
+
check(downgrades, P.tag + " I4: a defect downgrades the record to 'attempted'")
|
|
284
|
+
|
|
285
|
+
// I5 — the reply contract offers the defect decision.
|
|
286
|
+
const contractCount = js.split('"decision":"used|blocked|defect"').length - 1
|
|
287
|
+
check(contractCount >= 1, P.tag + ' I5: the reply contract offers used|blocked|defect', 'occurrences=' + contractCount)
|
|
288
|
+
|
|
289
|
+
// I6 — a defect without a reason is refused.
|
|
290
|
+
check(/!note/.test(js), P.tag + ' I6: a defect/blocker without a note is rejected')
|
|
291
|
+
|
|
292
|
+
// I7 — the fidelity instruction names the field the parser really reads.
|
|
293
|
+
{
|
|
294
|
+
const lines = js.split(/\r?\n/)
|
|
295
|
+
const i = lines.findIndex((l) => l.includes('不要投 0'))
|
|
296
|
+
const window = i >= 0 ? lines.slice(Math.max(0, i - 3), i + 8).join('\n') : ''
|
|
297
|
+
check(i >= 0 && window.includes(P.valueField),
|
|
298
|
+
P.tag + ' I7: the fidelity instruction names ' + P.valueField + ' (the real reply field)',
|
|
299
|
+
i < 0 ? 'the 不要投 0 rule was not found' : 'window: ' + window.slice(0, 120).replace(/\n/g, ' | '))
|
|
300
|
+
if (P.tag === 'v2' || P.tag === 'v3') {
|
|
301
|
+
check(!/给出\s*verdict/.test(js), P.tag + ' I7b: no "给出 verdict" in a Result-based preset')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// I8 — the `formal` reply channel is inert in off mode (tools stay usable on purpose).
|
|
306
|
+
const offGuards = {
|
|
307
|
+
v2: /absorbFormal(?:From)?Reply[\s\S]{0,900}!formalOn\(\)/,
|
|
308
|
+
v3: /absorbFormal(?:From)?Reply[\s\S]{0,900}!formalOn\(\)/,
|
|
309
|
+
v4: /applyFormalReply[\s\S]{0,900}!formalOn\(\)/,
|
|
310
|
+
v5: /formalOn\(\)\s*&&\s*p\.formal/,
|
|
311
|
+
}
|
|
312
|
+
check(offGuards[P.tag].test(js), P.tag + ' I8: the reply channel is gated on formalOn() (off stays a no-op)')
|
|
313
|
+
|
|
314
|
+
// I9 — the corpus covers every mode and is deterministic / machine-path free.
|
|
315
|
+
check(corpus.includes('【Lean 形式化验证(鼓励模式)】'), P.tag + ' I9: corpus covers encourage mode')
|
|
316
|
+
check(corpus.includes('【Lean 形式化验证(强制模式)】'), P.tag + ' I9: corpus covers REQUIRE mode')
|
|
317
|
+
check(corpus.includes('不要投 0'), P.tag + ' I9: corpus contains the fidelity/defect rule')
|
|
318
|
+
check(/used\|blocked\|defect/.test(corpus), P.tag + ' I9: corpus contains the reply contract line')
|
|
319
|
+
check(/【顺手形式化/.test(corpus), P.tag + ' I9: corpus contains the work-round line')
|
|
320
|
+
check(!/[A-Za-z]:[\\/]/.test(corpus), P.tag + ' I9: corpus leaks no absolute path')
|
|
321
|
+
check(!/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(corpus), P.tag + ' I9: corpus carries no timestamp (deterministic)')
|
|
322
|
+
check(!/vibe-v[0-9]-[a-z]+-[A-Za-z0-9]{4,}/.test(corpus), P.tag + ' I9: corpus leaks no temp-dir name')
|
|
323
|
+
|
|
324
|
+
// I10 — the prompt-rule probes exist for this preset (a guard nobody can prove is a guard nobody has).
|
|
325
|
+
const probeSrc = read('audit-formal-sensitivity.mjs') || ''
|
|
326
|
+
for (const kind of ['fidelity-defect-rule-removed', 'abbreviated-tool-name-injected', 'require-wording-removed', 'defect-decision-not-offered']) {
|
|
327
|
+
check(probeSrc.includes("tag + '-" + kind + "'") || probeSrc.includes("'" + P.tag + '-' + kind + "'"),
|
|
328
|
+
P.tag + ' I10: probe exists for ' + kind)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// I10b — the suite asserts the defect path (behaviourally, not just wording).
|
|
332
|
+
check(/\bdefect\b/.test(suite), P.tag + ' I10b: the suite exercises the defect path')
|
|
333
|
+
|
|
334
|
+
// I11 — the "no toolchain" guidance must name BOTH failure codes. An agent that only knows
|
|
335
|
+
// LEAN_NOT_FOUND treats NO_SUBPROCESS as an unknown failure and retries instead of recording the
|
|
336
|
+
// blocker (contract §6 hard rule 4).
|
|
337
|
+
{
|
|
338
|
+
const lines = js.split(/\r?\n/)
|
|
339
|
+
const gi = lines.findIndex((l) => l.includes('宿主无 Lean 工具链'))
|
|
340
|
+
const win = gi >= 0 ? lines.slice(Math.max(0, gi - 3), gi + 1).join('\n') : ''
|
|
341
|
+
check(gi >= 0 && /LEAN_NOT_FOUND/.test(win) && /NO_SUBPROCESS/.test(win),
|
|
342
|
+
P.tag + ' I11: the no-toolchain guidance names LEAN_NOT_FOUND AND NO_SUBPROCESS',
|
|
343
|
+
gi < 0 ? 'the guidance line was not found' : 'window: ' + win.slice(0, 140).replace(/\n/g, ' | '))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// I12 — the fidelity branch must qualify its promise BY MODE. Only `require` has a gate, so the
|
|
347
|
+
// text must say "本次裁定不定论" for require AND explicitly tell the voter, for encourage, that
|
|
348
|
+
// this mode has no gate and their abstention is what prevents a conclusion. Shipping the
|
|
349
|
+
// unconditional claim was a real defect that survived in three presets after v5 was fixed.
|
|
350
|
+
check(js.includes('本档没有门禁'), P.tag + ' I12: the fidelity text states the encourage branch has no gate')
|
|
351
|
+
check(js.includes('本次裁定**不定论**') || js.includes('本次裁定不定论'), P.tag + ' I12: the fidelity text states the require hold')
|
|
352
|
+
|
|
353
|
+
// I13 — the Lean switch must be REACHABLE THROUGH THE TOOL SCHEMA. Every tool schema here is
|
|
354
|
+
// built by `objParams`, which closes it with `additionalProperties:false`: a key the schema does
|
|
355
|
+
// not list is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four
|
|
356
|
+
// Lean params missing from `vibe_math_set_params` and every suite stayed green, because the
|
|
357
|
+
// suites call the handler directly and never look at the schema — the feature could not be turned
|
|
358
|
+
// on at all. So: EVERY registration of the parameter tool (v2/v3 register it twice, for two agent
|
|
359
|
+
// scopes) must advertise all four, and the closed-schema premise must still hold.
|
|
360
|
+
{
|
|
361
|
+
// EVERY objParams definition must close its schema: one open definition (v2/v3 define it twice,
|
|
362
|
+
// for two agent scopes) would let tools registered through it accept arbitrary keys.
|
|
363
|
+
const defs = [...code.matchAll(/function objParams\(/g)].map((m) => m.index)
|
|
364
|
+
const closedDefs = defs.filter((i) => /additionalProperties:\s*false/.test(code.slice(i, i + 220)))
|
|
365
|
+
check(defs.length > 0 && closedDefs.length === defs.length,
|
|
366
|
+
P.tag + ' I13: every objParams definition closes tool schemas (additionalProperties:false)',
|
|
367
|
+
'definitions=' + defs.length + ' closed=' + closedDefs.length +
|
|
368
|
+
(defs.length ? '; if this is gone the schema no longer rejects unlisted keys and I13 loses its premise' : ''))
|
|
369
|
+
const regRe = new RegExp("registerTool\\(\\s*['\"]" + P.setTool + "['\"]", 'g')
|
|
370
|
+
const schemas = []
|
|
371
|
+
let m
|
|
372
|
+
while ((m = regRe.exec(code))) {
|
|
373
|
+
const rest = code.slice(m.index)
|
|
374
|
+
const oi = rest.indexOf('objParams(')
|
|
375
|
+
schemas.push(oi < 0 ? null : objectKeys(rest, oi + 'objParams'.length))
|
|
376
|
+
}
|
|
377
|
+
check(schemas.length >= 1, P.tag + ' I13: the parameter tool ' + P.setTool + ' is registered',
|
|
378
|
+
'no registerTool(\'' + P.setTool + '\') call found')
|
|
379
|
+
const bad = schemas.map((s, i) => (s === null ? 'reg#' + i + ': no objParams schema'
|
|
380
|
+
: LEAN_PARAMS.filter((k) => !s.includes(k)))).filter((x) => (Array.isArray(x) ? x.length : true))
|
|
381
|
+
check(schemas.length >= 1 && bad.length === 0,
|
|
382
|
+
P.tag + ' I13: every ' + P.setTool + ' schema advertises ' + LEAN_PARAMS.join('/'),
|
|
383
|
+
bad.length ? JSON.stringify(bad) : 'no registration found')
|
|
384
|
+
|
|
385
|
+
// I14 — a key the schema ACCEPTS must really be accepted by the parameter layer. Otherwise the
|
|
386
|
+
// tool advertises a knob that is silently dropped: the caller sees {ok:true} and nothing changes.
|
|
387
|
+
// The accept gate is DEFAULT_PARAMS for v2/v3/v4 (`if (k in params)`) and the typed lists inside
|
|
388
|
+
// normalizeParams for v5 (only those keys are copied through).
|
|
389
|
+
let accepts = null
|
|
390
|
+
if (P.tag === 'v5') {
|
|
391
|
+
const fn = code.search(/function\s+normalizeParams\s*\(/)
|
|
392
|
+
const region = fn < 0 ? '' : balanced(code, code.indexOf('{', fn))
|
|
393
|
+
const names = []
|
|
394
|
+
for (const kind of ['ints', 'bools', 'strs', 'arrs']) {
|
|
395
|
+
const ai = region.search(new RegExp('const\\s+' + kind + '\\s*=\\s*\\['))
|
|
396
|
+
if (ai < 0) continue
|
|
397
|
+
const arr = balanced(region, region.indexOf('[', ai))
|
|
398
|
+
for (const lit of arr.match(/'[A-Za-z_$][\w$]*'/g) || []) names.push(lit.slice(1, -1))
|
|
399
|
+
}
|
|
400
|
+
accepts = names.length ? [...new Set(names)] : null
|
|
401
|
+
} else {
|
|
402
|
+
const di = code.search(/DEFAULT_PARAMS\s*=\s*\{/)
|
|
403
|
+
accepts = di < 0 ? null : objectKeys(code, di)
|
|
404
|
+
}
|
|
405
|
+
check(accepts !== null && accepts.length > 0, P.tag + ' I14: the parameter accept-set is readable',
|
|
406
|
+
'extractor found no parameter set — the invariant cannot be checked')
|
|
407
|
+
const union = [...new Set(schemas.filter(Boolean).flat())]
|
|
408
|
+
const dropped = accepts ? union.filter((k) => !accepts.includes(k)) : []
|
|
409
|
+
check(accepts !== null && dropped.length === 0,
|
|
410
|
+
P.tag + ' I14: every key ' + P.setTool + ' advertises is actually accepted (no silently-dropped knob)',
|
|
411
|
+
'advertised but dropped: [' + dropped.join(',') + ']')
|
|
412
|
+
check(union.length >= LEAN_PARAMS.length, P.tag + ' I14: the parameter schema was parsed (' + union.length + ' keys)',
|
|
413
|
+
'schema extraction returned ' + union.length + ' keys')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
notes.push(P.tag + ': plugin ' + js.length + 'B · suite ' + suite.length + 'B · corpus ' + corpus.length + 'B')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Cross-preset: the probe script must refuse to report success on an empty selection (false green).
|
|
420
|
+
{
|
|
421
|
+
const probeSrc = read('audit-formal-sensitivity.mjs') || ''
|
|
422
|
+
check(/selected\.length === 0/.test(probeSrc), 'X1: the probe runner fails on an empty selection instead of reporting success')
|
|
423
|
+
const runner = read('run-tests.mjs') || ''
|
|
424
|
+
check(/no suites matched/.test(runner), 'X2: the suite runner fails when no suite matches')
|
|
425
|
+
check(/argv\[i \+ 1\]/.test(runner) && /argv\[\+\+i\]/.test(runner), 'X3: the suite runner accepts both --flag=x and --flag x')
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// X5–X7: guard the guard. I1/I13/I14 trust stripComments(), and all four presets contain a regex
|
|
429
|
+
// literal whose character class holds a double quote (`/[\\/:*?"<>|…]+/`). If the scanner mistook
|
|
430
|
+
// that quote for a string start, the rest of the file would be misread — a comment quoting an
|
|
431
|
+
// anti-pattern would look like code (spurious failure) or real code would be blanked (missed bug).
|
|
432
|
+
{
|
|
433
|
+
const fixtureRegex = 'const t = s.replace(/[\\\\/:*?"<>|]+/g, "-")\n// lean_run is NOT a tool name\nconst b = 1'
|
|
434
|
+
check(!stripComments(fixtureRegex).includes('lean_run'),
|
|
435
|
+
'X5: the comment scanner survives a regex literal containing a quote (a following comment stays blanked)')
|
|
436
|
+
const fixtureUrl = "L.push('see https://example.com/a')\nconst c = 2"
|
|
437
|
+
check(stripComments(fixtureUrl).includes('https://example.com/a'),
|
|
438
|
+
'X6: a // inside a STRING is not treated as a comment start (URLs must survive)')
|
|
439
|
+
const fixtureBlock = "const d = /a\\/b/g\n/* lean_lib quoted in a block comment */\nconst e = 3"
|
|
440
|
+
check(!stripComments(fixtureBlock).includes('lean_lib'),
|
|
441
|
+
'X7: an escaped slash inside a regex does not end it early (a block comment after it stays blanked)')
|
|
442
|
+
for (const [label, src] of [['X5', fixtureRegex], ['X6', fixtureUrl], ['X7', fixtureBlock]]) {
|
|
443
|
+
check(stripComments(src).split('\n').length === src.split('\n').length,
|
|
444
|
+
label + ': comment blanking preserves line structure (diagnostics stay aligned)')
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const out = { passed, failed: failures.length, failures, notes }
|
|
449
|
+
if (process.argv.includes('--json')) {
|
|
450
|
+
console.log(JSON.stringify(out, null, 2))
|
|
451
|
+
} else {
|
|
452
|
+
console.log('-- prompt/interaction invariants (all four presets) --')
|
|
453
|
+
for (const n of notes) console.log(' note ' + n)
|
|
454
|
+
console.log('')
|
|
455
|
+
for (const f of failures) console.error(' FAIL ' + f)
|
|
456
|
+
console.log('')
|
|
457
|
+
console.log('PROMPT INVARIANTS: ' + passed + ' passed, ' + failures.length + ' failed')
|
|
458
|
+
}
|
|
459
|
+
process.exit(failures.length === 0 ? 0 : 1)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SPEC/README ↔ CODE TRACEABILITY (all four presets).
|
|
4
|
+
*
|
|
5
|
+
* This class of drift has produced real bugs here twice: a `/v4` usage string that advertised a
|
|
6
|
+
* `message` subcommand no branch implemented, and personas that never named registered tools. The
|
|
7
|
+
* rule is asymmetric on purpose:
|
|
8
|
+
*
|
|
9
|
+
* · a tool the SPEC/README advertises that the code does NOT register → FAIL (a documented
|
|
10
|
+
* capability that does not exist is a lie the agent will act on);
|
|
11
|
+
* · a tool the code registers that the spec never names → NOTE (documenting
|
|
12
|
+
* every member-only tool in the spec is not always desirable, but the gap must be visible);
|
|
13
|
+
* · the four Lean parameters must be documented in the spec AND the README AND accepted by the
|
|
14
|
+
* code — a parameter nobody documents cannot be discovered, and one nobody accepts cannot be set.
|
|
15
|
+
*
|
|
16
|
+
* Run: node audit-spec-traceability.mjs [--json]
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const HERE = fileURLToPath(new URL('./', import.meta.url))
|
|
23
|
+
const read = (rel) => (existsSync(join(HERE, rel)) ? readFileSync(join(HERE, rel), 'utf8') : null)
|
|
24
|
+
|
|
25
|
+
const PRESETS = [
|
|
26
|
+
{ tag: 'v2', js: 'vibe-math-v2/vibe-math-v2.js', spec: 'vibe-math-v2/实现方案.md', prefix: 'vibe_math_' },
|
|
27
|
+
{ tag: 'v3', js: 'vibe-math-v3/vibe-math-v3.js', spec: 'vibe-math-v3/实现方案.md', prefix: 'vibe_math_' },
|
|
28
|
+
{ tag: 'v4', js: 'vibe-math-v4/vibe-math-v4.js', spec: 'vibe-math-v4/实现方案.md', prefix: 'vibe_v4_' },
|
|
29
|
+
{ tag: 'v5', js: 'vibe-math-v5/vibe-math-v5.js', spec: 'vibe-math-v5/实现方案.md', prefix: 'vibe_v5_' },
|
|
30
|
+
]
|
|
31
|
+
// Tokens that look like tool names but are FILE names / namespace prose, not tools.
|
|
32
|
+
const NOT_A_TOOL = new Set([
|
|
33
|
+
'vibe_math_setting.json', 'vibe_math_installed.json', 'vibe_math_lean', 'vibe_math_lean_',
|
|
34
|
+
'vibe_v4_setting.json', 'vibe_v5_state', 'vibe_v5_state.json', 'vibe_v5_lean', 'vibe_v5_lean_',
|
|
35
|
+
'vibe_v4_lean', 'vibe_v4_lean_', 'vibe_math_state', 'vibe_math_state.json',
|
|
36
|
+
])
|
|
37
|
+
const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']
|
|
38
|
+
|
|
39
|
+
const README = read('README.md') || ''
|
|
40
|
+
const findings = []
|
|
41
|
+
const notes = []
|
|
42
|
+
let passed = 0
|
|
43
|
+
const ok = (cond, label, detail) => {
|
|
44
|
+
if (cond) { passed++; return true }
|
|
45
|
+
findings.push(label + (detail ? ' — ' + detail : ''))
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
// The README documents ALL FOUR presets at once, and v2/v3 share the `vibe_math_` prefix, so a
|
|
49
|
+
// repo-wide token is legitimate if ANY preset registers it. The per-preset SPEC is checked strictly
|
|
50
|
+
// against that preset's own registry.
|
|
51
|
+
const ALL_TOOLS = new Set()
|
|
52
|
+
for (const P of PRESETS) {
|
|
53
|
+
const src = read(P.js)
|
|
54
|
+
if (!src) continue
|
|
55
|
+
for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) ALL_TOOLS.add(m[1])
|
|
56
|
+
}
|
|
57
|
+
/** Collect `vibe_*` tokens that are used as TOOL names (not file names such as vibe_math_setting.json). */
|
|
58
|
+
function toolTokens(text) {
|
|
59
|
+
const out = new Set()
|
|
60
|
+
for (const m of text.matchAll(/\b(vibe_(?:math|v4|v5)_[A-Za-z0-9_]+)/g)) {
|
|
61
|
+
const after = text[m.index + m[0].length] || ''
|
|
62
|
+
if (after === '.') continue // a file name, e.g. vibe_math_setting.json
|
|
63
|
+
// A doc may name a tool precisely to say it does NOT exist ("v4 没有 vibe_v4_propose_verify 这个工具",
|
|
64
|
+
// "写了不存在的工具 …"). That is documentation of the fix, not a phantom capability — skip it, or
|
|
65
|
+
// the guard would forbid the very sentence that records the correction.
|
|
66
|
+
const lineStart = text.lastIndexOf('\n', m.index) + 1
|
|
67
|
+
const lineEnd = text.indexOf('\n', m.index)
|
|
68
|
+
const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd)
|
|
69
|
+
if (/没有|不存在|并非|不是工具|未注册|从未注册|无此|does not exist|no such|never registered|not a tool/i.test(line)) continue
|
|
70
|
+
out.add(m[1])
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const P of PRESETS) {
|
|
76
|
+
const js = read(P.js)
|
|
77
|
+
const spec = read(P.spec)
|
|
78
|
+
if (!js || !spec) { findings.push(P.tag + ': missing plugin or spec file'); continue }
|
|
79
|
+
|
|
80
|
+
const codeTools = new Set([...js.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))
|
|
81
|
+
ok(codeTools.size > 0, P.tag + ': the plugin registers tools at all')
|
|
82
|
+
|
|
83
|
+
// direction 1 (FAIL): anything the spec names as a tool must exist in THIS preset; anything the
|
|
84
|
+
// README names must exist in at least one preset (the README covers all four).
|
|
85
|
+
for (const [label, text, own] of [['实现方案', spec, true], ['README', README, false]]) {
|
|
86
|
+
for (const t of toolTokens(text)) {
|
|
87
|
+
if (NOT_A_TOOL.has(t)) continue
|
|
88
|
+
if (t.endsWith('_')) continue // a `vibe_x_*` wildcard placeholder
|
|
89
|
+
if (!t.startsWith(P.prefix)) continue
|
|
90
|
+
const known = own ? codeTools.has(t) : ALL_TOOLS.has(t)
|
|
91
|
+
if (!known) {
|
|
92
|
+
findings.push(P.tag + ' [' + label + ']: documents tool ' + t
|
|
93
|
+
+ (own ? ' but the plugin never registers it' : ' but NO preset registers it'))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// direction 2 (NOTE): registered but not documented anywhere
|
|
99
|
+
const md = spec + '\n' + README
|
|
100
|
+
const undocumented = [...codeTools].filter((t) => !md.includes(t)).sort()
|
|
101
|
+
if (undocumented.length) notes.push(P.tag + ': registered but not named in spec/README: ' + undocumented.join(', '))
|
|
102
|
+
|
|
103
|
+
// the four Lean parameters: documented in spec + README, and accepted by the code
|
|
104
|
+
for (const prm of LEAN_PARAMS) {
|
|
105
|
+
ok(spec.includes(prm), P.tag + ': 实现方案 documents ' + prm)
|
|
106
|
+
ok(README.includes(prm), P.tag + ': README documents ' + prm)
|
|
107
|
+
ok(js.includes(prm), P.tag + ': the plugin accepts ' + prm)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// the Lean tools must appear in the spec's tool table AND in the code
|
|
111
|
+
for (const t of [P.prefix + 'lean_run', P.prefix + 'lean_archive', P.prefix + 'lean_lib']) {
|
|
112
|
+
ok(codeTools.has(t), P.tag + ': registers ' + t)
|
|
113
|
+
ok(spec.includes(t), P.tag + ': 实现方案 names ' + t)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// the contract §7 requires an ACTIVE stop on timeout: `handle.terminate()`. Relying on the
|
|
117
|
+
// host's `graceMs` alone lets a runaway Lean process linger while the framework reports
|
|
118
|
+
// LEAN_TIMEOUT — three of four presets shipped that way, so this is now guarded statically.
|
|
119
|
+
ok(/terminate\s*\(/.test(js), P.tag + ': the Lean run path actively terminates on timeout (contract §7)')
|
|
120
|
+
|
|
121
|
+
// the contract §8 puts the `require` gate at the ONE choke point that writes a Verified card.
|
|
122
|
+
// A card writer reachable WITHOUT passing a gated function would be a gate bypass, i.e. the
|
|
123
|
+
// framework could conclude a verdict that `require` is supposed to withhold.
|
|
124
|
+
{
|
|
125
|
+
const cfg = {
|
|
126
|
+
v2: { writers: ['writeVerifiedCardIfNeeded', 'writeVerifiedProblemCardIfNeeded'], gate: /formalRequired\s*\(|formalGateOk\s*\(|formalVerdictDeferred\s*\(/ },
|
|
127
|
+
v3: { writers: ['writeVerifiedCardIfChanged', 'writeVerifiedPropositionCardIfNeeded', 'writeVerifiedProblemCardIfNeeded'], gate: /formalBlocksConclusion\s*\(/ },
|
|
128
|
+
v4: { writers: ['writeVerifiedCard'], gate: /formalGateOk\s*\(/ },
|
|
129
|
+
v5: { writers: ['writeVerifiedCard'], gate: /formalGateOk\s*\(/ },
|
|
130
|
+
}[P.tag]
|
|
131
|
+
const lines = js.split(/\r?\n/)
|
|
132
|
+
const fns = []
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
const m = /^(\s*)(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/.exec(lines[i])
|
|
135
|
+
if (m) fns.push({ name: m[2], indent: m[1].length, start: i })
|
|
136
|
+
}
|
|
137
|
+
for (let k = 0; k < fns.length; k++) {
|
|
138
|
+
const f = fns[k]
|
|
139
|
+
let end = lines.length
|
|
140
|
+
for (const g of fns) if (g.start > f.start && g.indent <= f.indent) { end = g.start; break }
|
|
141
|
+
f.body = lines.slice(f.start, end).join('\n')
|
|
142
|
+
}
|
|
143
|
+
const byName = (n) => fns.find((f) => f.name === n)
|
|
144
|
+
for (const w of cfg.writers) {
|
|
145
|
+
const f = byName(w)
|
|
146
|
+
if (!f) { ok(false, P.tag + ' §8: the documented card writer ' + w + ' does not exist'); continue }
|
|
147
|
+
const selfGated = cfg.gate.test(f.body)
|
|
148
|
+
const callers = fns.filter((g) => g !== f && new RegExp('(^|[^A-Za-z0-9_$])' + w + '\\s*\\(').test(g.body))
|
|
149
|
+
const allCallersGatedOrRecursive = callers.length > 0 && callers.every((c) => cfg.gate.test(c.body) || fns.some((d) => cfg.gate.test(d.body) && new RegExp('(^|[^A-Za-z0-9_$])' + c.name + '\\s*\\(').test(d.body)))
|
|
150
|
+
ok(selfGated || allCallersGatedOrRecursive,
|
|
151
|
+
P.tag + ' §8: ' + w + ' is reachable only through a gated function (no gate bypass)',
|
|
152
|
+
'callers: ' + callers.map((c) => c.name).join(', '))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
notes.push(P.tag + ': ' + codeTools.size + ' tools registered; spec ' + spec.length + 'B')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// cross-preset: the shared contract must name the four parameters too
|
|
160
|
+
const contract = read('docs/formal-verification.md') || ''
|
|
161
|
+
for (const prm of LEAN_PARAMS) ok(contract.includes(prm), 'contract documents ' + prm)
|
|
162
|
+
|
|
163
|
+
// The §4 transition table is the normative statement every preset implements. Two rows were WRONG
|
|
164
|
+
// here (the code was right, the contract was not), and a wrong contract is how the next edit gets
|
|
165
|
+
// written:
|
|
166
|
+
// · `used` must NOT withdraw an established proof (only a fidelity `defect` does). v2 actually
|
|
167
|
+
// shipped the downgrade, so the contract literal and reality disagreed in opposite directions.
|
|
168
|
+
// · a RED re-archive of kind='proof' overwrites the work file, so the old archived proof no longer
|
|
169
|
+
// corresponds to any code — it must be retracted, not left at the "everyone looks here" path.
|
|
170
|
+
{
|
|
171
|
+
const usedRow = (contract.split(/\r?\n/).find((l) => /decision:'used'/.test(l)) || '')
|
|
172
|
+
ok(/不得/.test(usedRow) && /passed/.test(usedRow) && /blocked/.test(usedRow),
|
|
173
|
+
"contract §4: the `used` row says an existing passed/blocked is PRESERVED (only `defect` retracts)",
|
|
174
|
+
'row: ' + usedRow.slice(0, 120))
|
|
175
|
+
const runRows = contract.split(/\r?\n/).filter((l) => /^\|\s*`lean_run`/.test(l))
|
|
176
|
+
ok(runRows.length >= 2 && runRows.every((l) => /不降级|保持原状/.test(l)),
|
|
177
|
+
'contract §4: both `lean_run` rows say they do not downgrade an existing passed/blocked',
|
|
178
|
+
'rows: ' + runRows.map((l) => l.slice(0, 60)).join(' || '))
|
|
179
|
+
ok(/该文件最近一次运行\*\*失败\*\*/.test(contract) && /撤回/.test(contract),
|
|
180
|
+
'contract §4: a FAILED proof re-archive is documented as retracting the previous archived proof')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const out = { passed, failed: findings.length, findings, notes }
|
|
184
|
+
if (process.argv.includes('--json')) console.log(JSON.stringify(out, null, 2))
|
|
185
|
+
else {
|
|
186
|
+
console.log('-- spec/README ↔ code traceability --')
|
|
187
|
+
for (const n of notes) console.log(' note ' + n)
|
|
188
|
+
console.log('')
|
|
189
|
+
for (const f of findings) console.error(' FAIL ' + f)
|
|
190
|
+
console.log('')
|
|
191
|
+
console.log('TRACEABILITY: ' + passed + ' passed, ' + findings.length + ' failed')
|
|
192
|
+
}
|
|
193
|
+
process.exit(findings.length === 0 ? 0 : 1)
|