diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 0000000..f917c3f --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,16 @@ +# Repo-local agent skills + +This repo consumes The Interdependency organization skill library. + +Canonical source: +- Preferred: `The-Interdependency/skill-lib` +- Temporary source: `The-Interdependency/a0/skill-lib` + +Installed skills: +- `msdmd/` — Module Self-Declared Metadata Markdown +- `test-build/` — test contract metadata blocks +- `meta-module-build/` — metadata-first module scaffolding + +Agents working in this repo should read `meta-module-build/SKILL.md` before +creating new modules, routes, services, schemas, adapters, workers, engines, +UI panels, migrations, or experiments. diff --git a/.agents/skills/meta-module-build/SKILL.md b/.agents/skills/meta-module-build/SKILL.md new file mode 100644 index 0000000..e9ca23f --- /dev/null +++ b/.agents/skills/meta-module-build/SKILL.md @@ -0,0 +1,185 @@ +--- +name: meta-module-build +description: Metadata-first module build skill built on msdmd. Use this when turning a capability idea into a bounded module manifest, file plan, public/internal surface, permission boundary, tests, docs, rollout, and rollback notes before implementation. +--- + +GPT generated; context, prompt Erin Spencer + +# meta-module-build — Metadata-first module scaffolding + +`meta-module-build` is an application of [msdmd](../msdmd/SKILL.md). It uses self-declared metadata to keep a proposed module's purpose, surfaces, dependencies, boundaries, tests, and rollout notes visible beside the files that implement it. + +Read `msdmd/SKILL.md` first. This skill inherits the block syntax, parser contract, and visible gap-reporting requirement. + +## Doctrine + +A module build is not an unbounded patch. It is a staged transformation: + +```text +intent -> manifest -> file plan -> tests -> scaffold -> reviewable change +``` + +If a field is not known, write `hmmm`. Do not guess certainty into the manifest. + +## The block + +A module owns its build declaration in a `MODULE_BUILD` block: + +```python +# === MODULE_BUILD === +# id: ucns_object_record +# module_name: object_record +# module_kind: service +# summary: describes a UCNS object without running factorization +# owner: Erin Spencer +# public_surface: object_record, UCNSObjectRecord +# internal_surface: status_for_object, depth_of, stable_hash +# auth_boundary: none +# storage_boundary: none +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: ucns_recursive.tests.test_object_record +# rollout: default_enabled +# rollback: remove export and call sites +# === END MODULE_BUILD === +``` + +## Field schema + +Required: + +| Field | Meaning | +|---|---| +| `id` | Unique snake_case identifier, stable across refactors. | +| `module_name` | Human/module name being built. | +| `module_kind` | One of `skill`, `service`, `route`, `adapter`, `engine`, `instrument`, `ui_panel`, `schema`, `migration`, `worker`, `experiment`, or `hmmm`. | +| `summary` | One-sentence purpose. | +| `owner` | Responsible person, role, or agent. | +| `public_surface` | Public exports, routes, commands, or user-visible functions. Use `none` if absent. | +| `internal_surface` | Internal functions/classes/routes touched. Use `none` if absent. | +| `tests` | Test module/path or `hmmm` if not written yet. | +| `rollout` | How the module becomes active. | +| `rollback` | How to disable or remove it cleanly. | + +Boundary fields are required because module generation often crosses hidden lines: + +| Field | Meaning | +|---|---| +| `auth_boundary` | Auth or permission effect: `none`, `read`, `write`, `admin`, or `hmmm`. | +| `storage_boundary` | Persistent storage effect: `none`, `read`, `write`, `migration`, or `hmmm`. | +| `network_boundary` | Network/API effect: `none`, `internal`, `external`, or `hmmm`. | +| `user_data_boundary` | User-data effect: `none`, `read`, `write`, `delete`, or `hmmm`. | +| `admin_only` | `true`, `false`, or `hmmm`. | + +Optional: + +| Field | Meaning | +|---|---| +| `ui_surface` | UI tab/panel/component affected. | +| `api_surface` | API route or RPC surface affected. | +| `data_schema` | Schema name or shape affected. | +| `feature_flag` | Flag or config gate. | +| `requires` | Comma-separated MODULE_BUILD ids this one depends on. | +| `since` | Date/version added. | +| `unresolved` | Comma-separated unresolved items. | + +## File plan rule + +Every implementation PR produced by this skill should include a file plan in the PR body: + +```text +path +created_or_modified +purpose +risk +required_tests +``` + +Do not hide unrelated file edits inside a module build. + +## A0 console metadata rule + +If a module touches the console, route surface, or dynamic UI, the manifest must name the metadata contract it preserves or adds. + +Expected concepts: + +```text +UI_META +DATA_SCHEMA +route namespace +renderer expectation +permission tier +empty state +error state +``` + +If the codebase uses different names, use the codebase names and map them to these concepts in the PR body. + +## UCNS-aware rule + +When a module touches UCNS objects, identity, factorization, or recursive interpretation, prefer the public safe boundary: + +```python +from ucns import a0_safe +``` + +Preferred calls: + +```python +a0_safe.describe(obj) +a0_safe.identity(obj) +a0_safe.canonical(obj) +a0_safe.factor(obj) +``` + +Do not use raw factorization sentinels for A0-facing claims when a scoped envelope exists. + +## Runner behavior + +A `MODULE_BUILD` runner should: + +1. parse all `MODULE_BUILD` blocks using the msdmd universal parser; +2. validate required fields; +3. report boundary fields visibly; +4. report modules without `MODULE_BUILD` as coverage gaps; +5. optionally fail in strict mode when required build metadata is missing; +6. emit a review summary grouped by `module_kind` and boundary risk. + +## Anti-patterns + +- Building code first and writing the manifest after. +- Omitting boundary fields because the module feels small. +- Marking unknowns as solved instead of `hmmm`. +- Using implementation-shaped ids that do not explain the protected capability. +- Adding UI or route behavior without declaring metadata expectations. +- Treating frontier-domain UCNS results as absolute claims. + +## Completion criteria + +A meta-module-build run is complete when it produces either: + +```text +manifest-only PR +``` + +or + +```text +scaffold PR with tests and docs +``` + +It is incomplete if it only produces an idea, a patch with no manifest, or a module with no boundary/test plan. + +## hmmm + +Default unresolved items for new modules: + +```text +exact registry location +feature flag or default activation +admin gate +persistence behavior +UI metadata naming +rollback owner +``` diff --git a/.agents/skills/msdmd/SKILL.md b/.agents/skills/msdmd/SKILL.md new file mode 100644 index 0000000..9448aa3 --- /dev/null +++ b/.agents/skills/msdmd/SKILL.md @@ -0,0 +1,213 @@ +--- +name: msdmd +description: Module Self-Declared Metadata in Markdown — the foundational convention where each source module declares its own structured metadata in a fenced comment block. Other skills in this lib (test-build, doc-build, cap-build, etc.) are thin applications on top of this convention. Load this when authoring a new metadata-driven skill, when extending the block schema, or when building a parser/executor for a new application. +--- + +# msdmd — Module Self-Declared Metadata in Markdown + +## The doctrine + +Every cross-cutting fact a module owns — its test contracts, its public +documentation, its declared capabilities, its dependency edges, its +owner — should live **in the same file as the code that implements it**, +in a structured comment block. A meta-runner walks the tree, parses +every block, and acts on it. + +Modules without the relevant block surface as visible coverage gaps in +the runner output. Coverage is observable, not implicit. + +This is the inverse of the conventional "keep your docs/tests/configs in +sync with code" approach, which fails because the contract and the +implementation live in different files. Anyone can delete the code and +forget the doc; the lie persists. msdmd makes the lie structurally +impossible: when you delete the code, you delete the block in the same +diff. + +## Block syntax + +```python +# === === +# id: +# : +# : +# +# id: +# : +# === END === +``` + +### Universal rules + +- **Fence**: `=== ===` opens, `=== END ===` + closes. Block name is uppercase snake_case (e.g. `CONTRACTS`, `DOCS`, + `CAPABILITIES`, `REQUIRES`, `OWNERS`). +- **Comment marker**: whatever is idiomatic for the file's language. + `#` for Python / Ruby / Elixir / shell. `//` for TS / JS / Rust / Go / + Java / C / C++ / Swift. `--` for SQL / Lua / Haskell. The marker + appears at the start of every line inside the block. +- **Entry boundary**: every entry begins with `id:`. The id must be + unique within its block and stable across refactors (so it can be + referenced from external tooling). +- **Field lines**: indented one level beneath the id (two spaces of + visible indent inside the comment). Field names are lowercase + snake_case followed by `:` and a value. +- **Multiple blocks per file**: a module may declare more than one + block, of the same or different types. The parser concatenates + entries. +- **Multiple block types per file**: a module may declare both + `CONTRACTS` and `DOCS` (and any others). Each is parsed + independently by its respective application. + +### Example (Python) + +```python +# === CONTRACTS === +# id: chat_get_other_owner_404 +# given: GET /api/v1/conversations/{id} with x-user-id != row.user_id +# then: 404 (existence non-disclosure) +# class: security +# call: tests.contracts.chat.test_get_other_owner_404 +# === END CONTRACTS === +``` + +### Example (TypeScript) + +```typescript +// === CONTRACTS === +// id: chat_input_send_disabled_while_pending +// given: a message is in flight +// then: send button is disabled and shows pending state +// class: ux_correctness +// call: src/__contracts__/chat_input.ts#test_send_disabled_while_pending +// === END CONTRACTS === +``` + +### Example (Elixir) + +```elixir +# === CAPABILITIES === +# id: agent_supervisor_dynamic_spawn +# summary: spawns child agents under a DynamicSupervisor with max_children=cap +# exposes: AgentSupervisor.start_child/1 +# === END CAPABILITIES === +``` + +The block content is identical across languages — only the comment +marker changes. + +## The parser contract + +A msdmd parser is a pure function over file text: + +``` +parse(file_text: str, block_name: str) -> list[Entry] +``` + +where `Entry` is a flat `dict[str, str]` containing at minimum the +`id` field plus whatever fields the entry declared. The parser: + +- Returns all entries from all matching blocks (using + `re.finditer`-style iteration, not just the first block). +- Does not interpret or validate field semantics — that's the + application's job. An entry missing a required field surfaces as an + error in the executor, not in the parser. +- Does not fail on missing block type — returns empty list if no block + of that name exists. + +A reference implementation in pure stdlib Python lives at +`parsers/universal.py`; the TypeScript equivalent at `parsers/universal.ts`. +Both commit to zero non-stdlib dependencies so you can copy them into +any project. + +## The runner protocol + +A msdmd runner combines a parser and an executor: + +``` +walk(root: Path, block_name: str) -> Iterator[(file: Path, entries: list[Entry])] +``` + +Implementation rules every runner MUST follow: + +1. **Walk the source tree** under a configurable root, skipping + conventional non-source paths (`__pycache__`, `node_modules`, + `.git`, build outputs, the runner's own test directory). +2. **Detect comment marker by extension**, not by content sniffing. + `.py / .rb / .ex / .sh → #`. `.ts / .js / .tsx / .jsx / .rs / .go / + .java / .c / .cpp / .swift → //`. `.sql / .lua / .hs → --`. +3. **Parse all matching blocks** in each file. Multiple blocks of the + same type concatenate; entries from different blocks are + distinguishable only by id, not by source block. +4. **Visit modules without any block of the requested type** and emit + them as a separate "untested" / "undocumented" / "uncapable" gap + list. Truncate noise (e.g. show first 20, count the rest), but + never silently drop. Visibility is the whole point. +5. **Exit non-zero** when any entry fails the executor's check. The + gap list itself is informational unless the application opts in to + strict mode (in which case missing blocks are also a fail). + +## Field naming conventions + +Reserved field names and their canonical meanings (for cross-skill +consistency): + +| Field | Meaning | +|---|---| +| `id` | Unique stable identifier within the block. Required on every entry. | +| `class` | Free-text tag for grouping (`security`, `correctness`, `idempotency`, etc.). The runner counts entries per class in summaries. | +| `call` | Fully-qualified path to an executable target (Python module path, JS module + export, etc.) the executor will invoke. | +| `summary` | One-sentence human description. | +| `requires` | Comma-separated list of other entry ids this one depends on. | +| `owner` | Who is responsible (person, agent role, team). | +| `since` | Version or date this declaration was added. | +| `deprecated` | If present, marks the entry as scheduled for removal. | + +Application-specific fields (`given`, `then`, `expects`, `inputs`, +`outputs`, etc.) are introduced by individual SKILLs and documented in +their own SKILL.md. + +## Authoring a new msdmd application + +1. **Pick a block name** that doesn't collide with an existing + application. Search the lib README for current names. +2. **Define the field schema** — which fields are required, which + optional, what types they carry. Document in your SKILL.md. +3. **Write the executor** — the function that takes parsed entries + and acts on them. Use the universal parser; do not write a new + one unless your block needs syntax the universal parser can't + express. +4. **Implement the visibility report** — your runner must list + modules without your block type as gaps, and the gap list must + be visible in normal output (not buried behind a flag). +5. **Author a SKILL.md** in this lib with the convention spec, the + executor's behavior, and at least one worked example. + +`test-build/` is the canonical reference application. Read its +SKILL.md alongside this one to see the pattern fully realized. + +## Anti-patterns + +- **Don't define the contract in a separate file.** The whole point is + that the declaration lives next to the implementation. If you find + yourself writing `tests.yaml` or `docs.json`, you're outside the + doctrine. +- **Don't make ids reflect implementation details.** `chat_returns_200` + tells future-you nothing; `chat_get_other_owner_404` tells you what's + protected. Ids are part of the documentation. +- **Don't silently drop modules without blocks.** Coverage gaps must be + visible. If your runner doesn't emit the gap list, it's not a msdmd + runner; it's a test discovery tool with extra steps. +- **Don't introduce parser dialects.** If you need richer syntax than + the universal parser handles, propose an extension to msdmd, not a + fork. The portability of the convention depends on the parser + contract being one thing. + +## Versioning + +- **Block syntax is stable.** Breaking changes (renaming the fence, + changing field-line indentation rules, etc.) go through a major + version bump and a migration note in the lib README. +- **Reserved field names** above are stable. New reserved names are + additive only. +- **Application SKILLs** version independently in their own SKILL.md + files. diff --git a/.agents/skills/msdmd/parsers/__init__.py b/.agents/skills/msdmd/parsers/__init__.py new file mode 100644 index 0000000..9e9a557 --- /dev/null +++ b/.agents/skills/msdmd/parsers/__init__.py @@ -0,0 +1,7 @@ +# 0:4 0:0 0:0 +"""msdmd reference parsers. + +`universal` is the canonical Python implementation of the parser +contract defined in ``msdmd/SKILL.md``. Pure stdlib; copy anywhere. +""" +# 0:4 0:0 0:0 diff --git a/.agents/skills/msdmd/parsers/universal.py b/.agents/skills/msdmd/parsers/universal.py new file mode 100644 index 0000000..16a57be --- /dev/null +++ b/.agents/skills/msdmd/parsers/universal.py @@ -0,0 +1,136 @@ +# 85:29 0:0 0:0 +"""Universal msdmd parser — pure stdlib. + +Implements the parser contract from ``msdmd/SKILL.md``: extracts every +``# === ===`` … ``# === END ===`` block from +a source file and returns its entries as flat dicts. + +Comment marker is auto-detected by file extension. The block syntax +itself is identical across languages; only the per-line marker changes. + +Public API: + + parse_text(text, block_name, marker="#") -> list[dict] + parse_file(path, block_name) -> list[dict] + walk_tree(root, block_name, *, skip=None) -> tuple[annotated, untested] + +This module has zero non-stdlib dependencies and is safe to copy +verbatim into any project that wants msdmd support. +""" +from __future__ import annotations +import re +from pathlib import Path +from typing import Iterable + +# extension → comment marker +_MARKERS: dict[str, str] = { + ".py": "#", ".rb": "#", ".ex": "#", ".exs": "#", ".sh": "#", + ".ts": "//", ".tsx": "//", ".js": "//", ".jsx": "//", ".mjs": "//", + ".rs": "//", ".go": "//", ".java": "//", ".c": "//", ".cpp": "//", + ".cc": "//", ".h": "//", ".hpp": "//", ".swift": "//", ".kt": "//", + ".sql": "--", ".lua": "--", ".hs": "--", +} + +_DEFAULT_SKIP = ( + "__pycache__", "node_modules", ".git", ".venv", "venv", + "dist", "build", ".next", ".nuxt", "target", ".pytest_cache", + ".mypy_cache", ".tox", +) + + +def marker_for(path: Path) -> str | None: + """Return the comment marker for a file path, or None if unsupported.""" + return _MARKERS.get(path.suffix.lower()) + + +def _block_regex(block_name: str, marker: str) -> re.Pattern[str]: + m = re.escape(marker) + name = re.escape(block_name) + return re.compile( + rf"^{m} === {name} ===\s*$(?P.*?)^{m} === END {name} ===\s*$", + re.MULTILINE | re.DOTALL, + ) + + +def parse_text(text: str, block_name: str, marker: str = "#") -> list[dict]: + """Extract every entry from every matching block in ``text``. + + Entries are flat ``dict[str, str]`` keyed by field name. The first + line of an entry must be ``id: ``; subsequent lines until + the next ``id:`` (or block end) carry indented ``: `` + pairs. + """ + block_re = _block_regex(block_name, marker) + m = re.escape(marker) + id_re = re.compile(rf"^\s*{m}\s*id:\s*(?P\S+)\s*$") + field_re = re.compile(rf"^\s*{m}\s+(?P[a-z_]+):\s*(?P.+?)\s*$") + + entries: list[dict] = [] + for block in block_re.finditer(text): + current: dict[str, str] | None = None + for line in block.group("body").splitlines(): + line = line.rstrip() + mid = id_re.match(line) + if mid: + if current is not None: + entries.append(current) + current = {"id": mid.group("id")} + continue + if current is None: + continue + mf = field_re.match(line) + if mf: + current[mf.group("key")] = mf.group("val") + if current is not None: + entries.append(current) + return entries + + +def parse_file(path: Path, block_name: str) -> list[dict]: + """Parse a single file. Returns [] if the file's extension has no + known comment marker or if the file can't be read.""" + marker = marker_for(path) + if marker is None: + return [] + try: + return parse_text(path.read_text(encoding="utf-8"), block_name, marker) + except (OSError, UnicodeDecodeError): + return [] + + +def walk_tree( + root: Path, + block_name: str, + *, + skip: Iterable[str] | None = None, + extensions: Iterable[str] | None = None, +) -> tuple[list[tuple[Path, list[dict]]], list[Path]]: + """Walk ``root`` and partition source files into (annotated, untested). + + ``annotated`` is a list of ``(path, entries)`` for every file that + contains at least one entry of ``block_name``. ``untested`` is every + other source file (still filtered by extension and skip-dirs) so + coverage gaps remain observable. + """ + skip_set = set(skip) if skip is not None else set(_DEFAULT_SKIP) + ext_set = ( + set(e.lower() if e.startswith(".") else "." + e.lower() for e in extensions) + if extensions is not None + else set(_MARKERS.keys()) + ) + annotated: list[tuple[Path, list[dict]]] = [] + untested: list[Path] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + if any(part in skip_set for part in path.parts): + continue + if path.suffix.lower() not in ext_set: + continue + entries = parse_file(path, block_name) + if entries: + annotated.append((path, entries)) + else: + untested.append(path) + return annotated, untested +# 85:29 0:0 0:0 diff --git a/.agents/skills/msdmd/parsers/universal.ts b/.agents/skills/msdmd/parsers/universal.ts new file mode 100644 index 0000000..d889169 --- /dev/null +++ b/.agents/skills/msdmd/parsers/universal.ts @@ -0,0 +1,140 @@ +// 110:15 0:6 0:0 +/** + * Universal msdmd parser — pure Node stdlib (fs, path). + * + * TypeScript counterpart to parsers/universal.py. Implements the + * parser contract from msdmd/SKILL.md: extracts every + * `// === ===` … `// === END ===` block + * from a source file and returns its entries as flat objects. + * + * Comment marker auto-detected by file extension. The block syntax + * itself is identical across languages; only the per-line marker + * changes. + * + * Zero non-stdlib dependencies. Safe to copy verbatim into any + * Node/Deno/Bun project that wants msdmd support. + */ +import { readFileSync, statSync, readdirSync } from "node:fs"; +import { join, extname } from "node:path"; + +export type Entry = Record; + +const MARKERS: Record = { + ".py": "#", ".rb": "#", ".ex": "#", ".exs": "#", ".sh": "#", + ".ts": "//", ".tsx": "//", ".js": "//", ".jsx": "//", ".mjs": "//", + ".rs": "//", ".go": "//", ".java": "//", ".c": "//", ".cpp": "//", + ".cc": "//", ".h": "//", ".hpp": "//", ".swift": "//", ".kt": "//", + ".sql": "--", ".lua": "--", ".hs": "--", +}; + +const DEFAULT_SKIP = new Set([ + "__pycache__", "node_modules", ".git", ".venv", "venv", + "dist", "build", ".next", ".nuxt", "target", ".pytest_cache", + ".mypy_cache", ".tox", +]); + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function markerFor(path: string): string | null { + return MARKERS[extname(path).toLowerCase()] ?? null; +} + +export function parseText( + text: string, + blockName: string, + marker: string = "#", +): Entry[] { + const m = escapeRegex(marker); + const name = escapeRegex(blockName); + const blockRe = new RegExp( + `^${m} === ${name} ===\\s*$([\\s\\S]*?)^${m} === END ${name} ===\\s*$`, + "gm", + ); + const idRe = new RegExp(`^\\s*${m}\\s*id:\\s*(\\S+)\\s*$`); + const fieldRe = new RegExp(`^\\s*${m}\\s+([a-z_]+):\\s*(.+?)\\s*$`); + + const entries: Entry[] = []; + let match: RegExpExecArray | null; + while ((match = blockRe.exec(text)) !== null) { + const body = match[1]; + let current: Entry | null = null; + for (const rawLine of body.split("\n")) { + const line = rawLine.replace(/\s+$/, ""); + const mid = idRe.exec(line); + if (mid) { + if (current !== null) entries.push(current); + current = { id: mid[1] }; + continue; + } + if (current === null) continue; + const mf = fieldRe.exec(line); + if (mf) current[mf[1]] = mf[2]; + } + if (current !== null) entries.push(current); + } + return entries; +} + +export function parseFile(path: string, blockName: string): Entry[] { + const marker = markerFor(path); + if (marker === null) return []; + try { + return parseText(readFileSync(path, "utf-8"), blockName, marker); + } catch { + return []; + } +} + +export interface WalkOptions { + skip?: Set; + extensions?: Set; +} + +export function walkTree( + root: string, + blockName: string, + opts: WalkOptions = {}, +): { annotated: Array<[string, Entry[]]>; untested: string[] } { + const skip = opts.skip ?? DEFAULT_SKIP; + const extensions = + opts.extensions ?? new Set(Object.keys(MARKERS)); + + const annotated: Array<[string, Entry[]]> = []; + const untested: string[] = []; + + function visit(dir: string): void { + let names: string[]; + try { + names = readdirSync(dir).sort(); + } catch { + return; + } + for (const name of names) { + if (skip.has(name)) continue; + const full = join(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + visit(full); + } else if (st.isFile()) { + if (!extensions.has(extname(full).toLowerCase())) continue; + const entries = parseFile(full, blockName); + if (entries.length > 0) { + annotated.push([full, entries]); + } else { + untested.push(full); + } + } + } + } + + visit(root); + return { annotated, untested }; +} +// 110:15 0:6 0:0 diff --git a/.agents/skills/test-build/SKILL.md b/.agents/skills/test-build/SKILL.md new file mode 100644 index 0000000..b782621 --- /dev/null +++ b/.agents/skills/test-build/SKILL.md @@ -0,0 +1,151 @@ +--- +name: test-build +description: Self-declaring contract tests built on msdmd. Each module owns the tests that protect its contracts via a `# === CONTRACTS ===` block; a runner discovers and executes them and reports per-contract status plus visible coverage gaps. Load this when adding tests that ride the msdmd convention, when refactoring a module that has CONTRACTS declarations, or when authoring a new contract test executor. +--- + +# test-build — Contract tests on msdmd + +`test-build` is an application of [msdmd](../msdmd/SKILL.md). The +foundational skill defines the comment-block convention, the universal +parser, and the gap-reporting requirement; this skill applies the +convention to test contracts and ships an executor. + +Read `msdmd/SKILL.md` first if you haven't — the block syntax, +parser contract, and visibility rules below are inherited from there +and not redefined. + +## The block + +Every module that promises a contract declares it in a `CONTRACTS` +block: + +```python +# === CONTRACTS === +# id: chat_create_owner_isolation +# given: POST /api/v1/conversations with x-user-id=A and body.user_id=B +# then: stored row has user_id=A; smuggled value is dropped +# class: security +# call: tests.contracts.chat.test_create_owner_isolation +# +# id: chat_get_other_owner_404 +# given: GET /api/v1/conversations/{id} where conv.user_id != caller +# then: returns 404 (existence non-disclosure, not 403) +# class: security +# call: tests.contracts.chat.test_get_other_owner_404 +# === END CONTRACTS === +``` + +## Field schema + +Required: + +| Field | Meaning | +|---|---| +| `id` | Unique snake_case identifier, stable across refactors. Becomes the test handle in reports. | +| `given` | Plain-English precondition / request shape. State the input, not the implementation. | +| `then` | The asserted post-condition — the actual contract, not the steps to verify it. | +| `call` | Fully-qualified path to the test function. The executor imports and invokes this. Sync or async; `None` return on pass; raise (typically `AssertionError`) on fail. | + +Optional: + +| Field | Meaning | +|---|---| +| `class` | Free-text tag (`security`, `correctness`, `idempotency`, `auth`, `regression`). The runner counts entries per class in the summary. | +| `requires` | Comma-separated list of other contract ids this one depends on (informational; the runner does not currently enforce ordering). | +| `since` | Version or date the contract was added. | +| `deprecated` | If present, the runner skips and reports the entry as deprecated. | + +## The contract for test functions + +A test function: + +- Is importable at the path declared in `call:`. +- Is a plain function, sync or async. The executor awaits it if it's a + coroutine. +- Takes no required arguments. The executor does not inject fixtures + or context; the test is self-contained or pulls from the language's + standard environment (env vars, a known service URL, etc.). +- Returns `None` on pass. +- Raises `AssertionError` on fail with a message that names the + violated invariant. Other exceptions are treated as `ERROR` + (test infra failure) rather than `FAIL` (contract violation). +- Cleans up any persistent state it creates. Tests run against the + same database / service as the executor; isolation is the test's + responsibility (uuid-prefixed identities, deletion in `finally`, + etc.). + +## Authoring a runner + +The reference Python runner uses `msdmd/parsers/universal.py`: + +```python +from pathlib import Path +import asyncio, importlib, sys +from collections import Counter +from skill_lib.msdmd.parsers.universal import walk_tree + +async def run_one(entry: dict) -> dict: + call = entry.get("call") + if not call: + return {**entry, "status": "ERROR", "error": "missing 'call' field"} + mod_path, _, fn_name = call.rpartition(".") + try: + fn = getattr(importlib.import_module(mod_path), fn_name) + except Exception as e: + return {**entry, "status": "ERROR", "error": f"import: {e}"} + try: + if asyncio.iscoroutinefunction(fn): + await fn() + else: + fn() + except AssertionError as e: + return {**entry, "status": "FAIL", "error": str(e)} + except Exception as e: + return {**entry, "status": "ERROR", "error": f"{type(e).__name__}: {e}"} + return {**entry, "status": "PASS", "error": None} + +async def main(root: Path) -> int: + annotated, untested = walk_tree(root, "CONTRACTS") + results = [await run_one(e) for _, entries in annotated for e in entries] + counts = Counter(r["status"] for r in results) + for r in results: + sym = {"PASS": "✓", "FAIL": "✗", "ERROR": "!"}[r["status"]] + tail = "" if r["status"] == "PASS" else f" — {r['error']}" + print(f" {sym} {r['id']}{tail}") + print(f"\n{counts['PASS']} pass / {counts['FAIL']} fail / " + f"{counts['ERROR']} error " + f"{len(untested)} modules without CONTRACTS") + for p in untested[:20]: + print(f" · {p.relative_to(root.parent)}") + return 0 if counts["FAIL"] + counts["ERROR"] == 0 else 1 + +if __name__ == "__main__": + sys.exit(asyncio.run(main(Path(sys.argv[1]).resolve()))) +``` + +The visibility-of-gaps requirement (`untested` list) is mandatory per +msdmd. Drop it and the runner stops being a msdmd application. + +## Anti-patterns + +- **Contracts in test files instead of source files.** The contract + belongs to the module that promises the behavior; the test file just + implements the check. Putting the CONTRACTS block in the test file + inverts the doctrine and lets the source module be deleted without + the contract noticing. +- **Tests with no CONTRACTS entry.** Orphan tests don't run via the + runner; they're dead weight. If you write a test, declare it. +- **Implementation-shaped ids.** `chat_create_returns_200` tells you + nothing; `chat_create_owner_isolation` tells you what's protected. + Ids are part of the documentation. +- **Catching unexpected exceptions in the test to "make it pass".** + Let the exception escape — the runner will mark it `ERROR` (infra + problem) instead of `PASS` (contract holds), which is the correct + signal. + +## Versioning + +Field schema additions are non-breaking and don't bump the version. +Field renames or removals are breaking; bump the major version and +note the migration in the lib README. The `CONTRACTS` block name +itself is stable — never reuse it for a different purpose. diff --git a/CLAUDE.md b/CLAUDE.md index ad9116f..cf48832 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,3 +161,13 @@ React app in `frontend/`. Components: `TopologyVisualization`, `SystemHealthDash - Feature branches: `feat/`, `fix/`, `claude/update-from-interdependency-a0-*` - Author: Erin Patrick Spencer - License: Apache 2.0 + +## Agent module-build doctrine + +Before adding a new module, route, service, adapter, schema, worker, engine, +UI panel, migration, or experiment, read: + +`./.agents/skills/meta-module-build/SKILL.md` + +New module work should start with a `MODULE_BUILD` block. Unknown fields must +be marked `hmmm`, not guessed.