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

b32_sama_v2_verify.ts 375 lines · 14771 bytes raw
// b32 — logic: the SAMA v2 verifier. Implements the seven §4
// conformance checks (Sorted, Architecture, Modeled-tests,
// Modeled-boundary, Atomic, the Law §1.2, Consistency §3) as pure
// functions over an in-memory (profile, files) input. Never reads
// the filesystem — the loader (c14_sama_profile + d21 handler)
// populates the input map. The shared pure helpers and the parse-
// boundary detector live in a31_sama_v2 so this verifier and the
// §5 metrics emitter agree by construction.

import {
  PARSE_BOUNDARY_PATTERNS,
  collectRelativeImports,
  declaredLayer,
  findParseBoundaryCallSites,
  isSamaFile,
  isTestFile,
  resolveImport,
  stripStringsAndComments,
  type SamaV2Check,
  type SamaV2Input,
  type SamaV2Report,
  type SamaV2Violation,
} from "./a31_sama_v2.ts";

// — Check 1: Sorted -------------------------------------------------
//
// "Every file carries a profile-recognised prefix; lexicographic
// prefix order equals layer order."
const checkSorted = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  // Collect (prefix, layer) pairs from the profile.
  const pairs: Array<{ prefix: string; layer: number }> = [];
  for (const [k, spec] of Object.entries(input.profile.layers)) {
    const layer = parseInt(k, 10);
    for (const sub of spec.sublayers) pairs.push({ prefix: sub.prefix, layer });
  }
  // For any two prefixes with layer(A) < layer(B), A must lex-sort < B.
  for (let i = 0; i < pairs.length; i++) {
    for (let j = 0; j < pairs.length; j++) {
      if (i === j) continue;
      const a = pairs[i]!;
      const b = pairs[j]!;
      if (a.layer < b.layer && a.prefix > b.prefix) {
        violations.push({
          file: a.prefix,
          detail: `prefix \`${a.prefix}\` (layer ${a.layer}) sorts after \`${b.prefix}\` (layer ${b.layer}) — lex order must equal layer order`,
        });
      }
    }
  }
  // Also count source files whose prefix isn't recognised by any
  // sublayer. They'd be flagged by Architecture too, but the Sorted
  // rule needs each file to have a recognised prefix.
  for (const path of input.files.keys()) {
    if (!isSamaFile(path)) continue;
    examined++;
    if (declaredLayer(path, input.profile) === null) {
      violations.push({ file: path, detail: "no profile-recognised prefix" });
    }
  }
  return {
    id: 1, name: "Sorted", property: "Sorted",
    passed: violations.length === 0, examined, violations,
  };
};

// — Check 2: Architecture -------------------------------------------
//
// "Every file maps to exactly one canonical layer; no file is
// unprefixed or maps to two layers."
const checkArchitecture = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  for (const path of input.files.keys()) {
    if (!isSamaFile(path) && !isTestFile(path)) continue;
    examined++;
    const base = path.split("/").pop() ?? path;
    // Find every profile prefix that matches this filename. Exactly
    // one is required; zero = unprefixed (caught by Sorted too) but
    // we surface it here as the canonical "unmapped" failure.
    const matches: Array<{ layer: number; prefix: string }> = [];
    for (const [k, spec] of Object.entries(input.profile.layers)) {
      const layer = parseInt(k, 10);
      for (const sub of spec.sublayers) {
        if (base.startsWith(sub.prefix)) matches.push({ layer, prefix: sub.prefix });
      }
    }
    if (matches.length === 0) {
      violations.push({ file: path, detail: "unprefixed — does not match any profile prefix" });
    } else if (matches.length > 1) {
      // Two prefixes claim the same file: profile ambiguity.
      const distinctLayers = new Set(matches.map((m) => m.layer));
      if (distinctLayers.size > 1) {
        violations.push({
          file: path,
          detail: `ambiguous — matches multiple layers: ${matches.map((m) => `${m.prefix}→L${m.layer}`).join(", ")}`,
        });
      }
    }
  }
  return {
    id: 2, name: "Architecture", property: "Architecture",
    passed: violations.length === 0, examined, violations,
  };
};

// — Check 3: Modeled (tests) ----------------------------------------
//
// "Every Layer 1 and Layer 2 behavior file has a sibling test file."
const checkModeledTests = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  for (const path of input.files.keys()) {
    if (!isSamaFile(path)) continue;
    const decl = declaredLayer(path, input.profile);
    if (!decl) continue;
    if (decl.layer !== 1 && decl.layer !== 2) continue;
    examined++;
    const siblingPath = path.replace(/\.ts$/, ".test.ts");
    if (!input.files.has(siblingPath)) {
      violations.push({
        file: path,
        detail: `no sibling test at \`${siblingPath}\` — Layer ${decl.layer} requires one`,
      });
    }
  }
  return {
    id: 3, name: "Modeled (tests)", property: "Modeled (tests)",
    passed: violations.length === 0, examined, violations,
  };
};

