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

a31_sama_v2.ts 321 lines · 11482 bytes raw
// a31 — model: types, constants, and pure helpers for the SAMA v2
// verifier + §5 core metrics emitter. No I/O lives here. c14_sama_profile
// parses .toml into ProfileSpec; b32_sama_v2_verify applies the seven §4
// checks; b32_sama_v2_metrics computes the §5 metrics. The verifier and
// metrics emitter share the helpers below — particularly the parse-
// boundary detector — so the Modeled-boundary check (#4) and
// boundaryRatio metric move in lockstep.

export type LayerNumber = 0 | 1 | 2 | 3;

export interface Sublayer {
  // Order within the array (in the source profile) = dependency order:
  // later may import earlier, never the reverse. We carry the index
  // here so the verifier can compare positions.
  name: string;
  prefix: string;
  index: number;
}

export interface LayerSpec {
  // A layer is either flat (an array of prefixes treated as one
  // sublayer) or subdivided (an ordered list of sublayers with their
  // own prefixes). The parser normalises flat layers into a single
  // synthetic sublayer named "default".
  sublayers: Sublayer[];
}

// — v2.1 dialect flags (per /sama/v2 §6.1–6.3) -----------------------
//
// Three opt-in profile flags admitted under §6 as provisional dialects.
// Each defaults to v2.0 behaviour when absent. The current verifier
// PARSES these (so opt-in profiles for non-TS/PHP languages don't get
// rejected as malformed) but does NOT yet activate their relaxed
// semantics — activation is a later promotion event that requires
// cross-repo §5 evidence per §6.A.

export type ProfileLayout = "prefix" | "directory";
export type ProfileTests = "sibling" | "inline";
export type ProfileAtomicExemption = "none" | "declarative";

export const PROFILE_LAYOUT_VALUES = ["prefix", "directory"] as const;
export const PROFILE_TESTS_VALUES = ["sibling", "inline"] as const;
export const PROFILE_ATOMIC_EXEMPTION_VALUES = ["none", "declarative"] as const;

export interface ProfileSpec {
  samaVersion: string;
  profile: string;        // profile name, e.g. "tdd-md"
  // Optional v2.1 dialect flags. Absent ≡ v2.0 default behaviour
  // (prefix / sibling / none). No §4 check currently inspects these —
  // see comment on the type aliases above.
  layout?: ProfileLayout;
  tests?: ProfileTests;
  atomicExemption?: ProfileAtomicExemption;
  layers: {
    0: LayerSpec;
    1: LayerSpec;
    2: LayerSpec;
    3: LayerSpec;
  };
}

export interface SamaV2Input {
  profile: ProfileSpec;
  // Map keyed by repo-relative path (e.g. "src/d11_server.ts") to
  // file contents. The verifier never reads files itself; the loader
  // populates this map.
  files: Map<string, string>;
}

export interface SamaV2Violation {
  file: string;
  detail: string;
}

export interface SamaV2Check {
  // Stable IDs matching §4 of the spec.
  id: 1 | 2 | 3 | 4 | 5 | 6 | 7;
  // Display name used in the rendered report.
  name: string;
  // Property letter / phrase from the spec.
  property:
    | "Sorted"
    | "Architecture"
    | "Modeled (tests)"
    | "Modeled (boundary)"
    | "Atomic"
    | "Law"
    | "Consistency";
  passed: boolean;
  examined: number;
  violations: SamaV2Violation[];
  // Free-form note shown alongside the verdict — used for §4.4 where
  // the profile may declare advisory-only enforcement.
  note?: string;
}

export interface SamaV2Report {
  profile: string;
  // Total files examined across all checks (matches the count emitted
  // by the §4.2 Architecture check).
  examined: number;
  checks: SamaV2Check[];
  overallPassed: boolean;
}

// — §5 core metrics: shape ----------------------------------------
//
// Operational definitions are pinned on /sama/v2 §5 (operational).
// The metric VALUES are computed in b32_sama_v2_metrics; this file
// just declares the shape so callers (and the renderer) can type-narrow.

export interface FanSummary {
  // {mean, p50, p95, max} over a per-file fan-in or fan-out series.
  // Empty series → all zeros.
  mean: number;
  p50: number;
  p95: number;
  max: number;
}

export interface FanByLayer {
  0: { fanIn: FanSummary; fanOut: FanSummary };
  1: { fanIn: FanSummary; fanOut: FanSummary };
  2: { fanIn: FanSummary; fanOut: FanSummary };
  3: { fanIn: FanSummary; fanOut: FanSummary };
}

export interface SamaV2ViolationCounts {
  // Counts of violations per §4 check, reported even when a check
  // passes (value = 0). This is §5's "trailing signal: which rules
  // agents *almost* break."
  sorted: number;
  architecture: number;
  modeledTests: number;
  modeledBoundary: number;
  atomic: number;
  law: number;
  consistency: number;
}

export interface SamaV2Metrics {
  graphDepth: number;
  fanByLayer: FanByLayer;
  boundaryRatio: number;
  workingSetFit: number;
  violationCounts: SamaV2ViolationCounts;
}

