mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
docs: review opt-in semantic read sets with executable counterexamples
This commit is contained in:
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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; });
|
||||
Reference in New Issue
Block a user