syntaxai/tdd.md · main · src / a31_sama_v2.ts
// 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;
};