feat: slot system + entries/priority/errorreport + typert generator
This commit is contained in:
@@ -859,17 +859,39 @@ class FaceAnalyzer {
|
||||
const result: ServiceModel[] = []
|
||||
for (const member of context.members) {
|
||||
if (!ts.isPropertySignature(member) || member.type === undefined) continue
|
||||
const symbol = this.symbolAtType(member.type)
|
||||
if (symbol === undefined) continue
|
||||
const symbolId = this.symbolId(symbol)
|
||||
const exported = bySymbol.get(symbolId)?.find(record => record.model.name === symbol.name)
|
||||
?? bySymbol.get(symbolId)?.find(record => record.model.name !== 'default')
|
||||
?? bySymbol.get(symbolId)?.[0]
|
||||
// An OPTIONAL key is not a service: `X | undefined` and `key?: X` both mark
|
||||
// a value the launcher or boot code installs before the tree mounts (a root
|
||||
// accessor, an environment snapshot), which no plugin provides and no
|
||||
// consumer can reach with `inject`. Describing one as a service would answer
|
||||
// "add the plugin that provides it" for a key where no such plugin exists.
|
||||
if (member.questionToken !== undefined
|
||||
|| (ts.isUnionTypeNode(member.type)
|
||||
&& member.type.types.some(node => node.kind === ts.SyntaxKind.UndefinedKeyword))) continue
|
||||
const authoredSymbol = this.symbolAtType(member.type)
|
||||
if (authoredSymbol === undefined) continue
|
||||
const authoredSymbolId = this.symbolId(authoredSymbol)
|
||||
const exported = bySymbol.get(authoredSymbolId)?.find(record => record.model.name === authoredSymbol.name)
|
||||
?? bySymbol.get(authoredSymbolId)?.find(record => record.model.name !== 'default')
|
||||
?? bySymbol.get(authoredSymbolId)?.[0]
|
||||
if (exported === undefined) continue
|
||||
const declaration = preferredDeclaration(symbol)
|
||||
let symbol = authoredSymbol
|
||||
let declaration = preferredDeclaration(symbol)
|
||||
const aliases = new Set<ts.Symbol>()
|
||||
while (declaration !== undefined && ts.isTypeAliasDeclaration(declaration)) {
|
||||
if (aliases.has(symbol)) break
|
||||
aliases.add(symbol)
|
||||
const target = this.symbolAtType(declaration.type)
|
||||
if (target === undefined) break
|
||||
symbol = target
|
||||
declaration = preferredDeclaration(symbol)
|
||||
}
|
||||
if (declaration === undefined || (!ts.isClassDeclaration(declaration) && !ts.isInterfaceDeclaration(declaration))) {
|
||||
this.fail(member, `service ${memberName(member.name)} does not resolve to an exported class or interface`)
|
||||
}
|
||||
const memberOwner = this.registrationForFile(member.getSourceFile().fileName)
|
||||
const declarationOwner = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (memberOwner?.name !== declarationOwner?.name) continue
|
||||
const symbolId = this.symbolId(symbol)
|
||||
const model = this.ensureDeclaration(symbol, declaration)
|
||||
const exposed = model.members
|
||||
.filter(exposableMember)
|
||||
@@ -1990,6 +2012,11 @@ class FaceAnalyzer {
|
||||
&& member.initializer !== undefined
|
||||
&& ts.isCallExpression(member.initializer)
|
||||
&& this.isTypeMetaSymbol(member.initializer.expression, 'bindTypertRemote')) continue
|
||||
if (ts.isMethodDeclaration(member) && member.body !== undefined
|
||||
&& members.some(candidate => candidate !== member
|
||||
&& (ts.isMethodDeclaration(candidate) || ts.isMethodSignature(candidate))
|
||||
&& memberName(candidate.name) === memberName(member.name)
|
||||
&& (!ts.isMethodDeclaration(candidate) || candidate.body === undefined))) continue
|
||||
const visibility = visibilityOf(member)
|
||||
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
|
||||
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
|
||||
|
||||
@@ -12,13 +12,15 @@ import type {
|
||||
FaceModel,
|
||||
MemberModel,
|
||||
ParameterModel,
|
||||
ServiceModel,
|
||||
SignatureModel,
|
||||
SourceDeclarationModel,
|
||||
SourceLocation,
|
||||
TypertFace,
|
||||
TypeNodeId,
|
||||
} from './model.ts'
|
||||
|
||||
type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
type Mode = 'emit' | 'bail' | 'waterfall' | 'parallel' | 'serial'
|
||||
|
||||
/** The fenced-block info string for generated signature blocks (skipped by
|
||||
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
|
||||
@@ -112,6 +114,10 @@ export interface CordisCatalogPolicy {
|
||||
readonly foundationTypeNames: ReadonlySet<string>
|
||||
/** Repository types deliberately documented outside the linked data catalog. */
|
||||
readonly typeLinkExemptions: Readonly<Record<string, string>>
|
||||
/** Framework Services included in the model-facing runtime catalog but not the harness documentation partition. */
|
||||
readonly runtimeServices?: readonly ServiceEntry[]
|
||||
/** Harness Services omitted from the model-facing runtime catalog because dynamic Plugins must not call them. */
|
||||
readonly runtimeServiceExclusions?: ReadonlySet<string>
|
||||
/** Manually curated framework events inherited by every plugin. */
|
||||
readonly inheritedEvents: readonly InheritedEntry[]
|
||||
/** Manually curated framework context members inherited by every plugin. */
|
||||
@@ -129,7 +135,7 @@ export class CordisCatalogProjector {
|
||||
private readonly renderer: TypeGraphRenderer
|
||||
|
||||
/**
|
||||
* @param face - analyzed host face containing package business semantics.
|
||||
* @param face - analyzed Host or Client face containing package business semantics.
|
||||
* @param sourceDeclarations - exported declarations available to the runtime type closure.
|
||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||
*/
|
||||
@@ -138,7 +144,6 @@ export class CordisCatalogProjector {
|
||||
private readonly sourceDeclarations: readonly SourceDeclarationModel[],
|
||||
private readonly policy: CordisCatalogPolicy,
|
||||
) {
|
||||
if (face.face !== 'host') throw new Error(`cordis catalog requires the host face, received ${face.face}`)
|
||||
this.renderer = new TypeGraphRenderer(face.graph)
|
||||
}
|
||||
|
||||
@@ -159,10 +164,13 @@ export class CordisCatalogProjector {
|
||||
* @returns the model-facing TypeScript catalog source.
|
||||
*/
|
||||
renderRuntimeApi(model: CordisCatalogModel): string {
|
||||
const services = [...model.services, ...(this.policy.runtimeServices ?? [])]
|
||||
.filter(service => !this.policy.runtimeServiceExclusions?.has(service.key))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
return renderRuntimeApi(
|
||||
model.services,
|
||||
services,
|
||||
model.events,
|
||||
this.runtimeTypes(model.services),
|
||||
this.runtimeTypes(services),
|
||||
this.policy.inheritedServices,
|
||||
)
|
||||
}
|
||||
@@ -173,6 +181,8 @@ export class CordisCatalogProjector {
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const packageModel of this.face.packages) {
|
||||
for (const event of packageModel.events) {
|
||||
const parsed = parseJsDoc(event.jsDoc ?? '')
|
||||
if (parsed.deprecated) continue
|
||||
const source = pointer(event.location)
|
||||
const where = `event '${event.name}' (${source})`
|
||||
const node = this.renderer.node(event.signature)
|
||||
@@ -180,11 +190,12 @@ export class CordisCatalogProjector {
|
||||
violations.push(`${where} is not represented by a callable type.`)
|
||||
continue
|
||||
}
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
|
||||
const parsed = parseJsDoc(event.jsDoc ?? '')
|
||||
if (this.face.face === 'host') {
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
|
||||
}
|
||||
const mode = event.mode
|
||||
if (!isMode(mode)) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|bail|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
const last = node.signature.parameters.at(-1)
|
||||
const hasNext = last?.name === 'next'
|
||||
@@ -223,47 +234,91 @@ export class CordisCatalogProjector {
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* The services this projection describes, one per `ctx.<key>`: those whose
|
||||
* Context merge sits one level under a package's `src` and whose declaration
|
||||
* belongs to that same package.
|
||||
*
|
||||
* Interfaces qualify beside classes, because an interface-typed key
|
||||
* (`lsp: LspService`) has its Service Definition — and, by repository
|
||||
* convention, its member documentation — on the interface; requiring a class
|
||||
* would drop a real injectable service from every catalog. The declaration may
|
||||
* live in any file of the package (`types.ts` is the usual home), while a
|
||||
* declaration from ANOTHER package is not this package's surface to document.
|
||||
*
|
||||
* One key can have both kinds of candidate across packages: `ctx.typert` is
|
||||
* typed by a merge-extensible interface in `type-meta` and implemented by a
|
||||
* class in `registry`. The CLASS wins — it carries the documentation and is the
|
||||
* object a caller meets — and picking before validating is what keeps a
|
||||
* discarded candidate's missing JSDoc from failing the gate.
|
||||
*/
|
||||
private renderableServices(): ServiceModel[] {
|
||||
const chosen = new Map<string, ServiceModel>()
|
||||
for (const packageModel of this.face.packages) {
|
||||
for (const service of packageModel.services) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
const owner = /^packages\/[^/]+\/[^/]+\/src\//.exec(service.location.file)?.[0]
|
||||
if ((declaration.kind !== 'class' && declaration.kind !== 'interface')
|
||||
|| owner === undefined
|
||||
|| (this.face.face === 'host'
|
||||
? !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|
||||
: !/^packages\/[^/]+\/[^/]+\/src\/client\/.+\.tsx?$/.test(service.location.file))
|
||||
|| !declaration.location.file.startsWith(owner)) continue
|
||||
const current = chosen.get(service.key)
|
||||
if (current !== undefined && this.renderer.declaration(current.symbol).kind === 'class') continue
|
||||
chosen.set(service.key, service)
|
||||
}
|
||||
}
|
||||
return [...chosen.values()]
|
||||
}
|
||||
|
||||
private collectServices(): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const packageModel of this.face.packages) {
|
||||
for (const service of packageModel.services) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
if (declaration.kind !== 'class'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|
||||
|| declaration.location.file !== service.location.file) continue
|
||||
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
|
||||
const source = pointer(declaration.location)
|
||||
if (doc === '') {
|
||||
violations.push(`service ctx.${service.key} (${source}): class ${declaration.name} has no JSDoc.`)
|
||||
}
|
||||
const methods: ServiceMethodEntry[] = []
|
||||
for (const memberId of service.members) {
|
||||
const member = this.renderer.member(memberId)
|
||||
if (member.kind !== 'method' || member.name.startsWith('[')) continue
|
||||
const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})`
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations)
|
||||
methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' })
|
||||
if (member.jsDoc === undefined) {
|
||||
violations.push(`${where} has no JSDoc.`)
|
||||
continue
|
||||
}
|
||||
const parsed = parseJsDoc(member.jsDoc)
|
||||
if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`)
|
||||
checkParams(where, 'service', member.signature.parameters, parsed.params,
|
||||
parameter => parameter.receiver, violations)
|
||||
checkReturns(where, member.signature, parsed.returns, this.renderer, violations)
|
||||
}
|
||||
entries.push({
|
||||
key: service.key,
|
||||
type: declaration.name,
|
||||
abstract: declaration.abstract,
|
||||
doc,
|
||||
methods,
|
||||
source,
|
||||
})
|
||||
for (const service of this.renderableServices()) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
const parsedDeclaration = parseJsDoc(declaration.jsDoc ?? '')
|
||||
if (parsedDeclaration.deprecated) continue
|
||||
const doc = parsedDeclaration.doc
|
||||
const source = pointer(declaration.location)
|
||||
if (doc === '') {
|
||||
violations.push(`service ctx.${service.key} (${source}): ${declaration.kind} ${declaration.name} has no JSDoc.`)
|
||||
}
|
||||
const methods: ServiceMethodEntry[] = []
|
||||
for (const memberId of service.members) {
|
||||
const member = this.renderer.member(memberId)
|
||||
if (member.name.startsWith('[')) continue
|
||||
const parsed = parseJsDoc(member.jsDoc ?? '')
|
||||
if (parsed.deprecated) continue
|
||||
if (member.kind === 'property') {
|
||||
if (member.jsDoc === undefined) continue
|
||||
methods.push({ signature: member.text, jsDoc: member.jsDoc })
|
||||
continue
|
||||
}
|
||||
if (member.kind !== 'method') continue
|
||||
const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})`
|
||||
if (this.face.face === 'host') {
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations)
|
||||
}
|
||||
methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' })
|
||||
if (member.jsDoc === undefined) {
|
||||
violations.push(`${where} has no JSDoc.`)
|
||||
continue
|
||||
}
|
||||
if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`)
|
||||
checkParams(where, 'service', member.signature.parameters, parsed.params,
|
||||
parameter => parameter.receiver, violations)
|
||||
checkReturns(where, member.signature, parsed.returns, this.renderer, violations)
|
||||
}
|
||||
entries.push({
|
||||
key: service.key,
|
||||
type: declaration.name,
|
||||
abstract: declaration.abstract,
|
||||
doc,
|
||||
methods,
|
||||
source,
|
||||
})
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||
@@ -274,8 +329,8 @@ export class CordisCatalogProjector {
|
||||
const declarations = new Map<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
for (const declaration of this.sourceDeclarations) {
|
||||
if (declaration.face !== 'host' || declaration.kind === 'enum'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue
|
||||
if (declaration.face !== this.face.face || declaration.kind === 'enum'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/.+\.tsx?$/.test(declaration.location.file)) continue
|
||||
if (declarations.has(declaration.name)) {
|
||||
ambiguous.add(declaration.name)
|
||||
continue
|
||||
@@ -298,31 +353,31 @@ export class CordisCatalogProjector {
|
||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||
* @returns the configured projector and its validated catalog model.
|
||||
*/
|
||||
export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy): {
|
||||
export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy, targetFace: TypertFace = 'host'): {
|
||||
readonly projector: CordisCatalogProjector
|
||||
readonly model: CordisCatalogModel
|
||||
} {
|
||||
const caches = new WorkspaceCaches()
|
||||
const discovery = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
faces: [targetFace],
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).discoverPackages()
|
||||
const packages = discovery.filter(candidate => candidate.faces.includes('host'))
|
||||
const packages = discovery.filter(candidate => candidate.faces.includes(targetFace))
|
||||
.map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
faces: [targetFace],
|
||||
packages,
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).analyzeInBatches()
|
||||
const face = workspace.faces.find(candidate => candidate.face === 'host')
|
||||
if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
|
||||
const face = workspace.faces.find(candidate => candidate.face === targetFace)
|
||||
if (face === undefined) throw new Error(`gen-cordis-catalog: Typert produced no ${targetFace} face`)
|
||||
const sourceDeclarations = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
faces: [targetFace],
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).indexSourceDeclarations()
|
||||
@@ -354,6 +409,7 @@ interface ParsedJsDoc {
|
||||
readonly doc: string
|
||||
readonly params: ReadonlyMap<string, string>
|
||||
readonly returns: string | null
|
||||
readonly deprecated: boolean
|
||||
}
|
||||
|
||||
function parseJsDoc(raw: string): ParsedJsDoc {
|
||||
@@ -410,8 +466,14 @@ function parseJsDoc(raw: string): ParsedJsDoc {
|
||||
|
||||
const params = new Map<string, string>()
|
||||
let returns: string | null = null
|
||||
let deprecated = false
|
||||
let sink: ((text: string) => void) | undefined
|
||||
for (const line of lines) {
|
||||
if (/^@deprecated(?:\s|$)/.test(line)) {
|
||||
deprecated = true
|
||||
sink = undefined
|
||||
continue
|
||||
}
|
||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||
if (param !== null) {
|
||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
||||
@@ -440,6 +502,7 @@ function parseJsDoc(raw: string): ParsedJsDoc {
|
||||
doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(),
|
||||
params,
|
||||
returns,
|
||||
deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +557,7 @@ function pointer(location: SourceLocation): string {
|
||||
}
|
||||
|
||||
function isMode(mode: string | undefined): mode is Mode {
|
||||
return mode === 'emit' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial'
|
||||
return mode === 'emit' || mode === 'bail' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial'
|
||||
}
|
||||
|
||||
function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] {
|
||||
|
||||
@@ -10,9 +10,14 @@ import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERV
|
||||
|
||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||
|
||||
/** One workspace projection shared by both cases: analyzing it twice doubles a multi-minute run. */
|
||||
let cached: ReturnType<typeof projectCordisCatalog> | undefined
|
||||
const projection = (): ReturnType<typeof projectCordisCatalog> =>
|
||||
(cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY))
|
||||
|
||||
describe('Typert-backed Cordis catalog', () => {
|
||||
it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => {
|
||||
const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)
|
||||
const { projector, model } = projection()
|
||||
const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8')
|
||||
|
||||
expect(renderInheritedPage(CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-api/inherited.md'))
|
||||
@@ -35,4 +40,24 @@ describe('Typert-backed Cordis catalog', () => {
|
||||
expected('packages/extensions/tool-cordis/src/api-catalog.ts'),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves each key to the declaration a caller meets, and drops keys no plugin provides', { timeout: 480_000 }, () => {
|
||||
const byKey = new Map(projection().model.services.map(service => [service.key, service]))
|
||||
// An interface-typed key is described by its Service Definition: that is where
|
||||
// the contract and, by repository convention, the member JSDoc live.
|
||||
expect(byKey.get('lsp')?.type).toBe('LspService')
|
||||
// The Service Definition may sit anywhere in the package, including a nested
|
||||
// contract directory (`src/api/`), while the Context merge stays in `src`.
|
||||
expect(byKey.get('apiProxy')?.type).toBe('ApiProxy')
|
||||
// Two packages describe `ctx.typert` — a merge-extensible interface in
|
||||
// type-meta and the implementing class in registry. The class wins: it is the
|
||||
// object a caller meets and it carries the documentation.
|
||||
expect(byKey.get('typert')?.type).toBe('TypertRegistry')
|
||||
// Optional keys are values a launcher installs before the tree mounts. No
|
||||
// plugin provides them, so describing one as a service would answer "add the
|
||||
// plugin that provides it" for a key where no such plugin exists.
|
||||
expect(byKey.has('headlessIo')).toBe(false)
|
||||
expect(byKey.has('dshHomePath')).toBe(false)
|
||||
expect(byKey.has('launcherEnvironment')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user