diff --git a/.gitignore b/.gitignore index b10c388..8487ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,7 @@ internal/web/dist/* /.mcp.json /.githooks/ /specs/ + +# csdd operational state (.csdd/): local, regenerable — but commit manifests. +/.csdd/state.json +/.csdd/cache/ diff --git a/go.mod b/go.mod index b36e6a1..8fbbde0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.10.0 + golang.org/x/text v0.3.8 ) require ( @@ -24,5 +25,4 @@ require ( golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.12.0 // indirect golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.3.8 // indirect ) diff --git a/internal/cli/claudemd.go b/internal/cli/claudemd.go index 910f506..e2ad28d 100644 --- a/internal/cli/claudemd.go +++ b/internal/cli/claudemd.go @@ -1,10 +1,12 @@ package cli import ( + "embed" "os" "strings" "github.com/protonspy/csdd/internal/paths" + "github.com/protonspy/csdd/internal/templater" "github.com/protonspy/csdd/internal/textutil" "github.com/protonspy/csdd/internal/workspace" ) @@ -15,6 +17,60 @@ const ( steeringMarkerEnd = "" ) +// Markers delimiting the csdd-managed knowledge-base section inside CLAUDE.md +// (the graph/wiki workflow moments + the tech-contract protocol). +const ( + knowledgeMarkerStart = "" + knowledgeMarkerEnd = "" +) + +// ensureKnowledgeSection writes the knowledge-base workflow moments (R16.3) and +// the tech-contract protocol (R17.4, R17.7) into the managed block of CLAUDE.md. +// It fills the empty markers a fresh template ships with, and — for a legacy +// CLAUDE.md that predates them — appends the whole section (markers + content). +// A no-op when CLAUDE.md is absent. Returns whether the file was changed. +func ensureKnowledgeSection(root string, templates embed.FS) (bool, error) { + entry := paths.Entry(root) + data, err := os.ReadFile(entry) + if err != nil { + return false, nil // no CLAUDE.md — nothing to wire + } + body, err := templater.Static(templates, "templates/root/knowledge-section.md.tmpl") + if err != nil { + return false, err + } + body = strings.TrimRight(body, "\n") + text := textutil.NormalizeNewlines(string(data)) + + start := strings.Index(text, knowledgeMarkerStart) + end := strings.Index(text, knowledgeMarkerEnd) + var rebuilt string + switch { + case start != -1 && end != -1 && end > start: + // Fill (or refresh) the content between existing markers. + before := text[:start+len(knowledgeMarkerStart)] + after := text[end:] + newBlock := before + "\n" + body + "\n" + after + if newBlock == text { + return false, nil + } + rebuilt = newBlock + default: + // Legacy CLAUDE.md without the markers: append the managed section. + section := "\n## Knowledge base — graph, wiki, tech contract\n\n" + + knowledgeMarkerStart + "\n" + body + "\n" + knowledgeMarkerEnd + "\n" + if strings.HasSuffix(text, "\n") { + rebuilt = text + section + } else { + rebuilt = text + "\n" + section + } + } + if err := workspace.AtomicWrite(entry, []byte(rebuilt), 0o644); err != nil { + return false, err + } + return true, nil +} + // ensureSteeringImports inserts `@.claude/steering/` import lines into the // managed block of CLAUDE.md, one per steering file name (e.g. "product.md"). // It is idempotent and a safe no-op when CLAUDE.md is missing or has no managed diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a54c4a2..b96625e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -62,6 +62,10 @@ func Run(args []string, templates embed.FS) int { return runAgent(rest, templates) case "mcp": return runMCP(rest) + case "graph": + return runGraph(rest) + case "wiki": + return runWiki(rest, templates) case "export": return runExport(rest) case "web", "--web": @@ -139,6 +143,8 @@ RESOURCES skill {create,list,show,add-reference,add-script,add-asset,validate,delete} agent {create,list,show,delete} mcp {add,install,presets,list,show,remove,enable,disable,validate} + graph {build,query,path,explain,analyze,export} The knowledge-base index (a structured brain over the workspace). + wiki {init,lint} The LLM-authored knowledge base under docs/ (scaffold + health lint). export {kiro,codex} Convert the workspace to Kiro / Codex format. web Launch a read-only web dashboard (live spec progress + file viewer). diff --git a/internal/cli/e2e_graph_test.go b/internal/cli/e2e_graph_test.go new file mode 100644 index 0000000..fa515cf --- /dev/null +++ b/internal/cli/e2e_graph_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestE2EGoldenPath drives the full knowledge-base flow through the CLI on a +// fixture workspace: init → build → analyze → query, and wiki lint — asserting +// the golden path holds end-to-end and the graph.json is byte-stable across +// rebuilds (task 15.1). +func TestE2EGoldenPath(t *testing.T) { + dir := freshWorkspace(t) + seedSpec(t, dir) + + // build → graph.json exists. + if code, _, e := run(t, "graph", "build", "--root", dir); code != 0 { + t.Fatalf("graph build failed: %s", e) + } + graphPath := filepath.Join(dir, "docs", "graph", "graph.json") + first, err := os.ReadFile(graphPath) + if err != nil { + t.Fatalf("graph.json missing: %v", err) + } + + // A second build with an unchanged corpus is byte-identical (§5.9, R7.2). + if code, _, e := run(t, "graph", "build", "--root", dir, "--full"); code != 0 { + t.Fatalf("second build failed: %s", e) + } + second, _ := os.ReadFile(graphPath) + if string(first) != string(second) { + t.Fatalf("graph.json not byte-stable across rebuilds") + } + + // Incremental (default) also matches the full rebuild byte-for-byte. + if code, _, e := run(t, "graph", "build", "--root", dir); code != 0 { + t.Fatalf("incremental build failed: %s", e) + } + third, _ := os.ReadFile(graphPath) + if string(first) != string(third) { + t.Fatalf("incremental graph.json differs from full rebuild") + } + + // analyze surfaces the seeded gap (criterion 1.2 untested); --strict gates. + if code, out, _ := run(t, "graph", "analyze", "--root", dir); code != 0 || !strings.Contains(out, "criterion") { + t.Fatalf("analyze: code=%d out=%s", code, out) + } + if code, _, _ := run(t, "graph", "analyze", "--strict", "--root", dir); code == 0 { + t.Fatalf("analyze --strict should gate non-zero on findings") + } + + // query resolves the design component. + if code, out, _ := run(t, "graph", "query", "Svc", "--root", dir); code != 0 || !strings.Contains(out, "Svc") { + t.Fatalf("query: code=%d out=%s", code, out) + } + + // wiki lint on the clean init scaffold passes; a dropped raw source fails it. + if code, _, _ := run(t, "wiki", "lint", "--root", dir); code != 0 { + t.Fatalf("clean wiki lint should pass") + } + if err := os.WriteFile(filepath.Join(dir, "docs", "raw", "src.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := run(t, "wiki", "lint", "--root", dir); code == 0 { + t.Fatalf("unprocessed raw source should fail wiki lint") + } +} diff --git a/internal/cli/graph.go b/internal/cli/graph.go new file mode 100644 index 0000000..a2e238c --- /dev/null +++ b/internal/cli/graph.go @@ -0,0 +1,359 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/protonspy/csdd/internal/graph" + "github.com/protonspy/csdd/internal/paths" + "github.com/protonspy/csdd/internal/render" + "github.com/protonspy/csdd/internal/workspace" +) + +// runGraph dispatches `csdd graph `. The graph is a structured brain for +// fast, token-cheap, precise consultation of the workspace: build persists the +// index, query/path/explain traverse it, analyze lints the SDD contract. +func runGraph(args []string) int { + action, rest, err := parseAction("graph", args) + if err != nil { + render.Err(err.Error()) + return 1 + } + if isHelpFlag(action) { + graphHelp() + return 0 + } + switch action { + case "build": + return graphBuild(rest) + case "query": + return graphQuery(rest) + case "path": + return graphPath(rest) + case "explain": + return graphExplain(rest) + case "analyze": + return graphAnalyze(rest) + case "export": + return graphExport(rest) + default: + render.Err("unknown action for `graph`: " + action) + graphHelp() + return 1 + } +} + +func graphHelp() { + fmt.Println(`csdd graph — the knowledge-base index (a structured brain over the workspace). + + build Rebuild docs/graph/graph.json from the corpus (+ appends log.md). + query "" Find nodes by label tier and show their neighborhood. + path Shortest path between two nodes. + explain ") + return 1 + } + g, _, code := loadGraph(root) + if code != 0 { + return code + } + pr, err := graph.Path(g, pos[0], pos[1], maxHops) + if err != nil { + render.Err(err.Error()) + return 1 + } + if jsonOut { + return emitJSON(pr) + } + fmt.Printf("%s\n", render.Bold(pr.From.Label)) + for _, s := range pr.Steps { + arrow := "──" + s.Relation + "──▶" + if s.Direction == "backward" { + arrow = "◀──" + s.Relation + "──" + } + fmt.Printf(" %s %s\n", arrow, s.Node.Label) + } + return 0 +} + +func graphExplain(args []string) int { + fs := flag.NewFlagSet("graph explain", flag.ContinueOnError) + var root string + var jsonOut bool + addRoot(fs, &root) + addJSON(fs, &jsonOut) + pos, err := parseFlags(fs, args) + if err != nil { + return failOnFlagParse(err) + } + if len(pos) == 0 { + render.Err("usage: " + prog() + " graph explain +allowed-tools: Bash(csdd graph path:*), Bash(npx @protonspy/csdd graph path:*) +--- + +## Task + +Use the **graph** skill. For the two nodes named in `$ARGUMENTS` (A then B): + +1. Run `csdd graph path "" "" --json` (or the `npx @protonspy/csdd` form). +2. Read the returned steps — each edge with its real direction. +3. Report the chain and what it implies: what connects A to B, and what would be + affected if either changes. + +If A and B resolve to the same node the CLI errors — pick more specific labels. diff --git a/internal/templater/templates/commands/csdd-graph-query.md.tmpl b/internal/templater/templates/commands/csdd-graph-query.md.tmpl new file mode 100644 index 0000000..9c28c82 --- /dev/null +++ b/internal/templater/templates/commands/csdd-graph-query.md.tmpl @@ -0,0 +1,20 @@ +--- +description: Query the csdd knowledge graph for nodes matching terms and their neighborhood (brain-first, before grepping). +argument-hint: +allowed-tools: Bash(csdd graph query:*), Bash(npx @protonspy/csdd graph query:*) +--- + +## Task + +Use the **graph** skill. Answer `$ARGUMENTS` by consulting the knowledge graph +instead of grepping or reading source: + +1. Run `csdd graph query "$ARGUMENTS" --json` (or `npx @protonspy/csdd graph query …` + when csdd is not installed globally). +2. Read the matched nodes and their 1-hop neighborhood from the JSON. +3. If you need the chain between two nodes, follow up with `csdd graph path`; for a + single node's full local map, `csdd graph explain`. +4. Report the answer from the graph. Only fall back to reading files when the + graph cannot answer. + +Never open `docs/graph/graph.json` by hand — query it. diff --git a/internal/templater/templates/commands/csdd-stack-propose.md.tmpl b/internal/templater/templates/commands/csdd-stack-propose.md.tmpl new file mode 100644 index 0000000..25cd4db --- /dev/null +++ b/internal/templater/templates/commands/csdd-stack-propose.md.tmpl @@ -0,0 +1,18 @@ +--- +description: Propose a technology decision — present 2-3 options with trade-offs and ask the human before amending the docs/stack.md contract. +argument-hint: +allowed-tools: Read, Edit, Bash(csdd graph analyze:*) +--- + +## Task + +Use the **stack** skill's **Propose** workflow for the need in `$ARGUMENTS`: + +1. Confirm the technology is not already a row in `docs/stack.md` **Decided**. +2. Present **2–3 options with trade-offs** (fit, maturity, ops cost, lock-in). +3. **Ask the human to choose.** Do not pick for them, and do not adopt anything. +4. Only after an explicit decision, add the chosen row to the **Decided** table + (`| Domain | Choice | Version | Why | Refs |`). Do not edit dependency + manifests here — that is a separate, human-authorized code change. + +The contract is law: any technology not listed is an open decision. diff --git a/internal/templater/templates/commands/csdd-stack-refine.md.tmpl b/internal/templater/templates/commands/csdd-stack-refine.md.tmpl new file mode 100644 index 0000000..238454e --- /dev/null +++ b/internal/templater/templates/commands/csdd-stack-refine.md.tmpl @@ -0,0 +1,20 @@ +--- +description: Refine a contracted library against current docs (Context7 MCP or web), file the findings as a wiki page, and link it in the contract's Refs. +argument-hint: +allowed-tools: Read, Edit, Write, Bash(npx ctx7:*) +--- + +## Task + +Use the **stack** skill's **Refine** workflow for the library in `$ARGUMENTS` +(before its first use in a feature): + +1. Run the currency check against **current documentation** — Context7 MCP when + configured (`ctx7`/find-docs), else web search. Do not rely on training data + for API/config/version details. +2. Capture the findings as a wiki page `docs/wiki/pages/.md` (via the + **wiki** skill, with `sources:` and `tags`). +3. Link that page in the library's **Refs** column in `docs/stack.md`, and set + its **Version** if it was blank. +4. Report what changed since your prior knowledge, so the implementation uses the + library correctly and current. diff --git a/internal/templater/templates/commands/csdd-wiki-ingest.md.tmpl b/internal/templater/templates/commands/csdd-wiki-ingest.md.tmpl new file mode 100644 index 0000000..f53fa5a --- /dev/null +++ b/internal/templater/templates/commands/csdd-wiki-ingest.md.tmpl @@ -0,0 +1,19 @@ +--- +description: Ingest a raw source from docs/raw/ into the wiki — read it, write/extend pages with provenance, cross-link, update index + log. +argument-hint: [docs/raw/ or a description of what to ingest] +allowed-tools: Read, Write, Edit, Bash(csdd wiki lint:*), Bash(csdd graph build:*) +--- + +## Task + +Use the **wiki** skill's **Ingest** workflow for `$ARGUMENTS` (or the most recent +file the user dropped into `docs/raw/`): + +1. Read the raw source. **Do not modify anything under `docs/raw/`.** +2. Write or extend pages under `docs/wiki/pages/` capturing its knowledge, each + with `title`, `tags`, and `sources: [docs/raw/]` frontmatter. +3. Add `[[wikilinks]]` to related pages; list the new page in `docs/wiki/index.md`. +4. Append `## [YYYY-MM-DD] ingest | ` to `docs/wiki/log.md`. +5. Rebuild and check: `csdd graph build` then `csdd wiki lint` (must be clean). + +Never restate spec/code internals — link to them. diff --git a/internal/templater/templates/commands/csdd-wiki-lint.md.tmpl b/internal/templater/templates/commands/csdd-wiki-lint.md.tmpl new file mode 100644 index 0000000..ab15c2f --- /dev/null +++ b/internal/templater/templates/commands/csdd-wiki-lint.md.tmpl @@ -0,0 +1,18 @@ +--- +description: Run the deterministic wiki health check — broken wikilinks, orphan pages, index/log desync, unprocessed raw sources — and fix what it finds. +argument-hint: [--json] +allowed-tools: Bash(csdd wiki lint:*), Bash(npx @protonspy/csdd wiki lint:*), Read, Write, Edit +--- + +## Task + +Use the **wiki** skill's **Maintain** workflow: + +1. Run `csdd wiki lint --json` (append `$ARGUMENTS`). It exits non-zero when + anything is found. +2. Fix each finding at the source: + - broken `[[wikilink]]` → correct the target or create the page. + - orphan page / index desync → list it in `index.md` (or link it from a page). + - malformed `log.md` entry → rewrite it as `## [YYYY-MM-DD] <op> | <title>`. + - **unprocessed raw source** → run the **Ingest** workflow for it. +3. Re-run `csdd wiki lint` until it is clean (exit 0). Report what you fixed. diff --git a/internal/templater/templates/commands/csdd-wiki-query.md.tmpl b/internal/templater/templates/commands/csdd-wiki-query.md.tmpl new file mode 100644 index 0000000..105f5c6 --- /dev/null +++ b/internal/templater/templates/commands/csdd-wiki-query.md.tmpl @@ -0,0 +1,17 @@ +--- +description: Answer a question from the accumulated wiki knowledge — read index + pages, use the graph for structure, synthesize with citations. +argument-hint: <question> +allowed-tools: Read, Bash(csdd graph query:*), Bash(csdd graph explain:*), Write +--- + +## Task + +Use the **wiki** skill's **Query** workflow to answer `$ARGUMENTS`: + +1. Read `docs/wiki/index.md`, then drill into the relevant pages. +2. For structural questions, run `csdd graph query`/`explain` (the wiki is part of + the same graph). +3. Synthesize an answer **with citations** to the pages/sources you used. +4. If the synthesis is worth keeping, file it back as a new page under + `docs/wiki/pages/` (with `sources:`), list it in `index.md`, and append + `## [YYYY-MM-DD] query | <title>` to `log.md` — explorations compound. diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl index c3607ac..b322dec 100644 --- a/internal/templater/templates/root/CLAUDE.md.tmpl +++ b/internal/templater/templates/root/CLAUDE.md.tmpl @@ -226,6 +226,16 @@ commits or PRs. Escalate to a human when a feature touches auth, payments, PII, or regulated data; when a change needs `--force` past a gate; or before anything destructive. +## Knowledge base — graph, wiki, tech contract + +The workflow moments for the knowledge base (consult the graph before searching; +rebuild after changes; ingest raw sources; gate with `analyze`/`wiki lint`) and +the tech-contract protocol live in the managed block below. `csdd init` keeps it +in sync — do not hand-edit between the markers. + +<!-- csdd:knowledge:start --> +<!-- csdd:knowledge:end --> + ## Project memory (steering) Steering files are always-on project memory, loaded via the `@`-imports below. diff --git a/internal/templater/templates/root/knowledge-section.md.tmpl b/internal/templater/templates/root/knowledge-section.md.tmpl new file mode 100644 index 0000000..4312a8c --- /dev/null +++ b/internal/templater/templates/root/knowledge-section.md.tmpl @@ -0,0 +1,32 @@ +The workspace carries a **knowledge base** — a graph brain and an LLM-wiki — plus +a **tech contract**. Drive them through the CLI (or `npx @protonspy/csdd …`); the CLI +is the only author of `docs/graph/` and `.csdd/`, and nothing ever edits +`docs/raw/`. + +**Workflow moments — do these without being asked:** + +- **Consult the graph before searching.** Before you grep or read source to + understand structure, query the brain: `csdd graph query "<terms>"`, + `csdd graph explain "<label>"`, `csdd graph path "<A>" "<B>"`. Understanding + should come from traversing relationships, not re-reading files. +- **Rebuild after changing** specs, tasks, `.claude/`, `docs/`, or `.go` files: + `csdd graph build` (byte-stable output — commit it with the change). +- **Ingest raw sources.** When a file lands in `docs/raw/`, run the `wiki` skill's + **Ingest** workflow: read it, write/extend pages under `docs/wiki/pages/` with + `sources:` provenance, cross-link, update `index.md` + `log.md`. +- **Gate before commit/PR:** `csdd graph analyze --strict` (SDD + tech lints) and + `csdd wiki lint` (wiki health) must be clean. + +**The tech contract (`docs/stack.md`) is law.** Any technology **not** listed +there is an **open decision**: propose 2–3 options with trade-offs and **ask the +human** before adopting — never introduce a dependency silently (this is what +stops assuming Redis when the decision was RabbitMQ). Before the first use of a +listed library in a feature, **refine** it against current documentation +(Context7 MCP when configured, else web search) and file the findings as a wiki +page linked from that row's **Refs** column. Use the **stack** skill for all of +this. + +**Invoke the `stack` skill whenever:** a new dependency or technology is about to +enter the project; `csdd graph analyze` reports a tech finding +(`undeclared`/`phantom`/`unrefined`); or the user mentions adopting or changing +technology. diff --git a/internal/templater/templates/rules/knowledge-base.md.tmpl b/internal/templater/templates/rules/knowledge-base.md.tmpl new file mode 100644 index 0000000..2f01c70 --- /dev/null +++ b/internal/templater/templates/rules/knowledge-base.md.tmpl @@ -0,0 +1,26 @@ +# Knowledge base — consult it before searching + +csdd maintains a **knowledge base** in two cooperating halves. Use it; do not +re-derive what it already holds. + +- **The graph** (`docs/graph/graph.json`, built by `csdd graph build`) is a + structured brain over specs, `.claude/`, `docs/`, the tech contract, and the Go + source. **Before you grep or read code** to understand structure, query it: + `csdd graph query "<terms>"`, `csdd graph explain "<label>"`, + `csdd graph path "<A>" "<B>"`. Understanding should come from traversing + relationships, not from re-reading files. +- **The wiki** (`docs/wiki/`, authored via the `wiki` skill from immutable sources + in `docs/raw/`) is the prose knowledge base. **Read `docs/wiki/index.md`** before + searching the docs. + +Discipline: + +- **Rebuild after you change** specs, tasks, `.claude/`, `docs/`, or `.go` files: + `csdd graph build` (byte-stable; commit the regenerated `graph.json` with the + change). +- **Gate before review/commit**: `csdd graph analyze --strict` (SDD + tech lints) + and `csdd wiki lint` (wiki health) must be clean. +- **The CLI is the only author of `docs/graph/` and `.csdd/`.** Never hand-edit + them; never modify `docs/raw/`. +- A new dependency or technology is an **open decision** — use the `stack` skill: + propose options, ask, and only then amend `docs/stack.md`. diff --git a/internal/templater/templates/skills/graph/SKILL.md.tmpl b/internal/templater/templates/skills/graph/SKILL.md.tmpl new file mode 100644 index 0000000..6b49cd9 --- /dev/null +++ b/internal/templater/templates/skills/graph/SKILL.md.tmpl @@ -0,0 +1,85 @@ +--- +name: graph +description: Use to consult or refresh the csdd knowledge graph — before grepping/reading code to understand structure, after editing specs/tasks/code, and before review/commit. Drives everything through the `csdd graph` CLI; never writes docs/graph/ or .csdd/ directly. +--- + +# Graph — the structured brain + +## Goal + +Understand and govern the workspace by **querying its structure**, not by reading +all of it. The graph is a deterministic, persistent index of every csdd artifact, +the docs/ knowledge base, the tech contract, and (once present) the Go source — +queried in milliseconds, answered within a token budget. Reach for it *before* +grep/read and *before* review, so comprehension comes from traversing +relationships (a few hundred tokens) instead of re-reading files (hundreds of +thousands). + +The CLI is the **single mutation path** for the graph. This skill only ever +*runs* `csdd graph …` (or `npx @protonspy/csdd graph …` when csdd is not installed +globally). It never edits `docs/graph/` or `.csdd/` by hand — those are generated. + +## Execution Workflow + +Pick the moment, run the command, read the output — do not open `graph.json`. + +1. **Consult before searching (the brain-first habit).** Before you grep or read + source to answer "what depends on X?", "what implements Y?", "where are the + hubs?", ask the graph: + - `csdd graph query "<terms>" --json` — nodes matching a label, with their + 1-hop neighborhood. Start here for "what is X and what touches it". + - `csdd graph explain "<label>" --json` — one node plus its connections + ordered by neighbor degree (the local map). + - `csdd graph path "<A>" "<B>" --json` — the shortest chain between two nodes + (e.g. a requirement and a code file): "what connects these, and what breaks + if I change it". +2. **Rebuild after editing specs, tasks, or code.** Any change to `specs/`, + `.claude/`, `docs/`, or `.go` files makes the index stale: + - `csdd graph build` — full deterministic rebuild of `docs/graph/graph.json` + (+ one line appended to `docs/graph/log.md`). Byte-stable for an unchanged + corpus, so commit the result with the change that caused it. +3. **Analyze before review or commit.** Run the mechanical SDD lint and treat it + as a gate: + - `csdd graph analyze --strict --json` — untested criteria, orphan tasks, + unimplemented components, pending references, dependency cycles, plus wiki + and tech findings. `--strict` exits non-zero, so it fails a pre-commit check. + +### Consuming `--json` + +Every subcommand takes `--json` and prints a machine-readable object on stdout +(diagnostics go to stderr). Parse that instead of scraping the human table. +`analyze --json` returns `{ "findings": [ {kind, corpus, node_id, label, +message, file} ], "god_nodes": [...] }`; filter `corpus` to focus on `spec`, +`wiki`, or `tech`. + +## Gotchas + +- **Never hand-edit `docs/graph/` or `.csdd/`.** They are generated; edits are + overwritten on the next `build` and corrupt the byte-stable diff. Change the + *source* artifact and rebuild. +- **`analyze` reflects the live corpus, not the last committed `graph.json`.** It + rebuilds in memory, so it is always current — but that means a green `analyze` + needs the same working tree the reviewer will see. +- **Range shorthand is a parse error.** `_Requirements: 3.1-3.5_` is reported, + not expanded. List IDs explicitly: `3.1, 3.2, 3.3, 3.4, 3.5`. +- **A pending reference is a real gap, not noise.** It means an annotation points + at an ID that does not exist — fix the annotation or add the missing artifact. +- **Tech findings route to the stack skill.** `undeclared_tech`,`phantom_tech`, + and `unrefined_tech` are contract issues — hand them to the `stack` skill's + Reconcile workflow, do not silently edit manifests. + +## Verification Before Reporting + +- You ran the actual `csdd graph …` command this session and read its output — + never report graph facts from memory or a stale `graph.json`. +- Before claiming the contract is clean, `csdd graph analyze --strict` exited 0. +- After any spec/task/code edit you intend to commit, `csdd graph build` ran and + the regenerated `docs/graph/graph.json` is staged alongside the change. + +## Completion Criteria + +- The question was answered by a graph query/path/explain, not by re-reading + source, whenever the graph could answer it. +- `docs/graph/graph.json` is current with the working tree (rebuilt after edits). +- `analyze` findings are either resolved or explicitly acknowledged to the human; + none were silently ignored. diff --git a/internal/templater/templates/skills/stack/SKILL.md.tmpl b/internal/templater/templates/skills/stack/SKILL.md.tmpl new file mode 100644 index 0000000..4dc95ef --- /dev/null +++ b/internal/templater/templates/skills/stack/SKILL.md.tmpl @@ -0,0 +1,83 @@ +--- +name: stack +description: Use whenever a new dependency/technology is about to enter the project, when `csdd graph analyze` reports tech findings, or when the user mentions adopting or changing technology. Owns the docs/stack.md contract — Propose, Refine, Reconcile — behind a human-decision gate. Never edits manifests or adopts tech without approval. +--- + +# Stack — the tech contract + +## Goal + +Keep **stack/library/framework decisions the human's**, written down in +`docs/stack.md` as a contract the agent must follow — so architectural choices +stay deliberate, libraries are used current and correctly, and silent tech +decisions (assuming Redis when the decision was RabbitMQ) stop happening. + +**The contract is law.** Any technology not listed in `docs/stack.md` is an +**open decision** — you propose options and ask; you do not adopt. This skill +never edits dependency manifests and never adopts a technology without an +explicit human decision. + +## The contract file + +`docs/stack.md` has three sections: +- **Decided** — a parseable table `| Domain | Choice | Version | Why | Refs |`. + Each row is indexed as a `tech` node and matched against declared dependencies. +- **Rules** — the decision protocol (pre-filled; see below). +- **Open questions** — pending decisions. + +## Execution Workflow + +Three workflows, each gated on a human decision. + +1. **Propose** (a new tech need appears): + - Identify the need (broker, cache, router, ORM, …). + - Present **2–3 options with trade-offs** (fit, maturity, ops cost, lock-in). + - **Ask the human to choose.** Do not pick for them. + - Only *after* approval, amend the **Decided** table with the chosen row + (Domain, Choice, Version, Why, Refs). Add nothing to manifests yourself + beyond what the human authorized. +2. **Refine** (before first use of a contracted library in a feature): + - Run the currency check against **current documentation** — Context7 MCP when + configured (`ctx7`/find-docs), else web search. Never rely on training data + for API details, config, or version specifics. + - File the findings as a wiki page (`docs/wiki/pages/<lib>.md`, via the wiki + skill) and link it in that row's **Refs** column. +3. **Reconcile** (react to `csdd graph analyze` tech findings): + - `undeclared_tech` → a dependency is used but absent from the contract: + **Propose** it (options + ask) or remove the dependency. This is the + Celery+Redis catcher — do not just add the row silently. + - `phantom_tech` → a contract entry with no detected usage: confirm intent + (planned?) or drop the entry. + - `unrefined_tech` → a contract entry missing Version or Refs: run **Refine**. + +## Gotchas + +- **Never adopt or swap technology without an explicit human decision.** Options + and a question first; amendment only after approval. +- **Never edit dependency manifests** (`go.mod`, `package.json`, + `pyproject.toml`, `requirements.txt`) as part of this skill — that is a code + change the human authorizes separately. +- **Match names so lints resolve.** The contract `Choice` is matched to a + dependency by normalized short name (`chi` ↔ `github.com/go-chi/chi/v5`). Name + the choice to match the library, or the lints misfire. +- **Refine uses live docs, not memory.** Context7 MCP when available, else web + search — then record what you found as a wiki page in Refs. +- **No `docs/stack.md` is a lint finding, not an error.** Adoption is + incremental; scaffold it with `csdd init` (or create it) when the project is + ready to commit its stack. + +## Verification Before Reporting + +- Any technology you are about to introduce is either already in the **Decided** + table, or you presented options and the human explicitly chose. +- After amending the contract, `csdd graph analyze` no longer reports the tech + finding you were resolving. +- Refined entries carry a **Refs** link to a wiki page built from current docs. + +## Completion Criteria + +- Every technology in play is a human-approved row in `docs/stack.md`; no silent + additions. +- `analyze` tech findings are resolved (Propose/confirm/drop/Refine) or explicitly + deferred to the human in Open questions. +- No manifest was edited and no library was adopted without an explicit decision. diff --git a/internal/templater/templates/skills/wiki/SKILL.md.tmpl b/internal/templater/templates/skills/wiki/SKILL.md.tmpl new file mode 100644 index 0000000..b2cc7cd --- /dev/null +++ b/internal/templater/templates/skills/wiki/SKILL.md.tmpl @@ -0,0 +1,84 @@ +--- +name: wiki +description: Use to build and maintain the docs/ knowledge base from raw sources — when the user drops a file into docs/raw/ (Ingest), when answering from accumulated knowledge (Query), or when keeping the wiki healthy (Maintain). You author the prose; the CLI scaffolds and lints deterministically. +--- + +# Wiki — the LLM-authored knowledge base + +## Goal + +Turn whatever the user drops into `docs/raw/` (articles, notes, transcripts, +clippings — immutable, never edited) into a **solid, structured, interlinked +markdown knowledge base** under `docs/wiki/` that is cheap for an LLM to consume. +This is Karpathy's LLM-wiki pattern instantiated on csdd: **you author content**; +the **CLI scaffolds and lints** (`csdd wiki init|lint`) but never writes prose and +never touches `docs/raw/`. + +**Boundary (do not cross):** the wiki is a knowledge base built from +`docs/raw/` — it is **not** documentation *of the specs*. `specs/` holds +per-feature contracts; the wiki holds knowledge. When a page must mention project +internals, **link** to them (a path or `[[wikilink]]`) — never restate them. +Duplicated text is drift waiting to happen and a Maintain finding. + +## Page conventions + +- **One concept per page**, under `docs/wiki/pages/<slug>.md`. +- **Frontmatter**: `title`, `tags: [..]`, and — crucially — `sources: [docs/raw/<file>]` + for provenance (every page records the raw source it was derived from). +- **`[[wikilinks]]`** connect related pages. `[[Page Title]]`, `[[slug|alias]]`, + and `[[slug#anchor]]` all resolve to the page whose filename matches the slug. +- **`index.md`** is the catalog (every page is listed under a category); + **`log.md`** is the append-only chronology. + +## Execution Workflow + +Three workflows. Deterministic structure comes from the CLI; judgment is yours. + +1. **Ingest** (a source landed in `docs/raw/`): + - Read the raw file. Do **not** modify it. + - Write or extend pages under `docs/wiki/pages/` capturing its knowledge, with + `sources:` pointing back at the raw file (this creates the `derived_from` + provenance edge that clears the "unprocessed source" lint). + - Add `[[wikilinks]]` to related pages; add the new page to `index.md` under + the right category. + - Append a `log.md` entry: `## [YYYY-MM-DD] ingest | <title>`. +2. **Query** (answer from accumulated knowledge): + - Read `index.md`, drill into the relevant pages, and — for structural + questions — run `csdd graph query`/`explain` (the wiki is part of the same + graph). Synthesize with citations. + - When the synthesis is worth keeping, file it back as a new page (explorations + compound). Append `## [YYYY-MM-DD] query | <title>`. +3. **Maintain** (keep it healthy): + - Run `csdd wiki lint` and fix every finding — broken `[[wikilinks]]`, orphan + pages, index desync, malformed `log.md` entries, and **unprocessed raw + sources** (Ingest them). + - Look for contradictions or stale claims between pages; reconcile them. + - Append `## [YYYY-MM-DD] refactor | <what changed>`. + +## Gotchas + +- **Never edit `docs/raw/`.** It is immutable input for everyone — CLI, LLM, and + (by convention) the user only adds, never rewrites. +- **Never restate spec/code content.** Link to it. A page that duplicates + internals is a Maintain finding the moment the source changes. +- **`sources:` is not optional.** A page without it reads as knowledge from + nowhere and leaves its raw source flagged "unprocessed". +- **`log.md` format is linted.** Entries must be exactly + `## [YYYY-MM-DD] <op> | <title>` with `op ∈ ingest|query|lint|refactor`. +- **The CLI is the only author of `docs/graph/`.** Rebuild with `csdd graph build` + after ingesting so the wiki corpus is reflected in the graph. + +## Verification Before Reporting + +- `csdd wiki lint` ran this session and reports **no findings** (exit 0), or every + finding is explicitly acknowledged to the human. +- Every new/edited page carries `title` and `sources:` frontmatter and is listed + in `index.md`. +- A `log.md` entry was appended for the operation, in the documented format. + +## Completion Criteria + +- New knowledge from `docs/raw/` is captured in `docs/wiki/pages/` with provenance + and cross-links; nothing under `docs/raw/` was modified. +- `index.md` and `log.md` are in sync with the pages on disk (lint clean). +- No page restates spec/code content — internals are linked, not duplicated. diff --git a/internal/templater/templates/wiki/graph-README.md.tmpl b/internal/templater/templates/wiki/graph-README.md.tmpl new file mode 100644 index 0000000..1a57041 --- /dev/null +++ b/internal/templater/templates/wiki/graph-README.md.tmpl @@ -0,0 +1,38 @@ +# `docs/graph/` — the knowledge-base index (generated) + +> **Generated by the csdd CLI — do not edit by hand.** This is the **only +> generated subtree inside `docs/`**. Everything else under `docs/` is +> human/LLM-authored; everything here is rebuilt deterministically by +> `csdd graph build`. + +This directory holds the **structured brain** of the project: a persistent +knowledge graph an LLM queries to understand the codebase without reading all of +it — fast, token-cheap, precise. It lives inside `docs/` because it *is* knowledge +(committed, shared, reviewed in PRs); csdd's machine state lives in `.csdd/`. + +## What lives here + +| File | Purpose | Writer | +|------|---------|--------| +| `graph.json` | The knowledge graph (NetworkX node-link, graphify-interoperable). | `csdd graph build` | +| `graph.html` | Self-contained interactive visualization. | `csdd graph export` | +| `log.md` | Append-only chronological record of builds. | every build | + +## The graph model + +One node per artifact/entity; one edge per relationship. Node IDs are canonical +and deterministic (NFKC → non-word→`_` → casefold), so the same artifact always +yields the same node ID across rebuilds. Every node carries +`{id, label, file_type, source_file, source_location}`; every edge carries +`{source, target, relation, confidence, confidence_score, source_file}`. + +**Confidence:** `EXTRACTED` (explicit annotation / exact cited path, 1.0) · +`INFERRED` (resolved by name/path match, 0.55–0.95) · `AMBIGUOUS` (surfaced for +the human gate, 0.1–0.3). + +## Consume it, don't read it + +Query the graph with `csdd graph query|path|explain`, lint it with +`csdd graph analyze`. The JSON is byte-stable for an unchanged corpus (total +deterministic ordering), so it diffs cleanly in PRs. Everything in `docs/` — +including this generated directory — is committed. diff --git a/internal/templater/templates/wiki/index.md.tmpl b/internal/templater/templates/wiki/index.md.tmpl new file mode 100644 index 0000000..3677935 --- /dev/null +++ b/internal/templater/templates/wiki/index.md.tmpl @@ -0,0 +1,20 @@ +# Wiki index + +The catalog of the knowledge base. Every page under `pages/` is listed here, +grouped by category. `csdd wiki lint` flags pages missing from this index and +index entries pointing at missing files. + +> **Entry format:** `- [Page Title](pages/<slug>.md) — one-line summary`. +> Keep entries under the right category heading; add categories as the base grows. + +## Concepts + +<!-- - [Example Concept](pages/example-concept.md) — what it is, in one line. --> + +## Guides + +<!-- - [Example Guide](pages/example-guide.md) — how to do X. --> + +## References + +<!-- - [Example Reference](pages/example-reference.md) — pinned facts / API notes. --> diff --git a/internal/templater/templates/wiki/log.md.tmpl b/internal/templater/templates/wiki/log.md.tmpl new file mode 100644 index 0000000..7d2fc9d --- /dev/null +++ b/internal/templater/templates/wiki/log.md.tmpl @@ -0,0 +1,16 @@ +# Wiki log + +Append-only chronology of knowledge-base operations. One heading per operation. +`csdd wiki lint` flags any `## [` heading that does not match the format below. + +> **Entry format:** `## [YYYY-MM-DD] <op> | <title>` where +> `op ∈ ingest | query | lint | refactor`. `grep "^## \[" log.md | tail -5` must +> always list the five most recent operations. + +<!-- Newest entries at the bottom. Example: + +## [2026-01-01] ingest | Payment webhooks overview + +Ingested docs/raw/stripe-webhooks.md into pages/payment-webhooks.md; linked from +[[payments]]; added to index under Concepts. +--> diff --git a/internal/templater/templates/wiki/raw-README.md.tmpl b/internal/templater/templates/wiki/raw-README.md.tmpl new file mode 100644 index 0000000..a081a2e --- /dev/null +++ b/internal/templater/templates/wiki/raw-README.md.tmpl @@ -0,0 +1,17 @@ +# `docs/raw/` — the raw-source dropzone (immutable) + +Drop sources here **verbatim**: articles, notes, transcripts, clippings, exported +threads — anything you want turned into knowledge. One rule: + +> **Nothing here is ever edited — not by the CLI, not by the LLM.** Raw sources +> are immutable input. You only ever *add* files. + +The `wiki` skill's **Ingest** workflow reads what you drop here and writes +structured, interlinked pages under `docs/wiki/pages/`, recording provenance with +a `sources:` frontmatter entry pointing back at the raw file. Until a page derives +from a raw file, `csdd wiki lint` flags it as an **unprocessed source** — so +nothing you drop is silently forgotten. + +The graph indexes each file here as an opaque `raw_source` node (path only; the +content is never parsed). Its purpose in the graph is provenance (`derived_from` +targets) and the unprocessed-source lint. diff --git a/internal/templater/templates/wiki/stack.md.tmpl b/internal/templater/templates/wiki/stack.md.tmpl new file mode 100644 index 0000000..7f4ffae --- /dev/null +++ b/internal/templater/templates/wiki/stack.md.tmpl @@ -0,0 +1,37 @@ +# Tech contract + +The stack/library/framework decisions for this project, authored by **humans**. +This file is **law**: any technology not listed below is an **open decision** — +the agent proposes options and asks before adopting. `csdd graph analyze` indexes +the Decided table and diffs it against declared dependencies, reporting undeclared +usage, phantom entries, and unrefined rows. + +Manage it with the `stack` skill (Propose / Refine / Reconcile). Do not prefill +technology choices you have not decided — the structure is provided; the decisions +are yours. + +## Decided + +| Domain | Choice | Version | Why | Refs | +|---|---|---|---|---| +<!-- | Language | ... | ... | ... | ... | --> +<!-- | HTTP router | ... | ... | ... | [notes](wiki/pages/<lib>.md) | --> + +## Rules + +1. **The contract is law.** Use only technologies listed in **Decided**. Anything + else is an open decision — propose 2–3 options with trade-offs and **ask the + human** before adopting. Never introduce a dependency silently. +2. **Refine before first use.** Before first using a listed library in a feature, + check it against **current documentation** (Context7 MCP when configured, else + web search), file the findings as a wiki page, and link it in that row's + **Refs**. Do not rely on training data for API/config/version details. +3. **Reconcile on findings.** When `csdd graph analyze` reports a tech finding, + resolve it: `undeclared` → propose it or remove the dependency; `phantom` → + confirm intent or drop the entry; `unrefined` → refine it. +4. **Decisions are the human's.** The agent never edits dependency manifests or + adopts technology without an explicit human decision. + +## Open questions + +<!-- - Which broker: RabbitMQ vs Redis Streams vs Kafka? (owner: ..., due: ...) --> diff --git a/internal/templater/templates/wiki/state-README.md.tmpl b/internal/templater/templates/wiki/state-README.md.tmpl new file mode 100644 index 0000000..ef73fae --- /dev/null +++ b/internal/templater/templates/wiki/state-README.md.tmpl @@ -0,0 +1,23 @@ +# `.csdd/` — csdd operational state + +Machine state only — the csdd analog of `.git/`. **No knowledge artifacts live +here**: the knowledge graph is at `docs/graph/` and the wiki at `docs/wiki/`. The +presence of this directory at the project root is the **workspace marker**: +`csdd init` creates it, and `csdd graph`/`csdd wiki` require it. + +| File | Purpose | Committed? | +|------|---------|------------| +| `manifest.json` | Content hashes of the csdd-installed core files, used by `csdd update` to tell your edits from core drift. Migrated from the legacy `.claude/.csdd-manifest.json` (read-fallback kept). | yes | +| `state.json` | Incremental graph-build state (per-source hashes). Regenerable. | no (local) | +| `cache/` | Per-file extraction fragments keyed by content hash. Regenerable. | no (local) | + +Recommended `.gitignore`: + +```gitignore +.csdd/state.json +.csdd/cache/ +``` + +Everything here except committed manifests is safe to delete — csdd rebuilds it. +If you are looking for the graph, it is at `docs/graph/graph.json` (query it with +`csdd graph query`, don't read it raw). diff --git a/internal/web/frontend/src/App.tsx b/internal/web/frontend/src/App.tsx index 9e6489a..c5fbd92 100644 --- a/internal/web/frontend/src/App.tsx +++ b/internal/web/frontend/src/App.tsx @@ -8,18 +8,20 @@ import { SpecView } from './components/SpecView' import { FileViewer } from './components/FileViewer' import { TestsView } from './components/TestsView' import { ResourceView } from './components/ResourceView' +import { GraphView } from './components/GraphView' import { AuthScreen } from './components/AuthScreen' import { resourceKindsHint } from './resources' import type { ResourceKind } from './resources' // View is the top-level workspace area chosen from the header tabs. Specs and // Tests are full-width pages; Resources and Files use the contextual sidebar. -export type View = 'specs' | 'resources' | 'files' | 'tests' +export type View = 'specs' | 'resources' | 'files' | 'tests' | 'graph' const VIEWS: { id: View; label: string }[] = [ { id: 'specs', label: 'Specs' }, { id: 'resources', label: 'Resources' }, { id: 'files', label: 'Files' }, { id: 'tests', label: 'Tests' }, + { id: 'graph', label: 'Graph' }, ] export type Selection = @@ -158,6 +160,8 @@ export function App() { ))} {view === 'tests' && <TestsView version={version} />} + + {view === 'graph' && <GraphView version={version} />} </main> </div> </div> diff --git a/internal/web/frontend/src/components/GraphView.tsx b/internal/web/frontend/src/components/GraphView.tsx new file mode 100644 index 0000000..8a0c04c --- /dev/null +++ b/internal/web/frontend/src/components/GraphView.tsx @@ -0,0 +1,252 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { api } from '../api' + +// GraphView renders docs/graph/graph.json — the csdd knowledge graph — through +// the same read-only /api/file route as every other file (host guard + auth + +// secret redaction, PR #43). It never writes; the CLI is the only author of the +// graph (R6.3). Layout is a dependency-free canvas force simulation so the tab +// stays self-contained. + +interface GNode { + id: string + label: string + file_type: string + source_file?: string +} +interface GLink { + source: string + target: string + relation: string +} +interface NodeLink { + nodes: GNode[] + links: GLink[] +} + +interface Sim extends GNode { + x: number + y: number + vx: number + vy: number + deg: number +} + +const PALETTE: Record<string, string> = { + spec: '#6C8EBF', requirement: '#B85450', criterion: '#D79B00', design: '#9673A6', + interface: '#82B366', flow: '#3A9CA6', task: '#D6B656', steering: '#647687', + skill: '#4C8C5A', agent: '#B46EAB', mcp: '#8C6D46', code_ref: '#999999', + wiki_page: '#5A7DBE', raw_source: '#B0B0B0', tech: '#C97B2C', code: '#4E79A7', +} +const colorFor = (t: string) => PALETTE[t] ?? '#888' + +export function GraphView({ version }: { version: number }) { + const canvasRef = useRef<HTMLCanvasElement | null>(null) + const [graph, setGraph] = useState<NodeLink | null>(null) + const [error, setError] = useState<string | null>(null) + const [filter, setFilter] = useState('') + const [selected, setSelected] = useState<GNode | null>(null) + + useEffect(() => { + let cancelled = false + api + .file('docs/graph/graph.json') + .then((fc) => { + if (cancelled) return + try { + setGraph(JSON.parse(fc.text) as NodeLink) + setError(null) + } catch (e) { + setError('graph.json is not valid JSON: ' + String(e)) + } + }) + .catch(() => { + if (!cancelled) setError('No graph yet. Run `csdd graph build` to generate docs/graph/graph.json.') + }) + return () => { + cancelled = true + } + }, [version]) + + const model = useMemo(() => { + if (!graph) return null + const byId: Record<string, Sim> = {} + const nodes: Sim[] = graph.nodes.map((n, i) => { + const s: Sim = { ...n, x: Math.cos(i) * 220 + 400, y: Math.sin(i * 1.7) * 220 + 300, vx: 0, vy: 0, deg: 0 } + byId[n.id] = s + return s + }) + const links = graph.links.filter((l) => byId[l.source] && byId[l.target]) + links.forEach((l) => { + byId[l.source].deg++ + byId[l.target].deg++ + }) + return { nodes, links, byId } + }, [graph]) + + useEffect(() => { + if (!model) return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + let raf = 0 + const { nodes, links, byId } = model + + const step = () => { + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i] + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j] + const dx = a.x - b.x + const dy = a.y - b.y + const d2 = dx * dx + dy * dy + 0.01 + const rep = 1400 / d2 + const d = Math.sqrt(d2) + a.vx += (dx / d) * rep + a.vy += (dy / d) * rep + b.vx -= (dx / d) * rep + b.vy -= (dy / d) * rep + } + } + for (const l of links) { + const a = byId[l.source] + const b = byId[l.target] + const dx = b.x - a.x + const dy = b.y - a.y + const d = Math.sqrt(dx * dx + dy * dy) + 0.01 + const k = (d - 90) * 0.01 + a.vx += (dx / d) * k + a.vy += (dy / d) * k + b.vx -= (dx / d) * k + b.vy -= (dy / d) * k + } + const cx = canvas.clientWidth / 2 + const cy = canvas.clientHeight / 2 + for (const n of nodes) { + n.vx += (cx - n.x) * 0.002 + n.vy += (cy - n.y) * 0.002 + n.vx *= 0.85 + n.vy *= 0.85 + n.x += n.vx + n.y += n.vy + } + } + + const draw = () => { + const dpr = window.devicePixelRatio || 1 + canvas.width = canvas.clientWidth * dpr + canvas.height = canvas.clientHeight * dpr + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight) + ctx.globalAlpha = 0.3 + ctx.strokeStyle = '#8888' + for (const l of links) { + const a = byId[l.source] + const b = byId[l.target] + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + ctx.globalAlpha = 1 + const f = filter.toLowerCase() + for (const n of nodes) { + const dim = f !== '' && !n.label.toLowerCase().includes(f) + ctx.globalAlpha = dim ? 0.15 : 1 + const r = 4 + Math.min(8, n.deg) + ctx.beginPath() + ctx.arc(n.x, n.y, r, 0, 7) + ctx.fillStyle = colorFor(n.file_type) + ctx.fill() + if (selected && n.id === selected.id) { + ctx.lineWidth = 2 + ctx.strokeStyle = '#111' + ctx.stroke() + } + } + ctx.globalAlpha = 1 + } + + const loop = () => { + step() + draw() + raf = requestAnimationFrame(loop) + } + loop() + return () => cancelAnimationFrame(raf) + }, [model, filter, selected]) + + const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => { + if (!model) return + const rect = e.currentTarget.getBoundingClientRect() + const mx = e.clientX - rect.left + const my = e.clientY - rect.top + let best: Sim | null = null + let bd = 1e9 + for (const n of model.nodes) { + const d = (n.x - mx) ** 2 + (n.y - my) ** 2 + if (d < bd) { + bd = d + best = n + } + } + setSelected(bd < 400 && best ? best : null) + } + + const connections = useMemo(() => { + if (!graph || !selected) return [] + const out: string[] = [] + for (const l of graph.links) { + if (l.source === selected.id) out.push(`→ ${l.relation} ${l.target}`) + else if (l.target === selected.id) out.push(`← ${l.relation} ${l.source}`) + } + return out + }, [graph, selected]) + + if (error) { + return ( + <div className="empty"> + <h2>Graph</h2> + <p className="muted">{error}</p> + </div> + ) + } + + return ( + <div className="graph-view" style={{ display: 'flex', height: '100%', minHeight: 0 }}> + <div style={{ flex: 1, position: 'relative', minWidth: 0 }}> + <input + placeholder="filter nodes by label…" + value={filter} + onChange={(e) => setFilter(e.target.value)} + style={{ position: 'absolute', top: 8, left: 8, zIndex: 2, padding: '4px 8px' }} + /> + <canvas + ref={canvasRef} + onClick={onClick} + style={{ width: '100%', height: '100%', display: 'block' }} + /> + </div> + <aside style={{ width: 300, overflow: 'auto', padding: '12px 14px', borderLeft: '1px solid #8883', fontSize: 13 }}> + <h3 style={{ marginTop: 0 }}>Graph</h3> + <p className="muted">{graph ? `${graph.nodes.length} nodes · ${graph.links.length} edges` : 'loading…'}</p> + {selected ? ( + <div> + <strong>{selected.label}</strong> + <div className="muted" style={{ margin: '4px 0' }}> + {selected.file_type} + {selected.source_file ? ` · ${selected.source_file}` : ''} + </div> + {connections.map((c, i) => ( + <div key={i} style={{ margin: '2px 0' }}> + {c} + </div> + ))} + </div> + ) : ( + <p className="muted">Click a node to inspect its connections.</p> + )} + </aside> + </div> + ) +} diff --git a/internal/web/graph_tab_test.go b/internal/web/graph_tab_test.go new file mode 100644 index 0000000..85b8014 --- /dev/null +++ b/internal/web/graph_tab_test.go @@ -0,0 +1,53 @@ +package web + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +// TestGraphJSONServedThroughHardenedRoute verifies the web dashboard serves +// docs/graph/graph.json through the same read-only /api/file route as every +// other file — with the Host-header guard applied (R6.3). The Graph tab reads +// exactly this; no new write endpoint is introduced. +func TestGraphJSONServedThroughHardenedRoute(t *testing.T) { + graphJSON := `{"directed":true,"multigraph":false,"graph":{},"nodes":[{"id":"spec_x","label":"x","file_type":"spec","source_file":"specs/x/spec.json","source_location":"L1"}],"links":[]}` + root := tempWorkspace(t, map[string]string{ + "docs/graph/graph.json": graphJSON, + "CLAUDE.md": "# c\n", + }) + srv := testServer(t, root) + + // Served through /api/file with a valid (loopback) Host. + var fc struct { + Path string `json:"path"` + Text string `json:"text"` + } + if code := getJSON(t, srv.URL+"/api/file?path=docs/graph/graph.json", &fc); code != http.StatusOK { + t.Fatalf("graph.json should be served; got %d", code) + } + if !strings.Contains(fc.Text, "spec_x") { + t.Errorf("served graph.json missing content: %q", fc.Text) + } + // It parses as node-link JSON (what the Graph tab consumes). + var nl struct { + Nodes []map[string]any `json:"nodes"` + } + if err := json.Unmarshal([]byte(fc.Text), &nl); err != nil || len(nl.Nodes) != 1 { + t.Fatalf("served payload is not node-link JSON: %v", err) + } + + // The Host-header guard (DNS-rebinding defense) rejects a foreign Host, so the + // graph is only reachable through the hardened path. + req, _ := http.NewRequest("GET", srv.URL+"/api/file?path=docs/graph/graph.json", nil) + req.Host = "evil.example.com" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("foreign Host should be forbidden; got %d", resp.StatusCode) + } +}