// 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; } 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_.ts (non-data) // should have a sibling cXX_.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(), }; };