Story navigation and details
Source-grounded
The CLI story validator initiates this sequence to validate a draft against its current source. The diagram follows one anchor; resolution repeats for every anchor. Snapshot is a local reader/captured map, and Parser is local language lookup—not another service. Digest checks apply when a hash is supplied; symbol and snippet checks are optional according to the anchor. Rendering/publication are outside this draft-validation path.
-
Story validator
The CLI's validateStory operation, invoked by topo story validate, needs to establish whether a draft's anchors still identify current code. It checks the draft structure, captures referenced files, delegates anchor resolution, then rechecks bytes and Git state before returning the resolved document. This is draft validation, not rendering or publication. These are explicit checkpoints, not a continuously held source-code lock. Valid anchors do not establish the explanation's semantic truth.
packages/cli/src/story-validation.ts:37-74 export async function validateStory( rootInput: string, documentInput: string, ): Promise<ResolvedStoryDocument> { const root = resolve(rootInput); const { documentAbsolute, documentPath } = repositoryStoryPath( root, documentInput, ); const document = parseStoryDocument( await readFile(documentAbsolute, "utf8"), documentPath, ); const source = await repositoryState(root); const snapshot = await captureSourceSnapshot( root, document.anchors.map((anchor) => anchor.path), ); const resolved = await resolveStoryDocument( root, document, documentPath, { revision: source.revision, dirty: source.dirty }, async (path) => snapshot.get(path), ); await assertSourceSnapshot(root, snapshot); const finalSource = await repositoryState(root); if ( finalSource.revision !== source.revision || finalSource.dirty !== source.dirty || finalSource.fingerprint !== source.fingerprint ) { throw new Error( `${documentPath}: repository changed while validating; retry`, ); } return resolved; } -
Source snapshot
captureSourceSnapshot deduplicates and sorts paths, then reads regular files with path, symlink, inode and modification checks. The captured map provides bytes (or absence) to the resolver. assertSourceSnapshot rereads and compares those bytes. Source changing during capture or after capture causes an explicit error. The reader/map are grouped as a data boundary, not a network service.
packages/cli/src/source-snapshot.ts:71-81 export async function captureSourceSnapshot( rootInput: string, paths: Iterable<string>, ): Promise<SourceSnapshot> { const root = await realpath(rootInput); const unique = [...new Set(paths)].sort(); return new Map(await Promise.all(unique.map(async (path) => [ path, await readStableSource(root, path), ] as const))); }packages/cli/src/source-snapshot.ts:7-69 async function readStableSource( root: string, repositoryPath: string, ): Promise<string | undefined> { const requested = resolve(root, repositoryPath); const rel = relative(root, requested); if ( isAbsolute(repositoryPath) || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) ) { throw new Error(`Source path escapes the repository: ${repositoryPath}`); } let actual: string; try { actual = await realpath(requested); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { return undefined; } throw error; } if (actual !== requested) { throw new Error(`Source path must not resolve through a symlink: ${repositoryPath}`); } const initial = await stat(requested); if (!initial.isFile()) { throw new Error(`Source path must be a regular file: ${repositoryPath}`); } const handle = await open( requested, constants.O_RDONLY | constants.O_NOFOLLOW, ); try { const opened = await handle.stat(); if ( !opened.isFile() || opened.dev !== initial.dev || opened.ino !== initial.ino ) { throw new Error(`Source changed before it was read: ${repositoryPath}`); } const contents = await handle.readFile("utf8"); const openedAfter = await handle.stat(); const currentActual = await realpath(requested); const current = await stat(requested); if ( currentActual !== requested || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino || openedAfter.size !== opened.size || openedAfter.mtimeMs !== opened.mtimeMs || openedAfter.ctimeMs !== opened.ctimeMs ) { throw new Error(`Source changed while it was read: ${repositoryPath}`); } return contents; } finally { await handle.close(); } }packages/cli/src/source-snapshot.ts:83-93 export async function assertSourceSnapshot( root: string, snapshot: SourceSnapshot, ): Promise<void> { const current = await captureSourceSnapshot(root, snapshot.keys()); for (const [path, contents] of snapshot) { if (current.get(path) !== contents) { throw new Error(`Repository source changed while rendering: ${path}`); } } } -
Anchor resolver
Open Dependency context by role: Code analysisOpen Dependency context by role: Language parsingOpen Dependency context by role: Schema contractsOpen Dependency context by role: JSON validationOpen Dependency context by role: Build, test, docsOpen Dependency context by role: Development toolsOpen @topo/diagram-core: public surface: @topo/storyOpen Topocode internal modules: @topo/story
resolveStoryDocument checks each path and refuses absent source. resolveAnchor checks a supplied whole-file SHA-256 BEFORE parsing, establishes a file or symbol scope, then finds the exact authored pattern and derives current lines/excerpts. Unscoped patterns must be unique. Missing symbols/patterns and stale hashes are errors. There is no fuzzy matching or automatic repair of renamed/moved code. Without a pattern, the selected file/symbol range is returned.
packages/story/src/index.ts:144-170 export async function resolveStoryDocument( root: string, document: StoryDocument, documentPath: string, source: { revision: string; dirty: boolean }, loadSource?: (path: string) => Promise<string | undefined>, ): Promise<ResolvedStoryDocument> { const repositoryRoot = loadSource === undefined ? await realpath(root) : resolve(root); const anchors: ResolvedSourceAnchor[] = []; for (const anchor of document.anchors) { assertRepositoryPath(root, documentPath, anchor); const contents = loadSource === undefined ? await readRepositorySource(repositoryRoot, documentPath, anchor) : await loadSource(anchor.path); if (contents === undefined) { throw anchorError( documentPath, anchor.id, "missing-file", `source file "${anchor.path}" does not exist`, ); } anchors.push(await resolveAnchor(documentPath, anchor, contents)); } return { document, documentPath, repositoryRoot, source, anchors }; }packages/story/src/index.ts:533-604 async function resolveAnchor( documentPath: string, anchor: SourceAnchor, contents: string, ): Promise<ResolvedSourceAnchor> { if (anchor.sha256 !== undefined && createHash("sha256").update(contents).digest("hex") !== anchor.sha256) { throw anchorError(documentPath, anchor.id, "stale-source", `source "${anchor.path}" changed; inspect the change and deliberately update its digest, preserving anchor identity`); } const sourceFile = ts.createSourceFile( anchor.path, contents, ts.ScriptTarget.Latest, true, ); let start = 0; let end = contents.length; if (anchor.symbol !== undefined) { const language = anchor.language ?? (anchor.path.endsWith(".rs") ? "rust" : "typescript"); if (language === "text") throw anchorError(documentPath, anchor.id, "unsupported-symbol", "text anchors support exact patterns, not compiler symbols"); const rust = language === "rust" ? await resolveRustSymbol(anchor.path, contents, anchor.symbol) : undefined; const symbol = rust === undefined ? findSymbolRange(sourceFile, anchor.symbol) : { start: sourceFile.getPositionOfLineAndCharacter(rust.startLine - 1, rust.startColumn - 1), end: sourceFile.getPositionOfLineAndCharacter(rust.endLine - 1, rust.endColumn - 1), }; if (symbol === undefined) { throw anchorError( documentPath, anchor.id, "missing-symbol", `symbol "${anchor.symbol}" was not found in "${anchor.path}"`, ); } start = symbol.start; end = symbol.end; } if (anchor.pattern !== undefined) { const scope = contents.slice(start, end); const offset = scope.indexOf(anchor.pattern); if (offset < 0) { throw anchorError( documentPath, anchor.id, "missing-pattern", anchor.symbol === undefined ? `pattern "${anchor.pattern}" was not found in "${anchor.path}"` : `pattern "${anchor.pattern}" was not found within symbol "${anchor.symbol}" in "${anchor.path}"`, ); } if ( anchor.symbol === undefined && scope.indexOf(anchor.pattern, offset + 1) >= 0 ) { throw anchorError( documentPath, anchor.id, "ambiguous-pattern", `pattern "${anchor.pattern}" occurs more than once in "${anchor.path}"`, ); } start += offset; end = start + anchor.pattern.length; } const startLine = sourceFile.getLineAndCharacterOfPosition(start).line + 1; const endLine = sourceFile.getLineAndCharacterOfPosition( Math.max(start, end - 1), ).line + 1; return { ...anchor, location: { startLine, endLine }, excerpt: contents.slice(start, end), }; }packages/story/package.json:1-33 { "name": "@topo/story", "version": "0.0.0", "private": true, "type": "module", "files": [ "dist", "story.schema.json" ], "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "./story.schema.json": "./story.schema.json" }, "scripts": { "build": "tsc -b", "typecheck": "tsc -b --pretty false", "test": "vitest run" }, "dependencies": { "@topo/languages": "workspace:*", "typescript-compiler-api": "npm:typescript@6.0.3" }, "devDependencies": { "@types/node": "^22", "ajv": "8.17.1", "typescript": "^7.0.2", "vite": "8.2.2", "vitest": "^5.0.0" } } -
Language lookup
TypeScript/JavaScript anchors use an AST walk for the first declaration with the requested identifier; this is not the full type-checker resolution used by scanning. Rust delegates to its lexical/parser-backed symbol resolver. Text anchors do not accept symbol lookup. The parser returns syntax/range information. Exact snippet matching and final source locations belong to the anchor resolver.
packages/story/src/index.ts:533-604 async function resolveAnchor( documentPath: string, anchor: SourceAnchor, contents: string, ): Promise<ResolvedSourceAnchor> { if (anchor.sha256 !== undefined && createHash("sha256").update(contents).digest("hex") !== anchor.sha256) { throw anchorError(documentPath, anchor.id, "stale-source", `source "${anchor.path}" changed; inspect the change and deliberately update its digest, preserving anchor identity`); } const sourceFile = ts.createSourceFile( anchor.path, contents, ts.ScriptTarget.Latest, true, ); let start = 0; let end = contents.length; if (anchor.symbol !== undefined) { const language = anchor.language ?? (anchor.path.endsWith(".rs") ? "rust" : "typescript"); if (language === "text") throw anchorError(documentPath, anchor.id, "unsupported-symbol", "text anchors support exact patterns, not compiler symbols"); const rust = language === "rust" ? await resolveRustSymbol(anchor.path, contents, anchor.symbol) : undefined; const symbol = rust === undefined ? findSymbolRange(sourceFile, anchor.symbol) : { start: sourceFile.getPositionOfLineAndCharacter(rust.startLine - 1, rust.startColumn - 1), end: sourceFile.getPositionOfLineAndCharacter(rust.endLine - 1, rust.endColumn - 1), }; if (symbol === undefined) { throw anchorError( documentPath, anchor.id, "missing-symbol", `symbol "${anchor.symbol}" was not found in "${anchor.path}"`, ); } start = symbol.start; end = symbol.end; } if (anchor.pattern !== undefined) { const scope = contents.slice(start, end); const offset = scope.indexOf(anchor.pattern); if (offset < 0) { throw anchorError( documentPath, anchor.id, "missing-pattern", anchor.symbol === undefined ? `pattern "${anchor.pattern}" was not found in "${anchor.path}"` : `pattern "${anchor.pattern}" was not found within symbol "${anchor.symbol}" in "${anchor.path}"`, ); } if ( anchor.symbol === undefined && scope.indexOf(anchor.pattern, offset + 1) >= 0 ) { throw anchorError( documentPath, anchor.id, "ambiguous-pattern", `pattern "${anchor.pattern}" occurs more than once in "${anchor.path}"`, ); } start += offset; end = start + anchor.pattern.length; } const startLine = sourceFile.getLineAndCharacterOfPosition(start).line + 1; const endLine = sourceFile.getLineAndCharacterOfPosition( Math.max(start, end - 1), ).line + 1; return { ...anchor, location: { startLine, endLine }, excerpt: contents.slice(start, end), }; }packages/story/src/index.ts:511-531 function findSymbolRange( sourceFile: ts.SourceFile, symbol: string, ): { start: number; end: number } | undefined { let result: ts.Node | undefined; const visit = (node: ts.Node): void => { if (result !== undefined) return; if (declarationName(node) === symbol) { result = node; return; } ts.forEachChild(node, visit); }; visit(sourceFile); return result === undefined ? undefined : { start: result.getStart(sourceFile), end: result.getEnd(), }; }packages/languages/src/index.ts:244-250 export async function resolveRustSymbol(path: string, contents: string, symbol: string): Promise<SourceLocation> { const contribution = await rustContribution({ root: "", files: [{ path, contents, sha256: hash(contents) }] }); const candidates = contribution.entities.filter((entity) => entity.name === symbol || entity.qualifiedName === symbol); if (candidates.length !== 1) throw new Error(`Rust symbol "${symbol}" in "${path}" is ${candidates.length ? "ambiguous; use a lexical qualifiedName" : "missing"}`); if (contribution.unresolved.some((item) => item.kind === "syntax-error")) throw new Error(`Rust source "${path}" has parser recovery; use an exact pattern after repairing it`); return candidates[0]!.location; }
source-traced: Capture draft evidence
The story validator captures the draft's referenced source so all of its anchors can be checked against one consistent set of bytes.
export async function validateStory(
rootInput: string,
documentInput: string,
): Promise<ResolvedStoryDocument> {
const root = resolve(rootInput);
const { documentAbsolute, documentPath } = repositoryStoryPath(
root,
documentInput,
);
const document = parseStoryDocument(
await readFile(documentAbsolute, "utf8"),
documentPath,
);
const source = await repositoryState(root);
const snapshot = await captureSourceSnapshot(
root,
document.anchors.map((anchor) => anchor.path),
);
const resolved = await resolveStoryDocument(
root,
document,
documentPath,
{ revision: source.revision, dirty: source.dirty },
async (path) => snapshot.get(path),
);
await assertSourceSnapshot(root, snapshot);
const finalSource = await repositoryState(root);
if (
finalSource.revision !== source.revision ||
finalSource.dirty !== source.dirty ||
finalSource.fingerprint !== source.fingerprint
) {
throw new Error(
`${documentPath}: repository changed while validating; retry`,
);
}
return resolved;
}export async function captureSourceSnapshot(
rootInput: string,
paths: Iterable<string>,
): Promise<SourceSnapshot> {
const root = await realpath(rootInput);
const unique = [...new Set(paths)].sort();
return new Map(await Promise.all(unique.map(async (path) => [
path,
await readStableSource(root, path),
] as const)));
}source-traced: Captured bytes or read error
Return a stable map; invalid/unstable paths throw. Missing files are represented as absent until resolution rejects them.
export async function captureSourceSnapshot(
rootInput: string,
paths: Iterable<string>,
): Promise<SourceSnapshot> {
const root = await realpath(rootInput);
const unique = [...new Set(paths)].sort();
return new Map(await Promise.all(unique.map(async (path) => [
path,
await readStableSource(root, path),
] as const)));
}async function readStableSource(
root: string,
repositoryPath: string,
): Promise<string | undefined> {
const requested = resolve(root, repositoryPath);
const rel = relative(root, requested);
if (
isAbsolute(repositoryPath) ||
rel === ".." ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel)
) {
throw new Error(`Source path escapes the repository: ${repositoryPath}`);
}
let actual: string;
try {
actual = await realpath(requested);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return undefined;
}
throw error;
}
if (actual !== requested) {
throw new Error(`Source path must not resolve through a symlink: ${repositoryPath}`);
}
const initial = await stat(requested);
if (!initial.isFile()) {
throw new Error(`Source path must be a regular file: ${repositoryPath}`);
}
const handle = await open(
requested,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const opened = await handle.stat();
if (
!opened.isFile() ||
opened.dev !== initial.dev ||
opened.ino !== initial.ino
) {
throw new Error(`Source changed before it was read: ${repositoryPath}`);
}
const contents = await handle.readFile("utf8");
const openedAfter = await handle.stat();
const currentActual = await realpath(requested);
const current = await stat(requested);
if (
currentActual !== requested ||
!current.isFile() ||
current.dev !== opened.dev ||
current.ino !== opened.ino ||
openedAfter.size !== opened.size ||
openedAfter.mtimeMs !== opened.mtimeMs ||
openedAfter.ctimeMs !== opened.ctimeMs
) {
throw new Error(`Source changed while it was read: ${repositoryPath}`);
}
return contents;
} finally {
await handle.close();
}
}source-traced: Validate anchors against the snapshot
validateStory supplies the captured-map loader to resolveStoryDocument, preventing different anchors from silently reading different snapshots.
export async function validateStory(
rootInput: string,
documentInput: string,
): Promise<ResolvedStoryDocument> {
const root = resolve(rootInput);
const { documentAbsolute, documentPath } = repositoryStoryPath(
root,
documentInput,
);
const document = parseStoryDocument(
await readFile(documentAbsolute, "utf8"),
documentPath,
);
const source = await repositoryState(root);
const snapshot = await captureSourceSnapshot(
root,
document.anchors.map((anchor) => anchor.path),
);
const resolved = await resolveStoryDocument(
root,
document,
documentPath,
{ revision: source.revision, dirty: source.dirty },
async (path) => snapshot.get(path),
);
await assertSourceSnapshot(root, snapshot);
const finalSource = await repositoryState(root);
if (
finalSource.revision !== source.revision ||
finalSource.dirty !== source.dirty ||
finalSource.fingerprint !== source.fingerprint
) {
throw new Error(
`${documentPath}: repository changed while validating; retry`,
);
}
return resolved;
}export async function resolveStoryDocument(
root: string,
document: StoryDocument,
documentPath: string,
source: { revision: string; dirty: boolean },
loadSource?: (path: string) => Promise<string | undefined>,
): Promise<ResolvedStoryDocument> {
const repositoryRoot =
loadSource === undefined ? await realpath(root) : resolve(root);
const anchors: ResolvedSourceAnchor[] = [];
for (const anchor of document.anchors) {
assertRepositoryPath(root, documentPath, anchor);
const contents = loadSource === undefined
? await readRepositorySource(repositoryRoot, documentPath, anchor)
: await loadSource(anchor.path);
if (contents === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-file",
`source file "${anchor.path}" does not exist`,
);
}
anchors.push(await resolveAnchor(documentPath, anchor, contents));
}
return { document, documentPath, repositoryRoot, source, anchors };
}source-traced: Load this anchor's file
resolveStoryDocument invokes the supplied loader for each anchor. This is a local map read.
export async function validateStory(
rootInput: string,
documentInput: string,
): Promise<ResolvedStoryDocument> {
const root = resolve(rootInput);
const { documentAbsolute, documentPath } = repositoryStoryPath(
root,
documentInput,
);
const document = parseStoryDocument(
await readFile(documentAbsolute, "utf8"),
documentPath,
);
const source = await repositoryState(root);
const snapshot = await captureSourceSnapshot(
root,
document.anchors.map((anchor) => anchor.path),
);
const resolved = await resolveStoryDocument(
root,
document,
documentPath,
{ revision: source.revision, dirty: source.dirty },
async (path) => snapshot.get(path),
);
await assertSourceSnapshot(root, snapshot);
const finalSource = await repositoryState(root);
if (
finalSource.revision !== source.revision ||
finalSource.dirty !== source.dirty ||
finalSource.fingerprint !== source.fingerprint
) {
throw new Error(
`${documentPath}: repository changed while validating; retry`,
);
}
return resolved;
}export async function resolveStoryDocument(
root: string,
document: StoryDocument,
documentPath: string,
source: { revision: string; dirty: boolean },
loadSource?: (path: string) => Promise<string | undefined>,
): Promise<ResolvedStoryDocument> {
const repositoryRoot =
loadSource === undefined ? await realpath(root) : resolve(root);
const anchors: ResolvedSourceAnchor[] = [];
for (const anchor of document.anchors) {
assertRepositoryPath(root, documentPath, anchor);
const contents = loadSource === undefined
? await readRepositorySource(repositoryRoot, documentPath, anchor)
: await loadSource(anchor.path);
if (contents === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-file",
`source file "${anchor.path}" does not exist`,
);
}
anchors.push(await resolveAnchor(documentPath, anchor, contents));
}
return { document, documentPath, repositoryRoot, source, anchors };
}source-traced: Source bytes or absence
Absent source is rejected as missing-file before resolveAnchor proceeds.
export async function resolveStoryDocument(
root: string,
document: StoryDocument,
documentPath: string,
source: { revision: string; dirty: boolean },
loadSource?: (path: string) => Promise<string | undefined>,
): Promise<ResolvedStoryDocument> {
const repositoryRoot =
loadSource === undefined ? await realpath(root) : resolve(root);
const anchors: ResolvedSourceAnchor[] = [];
for (const anchor of document.anchors) {
assertRepositoryPath(root, documentPath, anchor);
const contents = loadSource === undefined
? await readRepositorySource(repositoryRoot, documentPath, anchor)
: await loadSource(anchor.path);
if (contents === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-file",
`source file "${anchor.path}" does not exist`,
);
}
anchors.push(await resolveAnchor(documentPath, anchor, contents));
}
return { document, documentPath, repositoryRoot, source, anchors };
}source-traced: If hash passes: parse and scope
A supplied digest is checked first. Parse the source and locate the requested symbol when present; otherwise retain whole-file scope.
async function resolveAnchor(
documentPath: string,
anchor: SourceAnchor,
contents: string,
): Promise<ResolvedSourceAnchor> {
if (anchor.sha256 !== undefined && createHash("sha256").update(contents).digest("hex") !== anchor.sha256) {
throw anchorError(documentPath, anchor.id, "stale-source", `source "${anchor.path}" changed; inspect the change and deliberately update its digest, preserving anchor identity`);
}
const sourceFile = ts.createSourceFile(
anchor.path,
contents,
ts.ScriptTarget.Latest,
true,
);
let start = 0;
let end = contents.length;
if (anchor.symbol !== undefined) {
const language = anchor.language ?? (anchor.path.endsWith(".rs") ? "rust" : "typescript");
if (language === "text") throw anchorError(documentPath, anchor.id, "unsupported-symbol", "text anchors support exact patterns, not compiler symbols");
const rust = language === "rust" ? await resolveRustSymbol(anchor.path, contents, anchor.symbol) : undefined;
const symbol = rust === undefined ? findSymbolRange(sourceFile, anchor.symbol) : {
start: sourceFile.getPositionOfLineAndCharacter(rust.startLine - 1, rust.startColumn - 1),
end: sourceFile.getPositionOfLineAndCharacter(rust.endLine - 1, rust.endColumn - 1),
};
if (symbol === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-symbol",
`symbol "${anchor.symbol}" was not found in "${anchor.path}"`,
);
}
start = symbol.start;
end = symbol.end;
}
if (anchor.pattern !== undefined) {
const scope = contents.slice(start, end);
const offset = scope.indexOf(anchor.pattern);
if (offset < 0) {
throw anchorError(
documentPath,
anchor.id,
"missing-pattern",
anchor.symbol === undefined
? `pattern "${anchor.pattern}" was not found in "${anchor.path}"`
: `pattern "${anchor.pattern}" was not found within symbol "${anchor.symbol}" in "${anchor.path}"`,
);
}
if (
anchor.symbol === undefined &&
scope.indexOf(anchor.pattern, offset + 1) >= 0
) {
throw anchorError(
documentPath,
anchor.id,
"ambiguous-pattern",
`pattern "${anchor.pattern}" occurs more than once in "${anchor.path}"`,
);
}
start += offset;
end = start + anchor.pattern.length;
}
const startLine = sourceFile.getLineAndCharacterOfPosition(start).line + 1;
const endLine = sourceFile.getLineAndCharacterOfPosition(
Math.max(start, end - 1),
).line + 1;
return {
...anchor,
location: { startLine, endLine },
excerpt: contents.slice(start, end),
};
}function findSymbolRange(
sourceFile: ts.SourceFile,
symbol: string,
): { start: number; end: number } | undefined {
let result: ts.Node | undefined;
const visit = (node: ts.Node): void => {
if (result !== undefined) return;
if (declarationName(node) === symbol) {
result = node;
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return result === undefined
? undefined
: {
start: result.getStart(sourceFile),
end: result.getEnd(),
};
}export async function resolveRustSymbol(path: string, contents: string, symbol: string): Promise<SourceLocation> {
const contribution = await rustContribution({ root: "", files: [{ path, contents, sha256: hash(contents) }] });
const candidates = contribution.entities.filter((entity) => entity.name === symbol || entity.qualifiedName === symbol);
if (candidates.length !== 1) throw new Error(`Rust symbol "${symbol}" in "${path}" is ${candidates.length ? "ambiguous; use a lexical qualifiedName" : "missing"}`);
if (contribution.unresolved.some((item) => item.kind === "syntax-error")) throw new Error(`Rust source "${path}" has parser recovery; use an exact pattern after repairing it`);
return candidates[0]!.location;
}source-traced: Syntax and optional symbol range
The resolver applies the exact pattern inside that scope and derives the resulting excerpt and line positions.
async function resolveAnchor(
documentPath: string,
anchor: SourceAnchor,
contents: string,
): Promise<ResolvedSourceAnchor> {
if (anchor.sha256 !== undefined && createHash("sha256").update(contents).digest("hex") !== anchor.sha256) {
throw anchorError(documentPath, anchor.id, "stale-source", `source "${anchor.path}" changed; inspect the change and deliberately update its digest, preserving anchor identity`);
}
const sourceFile = ts.createSourceFile(
anchor.path,
contents,
ts.ScriptTarget.Latest,
true,
);
let start = 0;
let end = contents.length;
if (anchor.symbol !== undefined) {
const language = anchor.language ?? (anchor.path.endsWith(".rs") ? "rust" : "typescript");
if (language === "text") throw anchorError(documentPath, anchor.id, "unsupported-symbol", "text anchors support exact patterns, not compiler symbols");
const rust = language === "rust" ? await resolveRustSymbol(anchor.path, contents, anchor.symbol) : undefined;
const symbol = rust === undefined ? findSymbolRange(sourceFile, anchor.symbol) : {
start: sourceFile.getPositionOfLineAndCharacter(rust.startLine - 1, rust.startColumn - 1),
end: sourceFile.getPositionOfLineAndCharacter(rust.endLine - 1, rust.endColumn - 1),
};
if (symbol === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-symbol",
`symbol "${anchor.symbol}" was not found in "${anchor.path}"`,
);
}
start = symbol.start;
end = symbol.end;
}
if (anchor.pattern !== undefined) {
const scope = contents.slice(start, end);
const offset = scope.indexOf(anchor.pattern);
if (offset < 0) {
throw anchorError(
documentPath,
anchor.id,
"missing-pattern",
anchor.symbol === undefined
? `pattern "${anchor.pattern}" was not found in "${anchor.path}"`
: `pattern "${anchor.pattern}" was not found within symbol "${anchor.symbol}" in "${anchor.path}"`,
);
}
if (
anchor.symbol === undefined &&
scope.indexOf(anchor.pattern, offset + 1) >= 0
) {
throw anchorError(
documentPath,
anchor.id,
"ambiguous-pattern",
`pattern "${anchor.pattern}" occurs more than once in "${anchor.path}"`,
);
}
start += offset;
end = start + anchor.pattern.length;
}
const startLine = sourceFile.getLineAndCharacterOfPosition(start).line + 1;
const endLine = sourceFile.getLineAndCharacterOfPosition(
Math.max(start, end - 1),
).line + 1;
return {
...anchor,
location: { startLine, endLine },
excerpt: contents.slice(start, end),
};
}function findSymbolRange(
sourceFile: ts.SourceFile,
symbol: string,
): { start: number; end: number } | undefined {
let result: ts.Node | undefined;
const visit = (node: ts.Node): void => {
if (result !== undefined) return;
if (declarationName(node) === symbol) {
result = node;
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return result === undefined
? undefined
: {
start: result.getStart(sourceFile),
end: result.getEnd(),
};
}export async function resolveRustSymbol(path: string, contents: string, symbol: string): Promise<SourceLocation> {
const contribution = await rustContribution({ root: "", files: [{ path, contents, sha256: hash(contents) }] });
const candidates = contribution.entities.filter((entity) => entity.name === symbol || entity.qualifiedName === symbol);
if (candidates.length !== 1) throw new Error(`Rust symbol "${symbol}" in "${path}" is ${candidates.length ? "ambiguous; use a lexical qualifiedName" : "missing"}`);
if (contribution.unresolved.some((item) => item.kind === "syntax-error")) throw new Error(`Rust source "${path}" has parser recovery; use an exact pattern after repairing it`);
return candidates[0]!.location;
}source-traced: Resolved anchors or explicit refusal
Success returns resolved evidence; hash/symbol/pattern failures throw rather than manufacturing an anchor.
export async function resolveStoryDocument(
root: string,
document: StoryDocument,
documentPath: string,
source: { revision: string; dirty: boolean },
loadSource?: (path: string) => Promise<string | undefined>,
): Promise<ResolvedStoryDocument> {
const repositoryRoot =
loadSource === undefined ? await realpath(root) : resolve(root);
const anchors: ResolvedSourceAnchor[] = [];
for (const anchor of document.anchors) {
assertRepositoryPath(root, documentPath, anchor);
const contents = loadSource === undefined
? await readRepositorySource(repositoryRoot, documentPath, anchor)
: await loadSource(anchor.path);
if (contents === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-file",
`source file "${anchor.path}" does not exist`,
);
}
anchors.push(await resolveAnchor(documentPath, anchor, contents));
}
return { document, documentPath, repositoryRoot, source, anchors };
}async function resolveAnchor(
documentPath: string,
anchor: SourceAnchor,
contents: string,
): Promise<ResolvedSourceAnchor> {
if (anchor.sha256 !== undefined && createHash("sha256").update(contents).digest("hex") !== anchor.sha256) {
throw anchorError(documentPath, anchor.id, "stale-source", `source "${anchor.path}" changed; inspect the change and deliberately update its digest, preserving anchor identity`);
}
const sourceFile = ts.createSourceFile(
anchor.path,
contents,
ts.ScriptTarget.Latest,
true,
);
let start = 0;
let end = contents.length;
if (anchor.symbol !== undefined) {
const language = anchor.language ?? (anchor.path.endsWith(".rs") ? "rust" : "typescript");
if (language === "text") throw anchorError(documentPath, anchor.id, "unsupported-symbol", "text anchors support exact patterns, not compiler symbols");
const rust = language === "rust" ? await resolveRustSymbol(anchor.path, contents, anchor.symbol) : undefined;
const symbol = rust === undefined ? findSymbolRange(sourceFile, anchor.symbol) : {
start: sourceFile.getPositionOfLineAndCharacter(rust.startLine - 1, rust.startColumn - 1),
end: sourceFile.getPositionOfLineAndCharacter(rust.endLine - 1, rust.endColumn - 1),
};
if (symbol === undefined) {
throw anchorError(
documentPath,
anchor.id,
"missing-symbol",
`symbol "${anchor.symbol}" was not found in "${anchor.path}"`,
);
}
start = symbol.start;
end = symbol.end;
}
if (anchor.pattern !== undefined) {
const scope = contents.slice(start, end);
const offset = scope.indexOf(anchor.pattern);
if (offset < 0) {
throw anchorError(
documentPath,
anchor.id,
"missing-pattern",
anchor.symbol === undefined
? `pattern "${anchor.pattern}" was not found in "${anchor.path}"`
: `pattern "${anchor.pattern}" was not found within symbol "${anchor.symbol}" in "${anchor.path}"`,
);
}
if (
anchor.symbol === undefined &&
scope.indexOf(anchor.pattern, offset + 1) >= 0
) {
throw anchorError(
documentPath,
anchor.id,
"ambiguous-pattern",
`pattern "${anchor.pattern}" occurs more than once in "${anchor.path}"`,
);
}
start += offset;
end = start + anchor.pattern.length;
}
const startLine = sourceFile.getLineAndCharacterOfPosition(start).line + 1;
const endLine = sourceFile.getLineAndCharacterOfPosition(
Math.max(start, end - 1),
).line + 1;
return {
...anchor,
location: { startLine, endLine },
excerpt: contents.slice(start, end),
};
}source-traced: Confirm draft evidence is current
After resolution, validateStory rereads the captured paths, then compares repository revision, dirty state and fingerprint before returning.
export async function validateStory(
rootInput: string,
documentInput: string,
): Promise<ResolvedStoryDocument> {
const root = resolve(rootInput);
const { documentAbsolute, documentPath } = repositoryStoryPath(
root,
documentInput,
);
const document = parseStoryDocument(
await readFile(documentAbsolute, "utf8"),
documentPath,
);
const source = await repositoryState(root);
const snapshot = await captureSourceSnapshot(
root,
document.anchors.map((anchor) => anchor.path),
);
const resolved = await resolveStoryDocument(
root,
document,
documentPath,
{ revision: source.revision, dirty: source.dirty },
async (path) => snapshot.get(path),
);
await assertSourceSnapshot(root, snapshot);
const finalSource = await repositoryState(root);
if (
finalSource.revision !== source.revision ||
finalSource.dirty !== source.dirty ||
finalSource.fingerprint !== source.fingerprint
) {
throw new Error(
`${documentPath}: repository changed while validating; retry`,
);
}
return resolved;
}export async function assertSourceSnapshot(
root: string,
snapshot: SourceSnapshot,
): Promise<void> {
const current = await captureSourceSnapshot(root, snapshot.keys());
for (const [path, contents] of snapshot) {
if (current.get(path) !== contents) {
throw new Error(`Repository source changed while rendering: ${path}`);
}
}
}source-traced: Unchanged source or stale error
Changed bytes refuse validation; the validator also rejects changed Git state. This operation does not publish output.
export async function validateStory(
rootInput: string,
documentInput: string,
): Promise<ResolvedStoryDocument> {
const root = resolve(rootInput);
const { documentAbsolute, documentPath } = repositoryStoryPath(
root,
documentInput,
);
const document = parseStoryDocument(
await readFile(documentAbsolute, "utf8"),
documentPath,
);
const source = await repositoryState(root);
const snapshot = await captureSourceSnapshot(
root,
document.anchors.map((anchor) => anchor.path),
);
const resolved = await resolveStoryDocument(
root,
document,
documentPath,
{ revision: source.revision, dirty: source.dirty },
async (path) => snapshot.get(path),
);
await assertSourceSnapshot(root, snapshot);
const finalSource = await repositoryState(root);
if (
finalSource.revision !== source.revision ||
finalSource.dirty !== source.dirty ||
finalSource.fingerprint !== source.fingerprint
) {
throw new Error(
`${documentPath}: repository changed while validating; retry`,
);
}
return resolved;
}export async function assertSourceSnapshot(
root: string,
snapshot: SourceSnapshot,
): Promise<void> {
const current = await captureSourceSnapshot(root, snapshot.keys());
for (const [path, contents] of snapshot) {
if (current.get(path) !== contents) {
throw new Error(`Repository source changed while rendering: ${path}`);
}
}
}