dsh-vibe-math 2.7.3 → 2.8.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/LICENSE +21 -21
- package/README.en.md +682 -682
- package/README.md +683 -683
- package/docs/AUDIT-CHECKLIST.md +755 -744
- package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/audit-methodology.md +93 -0
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +613 -588
- package/docs/generate_framework_diagram_v2.py +114 -114
- package/docs/generate_framework_diagram_v3.py +127 -127
- package/docs/math-computation.md +195 -194
- package/docs/release-notes/README.en.md +1 -0
- package/docs/release-notes/README.md +1 -0
- package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
- package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
- package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
- package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
- package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
- package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
- package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
- package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
- package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
- package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
- package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
- package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
- package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
- package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
- package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
- package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
- package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
- package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
- package/docs/release-playbook.md +103 -0
- package/docs/slv-playbook.md +117 -0
- package/docs/test-timing.md +158 -157
- package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
- package/installer.js +792 -792
- package/package.json +13 -3
- package/prompt-corpus-v2/formal-verify-v2.json +2 -2
- package/prompt-corpus-v2/formal-verify-v2.md +4 -0
- package/prompt-corpus-v3/formal-verify-v3.json +7 -7
- package/prompt-corpus-v3/formal-verify-v3.md +14 -0
- package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
- package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
- package/scripts/clean-temp.mjs +234 -0
- package/tests/audit-engine-faces.mjs +298 -0
- package/tests/audit-formal-sensitivity.mjs +345 -342
- package/tests/audit-installer-compat.test.mjs +256 -256
- package/tests/audit-math-computation-sensitivity.mjs +1 -1
- package/tests/audit-persona-sensitivity.mjs +323 -323
- package/tests/audit-persona-surface.test.mjs +431 -431
- package/tests/audit-prompt-duplication.mutants.mjs +3 -2
- package/tests/audit-prompt-invariants.mjs +589 -546
- package/tests/audit-registration.mjs +78 -78
- package/tests/audit-spec-traceability.mjs +193 -193
- package/tests/audit-status-report-fields.mjs +172 -172
- package/tests/audit-v5-integrity.mjs +795 -795
- package/tests/audit-v5-sensitivity.mjs +433 -432
- package/tests/e2e-v5-round2.test.mjs +2453 -2426
- package/tests/formal-verify-v2.test.mjs +1806 -1800
- package/tests/formal-verify-v3.test.mjs +1757 -1750
- package/tests/formal-verify-v4.test.mjs +2114 -2106
- package/tests/formal-verify-v5.test.mjs +774 -767
- package/tests/math-computation-discovery.mutants.mjs +3 -3
- package/tests/math-computation-shared.test.mjs +122 -5
- package/tests/math-computation-v2.test.mjs +14 -2
- package/tests/math-computation-v3.test.mjs +14 -2
- package/tests/math-computation-v4.test.mjs +739 -739
- package/tests/prompt-v5-integrity.test.mjs +1291 -1291
- package/tests/run-tests.mjs +393 -332
- package/tests/selfdrive-v5.mjs +560 -560
- package/tests/temp-hygiene.mutants.mjs +80 -0
- package/tests/v2-fix-probes.mutants.mjs +4 -0
- package/tests/v2-fix-probes.test.mjs +1034 -1033
- package/tests/v2v3-interrupt.mutants.mjs +111 -0
- package/tests/v3-fix-probes.mutants.mjs +99 -95
- package/tests/v3-fix-probes.test.mjs +891 -890
- package/tests/v4-final-paper.mutants.mjs +108 -0
- package/tests/v5-institute-fixes.mutants.mjs +218 -190
- package/vibe-math-v2/agent.cordis.yml +376 -376
- package/vibe-math-v2/math-computation.js +17 -15
- package/vibe-math-v2/preset.yml +2 -2
- package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
- package/vibe-math-v2/vibe-math-v2.js +5078 -5011
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
- package/vibe-math-v3/agent.cordis.yml +420 -420
- package/vibe-math-v3/math-computation.js +17 -15
- package/vibe-math-v3/preset.yml +2 -2
- package/vibe-math-v3/vibe-math-v3.js +6215 -6149
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
- package/vibe-math-v4/agent.cordis.yml +438 -438
- package/vibe-math-v4/math-computation.js +17 -15
- package/vibe-math-v4/preset.yml +2 -2
- package/vibe-math-v4/vibe-math-v4.js +4490 -4422
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
- package/vibe-math-v5/math-computation.js +17 -15
- package/vibe-math-v5/vibe-math-v5.js +93 -11
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
|
@@ -1,795 +1,795 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// V5 INTEGRITY AUDIT — static self-check of vibe-math-v5.js against the ways this
|
|
3
|
-
// kind of single-file plugin actually breaks:
|
|
4
|
-
// 1. a function CALLED but never defined (ReferenceError at runtime only)
|
|
5
|
-
// 2. a `params.X` read for a key that DEFAULT_PARAMS never declares (silent undefined)
|
|
6
|
-
// 3. a tool handler calling `s.NAME(...)` on the session API object that the session
|
|
7
|
-
// never returns (TypeError only when that tool is used)
|
|
8
|
-
// 4. documented error codes that are never raised, and raised codes never documented
|
|
9
|
-
// 5. leftover development markers / TODO scaffolding
|
|
10
|
-
// Run: node tests/audit-v5-integrity.mjs (exit 1 on any finding)
|
|
11
|
-
// ============================================================
|
|
12
|
-
import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
|
|
13
|
-
import { spawnSync } from 'node:child_process'
|
|
14
|
-
import { tmpdir } from 'node:os'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
|
-
import { fileURLToPath } from 'node:url'
|
|
17
|
-
|
|
18
|
-
const HERE = new URL('../', import.meta.url)
|
|
19
|
-
/**
|
|
20
|
-
* `V5_INTEGRITY_MUTATE` carries a JSON `[rel, from, to]` triple: that ONE repo-relative file is mutated
|
|
21
|
-
* IN MEMORY for one child run. Combined with `--self-probe` this makes every guard below reproducible
|
|
22
|
-
* from OUTSIDE — a verifier no longer has to copy this audit and rewrite its URLs to point it at a
|
|
23
|
-
* mutated copy (R16's request). Same shape as `audit-prompt-invariants.mjs` and the
|
|
24
|
-
* `math-computation-shared --self-probe` pattern.
|
|
25
|
-
*
|
|
26
|
-
* SEAM COVERAGE (R17 measured, both directions): the seam reaches a **plugin** path (a plugin mutation
|
|
27
|
-
* produces 1 finding) AND the **corpus** path (a corpus mutation produces 2 findings) — i.e. it is not
|
|
28
|
-
* limited to the plugin file, and a corpus-only drift is observable from outside too. The corpus is read
|
|
29
|
-
* through the same `readRaw()`, so any new corpus check inherits the seam.
|
|
30
|
-
*/
|
|
31
|
-
const MUT = process.env.V5_INTEGRITY_MUTATE
|
|
32
|
-
function readRaw(rel) {
|
|
33
|
-
let text = readFileSync(new URL(rel, HERE), 'utf8')
|
|
34
|
-
if (MUT) {
|
|
35
|
-
try {
|
|
36
|
-
const [r, from, to] = JSON.parse(MUT)
|
|
37
|
-
if (r === rel && from) text = text.replace(from, to)
|
|
38
|
-
} catch (e) { /* a malformed mutation is a harness error, not an audit failure */ }
|
|
39
|
-
}
|
|
40
|
-
return text
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Each mutation is a REAL defect shape for one of this audit's guards. `expect` is a substring that MUST
|
|
45
|
-
* appear in the failing run; `control: true` marks the unmutated run, which must stay green.
|
|
46
|
-
*/
|
|
47
|
-
const SELF_PROBE_MUTATIONS = [
|
|
48
|
-
{ name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
|
|
49
|
-
{
|
|
50
|
-
name: 'F3: a status() top-level key is renamed (the frozen field surface must redden)',
|
|
51
|
-
rel: 'vibe-math-v5/vibe-math-v5.js',
|
|
52
|
-
from: 'leanNoticesScope: ',
|
|
53
|
-
to: 'leanNoticesScopeRenamed: ',
|
|
54
|
-
expect: 'status() field surface changed',
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
name: 'F1: the README resume claim loses its in-instance case (the pairing gate must redden)',
|
|
58
|
-
rel: 'README.md',
|
|
59
|
-
from: '**同实例重建**(宿主丢了子会话、插件实例还在)**继续**原编号',
|
|
60
|
-
to: '**同实例重建**从 1 重新开始',
|
|
61
|
-
expect: 'README resume-claim side missing',
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
name: 'F1: a code anchor the README cites is removed (its philosophy gate must redden)',
|
|
65
|
-
rel: 'vibe-math-v5/vibe-math-v5.js',
|
|
66
|
-
from: 'rounds.set(member.id, startRound)',
|
|
67
|
-
to: 'void 0 /* anchor removed (self-probe) */',
|
|
68
|
-
expect: 'philosophy gate missing from the implementation: the founding round is applied only AFTER a successful start',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
name: 'F2/G1: the diagram is mutated back to the pre-G1 inbox order (the pairing gate must redden)',
|
|
72
|
-
rel: 'vibe-math-v5/架构图.md',
|
|
73
|
-
from: '唯一的推进驱动',
|
|
74
|
-
to: '唯一的推进驱动(先 ack 再构造)',
|
|
75
|
-
expect: '架构图 still states the pre-G1 order',
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
name: 'F2/G1: the plan is mutated back to the explicit pre-G1 inbox phrasing (the pairing gate must redden)',
|
|
79
|
-
rel: 'vibe-math-v5/实现方案.md',
|
|
80
|
-
from: '**未 ack ⇒ 仍可重投**',
|
|
81
|
-
to: '**先 ack 邮件、再构造**;**未 ack ⇒ 仍可重投**',
|
|
82
|
-
expect: '实现方案.md still states the pre-G1 order',
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
name: 'F2/G1: a docs file is mutated back to the named pre-G1 phrasing (the docs-wide gate must redden)',
|
|
86
|
-
rel: 'docs/AUDIT-CHECKLIST.md',
|
|
87
|
-
from: '身份一律显式传递,绝不猜测。',
|
|
88
|
-
to: '身份一律显式传递,绝不猜测。先 ack 再构造',
|
|
89
|
-
expect: 'still states the pre-G1 order',
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
name: 'F2/G1: the plan is mutated to re-introduce the pre-G1 re-send contract (the named check must redden)',
|
|
93
|
-
rel: 'vibe-math-v5/实现方案.md',
|
|
94
|
-
from: '`makeFileBackend`',
|
|
95
|
-
to: '`makeFileBackend`(queued 永不重发)',
|
|
96
|
-
expect: 'still states the pre-G1 re-send contract',
|
|
97
|
-
},
|
|
98
|
-
]
|
|
99
|
-
|
|
100
|
-
if (process.argv.includes('--self-probe')) {
|
|
101
|
-
const bad = []
|
|
102
|
-
for (const mut of SELF_PROBE_MUTATIONS) {
|
|
103
|
-
const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
|
|
104
|
-
cwd: fileURLToPath(HERE),
|
|
105
|
-
env: Object.assign({}, process.env, mut.control ? {} : { V5_INTEGRITY_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
|
|
106
|
-
encoding: 'utf8',
|
|
107
|
-
})
|
|
108
|
-
let parsed = null
|
|
109
|
-
try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
|
|
110
|
-
const text = (r.stdout || '') + (r.stderr || '')
|
|
111
|
-
if (mut.control) {
|
|
112
|
-
const ok = r.status === 0 && parsed && parsed.failed === 0
|
|
113
|
-
console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' findings, ' + parsed.gates + ' gates' : 'unparsable output') + ')')
|
|
114
|
-
if (!ok) bad.push('control run was not green')
|
|
115
|
-
continue
|
|
116
|
-
}
|
|
117
|
-
const hit = text.includes(mut.expect)
|
|
118
|
-
const red = r.status === 1 && parsed && parsed.failed > 0
|
|
119
|
-
const ok = red && hit
|
|
120
|
-
console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit + ' failed=' + (parsed && parsed.failed)))
|
|
121
|
-
if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
|
|
122
|
-
}
|
|
123
|
-
console.log('')
|
|
124
|
-
console.log('V5 INTEGRITY SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
|
|
125
|
-
for (const b of bad) console.error(' FAIL ' + b)
|
|
126
|
-
process.exit(bad.length === 0 ? 0 : 1)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const FILE_REL = 'vibe-math-v5/vibe-math-v5.js'
|
|
130
|
-
const raw = readRaw(FILE_REL)
|
|
131
|
-
// Strip comments, string literals AND regex literals before any identifier scan. Without this the
|
|
132
|
-
// heuristic matches English words inside comments that merely precede a '(' (e.g.
|
|
133
|
-
// "// per unit (" becomes a phantom call to unit()), drowning the real findings.
|
|
134
|
-
//
|
|
135
|
-
// REGEX LITERALS ARE NOT OPTIONAL HERE: v5 contains `/[\\/:*?"<>|\u0000-\u001f]+/` — a character class
|
|
136
|
-
// holding a DOUBLE QUOTE. A scanner without regex support reads that quote as a string start and
|
|
137
|
-
// mangles the rest of the line. Measured on this very file (old vs new, line by line): 30 of 4365
|
|
138
|
-
// lines differed and the stripped output did NOT parse. No identifier this audit checks
|
|
139
|
-
// (call names, `params.*` reads, `s.*()` methods) happened to be missed in the current source, so this
|
|
140
|
-
// was latent rather than exploited — but a single call appearing only on such a line would have been
|
|
141
|
-
// invisible. The self-check at the bottom of this file keeps the scanner honest.
|
|
142
|
-
// The keyword rule matters for the same reason as in audit-prompt-invariants.mjs (`return /…/`).
|
|
143
|
-
function stripNoise(s) {
|
|
144
|
-
let out = ''
|
|
145
|
-
let i = 0
|
|
146
|
-
const n = s.length
|
|
147
|
-
let prev = '' // last significant token (single char, or a whole word such as `return`)
|
|
148
|
-
let word = ''
|
|
149
|
-
const REGEX_AFTER_KEYWORD = /^(?:return|typeof|case|delete|void|instanceof|in|of|yield|await|new|do|else)$/
|
|
150
|
-
const regexAllowed = () => prev === '' || REGEX_AFTER_KEYWORD.test(prev) || /[(,=:[!&|?{};+\-*%~^<>]/.test(prev)
|
|
151
|
-
while (i < n) {
|
|
152
|
-
const c = s[i], c2 = s[i + 1]
|
|
153
|
-
if (c === '/' && c2 === '/') { while (i < n && s[i] !== '\n') i++; continue }
|
|
154
|
-
if (c === '/' && c2 === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue }
|
|
155
|
-
if (c === '/' && regexAllowed()) {
|
|
156
|
-
i++
|
|
157
|
-
let inClass = false
|
|
158
|
-
while (i < n) {
|
|
159
|
-
if (s[i] === '\\') { i += 2; continue }
|
|
160
|
-
if (s[i] === '[') inClass = true
|
|
161
|
-
else if (s[i] === ']') inClass = false
|
|
162
|
-
else if (s[i] === '/' && !inClass) { i++; break }
|
|
163
|
-
else if (s[i] === '\n') break // a regex literal cannot span lines
|
|
164
|
-
i++
|
|
165
|
-
}
|
|
166
|
-
while (i < n && /[a-z]/i.test(s[i])) i++ // flags
|
|
167
|
-
out += "''" // a value placeholder, like strings: keeps `X: <value>` shapes but no phantom calls
|
|
168
|
-
prev = ')'
|
|
169
|
-
continue
|
|
170
|
-
}
|
|
171
|
-
if (c === "'" || c === '"' || c === '`') {
|
|
172
|
-
const q = c
|
|
173
|
-
i++
|
|
174
|
-
while (i < n) {
|
|
175
|
-
if (s[i] === '\\') { i += 2; continue }
|
|
176
|
-
if (s[i] === q) { i++; break }
|
|
177
|
-
if (q !== '`' && s[i] === '\n') break
|
|
178
|
-
i++
|
|
179
|
-
}
|
|
180
|
-
out += q + q // keep a placeholder so `X: ''` shape survives
|
|
181
|
-
prev = ')'
|
|
182
|
-
continue
|
|
183
|
-
}
|
|
184
|
-
out += c
|
|
185
|
-
if (/[A-Za-z0-9_$]/.test(c)) word += c
|
|
186
|
-
else { if (word) { prev = word; word = '' } if (!/\s/.test(c)) prev = c }
|
|
187
|
-
i++
|
|
188
|
-
}
|
|
189
|
-
return out
|
|
190
|
-
}
|
|
191
|
-
const src = stripNoise(raw)
|
|
192
|
-
const findings = []
|
|
193
|
-
const notes = []
|
|
194
|
-
// How many philosophy gates the run checked (module scope so `--json` can report it).
|
|
195
|
-
let gateCount = 0
|
|
196
|
-
|
|
197
|
-
const lineOf = (idx) => src.slice(0, idx).split('\n').length
|
|
198
|
-
|
|
199
|
-
// ---- 1. called-but-undefined -------------------------------------------
|
|
200
|
-
const defined = new Set()
|
|
201
|
-
for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) defined.add(m[1])
|
|
202
|
-
for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defined.add(m[1])
|
|
203
|
-
for (const m of src.matchAll(/(?:const|let|var)\s+\{([^}]+)\}\s*=/g)) {
|
|
204
|
-
for (const part of m[1].split(',')) { const n = part.split(':').pop().trim(); if (n) defined.add(n) }
|
|
205
|
-
}
|
|
206
|
-
for (const m of src.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) defined.add(m[1])
|
|
207
|
-
// ESM imports are DEFINITIONS too: the preset imports the shared `math_computation` module
|
|
208
|
-
// (INTERFACE-FREEZE §5.1), so calling an imported function is not a ReferenceError. Without
|
|
209
|
-
// this every imported symbol was reported as "called but never defined"; a call to a name that
|
|
210
|
-
// is neither declared nor imported is still flagged.
|
|
211
|
-
for (const m of src.matchAll(/import\s*(?:\*\s*as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s*['"][^'"]*['"]/g)) {
|
|
212
|
-
if (m[1]) defined.add(m[1])
|
|
213
|
-
if (m[2]) defined.add(m[2])
|
|
214
|
-
if (m[3]) {
|
|
215
|
-
for (const part of m[3].split(',')) {
|
|
216
|
-
const name = part.split(/\s+as\s+/).pop().trim()
|
|
217
|
-
if (name) defined.add(name)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Function/method PARAMETERS are locally bound identifiers too, not free calls.
|
|
222
|
-
for (const m of src.matchAll(/function\s*[A-Za-z_$]*\s*\(([^)]*)\)/g)) {
|
|
223
|
-
for (const raw of m[1].split(',')) {
|
|
224
|
-
const name = raw.trim().replace(/[={].*$/s, '').trim()
|
|
225
|
-
if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
for (const m of src.matchAll(/\(([^()]*)\)\s*=>/g)) {
|
|
229
|
-
for (const raw of m[1].split(',')) {
|
|
230
|
-
const name = raw.trim().replace(/[={].*$/s, '').trim()
|
|
231
|
-
if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
for (const m of src.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) defined.add(m[1])
|
|
235
|
-
// JS/DSH globals that are legitimately free
|
|
236
|
-
const GLOBALS = new Set([
|
|
237
|
-
'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function', 'new', 'await', 'delete', 'void', 'do', 'else', 'try',
|
|
238
|
-
'Array', 'Object', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Promise', 'Set', 'Map', 'WeakRef', 'WeakMap', 'Symbol', 'Error',
|
|
239
|
-
'isFinite', 'isNaN', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'structuredClone',
|
|
240
|
-
'AbortSignal', 'console', 'require', 'import', 'super', 'this', 'of', 'in', 'instanceof',
|
|
241
|
-
'RegExp', 'Proxy', 'Reflect', 'AggregateError', 'TextEncoder', 'TextDecoder', 'URL', 'BigInt', 'Intl', 'Buffer', 'process',
|
|
242
|
-
])
|
|
243
|
-
const LOCAL_METHODS = new Set()
|
|
244
|
-
for (const m of src.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*[:(]/g)) LOCAL_METHODS.add(m[1])
|
|
245
|
-
for (const m of src.matchAll(/\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) LOCAL_METHODS.add(m[1])
|
|
246
|
-
|
|
247
|
-
const called = new Map()
|
|
248
|
-
for (const m of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
249
|
-
const name = m[1]
|
|
250
|
-
if (GLOBALS.has(name) || LOCAL_METHODS.has(name)) continue
|
|
251
|
-
if (!called.has(name)) called.set(name, { n: 0, line: lineOf(m.index) })
|
|
252
|
-
called.get(name).n++
|
|
253
|
-
}
|
|
254
|
-
for (const [name, info] of called) {
|
|
255
|
-
if (!defined.has(name)) findings.push('line ' + info.line + ': called but never defined: ' + name + '() (' + info.n + ' call site(s))')
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ---- 2. params keys ----------------------------------------------------
|
|
259
|
-
const dpBlock = /const DEFAULT_PARAMS = \{([\s\S]*?)\n \}/.exec(src)
|
|
260
|
-
if (!dpBlock) findings.push('could not locate DEFAULT_PARAMS')
|
|
261
|
-
else {
|
|
262
|
-
const declared = new Set()
|
|
263
|
-
for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(m[1])
|
|
264
|
-
const used = new Map()
|
|
265
|
-
for (const m of src.matchAll(/\bparams\.([A-Za-z_$][\w$]*)/g)) used.set(m[1], (used.get(m[1]) || 0) + 1)
|
|
266
|
-
for (const [k, n] of used) {
|
|
267
|
-
if (!declared.has(k)) findings.push('params.' + k + ' read but not declared in DEFAULT_PARAMS (' + n + ' use(s))')
|
|
268
|
-
}
|
|
269
|
-
notes.push('DEFAULT_PARAMS keys: ' + declared.size + '; params.* reads: ' + used.size)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ---- 3. session-API surface used by tool handlers -----------------------
|
|
273
|
-
// `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
|
|
274
|
-
// parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
|
|
275
|
-
// parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
|
|
276
|
-
// session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
|
|
277
|
-
// the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
|
|
278
|
-
// actually uses: a plain `function (...)` whose parameter list binds `s`.
|
|
279
|
-
//
|
|
280
|
-
// This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
|
|
281
|
-
// the enclosing session object — the parameter shadows it. The tool handlers themselves are
|
|
282
|
-
// ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
|
|
283
|
-
// self-check at the bottom of this file proves the skip does not blind those.
|
|
284
|
-
function localSParamBodySpans(text) {
|
|
285
|
-
const spans = []
|
|
286
|
-
const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
|
|
287
|
-
let m
|
|
288
|
-
while ((m = re.exec(text)) !== null) {
|
|
289
|
-
const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
|
|
290
|
-
if (params.indexOf('s') === -1) continue
|
|
291
|
-
let i = m.index + m[0].length - 1 // sitting on the opening '{'
|
|
292
|
-
let depth = 0
|
|
293
|
-
for (; i < text.length; i++) {
|
|
294
|
-
if (text[i] === '{') depth++
|
|
295
|
-
else if (text[i] === '}') { depth--; if (depth === 0) break }
|
|
296
|
-
}
|
|
297
|
-
spans.push([m.index, i])
|
|
298
|
-
}
|
|
299
|
-
return spans
|
|
300
|
-
}
|
|
301
|
-
// The returned API object is the last `return { ... }` inside makeSession.
|
|
302
|
-
const apiStart = src.lastIndexOf(' return {\n sessionId,')
|
|
303
|
-
if (apiStart === -1) findings.push('could not locate the session API return object')
|
|
304
|
-
else {
|
|
305
|
-
const apiEnd = src.indexOf('\n }\n }', apiStart)
|
|
306
|
-
const apiText = src.slice(apiStart, apiEnd === -1 ? apiStart + 4000 : apiEnd)
|
|
307
|
-
const apiKeys = new Set()
|
|
308
|
-
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
|
|
309
|
-
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
|
|
310
|
-
const localScopes = localSParamBodySpans(src)
|
|
311
|
-
const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
|
|
312
|
-
const usedOnS = new Map()
|
|
313
|
-
let shadowed = 0
|
|
314
|
-
for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
315
|
-
if (inLocalScope(m.index)) { shadowed++; continue }
|
|
316
|
-
if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
|
|
317
|
-
}
|
|
318
|
-
for (const [k, line] of usedOnS) {
|
|
319
|
-
if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
|
|
320
|
-
}
|
|
321
|
-
notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
|
|
322
|
-
(shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// ---- 4. error codes ----------------------------------------------------
|
|
326
|
-
// Scan the RAW source: these patterns live inside string literals, which `src` strips.
|
|
327
|
-
const raised = new Set()
|
|
328
|
-
for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
|
|
329
|
-
for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
|
|
330
|
-
const docs = readRaw('vibe-math-v5/实现方案.md')
|
|
331
|
-
const documented = new Set()
|
|
332
|
-
for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
|
|
333
|
-
const onlyDocs = new Set()
|
|
334
|
-
for (const m of docs.matchAll(/(V5_[A-Z_]+)/g)) onlyDocs.add(m[1])
|
|
335
|
-
const raisedNotDocumented = [...raised].filter(c => !onlyDocs.has(c))
|
|
336
|
-
const documentedNotRaised = [...documented].filter(c => !raised.has(c))
|
|
337
|
-
if (raisedNotDocumented.length) findings.push('error codes raised but absent from 实现方案.md: ' + raisedNotDocumented.join(', '))
|
|
338
|
-
if (documentedNotRaised.length) notes.push('documented but not raised in code (ok if advisory): ' + documentedNotRaised.join(', '))
|
|
339
|
-
notes.push('error codes raised: ' + raised.size)
|
|
340
|
-
|
|
341
|
-
// ---- 5. leftover scaffolding -------------------------------------------
|
|
342
|
-
for (const bad of ['@@V5_SECTION@@', 'TODO', 'FIXME', 'XXX', 'PLACEHOLDER']) {
|
|
343
|
-
if (src.includes(bad)) findings.push('leftover development marker: ' + bad)
|
|
344
|
-
}
|
|
345
|
-
// every event type declared in EV must be appended somewhere
|
|
346
|
-
const evBlock = /const EV = \{([\s\S]*?)\n\}/.exec(raw)
|
|
347
|
-
if (evBlock) {
|
|
348
|
-
const keys = [...evBlock[1].matchAll(/([A-Za-z]+):\s*'(vibe5\/[a-z]+)'/g)]
|
|
349
|
-
for (const [, prop, literal] of keys) {
|
|
350
|
-
const uses = (raw.match(new RegExp("EV\\." + prop + "\\b", 'g')) || []).length
|
|
351
|
-
if (uses <= 1) findings.push('event type EV.' + prop + ' (' + literal + ') is declared but never committed')
|
|
352
|
-
}
|
|
353
|
-
notes.push('event types declared: ' + keys.length)
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// ---- 6. composition sanity --------------------------------------------
|
|
357
|
-
// The v5 preset is a composition, and a typo in a row `name` is a mounting failure that
|
|
358
|
-
// no unit test of the plugin can catch (the mock calls apply() directly and never goes
|
|
359
|
-
// through the loader). v4 is a proven-good composition mounted on the same hosts, so any
|
|
360
|
-
// package row v5 names that v4 does not is either a genuine new dependency or a typo —
|
|
361
|
-
// and a new dependency must be justified, so surface it for review.
|
|
362
|
-
function packageRows(yaml) {
|
|
363
|
-
const rows = []
|
|
364
|
-
const lines = yaml.split('\n')
|
|
365
|
-
for (let i = 0; i < lines.length; i++) {
|
|
366
|
-
const idm = /^\s*-\s*id:\s*(\S+)\s*$/.exec(lines[i])
|
|
367
|
-
if (!idm) continue
|
|
368
|
-
const row = { id: idm[1], name: '', disabled: false, line: i + 1 }
|
|
369
|
-
for (let j = i + 1; j < lines.length && j < i + 12; j++) {
|
|
370
|
-
if (/^\s*-\s*id:\s*\S+\s*$/.test(lines[j])) break
|
|
371
|
-
const nm = /^\s*name:\s*'?([^'\n]+?)'?\s*$/.exec(lines[j])
|
|
372
|
-
if (nm && !row.name) row.name = nm[1].trim()
|
|
373
|
-
if (/^\s*disabled:\s*true/.test(lines[j])) row.disabled = true
|
|
374
|
-
}
|
|
375
|
-
if (row.name) rows.push(row)
|
|
376
|
-
}
|
|
377
|
-
return rows
|
|
378
|
-
}
|
|
379
|
-
const v5yaml = readRaw('vibe-math-v5/agent.cordis.yml')
|
|
380
|
-
const v4yaml = readRaw('vibe-math-v4/agent.cordis.yml')
|
|
381
|
-
const v5rows = packageRows(v5yaml)
|
|
382
|
-
const v4names = new Set(packageRows(v4yaml).map(r => r.name))
|
|
383
|
-
const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
|
|
384
|
-
if (!v5rows.length) findings.push('composition: no rows parsed out of agent.cordis.yml (is the file still YAML?)')
|
|
385
|
-
for (const r of v5rows) {
|
|
386
|
-
if (r.name === 'cordis:group') continue
|
|
387
|
-
if (r.name.startsWith('./')) {
|
|
388
|
-
if (!RELATIVE_OK.has(r.name)) findings.push('composition line ' + r.line + ': relative row name "' + r.name + '" has no matching file in the preset directory')
|
|
389
|
-
continue
|
|
390
|
-
}
|
|
391
|
-
if (!v4names.has(r.name)) findings.push('composition line ' + r.line + ': row "' + r.id + '" names "' + r.name + '", which v4 does not — verify the package/name is correct')
|
|
392
|
-
}
|
|
393
|
-
// the preset must reference its own plugin row, and that file must exist
|
|
394
|
-
if (!v5rows.some(r => r.name === './vibe-math-v5.js')) findings.push('composition: the preset never mounts ./vibe-math-v5.js')
|
|
395
|
-
// prefix AND text are required on the persona row for the DSH schema and for back-compat
|
|
396
|
-
const personaBlock = /- id:\s*persona[\s\S]*?(?=\n-\s*id:)/.exec(v5yaml)
|
|
397
|
-
if (!personaBlock) findings.push('composition: no persona row found')
|
|
398
|
-
else {
|
|
399
|
-
if (!/^\s*prefix:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the required `prefix` key')
|
|
400
|
-
if (!/^\s*text:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the legacy `text` key')
|
|
401
|
-
}
|
|
402
|
-
notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5rows.filter(r => r.name !== 'cordis:group' && !r.name.startsWith('./') && !v4names.has(r.name)).length)
|
|
403
|
-
|
|
404
|
-
// ---- 7. requirements traceability against the plan ---------------------
|
|
405
|
-
// "No missing logic" is only checkable mechanically if the SPEC is machine-readable.
|
|
406
|
-
// The plan's §15 names every tool and §16 every parameter, so compare those sets
|
|
407
|
-
// against the implementation: a name in the plan but not the code is an unimplemented
|
|
408
|
-
// requirement, and a name in the code but not the plan is undocumented surface.
|
|
409
|
-
{
|
|
410
|
-
const plan = readRaw('vibe-math-v5/实现方案.md')
|
|
411
|
-
const planTools = new Set()
|
|
412
|
-
for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
|
|
413
|
-
const codeTools = new Set()
|
|
414
|
-
for (const m of raw.matchAll(/registerTool\(\s*'(vibe_v5_[a-z_]+)'/g)) codeTools.add(m[1])
|
|
415
|
-
// documented-but-wildcarded placeholders are not real tools
|
|
416
|
-
const IGNORE = new Set(['vibe_v5_', 'vibe_v5_record_', 'vibe_v5_lean_', 'vibe_v5_task_'])
|
|
417
|
-
for (const t of planTools) {
|
|
418
|
-
if (IGNORE.has(t)) continue
|
|
419
|
-
if (!codeTools.has(t)) findings.push('plan names tool ' + t + ' but the plugin never registers it')
|
|
420
|
-
}
|
|
421
|
-
const undocumented = [...codeTools].filter(t => !planTools.has(t))
|
|
422
|
-
if (undocumented.length) notes.push('tools registered but not named in the plan: ' + undocumented.join(', '))
|
|
423
|
-
notes.push('plan tools: ' + planTools.size + '; registered tools: ' + codeTools.size)
|
|
424
|
-
|
|
425
|
-
// Parameters: only the §16 default table, NOT the §15 tool tables (whose rows also
|
|
426
|
-
// begin with a backticked name).
|
|
427
|
-
const declared2 = new Set()
|
|
428
|
-
if (dpBlock) for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared2.add(m[1])
|
|
429
|
-
const sec16 = /##\s*16\.[\s\S]*?(?=\n##\s*17\.)/.exec(plan)
|
|
430
|
-
const paramRows = new Set()
|
|
431
|
-
if (sec16) {
|
|
432
|
-
for (const m of sec16[0].matchAll(/^\|\s*`([A-Za-z][\w]*)`(?:\s*\/\s*`([A-Za-z][\w]*)`)?\s*\|/gm)) {
|
|
433
|
-
paramRows.add(m[1])
|
|
434
|
-
if (m[2]) paramRows.add(m[2])
|
|
435
|
-
}
|
|
436
|
-
} else findings.push('could not locate the plan\'s §16 parameter table')
|
|
437
|
-
for (const p of paramRows) {
|
|
438
|
-
if (!declared2.has(p)) findings.push('plan documents parameter `' + p + '` but DEFAULT_PARAMS does not declare it')
|
|
439
|
-
}
|
|
440
|
-
notes.push('plan §16 parameter rows: ' + paramRows.size)
|
|
441
|
-
|
|
442
|
-
// F3 (docs-vs-behaviour sweep): `status()`'s machine surface. OWNERSHIP (agreed division, so the two
|
|
443
|
-
// guards never both claim the same job):
|
|
444
|
-
// • FIELD-LEVEL reconciliation (field ↔ doc row, BOTH directions) is owned by
|
|
445
|
-
// `tests/audit-status-report-fields.mjs`, which carries the table and prints the
|
|
446
|
-
// `frozen / doc-rows / exceptions` counts;
|
|
447
|
-
// • THIS audit keeps ONLY this frozen-key drift check: the extracted 43 keys below, the printed
|
|
448
|
-
// `captured: N`, and the `<30` anti-vacuity floor. Any addition/removal of a top-level key must be
|
|
449
|
-
// an explicit decision here (and in the owning guard's reconciliation).
|
|
450
|
-
// No "doc half" is promised or planned here — the note printed at the end points at the owner instead.
|
|
451
|
-
const stIdx = raw.indexOf('function status() {')
|
|
452
|
-
const statusKeys = []
|
|
453
|
-
if (stIdx === -1) findings.push('could not locate status() — the machine-surface extraction has no anchor')
|
|
454
|
-
else {
|
|
455
|
-
const open = raw.indexOf('return {', stIdx)
|
|
456
|
-
const close = raw.indexOf('\n }', open)
|
|
457
|
-
// Tokenise the returned literal: every key directly inside it — including SHORTHAND keys that share a
|
|
458
|
-
// line (`institute: instituteName, project, key, phase,`) — while SKIPPING each value (so an
|
|
459
|
-
// identifier used as a value can never be mistaken for a key).
|
|
460
|
-
const body = open === -1 ? '' : raw.slice(open + 'return {'.length, close === -1 ? open + 9000 : close)
|
|
461
|
-
// Comments must be skipped IN PLACE: the payload's comments contain prose with apostrophes, parens and
|
|
462
|
-
// commas (`…delivered ONCE…`, `phase:'failed'`, `(installer review)`), which otherwise corrupt both the
|
|
463
|
-
// depth tracking and the value skipping.
|
|
464
|
-
const skipComment = (s, at) => {
|
|
465
|
-
if (s[at + 1] === '/') { let j = at + 2; while (j < s.length && s[j] !== '\n') j += 1; return j }
|
|
466
|
-
if (s[at + 1] === '*') { const j = s.indexOf('*/', at + 2); return j === -1 ? s.length : j + 2 }
|
|
467
|
-
return -1
|
|
468
|
-
}
|
|
469
|
-
let i = 0, depth = 0
|
|
470
|
-
while (i < body.length) {
|
|
471
|
-
const ch = body[i]
|
|
472
|
-
if (ch === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
|
|
473
|
-
if (ch === '"' || ch === "'") { const q = ch; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
|
|
474
|
-
if (ch === '{' || ch === '[' || ch === '(') { depth += 1; i += 1; continue }
|
|
475
|
-
if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) break; depth -= 1; i += 1; continue }
|
|
476
|
-
if (depth === 0 && /[A-Za-z_$]/.test(ch)) {
|
|
477
|
-
const m = /^[A-Za-z_$][\w$]*/.exec(body.slice(i))
|
|
478
|
-
const after = body.slice(i + m[0].length).replace(/^[\s]+/, '')
|
|
479
|
-
if (after[0] === ':') {
|
|
480
|
-
statusKeys.push(m[0])
|
|
481
|
-
i += m[0].length
|
|
482
|
-
let d = 0
|
|
483
|
-
while (i < body.length) { // skip the VALUE up to the next top-level comma
|
|
484
|
-
const c = body[i]
|
|
485
|
-
if (c === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
|
|
486
|
-
if (c === '"' || c === "'") { const q = c; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
|
|
487
|
-
if (c === '{' || c === '[' || c === '(') { d += 1; i += 1; continue }
|
|
488
|
-
if (c === '}' || c === ']' || c === ')') { if (d === 0) break; d -= 1; i += 1; continue }
|
|
489
|
-
if (c === ',' && d === 0) { i += 1; break }
|
|
490
|
-
i += 1
|
|
491
|
-
}
|
|
492
|
-
continue
|
|
493
|
-
}
|
|
494
|
-
if (after[0] === ',') { statusKeys.push(m[0]); i += m[0].length; continue }
|
|
495
|
-
i += m[0].length; continue
|
|
496
|
-
}
|
|
497
|
-
i += 1
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
notes.push('status() top-level keys captured: ' + statusKeys.length)
|
|
501
|
-
if (statusKeys.length < 30) findings.push('status() key extraction captured only ' + statusKeys.length + ' keys (anchor or payload broke — this check must never pass vacuously)')
|
|
502
|
-
for (const must of ['phase', 'members', 'quorum', 'chat', 'persistence']) {
|
|
503
|
-
if (statusKeys.indexOf(must) === -1) findings.push('status() no longer exposes `' + must + '` (either the extraction anchor or the payload broke)')
|
|
504
|
-
}
|
|
505
|
-
const EXPECTED_STATUS_KEYS = ['ok', 'institute', 'project', 'key', 'phase', 'running', 'autoDone', 'runId', 'leanNotices', 'leanNoticesScope', 'fieldScopes', 'backend', 'diagnostics', 'debug', 'quorum', 'members', 'tasks', 'failedMembers', 'failedMembersNote', 'pendingSpawns', 'pendingSpawnsNote', 'chat', 'chatScope', 'officeRequests', 'officeRequestsShown', 'officeRequestsCap', 'officeRequestsDropped', 'officeRequestsTruncated', 'persistence', 'meeting', 'parkedMeeting', 'verify', 'verifyQueue', 'verified', 'verifiedTrue', 'concludedFalse', 'verifiedNote', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params']
|
|
506
|
-
const statusMissing = EXPECTED_STATUS_KEYS.filter((k) => statusKeys.indexOf(k) === -1)
|
|
507
|
-
const statusAdded = statusKeys.filter((k) => EXPECTED_STATUS_KEYS.indexOf(k) === -1)
|
|
508
|
-
if (statusMissing.length || statusAdded.length) {
|
|
509
|
-
findings.push('status() field surface changed — missing=' + JSON.stringify(statusMissing) + ' added=' + JSON.stringify(statusAdded) +
|
|
510
|
-
': record the decision in this freeze AND let the field-level owner reconcile the documents (tests/audit-status-report-fields.mjs)')
|
|
511
|
-
}
|
|
512
|
-
notes.push('status() field-surface drift: frozen=' + statusKeys.length + ' keys (this audit owns ONLY this freeze + the <30 anti-vacuity floor); ' +
|
|
513
|
-
'field-level reconciliation (field ↔ doc row, both directions) is OWNED BY tests/audit-status-report-fields.mjs, which prints frozen / doc-rows / exceptions')
|
|
514
|
-
|
|
515
|
-
// F1 (docs-vs-behaviour): the README's TWO-CASE `resume` claim must stay paired with the behavioural
|
|
516
|
-
// anchor that pins the in-instance case (the two CODE anchors are gates in the GATES table below). Only
|
|
517
|
-
// the prose side is checked here, and every side reports how much it matched, so this cannot pass vacuously.
|
|
518
|
-
{
|
|
519
|
-
const readme = readRaw('README.md')
|
|
520
|
-
const suite = readRaw('tests/e2e-v5-round2.test.mjs')
|
|
521
|
-
const readmeClaims = [
|
|
522
|
-
['the two-case framing', /`resume`\s*后轮次计数的两种情形/],
|
|
523
|
-
['case 1 = a cross-process reload counts from 1', /跨进程重载[^\n]*从\s*\*\*1\*\*\s*重新计/],
|
|
524
|
-
['case 2 = an in-instance rebuild CONTINUES the numbering (轮次 2)', /同实例重建[^\n]*继续[^\n]*轮次 2/],
|
|
525
|
-
['a failed/invalid start no longer erases the existing count', /失败\/无效的启动\*\*不再抹掉\*\*已有计数/],
|
|
526
|
-
['the claim is pinned to a revision', /口径复核于\s*`?[0-9a-f]{7,40}`?/],
|
|
527
|
-
]
|
|
528
|
-
let readmeHits = 0
|
|
529
|
-
for (const [label, re] of readmeClaims) {
|
|
530
|
-
if (re.test(readme)) readmeHits += 1
|
|
531
|
-
else findings.push('README resume-claim side missing: ' + label)
|
|
532
|
-
}
|
|
533
|
-
const suiteClaims = [
|
|
534
|
-
['the in-instance rebuild is asserted as 轮次 2', /a FAILED resume must not move the round number backwards/],
|
|
535
|
-
['the founding round is asserted as 轮次 1', /the founding prompt starts the member at 轮次 1/],
|
|
536
|
-
]
|
|
537
|
-
let suiteHits = 0
|
|
538
|
-
for (const [label, re] of suiteClaims) {
|
|
539
|
-
if (re.test(suite)) suiteHits += 1
|
|
540
|
-
else findings.push('README resume-claim has no behavioural anchor: ' + label)
|
|
541
|
-
}
|
|
542
|
-
// The README cites exact code/test lines PINNED to a revision ("口径复核于 <sha>"). Those drift by
|
|
543
|
-
// design, so the drift is a measured NOTE carrying the current lines — never a failure.
|
|
544
|
-
const lineOf = (needle) => { const at = raw.indexOf(needle); return at === -1 ? -1 : raw.slice(0, at).split('\n').length }
|
|
545
|
-
const startLine = lineOf('const startRound = (rounds.get(member.id) || 0) + 1')
|
|
546
|
-
const applyLine = lineOf('rounds.set(member.id, startRound)')
|
|
547
|
-
if (startLine === -1 || applyLine === -1) findings.push('the spawnMember round-count anchors are gone (startRound=' + startLine + ', applied=' + applyLine + ')')
|
|
548
|
-
notes.push('F1 resume-claim pairing: README ' + readmeHits + '/' + readmeClaims.length + ' claims, behavioural anchors ' + suiteHits + '/' + suiteClaims.length + '; cited-line drift (README pins a revision): startRound now :' + startLine + ', applied now :' + applyLine)
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// F2/G1 pairing (prose ↔ code): the v5 diagram must not state the PRE-G1 order, and must describe the
|
|
552
|
-
// implemented one. Going through `readRaw` gives this block the same `V5_INTEGRITY_MUTATE` seam as every
|
|
553
|
-
// other file, which is what the `--self-probe` case below relies on.
|
|
554
|
-
{
|
|
555
|
-
const diagram = readRaw('vibe-math-v5/架构图.md')
|
|
556
|
-
const FORBID = /先\s*ack[^\n]{0,8}再构造/
|
|
557
|
-
const CLAUSES = /构造[^\n]{0,12}(后|再)[^\n]{0,12}ack|ack[^\n]{0,12}(仅|只)[^\n]{0,12}(成功|送达)/
|
|
558
|
-
if (FORBID.test(diagram)) findings.push('架构图 still states the pre-G1 order: ack before the prompt is built (F2/G1)')
|
|
559
|
-
if (!CLAUSES.test(diagram)) findings.push('架构图 does not describe the implemented inbox order (construct first / ack only on success)')
|
|
560
|
-
if (!raw.includes('if (ok && prompt.pending.length) await ackPending(')) findings.push('the ack is no longer gated by a successful wake (F2/G1 anchor missing)')
|
|
561
|
-
notes.push('架构图 inbox pairing: forbid-hits=' + (FORBID.test(diagram) ? 1 : 0) + ', clauses=' + (CLAUSES.test(diagram) ? 1 : 0))
|
|
562
|
-
// The PLAN uses the explicit pre-G1 phrases only: a sentence that legitimately QUOTES or FORBIDS the old
|
|
563
|
-
// order must not trip this, so the pattern names the phrases instead of the bare `先 ack`.
|
|
564
|
-
const planDoc = readRaw('vibe-math-v5/实现方案.md')
|
|
565
|
-
const PLAN_FORBID = /先\s*ack\s*邮件[、,,]?\s*再构造|先\s*ack[、,,]?\s*后构造/
|
|
566
|
-
if (PLAN_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 order: ack the mail before building the prompt (F2/G1)')
|
|
567
|
-
notes.push('实现方案.md inbox pairing: forbid-hits=' + (PLAN_FORBID.test(planDoc) ? 1 : 0))
|
|
568
|
-
// The SAME class produced five instances, the last one inside the guard checklist itself, so the scan
|
|
569
|
-
// covers every top-level docs/*.md. The phrase is NAMED (never the bare `先 ack`) so a sentence that
|
|
570
|
-
// legitimately QUOTES or FORBIDS the old order cannot trip it. NOTE (applier): `HERE` is the REPO ROOT
|
|
571
|
-
// (see `new URL(rel, HERE)` above), so the docs directory is `new URL('docs/', HERE)` — NOT `../docs/`.
|
|
572
|
-
const DOC_FORBID = /先\s*ack[^\n]{0,8}再构造/
|
|
573
|
-
let docHits = 0, docFiles = 0
|
|
574
|
-
for (const f of readdirSync(new URL('docs/', HERE)).filter((n) => n.endsWith('.md'))) {
|
|
575
|
-
docFiles += 1
|
|
576
|
-
if (DOC_FORBID.test(readRaw('docs/' + f))) { docHits += 1; findings.push('docs/' + f + ' still states the pre-G1 order: ack the mail before building the prompt (F2/G1)') }
|
|
577
|
-
}
|
|
578
|
-
// The scan covers LIVE docs only. `docs/release-notes/**` is a FROZEN published record: the pre-G1
|
|
579
|
-
// wording there is history, not a stale claim, so it must stay OUT of the scan — and the exemption is
|
|
580
|
-
// ASSERTED (a missing/empty directory fails) so a future change to recursive scanning cannot silently
|
|
581
|
-
// start scanning frozen history. Same convention as audit-readme-counts.mjs's frozen TOTAL-65 notes.
|
|
582
|
-
let frozenNotes = []
|
|
583
|
-
try { frozenNotes = readdirSync(new URL('docs/release-notes/', HERE)).filter((n) => n.endsWith('.md')) } catch (e) { frozenNotes = [] }
|
|
584
|
-
if (!frozenNotes.length) findings.push('the frozen release-notes exemption is EMPTY or missing (docs/release-notes/*.md) — re-decide the scan scope explicitly, never widen it silently')
|
|
585
|
-
notes.push('docs/** inbox pairing: files=' + docFiles + ', forbid-hits=' + docHits + '; frozen docs/release-notes/*.md exempted=' + frozenNotes.length)
|
|
586
|
-
// The plan ALSO carried the pre-G1 re-send contract ("queued 不重发/永不重发") in two sketch lines while
|
|
587
|
-
// its own M2 row already said "未 ack ⇒ 仍可重投". NAMED phrase only, scoped to the plan file, so a
|
|
588
|
-
// sentence that legitimately quotes or forbids the old wording cannot trip it.
|
|
589
|
-
const QUEUED_FORBID = /queued\s*(永不|不)重发/
|
|
590
|
-
if (QUEUED_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 re-send contract (queued 不重发/永不重发): unacked mail is re-deliverable (F2/G1)')
|
|
591
|
-
notes.push('实现方案.md queued-no-resend phrasing: hits=' + (QUEUED_FORBID.test(planDoc) ? 1 : 0))
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
|
|
595
|
-
// still exist so a future edit cannot quietly drop a guard.
|
|
596
|
-
const GATES = [
|
|
597
|
-
['temp workers cannot vote', "if (m.kind === 'temp') return // no vote"],
|
|
598
|
-
['temp workers cannot hire', "if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER'"],
|
|
599
|
-
['only the academician assigns', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks'"],
|
|
600
|
-
['only the academician sets priorities', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities'"],
|
|
601
|
-
['permanent-staff changes need the office', "code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)'"],
|
|
602
|
-
['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
|
|
603
|
-
['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
|
|
604
|
-
['members may reject an assignment', "memberMayRejectAssign: true,"],
|
|
605
|
-
['the charter states the progress definition', "' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
|
|
606
|
-
['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
|
|
607
|
-
['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
|
|
608
|
-
|
|
609
|
-
// ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
|
|
610
|
-
// F1 (README two-case resume claim): the two code anchors the prose cites.
|
|
611
|
-
['the founding round is computed from the stored count (never reset)', 'const startRound = (rounds.get(member.id) || 0) + 1'],
|
|
612
|
-
['the founding round is applied only AFTER a successful start', 'rounds.set(member.id, startRound)'],
|
|
613
|
-
// The 2026-09 field test shipped a framework whose every member brief named the WRONG
|
|
614
|
-
// member. These gates keep the structural fixes in place, and the companion
|
|
615
|
-
// prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
|
|
616
|
-
['the status block takes the member it describes', 'function briefBlock(member, roundNo) {'],
|
|
617
|
-
['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
|
|
618
|
-
['the status block is built from that member, never a global', 'function stateBlock(member, roundNo) {\n return briefBlock(member, roundNo)\n }'],
|
|
619
|
-
['the founding prompt is told the round it is starting (never a stored 0)', 'const prompt = initialPrompt(member, initialTask, mode, startRound)'],
|
|
620
|
-
['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
|
|
621
|
-
['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
|
|
622
|
-
['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
|
|
623
|
-
['leadership text follows the live roster', 'function academicianId() {'],
|
|
624
|
-
['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
|
|
625
|
-
['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
|
|
626
|
-
['the mailbox is acked only AFTER the wake actually succeeded', "if (ok && prompt.pending.length) await ackPending(prompt.pending)"],
|
|
627
|
-
// F2: the three REAL mailbox invariants, each at its own source anchor. `inFlightMessages` is a DSH
|
|
628
|
-
// mailbox internal that v5 does NOT have (dedupe is `delivered` + the per-round injection marks), so
|
|
629
|
-
// no gate may reference it.
|
|
630
|
-
['the acknowledgement is gated by the wake result AND a non-empty pending list', 'const ok = await wakeMember(member, prompt.text, kind)'],
|
|
631
|
-
['the delivered ledger is CAPPED (oldest evicted, never unbounded)', 'const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered'],
|
|
632
|
-
['a prompt prepends only messages not already prepended in this round', 'const fresh = pending.filter((p) => !seen.has(p.id))'],
|
|
633
|
-
['the per-round injection mark is cleared with every concluded attempt', 'inboxInjected.delete(member.id)'],
|
|
634
|
-
['a prepended inbox suppresses the base block (exactly one inbox section per prompt)', "const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)"],
|
|
635
|
-
['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
|
|
636
|
-
['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
|
|
637
|
-
['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
|
|
638
|
-
['a voters-only broadcast is framed as such', "if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text"],
|
|
639
|
-
['relayed messages carry their true sender', "await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })"],
|
|
640
|
-
['a member that failed to provision stays visible', "b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))"],
|
|
641
|
-
['the objection channel is documented in the reply spec', '"reject_assign": {"task_id":"t-3"'],
|
|
642
|
-
['the task-done channel is documented in the reply spec', '"task_done": "t-3",'],
|
|
643
|
-
['the meeting input channel is documented in the reply spec', '"input": "本轮会议/辩论的发言正文'],
|
|
644
|
-
['the work push is paced, not an unbounded loop', 'if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue'],
|
|
645
|
-
|
|
646
|
-
// ── LEAN FORMAL VERIFICATION GATES (docs/formal-verification.md) ─────────
|
|
647
|
-
['the formal-verification mode defaults to off', "formalVerify: 'off',"],
|
|
648
|
-
['an unknown mode degrades to off, never to a stronger mode', "indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'"],
|
|
649
|
-
['the mode is read at prompt-build time (never frozen into the charter)', 'const formalOn = () => formalMode()'],
|
|
650
|
-
['a passing run — not merely an archived file — is what makes an object passed', 'const formalGateOk = (rec) => !!rec && (rec.status === \'passed\' || rec.status === \'blocked\')'],
|
|
651
|
-
['a passing Lean run switches the review subject to fidelity', '你的任务是**忠实性审查**'],
|
|
652
|
-
['the voting prompt tells voters not to re-derive once a proof exists', '**你不需要重新检查推导**'],
|
|
653
|
-
['require mode withholds a verdict without a formal record', 'if (formalMode() === \'require\' && !formalGateOk(rec)) {'],
|
|
654
|
-
['a withheld verdict is recorded as undecided with a machine-readable reason', 'formal-required:尚未取得 Lean 形式化通过'],
|
|
655
|
-
['the withheld object goes on a formalization TODO', "'Formal/TODO.md'"],
|
|
656
|
-
['a blocker record must carry a reason', "if (!note) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '阻塞记录必须写明原因(note)"],
|
|
657
|
-
['the proof of an object is archived under Verified/Lean/', "await writeTextRel('Verified/Lean/' + target + '.lean', body)"],
|
|
658
|
-
['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
|
|
659
|
-
['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
|
|
660
|
-
['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
|
|
661
|
-
['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
|
|
662
|
-
['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
|
|
663
|
-
['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
|
|
664
|
-
]
|
|
665
|
-
for (const [label, needle] of GATES) {
|
|
666
|
-
if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
|
|
667
|
-
}
|
|
668
|
-
notes.push('philosophy gates checked: ' + GATES.length)
|
|
669
|
-
gateCount = GATES.length
|
|
670
|
-
|
|
671
|
-
// The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
|
|
672
|
-
// to read the exact text every member receives without decoding session logs.
|
|
673
|
-
const REPO_ROOT = new URL('../', import.meta.url)
|
|
674
|
-
const needFiles = [
|
|
675
|
-
['the prompt-integrity suite is shipped', 'tests/prompt-v5-integrity.test.mjs'],
|
|
676
|
-
['the machine-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.json'],
|
|
677
|
-
['the human-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.md'],
|
|
678
|
-
['the persona-surface suite is shipped', 'tests/audit-persona-surface.test.mjs'],
|
|
679
|
-
['the four-preset persona corpus is shipped (machine-readable)', 'prompt-corpus-persona/persona-corpus.json'],
|
|
680
|
-
['the four-preset persona corpus is shipped (human-readable)', 'prompt-corpus-persona/persona-corpus.md'],
|
|
681
|
-
]
|
|
682
|
-
for (const [label, rel] of needFiles) {
|
|
683
|
-
let ok = false
|
|
684
|
-
try { ok = existsSync(new URL(rel, REPO_ROOT)) } catch (e) { ok = false }
|
|
685
|
-
if (!ok) findings.push('missing shipped prompt-correctness artifact: ' + label + ' (' + rel + ')')
|
|
686
|
-
}
|
|
687
|
-
notes.push('prompt-correctness artifacts checked: ' + needFiles.length)
|
|
688
|
-
|
|
689
|
-
// The corpus must actually contain the interactions a reviewer needs to see, and must
|
|
690
|
-
// never contain a wrong-identity brief.
|
|
691
|
-
try {
|
|
692
|
-
const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
|
|
693
|
-
const c = JSON.parse(readRaw('prompt-corpus-v5/prompt-corpus-v5.json'))
|
|
694
|
-
const kinds = new Set((c.prompts || []).map(p => p.kind))
|
|
695
|
-
const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
|
|
696
|
-
'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
|
|
697
|
-
'inbox-office', 'inbox-assign', 'inbox-nudge', 'inbox-office-assign', 'inbox-office-nudge',
|
|
698
|
-
'notice', 'notice-claim', 'after-failure',
|
|
699
|
-
// the Lean formal-verification interaction must be reviewable by a human too — including
|
|
700
|
-
// the `require` gate wording and the state a member sees AFTER a fidelity defect withdrew
|
|
701
|
-
// a proof (both were missing from the first corpus, so nobody could read them).
|
|
702
|
-
'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect']
|
|
703
|
-
for (const k of need) if (!kinds.has(k)) findings.push('the prompt corpus is missing a ' + k + ' prompt')
|
|
704
|
-
const all = (c.prompts || []).map(p => p.prompt + '\n' + (p.charter || '')).join('\n')
|
|
705
|
-
if (/你是 \?/.test(all)) findings.push('the prompt corpus contains a wrong-identity "你是 ?" brief')
|
|
706
|
-
if (/\[状态\][^\n]*你是\s+(\S+?)[^\n]*\n/.test(all)) {
|
|
707
|
-
// every [状态] line must name a real member id, never a placeholder
|
|
708
|
-
for (const m of all.match(/\[状态\][^\n]*/g) || []) {
|
|
709
|
-
const id = (/\[状态\]\s*你是\s+(\S+?)(/.exec(m) || [])[1]
|
|
710
|
-
if (!id || id === '?' || id === 'undefined') findings.push('the prompt corpus has a bad identity line: ' + m.slice(0, 60))
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
notes.push('prompt corpus: ' + (c.total || 0) + ' prompts, ' + kinds.size + ' kinds')
|
|
714
|
-
} catch (e) {
|
|
715
|
-
findings.push('the prompt corpus could not be read/parsed: ' + String((e && e.message) || e))
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// The persona surface of ALL FOUR presets is a shipped prompt-correctness artifact too:
|
|
719
|
-
// it is the only prompt the MAIN agent receives, and no e2e suite loads the YAML, so a
|
|
720
|
-
// human reviewer needs the corpus on disk (generated by audit-persona-surface.test.mjs).
|
|
721
|
-
try {
|
|
722
|
-
const pc = JSON.parse(readRaw('prompt-corpus-persona/persona-corpus.json'))
|
|
723
|
-
for (const d of ['vibe-math-v2', 'vibe-math-v3', 'vibe-math-v4', 'vibe-math-v5']) {
|
|
724
|
-
if (!(pc.presets || []).some((p) => p.preset === d)) findings.push('the persona corpus is missing preset ' + d)
|
|
725
|
-
}
|
|
726
|
-
const emptyBlocks = (pc.presets || []).filter((p) => !p.prefix || !p.text).map((p) => p.preset)
|
|
727
|
-
if (emptyBlocks.length) findings.push('the persona corpus has an empty persona block: ' + emptyBlocks.join(', '))
|
|
728
|
-
notes.push('persona corpus: ' + (pc.presets || []).length + ' presets')
|
|
729
|
-
} catch (e) {
|
|
730
|
-
findings.push('the persona corpus could not be read/parsed: ' + String((e && e.message) || e))
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ---- scanner self-check -------------------------------------------------
|
|
735
|
-
// The whole audit rests on stripNoise(); a scanner that mis-lexes silently BLINDS it (that is how the
|
|
736
|
-
// regex-with-a-quote bug lived here). Two checks, both cheap and both measured to be sensitive:
|
|
737
|
-
// · the stripped source must still PARSE (the pre-fix scanner produced a SyntaxError);
|
|
738
|
-
// · a fixture with a quoted character class + a following comment must survive intact.
|
|
739
|
-
{
|
|
740
|
-
const tmp = mkdtempSync(join(tmpdir(), 'v5-integrity-strip-'))
|
|
741
|
-
try {
|
|
742
|
-
const f = join(tmp, 'v5.mjs')
|
|
743
|
-
writeFileSync(f, src)
|
|
744
|
-
const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' })
|
|
745
|
-
if (r.status !== 0) {
|
|
746
|
-
findings.push('stripNoise() corrupted the source it scans (the stripped text does not parse) — every identifier check below is unreliable: ' +
|
|
747
|
-
String(r.stderr || '').split('\n').filter((l) => l.trim()).slice(-2).join(' ').slice(0, 160))
|
|
748
|
-
}
|
|
749
|
-
const fixture = [
|
|
750
|
-
'function f(s) { return s.replace(/["\']/g, "-") }',
|
|
751
|
-
'// phantom_call( must not survive as a call site',
|
|
752
|
-
'const div = 6 / 2',
|
|
753
|
-
].join('\n')
|
|
754
|
-
const stripped = stripNoise(fixture)
|
|
755
|
-
if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
|
|
756
|
-
if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
|
|
757
|
-
// The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
|
|
758
|
-
// real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
|
|
759
|
-
// scope skip silently blinds every tool-handler call — both are failures worth failing on.
|
|
760
|
-
const scopeFixture = [
|
|
761
|
-
'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
|
|
762
|
-
'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
|
|
763
|
-
].join('\n')
|
|
764
|
-
const spans = localSParamBodySpans(scopeFixture)
|
|
765
|
-
const idxShadow = scopeFixture.indexOf('s.trim()')
|
|
766
|
-
const idxApi = scopeFixture.indexOf('s.resume()')
|
|
767
|
-
if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
|
|
768
|
-
if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
|
|
769
|
-
notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
|
|
770
|
-
'; shadowed-param skip=' + spans.length)
|
|
771
|
-
} finally {
|
|
772
|
-
rmSync(tmp, { recursive: true, force: true })
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// ---- report ------------------------------------------------------------
|
|
777
|
-
// `--json` is the machine-readable contract the `--self-probe` children parse (same shape as
|
|
778
|
-
// audit-prompt-invariants.mjs): { gates, failed, findings, notes }. It is emitted BEFORE the human
|
|
779
|
-
// output so a child run's stdout is parseable in one piece.
|
|
780
|
-
if (process.argv.includes('--json')) {
|
|
781
|
-
console.log(JSON.stringify({ gates: gateCount, failed: findings.length, findings, notes }))
|
|
782
|
-
for (const f of findings) console.error(' FINDING: ' + f)
|
|
783
|
-
process.exit(findings.length ? 1 : 0)
|
|
784
|
-
}
|
|
785
|
-
console.log('-- V5 integrity audit --')
|
|
786
|
-
for (const n of notes) console.log(' note: ' + n)
|
|
787
|
-
console.log('')
|
|
788
|
-
if (findings.length) {
|
|
789
|
-
for (const f of findings) console.error(' FINDING: ' + f)
|
|
790
|
-
console.error('')
|
|
791
|
-
console.error(findings.length + ' finding(s)')
|
|
792
|
-
process.exit(1)
|
|
793
|
-
}
|
|
794
|
-
console.log('clean: no undefined calls, no undeclared params keys, no missing session API, no leftover markers')
|
|
795
|
-
process.exit(0)
|
|
1
|
+
// ============================================================
|
|
2
|
+
// V5 INTEGRITY AUDIT — static self-check of vibe-math-v5.js against the ways this
|
|
3
|
+
// kind of single-file plugin actually breaks:
|
|
4
|
+
// 1. a function CALLED but never defined (ReferenceError at runtime only)
|
|
5
|
+
// 2. a `params.X` read for a key that DEFAULT_PARAMS never declares (silent undefined)
|
|
6
|
+
// 3. a tool handler calling `s.NAME(...)` on the session API object that the session
|
|
7
|
+
// never returns (TypeError only when that tool is used)
|
|
8
|
+
// 4. documented error codes that are never raised, and raised codes never documented
|
|
9
|
+
// 5. leftover development markers / TODO scaffolding
|
|
10
|
+
// Run: node tests/audit-v5-integrity.mjs (exit 1 on any finding)
|
|
11
|
+
// ============================================================
|
|
12
|
+
import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const HERE = new URL('../', import.meta.url)
|
|
19
|
+
/**
|
|
20
|
+
* `V5_INTEGRITY_MUTATE` carries a JSON `[rel, from, to]` triple: that ONE repo-relative file is mutated
|
|
21
|
+
* IN MEMORY for one child run. Combined with `--self-probe` this makes every guard below reproducible
|
|
22
|
+
* from OUTSIDE — a verifier no longer has to copy this audit and rewrite its URLs to point it at a
|
|
23
|
+
* mutated copy (R16's request). Same shape as `audit-prompt-invariants.mjs` and the
|
|
24
|
+
* `math-computation-shared --self-probe` pattern.
|
|
25
|
+
*
|
|
26
|
+
* SEAM COVERAGE (R17 measured, both directions): the seam reaches a **plugin** path (a plugin mutation
|
|
27
|
+
* produces 1 finding) AND the **corpus** path (a corpus mutation produces 2 findings) — i.e. it is not
|
|
28
|
+
* limited to the plugin file, and a corpus-only drift is observable from outside too. The corpus is read
|
|
29
|
+
* through the same `readRaw()`, so any new corpus check inherits the seam.
|
|
30
|
+
*/
|
|
31
|
+
const MUT = process.env.V5_INTEGRITY_MUTATE
|
|
32
|
+
function readRaw(rel) {
|
|
33
|
+
let text = readFileSync(new URL(rel, HERE), 'utf8').replace(/\r\n?/g, '\n')
|
|
34
|
+
if (MUT) {
|
|
35
|
+
try {
|
|
36
|
+
const [r, from, to] = JSON.parse(MUT)
|
|
37
|
+
if (r === rel && from) text = text.replace(from, to)
|
|
38
|
+
} catch (e) { /* a malformed mutation is a harness error, not an audit failure */ }
|
|
39
|
+
}
|
|
40
|
+
return text
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Each mutation is a REAL defect shape for one of this audit's guards. `expect` is a substring that MUST
|
|
45
|
+
* appear in the failing run; `control: true` marks the unmutated run, which must stay green.
|
|
46
|
+
*/
|
|
47
|
+
const SELF_PROBE_MUTATIONS = [
|
|
48
|
+
{ name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
|
|
49
|
+
{
|
|
50
|
+
name: 'F3: a status() top-level key is renamed (the frozen field surface must redden)',
|
|
51
|
+
rel: 'vibe-math-v5/vibe-math-v5.js',
|
|
52
|
+
from: 'leanNoticesScope: ',
|
|
53
|
+
to: 'leanNoticesScopeRenamed: ',
|
|
54
|
+
expect: 'status() field surface changed',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'F1: the README resume claim loses its in-instance case (the pairing gate must redden)',
|
|
58
|
+
rel: 'README.md',
|
|
59
|
+
from: '**同实例重建**(宿主丢了子会话、插件实例还在)**继续**原编号',
|
|
60
|
+
to: '**同实例重建**从 1 重新开始',
|
|
61
|
+
expect: 'README resume-claim side missing',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'F1: a code anchor the README cites is removed (its philosophy gate must redden)',
|
|
65
|
+
rel: 'vibe-math-v5/vibe-math-v5.js',
|
|
66
|
+
from: 'rounds.set(member.id, startRound)',
|
|
67
|
+
to: 'void 0 /* anchor removed (self-probe) */',
|
|
68
|
+
expect: 'philosophy gate missing from the implementation: the founding round is applied only AFTER a successful start',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'F2/G1: the diagram is mutated back to the pre-G1 inbox order (the pairing gate must redden)',
|
|
72
|
+
rel: 'vibe-math-v5/架构图.md',
|
|
73
|
+
from: '唯一的推进驱动',
|
|
74
|
+
to: '唯一的推进驱动(先 ack 再构造)',
|
|
75
|
+
expect: '架构图 still states the pre-G1 order',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'F2/G1: the plan is mutated back to the explicit pre-G1 inbox phrasing (the pairing gate must redden)',
|
|
79
|
+
rel: 'vibe-math-v5/实现方案.md',
|
|
80
|
+
from: '**未 ack ⇒ 仍可重投**',
|
|
81
|
+
to: '**先 ack 邮件、再构造**;**未 ack ⇒ 仍可重投**',
|
|
82
|
+
expect: '实现方案.md still states the pre-G1 order',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'F2/G1: a docs file is mutated back to the named pre-G1 phrasing (the docs-wide gate must redden)',
|
|
86
|
+
rel: 'docs/AUDIT-CHECKLIST.md',
|
|
87
|
+
from: '身份一律显式传递,绝不猜测。',
|
|
88
|
+
to: '身份一律显式传递,绝不猜测。先 ack 再构造',
|
|
89
|
+
expect: 'still states the pre-G1 order',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'F2/G1: the plan is mutated to re-introduce the pre-G1 re-send contract (the named check must redden)',
|
|
93
|
+
rel: 'vibe-math-v5/实现方案.md',
|
|
94
|
+
from: '`makeFileBackend`',
|
|
95
|
+
to: '`makeFileBackend`(queued 永不重发)',
|
|
96
|
+
expect: 'still states the pre-G1 re-send contract',
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
if (process.argv.includes('--self-probe')) {
|
|
101
|
+
const bad = []
|
|
102
|
+
for (const mut of SELF_PROBE_MUTATIONS) {
|
|
103
|
+
const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
|
|
104
|
+
cwd: fileURLToPath(HERE),
|
|
105
|
+
env: Object.assign({}, process.env, mut.control ? {} : { V5_INTEGRITY_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
})
|
|
108
|
+
let parsed = null
|
|
109
|
+
try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
|
|
110
|
+
const text = (r.stdout || '') + (r.stderr || '')
|
|
111
|
+
if (mut.control) {
|
|
112
|
+
const ok = r.status === 0 && parsed && parsed.failed === 0
|
|
113
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' findings, ' + parsed.gates + ' gates' : 'unparsable output') + ')')
|
|
114
|
+
if (!ok) bad.push('control run was not green')
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const hit = text.includes(mut.expect)
|
|
118
|
+
const red = r.status === 1 && parsed && parsed.failed > 0
|
|
119
|
+
const ok = red && hit
|
|
120
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit + ' failed=' + (parsed && parsed.failed)))
|
|
121
|
+
if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
|
|
122
|
+
}
|
|
123
|
+
console.log('')
|
|
124
|
+
console.log('V5 INTEGRITY SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
|
|
125
|
+
for (const b of bad) console.error(' FAIL ' + b)
|
|
126
|
+
process.exit(bad.length === 0 ? 0 : 1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const FILE_REL = 'vibe-math-v5/vibe-math-v5.js'
|
|
130
|
+
const raw = readRaw(FILE_REL)
|
|
131
|
+
// Strip comments, string literals AND regex literals before any identifier scan. Without this the
|
|
132
|
+
// heuristic matches English words inside comments that merely precede a '(' (e.g.
|
|
133
|
+
// "// per unit (" becomes a phantom call to unit()), drowning the real findings.
|
|
134
|
+
//
|
|
135
|
+
// REGEX LITERALS ARE NOT OPTIONAL HERE: v5 contains `/[\\/:*?"<>|\u0000-\u001f]+/` — a character class
|
|
136
|
+
// holding a DOUBLE QUOTE. A scanner without regex support reads that quote as a string start and
|
|
137
|
+
// mangles the rest of the line. Measured on this very file (old vs new, line by line): 30 of 4365
|
|
138
|
+
// lines differed and the stripped output did NOT parse. No identifier this audit checks
|
|
139
|
+
// (call names, `params.*` reads, `s.*()` methods) happened to be missed in the current source, so this
|
|
140
|
+
// was latent rather than exploited — but a single call appearing only on such a line would have been
|
|
141
|
+
// invisible. The self-check at the bottom of this file keeps the scanner honest.
|
|
142
|
+
// The keyword rule matters for the same reason as in audit-prompt-invariants.mjs (`return /…/`).
|
|
143
|
+
function stripNoise(s) {
|
|
144
|
+
let out = ''
|
|
145
|
+
let i = 0
|
|
146
|
+
const n = s.length
|
|
147
|
+
let prev = '' // last significant token (single char, or a whole word such as `return`)
|
|
148
|
+
let word = ''
|
|
149
|
+
const REGEX_AFTER_KEYWORD = /^(?:return|typeof|case|delete|void|instanceof|in|of|yield|await|new|do|else)$/
|
|
150
|
+
const regexAllowed = () => prev === '' || REGEX_AFTER_KEYWORD.test(prev) || /[(,=:[!&|?{};+\-*%~^<>]/.test(prev)
|
|
151
|
+
while (i < n) {
|
|
152
|
+
const c = s[i], c2 = s[i + 1]
|
|
153
|
+
if (c === '/' && c2 === '/') { while (i < n && s[i] !== '\n') i++; continue }
|
|
154
|
+
if (c === '/' && c2 === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue }
|
|
155
|
+
if (c === '/' && regexAllowed()) {
|
|
156
|
+
i++
|
|
157
|
+
let inClass = false
|
|
158
|
+
while (i < n) {
|
|
159
|
+
if (s[i] === '\\') { i += 2; continue }
|
|
160
|
+
if (s[i] === '[') inClass = true
|
|
161
|
+
else if (s[i] === ']') inClass = false
|
|
162
|
+
else if (s[i] === '/' && !inClass) { i++; break }
|
|
163
|
+
else if (s[i] === '\n') break // a regex literal cannot span lines
|
|
164
|
+
i++
|
|
165
|
+
}
|
|
166
|
+
while (i < n && /[a-z]/i.test(s[i])) i++ // flags
|
|
167
|
+
out += "''" // a value placeholder, like strings: keeps `X: <value>` shapes but no phantom calls
|
|
168
|
+
prev = ')'
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
172
|
+
const q = c
|
|
173
|
+
i++
|
|
174
|
+
while (i < n) {
|
|
175
|
+
if (s[i] === '\\') { i += 2; continue }
|
|
176
|
+
if (s[i] === q) { i++; break }
|
|
177
|
+
if (q !== '`' && s[i] === '\n') break
|
|
178
|
+
i++
|
|
179
|
+
}
|
|
180
|
+
out += q + q // keep a placeholder so `X: ''` shape survives
|
|
181
|
+
prev = ')'
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
out += c
|
|
185
|
+
if (/[A-Za-z0-9_$]/.test(c)) word += c
|
|
186
|
+
else { if (word) { prev = word; word = '' } if (!/\s/.test(c)) prev = c }
|
|
187
|
+
i++
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
const src = stripNoise(raw)
|
|
192
|
+
const findings = []
|
|
193
|
+
const notes = []
|
|
194
|
+
// How many philosophy gates the run checked (module scope so `--json` can report it).
|
|
195
|
+
let gateCount = 0
|
|
196
|
+
|
|
197
|
+
const lineOf = (idx) => src.slice(0, idx).split('\n').length
|
|
198
|
+
|
|
199
|
+
// ---- 1. called-but-undefined -------------------------------------------
|
|
200
|
+
const defined = new Set()
|
|
201
|
+
for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) defined.add(m[1])
|
|
202
|
+
for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defined.add(m[1])
|
|
203
|
+
for (const m of src.matchAll(/(?:const|let|var)\s+\{([^}]+)\}\s*=/g)) {
|
|
204
|
+
for (const part of m[1].split(',')) { const n = part.split(':').pop().trim(); if (n) defined.add(n) }
|
|
205
|
+
}
|
|
206
|
+
for (const m of src.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) defined.add(m[1])
|
|
207
|
+
// ESM imports are DEFINITIONS too: the preset imports the shared `math_computation` module
|
|
208
|
+
// (INTERFACE-FREEZE §5.1), so calling an imported function is not a ReferenceError. Without
|
|
209
|
+
// this every imported symbol was reported as "called but never defined"; a call to a name that
|
|
210
|
+
// is neither declared nor imported is still flagged.
|
|
211
|
+
for (const m of src.matchAll(/import\s*(?:\*\s*as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s*['"][^'"]*['"]/g)) {
|
|
212
|
+
if (m[1]) defined.add(m[1])
|
|
213
|
+
if (m[2]) defined.add(m[2])
|
|
214
|
+
if (m[3]) {
|
|
215
|
+
for (const part of m[3].split(',')) {
|
|
216
|
+
const name = part.split(/\s+as\s+/).pop().trim()
|
|
217
|
+
if (name) defined.add(name)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Function/method PARAMETERS are locally bound identifiers too, not free calls.
|
|
222
|
+
for (const m of src.matchAll(/function\s*[A-Za-z_$]*\s*\(([^)]*)\)/g)) {
|
|
223
|
+
for (const raw of m[1].split(',')) {
|
|
224
|
+
const name = raw.trim().replace(/[={].*$/s, '').trim()
|
|
225
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const m of src.matchAll(/\(([^()]*)\)\s*=>/g)) {
|
|
229
|
+
for (const raw of m[1].split(',')) {
|
|
230
|
+
const name = raw.trim().replace(/[={].*$/s, '').trim()
|
|
231
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const m of src.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) defined.add(m[1])
|
|
235
|
+
// JS/DSH globals that are legitimately free
|
|
236
|
+
const GLOBALS = new Set([
|
|
237
|
+
'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function', 'new', 'await', 'delete', 'void', 'do', 'else', 'try',
|
|
238
|
+
'Array', 'Object', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Promise', 'Set', 'Map', 'WeakRef', 'WeakMap', 'Symbol', 'Error',
|
|
239
|
+
'isFinite', 'isNaN', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'structuredClone',
|
|
240
|
+
'AbortSignal', 'console', 'require', 'import', 'super', 'this', 'of', 'in', 'instanceof',
|
|
241
|
+
'RegExp', 'Proxy', 'Reflect', 'AggregateError', 'TextEncoder', 'TextDecoder', 'URL', 'BigInt', 'Intl', 'Buffer', 'process',
|
|
242
|
+
])
|
|
243
|
+
const LOCAL_METHODS = new Set()
|
|
244
|
+
for (const m of src.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*[:(]/g)) LOCAL_METHODS.add(m[1])
|
|
245
|
+
for (const m of src.matchAll(/\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) LOCAL_METHODS.add(m[1])
|
|
246
|
+
|
|
247
|
+
const called = new Map()
|
|
248
|
+
for (const m of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
249
|
+
const name = m[1]
|
|
250
|
+
if (GLOBALS.has(name) || LOCAL_METHODS.has(name)) continue
|
|
251
|
+
if (!called.has(name)) called.set(name, { n: 0, line: lineOf(m.index) })
|
|
252
|
+
called.get(name).n++
|
|
253
|
+
}
|
|
254
|
+
for (const [name, info] of called) {
|
|
255
|
+
if (!defined.has(name)) findings.push('line ' + info.line + ': called but never defined: ' + name + '() (' + info.n + ' call site(s))')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- 2. params keys ----------------------------------------------------
|
|
259
|
+
const dpBlock = /const DEFAULT_PARAMS = \{([\s\S]*?)\n \}/.exec(src)
|
|
260
|
+
if (!dpBlock) findings.push('could not locate DEFAULT_PARAMS')
|
|
261
|
+
else {
|
|
262
|
+
const declared = new Set()
|
|
263
|
+
for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(m[1])
|
|
264
|
+
const used = new Map()
|
|
265
|
+
for (const m of src.matchAll(/\bparams\.([A-Za-z_$][\w$]*)/g)) used.set(m[1], (used.get(m[1]) || 0) + 1)
|
|
266
|
+
for (const [k, n] of used) {
|
|
267
|
+
if (!declared.has(k)) findings.push('params.' + k + ' read but not declared in DEFAULT_PARAMS (' + n + ' use(s))')
|
|
268
|
+
}
|
|
269
|
+
notes.push('DEFAULT_PARAMS keys: ' + declared.size + '; params.* reads: ' + used.size)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- 3. session-API surface used by tool handlers -----------------------
|
|
273
|
+
// `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
|
|
274
|
+
// parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
|
|
275
|
+
// parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
|
|
276
|
+
// session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
|
|
277
|
+
// the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
|
|
278
|
+
// actually uses: a plain `function (...)` whose parameter list binds `s`.
|
|
279
|
+
//
|
|
280
|
+
// This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
|
|
281
|
+
// the enclosing session object — the parameter shadows it. The tool handlers themselves are
|
|
282
|
+
// ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
|
|
283
|
+
// self-check at the bottom of this file proves the skip does not blind those.
|
|
284
|
+
function localSParamBodySpans(text) {
|
|
285
|
+
const spans = []
|
|
286
|
+
const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
|
|
287
|
+
let m
|
|
288
|
+
while ((m = re.exec(text)) !== null) {
|
|
289
|
+
const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
|
|
290
|
+
if (params.indexOf('s') === -1) continue
|
|
291
|
+
let i = m.index + m[0].length - 1 // sitting on the opening '{'
|
|
292
|
+
let depth = 0
|
|
293
|
+
for (; i < text.length; i++) {
|
|
294
|
+
if (text[i] === '{') depth++
|
|
295
|
+
else if (text[i] === '}') { depth--; if (depth === 0) break }
|
|
296
|
+
}
|
|
297
|
+
spans.push([m.index, i])
|
|
298
|
+
}
|
|
299
|
+
return spans
|
|
300
|
+
}
|
|
301
|
+
// The returned API object is the last `return { ... }` inside makeSession.
|
|
302
|
+
const apiStart = src.lastIndexOf(' return {\n sessionId,')
|
|
303
|
+
if (apiStart === -1) findings.push('could not locate the session API return object')
|
|
304
|
+
else {
|
|
305
|
+
const apiEnd = src.indexOf('\n }\n }', apiStart)
|
|
306
|
+
const apiText = src.slice(apiStart, apiEnd === -1 ? apiStart + 4000 : apiEnd)
|
|
307
|
+
const apiKeys = new Set()
|
|
308
|
+
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
|
|
309
|
+
for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
|
|
310
|
+
const localScopes = localSParamBodySpans(src)
|
|
311
|
+
const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
|
|
312
|
+
const usedOnS = new Map()
|
|
313
|
+
let shadowed = 0
|
|
314
|
+
for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
315
|
+
if (inLocalScope(m.index)) { shadowed++; continue }
|
|
316
|
+
if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
|
|
317
|
+
}
|
|
318
|
+
for (const [k, line] of usedOnS) {
|
|
319
|
+
if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
|
|
320
|
+
}
|
|
321
|
+
notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
|
|
322
|
+
(shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- 4. error codes ----------------------------------------------------
|
|
326
|
+
// Scan the RAW source: these patterns live inside string literals, which `src` strips.
|
|
327
|
+
const raised = new Set()
|
|
328
|
+
for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
|
|
329
|
+
for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
|
|
330
|
+
const docs = readRaw('vibe-math-v5/实现方案.md')
|
|
331
|
+
const documented = new Set()
|
|
332
|
+
for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
|
|
333
|
+
const onlyDocs = new Set()
|
|
334
|
+
for (const m of docs.matchAll(/(V5_[A-Z_]+)/g)) onlyDocs.add(m[1])
|
|
335
|
+
const raisedNotDocumented = [...raised].filter(c => !onlyDocs.has(c))
|
|
336
|
+
const documentedNotRaised = [...documented].filter(c => !raised.has(c))
|
|
337
|
+
if (raisedNotDocumented.length) findings.push('error codes raised but absent from 实现方案.md: ' + raisedNotDocumented.join(', '))
|
|
338
|
+
if (documentedNotRaised.length) notes.push('documented but not raised in code (ok if advisory): ' + documentedNotRaised.join(', '))
|
|
339
|
+
notes.push('error codes raised: ' + raised.size)
|
|
340
|
+
|
|
341
|
+
// ---- 5. leftover scaffolding -------------------------------------------
|
|
342
|
+
for (const bad of ['@@V5_SECTION@@', 'TODO', 'FIXME', 'XXX', 'PLACEHOLDER']) {
|
|
343
|
+
if (src.includes(bad)) findings.push('leftover development marker: ' + bad)
|
|
344
|
+
}
|
|
345
|
+
// every event type declared in EV must be appended somewhere
|
|
346
|
+
const evBlock = /const EV = \{([\s\S]*?)\n\}/.exec(raw)
|
|
347
|
+
if (evBlock) {
|
|
348
|
+
const keys = [...evBlock[1].matchAll(/([A-Za-z]+):\s*'(vibe5\/[a-z]+)'/g)]
|
|
349
|
+
for (const [, prop, literal] of keys) {
|
|
350
|
+
const uses = (raw.match(new RegExp("EV\\." + prop + "\\b", 'g')) || []).length
|
|
351
|
+
if (uses <= 1) findings.push('event type EV.' + prop + ' (' + literal + ') is declared but never committed')
|
|
352
|
+
}
|
|
353
|
+
notes.push('event types declared: ' + keys.length)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- 6. composition sanity --------------------------------------------
|
|
357
|
+
// The v5 preset is a composition, and a typo in a row `name` is a mounting failure that
|
|
358
|
+
// no unit test of the plugin can catch (the mock calls apply() directly and never goes
|
|
359
|
+
// through the loader). v4 is a proven-good composition mounted on the same hosts, so any
|
|
360
|
+
// package row v5 names that v4 does not is either a genuine new dependency or a typo —
|
|
361
|
+
// and a new dependency must be justified, so surface it for review.
|
|
362
|
+
function packageRows(yaml) {
|
|
363
|
+
const rows = []
|
|
364
|
+
const lines = yaml.split('\n')
|
|
365
|
+
for (let i = 0; i < lines.length; i++) {
|
|
366
|
+
const idm = /^\s*-\s*id:\s*(\S+)\s*$/.exec(lines[i])
|
|
367
|
+
if (!idm) continue
|
|
368
|
+
const row = { id: idm[1], name: '', disabled: false, line: i + 1 }
|
|
369
|
+
for (let j = i + 1; j < lines.length && j < i + 12; j++) {
|
|
370
|
+
if (/^\s*-\s*id:\s*\S+\s*$/.test(lines[j])) break
|
|
371
|
+
const nm = /^\s*name:\s*'?([^'\n]+?)'?\s*$/.exec(lines[j])
|
|
372
|
+
if (nm && !row.name) row.name = nm[1].trim()
|
|
373
|
+
if (/^\s*disabled:\s*true/.test(lines[j])) row.disabled = true
|
|
374
|
+
}
|
|
375
|
+
if (row.name) rows.push(row)
|
|
376
|
+
}
|
|
377
|
+
return rows
|
|
378
|
+
}
|
|
379
|
+
const v5yaml = readRaw('vibe-math-v5/agent.cordis.yml')
|
|
380
|
+
const v4yaml = readRaw('vibe-math-v4/agent.cordis.yml')
|
|
381
|
+
const v5rows = packageRows(v5yaml)
|
|
382
|
+
const v4names = new Set(packageRows(v4yaml).map(r => r.name))
|
|
383
|
+
const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
|
|
384
|
+
if (!v5rows.length) findings.push('composition: no rows parsed out of agent.cordis.yml (is the file still YAML?)')
|
|
385
|
+
for (const r of v5rows) {
|
|
386
|
+
if (r.name === 'cordis:group') continue
|
|
387
|
+
if (r.name.startsWith('./')) {
|
|
388
|
+
if (!RELATIVE_OK.has(r.name)) findings.push('composition line ' + r.line + ': relative row name "' + r.name + '" has no matching file in the preset directory')
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (!v4names.has(r.name)) findings.push('composition line ' + r.line + ': row "' + r.id + '" names "' + r.name + '", which v4 does not — verify the package/name is correct')
|
|
392
|
+
}
|
|
393
|
+
// the preset must reference its own plugin row, and that file must exist
|
|
394
|
+
if (!v5rows.some(r => r.name === './vibe-math-v5.js')) findings.push('composition: the preset never mounts ./vibe-math-v5.js')
|
|
395
|
+
// prefix AND text are required on the persona row for the DSH schema and for back-compat
|
|
396
|
+
const personaBlock = /- id:\s*persona[\s\S]*?(?=\n-\s*id:)/.exec(v5yaml)
|
|
397
|
+
if (!personaBlock) findings.push('composition: no persona row found')
|
|
398
|
+
else {
|
|
399
|
+
if (!/^\s*prefix:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the required `prefix` key')
|
|
400
|
+
if (!/^\s*text:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the legacy `text` key')
|
|
401
|
+
}
|
|
402
|
+
notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5rows.filter(r => r.name !== 'cordis:group' && !r.name.startsWith('./') && !v4names.has(r.name)).length)
|
|
403
|
+
|
|
404
|
+
// ---- 7. requirements traceability against the plan ---------------------
|
|
405
|
+
// "No missing logic" is only checkable mechanically if the SPEC is machine-readable.
|
|
406
|
+
// The plan's §15 names every tool and §16 every parameter, so compare those sets
|
|
407
|
+
// against the implementation: a name in the plan but not the code is an unimplemented
|
|
408
|
+
// requirement, and a name in the code but not the plan is undocumented surface.
|
|
409
|
+
{
|
|
410
|
+
const plan = readRaw('vibe-math-v5/实现方案.md')
|
|
411
|
+
const planTools = new Set()
|
|
412
|
+
for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
|
|
413
|
+
const codeTools = new Set()
|
|
414
|
+
for (const m of raw.matchAll(/registerTool\(\s*'(vibe_v5_[a-z_]+)'/g)) codeTools.add(m[1])
|
|
415
|
+
// documented-but-wildcarded placeholders are not real tools
|
|
416
|
+
const IGNORE = new Set(['vibe_v5_', 'vibe_v5_record_', 'vibe_v5_lean_', 'vibe_v5_task_'])
|
|
417
|
+
for (const t of planTools) {
|
|
418
|
+
if (IGNORE.has(t)) continue
|
|
419
|
+
if (!codeTools.has(t)) findings.push('plan names tool ' + t + ' but the plugin never registers it')
|
|
420
|
+
}
|
|
421
|
+
const undocumented = [...codeTools].filter(t => !planTools.has(t))
|
|
422
|
+
if (undocumented.length) notes.push('tools registered but not named in the plan: ' + undocumented.join(', '))
|
|
423
|
+
notes.push('plan tools: ' + planTools.size + '; registered tools: ' + codeTools.size)
|
|
424
|
+
|
|
425
|
+
// Parameters: only the §16 default table, NOT the §15 tool tables (whose rows also
|
|
426
|
+
// begin with a backticked name).
|
|
427
|
+
const declared2 = new Set()
|
|
428
|
+
if (dpBlock) for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared2.add(m[1])
|
|
429
|
+
const sec16 = /##\s*16\.[\s\S]*?(?=\n##\s*17\.)/.exec(plan)
|
|
430
|
+
const paramRows = new Set()
|
|
431
|
+
if (sec16) {
|
|
432
|
+
for (const m of sec16[0].matchAll(/^\|\s*`([A-Za-z][\w]*)`(?:\s*\/\s*`([A-Za-z][\w]*)`)?\s*\|/gm)) {
|
|
433
|
+
paramRows.add(m[1])
|
|
434
|
+
if (m[2]) paramRows.add(m[2])
|
|
435
|
+
}
|
|
436
|
+
} else findings.push('could not locate the plan\'s §16 parameter table')
|
|
437
|
+
for (const p of paramRows) {
|
|
438
|
+
if (!declared2.has(p)) findings.push('plan documents parameter `' + p + '` but DEFAULT_PARAMS does not declare it')
|
|
439
|
+
}
|
|
440
|
+
notes.push('plan §16 parameter rows: ' + paramRows.size)
|
|
441
|
+
|
|
442
|
+
// F3 (docs-vs-behaviour sweep): `status()`'s machine surface. OWNERSHIP (agreed division, so the two
|
|
443
|
+
// guards never both claim the same job):
|
|
444
|
+
// • FIELD-LEVEL reconciliation (field ↔ doc row, BOTH directions) is owned by
|
|
445
|
+
// `tests/audit-status-report-fields.mjs`, which carries the table and prints the
|
|
446
|
+
// `frozen / doc-rows / exceptions` counts;
|
|
447
|
+
// • THIS audit keeps ONLY this frozen-key drift check: the extracted 43 keys below, the printed
|
|
448
|
+
// `captured: N`, and the `<30` anti-vacuity floor. Any addition/removal of a top-level key must be
|
|
449
|
+
// an explicit decision here (and in the owning guard's reconciliation).
|
|
450
|
+
// No "doc half" is promised or planned here — the note printed at the end points at the owner instead.
|
|
451
|
+
const stIdx = raw.indexOf('function status() {')
|
|
452
|
+
const statusKeys = []
|
|
453
|
+
if (stIdx === -1) findings.push('could not locate status() — the machine-surface extraction has no anchor')
|
|
454
|
+
else {
|
|
455
|
+
const open = raw.indexOf('return {', stIdx)
|
|
456
|
+
const close = raw.indexOf('\n }', open)
|
|
457
|
+
// Tokenise the returned literal: every key directly inside it — including SHORTHAND keys that share a
|
|
458
|
+
// line (`institute: instituteName, project, key, phase,`) — while SKIPPING each value (so an
|
|
459
|
+
// identifier used as a value can never be mistaken for a key).
|
|
460
|
+
const body = open === -1 ? '' : raw.slice(open + 'return {'.length, close === -1 ? open + 9000 : close)
|
|
461
|
+
// Comments must be skipped IN PLACE: the payload's comments contain prose with apostrophes, parens and
|
|
462
|
+
// commas (`…delivered ONCE…`, `phase:'failed'`, `(installer review)`), which otherwise corrupt both the
|
|
463
|
+
// depth tracking and the value skipping.
|
|
464
|
+
const skipComment = (s, at) => {
|
|
465
|
+
if (s[at + 1] === '/') { let j = at + 2; while (j < s.length && s[j] !== '\n') j += 1; return j }
|
|
466
|
+
if (s[at + 1] === '*') { const j = s.indexOf('*/', at + 2); return j === -1 ? s.length : j + 2 }
|
|
467
|
+
return -1
|
|
468
|
+
}
|
|
469
|
+
let i = 0, depth = 0
|
|
470
|
+
while (i < body.length) {
|
|
471
|
+
const ch = body[i]
|
|
472
|
+
if (ch === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
|
|
473
|
+
if (ch === '"' || ch === "'") { const q = ch; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
|
|
474
|
+
if (ch === '{' || ch === '[' || ch === '(') { depth += 1; i += 1; continue }
|
|
475
|
+
if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) break; depth -= 1; i += 1; continue }
|
|
476
|
+
if (depth === 0 && /[A-Za-z_$]/.test(ch)) {
|
|
477
|
+
const m = /^[A-Za-z_$][\w$]*/.exec(body.slice(i))
|
|
478
|
+
const after = body.slice(i + m[0].length).replace(/^[\s]+/, '')
|
|
479
|
+
if (after[0] === ':') {
|
|
480
|
+
statusKeys.push(m[0])
|
|
481
|
+
i += m[0].length
|
|
482
|
+
let d = 0
|
|
483
|
+
while (i < body.length) { // skip the VALUE up to the next top-level comma
|
|
484
|
+
const c = body[i]
|
|
485
|
+
if (c === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
|
|
486
|
+
if (c === '"' || c === "'") { const q = c; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
|
|
487
|
+
if (c === '{' || c === '[' || c === '(') { d += 1; i += 1; continue }
|
|
488
|
+
if (c === '}' || c === ']' || c === ')') { if (d === 0) break; d -= 1; i += 1; continue }
|
|
489
|
+
if (c === ',' && d === 0) { i += 1; break }
|
|
490
|
+
i += 1
|
|
491
|
+
}
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
if (after[0] === ',') { statusKeys.push(m[0]); i += m[0].length; continue }
|
|
495
|
+
i += m[0].length; continue
|
|
496
|
+
}
|
|
497
|
+
i += 1
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
notes.push('status() top-level keys captured: ' + statusKeys.length)
|
|
501
|
+
if (statusKeys.length < 30) findings.push('status() key extraction captured only ' + statusKeys.length + ' keys (anchor or payload broke — this check must never pass vacuously)')
|
|
502
|
+
for (const must of ['phase', 'members', 'quorum', 'chat', 'persistence']) {
|
|
503
|
+
if (statusKeys.indexOf(must) === -1) findings.push('status() no longer exposes `' + must + '` (either the extraction anchor or the payload broke)')
|
|
504
|
+
}
|
|
505
|
+
const EXPECTED_STATUS_KEYS = ['ok', 'institute', 'project', 'key', 'phase', 'running', 'autoDone', 'runId', 'leanNotices', 'leanNoticesScope', 'fieldScopes', 'backend', 'diagnostics', 'debug', 'quorum', 'members', 'tasks', 'failedMembers', 'failedMembersNote', 'pendingSpawns', 'pendingSpawnsNote', 'chat', 'chatScope', 'officeRequests', 'officeRequestsShown', 'officeRequestsCap', 'officeRequestsDropped', 'officeRequestsTruncated', 'persistence', 'meeting', 'parkedMeeting', 'verify', 'verifyQueue', 'verified', 'verifiedTrue', 'concludedFalse', 'verifiedNote', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params']
|
|
506
|
+
const statusMissing = EXPECTED_STATUS_KEYS.filter((k) => statusKeys.indexOf(k) === -1)
|
|
507
|
+
const statusAdded = statusKeys.filter((k) => EXPECTED_STATUS_KEYS.indexOf(k) === -1)
|
|
508
|
+
if (statusMissing.length || statusAdded.length) {
|
|
509
|
+
findings.push('status() field surface changed — missing=' + JSON.stringify(statusMissing) + ' added=' + JSON.stringify(statusAdded) +
|
|
510
|
+
': record the decision in this freeze AND let the field-level owner reconcile the documents (tests/audit-status-report-fields.mjs)')
|
|
511
|
+
}
|
|
512
|
+
notes.push('status() field-surface drift: frozen=' + statusKeys.length + ' keys (this audit owns ONLY this freeze + the <30 anti-vacuity floor); ' +
|
|
513
|
+
'field-level reconciliation (field ↔ doc row, both directions) is OWNED BY tests/audit-status-report-fields.mjs, which prints frozen / doc-rows / exceptions')
|
|
514
|
+
|
|
515
|
+
// F1 (docs-vs-behaviour): the README's TWO-CASE `resume` claim must stay paired with the behavioural
|
|
516
|
+
// anchor that pins the in-instance case (the two CODE anchors are gates in the GATES table below). Only
|
|
517
|
+
// the prose side is checked here, and every side reports how much it matched, so this cannot pass vacuously.
|
|
518
|
+
{
|
|
519
|
+
const readme = readRaw('README.md')
|
|
520
|
+
const suite = readRaw('tests/e2e-v5-round2.test.mjs')
|
|
521
|
+
const readmeClaims = [
|
|
522
|
+
['the two-case framing', /`resume`\s*后轮次计数的两种情形/],
|
|
523
|
+
['case 1 = a cross-process reload counts from 1', /跨进程重载[^\n]*从\s*\*\*1\*\*\s*重新计/],
|
|
524
|
+
['case 2 = an in-instance rebuild CONTINUES the numbering (轮次 2)', /同实例重建[^\n]*继续[^\n]*轮次 2/],
|
|
525
|
+
['a failed/invalid start no longer erases the existing count', /失败\/无效的启动\*\*不再抹掉\*\*已有计数/],
|
|
526
|
+
['the claim is pinned to a revision', /口径复核于\s*`?[0-9a-f]{7,40}`?/],
|
|
527
|
+
]
|
|
528
|
+
let readmeHits = 0
|
|
529
|
+
for (const [label, re] of readmeClaims) {
|
|
530
|
+
if (re.test(readme)) readmeHits += 1
|
|
531
|
+
else findings.push('README resume-claim side missing: ' + label)
|
|
532
|
+
}
|
|
533
|
+
const suiteClaims = [
|
|
534
|
+
['the in-instance rebuild is asserted as 轮次 2', /a FAILED resume must not move the round number backwards/],
|
|
535
|
+
['the founding round is asserted as 轮次 1', /the founding prompt starts the member at 轮次 1/],
|
|
536
|
+
]
|
|
537
|
+
let suiteHits = 0
|
|
538
|
+
for (const [label, re] of suiteClaims) {
|
|
539
|
+
if (re.test(suite)) suiteHits += 1
|
|
540
|
+
else findings.push('README resume-claim has no behavioural anchor: ' + label)
|
|
541
|
+
}
|
|
542
|
+
// The README cites exact code/test lines PINNED to a revision ("口径复核于 <sha>"). Those drift by
|
|
543
|
+
// design, so the drift is a measured NOTE carrying the current lines — never a failure.
|
|
544
|
+
const lineOf = (needle) => { const at = raw.indexOf(needle); return at === -1 ? -1 : raw.slice(0, at).split('\n').length }
|
|
545
|
+
const startLine = lineOf('const startRound = (rounds.get(member.id) || 0) + 1')
|
|
546
|
+
const applyLine = lineOf('rounds.set(member.id, startRound)')
|
|
547
|
+
if (startLine === -1 || applyLine === -1) findings.push('the spawnMember round-count anchors are gone (startRound=' + startLine + ', applied=' + applyLine + ')')
|
|
548
|
+
notes.push('F1 resume-claim pairing: README ' + readmeHits + '/' + readmeClaims.length + ' claims, behavioural anchors ' + suiteHits + '/' + suiteClaims.length + '; cited-line drift (README pins a revision): startRound now :' + startLine + ', applied now :' + applyLine)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// F2/G1 pairing (prose ↔ code): the v5 diagram must not state the PRE-G1 order, and must describe the
|
|
552
|
+
// implemented one. Going through `readRaw` gives this block the same `V5_INTEGRITY_MUTATE` seam as every
|
|
553
|
+
// other file, which is what the `--self-probe` case below relies on.
|
|
554
|
+
{
|
|
555
|
+
const diagram = readRaw('vibe-math-v5/架构图.md')
|
|
556
|
+
const FORBID = /先\s*ack[^\n]{0,8}再构造/
|
|
557
|
+
const CLAUSES = /构造[^\n]{0,12}(后|再)[^\n]{0,12}ack|ack[^\n]{0,12}(仅|只)[^\n]{0,12}(成功|送达)/
|
|
558
|
+
if (FORBID.test(diagram)) findings.push('架构图 still states the pre-G1 order: ack before the prompt is built (F2/G1)')
|
|
559
|
+
if (!CLAUSES.test(diagram)) findings.push('架构图 does not describe the implemented inbox order (construct first / ack only on success)')
|
|
560
|
+
if (!raw.includes('if (ok && prompt.pending.length) await ackPending(')) findings.push('the ack is no longer gated by a successful wake (F2/G1 anchor missing)')
|
|
561
|
+
notes.push('架构图 inbox pairing: forbid-hits=' + (FORBID.test(diagram) ? 1 : 0) + ', clauses=' + (CLAUSES.test(diagram) ? 1 : 0))
|
|
562
|
+
// The PLAN uses the explicit pre-G1 phrases only: a sentence that legitimately QUOTES or FORBIDS the old
|
|
563
|
+
// order must not trip this, so the pattern names the phrases instead of the bare `先 ack`.
|
|
564
|
+
const planDoc = readRaw('vibe-math-v5/实现方案.md')
|
|
565
|
+
const PLAN_FORBID = /先\s*ack\s*邮件[、,,]?\s*再构造|先\s*ack[、,,]?\s*后构造/
|
|
566
|
+
if (PLAN_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 order: ack the mail before building the prompt (F2/G1)')
|
|
567
|
+
notes.push('实现方案.md inbox pairing: forbid-hits=' + (PLAN_FORBID.test(planDoc) ? 1 : 0))
|
|
568
|
+
// The SAME class produced five instances, the last one inside the guard checklist itself, so the scan
|
|
569
|
+
// covers every top-level docs/*.md. The phrase is NAMED (never the bare `先 ack`) so a sentence that
|
|
570
|
+
// legitimately QUOTES or FORBIDS the old order cannot trip it. NOTE (applier): `HERE` is the REPO ROOT
|
|
571
|
+
// (see `new URL(rel, HERE)` above), so the docs directory is `new URL('docs/', HERE)` — NOT `../docs/`.
|
|
572
|
+
const DOC_FORBID = /先\s*ack[^\n]{0,8}再构造/
|
|
573
|
+
let docHits = 0, docFiles = 0
|
|
574
|
+
for (const f of readdirSync(new URL('docs/', HERE)).filter((n) => n.endsWith('.md'))) {
|
|
575
|
+
docFiles += 1
|
|
576
|
+
if (DOC_FORBID.test(readRaw('docs/' + f))) { docHits += 1; findings.push('docs/' + f + ' still states the pre-G1 order: ack the mail before building the prompt (F2/G1)') }
|
|
577
|
+
}
|
|
578
|
+
// The scan covers LIVE docs only. `docs/release-notes/**` is a FROZEN published record: the pre-G1
|
|
579
|
+
// wording there is history, not a stale claim, so it must stay OUT of the scan — and the exemption is
|
|
580
|
+
// ASSERTED (a missing/empty directory fails) so a future change to recursive scanning cannot silently
|
|
581
|
+
// start scanning frozen history. Same convention as audit-readme-counts.mjs's frozen TOTAL-65 notes.
|
|
582
|
+
let frozenNotes = []
|
|
583
|
+
try { frozenNotes = readdirSync(new URL('docs/release-notes/', HERE)).filter((n) => n.endsWith('.md')) } catch (e) { frozenNotes = [] }
|
|
584
|
+
if (!frozenNotes.length) findings.push('the frozen release-notes exemption is EMPTY or missing (docs/release-notes/*.md) — re-decide the scan scope explicitly, never widen it silently')
|
|
585
|
+
notes.push('docs/** inbox pairing: files=' + docFiles + ', forbid-hits=' + docHits + '; frozen docs/release-notes/*.md exempted=' + frozenNotes.length)
|
|
586
|
+
// The plan ALSO carried the pre-G1 re-send contract ("queued 不重发/永不重发") in two sketch lines while
|
|
587
|
+
// its own M2 row already said "未 ack ⇒ 仍可重投". NAMED phrase only, scoped to the plan file, so a
|
|
588
|
+
// sentence that legitimately quotes or forbids the old wording cannot trip it.
|
|
589
|
+
const QUEUED_FORBID = /queued\s*(永不|不)重发/
|
|
590
|
+
if (QUEUED_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 re-send contract (queued 不重发/永不重发): unacked mail is re-deliverable (F2/G1)')
|
|
591
|
+
notes.push('实现方案.md queued-no-resend phrasing: hits=' + (QUEUED_FORBID.test(planDoc) ? 1 : 0))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
|
|
595
|
+
// still exist so a future edit cannot quietly drop a guard.
|
|
596
|
+
const GATES = [
|
|
597
|
+
['temp workers cannot vote', "if (m.kind === 'temp') return // no vote"],
|
|
598
|
+
['temp workers cannot hire', "if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER'"],
|
|
599
|
+
['only the academician assigns', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks'"],
|
|
600
|
+
['only the academician sets priorities', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities'"],
|
|
601
|
+
['permanent-staff changes need the office', "code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)'"],
|
|
602
|
+
['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
|
|
603
|
+
['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
|
|
604
|
+
['members may reject an assignment', "memberMayRejectAssign: true,"],
|
|
605
|
+
['the charter states the progress definition', "' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
|
|
606
|
+
['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
|
|
607
|
+
['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
|
|
608
|
+
|
|
609
|
+
// ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
|
|
610
|
+
// F1 (README two-case resume claim): the two code anchors the prose cites.
|
|
611
|
+
['the founding round is computed from the stored count (never reset)', 'const startRound = (rounds.get(member.id) || 0) + 1'],
|
|
612
|
+
['the founding round is applied only AFTER a successful start', 'rounds.set(member.id, startRound)'],
|
|
613
|
+
// The 2026-09 field test shipped a framework whose every member brief named the WRONG
|
|
614
|
+
// member. These gates keep the structural fixes in place, and the companion
|
|
615
|
+
// prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
|
|
616
|
+
['the status block takes the member it describes', 'function briefBlock(member, roundNo) {'],
|
|
617
|
+
['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
|
|
618
|
+
['the status block is built from that member, never a global', 'function stateBlock(member, roundNo) {\n return briefBlock(member, roundNo)\n }'],
|
|
619
|
+
['the founding prompt is told the round it is starting (never a stored 0)', 'const prompt = initialPrompt(member, initialTask, mode, startRound)'],
|
|
620
|
+
['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
|
|
621
|
+
['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
|
|
622
|
+
['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
|
|
623
|
+
['leadership text follows the live roster', 'function academicianId() {'],
|
|
624
|
+
['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
|
|
625
|
+
['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
|
|
626
|
+
['the mailbox is acked only AFTER the wake actually succeeded', "if (ok && prompt.pending.length) await ackPending(prompt.pending)"],
|
|
627
|
+
// F2: the three REAL mailbox invariants, each at its own source anchor. `inFlightMessages` is a DSH
|
|
628
|
+
// mailbox internal that v5 does NOT have (dedupe is `delivered` + the per-round injection marks), so
|
|
629
|
+
// no gate may reference it.
|
|
630
|
+
['the acknowledgement is gated by the wake result AND a non-empty pending list', 'const ok = await wakeMember(member, prompt.text, kind)'],
|
|
631
|
+
['the delivered ledger is CAPPED (oldest evicted, never unbounded)', 'const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered'],
|
|
632
|
+
['a prompt prepends only messages not already prepended in this round', 'const fresh = pending.filter((p) => !seen.has(p.id))'],
|
|
633
|
+
['the per-round injection mark is cleared with every concluded attempt', 'inboxInjected.delete(member.id)'],
|
|
634
|
+
['a prepended inbox suppresses the base block (exactly one inbox section per prompt)', "const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)"],
|
|
635
|
+
['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
|
|
636
|
+
['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
|
|
637
|
+
['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
|
|
638
|
+
['a voters-only broadcast is framed as such', "if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text"],
|
|
639
|
+
['relayed messages carry their true sender', "await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })"],
|
|
640
|
+
['a member that failed to provision stays visible', "b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))"],
|
|
641
|
+
['the objection channel is documented in the reply spec', '"reject_assign": {"task_id":"t-3"'],
|
|
642
|
+
['the task-done channel is documented in the reply spec', '"task_done": "t-3",'],
|
|
643
|
+
['the meeting input channel is documented in the reply spec', '"input": "本轮会议/辩论的发言正文'],
|
|
644
|
+
['the work push is paced, not an unbounded loop', 'if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue'],
|
|
645
|
+
|
|
646
|
+
// ── LEAN FORMAL VERIFICATION GATES (docs/formal-verification.md) ─────────
|
|
647
|
+
['the formal-verification mode defaults to off', "formalVerify: 'off',"],
|
|
648
|
+
['an unknown mode degrades to off, never to a stronger mode', "indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'"],
|
|
649
|
+
['the mode is read at prompt-build time (never frozen into the charter)', 'const formalOn = () => formalMode()'],
|
|
650
|
+
['a passing run — not merely an archived file — is what makes an object passed', 'const formalGateOk = (rec) => !!rec && (rec.status === \'passed\' || rec.status === \'blocked\')'],
|
|
651
|
+
['a passing Lean run switches the review subject to fidelity', '你的任务是**忠实性审查**'],
|
|
652
|
+
['the voting prompt tells voters not to re-derive once a proof exists', '**你不需要重新检查推导**'],
|
|
653
|
+
['require mode withholds a verdict without a formal record', 'if (formalMode() === \'require\' && !formalGateOk(rec)) {'],
|
|
654
|
+
['a withheld verdict is recorded as undecided with a machine-readable reason', 'formal-required:尚未取得 Lean 形式化通过'],
|
|
655
|
+
['the withheld object goes on a formalization TODO', "'Formal/TODO.md'"],
|
|
656
|
+
['a blocker record must carry a reason', "if (!note) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '阻塞记录必须写明原因(note)"],
|
|
657
|
+
['the proof of an object is archived under Verified/Lean/', "await writeTextRel('Verified/Lean/' + target + '.lean', body)"],
|
|
658
|
+
['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
|
|
659
|
+
['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
|
|
660
|
+
['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
|
|
661
|
+
['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
|
|
662
|
+
['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
|
|
663
|
+
['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
|
|
664
|
+
]
|
|
665
|
+
for (const [label, needle] of GATES) {
|
|
666
|
+
if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
|
|
667
|
+
}
|
|
668
|
+
notes.push('philosophy gates checked: ' + GATES.length)
|
|
669
|
+
gateCount = GATES.length
|
|
670
|
+
|
|
671
|
+
// The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
|
|
672
|
+
// to read the exact text every member receives without decoding session logs.
|
|
673
|
+
const REPO_ROOT = new URL('../', import.meta.url)
|
|
674
|
+
const needFiles = [
|
|
675
|
+
['the prompt-integrity suite is shipped', 'tests/prompt-v5-integrity.test.mjs'],
|
|
676
|
+
['the machine-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.json'],
|
|
677
|
+
['the human-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.md'],
|
|
678
|
+
['the persona-surface suite is shipped', 'tests/audit-persona-surface.test.mjs'],
|
|
679
|
+
['the four-preset persona corpus is shipped (machine-readable)', 'prompt-corpus-persona/persona-corpus.json'],
|
|
680
|
+
['the four-preset persona corpus is shipped (human-readable)', 'prompt-corpus-persona/persona-corpus.md'],
|
|
681
|
+
]
|
|
682
|
+
for (const [label, rel] of needFiles) {
|
|
683
|
+
let ok = false
|
|
684
|
+
try { ok = existsSync(new URL(rel, REPO_ROOT)) } catch (e) { ok = false }
|
|
685
|
+
if (!ok) findings.push('missing shipped prompt-correctness artifact: ' + label + ' (' + rel + ')')
|
|
686
|
+
}
|
|
687
|
+
notes.push('prompt-correctness artifacts checked: ' + needFiles.length)
|
|
688
|
+
|
|
689
|
+
// The corpus must actually contain the interactions a reviewer needs to see, and must
|
|
690
|
+
// never contain a wrong-identity brief.
|
|
691
|
+
try {
|
|
692
|
+
const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
|
|
693
|
+
const c = JSON.parse(readRaw('prompt-corpus-v5/prompt-corpus-v5.json'))
|
|
694
|
+
const kinds = new Set((c.prompts || []).map(p => p.kind))
|
|
695
|
+
const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
|
|
696
|
+
'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
|
|
697
|
+
'inbox-office', 'inbox-assign', 'inbox-nudge', 'inbox-office-assign', 'inbox-office-nudge',
|
|
698
|
+
'notice', 'notice-claim', 'after-failure',
|
|
699
|
+
// the Lean formal-verification interaction must be reviewable by a human too — including
|
|
700
|
+
// the `require` gate wording and the state a member sees AFTER a fidelity defect withdrew
|
|
701
|
+
// a proof (both were missing from the first corpus, so nobody could read them).
|
|
702
|
+
'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect']
|
|
703
|
+
for (const k of need) if (!kinds.has(k)) findings.push('the prompt corpus is missing a ' + k + ' prompt')
|
|
704
|
+
const all = (c.prompts || []).map(p => p.prompt + '\n' + (p.charter || '')).join('\n')
|
|
705
|
+
if (/你是 \?/.test(all)) findings.push('the prompt corpus contains a wrong-identity "你是 ?" brief')
|
|
706
|
+
if (/\[状态\][^\n]*你是\s+(\S+?)[^\n]*\n/.test(all)) {
|
|
707
|
+
// every [状态] line must name a real member id, never a placeholder
|
|
708
|
+
for (const m of all.match(/\[状态\][^\n]*/g) || []) {
|
|
709
|
+
const id = (/\[状态\]\s*你是\s+(\S+?)(/.exec(m) || [])[1]
|
|
710
|
+
if (!id || id === '?' || id === 'undefined') findings.push('the prompt corpus has a bad identity line: ' + m.slice(0, 60))
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
notes.push('prompt corpus: ' + (c.total || 0) + ' prompts, ' + kinds.size + ' kinds')
|
|
714
|
+
} catch (e) {
|
|
715
|
+
findings.push('the prompt corpus could not be read/parsed: ' + String((e && e.message) || e))
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The persona surface of ALL FOUR presets is a shipped prompt-correctness artifact too:
|
|
719
|
+
// it is the only prompt the MAIN agent receives, and no e2e suite loads the YAML, so a
|
|
720
|
+
// human reviewer needs the corpus on disk (generated by audit-persona-surface.test.mjs).
|
|
721
|
+
try {
|
|
722
|
+
const pc = JSON.parse(readRaw('prompt-corpus-persona/persona-corpus.json'))
|
|
723
|
+
for (const d of ['vibe-math-v2', 'vibe-math-v3', 'vibe-math-v4', 'vibe-math-v5']) {
|
|
724
|
+
if (!(pc.presets || []).some((p) => p.preset === d)) findings.push('the persona corpus is missing preset ' + d)
|
|
725
|
+
}
|
|
726
|
+
const emptyBlocks = (pc.presets || []).filter((p) => !p.prefix || !p.text).map((p) => p.preset)
|
|
727
|
+
if (emptyBlocks.length) findings.push('the persona corpus has an empty persona block: ' + emptyBlocks.join(', '))
|
|
728
|
+
notes.push('persona corpus: ' + (pc.presets || []).length + ' presets')
|
|
729
|
+
} catch (e) {
|
|
730
|
+
findings.push('the persona corpus could not be read/parsed: ' + String((e && e.message) || e))
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---- scanner self-check -------------------------------------------------
|
|
735
|
+
// The whole audit rests on stripNoise(); a scanner that mis-lexes silently BLINDS it (that is how the
|
|
736
|
+
// regex-with-a-quote bug lived here). Two checks, both cheap and both measured to be sensitive:
|
|
737
|
+
// · the stripped source must still PARSE (the pre-fix scanner produced a SyntaxError);
|
|
738
|
+
// · a fixture with a quoted character class + a following comment must survive intact.
|
|
739
|
+
{
|
|
740
|
+
const tmp = mkdtempSync(join(tmpdir(), 'v5-integrity-strip-'))
|
|
741
|
+
try {
|
|
742
|
+
const f = join(tmp, 'v5.mjs')
|
|
743
|
+
writeFileSync(f, src)
|
|
744
|
+
const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' })
|
|
745
|
+
if (r.status !== 0) {
|
|
746
|
+
findings.push('stripNoise() corrupted the source it scans (the stripped text does not parse) — every identifier check below is unreliable: ' +
|
|
747
|
+
String(r.stderr || '').split('\n').filter((l) => l.trim()).slice(-2).join(' ').slice(0, 160))
|
|
748
|
+
}
|
|
749
|
+
const fixture = [
|
|
750
|
+
'function f(s) { return s.replace(/["\']/g, "-") }',
|
|
751
|
+
'// phantom_call( must not survive as a call site',
|
|
752
|
+
'const div = 6 / 2',
|
|
753
|
+
].join('\n')
|
|
754
|
+
const stripped = stripNoise(fixture)
|
|
755
|
+
if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
|
|
756
|
+
if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
|
|
757
|
+
// The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
|
|
758
|
+
// real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
|
|
759
|
+
// scope skip silently blinds every tool-handler call — both are failures worth failing on.
|
|
760
|
+
const scopeFixture = [
|
|
761
|
+
'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
|
|
762
|
+
'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
|
|
763
|
+
].join('\n')
|
|
764
|
+
const spans = localSParamBodySpans(scopeFixture)
|
|
765
|
+
const idxShadow = scopeFixture.indexOf('s.trim()')
|
|
766
|
+
const idxApi = scopeFixture.indexOf('s.resume()')
|
|
767
|
+
if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
|
|
768
|
+
if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
|
|
769
|
+
notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
|
|
770
|
+
'; shadowed-param skip=' + spans.length)
|
|
771
|
+
} finally {
|
|
772
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---- report ------------------------------------------------------------
|
|
777
|
+
// `--json` is the machine-readable contract the `--self-probe` children parse (same shape as
|
|
778
|
+
// audit-prompt-invariants.mjs): { gates, failed, findings, notes }. It is emitted BEFORE the human
|
|
779
|
+
// output so a child run's stdout is parseable in one piece.
|
|
780
|
+
if (process.argv.includes('--json')) {
|
|
781
|
+
console.log(JSON.stringify({ gates: gateCount, failed: findings.length, findings, notes }))
|
|
782
|
+
for (const f of findings) console.error(' FINDING: ' + f)
|
|
783
|
+
process.exit(findings.length ? 1 : 0)
|
|
784
|
+
}
|
|
785
|
+
console.log('-- V5 integrity audit --')
|
|
786
|
+
for (const n of notes) console.log(' note: ' + n)
|
|
787
|
+
console.log('')
|
|
788
|
+
if (findings.length) {
|
|
789
|
+
for (const f of findings) console.error(' FINDING: ' + f)
|
|
790
|
+
console.error('')
|
|
791
|
+
console.error(findings.length + ' finding(s)')
|
|
792
|
+
process.exit(1)
|
|
793
|
+
}
|
|
794
|
+
console.log('clean: no undefined calls, no undeclared params keys, no missing session API, no leftover markers')
|
|
795
|
+
process.exit(0)
|