syntaxai/tdd.md · main · src / b32_sama_verify.ts

b32_sama_verify.ts 348 lines · 12483 bytes raw
// 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(),
  };
};