// — Check 4: Modeled (boundary) -------------------------------------
//
// "External input is parsed only in Layer 2."
//
// §4.4 is profile-dependent (spec §6). Our profile defines boundary
// parsing as `JSON.parse(` of arbitrary input (not constant strings)
// or `new URL(` of arbitrary input — i.e. patterns that turn bytes
// into typed structures. Platform-provided parsers called *through*
// Layer 3 entry handlers (`req.json()`, `req.formData()`, route
// params) are treated as delegation to the platform's own Layer 2,
// not parsing performed in our Layer 3. The verifier reports any
// raw JSON.parse / new URL calls landing outside Layer 2.
//
// The call-site detector lives in a31_sama_v2 (findParseBoundary-
// CallSites). This check consumes its output and groups by
// (file, pattern) so the violation list stays at file-pattern
// granularity — the same shape pre-refactor. The §5 boundaryRatio
// metric consumes the same detector and counts individual call
// sites, but does not change this check's verdict.
const checkModeledBoundary = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;

  // Bucket call sites by file → set of patterns observed.
  const patternsByFile = new Map<string, Set<string>>();
  for (const site of findParseBoundaryCallSites(input.files)) {
    let s = patternsByFile.get(site.file);
    if (!s) { s = new Set(); patternsByFile.set(site.file, s); }
    s.add(site.pattern);
  }

  // Iterate files in input order; emit one violation per (file,
  // pattern) for files outside Layer 2, preserving PARSE_BOUNDARY_-
  // PATTERNS order. This matches the pre-refactor verdict bit-for-bit.
  for (const path of input.files.keys()) {
    if (!isSamaFile(path)) continue;
    const decl = declaredLayer(path, input.profile);
    if (!decl) continue;
    examined++;
    if (decl.layer === 2) continue; // Layer 2 is the legitimate site.
    const observed = patternsByFile.get(path);
    if (!observed) continue;
    for (const pat of PARSE_BOUNDARY_PATTERNS) {
      if (observed.has(pat.name)) {
        violations.push({
          file: path,
          detail: `boundary pattern \`${pat.name}\` found in Layer ${decl.layer} — parsing belongs in Layer 2`,
        });
      }
    }
  }
  return {
    id: 4, name: "Modeled (boundary)", property: "Modeled (boundary)",
    passed: violations.length === 0, examined, violations,
    note: "profile-dependent (spec §4.4): boundary = raw `JSON.parse` / `new URL` outside Layer 2. Platform parsers reached via `req.json()` etc. are treated as delegation to the platform's own Layer 2.",
  };
};

// — Check 5: Atomic -------------------------------------------------
//
// "No file exceeds the line cap (default ~700; profile may lower,
// never raise). No barrel re-export files."
const ATOMIC_LINE_CAP = 700;
const checkAtomic = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  for (const [path, content] of input.files.entries()) {
    if (!isSamaFile(path) && !isTestFile(path)) continue;
    examined++;
    const lines = content.split("\n").length;
    if (lines > ATOMIC_LINE_CAP) {
      violations.push({
        file: path,
        detail: `${lines} lines (over the ${ATOMIC_LINE_CAP}-line cap — split per UI/data domain)`,
      });
    }
    // Barrel detection: a file whose entire body is re-exports.
    // Heuristic: every non-blank, non-comment line is `export ... from`.
    const stripped = stripStringsAndComments(content);
    const codeLines = stripped.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
    if (codeLines.length >= 2 && codeLines.every((l) => /^export\s+(\*|\{)/.test(l) && /\bfrom\b/.test(l))) {
      violations.push({ file: path, detail: "barrel re-export file (all lines are `export … from`)" });
    }
  }
  return {
    id: 5, name: "Atomic", property: "Atomic",
    passed: violations.length === 0, examined, violations,
  };
};

