diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 708296d..bcddfcc 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -12,7 +12,7 @@ Installed skills: - `meta-module-build/` — metadata-first module scaffolding - `new-retain-old/` — safe replacement workflow that preserves the old implementation while creating a new active path - `manifest/` — living-spec generator for `CLAUDE.md` (vendored from - `The-Interdependency/skill-lib@d6e4d78`). Generates the mechanical facts + `The-Interdependency/skill-lib@d0f6209`). Generates the mechanical facts block in `CLAUDE.md` from `backend/pyproject.toml` + the tree; the `manifest drift check` workflow runs `generate.py --check` in CI. Refresh with `python .agents/skills/manifest/generate.py --pyproject backend/pyproject.toml --write`. diff --git a/.agents/skills/manifest/SKILL.md b/.agents/skills/manifest/SKILL.md index abb3d5b..4c11611 100644 --- a/.agents/skills/manifest/SKILL.md +++ b/.agents/skills/manifest/SKILL.md @@ -65,6 +65,21 @@ Flags: `--root ` (default `.`), `--file ` (default `CLAUDE.md`), `--pyproject ` (default `pyproject.toml`), and exactly one of `--write` / `--check` / `--print`. +## Field requirements + +The required generated fields are the observable repo facts the runner can +derive: package metadata, runtime dependencies, optional extras, top-level +layout, and CI workflow names. Judgement, rationale, test-command guesses, and +doctrine stay hand-authored outside the generated block. Unknown observable +facts render as `hmmm`. + +## Runner contract + +A compliant manifest runner is stdlib-only, deterministic, idempotent, and +non-destructive. It reads `pyproject.toml` plus the file tree, rewrites only the +bytes between the manifest markers, supports `--write`, `--check`, and +`--print`, and exits non-zero when `--check` detects drift. + ## Wiring a repo (the propagation recipe) 1. Vendor `generate.py` to `.agents/skills/manifest/generate.py` (verbatim copy @@ -109,3 +124,11 @@ Flags: `--root ` (default `.`), `--file ` (default `CLAUDE.md`), - **Additive scope.** Start with the high-signal/low-noise fields above. New derived fields are an extension here (bump the block, keep markers stable), not a per-repo fork — portability depends on one generator. + +## Anti-patterns + +- Hand-editing bytes inside the generated manifest markers. +- Emitting fuzzy or judgement-shaped facts as if they were mechanically derived. +- Forking the vendored generator in a consuming repo instead of changing this + canonical source and re-vendoring. +- Running `--write` in CI when the intended gate is `--check`. diff --git a/.agents/skills/manifest/generate.py b/.agents/skills/manifest/generate.py index eb0a494..f0a4630 100755 --- a/.agents/skills/manifest/generate.py +++ b/.agents/skills/manifest/generate.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +# ratios: loc_comments=209:37 imports_exports=6:5 calls_definitions=81:16 """manifest — generate the mechanical half of a repo's CLAUDE.md from source. Living-spec tool (msdmd family). It derives *observable* facts about a repo — @@ -300,3 +300,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) +# ratios: loc_comments=209:37 imports_exports=6:5 calls_definitions=81:16 diff --git a/.agents/skills/manifest/generate.py.sha256 b/.agents/skills/manifest/generate.py.sha256 index 8bfb34a..276a3ad 100644 --- a/.agents/skills/manifest/generate.py.sha256 +++ b/.agents/skills/manifest/generate.py.sha256 @@ -1 +1 @@ -a7100dd7895270498a1562aa9f4efc485e05b14e25c9fe0421a5bf3cb3b77408 generate.py +06cec293ebd7b9a04d84fbfcd4cee55ebb058b6f265e758b6acadd0efd86595d generate.py diff --git a/.agents/skills/msdmd/SKILL.md b/.agents/skills/msdmd/SKILL.md index 9448aa3..0af6b20 100644 --- a/.agents/skills/msdmd/SKILL.md +++ b/.agents/skills/msdmd/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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 (doc-build, cap-build, deps-build, owner-build, test-build, meta-module-build, risk-boundary-build, ratios, 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 @@ -119,6 +119,71 @@ A reference implementation in pure stdlib Python lives at Both commit to zero non-stdlib dependencies so you can copy them into any project. +## Repo collection point and visualizer + +Every consuming repo SHOULD maintain one repo-level collection point named +`_msdmd.ts` (for example, `a0_msdmd.ts`). This file is the +canonical aggregation surface for all parsed msdmd declarations in that +repo. It does not replace module-local blocks; it is generated from them +or maintained as a thin index over them. + +The collection point SHOULD use the shared shapes in `msdmd/collection.ts` +(or a verbatim copy in consuming repos) and export a `MsdmdCollection`: + +```typescript +import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; + +export default defineMsdmdCollection({ + repo: "", + declarations: [ + { file: "path/to/module.py", block: "CONTRACTS", id: "...", fields: { summary: "..." } }, + ], + gaps: [ + { file: "path/to/module.py", missing: ["CONTRACTS", "DOCS"] }, + ], + edges: [ + { from: "module_a", to: "module_b", kind: "requires", source_block: "DEPENDENCIES", source_id: "..." }, + ], +}); + +export const declarations = []; +export const gaps = []; +``` + +A repo-level msdmd visualizer SHOULD read `_msdmd.ts` and render +relationships between modules using the `MsdmdEdge` shape: `DEPENDENCIES.requires`, +`CAPABILITIES.exposes`, `OWNERS.owner`, `BOUNDARIES` risk fields, `DOCS.covers`, +`CONTRACTS.call`, and any `requires` edges shared across application skills. +The visualizer is a consumer of the collection point, not a second metadata +source. + +If a repo has no collection point or visualizer yet, record that as `hmmm` in +repo-local planning rather than pretending the graph exists. + +A small stdlib generator prototype lives at `msdmd/collect.py`. Consuming repos +can run it directly or copy it as a starting point: + +```bash +python -m msdmd.collect --root . --repo --out _msdmd.ts +``` + +The generator is intentionally conservative: it parses module-local blocks, +emits declarations, optional expected-block gaps, and simple relationship +edges from reserved fields. Repo-specific runners may enrich the output, but +should preserve the `MsdmdCollection` shape. + +A minimal Mermaid visualizer prototype lives at `msdmd/visualize.py` and reads +raw JSON or generated TypeScript collection points: + +```bash +python -m msdmd.visualize _msdmd.ts --out _msdmd.mmd +``` + +The visualizer is deliberately small: it renders declaration nodes, normalized +edge relationships, and visible gap nodes. Rich repo-specific UIs should consume +the same collection shape rather than re-parsing source files. + + ## The runner protocol A msdmd runner combines a parser and an executor: @@ -183,7 +248,10 @@ their own SKILL.md. 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. +SKILL.md alongside this one to see the pattern fully realized; read +`doc-build/`, `cap-build/`, `deps-build/`, `owner-build/`, +`risk-boundary-build/`, and `ratios/` for additional applications over +the same parser contract. ## Anti-patterns diff --git a/.agents/skills/msdmd/collect.py b/.agents/skills/msdmd/collect.py new file mode 100644 index 0000000..5e34235 --- /dev/null +++ b/.agents/skills/msdmd/collect.py @@ -0,0 +1,175 @@ +# ratios: loc_comments=141:7 imports_exports=6:3 calls_definitions=35:6 +"""Generate repo-level msdmd collection-point TypeScript. + +This is a small stdlib helper for consuming repos that want to generate a +`_msdmd.ts` aggregation file from module-local msdmd blocks. +It uses the universal parser and emits data shaped by `msdmd/collection.ts`. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Iterable + +from msdmd.parsers.universal import walk_tree + +DEFAULT_BLOCK_NAMES = ( + "DOCS", + "CAPABILITIES", + "DEPENDENCIES", + "OWNERS", + "CONTRACTS", + "MODULE_BUILD", + "BOUNDARIES", + "RATIOS", + "LLMS", + "FRONTEND_META", +) + +EDGE_FIELDS = { + "requires": "requires", + "exposes": "exposes", + "owner": "owns", + "covers": "covers", + "call": "calls", + "boundaries": "risk", +} + + +def _split_targets(value: str) -> list[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def _declaration(file: Path, root: Path, block: str, entry: dict) -> dict: + fields = {str(key): str(value) for key, value in entry.items() if key != "id"} + return { + "file": file.relative_to(root).as_posix(), + "block": block, + "id": str(entry["id"]), + "fields": fields, + } + + +def _edges_for(declaration: dict) -> list[dict]: + edges: list[dict] = [] + fields = declaration["fields"] + source = declaration["id"] + for field, kind in EDGE_FIELDS.items(): + value = fields.get(field) + if not value or value == "hmmm": + continue + for target in _split_targets(value): + edges.append( + { + "from": source, + "to": target, + "kind": kind, + "source_block": declaration["block"], + "source_id": source, + } + ) + return edges + + +def collect( + root: Path, + repo: str, + *, + block_names: Iterable[str] = DEFAULT_BLOCK_NAMES, + expected_blocks: Iterable[str] = (), + source_commit: str | None = None, +) -> dict: + """Collect msdmd declarations and optional coverage gaps under ``root``.""" + root = root.resolve() + block_names = tuple(block_names) + expected_blocks = tuple(expected_blocks) + + declarations: list[dict] = [] + missing_by_file: dict[str, set[str]] = {} + + for block in block_names: + annotated, _ = walk_tree(root, block) + for file, entries in annotated: + for entry in entries: + if "id" not in entry: + continue + declarations.append(_declaration(file.resolve(), root, block, entry)) + + for block in expected_blocks: + _, missing_files = walk_tree(root, block) + for file in missing_files: + relative = file.resolve().relative_to(root).as_posix() + missing_by_file.setdefault(relative, set()).add(block) + + declarations.sort(key=lambda item: (item["file"], item["block"], item["id"])) + gaps = [ + {"file": file, "missing": sorted(missing)} + for file, missing in sorted(missing_by_file.items()) + ] + edges = [edge for declaration in declarations for edge in _edges_for(declaration)] + edges.sort(key=lambda item: (item["source_block"], item["source_id"], item["kind"], item["to"])) + + collection = { + "repo": repo, + "declarations": declarations, + "gaps": gaps, + "edges": edges, + } + if source_commit: + collection["source_commit"] = source_commit + return collection + + +def render_typescript(collection: dict, *, import_path: str) -> str: + """Render a collection as a `_msdmd.ts` module.""" + payload = json.dumps(collection, indent=2, sort_keys=True) + return ( + f'import {{ defineMsdmdCollection }} from "{import_path}";\n\n' + f"export default defineMsdmdCollection({payload});\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path("."), help="repo root to scan") + parser.add_argument("--repo", required=True, help="repository slug for the collection") + parser.add_argument("--out", type=Path, help="output .ts path; stdout when omitted") + parser.add_argument( + "--block", + action="append", + dest="blocks", + help="block name to collect; may be repeated; defaults to all known blocks", + ) + parser.add_argument( + "--expected-block", + action="append", + default=[], + help="block expected on every source file for gap reporting; may be repeated", + ) + parser.add_argument( + "--import-path", + default="./.agents/skills/msdmd/collection", + help="TypeScript import path for defineMsdmdCollection", + ) + parser.add_argument("--source-commit", help="source commit SHA to record") + args = parser.parse_args() + + collection = collect( + args.root, + args.repo, + block_names=args.blocks or DEFAULT_BLOCK_NAMES, + expected_blocks=args.expected_block, + source_commit=args.source_commit, + ) + rendered = render_typescript(collection, import_path=args.import_path) + if args.out: + args.out.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +# ratios: loc_comments=141:7 imports_exports=6:3 calls_definitions=35:6 diff --git a/.agents/skills/msdmd/collection.ts b/.agents/skills/msdmd/collection.ts new file mode 100644 index 0000000..e86a6cf --- /dev/null +++ b/.agents/skills/msdmd/collection.ts @@ -0,0 +1,74 @@ +// ratios: loc_comments=66:0 imports_exports=0:0 calls_definitions=1:0 +/** + * Shared TypeScript shapes for repo-level msdmd collection points. + * + * A consuming repo's `_msdmd.ts` file may import or copy these + * types, then export a `MsdmdCollection` generated from module-local msdmd + * blocks. This file is type-only: it does not parse source files or validate + * declarations. + */ +export type MsdmdBlockName = + | "DOCS" + | "CAPABILITIES" + | "DEPENDENCIES" + | "OWNERS" + | "CONTRACTS" + | "MODULE_BUILD" + | "BOUNDARIES" + | "RATIOS" + | "LLMS" + | "FRONTEND_META"; + +export type MsdmdFieldMap = Record; + +export interface MsdmdDeclaration { + /** Repository-relative source file that owns the declaration. */ + file: string; + /** msdmd application block name, such as CONTRACTS or DOCS. */ + block: MsdmdBlockName; + /** Stable entry id declared inside the block. */ + id: string; + /** Flat parsed fields, excluding id unless a generator intentionally repeats it. */ + fields: MsdmdFieldMap; +} + +export interface MsdmdGap { + /** Repository-relative source file with missing expected block coverage. */ + file: string; + /** Block types expected by local policy but absent from this file. */ + missing: MsdmdBlockName[]; + /** Optional explanation from the collector or policy layer. */ + reason?: string; +} + +export interface MsdmdEdge { + /** Source declaration id or file path. */ + from: string; + /** Target declaration id, capability id, owner, route, file, or external system. */ + to: string; + /** Relationship kind: requires, exposes, owns, covers, calls, risk, etc. */ + kind: string; + /** Block that produced this edge. */ + source_block: MsdmdBlockName; + /** Entry id that produced this edge. */ + source_id: string; +} + +export interface MsdmdCollection { + /** Repository slug, for example a0 or skill-lib. */ + repo: string; + /** Parsed module-local msdmd entries. */ + declarations: MsdmdDeclaration[]; + /** Visible coverage gaps emitted by collectors or local policy. */ + gaps: MsdmdGap[]; + /** Optional normalized relationship graph for visualizers. */ + edges?: MsdmdEdge[]; + /** Optional collector metadata. */ + generated_at?: string; + source_commit?: string; +} + +export function defineMsdmdCollection(collection: MsdmdCollection): MsdmdCollection { + return collection; +} +// ratios: loc_comments=66:0 imports_exports=0:0 calls_definitions=1:0 diff --git a/.agents/skills/msdmd/parsers/__init__.py b/.agents/skills/msdmd/parsers/__init__.py index 9e9a557..13f9bf0 100644 --- a/.agents/skills/msdmd/parsers/__init__.py +++ b/.agents/skills/msdmd/parsers/__init__.py @@ -1,7 +1,7 @@ -# 0:4 0:0 0:0 +# ratios: loc_comments=0:4 imports_exports=0:0 calls_definitions=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 +# ratios: loc_comments=0:4 imports_exports=0:0 calls_definitions=0:0 diff --git a/.agents/skills/msdmd/parsers/universal.py b/.agents/skills/msdmd/parsers/universal.py index 5e6268f..7bc86bf 100644 --- a/.agents/skills/msdmd/parsers/universal.py +++ b/.agents/skills/msdmd/parsers/universal.py @@ -1,4 +1,4 @@ -# 85:29 0:0 0:0 +# ratios: loc_comments=128:49 imports_exports=4:7 calls_definitions=51:10 """Universal msdmd parser — pure stdlib. Implements the parser contract from ``msdmd/SKILL.md``: extracts every @@ -14,6 +14,14 @@ parse_file(path, block_name) -> list[dict] walk_tree(root, block_name, *, skip=None, extensions=None) -> tuple[annotated, untested] +RATIOS is the one msdmd declaration that is *not* a fenced block — it is a +single comment line carried on a file's first and last non-blank lines. The +reader for it lives here too, as a sanctioned extension rather than a fork: + + parse_ratios(text, marker="#") -> list[dict] + parse_ratios_file(path) -> list[dict] + ratios_placement(text, marker="#") -> tuple[first_ok, last_ok] + This module has zero non-stdlib dependencies and is safe to copy verbatim into any project that wants msdmd support. """ @@ -143,4 +151,64 @@ def iter_source_files(path: Path) -> Iterable[Path]: else: untested.append(path) return annotated, untested -# 85:29 0:0 0:0 + + +# --- RATIOS single-line declaration (msdmd extension) -------------------- +# Unlike every other declaration, RATIOS is not fenced. It is a single +# comment line carrying the three canonical ratios, placed on the file's +# first and last non-blank lines: +# ratios: loc_comments=N:M imports_exports=N:M calls_definitions=N:M +RATIO_IDS = ("loc_comments", "imports_exports", "calls_definitions") +_RATIOS_TOKEN_RE = re.compile(r"(?P[a-z_]+)=(?P\S+)") + + +def _ratios_line_re(marker: str) -> re.Pattern[str]: + return re.compile(rf"^{re.escape(marker)}\s*ratios:\s*(?P.+?)\s*$") + + +def parse_ratios(text: str, marker: str = "#") -> list[dict]: + """Read single-line RATIOS declarations from ``text``. + + RATIOS is not a fenced block: it is one comment line of the form + `` ratios: loc_comments=N:M imports_exports=N:M calls_definitions=N:M`` + placed on the file's first and last non-blank lines. Returns one flat + ``{"id", "value"}`` dict per (declaration line x ratio token) so a drift + gate can verify every occurrence. + """ + line_re = _ratios_line_re(marker) + out: list[dict] = [] + for raw in text.splitlines(): + lm = line_re.match(raw.rstrip()) + if not lm: + continue + for tm in _RATIOS_TOKEN_RE.finditer(lm.group("body")): + out.append({"id": tm.group("key"), "value": tm.group("val")}) + return out + + +def parse_ratios_file(path: Path) -> list[dict]: + """``parse_ratios`` for a file path (marker auto-detected); [] on error.""" + marker = marker_for(path) + if marker is None: + return [] + try: + return parse_ratios(path.read_text(encoding="utf-8"), marker) + except (OSError, UnicodeDecodeError): + return [] + + +def ratios_placement(text: str, marker: str = "#") -> tuple[bool, bool]: + """Return ``(first_line_has_ratios, last_non_blank_line_has_ratios)``.""" + line_re = _ratios_line_re(marker) + lines = text.splitlines() + if not lines: + return (False, False) + first_ok = bool(line_re.match(lines[0].rstrip())) + last_ok = False + for raw in reversed(lines): + if raw.strip() == "": + continue + last_ok = bool(line_re.match(raw.rstrip())) + break + return (first_ok, last_ok) +# ratios: loc_comments=128:49 imports_exports=4:7 calls_definitions=51:10 diff --git a/.agents/skills/msdmd/parsers/universal.ts b/.agents/skills/msdmd/parsers/universal.ts index d889169..2db076f 100644 --- a/.agents/skills/msdmd/parsers/universal.ts +++ b/.agents/skills/msdmd/parsers/universal.ts @@ -1,4 +1,4 @@ -// 110:15 0:6 0:0 +// ratios: loc_comments=176:0 imports_exports=2:0 calls_definitions=53:0 /** * Universal msdmd parser — pure Node stdlib (fs, path). * @@ -11,6 +11,11 @@ * itself is identical across languages; only the per-line marker * changes. * + * RATIOS is the one msdmd declaration that is not a fenced block — it is a + * single comment line on a file's first and last non-blank lines. Its reader + * (parseRatios / parseRatiosFile / ratiosPlacement) lives here too, as a + * sanctioned extension rather than a fork. + * * Zero non-stdlib dependencies. Safe to copy verbatim into any * Node/Deno/Bun project that wants msdmd support. */ @@ -137,4 +142,55 @@ export function walkTree( visit(root); return { annotated, untested }; } -// 110:15 0:6 0:0 + +// --- RATIOS single-line declaration (msdmd extension) -------------------- +// Unlike every other declaration, RATIOS is not fenced. It is a single +// comment line carrying the three canonical ratios, placed on the file's +// first and last non-blank lines: +// ratios: loc_comments=N:M imports_exports=N:M calls_definitions=N:M +export const RATIO_IDS = ["loc_comments", "imports_exports", "calls_definitions"] as const; + +function ratiosLineRe(marker: string): RegExp { + return new RegExp(`^${escapeRegex(marker)}\\s*ratios:\\s*(.+?)\\s*$`); +} + +export function parseRatios(text: string, marker: string = "#"): Entry[] { + const lineRe = ratiosLineRe(marker); + const tokenRe = /([a-z_]+)=(\S+)/g; + const out: Entry[] = []; + for (const raw of text.split("\n")) { + const lm = lineRe.exec(raw.replace(/\s+$/, "")); + if (!lm) continue; + let tm: RegExpExecArray | null; + tokenRe.lastIndex = 0; + while ((tm = tokenRe.exec(lm[1])) !== null) { + out.push({ id: tm[1], value: tm[2] }); + } + } + return out; +} + +export function parseRatiosFile(path: string): Entry[] { + const marker = markerFor(path); + if (marker === null) return []; + try { + return parseRatios(readFileSync(path, "utf-8"), marker); + } catch { + return []; + } +} + +export function ratiosPlacement(text: string, marker: string = "#"): [boolean, boolean] { + const lineRe = ratiosLineRe(marker); + const lines = text.split("\n"); + if (lines.length === 0) return [false, false]; + const firstOk = lineRe.test(lines[0].replace(/\s+$/, "")); + let lastOk = false; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim() === "") continue; + lastOk = lineRe.test(lines[i].replace(/\s+$/, "")); + break; + } + return [firstOk, lastOk]; +} +// ratios: loc_comments=176:0 imports_exports=2:0 calls_definitions=53:0 diff --git a/.agents/skills/msdmd/visualize.py b/.agents/skills/msdmd/visualize.py new file mode 100644 index 0000000..9dfa9d6 --- /dev/null +++ b/.agents/skills/msdmd/visualize.py @@ -0,0 +1,94 @@ +# ratios: loc_comments=63:8 imports_exports=5:3 calls_definitions=40:5 +"""Render an msdmd collection as a small Mermaid relationship graph. + +The input may be raw JSON or the generated TypeScript shape emitted by +``msdmd.collect.render_typescript``. This helper is intentionally minimal: +it visualizes the normalized ``edges`` array from a ``MsdmdCollection`` and +adds gap nodes for visible coverage gaps. +""" +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +_TS_COLLECTION_RE = re.compile(r"defineMsdmdCollection\((?P.*)\);\s*$", re.DOTALL) +_SAFE_NODE_RE = re.compile(r"[^A-Za-z0-9_]") + + +def load_collection(path: Path) -> dict: + """Load a collection from JSON or generated TypeScript.""" + text = path.read_text(encoding="utf-8") + stripped = text.strip() + if stripped.startswith("{"): + return json.loads(stripped) + + match = _TS_COLLECTION_RE.search(stripped) + if not match: + raise ValueError(f"{path} is not JSON or generated defineMsdmdCollection TypeScript") + return json.loads(match.group("payload")) + + +def _node_id(value: str) -> str: + normalized = _SAFE_NODE_RE.sub("_", value).strip("_") + return normalized or "hmmm" + + +def _label(value: str) -> str: + return value.replace('"', "'") + + +def render_mermaid(collection: dict) -> str: + """Render ``collection`` as Mermaid flowchart text.""" + lines = ["flowchart TD"] + repo = collection.get("repo", "repo") + lines.append(f' repo["{_label(str(repo))}"]') + + emitted_nodes = {"repo"} + for declaration in collection.get("declarations", []): + node = _node_id(str(declaration["id"])) + label = f'{declaration["id"]}\\n{declaration["block"]}\\n{declaration["file"]}' + if node not in emitted_nodes: + lines.append(f' {node}["{_label(label)}"]') + lines.append(f" repo --> {node}") + emitted_nodes.add(node) + + for edge in collection.get("edges", []): + source = _node_id(str(edge["from"])) + target = _node_id(str(edge["to"])) + if source not in emitted_nodes: + lines.append(f' {source}["{_label(str(edge["from"]))}"]') + emitted_nodes.add(source) + if target not in emitted_nodes: + lines.append(f' {target}["{_label(str(edge["to"]))}"]') + emitted_nodes.add(target) + lines.append(f' {source} -- "{_label(str(edge["kind"]))}" --> {target}') + + for index, gap in enumerate(collection.get("gaps", []), start=1): + node = f"gap_{index}" + missing = ", ".join(gap.get("missing", [])) + label = f'{gap.get("file", "hmmm")}\\nmissing: {missing or "hmmm"}' + lines.append(f' {node}[["{_label(label)}"]]') + lines.append(f" repo -. gap .-> {node}") + + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("collection", type=Path, help="collection .json or generated .ts file") + parser.add_argument("--out", type=Path, help="output .mmd path; stdout when omitted") + args = parser.parse_args() + + rendered = render_mermaid(load_collection(args.collection)) + if args.out: + args.out.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +# ratios: loc_comments=63:8 imports_exports=5:3 calls_definitions=40:5 diff --git a/.agents/skills/test-build/SKILL.md b/.agents/skills/test-build/SKILL.md index b782621..f3d74e2 100644 --- a/.agents/skills/test-build/SKILL.md +++ b/.agents/skills/test-build/SKILL.md @@ -149,3 +149,7 @@ 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. + +hmmm +- Whether a future test-build runner should standardize quarantine/flaky/slow contract states or leave them to consuming repos. +- The exact boundary between an `ERROR` caused by infrastructure and a `FAIL` caused by violated behavior can get swampy; bring boots.