kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
297 lines (269 loc) • 12.8 kB
text/typescript
/**
* # doc fences — the documentation-integrity extractor + classifier (kestrel-gsh.1)
*
* Every Kestrel code fence that ships in Markdown is a *product assertion*: prose that
* contains syntax is an executable claim, and a claim that no longer parses is a lie the
* repo tells its readers. This module makes that claim testable. It extracts fences from
* the SOURCE docs themselves (never a copied fixture), reads a small metadata contract off
* each fence's info string, and classifies every fence into exactly one role so the CI
* checker (see `tests/docs.fences.test.ts`) can hold each to the right invariant:
*
* - **example** — an accepted fence (` ```kestrel id=… [check=parse|roundtrip] `). Must
* parse with the REAL parser and print stably; `check=roundtrip` additionally demands the
* source text already be byte-canonical (`print(parse(text)) === text`, ADR-0004).
* - **proposed** — aspirational / reference notation not yet executable (grammar skeletons
* with `<placeholders>`). Visibly non-`kestrel` fence + `role=proposed id=…`. Asserted to
* FAIL closed, so the doc can never quietly imply the parser accepts sugar it does not.
* - **reject** — a diagnostic "this should fail" fence (`role=reject id=… expect="frag"`).
* Asserted to raise a {@link KestrelParseError}; the required `expect` pins the doctrine in
* the message (a reject with no `expect` is a structural error — assert *which* rule fails).
*
* The classifier fails closed itself: an unlabeled ` ```kestrel ` fence, a duplicate id, or
* an unknown role/check is a structural error the checker surfaces — no accepted fence may
* escape metadata, and no metadata may be silently ignored.
*
* Only the language surface is consumed here (`src/lang`); nothing in this module is on the
* runtime path and it never mutates the parser/printer.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Repo root, derived from this file's location. `fileURLToPath(import.meta.url)`, NOT
* `import.meta.dir`: the latter is a Bun-ism that `bun build --target=node` emits verbatim and node
* evaluates to `undefined`, turning any `join()` over it into a raw TypeError (kestrel-mkn review B1).
*/
export const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
/** A fenced code block lifted verbatim from a source doc, with its parsed info string. */
export interface RawFence {
/** Repo-relative path of the source file (e.g. `README.md`). */
readonly file: string;
/** 1-based line of the opening fence, for legible diagnostics. */
readonly startLine: number;
/** The info-string language token (`kestrel`, `text`, `mermaid`, …); `""` if absent. */
readonly lang: string;
/** `key=value` attributes parsed off the info string after the language token. */
readonly attrs: Readonly<Record<string, string>>;
/** The block's body, exactly as written (no trailing newline of the closing fence). */
readonly body: string;
}
/** The three roles every managed fence resolves to. */
export type FenceRole = "example" | "proposed" | "reject";
/** The canonical check an accepted fence must pass. */
export type CheckMode = "parse" | "roundtrip";
/** A classified, metadata-bearing fence ready for the checker. */
export interface ManagedFence extends RawFence {
readonly id: string;
readonly role: FenceRole;
/** Only meaningful for `role === "example"`; defaults to `"parse"`. */
readonly check: CheckMode;
/** Only for `role === "reject"`: a required substring of the failure message. */
readonly expect?: string;
}
// ── Extraction ────────────────────────────────────────────────────────────────
const FENCE_OPEN = /^(\s*)(`{3,})(.*)$/;
const ATTR = /([A-Za-z][\w-]*)=(?:"([^"]*)"|(\S+))/g;
/** Parse an info string (`kestrel id=x check=parse`) into its language + attributes. */
function parseInfo(info: string): { lang: string; attrs: Record<string, string> } {
const trimmed = info.trim();
const langMatch = /^([^\s]*)/.exec(trimmed);
const lang = langMatch ? langMatch[1]! : "";
const rest = trimmed.slice(lang.length);
const attrs: Record<string, string> = {};
for (const m of rest.matchAll(ATTR)) attrs[m[1]!] = m[2] ?? m[3]!;
return { lang, attrs };
}
/**
* Extract every fenced code block from a Markdown source. A block opens on a line of three
* or more backticks and closes on a line of at least as many backticks with no info string;
* the info string on the opening line is parsed for the language and `key=value` attributes.
*/
export function extractFences(markdown: string, file: string): RawFence[] {
const lines = markdown.split("\n");
const out: RawFence[] = [];
let i = 0;
while (i < lines.length) {
const open = FENCE_OPEN.exec(lines[i]!);
if (open === undefined || open === null) {
i += 1;
continue;
}
// A backtick fence's info string may not contain backticks (CommonMark §4.5). A line
// like ` ```` ```kestrel ```` blocks …` is an *inline* code span, not a fence open;
// treating it as one would silently swallow the rest of the file as this "fence"'s body,
// dropping any real fence after it from coverage. Fail closed: skip such non-openers.
if (open[3]!.includes("`")) {
i += 1;
continue;
}
const ticks = open[2]!;
const { lang, attrs } = parseInfo(open[3]!);
const startLine = i + 1;
const bodyLines: string[] = [];
let j = i + 1;
let closed = false;
for (; j < lines.length; j += 1) {
const line = lines[j]!;
const close = /^(\s*)(`{3,})\s*$/.exec(line);
if (close !== null && close[2]!.length >= ticks.length) {
closed = true;
break;
}
bodyLines.push(line);
}
out.push({ file, startLine, lang, attrs, body: bodyLines.join("\n") });
i = closed ? j + 1 : j;
}
return out;
}
// ── Source discovery ────────────────────────────────────────────────────────────
const DOC_EXT = /\.mdx?$/;
/**
* Recursively list `.md`/`.mdx` files under `dir` (repo-relative paths). `required` roots
* fail closed: a governed root that has been deleted/renamed is a coverage hole, not an
* empty set, so a missing root throws rather than silently dropping its fences (a discovery
* fail-open would let the whole reject/proposed corpus vanish from CI unnoticed). Only
* recursive descents are lenient, and only against a mid-walk race (a subdir seen via
* `statSync` then gone), which surfaces as its own entries simply being absent.
*/
function listDocs(root: string, dir: string, required = false): string[] {
let names: string[];
try {
names = readdirSync(join(root, dir));
} catch (e) {
if (required) {
throw new Error(
`governed docs root "${dir}" is missing — it must exist so its fences stay under coverage ` +
`(fail closed, not open). Original error: ${(e as Error).message}`,
);
}
return [];
}
const out: string[] = [];
for (const name of names) {
const rel = join(dir, name);
let isDir = false;
try {
isDir = statSync(join(root, rel)).isDirectory();
} catch {
continue;
}
if (isDir) out.push(...listDocs(root, rel));
else if (DOC_EXT.test(name)) out.push(rel);
}
return out.sort();
}
/**
* The canonical set of source documents the checker governs: `README.md` plus every
* Markdown file under `docs/public/**`. Returns `{ file, text }` for each, in a stable order
* so the extracted fence stream is deterministic.
*/
export function docSources(root: string = REPO_ROOT): { file: string; text: string }[] {
const files = ["README.md", ...listDocs(root, "docs/public", true)];
return files.map((file) => ({ file, text: readFileSync(join(root, file), "utf8") }));
}
// ── Classification (fails closed) ──────────────────────────────────────────────
/** A structural problem with a fence's metadata — surfaced by the checker as a hard error. */
export interface FenceStructError {
readonly file: string;
readonly startLine: number;
readonly message: string;
}
export interface Classified {
readonly managed: ManagedFence[];
readonly errors: FenceStructError[];
}
const ROLES: ReadonlySet<string> = new Set(["example", "proposed", "reject"]);
const CHECKS: ReadonlySet<string> = new Set(["parse", "roundtrip"]);
/**
* Classify raw fences into the managed set, accumulating structural errors instead of
* throwing so the checker can report every offender at once. The rules encode the
* acceptance contract:
*
* - Any ` ```kestrel ` fence is an accepted example and MUST carry a stable `id` — an
* unlabeled one is an error (no accepted fence escapes metadata).
* - A managed non-`kestrel` fence declares its `role` explicitly (`proposed`/`reject`) so
* rejected/proposed syntax is *visibly* classified and excluded from acceptance parsing.
* - `id`s are unique across all sources; unknown `role`/`check` values are errors.
* - A `role=reject` fence must carry an `expect` fragment so every diagnostic pins the
* specific failure it demonstrates (not merely "something threw").
*/
export function classify(raws: RawFence[]): Classified {
const managed: ManagedFence[] = [];
const errors: FenceStructError[] = [];
const seen = new Map<string, RawFence>();
for (const f of raws) {
const at = { file: f.file, startLine: f.startLine };
const hasId = typeof f.attrs["id"] === "string";
const declaredRole = f.attrs["role"];
if (f.lang === "kestrel") {
if (declaredRole !== undefined) {
errors.push({
...at,
message:
`a \`kestrel\` fence is always an accepted example — it may not declare role="${declaredRole}". ` +
`Rejected/proposed syntax must use a non-\`kestrel\` fence language so it is visibly excluded ` +
`(and skipped by parseMarkdown).`,
});
continue;
}
if (!hasId) {
errors.push({
...at,
message: `unlabeled \`kestrel\` fence: every accepted fence needs a stable \`id=…\` (and optional \`check=parse|roundtrip\`).`,
});
continue;
}
} else if (!hasId) {
continue; // unmanaged (mermaid, plain text/frame, prose) — ignored
}
// From here: hasId is true (managed).
const id = f.attrs["id"]!;
const role = (declaredRole ?? "example") as FenceRole;
if (!ROLES.has(role)) {
errors.push({ ...at, message: `fence "${id}" has unknown role="${role}"; expected example, proposed, or reject.` });
continue;
}
const prior = seen.get(id);
if (prior !== undefined) {
errors.push({
...at,
message: `duplicate fence id "${id}" (first seen at ${prior.file}:${prior.startLine}); ids must be globally unique.`,
});
continue;
}
seen.set(id, f);
const rawCheck = f.attrs["check"] ?? "parse";
if (role === "example" && !CHECKS.has(rawCheck)) {
errors.push({ ...at, message: `fence "${id}" has unknown check="${rawCheck}"; expected parse or roundtrip.` });
continue;
}
const expect = f.attrs["expect"];
if (role === "reject" && expect === undefined) {
// A diagnostic fence must pin *which* doctrine it demonstrates; asserting only that
// "something threw" would pass on an incidental typo elsewhere in the block. Fail closed.
errors.push({
...at,
message: `reject fence "${id}" must carry an \`expect="…"\` fragment pinning the required failure-message substring.`,
});
continue;
}
managed.push({
...f,
id,
role,
check: (rawCheck === "roundtrip" ? "roundtrip" : "parse") satisfies CheckMode,
...(expect !== undefined ? { expect } : {}),
});
}
return { managed, errors };
}
/** Extract + classify every governed source in one call (the checker's entry point). */
export function collectManagedFences(root: string = REPO_ROOT): Classified {
const raws = docSources(root).flatMap((s) => extractFences(s.text, s.file));
return classify(raws);
}
/** Human-legible location for a fence, used in test names and failure messages. */
export function loc(f: RawFence): string {
return `${relative(REPO_ROOT, join(REPO_ROOT, f.file))}:${f.startLine}`;
}