// — Check 6: The Law (§1.2) -----------------------------------------
//
// "Imports always point to a strictly lower layer number — never
// upward, never sideways across a higher number, never cyclic."
//
// Build the import graph from relative-.ts imports, then for each
// edge A → B require: layer(B) < layer(A), OR same layer + B's
// sublayer index <= A's sublayer index. Also run a DFS cycle detector.
const checkLaw = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  // Build adjacency.
  const adj = new Map<string, string[]>();
  for (const [path, content] of input.files.entries()) {
    if (!isSamaFile(path) && !isTestFile(path)) continue;
    examined++;
    const out: string[] = [];
    for (const imp of collectRelativeImports(content)) {
      const resolved = resolveImport(path, imp);
      // Only follow edges into known SAMA files (in-tree, in src/).
      if (input.files.has(resolved)) out.push(resolved);
    }
    adj.set(path, out);
  }
  // Edge-by-edge layer/sublayer check.
  for (const [from, outs] of adj.entries()) {
    const aDecl = declaredLayer(from, input.profile);
    if (!aDecl) continue; // Unmapped — caught by Architecture.
    for (const to of outs) {
      const bDecl = declaredLayer(to, input.profile);
      if (!bDecl) continue;
      if (bDecl.layer < aDecl.layer) continue;     // strictly lower — OK
      if (bDecl.layer > aDecl.layer) {
        violations.push({
          file: from,
          detail: `imports \`${to}\` — Layer ${aDecl.layer} → Layer ${bDecl.layer} (upward, breaks §1.2)`,
        });
        continue;
      }
      // Same layer: sublayer ordering. The import target must be in
      // an earlier-or-equal sublayer slot (spec §2.2: later may import
      // earlier).
      if (bDecl.sublayer.index > aDecl.sublayer.index) {
        violations.push({
          file: from,
          detail: `imports \`${to}\` — same layer ${aDecl.layer} but sublayer order is reversed (${aDecl.sublayer.name} sublayer-index ${aDecl.sublayer.index} → ${bDecl.sublayer.name} sublayer-index ${bDecl.sublayer.index})`,
        });
      }
    }
  }
  // DFS cycle detection on the same graph.
  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Map<string, number>();
  for (const k of adj.keys()) color.set(k, WHITE);
  const cycles: string[][] = [];
  const stack: string[] = [];
  const dfs = (node: string): boolean => {
    color.set(node, GRAY);
    stack.push(node);
    for (const next of adj.get(node) ?? []) {
      const c = color.get(next) ?? WHITE;
      if (c === GRAY) {
        const idx = stack.indexOf(next);
        if (idx !== -1) cycles.push([...stack.slice(idx), next]);
        return true;
      }
      if (c === WHITE && dfs(next)) {
        // bubble up
      }
    }
    stack.pop();
    color.set(node, BLACK);
    return false;
  };
  for (const k of adj.keys()) if (color.get(k) === WHITE) dfs(k);
  for (const cyc of cycles) {
    violations.push({
      file: cyc[0] ?? "(unknown)",
      detail: `import cycle: ${cyc.join(" → ")}`,
    });
  }
  return {
    id: 6, name: "Law (§1.2)", property: "Law",
    passed: violations.length === 0, examined, violations,
  };
};

// — Check 7: Consistency (§3) ---------------------------------------
//
// "Verifier FAILS if a file imports from a layer that its declared
// layer is not permitted to import." This is the same set of edges
// the Law check examines, framed from the file's own perspective:
// does the prefix lie about what the file actually does?
//
// We emit a separate verdict so the report can show both framings.
// In a profile where no §1.2 violation exists, §3 also passes by
// construction — both are derived from the same edge set.
const checkConsistency = (input: SamaV2Input): SamaV2Check => {
  const violations: SamaV2Violation[] = [];
  let examined = 0;
  for (const [path, content] of input.files.entries()) {
    if (!isSamaFile(path)) continue;
    const aDecl = declaredLayer(path, input.profile);
    if (!aDecl) continue;
    examined++;
    let ceiling = -1;
    let ceilingFile: string | null = null;
    for (const imp of collectRelativeImports(content)) {
      const resolved = resolveImport(path, imp);
      const bDecl = declaredLayer(resolved, input.profile);
      if (!bDecl) continue;
      if (bDecl.layer > ceiling) { ceiling = bDecl.layer; ceilingFile = resolved; }
    }
    // Consistency fails if any import goes to a strictly higher
    // layer than the file's declared layer. Same-layer with bad
    // sublayer order is the Law's concern, not Consistency's.
    if (ceiling > aDecl.layer) {
      violations.push({
        file: path,
        detail: `declared Layer ${aDecl.layer} (prefix \`${aDecl.sublayer.prefix}\`) but imports reach Layer ${ceiling} via \`${ceilingFile}\` — the prefix claims something the imports contradict`,
      });
    }
  }
  return {
    id: 7, name: "Consistency (§3)", property: "Consistency",
    passed: violations.length === 0, examined, violations,
  };
};

// — Orchestrator ----------------------------------------------------

export const verifySamaV2 = (input: SamaV2Input): SamaV2Report => {
  const checks: SamaV2Check[] = [
    checkSorted(input),
    checkArchitecture(input),
    checkModeledTests(input),
    checkModeledBoundary(input),
    checkAtomic(input),
    checkLaw(input),
    checkConsistency(input),
  ];
  // Architecture's examined count is the canonical total — it counts
  // every file the profile assigns to a layer (or fails to).
  const examined = checks.find((c) => c.id === 2)?.examined ?? 0;
  return {
    profile: input.profile.profile,
    examined,
    checks,
    overallPassed: checks.every((c) => c.passed),
  };
};