Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 350 additions & 0 deletions examples/living_specs_benchmark_corpus.ts

Large diffs are not rendered by default.

345 changes: 345 additions & 0 deletions examples/repository-context-living-specs-comparison.ts

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions loopx/control_plane/capabilities/repository_context/context_route.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
Comment on lines +49 to +55

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] write_scope 模式不能当作实体路径。现有 canonicalWriteScope 接受 src/** 和 **,共享 overlap owner 确认其覆盖 src/api/orders/index.ts;此处在16KiB足够预算下,src/能选中 src/api、src/api/orders、src/service,而src/和都选空,src/api/**只选父模块。请复用现有glob语义并补递归/全仓/嵌套模块回归。这是上下文漏选,不是权限扩大。见 F3。

}
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<string, unknown>;
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;
}
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 请按真实文件类型解析,并处理 parse diagnostics。合法 .ts 的 export const identity = <T>(value: T): T => value; export const secret = 1; 在真实 TS compiler 下零诊断且导出两个名称;固定 TSX 解析这里只返回 identity。SPEC 只声明 identity 时错误返回 hasDrift=false,声明两者时又错误报告 secret missing。请从 ModuleSourceFile.filePath 传入 ScriptKind,并补两方向回归。完整证据与影响边界见本 review F1。

const exports = new Set<string>();
const imports = new Set<string>();
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);
}
Comment on lines +33 to +40

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 顶层 statement 扫描遗漏 literal dynamic import 和 import type。export async function submit() { return (await import("../forbidden/index.ts")).secret; } / export type Order = import("../forbidden/index.ts").Secret; 分别声明公开名称并设置 allowedDependencies=[],两者都返回 forbiddenImports=[]、requiresGateReview=false。真实 compiler 均零诊断且解析到该文件。请递归收集这两类 AST 节点,再应用已有依赖规则。见 F2。

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<string>();
const forbidden = new Set<string>();
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};
}
108 changes: 108 additions & 0 deletions loopx/control_plane/capabilities/repository_context/module_graph.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
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};
Comment on lines +66 to +69

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 这段正则会把合法声明关键字当作接口名。SPEC bullet与source同为 export enum Mode { On, Off } 时,publicInterfaces.name变成enum,而AST导出Mode,因此合规代码被报missing enum、undeclared Mode且requiresGateReview=true。const enum、abstract class、declare function、namespace也可复现。请统一名称提取语法或显式拒绝不支持的声明,补相同SPEC/source的无漂移回归。见 F4。

});
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<string, ModuleKnowledge>();
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<string>();
const active = new Set<string>();
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};
}
Loading
Loading