ruvector/scripts/sona-drift/stub-tripwire.mjs
rUv 445b43f285
ci(sona): cross-implementation drift gate — parity harness, RVF fingerprints, stub tripwire (#554)
* ci(sona): cross-implementation drift gate — parity harness, RVF fingerprints, stub tripwire

Issues #519/#553 were the same learn-from-feedback stub shipped in two of
the three parallel SONA implementations (crates/sona, ruvllm TS
SonaCoordinator, ruvector CLI intelligence engine) — nothing enforced
behavioral parity, so a fix in one layer did not protect the others.

Three guards, wired into .github/workflows/sona-drift.yml (path-filtered,
fail-fast order, cached, guard-the-guard step so deleting the harness
fails CI):

1. scripts/sona-drift/stub-tripwire.mjs — static no-op detector on the
   seam functions; fails on effectively-empty bodies (comments/logging
   only), anchored to definitions, not call sites.
2. scripts/sona-drift/harness.mjs — runs one deterministic feedback
   scenario through all three implementations and enforces the contract
   matrix: fresh==0, single positive feedback adapts (the #519/#553
   regression), negative feedback adapts, neutral no-op where defined,
   inference output changes. Scenarios run twice and any bit-level
   fingerprint difference errors, so jitter cannot enter fingerprints.
3. scripts/sona-drift/rvf-fingerprint.mjs — behavioral fingerprints stored
   as vectors in a committed RVF store (reference.rvf, @ruvector/rvf
   NodeBackend); validates current behavior by L2 distance with a
   1e-3-relative tolerance (>100x FP-reassociation headroom, ~500x below
   a real stub regression). --update regenerates on intentional change.

All guards verified to fire: a reverted #553 stub fails C2/C3/C5 and the
tripwire; a perturbed fingerprint fails RVF validation. Skipped
implementations (unbuilt dist) degrade visibly, not silently.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(sona-drift): install @ruvector/rvf-node before the fingerprint gate

First CI run: tripwire + harness passed on Linux, but the RVF validation
threw BackendNotFound — nothing installed the rvf native backend on the
runner. Infra failure, not drift.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@users.noreply.github.com>
2026-06-11 22:40:27 -04:00

219 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Static no-op detector for the SONA learn-from-feedback seams.
*
* The #519 and #553 regressions were both "the function exists but its body
* does nothing" stubs that shipped because nothing checked the seam. This
* tripwire extracts each named seam function body, strips comments and
* logging-only statements, and fails if no state-mutating statement remains
* (assignment / compound assignment / increment / non-logging call).
*
* It is deliberately simple and lenient — a tripwire, not a verifier: a
* `let x = ...;` binding counts as evidence of real work. The failure mode
* it guards is "body is empty, or only comments + console/log lines".
*
* Usage: node scripts/sona-drift/stub-tripwire.mjs
* Exit: non-zero if any required seam is missing or effectively empty.
* Exports: scanFile(absPath, fnNames) for the demo in README.md.
*/
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(__dirname, '..', '..');
/**
* Seam registry. `required: false` functions may be absent (reported, not
* fatal) — recordRouteOutcome only exists once the #517 fix lands on main.
*/
const TARGETS = [
{
file: 'crates/sona/src/wasm.rs',
fns: [
{ name: 'learn_from_feedback', required: true },
{ name: 'end_trajectory', required: true },
],
},
{
file: 'crates/sona/src/napi_simple.rs',
fns: [
{ name: 'learn_from_feedback', required: false },
{ name: 'end_trajectory', required: true },
],
},
{
file: 'npm/packages/ruvllm/src/sona.ts',
fns: [{ name: 'processInstantLearning', required: true }],
},
{
file: 'npm/packages/ruvector/src/core/intelligence-engine.ts',
fns: [{ name: 'recordRouteOutcome', required: false, absentNote: '#517 fix not on main' }],
},
];
/** Statements that are pure logging — removed before the mutation check. */
const LOGGING_TOKENS = [
/console\.\w+\s*\(/y,
/web_sys::console::\w+\s*\(/y,
/log::\w+!\s*\(/y,
/tracing::\w+!\s*\(/y,
/println!\s*\(/y,
/eprintln!\s*\(/y,
/dbg!\s*\(/y,
];
/**
* Char-scan that understands line/block comments and string literals
* ("..." '...' `...`), so braces inside comments/strings don't break
* matching. Returns source with comments replaced by spaces.
*/
function stripComments(src) {
let out = '';
let i = 0;
const n = src.length;
while (i < n) {
const c = src[i];
const c2 = src[i + 1];
if (c === '/' && c2 === '/') {
while (i < n && src[i] !== '\n') { out += ' '; i++; }
} else if (c === '/' && c2 === '*') {
let depth = 1; // Rust block comments nest
out += ' '; i += 2;
while (i < n && depth > 0) {
if (src[i] === '/' && src[i + 1] === '*') { depth++; out += ' '; i += 2; continue; }
if (src[i] === '*' && src[i + 1] === '/') { depth--; out += ' '; i += 2; continue; }
out += src[i] === '\n' ? '\n' : ' ';
i++;
}
} else if (c === '"' || c === "'" || c === '`') {
// Keep string contents verbatim except we neutralize braces so brace
// matching stays balanced. Rust lifetimes ('a) are handled by bailing
// out of single-quote mode when no closing quote appears nearby.
const quote = c;
let j = i + 1;
let closed = -1;
while (j < n) {
if (src[j] === '\\') { j += 2; continue; }
if (src[j] === quote) { closed = j; break; }
if (quote === "'" && j - i > 2 && !src.slice(i + 1, j + 1).includes("'")) break; // lifetime, not char
j++;
}
if (quote === "'" && (closed === -1 || closed - i > 4)) {
out += c; i++; // treat as lifetime tick, not a string
} else {
const end = closed === -1 ? n - 1 : closed;
out += src.slice(i, end + 1).replace(/[{}]/g, '.');
i = end + 1;
}
} else {
out += c;
i++;
}
}
return out;
}
/** Extract `{...}` body of the named function from comment-stripped source. */
function extractBody(stripped, fnName) {
// Anchored to DEFINITIONS only (a call site like `this.fn(x)` must not
// match): Rust `fn name(`, or a TS class method at the start of a line
// (optionally private/public/protected/async).
let re = new RegExp(`\\bfn\\s+${fnName}\\s*\\(`, 'g');
let m = re.exec(stripped);
if (!m) {
re = new RegExp(`^\\s*(?:private\\s+|public\\s+|protected\\s+)?(?:async\\s+)?${fnName}\\s*\\(`, 'gm');
m = re.exec(stripped);
}
if (!m) return null;
const open = stripped.indexOf('{', re.lastIndex);
if (open === -1) return null;
let depth = 0;
for (let i = open; i < stripped.length; i++) {
if (stripped[i] === '{') depth++;
else if (stripped[i] === '}') {
depth--;
if (depth === 0) return stripped.slice(open + 1, i);
}
}
return null;
}
/** Remove a logging call starting at `start` through its closing paren + `;`. */
function removeLoggingCalls(body) {
let out = body;
for (const token of LOGGING_TOKENS) {
const re = new RegExp(token.source, 'g');
let m;
while ((m = re.exec(out)) !== null) {
// walk to matching close paren
let depth = 0;
let end = -1;
for (let i = m.index; i < out.length; i++) {
if (out[i] === '(') depth++;
else if (out[i] === ')') {
depth--;
if (depth === 0) { end = i; break; }
}
}
if (end === -1) break;
while (end + 1 < out.length && /[\s;?]/.test(out[end + 1])) end++;
out = out.slice(0, m.index) + out.slice(end + 1);
re.lastIndex = 0;
}
}
return out;
}
/** True if the cleaned body contains at least one state-mutating statement. */
function hasMutation(cleaned) {
// assignment (not ==, =>, <=, >=, !=), compound assignment, inc/dec
if (/(\+=|-=|\*=|\/=|\+\+|--)/.test(cleaned)) return true;
if (/[^=!<>+\-*/]=[^=>]/.test(cleaned)) return true;
// any remaining (non-logging) call expression
if (/[A-Za-z_][\w:.]*\s*\(/.test(cleaned)) return true;
return false;
}
export function scanFile(absPath, fnNames) {
const findings = [];
if (!existsSync(absPath)) {
return fnNames.map((f) => ({ fn: f.name, status: 'file-missing', ok: false }));
}
const stripped = stripComments(readFileSync(absPath, 'utf8'));
for (const f of fnNames) {
const body = extractBody(stripped, f.name);
if (body === null) {
findings.push({
fn: f.name,
status: f.required ? 'missing' : `absent (ok: ${f.absentNote ?? 'optional seam'})`,
ok: !f.required,
});
continue;
}
const cleaned = removeLoggingCalls(body).replace(/\breturn\b\s*(Ok\(\(\)\)|;)?/g, ' ');
const ok = hasMutation(cleaned);
findings.push({ fn: f.name, status: ok ? 'ok' : 'EFFECTIVELY EMPTY (no-op stub)', ok });
}
return findings;
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
let failed = 0;
for (const target of TARGETS) {
const abs = path.join(REPO, target.file);
for (const finding of scanFile(abs, target.fns)) {
const tag = finding.ok ? 'OK ' : 'FAIL';
console.log(`${tag} ${target.file} :: ${finding.fn}${finding.status}`);
if (!finding.ok) failed++;
}
}
if (failed > 0) {
console.log(`TRIPWIRE FIRED: ${failed} seam(s) look like no-op stubs or are missing.`);
console.log('A learn-from-feedback seam must mutate state — see scripts/sona-drift/README.md.');
process.exit(1);
}
console.log('TRIPWIRE PASS: all learn-from-feedback seams contain real work.');
}