Explorar o código

fix(scripts): own the locality proof's premises in gen-doc-graphs

Review follow-up. provenLocalCallee inferred file-local calls from module
scoping but borrowed non-exportedness from its one caller and never checked
module-ness: a helper in a global script file (no import/export) is
program-visible and callable cross-file with no same-file reference, so the
proof passed and those call sites were dropped as silently missing matrix
cells. Guard both premises at the proof entry, failing toward the global
fallback.

- State the EVENT_API_METHODS obligation: a visitSource branch for an
  unlisted method name is dead because the prefilter drops the call first.
- Add gen-doc-graphs.spec.ts pinning fast path vs global fallback equivalence
  on fixture programs: a proven-local helper, an alias-escaping helper, and a
  global-script helper (negative control that keeps the fallback exercised).
- Record the demand-driven indexing decision in the Program-backed semantic
  gates Agent Note (both languages, pairing re-recorded).

Generated docs stay byte-identical (verify-doc-graphs green).
imccyu hai 2 meses
pai
achega
f988ca9b86

+ 3 - 3
.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-14-typescript-program-backed-semantic-gates.md: 43a7b9b5369feb199721f5f1348c03cde66ee411
-2026-07-14-typescript-program-backed-semantic-gates.zh.md: 1ab027d723e30007e6675ae1f3589fb594d10afc
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
+2026-07-14-typescript-program-backed-semantic-gates.md: 91639d53b660c68ae52c7ddcef6b3f594e82273e
+2026-07-14-typescript-program-backed-semantic-gates.zh.md: 2270408564f0fc90241255dfb86a65c30362d651

+ 2 - 0
.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md

@@ -30,6 +30,8 @@ The wrapper owns config diagnostics, semantic compiler options, repository-relat
 
 Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value.
 
+Semantic queries run only where a branch can consume them: calls are prefiltered by the closed event-API method-name set before receiver classification, and helper call sites are indexed on demand instead of eagerly resolving every call in every package source. The demand-driven index proves locality per helper — a helper that is non-exported, sits in a real ES module, and whose every same-file reference is a direct callee has all of its calls in that file by module scoping, so only that file is indexed. Any unproven premise (an export modifier, a global script file, an aliasing or otherwise unclassifiable reference) falls back to the original full package-source index, which is the unchanged original semantics; the proof affects cost, never results. A lazy single global index was rejected because the helper-parameter path is reached on the current tree, so it would still pay nearly the whole `getResolvedSignature` sweep.
+
 Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships.
 
 ### B. Scoped-event routing generates one typed resolver map

+ 2 - 0
.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md

@@ -30,6 +30,8 @@ Status: implemented
 
 Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。
 
+语义查询只在存在消费分支的位置运行:调用先经过封闭的事件 API 方法名集合预过滤,再做接收者分类;辅助函数调用点索引按需构建,而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数,按模块作用域规则其全部调用必在本文件内,此时只索引该文件。任一前提无法证明(带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用)即回退到原全部包源码索引,回退路径就是原语义本身:证明只影响开销,不影响结果。惰性单一全局索引方案被否决,因为当前源码树确实会走到辅助函数参数路径,该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。
+
 每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。
 
 ### B. 带作用域的事件路由生成一份强类型解析函数表

+ 102 - 0
scripts/gen-doc-graphs.spec.ts

