docs: review opt-in semantic read sets with executable counterexamples

This commit is contained in:
Forge
2026-09-22 16:41:59 +00:00
parent b04cf47937
commit 57f332dbb7
3 changed files with 582 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
# Opt-in semantic read-set contract: decision review
Status: **proposal with validation-only evidence; no public API or production behavior change**.
Reviewed against application `7575dcc71e4524c660aff06486ebc007cca290b9` on draft PR #1911.
The executable [model](../validation/dependency-review/model.cjs) and
[driver](../validation/dependency-review/run.cjs) are not imported by the engine.
## Recommendation and decision
Proceed with a **bounded, opt-in, initially memory-only capture experiment for edge-only
synthesis over an immutable host snapshot**. Start with the generic cross-file binding
example and a trusted host-owned adapter. First implement shadow evaluation: capture
reads, predict reuse, still run the full callback, and compare outputs. Do not yet skip
community callbacks, cache extraction/post-extraction outputs, or replace the current
candidate/SQLite transaction. A production reuse gate requires enforceable capabilities
or a deliberately narrow audited host implementation; a package's declaration of
determinism is insufficient. Do not describe a Node worker or `vm` wrapper as a sandbox.
An additive *future* capability interface is necessary for general author opt-in. It
must not reinterpret existing `SynthPass.run` or `FrameworkResolver.extract` as pure.
Existing API-v1 packages, including immutable Drupal 0.1.0 and 0.1.1, continue through
global evaluation. No migration, new package release or engine rollout occurs here.
The contract can avoid repeated callback work when a narrow pass's observed inputs
are unchanged. It cannot avoid input-change discovery, base parsing/resolution,
whole-candidate construction or database replacement by itself. Current Drupal wiring
reads nearly all PHP/YAML inputs; whole-pass capture will therefore invalidate on most
Drupal edits. Useful finer reuse would require separately owned discovery/route/service/
hook/event units with tracked derived-index queries. That is a later design and a new
reviewed package version, not an automatic rewrite of either immutable release.
## Current reads and stage boundaries
Locations below refer to unchanged source. A read returning nothing is still an input.
The source ledger in the [validation report](../validation/extensions-dependency-review-20260922.md)
pins these files and representative corpus bytes.
| Surface | Actual behavior | Required dependency / consequence |
|---|---|---|
| [`PluginContext`](../../src/plugins/api.ts#L24), [factory loading](../../src/plugins/loader.ts#L76) | Factory receives real project root, cloned options, engine version; executes arbitrary CommonJS/ESM, independently in worker contexts | Project identity, **package content digest**, options, engine/adapter epoch; factory closure state, direct `fs`, network, process environment, time/random and module state remain outside capture |
| [`FrameworkResolver`](../../src/resolution/types.ts#L225) | `detect(ctx)`, `claimsReference(name)`, `extract(file,content)`, `resolve(ref,ctx)`, `postExtract(ctx)` | Detection/language gates, full input reference/source and invocation identity; capture a negative detect/resolve result. `extract` lacks a context argument and may close over arbitrary reads. `postExtract` mutates existing node fields; it is not an edge-only unit |
| [node lookups](../../src/resolution/index.ts#L440) | Name, qualified/lower name, file, kind, ID, method-match queries, optional iterator | Predicate plus complete observable result (all fields, order and missing/null). New matching nodes, second matches, changed fields, removals and renames invalidate. Node IDs alone cannot establish uniqueness |
| [files](../../src/resolution/index.ts#L539) | `fileExists` checks indexed set then filesystem; `readFile`/`getFileLines` can read metadata; `getAllFiles` enumerates **indexed paths**, `listDirectories` uses filesystem | Do not conflate indexed set with disk tree. Track absent paths, bytes, failures, directory membership and scan/ignore policy. A non-indexed `composer.json` can control output |
| [derived queries](../../src/resolution/index.ts#L615) | Supertypes read nodes and `extends`/`implements`; imports, reexports, aliases, Go module, workspace members and include dirs read additional state | Track complete derived response and upstream input epoch/closure; revalidate before reuse. Paths/globs and misses must be observable. Unsupported derived query must taint the capture, not return an untracked “empty” |
| [plugin fingerprint](../../src/plugins/loader.ts#L56) / registry | Enabled state, order, options and replacement choices affect graph; package digest checked at load | Same version is not a cache identity. Capture replacement/builtin selection, scan/language gates and ordered schedule, even if callback makes no queries |
| [independent synth passes](../../src/resolution/callback-synthesizer.ts#L3583) | Same post-resolution snapshot; results withheld until merge; parallel execution allowed; first `source > target` pair in **registry order** wins | Preserve that exact collision rule, including shadowed outputs; do not use source/target/kind/line as a replacement key. Ordinary synthesis does **not** see an earlier sibling's newly returned edges |
| [staged pre-passes](../../src/resolution/callback-synthesizer.ts#L3765) | Go method containment and implements are persisted before independent synthesis; postExtract also precedes resolution | Later stages must read current upstream outputs. Per-stage immutable epochs, not one unordered pass bag. No proposed fixed point or cycle evaluation in first scope |
| [candidate update](../../src/index.ts#L549) | Source observations before/after full candidate; loaded config fingerprint frozen; graph/index transaction controls visibility | Reuse must stay inside the candidate boundary. Existing observations are not a filesystem snapshot or a guarantee for arbitrary external I/O |
### Official Drupal read closure
[`extensions/drupal/index.cjs`](../../extensions/drupal/index.cjs) is a real broad pass:
* `detect` (line 125) reads `composer.json` even if unindexed, then searches indexed
`.info.yml` membership when that does not establish Drupal. A missing/invalid composer
and an empty info-file set must both be recorded. Short circuiting is safe only if the
observed condition is revalidated; newly taken branches acquire new traces.
* `extract` (line 137) derives route/service/plugin nodes from exact filename and bytes.
The builtin public signature has no tracked cross-file facility. The reference
implementation is source-only here, but that does not prove arbitrary authors are.
* `wiring` (line 146) sorts all indexed files, reads PHP, `.services.yml` and
`.routing.yml`, queries nodes per file and builds class, service, hook, subscriber and
plugin maps. Empty class/member/service/alias lookups are negative dependencies of
those complete maps; recording only successful final edges loses them.
* Service aliases/injection and class ambiguity (lines 183–243), route `_form` and
`_controller` resolution (245–256), API hook existence (277), event constants,
subscribers and plugin uniqueness all depend on set membership. A second class or
plugin with the same name can **remove** a formerly valid edge. Retargeting YAML can
change a link between PHP endpoints whose files never changed.
* The installed package itself has no tracked partition interface. Wrapping its entire
pass records broad reads; an unrelated PHP comment still changes one observed input.
Snapshot files not queried, or an irrelevant non-PHP/YAML content change with unchanged
membership, may leave the pass reusable, but no useful latency claim follows here.
### Generic author and hook examples
The shipped [Python event starter](../../src/plugins/author-template.ts#L6) detects
`.events.yaml` membership, extracts literal registrations and uses `getNodesByName`
with Python/function filtering. It deliberately rejects zero or multiple handlers.
An absent target appearing and a second target appearing must both invalidate.
The exact [cross-file fixture](../../__tests__/extension-sync.test.ts#L51) has a
framework extractor that directly reads `binding.py` through `require('fs')`, while
its semantic callback reads that file through `ctx.readFile` or selects an option.
The former can alter a `handlers.py` node while that file's source stays identical.
Our driver obtains these callback bytes from the TypeScript AST, not a rewritten
approximation. A capture flag falsely applied to this real hook produces stale output.
Reevaluating the whole legacy schedule produces the current result. This demonstrates
the admission problem; it does not solve arbitrary JavaScript I/O containment.
## Proposed additive contract (not shipped types)
An illustrative host capability could be called `capturedSemanticV1`. The name is
provisional. Old engines must be prevented from silently accepting a package that
requires it: capability negotiation plus an engine range is required; a parallel
ordinary v1 callback can remain the explicit fallback. An optional field old engines
ignore is not sufficient negotiation for a correctness-critical contract.
```ts
// Design sketch, not an export in @colbymchenry/codegraph.
capturedSemanticV1({ unit: 'bindings', stage: 'post-resolution', run(view, options) {
const target = options.target || view.readFile('binding.py').text?.trim();
if (!target) return [];
const from = view.nodes({ name: 'dispatch', kind: 'function', language: 'python' });
const to = view.nodes({ name: target, kind: 'function', language: 'python' });
return from.length === 1 && to.length === 1 ? [call(from[0], to[0])] : [];
}});
```
For a later Drupal adapter, an individual route unit could read one routing document,
query a **host-captured** class/service index by FQN/service ID and method membership,
then return owned handler edges. Recording `classIndex.lookup(fqn) === []` is mandatory.
That index first needs its own complete discovery/read closure (including new PHP
files, service aliases and duplicates). A hand-built map closed over by the callback
is not a tracked index. Do not split route work while retaining an untracked global map.
### Capture and invalidation rules
1. **Admission:** a fresh, stateless callable with no ambient filesystem/network/env/
clock/random/module access. Frozen JSON options and read-only host views are its only
inputs. This is a host enforcement/admission obligation, not a manifest assertion.
First implementation should limit admission to an audited host adapter; arbitrary
v1 packages always use global fallback. An eventual isolated runtime needs its own
security/compatibility review, including transitive imports and constructors.
2. **Snapshot:** invocation identity includes project identity, engine/parser/query
contract version, package/content digest, unit/stage, exact options, replacements,
enabled state, ordered registry and relevant scan/language policy. Do not reuse
across projects or upgrades. Read views supply detached/frozen values and never
expose mutable SQLite handles, root paths or `require`.
3. **Reads:** record typed operation, normalized arguments and complete result fingerprint.
`readFile` distinguishes present bytes, absent path and access/parse error. An error
is not “missing”; abort or explicit fallback. Directory/glob reads capture predicate
and complete membership, including empty. Query response fingerprints include every
field visible through the new contract and observable order; arrays remain ordered.
Explicitly define stable projections if omitting volatile `updatedAt`; never silently
drop a field existing callbacks can read. Early-stopped iterators capture their full
predicate/bucket revision, not only visited nodes. Unbounded/paged queries may use a
broad graph generation token until a correct finer index exists.
4. **Revalidation:** replay each prior query against the current immutable stage snapshot
(initial simplest mechanism), compare exact result fingerprints, then either reuse
its entire owned output or reevaluate with an entirely new trace. Deletions/renames
are old-path removal plus new-path membership; do not preserve identity by guessing.
A dependency to an empty predicate is real. Later scalable bucket generations must
include membership, field and order changes with the same observable semantics.
5. **Branching:** validate previously observed predicates before reuse. If any changes,
rerun; the new branch captures new reads. No need to guess untaken branches for a
truly deterministic, closed callable. An option branch is covered by the invocation
identity, even when it avoids reading a file this time.
6. **Fallback:** legacy hook/pass, unknown context operation, tainted capture, unsupported
I/O, changed/unverifiable package, malformed cache, memory bound, unknown stage or
incomplete snapshot => discard tentative selective results and reevaluate the affected
pipeline globally. The first implementation should use the **whole candidate**, since
unknown hook outputs may influence every downstream stage. Unsupported-operation
taint is sticky even if plugin code catches the exception. Direct ambient reads cannot
be discovered by a context proxy; they must never be admitted by that mechanism alone.
### Output ownership, order, failures and restart
Store outputs **before merging** per `(project, package digest, unit, stage, invocation)`.
Replacement removes the entire former owned set, including an empty new set; never
delete another producer's edge just because both contributed the same pair. Retain
shadowed contenders so disabling/removing the winning owner reveals the next one.
Remerge the complete candidate in existing registry order, regardless of which units
ran or finished first. Validate endpoint identities and output shapes before commit.
No query may read its own previously committed derived output as new base evidence.
First scope is edge-only ordinary synthesis over a fixed post-resolution snapshot.
PostExtract node patches, per-reference resolution, framework detection/extraction and
Go stages remain global. A future stage-DAG extension would invalidate downstream
readers using the changed upstream snapshot; cyclic dependencies are unsupported and
fall back globally. Do not introduce fixed-point iteration silently or allow ordinary
independent passes to start seeing each other's outputs.
Prepare traces, ownership and graph in one candidate generation. A callback throw,
missing endpoint, source/config observation change, cancellation or worker death must
publish **none** of them and retain the prior graph. Failed empty output is not success.
Fallback must not partially commit earlier “good” units. Only after the existing atomic
graph transaction commits may the process-local trace generation be installed; tag it
with that graph generation. Readers continue to observe the old or new graph using the
existing commit marker. Unstable source while materializing a snapshot requires retry;
the accepted stat/hash observation is not a cross-filesystem lock. Unindexed metadata
and negative paths must join the observation closure before production reuse is safe.
Start with process-local bounded caches: close/restart/unknown generation => cold global
evaluation. Discard traces on plugin/config/engine/scan contract changes. Persistent
read sets are deferred; they would need transactionally bound schema, package provenance,
input generation, output ownership, and orphan/corruption recovery. No change to the
accepted candidate record/lock/index transaction is proposed in this milestone.
## Executable proof boundary
The model records complete results, replays predicates, keeps per-unit outputs and
matches the current pair-merge rule. It does **not** enforce runtime capabilities,
implement all ResolutionContext methods, stream large results, integrate cache
generations into SQLite, or track production watchers. It materializes/hashes full
snapshots, which is intentionally unsuitable as a large-project optimization.
The driver invokes the actual generic fixture and generated author callback. It also
loads the immutable Drupal 0.1.1 artifact, installs it into a disposable project made
from four pinned Pathauto files, and compares modeled pass output with both uncached
callback output and the **actual full managed-engine SQLite contribution** for each
state. The engine always evaluates fully; it does not consume the modeled cache. This
is stronger than a pure invented graph example, but is not production incremental,
whole-corpus, arbitrary-plugin, native or hosted proof. Model failures/“restart” are
in-memory exercises, not new process-kill evidence. Existing transaction-kill evidence
is reused with its original source and scope.
## Smallest follow-on and acceptance gate
Approve only an internal shadow recorder for one host-owned edge-only adapter, with
explicit captured-view semantics and no public package opt-in yet. Run callback plus
uncached oracle on small generic fixtures, recording negative/ambiguous membership,
trace size, replay cost and invalidation causes. Preserve all current fallbacks. A
useful shadow result is less callback work predicted **and** exact output parity; no
time target or speed claim is established by this review.
Before actual skipping: test adversarial admission/bypass/caught-error cases, generation
and endpoint validation, scan/option/plugin/order changes, disabling overlapping owners,
source races, true worker failure and kill boundaries using the real candidate path.
Before public API: old engine/new package refusal or deliberate v1 fallback, new engine/
old packages unchanged, author types and framework/parsing worker parity on supported
native platforms. A later fine-grained Drupal adapter requires its own reviewed version
and corpus accuracy probes. A later storage-delta design requires separate atomicity
proof; read tracking does not remove global resolution or graph-copy costs.
The accepted mixed performance remains unchanged: limited warm comment-edit/retarget
medians improved about 22% Pathauto and 26% Commerce; core cold update worsened from
103.494 to 112.452 s with higher RSS, while some warm updates improved. Historical
22–30% rebuild overhead and unfavorable diagnostics remain in the prior report. Stable
failed references did not reproduce a no-change rebuild loop. No new performance
matrix or tuning was run for this contract review.
+113
View File
@@ -0,0 +1,113 @@
'use strict';
// Validation-only read-set model. Not imported by the application or SDK.
const { createHash } = require('node:crypto');
const clone = value => structuredClone(value);
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical); // order is observable
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])]));
return value;
}
const hash = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
class UntrackedRead extends Error {}
function answer(snapshot, method, args) {
const [arg, language] = args;
switch (method) {
case 'readFile': return snapshot.files[arg] ?? null;
case 'fileExists': return Object.hasOwn(snapshot.files, arg);
case 'getFileLines': return snapshot.files[arg]?.split(/\r?\n/) ?? null;
case 'getAllFiles': return snapshot.indexedFiles;
case 'listDirectories': return snapshot.directories[arg] ?? [];
case 'getNodesInFile': return snapshot.nodes.filter(n => n.filePath === arg);
case 'getNodesByName': return snapshot.nodes.filter(n => n.name === arg);
case 'getNodesByQualifiedName': return snapshot.nodes.filter(n => n.qualifiedName === arg);
case 'getNodesByLowerName': return snapshot.nodes.filter(n => n.name.toLowerCase() === arg);
case 'getNodesByKind': return snapshot.nodes.filter(n => n.kind === arg);
case 'getNodeById': return snapshot.nodes.find(n => n.id === arg) ?? null;
case 'getSupertypes': {
const ids = new Set(snapshot.nodes.filter(n => n.name === arg && n.language === language).map(n => n.id));
return [...new Set(snapshot.edges.filter(e => ids.has(e.source) && ['extends', 'implements'].includes(e.kind))
.map(e => snapshot.nodes.find(n => n.id === e.target)?.name).filter(Boolean))];
}
default: throw new UntrackedRead(method);
}
}
function context(snapshot, reads, legacy = {}, taint = () => {}) {
return new Proxy({}, { get(_object, method) {
return (...args) => {
let value;
try { value = answer(snapshot, method, args); }
catch (error) {
if (!(error instanceof UntrackedRead)) throw error;
taint(error);
if (!legacy[method]) throw error;
return legacy[method](...args);
}
reads?.push({ method, args: clone(args), value: clone(value) });
return clone(value);
};
} });
}
// Matches the existing independent-synthesis pair winner, not a proposed new key.
function merge(outputs) {
const seen = new Set();
return outputs.flat().filter(edge => {
const key = `${edge.source}>${edge.target}`;
if (seen.has(key)) return false;
seen.add(key); return true;
});
}
async function full(snapshot, units) {
const outputs = [];
for (const unit of units) outputs.push(await unit.run(context(snapshot, null, unit.legacyContext), clone(snapshot.options)));
return { output: merge(outputs), owned: outputs };
}
class ReadSetModel {
constructor({ positiveOnly = false } = {}) {
this.positiveOnly = positiveOnly; this.entries = new Map(); this.output = []; this.last = null;
}
async update(snapshot, units, beforeCommit = () => {}) {
if (new Set(units.map(u => u.id)).size !== units.length) throw new Error('duplicate unit identity');
const generation = hash(snapshot), next = new Map(), outputs = [], stats = { ran: [], reused: [], reads: {}, fallback: null };
// Certification here is test-supplied metadata, NOT enforced isolation.
const identity = hash({ environment: snapshot.identity, options: snapshot.options,
schedule: units.map(u => [u.id, u.digest, u.mode]) });
if (units.some(u => u.mode !== 'captured')) stats.fallback = 'legacy/untracked unit: whole schedule';
try {
for (const unit of units) {
const old = this.entries.get(unit.id);
if (!stats.fallback && old?.identity === identity && old.reads.every(r => hash(answer(snapshot, r.method, r.args)) === hash(r.value))) {
next.set(unit.id, old); outputs.push(clone(old.output)); stats.reused.push(unit.id); continue;
}
const reads = [];
let unsupported;
const output = await unit.run(context(snapshot, reads, stats.fallback ? unit.legacyContext : {}, error => unsupported = error), clone(snapshot.options));
if (unsupported && !stats.fallback) throw unsupported; // sticky even if extension caught it
stats.ran.push(unit.id); stats.reads[unit.id] = clone(reads);
// Deliberately broken comparison arm: omits empty results and tracks
// only members returned last time, not membership of their predicate.
let retained = reads;
if (this.positiveOnly) retained = reads.flatMap(r => {
if (r.value == null || r.value === false || (Array.isArray(r.value) && !r.value.length)) return [];
if (r.method.startsWith('getNodes')) return r.value.map(n => ({ method: 'getNodeById', args: [n.id], value: n }));
if (r.method === 'getAllFiles') return []; // filenames previously read are all it remembers
return [r];
});
if (!stats.fallback) next.set(unit.id, { identity, reads: retained, output: clone(output) });
outputs.push(output);
}
} catch (error) {
if (!(error instanceof UntrackedRead)) throw error;
// Discard every tentative reused/derived result before conservative retry.
const result = await full(snapshot, units);
outputs.splice(0, outputs.length, ...result.owned); next.clear();
stats.fallback = `unsupported context operation: ${error.message}`;
stats.ran = units.map(u => u.id); stats.reused = [];
}
beforeCommit();
if (hash(snapshot) !== generation) throw new Error('snapshot changed before model commit');
this.entries = next; this.output = merge(outputs); this.last = stats;
return { output: clone(this.output), ...stats };
}
}
module.exports = { ReadSetModel, context, full, hash, merge };
+238
View File
@@ -0,0 +1,238 @@
'use strict';
// Narrow executable contract review. It never enables caching in CodeGraph.
const assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
const cp = require('node:child_process'), crypto = require('node:crypto');
const ts = require('typescript');
const { ReadSetModel, full, hash } = require('./model.cjs');
const root = path.resolve(__dirname, '../../..');
const out = path.resolve(process.argv[2] || '.qa/dependency-review');
fs.mkdirSync(out, { recursive: true });
Object.assign(process.env, { CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '0', CODEGRAPH_RESOLVE_WORKERS: '0' });
const checks = [], inputs = [], lab = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-readset-review-'));
const sha = b => crypto.createHash('sha256').update(b).digest('hex');
const log = (name, detail = {}) => {
checks.push({ name, ...detail }); console.log('PASS', name);
fs.writeFileSync(path.join(out, 'progress.json'), JSON.stringify({ checks, inputs }, null, 2));
};
const load = file => { const b = fs.readFileSync(path.join(root, file)); inputs.push({ path: file, sha256: sha(b) }); return b; };
for (const name of ['model.cjs', 'run.cjs']) fs.copyFileSync(path.join(__dirname, name), path.join(out, 'source-' + name));
const factories = [];
const testSource = ts.createSourceFile('extension-sync.test.ts', load('__tests__/extension-sync.test.ts').toString(), ts.ScriptTarget.Latest, true);
function visit(node) {
if (ts.isNoSubstitutionTemplateLiteral(node) && node.text.startsWith('module.exports =')) factories.push(node.text);
ts.forEachChild(node, visit);
}
visit(testSource); assert.equal(factories.length, 2);
function factory(text) { const m = { exports: {} }; new Function('module', 'require', text)(m, require); return m.exports; }
const bindingFactory = factory(factories[1]);
const pythonNodes = ['dispatch', 'receipt', 'cancel'].map((name, i) => ({ id: 'py:' + name, name,
qualifiedName: name, kind: 'function', language: 'python', filePath: 'handlers.py', startLine: i * 2 + 1, endLine: i * 2 + 2 }));
const fresh = () => ({ identity: { project: 'isolated-model', engine: 'accepted-7575', schema: 1, config: {} },
options: {}, files: { 'binding.py': 'receipt', 'handlers.py': 'def dispatch(): pass\ndef receipt(): pass\ndef cancel(): pass\n' },
indexedFiles: ['binding.py', 'handlers.py'], directories: {}, nodes: structuredClone(pythonNodes), edges: [] });
const binding = { id: 'generic:bindings', digest: sha(factories[1]), mode: 'captured',
run: (ctx, options) => bindingFactory({ projectRoot: lab, options }).synthPasses[0].run(ctx) };
async function parity(name, model, world, units = [binding], extra = {}) {
const actual = await model.update(world, units), reference = await full(world, units);
assert.deepEqual(actual.output, reference.output, name);
const scope = units.length && units.every(u => u.id === binding.id) ? 'model with actual generic callback' :
units.length && units.every(u => u.id === 'actual-starter-resolve') ? 'model with actual generated starter' : 'model rule fixture';
log(name, { scope, ran: actual.ran, reused: actual.reused, fallback: actual.fallback,
outputHash: hash(actual.output), edges: actual.output.length, ...extra });
return actual;
}
async function counterexample(name, before, mutate, unit = binding) {
const bad = new ReadSetModel({ positiveOnly: true }), good = new ReadSetModel();
await bad.update(before, [unit]); await good.update(before, [unit]); mutate(before);
const expected = (await full(before, [unit])).output;
const stale = await bad.update(before, [unit]); assert.notDeepEqual(stale.output, expected, name);
const correct = await good.update(before, [unit]); assert.deepEqual(correct.output, expected);
log(name, { scope: 'expected stale-cache counterexample', staleHash: hash(stale.output), expectedHash: hash(expected),
capturedQueries: correct.reads[unit.id]?.map(r => [r.method, r.args]), corrected: true });
}
function edge(target, label = 'model') { return [{ source: 'dispatch', target, kind: 'calls', metadata: { label } }]; }
async function models() {
let w = fresh(); w.files['binding.py'] = 'missing';
await counterexample('negative symbol read: previously missing handler appears', w,
s => s.nodes.push({ ...pythonNodes[1], id: 'py:missing', name: 'missing', qualifiedName: 'missing' }));
w = fresh(); delete w.files['binding.py'];
await counterexample('negative file read: missing binding file appears', w, s => s.files['binding.py'] = 'receipt');
await counterexample('query membership: second same-named target makes prior unique result ambiguous', fresh(),
s => s.nodes.push({ ...pythonNodes[1], id: 'other:receipt', filePath: 'other.py' }));
const enumeration = { id: 'enumeration', digest: 'model-list-v1', mode: 'captured', run: ctx => ctx.getAllFiles().includes('events.yaml') ? edge('receipt') : [] };
await counterexample('enumeration membership: new indexed registration file appears', fresh(), s => s.indexedFiles.push('events.yaml'), enumeration);
const model = new ReadSetModel(); w = fresh();
await parity('generic initial full evaluation', model, w);
assert.deepEqual((await parity('unchanged snapshot reuses callback', model, w)).reused, [binding.id]);
w.files['unindexed-not-read.txt'] = 'no dependency';
assert.deepEqual((await parity('unread content change reuses callback', model, w)).reused, [binding.id]);
w.files['binding.py'] = 'cancel'; await parity('metadata retarget replaces old owned edge between untouched endpoints', model, w);
w.nodes = w.nodes.filter(n => n.name !== 'cancel'); await parity('target deletion removes owned output', model, w);
w.nodes.push({ ...pythonNodes[2], id: 'renamed:cancel', filePath: 'renamed.py' });
await parity('target rename invalidates identity and selects new endpoint', model, w);
w.nodes.find(n => n.name === 'cancel').startLine = 40;
assert.ok((await parity('same-ID node field change invalidates complete query value', model, w)).ran.length);
w.options.target = 'receipt'; await parity('option change is an invocation dependency', model, w);
w.identity.config.excluded = ['unread/**']; assert.ok((await parity('config and scan policy epoch invalidate', model, w)).ran.length);
w.identity.project = 'different-project'; assert.ok((await parity('project identity prevents cross-project reuse', model, w)).ran.length);
await parity('package digest change invalidates', model, w, [{ ...binding, digest: 'new-reviewed-bytes-model-only' }]);
const oldOutput = hash(model.output), oldEntries = hash([...model.entries]);
w.options.target = 'FAIL'; await assert.rejects(() => model.update(w, [binding]), /real pass failed/);
assert.equal(hash(model.output), oldOutput); assert.equal(hash([...model.entries]), oldEntries);
log('throw preserves prior output and dependency generation', { scope: 'model commit only' });
w.options.target = 'cancel'; await parity('failed invocation retries without reusing partial trace', model, w);
const prior = hash(model.output);
await assert.rejects(() => model.update(w, [binding], () => w.files['binding.py'] = 'changed concurrently'), /snapshot changed/);
assert.equal(hash(model.output), prior); log('source observation race aborts model commit', { scope: 'model; no OS/SQLite transaction' });
await parity('restart discards cache and reevaluates', new ReadSetModel(), w);
const dirs = { id: 'directories', digest: 'model-v1', mode: 'captured', run: ctx => ctx.listDirectories('modules').map(dir => edge(dir)[0]) };
const dm = new ReadSetModel(); w = fresh(); await dm.update(w, [dirs]); w.directories.modules = ['new-module'];
await parity('empty directory observation invalidates when child appears', dm, w, [dirs]);
const order = { id: 'ordered-query', digest: 'model-v1', mode: 'captured', run: ctx => edge(ctx.getNodesByKind('function')[0].id) };
const om = new ReadSetModel(); w = fresh(); await om.update(w, [order]); w.nodes.reverse();
await parity('query result order is observable and preserved', om, w, [order]);
const superUnit = { id: 'supertype', digest: 'model-v1', mode: 'captured', run: ctx => ctx.getSupertypes('Child', 'php').map(n => edge(n)[0]) };
w = fresh(); w.nodes = [{ id: 'child', name: 'Child', language: 'php', kind: 'class' }, { id: 'parent', name: 'Parent', language: 'php', kind: 'class' }];
const sm = new ReadSetModel(); await sm.update(w, [superUnit]); w.edges = [{ source: 'child', target: 'parent', kind: 'extends' }];
await parity('upstream-stage edge membership invalidates supertype query', sm, w, [superUnit]);
const a = { id: 'a', digest: 'a', mode: 'captured', run: () => edge('receipt', 'first') };
const b = { id: 'b', digest: 'b', mode: 'captured', run: () => edge('receipt', 'second') };
const mm = new ReadSetModel(); w = fresh(); await mm.update(w, [a, b]);
assert.equal((await parity('registry reorder changes first-pair winner', mm, w, [b, a])).output[0].metadata.label, 'second');
assert.equal((await parity('removing first owner reveals shadowed contribution', mm, w, [a])).output[0].metadata.label, 'first');
assert.deepEqual((await parity('disabling all owners removes all owned outputs', mm, w, [])).output, []);
const unsupported = { id: 'unsupported', digest: 'model-v1', mode: 'captured', legacyContext: { getProjectAliases: () => ({ target: 'receipt' }) }, run: ctx => edge(ctx.getProjectAliases().target) };
assert.match((await parity('unsupported context operation falls back to full schedule', new ReadSetModel(), fresh(), [binding, unsupported])).fallback, /unsupported/);
const swallowed = { ...unsupported, run: ctx => { try { return edge(ctx.getProjectAliases().target); } catch { return []; } } };
assert.match((await parity('caught unsupported read still taints capture and forces fallback', new ReadSetModel(), fresh(), [swallowed])).fallback, /unsupported/);
const fm = new ReadSetModel(); await fm.update(fresh(), [a]); const generation = hash([...fm.entries]);
await assert.rejects(() => fm.update(fresh(), [b, { ...a, run: () => { throw Error('second unit failed'); } }]), /second unit failed/);
assert.equal(hash([...fm.entries]), generation); assert.equal(fm.output[0].metadata.label, 'first');
log('later-unit failure publishes neither earlier output nor its new cache', { scope: 'model transaction' });
const templateSource = require('../../../dist/plugins/author-template').extensionTemplate('review-events');
inputs.push({ generated: 'extensionTemplate(review-events)', sha256: sha(templateSource) });
const fw = factory(templateSource)().frameworks[0];
const event = fw.extract('checkout.events.yaml', 'order.created: receipt\n').references[0];
const starter = { id: 'actual-starter-resolve', digest: sha(templateSource), mode: 'captured', run: ctx => {
if (!fw.detect(ctx)) return [];
const result = fw.resolve(event, ctx);
return result ? [{ source: event.fromNodeId, target: result.targetNodeId, kind: result.edgeKind, metadata: result.metadata }] : [];
} };
w = fresh(); const am = new ReadSetModel(); await am.update(w, [starter]);
w.indexedFiles.push('checkout.events.yaml');
assert.equal((await parity('actual generated starter: detection membership enables resolution', am, w, [starter])).output.length, 1);
w.nodes.push({ ...pythonNodes[1], id: 'duplicate', filePath: 'other.py' });
assert.equal((await parity('actual generated starter: query ambiguity removes resolution', am, w, [starter])).output.length, 0);
// Invoke the EXACT fs-using framework hook from extension-sync.test.ts.
// A JS declaration cannot stop it reading outside the supplied context.
fs.writeFileSync(path.join(lab, 'binding.py'), 'receipt');
const hook = { id: 'actual-untracked-hook', digest: sha(factories[1]), mode: 'captured', run: (_ctx, options) => {
const nodes = bindingFactory({ projectRoot: lab, options }).frameworks[0].extract('handlers.py', '').nodes;
return nodes.map(n => ({ source: n.id, target: n.name, kind: 'references' }));
} };
const liar = new ReadSetModel(); w = fresh(); await liar.update(w, [hook]);
fs.writeFileSync(path.join(lab, 'binding.py'), 'cancel');
assert.notDeepEqual((await liar.update(w, [hook])).output, (await full(w, [hook])).output);
log('declaration-only admission is UNSOUND for real cross-file fs hook', { scope: 'actual existing hook, expected stale result' });
hook.mode = 'legacy'; const fallback = new ReadSetModel(); await fallback.update(w, [hook, binding]);
fs.writeFileSync(path.join(lab, 'binding.py'), 'receipt');
const fr = await parity('legacy hook forces whole-schedule evaluation', fallback, w, [hook, binding]); assert.equal(fr.reused.length, 0);
const hidden = { id: 'untracked-env-model', digest: 'model', mode: 'legacy', run: () => edge(process.env.CG_DEPENDENCY_REVIEW_VALUE) };
const envModel = new ReadSetModel(); process.env.CG_DEPENDENCY_REVIEW_VALUE = 'receipt'; await envModel.update(w, [hidden]);
process.env.CG_DEPENDENCY_REVIEW_VALUE = 'cancel'; await parity('external environment is global fallback, never inferred from returned edges', envModel, w, [hidden]);
delete process.env.CG_DEPENDENCY_REVIEW_VALUE;
}
// A small actual-engine oracle: each state is rebuilt by the unchanged managed
// engine, then the model runs the immutable Drupal callback over its real nodes.
async function drupal() {
const { CodeGraph, NODE_KINDS } = require('../../../dist');
const { ExtensionManager } = require('../../../dist/plugins/manager');
const { DatabaseSync } = require('node:sqlite');
const corpus = path.resolve(process.env.PATHAUTO_CORPUS || '../validation/pathauto');
const pin = 'b97aadf47a37cff25f7d6105c3dade6778fccb47';
const project = path.join(lab, 'drupal'); fs.mkdirSync(project);
const files = {};
for (const file of ['composer.json', 'pathauto.routing.yml', 'src/Form/PathautoSettingsForm.php', 'src/Form/PathautoBulkUpdateForm.php']) {
const bytes = cp.execFileSync('git', ['-C', corpus, 'show', `${pin}:${file}`]);
inputs.push({ corpus: 'pathauto', revision: pin, path: file, sha256: sha(bytes) }); files[file] = bytes.toString();
}
const artifact = load('dist/extensions/drupal-0.1.1.cgext');
assert.equal(sha(artifact), 'e5ed73bee51524585e4f4da1d5017fdee3037a3fa71d8f08edab6c318fedc59a');
const { parsePackage } = require('../../../dist/plugins/package');
const pkg = parsePackage(artifact, require('../../../package.json').version), entry = Buffer.from(pkg.files[pkg.package.main], 'utf8');
assert.ok(entry.length > 1000);
const entryFile = path.join(lab, 'drupal-entry.cjs'); fs.writeFileSync(entryFile, entry);
const plugin = require(entryFile)();
const unit = { id: 'drupal:wiring', digest: sha(artifact), mode: 'captured', run: ctx => plugin.synthPasses[0].run(ctx, () => {}) };
// Model admission is an audit fixture, not a claim this v1 plugin is isolated.
const model = new ReadSetModel(); let graph, installed = false;
const nodesOf = g => NODE_KINDS.flatMap(kind => g.getNodesByKind(kind));
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 })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
async function evaluate(name, changes, expectedTarget, expectedCount = 2, detected = true) {
Object.assign(files, changes);
for (const [file, text] of Object.entries(files)) {
const target = path.join(project, file);
if (text === null) { if (fs.existsSync(target)) fs.unlinkSync(target); }
else { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, text); }
}
if (!installed) { await new ExtensionManager(project).install({ bytes: artifact, replaces: ['drupal'] }); installed = true; graph = await CodeGraph.open(project); }
else await graph.refreshPluginIndex(); // full current-engine oracle, not model cache
const nodes = nodesOf(graph), ids = nodes.map(n => n.id);
const sqliteEdges = graph.getOutgoingEdgesFrom(ids).filter(e => e.metadata?.synthesizedBy === 'drupal');
const db = new DatabaseSync(path.join(project, '.codegraph/codegraph.db'), { readOnly: true });
const indexedFiles = db.prepare('SELECT path FROM files ORDER BY path').all().map(r => r.path); db.close();
assert.ok(!indexedFiles.includes('composer.json'), 'detect input must actually be non-indexed for this oracle');
const world = { identity: { project: 'pinned-pathauto-subset', engine: '7575', config: 'drupal replaces builtin' }, options: {},
files: Object.fromEntries(Object.entries(files).filter(([, t]) => t !== null)), indexedFiles, directories: {}, nodes, edges: [] };
const result = await model.update(world, [unit]), reference = await full(world, [unit]);
assert.deepEqual(normalize(result.output), normalize(reference.output));
assert.deepEqual(normalize(result.output), normalize(sqliteEdges));
const source = nodes.find(n => n.name === '/admin/config/search/path/settings');
if (detected) assert.ok(source);
else { assert.equal(source, undefined); assert.equal(nodes.filter(n => n.id.startsWith('plugin:drupal:')).length, 0); }
const settings = sqliteEdges.filter(e => e.source === source?.id && e.metadata.label === 'Drupal route handler');
assert.equal(settings.length, expectedTarget ? 1 : 0);
if (expectedTarget) assert.equal(nodes.find(n => n.id === settings[0].target).filePath, expectedTarget);
assert.equal(sqliteEdges.length, expectedCount);
assert.ok(!sqliteEdges.some(e => nodes.find(n => n.id === e.source)?.name === '/admin/config/search/path/patterns/add'));
const evidence = { name, scope: 'model vs unmodified Drupal callback AND actual managed SQLite full rebuild on pinned subset',
nodes: nodes.length, edges: sqliteEdges.length, graphHash: hash(normalize(sqliteEdges)), ran: result.ran, reused: result.reused,
reads: result.reads[unit.id], expectedSettingsTarget: expectedTarget, snapshot: world, output: normalize(sqliteEdges) };
fs.writeFileSync(path.join(out, `drupal-${checks.filter(c => c.scope?.startsWith('model vs')).length + 1}.json`), JSON.stringify(evidence, null, 2));
log(name, { scope: evidence.scope, nodes: nodes.length, edges: sqliteEdges.length, graphHash: evidence.graphHash,
reads: evidence.reads?.length, ran: result.ran, reused: result.reused });
}
const settingsFile = 'src/Form/PathautoSettingsForm.php', bulk = 'src/Form/PathautoBulkUpdateForm.php', originalSettings = files[settingsFile], originalBulk = files[bulk];
try {
await evaluate('Drupal actual full oracle: missing form target', { [settingsFile]: null }, null, 1);
await evaluate('Drupal actual full oracle: missing form appears', { [settingsFile]: originalSettings }, settingsFile);
await evaluate('Drupal actual full oracle: duplicate class removes ambiguous route', { 'src/Form/Duplicate.php': originalSettings }, null, 1);
await evaluate('Drupal actual full oracle: duplicate deletion restores route', { 'src/Form/Duplicate.php': null }, settingsFile);
await evaluate('Drupal actual full oracle: YAML retargets untouched PHP endpoints', { 'pathauto.routing.yml': files['pathauto.routing.yml'].replace('\\Drupal\\pathauto\\Form\\PathautoSettingsForm', '\\Drupal\\pathauto\\Form\\PathautoBulkUpdateForm') }, bulk);
await evaluate('Drupal actual full oracle: target rename changes endpoint identity', { [bulk]: null, 'src/Form/RenamedBulk.php': files[bulk] }, 'src/Form/RenamedBulk.php');
await evaluate('Drupal actual full oracle: target deletion removes both routes', { 'src/Form/RenamedBulk.php': null }, null, 0);
await evaluate('Drupal actual full oracle: target restoration restores both routes', { [bulk]: originalBulk }, bulk);
await evaluate('Drupal actual full oracle: non-indexed composer detection turns off wiring and nodes', { 'composer.json': '{"name":"example/control"}' }, null, 0, false);
} finally { graph?.close(); }
}
(async () => {
try {
await models(); await drupal();
const result = { success: true, revision: cp.execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
node: process.version, platform: process.platform, inputs, checks, productionCachingEnabled: false,
limits: ['In-memory model; no cache integrated into engine', 'Admission flag is not a sandbox', 'Drupal oracle uses four pinned files, not full corpus', 'No new latency, restart/process-kill or hosted proof'] };
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ success: true, checks: checks.length }));
} catch (error) {
fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ checks, inputs, error: String(error.stack) }, null, 2)); throw error;
} finally { fs.rmSync(lab, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });