syntaxai/tdd.md · main · src / b32_sama_verify.ts
// c32 — logic: pure SAMA verification given a repo's file tree and the
// contents of every cXX_*.ts file. Drives /sama/verify.
//
// Verifier is intentionally strict: a check passes iff there is zero
// evidence of violation. The four properties (S/A/M/A) each become one
// callable, and the top-level `verifySama(...)` runs them all and
// returns a SamaReport.
export interface SamaViolation {
file: string;
detail: string;
}
export interface SamaCheckResult {
letter: "S" | "A" | "M" | "A";
property: "Sorted" | "Architecture" | "Modeled" | "Atomic";
passed: boolean;
examined: number;
violations: SamaViolation[];
note?: string;
}
export interface SamaReport {
repoSlug: string;
defaultBranch: string;
totalSrcFiles: number;
samaFiles: number;
testFiles: number;
checks: SamaCheckResult[];
overallPassed: boolean;
generatedAt: number;
}
export interface SamaVerifyInput {
repoOwner: string;
repoName: string;
defaultBranch: string;
// src-relative paths, e.g. "c21_app.ts", "c31_blog.ts", "c32_session.test.ts"
srcPaths: string[];
// file path -> content. Contents only required for cXX_*.ts files
// and *.test.ts files.
contents: Map<string, string>;
}
const SAMA_PREFIX = /^c(\d{2})_/;
// Strip JS string literals and comments from source, preserving
// position/length by replacing each character with whitespace. This
// is the cheapest reliable fix for the test-fixture false-positive:
// import strings and `test(...)` patterns inside literals/comments
// would otherwise trigger Sorted/Atomic violations.
export const stripStringsAndComments = (src: string): string => {
let out = "";
let i = 0;
while (i < src.length) {
const c = src[i];
const n = src[i + 1];
if (c === "/" && n === "/") {
out += " ";
i += 2;
while (i < src.length && src[i] !== "\n") {
out += " ";
i++;
}
} else if (c === "/" && n === "*") {
out += " ";
i += 2;
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
out += src[i] === "\n" ? "\n" : " ";
i++;
}
out += " ";
i += 2;
} else if (c === '"' || c === "'" || c === "`") {
const quote = c;
out += " ";
i++;
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && i + 1 < src.length) {
out += " ";
i += 2;
continue;
}
out += src[i] === "\n" ? "\n" : " ";
i++;
}
out += " ";
i++;
} else {
out += c ?? "";
i++;
}
}
return out;
};
const isSamaFile = (p: string): boolean => SAMA_PREFIX.test(p) && p.endsWith(".ts");
const isTestFile = (p: string): boolean => p.endsWith(".test.ts");
const layerOf = (filename: string): number | null => {
const m = SAMA_PREFIX.exec(filename);
if (!m) return null;
return parseInt(m[1] ?? "0", 10);
};
// Pull import targets out of a TypeScript source. Recognizes both
// static `import ... from "./x.ts"` and dynamic `import("./x.ts")`.
// We only care about relative imports (the cross-layer ones). We
// scan against a stripped source (string literals + comments
// blanked out) so test fixtures that quote import statements as
// data don't cause false positives.
const collectRelativeImports = (source: string): string[] => {
const out: string[] = [];
// Match against the original source so the captured import path
// text is preserved; but only accept matches whose start position
// is NOT inside a string-literal/comment region (we test that by
// checking the stripped source's character at the path-open quote).
const stripped = stripStringsAndComments(source);
const staticRe = /\bfrom\s+(["'])\s*(\.\/[^"']+)\1/g;
const dynRe = /\bimport\s*\(\s*(["'])\s*(\.\/[^"']+)\1/g;
let m: RegExpExecArray | null;
const pushIfReal = (mm: RegExpExecArray, pathIdx: number) => {
// Check the start of the match (the `f` of `from` or `i` of
// `import`). If that keyword is inside a string literal/comment
// in the original source, the stripped version replaces it with
// whitespace and we skip the match. The PATH itself is always
// inside quotes (that's how imports are written), so we never
// gate on the path's position — only the keyword's.
if (stripped[mm.index] === " " || stripped[mm.index] === "\n") return;
out.push(mm[pathIdx]!);
};
while ((m = staticRe.exec(source)) !== null) pushIfReal(m, 2);
while ((m = dynRe.exec(source)) !== null) pushIfReal(m, 2);
return out;
};
const importTargetFilename = (importPath: string): string => {
// "./c14_github.ts" -> "c14_github.ts"
return importPath.replace(/^\.\//, "");
};
// S — Sorted. The rule, as practiced: foundation, data and logic layers
// (c1*, c3*) don't import UI (c5*+). c21 (handlers/composers) is the
// orchestration layer and is allowed to import anything; c51 (UI) is
// allowed to import models (c3*) for the data it renders. A strict
// "lower never imports higher" reading would forbid c21 → c31, which
// is the natural pattern (handler composes model). The actual
// constraint is one-directional: UI sits at the edge, never below.
const checkSorted = (input: SamaVerifyInput): SamaCheckResult => {
const violations: SamaViolation[] = [];
let examined = 0;
for (const path of input.srcPaths) {
if (!isSamaFile(path)) continue;
examined++;
const m = SAMA_PREFIX.exec(path);
const prefix = m?.[1] ?? "";
// Skip c2* (handlers, allowed to depend on anything) and c5*+ (UI,
// its outbound deps are governed by other rules, not this one).
if (!/^[13]/.test(prefix)) continue;
const content = input.contents.get(path);
if (!content) continue;
for (const rawImport of collectRelativeImports(content)) {
const target = importTargetFilename(rawImport);
const targetMatch = SAMA_PREFIX.exec(target);
const targetPrefix = targetMatch?.[1] ?? "";
if (!targetPrefix) continue;
if (/^[59]/.test(targetPrefix)) {
violations.push({
file: path,
detail: `imports \`${target}\` (UI layer c${targetPrefix}_) from a non-UI/non-handler file (c${prefix}_) — UI sits at the edge, foundation/data/logic must not depend on it`,
});
}
}
}
return {
letter: "S",
property: "Sorted",
passed: violations.length === 0,
examined,
violations,
note: examined === 0
? "no cXX_*.ts files found in the project — the convention isn't applied here"
: undefined,
};
};
// A — Architecture. Each prefix is a known layer; flag unknown prefixes.
const KNOWN_LAYERS = new Set(["11", "13", "14", "21", "31", "32", "51"]);
const checkArchitecture = (input: SamaVerifyInput): SamaCheckResult => {
const violations: SamaViolation[] = [];
let examined = 0;
for (const path of input.srcPaths) {
if (!isSamaFile(path)) continue;
examined++;
const m = SAMA_PREFIX.exec(path);
const prefix = m?.[1] ?? "";
if (!KNOWN_LAYERS.has(prefix)) {
violations.push({
file: path,
detail: `unknown layer prefix \`c${prefix}_\` (known: c11, c13, c14, c21, c31, c32, c51)`,
});
}
}
return {
letter: "A",
property: "Architecture",
passed: violations.length === 0,
examined,
violations,
};
};
// M — Modeled. Tests live next to source. Every cXX_<name>.ts (non-data)
// should have a sibling cXX_<name>.test.ts. Pure data files (registries
// like c31_blog.ts that are just an exported array) often legitimately
// have no behaviour to test, so we soften this check by requiring a
// sibling for c32_*.ts (logic) at minimum, and reporting a list of c31
// files without siblings as informational rather than hard violations.
const checkModeled = (input: SamaVerifyInput): SamaCheckResult => {
const violations: SamaViolation[] = [];
const informational: SamaViolation[] = [];
let examined = 0;
const present = new Set(input.srcPaths);
for (const path of input.srcPaths) {
if (!isSamaFile(path) || isTestFile(path)) continue;
examined++;
const sibling = path.replace(/\.ts$/, ".test.ts");
if (present.has(sibling)) continue;
const layer = layerOf(path);
if (layer === 32) {
violations.push({ file: path, detail: `no sibling test file at \`${sibling}\`` });
} else if (layer === 31) {
informational.push({ file: path, detail: `no sibling test (often fine for pure data registries; flag if logic accumulates)` });
}
}
const passed = violations.length === 0;
const note = informational.length > 0
? `${informational.length} c31_* file${informational.length === 1 ? "" : "s"} without a sibling test — usually fine for pure-data registries, flag if logic accumulates: ${informational.map((v) => v.file).join(", ")}`
: undefined;
return {
letter: "M",
property: "Modeled",
passed,
examined,
violations,
note,
};
};
// A — Atomic. ~700-line split rule. Flag any cXX_*.ts over 700 lines.
// Also flag placeholder tests (zero expect() calls in test body) as
// part of the same pass — they're a structural violation of the
// testing surface that Atomic owns.
const findPlaceholderTestsLite = (file: string, content: string): SamaViolation[] => {
const out: SamaViolation[] = [];
// Same string/comment-aware trick as collectRelativeImports: only
// count test()/it() calls whose `test`/`it` keyword is real code,
// not a literal in a fixture.
const stripped = stripStringsAndComments(content);
const re = /\b(test|it)\s*\(\s*(["'`])((?:\\.|(?!\2).)*)\2\s*,\s*(?:async\s+)?(?:\([^)]*\)|[^=()]*?)\s*=>\s*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
// Skip matches whose `test`/`it` keyword is inside a string literal
// or comment (the stripped version replaces those with whitespace).
if (stripped[m.index] === " " || stripped[m.index] === "\n") continue;
const name = m[3] ?? "";
const startBrace = re.lastIndex - 1;
let depth = 1;
let i = startBrace + 1;
let inString: string | null = null;
while (i < content.length && depth > 0) {
const c = content[i];
if (inString !== null) {
if (c === "\\") { i += 2; continue; }
if (c === inString) inString = null;
} else {
if (c === '"' || c === "'" || c === "`") inString = c;
else if (c === "/" && content[i + 1] === "/") {
while (i < content.length && content[i] !== "\n") i++;
continue;
} else if (c === "/" && content[i + 1] === "*") {
i += 2;
while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
i += 2;
continue;
} else if (c === "{") depth++;
else if (c === "}") depth--;
}
i++;
}
const body = content.slice(startBrace + 1, i - 1);
const expectCount = (body.match(/\bexpect\s*\(/g) ?? []).length;
if (expectCount === 0) {
out.push({ file, detail: `placeholder test \`${name}\` — zero \`expect()\` calls` });
}
}
return out;
};
const checkAtomic = (input: SamaVerifyInput): SamaCheckResult => {
const violations: SamaViolation[] = [];
let examined = 0;
for (const path of input.srcPaths) {
if (!isSamaFile(path)) continue;
examined++;
const content = input.contents.get(path);
if (!content) continue;
const lineCount = content.split("\n").length;
if (lineCount > 700) {
violations.push({
file: path,
detail: `${lineCount} lines (over the 700-line split threshold — split per UI/data domain)`,
});
}
if (isTestFile(path)) {
violations.push(...findPlaceholderTestsLite(path, content));
}
}
return {
letter: "A",
property: "Atomic",
passed: violations.length === 0,
examined,
violations,
};
};
export const verifySama = (input: SamaVerifyInput): SamaReport => {
const samaPaths = input.srcPaths.filter(isSamaFile);
const testPaths = samaPaths.filter(isTestFile);
const checks = [
checkSorted(input),
checkArchitecture(input),
checkModeled(input),
checkAtomic(input),
];
return {
repoSlug: `${input.repoOwner}/${input.repoName}`,
defaultBranch: input.defaultBranch,
totalSrcFiles: input.srcPaths.length,
samaFiles: samaPaths.length,
testFiles: testPaths.length,
checks,
overallPassed: checks.every((c) => c.passed),
generatedAt: Date.now(),
};
};