diff --git a/.apm/skills/kyber-weave-docs/SKILL.md b/.apm/skills/kyber-weave-docs/SKILL.md index b682e3c..be1515b 100644 --- a/.apm/skills/kyber-weave-docs/SKILL.md +++ b/.apm/skills/kyber-weave-docs/SKILL.md @@ -4,7 +4,7 @@ description: "Generate conformant Kyber-Weave frontmatter for repository documen license: MIT metadata: author: dpalfery - version: 0.1.2 + version: 0.1.3 --- # Authoring Kyber-Weave documentation @@ -188,6 +188,31 @@ Agents and skills should look up the following properties dynamically to find th Fix what a rule reports. Do not widen the ontology in `.kyber-weave/kyber-weave.yml` to make a failure disappear — that discards the guarantee the corpus exists to provide. +## After conformance: analyze, do not auto-rewrite + +When the repository supports documentation analysis, run it only after `docs validate` +and `docs drift` are clean: + +```bash +kyber-weave docs analyze . +``` + +Treat duplicate, conflict, and terminology findings as evidence to review. Never merge, +delete, or rewrite source documentation merely because analysis paired two claims. Exact +duplicates are deterministic; conflicts and distinct term senses need a scope-aware human +or agent verdict through `docs review export` / `docs review import`. + +Use `` only for intentional, reviewed cases and only with `duplicate`, +`conflict`, `terminology`, or `all`. The tags are case-sensitive, balanced, non-nested, +and cannot cross frontmatter or a `##` boundary. Malformed suppression is an operational +error rather than a silent ignore. + +`docs glossary .` previews terminology proposals; `--write` merges them into the one +configured glossary without rewriting source documents. The glossary remains a conformant +`reference` document. Humans approve/reject sense rows, supply approved definitions and +component/code scopes, update `last-reviewed`, and return the document to `current` after +review. Do not invent a glossary doc-type or use sense-row status as document status. + ## Never - Invent a `component` or `owner` that is not in the catalog @@ -197,3 +222,5 @@ make a failure disappear — that discards the guarantee the corpus exists to pr - Change `doc-type` or `status` vocabularies to fit one document - Set `status: current` on frontmatter you filled in without review - Backdate or forward-date `last-reviewed` — use the date it was actually reviewed +- Auto-rewrite source prose from an unreviewed duplicate, conflict, or terminology candidate +- Use ignore markup to hide a finding whose scope or evidence has not been reviewed diff --git a/.apm/skills/kyber-weave-docs/references/rules.md b/.apm/skills/kyber-weave-docs/references/rules.md index 08fe50c..dcae3b9 100644 --- a/.apm/skills/kyber-weave-docs/references/rules.md +++ b/.apm/skills/kyber-weave-docs/references/rules.md @@ -96,3 +96,57 @@ not fail the build. `docs validate` and `docs drift` exit non-zero on any **error**. Warnings and info do not gate. `--no-info` hides informational findings; `--format sarif` emits SARIF for code scanning. + +## Analysis — `docs analyze` + +Analysis is advisory by default and never edits source documentation. + +### `KW-DOC-ANALYSIS-001` — duplicate cluster + +Info for a pending near duplicate; Warning for a deterministic exact cluster or a +high-confidence imported duplicate verdict. Confirm that the claims are substantively the +same, not merely about the same topic. + +### `KW-DOC-ANALYSIS-002` — potential conflict + +Info while pending; Error only after a high-confidence imported `conflict` verdict. +Confirm that both claims cannot be true in the same scope and time before choosing a +canonical source. + +### `KW-DOC-ANALYSIS-003` — ambiguous terminology + +Warning when one informative term occurs in divergent contexts not fully accounted for by +approved scoped glossary senses. Preview proposals with `docs glossary .`; do not rename +terms automatically. + +### `KW-DOC-ANALYSIS-004` — invalid ignore markup + +Operational Error. `` must be balanced, case-sensitive, non-nested, use +`duplicate`, `conflict`, `terminology`, or `all`, and stay within frontmatter/`##` +boundaries. Fix the markup; suppression never fails open. + +### `KW-DOC-ANALYSIS-005` — CodeGraph unavailable + +Warning. Analysis continues with document relationships and bounded lexical search. Build +or restore `.codegraph/codegraph.db` for code-neighborhood evidence. + +### `KW-DOC-ANALYSIS-006` — embedding unavailable + +Warning in `prefer`, operational Error in `required`. Embeddings remain off by default and +are never invoked unless the local cache path is safely ignored. Restore the loopback +provider/safe cache, use `prefer` for lexical fallback, or use `off`. + +### `KW-DOC-REVIEW-001` — invalid or stale verdict bundle + +Operational Error. Regenerate candidates from the current corpus and validate every +candidate id, claim hash, evidence id, label, confidence, and glossary proposal. Import is +atomic; one invalid item writes nothing. + +### `KW-DOC-GLOSSARY-001` — invalid managed glossary + +Operational Error. Keep the document a conformant `reference`; use only `proposed`, +`approved`, or `rejected` row status. Approved senses require a definition and at least +one valid `component:` or `code-ref:` scope. + +`docs analyze --fail-on none|warning|error` controls finding gating. Operational errors +always return non-zero. diff --git a/README.md b/README.md index b3eb761..930c54c 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,18 @@ artifact as current guidance, and returns budgeted excerpts that name what they [Retrieval →](docs/docgraph/retrieval.md) ```bash -kyber-weave docs init . # scaffold config, catalog, ontology + deploy the authoring skill +kyber-weave docs init . # scaffold config/catalog/ontology, protect local cache, deploy the skill kyber-weave docs validate . kyber-weave docs drift . +kyber-weave docs analyze . # advisory duplicate/conflict/terminology findings kyber-weave docs graph . --out ./build/doc-graph ``` ### Adopting an existing tree `docs init` does the mechanical half — host config, the catalog that supplies the -component and owner vocabularies, and the ontology reference every diagnostic points at. +component and owner vocabularies, the ontology reference every diagnostic points at, and +the narrow ignored cache path analysis needs before it can persist verdicts or vectors. It then deploys the **`kyber-weave-docs` skill** through [APM](https://microsoft.github.io/apm), defaulting to `.agents/skills/` so every APM-supported client picks it up. @@ -67,11 +69,35 @@ corpus degrades gracefully rather than serving unreviewed metadata as current gu |---|---| | `docs_explore(query, maxDocs, charBudget)` | Ranked documents with frontmatter identity, prose within budget, and code joins as `symbol → file:line` | | `docs_for_symbol(symbol)` | Reverse lookup: documents whose `code-refs` **formally claim** a symbol — not those that merely mention it | +| `docs_analysis_candidates(kind, cursor, limit, charBudget)` | Capped, stable, read-only duplicate/conflict/terminology evidence with local cost metrics | +| `docs_glossary(term)` | Capped, read-only lookup of managed term senses, scopes, and aliases | It is a separate binary from the CLI on purpose: JSON-RPC owns stdout and Spectre.Console also writes there, so separate entry points make stream corruption structurally impossible. [MCP runbook →](docs/docgraph/mcp-runbook.md) +### Documentation analysis and terminology + +`docs analyze` extracts line-addressable claims from paragraphs, list items, table rows, +and code fences, then uses DocGraph and one-hop CodeGraph relationships before bounded +lexical search. Exact duplicates are deterministic; potential conflicts and distinct term +senses can be exported for agent review and imported as reusable, content-addressed +verdicts. Source documents are never rewritten. + +```bash +kyber-weave docs analyze . +kyber-weave docs review export . --out candidates.json +kyber-weave docs review import . --in verdicts.json +kyber-weave docs glossary . # preview +kyber-weave docs glossary . --write # merge proposals into one reference document +``` + +Embeddings are off by default. When enabled, endpoints must resolve only to loopback, +redirects are disabled, and no document text is sent unless the local SQLite cache is +safely ignored. Default hybrid search avoids all-pairs work; `high-recall` is an explicit +quadratic first pass outside the default latency target. +[Analysis and review →](docs/docgraph/analysis.md) + ### One external dependency, and it's optional `docs drift` and `docs graph` resolve symbols against a **CodeGraph** index at @@ -157,6 +183,7 @@ dotnet test tests/KyberWeave.Tests/KyberWeave.Tests.csproj -c Release - **`docs init` expects [APM](https://microsoft.github.io/apm)** to deploy the authoring skill. Both it and CodeGraph are *expected* dependencies — Kyber-Weave detects them and degrades with a message, but never installs anything on your machine. - **Security scanning is necessary but not sufficient** — pair it with human review. - **The document index is rebuilt, never persisted.** Editing one document rebuilds the whole corpus; comfortable at hundreds of documents, worth revisiting at thousands. +- **Documentation analysis persistence is a separate local cache.** `.kyber-weave/cache/docs-analysis.sqlite3` stores reusable vectors and verdicts only when the narrow cache path is safely ignored; it is never a source of retrieval prose. - Some agent Core APIs exist without CLI verbs (`agent route` / `lint` / `new`) — known gap. > **Naming note.** "Kyber" collides with CRYSTALS-Kyber / ML-KEM, the NIST post-quantum KEM diff --git a/docs/README.md b/docs/README.md index 6f72402..089c032 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ title: Kyber-Weave documentation doc-type: index status: current owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 --- # Kyber-Weave documentation @@ -36,6 +36,7 @@ retrieval graph served to agents over MCP. The primary feature. | [Adoption](docgraph/onboarding.md) | `docs init`, the authoring skill, retrofitting an existing tree | | [Architecture](docgraph/architecture.md) | The pipeline, the two-clock reload, the code-graph join | | [Retrieval and ranking](docgraph/retrieval.md) | Scoring, authority weighting, budgeted excerpts | +| [Analysis and review](docgraph/analysis.md) | Graph-first duplicate/conflict/terminology detection, agent verdicts, managed glossary | | [Governance gates](docgraph/governance.md) | `docs validate`, `docs drift`, `docs catalog` | | [MCP server runbook](docgraph/mcp-runbook.md) | Serving the graph to an agent | diff --git a/docs/catalog.md b/docs/catalog.md index 7e2869f..911634f 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -4,7 +4,7 @@ title: Component and owner catalog doc-type: reference status: current owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 --- # Component and owner catalog @@ -19,7 +19,7 @@ answers for it, and where its source lives. | Component | Type | Source root | Overview | Detailed documentation | Owner | Last reviewed | Status | |---|---|---|---|---|---|---|---| -| DocGraph | Feature | `src/KyberWeave.Core/Docs` | The opinionated documentation ontology, its conformance gates, and the in-memory retrieval graph served over MCP. | [docgraph/architecture.md](docgraph/architecture.md) | dpalfery | 2026-08-01 | current | +| DocGraph | Feature | `src/KyberWeave.Core/Docs` | The documentation ontology, conformance gates, graph-first claim analysis, managed terminology, and retrieval graph served over MCP. | [docgraph/architecture.md](docgraph/architecture.md) · [docgraph/analysis.md](docgraph/analysis.md) | dpalfery | 2026-08-12 | current | | ContextHygiene | Feature | `src/KyberWeave.Core/Skills` | Governance for the artifacts that shape an agent's context: Agent Skills and harness agent definitions. | [context-hygiene/skills.md](context-hygiene/skills.md) | dpalfery | 2026-08-01 | current | | CI Pipelines | Feature | `src/KyberWeave.Core/Diagnostics` | The diagnostic engine every gate reports through: stable rule ids, severity gating, and SARIF. | [ci-pipelines/architecture.md](ci-pipelines/architecture.md) | dpalfery | 2026-08-01 | current | | Distribution | Supporting | `scripts` | Self-contained platform binaries and the install path that places them. | [install.md](install.md) | dpalfery | 2026-08-01 | current | diff --git a/docs/ci-pipelines/rule-reference.md b/docs/ci-pipelines/rule-reference.md index c9d4ccc..64489cd 100644 --- a/docs/ci-pipelines/rule-reference.md +++ b/docs/ci-pipelines/rule-reference.md @@ -5,7 +5,7 @@ doc-type: reference status: current component: CI Pipelines owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 --- # Rule reference @@ -34,6 +34,28 @@ baselines — see [CI Pipelines architecture](architecture.md) for why they neve | `KW-DOC-DRIFT-002` | Error | `api-endpoints` route matches no indexed route | | `KW-DOC-DRIFT-003` | Warning | `source-root` exists but nothing beneath it is indexed | +### Analysis — `docs analyze` + +| Id | Severity | Meaning | +|---|---|---| +| `KW-DOC-ANALYSIS-001` | Info / Warning | Duplicate cluster. Pending near duplicates inform; exact or high-confidence confirmed duplicates warn. | +| `KW-DOC-ANALYSIS-002` | Info / Error | Potential conflict. Only a high-confidence imported conflict verdict errors. | +| `KW-DOC-ANALYSIS-003` | Warning | Ambiguous terminology not fully explained by approved scoped senses. | +| `KW-DOC-ANALYSIS-004` | Operational Error | Malformed, nested, unknown, or cross-boundary ignore markup. | +| `KW-DOC-ANALYSIS-005` | Warning | CodeGraph unavailable; document relationships and bounded lexical search continue. | +| `KW-DOC-ANALYSIS-006` | Warning / Operational Error | Embeddings unavailable: warning in `prefer`, error in `required`. | + +### Review and managed glossary + +| Id | Severity | Meaning | +|---|---|---| +| `KW-DOC-REVIEW-001` | Operational Error | Verdict bundle is invalid/stale, or safe atomic persistence is unavailable. | +| `KW-DOC-GLOSSARY-001` | Operational Error | Configured managed glossary has invalid structure, status, definition, or scope. | + +Analysis findings respect `docs analyze --fail-on`; operational errors always return +non-zero. See [analysis and review](../docgraph/analysis.md) for classifier and lifecycle +details. + ## Skills — [Skill governance](../context-hygiene/skills.md) | Id range | Tier | Meaning | diff --git a/docs/configuration.md b/docs/configuration.md index 9288d11..f0cbe22 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ title: Configuration doc-type: reference status: current owner: dpalfery -last-reviewed: 2026-08-04 +last-reviewed: 2026-08-12 code-refs: - KyberWeaveConfigLoader - OntologyConfig @@ -55,6 +55,30 @@ harness: supports-native-parent-agents: false mapped-role-skill-overrides: reviewer: code-review + +docs-analysis: + statuses: [current] + glossary-path: docs/glossary.md + verdict-confidence: 0.80 + search: + mode: hybrid + min-claim-tokens: 5 + lexical-candidate-threshold: 0.45 + lexical-duplicate-threshold: 0.90 + semantic-candidate-threshold: 0.78 + semantic-duplicate-threshold: 0.92 + terminology-context-threshold: 0.30 + max-neighbors-per-claim: 10 + max-code-neighbors: 50 + max-candidates: 500 + embeddings: + mode: off + endpoint: http://127.0.0.1:1234/v1/embeddings + model: configured-model-name + dimensions: 768 + batch-size: 64 + timeout-seconds: 60 + api-key-env: LOCAL_EMBEDDING_TOKEN ``` ## Ontology keys @@ -135,6 +159,58 @@ returns one has actively misled its caller. This pairs with the authority weight [retrieval](docgraph/retrieval.md), which demotes plans and superseded documents that are still in scope. +## Documentation analysis + +The entire `docs-analysis` section is optional. Presets establish the defaults below; the +individual values are advanced overrides rather than a required tuning exercise. + +| Key | Default | Effect | +|---|---|---| +| `statuses` | `[current]` | Existing ontology statuses eligible for claim extraction | +| `glossary-path` | `/glossary.md` | Managed glossary, always beneath a configured docs root | +| `verdict-confidence` | `0.80` | Minimum imported confidence for a durable classification or suppression | +| `search.mode` | `hybrid` | `graph`, bounded `hybrid`, or explicitly expensive `high-recall` | +| `search.min-claim-tokens` | `5` | Claims below this token count are not compared | +| `search.lexical-candidate-threshold` | `0.45` | Minimum lexical evidence for ordinary candidacy | +| `search.lexical-duplicate-threshold` | `0.90` | Lexical near-duplicate threshold | +| `search.semantic-candidate-threshold` | `0.78` | Minimum semantic evidence for candidacy | +| `search.semantic-duplicate-threshold` | `0.92` | Semantic near-duplicate threshold | +| `search.terminology-context-threshold` | `0.30` | Maximum contextual similarity for divergent senses | +| `search.max-neighbors-per-claim` | `10` | Per-source top-k bound | +| `search.max-code-neighbors` | `50` | Code nodes above this degree are non-discriminating and skipped | +| `search.max-candidates` | `500` | Hard analysis/review candidate cap | + +`graph` compares global exact duplicates plus graph neighbors. `hybrid` adds a sparse +corpus-wide inverted-index fallback without an all-pairs scan and is the default. +`high-recall` broadens lexical comparison and, when embeddings are enabled, performs a +global exact cosine first pass. That first pass is explicitly quadratic and is outside +the 10-second default-path target. See [analysis and review](docgraph/analysis.md). + +### Embeddings are local, optional, and persistence-gated + +`embeddings.mode` is one of: + +| Mode | Behavior | +|---|---| +| `off` | Default. Never constructs or invokes an embedding provider. | +| `prefer` | Uses cached/local embeddings when safe; warns and falls back to lexical analysis otherwise. | +| `required` | Treats an unavailable provider or unsafe cache as an operational error. | + +When mode is `prefer` or `required`, `endpoint` and `model` are required. `dimensions` is +optional; the compatible request always sends batched string input, the model, and +`encoding_format: float`. `batch-size` defaults to 64 and `timeout-seconds` to 60. +`api-key-env` names an environment variable; it is not the token itself. + +The endpoint must be an absolute HTTP(S) URI whose every resolved address is loopback: +`localhost`, the full `127.0.0.0/8` range, or `::1`. Kyber-Weave validates again when the +socket connects and disables redirects, so a local name or response cannot escape to a +remote endpoint. Credentials and headers are not logged or persisted. + +Embedding calls are also gated by `.kyber-weave/.gitignore` effectively protecting the +narrow `cache/` path and by the cache not already being tracked. Without that proof, +Kyber-Weave sends no document text. `prefer` falls back; `required` fails. `docs init` +safely merges the ignore entry for new and existing hosts. + ## Harness profiles Harnesses differ in what they can express, so [agent parity](context-hygiene/agents.md) diff --git a/docs/docgraph/analysis.md b/docs/docgraph/analysis.md new file mode 100644 index 0000000..3d872cb --- /dev/null +++ b/docs/docgraph/analysis.md @@ -0,0 +1,189 @@ +--- +id: docgraph/analysis +title: Documentation analysis and review +doc-type: reference +status: current +component: DocGraph +owner: dpalfery +last-reviewed: 2026-08-12 +--- + +# Documentation analysis and review + +DocGraph can identify repeated claims, potential contradictions, and terms whose meaning +changes across the corpus. Analysis is deliberately **graph-first and advisory**: it +shortlists evidence for a human or reviewing agent, but never rewrites, merges, or deletes +source documentation. + +```bash +kyber-weave docs analyze . +kyber-weave docs review export . --out candidates.json +kyber-weave docs review import . --in verdicts.json +kyber-weave docs glossary . +``` + +The default path is English-first, selects `status: current`, uses bounded hybrid search, +does not call an embedding endpoint, and returns zero for findings. Operational failures +still return non-zero. + +## Claims, not whole files + +Markdown is parsed structurally with Markdig. A paragraph, individual list item, table row +with its header context, or fenced code block becomes a line-addressable claim beneath its +nearest `##` heading. Each claim retains document identity, component, section, source +lines, code references, a content hash, and a contextual hash. + +Exact duplicate hashing uses claim text alone, so moving unchanged prose does not change +its identity. Candidate search uses the section heading plus the claim, because the same +sentence beneath two different headings may not mean the same thing. The configured +managed glossary is excluded from its own analysis. + +The only inline suppressions are balanced, case-sensitive, non-nested wrappers: + +```html +intentional repeated claim +scope-qualified apparent contradiction +intentional local usage +intentional example +``` + +Wrappers inside fenced examples are literal. A wrapper cannot cross frontmatter or a `##` +boundary. Unknown, nested, unbalanced, or otherwise malformed wrappers fail analysis as +`KW-DOC-ANALYSIS-004`; suppression never fails open. Retrieval continues to see the +original prose. + +## Graph-first candidate generation + +Analysis reuses the immutable DocGraph projection behind export and retrieval. Claims are +neighbors when their documents share a document, component, endpoint, resolved code node, +or overlapping source root; are connected by `LINKS_TO`, `DECIDED_BY`, or an applicable +`SUPERSEDES`; or meet through one CodeGraph hop of `contains`, `calls`, `references`, +`instantiates`, `extends`, or `implements`. + +`imports` is excluded because it is too broad to discriminate claims. A code node above +`max-code-neighbors` is skipped for the same reason. If CodeGraph is unavailable, one +`KW-DOC-ANALYSIS-005` warning is reported and document relationships plus bounded lexical +search continue. + +Three modes control the cost/recall trade: + +| Mode | Candidate pool | Cost character | +|---|---|---| +| `graph` | Global exact duplicates plus graph-neighbor comparisons | Lowest cost; disconnected paraphrases are not found | +| `hybrid` | `graph` plus corpus-wide sparse inverted-index top-k fallback | Default; finds disconnected lexical similarity without all-pairs work | +| `high-recall` | Broad lexical candidates plus global exact cosine top-k when cached embeddings are enabled | Explicit quadratic first pass; progress should be monitored | + +Embeddings can rerank eligible claims, but exact duplicates stay deterministic and +model-free. The default `hybrid` path with embeddings off is designed for 1,000 documents +and 10,000 claims: bounded graph/top-k comparisons, at most 500 review candidates, and a +10-second / 512-MiB Release target on the CI reference runner. High-recall global +embedding search is explicitly outside that latency target. + +## Findings and confidence + +| Finding | Before review | After a high-confidence imported verdict | +|---|---|---| +| Duplicate | Exact clusters are Warning; near duplicates are Info | `duplicate` becomes Warning; `benign` suppresses unchanged evidence | +| Conflict | Info when graph/topic evidence and differing negation, obligation, number/version, path, command, or code literal make the pair plausible | `conflict` becomes Error | +| Terminology | Warning when one informative term occurs in divergent graph/context clusters | `distinct-senses` supplies glossary proposals; approved scoped senses can suppress it | + +The rubric is intentionally narrow: a duplicate is substantively the same claim, not +merely the same topic; a conflict means both claims cannot be true in the same scope and +time; `distinct-senses` means one term denotes multiple concepts; `benign` covers +compatible scopes, intentional examples, and harmless overlap; `uncertain` means the +evidence is insufficient. Low-confidence and uncertain verdicts remain pending for review; +the visible finding severity continues to follow the kind-specific table above. + +Candidate ids hash the rule kind, normalized term where applicable, sorted claim-content +hashes, and analyzer/rubric versions. Moving prose does not invalidate a verdict; changing +the claim or rubric does. + +## CLI and exit behavior + +```bash +kyber-weave docs analyze . [--fail-on none|warning|error] +``` + +JSON, SARIF, and Markdown output include related locations for clustered evidence; table +output shows the primary location and a related-location count. Every format includes +local cost metrics: extracted claims, comparisons and candidates by source, truncation, +embedding cache hits/misses, and provider usage when returned. `none` is the default. +`warning` gates Warning and Error findings; `error` gates Error findings. Operational +errors always return non-zero. + +```bash +kyber-weave docs review export . --out candidates.json +kyber-weave docs review import . --in verdicts.json +``` + +Export omits deterministic exact duplicates and includes bounded excerpts, graph evidence, +scores, content hashes, the rubric, and a candidate-set hash under +`kyber-weave.docs-review.candidates/v1`. It exports unreviewed, uncertain, low-confidence, +or changed candidates. + +Import accepts `kyber-weave.docs-review.verdicts/v1`. The entire bundle is checked before +one transaction: schema and analyzer versions, candidate ids and set hash, current claim +hashes, applicable labels, confidence, evidence ids, and glossary-sense shape. One stale or +malformed item rejects the whole import as `KW-DOC-REVIEW-001` and writes nothing. + +## Local cache and embedding privacy + +Reusable vectors and imported verdicts live in +`.kyber-weave/cache/docs-analysis.sqlite3`, accessed through the existing `sqlite3` CLI +rather than a new native dependency. Vectors are normalized and keyed by contextual claim +hash, provider fingerprint, model, dimensions, and float encoding. Credentials and +authorization headers are never stored. + +Persistence is allowed only when `.kyber-weave/.gitignore` effectively protects the +narrow `cache/` path and no cache entry is already tracked. `docs init` creates or safely +merges that ignore entry; it does not create an empty glossary. On an existing host without +the protection, deterministic analysis still runs without persistence. If embeddings were +requested, `embeddings.mode: prefer` warns, skips embeddings, and falls back to lexical +analysis; `embeddings.mode: required` and `docs review import` fail before writing or +sending text. + +Embedding endpoints must be absolute HTTP(S) and resolve **only to loopback** (`localhost`, +`127.0.0.0/8`, or `::1`). Resolution is checked again when connecting to close a DNS +rebinding window, and redirects are disabled even when a local server redirects elsewhere. +The optional bearer token is read from the configured environment-variable name and is +never included in diagnostics. Kyber-Weave never calls the endpoint when results cannot be +persisted safely. + +## Managed glossary + +```bash +kyber-weave docs glossary . # preview Markdown +kyber-weave docs glossary . --write # merge proposals only +``` + +The default path is `/glossary.md`, overridable with `glossary-path`. A +new glossary is a conformant `reference` document with `status: needs-review`, today's UTC +date, and the first catalog row's owner. It is not a new document type. + +Each `## ` section contains a managed table: + +```markdown +| Sense ID | Status | Definition | Scope | Aliases | +|---|---|---|---|---| +| loop-a1b2c3d4 | proposed | | component:Gameplay | gameplay loop | +``` + +Sense status is exactly `proposed`, `approved`, or `rejected`. Approved senses need a +definition and at least one semicolon-separated `component:` or +`code-ref:` scope. Humans approve or reject rows, update `last-reviewed`, and +return the document to `current` when review is complete. + +`--write` preserves approved/rejected rows, human definitions and prose, aliases, owner, +and the existing review date. It adds or refreshes proposals, demotes the document to +`needs-review` when proposals change, and removes only untouched generated proposals whose +evidence disappeared. `docs validate` checks the managed shape as +`KW-DOC-GLOSSARY-001`. `docs graph` exports approved Term/Sense nodes and `HAS_SENSE`, +`ALIAS_OF`, `SCOPED_TO`, and `EVIDENCED_BY` edges; proposed and rejected senses do not +enter the exported graph. + +## Related + +- [Configuration](../configuration.md) — thresholds, search modes, and embedding endpoint +- [Documentation governance](governance.md) — rule ids and CI use +- [MCP runbook](mcp-runbook.md) — capped conversational analysis and glossary tools +- [DocGraph architecture](architecture.md) — the shared projection and CodeGraph join diff --git a/docs/docgraph/architecture.md b/docs/docgraph/architecture.md index e1e0da9..954b88c 100644 --- a/docs/docgraph/architecture.md +++ b/docs/docgraph/architecture.md @@ -6,7 +6,7 @@ status: current component: DocGraph source-root: src/KyberWeave.Core/Docs owner: dpalfery -last-reviewed: 2026-08-04 +last-reviewed: 2026-08-12 code-refs: - DocumentLoader - DocumentCorpus @@ -35,15 +35,34 @@ DocumentIndexHost.Current() cache, and rebuild whichever half is stale Each stage is a pure transform of the one before it. Nothing writes to disk. -## There is no DocGraph database +Documentation analysis branches from the same `DocumentSet` and an immutable projection +of the export relationships: + +``` +ClaimExtractor paragraphs, list items, table rows, code fences → claims + │ +DocGraphProjection.Build() document relationships + one-hop CodeGraph → neighbors + │ +DocumentationAnalyzer exact clusters + bounded graph/lexical/semantic → findings +``` + +That shared projection keeps analysis graph-first rather than rebuilding relationships +or scanning every claim pair. See [documentation analysis](analysis.md) for the blocking, +classification, and review contracts. + +## Retrieval has no database The graph lives in process memory and is rebuilt from the Markdown on demand. The corpus is roughly 600 KB of prose for a repository of this size, and parsing plus vectorising it costs milliseconds — cheap enough that a persistence tier would buy latency at the cost of a cache-invalidation problem. -The only durable artifact DocGraph produces is the optional `nodes.jsonl` / `edges.jsonl` -export from `docs graph`, which is written for external consumers and never read back. +The optional `nodes.jsonl` / `edges.jsonl` export from `docs graph` is written for external +consumers and never read back. Documentation analysis has a separate local cache at +`.kyber-weave/cache/docs-analysis.sqlite3` for reusable vectors and agent verdicts. It is +not a retrieval database or a source of documentation, and deterministic analysis runs +without it. The cache exists only when the narrow path is safely ignored; see +[analysis privacy and persistence](analysis.md#local-cache-and-embedding-privacy). The one database in play is **CodeGraph's**, at `.codegraph/codegraph.db`. Kyber-Weave opens it read-only and issues nothing but `SELECT`. It does not create it, write to it, or @@ -73,7 +92,7 @@ comfortable at hundreds of documents and is the first thing to revisit at thousa A document's `code-refs` entries are resolved through `ICodeGraphResolver`, a port with one production adapter that reads CodeGraph's SQLite index. Resolution is a **join, not a merge**: documentation stays in memory, code stays in the index, and the two meet only -when a query needs them together. +when retrieval, export, drift, or analysis needs them together. Both the reference as authored and its last dotted segment are indexed, so a `code-refs` entry of `KyberWeave.Core.Docs.Search.DocumentIndex` is findable by the bare `DocumentIndex` @@ -84,9 +103,15 @@ beneath it win, and within that pool a declaration outranks an incidentally same member. A class named `X` is far likelier to be what documentation calls "X" than a property that happens to be called `X`. -**The index is optional.** Without it, `IsAvailable` is false, joins come back empty, and -retrieval still works completely — ranking never consults the code graph. Only -[`docs drift`](governance.md) and `docs graph` hard-require it. +Analysis also asks an optional one-hop neighborhood port for `contains`, `calls`, +`references`, `instantiates`, `extends`, and `implements` edges. `imports` and +high-degree code nodes are excluded because they connect too much of the repository to be +useful evidence. + +**The index is optional.** Without it, `IsAvailable` is false, joins come back empty, +retrieval still works completely, and analysis continues with document relationships and +bounded lexical search after one warning. Only [`docs drift`](governance.md) and +`docs graph` hard-require it. ## Why sqlite3 and not a library @@ -104,6 +129,7 @@ creating. ## Related - [Retrieval and ranking](retrieval.md) — how a query becomes an answer +- [Documentation analysis and review](analysis.md) — graph-first duplicates, conflicts, and terminology - [Documentation governance](governance.md) — the conformance and drift gates - [MCP server runbook](mcp-runbook.md) — serving this graph to an agent - [The documentation ontology](../documentation-ontology.md) — the schema all of this assumes diff --git a/docs/docgraph/governance.md b/docs/docgraph/governance.md index 22e33c4..454a533 100644 --- a/docs/docgraph/governance.md +++ b/docs/docgraph/governance.md @@ -5,7 +5,7 @@ doc-type: governance status: current component: DocGraph owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 code-refs: - DocSpecValidator - DocDriftLinter @@ -21,6 +21,7 @@ code is still true. kyber-weave docs validate . kyber-weave docs drift . kyber-weave docs catalog . +kyber-weave docs analyze . ``` Starting from an ungoverned tree? Run [`docs init`](onboarding.md) first — these gates @@ -44,6 +45,9 @@ Needs no code index. Exits non-zero on any error. offered only when the distance is plausibly a typo rather than a different word. A mistyped component says which one you probably meant. +When the configured managed glossary exists, `docs validate` also checks its table, +statuses, approved definitions, and component/code scopes as `KW-DOC-GLOSSARY-001`. + ## Entity drift — `docs drift` Requires a CodeGraph index and the `sqlite3` CLI on PATH. @@ -76,6 +80,40 @@ mention cannot be checked. A claim can. Reports doc-type coverage by component: which components have architecture documents, which have runbooks, and which have nothing. Advisory; it gates nothing. +## Analysis — `docs analyze`, `docs review`, and `docs glossary` + +Analysis identifies evidence; it never edits source documentation. Exact duplicates are +deterministic, while near duplicates, conflicts, and divergent term senses can be exported +for bounded agent review and imported as content-addressed verdicts. + +```bash +kyber-weave docs analyze . # advisory by default +kyber-weave docs analyze . --fail-on warning # gate warnings and errors +kyber-weave docs review export . --out candidates.json +kyber-weave docs review import . --in verdicts.json +kyber-weave docs glossary . # preview +kyber-weave docs glossary . --write # merge proposals only +``` + +`--fail-on` accepts `none`, `warning`, or `error`; `none` is the default. Operational +errors always return non-zero regardless of that setting. Review import validates the +entire versioned bundle before one transaction. Glossary writes are confined to the +configured glossary and preserve reviewed/human-owned content. + +| Rule | Severity | Fires when | +|---|---|---| +| `KW-DOC-ANALYSIS-001` | Info / Warning | Pending near duplicate, or exact/confirmed duplicate cluster | +| `KW-DOC-ANALYSIS-002` | Info / Error | Pending potential conflict, or high-confidence confirmed conflict | +| `KW-DOC-ANALYSIS-003` | Warning | One informative term has divergent unresolved senses | +| `KW-DOC-ANALYSIS-004` | Operational Error | Ignore markup is malformed or unsupported | +| `KW-DOC-ANALYSIS-005` | Warning | CodeGraph is unavailable; bounded lexical/document analysis continues | +| `KW-DOC-ANALYSIS-006` | Warning / Operational Error | Embeddings unavailable in `prefer` / `required` mode | +| `KW-DOC-REVIEW-001` | Operational Error | Review verdict bundle is invalid, stale, or cannot be persisted safely | +| `KW-DOC-GLOSSARY-001` | Operational Error | Managed glossary structure or approved scope is invalid | + +See [documentation analysis and review](analysis.md) for claim extraction, graph blocking, +cost modes, cache privacy, the review schemas, and glossary lifecycle. + ## Wiring into CI Both gates emit stable rule ids and render to SARIF for GitHub code scanning. See diff --git a/docs/docgraph/mcp-runbook.md b/docs/docgraph/mcp-runbook.md index 255cb37..e21adba 100644 --- a/docs/docgraph/mcp-runbook.md +++ b/docs/docgraph/mcp-runbook.md @@ -6,7 +6,7 @@ status: current component: DocGraph source-root: src/KyberWeave.Mcp owner: dpalfery -last-reviewed: 2026-08-04 +last-reviewed: 2026-08-12 code-refs: - DocsTools --- @@ -230,6 +230,9 @@ product defaults. A config that cannot be read is reported on **stderr** as `KW-CONFIG-001` and the server keeps running on defaults. That combination — a corpus that looks empty and a line on stderr the client may not surface — is worth checking before blaming the repo root. +This fallback applies to the retrieval corpus initialized at startup. The analysis and +glossary tools reload the current config for each call and return an unavailable response +until the invalid config is fixed; they do not analyze on defaults. ## The tools @@ -253,6 +256,31 @@ The documents whose `code-refs` **formally claim** a symbol — not those that m prose, which is exactly the distinction grep cannot make. Run it before renaming anything to find the documentation that must change with it. +### `docs_analysis_candidates(kind?, cursor?, limit = 20, charBudget = 12000)` + +Runs the repository's current configured documentation analysis and returns candidates in +stable kind, term, and candidate-id order. `kind` is optional and accepts `duplicate`, +`conflict`, or `terminology`. Pass the returned candidate id as `cursor` to continue after +that item. + +The tool is conversational rather than a bulk export: the hard candidate limit is 20, +the hard response budget is 12,000 characters, and evidence per candidate is capped. The +response keeps local cost/cache metrics and line-addressable evidence inside the same +budget. Use [`docs review export`](analysis.md#cli-and-exit-behavior) when a reviewing +agent needs the versioned rubric and hashes for reusable verdicts. + +### `docs_glossary(term)` + +Looks up one managed glossary term case-insensitively and returns its proposed, approved, +and rejected senses, definitions, scopes, and aliases. An unknown term is an ordinary +empty result, not an error. Output is capped at 20 senses and 12,000 characters. + +Both analysis tools are read-only. They accept no write parameter and cannot import a +verdict or change the glossary. Reusable decisions enter through `docs review import`, and +glossary proposals enter through `docs glossary --write` at the CLI. The MCP reader reloads +the current config and corpus for each call; embeddings remain loopback-only and are not +called unless the local cache is safely ignored. + ## Reading a code join ``` @@ -281,10 +309,13 @@ after editing documentation or after the CodeGraph daemon rewrites its index. | Every query is a miss | Wrong repo root, or `docs-root` in [config](../configuration.md) names a tree the documents are not in — check stderr for `KW-CONFIG-001` | | "no CodeGraph index was readable" | No `.codegraph/codegraph.db`, or `sqlite3` missing from PATH | | Joins show `(unresolved)` | The symbol is in `code-refs` but not in the index — run [`docs drift`](governance.md) | +| Analysis warns that the cache is unsafe | Run `docs init` to merge `.kyber-weave/.gitignore` with `cache/`; until then deterministic/lexical analysis continues and no document text is sent for embeddings | +| `docs_glossary` returns no senses | The configured glossary is absent, the term is not present, or its spelling differs; use `docs glossary .` to preview proposals | | Client reports a protocol error | Something wrote to stdout; check that you launched `kyber-weave-mcp`, not `kyber-weave` | ## Related - [Retrieval and ranking](retrieval.md) — how results are chosen and budgeted +- [Documentation analysis and review](analysis.md) — findings, review exchange, cache, and glossary - [DocGraph architecture](architecture.md) — the index behind these tools - [Installing Kyber-Weave](../install.md) — getting the binary on PATH diff --git a/docs/docgraph/onboarding.md b/docs/docgraph/onboarding.md index 8120c1e..714a7fc 100644 --- a/docs/docgraph/onboarding.md +++ b/docs/docgraph/onboarding.md @@ -6,7 +6,7 @@ status: current component: DocGraph source-root: src/KyberWeave.Core/Docs owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 --- # Adopting DocGraph in an existing repository @@ -21,14 +21,19 @@ command does the mechanical half, a skill does the judgment half. kyber-weave docs init . ``` -This writes three files and leaves any that already exist alone, so it is safe to re-run: +This considers four files and preserves existing operator-owned content, so it is safe to +re-run: | File | Why | |---|---| | `.kyber-weave/kyber-weave.yml` | Host config, with `docs-root` set to your detected tree and the inherited `DevOps/*` exclusions cleared | +| `.kyber-weave/.gitignore` | Safely merged narrow `cache/` entry for local analysis vectors and verdicts | | `/documentation-ontology.md` | The schema. Emitted because every `KW-DOC-SPEC-001` diagnostic tells the author to read it | | `/catalog.md` | The component and owner vocabulary, seeded with one example row | +Initialization does **not** create an empty glossary. A managed glossary is created only +when terminology evidence exists and `docs glossary --write` has something to merge. + The docs root is detected from the first conventional directory that exists — `docs`, `6-Docs`, `doc`, `documentation` — or created as `docs`. Re-running with an existing `.kyber-weave/kyber-weave.yml` honors its `docs-root` first, so scaffolding lands in the @@ -41,6 +46,11 @@ drop. The one key `docs init` will rewrite there is `ontology.docs-root`, in pla when `--docs-root` moves it, so the catalog and the validator never end up reading different trees. Comments and every other key survive the edit. +The same conservative rule applies to `.kyber-weave/.gitignore`: an effective `cache/` +entry is preserved, a missing one is appended without disturbing other patterns, and a +later negation is countered with a final narrow entry. `--force` does not replace this +operator-owned file. + ### APM is an expected dependency `docs init` also deploys the **`kyber-weave-docs` skill** through @@ -123,7 +133,24 @@ Be selective. Architecture documents, runbooks for a named service, and API refe earn `code-refs`; narrative and onboarding prose do not. **A document with no `code-refs` is completely valid** — an empty claim beats a false one. -## 5. Serve it +## 5. Analyze claims and terminology + +Once the corpus is valid, analysis can surface duplicated claims, potential conflicts, +and overloaded terms without editing any source document: + +```bash +kyber-weave docs analyze . +kyber-weave docs review export . --out candidates.json +kyber-weave docs glossary . +``` + +The default path is advisory, deterministic/lexical, and bounded. Embeddings are off. If +you later enable a loopback embedding endpoint, Kyber-Weave sends no document text unless +the local cache path is proven safely ignored. See +[documentation analysis and review](analysis.md) before enabling `high-recall` or +importing reusable verdicts. + +## 6. Serve it ```bash kyber-weave-mcp --repo-root . @@ -132,7 +159,10 @@ kyber-weave-mcp --repo-root . See the [MCP runbook](mcp-runbook.md) for client configuration. Agents should reach the corpus through `docs_explore` rather than by reading files. -## 6. Gate it +The MCP surface also exposes capped read-only `docs_analysis_candidates` and +`docs_glossary` tools; verdict and glossary writes remain CLI-only. + +## 7. Gate it Add the [docs gate workflow](../ci-pipelines/workflows-runbook.md) so the corpus cannot regress. Start with `docs validate` alone — it needs no index — and add `docs drift` once @@ -156,4 +186,5 @@ ontology at all. - [The documentation ontology](../documentation-ontology.md) — the schema being adopted - [Governance gates](governance.md) — what `validate` and `drift` check - [Retrieval and ranking](retrieval.md) — why doc-type and status affect results +- [Documentation analysis and review](analysis.md) — duplicates, conflicts, terms, and review - [Skill governance](../context-hygiene/skills.md) — the skill is itself a governed artifact diff --git a/docs/docgraph/retrieval.md b/docs/docgraph/retrieval.md index 9f30ca9..740b2c4 100644 --- a/docs/docgraph/retrieval.md +++ b/docs/docgraph/retrieval.md @@ -5,7 +5,7 @@ doc-type: reference status: current component: DocGraph owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 code-refs: - DocumentIndex - DocumentCorpus @@ -112,8 +112,21 @@ the document holds without opening it, and can ask again deliberately — and is whether a section was dropped for lack of budget or lack of relevance, since only the former makes asking again worthwhile. +## Retrieval is not analysis + +Retrieval ranks documents for a caller's question; documentation analysis compares +line-addressable claims to find duplication, conflicts, and divergent terminology. They +reuse the same parsed documents, declared identity, and DocGraph relationships, but their +candidate algorithms and budgets are separate. + +Analysis starts with graph neighbors and exact content hashes, then optionally adds a +sparse lexical top-k fallback or cached semantic reranking. The default never performs an +all-pairs scan and never calls an embedding endpoint. See +[Documentation analysis and review](analysis.md) for search modes and their cost bounds. + ## Related - [DocGraph architecture](architecture.md) — how the index is built and kept fresh - [MCP server runbook](mcp-runbook.md) — the tools that expose this +- [Documentation analysis and review](analysis.md) — claim comparison and review workflow - [The documentation ontology](../documentation-ontology.md) — the identity ranking reads diff --git a/docs/documentation-ontology.md b/docs/documentation-ontology.md index 2e63473..4c7b469 100644 --- a/docs/documentation-ontology.md +++ b/docs/documentation-ontology.md @@ -5,7 +5,7 @@ doc-type: reference status: current component: DocGraph owner: dpalfery -last-reviewed: 2026-08-01 +last-reviewed: 2026-08-12 --- # The documentation ontology @@ -70,6 +70,11 @@ Adding a member is a change to the ontology, made in [`.kyber-weave/kyber-weave.yml`](../.kyber-weave/kyber-weave.yml) — not an authoring decision made mid-document. +The managed glossary introduced by documentation analysis conforms to this ontology. It +is a `reference` document whose lifecycle uses the existing `needs-review` and `current` +statuses; `proposed`, `approved`, and `rejected` describe sense rows, not document status. +No glossary-specific document type or ontology widening is required. + ## The required-key matrix Requirements vary by doc-type, because what makes an architecture document complete does @@ -128,5 +133,6 @@ diagnostic names. See [adoption](docgraph/onboarding.md). ## Related - [DocGraph architecture](docgraph/architecture.md) — how the corpus becomes a graph +- [Documentation analysis and review](docgraph/analysis.md) — how claims and terminology are compared - [Documentation governance](docgraph/governance.md) — the gates that enforce this file - [Component and owner catalog](catalog.md) — the vocabulary this file defers to diff --git a/docs/install.md b/docs/install.md index de71525..f614fda 100644 --- a/docs/install.md +++ b/docs/install.md @@ -5,7 +5,7 @@ doc-type: runbook status: current component: Distribution owner: dpalfery -last-reviewed: 2026-08-11 +last-reviewed: 2026-08-12 --- # Installing Kyber-Weave @@ -111,8 +111,9 @@ kyber-weave --help kyber-weave docs init . ``` -This scaffolds host config, the catalog, and the ontology reference, and deploys the -`kyber-weave-docs` authoring skill via APM. See +This scaffolds host config, the catalog, and the ontology reference; safely merges the +narrow `.kyber-weave/.gitignore` entry for local analysis cache state; and deploys the +`kyber-weave-docs` authoring skill via APM. It does not create an empty glossary. See [Adopting DocGraph](docgraph/onboarding.md) for the whole path. ## External dependencies diff --git a/src/KyberWeave.Cli/Commands/AnalysisSettings.cs b/src/KyberWeave.Cli/Commands/AnalysisSettings.cs index 28fdb55..821e190 100644 --- a/src/KyberWeave.Cli/Commands/AnalysisSettings.cs +++ b/src/KyberWeave.Cli/Commands/AnalysisSettings.cs @@ -8,7 +8,7 @@ namespace KyberWeave.Cli.Commands; public class AnalysisSettings : CommandSettings { [CommandArgument(0, "[path]")] - [Description("Path to a SKILL.md, a skill directory, or a root containing many skills. Defaults to current directory.")] + [Description("Repository or artifact path to inspect, including its documentation corpus. Defaults to current directory.")] public string Path { get; set; } = "."; [CommandOption("-f|--format ")] diff --git a/src/KyberWeave.Cli/Commands/CommandHelpers.cs b/src/KyberWeave.Cli/Commands/CommandHelpers.cs index 2010407..eba0eac 100644 --- a/src/KyberWeave.Cli/Commands/CommandHelpers.cs +++ b/src/KyberWeave.Cli/Commands/CommandHelpers.cs @@ -66,6 +66,11 @@ public static void Finish(DiagnosticReport report, AnalysisSettings settings, st { var filtered = new DiagnosticReport(); filtered.AddRange(report.Items.Where(i => i.Severity != Severity.Info)); + foreach (var metric in report.Metrics) + { + filtered.AddMetric(metric.Key, metric.Value); + } + report = filtered; } diff --git a/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisCommands.cs b/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisCommands.cs new file mode 100644 index 0000000..267c627 --- /dev/null +++ b/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisCommands.cs @@ -0,0 +1,210 @@ +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Review; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace KyberWeave.Cli.Commands.Docs; + +/// Injectable command boundary over repository analysis and its review artifacts. +public interface IDocsAnalysisCommandService +{ + DocumentationAnalysisResult Analyze(DocsAnalyzeSettings settings); + ReviewExportResult ExportReview(DocsReviewExportSettings settings); + ReviewImportResult ImportReview(DocsReviewImportSettings settings, string json); + GlossaryUpdateResult UpdateGlossary(DocsGlossarySettings settings); +} + +public sealed class DocsAnalyzeCommand : Command +{ + private readonly IDocsAnalysisCommandService _service; + + public DocsAnalyzeCommand() : this(new RepositoryDocsAnalysisCommandService()) { } + + internal DocsAnalyzeCommand(IDocsAnalysisCommandService service) => + _service = service ?? throw new ArgumentNullException(nameof(service)); + + public override int Execute(CommandContext context, DocsAnalyzeSettings settings) + { + try + { + var result = _service.Analyze(settings); + CommandHelpers.Finish(result.Diagnostics, settings, "docs analyze", "Claim"); + if (HasOperationalErrors(result.Diagnostics)) return 1; + return FindingExitCode(result.Diagnostics, settings.FailOn); + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + var report = DocsAnalysisCommandErrors.Report(exception, DocumentationAnalyzer.IgnoreMarkupRuleCode); + CommandHelpers.Finish(report, settings, "docs analyze", "Claim"); + return 1; + } + } + + internal static int FindingExitCode(DiagnosticReport report, string failOn) => + failOn.Trim().ToLowerInvariant() switch + { + "none" => 0, + "warning" => report.Items.Any(item => item.Severity >= Severity.Warning) ? 1 : 0, + "error" => report.HasErrors ? 1 : 0, + _ => throw new ArgumentException("--fail-on must be none, warning, or error.", nameof(failOn)) + }; + + private static bool HasOperationalErrors(DiagnosticReport report) => + report.Items.Any(item => + item.Severity is Severity.Error or Severity.Critical + && item.Code is ( + DocumentationAnalyzer.IgnoreMarkupRuleCode or + DocumentationAnalyzer.EmbeddingUnavailableRuleCode)); +} + +public sealed class DocsReviewExportCommand : Command +{ + private readonly IDocsAnalysisCommandService _service; + + public DocsReviewExportCommand() : this(new RepositoryDocsAnalysisCommandService()) { } + + internal DocsReviewExportCommand(IDocsAnalysisCommandService service) => + _service = service ?? throw new ArgumentNullException(nameof(service)); + + public override int Execute(CommandContext context, DocsReviewExportSettings settings) + { + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(settings.OutputPath); + var result = _service.ExportReview(settings); + AtomicTextFile.Write(settings.OutputPath, result.Json); + var report = result.Diagnostics; + report.AddMetric("exportedReviewCharacters", result.ExportedExcerptCharacters); + report.AddMetric("reviewCandidates", result.Bundle.Candidates.Count); + report.AddMetric("truncated", result.Truncated); + CommandHelpers.Finish(report, settings, "docs review export", "Candidate"); + if (settings.ParsedFormat == KyberWeave.Cli.Rendering.OutputFormat.Table) + { + AnsiConsole.MarkupLine( + $"[green]Exported[/] {result.Bundle.Candidates.Count} review candidates to " + + $"[grey]{Markup.Escape(settings.OutputPath)}[/]."); + } + return 0; + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + DocsAnalysisCommandErrors.Render(exception, settings, "docs review export"); + return 1; + } + } +} + +public sealed class DocsReviewImportCommand : Command +{ + private readonly IDocsAnalysisCommandService _service; + + public DocsReviewImportCommand() : this(new RepositoryDocsAnalysisCommandService()) { } + + internal DocsReviewImportCommand(IDocsAnalysisCommandService service) => + _service = service ?? throw new ArgumentNullException(nameof(service)); + + public override int Execute(CommandContext context, DocsReviewImportSettings settings) + { + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(settings.InputPath); + var result = _service.ImportReview(settings, File.ReadAllText(settings.InputPath)); + CommandHelpers.Finish(result.Diagnostics, settings, "docs review import", "Candidate"); + return result.Success ? 0 : 1; + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + DocsAnalysisCommandErrors.Render( + exception, + settings, + "docs review import", + DocumentationReviewExchange.ReviewRuleCode); + return 1; + } + } +} + +public sealed class DocsGlossaryCommand : Command +{ + private readonly IDocsAnalysisCommandService _service; + + public DocsGlossaryCommand() : this(new RepositoryDocsAnalysisCommandService()) { } + + internal DocsGlossaryCommand(IDocsAnalysisCommandService service) => + _service = service ?? throw new ArgumentNullException(nameof(service)); + + public override int Execute(CommandContext context, DocsGlossarySettings settings) + { + try + { + var result = _service.UpdateGlossary(settings); + result.Diagnostics.AddMetric("glossaryPath", result.RelativePath); + result.Diagnostics.AddMetric("glossaryChanged", result.Changed); + result.Diagnostics.AddMetric("glossaryWritten", result.Written); + result.Diagnostics.AddMetric("glossaryPreview", result.Markdown); + CommandHelpers.Finish(result.Diagnostics, settings, "docs glossary", "Glossary"); + return result.Diagnostics.HasErrors ? 1 : 0; + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + DocsAnalysisCommandErrors.Render( + exception, + settings, + "docs glossary", + ManagedGlossaryService.ValidationRuleCode); + return 1; + } + } +} + +internal static class DocsAnalysisCommandErrors +{ + public static bool IsOperational(Exception exception) => exception is + IOException or UnauthorizedAccessException or InvalidDataException or + InvalidOperationException or ArgumentException; + + public static DiagnosticReport Report(Exception exception, string code) + { + var report = new DiagnosticReport(); + report.Add(new Diagnostic(CodeFrom(exception.Message) ?? code, Severity.Error, exception.Message, "docs analysis")); + return report; + } + + private static string? CodeFrom(string message) + { + if (!message.StartsWith("KW-", StringComparison.Ordinal)) return null; + var separator = message.IndexOf(':', StringComparison.Ordinal); + return separator > 0 ? message[..separator] : null; + } + + public static void Render( + Exception exception, + DocsSettings settings, + string command, + string code = DocumentationReviewExchange.ReviewRuleCode) => + CommandHelpers.Finish(Report(exception, code), settings, command, "Operation"); +} + +internal static class AtomicTextFile +{ + public static void Write(string path, string content) + { + var absolute = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(absolute) + ?? throw new ArgumentException("The output path has no parent directory.", nameof(path)); + Directory.CreateDirectory(directory); + var temporary = Path.Combine(directory, $".{Path.GetFileName(absolute)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporary, content); + File.Move(temporary, absolute, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } +} diff --git a/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisSettings.cs b/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisSettings.cs new file mode 100644 index 0000000..896d279 --- /dev/null +++ b/src/KyberWeave.Cli/Commands/Docs/DocsAnalysisSettings.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace KyberWeave.Cli.Commands.Docs; + +public sealed class DocsAnalyzeSettings : DocsSettings +{ + [CommandOption("--fail-on ")] + [Description("Finding severity that returns nonzero: none | warning | error.")] + [DefaultValue("none")] + public string FailOn { get; set; } = "none"; +} + +public sealed class DocsReviewExportSettings : DocsSettings +{ + [CommandOption("--out ")] + [Description("Destination for the versioned review candidate JSON bundle.")] + public string OutputPath { get; set; } = string.Empty; +} + +public sealed class DocsReviewImportSettings : DocsSettings +{ + [CommandOption("--in ")] + [Description("Versioned review verdict JSON bundle to validate and import atomically.")] + public string InputPath { get; set; } = string.Empty; +} + +public sealed class DocsGlossarySettings : DocsSettings +{ + [CommandOption("--write")] + [Description("Merge proposed senses into the managed glossary. Without this flag, preview only.")] + public bool Write { get; set; } +} diff --git a/src/KyberWeave.Cli/Commands/Docs/DocsCommandComposition.cs b/src/KyberWeave.Cli/Commands/Docs/DocsCommandComposition.cs index 6ff3e8c..0bc0a66 100644 --- a/src/KyberWeave.Cli/Commands/Docs/DocsCommandComposition.cs +++ b/src/KyberWeave.Cli/Commands/Docs/DocsCommandComposition.cs @@ -1,6 +1,11 @@ using KyberWeave.Core.CodeGraph; using KyberWeave.Core.Configuration; using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Persistence; using KyberWeave.Core.Docs.Parsing; namespace KyberWeave.Cli.Commands.Docs; @@ -17,7 +22,16 @@ public static bool TryResolveOntology( DiagnosticReport report, out OntologyConfig ontology) { - if (!CommandHelpers.TryLoadConfig(settings.Path, settings.Config, report, out var config)) + return TryResolveConfig(settings, report, out _, out ontology); + } + + public static bool TryResolveConfig( + DocsSettings settings, + DiagnosticReport report, + out KyberWeaveConfig config, + out OntologyConfig ontology) + { + if (!CommandHelpers.TryLoadConfig(settings.Path, settings.Config, report, out config)) { ontology = OntologyConfig.ProductDefaults; return false; @@ -36,6 +50,12 @@ public static bool TryResolveOntology( try { ontology = loaded.WithDocsRoots(settings.DocsRoots); + config = new KyberWeaveConfig + { + Ontology = ontology, + Harness = config.Harness, + DocsAnalysis = config.DocsAnalysis.ResolveFor(ontology) + }; return true; } catch (ArgumentException ex) @@ -63,8 +83,16 @@ public static bool TryCreateLoader( DiagnosticReport report, out DocumentLoader? loader, out OntologyConfig ontology) + => TryCreateLoader(settings, report, out loader, out ontology, out _); + + public static bool TryCreateLoader( + DocsSettings settings, + DiagnosticReport report, + out DocumentLoader? loader, + out OntologyConfig ontology, + out KyberWeaveConfig config) { - if (!TryResolveOntology(settings, report, out ontology)) + if (!TryResolveConfig(settings, report, out config, out ontology)) { loader = null; return false; @@ -76,4 +104,148 @@ public static bool TryCreateLoader( public static ICodeGraphResolver CreateResolver(DocsSettings settings) => CodeGraphResolverAdapter.ForRepository(settings.Path); + + public static bool TryCreateAnalysisRuntime( + DocsSettings settings, + DiagnosticReport report, + DocsAnalysisCompositionFactories factories, + out DocsAnalysisRuntime? runtime) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(factories); + + runtime = null; + if (!TryResolveConfig(settings, report, out var config, out var ontology)) return false; + + var resolver = factories.CreateResolver(settings.Path); + if (!resolver.IsAvailable) + { + report.Add(new Diagnostic( + DocumentationAnalyzer.CodeGraphUnavailableRuleCode, + Severity.Warning, + resolver.UnavailableReason ?? "The CodeGraph index is unavailable.", + "CodeGraph", + resolver.DatabasePath, + "Analysis continues with document relationships and bounded lexical search.")); + } + + var mode = config.DocsAnalysis.Embeddings.Mode; + var cacheSafe = factories.IsCacheSafe(settings.Path); + if (!cacheSafe && mode != DocsAnalysisEmbeddingMode.Off) + { + AddEmbeddingAvailability( + report, + mode, + "Analysis cache persistence is unsafe; no document text was sent to the embedding endpoint."); + if (mode == DocsAnalysisEmbeddingMode.Required) return false; + } + + IAnalysisPersistence? persistence = null; + IEmbeddingGenerator? embeddingGenerator = null; + if (cacheSafe) + { + persistence = factories.CreatePersistence(settings.Path); + if (!persistence.IsAvailable) + { + DisposeIfOwned(persistence); + persistence = null; + if (mode != DocsAnalysisEmbeddingMode.Off) + { + AddEmbeddingAvailability( + report, + mode, + "The local analysis cache is unavailable; embeddings were not constructed or called."); + if (mode == DocsAnalysisEmbeddingMode.Required) return false; + } + } + } + + if (mode != DocsAnalysisEmbeddingMode.Off && persistence is not null) + embeddingGenerator = factories.CreateEmbeddingGenerator(); + + runtime = new DocsAnalysisRuntime( + settings.Path, + config, + ontology, + resolver, + persistence, + embeddingGenerator); + return true; + } + + private static void AddEmbeddingAvailability( + DiagnosticReport report, + DocsAnalysisEmbeddingMode mode, + string message) => + report.Add(new Diagnostic( + DocumentationAnalyzer.EmbeddingUnavailableRuleCode, + mode == DocsAnalysisEmbeddingMode.Required ? Severity.Error : Severity.Warning, + message, + "embeddings", + Hint: mode == DocsAnalysisEmbeddingMode.Required + ? "Create the narrow .kyber-weave/.gitignore cache entry and ensure sqlite3 is available." + : "Lexical analysis remains active; configure safe persistence to enable embeddings.")); + + private static void DisposeIfOwned(object value) + { + if (value is IDisposable disposable) disposable.Dispose(); + } +} + +internal sealed class DocsAnalysisCompositionFactories +{ + public Func IsCacheSafe { get; init; } = AnalysisCacheSafety.IsSafe; + public Func CreatePersistence { get; init; } = + repositoryRoot => new SqliteAnalysisPersistence(repositoryRoot); + public Func CreateEmbeddingGenerator { get; init; } = + () => new OpenAiCompatibleEmbeddingGenerator(); + public Func CreateResolver { get; init; } = + CodeGraphResolverAdapter.ForRepository; +} + +internal sealed class DocsAnalysisRuntime : IDisposable +{ + private readonly IDisposable? _persistenceOwner; + private readonly IDisposable? _embeddingOwner; + private bool _disposed; + + public DocsAnalysisRuntime( + string repositoryRoot, + KyberWeaveConfig config, + OntologyConfig ontology, + ICodeGraphResolver resolver, + IAnalysisPersistence? persistence, + IEmbeddingGenerator? embeddingGenerator) + { + RepositoryRoot = Path.GetFullPath(repositoryRoot); + Config = config; + Ontology = ontology; + Resolver = resolver; + Persistence = persistence; + EmbeddingGenerator = embeddingGenerator; + _persistenceOwner = persistence as IDisposable; + _embeddingOwner = embeddingGenerator as IDisposable; + } + + public string RepositoryRoot { get; } + public KyberWeaveConfig Config { get; } + public OntologyConfig Ontology { get; } + public ICodeGraphResolver Resolver { get; } + public IAnalysisPersistence? Persistence { get; } + public IEmbeddingGenerator? EmbeddingGenerator { get; } + + public DocumentationAnalyzer CreateAnalyzer() => new( + new ClaimExtractor(), + [new GraphClaimCandidateSource(), new SparseLexicalCandidateSource()], + EmbeddingGenerator, + Persistence); + + public void Dispose() + { + if (_disposed) return; + _embeddingOwner?.Dispose(); + _persistenceOwner?.Dispose(); + _disposed = true; + } } diff --git a/src/KyberWeave.Cli/Commands/Docs/DocsGraphCommand.cs b/src/KyberWeave.Cli/Commands/Docs/DocsGraphCommand.cs index ed1505f..4434af7 100644 --- a/src/KyberWeave.Cli/Commands/Docs/DocsGraphCommand.cs +++ b/src/KyberWeave.Cli/Commands/Docs/DocsGraphCommand.cs @@ -1,4 +1,5 @@ using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Glossary; using KyberWeave.Core.Docs.Export; using Spectre.Console; using Spectre.Console.Cli; @@ -11,7 +12,12 @@ public sealed class DocsGraphCommand : Command public override int Execute(CommandContext context, DocsGraphSettings settings) { var report = new DiagnosticReport(); - if (!DocsCommandComposition.TryCreateLoader(settings, report, out var loader)) + if (!DocsCommandComposition.TryCreateLoader( + settings, + report, + out var loader, + out _, + out var config)) { CommandHelpers.Finish(report, settings, "docs graph", "Document"); return 1; @@ -28,7 +34,29 @@ public override int Execute(CommandContext context, DocsGraphSettings settings) return 1; } - var result = new DocGraphExporter(resolver).Export(set, settings.Out); + ManagedGlossaryLoadResult glossary; + ManagedGlossaryGraphContributor glossaryContributor; + try + { + glossary = new ManagedGlossaryService( + settings.Path, + config, + TimeProvider.System).Load(); + glossaryContributor = new ManagedGlossaryGraphContributor(glossary); + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + DocsAnalysisCommandErrors.Render( + exception, + settings, + "docs graph", + ManagedGlossaryService.ValidationRuleCode); + return 1; + } + var result = new DocGraphExporter(resolver).Export( + set, + settings.Out, + contributors: [glossaryContributor]); AnsiConsole.MarkupLine( $"[green]{result.NodeCount} nodes[/] → {Markup.Escape(result.NodesPath)}"); diff --git a/src/KyberWeave.Cli/Commands/Docs/DocsValidateCommand.cs b/src/KyberWeave.Cli/Commands/Docs/DocsValidateCommand.cs index e640699..bc3e372 100644 --- a/src/KyberWeave.Cli/Commands/Docs/DocsValidateCommand.cs +++ b/src/KyberWeave.Cli/Commands/Docs/DocsValidateCommand.cs @@ -1,4 +1,6 @@ using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Parsing; using KyberWeave.Core.Docs.Validation; using Spectre.Console.Cli; @@ -10,14 +12,30 @@ public sealed class DocsValidateCommand : Command public override int Execute(CommandContext context, DocsSettings settings) { var report = new DiagnosticReport(); - if (!DocsCommandComposition.TryCreateLoader(settings, report, out var loader, out var ontology)) + if (!DocsCommandComposition.TryResolveConfig(settings, report, out var config, out var ontology)) { CommandHelpers.Finish(report, settings, "docs validate", "Document"); return 1; } - var set = loader!.Load(); + var set = new DocumentLoader(settings.Path, ontology).Load(); report.AddRange(new DocSpecValidator(settings.Path, ontology).Validate(set).Items); + try + { + report.AddRange(new ManagedGlossaryService( + settings.Path, + config, + TimeProvider.System).Validate().Items); + } + catch (Exception exception) when (DocsAnalysisCommandErrors.IsOperational(exception)) + { + report.Add(new Diagnostic( + ManagedGlossaryService.ValidationRuleCode, + Severity.Error, + exception.Message, + "glossary", + Hint: "Fix the managed glossary path or contents, then re-run docs validate.")); + } CommandHelpers.Finish(report, settings, "docs validate", "Document"); return report.HasErrors ? 1 : 0; diff --git a/src/KyberWeave.Cli/Commands/Docs/RepositoryDocsAnalysisCommandService.cs b/src/KyberWeave.Cli/Commands/Docs/RepositoryDocsAnalysisCommandService.cs new file mode 100644 index 0000000..933d598 --- /dev/null +++ b/src/KyberWeave.Cli/Commands/Docs/RepositoryDocsAnalysisCommandService.cs @@ -0,0 +1,200 @@ +using System.Diagnostics.CodeAnalysis; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Review; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Parsing; + +namespace KyberWeave.Cli.Commands.Docs; + +/// Repository-backed implementation used by the parameterless CLI commands. +internal sealed class RepositoryDocsAnalysisCommandService : IDocsAnalysisCommandService +{ + private readonly DocsAnalysisCompositionFactories _factories; + + public RepositoryDocsAnalysisCommandService() + : this(new DocsAnalysisCompositionFactories()) { } + + internal RepositoryDocsAnalysisCommandService(DocsAnalysisCompositionFactories factories) => + _factories = factories ?? throw new ArgumentNullException(nameof(factories)); + + public DocumentationAnalysisResult Analyze(DocsAnalyzeSettings settings) + { + using var execution = RunAnalysis(settings); + return execution.Result; + } + + public ReviewExportResult ExportReview(DocsReviewExportSettings settings) + { + using var execution = RunAnalysis(settings); + ThrowIfOperational(execution.Result.Diagnostics); + var persistence = execution.Runtime.Persistence ?? UnavailableAnalysisPersistence.Instance; + var exported = new DocumentationReviewExchange( + persistence, + execution.Runtime.Config.DocsAnalysis.VerdictConfidence) + .Export(execution.Result.Candidates); + MergeDiagnostics(exported.Diagnostics, execution.Result.Diagnostics); + return exported; + } + + public ReviewImportResult ImportReview(DocsReviewImportSettings settings, string json) + { + ArgumentNullException.ThrowIfNull(json); + using var execution = RunAnalysis(settings); + ThrowIfOperational(execution.Result.Diagnostics); + var persistence = execution.Runtime.Persistence ?? UnavailableAnalysisPersistence.Instance; + var imported = new DocumentationReviewExchange( + persistence, + execution.Runtime.Config.DocsAnalysis.VerdictConfidence) + .Import(json, execution.Result.Candidates); + MergeDiagnostics(imported.Diagnostics, execution.Result.Diagnostics); + return imported; + } + + public GlossaryUpdateResult UpdateGlossary(DocsGlossarySettings settings) + { + using var execution = RunAnalysis(settings); + ThrowIfOperational(execution.Result.Diagnostics); + var proposals = Proposals(execution.Result.Candidates); + var service = new ManagedGlossaryService( + execution.Runtime.RepositoryRoot, + execution.Runtime.Config, + TimeProvider.System); + var glossary = settings.Write ? service.Write(proposals) : service.Preview(proposals); + MergeDiagnostics(glossary.Diagnostics, execution.Result.Diagnostics); + return glossary; + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership transfers to AnalysisExecution on success and the catch path disposes on failure.")] + private AnalysisExecution RunAnalysis(DocsSettings settings) + { + var compositionDiagnostics = new DiagnosticReport(); + if (!DocsCommandComposition.TryCreateAnalysisRuntime( + settings, + compositionDiagnostics, + _factories, + out var runtime)) + { + throw new InvalidOperationException(string.Join( + " ", + compositionDiagnostics.Items.Select(item => $"{item.Code}: {item.Message}"))); + } + + var created = runtime ?? throw new InvalidOperationException( + "Analysis composition succeeded without creating a runtime."); + + try + { + var documents = new DocumentLoader(created.RepositoryRoot, created.Ontology).Load(); + var graph = DocGraphProjection.Build( + documents, + created.Resolver, + created.Config.DocsAnalysis.Search.MaxCodeNeighbors); + var glossary = new ManagedGlossaryService( + created.RepositoryRoot, + created.Config, + TimeProvider.System).Load(); + var analyzed = created.CreateAnalyzer().Analyze( + documents, + graph, + created.Config.DocsAnalysis, + glossary.AnalysisGlossary); + analyzed.Diagnostics.AddRange(compositionDiagnostics.Items); + return new AnalysisExecution(created, analyzed); + } + catch + { + created.Dispose(); + throw; + } + } + + private static IReadOnlyList Proposals( + IReadOnlyList candidates) + { + var proposals = new List(); + foreach (var candidate in candidates.Where(candidate => + candidate.Kind == AnalysisRuleKind.Terminology + && !string.IsNullOrWhiteSpace(candidate.Term))) + { + if (candidate.Verdict?.ProposedGlossarySenses is { Count: > 0 } reviewed) + { + proposals.AddRange(reviewed.Select(sense => new GlossaryProposal( + sense.Term, + sense.Definition, + sense.Scopes, + sense.Aliases, + candidate.Claims.Select(claim => claim.ContentHash).ToArray()))); + continue; + } + + proposals.AddRange(candidate.Claims + .Where(claim => !string.IsNullOrWhiteSpace(claim.Component)) + .GroupBy(claim => claim.Component!, StringComparer.Ordinal) + .Select(group => new GlossaryProposal( + candidate.Term!, + string.Empty, + [$"component:{group.Key}"], + [], + group.Select(claim => claim.ContentHash).ToArray()))); + } + + return proposals; + } + + private static void ThrowIfOperational(DiagnosticReport diagnostics) + { + var operational = diagnostics.Items.Where(item => + item.Severity is Severity.Error or Severity.Critical + && item.Code is ( + DocumentationAnalyzer.IgnoreMarkupRuleCode or + DocumentationAnalyzer.EmbeddingUnavailableRuleCode)).ToArray(); + if (operational.Length == 0) return; + + throw new InvalidOperationException(string.Join( + " ", + operational.Select(item => $"{item.Code}: {item.Message}"))); + } + + private static void MergeDiagnostics(DiagnosticReport target, DiagnosticReport source) + { + foreach (var item in source.Items) + { + if (!target.Items.Contains(item)) target.Add(item); + } + + foreach (var metric in source.Metrics) + { + target.AddMetric(metric.Key, metric.Value); + } + } + + private sealed record AnalysisExecution( + DocsAnalysisRuntime Runtime, + DocumentationAnalysisResult Result) : IDisposable + { + public void Dispose() => Runtime.Dispose(); + } + + private sealed class UnavailableAnalysisPersistence : IAnalysisPersistence + { + public static UnavailableAnalysisPersistence Instance { get; } = new(); + public bool IsAvailable => false; + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) => + new Dictionary(); + + public void SaveEmbeddings(IReadOnlyCollection embeddings) => + throw new InvalidOperationException("The analysis cache is unavailable."); + } +} diff --git a/src/KyberWeave.Cli/Program.cs b/src/KyberWeave.Cli/Program.cs index a0d60b2..d6b1357 100644 --- a/src/KyberWeave.Cli/Program.cs +++ b/src/KyberWeave.Cli/Program.cs @@ -96,6 +96,27 @@ docs.AddCommand("catalog") .WithDescription("Display doc-type coverage by component.") .WithExample("docs", "catalog", "."); + + docs.AddCommand("analyze") + .WithDescription("Find duplicate claims, potential conflicts, and ambiguous terminology.") + .WithExample("docs", "analyze", ".", "--format", "sarif") + .WithExample("docs", "analyze", ".", "--fail-on", "warning"); + + docs.AddBranch("review", review => + { + review.SetDescription("Exchange bounded documentation candidates and reusable verdicts."); + review.AddCommand("export") + .WithDescription("Export pending documentation analysis candidates for agent review.") + .WithExample("docs", "review", "export", ".", "--out", "candidates.json"); + review.AddCommand("import") + .WithDescription("Validate and atomically cache an agent verdict bundle.") + .WithExample("docs", "review", "import", ".", "--in", "verdicts.json"); + }); + + docs.AddCommand("glossary") + .WithDescription("Preview or merge managed glossary proposals from terminology analysis.") + .WithExample("docs", "glossary", ".") + .WithExample("docs", "glossary", ".", "--write"); }); }); diff --git a/src/KyberWeave.Cli/Rendering/ReportRenderer.cs b/src/KyberWeave.Cli/Rendering/ReportRenderer.cs index 1fd289c..00d5ba5 100644 --- a/src/KyberWeave.Cli/Rendering/ReportRenderer.cs +++ b/src/KyberWeave.Cli/Rendering/ReportRenderer.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -48,6 +49,7 @@ private static void RenderTable(DiagnosticReport report, string subjectLabel) if (report.Items.Count == 0) { AnsiConsole.MarkupLine("[green]No findings.[/]"); + RenderMetricsTable(report); return; } @@ -55,6 +57,7 @@ private static void RenderTable(DiagnosticReport report, string subjectLabel) table.AddColumn("Severity"); table.AddColumn("Code"); table.AddColumn(subjectLabel); + table.AddColumn(new TableColumn("Location").NoWrap()); table.AddColumn("Message"); foreach (var d in report.Items.OrderByDescending(i => i.Severity)) @@ -64,10 +67,12 @@ private static void RenderTable(DiagnosticReport report, string subjectLabel) new Markup($"[{color.ToMarkup()}]{Glyph(d.Severity)} {d.Severity}[/]"), new Markup(Markup.Escape(d.Code)), new Markup(Markup.Escape(d.Subject)), + new Markup(Markup.Escape(FormatLocation(d))), new Markup(Markup.Escape(d.Message) + (d.Hint is null ? "" : $"\n[grey]→ {Markup.Escape(d.Hint)}[/]"))); } AnsiConsole.Write(table); + RenderMetricsTable(report); } public static void RenderSummary(DiagnosticReport report) @@ -83,7 +88,8 @@ private static string ToJson(DiagnosticReport report) { var arr = new JsonArray(); foreach (var d in report.Items) - arr.Add(new JsonObject + { + var finding = new JsonObject { ["code"] = d.Code, ["severity"] = d.Severity.ToString().ToLowerInvariant(), @@ -91,7 +97,28 @@ private static string ToJson(DiagnosticReport report) ["message"] = d.Message, ["file"] = d.FilePath, ["hint"] = d.Hint - }); + }; + AddRange(finding, d.StartLine, d.EndLine); + if (d.RelatedLocations is { Count: > 0 }) + { + var relatedLocations = new JsonArray(); + foreach (var related in d.RelatedLocations) + { + var location = new JsonObject + { + ["file"] = related.FilePath, + ["message"] = related.Message + }; + AddRange(location, related.StartLine, related.EndLine); + relatedLocations.Add(location); + } + + finding["relatedLocations"] = relatedLocations; + } + + arr.Add(finding); + } + var root = new JsonObject { ["summary"] = new JsonObject @@ -103,6 +130,7 @@ private static string ToJson(DiagnosticReport report) }, ["findings"] = arr }; + AddMetrics(root, report); return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } @@ -113,11 +141,31 @@ private static string ToMarkdown(DiagnosticReport report, string command, string sb.AppendLine(); sb.AppendLine($"**{report.Count(Severity.Critical)} critical · {report.Count(Severity.Error)} error · {report.Warnings} warning · {report.Infos} info**"); sb.AppendLine(); - if (report.Items.Count == 0) { sb.AppendLine("_No findings._"); return sb.ToString(); } - sb.AppendLine($"| Severity | Code | {subjectLabel} | Message |"); - sb.AppendLine("|---|---|---|---|"); - foreach (var d in report.Items.OrderByDescending(i => i.Severity)) - sb.AppendLine($"| {d.Severity} | {d.Code} | {d.Subject} | {d.Message.Replace("|", "\\|")} |"); + if (report.Items.Count == 0) + { + sb.AppendLine("_No findings._"); + } + else + { + sb.AppendLine($"| Severity | Code | {subjectLabel} | Location | Message |"); + sb.AppendLine("|---|---|---|---|---|"); + foreach (var d in report.Items.OrderByDescending(i => i.Severity)) + { + sb.AppendLine($"| {d.Severity} | {EscapeMarkdown(d.Code)} | {EscapeMarkdown(d.Subject)} | {EscapeMarkdown(FormatLocation(d, includeRelatedCount: false))} | {EscapeMarkdown(d.Message)} |"); + if (d.RelatedLocations is not { Count: > 0 }) + { + continue; + } + + foreach (var related in d.RelatedLocations) + { + var message = related.Message is null ? "Related location" : related.Message; + sb.AppendLine($"| | | | {EscapeMarkdown(FormatLocation(related))} | {EscapeMarkdown(message)} |"); + } + } + } + + AppendMarkdownMetrics(sb, report); return sb.ToString(); } @@ -146,41 +194,223 @@ private static string ToSarif(DiagnosticReport report) }; if (!string.IsNullOrEmpty(d.FilePath)) { + var physicalLocation = CreateSarifPhysicalLocation(d.FilePath, d.StartLine, d.EndLine); result["locations"] = new JsonArray { new JsonObject { - ["physicalLocation"] = new JsonObject - { - ["artifactLocation"] = new JsonObject { ["uri"] = d.FilePath } - } + ["physicalLocation"] = physicalLocation } }; } + + if (d.RelatedLocations is { Count: > 0 }) + { + var relatedLocations = new JsonArray(); + foreach (var related in d.RelatedLocations) + { + var location = new JsonObject + { + ["physicalLocation"] = CreateSarifPhysicalLocation( + related.FilePath, + related.StartLine, + related.EndLine) + }; + if (related.Message is not null) + { + location["message"] = new JsonObject { ["text"] = related.Message }; + } + + relatedLocations.Add(location); + } + + result["relatedLocations"] = relatedLocations; + } + results.Add(result); } + var run = new JsonObject + { + ["tool"] = new JsonObject + { + ["driver"] = new JsonObject + { + ["name"] = "Kyber-Weave", + ["version"] = "0.1.0", + ["rules"] = rules + } + }, + ["results"] = results + }; + if (report.Metrics.Count > 0) + { + var properties = new JsonObject(); + AddMetrics(properties, report); + run["properties"] = properties; + } + var sarif = new JsonObject { ["$schema"] = "https://json.schemastore.org/sarif-2.1.0.json", ["version"] = "2.1.0", ["runs"] = new JsonArray { - new JsonObject - { - ["tool"] = new JsonObject - { - ["driver"] = new JsonObject - { - ["name"] = "Kyber-Weave", - ["version"] = "0.1.0", - ["rules"] = rules - } - }, - ["results"] = results - } + run } }; return sarif.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } + + private static void RenderMetricsTable(DiagnosticReport report) + { + if (report.Metrics.Count == 0) + { + return; + } + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Metric"); + table.AddColumn("Value"); + foreach (var metric in report.Metrics) + { + table.AddRow( + new Markup(Markup.Escape(metric.Key)), + new Markup(Markup.Escape(FormatMetric(metric.Value)))); + } + + AnsiConsole.Write(table); + } + + private static void AppendMarkdownMetrics(StringBuilder sb, DiagnosticReport report) + { + if (report.Metrics.Count == 0) + { + return; + } + + sb.AppendLine(); + sb.AppendLine("#### Metrics"); + sb.AppendLine(); + sb.AppendLine("| Metric | Value |"); + sb.AppendLine("|---|---|"); + foreach (var metric in report.Metrics) + { + sb.AppendLine($"| {EscapeMarkdown(metric.Key)} | {EscapeMarkdown(FormatMetric(metric.Value))} |"); + } + } + + private static void AddMetrics(JsonObject parent, DiagnosticReport report) + { + if (report.Metrics.Count == 0) + { + return; + } + + var metrics = new JsonObject(); + foreach (var metric in report.Metrics) + { + metrics[metric.Key] = ToJsonScalar(metric.Value); + } + + parent["metrics"] = metrics; + } + + private static JsonNode? ToJsonScalar(object? value) => value switch + { + null => null, + string item => JsonValue.Create(item), + bool item => JsonValue.Create(item), + byte item => JsonValue.Create(item), + sbyte item => JsonValue.Create(item), + short item => JsonValue.Create(item), + ushort item => JsonValue.Create(item), + int item => JsonValue.Create(item), + uint item => JsonValue.Create(item), + long item => JsonValue.Create(item), + ulong item => JsonValue.Create(item), + float item => JsonValue.Create(item), + double item => JsonValue.Create(item), + decimal item => JsonValue.Create(item), + _ => throw new InvalidOperationException("Diagnostic metrics must be JSON scalar values.") + }; + + private static void AddRange(JsonObject target, int? startLine, int? endLine) + { + if (startLine is not null) + { + target["startLine"] = startLine.Value; + } + + if (endLine is not null) + { + target["endLine"] = endLine.Value; + } + } + + private static JsonObject CreateSarifPhysicalLocation(string filePath, int? startLine, int? endLine) + { + var physicalLocation = new JsonObject + { + ["artifactLocation"] = new JsonObject { ["uri"] = filePath } + }; + if (startLine is not null) + { + var region = new JsonObject { ["startLine"] = startLine.Value }; + if (endLine is not null) + { + region["endLine"] = endLine.Value; + } + + physicalLocation["region"] = region; + } + + return physicalLocation; + } + + private static string FormatLocation(Diagnostic diagnostic, bool includeRelatedCount = true) + { + var formatted = FormatLocation(diagnostic.FilePath, diagnostic.StartLine, diagnostic.EndLine); + if (includeRelatedCount && diagnostic.RelatedLocations is { Count: > 0 }) + { + formatted += $" (+{diagnostic.RelatedLocations.Count.ToString(CultureInfo.InvariantCulture)} related)"; + } + + return formatted; + } + + private static string FormatLocation(DiagnosticLocation location) => + FormatLocation(location.FilePath, location.StartLine, location.EndLine); + + private static string FormatLocation(string? filePath, int? startLine, int? endLine) + { + if (string.IsNullOrEmpty(filePath)) + { + return string.Empty; + } + + if (startLine is null) + { + return filePath; + } + + var start = startLine.Value.ToString(CultureInfo.InvariantCulture); + return endLine is null || endLine == startLine + ? $"{filePath}:{start}" + : $"{filePath}:{start}-{endLine.Value.ToString(CultureInfo.InvariantCulture)}"; + } + + private static string FormatMetric(object? value) => value switch + { + null => "null", + bool boolean => boolean ? "true" : "false", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + private static string EscapeMarkdown(string value) => value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); } diff --git a/src/KyberWeave.Core/AGENTS.md b/src/KyberWeave.Core/AGENTS.md index fdf56b3..2100904 100644 --- a/src/KyberWeave.Core/AGENTS.md +++ b/src/KyberWeave.Core/AGENTS.md @@ -60,6 +60,9 @@ separate times in this repository before being centralised. `Processes/ProcessRunner.ReadToEnd` starts both reads before awaiting either. Use it for anything with `RedirectStandardOutput` or `RedirectStandardError`. +`ProcessRunner.Run` additionally rebuilds the start info: `UseShellExecute` stays off and +arguments go through `ArgumentList`, never the concatenated `Arguments` string, so a +caller cannot re-enable the shell or smuggle metacharacters into argv. `ProcessRunnerTests` reintroduces the deadlock against a child that writes 300 KB to each stream and fails on a timeout, so a regression is caught rather than hanging CI. diff --git a/src/KyberWeave.Core/CodeGraph/CodeGraphEdge.cs b/src/KyberWeave.Core/CodeGraph/CodeGraphEdge.cs new file mode 100644 index 0000000..fa08eb4 --- /dev/null +++ b/src/KyberWeave.Core/CodeGraph/CodeGraphEdge.cs @@ -0,0 +1,7 @@ +namespace KyberWeave.Core.CodeGraph; + +/// One directed relationship from the CodeGraph index. +/// Stable id of the source code node. +/// Stable id of the target code node. +/// Indexed relationship kind, such as calls or references. +public sealed record CodeGraphEdge(string SourceId, string TargetId, string Kind); diff --git a/src/KyberWeave.Core/CodeGraph/CodeGraphResolverAdapter.cs b/src/KyberWeave.Core/CodeGraph/CodeGraphResolverAdapter.cs index e63c4a7..14d008f 100644 --- a/src/KyberWeave.Core/CodeGraph/CodeGraphResolverAdapter.cs +++ b/src/KyberWeave.Core/CodeGraph/CodeGraphResolverAdapter.cs @@ -20,22 +20,27 @@ namespace KyberWeave.Core.CodeGraph; /// than the Microsoft.Data.Sqlite package. That package's native dependency, /// SQLitePCLRaw.lib.e_sqlite3, carries advisory GHSA-2m69-gcr7-jv3q at every /// published version with no patched release available, and this repository runs -/// blocking dependency scanning. One subprocess call loads the whole node table into -/// memory, after which every lookup is in-process — so this is also faster than -/// per-symbol querying would have been. +/// blocking dependency scanning. One subprocess call loads the node table and approved +/// neighborhood edges into memory, after which every lookup is in-process — so this is +/// also faster than per-symbol querying would have been. /// /// -public sealed class CodeGraphResolverAdapter : ICodeGraphResolver +public sealed class CodeGraphResolverAdapter : ICodeGraphResolver, ICodeGraphNeighborhoodProvider { private const char FieldSeparator = '\u001f'; private static readonly string[] SymbolKinds = ["class", "interface", "method", "function", "struct", "enum", "type_alias"]; + private static readonly HashSet NeighborhoodEdgeKinds = + ["contains", "calls", "references", "instantiates", "extends", "implements"]; + private readonly Dictionary> _byName = new(StringComparer.Ordinal); private readonly Dictionary> _byQualifiedName = new(StringComparer.Ordinal); private readonly Dictionary _routes = new(StringComparer.Ordinal); private readonly List _filePaths = []; + private readonly List _edges = []; + private readonly Dictionary _edgeDegree = new(StringComparer.Ordinal); /// public bool IsAvailable { get; } @@ -86,21 +91,42 @@ public static CodeGraphResolverAdapter ForRepository(string repoRoot) private void Load() { - // One pass over the node table. 'import' rows are excluded: they are module - // references, never a documentable symbol. + // Nodes and the bounded neighborhood surface are loaded in the same sqlite3 + // process. Aside from avoiding per-node subprocess cost, this makes the adapter + // a stable snapshot even when CodeGraph replaces its index after construction. + // 'import' nodes and edges are excluded: module imports are too broad to be a + // useful documentation relationship. const string sql = """ - SELECT id, kind, name, qualified_name, file_path, language, start_line + SELECT 'node', id, kind, name, qualified_name, file_path, language, start_line FROM nodes WHERE kind <> 'import' + UNION ALL + SELECT 'edge', source, target, kind, '', '', '', '' + FROM edges + WHERE kind IN ('contains', 'calls', 'references', 'instantiates', 'extends', 'implements') """; foreach (var line in RunSqlite(sql)) { var parts = line.Split(FieldSeparator); - if (parts.Length < 7) continue; + if (parts.Length < 8) continue; + + if (parts[0] == "edge") + { + var edge = new CodeGraphEdge(parts[1], parts[2], parts[3]); + if (!NeighborhoodEdgeKinds.Contains(edge.Kind)) continue; + + _edges.Add(edge); + IncrementDegree(edge.SourceId); + if (!StringComparer.Ordinal.Equals(edge.SourceId, edge.TargetId)) + IncrementDegree(edge.TargetId); + continue; + } + + if (parts[0] != "node") continue; - _ = int.TryParse(parts[6], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startLine); - var node = new CodeGraphNode(parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], startLine); + _ = int.TryParse(parts[7], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startLine); + var node = new CodeGraphNode(parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], startLine); Index(_byName, node.Name, node); Index(_byQualifiedName, node.QualifiedName, node); @@ -117,6 +143,12 @@ WHERE kind <> 'import' } } + private void IncrementDegree(string nodeId) + { + _edgeDegree.TryGetValue(nodeId, out var degree); + _edgeDegree[nodeId] = degree + 1; + } + private static void Index(Dictionary> map, string key, CodeGraphNode node) { if (string.IsNullOrEmpty(key)) return; @@ -217,4 +249,24 @@ public IReadOnlyList CandidateNames(string like) /// public IReadOnlyList AllRoutes() => IsAvailable ? _routes.Keys.ToList() : []; + + /// + public IReadOnlyList GetEdges( + IReadOnlyCollection nodeIds, + int maxDegree) + { + ArgumentNullException.ThrowIfNull(nodeIds); + ArgumentOutOfRangeException.ThrowIfNegative(maxDegree); + + if (!IsAvailable || nodeIds.Count == 0) return []; + + var requested = nodeIds.ToHashSet(StringComparer.Ordinal); + return _edges + .Where(edge => + requested.Contains(edge.SourceId) + && requested.Contains(edge.TargetId) + && _edgeDegree.GetValueOrDefault(edge.SourceId) <= maxDegree + && _edgeDegree.GetValueOrDefault(edge.TargetId) <= maxDegree) + .ToArray(); + } } diff --git a/src/KyberWeave.Core/CodeGraph/ICodeGraphNeighborhoodProvider.cs b/src/KyberWeave.Core/CodeGraph/ICodeGraphNeighborhoodProvider.cs new file mode 100644 index 0000000..94322dc --- /dev/null +++ b/src/KyberWeave.Core/CodeGraph/ICodeGraphNeighborhoodProvider.cs @@ -0,0 +1,20 @@ +namespace KyberWeave.Core.CodeGraph; + +/// +/// Optional CodeGraph port for batched, one-hop relationships between resolved nodes. +/// +/// +/// This is separate from so existing resolver +/// implementations remain source-compatible. Consumers feature-detect this port and +/// retain document-only relationships when it is unavailable. +/// +public interface ICodeGraphNeighborhoodProvider +{ + /// + /// Returns approved edges between the requested nodes, excluding every node whose + /// total approved-edge degree exceeds . + /// + IReadOnlyList GetEdges( + IReadOnlyCollection nodeIds, + int maxDegree); +} diff --git a/src/KyberWeave.Core/Configuration/DocsAnalysisConfig.cs b/src/KyberWeave.Core/Configuration/DocsAnalysisConfig.cs new file mode 100644 index 0000000..a272bbe --- /dev/null +++ b/src/KyberWeave.Core/Configuration/DocsAnalysisConfig.cs @@ -0,0 +1,165 @@ +namespace KyberWeave.Core.Configuration; + +/// Host configuration for bounded documentation analysis. +public sealed class DocsAnalysisConfig +{ + private static readonly string[] DefaultStatuses = ["current"]; + + public IReadOnlyList Statuses { get; init; } = DefaultStatuses; + + /// + /// Repository-relative glossary path. When omitted, analysis uses the primary + /// documentation root's glossary.md. + /// + public string? GlossaryPath { get; init; } + + /// Effective path including the primary-root default resolved at config load. + public string ResolvedGlossaryPath { get; init; } = "6-Docs/glossary.md"; + + /// Resolves an omitted path to the primary documentation root. + public string ResolveGlossaryPath(OntologyConfig ontology) + { + ArgumentNullException.ThrowIfNull(ontology); + return string.IsNullOrWhiteSpace(GlossaryPath) + ? ontology.DocsRoot == "." + ? "glossary.md" + : $"{ontology.DocsRoot.TrimEnd('/')}/glossary.md" + : GlossaryPath.Replace('\\', '/'); + } + + /// Returns this analysis configuration with ontology-derived paths resolved. + public DocsAnalysisConfig ResolveFor(OntologyConfig ontology) => + Clone(resolvedGlossaryPath: ResolveGlossaryPath(ontology)); + + public double VerdictConfidence { get; init; } = 0.80; + + public DocsAnalysisSearchConfig Search { get; init; } = DocsAnalysisSearchConfig.ProductDefaults; + + public DocsAnalysisEmbeddingConfig Embeddings { get; init; } = + DocsAnalysisEmbeddingConfig.ProductDefaults; + + public static DocsAnalysisConfig ProductDefaults { get; } = new(); + + internal DocsAnalysisConfig Clone( + IReadOnlyList? statuses = null, + string? glossaryPath = null, + string? resolvedGlossaryPath = null, + double? verdictConfidence = null, + DocsAnalysisSearchConfig? search = null, + DocsAnalysisEmbeddingConfig? embeddings = null) => + new() + { + Statuses = statuses ?? Statuses, + GlossaryPath = glossaryPath ?? GlossaryPath, + ResolvedGlossaryPath = resolvedGlossaryPath ?? ResolvedGlossaryPath, + VerdictConfidence = verdictConfidence ?? VerdictConfidence, + Search = search ?? Search, + Embeddings = embeddings ?? Embeddings + }; +} + +/// Candidate-generation preset used by documentation analysis. +public enum DocsAnalysisSearchMode +{ + Graph, + Hybrid, + HighRecall +} + +/// Bounded candidate-generation settings. +public sealed class DocsAnalysisSearchConfig +{ + public DocsAnalysisSearchMode Mode { get; init; } = DocsAnalysisSearchMode.Hybrid; + + public int MinClaimTokens { get; init; } = 5; + + public double LexicalCandidateThreshold { get; init; } = 0.45; + + public double LexicalDuplicateThreshold { get; init; } = 0.90; + + public double SemanticCandidateThreshold { get; init; } = 0.78; + + public double SemanticDuplicateThreshold { get; init; } = 0.92; + + public double TerminologyContextThreshold { get; init; } = 0.30; + + public int MaxNeighborsPerClaim { get; init; } = 10; + + public int MaxCodeNeighbors { get; init; } = 50; + + public int MaxCandidates { get; init; } = 500; + + public static DocsAnalysisSearchConfig ProductDefaults { get; } = new(); + + internal DocsAnalysisSearchConfig Clone( + DocsAnalysisSearchMode? mode = null, + int? minClaimTokens = null, + double? lexicalCandidateThreshold = null, + double? lexicalDuplicateThreshold = null, + double? semanticCandidateThreshold = null, + double? semanticDuplicateThreshold = null, + double? terminologyContextThreshold = null, + int? maxNeighborsPerClaim = null, + int? maxCodeNeighbors = null, + int? maxCandidates = null) => + new() + { + Mode = mode ?? Mode, + MinClaimTokens = minClaimTokens ?? MinClaimTokens, + LexicalCandidateThreshold = lexicalCandidateThreshold ?? LexicalCandidateThreshold, + LexicalDuplicateThreshold = lexicalDuplicateThreshold ?? LexicalDuplicateThreshold, + SemanticCandidateThreshold = semanticCandidateThreshold ?? SemanticCandidateThreshold, + SemanticDuplicateThreshold = semanticDuplicateThreshold ?? SemanticDuplicateThreshold, + TerminologyContextThreshold = terminologyContextThreshold ?? TerminologyContextThreshold, + MaxNeighborsPerClaim = maxNeighborsPerClaim ?? MaxNeighborsPerClaim, + MaxCodeNeighbors = maxCodeNeighbors ?? MaxCodeNeighbors, + MaxCandidates = maxCandidates ?? MaxCandidates + }; +} + +/// Failure policy for the optional local embedding endpoint. +public enum DocsAnalysisEmbeddingMode +{ + Off, + Prefer, + Required +} + +/// Configuration for an OpenAI-compatible local embedding endpoint. +public sealed class DocsAnalysisEmbeddingConfig +{ + public DocsAnalysisEmbeddingMode Mode { get; init; } = DocsAnalysisEmbeddingMode.Off; + + public Uri? Endpoint { get; init; } + + public string? Model { get; init; } + + public int? Dimensions { get; init; } + + public int BatchSize { get; init; } = 64; + + public int TimeoutSeconds { get; init; } = 60; + + public string? ApiKeyEnv { get; init; } + + public static DocsAnalysisEmbeddingConfig ProductDefaults { get; } = new(); + + internal DocsAnalysisEmbeddingConfig Clone( + DocsAnalysisEmbeddingMode? mode = null, + Uri? endpoint = null, + string? model = null, + int? dimensions = null, + int? batchSize = null, + int? timeoutSeconds = null, + string? apiKeyEnv = null) => + new() + { + Mode = mode ?? Mode, + Endpoint = endpoint ?? Endpoint, + Model = model ?? Model, + Dimensions = dimensions ?? Dimensions, + BatchSize = batchSize ?? BatchSize, + TimeoutSeconds = timeoutSeconds ?? TimeoutSeconds, + ApiKeyEnv = apiKeyEnv ?? ApiKeyEnv + }; +} diff --git a/src/KyberWeave.Core/Configuration/DocsAnalysisConfigLoader.cs b/src/KyberWeave.Core/Configuration/DocsAnalysisConfigLoader.cs new file mode 100644 index 0000000..e0435a1 --- /dev/null +++ b/src/KyberWeave.Core/Configuration/DocsAnalysisConfigLoader.cs @@ -0,0 +1,247 @@ +using System.Net; +using System.Net.Sockets; +using KyberWeave.Core.Networking; +using YamlDotNet.Core; + +namespace KyberWeave.Core.Configuration; + +/// Loads, merges, and validates documentation-analysis configuration. +public static class DocsAnalysisConfigLoader +{ + internal static DocsAnalysisConfig Merge( + DocsAnalysisConfig defaults, + DocsAnalysisYamlSection? section, + OntologyConfig ontology) + { + ArgumentNullException.ThrowIfNull(defaults); + ArgumentNullException.ThrowIfNull(ontology); + + if (section is null) + { + var resolvedDefaults = defaults.Clone( + resolvedGlossaryPath: defaults.ResolveGlossaryPath(ontology)); + Validate(resolvedDefaults, ontology); + return resolvedDefaults; + } + + var search = MergeSearch(defaults.Search, section.Search); + var embeddings = MergeEmbeddings(defaults.Embeddings, section.Embeddings); + var statuses = section.Statuses is null + ? defaults.Statuses + : section.Statuses.ToArray(); + var glossaryPath = NormalizeGlossaryPath(section.GlossaryPath, ontology.DocsRoots); + + var merged = defaults.Clone( + statuses: statuses, + glossaryPath: glossaryPath, + resolvedGlossaryPath: glossaryPath ?? defaults.ResolveGlossaryPath(ontology), + verdictConfidence: section.VerdictConfidence, + search: search, + embeddings: embeddings); + + Validate(merged, ontology); + return merged; + } + + private static DocsAnalysisSearchConfig MergeSearch( + DocsAnalysisSearchConfig defaults, + DocsAnalysisSearchYamlSection? section) + { + if (section is null) + return defaults; + + return defaults.Clone( + mode: ParseSearchMode(section.Mode), + minClaimTokens: section.MinClaimTokens, + lexicalCandidateThreshold: section.LexicalCandidateThreshold, + lexicalDuplicateThreshold: section.LexicalDuplicateThreshold, + semanticCandidateThreshold: section.SemanticCandidateThreshold, + semanticDuplicateThreshold: section.SemanticDuplicateThreshold, + terminologyContextThreshold: section.TerminologyContextThreshold, + maxNeighborsPerClaim: section.MaxNeighborsPerClaim, + maxCodeNeighbors: section.MaxCodeNeighbors, + maxCandidates: section.MaxCandidates); + } + + private static DocsAnalysisEmbeddingConfig MergeEmbeddings( + DocsAnalysisEmbeddingConfig defaults, + DocsAnalysisEmbeddingYamlSection? section) + { + if (section is null) + return defaults; + + return defaults.Clone( + mode: ParseEmbeddingMode(section.Mode), + endpoint: ParseEndpoint(section.Endpoint), + model: section.Model, + dimensions: section.Dimensions, + batchSize: section.BatchSize, + timeoutSeconds: section.TimeoutSeconds, + apiKeyEnv: section.ApiKeyEnv); + } + + private static DocsAnalysisSearchMode? ParseSearchMode(string? value) + { + if (value is null) + return null; + + return value.Trim().ToLowerInvariant() switch + { + "graph" => DocsAnalysisSearchMode.Graph, + "hybrid" => DocsAnalysisSearchMode.Hybrid, + "high-recall" => DocsAnalysisSearchMode.HighRecall, + _ => throw new YamlException( + $"Unknown docs-analysis.search.mode '{value}'. Known modes: graph, hybrid, high-recall.") + }; + } + + private static DocsAnalysisEmbeddingMode? ParseEmbeddingMode(string? value) + { + if (value is null) + return null; + + return value.Trim().ToLowerInvariant() switch + { + "off" => DocsAnalysisEmbeddingMode.Off, + "prefer" => DocsAnalysisEmbeddingMode.Prefer, + "required" => DocsAnalysisEmbeddingMode.Required, + _ => throw new YamlException( + $"Unknown docs-analysis.embeddings.mode '{value}'. Known modes: off, prefer, required.") + }; + } + + private static Uri? ParseEndpoint(string? value) + { + if (value is null) + return null; + + if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint) + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) + { + throw new YamlException( + "docs-analysis.embeddings.endpoint must be an absolute HTTP endpoint on loopback."); + } + + return endpoint; + } + + private static string? NormalizeGlossaryPath( + string? value, + IReadOnlyList docsRoots) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + string path; + try + { + path = DocsRootPath.Normalize(value, "docs-analysis.glossary-path"); + } + catch (ArgumentException ex) + { + throw new YamlException(ex.Message); + } + + if (path.Length == 0 || !docsRoots.Any(root => IsUnderRoot(path, root))) + { + throw new YamlException( + $"docs-analysis.glossary-path '{value}' must be under a configured ontology.docs-root."); + } + + return path; + } + + private static bool IsUnderRoot(string path, string root) => + root == DocsRootPath.RepositoryRoot + || path.StartsWith(root + "/", DocsRootPath.PathComparer == StringComparer.OrdinalIgnoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void Validate(DocsAnalysisConfig config, OntologyConfig ontology) + { + foreach (var status in config.Statuses) + { + if (!ontology.Statuses.Contains(status, StringComparer.Ordinal)) + { + throw new YamlException( + $"docs-analysis.statuses contains '{status}', which is not in ontology.statuses."); + } + } + + RequireThreshold(config.VerdictConfidence, "docs-analysis.verdict-confidence"); + RequireThreshold( + config.Search.LexicalCandidateThreshold, + "docs-analysis.search.lexical-candidate-threshold"); + RequireThreshold( + config.Search.LexicalDuplicateThreshold, + "docs-analysis.search.lexical-duplicate-threshold"); + RequireThreshold( + config.Search.SemanticCandidateThreshold, + "docs-analysis.search.semantic-candidate-threshold"); + RequireThreshold( + config.Search.SemanticDuplicateThreshold, + "docs-analysis.search.semantic-duplicate-threshold"); + RequireThreshold( + config.Search.TerminologyContextThreshold, + "docs-analysis.search.terminology-context-threshold"); + + RequirePositive(config.Search.MinClaimTokens, "docs-analysis.search.min-claim-tokens"); + RequirePositive( + config.Search.MaxNeighborsPerClaim, + "docs-analysis.search.max-neighbors-per-claim"); + RequirePositive(config.Search.MaxCodeNeighbors, "docs-analysis.search.max-code-neighbors"); + RequirePositive(config.Search.MaxCandidates, "docs-analysis.search.max-candidates"); + RequirePositive(config.Embeddings.BatchSize, "docs-analysis.embeddings.batch-size"); + RequirePositive(config.Embeddings.TimeoutSeconds, "docs-analysis.embeddings.timeout-seconds"); + if (config.Embeddings.Dimensions is not null) + RequirePositive(config.Embeddings.Dimensions.Value, "docs-analysis.embeddings.dimensions"); + + var embeddingsEnabled = config.Embeddings.Mode is + DocsAnalysisEmbeddingMode.Prefer or DocsAnalysisEmbeddingMode.Required; + if (embeddingsEnabled && config.Embeddings.Endpoint is null) + { + throw new YamlException( + "docs-analysis.embeddings.endpoint is required when embeddings mode is prefer or required."); + } + + if (embeddingsEnabled && string.IsNullOrWhiteSpace(config.Embeddings.Model)) + { + throw new YamlException( + "docs-analysis.embeddings.model is required when embeddings mode is prefer or required."); + } + + if (config.Embeddings.Endpoint is not null && !ResolvesOnlyToLoopback(config.Embeddings.Endpoint)) + { + throw new YamlException( + "docs-analysis.embeddings.endpoint must resolve only to loopback addresses."); + } + } + + private static void RequireThreshold(double value, string key) + { + if (!double.IsFinite(value) || value < 0 || value > 1) + throw new YamlException($"{key} must be finite and between 0 and 1 inclusive."); + } + + private static void RequirePositive(int value, string key) + { + if (value <= 0) + throw new YamlException($"{key} must be a positive integer."); + } + + private static bool ResolvesOnlyToLoopback(Uri endpoint) + { + if (IPAddress.TryParse(endpoint.DnsSafeHost, out var address)) + return LoopbackAddress.IsLoopback(address); + + try + { + var addresses = Dns.GetHostAddresses(endpoint.DnsSafeHost); + return addresses.Length > 0 && addresses.All(LoopbackAddress.IsLoopback); + } + catch (SocketException) + { + return false; + } + } +} diff --git a/src/KyberWeave.Core/Configuration/DocsAnalysisYamlSection.cs b/src/KyberWeave.Core/Configuration/DocsAnalysisYamlSection.cs new file mode 100644 index 0000000..cde1bd6 --- /dev/null +++ b/src/KyberWeave.Core/Configuration/DocsAnalysisYamlSection.cs @@ -0,0 +1,55 @@ +namespace KyberWeave.Core.Configuration; + +/// The docs-analysis: section of kyber-weave.yml. +internal sealed class DocsAnalysisYamlSection +{ + public List? Statuses { get; set; } + + public string? GlossaryPath { get; set; } + + public double? VerdictConfidence { get; set; } + + public DocsAnalysisSearchYamlSection? Search { get; set; } + + public DocsAnalysisEmbeddingYamlSection? Embeddings { get; set; } +} + +internal sealed class DocsAnalysisSearchYamlSection +{ + public string? Mode { get; set; } + + public int? MinClaimTokens { get; set; } + + public double? LexicalCandidateThreshold { get; set; } + + public double? LexicalDuplicateThreshold { get; set; } + + public double? SemanticCandidateThreshold { get; set; } + + public double? SemanticDuplicateThreshold { get; set; } + + public double? TerminologyContextThreshold { get; set; } + + public int? MaxNeighborsPerClaim { get; set; } + + public int? MaxCodeNeighbors { get; set; } + + public int? MaxCandidates { get; set; } +} + +internal sealed class DocsAnalysisEmbeddingYamlSection +{ + public string? Mode { get; set; } + + public string? Endpoint { get; set; } + + public string? Model { get; set; } + + public int? Dimensions { get; set; } + + public int? BatchSize { get; set; } + + public int? TimeoutSeconds { get; set; } + + public string? ApiKeyEnv { get; set; } +} diff --git a/src/KyberWeave.Core/Configuration/KyberWeaveConfig.cs b/src/KyberWeave.Core/Configuration/KyberWeaveConfig.cs index 635f108..b93ae10 100644 --- a/src/KyberWeave.Core/Configuration/KyberWeaveConfig.cs +++ b/src/KyberWeave.Core/Configuration/KyberWeaveConfig.cs @@ -1,11 +1,13 @@ namespace KyberWeave.Core.Configuration; -/// Combined Kyber-Weave host configuration (ontology + harness profiles). +/// Combined Kyber-Weave host configuration. public sealed class KyberWeaveConfig { public OntologyConfig Ontology { get; init; } = OntologyConfig.ProductDefaults; public HarnessProfileConfig Harness { get; init; } = HarnessProfileConfig.ProductDefaults; + public DocsAnalysisConfig DocsAnalysis { get; init; } = DocsAnalysisConfig.ProductDefaults; + public static KyberWeaveConfig ProductDefaults { get; } = new(); } diff --git a/src/KyberWeave.Core/Configuration/KyberWeaveConfigLoader.cs b/src/KyberWeave.Core/Configuration/KyberWeaveConfigLoader.cs index 9cffb10..e2c435d 100644 --- a/src/KyberWeave.Core/Configuration/KyberWeaveConfigLoader.cs +++ b/src/KyberWeave.Core/Configuration/KyberWeaveConfigLoader.cs @@ -105,10 +105,17 @@ public static KyberWeaveConfigLoadResult TryLoad(string repoRoot, string? config return explicitPath; } - private static KyberWeaveConfig FromDocument(KyberWeaveYamlDocument document) => - new() + private static KyberWeaveConfig FromDocument(KyberWeaveYamlDocument document) + { + var ontology = OntologyConfigLoader.Merge(OntologyConfig.ProductDefaults, document.Ontology); + return new KyberWeaveConfig { - Ontology = OntologyConfigLoader.Merge(OntologyConfig.ProductDefaults, document.Ontology), - Harness = HarnessProfileConfigLoader.Merge(HarnessProfileConfig.ProductDefaults, document.Harness) + Ontology = ontology, + Harness = HarnessProfileConfigLoader.Merge(HarnessProfileConfig.ProductDefaults, document.Harness), + DocsAnalysis = DocsAnalysisConfigLoader.Merge( + DocsAnalysisConfig.ProductDefaults, + document.DocsAnalysis, + ontology) }; + } } diff --git a/src/KyberWeave.Core/Configuration/KyberWeaveYamlDocument.cs b/src/KyberWeave.Core/Configuration/KyberWeaveYamlDocument.cs index eb5ff0a..c289739 100644 --- a/src/KyberWeave.Core/Configuration/KyberWeaveYamlDocument.cs +++ b/src/KyberWeave.Core/Configuration/KyberWeaveYamlDocument.cs @@ -6,4 +6,6 @@ internal sealed class KyberWeaveYamlDocument public OntologyYamlSection? Ontology { get; set; } public HarnessYamlSection? Harness { get; set; } + + public DocsAnalysisYamlSection? DocsAnalysis { get; set; } } diff --git a/src/KyberWeave.Core/Diagnostics/Diagnostic.cs b/src/KyberWeave.Core/Diagnostics/Diagnostic.cs index 1fb3517..d2d1553 100644 --- a/src/KyberWeave.Core/Diagnostics/Diagnostic.cs +++ b/src/KyberWeave.Core/Diagnostics/Diagnostic.cs @@ -13,16 +13,36 @@ namespace KyberWeave.Core.Diagnostics; /// /// Path of the file the finding is in, when known. /// Optional remediation hint. +/// One-based first line of the finding, when known. +/// One-based last line of the finding, when known. +/// Other locations that contribute to the finding. public sealed record Diagnostic( string Code, Severity Severity, string Message, string Subject, string? FilePath = null, - string? Hint = null) + string? Hint = null, + int? StartLine = null, + int? EndLine = null, + IReadOnlyList? RelatedLocations = null) { /// Alias for used by harness sync contracts. public string? Location => FilePath; + /// Other locations that contribute to the finding. + public IReadOnlyList RelatedLocations { get; init; } = RelatedLocations ?? []; + public override string ToString() => $"[{Code}] {Severity}: {Message}"; } + +/// A line-addressable location related to a diagnostic. +/// Path of the related file. +/// One-based first line of the related evidence, when known. +/// One-based last line of the related evidence, when known. +/// Optional explanation of how the location is related. +public sealed record DiagnosticLocation( + string FilePath, + int? StartLine = null, + int? EndLine = null, + string? Message = null); diff --git a/src/KyberWeave.Core/Diagnostics/DiagnosticReport.cs b/src/KyberWeave.Core/Diagnostics/DiagnosticReport.cs index 1929a1c..be47bff 100644 --- a/src/KyberWeave.Core/Diagnostics/DiagnosticReport.cs +++ b/src/KyberWeave.Core/Diagnostics/DiagnosticReport.cs @@ -4,12 +4,30 @@ namespace KyberWeave.Core.Diagnostics; public sealed class DiagnosticReport { private readonly List _items = new(); + private readonly OrderedDictionary _metrics = new(StringComparer.Ordinal); public IReadOnlyList Items => _items; + /// + /// Scalar measurements produced by the command, in the order they were added. + /// + public IReadOnlyDictionary Metrics => _metrics; + public void Add(Diagnostic d) => _items.Add(d); public void AddRange(IEnumerable ds) => _items.AddRange(ds); + /// Adds or replaces a scalar command metric without changing its display order. + public void AddMetric(string key, object? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (!IsScalar(value)) + { + throw new ArgumentException("Diagnostic metrics must be JSON scalar values.", nameof(value)); + } + + _metrics[key] = value; + } + public int Count(Severity s) => _items.Count(i => i.Severity == s); public bool HasErrors => _items.Any(i => i.Severity is Severity.Error or Severity.Critical); @@ -18,4 +36,12 @@ public sealed class DiagnosticReport public int Errors => Count(Severity.Error) + Count(Severity.Critical); public int Warnings => Count(Severity.Warning); public int Infos => Count(Severity.Info); + + private static bool IsScalar(object? value) => value switch + { + float number => float.IsFinite(number), + double number => double.IsFinite(number), + null or string or bool or byte or sbyte or short or ushort or int or uint or long or ulong or decimal => true, + _ => false + }; } diff --git a/src/KyberWeave.Core/Docs/Analysis/AnalysisPorts.cs b/src/KyberWeave.Core/Docs/Analysis/AnalysisPorts.cs new file mode 100644 index 0000000..629c4ad --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/AnalysisPorts.cs @@ -0,0 +1,63 @@ +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Persistence; + +namespace KyberWeave.Core.Docs.Analysis; + +/// Infrastructure-neutral port for an optional embedding provider. +public interface IEmbeddingGenerator +{ + /// + /// Stable identity of the provider configuration used as part of the vector cache key. + /// Credentials must never contribute to this value. + /// + string GetProviderFingerprint(DocsAnalysisEmbeddingConfig config); + + EmbeddingGenerationResult Generate( + IReadOnlyCollection keys, + IReadOnlyCollection inputs, + DocsAnalysisEmbeddingConfig config); +} + +/// Infrastructure-neutral cache for reviewer verdicts and normalized vectors. +public interface IAnalysisPersistence +{ + bool IsAvailable { get; } + + IReadOnlyDictionary LoadClaims( + IReadOnlyCollection claimIds) => + new Dictionary(StringComparer.Ordinal); + + void SaveClaims(IReadOnlyCollection claims) => + throw new InvalidOperationException("This analysis persistence provider does not store claims."); + + IReadOnlyDictionary LoadCandidateFingerprints( + IReadOnlyCollection candidateIds) => + new Dictionary(StringComparer.Ordinal); + + void SaveCandidateFingerprints( + IReadOnlyCollection candidates) => + throw new InvalidOperationException("This analysis persistence provider does not store candidate fingerprints."); + + IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds); + + void SaveVerdicts(IReadOnlyCollection verdicts) => + throw new InvalidOperationException("This analysis persistence provider is read-only for verdicts."); + + /// + /// Persists the current review evidence and its validated verdicts as one logical import. + /// Stores with transactional support should override this operation; the default preserves + /// compatibility with verdict-only adapters. + /// + void SaveReviewImport( + IReadOnlyCollection claims, + IReadOnlyCollection candidates, + IReadOnlyCollection verdicts) => SaveVerdicts(verdicts); + + IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys); + + void SaveEmbeddings(IReadOnlyCollection embeddings); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Candidates/CandidateContracts.cs b/src/KyberWeave.Core/Docs/Analysis/Candidates/CandidateContracts.cs new file mode 100644 index 0000000..48116a1 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Candidates/CandidateContracts.cs @@ -0,0 +1,46 @@ +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Graph; + +namespace KyberWeave.Core.Docs.Analysis.Candidates; + +/// The bounded retrieval strategy that produced a claim pair. +public enum CandidateSourceKind +{ + Graph, + Lexical, + Embedding +} + +/// Independent evidence scores retained for classification and review. +public sealed record CandidateScore(double Lexical, double? Semantic, double Graph); + +/// Two claims shortlisted by one bounded candidate source. +public sealed record ClaimPairCandidate( + Claim Left, + Claim Right, + CandidateSourceKind Source, + CandidateScore Score); + +/// Immutable input shared by candidate-source implementations. +public sealed record ClaimCandidateSourceRequest( + IReadOnlyList Claims, + DocGraphProjection Graph, + DocsAnalysisSearchConfig Search); + +/// +/// Pairs returned by a source, the number of similarity comparisons it performed, and +/// whether its own configured capacity discarded otherwise eligible pairs. +/// +public sealed record ClaimCandidateSourceResult( + IReadOnlyList Pairs, + int ComparisonCount, + bool Truncated = false); + +/// Infrastructure-neutral port for bounded claim candidate generation. +public interface IClaimCandidateSource +{ + CandidateSourceKind Kind { get; } + + ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Candidates/GraphClaimCandidateSource.cs b/src/KyberWeave.Core/Docs/Analysis/Candidates/GraphClaimCandidateSource.cs new file mode 100644 index 0000000..0e7c30f --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Candidates/GraphClaimCandidateSource.cs @@ -0,0 +1,135 @@ +using KyberWeave.Core.Docs.Analysis.Claims; + +namespace KyberWeave.Core.Docs.Analysis.Candidates; + +/// Shortlists claims whose documents are neighbors in the shared DocGraph projection. +public sealed class GraphClaimCandidateSource : IClaimCandidateSource +{ + public CandidateSourceKind Kind => CandidateSourceKind.Graph; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var scored = SparseRelatedPairs(request); + var selected = new List(); + using var enumerator = SelectCapacityBoundedTopK( + scored, + request.Search.MaxNeighborsPerClaim) + .GetEnumerator(); + while (selected.Count < request.Search.MaxCandidates && enumerator.MoveNext()) + selected.Add(enumerator.Current); + var truncated = enumerator.MoveNext(); + return new ClaimCandidateSourceResult(selected, scored.Count, truncated); + } + + private static IReadOnlyList SparseRelatedPairs(ClaimCandidateSourceRequest request) + { + var tokens = request.Claims + .Select(claim => LexicalSimilarity.Tokens(claim.ContextualText)) + .ToArray(); + var postings = BuildPostings(tokens); + var postingLimit = Math.Max(8, request.Search.MaxNeighborsPerClaim * 8); + var pairs = new Dictionary(); + + for (var left = 0; left < request.Claims.Count; left++) + { + var overlap = new Dictionary(); + foreach (var token in tokens[left]) + { + if (!postings.TryGetValue(token, out var indexes) || indexes.Count > postingLimit) + continue; + foreach (var right in indexes) + { + if (right == left || !AreRelated(request, request.Claims[left], request.Claims[right])) + continue; + overlap.TryGetValue(right, out var count); + overlap[right] = count + 1; + } + } + + foreach (var item in overlap + .OrderByDescending(item => Score(tokens[left], tokens[item.Key], item.Value)) + .ThenBy(item => item.Key) + .Take(request.Search.MaxNeighborsPerClaim)) + { + var identity = ClaimPair.Create(request.Claims[left], request.Claims[item.Key]); + pairs.TryAdd(identity, new ClaimPairCandidate( + identity.Left, + identity.Right, + CandidateSourceKind.Graph, + new CandidateScore( + Score(tokens[left], tokens[item.Key], item.Value), + null, + 1))); + } + } + + return pairs.Values.ToArray(); + } + + private static double Score( + IReadOnlySet left, + IReadOnlySet right, + int overlap) => + left.Count == 0 || right.Count == 0 ? 0 : (double)overlap / Math.Min(left.Count, right.Count); + + private static bool AreRelated(ClaimCandidateSourceRequest request, Claim left, Claim right) => + request.Graph.AreDocumentsRelated(DocumentNodeId(left), DocumentNodeId(right)); + + private static IReadOnlyDictionary> BuildPostings( + IReadOnlyList> tokenSets) + { + var postings = new Dictionary>(StringComparer.Ordinal); + for (var index = 0; index < tokenSets.Count; index++) + foreach (var token in tokenSets[index]) + { + if (!postings.TryGetValue(token, out var indexes)) + { + indexes = []; + postings[token] = indexes; + } + + indexes.Add(index); + } + + return postings.ToDictionary( + item => item.Key, + item => (IReadOnlyList)item.Value, + StringComparer.Ordinal); + } + + private static IEnumerable SelectCapacityBoundedTopK( + IEnumerable pairs, + int maximumNeighbors) + { + var counts = new Dictionary(); + foreach (var pair in pairs + .OrderByDescending(pair => pair.Score.Lexical) + .ThenBy(pair => pair.Left.FilePath, StringComparer.Ordinal) + .ThenBy(pair => pair.Right.FilePath, StringComparer.Ordinal)) + { + counts.TryGetValue(pair.Left, out var leftCount); + counts.TryGetValue(pair.Right, out var rightCount); + if (leftCount >= maximumNeighbors || rightCount >= maximumNeighbors) continue; + counts[pair.Left] = leftCount + 1; + counts[pair.Right] = rightCount + 1; + yield return pair; + } + } + + private static string DocumentNodeId(Claim claim) => $"doc:{claim.DocumentIdentity}"; + + private readonly record struct ClaimPair(Claim Left, Claim Right) + { + internal static ClaimPair Create(Claim left, Claim right) => + Compare(left, right) <= 0 ? new ClaimPair(left, right) : new ClaimPair(right, left); + + private static int Compare(Claim left, Claim right) + { + var path = StringComparer.Ordinal.Compare(left.FilePath, right.FilePath); + if (path != 0) return path; + var line = left.StartLine.CompareTo(right.StartLine); + return line != 0 ? line : StringComparer.Ordinal.Compare(left.ContentHash, right.ContentHash); + } + } +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Candidates/LexicalSimilarity.cs b/src/KyberWeave.Core/Docs/Analysis/Candidates/LexicalSimilarity.cs new file mode 100644 index 0000000..48d73a8 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Candidates/LexicalSimilarity.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace KyberWeave.Core.Docs.Analysis.Candidates; + +internal static partial class LexicalSimilarity +{ + private static readonly IReadOnlyDictionary Equivalents = + new Dictionary(StringComparer.Ordinal) + { + ["every"] = "all", + ["consumption"] = "usage" + }; + + internal static IReadOnlySet Tokens(string text) + { + var tokens = new HashSet(StringComparer.Ordinal); + foreach (Match match in TokenPattern().Matches(text.ToLowerInvariant())) + { + var token = Stem(match.Value); + if (Equivalents.TryGetValue(token, out var equivalent)) token = equivalent; + if (token.Length > 0) tokens.Add(token); + } + + return tokens; + } + + internal static double Score(string left, string right) => + Score(Tokens(left), Tokens(right)); + + internal static double Score(IReadOnlySet leftTokens, IReadOnlySet rightTokens) + { + if (leftTokens.Count == 0 || rightTokens.Count == 0) return 0; + + var overlap = leftTokens.Count(token => rightTokens.Contains(token)); + return (double)overlap / Math.Min(leftTokens.Count, rightTokens.Count); + } + + private static string Stem(string token) + { + if (token.Length > 5 && token.EndsWith("ies", StringComparison.Ordinal)) + return token[..^3] + "y"; + if (token.Length > 4 && token.EndsWith("ing", StringComparison.Ordinal)) + return token[..^3]; + if (token.Length > 4 && token.EndsWith("ed", StringComparison.Ordinal)) + return token[..^2]; + if (token.Length > 3 && token.EndsWith('s') && !token.EndsWith("ss", StringComparison.Ordinal)) + return token[..^1]; + return token; + } + + [GeneratedRegex("[\\p{L}\\p{N}]+", RegexOptions.CultureInvariant)] + private static partial Regex TokenPattern(); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Candidates/SparseLexicalCandidateSource.cs b/src/KyberWeave.Core/Docs/Analysis/Candidates/SparseLexicalCandidateSource.cs new file mode 100644 index 0000000..d9e5cc6 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Candidates/SparseLexicalCandidateSource.cs @@ -0,0 +1,119 @@ +using KyberWeave.Core.Configuration; + +namespace KyberWeave.Core.Docs.Analysis.Candidates; + +/// +/// Uses a sparse inverted index to find a bounded top-k neighborhood without evaluating +/// every corpus pair. +/// +public sealed class SparseLexicalCandidateSource : IClaimCandidateSource +{ + public CandidateSourceKind Kind => CandidateSourceKind.Lexical; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var tokenSets = request.Claims.Select(claim => LexicalSimilarity.Tokens(claim.ContextualText)).ToArray(); + var postings = BuildPostings(tokenSets); + var postingLimit = Math.Max(8, request.Search.MaxNeighborsPerClaim * 8); + var compared = new HashSet(); + var scored = new Dictionary(); + var neighbors = Enumerable.Range(0, request.Claims.Count) + .Select(_ => new List<(IndexPair Pair, double Score)>()) + .ToArray(); + + for (var leftIndex = 0; leftIndex < request.Claims.Count; leftIndex++) + { + var possible = request.Search.Mode == DocsAnalysisSearchMode.HighRecall + ? Enumerable.Range(0, request.Claims.Count) + .Where(index => index != leftIndex) + .ToHashSet() + : FindSparseNeighbors(leftIndex, tokenSets[leftIndex], postings, postingLimit); + + foreach (var rightIndex in possible) + { + var identity = IndexPair.Create(leftIndex, rightIndex); + if (!compared.Add(identity)) continue; + var score = LexicalSimilarity.Score(tokenSets[leftIndex], tokenSets[rightIndex]); + scored[identity] = score; + neighbors[identity.Left].Add((identity, score)); + neighbors[identity.Right].Add((identity, score)); + } + } + + var selected = new HashSet(); + for (var claimIndex = 0; claimIndex < request.Claims.Count; claimIndex++) + { + foreach (var item in neighbors[claimIndex] + .OrderByDescending(item => item.Score) + .ThenBy(item => item.Pair.Left) + .ThenBy(item => item.Pair.Right) + .Take(request.Search.MaxNeighborsPerClaim)) + { + selected.Add(item.Pair); + } + } + + var truncated = selected.Count > request.Search.MaxCandidates; + var pairs = selected + .OrderByDescending(pair => scored[pair]) + .ThenBy(pair => pair.Left) + .ThenBy(pair => pair.Right) + .Take(request.Search.MaxCandidates) + .Select(pair => new ClaimPairCandidate( + request.Claims[pair.Left], + request.Claims[pair.Right], + Kind, + new CandidateScore(scored[pair], null, 0))) + .ToArray(); + return new ClaimCandidateSourceResult(pairs, compared.Count, truncated); + } + + private static HashSet FindSparseNeighbors( + int leftIndex, + IReadOnlySet tokens, + IReadOnlyDictionary> postings, + int postingLimit) + { + var possible = new HashSet(); + foreach (var token in tokens) + { + if (!postings.TryGetValue(token, out var indexes) || indexes.Count > postingLimit) continue; + foreach (var index in indexes) + { + if (index != leftIndex) possible.Add(index); + } + } + + return possible; + } + + private static IReadOnlyDictionary> BuildPostings( + IReadOnlyList> tokenSets) + { + var postings = new Dictionary>(StringComparer.Ordinal); + for (var index = 0; index < tokenSets.Count; index++) + { + foreach (var token in tokenSets[index]) + { + if (!postings.TryGetValue(token, out var indexes)) + { + indexes = []; + postings[token] = indexes; + } + indexes.Add(index); + } + } + + return postings.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.Ordinal); + } + + private readonly record struct IndexPair(int Left, int Right) + { + internal static IndexPair Create(int left, int right) => + left <= right ? new IndexPair(left, right) : new IndexPair(right, left); + } +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Claims/Claim.cs b/src/KyberWeave.Core/Docs/Analysis/Claims/Claim.cs new file mode 100644 index 0000000..6b61d6b --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Claims/Claim.cs @@ -0,0 +1,41 @@ +namespace KyberWeave.Core.Docs.Analysis.Claims; + +/// The Markdown structure from which a documentation claim was extracted. +public enum ClaimKind +{ + Paragraph, + ListItem, + TableRow, + CodeBlock +} + +/// Analysis rules explicitly suppressed for one source claim. +[Flags] +public enum IgnoreRule +{ + None = 0, + Duplicate = 1, + Conflict = 2, + Terminology = 4, + All = Duplicate | Conflict | Terminology +} + +/// One line-addressable unit used by documentation analysis. +public sealed record Claim( + ClaimKind Kind, + string Text, + string ContextualText, + string ContentHash, + string ContextualHash, + string DocumentIdentity, + string Component, + string Section, + string FilePath, + int StartLine, + int EndLine, + IgnoreRule IgnoreRules, + string? FenceInfo = null, + IReadOnlyList? CodeRefs = null) +{ + public IReadOnlyList CodeRefs { get; init; } = CodeRefs ?? []; +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractionResult.cs b/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractionResult.cs new file mode 100644 index 0000000..2fd70d6 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractionResult.cs @@ -0,0 +1,8 @@ +using KyberWeave.Core.Diagnostics; + +namespace KyberWeave.Core.Docs.Analysis.Claims; + +/// Claims and operational diagnostics produced from one document. +public sealed record ClaimExtractionResult( + IReadOnlyList Claims, + DiagnosticReport Diagnostics); diff --git a/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractor.cs b/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractor.cs new file mode 100644 index 0000000..53d326b --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Claims/ClaimExtractor.cs @@ -0,0 +1,332 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Model; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; + +namespace KyberWeave.Core.Docs.Analysis.Claims; + +/// Extracts graph-analysis claims from supported Markdown block structures. +public sealed partial class ClaimExtractor +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UsePipeTables() + .UsePreciseSourceLocation() + .Build(); + + private const char InlineLiteralStart = '\uE000'; + private const char InlineLiteralEnd = '\uE001'; + + /// Extracts claims without changing the document body used by retrieval. + public ClaimExtractionResult Extract(DocumentModel document) + { + ArgumentNullException.ThrowIfNull(document); + + var diagnostics = new DiagnosticReport(); + var ignoreRead = IgnoreMarkupReader.Read(document); + if (ignoreRead.Diagnostic is not null) + { + diagnostics.Add(ignoreRead.Diagnostic); + return new ClaimExtractionResult([], diagnostics); + } + + var markdown = ignoreRead.SanitizedBody; + var syntax = Markdown.Parse(markdown, Pipeline); + var sections = ReadSections(syntax, markdown); + var claims = new List(); + + foreach (var block in syntax.Descendants()) + { + switch (block) + { + case FencedCodeBlock code: + AddCodeClaim(claims, code, document, markdown, sections, ignoreRead.Intervals); + break; + case TableRow row when !row.IsHeader: + AddTableClaim(claims, row, document, markdown, sections, ignoreRead.Intervals); + break; + case ListItemBlock item: + AddListClaim(claims, item, document, markdown, sections, ignoreRead.Intervals); + break; + case ParagraphBlock paragraph when !HasAncestor(paragraph) && + !HasAncestor(paragraph): + AddProseClaim(claims, paragraph, ClaimKind.Paragraph, document, markdown, sections, ignoreRead.Intervals); + break; + } + } + + claims.Sort(static (left, right) => + { + var line = left.StartLine.CompareTo(right.StartLine); + return line != 0 ? line : left.Kind.CompareTo(right.Kind); + }); + return new ClaimExtractionResult(claims, diagnostics); + } + + private static void AddProseClaim( + ICollection claims, + Block block, + ClaimKind kind, + DocumentModel document, + string markdown, + IReadOnlyList sections, + IReadOnlyList ignores) + { + var section = SectionFor(block.Line, sections); + if (section.Length == 0) return; + + var source = Slice(markdown, block); + var text = DisplayProse(PlainText(source)); + if (text.Length == 0) return; + + AddClaim(claims, kind, text, section, text, Source(block), document, markdown, ignores, false); + } + + private static void AddListClaim( + ICollection claims, + ListItemBlock item, + DocumentModel document, + string markdown, + IReadOnlyList sections, + IReadOnlyList ignores) + { + var directParagraphs = item.OfType().ToList(); + if (directParagraphs.Count == 0) return; + + var text = DisplayProse(string.Join( + " ", + directParagraphs.Select(paragraph => PlainText(Slice(markdown, paragraph))))); + if (text.Length == 0) return; + + var first = directParagraphs[0]; + var last = directParagraphs[^1]; + var span = new SourceBlock(first.Line, first.Span.Start, last.Span.End); + var section = SectionFor(first.Line, sections); + if (section.Length == 0) return; + + AddClaim(claims, ClaimKind.ListItem, text, section, text, span, document, markdown, ignores, false); + } + + private static void AddTableClaim( + ICollection claims, + TableRow row, + DocumentModel document, + string markdown, + IReadOnlyList sections, + IReadOnlyList ignores) + { + if (row.Parent is not Table table) return; + var header = table.OfType().FirstOrDefault(candidate => candidate.IsHeader); + if (header is null) return; + + var headers = ReadCells(header, markdown); + var values = ReadCells(row, markdown); + if (values.Count == 0 || values.All(string.IsNullOrWhiteSpace)) return; + + var text = string.Join(" | ", values); + var pairs = values.Select((value, index) => + $"{(index < headers.Count && headers[index].Length > 0 ? headers[index] : $"Column {index + 1}")}: {value}"); + var context = string.Join("\n", pairs); + var section = SectionFor(row.Line, sections); + if (section.Length == 0) return; + + AddClaim(claims, ClaimKind.TableRow, text, section, context, Source(row), document, markdown, ignores, false); + } + + private static IReadOnlyList ReadCells(TableRow row, string markdown) => + row.OfType() + .Select(cell => DisplayProse(PlainText(Slice(markdown, cell)))) + .ToList(); + + private static void AddCodeClaim( + ICollection claims, + FencedCodeBlock code, + DocumentModel document, + string markdown, + IReadOnlyList sections, + IReadOnlyList ignores) + { + var section = SectionFor(code.Line, sections); + if (section.Length == 0) return; + + var source = Slice(markdown, code); + var lines = source.Split('\n'); + var contentEnd = code.ClosingFencedCharCount > 0 ? lines.Length - 1 : lines.Length; + var text = string.Join("\n", lines.Skip(1).Take(Math.Max(0, contentEnd - 1))).Trim('\n'); + if (text.Length == 0) return; + + var opening = lines[0].TrimStart(); + var markerLength = opening.TakeWhile(character => character is '`' or '~').Count(); + var info = markerLength < opening.Length ? opening[markerLength..].Trim() : string.Empty; + AddClaim(claims, ClaimKind.CodeBlock, text, section, info, Source(code), document, markdown, ignores, true); + } + + private static void AddClaim( + ICollection claims, + ClaimKind kind, + string text, + string section, + string contextDetail, + ISourceBlock block, + DocumentModel document, + string markdown, + IReadOnlyList ignores, + bool code) + { + var startBodyLine = block.Line + 1; + var endBodyLine = EndLine(markdown, block); + var contextualText = contextDetail.Length == 0 + ? section + "\n" + text + : section + "\n" + contextDetail + (contextDetail == text ? string.Empty : "\n" + text); + var normalizedContent = code ? NormalizeCode(text) : NormalizeProse(text); + var normalizedContext = code ? NormalizeCode(contextualText) : NormalizeProse(contextualText); + + claims.Add(new Claim( + kind, + text, + contextualText, + Hash(normalizedContent), + Hash(normalizedContext), + document.Subject, + document.Frontmatter.Component ?? string.Empty, + section, + document.FilePath, + document.BodyStartLine + startBodyLine - 1, + document.BodyStartLine + endBodyLine - 1, + IgnoreFor(startBodyLine, endBodyLine, ignores), + code ? contextDetail : null, + document.CodeRefs)); + } + + private static IReadOnlyList ReadSections(MarkdownDocument syntax, string markdown) => + syntax.Descendants() + .Where(heading => heading.Level == 2) + .Select(heading => new SectionAtLine( + heading.Line, + DisplayProse(PlainText(Slice(markdown, heading))))) + .ToList(); + + // Markdig's plain-text renderer deliberately omits inline code. Analysis retains the + // literal because commands, paths, and enum values are important conflict evidence. + // Private-use sentinels avoid colliding with source text that happens to contain a + // printable placeholder such as KYBERINLINELITERAL0END. + private static string PlainText(string markdown) + { + var literals = new List(); + var withPlaceholders = InlineCodePattern().Replace(markdown, match => + { + literals.Add(match.Groups[1].Value); + return $"{InlineLiteralStart}{literals.Count - 1}{InlineLiteralEnd}"; + }); + var plain = Markdown.ToPlainText(withPlaceholders, Pipeline); + for (var index = 0; index < literals.Count; index++) + { + plain = plain.Replace( + $"{InlineLiteralStart}{index}{InlineLiteralEnd}", + $"`{literals[index]}`", + StringComparison.Ordinal); + } + + return plain; + } + + private static string SectionFor(int line, IReadOnlyList sections) => + sections.LastOrDefault(section => section.Line < line)?.Heading ?? string.Empty; + + private static bool HasAncestor(Block block) where T : Block + { + for (var parent = block.Parent; parent is not null; parent = parent.Parent) + { + if (parent is T) return true; + } + + return false; + } + + private static string Slice(string markdown, ISourceBlock block) + { + if (block.SpanStart < 0 || block.SpanEnd < block.SpanStart || block.SpanStart >= markdown.Length) + { + return string.Empty; + } + + var length = Math.Min(markdown.Length - block.SpanStart, block.SpanEnd - block.SpanStart + 1); + return markdown.Substring(block.SpanStart, length); + } + + private static string Slice(string markdown, Block block) => + Slice(markdown, Source(block)); + + private static SourceBlock Source(Block block) => + new(block.Line, block.Span.Start, block.Span.End); + + private static int EndLine(string markdown, ISourceBlock block) + { + var end = Math.Min(markdown.Length, block.SpanEnd + 1); + var line = block.Line + 1; + for (var index = Math.Max(0, block.SpanStart); index < end; index++) + { + if (markdown[index] == '\n') line++; + } + + return line; + } + + private static IgnoreRule IgnoreFor( + int startLine, + int endLine, + IReadOnlyList intervals) + { + var result = IgnoreRule.None; + foreach (var interval in intervals) + { + if (startLine <= interval.EndLine && endLine >= interval.StartLine) + { + result |= interval.Rule; + } + } + + return result; + } + + private static string DisplayProse(string text) => WhitespacePattern().Replace(text, " ").Trim(); + + private static string NormalizeProse(string text) + { + var decomposed = text.Normalize(NormalizationForm.FormKD).ToLowerInvariant(); + var withoutPunctuation = ProseSeparatorPattern().Replace(decomposed, " "); + return WhitespacePattern().Replace(withoutPunctuation, " ").Trim(); + } + + private static string NormalizeCode(string text) => + string.Join("\n", text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n') + .Split('\n') + .Select(line => line.TrimEnd())) + .Trim('\n'); + + private static string Hash(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + [GeneratedRegex("\\s+", RegexOptions.CultureInvariant)] + private static partial Regex WhitespacePattern(); + + [GeneratedRegex("[\\p{P}\\p{S}]+", RegexOptions.CultureInvariant)] + private static partial Regex ProseSeparatorPattern(); + + [GeneratedRegex("`([^`\\r\\n]+)`", RegexOptions.CultureInvariant)] + private static partial Regex InlineCodePattern(); + + private sealed record SectionAtLine(int Line, string Heading); + + private interface ISourceBlock + { + int Line { get; } + int SpanStart { get; } + int SpanEnd { get; } + } + + private sealed record SourceBlock(int Line, int SpanStart, int SpanEnd) : ISourceBlock; +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Claims/IgnoreMarkupReader.cs b/src/KyberWeave.Core/Docs/Analysis/Claims/IgnoreMarkupReader.cs new file mode 100644 index 0000000..21a24ea --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Claims/IgnoreMarkupReader.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Model; + +namespace KyberWeave.Core.Docs.Analysis.Claims; + +/// +/// Validates the deliberately small ignore language and replaces only its tags with +/// spaces. Keeping character and newline positions stable lets Markdig remain the source +/// of structural claim boundaries without changing the retrieval body. +/// +internal static partial class IgnoreMarkupReader +{ + internal const string DiagnosticCode = "KW-DOC-ANALYSIS-004"; + + internal static IgnoreMarkupReadResult Read(DocumentModel document) + { + var body = NormalizeLineEndings(document.Body); + var prefix = FrontmatterPrefix(document); + if (ContainsTagLikeText(prefix)) + { + return Error(document, "Ignore markup is not allowed in YAML frontmatter."); + } + + var characters = body.ToCharArray(); + var intervals = new List(); + var activeRule = IgnoreRule.None; + var activeLine = 0; + var fence = new FenceState(); + var lineStart = 0; + var lineNumber = 1; + + while (lineStart <= body.Length) + { + var newline = body.IndexOf('\n', lineStart); + var lineEnd = newline < 0 ? body.Length : newline; + var line = body[lineStart..lineEnd]; + + if (UpdateFence(line, fence)) + { + goto NextLine; + } + + if (fence.IsOpen) + { + goto NextLine; + } + + if (activeRule != IgnoreRule.None && LevelTwoHeadingPattern().IsMatch(line)) + { + return Error(document, "Ignore markup cannot cross a level-two section boundary."); + } + + var matches = ExactTagPattern().Matches(line); + var residue = line; + foreach (Match match in matches.Cast().Reverse()) + { + residue = residue.Remove(match.Index, match.Length); + } + + if (TagLikePattern().IsMatch(residue)) + { + return Error(document, "Ignore markup is malformed or uses an unknown, case-changed rule."); + } + + foreach (Match match in matches) + { + Array.Fill(characters, ' ', lineStart + match.Index, match.Length); + if (match.Value == "
") + { + if (activeRule == IgnoreRule.None) + { + return Error(document, "Ignore markup has a closing tag without an opening tag."); + } + + intervals.Add(new IgnoreInterval(activeLine, lineNumber, activeRule)); + activeRule = IgnoreRule.None; + activeLine = 0; + continue; + } + + if (activeRule != IgnoreRule.None) + { + return Error(document, "Ignore markup cannot be nested."); + } + + activeRule = ParseRule(match.Groups["rule"].Value); + activeLine = lineNumber; + } + + NextLine: + if (newline < 0) break; + lineStart = newline + 1; + lineNumber++; + } + + if (activeRule != IgnoreRule.None) + { + return Error(document, "Ignore markup has an opening tag without a closing tag."); + } + + return new IgnoreMarkupReadResult(new string(characters), intervals, null); + } + + private static string FrontmatterPrefix(DocumentModel document) + { + if (!document.HasFrontmatter || document.BodyStartLine <= 1 || document.RawMarkdown.Length == 0) + { + return string.Empty; + } + + var raw = NormalizeLineEndings(document.RawMarkdown); + var line = 1; + var index = 0; + while (index < raw.Length && line < document.BodyStartLine) + { + if (raw[index++] == '\n') line++; + } + + return raw[..index]; + } + + private static bool ContainsTagLikeText(string text) => TagLikePattern().IsMatch(text); + + private static bool UpdateFence(string line, FenceState fence) + { + var match = FencePattern().Match(line); + if (!match.Success) return false; + + var marker = match.Groups["marker"].Value; + if (!fence.IsOpen) + { + fence.Character = marker[0]; + fence.Length = marker.Length; + return true; + } + + if (marker[0] == fence.Character && marker.Length >= fence.Length && + line[(match.Index + match.Length)..].Trim().Length == 0) + { + fence.Character = '\0'; + fence.Length = 0; + } + + return true; + } + + private static IgnoreRule ParseRule(string rule) => rule switch + { + "duplicate" => IgnoreRule.Duplicate, + "conflict" => IgnoreRule.Conflict, + "terminology" => IgnoreRule.Terminology, + "all" => IgnoreRule.All, + _ => IgnoreRule.None + }; + + private static IgnoreMarkupReadResult Error(DocumentModel document, string message) => + new( + string.Empty, + [], + new Diagnostic( + DiagnosticCode, + Severity.Error, + message, + document.Subject, + document.FilePath, + "Use balanced, non-nested tags inside one ## section, outside frontmatter and code fences.")); + + private static string NormalizeLineEndings(string value) => value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + + [GeneratedRegex("^ {0,3}(?`{3,}|~{3,})", RegexOptions.CultureInvariant)] + private static partial Regex FencePattern(); + + [GeneratedRegex("^ {0,3}##(?:[ \\t]+|$)", RegexOptions.CultureInvariant)] + private static partial Regex LevelTwoHeadingPattern(); + + [GeneratedRegex("duplicate|conflict|terminology|all)\">|", RegexOptions.CultureInvariant)] + private static partial Regex ExactTagPattern(); + + [GeneratedRegex("<[^\\r\\n>]*kyber-ignore[^\\r\\n]*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex TagLikePattern(); + + private sealed class FenceState + { + internal char Character { get; set; } + internal int Length { get; set; } + internal bool IsOpen => Length > 0; + } +} + +internal sealed record IgnoreMarkupReadResult( + string SanitizedBody, + IReadOnlyList Intervals, + Diagnostic? Diagnostic); + +internal sealed record IgnoreInterval(int StartLine, int EndLine, IgnoreRule Rule); diff --git a/src/KyberWeave.Core/Docs/Analysis/DocumentationAnalyzer.cs b/src/KyberWeave.Core/Docs/Analysis/DocumentationAnalyzer.cs new file mode 100644 index 0000000..db82520 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/DocumentationAnalyzer.cs @@ -0,0 +1,626 @@ +using System.Text.RegularExpressions; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Model; + +namespace KyberWeave.Core.Docs.Analysis; + +/// Runs graph-first, bounded documentation analysis over a parsed corpus. +public sealed partial class DocumentationAnalyzer +{ + public const string DuplicateRuleCode = "KW-DOC-ANALYSIS-001"; + public const string ConflictRuleCode = "KW-DOC-ANALYSIS-002"; + public const string TerminologyRuleCode = "KW-DOC-ANALYSIS-003"; + public const string IgnoreMarkupRuleCode = "KW-DOC-ANALYSIS-004"; + public const string CodeGraphUnavailableRuleCode = "KW-DOC-ANALYSIS-005"; + public const string EmbeddingUnavailableRuleCode = "KW-DOC-ANALYSIS-006"; + + public const string AnalyzerVersion = "analyzer/v1"; + public const string RubricVersion = "rubric/v1"; + + private static readonly IReadOnlySet TerminologyStopWords = + new HashSet(StringComparer.Ordinal) + { + "about", "after", "again", "before", "being", "could", "every", "from", + "into", "must", "should", "that", "their", "there", "these", "this", "those", + "using", "with", "would", "runner", "record", "report", "request", "analysis", + "behavior", "documentation", "reference" + }; + + private readonly ClaimExtractor _extractor; + private readonly IReadOnlyList _candidateSources; + private readonly IEmbeddingGenerator? _embeddingGenerator; + private readonly IAnalysisPersistence? _persistence; + + public DocumentationAnalyzer( + ClaimExtractor extractor, + IReadOnlyList candidateSources, + IEmbeddingGenerator? embeddingGenerator, + IAnalysisPersistence? persistence) + { + _extractor = extractor ?? throw new ArgumentNullException(nameof(extractor)); + _candidateSources = candidateSources ?? throw new ArgumentNullException(nameof(candidateSources)); + _embeddingGenerator = embeddingGenerator; + _persistence = persistence; + } + + /// Analyzes eligible documents without changing the corpus. + public DocumentationAnalysisResult Analyze( + DocumentSet documents, + DocGraphProjection graph, + DocsAnalysisConfig config, + AnalysisGlossary? glossary = null) + { + ArgumentNullException.ThrowIfNull(documents); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(config); + + var diagnostics = new DiagnosticReport(); + var claims = ExtractEligibleClaims(documents, config, diagnostics); + var candidates = new Dictionary(StringComparer.Ordinal); + + AddExactDuplicateClusters(claims, candidates); + + var graphComparisons = 0; + var lexicalComparisons = 0; + var embeddingComparisons = 0; + var graphCandidates = 0; + var lexicalCandidates = 0; + var embeddingCandidates = 0; + var embeddingCacheHits = 0; + var embeddingCacheMisses = 0; + var embeddingPromptTokens = 0; + var embeddingTotalTokens = 0; + var sourceTruncated = false; + var semanticSeedPairs = new List(); + + var request = new ClaimCandidateSourceRequest(claims, graph, config.Search); + foreach (var source in _candidateSources.Where(source => ShouldRun(source.Kind, config))) + { + var result = source.FindCandidates(request) + ?? throw new InvalidOperationException("A claim candidate source returned null."); + sourceTruncated |= result.Truncated; + switch (source.Kind) + { + case CandidateSourceKind.Graph: + graphComparisons += result.ComparisonCount; + graphCandidates += result.Pairs.Count; + break; + case CandidateSourceKind.Lexical: + lexicalComparisons += result.ComparisonCount; + lexicalCandidates += result.Pairs.Count; + break; + case CandidateSourceKind.Embedding: + embeddingComparisons += result.ComparisonCount; + embeddingCandidates += result.Pairs.Count; + break; + default: + throw new InvalidOperationException($"Unknown candidate source kind '{source.Kind}'."); + } + + foreach (var pair in result.Pairs) + { + if (source.Kind != CandidateSourceKind.Embedding) + semanticSeedPairs.Add(pair); + ClassifyPair(pair, config.Search, glossary, candidates); + } + } + + if (config.Embeddings.Mode != DocsAnalysisEmbeddingMode.Off) + { + var resolution = ResolveEmbeddings(claims, config.Embeddings); + diagnostics.AddRange(resolution.Diagnostics.Items); + embeddingCacheHits = resolution.CacheHits; + embeddingCacheMisses = resolution.CacheMisses; + embeddingPromptTokens = resolution.Usage.PromptTokens; + embeddingTotalTokens = resolution.Usage.TotalTokens; + + if (resolution.Embeddings.Count == claims.Count) + { + var result = EmbeddingCandidateBuilder.Build( + claims, + resolution.Embeddings, + semanticSeedPairs, + config.Search); + sourceTruncated |= result.Truncated; + embeddingComparisons += result.ComparisonCount; + embeddingCandidates += result.Pairs.Count; + foreach (var pair in result.Pairs) + ClassifyPair(pair, config.Search, glossary, candidates); + } + } + + var consolidated = ConsolidateTerminology(candidates.Values); + var reviewed = ApplyVerdicts(consolidated, config.VerdictConfidence); + var ordered = reviewed + .OrderBy(candidate => candidate.Kind) + .ThenBy(candidate => candidate.Term, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToArray(); + var truncated = sourceTruncated || ordered.Length > config.Search.MaxCandidates; + var visible = ordered.Take(config.Search.MaxCandidates).ToArray(); + + foreach (var candidate in visible) + diagnostics.Add(ToDiagnostic(candidate, config.VerdictConfidence)); + + var metrics = new AnalysisMetrics( + claims.Count, + graphComparisons, + lexicalComparisons, + embeddingComparisons, + graphCandidates, + lexicalCandidates, + embeddingCandidates, + truncated); + AddMetrics(diagnostics, metrics); + diagnostics.AddMetric("embeddingCacheHits", embeddingCacheHits); + diagnostics.AddMetric("embeddingCacheMisses", embeddingCacheMisses); + diagnostics.AddMetric("embeddingPromptTokens", embeddingPromptTokens); + diagnostics.AddMetric("embeddingTotalTokens", embeddingTotalTokens); + return new DocumentationAnalysisResult(visible, diagnostics, metrics); + } + + private EmbeddingResolutionResult ResolveEmbeddings( + IReadOnlyList claims, + DocsAnalysisEmbeddingConfig config) + { + if (_embeddingGenerator is null) + { + return EmbeddingCoordinator.Unavailable( + config.Mode, + "No embedding provider is configured in this host."); + } + if (_persistence is null) + { + return EmbeddingCoordinator.Unavailable( + config.Mode, + "No safe analysis persistence provider is configured in this host."); + } + + var coordinator = new EmbeddingCoordinator(_embeddingGenerator, _persistence); + return coordinator.Resolve( + claims.Select(claim => new EmbeddingWorkItem( + claim.ContextualHash, + claim.ContextualText)).ToArray(), + config); + } + + private IReadOnlyList ExtractEligibleClaims( + DocumentSet documents, + DocsAnalysisConfig config, + DiagnosticReport diagnostics) + { + var statuses = new HashSet(config.Statuses, StringComparer.OrdinalIgnoreCase); + var glossaryPath = NormalizePath(config.GlossaryPath ?? config.ResolvedGlossaryPath); + var claims = new List(); + + foreach (var document in documents.Documents) + { + if (!statuses.Contains(document.Frontmatter.Status ?? string.Empty)) continue; + if (glossaryPath is not null + && StringComparer.OrdinalIgnoreCase.Equals( + glossaryPath, + NormalizePath(document.RelativePath))) + { + continue; + } + + var extraction = _extractor.Extract(document); + diagnostics.AddRange(extraction.Diagnostics.Items); + claims.AddRange(extraction.Claims.Where(claim => + TokenPattern().Count(claim.Text) >= config.Search.MinClaimTokens)); + } + + return claims; + } + + private static void AddExactDuplicateClusters( + IReadOnlyList claims, + IDictionary candidates) + { + foreach (var group in claims + .Where(claim => !claim.IgnoreRules.HasFlag(IgnoreRule.Duplicate)) + .GroupBy(claim => claim.ContentHash, StringComparer.Ordinal) + .Where(group => group.Count() > 1)) + { + var groupedClaims = group + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .ToArray(); + var id = AnalysisCandidateId.Compute( + AnalysisRuleKind.Duplicate, + null, + groupedClaims.Select(claim => claim.ContentHash), + AnalyzerVersion, + RubricVersion); + candidates[id] = new AnalysisCandidate( + id, + AnalysisRuleKind.Duplicate, + groupedClaims, + new CandidateScore(1, null, 0), + IsExact: true); + } + } + + private static void ClassifyPair( + ClaimPairCandidate pair, + DocsAnalysisSearchConfig search, + AnalysisGlossary? glossary, + IDictionary candidates) + { + if (StringComparer.Ordinal.Equals(pair.Left.ContentHash, pair.Right.ContentHash)) return; + + var ordinaryCandidate = IsOrdinaryCandidate(pair.Score, search); + if (ordinaryCandidate + && !pair.Left.IgnoreRules.HasFlag(IgnoreRule.Duplicate) + && !pair.Right.IgnoreRules.HasFlag(IgnoreRule.Duplicate) + && IsNearDuplicate(pair.Score, search)) + { + AddOrMerge(candidates, CreateCandidate(AnalysisRuleKind.Duplicate, pair, null)); + } + + if (ordinaryCandidate + && !pair.Left.IgnoreRules.HasFlag(IgnoreRule.Conflict) + && !pair.Right.IgnoreRules.HasFlag(IgnoreRule.Conflict) + && pair.Score.Graph > 0 + && HasConflictSignal(pair.Left, pair.Right)) + { + AddOrMerge(candidates, CreateCandidate(AnalysisRuleKind.Conflict, pair, null)); + } + + if (pair.Left.IgnoreRules.HasFlag(IgnoreRule.Terminology) + || pair.Right.IgnoreRules.HasFlag(IgnoreRule.Terminology) + || pair.Score.Lexical > search.TerminologyContextThreshold) + { + return; + } + + foreach (var term in SharedInformativeTerms(pair.Left.Text, pair.Right.Text)) + { + var claims = new[] { pair.Left, pair.Right }; + if (glossary?.Covers(term, claims) == true) continue; + AddOrMerge(candidates, CreateCandidate(AnalysisRuleKind.Terminology, pair, term)); + } + } + + private static bool IsNearDuplicate(CandidateScore score, DocsAnalysisSearchConfig search) => + score.Lexical >= search.LexicalDuplicateThreshold + || score.Semantic >= search.SemanticDuplicateThreshold; + + private static bool IsOrdinaryCandidate(CandidateScore score, DocsAnalysisSearchConfig search) => + score.Lexical >= search.LexicalCandidateThreshold + || score.Semantic >= search.SemanticCandidateThreshold; + + private static AnalysisCandidate CreateCandidate( + AnalysisRuleKind kind, + ClaimPairCandidate pair, + string? term) + { + var claims = new[] { pair.Left, pair.Right } + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .ToArray(); + var id = AnalysisCandidateId.Compute( + kind, + term, + claims.Select(claim => claim.ContentHash), + AnalyzerVersion, + RubricVersion); + return new AnalysisCandidate( + id, + kind, + claims, + pair.Score, + Term: term, + Sources: [pair.Source]); + } + + private static void AddOrMerge( + IDictionary candidates, + AnalysisCandidate candidate) + { + if (!candidates.TryGetValue(candidate.Id, out var existing)) + { + candidates[candidate.Id] = candidate; + return; + } + + candidates[candidate.Id] = existing with + { + Claims = existing.Claims.Concat(candidate.Claims) + .Distinct() + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .ToArray(), + Score = new CandidateScore( + Math.Max(existing.Score.Lexical, candidate.Score.Lexical), + Max(existing.Score.Semantic, candidate.Score.Semantic), + Math.Max(existing.Score.Graph, candidate.Score.Graph)), + Sources = existing.Sources.Concat(candidate.Sources).Distinct().ToArray() + }; + } + + private static IReadOnlyList ConsolidateTerminology( + IEnumerable candidates) + { + var materialized = candidates.ToArray(); + var result = materialized + .Where(candidate => candidate.Kind != AnalysisRuleKind.Terminology) + .ToList(); + foreach (var group in materialized + .Where(candidate => candidate.Kind == AnalysisRuleKind.Terminology) + .GroupBy(candidate => candidate.Term!, StringComparer.Ordinal)) + { + var claims = group.SelectMany(candidate => candidate.Claims) + .Distinct() + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .ToArray(); + var first = group.First(); + var id = AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + group.Key, + claims.Select(claim => claim.ContentHash), + AnalyzerVersion, + RubricVersion); + result.Add(first with + { + Id = id, + Claims = claims, + Score = new CandidateScore( + group.Max(candidate => candidate.Score.Lexical), + MaximumSemantic(group), + group.Max(candidate => candidate.Score.Graph)), + Sources = group.SelectMany(candidate => candidate.Sources).Distinct().ToArray() + }); + } + + return result; + } + + private static double? MaximumSemantic(IEnumerable candidates) + { + var scores = candidates + .Select(candidate => candidate.Score.Semantic) + .Where(score => score.HasValue) + .Select(score => score!.Value) + .ToArray(); + return scores.Length == 0 ? null : scores.Max(); + } + + private IReadOnlyList ApplyVerdicts( + IEnumerable candidates, + double confidenceThreshold) + { + var materialized = candidates.ToArray(); + if (_persistence?.IsAvailable != true || materialized.Length == 0) return materialized; + + var verdicts = _persistence.LoadVerdicts(materialized.Select(candidate => candidate.Id).ToArray()); + return materialized + .Where(candidate => !IsSuppressed(candidate, verdicts, confidenceThreshold)) + .Select(candidate => verdicts.TryGetValue(candidate.Id, out var verdict) + ? candidate with { Verdict = verdict } + : candidate) + .ToArray(); + } + + private static bool IsSuppressed( + AnalysisCandidate candidate, + IReadOnlyDictionary verdicts, + double confidenceThreshold) => + verdicts.TryGetValue(candidate.Id, out var verdict) + && verdict.Confidence >= confidenceThreshold + && verdict.Label == AnalysisVerdictLabel.Benign; + + private static Diagnostic ToDiagnostic(AnalysisCandidate candidate, double confidenceThreshold) + { + var primary = candidate.Claims[0]; + var related = candidate.Claims.Skip(1) + .Select(claim => new DiagnosticLocation( + claim.FilePath, + claim.StartLine, + claim.EndLine, + "Related analysis evidence.")) + .ToArray(); + + var (code, severity, message, hint) = candidate.Kind switch + { + AnalysisRuleKind.Duplicate => ( + DuplicateRuleCode, + DuplicateSeverity(candidate, confidenceThreshold), + candidate.IsExact + ? $"The same documentation claim appears in {candidate.Claims.Count} locations." + : "These documentation claims may express the same requirement.", + "Review the evidence and designate a canonical source; do not remove intentional repetition without review."), + AnalysisRuleKind.Conflict => ( + ConflictRuleCode, + IsConfirmed(candidate, AnalysisVerdictLabel.Conflict, confidenceThreshold) + ? Severity.Error + : Severity.Info, + "These related documentation claims contain potentially incompatible values or obligations.", + "Review scope and time applicability, then import a conflict, benign, or uncertain verdict."), + AnalysisRuleKind.Terminology => ( + TerminologyRuleCode, + Severity.Warning, + $"The term '{candidate.Term}' appears to have distinct meanings in divergent contexts.", + "Define approved, scope-qualified senses in the managed glossary."), + _ => throw new InvalidOperationException($"Unknown analysis rule kind '{candidate.Kind}'.") + }; + + return new Diagnostic( + code, + severity, + message, + primary.DocumentIdentity, + primary.FilePath, + hint, + primary.StartLine, + primary.EndLine, + related); + } + + private static Severity DuplicateSeverity(AnalysisCandidate candidate, double confidenceThreshold) => + candidate.IsExact || IsConfirmed(candidate, AnalysisVerdictLabel.Duplicate, confidenceThreshold) + ? Severity.Warning + : Severity.Info; + + private static bool IsConfirmed( + AnalysisCandidate candidate, + AnalysisVerdictLabel label, + double confidenceThreshold) => + candidate.Verdict is { } verdict + && verdict.Label == label + && verdict.Confidence >= confidenceThreshold; + + private static bool HasConflictSignal(Claim left, Claim right) + { + if (left.Kind == ClaimKind.CodeBlock && right.Kind == ClaimKind.CodeBlock) + { + if (IsShellFence(left) && IsShellFence(right)) + return IsDifferentShellCommand(left, right); + } + + if (NegationPattern().IsMatch(left.Text) != NegationPattern().IsMatch(right.Text)) return true; + + var leftModal = ModalPattern().Matches(left.Text).Select(match => match.Value.ToLowerInvariant()).ToHashSet(); + var rightModal = ModalPattern().Matches(right.Text).Select(match => match.Value.ToLowerInvariant()).ToHashSet(); + if (leftModal.Count > 0 && rightModal.Count > 0 && !leftModal.SetEquals(rightModal)) return true; + + if (DifferentCapturedValues(NumberPattern(), left.Text, right.Text)) return true; + if (DifferentCapturedValues(PathPattern(), left.Text, right.Text)) return true; + if (DifferentCapturedValues(CodeLiteralPattern(), left.Text, right.Text)) return true; + if (CommandPattern().IsMatch(left.Text) + && CommandPattern().IsMatch(right.Text) + && !StringComparer.OrdinalIgnoreCase.Equals(left.Text, right.Text)) + { + return true; + } + + return false; + } + + private static bool IsDifferentShellCommand(Claim left, Claim right) + { + var leftCommands = MeaningfulShellLines(left); + var rightCommands = MeaningfulShellLines(right); + return leftCommands.Count > 0 + && rightCommands.Count > 0 + && !leftCommands.SequenceEqual(rightCommands, StringComparer.OrdinalIgnoreCase); + } + + private static bool IsShellFence(Claim claim) => FenceLanguage(claim) is + "sh" or "shell" or "bash" or "zsh" or "fish" or "powershell" or "pwsh" or "cmd" or "bat" or "batch"; + + private static string FenceLanguage(Claim claim) => + (claim.FenceInfo ?? string.Empty) + .Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()? + .ToLowerInvariant() ?? string.Empty; + + private static IReadOnlyList MeaningfulShellLines(Claim claim) => + claim.Text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(line => !IsShellComment(claim, line)) + .ToArray(); + + private static bool IsShellComment(Claim claim, string line) + { + if (FenceLanguage(claim) is "cmd" or "bat" or "batch") + { + return line.StartsWith("::", StringComparison.Ordinal) + || line.Equals("rem", StringComparison.OrdinalIgnoreCase) + || line.StartsWith("rem ", StringComparison.OrdinalIgnoreCase); + } + + return line.StartsWith('#'); + } + + private static bool DifferentCapturedValues(Regex pattern, string left, string right) + { + var leftValues = pattern.Matches(left).Select(match => match.Groups[1].Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + var rightValues = pattern.Matches(right).Select(match => match.Groups[1].Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + return leftValues.Count > 0 && rightValues.Count > 0 && !leftValues.SetEquals(rightValues); + } + + private static IReadOnlyList SharedInformativeTerms(string left, string right) + { + var leftTerms = TermPattern().Matches(left.ToLowerInvariant()) + .Select(match => match.Value) + .Where(IsInformativeTerm) + .ToHashSet(StringComparer.Ordinal); + return TermPattern().Matches(right.ToLowerInvariant()) + .Select(match => match.Value) + .Where(IsInformativeTerm) + .Where(leftTerms.Contains) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsInformativeTerm(string term) => + term.Length >= 4 && !TerminologyStopWords.Contains(term); + + private bool ShouldRun(CandidateSourceKind kind, DocsAnalysisConfig config) => kind switch + { + CandidateSourceKind.Graph => true, + CandidateSourceKind.Lexical => config.Search.Mode != DocsAnalysisSearchMode.Graph, + CandidateSourceKind.Embedding => _embeddingGenerator is not null + && config.Embeddings.Mode != DocsAnalysisEmbeddingMode.Off, + _ => false + }; + + private static double? Max(double? left, double? right) => (left, right) switch + { + (null, null) => null, + (not null, null) => left, + (null, not null) => right, + _ => Math.Max(left!.Value, right!.Value) + }; + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + var normalized = path.Replace('\\', '/').Trim().TrimStart('/'); + return normalized.StartsWith("./", StringComparison.Ordinal) ? normalized[2..] : normalized; + } + + private static void AddMetrics(DiagnosticReport diagnostics, AnalysisMetrics metrics) + { + diagnostics.AddMetric("extractedClaims", metrics.ExtractedClaims); + diagnostics.AddMetric("graphComparisons", metrics.GraphComparisons); + diagnostics.AddMetric("lexicalComparisons", metrics.LexicalComparisons); + diagnostics.AddMetric("embeddingComparisons", metrics.EmbeddingComparisons); + diagnostics.AddMetric("graphCandidates", metrics.GraphCandidates); + diagnostics.AddMetric("lexicalCandidates", metrics.LexicalCandidates); + diagnostics.AddMetric("embeddingCandidates", metrics.EmbeddingCandidates); + diagnostics.AddMetric("truncated", metrics.Truncated); + } + + [GeneratedRegex("[\\p{L}\\p{N}]+", RegexOptions.CultureInvariant)] + private static partial Regex TokenPattern(); + + [GeneratedRegex("\\b(?:not|never|no|cannot|can't|won't|isn't|aren't|doesn't|don't)\\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex NegationPattern(); + + [GeneratedRegex("\\b(?:must|may|should|shall|can|will)\\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ModalPattern(); + + [GeneratedRegex("\\b(\\d+(?:\\.\\d+)*)\\b", RegexOptions.CultureInvariant)] + private static partial Regex NumberPattern(); + + [GeneratedRegex("(?Creates semantic claim pairs from validated, ordered embeddings. +internal static class EmbeddingCandidateBuilder +{ + public static ClaimCandidateSourceResult Build( + IReadOnlyList claims, + IReadOnlyList embeddings, + IReadOnlyCollection seedPairs, + DocsAnalysisSearchConfig search) + { + ArgumentNullException.ThrowIfNull(claims); + ArgumentNullException.ThrowIfNull(embeddings); + ArgumentNullException.ThrowIfNull(seedPairs); + ArgumentNullException.ThrowIfNull(search); + if (claims.Count != embeddings.Count) + throw new InvalidDataException("Embedding count must match the analyzed claim count."); + + var claimIndexes = claims + .Select((claim, index) => new { claim, index }) + .ToDictionary(item => item.claim, item => item.index); + var pairs = search.Mode == DocsAnalysisSearchMode.HighRecall + ? AllPairs(claims.Count) + : SeedPairs(seedPairs, claimIndexes); + var scored = pairs + .Select(pair => new ScoredPair(pair, Cosine( + embeddings[pair.Left].Vector, + embeddings[pair.Right].Vector))) + .ToArray(); + + var selected = search.Mode == DocsAnalysisSearchMode.HighRecall + ? SelectTopNeighbors(scored, claims.Count, search.MaxNeighborsPerClaim) + : scored; + var truncated = selected.Count > search.MaxCandidates; + var candidates = selected + .OrderBy(item => item.Pair.Left) + .ThenBy(item => item.Pair.Right) + .Take(search.MaxCandidates) + .Select(item => + { + var seed = FindSeed(seedPairs, claims[item.Pair.Left], claims[item.Pair.Right]); + return new ClaimPairCandidate( + claims[item.Pair.Left], + claims[item.Pair.Right], + CandidateSourceKind.Embedding, + new CandidateScore( + seed?.Score.Lexical ?? LexicalSimilarity.Score( + claims[item.Pair.Left].ContextualText, + claims[item.Pair.Right].ContextualText), + item.Score, + seed?.Score.Graph ?? 0)); + }) + .ToArray(); + return new ClaimCandidateSourceResult(candidates, scored.Length, truncated); + } + + private static IReadOnlyList SelectTopNeighbors( + IReadOnlyList scored, + int claimCount, + int maximumNeighbors) + { + var neighbors = Enumerable.Range(0, claimCount) + .Select(_ => new List()) + .ToArray(); + foreach (var item in scored) + { + neighbors[item.Pair.Left].Add(item); + neighbors[item.Pair.Right].Add(item); + } + + var selected = new HashSet(); + for (var claimIndex = 0; claimIndex < claimCount; claimIndex++) + { + foreach (var item in neighbors[claimIndex] + .OrderByDescending(item => item.Score) + .ThenBy(item => item.Pair.Left) + .ThenBy(item => item.Pair.Right) + .Take(maximumNeighbors)) + { + selected.Add(item.Pair); + } + } + + return scored.Where(item => selected.Contains(item.Pair)).ToArray(); + } + + private static IReadOnlyList AllPairs(int count) + { + var pairs = new List(); + for (var left = 0; left < count; left++) + { + for (var right = left + 1; right < count; right++) + pairs.Add(new IndexPair(left, right)); + } + return pairs; + } + + private static IReadOnlyList SeedPairs( + IEnumerable seeds, + IReadOnlyDictionary indexes) => + seeds + .Where(seed => indexes.ContainsKey(seed.Left) && indexes.ContainsKey(seed.Right)) + .Select(seed => IndexPair.Create(indexes[seed.Left], indexes[seed.Right])) + .Distinct() + .ToArray(); + + private static ClaimPairCandidate? FindSeed( + IEnumerable seeds, + Claim left, + Claim right) => + seeds.FirstOrDefault(seed => + (seed.Left == left && seed.Right == right) + || (seed.Left == right && seed.Right == left)); + + private static double Cosine(IReadOnlyList left, IReadOnlyList right) + { + if (left.Count == 0 || left.Count != right.Count) + throw new InvalidDataException("Cached embedding vectors must have consistent, non-zero dimensions."); + + double dot = 0; + double leftNorm = 0; + double rightNorm = 0; + for (var index = 0; index < left.Count; index++) + { + if (!float.IsFinite(left[index]) || !float.IsFinite(right[index])) + throw new InvalidDataException("Cached embedding vectors must contain only finite values."); + dot += left[index] * right[index]; + leftNorm += left[index] * left[index]; + rightNorm += right[index] * right[index]; + } + + if (leftNorm <= 0 || rightNorm <= 0) + throw new InvalidDataException("Cached embedding vectors must have non-zero norms."); + return Math.Clamp(dot / Math.Sqrt(leftNorm * rightNorm), -1, 1); + } + + private readonly record struct IndexPair(int Left, int Right) + { + public static IndexPair Create(int left, int right) => + left < right ? new IndexPair(left, right) : new IndexPair(right, left); + } + + private sealed record ScoredPair(IndexPair Pair, double Score); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingCoordinator.cs b/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingCoordinator.cs new file mode 100644 index 0000000..5698fbb --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingCoordinator.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Core.Docs.Analysis.Embeddings; + +/// Applies embedding mode and safe-cache policy around a provider. +public sealed class EmbeddingCoordinator +{ + private readonly IEmbeddingGenerator _generator; + private readonly IAnalysisPersistence _persistence; + + public EmbeddingCoordinator(IEmbeddingGenerator generator, IAnalysisPersistence persistence) + { + _generator = generator ?? throw new ArgumentNullException(nameof(generator)); + _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + } + + public EmbeddingResolutionResult Resolve( + IReadOnlyCollection workItems, + DocsAnalysisEmbeddingConfig config) + { + ArgumentNullException.ThrowIfNull(workItems); + ArgumentNullException.ThrowIfNull(config); + + if (config.Mode == DocsAnalysisEmbeddingMode.Off) + return Empty(); + if (!_persistence.IsAvailable) + { + return Unavailable( + config.Mode, + "Embedding generation was skipped because the analysis cache is not safely ignored."); + } + + try + { + return ResolveFromCacheOrProvider(workItems, config); + } + catch (HttpRequestException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (IOException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (InvalidDataException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (InvalidOperationException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (ArgumentException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (OperationCanceledException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (JsonException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (OverflowException ex) + { + return Unavailable(config.Mode, ex.Message); + } + catch (FormatException) + { + // Header parsing is the only provider-boundary FormatException expected here. + // Use a fixed reason so malformed secret text can never reach diagnostics. + return Unavailable( + config.Mode, + "The configured embedding credential is not a valid HTTP bearer token."); + } + } + + private EmbeddingResolutionResult ResolveFromCacheOrProvider( + IReadOnlyCollection workItems, + DocsAnalysisEmbeddingConfig config) + { + var orderedWork = workItems.ToArray(); + if (orderedWork.Length == 0) return Empty(); + if (orderedWork.Any(item => string.IsNullOrWhiteSpace(item.ContextualHash))) + throw new ArgumentException("Embedding work items require a contextual hash.", nameof(workItems)); + + var model = string.IsNullOrWhiteSpace(config.Model) + ? throw new ArgumentException("An embedding model is required.", nameof(config)) + : config.Model; + var provider = _generator.GetProviderFingerprint(config); + var orderedKeys = orderedWork + .Select(item => new EmbeddingCacheKey( + item.ContextualHash, + provider, + model, + config.Dimensions, + "float")) + .ToArray(); + var uniqueKeys = orderedKeys.Distinct().ToArray(); + var cached = _persistence.LoadEmbeddings(uniqueKeys); + var misses = uniqueKeys.Where(key => !cached.ContainsKey(key)).ToArray(); + var generatedByKey = new Dictionary(); + var usage = EmbeddingUsage.None; + + if (misses.Length > 0) + { + var firstInputByKey = orderedKeys + .Select((key, index) => new { key, orderedWork[index].Input }) + .GroupBy(item => item.key) + .ToDictionary(group => group.Key, group => group.First().Input); + var generated = _generator.Generate( + misses, + misses.Select(key => firstInputByKey[key]).ToArray(), + config); + if (generated.Embeddings.Count != misses.Length) + throw new InvalidDataException("The embedding provider returned an incomplete result set."); + + generatedByKey = generated.Embeddings.ToDictionary(embedding => embedding.Key); + if (generatedByKey.Count != misses.Length + || misses.Any(key => !generatedByKey.ContainsKey(key))) + { + throw new InvalidDataException("The embedding provider returned mismatched cache keys."); + } + + _persistence.SaveEmbeddings(generated.Embeddings); + usage = generated.Usage; + } + + var ordered = orderedKeys.Select(key => cached.TryGetValue(key, out var hit) + ? hit + : generatedByKey[key]).ToArray(); + return new EmbeddingResolutionResult( + ordered, + new DiagnosticReport(), + orderedKeys.Count(key => cached.ContainsKey(key)), + orderedKeys.Count(key => !cached.ContainsKey(key)), + usage); + } + + private static EmbeddingResolutionResult Empty() => + new([], new DiagnosticReport(), 0, 0, EmbeddingUsage.None); + + internal static EmbeddingResolutionResult Unavailable( + DocsAnalysisEmbeddingMode mode, + string reason) + { + var diagnostics = new DiagnosticReport(); + diagnostics.Add(new Diagnostic( + DocumentationAnalyzer.EmbeddingUnavailableRuleCode, + mode == DocsAnalysisEmbeddingMode.Required ? Severity.Error : Severity.Warning, + $"Embedding analysis is unavailable: {reason}", + "docs-analysis.embeddings", + Hint: mode == DocsAnalysisEmbeddingMode.Required + ? "Restore the configured local embedding endpoint and safe analysis cache, or set embeddings.mode to prefer/off." + : "Lexical analysis will continue without embeddings.")); + return new EmbeddingResolutionResult([], diagnostics, 0, 0, EmbeddingUsage.None); + } +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingModels.cs b/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingModels.cs new file mode 100644 index 0000000..7a5a1a6 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Embeddings/EmbeddingModels.cs @@ -0,0 +1,29 @@ +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Core.Docs.Analysis.Embeddings; + +/// One contextual claim text requiring an embedding. +public sealed record EmbeddingWorkItem(string ContextualHash, string Input); + +/// Provider-reported token counts for local cost visibility. +public sealed record EmbeddingUsage(int PromptTokens = 0, int TotalTokens = 0) +{ + public static EmbeddingUsage None { get; } = new(); + + internal EmbeddingUsage Add(EmbeddingUsage other) => + new(checked(PromptTokens + other.PromptTokens), checked(TotalTokens + other.TotalTokens)); +} + +/// Validated provider vectors and aggregate usage for one generation request. +public sealed record EmbeddingGenerationResult( + IReadOnlyList Embeddings, + EmbeddingUsage Usage); + +/// Cache-aware embedding resolution result. +public sealed record EmbeddingResolutionResult( + IReadOnlyList Embeddings, + DiagnosticReport Diagnostics, + int CacheHits, + int CacheMisses, + EmbeddingUsage Usage); diff --git a/src/KyberWeave.Core/Docs/Analysis/Embeddings/OpenAiCompatibleEmbeddingGenerator.cs b/src/KyberWeave.Core/Docs/Analysis/Embeddings/OpenAiCompatibleEmbeddingGenerator.cs new file mode 100644 index 0000000..1461539 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Embeddings/OpenAiCompatibleEmbeddingGenerator.cs @@ -0,0 +1,326 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Networking; + +namespace KyberWeave.Core.Docs.Analysis.Embeddings; + +/// Calls an OpenAI-compatible embeddings endpoint that is confined to loopback. +public sealed class OpenAiCompatibleEmbeddingGenerator : IEmbeddingGenerator, IDisposable +{ + private readonly HttpClient _client; + private readonly Func> _resolveHost; + private readonly Func _readEnvironment; + + /// Creates a generator whose connections are resolved and pinned to loopback. + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "HttpClient owns and disposes the handler supplied by CreateLocalOnlyHandler.")] + public OpenAiCompatibleEmbeddingGenerator() + : this(CreateLocalOnlyHandler(), ResolveHost, Environment.GetEnvironmentVariable) + { + } + + /// + /// The injectable handler and resolvers keep the transport boundary deterministic in tests. + /// Production callers should use the parameterless constructor, whose socket callback repeats + /// loopback validation at connection time to avoid a DNS rebinding window. + /// + internal OpenAiCompatibleEmbeddingGenerator( + HttpMessageHandler handler, + Func> resolveHost, + Func readEnvironment) + { + ArgumentNullException.ThrowIfNull(handler); + _resolveHost = resolveHost ?? throw new ArgumentNullException(nameof(resolveHost)); + _readEnvironment = readEnvironment ?? throw new ArgumentNullException(nameof(readEnvironment)); + _client = new HttpClient(handler, disposeHandler: true) + { + // HttpClient's default 100s timeout would silently clamp config.TimeoutSeconds. + Timeout = Timeout.InfiniteTimeSpan + }; + } + + public string GetProviderFingerprint(DocsAnalysisEmbeddingConfig config) + { + ArgumentNullException.ThrowIfNull(config); + var endpoint = RequireEndpoint(config); + var identity = $"openai-compatible/v1\n{endpoint.AbsoluteUri}"; + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + } + + public EmbeddingGenerationResult Generate( + IReadOnlyCollection keys, + IReadOnlyCollection inputs, + DocsAnalysisEmbeddingConfig config) + { + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(config); + + var orderedKeys = keys.ToArray(); + var orderedInputs = inputs.ToArray(); + if (orderedKeys.Length != orderedInputs.Length) + throw new ArgumentException("Embedding keys and inputs must have the same count.", nameof(inputs)); + if (orderedKeys.Length == 0) + return new EmbeddingGenerationResult([], EmbeddingUsage.None); + if (config.BatchSize <= 0) + throw new ArgumentOutOfRangeException(nameof(config), "Embedding batch size must be positive."); + if (config.TimeoutSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(config), "Embedding timeout must be positive."); + + var endpoint = RequireEndpoint(config); + EnsureLoopback(endpoint); + var model = string.IsNullOrWhiteSpace(config.Model) + ? throw new ArgumentException("An embedding model is required.", nameof(config)) + : config.Model; + var bearerToken = string.IsNullOrWhiteSpace(config.ApiKeyEnv) + ? null + : _readEnvironment(config.ApiKeyEnv); + + var result = new List(orderedKeys.Length); + var usage = EmbeddingUsage.None; + for (var offset = 0; offset < orderedInputs.Length; offset += config.BatchSize) + { + var count = Math.Min(config.BatchSize, orderedInputs.Length - offset); + var batchInputs = orderedInputs.AsSpan(offset, count).ToArray(); + var parsed = SendBatchAsync( + endpoint, + model, + config.Dimensions, + batchInputs, + bearerToken, + config.TimeoutSeconds) + .GetAwaiter() + .GetResult(); + for (var index = 0; index < parsed.Vectors.Count; index++) + result.Add(new StoredEmbedding(orderedKeys[offset + index], parsed.Vectors[index])); + usage = usage.Add(parsed.Usage); + } + + return new EmbeddingGenerationResult(result, usage); + } + + private async Task SendBatchAsync( + Uri endpoint, + string model, + int? dimensions, + IReadOnlyList inputs, + string? bearerToken, + int timeoutSeconds) + { + using var request = CreateRequest(endpoint, model, dimensions, inputs, bearerToken); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + using var response = await _client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellation.Token).ConfigureAwait(false); + if (IsRedirect(response.StatusCode)) + throw new InvalidOperationException("The local embedding endpoint returned a redirect; redirects are disabled."); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"The local embedding endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase})."); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellation.Token) + .ConfigureAwait(false); + using var json = await JsonDocument.ParseAsync( + stream, + cancellationToken: cancellation.Token).ConfigureAwait(false); + return ParseBatch(json.RootElement, inputs.Count, dimensions); + } + + public void Dispose() => _client.Dispose(); + + private static HttpRequestMessage CreateRequest( + Uri endpoint, + string model, + int? dimensions, + IReadOnlyList inputs, + string? bearerToken) + { + var payload = new Dictionary(StringComparer.Ordinal) + { + ["input"] = inputs, + ["model"] = model, + ["encoding_format"] = "float" + }; + if (dimensions is not null) payload["dimensions"] = dimensions.Value; + + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json") + }; + if (!string.IsNullOrWhiteSpace(bearerToken)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + return request; + } + + private static ParsedBatch ParseBatch(JsonElement root, int expectedCount, int? configuredDimensions) + { + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) + throw new InvalidDataException("The embedding response must contain a data array with complete indices."); + + var vectors = new IReadOnlyList?[expectedCount]; + var dimensions = configuredDimensions; + foreach (var item in data.EnumerateArray()) + { + if (!item.TryGetProperty("index", out var indexValue) + || !indexValue.TryGetInt32(out var index) + || index < 0 + || index >= expectedCount + || vectors[index] is not null) + { + throw new InvalidDataException("Embedding response index values must be unique and in range."); + } + + if (!item.TryGetProperty("embedding", out var embedding) + || embedding.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Each embedding response item must contain a finite vector."); + } + + var raw = embedding.EnumerateArray().Select(ReadFiniteFloat).ToArray(); + dimensions ??= raw.Length; + if (raw.Length == 0 || raw.Length != dimensions.Value) + throw new InvalidDataException("Embedding vector dimensions must be non-zero and consistent."); + vectors[index] = Normalize(raw); + } + + if (vectors.Any(vector => vector is null)) + throw new InvalidDataException("Embedding response index values must be complete."); + + return new ParsedBatch( + vectors.Select(vector => vector!).ToArray(), + ReadUsage(root)); + } + + private static float ReadFiniteFloat(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var number) + || !double.IsFinite(number) || number > float.MaxValue || number < -float.MaxValue) + { + throw new InvalidDataException("Embedding vectors must contain only finite values."); + } + + return (float)number; + } + + private static IReadOnlyList Normalize(IReadOnlyList vector) + { + var squaredNorm = vector.Sum(value => (double)value * value); + if (!double.IsFinite(squaredNorm) || squaredNorm <= 0) + throw new InvalidDataException("Embedding vectors must have a finite, non-zero norm."); + + var norm = Math.Sqrt(squaredNorm); + return vector.Select(value => (float)(value / norm)).ToArray(); + } + + private static EmbeddingUsage ReadUsage(JsonElement root) + { + if (!root.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object) + return EmbeddingUsage.None; + + return new EmbeddingUsage( + ReadNonNegativeInt(usage, "prompt_tokens"), + ReadNonNegativeInt(usage, "total_tokens")); + } + + private static int ReadNonNegativeInt(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) return 0; + if (!value.TryGetInt32(out var count) || count < 0) + throw new InvalidDataException($"Embedding usage '{propertyName}' must be a non-negative integer."); + return count; + } + + private void EnsureLoopback(Uri endpoint) + { + IReadOnlyList addresses; + try + { + addresses = _resolveHost(endpoint.DnsSafeHost); + } + catch (SocketException ex) + { + throw new InvalidOperationException("The embedding endpoint host could not be resolved to loopback.", ex); + } + + if (addresses.Count == 0 || addresses.Any(address => !LoopbackAddress.IsLoopback(address))) + throw new InvalidOperationException("The embedding endpoint must resolve only to loopback addresses."); + } + + private static Uri RequireEndpoint(DocsAnalysisEmbeddingConfig config) + { + var endpoint = config.Endpoint + ?? throw new ArgumentException("An embedding endpoint is required.", nameof(config)); + if (!endpoint.IsAbsoluteUri + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) + { + throw new ArgumentException("The embedding endpoint must be an absolute HTTP(S) URI.", nameof(config)); + } + + return endpoint; + } + + private static bool IsRedirect(HttpStatusCode statusCode) => + (int)statusCode is >= 300 and <= 399; + + private static IReadOnlyList ResolveHost(string host) => Dns.GetHostAddresses(host); + + private static SocketsHttpHandler CreateLocalOnlyHandler() => new() + { + AllowAutoRedirect = false, + ConnectCallback = ConnectLoopback + }; + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "A successful NetworkStream takes socket ownership; every failed socket is disposed in the catch block.")] + private static async ValueTask ConnectLoopback( + SocketsHttpConnectionContext context, + CancellationToken cancellationToken) + { + var addresses = await Dns.GetHostAddressesAsync( + context.DnsEndPoint.Host, + cancellationToken).ConfigureAwait(false); + if (addresses.Length == 0 || addresses.Any(address => !LoopbackAddress.IsLoopback(address))) + throw new HttpRequestException("The embedding endpoint must resolve only to loopback addresses."); + + Exception? lastFailure = null; + foreach (var address in addresses) + { + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync( + new IPEndPoint(address, context.DnsEndPoint.Port), + cancellationToken).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch (SocketException ex) + { + socket.Dispose(); + lastFailure = ex; + } + } + + throw new HttpRequestException("No loopback address accepted the embedding connection.", lastFailure); + } + + private sealed record ParsedBatch( + IReadOnlyList> Vectors, + EmbeddingUsage Usage); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Glossary/GlossaryModels.cs b/src/KyberWeave.Core/Docs/Analysis/Glossary/GlossaryModels.cs new file mode 100644 index 0000000..9f27f5e --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Glossary/GlossaryModels.cs @@ -0,0 +1,49 @@ +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Core.Docs.Analysis.Glossary; + +/// The exact review states supported by a managed glossary row. +public enum GlossarySenseStatus +{ + Proposed, + Approved, + Rejected +} + +/// A generated glossary proposal and the claims that support it. +public sealed record GlossaryProposal( + string Term, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases, + IReadOnlyList EvidenceIds); + +/// One parsed sense returned by glossary lookup. +public sealed record GlossarySense( + string Id, + GlossarySenseStatus Status, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases, + IReadOnlyList? EvidenceIds = null) +{ + /// Opaque claim identities from the managed generated-evidence block. + public IReadOnlyList EvidenceIds { get; init; } = EvidenceIds ?? []; +} + +/// All managed senses declared for one glossary term. +public sealed record GlossaryLookupResult(string Term, IReadOnlyList Senses); + +/// Parsed glossary data used by analysis and conversational lookup. +public sealed record ManagedGlossaryLoadResult( + AnalysisGlossary AnalysisGlossary, + IReadOnlyList Terms); + +/// The result of previewing or writing a conservative glossary merge. +public sealed record GlossaryUpdateResult( + string RelativePath, + string Markdown, + bool Changed, + bool Written, + DiagnosticReport Diagnostics); diff --git a/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryGraphContributor.cs b/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryGraphContributor.cs new file mode 100644 index 0000000..75ae3f0 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryGraphContributor.cs @@ -0,0 +1,192 @@ +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Model; + +namespace KyberWeave.Core.Docs.Analysis.Glossary; + +/// Projects approved managed-glossary knowledge into DocGraph. +public sealed class ManagedGlossaryGraphContributor : IDocGraphContributor +{ + private readonly IReadOnlyList _terms; + + public ManagedGlossaryGraphContributor(ManagedGlossaryLoadResult glossary) + { + ArgumentNullException.ThrowIfNull(glossary); + _terms = SnapshotApproved(glossary.Terms); + ValidateGraphIdentities(_terms); + } + + public DocGraphContribution Contribute(DocumentSet documents, ICodeGraphResolver codeGraph) + { + ArgumentNullException.ThrowIfNull(documents); + ArgumentNullException.ThrowIfNull(codeGraph); + + var nodes = new List(); + var edges = new List(); + var emittedNodeIds = new HashSet(StringComparer.Ordinal); + + foreach (var term in _terms.OrderBy(item => item.Term, StringComparer.Ordinal)) + { + var termId = TermId(term.Term); + AddTermNode(nodes, emittedNodeIds, termId, term.Term); + foreach (var sense in term.Senses.OrderBy(item => item.Id, StringComparer.Ordinal)) + { + var senseId = $"sense:{sense.Id}"; + AddNode(nodes, emittedNodeIds, new DocGraphNode( + senseId, + "Sense", + new Dictionary(StringComparer.Ordinal) + { + ["term"] = term.Term, + ["definition"] = sense.Definition + })); + edges.Add(new DocGraphEdge("HAS_SENSE", termId, senseId)); + + foreach (var alias in sense.Aliases + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.Ordinal)) + { + var aliasId = TermId(alias); + AddTermNode(nodes, emittedNodeIds, aliasId, alias); + if (!StringComparer.Ordinal.Equals(aliasId, termId)) + edges.Add(new DocGraphEdge("ALIAS_OF", aliasId, senseId)); + } + + foreach (var scope in sense.Scopes + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal)) + { + AddScopeEdges(edges, senseId, scope, codeGraph); + } + + foreach (var evidenceId in sense.EvidenceIds + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal)) + { + edges.Add(new DocGraphEdge("EVIDENCED_BY", senseId, evidenceId)); + } + } + } + + return new DocGraphContribution(nodes, edges.Distinct().ToArray()); + } + + private static IReadOnlyList SnapshotApproved( + IReadOnlyList terms) + { + ArgumentNullException.ThrowIfNull(terms); + return terms + .Select(term => new TermSnapshot( + term.Term, + term.Senses + .Where(sense => sense.Status == GlossarySenseStatus.Approved) + .Select(sense => new SenseSnapshot( + sense.Id, + sense.Definition, + sense.Scopes.ToArray(), + sense.Aliases.ToArray(), + sense.EvidenceIds.ToArray())) + .ToArray())) + .Where(term => term.Senses.Count > 0) + .ToArray(); + } + + private static void ValidateGraphIdentities(IReadOnlyList terms) + { + var termIdentities = new Dictionary(StringComparer.Ordinal); + var senseIds = new HashSet(StringComparer.Ordinal); + + foreach (var term in terms) + { + RegisterTermIdentity(termIdentities, term.Term); + foreach (var sense in term.Senses) + { + var senseId = $"sense:{sense.Id}"; + if (!senseIds.Add(senseId)) + { + throw new InvalidDataException( + $"Managed glossary graph identity collision for sense id '{senseId}'."); + } + + foreach (var alias in sense.Aliases) + RegisterTermIdentity(termIdentities, alias); + } + } + } + + private static void RegisterTermIdentity( + IDictionary identities, + string term) + { + var id = TermId(term); + var semanticIdentity = term.Trim().ToLowerInvariant(); + if (identities.TryGetValue(id, out var existing) + && !StringComparer.Ordinal.Equals(existing, semanticIdentity)) + { + throw new InvalidDataException( + $"Managed glossary graph identity collision for term id '{id}'."); + } + + identities[id] = semanticIdentity; + } + + private static void AddScopeEdges( + ICollection edges, + string senseId, + string scope, + ICodeGraphResolver codeGraph) + { + const string componentPrefix = "component:"; + const string codePrefix = "code-ref:"; + if (scope.StartsWith(componentPrefix, StringComparison.Ordinal)) + { + var component = scope[componentPrefix.Length..]; + if (component.Length > 0) + edges.Add(new DocGraphEdge("SCOPED_TO", senseId, componentPrefix + component)); + return; + } + + if (!scope.StartsWith(codePrefix, StringComparison.Ordinal)) return; + var symbol = scope[codePrefix.Length..]; + foreach (var node in codeGraph.ResolveSymbol(symbol).OrderBy(node => node.Id, StringComparer.Ordinal)) + edges.Add(new DocGraphEdge("SCOPED_TO", senseId, node.Id)); + } + + private static void AddTermNode( + ICollection nodes, + ISet emittedNodeIds, + string id, + string name) => + AddNode(nodes, emittedNodeIds, new DocGraphNode( + id, + "Term", + new Dictionary(StringComparer.Ordinal) { ["name"] = name })); + + private static void AddNode( + ICollection nodes, + ISet emittedNodeIds, + DocGraphNode node) + { + if (emittedNodeIds.Add(node.Id)) nodes.Add(node); + } + + private static string TermId(string term) + { + var slug = new string(term.Trim().ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-') + .ToArray()); + while (slug.Contains("--", StringComparison.Ordinal)) + slug = slug.Replace("--", "-", StringComparison.Ordinal); + slug = slug.Trim('-'); + return $"term:{slug}"; + } + + private sealed record TermSnapshot(string Term, IReadOnlyList Senses); + + private sealed record SenseSnapshot( + string Id, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases, + IReadOnlyList EvidenceIds); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryService.cs b/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryService.cs new file mode 100644 index 0000000..0c450aa --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Glossary/ManagedGlossaryService.cs @@ -0,0 +1,1127 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Parsing; +using Markdig; +using Markdig.Syntax; +using YamlDotNet.Serialization; + +namespace KyberWeave.Core.Docs.Analysis.Glossary; + +/// +/// Previews, merges, validates, and reads the one managed documentation glossary. +/// +/// +/// A row is human-owned as soon as it is reviewed or differs from the generated row +/// fingerprint. The merger therefore removes only proposals it can prove it generated +/// unchanged, and never writes any source document other than the configured glossary. +/// +public sealed class ManagedGlossaryService +{ + public const string ValidationRuleCode = "KW-DOC-GLOSSARY-001"; + + private const string Header = "| Sense ID | Status | Definition | Scope | Aliases |"; + private const string Separator = "|---|---|---|---|---|"; + private const string EvidenceStart = ""; + private static readonly ISerializer FrontmatterSerializer = new SerializerBuilder().Build(); + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UsePreciseSourceLocation() + .Build(); + + private readonly string _repositoryRoot; + private readonly KyberWeaveConfig _config; + private readonly TimeProvider _timeProvider; + private readonly string _relativePath; + private readonly string _filePath; + + public ManagedGlossaryService( + string repositoryRoot, + KyberWeaveConfig config, + TimeProvider timeProvider) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(timeProvider); + + _repositoryRoot = Path.GetFullPath(repositoryRoot); + _config = config; + _timeProvider = timeProvider; + _relativePath = config.DocsAnalysis.ResolveGlossaryPath(config.Ontology); + _filePath = ResolveContainedPath(_repositoryRoot, _relativePath); + RejectLinkedPath(_repositoryRoot, _filePath); + } + + /// Builds the exact Markdown a write would produce without changing disk. + public GlossaryUpdateResult Preview(IReadOnlyList proposals) => + Merge(proposals, write: false); + + /// Atomically writes the glossary when the conservative merge changes it. + public GlossaryUpdateResult Write(IReadOnlyList proposals) => + Merge(proposals, write: true); + + /// Validates the configured glossary's managed structure and approved scopes. + public DiagnosticReport Validate() + { + var diagnostics = new DiagnosticReport(); + if (!File.Exists(_filePath)) return diagnostics; + + string markdown; + try + { + markdown = File.ReadAllText(_filePath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + diagnostics.Add(Error($"The managed glossary could not be read: {exception.Message}")); + return diagnostics; + } + + var document = Parse(markdown); + ValidateFrontmatter(document, diagnostics); + var catalog = ReadCatalog(); + + foreach (var section in document.Sections) + { + ValidateSectionStructure(section, diagnostics); + if (!section.HasTable) + { + diagnostics.Add(Error($"Glossary term '{section.Term}' must contain the managed sense table.")); + continue; + } + + foreach (var row in section.Rows) + { + if (!TryParseStatus(row.StatusText, out var status)) + { + diagnostics.Add(Error( + $"Glossary sense '{row.Id}' has unknown status '{row.StatusText}'. " + + "Use proposed, approved, or rejected.")); + continue; + } + + if (status == GlossarySenseStatus.Approved) + { + ValidateApprovedSense(row, catalog.Components, diagnostics); + } + } + } + + return diagnostics; + } + + /// Loads approved senses for analysis and every sense for lookup. + public ManagedGlossaryLoadResult Load() + { + if (!File.Exists(_filePath)) + { + return new ManagedGlossaryLoadResult(new AnalysisGlossary([]), []); + } + + var diagnostics = Validate(); + if (diagnostics.HasErrors) + { + throw new InvalidDataException( + $"{ValidationRuleCode}: The managed glossary is invalid: " + + string.Join(" ", diagnostics.Items.Select(item => item.Message))); + } + + var document = Parse(File.ReadAllText(_filePath)); + var terms = document.Sections.Select(section => new GlossaryLookupResult( + section.Term, + section.Rows.Select(row => ToSense(row, section.Evidence)).ToArray())).ToArray(); + var approved = document.Sections + .SelectMany(section => section.Rows.Select(row => (section.Term, Row: row))) + .Where(item => TryParseStatus(item.Row.StatusText, out var status) + && status == GlossarySenseStatus.Approved) + .Select(item => new ApprovedGlossarySense( + item.Row.Id, + item.Term, + item.Row.Definition, + item.Row.Scopes, + item.Row.Aliases)) + .ToArray(); + return new ManagedGlossaryLoadResult(new AnalysisGlossary(approved), terms); + } + + /// Returns all senses for a term using case-insensitive term matching. + public GlossaryLookupResult Lookup(string term) + { + ArgumentException.ThrowIfNullOrWhiteSpace(term); + var normalized = term.Trim().ToLowerInvariant(); + return Load().Terms.FirstOrDefault(candidate => + StringComparer.OrdinalIgnoreCase.Equals(candidate.Term, normalized)) + ?? new GlossaryLookupResult(normalized, []); + } + + private GlossaryUpdateResult Merge(IReadOnlyList proposals, bool write) + { + ArgumentNullException.ThrowIfNull(proposals); + ValidateProposals(proposals); + + var exists = File.Exists(_filePath); + var original = exists ? File.ReadAllText(_filePath) : string.Empty; + if (!exists && proposals.Count == 0) + { + return new GlossaryUpdateResult(_relativePath, string.Empty, false, false, new DiagnosticReport()); + } + + var initial = exists ? original : CreateDocument(FirstCatalogOwner()); + var merged = MergeDocument(initial, proposals); + var changed = !StringComparer.Ordinal.Equals(original, merged); + if (exists && changed) + { + merged = DemoteToNeedsReview(merged); + } + + var diagnostics = ValidateMarkdown(merged); + if (diagnostics.HasErrors) + { + throw new InvalidDataException( + $"{ValidationRuleCode}: Refusing to write an invalid managed glossary: " + + string.Join(" ", diagnostics.Items.Select(item => item.Message))); + } + + var written = false; + if (write && changed) + { + AtomicWrite(merged); + written = true; + } + + return new GlossaryUpdateResult(_relativePath, merged, changed, written, diagnostics); + } + + private string MergeDocument(string markdown, IReadOnlyList proposals) + { + var newline = markdown.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var hadFinalNewline = markdown.EndsWith(newline, StringComparison.Ordinal); + var lines = SplitLines(markdown); + var document = Parse(markdown); + var proposalsByTerm = proposals + .GroupBy(proposal => proposal.Term.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + + foreach (var section in document.Sections.OrderByDescending(section => section.StartLine)) + { + proposalsByTerm.Remove(section.Term, out var termProposals); + var replacement = MergeSection( + lines.GetRange(section.StartLine, section.EndLine - section.StartLine), + section.Term, + termProposals ?? []); + lines.RemoveRange(section.StartLine, section.EndLine - section.StartLine); + lines.InsertRange(section.StartLine, replacement); + } + + foreach (var entry in proposalsByTerm.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)) + { + if (lines.Count > 0 && lines[^1].Length != 0) lines.Add(string.Empty); + lines.AddRange(NewTermSection(entry.Key, entry.Value)); + } + + var result = string.Join(newline, lines); + return hadFinalNewline || result.Length > 0 ? result + newline : result; + } + + private static List MergeSection( + List sectionLines, + string term, + IReadOnlyList proposals) + { + var section = ParseSection(term, sectionLines, 0, sectionLines.Count); + if (!section.HasTable) + { + if (proposals.Count == 0) return sectionLines; + if (sectionLines.Count > 0 && sectionLines[^1].Length != 0) sectionLines.Add(string.Empty); + sectionLines.Add(Header); + sectionLines.Add(Separator); + foreach (var proposal in proposals) + { + var id = SenseId(proposal); + sectionLines.Add(RowMarkdown(id, proposal)); + sectionLines.Add(string.Empty); + sectionLines.AddRange(EvidenceLines(id, proposal)); + } + + return sectionLines; + } + + var removals = new HashSet(); + var replacements = new Dictionary(); + var evidenceToAppend = new List(); + var used = new HashSet(); + + foreach (var row in section.Rows) + { + if (!StringComparer.Ordinal.Equals(row.StatusText, "proposed") + || !section.Evidence.TryGetValue(row.Id, out var evidence)) + { + continue; + } + + var untouched = IsUntouchedGenerated(row, evidence); + var proposal = proposals.FirstOrDefault(candidate => + !used.Contains(candidate) && SameScopes(row.Scopes, candidate.Scopes)); + if (proposal is not null && untouched) + { + replacements[row.LineIndex] = RowMarkdown(row.Id, proposal); + for (var index = evidence.StartLine; index < evidence.EndLine; index++) removals.Add(index); + evidenceToAppend.AddRange(EvidenceLines(row.Id, proposal)); + used.Add(proposal); + } + else if (proposal is null && untouched) + { + removals.Add(row.LineIndex); + for (var index = evidence.StartLine; index < evidence.EndLine; index++) removals.Add(index); + } + } + + var insertAt = section.Rows.Count > 0 + ? section.Rows.Max(row => row.LineIndex) + 1 + : section.TableSeparatorLine + 1; + var newRows = proposals.Where(proposal => !used.Contains(proposal)).Select(proposal => + { + var id = SenseId(proposal); + evidenceToAppend.AddRange(EvidenceLines(id, proposal)); + return RowMarkdown(id, proposal); + }).ToArray(); + + var merged = new List(); + for (var index = 0; index < sectionLines.Count; index++) + { + if (index == insertAt) merged.AddRange(newRows); + if (removals.Contains(index)) continue; + merged.Add(replacements.TryGetValue(index, out var replacement) + ? replacement + : sectionLines[index]); + } + + if (insertAt == sectionLines.Count) merged.AddRange(newRows); + if (evidenceToAppend.Count > 0) + { + if (merged.Count > 0 && merged[^1].Length != 0) merged.Add(string.Empty); + foreach (var evidence in ChunkEvidence(evidenceToAppend)) + { + merged.AddRange(evidence); + merged.Add(string.Empty); + } + + if (merged.Count > 0 && merged[^1].Length == 0) merged.RemoveAt(merged.Count - 1); + } + + return merged; + } + + private static IEnumerable> ChunkEvidence(IReadOnlyList lines) + { + var start = 0; + while (start < lines.Count) + { + var end = start; + while (end < lines.Count && !StringComparer.Ordinal.Equals(lines[end], EvidenceEnd)) end++; + if (end < lines.Count) end++; + yield return lines.Skip(start).Take(end - start).ToArray(); + start = end; + } + } + + private static IReadOnlyList NewTermSection(string term, IReadOnlyList proposals) + { + var lines = new List { $"## {term}", string.Empty, Header, Separator }; + foreach (var proposal in proposals) + { + var id = SenseId(proposal); + lines.Add(RowMarkdown(id, proposal)); + } + + foreach (var proposal in proposals) + { + lines.Add(string.Empty); + lines.AddRange(EvidenceLines(SenseId(proposal), proposal)); + } + + return lines; + } + + private string CreateDocument(string owner) + { + var today = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime) + .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var safeOwner = FrontmatterSerializer.Serialize(owner).TrimEnd(); + return $""" + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: needs-review + owner: {safeOwner} + last-reviewed: {today} + --- + + # Glossary + + """; + } + + private string FirstCatalogOwner() + { + var catalog = ReadCatalog(); + return catalog.FirstOwner ?? throw new InvalidOperationException( + "The catalog has no data-row owner; a managed glossary cannot be created without an owner."); + } + + private CatalogData ReadCatalog() + { + var catalogPath = ResolveContainedPath(_repositoryRoot, _config.Ontology.ResolvedCatalogPath); + if (!File.Exists(catalogPath)) + { + return new CatalogData(null, new HashSet(StringComparer.Ordinal)); + } + + string? firstOwner = null; + var components = new HashSet(StringComparer.Ordinal); + foreach (var line in File.ReadLines(catalogPath)) + { + if (!line.StartsWith('|')) continue; + // Catalog column configuration intentionally uses the raw pipe-split indices, + // including the empty cell before a leading pipe, matching DocumentLoader. + var cells = line.Split('|', StringSplitOptions.None) + .Select(cell => cell.Trim()) + .ToArray(); + var maxColumn = Math.Max( + _config.Ontology.CatalogComponentColumn, + _config.Ontology.CatalogOwnerColumn); + if (cells.Length <= maxColumn) continue; + + var component = cells[_config.Ontology.CatalogComponentColumn]; + var owner = cells[_config.Ontology.CatalogOwnerColumn]; + if (component.Length == 0 + || component.StartsWith("---", StringComparison.Ordinal) + || StringComparer.Ordinal.Equals(component, "Component")) + { + continue; + } + + components.Add(component); + if (firstOwner is null && owner.Length > 0 && !owner.StartsWith("---", StringComparison.Ordinal)) + { + firstOwner = owner; + } + } + + return new CatalogData(firstOwner, components); + } + + private DiagnosticReport ValidateMarkdown(string markdown) + { + var document = Parse(markdown); + var diagnostics = new DiagnosticReport(); + ValidateFrontmatter(document, diagnostics); + var catalog = ReadCatalog(); + foreach (var section in document.Sections) + { + ValidateSectionStructure(section, diagnostics); + if (!section.HasTable) + { + diagnostics.Add(Error($"Glossary term '{section.Term}' must contain the managed sense table.")); + continue; + } + + foreach (var row in section.Rows) + { + if (!TryParseStatus(row.StatusText, out var status)) + { + diagnostics.Add(Error($"Glossary sense '{row.Id}' has unknown status '{row.StatusText}'.")); + } + else if (status == GlossarySenseStatus.Approved) + { + ValidateApprovedSense(row, catalog.Components, diagnostics); + } + } + } + + return diagnostics; + } + + private void ValidateFrontmatter(ParsedDocument document, DiagnosticReport diagnostics) + { + if (!document.FrontmatterValid) + { + diagnostics.Add(Error( + "The managed glossary frontmatter is invalid: " + + (document.FrontmatterError ?? "unknown frontmatter error"))); + return; + } + + if (!StringComparer.Ordinal.Equals(document.Frontmatter.GetValueOrDefault("doc-type"), "reference")) + { + diagnostics.Add(Error("The managed glossary must use doc-type 'reference'.")); + } + + if (string.IsNullOrWhiteSpace(document.Frontmatter.GetValueOrDefault("id"))) + { + diagnostics.Add(Error("The managed glossary must declare a document id.")); + } + + if (string.IsNullOrWhiteSpace(document.Frontmatter.GetValueOrDefault("title"))) + { + diagnostics.Add(Error("The managed glossary must declare a title.")); + } + + if (!document.Frontmatter.ContainsKey("owner") + || string.IsNullOrWhiteSpace(document.Frontmatter["owner"])) + { + diagnostics.Add(Error("The managed glossary must declare an owner.")); + } + + if (!document.Frontmatter.TryGetValue("last-reviewed", out var lastReviewed) + || !DateOnly.TryParseExact( + lastReviewed, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + diagnostics.Add(Error( + "The managed glossary must preserve an ISO yyyy-MM-dd last-reviewed date.")); + } + + var status = document.Frontmatter.GetValueOrDefault("status"); + if (status is not ("current" or "needs-review")) + { + diagnostics.Add(Error( + "The managed glossary status must be 'current' or 'needs-review'.")); + } + } + + private void ValidateSectionStructure(ParsedSection section, DiagnosticReport diagnostics) + { + if (section.MalformedRows > 0) + { + diagnostics.Add(Error( + $"Glossary term '{section.Term}' contains {section.MalformedRows} malformed sense row(s).")); + } + + if (section.MalformedEvidence) + { + diagnostics.Add(Error( + $"Glossary term '{section.Term}' contains malformed or unbalanced generated evidence markup.")); + } + + var rowIds = new HashSet(StringComparer.Ordinal); + foreach (var row in section.Rows) + { + if (string.IsNullOrWhiteSpace(row.Id)) + { + diagnostics.Add(Error($"Glossary term '{section.Term}' contains a sense without an ID.")); + } + else if (!rowIds.Add(row.Id)) + { + diagnostics.Add(Error($"Glossary term '{section.Term}' repeats sense ID '{row.Id}'.")); + } + } + + foreach (var evidenceId in section.Evidence.Keys) + { + if (!rowIds.Contains(evidenceId)) + { + diagnostics.Add(Error( + $"Glossary term '{section.Term}' has generated evidence for unknown sense '{evidenceId}'.")); + } + } + } + + private void ValidateApprovedSense( + ParsedRow row, + IReadOnlySet components, + DiagnosticReport diagnostics) + { + if (string.IsNullOrWhiteSpace(row.Definition)) + { + diagnostics.Add(Error($"Approved glossary sense '{row.Id}' requires a definition.")); + } + + if (row.Scopes.Count == 0) + { + diagnostics.Add(Error($"Approved glossary sense '{row.Id}' requires at least one scope.")); + return; + } + + foreach (var scope in row.Scopes) + { + if (scope.StartsWith("component:", StringComparison.Ordinal)) + { + var component = scope["component:".Length..]; + if (component.Length == 0 || !components.Contains(component)) + { + diagnostics.Add(Error( + $"Approved glossary sense '{row.Id}' references unknown component scope '{scope}'.")); + } + } + else if (scope.StartsWith("code-ref:", StringComparison.Ordinal)) + { + if (scope["code-ref:".Length..].Length == 0) + { + diagnostics.Add(Error( + $"Approved glossary sense '{row.Id}' has an empty code-ref scope.")); + } + } + else + { + diagnostics.Add(Error( + $"Approved glossary sense '{row.Id}' has unsupported scope '{scope}'.")); + } + } + } + + private Diagnostic Error(string message) => new( + ValidationRuleCode, + Severity.Error, + message, + "managed glossary", + _relativePath, + "Use the managed glossary table shape and approved component: or code-ref: scopes."); + + private static ParsedDocument Parse(string markdown) + { + var frontmatter = new Dictionary(StringComparer.Ordinal); + var frontmatterValid = true; + string? frontmatterError = null; + var read = MarkdownFrontmatterReader.Read(markdown); + if (!read.HasFrontmatter) + { + frontmatterValid = false; + frontmatterError = "The YAML frontmatter block is missing or unterminated."; + } + else + { + try + { + frontmatter = MarkdownFrontmatterReader.Deserializer + .Deserialize>(read.Yaml) + ?? new Dictionary(StringComparer.Ordinal); + } + catch (Exception exception) + { + frontmatterValid = false; + frontmatterError = exception.Message; + } + } + + var lines = SplitLines(markdown); + var bodyStart = read.HasFrontmatter ? Math.Max(0, read.BodyStartLine - 1) : 0; + return new ParsedDocument( + frontmatter, + ParseSections(lines, bodyStart), + frontmatterValid, + frontmatterError); + } + + private static ParsedDocument Parse(List lines) => new( + new Dictionary(StringComparer.Ordinal), + ParseSections(lines, 0), + true, + null); + + private static IReadOnlyList ParseSections(List lines, int bodyStart) + { + var body = string.Join('\n', lines.Skip(bodyStart)); + var syntax = Markdown.Parse(body, MarkdownPipeline); + var headings = syntax.Descendants() + .Where(heading => heading.Level == 2) + .Select(heading => + { + var line = heading.Line + bodyStart; + var term = HeadingTerm(lines[line]); + return (Line: line, Term: term); + }) + .ToList(); + + var sections = new List(); + for (var index = 0; index < headings.Count; index++) + { + var start = headings[index].Line; + var end = index + 1 < headings.Count ? headings[index + 1].Line : lines.Count; + sections.Add(ParseSection(headings[index].Term, lines, start, end)); + } + + return sections; + } + + private static ParsedSection ParseSection(string term, IReadOnlyList lines, int start, int end) + { + var fencedLines = FencedLineIndexes(lines, start, end); + var headerLine = -1; + for (var index = start; index < end; index++) + { + if (!fencedLines.Contains(index) + && StringComparer.Ordinal.Equals(lines[index].Trim(), Header)) + { + headerLine = index; + break; + } + } + + var separatorLine = headerLine >= 0 && headerLine + 1 < end ? headerLine + 1 : -1; + var rows = new List(); + var malformedRows = 0; + if (separatorLine >= 0 && IsTableSeparator(lines[separatorLine])) + { + for (var index = separatorLine + 1; index < end; index++) + { + if (fencedLines.Contains(index)) + { + malformedRows++; + break; + } + + if (lines[index].Trim().Length == 0) break; + if (lines[index].TrimStart().StartsWith(EvidenceStart, StringComparison.Ordinal)) break; + if (!lines[index].TrimStart().StartsWith('|')) + { + malformedRows++; + break; + } + + var cells = SplitTableRow(lines[index]); + if (cells.Count == 5) + { + rows.Add(new ParsedRow( + cells[0], + cells[1], + cells[2], + SplitSemicolon(cells[3]), + SplitSemicolon(cells[4]), + index, + lines[index])); + } + else + { + malformedRows++; + } + } + } + + var evidence = new Dictionary(StringComparer.Ordinal); + var malformedEvidence = false; + for (var index = start; index < end; index++) + { + if (fencedLines.Contains(index)) continue; + var trimmed = lines[index].Trim(); + if (!trimmed.StartsWith(EvidenceStart, StringComparison.Ordinal)) continue; + var id = Attribute(trimmed, "sense"); + if (id is null) + { + malformedEvidence = true; + continue; + } + var blockEnd = index + 1; + while (blockEnd < end && !StringComparer.Ordinal.Equals(lines[blockEnd].Trim(), EvidenceEnd)) blockEnd++; + if (blockEnd < end) + { + blockEnd++; + } + else + { + malformedEvidence = true; + } + + if (evidence.ContainsKey(id)) malformedEvidence = true; + var evidenceIds = lines + .Skip(index + 1) + .Take(Math.Max(0, blockEnd - index - 1)) + .Select(line => line.Trim()) + .Where(line => line.StartsWith("- ", StringComparison.Ordinal)) + .Select(line => line[2..].Trim()) + .Where(idValue => idValue.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var evidenceLines = lines + .Skip(index + 1) + .Take(Math.Max(0, blockEnd - index - 2)) + .ToArray(); + evidence[id] = new ParsedEvidence( + index, + blockEnd, + Attribute(trimmed, "fingerprint"), + evidenceIds, + evidenceLines); + index = blockEnd - 1; + } + + return new ParsedSection( + term, + start, + end, + headerLine >= 0 && separatorLine >= 0 && IsTableSeparator(lines[separatorLine]), + separatorLine, + rows, + evidence, + malformedRows, + malformedEvidence); + } + + private static IReadOnlySet FencedLineIndexes( + IReadOnlyList lines, + int start, + int end) + { + var fenced = new HashSet(); + char? marker = null; + var openingLength = 0; + + for (var index = start; index < end; index++) + { + var trimmed = lines[index].TrimStart(); + if (marker is not null) + { + fenced.Add(index); + var length = MarkerLength(trimmed, marker.Value); + if (length >= openingLength && trimmed[length..].Trim().Length == 0) + { + marker = null; + openingLength = 0; + } + + continue; + } + + var candidate = trimmed.Length > 0 ? trimmed[0] : '\0'; + if (candidate is not ('`' or '~')) continue; + var candidateLength = MarkerLength(trimmed, candidate); + if (candidateLength < 3) continue; + + marker = candidate; + openingLength = candidateLength; + fenced.Add(index); + } + + return fenced; + } + + private static int MarkerLength(string line, char marker) + { + var length = 0; + while (length < line.Length && line[length] == marker) length++; + return length; + } + + private static GlossarySense ToSense( + ParsedRow row, + IReadOnlyDictionary evidence) + { + _ = TryParseStatus(row.StatusText, out var status); + return new GlossarySense( + row.Id, + status, + row.Definition, + row.Scopes, + row.Aliases, + evidence.TryGetValue(row.Id, out var block) ? block.EvidenceIds : []); + } + + private static bool IsUntouchedGenerated(ParsedRow row, ParsedEvidence evidence) + { + return evidence.Fingerprint is not null + && StringComparer.Ordinal.Equals( + evidence.Fingerprint, + OwnershipFingerprint(row.RawLine, evidence.EvidenceLines)); + } + + private static string RowMarkdown(string id, GlossaryProposal proposal) => + $"| {EscapeCell(id)} | proposed | {EscapeCell(proposal.Definition)} | " + + $"{EscapeCell(string.Join("; ", proposal.Scopes))} | {EscapeCell(string.Join("; ", proposal.Aliases))} |"; + + private static IReadOnlyList EvidenceLines(string id, GlossaryProposal proposal) + { + var row = RowMarkdown(id, proposal); + var evidenceLines = proposal.EvidenceIds + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Select(evidenceId => $"- {EscapeEvidence(evidenceId)}") + .ToArray(); + var lines = new List + { + $"{EvidenceStart} sense=\"{EscapeAttribute(id)}\" fingerprint=\"{OwnershipFingerprint(row, evidenceLines)}\" -->" + }; + lines.AddRange(evidenceLines); + lines.Add(EvidenceEnd); + return lines; + } + + private static string SenseId(GlossaryProposal proposal) + { + var slug = new string(proposal.Term.Trim().ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-') + .ToArray()).Trim('-'); + if (slug.Length == 0) slug = "term"; + var identity = string.Join('\n', + proposal.Term.Trim().ToLowerInvariant(), + string.Join(';', proposal.Scopes.Order(StringComparer.Ordinal)), + string.Join(';', proposal.Aliases.Order(StringComparer.Ordinal))); + var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(identity)))[..8]; + return $"{slug}-{hash}"; + } + + private static string OwnershipFingerprint(string row, IReadOnlyList evidenceLines) + { + var content = string.Join('\n', + row, + string.Join("\n", evidenceLines)); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))); + } + + private static string DemoteToNeedsReview(string markdown) + { + var newline = markdown.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var lines = SplitLines(markdown); + var frontmatterEnd = lines.Count > 0 && StringComparer.Ordinal.Equals(lines[0], "---") + ? lines.FindIndex(1, line => StringComparer.Ordinal.Equals(line, "---")) + : -1; + for (var index = 1; index < frontmatterEnd; index++) + { + if (lines[index].StartsWith("status:", StringComparison.Ordinal)) + { + lines[index] = "status: needs-review"; + break; + } + } + + return string.Join(newline, lines) + newline; + } + + private void AtomicWrite(string markdown) + { + var directory = Path.GetDirectoryName(_filePath) + ?? throw new InvalidOperationException("The glossary path has no parent directory."); + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine(directory, $".{Path.GetFileName(_filePath)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporaryPath, markdown); + File.Move(temporaryPath, _filePath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + } + } + + private static void ValidateProposals(IReadOnlyList proposals) + { + foreach (var proposal in proposals) + { + ArgumentException.ThrowIfNullOrWhiteSpace(proposal.Term); + ArgumentNullException.ThrowIfNull(proposal.Scopes); + ArgumentNullException.ThrowIfNull(proposal.Aliases); + ArgumentNullException.ThrowIfNull(proposal.EvidenceIds); + if (proposal.Scopes.Count == 0) + { + throw new ArgumentException("Glossary proposals require at least one scope.", nameof(proposals)); + } + + RequireSingleLine(proposal.Term, "term", proposals); + RequireSingleLine(proposal.Definition, "definition", proposals); + foreach (var scope in proposal.Scopes) + { + RequireSingleLine(scope, "scope", proposals); + if (scope.Contains(';', StringComparison.Ordinal)) + { + throw new ArgumentException( + "Each glossary proposal scope must be a single component: or code-ref: value.", + nameof(proposals)); + } + } + + foreach (var alias in proposal.Aliases) + { + RequireSingleLine(alias, "alias", proposals); + if (alias.Contains(';', StringComparison.Ordinal)) + { + throw new ArgumentException( + "Each glossary proposal alias must be supplied as a separate value.", + nameof(proposals)); + } + } + } + } + + private static void RequireSingleLine( + string value, + string field, + IReadOnlyList proposals) + { + if (value.Contains('\r', StringComparison.Ordinal) + || value.Contains('\n', StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Glossary proposal {field} values must fit on one Markdown table line.", + nameof(proposals)); + } + } + + private static bool TryParseStatus(string value, out GlossarySenseStatus status) + { + status = value switch + { + "proposed" => GlossarySenseStatus.Proposed, + "approved" => GlossarySenseStatus.Approved, + "rejected" => GlossarySenseStatus.Rejected, + _ => default + }; + return value is "proposed" or "approved" or "rejected"; + } + + private static bool SameScopes(IReadOnlyList left, IReadOnlyList right) => + left.Count == right.Count + && left.Order(StringComparer.Ordinal).SequenceEqual(right.Order(StringComparer.Ordinal), StringComparer.Ordinal); + + private static string ResolveContainedPath(string repositoryRoot, string relativePath) + { + if (Path.IsPathRooted(relativePath)) + { + throw new ArgumentException("The glossary path must be repository-relative.", nameof(relativePath)); + } + + var resolved = Path.GetFullPath( + relativePath.Replace('/', Path.DirectorySeparatorChar), + repositoryRoot); + var prefix = repositoryRoot.EndsWith(Path.DirectorySeparatorChar) + ? repositoryRoot + : repositoryRoot + Path.DirectorySeparatorChar; + if (!resolved.StartsWith(prefix, OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new ArgumentException("The glossary path must remain inside the repository.", nameof(relativePath)); + } + + return resolved; + } + + private static void RejectLinkedPath(string repositoryRoot, string targetPath) + { + var relative = Path.GetRelativePath(repositoryRoot, targetPath); + var current = repositoryRoot; + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (!File.Exists(current) && !Directory.Exists(current)) continue; + + var attributes = File.GetAttributes(current); + if ((attributes & FileAttributes.ReparsePoint) != 0 + || new FileInfo(current).LinkTarget is not null + || new DirectoryInfo(current).LinkTarget is not null) + { + throw new InvalidOperationException( + $"The managed glossary path contains a symbolic link or reparse point: '{current}'."); + } + } + } + + private static bool IsTableSeparator(string line) + { + var cells = SplitTableRow(line); + return cells.Count == 5 && cells.All(cell => cell.Length >= 3 && cell.All(character => character == '-')); + } + + private static List SplitTableRow(string line) + { + var cells = new List(); + var current = new StringBuilder(); + var trimmed = line.Trim(); + for (var index = 0; index < trimmed.Length; index++) + { + var character = trimmed[index]; + if (character == '\\' && index + 1 < trimmed.Length && trimmed[index + 1] == '|') + { + current.Append('|'); + index++; + } + else if (character == '|') + { + cells.Add(current.ToString().Trim()); + current.Clear(); + } + else + { + current.Append(character); + } + } + + if (current.Length > 0) cells.Add(current.ToString().Trim()); + if (cells.Count > 0 && cells[0].Length == 0) cells.RemoveAt(0); + return cells; + } + + private static string HeadingTerm(string line) + { + var trimmed = line.TrimStart(); + var hashes = 0; + while (hashes < trimmed.Length && trimmed[hashes] == '#') hashes++; + return trimmed[hashes..].Trim().TrimEnd('#').TrimEnd(); + } + + private static IReadOnlyList SplitSemicolon(string value) => + value.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + private static string? Attribute(string marker, string name) + { + var prefix = name + "=\""; + var start = marker.IndexOf(prefix, StringComparison.Ordinal); + if (start < 0) return null; + start += prefix.Length; + var end = marker.IndexOf('"', start); + return end < 0 ? null : marker[start..end]; + } + + private static List SplitLines(string markdown) + { + var lines = markdown.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + if (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + return lines; + } + + private static string EscapeCell(string value) => value.Replace("|", "\\|", StringComparison.Ordinal); + private static string EscapeAttribute(string value) => value.Replace("\"", """, StringComparison.Ordinal); + private static string EscapeEvidence(string value) => value.Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); + + private sealed record ParsedDocument( + IReadOnlyDictionary Frontmatter, + IReadOnlyList Sections, + bool FrontmatterValid, + string? FrontmatterError); + + private sealed record ParsedSection( + string Term, + int StartLine, + int EndLine, + bool HasTable, + int TableSeparatorLine, + IReadOnlyList Rows, + IReadOnlyDictionary Evidence, + int MalformedRows, + bool MalformedEvidence); + + private sealed record ParsedRow( + string Id, + string StatusText, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases, + int LineIndex, + string RawLine); + + private sealed record ParsedEvidence( + int StartLine, + int EndLine, + string? Fingerprint, + IReadOnlyList EvidenceIds, + IReadOnlyList EvidenceLines); + private sealed record CatalogData(string? FirstOwner, IReadOnlySet Components); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Model/AnalysisModels.cs b/src/KyberWeave.Core/Docs/Analysis/Model/AnalysisModels.cs new file mode 100644 index 0000000..a40d39a --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Model/AnalysisModels.cs @@ -0,0 +1,142 @@ +using System.Security.Cryptography; +using System.Text; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; + +namespace KyberWeave.Core.Docs.Analysis.Model; + +/// The documentation condition represented by an analysis candidate. +public enum AnalysisRuleKind +{ + Duplicate, + Conflict, + Terminology +} + +/// A reviewer's disposition for an analysis candidate. +public enum AnalysisVerdictLabel +{ + Duplicate, + Conflict, + DistinctSenses, + Benign, + Uncertain +} + +/// A durable reviewer decision associated with a content-addressed candidate. +public sealed record AnalysisVerdict( + string CandidateId, + AnalysisVerdictLabel Label, + double Confidence, + string Rationale, + IReadOnlyList? EvidenceIds = null, + string? RecommendedCanonicalLocation = null, + IReadOnlyList? ProposedGlossarySenses = null); + +/// A glossary sense proposed by an external reviewer. +public sealed record ProposedGlossarySense( + string Term, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases); + +/// An approved, scope-qualified meaning for a glossary term. +public sealed record ApprovedGlossarySense( + string Id, + string Term, + string Definition, + IReadOnlyList Scopes, + IReadOnlyList Aliases); + +/// Approved glossary data used to suppress fully explained terminology findings. +public sealed record AnalysisGlossary(IReadOnlyList Senses) +{ + /// True when each occurrence maps to exactly one approved sense. + public bool Covers(string term, IReadOnlyList claims) + { + ArgumentException.ThrowIfNullOrWhiteSpace(term); + ArgumentNullException.ThrowIfNull(claims); + + var senses = Senses + .Where(sense => StringComparer.OrdinalIgnoreCase.Equals(sense.Term, term)) + .ToArray(); + if (senses.Length == 0) return false; + + return claims.All(claim => senses.Count(sense => AppliesTo(sense, claim)) == 1); + } + + private static bool AppliesTo(ApprovedGlossarySense sense, Claim claim) => + sense.Scopes.Any(scope => + scope.StartsWith("component:", StringComparison.Ordinal) + ? StringComparer.OrdinalIgnoreCase.Equals(scope["component:".Length..], claim.Component) + : scope.StartsWith("code-ref:", StringComparison.Ordinal) + && claim.CodeRefs.Contains(scope["code-ref:".Length..], StringComparer.Ordinal)); +} + +/// One clustered analysis finding and its evidence claims. +public sealed record AnalysisCandidate( + string Id, + AnalysisRuleKind Kind, + IReadOnlyList Claims, + CandidateScore Score, + bool IsExact = false, + string? Term = null, + IReadOnlyList? Sources = null, + AnalysisVerdict? Verdict = null) +{ + public IReadOnlyList Sources { get; init; } = Sources ?? []; +} + +/// Local-only measurements explaining analysis cost and truncation. +public sealed record AnalysisMetrics( + int ExtractedClaims, + int GraphComparisons, + int LexicalComparisons, + int EmbeddingComparisons, + int GraphCandidates, + int LexicalCandidates, + int EmbeddingCandidates, + bool Truncated); + +/// The candidates, diagnostics, and cost measurements from one analysis pass. +public sealed record DocumentationAnalysisResult( + IReadOnlyList Candidates, + DiagnosticReport Diagnostics, + AnalysisMetrics Metrics); + +/// Stable IDs for review decisions that survive source-file moves. +public static class AnalysisCandidateId +{ + public static string Compute( + AnalysisRuleKind kind, + string? normalizedTerm, + IEnumerable claimContentHashes, + string analyzerVersion, + string rubricVersion) + { + ArgumentNullException.ThrowIfNull(claimContentHashes); + ArgumentException.ThrowIfNullOrWhiteSpace(analyzerVersion); + ArgumentException.ThrowIfNullOrWhiteSpace(rubricVersion); + + var hashes = claimContentHashes.Order(StringComparer.Ordinal); + var identity = string.Join('\n', + kind.ToString().ToLowerInvariant(), + normalizedTerm?.Trim().ToLowerInvariant() ?? string.Empty, + analyzerVersion, + rubricVersion, + string.Join('\n', hashes)); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + } +} + +/// Identity of one cached embedding. +public sealed record EmbeddingCacheKey( + string ContextualHash, + string ProviderFingerprint, + string Model, + int? Dimensions, + string Encoding = "float"); + +/// A normalized vector ready for exact cosine comparison. +public sealed record StoredEmbedding(EmbeddingCacheKey Key, IReadOnlyList Vector); diff --git a/src/KyberWeave.Core/Docs/Analysis/Persistence/AnalysisCacheSafety.cs b/src/KyberWeave.Core/Docs/Analysis/Persistence/AnalysisCacheSafety.cs new file mode 100644 index 0000000..d36d0b8 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Persistence/AnalysisCacheSafety.cs @@ -0,0 +1,118 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO.Enumeration; +using KyberWeave.Core.Processes; + +namespace KyberWeave.Core.Docs.Analysis.Persistence; + +/// Checks whether local analysis state is protected from accidental commits. +public static class AnalysisCacheSafety +{ + private const string CacheIgnoreEntry = "cache/"; + + /// + /// Returns true only when the repository-owned state ignore file contains the narrow + /// cache-directory entry used by Kyber-Weave. + /// + public static bool IsSafe(string repositoryRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + + var ignorePath = Path.Combine( + Path.GetFullPath(repositoryRoot), + ".kyber-weave", + ".gitignore"); + try + { + return File.Exists(ignorePath) + && HasEffectiveCacheIgnore(ignorePath) + && !HasTrackedCacheEntry(repositoryRoot); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool HasEffectiveCacheIgnore(string ignorePath) + { + var protectedByExactEntry = false; + foreach (var line in File.ReadLines(ignorePath)) + { + if (StringComparer.Ordinal.Equals(line, CacheIgnoreEntry)) + { + protectedByExactEntry = true; + continue; + } + + if (protectedByExactEntry + && line.StartsWith('!') + && NegatesCacheProtection(line[1..])) + { + protectedByExactEntry = false; + } + } + + return protectedByExactEntry; + } + + private static bool NegatesCacheProtection(string pattern) + { + if (pattern.StartsWith('/')) pattern = pattern[1..]; + if (pattern.Length == 0 || pattern.StartsWith('#')) return false; + + const string databaseRelativePath = "cache/docs-analysis.sqlite3"; + if (pattern.EndsWith('/')) + return databaseRelativePath.StartsWith(pattern, StringComparison.Ordinal); + + if (!pattern.Contains('/')) + { + return FileSystemName.MatchesSimpleExpression( + pattern, + Path.GetFileName(databaseRelativePath), + ignoreCase: OperatingSystem.IsWindows()); + } + + // SimpleExpression covers the glob forms used by gitignore for this fixed target. + // Character classes and `**` are treated conservatively because collapsing `**` to + // `*` under-matches git's recursive semantics, and accepting an uncertain negation + // would make a cache appear safer than it is. + return pattern.Contains('[') || pattern.Contains("**", StringComparison.Ordinal) + ? pattern.Contains("cache", StringComparison.Ordinal) + : FileSystemName.MatchesSimpleExpression( + pattern, + databaseRelativePath, + ignoreCase: OperatingSystem.IsWindows()); + } + + private static bool HasTrackedCacheEntry(string repositoryRoot) + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = repositoryRoot, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("ls-files"); + startInfo.ArgumentList.Add("--cached"); + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add(".kyber-weave/cache"); + + try + { + var result = ProcessRunner.Run(startInfo, string.Empty); + return result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.StandardOutput); + } + catch (Win32Exception) + { + // Without git, persistence cannot prove that the cache is outside the index. + return true; + } + } +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Persistence/PersistenceModels.cs b/src/KyberWeave.Core/Docs/Analysis/Persistence/PersistenceModels.cs new file mode 100644 index 0000000..9a1c6e4 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Persistence/PersistenceModels.cs @@ -0,0 +1,24 @@ +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Core.Docs.Analysis.Persistence; + +/// A line-addressable claim occurrence retained for review evidence. +public sealed record PersistedClaim( + string Id, + string ContentHash, + string ContextualHash, + string DocumentIdentity, + string FilePath, + int StartLine, + int EndLine, + string Text); + +/// The content and rubric identity against which a reviewer verdict was made. +public sealed record PersistedCandidateFingerprint( + string CandidateId, + AnalysisRuleKind Kind, + string? NormalizedTerm, + string CandidateSetHash, + string AnalyzerVersion, + string RubricVersion, + IReadOnlyList ClaimContentHashes); diff --git a/src/KyberWeave.Core/Docs/Analysis/Persistence/SqliteAnalysisPersistence.cs b/src/KyberWeave.Core/Docs/Analysis/Persistence/SqliteAnalysisPersistence.cs new file mode 100644 index 0000000..bb23831 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Persistence/SqliteAnalysisPersistence.cs @@ -0,0 +1,625 @@ +using System.Buffers.Binary; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Processes; + +namespace KyberWeave.Core.Docs.Analysis.Persistence; + +/// +/// Stores local analysis evidence through the system sqlite3 executable. +/// +/// +/// The CLI boundary avoids adding a native SQLite package with its transitive advisory. +/// Every caller-controlled value is encoded as a SQLite BLOB literal, so prose cannot +/// become SQL even when it contains quotes, newlines, or SQL-looking text. +/// +public sealed class SqliteAnalysisPersistence : IAnalysisPersistence +{ + public const int SchemaVersion = 1; + + private const double NormalizedVectorTolerance = 0.0001; + private const int BusyTimeoutMilliseconds = 250; + private const int BusyAttempts = 3; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + private readonly string _repositoryRoot; + + public SqliteAnalysisPersistence(string repositoryRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + + var fullRoot = Path.GetFullPath(repositoryRoot); + _repositoryRoot = fullRoot; + DatabasePath = Path.Combine( + fullRoot, + ".kyber-weave", + "cache", + "docs-analysis.sqlite3"); + + EnsureSafePersistencePath(fullRoot, DatabasePath); + if (!AnalysisCacheSafety.IsSafe(fullRoot) || !CanStartSqlite()) return; + + Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!); + EnsureSafePersistencePath(fullRoot, DatabasePath); + InitializeSchema(); + IsAvailable = true; + } + + public bool IsAvailable { get; } + + public string DatabasePath { get; } + + public IReadOnlyDictionary LoadClaims( + IReadOnlyCollection claimIds) + { + ArgumentNullException.ThrowIfNull(claimIds); + if (!IsAvailable || claimIds.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var requested = new HashSet(claimIds, StringComparer.Ordinal); + return ReadPayloadRows("analysis_claims", "id") + .Where(pair => requested.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + } + + public void SaveClaims(IReadOnlyCollection claims) + { + ArgumentNullException.ThrowIfNull(claims); + EnsureAvailable(); + foreach (var claim in claims) Validate(claim); + + ExecuteWriteTransaction(claims.Select(ClaimUpsert)); + } + + public IReadOnlyDictionary LoadCandidateFingerprints( + IReadOnlyCollection candidateIds) + { + ArgumentNullException.ThrowIfNull(candidateIds); + if (!IsAvailable || candidateIds.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var requested = new HashSet(candidateIds, StringComparer.Ordinal); + return ReadPayloadRows("analysis_candidates", "candidate_id") + .Where(pair => requested.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + } + + public void SaveCandidateFingerprints( + IReadOnlyCollection candidates) + { + ArgumentNullException.ThrowIfNull(candidates); + EnsureAvailable(); + foreach (var candidate in candidates) Validate(candidate); + + ExecuteWriteTransaction(candidates.Select(CandidateUpsert)); + } + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) + { + ArgumentNullException.ThrowIfNull(candidateIds); + if (!IsAvailable || candidateIds.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var requested = new HashSet(candidateIds, StringComparer.Ordinal); + return ReadPayloadRows("analysis_verdicts", "candidate_id") + .Where(pair => requested.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + } + + public void SaveVerdicts(IReadOnlyCollection verdicts) + { + ArgumentNullException.ThrowIfNull(verdicts); + EnsureAvailable(); + foreach (var verdict in verdicts) Validate(verdict); + + ExecuteWriteTransaction(verdicts.Select(VerdictUpsert)); + } + + /// + public void SaveReviewImport( + IReadOnlyCollection claims, + IReadOnlyCollection candidates, + IReadOnlyCollection verdicts) + { + ArgumentNullException.ThrowIfNull(claims); + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(verdicts); + EnsureAvailable(); + foreach (var claim in claims) Validate(claim); + foreach (var candidate in candidates) Validate(candidate); + foreach (var verdict in verdicts) Validate(verdict); + + var candidateIds = candidates + .Select(candidate => candidate.CandidateId) + .ToHashSet(StringComparer.Ordinal); + if (verdicts.Any(verdict => !candidateIds.Contains(verdict.CandidateId))) + throw new ArgumentException( + "Every imported verdict must have a current candidate fingerprint.", + nameof(verdicts)); + + ExecuteWriteTransaction( + claims.Select(ClaimUpsert) + .Concat(candidates.Select(CandidateUpsert)) + .Concat(verdicts.Select(VerdictUpsert))); + } + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) + { + ArgumentNullException.ThrowIfNull(keys); + if (!IsAvailable || keys.Count == 0) + return new Dictionary(); + + var requested = new HashSet(keys); + var output = ExecuteSqlite( + ".mode tabs\n" + + ".headers off\n" + + "SELECT hex(contextual_hash), hex(provider_fingerprint), hex(model), " + + "dimensions, hex(encoding), hex(vector) FROM analysis_embeddings;"); + var loaded = new Dictionary(); + foreach (var line in Lines(output)) + { + try + { + var fields = line.Split('\t'); + if (fields.Length != 6 + || !int.TryParse(fields[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var storedDimensions)) + { + throw new InvalidDataException("An embedding row has an invalid shape."); + } + + var key = new EmbeddingCacheKey( + Text(fields[0]), + Text(fields[1]), + Text(fields[2]), + storedDimensions < 0 ? null : storedDimensions, + Text(fields[4])); + if (!requested.Contains(key)) continue; + + var vector = DecodeVector(fields[5]); + Validate(new StoredEmbedding(key, vector)); + loaded[key] = new StoredEmbedding(key, vector); + } + catch (Exception exception) when ( + exception is FormatException or ArgumentException or InvalidDataException) + { + throw CorruptCache("An embedding row is invalid.", exception); + } + } + + return loaded; + } + + public void SaveEmbeddings(IReadOnlyCollection embeddings) + { + ArgumentNullException.ThrowIfNull(embeddings); + EnsureAvailable(); + foreach (var embedding in embeddings) Validate(embedding); + + ExecuteWriteTransaction(embeddings.Select(embedding => + { + var dimensions = embedding.Key.Dimensions ?? -1; + return "INSERT INTO analysis_embeddings(" + + "contextual_hash, provider_fingerprint, model, dimensions, encoding, vector) VALUES (" + + $"{Blob(embedding.Key.ContextualHash)}, " + + $"{Blob(embedding.Key.ProviderFingerprint)}, " + + $"{Blob(embedding.Key.Model)}, " + + $"{dimensions.ToString(CultureInfo.InvariantCulture)}, " + + $"{Blob(embedding.Key.Encoding)}, " + + $"{Blob(EncodeVector(embedding.Vector))}) " + + "ON CONFLICT(contextual_hash, provider_fingerprint, model, dimensions, encoding) " + + "DO UPDATE SET vector = excluded.vector;"; + })); + } + + private void InitializeSchema() + { + var versionText = ExecuteSqlite("PRAGMA user_version;").Trim(); + if (!int.TryParse(versionText, NumberStyles.None, CultureInfo.InvariantCulture, out var version)) + throw CorruptCache("The analysis cache schema version is not an integer."); + if (version > SchemaVersion) + throw CorruptCache( + $"The analysis cache uses schema version {version}, newer than supported version {SchemaVersion}."); + + if (version == 0) + { + ExecuteSqlite( + "PRAGMA foreign_keys = ON;\n" + + "BEGIN IMMEDIATE;\n" + + "CREATE TABLE IF NOT EXISTS analysis_claims (" + + "id BLOB PRIMARY KEY NOT NULL, payload BLOB NOT NULL);\n" + + "CREATE TABLE IF NOT EXISTS analysis_candidates (" + + "candidate_id BLOB PRIMARY KEY NOT NULL, payload BLOB NOT NULL);\n" + + "CREATE TABLE IF NOT EXISTS analysis_verdicts (" + + "candidate_id BLOB PRIMARY KEY NOT NULL, payload BLOB NOT NULL, " + + "FOREIGN KEY(candidate_id) REFERENCES analysis_candidates(candidate_id) ON DELETE CASCADE);\n" + + "CREATE TABLE IF NOT EXISTS analysis_embeddings (" + + "contextual_hash BLOB NOT NULL, provider_fingerprint BLOB NOT NULL, " + + "model BLOB NOT NULL, dimensions INTEGER NOT NULL, encoding BLOB NOT NULL, " + + "vector BLOB NOT NULL, PRIMARY KEY(" + + "contextual_hash, provider_fingerprint, model, dimensions, encoding));\n" + + SchemaValidationSql() + + $"PRAGMA user_version = {SchemaVersion.ToString(CultureInfo.InvariantCulture)};\n" + + "COMMIT;"); + return; + } + + ExecuteSqlite("PRAGMA foreign_keys = ON;\n" + SchemaValidationSql()); + } + + private static string SchemaValidationSql() => + "SELECT id, payload FROM analysis_claims LIMIT 0;\n" + + "SELECT candidate_id, payload FROM analysis_candidates LIMIT 0;\n" + + "SELECT candidate_id, payload FROM analysis_verdicts LIMIT 0;\n" + + "SELECT contextual_hash, provider_fingerprint, model, dimensions, encoding, vector " + + "FROM analysis_embeddings LIMIT 0;\n"; + + private IReadOnlyDictionary ReadPayloadRows(string table, string keyColumn) + { + var output = ExecuteSqlite( + ".mode tabs\n" + + ".headers off\n" + + $"SELECT hex({keyColumn}), hex(payload) FROM {table};"); + var loaded = new Dictionary(StringComparer.Ordinal); + foreach (var line in Lines(output)) + { + var fields = line.Split('\t'); + if (fields.Length != 2) throw CorruptCache($"Table '{table}' contains an invalid row."); + + try + { + var key = Text(fields[0]); + var value = JsonSerializer.Deserialize(Convert.FromHexString(fields[1]), SerializerOptions) + ?? throw CorruptCache($"Table '{table}' contains an empty payload."); + ValidateLoadedPayload(table, key, value); + loaded[key] = value; + } + catch (Exception exception) when ( + exception is FormatException or JsonException or NotSupportedException or ArgumentException) + { + throw CorruptCache($"Table '{table}' contains an invalid payload.", exception); + } + } + + return loaded; + } + + private void ExecuteWriteTransaction(IEnumerable statements) + { + var script = new StringBuilder("PRAGMA foreign_keys = ON;\nBEGIN IMMEDIATE;\n"); + foreach (var statement in statements) script.AppendLine(statement); + script.AppendLine("COMMIT;"); + ExecuteSqlite(script.ToString()); + } + + private string ExecuteSqlite(string input) + { + EnsureSafePersistencePath(_repositoryRoot, DatabasePath); + var boundedInput = $".timeout {BusyTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}\n{input}"; + for (var attempt = 1; attempt <= BusyAttempts; attempt++) + { + var startInfo = CreateStartInfo(); + startInfo.ArgumentList.Add("-batch"); + startInfo.ArgumentList.Add("-bail"); + startInfo.ArgumentList.Add(DatabasePath); + + ProcessResult result; + try + { + result = ProcessRunner.Run(startInfo, boundedInput); + } + catch (Win32Exception exception) + { + throw new InvalidOperationException("The 'sqlite3' executable is unavailable.", exception); + } + + if (result.ExitCode == 0) return result.StandardOutput; + + var reason = result.StandardError.Trim(); + if (IsBusy(reason)) + { + if (attempt < BusyAttempts) continue; + throw new InvalidOperationException( + $"The documentation analysis cache remained locked after {BusyAttempts} bounded attempts. " + + FailureDetail(result, reason)); + } + + if (IsOperationalFailure(reason)) + { + throw new InvalidOperationException( + "The documentation analysis cache could not be accessed. " + FailureDetail(result, reason)); + } + + throw CorruptCache(FailureDetail(result, reason)); + } + + throw new InvalidOperationException("The documentation analysis cache operation did not complete."); + } + + private static bool CanStartSqlite() + { + var startInfo = CreateStartInfo(); + startInfo.ArgumentList.Add("--version"); + try + { + return ProcessRunner.Run(startInfo, string.Empty).ExitCode == 0; + } + catch (Win32Exception) + { + return false; + } + } + + private static ProcessStartInfo CreateStartInfo() => + new("sqlite3") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + private static IEnumerable Lines(string output) => + output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static string JsonBlob(T value) => Blob(JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions)); + + private static string ClaimUpsert(PersistedClaim claim) => + $"INSERT INTO analysis_claims(id, payload) VALUES ({Blob(claim.Id)}, {JsonBlob(claim)}) " + + "ON CONFLICT(id) DO UPDATE SET payload = excluded.payload;"; + + private static string CandidateUpsert(PersistedCandidateFingerprint candidate) => + "INSERT INTO analysis_candidates(candidate_id, payload) VALUES " + + $"({Blob(candidate.CandidateId)}, {JsonBlob(candidate)}) " + + "ON CONFLICT(candidate_id) DO UPDATE SET payload = excluded.payload;"; + + private static string VerdictUpsert(AnalysisVerdict verdict) => + "INSERT INTO analysis_verdicts(candidate_id, payload) VALUES " + + $"({Blob(verdict.CandidateId)}, {JsonBlob(verdict)}) " + + "ON CONFLICT(candidate_id) DO UPDATE SET payload = excluded.payload;"; + + private static string Blob(string value) => Blob(Encoding.UTF8.GetBytes(value)); + + private static string Blob(byte[] value) => $"X'{Convert.ToHexString(value)}'"; + + private static string Text(string hex) => Encoding.UTF8.GetString(Convert.FromHexString(hex)); + + private static byte[] EncodeVector(IReadOnlyList vector) + { + var bytes = new byte[checked(vector.Count * sizeof(float))]; + for (var index = 0; index < vector.Count; index++) + BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(index * sizeof(float)), vector[index]); + return bytes; + } + + private static IReadOnlyList DecodeVector(string hex) + { + byte[] bytes; + try + { + bytes = Convert.FromHexString(hex); + } + catch (FormatException exception) + { + throw CorruptCache("An embedding vector is not valid hexadecimal.", exception); + } + + if (bytes.Length == 0 || bytes.Length % sizeof(float) != 0) + throw CorruptCache("An embedding vector has an invalid byte length."); + + var vector = new float[bytes.Length / sizeof(float)]; + for (var index = 0; index < vector.Length; index++) + vector[index] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(index * sizeof(float))); + return vector; + } + + private static void Validate(PersistedClaim claim) + { + ArgumentNullException.ThrowIfNull(claim); + Required(claim.Id, nameof(claim.Id)); + Required(claim.ContentHash, nameof(claim.ContentHash)); + Required(claim.ContextualHash, nameof(claim.ContextualHash)); + Required(claim.DocumentIdentity, nameof(claim.DocumentIdentity)); + Required(claim.FilePath, nameof(claim.FilePath)); + ArgumentOutOfRangeException.ThrowIfLessThan(claim.StartLine, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(claim.EndLine, claim.StartLine); + Required(claim.Text, nameof(claim.Text)); + } + + private static void Validate(PersistedCandidateFingerprint candidate) + { + ArgumentNullException.ThrowIfNull(candidate); + Required(candidate.CandidateId, nameof(candidate.CandidateId)); + if (!Enum.IsDefined(candidate.Kind)) + throw new ArgumentException("The candidate kind is invalid.", nameof(candidate)); + Required(candidate.CandidateSetHash, nameof(candidate.CandidateSetHash)); + Required(candidate.AnalyzerVersion, nameof(candidate.AnalyzerVersion)); + Required(candidate.RubricVersion, nameof(candidate.RubricVersion)); + ArgumentNullException.ThrowIfNull(candidate.ClaimContentHashes); + if (candidate.ClaimContentHashes.Count == 0 + || candidate.ClaimContentHashes.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException( + "At least one non-empty claim content hash is required.", + nameof(candidate)); + } + } + + private static void Validate(AnalysisVerdict verdict) + { + ArgumentNullException.ThrowIfNull(verdict); + Required(verdict.CandidateId, nameof(verdict.CandidateId)); + if (!Enum.IsDefined(verdict.Label)) + throw new ArgumentException("The verdict label is invalid.", nameof(verdict)); + if (!double.IsFinite(verdict.Confidence) || verdict.Confidence is < 0 or > 1) + throw new ArgumentException("Verdict confidence must be between zero and one.", nameof(verdict)); + Required(verdict.Rationale, nameof(verdict.Rationale)); + if (verdict.EvidenceIds?.Any(string.IsNullOrWhiteSpace) == true) + throw new ArgumentException("Evidence ids cannot be empty.", nameof(verdict)); + if (verdict.RecommendedCanonicalLocation is not null + && string.IsNullOrWhiteSpace(verdict.RecommendedCanonicalLocation)) + { + throw new ArgumentException( + "The recommended canonical location cannot be empty.", + nameof(verdict)); + } + if (verdict.ProposedGlossarySenses is not null) + { + foreach (var sense in verdict.ProposedGlossarySenses) + Validate(sense); + } + } + + private static void Validate(ProposedGlossarySense sense) + { + ArgumentNullException.ThrowIfNull(sense); + Required(sense.Term, nameof(sense.Term)); + Required(sense.Definition, nameof(sense.Definition)); + ArgumentNullException.ThrowIfNull(sense.Scopes); + if (sense.Scopes.Count == 0 || sense.Scopes.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Glossary sense scopes cannot be empty.", nameof(sense)); + ArgumentNullException.ThrowIfNull(sense.Aliases); + if (sense.Aliases.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Glossary sense aliases cannot be empty.", nameof(sense)); + } + + private static void Validate(StoredEmbedding embedding) + { + ArgumentNullException.ThrowIfNull(embedding); + ArgumentNullException.ThrowIfNull(embedding.Key); + Required(embedding.Key.ContextualHash, nameof(embedding.Key.ContextualHash)); + Required(embedding.Key.ProviderFingerprint, nameof(embedding.Key.ProviderFingerprint)); + Required(embedding.Key.Model, nameof(embedding.Key.Model)); + Required(embedding.Key.Encoding, nameof(embedding.Key.Encoding)); + ArgumentNullException.ThrowIfNull(embedding.Vector); + if (embedding.Vector.Count == 0 + || embedding.Vector.Any(value => !float.IsFinite(value))) + { + throw new ArgumentException("Embedding vectors must contain only finite values.", nameof(embedding)); + } + if (embedding.Key.Dimensions is <= 0 + || embedding.Key.Dimensions is int dimensions && dimensions != embedding.Vector.Count) + { + throw new ArgumentException("Embedding dimensions must match the vector length.", nameof(embedding)); + } + + var squaredNorm = embedding.Vector.Sum(value => (double)value * value); + if (Math.Abs(Math.Sqrt(squaredNorm) - 1) > NormalizedVectorTolerance) + throw new ArgumentException("Embedding vectors must be normalized.", nameof(embedding)); + } + + private static void Required(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("The value cannot be empty.", parameterName); + } + + private static void ValidateLoadedPayload(string table, string rowKey, T value) + { + switch (value) + { + case PersistedClaim claim: + Validate(claim); + RequireMatchingIdentity(table, rowKey, claim.Id); + break; + case PersistedCandidateFingerprint candidate: + Validate(candidate); + RequireMatchingIdentity(table, rowKey, candidate.CandidateId); + break; + case AnalysisVerdict verdict: + Validate(verdict); + RequireMatchingIdentity(table, rowKey, verdict.CandidateId); + break; + default: + throw new InvalidOperationException( + $"Table '{table}' uses an unsupported persisted payload type '{typeof(T).Name}'."); + } + } + + private static void RequireMatchingIdentity(string table, string rowKey, string payloadId) + { + if (!StringComparer.Ordinal.Equals(rowKey, payloadId)) + { + throw new ArgumentException( + $"Table '{table}' row identity does not match its payload identity.", + nameof(payloadId)); + } + } + + private static void EnsureSafePersistencePath(string repositoryRoot, string databasePath) + { + EnsureContained(repositoryRoot, databasePath); + var stateDirectory = Path.Combine(repositoryRoot, ".kyber-weave"); + var cacheDirectory = Path.GetDirectoryName(databasePath)!; + RejectLinkOrReparsePoint(stateDirectory); + RejectLinkOrReparsePoint(cacheDirectory); + RejectLinkOrReparsePoint(databasePath); + } + + private static void EnsureContained(string repositoryRoot, string candidatePath) + { + var relative = Path.GetRelativePath(repositoryRoot, candidatePath); + if (Path.IsPathRooted(relative) + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The documentation analysis cache must resolve inside the repository root."); + } + } + + private static void RejectLinkOrReparsePoint(string path) + { + FileSystemInfo info = Directory.Exists(path) + ? new DirectoryInfo(path) + : new FileInfo(path); + try + { + if (info.LinkTarget is not null + || info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidOperationException( + $"The documentation analysis cache path cannot contain a symbolic link or reparse point: '{path}'."); + } + } + catch (FileNotFoundException) + { + // A not-yet-created cache component is validated again immediately after creation. + } + } + + private static bool IsBusy(string reason) => + reason.Contains("locked", StringComparison.OrdinalIgnoreCase) + || reason.Contains("busy", StringComparison.OrdinalIgnoreCase); + + private static bool IsOperationalFailure(string reason) => + reason.Contains("readonly", StringComparison.OrdinalIgnoreCase) + || reason.Contains("read-only", StringComparison.OrdinalIgnoreCase) + || reason.Contains("disk i/o", StringComparison.OrdinalIgnoreCase) + || reason.Contains("unable to open database", StringComparison.OrdinalIgnoreCase) + || reason.Contains("permission denied", StringComparison.OrdinalIgnoreCase) + || reason.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase) + || reason.Contains("interrupted", StringComparison.OrdinalIgnoreCase); + + private static string FailureDetail(ProcessResult result, string reason) => + reason.Length == 0 + ? $"sqlite3 exited with code {result.ExitCode}." + : $"sqlite3 exited with code {result.ExitCode}: {reason}"; + + private void EnsureAvailable() + { + if (!IsAvailable) + { + throw new InvalidOperationException( + "Analysis persistence is disabled until .kyber-weave/.gitignore contains the exact 'cache/' entry and sqlite3 is available."); + } + } + + private static InvalidDataException CorruptCache(string message, Exception? innerException = null) => + new($"The documentation analysis cache is invalid. {message}", innerException); +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Review/DocumentationReviewExchange.cs b/src/KyberWeave.Core/Docs/Analysis/Review/DocumentationReviewExchange.cs new file mode 100644 index 0000000..876eab2 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Review/DocumentationReviewExchange.cs @@ -0,0 +1,436 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Persistence; + +namespace KyberWeave.Core.Docs.Analysis.Review; + +/// +/// Exports bounded review evidence and atomically imports content-addressed verdicts. +/// +public sealed class DocumentationReviewExchange +{ + public const string CandidateSchema = "kyber-weave.docs-review.candidates/v1"; + public const string VerdictSchema = "kyber-weave.docs-review.verdicts/v1"; + public const string ReviewRuleCode = "KW-DOC-REVIEW-001"; + + private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); + private static readonly ReviewRubric Rubric = new( + [ + new(AnalysisVerdictLabel.Duplicate, "The claims express substantively the same claim, not merely the same topic."), + new(AnalysisVerdictLabel.Conflict, "The claims cannot both be true in the same scope and time."), + new(AnalysisVerdictLabel.DistinctSenses, "The shared term denotes multiple concepts."), + new(AnalysisVerdictLabel.Benign, "The overlap is intentional or the claims apply to compatible scopes."), + new(AnalysisVerdictLabel.Uncertain, "The supplied evidence is insufficient for a durable disposition.") + ]); + + private readonly IAnalysisPersistence _persistence; + private readonly double _confidenceThreshold; + + public DocumentationReviewExchange( + IAnalysisPersistence persistence, + double confidenceThreshold = 0.80) + { + _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + if (!double.IsFinite(confidenceThreshold) + || confidenceThreshold < 0 + || confidenceThreshold > 1) + { + throw new ArgumentOutOfRangeException( + nameof(confidenceThreshold), + confidenceThreshold, + "The verdict confidence threshold must be between zero and one."); + } + + _confidenceThreshold = confidenceThreshold; + } + + /// Exports pending review candidates without exact duplicate clusters. + public ReviewExportResult Export( + IReadOnlyList currentCandidates, + ReviewExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(currentCandidates); + options ??= new ReviewExportOptions(); + Validate(options); + + var pending = PendingCandidates(currentCandidates); + + var remaining = options.CharacterBudget; + var exportedCharacters = 0; + var truncated = false; + var items = new List(pending.Count); + var emittedCandidates = new List(pending.Count); + foreach (var candidate in pending) + { + var orderedClaims = OrderedClaims(candidate.Claims); + var requiredCharacters = orderedClaims.Sum(claim => + Math.Min(claim.Text.Length, options.MaxExcerptCharacters)); + if (remaining < orderedClaims.Count + || (items.Count > 0 && requiredCharacters > remaining)) + { + truncated = true; + break; + } + + var evidence = new List(orderedClaims.Count); + var occurrences = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < orderedClaims.Count; index++) + { + var claim = orderedClaims[index]; + occurrences.TryGetValue(claim.ContentHash, out var occurrence); + occurrences[claim.ContentHash] = occurrence + 1; + var reservedForRemainingEvidence = orderedClaims.Count - index - 1; + var maximum = Math.Min( + options.MaxExcerptCharacters, + remaining - reservedForRemainingEvidence); + var excerptLength = Math.Min(claim.Text.Length, maximum); + var excerpt = claim.Text[..excerptLength]; + truncated |= excerptLength < claim.Text.Length; + remaining -= excerptLength; + exportedCharacters += excerptLength; + evidence.Add(new ReviewEvidenceItem( + EvidenceId(candidate.Id, claim.ContentHash, occurrence), + claim.ContentHash, + claim.ContextualHash, + claim.DocumentIdentity, + claim.FilePath, + claim.StartLine, + claim.EndLine, + excerpt)); + } + + items.Add(new ReviewCandidateItem( + candidate.Id, + candidate.Kind, + candidate.Term, + candidate.Score, + candidate.Sources.Order().ToArray(), + evidence.Select(item => item.ContentHash).ToArray(), + evidence)); + emittedCandidates.Add(candidate); + } + + truncated |= emittedCandidates.Count < pending.Count; + + var bundle = new ReviewCandidateBundle( + CandidateSchema, + DocumentationAnalyzer.AnalyzerVersion, + DocumentationAnalyzer.RubricVersion, + CandidateSetHash(emittedCandidates), + Rubric, + items); + return new ReviewExportResult( + bundle, + JsonSerializer.Serialize(bundle, SerializerOptions), + exportedCharacters, + truncated); + } + + /// + /// Validates the complete verdict bundle against the current candidate content before + /// making one persistence call. + /// + public ReviewImportResult Import( + string json, + IReadOnlyList currentCandidates) + { + ArgumentNullException.ThrowIfNull(json); + ArgumentNullException.ThrowIfNull(currentCandidates); + + ReviewVerdictBundle? bundle; + try + { + bundle = JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (Exception exception) when ( + exception is JsonException or NotSupportedException or ArgumentException) + { + return Failure("The verdict bundle is not valid JSON for the review schema."); + } + + if (bundle is null) return Failure("The verdict bundle is empty."); + var pending = PendingCandidates(currentCandidates); + var candidatesById = pending.ToDictionary(candidate => candidate.Id, StringComparer.Ordinal); + var error = ValidateBundle(bundle, candidatesById); + if (error is not null) return Failure(error); + + var verdicts = bundle.Verdicts.Select(item => new AnalysisVerdict( + item.CandidateId, + item.Label!.Value, + item.Confidence!.Value, + item.Rationale, + item.EvidenceIds, + item.RecommendedCanonicalLocation, + item.ProposedGlossarySenses)).ToArray(); + var reviewedCandidates = bundle.Verdicts + .Select(item => candidatesById[item.CandidateId]) + .ToArray(); + var claims = PersistedClaims(reviewedCandidates); + var fingerprints = reviewedCandidates.Select(candidate => new PersistedCandidateFingerprint( + candidate.Id, + candidate.Kind, + candidate.Term?.Trim().ToLowerInvariant(), + bundle.CandidateSetHash, + bundle.AnalyzerVersion, + bundle.RubricVersion, + candidate.Claims.Select(claim => claim.ContentHash).ToArray())).ToArray(); + + try + { + _persistence.SaveReviewImport(claims, fingerprints, verdicts); + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException or IOException or InvalidDataException) + { + return Failure($"The validated verdict bundle could not be persisted: {exception.Message}"); + } + + return new ReviewImportResult(true, verdicts.Length, new DiagnosticReport()); + } + + private static IReadOnlyList PersistedClaims( + IReadOnlyList candidates) + { + var claims = new List(); + foreach (var candidate in candidates) + { + var occurrences = new Dictionary(StringComparer.Ordinal); + foreach (var claim in OrderedClaims(candidate.Claims)) + { + occurrences.TryGetValue(claim.ContentHash, out var occurrence); + occurrences[claim.ContentHash] = occurrence + 1; + claims.Add(new PersistedClaim( + EvidenceId(candidate.Id, claim.ContentHash, occurrence), + claim.ContentHash, + claim.ContextualHash, + claim.DocumentIdentity, + claim.FilePath, + claim.StartLine, + claim.EndLine, + claim.Text)); + } + } + + return claims; + } + + private string? ValidateBundle( + ReviewVerdictBundle bundle, + IReadOnlyDictionary candidates) + { + if (!StringComparer.Ordinal.Equals(bundle.Schema, VerdictSchema)) + return $"Unsupported verdict schema '{bundle.Schema}'."; + if (!StringComparer.Ordinal.Equals(bundle.AnalyzerVersion, DocumentationAnalyzer.AnalyzerVersion)) + return "The verdict bundle analyzer version is stale."; + if (!StringComparer.Ordinal.Equals(bundle.RubricVersion, DocumentationAnalyzer.RubricVersion)) + return "The verdict bundle rubric version is stale."; + if (bundle.Verdicts is null || bundle.Verdicts.Count == 0) + return "The verdict bundle does not contain any verdicts."; + + var seen = new HashSet(StringComparer.Ordinal); + var reviewedCandidates = new List(bundle.Verdicts.Count); + foreach (var verdict in bundle.Verdicts) + { + if (verdict is null) return "The verdict bundle contains an empty verdict."; + if (string.IsNullOrWhiteSpace(verdict.CandidateId) || !seen.Add(verdict.CandidateId)) + return "Verdict candidate ids must be non-empty and unique."; + if (!candidates.TryGetValue(verdict.CandidateId, out var candidate)) + return $"Verdict candidate '{verdict.CandidateId}' is not current."; + reviewedCandidates.Add(candidate); + } + + if (!StringComparer.Ordinal.Equals( + bundle.CandidateSetHash, + CandidateSetHash(reviewedCandidates))) + { + return "The verdict bundle candidate set is stale."; + } + + foreach (var verdict in bundle.Verdicts) + { + var candidate = candidates[verdict.CandidateId]; + if (!HashesMatch(verdict.ClaimContentHashes, candidate.Claims)) + return $"Verdict candidate '{verdict.CandidateId}' has stale claim content."; + if (verdict.Label is null || !LabelApplies(candidate.Kind, verdict.Label.Value)) + return $"Verdict label '{verdict.Label}' does not apply to a {candidate.Kind} candidate."; + if (verdict.Confidence is null + || !double.IsFinite(verdict.Confidence.Value) + || verdict.Confidence.Value < 0 + || verdict.Confidence.Value > 1) + { + return $"Verdict candidate '{verdict.CandidateId}' has invalid confidence."; + } + if (string.IsNullOrWhiteSpace(verdict.Rationale)) + return $"Verdict candidate '{verdict.CandidateId}' requires a rationale."; + if (!EvidenceReferencesMatch(verdict.EvidenceIds, candidate)) + return $"Verdict candidate '{verdict.CandidateId}' references unknown evidence."; + if (!GlossarySensesAreValid(verdict, candidate)) + return $"Verdict candidate '{verdict.CandidateId}' has invalid glossary sense proposals."; + } + + if (!_persistence.IsAvailable) return "The analysis cache is unavailable for verdict import."; + return null; + } + + private static IReadOnlyList ReviewableCandidates( + IEnumerable candidates) => + candidates + .Where(candidate => !candidate.IsExact) + .OrderBy(candidate => candidate.Kind) + .ThenBy(candidate => candidate.Term, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToArray(); + + private IReadOnlyList PendingCandidates( + IEnumerable candidates) + { + var reviewable = ReviewableCandidates(candidates); + var verdicts = _persistence.IsAvailable + ? _persistence.LoadVerdicts(reviewable.Select(candidate => candidate.Id).ToArray()) + : new Dictionary(StringComparer.Ordinal); + return reviewable + .Where(candidate => IsPending(candidate.Id, verdicts)) + .ToArray(); + } + + private bool IsPending( + string candidateId, + IReadOnlyDictionary verdicts) => + !verdicts.TryGetValue(candidateId, out var verdict) + || verdict.Label == AnalysisVerdictLabel.Uncertain + || verdict.Confidence < _confidenceThreshold; + + private static IReadOnlyList OrderedClaims(IEnumerable claims) => + claims.OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .ThenBy(claim => claim.ContentHash, StringComparer.Ordinal) + .ToArray(); + + private static bool HashesMatch( + IReadOnlyList? hashes, + IReadOnlyList claims) => + hashes is not null + && hashes.Order(StringComparer.Ordinal).SequenceEqual( + claims.Select(claim => claim.ContentHash).Order(StringComparer.Ordinal), + StringComparer.Ordinal); + + private static bool EvidenceReferencesMatch( + IReadOnlyList? evidenceIds, + AnalysisCandidate candidate) + { + if (evidenceIds is null || evidenceIds.Count == 0) return false; + var expected = ExpectedEvidenceIds(candidate); + return evidenceIds.All(expected.Contains); + } + + private static IReadOnlySet ExpectedEvidenceIds(AnalysisCandidate candidate) + { + var occurrences = new Dictionary(StringComparer.Ordinal); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var claim in OrderedClaims(candidate.Claims)) + { + occurrences.TryGetValue(claim.ContentHash, out var occurrence); + occurrences[claim.ContentHash] = occurrence + 1; + ids.Add(EvidenceId(candidate.Id, claim.ContentHash, occurrence)); + } + + return ids; + } + + private static bool LabelApplies(AnalysisRuleKind kind, AnalysisVerdictLabel label) => + label is AnalysisVerdictLabel.Benign or AnalysisVerdictLabel.Uncertain + || (kind == AnalysisRuleKind.Duplicate && label == AnalysisVerdictLabel.Duplicate) + || (kind == AnalysisRuleKind.Conflict && label == AnalysisVerdictLabel.Conflict) + || (kind == AnalysisRuleKind.Terminology && label == AnalysisVerdictLabel.DistinctSenses); + + private static bool GlossarySensesAreValid( + ReviewVerdictItem verdict, + AnalysisCandidate candidate) + { + if (verdict.ProposedGlossarySenses is null) return true; + if (candidate.Kind != AnalysisRuleKind.Terminology + || verdict.Label != AnalysisVerdictLabel.DistinctSenses + || verdict.ProposedGlossarySenses.Count == 0) + { + return false; + } + + return verdict.ProposedGlossarySenses.All(sense => + !string.IsNullOrWhiteSpace(sense.Term) + && StringComparer.OrdinalIgnoreCase.Equals(sense.Term, candidate.Term) + && !string.IsNullOrWhiteSpace(sense.Definition) + && sense.Scopes is { Count: > 0 } + && sense.Scopes.All(ValidScope) + && sense.Aliases is not null + && sense.Aliases.All(alias => !string.IsNullOrWhiteSpace(alias))); + } + + private static bool ValidScope(string scope) => + (scope.StartsWith("component:", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(scope["component:".Length..])) + || (scope.StartsWith("code-ref:", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(scope["code-ref:".Length..])); + + private static string CandidateSetHash(IEnumerable candidates) + { + var candidateLines = candidates + .Select(candidate => string.Join('|', + candidate.Id, + string.Join(',', candidate.Claims + .Select(claim => claim.ContentHash) + .Order(StringComparer.Ordinal)))) + .Order(StringComparer.Ordinal); + var identity = string.Join('\n', + new[] + { + DocumentationAnalyzer.AnalyzerVersion, + DocumentationAnalyzer.RubricVersion + }.Concat(candidateLines)); + return Hash(identity); + } + + private static string EvidenceId( + string candidateId, + string contentHash, + int occurrence) => + Hash(string.Join('|', + candidateId, + contentHash, + occurrence)); + + private static string Hash(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + private static ReviewImportResult Failure(string message) + { + var diagnostics = new DiagnosticReport(); + diagnostics.Add(new Diagnostic( + ReviewRuleCode, + Severity.Error, + message, + "docs review import", + Hint: "Export a fresh candidate bundle, review only its evidence ids, and import a complete verdicts/v1 document.")); + return new ReviewImportResult(false, 0, diagnostics); + } + + private static void Validate(ReviewExportOptions options) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(options.MaxExcerptCharacters); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(options.CharacterBudget); + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} diff --git a/src/KyberWeave.Core/Docs/Analysis/Review/ReviewModels.cs b/src/KyberWeave.Core/Docs/Analysis/Review/ReviewModels.cs new file mode 100644 index 0000000..7dcb6f8 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Analysis/Review/ReviewModels.cs @@ -0,0 +1,83 @@ +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Core.Docs.Analysis.Review; + +/// Character limits applied to reviewer evidence exports. +public sealed record ReviewExportOptions( + int MaxExcerptCharacters = 500, + int CharacterBudget = 12_000); + +/// One label and its stable judging definition. +public sealed record ReviewRubricLabel(AnalysisVerdictLabel Label, string Definition); + +/// The judging rubric embedded in every candidate export. +public sealed record ReviewRubric(IReadOnlyList Labels); + +/// One capped, line-addressable claim occurrence supplied to a reviewer. +public sealed record ReviewEvidenceItem( + string Id, + string ContentHash, + string ContextualHash, + string DocumentIdentity, + string FilePath, + int StartLine, + int EndLine, + string Excerpt); + +/// One pending duplicate, conflict, or terminology review candidate. +public sealed record ReviewCandidateItem( + string CandidateId, + AnalysisRuleKind Kind, + string? Term, + CandidateScore Score, + IReadOnlyList Sources, + IReadOnlyList ClaimContentHashes, + IReadOnlyList Evidence); + +/// Versioned candidate exchange document. +public sealed record ReviewCandidateBundle( + string Schema, + string AnalyzerVersion, + string RubricVersion, + string CandidateSetHash, + ReviewRubric Rubric, + IReadOnlyList Candidates); + +/// Serialized candidate export and local-only budget measurements. +public sealed record ReviewExportResult( + ReviewCandidateBundle Bundle, + string Json, + int ExportedExcerptCharacters, + bool Truncated, + DiagnosticReport? Diagnostics = null) +{ + /// Analysis warnings and local cost measurements associated with the export. + public DiagnosticReport Diagnostics { get; init; } = Diagnostics ?? new DiagnosticReport(); +} + +/// One reviewer verdict echoed against the exported content identity. +public sealed record ReviewVerdictItem( + string CandidateId, + AnalysisVerdictLabel? Label, + double? Confidence, + string Rationale, + IReadOnlyList ClaimContentHashes, + IReadOnlyList EvidenceIds, + string? RecommendedCanonicalLocation = null, + IReadOnlyList? ProposedGlossarySenses = null); + +/// Versioned reviewer verdict exchange document. +public sealed record ReviewVerdictBundle( + string Schema, + string AnalyzerVersion, + string RubricVersion, + string CandidateSetHash, + IReadOnlyList Verdicts); + +/// Atomic verdict-import outcome. +public sealed record ReviewImportResult( + bool Success, + int ImportedCount, + DiagnosticReport Diagnostics); diff --git a/src/KyberWeave.Core/Docs/Export/DocGraphExporter.cs b/src/KyberWeave.Core/Docs/Export/DocGraphExporter.cs index 1090cea..b4f7cd3 100644 --- a/src/KyberWeave.Core/Docs/Export/DocGraphExporter.cs +++ b/src/KyberWeave.Core/Docs/Export/DocGraphExporter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Docs.Graph; using KyberWeave.Core.Docs.Model; namespace KyberWeave.Core.Docs.Export; @@ -26,6 +27,13 @@ public DocGraphExporter(ICodeGraphResolver resolver) } public DocGraphExportResult Export(DocumentSet set, string outputDirectory) + => Export(set, outputDirectory, contributors: null); + + /// Exports documents plus independently governed graph contributions. + public DocGraphExportResult Export( + DocumentSet set, + string outputDirectory, + IReadOnlyList? contributors) { ArgumentNullException.ThrowIfNull(set); ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); @@ -34,68 +42,9 @@ public DocGraphExportResult Export(DocumentSet set, string outputDirectory) var nodesPath = Path.Combine(outputDirectory, "nodes.jsonl"); var edgesPath = Path.Combine(outputDirectory, "edges.jsonl"); - var nodeLines = new List(); - var edgeLines = new List(); - - var emittedConceptNodes = new HashSet(StringComparer.Ordinal); - var pathToId = set.Documents - .Where(d => !string.IsNullOrWhiteSpace(d.Frontmatter.Id)) - .ToDictionary(d => d.RelativePath, d => DocId(d.Frontmatter.Id!), StringComparer.Ordinal); - - foreach (var doc in set.Documents) - { - if (string.IsNullOrWhiteSpace(doc.Frontmatter.Id)) continue; - - var id = DocId(doc.Frontmatter.Id!); - - nodeLines.Add(Line(new JsonObject - { - ["type"] = "node", - ["id"] = id, - ["label"] = "Document", - ["docType"] = doc.DocType.ToString().ToLowerInvariant(), - ["status"] = doc.Status.ToString().ToLowerInvariant(), - ["title"] = doc.Frontmatter.Title, - ["path"] = doc.RelativePath - })); - - AddConceptNode(nodeLines, emittedConceptNodes, "Component", doc.Frontmatter.Component); - AddConceptNode(nodeLines, emittedConceptNodes, "Team", doc.Frontmatter.Owner); - - if (!string.IsNullOrWhiteSpace(doc.Frontmatter.Component)) - edgeLines.Add(Edge("DOCUMENTS", id, ConceptId("Component", doc.Frontmatter.Component))); - - if (!string.IsNullOrWhiteSpace(doc.Frontmatter.Owner)) - edgeLines.Add(Edge("OWNED_BY", id, ConceptId("Team", doc.Frontmatter.Owner))); - - if (!string.IsNullOrWhiteSpace(doc.Frontmatter.SourceRoot)) - edgeLines.Add(Edge("DESCRIBES", id, $"path:{doc.Frontmatter.SourceRoot}")); - - foreach (var symbol in doc.CodeRefs) - foreach (var node in _resolver.ResolveSymbol(symbol)) - edgeLines.Add(Edge("REFERENCES", id, node.Id)); - - foreach (var endpoint in doc.ApiEndpoints) - foreach (var node in _resolver.ResolveRoute(endpoint)) - edgeLines.Add(Edge("EXPOSES", id, node.Id)); - - foreach (var adr in doc.DecidedBy) - edgeLines.Add(Edge("DECIDED_BY", id, DocId(adr))); - - foreach (var superseded in doc.Supersedes) - edgeLines.Add(Edge("SUPERSEDES", id, DocId(superseded))); - - foreach (var link in doc.BodyLinks) - { - var target = ResolveLink(doc.RelativePath, link); - if (target is not null && pathToId.TryGetValue(target, out var targetId) && targetId != id) - { - edgeLines.Add(Edge("LINKS_TO", id, targetId)); - } - } - } - - edgeLines = edgeLines.Distinct(StringComparer.Ordinal).ToList(); + var projection = DocGraphProjection.Build(set, _resolver, contributors: contributors); + var nodeLines = projection.Nodes.Select(Node).ToList(); + var edgeLines = projection.Edges.Select(Edge).ToList(); File.WriteAllLines(nodesPath, nodeLines); File.WriteAllLines(edgesPath, edgeLines); @@ -103,55 +52,35 @@ public DocGraphExportResult Export(DocumentSet set, string outputDirectory) return new DocGraphExportResult(nodeLines.Count, edgeLines.Count, nodesPath, edgesPath); } - private static void AddConceptNode(List lines, HashSet emitted, string label, string? name) + private static string Node(DocGraphNode node) { - if (string.IsNullOrWhiteSpace(name)) return; - var id = ConceptId(label, name); - if (!emitted.Add(id)) return; - - lines.Add(Line(new JsonObject + var json = new JsonObject { ["type"] = "node", - ["id"] = id, - ["label"] = label, - ["name"] = name - })); - } + ["id"] = node.Id, + ["label"] = node.Label + }; - internal static string DocId(string id) => $"doc:{id}"; + foreach (var property in node.Properties) + { + if (property.Key is "type" or "id" or "label") continue; + json[property.Key] = property.Value; + } - private static string ConceptId(string label, string? name) => - $"{label.ToLowerInvariant()}:{name}"; + return Line(json); + } - private static string Edge(string label, string from, string to) => Line(new JsonObject + private static string Edge(DocGraphEdge edge) => Line(new JsonObject { ["type"] = "edge", - ["label"] = label, - ["from"] = from, - ["to"] = to + ["label"] = edge.Label, + ["from"] = edge.From, + ["to"] = edge.To }); private static string Line(JsonNode node) => node.ToJsonString(Compact); /// Resolves a relative link against the linking document's directory. - internal static string? ResolveLink(string fromRelativePath, string link) - { - var directory = Path.GetDirectoryName(fromRelativePath)?.Replace('\\', '/') ?? string.Empty; - var combined = string.IsNullOrEmpty(directory) ? link : $"{directory}/{link}"; - - var parts = new List(); - foreach (var segment in combined.Split('/')) - { - if (segment is "." or "") continue; - if (segment == "..") - { - if (parts.Count == 0) return null; - parts.RemoveAt(parts.Count - 1); - continue; - } - parts.Add(segment); - } - - return parts.Count == 0 ? null : string.Join('/', parts); - } + internal static string? ResolveLink(string fromRelativePath, string link) => + DocGraphProjection.ResolveLink(fromRelativePath, link); } diff --git a/src/KyberWeave.Core/Docs/Graph/DocGraphContribution.cs b/src/KyberWeave.Core/Docs/Graph/DocGraphContribution.cs new file mode 100644 index 0000000..a5e7026 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Graph/DocGraphContribution.cs @@ -0,0 +1,6 @@ +namespace KyberWeave.Core.Docs.Graph; + +/// Additional nodes and edges contributed to a DocGraph projection. +public sealed record DocGraphContribution( + IReadOnlyList Nodes, + IReadOnlyList Edges); diff --git a/src/KyberWeave.Core/Docs/Graph/DocGraphEdge.cs b/src/KyberWeave.Core/Docs/Graph/DocGraphEdge.cs new file mode 100644 index 0000000..fceb3cf --- /dev/null +++ b/src/KyberWeave.Core/Docs/Graph/DocGraphEdge.cs @@ -0,0 +1,4 @@ +namespace KyberWeave.Core.Docs.Graph; + +/// One directed relationship in the documentation graph projection. +public sealed record DocGraphEdge(string Label, string From, string To); diff --git a/src/KyberWeave.Core/Docs/Graph/DocGraphNode.cs b/src/KyberWeave.Core/Docs/Graph/DocGraphNode.cs new file mode 100644 index 0000000..99fb80b --- /dev/null +++ b/src/KyberWeave.Core/Docs/Graph/DocGraphNode.cs @@ -0,0 +1,29 @@ +using System.Collections.ObjectModel; + +namespace KyberWeave.Core.Docs.Graph; + +/// One node in the immutable documentation graph projection. +public sealed record DocGraphNode +{ + /// Creates a node and defensively snapshots its properties. + public DocGraphNode( + string id, + string label, + IReadOnlyDictionary properties) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(label); + ArgumentNullException.ThrowIfNull(properties); + + Id = id; + Label = label; + Properties = new ReadOnlyDictionary( + new Dictionary(properties, StringComparer.Ordinal)); + } + + public string Id { get; } + + public string Label { get; } + + public IReadOnlyDictionary Properties { get; } +} diff --git a/src/KyberWeave.Core/Docs/Graph/DocGraphProjection.cs b/src/KyberWeave.Core/Docs/Graph/DocGraphProjection.cs new file mode 100644 index 0000000..26bb649 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Graph/DocGraphProjection.cs @@ -0,0 +1,384 @@ +using System.Collections.ObjectModel; +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Docs.Model; + +namespace KyberWeave.Core.Docs.Graph; + +/// +/// Immutable, reusable projection of document identity, ownership, links, and code joins. +/// +public sealed class DocGraphProjection +{ + private static readonly HashSet SharedTargetLabels = + ["DOCUMENTS", "DESCRIBES", "REFERENCES", "EXPOSES"]; + + private static readonly HashSet DirectDocumentLabels = + ["LINKS_TO", "DECIDED_BY", "SUPERSEDES"]; + + private static readonly HashSet TraversedCodeEdgeKinds = + ["contains", "calls", "references", "instantiates", "extends", "implements"]; + + private readonly IReadOnlySet _documentIds; + private readonly IReadOnlySet _relatedDocuments; + private readonly IReadOnlyDictionary> _relatedDocumentIds; + + private DocGraphProjection( + IReadOnlyList nodes, + IReadOnlyList edges, + IReadOnlySet documentIds, + IReadOnlySet relatedDocuments) + { + Nodes = new ReadOnlyCollection(nodes.ToArray()); + Edges = new ReadOnlyCollection(edges.ToArray()); + _documentIds = new ReadOnlySet( + new HashSet(documentIds, StringComparer.Ordinal)); + _relatedDocuments = new ReadOnlySet( + new HashSet(relatedDocuments)); + _relatedDocumentIds = BuildRelationshipIndex(documentIds, relatedDocuments); + } + + /// Snapshot of exportable DocGraph nodes. + public IReadOnlyList Nodes { get; } + + /// Snapshot of exportable DocGraph edges. + public IReadOnlyList Edges { get; } + + /// Builds the shared projection and optional one-hop CodeGraph neighborhood. + public static DocGraphProjection Build( + DocumentSet documents, + ICodeGraphResolver codeGraph, + int maxCodeNeighbors = 50, + IReadOnlyList? contributors = null) + { + ArgumentNullException.ThrowIfNull(documents); + ArgumentNullException.ThrowIfNull(codeGraph); + ArgumentOutOfRangeException.ThrowIfNegative(maxCodeNeighbors); + + var nodes = new List(); + var edges = new List(); + var emittedNodeIds = new HashSet(StringComparer.Ordinal); + var documentIds = new HashSet(StringComparer.Ordinal); + var pathToId = new Dictionary(StringComparer.Ordinal); + + foreach (var document in documents.Documents) + pathToId.TryAdd(document.RelativePath, DocId(document.Subject)); + + foreach (var document in documents.Documents) + { + // Claim.DocumentIdentity is document.Subject, which falls back to RelativePath + // when frontmatter has no id. Register the same node so graph candidacy can + // find intra-document and shared-concept pairs instead of querying a missing id. + var documentId = DocId(document.Subject); + documentIds.Add(documentId); + + AddNode(nodes, emittedNodeIds, new DocGraphNode( + documentId, + "Document", + new Dictionary(StringComparer.Ordinal) + { + ["docType"] = document.DocType.ToString().ToLowerInvariant(), + ["status"] = document.Status.ToString().ToLowerInvariant(), + ["title"] = document.Frontmatter.Title, + ["path"] = document.RelativePath + })); + + AddConcept(nodes, emittedNodeIds, "Component", document.Frontmatter.Component); + AddConcept(nodes, emittedNodeIds, "Team", document.Frontmatter.Owner); + + AddEdge(edges, "DOCUMENTS", documentId, + ConceptId("Component", document.Frontmatter.Component)); + AddEdge(edges, "OWNED_BY", documentId, + ConceptId("Team", document.Frontmatter.Owner)); + + if (!string.IsNullOrWhiteSpace(document.Frontmatter.SourceRoot)) + edges.Add(new DocGraphEdge( + "DESCRIBES", documentId, $"path:{document.Frontmatter.SourceRoot}")); + + foreach (var symbol in document.CodeRefs) + foreach (var node in codeGraph.ResolveSymbol(symbol)) + edges.Add(new DocGraphEdge("REFERENCES", documentId, node.Id)); + + foreach (var endpoint in document.ApiEndpoints) + foreach (var node in codeGraph.ResolveRoute(endpoint)) + edges.Add(new DocGraphEdge("EXPOSES", documentId, node.Id)); + + foreach (var adr in document.DecidedBy) + edges.Add(new DocGraphEdge("DECIDED_BY", documentId, DocId(adr))); + + foreach (var superseded in document.Supersedes) + edges.Add(new DocGraphEdge("SUPERSEDES", documentId, DocId(superseded))); + + foreach (var link in document.BodyLinks) + { + var target = ResolveLink(document.RelativePath, link); + if (target is not null + && pathToId.TryGetValue(target, out var targetId) + && !StringComparer.Ordinal.Equals(targetId, documentId)) + { + edges.Add(new DocGraphEdge("LINKS_TO", documentId, targetId)); + } + } + } + + if (contributors is not null) + { + foreach (var contributor in contributors) + { + ArgumentNullException.ThrowIfNull(contributor); + var contribution = contributor.Contribute(documents, codeGraph) + ?? throw new InvalidOperationException("A DocGraph contributor returned null."); + + foreach (var node in contribution.Nodes) + AddNode(nodes, emittedNodeIds, Copy(node)); + foreach (var edge in contribution.Edges) + edges.Add(edge with { }); + } + } + + var distinctEdges = edges.Distinct().ToArray(); + var related = BuildDocumentRelationships( + documentIds, + distinctEdges, + codeGraph, + maxCodeNeighbors); + + return new DocGraphProjection(nodes, distinctEdges, documentIds, related); + } + + /// + /// True when two documents share a projected concept, direct document relationship, + /// overlapping source root, or approved one-hop CodeGraph relationship. + /// + public bool AreDocumentsRelated(string leftDocumentId, string rightDocumentId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(leftDocumentId); + ArgumentException.ThrowIfNullOrWhiteSpace(rightDocumentId); + + if (StringComparer.Ordinal.Equals(leftDocumentId, rightDocumentId)) + return _documentIds.Contains(leftDocumentId); + + return _relatedDocuments.Contains(DocumentPair.Create(leftDocumentId, rightDocumentId)); + } + + /// + /// Returns the pre-indexed graph neighborhood for a document. The document itself is + /// not included; callers that compare claims within one document handle that bucket + /// directly. + /// + public IReadOnlySet GetRelatedDocumentIds(string documentId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(documentId); + return _relatedDocumentIds.TryGetValue(documentId, out var related) + ? related + : new ReadOnlySet(new HashSet(StringComparer.Ordinal)); + } + + private static IReadOnlyDictionary> BuildRelationshipIndex( + IReadOnlySet documentIds, + IReadOnlySet relationships) + { + var index = documentIds.ToDictionary( + documentId => documentId, + _ => new HashSet(StringComparer.Ordinal), + StringComparer.Ordinal); + foreach (var relationship in relationships) + { + index[relationship.Left].Add(relationship.Right); + index[relationship.Right].Add(relationship.Left); + } + + return new ReadOnlyDictionary>( + index.ToDictionary( + pair => pair.Key, + pair => (IReadOnlySet)new ReadOnlySet(pair.Value), + StringComparer.Ordinal)); + } + + private static IReadOnlySet BuildDocumentRelationships( + IReadOnlySet documentIds, + IReadOnlyList edges, + ICodeGraphResolver codeGraph, + int maxCodeNeighbors) + { + var related = new HashSet(); + + foreach (var group in edges + .Where(edge => SharedTargetLabels.Contains(edge.Label) + && documentIds.Contains(edge.From)) + .GroupBy(edge => edge.To, StringComparer.Ordinal)) + { + RelateEveryPair(group.Select(edge => edge.From), related); + } + + foreach (var edge in edges.Where(edge => DirectDocumentLabels.Contains(edge.Label))) + { + if (documentIds.Contains(edge.From) && documentIds.Contains(edge.To)) + related.Add(DocumentPair.Create(edge.From, edge.To)); + } + + var sourceRoots = edges + .Where(edge => edge.Label == "DESCRIBES" + && documentIds.Contains(edge.From) + && edge.To.StartsWith("path:", StringComparison.Ordinal)) + .Select(edge => (DocumentId: edge.From, Path: NormalizePath(edge.To["path:".Length..]))) + .Where(item => item.Path.Length > 0) + .ToArray(); + + for (var left = 0; left < sourceRoots.Length; left++) + { + for (var right = left + 1; right < sourceRoots.Length; right++) + { + if (PathsOverlap(sourceRoots[left].Path, sourceRoots[right].Path)) + related.Add(DocumentPair.Create( + sourceRoots[left].DocumentId, + sourceRoots[right].DocumentId)); + } + } + + if (codeGraph is not ICodeGraphNeighborhoodProvider neighborhoods) + return related; + + var codeToDocuments = edges + .Where(edge => edge.Label is "REFERENCES" or "EXPOSES" + && documentIds.Contains(edge.From)) + .GroupBy(edge => edge.To, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.Select(edge => edge.From).Distinct(StringComparer.Ordinal).ToArray(), + StringComparer.Ordinal); + + if (codeToDocuments.Count == 0) return related; + + var codeEdges = neighborhoods.GetEdges(codeToDocuments.Keys.ToArray(), maxCodeNeighbors) + .Where(edge => TraversedCodeEdgeKinds.Contains(edge.Kind)) + .Distinct() + .ToArray(); + + // Providers enforce the cap against the full index. Enforcing it again makes the + // optional port safe for simpler fakes and alternative providers as well. + var returnedDegree = new Dictionary(StringComparer.Ordinal); + foreach (var edge in codeEdges) + { + IncrementDegree(returnedDegree, edge.SourceId); + if (!StringComparer.Ordinal.Equals(edge.SourceId, edge.TargetId)) + IncrementDegree(returnedDegree, edge.TargetId); + } + + foreach (var edge in codeEdges) + { + if (returnedDegree[edge.SourceId] > maxCodeNeighbors + || returnedDegree[edge.TargetId] > maxCodeNeighbors + || !codeToDocuments.TryGetValue(edge.SourceId, out var sources) + || !codeToDocuments.TryGetValue(edge.TargetId, out var targets)) + { + continue; + } + + foreach (var source in sources) + foreach (var target in targets) + { + if (!StringComparer.Ordinal.Equals(source, target)) + related.Add(DocumentPair.Create(source, target)); + } + } + + return related; + } + + private static void IncrementDegree(IDictionary degrees, string nodeId) + { + degrees.TryGetValue(nodeId, out var degree); + degrees[nodeId] = degree + 1; + } + + private static void RelateEveryPair( + IEnumerable documentIds, + ISet related) + { + var ids = documentIds.Distinct(StringComparer.Ordinal).ToArray(); + for (var left = 0; left < ids.Length; left++) + for (var right = left + 1; right < ids.Length; right++) + related.Add(DocumentPair.Create(ids[left], ids[right])); + } + + private static bool PathsOverlap(string left, string right) => + left == "." + || right == "." + || StringComparer.OrdinalIgnoreCase.Equals(left, right) + || left.StartsWith(right + "/", StringComparison.OrdinalIgnoreCase) + || right.StartsWith(left + "/", StringComparison.OrdinalIgnoreCase); + + private static string NormalizePath(string path) + { + var normalized = path.Replace('\\', '/').Trim().TrimEnd('/'); + return normalized.StartsWith("./", StringComparison.Ordinal) ? normalized[2..] : normalized; + } + + private static void AddConcept( + ICollection nodes, + ISet emittedNodeIds, + string label, + string? name) + { + if (string.IsNullOrWhiteSpace(name)) return; + AddNode(nodes, emittedNodeIds, new DocGraphNode( + $"{label.ToLowerInvariant()}:{name}", + label, + new Dictionary(StringComparer.Ordinal) { ["name"] = name })); + } + + private static void AddNode( + ICollection nodes, + ISet emittedNodeIds, + DocGraphNode node) + { + if (emittedNodeIds.Add(node.Id)) nodes.Add(node); + } + + private static void AddEdge( + ICollection edges, + string label, + string from, + string? to) + { + if (!string.IsNullOrWhiteSpace(to)) edges.Add(new DocGraphEdge(label, from, to)); + } + + private static DocGraphNode Copy(DocGraphNode node) => + new(node.Id, node.Label, node.Properties); + + internal static string DocId(string id) => $"doc:{id}"; + + private static string? ConceptId(string label, string? name) => + string.IsNullOrWhiteSpace(name) ? null : $"{label.ToLowerInvariant()}:{name}"; + + /// Resolves a relative link against the linking document's directory. + internal static string? ResolveLink(string fromRelativePath, string link) + { + var directory = Path.GetDirectoryName(fromRelativePath)?.Replace('\\', '/') ?? string.Empty; + var combined = string.IsNullOrEmpty(directory) ? link : $"{directory}/{link}"; + + var parts = new List(); + foreach (var segment in combined.Split('/')) + { + if (segment is "." or "") continue; + if (segment == "..") + { + if (parts.Count == 0) return null; + parts.RemoveAt(parts.Count - 1); + continue; + } + parts.Add(segment); + } + + return parts.Count == 0 ? null : string.Join('/', parts); + } + + private readonly record struct DocumentPair(string Left, string Right) + { + public static DocumentPair Create(string left, string right) => + StringComparer.Ordinal.Compare(left, right) <= 0 + ? new DocumentPair(left, right) + : new DocumentPair(right, left); + } +} diff --git a/src/KyberWeave.Core/Docs/Graph/IDocGraphContributor.cs b/src/KyberWeave.Core/Docs/Graph/IDocGraphContributor.cs new file mode 100644 index 0000000..a48fef1 --- /dev/null +++ b/src/KyberWeave.Core/Docs/Graph/IDocGraphContributor.cs @@ -0,0 +1,11 @@ +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Docs.Model; + +namespace KyberWeave.Core.Docs.Graph; + +/// Port for adding independently governed concepts to DocGraph. +public interface IDocGraphContributor +{ + /// Builds a contribution for the current immutable document snapshot. + DocGraphContribution Contribute(DocumentSet documents, ICodeGraphResolver codeGraph); +} diff --git a/src/KyberWeave.Core/Docs/Model/DocumentModel.cs b/src/KyberWeave.Core/Docs/Model/DocumentModel.cs index 15082c7..b0c921e 100644 --- a/src/KyberWeave.Core/Docs/Model/DocumentModel.cs +++ b/src/KyberWeave.Core/Docs/Model/DocumentModel.cs @@ -91,6 +91,16 @@ public sealed class DocumentModel /// public string Body { get; init; } = string.Empty; + /// + /// The source Markdown exactly as read from disk. Analysis uses this to keep source + /// locations and frontmatter boundaries while retrieval continues to consume + /// . + /// + public string RawMarkdown { get; init; } = string.Empty; + + /// 1-based source line on which begins. + public int BodyStartLine { get; init; } = 1; + /// /// The body split on ## headings. Retrieval returns the one relevant section /// rather than the whole file, which is the difference between an answer and a dump. diff --git a/src/KyberWeave.Core/Docs/Parsing/DocumentLoader.cs b/src/KyberWeave.Core/Docs/Parsing/DocumentLoader.cs index 3600cb9..f57f754 100644 --- a/src/KyberWeave.Core/Docs/Parsing/DocumentLoader.cs +++ b/src/KyberWeave.Core/Docs/Parsing/DocumentLoader.cs @@ -141,6 +141,8 @@ private DocumentModel Parse(string absolutePath, string relativePath) HasFrontmatter = false, BodyLinks = ExtractRelativeLinks(raw), Body = raw, + RawMarkdown = raw, + BodyStartLine = read.BodyStartLine, Sections = SplitSections(raw) }; } @@ -170,6 +172,8 @@ private DocumentModel Parse(string absolutePath, string relativePath) Status = ParseStatus(frontmatter.Status), BodyLinks = ExtractRelativeLinks(read.Body), Body = read.Body, + RawMarkdown = raw, + BodyStartLine = read.BodyStartLine, Sections = SplitSections(read.Body) }; } diff --git a/src/KyberWeave.Core/Docs/Scaffolding/DocsScaffolder.cs b/src/KyberWeave.Core/Docs/Scaffolding/DocsScaffolder.cs index 83d2c79..a4b4709 100644 --- a/src/KyberWeave.Core/Docs/Scaffolding/DocsScaffolder.cs +++ b/src/KyberWeave.Core/Docs/Scaffolding/DocsScaffolder.cs @@ -1,3 +1,5 @@ +using System.IO.Enumeration; +using System.Text; using KyberWeave.Core.Configuration; namespace KyberWeave.Core.Docs.Scaffolding; @@ -79,6 +81,9 @@ public sealed record ScaffoldResult( /// public static class DocsScaffolder { + private const string AnalysisCacheIgnorePath = ".kyber-weave/.gitignore"; + private const string AnalysisCacheIgnoreEntry = "cache/"; + /// Roots checked, in order, when no docs root is supplied. private static readonly string[] ConventionalRoots = ["docs", "6-Docs", "doc", "documentation"]; @@ -110,6 +115,7 @@ public static ScaffoldResult Scaffold( var files = new List { WriteHostConfig(root, resolvedDocsRoot), + WriteAnalysisCacheIgnore(root), Write(root, $"{resolvedDocsRoot}/documentation-ontology.md", OntologyReference(resolvedDocsRoot, resolvedOwner), force), Write(root, $"{resolvedDocsRoot}/catalog.md", @@ -287,6 +293,126 @@ private static ScaffoldedFile WriteHostConfig(string repoRoot, string docsRoot) relativePath, ScaffoldOutcome.Updated, "docs-root only; the rest of the file is untouched"); } + /// + /// Establishes the narrow repository-local cache exclusion without regenerating the + /// operator's other ignore rules. + /// + /// + /// An exact entry earlier in the file is not enough when a later negation exposes the + /// analysis database again. Appending the same narrow entry after that negation restores + /// protection while preserving every existing byte. The existing newline style is used + /// for the appended boundary so a merge does not introduce mixed line endings. + /// + private static ScaffoldedFile WriteAnalysisCacheIgnore(string repoRoot) + { + var absolute = RequireContained(repoRoot, AnalysisCacheIgnorePath, nameof(repoRoot)); + if (!File.Exists(absolute)) + { + Directory.CreateDirectory(Path.GetDirectoryName(absolute)!); + File.WriteAllText(absolute, AnalysisCacheIgnoreEntry + "\n"); + return new ScaffoldedFile(AnalysisCacheIgnorePath, ScaffoldOutcome.Created); + } + + var existingBytes = File.ReadAllBytes(absolute); + var (encoding, preambleLength) = DetectEncoding(existingBytes); + var existing = encoding.GetString( + existingBytes, + preambleLength, + existingBytes.Length - preambleLength); + if (HasEffectiveAnalysisCacheIgnore(existing)) + { + return new ScaffoldedFile( + AnalysisCacheIgnorePath, + ScaffoldOutcome.Preserved, + "your local-state ignore rules, kept as-is"); + } + + var newline = ExistingNewline(existing); + var boundary = existing.Length == 0 || EndsWithNewline(existing) ? string.Empty : newline; + var appendedBytes = encoding.GetBytes(boundary + AnalysisCacheIgnoreEntry + newline); + using (var stream = File.Open(absolute, FileMode.Append, FileAccess.Write, FileShare.None)) + { + stream.Write(appendedBytes); + } + + return new ScaffoldedFile( + AnalysisCacheIgnorePath, + ScaffoldOutcome.Updated, + "added cache/ only; existing ignore rules are untouched"); + } + + private static (Encoding Encoding, int PreambleLength) DetectEncoding(byte[] content) => + content switch + { + [0x00, 0x00, 0xFE, 0xFF, ..] => + (new UTF32Encoding(bigEndian: true, byteOrderMark: true), 4), + [0xFF, 0xFE, 0x00, 0x00, ..] => (Encoding.UTF32, 4), + [0xEF, 0xBB, 0xBF, ..] => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), 3), + [0xFE, 0xFF, ..] => (Encoding.BigEndianUnicode, 2), + [0xFF, 0xFE, ..] => (Encoding.Unicode, 2), + _ => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), 0) + }; + + private static bool HasEffectiveAnalysisCacheIgnore(string content) + { + var protectedByExactEntry = false; + using var reader = new StringReader(content); + while (reader.ReadLine() is { } line) + { + if (StringComparer.Ordinal.Equals(line, AnalysisCacheIgnoreEntry)) + { + protectedByExactEntry = true; + continue; + } + + if (protectedByExactEntry + && line.StartsWith('!') + && NegatesAnalysisCacheProtection(line[1..])) + { + protectedByExactEntry = false; + } + } + + return protectedByExactEntry; + } + + private static bool NegatesAnalysisCacheProtection(string pattern) + { + if (pattern.StartsWith('/')) pattern = pattern[1..]; + if (pattern.Length == 0 || pattern.StartsWith('#')) return false; + + const string databaseRelativePath = "cache/docs-analysis.sqlite3"; + if (pattern.EndsWith('/')) + return databaseRelativePath.StartsWith(pattern, StringComparison.Ordinal); + + if (!pattern.Contains('/')) + { + return FileSystemName.MatchesSimpleExpression( + pattern, + Path.GetFileName(databaseRelativePath), + ignoreCase: OperatingSystem.IsWindows()); + } + + return pattern.Contains('[') + ? pattern.StartsWith("cache/", StringComparison.Ordinal) + : FileSystemName.MatchesSimpleExpression( + pattern.Replace("**", "*", StringComparison.Ordinal), + databaseRelativePath, + ignoreCase: OperatingSystem.IsWindows()); + } + + private static string ExistingNewline(string content) + { + var lineFeed = content.IndexOf('\n', StringComparison.Ordinal); + if (lineFeed >= 0) + return lineFeed > 0 && content[lineFeed - 1] == '\r' ? "\r\n" : "\n"; + + return content.Contains('\r', StringComparison.Ordinal) ? "\r" : "\n"; + } + + private static bool EndsWithNewline(string content) => + content.EndsWith('\n') || content.EndsWith('\r'); + private static ScaffoldedFile Write(string repoRoot, string relativePath, string content, bool force) { // Enforced per write as well as up front, so the invariant holds for every path diff --git a/src/KyberWeave.Core/Networking/LoopbackAddress.cs b/src/KyberWeave.Core/Networking/LoopbackAddress.cs new file mode 100644 index 0000000..2c14197 --- /dev/null +++ b/src/KyberWeave.Core/Networking/LoopbackAddress.cs @@ -0,0 +1,19 @@ +using System.Net; + +namespace KyberWeave.Core.Networking; + +/// Canonical loopback checks shared by configuration and connection-time policy. +internal static class LoopbackAddress +{ + /// + /// Treats IPv4-mapped IPv6 addresses according to their mapped IPv4 value. This is + /// required for the complete 127/8 range because + /// recognizes mapped 127.0.0.1 but not every mapped 127/8 address consistently. + /// + public static bool IsLoopback(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + var normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + return IPAddress.IsLoopback(normalized); + } +} diff --git a/src/KyberWeave.Core/Parsing/MarkdownFrontmatterReader.cs b/src/KyberWeave.Core/Parsing/MarkdownFrontmatterReader.cs index e98d643..a9a44ba 100644 --- a/src/KyberWeave.Core/Parsing/MarkdownFrontmatterReader.cs +++ b/src/KyberWeave.Core/Parsing/MarkdownFrontmatterReader.cs @@ -12,7 +12,12 @@ namespace KyberWeave.Core.Parsing; /// True when a frontmatter block was present. /// The raw YAML text with its delimiters removed. /// The Markdown body following the frontmatter block. -public readonly record struct FrontmatterReadResult(bool HasFrontmatter, string Yaml, string Body); +/// The 1-based source line on which begins. +public readonly record struct FrontmatterReadResult( + bool HasFrontmatter, + string Yaml, + string Body, + int BodyStartLine = 1); /// /// Format-agnostic reader for YAML frontmatter in Markdown files. @@ -53,7 +58,8 @@ public static FrontmatterReadResult Read(string content) return new FrontmatterReadResult(false, string.Empty, content); } - return new FrontmatterReadResult(true, ExtractYaml(content, block), ExtractBody(content, block)); + var (body, bodyStartLine) = ExtractBody(content, block); + return new FrontmatterReadResult(true, ExtractYaml(content, block), body, bodyStartLine); } /// Reads frontmatter and body from a file on disk. @@ -69,10 +75,32 @@ private static string ExtractYaml(string content, YamlFrontMatterBlock block) return string.Join("\n", lines); } - private static string ExtractBody(string content, YamlFrontMatterBlock block) + private static (string Body, int StartLine) ExtractBody(string content, YamlFrontMatterBlock block) { var afterIndex = block.Span.End + 1; - if (afterIndex >= content.Length) return string.Empty; - return content[afterIndex..].TrimStart('\r', '\n'); + while (afterIndex < content.Length && content[afterIndex] is '\r' or '\n') + { + afterIndex++; + } + + var startLine = SourceLineAt(content, afterIndex); + return afterIndex >= content.Length + ? (string.Empty, startLine) + : (content[afterIndex..], startLine); + } + + private static int SourceLineAt(string content, int position) + { + var line = 1; + for (var index = 0; index < position; index++) + { + if (content[index] == '\n' || + content[index] == '\r' && (index + 1 >= position || content[index + 1] != '\n')) + { + line++; + } + } + + return line; } } diff --git a/src/KyberWeave.Core/Processes/ProcessRunner.cs b/src/KyberWeave.Core/Processes/ProcessRunner.cs index 0578110..9f2e3f1 100644 --- a/src/KyberWeave.Core/Processes/ProcessRunner.cs +++ b/src/KyberWeave.Core/Processes/ProcessRunner.cs @@ -11,6 +11,74 @@ namespace KyberWeave.Core.Processes; /// Runs a child process to completion without deadlocking on its pipes. public static class ProcessRunner { + /// + /// Starts a child process, transfers its standard input, and captures its output. + /// + /// + /// The output reads begin before input is written. A child is allowed to fill its + /// output pipes before reading stdin, so writing all input first can deadlock just as + /// surely as draining stdout and stderr sequentially can. Closing stdin after the + /// write is equally important: many command-line tools do not proceed until EOF. + /// + /// + /// Process configuration with stdin, stdout, and stderr redirected, shell execution + /// disabled, and arguments supplied through + /// rather than the concatenated string. + /// + /// The complete text to write to the child process. + public static ProcessResult Run(ProcessStartInfo startInfo, string standardInput) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(standardInput); + + if (startInfo.UseShellExecute || + !startInfo.RedirectStandardInput || + !startInfo.RedirectStandardOutput || + !startInfo.RedirectStandardError) + { + throw new ArgumentException( + "Standard input, standard output, and standard error must be redirected " + + "with shell execution disabled.", + nameof(startInfo)); + } + + if (!string.IsNullOrEmpty(startInfo.Arguments)) + { + throw new ArgumentException( + "Pass arguments through ArgumentList, not Arguments.", + nameof(startInfo)); + } + + // The caller's object is not started as-is. Arguments is a single string a shell + // would re-parse; ArgumentList is argv. A fresh start info carries only the file + // name, working directory, and argument list, with the shell kept off, so neither + // a concatenated command string nor UseShellExecute can reach Process.Start. + using var process = Process.Start(CreateSafeStartInfo(startInfo)) + ?? throw new InvalidOperationException("The child process could not be started."); + + // Reads start before the write because a child may produce more than one pipe + // buffer of output before it consumes any stdin. + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + var inputWrite = WriteAndCloseAsync(process.StandardInput, standardInput); + + try + { + Task.WhenAll(inputWrite, standardOutput, standardError).GetAwaiter().GetResult(); + } + finally + { + // WhenAll does not finish until both output streams reach EOF, so waiting here + // cannot leave the child blocked on a full redirected pipe. + process.WaitForExit(); + } + + return new ProcessResult( + process.ExitCode, + standardOutput.GetAwaiter().GetResult(), + standardError.GetAwaiter().GetResult()); + } + /// /// Drains both redirected streams, waits for exit, and returns what was captured. /// @@ -45,4 +113,49 @@ public static ProcessResult ReadToEnd(Process process) return new ProcessResult(process.ExitCode, captured[0], captured[1]); } + + private static ProcessStartInfo CreateSafeStartInfo(ProcessStartInfo startInfo) + { + var safe = new ProcessStartInfo(startInfo.FileName) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = startInfo.WorkingDirectory, + CreateNoWindow = startInfo.CreateNoWindow, + StandardInputEncoding = startInfo.StandardInputEncoding, + StandardOutputEncoding = startInfo.StandardOutputEncoding, + StandardErrorEncoding = startInfo.StandardErrorEncoding + }; + + foreach (var argument in startInfo.ArgumentList) + safe.ArgumentList.Add(argument); + + // A fresh ProcessStartInfo inherits the current environment. Clear and copy so a + // caller that removed or overrode variables keeps that view, without re-enabling + // the shell or the concatenated Arguments string. + safe.Environment.Clear(); + foreach (var pair in startInfo.Environment) + { + if (pair.Value is not null) + safe.Environment[pair.Key] = pair.Value; + } + + return safe; + } + + private static async Task WriteAndCloseAsync(StreamWriter writer, string input) + { + try + { + await writer.WriteAsync(input).ConfigureAwait(false); + } + finally + { + // Close communicates EOF even for empty input. It also flushes any text still + // buffered by StreamWriter, so failures during that final transfer propagate. + writer.Close(); + } + } } diff --git a/src/KyberWeave.Mcp/DocsTools.cs b/src/KyberWeave.Mcp/DocsTools.cs index 7bf807b..1d6f9d8 100644 --- a/src/KyberWeave.Mcp/DocsTools.cs +++ b/src/KyberWeave.Mcp/DocsTools.cs @@ -1,6 +1,9 @@ using System.ComponentModel; using System.Globalization; using System.Text; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; using KyberWeave.Core.Docs.Search; using ModelContextProtocol.Server; @@ -20,13 +23,32 @@ public sealed class DocsTools /// Code joins listed per document. private const int JoinCap = 20; + /// Hard caps for the conversational analysis surface. + private const int AnalysisCandidateCap = 20; + private const int AnalysisCharCap = 12000; + private const int AnalysisEvidenceCap = 8; + + private static readonly HashSet AnalysisKindNames = new(StringComparer.OrdinalIgnoreCase) + { + nameof(AnalysisRuleKind.Duplicate), + nameof(AnalysisRuleKind.Conflict), + nameof(AnalysisRuleKind.Terminology) + }; + private readonly DocumentIndexHost _host; + private readonly IDocsAnalysisReader? _analysisReader; public DocsTools(DocumentIndexHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); } + public DocsTools(DocumentIndexHost host, IDocsAnalysisReader analysisReader) + : this(host) + { + _analysisReader = analysisReader ?? throw new ArgumentNullException(nameof(analysisReader)); + } + [McpServerTool(Name = "docs_explore")] [Description(""" Retrieve repository documentation for a question, symbol, route, component or @@ -123,6 +145,305 @@ public string ForSymbol( return sb.ToString(); } + [McpServerTool(Name = "docs_analysis_candidates")] + [Description(""" + Read bounded documentation-analysis candidates for agent review. Use kind to limit + results to duplicate, conflict, or terminology findings, and pass the returned + cursor to continue stable paging. This tool is read-only; import reusable verdicts + through the Kyber-Weave CLI rather than returning them here. + """)] + public string AnalysisCandidates( + [Description("Optional candidate kind: duplicate, conflict, or terminology.")] string? kind = null, + [Description("Opaque candidate id returned as the next cursor by the previous page.")] string? cursor = null, + [Description("Maximum candidates to return (1-20). Defaults to 20.")] int limit = 20, + [Description("Maximum response characters (up to 12000). Defaults to 12000.")] int charBudget = 12000) + { + if (_analysisReader is null) + return "Documentation analysis is unavailable in this host."; + + if (!TryParseKind(kind, out var parsedKind)) + { + return CapToBudget( + $"Unknown documentation-analysis kind '{kind}'. Use duplicate, conflict, or terminology.", + Math.Clamp(charBudget, 0, AnalysisCharCap)); + } + + DocumentationAnalysisResult result; + try + { + result = _analysisReader.Analyze(); + } + catch (Exception exception) when (IsExpectedReadFailure(exception)) + { + return CapToBudget( + $"Documentation analysis is unavailable: {exception.Message}", + Math.Clamp(charBudget, 0, AnalysisCharCap)); + } + + var ordered = result.Candidates + .Where(candidate => parsedKind is null || candidate.Kind == parsedKind) + .OrderBy(candidate => candidate.Kind) + .ThenBy(candidate => candidate.Term, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToArray(); + var start = ResolveCursor(ordered, cursor); + if (start < 0) + { + return CapToBudget( + $"The analysis cursor '{cursor}' is no longer present. Start again without a cursor.", + Math.Clamp(charBudget, 0, AnalysisCharCap)); + } + + var effectiveLimit = Math.Clamp(limit, 1, AnalysisCandidateCap); + var effectiveBudget = Math.Clamp(charBudget, 0, AnalysisCharCap); + var sb = new StringBuilder(Math.Min(effectiveBudget, 4096)); + AppendAnalysisMetrics(sb, result); + + if (start >= ordered.Length) + { + sb.AppendLine(parsedKind is null + ? "No documentation-analysis candidates are pending." + : $"No {parsedKind.Value.ToString().ToLowerInvariant()} candidates are pending."); + return CapToBudget(sb.ToString(), effectiveBudget); + } + + var emitted = 0; + string? lastCursor = null; + while (emitted < effectiveLimit && start + emitted < ordered.Length) + { + var candidate = ordered[start + emitted]; + var moreAfter = start + emitted + 1 < ordered.Length; + var cursorFooter = moreAfter ? $"next cursor: {candidate.Id}{Environment.NewLine}" : string.Empty; + var available = effectiveBudget - sb.Length - cursorFooter.Length; + if (available <= 0) break; + + var block = FormatCandidate(candidate); + sb.Append(CapToBudget(block, available)); + emitted++; + lastCursor = candidate.Id; + + if (block.Length > available) break; + } + + if (emitted == 0) + return CapToBudget( + sb.AppendLine("The response budget is too small for a candidate.").ToString(), + effectiveBudget); + + if (start + emitted < ordered.Length && lastCursor is not null) + { + var footer = $"next cursor: {lastCursor}{Environment.NewLine}"; + if (sb.Length + footer.Length <= effectiveBudget) sb.Append(footer); + } + + return CapToBudget(sb.ToString(), effectiveBudget); + } + + [McpServerTool(Name = "docs_glossary")] + [Description(""" + Look up the managed documentation glossary for a term. Returns proposed, approved, + and rejected senses with their scopes and aliases so an agent can disambiguate + repository vocabulary. This tool is read-only and never changes glossary status. + """)] + public string Glossary( + [Description("The exact glossary term to look up, matched case-insensitively.")] string term) + { + if (_analysisReader is null) + return "The managed documentation glossary is unavailable in this host."; + if (string.IsNullOrWhiteSpace(term)) + return "Provide a glossary term to look up."; + + GlossaryLookupResult result; + try + { + result = _analysisReader.LookupGlossary(term); + } + catch (Exception exception) when (IsExpectedReadFailure(exception)) + { + return CapToBudget( + $"The managed documentation glossary is unavailable: {exception.Message}", + AnalysisCharCap); + } + + if (result.Senses.Count == 0) + { + return CapToBudget( + $"No glossary senses are declared for '{result.Term.ReplaceLineEndings(" ")}'.", + AnalysisCharCap); + } + + var sb = new StringBuilder(); + sb.Append(result.Senses.Count) + .Append(result.Senses.Count == 1 ? " glossary sense for '" : " glossary senses for '") + .Append(result.Term.ReplaceLineEndings(" ")) + .AppendLine("'."); + foreach (var sense in result.Senses + .OrderBy(sense => sense.Status) + .ThenBy(sense => sense.Id, StringComparer.Ordinal) + .Take(AnalysisCandidateCap)) + { + sb.AppendLine(); + sb.Append("sense: ").AppendLine(sense.Id); + sb.Append("status: ").AppendLine(sense.Status.ToString().ToLowerInvariant()); + sb.Append("definition: ").AppendLine( + string.IsNullOrWhiteSpace(sense.Definition) + ? "(not yet defined)" + : CapToBudget(sense.Definition.ReplaceLineEndings(" "), 1000)); + sb.Append("scope: ").AppendLine( + sense.Scopes.Count == 0 + ? "(none)" + : CapToBudget( + string.Join("; ", sense.Scopes.Take(AnalysisEvidenceCap).Select(scope => CapToBudget(scope, 240))), + 1000)); + sb.Append("aliases: ").AppendLine( + sense.Aliases.Count == 0 + ? "(none)" + : CapToBudget( + string.Join("; ", sense.Aliases.Take(AnalysisEvidenceCap).Select(alias => CapToBudget(alias, 240))), + 1000)); + } + + if (result.Senses.Count > AnalysisCandidateCap) + sb.AppendLine($"… {result.Senses.Count - AnalysisCandidateCap} more senses omitted by the hard cap."); + return CapToBudget(sb.ToString(), AnalysisCharCap); + } + + private static bool TryParseKind(string? kind, out AnalysisRuleKind? parsedKind) + { + if (string.IsNullOrWhiteSpace(kind)) + { + parsedKind = null; + return true; + } + + var trimmed = kind.Trim(); + if (AnalysisKindNames.Contains(trimmed) + && Enum.TryParse(trimmed, ignoreCase: true, out var parsed)) + { + parsedKind = parsed; + return true; + } + + parsedKind = null; + return false; + } + + private static int ResolveCursor(IReadOnlyList candidates, string? cursor) + { + if (string.IsNullOrWhiteSpace(cursor)) return 0; + + for (var index = 0; index < candidates.Count; index++) + { + if (StringComparer.Ordinal.Equals(candidates[index].Id, cursor)) return index + 1; + } + + return -1; + } + + private static void AppendAnalysisMetrics(StringBuilder sb, DocumentationAnalysisResult result) + { + var metrics = result.Metrics; + sb.AppendLine("metrics:"); + sb.Append(" extracted claims: ").AppendLine(metrics.ExtractedClaims.ToString(CultureInfo.InvariantCulture)); + sb.Append(" graph comparisons: ").AppendLine(metrics.GraphComparisons.ToString(CultureInfo.InvariantCulture)); + sb.Append(" lexical comparisons: ").AppendLine(metrics.LexicalComparisons.ToString(CultureInfo.InvariantCulture)); + sb.Append(" embedding comparisons: ").AppendLine(metrics.EmbeddingComparisons.ToString(CultureInfo.InvariantCulture)); + sb.Append(" candidates: graph ").Append(metrics.GraphCandidates.ToString(CultureInfo.InvariantCulture)) + .Append(", lexical ").Append(metrics.LexicalCandidates.ToString(CultureInfo.InvariantCulture)) + .Append(", embedding ").AppendLine(metrics.EmbeddingCandidates.ToString(CultureInfo.InvariantCulture)); + sb.Append(" truncated: ").AppendLine(metrics.Truncated ? "yes" : "no"); + AppendOptionalMetric(sb, result, "embeddingCacheHits", "embedding cache hits"); + AppendOptionalMetric(sb, result, "embeddingCacheMisses", "embedding cache misses"); + AppendOptionalMetric(sb, result, "embeddingPromptTokens", "embedding prompt tokens"); + AppendOptionalMetric(sb, result, "embeddingTotalTokens", "embedding total tokens"); + sb.Append(" diagnostics: ") + .Append(result.Diagnostics.Errors.ToString(CultureInfo.InvariantCulture)).Append(" errors, ") + .Append(result.Diagnostics.Warnings.ToString(CultureInfo.InvariantCulture)).Append(" warnings, ") + .Append(result.Diagnostics.Infos.ToString(CultureInfo.InvariantCulture)).AppendLine(" info"); + foreach (var diagnostic in result.Diagnostics.Items + .Where(item => item.Severity is Severity.Warning or Severity.Error or Severity.Critical) + .Take(3)) + { + sb.Append(" ").Append(diagnostic.Code).Append(" [") + .Append(diagnostic.Severity.ToString().ToLowerInvariant()).Append("]: ") + .AppendLine(CapToBudget(diagnostic.Message.ReplaceLineEndings(" "), 240)); + } + sb.AppendLine(); + } + + private static void AppendOptionalMetric( + StringBuilder sb, + DocumentationAnalysisResult result, + string key, + string label) + { + if (!result.Diagnostics.Metrics.TryGetValue(key, out var value)) return; + sb.Append(" ").Append(label).Append(": ").AppendLine( + Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private static string FormatCandidate(AnalysisCandidate candidate) + { + var sb = new StringBuilder(); + sb.Append("candidate: ").AppendLine(candidate.Id); + sb.Append("kind: ").AppendLine(candidate.Kind.ToString().ToLowerInvariant()); + if (!string.IsNullOrWhiteSpace(candidate.Term)) + sb.Append("term: ").AppendLine(Truncate(candidate.Term.ReplaceLineEndings(" "), 240)); + sb.Append("evidence: ").AppendLine(string.Join( + "; ", + candidate.Claims + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .Take(AnalysisEvidenceCap) + .Select(claim => $"{claim.FilePath}:{claim.StartLine}-{claim.EndLine}"))); + sb.Append("scores: lexical ") + .Append(candidate.Score.Lexical.ToString("0.000", CultureInfo.InvariantCulture)) + .Append(", semantic ") + .Append(candidate.Score.Semantic?.ToString("0.000", CultureInfo.InvariantCulture) ?? "n/a") + .Append(", graph ") + .AppendLine(candidate.Score.Graph.ToString("0.000", CultureInfo.InvariantCulture)); + if (candidate.Sources.Count > 0) + { + sb.Append("sources: ").AppendLine(string.Join( + ", ", + candidate.Sources + .OrderBy(source => source) + .Select(source => source.ToString().ToLowerInvariant()))); + } + if (candidate.Verdict is not null) + { + sb.Append("verdict: ").Append(candidate.Verdict.Label.ToString().ToLowerInvariant()) + .Append(" at ") + .AppendLine(candidate.Verdict.Confidence.ToString("0.00", CultureInfo.InvariantCulture)); + } + + foreach (var claim in candidate.Claims + .OrderBy(claim => claim.FilePath, StringComparer.Ordinal) + .ThenBy(claim => claim.StartLine) + .Take(AnalysisEvidenceCap)) + { + sb.Append(" - ").Append(claim.FilePath).Append(':') + .Append(claim.StartLine.ToString(CultureInfo.InvariantCulture)).Append('-') + .Append(claim.EndLine.ToString(CultureInfo.InvariantCulture)) + .Append(" [").Append(claim.DocumentIdentity).AppendLine("]"); + sb.Append(" ").AppendLine(Truncate(claim.Text.ReplaceLineEndings(" "), 240)); + } + if (candidate.Claims.Count > AnalysisEvidenceCap) + sb.Append(" … ").Append(candidate.Claims.Count - AnalysisEvidenceCap).AppendLine(" more evidence locations."); + sb.AppendLine(); + return sb.ToString(); + } + + private static bool IsExpectedReadFailure(Exception exception) => + exception is IOException + or UnauthorizedAccessException + or InvalidDataException + or InvalidOperationException + or ArgumentException; + + private static string CapToBudget(string text, int cap) => + text.Length <= cap ? text : text[..cap]; + private static void AppendIdentity(StringBuilder sb, DocumentHit hit) { var doc = hit.Document; diff --git a/src/KyberWeave.Mcp/IDocsAnalysisReader.cs b/src/KyberWeave.Mcp/IDocsAnalysisReader.cs new file mode 100644 index 0000000..c36cfff --- /dev/null +++ b/src/KyberWeave.Mcp/IDocsAnalysisReader.cs @@ -0,0 +1,14 @@ +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; + +namespace KyberWeave.Mcp; + +/// Read-only repository analysis used by the conversational MCP tools. +public interface IDocsAnalysisReader +{ + /// Runs bounded analysis over the repository's current configured corpus. + DocumentationAnalysisResult Analyze(); + + /// Looks up every managed glossary sense for one term. + GlossaryLookupResult LookupGlossary(string term); +} diff --git a/src/KyberWeave.Mcp/Program.cs b/src/KyberWeave.Mcp/Program.cs index 9cdb12d..4431091 100644 --- a/src/KyberWeave.Mcp/Program.cs +++ b/src/KyberWeave.Mcp/Program.cs @@ -3,6 +3,7 @@ using KyberWeave.Core.Configuration; using KyberWeave.Core.Docs.Parsing; using KyberWeave.Core.Docs.Search; +using KyberWeave.Mcp; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -33,6 +34,7 @@ () => new DocumentLoader(repoRoot, ontology).Load(), ontology.DocsRoots, ontology.ResolvedCatalogPath)); +builder.Services.AddSingleton(new RepositoryDocsAnalysisReader(repoRoot)); builder.Services .AddMcpServer() diff --git a/src/KyberWeave.Mcp/RepositoryDocsAnalysisReader.cs b/src/KyberWeave.Mcp/RepositoryDocsAnalysisReader.cs new file mode 100644 index 0000000..73edd4e --- /dev/null +++ b/src/KyberWeave.Mcp/RepositoryDocsAnalysisReader.cs @@ -0,0 +1,106 @@ +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Persistence; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Parsing; + +namespace KyberWeave.Mcp; + +/// +/// Composes the read-only documentation analyzer from the repository's current config. +/// +/// +/// Configuration and documents are loaded for each call so a long-lived MCP process sees +/// edits without owning a second staleness cache. The persistence adapter remains responsible +/// for refusing tracked or otherwise unsafe cache paths before embeddings can send prose. +/// +public sealed class RepositoryDocsAnalysisReader : IDocsAnalysisReader +{ + private readonly string _repositoryRoot; + + public RepositoryDocsAnalysisReader(string repositoryRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + _repositoryRoot = Path.GetFullPath(repositoryRoot); + } + + /// + public DocumentationAnalysisResult Analyze() + { + var config = LoadConfig(); + var documents = new DocumentLoader(_repositoryRoot, config.Ontology).Load(); + var codeGraph = CodeGraphResolverAdapter.ForRepository(_repositoryRoot); + var graph = DocGraphProjection.Build( + documents, + codeGraph, + config.DocsAnalysis.Search.MaxCodeNeighbors); + var persistence = new SqliteAnalysisPersistence(_repositoryRoot); + using var embeddingGenerator = config.DocsAnalysis.Embeddings.Mode == DocsAnalysisEmbeddingMode.Off + ? null + : new OpenAiCompatibleEmbeddingGenerator(); + var analyzer = new DocumentationAnalyzer( + new ClaimExtractor(), + [new GraphClaimCandidateSource(), new SparseLexicalCandidateSource()], + embeddingGenerator, + persistence); + var glossary = new ManagedGlossaryService( + _repositoryRoot, + config, + TimeProvider.System).Load(); + + var result = analyzer.Analyze( + documents, + graph, + config.DocsAnalysis, + glossary.AnalysisGlossary); + AddCodeGraphUnavailable(result.Diagnostics, codeGraph); + return result; + } + + /// + public GlossaryLookupResult LookupGlossary(string term) + { + ArgumentException.ThrowIfNullOrWhiteSpace(term); + var config = LoadConfig(); + return new ManagedGlossaryService( + _repositoryRoot, + config, + TimeProvider.System).Lookup(term); + } + + private KyberWeaveConfig LoadConfig() + { + var loaded = KyberWeaveConfigLoader.TryLoad(_repositoryRoot); + if (loaded.Success && loaded.Config is not null) return loaded.Config; + + throw new InvalidDataException( + $"{KyberWeaveConfigLoader.ConfigLoadErrorCode}: Failed to load " + + $"'{loaded.ConfigPath ?? "kyber-weave.yml"}': {loaded.Error ?? "unknown error"}."); + } + + private static void AddCodeGraphUnavailable( + DiagnosticReport diagnostics, + ICodeGraphResolver codeGraph) + { + if (codeGraph.IsAvailable || diagnostics.Items.Any(item => + item.Code == DocumentationAnalyzer.CodeGraphUnavailableRuleCode)) + { + return; + } + + diagnostics.Add(new Diagnostic( + DocumentationAnalyzer.CodeGraphUnavailableRuleCode, + Severity.Warning, + codeGraph.UnavailableReason ?? "The CodeGraph index is unavailable.", + "CodeGraph", + codeGraph.DatabasePath, + "Analysis continues with document relationships and bounded lexical search.")); + } +} diff --git a/tests/KyberWeave.Tests/AnalysisPersistenceTests.cs b/tests/KyberWeave.Tests/AnalysisPersistenceTests.cs new file mode 100644 index 0000000..bca8c9d --- /dev/null +++ b/tests/KyberWeave.Tests/AnalysisPersistenceTests.cs @@ -0,0 +1,632 @@ +using System.ComponentModel; +using System.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Persistence; +using KyberWeave.Core.Processes; +using Xunit; +using Xunit.Sdk; + +namespace KyberWeave.Tests; + +/// +/// T07 — the analysis cache is optional for ordinary analysis, but once enabled its +/// content-addressed vectors and review decisions must be durable and safe to reuse. +/// +public sealed class AnalysisPersistenceTests +{ + private static readonly float[] ExpectedNormalizedVector = [0.6f, 0.8f]; + + [Theory] + [InlineData("cache/\n", true)] + [InlineData("# Kyber-Weave local state\ncache/\n", true)] + [InlineData("cache\n", false)] + [InlineData("/cache/\n", false)] + [InlineData(".kyber-weave/cache/\n", false)] + [InlineData("cache/*\n", false)] + public void IsSafe_RequiresExactCacheDirectoryIgnoreEntry(string ignoreContents, bool expected) + { + using var repository = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), ignoreContents); + + var actual = AnalysisCacheSafety.IsSafe(repository.Path); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("cache/\n!cache/\n")] + [InlineData("cache/\n!cache/docs-analysis.sqlite3\n")] + [InlineData("cache/\n!**/cache/**\n")] + public void IsSafe_WhenLaterRuleNegatesCacheProtection_ReturnsFalse(string ignoreContents) + { + using var repository = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), ignoreContents); + + Assert.False(AnalysisCacheSafety.IsSafe(repository.Path)); + } + + [Fact] + public void IsSafe_WhenDatabaseIsAlreadyTracked_ReturnsFalse() + { + RequireGit(); + using var repository = SafeRepository(createCache: true); + var databasePath = DatabasePath(repository.Path); + File.WriteAllText(databasePath, "tracked cache placeholder"); + RunGit(repository.Path, "init"); + RunGit(repository.Path, "add", "-f", ".kyber-weave/cache/docs-analysis.sqlite3"); + + Assert.False(AnalysisCacheSafety.IsSafe(repository.Path)); + } + + [Theory] + [InlineData("state")] + [InlineData("cache")] + [InlineData("database")] + public void Constructor_WhenPersistencePathContainsSymbolicLink_RejectsWithoutChangingExternalTarget( + string linkedSegment) + { + RequireSqlite(); + if (OperatingSystem.IsWindows()) + throw SkipException.ForSkip("Symbolic-link creation requires platform-specific privileges on Windows."); + + using var repository = new TempDirectory(); + using var external = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + var cacheDirectory = Path.Combine(stateDirectory, "cache"); + var databasePath = Path.Combine(cacheDirectory, "docs-analysis.sqlite3"); + var sentinelPath = Path.Combine(external.Path, "sentinel.txt"); + File.WriteAllText(sentinelPath, "external state must remain unchanged"); + + switch (linkedSegment) + { + case "state": + Directory.CreateSymbolicLink(stateDirectory, external.Path); + File.WriteAllText(Path.Combine(external.Path, ".gitignore"), "cache/\n"); + break; + case "cache": + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), "cache/\n"); + Directory.CreateSymbolicLink(cacheDirectory, external.Path); + break; + case "database": + Directory.CreateDirectory(cacheDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), "cache/\n"); + File.CreateSymbolicLink(databasePath, sentinelPath); + break; + } + + Assert.Throws(() => new SqliteAnalysisPersistence(repository.Path)); + Assert.Equal("external state must remain unchanged", File.ReadAllText(sentinelPath)); + Assert.False(File.Exists(Path.Combine(external.Path, "docs-analysis.sqlite3"))); + } + + [Fact] + public void Constructor_WhenCacheIsNotSafelyIgnored_DisablesReadsAndWritesWithoutCreatingState() + { + using var repository = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + var cacheDirectory = Path.Combine(stateDirectory, "cache"); + + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + + Assert.False(persistence.IsAvailable); + Assert.Empty(persistence.LoadClaims(["claim-1"])); + Assert.Empty(persistence.LoadCandidateFingerprints(["candidate-1"])); + Assert.Empty(persistence.LoadVerdicts(["candidate-1"])); + Assert.Empty(persistence.LoadEmbeddings([Key("context-1")])); + Assert.False(Directory.Exists(stateDirectory)); + Assert.False(Directory.Exists(cacheDirectory)); + Assert.Throws(() => persistence.SaveClaims([Claim("claim-1")])); + Assert.Throws(() => persistence.SaveCandidateFingerprints([Candidate("candidate-1")])); + Assert.Throws(() => persistence.SaveVerdicts([Verdict("candidate-1")])); + Assert.Throws(() => persistence.SaveEmbeddings([Embedding(Key("context-1"))])); + Assert.False(Directory.Exists(stateDirectory)); + Assert.False(Directory.Exists(cacheDirectory)); + } + + [Fact] + public void Constructor_WhenCacheIsSafe_InitializesVersionedSchemaIdempotently() + { + RequireSqlite(); + using var repository = SafeRepository(); + var first = new SqliteAnalysisPersistence(repository.Path); + var claim = Claim("claim-idempotent"); + first.SaveClaims([claim]); + + var second = new SqliteAnalysisPersistence(repository.Path); + + Assert.True(first.IsAvailable); + Assert.True(second.IsAvailable); + Assert.Equal( + SqliteAnalysisPersistence.SchemaVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + QuerySqlite(second.DatabasePath, "PRAGMA user_version;").Trim()); + Assert.Equal(claim, Assert.Single(second.LoadClaims([claim.Id])).Value); + } + + [Fact] + public void SaveAndLoadClaims_PreservesDuplicateOccurrencesAndLineAddressableText() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var first = Claim("claim-left", filePath: "docs/left.md", startLine: 11); + var second = Claim("claim-right", filePath: "docs/right.md", startLine: 29); + + persistence.SaveClaims([first, second]); + var loaded = persistence.LoadClaims([first.Id, second.Id]); + + Assert.Equal(first, loaded[first.Id]); + Assert.Equal(second, loaded[second.Id]); + Assert.Equal(first.ContentHash, second.ContentHash); + } + + [Fact] + public void SaveAndLoadEmbeddings_UsesEveryContentProviderModelAndShapeKeyField() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var storedKey = Key("context", provider: "local-a", model: "embed-a", dimensions: 2); + persistence.SaveEmbeddings([Embedding(storedKey)]); + var requested = new[] + { + storedKey, + Key("other-context", provider: "local-a", model: "embed-a", dimensions: 2), + Key("context", provider: "local-b", model: "embed-a", dimensions: 2), + Key("context", provider: "local-a", model: "embed-b", dimensions: 2), + Key("context", provider: "local-a", model: "embed-a", dimensions: 3), + Key("context", provider: "local-a", model: "embed-a", dimensions: 2, encoding: "base64") + }; + + var loaded = persistence.LoadEmbeddings(requested); + + var embedding = Assert.Single(loaded).Value; + Assert.Equal(storedKey, embedding.Key); + Assert.Equal(ExpectedNormalizedVector, embedding.Vector); + } + + [Fact] + public void SaveAndLoadCandidateVerdict_PreservesReviewFingerprintAndUnicodeText() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var candidate = Candidate("candidate-review"); + var verdict = new AnalysisVerdict( + candidate.CandidateId, + AnalysisVerdictLabel.DistinctSenses, + 0.91, + "Gameplay loop and Codex loop are distinct — reviewer’s rationale.", + ["claim-left", "claim-right"], + "docs/glossary.md#loop", + [new ProposedGlossarySense("loop", "An autonomous Codex cycle.", ["component:Agents"], ["churn loop"])]); + + persistence.SaveCandidateFingerprints([candidate]); + persistence.SaveVerdicts([verdict]); + var loadedCandidate = Assert.Single( + persistence.LoadCandidateFingerprints([candidate.CandidateId])).Value; + var loadedVerdict = Assert.Single(persistence.LoadVerdicts([candidate.CandidateId])).Value; + + AssertCandidateEqual(candidate, loadedCandidate); + Assert.Equal(verdict.CandidateId, loadedVerdict.CandidateId); + Assert.Equal(verdict.Label, loadedVerdict.Label); + Assert.Equal(verdict.Confidence, loadedVerdict.Confidence); + Assert.Equal(verdict.Rationale, loadedVerdict.Rationale); + Assert.Equal(verdict.EvidenceIds, loadedVerdict.EvidenceIds); + Assert.Equal(verdict.RecommendedCanonicalLocation, loadedVerdict.RecommendedCanonicalLocation); + var expectedSense = Assert.Single(verdict.ProposedGlossarySenses!); + var actualSense = Assert.Single(loadedVerdict.ProposedGlossarySenses!); + Assert.Equal(expectedSense.Term, actualSense.Term); + Assert.Equal(expectedSense.Definition, actualSense.Definition); + Assert.Equal(expectedSense.Scopes, actualSense.Scopes); + Assert.Equal(expectedSense.Aliases, actualSense.Aliases); + } + + [Fact] + public void SaveBatches_WithApostrophesNewlinesAndSqlTokens_RoundTripWithoutExecutingInput() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + const string hostile = "'close\n); DROP TABLE analysis_claims; --\nnext"; + var claim = Claim(hostile, text: "Operator's first line\nsecond line; SELECT * FROM verdicts;"); + var candidate = Candidate(hostile); + var verdict = Verdict(hostile, rationale: "It isn't a conflict.\nKeep both; -- literally."); + + persistence.SaveClaims([claim]); + persistence.SaveCandidateFingerprints([candidate]); + persistence.SaveVerdicts([verdict]); + + Assert.Equal(claim, persistence.LoadClaims([hostile])[hostile]); + AssertCandidateEqual(candidate, persistence.LoadCandidateFingerprints([hostile])[hostile]); + Assert.Equal(verdict.Rationale, persistence.LoadVerdicts([hostile])[hostile].Rationale); + persistence.SaveClaims([Claim("schema-still-present")]); + Assert.Single(persistence.LoadClaims(["schema-still-present"])); + } + + [Fact] + public void SaveEmbeddings_WhenAnyVectorIsNotFiniteAndNormalized_RollsBackWholeBatch() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var valid = Embedding(Key("valid")); + var invalid = new StoredEmbedding(Key("invalid"), [float.NaN, 1f]); + + Assert.Throws(() => persistence.SaveEmbeddings([valid, invalid])); + + Assert.Empty(persistence.LoadEmbeddings([valid.Key, invalid.Key])); + } + + [Fact] + public void SaveVerdicts_WhenAnyVerdictIsInvalid_RollsBackWholeBatch() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var first = Candidate("candidate-valid"); + var second = Candidate("candidate-invalid"); + persistence.SaveCandidateFingerprints([first, second]); + + Assert.Throws(() => persistence.SaveVerdicts( + [ + Verdict(first.CandidateId), + Verdict(second.CandidateId) with { Confidence = double.NaN } + ])); + + Assert.Empty(persistence.LoadVerdicts([first.CandidateId, second.CandidateId])); + } + + [Fact] + public void Constructor_WhenMigrationFails_RollsBackSchemaAndVersion() + { + RequireSqlite(); + using var repository = SafeRepository(createCache: true); + var databasePath = DatabasePath(repository.Path); + RunSqlite( + databasePath, + "CREATE TABLE analysis_claims (broken TEXT);\nPRAGMA user_version = 0;"); + var originalSchema = QuerySqlite(databasePath, ".schema analysis_claims"); + + Assert.Throws(() => new SqliteAnalysisPersistence(repository.Path)); + + Assert.Equal("0", QuerySqlite(databasePath, "PRAGMA user_version;").Trim()); + Assert.Equal(originalSchema, QuerySqlite(databasePath, ".schema analysis_claims")); + } + + [Fact] + public void Constructor_WhenDatabaseIsInvalid_DoesNotOverwriteIt() + { + RequireSqlite(); + using var repository = SafeRepository(createCache: true); + var databasePath = DatabasePath(repository.Path); + var original = "not a sqlite database\nwith operator-owned evidence"; + File.WriteAllText(databasePath, original); + + Assert.Throws(() => new SqliteAnalysisPersistence(repository.Path)); + + Assert.Equal(original, File.ReadAllText(databasePath)); + } + + [Theory] + [InlineData("analysis_claims", "stored-id", "payload-id")] + [InlineData("analysis_candidates", "stored-id", "payload-id")] + [InlineData("analysis_verdicts", "stored-id", "payload-id")] + public void Load_WhenRowKeyDoesNotMatchPayloadIdentity_ReportsCorruptCache( + string table, + string rowId, + string payloadId) + { + RequireSqlite(); + using var repository = SafeRepository(); + var persistence = new SqliteAnalysisPersistence(repository.Path); + var payload = table switch + { + "analysis_claims" => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Claim(payloadId)), + "analysis_candidates" => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Candidate(payloadId)), + "analysis_verdicts" => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Verdict(payloadId)), + _ => throw new InvalidOperationException() + }; + var keyColumn = table == "analysis_claims" ? "id" : "candidate_id"; + RunSqlite( + persistence.DatabasePath, + $"PRAGMA foreign_keys=OFF; INSERT INTO {table}({keyColumn}, payload) VALUES " + + $"({Blob(rowId)}, {Blob(payload)});"); + + var exception = Assert.Throws(() => table switch + { + "analysis_claims" => persistence.LoadClaims([rowId]), + "analysis_candidates" => persistence.LoadCandidateFingerprints([rowId]), + "analysis_verdicts" => persistence.LoadVerdicts([rowId]), + _ => throw new InvalidOperationException() + }); + + Assert.Contains("invalid", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("claim-empty-id")] + [InlineData("candidate-empty-hashes")] + [InlineData("verdict-invalid-label")] + [InlineData("verdict-invalid-confidence")] + [InlineData("verdict-empty-rationale")] + public void Load_WhenPersistedPayloadViolatesDomainContract_ReportsCorruptCache(string scenario) + { + RequireSqlite(); + using var repository = SafeRepository(); + var persistence = new SqliteAnalysisPersistence(repository.Path); + var (table, keyColumn, rowId, payload) = scenario switch + { + "claim-empty-id" => ( + "analysis_claims", + "id", + "claim-row", + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Claim(string.Empty))), + "candidate-empty-hashes" => ( + "analysis_candidates", + "candidate_id", + "candidate-row", + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( + Candidate("candidate-row") with { ClaimContentHashes = [] })), + "verdict-invalid-label" => ( + "analysis_verdicts", + "candidate_id", + "verdict-row", + System.Text.Encoding.UTF8.GetBytes( + "{\"CandidateId\":\"verdict-row\",\"Label\":999," + + "\"Confidence\":0.9,\"Rationale\":\"reviewed\"}")), + "verdict-invalid-confidence" => ( + "analysis_verdicts", + "candidate_id", + "verdict-row", + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( + Verdict("verdict-row") with { Confidence = 1.1 })), + "verdict-empty-rationale" => ( + "analysis_verdicts", + "candidate_id", + "verdict-row", + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( + Verdict("verdict-row") with { Rationale = string.Empty })), + _ => throw new InvalidOperationException() + }; + RunSqlite( + persistence.DatabasePath, + $"PRAGMA foreign_keys=OFF; INSERT INTO {table}({keyColumn}, payload) VALUES " + + $"({Blob(rowId)}, {Blob(payload)});"); + + var exception = Assert.Throws(() => table switch + { + "analysis_claims" => persistence.LoadClaims([rowId]), + "analysis_candidates" => persistence.LoadCandidateFingerprints([rowId]), + "analysis_verdicts" => persistence.LoadVerdicts([rowId]), + _ => throw new InvalidOperationException() + }); + + Assert.Contains("invalid", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SaveClaims_WhenDatabaseHasConcurrentImmediateTransaction_DoesNotMisreportCorruption() + { + RequireSqlite(); + using var repository = SafeRepository(); + var persistence = new SqliteAnalysisPersistence(repository.Path); + using var lockProcess = StartSqliteLock(persistence.DatabasePath); + try + { + var exception = Record.Exception(() => persistence.SaveClaims([Claim("contended-claim")])); + + if (exception is null) + { + Assert.Single(persistence.LoadClaims(["contended-claim"])); + } + else + { + Assert.IsNotType(exception); + Assert.Contains("lock", exception.Message, StringComparison.OrdinalIgnoreCase); + } + } + finally + { + lockProcess.StandardInput.WriteLine("ROLLBACK;"); + lockProcess.StandardInput.Close(); + Assert.True(lockProcess.WaitForExit(5_000), "sqlite lock fixture did not exit."); + } + } + + [Fact] + public void PublicPersistenceContract_DoesNotAcceptCredentialsOrRequestHeaders() + { + var publicNames = typeof(SqliteAnalysisPersistence) + .GetMembers() + .SelectMany(member => member switch + { + System.Reflection.MethodBase method => + method.GetParameters().Select(parameter => parameter.Name ?? string.Empty), + System.Reflection.PropertyInfo property => [property.Name], + _ => [] + }) + .ToArray(); + + Assert.DoesNotContain(publicNames, name => + name.Contains("credential", StringComparison.OrdinalIgnoreCase) + || name.Contains("authorization", StringComparison.OrdinalIgnoreCase) + || name.Contains("apiKey", StringComparison.OrdinalIgnoreCase) + || name.Contains("header", StringComparison.OrdinalIgnoreCase)); + } + + private static TempDirectory SafeRepository(bool createCache = false) + { + var repository = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), "cache/\n"); + if (createCache) Directory.CreateDirectory(Path.Combine(stateDirectory, "cache")); + return repository; + } + + private static string DatabasePath(string repositoryRoot) => + Path.Combine(repositoryRoot, ".kyber-weave", "cache", "docs-analysis.sqlite3"); + + private static string Blob(string value) => Blob(System.Text.Encoding.UTF8.GetBytes(value)); + + private static string Blob(byte[] value) => $"X'{Convert.ToHexString(value)}'"; + + private static PersistedClaim Claim( + string id, + string filePath = "docs/reference.md", + int startLine = 7, + string text = "The processor retains approved reviewer verdicts.") => + new( + id, + "shared-content-hash", + "context-" + id, + "reference/runtime", + filePath, + startLine, + startLine + 1, + text); + + private static PersistedCandidateFingerprint Candidate(string id) => + new( + id, + AnalysisRuleKind.Conflict, + "loop", + "candidate-set-v1", + DocumentationAnalyzer.AnalyzerVersion, + DocumentationAnalyzer.RubricVersion, + ["shared-content-hash", "other-content-hash"]); + + private static AnalysisVerdict Verdict( + string candidateId, + string rationale = "The scopes are intentionally compatible.") => + new(candidateId, AnalysisVerdictLabel.Benign, 0.95, rationale, ["claim-left", "claim-right"]); + + private static void AssertCandidateEqual( + PersistedCandidateFingerprint expected, + PersistedCandidateFingerprint actual) + { + Assert.Equal(expected.CandidateId, actual.CandidateId); + Assert.Equal(expected.Kind, actual.Kind); + Assert.Equal(expected.NormalizedTerm, actual.NormalizedTerm); + Assert.Equal(expected.CandidateSetHash, actual.CandidateSetHash); + Assert.Equal(expected.AnalyzerVersion, actual.AnalyzerVersion); + Assert.Equal(expected.RubricVersion, actual.RubricVersion); + Assert.Equal(expected.ClaimContentHashes, actual.ClaimContentHashes); + } + + private static EmbeddingCacheKey Key( + string context, + string provider = "http://127.0.0.1:1234/v1/embeddings", + string model = "local-model", + int? dimensions = 2, + string encoding = "float") => + new(context, provider, model, dimensions, encoding); + + private static StoredEmbedding Embedding(EmbeddingCacheKey key) => new(key, [0.6f, 0.8f]); + + private static void RequireSqlite() + { + var startInfo = SqliteStartInfo(); + startInfo.ArgumentList.Add("--version"); + try + { + var result = ProcessRunner.Run(startInfo, string.Empty); + if (result.ExitCode != 0) + throw SkipException.ForSkip("sqlite3 is unavailable; SQLite adapter parity was not run."); + } + catch (Win32Exception) + { + throw SkipException.ForSkip("sqlite3 is unavailable; SQLite adapter parity was not run."); + } + } + + private static void RequireGit() + { + var startInfo = ProcessStartInfo("git"); + startInfo.ArgumentList.Add("--version"); + try + { + if (ProcessRunner.Run(startInfo, string.Empty).ExitCode != 0) + throw SkipException.ForSkip("git is unavailable; tracked-file safety was not run."); + } + catch (Win32Exception) + { + throw SkipException.ForSkip("git is unavailable; tracked-file safety was not run."); + } + } + + private static void RunGit(string workingDirectory, params string[] arguments) + { + var startInfo = ProcessStartInfo("git"); + startInfo.WorkingDirectory = workingDirectory; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + var result = ProcessRunner.Run(startInfo, string.Empty); + if (result.ExitCode != 0) throw new InvalidOperationException(result.StandardError); + } + + private static Process StartSqliteLock(string databasePath) + { + var startInfo = SqliteStartInfo(); + startInfo.ArgumentList.Add(databasePath); + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start sqlite lock fixture."); + process.StandardInput.WriteLine("BEGIN IMMEDIATE;"); + process.StandardInput.Flush(); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + var result = RunSqliteAllowFailure(databasePath, ".timeout 1\nBEGIN IMMEDIATE;"); + if (result.ExitCode != 0 + && result.StandardError.Contains("locked", StringComparison.OrdinalIgnoreCase)) + { + return process; + } + } + + process.StandardInput.WriteLine("ROLLBACK;"); + process.StandardInput.Close(); + process.WaitForExit(); + throw new TimeoutException("sqlite lock fixture did not acquire an immediate transaction."); + } + + private static string QuerySqlite(string databasePath, string input) + { + var result = RunSqlite(databasePath, input); + return result.StandardOutput; + } + + private static ProcessResult RunSqlite(string databasePath, string input) + { + var result = RunSqliteAllowFailure(databasePath, input); + if (result.ExitCode != 0) + throw new InvalidOperationException(result.StandardError); + return result; + } + + private static ProcessResult RunSqliteAllowFailure(string databasePath, string input) + { + var startInfo = SqliteStartInfo(); + startInfo.ArgumentList.Add("-batch"); + startInfo.ArgumentList.Add("-bail"); + startInfo.ArgumentList.Add(databasePath); + return ProcessRunner.Run(startInfo, input); + } + + private static ProcessStartInfo SqliteStartInfo() => ProcessStartInfo("sqlite3"); + + private static ProcessStartInfo ProcessStartInfo(string fileName) => + new(fileName) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; +} diff --git a/tests/KyberWeave.Tests/ClaimExtractionTests.cs b/tests/KyberWeave.Tests/ClaimExtractionTests.cs new file mode 100644 index 0000000..ebe98cd --- /dev/null +++ b/tests/KyberWeave.Tests/ClaimExtractionTests.cs @@ -0,0 +1,192 @@ +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Pins the structural claim boundary used by duplicate, conflict, and terminology +/// analysis. A claim must remain traceable to source even though retrieval stores a +/// frontmatter-free body. +/// +public sealed class ClaimExtractionTests +{ + [Fact] + public void Extract_WithSupportedMarkdownBlocks_ReturnsLineAddressableClaimsInSourceOrder() + { + const string body = """ + # Claim extraction + + ## Runtime + + First paragraph wraps onto + its second source line. + + - Direct list item. + + | Term | Meaning | + | --- | --- | + | loop | gameplay wrapper | + + ```csharp title="sample" + Console.WriteLine("loop"); + ``` + + ~~~json + {"loop":"codex"} + ~~~ + """; + var document = Document(body); + + var result = new ClaimExtractor().Extract(document); + + Assert.Empty(result.Diagnostics.Items); + Assert.Collection( + result.Claims, + claim => AssertClaim( + claim, + ClaimKind.Paragraph, + "First paragraph wraps onto its second source line.", + 13, + 14), + claim => AssertClaim(claim, ClaimKind.ListItem, "Direct list item.", 16, 16), + claim => + { + AssertClaim(claim, ClaimKind.TableRow, "loop | gameplay wrapper", 20, 20); + Assert.Contains("Term: loop", claim.ContextualText, StringComparison.Ordinal); + Assert.Contains("Meaning: gameplay wrapper", claim.ContextualText, StringComparison.Ordinal); + }, + claim => + { + AssertClaim(claim, ClaimKind.CodeBlock, "Console.WriteLine(\"loop\");", 22, 24); + Assert.Contains("csharp title=\"sample\"", claim.ContextualText, StringComparison.Ordinal); + }, + claim => + { + AssertClaim(claim, ClaimKind.CodeBlock, "{\"loop\":\"codex\"}", 26, 28); + Assert.Contains("json", claim.ContextualText, StringComparison.Ordinal); + }); + } + + [Fact] + public void Extract_WithSameProseUnderDifferentSections_SeparatesContentAndContextHashes() + { + const string body = """ + # Hashes + + ## Gameplay + + Run the Loop now. + + ## Automation + + run the loop now + """; + + var claims = new ClaimExtractor().Extract(Document(body)).Claims; + + Assert.Equal(2, claims.Count); + Assert.Equal(claims[0].ContentHash, claims[1].ContentHash); + Assert.NotEqual(claims[0].ContextualHash, claims[1].ContextualHash); + Assert.StartsWith("Gameplay", claims[0].ContextualText, StringComparison.Ordinal); + Assert.StartsWith("Automation", claims[1].ContextualText, StringComparison.Ordinal); + } + + [Fact] + public void Extract_WithCaseDistinctCode_DoesNotApplyEnglishProseNormalizationToCode() + { + const string body = """ + # Hashes + + ## Runtime + + ```csharp + Loop.Run(); + ``` + + ```csharp + loop.Run(); + ``` + """; + + var claims = new ClaimExtractor().Extract(Document(body)).Claims; + + Assert.Equal(2, claims.Count); + Assert.All(claims, claim => Assert.Equal(ClaimKind.CodeBlock, claim.Kind)); + Assert.NotEqual(claims[0].ContentHash, claims[1].ContentHash); + } + + [Fact] + public void Extract_WithInlineCodeAndLiteralPlaceholderText_RestoresFenceWithoutColliding() + { + const string body = """ + # Claims + + ## Runtime + + Run `dotnet test` even if the prose mentions KYBERINLINELITERAL0END. + """; + + var claim = Assert.Single(new ClaimExtractor().Extract(Document(body)).Claims); + + Assert.Contains("`dotnet test`", claim.ContextualText, StringComparison.Ordinal); + Assert.Contains("KYBERINLINELITERAL0END", claim.ContextualText, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet testEND", claim.ContextualText, StringComparison.Ordinal); + } + + private static void AssertClaim( + Claim claim, + ClaimKind expectedKind, + string expectedText, + int expectedStartLine, + int expectedEndLine) + { + Assert.Equal(expectedKind, claim.Kind); + Assert.Equal(expectedText, claim.Text); + Assert.Equal("Runtime", claim.Section); + Assert.Equal(expectedStartLine, claim.StartLine); + Assert.Equal(expectedEndLine, claim.EndLine); + Assert.Equal("docs/claims", claim.DocumentIdentity); + Assert.Equal("DocGraph", claim.Component); + Assert.False(string.IsNullOrWhiteSpace(claim.ContentHash)); + Assert.False(string.IsNullOrWhiteSpace(claim.ContextualHash)); + Assert.Contains("Runtime", claim.ContextualText, StringComparison.Ordinal); + } + + internal static DocumentModel Document( + string body, + string? rawMarkdown = null, + int bodyStartLine = 9) + { + rawMarkdown ??= """ + --- + id: docs/claims + title: Claims + doc-type: reference + status: current + component: DocGraph + --- + + """ + body; + + return new DocumentModel + { + RelativePath = "docs/claims.md", + FilePath = "/repo/docs/claims.md", + HasFrontmatter = true, + Frontmatter = new DocumentFrontmatter + { + Id = "docs/claims", + Title = "Claims", + DocType = "reference", + Status = "current", + Component = "DocGraph" + }, + DocType = DocType.Reference, + Status = DocStatus.Current, + Body = body, + RawMarkdown = rawMarkdown, + BodyStartLine = bodyStartLine + }; + } +} diff --git a/tests/KyberWeave.Tests/CodeGraphFixtureDb.cs b/tests/KyberWeave.Tests/CodeGraphFixtureDb.cs index 47beb58..bd12cad 100644 --- a/tests/KyberWeave.Tests/CodeGraphFixtureDb.cs +++ b/tests/KyberWeave.Tests/CodeGraphFixtureDb.cs @@ -12,7 +12,9 @@ public CodeGraphFixtureDb() var dir = Path.Combine(Path.GetTempPath(), "kw-codegraph-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); DatabasePath = Path.Combine(dir, "codegraph.db"); - RunSqlite($"CREATE TABLE nodes (id TEXT, kind TEXT, name TEXT, qualified_name TEXT, file_path TEXT, language TEXT, start_line INTEGER);"); + RunSqlite( + "CREATE TABLE nodes (id TEXT, kind TEXT, name TEXT, qualified_name TEXT, file_path TEXT, language TEXT, start_line INTEGER); " + + "CREATE TABLE edges (source TEXT, target TEXT, kind TEXT);"); } public void IndexSymbol(string name, string filePath, int startLine) => @@ -27,6 +29,9 @@ public void IndexFile(string filePath) => RunSqlite( $"INSERT INTO nodes VALUES ('file-{filePath.GetHashCode()}', 'import', 'file', 'file', '{filePath}', 'csharp', 0);"); + public void IndexEdge(string sourceId, string targetId, string kind) => + RunSqlite($"INSERT INTO edges VALUES ('{sourceId}', '{targetId}', '{kind}');"); + private void RunSqlite(string sql) { var startInfo = new System.Diagnostics.ProcessStartInfo("sqlite3") diff --git a/tests/KyberWeave.Tests/CodeGraphNeighborhoodPortTests.cs b/tests/KyberWeave.Tests/CodeGraphNeighborhoodPortTests.cs new file mode 100644 index 0000000..2440d4e --- /dev/null +++ b/tests/KyberWeave.Tests/CodeGraphNeighborhoodPortTests.cs @@ -0,0 +1,69 @@ +using KyberWeave.Core.CodeGraph; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// T05 RED — CodeGraph neighborhood traversal is optional and batch-oriented. Existing +/// resolvers remain valid; adapters that expose neighborhoods preload them with the nodes. +/// +public sealed class CodeGraphNeighborhoodPortTests +{ + [Fact] + public void ICodeGraphNeighborhoodProvider_Is_Optional_For_Existing_Resolvers() + { + ICodeGraphResolver resolver = FakeCodeGraphResolver.WithSymbols( + ("Known", Node("known", "Known"))); + + Assert.IsNotAssignableFrom(resolver); + Assert.Single(resolver.ResolveSymbol("Known")); + } + + [Fact] + public void CodeGraphResolverAdapter_Loads_Nodes_And_Edges_Before_The_Index_Is_Removed() + { + var fixture = new CodeGraphFixtureDb(); + try + { + fixture.IndexSymbol("Caller", "src/Caller.cs", 10); + fixture.IndexSymbol("Callee", "src/Callee.cs", 20); + fixture.IndexEdge("id-Caller", "id-Callee", "calls"); + + var adapter = new CodeGraphResolverAdapter(fixture.DatabasePath); + var provider = Assert.IsAssignableFrom(adapter); + + // A neighborhood lookup must be in-memory. Removing the database after the + // constructor proves GetEdges does not launch sqlite once per edge or node. + fixture.Dispose(); + + var edge = Assert.Single(provider.GetEdges(["id-Caller", "id-Callee"], maxDegree: 50)); + Assert.Equal(new CodeGraphEdge("id-Caller", "id-Callee", "calls"), edge); + Assert.Single(adapter.ResolveSymbol("Caller")); + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void CodeGraphResolverAdapter_Neighborhood_Query_Is_Batched_And_Applies_Degree_Cap() + { + using var fixture = new CodeGraphFixtureDb(); + fixture.IndexSymbol("Hub", "src/Hub.cs", 1); + fixture.IndexSymbol("First", "src/First.cs", 2); + fixture.IndexSymbol("Second", "src/Second.cs", 3); + fixture.IndexEdge("id-Hub", "id-First", "calls"); + fixture.IndexEdge("id-Hub", "id-Second", "references"); + + var provider = Assert.IsAssignableFrom( + new CodeGraphResolverAdapter(fixture.DatabasePath)); + + var capped = provider.GetEdges(["id-Hub", "id-First", "id-Second"], maxDegree: 1); + + Assert.Empty(capped); + } + + private static CodeGraphNode Node(string id, string name) => + new(id, "class", name, name, $"src/{name}.cs", "csharp", 1); +} diff --git a/tests/KyberWeave.Tests/DiagnosticLocationRenderingTests.cs b/tests/KyberWeave.Tests/DiagnosticLocationRenderingTests.cs new file mode 100644 index 0000000..6799626 --- /dev/null +++ b/tests/KyberWeave.Tests/DiagnosticLocationRenderingTests.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using KyberWeave.Cli.Rendering; +using KyberWeave.Core.Diagnostics; +using Xunit; + +namespace KyberWeave.Tests; + +public sealed class DiagnosticLocationRenderingTests +{ + [Fact] + public void Diagnostic_WithOptionalRangesAndRelatedLocations_PreservesExistingConstructors() + { + var legacy = new Diagnostic( + "KW-LEGACY-001", + Severity.Info, + "Legacy diagnostic", + "legacy-subject", + "docs/legacy.md", + "Legacy hint"); + var related = new DiagnosticLocation( + "docs/related.md", + StartLine: 21, + EndLine: 23, + Message: "Matching claim"); + + var ranged = new Diagnostic( + "KW-DOC-ANALYSIS-001", + Severity.Warning, + "Duplicate claim", + "claim-a", + "docs/primary.md", + "Choose one canonical claim", + StartLine: 10, + EndLine: 12, + RelatedLocations: [related]); + + Assert.Equal("docs/legacy.md", legacy.FilePath); + Assert.Equal("Legacy hint", legacy.Hint); + Assert.Equal(10, ranged.StartLine); + Assert.Equal(12, ranged.EndLine); + Assert.Equal(related, Assert.Single(ranged.RelatedLocations)); + } + + [Fact] + public void DiagnosticReport_WithScalarMetrics_PreservesInsertionOrderAndValues() + { + var report = new DiagnosticReport(); + + report.AddMetric("extractedClaims", 42); + report.AddMetric("candidateRatio", 0.25); + report.AddMetric("truncated", false); + report.AddMetric("searchMode", "hybrid"); + + Assert.Equal( + ["extractedClaims", "candidateRatio", "truncated", "searchMode"], + report.Metrics.Select(metric => metric.Key)); + Assert.Equal(42, report.Metrics["extractedClaims"]); + Assert.Equal(0.25, report.Metrics["candidateRatio"]); + Assert.Equal(false, report.Metrics["truncated"]); + Assert.Equal("hybrid", report.Metrics["searchMode"]); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void DiagnosticReport_WithNonFiniteFloatingPointMetric_RejectsAtAddMetric(object value) + { + var report = new DiagnosticReport(); + + var exception = Assert.Throws(() => report.AddMetric("score", value)); + + Assert.Equal("value", exception.ParamName); + Assert.Empty(report.Metrics); + } + + [Fact] + public void Render_WithFiniteFloatingPointMetrics_WritesJsonAndSarifNumbers() + { + var report = new DiagnosticReport(); + report.AddMetric("floatScore", 0.5f); + report.AddMetric("doubleScore", 0.25d); + + using var json = JsonDocument.Parse(Render(OutputFormat.Json, report)); + using var sarif = JsonDocument.Parse(Render(OutputFormat.Sarif, report)); + + var jsonMetrics = json.RootElement.GetProperty("metrics"); + var sarifMetrics = sarif.RootElement + .GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("metrics"); + Assert.Equal(0.5, jsonMetrics.GetProperty("floatScore").GetDouble()); + Assert.Equal(0.25, jsonMetrics.GetProperty("doubleScore").GetDouble()); + Assert.Equal(0.5, sarifMetrics.GetProperty("floatScore").GetDouble()); + Assert.Equal(0.25, sarifMetrics.GetProperty("doubleScore").GetDouble()); + } + + [Fact] + public void Render_Json_IncludesRangesRelatedLocationsAndOrderedMetrics() + { + using var document = JsonDocument.Parse(Render(OutputFormat.Json)); + var root = document.RootElement; + var finding = root.GetProperty("findings")[0]; + var related = finding.GetProperty("relatedLocations")[0]; + + Assert.Equal(10, finding.GetProperty("startLine").GetInt32()); + Assert.Equal(12, finding.GetProperty("endLine").GetInt32()); + Assert.Equal("docs/related.md", related.GetProperty("file").GetString()); + Assert.Equal(21, related.GetProperty("startLine").GetInt32()); + Assert.Equal(23, related.GetProperty("endLine").GetInt32()); + Assert.Equal("Matching claim", related.GetProperty("message").GetString()); + Assert.Equal( + ["extractedClaims", "truncated"], + root.GetProperty("metrics").EnumerateObject().Select(property => property.Name)); + } + + [Fact] + public void Render_Table_UsesCompactPrimaryLocationAndReportsMetrics() + { + var output = Render(OutputFormat.Table); + + Assert.Contains("docs/primary.md:10-12 (+1 related)", output, StringComparison.Ordinal); + AssertMetricsRenderedInOrder(output); + } + + [Fact] + public void Render_Markdown_IncludesRangesRelatedLocationsAndMetrics() + { + var output = Render(OutputFormat.Markdown); + + Assert.Contains("docs/primary.md:10-12", output, StringComparison.Ordinal); + Assert.Contains("docs/related.md:21-23", output, StringComparison.Ordinal); + Assert.Contains("Matching claim", output, StringComparison.Ordinal); + AssertMetricsRenderedInOrder(output); + } + + [Fact] + public void Render_Sarif_UsesRegionsRelatedLocationsAndRunMetrics() + { + using var document = JsonDocument.Parse(Render(OutputFormat.Sarif)); + var run = document.RootElement.GetProperty("runs")[0]; + var result = run.GetProperty("results")[0]; + var primaryRegion = result + .GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("region"); + var related = result.GetProperty("relatedLocations")[0]; + var relatedRegion = related + .GetProperty("physicalLocation") + .GetProperty("region"); + + Assert.Equal(10, primaryRegion.GetProperty("startLine").GetInt32()); + Assert.Equal(12, primaryRegion.GetProperty("endLine").GetInt32()); + Assert.Equal( + "docs/related.md", + related.GetProperty("physicalLocation") + .GetProperty("artifactLocation") + .GetProperty("uri") + .GetString()); + Assert.Equal(21, relatedRegion.GetProperty("startLine").GetInt32()); + Assert.Equal(23, relatedRegion.GetProperty("endLine").GetInt32()); + Assert.Equal("Matching claim", related.GetProperty("message").GetProperty("text").GetString()); + Assert.Equal( + ["extractedClaims", "truncated"], + run.GetProperty("properties") + .GetProperty("metrics") + .EnumerateObject() + .Select(property => property.Name)); + } + + private static DiagnosticReport CreateReport() + { + var report = new DiagnosticReport(); + report.Add(new Diagnostic( + "KW-DOC-ANALYSIS-001", + Severity.Warning, + "Duplicate claim", + "claim-a", + "docs/primary.md", + "Choose one canonical claim", + StartLine: 10, + EndLine: 12, + RelatedLocations: + [ + new DiagnosticLocation( + "docs/related.md", + StartLine: 21, + EndLine: 23, + Message: "Matching claim") + ])); + report.AddMetric("extractedClaims", 17); + report.AddMetric("truncated", false); + return report; + } + + private static string Render(OutputFormat format, DiagnosticReport? report = null) + { + var execution = ProcessConsoleCapture.Run(() => + { + ReportRenderer.Render(report ?? CreateReport(), format, "docs analyze", "Claim"); + return true; + }); + return execution.Output; + } + + private static void AssertMetricsRenderedInOrder(string output) + { + var claimsIndex = output.IndexOf("extractedClaims", StringComparison.Ordinal); + var truncatedIndex = output.IndexOf("truncated", StringComparison.Ordinal); + + Assert.True(claimsIndex >= 0, $"Expected extractedClaims metric in output:{Environment.NewLine}{output}"); + Assert.True(truncatedIndex > claimsIndex, $"Expected ordered metrics in output:{Environment.NewLine}{output}"); + Assert.Contains("17", output, StringComparison.Ordinal); + Assert.Contains("false", output, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/KyberWeave.Tests/DocGraphProjectionTests.cs b/tests/KyberWeave.Tests/DocGraphProjectionTests.cs new file mode 100644 index 0000000..f8dbebf --- /dev/null +++ b/tests/KyberWeave.Tests/DocGraphProjectionTests.cs @@ -0,0 +1,343 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Docs.Export; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// T05 RED — one immutable DocGraph projection is shared by export, retrieval, and +/// documentation analysis instead of each consumer reconstructing relationships. +/// +public sealed class DocGraphProjectionTests +{ + private static readonly string[] TraversedCodeEdgeKinds = + ["contains", "calls", "references", "instantiates", "extends", "implements"]; + + [Fact] + public void Build_Projects_Exporter_Compatible_Document_Concept_And_Join_Relationships() + { + var resolver = new NeighborhoodResolver() + .WithSymbol("BillingService", Node("code:billing", "BillingService")) + .WithRoute("GET /billing", Node("route:billing", "GET /billing", "route")); + var set = FullRelationshipSet(); + + var projection = DocGraphProjection.Build(set, resolver); + + Assert.Contains(projection.Nodes, n => n.Id == "doc:architecture/billing" && n.Label == "Document"); + Assert.Contains(projection.Nodes, n => n.Id == "component:Billing" && n.Label == "Component"); + Assert.Contains(projection.Nodes, n => n.Id == "team:Platform" && n.Label == "Team"); + Assert.Contains(new DocGraphEdge("DOCUMENTS", "doc:architecture/billing", "component:Billing"), projection.Edges); + Assert.Contains(new DocGraphEdge("OWNED_BY", "doc:architecture/billing", "team:Platform"), projection.Edges); + Assert.Contains(new DocGraphEdge("DESCRIBES", "doc:architecture/billing", "path:src/Billing"), projection.Edges); + Assert.Contains(new DocGraphEdge("REFERENCES", "doc:architecture/billing", "code:billing"), projection.Edges); + Assert.Contains(new DocGraphEdge("EXPOSES", "doc:architecture/billing", "route:billing"), projection.Edges); + Assert.Contains(new DocGraphEdge("LINKS_TO", "doc:architecture/billing", "doc:reference/billing"), projection.Edges); + Assert.Contains(new DocGraphEdge("DECIDED_BY", "doc:architecture/billing", "doc:adr/0001"), projection.Edges); + Assert.Contains(new DocGraphEdge("SUPERSEDES", "doc:architecture/billing", "doc:architecture/legacy"), projection.Edges); + } + + [Fact] + public void Build_Copies_Contributor_Output_Into_An_Immutable_Snapshot() + { + var nodes = new List + { + new("term:loop", "Term", new ReadOnlyDictionary( + new Dictionary { ["name"] = "loop" })) + }; + var edges = new List + { + new("EVIDENCED_BY", "term:loop", "doc:reference/billing") + }; + var contributor = new StubContributor(nodes, edges); + + var projection = DocGraphProjection.Build( + new DocumentSet { Documents = [] }, + FakeCodeGraphResolver.WithSymbols(), + contributors: [contributor]); + nodes.Clear(); + edges.Clear(); + + Assert.Contains(projection.Nodes, n => n.Id == "term:loop"); + Assert.Contains(projection.Edges, e => e.From == "term:loop"); + } + + [Fact] + public void AreDocumentsRelated_Treats_Overlapping_Source_Roots_As_Graph_Neighbors() + { + var set = new DocumentSet + { + Documents = + [ + Document("architecture/parent", "docs/parent.md", sourceRoot: "src/Gameplay"), + Document("reference/child", "docs/child.md", sourceRoot: "src/Gameplay/Loops") + ] + }; + + var projection = DocGraphProjection.Build(set, FakeCodeGraphResolver.WithSymbols()); + + Assert.True(projection.AreDocumentsRelated("doc:architecture/parent", "doc:reference/child")); + } + + [Fact] + public void Build_IdLessDocuments_RegisterFallbackPathNodesAndSharedComponentRelatedness() + { + var set = new DocumentSet + { + Documents = + [ + Document("", "docs/left.md", component: "Runtime"), + Document("", "docs/right.md", component: "Runtime") + ] + }; + + var projection = DocGraphProjection.Build(set, FakeCodeGraphResolver.WithSymbols()); + + Assert.Contains(projection.Nodes, node => node.Id == "doc:docs/left.md" && node.Label == "Document"); + Assert.Contains(projection.Nodes, node => node.Id == "doc:docs/right.md" && node.Label == "Document"); + Assert.True(projection.AreDocumentsRelated("doc:docs/left.md", "doc:docs/right.md")); + } + + [Theory] + [MemberData(nameof(ApprovedCodeEdgeKinds))] + public void AreDocumentsRelated_Traverses_Approved_OneHop_CodeGraph_Edges(string kind) + { + var resolver = new NeighborhoodResolver() + .WithSymbol("Left", Node("code:left", "Left")) + .WithSymbol("Right", Node("code:right", "Right")) + .WithEdge(new CodeGraphEdge("code:left", "code:right", kind)); + var set = TwoCodeReferenceDocuments(); + + var projection = DocGraphProjection.Build(set, resolver); + + Assert.True(projection.AreDocumentsRelated("doc:left", "doc:right")); + Assert.Equal(1, resolver.NeighborhoodRequestCount); + Assert.Equal(["code:left", "code:right"], resolver.LastRequestedNodeIds.Order(StringComparer.Ordinal)); + } + + [Fact] + public void AreDocumentsRelated_Does_Not_Traverse_Imports() + { + var resolver = new NeighborhoodResolver() + .WithSymbol("Left", Node("code:left", "Left")) + .WithSymbol("Right", Node("code:right", "Right")) + .WithEdge(new CodeGraphEdge("code:left", "code:right", "imports")); + + var projection = DocGraphProjection.Build(TwoCodeReferenceDocuments(), resolver); + + Assert.False(projection.AreDocumentsRelated("doc:left", "doc:right")); + } + + [Fact] + public void Build_Skips_Code_Nodes_Whose_Degree_Exceeds_The_Cap() + { + var resolver = new NeighborhoodResolver() + .WithSymbol("Left", Node("code:hub", "Left")) + .WithSymbol("Right", Node("code:right", "Right")) + .WithEdge(new CodeGraphEdge("code:hub", "code:right", "calls")) + .WithEdge(new CodeGraphEdge("code:hub", "code:third", "calls")); + + var projection = DocGraphProjection.Build( + TwoCodeReferenceDocuments(), resolver, maxCodeNeighbors: 1); + + Assert.False(projection.AreDocumentsRelated("doc:left", "doc:right")); + Assert.Equal(1, resolver.LastMaxDegree); + } + + [Fact] + public void Build_When_Resolver_Has_No_Neighborhood_Port_Preserves_Document_Relationships() + { + var set = new DocumentSet + { + Documents = + [ + Document("first", "docs/first.md", component: "Shared"), + Document("second", "docs/second.md", component: "Shared") + ] + }; + + var projection = DocGraphProjection.Build(set, FakeCodeGraphResolver.WithSymbols()); + + Assert.True(projection.AreDocumentsRelated("doc:first", "doc:second")); + } + + [Fact] + public void AreDocumentsRelated_Does_Not_Relate_Documents_Only_Because_They_Share_An_Owner() + { + var set = new DocumentSet + { + Documents = + [ + Document("first", "docs/first.md", owner: "Platform"), + Document("second", "docs/second.md", owner: "Platform") + ] + }; + + var projection = DocGraphProjection.Build(set, FakeCodeGraphResolver.WithSymbols()); + + Assert.False(projection.AreDocumentsRelated("doc:first", "doc:second")); + } + + [Fact] + public void DocGraphExporter_Output_Remains_Compatible_With_The_V1_Jsonl_Schema() + { + var resolver = new NeighborhoodResolver() + .WithSymbol("BillingService", Node("code:billing", "BillingService")) + .WithRoute("GET /billing", Node("route:billing", "GET /billing", "route")); + using var output = new TempDirectory(); + + var result = new DocGraphExporter(resolver).Export(FullRelationshipSet(), output.Path); + var nodes = File.ReadAllLines(result.NodesPath).Select(Parse).ToList(); + var edges = File.ReadAllLines(result.EdgesPath).Select(Parse).ToList(); + + Assert.All(nodes, node => Assert.Equal("node", node.GetProperty("type").GetString())); + Assert.All(edges, edge => Assert.Equal("edge", edge.GetProperty("type").GetString())); + Assert.Contains(nodes, node => node.GetProperty("id").GetString() == "doc:architecture/billing" + && node.GetProperty("docType").GetString() == "architecture" + && node.GetProperty("status").GetString() == "current" + && node.GetProperty("path").GetString() == "docs/billing.md"); + Assert.Contains(edges, edge => edge.GetProperty("label").GetString() == "REFERENCES" + && edge.GetProperty("from").GetString() == "doc:architecture/billing" + && edge.GetProperty("to").GetString() == "code:billing"); + } + + public static TheoryData ApprovedCodeEdgeKinds() + { + var data = new TheoryData(); + foreach (var kind in TraversedCodeEdgeKinds) + data.Add(kind); + return data; + } + + private static DocumentSet FullRelationshipSet() => + new() + { + Documents = + [ + Document( + "architecture/billing", + "docs/billing.md", + component: "Billing", + owner: "Platform", + sourceRoot: "src/Billing", + codeRefs: ["BillingService"], + endpoints: ["GET /billing"], + decidedBy: ["adr/0001"], + supersedes: ["architecture/legacy"], + bodyLinks: ["billing-reference.md"]), + Document("reference/billing", "docs/billing-reference.md") + ] + }; + + private static DocumentSet TwoCodeReferenceDocuments() => + new() + { + Documents = + [ + Document("left", "docs/left.md", codeRefs: ["Left"]), + Document("right", "docs/right.md", codeRefs: ["Right"]) + ] + }; + + private static DocumentModel Document( + string id, + string path, + string? component = null, + string? owner = null, + string? sourceRoot = null, + IReadOnlyList? codeRefs = null, + IReadOnlyList? endpoints = null, + IReadOnlyList? decidedBy = null, + IReadOnlyList? supersedes = null, + IReadOnlyList? bodyLinks = null) => + new() + { + RelativePath = path, + FilePath = "/tmp/" + path, + HasFrontmatter = true, + Frontmatter = new DocumentFrontmatter + { + Id = id, + Title = id, + DocType = "architecture", + Status = "current", + Component = component, + Owner = owner, + SourceRoot = sourceRoot, + LastReviewed = "2026-08-11", + CodeRefs = codeRefs is null ? null : new Collection(codeRefs.ToList()), + ApiEndpoints = endpoints is null ? null : new Collection(endpoints.ToList()), + DecidedBy = decidedBy is null ? null : new Collection(decidedBy.ToList()), + Supersedes = supersedes is null ? null : new Collection(supersedes.ToList()) + }, + DocType = DocType.Architecture, + Status = DocStatus.Current, + BodyLinks = bodyLinks ?? [] + }; + + private static CodeGraphNode Node(string id, string name, string kind = "class") => + new(id, kind, name, name, $"src/{name}.cs", "csharp", 1); + + private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private sealed class StubContributor( + IReadOnlyList nodes, + IReadOnlyList edges) : IDocGraphContributor + { + public DocGraphContribution Contribute(DocumentSet documents, ICodeGraphResolver codeGraph) => + new(nodes, edges); + } + + private sealed class NeighborhoodResolver : ICodeGraphResolver, ICodeGraphNeighborhoodProvider + { + private readonly Dictionary _symbols = new(StringComparer.Ordinal); + private readonly Dictionary _routes = new(StringComparer.Ordinal); + private readonly List _edges = []; + + public bool IsAvailable => true; + public string? UnavailableReason => null; + public string DatabasePath => ":memory:"; + public int NeighborhoodRequestCount { get; private set; } + public int LastMaxDegree { get; private set; } + public IReadOnlyList LastRequestedNodeIds { get; private set; } = []; + + public NeighborhoodResolver WithSymbol(string name, CodeGraphNode node) + { + _symbols[name] = node; + return this; + } + + public NeighborhoodResolver WithRoute(string route, CodeGraphNode node) + { + _routes[route] = node; + return this; + } + + public NeighborhoodResolver WithEdge(CodeGraphEdge edge) + { + _edges.Add(edge); + return this; + } + + public IReadOnlyList ResolveSymbol(string name) => + _symbols.TryGetValue(name, out var node) ? [node] : []; + + public IReadOnlyList ResolveRoute(string route) => + _routes.TryGetValue(route, out var node) ? [node] : []; + + public IReadOnlyList GetEdges(IReadOnlyCollection nodeIds, int maxDegree) + { + NeighborhoodRequestCount++; + LastMaxDegree = maxDegree; + LastRequestedNodeIds = nodeIds.ToArray(); + return _edges; + } + + public bool HasFilesUnder(string relativePathPrefix) => true; + public IReadOnlyList CandidateNames(string like) => []; + public IReadOnlyList AllRoutes() => _routes.Keys.ToList(); + } +} diff --git a/tests/KyberWeave.Tests/DocsAnalysisCliCommandTests.cs b/tests/KyberWeave.Tests/DocsAnalysisCliCommandTests.cs new file mode 100644 index 0000000..5aef529 --- /dev/null +++ b/tests/KyberWeave.Tests/DocsAnalysisCliCommandTests.cs @@ -0,0 +1,423 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using KyberWeave.Cli.Commands.Docs; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Review; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Pins the public documentation-analysis command surface independently of concrete graph, +/// persistence, and embedding adapters. Composition has a separate contract below this file. +/// +public sealed class DocsAnalysisCliCommandTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + public void Dispose() => _temp.Dispose(); + + [Fact] + public void Program_RegistersAnalyzeReviewAndGlossaryCommands() + { + var program = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "src", + "KyberWeave.Cli", + "Program.cs")); + + Assert.Contains("AddCommand(\"analyze\")", program, StringComparison.Ordinal); + Assert.Contains("AddBranch(\"review\"", program, StringComparison.Ordinal); + Assert.Contains("AddCommand(\"export\")", program, StringComparison.Ordinal); + Assert.Contains("AddCommand(\"import\")", program, StringComparison.Ordinal); + Assert.Contains("AddCommand(\"glossary\")", program, StringComparison.Ordinal); + } + + [Fact] + public void AnalyzeSettings_DefaultToAdvisoryAndRetainEveryExistingFormat() + { + var settings = new DocsAnalyzeSettings(); + + Assert.Equal("none", settings.FailOn); + Assert.Equal("table", settings.Format); + Assert.Equal(KyberWeave.Cli.Rendering.OutputFormat.Json, + new DocsAnalyzeSettings { Format = "json" }.ParsedFormat); + Assert.Equal(KyberWeave.Cli.Rendering.OutputFormat.Sarif, + new DocsAnalyzeSettings { Format = "sarif" }.ParsedFormat); + Assert.Equal(KyberWeave.Cli.Rendering.OutputFormat.Markdown, + new DocsAnalyzeSettings { Format = "markdown" }.ParsedFormat); + } + + [Theory] + [InlineData("none", Severity.Error, 0)] + [InlineData("none", Severity.Warning, 0)] + [InlineData("warning", Severity.Info, 0)] + [InlineData("warning", Severity.Warning, 1)] + [InlineData("warning", Severity.Error, 1)] + [InlineData("error", Severity.Warning, 0)] + [InlineData("error", Severity.Error, 1)] + [InlineData("error", Severity.Critical, 1)] + public void Analyze_FindingExitGateHonorsFailOn( + string failOn, + Severity severity, + int expectedExitCode) + { + var service = new RecordingCommandService + { + AnalysisResult = AnalysisResult(Finding(severity)) + }; + var command = new DocsAnalyzeCommand(service); + + var execution = Capture(() => command.Execute( + null!, + new DocsAnalyzeSettings + { + Path = _temp.Path, + FailOn = failOn, + Format = "json" + })); + + Assert.Equal(expectedExitCode, execution.ExitCode); + Assert.Contains("KW-DOC-ANALYSIS-TEST", execution.Output, StringComparison.Ordinal); + } + + [Fact] + public void Analyze_OperationalFailureIsNonzeroEvenWhenFailOnNone() + { + var service = new RecordingCommandService + { + AnalysisException = new InvalidDataException( + "KW-DOC-ANALYSIS-004: malformed ignore markup") + }; + + var execution = Capture(() => new DocsAnalyzeCommand(service).Execute( + null!, + new DocsAnalyzeSettings + { + Path = _temp.Path, + FailOn = "none", + Format = "json" + })); + + Assert.Equal(1, execution.ExitCode); + Assert.Contains("KW-DOC-ANALYSIS-004", execution.Output, StringComparison.Ordinal); + Assert.Contains("malformed ignore markup", execution.Output, StringComparison.Ordinal); + } + + [Theory] + [InlineData(DocumentationAnalyzer.IgnoreMarkupRuleCode)] + [InlineData(DocumentationAnalyzer.EmbeddingUnavailableRuleCode)] + public void Analyze_OperationalErrorDiagnosticIsNonzeroEvenWhenFailOnNone(string ruleCode) + { + var report = new DiagnosticReport(); + report.Add(new Diagnostic( + ruleCode, + Severity.Error, + "Operational analysis failure.", + "docs analysis")); + var service = new RecordingCommandService { AnalysisResult = AnalysisResult(report) }; + + var execution = Capture(() => new DocsAnalyzeCommand(service).Execute( + null!, + new DocsAnalyzeSettings + { + Path = _temp.Path, + FailOn = "none", + Format = "json" + })); + + Assert.Equal(1, execution.ExitCode); + Assert.Contains(ruleCode, execution.Output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("table", "(+1 related)")] + [InlineData("json", "docs/related.md")] + [InlineData("sarif", "docs/related.md")] + [InlineData("markdown", "docs/related.md")] + public void Analyze_EveryFormatReportsRelatedLocationsAndMetrics( + string format, + string relatedMarker) + { + var report = new DiagnosticReport(); + report.Add(FindingDiagnostic(Severity.Warning)); + report.AddMetric("extractedClaims", 17); + report.AddMetric("truncated", false); + var service = new RecordingCommandService { AnalysisResult = AnalysisResult(report) }; + + var execution = Capture(() => new DocsAnalyzeCommand(service).Execute( + null!, + new DocsAnalyzeSettings + { + Path = _temp.Path, + FailOn = "none", + Format = format + })); + + Assert.Equal(0, execution.ExitCode); + Assert.Contains(relatedMarker, execution.Output, StringComparison.Ordinal); + Assert.Contains("extractedClaims", execution.Output, StringComparison.Ordinal); + Assert.Contains("17", execution.Output, StringComparison.Ordinal); + Assert.Contains("truncated", execution.Output, StringComparison.Ordinal); + } + + [Fact] + public void ReviewExport_WritesTheCompleteBundleToTheRequestedPath() + { + var output = Path.Combine(_temp.Path, "review", "candidates.json"); + var service = new RecordingCommandService { ExportResult = ExportResult("{\"schema\":\"candidates/v1\"}") }; + + var execution = Capture(() => new DocsReviewExportCommand(service).Execute( + null!, + new DocsReviewExportSettings { Path = _temp.Path, OutputPath = output })); + + Assert.Equal(0, execution.ExitCode); + Assert.Equal(service.ExportResult.Json, File.ReadAllText(output)); + } + + [Fact] + public void ReviewExport_OperationalFailureLeavesAnExistingOutputByteForByteUnchanged() + { + var output = Path.Combine(_temp.Path, "candidates.json"); + const string sentinel = "operator-owned output"; + File.WriteAllText(output, sentinel); + var service = new RecordingCommandService + { + ExportException = new IOException("candidate export failed") + }; + + var execution = Capture(() => new DocsReviewExportCommand(service).Execute( + null!, + new DocsReviewExportSettings { Path = _temp.Path, OutputPath = output })); + + Assert.Equal(1, execution.ExitCode); + Assert.Equal(sentinel, File.ReadAllText(output)); + } + + [Fact] + public void ReviewImport_ReadsTheRequestedBundleAndReturnsFailureWithoutPartialWrites() + { + var input = Path.Combine(_temp.Path, "verdicts.json"); + const string verdicts = "{\"schema\":\"kyber-weave.docs-review.verdicts/v1\"}"; + File.WriteAllText(input, verdicts); + var diagnostics = new DiagnosticReport(); + diagnostics.Add(new Diagnostic( + DocumentationReviewExchange.ReviewRuleCode, + Severity.Error, + "The verdict bundle is stale.", + "docs review import")); + var service = new RecordingCommandService + { + ImportResult = new ReviewImportResult(false, 0, diagnostics) + }; + + var execution = Capture(() => new DocsReviewImportCommand(service).Execute( + null!, + new DocsReviewImportSettings + { + Path = _temp.Path, + InputPath = input, + Format = "json" + })); + + Assert.Equal(1, execution.ExitCode); + Assert.Equal(verdicts, service.ImportedJson); + Assert.Equal(0, service.PersistedVerdictCount); + Assert.Contains(DocumentationReviewExchange.ReviewRuleCode, execution.Output, StringComparison.Ordinal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Glossary_PreviewAndWritePassTheExplicitMutationChoice(bool write) + { + const string markdown = "## loop\n\n| Sense ID | Status | Definition | Scope | Aliases |"; + var service = new RecordingCommandService + { + GlossaryResult = new GlossaryUpdateResult( + "docs/glossary.md", + markdown, + Changed: true, + Written: write, + new DiagnosticReport()) + }; + + var execution = Capture(() => new DocsGlossaryCommand(service).Execute( + null!, + new DocsGlossarySettings { Path = _temp.Path, Write = write })); + + Assert.Equal(0, execution.ExitCode); + Assert.Equal(write, service.GlossaryWriteRequested); + if (!write) + { + Assert.Contains("glossaryPreview", execution.Output, StringComparison.Ordinal); + Assert.Contains("## loop", execution.Output, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("json")] + [InlineData("sarif")] + public void Glossary_MachineFormatsEmitOneParseablePayloadWithoutMarkdownPrefix(string format) + { + const string markdown = "---\nid: reference/glossary\n---\n\n## loop\n"; + var service = new RecordingCommandService + { + GlossaryResult = new GlossaryUpdateResult( + "docs/glossary.md", + markdown, + Changed: true, + Written: false, + new DiagnosticReport()) + }; + + var execution = Capture(() => new DocsGlossaryCommand(service).Execute( + null!, + new DocsGlossarySettings { Path = _temp.Path, Format = format })); + + Assert.Equal(0, execution.ExitCode); + using var payload = JsonDocument.Parse(execution.Output); + Assert.DoesNotContain("---\n", execution.Output[..Math.Min(20, execution.Output.Length)], StringComparison.Ordinal); + Assert.Contains("glossaryPreview", execution.Output, StringComparison.Ordinal); + Assert.Contains("reference/glossary", execution.Output, StringComparison.Ordinal); + Assert.Equal(JsonValueKind.Object, payload.RootElement.ValueKind); + } + + [Theory] + [InlineData("table")] + [InlineData("markdown")] + public void Glossary_HumanFormatsRenderPreviewInsideTheSelectedReport(string format) + { + const string markdown = "## loop\n\n| Sense ID | Status | Definition | Scope | Aliases |"; + var service = new RecordingCommandService + { + GlossaryResult = new GlossaryUpdateResult( + "docs/glossary.md", + markdown, + Changed: true, + Written: false, + new DiagnosticReport()) + }; + + var execution = Capture(() => new DocsGlossaryCommand(service).Execute( + null!, + new DocsGlossarySettings { Path = _temp.Path, Format = format })); + + Assert.Equal(0, execution.ExitCode); + Assert.Contains("glossaryPreview", execution.Output, StringComparison.Ordinal); + Assert.Contains("## loop", execution.Output, StringComparison.Ordinal); + Assert.False(execution.Output.StartsWith(markdown, StringComparison.Ordinal)); + } + + [Fact] + public void DocsSettings_DescribePathAsRepositoryDocumentationRatherThanSkillInput() + { + var description = typeof(DocsSettings) + .GetProperty(nameof(DocsSettings.Path))! + .GetCustomAttributes(typeof(System.ComponentModel.DescriptionAttribute), inherit: true) + .Cast() + .Single() + .Description; + + Assert.Contains("repository", description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("documentation", description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SKILL.md", description, StringComparison.Ordinal); + } + + private static DiagnosticReport Finding(Severity severity) + { + var report = new DiagnosticReport(); + report.Add(FindingDiagnostic(severity)); + return report; + } + + private static Diagnostic FindingDiagnostic(Severity severity) => new( + "KW-DOC-ANALYSIS-TEST", + severity, + "Documentation analysis finding.", + "claim-a", + "docs/primary.md", + StartLine: 10, + EndLine: 12, + RelatedLocations: + [ + new DiagnosticLocation( + "docs/related.md", + StartLine: 21, + EndLine: 23, + Message: "Related claim") + ]); + + private static DocumentationAnalysisResult AnalysisResult(DiagnosticReport report) => new( + [], + report, + new AnalysisMetrics(17, 3, 4, 0, 2, 2, 0, false)); + + private static ReviewExportResult ExportResult(string json) => new( + new ReviewCandidateBundle( + DocumentationReviewExchange.CandidateSchema, + DocumentationAnalyzer.AnalyzerVersion, + DocumentationAnalyzer.RubricVersion, + "set-hash", + new ReviewRubric([]), + []), + json, + json.Length, + Truncated: false); + + private static CommandExecution Capture(Func execute) + { + var execution = ProcessConsoleCapture.Run(execute); + return new CommandExecution(execution.Result, execution.Output); + } + + private static string RepositoryRoot([CallerFilePath] string sourcePath = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, "..", "..")); + + private sealed record CommandExecution(int ExitCode, string Output); + + private sealed class RecordingCommandService : IDocsAnalysisCommandService + { + public DocumentationAnalysisResult AnalysisResult { get; init; } = + DocsAnalysisCliCommandTests.AnalysisResult(new DiagnosticReport()); + public Exception? AnalysisException { get; init; } + public ReviewExportResult ExportResult { get; init; } = + DocsAnalysisCliCommandTests.ExportResult("{}"); + public Exception? ExportException { get; init; } + public ReviewImportResult ImportResult { get; init; } = + new(true, 0, new DiagnosticReport()); + public GlossaryUpdateResult GlossaryResult { get; init; } = + new("docs/glossary.md", string.Empty, false, false, new DiagnosticReport()); + public string? ImportedJson { get; private set; } + public int PersistedVerdictCount { get; private set; } + public bool GlossaryWriteRequested { get; private set; } + + public DocumentationAnalysisResult Analyze(DocsAnalyzeSettings settings) + { + if (AnalysisException is not null) throw AnalysisException; + return AnalysisResult; + } + + public ReviewExportResult ExportReview(DocsReviewExportSettings settings) + { + if (ExportException is not null) throw ExportException; + return ExportResult; + } + + public ReviewImportResult ImportReview(DocsReviewImportSettings settings, string json) + { + ImportedJson = json; + if (ImportResult.Success) PersistedVerdictCount = ImportResult.ImportedCount; + return ImportResult; + } + + public GlossaryUpdateResult UpdateGlossary(DocsGlossarySettings settings) + { + GlossaryWriteRequested = settings.Write; + return GlossaryResult; + } + } +} diff --git a/tests/KyberWeave.Tests/DocsAnalysisCompositionTests.cs b/tests/KyberWeave.Tests/DocsAnalysisCompositionTests.cs new file mode 100644 index 0000000..5ea2cf0 --- /dev/null +++ b/tests/KyberWeave.Tests/DocsAnalysisCompositionTests.cs @@ -0,0 +1,409 @@ +using System.Diagnostics.CodeAnalysis; +using KyberWeave.Cli.Commands.Docs; +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Specifies the CLI's adapter lifetime and cost boundary. Unsafe persistence must prevent +/// both cache writes and embedding construction, and every constructed disposable is owned +/// by the command runtime. +/// +[SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Tests intentionally transfer fake adapter ownership to the runtime and assert disposal.")] +public sealed class DocsAnalysisCompositionTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + public void Dispose() => _temp.Dispose(); + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Off, false, true, 0, 0)] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, false, true, 0, 0)] + [InlineData(DocsAnalysisEmbeddingMode.Required, false, false, 0, 0)] + [InlineData(DocsAnalysisEmbeddingMode.Off, true, true, 1, 0)] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, true, true, 1, 1)] + [InlineData(DocsAnalysisEmbeddingMode.Required, true, true, 1, 1)] + public void Composition_EnforcesEmbeddingModeAndSafePersistenceBeforeConstruction( + DocsAnalysisEmbeddingMode mode, + bool cacheSafe, + bool expectedSuccess, + int expectedPersistenceConstructions, + int expectedEmbeddingConstructions) + { + WriteConfig(mode); + var persistence = new DisposablePersistence(isAvailable: true); + var embedding = new DisposableEmbeddingGenerator(); + var factories = Factories(cacheSafe, persistence, embedding, new AvailableResolver()); + var report = new DiagnosticReport(); + + var success = DocsCommandComposition.TryCreateAnalysisRuntime( + new DocsAnalyzeSettings { Path = _temp.Path }, + report, + factories.Factories, + out var runtime); + + Assert.Equal(expectedSuccess, success); + Assert.Equal(expectedPersistenceConstructions, factories.PersistenceConstructions); + Assert.Equal(expectedEmbeddingConstructions, factories.EmbeddingConstructions); + if (success) + { + Assert.NotNull(runtime); + runtime.Dispose(); + } + else + { + Assert.Null(runtime); + } + + if (mode == DocsAnalysisEmbeddingMode.Required && !cacheSafe) + { + var finding = Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + Assert.Equal(Severity.Error, finding.Severity); + } + else if (mode == DocsAnalysisEmbeddingMode.Prefer && !cacheSafe) + { + var finding = Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + Assert.Equal(Severity.Warning, finding.Severity); + } + } + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, true)] + [InlineData(DocsAnalysisEmbeddingMode.Required, false)] + public void Composition_UnavailablePersistenceFallsBackOnlyForPreferAndDisposesPartialRuntime( + DocsAnalysisEmbeddingMode mode, + bool expectedSuccess) + { + WriteConfig(mode); + var persistence = new DisposablePersistence(isAvailable: false); + var embedding = new DisposableEmbeddingGenerator(); + var factories = Factories(cacheSafe: true, persistence, embedding, new AvailableResolver()); + var report = new DiagnosticReport(); + + var success = DocsCommandComposition.TryCreateAnalysisRuntime( + new DocsAnalyzeSettings { Path = _temp.Path }, + report, + factories.Factories, + out var runtime); + + Assert.Equal(expectedSuccess, success); + Assert.Equal(1, factories.PersistenceConstructions); + Assert.Equal(0, factories.EmbeddingConstructions); + Assert.Equal(1, persistence.DisposeCount); + if (expectedSuccess) + { + Assert.NotNull(runtime); + runtime.Dispose(); + } + else + { + Assert.Null(runtime); + } + var finding = Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + Assert.Equal(expectedSuccess ? Severity.Warning : Severity.Error, finding.Severity); + } + + [Fact] + public void Composition_SuccessfulRuntimeDisposesEveryConstructedDisposableExactlyOnce() + { + WriteConfig(DocsAnalysisEmbeddingMode.Prefer); + var persistence = new DisposablePersistence(isAvailable: true); + var embedding = new DisposableEmbeddingGenerator(); + var factories = Factories(cacheSafe: true, persistence, embedding, new AvailableResolver()); + + var success = DocsCommandComposition.TryCreateAnalysisRuntime( + new DocsAnalyzeSettings { Path = _temp.Path }, + new DiagnosticReport(), + factories.Factories, + out var runtime); + runtime!.Dispose(); + runtime.Dispose(); + + Assert.True(success); + Assert.Equal(1, persistence.DisposeCount); + Assert.Equal(1, embedding.DisposeCount); + } + + [Fact] + public void Composition_MissingCodeGraphWarnsExactlyOnceAndContinues() + { + WriteConfig(DocsAnalysisEmbeddingMode.Off); + var report = new DiagnosticReport(); + var factories = Factories( + cacheSafe: false, + new DisposablePersistence(isAvailable: true), + new DisposableEmbeddingGenerator(), + new UnavailableResolver()); + + var success = DocsCommandComposition.TryCreateAnalysisRuntime( + new DocsAnalyzeSettings { Path = _temp.Path }, + report, + factories.Factories, + out var runtime); + + Assert.True(success); + runtime!.Dispose(); + var warning = Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.CodeGraphUnavailableRuleCode); + Assert.Equal(Severity.Warning, warning.Severity); + } + + [Fact] + public void Composition_UnsafeOrdinaryAnalysisCreatesNoCacheOrTrackedFiles() + { + WriteConfig(DocsAnalysisEmbeddingMode.Off); + var before = Files(); + var factories = Factories( + cacheSafe: false, + new DisposablePersistence(isAvailable: true), + new DisposableEmbeddingGenerator(), + new AvailableResolver()); + + var success = DocsCommandComposition.TryCreateAnalysisRuntime( + new DocsAnalyzeSettings { Path = _temp.Path }, + new DiagnosticReport(), + factories.Factories, + out var runtime); + runtime!.Dispose(); + + Assert.True(success); + Assert.Equal(before, Files()); + Assert.False(Directory.Exists(Path.Combine(_temp.Path, ".kyber-weave", "cache"))); + Assert.Equal(0, factories.PersistenceConstructions); + Assert.Equal(0, factories.EmbeddingConstructions); + } + + [Theory] + [InlineData("export")] + [InlineData("import")] + [InlineData("glossary")] + public void RepositoryService_RequiredEmbeddingFailureStopsEveryRequestedWrite(string operation) + { + WriteAnalysisFixture(DocsAnalysisEmbeddingMode.Required); + var persistence = new DisposablePersistence(isAvailable: true); + var factories = Factories( + cacheSafe: true, + persistence, + new DisposableEmbeddingGenerator(), + new AvailableResolver()); + var service = new RepositoryDocsAnalysisCommandService(factories.Factories); + var output = Path.Combine(_temp.Path, "candidates.json"); + var input = Path.Combine(_temp.Path, "verdicts.json"); + File.WriteAllText(input, "{}"); + + var exitCode = ProcessConsoleCapture.Run(() => operation switch + { + "export" => new DocsReviewExportCommand(service).Execute( + null!, + new DocsReviewExportSettings { Path = _temp.Path, OutputPath = output, Format = "json" }), + "import" => new DocsReviewImportCommand(service).Execute( + null!, + new DocsReviewImportSettings { Path = _temp.Path, InputPath = input, Format = "json" }), + "glossary" => new DocsGlossaryCommand(service).Execute( + null!, + new DocsGlossarySettings { Path = _temp.Path, Write = true, Format = "json" }), + _ => throw new InvalidOperationException("Unknown operation.") + }).Result; + + Assert.Equal(1, exitCode); + Assert.False(File.Exists(output)); + Assert.False(File.Exists(Path.Combine(_temp.Path, "docs", "glossary.md"))); + Assert.Equal(0, persistence.SavedVerdictCount); + } + + [Fact] + public void RepositoryService_PreferAndCodeGraphWarningsFlowOnceThroughEveryResult() + { + WriteAnalysisFixture(DocsAnalysisEmbeddingMode.Prefer); + var persistence = new DisposablePersistence(isAvailable: true); + var factories = Factories( + cacheSafe: true, + persistence, + new DisposableEmbeddingGenerator(), + new UnavailableResolver()); + var service = new RepositoryDocsAnalysisCommandService(factories.Factories); + + var exported = service.ExportReview(new DocsReviewExportSettings { Path = _temp.Path }); + var imported = service.ImportReview(new DocsReviewImportSettings { Path = _temp.Path }, "{}"); + var glossary = service.UpdateGlossary(new DocsGlossarySettings { Path = _temp.Path }); + + AssertWarningsOnce(exported.Diagnostics); + AssertWarningsOnce(imported.Diagnostics); + AssertWarningsOnce(glossary.Diagnostics); + } + + private sealed class FactoryProbe + { + public FactoryProbe( + bool cacheSafe, + DisposablePersistence persistence, + DisposableEmbeddingGenerator embedding, + ICodeGraphResolver resolver) + { + Factories = new DocsAnalysisCompositionFactories + { + IsCacheSafe = _ => cacheSafe, + CreatePersistence = _ => + { + PersistenceConstructions++; + return persistence; + }, + CreateEmbeddingGenerator = () => + { + EmbeddingConstructions++; + return embedding; + }, + CreateResolver = _ => resolver + }; + } + + public DocsAnalysisCompositionFactories Factories { get; } + public int PersistenceConstructions { get; private set; } + public int EmbeddingConstructions { get; private set; } + } + + private void WriteConfig(DocsAnalysisEmbeddingMode mode) + { + var state = Path.Combine(_temp.Path, ".kyber-weave"); + Directory.CreateDirectory(state); + File.WriteAllText( + Path.Combine(state, "kyber-weave.yml"), + $$""" + ontology: + docs-root: docs + excluded-files: [] + docs-analysis: + embeddings: + mode: {{mode.ToString().ToLowerInvariant()}} + endpoint: http://127.0.0.1:1234/v1/embeddings + model: local-test-model + """); + } + + private void WriteAnalysisFixture(DocsAnalysisEmbeddingMode mode) + { + WriteConfig(mode); + WriteDocument("gameplay", "The gameplay loop measures live-test runtime.", "Gameplay"); + WriteDocument("automation", "The Codex loop consumes model tokens.", "Automation"); + } + + private void WriteDocument(string id, string claim, string component) + { + var docs = Path.Combine(_temp.Path, "docs"); + Directory.CreateDirectory(docs); + File.WriteAllText( + Path.Combine(docs, $"{id}.md"), + $$""" + --- + id: reference/{{id}} + title: {{id}} + doc-type: reference + status: current + owner: Maintainers + last-reviewed: 2026-08-12 + component: {{component}} + --- + + # {{id}} + + ## Behavior + + {{claim}} + """); + } + + private static void AssertWarningsOnce(DiagnosticReport report) + { + Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + Assert.Single(report.Items, item => + item.Code == DocumentationAnalyzer.CodeGraphUnavailableRuleCode); + } + + private static FactoryProbe Factories( + bool cacheSafe, + DisposablePersistence persistence, + DisposableEmbeddingGenerator embedding, + ICodeGraphResolver resolver) => new(cacheSafe, persistence, embedding, resolver); + + private string[] Files() => Directory + .GetFiles(_temp.Path, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(_temp.Path, path).Replace('\\', '/')) + .Order(StringComparer.Ordinal) + .ToArray(); + + private sealed class DisposablePersistence(bool isAvailable) : IAnalysisPersistence, IDisposable + { + public bool IsAvailable { get; } = isAvailable; + public int DisposeCount { get; private set; } + public int SavedVerdictCount { get; private set; } + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) => + new Dictionary(); + + public void SaveEmbeddings(IReadOnlyCollection embeddings) + { + } + + public void SaveVerdicts(IReadOnlyCollection verdicts) => + SavedVerdictCount += verdicts.Count; + + public void Dispose() => DisposeCount++; + } + + private sealed class DisposableEmbeddingGenerator : IEmbeddingGenerator, IDisposable + { + public int DisposeCount { get; private set; } + + public string GetProviderFingerprint(DocsAnalysisEmbeddingConfig config) => "fake-provider"; + + public EmbeddingGenerationResult Generate( + IReadOnlyCollection keys, + IReadOnlyCollection inputs, + DocsAnalysisEmbeddingConfig config) => + new([], EmbeddingUsage.None); + + public void Dispose() => DisposeCount++; + } + + private sealed class AvailableResolver : EmptyResolver + { + public override bool IsAvailable => true; + public override string? UnavailableReason => null; + } + + private sealed class UnavailableResolver : EmptyResolver + { + public override bool IsAvailable => false; + public override string? UnavailableReason => "No CodeGraph index."; + } + + private abstract class EmptyResolver : ICodeGraphResolver + { + public abstract bool IsAvailable { get; } + public abstract string? UnavailableReason { get; } + public string DatabasePath => ".codegraph/codegraph.db"; + public IReadOnlyList ResolveSymbol(string name) => []; + public IReadOnlyList ResolveRoute(string route) => []; + public bool HasFilesUnder(string relativePathPrefix) => false; + public IReadOnlyList CandidateNames(string like) => []; + public IReadOnlyList AllRoutes() => []; + } +} diff --git a/tests/KyberWeave.Tests/DocsAnalysisConfigTests.cs b/tests/KyberWeave.Tests/DocsAnalysisConfigTests.cs new file mode 100644 index 0000000..025b368 --- /dev/null +++ b/tests/KyberWeave.Tests/DocsAnalysisConfigTests.cs @@ -0,0 +1,278 @@ +using KyberWeave.Core.Configuration; +using Xunit; +using YamlDotNet.Core; + +namespace KyberWeave.Tests; + +/// +/// T01 — documentation-analysis configuration defaults, merge semantics, and validation. +/// Invalid analysis settings must fail host configuration loading rather than silently +/// falling back to a cheaper or broader analysis mode. +/// +public class DocsAnalysisConfigTests +{ + [Fact] + public void ProductDefaults_ExactlyMatchTheBoundedAnalysisPreset() + { + var config = DocsAnalysisConfig.ProductDefaults; + + Assert.Equal(["current"], config.Statuses); + Assert.Null(config.GlossaryPath); + Assert.Equal(0.80, config.VerdictConfidence); + + Assert.Equal(DocsAnalysisSearchMode.Hybrid, config.Search.Mode); + Assert.Equal(5, config.Search.MinClaimTokens); + Assert.Equal(0.45, config.Search.LexicalCandidateThreshold); + Assert.Equal(0.90, config.Search.LexicalDuplicateThreshold); + Assert.Equal(0.78, config.Search.SemanticCandidateThreshold); + Assert.Equal(0.92, config.Search.SemanticDuplicateThreshold); + Assert.Equal(0.30, config.Search.TerminologyContextThreshold); + Assert.Equal(10, config.Search.MaxNeighborsPerClaim); + Assert.Equal(50, config.Search.MaxCodeNeighbors); + Assert.Equal(500, config.Search.MaxCandidates); + + Assert.Equal(DocsAnalysisEmbeddingMode.Off, config.Embeddings.Mode); + Assert.Null(config.Embeddings.Endpoint); + Assert.Null(config.Embeddings.Model); + Assert.Null(config.Embeddings.Dimensions); + Assert.Equal(64, config.Embeddings.BatchSize); + Assert.Equal(60, config.Embeddings.TimeoutSeconds); + Assert.Null(config.Embeddings.ApiKeyEnv); + } + + [Fact] + public void LoadFromYaml_ParsesEveryDocsAnalysisSetting() + { + var config = KyberWeaveConfigLoader.LoadFromYaml(""" + ontology: + docs-root: [docs, components/gameplay/docs] + docs-analysis: + statuses: [draft, needs-review] + glossary-path: components/gameplay/docs/terms.md + verdict-confidence: 0.73 + search: + mode: high-recall + min-claim-tokens: 8 + lexical-candidate-threshold: 0.46 + lexical-duplicate-threshold: 0.91 + semantic-candidate-threshold: 0.79 + semantic-duplicate-threshold: 0.93 + terminology-context-threshold: 0.29 + max-neighbors-per-claim: 11 + max-code-neighbors: 51 + max-candidates: 501 + embeddings: + mode: prefer + endpoint: http://localhost:1234/v1/embeddings + model: text-embedding-local + dimensions: 768 + batch-size: 32 + timeout-seconds: 45 + api-key-env: LOCAL_EMBEDDING_TOKEN + """); + + var analysis = config.DocsAnalysis; + Assert.Equal(["draft", "needs-review"], analysis.Statuses); + Assert.Equal("components/gameplay/docs/terms.md", analysis.GlossaryPath); + Assert.Equal(0.73, analysis.VerdictConfidence); + Assert.Equal(DocsAnalysisSearchMode.HighRecall, analysis.Search.Mode); + Assert.Equal(8, analysis.Search.MinClaimTokens); + Assert.Equal(0.46, analysis.Search.LexicalCandidateThreshold); + Assert.Equal(0.91, analysis.Search.LexicalDuplicateThreshold); + Assert.Equal(0.79, analysis.Search.SemanticCandidateThreshold); + Assert.Equal(0.93, analysis.Search.SemanticDuplicateThreshold); + Assert.Equal(0.29, analysis.Search.TerminologyContextThreshold); + Assert.Equal(11, analysis.Search.MaxNeighborsPerClaim); + Assert.Equal(51, analysis.Search.MaxCodeNeighbors); + Assert.Equal(501, analysis.Search.MaxCandidates); + Assert.Equal(DocsAnalysisEmbeddingMode.Prefer, analysis.Embeddings.Mode); + Assert.Equal(new Uri("http://localhost:1234/v1/embeddings"), analysis.Embeddings.Endpoint); + Assert.Equal("text-embedding-local", analysis.Embeddings.Model); + Assert.Equal(768, analysis.Embeddings.Dimensions); + Assert.Equal(32, analysis.Embeddings.BatchSize); + Assert.Equal(45, analysis.Embeddings.TimeoutSeconds); + Assert.Equal("LOCAL_EMBEDDING_TOKEN", analysis.Embeddings.ApiKeyEnv); + } + + [Fact] + public void LoadFromYaml_ReplacesStatusesAndRetainsUnspecifiedNestedDefaults() + { + var config = KyberWeaveConfigLoader.LoadFromYaml(""" + ontology: + statuses: [current, editorial] + docs-analysis: + statuses: [editorial] + search: + max-candidates: 25 + embeddings: + batch-size: 16 + """).DocsAnalysis; + + Assert.Equal(["editorial"], config.Statuses); + Assert.Equal(25, config.Search.MaxCandidates); + Assert.Equal(DocsAnalysisSearchMode.Hybrid, config.Search.Mode); + Assert.Equal(0.45, config.Search.LexicalCandidateThreshold); + Assert.Equal(16, config.Embeddings.BatchSize); + Assert.Equal(DocsAnalysisEmbeddingMode.Off, config.Embeddings.Mode); + Assert.Equal(60, config.Embeddings.TimeoutSeconds); + } + + [Fact] + public void LoadFromYaml_WhenAnalysisStatusIsNotInMergedOntology_RejectsIt() + { + var exception = AssertInvalid(""" + ontology: + statuses: [current, editorial] + docs-analysis: + statuses: [draft] + """); + + Assert.Contains("docs-analysis.statuses", exception.Message, StringComparison.Ordinal); + Assert.Contains("draft", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(InvalidThresholds))] + public void LoadFromYaml_WhenThresholdIsNotFiniteAndBetweenZeroAndOne_RejectsIt( + string yamlKey, + bool underSearch, + string invalidValue) + { + var yaml = underSearch + ? $"docs-analysis:\n search:\n {yamlKey}: {invalidValue}\n" + : $"docs-analysis:\n {yamlKey}: {invalidValue}\n"; + + var exception = AssertInvalid(yaml); + + Assert.Contains(yamlKey, exception.Message, StringComparison.Ordinal); + } + + public static TheoryData InvalidThresholds() + { + var data = new TheoryData(); + foreach (var (key, underSearch) in new[] + { + ("verdict-confidence", false), + ("lexical-candidate-threshold", true), + ("lexical-duplicate-threshold", true), + ("semantic-candidate-threshold", true), + ("semantic-duplicate-threshold", true), + ("terminology-context-threshold", true) + }) + { + data.Add(key, underSearch, "-.inf"); + data.Add(key, underSearch, ".nan"); + data.Add(key, underSearch, "-0.01"); + data.Add(key, underSearch, "1.01"); + } + + return data; + } + + [Theory] + [MemberData(nameof(NonPositiveIntegerSettings))] + public void LoadFromYaml_WhenIntegerSettingIsNotPositive_RejectsIt( + string section, + string yamlKey) + { + var exception = AssertInvalid($""" + docs-analysis: + {section}: + {yamlKey}: 0 + """); + + Assert.Contains(yamlKey, exception.Message, StringComparison.Ordinal); + } + + public static TheoryData NonPositiveIntegerSettings() => new() + { + { "search", "min-claim-tokens" }, + { "search", "max-neighbors-per-claim" }, + { "search", "max-code-neighbors" }, + { "search", "max-candidates" }, + { "embeddings", "dimensions" }, + { "embeddings", "batch-size" }, + { "embeddings", "timeout-seconds" } + }; + + [Theory] + [InlineData("prefer")] + [InlineData("required")] + public void LoadFromYaml_WhenEnabledEmbeddingsOmitEndpoint_RejectsIt(string mode) + { + var exception = AssertInvalid($""" + docs-analysis: + embeddings: + mode: {mode} + model: text-embedding-local + """); + + Assert.Contains("endpoint", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("prefer")] + [InlineData("required")] + public void LoadFromYaml_WhenEnabledEmbeddingsOmitModel_RejectsIt(string mode) + { + var exception = AssertInvalid($""" + docs-analysis: + embeddings: + mode: {mode} + endpoint: http://127.0.0.1:1234/v1/embeddings + """); + + Assert.Contains("model", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("prefer", "/v1/embeddings")] + [InlineData("required", "ftp://127.0.0.1/embeddings")] + [InlineData("prefer", "https://example.com/v1/embeddings")] + public void LoadFromYaml_WhenEmbeddingEndpointIsNotAbsoluteLoopbackHttp_RejectsIt( + string mode, + string endpoint) + { + var exception = AssertInvalid($""" + docs-analysis: + embeddings: + mode: {mode} + endpoint: {endpoint} + model: text-embedding-local + """); + + Assert.Contains("endpoint", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("/tmp/glossary.md")] + [InlineData("../glossary.md")] + [InlineData("other-docs/glossary.md")] + public void LoadFromYaml_WhenGlossaryPathIsOutsideConfiguredDocsRoots_RejectsIt(string path) + { + var exception = AssertInvalid($""" + ontology: + docs-root: [docs, components/gameplay/docs] + docs-analysis: + glossary-path: {path} + """); + + Assert.Contains("glossary-path", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void LoadFromYaml_WhenGlossaryPathIsUnderAnyConfiguredDocsRoot_AcceptsIt() + { + var config = KyberWeaveConfigLoader.LoadFromYaml(""" + ontology: + docs-root: [docs, components/gameplay/docs] + docs-analysis: + glossary-path: components/gameplay/docs/glossary.md + """); + + Assert.Equal("components/gameplay/docs/glossary.md", config.DocsAnalysis.GlossaryPath); + } + + private static YamlException AssertInvalid(string yaml) => + Assert.ThrowsAny(() => KyberWeaveConfigLoader.LoadFromYaml(yaml)); +} diff --git a/tests/KyberWeave.Tests/DocsGraphCliCommandTests.cs b/tests/KyberWeave.Tests/DocsGraphCliCommandTests.cs new file mode 100644 index 0000000..8d50845 --- /dev/null +++ b/tests/KyberWeave.Tests/DocsGraphCliCommandTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using KyberWeave.Cli.Commands.Docs; +using Xunit; + +namespace KyberWeave.Tests; + +/// Pins managed-glossary contribution at the public docs graph command boundary. +public sealed class DocsGraphCliCommandTests : IDisposable +{ + private readonly TempDirectory _repository = new(); + private readonly TempDirectory _output = new(); + + [Fact] + public void Execute_ManagedGlossary_ExportsApprovedKnowledgeOnlyAndPreservesDocuments() + { + WriteRepository(); + using var codeGraph = new CodeGraphFixtureDb(); + codeGraph.IndexSymbol("Game.Run", "src/Game.cs", 10); + var codeGraphDirectory = Path.Combine(_repository.Path, ".codegraph"); + Directory.CreateDirectory(codeGraphDirectory); + File.Copy(codeGraph.DatabasePath, Path.Combine(codeGraphDirectory, "codegraph.db")); + + var execution = ProcessConsoleCapture.Run(() => new DocsGraphCommand().Execute( + null!, + new DocsGraphSettings { Path = _repository.Path, Out = _output.Path })); + var exitCode = execution.Result; + var nodes = ReadJsonLines(Path.Combine(_output.Path, "nodes.jsonl")); + var edges = ReadJsonLines(Path.Combine(_output.Path, "edges.jsonl")); + var allOutput = File.ReadAllText(Path.Combine(_output.Path, "nodes.jsonl")) + + File.ReadAllText(Path.Combine(_output.Path, "edges.jsonl")); + + Assert.Equal(0, exitCode); + Assert.Contains(nodes, node => IsNode(node, "doc:reference/gameplay", "Document")); + Assert.Contains(nodes, node => IsNode(node, "term:loop", "Term")); + Assert.Contains(nodes, node => IsNode(node, "sense:loop-gameplay", "Sense")); + Assert.Contains(nodes, node => IsNode(node, "term:gameplay-loop", "Term")); + Assert.Contains(edges, edge => IsEdge(edge, "HAS_SENSE", "term:loop", "sense:loop-gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "ALIAS_OF", "term:gameplay-loop", "sense:loop-gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "SCOPED_TO", "sense:loop-gameplay", "component:Gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "SCOPED_TO", "sense:loop-gameplay", "id-Game.Run")); + Assert.Contains(edges, edge => IsEdge(edge, "EVIDENCED_BY", "sense:loop-gameplay", "claim-gameplay")); + Assert.DoesNotContain("loop-proposed", allOutput, StringComparison.Ordinal); + Assert.DoesNotContain("loop-rejected", allOutput, StringComparison.Ordinal); + Assert.DoesNotContain("agent loop", allOutput, StringComparison.Ordinal); + Assert.DoesNotContain("legacy loop", allOutput, StringComparison.Ordinal); + } + + [Fact] + public void Execute_InvalidGlossary_ReturnsOperationalFailureInsteadOfThrowing() + { + WriteRepository(); + Write("docs/glossary.md", """ + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: current + owner: Gameplay maintainers + last-reviewed: 2026-08-12 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | CURRENT | The gameplay update cycle. | component:Gameplay | gameplay loop | + """); + using var codeGraph = new CodeGraphFixtureDb(); + codeGraph.IndexSymbol("Game.Run", "src/Game.cs", 10); + var codeGraphDirectory = Path.Combine(_repository.Path, ".codegraph"); + Directory.CreateDirectory(codeGraphDirectory); + File.Copy(codeGraph.DatabasePath, Path.Combine(codeGraphDirectory, "codegraph.db")); + + var execution = ProcessConsoleCapture.Run(() => new DocsGraphCommand().Execute( + null!, + new DocsGraphSettings { Path = _repository.Path, Out = _output.Path, Format = "json" })); + + Assert.Equal(1, execution.Result); + Assert.Contains("KW-DOC-GLOSSARY-001", execution.Output, StringComparison.Ordinal); + } + + public void Dispose() + { + _output.Dispose(); + _repository.Dispose(); + } + + private void WriteRepository() + { + Write(".kyber-weave/kyber-weave.yml", """ + ontology: + docs-root: docs + docs-analysis: + glossary-path: docs/glossary.md + """); + Write("docs/catalog.md", """ + | Component | Type | Source root | Overview | Detailed documentation | Owner | Last reviewed | Status | + | --- | --- | --- | --- | --- | --- | --- | --- | + | Gameplay | Application | `src/Game` | [README](x) | [docs](y) | Gameplay maintainers | 2026-08-01 | Current | + """); + Write("docs/gameplay.md", """ + --- + id: reference/gameplay + title: Gameplay + doc-type: reference + status: current + component: Gameplay + owner: Gameplay maintainers + last-reviewed: 2026-08-12 + code-refs: + - Game.Run + --- + + # Gameplay + + The gameplay loop updates the world. + """); + Write("docs/glossary.md", """ + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: current + owner: Gameplay maintainers + last-reviewed: 2026-08-12 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay; code-ref:Game.Run | gameplay loop | + | loop-proposed | proposed | | component:Gameplay | agent loop | + | loop-rejected | rejected | A retired cycle. | component:Gameplay | legacy loop | + + + - claim-gameplay + + """); + Directory.CreateDirectory(Path.Combine(_repository.Path, "src", "Game")); + } + + private void Write(string relativePath, string content) + { + var path = Path.Combine(_repository.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static JsonElement[] ReadJsonLines(string path) => + File.ReadAllLines(path) + .Select(line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static bool IsNode(JsonElement node, string id, string label) => + node.GetProperty("type").GetString() == "node" + && node.GetProperty("id").GetString() == id + && node.GetProperty("label").GetString() == label; + + private static bool IsEdge(JsonElement edge, string label, string from, string to) => + edge.GetProperty("type").GetString() == "edge" + && edge.GetProperty("label").GetString() == label + && edge.GetProperty("from").GetString() == from + && edge.GetProperty("to").GetString() == to; +} diff --git a/tests/KyberWeave.Tests/DocsScaffolderTests.cs b/tests/KyberWeave.Tests/DocsScaffolderTests.cs index 4e8d5d5..2489cac 100644 --- a/tests/KyberWeave.Tests/DocsScaffolderTests.cs +++ b/tests/KyberWeave.Tests/DocsScaffolderTests.cs @@ -1,6 +1,8 @@ +using System.Text; using KyberWeave.Cli.Commands.Docs; using KyberWeave.Core.Agents.Model; using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Persistence; using KyberWeave.Core.Docs.Parsing; using KyberWeave.Core.Docs.Scaffolding; using KyberWeave.Core.Docs.Validation; @@ -253,6 +255,110 @@ public void ForceOverwritesExistingFiles() Assert.True(result.Files.Single(f => f.RelativePath == "docs/catalog.md").Written); } + /// + /// Analysis persistence is allowed only after the repository-owned state directory has + /// the exact narrow ignore entry. A fresh init must establish that safety without also + /// creating a glossary that has no reviewed senses. + /// + [Fact] + public void FreshInitCreatesOnlyTheNarrowCacheIgnoreAndNoEmptyGlossary() + { + var result = DocsScaffolder.Scaffold(_temp.Path); + + Assert.Equal("cache/\n", Read(".kyber-weave/.gitignore")); + Assert.True(AnalysisCacheSafety.IsSafe(_temp.Path)); + Assert.False(File.Exists(Path.Combine(_temp.Path, "docs", "glossary.md"))); + var entry = result.Files.Single(file => file.RelativePath == ".kyber-weave/.gitignore"); + Assert.Equal(ScaffoldOutcome.Created, entry.Outcome); + } + + /// + /// The state ignore file may contain operator-owned entries. Init owns only the narrow + /// cache line, including under --force, and must not regenerate the rest. + /// + [Fact] + public void ForceMergesCacheIgnoreWithoutReplacingExistingLines() + { + Directory.CreateDirectory(Path.Combine(_temp.Path, ".kyber-weave")); + File.WriteAllText( + Path.Combine(_temp.Path, ".kyber-weave", ".gitignore"), + "# operator entry\nlocal-notes/"); + + var result = DocsScaffolder.Scaffold(_temp.Path, force: true); + + Assert.Equal( + "# operator entry\nlocal-notes/\ncache/\n", + Read(".kyber-weave/.gitignore")); + Assert.True(AnalysisCacheSafety.IsSafe(_temp.Path)); + var entry = result.Files.Single(file => file.RelativePath == ".kyber-weave/.gitignore"); + Assert.Equal(ScaffoldOutcome.Updated, entry.Outcome); + } + + /// + /// A prior exact entry is not sufficient when a later negation exposes the cache again. + /// Init must append the narrow rule after that negation so ordinary analysis can persist + /// safely, while preserving the operator's original lines for inspection. + /// + [Fact] + public void InitRepairsAnIneffectiveCacheIgnoreWithoutDiscardingItsLines() + { + Directory.CreateDirectory(Path.Combine(_temp.Path, ".kyber-weave")); + const string existing = "cache/\n!cache/docs-analysis.sqlite3\n"; + File.WriteAllText(Path.Combine(_temp.Path, ".kyber-weave", ".gitignore"), existing); + Assert.False(AnalysisCacheSafety.IsSafe(_temp.Path)); + + DocsScaffolder.Scaffold(_temp.Path); + + Assert.Equal(existing + "cache/\n", Read(".kyber-weave/.gitignore")); + Assert.True(AnalysisCacheSafety.IsSafe(_temp.Path)); + } + + /// + /// Re-running init must not duplicate or reformat an already effective ignore entry. + /// This keeps the merge byte-stable for hosts that maintain other local-state rules. + /// + [Fact] + public void CacheIgnoreMergeIsIdempotentAndPreservesExistingBytes() + { + Directory.CreateDirectory(Path.Combine(_temp.Path, ".kyber-weave")); + const string existing = "# local state\ncache/\nlocal-notes/\n"; + File.WriteAllText(Path.Combine(_temp.Path, ".kyber-weave", ".gitignore"), existing); + + var first = DocsScaffolder.Scaffold(_temp.Path); + var second = DocsScaffolder.Scaffold(_temp.Path, force: true); + + Assert.Equal(existing, Read(".kyber-weave/.gitignore")); + Assert.Single( + File.ReadAllLines(Path.Combine(_temp.Path, ".kyber-weave", ".gitignore")), + line => StringComparer.Ordinal.Equals(line, "cache/")); + Assert.All( + new[] { first, second }, + result => Assert.Equal( + ScaffoldOutcome.Preserved, + result.Files.Single(file => file.RelativePath == ".kyber-weave/.gitignore").Outcome)); + } + + /// + /// Reading and rewriting decoded text silently changes an operator-owned file's byte + /// representation. The merge must append in the detected encoding so the original BOM, + /// Unicode text, and CRLF bytes remain an exact prefix of the result. + /// + [Fact] + public void CacheIgnoreMergePreservesExistingEncodingPreambleAndBytePrefix() + { + Directory.CreateDirectory(Path.Combine(_temp.Path, ".kyber-weave")); + var path = Path.Combine(_temp.Path, ".kyber-weave", ".gitignore"); + const string existing = "# opérateur\r\nlocal-notes/"; + File.WriteAllText(path, existing, Encoding.Unicode); + var originalBytes = File.ReadAllBytes(path); + + DocsScaffolder.Scaffold(_temp.Path); + + var appendedBytes = Encoding.Unicode.GetBytes("\r\ncache/\r\n"); + Assert.Equal(originalBytes.Concat(appendedBytes), File.ReadAllBytes(path)); + Assert.True(AnalysisCacheSafety.IsSafe(_temp.Path)); + } + /// /// A host config carries settings this scaffolder's template knows nothing about — /// harness profiles, catalog column overrides, closed vocabularies. Regenerating it @@ -419,7 +525,8 @@ public void TheLegacyRootConfigIsUpdatedRatherThanShadowed() var result = DocsScaffolder.Scaffold(_temp.Path, docsRoot: "handbook", force: true); - Assert.False(Directory.Exists(Path.Combine(_temp.Path, ".kyber-weave"))); + Assert.False(File.Exists(Path.Combine(_temp.Path, ".kyber-weave", "kyber-weave.yml"))); + Assert.Equal("cache/\n", Read(".kyber-weave/.gitignore")); var config = KyberWeaveConfigLoader.Load(_temp.Path); Assert.Equal("handbook", config.Ontology.DocsRoot); diff --git a/tests/KyberWeave.Tests/DocumentationAnalysisScaleTests.cs b/tests/KyberWeave.Tests/DocumentationAnalysisScaleTests.cs new file mode 100644 index 0000000..7ffd3f3 --- /dev/null +++ b/tests/KyberWeave.Tests/DocumentationAnalysisScaleTests.cs @@ -0,0 +1,294 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Model; +using Xunit; +using Xunit.Abstractions; + +namespace KyberWeave.Tests; + +/// +/// Guards the documented default-analysis scale envelope with deterministic data. The +/// algorithmic assertions are the primary regression signal; elapsed time and allocation +/// ceilings are Release-only acceptance checks so debug instrumentation does not make the +/// ordinary inner loop flaky. +/// +[Collection(ScaleAcceptanceCollection.Name)] +public sealed class DocumentationAnalysisScaleTests(ITestOutputHelper output) +{ + private const int DocumentCount = 1_000; + private const int ClaimsPerDocument = 10; + private const int ClaimCount = DocumentCount * ClaimsPerDocument; + private const int MaximumReviewCandidates = 500; + + [Fact] + [Trait("Category", "Scale")] + public void Analyze_DefaultHybridTenThousandClaims_RemainsBoundedAndWithinReleaseEnvelope() + { + var documents = ScaleCorpus.Create(DocumentCount, ClaimsPerDocument); + var graph = DocGraphProjection.Build(documents, FakeCodeGraphResolver.WithSymbols()); + var config = DefaultHybridConfig(); + var graphSource = new RecordingCandidateSource(new GraphClaimCandidateSource()); + var lexicalSource = new RecordingCandidateSource(new SparseLexicalCandidateSource()); + var analyzer = new DocumentationAnalyzer( + new ClaimExtractor(), + [graphSource, lexicalSource], + embeddingGenerator: null, + persistence: null); + + WarmCandidateGeneration(); + var measurement = Measure(() => analyzer.Analyze(documents, graph, config)); + var result = measurement.Value; + var allPairs = (long)ClaimCount * (ClaimCount - 1) / 2; + var configuredBound = (long)ClaimCount * config.Search.MaxNeighborsPerClaim; + + output.WriteLine( + "Hybrid scale: {0} claims, {1} graph comparisons, {2} lexical comparisons, " + + "{3} candidates, {4:F3}s, {5:F1} MiB peak working set, {6:F1} MiB allocated.", + result.Metrics.ExtractedClaims, + result.Metrics.GraphComparisons, + result.Metrics.LexicalComparisons, + result.Candidates.Count, + measurement.Elapsed.TotalSeconds, + measurement.PeakWorkingSetBytes / 1024d / 1024d, + measurement.AllocatedBytes / 1024d / 1024d); + + Assert.Equal(ClaimCount, result.Metrics.ExtractedClaims); + Assert.Equal(DocsAnalysisEmbeddingMode.Off, config.Embeddings.Mode); + Assert.Equal(0, result.Metrics.EmbeddingComparisons); + Assert.Equal(0, result.Metrics.EmbeddingCandidates); + + Assert.NotNull(graphSource.LastResult); + Assert.NotNull(lexicalSource.LastResult); + Assert.Equal(graphSource.LastResult.ComparisonCount, result.Metrics.GraphComparisons); + Assert.Equal(lexicalSource.LastResult.ComparisonCount, result.Metrics.LexicalComparisons); + Assert.Equal(graphSource.LastResult.Pairs.Count, result.Metrics.GraphCandidates); + Assert.Equal(lexicalSource.LastResult.Pairs.Count, result.Metrics.LexicalCandidates); + + Assert.True( + result.Metrics.GraphComparisons <= configuredBound, + $"Graph source performed {result.Metrics.GraphComparisons:N0} comparisons; " + + $"the configured top-k bound is {configuredBound:N0}."); + Assert.True( + result.Metrics.LexicalComparisons <= configuredBound, + $"Lexical source performed {result.Metrics.LexicalComparisons:N0} comparisons; " + + $"the deterministic sparse-neighborhood bound is {configuredBound:N0}."); + Assert.True( + (long)result.Metrics.GraphComparisons + result.Metrics.LexicalComparisons < allPairs, + $"Default hybrid analysis approached the {allPairs:N0}-pair all-pairs space."); + Assert.Equal(MaximumReviewCandidates, result.Candidates.Count); + Assert.True(graphSource.LastResult.Pairs.Count <= MaximumReviewCandidates); + Assert.True(lexicalSource.LastResult.Pairs.Count <= MaximumReviewCandidates); + Assert.True( + result.Metrics.Truncated, + "The bounded sources discarded eligible pairs at the 500-candidate review cap, " + + "so the reported truncation metric must not claim the result was complete."); + +#if !DEBUG + Assert.True( + measurement.Elapsed < TimeSpan.FromSeconds(10), + $"Default hybrid analysis took {measurement.Elapsed.TotalSeconds:F3}s; the Release target is under 10s."); + Assert.True( + measurement.PeakWorkingSetBytes < 512L * 1024 * 1024, + $"Default hybrid analysis used {measurement.PeakWorkingSetBytes / 1024d / 1024d:F1} MiB peak working set; " + + "the Release target is under 512 MiB."); +#endif + } + + [Fact] + [Trait("Category", "Scale")] + public void HighRecall_ReportsItsExplicitQuadraticFirstPassAndIsOutsideDefaultSla() + { + const int documentsCount = 160; + var documents = ScaleCorpus.Create(documentsCount, claimsPerDocument: 1); + var claims = Extract(documents); + var graph = DocGraphProjection.Build(documents, FakeCodeGraphResolver.WithSymbols()); + var source = new SparseLexicalCandidateSource(); + var hybridRequest = new ClaimCandidateSourceRequest( + claims, + graph, + Search(DocsAnalysisSearchMode.Hybrid)); + var highRecallRequest = new ClaimCandidateSourceRequest( + claims, + graph, + Search(DocsAnalysisSearchMode.HighRecall)); + + var hybrid = source.FindCandidates(hybridRequest); + var highRecall = source.FindCandidates(highRecallRequest); + var quadraticFirstPass = documentsCount * (documentsCount - 1) / 2; + + output.WriteLine( + "High-recall first pass: {0:N0} comparisons versus {1:N0} for hybrid; " + + "the quadratic pass is intentionally outside the default SLA.", + highRecall.ComparisonCount, + hybrid.ComparisonCount); + + Assert.Equal(quadraticFirstPass, highRecall.ComparisonCount); + Assert.True(highRecall.ComparisonCount > hybrid.ComparisonCount); + Assert.True(highRecall.Pairs.Count <= highRecallRequest.Search.MaxCandidates); + Assert.True( + highRecall.Pairs.Count + <= claims.Count * highRecallRequest.Search.MaxNeighborsPerClaim); + } + + private static DocsAnalysisConfig DefaultHybridConfig() => new() + { + Statuses = ["current"], + Search = Search(DocsAnalysisSearchMode.Hybrid), + Embeddings = DocsAnalysisEmbeddingConfig.ProductDefaults + }; + + private static DocsAnalysisSearchConfig Search(DocsAnalysisSearchMode mode) => new() + { + Mode = mode, + MinClaimTokens = 5, + LexicalCandidateThreshold = 0.45, + LexicalDuplicateThreshold = 0.90, + SemanticCandidateThreshold = 0.78, + SemanticDuplicateThreshold = 0.92, + TerminologyContextThreshold = 0.30, + MaxNeighborsPerClaim = 10, + MaxCodeNeighbors = 50, + MaxCandidates = MaximumReviewCandidates + }; + + private static IReadOnlyList Extract(DocumentSet documents) + { + var extractor = new ClaimExtractor(); + return documents.Documents + .SelectMany(document => extractor.Extract(document).Claims) + .ToArray(); + } + + private static void WarmCandidateGeneration() + { + var documents = ScaleCorpus.Create(documentCount: 2, claimsPerDocument: 2); + var graph = DocGraphProjection.Build(documents, FakeCodeGraphResolver.WithSymbols()); + var claims = Extract(documents); + var request = new ClaimCandidateSourceRequest( + claims, + graph, + Search(DocsAnalysisSearchMode.Hybrid)); + _ = new GraphClaimCandidateSource().FindCandidates(request); + _ = new SparseLexicalCandidateSource().FindCandidates(request); + } + + private static Measurement Measure(Func action) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + using var process = Process.GetCurrentProcess(); + var peakWorkingSet = process.WorkingSet64; + using var samplingCancellation = new CancellationTokenSource(); + var sampler = Task.Run(async () => + { + try + { + while (true) + { + process.Refresh(); + InterlockedExtensions.Max(ref peakWorkingSet, process.WorkingSet64); + await Task.Delay(TimeSpan.FromMilliseconds(5), samplingCancellation.Token) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (samplingCancellation.IsCancellationRequested) + { + // The measurement completed normally. + } + }); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + var value = action(); + stopwatch.Stop(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + samplingCancellation.Cancel(); + sampler.GetAwaiter().GetResult(); + process.Refresh(); + InterlockedExtensions.Max(ref peakWorkingSet, process.WorkingSet64); + return new Measurement(value, stopwatch.Elapsed, peakWorkingSet, allocated); + } + + private sealed record Measurement( + T Value, + TimeSpan Elapsed, + long PeakWorkingSetBytes, + long AllocatedBytes); + + private static class InterlockedExtensions + { + public static void Max(ref long location, long candidate) + { + var current = Volatile.Read(ref location); + while (candidate > current) + { + var observed = Interlocked.CompareExchange(ref location, candidate, current); + if (observed == current) return; + current = observed; + } + } + } + + private sealed class RecordingCandidateSource(IClaimCandidateSource inner) : IClaimCandidateSource + { + public CandidateSourceKind Kind => inner.Kind; + + public ClaimCandidateSourceResult? LastResult { get; private set; } + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + LastResult = inner.FindCandidates(request); + return LastResult; + } + } + + private static class ScaleCorpus + { + public static DocumentSet Create(int documentCount, int claimsPerDocument) => new() + { + Documents = Enumerable.Range(0, documentCount) + .Select(documentIndex => Document(documentIndex, claimsPerDocument)) + .ToArray() + }; + + private static DocumentModel Document(int documentIndex, int claimsPerDocument) + { + var paragraphs = Enumerable.Range(0, claimsPerDocument) + .Select(claimIndex => + $"Analyzer groupdoc{documentIndex} retains bounded evidence itemclaim{documentIndex * claimsPerDocument + claimIndex} " + + "while producing deterministic review candidates."); + var body = $"## Behavior\n\n{string.Join("\n\n", paragraphs)}\n"; + return new DocumentModel + { + RelativePath = $"docs/scale-{documentIndex:D4}.md", + FilePath = $"/repo/docs/scale-{documentIndex:D4}.md", + HasFrontmatter = true, + Frontmatter = new DocumentFrontmatter + { + Id = $"scale-{documentIndex:D4}", + Title = $"Scale {documentIndex:D4}", + DocType = "reference", + Status = "current", + Component = $"ScaleComponent{documentIndex:D4}", + CodeRefs = new Collection() + }, + DocType = DocType.Reference, + Status = DocStatus.Current, + Body = body, + RawMarkdown = body, + BodyStartLine = 1 + }; + } + } +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ScaleAcceptanceCollection +{ + public const string Name = "Documentation analysis scale acceptance"; +} diff --git a/tests/KyberWeave.Tests/DocumentationAnalyzerTests.cs b/tests/KyberWeave.Tests/DocumentationAnalyzerTests.cs new file mode 100644 index 0000000..3a6df4d --- /dev/null +++ b/tests/KyberWeave.Tests/DocumentationAnalyzerTests.cs @@ -0,0 +1,1227 @@ +using System.Collections.ObjectModel; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Graph; +using KyberWeave.Core.Docs.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Defines the analysis engine contract independently of its CLI, MCP, persistence, and +/// embedding adapters. These tests intentionally use in-memory ports so candidate +/// generation and classification remain cheap and deterministic. +/// +public sealed class DocumentationAnalyzerTests +{ + [Fact] + public void Analyze_FiltersConfiguredStatusesAndGlossaryBeforeExtractingClaims() + { + var documents = Set( + Document("current", "docs/current.md", Status.Current, "Shared claim with enough useful words."), + Document("draft", "docs/draft.md", Status.Draft, "Shared claim with enough useful words."), + Document("glossary", "docs/glossary.md", Status.Current, "Shared claim with enough useful words.")); + var config = Config(statuses: ["current"], glossaryPath: "docs/glossary.md"); + + var result = Analyzer().Analyze(documents, Graph(documents), config); + + Assert.Equal(1, result.Metrics.ExtractedClaims); + Assert.Empty(result.Candidates); + } + + [Fact] + public void Analyze_DefaultResolvedGlossaryPathWithNoncanonicalIdentity_ExcludesGlossary() + { + var documents = Set( + Document("current", "docs/current.md", Status.Current, "One current claim with enough useful words."), + Document("custom-terms", "docs/glossary.md", Status.Current, "Glossary prose must never become an analysis claim.")); + var config = KyberWeaveConfigLoader.LoadFromYaml(""" + ontology: + docs-root: docs + """).DocsAnalysis; + + var result = Analyzer().Analyze(documents, Graph(documents), config); + + Assert.Equal(1, result.Metrics.ExtractedClaims); + } + + [Fact] + public void Analyze_ExactDuplicateClaimsAcrossUnrelatedDocuments_ReturnsOneGlobalCluster() + { + var documents = Set( + Document("first", "docs/first.md", Status.Current, "The processor must retain every approved verdict."), + Document("second", "docs/second.md", Status.Current, "THE processor must retain every approved verdict!"), + Document("third", "docs/third.md", Status.Current, "The processor must retain every approved verdict.")); + + var result = Analyzer().Analyze(documents, Graph(documents), Config(mode: DocsAnalysisSearchMode.Graph)); + + var candidate = Assert.Single(result.Candidates); + Assert.Equal(AnalysisRuleKind.Duplicate, candidate.Kind); + Assert.True(candidate.IsExact); + Assert.Equal(3, candidate.Claims.Count); + var finding = Assert.Single(result.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.DuplicateRuleCode); + Assert.Equal(Severity.Warning, finding.Severity); + Assert.Equal(2, finding.RelatedLocations.Count); + } + + [Fact] + public void GraphCandidateSource_RelatedDocuments_ReturnsGraphWeightedLexicalEvidence() + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, "The runner records model token usage for automation.", component: "Runtime"), + Document("right", "docs/right.md", Status.Current, "Automation runners record model token consumption.", component: "Runtime")); + var claims = Extract(documents); + var request = new ClaimCandidateSourceRequest(claims, Graph(documents), Config().Search); + + var result = new GraphClaimCandidateSource().FindCandidates(request); + + var pair = Assert.Single(result.Pairs); + Assert.Equal(CandidateSourceKind.Graph, pair.Source); + Assert.Equal(1, pair.Score.Graph); + Assert.True(pair.Score.Lexical >= Config().Search.LexicalCandidateThreshold); + Assert.Equal(1, result.ComparisonCount); + } + + [Fact] + public void SparseLexicalCandidateSource_HybridSearchIsTopKBoundedWithoutAllPairs() + { + var documents = Set(Enumerable.Range(0, 100) + .Select(index => Document( + $"doc-{index}", + $"docs/doc-{index}.md", + Status.Current, + $"Shared retrieval term group {index % 10} has unique value {index}.")) + .ToArray()); + var claims = Extract(documents); + var config = Config(maxNeighbors: 2, lexicalCandidateThreshold: 0.10); + var request = new ClaimCandidateSourceRequest(claims, Graph(documents), config.Search); + + var result = new SparseLexicalCandidateSource().FindCandidates(request); + + Assert.True(result.ComparisonCount < claims.Count * (claims.Count - 1) / 2); + Assert.True(result.Pairs.Count <= claims.Count * config.Search.MaxNeighborsPerClaim); + Assert.All(result.Pairs, pair => Assert.Equal(CandidateSourceKind.Lexical, pair.Source)); + } + + [Theory] + [InlineData(DocsAnalysisSearchMode.Graph, 1, 0)] + [InlineData(DocsAnalysisSearchMode.Hybrid, 1, 1)] + [InlineData(DocsAnalysisSearchMode.HighRecall, 1, 1)] + public void Analyze_SearchModeSelectsGraphAndLexicalCandidateSources( + DocsAnalysisSearchMode mode, + int expectedGraphCalls, + int expectedLexicalCalls) + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, "The runtime wrapper measures elapsed execution time."), + Document("right", "docs/right.md", Status.Current, "The execution wrapper measures elapsed runtime.")); + var graph = new RecordingCandidateSource(CandidateSourceKind.Graph); + var lexical = new RecordingCandidateSource(CandidateSourceKind.Lexical); + + Analyzer([graph, lexical]).Analyze(documents, Graph(documents), Config(mode: mode)); + + Assert.Equal(expectedGraphCalls, graph.CallCount); + Assert.Equal(expectedLexicalCalls, lexical.CallCount); + Assert.All(graph.RequestedModes, requested => Assert.Equal(mode, requested)); + Assert.All(lexical.RequestedModes, requested => Assert.Equal(mode, requested)); + } + + [Theory] + [InlineData("The runner must emit a token report.", "The runner must not emit a token report.")] + [InlineData("The runner must emit a token report.", "The runner may emit a token report.")] + [InlineData("Use protocol version 1 for every request.", "Use protocol version 2 for every request.")] + [InlineData("Send requests to /api/v1/report.", "Send requests to /api/v2/report.")] + [InlineData("Run `dotnet test` before review.", "Run `npm test` before review.")] + [InlineData("Set `ExecutionMode.Local` for analysis.", "Set `ExecutionMode.Remote` for analysis.")] + public void Analyze_GraphRelatedClaimsWithConflictSignals_ReturnsPendingConflict( + string leftText, + string rightText) + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, leftText, component: "Runtime"), + Document("right", "docs/right.md", Status.Current, rightText, component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.60, graph: 1); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + + var conflict = Assert.Single(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + Assert.False(conflict.IsExact); + var finding = Assert.Single(result.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.ConflictRuleCode); + Assert.Equal(Severity.Info, finding.Severity); + } + + [Fact] + public void Analyze_FencedCodeClaimsWithDifferentCommands_ReturnsPendingConflict() + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, "```sh\ndotnet test\n```", component: "Runtime"), + Document("right", "docs/right.md", Status.Current, "```sh\nnpm test\n```", component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config(minClaimTokens: 1)); + + var conflict = Assert.Single(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + Assert.All(conflict.Claims, claim => Assert.Equal(ClaimKind.CodeBlock, claim.Kind)); + } + + [Fact] + public void Analyze_InformativeTermInDivergentDocumentContexts_ReturnsTerminologyCandidate() + { + var documents = Set( + Document( + "gameplay-loop", + "docs/gameplay.md", + Status.Current, + "The gameplay loop wraps the live-test executable and measures runtime.", + component: "Gameplay", + section: "Live testing"), + Document( + "codex-loop", + "docs/codex.md", + Status.Current, + "The Codex loop repeatedly churns autonomous tasks and consumes model tokens.", + component: "Automation", + section: "Agent execution")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Lexical, lexical: 0.20, graph: 0); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + + var terminology = Assert.Single(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Terminology); + Assert.Equal("loop", terminology.Term); + Assert.Equal(2, terminology.Claims.Count); + Assert.Contains( + result.Diagnostics.Items, + item => item.Code == DocumentationAnalyzer.TerminologyRuleCode && item.Severity == Severity.Warning); + } + + [Fact] + public void Analyze_HighConfidenceDuplicateVerdictPromotesNearDuplicateToWarning() + { + var documents = NearDuplicateDocuments(); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.95, graph: 1); + var pending = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + var candidate = Assert.Single(pending.Candidates, item => item.Kind == AnalysisRuleKind.Duplicate); + var persistence = new StubPersistence(new AnalysisVerdict( + candidate.Id, + AnalysisVerdictLabel.Duplicate, + 0.90, + "Both claims impose the same requirement.")); + + var reviewed = Analyzer([source], persistence).Analyze(documents, Graph(documents), Config()); + + var finding = Assert.Single(reviewed.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.DuplicateRuleCode); + Assert.Equal(Severity.Warning, finding.Severity); + } + + [Fact] + public void Analyze_HighConfidenceConflictVerdictPromotesConflictToError() + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, "The runner must emit token usage.", component: "Runtime"), + Document("right", "docs/right.md", Status.Current, "The runner must not emit token usage.", component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.70, graph: 1); + var pending = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + var candidate = Assert.Single(pending.Candidates, item => item.Kind == AnalysisRuleKind.Conflict); + var persistence = new StubPersistence(new AnalysisVerdict( + candidate.Id, + AnalysisVerdictLabel.Conflict, + 0.90, + "The obligations cannot both hold in the same runtime scope.")); + + var reviewed = Analyzer([source], persistence).Analyze(documents, Graph(documents), Config()); + + var finding = Assert.Single(reviewed.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.ConflictRuleCode); + Assert.Equal(Severity.Error, finding.Severity); + } + + [Theory] + [InlineData(AnalysisVerdictLabel.Benign, 0.90, 0)] + [InlineData(AnalysisVerdictLabel.Benign, 0.79, 1)] + [InlineData(AnalysisVerdictLabel.Uncertain, 0.95, 1)] + public void Analyze_VerdictLabelAndConfidenceControlCandidateSuppression( + AnalysisVerdictLabel label, + double confidence, + int expectedCandidates) + { + var documents = NearDuplicateDocuments(); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.95, graph: 1); + var pending = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + var candidate = Assert.Single(pending.Candidates, item => item.Kind == AnalysisRuleKind.Duplicate); + var persistence = new StubPersistence(new AnalysisVerdict( + candidate.Id, + label, + confidence, + "Review disposition.")); + + var reviewed = Analyzer([source], persistence).Analyze(documents, Graph(documents), Config()); + + Assert.Equal(expectedCandidates, reviewed.Candidates.Count); + } + + [Fact] + public void Analyze_ApprovedScopedGlossarySensesCoverEveryOccurrence_SuppressesTerminologyWarning() + { + var documents = Set( + Document("gameplay", "docs/gameplay.md", Status.Current, "The gameplay loop measures live-test runtime.", component: "Gameplay"), + Document("automation", "docs/automation.md", Status.Current, "The Codex loop consumes model tokens.", component: "Automation")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Lexical, lexical: 0.10, graph: 0); + var glossary = new AnalysisGlossary( + [ + new ApprovedGlossarySense( + "loop-gameplay", + "loop", + "The gameplay live-test wrapper.", + ["component:Gameplay"], + ["gameplay loop"]), + new ApprovedGlossarySense( + "loop-codex", + "loop", + "The autonomous Codex churn cycle.", + ["component:Automation"], + ["Codex loop"]) + ]); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config(), glossary); + + Assert.DoesNotContain(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Terminology); + Assert.DoesNotContain(result.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.TerminologyRuleCode); + } + + [Fact] + public void Analyze_ApprovedComponentScopeIgnoresCase_SuppressesTerminologyWarning() + { + var documents = Set( + Document("gameplay", "docs/gameplay.md", Status.Current, "The gameplay loop measures live-test runtime.", component: "Gameplay"), + Document("live-test", "docs/live-test.md", Status.Current, "The loop records live-test runtime samples.", component: "Gameplay")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Lexical, lexical: 0.10, graph: 0); + var glossary = new AnalysisGlossary( + [ + new ApprovedGlossarySense( + "loop-gameplay", + "loop", + "The gameplay live-test wrapper.", + ["component:gameplay"], + ["gameplay loop"]) + ]); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config(), glossary); + + Assert.DoesNotContain( + result.Candidates, + candidate => candidate.Kind == AnalysisRuleKind.Terminology + && StringComparer.OrdinalIgnoreCase.Equals(candidate.Term, "loop")); + } + + [Fact] + public void Analyze_ApprovedCodeRefScopedSensesCoverEveryOccurrence_SuppressesTerminologyWarning() + { + var documents = Set( + Document( + "gameplay", + "docs/gameplay.md", + Status.Current, + "The gameplay loop measures live-test runtime.", + component: "Runtime", + codeRefs: ["Game.Run"]), + Document( + "automation", + "docs/automation.md", + Status.Current, + "The Codex loop consumes model tokens.", + component: "Runtime", + codeRefs: ["Agent.Run"])); + var source = new FirstPairCandidateSource(CandidateSourceKind.Lexical, lexical: 0.10, graph: 0); + var glossary = new AnalysisGlossary( + [ + new ApprovedGlossarySense( + "loop-gameplay", + "loop", + "The gameplay live-test wrapper.", + ["code-ref:Game.Run"], + ["gameplay loop"]), + new ApprovedGlossarySense( + "loop-codex", + "loop", + "The autonomous Codex churn cycle.", + ["code-ref:Agent.Run"], + ["Codex loop"]) + ]); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config(), glossary); + + Assert.DoesNotContain(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Terminology); + Assert.DoesNotContain(result.Diagnostics.Items, item => item.Code == DocumentationAnalyzer.TerminologyRuleCode); + } + + [Fact] + public void CandidateId_UsesKindTermSortedContentHashesAndAnalyzerRubricVersions() + { + var first = AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + "loop", + ["hash-b", "hash-a"], + "analyzer/v1", + "rubric/v1"); + var reordered = AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + "loop", + ["hash-a", "hash-b"], + "analyzer/v1", + "rubric/v1"); + + Assert.Equal(first, reordered); + Assert.Matches("^[a-f0-9]{64}$", first); + Assert.NotEqual(first, AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + "cycle", + ["hash-a", "hash-b"], + "analyzer/v1", + "rubric/v1")); + Assert.NotEqual(first, AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + "loop", + ["hash-a", "hash-b"], + "analyzer/v2", + "rubric/v1")); + Assert.NotEqual(first, AnalysisCandidateId.Compute( + AnalysisRuleKind.Terminology, + "loop", + ["hash-a", "hash-b"], + "analyzer/v1", + "rubric/v2")); + } + + [Fact] + public void Analyze_InvalidIgnoreMarkupPropagatesOperationalRule004() + { + var documents = Set(Document( + "invalid", + "docs/invalid.md", + Status.Current, + "Claim text.")); + + var result = Analyzer().Analyze(documents, Graph(documents), Config()); + + Assert.Contains( + result.Diagnostics.Items, + item => item.Code == DocumentationAnalyzer.IgnoreMarkupRuleCode && item.Severity == Severity.Error); + } + + [Fact] + public void DocumentationAnalyzer_ReservesPermanentAnalysisRuleIds() + { + Assert.Equal("KW-DOC-ANALYSIS-001", DocumentationAnalyzer.DuplicateRuleCode); + Assert.Equal("KW-DOC-ANALYSIS-002", DocumentationAnalyzer.ConflictRuleCode); + Assert.Equal("KW-DOC-ANALYSIS-003", DocumentationAnalyzer.TerminologyRuleCode); + Assert.Equal("KW-DOC-ANALYSIS-004", DocumentationAnalyzer.IgnoreMarkupRuleCode); + Assert.Equal("KW-DOC-ANALYSIS-005", DocumentationAnalyzer.CodeGraphUnavailableRuleCode); + Assert.Equal("KW-DOC-ANALYSIS-006", DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + } + + [Fact] + public void Analyze_ReportsCandidateSourceMetricsAndAppliesGlobalCandidateCap() + { + var documents = Set(Enumerable.Range(0, 6) + .Select(index => Document( + $"doc-{index}", + $"docs/doc-{index}.md", + Status.Current, + $"The runner records token usage for execution variant {index}.")) + .ToArray()); + var graph = new AdjacentPairCandidateSource(CandidateSourceKind.Graph, comparisonCount: 5); + var lexical = new AdjacentPairCandidateSource(CandidateSourceKind.Lexical, comparisonCount: 7); + + var result = Analyzer([graph, lexical]).Analyze( + documents, + Graph(documents), + Config(maxCandidates: 2, lexicalDuplicateThreshold: 0.90)); + + Assert.Equal(2, result.Candidates.Count); + Assert.True(result.Metrics.Truncated); + Assert.Equal(6, result.Metrics.ExtractedClaims); + Assert.Equal(5, result.Metrics.GraphComparisons); + Assert.Equal(7, result.Metrics.LexicalComparisons); + Assert.True(result.Metrics.GraphCandidates > 0); + Assert.True(result.Metrics.LexicalCandidates > 0); + Assert.Equal(0, result.Metrics.EmbeddingComparisons); + Assert.Equal(0, result.Metrics.EmbeddingCandidates); + } + + [Fact] + public void Analyze_WhenEmbeddingsAreEnabled_GeneratesCachesAndSuppliesSemanticCandidates() + { + var documents = Set( + Document( + "first", + "docs/first.md", + Status.Current, + "Operators archive accepted adjudications for later use."), + Document( + "second", + "docs/second.md", + Status.Current, + "The service retains approved review verdicts durably.")); + var lexical = new FirstPairCandidateSource( + CandidateSourceKind.Lexical, + lexical: 0.10, + graph: 0); + var generator = new SemanticMatchGenerator(); + var persistence = new EmbeddingPersistence(); + + var result = Analyzer([lexical], persistence, generator).Analyze( + documents, + Graph(documents), + Config(embeddings: new DocsAnalysisEmbeddingConfig + { + Mode = DocsAnalysisEmbeddingMode.Prefer, + Endpoint = new Uri("http://127.0.0.1:1234/v1/embeddings"), + Model = "semantic-test", + Dimensions = 2 + })); + + var candidate = Assert.Single( + result.Candidates, + item => item.Kind == AnalysisRuleKind.Duplicate && !item.IsExact); + Assert.Contains(CandidateSourceKind.Embedding, candidate.Sources); + Assert.Equal(1, result.Metrics.EmbeddingComparisons); + Assert.Equal(1, result.Metrics.EmbeddingCandidates); + Assert.Equal(1, generator.CallCount); + Assert.Equal(2, persistence.SavedEmbeddings.Count); + Assert.DoesNotContain( + result.Diagnostics.Items, + item => item.Code == DocumentationAnalyzer.EmbeddingUnavailableRuleCode); + } + + [Fact] + public void AnalyzerPorts_ArePublicAndInfrastructureNeutral() + { + Assert.True(typeof(IClaimCandidateSource).IsInterface); + Assert.True(typeof(IEmbeddingGenerator).IsInterface); + Assert.True(typeof(IAnalysisPersistence).IsInterface); + Assert.DoesNotContain( + typeof(IAnalysisPersistence).GetMethods(), + method => method.ReturnType.FullName?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true); + } + + [Fact] + public void Analyze_DefaultSources_SurfaceDivergentTerminologyBelowDuplicateCandidateThreshold() + { + var documents = Set( + Document( + "gameplay", + "docs/gameplay.md", + Status.Current, + "The gameplay loop wraps live testing and reports elapsed runtime.", + section: "Gameplay testing"), + Document( + "automation", + "docs/automation.md", + Status.Current, + "The Codex loop churns autonomous tasks and consumes model tokens.", + section: "Agent automation")); + + var result = Analyzer().Analyze( + documents, + Graph(documents), + Config(lexicalCandidateThreshold: 0.80)); + + var terminology = Assert.Single( + result.Candidates, + candidate => candidate.Kind == AnalysisRuleKind.Terminology); + Assert.Equal("loop", terminology.Term); + } + + [Fact] + public void DocGraphProjection_ExposesIndexedRelatedDocumentNeighborhoods() + { + var method = typeof(DocGraphProjection).GetMethod( + "GetRelatedDocumentIds", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic, + binder: null, + types: [typeof(string)], + modifiers: null); + + Assert.NotNull(method); + } + + [Fact] + public void GraphCandidateSource_ScoresEveryIndexedNeighborThenSelectsHighestTopK() + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, "Alpha beta gamma delta epsilon requirement.", component: "Runtime"), + Document("early", "docs/early.md", Status.Current, "Alpha beta unrelated early candidate text.", component: "Runtime"), + Document("middle", "docs/middle.md", Status.Current, "Alpha beta another candidate with noise.", component: "Runtime"), + Document("best", "docs/best.md", Status.Current, "Alpha beta gamma delta epsilon guarantee.", component: "Runtime")); + var claims = Extract(documents); + var request = new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config(maxNeighbors: 1, lexicalCandidateThreshold: 0.10).Search); + + var result = new GraphClaimCandidateSource().FindCandidates(request); + + Assert.True(result.ComparisonCount <= claims.Count); + Assert.Contains(result.Pairs, pair => + PairIdentities(pair).SetEquals(["left", "best"])); + } + + [Fact] + public void Analyze_InlineCodeLiteralDifferencesRemainDetectableConflictEvidence() + { + var documents = Set( + Document("local", "docs/local.md", Status.Current, "The analysis mode is `ExecutionMode.Local` for requests.", component: "Runtime"), + Document("remote", "docs/remote.md", Status.Current, "The analysis mode is `ExecutionMode.Remote` for requests.", component: "Runtime")); + var claims = Extract(documents); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.70, graph: 1); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + + Assert.Contains("ExecutionMode.Local", claims[0].Text, StringComparison.Ordinal); + Assert.Contains("ExecutionMode.Remote", claims[1].ContextualText, StringComparison.Ordinal); + Assert.Contains(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Fact] + public void Analyze_SameContentPairAcrossLocations_MergesEveryDistinctEvidenceClaim() + { + var documents = Set( + Document("left-one", "docs/left-one.md", Status.Current, "The processor retains every imported approved verdict."), + Document("left-two", "docs/left-two.md", Status.Current, "The processor retains every imported approved verdict."), + Document("right-one", "docs/right-one.md", Status.Current, "The processor must retain all approved imported verdicts."), + Document("right-two", "docs/right-two.md", Status.Current, "The processor must retain all approved imported verdicts.")); + var source = new AllPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.95, graph: 1); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + + var nearDuplicate = Assert.Single( + result.Candidates, + candidate => candidate.Kind == AnalysisRuleKind.Duplicate && !candidate.IsExact); + Assert.Equal(4, nearDuplicate.Claims.Count); + Assert.Equal(4, nearDuplicate.Claims.Select(claim => claim.FilePath).Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void SparseLexicalCandidateSource_HighRecallBroadensHybridPoolAndReportsComparisons() + { + var documents = Set(Enumerable.Range(0, 20) + .Select(index => Document( + $"doc-{index}", + $"docs/doc-{index}.md", + Status.Current, + index is 0 or 19 + ? $"Alpha beta gamma critical-token-{index} governs runtime behavior." + : $"Alpha beta gamma filler-token-{index} documents unrelated behavior.")) + .ToArray()); + var claims = Extract(documents); + var source = new SparseLexicalCandidateSource(); + var hybrid = source.FindCandidates(new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config( + mode: DocsAnalysisSearchMode.Hybrid, + maxNeighbors: 1, + lexicalCandidateThreshold: 0.40).Search)); + var highRecall = source.FindCandidates(new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config( + mode: DocsAnalysisSearchMode.HighRecall, + maxNeighbors: 1, + lexicalCandidateThreshold: 0.40).Search)); + + Assert.True(highRecall.ComparisonCount > hybrid.ComparisonCount); + Assert.Contains(highRecall.Pairs, pair => PairIdentities(pair).SetEquals(["doc-0", "doc-19"])); + } + + [Fact] + public void Analyze_TerminologyClustersOneInformativeTermAcrossAllDivergentContexts() + { + var documents = Set( + Document("gameplay", "docs/gameplay.md", Status.Current, "The gameplay loop wraps live testing documentation.", section: "Gameplay"), + Document("automation", "docs/automation.md", Status.Current, "The autonomous loop churns agent tasks documentation.", section: "Automation"), + Document("desktop", "docs/desktop.md", Status.Current, "The event loop schedules UI callbacks documentation.", section: "Desktop")); + var source = new AllPairCandidateSource(CandidateSourceKind.Lexical, lexical: 0.10, graph: 0); + + var result = Analyzer([source]).Analyze(documents, Graph(documents), Config()); + + var terminology = Assert.Single( + result.Candidates, + candidate => candidate.Kind == AnalysisRuleKind.Terminology); + Assert.Equal("loop", terminology.Term); + Assert.Equal(3, terminology.Claims.Count); + } + + [Fact] + public void SparseLexicalCandidateSource_AppliesGlobalCandidateCapDuringGeneration() + { + var documents = Set(Enumerable.Range(0, 12) + .Select(index => Document( + $"doc-{index}", + $"docs/doc-{index}.md", + Status.Current, + $"Shared analysis runtime behavior variant {index} is documented here.")) + .ToArray()); + var request = new ClaimCandidateSourceRequest( + Extract(documents), + Graph(documents), + Config( + lexicalCandidateThreshold: 0.10, + maxNeighbors: 10, + maxCandidates: 3).Search); + + var result = new SparseLexicalCandidateSource().FindCandidates(request); + + Assert.True(result.Pairs.Count <= request.Search.MaxCandidates); + } + + [Fact] + public void SparseLexicalCandidateSource_GlobalCandidateCapKeepsHighestScoringPairs() + { + var documents = Set( + Document("low-a", "docs/00-low-a.md", Status.Current, "unrelated copper material"), + Document("low-b", "docs/01-low-b.md", Status.Current, "distinct silver mineral"), + Document("high-a", "docs/02-high-a.md", Status.Current, "shared analysis runtime behavior is documented here"), + Document("high-b", "docs/03-high-b.md", Status.Current, "shared analysis runtime behavior is documented there")); + var request = new ClaimCandidateSourceRequest( + Extract(documents), + Graph(documents), + Config( + mode: DocsAnalysisSearchMode.HighRecall, + lexicalCandidateThreshold: 0.10, + maxNeighbors: 10, + maxCandidates: 1).Search); + + var result = new SparseLexicalCandidateSource().FindCandidates(request); + + var pair = Assert.Single(result.Pairs); + Assert.True(PairIdentities(pair).SetEquals(["high-a", "high-b"])); + } + + [Fact] + public void GraphCandidateSource_IdLessDocuments_StillFindSharedComponentPairs() + { + var documents = Set( + Document("", "docs/left.md", Status.Current, "The runner records model token usage for automation.", component: "Runtime"), + Document("", "docs/right.md", Status.Current, "Automation runners record model token consumption.", component: "Runtime")); + var claims = Extract(documents); + var request = new ClaimCandidateSourceRequest(claims, Graph(documents), Config().Search); + + var result = new GraphClaimCandidateSource().FindCandidates(request); + + var pair = Assert.Single(result.Pairs); + Assert.Equal(CandidateSourceKind.Graph, pair.Source); + Assert.Equal(1, pair.Score.Graph); + Assert.True(pair.Score.Lexical >= Config().Search.LexicalCandidateThreshold); + } + + [Fact] + public void Analyze_NonidenticalCodeBlocksWithoutSubstantiveDisagreement_AreNotConflicts() + { + var documents = Set( + Document("debug", "docs/debug.md", Status.Current, "```csharp\nlogger.Debug(message);\n```", component: "Runtime"), + Document("info", "docs/info.md", Status.Current, "```csharp\nlogger.Info(message);\n```", component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze( + documents, + Graph(documents), + Config(minClaimTokens: 1)); + + Assert.DoesNotContain(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Fact] + public void GraphCandidateSource_DenseGraphBoundsScoringBeforeSelectingCorrectPerClaimTopK() + { + const int documentCount = 40; + const int maximumNeighbors = 2; + var documents = Set(Enumerable.Range(0, documentCount) + .Select(index => Document( + $"dense-{index}", + $"docs/dense-{index}.md", + Status.Current, + index switch + { + 0 => "Anchor alpha beta gamma delta epsilon omega governs processing.", + documentCount - 1 => "Anchor alpha beta gamma delta epsilon omega governs execution.", + _ => $"Filler topic-{index} records unrelated operational material." + }, + component: "DenseRuntime")) + .ToArray()); + var claims = Extract(documents); + var request = new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config( + maxNeighbors: maximumNeighbors, + lexicalCandidateThreshold: 0.10).Search); + + var result = new GraphClaimCandidateSource().FindCandidates(request); + + Assert.True( + result.ComparisonCount <= claims.Count * maximumNeighbors, + $"Dense graph scoring performed {result.ComparisonCount} comparisons for {claims.Count} claims at top-{maximumNeighbors}."); + Assert.Contains(result.Pairs, pair => + PairIdentities(pair).SetEquals(["dense-0", $"dense-{documentCount - 1}"])); + Assert.All( + claims, + claim => Assert.True( + result.Pairs.Count(pair => pair.Left == claim || pair.Right == claim) <= maximumNeighbors)); + } + + [Fact] + public void Analyze_LexicalCandidateThresholdGatesDuplicateAndConflictButNotTerminologyDivergence() + { + var ordinaryDocuments = Set( + Document("duplicate-a", "docs/duplicate-a.md", Status.Current, "The processor securely retains approved imported verdict records."), + Document("duplicate-b", "docs/duplicate-b.md", Status.Current, "The service safely preserves reviewed verdict records from imports."), + Document("conflict-a", "docs/conflict-a.md", Status.Current, "The runtime runner must emit token usage reports.", component: "Runtime"), + Document("conflict-b", "docs/conflict-b.md", Status.Current, "The runtime exporter must not publish model accounting summaries.", component: "Runtime")); + var terminologyDocuments = Set( + Document("gameplay", "docs/gameplay.md", Status.Current, "Gameplay loop wraps live testing while measuring elapsed duration.", section: "Gameplay"), + Document("automation", "docs/automation.md", Status.Current, "Autonomous Codex loop churns agent tasks while consuming model tokens.", section: "Automation")); + var config = Config( + lexicalCandidateThreshold: 0.80, + lexicalDuplicateThreshold: 0.30); + + var ordinary = Analyzer().Analyze(ordinaryDocuments, Graph(ordinaryDocuments), config); + var terminology = Analyzer().Analyze(terminologyDocuments, Graph(terminologyDocuments), config); + + Assert.DoesNotContain(ordinary.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Duplicate); + Assert.DoesNotContain(ordinary.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + Assert.Contains( + terminology.Candidates, + candidate => candidate.Kind == AnalysisRuleKind.Terminology && candidate.Term == "loop"); + } + + [Fact] + public void ClaimExtractor_FencedCodeRetainsLanguageAndInfoOnClaimMetadata() + { + var document = Document( + "shell", + "docs/shell.md", + Status.Current, + "```bash title=\"verification\"\ndotnet test\n```"); + + var claim = Assert.Single(Extract(Set(document))); + var fenceInfo = typeof(Claim).GetProperty("FenceInfo"); + + Assert.NotNull(fenceInfo); + Assert.Equal("bash title=\"verification\"", fenceInfo.GetValue(claim)); + } + + [Theory] + [InlineData("yaml", "mode: local", "mode: remote")] + [InlineData("", "plain documentation example", "another documentation example")] + public void Analyze_NonShellFencedBlocksAreNotShellCommandConflicts( + string fenceInfo, + string leftText, + string rightText) + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, Fence(fenceInfo, leftText), component: "Runtime"), + Document("right", "docs/right.md", Status.Current, Fence(fenceInfo, rightText), component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze( + documents, + Graph(documents), + Config(minClaimTokens: 1)); + + Assert.DoesNotContain(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Theory] + [InlineData("bash", "MODE=local dotnet test", "MODE=remote dotnet test")] + [InlineData("sh", "dotnet test && echo local", "dotnet test && echo remote")] + [InlineData("bash", "cp /src/v1/report /dest", "cp /src/v2/report /dest")] + public void Analyze_ShellFencesRecognizeAssignmentsAndCompoundCommandsAsConflictEligible( + string fenceInfo, + string leftCommand, + string rightCommand) + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, Fence(fenceInfo, leftCommand), component: "Runtime"), + Document("right", "docs/right.md", Status.Current, Fence(fenceInfo, rightCommand), component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze( + documents, + Graph(documents), + Config(minClaimTokens: 1)); + + Assert.Contains(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Fact] + public void GraphCandidateSource_DenseSmallCorpusStillBoundsScoringToClaimsTimesTopK() + { + const int documentCount = 8; + const int maximumNeighbors = 2; + var documents = Set(Enumerable.Range(0, documentCount) + .Select(index => Document( + $"small-dense-{index}", + $"docs/small-dense-{index}.md", + Status.Current, + $"Shared dense runtime behavior has variant {index} documentation.", + component: "DenseRuntime")) + .ToArray()); + var claims = Extract(documents); + + var result = new GraphClaimCandidateSource().FindCandidates(new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config(maxNeighbors: maximumNeighbors, lexicalCandidateThreshold: 0.10).Search)); + + Assert.True( + result.ComparisonCount <= claims.Count * maximumNeighbors, + $"Dense graph scoring performed {result.ComparisonCount} comparisons for {claims.Count} claims at top-{maximumNeighbors}."); + } + + [Fact] + public void GraphCandidateSource_SparseRankingKeepsShortPerfectLexicalMatch() + { + var documents = Set( + Document("short", "docs/00-short.md", Status.Current, "alpha beta", component: "Runtime", section: "Short"), + Document("short-decoy", "docs/01-short-decoy.md", Status.Current, "alpha beta theta", component: "Runtime", section: "Decoy"), + Document("filler-a", "docs/02-filler.md", Status.Current, "unrelated copper material", component: "Runtime", section: "Filler"), + Document("filler-b", "docs/03-filler.md", Status.Current, "unrelated silver material", component: "Runtime", section: "Filler"), + Document("long-decoy", "docs/04-long-decoy.md", Status.Current, "alpha beta gamma delta copper silver bronze quartz", component: "Runtime", section: "Long"), + Document("anchor", "docs/05-anchor.md", Status.Current, "alpha beta gamma delta epsilon zeta", component: "Runtime", section: "Short")); + var claims = Extract(documents); + + var result = new GraphClaimCandidateSource().FindCandidates(new ClaimCandidateSourceRequest( + claims, + Graph(documents), + Config(maxNeighbors: 1, lexicalCandidateThreshold: 0.10).Search)); + + Assert.Contains(result.Pairs, pair => PairIdentities(pair).SetEquals(["short", "anchor"])); + Assert.True(result.ComparisonCount <= claims.Count); + } + + [Theory] + [InlineData("yaml", "version: 1", "version: 2")] + [InlineData("text", "Endpoint is /api/v1/report", "Endpoint is /api/v2/report")] + public void Analyze_NonShellFencesRemainEligibleForSubstantiveValueConflicts( + string fenceInfo, + string leftValue, + string rightValue) + { + var documents = Set( + Document("left", "docs/left.md", Status.Current, Fence(fenceInfo, leftValue), component: "Runtime"), + Document("right", "docs/right.md", Status.Current, Fence(fenceInfo, rightValue), component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze( + documents, + Graph(documents), + Config(minClaimTokens: 1)); + + Assert.Contains(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Fact] + public void Analyze_ShellFencesCompareMeaningfulCommandsBeyondComments() + { + var differingAssignments = Set( + Document("local", "docs/local.md", Status.Current, Fence("bash", "# shared setup\nMODE=local dotnet test"), component: "Runtime"), + Document("remote", "docs/remote.md", Status.Current, Fence("bash", "# shared setup\nMODE=remote dotnet test"), component: "Runtime")); + var commentsOnly = Set( + Document("comment-a", "docs/comment-a.md", Status.Current, Fence("bash", "# local note\ndotnet test"), component: "Runtime"), + Document("comment-b", "docs/comment-b.md", Status.Current, Fence("bash", "# remote note\ndotnet test"), component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var assignmentsResult = Analyzer([source]).Analyze( + differingAssignments, + Graph(differingAssignments), + Config(minClaimTokens: 1)); + var commentsResult = Analyzer([source]).Analyze( + commentsOnly, + Graph(commentsOnly), + Config(minClaimTokens: 1)); + + Assert.Contains(assignmentsResult.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + Assert.DoesNotContain(commentsResult.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + [Theory] + [InlineData("bash", "# ")] + [InlineData("powershell", "# ")] + [InlineData("cmd", "REM ")] + [InlineData("bat", "::")] + public void Analyze_RecognizedShellFenceIgnoresNumericAndNegationDifferencesInComments( + string fenceInfo, + string commentPrefix) + { + var documents = Set( + Document( + "left", + "docs/left.md", + Status.Current, + Fence(fenceInfo, $"{commentPrefix}do not use version 1\ndotnet test"), + component: "Runtime"), + Document( + "right", + "docs/right.md", + Status.Current, + Fence(fenceInfo, $"{commentPrefix}use version 2\ndotnet test"), + component: "Runtime")); + var source = new FirstPairCandidateSource(CandidateSourceKind.Graph, lexical: 0.50, graph: 1); + + var result = Analyzer([source]).Analyze( + documents, + Graph(documents), + Config(minClaimTokens: 1)); + + Assert.DoesNotContain(result.Candidates, candidate => candidate.Kind == AnalysisRuleKind.Conflict); + } + + private static DocumentationAnalyzer Analyzer( + IReadOnlyList? sources = null, + IAnalysisPersistence? persistence = null, + IEmbeddingGenerator? embeddingGenerator = null) => + new( + new ClaimExtractor(), + sources ?? [new GraphClaimCandidateSource(), new SparseLexicalCandidateSource()], + embeddingGenerator, + persistence); + + private static DocumentSet NearDuplicateDocuments() => + Set( + Document("left", "docs/left.md", Status.Current, "The processor retains every imported approved verdict.", component: "Runtime"), + Document("right", "docs/right.md", Status.Current, "The processor must retain all approved imported verdicts.", component: "Runtime")); + + private static IReadOnlyList Extract(DocumentSet documents) => + documents.Documents.SelectMany(document => new ClaimExtractor().Extract(document).Claims).ToArray(); + + private static DocGraphProjection Graph(DocumentSet documents) => + DocGraphProjection.Build(documents, FakeCodeGraphResolver.WithSymbols()); + + private static DocumentSet Set(params DocumentModel[] documents) => new() { Documents = documents }; + + private static DocumentModel Document( + string id, + string path, + Status status, + string claim, + string? component = null, + string section = "Behavior", + IReadOnlyList? codeRefs = null) + { + var body = $"## {section}\n\n{claim}\n"; + return new DocumentModel + { + RelativePath = path, + FilePath = "/repo/" + path, + HasFrontmatter = true, + Frontmatter = new DocumentFrontmatter + { + Id = id, + Title = id, + DocType = "reference", + Status = status.Value, + Component = component, + CodeRefs = codeRefs is null ? null : new Collection(codeRefs.ToList()) + }, + DocType = DocType.Reference, + Status = status.Model, + Body = body, + RawMarkdown = body, + BodyStartLine = 1 + }; + } + + private static DocsAnalysisConfig Config( + IReadOnlyList? statuses = null, + string? glossaryPath = null, + DocsAnalysisSearchMode mode = DocsAnalysisSearchMode.Hybrid, + int minClaimTokens = 5, + double lexicalCandidateThreshold = 0.45, + double lexicalDuplicateThreshold = 0.90, + int maxNeighbors = 10, + int maxCandidates = 500, + DocsAnalysisEmbeddingConfig? embeddings = null) => + new() + { + Statuses = statuses ?? ["current"], + GlossaryPath = glossaryPath, + Search = new DocsAnalysisSearchConfig + { + Mode = mode, + MinClaimTokens = minClaimTokens, + LexicalCandidateThreshold = lexicalCandidateThreshold, + LexicalDuplicateThreshold = lexicalDuplicateThreshold, + SemanticCandidateThreshold = 0.78, + SemanticDuplicateThreshold = 0.92, + TerminologyContextThreshold = 0.30, + MaxNeighborsPerClaim = maxNeighbors, + MaxCodeNeighbors = 50, + MaxCandidates = maxCandidates + }, + Embeddings = embeddings ?? DocsAnalysisEmbeddingConfig.ProductDefaults + }; + + private static HashSet PairIdentities(ClaimPairCandidate pair) => + new([pair.Left.DocumentIdentity, pair.Right.DocumentIdentity], StringComparer.Ordinal); + + private static string Fence(string info, string content) => $"```{info}\n{content}\n```"; + + private sealed class RecordingCandidateSource(CandidateSourceKind kind) : IClaimCandidateSource + { + public CandidateSourceKind Kind { get; } = kind; + public int CallCount { get; private set; } + public List RequestedModes { get; } = []; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + CallCount++; + RequestedModes.Add(request.Search.Mode); + return new ClaimCandidateSourceResult([], 0); + } + } + + private sealed class FirstPairCandidateSource( + CandidateSourceKind kind, + double lexical, + double graph) : IClaimCandidateSource + { + public CandidateSourceKind Kind { get; } = kind; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + Assert.True(request.Claims.Count >= 2); + return new ClaimCandidateSourceResult( + [ + new ClaimPairCandidate( + request.Claims[0], + request.Claims[1], + Kind, + new CandidateScore(lexical, null, graph)) + ], 1); + } + } + + private sealed class AdjacentPairCandidateSource( + CandidateSourceKind kind, + int comparisonCount) : IClaimCandidateSource + { + public CandidateSourceKind Kind { get; } = kind; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + var pairs = request.Claims.Zip(request.Claims.Skip(1)) + .Select(pair => new ClaimPairCandidate( + pair.First, + pair.Second, + Kind, + new CandidateScore(0.95, null, Kind == CandidateSourceKind.Graph ? 1 : 0))) + .ToArray(); + return new ClaimCandidateSourceResult(pairs, comparisonCount); + } + } + + private sealed class AllPairCandidateSource( + CandidateSourceKind kind, + double lexical, + double graph) : IClaimCandidateSource + { + public CandidateSourceKind Kind { get; } = kind; + + public ClaimCandidateSourceResult FindCandidates(ClaimCandidateSourceRequest request) + { + var pairs = new List(); + for (var left = 0; left < request.Claims.Count; left++) + { + for (var right = left + 1; right < request.Claims.Count; right++) + { + pairs.Add(new ClaimPairCandidate( + request.Claims[left], + request.Claims[right], + Kind, + new CandidateScore(lexical, null, graph))); + } + } + + return new ClaimCandidateSourceResult(pairs, pairs.Count); + } + } + + private sealed class StubPersistence(params AnalysisVerdict[] verdicts) : IAnalysisPersistence + { + private readonly IReadOnlyDictionary _verdicts = + verdicts.ToDictionary(verdict => verdict.CandidateId, StringComparer.Ordinal); + + public bool IsAvailable => true; + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + _verdicts + .Where(pair => candidateIds.Contains(pair.Key, StringComparer.Ordinal)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) => + new Dictionary(); + + public void SaveEmbeddings(IReadOnlyCollection embeddings) + { + } + } + + private sealed class SemanticMatchGenerator : IEmbeddingGenerator + { + public int CallCount { get; private set; } + + public string GetProviderFingerprint(DocsAnalysisEmbeddingConfig config) => "semantic-provider"; + + public EmbeddingGenerationResult Generate( + IReadOnlyCollection keys, + IReadOnlyCollection inputs, + DocsAnalysisEmbeddingConfig config) + { + CallCount++; + Assert.Equal(keys.Count, inputs.Count); + return new EmbeddingGenerationResult( + keys.Select(key => new StoredEmbedding(key, [1f, 0f])).ToArray(), + EmbeddingUsage.None); + } + } + + private sealed class EmbeddingPersistence : IAnalysisPersistence + { + private readonly Dictionary _embeddings = []; + + public bool IsAvailable => true; + public List SavedEmbeddings { get; } = []; + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) => + keys.Where(_embeddings.ContainsKey).ToDictionary(key => key, key => _embeddings[key]); + + public void SaveEmbeddings(IReadOnlyCollection embeddings) + { + foreach (var embedding in embeddings) + { + SavedEmbeddings.Add(embedding); + _embeddings[embedding.Key] = embedding; + } + } + } + + private sealed record Status(string Value, DocStatus Model) + { + public static Status Current { get; } = new("current", DocStatus.Current); + public static Status Draft { get; } = new("draft", DocStatus.Draft); + } +} diff --git a/tests/KyberWeave.Tests/DocumentationReviewExchangeTests.cs b/tests/KyberWeave.Tests/DocumentationReviewExchangeTests.cs new file mode 100644 index 0000000..9cbaae8 --- /dev/null +++ b/tests/KyberWeave.Tests/DocumentationReviewExchangeTests.cs @@ -0,0 +1,587 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Analysis.Persistence; +using KyberWeave.Core.Docs.Analysis.Review; +using KyberWeave.Core.Processes; +using Xunit; +using Xunit.Sdk; + +namespace KyberWeave.Tests; + +/// +/// Pins the content-addressed agent review exchange. Import validation must remain ahead +/// of persistence so one malformed verdict cannot partially poison the reusable cache. +/// +public sealed class DocumentationReviewExchangeTests +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + [Fact] + public void Export_PendingCandidates_UsesVersionedSchemaAndCompleteBoundedEvidence() + { + var exact = Candidate("exact", AnalysisRuleKind.Duplicate, isExact: true); + var pending = Candidate( + "pending", + AnalysisRuleKind.Conflict, + sources: [CandidateSourceKind.Graph, CandidateSourceKind.Lexical], + score: new CandidateScore(0.71, 0.84, 1)); + + var result = Exchange().Export([exact, pending]); + + Assert.Equal("kyber-weave.docs-review.candidates/v1", result.Bundle.Schema); + Assert.Equal(DocumentationAnalyzer.AnalyzerVersion, result.Bundle.AnalyzerVersion); + Assert.Equal(DocumentationAnalyzer.RubricVersion, result.Bundle.RubricVersion); + Assert.Matches("^[a-f0-9]{64}$", result.Bundle.CandidateSetHash); + Assert.Equal(5, result.Bundle.Rubric.Labels.Count); + var item = Assert.Single(result.Bundle.Candidates); + Assert.Equal(pending.Id, item.CandidateId); + Assert.Equal(pending.Kind, item.Kind); + Assert.Equal(pending.Score, item.Score); + Assert.Equal(pending.Sources, item.Sources); + Assert.Equal(pending.Claims.Select(claim => claim.ContentHash), item.ClaimContentHashes); + Assert.All(item.Evidence, evidence => + { + Assert.False(string.IsNullOrWhiteSpace(evidence.Id)); + Assert.False(string.IsNullOrWhiteSpace(evidence.Excerpt)); + Assert.False(string.IsNullOrWhiteSpace(evidence.ContentHash)); + Assert.False(string.IsNullOrWhiteSpace(evidence.ContextualHash)); + Assert.True(evidence.StartLine > 0); + Assert.True(evidence.EndLine >= evidence.StartLine); + }); + using var json = JsonDocument.Parse(result.Json); + Assert.Equal( + "kyber-weave.docs-review.candidates/v1", + json.RootElement.GetProperty("schema").GetString()); + } + + [Fact] + public void Export_LongEvidence_EnforcesPerExcerptAndAggregateCharacterBudgets() + { + var candidate = Candidate( + "bounded", + AnalysisRuleKind.Conflict, + claimText: new string('x', 2_000)); + var options = new ReviewExportOptions(MaxExcerptCharacters: 40, CharacterBudget: 65); + + var result = Exchange().Export([candidate], options); + + var excerpts = Assert.Single(result.Bundle.Candidates).Evidence.Select(item => item.Excerpt).ToArray(); + Assert.All(excerpts, excerpt => Assert.True(excerpt.Length <= options.MaxExcerptCharacters)); + Assert.True(excerpts.Sum(excerpt => excerpt.Length) <= options.CharacterBudget); + Assert.Equal(excerpts.Sum(excerpt => excerpt.Length), result.ExportedExcerptCharacters); + Assert.True(result.Truncated); + } + + [Fact] + public void Export_CandidateSetHashIsStableAcrossInputOrderAndChangesWithCurrentContent() + { + var first = Candidate("first", AnalysisRuleKind.Conflict); + var second = Candidate("second", AnalysisRuleKind.Terminology, term: "loop"); + var exchange = Exchange(); + + var ordered = exchange.Export([first, second]).Bundle.CandidateSetHash; + var reversed = exchange.Export([second, first]).Bundle.CandidateSetHash; + var changed = exchange.Export([ + first, + second with + { + Claims = second.Claims + .Select(claim => claim with { ContentHash = claim.ContentHash + "-changed" }) + .ToArray() + } + ]).Bundle.CandidateSetHash; + + Assert.Equal(ordered, reversed); + Assert.NotEqual(ordered, changed); + } + + [Fact] + public void Export_UsesCachedVerdictsToReExportLowConfidenceAndUncertainButSuppressConfirmedBenign() + { + var low = Candidate("low", AnalysisRuleKind.Duplicate); + var uncertain = Candidate("uncertain", AnalysisRuleKind.Conflict); + var benign = Candidate("benign", AnalysisRuleKind.Terminology, term: "loop"); + var persistence = new RecordingPersistence( + Verdict(low, AnalysisVerdictLabel.Duplicate, 0.79), + Verdict(uncertain, AnalysisVerdictLabel.Uncertain, 0.99), + Verdict(benign, AnalysisVerdictLabel.Benign, 0.95)); + + var result = Exchange(persistence, confidence: 0.80).Export([low, uncertain, benign]); + + Assert.Equal( + [low.Id, uncertain.Id], + result.Bundle.Candidates.Select(item => item.CandidateId).Order(StringComparer.Ordinal)); + } + + [Fact] + public void Export_ExcludesEveryHighConfidenceResolvedVerdictButKeepsUncertainPending() + { + var duplicate = Candidate("duplicate", AnalysisRuleKind.Duplicate); + var conflict = Candidate("conflict", AnalysisRuleKind.Conflict); + var senses = Candidate("senses", AnalysisRuleKind.Terminology, term: "loop"); + var benign = Candidate("benign", AnalysisRuleKind.Conflict); + var uncertain = Candidate("uncertain", AnalysisRuleKind.Conflict); + var persistence = new RecordingPersistence( + Verdict(duplicate, AnalysisVerdictLabel.Duplicate, 0.90), + Verdict(conflict, AnalysisVerdictLabel.Conflict, 0.90), + Verdict(senses, AnalysisVerdictLabel.DistinctSenses, 0.90), + Verdict(benign, AnalysisVerdictLabel.Benign, 0.90), + Verdict(uncertain, AnalysisVerdictLabel.Uncertain, 0.99)); + + var result = Exchange(persistence).Export([duplicate, conflict, senses, benign, uncertain]); + + Assert.Equal([uncertain.Id], result.Bundle.Candidates.Select(item => item.CandidateId)); + Assert.All( + new[] { duplicate, conflict, senses, benign }, + candidate => Assert.DoesNotContain(result.Bundle.Candidates, item => item.CandidateId == candidate.Id)); + } + + [Fact] + public void Export_EvidenceIdsRemainStableAcrossSourceMovesAndDisambiguateRepeatedContentHashes() + { + var original = Candidate("move-stable", AnalysisRuleKind.Conflict); + original = original with + { + Claims = original.Claims + .Select(claim => claim with { ContentHash = "repeated-content" }) + .ToArray() + }; + var moved = original with + { + Claims = original.Claims + .Reverse() + .Select((claim, index) => claim with + { + ContextualHash = "moved-context-" + index, + DocumentIdentity = "moved/document-" + index, + FilePath = $"/repo/moved/{index}.md", + StartLine = 100 + index, + EndLine = 100 + index + }) + .ToArray() + }; + var exchange = Exchange(); + + var first = Assert.Single(exchange.Export([original]).Bundle.Candidates); + var second = Assert.Single(exchange.Export([moved]).Bundle.Candidates); + + Assert.Equal(first.Evidence.Select(item => item.Id), second.Evidence.Select(item => item.Id)); + Assert.Equal(2, first.Evidence.Select(item => item.Id).Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Export_WhenBudgetCannotCoverNextCandidateStopsWithoutEmptyEvidenceAndHashesEmittedSet() + { + var first = Candidate("a-first", AnalysisRuleKind.Conflict, claimText: "12345"); + var second = Candidate("b-second", AnalysisRuleKind.Conflict, claimText: "12345"); + var third = Candidate("c-third", AnalysisRuleKind.Conflict, claimText: "12345"); + var exchange = Exchange(); + var options = new ReviewExportOptions(MaxExcerptCharacters: 20, CharacterBudget: 30); + + var result = exchange.Export([first, second, third], options); + var firstOnly = exchange.Export([first]).Bundle; + + var emitted = Assert.Single(result.Bundle.Candidates); + Assert.Equal(first.Id, emitted.CandidateId); + Assert.All(emitted.Evidence, evidence => Assert.NotEmpty(evidence.Excerpt)); + Assert.Equal(firstOnly.CandidateSetHash, result.Bundle.CandidateSetHash); + Assert.True(result.Truncated); + } + + [Fact] + public void Import_ValidBundlePersistsAllVerdictsInOneAtomicCall() + { + var candidates = new[] + { + Candidate("duplicate", AnalysisRuleKind.Duplicate), + Candidate("terminology", AnalysisRuleKind.Terminology, term: "loop") + }; + var persistence = new RecordingPersistence(); + var exchange = Exchange(persistence); + var export = exchange.Export(candidates).Bundle; + var verdicts = VerdictBundle( + export, + VerdictItem(export.Candidates[0], AnalysisVerdictLabel.Duplicate), + VerdictItem( + export.Candidates[1], + AnalysisVerdictLabel.DistinctSenses, + senses: + [ + new ProposedGlossarySense( + "loop", + "The autonomous agent execution cycle.", + ["component:Automation"], + ["Codex loop"]) + ])); + + var result = exchange.Import(Serialize(verdicts), candidates); + + Assert.True(result.Success, Join(result.Diagnostics)); + Assert.Equal(2, result.ImportedCount); + Assert.Equal(1, persistence.SaveVerdictCallCount); + Assert.Equal(2, persistence.Verdicts.Count); + } + + [Theory] + [InlineData("schema")] + [InlineData("analyzer-version")] + [InlineData("rubric-version")] + [InlineData("candidate-set-hash")] + [InlineData("candidate-id")] + [InlineData("claim-hash")] + [InlineData("label")] + [InlineData("confidence")] + [InlineData("evidence-reference")] + [InlineData("glossary-sense")] + [InlineData("glossary-blank-scope")] + public void Import_WhenAnyBundleContractIsInvalidRejectsEverythingWithReview001(string scenario) + { + var candidate = Candidate( + "review", + scenario is "glossary-sense" or "glossary-blank-scope" + ? AnalysisRuleKind.Terminology + : AnalysisRuleKind.Duplicate, + term: scenario is "glossary-sense" or "glossary-blank-scope" ? "loop" : null); + var persistence = new RecordingPersistence(); + var exchange = Exchange(persistence); + var export = exchange.Export([candidate]).Bundle; + var item = VerdictItem( + export.Candidates[0], + scenario is "glossary-sense" or "glossary-blank-scope" + ? AnalysisVerdictLabel.DistinctSenses + : AnalysisVerdictLabel.Duplicate, + senses: scenario switch + { + "glossary-sense" => + [new ProposedGlossarySense("loop", "", ["invalid-scope"], [])], + "glossary-blank-scope" => + [new ProposedGlossarySense("loop", "Agent cycle.", ["component: "], [])], + _ => null + }); + var bundle = VerdictBundle(export, item); + bundle = scenario switch + { + "schema" => bundle with { Schema = "kyber-weave.docs-review.verdicts/v2" }, + "analyzer-version" => bundle with { AnalyzerVersion = "analyzer/v0" }, + "rubric-version" => bundle with { RubricVersion = "rubric/v0" }, + "candidate-set-hash" => bundle with { CandidateSetHash = "stale-set" }, + "candidate-id" => bundle with + { + Verdicts = [item with { CandidateId = "unknown-candidate" }] + }, + "claim-hash" => bundle with + { + Verdicts = [item with { ClaimContentHashes = ["stale-content"] }] + }, + "label" => bundle with + { + Verdicts = [item with { Label = AnalysisVerdictLabel.Conflict }] + }, + "confidence" => bundle with + { + Verdicts = [item with { Confidence = 1.01 }] + }, + "evidence-reference" => bundle with + { + Verdicts = [item with { EvidenceIds = ["unknown-evidence"] }] + }, + "glossary-sense" => bundle, + "glossary-blank-scope" => bundle, + _ => throw new InvalidOperationException(scenario) + }; + + var result = exchange.Import(Serialize(bundle), [candidate]); + + Assert.False(result.Success); + Assert.Equal(0, result.ImportedCount); + Assert.Equal(0, persistence.SaveVerdictCallCount); + var finding = Assert.Single(result.Diagnostics.Items); + Assert.Equal("KW-DOC-REVIEW-001", finding.Code); + Assert.Equal(Severity.Error, finding.Severity); + } + + [Fact] + public void Import_MalformedJsonRejectsWithoutWriting() + { + var candidate = Candidate("malformed", AnalysisRuleKind.Conflict); + var persistence = new RecordingPersistence(); + + var result = Exchange(persistence).Import("{\"schema\":", [candidate]); + + Assert.False(result.Success); + Assert.Empty(persistence.Verdicts); + Assert.Equal(0, persistence.SaveVerdictCallCount); + Assert.Contains(result.Diagnostics.Items, item => item.Code == "KW-DOC-REVIEW-001"); + } + + [Fact] + public void Import_OneInvalidVerdictRejectsOtherwiseValidVerdictsAtomically() + { + var first = Candidate("first", AnalysisRuleKind.Duplicate); + var second = Candidate("second", AnalysisRuleKind.Conflict); + var candidates = new[] { first, second }; + var persistence = new RecordingPersistence(); + var exchange = Exchange(persistence); + var export = exchange.Export(candidates).Bundle; + var valid = VerdictItem(export.Candidates[0], AnalysisVerdictLabel.Duplicate); + var invalid = VerdictItem(export.Candidates[1], AnalysisVerdictLabel.Conflict) with + { + EvidenceIds = ["not-in-export"] + }; + + var result = exchange.Import(Serialize(VerdictBundle(export, valid, invalid)), candidates); + + Assert.False(result.Success); + Assert.Equal(0, persistence.SaveVerdictCallCount); + Assert.Empty(persistence.Verdicts); + } + + [Fact] + public void Import_HighConfidenceBenignThenExportUnchangedCandidateSuppressesIt() + { + var candidate = Candidate("benign-round-trip", AnalysisRuleKind.Conflict); + var persistence = new RecordingPersistence(); + var exchange = Exchange(persistence, confidence: 0.80); + var exported = exchange.Export([candidate]).Bundle; + var bundle = VerdictBundle( + exported, + VerdictItem(exported.Candidates[0], AnalysisVerdictLabel.Benign, confidence: 0.90)); + + var imported = exchange.Import(Serialize(bundle), [candidate]); + var reExported = exchange.Export([candidate]); + + Assert.True(imported.Success, Join(imported.Diagnostics)); + Assert.Empty(reExported.Bundle.Candidates); + } + + [Fact] + public void Import_RealSqliteAfterCurrentExport_PersistsClaimsFingerprintsAndVerdictAtomically() + { + RequireSqlite(); + using var repository = SafeRepository(); + IAnalysisPersistence persistence = new SqliteAnalysisPersistence(repository.Path); + var candidate = Candidate("sqlite-round-trip", AnalysisRuleKind.Conflict); + var exchange = new DocumentationReviewExchange(persistence); + var exported = exchange.Export([candidate]).Bundle; + var bundle = VerdictBundle( + exported, + VerdictItem(exported.Candidates[0], AnalysisVerdictLabel.Benign)); + + var imported = exchange.Import(Serialize(bundle), [candidate]); + + Assert.True(imported.Success, Join(imported.Diagnostics)); + Assert.Equal(2, persistence.LoadClaims(CurrentClaimIds(persistence)).Count); + Assert.Equal( + candidate.Id, + Assert.Single(persistence.LoadCandidateFingerprints([candidate.Id])).Value.CandidateId); + Assert.Equal( + AnalysisVerdictLabel.Benign, + Assert.Single(persistence.LoadVerdicts([candidate.Id])).Value.Label); + } + + [Fact] + public void Import_RealSqliteWhenVerdictWriteFails_RollsBackClaimsFingerprintsAndVerdicts() + { + RequireSqlite(); + using var repository = SafeRepository(); + var persistence = new SqliteAnalysisPersistence(repository.Path); + var candidate = Candidate("sqlite-rollback", AnalysisRuleKind.Conflict); + var exchange = new DocumentationReviewExchange(persistence); + var exported = exchange.Export([candidate]).Bundle; + RunSqlite( + persistence.DatabasePath, + "CREATE TRIGGER reject_review BEFORE INSERT ON analysis_verdicts " + + "BEGIN SELECT RAISE(ABORT, 'forced review failure'); END;"); + + var imported = exchange.Import( + Serialize(VerdictBundle( + exported, + VerdictItem(exported.Candidates[0], AnalysisVerdictLabel.Benign))), + [candidate]); + + Assert.False(imported.Success); + Assert.Equal("0", QuerySqlite(persistence.DatabasePath, "SELECT COUNT(*) FROM analysis_claims;").Trim()); + Assert.Empty(persistence.LoadCandidateFingerprints([candidate.Id])); + Assert.Empty(persistence.LoadVerdicts([candidate.Id])); + } + + private static DocumentationReviewExchange Exchange( + RecordingPersistence? persistence = null, + double confidence = 0.80) => + new(persistence ?? new RecordingPersistence(), confidence); + + private static AnalysisCandidate Candidate( + string id, + AnalysisRuleKind kind, + bool isExact = false, + string? term = null, + string claimText = "The runner emits the configured documentation review evidence.", + IReadOnlyList? sources = null, + CandidateScore? score = null) + { + var claims = new[] + { + Claim(id + "-left", "hash-" + id + "-left", claimText, 10), + Claim(id + "-right", "hash-" + id + "-right", claimText + " Related context.", 20) + }; + return new AnalysisCandidate( + id, + kind, + claims, + score ?? new CandidateScore(0.72, null, 1), + isExact, + term, + sources ?? [CandidateSourceKind.Graph]); + } + + private static Claim Claim(string id, string contentHash, string text, int line) => + new( + ClaimKind.Paragraph, + text, + "Behavior\n" + text, + contentHash, + "context-" + id, + "docs/" + id, + "Runtime", + "Behavior", + "/repo/docs/" + id + ".md", + line, + line + 1, + IgnoreRule.None); + + private static AnalysisVerdict Verdict( + AnalysisCandidate candidate, + AnalysisVerdictLabel label, + double confidence) => + new(candidate.Id, label, confidence, "Reviewer disposition."); + + private static ReviewVerdictBundle VerdictBundle( + ReviewCandidateBundle export, + params ReviewVerdictItem[] verdicts) => + new( + "kyber-weave.docs-review.verdicts/v1", + export.AnalyzerVersion, + export.RubricVersion, + export.CandidateSetHash, + verdicts); + + private static ReviewVerdictItem VerdictItem( + ReviewCandidateItem candidate, + AnalysisVerdictLabel label, + double confidence = 0.90, + IReadOnlyList? senses = null) => + new( + candidate.CandidateId, + label, + confidence, + "Reviewer evaluated every supplied evidence location.", + candidate.ClaimContentHashes, + candidate.Evidence.Select(evidence => evidence.Id).ToArray(), + null, + senses); + + private static string Serialize(ReviewVerdictBundle bundle) => + JsonSerializer.Serialize(bundle, JsonOptions); + + private static string Join(DiagnosticReport diagnostics) => + string.Join(Environment.NewLine, diagnostics.Items); + + private static IReadOnlyCollection CurrentClaimIds(IAnalysisPersistence persistence) + { + if (persistence is not SqliteAnalysisPersistence sqlite) + throw new InvalidOperationException("A SQLite persistence adapter is required."); + return QuerySqlite(sqlite.DatabasePath, "SELECT CAST(id AS TEXT) FROM analysis_claims;") + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static TempDirectory SafeRepository() + { + var repository = new TempDirectory(); + var stateDirectory = Path.Combine(repository.Path, ".kyber-weave"); + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, ".gitignore"), "cache/\n"); + return repository; + } + + private static void RequireSqlite() + { + var startInfo = SqliteStartInfo(); + startInfo.ArgumentList.Add("--version"); + try + { + if (ProcessRunner.Run(startInfo, string.Empty).ExitCode != 0) + throw SkipException.ForSkip("sqlite3 is unavailable; SQLite review import parity was not run."); + } + catch (Win32Exception) + { + throw SkipException.ForSkip("sqlite3 is unavailable; SQLite review import parity was not run."); + } + } + + private static string QuerySqlite(string databasePath, string sql) + { + var result = RunSqlite(databasePath, sql); + Assert.Equal(0, result.ExitCode); + return result.StandardOutput; + } + + private static ProcessResult RunSqlite(string databasePath, string sql) + { + var startInfo = SqliteStartInfo(); + startInfo.ArgumentList.Add("-batch"); + startInfo.ArgumentList.Add("-bail"); + startInfo.ArgumentList.Add(databasePath); + return ProcessRunner.Run(startInfo, sql); + } + + private static ProcessStartInfo SqliteStartInfo() => new("sqlite3") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + private sealed class RecordingPersistence(params AnalysisVerdict[] verdicts) : IAnalysisPersistence + { + public Dictionary Verdicts { get; } = + verdicts.ToDictionary(verdict => verdict.CandidateId, StringComparer.Ordinal); + + public int SaveVerdictCallCount { get; private set; } + public bool IsAvailable => true; + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + Verdicts + .Where(item => candidateIds.Contains(item.Key, StringComparer.Ordinal)) + .ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal); + + public void SaveVerdicts(IReadOnlyCollection imported) + { + SaveVerdictCallCount++; + foreach (var verdict in imported) Verdicts[verdict.CandidateId] = verdict; + } + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) => + new Dictionary(); + + public void SaveEmbeddings(IReadOnlyCollection embeddings) + { + } + } +} diff --git a/tests/KyberWeave.Tests/EmbeddingClientTests.cs b/tests/KyberWeave.Tests/EmbeddingClientTests.cs new file mode 100644 index 0000000..a136052 --- /dev/null +++ b/tests/KyberWeave.Tests/EmbeddingClientTests.cs @@ -0,0 +1,659 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Text.Json; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Embeddings; +using KyberWeave.Core.Docs.Analysis.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// T08 — embedding requests are an optional, local-only optimization. The client must +/// reject any route that could leave loopback, validate provider output before caching +/// it, and make cache safety a precondition of every provider call. +/// +public sealed class EmbeddingClientTests +{ + [Fact] + public void Generate_WithOrderedBatchesAndOptionalSettings_MapsByIndexNormalizesAndAggregatesUsage() + { + using var handler = new RecordingHandler( + JsonResponse(""" + { + "data": [ + { "index": 1, "embedding": [0, 5] }, + { "index": 0, "embedding": [3, 4] } + ], + "usage": { "prompt_tokens": 7, "total_tokens": 7 } + } + """), + JsonResponse(""" + { + "data": [ + { "index": 0, "embedding": [8, 6] } + ], + "usage": { "prompt_tokens": 5, "total_tokens": 5 } + } + """)); + var environmentReads = new List(); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + name => + { + environmentReads.Add(name); + return "secret-local-token"; + }); + var config = Config( + endpoint: "http://localhost:1234/v1/embeddings", + batchSize: 2, + dimensions: 2, + apiKeyEnv: "LOCAL_EMBEDDING_TOKEN"); + var keys = new[] { Key("alpha"), Key("beta"), Key("gamma") }; + + var result = generator.Generate(keys, ["first input", "second input", "third input"], config); + + Assert.Equal(2, handler.Requests.Count); + Assert.Equal(["first input", "second input"], Inputs(handler.Requests[0].Body)); + Assert.Equal(["third input"], Inputs(handler.Requests[1].Body)); + Assert.All(handler.Requests, request => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(config.Endpoint, request.Uri); + Assert.Equal("application/json", request.ContentType); + Assert.Equal("Bearer", request.Authorization?.Scheme); + Assert.Equal("secret-local-token", request.Authorization?.Parameter); + Assert.True(request.CanBeCanceled); + using var json = JsonDocument.Parse(request.Body); + Assert.Equal("text-embedding-local", json.RootElement.GetProperty("model").GetString()); + Assert.Equal("float", json.RootElement.GetProperty("encoding_format").GetString()); + Assert.Equal(2, json.RootElement.GetProperty("dimensions").GetInt32()); + }); + Assert.Equal(["LOCAL_EMBEDDING_TOKEN"], environmentReads); + Assert.Equal(keys, result.Embeddings.Select(embedding => embedding.Key)); + AssertVector([0.6f, 0.8f], result.Embeddings[0].Vector); + AssertVector([0f, 1f], result.Embeddings[1].Vector); + AssertVector([0.8f, 0.6f], result.Embeddings[2].Vector); + Assert.Equal(12, result.Usage.PromptTokens); + Assert.Equal(12, result.Usage.TotalTokens); + } + + [Theory] + [InlineData("http://localhost:1234/v1/embeddings", "::1")] + [InlineData("http://127.9.8.7:1234/v1/embeddings", "127.9.8.7")] + [InlineData("http://[::1]:1234/v1/embeddings", "::1")] + [InlineData("http://[::ffff:127.9.8.7]:1234/v1/embeddings", "::ffff:127.9.8.7")] + public void Generate_WhenEveryResolvedAddressIsLoopback_AcceptsSupportedLoopbackForms( + string endpoint, + string resolvedAddress) + { + using var handler = new RecordingHandler(JsonResponse(""" + { "data": [{ "index": 0, "embedding": [1, 0] }] } + """)); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Parse(resolvedAddress)), + _ => null); + + var result = generator.Generate( + [Key("loopback")], + ["local-only input"], + Config(endpoint: endpoint)); + + Assert.Single(result.Embeddings); + Assert.Single(handler.Requests); + } + + [Fact] + public void Constructor_DisablesHttpClientTimeoutSoConfigTimeoutIsAuthoritative() + { + using var handler = new RecordingHandler(JsonResponse(""" + { "data": [{ "index": 0, "embedding": [1, 0] }] } + """)); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => null); + + var client = typeof(OpenAiCompatibleEmbeddingGenerator) + .GetField("_client", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(generator) as HttpClient; + + Assert.NotNull(client); + Assert.Equal(Timeout.InfiniteTimeSpan, client.Timeout); + } + + [Fact] + public void LoadConfig_WhenEndpointIsIpv4Mapped127Slash8_AcceptsItAsLoopback() + { + var config = KyberWeaveConfigLoader.LoadFromYaml(""" + docs-analysis: + embeddings: + mode: prefer + endpoint: http://[::ffff:127.9.8.7]:1234/v1/embeddings + model: text-embedding-local + """); + + Assert.Equal( + new Uri("http://[::ffff:127.9.8.7]:1234/v1/embeddings"), + config.DocsAnalysis.Embeddings.Endpoint); + } + + [Fact] + public void Generate_WithoutOptionalDimensionsOrApiKey_OmitsBothFromTheRequest() + { + using var handler = new RecordingHandler(JsonResponse(""" + { + "data": [{ "index": 0, "embedding": [1, 0] }] + } + """)); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => throw new Xunit.Sdk.XunitException("No environment variable should be read.")); + var config = Config(dimensions: null, apiKeyEnv: null); + + var result = generator.Generate([Key("only", dimensions: null)], ["only input"], config); + + var request = Assert.Single(handler.Requests); + Assert.Null(request.Authorization); + using var json = JsonDocument.Parse(request.Body); + Assert.False(json.RootElement.TryGetProperty("dimensions", out _)); + Assert.Equal(0, result.Usage.PromptTokens); + Assert.Equal(0, result.Usage.TotalTokens); + } + + [Fact] + public void Generate_WhenAnyResolvedAddressIsNotLoopback_RejectsBeforeSending() + { + using var handler = new RecordingHandler(JsonResponse("{}")); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback, IPAddress.Parse("203.0.113.9")), + _ => null); + var config = Config(endpoint: "http://embedding.test:1234/v1/embeddings"); + + var exception = Assert.Throws(() => + generator.Generate([Key("unsafe")], ["must stay local"], config)); + + Assert.Contains("loopback", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(handler.Requests); + } + + [Theory] + [InlineData("/v1/embeddings")] + [InlineData("ftp://127.0.0.1/v1/embeddings")] + [InlineData("http://192.0.2.8/v1/embeddings")] + public void Generate_WhenEndpointIsNotAbsoluteLoopbackHttp_RejectsBeforeSending(string endpoint) + { + using var handler = new RecordingHandler(JsonResponse("{}")); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Parse("192.0.2.8")), + _ => null); + var config = Config(new Uri(endpoint, UriKind.RelativeOrAbsolute)); + + var exception = Assert.ThrowsAny(() => + generator.Generate([Key("unsafe")], ["must stay local"], config)); + + Assert.True( + exception is ArgumentException or InvalidOperationException, + $"Expected a configuration or loopback-policy failure, got {exception.GetType().Name}: {exception.Message}"); + Assert.Empty(handler.Requests); + } + + [Fact] + public void Generate_WhenEndpointRedirects_RejectsTheRedirectWithoutFollowingIt() + { + using var redirect = new HttpResponseMessage(HttpStatusCode.Redirect) + { + Headers = { Location = new Uri("https://example.com/v1/embeddings") } + }; + using var handler = new RecordingHandler(redirect); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => null); + + var exception = Assert.Throws(() => + generator.Generate([Key("redirect")], ["never forward this text"], Config())); + + Assert.Contains("redirect", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Single(handler.Requests); + } + + [Theory] + [MemberData(nameof(InvalidResponses))] + public void Generate_WhenResponseIndicesOrVectorsAreInvalid_RejectsTheWholeBatch( + string response, + string expectedMessage) + { + using var handler = new RecordingHandler(JsonResponse(response)); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => null); + + var exception = Assert.Throws(() => + generator.Generate( + [Key("left"), Key("right")], + ["left input", "right input"], + Config(dimensions: null))); + + Assert.Contains(expectedMessage, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + public static TheoryData InvalidResponses() => new() + { + { + """ + { "data": [ + { "index": 0, "embedding": [1, 0] }, + { "index": 0, "embedding": [0, 1] } + ] } + """, + "index" + }, + { + """ + { "data": [ + { "index": 0, "embedding": [1, 0] } + ] } + """, + "index" + }, + { + """ + { "data": [ + { "index": 0, "embedding": [1, 0] }, + { "index": 2, "embedding": [0, 1] } + ] } + """, + "index" + }, + { + """ + { "data": [ + { "index": 0, "embedding": [1, 0] }, + { "index": 1, "embedding": [0, 1, 0] } + ] } + """, + "dimension" + }, + { + """ + { "data": [ + { "index": 0, "embedding": [0, 0] }, + { "index": 1, "embedding": [0, 1] } + ] } + """, + "finite" + }, + { + """ + { "data": [ + { "index": 0, "embedding": [1e400, 0] }, + { "index": 1, "embedding": [0, 1] } + ] } + """, + "finite" + } + }; + + [Fact] + public void Generate_WhenProviderFails_DoesNotExposeTheBearerToken() + { + using var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("provider rejected credentials", Encoding.UTF8, "text/plain") + }); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => "do-not-disclose-this-token"); + + var exception = Assert.Throws(() => generator.Generate( + [Key("failure")], + ["input"], + Config(apiKeyEnv: "LOCAL_EMBEDDING_TOKEN"))); + + Assert.DoesNotContain("do-not-disclose-this-token", exception.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain( + "do-not-disclose-this-token", + generator.GetProviderFingerprint(Config(apiKeyEnv: "LOCAL_EMBEDDING_TOKEN")), + StringComparison.Ordinal); + } + + [Fact] + public void Resolve_WhenModeIsOff_NeverReadsCacheOrInvokesProvider() + { + var generator = new RecordingGenerator(); + var persistence = new RecordingPersistence(isAvailable: true); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [new EmbeddingWorkItem("context", "input")], + Config(mode: DocsAnalysisEmbeddingMode.Off)); + + Assert.Empty(result.Embeddings); + Assert.Empty(result.Diagnostics.Items); + Assert.Equal(0, result.CacheHits); + Assert.Equal(0, result.CacheMisses); + Assert.Equal(0, persistence.LoadCount); + Assert.Equal(0, generator.CallCount); + } + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, Severity.Warning)] + [InlineData(DocsAnalysisEmbeddingMode.Required, Severity.Error)] + public void Resolve_WhenPersistenceIsUnsafe_ReportsModeSeverityAndNeverInvokesProvider( + DocsAnalysisEmbeddingMode mode, + Severity severity) + { + var generator = new RecordingGenerator(); + var persistence = new RecordingPersistence(isAvailable: false); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [new EmbeddingWorkItem("context", "input")], + Config(mode: mode)); + + var diagnostic = Assert.Single(result.Diagnostics.Items); + Assert.Equal(DocumentationAnalyzer.EmbeddingUnavailableRuleCode, diagnostic.Code); + Assert.Equal(severity, diagnostic.Severity); + Assert.Empty(result.Embeddings); + Assert.Equal(0, generator.CallCount); + Assert.Empty(persistence.Saved); + } + + [Fact] + public void Resolve_WithPartialCacheHit_RequestsOnlyMissesAndPreservesInputOrder() + { + var generator = new RecordingGenerator(providerFingerprint: "provider-a"); + var config = Config(model: "model-a", dimensions: 2); + var hitKey = Key( + "context-hit", + provider: "provider-a", + model: "model-a", + dimensions: 2); + var cached = new StoredEmbedding(hitKey, [1f, 0f]); + var persistence = new RecordingPersistence(isAvailable: true, cached); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [ + new EmbeddingWorkItem("context-miss-a", "first miss"), + new EmbeddingWorkItem("context-hit", "cached input"), + new EmbeddingWorkItem("context-miss-b", "second miss") + ], + config); + + Assert.Equal(1, result.CacheHits); + Assert.Equal(2, result.CacheMisses); + Assert.Equal(1, generator.CallCount); + Assert.Equal(["first miss", "second miss"], generator.Inputs); + Assert.Equal( + ["context-miss-a", "context-miss-b"], + generator.Keys.Select(key => key.ContextualHash)); + Assert.All(generator.Keys, key => + { + Assert.Equal("provider-a", key.ProviderFingerprint); + Assert.Equal("model-a", key.Model); + Assert.Equal(2, key.Dimensions); + Assert.Equal("float", key.Encoding); + }); + Assert.Equal( + ["context-miss-a", "context-hit", "context-miss-b"], + result.Embeddings.Select(embedding => embedding.Key.ContextualHash)); + Assert.Equal(2, persistence.Saved.Count); + Assert.Equal(11, result.Usage.PromptTokens); + Assert.Equal(13, result.Usage.TotalTokens); + } + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, Severity.Warning)] + [InlineData(DocsAnalysisEmbeddingMode.Required, Severity.Error)] + public void Resolve_WhenProviderFails_ReportsModeSeverityAndDoesNotPersistPartialResults( + DocsAnalysisEmbeddingMode mode, + Severity severity) + { + var generator = new RecordingGenerator(failure: new InvalidOperationException("endpoint unavailable")); + var persistence = new RecordingPersistence(isAvailable: true); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [new EmbeddingWorkItem("context", "input")], + Config(mode: mode)); + + var diagnostic = Assert.Single(result.Diagnostics.Items); + Assert.Equal(DocumentationAnalyzer.EmbeddingUnavailableRuleCode, diagnostic.Code); + Assert.Equal(severity, diagnostic.Severity); + Assert.Contains("endpoint unavailable", diagnostic.Message, StringComparison.Ordinal); + Assert.Empty(result.Embeddings); + Assert.Empty(persistence.Saved); + } + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, "json")] + [InlineData(DocsAnalysisEmbeddingMode.Required, "json")] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, "overflow")] + [InlineData(DocsAnalysisEmbeddingMode.Required, "overflow")] + public void Resolve_WhenProviderPayloadIsMalformed_UsesConfiguredFailurePolicy( + DocsAnalysisEmbeddingMode mode, + string failureKind) + { + Exception failure = failureKind == "json" + ? new JsonException("malformed provider JSON") + : new OverflowException("provider usage overflow"); + var generator = new RecordingGenerator(failure: failure); + var persistence = new RecordingPersistence(isAvailable: true); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [new EmbeddingWorkItem("context", "input")], + Config(mode: mode)); + + var diagnostic = Assert.Single(result.Diagnostics.Items); + Assert.Equal(DocumentationAnalyzer.EmbeddingUnavailableRuleCode, diagnostic.Code); + Assert.Equal( + mode == DocsAnalysisEmbeddingMode.Required ? Severity.Error : Severity.Warning, + diagnostic.Severity); + Assert.Contains(failure.Message, diagnostic.Message, StringComparison.Ordinal); + Assert.Empty(result.Embeddings); + Assert.Empty(persistence.Saved); + } + + [Theory] + [InlineData(DocsAnalysisEmbeddingMode.Prefer, Severity.Warning)] + [InlineData(DocsAnalysisEmbeddingMode.Required, Severity.Error)] + public void Resolve_WhenBearerTokenFormatIsInvalid_ReportsPolicyWithoutDisclosingSecret( + DocsAnalysisEmbeddingMode mode, + Severity severity) + { + const string secret = "secret-token\nwith-invalid-header-content"; + using var handler = new RecordingHandler(JsonResponse("{}")); + using var generator = new OpenAiCompatibleEmbeddingGenerator( + handler, + ResolveTo(IPAddress.Loopback), + _ => secret); + var persistence = new RecordingPersistence(isAvailable: true); + var coordinator = new EmbeddingCoordinator(generator, persistence); + + var result = coordinator.Resolve( + [new EmbeddingWorkItem("context", "input")], + Config(mode: mode, apiKeyEnv: "LOCAL_EMBEDDING_TOKEN")); + + var diagnostic = Assert.Single(result.Diagnostics.Items); + Assert.Equal(DocumentationAnalyzer.EmbeddingUnavailableRuleCode, diagnostic.Code); + Assert.Equal(severity, diagnostic.Severity); + Assert.DoesNotContain(secret, diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain("secret-token", diagnostic.Message, StringComparison.Ordinal); + Assert.Empty(result.Embeddings); + Assert.Empty(persistence.Saved); + Assert.Empty(handler.Requests); + } + + private static DocsAnalysisEmbeddingConfig Config( + string endpoint = "http://127.0.0.1:1234/v1/embeddings", + DocsAnalysisEmbeddingMode mode = DocsAnalysisEmbeddingMode.Required, + string model = "text-embedding-local", + int? dimensions = 2, + int batchSize = 64, + string? apiKeyEnv = null) => + Config(new Uri(endpoint, UriKind.Absolute), mode, model, dimensions, batchSize, apiKeyEnv); + + private static DocsAnalysisEmbeddingConfig Config( + Uri endpoint, + DocsAnalysisEmbeddingMode mode = DocsAnalysisEmbeddingMode.Required, + string model = "text-embedding-local", + int? dimensions = 2, + int batchSize = 64, + string? apiKeyEnv = null) => + new() + { + Mode = mode, + Endpoint = endpoint, + Model = model, + Dimensions = dimensions, + BatchSize = batchSize, + TimeoutSeconds = 5, + ApiKeyEnv = apiKeyEnv + }; + + private static EmbeddingCacheKey Key( + string contextualHash, + string provider = "provider", + string model = "text-embedding-local", + int? dimensions = 2, + string encoding = "float") => + new(contextualHash, provider, model, dimensions, encoding); + + private static Func> ResolveTo(params IPAddress[] addresses) => + _ => addresses; + + private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static string[] Inputs(string body) + { + using var json = JsonDocument.Parse(body); + return json.RootElement.GetProperty("input") + .EnumerateArray() + .Select(value => value.GetString()!) + .ToArray(); + } + + private static void AssertVector(IReadOnlyList expected, IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (var index = 0; index < expected.Count; index++) + Assert.Equal(expected[index], actual[index], precision: 5); + } + + private sealed record CapturedRequest( + HttpMethod Method, + Uri? Uri, + string Body, + string? ContentType, + AuthenticationHeaderValue? Authorization, + bool CanBeCanceled); + + private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + + public List Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + Requests.Add(new CapturedRequest( + request.Method, + request.RequestUri, + body, + request.Content?.Headers.ContentType?.MediaType, + request.Headers.Authorization, + cancellationToken.CanBeCanceled)); + if (_responses.Count == 0) + throw new Xunit.Sdk.XunitException("The embedding client sent an unexpected request."); + + return _responses.Dequeue(); + } + } + + private sealed class RecordingGenerator( + string providerFingerprint = "provider-a", + Exception? failure = null) : IEmbeddingGenerator + { + public int CallCount { get; private set; } + public IReadOnlyList Keys { get; private set; } = []; + public IReadOnlyList Inputs { get; private set; } = []; + + public string GetProviderFingerprint(DocsAnalysisEmbeddingConfig config) => providerFingerprint; + + public EmbeddingGenerationResult Generate( + IReadOnlyCollection keys, + IReadOnlyCollection inputs, + DocsAnalysisEmbeddingConfig config) + { + CallCount++; + Keys = keys.ToArray(); + Inputs = inputs.ToArray(); + if (failure is not null) throw failure; + + var embeddings = Keys + .Select((key, index) => new StoredEmbedding( + key, + index % 2 == 0 ? [1f, 0f] : [0f, 1f])) + .ToArray(); + return new EmbeddingGenerationResult( + embeddings, + new EmbeddingUsage(PromptTokens: 11, TotalTokens: 13)); + } + } + + private sealed class RecordingPersistence( + bool isAvailable, + params StoredEmbedding[] embeddings) : IAnalysisPersistence + { + private readonly Dictionary _embeddings = + embeddings.ToDictionary(embedding => embedding.Key); + + public bool IsAvailable { get; } = isAvailable; + public int LoadCount { get; private set; } + public List Saved { get; } = []; + + public IReadOnlyDictionary LoadVerdicts( + IReadOnlyCollection candidateIds) => + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary LoadEmbeddings( + IReadOnlyCollection keys) + { + LoadCount++; + return keys + .Where(_embeddings.ContainsKey) + .ToDictionary(key => key, key => _embeddings[key]); + } + + public void SaveEmbeddings(IReadOnlyCollection embeddingsToSave) + { + foreach (var embedding in embeddingsToSave) + { + Saved.Add(embedding); + _embeddings[embedding.Key] = embedding; + } + } + } +} diff --git a/tests/KyberWeave.Tests/GlossaryGraphExportTests.cs b/tests/KyberWeave.Tests/GlossaryGraphExportTests.cs new file mode 100644 index 0000000..dd193a2 --- /dev/null +++ b/tests/KyberWeave.Tests/GlossaryGraphExportTests.cs @@ -0,0 +1,282 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using KyberWeave.Core.CodeGraph; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Export; +using KyberWeave.Core.Docs.Model; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// T11 RED — docs graph exports only approved managed-glossary knowledge while +/// preserving the existing deterministic JSONL contract. +/// +public sealed class GlossaryGraphExportTests +{ + [Fact] + public void Export_ApprovedManagedSenses_EmitsTermSenseAliasScopeAndEvidenceGraph() + { + using var repository = GlossaryRepository(); + using var firstOutput = new TempDirectory(); + using var secondOutput = new TempDirectory(); + var glossary = GlossaryService(repository.Path).Load(); + var resolver = Resolver(); + var contributor = new ManagedGlossaryGraphContributor(glossary); + var exporter = new DocGraphExporter(resolver); + + var first = exporter.Export(Documents(), firstOutput.Path, contributors: [contributor]); + var second = exporter.Export(Documents(), secondOutput.Path, contributors: [contributor]); + var nodes = File.ReadAllLines(first.NodesPath).Select(Parse).ToArray(); + var edges = File.ReadAllLines(first.EdgesPath).Select(Parse).ToArray(); + + Assert.Contains(nodes, node => IsNode(node, "term:loop", "Term") + && node.GetProperty("name").GetString() == "loop"); + Assert.Contains(nodes, node => IsNode(node, "sense:loop-gameplay", "Sense") + && node.GetProperty("term").GetString() == "loop" + && node.GetProperty("definition").GetString() == "The gameplay update cycle."); + Assert.Contains(nodes, node => IsNode(node, "term:gameplay-loop", "Term") + && node.GetProperty("name").GetString() == "gameplay loop"); + Assert.Contains(edges, edge => IsEdge(edge, "HAS_SENSE", "term:loop", "sense:loop-gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "ALIAS_OF", "term:gameplay-loop", "sense:loop-gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "SCOPED_TO", "sense:loop-gameplay", "component:Gameplay")); + Assert.Contains(edges, edge => IsEdge(edge, "SCOPED_TO", "sense:loop-gameplay", "code:game-run")); + Assert.Contains(edges, edge => IsEdge(edge, "EVIDENCED_BY", "sense:loop-gameplay", "claim-gameplay")); + + Assert.All(nodes, node => + { + Assert.Equal("node", node.GetProperty("type").GetString()); + Assert.True(node.TryGetProperty("id", out _)); + Assert.True(node.TryGetProperty("label", out _)); + }); + Assert.All(edges, edge => + { + Assert.Equal("edge", edge.GetProperty("type").GetString()); + Assert.True(edge.TryGetProperty("label", out _)); + Assert.True(edge.TryGetProperty("from", out _)); + Assert.True(edge.TryGetProperty("to", out _)); + }); + Assert.Equal(File.ReadAllText(first.NodesPath), File.ReadAllText(second.NodesPath)); + Assert.Equal(File.ReadAllText(first.EdgesPath), File.ReadAllText(second.EdgesPath)); + } + + [Fact] + public void Export_ProposedAndRejectedSenses_ExcludesTheirNodesAndEveryDerivedEdge() + { + using var repository = GlossaryRepository(); + using var output = new TempDirectory(); + var contributor = new ManagedGlossaryGraphContributor(GlossaryService(repository.Path).Load()); + + var result = new DocGraphExporter(Resolver()).Export( + Documents(), + output.Path, + contributors: [contributor]); + var allLines = File.ReadAllText(result.NodesPath) + File.ReadAllText(result.EdgesPath); + + Assert.DoesNotContain("loop-agent", allLines, StringComparison.Ordinal); + Assert.DoesNotContain("churn loop", allLines, StringComparison.Ordinal); + Assert.DoesNotContain("claim-agent", allLines, StringComparison.Ordinal); + Assert.DoesNotContain("loop-legacy", allLines, StringComparison.Ordinal); + Assert.DoesNotContain("legacy loop", allLines, StringComparison.Ordinal); + Assert.DoesNotContain("claim-legacy", allLines, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(CollidingGlossaries))] + public void Constructor_DistinctGlossaryIdentityWouldShareGraphId_FailsClosed( + ManagedGlossaryLoadResult glossary) + { + var exception = Assert.Throws(() => + new ManagedGlossaryGraphContributor(glossary)); + + Assert.Contains("collision", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + public static TheoryData CollidingGlossaries() => new() + { + CreateLoad( + Term("run loop", Sense("run-cycle-a")), + Term("run-loop", Sense("run-cycle-b"))), + CreateLoad( + Term("loop", Sense("loop-primary", aliases: ["agent loop"])), + Term("agent-loop", Sense("loop-agent"))), + CreateLoad( + Term("loop", Sense("shared-sense")), + Term("cycle", Sense("shared-sense"))) + }; + + [Fact] + public void Constructor_SnapshotsOuterAndNestedGlossaryCollections() + { + var scopes = new List { "component:Gameplay" }; + var aliases = new List { "gameplay loop" }; + var evidence = new List { "claim-gameplay" }; + var senses = new List + { + new( + "loop-gameplay", + GlossarySenseStatus.Approved, + "The gameplay update cycle.", + scopes, + aliases, + evidence) + }; + var terms = new List { new("loop", senses) }; + var contributor = new ManagedGlossaryGraphContributor( + new ManagedGlossaryLoadResult(new AnalysisGlossary([]), terms)); + + scopes.Clear(); + scopes.Add("component:Mutated"); + aliases.Clear(); + aliases.Add("mutated alias"); + evidence.Clear(); + evidence.Add("claim-mutated"); + senses.Clear(); + terms.Clear(); + terms.Add(Term("mutated", Sense("mutated-sense"))); + + var contribution = contributor.Contribute(Documents(), Resolver()); + + Assert.Contains(contribution.Nodes, node => node.Id == "term:loop"); + Assert.Contains(contribution.Nodes, node => node.Id == "sense:loop-gameplay" + && node.Properties["definition"] == "The gameplay update cycle."); + Assert.Contains(contribution.Nodes, node => node.Id == "term:gameplay-loop"); + Assert.Contains( + new KyberWeave.Core.Docs.Graph.DocGraphEdge( + "SCOPED_TO", + "sense:loop-gameplay", + "component:Gameplay"), + contribution.Edges); + Assert.Contains( + new KyberWeave.Core.Docs.Graph.DocGraphEdge( + "EVIDENCED_BY", + "sense:loop-gameplay", + "claim-gameplay"), + contribution.Edges); + Assert.DoesNotContain(contribution.Nodes, node => node.Id.Contains("mutated", StringComparison.Ordinal)); + Assert.DoesNotContain(contribution.Edges, edge => edge.To.Contains("mutated", StringComparison.Ordinal)); + } + + private static ManagedGlossaryService GlossaryService(string repositoryRoot) => + new( + repositoryRoot, + new KyberWeaveConfig + { + Ontology = OntologyConfig.ProductDefaults.WithDocsRoots(["docs"]), + DocsAnalysis = new DocsAnalysisConfig { GlossaryPath = "docs/glossary.md" } + }, + TimeProvider.System); + + private static ManagedGlossaryLoadResult CreateLoad(params GlossaryLookupResult[] terms) => + new(new AnalysisGlossary([]), terms); + + private static GlossaryLookupResult Term(string term, params GlossarySense[] senses) => + new(term, senses); + + private static GlossarySense Sense( + string id, + IReadOnlyList? aliases = null) => + new( + id, + GlossarySenseStatus.Approved, + $"Definition for {id}.", + ["component:Gameplay"], + aliases ?? [], + ["claim-" + id]); + + private static TempDirectory GlossaryRepository() + { + var repository = new TempDirectory(); + var docs = Path.Combine(repository.Path, "docs"); + Directory.CreateDirectory(docs); + File.WriteAllText(Path.Combine(docs, "catalog.md"), """ + | Component | Type | Source root | Overview | Detailed documentation | Owner | Last reviewed | Status | + | --- | --- | --- | --- | --- | --- | --- | --- | + | Gameplay | Application | `src/Game` | [README](x) | [docs](y) | Gameplay maintainers | 2026-08-01 | Current | + | Agents | Tool | `src/Agents` | [README](x) | [docs](y) | Agent maintainers | 2026-08-01 | Current | + """); + File.WriteAllText(Path.Combine(docs, "glossary.md"), """ + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: current + owner: Maintainers + last-reviewed: 2026-08-12 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay; code-ref:Game.Run | gameplay loop | + | loop-agent | proposed | An autonomous agent cycle. | component:Agents | churn loop | + | loop-legacy | rejected | A retired cycle. | component:Legacy | legacy loop | + + + - claim-gameplay + + + + - claim-agent + + + + - claim-legacy + + """); + return repository; + } + + private static FakeCodeGraphResolver Resolver() => FakeCodeGraphResolver.WithSymbols( + ("Game.Run", new CodeGraphNode( + "code:game-run", + "method", + "Run", + "Game.Run", + "src/Game.cs", + "csharp", + 10))); + + private static DocumentSet Documents() => new() + { + Documents = + [ + new DocumentModel + { + RelativePath = "docs/gameplay.md", + FilePath = "/repo/docs/gameplay.md", + HasFrontmatter = true, + Frontmatter = new DocumentFrontmatter + { + Id = "reference/gameplay", + Title = "Gameplay", + DocType = "reference", + Status = "current", + Component = "Gameplay", + LastReviewed = "2026-08-12", + CodeRefs = new Collection(["Game.Run"]) + }, + DocType = DocType.Reference, + Status = DocStatus.Current + } + ] + }; + + private static JsonElement Parse(string line) => JsonDocument.Parse(line).RootElement.Clone(); + + private static bool IsNode(JsonElement node, string id, string label) => + node.GetProperty("type").GetString() == "node" + && node.GetProperty("id").GetString() == id + && node.GetProperty("label").GetString() == label; + + private static bool IsEdge(JsonElement edge, string label, string from, string to) => + edge.GetProperty("type").GetString() == "edge" + && edge.GetProperty("label").GetString() == label + && edge.GetProperty("from").GetString() == from + && edge.GetProperty("to").GetString() == to; +} diff --git a/tests/KyberWeave.Tests/IgnoreMarkupTests.cs b/tests/KyberWeave.Tests/IgnoreMarkupTests.cs new file mode 100644 index 0000000..5d069c2 --- /dev/null +++ b/tests/KyberWeave.Tests/IgnoreMarkupTests.cs @@ -0,0 +1,203 @@ +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis.Claims; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Ignore markup is deliberately strict: malformed suppression must become an +/// operational error instead of silently allowing a finding to disappear. +/// +public sealed class IgnoreMarkupTests +{ + [Theory] + [InlineData("duplicate", IgnoreRule.Duplicate)] + [InlineData("conflict", IgnoreRule.Conflict)] + [InlineData("terminology", IgnoreRule.Terminology)] + [InlineData("all", IgnoreRule.All)] + public void Extract_WithAnExactIgnoreRule_MarksOnlyTheWrappedClaim( + string rule, + IgnoreRule expectedRule) + { + var body = $$""" + # Ignores + + ## Runtime + + + The gameplay loop runs live tests. + + + The Codex loop consumes model tokens. + """; + var originalBody = body; + + var result = new ClaimExtractor().Extract(ClaimExtractionTests.Document(body)); + + Assert.Empty(result.Diagnostics.Items); + Assert.Equal(2, result.Claims.Count); + Assert.Equal(expectedRule, result.Claims[0].IgnoreRules); + Assert.Equal(IgnoreRule.None, result.Claims[1].IgnoreRules); + Assert.Equal("The gameplay loop runs live tests.", result.Claims[0].Text); + Assert.Equal(originalBody, ClaimExtractionTests.Document(body).Body); + } + + [Theory] + [InlineData("\nSuppressed prose.\n")] + [InlineData("\nSuppressed prose.\n")] + [InlineData("\nSuppressed prose.\n")] + [InlineData("\nSuppressed prose.\n")] + [InlineData("\nSuppressed prose.\n")] + [InlineData("\nSuppressed prose.")] + [InlineData("Suppressed prose.\n")] + public void Extract_WithMalformedUnknownCaseChangedOrUnbalancedMarkup_ReportsOperationalError( + string markup) + { + var body = $$""" + # Invalid ignores + + ## Runtime + + {{markup}} + """; + + var result = new ClaimExtractor().Extract(ClaimExtractionTests.Document(body)); + + AssertOperationalIgnoreError(result.Diagnostics.Items); + } + + [Fact] + public void Extract_WithNestedIgnoreMarkup_ReportsOperationalError() + { + const string body = """ + # Invalid ignores + + ## Runtime + + + + Suppressed prose. + + + """; + + var result = new ClaimExtractor().Extract(ClaimExtractionTests.Document(body)); + + AssertOperationalIgnoreError(result.Diagnostics.Items); + } + + [Fact] + public void Extract_WithIgnoreMarkupCrossingASectionBoundary_ReportsOperationalError() + { + const string body = """ + # Invalid ignores + + ## Runtime + + + Suppressed prose. + + ## Automation + + Other prose. + + """; + + var result = new ClaimExtractor().Extract(ClaimExtractionTests.Document(body)); + + AssertOperationalIgnoreError(result.Diagnostics.Items); + } + + [Fact] + public void Extract_WithIgnoreMarkupCrossingFrontmatter_ReportsOperationalError() + { + const string rawMarkdown = """ + --- + id: docs/claims + title: Claims + doc-type: reference + status: current + component: DocGraph + note: + --- + + # Invalid ignores + + ## Runtime + + Suppressed prose. + + """; + const string body = """ + # Invalid ignores + + ## Runtime + + Suppressed prose. + + """; + + var document = ClaimExtractionTests.Document(body, rawMarkdown, bodyStartLine: 10); + + var result = new ClaimExtractor().Extract(document); + + AssertOperationalIgnoreError(result.Diagnostics.Items); + } + + [Fact] + public void Extract_WithTagLikeTextInsideFences_TreatsItAsCodeInsteadOfMarkup() + { + const string body = """ + # Ignore examples + + ## Runtime + + ```html + + Example only. + + ``` + + ~~~text + also an example + ~~~ + """; + + var result = new ClaimExtractor().Extract(ClaimExtractionTests.Document(body)); + + Assert.Empty(result.Diagnostics.Items); + Assert.Equal(2, result.Claims.Count); + Assert.All(result.Claims, claim => + { + Assert.Equal(ClaimKind.CodeBlock, claim.Kind); + Assert.Equal(IgnoreRule.None, claim.IgnoreRules); + }); + } + + [Fact] + public void Extract_WithIgnoreMarkup_DoesNotMutateTheRetrievalBody() + { + const string body = """ + # Ignores + + ## Runtime + + + Preserve this original body verbatim. + + """; + var document = ClaimExtractionTests.Document(body); + + _ = new ClaimExtractor().Extract(document); + + Assert.Equal(body, document.Body); + } + + private static void AssertOperationalIgnoreError(IReadOnlyList diagnostics) + { + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("KW-DOC-ANALYSIS-004", diagnostic.Code); + Assert.Equal(Severity.Error, diagnostic.Severity); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.Hint)); + } +} diff --git a/tests/KyberWeave.Tests/ManagedGlossaryTests.cs b/tests/KyberWeave.Tests/ManagedGlossaryTests.cs new file mode 100644 index 0000000..9d72677 --- /dev/null +++ b/tests/KyberWeave.Tests/ManagedGlossaryTests.cs @@ -0,0 +1,620 @@ +using KyberWeave.Cli.Commands.Docs; +using KyberWeave.Core.Configuration; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Parsing; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Specifies the managed glossary as a conservative file merger. Source documentation is +/// never changed; only generated proposals in the configured reference document are owned +/// by the feature. +/// +public sealed class ManagedGlossaryTests +{ + private static readonly DateTimeOffset Today = new(2026, 8, 11, 15, 30, 0, TimeSpan.Zero); + + [Theory] + [InlineData(null, "docs/glossary.md")] + [InlineData("components/gameplay/docs/terms.md", "components/gameplay/docs/terms.md")] + public void Preview_DefaultOrConfiguredPath_UsesConfiguredCorpusLocationWithoutWriting( + string? configuredPath, + string expectedPath) + { + using var repository = Repository(docsRoots: ["docs", "components/gameplay/docs"]); + var service = Service(repository, glossaryPath: configuredPath); + + var result = service.Preview([Proposal("loop", "component:Gameplay", "claim-1")]); + + Assert.Equal(expectedPath, result.RelativePath); + Assert.False(result.Written); + Assert.False(File.Exists(repository.FullPath(expectedPath))); + Assert.Contains("## loop", result.Markdown, StringComparison.Ordinal); + } + + [Fact] + public void Write_MissingGlossary_CreatesConformantReferenceUsingFirstCatalogOwnerAndUtcDate() + { + using var repository = Repository(); + var service = Service(repository); + + var result = service.Write([Proposal("loop", "component:Gameplay", "claim-1")]); + + Assert.True(result.Written); + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains("doc-type: reference", markdown, StringComparison.Ordinal); + Assert.Contains("status: needs-review", markdown, StringComparison.Ordinal); + Assert.Contains("owner: Gameplay maintainers", markdown, StringComparison.Ordinal); + Assert.Contains("last-reviewed: 2026-08-11", markdown, StringComparison.Ordinal); + } + + [Fact] + public void Write_CatalogHasNoDataOwner_FailsBeforeCreatingGlossary() + { + using var repository = Repository(catalogRows: []); + var service = Service(repository); + + var exception = Assert.Throws(() => + service.Write([Proposal("loop", "component:Gameplay", "claim-1")])); + + Assert.Contains("owner", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(repository.FullPath("docs/glossary.md"))); + } + + [Fact] + public void Write_NewProposal_UsesExactTableShapeStatusScopeAndMarkedEvidence() + { + using var repository = Repository(); + var service = Service(repository); + + service.Write([Proposal("loop", "component:Gameplay", "claim-1", aliases: ["gameplay loop"])]); + + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains("| Sense ID | Status | Definition | Scope | Aliases |", markdown, StringComparison.Ordinal); + Assert.Matches(@"\| loop-[a-f0-9]{8} \| proposed \| \| component:Gameplay \| gameplay loop \|", markdown); + Assert.Contains(" + - old-claim + + """)); + + Service(repository).Write([]); + + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains("loop-a1b2c3d4", markdown, StringComparison.Ordinal); + Assert.Contains("old-claim", markdown, StringComparison.Ordinal); + } + + [Fact] + public void Write_GeneratedProposalEvidenceWasHumanEdited_PreservesRowAndEvidence() + { + using var repository = Repository(); + var service = Service(repository); + service.Write([Proposal("loop", "component:Gameplay", "claim-1", aliases: ["gameplay loop"])]); + repository.ReplaceInGlossary("- claim-1", "- human evidence note"); + + service.Write([]); + + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains("| proposed |", markdown, StringComparison.Ordinal); + Assert.Contains("human evidence note", markdown, StringComparison.Ordinal); + } + + [Fact] + public void Write_FencedManagedHeadingAndTableExample_PreservesExampleByteForByte() + { + const string example = """ + ```markdown + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | example-loop | approved | Example only. | component:Gameplay | example | + ``` + """; + using var repository = Repository().WriteGlossary(ExistingGlossary(example)); + + Service(repository).Write([Proposal("loop", "component:Agents", "claim-1")]); + + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains(example, markdown, StringComparison.Ordinal); + Assert.True(markdown.LastIndexOf("## loop", StringComparison.Ordinal) + > markdown.IndexOf("\n```\n", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_RealTermWithFencedManagedTableAndEvidence_IgnoresFencedLookalikes() + { + const string fencedExample = """ + ```markdown + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | example-loop | accepted | Example only. | team:Example | example | + + + - example-claim + + ``` + """; + using var repository = Repository().WriteGlossary(ExistingGlossary($$""" + ## loop + + {{fencedExample}} + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay | gameplay loop | + """)); + + var report = Service(repository).Validate(); + + Assert.Empty(report.Items); + } + + [Fact] + public void PreviewAndWrite_RealTermWithFencedManagedTableAndEvidence_PreserveFencedBytes() + { + const string fencedExample = """ + ```markdown + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | example-loop | accepted | Example only. | team:Example | example | + + + - example-claim + + ``` + """; + using var repository = Repository().WriteGlossary(ExistingGlossary($$""" + ## loop + + {{fencedExample}} + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay | gameplay loop | + """)); + var service = Service(repository); + var proposals = new[] { Proposal("loop", "component:Agents", "claim-1") }; + + var preview = service.Preview(proposals); + var written = service.Write(proposals); + + Assert.Contains(fencedExample, preview.Markdown, StringComparison.Ordinal); + Assert.Contains(fencedExample, written.Markdown, StringComparison.Ordinal); + Assert.Contains(fencedExample, File.ReadAllText(repository.FullPath("docs/glossary.md")), StringComparison.Ordinal); + Assert.True(written.Markdown.LastIndexOf("| proposed |", StringComparison.Ordinal) + > written.Markdown.LastIndexOf("```", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("Human-authored definition", "component:Gameplay", "game cycle")] + [InlineData("", "component:Gameplay; code-ref:Runner.Loop", "human alias")] + public void Write_EditedProposalLosesEvidence_PreservesHumanEdits( + string definition, + string scope, + string aliases) + { + using var repository = Repository().WriteGlossary(ExistingGlossary($$""" + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a1b2c3d4 | proposed | {{definition}} | {{scope}} | {{aliases}} | + + + - old-claim + + """)); + var service = Service(repository); + + service.Write([]); + + var markdown = File.ReadAllText(repository.FullPath("docs/glossary.md")); + Assert.Contains("loop-a1b2c3d4", markdown, StringComparison.Ordinal); + if (definition.Length > 0) + Assert.Contains(definition, markdown, StringComparison.Ordinal); + Assert.Contains(scope, markdown, StringComparison.Ordinal); + Assert.Contains(aliases, markdown, StringComparison.Ordinal); + } + + [Theory] + [InlineData("accepted")] + [InlineData("pending")] + [InlineData("CURRENT")] + public void Validate_UnknownSenseStatus_ReturnsGlossaryDiagnostic(string status) + { + using var repository = Repository().WriteGlossary(ExistingGlossary($$""" + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a1b2c3d4 | {{status}} | A definition. | component:Gameplay | gameplay loop | + """)); + + var report = Service(repository).Validate(); + + Assert.Contains(report.Items, item => item.Code == ManagedGlossaryService.ValidationRuleCode); + Assert.All(report.Items, item => Assert.Equal("KW-DOC-GLOSSARY-001", item.Code)); + } + + [Theory] + [InlineData("", "component:Gameplay")] + [InlineData("A definition.", "")] + [InlineData("A definition.", "team:Gameplay")] + [InlineData("A definition.", "component:Unknown")] + public void Validate_InvalidApprovedSense_ReturnsGlossaryDiagnostic(string definition, string scope) + { + using var repository = Repository().WriteGlossary(ExistingGlossary($$""" + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a1b2c3d4 | approved | {{definition}} | {{scope}} | gameplay loop | + """)); + + var report = Service(repository).Validate(); + + Assert.Contains(report.Items, item => item.Code == "KW-DOC-GLOSSARY-001"); + } + + [Fact] + public void Load_ApprovedAndRejectedRows_ReturnsOnlyApprovedAnalysisSenses() + { + using var repository = Repository().WriteGlossary(ExistingGlossary( + """ + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay | gameplay loop | + | loop-agent | rejected | Rejected meaning. | component:Agents | churn loop | + """)); + + AnalysisGlossary glossary = Service(repository).Load().AnalysisGlossary; + + var sense = Assert.Single(glossary.Senses); + Assert.Equal("loop-gameplay", sense.Id); + Assert.Equal("The gameplay update cycle.", sense.Definition); + Assert.Equal(["component:Gameplay"], sense.Scopes); + Assert.Equal(["gameplay loop"], sense.Aliases); + } + + [Fact] + public void Lookup_TermIsCaseInsensitive_ReturnsAllStatusesAndParsedScopes() + { + using var repository = Repository().WriteGlossary(ExistingGlossary( + """ + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-gameplay | approved | The gameplay update cycle. | component:Gameplay; code-ref:Game.Run | gameplay loop | + | loop-agent | proposed | | component:Agents | churn loop | + """)); + + var result = Service(repository).Lookup("LOOP"); + + Assert.Equal("loop", result.Term); + Assert.Equal(2, result.Senses.Count); + Assert.Contains(result.Senses, sense => sense.Status == GlossarySenseStatus.Approved + && sense.Scopes.SequenceEqual(["component:Gameplay", "code-ref:Game.Run"])); + Assert.Contains(result.Senses, sense => sense.Status == GlossarySenseStatus.Proposed); + } + + [Theory] + [InlineData(""" + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: needs-review + owner: Human owner + last-reviewed: 2026-07-01 + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a | approved | Definition. | component:Gameplay | gameplay loop | + """)] + [InlineData(""" + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: needs-review + owner: Human owner + last-reviewed: 2026-07-01 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + loop-a | approved | Definition. | component:Gameplay | gameplay loop + """)] + public void Validate_MalformedFrontmatterOrSenseRow_FailsClosed(string markdown) + { + using var repository = Repository().WriteGlossary(markdown); + + var report = Service(repository).Validate(); + + Assert.Contains(report.Items, item => item.Code == "KW-DOC-GLOSSARY-001"); + } + + [Fact] + public void Load_IndentedAtxHeading_UsesTermWithoutHashPrefix() + { + using var repository = Repository().WriteGlossary(ExistingGlossary(""" + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a | approved | A definition. | component:Gameplay | gameplay loop | + """)); + + var result = Service(repository).Load(); + + var term = Assert.Single(result.Terms); + Assert.Equal("loop", term.Term); + Assert.DoesNotContain("# loop", result.Terms.Select(item => item.Term)); + } + + [Fact] + public void Load_DefinitionWithBackslashes_PreservesWindowsPathAndRegexEscapes() + { + using var repository = Repository().WriteGlossary(ExistingGlossary(""" + ## path + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | path-a | approved | Output is C:\build\out and tokens match \w+. | component:Gameplay | output path | + """)); + + var result = Service(repository).Load(); + + var sense = Assert.Single(Assert.Single(result.Terms).Senses); + Assert.Equal(@"Output is C:\build\out and tokens match \w+.", sense.Definition); + } + + [Fact] + public void Write_FirstCatalogOwnerContainsYamlPunctuation_RoundTripsExactOwner() + { + const string owner = "Docs: Core # on-call"; + using var repository = Repository(catalogRows: + [ + $"| Gameplay | Application | `src/Game` | [README](x) | [docs](y) | {owner} | 2026-08-01 | Current |" + ]); + + Service(repository).Write([Proposal("loop", "component:Gameplay", "claim-1")]); + + var set = new DocumentLoader(repository.Root, repository.Ontology).Load(); + var glossary = Assert.Single(set.Documents, document => document.RelativePath == "docs/glossary.md"); + Assert.Null(glossary.ParseError); + Assert.Equal(owner, glossary.Frontmatter.Owner); + } + + [Fact] + public void DocsValidate_InvalidManagedGlossary_ReturnsGlossaryOperationalError() + { + using var repository = Repository(catalogRows: + [ + "| Gameplay | Application | `src/Game` | [README](x) | [docs](y) | Gameplay maintainers | 2026-08-01 | Current |", + "| System | System | repository root | [README](x) | [docs](y) | Maintainers | 2026-08-01 | Current |" + ]).WriteGlossary(""" + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: needs-review + owner: Gameplay maintainers + last-reviewed: 2026-07-01 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-a | accepted | Definition. | component:Gameplay | gameplay loop | + """); + repository.WriteConfig(""" + ontology: + docs-root: docs + docs-analysis: + glossary-path: docs/glossary.md + """); + var settings = new DocsSettings { Path = repository.Root, Format = "json" }; + + var exitCode = ProcessConsoleCapture.Run(() => new DocsValidateCommand().Execute(null!, settings)).Result; + + Assert.Equal(1, exitCode); + } + + private static ManagedGlossaryService Service(GlossaryRepository repository, string? glossaryPath = null) => + new( + repository.Root, + new KyberWeaveConfig + { + Ontology = repository.Ontology, + DocsAnalysis = new DocsAnalysisConfig { GlossaryPath = glossaryPath } + }, + new FixedTimeProvider(Today)); + + private static GlossaryProposal Proposal( + string term, + string scope, + string evidenceId, + IReadOnlyList? aliases = null) => + new(term, "", [scope], aliases ?? [], [evidenceId]); + + private static GlossaryRepository Repository( + IReadOnlyList? docsRoots = null, + IReadOnlyList? catalogRows = null) => + new(docsRoots ?? ["docs"], catalogRows ?? + [ + "| Gameplay | Application | `src/Game` | [README](x) | [docs](y) | Gameplay maintainers | 2026-08-01 | Current |", + "| Agents | Tool | `src/Agents` | [README](x) | [docs](y) | Agent maintainers | 2026-08-01 | Current |" + ]); + + private static string ExistingGlossary(string body, string status = "needs-review") => $$""" + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: {{status}} + owner: Human owner + last-reviewed: 2026-07-01 + --- + + # Glossary + + {{body}} + """; + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private sealed class GlossaryRepository : IDisposable + { + private readonly TempDirectory _temp = new(); + + public GlossaryRepository(IReadOnlyList docsRoots, IReadOnlyList catalogRows) + { + Ontology = OntologyConfig.ProductDefaults.WithDocsRoots(docsRoots); + foreach (var root in docsRoots) Directory.CreateDirectory(FullPath(root)); + Write(Ontology.ResolvedCatalogPath, $$""" + --- + id: system/catalog + title: Catalog + doc-type: index + status: current + owner: Maintainers + last-reviewed: 2026-08-01 + --- + + # Catalog + + | Component | Type | Source root | Overview | Detailed documentation | Owner | Last reviewed | Status | + | --- | --- | --- | --- | --- | --- | --- | --- | + {{string.Join('\n', catalogRows)}} + """); + } + + public string Root => _temp.Path; + public OntologyConfig Ontology { get; } + + public string FullPath(string relativePath) => + Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + public GlossaryRepository WriteGlossary(string markdown) + { + Write("docs/glossary.md", markdown); + return this; + } + + public void ReplaceInGlossary(string oldValue, string newValue) + { + var path = FullPath("docs/glossary.md"); + File.WriteAllText(path, File.ReadAllText(path).Replace(oldValue, newValue, StringComparison.Ordinal)); + } + + public void WriteConfig(string yaml) => Write(".kyber-weave/kyber-weave.yml", yaml); + + private void Write(string relativePath, string content) + { + var path = FullPath(relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + public void Dispose() => _temp.Dispose(); + } +} diff --git a/tests/KyberWeave.Tests/McpAnalysisToolsTests.cs b/tests/KyberWeave.Tests/McpAnalysisToolsTests.cs new file mode 100644 index 0000000..9287f22 --- /dev/null +++ b/tests/KyberWeave.Tests/McpAnalysisToolsTests.cs @@ -0,0 +1,390 @@ +using System.Reflection; +using KyberWeave.Core.Diagnostics; +using KyberWeave.Core.Docs.Analysis; +using KyberWeave.Core.Docs.Analysis.Candidates; +using KyberWeave.Core.Docs.Analysis.Claims; +using KyberWeave.Core.Docs.Analysis.Glossary; +using KyberWeave.Core.Docs.Analysis.Model; +using KyberWeave.Core.Docs.Model; +using KyberWeave.Core.Docs.Search; +using KyberWeave.Mcp; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// T14 RED — MCP analysis tools are capped, stable, conversational reads over the +/// repository's current configured corpus and never expose a write capability. +/// +public sealed class McpAnalysisToolsTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + [Fact] + public void AnalysisCandidates_KindFilterAndCursor_PageStableOrderedCandidates() + { + var reader = new StubAnalysisReader( + Result( + Candidate("candidate-c", AnalysisRuleKind.Conflict), + Candidate("candidate-b", AnalysisRuleKind.Duplicate), + Candidate("candidate-a", AnalysisRuleKind.Duplicate))); + var tools = Tools(reader); + + var first = tools.AnalysisCandidates( + kind: "duplicate", + cursor: null, + limit: 1, + charBudget: 4_000); + var second = tools.AnalysisCandidates( + kind: "duplicate", + cursor: "candidate-a", + limit: 1, + charBudget: 4_000); + + Assert.Contains("candidate: candidate-a", first, StringComparison.Ordinal); + Assert.Contains("next cursor: candidate-a", first, StringComparison.Ordinal); + Assert.DoesNotContain("candidate-b", first, StringComparison.Ordinal); + Assert.DoesNotContain("candidate-c", first, StringComparison.Ordinal); + Assert.Contains("candidate: candidate-b", second, StringComparison.Ordinal); + Assert.DoesNotContain("candidate-a", second, StringComparison.Ordinal); + Assert.DoesNotContain("candidate-c", second, StringComparison.Ordinal); + } + + [Fact] + public void AnalysisCandidates_ExcessiveLimitAndBudget_EnforcesHardConversationalCaps() + { + var candidates = Enumerable.Range(0, 40) + .Select(index => Candidate($"candidate-{index:D2}", AnalysisRuleKind.Duplicate)) + .ToArray(); + var tools = Tools(new StubAnalysisReader(Result(candidates))); + + var response = tools.AnalysisCandidates( + kind: null, + cursor: null, + limit: int.MaxValue, + charBudget: int.MaxValue); + + Assert.True(response.Length <= 12_000, $"MCP response contained {response.Length} characters."); + Assert.InRange(Occurrences(response, "candidate: "), 1, 20); + Assert.Contains("next cursor:", response, StringComparison.Ordinal); + } + + [Fact] + public void AnalysisCandidates_SmallBudget_KeepsMetricsAndLineEvidenceInsideBudget() + { + var candidate = Candidate( + "candidate-budget", + AnalysisRuleKind.Terminology, + term: "loop", + claimText: new string('x', 2_000)); + var tools = Tools(new StubAnalysisReader(Result(candidate))); + + var response = tools.AnalysisCandidates( + kind: "terminology", + cursor: null, + limit: 20, + charBudget: 600); + + Assert.True(response.Length <= 600, $"MCP response contained {response.Length} characters."); + Assert.Contains("metrics:", response, StringComparison.Ordinal); + Assert.Contains("extracted claims: 2", response, StringComparison.Ordinal); + Assert.Contains("candidate: candidate-budget", response, StringComparison.Ordinal); + Assert.Contains("docs/left.md:10-10", response, StringComparison.Ordinal); + Assert.DoesNotContain(new string('x', 1_000), response, StringComparison.Ordinal); + } + + [Theory] + [InlineData("99")] + [InlineData("duplicate, conflict")] + public void AnalysisCandidates_NumericOrCompositeKind_ReturnsUnknownKind(string kind) + { + var tools = Tools(new StubAnalysisReader(Result(Candidate("candidate-a", AnalysisRuleKind.Duplicate)))); + + var response = tools.AnalysisCandidates(kind: kind, cursor: null, limit: 20, charBudget: 4_000); + + Assert.Contains($"Unknown documentation-analysis kind '{kind}'", response, StringComparison.Ordinal); + Assert.DoesNotContain("No 99 candidates", response, StringComparison.Ordinal); + Assert.DoesNotContain("candidate-a", response, StringComparison.Ordinal); + } + + [Fact] + public void Glossary_KnownAndUnknownTerms_ReturnConversationalReadOnlyResults() + { + var reader = new StubAnalysisReader( + Result(), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["loop"] = new GlossaryLookupResult( + "loop", + [new GlossarySense( + "loop-gameplay", + GlossarySenseStatus.Approved, + "The gameplay update cycle.", + ["component:Gameplay"], + ["gameplay loop"], + ["claim-gameplay"])]) + }); + var tools = Tools(reader); + + var known = tools.Glossary("LOOP"); + var unknown = tools.Glossary("missing-term"); + + Assert.Contains("loop-gameplay", known, StringComparison.Ordinal); + Assert.Contains("approved", known, StringComparison.OrdinalIgnoreCase); + Assert.Contains("The gameplay update cycle.", known, StringComparison.Ordinal); + Assert.Contains("component:Gameplay", known, StringComparison.Ordinal); + Assert.Contains("gameplay loop", known, StringComparison.Ordinal); + Assert.Contains("No glossary senses", unknown, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", unknown, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Glossary_TermWithNewlines_DoesNotInjectResponseLines() + { + var reader = new StubAnalysisReader( + Result(), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["loop"] = new GlossaryLookupResult( + "loop\nstatus: injected", + [new GlossarySense( + "loop-gameplay", + GlossarySenseStatus.Approved, + "The gameplay update cycle.", + ["component:Gameplay"], + ["gameplay loop"])]) + }); + var tools = Tools(reader); + + var response = tools.Glossary("loop"); + + Assert.Contains("glossary sense for 'loop status: injected'", response, StringComparison.Ordinal); + Assert.DoesNotContain("\nstatus: injected", response, StringComparison.Ordinal); + } + + [Fact] + public void AnalysisCandidates_TermWithNewlines_DoesNotInjectResponseLines() + { + var tools = Tools(new StubAnalysisReader(Result( + Candidate("candidate-a", AnalysisRuleKind.Terminology, term: "loop\ncandidate: forged")))); + + var response = tools.AnalysisCandidates(kind: "terminology", cursor: null, limit: 20, charBudget: 4_000); + + Assert.Contains("term: loop candidate: forged", response, StringComparison.Ordinal); + Assert.DoesNotContain("\ncandidate: forged", response, StringComparison.Ordinal); + } + + [Fact] + public void Glossary_LongUnknownTerm_EnforcesHardConversationalCap() + { + var tools = Tools(new StubAnalysisReader(Result())); + var term = new string('x', 20_000); + + var response = tools.Glossary(term); + + Assert.True(response.Length <= 12_000, $"MCP response contained {response.Length} characters."); + Assert.Contains("No glossary senses", response, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void McpAnalysisTools_ExposeOnlyThePinnedReadParameters() + { + var candidates = typeof(DocsTools).GetMethod(nameof(DocsTools.AnalysisCandidates)); + var glossary = typeof(DocsTools).GetMethod(nameof(DocsTools.Glossary)); + + Assert.NotNull(candidates); + Assert.Equal(typeof(string), candidates.ReturnType); + Assert.Equal( + ["kind", "cursor", "limit", "charBudget"], + candidates.GetParameters().Select(parameter => parameter.Name)); + Assert.Equal(20, candidates.GetParameters()[2].DefaultValue); + Assert.Equal(12_000, candidates.GetParameters()[3].DefaultValue); + Assert.Equal("docs_analysis_candidates", McpToolName(candidates)); + + Assert.NotNull(glossary); + Assert.Equal(typeof(string), glossary.ReturnType); + Assert.Equal(["term"], glossary.GetParameters().Select(parameter => parameter.Name)); + Assert.Equal("docs_glossary", McpToolName(glossary)); + Assert.DoesNotContain( + typeof(DocsTools).GetMethods( + BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public), + method => method.Name.Contains("Write", StringComparison.OrdinalIgnoreCase) + || method.GetParameters().Any(parameter => + parameter.Name?.Contains("write", StringComparison.OrdinalIgnoreCase) == true)); + } + + [Fact] + public void RepositoryAnalysisReader_UsesConfiguredRootAndDoesNotCreateUnsafeCacheState() + { + Write(".kyber-weave/kyber-weave.yml", """ + ontology: + docs-root: [knowledge] + docs-analysis: + statuses: [current] + glossary-path: knowledge/terms.md + """); + Write("knowledge/first.md", Document("reference/first", "current")); + Write("knowledge/second.md", Document("reference/second", "current")); + Write("knowledge/draft.md", Document("reference/draft", "draft")); + Write("knowledge/terms.md", GlossaryMarkdown()); + var reader = new RepositoryDocsAnalysisReader(_temp.Path); + + var analysis = reader.Analyze(); + var glossary = reader.LookupGlossary("loop"); + + Assert.Equal(2, analysis.Metrics.ExtractedClaims); + Assert.Single(analysis.Candidates, candidate => candidate.IsExact); + Assert.Equal("loop", glossary.Term); + Assert.Single(glossary.Senses); + Assert.False(Directory.Exists(Path.Combine(_temp.Path, ".kyber-weave", "cache"))); + } + + [Fact] + public void RepositoryAnalysisReader_UnavailableCodeGraph_ReportsOneDegradedWarning() + { + Write(".kyber-weave/kyber-weave.yml", """ + ontology: + docs-root: [knowledge] + docs-analysis: + statuses: [current] + """); + Write("knowledge/first.md", Document("reference/first", "current")); + var reader = new RepositoryDocsAnalysisReader(_temp.Path); + + var analysis = reader.Analyze(); + + var warning = Assert.Single(analysis.Diagnostics.Items, finding => + finding.Code == DocumentationAnalyzer.CodeGraphUnavailableRuleCode); + Assert.Equal(Severity.Warning, warning.Severity); + Assert.Contains("bounded lexical search", warning.Hint, StringComparison.Ordinal); + } + + public void Dispose() => _temp.Dispose(); + + private DocsTools Tools(IDocsAnalysisReader reader) + { + var host = new DocumentIndexHost( + _temp.Path, + () => FakeCodeGraphResolver.WithSymbols(), + () => new DocumentSet { Documents = [] }, + "docs"); + return new DocsTools(host, reader); + } + + private static DocumentationAnalysisResult Result(params AnalysisCandidate[] candidates) => + new( + candidates, + new DiagnosticReport(), + new AnalysisMetrics( + ExtractedClaims: 2, + GraphComparisons: 3, + LexicalComparisons: 4, + EmbeddingComparisons: 0, + GraphCandidates: 1, + LexicalCandidates: 2, + EmbeddingCandidates: 0, + Truncated: false)); + + private static AnalysisCandidate Candidate( + string id, + AnalysisRuleKind kind, + string? term = null, + string claimText = "The runtime retains reviewed documentation evidence for later analysis.") => + new( + id, + kind, + [ + Claim("left", "docs/left.md", 10, claimText), + Claim("right", "docs/right.md", 20, claimText + " Additional context.") + ], + new CandidateScore(0.75, 0.88, 1), + Term: term, + Sources: [CandidateSourceKind.Graph, CandidateSourceKind.Lexical]); + + private static Claim Claim(string id, string path, int line, string text) => new( + ClaimKind.Paragraph, + text, + "Behavior\n" + text, + "content-" + id, + "context-" + id, + "reference/" + id, + "Runtime", + "Behavior", + path, + line, + line, + IgnoreRule.None); + + private static string McpToolName(MethodInfo method) => + method.CustomAttributes + .Single(attribute => attribute.AttributeType.Name == "McpServerToolAttribute") + .NamedArguments + .Single(argument => argument.MemberName == "Name") + .TypedValue.Value?.ToString() ?? string.Empty; + + private static int Occurrences(string text, string value) + { + var count = 0; + var start = 0; + while ((start = text.IndexOf(value, start, StringComparison.Ordinal)) >= 0) + { + count++; + start += value.Length; + } + return count; + } + + private void Write(string relativePath, string content) + { + var path = Path.Combine(_temp.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static string Document(string id, string status) => $$""" + --- + id: {{id}} + title: {{id}} + doc-type: reference + status: {{status}} + owner: Maintainers + last-reviewed: 2026-08-12 + --- + + # Reference + + ## Behavior + + The runtime retains reviewed documentation evidence for later analysis. + """; + + private static string GlossaryMarkdown() => """ + --- + id: reference/glossary + title: Glossary + doc-type: reference + status: needs-review + owner: Maintainers + last-reviewed: 2026-08-12 + --- + + # Glossary + + ## loop + + | Sense ID | Status | Definition | Scope | Aliases | + |---|---|---|---|---| + | loop-proposed | proposed | | component:Gameplay | gameplay loop | + """; + + private sealed class StubAnalysisReader( + DocumentationAnalysisResult result, + IReadOnlyDictionary? glossary = null) : IDocsAnalysisReader + { + public DocumentationAnalysisResult Analyze() => result; + + public GlossaryLookupResult LookupGlossary(string term) => + glossary?.GetValueOrDefault(term) + ?? new GlossaryLookupResult(term.Trim().ToLowerInvariant(), []); + } +} diff --git a/tests/KyberWeave.Tests/ProcessConsoleCapture.cs b/tests/KyberWeave.Tests/ProcessConsoleCapture.cs new file mode 100644 index 0000000..f9b1246 --- /dev/null +++ b/tests/KyberWeave.Tests/ProcessConsoleCapture.cs @@ -0,0 +1,43 @@ +using Spectre.Console; + +namespace KyberWeave.Tests; + +/// +/// Serializes tests that temporarily replace process-global console state without +/// disabling parallel execution for unrelated test work. +/// +internal static class ProcessConsoleCapture +{ + private static readonly object Gate = new(); + + public static CapturedConsoleExecution Run(Func execute) + { + ArgumentNullException.ThrowIfNull(execute); + + lock (Gate) + { + using var writer = new StringWriter(); + var originalOut = Console.Out; + var originalAnsiConsole = AnsiConsole.Console; + try + { + Console.SetOut(writer); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(writer) + }); + return new CapturedConsoleExecution(execute(), writer.ToString()); + } + finally + { + AnsiConsole.Console = originalAnsiConsole; + Console.SetOut(originalOut); + } + } + } +} + +internal sealed record CapturedConsoleExecution(T Result, string Output); diff --git a/tests/KyberWeave.Tests/ProcessRunnerInputTests.cs b/tests/KyberWeave.Tests/ProcessRunnerInputTests.cs new file mode 100644 index 0000000..dc93b55 --- /dev/null +++ b/tests/KyberWeave.Tests/ProcessRunnerInputTests.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; +using System.Diagnostics; +using KyberWeave.Core.Processes; +using Xunit; + +namespace KyberWeave.Tests; + +/// +/// Full-duplex child process execution. A parent that fills stdin before draining output +/// can deadlock with a child that fills its output pipes before reading stdin. +/// +public sealed class ProcessRunnerInputTests +{ + private const int BytesPerStream = 300_000; + private const int TimeoutSeconds = 30; + + private static string Emit(int bytes, char fill, bool toStandardError) => + $"head -c {bytes} /dev/zero | tr '\\0' '{fill}'{(toStandardError ? " >&2" : string.Empty)}"; + + private static ProcessStartInfo CreateShellStartInfo(string script) + { + var startInfo = new ProcessStartInfo("/bin/sh") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(script); + return startInfo; + } + + [Fact] + public async Task Run_WithLargeInputAndOutput_TransfersAllStreamsWithoutDeadlock() + { + if (OperatingSystem.IsWindows()) return; // CI runs Linux; the contract is platform-independent. + + var input = new string('i', BytesPerStream); + var startInfo = CreateShellStartInfo( + $"{Emit(BytesPerStream, 'e', toStandardError: true)}; " + + $"{Emit(BytesPerStream, 'o', toStandardError: false)}; " + + "count=$(wc -c | tr -d '[:space:]'); printf '\\nstdin:%s\\n' \"$count\"; exit 11"); + + var result = await Task + .Run(() => ProcessRunner.Run(startInfo, input)) + .WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + + Assert.Equal(new string('o', BytesPerStream) + $"\nstdin:{BytesPerStream}\n", result.StandardOutput); + Assert.Equal(new string('e', BytesPerStream), result.StandardError); + Assert.Equal(11, result.ExitCode); + } + + [Fact] + public void Run_PreservesCallerEnvironmentOverrides() + { + if (OperatingSystem.IsWindows()) return; + + var startInfo = CreateShellStartInfo("printf '%s' \"$KYBER_WEAVE_PROCESS_MARKER\""); + startInfo.Environment["KYBER_WEAVE_PROCESS_MARKER"] = "from-caller"; + + var result = ProcessRunner.Run(startInfo, string.Empty); + + Assert.Equal("from-caller", result.StandardOutput); + Assert.Equal(0, result.ExitCode); + } + + [Theory] + [InlineData("stdin")] + [InlineData("stdout")] + [InlineData("stderr")] + public void Run_WhenARequiredStreamIsNotRedirected_RejectsTheStartInfo(string stream) + { + var startInfo = CreateShellStartInfo("exit 0"); + switch (stream) + { + case "stdin": + startInfo.RedirectStandardInput = false; + break; + case "stdout": + startInfo.RedirectStandardOutput = false; + break; + case "stderr": + startInfo.RedirectStandardError = false; + break; + } + + Assert.Throws(() => ProcessRunner.Run(startInfo, string.Empty)); + } + + [Fact] + public void Run_WhenArgumentsIsAConcatenatedString_RejectsTheStartInfo() + { + var startInfo = new ProcessStartInfo("sqlite3") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + Arguments = "--version" + }; + + var exception = Assert.Throws(() => ProcessRunner.Run(startInfo, string.Empty)); + Assert.Equal("startInfo", exception.ParamName); + } + + [Fact] + public void Run_WhenProcessCannotStart_PropagatesTheStartupFailure() + { + var missingExecutable = Path.Combine( + Path.GetTempPath(), + $"kyber-weave-missing-{Guid.NewGuid():N}"); + var startInfo = new ProcessStartInfo(missingExecutable) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + Assert.Throws(() => ProcessRunner.Run(startInfo, string.Empty)); + } + + [Fact] + public async Task Run_WhenChildClosesStdin_PropagatesTheWriteFailure() + { + if (OperatingSystem.IsWindows()) return; // CI runs Linux; the contract is platform-independent. + + var startInfo = CreateShellStartInfo("exec 0<&-; exit 0"); + var input = new string('i', BytesPerStream * 4); + + await Assert.ThrowsAnyAsync(async () => + await Task + .Run(() => ProcessRunner.Run(startInfo, input)) + .WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds))); + } +}