@@ -0,0 +1,102 @@
+/**
+ * Tests for the event-relation collector's demand-driven call-site indexing:
+ * the single-file fast path and the global fallback must recover the same
+ * helper-parameter event names, including shapes that defeat the locality
+ * proof (alias escapes and global script files).
+ */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterAll, describe, expect, it } from 'vitest'
+import { EventRelationCollector, type PackageSource } from './gen-doc-graphs.ts'
+import { TypeScriptProject } from './ts-project.ts'
+
+const FIXTURE: Record<string, string> = {
+  'tsconfig.host.json': JSON.stringify({
+    compilerOptions: {
+      target: 'es2022',
+      module: 'esnext',
+      moduleResolution: 'bundler',
+      allowImportingTsExtensions: true,
+      noEmit: true,
+      skipLibCheck: true,
+      types: [],
+    },
+    include: ['vendor/**/*.ts', 'packages/**/*.ts'],
+  }),
+  'vendor/cordis/src/context.ts': 'export class Context { private brand!: void }\n',
+  'vendor/cordis/src/events.ts': [
+    'export class EventsService {',
+    '  dispatch(type: string, args: unknown[]): unknown[] { return [type, args] }',
+    '}',
+    '',
+  ].join('\n'),
+  'packages/core/agent/src/dispatch.ts':
+    'export interface AgentEventDispatch { emit(...args: unknown[]): void }\n',
+  // fireLocal: every same-file reference is a direct callee, so the locality
+  // proof holds and only this file is indexed. fireAliased: the exported
+  // const is a value-position reference, so the proof fails and the global
+  // fallback must find the cross-file call in pkgb.
+  'packages/fix/pkga/src/index.ts': [
+    "import { EventsService } from '../../../../vendor/cordis/src/events.ts'",
+    'declare const events: EventsService',
+    "function fireLocal(args: [string]): void { void events.dispatch('emit', args) }",
+    "fireLocal(['pkga/local-event'])",
+    "function fireAliased(args: [string]): void { void events.dispatch('emit', args) }",
+    'export const aliased = fireAliased',
+    '',
+  ].join('\n'),
+  'packages/fix/pkgb/src/index.ts': [
+    "import { aliased } from '../../pkga/src/index.ts'",
+    "aliased(['pkgb/aliased-event'])",
+    '',
+  ].join('\n'),
+  // Global script files (no import/export): scriptFire is program-visible, so
+  // the cross-file call in caller.ts leaves no same-file reference. Only the
+  // module-ness premise check routes this helper to the global index; without
+  // it the proof would pass and the event would silently drop.
+  'packages/fix/pkgc/src/globals.ts':
+    "declare var gEvents: import('../../../../vendor/cordis/src/events.ts').EventsService\n",
+  'packages/fix/pkgc/src/helper.ts':
+    "function scriptFire(args: [string]): void { void gEvents.dispatch('emit', args) }\n",
+  'packages/fix/pkgc/src/caller.ts': "scriptFire(['pkgc/script-event'])\n",
+}
+
+const root = mkdtempSync(join(tmpdir(), 'gen-doc-graphs-'))
+for (const [rel, content] of Object.entries(FIXTURE)) {
+  mkdirSync(dirname(join(root, rel)), { recursive: true })
+  writeFileSync(join(root, rel), content)
+}
+const project = new TypeScriptProject(root)
+const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
+  const rel = project.relativePath(sourceFile)
+  const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
+  return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
+}).sort((left, right) => left.rel.localeCompare(right.rel))
+
+afterAll(() => {
+  rmSync(root, { recursive: true, force: true })
+})
+
+function dispatchersOf(pkgs: readonly string[], event: string): string[] {
+  const subset = sources.filter(source => pkgs.includes(source.pkg))
+  const relations = new EventRelationCollector(project, subset).collect()
+  return [...(relations.get(event)?.dispatchers.keys() ?? [])]
+}
+
+describe('event relation call-site indexing', () => {
+  it('recovers a proven-local helper through the single-file fast path', () => {
+    expect(dispatchersOf(['pkga', 'pkgb'], 'pkga/local-event')).toEqual(['pkga'])
+  })
+
+  it('recovers an alias-escaped helper through the global fallback', () => {
+    expect(dispatchersOf(['pkga', 'pkgb'], 'pkgb/aliased-event')).toEqual(['pkga'])
+  })
+
+  it('rejects the locality proof for global script files', () => {
+    // pkgc alone: the script helper is the first demand, so a wrongly passing
+    // proof would index helper.ts only and lose the caller.ts call site.
+    expect(dispatchersOf(['pkgc'], 'pkgc/script-event')).toEqual(['pkgc'])
+  })
+})

+ 23 - 7
scripts/gen-doc-graphs.ts

@@ -48,9 +48,13 @@ interface EventRelation {
   listeners: Set<string>
 }
 
-interface PackageSource {
+/** One scanned package source file and its owning package short name. */
+export interface PackageSource {
+  /** Repository-relative path. */
   rel: string
+  /** Package short name from the `packages/<group>/<pkg>/src` path. */
   pkg: string
+  /** The bound program source file. */
   sourceFile: ts.SourceFile
 }
 
@@ -685,11 +689,16 @@ function renderAppComposition(example: AppExample): string {
 
 type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>
 
-/** The only method names visitSource classifies; receiver typing runs on these alone. */
+/**
+ * The only method names visitSource classifies; receiver typing runs on these
+ * alone. Obligation: every method name matched by a branch inside visitSource
+ * must appear here — the prefilter drops non-members before any branch runs,
+ * so a branch for an unlisted name is silently dead.
+ */
 const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
 
 /** Collect event dispatch/listener relations from real cross-file receiver types. */
-class EventRelationCollector {
+export class EventRelationCollector {
   private readonly relations = new Map<string, EventRelation>()
   private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
   private readonly localCalleeProofs = new Map<ts.FunctionDeclaration, boolean>()
@@ -767,14 +776,21 @@ class EventRelationCollector {
   }
 
   /**
-   * Prove every same-file reference to one helper is a direct callee. Alias
-   * escapes (re-export statements, default exports, value reads) resolve back
-   * to the owner symbol at a non-callee position and fail the proof, as does
-   * anything the scan cannot positively classify.
+   * Prove every same-file reference to one helper is a direct callee. The
+   * proof owns its premises: an exported helper or a helper in a global
+   * script file (no import/export means program-wide scope, callable from
+   * another file with no same-file reference at all) fails immediately.
+   * Alias escapes (re-export statements, default exports, value reads)
+   * resolve back to the owner symbol at a non-callee position and fail the
+   * proof, as does anything the scan cannot positively classify.
    */
   private provenLocalCallee(owner: ts.FunctionDeclaration): boolean {
     const cached = this.localCalleeProofs.get(owner)
     if (cached !== undefined) return cached
+    if (hasExportModifier(owner) || !ts.isExternalModule(owner.getSourceFile())) {
+      this.localCalleeProofs.set(owner, false)
+      return false
+    }
     const name = owner.name
     const ownerSymbol = name && this.project.checker.getSymbolAtLocation(name)
     let proven = !!ownerSymbol