From 5dbd86891414a225d481858499284d44b18ca945 Mon Sep 17 00:00:00 2001 From: aihop <4408344+hhyykk@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:15:58 +0800 Subject: [PATCH] feat(control-plane): add module living spec graph routing and AST interface boundary checks --- examples/living_specs_benchmark_corpus.ts | 350 ++++++++++++++++++ ...ository-context-living-specs-comparison.ts | 345 +++++++++++++++++ .../repository_context/context_route.ts | 117 ++++++ .../capabilities/repository_context/index.ts | 6 + .../repository_context/interface_boundary.ts | 96 +++++ .../repository_context/module_graph.ts | 108 ++++++ .../repository_context_living_specs.test.ts | 100 +++++ tsconfig.control-plane.json | 1 + 8 files changed, 1123 insertions(+) create mode 100644 examples/living_specs_benchmark_corpus.ts create mode 100644 examples/repository-context-living-specs-comparison.ts create mode 100644 loopx/control_plane/capabilities/repository_context/context_route.ts create mode 100644 loopx/control_plane/capabilities/repository_context/index.ts create mode 100644 loopx/control_plane/capabilities/repository_context/interface_boundary.ts create mode 100644 loopx/control_plane/capabilities/repository_context/module_graph.ts create mode 100644 tests/control_plane_ts/repository_context_living_specs.test.ts diff --git a/examples/living_specs_benchmark_corpus.ts b/examples/living_specs_benchmark_corpus.ts new file mode 100644 index 0000000000..c76a219a1a --- /dev/null +++ b/examples/living_specs_benchmark_corpus.ts @@ -0,0 +1,350 @@ +import {canonicalAuthoritySha256} from "../loopx/control_plane/coordination/authority_store_codec.ts"; + +/** Synthetic, deterministic inputs; task expectations are authored independently of router output. */ +export interface BenchmarkSpec { + readonly modulePath: string; + readonly specPath: string; + readonly content: string; +} +export interface BenchmarkModule extends BenchmarkSpec { + readonly layer: "api" | "app" | "domain" | "infra" | "shared"; + readonly summary: string; + readonly publicInterfaces: readonly string[]; + readonly allowedDependencies: readonly string[]; + readonly files: Readonly>; +} +export interface RoutingTaskOracle { + readonly taskId: string; + readonly category: "single_module" | "single_direct_dep" | "multi_direct_dep" | + "transitive_dep" | "directory_scope" | "disjoint_scopes"; + readonly taskPrompt: string; + readonly writeScope: readonly string[]; + readonly requiredModulePaths: readonly string[]; + readonly usefulModulePaths: readonly string[]; + readonly staleModulePaths?: readonly string[]; + readonly rationale: string; +} +export interface BoundaryEvalCase { + readonly caseId: string; + readonly category: "compliant" | "missing_export" | "undeclared_export" | + "syntax_trap_comment_or_string" | "complex_ts_syntax_export" | "forbidden_import" | + "prefix_trap_import" | "signature_only_limitation"; + readonly modulePath: string; + readonly declaredInterfaces: readonly string[]; + readonly allowedDependencies: readonly string[]; + readonly files: Readonly>; + readonly expectedExportDrift: boolean; + readonly expectedForbiddenImport: boolean; + readonly isSignatureOnlyLimitation: boolean; + readonly description: string; +} + +const M = { + orders: "src/api/orders_controller", payments: "src/api/payments_controller", webhooks: "src/api/webhooks_controller", + checkout: "src/app/order_checkout", trap: "src/app/order_audit_trap", orchestrator: "src/app/payment_orchestrator", + ledger: "src/domain/order_ledger", policy: "src/domain/payment_policy", pricing: "src/domain/pricing_rules", + repo: "src/infra/postgres_order_repo", stripe: "src/infra/stripe_gateway", outbox: "src/infra/event_outbox", + money: "src/shared/money", idempotency: "src/shared/idempotency", clock: "src/shared/clock", +} as const; + +function specContent(module: Pick, + sourceDigest: string): string { + return [`# ${module.modulePath}`, "", "## Summary", module.summary, "", "## Public Interface", + ...module.publicInterfaces.map(declaration => `- ${declaration}`), "", "## Allowed Dependencies", + ...module.allowedDependencies.map(path => `- ${path}`), "", "## Source Digest", sourceDigest, ""].join("\n"); +} + +function moduleFixture(modulePath: string, layer: BenchmarkModule["layer"], summary: string, + publicInterfaces: readonly string[], allowedDependencies: readonly string[], source: string): BenchmarkModule { + const files = {[`${modulePath}/index.ts`]: source}; + const definition = {modulePath, layer, summary, publicInterfaces, allowedDependencies, files}; + return {...definition, specPath: `${modulePath}/SPEC.md`, + content: specContent(definition, canonicalAuthoritySha256(files))}; +} + +export const BENCHMARK_MODULES: readonly BenchmarkModule[] = [ + moduleFixture(M.orders, "api", + "Owns authenticated order creation and the HTTP representation of checkout results. It translates validated request fields into application commands, formats currency with the shared money contract, and maps rejected commands to stable response codes. Controllers must never write ledger rows or charge payment providers directly. An order checkout payment settlement request is accepted once, and retry responses must preserve the original order identifier without exposing internal transaction details.", + ["export function submitOrder(input: OrderRequest): Promise"], [M.checkout, M.money], + 'import {checkoutOrder} from "../../app/order_checkout/index.ts";\nimport {formatMoney} from "../../shared/money/index.ts";\nexport async function submitOrder(input: OrderRequest): Promise { return {order: await checkoutOrder(input), total: formatMoney(input.total)}; }'), + moduleFixture(M.payments, "api", + "Provides the payment authorization endpoint and translates application payment decisions into retryable HTTP responses. The request idempotency key is normalized before orchestration begins; the controller may read but never synthesize provider authorization identifiers. Payment authorization, capture, and settlement errors retain their application category across response versions. The HTTP boundary does not choose fraud thresholds, calculate prices, persist account balances, or interpret raw gateway failure messages.", + ["export function authorizePayment(input: PaymentRequest): Promise"], [M.orchestrator, M.idempotency], + 'import {orchestratePayment} from "../../app/payment_orchestrator/index.ts";\nimport {normalizeKey} from "../../shared/idempotency/index.ts";\nexport async function authorizePayment(input: PaymentRequest): Promise { return orchestratePayment({...input, key: normalizeKey(input.key)}); }'), + moduleFixture(M.webhooks, "api", + "Receives provider webhook notifications for order checkout payment settlement, pricing reconciliation, order ledger settlement, and checkout idempotency replay. These highly overlapping event names describe inbound notifications, not the synchronous order checkout command path. The adapter verifies delivery identity, delegates payment reconciliation, and appends an outbox notification once. A webhook replay cannot create an order, revise pricing rules, or bypass payment policy. Delivery timestamps remain evidence rather than authority to reorder accepted commands. " + + "An order checkout settlement notification may arrive before an earlier authorization webhook. Reconciliation must retain that uncertainty and must never reinterpret notification arrival order as command acceptance order.", + ["export function receiveWebhook(event: ProviderEvent): Promise"], [M.orchestrator, M.outbox, M.idempotency], + 'import {orchestratePayment} from "../../app/payment_orchestrator/index.ts";\nimport {enqueueEvent} from "../../infra/event_outbox/index.ts";\nimport {normalizeKey} from "../../shared/idempotency/index.ts";\nexport async function receiveWebhook(event: ProviderEvent): Promise { await orchestratePayment({...event, key: normalizeKey(event.id)}); enqueueEvent(event); }'), + moduleFixture(M.checkout, "app", + "Coordinates the synchronous order checkout transaction from a validated command to an accepted ledger result. It obtains a domain quote, applies the order ledger transition, reserves the command idempotency key, and emits the accepted order event through the transactional outbox. Pricing and ledger rules remain owned by their domain modules. Payment settlement wording in the command must not select reporting or audit projections. A failed quote cannot reserve an order permanently, and a replay cannot append a second order event. " + + "Contract changes must preserve the order identifier, selected price rule version, and normalized command key as one accepted result. Recovery after an outbox failure resumes the same command instead of constructing a fresh purchase from a partially accepted quote.", + ["export function checkoutOrder(command: CheckoutCommand): Promise"], [M.ledger, M.pricing, M.outbox, M.idempotency], + 'import {appendOrder} from "../../domain/order_ledger/index.ts";\nimport {quoteOrder} from "../../domain/pricing_rules/index.ts";\nimport {enqueueEvent} from "../../infra/event_outbox/index.ts";\nimport {normalizeKey} from "../../shared/idempotency/index.ts";\nexport async function checkoutOrder(command: CheckoutCommand): Promise { const result = appendOrder({...command, quote: quoteOrder(command), key: normalizeKey(command.key)}); enqueueEvent(result); return result; }'), + moduleFixture(M.trap, "app", + "Builds an asynchronous order checkout payment settlement audit report for order checkout reconciliation. Order ledger pricing, payment settlement idempotency, checkout totals, payment authorization, and order event outbox appear repeatedly as report dimensions. Despite this vocabulary, the audit projection is not a dependency of order checkout and cannot approve checkout payment settlement. It reads persisted orders after acceptance and records observation time. Missing audit rows never block a customer command, and report repair must never mutate canonical order or payment state. " + + "The order checkout payment settlement dashboard groups order pricing settlement, checkout payment replay, and ledger settlement exceptions for observation only. These search terms describe a projection rather than a command dependency.", + ["export function projectOrderAudit(orderId: string): Promise"], [M.repo, M.clock], + 'import {loadOrder} from "../../infra/postgres_order_repo/index.ts";\nimport {nowMillis} from "../../shared/clock/index.ts";\nexport async function projectOrderAudit(orderId: string): Promise { return {order: await loadOrder(orderId), observedAt: nowMillis()}; }'), + moduleFixture(M.orchestrator, "app", + "Coordinates payment authorization against the domain policy before requesting a gateway operation. It converts amounts through shared money utilities and emits a durable payment decision event through the outbox after a confirmed provider result. Gateway retries preserve the original command identity rather than creating independent authorizations. Policy refusal must occur before external charging. Provider responses do not override domain limits, and uncertain gateway outcomes remain explicitly pending until reconciliation supplies evidence. " + + "A confirmed authorization and its emitted payment decision must carry the same amount and provider reference. Recovery must distinguish a refused policy check from a charge that might already have reached the provider.", + ["export function orchestratePayment(command: PaymentCommand): Promise"], [M.policy, M.stripe, M.outbox, M.money], + 'import {canAuthorize} from "../../domain/payment_policy/index.ts";\nimport {chargeCard} from "../../infra/stripe_gateway/index.ts";\nimport {enqueueEvent} from "../../infra/event_outbox/index.ts";\nimport {roundMinor} from "../../shared/money/index.ts";\nexport async function orchestratePayment(command: PaymentCommand): Promise { if (!canAuthorize(command)) throw new Error("policy refused"); const result = await chargeCard({...command, amount: roundMinor(command.amount)}); enqueueEvent(result); return result; }'), + moduleFixture(M.ledger, "domain", + "Owns order state transitions and the append-only ledger representation of an accepted purchase. A transition validates amount units, reads the durable repository version, and records the effective domain timestamp through the shared clock. An accepted order cannot silently return to a pending state. Repository conflict handling must preserve the original expected version, and monetary comparisons use integer minor units. This module has no provider charging authority and does not format transport responses or build audit reports.", + ["export function appendOrder(command: LedgerCommand): LedgerResult"], [M.repo, M.money, M.clock], + 'import {saveOrder} from "../../infra/postgres_order_repo/index.ts";\nimport {roundMinor} from "../../shared/money/index.ts";\nimport {nowMillis} from "../../shared/clock/index.ts";\nexport function appendOrder(command: LedgerCommand): LedgerResult { return saveOrder({...command, amount: roundMinor(command.amount), at: nowMillis()}); }'), + moduleFixture(M.policy, "domain", + "Decides whether a payment amount and authorization window satisfy merchant-neutral domain constraints. Policy evaluates integer minor units and a supplied effective time without calling a gateway or persisting orders. Expired authorization windows are refused consistently on retries, while rounding occurs before comparison with the amount ceiling. A transport-specific failure message cannot alter eligibility. Policy changes require clear treatment of boundary amounts and exact expiry instants so callers can distinguish refusal from provider uncertainty.", + ["export function canAuthorize(input: PaymentPolicyInput): boolean"], [M.money, M.clock], + 'import {roundMinor} from "../../shared/money/index.ts";\nimport {nowMillis} from "../../shared/clock/index.ts";\nexport function canAuthorize(input: PaymentPolicyInput): boolean { return roundMinor(input.amount) <= input.limit && nowMillis() < input.expiresAt; }'), + moduleFixture(M.pricing, "domain", + "Calculates a deterministic order quote from line quantities, unit prices, and explicitly supplied discount rules. All arithmetic is performed in currency minor units through the shared money contract; a quote has no gateway, database, or wall-clock side effects. Reordering identical input lines must preserve the total, and negative quantities are rejected before discount allocation. Rounding residuals are assigned deterministically so the sum of displayed line totals equals the charged order amount. Historical quotes retain their chosen rule version.", + ["export function quoteOrder(input: PricingInput): Quote"], [M.money], + 'import {roundMinor} from "../../shared/money/index.ts";\nexport function quoteOrder(input: PricingInput): Quote { return {total: roundMinor(input.lines.reduce((sum, line) => sum + line.quantity * line.price, 0))}; }'), + moduleFixture(M.repo, "infra", + "Implements order persistence with optimistic version checks and a stable mapping between database rows and repository records. Writes carry an idempotency key and receive an observation timestamp from the shared clock, while reads return the stored domain version unchanged. The adapter cannot infer an accepted transition from an incomplete row or retry a conflict as an unconditional overwrite. Transaction failure leaves both the order row and its key claim untouched. Connection details and raw query errors never become public domain fields. " + + "Concurrent updates must compare the caller's expected version before writing any replacement value. Readback returns the committed row rather than an optimistic reconstruction assembled before the transaction completed.", + ["export function loadOrder(id: string): Promise", "export function saveOrder(record: OrderRecord): OrderRecord"], [M.idempotency, M.clock], + 'import {normalizeKey} from "../../shared/idempotency/index.ts";\nimport {nowMillis} from "../../shared/clock/index.ts";\nexport async function loadOrder(id: string): Promise { return {id, version: 1}; }\nexport function saveOrder(record: OrderRecord): OrderRecord { return {...record, key: normalizeKey(record.key), updatedAt: nowMillis()}; }'), + moduleFixture(M.stripe, "infra", + "Adapts provider-neutral charge commands into gateway requests and normalizes confirmed or uncertain outcomes. Currency amounts are serialized in integer minor units, and retry requests retain a normalized idempotency key across transport reconnection. The adapter never chooses merchant policy, computes discounts, or writes the order ledger. A timeout after request acceptance remains uncertain instead of being reported as a definite refusal. Provider identifiers are opaque, and transport diagnostics must not expose card data or authentication material.", + ["export function chargeCard(input: ChargeInput): Promise"], [M.money, M.idempotency], + 'import {roundMinor} from "../../shared/money/index.ts";\nimport {normalizeKey} from "../../shared/idempotency/index.ts";\nexport async function chargeCard(input: ChargeInput): Promise { return {amount: roundMinor(input.amount), key: normalizeKey(input.key), status: "confirmed"}; }'), + moduleFixture(M.outbox, "infra", + "Persists application events for eventual delivery without granting the delivery worker authority over domain state. Each event retains its normalized command key and original observation time. A duplicate append returns the existing envelope, while transport retries update delivery bookkeeping without changing the event payload. Ordering is scoped to the aggregate rather than inferred from global timestamps. Events become visible only with the associated accepted transaction, and a failed append must leave no apparently deliverable partial envelope.", + ["export function enqueueEvent(event: EventInput): EventEnvelope"], [M.idempotency, M.clock], + 'import {normalizeKey} from "../../shared/idempotency/index.ts";\nimport {nowMillis} from "../../shared/clock/index.ts";\nexport function enqueueEvent(event: EventInput): EventEnvelope { return {...event, key: normalizeKey(event.key), observedAt: nowMillis()}; }'), + moduleFixture(M.money, "shared", + "Defines currency minor-unit rounding and presentation used consistently by transport, domain, and provider adapters. Amounts are finite numbers representing minor units, with no implicit exchange-rate lookup or locale-dependent parsing. Rounding is deterministic for positive and negative ties and formatting preserves the currency identifier supplied by the caller. This utility has no application dependencies, clock reads, persistence, or authorization behavior. Invalid numeric inputs fail explicitly rather than producing a display string that resembles an accepted payment amount.", + ["export function roundMinor(amount: number): number", "export function formatMoney(amount: number): string"], [], + 'export function roundMinor(amount: number): number { if (!Number.isFinite(amount)) throw new Error("invalid amount"); return Math.round(amount); }\nexport function formatMoney(amount: number): string { return String(roundMinor(amount)); }'), + moduleFixture(M.idempotency, "shared", + "Normalizes caller-supplied command identifiers before repositories and gateway adapters use them as replay keys. Normalization removes surrounding whitespace while preserving case and internal separators, so distinct customer commands cannot collapse accidentally. An empty identifier is rejected rather than replaced with a random value. This helper stores no claims, chooses no expiry window, and does not assert that a previous command succeeded. Every caller must retain ownership of claim persistence and independently validate the returned command result.", + ["export function normalizeKey(key: string): string"], [], + 'export function normalizeKey(key: string): string { const normalized = key.trim(); if (!normalized) throw new Error("empty key"); return normalized; }'), + moduleFixture(M.clock, "shared", + "Provides the single observation-time adapter used by repositories, policy evaluation, and audit projections. Values are epoch milliseconds and must remain finite integers; callers explicitly distinguish effective domain time from transport arrival time. Reading the clock cannot authorize a state transition, establish event order across aggregates, or reset a retry identity. Deterministic evaluation supplies a controlled clock at the owning host boundary. The utility has no dependency on payment providers, persistence, application orchestration, or customer-specific scheduling policy.", + ["export function nowMillis(): number"], [], + 'export function nowMillis(): number { return Date.now(); }'), +]; + +/** Digest the canonical path/content map. Stale specs bind a deterministic prior source revision. */ +export function buildBenchmarkSpecs(options: {staleModulePaths?: readonly string[]} = {}): readonly BenchmarkSpec[] { + const stale = new Set(options.staleModulePaths ?? []); + for (const path of stale) if (!BENCHMARK_MODULES.some(module => module.modulePath === path)) { + throw new Error(`Unknown stale module: ${path}`); + } + return BENCHMARK_MODULES.map(module => { + const files = stale.has(module.modulePath) + ? Object.fromEntries(Object.entries(module.files).map(([path, source]) => [path, `${source}\n// Previous source revision.\n`])) + : module.files; + return {modulePath: module.modulePath, specPath: module.specPath, + content: specContent(module, canonicalAuthoritySha256(files))}; + }); +} + +function routing(category: RoutingTaskOracle["category"], taskId: string, taskPrompt: string, + writeScope: readonly string[], requiredModulePaths: readonly string[], usefulModulePaths: readonly string[], + rationale: string, staleModulePaths?: readonly string[]): RoutingTaskOracle { + return {taskId, category, taskPrompt, writeScope, requiredModulePaths, usefulModulePaths, rationale, + ...(staleModulePaths === undefined ? {} : {staleModulePaths})}; +} +const file = (path: string): string => `${path}/index.ts`; + +// Required sets describe each requested contract change, not every edge reachable from its owner. +export const ROUTING_TASKS: readonly RoutingTaskOracle[] = [ + routing("single_module", "single-01", "Reject nonfinite minor-unit inputs before formatting money.", + [file(M.money)], [M.money], [], "Pure amount validation changes only the money contract."), + routing("single_module", "single-02", "Preserve case while trimming command replay keys.", + [file(M.idempotency)], [M.idempotency], [], "Key normalization owns this behavior without downstream changes."), + routing("single_module", "single-03", "Document epoch milliseconds without changing the clock export.", + [file(M.clock)], [M.clock], [], "Observation-time documentation is local to the clock module."), + routing("single_module", "single-04", "Clarify the order checkout HTTP response error wording only.", + [file(M.orders)], [M.orders], [M.checkout], "Application context is useful but no application contract changes."), + routing("single_module", "single-05", "Rename a private order checkout payment settlement audit column label.", + [file(M.trap)], [M.trap], [M.repo], "The audit projection is the owner; checkout is an intentional lexical distractor."), + routing("single_module", "single-06", "Improve private pricing rule variable names without changing the quote.", + [file(M.pricing)], [M.pricing], [M.money], "The task preserves the shared amount contract."), + routing("single_direct_dep", "direct-01", "Order checkout payment settlement must return the accepted application order identifier.", + [file(M.orders)], [M.orders, M.checkout], [M.money], "The HTTP result must match its direct application contract, not webhook or audit terminology."), + routing("single_direct_dep", "direct-02", "Order checkout payment settlement prices must use deterministic domain rounding.", + [file(M.checkout)], [M.checkout, M.pricing], [M.ledger], "Pricing is a direct cross-layer dependency; audit keyword overlap is irrelevant."), + routing("single_direct_dep", "direct-03", "Reserve the order checkout idempotency key without folding letter case.", + [file(M.checkout)], [M.checkout, M.idempotency], [M.outbox], "The shared normalizer, not the neighboring audit module, owns case preservation."), + routing("single_direct_dep", "direct-04", "Payment authorization must preserve a gateway uncertain outcome on retry.", + [file(M.orchestrator)], [M.orchestrator, M.stripe], [M.policy], "The provider adapter defines the outcome contract."), + routing("single_direct_dep", "direct-05", "Enforce the exact authorization expiry instant before payment settlement.", + [file(M.orchestrator)], [M.orchestrator, M.policy], [M.money], "The required policy dependency lives outside the application's directory."), + routing("single_direct_dep", "direct-06", "Order ledger conflict handling must retain the stored repository version.", + [file(M.ledger)], [M.ledger, M.repo], [M.clock], "The persistence version contract is required, not merely useful context."), + routing("single_direct_dep", "direct-07", "Normalize webhook replay identity before payment settlement reconciliation.", + [file(M.webhooks)], [M.webhooks, M.idempotency], [M.orchestrator], "The webhook owns this replay change, and the shared key contract is required."), + routing("single_direct_dep", "direct-08", "Keep order event outbox observation timestamps in epoch milliseconds.", + [file(M.outbox)], [M.outbox, M.clock], [M.idempotency], "The clock dependency crosses infrastructure and shared utility layers."), + routing("multi_direct_dep", "multi-01", "Order checkout payment settlement must quote before appending the order ledger.", + [file(M.checkout)], [M.checkout, M.pricing, M.ledger], [M.outbox], "Both domain contracts determine checkout ordering; lexical audit matches cannot substitute."), + routing("multi_direct_dep", "multi-02", "Order checkout replay must append one order event for the normalized command key.", + [file(M.checkout)], [M.checkout, M.outbox, M.idempotency], [M.ledger], "Replay and event identity require two direct dependencies from different layers."), + routing("multi_direct_dep", "multi-03", "Reject invalid order checkout quotes before producing settlement event payloads.", + [file(M.checkout)], [M.checkout, M.pricing, M.outbox], [M.idempotency], "Quote validation and emitted payload shape are both part of acceptance."), + routing("multi_direct_dep", "multi-04", "Refuse disallowed payment amounts before calling the card gateway.", + [file(M.orchestrator)], [M.orchestrator, M.policy, M.stripe], [M.money], "Policy and gateway ordering must be inspected together."), + routing("multi_direct_dep", "multi-05", "Emit one confirmed payment settlement event using rounded minor units.", + [file(M.orchestrator)], [M.orchestrator, M.outbox, M.money], [M.stripe], "The changed event and amount contracts are not path-adjacent."), + routing("multi_direct_dep", "multi-06", "Keep order checkout response totals consistent with the accepted application quote.", + [file(M.orders)], [M.orders, M.checkout, M.money], [], "Both declared API dependencies are essential despite audit and webhook lexical traps."), + routing("multi_direct_dep", "multi-07", "Return stable payment retry responses for normalized authorization keys.", + [file(M.payments)], [M.payments, M.orchestrator, M.idempotency], [], "The HTTP adapter must coordinate the result and key contracts."), + routing("multi_direct_dep", "multi-08", "Persist accepted order amounts with the optimistic repository version.", + [file(M.ledger)], [M.ledger, M.repo, M.money], [M.clock], "Durable version and minor-unit contracts jointly constrain the ledger."), + routing("multi_direct_dep", "multi-09", "Payment settlement gateway retries must retain the key and minor-unit amount.", + [file(M.stripe)], [M.stripe, M.idempotency, M.money], [], "Both shared utilities are required for retry parity."), + routing("multi_direct_dep", "multi-10", "Reconcile webhook payment settlement once before enqueueing its notification.", + [file(M.webhooks)], [M.webhooks, M.orchestrator, M.outbox, M.idempotency], [], + "All three direct dependencies participate; the application spec is deliberately stale.", [M.orchestrator]), + routing("transitive_dep", "transitive-01", "Trace order checkout quote acceptance through the application into domain pricing.", + [file(M.orders)], [M.orders, M.checkout, M.pricing], [M.money], "Pricing is two hops away via checkout and absent from a one-hop API route."), + routing("transitive_dep", "transitive-02", "Trace checkout ledger version conflicts into the PostgreSQL repository contract.", + [file(M.checkout)], [M.checkout, M.ledger, M.repo], [M.idempotency], "The required repository is two hops away via the domain ledger."), + routing("transitive_dep", "transitive-03", "Trace payment HTTP refusal to the domain authorization policy.", + [file(M.payments)], [M.payments, M.orchestrator, M.policy], [M.stripe], "Policy is two hops away and the one-hop limitation must remain visible."), + routing("transitive_dep", "transitive-04", "Trace provider amount encoding from the payment HTTP endpoint into the gateway.", + [file(M.payments)], [M.payments, M.orchestrator, M.stripe], [M.money], "The gateway is a required second-hop dependency, not an oracle derived from routing."), + routing("directory_scope", "directory-01", "Review HTTP response and webhook delivery consistency across the API directory.", + ["src/api/"], [M.orders, M.payments, M.webhooks], [M.checkout, M.orchestrator], "Every API owner is required, even when a budget cannot retain all three."), + routing("directory_scope", "directory-02", "Align command and observation responsibilities across application modules.", + ["src/app/"], [M.checkout, M.trap, M.orchestrator], [M.ledger, M.policy], "Directory scope genuinely includes the audit trap here."), + routing("directory_scope", "directory-03", "Review durable version, gateway uncertainty, and outbox replay adapters.", + ["src/infra/"], [M.repo, M.stripe, M.outbox], [M.idempotency, M.clock], "All infrastructure owners are required independently of lexical scoring."), + routing("directory_scope", "directory-04", "Document deterministic amounts, replay keys, and epoch timestamps together.", + ["src/shared/"], [M.money, M.idempotency, M.clock], [], "Shared directory ownership spans three dependency-free utilities."), + routing("disjoint_scopes", "disjoint-01", "Coordinate checkout HTTP totals with domain quote rounding.", + [file(M.orders), file(M.pricing)], [M.orders, M.pricing, M.money], [M.checkout], "Disjoint owners both require the shared money contract."), + routing("disjoint_scopes", "disjoint-02", "Align payment HTTP replay behavior with gateway key serialization.", + [file(M.payments), file(M.stripe)], [M.payments, M.stripe, M.idempotency], [M.orchestrator], "Neither directory adjacency nor a single owner covers both changes."), + routing("disjoint_scopes", "disjoint-03", "Preserve checkout event payloads and webhook outbox replay keys.", + [file(M.checkout), file(M.webhooks)], [M.checkout, M.webhooks, M.outbox, M.idempotency], [M.orchestrator], + "The outbox and replay contracts are shared direct dependencies of both owners.", [M.outbox]), + routing("disjoint_scopes", "disjoint-04", "Align ledger effective time with payment policy expiry boundaries.", + [file(M.ledger), file(M.policy)], [M.ledger, M.policy, M.clock], [M.money, M.repo], "Both domain owners need the clock contract, whose spec is stale.", [M.clock]), +]; + +interface BoundaryRow { + readonly description: string; + readonly declared: readonly string[]; + readonly source: string; + readonly exportDrift?: boolean; + readonly forbiddenImport?: boolean; + readonly allowed?: readonly string[]; +} +function cases(category: BoundaryEvalCase["category"], rows: readonly BoundaryRow[]): BoundaryEvalCase[] { + return rows.map((row, index) => ({caseId: `${category}-${String(index + 1).padStart(2, "0")}`, category, + modulePath: M.checkout, declaredInterfaces: row.declared, allowedDependencies: row.allowed ?? [M.ledger, M.money], + files: {[file(M.checkout)]: row.source}, + expectedExportDrift: row.exportDrift ?? ["missing_export", "undeclared_export", "signature_only_limitation"].includes(category), + expectedForbiddenImport: row.forbiddenImport ?? category === "forbidden_import", + isSignatureOnlyLimitation: category === "signature_only_limitation", description: row.description})); +} +const row = (description: string, declared: readonly string[], source: string, + options: Omit = {}): BoundaryRow => ({description, declared, source, ...options}); + +export const BOUNDARY_CASES: readonly BoundaryEvalCase[] = [ + ...cases("compliant", [ + row("Standard function export", ["submit"], "export function submit(): void {}"), + row("Typed constant export", ["retryLimit"], "export const retryLimit: number = 3;"), + row("Class export", ["Checkout"], "export class Checkout { run(): void {} }"), + row("Interface export", ["Order"], "export interface Order { id: string }"), + row("Type alias export", ["OrderId"], "export type OrderId = string;"), + row("Async function export", ["submit"], "export async function submit(): Promise {}"), + row("Allowed relative ledger import", ["submit"], 'import {appendOrder} from "../../domain/order_ledger/index.ts"; export const submit = appendOrder;'), + row("Allowed repository-style money import", ["submit"], 'import {roundMinor} from "src/shared/money/index.ts"; export const submit = roundMinor;'), + row("Allowed type import", ["Order"], 'import type {LedgerOrder} from "../../domain/order_ledger/types.ts"; export type Order = LedgerOrder;'), + row("Local import within owning module", ["submit"], 'import {validate} from "./validate.ts"; export const submit = validate;'), + ]), + ...cases("missing_export", [ + row("Omitted function", ["submit"], "function submit(): void {}"), + row("Renamed exported function", ["submit"], "export function submitNew(): void {}"), + row("Omitted class", ["Checkout"], "class Checkout {}"), + row("Omitted interface", ["Order"], "interface Order { id: string }"), + row("Omitted type alias", ["OrderId"], "type OrderId = string;"), + row("One of two declared symbols removed", ["submit", "cancel"], "export function submit(): void {}"), + row("Empty implementation", ["submit"], "// Implementation intentionally absent.\n"), + row("Async function made private", ["submit"], "async function submit(): Promise {}"), + row("Named export replaced by default", ["submit"], "export default function submit(): void {}"), + row("Required alias not re-exported", ["publicSubmit"], "const internalSubmit = () => {}; export {internalSubmit};"), + ]), + ...cases("undeclared_export", [ + row("Extra function", ["submit"], "export function submit() {} export function debug() {}"), + row("Extra constant", ["submit"], "export function submit() {} export const debug = true;"), + row("Extra class", ["submit"], "export function submit() {} export class DebugSession {}"), + row("Extra interface", ["submit"], "export function submit() {} export interface DebugInfo { value: string }"), + row("Extra type alias", ["submit"], "export function submit() {} export type DebugId = string;"), + row("Extra named alias", ["submit"], "export function submit() {} const hidden = 1; export {hidden as debug};"), + row("Extra async function", ["submit"], "export function submit() {} export async function inspect() {}"), + row("Extra mutable binding", ["submit"], "export function submit() {} export let debugCount = 0;"), + row("Extra default export", ["submit"], "export function submit() {} export default 1;"), + row("Extra allowed re-export", ["submit"], 'export function submit() {} export {roundMinor as debugRound} from "src/shared/money/index.ts";'), + ]), + ...cases("syntax_trap_comment_or_string", [ + row("Block comment fake function", ["submit"], "/* export function fakeTrap() {} */\nexport function submit() {}"), + row("Line comment fake constant", ["submit"], "// export const bad = 1;\nexport function submit() {}"), + row("Double-quoted fake declarations", ["submit"], 'const text = "export function notAnExport() {}\\nimport {x} from \'src/infra/stripe_gateway\'"; export function submit() {}'), + row("Template fake function", ["submit"], 'const text = `export function fakeTrap() {}\nimport {x} from "src/infra/stripe_gateway"`; export function submit() {}'), + row("Commented forbidden import", ["submit"], '/* import {chargeCard} from "src/infra/stripe_gateway"; */ export function submit() {}'), + row("Line-commented re-export", ["submit"], '// export {chargeCard} from "src/infra/stripe_gateway";\nexport function submit() {}'), + row("Single-quoted fake class", ["submit"], "const text = 'export class Secret {}'; export function submit() {}"), + row("Documentation fake interface", ["submit"], "/** Example:\n * export interface Ghost { id: string }\n */\nexport function submit() {}"), + row("Template fake type export", ["submit"], "const text = `export type Ghost = string;`; export function submit() {}"), + row("Multiline block trap", ["submit"], '/*\nexport async function charge() {}\nimport type {Card} from "src/infra/stripe_gateway";\n*/\nexport function submit() {}'), + ]), + ...cases("complex_ts_syntax_export", [ + row("Object destructuring aliases", ["a", "renamedB"], "const obj = {a: 1, b: 2}; export const {a, b: renamedB} = obj;"), + row("Nested object destructuring", ["inner"], "const obj = {nested: {inner: 1}}; export const {nested: {inner}} = obj;"), + row("Array destructuring with hole", ["first", "third"], "export const [first, , third] = [1, 2, 3];"), + row("Aliased local export", ["publicFn"], "function internalFn() {} export {internalFn as publicFn};"), + row("Multiline async declaration", ["compute"], "export\n async function\n compute(): Promise { return 1; }"), + row("Undeclared destructured alias", ["a"], "const obj = {a: 1, b: 2}; export const {a, b: surprise} = obj;", {exportDrift: true}), + row("Undeclared multiline function", ["compute"], "export\nasync function\ncompute() {}\nexport\nfunction surprise() {}", {exportDrift: true}), + row("Missing original alias name", ["internalFn"], "function internalFn() {} export {internalFn as publicFn};", {exportDrift: true}), + row("Allowed multiline type re-export", ["PublicOrder"], 'export type {\nOrder as PublicOrder\n} from "src/domain/order_ledger/types.ts";'), + row("Rest binding adds undeclared export", ["first"], "export const [first, ...remaining] = [1, 2, 3];", {exportDrift: true}), + ]), + ...cases("forbidden_import", [ + row("Unallowed absolute module import", ["submit"], 'import {chargeCard} from "src/infra/stripe_gateway/index.ts"; export function submit() {}'), + row("Unallowed relative module import", ["submit"], 'import {chargeCard} from "../../infra/stripe_gateway/index.ts"; export function submit() {}'), + row("Multiline forbidden import", ["submit"], 'import {\nchargeCard\n} from "src/infra/stripe_gateway/index.ts"; export function submit() {}'), + row("Forbidden type import", ["submit"], 'import type {Charge} from "src/infra/stripe_gateway/types.ts"; export function submit() {}'), + row("Forbidden named re-export", ["chargeCard"], 'export {chargeCard} from "src/infra/stripe_gateway/index.ts";'), + row("Forbidden namespace import", ["submit"], 'import * as gateway from "src/infra/stripe_gateway/index.ts"; export function submit() {}'), + row("Forbidden side-effect import", ["submit"], 'import "src/infra/stripe_gateway/register.ts"; export function submit() {}'), + row("Forbidden default import", ["submit"], 'import gateway from "src/infra/stripe_gateway/index.ts"; export function submit() {}'), + row("Forbidden type re-export", ["Charge"], 'export type {Charge} from "src/infra/stripe_gateway/types.ts";'), + row("Forbidden import-equals declaration", ["submit"], 'import gateway = require("src/infra/stripe_gateway/index.ts"); export function submit() {}'), + ]), + ...cases("prefix_trap_import", [ + row("Ledger underscore sibling", ["submit"], 'import {secret} from "src/domain/order_ledger_internal/secret.ts"; export function submit() {}', {forbiddenImport: true, allowed: [M.ledger]}), + row("Ledger suffix sibling", ["submit"], 'import {secret} from "src/domain/order_ledger_v2/index.ts"; export function submit() {}', {forbiddenImport: true, allowed: [M.ledger]}), + row("Money underscore sibling", ["submit"], 'import {secret} from "src/shared/money_private/index.ts"; export function submit() {}', {forbiddenImport: true, allowed: [M.money]}), + row("Relative ledger sibling", ["submit"], 'import {secret} from "../../domain/order_ledger_internal/secret.ts"; export function submit() {}', {forbiddenImport: true, allowed: [M.ledger]}), + row("Relative escape from allowed ledger", ["submit"], 'import {secret} from "../../domain/order_ledger/../order_ledger_internal/secret.ts"; export function submit() {}', {forbiddenImport: true, allowed: [M.ledger]}), + row("Allowed money nested currency", ["submit"], 'import {currency} from "src/shared/money/currency.ts"; export function submit() {}', {allowed: [M.money]}), + row("Allowed ledger nested type", ["submit"], 'import type {Order} from "src/domain/order_ledger/types/order.ts"; export function submit() {}', {allowed: [M.ledger]}), + row("Allowed relative money nested helper", ["submit"], 'import {roundMinor} from "../../shared/money/rounding.ts"; export function submit() {}', {allowed: [M.money]}), + row("Allowed exact ledger module", ["submit"], 'import {appendOrder} from "src/domain/order_ledger"; export function submit() {}', {allowed: [M.ledger]}), + row("Allowed ledger nested re-export", ["Order"], 'export type {Order} from "src/domain/order_ledger/types.ts";', {allowed: [M.ledger]}), + ]), + ...cases("signature_only_limitation", [ + row("submit parameter changed from number to string", ["submit"], "export function submit(amount: string): boolean { return true; }"), + row("submit return changed from boolean to string", ["submit"], "export function submit(amount: number): string { return String(amount); }"), + row("submit gained required currency parameter", ["submit"], "export function submit(amount: number, currency: string): boolean { return true; }"), + row("load changed from Promise to synchronous Order", ["load"], "export function load(id: string): Order { return {id}; }"), + row("cancel parameter narrowed from string to literal", ["cancel"], 'export function cancel(id: "fixed"): void {}'), + row("Quote total changed from number to string", ["Quote"], "export interface Quote { total: string }"), + row("OrderId changed from string to number", ["OrderId"], "export type OrderId = number;"), + row("Checkout constructor changed from string key to numeric key", ["Checkout"], "export class Checkout { constructor(key: number) { void key; } }"), + row("submit callback changed from void result to mandatory boolean result", ["submit"], "export function submit(callback: () => boolean): void { callback(); }"), + row("quote generic removed from formerly generic identity function", ["quote"], "export function quote(input: string): string { return input; }"), + ]), +]; diff --git a/examples/repository-context-living-specs-comparison.ts b/examples/repository-context-living-specs-comparison.ts new file mode 100644 index 0000000000..ebf6469729 --- /dev/null +++ b/examples/repository-context-living-specs-comparison.ts @@ -0,0 +1,345 @@ +/** Local synthetic evaluation, deliberately outside active CI. No model jobs or production writes. */ +import assert from "node:assert/strict"; +import {readFileSync} from "node:fs"; +import {posix} from "node:path"; +import {canonicalAuthoritySha256} from "../loopx/control_plane/coordination/authority_store_codec.ts"; +import {buildModuleKnowledgeGraph, comparePublicInterfaces, extractTypeScriptExportsAndImports, + parseModuleSpec, routeRepositoryContext, type ModuleKnowledge, type RoutedModuleSummary, +} from "../loopx/control_plane/capabilities/repository_context/index.ts"; +import {BENCHMARK_MODULES, BOUNDARY_CASES, ROUTING_TASKS, buildBenchmarkSpecs, + type BoundaryEvalCase, type RoutingTaskOracle} from "./living_specs_benchmark_corpus.ts"; + +const budgets = [2048, 4096, 8192, 16384]; +const arms = ["R0", "R1", "R2", "R3-no-deps", "R3", "R3-2hop"]; +const comparePath = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0; +const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8"); +const mean = (values: readonly number[]): number => values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; +const percent = (n: number, d: number): number | null => d ? 100 * n / d : null; +const display = (value: number | null): string => value === null ? "n/a" : value.toFixed(2); + +// Explicit fixture-to-production adapter: the corpus predates the plural headings. +// Preserve Summary text as responsibilities instead of benchmarking empty summaries. +function benchmarkSpecs(task: RoutingTaskOracle) { + return buildBenchmarkSpecs({staleModulePaths: task.staleModulePaths}).map(spec => ({...spec, + content: spec.content.replace("## Public Interface\n", "## Public Interfaces\n") + .replace(/## Summary\n([^]*?)(?=\n## )/, (_, summary: string) => `## Responsibilities\n- ${summary.trim()}\n`), + })); +} +function within(path: string, parent: string): boolean { + return parent === "." || path === parent || path.startsWith(`${parent}/`); +} +function ownersFor(modules: readonly ModuleKnowledge[], scope: readonly string[]): Set { + const owners = new Set(); + for (const entry of scope) { + const path = posix.normalize(entry).replace(/\/$/, ""); + const longest = modules.filter(module => within(path, module.modulePath)) + .sort((a, b) => b.modulePath.length - a.modulePath.length)[0]; + if (longest) owners.add(longest.modulePath); + for (const module of modules) if (within(module.modulePath, path)) owners.add(module.modulePath); + } + return owners; +} +type ContextBlock = Omit & {readonly selectionReason: string}; +/** Same field ordering and schema as context_route.ts. Non-owner baseline reason + * labels have the same UTF-8 length as direct_dependency, avoiding metadata tax. */ +function formatContextBlock(spec: ModuleKnowledge, owners: ReadonlySet, stale: ReadonlySet, + nonOwnerReason = "direct_dependency"): ContextBlock { + if (nonOwnerReason !== "transitive_dependency") assert.equal(Buffer.byteLength(nonOwnerReason), Buffer.byteLength("direct_dependency")); + return {modulePath: spec.modulePath, specPath: spec.specPath, specDigest: spec.specDigest, + responsibilities: spec.responsibilities, publicInterfaces: spec.publicInterfaces, + allowedDependencies: spec.allowedDependencies, invariants: spec.invariants, + selectionReason: owners.has(spec.modulePath) ? "owning_write_scope" : nonOwnerReason, + stale: stale.has(spec.modulePath)}; +} +function pack(candidates: readonly ContextBlock[], maxBytes: number): ContextBlock[] { + const selected: ContextBlock[] = []; + for (const candidate of candidates) if (bytes([...selected, candidate]) <= maxBytes) selected.push(candidate); + return selected; +} +function proximity(module: string, scope: readonly string[]): {depth: number; distance: number} { + const a = module.split("/"); + const scores = scope.map(entry => { + const path = entry.endsWith("/") || !posix.extname(entry) ? entry : posix.dirname(entry); + const b = posix.normalize(path).replace(/\/$/, "").split("/"); + let depth = 0; + while (depth < Math.min(a.length, b.length) && a[depth] === b[depth]) depth++; + return {depth, distance: a.length + b.length - 2 * depth}; + }); + return scores.sort((x, y) => y.depth - x.depth || x.distance - y.distance)[0] ?? {depth: 0, distance: Infinity}; +} +function tokens(value: string): string[] { + return value.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().match(/[a-z0-9]+/g) ?? []; +} +function lexicalScores(specs: readonly {modulePath: string; content: string}[], query: string): Map { + const documents = specs.map(spec => tokens(spec.content)); + const averageLength = mean(documents.map(document => document.length)); + const queryTerms = new Set(tokens(query)); + const frequencies = new Map(); + for (const document of documents) for (const term of new Set(document)) frequencies.set(term, (frequencies.get(term) ?? 0) + 1); + return new Map(specs.map((spec, index) => { + const document = documents[index]; + const counts = new Map(); + for (const term of document) counts.set(term, (counts.get(term) ?? 0) + 1); + let score = 0; + for (const term of queryTerms) { + const tf = counts.get(term) ?? 0; + const df = frequencies.get(term) ?? 0; + const idf = Math.log(1 + (documents.length - df + 0.5) / (df + 0.5)); + score += idf * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * document.length / averageLength)); + } + return [spec.modulePath, score]; + })); +} +interface RoutingObservation { + taskId: string; category: RoutingTaskOracle["category"]; arm: string; budget: number; + recallPercent: number; complete: boolean; irrelevantNoisePercent: number; serializedBytes: number; + budgetViolation: boolean; selectedModulePaths: string[]; staleSpecs: string[]; + staleRequiredCount: number; staleRequiredSurfacedCount: number; allRequiredStaleSurfaced: boolean | null; +} + +assert.equal(BENCHMARK_MODULES.length, 15); +assert.equal(ROUTING_TASKS.length, 36); +assert.equal(ROUTING_TASKS.filter(task => task.category === "transitive_dep").length, 4); +assert.equal(BOUNDARY_CASES.length, 80); +assert.equal(new Set(ROUTING_TASKS.map(task => task.taskId)).size, 36); +assert.equal(new Set(BOUNDARY_CASES.map(test => test.caseId)).size, 80); +const paths = new Set(BENCHMARK_MODULES.map(module => module.modulePath)); +const routingObservations: RoutingObservation[] = []; +const references: {taskId: string; fullSpecSummaryBytes: number; rawFullSpecBytes: number; fullSourceBytes: number}[] = []; +for (const task of ROUTING_TASKS) { + for (const path of [...task.requiredModulePaths, ...task.usefulModulePaths, ...(task.staleModulePaths ?? [])]) assert.ok(paths.has(path), path); + assert.ok(task.requiredModulePaths.length > 0); + const specs = benchmarkSpecs(task); + const graph = buildModuleKnowledgeGraph(specs); + for (const module of graph.modules) { + const fixture = BENCHMARK_MODULES.find(item => item.modulePath === module.modulePath)!; + assert.deepEqual(module.responsibilities, [fixture.summary]); + assert.equal(module.publicInterfaces.length, fixture.publicInterfaces.length); + } + const owners = ownersFor(graph.modules, task.writeScope); + const stale = new Set(task.staleModulePaths ?? []); + const summaries = graph.modules.map(module => formatContextBlock(module, owners, stale)); + references.push({taskId: task.taskId, fullSpecSummaryBytes: bytes(summaries), + rawFullSpecBytes: bytes(buildBenchmarkSpecs({staleModulePaths: task.staleModulePaths})), + fullSourceBytes: bytes(Object.fromEntries(BENCHMARK_MODULES.flatMap(module => Object.entries(module.files))))}); + const scores = lexicalScores(specs, `${task.taskPrompt} ${task.writeScope.join(" ")}`); + for (const budget of budgets) for (const arm of arms) { + let selected: readonly ContextBlock[]; + if (arm.startsWith("R3")) { + const plan = routeRepositoryContext(graph, {writeScope: task.writeScope, maxBytes: budget, + maxDependencyDepth: arm === "R3-no-deps" ? 0 : arm === "R3-2hop" ? 2 : 1, staleModulePaths: task.staleModulePaths}); + selected = plan.selectedModules; + assert.equal(plan.totalBytes, selected.length ? bytes(selected) : 0); + for (const block of selected) assert.deepEqual(block, formatContextBlock(graph.modules.find(module => module.modulePath === block.modulePath)!, owners, stale, block.selectionReason === "owning_write_scope" ? "direct_dependency" : block.selectionReason)); + } else { + const ordered = [...graph.modules].sort((a, b) => { + const ownerPriority = Number(owners.has(b.modulePath)) - Number(owners.has(a.modulePath)); + if (arm !== "R0" && ownerPriority) return ownerPriority; + if (arm === "R1") { + const x = proximity(a.modulePath, task.writeScope), y = proximity(b.modulePath, task.writeScope); + if (x.depth !== y.depth || x.distance !== y.distance) return y.depth - x.depth || x.distance - y.distance; + } + if (arm === "R2" && scores.get(a.modulePath) !== scores.get(b.modulePath)) return scores.get(b.modulePath)! - scores.get(a.modulePath)!; + return comparePath(a.modulePath, b.modulePath); + }); + const reason = arm === "R0" ? "repository_order_" : arm === "R1" ? "path_proximity___" : "lexical_relevance"; + selected = pack(ordered.map(module => formatContextBlock(module, owners, stale, reason)), budget); + } + const selectedPaths = new Set(selected.map(module => module.modulePath)); + const required = new Set(task.requiredModulePaths); + const relevant = new Set([...required, ...task.usefulModulePaths]); + const staleRequired = [...required].filter(path => stale.has(path)); + const staleSpecs = selected.filter(module => module.stale).map(module => module.modulePath); + const staleFound = staleRequired.filter(path => staleSpecs.includes(path)).length; + const serializedBytes = selected.length ? bytes(selected) : 0; + // Attribute each object's bytes plus one array separator; the final closing + // bracket is shared overhead. Thus irrelevant bytes + relevant bytes + 1 = total. + const irrelevantBytes = selected.filter(module => !relevant.has(module.modulePath)).reduce((sum, module) => sum + bytes(module) + 1, 0); + const found = [...required].filter(path => selectedPaths.has(path)).length; + routingObservations.push({taskId: task.taskId, category: task.category, arm, budget, + recallPercent: 100 * found / required.size, complete: found === required.size, + irrelevantNoisePercent: serializedBytes ? 100 * irrelevantBytes / serializedBytes : 0, + serializedBytes, budgetViolation: serializedBytes > budget, selectedModulePaths: [...selectedPaths], staleSpecs, + staleRequiredCount: staleRequired.length, staleRequiredSurfacedCount: staleFound, + allRequiredStaleSurfaced: staleRequired.length ? staleFound === staleRequired.length : null}); + } +} +assert.equal(routingObservations.length, ROUTING_TASKS.length * budgets.length * arms.length); +// Increasing depth must retain every selected owner/direct dependency as a prefix. +for (const task of ROUTING_TASKS) for (const budget of budgets) { + const rows = routingObservations.filter(row => row.taskId === task.taskId && row.budget === budget); + const oneHop = rows.find(row => row.arm === "R3")!.selectedModulePaths; + const twoHop = rows.find(row => row.arm === "R3-2hop")!.selectedModulePaths; + assert.deepEqual(twoHop.slice(0, oneHop.length), oneHop); +} +assert.ok(routingObservations.every(observation => !observation.budgetViolation)); +const routingSummary = budgets.flatMap(budget => arms.map(arm => { + const observations = routingObservations.filter(row => row.arm === arm && row.budget === budget); + const coverage = (rows: typeof observations) => percent(rows.filter(row => row.complete).length, rows.length); + const staleRows = observations.filter(row => row.allRequiredStaleSurfaced !== null); + return {arm, budget, observations: observations.length, + meanRequiredRecallPercent: mean(observations.map(row => row.recallPercent)), + completeTaskCoveragePercent: coverage(observations), + directContractCoveragePercent: coverage(observations.filter(row => row.category !== "transitive_dep")), + transitiveCoveragePercent: coverage(observations.filter(row => row.category === "transitive_dep")), + meanIrrelevantNoisePercent: mean(observations.map(row => row.irrelevantNoisePercent)), + meanSerializedBytes: mean(observations.map(row => row.serializedBytes)), + budgetViolations: observations.filter(row => row.budgetViolation).length, + staleRequiredTasks: staleRows.length, + staleTaskSurfacedPercent: percent(staleRows.filter(row => row.allRequiredStaleSurfaced).length, staleRows.length), + staleSpecSurfacedPercent: percent(staleRows.reduce((sum, row) => sum + row.staleRequiredSurfacedCount, 0), + staleRows.reduce((sum, row) => sum + row.staleRequiredCount, 0)), + meanByteReductionVsFullSpecPercent: mean(observations.map(row => 100 * (1 - row.serializedBytes / references.find(ref => ref.taskId === row.taskId)!.fullSpecSummaryBytes))), + }; +})); + +// Fixed-seed paired bootstrap, clustered by task; budgets are not independent samples. +function pairedCoverage(budget: number, treatment: string, baseline: string, stratum: string) { + const paired = ROUTING_TASKS.filter(task => stratum === "all" || + (stratum === "transitive" ? task.category === "transitive_dep" : task.category !== "transitive_dep")).map(task => { + const rows = routingObservations.filter(row => row.taskId === task.taskId && row.budget === budget); + return Number(rows.find(row => row.arm === treatment)!.complete) - Number(rows.find(row => row.arm === baseline)!.complete); + }); + let seed = 7321; + const replicates = Array.from({length: 2000}, () => { + let sum = 0; + for (let i = 0; i < paired.length; i++) { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + sum += paired[(seed >>> 0) % paired.length]; + } + return 100 * sum / paired.length; + }).sort((a, b) => a - b); + return {budget, treatment, baseline, stratum, tasks: paired.length, coverageDeltaPoints: 100 * mean(paired), + bootstrap95PercentInterval: [replicates[49], replicates[1949]], + exploratoryTenPointCriterionMet: 100 * mean(paired) >= 10 && replicates[49] > 0}; +} +const pairedComparisons = budgets.flatMap(budget => ["R3", "R3-2hop"].flatMap(treatment => + arms.filter(arm => arm !== treatment).flatMap(baseline => + ["all", "direct", "transitive"].map(stratum => pairedCoverage(budget, treatment, baseline, stratum))))); + +function regexExtract(source: string): {exports: string[]; imports: string[]} { + const exports = [...source.matchAll(/\bexport\s+(?:async\s+)?(?:function|const|let|var|class|interface|type)\s+([A-Za-z_$][\w$]*)/g)].map(match => match[1]); + const imports = [...source.matchAll(/\bfrom\s+["']([^"']+)["']/g)].map(match => match[1]); + return {exports: [...new Set(exports)].sort(), imports: [...new Set(imports)].sort()}; +} +// Same set and path-boundary policy for both extractors. For every AST case, +// assert this evaluator's complete result equals the actual production comparator. +function evaluateExtraction(spec: ModuleKnowledge, files: readonly {filePath: string; content: string}[], + extract: typeof regexExtract) { + const declaredExports = [...new Set(spec.publicInterfaces.map(item => item.name))].sort(); + const observed = new Set(), forbidden = new Set(); + for (const file of files) { + const extracted = extract(file.content); + for (const name of extracted.exports) observed.add(name); + for (const imported of extracted.imports) { + const relative = imported.startsWith("./") || imported.startsWith("../"); + const target = relative ? posix.normalize(posix.join(posix.dirname(file.filePath), imported)) : imported; + if (!(relative && within(target, spec.modulePath)) && !spec.allowedDependencies.some(dependency => within(target, dependency))) forbidden.add(imported); + } + } + const observedExports = [...observed].sort(); + const undeclaredExports = observedExports.filter(name => !declaredExports.includes(name)); + const missingExports = declaredExports.filter(name => !observed.has(name)); + const forbiddenImports = [...forbidden].sort(); + const hasDrift = undeclaredExports.length > 0 || missingExports.length > 0 || forbiddenImports.length > 0; + return {modulePath: spec.modulePath, declaredExports, observedExports, undeclaredExports, missingExports, + forbiddenImports, hasDrift, requiresGateReview: hasDrift}; +} +function boundarySpec(test: BoundaryEvalCase): ModuleKnowledge { + return parseModuleSpec({modulePath: test.modulePath, specPath: `${test.modulePath}/SPEC.md`, content: + `## Public Interfaces\n${test.declaredInterfaces.map(name => `- ${name}`).join("\n")}\n## Allowed Dependencies\n${test.allowedDependencies.map(path => `- ${path}`).join("\n")}`}); +} +interface BoundaryObservation { + arm: string; caseId: string; category: BoundaryEvalCase["category"]; signatureOnly: boolean; + expectedExportDrift: boolean; expectedForbiddenImport: boolean; + predictedExportDrift: boolean | null; predictedForbiddenImport: boolean | null; + error: string | null; +} +const boundaryObservations: BoundaryObservation[] = []; +for (const test of BOUNDARY_CASES) for (const arm of ["B0", "B1"]) { + const spec = boundarySpec(test); + const files = Object.entries(test.files).map(([filePath, content]) => ({filePath, content})); + let prediction: ReturnType | undefined; + let error: string | null = null; + try { + prediction = arm === "B1" ? comparePublicInterfaces(spec, files) : evaluateExtraction(spec, files, regexExtract); + } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); } + if (arm === "B1" && prediction) assert.deepEqual(prediction, evaluateExtraction(spec, files, extractTypeScriptExportsAndImports)); + boundaryObservations.push({arm, caseId: test.caseId, category: test.category, + signatureOnly: test.isSignatureOnlyLimitation, expectedExportDrift: test.expectedExportDrift, + expectedForbiddenImport: test.expectedForbiddenImport, + predictedExportDrift: prediction ? prediction.missingExports.length > 0 || prediction.undeclaredExports.length > 0 : null, + predictedForbiddenImport: prediction ? prediction.forbiddenImports.length > 0 : null, error}); +} +function confusion(rows: readonly BoundaryObservation[], signal: "exports" | "imports" | "combined") { + let tp = 0, fp = 0, tn = 0, fn = 0, errors = 0; + for (const row of rows) { + const expected = signal === "exports" ? row.expectedExportDrift : signal === "imports" ? row.expectedForbiddenImport : row.expectedExportDrift || row.expectedForbiddenImport; + const predicted = signal === "exports" ? row.predictedExportDrift : signal === "imports" ? row.predictedForbiddenImport : row.predictedExportDrift === null ? null : row.predictedExportDrift || row.predictedForbiddenImport; + if (predicted === null) { errors++; if (expected) fn++; continue; } + if (expected && predicted) tp++; else if (predicted) fp++; else if (expected) fn++; else tn++; + } + const negatives = rows.filter(row => signal === "exports" ? !row.expectedExportDrift : signal === "imports" ? !row.expectedForbiddenImport : !row.expectedExportDrift && !row.expectedForbiddenImport).length; + return {cases: rows.length, tp, fp, tn, fn, errors, precisionPercent: percent(tp, tp + fp), + recallPercent: percent(tp, tp + fn), falsePositivePercent: percent(fp, negatives)}; +} +const boundarySummary = ["B0", "B1"].map(arm => { + const all = boundaryObservations.filter(row => row.arm === arm); + const supported = all.filter(row => !row.signatureOnly); + assert.equal(supported.length, 70); + const signatures = all.filter(row => row.signatureOnly); + assert.equal(signatures.length, 10); + return {arm, supportedExports: confusion(supported, "exports"), supportedImports: confusion(supported, "imports"), + supportedCombined: confusion(supported, "combined"), + syntaxTraps: confusion(supported.filter(row => row.category === "syntax_trap_comment_or_string"), "combined"), + complexExports: confusion(supported.filter(row => row.category === "complex_ts_syntax_export"), "exports"), + prefixImports: confusion(supported.filter(row => row.category === "prefix_trap_import"), "imports"), + signatureOnly: {cases: signatures.length, detected: signatures.filter(row => row.predictedExportDrift === true).length, + errors: signatures.filter(row => row.error !== null).length}, + }; +}); + +const sourceFiles = ["./repository-context-living-specs-comparison.ts", "./living_specs_benchmark_corpus.ts", + "../loopx/control_plane/capabilities/repository_context/module_graph.ts", + "../loopx/control_plane/capabilities/repository_context/context_route.ts", + "../loopx/control_plane/capabilities/repository_context/interface_boundary.ts"]; +const summary = { + metadata: {nodeVersion: process.version, corpusModules: 15, routingTasks: 36, routingObservations: routingObservations.length, + boundaryCases: 80, boundaryObservations: 160, + sourceDigest: canonicalAuthoritySha256(sourceFiles.map(path => ({path, content: readFileSync(new URL(path, import.meta.url), "utf8")}))), + inputDigest: canonicalAuthoritySha256({modules: BENCHMARK_MODULES, routing: ROUTING_TASKS, boundary: BOUNDARY_CASES}), + fixtureAdapter: "Summary -> Responsibilities bullet; Public Interface -> Public Interfaces; no corpus edits", + freshness: "Caller-provided stale paths, identical across arms. No source-digest freshness detection claim.", + budgetScope: "Selected JSON array, including punctuation; empty contribution costs zero. Omission/readback envelopes are outside this budget.", + noiseAttribution: "Object bytes plus one separator per object; final array bracket is shared overhead. Empty selection has zero noise and zero recall.", + limits: "Synthetic retrieval and extraction only; no agent outcomes, signature checking, provider tokens, latency or total-cost qualification. Corpus is not a held-out study; thresholds and bootstrap intervals are exploratory.", + }, + references: {meanFullSpecSummaryBytes: mean(references.map(row => row.fullSpecSummaryBytes)), + meanRawFullSpecBytes: mean(references.map(row => row.rawFullSpecBytes)), + fullSourceBytes: references[0].fullSourceBytes, fullSpecRequiredRecallPercent: 100, fullSpecCompleteCoveragePercent: 100}, + routingSummary, pairedComparisons, boundarySummary, routingObservations, boundaryObservations, +}; +console.log("# Living-spec multi-arm evaluation\n"); +console.log("Synthetic corpus: 15 modules, 36 tasks, 4 budgets, 6 routing arms = 864 observations; 80 boundary cases × 2 extractors.\n"); +console.log("R0 = repository order; R1 = owner + path proximity; R2 = owner + BM25 (k1=1.2, b=0.75); R3-no-deps = owner-only ablation; R3 = one hop; R3-2hop = two hops. All ties use repository path. Oversized candidates are skipped; later candidates may fit.\n"); +console.log("Identical block fields and UTF-8 JSON-array accounting; baseline non-owner reason labels match the direct_dependency byte length. Actual transitive_dependency labels cost four additional bytes per block, included in the budget. Corpus headings are explicitly adapted to the production parser. Stale annotations are supplied equally to every arm; graph-unaware selectors can surface them too.\n"); +console.log(`Unbounded references: normalized full-spec summaries ${display(summary.references.meanFullSpecSummaryBytes)} mean bytes; raw full specs ${display(summary.references.meanRawFullSpecBytes)} mean bytes; full-source path/content map ${summary.references.fullSourceBytes} bytes. Source and spec representations differ: these are cost references, not interchangeable task inputs.\n`); +console.log("## A. Context routing\n"); +console.log("Direct coverage excludes the four transitive tasks. Stale task rate requires every stale required spec to be selected and flagged; JSON also reports per-spec rate.\n"); +console.log("| Arm | Budget | Recall % | Complete % | Direct % | Transitive % | Noise % | Mean bytes | Violations | Stale tasks % (n) | Byte reduction vs full specs % |\n|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); +for (const row of routingSummary) console.log(`| ${row.arm} | ${row.budget} | ${display(row.meanRequiredRecallPercent)} | ${display(row.completeTaskCoveragePercent)} | ${display(row.directContractCoveragePercent)} | ${display(row.transitiveCoveragePercent)} | ${display(row.meanIrrelevantNoisePercent)} | ${display(row.meanSerializedBytes)} | ${row.budgetViolations} | ${display(row.staleTaskSurfacedPercent)} (${row.staleRequiredTasks}) | ${display(row.meanByteReductionVsFullSpecPercent)} |`); +console.log("\n### Paired task coverage differences (overall, direct-contract and transitive strata)\n\nFixed-seed 2,000-resample task bootstrap, pointwise descriptive 95% intervals; no multiplicity correction or population-generalization claim.\n"); +console.log("| Budget | Treatment | Versus | Stratum | Difference (pp) | 95% interval | ≥10 pp and lower bound >0 |\n|---|---|---|---|---:|---|---|"); +for (const row of pairedComparisons) console.log(`| ${row.budget} | ${row.treatment} | ${row.baseline} | ${row.stratum} | ${display(row.coverageDeltaPoints)} | [${row.bootstrap95PercentInterval.map(display).join(", ")}] | ${row.exploratoryTenPointCriterionMet} |`); +console.log("\n## B. Boundary detection\n\nMetrics classify cases, not individual symbols. Export and import labels are evaluated separately; combined drift is their union. Exceptions remain errors and count as misses on positive cases. n/a means a zero denominator. Both extractors use the same set/path policy, checked against the production AST comparator on every successful case.\n"); +console.log("| Arm | Signal (70 supported cases) | TP | FP | TN | FN | Errors | Precision % | Recall % | FPR % |\n|---|---|---:|---:|---:|---:|---:|---:|---:|---:|"); +for (const row of boundarySummary) for (const [signal, metric] of [["Exports", row.supportedExports], ["Imports", row.supportedImports], ["Combined", row.supportedCombined]] as const) { + console.log(`| ${row.arm} | ${signal} | ${metric.tp} | ${metric.fp} | ${metric.tn} | ${metric.fn} | ${metric.errors} | ${display(metric.precisionPercent)} | ${display(metric.recallPercent)} | ${display(metric.falsePositivePercent)} |`); +} +console.log("\n| Arm | Syntax-trap combined FPR % (10 cases) | Complex-export recall % (positives / 10 cases) | Complex-export FPR % | Prefix-import recall % (positives / 10 cases) | Signature-only detected / 10 |\n|---|---:|---:|---:|---:|---:|"); +for (const row of boundarySummary) console.log(`| ${row.arm} | ${display(row.syntaxTraps.falsePositivePercent)} | ${display(row.complexExports.recallPercent)} (${row.complexExports.tp + row.complexExports.fn}/10) | ${display(row.complexExports.falsePositivePercent)} | ${display(row.prefixImports.recallPercent)} (${row.prefixImports.tp + row.prefixImports.fn}/10) | ${row.signatureOnly.detected}/10 |`); +console.log("\nB0 is a conventional raw-text declaration/from regex: it does not strip comments/strings or resolve destructured bindings. Prefix checking is shared with B1, so prefix-policy successes are not AST-specific gains. Signature-only cases are excluded from supported-case metrics and remain a known detection gap.\n"); +console.log(`Limits: ${summary.metadata.limits}\n`); +console.log("## JSON summary\n\n```json"); +console.log(JSON.stringify(summary, null, 2)); +console.log("```"); diff --git a/loopx/control_plane/capabilities/repository_context/context_route.ts b/loopx/control_plane/capabilities/repository_context/context_route.ts new file mode 100644 index 0000000000..ce7c6a8983 --- /dev/null +++ b/loopx/control_plane/capabilities/repository_context/context_route.ts @@ -0,0 +1,117 @@ +import {posix} from "node:path"; +import type {JsonObject} from "../../effect_program.ts"; +import {validateTurnContextContribution} from "../../turn_driver/capability_context.ts"; +import type {ModuleKnowledge, ModuleKnowledgeGraph} from "./module_graph.ts"; + +export interface RepositoryContextRequest { + readonly writeScope: readonly string[]; + readonly maxBytes: number; + readonly includeDirectDependencies?: boolean; + /** Defaults to one hop, or zero when includeDirectDependencies is false. */ + readonly maxDependencyDepth?: number; + readonly staleModulePaths?: readonly string[]; +} +export interface RoutedModuleSummary extends ModuleKnowledge { + readonly selectionReason: "owning_write_scope" | "direct_dependency" | "transitive_dependency"; + readonly stale: boolean; +} +export interface OmittedModuleSummary { + readonly modulePath: string; + readonly reason: "budget_exceeded" | "outside_write_scope"; +} +export interface RepositoryContextPlan { + readonly selectedModules: readonly RoutedModuleSummary[]; + readonly omittedModules: readonly OmittedModuleSummary[]; + /** UTF-8 bytes of the selected JSON array; no contribution costs zero bytes. */ + readonly totalBytes: number; + readonly maxBytes: number; +} +function within(path: string, module: string): boolean { + return module === "." || path === module || path.startsWith(`${module}/`); +} +function normalized(path: string): string { + const result = posix.normalize(path.replaceAll("\\", "/")); + if (!path.trim() || posix.isAbsolute(result) || result === ".." || result.startsWith("../")) { + throw new Error(`Invalid repository path: ${path}`); + } + return result.replace(/\/$/, ""); +} +export function routeRepositoryContext(graph: ModuleKnowledgeGraph, request: RepositoryContextRequest): RepositoryContextPlan { + if (!Number.isSafeInteger(request.maxBytes) || request.maxBytes < 0) throw new Error("maxBytes must be a nonnegative safe integer"); + if (request.maxDependencyDepth !== undefined && (!Number.isSafeInteger(request.maxDependencyDepth) || request.maxDependencyDepth < 0)) { + throw new Error("maxDependencyDepth must be a nonnegative safe integer"); + } + if (request.includeDirectDependencies === false && request.maxDependencyDepth !== undefined && request.maxDependencyDepth > 0) { + throw new Error("maxDependencyDepth > 0 conflicts with includeDirectDependencies: false"); + } + const maxDepth = request.maxDependencyDepth ?? (request.includeDirectDependencies === false ? 0 : 1); + const owners = new Set(); + for (const entry of request.writeScope) { + const path = normalized(entry); + const matching = graph.modules.filter(module => within(path, module.modulePath)); + const longest = matching.sort((a, b) => b.modulePath.length - a.modulePath.length)[0]; + if (longest) owners.add(longest.modulePath); + // A directory scope also authorizes modules nested beneath that directory. + for (const module of graph.modules) if (within(module.modulePath, path)) owners.add(module.modulePath); + } + const byPath = new Map(graph.modules.map(module => [module.modulePath, module])); + const depths = new Map([...owners].map(path => [path, 0])); + let frontier = [...owners]; + for (let depth = 1; depth <= maxDepth && frontier.length; depth++) { + const next: string[] = []; + for (const path of frontier) for (const target of byPath.get(path)!.allowedDependencies) { + if (!byPath.has(target)) throw new Error(`Missing dependency module: ${path} -> ${target}`); + if (!depths.has(target)) { depths.set(target, depth); next.push(target); } + } + frontier = next; + } + const stale = new Set((request.staleModulePaths ?? []).map(normalized)); + const ordered = [...graph.modules].sort((a, b) => a.modulePath < b.modulePath ? -1 : a.modulePath > b.modulePath ? 1 : 0); + const selectedModules: RoutedModuleSummary[] = []; + const omittedModules: OmittedModuleSummary[] = []; + for (const module of ordered) if (!depths.has(module.modulePath)) { + omittedModules.push({modulePath: module.modulePath, reason: "outside_write_scope"}); + } + // Stable path order within each shortest-hop frontier. Deeper context cannot + // displace any earlier owning or direct-dependency selection under the budget. + const candidates = ordered.filter(module => depths.has(module.modulePath)) + .sort((a, b) => depths.get(a.modulePath)! - depths.get(b.modulePath)!); + for (const module of candidates) { + const summary: RoutedModuleSummary = { + modulePath: module.modulePath, specPath: module.specPath, specDigest: module.specDigest, + responsibilities: module.responsibilities, publicInterfaces: module.publicInterfaces, + allowedDependencies: module.allowedDependencies, invariants: module.invariants, + selectionReason: depths.get(module.modulePath) === 0 ? "owning_write_scope" + : depths.get(module.modulePath) === 1 ? "direct_dependency" : "transitive_dependency", + stale: stale.has(module.modulePath), + }; + if (Buffer.byteLength(JSON.stringify([...selectedModules, summary]), "utf8") > request.maxBytes) { + omittedModules.push({modulePath: module.modulePath, reason: "budget_exceeded"}); + } else selectedModules.push(summary); + } + return {selectedModules, omittedModules, + totalBytes: selectedModules.length ? Buffer.byteLength(JSON.stringify(selectedModules), "utf8") : 0, maxBytes: request.maxBytes}; +} + +/** Bind advisory knowledge to the existing governing turn; never invent authority. */ +export function buildTurnContextContributionInput(plan: RepositoryContextPlan, options: { + goalId: string; agentId: string; turnEnvelope: Record; + hookId?: string; capabilityId?: string; +}): JsonObject { + const source = {...options.turnEnvelope, goal_id: options.goalId, agent_id: options.agentId}; + for (const [key, expected] of [["goal_id", options.goalId], ["agent_id", options.agentId]] as const) { + if (!expected.trim() || (options.turnEnvelope[key] !== undefined && options.turnEnvelope[key] !== expected)) { + throw new Error(`Turn context ${key} mismatch`); + } + } + const input: JsonObject = { + source, + registration: {hook_id: options.hookId ?? "repository_context", capability_id: options.capabilityId ?? "repository_context", + readback_slots: ["repository_context"], max_result_bytes: 65536}, + result: {context_fields: {repository_context: {selectedModules: plan.selectedModules}}, + readback: {repository_context: {omittedModules: plan.omittedModules, totalBytes: plan.totalBytes, maxBytes: plan.maxBytes}}, + instructions: [], result_fields: {}}, + }; + validateTurnContextContribution(input); + return input; +} diff --git a/loopx/control_plane/capabilities/repository_context/index.ts b/loopx/control_plane/capabilities/repository_context/index.ts new file mode 100644 index 0000000000..761056018b --- /dev/null +++ b/loopx/control_plane/capabilities/repository_context/index.ts @@ -0,0 +1,6 @@ +export {parseModuleSpec, buildModuleKnowledgeGraph} from "./module_graph.ts"; +export type {SpecDocumentInput, ModuleDependency, ModuleKnowledge, ModuleKnowledgeGraph} from "./module_graph.ts"; +export {routeRepositoryContext, buildTurnContextContributionInput} from "./context_route.ts"; +export type {RepositoryContextRequest, RoutedModuleSummary, OmittedModuleSummary, RepositoryContextPlan} from "./context_route.ts"; +export {extractTypeScriptExportsAndImports, comparePublicInterfaces} from "./interface_boundary.ts"; +export type {ModuleSourceFile, InterfaceBoundaryDelta} from "./interface_boundary.ts"; diff --git a/loopx/control_plane/capabilities/repository_context/interface_boundary.ts b/loopx/control_plane/capabilities/repository_context/interface_boundary.ts new file mode 100644 index 0000000000..75a0d7b956 --- /dev/null +++ b/loopx/control_plane/capabilities/repository_context/interface_boundary.ts @@ -0,0 +1,96 @@ +import {posix} from "node:path"; +import ts from "typescript"; +import type {ModuleKnowledge} from "./module_graph.ts"; + +export interface ModuleSourceFile { + readonly filePath: string; + readonly content: string; +} +export interface InterfaceBoundaryDelta { + readonly modulePath: string; + readonly declaredExports: readonly string[]; + readonly observedExports: readonly string[]; + readonly undeclaredExports: readonly string[]; + readonly missingExports: readonly string[]; + readonly forbiddenImports: readonly string[]; + readonly hasDrift: boolean; + readonly requiresGateReview: boolean; +} + +function bindingNames(name: ts.BindingName): string[] { + if (ts.isIdentifier(name)) return [name.text]; + return name.elements.flatMap(element => ts.isOmittedExpression(element) ? [] : bindingNames(element.name)); +} + +/** Syntax-only extraction: comments and strings cannot masquerade as declarations. + * Wildcard re-exports require a resolver; refuse them rather than certify an + * incomplete public interface. Named re-exports are supported without IO. + */ +export function extractTypeScriptExportsAndImports(sourceCode: string): {exports: string[]; imports: string[]} { + const source = ts.createSourceFile("module.tsx", sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const exports = new Set(); + const imports = new Set(); + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + imports.add(statement.moduleSpecifier.text); + } + if (ts.isImportEqualsDeclaration(statement) && ts.isExternalModuleReference(statement.moduleReference)) { + const expression = statement.moduleReference.expression; + if (expression && ts.isStringLiteral(expression)) imports.add(expression.text); + } + if (ts.isExportDeclaration(statement)) { + if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) imports.add(statement.moduleSpecifier.text); + if (!statement.exportClause) throw new Error("Wildcard re-exports require resolved public interface evidence"); + if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) exports.add(element.name.text); + } else exports.add(statement.exportClause.name.text); + continue; + } + if (ts.isExportAssignment(statement)) { exports.add("default"); continue; } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + if (!modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue; + if (modifiers.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) { + exports.add("default"); continue; + } + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) for (const name of bindingNames(declaration.name)) exports.add(name); + } else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) + || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) + || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) && statement.name) { + exports.add(statement.name.text); + } + } + return {exports: [...exports].sort(), imports: [...imports].sort()}; +} + +function isWithin(path: string, module: string): boolean { + return module === "." ? path !== ".." && !path.startsWith("../") && !posix.isAbsolute(path) + : path === module || path.startsWith(`${module}/`); +} + +export function comparePublicInterfaces(moduleKnowledge: ModuleKnowledge, sourceFiles: readonly ModuleSourceFile[]): InterfaceBoundaryDelta { + const declaredExports = [...new Set(moduleKnowledge.publicInterfaces.map(item => item.name))].sort(); + const observed = new Set(); + const forbidden = new Set(); + for (const file of sourceFiles) { + const filePath = posix.normalize(file.filePath.replaceAll("\\", "/")); + if (!isWithin(filePath, moduleKnowledge.modulePath)) throw new Error(`Source outside module ${moduleKnowledge.modulePath}: ${file.filePath}`); + const extracted = extractTypeScriptExportsAndImports(file.content); + for (const name of extracted.exports) observed.add(name); + for (const specifier of extracted.imports) { + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + const target = relative ? posix.normalize(posix.join(posix.dirname(filePath), specifier)) : specifier; + const internal = relative && isWithin(target, moduleKnowledge.modulePath); + const allowed = internal || moduleKnowledge.allowedDependencies.some(dependency => isWithin(target, dependency)); + if (!allowed) forbidden.add(specifier); + } + } + const observedExports = [...observed].sort(); + const declared = new Set(declaredExports); + const undeclaredExports = observedExports.filter(name => !declared.has(name)); + const missingExports = declaredExports.filter(name => !observed.has(name)); + const forbiddenImports = [...forbidden].sort(); + const hasDrift = undeclaredExports.length > 0 || missingExports.length > 0 || forbiddenImports.length > 0; + return {modulePath: moduleKnowledge.modulePath, declaredExports, observedExports, + undeclaredExports, missingExports, forbiddenImports, hasDrift, requiresGateReview: hasDrift}; +} diff --git a/loopx/control_plane/capabilities/repository_context/module_graph.ts b/loopx/control_plane/capabilities/repository_context/module_graph.ts new file mode 100644 index 0000000000..f9e418f717 --- /dev/null +++ b/loopx/control_plane/capabilities/repository_context/module_graph.ts @@ -0,0 +1,108 @@ +import {posix} from "node:path"; +import {canonicalAuthoritySha256} from "../../coordination/authority_store_codec.ts"; + +export interface SpecDocumentInput { + readonly modulePath: string; + readonly specPath: string; + readonly content: string; + readonly observedLastModifiedRevision?: string; +} + +export interface ModuleDependency { + readonly from: string; + readonly to: string; +} + +export interface ModuleKnowledge { + readonly modulePath: string; + readonly specPath: string; + readonly specDigest: string; + readonly observedLastModifiedRevision?: string; + readonly responsibilities: readonly string[]; + readonly publicInterfaces: readonly {readonly name: string; readonly declaration: string}[]; + readonly allowedDependencies: readonly string[]; + readonly invariants: readonly string[]; +} + +export interface ModuleKnowledgeGraph { + readonly modules: readonly ModuleKnowledge[]; + readonly edges: readonly ModuleDependency[]; +} + +function repositoryPath(value: string): string { + const path = posix.normalize(value.replaceAll("\\", "/")).replace(/\/$/, ""); + if (!value.trim() || posix.isAbsolute(path) || path === ".." || path.startsWith("../")) { + throw new Error(`Expected a repository-relative path: ${value}`); + } + return path; +} + +function unquote(value: string): string { + return value.replace(/^`([^`]+)`/, "$1").trim(); +} + +/** Parse only actual level-two sections and bullets outside fenced examples. */ +export function parseModuleSpec(input: SpecDocumentInput): ModuleKnowledge { + const sections = new Map(); + let section: string | undefined; + let fence: string | undefined; + for (const line of input.content.split(/\r?\n/)) { + const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (marker) { + if (!fence) fence = marker; + else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined; + continue; + } + if (fence) continue; + const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (heading) { + section = heading[1].length === 2 ? heading[2].trim() : undefined; + if (section && !sections.has(section)) sections.set(section, []); + continue; + } + const bullet = /^\s*[-*+]\s+(.+?)\s*$/.exec(line); + if (section && bullet) sections.get(section)!.push(bullet[1]); + } + const publicInterfaces = (sections.get("Public Interfaces") ?? []).map(declaration => { + const name = /^(?:export\s+)?(?:(?:async\s+)?function\s+|class\s+|interface\s+|type\s+|const\s+|let\s+|var\s+)?([$\p{ID_Start}][$\p{ID_Continue}]*)/u.exec(unquote(declaration))?.[1]; + if (!name) throw new Error(`Invalid public interface in ${input.specPath}: ${declaration}`); + return {name, declaration}; + }); + return { + modulePath: repositoryPath(input.modulePath), specPath: repositoryPath(input.specPath), + specDigest: canonicalAuthoritySha256({specPath: input.specPath, content: input.content}), + ...(input.observedLastModifiedRevision === undefined ? {} : { + observedLastModifiedRevision: input.observedLastModifiedRevision, + }), + responsibilities: sections.get("Responsibilities") ?? [], publicInterfaces, + allowedDependencies: [...new Set((sections.get("Allowed Dependencies") ?? []).map(value => repositoryPath(unquote(value))))].sort(), + invariants: sections.get("Invariants") ?? [], + }; +} + +/** Invalid graphs fail explicitly; routing must never silently use partial knowledge. */ +export function buildModuleKnowledgeGraph(specs: readonly SpecDocumentInput[]): ModuleKnowledgeGraph { + const modules = specs.map(parseModuleSpec).sort((a, b) => a.modulePath < b.modulePath ? -1 : a.modulePath > b.modulePath ? 1 : 0); + const byPath = new Map(); + for (const module of modules) { + if (byPath.has(module.modulePath)) throw new Error(`Duplicate module: ${module.modulePath}`); + byPath.set(module.modulePath, module); + } + const edges: ModuleDependency[] = []; + for (const module of modules) for (const target of module.allowedDependencies) { + if (!byPath.has(target)) throw new Error(`Missing dependency module: ${module.modulePath} -> ${target}`); + edges.push({from: module.modulePath, to: target}); + } + const visited = new Set(); + const active = new Set(); + const trail: string[] = []; + function visit(path: string): void { + if (active.has(path)) throw new Error(`Module dependency cycle: ${[...trail, path].join(" -> ")}`); + if (visited.has(path)) return; + active.add(path); trail.push(path); + for (const target of byPath.get(path)!.allowedDependencies) visit(target); + trail.pop(); active.delete(path); visited.add(path); + } + for (const module of modules) visit(module.modulePath); + return {modules, edges}; +} diff --git a/tests/control_plane_ts/repository_context_living_specs.test.ts b/tests/control_plane_ts/repository_context_living_specs.test.ts new file mode 100644 index 0000000000..1a412463a1 --- /dev/null +++ b/tests/control_plane_ts/repository_context_living_specs.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {buildModuleKnowledgeGraph, buildTurnContextContributionInput, comparePublicInterfaces, + extractTypeScriptExportsAndImports, routeRepositoryContext} from "../../loopx/control_plane/capabilities/repository_context/index.ts"; +import {validateTurnContextContribution} from "../../loopx/control_plane/turn_driver/capability_context.ts"; + +const specifications = [ + {modulePath: "api/orders", specPath: "api/orders/SPEC.md", content: `# Orders API\n## Responsibilities\n- Accept order commands and expose order reads.\n## Public Interfaces\n- export function createOrder(input: CreateOrderInput): Order\n## Allowed Dependencies\n- services/orders\n## Invariants\n- Validate every command before dispatch.`}, + {modulePath: "services/orders", specPath: "services/orders/SPEC.md", content: `# Orders Service\n## Responsibilities\n- Apply order changes and persist order state.\n## Public Interfaces\n- export function createOrder(input: CreateOrderInput): Order\n## Allowed Dependencies\n- services/audit\n## Invariants\n- Emit an audit event for each accepted change.`}, + {modulePath: "services/audit", specPath: "services/audit/SPEC.md", content: `# Audit Service\n## Responsibilities\n- Record immutable order activity.\n## Public Interfaces\n- export function recordOrderEvent(event: OrderEvent): void\n## Allowed Dependencies\n## Invariants\n- Existing events are never overwritten.`}, + {modulePath: "analytics/reporting", specPath: "analytics/reporting/SPEC.md", content: `# Reporting\n## Responsibilities\n- Build read-only order reports.\n## Public Interfaces\n- export function buildOrderReport(): Report\n## Allowed Dependencies\n- services/orders\n## Invariants\n- Reports never mutate order state.`}, +]; +const graph = buildModuleKnowledgeGraph(specifications); +const modules = graph.modules; +const sources: Record = { + "api/orders": `import {createOrder as createServiceOrder} from "../../services/orders/index.ts";\nexport function createOrder(input: CreateOrderInput): Order { return createServiceOrder(input); }`, + "services/orders": `import {recordOrderEvent} from "../audit/index.ts";\nexport function createOrder(input: CreateOrderInput): Order { recordOrderEvent({input}); return {input}; }`, + "services/audit": `export function recordOrderEvent(event: OrderEvent): void { void event; }`, + "analytics/reporting": `import {createOrder} from "../../services/orders/index.ts";\nexport function buildOrderReport(): Report { return {count: 0}; }`, +}; + +test("module specs produce a four-module knowledge graph with declared dependency edges", () => { + assert.equal(modules.length, 4); + assert.ok(modules.every(module => module.responsibilities.length > 0 && module.publicInterfaces.length > 0 && + module.invariants.length > 0)); + assert.ok(graph.edges.some(edge => edge.from === "api/orders" && edge.to === "services/orders")); +}); + +test("routing follows write ownership and declared dependencies, marks exclusions and stale modules", () => { + const route = routeRepositoryContext(graph, {writeScope: ["api/orders/controller.ts"], + staleModulePaths: ["api/orders"], maxBytes: 16_384}); + const selected = route.selectedModules; + assert.equal(selected.find(module => module.modulePath === "api/orders")?.selectionReason, "owning_write_scope"); + assert.equal(selected.find(module => module.modulePath === "services/orders")?.selectionReason, "direct_dependency"); + assert.deepEqual(selected.map(module => module.modulePath), ["api/orders", "services/orders"]); + assert.ok(route.omittedModules.some(module => module.modulePath === "services/audit" && module.reason === "outside_write_scope")); + assert.ok(route.omittedModules.some(module => module.modulePath === "analytics/reporting" && module.reason === "outside_write_scope")); + assert.equal(selected.find(module => module.modulePath === "api/orders")?.stale, true); + const constrained = routeRepositoryContext(graph, {writeScope: ["api/orders/controller.ts"], maxBytes: 1}); + assert.ok(constrained.omittedModules.some(module => module.reason === "budget_exceeded")); +}); + +test("dependency depth selects shortest-hop context and rejects invalid requests", () => { + const request = {writeScope: ["api/orders/controller.ts"], maxBytes: 16_384}; + const transitive = routeRepositoryContext(graph, {...request, maxDependencyDepth: 2}); + assert.deepEqual(transitive.selectedModules.map(({modulePath, selectionReason}) => ({modulePath, selectionReason})), [ + {modulePath: "api/orders", selectionReason: "owning_write_scope"}, + {modulePath: "services/orders", selectionReason: "direct_dependency"}, + {modulePath: "services/audit", selectionReason: "transitive_dependency"}, + ]); + assert.deepEqual(transitive.omittedModules, [{modulePath: "analytics/reporting", reason: "outside_write_scope"}]); + + const ownerOnly = routeRepositoryContext(graph, {...request, maxDependencyDepth: 0}); + assert.deepEqual(ownerOnly.selectedModules.map(({modulePath, selectionReason}) => ({modulePath, selectionReason})), [ + {modulePath: "api/orders", selectionReason: "owning_write_scope"}, + ]); + assert.throws(() => routeRepositoryContext(graph, {...request, maxDependencyDepth: -1}), Error); + assert.throws(() => routeRepositoryContext(graph, {...request, maxDependencyDepth: 1, + includeDirectDependencies: false}), Error); +}); + +test("contribution input is accepted by the host turn-context validator", () => { + const route = routeRepositoryContext(graph, {writeScope: ["api/orders/controller.ts"], maxBytes: 16_384}); + const contribution = buildTurnContextContributionInput(route, {goalId: "goal-orders", agentId: "agent-orders", + turnEnvelope: {goal_id: "goal-orders", agent_id: "agent-orders", ok: true, should_run: true, + mode: "should-run", effective_action: "normal_run", interaction_contract: { + schema_version: "loopx_interaction_contract_v0", mode: "bounded_delivery", + user_channel: {action_required: false, notify: "DONT_NOTIFY"}, + agent_channel: {must_attempt: true, delivery_allowed: true, quiet_noop_allowed: false}, cli_channel: {}}, + agent_identity: {agent_id: "agent-orders", registered: true}, + heartbeat_receipt: {status: "committed", turn_instance_id: "fixture-turn"}, + goal_boundary: {write_scope: ["api/orders/**"], capabilities: {}}}, hookId: "repository_context_hook", + capabilityId: "repository_context"}); + assert.ok(validateTurnContextContribution(contribution)); +}); + +test("interface comparison accepts compliant code and gates drift or forbidden imports", () => { + const apiModule = modules.find(module => module.modulePath === "api/orders")!; + const compliantFiles = [{filePath: "api/orders/index.ts", content: sources["api/orders"]}]; + const compliant = comparePublicInterfaces(apiModule, compliantFiles); + assert.equal(compliant.hasDrift, false); + assert.equal(compliant.requiresGateReview, false); + assert.deepEqual(compliant.forbiddenImports, []); + const removed = comparePublicInterfaces(apiModule, [{filePath: "api/orders/index.ts", content: `export function other(): void {}`}]); + assert.ok(removed.missingExports.length > 0); + assert.equal(removed.hasDrift, true); + assert.equal(removed.requiresGateReview, true); + const added = comparePublicInterfaces(apiModule, [{filePath: "api/orders/index.ts", + content: `${sources["api/orders"]}\nexport function debugOnly(): void {}`}]); + assert.ok(added.undeclaredExports.includes("debugOnly")); + assert.equal(added.hasDrift, true); + assert.equal(added.requiresGateReview, true); + const forbidden = comparePublicInterfaces(apiModule, [{filePath: "api/orders/index.ts", + content: `import {recordOrderEvent} from "../../services/audit/index.ts";\n${sources["api/orders"]}`}]); + assert.ok(forbidden.forbiddenImports.length > 0); + assert.equal(forbidden.hasDrift, true); + assert.equal(forbidden.requiresGateReview, true); + const parsed = extractTypeScriptExportsAndImports(sources["api/orders"]); + assert.ok(parsed.exports.length > 0 && parsed.imports.length > 0); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 72e0c2b7bd..442c3f18f3 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -21,6 +21,7 @@ "tests/control_plane_ts/usage_statistics_delivery.test.ts", "tests/control_plane_ts/usage_statistics_goals.test.ts", "tests/control_plane_ts/usage_statistics_sources.test.ts", + "tests/control_plane_ts/repository_context_living_specs.test.ts", "tests/control_plane_ts/content_digest_single_owner.test.ts", "tests/control_plane_ts/goal_amendment_authority.test.ts", "tests/control_plane_ts/request_control.test.ts",