diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1012f14..3758d52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: jobs: # --------------------------------------------------------------------------- - # Lint — ruff + mypy + banned-terms + prompt freshness + # Lint — ruff + mypy + 5 gates (prompt, gitleaks pin, scorecard schema, manifest schema, refs) # Fast-fail target: ~30s signal on trivial errors # --------------------------------------------------------------------------- lint: @@ -71,6 +71,10 @@ jobs: shell: bash run: python -m codeograph.manifest.schema_cli --check + - name: No unresolvable or private-workspace references + shell: bash + run: python scripts/check_no_workspace_refs.py + # --------------------------------------------------------------------------- # Unit — pytest fast suite (excludes slow/external/eval markers) # strategy.matrix.os: [ubuntu-latest] — extension hook for multi-OS (v1.1) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1350ddb..5d56cdb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,3 +17,8 @@ repos: language: system pass_filenames: false always_run: true + - id: check-no-workspace-refs + name: No unresolvable or private-workspace references + entry: python scripts/check_no_workspace_refs.py + language: system + pass_filenames: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 817b48d..9b34d1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,30 @@ # Contributing +## Before you start + +Codeograph is solo-maintained. v1 has shipped; v1.1 is in planning, and the roadmap is visible in +the [ADR index](docs/adr/README.md) — anything marked deferred there is scope that has been thought +about but not built. + +**Please open an issue before investing effort in a change.** Not a formality: it is so we can agree +scope before you write anything. Some areas are settled and closed to outside proposals, some are +mid-design, and some have constraints that are not obvious from the code — the deterministic-versus-LLM +boundary in particular is load-bearing, and a change that blurs it will be declined however well it +is written. + +What that means in practice: + +| You want to | Start with | +|---|---| +| Report a bug, or ask whether an idea fits | An issue | +| Propose an architectural decision | An issue first, then the [ADR process](docs/adr/README.md) — which requires reserving a number before any ADR file exists | +| Fix something small and self-evident | A PR directly is fine | + +The [ADR index](docs/adr/README.md) also lists the areas that do not accept outside proposals, with +the reason for each. Reading that first will save you time. + +Response times are best-effort — this is not anyone's day job. + ## Commits **Conventional Commits.** Subject line: `(): `. @@ -10,7 +35,7 @@ Scopes follow the codebase layout: `parser`, `graph`, `input`, `analyzer`, `cli` Every AI-assisted commit ends with two attribution trailers (no email required): ``` -AI-assistant: v () +AI-assistant: () Model: ``` @@ -18,8 +43,8 @@ Concrete forms by tool: ``` # Claude Code (Opus / Sonnet) -AI-assistant: Claude Code v0.2.6 (Claude Opus 4.1 via Anthropic) -Model: claude-opus-4-1-20250514 +AI-assistant: Claude Code (Claude Opus 5 via Anthropic) +Model: claude-opus-5 # Google Antigravity (Gemini) AI-assistant: Google Antigravity (Gemini 3.5 Flash via Google) @@ -147,6 +172,8 @@ Modifying an existing prompt version *changes its hash*. The loader strictly ver Architecture decisions land as ADRs under `docs/adr/`, numbered sequentially. Amendments to an existing ADR go in the same file under an "Amendment" heading with the date and rationale. Don't backfill ADRs to justify code — write the ADR first, then implement. +**Proposing a new ADR requires reserving its number first** — see [`docs/adr/README.md`](docs/adr/README.md), which is the single home for the ADR template, numbering rules, status lifecycle, and the reservation process. A PR that adds an ADR without a reserved number will be declined. + ## Running tests We use `pytest` for Python tests and Maven for Java tests. diff --git a/README.md b/README.md index 7d3165b..1d577fa 100644 --- a/README.md +++ b/README.md @@ -191,5 +191,6 @@ The run manifest (`manifest.json`) is written once at the terminal checkpoint, a - [Running Codeograph](docs/running-codeograph.md) — pipeline stages, command order, and exact flag combinations for common scenarios - [Model selection & cost](docs/model-selection-and-cost.md) — which model/provider to pick and what it costs - [Architecture snapshot](docs/architecture.md) — what's wired today +- [Requirements](docs/requirements.md) — what the tool is built to do, marked v1 or v1.1 - [ADRs](docs/adr/) — design decisions and their rationale - [Contributing](CONTRIBUTING.md) — commit conventions, branching, CI diff --git a/docs/adr/ADR-001-project-skeleton.md b/docs/adr/ADR-001-project-skeleton.md index f0b7b64..9101e9d 100644 --- a/docs/adr/ADR-001-project-skeleton.md +++ b/docs/adr/ADR-001-project-skeleton.md @@ -34,7 +34,7 @@ Scope: the `codeograph` Python package skeleton, its entry point, and the single * Bundle A — `argparse` + manual 3-level YAML merge + `python-dotenv` * Bundle B — `typer` + `pydantic-settings` (unified) * Bundle C — `click` + manual 3-level YAML merge + `python-dotenv` -* Bundle D — `click` + `pydantic-settings` (unified) ← code sketches in [adr-001-examples.md](adr-001-examples.md) +* Bundle D — `click` + `pydantic-settings` (unified) ← code sketches in "Code sketches" below ## Decision Outcome @@ -125,7 +125,7 @@ One typed `Settings` object, one click entry point. ## More Information -Code sketches for all three CLI frameworks and all three config bundles live in [adr-001-examples.md](adr-001-examples.md) (scratch workspace, not committed to the repo). +Code sketches for all three CLI frameworks and all three config bundles are in "Code sketches" at the end of this section. Structural outcome — the two files this ADR commits the skeleton to: @@ -188,6 +188,203 @@ References: * `pydantic-settings` docs — https://docs.pydantic.dev/latest/concepts/pydantic_settings/ * `click` docs — https://click.palletsprojects.com/ +### Code sketches + +The sketches the options above were compared against. Same CLI surface in every example: source +path, `--target`, `--max-classes-per-domain`, `--config`, `--dry-run`. + +> **Read these as of the decision date.** They are the material this ADR weighed, not current API. +> The provider set shown (`anthropic | ollama | bedrock`) was later revised by D-013-1 and D-013-7 — +> see ADR-013. Left unchanged so the comparison reads as it did when the choice was made. + +#### CLI frameworks + +`argparse` (stdlib): + +```python +# codeograph/main.py +import argparse + +def main(): + parser = argparse.ArgumentParser(description="Analyse a Spring Boot codebase") + parser.add_argument("source", help="Path to source project") + parser.add_argument("--target", choices=["ts", "go"], default="ts") + parser.add_argument("--max-classes-per-domain", type=int, default=3) + parser.add_argument("--config", default="config.yaml") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + run(args.source, args.target, args.max_classes_per_domain, args.dry_run) +``` + +`click`: + +```python +# codeograph/main.py +import click + +@click.command() +@click.argument("source") +@click.option("--target", type=click.Choice(["ts", "go"]), default="ts", show_default=True) +@click.option("--max-classes-per-domain", type=int, default=3, show_default=True) +@click.option("--config", default="config.yaml", show_default=True) +@click.option("--dry-run", is_flag=True, default=False) +def main(source, target, max_classes_per_domain, config, dry_run): + run(source, target, max_classes_per_domain, dry_run) +``` + +`typer`: + +```python +# codeograph/main.py +import typer +from enum import Enum +from typing import Annotated + +class Target(str, Enum): + ts = "ts" + go = "go" + +app = typer.Typer() + +@app.command() +def main( + source: Annotated[str, typer.Argument(help="Path to source project")], + target: Annotated[Target, typer.Option(help="Target language")] = Target.ts, + max_classes_per_domain: Annotated[int, typer.Option(help="Cap per domain, 0=unlimited")] = 3, + config: Annotated[str, typer.Option()] = "config.yaml", + dry_run: Annotated[bool, typer.Option("--dry-run")] = False, +): + run(source, target.value, max_classes_per_domain, dry_run) +``` + +#### Bundle A — `argparse` + 3-level YAML merge + `python-dotenv` + +``` +# .env (gitignored) +ANTHROPIC_API_KEY=sk-ant-... +LLM_PROVIDER=anthropic +``` + +```yaml +# config.yaml (committed — no secrets) +llm: + model: claude-sonnet-4-6 +max_classes_per_domain: 3 +``` + +```python +# codeograph/config.py +import os +import yaml +from dotenv import load_dotenv + +load_dotenv() # reads .env into os.environ before anything else + +DEFAULTS = { + "target": "ts", + "max_classes_per_domain": 3, + "llm": { + "provider": "anthropic", + "model": "claude-sonnet-4-6", + } +} + +def load(yaml_path: str, cli_overrides: dict) -> dict: + cfg = dict(DEFAULTS) + if os.path.exists(yaml_path): + with open(yaml_path) as f: + cfg = deep_merge(cfg, yaml.safe_load(f) or {}) + cfg.update({k: v for k, v in cli_overrides.items() if v is not None}) + return cfg + +# Secrets accessed at call-site: +# api_key = os.environ["ANTHROPIC_API_KEY"] +``` + +Three explicit layers, nothing implicit. To see where a value came from, you read the merge logic. + +#### Bundle B — `typer` + `pydantic-settings` (unified) + +``` +# .env (gitignored) +ANTHROPIC_API_KEY=sk-ant-... +LLM_PROVIDER=anthropic +``` + +```yaml +# config.yaml (committed — no secrets) +llm_model: claude-sonnet-4-6 +max_classes_per_domain: 3 +``` + +```python +# codeograph/settings.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + yaml_file="config.yaml", # pydantic-settings 2.3+ + # priority: init kwargs > env vars > .env > yaml > field defaults + ) + + # Secrets — from .env or real env only, never from config.yaml + anthropic_api_key: str = Field(default="") + ollama_base_url: str = Field(default="http://localhost:11434") + + # Pipeline config — can come from any layer + llm_provider: str = Field(default="anthropic") + llm_model: str = Field(default="claude-sonnet-4-6") + max_classes_per_domain: int = Field(default=3) + target: str = Field(default="ts") +``` + +```python +# codeograph/main.py (typer CLI overrides settings) +@app.command() +def main(source: str, target: Target = Target.ts, ...): + settings = Settings(target=target.value) # CLI wins over everything + run(source, settings) +``` + +One object, typed and validated. CLI kwargs passed to the constructor override all lower layers. + +#### Bundle C — `click` + 3-level YAML merge + `python-dotenv` + +Same `config.py` and `.env` as Bundle A; the CLI layer is `click` instead of `argparse`. No +structural difference beyond decorator syntax. + +#### Secrets handling — identical across all bundles + +```gitignore +# Secrets — never committed +.env +.env.local +.env.*.local + +# But DO commit the example template +# .env.example <-- not ignored +``` + +``` +# .env.example (committed, placeholder values only) +ANTHROPIC_API_KEY=your-key-here +LLM_PROVIDER=anthropic # anthropic | ollama | bedrock +OLLAMA_BASE_URL=http://localhost:11434 +AWS_PROFILE= # for Bedrock +``` + +Contributors clone, copy `.env.example` to `.env`, and fill in their own keys. The real `.env` is +never committed regardless of which bundle is chosen. + +--- + +*Editorial note, 2026-08-05: these sketches were referenced from this ADR's first commit as a link +to a file that was never committed, so the link had always resolved to nothing. The content is +inlined here unchanged. No decision is altered — the material was always intended to be part of +this ADR's supporting detail.* + ## Amendments **2026-06-14 — Design-review pass (2 decisions + 1 spin-off).** A design review produced two decisions recorded here and one finding that spins off into a dedicated ADR. No prior locked fork is reversed. (Other findings — single-command-vs-group, the "no custom merge code" wording, config.yaml-absent behaviour, the CLI-kwarg→field contract, the stale `--dry-run` note — are description-level corrections handled as documentation, not decisions.) diff --git a/docs/adr/ADR-023-secret-scanning-with-gitleaks.md b/docs/adr/ADR-023-secret-scanning-with-gitleaks.md index c14b9c5..d8c12e2 100644 --- a/docs/adr/ADR-023-secret-scanning-with-gitleaks.md +++ b/docs/adr/ADR-023-secret-scanning-with-gitleaks.md @@ -567,11 +567,13 @@ An admin bypass mechanism is explicitly deferred. The v1.1 trigger is "a real fa The Fork 1 Decision Outcome and the pyproject.toml sketch both treated `.pre-commit-config.yaml` and the `pre-commit` Python framework as new introductions in DC5, including a `# NEW` annotation and an explicit "add `pre-commit` to dev deps" step. -**This framing is incorrect.** The `pre-commit` framework has been present since DC2. The existing `.pre-commit-config.yaml` already contains four hooks: the ADR-014 prompt-hash and gen-constants freshness gates, plus two NFR-1 banned-terms hooks. The `pre-commit` package is already in `[project.optional-dependencies] dev`. +**This framing is incorrect.** The `pre-commit` framework has been present since DC2. The existing `.pre-commit-config.yaml` already contains two hooks: the ADR-014 prompt-hash and gen-constants freshness gates. The `pre-commit` package is already in `[project.optional-dependencies] dev`. **Corrected Fork 1 scope:** the DC5 action is to **add the gitleaks `repo` entry to the existing `.pre-commit-config.yaml`** — not to bootstrap the pre-commit framework. The Decision Outcome's "add `pre-commit` to dev deps" step and the `# NEW` annotation in the pyproject example are removed from the design intent; they were already satisfied before DC5. -**Cascade:** The DC5 M8 implementation plan must **not** overwrite the existing `.pre-commit-config.yaml`; it must append the gitleaks hook entry. Overwriting would clobber the four existing hooks. This is the learner's dev fix (DC5-04 in `dev-dc5.md`). +**Cascade:** The DC5 M8 implementation plan must **not** overwrite the existing `.pre-commit-config.yaml`; it must append the gitleaks hook entry. Overwriting would clobber the two existing hooks. This is the learner's dev fix (DC5-04). + +> **Errata — 2026-08-05.** As first written, this amendment said the config held *four* hooks, counting two additional origin-hygiene hooks. That count was wrong on the date of the amendment: those two hooks had been moved out of the committed config on 2026-06-12, ten days earlier, and the config held two. The figures above are corrected to two. **The decision and its cascade are unaffected** — append the gitleaks entry, do not overwrite, do not bootstrap the framework — and Confirmation #2 was never affected, as it tests for the gitleaks entry specifically rather than counting entries. **No change to Confirmation items** — Confirmation #2 ("`.pre-commit-config.yaml` exists at repo root with one entry whose `repo` field is `https://github.com/gitleaks/gitleaks`") remains correct; it tests for the gitleaks entry specifically, not for other entries' presence. diff --git a/docs/adr/README.md b/docs/adr/README.md index ffea2d9..e7f3d7f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,12 +8,15 @@ ADR numbers reflect **architectural layer** (input → graph → rendering → L ## Status legend -| Symbol | Meaning | -|---|---| -| ✅ | Locked — decision committed; changes require a new ADR or formal amendment | -| 📝 | Drafted — locked-decision form lives in `work/adr-drafts/`, awaiting promotion | -| ⬜ | Designed but not yet drafted | -| ⏸️ | Deferred — v1.1 or post-v1 scope; design not started | +| Symbol | `status:` | Meaning | +|---|---|---| +| ✅ | `accepted` | Decision committed; changes require a new ADR or a formal amendment | +| 📝 | `proposed` | File is in this directory, decision not yet accepted — open for review | +| ⬜ | — | Number **reserved**; no file yet | +| ⏸️ | — | Number reserved, deferred to v1.1 or later; design not started | + +The symbol in the index below and the `status:` field in each ADR's frontmatter describe the same +state. ⬜ and ⏸️ have no file, so they have no frontmatter — the reservation row is the whole record. --- @@ -100,8 +103,73 @@ For someone new to the project who wants to understand the design end-to-end, re --- +## Proposing a new ADR + +Three steps. Step 1 is a precondition — a PR that adds an ADR file without a reserved number will be +declined. + +### 1. Reserve the number + +Open a PR that adds a single row to the **Index by ADR number** table above: the next free number, +the title, `⬜`, and your GitHub handle as owner. Nothing else — no ADR file yet. + +The index is one file with one row per number, so two people claiming the same number produce a +merge conflict. That is the mechanism: reservation is serialised by merge, not by convention. + +A reservation with no PR activity for six months may be released and the number reassigned. + +### 2. Write the ADR + +Once the reservation is merged, add `ADR--.md` to this directory with +`status: proposed`, on a branch, via PR. Use the number you reserved. Follow the template and +conventions below; an ADR that doesn't meet them will be sent back rather than declined. + +Discussion happens in the PR. If the problem needs exploring before a decision is defensible, open +an issue first — that's the cheaper place for option debates than a PR thread. + +### 3. Acceptance + +Merge to `main` with `status: accepted`. A reviewer checks that the decision is genuinely +architectural (see "When to write a new ADR"), that at least two options were considered with real +tradeoffs, that the Confirmation section states how the decision can be verified in code, and that +no forward reference contradicts a locked ADR. + +### Areas that do not accept outside proposals + +These are maintainer-owned by design. Issues and discussion are welcome; ADR PRs will be declined. + +| Area | Why | +|---|---| +| ADR-003 — parsing strategy | Tool-selection call reserved to the maintainer | +| ADR-004 — complexity model | Every threshold must cite a published source | +| ADR-011 — Spring → Go idiom mapping | The mapping design is the project's core exercise | +| README "Point of view" section | Author's personal voice | + +--- + ## Conventions +### Numbering + +- Three digits, sequential from `001`. **Never reuse a number**, including for a withdrawn proposal. +- Numbers `001`–`021` were allocated by architectural layer (input → graph → rendering → LLM → + quality → cross-cutting). That scheme is closed: from `022` onward numbers are allocated in + reservation order. The recommended reading order above carries the layer grouping instead. +- One decision per file. If a file covers two decisions, split it into two numbers. + +### Status lifecycle + +``` +reserved (index row only) → proposed (file, status: proposed) → accepted + ↘ withdrawn (number retired, not reused) +accepted → deprecated + → superseded by ADR-NNN +``` + +An accepted ADR is immutable, with exactly two exceptions: its `status:` field, and append-only +`## Amendments` sections. Never edit the original decision text to reflect a later change — that +destroys the record of what was decided when, which is the entire point of an ADR. + ### When to write a new ADR Write a new ADR when: @@ -145,11 +213,15 @@ Each ADR follows MADR with these sections: --- -## How ADR design rounds work +## Why the numbers don't match the design order -ADRs are designed in **Rounds** (R1–R10), grouped by what gets locked in the same design session and sequenced by which Dev Chunk consumes them. The round sequence is **not** the same as the ADR number sequence — see `work/PROJECT-TRACKER.md` for the round-to-Dev-Chunk timeline. +ADRs 001–021 were designed in batches, grouped by which decisions had to lock together, and +numbered by architectural layer rather than by the order they were written. So the number sequence +is neither the design sequence nor the runtime sequence — which is why the reading order above is +grouped by topic instead. -The round that drafted each ADR is recorded in PROJECT-TRACKER; it isn't part of the ADR itself because the design-time ordering is operational metadata, not architectural metadata. The ADR itself only carries what's needed to understand and confirm the decision. +The design-time ordering is operational metadata, not architectural metadata, so it isn't recorded +in the ADRs themselves. Each ADR carries only what's needed to understand and confirm its decision. --- @@ -160,7 +232,7 @@ Some ADRs introduce concepts referenced across many others: - **Canonical-form sha256** — defined in ADR-006 amendment; consumed by ADR-007 (goldens), ADR-015 (cache key), ADR-022 (manifest integrity). - **`--ast-only` mode** — declared in ADR-007 §"Pipeline orchestration constraint"; honored by ADR-013 §"Decision Outcome". - **`CallContext` (purpose, prompt_id, prompt_version, prompt_content_hash, corpus_id)** — defined in ADR-013 Fork 3; consumed by ADR-015 cache key and telemetry record. -- **Banned terms list** — operational discipline documented in `CONTRIBUTING.md`; no ADR enforces it (manual review baseline, not feature scope). +- **Banned terms** — a maintainer-side review discipline, not a repo-enforced rule and not owned by any ADR. --- diff --git a/docs/manifest-versions.md b/docs/manifest-versions.md index 09ff006..9881dbf 100644 --- a/docs/manifest-versions.md +++ b/docs/manifest-versions.md @@ -64,5 +64,5 @@ bump date. Per ADR-022 Fork 7, every bump requires: (the load-bearing design ADR; Forks 1 + 2 + 7 govern this log) - [ADR-006 — Knowledge Graph Schema](adr/ADR-006-knowledge-graph-schema.md) (the base manifest shape; canonical-form sha256 substrate) -- [ADR-017 — Eval Framework](adr/ADR-017-eval-framework.md) +- [ADR-017 — Eval Framework](adr/ADR-017-evaluation-framework.md) (consuming ADR for the `scorecards` + `compile_checks` pointer collections and the `source_path` / `corpus_id` / `run_id` scalars) diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..a463aa9 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,154 @@ +# Requirements + +The functional and non-functional requirements codeograph is built against. Architecture decisions +that implement them are recorded as ADRs under [`docs/adr/`](adr/README.md), and each ADR cites the +requirements it satisfies — so traceability runs from a decision back to its requirement, not the +other way round. Requirements here state *what* the system must do; they do not name the designs +that chose *how*. + +**Release markers.** Every requirement carries `[v1]` or `[v1.1]`. + +| Marker | Meaning | +|---|---| +| `[v1]` | Shipped in v1 | +| `[v1.1]` | Planned for v1.1 — paths and flags named in these requirements do not exist yet | + +Identifiers are stable and are never reused. A retired requirement keeps its number so existing +references continue to resolve. + +--- + +## Functional + +### Core pipeline + +- **FR-1** `[v1]` — Accept any Java/Spring Boot project directory as input (Gradle or Maven). +- **FR-2** `[v1]` — Extract a structured knowledge graph (JSON) from the source, independent of any + render target. Structure is extracted deterministically (FR-10); semantic enrichment is layered on + top of it (FR-11). +- **FR-3** `[v1]` — Render the knowledge graph into idiomatic TypeScript/NestJS source. +- **FR-4** `[v1.1]` — Render the knowledge graph into idiomatic Go source — same graph, different + target. +- **FR-5** `[v1]` — CLI accepts a `--target` flag to select the renderer. +- **FR-6** `[v1]` — All renderers implement a common `Renderer` interface; no target-specific code in + the core pipeline. +- **FR-7** `[v1]` — Run code-quality evaluations per target (compile, coverage, LLM judge) and + produce target-specific scorecards. +- **FR-7a** `[v1]` — Run graph-quality evaluations — structural completeness, relationship + correctness, schema validity, internal consistency, semantic accuracy, reproducibility, and + golden-graph agreement — and produce a graph scorecard. +- **FR-7b** `[v1]` — Maintain documented JSON Schema files for the knowledge graph under + `codeograph/schema/*.schema.json` as the language-neutral source of truth. Every emitted graph + validates against them: enforced at write time through the generated Pydantic models, and + re-checked by the FR-7a schema-validity evaluation. +- **FR-7c** `[v1]` — The deterministic graph (`graph.json`) is regression-tested against committed + golden files under `tests/golden//` for the project's test corpora — a hand-built + edge-case fixture and a pinned real Spring project. CI diffs every run byte-equal in canonical + form. Golden refreshes are deliberate (`--update-goldens`), and each refresh records its diff + category: update-golden, fix-bug, or intentional-change. The committed example run (FR-9) ships + its `graph.json` as illustrative output, not as a byte-equal regression golden. +- **FR-8** `[v1]` — Support provider switching by environment variable. The v1 provider set is + `anthropic | openrouter | openai_compatible`; `ollama` and `bedrock` are `[v1.1]`. The + `openai_compatible` provider reaches any API-key, OpenAI-compatible endpoint through a + configurable base URL, with no coded vendor allowlist; `openrouter` is its OpenRouter-URL preset. + All providers accept any model id as free-form pass-through — no project-blessed allowlist. +- **FR-9** `[v1]` — Include at least one complete example run per target, with committed output and + evaluation scorecards. +- **FR-10** `[v1]` — Structural and syntactic facts — classes, methods, annotations, imports, + dependencies — are extracted deterministically from the source, not by an LLM. +- **FR-11** `[v1]` — Semantic facts — service intent, domain boundaries, migration hints — are + extracted by an LLM operating on the deterministic output. +- **FR-12** `[v1]` — The complexity scorecard uses named industry-standard metrics with numeric + thresholds. No subjective high/medium/low buckets. +- **FR-13** `[v1]` — CLI accepts `--max-classes-per-domain N` (default 3) to cap rendering output + per domain, bounding token spend and keeping runs reproducible. A value of `0` disables the cap. +- **FR-14** `[v1]` — When the cap is reached, rendering selects classes deterministically, ordered by + a documented rule, so runs are reproducible. The knowledge graph always covers every class — only + rendering is capped. + +### Quality infrastructure + +- **FR-15** `[v1]` — All prompts live as versioned files under `codeograph/prompts/`; scorecards + record the prompt filenames and versions used. +- **FR-16** `[v1]` — Every LLM call emits telemetry: `run_id`, provider, model, prompt file, prompt + version, input and output tokens, cost estimate, latency, and timestamp. Per-call records are + written to a sidecar JSONL; aggregate statistics roll into the run manifest. `run_id` is the + correlation key joining manifest, logs, and telemetry. +- **FR-17** `[v1]` — LLM responses are cached content-addressed on provider, model, prompt version, + rendered prompt, and input hash. Cache hits are recorded in telemetry. +- **FR-18** `[v1]` — Every scorecard records exact model version strings, not family names. +- **FR-19** `[v1]` — A `pytest` suite at `tests/` covers unit and integration tests. CI blocks merge + on failure; unit-test coverage target is 80% or above. +- **FR-20** `[v1]` — Every tool invocation writes a run manifest (`manifest.json`) and structured + logs (`logs.jsonl`) into the output directory, alongside the run's other artefacts. +- **FR-21** `[v1]` — Secret scanning runs in CI on every push and pull request; merges are blocked on + detection. +- **FR-27** `[v1]` — Output-path safety: all run artefacts are written inside the output directory; + an output directory that is the working directory, or an ancestor of it, is rejected; a non-empty + output directory is never silently overwritten — `--force` is required. + +### Quality hardening + +- **FR-22** `[v1.1]` — CLI supports cost estimation before a run (`--dry-run`) and a hard spend + ceiling (`--max-cost-usd`). *v1 ships an interim mechanism instead: call and token ceilings with a + confirmation gate (`--max-llm-calls`, `--max-tokens-total`, `--llm-call-confirm-threshold`).* +- **FR-23** `[v1.1]` — Snapshot tests lock byte-stable rendered output for a fixed input and cached + responses; unexplained drift fails CI. +- **FR-24** `[v1.1]` — Negative tests assert graceful failure with specific error messages for at + least six malformed-input classes. The baseline classes: + 1. **Zip bomb** — extracted size exceeds the configured cap; abort with a size-limit error before + extraction completes. + 2. **Zip path traversal** — an archive entry resolves outside the extraction root; reject the + archive. + 3. **Corrupt or malformed archive** — input is not a readable archive; return a clear error. + 4. **Acquisition failure** — the source cannot be fetched, or the URL is unreachable or + credential-gated; return an actionable error naming the cause. + 5. **Empty corpus** — no `.java` files remain in scope after discovery; fail loudly with a + user-readable message and a non-zero exit, never an empty-graph pass-through. + 6. **No recognised build file** — neither a Maven nor a Gradle build file is present; reject the + corpus with a clear error rather than recording an unknown build system. +- **FR-25** `[v1.1]` — An LLM-judge calibration suite discriminates known-good from known-bad output + at documented thresholds, committed as a calibration record. +- **FR-26** `[v1.1]` — Prompt-injection mitigation: source is wrapped in data tags with explicit + instruction guards, escaped for prompt-breaking sequences, and validated by a negative test. +- **FR-28** `[v1.1]` — A determinism contract documents per-field determinism classification for the + graph schema. + +--- + +## Non-functional + +- **NFR-1** — **Retired.** No longer tracked as a requirement. The number is retained so existing + references resolve. +- **NFR-2** `[v1]` — A defined token-utilization strategy governs LLM input construction; v1 ships + prefix caching. `[v1.1]` A token and cost scorecard surfaces spend per run. +- **NFR-3** `[v1]` — Evaluation results are reproducible: seed, model version, and prompt version are + tracked per target. +- **NFR-4** `[v1]` — CI runs lint, unit tests, and the TypeScript evaluation path on every push. + `[v1.1]` The Go evaluation path lands with the Go renderer. +- **NFR-5** `[v1]` — Adding a new target language requires no changes to the analyzer or the core + pipeline — only a new renderer, a compile-check entry, and renderer-authoring documentation. v1 + ships a short "adding a renderer" section in `CONTRIBUTING.md`. `[v1.1]` The full renderer guide + lands with the second renderer, which is what makes the pattern real. + +--- + +## Revision history + +These requirements were first drafted before implementation began, and were revised during design +review as decisions sharpened or rescoped them. **This section closes at this document's first +commit** — from that point, the git history of this file is the record of change. + +| Date | Requirement | Change | Driver | +|---|---|---|---| +| 2026-06-14 | FR-24 | "6+ malformed-input classes" enumerated as six named classes with required failure behaviours | design review; ADR-002 | +| 2026-06-19 | FR-8 | Provider set replaced — `anthropic \| ollama \| bedrock` became `anthropic \| openrouter \| openai_compatible`; `ollama` and `bedrock` moved to v1.1 | design review; ADR-013 | +| 2026-06-19 | FR-16 | `run_id` added to the telemetry payload as the manifest-to-logs correlation key; per-call records routed to a sidecar JSONL with aggregates in the run manifest | design review; ADR-015, ADR-022 | +| 2026-06-19 | FR-22 | Deferred to v1.1. v1 ships call and token ceilings with a confirmation gate in place of cost estimation and a spend ceiling | design review; ADR-027 | +| 2026-06-19 | NFR-2 | Token and cost scorecard deferred to v1.1. v1 commits graph scorecards and ships prefix caching | design review | +| 2026-06-19 | NFR-5 | Full renderer guide deferred to v1.1; v1 ships a short `CONTRIBUTING.md` section. The guide waits on a second renderer to make the pattern real | design review; ADR-008 | +| 2026-06-29 | FR-8 | Generalised — the OpenAI-compatible provider accepts any base URL, with no vendor allowlist | design review; ADR-013 | +| 2026-08-05 | NFR-1 | Retired. Number retained so existing references resolve | — | + +Three requirements moved from v1 to v1.1 during design review — FR-22, NFR-2 and NFR-5. In each case +v1 ships a narrower capability that meets the immediate need, and the fuller form is scheduled. diff --git a/scripts/check_no_workspace_refs.py b/scripts/check_no_workspace_refs.py new file mode 100644 index 0000000..1da6a97 --- /dev/null +++ b/scripts/check_no_workspace_refs.py @@ -0,0 +1,148 @@ +"""Fail if a committed file points at something a reader cannot open. + +Two checks, both guarding the same promise: a path written in this repository +must resolve for someone who has only cloned this repository. + +1. **Broken relative links.** A markdown link whose target does not exist in the + repo. This is the general case — the target may be a bare filename, a + relative path, or a path into a private sibling directory; all of them render + as clickable and all of them 404. + +2. **Private workspace paths.** Prose or code references to ``work/``, ``plan/`` + or ``archive/`` — sibling directories of the repo root that are not committed. + These are usually not written as links, so check 1 does not see them. + +Both have happened, repeatedly, so this is a gate rather than a convention. It +runs over staged files in pre-commit and over the whole tree in CI. + +Usage:: + + python scripts/check_no_workspace_refs.py # whole tree (CI) + python scripts/check_no_workspace_refs.py FILE... # named files (pre-commit) +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Markdown inline links and images: [text](target) / ![alt](target). +LINK = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)") + +# A private-directory reference: the name followed by a separator. Guarded on the +# left so "framework/", "workflows/", "network/" do not match, and on the right so +# bare prose ("the work we did") does not either. +WORKSPACE_PATH = re.compile(r"(? list[Path]: + out = subprocess.run(["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, check=True) + return [REPO_ROOT / line for line in out.stdout.splitlines() if line] + + +def should_scan(path: Path) -> bool: + try: + rel = path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return False + if rel in EXEMPT_PATHS: + return False + return path.suffix.lower() in TEXT_SUFFIXES + + +def check_links(path: Path, text: str) -> list[tuple[int, str, str]]: + """Markdown links whose target does not exist in the repo.""" + if path.suffix.lower() != ".md": + return [] + hits = [] + for lineno, line in enumerate(text.splitlines(), start=1): + for match in LINK.finditer(line): + target = match.group(1) + if EXTERNAL_SCHEME.match(target): + continue + target = target.split("#", 1)[0] # drop any anchor + if not target: + continue + resolved = (path.parent / target).resolve() + if not resolved.exists(): + hits.append((lineno, f"link target does not exist: {target}", line.strip())) + return hits + + +def check_workspace_paths(text: str) -> list[tuple[int, str, str]]: + """References to private sibling directories.""" + hits = [] + for lineno, line in enumerate(text.splitlines(), start=1): + if any(s in line for s in EXEMPT_SUBSTRINGS): + continue + match = WORKSPACE_PATH.search(line) + if match: + hits.append((lineno, f"private workspace path: {match.group(0)}", line.strip())) + return hits + + +def main(argv: list[str]) -> int: + candidates = [Path(a).resolve() for a in argv] if argv else tracked_files() + + failures: list[tuple[str, int, str, str]] = [] + for path in candidates: + if not path.is_file() or not should_scan(path): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(REPO_ROOT).as_posix() + for lineno, reason, line in check_links(path, text) + check_workspace_paths(text): + failures.append((rel, lineno, reason, line)) + + if not failures: + return 0 + + print("Committed files must not point at things a reader cannot open.\n") + for rel, lineno, reason, line in sorted(failures): + print(f" {rel}:{lineno} — {reason}") + print(f" {line[:120]}") + print( + "\nA path written here must resolve for someone who has only cloned this\n" + "repository. `work/`, `plan/` and `archive/` are not part of it. Inline the\n" + "content, describe it without a path, or drop the reference." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/unit/manifest/__init__.py b/tests/unit/manifest/__init__.py index db4c705..9733f8b 100644 --- a/tests/unit/manifest/__init__.py +++ b/tests/unit/manifest/__init__.py @@ -1,9 +1,5 @@ """Tests for the manifest package (schema, IO, run_id, assembler, CLI gate). -Per the project convention in ``work/guidelines/04-dev-chunk-implementation.md`` -and ``AGENTS.md`` (boilerplate-OK zones for AI generation), the **test -scaffolding** in this package is AI-generated; the **assertion bodies** are -learner-write (the DC5 M12 spec at ``work/dev/DEV-CHUNK-5-KICKOFF.md`` -§"M12" says so explicitly). Each test method has a clear ``# TODO(learner):`` -marker in its body pointing at the ADR-025 Confirmation item it implements. +Each test method carries a ``# TODO`` marker naming the ADR-025 Confirmation +item it implements, so every assertion traces back to the decision it verifies. """