From 8dfb7a7f2d9e24bde8b1fef7f887e094ad438c71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 3 Sep 2026 09:56:42 +0000 Subject: [PATCH] chore: sync from vibgrate-cli monorepo (v2026.903.1) Outbound mirror of packages/vibgrate-cli-public. Passed the public-surface leak gate. --- action.yml | 2 +- charts/vibgrate/Chart.yaml | 2 +- package.json | 2 +- releases/v2026.903.1.md | 50 ++ src/cli.ts | 9 +- src/code/agent.ts | 3 + src/code/capsule.ts | 8 +- src/code/context.ts | 4 + src/code/session.ts | 5 +- src/commands/ask.ts | 12 +- src/commands/build.ts | 17 + src/commands/module.ts | 31 +- src/commands/show.ts | 14 +- src/engine/artifacts.test.ts | 28 + src/engine/artifacts.ts | 8 + src/engine/build.ts | 4 + src/engine/duties-inherit.test.ts | 90 +++ src/engine/duties-inherit.ts | 124 ++++ src/engine/duties.test.ts | 235 +++++++ src/engine/duties.ts | 604 ++++++++++++++++++ src/engine/effects.test.ts | 264 ++++++++ src/engine/effects.ts | 246 +++++++ src/engine/haile/adapter.ts | 110 ++++ .../haile/architecture-callables.test.ts | 75 +++ src/engine/haile/architecture-callables.ts | 86 +++ src/engine/haile/classify.ts | 22 + src/engine/haile/create-provider.ts | 9 + src/engine/haile/extract-count.test.ts | 29 + src/engine/haile/extract-count.ts | 56 ++ src/engine/haile/findings.test.ts | 38 ++ src/engine/haile/format.ts | 59 ++ src/engine/haile/haile-provider.ts | 114 ++++ src/engine/haile/haile.test.ts | 180 ++++++ src/engine/haile/index.ts | 31 + src/engine/haile/intent.ts | 11 + src/engine/haile/role-preference.test.ts | 88 +++ src/engine/haile/role-preference.ts | 123 ++++ src/engine/haile/sidecar.ts | 140 ++++ src/engine/haile/types.ts | 155 +++++ src/engine/haile/wasm-kernel.ts | 11 + src/engine/parse.ts | 56 +- src/engine/query.ts | 18 +- src/engine/resolve.ts | 2 + src/engine/types.ts | 6 + src/install/haile-module.test.ts | 165 +++++ src/install/haile-module.ts | 133 ++++ src/install/module-core.ts | 29 +- src/install/update-modules.test.ts | 113 ++++ src/install/update-modules.ts | 123 ++++ src/lsp/graph-query.test.ts | 42 ++ src/lsp/graph-query.ts | 28 +- src/lsp/server.ts | 51 ++ src/mcp/tools.ts | 7 +- src/reporting/commands/update.ts | 49 +- src/schema.ts | 23 + src/version.ts | 2 +- 56 files changed, 3907 insertions(+), 39 deletions(-) create mode 100644 releases/v2026.903.1.md create mode 100644 src/engine/duties-inherit.test.ts create mode 100644 src/engine/duties-inherit.ts create mode 100644 src/engine/duties.test.ts create mode 100644 src/engine/duties.ts create mode 100644 src/engine/effects.test.ts create mode 100644 src/engine/effects.ts create mode 100644 src/engine/haile/adapter.ts create mode 100644 src/engine/haile/architecture-callables.test.ts create mode 100644 src/engine/haile/architecture-callables.ts create mode 100644 src/engine/haile/classify.ts create mode 100644 src/engine/haile/create-provider.ts create mode 100644 src/engine/haile/extract-count.test.ts create mode 100644 src/engine/haile/extract-count.ts create mode 100644 src/engine/haile/findings.test.ts create mode 100644 src/engine/haile/format.ts create mode 100644 src/engine/haile/haile-provider.ts create mode 100644 src/engine/haile/haile.test.ts create mode 100644 src/engine/haile/index.ts create mode 100644 src/engine/haile/intent.ts create mode 100644 src/engine/haile/role-preference.test.ts create mode 100644 src/engine/haile/role-preference.ts create mode 100644 src/engine/haile/sidecar.ts create mode 100644 src/engine/haile/types.ts create mode 100644 src/engine/haile/wasm-kernel.ts create mode 100644 src/install/haile-module.test.ts create mode 100644 src/install/haile-module.ts create mode 100644 src/install/update-modules.test.ts create mode 100644 src/install/update-modules.ts diff --git a/action.yml b/action.yml index d6129a2..55fa324 100644 --- a/action.yml +++ b/action.yml @@ -46,7 +46,7 @@ inputs: image-tag: description: 'Scanner image tag to run (defaults to a pinned, tested release).' required: false - default: '2026.829.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.903.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs verify: description: 'Verify the image cosign signature + provenance before running (requires cosign on the runner).' required: false diff --git a/charts/vibgrate/Chart.yaml b/charts/vibgrate/Chart.yaml index 797a6f1..4c7efc1 100644 --- a/charts/vibgrate/Chart.yaml +++ b/charts/vibgrate/Chart.yaml @@ -7,7 +7,7 @@ type: application # stamped to the released @vibgrate/cli calendar version by # scripts/stamp-release-pins.mjs (via the marker on the appVersion line below). version: 0.1.1 -appVersion: "2026.829.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +appVersion: "2026.903.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs home: https://vibgrate.com icon: https://vibgrate.com/web-app-manifest-512x512.png sources: diff --git a/package.json b/package.json index fb86557..c1c38ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.829.1", + "version": "2026.903.1", "description": "vg — local codebase intelligence CLI + MCP server for AI coding agents: deterministic code graph, drift reporting, and version-correct library docs (Apache-2.0)", "//mcpName": "Official MCP registry ownership proof: the registry fetches the published npm package and requires this field to match the com.vibgrate/ai-context server entry (see docs/marketing/mcp-registry/README.md). Must ship in the published @vibgrate/cli package.json.", "mcpName": "com.vibgrate/ai-context", diff --git a/releases/v2026.903.1.md b/releases/v2026.903.1.md new file mode 100644 index 0000000..2bac1d0 --- /dev/null +++ b/releases/v2026.903.1.md @@ -0,0 +1,50 @@ +# Vibgrate CLI 2026.903.1 + +_Released 2026-09-03_ + +This release of the Vibgrate CLI introduces several new features and improvements focused on architecture classification, module management, and callable analysis. Key updates include enhanced callable descriptions, a new duty list reconstruction in `vg build`, and improved module update checks. + +## What changed + +### New + +- An extract-count gate compares fixture gold against `vg build` callable counts. +- Architecture classification installs by default with local modules, with warnings printed on failures. +- `vg update` now checks optional local modules against the registry and updates them if newer versions are available. +- The language server now carries architecture module classifications to editors, enhancing context for callables. +- `vg build` reconstructs an ordered duty list for every callable from its syntax tree. +- `vg build` records what each callable's body calls, providing an effects profile for better analysis. +- When the architecture module classifies the map, various tools now prioritize application shape symbols. + +### Changed + +- Architecture classification now explains what a callable does using its domain-specific language. + +## Benchmarks + +Two-arm benchmark of this release against 2026.829.1, interleaved on one runner against the pinned corpus (187 metrics compared). + +| Metric | Previous | This release | +| --- | --- | --- | +| Languages with extraction | 19 count | 19 count | +| Definitions extracted (corpus total) | 22334 count | 22334 count | +| Call edges extracted (corpus total) | 12367 count | 12367 count | +| Locate accuracy (top-1) | 0.95 ratio | 0.95 ratio | +| Dependency detection (authored manifest truth) | 0.96 ratio | 0.96 ratio | +| CLI startup (--version, median) | 570.20 ms | 567.30 ms | + +3 regression(s) — published, not omitted: +- Token reduction vs baseline agent (equal success): 0.20 → 0.17 (-15.2%) +- Tasks passed on both arms: 36 → 35 (-2.8%) +- Comparable-task rate (both arms passed / total): 0.95 → 0.92 (-2.8%) + +Full report and methodology: https://vibgrate.com/cli/benchmarks + +## Install or update + +```sh +npm install -g @vibgrate/cli +vg +``` + +Full changelog: https://vibgrate.com/changelog/cli/2026.903.1 diff --git a/src/cli.ts b/src/cli.ts index feef6d3..af99547 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,6 +60,7 @@ import { updateCommand } from './reporting/commands/update.js'; import { sbomCommand } from './reporting/commands/sbom.js'; import { evidenceCommand } from './reporting/commands/evidence/index.js'; import { kickRelevanceReadiness } from './install/relevance-module.js'; +import { kickHaileReadiness } from './install/haile-module.js'; /** The set of registered subcommand names (kept in sync with registration). */ export const KNOWN_COMMANDS = new Set([ @@ -340,7 +341,13 @@ export async function main(argv = process.argv): Promise { // VIBGRATE_NO_KERNEL / a recorded decline / --local skip it entirely. // Both flags suppress it: `--offline` says "no network", and `--local` // implies that. Read from raw argv because this runs before commander parses. - if (!raw.includes('--local') && !raw.includes('--offline')) kickRelevanceReadiness(); + if (!raw.includes('--local') && !raw.includes('--offline')) { + kickRelevanceReadiness(); + // Same posture for the architecture-classify module: installed by default, + // silently, in the background. `vg build` runs the bounded ensure and owns + // the user-visible warning when the module cannot be provisioned. + kickHaileReadiness(); + } // We need cwd for path-based dispatch; read -C/--cwd from the raw args. const cwd = readCwd(raw); diff --git a/src/code/agent.ts b/src/code/agent.ts index 50ac18a..930bc64 100644 --- a/src/code/agent.ts +++ b/src/code/agent.ts @@ -21,6 +21,7 @@ */ import { buildCodeContext } from './context.js'; +import { loadRoleMap } from '../engine/haile/role-preference.js'; import { rankQuestion, type SanitizedRank } from '../engine/relevance-provider.js'; import { loadTopicTags } from '../engine/relevance-enrich.js'; import { @@ -751,6 +752,7 @@ export async function runAgent(options: AgentOptions): Promise { budget, files: options.files, ranked: nextRanked, + roles: loadRoleMap(root, session.graph.provenance?.corpusHash), readFile: (rel) => fsImpl.read(rel), repositoryId: repositoryIdFromRoot(root), provenance: { @@ -1422,6 +1424,7 @@ function buildAgentContext( budget, files, ranked, + roles: loadRoleMap(options.root, graph.provenance?.corpusHash), readFile, repositoryId: repositoryIdFromRoot(options.root), extraPinnedFacts: options.extraPinnedFacts, diff --git a/src/code/capsule.ts b/src/code/capsule.ts index e9fd4b1..97fbba6 100644 --- a/src/code/capsule.ts +++ b/src/code/capsule.ts @@ -43,7 +43,13 @@ export const TASK_CAPSULE_SCHEMA_VERSION = 'task-capsule/0' as const; * module-less fallback. Seed content with the module active matches * 2026.08.3 + the coding-prompt-corpus improvements; the recorded * relevanceVersion says which engine ranked this capsule. */ -export const CAPSULE_RANKING_VERSION = 'capsule-rank@2026.08.4' as const; +/** Bumped 2026.09.1: architecture-module role preference (engine/haile/ + * role-preference.ts). When a classify file bound to the graph exists, the + * ranked seeds re-order by a bounded lift — a controller / application_service + * / port rises by at most two places — and utilities the ask did not reach for + * are dropped; the role travels as a structured field, never in the rendered + * text. No classify file = 2026.08.4 behaviour exactly. */ +export const CAPSULE_RANKING_VERSION = 'capsule-rank@2026.09.1' as const; export const CAPSULE_COMPILER_ID = 'vg-task-capsule/0' as const; export interface BuildCapsuleOptions extends BuildContextOptions { diff --git a/src/code/context.ts b/src/code/context.ts index 61cdef4..db13a2f 100644 --- a/src/code/context.ts +++ b/src/code/context.ts @@ -13,6 +13,7 @@ import { extractLiteralNeedles, queryGraph } from '../engine/query.js'; import type { SanitizedRank } from '../engine/relevance-provider.js'; +import type { RoleMap } from '../engine/haile/role-preference.js'; import { indexFor } from '../engine/relations.js'; import { impactOf } from '../engine/impact.js'; import type { CodeContext } from './types.js'; @@ -33,6 +34,8 @@ export interface BuildContextOptions { * ordering AND the plain-language concept map. Absent → the mechanical * fallback ranks and the concept map is empty. */ ranked?: SanitizedRank | null; + /** Architecture-module roles (loadRoleMap) — orders seeds by role under the budget; absent → untouched. */ + roles?: RoleMap | null; } /** @@ -62,6 +65,7 @@ export function buildCodeContext(graph: VgGraph, instruction: string, options: B budget: Math.floor(budget * 0.6), limit: seedLimit * 2, ranked: options.ranked, + roles: options.roles, }); // Keep every candidate here: `--file` narrows below, and capping first would // discard in-scope symbols ranked outside the top `seedLimit`. The cap runs diff --git a/src/code/session.ts b/src/code/session.ts index 54e5d10..1030474 100644 --- a/src/code/session.ts +++ b/src/code/session.ts @@ -22,6 +22,7 @@ */ import * as fs from 'node:fs'; +import { loadRoleMap } from '../engine/haile/role-preference.js'; import * as path from 'node:path'; import { buildCodeContext } from './context.js'; import { rankQuestion, type SanitizedRank } from '../engine/relevance-provider.js'; @@ -266,8 +267,9 @@ function buildSessionContext( ): CodeContext { const budget = opts.budget; const files = opts.files; + const roles = loadRoleMap(opts.root, graph.provenance?.corpusHash); if (!opts.capsule) { - return buildCodeContext(graph, instruction, { budget, files, ranked: opts.ranked }); + return buildCodeContext(graph, instruction, { budget, files, ranked: opts.ranked, roles }); } const capsule = buildTaskCapsule(graph, instruction, { budget, @@ -275,6 +277,7 @@ function buildSessionContext( readFile: (rel) => opts.fsImpl.read(rel), repositoryId: repositoryIdFromRoot(opts.root), ranked: opts.ranked, + roles, }); return capsuleToCodeContext(capsule); } diff --git a/src/commands/ask.ts b/src/commands/ask.ts index 71f9f28..5988991 100644 --- a/src/commands/ask.ts +++ b/src/commands/ask.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { loadRoleMap } from '../engine/haile/role-preference.js'; import { queryGraph, queryGraphSemantic, type QueryResult } from '../engine/query.js'; import { rankQuestion } from '../engine/relevance-provider.js'; import { rankingAskFrom } from '../engine/user-ask.js'; @@ -97,6 +98,10 @@ export function registerAsk(program: Command): void { // silent on failure) — then rank, falling back mechanically when the // module still is not available. `--local` stays network-free. if (!global.offline) await ensureRelevanceModule(); + // Architecture-module roles (when `vg build` classified this map): the + // context builders open with controllers / services / ports and leave + // unasked-for utilities out. Null when there is no classify file. + const roles = loadRoleMap(root, graph.provenance?.corpusHash); const topicTags = await loadTopicTags(graph, root, resolveGraphPath(root, global.graph)); const modRank = await rankQuestion(graph, rankingAskFrom(q), { limit: 48, topicTags }); @@ -123,6 +128,7 @@ export function registerAsk(program: Command): void { if (ranked) { live?.set('answering from the daemon index…'); result = await queryGraphSemantic(graph, q, { + roles, budget, semanticRanked: ranked.ranked, ranked: modRank, @@ -153,18 +159,18 @@ export function registerAsk(program: Command): void { const bar = !global.json ? new ProgressBar(c.dim('embedding')) : undefined; const vectors = await getNodeEmbeddings(graph, embedder, root, bar ? (d, t) => bar.update(d, t) : undefined); bar?.done(); - result = await queryGraphSemantic(graph, q, { budget, embedder, nodeVectors: vectors, ranked: modRank }); + result = await queryGraphSemantic(graph, q, { budget, embedder, nodeVectors: vectors, ranked: modRank, roles }); mode = `semantic (${embedder.id})`; activity.add('answer', 'ok', `answered in this process · ${embedder.id} · ${vectors.size} vector(s)`); } else { - result = queryGraph(graph, q, { budget, ranked: modRank }); + result = queryGraph(graph, q, { budget, ranked: modRank, roles }); note = reason ? unavailableMessage(reason) : 'semantic unavailable; used lexical'; if (detail) note += ` (${detail})`; activity.add('answer', 'warn', `lexical only — ${note}`); } } } else { - result = queryGraph(graph, q, { budget, ranked: modRank }); + result = queryGraph(graph, q, { budget, ranked: modRank, roles }); if (global.offline) note = 'semantic skipped under --offline; used lexical'; activity.add('answer', 'skip', global.offline ? 'lexical only (--offline)' : 'lexical only (--no-semantic)'); } diff --git a/src/commands/build.ts b/src/commands/build.ts index 1a82f69..4068174 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import { spawn } from 'node:child_process'; import { Command } from 'commander'; import { buildGraph } from '../engine/build.js'; +import { ensureHaileModule } from '../install/haile-module.js'; import { verifyDeterminism } from '../engine/verify.js'; import { epistemicBreakdown } from '../engine/epistemic.js'; import { signGraphAttestation, verifyGraphAttestation, type SignSummary } from './attest-actions.js'; @@ -151,6 +152,13 @@ export async function runBuild( } bar?.done(); + // Architecture classify is installed by default: run the bounded ensure now + // so this build can write the classify sidecar. A module that cannot be + // provisioned (or was declined / disabled) only costs the role/purpose + // lines — the build itself never waits on the network under --offline and + // never fails because of it. + const haile = global.offline ? null : await ensureHaileModule().catch(() => null); + const written = writeArtifacts(result.graph, { root, html: opts.html, @@ -158,6 +166,15 @@ export async function runBuild( graphPath: global.graph, }); + if (haile?.status === 'unavailable' && !global.json && !global.quiet) { + info( + c.yellow( + ' architecture module could not be installed — role/purpose lines are omitted; ' + + 'vg retries automatically (disable with VIBGRATE_NO_KERNEL=1)', + ), + ); + } + // Record the freshness snapshot (stat+hash per corpus file, plus this build's // scope) so `vg serve`/`vg ask` can auto-refresh the map when the tree drifts. // Skipped for a custom --graph target: that is an explicit artifact the diff --git a/src/commands/module.ts b/src/commands/module.ts index 119440e..8eb6228 100644 --- a/src/commands/module.ts +++ b/src/commands/module.ts @@ -14,14 +14,21 @@ import { installHcsModule, removeHcsModule, } from '../install/hcs-module.js'; +import { + HAILE_DISCLOSURE, + HAILE_MODULE_NAME, + haileModuleInstalled, + installHaileModule, + removeHaileModule, +} from '../install/haile-module.js'; import { type InstallOptions, type InstallResult, kernelDisabled, readConsent, writeConsent } from '../install/module-core.js'; import { loadRelevanceProvider, resetRelevanceProviderCache } from '../engine/relevance-provider.js'; import { loadHcsEngine, resetHcsEngineCache } from '../engine/hcs-provider.js'; +import { loadHaileProvider, resetHaileProviderCache } from '../engine/haile/haile-provider.js'; import { CliError, ExitCode, usageError } from '../util/exit.js'; import { c, info, json, out } from '../util/output.js'; import { readGlobal, applyGlobalOptions } from '../cli-options.js'; -/** Everything the command needs to manage one optional module. */ interface ManagedModule { npmName: string; disclosure: string; @@ -29,7 +36,6 @@ interface ManagedModule { remove(): void; installed(): { installed: boolean; version?: string }; resetLoader(): void; - /** Load through the seam; report the loaded engine/provider version. */ loadedVersion(): Promise; } @@ -58,19 +64,22 @@ const MODULES: Record = { return engine ? engine.version() : null; }, }, + haile: { + npmName: HAILE_MODULE_NAME, + disclosure: HAILE_DISCLOSURE, + install: installHaileModule, + remove: removeHaileModule, + installed: haileModuleInstalled, + resetLoader: resetHaileProviderCache, + loadedVersion: async () => { + const provider = await loadHaileProvider(); + return provider ? provider.version() : null; + }, + }, }; const SUPPORTED = Object.keys(MODULES).join(', '); -/** - * `vg module` — manage optional local modules (relevance, hcs). - * - * `install` fetches the module from the npm registry as a plain tarball, - * verifies its integrity, and unpacks it into the vibgrate cache — the user's - * project is never touched and nothing from the tarball executes at install - * time. `remove` deletes it. `status` reports what is installed and whether - * the seam can load it. All state is per-user, not per-repo. - */ export function registerModule(program: Command): void { const cmd = program.command('module').description(`manage optional local modules (${SUPPORTED})`); diff --git a/src/commands/show.ts b/src/commands/show.ts index 9754a7a..4e34175 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -7,6 +7,8 @@ import { applyGlobalOptions, readGlobal } from '../cli-options.js'; import { requireGraph, rootOf } from './util.js'; import { ambiguityError } from './ambiguity.js'; import { c, info, json } from '../util/output.js'; +import { resolveGraphPath } from '../engine/artifacts.js'; +import { findHaileSymbol, formatHaileLines, haileJsonFields, readHaileSidecar } from '../engine/haile/index.js'; /** * `vg show ` (VG-CLI-SPEC §3.3) — the richest single-node view: what it @@ -21,7 +23,7 @@ export function registerShow(program: Command): void { .option('--pick ', 'pick the nth candidate when ambiguous') .action(function (this: Command, name: string, opts: { pick?: string }) { const global = readGlobal(this); - const { graph } = requireGraph(global); + const { root, graph } = requireGraph(global); const { node, candidates } = resolveOne(graph, name, opts.pick ? Number(opts.pick) : undefined); if (!node) { @@ -37,6 +39,12 @@ export function registerShow(program: Command): void { const extendsEdges = index.out(node.id, 'extends').concat(index.out(node.id, 'implements')); const supertypes = extendsEdges.map((e) => index.node(e.dst)?.qualifiedName).filter(Boolean); const area = graph.areas.find((a) => a.id === node.area); + const haile = findHaileSymbol( + readHaileSidecar(resolveGraphPath(root, global.graph), { + corpusHash: graph.provenance?.corpusHash, + }), + node.id, + ); // `show` is the CLI twin of the MCP `get_node` tool — record it under that // shared name (source `cli`) when an AI host identified itself. Baseline = @@ -79,6 +87,7 @@ export function registerShow(program: Command): void { calls: callees.map((n) => n.qualifiedName), calledBy: callers.map((n) => n.qualifiedName), extends: supertypes, + haile: haileJsonFields(haile) ?? null, }); return; } @@ -89,6 +98,9 @@ export function registerShow(program: Command): void { info( ` importance ${node.importance.toFixed(3)}${node.isHub ? c.yellow(' ★ hub') : ''} · area #${node.area}${area ? ` ${c.dim(area.label)}` : ''}`, ); + if (haile) { + for (const line of formatHaileLines(haile)) info(line); + } if (supertypes.length) info(` ${c.dim('extends:')} ${supertypes.join(', ')}`); info(` ${c.dim('calls')} (${callees.length}): ${callees.slice(0, 12).map((n) => n.qualifiedName).join(', ') || '—'}`); info(` ${c.dim('called by')} (${callers.length}): ${callers.slice(0, 12).map((n) => n.qualifiedName).join(', ') || '—'}`); diff --git a/src/engine/artifacts.test.ts b/src/engine/artifacts.test.ts index bd7f5b8..d52faba 100644 --- a/src/engine/artifacts.test.ts +++ b/src/engine/artifacts.test.ts @@ -145,3 +145,31 @@ describe('writeArtifacts global store', () => { } }); }); + +describe('HAILE classify file via writeArtifacts', () => { + it('gitignores graph.haile.json and does not write it when the module is absent', () => { + const root = tmpDir(); + const prev = process.env.VIBGRATE_GRAPH_IN_REPO; + const prevNo = process.env.VIBGRATE_NO_KERNEL; + const prevPath = process.env.VIBGRATE_HAILE_PATH; + process.env.VIBGRATE_GRAPH_IN_REPO = '1'; + process.env.VIBGRATE_NO_KERNEL = '1'; + delete process.env.VIBGRATE_HAILE_PATH; + try { + const written = writeArtifacts(tinyGraph(), { root, html: false, report: false }); + const classifyFile = written.graphPath.replace(/\.json$/, '.haile.json'); + expect(classifyFile).toBe(path.join(root, '.vibgrate', 'graph.haile.json')); + const gi = fs.readFileSync(path.join(root, '.vibgrate', '.gitignore'), 'utf8'); + expect(gi.split(/\r?\n/)).toContain('graph.haile.json'); + // Public CLI must not classify in-process. Missing module → no file, build still succeeds. + expect(fs.existsSync(classifyFile)).toBe(false); + } finally { + if (prev === undefined) delete process.env.VIBGRATE_GRAPH_IN_REPO; + else process.env.VIBGRATE_GRAPH_IN_REPO = prev; + if (prevNo === undefined) delete process.env.VIBGRATE_NO_KERNEL; + else process.env.VIBGRATE_NO_KERNEL = prevNo; + if (prevPath === undefined) delete process.env.VIBGRATE_HAILE_PATH; + else process.env.VIBGRATE_HAILE_PATH = prevPath; + } + }); +}); diff --git a/src/engine/artifacts.ts b/src/engine/artifacts.ts index 85a839c..9b6642e 100644 --- a/src/engine/artifacts.ts +++ b/src/engine/artifacts.ts @@ -7,6 +7,7 @@ import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import { globalGraphPath, globalGraphPathForRef, repositoryStoreDir } from '../runtime/paths.js'; import { deleteTagsSidecarFor } from './relevance-enrich.js'; +import { deleteHaileSidecarFor, writeHaileSidecarFor } from './haile/index.js'; import { detectGitRef } from '../runtime/git-ref.js'; import type { VgGraph } from '../schema.js'; @@ -146,6 +147,7 @@ const DEFAULT_GITIGNORE = [ 'graph.json', 'graph.snap', 'graph.tags.snap', + 'graph.haile.json', // Binary semantic index next to the map (never model-named; never under cache/). 'embeddings', 'graph.html', @@ -240,6 +242,7 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr // graph (the sidecar would also self-invalidate via corpusHash, but a // removed/replaced graph must take its derived artifacts with it). deleteTagsSidecarFor(graphPath); + deleteHaileSidecarFor(graphPath); if (storeMode) { if (!writeGraphSnapshot(graphPath, graph, { standalone: true })) { @@ -291,6 +294,11 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr written.factsPath = factsPath; } + // HAILE sidecar is derived and must not enter graph.json. Best-effort: + // a classify fault never fails the build. --max-privacy / --no-graph never + // reach writeArtifacts (see shouldBuildCodeMap). + writeHaileSidecarFor(graph, graphPath); + return written; } diff --git a/src/engine/build.ts b/src/engine/build.ts index e700fd2..8b3bb5a 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import { discover, mergeExcludes, type DiscoveredFile } from './discover.js'; import { parseFiles } from './pool.js'; import { resolve } from './resolve.js'; +import { inheritDuties } from './duties-inherit.js'; import { buildModuleResolver } from './module-resolver.js'; import { assembleTsResult, tsWalkFiles, type TsFilePartial } from './ts-resolver.js'; import { decodeScipIndex, scipEdges } from './scip.js'; @@ -300,6 +301,9 @@ export async function buildGraph(options: BuildOptions): Promise { timer.start('resolve'); const moduleResolver = buildModuleResolver(root, new Set(parses.map((p) => p.rel))); const resolved = resolve(parses, moduleResolver); + // Duties a callee performs become purposes of its callers (1–2 hops): a + // delegating one-liner controller still reports the write behind it. + inheritDuties(resolved.nodes, resolved.edges); timer.end('resolve'); checkMemoryBudget('resolve', limits.memoryBudgetMb); diff --git a/src/engine/duties-inherit.test.ts b/src/engine/duties-inherit.test.ts new file mode 100644 index 0000000..2212dd5 --- /dev/null +++ b/src/engine/duties-inherit.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { inheritDuties } from './duties-inherit.js'; +import type { GraphEdge, GraphNode } from '../schema.js'; + +/** + * A delegating one-liner controller inherits the write behind it as a + * purpose (hop 1), never as a role. Typed delegations resolve by the + * declared type; MediatR-style sends resolve by the command's handler. + */ +function node(id: string, qualifiedName: string, file: string, duties?: GraphNode['duties']): GraphNode { + return { + id, + kind: 'method', + name: qualifiedName.split('.').pop()!, + qualifiedName, + file, + span: { start: 1, end: 5 }, + lang: 'cs', + importance: 0, + centrality: { degree: 0, betweenness: 0, pagerank: 0 } as never, + area: -1, + isHub: false, + tested: null, + ...(duties ? { duties } : {}), + } as GraphNode; +} +const edge = (src: string, dst: string): GraphEdge => ({ id: `${src}-${dst}`, kind: 'call', src, dst, resolution: 'heuristic', confidence: 0.8 }) as GraphEdge; + +describe('inheritDuties', () => { + it('follows call edges one and two hops, damping via hop, purpose only', () => { + const nodes = [ + node('c', 'ProductsController.Create', 'Api/ProductsController.cs', [{ k: 'delegate', o: 'CreateProductDto', via: 'IProductService.Create', live: true, line: 3 }]), + node('s', 'ProductService.Create', 'App/ProductService.cs', [{ k: 'delegate', via: 'IProductRepository.AddAsync', live: true, line: 8 }]), + node('r', 'ProductRepository.AddAsync', 'Infra/ProductRepository.cs', [{ k: 'persist', o: 'Product', via: '_context.Products.Add', t: 'ApplicationDbContext', live: true, line: 12 }]), + ]; + inheritDuties(nodes, [edge('c', 's'), edge('s', 'r')]); + const controller = nodes[0]!.duties!; + // `via` names what the caller itself calls; the hop count says how far + // behind it the write sits. + expect(controller.map((d) => [d.k, d.via, d.hop])).toEqual([ + ['delegate', 'IProductService.Create', undefined], + ['persist', 'ProductService.Create', 2], + ]); + expect(nodes[1]!.duties!.find((d) => d.hop === 1)).toMatchObject({ k: 'persist', o: 'Product', via: 'ProductRepository.AddAsync', t: 'ApplicationDbContext' }); + }); + + it('resolves a typed delegation without a call edge, stripping the interface I', () => { + const nodes = [ + node('c', 'ProductsController.Create', 'Api/ProductsController.cs', [{ k: 'delegate', o: 'CreateProductDto', via: 'IProductService.Create', live: true, line: 3 }]), + node('s', 'ProductService.Create', 'App/ProductService.cs', [{ k: 'persist', o: 'Product', via: 'repo.AddAsync', live: true, line: 8 }]), + ]; + inheritDuties(nodes, []); + expect(nodes[0]!.duties!.at(-1)).toMatchObject({ k: 'persist', o: 'Product', via: 'ProductService.Create', hop: 1 }); + }); + + it('resolves a MediatR send to the command handler', () => { + const nodes = [ + node('c', 'ProductsController.Create', 'Api/ProductsController.cs', [{ k: 'delegate', o: 'CreateProductCommand', via: '_mediator.Send', live: true, line: 3 }]), + node('h', 'CreateProductCommandHandler.Handle', 'App/CreateProductCommand.cs', [{ k: 'persist', o: 'Product', via: '_context.Products.Add', live: true, line: 9 }]), + ]; + inheritDuties(nodes, []); + expect(nodes[0]!.duties!.at(-1)).toMatchObject({ k: 'persist', o: 'Product', via: 'CreateProductCommandHandler.Handle', hop: 1 }); + }); + + it('never inherits dead, failure-path, log or delegate duties, and never from itself', () => { + const nodes = [ + node('a', 'A.run', 'a.ts', [{ k: 'delegate', via: 'B.go', live: true, line: 1 }]), + node('b', 'B.go', 'b.ts', [ + { k: 'persist', o: 'X', via: 'repo.save', live: false, line: 2 }, + { k: 'log', via: 'logger.info', live: true, line: 3 }, + { k: 'http', o: '/x', via: 'client.get', live: false, g: 'catch', line: 4 }, + { k: 'delegate', via: 'C.z', live: true, line: 5 }, + ]), + ]; + inheritDuties(nodes, [edge('a', 'b'), edge('b', 'b')]); + expect(nodes[0]!.duties).toHaveLength(1); + }); + + it('is deterministic and capped', () => { + const many = Array.from({ length: 12 }, (_, i) => node(`s${i}`, `S${i}.do`, `s${i}.ts`, [{ k: 'persist', o: `T${i}`, via: 'repo.save', live: true, line: 1 }])); + const caller = node('c', 'C.run', 'c.ts', []); + const edges = many.map((n) => edge('c', n.id)); + const a = [caller, ...many]; + inheritDuties(a, edges); + const b = [node('c', 'C.run', 'c.ts', []), ...many.map((n) => ({ ...n, duties: n.duties!.filter((d) => !d.hop) }))]; + inheritDuties(b, edges); + expect(a[0]!.duties).toHaveLength(8); + expect(JSON.stringify(a[0]!.duties)).toBe(JSON.stringify(b[0]!.duties)); + }); +}); diff --git a/src/engine/duties-inherit.ts b/src/engine/duties-inherit.ts new file mode 100644 index 0000000..e40b72e --- /dev/null +++ b/src/engine/duties-inherit.ts @@ -0,0 +1,124 @@ +/** + * Interprocedural inherit for the duty IR — one to two hops along the call + * edges the graph already stores. + * + * A controller whose whole body is `return _products.Create(dto);` has no + * store call of its own, so its own duties say nothing about persistence. + * The write lives one call away. This pass copies a callee's *own* live + * duties (persist / query / http / publish / fs / auth / crypto / cache) + * onto each caller as `hop: 1`, and a callee's hop-1 duties as `hop: 2`, + * with `via` naming the callee. Purpose only, never role: the kernel scores + * an inherited duty at half weight per hop, and the caller's role stays + * where its names put it. Bounded: 8 inherited duties per caller, two hops, + * deterministic order. + */ +import type { GraphEdge, GraphNode } from '../schema.js'; +import type { Duty } from './duties.js'; + +const INHERITABLE = new Set(['persist', 'query', 'http', 'publish', 'fs', 'auth', 'crypto', 'cache']); +const CALLABLE = new Set(['function', 'method', 'route', 'component', 'job', 'test']); +const MAX_INHERITED = 8; +const MAX_HOPS = 2; + +export function inheritDuties(nodes: GraphNode[], edges: GraphEdge[]): void { + const byId = new Map(); + for (const n of nodes) if (CALLABLE.has(n.kind)) byId.set(n.id, n); + const callees = new Map(); + for (const e of edges) { + if (e.kind !== 'call' || e.src === e.dst) continue; + if (!byId.has(e.src) || !byId.has(e.dst)) continue; + let list = callees.get(e.src); + if (!list) { + list = []; + callees.set(e.src, list); + } + if (!list.includes(e.dst)) list.push(e.dst); + } + // Typed delegations the edge resolver could not follow: a duty `via` + // `ProductService.Create` (or `IProductService.Create`) names a callable; + // a MediatR-style `Send(CreateProductCommand)` names `CreateProductCommandHandler.Handle`. + const byQualified = new Map(); + const handlerByClass = new Map(); + for (const n of byId.values()) { + if (!byQualified.has(n.qualifiedName)) byQualified.set(n.qualifiedName, n.id); + const segs = n.qualifiedName.split('.'); + if (segs.length >= 2 && /^(?:Handle|handle|HandleAsync|Execute|execute|ExecuteAsync|__call__|run|Run|invoke|Invoke)$/.test(segs[segs.length - 1]!)) { + const cls = segs[segs.length - 2]!; + if (!handlerByClass.has(cls)) handlerByClass.set(cls, n.id); + } + } + for (const n of byId.values()) { + for (const d of n.duties ?? []) { + if (d.k !== 'delegate' || d.hop) continue; + const targets: string[] = []; + if (d.via) { + const [cls, method] = d.via.split('.'); + if (cls && method) { + for (const c of [cls, cls.replace(/^I(?=[A-Z])/, ''), `${cls}Impl`]) { + const id = byQualified.get(`${c}.${method}`); + if (id) targets.push(id); + } + } + } + if (d.o && /mediator|sender|bus|dispatch/i.test(d.via ?? '')) { + for (const c of [`${d.o}Handler`, d.o.replace(/(?:Command|Query|Request)$/, '') + 'Handler']) { + const id = handlerByClass.get(c); + if (id) targets.push(id); + } + } + if (!targets.length) continue; + let list = callees.get(n.id); + if (!list) { + list = []; + callees.set(n.id, list); + } + for (const t of targets) if (t !== n.id && !list.includes(t)) list.push(t); + } + } + if (!callees.size) return; + + // Own duties are the seed; each hop reads the previous hop's result only, + // so a cycle cannot feed on itself. + const layer = new Map(); + for (const n of byId.values()) { + const own = (n.duties ?? []).filter((d) => !d.hop && d.live && INHERITABLE.has(d.k)); + if (own.length) layer.set(n.id, own); + } + const added = new Map(); + let current = layer; + for (let hop = 1; hop <= MAX_HOPS; hop++) { + const next = new Map(); + const callerIds = [...callees.keys()].sort(); + for (const callerId of callerIds) { + const caller = byId.get(callerId)!; + const have = new Set((caller.duties ?? []).map((d) => `${d.k}|${d.via ?? ''}`)); + const out = added.get(callerId) ?? []; + for (const calleeId of [...callees.get(callerId)!].sort()) { + const src = current.get(calleeId); + if (!src) continue; + const callee = byId.get(calleeId)!; + for (const d of src) { + if (out.length >= MAX_INHERITED) break; + const key = `${d.k}|${callee.qualifiedName}`; + if (have.has(key)) continue; + have.add(key); + const inherited: Duty = { k: d.k, via: callee.qualifiedName, live: true, hop, line: 0 }; + if (d.o) inherited.o = d.o; + if (d.t) inherited.t = d.t; + out.push(inherited); + } + } + if (out.length) { + added.set(callerId, out); + const thisHop = out.filter((d) => d.hop === hop); + if (thisHop.length) next.set(callerId, thisHop); + } + } + current = next; + if (!current.size) break; + } + for (const [id, extra] of added) { + const n = byId.get(id)!; + n.duties = [...(n.duties ?? []), ...extra]; + } +} diff --git a/src/engine/duties.test.ts b/src/engine/duties.test.ts new file mode 100644 index 0000000..73c582f --- /dev/null +++ b/src/engine/duties.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; +import { parseSource } from './parse.js'; +import { fileBindings } from './duties.js'; +import type { Duty } from './duties.js'; + +/** + * Duty IR on real-shaped bodies. Each case parses a file with the real + * tree-sitter grammar and asserts the ordered duties a reader would name — + * and the ones that must not appear: a dead write after `return`, a + * catch-only log on the happy path, a fetch `Response.json()` mistaken for + * an HTTP response, MediatR `Send` mistaken for a publish. + */ + +async function dutiesOf(lang: string, file: string, source: string, name: string): Promise { + const parsed = await parseSource(file, lang, source); + const def = parsed.defs.find((d) => d.name === name); + if (!def) throw new Error(`no def ${name} in ${parsed.defs.map((d) => d.name).join(',')}`); + return def.duties ?? []; +} + +const kinds = (d: Duty[]): string[] => d.map((x) => x.k); + +describe('duties: typed receivers', () => { + it('reads an unconventionally named field as a store because of its declared type', async () => { + const src = ` +using System; +public class ProductsController { + private readonly IProductRepository catalog; + private readonly ILogger _logger; + public ProductsController(IProductRepository catalog, ILogger logger) { this.catalog = catalog; _logger = logger; } + + public async Task Create(CreateProductDto dto, CancellationToken ct) + { + var product = new Product(dto.Name); + await catalog.AddAsync(product, ct); + _logger.LogInformation("created"); + return CreatedAtAction(nameof(Get), new { id = product.Id }, product); + } +}`; + const d = await dutiesOf('cs', 'Api/ProductsController.cs', src, 'Create'); + expect(kinds(d)).toEqual(['persist', 'log', 'respond']); + expect(d[0]).toMatchObject({ k: 'persist', o: 'Product', via: 'catalog.AddAsync', live: true }); + expect(d[2]).toMatchObject({ k: 'respond', o: '201', live: true }); + }); + + it('binds a TypeScript constructor-injected collaborator by its type', async () => { + const src = ` +export class OrdersController { + constructor(private readonly orders: OrderRepository, private readonly bus: EventBus) {} + + async create(req: Request, res: Response): Promise { + const order = await this.orders.save(req.body); + this.bus.publish(new OrderCreated(order.id)); + res.status(201).json(order); + } +}`; + const d = await dutiesOf('ts', 'src/orders.controller.ts', src, 'create'); + expect(kinds(d)).toEqual(['persist', 'publish', 'respond']); + expect(d[1]).toMatchObject({ k: 'publish', o: 'OrderCreated' }); + expect(d[2]).toMatchObject({ k: 'respond', o: '201' }); + }); + + it('a variable spelled like a store but typed as an HTTP context is not a store', async () => { + const src = ` +public class Middleware { + public async Task Invoke(HttpContext context) + { + await context.Response.WriteAsync("ok"); + } +}`; + const d = await dutiesOf('cs', 'Middleware.cs', src, 'Invoke'); + expect(kinds(d)).not.toContain('persist'); + }); +}); + +describe('duties: control flow', () => { + it('a write after return is dead, a write under a dry-run guard carries the guard', async () => { + const src = ` +export class Sync { + constructor(private readonly repo: UserRepository) {} + async run(dryRun: boolean): Promise { + if (dryRun) return 0; + await this.repo.save({ id: 1 }); + return 1; + await this.repo.delete(1); + } + never(): void { + if (false) { this.repo.save({}); } + } +}`; + const run = await dutiesOf('ts', 'sync.ts', src, 'run'); + expect(run.map((d) => [d.k, d.live, d.g])).toEqual([ + ['persist', true, 'unless dryRun'], + ['persist', false, undefined], + ]); + const never = await dutiesOf('ts', 'sync.ts', src, 'never'); + expect(never).toEqual([expect.objectContaining({ k: 'persist', live: false, g: 'false' })]); + }); + + it('a catch-only log is the failure path, not the happy path', async () => { + const src = ` +def charge(self, order): + try: + self.gateway.post("/charges", order) + except Exception as exc: + self.logger.error("charge failed", exc) + raise +`; + const d = await dutiesOf('py', 'billing.py', src, 'charge'); + const http = d.find((x) => x.k === 'http'); + const log = d.find((x) => x.k === 'log'); + expect(http).toMatchObject({ live: true, o: '/charges' }); + expect(log).toMatchObject({ live: false, g: 'catch' }); + }); +}); + +describe('duties: what is not a duty', () => { + it('a fetch Response.json() is not an HTTP response being sent', async () => { + const src = ` +export async function fetchApi(endpoint: string, options: RequestInit = {}): Promise { + const res = await fetch(\`\${BASE}\${endpoint}\`, options); + if (!res.ok) throw new ApiError(res.status); + return res.json(); +}`; + const d = await dutiesOf('ts', 'api.ts', src, 'fetchApi'); + expect(kinds(d)).toContain('http'); + expect(kinds(d)).not.toContain('respond'); + }); + + it('MediatR Send is a delegation, Publish is a publish', async () => { + const src = ` +public class ProductsController { + private readonly IMediator _mediator; + public async Task Create(CreateProductCommand cmd) + { + var id = await _mediator.Send(cmd); + await _mediator.Publish(new ProductCreated(id)); + return Ok(id); + } +}`; + const d = await dutiesOf('cs', 'ProductsController.cs', src, 'Create'); + expect(kinds(d)).toEqual(['delegate', 'publish', 'respond']); + expect(d[1]).toMatchObject({ o: 'ProductCreated' }); + expect(d[2]).toMatchObject({ o: '200' }); + }); + + it('a service call is a delegation the inherit pass can follow', async () => { + const src = ` +public class ProductsController { + private readonly IProductService _products; + public Task Create(CreateProductDto dto) => _products.Create(dto); +}`; + const d = await dutiesOf('cs', 'ProductsController.cs', src, 'Create'); + expect(d).toEqual([expect.objectContaining({ k: 'delegate', via: 'IProductService.Create', o: 'CreateProductDto', t: 'IProductService' })]); + }); +}); + +describe('duties: languages', () => { + it('Python SQLAlchemy session writes and a password hash', async () => { + const src = ` +class UserService: + def __init__(self, db: AsyncSession): + self.db = db + + async def create(self, user_in: UserCreate) -> User: + user = User(email=user_in.email, hashed_password=get_password_hash(user_in.password)) + self.db.add(user) + await self.db.commit() + return user +`; + const d = await dutiesOf('py', 'app/services/user_service.py', src, 'create'); + expect(kinds(d)).toEqual(['auth', 'persist', 'persist']); + expect(d[1]).toMatchObject({ k: 'persist', o: 'User', via: 'db.add' }); + }); + + it('Prisma $transaction and a Java Spring repository save', async () => { + const ts = ` +export async function moveStock(prisma: PrismaClient, from: string, to: string) { + await prisma.$transaction([prisma.stock.update({ where: { id: from } }), prisma.stock.update({ where: { id: to } })]); +}`; + const d = await dutiesOf('ts', 'stock.ts', ts, 'moveStock'); + expect(kinds(d)).toContain('persist'); + const java = ` +@Service +public class ProductServiceImpl implements ProductService { + private final ProductRepository productRepository; + private final ApplicationEventPublisher publisher; + + @Override + public ProductDTO createProduct(CreateProductRequest request) { + if (productRepository.existsBySku(request.getSku())) { + throw new IllegalArgumentException("SKU exists"); + } + Product saved = productRepository.save(toEntity(request)); + publisher.publishEvent(new ProductCreated(saved.getId())); + return toDTO(saved); + } +}`; + const j = await dutiesOf('java', 'ProductServiceImpl.java', java, 'createProduct'); + expect(kinds(j)).toEqual(['query', 'persist', 'publish']); + expect(j[0]).toMatchObject({ k: 'query', o: 'Product' }); + expect(j[0]!.g).toBeUndefined(); + expect(j[1]).toMatchObject({ k: 'persist', o: 'Product', via: 'productRepository.save', g: 'unless existsBySku(…)' }); + expect(j[2]).toMatchObject({ k: 'publish', o: 'ProductCreated' }); + }); + + it('Ruby paren-less save! and render', async () => { + const src = ` +class PostsController < ApplicationController + def create + @post = Post.new(post_params) + @post.save! + render json: @post, status: :created + end +end +`; + const d = await dutiesOf('rb', 'app/controllers/posts_controller.rb', src, 'create'); + expect(kinds(d)).toContain('persist'); + }); +}); + +describe('fileBindings', () => { + it('reads C#, TS and Python declarations', () => { + const cs = fileBindings('private readonly IProductRepository _products; public X(ILogger logger) {}', 'cs'); + expect(cs.get('_products')).toBe('IProductRepository'); + expect(cs.get('products')).toBe('IProductRepository'); + expect(cs.get('logger')).toBe('ILogger'); + const ts = fileBindings('constructor(private readonly orders: OrderRepository, bus: EventBus) {}', 'ts'); + expect(ts.get('orders')).toBe('OrderRepository'); + expect(ts.get('bus')).toBe('EventBus'); + const py = fileBindings('def __init__(self, db: AsyncSession):\n self.db = db\n self.cache = RedisCache()', 'py'); + expect(py.get('db')).toBe('AsyncSession'); + expect(py.get('cache')).toBe('RedisCache'); + }); +}); diff --git a/src/engine/duties.ts b/src/engine/duties.ts new file mode 100644 index 0000000..f475003 --- /dev/null +++ b/src/engine/duties.ts @@ -0,0 +1,604 @@ +/** + * Duty IR — what a callable's body would do, statement by statement, + * reconstructed from its syntax tree. Nothing is compiled or run. + * + * Where `effects.ts` counts API tokens in the flattened text, this walks the + * tree-sitter call sites the extractor already captured and emits an ordered + * list of duties: + * + * validate SKU + * persist Product via SaveChangesAsync [live] + * publish ProductCreated via publishEvent [live] + * respond 201 [live] + * + * Three things the text scan could not know: + * + * 1. **What the receiver is.** `_products.Create(dto)` is a store write + * because the file declares `IProductRepository _products`, not because + * the identifier is spelled like a store. Bindings come from field, + * property and constructor-parameter declarations in the same file (and + * the graph's DI type refs); spelling is only the fallback. + * 2. **Whether the statement can run.** A call after `return` / `throw` in + * the same block is dead; a call inside `catch` / `except` / `rescue` is + * the failure path; a call under `if (dryRun) …` carries that guard; + * `if (false)` / `if (0)` is dead. This is one-function control flow + * only — no abstract interpretation, no cross-function facts. + * 3. **What the object is.** `_context.Products.Add(product)` persists a + * `Product` because of the `Products` set and the `product` argument's + * declared type; `publishEvent(new ProductCreated(id))` publishes a + * `ProductCreated`; `return Ok(dto)` responds `200`. + * + * Output is bounded (24 duties, short strings) and carries identifiers only — + * never a slice of source text longer than an identifier or a short guard. + */ +import type { Node } from 'web-tree-sitter'; + +export type DutyKind = + | 'validate' + | 'query' + | 'persist' + | 'http' + | 'respond' + | 'fs' + | 'auth' + | 'crypto' + | 'publish' + | 'consume' + | 'cache' + | 'log' + | 'render' + | 'delegate'; + +export interface Duty { + /** Closed duty kind. */ + k: DutyKind; + /** Domain object, when known (`Product`, `ProductCreated`, `201`). */ + o?: string; + /** API / collaborator the duty goes through (`SaveChangesAsync`, `ProductService.Create`). */ + via?: string; + /** Declared type of the receiver, when the file declares it (`IProductRepository`, `AsyncSession`). */ + t?: string; + /** Condition the duty sits under (`dryRun`, `unless !user`), or `catch`. */ + g?: string; + /** False when the site cannot run on the happy path (after return/throw, in catch, `if (false)`). */ + live: boolean; + /** 0 for the body's own sites; 1–2 for duties inherited from callees. */ + hop?: number; + /** 1-based line of the call site. */ + line: number; +} + +/** Declared types of identifiers in a file (fields, properties, ctor params). */ +export type Bindings = Map; + +const MAX_DUTIES = 24; +const MAX_GUARD = 40; + +// --------------------------------------------------------------------------- +// Receiver typing. +// --------------------------------------------------------------------------- + +type ReceiverClass = 'store' | 'http' | 'queue' | 'cache' | 'log' | 'auth' | 'crypto' | 'fs' | 'mediator' | 'response' | 'validator' | 'service' | 'unknown'; + +/** Known framework / library types by (suffix or exact) name, lowercase. */ +const TYPE_CLASS: Array<[RegExp, ReceiverClass]> = [ + [/(?:^|\.)(?:i?)(?:[a-z]*repositor(?:y|ies)|[a-z]*repo|[a-z]*dao|dbcontext|[a-z]*context$|dbset|[a-z]*session|asyncsession|prismaclient|prisma|knex|entitymanager|[a-z]*unitofwork|iqueryable|[a-z]*collection|mongo[a-z]*|[a-z]*datasource|jdbctemplate|sqlx[a-z]*|[a-z]*store|querybuilder|model|[a-z]*table|connection|pool)$/, 'store'], + [/(?:^|\.)(?:i?)(?:httpclient|[a-z]*httpclient|resttemplate|webclient|[a-z]*client|[a-z]*gateway|[a-z]*adapter|okhttpclient|axiosinstance|axios|fetch|[a-z]*apiclient|[a-z]*sdk|grpc[a-z]*|[a-z]*stub)$/, 'http'], + [/(?:^|\.)(?:i?)(?:[a-z]*publisher|[a-z]*producer|[a-z]*bus|[a-z]*queue|[a-z]*topic|[a-z]*channel|[a-z]*emitter|applicationeventpublisher|ipublishendpoint|[a-z]*mailer|[a-z]*sender|[a-z]*notifier|kafka[a-z]*|rabbit[a-z]*|sqs[a-z]*|sns[a-z]*|celery|[a-z]*dispatcher)$/, 'queue'], + [/(?:^|\.)(?:i?)(?:[a-z]*cache|imemorycache|idistributedcache|redis[a-z]*|memcached?)$/, 'cache'], + [/(?:^|\.)(?:i?)(?:[a-z]*logger|log|logging|[a-z]*tracer|[a-z]*metrics|telemetry[a-z]*)$/, 'log'], + [/(?:^|\.)(?:i?)(?:[a-z]*usermanager|signinmanager|[a-z]*passwordhasher|[a-z]*tokenservice|[a-z]*authservice|[a-z]*authenticator|[a-z]*jwt[a-z]*|passport|oauth[a-z]*|[a-z]*identityservice|[a-z]*credential[a-z]*)$/, 'auth'], + [/(?:^|\.)(?:i?)(?:[a-z]*cipher|[a-z]*hasher|[a-z]*crypto[a-z]*|[a-z]*encryptor|[a-z]*signer|messagedigest|randomnumbergenerator)$/, 'crypto'], + [/(?:^|\.)(?:i?)(?:[a-z]*filesystem|[a-z]*storage|[a-z]*blob[a-z]*|[a-z]*bucket|s3[a-z]*|filestream|streamreader|streamwriter|fs)$/, 'fs'], + [/(?:^|\.)(?:i?)(?:mediator|isender|[a-z]*commandbus|[a-z]*querybus)$/, 'mediator'], + [/(?:^|\.)(?:i?)(?:[a-z]*validator|validationcontext)$/, 'validator'], + [/(?:^|\.)(?:i?)(?:[a-z]*service|[a-z]*usecase|[a-z]*interactor|[a-z]*handler|[a-z]*manager|[a-z]*facade)$/, 'service'], +]; + +/** Same classes by the *spelling* of an untyped receiver (last resort). */ +const NAME_CLASS: Array<[RegExp, ReceiverClass]> = [ + [/^(?:_?(?:db|database|conn|connection|cur|cursor|session|tx|transaction|em|prisma|knex|orm|qs|queryset|objects|context|_context|dbcontext|ctx\.db|supabase|firestore|mongo|collection|table|store))$|(?:repositor(?:y|ies)|repo|dao|context|session|store|collection|model|entities)$/i, 'store'], + [/^(?:_?(?:http|httpclient|client|axios|api|apiclient|gateway|adapter|sdk|fetcher|requests|httpx|aiohttp))$|(?:client|gateway|adapter|api)$/i, 'http'], + [/^(?:_?(?:bus|queue|publisher|producer|emitter|events|eventbus|channel|topic|mailer|notifier|kafka|rabbit|sqs|sns|celery|sender|dispatcher))$|(?:bus|queue|publisher|producer|emitter|mailer|sender|dispatcher)$/i, 'queue'], + [/^(?:_?(?:cache|redis|memcache|memcached))$|cache$/i, 'cache'], + [/^(?:_?(?:log|logger|logging|console|tracer|metrics|telemetry|sentry))$|logger$/i, 'log'], + [/^(?:_?(?:mediator|sender|commandbus|querybus))$/i, 'mediator'], + [/^(?:res|response|reply|ctx\.res|w)$|(?:\.response|\.res)$/i, 'response'], + [/^(?:_?(?:validator|validation|schema|rules))$|validator$/i, 'validator'], + [/(?:service|usecase|interactor|handler|manager|facade)$/i, 'service'], +]; + +/** Contexts and sessions that are request plumbing, never a store. */ +const NOT_STORE = /(?:httpcontext|servletcontext|requestcontext|securitycontext|executioncontext|bindingcontext|validationcontext|actioncontext|filtercontext|hubcallercontext|synchronizationcontext|cancellationtoken|httpsession|websocketsession|clientsession|usersession|appcontext|applicationcontext|beancontext|springcontext|reactcontext|canvasrenderingcontext)$/; + +function classifyType(typeName: string): ReceiverClass { + const t = typeName.replace(/<.*$/, '').replace(/[?[\]*&]/g, '').toLowerCase(); + if (NOT_STORE.test(t)) return t.endsWith('httpcontext') ? 'response' : 'unknown'; + for (const [re, cls] of TYPE_CLASS) if (re.test(t)) return cls; + return 'unknown'; +} + +function classifyName(receiver: string): ReceiverClass { + const r = receiver.replace(/^(?:this|self|@|super)[.:>-]*/, ''); + for (const [re, cls] of NAME_CLASS) if (re.test(r)) return cls; + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Verb sets (reads / writes / responses …). +// --------------------------------------------------------------------------- + +const READ = /^(?:find\w*|get\w*|load\w*|read\w*|fetch\w*|query\w*|select\w*|where|filter|first\w*|single\w*|last\w*|all|count\w*|exists\w*|any\w*|aggregate|search\w*|list\w*|lookup\w*|scan|one|many|paginate|include|asnotracking|tolist\w*|toarray\w*|execute(?:query|reader)\w*|raw|exec|values|pluck|only|defer|selectinload|joinedload|find_by|find_each|get_object_or_404|from|of|fetchone|fetchall|queryrow\w*|querycontext)$/i; +const WRITE = /^(?:save\w*|insert\w*|create\w*|update\w*|upsert\w*|delete\w*|remove\w*|destroy\w*|persist\w*|merge|flush|commit|add\w*|attach|detach|entry|bulk_\w+|get_or_create|update_or_create|executeupdate|executenonquery|namedexec|mustexec|increment|decrement|set|put|write\w*|store|truncate|drop|sync|push|touch|delete_all|update_all|insert_all|upsert_all|find_or_create_by|update_attributes?|savechanges\w*|addrange\w*|removerange|\$transaction|transaction)$/i; +/** Unit-of-work verbs: a write with no object of its own. */ +const UOW = /^(?:savechanges(?:async)?|commit|flush|\$transaction|transaction|begin_transaction|begintransaction|save_changes)$/i; +/** `execute` / `query` / `raw`: a read unless the statement text says otherwise. */ +const AMBIGUOUS_EXEC = /^(?:execute|executeasync|exec|query|queryasync|raw|run|sql|statement|execute_query|executequery|executescalar|executeraw|queryraw|\$executeraw|\$queryraw|executesql|executesqlraw)$/i; +const SQL_WRITE_WORDS = /\b(?:insert|update|delete|merge|drop|alter|truncate|upsert|create\s+(?:table|index))\b/i; +const HTTP_VERB = /^(?:get|post|put|patch|delete|head|options|request|send|fetch|do|call|invoke|getasync|postasync|putasync|deleteasync|patchasync|postasjsonasync|putasjsonasync|getstringasync|getfromjsonasync|sendasync|getforobject|getforentity|postforobject|postforentity|exchange|execute|urlopen|newrequest|ajax)$/i; +const PUBLISH_VERB = /^(?:publish\w*|emit|enqueue|send\w*|dispatch|broadcast\w*|produce|push|add|notify\w*|raise\w*|fire\w*|trigger\w*|deliver\w*|perform_later|perform_async|delay|apply_async|sendmail|send_mail|deliver_now|deliver_later|publishevent)$/i; +const CACHE_VERB = /^(?:get\w*|set\w*|del|delete\w*|has|remember|fetch|wrap|clear|invalidate\w*|getorcreate\w*|getorset\w*|expire|ttl|incr|decr|write|read)$/i; +const LOG_VERB = /^(?:log\w*|info|warn\w*|error|debug|trace|fatal|critical|exception|record\w*|track\w*|capture\w*|increment|gauge|histogram|timing)$/i; +const AUTH_VERB = /^(?:authenticate\w*|login\w*|logout\w*|signin\w*|signout\w*|verify\w*|check\w*|validate\w*|hash\w*|compare\w*|issue\w*|create\w*token\w*|generate\w*token\w*|decode\w*|refresh\w*|authorize\w*|authorise\w*|getuser\w*|findbyname\w*|checkpassword\w*)$/i; +const CRYPTO_VERB = /^(?:encrypt\w*|decrypt\w*|hash\w*|sign\w*|verify\w*|digest\w*|random\w*|derive\w*|hmac\w*|createhash|createhmac|createcipher\w*|createdecipher\w*|sha\d*|md5)$/i; +const FS_VERB = /^(?:read\w*|write\w*|open\w*|exists\w*|delete\w*|copy\w*|move\w*|mkdir\w*|readdir\w*|unlink\w*|stat\w*|append\w*|createreadstream|createwritestream|upload\w*|download\w*|put\w*|get\w*|list\w*|walk|glob)$/i; +const VALIDATE_VERB = /^(?:validate\w*|parse\w*|safeparse|assert\w*|ensure\w*|check\w*|verify\w*|require\w*|guard\w*|rulefor|must\w*|is_valid|full_clean|invariant|precondition|validateandthrow\w*|validateasync)$/i; +const RESPOND_VERB = /^(?:ok|created|createdataction|createdatroute|nocontent|notfound|badrequest|unauthorized|forbid|accepted|conflict|problem|statuscode|json|view|partialview|redirect\w*|file|content|send\w*|status|end|write\w*|render|jsonify|jsonresponse|httpresponse\w*|writeheader|encode|string|html|text)$/i; +const RENDER_VERB = /^(?:render\w*|createelement|html|view|template|partial|component|mount|hydrate|build|setcontent)$/i; + +/** HTTP status code implied by a response verb. */ +const STATUS_OF: Record = { + ok: '200', json: '200', view: '200', content: '200', file: '200', send: '200', render: '200', jsonify: '200', text: '200', html: '200', string: '200', encode: '200', + created: '201', createdataction: '201', createdatroute: '201', + accepted: '202', nocontent: '204', redirect: '302', redirecttoaction: '302', + badrequest: '400', unauthorized: '401', forbid: '403', notfound: '404', conflict: '409', unprocessableentity: '422', problem: '500', +}; + +// --------------------------------------------------------------------------- +// Bindings: declared types of fields / properties / constructor params. +// --------------------------------------------------------------------------- + +const BINDING_PATTERNS: RegExp[] = [ + // C# / Java / Kotlin-ish fields and ctor params: `private readonly IFoo _foo;` `IFoo foo,` `IFoo foo)` + /\b(?:private|protected|public|internal|readonly|final|static|val|var|lateinit|@\w+)?\s*(?:readonly\s+)?([A-Z][\w.]*(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\??)\s+(_?[a-z]\w*)\s*(?:[;=,)]|\{\s*get)/g, + // TS / Kotlin / Swift / Dart / Scala: `private readonly foo: Foo` / `val foo: Foo` / `foo: Foo,` + /\b(?:private|protected|public|readonly|val|var|let|const|override)?\s*(?:readonly\s+)?(_?[a-z]\w*)\s*:\s*([A-Z][\w.]*(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\??)\s*(?:[;=,)]|$)/gm, + // Python: `self.foo = Foo(` / `self.foo: Foo` / `foo: Foo,` in __init__ + /\bself\.(\w+)\s*(?::\s*([A-Z]\w*)|=\s*([A-Z]\w*)\s*\()/g, + /\bdef\s+__init__\s*\(([^)]*)\)/g, + // PHP / Ruby: `private Foo $foo` / `@foo = Foo.new` + /\b(?:private|protected|public)\s+(?:readonly\s+)?\??([A-Z]\w*)\s+\$(\w+)/g, + /@(\w+)\s*=\s*([A-Z]\w*)\.new\b/g, +]; + +/** Declared types of identifiers in a file. First declaration wins. */ +export function fileBindings(text: string, langId: string): Bindings { + const out: Bindings = new Map(); + const put = (name: string, type: string): void => { + if (!name || !type || out.has(name)) return; + const t = type.replace(/\?$/, ''); + if (/^(?:string|int|long|bool|boolean|double|float|decimal|object|void|var|let|const|new|return|await|async|readonly|static|public|private|protected|internal|override|virtual|abstract|final|def|fn|function|class|interface|enum|struct|record|this|self|null|true|false|List|Dictionary|Map|Set|Array|Task|Promise|Optional|Result|Guid|DateTime|CancellationToken|Exception)$/.test(t)) return; + out.set(name, t); + // `this.foo` / `self.foo` / `_foo` / `foo` all name the same member. + out.set(name.replace(/^_/, ''), t); + }; + const clipped = text.length > 200_000 ? text.slice(0, 200_000) : text; + // Pattern 0: C#/Java style `Type name`. + for (const m of clipped.matchAll(BINDING_PATTERNS[0]!)) put(m[2]!, m[1]!); + // Pattern 1: `name: Type`. + for (const m of clipped.matchAll(BINDING_PATTERNS[1]!)) put(m[1]!, m[2]!); + if (langId === 'py') { + for (const m of clipped.matchAll(BINDING_PATTERNS[2]!)) put(m[1]!, m[2] ?? m[3] ?? ''); + for (const m of clipped.matchAll(BINDING_PATTERNS[3]!)) { + for (const part of m[1]!.split(',')) { + const mm = /^\s*(\w+)\s*:\s*([A-Z]\w*)/.exec(part); + if (mm) put(mm[1]!, mm[2]!); + } + } + } + if (langId === 'php') for (const m of clipped.matchAll(BINDING_PATTERNS[4]!)) put(m[2]!, m[1]!); + if (langId === 'rb') for (const m of clipped.matchAll(BINDING_PATTERNS[5]!)) put(m[1]!, m[2]!); + return out; +} + +/** Local `var x = new Foo()` / `Foo x = …` / `x: Foo = …` / `x = Foo(` inside a body. */ +function localTypes(bodyText: string): Bindings { + const out: Bindings = new Map(); + const put = (n: string, t: string): void => { + if (n && t && /^[A-Z]/.test(t) && !out.has(n)) out.set(n, t); + }; + for (const m of bodyText.matchAll(/\b(?:var|let|const|final|val)\s+(\w+)\s*(?::\s*([A-Z]\w*))?\s*=\s*(?:new\s+|await\s+)?([A-Z]\w*)?/g)) put(m[1]!, m[2] ?? m[3] ?? ''); + for (const m of bodyText.matchAll(/\b([A-Z]\w*(?:<[^>]*>)?)\s+(\w+)\s*=\s*(?:new\s+)?/g)) put(m[2]!, m[1]!.replace(/<.*$/, '')); + for (const m of bodyText.matchAll(/\b(\w+)\s*=\s*([A-Z]\w*)\s*\(/g)) put(m[1]!, m[2]!); + // `query = select(Product).where(…)`: the local carries the table it reads. + for (const m of bodyText.matchAll(/\b(\w+)\s*=\s*(?:select|query|from|Query)\s*[(<]\s*([A-Z]\w*)/g)) put(m[1]!, m[2]!); + return out; +} + +// --------------------------------------------------------------------------- +// Tree helpers. +// --------------------------------------------------------------------------- + +const CALL_TYPE = /call|invocation|message_expression|object_creation|new_expression|command|macro_invocation/; +const BLOCK_TYPE = /^(?:block|statement_block|body|statement_list|program|module|suite|compound_statement|do_block|then|else|else_clause|function_body|method_body|class_body|source_file|declaration_list|expression_statement_list|begin_block|block_body|control_structure_body|switch_section|case_clause|when_entry)$/; +const TERMINAL_TYPE = /^(?:return|throw|raise|panic|break|continue|exit|yield_break|goto|unreachable)(?:_statement|_expression)?$/; +const COND_TYPE = /^(?:if|if_statement|if_expression|conditional_expression|ternary_expression|unless|unless_statement|switch_statement|switch_expression|match_expression|match_statement|when_expression|when|case_statement|guard_statement|elif_clause|else_if_clause|conditional|if_modifier|unless_modifier)$/; +const CATCH_TYPE = /^(?:catch_clause|except_clause|rescue|rescue_clause|rescue_modifier|catch|finally_clause|finally|except_group_clause|on_part|catch_block)$/; +const LOOP_TYPE = /^(?:for|while|foreach|loop|do)_?(?:statement|expression|in_statement|each)?$/; + +function callNodeOf(callee: Node): Node | null { + let n: Node | null = callee; + for (let i = 0; i < 5 && n; i++) { + if (CALL_TYPE.test(n.type) && !/identifier|name$/.test(n.type)) return n; + n = n.parent; + } + return null; +} + +function argumentsText(call: Node): string { + const args = + call.childForFieldName('arguments') ?? + call.childForFieldName('argument_list') ?? + call.namedChildren.find((c) => c && /argument|arguments|args/.test(c.type)) ?? + null; + if (args) return args.text; + const open = call.text.indexOf('('); + return open >= 0 ? call.text.slice(open) : ''; +} + +/** Receiver expression text (`_context.Products`, `this.products`), or ''. */ +function receiverOf(call: Node, callee: Node): string { + const fn = call.childForFieldName('function') ?? call.childForFieldName('receiver') ?? call.childForFieldName('object') ?? null; + let head = ''; + if (fn && fn.type !== callee.type) head = fn.text; + else head = call.text.slice(0, Math.max(0, callee.startIndex - call.startIndex + callee.text.length)); + // Drop the trailing callee segment and any separator. + const idx = head.lastIndexOf(callee.text); + if (idx > 0) head = head.slice(0, idx).replace(/[.:>\-?!\s]+$/, ''); + else if (idx === 0) head = ''; + return head.replace(/\s+/g, '').slice(0, 80); +} + +function statementOf(call: Node, def: Node): Node { + let n: Node = call; + while (n.parent && n.parent.id !== def.id && !BLOCK_TYPE.test(n.parent.type)) n = n.parent; + return n; +} + +function shortCondition(node: Node): string { + const cond = node.childForFieldName('condition') ?? node.childForFieldName('value') ?? node.namedChildren[0] ?? null; + let raw = (cond?.text ?? node.text.split('\n')[0] ?? '').replace(/^\((.*)\)$/s, '$1').replace(/\s+/g, ' ').trim(); + // `productRepository.existsBySku(request.getSku())` → `existsBySku(…)`: + // a guard names the check, not the receiver chain or the arguments. + raw = raw.replace(/\(([^()]|\([^()]*\))*\)/g, '(…)').replace(/\b(?:this|self|@)[.:>-]*/g, '').replace(/\b[\w$]+(?:\.|->|::)(?=[\w$]+)/g, ''); + return raw.length > MAX_GUARD ? `${raw.slice(0, MAX_GUARD - 1)}…` : raw; +} + +function isConstantFalse(cond: string): boolean { + return /^(?:false|0|null|nil|None|False|!true|!1)$/.test(cond.trim()); +} + +/** Liveness and guard of a call site within its definition. */ +function controlFlow(call: Node, def: Node): { live: boolean; guard?: string } { + let live = true; + let guard: string | undefined; + // Ancestors: conditionals, catch clauses, `if (false)`. + let n: Node | null = call.parent; + let inCatch = false; + while (n && n.id !== def.id) { + if (CATCH_TYPE.test(n.type)) inCatch = true; + else if (COND_TYPE.test(n.type)) { + // The call may be the condition itself (`if (repo.exists(x))`): then it + // always runs, and this conditional guards nothing about it. + const condNode = n.childForFieldName('condition'); + if (condNode && condNode.startIndex <= call.startIndex && call.endIndex <= condNode.endIndex) { + n = n.parent; + continue; + } + const cond = shortCondition(n); + if (isConstantFalse(cond)) live = false; + // Is the call in the alternative branch of this conditional? + const alt = n.childForFieldName('alternative'); + const inAlt = alt != null && alt.startIndex <= call.startIndex && call.endIndex <= alt.endIndex; + if (!guard && cond) guard = inAlt ? `not ${cond}` : cond; + } + n = n.parent; + } + if (inCatch) return { live: false, guard: 'catch' }; + // Dead code: a terminal statement earlier in the same block. + const stmt = statementOf(call, def); + let prev = stmt.previousNamedSibling; + const earlyReturns: string[] = []; + while (prev) { + if (TERMINAL_TYPE.test(prev.type) || (prev.type === 'expression_statement' && prev.namedChildren[0] && TERMINAL_TYPE.test(prev.namedChildren[0].type))) { + live = false; + break; + } + // `if (x) return;` before the site: the site runs only when !x. + if (COND_TYPE.test(prev.type) && /\b(?:return|throw|raise|panic|exit)\b/.test(prev.text) && prev.text.length <= 160 && !prev.childForFieldName('alternative')) { + const cond = shortCondition(prev); + if (cond) earlyReturns.push(cond); + } + prev = prev.previousNamedSibling; + } + if (!guard && earlyReturns.length) guard = `unless ${earlyReturns[earlyReturns.length - 1]}`; + return { live, guard }; +} + +// --------------------------------------------------------------------------- +// Object (noun) extraction. +// --------------------------------------------------------------------------- + +function singular(word: string): string { + if (/ies$/.test(word)) return word.replace(/ies$/, 'y'); + if (/(?:ses|xes|ches|shes)$/.test(word)) return word.replace(/es$/, ''); + if (/s$/.test(word) && !/ss$/.test(word)) return word.replace(/s$/, ''); + return word; +} + +const PLUMBING = /^(?:CancellationToken|Request|Response|HttpContext|Context|Logger|ILogger|Session|Task|Promise|String|Int32|Int64|Long|Integer|Boolean|Guid|DateTime|Object|Any|Dict|List|Map|Set|Array|Exception|Error|Unit|Void|None|Type|Self|Cls|Args|Kwargs|Value|Data|Result|Ok|Err|Some|Query|Stmt|Statement|Sql|Cursor|Conn|Connection|Tx|Transaction|Db|Database)$/; + +/** `select(Product)` / `FROM products` / `Set`: the table a query reads. */ +function nounFromQuery(args: string): string | undefined { + const m = /\b(?:select|from|query|SelectMany|Set|Table|Query)\s*[(<]\s*([A-Z]\w*)/.exec(args) ?? /\bfrom\s+["'`]?([A-Za-z_]\w*)/i.exec(args); + if (!m) return undefined; + const raw = m[1]!; + const cap = raw[0]!.toUpperCase() + raw.slice(1); + return PLUMBING.test(cap) ? undefined : domainNoun(singular(cap)); +} + +/** Raw declared type of the first typed argument (`CreateProductCommand`), for handler lookup. */ +function rawTypeFromArgs(args: string, bindings: Bindings, locals: Bindings): string | undefined { + const inner = args.replace(/^\(|\)$/g, ''); + const ctor = /\bnew\s+([A-Z]\w*)|(?:^|[\s,(])([A-Z]\w*)\s*[({]/.exec(inner); + const ctorName = ctor?.[1] ?? ctor?.[2]; + if (ctorName && !PLUMBING.test(ctorName)) return ctorName; + for (const m of inner.matchAll(/(?:^|[\s,(])(?:this\.|self\.|@)?([a-z_]\w*)\b/g)) { + const t = (locals.get(m[1]!) ?? bindings.get(m[1]!))?.replace(/<.*$/, ''); + if (t && !PLUMBING.test(t)) return t; + } + return undefined; +} + +function nounFromArgs(args: string, bindings: Bindings, locals: Bindings): string | undefined { + const inner = args.replace(/^\(|\)$/g, ''); + // `new ProductCreated(…)` / `ProductCreated(…)` / `ProductCreated { … }` + const ctor = /\bnew\s+([A-Z]\w*)|(?:^|[\s,(])([A-Z]\w*)\s*[({]/.exec(inner); + const ctorName = ctor?.[1] ?? ctor?.[2]; + if (ctorName && !PLUMBING.test(ctorName)) return ctorName; + // First identifier argument with a known declared type. + for (const m of inner.matchAll(/(?:^|[\s,(])(?:this\.|self\.|@)?([a-z_]\w*)\b/g)) { + const t = locals.get(m[1]!) ?? bindings.get(m[1]!); + const noun = domainNoun(t); + if (noun) return noun; + } + // A bare identifier argument: capitalise it (`product` → `Product`). + const first = /^\s*(?:this\.|self\.|@)?([a-z][a-zA-Z0-9]*)\s*(?:,|$)/.exec(inner); + if (first && first[1]!.length > 2 && !/^(?:id|ids|dto|req|res|ctx|cb|err|error|args|opts|options|params|data|value|key|name|input|output|request|response|token|cancellationtoken|ct)$/i.test(first[1]!)) { + return first[1]![0]!.toUpperCase() + first[1]!.slice(1).replace(/(?:Dto|Request|Command|Query|Input|Model)$/, ''); + } + return undefined; +} + +/** `_context.Products` → `Product`; `this.orders` → `Order`; `prisma.user` → `User`. */ +function nounFromReceiver(receiver: string): string | undefined { + const segs = receiver.split(/[.:>\-?!]+/).filter(Boolean); + const last = segs[segs.length - 1]; + if (!last) return undefined; + const clean = last.replace(/^_/, ''); + if (/^(?:db|context|_context|dbContext|session|prisma|repo|repository|em|manager|orm|conn|tx|store|client|http|cache|logger|log|bus|queue|mediator|this|self|base|super|ctx|entities)$/i.test(clean)) return undefined; + if (/^[A-Z]/.test(clean)) return domainNoun(singular(clean)); + if (clean.length > 3) return domainNoun(singular(clean[0]!.toUpperCase() + clean.slice(1))); + return undefined; +} + +/** `CreateProductDto` → `Product`; `IProductRepository` → `Product`; `IEnumerable` → `Order`. */ +export function domainNoun(typeName: string | undefined): string | undefined { + if (!typeName) return undefined; + const generic = /<\s*([A-Z]\w*)/.exec(typeName); + if (generic) return domainNoun(generic[1]); + let bare = typeName.replace(/[?[\]*&]/g, '').replace(/^I(?=[A-Z])/, ''); + bare = bare.replace(/(?:Dto|DTO|Request|Command|Query|Input|Output|Model|Entity|Response|Repository|Repo|Dao|Service|Client|Store|Set|Collection|Context|Event|Message|Params|Args|Options)$/, '') || bare; + const stripped = bare.replace(/^(?:Create|Update|Delete|Remove|Get|Find|List|Add|Register|Save|Fetch|Load|Search|Cancel|Approve|Reject|Sync|Send|Publish)(?=[A-Z])/, ''); + if (stripped) bare = stripped; + return bare && /^[A-Z]/.test(bare) && !PLUMBING.test(bare) ? bare : undefined; +} + +function nounFromType(typeName: string | undefined): string | undefined { + return domainNoun(typeName); +} + +// --------------------------------------------------------------------------- +// Classification of one call site. +// --------------------------------------------------------------------------- + +interface Site { + call: Node; + callee: Node; +} + +function classifySite(site: Site, def: Node, langId: string, bindings: Bindings, locals: Bindings, calleeOf: (n: Node) => string | undefined): Duty | undefined { + const callee = site.callee.text; + const verb = callee.replace(/[!?]$/, ''); + const receiver = receiverOf(site.call, site.callee); + const base = receiver.replace(/^(?:this|self|@|super)[.:>-]*/, '').split(/[.:>\-?!]+/)[0] ?? ''; + const declared = base ? (bindings.get(base) ?? locals.get(base)) : undefined; + let cls: ReceiverClass = declared ? classifyType(declared) : 'unknown'; + if (cls === 'unknown' && receiver) cls = classifyName(receiver); + const args = argumentsText(site.call); + const flow = controlFlow(site.call, def); + const line = site.call.startPosition.row + 1; + const declaredShort = declared?.replace(/<.*$/, '').replace(/[?[\]*&]/g, ''); + const mk = (k: DutyKind, o?: string, via?: string): Duty => { + const d: Duty = { k, live: flow.live, line }; + if (o) d.o = o; + if (via) d.via = via; + if (declaredShort) d.t = declaredShort.slice(0, 40); + if (flow.guard) d.g = flow.guard; + return d; + }; + const viaOf = (): string => (receiver ? `${receiver.split(/[.:>\-?!]+/).filter(Boolean).slice(-1)[0]}.${callee}` : callee).slice(0, 60); + // A typed collaborator is cited by its type (`ProductService.Create`), which + // the inherit pass can resolve to the callee even when the edge resolver + // could not follow the receiver. + const typedVia = (): string => (declaredShort ? `${declaredShort}.${callee}` : viaOf()).slice(0, 60); + const lower = verb.toLowerCase(); + + // Unit-of-work verbs: a write, no object of its own. + if (UOW.test(lower) && (cls === 'store' || cls === 'unknown')) { + return mk('persist', undefined, viaOf()); + } + // Strong, receiver-independent verbs. + if (/^(?:saveandflush|saveall|insertmany|insertone|createmany|updatemany|deletemany|bulk_create|bulk_update|get_or_create|update_or_create|executeupdate|find_or_create_by)$/i.test(lower) || /^(?:save|update|create|destroy)!$/.test(callee)) { + return mk('persist', nounFromArgs(args, bindings, locals) ?? nounFromReceiver(receiver) ?? nounFromType(declared), callee); + } + if (/^(?:findunique|findmany|findfirst|findone|findall|findbyid|findbypk|tolistasync|toarrayasync|firstordefaultasync|singleordefaultasync|anyasync|countasync|findasync|asnotracking|fetchone|fetchall|find_by|find_each|selectinload|joinedload|get_object_or_404)$/i.test(lower)) { + return mk('query', nounFromReceiver(receiver) ?? nounFromType(declared) ?? nounFromArgs(args, bindings, locals), callee); + } + if (/^(?:fetch|urlopen)$/i.test(lower) && !receiver) return mk('http', undefined, callee); + + switch (cls) { + case 'store': { + if (AMBIGUOUS_EXEC.test(verb)) { + const write = SQL_WRITE_WORDS.test(args); + return mk(write ? 'persist' : 'query', nounFromQuery(args) ?? nounFromArgs(args, bindings, locals) ?? nounFromReceiver(receiver) ?? nounFromType(declared), viaOf()); + } + if (WRITE.test(verb)) return mk('persist', nounFromArgs(args, bindings, locals) ?? nounFromReceiver(receiver) ?? nounFromType(declared), viaOf()); + if (READ.test(verb)) return mk('query', nounFromQuery(args) ?? nounFromReceiver(receiver) ?? nounFromType(declared) ?? nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'http': { + if (HTTP_VERB.test(verb) || /async$/i.test(verb)) { + const path = /["'`]([^"'`\s]{1,60})["'`]/.exec(args)?.[1]; + return mk('http', nounFromArgs(args.replace(/["'`][^"'`]*["'`]/g, ''), bindings, locals) ?? (path ? path : undefined), viaOf()); + } + break; + } + case 'queue': { + if (PUBLISH_VERB.test(verb)) return mk('publish', nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'mediator': { + if (/^publish\w*$/i.test(verb)) return mk('publish', nounFromArgs(args, bindings, locals), viaOf()); + // The command type names the handler (`CreateProductCommand` → `CreateProductCommandHandler`). + if (/^send\w*$/i.test(verb)) return mk('delegate', rawTypeFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'cache': { + if (CACHE_VERB.test(verb)) return mk('cache', nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'log': { + if (LOG_VERB.test(verb)) return mk('log', undefined, viaOf()); + break; + } + case 'auth': { + if (AUTH_VERB.test(verb)) return mk('auth', nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'crypto': { + if (CRYPTO_VERB.test(verb)) return mk('crypto', undefined, viaOf()); + break; + } + case 'fs': { + if (FS_VERB.test(verb)) return mk('fs', nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'response': { + const inner = args.replace(/^\(|\)$/g, '').trim(); + // `res.json()` / `res.text()` with no argument reads a fetch Response. + if (/^(?:json|text|send|write|end|encode|html)$/i.test(lower) && !inner) break; + if (RESPOND_VERB.test(verb)) { + const code = /\b([1-5]\d\d)\b/.exec(args)?.[1] ?? STATUS_OF[lower]; + return mk('respond', code, viaOf()); + } + break; + } + case 'validator': { + if (VALIDATE_VERB.test(verb) || /^(?:for|rulefor|when|must)$/i.test(verb)) return mk('validate', nounFromArgs(args, bindings, locals), viaOf()); + break; + } + case 'service': { + // A collaborator call: a delegate duty the inherit pass can expand. + return mk('delegate', rawTypeFromArgs(args, bindings, locals), typedVia()); + } + default: + break; + } + + // Receiver-free duties by verb alone. + if (!receiver || /^(?:this|self|@)$/.test(receiver)) { + if (/^(?:ok|created|createdataction|createdatroute|nocontent|notfound|badrequest|unauthorized|forbid|accepted|conflict|problem|statuscode|jsonify|jsonresponse|httpresponse\w*|redirect(?:toaction|toroute)?)$/i.test(lower)) { + return mk('respond', /\b([1-5]\d\d)\b/.exec(args)?.[1] ?? STATUS_OF[lower], callee); + } + if (/^(?:render|render_template|renderToString|createElement|jsx|jsxs|h)$/i.test(verb)) return mk('render', nounFromArgs(args, bindings, locals), callee); + if (/^(?:validate\w*|assert\w*|invariant|precondition|is_valid|full_clean|ensure\w*)$/i.test(verb)) return mk('validate', nounFromArgs(args, bindings, locals), callee); + if (/^(?:bcrypt|checkpw|hashpw|verify_password|get_password_hash|check_password|create_access_token|create_refresh_token|decode_token|verify_token|authenticate|login_user|logout_user|comparePassword|verifyPassword)$/i.test(verb)) return mk('auth', nounFromArgs(args, bindings, locals), callee); + if (/^(?:publishEvent|publish|emit|broadcast|dispatch|sendEmail|send_mail|deliver_now|deliver_later)$/i.test(verb)) return mk('publish', nounFromArgs(args, bindings, locals), callee); + } + // Store-shaped strong verbs on an untyped, unspelled receiver stay quiet: + // `catalog.put(x)` without a declared type is not evidence. + // A resolved same-file / cross-file callee that is itself a callable is a + // delegation the inherit pass can follow. + const resolved = calleeOf(site.callee); + if (resolved) return mk('delegate', rawTypeFromArgs(args, bindings, locals), resolved.slice(0, 60)); + return undefined; +} + +// --------------------------------------------------------------------------- +// Entry point. +// --------------------------------------------------------------------------- + +export interface DutyInput { + /** The definition node. */ + def: Node; + /** Callee capture nodes that lie inside the definition, in source order. */ + callees: Node[]; + /** Whole-file text (for bindings) and the language id. */ + text: string; + langId: string; + bindings: Bindings; + /** Qualified name of a callee node when the parser already knows it (same-file defs). */ + calleeOf?: (n: Node) => string | undefined; +} + +/** Ordered duties of one definition. Undefined when nothing was recognised. */ +export function extractDuties(input: DutyInput): Duty[] | undefined { + const { def, callees, langId, bindings } = input; + if (!callees.length) return undefined; + const bodyText = input.text.slice(def.startIndex, Math.min(def.endIndex, def.startIndex + 30_000)); + const locals = localTypes(bodyText); + const calleeOf = input.calleeOf ?? (() => undefined); + const out: Duty[] = []; + const seen = new Set(); + for (const callee of callees) { + const call = callNodeOf(callee); + if (!call) continue; + // Nested definitions (lambdas that are their own defs) are not this body's duties. + const duty = classifySite({ call, callee }, def, langId, bindings, locals, calleeOf); + if (!duty) continue; + const key = duty.k === 'respond' ? `respond|${duty.line}` : `${duty.k}|${duty.o ?? ''}|${duty.via ?? ''}|${duty.line}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(duty); + if (out.length >= MAX_DUTIES) break; + } + // Delegations that led nowhere add noise; keep them only when they are the + // whole story (a delegating one-liner) or point at a named collaborator. + const real = out.filter((d) => d.k !== 'delegate'); + const delegates = out.filter((d) => d.k === 'delegate' && d.via && /[A-Z]\w*\.|\./.test(d.via)); + const kept = [...real, ...delegates].sort((a, b) => a.line - b.line || a.k.localeCompare(b.k)); + return kept.length ? kept : undefined; +} + +/** Effect-style counts derived from duties (own sites only), for consumers of `node.effects`. */ +export function summarizeDuties(duties: Duty[]): Record { + const out: Record = {}; + for (const d of duties) { + if (d.hop) continue; + const key = d.k === 'persist' ? 'sqlWrite' : d.k === 'query' ? 'sql' : d.k === 'publish' ? 'msg' : d.k; + if (key === 'delegate') continue; + out[key] = (out[key] ?? 0) + (d.live ? 1 : 0); + } + return out; +} diff --git a/src/engine/effects.test.ts b/src/engine/effects.test.ts new file mode 100644 index 0000000..c550d07 --- /dev/null +++ b/src/engine/effects.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import { scanEffects, stripComments } from './effects.js'; + +/** + * The effect scanner reads what a body executes. Each case is a real-shaped + * snippet from a language in the test pack; the assertions are the effects + * a reader would name, and the ones that must NOT fire (a `// fetch` comment, + * `res.send` as messaging, a parameter type as a call). + */ + +describe('scanEffects', () => { + it('reads a C# EF Core command handler as a write via the db context', () => { + const e = scanEffects( + `public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + var entity = new Product { Name = request.Name, Price = request.Price }; + _context.Products.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Created {Id}", entity.Id); + return entity.Id; + }`, + 'cs', + )!; + expect(e.sqlWrite).toBeGreaterThanOrEqual(2); + expect(e.sql).toBeUndefined(); + expect(e.log).toBe(1); + expect(e.awaits).toBe(1); + expect(e.returns).toBe(1); + expect(e.apis).toContain('SaveChangesAsync'); + expect(e.apis).toContain('_context.Add'); + }); + + it('reads an EF Core query handler as a read', () => { + const e = scanEffects( + `public async Task Handle(GetProductByIdQuery request, CancellationToken ct) + { + var p = await _context.Products.AsNoTracking().FirstOrDefaultAsync(x => x.Id == request.Id, ct); + if (p is null) return null; + return new ProductDto(p.Id, p.Name); + }`, + 'cs', + )!; + expect(e.sql).toBeGreaterThanOrEqual(1); + expect(e.sqlWrite).toBeUndefined(); + expect(e.branches).toBe(1); + }); + + it('reads a TS fetch helper as outbound HTTP, not persistence', () => { + const e = scanEffects( + `async function fetchApi(endpoint: string, options: RequestInit = {}): Promise { + // TODO: retry on 503 — never fetch twice for now + const res = await fetch(\`\${BASE}\${endpoint}\`, { ...options, headers: { 'Content-Type': 'application/json' } }); + if (!res.ok) throw new ApiError(res.status, await res.text()); + return res.json(); + }`, + 'ts', + )!; + expect(e.http).toBe(1); + expect(e.sql).toBeUndefined(); + expect(e.sqlWrite).toBeUndefined(); + // `res.json()` on a fetch Response is not an HTTP response being sent. + expect(e.respond).toBeUndefined(); + expect(e.throws).toBe(1); + expect(e.apis).toContain('fetch'); + }); + + it('reads an Express controller as responding, and res.send is not messaging', () => { + const e = scanEffects( + `async create(req: Request, res: Response): Promise { + const dto = req.body; + const article = await this.service.create(dto); + res.status(201).json(article); + }`, + 'ts', + )!; + expect(e.respond).toBeGreaterThanOrEqual(1); + expect(e.msg).toBeUndefined(); + expect(e.http).toBeUndefined(); + expect(e.awaits).toBe(1); + }); + + it('reads a Prisma service call as a store write', () => { + const e = scanEffects( + `async create(dto: CreateMediaDto): Promise { + const media = await prisma.media.create({ data: dto }); + await this.cache.del('media:list'); + return media; + }`, + 'ts', + )!; + expect(e.sqlWrite).toBe(1); + expect(e.cache).toBe(1); + expect(e.apis).toContain('prisma.create'); + }); + + it('reads a Python SQLAlchemy service and a bcrypt helper', () => { + const svc = scanEffects( + `async def create(self, user_in: UserCreate) -> User: + """Create a new user.""" + user = User(email=user_in.email, hashed_password=get_password_hash(user_in.password)) + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + return user`, + 'py', + )!; + expect(svc.sqlWrite).toBeGreaterThanOrEqual(2); + expect(svc.auth).toBe(1); + expect(svc.awaits).toBe(2); + const hash = scanEffects( + `def get_password_hash(password: str) -> str: + return pwd_context.hash(password) # bcrypt`, + 'py', + )!; + // The comment is stripped; the call itself is a plain call. + expect(hash.auth).toBeUndefined(); + expect(hash.calls).toBe(1); + const verify = scanEffects( + `def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode())`, + 'py', + )!; + expect(verify.auth).toBeGreaterThanOrEqual(1); + }); + + it('reads a Python file writer and a raw SQL query', () => { + const w = scanEffects( + `def export_report(path, rows): + with open(path, "w") as f: + for row in rows: + f.write(row)`, + 'py', + )!; + expect(w.fs).toBeGreaterThanOrEqual(1); + expect(w.loops).toBe(1); + const q = scanEffects( + `def count_active(conn): + cur = conn.cursor() + cur.execute("SELECT count(*) FROM users WHERE active = 1") + return cur.fetchone()[0]`, + 'py', + )!; + expect(q.sql).toBeGreaterThanOrEqual(2); + expect(q.sqlWrite).toBeUndefined(); + }); + + it('reads a Java Spring service that logs and saves through a repository', () => { + const e = scanEffects( + `@Override + @Transactional + public ProductDTO createProduct(CreateProductRequest request) { + log.info("Creating product {}", request.getName()); + if (productRepository.existsBySku(request.getSku())) { + throw new IllegalArgumentException("SKU exists"); + } + Product saved = productRepository.save(toEntity(request)); + eventPublisher.publishEvent(new ProductCreated(saved.getId())); + return toDTO(saved); + }`, + 'java', + )!; + expect(e.sqlWrite).toBe(1); + expect(e.log).toBe(1); + expect(e.msg).toBe(1); + expect(e.throws).toBe(1); + expect(e.branches).toBe(1); + expect(e.apis).toContain('productRepository.save'); + }); + + it('reads a React component as rendering', () => { + const e = scanEffects( + `export function ProductCard({ product }: ProductCardProps) { + const [open, setOpen] = useState(false); + return ( +
+

