Repository navigation
feat(control-plane): add module living spec graph routing and AST interface boundary checks #7347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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); | ||
| } | ||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 请按真实文件类型解析,并处理 parse diagnostics。合法 |
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 顶层 statement 扫描遗漏 literal dynamic import 和 import type。 |
||
| 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}; | ||
| } | ||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 这段正则会把合法声明关键字当作接口名。SPEC bullet与source同为 |
||
| }); | ||
| 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}; | ||
| } | ||
There was a problem hiding this comment.
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。