// — Working-set bounds (per /sama/v2 §5 documented reasoning) -----
//
// Upper 500: comfortably below the §4.5 Atomic 700-LOC cap, leaving
// headroom before a file approaches "split soon" territory.
// Lower 50: below this, a file is too small to be a substantive
// module — usually a type-only file, a stub, or a single helper that
// would read better inlined into a sibling. Type-only files (Layer 0
// model shards) and minimal test fixtures fall here by design; they
// are acceptable but counted as "not in the working-set sweet spot"
// because they are not load-bearing modules.
//
// Hard-coded for v1 of the metrics emitter. Making them profile-
// configurable is a deliberate later step (requires extending the
// TOML subset parser to handle integer values).
export const WORKING_SET_MIN_LOC = 50;
export const WORKING_SET_MAX_LOC = 500;

// — Layer assignment helper --------------------------------------
//
// Returns the canonical layer a file's basename declares via prefix,
// or null if no profile prefix matches. The verifier and metrics
// emitter both call this for every file they examine.
export const declaredLayer = (
  path: string,
  profile: ProfileSpec,
): { layer: LayerNumber; sublayer: Sublayer } | null => {
  const base = path.split("/").pop() ?? path;
  for (const k of [0, 1, 2, 3] as LayerNumber[]) {
    const spec = profile.layers[k];
    for (const sub of spec.sublayers) {
      if (base.startsWith(sub.prefix)) return { layer: k, sublayer: sub };
    }
  }
  return null;
};

// — File classifiers ---------------------------------------------

// A SAMA file is one we expect to obey the layer rules: any *.ts
// under src/ that isn't a *.test.ts. Tests live next to source as
// siblings; they're examined for the Modeled check but don't carry
// their own layer.
export const isSamaFile = (path: string): boolean =>
  path.startsWith("src/") && path.endsWith(".ts") && !path.endsWith(".test.ts");

export const isTestFile = (path: string): boolean =>
  path.startsWith("src/") && path.endsWith(".test.ts");

// — Source-mask helpers ------------------------------------------

// Strip JS/TS string literals and comments to whitespace so a regex
// that walks the source doesn't trip on test fixtures that contain
// the very patterns we're scanning for. Preserves newline positions
// so line numbers stay aligned.
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;
};

// Collect every relative ".ts" import edge in a file. Scans raw
// source: a stripped copy would erase the quoted import paths along
// with all other string literals, so the regex must run over the
// original. To avoid picking up import-like strings inside test
// fixtures, we cross-check each match position against the stripped
// mask — if the keyword `from` lands on whitespace in the mask, it
// was inside a string literal and we skip it.
export const collectRelativeImports = (content: string): string[] => {
  const mask = stripStringsAndComments(content);
  const re = /\bfrom\s+["'](\.\/[A-Za-z0-9_./-]+\.ts)["']/g;
  const out: string[] = [];
  let m: RegExpExecArray | null;
  while ((m = re.exec(content)) !== null) {
    // If the `from` keyword position is whitespace in the mask, the
    // entire match was inside a string literal (e.g. a test fixture).
    if (mask[m.index] === " " || mask[m.index] === "\n") continue;
    if (m[1]) out.push(m[1]);
  }
  return out;
};

// Resolve a relative import like "./c14_git.ts" from the importing
// file's directory to the repo-relative path used as the input map's
// key (e.g. "src/c14_git.ts").
export const resolveImport = (fromPath: string, importPath: string): string => {
  const dir = fromPath.split("/").slice(0, -1).join("/");
  const rel = importPath.replace(/^\.\//, "");
  return dir + "/" + rel;
};

// — Parse-boundary call-site detector -----------------------------
//
// Source of truth for what counts as "external input parsed at the
// boundary" under SAMA v2 §4.4. Consumed by:
//   - b32_sama_v2_verify.checkModeledBoundary (#4) — flags Layer 1/3
//     files where any pattern occurs; emits one violation per
//     (file, pattern) pair preserving PARSE_BOUNDARY_PATTERNS order.
//   - b32_sama_v2_metrics.boundaryRatio — counts every individual
//     call site and reports the Layer-2 share.
// If you change the patterns, both check and metric move in lockstep.

export const PARSE_BOUNDARY_PATTERNS: ReadonlyArray<{
  name: "JSON.parse" | "new URL";
  source: string;
}> = [
  { name: "JSON.parse", source: "\\bJSON\\.parse\\s*\\(" },
  { name: "new URL",    source: "\\bnew\\s+URL\\s*\\("   },
];

export interface ParseBoundaryCallSite {
  file: string;
  pattern: "JSON.parse" | "new URL";
  // Position in the stripped source. Useful for line-number lookup;
  // the verifier currently only needs (file, pattern).
  index: number;
}

// Walk every SAMA file (src/*.ts non-test) and return every parse-
// boundary call site. Operates on the stripped source so string-
// literal fixtures don't false-positive. Iteration order: files in
// input map order (Map preserves insertion order), patterns in
// PARSE_BOUNDARY_PATTERNS order, occurrences in source order.
export const findParseBoundaryCallSites = (
  files: Map<string, string>,
): ParseBoundaryCallSite[] => {
  const out: ParseBoundaryCallSite[] = [];
  for (const [path, content] of files) {
    if (!isSamaFile(path)) continue;
    const stripped = stripStringsAndComments(content);
    for (const pat of PARSE_BOUNDARY_PATTERNS) {
      // Fresh regex per file so lastIndex never bleeds.
      const re = new RegExp(pat.source, "g");
      let m: RegExpExecArray | null;
      while ((m = re.exec(stripped)) !== null) {
        out.push({ file: path, pattern: pat.name, index: m.index });
      }
    }
  }
  return out;
};