From 57f332dbb70dbbaf2a45fc2f6bf8fdd76c11f46a Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 16:41:59 +0000 Subject: [PATCH] docs: review opt-in semantic read sets with executable counterexamples --- docs/design/semantic-dependency-contract.md | 231 +++++++++++++++++++ docs/validation/dependency-review/model.cjs | 113 ++++++++++ docs/validation/dependency-review/run.cjs | 238 ++++++++++++++++++++ 3 files changed, 582 insertions(+) create mode 100644 docs/design/semantic-dependency-contract.md create mode 100644 docs/validation/dependency-review/model.cjs create mode 100644 docs/validation/dependency-review/run.cjs diff --git a/docs/design/semantic-dependency-contract.md b/docs/design/semantic-dependency-contract.md new file mode 100644 index 000000000..63f9a892a --- /dev/null +++ b/docs/design/semantic-dependency-contract.md @@ -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. diff --git a/docs/validation/dependency-review/model.cjs b/docs/validation/dependency-review/model.cjs new file mode 100644 index 000000000..51d1e8c82 --- /dev/null +++ b/docs/validation/dependency-review/model.cjs @@ -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 }; diff --git a/docs/validation/dependency-review/run.cjs b/docs/validation/dependency-review/run.cjs new file mode 100644 index 000000000..d7f7e9d7d --- /dev/null +++ b/docs/validation/dependency-review/run.cjs @@ -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; });