mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
test: observe audited semantic adapter in managed candidate path
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
# Internal dependency shadow recorder
|
||||
|
||||
This implements the bounded follow-on to the [contract review](semantic-dependency-contract.md)
|
||||
as an **explicitly enabled validation host**, not a shipping engine option. The three
|
||||
modules live under [docs/validation/dependency-shadow](../validation/dependency-shadow/).
|
||||
No production source, schema, worker, public SDK or Drupal package is changed.
|
||||
|
||||
## Observation point and admission
|
||||
|
||||
The harness packs a small `shadow-binding` package whose entry points to the audited
|
||||
host adapter. The real `ExtensionManager` installs/trusts its bytes; the normal loader
|
||||
registers its pass and the existing candidate/synthesis pipeline runs it. The pass
|
||||
receives the real `ResolutionContext`, reads the real candidate nodes and returns
|
||||
ordinary validated edges through the real registry merge into SQLite. No pipeline or
|
||||
context monkey patch is used. The validation package's absolute host-module path is
|
||||
test-owned, local and nonportable; it is never a registry artifact or public opt-in.
|
||||
|
||||
`recorder.enable(projectRoot, { expectedPackageDigest, owners })` explicitly creates
|
||||
one process-local validation session. The session is not inferred from a plugin option
|
||||
or manifest. The loader's actual resolved package digest must match the host's approval.
|
||||
The host adapter/recorder/compiled engine digests are bound at module load and a later
|
||||
disk change disables recording. This is an audited allowlist, **not arbitrary-plugin
|
||||
containment**. Existing v1 code can access Node; a proxy cannot stop that. Without a
|
||||
session, the adapter executes normally with no shadow callback. Other v1 hooks are
|
||||
never double-invoked by the observer and normally make the generation ineligible.
|
||||
|
||||
Two additional exact-digest fixture producers are admitted only in isolated tests:
|
||||
a source-only node producer for stable-ID query-order changes, and a colliding edge
|
||||
producer for ownership order. They always evaluate normally, once; their output is
|
||||
never cached or shadow-executed. They are not a second reusable adapter or an API for
|
||||
arbitrary upstream declarations. The legacy filesystem hook remains ineligible.
|
||||
|
||||
The adapter is grounded in the generic binding pass in
|
||||
[`__tests__/extension-sync.test.ts`](../../__tests__/extension-sync.test.ts): resolve
|
||||
`dispatch` to the unique named Python function, with option override and a real failure
|
||||
sentinel. `binding.txt` deliberately moves metadata outside the indexed source set;
|
||||
`.events.yaml` registration membership supplies a fallback. Fixed optional probes
|
||||
exercise an unsupported derived query and a caught missing operation. No option can
|
||||
select an arbitrary function or arbitrary context operation.
|
||||
|
||||
## Exact views, reads and invocation identity
|
||||
|
||||
The audited evaluator has no filesystem, network, clock, environment or module reads.
|
||||
Only the surrounding host observer performs bookkeeping. Its four captured operations
|
||||
delegate to the actual context: `getAllFiles`, `fileExists`, `readFile`, `getNodesByName`.
|
||||
Unknown operations are forwarded with sticky taint set **before** execution or an
|
||||
exception, including when the audited callback catches that exception. The observer
|
||||
does not reinterpret errors as an empty successful capture.
|
||||
|
||||
Node results use a declared internal JSON projection: remove `updatedAt` and undefined
|
||||
properties, preserve all remaining values and array order. This is a new internal view,
|
||||
not transparent interception of arbitrary v1 callbacks. The evaluator cannot read the
|
||||
omitted fields. Returned arrays/objects are detached copies. All empty, false and null
|
||||
results are recorded, not just successful symbols. Each observation stores operation,
|
||||
arguments, complete result SHA256, serialized value byte count and empty-result marker.
|
||||
No previous returned member list substitutes for reexecuting a query predicate.
|
||||
|
||||
Identity binds canonical project path, loaded adapter/observer/engine bytes, engine
|
||||
version, view contract, post-resolution stage, options, full project config, ignore
|
||||
rules, CODEGRAPH environment settings, all loaded package digests/options/replacements,
|
||||
and the actual ordered synthesis registry (including builtins). The config includes
|
||||
scan policies, enabled state and replacements. Read-set replay uses the new candidate's
|
||||
context. A trace from another committed graph marker or identity is cold/ineligible.
|
||||
|
||||
The first implementation is deliberately conservative and expensive: it replays complete
|
||||
queries and hashes their results, reads identity files and inspects SQLite. No efficient
|
||||
bucket index, missing-path watcher or general derived-query implementation is claimed.
|
||||
|
||||
## Authoritative execution and commit discipline
|
||||
|
||||
1. Always run the full audited callback on the real context. Its result is authoritative.
|
||||
An actual callback error follows the existing loader/candidate failure path.
|
||||
2. If explicitly admitted, replay the previous committed trace to predict `wouldReuse`.
|
||||
Always run the separate capture execution of **only this audited callback**, and
|
||||
compare its exact premerge output with the authoritative output. For a predicted hit,
|
||||
also compare the previous owned output exactly with the newly evaluated result.
|
||||
3. Return the original authoritative result regardless of observer error, mismatch,
|
||||
budget or taint. Observer failure/mismatch disables the session's trace; unsupported
|
||||
reads/legacy input discard that generation. No observed result controls the graph.
|
||||
4. The validation owner wraps a real engine/manager operation in `session.operation`.
|
||||
A pending trace is not retained as a valid generation unless the operation returns
|
||||
successfully, the existing `codegraph.semantic.update` **committed** event occurred,
|
||||
the actual SQLite `semantic_update` marker changed, observed metadata/config still
|
||||
matches, and exact fixture-owner SQLite output matches the expected ordered merge.
|
||||
5. Store the full owned output **before pair merge**, including an output shadowed by
|
||||
another owner. The ownership test reverses registry order and removes the winner;
|
||||
the previously shadowed contribution must appear. The private fixture oracle supplies
|
||||
the known competing owner's output. This is not a general replacement for observing
|
||||
all builtin/extension merge outputs in a production recorder.
|
||||
|
||||
Failure, partial work, unknown commit path or missing observation drops the trace. The
|
||||
existing transaction and recovery code is untouched; no new process-death mechanism
|
||||
exists to validate. A fresh child process proves that observation state is absent while
|
||||
the real graph remains correct. The validation owner closes the recorder with its graph.
|
||||
Normal `CodeGraph.close()` has no new observer hook; lifecycle association is private
|
||||
to this owner and would need an explicit internal hook before product integration.
|
||||
|
||||
One trace is retained per explicitly registered project, bounded to 128 observations
|
||||
and a 64 KiB serialized `{identity, trace, owned}` payload by default. Oversized captures
|
||||
are declined without affecting the callback. Reporting history is capped at 256 rows
|
||||
per session; it is diagnostic overhead, not an engine cache. The limits are serialized
|
||||
payload bounds, not promises about V8 heap allocation. Closing removes the session,
|
||||
trace and listener. No trace is written to the project or restored from disk.
|
||||
|
||||
## Integrated boundaries discovered
|
||||
|
||||
**Normal config-triggered sync can bypass candidate events.** It still runs the normal
|
||||
engine and applies the changed options, but this observer cannot certify that path.
|
||||
It discards observations. The options/scan identity cases therefore use the real explicit
|
||||
candidate refresh, and a separate normal-sync case asserts this conservative refusal.
|
||||
This is a finding about observation coverage, not a change to sync behavior.
|
||||
|
||||
**Non-indexed metadata is not a certified filesystem snapshot.** The existing engine
|
||||
source stamp does not cover arbitrary `binding.txt` reads. A deliberately timed edit
|
||||
after candidate evaluation can leave the old observed binding committed. The observer
|
||||
detects the changed direct-file observation and refuses to certify a trace; it cannot
|
||||
retroactively undo or repair the authoritative graph. Explicit refresh converges. Even
|
||||
auth/shadow equality can compare the same cached old file content, so equality alone is
|
||||
not a freshness guarantee. Indexed-source mutation rejects the actual candidate; config
|
||||
mutation during copy remains pending for the next sync and cannot certify the old trace.
|
||||
|
||||
Only the main-thread resolver is instrumented in this validation. Worker dispatch lacks
|
||||
the private session and must remain ineligible; no shared-process state or worker-channel
|
||||
integration was added. Native worker and transaction evidence remains the unchanged
|
||||
accepted evidence, not a new native shadow claim.
|
||||
|
||||
## Decision
|
||||
|
||||
**Pass for this bounded shadow experiment; no-go for production skipping yet.** It
|
||||
predicts repeat/irrelevant-input reuse while preserving actual full evaluation and
|
||||
detects the specified changes and failures. This cheap binding callback does not establish
|
||||
an economic case: small-fixture observation overhead must be compared with the much
|
||||
smaller callback time, not with the whole indexing operation. Predicted work avoided
|
||||
is hypothetical; actual callback skips are zero.
|
||||
|
||||
Before a selective-reuse implementation: provide real candidate generation and close/
|
||||
worker lifecycle hooks; close the non-indexed/negative-input observation and revalidation
|
||||
gap; cover every eligible commit path; generalize premerge ownership comparison without
|
||||
assuming the fixture schedule; measure a useful audited callback where replay costs less
|
||||
than reevaluation. Retain legacy/global fallback and the current atomic graph transaction.
|
||||
Do not roll this into public API-v1 admission, persistent cache, Drupal partitioning,
|
||||
cyclic stage evaluation or graph storage deltas. Global extraction/resolution/candidate/
|
||||
database costs persist. See the [exact evidence report](../validation/extensions-dependency-shadow-20260922.md).
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
// Audited validation adapter, grounded in extension-sync.test.ts's bindings pass.
|
||||
// No ambient I/O inside evaluate(). This is NOT a public v1 purity declaration.
|
||||
function evaluate(view, options) {
|
||||
// Enumerate registrations even when absent; predicate membership is observable.
|
||||
const registrations = view.getAllFiles().filter(f => f.endsWith('.events.yaml'));
|
||||
let target = options.target;
|
||||
if (!target) {
|
||||
const exists = view.fileExists('binding.txt');
|
||||
target = exists ? (view.readFile('binding.txt') || '').trim() : '';
|
||||
if (!target && registrations.length === 1) target = (view.readFile(registrations[0]) || '').trim();
|
||||
}
|
||||
// Fixed validation probes; arbitrary method names cannot be passed as options.
|
||||
if (options.probe === 'aliases') view.getProjectAliases();
|
||||
if (options.probe === 'caught') { try { view.unknownReviewOperation(); } catch { /* test sticky taint */ } }
|
||||
if (target === 'FAIL') throw new Error('audited binding failed');
|
||||
if (!target) return [];
|
||||
const from = view.getNodesByName('dispatch').filter(n => n.kind === 'function' && n.language === 'python');
|
||||
const targets = view.getNodesByName(target).filter(n => n.kind === 'function' && n.language === 'python');
|
||||
if (from.length !== 1 || (!options.first && targets.length !== 1) || !targets.length) return [];
|
||||
return [{ source: from[0].id, target: targets[0].id, kind: 'calls', line: 1,
|
||||
metadata: { label: 'Audited binding', targetSignature: targets[0].signature || '' } }];
|
||||
}
|
||||
exports.evaluate = evaluate;
|
||||
exports.factory = ({ projectRoot, options }) => ({ synthPasses: [{ name: 'binding', languages: ['python'],
|
||||
run: ctx => require('./recorder.cjs').invoke(projectRoot, ctx, options) }] });
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
// Internal opt-in experiment. Never imported by production. No callback skips.
|
||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||
const { channel } = require('node:diagnostics_channel');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { currentPlugins } = require('../../../dist/plugins/registry');
|
||||
const { getSynthPasses } = require('../../../dist/resolution/callback-synthesizer');
|
||||
const { getCodeGraphDir } = require('../../../dist/directory');
|
||||
const { evaluate } = require('./adapter.cjs');
|
||||
const { hash } = require('../dependency-review/model.cjs');
|
||||
const sessions = new Map(), events = channel('codegraph.semantic.update');
|
||||
const copy = value => structuredClone(value);
|
||||
const clock = () => performance.now();
|
||||
const raw = file => { try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
|
||||
const byteHash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const loadedCode = { adapter: byteHash(path.join(__dirname, 'adapter.cjs')), observer: byteHash(__filename),
|
||||
engineSource: byteHash(path.join(__dirname, '../../../dist/index.js')) };
|
||||
const normalize = edges => edges.map(e => ({ source: e.source, target: e.target, kind: e.kind,
|
||||
line: e.line ?? null, metadata: e.metadata, provenance: e.provenance }));
|
||||
const guardedOutput = edges => edges.map(e => ({ ...e, provenance: 'heuristic', metadata: { ...e.metadata, synthesizedBy: 'shadow-binding' } }));
|
||||
const methods = new Set(['readFile', 'fileExists', 'getAllFiles', 'getNodesByName']);
|
||||
function projected(value, method) {
|
||||
// New internal view explicitly excludes wall-clock updatedAt. Every other
|
||||
// observable Node field and query order is kept. No claim of transparent v1 capture.
|
||||
return method === 'getNodesByName' ? JSON.parse(JSON.stringify(value.map(({ updatedAt, ...node }) => node))) : value;
|
||||
}
|
||||
function view(ctx, observer) {
|
||||
return new Proxy({}, { get(_, method) {
|
||||
return (...args) => {
|
||||
// Unknown/caught operations taint before calling or throwing; authoritative
|
||||
// semantics are forwarded unchanged. A proxy is not an I/O sandbox.
|
||||
if (!methods.has(method)) observer?.taint('unsupported:' + String(method));
|
||||
const value = projected(ctx[method](...args), method);
|
||||
observer?.read(method, args, value);
|
||||
return copy(value);
|
||||
};
|
||||
} });
|
||||
}
|
||||
function dbState(root) {
|
||||
const file = path.join(getCodeGraphDir(root), 'codegraph.db');
|
||||
if (!fs.existsSync(file)) return { marker: null, edges: [] };
|
||||
const db = new DatabaseSync(file, { readOnly: true });
|
||||
try {
|
||||
const marker = db.prepare("SELECT value FROM project_metadata WHERE key='semantic_update'").get()?.value || null;
|
||||
const edges = db.prepare('SELECT source,target,kind,line,metadata,provenance FROM edges').all()
|
||||
.map(e => ({ ...e, metadata: e.metadata ? JSON.parse(e.metadata) : undefined }));
|
||||
return { marker, edges };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
function sourceIdentity(root, registry, options) {
|
||||
if (loadedCode.adapter !== byteHash(path.join(__dirname, 'adapter.cjs')) || loadedCode.observer !== byteHash(__filename) ||
|
||||
loadedCode.engineSource !== byteHash(path.join(__dirname, '../../../dist/index.js'))) throw Error('loaded host code changed; restart required');
|
||||
return { project: fs.realpathSync(root), ...loadedCode, engine: require('../../../package.json').version,
|
||||
queryContract: 'shadow-view-1-without-updatedAt',
|
||||
stage: 'independent-post-resolution', options: copy(options),
|
||||
config: raw(path.join(root, 'codegraph.json')), ignore: raw(path.join(root, '.gitignore')),
|
||||
// Whole codegraph config includes scan policy, replacements and enabled order.
|
||||
environment: Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith('CODEGRAPH_')).sort()),
|
||||
registry: registry.resolved.map(p => [p.manifest.id, p.version, p.digest, p.options, p.replaces]),
|
||||
mergeOrder: getSynthPasses().map(p => p.name) };
|
||||
}
|
||||
function currentFileMatches(root, read) {
|
||||
if (read.method === 'readFile') return hash(raw(path.join(root, read.args[0]))) === read.hash;
|
||||
if (read.method === 'fileExists') return hash(fs.existsSync(path.join(root, read.args[0]))) === read.hash;
|
||||
return true;
|
||||
}
|
||||
class Session {
|
||||
constructor(root, options) {
|
||||
this.root = fs.realpathSync(root); this.options = options; this.committed = null; this.active = null;
|
||||
this.records = []; this.closed = false; this.disabled = false;
|
||||
this.listener = e => { if (e.projectRoot === this.root && this.active) {
|
||||
this.active.phases.push(e.phase); if (e.phase === 'committed') this.active.graphCommitted = true;
|
||||
} };
|
||||
events.subscribe(this.listener);
|
||||
}
|
||||
async operation(name, fn, { fault } = {}) {
|
||||
if (this.closed || this.active) throw Error('shadow session closed or operation active');
|
||||
const start = clock(); let previousMarker;
|
||||
try { previousMarker = dbState(this.root).marker; } catch { this.disabled = true; this.committed = null; }
|
||||
const op = this.active = { name, phases: [], pending: null, graphCommitted: false, fault, invocations: 0 };
|
||||
let error, result;
|
||||
try { result = await fn(); } catch (e) { error = e; }
|
||||
const finalizeStart = clock();
|
||||
// Never throw an observer failure into the successful engine operation.
|
||||
const record = { name, success: !error, certified: false, phases: op.phases, ...op.pending?.metrics,
|
||||
authoritativeInvocations: op.invocations, graphMarker: null, trace: op.pending?.trace || [],
|
||||
owned: op.pending?.owned || [], reason: error ? 'operation-failed' : op.reason || 'not-observed' };
|
||||
try {
|
||||
const state = dbState(this.root); record.graphMarker = state.marker;
|
||||
if (!error && op.pending && !this.disabled && !op.reason && op.graphCommitted && state.marker !== previousMarker) {
|
||||
const p = op.pending;
|
||||
if (raw(path.join(this.root, 'codegraph.json')) !== p.identity.config || raw(path.join(this.root, '.gitignore')) !== p.identity.ignore ||
|
||||
!p.trace.every(r => currentFileMatches(this.root, r))) throw Error('observed source/config changed before certification');
|
||||
// Owned premerge output can be shadowed by another earlier pass. Compare
|
||||
// the complete registered fixture-owner merge, never delete shadowed ownership.
|
||||
const expected = this.options.expectedMerge ? this.options.expectedMerge(p.owned) : guardedOutput(p.owned);
|
||||
const actual = state.edges.filter(e => this.options.owners.includes(e.metadata?.synthesizedBy));
|
||||
const sort = es => normalize(es).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
if (hash(sort(actual)) !== hash(sort(expected))) throw Error('committed SQLite/owned merge mismatch');
|
||||
this.committed = { identity: p.identity, trace: p.trace, owned: copy(p.owned), marker: state.marker, bytes: p.metrics.traceBytes };
|
||||
record.certified = true; record.reason = 'committed'; record.sqliteHash = hash(sort(actual));
|
||||
} else if (error || op.reason || !op.graphCommitted) this.committed = null;
|
||||
} catch (e) { record.reason = 'observer:' + e.message; this.committed = null; this.disabled = true; }
|
||||
record.finalizeMs = clock() - finalizeStart;
|
||||
record.observerMs = (record.identityMs || 0) + (record.replayMs || 0) + (record.shadowMs || 0) + (record.compareMs || 0) + record.finalizeMs;
|
||||
record.totalOperationMs = clock() - start;
|
||||
this.records.push(record); if (this.records.length > 256) this.records.shift();
|
||||
this.active = null;
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
close() { this.committed = null; this.active = null; this.closed = true; sessions.delete(this.root); events.unsubscribe(this.listener); }
|
||||
}
|
||||
function enable(root, options) {
|
||||
const real = fs.realpathSync(root);
|
||||
if (sessions.has(real) || !options.expectedPackageDigest || !options.owners?.includes('shadow-binding')) throw Error('explicit unique project/package admission required');
|
||||
const session = new Session(real, { maxReads: 128, maxBytes: 65536, ...options }); sessions.set(real, session); return session;
|
||||
}
|
||||
function invoke(root, ctx, options) {
|
||||
// The full authoritative callback is unconditional. Observer code runs only
|
||||
// afterward and never changes this value, including mismatches/over-budget.
|
||||
const session = sessions.get(root), op = session?.active;
|
||||
if (op) op.invocations++;
|
||||
const authStart = clock(); const authoritative = evaluate(view(ctx), copy(options)); const authoritativeMs = clock() - authStart;
|
||||
if (!session || !op || session.disabled) return authoritative;
|
||||
try {
|
||||
const identityStart = clock(), registry = currentPlugins();
|
||||
const own = registry?.resolved.find(p => p.manifest.id === 'shadow-binding');
|
||||
if (!own || own.digest !== session.options.expectedPackageDigest) { op.reason = 'not-admitted-package'; return authoritative; }
|
||||
const identity = sourceIdentity(root, registry, options), identityMs = clock() - identityStart;
|
||||
const legacy = registry.resolved.some(p => !session.options.auditedUpstreamDigests?.includes(p.digest) && p !== own);
|
||||
let reason = legacy ? 'legacy-untracked-global-fallback' : null;
|
||||
const old = session.committed, replayStart = clock();
|
||||
let wouldReuse = !reason && !!old && old.marker === dbState(root).marker && hash(old.identity) === hash(identity);
|
||||
if (wouldReuse) for (const r of old.trace) {
|
||||
if (hash(projected(ctx[r.method](...r.args), r.method)) !== r.hash) { wouldReuse = false; break; }
|
||||
}
|
||||
const replayMs = clock() - replayStart;
|
||||
const trace = []; let traceBytes = 0, captureMs = 0;
|
||||
const observer = { taint: why => { reason ||= why; }, read(method, args, value) {
|
||||
const t = clock();
|
||||
if (op.fault === 'capture-error') throw Error('injected observer failure');
|
||||
if (methods.has(method) && !reason) {
|
||||
const entry = { method, args: copy(args), hash: hash(value), valueBytes: Buffer.byteLength(JSON.stringify(value)),
|
||||
empty: value === null || value === false || (Array.isArray(value) && value.length === 0) };
|
||||
traceBytes += Buffer.byteLength(JSON.stringify(entry));
|
||||
if (trace.length >= session.options.maxReads || traceBytes > session.options.maxBytes) reason = 'observation-budget';
|
||||
else trace.push(entry);
|
||||
}
|
||||
captureMs += clock() - t;
|
||||
} };
|
||||
const shadowStart = clock(); const shadow = evaluate(view(ctx, observer), copy(options)); const shadowMs = clock() - shadowStart;
|
||||
const compareStart = clock();
|
||||
if (op.fault === 'mismatch') shadow.push({ source: 'invalid', target: 'invalid', kind: 'calls' });
|
||||
if (hash(shadow) !== hash(authoritative) || (wouldReuse && hash(old.owned) !== hash(authoritative))) {
|
||||
reason = 'prediction-or-shadow-mismatch'; session.disabled = true;
|
||||
}
|
||||
const compareMs = clock() - compareStart;
|
||||
if (reason) { op.reason = reason; session.committed = null; }
|
||||
op.pending = { identity, trace: reason ? [] : trace, owned: copy(authoritative), metrics: {
|
||||
wouldReuse: wouldReuse && !reason, predictedCallbackMsAvoided: wouldReuse && !reason ? authoritativeMs : 0,
|
||||
authoritativeMs, identityMs, captureMs, replayMs, compareMs, shadowMs,
|
||||
traceBytes: reason ? 0 : Buffer.byteLength(JSON.stringify({ identity, trace, owned: authoritative })),
|
||||
readCount: trace.length, negativeReads: trace.filter(r => r.empty).length,
|
||||
actualCallbacksSkipped: 0, shadowExecutions: 1 } };
|
||||
if (op.pending.metrics.traceBytes > session.options.maxBytes) { op.reason = 'total-observation-budget'; op.pending.trace = []; session.committed = null; }
|
||||
} catch (error) { session.committed = null; session.disabled = true; op.reason = 'observer:' + error.message; }
|
||||
return authoritative;
|
||||
}
|
||||
module.exports = { enable, invoke, dbState, normalize, guardedOutput, hash };
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
|
||||
const assert = require('node:assert/strict'), cp = require('node:child_process');
|
||||
const { channel } = require('node:diagnostics_channel');
|
||||
Object.assign(process.env, { CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '0', CODEGRAPH_RESOLVE_WORKERS: '0' });
|
||||
const { CodeGraph, packExtension } = require('../../../dist');
|
||||
const { ExtensionManager } = require('../../../dist/plugins/manager');
|
||||
const { packageDigest } = require('../../../dist/plugins/package');
|
||||
const { enable, dbState, hash, normalize, guardedOutput } = require('./recorder.cjs');
|
||||
const adapter = path.join(__dirname, 'adapter.cjs');
|
||||
const projectName = 'shadow-binding';
|
||||
const handlers = 'def dispatch():\n pass\ndef receipt():\n pass\ndef cancel():\n pass\n';
|
||||
const checks = [], commands = [];
|
||||
let lab, out;
|
||||
function write(root, file, value) { const p = path.join(root, file); if (value === null) { if (fs.existsSync(p)) fs.unlinkSync(p); }
|
||||
else { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, value); } }
|
||||
function config(root, mutate) { const p = path.join(root, 'codegraph.json'), c = JSON.parse(fs.readFileSync(p)); mutate(c); fs.writeFileSync(p, JSON.stringify(c, null, 2)); }
|
||||
function nodeState(root) {
|
||||
const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(path.join(root, '.codegraph/codegraph.db'), { readOnly: true });
|
||||
try { return db.prepare('SELECT id,name,file_path,start_line,signature FROM nodes ORDER BY id').all(); } finally { db.close(); }
|
||||
}
|
||||
function graphHash(root) { const s = dbState(root); return hash(normalize(s.edges).sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b)))); }
|
||||
function bindingEdges(root) { return dbState(root).edges.filter(e => ['shadow-binding', 'overlap'].includes(e.metadata?.synthesizedBy)); }
|
||||
function targetName(root) { const ns = nodeState(root); return bindingEdges(root).map(e => ns.find(n => n.id === e.target)?.name); }
|
||||
function makePackage(id, source, capabilities = ['synthPasses'], version = '0.1.0') {
|
||||
const dir = path.join(lab, `author-${id}-${version}`); fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: id, version, main: 'index.cjs', codegraph: { id, apiVersion: 1, capabilities } }, null, 2));
|
||||
fs.writeFileSync(path.join(dir, 'index.cjs'), source);
|
||||
return { bytes: packExtension(dir), digest: packageDigest(dir), dir };
|
||||
}
|
||||
function save() { if (out) fs.writeFileSync(path.join(out, 'progress.json'), JSON.stringify({ checks, commands }, null, 2)); }
|
||||
function pass(name, record, extra = {}) { checks.push({ name, scope: 'real managed candidate/synthesis/SQLite', record, ...extra }); console.log('PASS', name); save(); }
|
||||
async function main() {
|
||||
out = path.resolve(process.argv[2] || '.qa/dependency-shadow'); fs.mkdirSync(out, { recursive: true });
|
||||
for (const name of ['adapter.cjs','recorder.cjs','run.cjs']) fs.copyFileSync(path.join(__dirname,name),path.join(out,'source-'+name));
|
||||
lab = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-shadow-'));
|
||||
const pkg = makePackage(projectName, `module.exports=require(${JSON.stringify(adapter)}).factory;`);
|
||||
const root = path.join(lab,'project');fs.mkdirSync(root);write(root,'handlers.py',handlers);write(root,'unrelated.py','def unrelated():\n pass\n');
|
||||
const manager = new ExtensionManager(root); let graph, s;
|
||||
const settings = { expectedPackageDigest: pkg.digest, owners: ['shadow-binding'] };
|
||||
function start(overrides = {}) { s?.close(); s = enable(root, { ...settings, ...overrides }); return s; }
|
||||
async function run(name, fn, expected, prediction, extras = {}) {
|
||||
await s.operation(name, fn); const r = s.records.at(-1);
|
||||
assert.deepEqual(targetName(root), expected, name);
|
||||
if (prediction !== undefined) assert.equal(r.wouldReuse, prediction, name);
|
||||
if (!extras.uncertified) assert.equal(r.certified,true, JSON.stringify(r));
|
||||
assert.equal(r.actualCallbacksSkipped ?? 0, 0); pass(name,r,extras); return r;
|
||||
}
|
||||
const refresh = () => graph.refreshPluginIndex();
|
||||
try {
|
||||
// Ordinary installation before opt-in produces a real graph but no recorder.
|
||||
await manager.install({bytes:pkg.bytes});graph=await CodeGraph.open(root);
|
||||
assert.deepEqual(targetName(root),[]);pass('disabled by default: ordinary full install works',null);
|
||||
start();
|
||||
const cold=await run('explicitly enabled cold trace with missing metadata',refresh,[],false);
|
||||
assert.ok(cold.negativeReads>=1);assert.ok(cold.trace.some(r=>r.method==='fileExists'&&r.empty));
|
||||
await run('unchanged full evaluation predicts reuse',refresh,[],true);
|
||||
write(root,'binding.txt','missing');await run('non-indexed metadata appears with missing symbol',refresh,[],false);
|
||||
assert.ok(s.records.at(-1).trace.some(r=>r.method==='getNodesByName'&&r.empty));
|
||||
write(root,'missing.py','def missing():\n pass\n');await run('missing symbol appears in candidate query membership',()=>graph.sync(),['missing'],false);
|
||||
write(root,'duplicate.py','def missing():\n pass\n');await run('duplicate matching symbol removes ambiguous edge',()=>graph.sync(),[],false);
|
||||
write(root,'duplicate.py',null);await run('duplicate deletion restores unique target',()=>graph.sync(),['missing'],false);
|
||||
fs.renameSync(path.join(root,'missing.py'),path.join(root,'renamed.py'));await run('target rename replaces endpoint identity',()=>graph.sync(),['missing'],false);
|
||||
write(root,'renamed.py',null);await run('target deletion removes owned edge',()=>graph.sync(),[],false);
|
||||
write(root,'binding.txt','receipt');await run('metadata retarget selects unchanged receipt endpoint',refresh,['receipt'],false);
|
||||
write(root,'binding.txt','cancel');await run('metadata retarget selects unchanged cancel endpoint',refresh,['cancel'],false);
|
||||
const beforeNodes=nodeState(root);write(root,'handlers.py',handlers.replace('cancel():','cancel(value=1):'));
|
||||
await run('same-ID node signature field invalidates captured query',()=>graph.sync(),['cancel'],false);
|
||||
const a=beforeNodes.find(n=>n.name==='cancel'),b=nodeState(root).find(n=>n.name==='cancel');assert.equal(a.id,b.id);assert.notEqual(a.signature,b.signature);
|
||||
write(root,'unrelated.py','def unrelated():\n return 7\n');await run('irrelevant existing-file edit predicts reuse but still evaluates',()=>graph.sync(),['cancel'],true);
|
||||
write(root,'binding.txt',null);await run('metadata deletion removes binding',refresh,[],false);
|
||||
write(root,'registration.events.yaml','receipt');await run('registration enumeration membership enables fallback binding',()=>graph.sync(),['receipt'],false);
|
||||
write(root,'registration.events.yaml',null);await run('registration deletion clears output',()=>graph.sync(),[],false);
|
||||
write(root,'binding.txt','receipt');await run('restore metadata and rebuild trace',refresh,['receipt'],false);
|
||||
config(root,c=>c.plugins[0].options={target:'cancel'});await run('loaded options identity invalidates',refresh,['cancel'],false);
|
||||
config(root,c=>c.exclude=['unrelated.py']);await run('scan policy identity invalidates',refresh,['cancel'],false);
|
||||
write(root,'.gitignore','unrelated.py\n');await run('ignore policy identity invalidates',refresh,['cancel'],false);
|
||||
// Independent project with identical fixture package/options never shares traces.
|
||||
const other=path.join(lab,'other');fs.mkdirSync(other);write(other,'handlers.py',handlers);write(other,'binding.txt','receipt');
|
||||
const osession=enable(other,settings);try{await osession.operation('project identity',()=>new ExtensionManager(other).install({bytes:pkg.bytes}));
|
||||
assert.equal(osession.records.at(-1).wouldReuse,false);assert.equal(osession.records.at(-1).certified,true);pass('project identity isolates independent managed project',osession.records.at(-1));
|
||||
}finally{osession.close();}
|
||||
const pkg2=makePackage(projectName,`// changed reviewed package bytes\nmodule.exports=require(${JSON.stringify(adapter)}).factory;`,['synthPasses'],'0.1.1');
|
||||
settings.expectedPackageDigest=pkg2.digest;s.options.expectedPackageDigest=pkg2.digest;
|
||||
await run('adapter package content/version identity invalidates',()=>manager.install({bytes:pkg2.bytes}),['cancel'],false);
|
||||
// A forged v1 declaration has no admission effect. Exact content is required.
|
||||
const admitted=s.options.expectedPackageDigest;s.options.expectedPackageDigest='forged-purity-flag';
|
||||
await run('wrong package admission cannot opt into recording',refresh,['cancel'],undefined,{uncertified:true});assert.equal(s.records.at(-1).reason,'not-admitted-package');
|
||||
s.options.expectedPackageDigest=admitted;
|
||||
// Real legacy hook reads another file without using ResolutionContext.
|
||||
const legacy=makePackage('legacy-hook',`module.exports=({projectRoot})=>({frameworks:[{name:'legacy',languages:['python'],detect:()=>true,resolve:()=>null,extract(file){if(file!=='handlers.py')return {nodes:[],references:[]};const name=require('fs').readFileSync(require('path').join(projectRoot,'binding.txt'),'utf8');return {nodes:[{id:'plugin:legacy-hook:route',name,qualifiedName:'legacy:route',kind:'route',language:'python',filePath:file,startLine:1,endLine:1,startColumn:0,endColumn:0}],references:[]}}}],synthPasses:[{name:'count',run(){require('fs').appendFileSync(require('path').join(projectRoot,'legacy-calls.txt'),'1');return []}}]});`,['frameworks','synthPasses']);
|
||||
await run('legacy fs hook is ineligible and still evaluated once',()=>manager.install({bytes:legacy.bytes}),['cancel'],false,{uncertified:true});
|
||||
assert.equal(s.records.at(-1).reason,'legacy-untracked-global-fallback');const count=fs.readFileSync(path.join(root,'legacy-calls.txt'),'utf8').length;
|
||||
write(root,'binding.txt','cancel');await run('legacy cross-file edit changes actual node without capture admission',refresh,['cancel'],false,{uncertified:true});
|
||||
assert.equal(fs.readFileSync(path.join(root,'legacy-calls.txt'),'utf8').length,count+1);assert.ok(nodeState(root).some(n=>n.id==='plugin:legacy-hook:route'&&n.name==='cancel'));
|
||||
await run('legacy removal returns to cold eligible trace',()=>manager.remove('legacy-hook'),['cancel'],false);
|
||||
// Unsupported operation is performed only by the fixed audited probe branch.
|
||||
for(const probe of ['aliases','caught']){
|
||||
config(root,c=>c.plugins.find(p=>p.name==='managed:shadow-binding').options={target:'receipt',probe});
|
||||
await run('unsupported '+probe+' read taints even when caught',refresh,['receipt'],false,{uncertified:true});assert.match(s.records.at(-1).reason,/unsupported/);
|
||||
}
|
||||
config(root,c=>c.plugins[0].options={});write(root,'binding.txt','receipt');start();await run('cold reset after unsupported traces',refresh,['receipt'],false);
|
||||
// Failure originates in actual callback and existing loader/candidate guard.
|
||||
write(root,'binding.txt','FAIL');const working=graphHash(root);
|
||||
await assert.rejects(()=>s.operation('real callback failure',refresh),/audited binding failed/);assert.equal(graphHash(root),working);assert.equal(s.committed,null);pass('real callback failure preserves SQLite and certifies no partial trace',s.records.at(-1));
|
||||
write(root,'binding.txt','receipt');await run('actual failed callback retries cold',refresh,['receipt'],false);
|
||||
for(const fault of ['capture-error','mismatch']){
|
||||
await s.operation('observer '+fault,refresh,{fault});assert.deepEqual(targetName(root),['receipt']);assert.equal(s.committed,null);assert.equal(s.disabled,true);
|
||||
pass('observer '+fault+' disables trace and preserves authoritative SQLite',s.records.at(-1));start();await run('reset after '+fault,refresh,['receipt'],false);
|
||||
}
|
||||
s.committed.owned[0].metadata.label='injected stale prediction';await s.operation('prediction mismatch',refresh);assert.equal(s.disabled,true);assert.deepEqual(targetName(root),['receipt']);assert.equal(s.records.at(-1).reason,'prediction-or-shadow-mismatch');pass('incorrect predicted output disables trace, never replaces graph',s.records.at(-1));start();
|
||||
await run('reset before race tests',refresh,['receipt'],false);
|
||||
let listener=e=>{if(e.projectRoot===root&&e.phase==='candidate_ready')write(root,'handlers.py',handlers+'# changed during candidate\n');};
|
||||
channel('codegraph.semantic.update').subscribe(listener);const beforeRace=graphHash(root);
|
||||
try{await assert.rejects(()=>s.operation('indexed source race',refresh),/changed during semantic indexing/);}finally{channel('codegraph.semantic.update').unsubscribe(listener);}
|
||||
assert.equal(graphHash(root),beforeRace);assert.equal(s.committed,null);pass('indexed source mutation rejects actual candidate and trace',s.records.at(-1));
|
||||
await run('indexed source race retries cold',refresh,['receipt'],false);
|
||||
listener=e=>{if(e.projectRoot===root&&e.phase==='candidate_ready')write(root,'binding.txt','cancel');};
|
||||
channel('codegraph.semantic.update').subscribe(listener);
|
||||
try{await s.operation('non-indexed source race',refresh);}finally{channel('codegraph.semantic.update').unsubscribe(listener);}
|
||||
// The existing engine does NOT stamp arbitrary non-indexed metadata. The
|
||||
// graph reflects its observed old value, but recorder refuses certification.
|
||||
assert.deepEqual(targetName(root),['receipt']);assert.equal(s.committed,null);assert.match(s.records.at(-1).reason,/source\/config changed/);
|
||||
pass('non-indexed race exposes existing snapshot limit and disables trace',s.records.at(-1),{limitation:'Engine commits old observed metadata; explicit refresh required'});start();
|
||||
await run('explicit refresh converges after metadata race',refresh,['cancel'],false);
|
||||
listener=e=>{if(e.projectRoot===root&&e.phase==='copy_started')config(root,c=>c.plugins[0].options={target:'receipt'});};
|
||||
channel('codegraph.semantic.update').subscribe(listener);
|
||||
try{await s.operation('config race',refresh);}finally{channel('codegraph.semantic.update').unsubscribe(listener);}
|
||||
assert.deepEqual(targetName(root),['cancel']);assert.equal(s.committed,null);assert.equal(s.disabled,true);pass('configuration changed during commit cannot certify old trace',s.records.at(-1));start();
|
||||
await run('next normal config sync applies options but has no candidate certificate',()=>graph.sync(),['receipt'],false,{uncertified:true});
|
||||
assert.equal(s.records.at(-1).certified,false);assert.equal(s.committed,null);
|
||||
|
||||
// Source-only framework fixture supplies fixed node IDs so query order can
|
||||
// change independently of identity. It is a pinned test producer, never cached.
|
||||
const provider=makePackage('target-provider',`module.exports=()=>({frameworks:[{name:'targets',languages:['yaml'],detect:()=>true,resolve:()=>null,extract(file,source){if(file!=='targets.yaml')return {nodes:[],references:[]};return {nodes:JSON.parse(source).map(n=>({id:'plugin:target-provider:'+n.id,name:'ordered',qualifiedName:'fixture:'+n.id,kind:'function',language:'python',filePath:file,startLine:n.line,endLine:n.line,startColumn:0,endColumn:1,signature:n.sig})),references:[]}}}]});`,['frameworks']);
|
||||
settings.auditedUpstreamDigests=[provider.digest];start();
|
||||
write(root,'targets.yaml',JSON.stringify([{id:'a',line:1,sig:'a'},{id:'b',line:2,sig:'b'}]));
|
||||
await s.operation('provider install',()=>manager.install({bytes:provider.bytes}));
|
||||
config(root,c=>c.plugins.find(p=>p.name==='managed:shadow-binding').options={target:'ordered',first:true});
|
||||
await run('ordered membership baseline with pinned upstream producer',refresh,['ordered'],false);assert.equal(bindingEdges(root)[0].target,'plugin:target-provider:a');
|
||||
const ids=nodeState(root).filter(n=>n.name==='ordered').map(n=>n.id);
|
||||
write(root,'targets.yaml',JSON.stringify([{id:'a',line:2,sig:'a'},{id:'b',line:1,sig:'b'}]));
|
||||
await run('same-ID query order change selects correct first result',()=>graph.sync(),['ordered'],false);
|
||||
assert.deepEqual(nodeState(root).filter(n=>n.name==='ordered').map(n=>n.id),ids);assert.equal(bindingEdges(root)[0].target,'plugin:target-provider:b');
|
||||
await s.operation('provider remove',()=>manager.remove('target-provider'));settings.auditedUpstreamDigests=[];
|
||||
config(root,c=>c.plugins[0].options={target:'receipt'});start();await run('restore ordinary audited binding',refresh,['receipt'],false);
|
||||
const overlap=makePackage('overlap',`module.exports=()=>({synthPasses:[{name:'overlap',run(ctx){const a=ctx.getNodesByName('dispatch'),b=ctx.getNodesByName('receipt');return a.length===1&&b.length===1?[{source:a[0].id,target:b[0].id,kind:'calls',line:1,metadata:{label:'Overlap'}}]:[]}}]});`);
|
||||
settings.owners=['shadow-binding','overlap'];settings.auditedUpstreamDigests=[overlap.digest];
|
||||
settings.expectedMerge=owned=>{const ordered=JSON.parse(fs.readFileSync(path.join(root,'codegraph.json'))).plugins.filter(p=>p.enabled!==false);const first=ordered[0].name;
|
||||
return first==='managed:overlap'?owned.map(e=>({...e,provenance:'heuristic',metadata:{label:'Overlap',synthesizedBy:'overlap'}})):guardedOutput(owned);};start();
|
||||
await run('overlap keeps full premerge owned outputs',()=>manager.install({bytes:overlap.bytes}),['receipt'],false);assert.equal(s.committed.owned.length,1);assert.equal(bindingEdges(root)[0].metadata.synthesizedBy,'shadow-binding');
|
||||
config(root,c=>c.plugins.reverse());await run('ordered registry identity changes collision winner',refresh,['receipt'],false);assert.equal(bindingEdges(root)[0].metadata.synthesizedBy,'overlap');assert.equal(s.committed.owned.length,1);
|
||||
await run('removing winning owner reveals shadowed output',()=>manager.remove('overlap'),['receipt'],false);assert.equal(bindingEdges(root)[0].metadata.synthesizedBy,'shadow-binding');
|
||||
settings.owners=['shadow-binding'];settings.auditedUpstreamDigests=[];delete settings.expectedMerge;
|
||||
config(root,c=>delete c.exclude);write(root,'.gitignore',null); // keep repeat edits in the indexed set
|
||||
start({maxBytes:64});await s.operation('bounded recorder',refresh);assert.deepEqual(targetName(root),['receipt']);assert.equal(s.committed,null);assert.match(s.records.at(-1).reason,/budget/);pass('bounded observations decline caching without changing graph',s.records.at(-1));
|
||||
start();await run('fresh session starts cold',refresh,['receipt'],false);
|
||||
// Store exact observation breakdown, not an estimate of engine speedup.
|
||||
for(let i=0;i<5;i++)await run('unchanged overhead sample '+(i+1),refresh,['receipt'],true,{sample:'unchanged'});
|
||||
for(let i=0;i<5;i++){write(root,'unrelated.py','def unrelated():\n return '+i+'\n');await run('irrelevant content overhead sample '+(i+1),refresh,['receipt'],true,{sample:'irrelevant-content-explicit-refresh'});}
|
||||
const stable=graphHash(root);s.close();graph.close();graph=null;
|
||||
const child=cp.spawnSync(process.execPath,[__filename,'--cold-child',root,settings.expectedPackageDigest],{encoding:'utf8',timeout:60000});
|
||||
fs.writeFileSync(path.join(out,'cold-child.log'),child.stdout+child.stderr);commands.push({command:[process.execPath,__filename,'--cold-child',root,settings.expectedPackageDigest],exit:child.status,signal:child.signal});assert.equal(child.status,0,child.stderr);
|
||||
const last=JSON.parse(child.stdout.trim().split('\n').at(-1));assert.equal(last.wouldReuse,false);assert.equal(last.certified,true);assert.equal(graphHash(root),stable);pass('fresh OS process has no prior trace and preserves full graph',last);
|
||||
// Cold full reference with recorder absent has exactly the same graph.
|
||||
graph=await CodeGraph.open(root);await graph.refreshPluginIndex();assert.equal(graphHash(root),stable);pass('recorder-disabled full reference equals final SQLite graph',null);
|
||||
fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({success:true,revision:cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),platform:process.platform,node:process.version,
|
||||
checks,commands,productionChanges:false,callbackSkipping:false,workerCoverage:'main-thread resolver; production worker code unchanged',limits:['No arbitrary-plugin containment','Non-indexed metadata races are detected for trace but existing engine requires explicit refresh','Process-local validation owner must close the recorder with graph','No full suite/native rerun: no production source change']},null,2));
|
||||
}finally{s?.close();graph?.close();fs.rmSync(lab,{recursive:true,force:true});}
|
||||
}
|
||||
if(process.argv[2]==='--cold-child'){
|
||||
(async()=>{const root=process.argv[3],s=enable(root,{expectedPackageDigest:process.argv[4],owners:['shadow-binding']});const g=await CodeGraph.open(root);
|
||||
try{await s.operation('cold process',()=>g.refreshPluginIndex());console.log(JSON.stringify(s.records.at(-1)));}finally{s.close();g.close();}})().catch(e=>{console.error(e.stack);process.exitCode=1});
|
||||
}else main().catch(e=>{save();fs.writeFileSync(path.join(out,'failure.json'),JSON.stringify({checks,commands,error:String(e.stack)},null,2));console.error(e.stack);process.exitCode=1;});
|
||||
Reference in New Issue
Block a user