/** * API Surface Diff Test * * Prevents accidental Reams-novel API accretion in public class surfaces. * Every public member of covered classes must be in one of: * 1. Vendored obsidian.d.ts (Obsidian-shape) * 2. Marked @internal in source TSDoc * 3. Transitional whitelist (with PENDING note) * * This is the critical enforcement mechanism for the Reams namespace epic (#389). * Without this test, the namespace design becomes shelfware. * * ## Known scope limits (NOT bugs) * * - **Inherited members** (from `Events`, `Component`, etc.) are NOT checked. * The test compares direct members of each covered class to the vendored * .d.ts class declaration. Adding novel surface to a base class is a * bypass — see test failure messages. * - **Member shape/signature** is NOT checked. Same name, different * signature = pass. Catching signature drift requires a full type-level * diff, which is significantly more expensive. * - **Inline object property types** (e.g. `App.foldManager: { save(); load() }`) * are NOT recursed into. Only the top-level property name is checked. * * ## Bypasses the test DOES catch * * - Computed property names (`[Symbol.iterator]() {}`) — flagged as un-extractable. * - Constructor parameter properties (`constructor(public x: T)`) — extracted. * - String-literal member names — extracted by their string value. * - Interface-side declaration merging — extracted from merged declarations. * - Static members — extracted (annotated in violation messages). * - Method overloads — @internal on ANY overload counts as @internal. * * @see docs/audits/core-services-audit.md — "Scope reframed" section * @see chainlink issue #391 */ import { describe, it, expect, beforeAll } from 'vitest' import * as ts from 'typescript' import * as path from 'path' import * as fs from 'fs' import { REAMS_NOVEL_WHITELIST, REAMS_NOVEL_EVENTS_WHITELIST, REAMS_NOVEL_TYPES_WHITELIST, COVERED_CLASSES, COVERED_SOURCE_FILES, type CoveredClass, } from './__fixtures__/api-surface-whitelist' // Resolve paths relative to project root // __dirname is src/plugin/__tests__, so go up 3 levels to reach project root const PROJECT_ROOT = path.resolve(__dirname, '../../..') const VENDOR_DTS = path.join(PROJECT_ROOT, 'vendor/obsidian-1.11.4.d.ts') // Source files for each covered class const SOURCE_FILES: Record = { App: path.join(PROJECT_ROOT, 'src/plugin/app.ts'), Workspace: path.join(PROJECT_ROOT, 'src/plugin/workspace.ts'), Vault: path.join(PROJECT_ROOT, 'src/plugin/vault.ts'), MetadataCache: path.join(PROJECT_ROOT, 'src/services/metadata/metadata-cache.ts'), Plugin: path.join(PROJECT_ROOT, 'src/plugin/plugin.ts'), Editor: path.join(PROJECT_ROOT, 'src/plugin/editor.ts'), WorkspaceLeaf: path.join(PROJECT_ROOT, 'src/plugin/workspace.ts'), } /** * A member found in a class (or interface, via declaration merging). * Multiple overloads of the same name collapse into one entry: `isInternal` * is true if ANY overload is marked @internal (most permissive — overload * signatures conventionally bear the doc comments). */ interface MemberInfo { isInternal: boolean isStatic: boolean /** For diagnostics. */ declarationCount: number } /** * Parse a source file with JSDoc support. * Using ts.createSourceFile directly with setParentNodes=true ensures JSDoc is parsed. * Strips BOM if present (defensive against future re-vendoring). */ function parseSourceFile(filePath: string): ts.SourceFile { let content = fs.readFileSync(filePath, 'utf-8') // Strip UTF-8 BOM if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1) } return ts.createSourceFile( path.basename(filePath), content, ts.ScriptTarget.Latest, true, // setParentNodes - required for JSDoc parsing ts.ScriptKind.TS ) } /** * Extract the name of a class member as a stable string key. * Returns null for members with no name (index signatures). * Sets `extractable=false` for computed names that aren't statically resolvable * (e.g., `[Symbol.iterator]`); string literals like `"foo-bar"` ARE extractable. */ function getMemberName(member: ts.ClassElement | ts.TypeElement): { name: string; extractable: boolean } | null { if (ts.isConstructorDeclaration(member)) { return { name: 'constructor', extractable: true } } if (ts.isIndexSignatureDeclaration(member)) { return null } if (!('name' in member) || !member.name) return null const name = member.name if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) { return { name: name.text, extractable: true } } if (ts.isStringLiteral(name)) { return { name: name.text, extractable: true } } if (ts.isNumericLiteral(name)) { return { name: name.text, extractable: true } } if (ts.isBigIntLiteral(name)) { return { name: name.text, extractable: true } } if (ts.isComputedPropertyName(name)) { // Computed names whose expression is a literal ARE statically extractable. // E.g. `["foo-bar"]` is equivalent to declaring a `"foo-bar"` member. if (ts.isStringLiteral(name.expression) || ts.isNoSubstitutionTemplateLiteral(name.expression)) { return { name: name.expression.text, extractable: true } } if (ts.isNumericLiteral(name.expression) || ts.isBigIntLiteral(name.expression)) { return { name: name.expression.text, extractable: true } } // Genuinely computed (Symbol.iterator, dynamic expressions) — un-extractable. return { name: `[computed:${name.expression.getText()}]`, extractable: false } } return null } /** * Check if a class member has @internal in its JSDoc comments. * * Uses both TS API for JSDoc tags AND raw comment parsing as a fallback. * Raw comment parsing catches edge cases where TS's JSDoc parser misses tags. * * NOTE: This is for class-level members (properties, methods, accessors). * Constructor parameter properties use hasInternalTagOnParam instead — * leading-comment scanning on parameters can produce false positives via * overlapping comment ranges (e.g., a JSDoc block between params is the * trailing comment of one AND the leading comment of the next). */ function hasInternalTag(node: ts.Node, sourceFile: ts.SourceFile): boolean { // Method 1: TS API for JSDoc tags (works when source file has parent nodes) const jsDocs = ts.getJSDocTags(node) for (const tag of jsDocs) { if (tag.tagName.text === 'internal') { return true } } // Method 2: Parse leading comments as a defense-in-depth fallback. Catches // edge cases where TS's JSDoc parser misses tags (rare, but possible across // TS versions). Uses word-boundary regex to avoid false positives like // @internalFoo or not@internal. const fullText = sourceFile.getFullText() const nodeStart = node.getFullStart() const leadingComments = ts.getLeadingCommentRanges(fullText, nodeStart) if (leadingComments) { const internalRegex = /\b@internal\b/ for (const comment of leadingComments) { const commentText = fullText.slice(comment.pos, comment.end) if (internalRegex.test(commentText)) { return true } } } return false } /** * Check if a constructor parameter has @internal directly on it. * * Conservative: uses ONLY the TS JSDoc API, not leading-comment scanning. * This avoids a false-positive scenario where a JSDoc tag block placed * between two parameters is the trailing comment of one parameter AND the * leading comment of the next — leading-comment scanning would incorrectly * mark the next parameter as @internal. * * Trade-off: an @internal placed in the constructor's JSDoc comment block * does NOT propagate to its parameter properties. To mark a parameter * property as internal, place a JSDoc @internal block immediately before * the parameter declaration itself, NOT on the constructor. */ function hasInternalTagOnParam(param: ts.ParameterDeclaration): boolean { return ts.getJSDocTags(param).some(t => t.tagName.text === 'internal') } /** * Extract public members from all class AND interface declarations matching * `className` in `sourceFile`. Handles declaration merging: an `interface X` * augmenting `class X` contributes its members to the same map. * * Includes: * - Properties, methods, getters, setters (instance and static) * - Constructor parameter properties (`constructor(public x: T)`) * - Members from merged interface declarations * * Excludes: * - Private/protected members (TS modifier or leading underscore) * - Inherited members from base classes (out of scope — see test docstring) * * For overloaded methods, the member is treated as @internal if ANY overload * bears the tag (matches the convention of putting JSDoc on overload signatures). * * Returns un-extractable computed-name members separately so the caller can * report them as violations rather than silently skipping them. */ function extractClassMembers( sourceFile: ts.SourceFile, className: string ): { members: Map; unnamed: string[] } { const members = new Map() const unnamed: string[] = [] function processMember(member: ts.ClassElement | ts.TypeElement): void { // Skip private/protected via modifier keyword const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined const isPrivate = modifiers?.some( m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword ) if (isPrivate) return const isStatic = modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false const nameInfo = getMemberName(member) if (!nameInfo) return if (!nameInfo.extractable) { unnamed.push(nameInfo.name) return } // Skip members starting with underscore (conventional private) if (nameInfo.name.startsWith('_')) return // Constructor itself isn't meaningful for the API surface diff if (nameInfo.name === 'constructor') return const isInternal = hasInternalTag(member, sourceFile) const existing = members.get(nameInfo.name) if (existing) { // Overload merge: @internal of ANY overload wins (most permissive). members.set(nameInfo.name, { isInternal: existing.isInternal || isInternal, isStatic: existing.isStatic || isStatic, declarationCount: existing.declarationCount + 1, }) } else { members.set(nameInfo.name, { isInternal, isStatic, declarationCount: 1, }) } } function processConstructor(ctor: ts.ConstructorDeclaration): void { for (const param of ctor.parameters) { const modifiers = ts.canHaveModifiers(param) ? ts.getModifiers(param) : undefined // Parameter properties require public/private/protected/readonly modifier const hasPropModifier = modifiers?.some( m => m.kind === ts.SyntaxKind.PublicKeyword || m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword || m.kind === ts.SyntaxKind.ReadonlyKeyword ) if (!hasPropModifier) continue const isPrivate = modifiers?.some( m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword ) if (isPrivate) continue // Parameter name must be an identifier (we don't handle destructuring) if (!ts.isIdentifier(param.name)) continue const name = param.name.text if (name.startsWith('_')) continue // Use the parameter-specific @internal check to avoid false positives // from overlapping comment ranges between adjacent parameters. const isInternal = hasInternalTagOnParam(param) const existing = members.get(name) if (existing) { members.set(name, { isInternal: existing.isInternal || isInternal, isStatic: existing.isStatic, declarationCount: existing.declarationCount + 1, }) } else { members.set(name, { isInternal, isStatic: false, declarationCount: 1 }) } } } // Walk top-level declarations only — don't recurse into method bodies, // which prevents accidental matching of nested same-name classes. for (const statement of sourceFile.statements) { if (ts.isClassDeclaration(statement) && statement.name?.text === className) { for (const member of statement.members) { if (ts.isConstructorDeclaration(member)) { processConstructor(member) continue } processMember(member) } } else if (ts.isInterfaceDeclaration(statement) && statement.name.text === className) { // Declaration merging: interface X augments class X for (const member of statement.members) { processMember(member) } } } return { members, unnamed } } /** * Extract public members from obsidian.d.ts for a given class. * Includes both class members and merged interface members. */ function extractObsidianMembers(dtsContent: string, className: string): Set { const members = new Set() const sourceFile = ts.createSourceFile( 'obsidian.d.ts', dtsContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) for (const statement of sourceFile.statements) { if (ts.isClassDeclaration(statement) && statement.name?.text === className) { for (const member of statement.members) { const nameInfo = getMemberName(member) if (nameInfo?.extractable) { members.add(nameInfo.name) } } } else if (ts.isInterfaceDeclaration(statement) && statement.name.text === className) { for (const member of statement.members) { const nameInfo = getMemberName(member) if (nameInfo?.extractable) { members.add(nameInfo.name) } } } } return members } /** * Event info from the WorkspaceEvents interface. */ interface EventInfo { isInternal: boolean } /** * Extract event names from the WorkspaceEvents interface in workspace.ts. * * Walks the `WorkspaceEvents` interface declaration and extracts each * property key (event name). For each key, checks if the member has * an @internal JSDoc tag. */ function extractWorkspaceEvents(sourceFile: ts.SourceFile): Map { const events = new Map() for (const statement of sourceFile.statements) { if (ts.isInterfaceDeclaration(statement) && statement.name.text === 'WorkspaceEvents') { for (const member of statement.members) { // Property signatures in interfaces have names if (!ts.isPropertySignature(member)) continue const nameInfo = getMemberName(member) if (!nameInfo?.extractable) continue const isInternal = hasInternalTag(member, sourceFile) events.set(nameInfo.name, { isInternal }) } } } return events } /** * Extract Obsidian-shape workspace event names from Workspace.on() overloads. * * Walks the Workspace class in obsidian.d.ts and extracts the first argument * (a string literal) from each `on(name: 'event-name', ...)` overload signature. */ function extractObsidianWorkspaceEvents(dtsContent: string): Set { const events = new Set() const sourceFile = ts.createSourceFile( 'obsidian.d.ts', dtsContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) for (const statement of sourceFile.statements) { // Look for the Workspace class if (!ts.isClassDeclaration(statement) || statement.name?.text !== 'Workspace') { continue } for (const member of statement.members) { // Look for method declarations named 'on' if (!ts.isMethodDeclaration(member) && !ts.isMethodSignature(member)) continue const nameInfo = getMemberName(member) if (nameInfo?.name !== 'on') continue // Get the first parameter — it should be a string literal type const params = member.parameters if (params.length === 0) continue const firstParam = params[0] const paramType = firstParam.type // The parameter type should be a literal type with a string literal // e.g., `name: 'active-leaf-change'` if (paramType && ts.isLiteralTypeNode(paramType)) { const literal = paramType.literal if (ts.isStringLiteral(literal)) { events.add(literal.text) } } } } return events } /** * Info about an exported type/interface/class/enum declaration. */ interface ExportedTypeInfo { /** Name of the exported type */ name: string /** Whether the declaration has @internal JSDoc tag */ isInternal: boolean /** Declaration kind */ kind: 'interface' | 'type' | 'class' | 'enum' } /** * Extract top-level exported interface/type/class/enum declarations from a source file. * * Walks the source file's top-level statements and extracts: * - `export interface Foo { ... }` * - `export type Foo = ...` * - `export class Foo { ... }` * - `export abstract class Foo { ... }` * - `export enum Foo { ... }` * * For each, checks if the declaration has an @internal JSDoc tag. * * ## Known scope limits (NOT bugs — document these when exploiting) * * - **Re-exports** (`export { X } from './other'`) are NOT caught. These * bypass the test. If you need to re-export an internal type from a * covered file, document it in a comment next to the re-export. * - **Namespace exports** (`export namespace X { ... }`) are NOT caught. * - **Module augmentations** (`declare module 'obsidian' { ... }`) are * NOT caught. Augmentations in separate files (e.g., augmentations.ts) * are out of scope; the class member tests cover those. * - **Inline type definitions** on exported consts/variables are NOT checked. * Only named type declarations are extracted. */ function extractExportedTypes(sourceFile: ts.SourceFile): Map { const types = new Map() for (const statement of sourceFile.statements) { // Check for export modifier const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined const isExported = modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false if (!isExported) continue // Interface declaration if (ts.isInterfaceDeclaration(statement)) { const name = statement.name.text const isInternal = hasInternalTag(statement, sourceFile) types.set(name, { name, isInternal, kind: 'interface' }) } // Type alias declaration if (ts.isTypeAliasDeclaration(statement)) { const name = statement.name.text const isInternal = hasInternalTag(statement, sourceFile) types.set(name, { name, isInternal, kind: 'type' }) } // Class declaration (including abstract classes) if (ts.isClassDeclaration(statement) && statement.name) { const name = statement.name.text const isInternal = hasInternalTag(statement, sourceFile) types.set(name, { name, isInternal, kind: 'class' }) } // Enum declaration if (ts.isEnumDeclaration(statement)) { const name = statement.name.text const isInternal = hasInternalTag(statement, sourceFile) types.set(name, { name, isInternal, kind: 'enum' }) } } return types } /** * Extract all exported type/interface/class/enum names from obsidian.d.ts. * * Walks the vendor file and extracts every top-level: * - `export interface X` * - `export type X` * - `export class X` * - `export abstract class X` * - `export enum X` * * Returns a Set of names for quick lookup. */ function extractObsidianExportedTypes(dtsContent: string): Set { const types = new Set() const sourceFile = ts.createSourceFile( 'obsidian.d.ts', dtsContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) for (const statement of sourceFile.statements) { // Check for export modifier const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined const isExported = modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false if (!isExported) continue if (ts.isInterfaceDeclaration(statement)) { types.add(statement.name.text) } if (ts.isTypeAliasDeclaration(statement)) { types.add(statement.name.text) } if (ts.isClassDeclaration(statement) && statement.name) { types.add(statement.name.text) } if (ts.isEnumDeclaration(statement)) { types.add(statement.name.text) } } return types } /** * Minimum expected member counts per covered class in the vendored .d.ts. * * These are floors, not contracts: they catch corrupt/truncated/stub vendor * files. Real Obsidian 1.11.4 numbers are well above these. If a future * Obsidian version legitimately removes a method, decrement the floor and * note why in the commit message. */ const VENDOR_MIN_MEMBERS: Record = { App: 10, // ~13 in 1.11.4 Workspace: 30, // ~50+ in 1.11.4 Vault: 20, // ~25 in 1.11.4 MetadataCache: 5, // ~8 in 1.11.4 Plugin: 10, // ~15 in 1.11.4 Editor: 25, // ~30+ in 1.11.4 WorkspaceLeaf: 10, // ~14 in 1.11.4 } /** * Sanity-check the vendor file before running tests. * Detects missing/corrupt/truncated/stub vendor files with a clear error message. */ function validateVendorFile(dtsContent: string): void { if (!dtsContent || dtsContent.length < 100) { throw new Error( `Vendored obsidian.d.ts at ${VENDOR_DTS} is empty or too small. ` + `Re-vendor from npm — see chainlink #391 implementation notes.` ) } for (const className of COVERED_CLASSES) { const obsidianMembers = extractObsidianMembers(dtsContent, className) const minExpected = VENDOR_MIN_MEMBERS[className] if (obsidianMembers.size < minExpected) { throw new Error( `Vendored obsidian.d.ts contains class '${className}' with only ${obsidianMembers.size} members ` + `(expected at least ${minExpected}). The vendor file may be corrupt, truncated, or a stub. ` + `Expected version 1.11.4 at ${VENDOR_DTS}. ` + `Re-vendor: \`curl -sL https://registry.npmjs.org/obsidian/-/obsidian-1.11.4.tgz | tar -xzO package/obsidian.d.ts > vendor/obsidian-1.11.4.d.ts\`` ) } } } /** * Minimum expected member counts per covered class in Reams source. * * Catches the bypass where a class is refactored to a class expression * (`export const App = class { ... }`) which `extractClassMembers` would * silently treat as empty. If extraction returns far fewer members than * expected, the test fails loudly with a refactor hint rather than passing * with zero violations. */ const SOURCE_MIN_MEMBERS: Record = { App: 15, Workspace: 25, Vault: 15, MetadataCache: 10, Plugin: 8, Editor: 15, WorkspaceLeaf: 5, } describe('API Surface Diff', () => { let obsidianDts: string // Cache parsed source files and extracted members to avoid redundant work const sourceFileCache = new Map() const extractCache = new Map>() const obsidianCache = new Map>() beforeAll(() => { if (!fs.existsSync(VENDOR_DTS)) { throw new Error( `Vendored obsidian.d.ts not found at ${VENDOR_DTS}. ` + `This file is required for the API surface diff test. ` + `Re-vendor: \`curl -sL https://registry.npmjs.org/obsidian/-/obsidian-1.11.4.tgz | tar -xzO package/obsidian.d.ts > vendor/obsidian-1.11.4.d.ts\`` ) } obsidianDts = fs.readFileSync(VENDOR_DTS, 'utf-8') validateVendorFile(obsidianDts) for (const filePath of new Set(Object.values(SOURCE_FILES))) { sourceFileCache.set(filePath, parseSourceFile(filePath)) } }) function getMembers(className: CoveredClass): ReturnType { const filePath = SOURCE_FILES[className] const cacheKey = `${filePath}:${className}` let cached = extractCache.get(cacheKey) if (!cached) { const sourceFile = sourceFileCache.get(filePath) if (!sourceFile) { throw new Error(`Source file not parsed: ${filePath}`) } cached = extractClassMembers(sourceFile, className) extractCache.set(cacheKey, cached) } return cached } function getObsidianMembers(className: CoveredClass): Set { let cached = obsidianCache.get(className) if (!cached) { cached = extractObsidianMembers(obsidianDts, className) obsidianCache.set(className, cached) } return cached } for (const className of COVERED_CLASSES) { describe(className, () => { it('source class declaration must contain expected number of members (catches refactor neutering)', () => { const { members: reamsMembers } = getMembers(className) const minExpected = SOURCE_MIN_MEMBERS[className] if (reamsMembers.size < minExpected) { expect.fail( `${className} source class declaration extracted only ${reamsMembers.size} members ` + `(expected at least ${minExpected}). ` + `Possible causes: (1) class was refactored to a class expression ` + `(e.g. \`export const ${className} = class { ... }\`) — only \`class ${className} {}\` ` + `declarations are detected; (2) class was renamed; (3) class moved to a different file ` + `(update SOURCE_FILES). Update SOURCE_MIN_MEMBERS if the floor is legitimately lower.` ) } }) it('all public members must be Obsidian-shape, @internal, or whitelisted', () => { const obsidianMembers = getObsidianMembers(className) const { members: reamsMembers, unnamed } = getMembers(className) const whitelist = REAMS_NOVEL_WHITELIST[className] ?? {} const violations: string[] = [] for (const [memberName, info] of reamsMembers) { if (obsidianMembers.has(memberName)) continue if (info.isInternal) continue if (whitelist[memberName]) continue violations.push(memberName + (info.isStatic ? ' (static)' : '')) } // Un-extractable names (computed/symbol) are always violations: // we can't verify them against obsidian.d.ts. for (const u of unnamed) { violations.push(`${u} (un-extractable; rename to a static identifier)`) } if (violations.length > 0) { const message = [ `${className} has ${violations.length} Reams-novel public member(s) not in obsidian.d.ts, @internal, or whitelist:`, '', ...violations.map(m => ` - ${m}`), '', 'To fix:', ' 1. Add @internal tag to the TSDoc comment if this is host-internal', ' 2. Add to REAMS_NOVEL_WHITELIST with PENDING note if this needs namespace migration', ' 3. Remove the member if it was added accidentally', '', 'Known bypasses NOT caught by this test (do not exploit):', ' - Members on inherited base classes (Events, Component)', ' - Members of inline object property types (e.g. App.foldManager.save)', ' - Signature drift on existing names (same name, different signature)', ].join('\n') expect.fail(message) } }) it('whitelist must not contain Obsidian-shape members (redundant)', () => { const obsidianMembers = getObsidianMembers(className) const whitelist = REAMS_NOVEL_WHITELIST[className] ?? {} const redundant = Object.keys(whitelist).filter(m => obsidianMembers.has(m)) if (redundant.length > 0) { const message = [ `${className} has ${redundant.length} whitelist entry/entries that are also in obsidian.d.ts (redundant):`, '', ...redundant.map(m => ` - ${m}`), '', 'Remove these from REAMS_NOVEL_WHITELIST — they are Obsidian-shape and need no whitelist entry.', ].join('\n') expect.fail(message) } }) it('whitelist must not contain @internal members (redundant)', () => { const { members: reamsMembers } = getMembers(className) const whitelist = REAMS_NOVEL_WHITELIST[className] ?? {} const redundant = Object.keys(whitelist).filter(m => reamsMembers.get(m)?.isInternal) if (redundant.length > 0) { const message = [ `${className} has ${redundant.length} whitelist entry/entries that are also marked @internal (redundant):`, '', ...redundant.map(m => ` - ${m}`), '', 'Remove these from REAMS_NOVEL_WHITELIST — @internal members are already exempt.', ].join('\n') expect.fail(message) } }) }) } describe('Whitelist hygiene', () => { it('whitelist must not contain entries for non-existent members', () => { const stale: Array<{ className: string; memberName: string }> = [] for (const className of COVERED_CLASSES) { const { members: reamsMembers } = getMembers(className) const whitelist = REAMS_NOVEL_WHITELIST[className] ?? {} for (const memberName of Object.keys(whitelist)) { if (!reamsMembers.has(memberName)) { stale.push({ className, memberName }) } } } if (stale.length > 0) { const message = [ `Found ${stale.length} whitelist entry/entries for members that no longer exist:`, '', ...stale.map(s => ` - ${s.className}.${s.memberName}`), '', 'Remove these from REAMS_NOVEL_WHITELIST.', ].join('\n') expect.fail(message) } }) it('whitelist class keys must all be in COVERED_CLASSES (no typos)', () => { const knownClasses = new Set(COVERED_CLASSES) const unknown = Object.keys(REAMS_NOVEL_WHITELIST).filter(k => !knownClasses.has(k)) if (unknown.length > 0) { const message = [ `REAMS_NOVEL_WHITELIST has ${unknown.length} entry/entries for unknown class(es):`, '', ...unknown.map(c => ` - ${c}`), '', `Either add to COVERED_CLASSES or fix the typo. Known classes: ${[...knownClasses].join(', ')}`, ].join('\n') expect.fail(message) } }) }) describe('Self-test (meta-tests for the extraction logic)', () => { /** * Helper: parse synthetic TS source for a single self-test. * Uses a unique filename per call to prevent any future cross-test * caching collision (e.g., if extractClassMembers ever caches by filename). */ function parseSynthetic(testName: string, code: string): ts.SourceFile { // Sanitize testName for use as a filename const safe = testName.replace(/[^a-zA-Z0-9_-]/g, '_') return ts.createSourceFile(`self-test-${safe}.ts`, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) } it('detects @internal via TS API JSDoc tag', () => { const sf = parseSynthetic('internal-jsdoc', `class Foo { /** @internal */ bar(): void {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.get('bar')?.isInternal).toBe(true) }) it('does not match @internalFoo (word boundary)', () => { const sf = parseSynthetic('internal-word-boundary', `class Foo { /** @internalFoo */ bar(): void {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.get('bar')?.isInternal).toBe(false) }) it('detects static members', () => { const sf = parseSynthetic('static', `class Foo { static bar(): void {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.get('bar')?.isStatic).toBe(true) }) it('extracts constructor parameter properties', () => { const sf = parseSynthetic('param-props', `class Foo { constructor(public sneaky: number, private hidden: string, readonly visible: boolean) {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.has('sneaky')).toBe(true) expect(members.has('hidden')).toBe(false) // private excluded expect(members.has('visible')).toBe(true) // readonly counts as public }) it('flags computed property names as un-extractable, but extracts string-literal names', () => { const sf = parseSynthetic('computed-names', `class Foo { ["foo-bar"](): void {} [Symbol.iterator](): void {} }`) const { members, unnamed } = extractClassMembers(sf, 'Foo') expect(members.has('foo-bar')).toBe(true) // string literal IS extractable expect(unnamed.length).toBeGreaterThan(0) // Symbol.iterator is not statically resolvable }) it('handles declaration merging (interface augments class)', () => { const sf = parseSynthetic('decl-merging', `class Foo { bar(): void {} } interface Foo { baz: string }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.has('bar')).toBe(true) expect(members.has('baz')).toBe(true) }) it('collapses overloads, propagating @internal from any signature', () => { const sf = parseSynthetic('overload-internal', `class Foo { /** @internal */ bar(x: string): void bar(x: number): void bar(x: any): void {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.get('bar')?.isInternal).toBe(true) expect(members.get('bar')?.declarationCount).toBe(3) }) it('does not match nested same-name classes inside method bodies', () => { const sf = parseSynthetic('nested-class', `class Foo { outer(): void { class Foo { secret(): void {} } } }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.has('outer')).toBe(true) expect(members.has('secret')).toBe(false) }) it('skips underscore-prefixed members', () => { const sf = parseSynthetic('underscore', `class Foo { _privateField: string = '' publicField: number = 0 }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.has('_privateField')).toBe(false) expect(members.has('publicField')).toBe(true) }) it('parameter @internal is local to the parameter (not propagated from constructor JSDoc)', () => { // Adversarial test: constructor JSDoc has @internal, but the param property // should NOT be marked @internal — only the constructor itself is. const sf = parseSynthetic('param-internal-isolation', `class Foo { /** * @internal Construction is host-internal */ constructor(public visiblePublic: number) {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.has('visiblePublic')).toBe(true) // The param property should NOT inherit @internal from the constructor's JSDoc expect(members.get('visiblePublic')?.isInternal).toBe(false) }) it('parameter @internal does not leak between adjacent parameters via overlapping comment ranges', () => { // Adversarial test for the overlapping-comment-range bug: // Without hasInternalTagOnParam (param-specific check), the second param // could be falsely marked @internal because /** @internal */ between // params is the leading comment of the next param. const sf = parseSynthetic('param-overlap', `class Foo { constructor( /** @internal */ public hiddenParam: number, public visibleParam: string, ) {} }`) const { members } = extractClassMembers(sf, 'Foo') expect(members.get('hiddenParam')?.isInternal).toBe(true) // Critical: visibleParam must NOT be falsely marked @internal expect(members.get('visibleParam')?.isInternal).toBe(false) }) }) /** * WorkspaceEvents interface diff tests. * * Prevents accidental Reams-novel event accretion in the WorkspaceEvents * event-map interface. Every event name must be in one of: * 1. Obsidian's Workspace.on() overloads (Obsidian-shape) * 2. Marked @internal in the interface member's TSDoc * 3. Transitional events whitelist (REAMS_NOVEL_EVENTS_WHITELIST) * * This complements the class member tests by covering event-map interfaces. * * @see chainlink issue #399 */ describe('WorkspaceEvents', () => { let workspaceEvents: Map let obsidianEvents: Set beforeAll(() => { const workspaceSourceFile = sourceFileCache.get(SOURCE_FILES.Workspace) if (!workspaceSourceFile) { throw new Error('Workspace source file not loaded') } workspaceEvents = extractWorkspaceEvents(workspaceSourceFile) obsidianEvents = extractObsidianWorkspaceEvents(obsidianDts) }) it('WorkspaceEvents interface must have at least 5 events (sanity check)', () => { if (workspaceEvents.size < 5) { expect.fail( `WorkspaceEvents interface has only ${workspaceEvents.size} events (expected at least 5). ` + `Either the interface was removed/renamed or extraction logic is broken.` ) } }) it('Obsidian Workspace.on() overloads must have at least 10 events (vendor sanity check)', () => { if (obsidianEvents.size < 10) { expect.fail( `Obsidian Workspace.on() overloads contain only ${obsidianEvents.size} events (expected at least 10). ` + `The vendor file may be corrupt or the extraction logic is broken.` ) } }) it('all event names must be Obsidian-shape, @internal, or whitelisted', () => { const violations: string[] = [] for (const [eventName, info] of workspaceEvents) { if (obsidianEvents.has(eventName)) continue if (info.isInternal) continue if (REAMS_NOVEL_EVENTS_WHITELIST[eventName]) continue violations.push(eventName) } if (violations.length > 0) { const message = [ `WorkspaceEvents has ${violations.length} Reams-novel event(s) not in Obsidian Workspace.on(), @internal, or whitelist:`, '', ...violations.map(e => ` - '${e}'`), '', 'To fix:', ' 1. Add @internal tag to the event in WorkspaceEvents if this is host-internal', ' 2. Add to REAMS_NOVEL_EVENTS_WHITELIST with PENDING note if this needs namespace migration', ' 3. Move the event to app.reamsEvents if it is Reams-novel', ' 4. Remove the event if it was added accidentally', ].join('\n') expect.fail(message) } }) it('events whitelist must not contain Obsidian-shape events (redundant)', () => { const redundant = Object.keys(REAMS_NOVEL_EVENTS_WHITELIST).filter(e => obsidianEvents.has(e)) if (redundant.length > 0) { const message = [ `REAMS_NOVEL_EVENTS_WHITELIST has ${redundant.length} entry/entries that are also in Obsidian (redundant):`, '', ...redundant.map(e => ` - '${e}'`), '', 'Remove these from REAMS_NOVEL_EVENTS_WHITELIST — they are Obsidian-shape and need no whitelist entry.', ].join('\n') expect.fail(message) } }) it('events whitelist must not contain @internal events (redundant)', () => { const redundant = Object.keys(REAMS_NOVEL_EVENTS_WHITELIST).filter(e => workspaceEvents.get(e)?.isInternal) if (redundant.length > 0) { const message = [ `REAMS_NOVEL_EVENTS_WHITELIST has ${redundant.length} entry/entries that are also marked @internal (redundant):`, '', ...redundant.map(e => ` - '${e}'`), '', 'Remove these from REAMS_NOVEL_EVENTS_WHITELIST — @internal events are already exempt.', ].join('\n') expect.fail(message) } }) it('events whitelist must not contain non-existent events (stale)', () => { const stale = Object.keys(REAMS_NOVEL_EVENTS_WHITELIST).filter(e => !workspaceEvents.has(e)) if (stale.length > 0) { const message = [ `REAMS_NOVEL_EVENTS_WHITELIST has ${stale.length} entry/entries for events that don't exist in WorkspaceEvents:`, '', ...stale.map(e => ` - '${e}'`), '', 'Remove these stale entries from REAMS_NOVEL_EVENTS_WHITELIST.', ].join('\n') expect.fail(message) } }) }) /** * Exported types/interfaces diff tests. * * Prevents accidental Reams-novel type accretion in covered source files. * Every top-level `export interface X` / `export type X` / `export class X` * in the covered files must be in one of: * 1. Obsidian's vendored .d.ts (Obsidian-shape) * 2. Marked @internal in source TSDoc * 3. Transitional types whitelist (REAMS_NOVEL_TYPES_WHITELIST) * * This complements the class member tests by catching standalone exported * types like `PendingScrollRequest` and `WorkspaceEvents`. * * @see chainlink issue #400 */ describe('Exported Types', () => { // Cache: filePath → Map const exportedTypesCache = new Map>() let obsidianTypes: Set beforeAll(() => { // Extract types from each covered source file for (const relativePath of COVERED_SOURCE_FILES) { const filePath = path.join(PROJECT_ROOT, relativePath) const sourceFile = sourceFileCache.get(filePath) ?? parseSourceFile(filePath) if (!sourceFileCache.has(filePath)) { sourceFileCache.set(filePath, sourceFile) } exportedTypesCache.set(relativePath, extractExportedTypes(sourceFile)) } // Extract types from vendored obsidian.d.ts obsidianTypes = extractObsidianExportedTypes(obsidianDts) }) it('should have extracted at least some types from vendor (sanity check)', () => { // Obsidian 1.11.4 has hundreds of exported types — this floor just catches corrupt files if (obsidianTypes.size < 100) { expect.fail( `Vendored obsidian.d.ts contains only ${obsidianTypes.size} exported types (expected at least 100). ` + `The vendor file may be corrupt or the extraction logic is broken.` ) } }) it('should have extracted at least some types from covered source files (sanity check)', () => { let totalTypes = 0 for (const [, types] of exportedTypesCache) { totalTypes += types.size } if (totalTypes < 5) { expect.fail( `Covered source files contain only ${totalTypes} exported types (expected at least 5). ` + `Either the source files changed significantly or the extraction logic is broken.` ) } }) it('all exported types must be Obsidian-shape, @internal, or whitelisted', () => { const violations: Array<{ file: string; name: string; kind: string }> = [] for (const [relativePath, types] of exportedTypesCache) { for (const [name, info] of types) { // Skip if in vendored obsidian.d.ts if (obsidianTypes.has(name)) continue // Skip if marked @internal if (info.isInternal) continue // Skip if in whitelist if (REAMS_NOVEL_TYPES_WHITELIST[name]) continue violations.push({ file: relativePath, name, kind: info.kind }) } } if (violations.length > 0) { const message = [ `Found ${violations.length} Reams-novel exported type(s) not in obsidian.d.ts, @internal, or whitelist:`, '', ...violations.map(v => ` - ${v.kind} ${v.name} (in ${v.file})`), '', 'To fix:', ' 1. Add @internal tag to the TSDoc comment if this is host-internal', ' 2. Add to REAMS_NOVEL_TYPES_WHITELIST with PENDING note if this needs namespace migration', ' 3. Remove the export if it was added accidentally', ].join('\n') expect.fail(message) } }) it('types whitelist must not contain Obsidian-shape types (redundant)', () => { const redundant = Object.keys(REAMS_NOVEL_TYPES_WHITELIST).filter(name => obsidianTypes.has(name)) if (redundant.length > 0) { const message = [ `REAMS_NOVEL_TYPES_WHITELIST has ${redundant.length} entry/entries that are also in obsidian.d.ts (redundant):`, '', ...redundant.map(name => ` - ${name}`), '', 'Remove these from REAMS_NOVEL_TYPES_WHITELIST — they are Obsidian-shape and need no whitelist entry.', ].join('\n') expect.fail(message) } }) it('types whitelist must not contain @internal types (redundant)', () => { const redundant: string[] = [] for (const [, types] of exportedTypesCache) { for (const [name, info] of types) { if (REAMS_NOVEL_TYPES_WHITELIST[name] && info.isInternal) { redundant.push(name) } } } if (redundant.length > 0) { const message = [ `REAMS_NOVEL_TYPES_WHITELIST has ${redundant.length} entry/entries that are also marked @internal (redundant):`, '', ...redundant.map(name => ` - ${name}`), '', 'Remove these from REAMS_NOVEL_TYPES_WHITELIST — @internal types are already exempt.', ].join('\n') expect.fail(message) } }) it('types whitelist must not contain non-existent types (stale)', () => { const allExportedTypes = new Set() for (const [, types] of exportedTypesCache) { for (const name of types.keys()) { allExportedTypes.add(name) } } const stale = Object.keys(REAMS_NOVEL_TYPES_WHITELIST).filter(name => !allExportedTypes.has(name)) if (stale.length > 0) { const message = [ `REAMS_NOVEL_TYPES_WHITELIST has ${stale.length} entry/entries for types that don't exist in covered files:`, '', ...stale.map(name => ` - ${name}`), '', 'Remove these stale entries from REAMS_NOVEL_TYPES_WHITELIST.', ].join('\n') expect.fail(message) } }) }) /** * Self-tests for exported types extraction logic. */ describe('Self-test (exported types extraction)', () => { it('extracts exported interface declarations', () => { const sf = ts.createSourceFile( 'test-interface.ts', `export interface Foo { bar: string } interface NotExported { baz: number }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.has('Foo')).toBe(true) expect(types.get('Foo')?.kind).toBe('interface') expect(types.has('NotExported')).toBe(false) }) it('extracts exported type alias declarations', () => { const sf = ts.createSourceFile( 'test-type.ts', `export type MyType = 'a' | 'b' type NotExported = string`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.has('MyType')).toBe(true) expect(types.get('MyType')?.kind).toBe('type') expect(types.has('NotExported')).toBe(false) }) it('extracts exported class declarations', () => { const sf = ts.createSourceFile( 'test-class.ts', `export class MyClass {} export abstract class AbstractClass {} class NotExported {}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.has('MyClass')).toBe(true) expect(types.get('MyClass')?.kind).toBe('class') expect(types.has('AbstractClass')).toBe(true) expect(types.get('AbstractClass')?.kind).toBe('class') expect(types.has('NotExported')).toBe(false) }) it('extracts exported enum declarations', () => { const sf = ts.createSourceFile( 'test-enum.ts', `export enum MyEnum { A, B, C } enum NotExported { X, Y }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.has('MyEnum')).toBe(true) expect(types.get('MyEnum')?.kind).toBe('enum') expect(types.has('NotExported')).toBe(false) }) it('detects @internal tag on exported types', () => { const sf = ts.createSourceFile( 'test-internal.ts', `/** @internal */ export interface InternalFoo {} export interface PublicBar {}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.get('InternalFoo')?.isInternal).toBe(true) expect(types.get('PublicBar')?.isInternal).toBe(false) }) it('detects @internal in multi-line JSDoc', () => { const sf = ts.createSourceFile( 'test-multiline.ts', `/** * Host-internal type. * @internal Not part of the public API. */ export interface InternalType {}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const types = extractExportedTypes(sf) expect(types.get('InternalType')?.isInternal).toBe(true) }) it('extracts types from obsidian.d.ts format', () => { const dts = ` export interface EditorPosition { line: number; ch: number } export type HexString = string export class App {} export abstract class Editor {} export enum Platform { Desktop, Mobile } interface NotExported {} enum NotExportedEnum { A, B } ` const types = extractObsidianExportedTypes(dts) expect(types.has('EditorPosition')).toBe(true) expect(types.has('HexString')).toBe(true) expect(types.has('App')).toBe(true) expect(types.has('Editor')).toBe(true) expect(types.has('Platform')).toBe(true) // enum support expect(types.has('NotExported')).toBe(false) expect(types.has('NotExportedEnum')).toBe(false) }) }) /** * Self-tests for event extraction logic. */ describe('Self-test (event extraction)', () => { it('extracts event names from WorkspaceEvents interface', () => { const sf = ts.createSourceFile( 'test-events.ts', `interface WorkspaceEvents { 'active-leaf-change': [leaf: unknown] 'file-open': [file: unknown] }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const events = extractWorkspaceEvents(sf) expect(events.has('active-leaf-change')).toBe(true) expect(events.has('file-open')).toBe(true) expect(events.size).toBe(2) }) it('detects @internal tag on event members', () => { const sf = ts.createSourceFile( 'test-events-internal.ts', `interface WorkspaceEvents { /** @internal */ 'pending-scroll-set': [pending: unknown] 'file-open': [file: unknown] }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const events = extractWorkspaceEvents(sf) expect(events.get('pending-scroll-set')?.isInternal).toBe(true) expect(events.get('file-open')?.isInternal).toBe(false) }) it('extracts event names from Workspace.on() overloads', () => { const dts = ` class Workspace { on(name: 'active-leaf-change', callback: () => any): any; on(name: 'file-open', callback: () => any): any; on(name: string, callback: () => any): any; } ` const events = extractObsidianWorkspaceEvents(dts) expect(events.has('active-leaf-change')).toBe(true) expect(events.has('file-open')).toBe(true) expect(events.size).toBe(2) // string overload is ignored }) it('detects @internal in multi-line JSDoc comments', () => { const sf = ts.createSourceFile( 'test-multiline-jsdoc.ts', `interface WorkspaceEvents { /** * Internal event for scroll restoration. * @internal This is not part of the public API. */ 'pending-scroll-set': [pending: unknown] 'file-open': [file: unknown] }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const events = extractWorkspaceEvents(sf) expect(events.get('pending-scroll-set')?.isInternal).toBe(true) expect(events.get('file-open')?.isInternal).toBe(false) }) it('returns empty map for empty WorkspaceEvents interface', () => { const sf = ts.createSourceFile( 'test-empty.ts', `interface WorkspaceEvents {}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const events = extractWorkspaceEvents(sf) expect(events.size).toBe(0) }) it('skips method signatures, only extracts property signatures', () => { const sf = ts.createSourceFile( 'test-methods.ts', `interface WorkspaceEvents { 'file-open': [file: unknown] someMethod(): void anotherMethod(arg: string): boolean }`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const events = extractWorkspaceEvents(sf) // Only the property signature should be extracted expect(events.has('file-open')).toBe(true) expect(events.size).toBe(1) // Method signatures should not appear expect(events.has('someMethod')).toBe(false) expect(events.has('anotherMethod')).toBe(false) }) }) })