{product.name}

+ +
+ ); + }`, + 'tsx', + )!; + expect(e.render).toBeGreaterThanOrEqual(3); + expect(e.http).toBeUndefined(); + }); + + it('reads a Rails migration and a Go handler', () => { + const rb = scanEffects( + `def change + create_table :posts do |t| + t.string :title + t.timestamps + end + end`, + 'rb', + ); + // Ruby's paren-less calls are not counted as calls; the body is still profiled. + expect(rb?.lines).toBeGreaterThanOrEqual(5); + const go = scanEffects( + `func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + u, err := h.db.QueryRow("SELECT id, name FROM users WHERE id = $1", id) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(u) + }`, + 'go', + )!; + expect(go.sql).toBeGreaterThanOrEqual(1); + expect(go.respond).toBeGreaterThanOrEqual(1); + expect(go.branches).toBe(1); + }); + + it('reads an expression-bodied C# member and does not call MediatR Send a message', () => { + const one = scanEffects('public Task GetByIdAsync(int id) => _context.Products.FirstOrDefaultAsync(p => p.Id == id);', 'cs')!; + expect(one.sql).toBeGreaterThanOrEqual(1); + const ctl = scanEffects( + `public async Task> Create(CreateProductCommand command) + { + var id = await _mediator.Send(command); + _mediator.Publish(new ProductCreated(id)); + return CreatedAtAction(nameof(GetById), new { id }, id); + }`, + 'cs', + )!; + expect(ctl.msg).toBe(1); + expect(ctl.respond).toBe(1); + }); + + it('never counts a parameter type or a comment as an effect', () => { + const e = scanEffects( + `function noop(req: Request, client: HttpClient): void { + // fetch(url) would go here; axios.get too + /* prisma.user.create({}) */ + return; + }`, + 'ts', + )!; + expect(e.http).toBeUndefined(); + expect(e.sqlWrite).toBeUndefined(); + expect(e.calls).toBe(0); + expect(e.returns).toBe(1); + }); + + it('returns undefined for an empty body, is deterministic, and caps apis at 8', () => { + expect(scanEffects('', 'ts')).toBeUndefined(); + expect(scanEffects('function x(): void', 'ts')).toBeUndefined(); + const body = `function many() {\n${['fetch(a)', 'axios.get(b)', 'redis.get(c)', 'logger.info(d)', 'bcrypt.hash(e)', 'jwt.sign(f)', 'crypto.randomUUID()', 'publish(g)', 'queue.add(h)', 'fs.readFileSync(i)', 'sha256(j)'].join(';\n')}\n}`; + const a = scanEffects(body, 'ts')!; + const b = scanEffects(body, 'ts')!; + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + expect(a.apis!.length).toBeLessThanOrEqual(8); + expect([...a.apis!]).toEqual([...a.apis!].sort()); + }); + + it('strips comments per language family', () => { + expect(stripComments('a() // b()\nc()', 'ts')).toBe('a() \nc()'); + expect(stripComments('a() # b()\nc()', 'py')).toBe('a() \nc()'); + expect(stripComments('x = "http://h" // keep', 'ts')).toBe('x = "http://h" '); + expect(stripComments('/* a() */ b()', 'java')).toBe(' b()'); + }); +}); diff --git a/src/engine/effects.ts b/src/engine/effects.ts new file mode 100644 index 0000000..195ca33 --- /dev/null +++ b/src/engine/effects.ts @@ -0,0 +1,246 @@ +/** + * Body effects — what a callable's body *calls*, scanned from its source text. + * + * The architecture classifier used to see only names: folder tokens, the + * symbol name, callee names, type names. That answers "what do the names + * claim this is for?", never "what runs when this is called?". This scanner + * gives the graph a small, deterministic effect profile per callable so the + * classifier can score `persist` because the body calls `SaveChangesAsync`, + * `network_io` because it calls `fetch(`, `render` because it returns JSX — + * and so a controller that writes straight to the store shows the leak. + * + * It is a bounded regex scan over the definition's own text with comments + * removed — not a parser, not a type checker, no execution. Every count is + * a number of matching sites; `apis` names the first few matched calls so + * the evidence is inspectable. Same source in → same profile out. + * + * Deliberately conservative: a generic verb (`.get(`, `.create(`, `.send(`) + * only counts when its receiver looks like a store, a client, or a queue; + * strong verbs (`SaveChangesAsync`, `findUnique`, `fetch(`) count on their own. + */ + +export interface CallableEffects { + /** SQL text or ORM/store reads: `SELECT`, `.findMany(`, `ToListAsync(`. */ + sql?: number; + /** SQL/ORM writes: `INSERT`, `.save(`, `SaveChangesAsync(`, `session.commit(`. */ + sqlWrite?: number; + /** Outbound HTTP / RPC client calls: `fetch(`, `axios.`, `httpClient.GetAsync(`. */ + http?: number; + /** Sending an HTTP response: `res.json(`, `return Ok(`, `jsonify(`. */ + respond?: number; + /** File-system access. */ + fs?: number; + /** Authentication primitives: password verify/hash, JWT, sign-in. */ + auth?: number; + /** Cryptography beyond passwords: hashing, ciphers, random. */ + crypto?: number; + /** Publishing to a queue / bus / event emitter. */ + msg?: number; + /** Cache reads/writes. */ + cache?: number; + /** Logging / tracing / metrics calls. */ + log?: number; + /** UI rendering: JSX, templates, `render(`. */ + render?: number; + /** Validation calls and validation-typed throws. */ + validate?: number; + /** Call sites (fan-out). */ + calls: number; + /** Branch points: if / else if / switch / case / match. */ + branches: number; + loops: number; + awaits: number; + throws: number; + returns: number; + /** Assignments to member / instance state (`this.x =`, `self.x =`, `@x =`). */ + assigns: number; + lines: number; + /** Up to 8 distinct matched API tokens, sorted (`fetch`, `SaveChangesAsync`). */ + apis?: string[]; +} + +const MAX_BODY_CHARS = 30_000; +const MAX_APIS = 8; + +/** + * Receivers that make a generic verb a store call: a word that is, or ends + * in, a store-shaped name (`productRepository`, `_context`, `db`, `prisma`). + * Up to two member hops may follow (`_context.Products.Add(`). + */ +const STORE_RECEIVER = + '(?:\\w*(?:[Rr]epositor(?:y|ies)|[Rr]epos?|[Dd]ao|[Ss]tore|[Cc]ontext|[Ss]ession|[Cc]ollection|[Mm]odel|[Tt]able|[Dd]atabase|[Cc]onnection|[Cc]ursor|[Tt]ransaction|[Ee]ntityManager|[Mm]anager|[Qq]uery(?:set)?|[Oo]bjects|[Dd]b|DB|[Ee]ntities|[Cc]lient|[Oo]rm)|_?(?:prisma|knex|sequelize|mongoose|conn|cur|em|qs|tx|dbSet|typeorm|drizzle|supabase|firestore|mongo|collection|redis))'; +const STORE_RECEIVER_RE = new RegExp(`\\b${STORE_RECEIVER}(?:\\.\\w+){0,2}\\.(\\w+)\\s*\\(`, 'g'); + +const READ_VERBS = new Set([ + 'find', 'findOne', 'findMany', 'findAll', 'findFirst', 'findById', 'findByPk', 'findUnique', 'findOneBy', + 'findAndCount', 'findAndCountAll', 'get', 'getAll', 'getOne', 'getMany', 'getById', 'query', 'select', + 'where', 'filter', 'first', 'all', 'count', 'exists', 'aggregate', 'fetch', 'load', 'read', 'scan', + 'search', 'list', 'lookup', 'exec', 'execute', 'raw', 'ToListAsync', 'ToArrayAsync', 'FirstOrDefaultAsync', + 'FirstAsync', 'SingleOrDefaultAsync', 'SingleAsync', 'AnyAsync', 'CountAsync', 'FindAsync', 'Find', + 'Include', 'AsNoTracking', 'Where', 'Select', 'OrderBy', 'ToList', 'FirstOrDefault', 'SingleOrDefault', + 'Any', 'Count', 'findAllBy', 'findOneAndUpdate', 'getRepository', 'createQueryBuilder', 'queryOne', + 'queryAll', 'fetchOne', 'fetchAll', 'fetchone', 'fetchall', 'one', 'many', 'paginate', 'QueryRow', + 'Query', 'QueryContext', 'QueryRowContext', 'Get', 'Select', 'SelectContext', 'GetContext', 'find_by', + 'find_each', 'pluck', 'exists?', 'get_object_or_404', 'values', 'values_list', 'only', 'defer', +]); +const WRITE_VERBS = new Set([ + 'save', 'saveAll', 'saveAndFlush', 'insert', 'insertOne', 'insertMany', 'create', 'createMany', + 'update', 'updateOne', 'updateMany', 'upsert', 'delete', 'deleteOne', 'deleteMany', 'deleteById', + 'remove', 'destroy', 'persist', 'merge', 'flush', 'commit', 'add', 'Add', 'AddAsync', 'AddRange', + 'AddRangeAsync', 'Update', 'Remove', 'RemoveRange', 'SaveChanges', 'SaveChangesAsync', 'bulk_create', + 'bulk_update', 'get_or_create', 'update_or_create', 'executeUpdate', 'increment', 'decrement', 'set', + 'put', 'write', 'store', 'truncate', 'drop', 'refresh', 'sync', 'push', 'Attach', 'Entry', 'Exec', + 'ExecContext', 'NamedExec', 'MustExec', 'create!', 'save!', 'update!', 'destroy!', 'delete_all', + 'update_all', 'insert_all', 'upsert_all', 'touch', 'set', +]); +/** Verbs that count as a store call even without a store-shaped receiver. */ +const STRONG_READ = /\b(findOne|findMany|findAll|findFirst|findById|findByPk|findUnique|findOneBy|findAndCount(?:All)?|findAllBy|ToListAsync|ToArrayAsync|FirstOrDefaultAsync|SingleOrDefaultAsync|AnyAsync|CountAsync|FindAsync|AsNoTracking|createQueryBuilder|getRepository|fetchone|fetchall|find_by|find_each|where\s*\(|selectinload|joinedload)\s*\(?/g; +const STRONG_WRITE = /\b(saveAndFlush|saveAll|insertMany|insertOne|createMany|updateMany|deleteMany|SaveChanges(?:Async)?|AddRangeAsync|AddAsync|RemoveRange|bulk_create|bulk_update|get_or_create|update_or_create|executeUpdate|save!|update!|destroy!|create!|find_or_create_by|update_attributes?|upsert)\s*\(?/g; +const SQL_TEXT = /\b(SELECT\s+[^;'"`]{1,200}?\s+FROM\b|INSERT\s+INTO\b|UPDATE\s+[\w"`.]+\s+SET\b|DELETE\s+FROM\b|CREATE\s+(?:TABLE|INDEX)\b|ALTER\s+TABLE\b|DROP\s+TABLE\b|MERGE\s+INTO\b)/gi; +const SQL_WRITE_TEXT = /\b(INSERT\s+INTO|UPDATE\s+[\w"`.]+\s+SET|DELETE\s+FROM|CREATE\s+(?:TABLE|INDEX)|ALTER\s+TABLE|DROP\s+TABLE|MERGE\s+INTO)\b/gi; + +const HTTP_OUT = /\b(fetch\s*\(|axios(?:\.\w+)?\s*[(.]|got\s*\(|ky\.\w+\s*\(|superagent|requests\.(?:get|post|put|patch|delete|head|request)\s*\(|httpx\.\w+\s*\(|aiohttp\.|urlopen\s*\(|urllib\.request|http\.(?:Get|Post|Put|Head|NewRequest|Do)\s*\(|HttpClient|httpClient\.\w+\s*\(|_httpClient\.\w+\s*\(|RestTemplate|restTemplate\.\w+\s*\(|WebClient|webClient\.\w+\s*\(|OkHttpClient|Feign|reqwest(?:::\w+)+|ureq::|XMLHttpRequest|\$\.ajax|\.(?:GetAsync|PostAsync|PutAsync|DeleteAsync|PatchAsync|PostAsJsonAsync|PutAsJsonAsync|GetStringAsync|GetFromJsonAsync|SendAsync)\s*\(|grpc\.|\.invoke\s*\(|Net::HTTP|HTTParty|Faraday|RestClient)/g; +const RESPOND = /\b(res\.(?:send|render|redirect|sendStatus|end|write)\s*\(|res\.json\s*\(\s*[^)\s]|res\.status\s*\(\s*\d|response\.(?:send|write|redirect)\s*\(|response\.json\s*\(\s*[^)\s]|response\.status\s*\(\s*\d|reply\.(?:send|code|status)\s*\(|ctx\.(?:body|status|json)\b|jsonify\s*\(|render_template\s*\(|JsonResponse\s*\(|HttpResponse\w*\s*\(|return\s+(?:Ok|NotFound|BadRequest|Created|CreatedAtAction|CreatedAtRoute|NoContent|Unauthorized|Forbid|StatusCode|Json|View|Redirect|Accepted|Conflict|Problem)\s*\(|ResponseEntity\.|render\s+json:|render\s+:|respond_to|NextResponse\.|Response\.json\s*\(|new\s+Response\s*\(|c\.JSON\s*\(|c\.String\s*\(|w\.Write(?:Header)?\s*\(|json\.NewEncoder\s*\(\s*w\s*\)|HttpResponseMessage|IActionResult)/g; +const FS_IO = /\b(fs\.\w+\s*\(|fsPromises\.\w+|readFile(?:Sync)?\s*\(|writeFile(?:Sync)?\s*\(|appendFile|mkdir(?:Sync)?\s*\(|readdir|unlink(?:Sync)?\s*\(|createReadStream|createWriteStream|os\.path\.|pathlib|shutil\.|Path\s*\([^)]*\)\s*\.\s*(?:read|write|open)|\.read_text\s*\(|\.write_text\s*\(|\.read_bytes\s*\(|\.write_bytes\s*\(|File\.(?:ReadAll\w*|WriteAll\w*|Exists|Open\w*|Delete|Copy|Move|Create)\s*\(|FileStream|StreamReader|StreamWriter|Files\.(?:read\w*|write\w*|exists|delete|copy|move|create\w*|walk|list)\s*\(|FileReader|FileWriter|BufferedReader|BufferedWriter|ioutil\.\w+\s*\(|os\.(?:Open|Create|ReadFile|WriteFile|Remove|MkdirAll|Stat)\s*\(|std::fs::|File::(?:open|create)\s*\(|IO\.(?:read|write|readlines)\s*\(|File\.(?:read|write|open|exist\?|readlines)\s*\(|Dir\.\w+\s*\(|open\s*\([^)]*['"][rwab+]{1,3}['"]\s*\))/g; +const PY_RB_OPEN = /\bopen\s*\(/g; +const AUTH = /\b(bcrypt|argon2|scrypt|pbkdf2|jwt\.\w+\s*\(|jsonwebtoken|verify_password|check_password|checkpw|hashpw|hash_password|get_password_hash|create_access_token|create_refresh_token|decode_token|verify_token|passport\.|authenticate\s*\(|login\s*\(|logout\s*\(|login_user|logout_user|OAuth|oauth|SignInAsync|SignOutAsync|PasswordHasher|UserManager|SignInManager|SecurityContext|AuthenticationManager|verifyPassword|comparePassword|compareSync\s*\(|\.compare\s*\(|hashSync\s*\(|getServerSession|getSession\s*\(|auth\s*\(|currentUser|current_user|has_secure_password|authenticate_user!|devise|Authorization|Bearer\b|api_key|apiKey)/g; +const CRYPTO = /\b(crypto\.\w+|hashlib\.\w+|sha1|sha256|sha512|md5|hmac|HMAC|createHash|createHmac|createCipher\w*|createDecipher\w*|encrypt\w*\s*\(|decrypt\w*\s*\(|AES|RSA|Cipher|randomBytes|randomUUID|secrets\.\w+|MessageDigest|SHA256|Rfc2898|RandomNumberGenerator|Fernet|nacl\.|libsodium|ring::|openssl)/g; +const MSG = /\b(publish\w*\s*\(|Publish(?:Async)?\s*\(|\.emit\s*\(|enqueue|producer\.\w+\s*\(|kafka|Kafka|rabbit|amqp|sqs\.|sns\.|pubsub|PubSub|channel\.(?:publish|sendToQueue|basicPublish)|queue\.(?:add|push|send|enqueue)\s*\(|celery|\.delay\s*\(|apply_async|bullmq|EventEmitter|eventBus|EventBus|mediator\.Publish\w*\s*\(|_mediator\.Publish\w*\s*\(|_publisher\.\w+\s*\(|_bus\.\w+\s*\(|ActiveJob|perform_later|perform_async|Sidekiq|broadcast\w*\s*\(|dispatch\s*\(|SendMessage(?:Async)?\s*\(|ServiceBus|EventGrid|MassTransit|IPublishEndpoint|rebus|NATS|nats\.|mqtt|kinesis|WebSocket|socket\.emit|io\.emit|\.sendMail\s*\(|sendEmail|send_mail|deliver_now|deliver_later|mailer|Mailer|twilio|sendgrid|nodemailer)/g; +const CACHE = /\b(redis|Redis|cache\.(?:get|set|del|delete|has|remember|fetch|wrap|clear|invalidate)\w*\s*\(|_cache\.\w+\s*\(|Cache\.|IMemoryCache|IDistributedCache|memcache|lru_cache|lru\.|memoize|memo\s*\(|useMemo\s*\(|Rails\.cache|cache_page|@cache|@Cacheable|@CacheEvict|CacheManager)/g; +const LOG = /\b(logger\.\w+\s*\(|_logger\.\w+\s*\(|log\.(?:info|warn|warning|error|debug|trace|fatal|critical)\w*\s*\(|console\.(?:log|warn|error|info|debug|trace)\s*\(|logging\.\w+\s*\(|Log\.\w+\s*\(|Logger\.\w+\s*\(|slf4j|log4j|tracing::|log::(?:info|warn|error|debug|trace)!|println!|eprintln!|Rails\.logger|puts\s|logger\s*\.|winston|pino|tracer\.\w+\s*\(|span\.\w+\s*\(|metrics\.\w+\s*\(|Metrics\.\w+\s*\(|Sentry\.\w+\s*\(|captureException|Telemetry|ILogger)/g; +const RENDER_JSX = /<(?:[A-Z][\w.]*|div|span|p|a|ul|ol|li|button|form|input|label|section|header|footer|main|nav|table|tr|td|th|h[1-6]|img|svg|select|option|textarea|article|aside|dialog|Fragment)\b[^>]*>|<>/g; +const RENDER = /\b(render\s*\(|renderToString|ReactDOM\.\w+|createElement\s*\(|res\.render\s*\(|render_template\s*\(|\.render\s*\(|template\.\w+\s*\(|Template\s*\(|html`|@Composable|setContent\s*\{|Widget\s+build\s*\(|return\s+View\s*\(|PartialView\s*\(|Html\.\w+\s*\(|innerHTML|document\.(?:createElement|getElementById|querySelector)\w*\s*\(|useState\s*\(|useEffect\s*\(|useRef\s*\(|useCallback\s*\(|useContext\s*\(|useRouter\s*\(|useQuery\s*\(|useMutation\s*\(|useForm\s*\(|useSelector\s*\(|useDispatch\s*\(|useTranslation\s*\(|Widget\b|StatelessWidget|StatefulWidget|SwiftUI|UIView|NSView|@Component|template:|styleUrls|render_to_response|mark_safe|format_html|erb|haml|slim|jinja|Blade|\.blade\.)/g; +const VALIDATE = /\b(validate\w*\s*\(|Validate\w*\s*\(|validator\.|Validator\.|schema\.(?:parse|safeParse|validate|validateSync|parseAsync)\s*\(|z\.\w+\s*\(|zod|yup\.|Joi\.|joi\.|class-validator|validateOrReject|ValidationError|ValidationException|ValidationProblem|BadRequestException|UnprocessableEntity|ArgumentException|ArgumentNullException|ArgumentOutOfRangeException|InvalidOperationException|ValueError|TypeError\s*\(|assert\s*\(|assert\s+\w|invariant\s*\(|@Valid|@Validated|is_valid\s*\(|full_clean|\.valid\?|validates\s|ModelState\.IsValid|IsValid|RuleFor\s*\(|FluentValidation|ensure\w*\s*\(|check\w*\s*\(|guard\s+(?:let|var)|require\s*\(\s*[^'"]|precondition\s*\(|Guard\.\w+\s*\(|Ensure\.\w+\s*\(|throw\s+new\s+\w*(?:Validation|Argument|Invalid|BadRequest|Unprocessable)\w*)/g; + +const BRANCH = /\b(if|else\s+if|elif|elsif|switch|case|match|when|unless|\?\?|catch|except|rescue)\b/g; +const LOOP = /\b(for|while|foreach|loop|until|do)\b|\.(?:forEach|map|flatMap|filter|reduce|each|each_with_index|times|select|reject|collect)\s*\(|\.(?:each|map|select|reject|collect)\s*(?:\{|do\b)/g; +const AWAIT = /\b(await|\.then\s*\(|\.subscribe\s*\(|\.Result\b|\.Wait\s*\(|\.GetAwaiter\s*\(|yield\s+from|asyncio\.\w+|Task\.WhenAll|Promise\.all|Promise\.allSettled|\.await\b)/g; +const THROW = /\b(throw|raise|panic!?|rethrow|fail\s*\()/g; +const RETURN = /\breturn\b/g; +const ASSIGN = /(?:\bthis\.|\bself\.|@|\b_|\bthis->|\bself->)\w+\s*(?:[+\-*/|&]?=)(?!=)/g; +const CALL = /\b([A-Za-z_$][\w$]*)\s*\(/g; +const CALL_KEYWORDS = new Set([ + 'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'new', 'typeof', 'await', 'async', 'else', + 'elif', 'except', 'with', 'match', 'case', 'when', 'unless', 'until', 'do', 'foreach', 'lock', 'using', + 'sizeof', 'defer', 'go', 'select', 'yield', 'print', 'echo', 'and', 'or', 'not', 'in', 'is', 'as', + 'super', 'this', 'self', 'throw', 'raise', 'assert', 'require', 'import', 'from', 'def', 'fn', 'fun', + 'func', 'class', 'interface', 'struct', 'enum', 'delete', 'void', 'int', 'string', 'bool', 'float', + 'double', 'long', 'char', 'byte', 'var', 'let', 'const', 'static', 'public', 'private', 'protected', +]); + +const PY_RB_COMMENT_LANGS = new Set(['py', 'rb', 'sh', 'bash', 'ex', 'elixir', 'lua', 'r', 'yaml', 'toml']); +const JSX_LANGS = new Set(['tsx', 'jsx', 'js', 'vue', 'svelte', 'astro']); + +/** Remove comments so a `// TODO: fetch from the API` never counts as a call. */ +export function stripComments(text: string, langId: string): string { + let out = text.replace(/\/\*[\s\S]*?\*\//g, ' '); + if (PY_RB_COMMENT_LANGS.has(langId)) { + out = out.replace(/(^|[^\w'":`\\])#[^\n]*/g, '$1'); + // Python docstrings. + out = out.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, ' '); + } else if (langId !== 'sql') { + out = out.replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1'); + } + return out; +} + +function countMatches(text: string, re: RegExp, apis?: Set, keep = false): number { + re.lastIndex = 0; + let n = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + n++; + if (keep && apis) { + const token = (m[1] ?? m[0]).replace(/[\s(.:!{]+$/g, '').replace(/^\./, ''); + if (token && token.length <= 40) apis.add(token); + } + if (m[0].length === 0) re.lastIndex++; + } + return n; +} + +/** Store-shaped receiver calls split into reads and writes. */ +function storeCalls(text: string, apis: Set): { reads: number; writes: number } { + let reads = 0; + let writes = 0; + STORE_RECEIVER_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = STORE_RECEIVER_RE.exec(text)) !== null) { + const verb = m[1] ?? ''; + if (WRITE_VERBS.has(verb)) { + writes++; + apis.add(`${m[0].split('.')[0]}.${verb}`); + } else if (READ_VERBS.has(verb)) { + reads++; + apis.add(`${m[0].split('.')[0]}.${verb}`); + } + } + return { reads, writes }; +} + +function countCalls(text: string): number { + CALL.lastIndex = 0; + let n = 0; + let m: RegExpExecArray | null; + while ((m = CALL.exec(text)) !== null) { + if (!CALL_KEYWORDS.has(m[1]!)) n++; + } + return n; +} + +/** + * Effect profile of one definition's text (signature + body). Returns + * undefined for an empty body so the graph stays compact for declarations. + */ +export function scanEffects(defText: string, langId: string): CallableEffects | undefined { + if (!defText) return undefined; + const clipped = defText.length > MAX_BODY_CHARS ? defText.slice(0, MAX_BODY_CHARS) : defText; + const text = stripComments(clipped, langId); + // Skip the header so parameter types (`Request`) don't read as calls. A + // definition with neither a brace nor a second line has no body. + let bodyStart = Math.max(text.indexOf('{'), text.indexOf(':\n'), text.indexOf('\n')); + // Expression-bodied members (`public int Total() => Items.Sum(i => i.Price);`). + if (bodyStart < 0) bodyStart = text.indexOf('=>'); + if (bodyStart < 0) return undefined; + const body = text.slice(bodyStart); + if (!body.trim()) return undefined; + + const apis = new Set(); + const store = storeCalls(body, apis); + const sqlText = countMatches(body, SQL_TEXT, apis, true); + const sqlWriteText = countMatches(body, SQL_WRITE_TEXT); + const strongRead = countMatches(body, STRONG_READ, apis, true); + const strongWrite = countMatches(body, STRONG_WRITE, apis, true); + + const e: CallableEffects = { + calls: countCalls(body), + branches: countMatches(body, BRANCH), + loops: countMatches(body, LOOP), + awaits: countMatches(body, AWAIT), + throws: countMatches(body, THROW), + returns: countMatches(body, RETURN), + assigns: countMatches(body, ASSIGN), + lines: body.split('\n').length, + }; + const set = (key: keyof CallableEffects, n: number): void => { + if (n > 0) (e as unknown as Record)[key] = n; + }; + set('sql', store.reads + strongRead + (sqlText - sqlWriteText)); + set('sqlWrite', store.writes + strongWrite + sqlWriteText); + set('http', countMatches(body, HTTP_OUT, apis, true)); + set('respond', countMatches(body, RESPOND, apis, true)); + let fsN = countMatches(body, FS_IO, apis, true); + if (langId === 'py' || langId === 'rb') fsN += countMatches(body, PY_RB_OPEN, apis, true); + set('fs', fsN); + set('auth', countMatches(body, AUTH, apis, true)); + set('crypto', countMatches(body, CRYPTO, apis, true)); + set('msg', countMatches(body, MSG, apis, true)); + set('cache', countMatches(body, CACHE, apis, true)); + set('log', countMatches(body, LOG)); + let render = countMatches(body, RENDER, apis, true); + if (JSX_LANGS.has(langId)) render += countMatches(body, RENDER_JSX); + set('render', render); + set('validate', countMatches(body, VALIDATE, apis, true)); + if (apis.size) e.apis = [...apis].sort().slice(0, MAX_APIS); + return e; +} diff --git a/src/engine/haile/adapter.ts b/src/engine/haile/adapter.ts new file mode 100644 index 0000000..493babe --- /dev/null +++ b/src/engine/haile/adapter.ts @@ -0,0 +1,110 @@ +/** H0: map vg-graph/1.x callable nodes onto HAILE callables. Does not re-parse. */ + +import type { GraphEdge, GraphNode, VgGraph } from '../../schema.js'; +import type { HaileCallable, HaileSymbolKind } from './types.js'; + +const CALLABLE_KINDS = new Set(['function', 'method', 'route', 'test', 'component', 'job']); + +export function isCallableKind(kind: string): boolean { + return CALLABLE_KINDS.has(kind); +} + +export function symbolKindOf(kind: string): HaileSymbolKind { + switch (kind) { + case 'method': + return 'method'; + case 'route': + return 'route'; + case 'test': + return 'test'; + case 'job': + return 'job'; + case 'component': + return 'handler'; + default: + return 'function'; + } +} + +export interface AdaptOptions { + fileLayerByPath?: Map; + astRoleByPath?: Map; +} + +function parseParams(signature: string): Array<{ name: string; type_name?: string }> { + const open = signature.indexOf('('); + const close = signature.lastIndexOf(')'); + if (open < 0 || close <= open) return []; + const inner = signature.slice(open + 1, close).trim(); + if (!inner || inner === 'void') return []; + return inner + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .slice(0, 16) + .map((part) => { + const cleaned = part.replace(/\b(const|mut|ref|public|private|protected|readonly|final)\b/g, '').trim(); + if (cleaned.includes(':')) { + const [name, typeName] = cleaned.split(':').map((s) => s.trim()); + return { name: name || 'arg', type_name: typeName || undefined }; + } + const bits = cleaned.split(/\s+/); + if (bits.length >= 2) return { name: bits[bits.length - 1]!, type_name: bits[0] }; + return { name: cleaned }; + }); +} + +function parseReturnType(signature: string): string | undefined { + const arrow = signature.lastIndexOf('->'); + if (arrow >= 0) return signature.slice(arrow + 2).replace(/[{;].*$/, '').trim() || undefined; + const colon = signature.lastIndexOf(':'); + const paren = signature.lastIndexOf(')'); + if (colon > paren && paren >= 0) { + return signature.slice(colon + 1).replace(/[{;=>].*$/, '').trim() || undefined; + } + return undefined; +} + +function callsFor(nodeId: string, edges: GraphEdge[], idToName: Map): string[] { + const out: string[] = []; + const seen = new Set(); + for (const e of edges) { + if (e.src !== nodeId) continue; + if (e.kind !== 'call' && e.kind !== 'references') continue; + const name = idToName.get(e.dst); + if (!name || seen.has(name)) continue; + seen.add(name); + out.push(name); + if (out.length >= 24) break; + } + return out; +} + +export function adaptGraph(graph: Pick, options: AdaptOptions = {}): HaileCallable[] { + const idToName = new Map(); + for (const n of graph.nodes) idToName.set(n.id, n.name); + const out: HaileCallable[] = []; + for (const node of graph.nodes) { + if (!isCallableKind(node.kind)) continue; + const signature = node.signature ?? ''; + const callable: HaileCallable = { + node_id: node.id, + file_path: node.file, + name: node.name, + qualified_name: node.qualifiedName || node.name, + symbol_kind: symbolKindOf(node.kind), + language: node.lang ?? '', + signature, + calls: callsFor(node.id, graph.edges, idToName), + parameters: parseParams(signature), + return_type: parseReturnType(signature), + }; + const layer = options.fileLayerByPath?.get(node.file); + if (layer) callable.file_layer = layer; + const astRole = options.astRoleByPath?.get(node.file); + if (astRole) callable.ast_role = astRole; + out.push(callable); + } + out.sort((a, b) => (a.node_id < b.node_id ? -1 : a.node_id > b.node_id ? 1 : 0)); + return out; +} diff --git a/src/engine/haile/architecture-callables.test.ts b/src/engine/haile/architecture-callables.test.ts new file mode 100644 index 0000000..b3190e9 --- /dev/null +++ b/src/engine/haile/architecture-callables.test.ts @@ -0,0 +1,75 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { architectureSidecarSummary, loadArchitectureCallables } from './architecture-callables.js'; +import { emptySidecar, writeSidecarDocument } from './sidecar.js'; +import type { HaileSymbol } from './types.js'; + +let root: string; +let prevInRepo: string | undefined; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-arch-callables-')); + prevInRepo = process.env.VIBGRATE_GRAPH_IN_REPO; + process.env.VIBGRATE_GRAPH_IN_REPO = '1'; // the map (and its sidecar) live under /.vibgrate + fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate', 'graph.json'), '{}'); +}); + +afterEach(() => { + if (prevInRepo === undefined) delete process.env.VIBGRATE_GRAPH_IN_REPO; + else process.env.VIBGRATE_GRAPH_IN_REPO = prevInRepo; + fs.rmSync(root, { recursive: true, force: true }); +}); + +function symbol(over: Partial & Pick): HaileSymbol { + return { + qualified_name: over.name, + symbol_kind: 'function', + role: { primary: 'controller', alternatives: [], confidence: 0.8, band: 'high' }, + purposes: [{ purpose: 'validate', confidence: 1 }], + intent: { text: `validates ${over.name}`, verbs: ['validate'], objects: [over.name] }, + evidence: [], + ...over, + }; +} + +function writeSidecar(corpusHash: string, symbols: HaileSymbol[]): void { + const doc = emptySidecar(corpusHash); + doc.symbols = symbols; + writeSidecarDocument(doc, path.join(root, '.vibgrate', 'graph.json')); +} + +describe('loadArchitectureCallables', () => { + it('projects usable symbols, skips abstain / unknown, and copies file-layer as an annotation', () => { + writeSidecar('abc', [ + symbol({ node_id: 'n1', file_path: 'src/api/users.ts', name: 'createUser', file_layer: 'routing' }), + symbol({ node_id: 'n2', file_path: 'src/api/misc.ts', name: 'helper', role: { primary: 'unknown', alternatives: [], confidence: 0.1, band: 'abstain' } }), + ]); + const rows = loadArchitectureCallables(root, '__repo__', { corpusHash: 'abc' }); + expect(rows).toEqual([ + { file: 'src/api/users.ts', name: 'createUser', role: 'controller', purposes: ['validate'], intent: 'validates createUser', layer: 'routing' }, + ]); + }); + + it('omits everything when the sidecar is bound to a different corpus', () => { + writeSidecar('abc', [symbol({ node_id: 'n1', file_path: 'src/a.ts', name: 'a' })]); + expect(loadArchitectureCallables(root, '__repo__', { corpusHash: 'other' })).toBeUndefined(); + expect(architectureSidecarSummary(root, { corpusHash: 'other' })).toBeUndefined(); + expect(architectureSidecarSummary(root, { corpusHash: 'abc' })).toMatchObject({ symbolCount: 1 }); + }); + + it('filters to the scope path and returns undefined for an empty scope', () => { + writeSidecar('abc', [ + symbol({ node_id: 'n1', file_path: 'apps/web/src/a.ts', name: 'a' }), + symbol({ node_id: 'n2', file_path: 'apps/api/src/b.ts', name: 'b' }), + ]); + expect(loadArchitectureCallables(root, 'apps/api', { corpusHash: 'abc' })?.map((r) => r.name)).toEqual(['b']); + expect(loadArchitectureCallables(root, 'packages/none', { corpusHash: 'abc' })).toBeUndefined(); + }); + + it('is absent (not empty) when no sidecar exists', () => { + expect(loadArchitectureCallables(root, '__repo__', { corpusHash: 'abc' })).toBeUndefined(); + }); +}); diff --git a/src/engine/haile/architecture-callables.ts b/src/engine/haile/architecture-callables.ts new file mode 100644 index 0000000..1f24b2e --- /dev/null +++ b/src/engine/haile/architecture-callables.ts @@ -0,0 +1,86 @@ +/** + * Project usable classify-file symbols onto the Architecture panel wire. + * File-layer stays the interlingua. These rows are omitted when the module + * did not run, when a symbol is malformed, or when the band is abstain. + */ +import { resolveGraphPath } from '../artifacts.js'; +import { isUsableHaileSymbol } from './format.js'; +import { readHaileSidecar } from './sidecar.js'; + +export interface ArchitectureCallableWire { + file: string; + name: string; + role: string; + purposes: string[]; + intent: string; + layer?: string; + /** Boundary findings from the module's policy pack (additive). */ + findings?: { rule: string; severity: string; message: string }[]; +} + +const CALLABLE_CAP = 200; + +export interface LoadArchitectureCallablesOptions { + /** + * The graph's `provenance.corpusHash`. When given, a sidecar bound to a + * different corpus is treated as absent (stale → omit, never a stale row on + * the panel). Pass it whenever the caller holds the graph. + */ + corpusHash?: string; + /** Override the map location (the `--graph` flag); defaults to the resolved graph path. */ + graphPath?: string; +} + +export function loadArchitectureCallables( + root: string, + scopePath?: string, + options: LoadArchitectureCallablesOptions = {}, +): ArchitectureCallableWire[] | undefined { + const sidecar = readHaileSidecar(options.graphPath ?? resolveGraphPath(root), { + corpusHash: options.corpusHash, + }); + if (!sidecar?.symbols.length) return undefined; + const prefix = + scopePath && scopePath !== '.' && scopePath !== '__repo__' + ? scopePath.replace(/\\/g, '/').replace(/\/+$/, '') + : ''; + const rows: ArchitectureCallableWire[] = []; + for (const symbol of sidecar.symbols) { + if (!isUsableHaileSymbol(symbol)) continue; + if (symbol.role.band === 'abstain' || symbol.role.primary === 'unknown') continue; + const file = symbol.file_path.replace(/\\/g, '/'); + if (prefix && file !== prefix && !file.startsWith(`${prefix}/`)) continue; + const purposes = symbol.purposes + .filter((p) => p && typeof p.purpose === 'string' && p.purpose) + .map((p) => p.purpose); + const row: ArchitectureCallableWire = { + file, + name: symbol.name, + role: symbol.role.primary, + purposes, + intent: symbol.intent.text, + }; + if (symbol.file_layer) row.layer = symbol.file_layer; + if (Array.isArray(symbol.findings) && symbol.findings.length) { + row.findings = symbol.findings + .filter((f) => f && typeof f.rule === 'string' && typeof f.message === 'string') + .slice(0, 4) + .map((f) => ({ rule: f.rule, severity: typeof f.severity === 'string' ? f.severity : 'warn', message: f.message })); + } + rows.push(row); + if (rows.length >= CALLABLE_CAP) break; + } + return rows.length ? rows : undefined; +} + +/** Symbol count of a usable (magic + corpus-bound) sidecar, for the module-status footnote. */ +export function architectureSidecarSummary( + root: string, + options: LoadArchitectureCallablesOptions = {}, +): { engineVersion: string; symbolCount: number } | undefined { + const sidecar = readHaileSidecar(options.graphPath ?? resolveGraphPath(root), { + corpusHash: options.corpusHash, + }); + if (!sidecar) return undefined; + return { engineVersion: sidecar.engine_version, symbolCount: sidecar.symbols.length }; +} diff --git a/src/engine/haile/classify.ts b/src/engine/haile/classify.ts new file mode 100644 index 0000000..bc1aa7c --- /dev/null +++ b/src/engine/haile/classify.ts @@ -0,0 +1,22 @@ +/** + * IP BOUNDARY — this file must not contain a lexicon. + * + * Classification lives in the separately installed architecture module. + * The public CLI loads that module from the cache (or VIBGRATE_HAILE_PATH) + * and only reads the classify file it writes. + * + * Do not add token weights, path rules, softmax, or purpose floors here. + * If a caller still imports classifyCallable / classifyAll, it is a bug. + */ + +export function classifyCallable(): never { + throw new Error( + 'Architecture classify is not shipped in the public CLI. The architecture module installs by default; set VIBGRATE_NO_KERNEL=1 to disable it.', + ); +} + +export function classifyAll(): never { + throw new Error( + 'Architecture classify is not shipped in the public CLI. The architecture module installs by default; set VIBGRATE_NO_KERNEL=1 to disable it.', + ); +} diff --git a/src/engine/haile/create-provider.ts b/src/engine/haile/create-provider.ts new file mode 100644 index 0000000..e790f56 --- /dev/null +++ b/src/engine/haile/create-provider.ts @@ -0,0 +1,9 @@ +/** + * IP BOUNDARY — the factory lives in the optional `@vibgrate/haile` module. + * The public CLI loads that module through haile-provider.ts. + * + * A missing module means the provider is absent. Never a TypeScript lexicon. + */ +export function createHaileProvider(): null { + return null; +} diff --git a/src/engine/haile/extract-count.test.ts b/src/engine/haile/extract-count.test.ts new file mode 100644 index 0000000..50d7a62 --- /dev/null +++ b/src/engine/haile/extract-count.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { compareExtractCounts, vgCallableNames } from './extract-count.js'; + +describe('H3 extract-count', () => { + it('counts only callable node kinds', () => { + const names = vgCallableNames([ + { kind: 'method', name: 'handle_login' }, + { kind: 'function', name: 'find_user' }, + { kind: 'file', name: 'login.py' }, + { kind: 'module', name: 'app' }, + { kind: 'method', name: 'handle_login' }, + ]); + expect(names).toEqual(['find_user', 'handle_login']); + }); + + it('reports gold names vg missed without inventing a re-parse', () => { + const row = compareExtractCounts( + [ + { kind: 'method', name: 'handle_login', file: 'app/controllers/login.py' }, + { kind: 'function', name: 'find_user', file: 'app/repo/users.py' }, + ], + ['handle_login', 'find_user', 'verify_jwt'], + ); + expect(row.vgCallables).toBe(2); + expect(row.goldTotal).toBe(3); + expect(row.goldInVg).toBe(2); + expect(row.goldMissingVg).toEqual(['verify_jwt']); + }); +}); diff --git a/src/engine/haile/extract-count.ts b/src/engine/haile/extract-count.ts new file mode 100644 index 0000000..0cd648f --- /dev/null +++ b/src/engine/haile/extract-count.ts @@ -0,0 +1,56 @@ +/** + * H3 extract-count helpers. Compare HAILE fixture gold / extract names + * against callable nodes in a vg-graph/1.x document. A miss is an + * extract-quality finding — this module never re-parses source. + */ + +export const H3_CALLABLE_KINDS = new Set([ + 'function', + 'method', + 'route', + 'test', + 'component', + 'job', +]); + +export interface GraphNodeLite { + kind?: string; + name?: string; + file?: string; +} + +export interface ExtractCountRow { + vgCallables: number; + goldTotal: number; + goldInVg: number; + goldMissingVg: string[]; +} + +export function vgCallableNames(nodes: readonly GraphNodeLite[]): string[] { + const names: string[] = []; + const seen = new Set(); + for (const n of nodes) { + if (!H3_CALLABLE_KINDS.has(String(n.kind ?? ''))) continue; + const name = (n.name ?? '').trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + names.push(name); + } + names.sort(); + return names; +} + +export function compareExtractCounts( + nodes: readonly GraphNodeLite[], + goldNames: readonly string[], +): ExtractCountRow { + const vg = new Set(vgCallableNames(nodes)); + const gold = [...new Set(goldNames.filter(Boolean))]; + const missing = gold.filter((n) => !vg.has(n)).sort(); + return { + vgCallables: vg.size, + goldTotal: gold.length, + goldInVg: gold.length - missing.length, + goldMissingVg: missing, + }; +} diff --git a/src/engine/haile/findings.test.ts b/src/engine/haile/findings.test.ts new file mode 100644 index 0000000..f52c494 --- /dev/null +++ b/src/engine/haile/findings.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { formatHaileLines, haileJsonFields } from './format.js'; +import type { HaileSymbol } from './types.js'; + +/** Boundary findings ride on the symbol record; `vg show` prints them as their own lines. */ +const symbol: HaileSymbol = { + node_id: 'n1', + file_path: 'src/Api/Controllers/ProductsController.cs', + name: 'Create', + qualified_name: 'ProductsController.Create', + symbol_kind: 'method', + role: { primary: 'controller', alternatives: [], confidence: 0.9, band: 'high' }, + purposes: [{ purpose: 'persist', confidence: 1 }, { purpose: 'network_io', confidence: 0.6 }], + intent: { text: 'writes Product via SaveChangesAsync, returns 201', verbs: ['persist'], objects: ['Product'] }, + evidence: [{ kind: 'effect', signal: 'body writes Product via SaveChangesAsync', weight: 4.2 }], + findings: [ + { rule: 'hexagonal-v1/controller-persists', severity: 'hard', message: 'HTTP handler writes the store via SaveChangesAsync; controllers must not persist' }, + { rule: 'hexagonal-v1/controller-calls-out', severity: 'warn', message: 'HTTP handler calls out via post; it skips the application layer' }, + ], +}; + +describe('boundary findings on vg show', () => { + it('prints one boundary line per finding after the classification lines', () => { + const lines = formatHaileLines(symbol); + expect(lines[0]).toContain('role controller'); + expect(lines).toContain(' boundary violation: HTTP handler writes the store via SaveChangesAsync; controllers must not persist (hexagonal-v1/controller-persists)'); + expect(lines).toContain(' boundary warning: HTTP handler calls out via post; it skips the application layer (hexagonal-v1/controller-calls-out)'); + }); + + it('is absent, not broken, when a symbol carries none or malformed rows', () => { + const none = formatHaileLines({ ...symbol, findings: undefined }); + expect(none.some((l) => l.includes('boundary'))).toBe(false); + const bad = formatHaileLines({ ...symbol, findings: [{ rule: 1, message: null } as never, null as never] }); + expect(bad.some((l) => l.includes('boundary'))).toBe(false); + expect(haileJsonFields(symbol)?.findings).toHaveLength(2); + expect(haileJsonFields({ ...symbol, findings: undefined })?.findings).toEqual([]); + }); +}); diff --git a/src/engine/haile/format.ts b/src/engine/haile/format.ts new file mode 100644 index 0000000..52fc6af --- /dev/null +++ b/src/engine/haile/format.ts @@ -0,0 +1,59 @@ +import type { HaileSymbol } from './types.js'; + +/** True when a classify-file symbol has the fields `vg show` reads. */ +export function isUsableHaileSymbol(symbol: unknown): symbol is HaileSymbol { + if (!symbol || typeof symbol !== 'object') return false; + const s = symbol as Partial; + if (typeof s.node_id !== 'string' || !s.node_id) return false; + const role = s.role; + if (!role || typeof role !== 'object') return false; + if (typeof role.primary !== 'string' || !role.primary) return false; + if (typeof role.confidence !== 'number' || !Number.isFinite(role.confidence)) return false; + if (typeof role.band !== 'string') return false; + if (!Array.isArray(role.alternatives)) return false; + if (!Array.isArray(s.purposes)) return false; + const intent = s.intent; + if (!intent || typeof intent !== 'object' || typeof intent.text !== 'string') return false; + if (!Array.isArray(intent.verbs) || !Array.isArray(intent.objects)) return false; + return true; +} + +/** Text lines for `vg show`. Empty when the symbol is missing or malformed. */ +export function formatHaileLines(symbol: HaileSymbol | null | undefined): string[] { + if (!isUsableHaileSymbol(symbol)) return []; + const alts = symbol.role.alternatives + .slice(0, 3) + .filter((a) => a && typeof a.role === 'string' && typeof a.confidence === 'number') + .map((a) => `${a.role} ${a.confidence.toFixed(2)}`) + .join(', '); + const purposes = + symbol.purposes + .filter((p) => p && typeof p.purpose === 'string' && typeof p.confidence === 'number') + .map((p) => `${p.purpose} ${p.confidence.toFixed(2)}`) + .join(', ') || '—'; + const lines = [ + ` role ${symbol.role.primary} ${symbol.role.confidence.toFixed(2)} · ${symbol.role.band}${alts ? ` (${alts})` : ''}`, + ` purposes ${purposes}`, + ` intent ${symbol.intent.text}`, + ]; + if (symbol.file_layer) lines.push(` file-layer ${symbol.file_layer}`); + for (const finding of symbol.findings ?? []) { + if (!finding || typeof finding.message !== 'string' || typeof finding.rule !== 'string') continue; + lines.push(` boundary ${finding.severity === 'hard' ? 'violation' : 'warning'}: ${finding.message} (${finding.rule})`); + } + return lines; +} + +export function haileJsonFields(symbol: HaileSymbol | undefined): Record | undefined { + if (!isUsableHaileSymbol(symbol)) return undefined; + return { + role: symbol.role, + purposes: symbol.purposes, + intent: symbol.intent, + band: symbol.role.band, + evidence: Array.isArray(symbol.evidence) ? symbol.evidence : [], + findings: Array.isArray(symbol.findings) ? symbol.findings : [], + file_layer: symbol.file_layer ?? null, + ast_role: symbol.ast_role ?? null, + }; +} diff --git a/src/engine/haile/haile-provider.ts b/src/engine/haile/haile-provider.ts new file mode 100644 index 0000000..1483c6d --- /dev/null +++ b/src/engine/haile/haile-provider.ts @@ -0,0 +1,114 @@ +/** + * Public seam for the HAILE Fast kernel. Mirrors engine/hcs-provider.ts: + * a capability interface with no lexicon, no weights, no softmax. + * The CLI loads the separately installed module (`@vibgrate/haile`) from + * the modules cache and sanitises every result at its own trust boundary. + * + * - VIBGRATE_NO_KERNEL=1 disables the seam. + * - VIBGRATE_HAILE_PATH points at a provider module (file, or a directory + * containing index.js). + * - Otherwise `$VIBGRATE_MODULE_DIR|~/.cache/vibgrate/modules/haile/index.js`. + * + * Absent / failed / privacy mode → null. Never a TS lexicon. + */ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { modulesBaseDir } from '../../install/module-core.js'; + +export type HaileProfile = 'strict' | 'balanced' | 'exploratory'; + +export interface HaileClassifyInput { + name: string; + path: string; + symbolKind?: string; + fileKind?: string; + calls?: readonly string[]; + types?: readonly string[]; + idents?: readonly string[]; + profile?: HaileProfile; + // Optional richer evidence understood by module kernels ≥ 0.3.0. + qualifiedName?: string; + doc?: string; + signature?: string; + params?: readonly string[]; + returnType?: string; + calleePaths?: readonly string[]; + imports?: readonly string[]; + astRole?: string; + effects?: Readonly>; + duties?: readonly unknown[]; +} + +export interface HaileClassification { + primary: string; + confidence: number; + band: string; + alternatives: { role: string; confidence: number }[]; + purposes: { purpose: string; confidence: number }[]; + intent: { text: string; verbs: string[]; objects: string[] }; + evidence: { kind: string; signal: string; weight: number }[]; + findings?: { rule: string; severity: string; message: string }[]; +} + +export interface HaileProvider { + version(): string; + classify(input: HaileClassifyInput): HaileClassification | null; +} + +function disabled(): boolean { + const v = process.env.VIBGRATE_NO_KERNEL; + return v === '1' || v === 'true'; +} + +/** Default install location (shared with install/haile-module.ts). */ +export function haileModuleDir(): string { + return path.join(modulesBaseDir(), 'haile'); +} + +function candidatePaths(): string[] { + const custom = process.env.VIBGRATE_HAILE_PATH; + if (custom) { + const p = path.resolve(custom); + try { + if (fs.statSync(p).isDirectory()) return [path.join(p, 'index.js')]; + } catch { + /* fall through — treat as a file path */ + } + return [p]; + } + return [path.join(haileModuleDir(), 'index.js')]; +} + +let cached: HaileProvider | null | undefined; + +export function resetHaileProviderCache(): void { + cached = undefined; +} + +/** + * Load the optional HAILE provider. Memoized; returns null when disabled, + * not installed, or the module fails the contract. Callers treat null as + * "do not write the classify file" — never as a guess. + */ +export async function loadHaileProvider(): Promise { + if (cached !== undefined) return cached; + cached = null; + if (disabled()) return cached; + for (const p of candidatePaths()) { + if (!fs.existsSync(p)) continue; + try { + const mod = (await import(pathToFileURL(p).href)) as { + createHaileProvider?: () => HaileProvider | null; + }; + const provider = mod.createHaileProvider?.(); + if (provider && typeof provider.version === 'function' && typeof provider.classify === 'function') { + cached = provider; + return cached; + } + } catch { + /* broken module reads as absent */ + } + } + return cached; +} diff --git a/src/engine/haile/haile.test.ts b/src/engine/haile/haile.test.ts new file mode 100644 index 0000000..a0ff57e --- /dev/null +++ b/src/engine/haile/haile.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { GraphEdge, GraphNode, VgGraph } from '../../schema.js'; +import { adaptGraph, isCallableKind } from './adapter.js'; +import { classifyCallable } from './classify.js'; +import { formatHaileLines } from './format.js'; +import { + emptySidecar, + haileSidecarPathFor, + readHaileSidecar, + serializeSidecar, + writeHaileSidecarFor, + writeSidecarDocument, +} from './sidecar.js'; +import type { HaileSymbol } from './types.js'; +import { HAILE_ENGINE_VERSION, HAILE_MAGIC, HAILE_TAXONOMY } from './types.js'; + +function node(partial: Partial & Pick): GraphNode { + return { + qualifiedName: partial.qualifiedName ?? partial.name, + span: partial.span ?? { start: 1, end: 8 }, + lang: partial.lang ?? 'cs', + importance: 0.1, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: 0, + isHub: false, + tested: null, + ...partial, + }; +} + +function tinyGraph(nodes: GraphNode[], edges: GraphEdge[] = []): VgGraph { + return { + schemaVersion: 'vg-graph/1.1', + generatedAt: '2020-01-01T00:00:00.000Z', + provenance: { + tool: 'vg', + version: 'test', + grammars: {}, + resolver: ['heuristic'], + deep: false, + corpusHash: 'abc123', + }, + meta: { + root: '.', + languages: ['cs'], + counts: { nodes: nodes.length, edges: edges.length, areas: 0, tests: 0, untested: 0 }, + cluster: 'none', + edgeKinds: [], + }, + nodes, + edges, + areas: [], + }; +} + +function sampleSymbol(): HaileSymbol { + return { + node_id: 'n1', + file_path: 'src/controllers/UsersController.cs', + name: 'CreateUser', + qualified_name: 'UsersController.CreateUser', + symbol_kind: 'method', + role: { + primary: 'controller', + alternatives: [{ role: 'application_service', confidence: 0.22 }], + confidence: 0.81, + band: 'high', + }, + purposes: [ + { purpose: 'validate', confidence: 0.9 }, + { purpose: 'persist', confidence: 0.8 }, + ], + intent: { + text: 'validates CreateUserRequest and persists User', + verbs: ['validate', 'persist'], + objects: ['CreateUserRequest', 'User'], + }, + evidence: [{ kind: 'lexical', signal: 'name looks like a handler', weight: 1 }], + }; +} + +describe('H0 adapter', () => { + it('keeps only callable node kinds and copies call edges as names', () => { + const graph = tinyGraph( + [ + node({ id: 'n1', kind: 'method', name: 'CreateUser', file: 'UsersController.cs', signature: 'CreateUser(req: CreateUserRequest): User' }), + node({ id: 'n2', kind: 'method', name: 'Save', file: 'UserRepo.cs' }), + node({ id: 'n3', kind: 'file', name: 'UsersController.cs', file: 'UsersController.cs' }), + ], + [{ id: 'e1', kind: 'call', src: 'n1', dst: 'n2', resolution: 'heuristic', confidence: 0.7 }], + ); + const adapted = adaptGraph(graph); + expect(adapted.map((c) => c.name)).toEqual(['CreateUser', 'Save']); + expect(adapted[0]!.calls).toEqual(['Save']); + expect(adapted[0]!.parameters[0]?.type_name).toBe('CreateUserRequest'); + expect(isCallableKind('file')).toBe(false); + }); + + it('carries file-layer and ast-role annotations without recomputing them', () => { + const graph = tinyGraph([ + node({ id: 'n1', kind: 'method', name: 'CreateUser', file: 'src/controllers/UsersController.cs' }), + ]); + const adapted = adaptGraph(graph, { + fileLayerByPath: new Map([['src/controllers/UsersController.cs', 'routing']]), + astRoleByPath: new Map([['src/controllers/UsersController.cs', 'controller']]), + }); + expect(adapted[0]!.file_layer).toBe('routing'); + expect(adapted[0]!.ast_role).toBe('controller'); + }); +}); + +describe('H1 classify boundary', () => { + it('refuses rather than guessing when the kernel is not shipped', () => { + expect(() => classifyCallable()).toThrow(/Architecture classify is not shipped/); + }); +}); + +describe('H1 classify file', () => { + it('is snake_case, corpus-hash bound, and byte-stable', () => { + const doc = emptySidecar('abc123'); + doc.symbols = [sampleSymbol()]; + const a = serializeSidecar(doc); + const b = serializeSidecar(doc); + expect(a).toBe(b); + const parsed = JSON.parse(a); + expect(parsed.magic).toBe(HAILE_MAGIC); + expect(parsed.taxonomy).toBe(HAILE_TAXONOMY); + expect(parsed.engine_version).toBe(HAILE_ENGINE_VERSION); + expect(parsed.corpus_hash).toBe('abc123'); + expect(parsed.symbols[0].node_id).toBe('n1'); + expect(Object.keys(parsed.symbols[0])).toEqual( + expect.arrayContaining(['node_id', 'file_path', 'qualified_name', 'symbol_kind', 'role', 'purposes', 'intent', 'evidence']), + ); + }); + + it('returns null on stale corpus_hash and never throws', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'haile-')); + const graphPath = path.join(dir, 'graph.json'); + fs.writeFileSync(graphPath, '{}'); + const doc = emptySidecar('abc123'); + doc.symbols = [sampleSymbol()]; + expect(writeSidecarDocument(doc, graphPath)).toBe(haileSidecarPathFor(graphPath)); + expect(readHaileSidecar(graphPath, { corpusHash: 'abc123' })?.symbols).toHaveLength(1); + expect(readHaileSidecar(graphPath, { corpusHash: 'other' })).toBeNull(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writeHaileSidecarFor refuses when the architecture module is not installed', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'haile-')); + const graphPath = path.join(dir, 'graph.json'); + fs.writeFileSync(graphPath, '{}'); + const prevPath = process.env.VIBGRATE_HAILE_PATH; + const prevNo = process.env.VIBGRATE_NO_KERNEL; + delete process.env.VIBGRATE_HAILE_PATH; + process.env.VIBGRATE_NO_KERNEL = '1'; + try { + const graph = tinyGraph([node({ id: 'n1', kind: 'function', name: 'main', file: 'main.rs' })]); + expect(writeHaileSidecarFor(graph, graphPath)).toBeNull(); + } finally { + if (prevPath === undefined) delete process.env.VIBGRATE_HAILE_PATH; + else process.env.VIBGRATE_HAILE_PATH = prevPath; + if (prevNo === undefined) delete process.env.VIBGRATE_NO_KERNEL; + else process.env.VIBGRATE_NO_KERNEL = prevNo; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('H2 vg show formatter', () => { + it('prints role distribution, purposes, intent, and band', () => { + const text = formatHaileLines(sampleSymbol()).join('\n'); + expect(text).toMatch(/role controller/); + expect(text).toMatch(/purposes /); + expect(text).toMatch(/intent /); + expect(text).toMatch(/high|medium|low|abstain/); + }); +}); diff --git a/src/engine/haile/index.ts b/src/engine/haile/index.ts new file mode 100644 index 0000000..64a489d --- /dev/null +++ b/src/engine/haile/index.ts @@ -0,0 +1,31 @@ +export { adaptGraph, isCallableKind, symbolKindOf } from './adapter.js'; +export { formatHaileLines, haileJsonFields } from './format.js'; +export { loadHaileProvider, resetHaileProviderCache, haileModuleDir } from './haile-provider.js'; +export type { HaileClassification, HaileClassifyInput, HaileProvider } from './haile-provider.js'; +export { + deleteHaileSidecarFor, + emptySidecar, + findHaileSymbol, + haileSidecarPathFor, + readHaileSidecar, + serializeSidecar, + writeHaileSidecarFor, + writeSidecarDocument, +} from './sidecar.js'; +export type { + HaileCallable, + HaileModuleSummary, + HaileProfile, + HaileSidecar, + HaileSymbol, +} from './types.js'; +export { + DEFAULT_PROFILE, + DEFAULT_SYMBOL_CAP, + HAILE_ENGINE_VERSION, + HAILE_IR, + HAILE_MAGIC, + HAILE_TAXONOMY, + PURPOSES, + ROLES, +} from './types.js'; diff --git a/src/engine/haile/intent.ts b/src/engine/haile/intent.ts new file mode 100644 index 0000000..8e419ef --- /dev/null +++ b/src/engine/haile/intent.ts @@ -0,0 +1,11 @@ +/** + * IP BOUNDARY — this file must not contain a lexicon. + * + * Intent rendering lives in the optional `@vibgrate/haile` module. + * Do not add verb tokens, purpose maps, noun extractors, or templates here. + */ +export function renderIntent(): never { + throw new Error( + 'HAILE intent is not shipped in the public CLI. Install the architecture module (`vg module install haile`) or set VIBGRATE_HAILE_PATH.', + ); +} diff --git a/src/engine/haile/role-preference.test.ts b/src/engine/haile/role-preference.test.ts new file mode 100644 index 0000000..fce29cf --- /dev/null +++ b/src/engine/haile/role-preference.test.ts @@ -0,0 +1,88 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { applyRolePreference, loadRoleMap, type RoleMap } from './role-preference.js'; +import { emptySidecar, writeSidecarDocument } from './sidecar.js'; + +function seed(id: string, name: string, why = 'name match'): { node: { id: string; name: string; qualifiedName: string }; why: string; score: number; role?: string } { + return { node: { id, name, qualifiedName: `mod.${name}` }, why, score: 1 }; +} + +const roles: RoleMap = new Map([ + ['svc', { role: 'application_service', band: 'high' }], + ['ctl', { role: 'controller', band: 'medium' }], + ['fmt', { role: 'utility', band: 'high' }], + ['repo', { role: 'repository', band: 'high' }], +]); + +describe('applyRolePreference', () => { + it('lifts a controller / application service by at most two places, keeps everything else in rank order, drops unnamed utilities', () => { + const out = applyRolePreference( + [seed('e', 'Product'), seed('fmt', 'formatMoney'), seed('repo', 'findOrder'), seed('x', 'unclassified'), seed('svc', 'placeOrder'), seed('ctl', 'ordersHandler')], + roles, + 'how is an order placed', + ); + // Once the utility is gone placeOrder is 4th → it rises two places, past + // the unclassified row and level with the repository, where rank order + // keeps the repository first; the top match is never displaced. + expect(out.map((s) => s.node.id)).toEqual(['e', 'repo', 'svc', 'x', 'ctl']); + // The role is a structured field; `why` (rendered into the context) is untouched. + expect(out[2]!.role).toBe('application_service'); + expect(out[2]!.why).toBe('name match'); + expect(out[3]!.role).toBeUndefined(); // unclassified rows are untouched + expect(out[3]!.why).toBe('name match'); + }); + + it('never leapfrogs a clearly better match: the top hit stays on top', () => { + const out = applyRolePreference( + [seed('login', 'login'), seed('t', 'test_login'), seed('req', 'LoginRequest'), seed('h', 'hash'), seed('ctl', 'register')], + new Map([['ctl', { role: 'controller', band: 'high' }], ['login', { role: 'cross_cutting', band: 'high' }]]), + 'how does user login work', + ); + expect(out.map((s) => s.node.id)).toEqual(['login', 't', 'req', 'ctl', 'h']); + }); + + it('keeps a utility the ask names, or when the ask reaches for helpers', () => { + const seeds = [seed('fmt', 'formatMoney'), seed('svc', 'placeOrder')]; + expect(applyRolePreference(seeds, roles, 'where is formatMoney used').map((s) => s.node.id)).toEqual(['svc', 'fmt']); + expect(applyRolePreference(seeds, roles, 'list the formatting helpers').map((s) => s.node.id)).toEqual(['svc', 'fmt']); + expect(applyRolePreference(seeds, roles, 'how do we charge a card').map((s) => s.node.id)).toEqual(['svc']); + }); + + it('is inert without a role map', () => { + const seeds = [seed('fmt', 'formatMoney'), seed('svc', 'placeOrder')]; + expect(applyRolePreference(seeds, null, 'anything')).toBe(seeds); + expect(applyRolePreference(seeds, new Map(), 'anything')).toBe(seeds); + }); +}); + +describe('loadRoleMap', () => { + let root: string; + let prev: string | undefined; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-role-map-')); + prev = process.env.VIBGRATE_GRAPH_IN_REPO; + process.env.VIBGRATE_GRAPH_IN_REPO = '1'; + fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate', 'graph.json'), '{}'); + }); + afterEach(() => { + if (prev === undefined) delete process.env.VIBGRATE_GRAPH_IN_REPO; + else process.env.VIBGRATE_GRAPH_IN_REPO = prev; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads confident roles bound to the corpus, skips abstain, and is null when stale or missing', () => { + expect(loadRoleMap(root, 'abc')).toBeNull(); + const doc = emptySidecar('abc'); + const base = { file_path: 'a.ts', qualified_name: 'a', symbol_kind: 'function', purposes: [], intent: { text: '', verbs: [], objects: [] }, evidence: [] }; + doc.symbols = [ + { ...base, node_id: 'n1', name: 'a', role: { primary: 'controller', alternatives: [], confidence: 0.8, band: 'high' } }, + { ...base, node_id: 'n2', name: 'b', role: { primary: 'unknown', alternatives: [], confidence: 0.1, band: 'abstain' } }, + ]; + writeSidecarDocument(doc, path.join(root, '.vibgrate', 'graph.json')); + expect([...loadRoleMap(root, 'abc')!.entries()]).toEqual([['n1', { role: 'controller', band: 'high' }]]); + expect(loadRoleMap(root, 'other')).toBeNull(); + }); +}); diff --git a/src/engine/haile/role-preference.ts b/src/engine/haile/role-preference.ts new file mode 100644 index 0000000..4bc9c56 --- /dev/null +++ b/src/engine/haile/role-preference.ts @@ -0,0 +1,123 @@ +/** + * H4, engine side (docs/VSCODE-HAILE-MODULE-NOW.md §5.6): when the architecture + * module has classified the map, the context builders prefer the symbols that + * carry the application's shape — controllers, application services, ports — + * inside the first tokens of an answer, and leave utilities out unless the + * ask names them or asks for that role. + * + * This is a re-ordering of an already-ranked seed list, never a scorer: the + * relevance engine (or the mechanical fallback) decides what matches; this + * decides what a reader with a small budget should see first. It is inert + * without a classify file bound to the graph, and it never invents a role — + * abstain / unknown symbols are simply not in the map. + */ +import { resolveGraphPath } from '../artifacts.js'; +import { readHaileSidecar } from './sidecar.js'; +import { isUsableHaileSymbol } from './format.js'; + +export interface RoleHint { + role: string; + band: string; +} + +/** node id → role, for symbols the module was confident about. */ +export type RoleMap = Map; + +/** Roles that describe what the application does, shown first under a tight budget. */ +export const PREFERRED_ROLES: ReadonlySet = new Set(['controller', 'application_service', 'port']); +/** Roles left out of a budgeted answer unless the ask reaches for them. */ +export const DEMOTED_ROLES: ReadonlySet = new Set(['utility']); + +/** + * Read the classify file next to the map, bound to `corpusHash`. Returns + * null when there is none (module absent, privacy mode, stale sidecar) — the + * callers then leave the ranking exactly as it was. + */ +export function loadRoleMap(root: string, corpusHash: string | undefined): RoleMap | null { + try { + const sidecar = readHaileSidecar(resolveGraphPath(root), { corpusHash }); + if (!sidecar) return null; + const map: RoleMap = new Map(); + for (const symbol of sidecar.symbols) { + if (!isUsableHaileSymbol(symbol)) continue; + if (symbol.role.band === 'abstain' || symbol.role.primary === 'unknown') continue; + map.set(symbol.node_id, { role: symbol.role.primary, band: symbol.role.band }); + } + return map.size ? map : null; + } catch { + return null; + } +} + +/** The ask reaches for utilities when it names one, or asks for helpers/utilities as such. */ +function asksForUtilities(question: string): boolean { + return /\b(util|utils|utility|utilities|helper|helpers|format(?:ter|ting)?|parse(?:r|rs|ing)?|convert(?:er|ers)?|mapper|serializ|transform)\w*/i.test(question); +} + +/** Identifier parts of the ask, lowercased, so "chargeCard" and "charge_card" both name `chargeCard`. */ +function askIdentifiers(question: string): Set { + return new Set( + question + .split(/[^\p{L}\p{N}_]+/u) + .filter((t) => t.length >= 3) + .map((t) => t.toLowerCase()), + ); +} + +export interface Seedlike { + node: { id: string; name: string; qualifiedName: string }; + why: string; + /** Set by {@link applyRolePreference} for classified seeds — a structured field, never rendered into the context text. */ + role?: string; +} + +/** How many positions a preferred-role seed may rise past better-ranked neighbours. */ +export const ROLE_LIFT = 2; + +/** + * Bounded re-order of ranked seeds by architectural role. Relevance decided + * the order; this nudges it for a reader with a small budget: + * - a controller / application_service / port rises by at most + * {@link ROLE_LIFT} positions, so it opens the answer when it was close to + * the top but never leapfrogs a clearly better match (a wholesale + * "preferred roles first" put `register` above `login` for "how does + * login work"); + * - a utility is dropped unless the ask names it or asks for helpers; + * - everything else keeps its place, and seeds the module did not classify + * are untouched relative to each other. + * The role travels as a structured `role` field on the seed for surfaces that + * render structure (JSON, the editor); it is deliberately NOT appended to + * `why`, which is rendered into the model's context — annotating twelve rows + * measured +5.6 % context tokens on the test pack, the opposite of the point. + */ +export function applyRolePreference(seeds: T[], roles: RoleMap | null | undefined, question: string): T[] { + if (!roles || roles.size === 0 || seeds.length === 0) return seeds; + const named = askIdentifiers(question); + const wantsUtilities = asksForUtilities(question); + // Drop first, then number the survivors: a dropped utility must not count + // as a place a lifted seed has to climb past. + const kept: Array<{ seed: T; lift: number }> = []; + for (const seed of seeds) { + const hint = roles.get(seed.node.id); + if (!hint) { + kept.push({ seed, lift: 0 }); + continue; + } + const tagged = { ...seed, role: hint.role } as T; + if (PREFERRED_ROLES.has(hint.role)) { + kept.push({ seed: tagged, lift: ROLE_LIFT }); + } else if (DEMOTED_ROLES.has(hint.role)) { + const isNamed = + named.has(seed.node.name.toLowerCase()) || + [...named].some((t) => seed.node.qualifiedName.toLowerCase().endsWith(`.${t}`)); + if (wantsUtilities || isNamed) kept.push({ seed: tagged, lift: 0 }); + // otherwise dropped: a utility the ask did not reach for is budget spent on plumbing + } else { + kept.push({ seed: tagged, lift: 0 }); + } + } + const keyed = kept.map((k, index) => ({ seed: k.seed, key: index - k.lift, index })); + // A seed that lands on a neighbour's key keeps rank order (stable). + keyed.sort((a, b) => a.key - b.key || a.index - b.index); + return keyed.map((k) => k.seed); +} diff --git a/src/engine/haile/sidecar.ts b/src/engine/haile/sidecar.ts new file mode 100644 index 0000000..d647ac9 --- /dev/null +++ b/src/engine/haile/sidecar.ts @@ -0,0 +1,140 @@ +/** graph.haile.json reader + module launcher. Never classifies in-process. */ + +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { VgGraph } from '../../schema.js'; +import { kernelDisabled } from '../../install/module-core.js'; +import { haileModuleDir } from './haile-provider.js'; +import type { HaileModuleSummary, HaileProfile, HaileSidecar, HaileSymbol } from './types.js'; +import { + DEFAULT_PROFILE, + HAILE_ENGINE_VERSION, + HAILE_IR, + HAILE_MAGIC, + HAILE_TAXONOMY, +} from './types.js'; + +export function haileSidecarPathFor(graphPath: string): string { + if (graphPath.endsWith('.json')) return `${graphPath.slice(0, -5)}.haile.json`; + if (graphPath.endsWith('.snap')) return `${graphPath.slice(0, -5)}.haile.json`; + return `${graphPath}.haile.json`; +} + +export function deleteHaileSidecarFor(graphPath: string): void { + try { + fs.rmSync(haileSidecarPathFor(graphPath), { force: true }); + } catch { + /* best-effort */ + } +} + +/** Installed module entry: VIBGRATE_HAILE_PATH or the modules cache. */ +function resolveHaileModuleEntry(): string | null { + if (kernelDisabled()) return null; + const custom = process.env.VIBGRATE_HAILE_PATH?.trim(); + if (custom) { + const p = path.resolve(custom); + try { + if (fs.statSync(p).isDirectory()) { + const idx = path.join(p, 'index.js'); + return fs.existsSync(idx) ? idx : null; + } + } catch { + /* treat as a file path */ + } + return fs.existsSync(p) ? p : null; + } + const idx = path.join(haileModuleDir(), 'index.js'); + return fs.existsSync(idx) ? idx : null; +} + +export function serializeSidecar(sidecar: HaileSidecar): string { + return `${JSON.stringify(sidecar)}\n`; +} + +/** Write an already-built classify document. Does not classify. */ +export function writeSidecarDocument(sidecar: HaileSidecar, graphPath: string): string | null { + try { + const file = haileSidecarPathFor(graphPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, serializeSidecar(sidecar)); + fs.renameSync(tmp, file); + return file; + } catch { + return null; + } +} + +/** + * Derive the classify file from an in-memory graph by launching the + * installed module. Returns null when the module is missing — never a + * TypeScript lexicon copy, never a PATH/`HAILE_BIN` fallback. + */ +export function writeHaileSidecarFor( + graph: VgGraph, + graphPath: string, + options: { profile?: HaileProfile } = {}, +): string | null { + try { + const entry = resolveHaileModuleEntry(); + if (!entry) return null; + const file = haileSidecarPathFor(graphPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const profile = options.profile ?? DEFAULT_PROFILE; + const result = spawnSync( + process.execPath, + [entry, 'sidecar', '--stdin', '--out', file, '--profile', profile], + { + input: JSON.stringify(graph), + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, + }, + ); + if (result.status !== 0) return null; + if (!fs.existsSync(file)) return null; + return file; + } catch { + return null; + } +} + +export function readHaileSidecar( + graphPath: string, + expect?: { corpusHash?: string; engineVersion?: string }, +): HaileSidecar | null { + try { + const file = haileSidecarPathFor(graphPath); + if (!fs.existsSync(file)) return null; + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as HaileSidecar; + if (parsed.magic !== HAILE_MAGIC) return null; + if (parsed.taxonomy !== HAILE_TAXONOMY) return null; + if (expect?.corpusHash && parsed.corpus_hash !== expect.corpusHash) return null; + if (expect?.engineVersion && parsed.engine_version !== expect.engineVersion) return null; + if (!Array.isArray(parsed.symbols)) return null; + return parsed; + } catch { + return null; + } +} + +export function findHaileSymbol(sidecar: HaileSidecar | null, nodeId: string): HaileSymbol | undefined { + return sidecar?.symbols.find((s) => s.node_id === nodeId); +} + +/** Test helper: a schema-valid empty classify document. Not a classifier. */ +export function emptySidecar(corpusHash = '', profile: HaileProfile = DEFAULT_PROFILE): HaileSidecar { + return { + magic: HAILE_MAGIC, + taxonomy: HAILE_TAXONOMY, + ir: HAILE_IR, + corpus_hash: corpusHash, + engine_version: HAILE_ENGINE_VERSION, + profile, + symbols: [], + }; +} + +export type { HaileModuleSummary }; diff --git a/src/engine/haile/types.ts b/src/engine/haile/types.ts new file mode 100644 index 0000000..be3380d --- /dev/null +++ b/src/engine/haile/types.ts @@ -0,0 +1,155 @@ +/** HAILE sidecar types. Snake_case on the wire. Frozen for H0–H2. */ + +export const HAILE_MAGIC = 'vg.haile.v1'; +export const HAILE_TAXONOMY = 'haile.taxonomy.v1'; +export const HAILE_IR = 'haile.ir.v1'; +export const HAILE_ENGINE_VERSION = 'haile-fast/2026.903.3'; + +export const ROLES = [ + 'entry_point', + 'user_interface', + 'transport', + 'controller', + 'application_service', + 'use_case', + 'domain_service', + 'domain_model', + 'port', + 'repository', + 'adapter', + 'persistence', + 'integration', + 'messaging', + 'worker', + 'infrastructure', + 'cross_cutting', + 'test_support', + 'utility', + 'unknown', +] as const; + +export type HaileRole = (typeof ROLES)[number]; + +export const PURPOSES = [ + 'authenticate', + 'authorise', + 'validate', + 'orchestrate', + 'query', + 'persist', + 'transform', + 'map', + 'parse', + 'serialise', + 'render', + 'network_io', + 'file_io', + 'compute', + 'cache', + 'publish', + 'consume', + 'schedule', + 'configure', + 'observe', + 'encrypt', + 'hash', + 'lifecycle', + 'test', + 'unknown', +] as const; + +export type HailePurpose = (typeof PURPOSES)[number]; + +export const SYMBOL_KINDS = [ + 'function', + 'method', + 'constructor', + 'lambda', + 'type', + 'interface', + 'route', + 'handler', + 'job', + 'test', + 'query', + 'unknown', +] as const; + +export type HaileSymbolKind = (typeof SYMBOL_KINDS)[number]; + +export type ConfidenceBand = 'high' | 'medium' | 'low' | 'abstain'; +export type HaileProfile = 'strict' | 'balanced' | 'exploratory'; + +export interface HaileEvidence { + kind: 'structural' | 'framework' | 'lexical' | 'name' | 'call_graph' | 'quality' | 'effect'; + signal: string; + weight: number; +} + +export interface HaileCallable { + node_id: string; + file_path: string; + name: string; + qualified_name: string; + symbol_kind: HaileSymbolKind; + language: string; + signature: string; + calls: string[]; + parameters: Array<{ name: string; type_name?: string }>; + return_type?: string; + file_layer?: string; + ast_role?: string; + pack_ids?: string[]; +} + +export interface HaileSymbol { + node_id: string; + file_path: string; + name: string; + qualified_name: string; + symbol_kind: string; + role: { + primary: string; + alternatives: Array<{ role: string; confidence: number }>; + confidence: number; + band: ConfidenceBand; + }; + purposes: Array<{ purpose: string; confidence: number }>; + intent: { text: string; verbs: string[]; objects: string[] }; + evidence: HaileEvidence[]; + /** Boundary findings from the module's policy pack (additive; absent when none). */ + findings?: HaileFinding[]; + file_layer?: string; + ast_role?: string; +} + +export interface HaileFinding { + /** `hexagonal-v1/controller-persists` … */ + rule: string; + severity: 'hard' | 'warn' | string; + message: string; +} + +export interface HaileModuleSummary { + path: string; + symbol_count: number; + role_histogram: Record; + purpose_histogram: Record; + description: string; + band: ConfidenceBand; +} + +export interface HaileSidecar { + magic: typeof HAILE_MAGIC; + taxonomy: typeof HAILE_TAXONOMY; + ir: typeof HAILE_IR; + corpus_hash: string; + engine_version: string; + profile: HaileProfile; + symbols: HaileSymbol[]; + symbols_capped?: true; + modules?: HaileModuleSummary[]; +} + +export const DEFAULT_SYMBOL_CAP = 8_000; +export const DEFAULT_PROFILE: HaileProfile = 'balanced'; diff --git a/src/engine/haile/wasm-kernel.ts b/src/engine/haile/wasm-kernel.ts new file mode 100644 index 0000000..98ecdc5 --- /dev/null +++ b/src/engine/haile/wasm-kernel.ts @@ -0,0 +1,11 @@ +/** + * Moved to packages/vibgrate-haile. The public CLI does not load a .wasm + * file directly and does not honour HAILE_WASM / HAILE_BIN / HAILE_MODULE. + */ +export function loadWasmHaileKernel(): null { + return null; +} + +export function resetWasmHaileKernelCache(): void { + /* no-op — kernel lives in the installed module */ +} diff --git a/src/engine/parse.ts b/src/engine/parse.ts index 3fa1cc5..e04a3b4 100644 --- a/src/engine/parse.ts +++ b/src/engine/parse.ts @@ -6,6 +6,8 @@ import { extractEmbeddedScript } from './sfc.js'; import { hashString } from './hash.js'; import { redactSecrets } from '../core-open/utils/redact.js'; import { extractAstRolesFromTree } from './ast-roles.js'; +import { scanEffects } from './effects.js'; +import { extractDuties, fileBindings, type Bindings } from './duties.js'; import type { FileParse, RawCall, RawDef, RawGuard, RawHeritage, RawImport, RawTypeRef } from './types.js'; /** @@ -43,6 +45,8 @@ function namedCapture( * call node (`obj.foo()` / `pkg::foo()` / `recv.foo()` across the grammars). * A bare call's identifier hangs directly off the call node instead. */ +const CALLABLE_DEF_KINDS = new Set(['function', 'method', 'route', 'job', 'component', 'test']); + const MEMBER_PARENT_TYPES = new Set([ 'member_expression', // ts/js: obj.foo() 'attribute', // python: obj.foo() @@ -238,7 +242,7 @@ export async function parseSource( const root = tree.rootNode; // --- definitions --- - const rawDefs: (RawDef & { _start: number; _end: number })[] = []; + const rawDefs: (RawDef & { _start: number; _end: number; _node: Node })[] = []; for (const rule of langQueries.defs) { collectDefs(language, effLangId, text, root, rule, rawDefs); } @@ -259,9 +263,10 @@ export async function parseSource( } result.defs = byStart .map((d) => { - const { _start, _end, ...rest } = d; + const { _start, _end, _node, ...rest } = d; void _start; void _end; + void _node; return rest; }) .sort( @@ -285,12 +290,14 @@ export async function parseSource( } } const calls: RawCall[] = []; + const calleeCaptures: Node[] = []; for (const qsrc of langQueries.calls) { const q = compile(language, effLangId, qsrc); if (!q) continue; for (const cap of q.captures(root)) { if (cap.name !== 'callee') continue; if (defNameBytes.has(cap.node.startIndex)) continue; + calleeCaptures.push(cap.node); calls.push({ callee: cap.node.text, byte: cap.node.startIndex, @@ -301,6 +308,42 @@ export async function parseSource( } result.calls = calls.sort((a, b) => a.byte - b.byte || a.callee.localeCompare(b.callee)); + // --- duties (what each callable's body would do, statement by statement) --- + // Each callee capture is attributed to the smallest enclosing definition, + // the same rule resolve.ts uses for call edges, so a lambda's calls do not + // double as its parent's. + const calleeNodesByDef = new Map(); + for (const cap of calleeCaptures) { + let owner: (typeof byStart)[number] | undefined; + for (const d of byStart) { + if (d._start <= cap.startIndex && d._end >= cap.endIndex && (!owner || d._end - d._start < owner._end - owner._start)) owner = d; + } + if (!owner) continue; + const key = owner._start; + let list = calleeNodesByDef.get(key); + if (!list) { + list = []; + calleeNodesByDef.set(key, list); + } + list.push(cap); + } + if (calleeNodesByDef.size) { + const bindings: Bindings = fileBindings(text, effLangId); + const uniqueByName = new Map(); + for (const d of byStart) uniqueByName.set(d.name, uniqueByName.has(d.name) ? null : d.qualifiedName); + const calleeOf = (n: Node): string | undefined => uniqueByName.get(n.text) ?? undefined; + for (const d of byStart) { + if (!CALLABLE_DEF_KINDS.has(d.kind)) continue; + const callees = calleeNodesByDef.get(d._start); + if (!callees?.length) continue; + const duties = extractDuties({ def: d._node, callees, text, langId: effLangId, bindings, calleeOf }); + if (duties) { + const target = result.defs.find((r) => r.qualifiedName === d.qualifiedName && r.startLine === d.startLine); + if (target) target.duties = duties; + } + } + } + // --- imports --- const imports: RawImport[] = []; for (const qsrc of langQueries.imports) { @@ -395,7 +438,7 @@ function collectDefs( source: string, root: Node, rule: DefRule, - out: (RawDef & { _start: number; _end: number })[], + out: (RawDef & { _start: number; _end: number; _node: Node })[], ): void { const q = compile(language, langId, rule.query); if (!q) return; @@ -423,8 +466,15 @@ function collectDefs( // persisted (graph.json, `vg share` commits it) — scrub at ingest. doc: scrubbedDoc(source, defNode, langId), visibility: undefined, + // Body effects — what the callable executes — for the architecture + // classifier. Counts only; no source text leaves this function. + effects: + rule.kind === 'function' || rule.kind === 'method' || rule.kind === 'route' || rule.kind === 'job' || rule.kind === 'component' || rule.kind === 'test' + ? scanEffects(source.slice(defNode.startIndex, spanEnd.endIndex), langId) + : undefined, _start: defNode.startIndex, _end: spanEnd.endIndex, + _node: defNode, }); } } diff --git a/src/engine/query.ts b/src/engine/query.ts index 955d7d4..57877fb 100644 --- a/src/engine/query.ts +++ b/src/engine/query.ts @@ -1,6 +1,7 @@ import { indexFor, type GraphIndex } from './relations.js'; import { cosine, type Embedder } from './embeddings.js'; import type { SanitizedRank } from './relevance-provider.js'; +import { applyRolePreference, type RoleMap } from './haile/role-preference.js'; import type { GraphNode, VgGraph } from '../schema.js'; import { userAskFromInstruction } from './user-ask.js'; @@ -33,12 +34,21 @@ export interface QueryOptions { * absent — module not installed, predates the ranking API, or failed — * the mechanical fallback below answers. */ ranked?: SanitizedRank | null; + /** + * Architecture-module roles for this graph (engine/haile/role-preference.ts + * loadRoleMap). When present, the ranked seeds are re-ordered so controllers, + * application services and ports come first under the budget and utilities + * the ask did not reach for are left out. Absent → ranking untouched. + */ + roles?: RoleMap | null; } export interface QueryMatch { node: GraphNode; score: number; why: string; + /** Architecture-module role when the map is classified (structured; not part of the rendered context). */ + role?: string; } export interface QueryResult { @@ -229,11 +239,13 @@ export function queryGraph(graph: VgGraph, question: string, options: QueryOptio return node ? { node, score: s.score, why: s.why } : null; }) .filter((m): m is QueryMatch => m !== null) - .slice(0, limit) : []; } else { - seeds = mechanicalRank(graph, question, literals).slice(0, limit); + seeds = mechanicalRank(graph, question, literals); } + // Role preference looks one window past the cut so a controller ranked + // 13th can still open the answer, then the budget cut applies as before. + seeds = applyRolePreference(seeds.slice(0, limit * 2), options.roles, question).slice(0, limit); const { context, tokensEstimate } = buildContext(graph, index, question, seeds, budget, literals); return { question, matches: seeds, context, tokensEstimate }; @@ -447,7 +459,7 @@ export async function queryGraphSemantic( scored.sort((a, b) => b.score - a.score || a.node.qualifiedName.localeCompare(b.node.qualifiedName)); } - const seeds = diversifyByFile(scored).slice(0, limit); + const seeds = applyRolePreference(diversifyByFile(scored).slice(0, limit * 2), options.roles, question).slice(0, limit); const { context, tokensEstimate } = buildContext(graph, index, question, seeds, budget, literals); return { question, matches: seeds, context, tokensEstimate }; } diff --git a/src/engine/resolve.ts b/src/engine/resolve.ts index 74633fe..fa7e188 100644 --- a/src/engine/resolve.ts +++ b/src/engine/resolve.ts @@ -111,6 +111,8 @@ export function resolve(parses: FileParse[], resolver?: ModuleResolver): Resolve lang: p.lang, signature: d.signature, doc: d.doc, + ...(d.effects ? { effects: d.effects } : {}), + ...(d.duties ? { duties: d.duties } : {}), }); const ref: DefNodeRef = { id, diff --git a/src/engine/types.ts b/src/engine/types.ts index d0a055e..e4fd1af 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -1,4 +1,6 @@ import type { NodeKind } from '../schema.js'; +import type { CallableEffects } from './effects.js'; +import type { Duty } from './duties.js'; /** A definition extracted from one file (pre-resolution, pre-id). */ export interface RawDef { @@ -12,6 +14,10 @@ export interface RawDef { signature?: string; doc?: string; // short leading doc-comment / docstring summary (deterministic, truncated) visibility?: 'public' | 'private' | 'protected' | 'internal'; + /** What the body's code calls, scanned (engine/effects.ts) — callables only, deterministic. */ + effects?: CallableEffects; + /** Ordered duties reconstructed from the syntax tree (engine/duties.ts). */ + duties?: Duty[]; } export interface RawCall { diff --git a/src/install/haile-module.test.ts b/src/install/haile-module.test.ts new file mode 100644 index 0000000..b499a55 --- /dev/null +++ b/src/install/haile-module.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + ensureHaileModule, + haileModuleInstalled, + haileModuleStatus, + installHaileModule, + kickHaileReadiness, + removeHaileModule, +} from './haile-module.js'; +import { readConsent, writeConsent } from './module-core.js'; +import { haileModuleDir, resetHaileProviderCache } from '../engine/haile/haile-provider.js'; + +let tmp: string; +let savedEnv: Record; +const ENV = ['XDG_CACHE_HOME', 'VIBGRATE_MODULE_DIR', 'VIBGRATE_NO_KERNEL', 'VIBGRATE_MODULE_REGISTRY', 'VIBGRATE_HAILE_PATH'] as const; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV.map((k) => [k, process.env[k]])); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-haile-module-')); + process.env.XDG_CACHE_HOME = tmp; // isolate module dir + consent state + delete process.env.VIBGRATE_MODULE_DIR; + delete process.env.VIBGRATE_NO_KERNEL; + delete process.env.VIBGRATE_MODULE_REGISTRY; + delete process.env.VIBGRATE_HAILE_PATH; + resetHaileProviderCache(); +}); + +afterEach(() => { + for (const k of ENV) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + fs.rmSync(tmp, { recursive: true, force: true }); + resetHaileProviderCache(); +}); + +/** Build a real npm-shaped tarball (package/dist/index.js …) with system tar. */ +function makeTarball(indexJs: string): Buffer { + const stage = fs.mkdtempSync(path.join(tmp, 'pkg-')); + fs.mkdirSync(path.join(stage, 'package', 'dist'), { recursive: true }); + fs.writeFileSync(path.join(stage, 'package', 'dist', 'index.js'), indexJs); + fs.writeFileSync(path.join(stage, 'package', 'LICENSE'), 'proprietary'); + const out = path.join(stage, 'pkg.tgz'); + execFileSync('tar', ['-czf', out, '-C', stage, 'package']); + return fs.readFileSync(out); +} + +const PROVIDER_JS = `export function createHaileProvider() { + return { + version: () => 'tarball-haile@1', + classify: () => null, + }; +}`; + +function registryFetch(tarball: Buffer, opts: { integrity?: string; version?: string } = {}): typeof fetch { + const version = opts.version ?? '0.1.1'; + const meta = { + 'dist-tags': { latest: version }, + versions: { + [version]: { + dist: { + tarball: 'https://registry.test/tarball.tgz', + integrity: opts.integrity ?? `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + }, + }, + }, + }; + return (async (url: string | URL | Request) => { + const u = String(url); + if (u.includes('tarball')) return new Response(new Uint8Array(tarball), { status: 200 }); + return new Response(JSON.stringify(meta), { status: 200 }); + }) as typeof fetch; +} + +const failingFetch = (async () => { + throw new Error('registry unreachable'); +}) as unknown as typeof fetch; + +describe('ensureHaileModule (default-install posture)', () => { + it('installs when absent, then no-ops when installed', async () => { + const io = { fetchImpl: registryFetch(makeTarball(PROVIDER_JS)) }; + expect((await ensureHaileModule(io)).status).toBe('installed'); + expect(haileModuleInstalled()).toMatchObject({ installed: true, version: '0.1.1' }); + expect((await ensureHaileModule(io)).status).toBe('already-installed'); + removeHaileModule(); + expect(haileModuleInstalled().installed).toBe(false); + }); + + it('reports unavailable when the registry cannot be reached — never throws', async () => { + const result = await ensureHaileModule({ fetchImpl: failingFetch }); + expect(result.status).toBe('unavailable'); + expect(haileModuleInstalled().installed).toBe(false); + }); + + it('honours VIBGRATE_NO_KERNEL and a recorded denial', async () => { + process.env.VIBGRATE_NO_KERNEL = '1'; + expect((await ensureHaileModule({ fetchImpl: failingFetch })).status).toBe('disabled'); + delete process.env.VIBGRATE_NO_KERNEL; + writeConsent({ ...readConsent(), haile: 'denied' }); + expect((await ensureHaileModule({ fetchImpl: failingFetch })).status).toBe('declined'); + }); + + it('refuses a tarball that fails the integrity check, leaving nothing behind', async () => { + const result = await installHaileModule({ fetchImpl: registryFetch(makeTarball(PROVIDER_JS), { integrity: 'sha512-BAD' }) }); + expect(result.status).toBe('unavailable'); + expect(haileModuleInstalled().installed).toBe(false); + }); +}); + +describe('kickHaileReadiness (per-invocation background provision)', () => { + const stampPath = () => path.join(path.dirname(haileModuleDir()), 'haile.last-attempt'); + + it('writes the throttle stamp and never throws when the install fails', () => { + expect(() => kickHaileReadiness({ fetchImpl: failingFetch })).not.toThrow(); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('is throttled: a fresh stamp suppresses the next attempt', () => { + kickHaileReadiness({ fetchImpl: failingFetch }); + const first = fs.readFileSync(stampPath(), 'utf8'); + kickHaileReadiness({ fetchImpl: failingFetch }); + expect(fs.readFileSync(stampPath(), 'utf8')).toBe(first); + }); + + it('is a no-op under VIBGRATE_NO_KERNEL and after a recorded denial', () => { + process.env.VIBGRATE_NO_KERNEL = '1'; + kickHaileReadiness({ fetchImpl: failingFetch }); + expect(fs.existsSync(stampPath())).toBe(false); + delete process.env.VIBGRATE_NO_KERNEL; + writeConsent({ ...readConsent(), haile: 'denied' }); + kickHaileReadiness({ fetchImpl: failingFetch }); + expect(fs.existsSync(stampPath())).toBe(false); + }); +}); + +describe('haileModuleStatus (what the editor may offer)', () => { + const stampPath = () => path.join(path.dirname(haileModuleDir()), 'haile.last-attempt'); + + it('is absent before any attempt and while a fresh kick is in flight', () => { + expect(haileModuleStatus()).toEqual({ status: 'absent' }); + kickHaileReadiness({ fetchImpl: failingFetch }); + expect(haileModuleStatus()).toEqual({ status: 'absent' }); + }); + + it('is unavailable once a settled attempt left no module behind', () => { + kickHaileReadiness({ fetchImpl: failingFetch }); + const at = Number(fs.readFileSync(stampPath(), 'utf8')); + expect(haileModuleStatus(at + 5 * 60 * 1000)).toEqual({ status: 'unavailable' }); + }); + + it('is present with the version once installed, and honours the opt-outs', async () => { + await installHaileModule({ fetchImpl: registryFetch(makeTarball(PROVIDER_JS)) }); + expect(haileModuleStatus()).toEqual({ status: 'present', version: '0.1.1' }); + process.env.VIBGRATE_NO_KERNEL = '1'; + expect(haileModuleStatus()).toEqual({ status: 'disabled' }); + delete process.env.VIBGRATE_NO_KERNEL; + writeConsent({ ...readConsent(), haile: 'denied' }); + expect(haileModuleStatus()).toEqual({ status: 'declined' }); + }); +}); diff --git a/src/install/haile-module.ts b/src/install/haile-module.ts new file mode 100644 index 0000000..a282bd8 --- /dev/null +++ b/src/install/haile-module.ts @@ -0,0 +1,133 @@ +/** + * Install / remove / auto-provision the architecture-classify module. + * + * The compiled kernel ships as a separately distributed local package. + * The CLI loads it through engine/haile/haile-provider.ts. Shared + * fetch/verify/unpack lives in install/module-core.ts. + * + * Consent posture: ON by default. Every `vg` invocation kicks a throttled + * silent background install (cli.ts, suppressed under --offline/--local), + * and `vg build` runs the bounded ensure at use time and prints the one + * user-visible warning when the module cannot be provisioned. A recorded + * denial and VIBGRATE_NO_KERNEL=1 always win. A missing or failed module + * omits architecture lines; it never breaks vg build / vg show and never + * falls back to a TypeScript lexicon. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { haileModuleDir, resetHaileProviderCache } from '../engine/haile/haile-provider.js'; +import { + type InstallOptions, + type InstallResult, + type ModuleDescriptor, + installModule, + kernelDisabled, + moduleInstalledAt, + readConsent, + removeModule, +} from './module-core.js'; + +export const HAILE_MODULE_NAME = '@vibgrate/haile'; + +const HAILE_MODULE: ModuleDescriptor = { + id: 'haile', + npmName: HAILE_MODULE_NAME, + dir: haileModuleDir, + onChanged: resetHaileProviderCache, +}; + +export const HAILE_DISCLOSURE = + 'vg can install the optional Vibgrate architecture module (proprietary license, runs fully locally ' + + 'in a WASM sandbox — no network). Disable any time with VIBGRATE_NO_KERNEL=1.'; + +export function haileModuleInstalled(): { installed: boolean; version?: string } { + return moduleInstalledAt(haileModuleDir()); +} + +export async function installHaileModule(opts: InstallOptions = {}): Promise { + return installModule(HAILE_MODULE, opts); +} + +export function removeHaileModule(): void { + removeModule(HAILE_MODULE); +} + +export async function ensureHaileModule(io: { fetchImpl?: typeof fetch } = {}): Promise { + try { + if (kernelDisabled()) return { status: 'disabled' }; + const existing = haileModuleInstalled(); + if (existing.installed) return { status: 'already-installed', version: existing.version }; + if (readConsent().haile === 'denied') return { status: 'declined' }; + return await installHaileModule({ fetchImpl: io.fetchImpl }); + } catch { + return { status: 'unavailable', detail: 'auto-install failed' }; + } +} + +const READINESS_THROTTLE_MS = 60 * 60 * 1000; +/** A kick older than this with no install behind it is a failed attempt, not one in flight. */ +const ATTEMPT_SETTLE_MS = 2 * 60 * 1000; + +export type HaileModuleStatus = + | { status: 'present'; version?: string } + /** Default-install was attempted and did not produce a module — the one case a UI may offer an install. */ + | { status: 'unavailable' } + /** Not installed and no settled attempt yet (a kick may be in flight). Never a banner. */ + | { status: 'absent' } + | { status: 'disabled' } + | { status: 'declined' }; + +/** + * Where the architecture module stands, for surfaces that must decide + * between "say nothing" and "offer an install" — the LSP projects this onto + * `vibgrate/architecture` so the editor never reads the cache itself. + * Explicit opt-outs win; a fresh kick reads as absent until it has had time + * to settle, so an install that is about to succeed never flashes a banner. + */ +export function haileModuleStatus(now = Date.now()): HaileModuleStatus { + try { + if (kernelDisabled()) return { status: 'disabled' }; + if (readConsent().haile === 'denied') return { status: 'declined' }; + const existing = haileModuleInstalled(); + if (existing.installed) return { status: 'present', version: existing.version }; + const stamp = path.join(path.dirname(haileModuleDir()), 'haile.last-attempt'); + const at = Number(fs.readFileSync(stamp, 'utf8')); + if (Number.isFinite(at) && now - at >= ATTEMPT_SETTLE_MS) return { status: 'unavailable' }; + return { status: 'absent' }; + } catch { + return { status: 'absent' }; + } +} + +/** + * Fire-and-forget readiness kick, called on every `vg` invocation (cli.ts). + * When the architecture module is missing and no attempt was made within the + * last hour, start a silent background install and return immediately — the + * command never waits on it. `vg build` still runs a bounded ensure at use + * time. Failures never throw and never change the command exit code. + */ +export function kickHaileReadiness(io: { fetchImpl?: typeof fetch } = {}): void { + try { + if (kernelDisabled()) return; + if (readConsent().haile === 'denied') return; + const existing = haileModuleInstalled(); + if (existing.installed) return; + const modulesRoot = path.dirname(haileModuleDir()); + const stamp = path.join(modulesRoot, 'haile.last-attempt'); + try { + const at = Number(fs.readFileSync(stamp, 'utf8')); + if (Number.isFinite(at) && Date.now() - at < READINESS_THROTTLE_MS) return; + } catch { + /* no stamp yet — attempt */ + } + try { + fs.mkdirSync(modulesRoot, { recursive: true }); + fs.writeFileSync(stamp, String(Date.now())); + } catch { + /* unwritable cache dir — skip quietly */ + } + void ensureHaileModule(io).catch(() => {}); + } catch { + /* readiness must never affect the command */ + } +} diff --git a/src/install/module-core.ts b/src/install/module-core.ts index 3c125d5..87177ea 100644 --- a/src/install/module-core.ts +++ b/src/install/module-core.ts @@ -1,5 +1,5 @@ /** - * Generic installer for optional local modules (relevance, hcs, …). + * Generic installer for optional local modules (relevance, hcs, haile, …). * * A module is a separately licensed, separately distributed local package that * the CLI loads through a provider seam. This core manages the shared @@ -9,7 +9,7 @@ * script execution (the tarball is unpacked, nothing in it is run at install * time). Per-module policy (consent posture, auto-provisioning, error * loudness) lives with each module's own wrapper (relevance-module.ts, - * hcs-module.ts) — this file is mechanism only. + * hcs-module.ts, haile-module.ts) — this file is mechanism only. */ import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -50,8 +50,14 @@ function registryBase(opts: InstallOptions): string { return (process.env.VIBGRATE_MODULE_REGISTRY || opts.registry || DEFAULT_REGISTRY).replace(/\/+$/, ''); } -/** Base dir all modules install under: `$XDG_CACHE_HOME|~/.cache /vibgrate/modules`. */ +/** + * Base dir all modules install under. + * `VIBGRATE_MODULE_DIR` overrides the whole tree; otherwise + * `$XDG_CACHE_HOME|~/.cache/vibgrate/modules`. + */ export function modulesBaseDir(): string { + const override = process.env.VIBGRATE_MODULE_DIR?.trim(); + if (override) return path.resolve(override); const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); return path.join(base, 'vibgrate', 'modules'); } @@ -165,6 +171,23 @@ export function untar(data: Buffer): Array<{ name: string; body: Buffer }> { * is the tarball's `package/dist/*` mapped to the module root (so * `/index.js` is the seam's entrypoint), plus LICENSE/README. */ +/** + * The registry's `dist-tags.latest` for a module package, or null when the + * registry is unreachable or the package has no published version. Read-only: + * fetches metadata only, never a tarball. + */ +export async function latestModuleVersion(npmName: string, opts: InstallOptions = {}): Promise { + const doFetch = opts.fetchImpl ?? fetch; + try { + const res = await doFetch(`${registryBase(opts)}/${npmName.replace('/', '%2f')}`); + if (!res.ok) return null; + const meta = (await res.json()) as { 'dist-tags'?: Record }; + return meta['dist-tags']?.latest ?? null; + } catch { + return null; + } +} + export async function installModule(mod: ModuleDescriptor, opts: InstallOptions = {}): Promise { if (kernelDisabled()) return { status: 'disabled', detail: 'VIBGRATE_NO_KERNEL is set' }; const existing = moduleInstalledAt(mod.dir()); diff --git a/src/install/update-modules.test.ts b/src/install/update-modules.test.ts new file mode 100644 index 0000000..66ff190 --- /dev/null +++ b/src/install/update-modules.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { updateLocalModules } from './update-modules.js'; +import { readConsent, writeConsent } from './module-core.js'; +import { installRelevanceModule, moduleInstalled } from './relevance-module.js'; +import { haileModuleInstalled } from './haile-module.js'; +import { hcsModuleInstalled } from './hcs-module.js'; + +let tmp: string; +let savedEnv: Record; +const ENV = ['XDG_CACHE_HOME', 'VIBGRATE_MODULE_DIR', 'VIBGRATE_NO_KERNEL', 'VIBGRATE_MODULE_REGISTRY'] as const; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV.map((k) => [k, process.env[k]])); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-update-modules-')); + process.env.XDG_CACHE_HOME = tmp; // isolate module dirs + consent state + delete process.env.VIBGRATE_MODULE_DIR; + delete process.env.VIBGRATE_NO_KERNEL; + delete process.env.VIBGRATE_MODULE_REGISTRY; +}); + +afterEach(() => { + for (const k of ENV) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function makeTarball(): Buffer { + const stage = fs.mkdtempSync(path.join(tmp, 'pkg-')); + fs.mkdirSync(path.join(stage, 'package', 'dist'), { recursive: true }); + fs.writeFileSync(path.join(stage, 'package', 'dist', 'index.js'), 'export {};'); + const out = path.join(stage, 'pkg.tgz'); + execFileSync('tar', ['-czf', out, '-C', stage, 'package']); + return fs.readFileSync(out); +} + +/** One registry serving the same latest version for every package name. */ +function registryFetch(version: string, tarball: Buffer): typeof fetch { + const meta = { + 'dist-tags': { latest: version }, + versions: { + [version]: { + dist: { + tarball: 'https://registry.test/tarball.tgz', + integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + }, + }, + }, + }; + return (async (url: string | URL | Request) => { + const u = String(url); + if (u.includes('tarball')) return new Response(new Uint8Array(tarball), { status: 200 }); + return new Response(JSON.stringify(meta), { status: 200 }); + }) as typeof fetch; +} + +const failingFetch = (async () => { + throw new Error('registry unreachable'); +}) as unknown as typeof fetch; + +const byId = (reports: Awaited>) => + Object.fromEntries(reports.map((r) => [r.id, r])); + +describe('updateLocalModules (vg update)', () => { + it('updates an installed module to the newer latest and default-installs the missing ones', async () => { + const gz = makeTarball(); + expect((await installRelevanceModule({ fetchImpl: registryFetch('1.0.0', gz) })).status).toBe('installed'); + const r = byId(await updateLocalModules({ fetchImpl: registryFetch('1.1.0', gz) })); + expect(r.relevance).toMatchObject({ status: 'updated', from: '1.0.0', to: '1.1.0' }); + expect(moduleInstalled()).toMatchObject({ installed: true, version: '1.1.0' }); + // haile is ON by default → installed even though it was absent. + expect(r.haile).toMatchObject({ status: 'installed', to: '1.1.0' }); + expect(haileModuleInstalled().installed).toBe(true); + // hcs provisions on use, not on update. + expect(r.hcs).toMatchObject({ status: 'not-installed' }); + expect(hcsModuleInstalled().installed).toBe(false); + }); + + it('reports up-to-date without reinstalling when nothing newer is published', async () => { + const gz = makeTarball(); + await installRelevanceModule({ fetchImpl: registryFetch('1.0.0', gz) }); + const r = byId(await updateLocalModules({ fetchImpl: registryFetch('1.0.0', gz) })); + expect(r.relevance).toMatchObject({ status: 'up-to-date', to: '1.0.0' }); + }); + + it('checkOnly reports availability without touching the disk', async () => { + const gz = makeTarball(); + await installRelevanceModule({ fetchImpl: registryFetch('1.0.0', gz) }); + const r = byId(await updateLocalModules({ fetchImpl: registryFetch('1.1.0', gz), checkOnly: true })); + expect(r.relevance).toMatchObject({ status: 'update-available', from: '1.0.0', to: '1.1.0' }); + expect(r.haile).toMatchObject({ status: 'install-available', to: '1.1.0' }); + expect(moduleInstalled().version).toBe('1.0.0'); + expect(haileModuleInstalled().installed).toBe(false); + }); + + it('honours VIBGRATE_NO_KERNEL and per-module denial, and never throws on registry failure', async () => { + process.env.VIBGRATE_NO_KERNEL = '1'; + expect((await updateLocalModules({ fetchImpl: failingFetch })).every((r) => r.status === 'disabled')).toBe(true); + delete process.env.VIBGRATE_NO_KERNEL; + + writeConsent({ ...readConsent(), haile: 'denied' }); + const r = byId(await updateLocalModules({ fetchImpl: failingFetch })); + expect(r.haile.status).toBe('declined'); + // relevance is default-on but the registry is down → failed, not thrown. + expect(r.relevance.status).toBe('failed'); + }); +}); diff --git a/src/install/update-modules.ts b/src/install/update-modules.ts new file mode 100644 index 0000000..b1799c9 --- /dev/null +++ b/src/install/update-modules.ts @@ -0,0 +1,123 @@ +/** + * Bring the optional local modules (relevance, hcs, haile) up to the + * registry's latest as part of `vg update`. + * + * Modules are pinned to whatever `dist-tags.latest` was when they were first + * provisioned and never refresh on their own (`installModule` short-circuits + * on an existing install), so the CLI self-update is the natural moment to + * check them. Policy per module: + * + * - VIBGRATE_NO_KERNEL=1 skips everything; a recorded denial skips that + * module — `vg update` never overrides an explicit opt-out. + * - An installed module with a newer published version is reinstalled + * (force) at latest. + * - A missing module is installed only when its posture is ON by default + * (relevance, haile). HCS provisions on first use of `vg hcs` instead. + * - Every failure is reported, never thrown: a module problem must not + * fail a successful CLI update. + */ +import semver from 'semver'; +import { + type InstallOptions, + type InstallResult, + kernelDisabled, + latestModuleVersion, + readConsent, +} from './module-core.js'; +import { RELEVANCE_MODULE_NAME, installRelevanceModule, moduleInstalled } from './relevance-module.js'; +import { HCS_MODULE_NAME, hcsModuleInstalled, installHcsModule } from './hcs-module.js'; +import { HAILE_MODULE_NAME, haileModuleInstalled, installHaileModule } from './haile-module.js'; + +export interface ModuleUpdateReport { + /** Consent/registry id (`relevance`, `hcs`, `haile`). */ + id: string; + npmName: string; + status: + | 'updated' + | 'installed' + | 'update-available' + | 'install-available' + | 'up-to-date' + | 'not-installed' + | 'declined' + | 'disabled' + | 'failed'; + /** Version installed before the check, when there was one. */ + from?: string; + /** Version now installed (or available, on failure). */ + to?: string; + detail?: string; +} + +interface ManagedModuleRef { + id: string; + npmName: string; + installedNow(): { installed: boolean; version?: string }; + install(opts: InstallOptions): Promise; + /** ON-by-default modules are installed by `vg update` even when absent. */ + defaultOn: boolean; +} + +const MODULES: ManagedModuleRef[] = [ + { id: 'relevance', npmName: RELEVANCE_MODULE_NAME, installedNow: moduleInstalled, install: installRelevanceModule, defaultOn: true }, + { id: 'hcs', npmName: HCS_MODULE_NAME, installedNow: hcsModuleInstalled, install: installHcsModule, defaultOn: false }, + { id: 'haile', npmName: HAILE_MODULE_NAME, installedNow: haileModuleInstalled, install: installHaileModule, defaultOn: true }, +]; + +/** True when `candidate` is a strictly newer version than `installed`. */ +function isNewer(candidate: string, installed: string | undefined): boolean { + if (!installed) return true; + const a = semver.coerce(candidate)?.version; + const b = semver.coerce(installed)?.version; + return a !== undefined && b !== undefined ? semver.gt(a, b) : candidate !== installed; +} + +/** + * Check every module against the registry and (unless `checkOnly`) bring the + * eligible ones to latest. Always resolves; never throws. + */ +export async function updateLocalModules( + opts: InstallOptions & { checkOnly?: boolean } = {}, +): Promise { + const reports: ModuleUpdateReport[] = []; + for (const mod of MODULES) { + const base: Pick = { id: mod.id, npmName: mod.npmName }; + try { + if (kernelDisabled()) { + reports.push({ ...base, status: 'disabled' }); + continue; + } + if (readConsent()[mod.id] === 'denied') { + reports.push({ ...base, status: 'declined' }); + continue; + } + const existing = mod.installedNow(); + if (!existing.installed && !mod.defaultOn) { + reports.push({ ...base, status: 'not-installed' }); + continue; + } + const latest = await latestModuleVersion(mod.npmName, opts); + if (!latest) { + reports.push({ ...base, status: 'failed', from: existing.version, detail: 'registry unreachable or no published version' }); + continue; + } + if (existing.installed && !isNewer(latest, existing.version)) { + reports.push({ ...base, status: 'up-to-date', from: existing.version, to: existing.version }); + continue; + } + if (opts.checkOnly) { + reports.push({ ...base, status: existing.installed ? 'update-available' : 'install-available', from: existing.version, to: latest }); + continue; + } + const result = await mod.install({ ...opts, force: existing.installed }); + if (result.status === 'installed') { + reports.push({ ...base, status: existing.installed ? 'updated' : 'installed', from: existing.version, to: result.version }); + } else { + reports.push({ ...base, status: 'failed', from: existing.version, to: latest, detail: result.detail ?? result.status }); + } + } catch (err) { + reports.push({ ...base, status: 'failed', detail: err instanceof Error ? err.message : String(err) }); + } + } + return reports; +} diff --git a/src/lsp/graph-query.test.ts b/src/lsp/graph-query.test.ts index 42cf58f..e1876b8 100644 --- a/src/lsp/graph-query.test.ts +++ b/src/lsp/graph-query.test.ts @@ -190,3 +190,45 @@ describe('runGraphQuery — ask via the local runtime', () => { expectLexicalAnswer(result); }); }); + +describe('runGraphQuery show — architecture module fields', () => { + it('attaches the classify fields when a corpus-bound sidecar holds the node, and nothing otherwise', async () => { + const { emptySidecar, writeSidecarDocument } = await import('../engine/haile/sidecar.js'); + const { resolveGraphPath } = await import('../engine/artifacts.js'); + const prev = process.env.VIBGRATE_GRAPH_IN_REPO; + process.env.VIBGRATE_GRAPH_IN_REPO = '1'; + try { + const target = graph.nodes.find((n) => n.name === 'verifyPassword')!; + const before = await runGraphQuery(graph, { mode: 'show', name: 'verifyPassword' }, { root, offline: true }); + expect(before.ok ? (before.data as { architecture?: unknown }).architecture : 'query failed').toBeUndefined(); + + const graphPath = resolveGraphPath(root); + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + const doc = emptySidecar(graph.provenance?.corpusHash ?? ''); + doc.symbols = [ + { + node_id: target.id, + file_path: target.file, + name: target.name, + qualified_name: target.qualifiedName, + symbol_kind: 'function', + role: { primary: 'cross_cutting', alternatives: [], confidence: 0.9, band: 'high' }, + purposes: [{ purpose: 'authenticate', confidence: 1 }], + intent: { text: 'authenticates verifyPassword', verbs: ['authenticate'], objects: ['verifyPassword'] }, + evidence: [], + }, + ]; + writeSidecarDocument(doc, graphPath); + + const after = await runGraphQuery(graph, { mode: 'show', name: 'verifyPassword' }, { root, offline: true }); + if (!after.ok) throw new Error(`show failed: ${after.message}`); + const arch = (after.data as { architecture?: { role: { primary: string }; band: string; intent: { text: string } } }).architecture; + expect(arch?.role.primary).toBe('cross_cutting'); + expect(arch?.band).toBe('high'); + expect(arch?.intent.text).toBe('authenticates verifyPassword'); + } finally { + if (prev === undefined) delete process.env.VIBGRATE_GRAPH_IN_REPO; + else process.env.VIBGRATE_GRAPH_IN_REPO = prev; + } + }); +}); diff --git a/src/lsp/graph-query.ts b/src/lsp/graph-query.ts index 10c3335..e7393fa 100644 --- a/src/lsp/graph-query.ts +++ b/src/lsp/graph-query.ts @@ -13,6 +13,9 @@ */ import { queryGraph, queryGraphSemantic, type QueryResult } from '../engine/query.js'; +import { resolveGraphPath } from '../engine/artifacts.js'; +import { findHaileSymbol, haileJsonFields, readHaileSidecar } from '../engine/haile/index.js'; +import { loadRoleMap } from '../engine/haile/role-preference.js'; import type { DaemonSemanticSession } from '../runtime/vgd/semantic-client.js'; import { loadEmbedder, @@ -107,7 +110,7 @@ export async function runGraphQuery( case 'path': return runPath(graph, params); case 'show': - return runShow(graph, params); + return runShow(graph, params, ctx); case 'tree': return runTree(graph, params); default: @@ -133,6 +136,7 @@ async function runAsk(graph: VgGraph, params: GraphQueryParams, ctx: GraphQueryC let result: QueryResult | null = null; let mode = 'lexical'; let note: string | undefined; + const roles = loadRoleMap(ctx.root, graph.provenance?.corpusHash); // The daemon first — and for this process especially, because ranking there // means the native backend is never loaded into the language server at all. @@ -145,7 +149,7 @@ async function runAsk(graph: VgGraph, params: GraphQueryParams, ctx: GraphQueryC ); if (ranked) { log(`ask: ranked by vgd against ${ranked.vectors} vector(s) — no local model load`); - result = await queryGraphSemantic(graph, question, { budget, semanticRanked: ranked.ranked }); + result = await queryGraphSemantic(graph, question, { budget, semanticRanked: ranked.ranked, roles }); mode = `semantic (vgd${ranked.model ? `, ${ranked.model}` : ''})`; } } catch { @@ -175,7 +179,7 @@ async function runAsk(graph: VgGraph, params: GraphQueryParams, ctx: GraphQueryC log(`ask: embedder "${embedder.id}" loaded in ${Date.now() - started}ms; embedding nodes…`); const vectors = await getNodeEmbeddings(graph, embedder, ctx.root); log(`ask: ${vectors.size} node vectors ready in ${Date.now() - started}ms; ranking…`); - const r = await queryGraphSemantic(graph, question, { budget, embedder, nodeVectors: vectors }); + const r = await queryGraphSemantic(graph, question, { budget, embedder, nodeVectors: vectors, roles }); mode = `semantic (${embedder.id})`; return r; })(), @@ -213,7 +217,7 @@ async function runAsk(graph: VgGraph, params: GraphQueryParams, ctx: GraphQueryC } if (!result) { - result = queryGraph(graph, question, { budget }); + result = queryGraph(graph, question, { budget, roles }); } log(`ask: answered with ${mode} — ${result.matches.length} match(es)`); @@ -325,7 +329,7 @@ function runPath(graph: VgGraph, params: GraphQueryParams): GraphQueryResult { }; } -function runShow(graph: VgGraph, params: GraphQueryParams): GraphQueryResult { +function runShow(graph: VgGraph, params: GraphQueryParams, ctx: GraphQueryContext): GraphQueryResult { const name = (params.name ?? '').trim(); if (!name) return { ok: false, mode: 'show', error: 'bad-request', message: 'name is required' }; const resolved = resolveOrError(graph, name, 'show'); @@ -358,10 +362,24 @@ function runShow(graph: VgGraph, params: GraphQueryParams): GraphQueryResult { calls: callees.map((n) => n.qualifiedName), calledBy: callers.map((n) => n.qualifiedName), extends: supertypes, + // The architecture module's view of this node — the same four fields + // `vg show --json` carries — when a classify file bound to this graph + // holds it. Absent (not null) otherwise, so a client renders nothing. + ...architectureFieldsFor(graph, node.id, ctx), }, }; } +function architectureFieldsFor(graph: VgGraph, nodeId: string, ctx: GraphQueryContext): { architecture?: Record } { + try { + const sidecar = readHaileSidecar(resolveGraphPath(ctx.root), { corpusHash: graph.provenance?.corpusHash }); + const fields = haileJsonFields(findHaileSymbol(sidecar, nodeId)); + return fields ? { architecture: fields } : {}; + } catch { + return {}; + } +} + function dedupeNodes(nodes: GraphNode[]): GraphNode[] { const seen = new Set(); const out: GraphNode[] = []; diff --git a/src/lsp/server.ts b/src/lsp/server.ts index d17eef2..5d28e27 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -45,6 +45,12 @@ import { fileRolesFromParseCache } from '../engine/ast-roles.js'; import type { AstRoleHit } from '../core-open/scanners/architecture/ast-roles.js'; import { boundaryProfileRules } from '../core-open/scanners/architecture/graph-refine.js'; import { loadGraph } from '../engine/load.js'; +import { + architectureSidecarSummary, + loadArchitectureCallables, + type ArchitectureCallableWire, +} from '../engine/haile/architecture-callables.js'; +import { haileModuleStatus } from '../install/haile-module.js'; import { writeArtifacts, resolveGraphPath } from '../engine/artifacts.js'; import { writeSnapshot } from '../engine/freshness.js'; import { loadGraphPreferIndex } from '../engine/index-db.js'; @@ -222,6 +228,27 @@ export interface ArchitectureResponse { manifestPath?: string; /** Workspace-relative lockfile path — absent at whole-workspace scope or when none was resolved. */ lockfilePath?: string; + /** + * What the callables in this scope do, from the architecture module's + * classify file (`vg build`). Omitted — not empty — when the module did not + * run, the sidecar is stale for this graph, or nothing in scope classified. + * Abstain / unknown rows are never sent. File-layer stays the interlingua: + * `layer` on a row is a copy, never a relabel. + */ + callables?: ArchitectureCallableWire[]; + /** + * Where the architecture module stands, so a client can tell "nothing to + * show" from "the engine tried to install it and could not" without + * reading the module cache itself. Only `unavailable` justifies an offer. + */ + architectureModule?: ArchitectureModuleWire; +} + +export interface ArchitectureModuleWire { + status: 'present' | 'unavailable' | 'absent' | 'disabled' | 'declined'; + version?: string; + engineVersion?: string; + symbolCount?: number; } type ProjectArchetypeWire = string; @@ -1451,6 +1478,7 @@ export class VibgrateLanguageServer { if (!arch) return null; return { ...architectureWire(arch), + ...this.architectureCallables('__repo__'), }; } const project = projectByPath(a, p.path); @@ -1459,9 +1487,32 @@ export class VibgrateLanguageServer { ...architectureWire(project.architecture), manifestPath: manifestRelativePath(project), lockfilePath: lockfileRelativePath(this.opts.root, project), + ...this.architectureCallables(p.path), }; } + /** + * The architecture-module half of `vibgrate/architecture`: classified + * callables for the scope plus the module's status. Additive and + * best-effort — a missing or stale classify file simply omits `callables`, + * and nothing here can fail the file-layer response. + */ + private architectureCallables(scopePath: string): Pick { + try { + const corpusHash = this.graph?.provenance?.corpusHash; + const status = haileModuleStatus(); + const summary = status.status === 'present' ? architectureSidecarSummary(this.opts.root, { corpusHash }) : undefined; + const out: Pick = { + architectureModule: { ...status, ...(summary ?? {}) }, + }; + const callables = loadArchitectureCallables(this.opts.root, scopePath, { corpusHash }); + if (callables) out.callables = callables; + return out; + } catch { + return {}; + } + } + /** * `vibgrate/architecture/projects` — the workspace-scope Projects view. * One row per scanned project that produced architecture data, so a client diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 59a3f23..1fc23ba 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import { loadRoleMap } from '../engine/haile/role-preference.js'; import * as path from 'node:path'; import { queryGraph, queryGraphSemantic } from '../engine/query.js'; import type { DaemonSemanticSession } from '../runtime/vgd/semantic-client.js'; @@ -135,6 +136,7 @@ async function retrieve(graph: VgGraph, question: string, budget: number, ctx: T // this adds no meaningful latency to the navigation path. const topicTags = await loadTopicTags(graph, ctx.root, ctx.graphPath); const modRank = await rankQuestion(graph, rankingAskFrom(question), { limit: 48, topicTags }); + const roles = loadRoleMap(ctx.root, graph.provenance?.corpusHash); // The daemon first: it already holds vectors for this slot, so it answers the // semantic half without this process loading the model at all. `null` means // "rank it yourself" — the same path taken when no daemon is running. @@ -150,6 +152,7 @@ async function retrieve(graph: VgGraph, question: string, budget: number, ctx: T budget, semanticRanked: ranked.ranked, ranked: modRank, + roles, }); return { q, mode: `semantic (vgd${ranked.model ? `, ${ranked.model}` : ''})` }; } @@ -166,7 +169,7 @@ async function retrieve(graph: VgGraph, question: string, budget: number, ctx: T // cancel it, so the work continues in the background and caches to disk — // this call answers lexically, the next hits the warm cache and is fast. const nodeVectors = await getNodeEmbeddings(graph, embedder, ctx.root); - const r = await queryGraphSemantic(graph, question, { budget, embedder, nodeVectors, ranked: modRank }); + const r = await queryGraphSemantic(graph, question, { budget, embedder, nodeVectors, ranked: modRank, roles }); mode = `semantic (${embedder.id})`; return r; })(), @@ -177,7 +180,7 @@ async function retrieve(graph: VgGraph, question: string, budget: number, ctx: T } catch { // Over the latency budget or a semantic fault — answer from the lexical floor. } - return { q: queryGraph(graph, question, { budget, ranked: modRank }), mode: 'lexical' }; + return { q: queryGraph(graph, question, { budget, ranked: modRank, roles }), mode: 'lexical' }; } /** diff --git a/src/reporting/commands/update.ts b/src/reporting/commands/update.ts index b50eb17..a52a753 100644 --- a/src/reporting/commands/update.ts +++ b/src/reporting/commands/update.ts @@ -12,6 +12,7 @@ import { pathExists } from '../utils/fs.js'; import { liveStatsDir, reapOtherVersionServers, type ReapedServer } from '../../mcp/live-stats.js'; import { restartVgdIfRunning, stopVgd, vgdVersionSkew } from '../../commands/daemon.js'; import { isFileLockError, scheduleDeferredUpdate } from './update-windows.js'; +import { updateLocalModules } from '../../install/update-modules.js'; export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun'; @@ -383,8 +384,45 @@ async function offerDeferredWindowsUpdate(cmd: string, assumeYes: boolean): Prom return true; } +/** + * Check the optional local modules (relevance, hcs, haile) against the + * registry and, unless checkOnly, bring the eligible ones to latest. Modules + * pin to `dist-tags.latest` at first provision and never refresh on their + * own, so `vg update` is where they catch up. Explicit opt-outs (a recorded + * denial, VIBGRATE_NO_KERNEL=1) and HCS-not-installed stay silent; a module + * failure is a warning, never a failed update. + */ +async function refreshLocalModules(checkOnly: boolean): Promise { + for (const r of await updateLocalModules({ checkOnly })) { + switch (r.status) { + case 'updated': + console.log(chalk.green('✔') + ` ${r.npmName} module updated ${r.from} → ${r.to}`); + break; + case 'installed': + console.log(chalk.green('✔') + ` ${r.npmName} module installed (${r.to})`); + break; + case 'update-available': + console.log(chalk.yellow(`Module update available: ${r.npmName} ${r.from} → ${r.to}`)); + break; + case 'install-available': + console.log(chalk.yellow(`Module not installed yet: ${r.npmName} (${r.to} available)`)); + break; + case 'up-to-date': + console.log(chalk.dim(` ${r.npmName} module up to date (${r.to})`)); + break; + case 'failed': + console.log(chalk.yellow(` ${r.npmName} module could not be updated — ${r.detail ?? 'unknown error'}`)); + break; + default: + // disabled / declined / hcs-not-installed: explicit opt-outs and + // provision-on-use modules are not news during an update. + break; + } + } +} + export const updateCommand = new Command('update') - .description('Update vibgrate to the latest version') + .description('Update vibgrate and its local modules to the latest versions') .option('--check', 'Only check for updates, do not install') .option('--pm ', 'Package manager to use (npm, pnpm, yarn, bun)') .option('--global', 'Update global installation') @@ -413,6 +451,11 @@ export const updateCommand = new Command('update') const semver = await import('semver'); if (!semver.gt(latest, VERSION)) { console.log(chalk.green('✔') + ` You are on the latest version (${VERSION}).`); + // The CLI being current says nothing about the local modules — they + // pin to the registry's latest at first install and only catch up + // here. Refresh them (or just report, under --check) before the + // process-hygiene steps below. + await refreshLocalModules(Boolean(opts.check)); // Even on the latest binary, an assistant-spawned `vg serve` started // before a prior update may still be executing an OLDER build (a // long-lived stdio child the client never restarted). Retire those so @@ -432,6 +475,7 @@ export const updateCommand = new Command('update') console.log(chalk.yellow(`Update available: ${VERSION} → ${latest}`)); if (opts.check) { + await refreshLocalModules(true); console.log(chalk.dim('Run "vg update" to install.')); return; } @@ -505,6 +549,9 @@ export const updateCommand = new Command('update') const install = await runInstall(cmd, cwd); if (install.ok) { console.log(chalk.green('✔') + ` Updated to ${PACKAGE_NAME}@${latest}`); + // The self-update carried the binary; the local modules update here, + // in the same run, so one `vg update` leaves everything current. + await refreshLocalModules(false); } else if (process.platform === 'win32' && isFileLockError(install.output)) { // Only this process's own locks are left. It cannot release them and // keep running, so hand the install to a script that waits for us. diff --git a/src/schema.ts b/src/schema.ts index 04327eb..a56dd97 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,3 +1,5 @@ +import type { CallableEffects } from './engine/effects.js'; +import type { Duty } from './engine/duties.js'; /** * The `vg-graph/1.1` on-disk schema. * @@ -126,6 +128,8 @@ export interface Centrality { eigenvector: number; } +export type { CallableEffects, Duty }; + export interface GraphNode { id: string; // blake3(canonical(kind, qualifiedName, file, signature)) kind: NodeKind; @@ -137,6 +141,25 @@ export interface GraphNode { visibility?: 'public' | 'private' | 'protected' | 'internal'; signature?: string; doc?: string; // short leading doc-comment / docstring summary (deterministic; not a full body) + /** + * What the callable's body calls, scanned from its text (never run), as bounded counts (SQL/ORM reads and + * writes, outbound HTTP, HTTP responses, file I/O, auth, crypto, messaging, + * cache, logging, rendering, validation, plus fan-out / branch / loop / + * await / throw / return / state-assignment counts and a few matched API + * tokens). Additive in vg-graph/1.1; absent for non-callables and empty + * bodies. Excluded from the id hash. See engine/effects.ts. + */ + effects?: CallableEffects; + /** + * Ordered duties reconstructed from the callable's syntax tree — what each + * statement would do (`validate SKU`, `persist Product via SaveChangesAsync`, + * `respond 201`), with a liveness bit (dead after return/throw, in catch, + * `if (false)`), an optional guard, and `hop` 1–2 for duties inherited from + * callees along call edges. Additive in vg-graph/1.1; identifiers and short + * guards only, never source text. Excluded from the id hash. See + * engine/duties.ts. + */ + duties?: Duty[]; importance: number; // 0..1 blended centrality centrality: Centrality; area: number; // area id (the node's community); -1 = unassigned (cluster "none") diff --git a/src/version.ts b/src/version.ts index 24330e4..59e7312 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Calendar version (YYYY.DDD.PATCH), shared scheme with @vibgrate/cli. -export const VERSION = '2026.829.1'; +export const VERSION = '2026.903.1';