From 05b43ae7ef19772764bf7ea2c8765508a39849b6 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Tue, 22 Sep 2026 14:14:17 +0100 Subject: [PATCH] fix: ship scope-aware TS/JS call evidence in context 0.3.3 --- GOALS.md | 13 +- PRODUCT_DIRECTION.md | 16 +- RELEASE_EVIDENCE.md | 23 ++ docs/CLIENTS-AND-LANGUAGES.md | 16 +- docs/DETERMINISTIC-CODE-INTELLIGENCE.md | 224 ++++++++++++++++++ docs/DOGFOOD-EXECUTION.md | 53 +++++ docs/GETTING-STARTED.md | 4 +- docs/LANGUAGE-SUPPORT.md | 8 +- docs/OPEN-SOURCE-EXECUTION.md | 21 +- package-lock.json | 6 +- packages/context-tools/README.md | 27 ++- packages/context-tools/THIRD_PARTY_NOTICES.md | 6 +- packages/context-tools/package.json | 4 +- .../context-tools/src/source-scan.test.ts | 222 ++++++++++++++++- packages/context-tools/src/source-scan.ts | 206 +++++++++++++--- packages/context/THIRD_PARTY_NOTICES.md | 2 +- packages/context/package.json | 2 +- 17 files changed, 787 insertions(+), 66 deletions(-) create mode 100644 docs/DETERMINISTIC-CODE-INTELLIGENCE.md diff --git a/GOALS.md b/GOALS.md index ce9261f..c7fdadd 100644 --- a/GOALS.md +++ b/GOALS.md @@ -81,7 +81,7 @@ Exit: documented behaviour matches executable fixtures, including the cases wher - [ ] Walk through the documented install, scan, persist, retrieve, export and import path on a clean machine or isolated environment. - [ ] Exercise the supported local workflow on an explicitly selected non-ForgeSworn repository without workspace-specific paths, private worker helpers, Oathrun or a ForgeSworn account. State language/relationship coverage and gaps. - [ ] Exercise an actual MCP client session, including initialisation, errors, cancellation and bounded output. Name the tested client and version. -- [ ] Qualify actual Claude Code and Codex task/tool use separately, recording model/effort, source freshness, restart and disable. Connection health alone is insufficient; Claude Desktop is a separate client surface. Keep provider authentication outside Context. +- [ ] Qualify actual Claude Code, Codex and OpenCode task/tool use separately, recording model/effort, source freshness, restart and disable. Connection health alone is insufficient; Claude Desktop is a separate client surface. Keep provider authentication outside Context. - [ ] Verify compatibility fixtures for existing package names, APIs and signed wire formats. Document any required migration before release. - [ ] Test intended consumer integration in isolated branches or fixtures. Do not silently repin or migrate live consumers. - [ ] Reconcile README commands, package versions, changelog and extraction provenance. Distinguish a prepared tarball from an available registry release. @@ -89,13 +89,22 @@ Exit: documented behaviour matches executable fixtures, including the cases wher Exit: a newcomer can follow the published instructions successfully without local workspace links or unpublished dependencies. -## G4: Useful savings, with quality held constant +## G4: Useful savings and better accepted results + +The owner's stronger product requirement is **better accepted developer results**. +The [deterministic intelligence contract](docs/DETERMINISTIC-CODE-INTELLIGENCE.md) +adds a prospectively locked quality-improvement measure and competent +ordinary/Graphify-assisted baselines, including an OpenCode client cohort. +Smaller packets or unchanged quality alone do not establish that claim. Preserve +existing D5 protocols/results; qualify retrieval first, then mixed-model routing +separately. Internal adoption need not wait for the complete external comparison. - [ ] Define representative tasks and acceptance tests before comparing baseline and Z1P-assisted runs. Include repository orientation, bug investigation, change impact and an accepted code change. - [ ] Hold repository revisions, task instructions, model settings and acceptance standards constant. Separate context improvements from any later model-routing experiment. - [ ] Count the whole task: retrieved source, input/output tokens, cached tokens where reported, retries, tool calls, failures and review time. Include scan and refresh overhead. - [ ] Record missing provider usage as unknown, never zero. Keep observed billing, estimated token cost and fixed subscription costs distinct. - [ ] Report accepted tasks, regressions and unsuccessful runs alongside cost. Navigation compression alone is not an inference-bill saving. +- [ ] Demonstrate a predeclared improvement in first-pass acceptance, missed/incorrect dependencies or substantive review repairs on representative tasks against the stated existing workflow; report losses and inconclusive results too. - [ ] Publish a reproducible, non-sensitive evaluation and its limitations. Keep private code, prompts, invoices and customer data out of this repository. Exit: the tested workflow reduces cost per accepted outcome without lowering the agreed quality bar. If it does not, record the result and fix the cause before making a savings claim. diff --git a/PRODUCT_DIRECTION.md b/PRODUCT_DIRECTION.md index 22891a0..b63903c 100644 --- a/PRODUCT_DIRECTION.md +++ b/PRODUCT_DIRECTION.md @@ -3,9 +3,12 @@ Updated: 22 September 2026. Product direction and acceptance plan, not a claim that the capabilities below are all shipped. -Context should help any developer understand and change a codebase using less -model context, while preserving the quality of their development work. The -target is an independently implemented, MIT-licensed alternative to Graphify +Context must help developers produce better accepted changes with less complete +task cost. Smaller model context alone is insufficient: fewer wrong conclusions, +missed dependencies and repair rounds must be demonstrated against competent +existing workflows. Deterministic analysis and deeper language resolution are +near-term product priorities. See the [implementation and benefit contract](docs/DETERMINISTIC-CODE-INTELLIGENCE.md). +The target is an independently implemented, MIT-licensed alternative to Graphify for repository graphs, exploration and coding-agent context. Do not copy or integrate Graphify code. Replacement is a product goal; feature parity and whole-task savings have not been demonstrated. @@ -24,8 +27,11 @@ and actual cash savings are separate outcomes. ## Product boundaries -Claude Code and Codex are first-class target MCP clients. Context already has -stdio MCP interfaces; both need complete independent developer acceptance. +Claude Code, Codex and OpenCode are first-class target MCP clients. Context already +has stdio MCP interfaces; each needs separate complete developer acceptance. +OpenCode qualification is planned, not delivered. Support developers who already +combine Graphify and frontier/cheap models; measure the benefit over that workflow +without requiring them to replace their client or routing policy. Provider login, credentials, model selection and execution belong to the client or optional Oathrun runtime. Context retains its own source/evidence access boundaries. See [client and language goals](docs/CLIENTS-AND-LANGUAGES.md). diff --git a/RELEASE_EVIDENCE.md b/RELEASE_EVIDENCE.md index 7a24506..c2d0c4c 100644 --- a/RELEASE_EVIDENCE.md +++ b/RELEASE_EVIDENCE.md @@ -4,6 +4,29 @@ Latest implementation and adoption results are in the [dogfood execution ledger](docs/DOGFOOD-EXECUTION.md). The dated snapshots below remain historical evidence; internal pilot acceptance is separate from G0–G4. +## 0.3.3 shipment checks, 22 September 2026 + +The TS/JS scanner now binds calls to scoped compiler symbols over selected source +files. It avoids false shadowed targets and nested-callback attribution, follows +supported local aliases, rejects type-only/ambiguous exports and separates +same-named static/instance methods. Analysis uses no LLM and the compiler host has +no filesystem, configuration, default-library or network fallback. Full project +resolution and default-export expression assignments remain unsupported. + +Implementation validation passed 420 tests, independent packed-package smoke and +both unchanged benchmark gates with required-source recall 1.0. Independent +review findings were repaired and rechecked. See the +[execution ledger](docs/DOGFOOD-EXECUTION.md#22-september-2026--scope-aware-tsjs-call-evidence-local-unreleased) +for attempts, repairs and the distinction between worker tokens and total cost. +Versioned shipment CI, archive checksums and public-download checks accompany the +GitHub release; this source entry does not pre-claim their outcome. + +Install both matching 0.3.3 tarballs with Node 24. Existing MCP servers require a +reconnect after upgrading. npm authentication again returned HTTP 401 on +22 September; registry publication remains blocked. Actual client acceptance, +consumer upgrades and measured developer/subscription/cash benefits are separate +checks; this scanner correction does not close those gates. + ## 0.3.2 shipment checks, 22 September 2026 Navigation and exact source packets now accept `.kts`, `.cc`, `.cxx`, `.hh`, diff --git a/docs/CLIENTS-AND-LANGUAGES.md b/docs/CLIENTS-AND-LANGUAGES.md index 339f570..fa6dc26 100644 --- a/docs/CLIENTS-AND-LANGUAGES.md +++ b/docs/CLIENTS-AND-LANGUAGES.md @@ -11,7 +11,7 @@ selection, extraction, graphs, freshness, bounded retrieval, provenance and evidence access checks. Its supported local workflow needs no AI provider login, API key, subscription or model invocation. -Claude Code, Codex or another client consumes that evidence over MCP or CLI. +Claude Code, Codex, OpenCode or another client consumes that evidence over MCP or CLI. The client chooses its model/effort and handles provider authentication, billing, conversation, tool execution and approval UX. Oathrun owns those execution and routing responsibilities when it is the chosen agent host. Using Context from @@ -55,12 +55,18 @@ restart/disable and consequential task qualification remain open. See ## OS1 client and retrieval assignments +OpenCode is now a first-class qualification target. Its setup, lifecycle, +receipt and mixed-model acceptance are **not yet tested**. Follow the +[deterministic language and developer-results contract](DETERMINISTIC-CODE-INTELLIGENCE.md) +for OS1e, compiler/resolver boundaries and fair Graphify/OpenCode comparisons. + | Task | Owner / where | Model and effort | Acceptance | | --- | --- | --- | --- | | **OS1a — Claude Code alongside Codex. Partial: read-only audit accepted** | Context integration owner, Node tools, public setup docs and isolated client fixtures | Flash/thinking off for bounded docs/fixtures; Terra/medium integration; **Claude Sonnet 5/medium** as the first real Claude executor; **Claude Opus 5/high** for one consequential source-backed task after basic acceptance | A pinned package launches in both clients against an explicit root; real tool discovery, status/refresh/search, bounded evidence, edit/stale/refresh, restart and disable work. Record actual client/model/effort and accepted task, not just Connected | | **OS1b — Sufficient evidence through MCP. Shipped; first Claude use recorded** | Context implementation owner, shared packet module plus `packages/context-tools` adapters/tests | Terra/medium implementation; Sol/high boundary review; Flash/off for scoped fixtures | `repository_packet` returns bounded exact ranges or complete selected TS/JS blocks with provenance and stale rejection. Installed-tarball stdio retrieval, edit/refresh and restart pass. Reconnect existing clients to discover the new tool; first Claude read-only use is recorded, while lifecycle qualification remains open. No shell helper required by the consuming model; no arbitrary path escape | | **OS1c — Graph questions through MCP** | Context graph owner, same tools adapter; follows OS3/OS4 evidence integration | Terra/medium; Sol/high review of scope/provenance | Bounded repository symbol/neighbour/path queries return precise source and derivation metadata in both clients; do not mistake the existing signed-cache graph tools for this capability | | **OS1d — Other client surfaces** | Context integration owner, portable examples and clean-client acceptance | Flash/off for docs, Terra/medium review; user's chosen model for the actual client | Claude Desktop is separately qualified after Claude Code; other MCP clients follow demand. Never infer their acceptance from Claude Code or Codex | +| **OS1e — OpenCode. Planned; first-class target** | Context integration owner; public setup, shared MCP and provider-neutral receipt fixtures | Flash/off bounded fixtures/docs; Terra/medium integration; existing user-selected model for client qualification | Actual pinned-client task, exact root and four tools, edit/stale/refresh, restart/cancellation/disable; preserve existing agents/Graphify and count model handoffs without changing routing policy | The Anthropic API model IDs for those qualification assignments are `claude-sonnet-5` and `claude-opus-5`. Claude Fable 5.1 @@ -95,19 +101,19 @@ do not silently scan the parent workspace. | Language / surface | Current support | Next useful depth | | --- | --- | --- | -| TypeScript / JavaScript / TSX / JSX | Compiler API syntax trees; selected relative imports and syntactic call hints. No tsconfig loading or type checker; not complete semantic resolution | Aliases, re-exports, symbol identity and references with honest unresolved cases; keep dynamic calls and type-directed evidence distinct | +| TypeScript / JavaScript / TSX / JSX | 0.3.3: isolated Program/TypeChecker binding over selected trees; scope-aware direct calls and local named default/re-export aliases. No tsconfig, external libraries or project type checking. 0.3.2 has earlier syntax/name hints | Explicit project configuration, references and task-level evidence queries; keep dynamic calls and type-directed evidence distinct | | Rust / Cargo | Lexical declarations and simple module-file guesses; manifest path dependencies in separate snapshots; Cargo lock resolution unsupported | Parser-backed modules, use/re-exports, types/traits/impls and call/reference candidates; selected workspace/features/cfg context; preserve unresolved macro/trait dispatch | | Kotlin / Java | Lexical declaration inference; no meaningful compiler-resolved import/type/call graph | Parsed declarations/imports; package/module and Gradle dependency evidence, then explicitly qualified references. Do not execute Gradle scripts during indexing | | Python | Lexical declarations and conservative relative-import guesses | Parsed scopes/imports and useful references; explicit environment/package assumptions; no import execution | -| C / C++ | Lexical declarations and quoted-include guesses; broad scanner accepts more suffixes than navigation | Align `.cc/.cxx/.hpp` selection; parse declarations/includes; distinguish build-config/preprocessor-dependent and FFI edges | +| C / C++ | Lexical declarations and quoted-include guesses; selected suffix gaps aligned in 0.3.2 | Parse declarations/includes; distinguish build-config/preprocessor-dependent and FFI edges | | Dart / Flutter | Absent from both the navigation allowlist and broad extractor | Add declared support and fixtures, then parsed imports/exports/parts and declarations with pub package/version evidence; no claim that adding `.dart` is semantic support | | Swift | Lexical declarations; no module/type/call resolution | Parsed declarations/imports with selected package/build metadata and explicit unresolved cases | | Vue / Svelte, SQL and build/config contracts | Dedicated parsing absent from these source extractors | Confirm use in selected projects; add component/script boundaries or schema/config relationships only against actual tasks | | Go, C#, Ruby, PHP | Conservative lexical extraction; some local-import guesses | Retain explicit support level; deepen when an accepted task needs it | Navigation and extraction must have a tested capability manifest so their suffix -support cannot silently diverge. Current navigation, for example, omits `.kts` -and several C++ suffixes which the broad scanner accepts. Source packets currently +support cannot silently diverge. The `.kts` and selected C++ gaps were fixed in +0.3.2; that adds source eligibility, not deeper resolution. Source packets currently have syntax-aware planning for TS/JS only; add complete-span planning for each new parser rather than silently truncating another language's function. diff --git a/docs/DETERMINISTIC-CODE-INTELLIGENCE.md b/docs/DETERMINISTIC-CODE-INTELLIGENCE.md new file mode 100644 index 0000000..729ed4a --- /dev/null +++ b/docs/DETERMINISTIC-CODE-INTELLIGENCE.md @@ -0,0 +1,224 @@ +# Deterministic code intelligence and better developer results + +Updated: 22 September 2026. Product direction and implementation contracts; +not a claim that the proposed capabilities or benefit gates have passed. +This implements the owner's priority: do more without an LLM, deepen language +understanding, and make developers demonstrably more successful with Context. +The active owner/model queue remains [OS0–OS6](OPEN-SOURCE-EXECUTION.md). + +## Product test + +Developers may already use OpenCode, Graphify, compiler diagnostics and a mix of +frontier and inexpensive cloud models. Treat that as a target workflow to qualify, +not an established market-share claim. A smaller prompt or another graph is not +sufficient reason to switch. Context must reduce wrong conclusions, failed changes +and repeated investigation while preserving the developer's existing acceptance +bar. Cost reduction and better outcomes both need whole-task evidence. + +OpenCode is a first-class qualification target alongside Claude Code and Codex. +Its current docs describe MCP, per-agent model selection and optional LSP +diagnostics; the LSP docs also describe resource/freshness tradeoffs. Graphify +already describes deterministic local code parsing. Therefore neither MCP nor +LLM-free parsing alone is a competitive advantage. This is a documentation +comparison, not executed acceptance of either product. + +## What the machine should do without inference + +| Work | Deterministic implementation direction | Evidence and boundary | +| --- | --- | --- | +| Discover code | Existing root/policy selection; parser-backed declarations and exact spans | Exclusions, parse failures and unsupported syntax remain visible | +| Resolve symbols | Compiler or language resolver for selected project configuration | Distinguish syntax, resolved static binding, possible dispatch and unknown target | +| Explain dependencies | Traverse typed relations; render evidence-backed templates | Explanation cites the supporting source and resolution assumptions; no generated narrative needed | +| Find relevant tests | Direct symbol/import links and explicit test configuration; optional imported coverage | Naming proximity is a candidate, not proof of coverage; tests remain client-executed | +| Assemble task evidence | Start with a symbol, diagnostic or diff; select definitions, references, contracts and test candidates under a budget | Return selection reasons, omitted candidates and unresolved dependencies; no claim of complete semantic closure | +| Reuse work | Content-addressed analysis; bounded changed-file invalidation and reverse-dependency updates | Include config, dependency, parser, policy and worktree identity; watcher events alone do not establish freshness | +| Transfer between models | Reuse source packets and structured handover state | Freshness verified again by each consumer; assertions and pending side effects are labelled | +| Account for benefit | Parse receipts, deduplicate requests and aggregate all attempts/review | Include refresh work, tool schemas, cached/uncached tokens, failures and human time | + +Natural-language intent and code changes can still use the client's chosen model. +Context's deterministic route must work with no model account, embeddings, model +classification or generated summaries. Optional enrichment remains an external, +labelled input. Zero inference cost does not mean zero CPU, memory, disk or +maintenance cost; measure both initial analysis and repeated use. + +## Confirmed correctness issue: shadowed TypeScript call + +On `5df1b8b`, a disposable fixture passed to the built `scanSourceGraph` returned +`invoke --calls--> target` for this source: + +```ts +export function target() { return 1 } +export function invoke(target: () => number) { return target() } +``` + +The called identifier is the parameter; the file-level function is not an +established target. The emitted record carries `typescript-ast` / `extracted` +provenance with confidence 90. That record-level label does not communicate the +incorrect call resolution. The probe used a fresh temporary root, fixed +`observedAt`, no model or network and removed only its own fixture afterwards. + +The cause was the file-level name lookup in +[`source-scan.ts`](../packages/context-tools/src/source-scan.ts), which did not +resolve the lexical binding at the call site. The original source-scan tests covered +ordinary calls and some ambiguous object calls, but not this shadowing case. + +**First implementation gate:** add the failing regression, then resolve the actual +binding or omit the unproven edge. Cover parameter/local/block/import shadowing, +destructuring, nested callbacks, duplicate method names and `this` rebinding. +Retain valid direct calls, aliases and cyclic imports. Correctness must not be +traded for graph density or benchmark compression. This was a confirmed +correctness defect in 0.3.2; it does not invalidate exact source packet copying. + +Version 0.3.3 replaces name lookup with isolated TypeScript compiler +binding over selected trees. Regression fixtures cover the reproduced error, +TS/JS shadowing, call ownership and declaration identity, with additional compiler +access-boundary tests. Named default and local re-export aliases also resolve. +See the [execution ledger](DOGFOOD-EXECUTION.md) for final checks and review. +This is a bounded scanner correction, not a completed OS4b task query, project-aware +resolver, client qualification or measured developer savings result. + +## Architecture and language depth + +Keep the browser-safe signed core separate from Node analysis. Build a derived +repository graph with bounded queries, then optionally project selected evidence +into the existing signed wire format. Do not route every repository symbol +through a 128-record signed collection or silently raise that protocol limit. +Extend the existing Node tools first; a new package needs a demonstrated packaging +reason. Preserve public names, unsigned/signed boundaries and explicit roots. + +Define one internal extractor contract before adding grammars: language and parser +version, source hash, exact span, scoped symbol identity, relationship kind, +derivation/resolution method, selected build configuration, target candidates +and unresolved reason. A compiler-resolved symbol still does not prove runtime +dispatch. Define edge-level evidence internally without changing signed v1 fields +until a compatible projection has been reviewed. Numeric confidence is not a +measured probability. + +| Order | Language work | First useful acceptance | +| --- | --- | --- | +| 1 | TS/JS: scope correctness, then bounded TypeScript Program/TypeChecker integration; explicit tsconfig, aliases, re-exports and project references | An API-change task follows the actual symbol through a barrel/alias, finds its callers and test candidates, and avoids shadowed names | +| 2 | Rust: parser-backed spans/modules/use/re-exports, impls and traits; explicit Cargo workspace/features/cfg identity | A module/trait change returns correct declarations and candidate references; unresolved macros and dispatch remain explicit | +| 3 | Kotlin: parsed packages/imports, complete declarations, extension/overload distinctions and selected module metadata | A contract-change task retrieves complete relevant Kotlin evidence and distinguishes same-named functions | +| 4 | Dart/Python/Swift/C++ according to selected developer tasks | Add eligibility, syntax spans, resolution and task evidence as separate support levels; no language-complete claim from an extension list | + +Use the pinned TypeScript API already present before adding a TS parser. Its +official documentation distinguishes syntax trees, Programs and TypeCheckers and +warns that API generations differ. Investigate Tree-sitter for syntax coverage, +not as a substitute for symbol/type resolution. Evaluate rust-analyzer and other +language services as explicit optional adapters where they improve accepted tasks. +Do not run two expensive language services merely because the client already has +one; first establish whether a supported sharing/export interface exists. + +All compiler-host file reads, including tsconfig extends, project references, +package metadata and declarations, must pass explicit scope and resource policy. +Dependencies outside the selected root require a declared additional scope; an +import is not access authority. No hidden package install, network fetch, build, +macro execution or Gradle evaluation. In particular, rust-analyzer documents +build-script/procedural-macro execution options: a future adapter must verify its +effective no-execution settings and qualify them before use, or require a separate +explicit execution operation. Ordinary approved client tests stay client-owned. + +## Queries and handoffs that replace repeated model work + +Proposed query operations are symbol, references, neighbours, path, impact +candidates and evidence packet. They are requirements, not newly available MCP +tool names. Prefer a compact shared query contract over many overlapping tools. +Start from exact identifiers, source locations, compiler diagnostics or changed +ranges. Natural-language decomposition can remain with the client. + +Return exact source, relationship reasons, revision/generation and completeness +metadata in one bounded answer where possible. Include complete selected syntax +units and pertinent tests; support expansion without repeatedly returning the +entire packet. Measure wire/schema overhead as well as source tokens. Evidence +handles cannot assume a new model has read earlier content: materialise the +required source for each receiving model and verify the generation. + +Reuse [`task-handover.mjs`](../scripts/task-handover.mjs) and packet contracts: +task, allowed files, acceptance checks, decisions, diff/source identity, unresolved +questions and pending effects. Package useful handover functionality after its +contract is qualified; today's helper is checkout tooling. A switch to a cheap +worker must not silently discard constraints, hide failed attempts or cause +another full repository exploration. The client owns model choice, escalation, +execution and review; Context owns the evidence. + +## OpenCode and existing-tool adoption + +Qualify a pinned OpenCode version with the same four repository tools and exact +worktree binding. Verify source use, edit/stale/refresh, wrong-root refusal, +restart, cancellation and disable in the actual client. Record its effective +agent permissions, model and tool inventory; a sample JSON file is preparation. +Provide concise setup and skill/instruction material that preserves existing +agents and Graphify configuration. Never claim that this plan installed OpenCode +or that its subscription/provider combinations have been tested. + +Test selective use alongside Graphify separately from replacement. Record which +tool supplied each fact, prevent duplicate graph dumps and count both tools' +indexing and context overhead. Do not copy/integrate Graphify code or assume its +graph is authoritative. Defer a format adapter until a real coexistence task +needs one. Extend provider-neutral accounting to actual OpenCode exports after +inspecting a selected version's schema; missing usage remains unknown. + +## Acceptance: better outcomes and lower complete cost + +Preserve all existing D5 results/protocols and benchmark thresholds. The following +is a new prospective evaluation to lock before running paid arms: + +1. **Zero-model conformance first.** Fixtures check symbol/edge correctness, + evidence precision/recall, exact spans, budgets, repeatability, edits/deletions, + policy and config changes. Include the shadowing regression and examples the + current implementation cannot answer correctly. Record analysis latency, + memory and bytes read, cold and warm. Passing does not prove developer benefit. +2. **Qualify one representative task.** Use one versioned client/model/effort and + useful task with predefined acceptance. Fix demonstrated evidence gaps before + buying more comparisons. Every attempt and repair stays in the record. +3. **Compare competent workflows.** Ordinary search plus the client's normal + compiler/LSP/test tools; the same workflow with a pinned Graphify configuration; + and with Context. Hold repository, task, model, review and permissions fixed. + Pin Graphify mode and optional semantic processing; count any extraction-model + cost. Retain built-in tools in every arm, alternate order, isolate sessions and + record cold/warm caches and all setup/amortisation assumptions. Graphify is an + external evaluation dependency, never a core runtime prerequisite. +4. **Lock the quality and cost gates.** Retain OS2's proposed eight tasks across + two repositories, one external. All Context outcomes must meet the common + acceptance bar with no material regression. Require improvement in a named + outcome measure: first-pass accepted changes, missed/incorrect dependencies, + or substantive review repairs. A lower token count alone cannot pass the + better-results claim. If the baseline is perfect, use a prospectively selected + harder task class rather than claim an unobserved quality improvement. Retain + the proposed 20% aggregate total-token reduction and no higher review-time + target; additionally report wall time, local work and attributable money. +5. **Then qualify mixed models.** Keep the chosen frontier-planner/cheap-worker/ + reviewer policy identical across retrieval arms; count escalation and failed + cheap attempts. Only a separate routing comparison may change that policy. + A cheaper worker must pass the same task checks. Do not attribute routing + gains to retrieval or require users to adopt our development model choices. + +Report task classes won, lost and unknown against each baseline. Stop claiming a +general replacement if Context cannot beat the existing workflow at useful work. +Token reduction, subscription headroom and lower paid bills remain separate; +ordinary internal adoption continues while external claims remain gated. + +## First delivery slices + +| Slice | Owner and existing assignment | Concrete deliverable | +| --- | --- | --- | +| OS4b-1 correctness | Context language implementer; Sol/high contract/review, Terra/medium integration, Flash/off bounded fixtures | Regression and scope-aware call resolution; no false shadowed edge and retained valid relationships | +| OS4b-2 useful resolution | Same language owner; existing OS4b model assignments | Selected TS project resolution plus one symbol-to-caller/test evidence query, then first Rust parser slice | +| OS1e OpenCode | Context integration owner; Terra/medium integration, Flash/off fixtures/docs | Portable setup, client acceptance and one inspectable task receipt; no automatic model/account change | +| OS2 outcomes | Context lead and independent reviewer; existing OS2 assignments | Locked quality/cost comparison against competent ordinary and Graphify workflows; failed arms retained | +| OS5 measured reuse | Context indexing owner; Sol/high design/review, Terra/medium integration | Profile repeated analysis, then bounded reuse/invalidation only where it removes measured work | + +These slices start alongside daily FS adoption and installation work, before the +viewer or enterprise work. They do not authorise paid experiments automatically, +select an external private repository or activate a provider. No new parser, +client acceptance or savings result was delivered by this planning change. + +## Primary sources checked 22 September 2026 + +- [OpenCode MCP](https://opencode.ai/docs/mcp-servers/): local/remote tools and context overhead. +- [OpenCode agents](https://opencode.ai/docs/agents/): agent-specific model and permission configuration. +- [OpenCode LSP](https://opencode.ai/docs/lsp/): optional diagnostics and operational tradeoffs. +- [Graphify](https://github.com/Graphify-Labs/graphify): deterministic code extraction and graph workflow; vendor claims, not our acceptance results. +- [TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API): Program, TypeChecker and version boundaries. +- [Tree-sitter](https://tree-sitter.github.io/tree-sitter/): incremental syntax parsing. +- [rust-analyzer configuration](https://rust-analyzer.github.io/book/configuration): build-script and procedural-macro behaviour. diff --git a/docs/DOGFOOD-EXECUTION.md b/docs/DOGFOOD-EXECUTION.md index ee13515..cbb3ab7 100644 --- a/docs/DOGFOOD-EXECUTION.md +++ b/docs/DOGFOOD-EXECUTION.md @@ -1002,3 +1002,56 @@ Dart remains explicitly unsupported. Publication/CI evidence belongs to the Existing servers need a reconnect after the upgrade. No Heartwood checkout, configuration or live job was changed. Real Claude lifecycle qualification, routine whole-task receipts and measured benefit remain the next adoption gates. + +## 22 September 2026 — Scope-aware TS/JS call evidence (local, unreleased) + +Starting from `5df1b8b`, the source scanner replaces file-level name matching with +an isolated TypeScript Program/TypeChecker over the already selected syntax trees. +It resolves lexical bindings and selected local import/default/re-export aliases, +without loading tsconfig, external libraries, package metadata or extra files. +The compiler host has no filesystem fallback. Relative imports reuse the scanner's +real-path selection so virtual-root normalisation cannot widen the graph. + +This corrects a reproduced edge from `invoke(target) { target() }` to an unrelated +file-level `target`. Nested functions/arrows/classes no longer donate their calls +to their parent; multiple arrow declarations in one statement are independent. +Unindexed nested symbols, dynamic/object dispatch and project-level configuration +remain outside this bounded static-evidence contract. This is not complete +language support or proof of runtime dispatch; the signed wire format is unchanged. + +The host implemented the resolver and compiler-boundary tests. DeepSeek +`deepseek-v4.1-flash:cloud`, thinking off, supplied seven regression drafts through +the explicit M4 endpoint. The first draft invented indexed nested declarations; +two tests failed. One focused repair fixed those failures, but the host still +corrected negative assertions that contradicted direct calls in their fixtures. +Both attempts are retained and marked partial. Worker usage was 8,934 reported +prompt + 3,567 completion = 12,501 tokens. Preparation, integration and independent +review usage/billing are incomplete; worker tokens establish no savings. + +The new regression set fails 11 tests against the original scanner. Restoring the +implementation passes all 47 focused source/broad-scanner tests. Initial full +Node 24.21.0 validation passed 416 tests and independent packed-package checks; +both unchanged benchmark gates passed with required-source recall 1.0. These +are deterministic correctness checks and synthetic payload gates, not improved +whole-task/subscription/cash results. + +Independent Sol/high review found three additional cases: type-only export-star +barrels leaked a value target; ambiguous barrels selected the first target; and +same-named static/instance methods shared one record identity. The host added +selected-module value-export provenance with explicit-over-star precedence and +ambiguity rejection, plus distinct static identities only on a name collision. +Direct/chained/mixed/cyclic export and member-identity regressions bring the +focused suite to 51 passing tests. Compiler feedback also required an explicit +JSDoc-import exclusion; no JSDoc type import becomes runtime access. Final full +validation passed 420 tests, independent packed-package smoke and both unchanged +benchmark gates with required-source recall 1.0. The reviewer reprobed all three +findings successfully after repair. Identifier default-export assignments +(`export default value`) remain unsupported, as in HEAD; documentation now states +that limit separately from supported named default function declarations. +Private worker attempts and validation logs are under +`/tmp/context-scope-worker.4AxXdm`; no private transcript is committed. + +The product/client plans and language docs distinguish this unreleased change +from the published 0.3.2 artefacts. No client configuration, consumer pin, registry +release or deployment was changed. Next: explicit project resolution and useful +bounded symbol/reference/test evidence queries. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 384eba0..b3c9312 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -27,8 +27,8 @@ matching core version: mkdir context-install cd context-install npm init -y -npm install --ignore-scripts /absolute/path/to/forgesworn-context-0.3.2.tgz \ - /absolute/path/to/forgesworn-context-tools-0.3.2.tgz +npm install --ignore-scripts /absolute/path/to/forgesworn-context-0.3.3.tgz \ + /absolute/path/to/forgesworn-context-tools-0.3.3.tgz ``` For that installation, use diff --git a/docs/LANGUAGE-SUPPORT.md b/docs/LANGUAGE-SUPPORT.md index 8c85d6b..3ed1e93 100644 --- a/docs/LANGUAGE-SUPPORT.md +++ b/docs/LANGUAGE-SUPPORT.md @@ -26,8 +26,12 @@ The source contracts are Navigation provides exact-token line lookup. Broad scanning uses regular expressions and labels inferred relationships; it is not a language parser. -The dedicated TS/JS scanner and packet planner use TypeScript syntax trees, but -do not establish type-checked semantic resolution. Exact build packets copy +The dedicated TS/JS scanner and packet planner use TypeScript syntax trees. The +0.3.3 scanner also uses isolated compiler binding for scopes and +selected local import/re-export aliases; it does not load tsconfig, external +libraries or establish project-wide type-checked semantic resolution. Nested +callable bodies are not counted as direct calls of their parents. The packet +planner still establishes syntax completeness only. Exact build packets copy selected lines with provenance; they make no syntax-completeness guarantee. Unsupported plan suffixes are explicitly rejected by the planner guard before TypeScript parsing; rejection is not a parser failure on those languages. diff --git a/docs/OPEN-SOURCE-EXECUTION.md b/docs/OPEN-SOURCE-EXECUTION.md index f4bb8af..ed7b77e 100644 --- a/docs/OPEN-SOURCE-EXECUTION.md +++ b/docs/OPEN-SOURCE-EXECUTION.md @@ -11,8 +11,8 @@ now; the external-project and full controlled release evaluation below follow without blocking internal adoption. These FS goals apply this queue to the first users, not a separate product or enterprise workstream. -The [client and language work packets](CLIENTS-AND-LANGUAGES.md) make Claude Code -and Codex first-class MCP clients and prioritise deeper Rust/TS/Kotlin evidence. +The [client and language work packets](CLIENTS-AND-LANGUAGES.md) make Claude Code, +Codex and OpenCode first-class target MCP clients and prioritise deeper Rust/TS/Kotlin evidence. OS1a/OS1b and OS4a/the smallest OS4b slice are near-term work, not enterprise. Provider authentication, model selection and runtime routing remain client-owned; the model assignments here describe implementation/testing, not Context coupling. @@ -82,11 +82,19 @@ and effort; record the actual values rather than silently switching them. ## Ordered work queue +The owner's 22 September priority is [deterministic code intelligence and better +developer results](DETERMINISTIC-CODE-INTELLIGENCE.md). Begin OS4b-1 with the +reproduced shadowed-call correctness defect, then OS4b-2 symbol resolution and +useful evidence queries alongside OS0/OS1. Add OS1e OpenCode qualification and +OS2 comparisons against competent Graphify-assisted workflows. Quality improvement +is an additional product gate, not a substitute for existing acceptance/cost +checks. Model routing stays client-owned; no new paid trial is started by this plan. + | Goal / status | What needs doing | Owner and location | Implementation | Review | Completion evidence | | --- | --- | --- | --- | --- | --- | | **OS0 — Useful daily task. Next; tools exist** | Supply one sufficient verified packet for a real task; reuse current evidence on two further tasks; capture repairs and usage | Context lead: `scripts/worker-packet.mjs`, `docs/WORKER-PACKETS.md`, private receipts. Heartwood owner: application task in its own checkout | Deterministic first; Flash/off only for a demonstrated small tooling gap. Application work keeps its existing frontier model/effort | Existing application review; Terra/medium for routine Context changes, Sol/high only for a boundary change | Three accepted tasks with task/revision/model, source selection, freshness, checks, failure and usage records. This proves usability, not savings | -| **OS1 — Independent developer installation and MCP clients. Partial: shipped doctor and first Claude audit** | Package supported CLI/MCP use for **Claude Code and Codex**; expose bounded verified source packets; eliminate private-helper dependencies; follow OS1a–OS1d | Context implementer: `packages/context-tools`, `scripts`, package manifests, `test/context-package-smoke.mjs`, README and client docs | Flash/off for scoped packaging/docs; Terra/medium integration; Sonnet/medium then Opus/high for Claude qualification | Terra/medium; Sol/high for new disclosure/path boundaries | Pinned install outside workspace; actual task/tool use in both clients, edits → stale → refresh, restart/removal and bounded source; Node/Python prerequisites explicit; no Oathrun/provider credentials in Context/M4 required | -| **OS2 — Benefits with frontier quality. Open; previous assisted qualification failed** | Qualify the improved packet once, then prospectively lock and run a fair retrieval comparison using the same frontier model per pair; include non-ForgeSworn use | Context lead + reviewer: `benchmarks`, `test/task-cost-report.test.mjs`, `scripts/task-cost-report.mjs`, a new `docs/experiments` directory and private usage receipts; external pilot owns its repository | Deterministic accounting; Flash/off for small reporting gaps; **Sol/high for both new baseline and assisted executors**, separate fresh sessions | Independent Sol/high for protocol and accepted outcomes | Eight paired tasks across at least two explicit repositories, including one outside ForgeSworn; all assisted outcomes accepted; target ≥20% lower aggregate total model tokens and no higher aggregate review time. Report cached/uncached input and output separately; billing unknown leaves cash savings unproven | +| **OS1 — Independent developer installation and MCP clients. Partial: shipped doctor and first Claude audit** | Package supported CLI/MCP use for **Claude Code, Codex and OpenCode**; expose bounded verified source packets; eliminate private-helper dependencies; follow OS1a–OS1e | Context implementer: `packages/context-tools`, `scripts`, package manifests, `test/context-package-smoke.mjs`, README and client docs | Flash/off for scoped packaging/docs; Terra/medium integration; Sonnet/medium then Opus/high for Claude qualification | Terra/medium; Sol/high for new disclosure/path boundaries | Pinned install outside workspace; separate actual task/tool use in all three target clients, edits → stale → refresh, restart/removal and bounded source; Node/Python prerequisites explicit; no Oathrun/provider credentials in Context/M4 required | +| **OS2 — Benefits with frontier quality. Open; previous assisted qualification failed** | Qualify the improved packet once, then prospectively lock and run a fair retrieval comparison using the same frontier model per pair; include non-ForgeSworn use and the prospectively locked competitive arms | Context lead + reviewer: `benchmarks`, `test/task-cost-report.test.mjs`, `scripts/task-cost-report.mjs`, a new `docs/experiments` directory and private usage receipts; external pilot owns its repository | Deterministic accounting; Flash/off for small reporting gaps; **Sol/high for both new baseline and assisted executors**, separate fresh sessions | Independent Sol/high for protocol and accepted outcomes | Eight paired tasks across at least two explicit repositories, including one outside ForgeSworn; all assisted outcomes accepted; prospectively declared quality improvement against the stated ordinary/Graphify baseline; target ≥20% lower aggregate total model tokens and no higher aggregate review time. Report cached/uncached input and output separately; billing unknown leaves cash savings unproven | | **OS3 — Usable local graph. Queued after OS0–OS2** | Bring the original viewer and dependency graph into maintained MIT tooling; expose neighbourhoods, search, source links, edge provenance and incomplete/unknown results | Context implementer: original private prototype as input, maintained implementation under `packages/context-tools`, tests and public fixtures | Flash/off for bounded UI/fixtures; Terra/medium for graph/tool integration | Terra/medium for usability; Sol/high for unsafe-content and source/disclosure boundaries | An independent install visualises a selected repository/ecosystem, explains each relationship and opens exact evidence; works offline without a service; no Graphify code; hostile labels/links and large result bounds tested | | **OS4 — Correct versions and language understanding. Partial; OS4a/b start with OS1** | Connect snapshot evidence; publish exact support levels; deepen Rust and TS/JS, then Kotlin and task-selected wider-stack languages; follow OS4a–OS4d | Context lead: `scripts/ecosystem_resolution.py`, `scripts/ecosystem_snapshot.py`, source/ecosystem scanners in `packages/context-tools`, matching tests and fixtures | Sol/high version/provenance design; Terra/medium parser/integration work; Flash/off mechanically specified fixtures | Independent Sol/high for resolution/identity; Terra/medium ordinary extractor changes | Released package vs HEAD/worktree/ambiguity fixtures; parser-backed relationships and complete packets on real stack tasks; TS/JS syntax hints are not type-checked proof, and lexical inference remains labelled; unsupported cases visible | | **OS5 — Practical local scale and refresh. Proposed; activate for measured need** | Qualify persistent local indexing and incremental refresh against actual workloads; resolve private-index storage policy before private source is persisted | Context lead: `docs/ENTERPRISE-SCALE-REVIEW.md`, `docs/BOUNDED-SEARCH-DESIGN.md`, `benchmarks/scale`, Node tools adapter; protocol changes only if justified | Sol/high for storage/generation design; Terra/medium for integration; Flash/off for scoped fixtures | Independent Sol/high | Predeclared corpus, latency/memory/work budgets, recall and update targets pass; cancellation, deletion, crash/recovery and concurrent readers tested; no silent plaintext private index. All local capabilities remain MIT | @@ -109,6 +117,11 @@ preparation alongside OS1, but run paid comparison arms only after the improved handoff passes qualification. Keep model-routing comparisons separate. Run Claude client acceptance as OS1a, with its own receipts and eligible account. +Run OpenCode acceptance as OS1e with its own pinned version, effective agent +permissions and usage evidence. It is a first-class target, not an assumed +consequence of passing another MCP client. The deterministic-language contract +defines the new quality metric and competitive arms to lock prospectively; existing +D5 results and both benchmark thresholds remain unchanged. OS2's Sol/high pair is the Codex cohort; any Claude savings cohort must hold its own Claude model/effort fixed. Passing one client is not evidence for another. diff --git a/package-lock.json b/package-lock.json index 5a08a62..6b46fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3071,7 +3071,7 @@ }, "packages/context": { "name": "@forgesworn/context", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "@noble/ciphers": "2.1.1", @@ -3089,10 +3089,10 @@ }, "packages/context-tools": { "name": "@forgesworn/context-tools", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { - "@forgesworn/context": "0.3.2", + "@forgesworn/context": "0.3.3", "@modelcontextprotocol/sdk": "1.30.0", "@noble/hashes": "1.8.0", "ignore": "7.0.9", diff --git a/packages/context-tools/README.md b/packages/context-tools/README.md index b080827..1ab5dad 100644 --- a/packages/context-tools/README.md +++ b/packages/context-tools/README.md @@ -142,11 +142,28 @@ encrypted-context scan-source /projects/my-project \ --max-file-bytes 262144 --max-records 128 --observed-at 1800000000 ``` -The first analyser supports `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, -`.mjs` and `.cjs`. Identifier calls, imported named calls and `this.method()` -within a class are linked only when their target is present and unambiguous. -Dynamic imports, computed calls, object dispatch, package exports and type-level -resolution are deliberately not inferred. These records are reviewable static +The analyser supports `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, +`.mjs` and `.cjs`. Version 0.3.3 binds identifier calls using an +isolated TypeScript Program/TypeChecker over already selected syntax trees. +Parameter, local, block and import shadowing use the actual declaration identity. +Selected named default function declarations and re-export aliases can resolve through local +modules; type-only aliases and re-export names without a local binding do not +establish value calls. `this.method()` uses the selected method declaration. +Conflicting value targets from named/star barrels produce no call edge; +explicit exports take precedence over stars. When a class has both static and +instance members with the same name, their source identities are separated: +`Class.static.member` and `Class.member`. Other member identities stay unchanged. +Nested functions, arrows and classes are not attributed as direct calls of an +enclosing callable; their unindexed symbols remain outside this graph. Sibling +arrow declarations are analysed independently. + +Compiler binding has no filesystem fallback, default libraries, configuration +loading or additional dependency discovery. It only follows relative modules +already resolved against the bounded source selection. Version 0.3.2 and earlier +retain the earlier name-based call hints. +Default-export expression assignments (`export default value`), dynamic imports, +computed calls, object dispatch, package exports and type-level +project resolution are deliberately not inferred. These records are reviewable static evidence, not proof of runtime behaviour or semantic intent. `encrypted-context scan-broad-source ` adds conservative navigation diff --git a/packages/context-tools/THIRD_PARTY_NOTICES.md b/packages/context-tools/THIRD_PARTY_NOTICES.md index 64e8d1e..5929757 100644 --- a/packages/context-tools/THIRD_PARTY_NOTICES.md +++ b/packages/context-tools/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -These notices accompany `@forgesworn/context-tools@0.3.2`. The package itself is +These notices accompany `@forgesworn/context-tools@0.3.3`. The package itself is MIT licensed, Copyright (c) 2026 TheCryptoDonkey; see `LICENSE`. Runtime dependencies are installed separately by the package manager, not @@ -9,9 +9,9 @@ notices below cover direct runtime dependencies; retain the licence files from transitive dependencies as well when redistributing an installation or bundle. Dependency versions and source links refer to this release. -## @forgesworn/context@0.3.2 +## @forgesworn/context@0.3.3 -Declared licence: MIT. Source package: https://www.npmjs.com/package/@forgesworn/context/v/0.3.2 +Declared licence: MIT. Source package: https://www.npmjs.com/package/@forgesworn/context/v/0.3.3 ```text MIT License diff --git a/packages/context-tools/package.json b/packages/context-tools/package.json index c1071bb..88e07a5 100644 --- a/packages/context-tools/package.json +++ b/packages/context-tools/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/context-tools", - "version": "0.3.2", + "version": "0.3.3", "description": "Local CLI and MCP tools for Z1P Core encrypted context", "license": "MIT", "type": "module", @@ -36,7 +36,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@forgesworn/context": "0.3.2", + "@forgesworn/context": "0.3.3", "@modelcontextprotocol/sdk": "1.30.0", "@noble/hashes": "1.8.0", "ignore": "7.0.9", diff --git a/packages/context-tools/src/source-scan.test.ts b/packages/context-tools/src/source-scan.test.ts index bdc6b81..3cd708c 100644 --- a/packages/context-tools/src/source-scan.test.ts +++ b/packages/context-tools/src/source-scan.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { scanSourceGraph } from './source-scan.js' import { ContextVault } from '@forgesworn/context' import { createNostrIdentity } from '@forgesworn/context/nostr' +import ts from 'typescript' const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -17,6 +18,136 @@ function bySource(records: Awaited>['records' } describe('bounded TypeScript and JavaScript source graph scan', () => { + it.each(['ts', 'js'])('resolves file-level shadowing in %s without losing direct calls', async extension => { + const root = await fixture() + await source(root, `scope.${extension}`, ` + export function target() { return 1 } + export function parameter(target) { return target() } + export function destructured({ target }) { return target() } + export function local() { const target = () => 2; return target() } + export function block() { { let target = () => 2; target() } } + export function caught() { try {} catch (target) { target() } } + export function loop(items) { for (const target of items) target() } + export function hoisted() { target(); function target() {} } + export function direct() { return target() } + `) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + for (const name of ['parameter', 'destructured', 'local', 'block', 'caught', 'loop', 'hoisted']) { + expect(bySource(records, `repo://scope.${extension}#${name}`)!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + } + expect(bySource(records, `repo://scope.${extension}#direct`)!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, `repo://scope.${extension}#target`)!.id }]) + }) + + it('resolves named defaults and re-export aliases to the selected declaration', async () => { + const root = await fixture() + await source(root, 'target.ts', `export default function target() {}\nexport const arrow = () => target()`) + await source(root, 'barrel.ts', `export { default as renamed, arrow } from './target.js'`) + await source(root, 'a.ts', `import defaultCall from './target.js'\nimport { renamed as indirect, arrow } from './barrel.js'\nexport function invoke() { defaultCall(); indirect(); arrow() }`) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://a.ts#invoke')!.relations?.filter(edge => edge.kind === 'calls').map(edge => edge.to).sort()) + .toEqual(['target', 'arrow'].map(name => bySource(records, `repo://target.ts#${name}`)!.id).sort()) + }) + + it('does not turn re-export names into local bindings or type-only imports into calls', async () => { + const root = await fixture() + await source(root, 'target.ts', 'export function target() {}') + await source(root, 'a.ts', `export { target as alias } from './target'\nexport function invoke() { alias() }`) + await source(root, 'b.ts', `import type { target } from './target'\nexport function invoke() { target() }`) + await source(root, 'c.ts', `import { type target } from './target'\nexport function invoke() { target() }`) + await source(root, 'barrel.ts', `export type { target } from './target'`) + await source(root, 'd.ts', `import { target } from './barrel'\nexport function invoke() { target() }`) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + for (const file of ['a', 'b', 'c', 'd']) { + expect(bySource(records, `repo://${file}.ts#invoke`)!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + } + }) + + it('keeps compiler binding inside selected sources without reading configs or excluded dependencies', async () => { + const root = await fixture() + await source(root, 'tsconfig.json', '{"extends":"../outside.json","compilerOptions":{"types":["outside"]}}') + await source(root, 'node_modules/outside/index.ts', 'export function external() {}') + await source(root, 'hidden.ts', 'export function hidden() {}') + await source(root, 'a.ts', `/// \nimport { external } from 'outside'\nimport { hidden } from './hidden'\nexport function target() {}\nexport function invoke() { external(); hidden(); target() }`) + const spies = [vi.spyOn(ts.sys, 'readFile'), vi.spyOn(ts.sys, 'fileExists'), vi.spyOn(ts.sys, 'readDirectory')] + for (const spy of spies) spy.mockImplementation(() => { throw new Error('Unexpected compiler filesystem access') }) + try { + const { records } = await scanSourceGraph(root, { observedAt: 123, maxFiles: 1 }) + expect(records.every(record => record.source.startsWith('repo://a.ts'))).toBe(true) + expect(bySource(records, 'repo://a.ts#invoke')!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, 'repo://a.ts#target')!.id }]) + for (const spy of spies) expect(spy).not.toHaveBeenCalled() + } finally { for (const spy of spies) spy.mockRestore() } + }) + + it('does not join unrelated script globals or same-named class methods', async () => { + const root = await fixture() + await source(root, 'a.ts', 'function target() {}') + await source(root, 'b.ts', 'function invoke() { target() }') + await source(root, 'classes.ts', `export class A { target() {} run() { return this.target() } }\nexport class B { target() {} run() { return this.target() } }`) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://b.ts#invoke')!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + for (const name of ['A', 'B']) { + expect(bySource(records, `repo://classes.ts#${name}.run`)!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, `repo://classes.ts#${name}.target`)!.id }]) + } + }) + + it('does not alias an escaping relative import to a selected file at the virtual root', async () => { + const root = await fixture() + await source(root, 'target.ts', 'export function target() {}') + await source(root, 'a.ts', `import { target } from '../target'\nexport function invoke() { target() }`) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://a.ts')!.relations ?? []).toEqual([]) + expect(bySource(records, 'repo://a.ts#invoke')!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + }) + + it('distinguishes type-only export stars from real values through chained and cyclic barrels', async () => { + const root = await fixture() + await source(root, 'target.ts', 'export function target() {}\nexport function live() {}') + await source(root, 'types.ts', `export type * from './target'\nexport { live } from './target'`) + await source(root, 'chain.ts', `export { target, live } from './types'`) + await source(root, 'local.ts', `import { target, live } from './types'\nexport { target, live }`) + await source(root, 'values.ts', `export * from './cycle'\nexport * from './target'`) + await source(root, 'cycle.ts', `export * from './values'`) + await source(root, 'consumer.ts', ` + import { target as types, live as a } from './types' + import { target as chain, live as b } from './chain' + import { target as local, live as c } from './local' + import { target as value } from './values' + export function omitted() { types(); chain(); local() } + export function kept() { a(); b(); c(); value() } + `) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://consumer.ts#omitted')!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + expect(bySource(records, 'repo://consumer.ts#kept')!.relations?.filter(edge => edge.kind === 'calls').map(edge => edge.to).sort()) + .toEqual(['target', 'live'].map(name => bySource(records, `repo://target.ts#${name}`)!.id).sort()) + }) + + it.each(['named', 'star'])('omits ambiguous %s barrel exports while retaining explicit overrides', async form => { + const root = await fixture() + await source(root, 'a.ts', 'export function target() {}') + await source(root, 'b.ts', 'export function target() {}') + const clause = form === 'named' ? '{ target }' : '*' + await source(root, 'ambiguous.ts', `export ${clause} from './a'\nexport ${clause} from './b'`) + await source(root, 'explicit.ts', `export * from './a'\nexport { target } from './b'`) + await source(root, 'consumer.ts', `import { target as ambiguous } from './ambiguous'\nimport { target as explicit } from './explicit'\nexport function omitted() { ambiguous() }\nexport function kept() { explicit() }`) + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://consumer.ts#omitted')!.relations?.filter(edge => edge.kind === 'calls')).toEqual([]) + expect(bySource(records, 'repo://consumer.ts#kept')!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, 'repo://b.ts#target')!.id }]) + }) + + it('preserves distinct static and instance method identities when their names coincide', async () => { + const root = await fixture() + await source(root, 'a.ts', 'export class C { static target() {} target() {} static s() { this.target() } i() { this.target() } }') + const { records } = await scanSourceGraph(root, { observedAt: 123 }) + expect(bySource(records, 'repo://a.ts#C.s')!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, 'repo://a.ts#C.static.target')!.id }]) + expect(bySource(records, 'repo://a.ts#C.i')!.relations?.filter(edge => edge.kind === 'calls')) + .toEqual([{ kind: 'calls', to: bySource(records, 'repo://a.ts#C.target')!.id }]) + }) + it('extracts deterministic files, symbols, imports, re-exports and unambiguous calls', async () => { const root = await fixture() await source(root, 'src/a.ts', `export function alpha(value: number) { return helper(value) }\nfunction helper(value: number) { return value + 1 }\nexport class Worker { run() { return this.finish() } finish() { return alpha(1) } }`) @@ -307,4 +438,93 @@ describe('bounded TypeScript and JavaScript source graph scan', () => { expect(first.records).toEqual(second.records) expect(first.scanBounds).toEqual(second.scanBounds) }) + + it('does not attribute calls inside nested function bodies to the outer callable', async () => { + const root = await fixture() + await source(root, 'a.ts', `export function outer() {\n function inner() { return target() }\n return inner()\n}\nexport function target() { return 1 }`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const outer = bySource(result.records, 'repo://a.ts#outer')! + const target = bySource(result.records, 'repo://a.ts#target')! + expect(bySource(result.records, 'repo://a.ts#inner')).toBeUndefined() + expect(outer.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + }) + + it('does not attribute calls inside arrow bodies to the enclosing callable', async () => { + const root = await fixture() + await source(root, 'a.ts', `export function outer() {\n const step = () => target()\n return step()\n}\nexport function target() { return 1 }`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const outer = bySource(result.records, 'repo://a.ts#outer')! + const target = bySource(result.records, 'repo://a.ts#target')! + expect(bySource(result.records, 'repo://a.ts#step')).toBeUndefined() + expect(outer.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + }) + + it('omits callback-only calls from the enclosing callable', async () => { + const root = await fixture() + await source(root, 'a.ts', `export function outer() {\n items.map(item => only())\n return direct()\n}\nexport function only() { return 1 }\nexport function direct() { return 2 }`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const outer = bySource(result.records, 'repo://a.ts#outer')! + const only = bySource(result.records, 'repo://a.ts#only')! + const direct = bySource(result.records, 'repo://a.ts#direct')! + expect(outer.relations?.filter(edge => edge.kind === 'calls').map(edge => edge.to).sort()).toEqual([direct.id]) + expect(outer.relations?.filter(edge => edge.kind === 'calls' && edge.to === only.id) ?? []).toEqual([]) + expect(outer.relations).toContainEqual({ to: direct.id, kind: 'calls' }) + }) + + it('binds this.finish for the method but not for ordinary nested functions', async () => { + const root = await fixture() + await source(root, 'a.ts', `export class Worker {\n run() {\n function helper() { return this.finish() }\n return helper()\n }\n finish() { return 1 }\n direct() { return this.finish() }\n}`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const run = bySource(result.records, 'repo://a.ts#Worker.run')! + const direct = bySource(result.records, 'repo://a.ts#Worker.direct')! + const finish = bySource(result.records, 'repo://a.ts#Worker.finish')! + expect(run.relations?.filter(edge => edge.kind === 'calls' && edge.to === finish.id) ?? []).toEqual([]) + expect(direct.relations).toContainEqual({ to: finish.id, kind: 'calls' }) + }) + + it('keeps shadowing bindings distinct from imported call targets across independent functions', async () => { + const root = await fixture() + await source(root, 'a.ts', `import { target } from './b'\nexport function paramShadow(target: () => number) {\n return target()\n}\nexport function localShadow() {\n const target = () => 3\n return target()\n}\nexport function blockShadow() {\n { const target = () => 4; target() }\n}\nexport function importParam(target: () => number) {\n return target()\n}\nexport function catchShadow() {\n try { throw 1 } catch (target) { target() }\n}\nexport function loopShadow() {\n for (const target of items) target()\n}\nexport function direct() { return target() }`) + await source(root, 'b.ts', `export function target() { return 1 }`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const target = bySource(result.records, 'repo://b.ts#target')! + const paramShadow = bySource(result.records, 'repo://a.ts#paramShadow')! + const localShadow = bySource(result.records, 'repo://a.ts#localShadow')! + const blockShadow = bySource(result.records, 'repo://a.ts#blockShadow')! + const importParam = bySource(result.records, 'repo://a.ts#importParam')! + const catchShadow = bySource(result.records, 'repo://a.ts#catchShadow')! + const loopShadow = bySource(result.records, 'repo://a.ts#loopShadow')! + const direct = bySource(result.records, 'repo://a.ts#direct')! + expect(paramShadow.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(localShadow.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(blockShadow.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(importParam.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(catchShadow.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(loopShadow.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + expect(direct.relations).toContainEqual({ to: target.id, kind: 'calls' }) + }) + + it('attributes destructuring, catch and loop bindings to their local declarations only', async () => { + const root = await fixture() + await source(root, 'a.ts', `export function pick() { return 1 }\nexport function use() {\n const { pick } = source\n try { pick() } catch (pick) { pick() }\n for (const pick of items) pick()\n return pick()\n}\nexport const source = { pick: () => 2 }\nconst items: Array<() => void> = []`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const use = bySource(result.records, 'repo://a.ts#use')! + const pick = bySource(result.records, 'repo://a.ts#pick')! + expect(use.relations?.filter(edge => edge.kind === 'calls' && edge.to === pick.id) ?? []).toEqual([]) + }) + + it('keeps independently declared arrows in one variable statement as separate callables', async () => { + const root = await fixture() + await source(root, 'a.ts', `export const first = () => target(), second = () => other()\nexport function target() { return 1 }\nexport function other() { return 2 }`) + const result = await scanSourceGraph(root, { observedAt: 123 }) + const first = bySource(result.records, 'repo://a.ts#first')! + const second = bySource(result.records, 'repo://a.ts#second')! + const target = bySource(result.records, 'repo://a.ts#target')! + const other = bySource(result.records, 'repo://a.ts#other')! + expect(first.relations).toContainEqual({ to: target.id, kind: 'calls' }) + expect(first.relations?.filter(edge => edge.kind === 'calls' && edge.to === other.id) ?? []).toEqual([]) + expect(second.relations).toContainEqual({ to: other.id, kind: 'calls' }) + expect(second.relations?.filter(edge => edge.kind === 'calls' && edge.to === target.id) ?? []).toEqual([]) + }) + }) diff --git a/packages/context-tools/src/source-scan.ts b/packages/context-tools/src/source-scan.ts index 2e542cc..afdff10 100644 --- a/packages/context-tools/src/source-scan.ts +++ b/packages/context-tools/src/source-scan.ts @@ -39,9 +39,8 @@ export interface SourceGraphScan { } type SymbolKind = 'function' | 'class' | 'method' | 'interface' | 'type' | 'enum' | 'variable' -interface ImportBinding { target: string; imported: string } interface ParsedSymbol { key: string; file: string; name: string; kind: SymbolKind; exported: boolean; line: number; parameters?: number; node: ts.Node; className?: string } -interface ParsedFile { path: string; bytes: number; source: ts.SourceFile; imports: string[]; bindings: Map; symbols: ParsedSymbol[] } +interface ParsedFile { path: string; bytes: number; source: ts.SourceFile; imports: string[]; modules: Map; symbols: ParsedSymbol[] } const ignored = new Set(['.git', 'node_modules', 'build', 'dist', 'coverage', 'out']) const extensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']) @@ -81,6 +80,154 @@ function declarationName(node: ts.DeclarationStatement): string | undefined { function relation(to: string, kind: ContextRelationKind): ContextRelation { return { to, kind } } function relationSort(a: ContextRelation, b: ContextRelation): number { return a.kind.localeCompare(b.kind) || a.to.localeCompare(b.to) } +/** Bind only already selected syntax trees. The compiler has no filesystem, + * config, default-library, package or network fallback. Virtual paths keep + * compiler resolution independent of the operator's current directory. */ +function callResolver(files: ParsedFile[]): (expression: ts.Expression, caller: ParsedSymbol) => ParsedSymbol | undefined { + const sources = new Map(files.map(file => [file.source.fileName, file.source])) + const modules = new Map(files.map(file => [file.source.fileName, file.modules])) + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, + allowJs: true, noLib: true, noEmit: true, types: [], skipLibCheck: true, + } + const host: ts.CompilerHost = { + getSourceFile: path => sources.get(path), + getDefaultLibFileName: () => '/__context_no_lib__.d.ts', + writeFile: () => {}, + getCurrentDirectory: () => '/', + getDirectories: () => [], + fileExists: path => sources.has(path), + readFile: path => sources.get(path)?.text, + getCanonicalFileName: path => path, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + resolveModuleNames: (names, from) => names.map(name => { + // Reuse only imports resolved against the real selected paths. Resolving + // ../ at a virtual / root could otherwise alias an out-of-root import. + const target = modules.get(from)?.get(name) + return target ? { resolvedFileName: `/${target}` } : undefined + }), + } + const checker = ts.createProgram([...sources.keys()], options, host).getTypeChecker() + const declarations = new Map() + for (const file of files) for (const symbol of file.symbols) { + const name = (symbol.node as ts.NamedDeclaration).name + if (!name) continue + const binding = checker.getSymbolAtLocation(name) + if (binding) declarations.set(binding, symbol) + } + // The checker follows type-only export stars to their original value symbol. + // Prove a value export path separately before accepting an imported call. + const byPath = new Map(files.map(file => [file.path, file])) + type ExportSeen = Set + function valueBindings(binding: ts.Symbol | undefined, seen: ExportSeen): Set { + const values = new Set() + if (!binding || seen.has(binding)) return values + seen.add(binding) + if (!(binding.flags & ts.SymbolFlags.Alias)) { + if (binding.flags & ts.SymbolFlags.Value) values.add(binding) + return values + } + for (const node of binding.declarations ?? []) { + if (ts.isTypeOnlyImportOrExportDeclaration(node)) continue + let declaration: ts.ImportDeclaration | ts.ExportDeclaration + let name: string + if (ts.isImportSpecifier(node)) { + if (!ts.isImportDeclaration(node.parent.parent.parent)) continue + declaration = node.parent.parent.parent + name = (node.propertyName ?? node.name).text + } else if (ts.isImportClause(node)) { + if (!ts.isImportDeclaration(node.parent)) continue + declaration = node.parent + name = 'default' + } else if (ts.isExportSpecifier(node)) { + declaration = node.parent.parent + name = (node.propertyName ?? node.name).text + if (!declaration.moduleSpecifier) { + for (const value of valueBindings(checker.getExportSpecifierLocalTargetSymbol(node), seen)) values.add(value) + continue + } + } else continue + const specifier = declaration.moduleSpecifier + if (!specifier || !ts.isStringLiteral(specifier)) continue + const target = modules.get(declaration.getSourceFile().fileName)?.get(specifier.text) + if (target !== undefined) for (const value of valueExports(target, name, seen)) values.add(value) + } + return values + } + function valueExports(path: string, name: string, seen: ExportSeen): Set { + const values = new Set(), key = `${path}\0${name}` + if (seen.has(key)) return values + seen.add(key) + const file = byPath.get(path) + if (!file) return values + let explicit = false + for (const symbol of file.symbols) { + if (!symbol.exported || symbol.kind === 'method') continue + const exportName = ts.getCombinedModifierFlags(symbol.node as ts.Declaration) & ts.ModifierFlags.Default ? 'default' : symbol.name + if (exportName === name) { + explicit = true + const binding = checker.getSymbolAtLocation((symbol.node as ts.NamedDeclaration).name!) + for (const value of valueBindings(binding, seen)) values.add(value) + } + } + const stars: string[] = [] + for (const node of file.source.statements) { + if (!ts.isExportDeclaration(node)) continue + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) if (element.name.text === name) { + explicit = true + if (node.isTypeOnly || element.isTypeOnly) continue + if (!node.moduleSpecifier) { + for (const value of valueBindings(checker.getExportSpecifierLocalTargetSymbol(element), seen)) values.add(value) + } else if (ts.isStringLiteral(node.moduleSpecifier)) { + const target = file.modules.get(node.moduleSpecifier.text) + if (target !== undefined) for (const value of valueExports(target, (element.propertyName ?? element.name).text, seen)) values.add(value) + } + } + } else if (!node.exportClause && !node.isTypeOnly && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const target = file.modules.get(node.moduleSpecifier.text) + if (target) stars.push(target) + } + } + if (!explicit && name !== 'default') for (const target of stars) { + for (const value of valueExports(target, name, seen)) values.add(value) + } + return values + } + const importedValues = new Map>() + return (expression, caller) => { + // Ordinary object/namespace/dynamic calls remain outside this contract. + const identifier = ts.isIdentifier(expression) + const member = ts.isPropertyAccessExpression(expression) + && expression.expression.kind === ts.SyntaxKind.ThisKeyword && !!caller.className + if (!identifier && !member) return undefined + let binding = checker.getSymbolAtLocation(identifier ? expression : expression.name) + if (!binding) return undefined + const imported = !!(binding.flags & ts.SymbolFlags.Alias) + if (imported) { + let values = importedValues.get(binding) + if (!values) { values = valueBindings(binding, new Set()); importedValues.set(binding, values) } + // A checker alias alone can choose the first of conflicting barrel exports. + if (values.size !== 1) return undefined + const [value] = values + if (checker.getAliasedSymbol(binding) !== value) return undefined + } + const seen = new Set() + while (binding.flags & ts.SymbolFlags.Alias) { + if (seen.has(binding) || binding.declarations?.some(ts.isTypeOnlyImportOrExportDeclaration)) return undefined + seen.add(binding) + binding = checker.getImmediateAliasedSymbol(binding) + if (!binding) return undefined + } + const target = declarations.get(binding) + // Do not infer a shared script-global environment across unrelated files. + if (!target || (!imported && target.file !== caller.file)) return undefined + if (member && !ts.isMethodDeclaration(target.node)) return undefined + return target + } +} + async function readBounded(path: string, max: number): Promise<{ text: string; bytes: number }> { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) try { @@ -152,18 +299,24 @@ export async function scanSourceGraph(root: string, options: SourceGraphScanOpti const known = new Set(selected.map(file => file.path.split(sep).join('/'))) const parsed: ParsedFile[] = selected.map(file => { const path = normal(canonicalRoot, file.path) - const source = ts.createSourceFile(path, file.text, ts.ScriptTarget.Latest, true, sourceKind(path)) - const symbols: ParsedSymbol[] = [], rawImports: { specifier: string; clause?: ts.ImportClause; declaration?: ts.ExportDeclaration }[] = [] + const source = ts.createSourceFile(`/${path}`, file.text, ts.ScriptTarget.Latest, true, sourceKind(path)) + const symbols: ParsedSymbol[] = [], rawImports: string[] = [] for (const node of source.statements) { - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) rawImports.push({ specifier: node.moduleSpecifier.text, clause: node.importClause }) - if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) rawImports.push({ specifier: node.moduleSpecifier.text, declaration: node }) + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) rawImports.push(node.moduleSpecifier.text) + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) rawImports.push(node.moduleSpecifier.text) if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node)) { const name = declarationName(node) if (name) symbols.push({ key: `${path}#${name}`, file: path, name, kind: ts.isFunctionDeclaration(node) ? 'function' : ts.isClassDeclaration(node) ? 'class' : ts.isInterfaceDeclaration(node) ? 'interface' : ts.isTypeAliasDeclaration(node) ? 'type' : 'enum', exported: exported(node), line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, parameters: ts.isFunctionDeclaration(node) ? node.parameters.length : undefined, node }) - if (name && ts.isClassDeclaration(node)) for (const member of node.members) { - if ((!ts.isMethodDeclaration(member) && !ts.isGetAccessorDeclaration(member) && !ts.isSetAccessorDeclaration(member)) || !member.name || !ts.isIdentifier(member.name)) continue - const method = member.name.text - symbols.push({ key: `${path}#${name}.${method}`, file: path, name: `${name}.${method}`, kind: 'method', exported: exported(node), line: source.getLineAndCharacterOfPosition(member.getStart(source)).line + 1, parameters: member.parameters.length, node: member, className: name }) + if (name && ts.isClassDeclaration(node)) { + const instanceNames = new Set(node.members.flatMap(member => member.name && ts.isIdentifier(member.name) + && !(ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static) ? [member.name.text] : [])) + for (const member of node.members) { + if ((!ts.isMethodDeclaration(member) && !ts.isGetAccessorDeclaration(member) && !ts.isSetAccessorDeclaration(member)) || !member.name || !ts.isIdentifier(member.name)) continue + // Static and instance members have different bindings. Split a legacy + // shared name only when both exist; ordinary source identities stay stable. + const method = (instanceNames.has(member.name.text) && (ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static) ? 'static.' : '') + member.name.text + symbols.push({ key: `${path}#${name}.${method}`, file: path, name: `${name}.${method}`, kind: 'method', exported: exported(node), line: source.getLineAndCharacterOfPosition(member.getStart(source)).line + 1, parameters: member.parameters.length, node: member, className: name }) + } } } if (ts.isVariableStatement(node)) for (const declaration of node.declarationList.declarations) { @@ -171,22 +324,20 @@ export async function scanSourceGraph(root: string, options: SourceGraphScanOpti const init = declaration.initializer if (!init || (!ts.isArrowFunction(init) && !ts.isFunctionExpression(init))) continue const name = declaration.name.text - symbols.push({ key: `${path}#${name}`, file: path, name, kind: 'variable', exported: exported(node), line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, parameters: init.parameters.length, node }) + symbols.push({ key: `${path}#${name}`, file: path, name, kind: 'variable', exported: exported(node), line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, parameters: init.parameters.length, node: declaration }) } } - const imports: string[] = [], bindings = new Map() - for (const item of rawImports) { - const absolute = resolveImport(file.path.split(sep).join('/'), item.specifier, known) + const imports: string[] = [], modules = new Map() + for (const specifier of rawImports) { + const absolute = resolveImport(file.path.split(sep).join('/'), specifier, known) if (!absolute) continue const target = normal(canonicalRoot, absolute) imports.push(target) - if (item.clause?.name) bindings.set(item.clause.name.text, { target, imported: 'default' }) - if (item.clause?.namedBindings && ts.isNamedImports(item.clause.namedBindings)) for (const element of item.clause.namedBindings.elements) bindings.set(element.name.text, { target, imported: element.propertyName?.text ?? element.name.text }) - if (item.declaration?.exportClause && ts.isNamedExports(item.declaration.exportClause)) for (const element of item.declaration.exportClause.elements) bindings.set(element.name.text, { target, imported: element.propertyName?.text ?? element.name.text }) + modules.set(specifier, target) } const uniqueSymbols = new Map() for (const symbol of symbols) uniqueSymbols.set(symbol.key, symbol) - return { path, bytes: file.bytes, source, imports: [...new Set(imports)].sort(), bindings, + return { path, bytes: file.bytes, source, imports: [...new Set(imports)].sort(), modules, symbols: [...uniqueSymbols.values()].filter(symbol => validRecordSource(`repo://${path}#${encodeURIComponent(symbol.name)}`)) .sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)) } }) @@ -233,7 +384,7 @@ export async function scanSourceGraph(root: string, options: SourceGraphScanOpti } } const ids = new Map(retained.map(candidate => [candidate.key, id(candidate.type, candidate.key)])) - const symbolsByFile = new Map(parsed.map(file => [file.path, new Map(file.symbols.map(symbol => [symbol.name, symbol]))])) + const resolveCall = callResolver(parsed) let callsFound = 0 const records = retained.map((candidate): ContextRecord => { const relations: ContextRelation[] = [] @@ -260,18 +411,13 @@ export async function scanSourceGraph(root: string, options: SourceGraphScanOpti } const symbol = candidate.symbol if (retainedKeys.has(symbol.file)) relations.push(relation(ids.get(symbol.file)!, 'relates-to')) - const local = symbolsByFile.get(symbol.file)! + const body = ts.isVariableDeclaration(symbol.node) ? symbol.node.initializer! : symbol.node function visit(node: ts.Node): void { + // A nested callable owns its calls. It is not a direct call by its parent, + // even when an arrow captures lexical this. Nested symbols are not indexed. + if (node !== body && (ts.isFunctionLike(node) || ts.isClassLike(node))) return if (ts.isCallExpression(node) || ts.isNewExpression(node)) { - const expression = node.expression - let target: ParsedSymbol | undefined - if (ts.isIdentifier(expression)) { - target = local.get(expression.text) - const imported = candidate.file.bindings.get(expression.text) - if (!target && imported) target = symbolsByFile.get(imported.target)?.get(imported.imported) - } else if (ts.isPropertyAccessExpression(expression) && expression.expression.kind === ts.SyntaxKind.ThisKeyword && symbol.className) { - target = local.get(`${symbol.className}.${expression.name.text}`) - } + const target = resolveCall(node.expression, symbol) if (target && retainedKeys.has(target.key) && target.key !== symbol.key) { const edge = relation(ids.get(target.key)!, 'calls') if (!relations.some(item => item.kind === edge.kind && item.to === edge.to)) { relations.push(edge); callsFound++ } @@ -279,7 +425,7 @@ export async function scanSourceGraph(root: string, options: SourceGraphScanOpti } ts.forEachChild(node, visit) } - if (['function', 'method', 'variable'].includes(symbol.kind)) visit(symbol.node) + if (['function', 'method', 'variable'].includes(symbol.kind)) visit(body) return { id: ids.get(candidate.key)!, kind: 'evidence', text: `${symbol.exported ? 'Exported' : 'Local'} ${symbol.kind} ${symbol.name} in ${symbol.file} at line ${symbol.line}${symbol.parameters === undefined ? '' : ` with ${symbol.parameters} parameter${symbol.parameters === 1 ? '' : 's'}`}.`, source: `repo://${symbol.file}#${encodeURIComponent(symbol.name)}`, observedAt, provenance, ...(relations.length ? { relations: relations.sort(relationSort).slice(0, 16) } : {}) } }) return { root: canonicalRoot, records, filesScanned: parsed.length, filesSkipped, bytesRead, diff --git a/packages/context/THIRD_PARTY_NOTICES.md b/packages/context/THIRD_PARTY_NOTICES.md index 4cefd89..3f0cf0e 100644 --- a/packages/context/THIRD_PARTY_NOTICES.md +++ b/packages/context/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -These notices accompany `@forgesworn/context@0.3.2`. The package itself is +These notices accompany `@forgesworn/context@0.3.3`. The package itself is MIT licensed, Copyright (c) 2026 TheCryptoDonkey; see `LICENSE`. Runtime dependencies are installed separately by the package manager, not diff --git a/packages/context/package.json b/packages/context/package.json index d3dc6f7..1a019b4 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/context", - "version": "0.3.2", + "version": "0.3.3", "description": "Z1P Core portable signed and encrypted context collections", "license": "MIT", "type": "module",