// 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; } 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, ): 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; };