Skip to content

Commit 2a2df1e

Browse files
committed
phase9: seq 130 W5-AUDIT-QUERIES — audit query helpers + Wave 6 evidence excerpt
1 parent 8079538 commit 2a2df1e

11 files changed

Lines changed: 589 additions & 3 deletions

environment/audit/query.js

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { spawn } from 'node:child_process';
2+
import { access } from 'node:fs/promises';
3+
import path from 'node:path';
4+
5+
import {
6+
readClaimEdges
7+
} from '../claims/edges.js';
8+
import {
9+
resolveProjectRoot
10+
} from '../control/_io.js';
11+
12+
export const AUDIT_QUERY_TIMEOUT_MS = 5_000;
13+
export const EVIDENCE_EDGE_RELATIONS = Object.freeze([
14+
'contradicts',
15+
'supports',
16+
'supersedes',
17+
'depends_on',
18+
'evolved_into',
19+
'related_to'
20+
]);
21+
22+
export class AuditQueryError extends Error {
23+
constructor({ code, message, extra = {} }) {
24+
super(message);
25+
this.name = 'AuditQueryError';
26+
this.code = code;
27+
this.extra = extra;
28+
}
29+
}
30+
31+
function fail(code, message, extra = {}) {
32+
throw new AuditQueryError({ code, message, extra });
33+
}
34+
35+
function defaultAuditQueryCliPath(projectRoot, env = process.env) {
36+
if (typeof env.VIBE_SCIENCE_AUDIT_QUERY_CLI === 'string' && env.VIBE_SCIENCE_AUDIT_QUERY_CLI.trim() !== '') {
37+
return path.resolve(env.VIBE_SCIENCE_AUDIT_QUERY_CLI);
38+
}
39+
if (typeof env.VIBE_SCIENCE_PLUGIN_ROOT === 'string' && env.VIBE_SCIENCE_PLUGIN_ROOT.trim() !== '') {
40+
return path.resolve(env.VIBE_SCIENCE_PLUGIN_ROOT, 'scripts', 'audit-query-cli.js');
41+
}
42+
return path.join(
43+
path.dirname(projectRoot),
44+
'vibe-science',
45+
'plugin',
46+
'scripts',
47+
'audit-query-cli.js'
48+
);
49+
}
50+
51+
function parseAuditQueryStdout(stdout, cliPath) {
52+
const trimmed = stdout.trim();
53+
if (trimmed === '') {
54+
fail(
55+
'E_AUDIT_QUERY_UNAVAILABLE',
56+
'audit query CLI emitted empty stdout.',
57+
{ cliPath, stdout }
58+
);
59+
}
60+
try {
61+
return JSON.parse(trimmed);
62+
} catch (error) {
63+
fail(
64+
'E_AUDIT_QUERY_UNAVAILABLE',
65+
`audit query CLI emitted non-JSON stdout: ${error.message}`,
66+
{ cliPath, stdout }
67+
);
68+
}
69+
}
70+
71+
function normalizeRows(rows, cliPath) {
72+
if (!Array.isArray(rows)) {
73+
fail(
74+
'E_AUDIT_QUERY_UNAVAILABLE',
75+
'audit query CLI output rows must be an array.',
76+
{ cliPath }
77+
);
78+
}
79+
return rows.map((row) => {
80+
if (typeof row?.event_type !== 'string') {
81+
fail('E_AUDIT_QUERY_UNAVAILABLE', 'audit query row missing event_type.', { cliPath, row });
82+
}
83+
if (row.source_component != null && typeof row.source_component !== 'string') {
84+
fail('E_AUDIT_QUERY_UNAVAILABLE', 'audit query row source_component must be string or null.', { cliPath, row });
85+
}
86+
const count = Number(row.count);
87+
if (!Number.isInteger(count) || count < 0) {
88+
fail('E_AUDIT_QUERY_UNAVAILABLE', 'audit query row count must be a non-negative integer.', { cliPath, row });
89+
}
90+
return {
91+
event_type: row.event_type,
92+
source_component: row.source_component ?? null,
93+
count
94+
};
95+
});
96+
}
97+
98+
export async function aggregateGovernanceEvents(projectRoot, { from, to } = {}) {
99+
const canonicalProjectRoot = resolveProjectRoot(projectRoot);
100+
const env = process.env;
101+
const cliPath = defaultAuditQueryCliPath(canonicalProjectRoot, env);
102+
const stdinPayload = JSON.stringify({
103+
from,
104+
to,
105+
pluginProjectRoot: null
106+
});
107+
108+
try {
109+
await access(cliPath);
110+
} catch {
111+
fail(
112+
'E_AUDIT_QUERY_UNAVAILABLE',
113+
'Audit query CLI is missing.',
114+
{ cliPath }
115+
);
116+
}
117+
118+
return new Promise((resolve, reject) => {
119+
let child;
120+
try {
121+
child = spawn(process.execPath, [cliPath], {
122+
cwd: path.dirname(path.dirname(path.dirname(cliPath))),
123+
env,
124+
shell: false,
125+
stdio: ['pipe', 'pipe', 'pipe'],
126+
windowsHide: true
127+
});
128+
} catch (error) {
129+
reject(new AuditQueryError({
130+
code: 'E_AUDIT_QUERY_UNAVAILABLE',
131+
message: `Audit query spawn failed: ${error.message}`,
132+
extra: { cliPath }
133+
}));
134+
return;
135+
}
136+
137+
let stdout = '';
138+
let stderr = '';
139+
let timedOut = false;
140+
let spawnFailure = null;
141+
const timeout = setTimeout(() => {
142+
timedOut = true;
143+
try {
144+
child.kill('SIGKILL');
145+
} catch {
146+
// Child may already be closed.
147+
}
148+
}, AUDIT_QUERY_TIMEOUT_MS);
149+
150+
child.stdout.on('data', (chunk) => {
151+
stdout += chunk.toString('utf8');
152+
});
153+
child.stderr.on('data', (chunk) => {
154+
stderr += chunk.toString('utf8');
155+
});
156+
child.on('error', (error) => {
157+
spawnFailure = error;
158+
});
159+
child.on('close', (exitCode, signal) => {
160+
clearTimeout(timeout);
161+
162+
if (timedOut) {
163+
reject(new AuditQueryError({
164+
code: 'E_AUDIT_QUERY_UNAVAILABLE',
165+
message: `Audit query CLI timed out after ${AUDIT_QUERY_TIMEOUT_MS} ms.`,
166+
extra: { cliPath, signal }
167+
}));
168+
return;
169+
}
170+
171+
if (spawnFailure) {
172+
reject(new AuditQueryError({
173+
code: 'E_AUDIT_QUERY_UNAVAILABLE',
174+
message: `Audit query spawn failed: ${spawnFailure.message}`,
175+
extra: { cliPath }
176+
}));
177+
return;
178+
}
179+
180+
let parsed;
181+
try {
182+
parsed = parseAuditQueryStdout(stdout, cliPath);
183+
} catch (error) {
184+
reject(error);
185+
return;
186+
}
187+
188+
if (exitCode !== 0 || parsed?.ok !== true) {
189+
reject(new AuditQueryError({
190+
code: 'E_AUDIT_QUERY_UNAVAILABLE',
191+
message: parsed?.error ?? `Audit query CLI exited with code ${exitCode}.`,
192+
extra: { cliPath, exitCode, stderr }
193+
}));
194+
return;
195+
}
196+
197+
try {
198+
resolve(normalizeRows(parsed.rows, cliPath));
199+
} catch (error) {
200+
reject(error);
201+
}
202+
});
203+
204+
child.stdin.end(stdinPayload);
205+
});
206+
}
207+
208+
export async function listEdgesByRelation(projectRoot, relation) {
209+
return readClaimEdges(projectRoot, { relation });
210+
}
211+
212+
export async function buildEvidenceExcerpt(projectRoot, { from, to } = {}) {
213+
const governanceEventsAggregated = await aggregateGovernanceEvents(projectRoot, { from, to });
214+
const edgesByRelation = {};
215+
await Promise.all(EVIDENCE_EDGE_RELATIONS.map(async (relation) => {
216+
edgesByRelation[relation] = await listEdgesByRelation(projectRoot, relation);
217+
}));
218+
219+
const totalEvents = governanceEventsAggregated
220+
.reduce((total, row) => total + row.count, 0);
221+
const totalEdges = Object.values(edgesByRelation)
222+
.reduce((total, edges) => total + edges.length, 0);
223+
224+
return {
225+
governance_events_aggregated: governanceEventsAggregated,
226+
edges_by_relation: edgesByRelation,
227+
summary: {
228+
total_events: totalEvents,
229+
total_edges: totalEdges,
230+
time_range: { from, to }
231+
}
232+
};
233+
}

0 commit comments

Comments
 (0)