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).
This commit is contained in:
102
scripts/gen-doc-graphs.spec.ts
Normal file
102
scripts/gen-doc-graphs.spec.ts
Normal file
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user