diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c825a3f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Launchers: bash needs LF, cmd.exe needs CRLF (labels misparse in LF-only batch files). +*.sh text eol=lf +*.bat text eol=crlf +*.ps1 text eol=crlf diff --git a/AGENTS.md b/AGENTS.md index af87734..6800eb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Entry point for any coding agent working in this repository, or using Wikifier on another tree. The day-to-day contract is `skills/run.md` (Agent Protocol v0.7, package 5.0.0). Read that file before substantive work. This file says what is live, what is history, and which rules are easy to miss. +Entry point for any coding agent working in this repository, or using Wikifier on another tree. The day-to-day contract is `skills/run.md` (Agent Protocol v0.7, package 5.1.0). Read that file before substantive work. This file says what is live, what is history, and which rules are easy to miss. ## What this program is diff --git a/AGENTS.md.wiki.md b/AGENTS.md.wiki.md index 6a603af..f300bcd 100644 --- a/AGENTS.md.wiki.md +++ b/AGENTS.md.wiki.md @@ -2,4 +2,4 @@ **Role:** Entry point for any coding agent in this repository. -It points at `skills/run.md` (protocol v0.7, package 5.0.0) and names the live Rust binary, the daily loop, and the Python-era records kept under `Findings/archive/`. `pip install wikifier` is the archived Python 4.7 package. Map roots stay `src/` and `tests/`. The import scan stays comment-masked regex plus named re-exports. Rust also resolves `crate::`, `self::`, `super::`, root crate names, and `[workspace] members`, so `src/main.rs` and `tests/*.rs` are dependents of the library modules they import. `mark-green` checks Rust `pub` names, impl methods, and trait-impl items. +It points at `skills/run.md` (protocol v0.7, package 5.1.0) and names the live Rust binary, the daily loop, and the Python-era records kept under `Findings/archive/`. `pip install wikifier` is the archived Python 4.7 package. Map roots stay `src/` and `tests/`. The import scan stays comment-masked regex plus named re-exports. Rust also resolves `crate::`, `self::`, `super::`, root crate names, and `[workspace] members`, so `src/main.rs` and `tests/*.rs` are dependents of the library modules they import. `mark-green` checks Rust `pub` names, impl methods, and trait-impl items. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e65c2e..5d35231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.1.0] - 2026-11-05 + +Agent-facing fixes found by driving the MCP server like an agent on real repositories, Windows fixes, and a speed pass. Protocol stays v0.7. + +**MCP clients, note:** `session_bootstrap` and `get_project_status` now list at most 100 files unless the call passes `limit` (`files_total` gives the full count, `limit: 0` lists every file), and `update_maps` leaves out the edge list unless `edges: true`. Orient with `map_index` and `list_paths`. The CLI defaults for both commands are unchanged. + +### Fixed +- Windows: `wikifier.bat` passes the binary's exit code through. It read `%ERRORLEVEL%` inside a parenthesized block, so every failure exited 0. +- Windows: health baselines hash CRLF as LF (`content_sha256_prefixed`). A `core.autocrlf` checkout no longer flips every Green file to dirty (51 files in this repo). +- A file argument written with `\` separators or as an absolute path under the root names the same row as `src/a.rs`. Before, an absolute path returned empty lookups and a backslash path created a second health row. +- `prepare_edit`, `get_dependencies`, `get_dependents`, and `get_file_wiki` return `FileNotFound` with a `list_paths` hint for a path that is neither on disk nor in the map, instead of `success: true` with empty fields. +- MCP `tools/call` with a missing required argument (for example `file_path` instead of `file`) returns `InvalidArguments` naming the accepted and received arguments. Before, the tool ran with an empty path and reported success. +- `health --limit N` (and MCP `limit`) caps the entry list and adds `entries_total`. The argument was advertised and ignored. +- `pending_updates.md` no longer says "(no pending items …)" above a list of items. The note is dropped when the first item arrives and comes back when the last one is removed. This was carried over from the Python package. +- A `record-change` pending item reads `- path: — refresh the wiki, then mark-green` instead of the Python wording `LLM/agent edit — `. + +### Performance +Same outputs: on a 587-file Cargo workspace the cache files, `library.md`, folder cards, health hashes, and the `update-maps`, `cycles`, `dependencies --full`, `dependents`, `check-changes`, and `session-bootstrap` output were byte-identical before and after (CLI, timestamps aside). Warm MCP calls there, median of 5 on Windows 11: `check_changes` 353 → 53 ms, `update_maps` 708 → 50 ms, `get_cycles` 317 → 39 ms, `session_bootstrap` 33 → 16 ms and 21.7 → 4.7 KB. Cold `update-maps` (CLI, runs interleaved on a loaded machine, median of 3) went from 18.7 s to 6.4 s. +- Derived cache files (`by_src`, `by_dst`, `payload`, `notes`, barrel records) are written on several threads through a temp file and a rename, so a reader never sees a torn file, and are no longer synced. `wk_cache/sizes.tsv` records each file's length; `update-maps` compares it with one directory listing per kind and treats a missing, resized, or unreadable file as a miss: the source is parsed again or the `by_dst` record is rewritten from the index. Without `sizes.tsv` every file is read and checked once. Lookups that meet a bad record answer from `index.tsv`. On this Windows machine the sync change made no measurable difference to a cold map (median 11.9 s with batched syncs, 12.5 s without, same copy, interleaved, machine at about 80% load): the remaining cost is creating about 2,000 small files. +- `file_health.json`, `file_health.md`, the journal, and `pending_updates.md` are now replaced through a synced temp file and a rename. They were plain overwrites with no sync. +- A full map scans each file once; the second JS/TS/Rust resolution pass reuses those scans. Import line numbers come from one substring search per distinct spec instead of a line-by-line scan per import. JS/TS barrel follow-through builds its sorted scan table once instead of once per imported name. +- `cycles` and `dependencies --full` read edges from `index.tsv` instead of one `by_src` file per mapped file, and `cycles` loads edge flags only for files inside a cycle. `library.md` reads `by_src` records without the notes files. +- `update-maps` keeps `library.md` and the folder cards when the cache commit changed nothing. +- `check-changes` and health seeding take a mapped file's content hash from its cache row when size and mtime match (the rule `update-maps` already used), and read the file otherwise or when that hash is not the baseline. Health rows are found by path through a map, not a scan per file. +- The file walk takes directory and file types from the directory entry instead of two `stat` calls per entry. + +### Changed +- `init` seeds `map_paths.txt` and `monitored_paths.txt` with `src/` when it exists, otherwise with each top-level directory that holds source (a Cargo workspace's `crates/`, a Python package directory, `tests/`). Before, a project without `src/` hit a scope error on the first `update-maps`. +- MCP `update_maps` omits the edge list unless `edges: true`; `edges_persisted` still gives the count. On a 587-file Cargo workspace the list was about 280 KB. The schema now lists `full`, `max_files`, and `edges`; `health` lists `limit` and `summary`. +- `.gitattributes` keeps `*.sh` LF and `*.bat` / `*.ps1` CRLF on every checkout. +- `session-bootstrap --limit N` (MCP `limit`) caps the `files` list and adds `files_total`. Over MCP the default cap is 100; `limit: 0` lists every file. `map_index` and `list_paths` are the way to orient on a large tree. + ## [5.0.0] - 2026-11-03 First release of the std-only Rust rewrite. `[dependencies]` is empty: no crates.io crates and no Python runtime. The binary implements the Python 4.7 agent contract. Protocol v0.7 stays. `pip install wikifier` still installs that Python 4.7 package. diff --git a/CLAUDE.md b/CLAUDE.md index 10137f5..4d2c9d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Wikifier is a **zero-dependency, agent-to-agent codebase wiki**: it gives AI agents a token-efficient, queryable map of a codebase (health matrix, dependency graph, per-file wiki summaries) so they can look things up instead of re-reading full sources. The human layer (`index.html` dashboard) is strictly secondary — a read-only viewer of the same artifacts. It is deliberately NOT a general human documentation tool. -**This repo dogfoods itself.** Wikifier runs on its own source, and you are expected to follow its agent protocol when working here (see below). `AGENTS.md` is the entry point for any coding agent. The authoritative contract is `skills/run.md` (Agent Protocol v0.7, package 5.0.0). Read it before substantive work. +**This repo dogfoods itself.** Wikifier runs on its own source, and you are expected to follow its agent protocol when working here (see below). `AGENTS.md` is the entry point for any coding agent. The authoritative contract is `skills/run.md` (Agent Protocol v0.7, package 5.1.0). Read it before substantive work. **USE WIKIFIER FIRST (non-negotiable in this repo, preferred on any managed target):** At session start call `session_bootstrap` (MCP or `wikifier session-bootstrap`). Prefer Core daily tools (`list_core_tools`): bootstrap, `check_changes`, `prepare_edit`, `suggest_next_actions` (json `actions[]`), `record_change`, `mark_green`. Look up via map/wiki/deps before re-reading full sources. Selective work only (🔴 / actionable 🟡). Never skip `record_change` after edits. @@ -56,12 +56,12 @@ wikifier mcp # MCP server on stdio ## Architecture -Pipeline: **scan → parse → resolve → cache → health → artifacts**, implemented in Rust under `src/` (version in `Cargo.toml`, currently **5.0.0**). The Python package is no longer in this repository. `pip install wikifier` still installs Python 4.7. CLI (`src/main.rs`) and MCP (`src/mcp.rs`) both call `src/api.rs`. Every function returns a JSON object with `success`. Operational failures return `{"success": false, "error", "error_type"}`. +Pipeline: **scan → parse → resolve → cache → health → artifacts**, implemented in Rust under `src/` (version in `Cargo.toml`, currently **5.1.0**). The Python package is no longer in this repository. `pip install wikifier` still installs Python 4.7. CLI (`src/main.rs`) and MCP (`src/mcp.rs`) both call `src/api.rs`. Every function returns a JSON object with `success`. Operational failures return `{"success": false, "error", "error_type"}`. - `src/api.rs` — the library surface: `session_bootstrap`, `check_changes`, `prepare_edit`, `suggest_next_actions`, `record_change`, `record_changes`, `record_deletion`, `mark_green`, `verify_wikis`, `update_maps` / `update_maps_run`, `dependency_report`, `load_dependents`, `cycles`, `init_project`, `serve_loop`, `monitor_loop`, `daemon_ctl`. `check_changes` watches mapped files, Green files, and `monitored_paths.txt`. `record_changes` reads `git status` (git is optional). - `src/scope.rs` — project root, `map_paths.txt`, `monitored_paths.txt`, excludes, and the file walk. A missing configured map directory is a scope error. Three or more child projects make the root a container and the walk stops. - `src/parse.rs` — comment-masked regex scans for Python, JS/TS, Rust, Go, C/C++, C#, and Java. Named JS/TS re-exports are followed to depth 8 (`via_barrel`). An indented Python import is `conditional`. `import()` is `dynamic`. JS/TS resolution reads `tsconfig.json` `paths`, `package.json` `exports`, and npm `workspaces`. Rust resolves `mod`, `crate::`, `self::`, `super::`, and package or lib names from the root `Cargo.toml` and `[workspace] members` (`-` to `_`, globs included). `crate::` stays in the file's crate; a member name points at that member's library. This is not a CPython `ast` parser. The accuracy gate is `accuracy_fixtures_match_frozen_edges`. -- `src/cache.rs` + `src/cache_commit.rs` — text index at `.wikifier_staging/wk_cache` (per-file payload, `by_src`, `by_dst`, notes, barrels). One commit per `update-maps`. Size and mtime skip unchanged files. `--full` rehashes. `src/legacy_cache.rs` imports an empty cache from `import_cache.sqlite` (the `sqlite3` CLI) or `import_cache.json`. +- `src/cache.rs` + `src/cache_commit.rs` — text index at `.wikifier_staging/wk_cache` (per-file payload, `by_src`, `by_dst`, notes, barrels). One commit per `update-maps`. Size and mtime skip unchanged files. `--full` rehashes. Cache files are derived: they are renamed into place but not synced, `wk_cache/sizes.tsv` records their lengths, and a missing, resized, or malformed file is a miss that the next `update-maps` re-parses. Health, journal, and pending writes are synced. `src/legacy_cache.rs` imports an empty cache from `import_cache.sqlite` (the `sqlite3` CLI) or `import_cache.json`. - `src/artifacts.rs` — `file_health.json` (schema version 2) plus the `file_health.md` view, pending lines, and the journal. Identical journal retries within 120 seconds are skipped. `mark_green` stores `public_symbols`, `wiki_hash`, and `source_mtime_ns`, and refuses a Python or JS/TS wiki that misses the current public names, still mentions a removed name in backticks, or is unchanged after the public surface changed. - `src/maps_out.rs` — `library.md` (a file tree, a Mermaid block capped at 80 edges, and the dependency table) and sharded folder cards at `.wikifier_staging/maps/_root.json` and `maps/{prefix}/_index.json`. - `src/project_lock.rs` — advisory lock `.wikifier_staging/.lock`, taken before the cache lock, re-entrant per thread, 30 second wait, stolen when the recorded pid is dead. diff --git a/CLAUDE.md.wiki.md b/CLAUDE.md.wiki.md index d9a3c6b..de3bfb6 100644 --- a/CLAUDE.md.wiki.md +++ b/CLAUDE.md.wiki.md @@ -2,4 +2,4 @@ **Role:** Repo guidance for an agent working in this tree. -Hard constraints: empty Cargo dependencies, no hand-edits of `library.md` or the health and pending files, and the human dashboard stays secondary. The Python-era plan logs live under `Findings/archive/`. It points agents at `AGENTS.md` and `skills/run.md` (protocol v0.7, package 5.0.0). The architecture section names the Rust modules under `src/`, the library file tree, and this repo's map roots `src/` and `tests/`. The parse note includes Rust `crate::`, `self::`, `super::`, Cargo.toml crate names, `[workspace] members`, and `rust_public_symbols` for wiki checks. JSON failures use `success: false`. +Hard constraints: empty Cargo dependencies, no hand-edits of `library.md` or the health and pending files, and the human dashboard stays secondary. The Python-era plan logs live under `Findings/archive/`. It points agents at `AGENTS.md` and `skills/run.md` (protocol v0.7, package 5.1.0). The architecture section names the Rust modules under `src/`, the library file tree, and this repo's map roots `src/` and `tests/`. The parse note includes Rust `crate::`, `self::`, `super::`, Cargo.toml crate names, `[workspace] members`, and `rust_public_symbols` for wiki checks. JSON failures use `success: false`. diff --git a/Cargo.lock b/Cargo.lock index 6c8c4a1..f00ad76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "wikifier" -version = "5.0.0" +version = "5.1.0" diff --git a/Cargo.toml b/Cargo.toml index 93878da..5c8fc61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wikifier" -version = "5.0.0" +version = "5.1.0" edition = "2021" description = "Wikifier — zero-dependency agent-to-agent codebase wiki." license = "MIT" diff --git a/README.md b/README.md index 2848465..46b94a4 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,13 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub Stars](https://img.shields.io/github/stars/IronAdamant/wikifier?style=social)](https://github.com/IronAdamant/wikifier/stargazers) +**A zero-dependency codebase wiki for AI coding agents.** Wikifier keeps a small, queryable map of a project: a dependency graph, a health matrix that says which file notes are current, short per-file wiki notes, and a journal of why each file changed. An agent asks "who imports this file?" or "what changed since the last session, and why?" and gets a few hundred bytes of JSON instead of re-reading sources. + +It is one std-only Rust binary (empty `[dependencies]`) with a CLI and an MCP server (`wikifier mcp`). Agents keep the map current as they work. A small HTML dashboard exists for humans, but the product is the agent loop, not a docs site. + ## Install -Download a binary from the [GitHub releases](https://github.com/IronAdamant/wikifier/releases). A `v*.*.*` tag publishes these archives and a `SHA256SUMS` file: +Download a binary from the [GitHub releases](https://github.com/IronAdamant/wikifier/releases). Each `v*.*.*` release has these archives and a `SHA256SUMS` file: | Platform | Archive | |---|---| @@ -14,18 +18,18 @@ Download a binary from the [GitHub releases](https://github.com/IronAdamant/wiki | macOS Intel | `wikifier--x86_64-apple-darwin.tar.gz` | | Windows x86_64 | `wikifier--x86_64-pc-windows-msvc.zip` | -Check `SHA256SUMS`, unpack, and put `wikifier` (`wikifier.exe` on Windows) on your `PATH`. +Check `SHA256SUMS`, unpack, and put `wikifier` (`wikifier.exe` on Windows) on your `PATH`: ```bash -# Linux x86_64, 5.0.0 -curl -fsSL -O https://github.com/IronAdamant/wikifier/releases/download/v5.0.0/SHA256SUMS -curl -fsSL -O https://github.com/IronAdamant/wikifier/releases/download/v5.0.0/wikifier-5.0.0-x86_64-unknown-linux-gnu.tar.gz +# Linux x86_64, 5.1.0 +curl -fsSL -O https://github.com/IronAdamant/wikifier/releases/download/v5.1.0/SHA256SUMS +curl -fsSL -O https://github.com/IronAdamant/wikifier/releases/download/v5.1.0/wikifier-5.1.0-x86_64-unknown-linux-gnu.tar.gz sha256sum --check --ignore-missing SHA256SUMS -tar -xzf wikifier-5.0.0-x86_64-unknown-linux-gnu.tar.gz +tar -xzf wikifier-5.1.0-x86_64-unknown-linux-gnu.tar.gz ./wikifier --version ``` -Or build from source. `[dependencies]` is empty: +Or build from source (Rust 1.83 or newer): ```bash git clone https://github.com/IronAdamant/wikifier @@ -34,106 +38,69 @@ cargo build --release ./target/release/wikifier --version ``` -`pip install wikifier` still installs the archived Python 4.7 package. - -**A zero-dependency codebase wiki for AI agents.** Token-efficient maps so LLMs look things up instead of re-reading full sources. - -Wikifier is an **agent-to-agent** tool: it builds a living map of a project (health matrix, dependency graph, short file summaries) and agents keep that map current as they work. Humans can peek via a small dashboard; the product is the agent loop, not a general docs site or IDE. - -Agents working in this repository start at [`AGENTS.md`](AGENTS.md). The day-to-day contract is [`skills/run.md`](skills/run.md). - -Works from small scripts to large monorepos. **Deep import/include maps** (zero-dependency parsers): - -| Language | Extensions | Notes | -|----------|------------|--------| -| Python | `.py` | comment-masked regex; relative imports and same-tree module paths; an indented import is flagged conditional | -| JavaScript / TypeScript | `.js` `.ts` `.jsx` `.tsx` `.mjs` `.cjs` `.mts` `.cts` | comment-masked regex; named re-exports followed to depth 8; `import()` flagged dynamic | -| Rust | `.rs` | `mod`, `use`, `crate::`, `self::`, `super::`, and package or lib names from the root `Cargo.toml` and from `[workspace] members` (globs included). `-` becomes `_`. A member name points at that member's library. `crate::` stays inside the crate that owns the file, including `tests/` and `src/bin/` | -| Go | `.go` | `import` / import blocks | -| C / C++ | `.c` `.h` `.cpp` `.cc` `.cxx` `.hpp` `.hh` | `#include` (local + system) | -| C# | `.cs` | `using` namespaces | -| Java | `.java` | `import` / `import static` | +`wikifier.sh`, `wikifier.ps1`, and `wikifier.bat` run that build from a checkout (`WIKIFIER_BIN` overrides the path). `pip install wikifier` installs the archived Python 4.7 package, not this program. -JavaScript and TypeScript resolution also reads `tsconfig.json` `paths`, `package.json` `exports`, and npm `workspaces`. Health/journal still work for any monitored path. The non-Python parsers are pragmatic regex (not full cargo/`go.mod`/classpath/`-I` resolution). On huge monorepos, split scope deliberately: +## Connect an agent (MCP) -| File | Surface | -|------|---------| -| **`map_paths.txt`** | Package roots for **import maps** (`update-maps` walk). Prefer package dirs (`src/`, `packages/foo/`) — not a wiki-only file list. | -| **`monitored_paths.txt`** | **Wiki / health** watch list (can be individual `.md` files). Does **not** define the map. | - -Or pass `--directory=pkg/` / `--max-files=N` per run. Raise dirty cap with `WIKIFIER_CHECK_CHANGES_MAX` (default 2000) only when needed. Never set `project_root` to a multi-repo parent of clones. - -## Why - -Context windows are finite. Re-reading a large file to answer “what is this and who depends on it?” wastes tokens. - -| Artifact | Role | -|----------|------| -| `file_health.md` | 🟢 / 🟡 / 🔴 matrix — what to trust, what to fix first | -| `library.md` | File tree, Mermaid dependency map, import tables, cycles + confidence | -| `*.wiki.md` | Short per-file “what this is for” notes (**agent-maintained** prose) | -| `journal/` + `pending_updates.md` | Semantic *why* trail + work queue (audit, **not** a full issue tracker) | - -**Map first, wiki depth second:** `update-maps` builds the structural map automatically. Rich per-file wiki text is filled by agents as they work — not a free full-repo “understand everything” pass on init. - -## First run (bootstrap the map) +The server speaks newline-delimited JSON-RPC on stdio. With Claude Code: ```bash -cargo build --release # std-only Rust binary — no crates.io dependencies -./target/release/wikifier --version - -cd /path/to/your/project -./path/to/wikifier init # seeds lean path-list templates -# Edit monitored_paths.txt + map_paths.txt to package roots (not bare ".") on real trees -wikifier update-maps # full structural map → library.md + import cache -wikifier health --summary # matrix counts -wikifier suggest-next # or MCP suggest_next_actions — 🔴/🟡 only +claude mcp add wikifier -- wikifier mcp ``` -Always set an explicit root for external trees: `WIKIFIER_PROJECT_ROOT=/abs/path wikifier …` +Or add it to a project's `.mcp.json`: -**MCP `session_bootstrap` → `readiness: blocked`?** That means lean scope and/or the map are missing (often bare `.` monitor + never ran `update-maps`) — not a broken install. Fix: write lean `monitored_paths.txt` / `map_paths.txt`, then `update-maps`. Agent contract: **`skills/run.md`** § *Readiness*. +```json +{ + "mcpServers": { + "wikifier": { "command": "wikifier", "args": ["mcp"] } + } +} +``` -## Steady state (only touch what needs it) +Every tool takes `project_root` (an absolute path). Without it the server uses `WIKIFIER_PROJECT_ROOT`, then its working directory. A call with a missing argument or an unknown file fails with a message that names the fix, and a failed call sets `isError`. To keep replies small, MCP `session_bootstrap` lists at most 100 files unless you pass `limit` (`files_total` gives the full count, `limit: 0` lists every file), and `update_maps` leaves out the edge list unless you pass `edges: true`. Then give the agent the short prompt in [`skills/run.md`](skills/run.md#paste-into-new-sessions). Any agent that can run shell commands can use the CLI instead: the daily tools have twins such as `wikifier session-bootstrap`, `prepare-edit`, and `record-change` that print the same JSON (the mapping is in `skills/run.md`). -Full protocol: [`skills/run.md`](skills/run.md) (Agent Protocol v0.7 — package **5.0.0**). +## Quick start ```bash -wikifier session-bootstrap # one-shot: root, health, attention, actions[], names-only map_index -wikifier check-changes # content-honest dirty; red ghosts (missing paths) -# prioritize 🔴 then *actionable* 🟡 — do NOT re-wiki 🟢 Green files -wikifier prepare-edit path/file.py # wiki + status + deps/dependents preflight -# ... edit only those sources ... -wikifier record-change "path/file.py" "why this changed" # required -# ... refresh that file’s wiki summary only ... -wikifier mark-green "path/file.py" # refused if the wiki misses public symbols you added/removed -# many files at once (reasons per path, glob or dir/; git supplies the file list): -wikifier record-changes -r "src/api/=retry on 429" -r "tests/=cover retries" --only src/ --only tests/ --green -wikifier update-maps # only if imports/structure changed (warm 0-dirty is cheap) -# removals: -wikifier record-deletion "path/gone.py" "why removed" +cd /path/to/project +wikifier init # path lists (src/, or the top-level dirs that hold source), empty health and journal +wikifier update-maps # parse imports, write the map and library.md +wikifier session-bootstrap # readiness, health counts, next actions[] + +wikifier prepare-edit src/api.rs # before an edit: status, wiki, dependencies, dependents +# ... edit src/api.rs ... +wikifier record-change src/api.rs "retry on 429 (task-12)" # the why goes to the journal +# ... update src/api.rs.wiki.md ... +wikifier mark-green src/api.rs # refused while the wiki misses a public symbol ``` -**Core 6** (prefer every session — MCP or library/CLI): -`session_bootstrap` → `check_changes` → `prepare_edit` → `suggest_next_actions` (json `actions[]`) → `record_change` → `mark_green`. +A second agent that starts cold runs `session-bootstrap`: files left Yellow come back as `actions[]` with the first agent's reason, and `search-journal` / `why-file` return the recorded reasons for any file. The full loop, JSON shapes, and every command are in [`skills/run.md`](skills/run.md). -Advanced intel as needed: `list_paths` (names-only folder expand), `get_dependencies`, `get_dependents`, `get_cycles`, barrels/diagnostics. Always pass `project_root=` / `WIKIFIER_PROJECT_ROOT` for external trees. **Never** point `project_root` at a multi-repo parent folder (e.g. a directory of clones). +## How it works -**Map split:** `update-maps` writes two views from the same import cache — human `library.md` (File Tree + mermaid, dashboard only) and sharded agent folder cards (`.wikifier_staging/maps/`, depth-1). Bootstrap returns the root card; `list_paths` expands one folder; `prepare_edit` follows file-to-file links. Do **not** read `library.md` mermaid for orientation. +`update-maps` runs **scan → parse → resolve → cache → artifacts**. It walks the directories in `map_paths.txt`, parses imports with comment-masked scanners, resolves them to project files, and stores edges in a text index under `.wikifier_staging/`. Unchanged files are skipped by size and mtime, so a warm run is fast. + +| Artifact | What it holds | +|---|---| +| `file_health.md` / `.json` | 🟢 Green (wiki checked against the code), 🟡 Yellow (changed, or no wiki yet), 🔴 Red (deleted) per file | +| `*.wiki.md` | Short agent-written note beside each source file | +| `journal/` + `pending_updates.md` | Why each file changed, and the work queue | +| `library.md` + `.wikifier_staging/maps/` | File tree and dependency tables; folder cards that `list-paths` returns | -## What you get +`check-changes` compares files with their Green baselines. `mark-green` checks that the wiki names the file's public Python, JS/TS, or Rust symbols. Other languages are mapped and recorded as `unchecked`. `map_paths.txt` lists the import-map roots; `monitored_paths.txt` adds extra files to watch, such as docs. -- **Import analysis** — comment-masked regex for Python, JS/TS, Rust, Go, C/C++, C#, and Java. Named JS/TS re-exports are followed to depth 8. Edges carry conditional, dynamic, and via-barrel flags, and a confidence read from those flags. -- **Incremental pipeline** — `update-maps` parses files whose size or mtime changed, then writes the `wk_cache` text index, reverse edges, `library.md`, and sharded folder cards. -- **Two path lists** — `map_paths.txt` = map package roots; `monitored_paths.txt` = extra watch list (docs, scripts). `check-changes` always watches mapped files and anything ever marked Green, so a narrow list never hides edits. -- **Green you can trust** — `mark-green` checks the wiki against Python, JS/TS, and Rust public symbols and refuses when a public name is missing, a removed name is still in backticks, or the public surface changed while the wiki file did not. Rust names are unrestricted `pub` items, including methods and associated items on a public type and the items written in a trait impl for that type. `pub(crate)`, `pub(super)`, `pub(self)`, and `pub(in ...)` stay out of that set. Go, C/C++, C#, and Java are recorded as unchecked. `verify-wikis` re-checks every Green wiki. `health --summary` includes `green_breakdown`. -- **Partial-map honesty** — `--max-files` returns `map_complete=false` and `files_remaining_dirty`. Run the same command again to resume. Already-parsed files are stat-skipped. -- **Cache** — `.wikifier_staging/wk_cache`. `wikifier cache-status` reports the backend and the file count. An empty cache imports `import_cache.sqlite` through the `sqlite3` CLI, or `import_cache.json`. -- **Selective agent work** — health and `suggest-next` stay on 🔴 and actionable 🟡. Diagnostics list ACS reason codes and `actionable_low_conf_edges`. -- **Scale** — dependents and dependencies read one edge index. Unchanged files are skipped by size and mtime. `--full` rehashes. -- **MCP** — `wikifier mcp` in the same binary. A failed tool call sets `isError`. Calls stop at `WIKIFIER_MCP_DEADLINE_S` (default 60 seconds). -- **Zero core dependencies** — empty `[dependencies]` in `Cargo.toml`. -- **Honest failures** — a missing `project_root`, missing file, or lock timeout is an error result, never a silent fallback. `wikifier.sh` launches the Rust binary. +| Language | Extensions | Import scan | +|---|---|---| +| Python | `.py` | relative and same-tree absolute imports; an indented import is flagged conditional | +| JavaScript / TypeScript | `.js` `.ts` `.jsx` `.tsx` `.mjs` `.cjs` `.mts` `.cts` | named re-exports followed to depth 8; `import()` flagged dynamic; reads `tsconfig.json` `paths`, `package.json` `exports`, npm `workspaces` | +| Rust | `.rs` | `mod`, `crate::`, `self::`, `super::`, and crate names from `Cargo.toml` and `[workspace] members` | +| Go | `.go` | `import` and import blocks | +| C / C++ | `.c` `.h` `.cpp` `.cc` `.cxx` `.hpp` `.hh` | `#include` | +| C# | `.cs` | `using` | +| Java | `.java` | `import`, `import static` | + +The scanners are pragmatic regex, not compilers. On a very large tree, list package roots in `map_paths.txt`, or pass `--directory` / `--max-files` (a budgeted run returns `map_complete=false` and the next run resumes). Never point the project root at a folder that holds several repositories. ## Performance (measured) @@ -189,59 +156,18 @@ Small is three modules under `src/pkg`. Medium is `src/` and `tests/`. Large is Tests: `cargo test` (std-only Rust; accuracy fixtures, cache lookups, and the agent-loop commands). -## Commands - -| Command | Purpose | -|---------|---------| -| `wikifier init [--target DIR]` | Bootstrap project + human `index.html` | -| `wikifier session-bootstrap` | Session start: health, attention, `actions[]`, names-only `map_index` | -| `wikifier check-changes` | Content-honest scan → health / pending | -| `wikifier prepare-edit ` | Preflight: status, wiki snippet, deps, dependents | -| `wikifier list-paths [prefix]` | Depth-1 folder card (names only). `--recursive` / `--depth=0` for a subtree. Then `prepare-edit` for file links. | -| `wikifier record-change "reason"` | Log *why* (required after edits) | -| `wikifier mark-green [--force]` | Check the wiki against the code, then mark it current (`--force` + reason overrides) | -| `wikifier record-changes [-r PATH=WHY]… [--only P]… [--green] [--dry-run]` | Record every file git reports as changed, reasons per path/glob/`dir/`; nothing written if any reason is missing | -| `wikifier verify-wikis [dir]` | Re-check every Green wiki against its code (exit 1 on failures) | -| `wikifier record-deletion "reason"` | Mark removed paths 🔴, drop them from the graph, prune barrel refs | -| `wikifier dependencies [--full]` / `dependents ` | What a file imports (compact; `--full` for per-edge confidence records) / who imports it (JSON) | -| `wikifier suggest-next` | Next actions (JSON `actions[]`; `--text` for prose). Includes low-confidence edges when any exist | -| `wikifier update-maps [--directory=src/] [--max-files=N]` | Rebuild graph + human `library.md` + agent folder maps (warm 0-dirty is fast; honors `map_paths.txt`) | -| `wikifier cache-status` | `wk_cache` backend and mapped file count | -| `wikifier health [--summary\|--format text]` | Health matrix. Default is JSON. `--summary` is counts only. `--text` or `--format text` is prose | -| `wikifier validate` | Missing wiki rows + ghost paths | -| `wikifier cycles [--no-analysis] [--max-items N]` | Circular deps, break hints, severity, and who is most depended on | -| `wikifier heal-stubs [--dry-run]` | Promote Initial stubs whose wiki is at least 350 characters | -| `wikifier monitor` / `daemon` | Background maintenance (`WIKIFIER_DAEMON_MAPS=0` for check-only) | -| `wikifier serve` | Localhost dashboard with Run/Stop | - -The same operations live in `src/api.rs`. Agents call the CLI or `wikifier mcp`. `wikifier.sh` / `wikifier.ps1` / `wikifier.bat` launch the Rust binary (`WIKIFIER_BIN` overrides the path). `pip install wikifier` still installs the archived Python 4.7 package. - -## MCP - -```bash -WIKIFIER_PROJECT_ROOT=/abs/path/to/project wikifier mcp -``` - -This repo's `.mcp.json` launches `wikifier` with argument `mcp`. Tool names and the daily loop are in [`skills/run.md`](skills/run.md). - -## Human dashboard (secondary) +## Human dashboard ![Wikifier dashboard — file tree, health pills, local Run/Stop](https://raw.githubusercontent.com/IronAdamant/wikifier/main/screenshot/front_page_review.png) -`wikifier init` drops a single `index.html`. Prefer **`wikifier serve`** (e.g. http://localhost:8787/index.html) — `file://` can’t load project files. The markdown artifacts and CLI/MCP tools stay the source of truth; the UI is a read-only window. - -`wikifier serve` also exposes a read-only JSON API (`/__wikifier/api/file?path=…`, `dependencies`, `dependents`, `cycles`, `journal`, `bootstrap`, `diagnostics`) that the dashboard uses for per-file dependencies and history. This repo also contains a proposed redesign, `index.v2.html` and `diagnostics.v2.html`, to compare side by side with the current pages (`http://localhost:8787/index.v2.html`); they are not deployed by `init`. - -## Scope - -**In:** agent-maintained codebase wiki, dependency intelligence, token-saving lookup for LLMs and coding agents. -**Out:** general human documentation systems, IDE plugins, “docs for everyone” product growth. +`wikifier init` copies one `index.html`. Run `wikifier serve` and open http://localhost:8787/index.html. It is a read-only view of the same artifacts. -**Agent navigability:** Prefer the protocol ([`skills/run.md`](skills/run.md)) and the MCP Core 6 over reading the parsers or the cache. Tests live in `tests/*.rs`. +## Documentation -## Links +- [`skills/run.md`](skills/run.md): the agent protocol (v0.7): the daily loop, readiness, JSON shapes, and every command +- [`AGENTS.md`](AGENTS.md): start here when working on this repository +- [`CONTRIBUTING.md`](CONTRIBUTING.md): adding a command or a language +- [`docs/spec.md`](docs/spec.md): original requirements and core principles +- [`CHANGELOG.md`](CHANGELOG.md) · [Releases](https://github.com/IronAdamant/wikifier/releases) -- [Releases](https://github.com/IronAdamant/wikifier/releases) · [PyPI](https://pypi.org/project/wikifier/) · [GitHub](https://github.com/IronAdamant/wikifier) -- Agent protocol: [`skills/run.md`](skills/run.md) -- Changelog: [`CHANGELOG.md`](CHANGELOG.md) -- Dogfood notes: `Findings/` (historical plans and research in `Findings/archive/`) +Scope: an agent-maintained codebase wiki for token-efficient lookup. General human documentation and IDE plugins are out of scope. diff --git a/README.md.wiki.md b/README.md.wiki.md index cd1e259..31239a4 100644 --- a/README.md.wiki.md +++ b/README.md.wiki.md @@ -2,4 +2,4 @@ **Role:** The public entry point for the std-only Rust program. -Install is a release archive (`wikifier--`, plus `SHA256SUMS`) or `cargo build --release`. `pip install wikifier` still installs Python 4.7. Agents start in this repo at `AGENTS.md` and follow `skills/run.md` (protocol v0.7, package 5.0.0). Bootstrap a target with `wikifier init`. The language table describes the comment-masked regex scans, including Rust crate-name imports. The lookup benchmark that cited a missing `scripts/benchmark_lookup.py` is gone. The performance table that remains says its numbers are from the Python package. `mark-green` checks Rust `pub` items, impl methods, and trait-impl items, as well as Python and JS/TS. Rust also reads `[workspace] members`. MCP is `wikifier mcp`. +Order: what it is, install (release archive `wikifier--` plus `SHA256SUMS`, or `cargo build --release`), connecting an agent over MCP (`claude mcp add wikifier -- wikifier mcp` or `.mcp.json`, `project_root` on every call), a quick start of the edit loop, how it works (pipeline, artifacts, language table), then the measured Performance and Benchmarks tables (unchanged; the Performance table says its numbers are from the Python package), the dashboard, and links. Detail lives in `skills/run.md` (protocol v0.7, package 5.0.0) and `AGENTS.md`. `pip install wikifier` still installs Python 4.7. diff --git a/file_health.json b/file_health.json index 4a75859..893bbe2 100644 --- a/file_health.json +++ b/file_health.json @@ -1 +1 @@ -{"entries":{".github/workflows/publish.yml":{"last_updated":"2026-11-03 09:27:04","reason":"Multi-target release archives and SHA256SUMS (subid=release-5.0.0)","source_content_hash":"sha256:930170e1eb57afbe2850a0e9c2ac179eb2b5947bf2f74d4292bcd2b0099ab8de","source_mtime_ns":"1793697915725124882","status":"🟢 Green","verification":"no_wiki"},"AGENTS.md":{"last_updated":"2026-11-03 09:27:04","reason":"Package line is 5.0.0; pip remains Python 4.7 (subid=release-5.0.0)","source_content_hash":"sha256:f2db15364d5b8b309d03a37fa123c55eb73103316632d2d0b3418205fea5625e","source_mtime_ns":"1793697832705476376","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:d82ebf68153d48b828a60ddafa7e0f4ed00095141048f3014da7e520d994cde0"},"CHANGELOG.md":{"last_updated":"2026-11-03 09:27:04","reason":"5.0.0 Rust release notes and benchmark link (subid=release-5.0.0)","source_content_hash":"sha256:799b5a8acb15bd3ddcd35d9812d7014bab833bbab5dd11b51a580c7da107e729","source_mtime_ns":"1793697832121478133","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:757601288624e346279cef86383a88cdf7c1c467e7c6538f40c05d475e243de8"},"CLAUDE.md":{"last_updated":"2026-11-03 09:27:04","reason":"Package line is 5.0.0 (subid=release-5.0.0)","source_content_hash":"sha256:2734c85af757bf0b873c3cc5ced86347f9042e37a90976023fbdbe43a6dbeda3","source_mtime_ns":"1793697832749476242","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:8991f81c78306a55befa7ef13876879fc5c22cd58ee6013567db4133afa14d34"},"CONTRIBUTING.md":{"last_updated":"2026-11-03 05:34:35","reason":"Address the 3 Nov review findings (subid=review-2026-11-03)","source_content_hash":"sha256:d090280cd3d1c3ab1cf342d69392471ed3835512ff7381bf4409bada57bbe89c","source_mtime_ns":"1793683924316411256","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:ec0bd9342b8e9dfeefaabc8dcddeb577f6bd4f1c760d7286b21d709c2a372b29"},"Cargo.lock":{"last_updated":"2026-11-03 09:27:04","reason":"Lockfile version 5.0.0 (subid=release-5.0.0)","source_content_hash":"sha256:eb5e5f51e2a4fd584bd48af18328f7eaaae1efc4ee9fb23743d85a34faa8b435","source_mtime_ns":"1793697831949478649","status":"🟢 Green","verification":"no_wiki"},"Cargo.toml":{"last_updated":"2026-11-03 09:27:04","reason":"Version 5.0.0 (subid=release-5.0.0)","source_content_hash":"sha256:d16350a4d796d62eac12d64651ff9e9ae843db94489bfa4fb67c4a657b31b288","source_mtime_ns":"1793697832013478456","status":"🟢 Green","verification":"no_wiki"},"Findings/M5-Dogfood-Assessment-Report.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:238435188b1699e5bcf4753b62a6fe55cb064413a291259f0b696b5bd3c1692f","source_mtime_ns":"1780519956000000000","status":"🟢 Green"},"Findings/M5-Dogfood-Progress.md":{"last_updated":"2026-11-03 08:06:46","reason":"Wiki marks this file as the historical M5 log (subid=protocol-v07)","source_content_hash":"sha256:ef55167e819806060dfd7dfe7cdfd4d02024ca97706a7faa243710e2630b65fe","source_mtime_ns":"1793408707259550076","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:b6b1fa4bbee0c7e8c4035cd925f6d07e1221ba483e3472878d5b88236c5cf7f9"},"Findings/M5.1-cross-hardening-analysis.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:a2c2414e5b6c9b1a802bec8734a7924850d546cd7746a8087216f3685ca36cb4","source_mtime_ns":"1780519762000000000","status":"🟢 Green"},"Findings/Milestones-Overview.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:f8b7030ec8980774cf3a2560ad7aee6537263eadf8374e650718f7c6793c6d50","source_mtime_ns":"1780800604000000000","status":"🟢 Green"},"Findings/p6_real_world_validation_report.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:0468224675888f2747674324726c0ea9a5dddded39d9a1a50f22fc3357dafc0f","source_mtime_ns":"1780520008000000000","status":"🟢 Green"},"README.md":{"last_updated":"2026-11-03 09:27:04","reason":"Install leads with release archives; package 5.0.0 (subid=release-5.0.0)","source_content_hash":"sha256:040de72ecff9502785073970d2d04dc8fa07da8d440b9e9dc6d8f0253655bd8e","source_mtime_ns":"1793697834025472402","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:db9ac3864b0bfcee45ebc1fc34622dabaa5ba34d49baa171626997c3e03c677f"},"docs/spec.md":{"last_updated":"2026-11-03 08:00:11","reason":"Name the Rust binary and protocol v0.7 in the living principles (subid=protocol-v07)","source_content_hash":"sha256:31fe6b3b3d705628692eb0512cf44cbb21861ccfc5bb02afdd46d796ec066c6e","source_mtime_ns":"1793653008580824356","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:a58b929d25d510f15f9fe176d7823e852f6d089e398accc2e22e1ed189f260ac"},"scripts/benchmark_parity.sh":{"last_updated":"2026-11-03 08:56:15","reason":"Benchmark script and measured results.","source_content_hash":"sha256:b8f72e3245e77b9df17b303293d8cbe4a93cc36023c41b89a794b77b01daef87","status":"🟡 Yellow"},"scripts/benchmark_results.md":{"last_updated":"2026-11-03 08:56:15","reason":"Benchmark script and measured results.","source_content_hash":"sha256:8bf9342fce85f126a37f812b03b6f6997f1fe8e7c5176d30301c1a4a15aeee29","status":"🟡 Yellow"},"skills/run.md":{"last_updated":"2026-11-03 09:27:04","reason":"Package line is 5.0.0; protocol stays v0.7 (subid=release-5.0.0)","source_content_hash":"sha256:2ab1c890e39e1b499ebcfd4dd4a83733450369af6ef922ddf4fb4469b63b6b54","source_mtime_ns":"1793697833221474822","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:3956ae27f074aa9f8a94c643d2c8fb9ab56c38eb75e47a9bd727ad5de38ea4c7"},"src/api.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"MapRun\nVERSION\nMCP_TOOLS\nupdate_maps\nupdate_maps_opts\nupdate_maps_run\nmapped_edges\nload_dependencies\ndependency_report\nload_dependents\nlist_paths\nsession_bootstrap\nsuggest_next_actions\ncheck_changes\nprepare_edit\nrecord_change\nmark_green\nhealth\nread_health\nread_journal\ncache_status\nvalidate\ncycles\nverify_wikis\nrecord_changes\nrecord_changes_opts\nrecord_deletion\nheal_stubs\nprune_health\nprune_health_opts\nprune_pending\nstale_wikis\nprune_barrels\nmetrics_snapshot\nhealing_stats\nresolution_diagnostics\nbarrel_reports\nincremental_status\nserve_once\nserve_loop\nmonitor_loop\ndaemon_ctl\ndispatch\nCLI_COMMANDS\nensure_root","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:8b23c099bfab315e96c055c0bcaf7d7edceb9e40420adc2d21a8ba930abbf1ef","source_mtime_ns":"1793684003575439733","status":"🟡 Yellow","wiki_hash":"sha256:69ac1101639d23ac6893adf37e501a820e277ace0ddeaf3c2c80afba28557e54"},"src/artifacts.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"HealthRow\nread_health\nwrite_health\nupsert_health_row\nJournalEntry\njournal_path\nread_journal\nparse_journal\nJournalWrite\nappend_journal\nread_pending\nfolder_card\nnew\nkeep_baseline_from","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:6a7ca10120cbb4a37de62999330a92b181f3ebd7210f92209b280ba5282d8544","source_mtime_ns":"1793678007374624809","status":"🟡 Yellow","wiki_hash":"sha256:75bf0f72cc4bd57b6178dc6a0a58b8bf97bc3e7265485a28102093b967f20181"},"src/cache.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"check_consistency\nrebuild\nConsistency\nSession\npayload_path\nby_src_path\ncommit_pending_path\nload_dependencies\nInternalEdge\nconfidence_of\nformat_edge_line\nDependencyView\nload_dependency_view\nload_dependents\nlist_prefix\nall_paths\nFileUpdate\nupsert_file\nremove_file\nindexed_rows\npayload_bytes\nstat_ns\nedges\nrel_string\nwrite_barrel_record\nprune_barrel_files\nconfidence\nacs_reason","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:fb8a06997639e0f1556cc64ce5792f1525b0cb3c4afab5f9cbfcaeb9c809adf4","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:afb7493ff8742682f5f3c743da8919408502e6a6e75bba9998eb59cbbaf1c02a"},"src/cache_commit.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"Consistency\nSession\ncheck_consistency\nrebuild\nopen\nknown\ndsts_of\nnote_stat\nrels\nupsert\nremove\nstage_barrel\ncommit","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:c987ccdccdd0c980c0af64a2dcb349cd3b4ed1de7070a481dba146782f40a745","source_mtime_ns":"1793684429116993727","status":"🟡 Yellow","wiki_hash":"sha256:c6f3fbdcd6aafe62de119c302e09e11ae0ea208681c52166cfcf79de80bb5f6c"},"src/cycle_report.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:db35b610d9a9f9917805da9383e61872e66e640482f57fc9e17ae91daf77fe3d","status":"🟡 Yellow"},"src/daemon_svc.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:0887baeb3ad7f2fca851fccc0d9335dafd4b42f3e54e236175a13d39c5bcd063","status":"🟡 Yellow"},"src/jsonutil.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"Json\nparse\nparse_jsonc\nstrip_json_comments\nobj\nfrom_pairs\nget\nas_str\nas_bool\nas_array\nas_object\ninsert\nfmt","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:cb71e00e352df010928a42323d6d87bbe67348834a209348fc174b9d00489676","source_mtime_ns":"1793684429116993727","status":"🟡 Yellow","wiki_hash":"sha256:fb4cffba8b14a179dd90f201b61df2608ca78a9775e9646e7f011f1b9fa07c63"},"src/legacy_cache.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"import_if_empty","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:a127d0b1742d69b736194ca98579f5fc0611fc51a291d691aef6fe586fd68ba3","source_mtime_ns":"1793683981803718735","status":"🟡 Yellow","wiki_hash":"sha256:a6fe6c79185a46cc4b30d215475bab28f517f6e5a6fd7bce6423c2792814aac8"},"src/lib.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"api\nartifacts\ncache\njsonutil\nmaps_out\nmcp\nparse\nscope\nsha256\ntimeutil\ncache_status\ncheck_changes\nhealth\nlist_paths\nload_dependencies\nload_dependents\nmapped_edges\nmark_green\nprepare_edit\nread_health\nread_journal\nrecord_change\nsession_bootstrap\nsuggest_next_actions\nupdate_maps\nVERSION\nJson","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:9c27c83b58ff7f1acf62d5d19ee31e2070daa06499e27141cc7071fae44e26b8","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:d7e771a3ff63ab55a7967195e3b403dd75d5ae020a383adc56f0e5c09d7b03a0"},"src/low_conf.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:3ade5fc70ce585a48b9acf6a2b48753e58e80f63281a31c8b8453816700b375e","status":"🟡 Yellow"},"src/main.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:2b3cebdafca3ca3da7831101a63b36f72260b3b2879871e5364b427428e782e0","source_mtime_ns":"1793683990351610198","status":"🟡 Yellow","wiki_hash":"sha256:db6e1cd5beeb90e5a65b3e00a37ee2c73ea4c53750771151e655459a1b823ff5"},"src/maps_out.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"write\nwrite_status\nread_status","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:c018687f1e511e23bc92a3f771a1f2bf5e693d513d8e4bcad958d9908f0af09c","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:d441c395fe565828a87c54d5545e4c89e4ec4cfc49308a08525e6dcf3c744e75"},"src/mcp.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"tool_schema\nhandle_message\nserve_stdio","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:22c88894fc72f3fad68a31c1582a01772a8409483d3a3127730eccce63067119","source_mtime_ns":"1793683982335712018","status":"🟡 Yellow","wiki_hash":"sha256:8414b1757bf3acc39a66a52443e9625cb027173b2783d996e39e9f707a9a1933"},"src/parse.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"RawImport\nKind\nScan\nlanguage\nread_utf8\nscan_file_warned\nscan_file\nresolve_project\nrust_public_symbols\nresolve_python_spec\nFactEdge\nFileFacts\nAnalysis\nanalyze_files\nanalyze_changed\nedges_for_files","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:5607acdac0b08854951700a9d2187a9f849d2fdb4febc2959fe065bcf778963a","source_mtime_ns":"1793684824322776381","status":"🟡 Yellow","wiki_hash":"sha256:c70af52c81a4dfffb364cba13ca21873491e83e1c9c5cee31ab2bab2bc379fbb"},"src/pending_line.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:c941813474dbb2cfc1ffed8c7bc9791d08eb718d92d336e1f42d1c1b0d67bcdc","status":"🟡 Yellow"},"src/proc.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:8f13798a8b9289d78913becc626056c8b016838570e356e09bfa3333e800351c","status":"🟡 Yellow"},"src/project_lock.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"ProjectLock\ndrop\nacquire","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:194e329f35ff59b11fc734b980a1ee33f8e464bcdb5eae899486e487daef3a25","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:fccc8d547f30262ff64d506d842472623e828ebe39fde71532a683c6956329d2"},"src/scope.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:6dec4152e1ae1657661bd9f504877c02dd70bdb97c39173a952bea2851bedb4c","status":"🟡 Yellow"},"src/sha256.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:d16c7732fbdd20b3697b78129e8052f676c42bc862065f2bfe2b16fae1b7df30","status":"🟡 Yellow"},"src/symbols.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:4a150ba04e9ea26020c874c5e256a9ec5acc3ef4d621fb6ff03b21935c3f8046","status":"🟡 Yellow"},"src/text_out.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:8e0edc1141a2c1745c0e6fdd7a7645c7332365fac532d52fb3e188f4ef06148e","status":"🟡 Yellow"},"src/timeutil.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:9921127144d9d40619e260753e700e9c8a47135ed95b2ad8791afb7fe78afd75","status":"🟡 Yellow"},"src/wiki_files.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:2612539ce9092a3d126d80ce2947f6b4555ddb0bb335769b1619ec67d08a5e84","status":"🟡 Yellow"},"tests/behaviour_gaps.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:8c8d669dae2dbe39b88c2223d20fb2fca51ac1605c62edd108d6ef224223402b","source_mtime_ns":"1793684893298602299","status":"🟡 Yellow","wiki_hash":"sha256:b9d20a310ca48ffd858e6d0d4e49b5e0568ecb57dc5ad981eb54398562864691"},"tests/cache_and_artifacts.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:b9483666c1e19f90d4db1af718b08beebadfbcba66152497b7694d8e757a5ec1","status":"🟡 Yellow"},"tests/cli_surface.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:c6d2ec49ab47aac39a17e6d39fb7eae08d4b27935a9d68bc02a238000646c739","status":"🟡 Yellow"},"tests/contract_gaps.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:dc91a239bbc615524b4ac2c58d06515183dfc4981309906003f551e6721d82ae","status":"🟡 Yellow"},"tests/dogfood.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:b596d9aa2a19c0e030e8d0c02613d1ced0ddf026c976bfcb3c563758bce57fb6","source_mtime_ns":"1793683337392806763","status":"🟡 Yellow","wiki_hash":"sha256:9546cc2062f19584c348419322f07d25d353c7f8ba0257e992372f63d798c020"},"tests/parity.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:0246038a1cc01bf66da2fd186ede61f2dcd2c98da9c0c90dd7f3b2cbab1036db","source_mtime_ns":"1793683314773282057","status":"🟡 Yellow","wiki_hash":"sha256:32753c244642df6e08f8578c6203f77225363783c15bc57c33a84f03989f2378"},"tests/parity_fixes.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:ac0fa6064323885f6e7bd767557fe3721723c8d20d60e36c7cc5265b85747e31","status":"🟡 Yellow"},"tests/protocol_v07.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:bb7cffecabc3fb51ebcc77c64f7fd78f5cd15c445d7e13493ed491227280f37d","source_mtime_ns":"1793684781738827745","status":"🟡 Yellow","wiki_hash":"sha256:b38f4a13e316cefd56070f822980b4f83d9cdacb850ab1978de48ed35c630ec8"},"tests/robustness.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:fad304193a47baa8cce21cff8786b35974c7c7f6f71b3318edeb68bd60121681","status":"🟡 Yellow"},"tests/skip_maps.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:2eed0c6bc97d91a4be3f2b6ab08c78c62a3a05725a503496e3df91abad6b2130","status":"🟡 Yellow"},"wikifier.sh":{"last_updated":"2026-11-03 08:06:46","reason":"Wiki describes the Rust binary launcher (subid=protocol-v07)","source_content_hash":"sha256:4f467e2117e8c361882054998cb0506717d9555e2aa570f0e176fc18d9c2f4aa","source_mtime_ns":"1793410024331635951","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:3876708eaec60862b9b8b6f73e2ae87e4abca2f228c456f973ddb7c9ee36854a"}},"version":2} \ No newline at end of file +{"entries":{".github/workflows/publish.yml":{"last_updated":"2026-11-03 09:27:04","reason":"Multi-target release archives and SHA256SUMS (subid=release-5.0.0)","source_content_hash":"sha256:930170e1eb57afbe2850a0e9c2ac179eb2b5947bf2f74d4292bcd2b0099ab8de","source_mtime_ns":"1793697915725124882","status":"🟢 Green","verification":"no_wiki"},"AGENTS.md":{"last_updated":"2026-11-05 05:37:13","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:061e89b5c878a724b70961af111a00463bf7825d89ffa36199f670854c492d0b","source_mtime_ns":"1793856741183515200","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:566772a56a5d2658deaf7433304d296a587dacd1974ccac0e51f5311fbfa0eb2"},"CHANGELOG.md":{"last_updated":"2026-11-05 05:37:12","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:e934ea90eda41f79960d168deb7ea605cef9499f0b00a30ac3acc51ea7ddd1dc","source_mtime_ns":"1793856916335755100","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:757601288624e346279cef86383a88cdf7c1c467e7c6538f40c05d475e243de8"},"CLAUDE.md":{"last_updated":"2026-11-05 05:37:13","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:cf5400147486bb6de80ce55aa1e40019eaa135d384900fe32d9f4327bbd42a7c","source_mtime_ns":"1793856929650897400","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:b1361a7b9b66eee9ebcc41b71c3535ab4deda7ac10495a352c93f9ceb0b96708"},"CONTRIBUTING.md":{"last_updated":"2026-11-03 05:34:35","reason":"Address the 3 Nov review findings (subid=review-2026-11-03)","source_content_hash":"sha256:d090280cd3d1c3ab1cf342d69392471ed3835512ff7381bf4409bada57bbe89c","source_mtime_ns":"1793683924316411256","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:ec0bd9342b8e9dfeefaabc8dcddeb577f6bd4f1c760d7286b21d709c2a372b29"},"Cargo.lock":{"last_updated":"2026-11-05 05:37:21","reason":"version 5.1.0 (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:268450875f2c013867053718dcf4691bc6bdb6e87986978c66c9c30dd5b57851","source_mtime_ns":"1793856741182515400","status":"🟢 Green","verification":"no_wiki"},"Cargo.toml":{"last_updated":"2026-11-05 05:37:20","reason":"version 5.1.0 (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:696d54c0d35dbe7d4d2bfe8243808ab3e1ca4682decd39714a2b76ae11c2405b","source_mtime_ns":"1793856741181514900","status":"🟢 Green","verification":"no_wiki"},"Findings/M5-Dogfood-Assessment-Report.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:238435188b1699e5bcf4753b62a6fe55cb064413a291259f0b696b5bd3c1692f","source_mtime_ns":"1780519956000000000","status":"🟢 Green"},"Findings/M5-Dogfood-Progress.md":{"last_updated":"2026-11-03 08:06:46","reason":"Wiki marks this file as the historical M5 log (subid=protocol-v07)","source_content_hash":"sha256:ef55167e819806060dfd7dfe7cdfd4d02024ca97706a7faa243710e2630b65fe","source_mtime_ns":"1793408707259550076","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:b6b1fa4bbee0c7e8c4035cd925f6d07e1221ba483e3472878d5b88236c5cf7f9"},"Findings/M5.1-cross-hardening-analysis.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:a2c2414e5b6c9b1a802bec8734a7924850d546cd7746a8087216f3685ca36cb4","source_mtime_ns":"1780519762000000000","status":"🟢 Green"},"Findings/Milestones-Overview.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:f8b7030ec8980774cf3a2560ad7aee6537263eadf8374e650718f7c6793c6d50","source_mtime_ns":"1780800604000000000","status":"🟢 Green"},"Findings/p6_real_world_validation_report.md":{"last_updated":"2026-07-09 05:02:27","reason":"kept; stale yellow cleared","source_content_hash":"sha256:0468224675888f2747674324726c0ea9a5dddded39d9a1a50f22fc3357dafc0f","source_mtime_ns":"1780520008000000000","status":"🟢 Green"},"README.md":{"last_updated":"2026-11-05 05:37:12","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:842e32d2b29147c076979247ecd481341d592b59c96887e889c00d3a5d9ad270","source_mtime_ns":"1793856929649896000","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:45e2581fe9d69ba807dfce7e982c015e23c2702a6c765b83eb9513ef809418da"},"docs/spec.md":{"last_updated":"2026-11-03 08:00:11","reason":"Name the Rust binary and protocol v0.7 in the living principles (subid=protocol-v07)","source_content_hash":"sha256:31fe6b3b3d705628692eb0512cf44cbb21861ccfc5bb02afdd46d796ec066c6e","source_mtime_ns":"1793653008580824356","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:a58b929d25d510f15f9fe176d7823e852f6d089e398accc2e22e1ed189f260ac"},"scripts/benchmark_parity.sh":{"last_updated":"2026-11-03 08:56:15","reason":"Benchmark script and measured results.","source_content_hash":"sha256:b8f72e3245e77b9df17b303293d8cbe4a93cc36023c41b89a794b77b01daef87","status":"🟡 Yellow"},"scripts/benchmark_results.md":{"last_updated":"2026-11-03 08:56:15","reason":"Benchmark script and measured results.","source_content_hash":"sha256:8bf9342fce85f126a37f812b03b6f6997f1fe8e7c5176d30301c1a4a15aeee29","status":"🟡 Yellow"},"skills/run.md":{"last_updated":"2026-11-05 05:37:13","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:0b9b5a13be93850438c7fc0e4f5a89c59d7aabcb1984d95e1fc320467ca6f3c5","source_mtime_ns":"1793856929676891500","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:32979845d60e04b005050ff669cda3dcaf90a8cb0d2c774bda517b1e11b70a0b"},"src/api.rs":{"last_updated":"2026-11-05 05:37:12","public_symbols":"MapRun\nVERSION\nMCP_TOOLS\nupdate_maps\nupdate_maps_opts\nupdate_maps_run\nseed_source_hashes\nmapped_edges\nload_dependencies\ndependency_report\nload_dependents\nlist_paths\nlist_paths_at\nsession_bootstrap\nsession_bootstrap_scoped\nsuggest_next_actions\nsuggest_next_scoped\ncheck_changes\nprepare_edit\nrecord_change\nmark_green\nhealth\nhealth_view\nread_health\nread_journal\ncache_status\nvalidate\ncycles\ncycles_view\nverify_wikis\nrecord_changes\nrecord_changes_opts\nrecord_deletion\nheal_stubs\nprune_health\nprune_health_opts\nprune_pending\nstale_wikis\nstale_wikis_in\nprune_barrels\nmetrics_snapshot\nhealing_stats\nresolution_diagnostics\nbarrel_reports\nincremental_status\nserve_once\nserve_loop\nmonitor_loop\ndaemon_ctl\ndaemon_command\ndispatch\nCLI_COMMANDS\nensure_root","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:f5575d808fab6a3334acd6af46c1f926b15156993828a21be86e7335487baab2","source_mtime_ns":"1793856867181743100","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:1959bc30bd0758cbcbef5c2c1da7bf2599b044f8f954f8d9043ca7e794590a7a"},"src/artifacts.rs":{"last_updated":"2026-11-05 05:37:12","public_symbols":"HealthRow\nwrite_durable\nread_health\nwrite_health\nupsert_health_row\nJournalEntry\njournal_path\nread_journal\nparse_journal\nJournalWrite\nappend_journal\nread_pending\nfolder_card\nnew\nkeep_baseline_from","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:c5989a5fe22edf99afab4b4721fda9f45a12faff19d9f1a97277fcf1003d1ffd","source_mtime_ns":"1793856655049237400","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:751e2374f151dc19c0d20ff447db2ce26b908af28067a7e74ef3a0502ce51bc2"},"src/cache.rs":{"last_updated":"2026-11-05 05:37:12","public_symbols":"check_consistency\nrebuild\nConsistency\nSession\npayload_path\nby_src_path\ncommit_pending_path\nload_dependencies\nInternalEdge\nconfidence_of\nformat_edge_line\nDependencyView\nload_dependency_view\nload_internal_edges\nload_dependents\nlist_prefix\nall_paths\nFileUpdate\nupsert_file\nremove_file\nindexed_rows\nindex_by_path\nhash_if_unchanged\npayload_bytes\nstat_ns\nedges\nrel_string\nwrite_barrel_record\nprune_barrel_files\nconfidence\nacs_reason","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:d0e30e9031d1259333ebeb7799ad78c1836135fa762197d3512f7193640ed6af","source_mtime_ns":"1793856524124108400","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:a48d3652a05cb7e208651c63045e714c62ba5ea1fd7e19fac3b9df84c1af31de"},"src/cache_commit.rs":{"last_updated":"2026-11-05 05:37:12","public_symbols":"Consistency\nSession\ncheck_consistency\nrebuild\nopen\nknown\ndsts_of\nnote_stat\nrels\nupsert\nremove\nstage_barrel\nhas_changes\nneeds_parse\ncommit","reason":"wiki updated (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:fb1f90a3d27558ed141eba2d8fb408bc76a47b2c6715096dba5e75819809513f","source_mtime_ns":"1793856524408983600","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:60c2ba29a68f5feb2a5dd69b9a99d550c6d288386f2a822811eb1530742bf336"},"src/cycle_report.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:db35b610d9a9f9917805da9383e61872e66e640482f57fc9e17ae91daf77fe3d","status":"🟡 Yellow"},"src/daemon_svc.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:0887baeb3ad7f2fca851fccc0d9335dafd4b42f3e54e236175a13d39c5bcd063","status":"🟡 Yellow"},"src/jsonutil.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"Json\nparse\nparse_jsonc\nstrip_json_comments\nobj\nfrom_pairs\nget\nas_str\nas_bool\nas_array\nas_object\ninsert\nfmt","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:cb71e00e352df010928a42323d6d87bbe67348834a209348fc174b9d00489676","source_mtime_ns":"1793684429116993727","status":"🟡 Yellow","wiki_hash":"sha256:fb4cffba8b14a179dd90f201b61df2608ca78a9775e9646e7f011f1b9fa07c63"},"src/legacy_cache.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"import_if_empty","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:a127d0b1742d69b736194ca98579f5fc0611fc51a291d691aef6fe586fd68ba3","source_mtime_ns":"1793683981803718735","status":"🟡 Yellow","wiki_hash":"sha256:a6fe6c79185a46cc4b30d215475bab28f517f6e5a6fd7bce6423c2792814aac8"},"src/lib.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"api\nartifacts\ncache\njsonutil\nmaps_out\nmcp\nparse\nscope\nsha256\ntimeutil\ncache_status\ncheck_changes\nhealth\nlist_paths\nload_dependencies\nload_dependents\nmapped_edges\nmark_green\nprepare_edit\nread_health\nread_journal\nrecord_change\nsession_bootstrap\nsuggest_next_actions\nupdate_maps\nVERSION\nJson","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:9c27c83b58ff7f1acf62d5d19ee31e2070daa06499e27141cc7071fae44e26b8","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:d7e771a3ff63ab55a7967195e3b403dd75d5ae020a383adc56f0e5c09d7b03a0"},"src/low_conf.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:3ade5fc70ce585a48b9acf6a2b48753e58e80f63281a31c8b8453816700b375e","status":"🟡 Yellow"},"src/main.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:2b3cebdafca3ca3da7831101a63b36f72260b3b2879871e5364b427428e782e0","source_mtime_ns":"1793683990351610198","status":"🟡 Yellow","wiki_hash":"sha256:db6e1cd5beeb90e5a65b3e00a37ee2c73ea4c53750771151e655459a1b823ff5"},"src/maps_out.rs":{"last_updated":"2026-11-05 05:15:33","public_symbols":"current\nwrite\nwrite_status\nread_status","reason":"wiki updated (claude/windows-verify speed)","source_content_hash":"sha256:4223756e3ddd7a7317d364f5de9bbffe1fe59e820b0b8187164bd30449039432","source_mtime_ns":"1793854043821084000","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:5218571835e011f1f33b83b5a57723204910bb8209a5f88a1a2d3a06ee82e30a"},"src/mcp.rs":{"last_updated":"2026-11-05 05:15:47","public_symbols":"tool_schema\nhandle_message\nserve_stdio","reason":"wiki updated (claude/windows-verify speed)","source_content_hash":"sha256:e2c23ee73b46d74c10ede239e9e84b6d23780d3327243fb13a8ab81598fa2206","source_mtime_ns":"1793854176936293700","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:fa2b29df474b116fd21d53019c113eb9171681362d072b6e6db4a2f15b049cf4"},"src/parse.rs":{"last_updated":"2026-11-05 05:15:33","public_symbols":"RawImport\nKind\nScan\nlanguage\nread_utf8\nscan_file_warned\nscan_file\nresolve_project\nrust_public_symbols\nresolve_python_spec\nFactEdge\nFileFacts\nAnalysis\nanalyze_files\nanalyze_changed\nedges_for_files","reason":"wiki updated (claude/windows-verify speed)","source_content_hash":"sha256:4122cf1ec797e2af0a410dcf13d6cfb3f087410db5dcae27cff7c79cbdd88228","source_mtime_ns":"1793855453252350400","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:3ea6562f6fbb69c3510cba443a994caa2dbab3fae30e290847ec1cd43913e047"},"src/pending_line.rs":{"last_updated":"2026-11-05 05:19:31","public_symbols":"file_of\nformat_item\nEMPTY_NOTE\nappend_item\nremove_item\nrecord_message\nCHECK_MESSAGE","reason":"wiki (claude/windows-verify pending)","source_content_hash":"sha256:f1ad66b1c330ce490b36a72d58cb2a341b0f44b0b5ec2166cd943a83cf694d05","source_mtime_ns":"1793855871596115600","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:3bac5aead2f3a0b020e056fe388d7670f9a3b45d79be75d197f5b71966620f56"},"src/proc.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:8f13798a8b9289d78913becc626056c8b016838570e356e09bfa3333e800351c","status":"🟡 Yellow"},"src/project_lock.rs":{"last_updated":"2026-11-03 08:56:14","public_symbols":"ProjectLock\ndrop\nacquire","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:194e329f35ff59b11fc734b980a1ee33f8e464bcdb5eae899486e487daef3a25","source_mtime_ns":"1793678007370625780","status":"🟡 Yellow","wiki_hash":"sha256:fccc8d547f30262ff64d506d842472623e828ebe39fde71532a683c6956329d2"},"src/scope.rs":{"last_updated":"2026-11-05 05:15:34","public_symbols":"SOURCE_EXTS\nScope\ndetect\nread_path_list\nexcludes\nis_source\ncollect\nrel_of\ndefault_map_roots\nrel_arg\nallows_walk","reason":"wiki updated (claude/windows-verify speed)","source_content_hash":"sha256:0843deef3d82162edf7bac2c53c9a9f716735b4e639451f7fb36620de2dcd059","source_mtime_ns":"1793854073257790700","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:574d4cc8bd261700b1e192628435926d8f1e533f7f1967e27eb6c797e59e6c0e"},"src/sha256.rs":{"last_updated":"2026-11-05 04:14:08","public_symbols":"sha256\nsha256_hex\nsha256_prefixed\ncontent_sha256_prefixed\nfile_sha256_prefixed","reason":"wiki updated (claude/windows-verify)","source_content_hash":"sha256:2f4f8a8ccb9f9d4748fc0678e6bac63dc69ade57e9ab4b3c6eb2a1c43fcf5c14","source_mtime_ns":"1793851995838527400","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:0d1d67e61bed7a20c6a8413a352a55fb87f05bda6e39760015727d128a67ae33"},"src/symbols.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:4a150ba04e9ea26020c874c5e256a9ec5acc3ef4d621fb6ff03b21935c3f8046","status":"🟡 Yellow"},"src/text_out.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:8e0edc1141a2c1745c0e6fdd7a7645c7332365fac532d52fb3e188f4ef06148e","status":"🟡 Yellow"},"src/timeutil.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:9921127144d9d40619e260753e700e9c8a47135ed95b2ad8791afb7fe78afd75","status":"🟡 Yellow"},"src/wiki_files.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions.","source_content_hash":"sha256:2612539ce9092a3d126d80ce2947f6b4555ddb0bb335769b1619ec67d08a5e84","status":"🟡 Yellow"},"tests/agent_args.rs":{"last_updated":"2026-11-05 04:14:08","reason":"new: agent-style file args, missing args, lean MCP outputs, init roots, CRLF (claude/windows-verify)","source_content_hash":"sha256:ae25dcc233b1d40c48dbc7199e350032717412316089084751b9280970feb66d","status":"🟡 Yellow"},"tests/behaviour_gaps.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:8c8d669dae2dbe39b88c2223d20fb2fca51ac1605c62edd108d6ef224223402b","source_mtime_ns":"1793684893298602299","status":"🟡 Yellow","wiki_hash":"sha256:b9d20a310ca48ffd858e6d0d4e49b5e0568ecb57dc5ad981eb54398562864691"},"tests/cache_and_artifacts.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:b9483666c1e19f90d4db1af718b08beebadfbcba66152497b7694d8e757a5ec1","status":"🟡 Yellow"},"tests/cli_surface.rs":{"last_updated":"2026-11-05 05:37:11","reason":"docs name the Cargo version (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:0aef6e3256a524e014971637bad8a928deba528b734995728bfb72ea606730f5","status":"🟡 Yellow"},"tests/contract_gaps.rs":{"last_updated":"2026-11-03 08:56:14","reason":"Tests for those parity fixes.","source_content_hash":"sha256:dc91a239bbc615524b4ac2c58d06515183dfc4981309906003f551e6721d82ae","status":"🟡 Yellow"},"tests/dogfood.rs":{"last_updated":"2026-11-05 05:15:34","reason":"wiki updated (claude/windows-verify speed)","source_content_hash":"sha256:3d0509564036285a918a0a8d720df5ec9fa0d31490c8cf16b49c16570f31f6c5","source_mtime_ns":"1793855180442138300","status":"🟢 Green","verification":"verified","wiki_hash":"sha256:0bc5f3d78d1779ba6302ecc406dc0b25d8beda25f1681a26d57e97210df19906"},"tests/parity.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:0246038a1cc01bf66da2fd186ede61f2dcd2c98da9c0c90dd7f3b2cbab1036db","source_mtime_ns":"1793683314773282057","status":"🟡 Yellow","wiki_hash":"sha256:32753c244642df6e08f8578c6203f77225363783c15bc57c33a84f03989f2378"},"tests/parity_fixes.rs":{"last_updated":"2026-11-05 05:15:33","reason":"pending item wording and empty-queue note (claude/windows-verify speed)","source_content_hash":"sha256:b74f37dffb2a5bbcc8724298885a677a9d4476f846791d31770798e0a6caa7a3","status":"🟡 Yellow"},"tests/protocol_v07.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:bb7cffecabc3fb51ebcc77c64f7fd78f5cd15c445d7e13493ed491227280f37d","source_mtime_ns":"1793684781738827745","status":"🟡 Yellow","wiki_hash":"sha256:b38f4a13e316cefd56070f822980b4f83d9cdacb850ab1978de48ed35c630ec8"},"tests/robustness.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:fad304193a47baa8cce21cff8786b35974c7c7f6f71b3318edeb68bd60121681","status":"🟡 Yellow"},"tests/skip_maps.rs":{"last_updated":"2026-11-03 08:56:15","reason":"Tests for those parity fixes.","source_content_hash":"sha256:2eed0c6bc97d91a4be3f2b6ab08c78c62a3a05725a503496e3df91abad6b2130","status":"🟡 Yellow"},"tests/speed_paths.rs":{"last_updated":"2026-11-05 05:37:11","reason":"self-heal and durable user-state tests (claude/windows-verify release-5.1.0)","source_content_hash":"sha256:a9348e032c3ffd9f6960710b364470cbcbf935f366af604dd2ad62ac4f2a7a9e","status":"🟡 Yellow"},"wikifier.sh":{"last_updated":"2026-11-03 08:06:46","reason":"Wiki describes the Rust binary launcher (subid=protocol-v07)","source_content_hash":"sha256:4f467e2117e8c361882054998cb0506717d9555e2aa570f0e176fc18d9c2f4aa","source_mtime_ns":"1793410024331635951","status":"🟢 Green","verification":"unchecked","wiki_hash":"sha256:3876708eaec60862b9b8b6f73e2ae87e4abca2f228c456f973ddb7c9ee36854a"}},"version":2} \ No newline at end of file diff --git a/file_health.md b/file_health.md index e7c22a2..0f2c0c1 100644 --- a/file_health.md +++ b/file_health.md @@ -3,26 +3,26 @@ | File | Status | Last Updated | Reason / Intent | |------|--------|--------------|-----------------| | .github/workflows/publish.yml | 🟢 Green | 2026-11-03 09:27:04 | Multi-target release archives and SHA256SUMS (subid=release-5.0.0) | -| AGENTS.md | 🟢 Green | 2026-11-03 09:27:04 | Package line is 5.0.0; pip remains Python 4.7 (subid=release-5.0.0) | -| CHANGELOG.md | 🟢 Green | 2026-11-03 09:27:04 | 5.0.0 Rust release notes and benchmark link (subid=release-5.0.0) | -| CLAUDE.md | 🟢 Green | 2026-11-03 09:27:04 | Package line is 5.0.0 (subid=release-5.0.0) | +| AGENTS.md | 🟢 Green | 2026-11-05 05:37:13 | wiki updated (claude/windows-verify release-5.1.0) | +| CHANGELOG.md | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | +| CLAUDE.md | 🟢 Green | 2026-11-05 05:37:13 | wiki updated (claude/windows-verify release-5.1.0) | | CONTRIBUTING.md | 🟢 Green | 2026-11-03 05:34:35 | Address the 3 Nov review findings (subid=review-2026-11-03) | -| Cargo.lock | 🟢 Green | 2026-11-03 09:27:04 | Lockfile version 5.0.0 (subid=release-5.0.0) | -| Cargo.toml | 🟢 Green | 2026-11-03 09:27:04 | Version 5.0.0 (subid=release-5.0.0) | +| Cargo.lock | 🟢 Green | 2026-11-05 05:37:21 | version 5.1.0 (claude/windows-verify release-5.1.0) | +| Cargo.toml | 🟢 Green | 2026-11-05 05:37:20 | version 5.1.0 (claude/windows-verify release-5.1.0) | | Findings/M5-Dogfood-Assessment-Report.md | 🟢 Green | 2026-07-09 05:02:27 | kept; stale yellow cleared | | Findings/M5-Dogfood-Progress.md | 🟢 Green | 2026-11-03 08:06:46 | Wiki marks this file as the historical M5 log (subid=protocol-v07) | | Findings/M5.1-cross-hardening-analysis.md | 🟢 Green | 2026-07-09 05:02:27 | kept; stale yellow cleared | | Findings/Milestones-Overview.md | 🟢 Green | 2026-07-09 05:02:27 | kept; stale yellow cleared | | Findings/p6_real_world_validation_report.md | 🟢 Green | 2026-07-09 05:02:27 | kept; stale yellow cleared | -| README.md | 🟢 Green | 2026-11-03 09:27:04 | Install leads with release archives; package 5.0.0 (subid=release-5.0.0) | +| README.md | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | | docs/spec.md | 🟢 Green | 2026-11-03 08:00:11 | Name the Rust binary and protocol v0.7 in the living principles (subid=protocol-v07) | | scripts/benchmark_parity.sh | 🟡 Yellow | 2026-11-03 08:56:15 | Benchmark script and measured results. | | scripts/benchmark_results.md | 🟡 Yellow | 2026-11-03 08:56:15 | Benchmark script and measured results. | -| skills/run.md | 🟢 Green | 2026-11-03 09:27:04 | Package line is 5.0.0; protocol stays v0.7 (subid=release-5.0.0) | -| src/api.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/artifacts.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/cache.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/cache_commit.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | +| skills/run.md | 🟢 Green | 2026-11-05 05:37:13 | wiki updated (claude/windows-verify release-5.1.0) | +| src/api.rs | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | +| src/artifacts.rs | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | +| src/cache.rs | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | +| src/cache_commit.rs | 🟢 Green | 2026-11-05 05:37:12 | wiki updated (claude/windows-verify release-5.1.0) | | src/cycle_report.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/daemon_svc.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/jsonutil.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | @@ -30,26 +30,28 @@ | src/lib.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/low_conf.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/main.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/maps_out.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/mcp.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/parse.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/pending_line.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | +| src/maps_out.rs | 🟢 Green | 2026-11-05 05:15:33 | wiki updated (claude/windows-verify speed) | +| src/mcp.rs | 🟢 Green | 2026-11-05 05:15:47 | wiki updated (claude/windows-verify speed) | +| src/parse.rs | 🟢 Green | 2026-11-05 05:15:33 | wiki updated (claude/windows-verify speed) | +| src/pending_line.rs | 🟢 Green | 2026-11-05 05:19:31 | wiki (claude/windows-verify pending) | | src/proc.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/project_lock.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/scope.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | -| src/sha256.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | +| src/scope.rs | 🟢 Green | 2026-11-05 05:15:34 | wiki updated (claude/windows-verify speed) | +| src/sha256.rs | 🟢 Green | 2026-11-05 04:14:08 | wiki updated (claude/windows-verify) | | src/symbols.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/text_out.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/timeutil.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | | src/wiki_files.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. | +| tests/agent_args.rs | 🟡 Yellow | 2026-11-05 04:14:08 | new: agent-style file args, missing args, lean MCP outputs, init roots, CRLF (claude/windows-verify) | | tests/behaviour_gaps.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Tests for those parity fixes. | | tests/cache_and_artifacts.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Tests for those parity fixes. | -| tests/cli_surface.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Tests for those parity fixes. | +| tests/cli_surface.rs | 🟡 Yellow | 2026-11-05 05:37:11 | docs name the Cargo version (claude/windows-verify release-5.1.0) | | tests/contract_gaps.rs | 🟡 Yellow | 2026-11-03 08:56:14 | Tests for those parity fixes. | -| tests/dogfood.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | +| tests/dogfood.rs | 🟢 Green | 2026-11-05 05:15:34 | wiki updated (claude/windows-verify speed) | | tests/parity.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | -| tests/parity_fixes.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | +| tests/parity_fixes.rs | 🟡 Yellow | 2026-11-05 05:15:33 | pending item wording and empty-queue note (claude/windows-verify speed) | | tests/protocol_v07.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | | tests/robustness.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | | tests/skip_maps.rs | 🟡 Yellow | 2026-11-03 08:56:15 | Tests for those parity fixes. | +| tests/speed_paths.rs | 🟡 Yellow | 2026-11-05 05:37:11 | self-heal and durable user-state tests (claude/windows-verify release-5.1.0) | | wikifier.sh | 🟢 Green | 2026-11-03 08:06:46 | Wiki describes the Rust binary launcher (subid=protocol-v07) | diff --git a/journal/2026/11/05.md b/journal/2026/11/05.md new file mode 100644 index 0000000..7634120 --- /dev/null +++ b/journal/2026/11/05.md @@ -0,0 +1,272 @@ +## [2026-11-05 04:11:53] check-changes +**File:** src/api.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:11:53] check-changes +**File:** src/mcp.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:11:53] check-changes +**File:** src/scope.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:11:53] check-changes +**File:** src/sha256.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:11:53] check-changes +**File:** tests/cli_surface.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:11:53] check-changes +**File:** tests/dogfood.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 04:14:08] record-change +**File:** src/scope.rs +**Reason:** rel_arg normalizes Windows/absolute file args; default_map_roots for init (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** src/sha256.rs +**Reason:** content_sha256_prefixed: CRLF hashes as LF so autocrlf checkouts stay Green (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** src/api.rs +**Reason:** file args via rel_arg, FileNotFound for unknown lookups, health limit, init map roots, CRLF-neutral hashes (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** src/mcp.rs +**Reason:** InvalidArguments for missing required args; update_maps edges opt-in; health limit/summary schema (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** tests/cli_surface.rs +**Reason:** launcher exit-code test for wikifier.bat / wikifier.sh (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** tests/dogfood.rs +**Reason:** agent_args.rs is a new api dependent (claude/windows-verify) + +## [2026-11-05 04:14:08] record-change +**File:** tests/agent_args.rs +**Reason:** new: agent-style file args, missing args, lean MCP outputs, init roots, CRLF (claude/windows-verify) + +## [2026-11-05 04:16:31] record-change +**File:** README.md +**Reason:** rewrite for newcomers: what, install, MCP setup, quick start, how it works; benchmarks unchanged (claude/windows-verify) + +## [2026-11-05 04:16:31] record-change +**File:** skills/run.md +**Reason:** document backslash/absolute file args, FileNotFound lookups, InvalidArguments, MCP update_maps edges opt-in, health limit, init map roots (claude/windows-verify) + +## [2026-11-05 04:16:31] record-change +**File:** CHANGELOG.md +**Reason:** Unreleased: Windows fixes, agent-arg errors, lean MCP outputs, init map roots (claude/windows-verify) + +## [2026-11-05 05:15:32] check-changes +**File:** CHANGELOG.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** skills/run.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/api.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/cache.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/cache_commit.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/maps_out.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/mcp.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/parse.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/pending_line.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** src/scope.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** tests/dogfood.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:32] check-changes +**File:** tests/parity_fixes.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:15:33] record-change +**File:** src/cache.rs +**Reason:** parallel batched atomic writes; edges from index.tsv; load_internal_edges; stat-first hash helpers (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/cache_commit.rs +**Reason:** commit and rebuild write forward and by_dst files as two parallel batches; has_changes; index_targets (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/maps_out.rs +**Reason:** library from by_src only; current() lets update-maps skip unchanged artifacts (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/parse.rs +**Reason:** reuse scans in the second resolution pass; substring line_of with per-spec memo; sorted scans built once (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/api.rs +**Reason:** stat-first check_changes and seeding, lazy cycle edge flags, skip map artifacts when cache unchanged, bootstrap limit, pending removal via pending_line (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/mcp.rs +**Reason:** session_bootstrap default files cap 100 over MCP; limit schema text (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/scope.rs +**Reason:** walk uses directory entry file types (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** src/pending_line.rs +**Reason:** EMPTY_NOTE dropped/restored with items; record message names the next step (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** tests/speed_paths.rs +**Reason:** new: fast paths give the slow paths' answers (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** tests/parity_fixes.rs +**Reason:** pending item wording and empty-queue note (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** tests/dogfood.rs +**Reason:** speed_paths.rs is a new api dependent (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** skills/run.md +**Reason:** document stat-first check, unchanged-map artifacts, pending wording, bootstrap limit (claude/windows-verify speed) + +## [2026-11-05 05:15:33] record-change +**File:** CHANGELOG.md +**Reason:** Unreleased: performance section with before/after timings, pending fixes (claude/windows-verify speed) + +## [2026-11-05 05:19:31] record-change +**File:** src/api.rs +**Reason:** pending removal via pending_line::remove_item; init uses EMPTY_NOTE (claude/windows-verify pending) + +## [2026-11-05 05:37:10] check-changes +**File:** AGENTS.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** CHANGELOG.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** CLAUDE.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** Cargo.lock +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** Cargo.toml +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** README.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** skills/run.md +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** src/api.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** src/artifacts.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** src/cache.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** src/cache_commit.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** tests/cli_surface.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] check-changes +**File:** tests/speed_paths.rs +**Reason:** content changed since last mark-green (check-changes) + +## [2026-11-05 05:37:10] record-change +**File:** src/cache.rs +**Reason:** derived cache writes unsynced with size/content checks and self-heal; user state written durably (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** src/cache_commit.rs +**Reason:** derived cache writes unsynced with size/content checks and self-heal; user state written durably (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** src/api.rs +**Reason:** derived cache writes unsynced with size/content checks and self-heal; user state written durably (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** src/artifacts.rs +**Reason:** derived cache writes unsynced with size/content checks and self-heal; user state written durably (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** tests/speed_paths.rs +**Reason:** self-heal and durable user-state tests (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** tests/cli_surface.rs +**Reason:** docs name the Cargo version (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** README.md +**Reason:** release 5.1.0: version, MCP bootstrap cap callout, cache durability notes (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:11] record-change +**File:** CHANGELOG.md +**Reason:** release 5.1.0: version, MCP bootstrap cap callout, cache durability notes (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:12] record-change +**File:** skills/run.md +**Reason:** release 5.1.0: version, MCP bootstrap cap callout, cache durability notes (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:12] record-change +**File:** AGENTS.md +**Reason:** release 5.1.0: version, MCP bootstrap cap callout, cache durability notes (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:12] record-change +**File:** CLAUDE.md +**Reason:** release 5.1.0: version, MCP bootstrap cap callout, cache durability notes (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:20] record-change +**File:** Cargo.toml +**Reason:** version 5.1.0 (claude/windows-verify release-5.1.0) + +## [2026-11-05 05:37:21] record-change +**File:** Cargo.lock +**Reason:** version 5.1.0 (claude/windows-verify release-5.1.0) + diff --git a/library.md b/library.md index 6f6eed5..c02c907 100644 --- a/library.md +++ b/library.md @@ -2,7 +2,7 @@ Generated by `wikifier update-maps`. Agents orient with the root folder card and `list-paths`, not this mermaid. -Files: 25. Edges: 74. +Files: 35. Edges: 108. ## File Tree @@ -11,27 +11,37 @@ src/api.rs src/artifacts.rs src/cache.rs src/cache_commit.rs +src/cycle_report.rs +src/daemon_svc.rs src/jsonutil.rs src/legacy_cache.rs src/lib.rs +src/low_conf.rs src/main.rs src/maps_out.rs src/mcp.rs src/parse.rs +src/pending_line.rs src/proc.rs src/project_lock.rs src/scope.rs src/sha256.rs +src/symbols.rs +src/text_out.rs src/timeutil.rs +src/wiki_files.rs +tests/agent_args.rs tests/behaviour_gaps.rs tests/cache_and_artifacts.rs tests/cli_surface.rs tests/contract_gaps.rs tests/dogfood.rs tests/parity.rs +tests/parity_fixes.rs tests/protocol_v07.rs tests/robustness.rs tests/skip_maps.rs +tests/speed_paths.rs ``` ## Dependencies @@ -40,102 +50,117 @@ tests/skip_maps.rs flowchart LR n0["src/api.rs"] --> n1["src/artifacts.rs"] n0["src/api.rs"] --> n2["src/cache.rs"] - n0["src/api.rs"] --> n3["src/jsonutil.rs"] - n0["src/api.rs"] --> n4["src/legacy_cache.rs"] - n0["src/api.rs"] --> n5["src/maps_out.rs"] - n0["src/api.rs"] --> n6["src/parse.rs"] - n0["src/api.rs"] --> n7["src/proc.rs"] - n0["src/api.rs"] --> n8["src/project_lock.rs"] - n0["src/api.rs"] --> n9["src/scope.rs"] - n0["src/api.rs"] --> n10["src/sha256.rs"] - n0["src/api.rs"] --> n11["src/timeutil.rs"] - n1["src/artifacts.rs"] --> n3["src/jsonutil.rs"] - n1["src/artifacts.rs"] --> n11["src/timeutil.rs"] - n2["src/cache.rs"] --> n12["src/cache_commit.rs"] - n2["src/cache.rs"] --> n3["src/jsonutil.rs"] - n2["src/cache.rs"] --> n9["src/scope.rs"] - n12["src/cache_commit.rs"] --> n2["src/cache.rs"] - n12["src/cache_commit.rs"] --> n3["src/jsonutil.rs"] - n12["src/cache_commit.rs"] --> n7["src/proc.rs"] - n4["src/legacy_cache.rs"] --> n2["src/cache.rs"] - n4["src/legacy_cache.rs"] --> n3["src/jsonutil.rs"] - n4["src/legacy_cache.rs"] --> n5["src/maps_out.rs"] - n4["src/legacy_cache.rs"] --> n8["src/project_lock.rs"] - n13["src/lib.rs"] --> n0["src/api.rs"] - n13["src/lib.rs"] --> n1["src/artifacts.rs"] - n13["src/lib.rs"] --> n2["src/cache.rs"] - n13["src/lib.rs"] --> n12["src/cache_commit.rs"] - n13["src/lib.rs"] --> n3["src/jsonutil.rs"] - n13["src/lib.rs"] --> n4["src/legacy_cache.rs"] - n13["src/lib.rs"] --> n5["src/maps_out.rs"] - n13["src/lib.rs"] --> n14["src/mcp.rs"] - n13["src/lib.rs"] --> n6["src/parse.rs"] - n13["src/lib.rs"] --> n7["src/proc.rs"] - n13["src/lib.rs"] --> n8["src/project_lock.rs"] - n13["src/lib.rs"] --> n9["src/scope.rs"] - n13["src/lib.rs"] --> n10["src/sha256.rs"] - n13["src/lib.rs"] --> n11["src/timeutil.rs"] - n15["src/main.rs"] --> n0["src/api.rs"] - n15["src/main.rs"] --> n14["src/mcp.rs"] - n15["src/main.rs"] --> n11["src/timeutil.rs"] - n5["src/maps_out.rs"] --> n2["src/cache.rs"] - n5["src/maps_out.rs"] --> n3["src/jsonutil.rs"] - n14["src/mcp.rs"] --> n0["src/api.rs"] - n14["src/mcp.rs"] --> n3["src/jsonutil.rs"] - n6["src/parse.rs"] --> n3["src/jsonutil.rs"] - n6["src/parse.rs"] --> n9["src/scope.rs"] - n8["src/project_lock.rs"] --> n7["src/proc.rs"] - n16["tests/behaviour_gaps.rs"] --> n2["src/cache.rs"] - n16["tests/behaviour_gaps.rs"] --> n3["src/jsonutil.rs"] - n16["tests/behaviour_gaps.rs"] --> n13["src/lib.rs"] - n16["tests/behaviour_gaps.rs"] --> n14["src/mcp.rs"] - n17["tests/cache_and_artifacts.rs"] --> n2["src/cache.rs"] - n17["tests/cache_and_artifacts.rs"] --> n13["src/lib.rs"] - n18["tests/cli_surface.rs"] --> n0["src/api.rs"] - n18["tests/cli_surface.rs"] --> n3["src/jsonutil.rs"] - n18["tests/cli_surface.rs"] --> n13["src/lib.rs"] - n19["tests/contract_gaps.rs"] --> n0["src/api.rs"] - n19["tests/contract_gaps.rs"] --> n3["src/jsonutil.rs"] - n19["tests/contract_gaps.rs"] --> n13["src/lib.rs"] - n20["tests/dogfood.rs"] --> n3["src/jsonutil.rs"] - n20["tests/dogfood.rs"] --> n13["src/lib.rs"] - n21["tests/parity.rs"] --> n13["src/lib.rs"] - n22["tests/protocol_v07.rs"] --> n0["src/api.rs"] - n22["tests/protocol_v07.rs"] --> n2["src/cache.rs"] - n22["tests/protocol_v07.rs"] --> n3["src/jsonutil.rs"] - n22["tests/protocol_v07.rs"] --> n14["src/mcp.rs"] - n23["tests/robustness.rs"] --> n0["src/api.rs"] - n23["tests/robustness.rs"] --> n2["src/cache.rs"] - n23["tests/robustness.rs"] --> n3["src/jsonutil.rs"] - n23["tests/robustness.rs"] --> n13["src/lib.rs"] - n24["tests/skip_maps.rs"] --> n0["src/api.rs"] - n24["tests/skip_maps.rs"] --> n2["src/cache.rs"] - n24["tests/skip_maps.rs"] --> n3["src/jsonutil.rs"] - n24["tests/skip_maps.rs"] --> n13["src/lib.rs"] + n0["src/api.rs"] --> n3["src/cycle_report.rs"] + n0["src/api.rs"] --> n4["src/daemon_svc.rs"] + n0["src/api.rs"] --> n5["src/jsonutil.rs"] + n0["src/api.rs"] --> n6["src/legacy_cache.rs"] + n0["src/api.rs"] --> n7["src/low_conf.rs"] + n0["src/api.rs"] --> n8["src/maps_out.rs"] + n0["src/api.rs"] --> n9["src/parse.rs"] + n0["src/api.rs"] --> n10["src/pending_line.rs"] + n0["src/api.rs"] --> n11["src/proc.rs"] + n0["src/api.rs"] --> n12["src/project_lock.rs"] + n0["src/api.rs"] --> n13["src/scope.rs"] + n0["src/api.rs"] --> n14["src/sha256.rs"] + n0["src/api.rs"] --> n15["src/symbols.rs"] + n0["src/api.rs"] --> n16["src/timeutil.rs"] + n0["src/api.rs"] --> n17["src/wiki_files.rs"] + n1["src/artifacts.rs"] --> n2["src/cache.rs"] + n1["src/artifacts.rs"] --> n5["src/jsonutil.rs"] + n1["src/artifacts.rs"] --> n16["src/timeutil.rs"] + n2["src/cache.rs"] --> n18["src/cache_commit.rs"] + n2["src/cache.rs"] --> n5["src/jsonutil.rs"] + n2["src/cache.rs"] --> n13["src/scope.rs"] + n18["src/cache_commit.rs"] --> n2["src/cache.rs"] + n18["src/cache_commit.rs"] --> n5["src/jsonutil.rs"] + n18["src/cache_commit.rs"] --> n11["src/proc.rs"] + n3["src/cycle_report.rs"] --> n5["src/jsonutil.rs"] + n4["src/daemon_svc.rs"] --> n5["src/jsonutil.rs"] + n4["src/daemon_svc.rs"] --> n16["src/timeutil.rs"] + n6["src/legacy_cache.rs"] --> n2["src/cache.rs"] + n6["src/legacy_cache.rs"] --> n5["src/jsonutil.rs"] + n6["src/legacy_cache.rs"] --> n8["src/maps_out.rs"] + n6["src/legacy_cache.rs"] --> n12["src/project_lock.rs"] + n19["src/lib.rs"] --> n0["src/api.rs"] + n19["src/lib.rs"] --> n1["src/artifacts.rs"] + n19["src/lib.rs"] --> n2["src/cache.rs"] + n19["src/lib.rs"] --> n18["src/cache_commit.rs"] + n19["src/lib.rs"] --> n3["src/cycle_report.rs"] + n19["src/lib.rs"] --> n4["src/daemon_svc.rs"] + n19["src/lib.rs"] --> n5["src/jsonutil.rs"] + n19["src/lib.rs"] --> n6["src/legacy_cache.rs"] + n19["src/lib.rs"] --> n7["src/low_conf.rs"] + n19["src/lib.rs"] --> n8["src/maps_out.rs"] + n19["src/lib.rs"] --> n20["src/mcp.rs"] + n19["src/lib.rs"] --> n9["src/parse.rs"] + n19["src/lib.rs"] --> n10["src/pending_line.rs"] + n19["src/lib.rs"] --> n11["src/proc.rs"] + n19["src/lib.rs"] --> n12["src/project_lock.rs"] + n19["src/lib.rs"] --> n13["src/scope.rs"] + n19["src/lib.rs"] --> n14["src/sha256.rs"] + n19["src/lib.rs"] --> n15["src/symbols.rs"] + n19["src/lib.rs"] --> n21["src/text_out.rs"] + n19["src/lib.rs"] --> n16["src/timeutil.rs"] + n19["src/lib.rs"] --> n17["src/wiki_files.rs"] + n7["src/low_conf.rs"] --> n2["src/cache.rs"] + n22["src/main.rs"] --> n0["src/api.rs"] + n22["src/main.rs"] --> n5["src/jsonutil.rs"] + n22["src/main.rs"] --> n20["src/mcp.rs"] + n22["src/main.rs"] --> n21["src/text_out.rs"] + n22["src/main.rs"] --> n16["src/timeutil.rs"] + n8["src/maps_out.rs"] --> n2["src/cache.rs"] + n8["src/maps_out.rs"] --> n5["src/jsonutil.rs"] + n20["src/mcp.rs"] --> n0["src/api.rs"] + n20["src/mcp.rs"] --> n5["src/jsonutil.rs"] + n20["src/mcp.rs"] --> n21["src/text_out.rs"] + n9["src/parse.rs"] --> n5["src/jsonutil.rs"] + n9["src/parse.rs"] --> n13["src/scope.rs"] + n12["src/project_lock.rs"] --> n11["src/proc.rs"] + n21["src/text_out.rs"] --> n5["src/jsonutil.rs"] + n23["tests/agent_args.rs"] --> n0["src/api.rs"] + n23["tests/agent_args.rs"] --> n19["src/lib.rs"] + n23["tests/agent_args.rs"] --> n20["src/mcp.rs"] + n24["tests/behaviour_gaps.rs"] --> n2["src/cache.rs"] + n24["tests/behaviour_gaps.rs"] --> n5["src/jsonutil.rs"] + n24["tests/behaviour_gaps.rs"] --> n19["src/lib.rs"] + n24["tests/behaviour_gaps.rs"] --> n20["src/mcp.rs"] + n25["tests/cache_and_artifacts.rs"] --> n2["src/cache.rs"] + n25["tests/cache_and_artifacts.rs"] --> n19["src/lib.rs"] + n26["tests/cli_surface.rs"] --> n0["src/api.rs"] + n26["tests/cli_surface.rs"] --> n5["src/jsonutil.rs"] ``` +Mermaid shows the first 80 edges of 108. + | Source | Depends on | |--------|------------| -| src/api.rs | src/artifacts.rs, src/cache.rs, src/jsonutil.rs, src/legacy_cache.rs, src/maps_out.rs, src/parse.rs, src/proc.rs, src/project_lock.rs, src/scope.rs, src/sha256.rs, src/timeutil.rs | -| src/artifacts.rs | src/jsonutil.rs, src/timeutil.rs | +| src/api.rs | src/artifacts.rs, src/cache.rs, src/cycle_report.rs, src/daemon_svc.rs, src/jsonutil.rs, src/legacy_cache.rs, src/low_conf.rs, src/maps_out.rs, src/parse.rs, src/pending_line.rs, src/proc.rs, src/project_lock.rs, src/scope.rs, src/sha256.rs, src/symbols.rs, src/timeutil.rs, src/wiki_files.rs | +| src/artifacts.rs | src/cache.rs, src/jsonutil.rs, src/timeutil.rs | | src/cache.rs | src/cache_commit.rs, src/jsonutil.rs, src/scope.rs | | src/cache_commit.rs | src/cache.rs, src/jsonutil.rs, src/proc.rs | +| src/cycle_report.rs | src/jsonutil.rs | +| src/daemon_svc.rs | src/jsonutil.rs, src/timeutil.rs | | src/legacy_cache.rs | src/cache.rs, src/jsonutil.rs, src/maps_out.rs, src/project_lock.rs | -| src/lib.rs | src/api.rs, src/artifacts.rs, src/cache.rs, src/cache_commit.rs, src/jsonutil.rs, src/legacy_cache.rs, src/maps_out.rs, src/mcp.rs, src/parse.rs, src/proc.rs, src/project_lock.rs, src/scope.rs, src/sha256.rs, src/timeutil.rs | -| src/main.rs | src/api.rs, src/mcp.rs, src/timeutil.rs | +| src/lib.rs | src/api.rs, src/artifacts.rs, src/cache.rs, src/cache_commit.rs, src/cycle_report.rs, src/daemon_svc.rs, src/jsonutil.rs, src/legacy_cache.rs, src/low_conf.rs, src/maps_out.rs, src/mcp.rs, src/parse.rs, src/pending_line.rs, src/proc.rs, src/project_lock.rs, src/scope.rs, src/sha256.rs, src/symbols.rs, src/text_out.rs, src/timeutil.rs, src/wiki_files.rs | +| src/low_conf.rs | src/cache.rs | +| src/main.rs | src/api.rs, src/jsonutil.rs, src/mcp.rs, src/text_out.rs, src/timeutil.rs | | src/maps_out.rs | src/cache.rs, src/jsonutil.rs | -| src/mcp.rs | src/api.rs, src/jsonutil.rs | +| src/mcp.rs | src/api.rs, src/jsonutil.rs, src/text_out.rs | | src/parse.rs | src/jsonutil.rs, src/scope.rs | | src/project_lock.rs | src/proc.rs | +| src/text_out.rs | src/jsonutil.rs | +| tests/agent_args.rs | src/api.rs, src/lib.rs, src/mcp.rs | | tests/behaviour_gaps.rs | src/cache.rs, src/jsonutil.rs, src/lib.rs, src/mcp.rs | | tests/cache_and_artifacts.rs | src/cache.rs, src/lib.rs | | tests/cli_surface.rs | src/api.rs, src/jsonutil.rs, src/lib.rs | | tests/contract_gaps.rs | src/api.rs, src/jsonutil.rs, src/lib.rs | | tests/dogfood.rs | src/jsonutil.rs, src/lib.rs | | tests/parity.rs | src/lib.rs | +| tests/parity_fixes.rs | src/api.rs, src/jsonutil.rs, src/mcp.rs | | tests/protocol_v07.rs | src/api.rs, src/cache.rs, src/jsonutil.rs, src/mcp.rs | | tests/robustness.rs | src/api.rs, src/cache.rs, src/jsonutil.rs, src/lib.rs | | tests/skip_maps.rs | src/api.rs, src/cache.rs, src/jsonutil.rs, src/lib.rs | +| tests/speed_paths.rs | src/api.rs, src/artifacts.rs, src/cache.rs, src/lib.rs, src/mcp.rs, src/sha256.rs | Edges with a conditional, dynamic, barrel, or lower-confidence flag: 0. Run `wikifier cycles` for a break hint on each strongly connected component. diff --git a/pending_updates.md b/pending_updates.md index 8253624..a1570ae 100644 --- a/pending_updates.md +++ b/pending_updates.md @@ -1,27 +1,16 @@ # Pending Updates -(no pending items — run check-changes after making edits) -- src/api.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/artifacts.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/cache.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/cache_commit.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/jsonutil.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/legacy_cache.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/lib.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/main.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/maps_out.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/mcp.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/parse.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/proc.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/project_lock.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/scope.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/sha256.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/timeutil.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - tests/behaviour_gaps.rs: LLM/agent edit — Tests for those parity fixes. - tests/cache_and_artifacts.rs: LLM/agent edit — Tests for those parity fixes. - tests/cli_surface.rs: LLM/agent edit — Tests for those parity fixes. - tests/contract_gaps.rs: LLM/agent edit — Tests for those parity fixes. -- tests/dogfood.rs: LLM/agent edit — Tests for those parity fixes. - tests/parity.rs: LLM/agent edit — Tests for those parity fixes. - tests/protocol_v07.rs: LLM/agent edit — Tests for those parity fixes. - tests/robustness.rs: LLM/agent edit — Tests for those parity fixes. @@ -31,8 +20,9 @@ - src/cycle_report.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/daemon_svc.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/low_conf.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. -- src/pending_line.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/symbols.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/text_out.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - src/wiki_files.rs: LLM/agent edit — Parity with the Python package: wiki lookup, symbols, daemon, init, flags, MCP, cycles, and low-confidence actions. - tests/parity_fixes.rs: LLM/agent edit — Tests for those parity fixes. +- tests/agent_args.rs: LLM/agent edit — new: agent-style file args, missing args, lean MCP outputs, init roots, CRLF (claude/windows-verify) +- tests/speed_paths.rs: new: fast paths give the slow paths' answers (claude/windows-verify speed) — refresh the wiki, then mark-green diff --git a/skills/run.md b/skills/run.md index dece840..215e100 100644 --- a/skills/run.md +++ b/skills/run.md @@ -1,6 +1,6 @@ # Wikifier Agent Protocol v0.7 -**Package 5.0.0** (live version: `wikifier --version`, from `Cargo.toml`) · **Status:** active, supersedes v0.6 · **Scope:** agent-to-agent codebase wiki for token-efficient lookup. Not a human docs product, not an issue tracker. +**Package 5.1.0** (live version: `wikifier --version`, from `Cargo.toml`) · **Status:** active, supersedes v0.6 · **Scope:** agent-to-agent codebase wiki for token-efficient lookup. Not a human docs product, not an issue tracker. This file is the contract for any agent operating Wikifier. Part 1 is all you need day to day; Part 2 is reference. In the Wikifier repository, `AGENTS.md` is the entry point and points here. @@ -24,9 +24,9 @@ This file is the contract for any agent operating Wikifier. Part 1 is all you ne | 5+6 for many files | `record_changes(reasons, green=True)` — reasons per path/glob/`dir/`, from git | `record-changes -r "src/=why" --green` | - Always pass `project_root=` (MCP/library) or `--target DIR` / `WIKIFIER_PROJECT_ROOT` (CLI) for any project other than the current directory. **A root that does not exist is an error** — nothing is ever written elsewhere. -- File arguments may be project-relative, `./`-prefixed or absolute (inside the project). Results return the canonical project-relative `file`. +- File arguments may be project-relative, `./`-prefixed, `\`-separated (Windows) or absolute (inside the project). Results return the canonical project-relative `file`. A lookup (`prepare_edit`, `get_dependencies`, `get_dependents`, `get_file_wiki`) on a path that is neither on disk nor in the map returns `error_type: "FileNotFound"`; `list_paths` shows the mapped files. - `record_change` and `mark_green` require the file to exist; for removed files use `record_deletion`. -- `check_changes` watches every mapped file, every file ever marked Green, and `monitored_paths.txt` entries — the list only adds files (docs, scripts), it never hides mapped ones. +- `check_changes` watches every mapped file, every file ever marked Green, and `monitored_paths.txt` entries — the list only adds files (docs, scripts), it never hides mapped ones. A mapped file whose size and mtime still match its cache row is compared by the hash stored there (the rule `update_maps` skips by); any other file is read and hashed. - Green means the wiki was checked against the code (see *Wiki verification*). A failed check lists the symbols to document; update the wiki and retry. `force=True` with a reason overrides and is recorded as `forced`. - Orient with the bootstrap **root folder card** → `list_paths(prefix)` for one folder → `prepare_edit(file)` for file-to-file links. Do not read `library.md` mermaid to orient. - Wiki convention: `.wiki.md` next to the source (e.g. `src/api.rs.wiki.md`). @@ -42,7 +42,7 @@ This file is the contract for any agent operating Wikifier. Part 1 is all you ne ### Paste into new sessions ``` -You are operating inside a Wikifier-managed codebase (Agent Protocol v0.7, package 5.0.0). +You are operating inside a Wikifier-managed codebase (Agent Protocol v0.7, package 5.1.0). It is an agent-to-agent wiki: look things up (health, file wikis, deps) instead of re-reading sources. SELECTIVE WORK: only update 🔴 Red and actionable 🟡 Yellow files; never re-wiki 🟢 Green files; @@ -128,7 +128,7 @@ Mutations take an advisory project lock (`.wikifier_staging/.lock`, a create-new | Command | Purpose | |---|---| -| `init [DIR]` | keeps an existing path list, seeds `src/` when that list is empty, copies `index.html` only when it is missing, writes `.wikifier/marker`. `[DIR]` is the project root unless `--target` is set | +| `init [DIR]` | keeps an existing path list, seeds the map roots when that list is empty (`src/` if present, else each top-level directory that holds source), copies `index.html` only when it is missing, writes `.wikifier/marker`. `[DIR]` is the project root unless `--target` is set | | `session-bootstrap`, `check-changes`, `suggest-next [--json]` | core loop | | `prepare-edit F`, `record-change F WHY`, `mark-green F [WHY] [--force]`, `record-deletion F [WHY]` | per-file loop | | `record-changes [-r PATH=WHY]… [--reasons FILE] [--default WHY] [--only P]… [--since REF] [--green] [--dry-run]` | batch loop (git) | @@ -147,25 +147,25 @@ The implementation is a std-only Rust binary. `wikifier.sh` / `.ps1` / `.bat` la There is no Python library. Agents call the CLI or `wikifier mcp`. The same functions live in the Rust crate (`src/api.rs`) and both fronts call them. -### Implementation (package 5.0.0, protocol stays v0.7) +### Implementation (package 5.1.0, protocol stays v0.7) The program is a std-only Rust binary under `src/`. `Cargo.toml` has an empty `[dependencies]` list. Launchers exec that binary. MCP is `wikifier mcp`. -- `update-maps` writes `library.md` (a file tree, a Mermaid block capped at 80 edges, and the dependency table) and `.wikifier_staging/maps/` (`_root.json` plus one `_index.json` per directory). `--directory`, `--max-files`, and `--stream` apply. A file budget returns `map_complete=false` and `files_remaining_dirty`. The next run stat-skips files already parsed, so the same command resumes. `--stream` prints JSON events on stderr and still persists the map. The result field `parser` is `regex+reexport`. +- `update-maps` writes `library.md` (a file tree, a Mermaid block capped at 80 edges, and the dependency table) and `.wikifier_staging/maps/` (`_root.json` plus one `_index.json` per directory). `--directory`, `--max-files`, and `--stream` apply. A file budget returns `map_complete=false` and `files_remaining_dirty`. The next run stat-skips files already parsed, so the same command resumes. `--stream` prints JSON events on stderr and still persists the map. The result field `parser` is `regex+reexport`. A run that changes nothing in the cache keeps `library.md` and the folder cards as they are. Cache files are derived and are not synced; a missing, resized, or malformed cache file is a miss, and `update_maps` parses that source again (lookups answer from `index.tsv` meanwhile). Health, journal, and pending files are written through a synced temp file. - The cache is `.wikifier_staging/wk_cache`, a text index (payload, `by_src`, `by_dst`, notes, barrels). When that index is empty, an existing `import_cache.sqlite` is imported with the `sqlite3` CLI, and `import_cache.json` is the other fallback. Rows imported this way use hash `sha256:legacy` and mtime 0, so the next `update-maps` re-parses them. If the sqlite file is present and cannot be read, the result includes the warning `sqlite_present_unread`. - A resolved edge line is `dst`, `line`, `names`, then optional flag letters `c` (conditional), `d` (dynamic), and `b` (via barrel). Confidence is assigned when the edge is read: dynamic 0.4, conditional 0.55, via barrel 0.7, static 0.9. ACS reason codes are `dynamic_literal`, `conditional_guard`, `via_barrel`, and `resolved_static`. Unresolved notes use `unresolved_relative` (the specifier starts with `.`), `dynamic_literal`, or `unresolved`. - `dependencies --full` returns `imports[]` with `confidence_score`, `confidence_reasons`, `acs`, `is_dynamic`, `is_conditional`, and `in_cycle`. Compact rows always include `conditional`, `dynamic`, `via_barrel`, and `confidence`. - `record_deletion` appends the journal line, marks the health row Red, drops the file from the graph, and removes its pending line. The file does not have to exist. - `record_changes` matches reasons by exact path, then the longest `dir/` prefix, then the longest glob (`*` and `?`). `--reasons` accepts a JSON object (`{"path": "why"}`) or `PATH=REASON` lines (`-` reads stdin). `--only`, `--green`, `--dry-run`, and deletions are applied. `--green` lists verification failures and leaves those files Yellow. MCP `reasons` may be that object. -- `mark_green` extracts Python public names with a tokenizer (multi-line `__all__` lists, tuples, and `+=`; comments and strings ignored; top-level `def`, `async def`, and `class`, including decorated ones; `.py` and `.pyi`), JS/TS `export` names after comments and strings are masked, and Rust unrestricted `pub` items (`fn`, `struct`, `enum`, `trait`, `mod`, `type`, `const`, `static`, and `pub use` names), plus `pub` methods and associated items on a public type and the items written in a trait impl for that type. Each current name must appear in the wiki. `pub(crate)`, `pub(super)`, `pub(self)`, and `pub(in ...)` are not Rust public names. With a previous baseline, a removed name still wrapped in backticks is `wiki_mentions_removed_symbols`, and a changed public surface with an unchanged wiki hash is `wiki_not_updated_for_api_change`. Go, C/C++, C#, and Java have no symbol extractor, so their rows are `unchecked`. The wiki is `.wiki.md`, then `.md`, the stem beside the source, then the same names under `docs/wiki`, `docs`, `wiki`, `documentation`, and `.wiki`. `health --summary` returns `health_summary` (including `green_breakdown`) without the entry list. `health --format healing-stats` is the stub counts. `--directory` limits health, bootstrap, and suggest-next. A successful mark removes that file's pending line. Pending bullets are `- path: message` (`LLM/agent edit — …` after `record-change`). +- `mark_green` extracts Python public names with a tokenizer (multi-line `__all__` lists, tuples, and `+=`; comments and strings ignored; top-level `def`, `async def`, and `class`, including decorated ones; `.py` and `.pyi`), JS/TS `export` names after comments and strings are masked, and Rust unrestricted `pub` items (`fn`, `struct`, `enum`, `trait`, `mod`, `type`, `const`, `static`, and `pub use` names), plus `pub` methods and associated items on a public type and the items written in a trait impl for that type. Each current name must appear in the wiki. `pub(crate)`, `pub(super)`, `pub(self)`, and `pub(in ...)` are not Rust public names. With a previous baseline, a removed name still wrapped in backticks is `wiki_mentions_removed_symbols`, and a changed public surface with an unchanged wiki hash is `wiki_not_updated_for_api_change`. Go, C/C++, C#, and Java have no symbol extractor, so their rows are `unchecked`. The wiki is `.wiki.md`, then `.md`, the stem beside the source, then the same names under `docs/wiki`, `docs`, `wiki`, `documentation`, and `.wiki`. `health --summary` returns `health_summary` (including `green_breakdown`) without the entry list. `health --format healing-stats` is the stub counts. `--directory` limits health, bootstrap, and suggest-next. A successful mark removes that file's pending line. Pending bullets are `- path: message` (` — refresh the wiki, then mark-green` after `record-change`). The `(no pending items …)` line shows only while the queue is empty. `session_bootstrap --limit N` caps `files` and adds `files_total`; over MCP the default cap is 100 (`limit: 0` lists every file). - `suggest-next` adds `review_low_confidence_edges` when a mapped file has a resolved, non-dynamic edge below confidence 0.65. The count is stored while parsing (`low_conf.tsv`) so a warm `update-maps` does not rescan it. - `list-paths --depth 0` or `--recursive` lists every file under the prefix. `--max-files` caps that list. `verify-wikis --limit` and `stale-wikis --limit` cap the rows returned. `cycles` adds `severity`, `score`, `external_blast_radius`, `recommendation`, and `reverse_dependencies` (top 20 by inbound edges). `--no-analysis` omits those. `--max-items` caps the cycle list. - `seed-source-hashes` writes the current hash onto Green rows and does not change status. `--force` replaces a hash that is already stored. `seed-health` still adds Initial-stub rows. `heal-stubs` promotes a stub whose wiki is at least 350 characters (`--min-wiki-length` overrides). - Health stubs from `update-maps` follow `monitored_paths.txt` when that file lists real paths. If the file is absent, empty, or only a bare `.`, every mapped file is seeded. - The scan ignores comments and strings. It does not use CPython `ast`. It does read `tsconfig.json` `paths`, `package.json` `exports`, and npm `workspaces` (`*` directory globs). Named re-exports are the barrel follow. Rust reads the root `Cargo.toml` `[package].name`, `[lib].name`, and `[workspace] members` (globs included; hyphens become underscores). It resolves `crate::`, `self::`, `super::`, those crate names, and `mod` to files. `crate::` stays in the file's crate (`tests/*.rs` and `src/bin/*.rs` are their own crates, and a workspace member does not share `crate::` with another member). A package or lib name points at that member's library. Accuracy is `accuracy_fixtures_match_frozen_edges`, including the Rust crate layout. This parser has not been run on Babylon.js. -- MCP `tools/call` sets `isError` when the payload has `success: false`. `resources/read` and `prompts/get` answer. Prompts name the tools to call. Optional arguments include `format` (`text` or `json`), `timeout_s`, `directory`, `detail`, `low_confidence_only`, `unresolved_only`, `depth`, `recursive`, `max_files`, `limit`, `max_items`, and a `reasons` object. A call waits at most `timeout_s` or `WIKIFIER_MCP_DEADLINE_S` seconds (default 60) and then returns `timed_out` and `may_still_complete`. An identical journal action, file, and reason inside 120 seconds sets `duplicate_skipped` and does not append another line. The CLI default is JSON. `--text` prints prose. `health --format text` does the same for health. +- MCP `tools/call` sets `isError` when the payload has `success: false`. `resources/read` and `prompts/get` answer. Prompts name the tools to call. Optional arguments include `format` (`text` or `json`), `timeout_s`, `directory`, `detail`, `low_confidence_only`, `unresolved_only`, `depth`, `recursive`, `max_files`, `limit`, `max_items`, and a `reasons` object. A call waits at most `timeout_s` or `WIKIFIER_MCP_DEADLINE_S` seconds (default 60) and then returns `timed_out` and `may_still_complete`. An identical journal action, file, and reason inside 120 seconds sets `duplicate_skipped` and does not append another line. A call that leaves out a required argument other than `project_root` returns `error_type: "InvalidArguments"` with the accepted and received argument names. Over MCP, `update_maps` omits the edge list unless `edges: true` (`edges_persisted` is the count), and `health` takes `limit` (with `entries_total`) and `summary`. The CLI default is JSON. `--text` prints prose. `health --format text` does the same for health. - Commands that write health, pending, the map, or the cache take `.wikifier_staging/.lock` before the cache lock. The lock is re-entrant on the same thread, waits 30 seconds, and is stolen when the recorded pid is dead. A timeout returns `error_type: "LockTimeoutError"` and `timed_out: true`. -- `init` keeps a path list that already has a non-comment entry. When a list is missing it writes a `src/` template. It also writes `exclude_patterns.txt`, empty `file_health.md`, `pending_updates.md`, `library.md`, `.wikifier/config`, `.wikifier/marker`, and the `journal/` and `Logged_issues/` directories when those paths are absent. `--no-copy` skips `index.html`. Otherwise `index.html` is copied only when it is missing. +- `init` keeps a path list that already has a non-comment entry. When a list is missing it writes the map roots: `src/` when it exists, otherwise every top-level directory that holds a source file (for example `crates/` and `tests/` in a Cargo workspace), otherwise `src/`. It also writes `exclude_patterns.txt`, empty `file_health.md`, `pending_updates.md`, `library.md`, `.wikifier/config`, `.wikifier/marker`, and the `journal/` and `Logged_issues/` directories when those paths are absent. `--no-copy` skips `index.html`. Otherwise `index.html` is copied only when it is missing. - `serve` listens on `127.0.0.1:8787` until stopped. It answers `/`, `/__wikifier/api/{bootstrap,file,dependencies,dependents,cycles,journal,diagnostics}`, `/__wikifier/status`, and the project files the dashboard reads (`file_health.json`, `library.md`). `POST /__wikifier/run` accepts `update-maps`, `update-maps --full`, and `check-changes`. `POST /__wikifier/shutdown` stops the process. `monitor` repeats `check-changes` (default every 30 seconds). `daemon run` checks every `WIKIFIER_POLL_INTERVAL` seconds (default 30) and runs `update-maps` at start and then at most every `WIKIFIER_DAEMON_MAPS_INTERVAL` seconds (default 600). `WIKIFIER_DAEMON_MAPS=0` skips the map. State is `.wikifier_staging/wikifier.pid`, `daemon.log`, and `daemon_heartbeat.json`. `daemon logs`, `restart`, `install-service`, and `uninstall-service` are supported (systemd user unit on Linux, launchd plist on macOS, Task Scheduler on Windows). `daemon start` refuses readiness `blocked` unless `--force`. `not_ready` may start. `issues` is the same `actions[]` list as `suggest-next`. `library.md` stays the short tree, Mermaid block, and dependency table; cycle severity is on `cycles`, not in that file. ```bash diff --git a/skills/run.md.wiki.md b/skills/run.md.wiki.md index 5060820..84b8bd5 100644 --- a/skills/run.md.wiki.md +++ b/skills/run.md.wiki.md @@ -4,4 +4,4 @@ Part 1 is the session loop: bootstrap, check-changes, suggest-next, prepare-edit, record-change, mark-green, update-maps. Part 2 is the reference for health, wiki verification, batch recording, dependencies, and commands. Wiki verification covers Python, JS/TS, and Rust `pub` items including impl methods and trait-impl items. Rust import names include `[workspace] members`. -The implementation note records how the std-only Rust binary fills that contract: `wk_cache`, regex-plus-reexport parsing, Rust paths from `crate::` / `self::` / `super::` / the root Cargo.toml crate name, map artifacts, deletion, MCP errors, the project lock, and a standing `serve` that feeds the dashboard. Daemon actions are `start`, `stop`, `status`, and `run`. `init DIR`, `verify-wikis DIR`, and `search-journal --file` apply. The protocol version stays v0.7. The package line is 5.0.0. The live version command is `wikifier --version` (from `Cargo.toml`). +The implementation note records how the std-only Rust binary fills that contract: `wk_cache`, regex-plus-reexport parsing, Rust paths from `crate::` / `self::` / `super::` / the root Cargo.toml crate name, map artifacts, deletion, MCP errors, the project lock, and a standing `serve` that feeds the dashboard. Daemon actions are `start`, `stop`, `status`, and `run`. `init DIR`, `verify-wikis DIR`, and `search-journal --file` apply. `init` seeds the map roots it finds. File arguments may use `\` separators. Lookups on an unknown path return `FileNotFound`; an MCP call missing a required argument returns `InvalidArguments`; MCP `update_maps` leaves out the edge list unless `edges: true`. The protocol version stays v0.7. The package line is 5.1.0. The live version command is `wikifier --version` (from `Cargo.toml`). diff --git a/src/api.rs b/src/api.rs index 769ea0b..e890350 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ //! One function per agent-facing operation. CLI and MCP both call these. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::path::{Path, PathBuf}; @@ -8,7 +8,7 @@ use crate::artifacts::{self, HealthRow, JournalEntry}; use crate::cache::{self, FileUpdate, Session}; use crate::jsonutil::Json; use crate::scope::{self, Scope}; -use crate::sha256::{file_sha256_prefixed, sha256_prefixed}; +use crate::sha256::{content_sha256_prefixed, file_sha256_prefixed}; use crate::timeutil::local_stamp; pub struct MapRun { @@ -84,6 +84,45 @@ fn fail(error_type: &str, error: impl Into) -> Json { ]) } +/// A lookup on a path that is neither on disk nor in the map fails with a next step, +/// instead of a `success: true` reply whose fields are all empty. +fn unknown_file(root: &Path, rel: &str) -> Option { + if rel.is_empty() { + return Some(fail( + "ValueError", + "file is required: pass a project-relative path such as src/main.rs", + )); + } + if root.join(rel).exists() || cache::all_paths(root).iter().any(|p| p == rel) { + return None; + } + Some(fail( + "FileNotFound", + format!( + "{rel} is not on disk under {} and not in the map. Pass a path relative to the project root; list_paths shows the mapped files.", + root.display() + ), + )) +} + +/// `limit` caps the bootstrap `files` list and adds `files_total`. `0` or no limit keeps +/// every file. `map_index` and `list_paths` are the way to orient on a large tree. +fn cap_files(mut value: Json, flags: &BTreeMap) -> Json { + let limit = flags + .get("limit") + .and_then(|s| s.parse::().ok()) + .filter(|n| *n > 0); + if let (Some(limit), Some(Json::Array(files))) = (limit, value.get("files")) { + if files.len() > limit { + let total = files.len(); + let kept = files[..limit].to_vec(); + value.insert("files", Json::Array(kept)); + value.insert("files_total", Json::Int(total as i64)); + } + } + value +} + fn root_or_err(root: Option<&Path>) -> Result { let root = match root { Some(p) => p.to_path_buf(), @@ -278,7 +317,9 @@ pub fn update_maps_run(root: &Path, run: &MapRun) -> Json { seen.insert(rel.clone()); let (mtime, size) = cache::stat_ns(file).unwrap_or((0, 0)); let known = session.known(&rel); + let reparse = session.needs_parse(&rel); let stat_hit = !full + && !reparse && known .as_ref() .is_some_and(|(old_mtime, old_size, old_hash)| { @@ -292,7 +333,7 @@ pub fn update_maps_run(root: &Path, run: &MapRun) -> Json { continue; } let bytes = fs::read(file).unwrap_or_default(); - let hash = sha256_prefixed(&bytes); + let hash = content_sha256_prefixed(&bytes); let (mtime, size) = if mtime == 0 && size == 0 { cache::stat_ns(file).unwrap_or((0, bytes.len() as u64)) } else { @@ -300,7 +341,7 @@ pub fn update_maps_run(root: &Path, run: &MapRun) -> Json { }; hashed += 1; if let Some((_, _, old_hash)) = &known { - if old_hash == &hash && old_hash.starts_with("sha256:") { + if !reparse && old_hash == &hash && old_hash.starts_with("sha256:") { session.note_stat(&rel, mtime, size); continue; } @@ -430,6 +471,7 @@ pub fn update_maps_run(root: &Path, run: &MapRun) -> Json { } else { edge_pairs.len() as i64 }; + let cache_changed = session.has_changes(); if let Err(err) = session.commit() { let kind = if err.contains("locked") { "CacheLocked" @@ -439,8 +481,10 @@ pub fn update_maps_run(root: &Path, run: &MapRun) -> Json { return fail_warnings(kind, err, &warnings); } let _ = seed_health_from_map(root); - if let Err(err) = crate::maps_out::write(root) { - warnings.push(format!("map artifacts: {err}")); + if cache_changed || !crate::maps_out::current(root) { + if let Err(err) = crate::maps_out::write(root) { + warnings.push(format!("map artifacts: {err}")); + } } let map_complete = files_remaining_dirty == 0; let status = Json::obj(vec![ @@ -524,6 +568,7 @@ fn seed_health_from_map(root: &Path) -> Result<(), String> { let stamp = local_stamp(); let mut changed = false; let scoped = monitored_seed_prefixes(root); + let indexed = cache::index_by_path(root); for rel in cache::all_paths(root) { if let Some(prefixes) = &scoped { if !prefixes.iter().any(|prefix| under_prefix(prefix, &rel)) { @@ -533,7 +578,9 @@ fn seed_health_from_map(root: &Path) -> Result<(), String> { if known.contains(&rel) { continue; } - let hash = file_sha256_prefixed(&root.join(&rel)).unwrap_or_default(); + let path = root.join(&rel); + let hash = cache::hash_if_unchanged(&path, indexed.get(&rel)) + .unwrap_or_else(|| file_sha256_prefixed(&path).unwrap_or_default()); rows.push(HealthRow::new( rel, "🟡 Yellow", @@ -631,7 +678,11 @@ pub fn load_dependencies(root: &Path, rel: &str) -> Json { pub fn dependency_report(root: &Path, rel: &str, full: bool) -> Json { let _ = crate::legacy_cache::import_if_empty(root); - let rel = rel.trim_start_matches("./"); + let rel = scope::rel_arg(root, rel); + let rel = rel.as_str(); + if let Some(err) = unknown_file(root, rel) { + return err; + } match cache::load_dependency_view(root, rel) { Ok(view) => { let cycled = if full { @@ -717,6 +768,11 @@ fn edge_record(edge: &cache::InternalEdge, full: bool, in_cycle: bool) -> Json { pub fn load_dependents(root: &Path, rel: &str) -> Json { let _ = crate::legacy_cache::import_if_empty(root); + let rel = scope::rel_arg(root, rel); + let rel = rel.as_str(); + if let Some(err) = unknown_file(root, rel) { + return err; + } match cache::load_dependents(root, rel) { Ok(srcs) => ok(vec![ ("file", Json::String(rel.into())), @@ -1089,9 +1145,15 @@ pub fn check_changes(root: &Path) -> Json { let reason = "content changed since last mark-green (check-changes)"; let mut dirty = Vec::new(); let mut changed_rows = false; + let indexed = cache::index_by_path(root); + let mut row_at: HashMap = rows + .iter() + .enumerate() + .map(|(i, row)| (row.file.clone(), i)) + .collect(); for rel in watched { let path = root.join(&rel); - let idx = rows.iter().position(|r| r.file == rel); + let idx = row_at.get(&rel).copied(); if !path.is_file() { dirty.push(rel.clone()); let already = idx.map(|i| is_red(&rows[i].status)).unwrap_or(false); @@ -1111,6 +1173,7 @@ pub fn check_changes(root: &Path) -> Json { if let Some(i) = idx { rows[i] = row; } else { + row_at.insert(rel.clone(), rows.len()); rows.push(row); } let _ = artifacts::append_journal(root, "check-changes", &rel, "missing on disk"); @@ -1118,7 +1181,6 @@ pub fn check_changes(root: &Path) -> Json { } continue; } - let live = file_sha256_prefixed(&path).unwrap_or_default(); let baseline = idx.and_then(|i| { let h = rows[i].source_hash.clone(); if h.starts_with("sha256:") { @@ -1127,13 +1189,16 @@ pub fn check_changes(root: &Path) -> Json { None } }); + let index_row = indexed.get(&rel); + // Stat first: a file whose size and mtime still match its cache row has the content + // hash recorded there (the rule update-maps skips by). Read and hash it only when + // the stat moved or that hash is not the baseline being checked. + let live = cache::hash_if_unchanged(&path, index_row) + .filter(|hash| baseline.as_ref().is_none_or(|base| base == hash)) + .unwrap_or_else(|| file_sha256_prefixed(&path).unwrap_or_default()); let drifted = match &baseline { Some(base) => live != *base, - None => cache::indexed_rows(root) - .into_iter() - .find(|r| r.0 == rel) - .map(|r| r.3 != live) - .unwrap_or(false), + None => index_row.map(|r| r.2 != live).unwrap_or(false), }; if !drifted { continue; @@ -1166,6 +1231,7 @@ pub fn check_changes(root: &Path) -> Json { if let Some(i) = idx { rows[i] = row; } else { + row_at.insert(rel.clone(), rows.len()); rows.push(row); } let _ = artifacts::append_journal(root, "check-changes", &rel, reason); @@ -1173,7 +1239,7 @@ pub fn check_changes(root: &Path) -> Json { let text = fs::read_to_string(&pending).unwrap_or_default(); let text = crate::pending_line::append_item(&text, &rel, crate::pending_line::CHECK_MESSAGE); - let _ = fs::write(&pending, text); + let _ = artifacts::write_durable(&pending, text.as_bytes()); changed_rows = true; } } @@ -1202,8 +1268,12 @@ pub fn check_changes(root: &Path) -> Json { } pub fn prepare_edit(root: &Path, file: &str) -> Json { - let rel = file.trim_start_matches("./"); + let rel = scope::rel_arg(root, file); + let rel = rel.as_str(); let _ = crate::legacy_cache::import_if_empty(root); + if let Some(err) = unknown_file(root, rel) { + return err; + } let deps = cache::load_dependencies(root, rel).unwrap_or_default(); let importers = cache::load_dependents(root, rel).unwrap_or_default(); let row = artifacts::read_health(root) @@ -1244,7 +1314,8 @@ pub fn record_change(root: &Path, file: &str, reason: &str) -> Json { Ok(lock) => lock, Err(err) => return err, }; - let rel = file.trim_start_matches("./"); + let rel = scope::rel_arg(root, file); + let rel = rel.as_str(); let path = root.join(rel); if !path.is_file() { return fail("FileNotFound", format!("file does not exist: {rel}")); @@ -1268,7 +1339,7 @@ pub fn record_change(root: &Path, file: &str, reason: &str) -> Json { let text = fs::read_to_string(&pending).unwrap_or_default(); let text = crate::pending_line::append_item(&text, rel, &crate::pending_line::record_message(reason)); - if fs::write(&pending, text).is_err() { + if artifacts::write_durable(&pending, text.as_bytes()).is_err() { return fail("PendingError", "could not write pending_updates.md"); } ok(vec![ @@ -1283,7 +1354,8 @@ pub fn mark_green(root: &Path, file: &str, reason: &str, force: bool) -> Json { Ok(lock) => lock, Err(err) => return err, }; - let rel = file.trim_start_matches("./"); + let rel = scope::rel_arg(root, file); + let rel = rel.as_str(); let path = root.join(rel); if !path.is_file() { return fail("FileNotFound", format!("file does not exist: {rel}")); @@ -1629,7 +1701,6 @@ pub fn cycles(root: &Path) -> Json { pub fn cycles_view(root: &Path, analysis: bool, max_items: Option) -> Json { let _ = crate::legacy_cache::import_if_empty(root); let edges = cache::edges(root); - let meta = edge_meta(root); let mut graph: BTreeMap> = BTreeMap::new(); for (src, dst) in &edges { graph.entry(src.clone()).or_default().push(dst.clone()); @@ -1637,6 +1708,7 @@ pub fn cycles_view(root: &Path, analysis: bool, max_items: Option) -> Jso } let sccs = tarjan(&graph); let cyclic: Vec> = sccs.into_iter().filter(|c| c.len() > 1).collect(); + let meta = edge_meta(root, cyclic.iter().flatten()); let value = ok(vec![ ("count", Json::Int(cyclic.len() as i64)), ( @@ -1710,13 +1782,17 @@ pub fn cycles_view(root: &Path, analysis: bool, max_items: Option) -> Jso value } -fn edge_meta(root: &Path) -> BTreeMap<(String, String), cache::InternalEdge> { +/// Edge flags for the given source files only. `cycles` needs them for files inside a +/// cycle, so it no longer opens every mapped file's records. +fn edge_meta<'a>( + root: &Path, + files: impl Iterator, +) -> BTreeMap<(String, String), cache::InternalEdge> { let mut out = BTreeMap::new(); - for rel in cache::all_paths(root) { - if let Ok(view) = cache::load_dependency_view(root, &rel) { - for edge in view.internal { - out.insert((rel.clone(), edge.path.clone()), edge); - } + let files: BTreeSet<&String> = files.collect(); + for rel in files { + for edge in cache::load_internal_edges(root, rel) { + out.insert((rel.clone(), edge.path.clone()), edge); } } out @@ -2231,7 +2307,8 @@ pub fn record_deletion(root: &Path, file: &str, reason: &str) -> Json { Ok(lock) => lock, Err(err) => return err, }; - let rel = file.trim_start_matches("./"); + let rel = scope::rel_arg(root, file); + let rel = rel.as_str(); let written = match artifacts::append_journal(root, "record-deletion", rel, reason) { Ok(written) => written, Err(e) => return fail("JournalError", e), @@ -2274,18 +2351,17 @@ pub fn record_deletion(root: &Path, file: &str, reason: &str) -> Json { fn drop_pending_line(root: &Path, rel: &str) { let path = root.join("pending_updates.md"); let text = fs::read_to_string(&path).unwrap_or_default(); - if text.is_empty() { + if text.is_empty() + || !text + .lines() + .any(|line| crate::pending_line::file_of(line) == Some(rel)) + { return; } - let mut out = String::new(); - for line in text.lines() { - let hit = crate::pending_line::file_of(line) == Some(rel); - if !hit { - out.push_str(line); - out.push('\n'); - } - } - let _ = fs::write(path, out); + let _ = artifacts::write_durable( + &path, + crate::pending_line::remove_item(&text, rel).as_bytes(), + ); } pub fn heal_stubs(root: &Path, min_len: usize, dry_run: bool) -> Json { @@ -2361,7 +2437,7 @@ pub fn prune_health_opts(root: &Path, deleted_missing: bool) -> Json { out.push('\n'); } } - let _ = fs::write(pending, out); + let _ = artifacts::write_durable(&pending, out.as_bytes()); } ok(vec![ ("changed", Json::Int(changed as i64)), @@ -2396,7 +2472,7 @@ pub fn prune_pending(root: &Path) -> Json { out.push('\n'); } } - let _ = fs::write(path, out); + let _ = artifacts::write_durable(&path, out.as_bytes()); ok(vec![("changed", Json::Int(changed))]) } @@ -3316,7 +3392,11 @@ pub fn dispatch( positional: &[String], flags: &BTreeMap, ) -> Json { - let file = positional.first().map(|s| s.as_str()).unwrap_or(""); + let file_arg = positional + .first() + .map(|s| scope::rel_arg(root, s)) + .unwrap_or_default(); + let file = file_arg.as_str(); let reason = if positional.len() > 1 { positional[1..].join(" ") } else { @@ -3332,9 +3412,10 @@ pub fn dispatch( flags.get("force").map(|s| s == "true").unwrap_or(false), ), "record-deletion" | "record_deletion" => record_deletion(root, file, &reason), - "session-bootstrap" | "bootstrap" | "session-start" | "session_bootstrap" => { - session_bootstrap_scoped(root, flag_text(flags, "directory")) - } + "session-bootstrap" | "bootstrap" | "session-start" | "session_bootstrap" => cap_files( + session_bootstrap_scoped(root, flag_text(flags, "directory")), + flags, + ), "suggest-next" | "suggest" | "suggest-next-actions" | "suggest_next_actions" => { suggest_next_scoped(root, flag_text(flags, "directory")) } @@ -3396,7 +3477,17 @@ pub fn dispatch( } else { flags.get("format").map(|s| s.as_str()).unwrap_or("json") }; - health_view(root, flag_text(flags, "directory"), format) + let mut value = health_view(root, flag_text(flags, "directory"), format); + let limit = flags.get("limit").and_then(|s| s.parse::().ok()); + if let (Some(limit), Some(Json::Array(rows))) = (limit, value.get("entries")) { + if rows.len() > limit { + let total = rows.len(); + let kept = rows[..limit].to_vec(); + value.insert("entries", Json::Array(kept)); + value.insert("entries_total", Json::Int(total as i64)); + } + } + value } "cache-status" | "cache" => cache_status(root), "list-core-tools" | "list_core_tools" => ok(vec![ @@ -3554,12 +3645,18 @@ pub fn dispatch( ]) } "init" => init_project(root, flag_on(flags, "no-copy")), - "get_project_status" => session_bootstrap_scoped(root, flag_text(flags, "directory")), + "get_project_status" => cap_files( + session_bootstrap_scoped(root, flag_text(flags, "directory")), + flags, + ), "get_current_project_root" => ok(vec![( "project_root", Json::String(root.display().to_string()), )]), "get_file_wiki" => { + if let Some(err) = unknown_file(root, file) { + return err; + } let (path, wiki) = crate::wiki_files::read_wiki(root, file); ok(vec![ ("file", Json::String(file.into())), @@ -3816,16 +3913,16 @@ fn init_project(root: &Path, no_copy: bool) -> Json { created.push(rel.to_string()); true }; - let map_body = "\ -# map_paths.txt — import-map package roots for update-maps -# One directory per line. No bare \".\". -src/ -"; - let monitored_body = "\ -# monitored_paths.txt — wiki and health watch list -# Mapped sources are watched even when they are not listed here. -src/ -"; + let roots = scope::default_map_roots(root) + .into_iter() + .map(|dir| format!("{dir}\n")) + .collect::(); + let map_body = format!( + "# map_paths.txt — import-map package roots for update-maps\n# One directory per line. No bare \".\".\n{roots}" + ); + let monitored_body = format!( + "# monitored_paths.txt — wiki and health watch list\n# Mapped sources are watched even when they are not listed here.\n{roots}" + ); let exclude_body = "\ node_modules .git @@ -3841,16 +3938,15 @@ target | File | Status | Last Updated | Reason / Intent | |------|--------|--------------|-----------------| "; - let pending_body = - "# Pending Updates\n\n(no pending items — run check-changes after making edits)\n"; + let pending_body = format!("# Pending Updates\n\n{}\n", crate::pending_line::EMPTY_NOTE); let library_body = "# Library & Imports Map\n\nRun `wikifier update-maps` to populate.\n"; if !path_list_has_entries(root, "map_paths.txt") - && !write_new("map_paths.txt", map_body, &mut created) + && !write_new("map_paths.txt", &map_body, &mut created) { return fail("InitError", "could not write map_paths.txt"); } if !path_list_has_entries(root, "monitored_paths.txt") - && !write_new("monitored_paths.txt", monitored_body, &mut created) + && !write_new("monitored_paths.txt", &monitored_body, &mut created) { return fail("InitError", "could not write monitored_paths.txt"); } @@ -3860,7 +3956,7 @@ target if !write_new("file_health.md", health_body, &mut created) { return fail("InitError", "could not write file_health.md"); } - if !write_new("pending_updates.md", pending_body, &mut created) { + if !write_new("pending_updates.md", &pending_body, &mut created) { return fail("InitError", "could not write pending_updates.md"); } if !write_new("library.md", library_body, &mut created) { diff --git a/src/api.rs.wiki.md b/src/api.rs.wiki.md index b99e56e..e4922e7 100644 --- a/src/api.rs.wiki.md +++ b/src/api.rs.wiki.md @@ -6,6 +6,6 @@ `record_deletion` journals the removal, marks the health row Red, and drops the file from the graph. `record_changes_opts` takes the git change list and matches reasons by exact path, directory prefix, then glob. `mark_green` checks Python, JS/TS, and Rust public names against the wiki and removes that file's pending line. Rust names come from `rust_public_symbols`. -Public names: `MapRun`, `VERSION`, `MCP_TOOLS`, `update_maps`, `update_maps_opts`, `update_maps_run`, `mapped_edges`, `load_dependencies`, `dependency_report`, `load_dependents`, `list_paths`, `session_bootstrap`, `suggest_next_actions`, `check_changes`, `prepare_edit`, `record_change`, `mark_green`, `health`, `read_health`, `read_journal`, `cache_status`, `validate`, `cycles`, `verify_wikis`, `record_changes`, `record_changes_opts`, `record_deletion`, `heal_stubs`, `prune_health`, `prune_health_opts`, `prune_pending`, `stale_wikis`, `prune_barrels`, `metrics_snapshot`, `healing_stats`, `resolution_diagnostics`, `barrel_reports`, `incremental_status`, `serve_once`, `serve_loop`, `monitor_loop`, `daemon_ctl`, `dispatch`, `CLI_COMMANDS`, `ensure_root`. +Public names: `MapRun`, `VERSION`, `MCP_TOOLS`, `update_maps`, `update_maps_opts`, `update_maps_run`, `mapped_edges`, `load_dependencies`, `dependency_report`, `load_dependents`, `list_paths`, `session_bootstrap`, `suggest_next_actions`, `check_changes`, `prepare_edit`, `record_change`, `mark_green`, `health`, `read_health`, `read_journal`, `cache_status`, `validate`, `cycles`, `verify_wikis`, `record_changes`, `record_changes_opts`, `record_deletion`, `heal_stubs`, `prune_health`, `prune_health_opts`, `prune_pending`, `stale_wikis`, `prune_barrels`, `metrics_snapshot`, `healing_stats`, `resolution_diagnostics`, `barrel_reports`, `incremental_status`, `serve_once`, `serve_loop`, `monitor_loop`, `daemon_ctl`, `dispatch`, `CLI_COMMANDS`, `ensure_root`, `seed_source_hashes`, `list_paths_at`, `session_bootstrap_scoped`, `suggest_next_scoped`, `health_view`, `cycles_view`, `stale_wikis_in`, `daemon_command`. -`serve_loop` answers the JSON API, `/__wikifier/status`, dashboard `run` and `shutdown`, and project files such as `file_health.json` and `library.md`. `monitor_loop` and `daemon_ctl` (`start`, `stop`, `status`, `run`) are the other standing processes. `heal_stubs` with `dry_run` counts long Initial stubs and does not write. `seed-health` returns `HealthError` when the write fails. `verify_wikis_under` can limit the check to one file or directory. `search-journal --file` keeps one path. Mutating commands take `.wikifier_staging/.lock` before the cache lock. `prune_health` and `prune_pending` keep files named in `monitored_paths.txt`. `prune-health --deleted-missing` also drops rows whose path is no longer a file. +`serve_loop` answers the JSON API, `/__wikifier/status`, dashboard `run` and `shutdown`, and project files such as `file_health.json` and `library.md`. `monitor_loop` and `daemon_ctl` (`start`, `stop`, `status`, `run`) are the other standing processes. `heal_stubs` with `dry_run` counts long Initial stubs and does not write. `seed-health` returns `HealthError` when the write fails. `verify_wikis_under` can limit the check to one file or directory. `search-journal --file` keeps one path. File arguments go through `scope::rel_arg`, so `src.rs`, `./src/a.rs`, and an absolute path under the root all name the `src/a.rs` row. `prepare_edit`, `dependency_report`, `load_dependents`, and `get_file_wiki` return `FileNotFound` (with a `list_paths` hint) for a path that is neither on disk nor in the map, and `ValueError` for an empty path. `health --limit N` keeps N entries and reports `entries_total`. `init` seeds `map_paths.txt` and `monitored_paths.txt` from `scope::default_map_roots`. Content hashes for health baselines use `content_sha256_prefixed`, so CRLF and LF copies of a file match. `check_changes` and health seeding reuse a mapped file's cache hash when size and mtime match (`cache::hash_if_unchanged`); `cycles` loads edge flags only for files in a cycle; `update_maps_run` skips `maps_out::write` when the commit changed nothing and the artifacts are current; `session-bootstrap --limit` caps `files` and adds `files_total`. Mutating commands take `.wikifier_staging/.lock` before the cache lock. `prune_health` and `prune_pending` keep files named in `monitored_paths.txt`. `prune-health --deleted-missing` also drops rows whose path is no longer a file. diff --git a/src/artifacts.rs b/src/artifacts.rs index 0356a6a..6972aa0 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -54,6 +54,13 @@ impl HealthRow { } } +/// Replace a user-facing state file (health, journal, pending) atomically and durably: temp +/// file, sync, rename, directory flush. Unlike the derived cache, these hold the agents' +/// recorded reasons and baselines and cannot be rebuilt from the sources. +pub fn write_durable(path: &Path, bytes: &[u8]) -> Result<(), String> { + crate::cache::atomic_write(path, bytes) +} + pub fn read_health(root: &Path) -> Vec { let json_path = root.join("file_health.json"); if json_path.is_file() { @@ -205,7 +212,10 @@ pub fn write_health(root: &Path, rows: &[HealthRow]) -> Result<(), String> { ), ), ]); - fs::write(root.join("file_health.json"), stable.to_string()).map_err(|e| e.to_string())?; + write_durable( + &root.join("file_health.json"), + stable.to_string().as_bytes(), + )?; let mut md = String::from("# Documentation Health Matrix\n\n| File | Status | Last Updated | Reason / Intent |\n|------|--------|--------------|-----------------|\n"); let mut ordered = rows.to_vec(); ordered.sort_by(|a, b| a.file.cmp(&b.file)); @@ -218,7 +228,7 @@ pub fn write_health(root: &Path, rows: &[HealthRow]) -> Result<(), String> { row.reason.replace('|', "/") )); } - fs::write(root.join("file_health.md"), md).map_err(|e| e.to_string())?; + write_durable(&root.join("file_health.md"), md.as_bytes())?; Ok(()) } @@ -332,7 +342,7 @@ pub fn append_journal( let block = format!("## [{stamp}] {action}\n**File:** {file}\n**Reason:** {reason}\n\n"); let mut body = existing; body.push_str(&block); - fs::write(&path, body).map_err(|e| e.to_string())?; + write_durable(&path, body.as_bytes())?; Ok(JournalWrite { stamp, journaled: true, diff --git a/src/artifacts.rs.wiki.md b/src/artifacts.rs.wiki.md index 3439dd5..2614942 100644 --- a/src/artifacts.rs.wiki.md +++ b/src/artifacts.rs.wiki.md @@ -4,6 +4,6 @@ `file_health.json` is schema version 2. A row can store `public_symbols`, `wiki_hash`, and `source_mtime_ns` from the last mark-green. Those fields are written only when they are set, so an older health file is not rewritten just by being read. -`append_journal` skips an identical action, file, and reason inside 120 seconds. `folder_card` reads `.wikifier_staging/maps/_root.json` or a directory `_index.json`. +`append_journal` skips an identical action, file, and reason inside 120 seconds. `write_durable` replaces health, journal, and pending files through a synced temp file and a rename; `write_health` and `append_journal` use it. `folder_card` reads `.wikifier_staging/maps/_root.json` or a directory `_index.json`. -Public names: `HealthRow`, `new`, `keep_baseline_from`, `read_health`, `write_health`, `upsert_health_row`, `JournalEntry`, `journal_path`, `read_journal`, `parse_journal`, `JournalWrite`, `append_journal`, `read_pending`, `folder_card`. +Public names: `HealthRow`, `new`, `keep_baseline_from`, `read_health`, `write_health`, `upsert_health_row`, `JournalEntry`, `journal_path`, `read_journal`, `parse_journal`, `JournalWrite`, `append_journal`, `read_pending`, `folder_card`, `write_durable`. diff --git a/src/cache.rs b/src/cache.rs index 91e8876..1afdd1a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -103,6 +103,16 @@ pub(crate) fn names_marker(root: &Path) -> PathBuf { pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { let parent = path.parent().unwrap_or(Path::new(".")); fs::create_dir_all(parent).map_err(|e| e.to_string())?; + write_replace(path, bytes, true)?; + fsync_dir(parent); + Ok(()) +} + +/// Write a temp file beside `path` and rename it into place, so a reader never sees a +/// torn file. `durable` also syncs the data before the rename and writes the rename +/// through; the parent directory must exist and the caller flushes it. +fn write_replace(path: &Path, bytes: &[u8], durable: bool) -> Result<(), String> { + let parent = path.parent().unwrap_or(Path::new(".")); let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("cache"); let tmp = parent.join(format!(".{file_name}.{}.{seq}.tmp", std::process::id())); @@ -113,44 +123,110 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { .open(&tmp) .map_err(|e| e.to_string())?; file.write_all(bytes).map_err(|e| e.to_string())?; - file.sync_all().map_err(|e| e.to_string())?; + if durable { + file.sync_all().map_err(|e| e.to_string())?; + } Ok(()) })(); if let Err(err) = write_result { let _ = fs::remove_file(&tmp); return Err(err); } - if let Err(err) = replace_file(&tmp, path) { + if let Err(err) = replace_file(&tmp, path, durable) { let _ = fs::remove_file(&tmp); return Err(err); } - fsync_dir(parent); Ok(()) } -pub(crate) fn write_labeled(path: &Path, rel: &str, body: &str) -> Result<(), String> { +/// Write many derived cache files: each goes to a temp file and is renamed into place, so a +/// reader never sees a torn file, but nothing is synced. The cache can be rebuilt from the +/// sources, and `Session::open` treats a file whose size or content does not check out as +/// a miss and parses that source again. Writes run on several threads. +pub(crate) fn write_derived_all(entries: &[(PathBuf, Vec)]) -> Result<(), String> { + if entries.is_empty() { + return Ok(()); + } + let parents: BTreeSet = entries + .iter() + .map(|(path, _)| path.parent().unwrap_or(Path::new(".")).to_path_buf()) + .collect(); + for dir in &parents { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + .clamp(1, 16); + let chunk = entries.len().div_ceil(threads).max(1); + std::thread::scope(|scope| { + let workers: Vec<_> = entries + .chunks(chunk) + .map(|part| { + scope.spawn(move || { + part.iter() + .try_for_each(|(path, bytes)| write_replace(path, bytes, false)) + }) + }) + .collect(); + workers.into_iter().try_for_each(|worker| { + worker + .join() + .unwrap_or_else(|_| Err("cache writer thread panicked".to_string())) + }) + }) +} + +/// The bytes of an edge or notes file: an `@` header line, then the body. +pub(crate) fn labeled(rel: &str, body: &str) -> Vec { let mut text = format!("@\t{rel}\n"); text.push_str(body); if !text.ends_with('\n') { text.push('\n'); } - atomic_write(path, text.as_bytes()) + text.into_bytes() +} + +/// Body lines of a labeled cache file written for `rel`: valid UTF-8, no NUL, an +/// `@` header and a final newline. `None` for a torn or foreign file. +pub(crate) fn labeled_lines(bytes: &[u8], rel: &str) -> Option> { + let text = std::str::from_utf8(bytes).ok()?; + let body = text.strip_prefix(&format!("@\t{rel}\n"))?; + if text.contains('\0') || !text.ends_with('\n') { + return None; + } + Some( + body.lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(), + ) } -fn replace_file(from: &Path, to: &Path) -> Result<(), String> { +/// Body lines of `rel`'s record of one kind: `Some(empty)` when there is no file, `None` +/// when the file is there but does not check out. +fn record_lines(root: &Path, sub: &str, rel: &str, ext: &str) -> Option> { + match fs::read(read_file(root, sub, rel, ext)) { + Ok(bytes) => labeled_lines(&bytes, rel), + Err(_) => Some(Vec::new()), + } +} + +fn replace_file(from: &Path, to: &Path, durable: bool) -> Result<(), String> { #[cfg(windows)] { - replace_file_windows(from, to) + replace_file_windows(from, to, durable) } #[cfg(not(windows))] { + let _ = durable; fs::rename(from, to) .map_err(|e| format!("rename {} -> {}: {e}", from.display(), to.display())) } } #[cfg(windows)] -fn replace_file_windows(from: &Path, to: &Path) -> Result<(), String> { +fn replace_file_windows(from: &Path, to: &Path, durable: bool) -> Result<(), String> { use std::os::windows::ffi::OsStrExt; const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; const MOVEFILE_WRITE_THROUGH: u32 = 0x8; @@ -169,7 +245,11 @@ fn replace_file_windows(from: &Path, to: &Path) -> Result<(), String> { MoveFileExW( src.as_ptr(), dst.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + if durable { + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH + } else { + MOVEFILE_REPLACE_EXISTING + }, ) }; if ok == 0 { @@ -260,10 +340,9 @@ pub(crate) fn dst_of(line: &str) -> String { } pub fn load_dependencies(root: &Path, rel: &str) -> Result, String> { - let mut dsts: Vec = content_lines(&by_src_path(root, rel)) + let mut dsts: Vec = load_internal_edges(root, rel) .into_iter() - .map(|line| dst_of(&line)) - .filter(|s| !s.is_empty()) + .map(|edge| edge.path) .collect(); dsts.sort(); dsts.dedup(); @@ -351,39 +430,11 @@ pub struct DependencyView { pub unresolved: Vec<(String, i64, String)>, } -fn notes_path(root: &Path, rel: &str) -> PathBuf { - read_file(root, "notes", rel, "txt") -} - pub fn load_dependency_view(root: &Path, rel: &str) -> Result { - let mut internal = Vec::new(); - for line in content_lines(&by_src_path(root, rel)) { - let mut parts = line.split('\t'); - let path = parts.next().unwrap_or("").trim().to_string(); - if path.is_empty() { - continue; - } - let line_no = parts.next().unwrap_or("0").parse().unwrap_or(0); - let names = parts - .next() - .unwrap_or("") - .split(',') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - let (conditional, dynamic, via_barrel) = parse_flags(parts.next().unwrap_or("")); - internal.push(InternalEdge { - path, - line: line_no, - names, - conditional, - dynamic, - via_barrel, - }); - } + let internal = load_internal_edges(root, rel); let mut external = Vec::new(); let mut unresolved = Vec::new(); - for line in read_lines(¬es_path(root, rel)) { + for line in record_lines(root, "notes", rel, "txt").unwrap_or_default() { let mut parts = line.split('\t'); match parts.next().unwrap_or("") { "E" => { @@ -417,8 +468,71 @@ pub fn load_dependency_view(root: &Path, rel: &str) -> Result Vec { + let Some(lines) = record_lines(root, "by_src", rel, "edges") else { + return index_dsts(root, rel) + .into_iter() + .map(|path| InternalEdge { + path, + line: 0, + names: Vec::new(), + conditional: false, + dynamic: false, + via_barrel: false, + }) + .collect(); + }; + let mut internal = Vec::new(); + for line in lines { + let mut parts = line.split('\t'); + let path = parts.next().unwrap_or("").trim().to_string(); + if path.is_empty() { + continue; + } + let line_no = parts.next().unwrap_or("0").parse().unwrap_or(0); + let names = parts + .next() + .unwrap_or("") + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + let (conditional, dynamic, via_barrel) = parse_flags(parts.next().unwrap_or("")); + internal.push(InternalEdge { + path, + line: line_no, + names, + conditional, + dynamic, + via_barrel, + }); + } + internal +} + +/// `rel`'s targets as `index.tsv` records them. +fn index_dsts(root: &Path, rel: &str) -> BTreeSet { + crate::cache_commit::index_targets(root) + .into_iter() + .find(|(src, _)| src == rel) + .and_then(|(_, dsts)| dsts) + .unwrap_or_default() +} + +/// Files that import `rel`. A `by_dst` record that does not check out is a miss: the +/// importers come from the targets in `index.tsv`. pub fn load_dependents(root: &Path, rel: &str) -> Result, String> { - let mut srcs = content_lines(&read_file(root, "by_dst", rel, "edges")); + let mut srcs = match record_lines(root, "by_dst", rel, "edges") { + Some(lines) => lines, + None => crate::cache_commit::index_targets(root) + .into_iter() + .filter(|(_, dsts)| dsts.as_ref().is_some_and(|d| d.contains(rel))) + .map(|(src, _)| src) + .collect(), + }; srcs.sort(); srcs.dedup(); Ok(srcs) @@ -481,6 +595,22 @@ pub fn indexed_rows(root: &Path) -> Vec<(String, u128, u64, String)> { .collect() } +/// Index rows by path: `(mtime_ns, size, content hash)`. +pub fn index_by_path(root: &Path) -> std::collections::HashMap { + indexed_rows(root) + .into_iter() + .map(|(rel, mtime, size, hash)| (rel, (mtime, size, hash))) + .collect() +} + +/// The content hash stored for `path` when its size and mtime still match the index row, +/// the same rule `update-maps` uses to skip a file. `None` means read and hash the file. +pub fn hash_if_unchanged(path: &Path, row: Option<&(u128, u64, String)>) -> Option { + let (mtime, size, hash) = row?; + (*mtime != 0 && hash.starts_with("sha256:") && stat_ns(path) == Some((*mtime, *size))) + .then(|| hash.clone()) +} + pub fn payload_bytes(root: &Path, rel: &str) -> Option> { fs::read(payload_path(root, rel)).ok() } @@ -496,14 +626,22 @@ pub fn stat_ns(path: &Path) -> Option<(u128, u64)> { Some((mtime, meta.len())) } +/// Every mapped edge, self-imports left out. Read from `index.tsv`, which carries each +/// file's resolved targets, instead of opening one `by_src` file per mapped file. An index +/// row written before targets were recorded falls back to that file's `by_src` record. pub fn edges(root: &Path) -> BTreeSet<(String, String)> { let mut out = BTreeSet::new(); - for rel in all_paths(root) { - if let Ok(dsts) = load_dependencies(root, &rel) { - for dst in dsts { - if dst != rel { - out.insert((rel.clone(), dst)); - } + for (rel, dsts) in crate::cache_commit::index_targets(root) { + let dsts = match dsts { + Some(dsts) => dsts, + None => load_dependencies(root, &rel) + .unwrap_or_default() + .into_iter() + .collect(), + }; + for dst in dsts { + if dst != rel { + out.insert((rel.clone(), dst)); } } } @@ -586,3 +724,52 @@ pub fn prune_barrel_files(root: &Path, max_age_days: f64, dry_run: bool) -> Resu } Ok(pruned) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_derived_all_publishes_every_file_and_leaves_no_temp() { + let dir = std::env::temp_dir().join(format!( + "wk-batch-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&dir); + let mut entries: Vec<(PathBuf, Vec)> = (0..300) + .map(|i| { + let sub = ["by_src", "by_dst", "payload"][i % 3]; + ( + dir.join(sub).join(format!("{i}.edges")), + format!("body {i}").into_bytes(), + ) + }) + .collect(); + write_derived_all(&entries).unwrap(); + // A second batch replaces existing files in place. + for (i, entry) in entries.iter_mut().enumerate() { + entry.1 = format!("second {i}").into_bytes(); + } + write_derived_all(&entries).unwrap(); + for (i, (path, _)) in entries.iter().enumerate() { + assert_eq!(fs::read_to_string(path).unwrap(), format!("second {i}")); + } + for sub in ["by_src", "by_dst", "payload"] { + let leftovers: Vec<_> = fs::read_dir(dir.join(sub)) + .unwrap() + .flatten() + .filter(|ent| ent.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "{sub}: {leftovers:?}"); + } + assert!(write_derived_all(&[]).is_ok()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn labeled_adds_the_header_and_a_final_newline() { + assert_eq!(labeled("src/a.rs", "x"), b"@\tsrc/a.rs\nx\n".to_vec()); + assert_eq!(labeled("src/a.rs", ""), b"@\tsrc/a.rs\n".to_vec()); + } +} diff --git a/src/cache.rs.wiki.md b/src/cache.rs.wiki.md index f24e539..3c0a2d9 100644 --- a/src/cache.rs.wiki.md +++ b/src/cache.rs.wiki.md @@ -4,6 +4,8 @@ Each mapped file has a payload, a `by_src` edge list, a `by_dst` edge list, notes, and an optional barrel record. An edge line is destination, line, names, then optional flag letters `c`, `d`, and `b`. +`atomic_write` writes a temp file, syncs it, renames it into place, and flushes the directory (index, generation, `sizes.tsv`, and user state through `artifacts::write_durable`). `write_derived_all` writes a batch of derived files on several threads through a temp file and a rename, without syncing; a commit uses it for the forward files, then for `by_dst`. `labeled_lines` checks a labeled record (UTF-8, no NUL, `@` header, final newline); `load_internal_edges`, `load_dependents`, and the notes read treat a record that fails it as a miss and answer from `index.tsv`. `edges` reads every file's targets from `index.tsv` (one file), falling back to `by_src` for an index row without targets. `load_internal_edges` reads one `by_src` record without the notes. `index_by_path` and `hash_if_unchanged` give `check-changes` and health seeding the stored content hash when a file's size and mtime still match its index row. + Confidence is computed when an edge is read: dynamic 0.4, conditional 0.55, via barrel 0.7, static 0.9. The ACS reason is `dynamic_literal`, `conditional_guard`, `via_barrel`, or `resolved_static`. -Public names: `check_consistency`, `rebuild`, `Consistency`, `Session`, `payload_path`, `by_src_path`, `commit_pending_path`, `load_dependencies`, `InternalEdge`, `confidence`, `acs_reason`, `confidence_of`, `format_edge_line`, `DependencyView`, `load_dependency_view`, `load_dependents`, `list_prefix`, `all_paths`, `FileUpdate`, `upsert_file`, `remove_file`, `indexed_rows`, `payload_bytes`, `stat_ns`, `edges`, `rel_string`, `write_barrel_record`, `prune_barrel_files`. +Public names: `check_consistency`, `rebuild`, `Consistency`, `Session`, `payload_path`, `by_src_path`, `commit_pending_path`, `load_dependencies`, `InternalEdge`, `confidence`, `acs_reason`, `confidence_of`, `format_edge_line`, `DependencyView`, `load_dependency_view`, `load_internal_edges`, `load_dependents`, `list_prefix`, `all_paths`, `FileUpdate`, `upsert_file`, `remove_file`, `indexed_rows`, `index_by_path`, `hash_if_unchanged`, `payload_bytes`, `stat_ns`, `edges`, `rel_string`, `write_barrel_record`, `prune_barrel_files`. diff --git a/src/cache_commit.rs b/src/cache_commit.rs index 385346e..34eef85 100644 --- a/src/cache_commit.rs +++ b/src/cache_commit.rs @@ -114,6 +114,8 @@ pub struct Session { barrels: Vec<(String, Vec)>, removed: BTreeSet, rewrite_index: bool, + sizes: Sizes, + reparse: BTreeSet, _lock: LockGuard, } @@ -131,17 +133,24 @@ impl Session { rev.entry(dst.clone()).or_default().insert(rel.clone()); } } + let check = check_entries(root, &rows, &rev); + let rewrite_index = rewrite_index + || check.rewrite + || !check.reparse.is_empty() + || !check.stale_dsts.is_empty(); Ok(Session { root: root.to_path_buf(), rows, rev, dirty_src: BTreeMap::new(), - dirty_dst: BTreeSet::new(), + dirty_dst: check.stale_dsts, payloads: BTreeMap::new(), notes: BTreeMap::new(), barrels: Vec::new(), removed: BTreeSet::new(), rewrite_index, + sizes: check.sizes, + reparse: check.reparse, _lock: lock, }) } @@ -298,10 +307,22 @@ impl Session { self.barrels.push((rel.to_string(), dsts.to_vec())); } - pub fn commit(self) -> Result<(), String> { - if !self.rewrite_index && self.barrels.is_empty() { + /// True when [`Session::commit`] would write anything. + pub fn has_changes(&self) -> bool { + self.rewrite_index || !self.barrels.is_empty() + } + + /// True when `rel`'s cache files failed the open check, so it must be parsed again + /// even though its size and mtime are unchanged. + pub fn needs_parse(&self, rel: &str) -> bool { + self.reparse.contains(rel) + } + + pub fn commit(mut self) -> Result<(), String> { + if !self.has_changes() { return Ok(()); } + let mut sizes = std::mem::take(&mut self.sizes); let gen = read_generation(&self.root).saturating_add(1); cache::atomic_write( &cache::commit_pending_path(&self.root), @@ -310,6 +331,9 @@ impl Session { // by_dst is keyed by the importer's target. Dropping a file removes its // forward edges above; the reverse file for this path stays until importers change. for rel in &self.removed { + for kind in ["by_src", "payload", "notes"] { + sizes.remove(&(kind.to_string(), rel.clone())); + } for (sub, ext) in [ ("payload", "json"), ("by_src", "edges"), @@ -320,6 +344,9 @@ impl Session { let _ = fs::remove_file(cache::legacy_file(&self.root, sub, rel, ext)); } } + // Forward files (by_src, payload, notes, barrels) are written and flushed as one + // batch before any by_dst file, so a crash between them is repaired from by_src. + let mut forward: Vec<(PathBuf, Vec)> = Vec::new(); for (rel, lines) in &self.dirty_src { if self.removed.contains(rel) { continue; @@ -329,28 +356,35 @@ impl Session { } else { format!("{}\n", lines.join("\n")) }; - cache::write_labeled( - &cache::store_file(&self.root, "by_src", rel, "edges"), - rel, - &body, - )?; + let bytes = cache::labeled(rel, &body); + sizes.insert(("by_src".into(), rel.clone()), bytes.len() as u64); + forward.push((cache::store_file(&self.root, "by_src", rel, "edges"), bytes)); } for (rel, payload) in &self.payloads { - cache::atomic_write( - &cache::store_file(&self.root, "payload", rel, "json"), - payload.as_bytes(), - )?; + sizes.insert(("payload".into(), rel.clone()), payload.len() as u64); + forward.push(( + cache::store_file(&self.root, "payload", rel, "json"), + payload.as_bytes().to_vec(), + )); } for (rel, notes) in &self.notes { let path = cache::store_file(&self.root, "notes", rel, "txt"); match notes { - Some(body) => cache::write_labeled(&path, rel, body)?, + Some(body) => { + let bytes = cache::labeled(rel, body); + sizes.insert(("notes".into(), rel.clone()), bytes.len() as u64); + forward.push((path, bytes)); + } None => { + sizes.remove(&("notes".to_string(), rel.clone())); let _ = fs::remove_file(&path); } } } - for (rel, dsts) in &self.barrels { + // A file staged twice keeps its last record, as the serial writes did. + let barrels: BTreeMap<&String, &Vec> = + self.barrels.iter().map(|(rel, dsts)| (rel, dsts)).collect(); + for (rel, dsts) in barrels { let body = crate::jsonutil::Json::obj(vec![ ("src", crate::jsonutil::Json::String(rel.clone())), ( @@ -363,24 +397,30 @@ impl Session { ), ), ]); - cache::atomic_write( - &cache::store_file(&self.root, "barrels", rel, "json"), - body.to_string().as_bytes(), - )?; + forward.push(( + cache::store_file(&self.root, "barrels", rel, "json"), + body.to_string().into_bytes(), + )); } - // Reverse edges after forward edges. A crash here is repaired from by_src. + cache::write_derived_all(&forward)?; + let mut reverse: Vec<(PathBuf, Vec)> = Vec::new(); for dst in &self.dirty_dst { let path = cache::store_file(&self.root, "by_dst", dst, "edges"); let srcs = self.rev.get(dst).cloned().unwrap_or_default(); if srcs.is_empty() { + sizes.remove(&("by_dst".to_string(), dst.clone())); let _ = fs::remove_file(&path); } else { let lines: Vec = srcs.iter().cloned().collect(); let body = format!("{}\n", lines.join("\n")); - cache::write_labeled(&path, dst, &body)?; + let bytes = cache::labeled(dst, &body); + sizes.insert(("by_dst".into(), dst.clone()), bytes.len() as u64); + reverse.push((path, bytes)); } } + cache::write_derived_all(&reverse)?; save_index(&self.root, &self.rows)?; + save_sizes(&self.root, &sizes)?; cache::atomic_write( &cache::generation_path(&self.root), format!("{gen}\n").as_bytes(), @@ -391,6 +431,151 @@ impl Session { } } +/// Kinds of cache file `Session::open` checks before trusting a row: sub-directory and +/// extension. Barrel records are auxiliary and are not checked. +const CHECKED: &[(&str, &str)] = &[ + ("by_src", "edges"), + ("payload", "json"), + ("notes", "txt"), + ("by_dst", "edges"), +]; + +/// Byte length of each checked cache file, keyed by kind and path. +type Sizes = BTreeMap<(String, String), u64>; + +fn sizes_path(root: &Path) -> PathBuf { + cache::cache_root(root).join("sizes.tsv") +} + +/// The recorded sizes, or `None` when the file is missing or any line is malformed. +fn read_sizes(root: &Path) -> Option { + let text = fs::read_to_string(sizes_path(root)).ok()?; + let mut out = Sizes::new(); + for line in text.lines() { + let mut parts = line.splitn(3, '\t'); + let (Some(kind), Some(len), Some(rel)) = (parts.next(), parts.next(), parts.next()) else { + return None; + }; + if rel.is_empty() || !CHECKED.iter().any(|(k, _)| *k == kind) { + return None; + } + out.insert((kind.to_string(), rel.to_string()), len.parse().ok()?); + } + Some(out) +} + +fn save_sizes(root: &Path, sizes: &Sizes) -> Result<(), String> { + let mut body = String::new(); + for ((kind, rel), len) in sizes { + body.push_str(&format!("{kind}\t{len}\t{rel}\n")); + } + cache::atomic_write(&sizes_path(root), body.as_bytes()) +} + +/// File name to byte length for one cache directory, from a single listing. +fn listed_sizes(dir: &Path) -> HashMap { + let Ok(entries) = fs::read_dir(dir) else { + return HashMap::new(); + }; + entries + .flatten() + .filter_map(|ent| { + let len = ent.metadata().ok()?.len(); + Some((ent.file_name().to_string_lossy().into_owned(), len)) + }) + .collect() +} + +/// What `Session::open` found wrong with the cache files. +struct EntryCheck { + sizes: Sizes, + reparse: BTreeSet, + stale_dsts: BTreeSet, + rewrite: bool, +} + +/// Compare every checked cache file with its recorded size (one directory listing per +/// kind). A file with no recorded size is read and its content checked once. A missing, +/// resized, or malformed file is a miss: its source is parsed again, or its `by_dst` +/// record is rewritten from the index. This is what lets the cache skip fsync. +fn check_entries( + root: &Path, + rows: &BTreeMap, + rev: &BTreeMap>, +) -> EntryCheck { + let recorded = read_sizes(root); + let mut check = EntryCheck { + sizes: Sizes::new(), + reparse: BTreeSet::new(), + stale_dsts: BTreeSet::new(), + rewrite: recorded.is_none(), + }; + let recorded = recorded.unwrap_or_default(); + let listed: HashMap<&str, HashMap> = CHECKED + .iter() + .map(|(kind, _)| (*kind, listed_sizes(&cache::cache_root(root).join(kind)))) + .collect(); + let mut verify = |kind: &str, ext: &str, rel: &str, required: bool| -> bool { + let name = format!("{}.{ext}", cache::enc(rel)); + let actual = listed.get(kind).and_then(|dir| dir.get(&name)).copied(); + let key = (kind.to_string(), rel.to_string()); + if let Some(len) = recorded.get(&key) { + if actual == Some(*len) { + check.sizes.insert(key, *len); + return true; + } + return false; + } + let Some(len) = actual else { + return !required; + }; + let path = cache::store_file(root, kind, rel, ext); + let bytes = fs::read(&path).unwrap_or_default(); + let ok = match kind { + "by_src" => cache::labeled_lines(&bytes, rel).is_some_and(|lines| { + let dsts: BTreeSet = lines.iter().map(|l| cache::dst_of(l)).collect(); + rows.get(rel).is_some_and(|row| row.dsts == dsts) + }), + "notes" => cache::labeled_lines(&bytes, rel).is_some_and(|lines| { + lines + .iter() + .all(|l| l.starts_with("E\t") || l.starts_with("U\t")) + }), + "payload" => std::str::from_utf8(&bytes) + .ok() + .and_then(|text| crate::jsonutil::parse(text).ok()) + .is_some_and(|json| json.get("rel").and_then(|v| v.as_str()) == Some(rel)), + _ => cache::labeled_lines(&bytes, rel).is_some_and(|lines| { + let srcs: BTreeSet = lines.into_iter().collect(); + rev.get(rel).is_some_and(|want| *want == srcs) + }), + }; + if ok { + check.sizes.insert(key, len); + check.rewrite = true; + } + ok + }; + let mut reparse = BTreeSet::new(); + for rel in rows.keys() { + let src_ok = verify("by_src", "edges", rel, true); + let payload_ok = verify("payload", "json", rel, false); + let notes_ok = verify("notes", "txt", rel, false); + if !(src_ok && payload_ok && notes_ok) { + reparse.insert(rel.clone()); + } + } + let mut stale = BTreeSet::new(); + for (dst, srcs) in rev { + if !srcs.is_empty() && !verify("by_dst", "edges", dst, true) { + stale.insert(dst.clone()); + } + } + check.reparse = reparse; + check.stale_dsts = stale; + check +} + fn read_generation(root: &Path) -> u64 { fs::read_to_string(cache::generation_path(root)) .ok() @@ -425,6 +610,22 @@ fn index_record_lines(root: &Path) -> Vec { .collect() } +/// Each indexed file with its resolved targets, or `None` for a row from before targets +/// were stored in the index. +pub(crate) fn index_targets(root: &Path) -> Vec<(String, Option>)> { + index_record_lines(root) + .into_iter() + .filter_map(|line| { + let mut parts = line.split('\t'); + let rel = parts.next().unwrap_or("").to_string(); + if rel.is_empty() { + return None; + } + Some((rel, parts.nth(3).map(parse_dsts))) + }) + .collect() +} + fn load_rows(root: &Path) -> (BTreeMap, bool) { let mut map = BTreeMap::new(); let mut upgrade = false; @@ -674,6 +875,8 @@ pub fn rebuild(root: &Path) -> Result<(), String> { } fn rebuild_locked(root: &Path) -> Result<(), String> { + // by_dst is rewritten from scratch; the next open re-reads every file for its size. + let _ = fs::remove_file(sizes_path(root)); let (old, _) = load_rows(root); let names = hash_to_rel(root); let mut orphans = Vec::new(); @@ -699,11 +902,18 @@ fn rebuild_locked(root: &Path) -> Result<(), String> { rev.entry(dst.clone()).or_default().insert(src.clone()); } } - for (dst, srcs) in &rev { - let lines: Vec = srcs.iter().cloned().collect(); - let body = format!("{}\n", lines.join("\n")); - cache::write_labeled(&cache::store_file(root, "by_dst", dst, "edges"), dst, &body)?; - } + let reverse: Vec<(PathBuf, Vec)> = rev + .iter() + .map(|(dst, srcs)| { + let lines: Vec = srcs.iter().cloned().collect(); + let body = format!("{}\n", lines.join("\n")); + ( + cache::store_file(root, "by_dst", dst, "edges"), + cache::labeled(dst, &body), + ) + }) + .collect(); + cache::write_derived_all(&reverse)?; let mut rows = BTreeMap::new(); for (rel, dsts) in &forward { let prev = old.get(rel); diff --git a/src/cache_commit.rs.wiki.md b/src/cache_commit.rs.wiki.md index 6916418..22d5810 100644 --- a/src/cache_commit.rs.wiki.md +++ b/src/cache_commit.rs.wiki.md @@ -4,4 +4,6 @@ The `Session` edits the index in memory and writes `by_src` before `by_dst`, with the generation file last. A crash leaves `commit.pending`. Unresolved notes are stored as `U`, the raw specifier, the line, and a reason. Removing a file also drops its barrel record on commit. The cache lock is `wk.lock`. `check_consistency` compares the forward and reverse indexes. `rebuild` rewrites them. `Consistency` is that comparison's result. -`Session` methods: `open`, `known`, `dsts_of`, `note_stat`, `rels`, `upsert`, `remove`, `stage_barrel`, `commit`. +Forward files are written as one parallel batch before the `by_dst` batch (`cache::atomic_write_all`). `index_targets` lists each index row's targets for `cache::edges`. Every commit records each checked file's length in `sizes.tsv`; `Session::open` compares those lengths with one directory listing per kind (`by_src`, `payload`, `notes`, `by_dst`) and reads a file only when no length is recorded. A bad forward file marks its source for re-parsing (`needs_parse`); a bad `by_dst` file is rewritten from the index. `rebuild` deletes `sizes.tsv`. + +`Session` methods: `open`, `known`, `dsts_of`, `note_stat`, `rels`, `upsert`, `remove`, `stage_barrel`, `has_changes`, `needs_parse`, `commit`. `has_changes` is true when `commit` would write. diff --git a/src/maps_out.rs b/src/maps_out.rs index b0c3302..bbc01cc 100644 --- a/src/maps_out.rs +++ b/src/maps_out.rs @@ -9,13 +9,26 @@ use crate::jsonutil::Json; const MERMAID_CAP: usize = 80; +/// True when `library.md` and the root folder card were written by [`write`]. Both are +/// pure functions of the cache, so a run that commits nothing can keep them. +pub fn current(root: &Path) -> bool { + let library = fs::read_to_string(root.join("library.md")).unwrap_or_default(); + // A checkout with core.autocrlf turns the LF header into CRLF. + let head: String = library.chars().take(80).filter(|c| *c != '\r').collect(); + head.starts_with("# Library\n\nGenerated by `wikifier update-maps`") + && root + .join(".wikifier_staging") + .join("maps") + .join("_root.json") + .is_file() +} + pub fn write(root: &Path) -> Result<(), String> { let paths = cache::all_paths(root); let mut edges: Vec<(String, String)> = Vec::new(); let mut flagged = 0i64; for src in &paths { - let view = cache::load_dependency_view(root, src).map_err(|e| e.to_string())?; - for edge in view.internal { + for edge in cache::load_internal_edges(root, src) { if edge.conditional || edge.dynamic || edge.via_barrel || edge.confidence() < 0.8 { flagged += 1; } diff --git a/src/maps_out.rs.wiki.md b/src/maps_out.rs.wiki.md index 1374e6f..e8bb881 100644 --- a/src/maps_out.rs.wiki.md +++ b/src/maps_out.rs.wiki.md @@ -2,4 +2,4 @@ **Role:** Human `library.md` and the sharded agent folder cards. -`write` rebuilds `.wikifier_staging/maps/_root.json` and one `_index.json` per directory prefix. `library.md` has a file tree, then a Mermaid block capped at 80 edges, then the dependency table. `write_status` stores `incremental_status.json` for `map_complete` and `files_remaining_dirty`. `read_status` reads that file back. +`write` rebuilds `.wikifier_staging/maps/_root.json` and one `_index.json` per directory prefix. `library.md` has a file tree, then a Mermaid block capped at 80 edges, then the dependency table. `write` reads each file's `by_src` record only. `current` is true when `library.md` (LF or CRLF) and `_root.json` were written by `write`; `update-maps` skips `write` when the cache commit changed nothing and `current` holds. `write_status` stores `incremental_status.json` for `map_complete` and `files_remaining_dirty`. `read_status` reads that file back. diff --git a/src/mcp.rs b/src/mcp.rs index 0067809..6c31903 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -55,7 +55,15 @@ fn extra_props(name: &str) -> Vec<(&'static str, Json)> { ("max_items", integer("Cap the cycle list.")), ("analysis", boolean("Include severity and reverse-dependency ranking. Default true.")), ]), - "verify_wikis" | "health" | "suggest_next_actions" | "session_bootstrap" | "get_project_status" => { + "health" => props.extend([ + ("limit", integer("Return at most this many entries. entries_total gives the full count.")), + ("summary", boolean("Return only the green, yellow, and red counts.")), + ]), + "session_bootstrap" | "get_project_status" => props.push(( + "limit", + integer("Cap the files list. Default 100 over MCP; 0 lists every file. files_total gives the count. Orient with map_index and list_paths."), + )), + "verify_wikis" | "suggest_next_actions" => { props.push(("limit", integer("Cap listed failures or stale rows."))); } "record_changes" => props.push(("reasons", Json::obj(vec![ @@ -64,11 +72,19 @@ fn extra_props(name: &str) -> Vec<(&'static str, Json)> { ]))), "heal_stubs" | "list_healable_stubs" => props.push(("min_wiki_length", integer("Minimum wiki length. Default 350."))), "mark_green" => props.push(("force", boolean("Override a failed wiki check. Requires a reason."))), + "update_maps" => props.extend([ + ("full", boolean("Rehash every file instead of skipping unchanged size and mtime.")), + ("max_files", integer("Parse at most this many changed files. Re-run while files_remaining_dirty > 0.")), + ("edges", boolean("Include the whole edge list. Off by default: it runs to hundreds of KB on a mid-size repo. Use get_dependencies for one file.")), + ]), _ => {} } props } +/// Files listed by an MCP `session_bootstrap` that passes no `limit`. +const MCP_BOOTSTRAP_FILES: usize = 100; + fn string_prop(description: &str) -> Json { Json::obj(vec![ ("type", Json::String("string".into())), @@ -385,6 +401,9 @@ fn call_tool(msg: &Json) -> Json { .unwrap_or("") .to_string(); let args = params.and_then(|p| p.get("arguments")).cloned(); + if let Some(err) = missing_required(&name, args.as_ref()) { + return tool_result(err, false, &name); + } let root = args .as_ref() .and_then(|a| a.get("project_root")) @@ -475,7 +494,7 @@ fn call_tool(msg: &Json) -> Json { } } for key in [ - "full", "quiet", "stream", "green", "force", "dry_run", "dry-run", + "full", "quiet", "stream", "green", "force", "dry_run", "dry-run", "summary", ] { if arg_on(args.as_ref(), key) { let stored = if key == "dry_run" { "dry-run" } else { key }; @@ -524,6 +543,18 @@ fn call_tool(msg: &Json) -> Json { _ => {} } } + // Over MCP the edge list is opt-in. Its size grows with the repo and every byte lands in + // the agent's context; `edges_persisted` still reports the count. + if name == "update_maps" && !arg_on(args.as_ref(), "edges") { + flags.insert("quiet".into(), "true".into()); + } + // The bootstrap file list grows with the repo (about 21 KB at 587 files). Over MCP it + // is capped unless the caller asks; map_index already gives the folder counts. + if matches!(name.as_str(), "session_bootstrap" | "get_project_status") + && !flags.contains_key("limit") + { + flags.insert("limit".into(), MCP_BOOTSTRAP_FILES.to_string()); + } let textual = flags.get("format").map(|s| s == "text").unwrap_or(false); let tool_name = name.clone(); let from_arg = args @@ -572,6 +603,58 @@ fn call_tool(msg: &Json) -> Json { tool_result(value, textual, &tool_name) } +/// A required argument the client left out fails the call and names the accepted fields, +/// so an agent that guessed `file_path` or `path` can retry at once. Without this the tool +/// ran with an empty path and answered `success: true` with every field empty. +/// `project_root` is exempt: it falls back to WIKIFIER_PROJECT_ROOT, then the cwd. +fn missing_required(name: &str, args: Option<&Json>) -> Option { + if !MCP_TOOLS.contains(&name) { + return None; + } + let (_, required, _) = tool_spec(name); + let present = |key: &str| { + let value = args.and_then(|a| a.get(key)).or_else(|| { + (key == "query") + .then(|| args.and_then(|a| a.get("text"))) + .flatten() + }); + matches!(value, Some(Json::String(text)) if !text.trim().is_empty()) + }; + let missing: Vec<&str> = required + .iter() + .copied() + .filter(|field| *field != "project_root" && !present(field)) + .collect(); + if missing.is_empty() { + return None; + } + let names = |value: Option<&Json>| { + value + .and_then(|v| v.as_object()) + .map(|map| map.keys().cloned().collect::>().join(", ")) + .unwrap_or_default() + }; + let schema = tool_schema(name); + let accepted = names(schema.get("inputSchema").and_then(|s| s.get("properties"))); + let given = names(args); + let given = if given.is_empty() { + "none".to_string() + } else { + given + }; + Some(Json::obj(vec![ + ("success", Json::Bool(false)), + ( + "error", + Json::String(format!( + "{name} needs {} as a non-empty string. Accepted arguments: {accepted}. Received: {given}.", + missing.join(", ") + )), + ), + ("error_type", Json::String("InvalidArguments".into())), + ])) +} + fn tool_result(value: Json, textual: bool, tool_name: &str) -> Json { let is_error = value.get("success").and_then(|s| s.as_bool()) == Some(false); let text = if textual { diff --git a/src/mcp.rs.wiki.md b/src/mcp.rs.wiki.md index c6228e7..0abd0ac 100644 --- a/src/mcp.rs.wiki.md +++ b/src/mcp.rs.wiki.md @@ -3,3 +3,5 @@ **Role:** Stdio JSON-RPC MCP adapter over `src/api.rs`. `tool_schema` describes one tool. `handle_message` answers one JSON-RPC request. `serve_stdio` reads messages from stdin. `tools/call` sets `isError` when the payload has `success: false`. `resources/read` serves the library, health, pending, and journal resources. `prompts/get` returns a short user message. Each call runs on a thread and stops waiting after `WIKIFIER_MCP_DEADLINE_S` seconds (default 60). A timeout payload has `timed_out` and `may_still_complete`. + +A `tools/call` that leaves out a required argument other than `project_root` (or sends it empty) gets `InvalidArguments` naming the missing field, the accepted fields, and the fields received. `project_root` falls back to `WIKIFIER_PROJECT_ROOT`, then the cwd. `update_maps` omits the edge list unless `edges: true`; `edges_persisted` still gives the count. `health` takes `limit` (adds `entries_total` when it cuts) and `summary`. `session_bootstrap` and `get_project_status` cap `files` at `MCP_BOOTSTRAP_FILES` (100) unless the call passes `limit` (`0` lists all). diff --git a/src/parse.rs b/src/parse.rs index e36c2f0..28428b0 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -136,9 +136,19 @@ pub fn resolve_project(root: &Path, files: &[PathBuf]) -> BTreeMap, +) -> BTreeMap> { + let resolver = Resolver::build(root, rels); let mut edges: BTreeMap> = BTreeMap::new(); - for rel in &rels { + for rel in rels { let scan = scans.get(rel).cloned().unwrap_or_default(); let mut dsts = BTreeSet::new(); let lang = language(Path::new(rel)).unwrap_or(""); @@ -152,7 +162,7 @@ pub fn resolve_project(root: &Path, files: &[PathBuf]) -> BTreeMap bool { #[cfg(test)] mod dbg_scan { + #[test] + fn line_of_matches_the_line_by_line_scan() { + let texts = [ + "use a::b;@N@fn f() { a::b::c(); }@N@", + "x@R@@N@use crate::m::{n, o};@R@@N@m::n();@R@@N@", + "", + "one line, no newline", + ]; + let needles: [&[&str]; 6] = [ + &["a::b"], + &["crate::m::n"], + &["m::n", "use"], + &["missing"], + &["", "fn"], + &["line"], + ]; + for text in texts { + let text = text.replace("@N@", "\n").replace("@R@", "\r"); + for set in needles { + let set: Vec = set.iter().map(|s| s.to_string()).collect(); + assert_eq!( + super::line_of(&text, &set), + super::line_of_by_lines(&text, &set), + "{text:?} {set:?}" + ); + } + } + } + #[test] fn dynamic_import_spec() { let src = "import { add } from '@acme/core';\nimport { log } from '@app/util';\nimport type { Config } from './types';\nconst lazy = () => import('./lazy');\nimport React from 'react';\nlog(add(1, 2));\n"; @@ -2961,7 +3000,32 @@ pub struct Analysis { pub warnings: Vec, } +/// 1-based number of the first line that contains any needle, else 1. A needle without a +/// line break matches inside one line, so the earliest byte match gives the same line as a +/// line-by-line scan, without the per-line loop (most of a cold map on a large crate). fn line_of(text: &str, needles: &[String]) -> i64 { + let mut first: Option = None; + for needle in needles.iter().filter(|n| !n.is_empty()) { + if needle.contains(['\n', '\r']) { + return line_of_by_lines(text, needles); + } + if let Some(at) = text.find(needle.as_str()) { + first = Some(first.map_or(at, |seen| seen.min(at))); + } + } + match first { + Some(at) => { + text.as_bytes()[..at] + .iter() + .filter(|b| **b == b'\n') + .count() as i64 + + 1 + } + None => 1, + } +} + +fn line_of_by_lines(text: &str, needles: &[String]) -> i64 { for (i, line) in text.lines().enumerate() { if needles .iter() @@ -3044,11 +3108,18 @@ fn analyze_files_filtered( // The full-tree path keeps the second resolution pass. A partial update // resolves changed files against the whole universe below. let js_edges = if only.is_none() { - resolve_project(root, &other_files) + let other_rels: Vec = other_files.iter().map(|f| rel_of(root, f)).collect(); + let other_scans: BTreeMap = other_rels + .iter() + .map(|rel| (rel.clone(), scans.get(rel).cloned().unwrap_or_default())) + .collect(); + resolve_scanned(root, &other_rels, &other_scans) } else { BTreeMap::new() }; let resolver = Resolver::build(root, &rels); + // Barrel follow-through wants the scans sorted. Build that copy once, not per name. + let mut sorted_scans: Option> = None; let mut out = BTreeMap::new(); for rel in &rels { if only.as_ref().is_some_and(|set| !set.contains(rel)) { @@ -3098,9 +3169,13 @@ fn analyze_files_filtered( } } } else if !lang.is_empty() { + // A spec repeats across imports (every `a::b` path in a Rust file); find its line once. + let mut lines_seen: HashMap<&str, i64> = HashMap::new(); for imp in &scan.imports { let target = resolver.resolve(rel, &imp.spec, lang, imp.inline_depth); - let line = line_of(&text, &[imp.spec.clone()]); + let line = *lines_seen + .entry(imp.spec.as_str()) + .or_insert_with(|| line_of(&text, std::slice::from_ref(&imp.spec))); let dynamic = imp.kind == Kind::Dynamic; match target { Some(dst) if dst != *rel => { @@ -3117,19 +3192,10 @@ fn analyze_files_filtered( && (imp.kind == Kind::Import || imp.kind == Kind::ExportFrom) { for name in &imp.names { - if let Some(def) = defining_file( - &resolver, - &{ - let mut m = BTreeMap::new(); - for (k, v) in &scans { - m.insert(k.clone(), v.clone()); - } - m - }, - &dst, - name, - 0, - ) { + let sorted = sorted_scans.get_or_insert_with(|| { + scans.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + }); + if let Some(def) = defining_file(&resolver, sorted, &dst, name, 0) { if def != *rel && def != dst && !facts.dsts.contains(&def) { add_edge( &mut facts, diff --git a/src/parse.rs.wiki.md b/src/parse.rs.wiki.md index 38533ac..aa358e1 100644 --- a/src/parse.rs.wiki.md +++ b/src/parse.rs.wiki.md @@ -6,6 +6,6 @@ Unknown suffixes emit no edges. Named JS/TS re-exports are followed to depth 8 a Rust `mod` names a file beside the declaring module (`lib.rs`, `main.rs`, and `mod.rs` use the parent directory; other files use a directory with the file's stem). Inline `mod name { ... }` is not a file. `crate::` stays in the file's crate, so `tests/*.rs` and `src/bin/*.rs` do not point `crate::` at the library. `self::` and `super::` walk from that file; a `super::` inside an inline module pops the inline frame before it leaves the file. The root `Cargo.toml` `[package].name` and `[lib].name` (hyphens become underscores) both point at the library root (`src/lib.rs` when it exists). That is how `use wikifier::...` from `src/main.rs` and `tests/*.rs` becomes an edge. A path expression with the same shape counts as an edge too. `use` of any other crate is external. The scan does not read workspace members. -The scan does not use CPython `ast` and does not read tsconfig paths, package exports, or workspaces. The frozen fixtures, including the Cargo crate layout, are the accuracy gate. +The scan does not use CPython `ast` and does not read tsconfig paths, package exports, or workspaces. The frozen fixtures, including the Cargo crate layout, are the accuracy gate. A full map scans each file once: `resolve_project` and the internal `resolve_scanned` share those scans. `line_of` finds an import's line by its earliest byte match (a needle with a line break uses the line-by-line scan), and repeated specs in one file are looked up once. Public scan entry points: `language`, `read_utf8`, `scan_file_warned`, `scan_file`, `resolve_project`, `resolve_python_spec`, `analyze_files`, `analyze_changed`, `edges_for_files`, `rust_public_symbols`. Public types: `RawImport`, `Kind`, `Scan`, `FactEdge`, `FileFacts`, `Analysis`. `rust_public_symbols` lists unrestricted `pub` items, including `pub` methods and associated items on a public type, and the items written in a trait impl for a public type. `pub(crate)`, `pub(super)`, `pub(self)`, and `pub(in ...)` stay out. A root `Cargo.toml` `[workspace] members` list, including globs, supplies each member crate name. `crate::` stays inside the member that owns the file. diff --git a/src/pending_line.rs b/src/pending_line.rs index fe3a6b5..2bcd580 100644 --- a/src/pending_line.rs +++ b/src/pending_line.rs @@ -24,6 +24,10 @@ pub fn format_item(rel: &str, message: &str) -> String { format!("- {rel}: {message}\n") } +/// The line an empty queue shows. It goes when the first item arrives and comes back when +/// the last one leaves, so the file never says "no pending items" above a list of items. +pub const EMPTY_NOTE: &str = "(no pending items — run check-changes after making edits)"; + /// Append `message` for `rel` when that file is not already listed. pub fn append_item(text: &str, rel: &str, message: &str) -> String { if text.lines().any(|line| file_of(line) == Some(rel)) { @@ -32,17 +36,38 @@ pub fn append_item(text: &str, rel: &str, message: &str) -> String { let mut out = if text.trim().is_empty() { "# Pending Updates\n\n".to_string() } else { - text.to_string() + text.lines() + .filter(|line| line.trim() != EMPTY_NOTE) + .map(|line| format!("{line}\n")) + .collect() }; - if !out.is_empty() && !out.ends_with('\n') { + out.push_str(&format_item(rel, message)); + out +} + +/// `text` without the item for `rel`. The empty-queue note returns with the last item. +pub fn remove_item(text: &str, rel: &str) -> String { + let mut out = String::new(); + for line in text.lines() { + if file_of(line) != Some(rel) { + out.push_str(line); + out.push('\n'); + } + } + let empty = out.lines().all(|line| file_of(line).is_none()); + if empty && !out.contains(EMPTY_NOTE) { + if !out.ends_with("\n\n") { + out.push('\n'); + } + out.push_str(EMPTY_NOTE); out.push('\n'); } - out.push_str(&format_item(rel, message)); out } +/// A `record-change` item: the agent's reason, then what closes the item. pub fn record_message(reason: &str) -> String { - format!("LLM/agent edit — {reason}") + format!("{reason} — refresh the wiki, then mark-green") } pub const CHECK_MESSAGE: &str = "Content change detected — review, refresh wiki, then mark-green"; diff --git a/src/pending_line.rs.wiki.md b/src/pending_line.rs.wiki.md new file mode 100644 index 0000000..073b81e --- /dev/null +++ b/src/pending_line.rs.wiki.md @@ -0,0 +1,5 @@ +# src/pending_line.rs + +**Role:** Items in `pending_updates.md`. + +An item is `- path: message`; `file_of` also reads the older `- path — message` shape. `format_item` builds one line. `append_item` adds an item when the file is not listed yet and drops the `EMPTY_NOTE` line; `remove_item` drops a file's item and puts `EMPTY_NOTE` back when no item is left. `record_message` is the reason followed by "refresh the wiki, then mark-green". `CHECK_MESSAGE` is the text `check-changes` writes. diff --git a/src/scope.rs b/src/scope.rs index 4bcbfcc..489652b 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -278,9 +278,14 @@ fn walk(root: &Path, dir: &Path, ex_names: &[String], ex_globs: &[String], out: if ex_names.iter().any(|n| n == &name) || ex_globs.iter().any(|g| fnmatch(g, &name)) { continue; } - if path.is_dir() { + // The directory entry already knows its type; a symlink still follows its target. + let (is_dir, is_file) = match ent.file_type() { + Ok(kind) if !kind.is_symlink() => (kind.is_dir(), kind.is_file()), + _ => (path.is_dir(), path.is_file()), + }; + if is_dir { walk(root, &path, ex_names, ex_globs, out); - } else if path.is_file() && is_source(&path) && path.starts_with(root) { + } else if is_file && is_source(&path) && path.starts_with(root) { out.push(path); } } @@ -313,3 +318,73 @@ pub fn rel_of(root: &Path, path: &Path) -> String { .collect::>() .join("/") } + +/// Map roots for a fresh `init`: `src/` when it exists, else every top-level directory that +/// holds a source file (a Cargo workspace's `crates/`, a Python package dir, `tests/`). +/// Falls back to `src/` so the scope error still names a directory to create. +pub fn default_map_roots(root: &Path) -> Vec { + if root.join("src").is_dir() { + return vec!["src/".to_string()]; + } + let mut found: Vec = fs::read_dir(root) + .map(|entries| { + entries + .flatten() + .filter(|ent| ent.path().is_dir()) + .map(|ent| ent.file_name().to_string_lossy().into_owned()) + .filter(|name| !skip_dir(name) && has_source(&root.join(name), 6)) + .map(|name| format!("{name}/")) + .collect() + }) + .unwrap_or_default(); + found.sort(); + if found.is_empty() { + found.push("src/".to_string()); + } + found +} + +fn skip_dir(name: &str) -> bool { + name.starts_with('.') || DEFAULT_EXCLUDES.contains(&name) +} + +fn has_source(dir: &Path, depth: usize) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return false; + }; + let mut subdirs = Vec::new(); + for ent in entries.flatten() { + let path = ent.path(); + if path.is_dir() { + if depth > 0 && !skip_dir(&ent.file_name().to_string_lossy()) { + subdirs.push(path); + } + } else if is_source(&path) { + return true; + } + } + subdirs.into_iter().any(|sub| has_source(&sub, depth - 1)) +} + +/// A caller's file argument as the map and health key: project-relative and `/`-separated. +/// Accepts `./src/a.rs`, `src\a.rs`, and an absolute path under `root`, so a Windows agent +/// does not get an empty lookup or a second health row for the same file. +pub fn rel_arg(root: &Path, file: &str) -> String { + let path = Path::new(file); + if path.is_absolute() { + if let Ok(rel) = path.strip_prefix(root) { + return rel_of(Path::new(""), rel); + } + if let (Ok(real_root), Ok(real_path)) = (root.canonicalize(), path.canonicalize()) { + if let Ok(rel) = real_path.strip_prefix(&real_root) { + return rel_of(Path::new(""), rel); + } + } + } + let slashed = file.replace('\\', "/"); + let mut rel = slashed.as_str(); + while let Some(rest) = rel.strip_prefix("./") { + rel = rest; + } + rel.to_string() +} diff --git a/src/scope.rs.wiki.md b/src/scope.rs.wiki.md new file mode 100644 index 0000000..65ed0d9 --- /dev/null +++ b/src/scope.rs.wiki.md @@ -0,0 +1,7 @@ +# src/scope.rs + +**Role:** Which files a run may touch, and how a caller's path becomes a map key. + +`detect` reads `map_paths.txt` and `monitored_paths.txt` into a `Scope`. A missing configured directory is a scope error, and three or more child projects make the root a container; `Scope::allows_walk` is false in both cases. `collect` walks the allowed prefixes with `excludes` (defaults plus `exclude_patterns.txt`) and keeps files that pass `is_source` (extensions in `SOURCE_EXTS`). `read_path_list` reads one path list. + +`rel_of` turns a path under the root into the `/`-separated key the cache and health use. `rel_arg` does the same for a caller's argument: `src\a.rs`, `./src/a.rs`, and an absolute path under the root all give `src/a.rs`. `default_map_roots` picks the map roots for `init`: `src/` when it exists, else each top-level directory holding a source file, else `src/`. diff --git a/src/sha256.rs b/src/sha256.rs index 2540de1..4b79ebb 100644 --- a/src/sha256.rs +++ b/src/sha256.rs @@ -95,9 +95,28 @@ pub fn sha256_prefixed(data: &[u8]) -> String { format!("sha256:{}", sha256_hex(data)) } +/// Content identity for health baselines: CRLF counts as LF. A Windows checkout with +/// `core.autocrlf` has CRLF files whose Green baselines were hashed from LF elsewhere; +/// hashing raw bytes flipped every one of them to dirty. +pub fn content_sha256_prefixed(data: &[u8]) -> String { + const CR: u8 = 13; + const LF: u8 = 10; + if !data.windows(2).any(|pair| pair == [CR, LF]) { + return sha256_prefixed(data); + } + let mut unix = Vec::with_capacity(data.len()); + for (i, byte) in data.iter().enumerate() { + if *byte == CR && data.get(i + 1) == Some(&LF) { + continue; + } + unix.push(*byte); + } + sha256_prefixed(&unix) +} + pub fn file_sha256_prefixed(path: &std::path::Path) -> Option { let bytes = std::fs::read(path).ok()?; - Some(sha256_prefixed(&bytes)) + Some(content_sha256_prefixed(&bytes)) } #[cfg(test)] @@ -112,4 +131,13 @@ mod tests { ); assert!(sha256_prefixed(b"abc").starts_with("sha256:")); } + + #[test] + fn content_hash_treats_crlf_as_lf() { + let unix = content_sha256_prefixed(&[b'a', 10, b'b', 10]); + assert_eq!(unix, sha256_prefixed(&[b'a', 10, b'b', 10])); + assert_eq!(content_sha256_prefixed(&[b'a', 13, 10, b'b', 13, 10]), unix); + assert_eq!(content_sha256_prefixed(&[b'a', 13, 10, b'b', 10]), unix); + assert_ne!(content_sha256_prefixed(&[b'a', 13, b'b', 10]), unix); + } } diff --git a/src/sha256.rs.wiki.md b/src/sha256.rs.wiki.md new file mode 100644 index 0000000..e4e8d4d --- /dev/null +++ b/src/sha256.rs.wiki.md @@ -0,0 +1,5 @@ +# src/sha256.rs + +**Role:** Std-only SHA-256 for content identity. + +`sha256` returns the digest, `sha256_hex` the hex string, and `sha256_prefixed` the `sha256:` form stored in `file_health.json`. `content_sha256_prefixed` hashes with CRLF read as LF, so a Windows `core.autocrlf` checkout matches a baseline hashed from LF. `file_sha256_prefixed` reads a file and returns its content hash. diff --git a/tests/agent_args.rs b/tests/agent_args.rs new file mode 100644 index 0000000..a8639d9 --- /dev/null +++ b/tests/agent_args.rs @@ -0,0 +1,302 @@ +//! Arguments as agents actually send them: Windows separators, absolute paths, a guessed +//! argument name, a file that is not there. Each must hit the right row or fail with a next step. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wikifier::api; +use wikifier::{load_dependents, prepare_edit, read_health, record_change, update_maps, Json}; + +fn scratch(name: &str) -> PathBuf { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("wk-args-{name}-{n}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn mapped_project(name: &str) -> PathBuf { + let root = scratch(name); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("map_paths.txt"), "src\n").unwrap(); + fs::write(root.join("src/a.py"), "def hello():\n return 1\n").unwrap(); + fs::write(root.join("src/b.py"), "from a import hello\n").unwrap(); + let built = update_maps(&root); + assert_eq!( + built.get("success").and_then(|v| v.as_bool()), + Some(true), + "{built}" + ); + root +} + +fn field<'a>(value: &'a Json, key: &str) -> &'a str { + value.get(key).and_then(|v| v.as_str()).unwrap_or("") +} + +fn mcp_call(root: &Path, name: &str, mut args: Vec<(&str, Json)>) -> Json { + args.push(( + "project_root", + Json::String(root.to_string_lossy().into_owned()), + )); + let msg = Json::obj(vec![ + ("jsonrpc", Json::String("2.0".into())), + ("id", Json::Int(7)), + ("method", Json::String("tools/call".into())), + ( + "params", + Json::obj(vec![ + ("name", Json::String(name.into())), + ("arguments", Json::obj(args)), + ]), + ), + ]); + let reply = wikifier::mcp::handle_message(&msg).expect("tools/call has an id"); + reply + .get("result") + .and_then(|r| r.get("structuredContent")) + .cloned() + .unwrap_or_else(|| panic!("no structuredContent: {reply}")) +} + +#[test] +fn backslash_and_absolute_file_args_hit_the_mapped_row() { + let root = mapped_project("sep"); + let recorded = record_change(&root, "src\\a.py", "windows separator (args-1)"); + assert_eq!(field(&recorded, "file"), "src/a.py", "{recorded}"); + let rows: Vec<_> = read_health(&root) + .into_iter() + .filter(|row| row.file.ends_with("a.py")) + .collect(); + assert_eq!(rows.len(), 1, "a second health row appeared: {rows:?}"); + assert_eq!(rows[0].file, "src/a.py"); + assert_eq!(rows[0].reason, "windows separator (args-1)"); + + let absolute = root.join("src").join("a.py"); + for arg in [absolute.to_string_lossy().into_owned(), "./src/a.py".into()] { + let report = prepare_edit(&root, &arg); + assert_eq!(field(&report, "file"), "src/a.py", "{arg}: {report}"); + assert!(report.to_string().contains("src/b.py"), "{arg}: {report}"); + } + let importers = load_dependents(&root, "src\\a.py"); + assert!(importers.to_string().contains("src/b.py"), "{importers}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn lookups_on_a_missing_file_fail_with_a_next_step() { + let root = mapped_project("missing"); + for value in [ + prepare_edit(&root, "src/nope.py"), + load_dependents(&root, "src/nope.py"), + api::dependency_report(&root, "src/nope.py", false), + api::dispatch( + "get_file_wiki", + &root, + &["src/nope.py".into()], + &BTreeMap::new(), + ), + ] { + assert_eq!( + value.get("success").and_then(|v| v.as_bool()), + Some(false), + "{value}" + ); + assert_eq!(field(&value, "error_type"), "FileNotFound", "{value}"); + assert!(field(&value, "error").contains("list_paths"), "{value}"); + } + let empty = api::dependency_report(&root, "", false); + assert_eq!(field(&empty, "error_type"), "ValueError", "{empty}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mcp_names_the_argument_an_agent_left_out() { + let root = mapped_project("mcpargs"); + let guessed = mcp_call( + &root, + "prepare_edit", + vec![("file_path", Json::String("src/a.py".into()))], + ); + assert_eq!( + field(&guessed, "error_type"), + "InvalidArguments", + "{guessed}" + ); + let text = field(&guessed, "error"); + assert!(text.contains("needs file"), "{text}"); + assert!(text.contains("Received: file_path, project_root"), "{text}"); + + let no_reason = mcp_call( + &root, + "record_change", + vec![("file", Json::String("src/a.py".into()))], + ); + assert!( + field(&no_reason, "error").contains("needs reason"), + "{no_reason}" + ); + assert!( + wikifier::read_journal(&root, None).is_empty(), + "a rejected call still wrote the journal" + ); + + let alias = mcp_call( + &root, + "search_journal", + vec![("text", Json::String("anything".into()))], + ); + assert_eq!( + alias.get("success").and_then(|v| v.as_bool()), + Some(true), + "{alias}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mcp_update_maps_leaves_out_the_edge_list_unless_asked() { + let root = mapped_project("edges"); + let lean = mcp_call(&root, "update_maps", vec![("full", Json::Bool(true))]); + assert_eq!( + lean.get("success").and_then(|v| v.as_bool()), + Some(true), + "{lean}" + ); + assert_eq!( + lean.get("edges_omitted").and_then(|v| v.as_bool()), + Some(true), + "{lean}" + ); + assert_eq!(lean.get("edges_persisted"), Some(&Json::Int(1)), "{lean}"); + assert_eq!( + lean.get("edges") + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(0) + ); + + let full = mcp_call( + &root, + "update_maps", + vec![("full", Json::Bool(true)), ("edges", Json::Bool(true))], + ); + assert_eq!( + full.get("edges") + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(1), + "{full}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn init_maps_the_directories_that_hold_source() { + let init = |root: &Path| { + let value = api::dispatch("init", root, &[], &BTreeMap::new()); + assert_eq!( + value.get("success").and_then(|v| v.as_bool()), + Some(true), + "{value}" + ); + let text = fs::read_to_string(root.join("map_paths.txt")).unwrap(); + text.lines() + .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) + .map(|line| line.to_string()) + .collect::>() + }; + + let workspace = scratch("workspace"); + fs::create_dir_all(workspace.join("crates/core/src")).unwrap(); + fs::write(workspace.join("crates/core/src/lib.rs"), "pub fn f() {}\n").unwrap(); + fs::create_dir_all(workspace.join("tests")).unwrap(); + fs::write(workspace.join("tests/it.rs"), "#[test]\nfn t() {}\n").unwrap(); + fs::create_dir_all(workspace.join("docs")).unwrap(); + fs::write(workspace.join("docs/guide.md"), "# guide\n").unwrap(); + fs::create_dir_all(workspace.join("target/debug")).unwrap(); + fs::write(workspace.join("target/debug/build.rs"), "fn main() {}\n").unwrap(); + assert_eq!(init(&workspace), ["crates/", "tests/"]); + let built = update_maps(&workspace); + assert_eq!( + built.get("success").and_then(|v| v.as_bool()), + Some(true), + "{built}" + ); + let _ = fs::remove_dir_all(workspace); + + let classic = scratch("classic"); + fs::create_dir_all(classic.join("src")).unwrap(); + fs::create_dir_all(classic.join("lib")).unwrap(); + fs::write(classic.join("lib/x.py"), "x = 1\n").unwrap(); + assert_eq!(init(&classic), ["src/"]); + let _ = fs::remove_dir_all(classic); + + let empty = scratch("empty"); + assert_eq!(init(&empty), ["src/"]); + let _ = fs::remove_dir_all(empty); +} + +#[test] +fn mcp_health_honors_limit_and_summary() { + let root = mapped_project("health"); + let capped = mcp_call(&root, "health", vec![("limit", Json::Int(1))]); + assert_eq!( + capped + .get("entries") + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(1), + "{capped}" + ); + assert_eq!(capped.get("entries_total"), Some(&Json::Int(2)), "{capped}"); + + let counts = mcp_call(&root, "health", vec![("summary", Json::Bool(true))]); + assert!(counts.get("entries").is_none(), "{counts}"); + assert!(counts.get("health_summary").is_some(), "{counts}"); + let _ = fs::remove_dir_all(root); +} + +/// A Windows checkout with `core.autocrlf` turns LF into CRLF. That is not an edit, so a +/// Green file must stay clean. +#[test] +fn crlf_checkout_of_a_green_file_is_not_a_change() { + let root = mapped_project("crlf"); + fs::write( + root.join("src/a.py.wiki.md"), + "# a.py\n\n- `hello()` returns 1.\n", + ) + .unwrap(); + let green = wikifier::mark_green(&root, "src/a.py", "wiki written (args-2)", false); + assert_eq!( + green.get("success").and_then(|v| v.as_bool()), + Some(true), + "{green}" + ); + let lf = fs::read(root.join("src/a.py")).unwrap(); + let mut crlf = Vec::new(); + for byte in lf { + if byte == b'\n' { + crlf.push(b'\r'); + } + crlf.push(byte); + } + fs::write(root.join("src/a.py"), crlf).unwrap(); + let checked = wikifier::check_changes(&root); + assert_eq!( + checked.get("changes_detected"), + Some(&Json::Int(0)), + "{checked}" + ); + let row = read_health(&root) + .into_iter() + .find(|row| row.file == "src/a.py") + .unwrap(); + assert!(row.status.contains("Green"), "{row:?}"); + let _ = fs::remove_dir_all(root); +} diff --git a/tests/cli_surface.rs b/tests/cli_surface.rs index 110cb0b..4a30b6f 100644 --- a/tests/cli_surface.rs +++ b/tests/cli_surface.rs @@ -165,6 +165,58 @@ fn mcp_stdio_is_newline_delimited_json() { let _ = std::fs::remove_dir_all(root); } +/// The launcher must pass the binary's exit code through. `wikifier.bat` used to read +/// `%ERRORLEVEL%` inside a parenthesized block, which expands before the command runs, +/// so every failure exited 0. +#[test] +fn launcher_passes_exit_code_through() { + let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let launch = |arg: &str| { + let mut cmd = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.arg("/c").arg(here.join("wikifier.bat")); + c + } else { + let mut c = Command::new("bash"); + c.arg(here.join("wikifier.sh")); + c + }; + cmd.arg(arg).env("WIKIFIER_BIN", bin()).output().unwrap() + }; + let ok = launch("--version"); + assert!(ok.status.success(), "status {:?}", ok.status); + assert!( + String::from_utf8_lossy(&ok.stdout).contains(env!("CARGO_PKG_VERSION")), + "{}", + String::from_utf8_lossy(&ok.stdout) + ); + let bad = launch("no-such-command"); + assert_eq!(bad.status.code(), Some(1), "status {:?}", bad.status); + assert!( + String::from_utf8_lossy(&bad.stdout).contains("UnknownCommand"), + "{}", + String::from_utf8_lossy(&bad.stdout) + ); +} + fn parse_line(line: &str) -> wikifier::Json { wikifier::jsonutil::parse(line.trim()).unwrap_or_else(|err| panic!("stdio json {err}: {line}")) } + +/// The docs that name a package version name this one. Cargo.toml is the source. +#[test] +fn docs_name_the_cargo_version() { + let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let version = env!("CARGO_PKG_VERSION"); + let read = |rel: &str| std::fs::read_to_string(here.join(rel)).unwrap(); + for (rel, needle) in [ + ("skills/run.md", format!("**Package {version}**")), + ("skills/run.md", format!("package {version})")), + ("AGENTS.md", format!("package {version})")), + ("CLAUDE.md", format!("currently **{version}**")), + ("README.md", format!("releases/download/v{version}/")), + ("CHANGELOG.md", format!("## [{version}]")), + ] { + assert!(read(rel).contains(&needle), "{rel} does not say {needle}"); + } +} diff --git a/tests/dogfood.rs b/tests/dogfood.rs index 72d83af..a5bdc40 100644 --- a/tests/dogfood.rs +++ b/tests/dogfood.rs @@ -282,12 +282,14 @@ fn prepare_edit_on_this_repo_lists_api_dependents() { "src/lib.rs", "src/main.rs", "src/mcp.rs", + "tests/agent_args.rs", "tests/cli_surface.rs", "tests/contract_gaps.rs", "tests/parity_fixes.rs", "tests/protocol_v07.rs", "tests/robustness.rs", "tests/skip_maps.rs", + "tests/speed_paths.rs", ]; assert_eq!(dependents, expected, "prepare-edit src/api.rs dependents"); diff --git a/tests/dogfood.rs.wiki.md b/tests/dogfood.rs.wiki.md index ad425a4..58c87c0 100644 --- a/tests/dogfood.rs.wiki.md +++ b/tests/dogfood.rs.wiki.md @@ -2,4 +2,4 @@ **Role:** Dogfood checks against fixture trees and this repository's own sources. -`containers_are_refused_and_scoped_children_map` refuses a root that contains several child projects and still maps a scoped child. `prepare_edit_on_this_repo_lists_api_dependents` copies this repo's `Cargo.toml`, `src/`, and `tests/` into a temp root, runs `update_maps`, and checks `prepare_edit` on `src/api.rs`. The dependent set is `src/lib.rs`, `src/mcp.rs`, `src/main.rs`, and the test files that import `wikifier::api` (`cli_surface`, `contract_gaps`, `protocol_v07`, `robustness`, `skip_maps`). +`containers_are_refused_and_scoped_children_map` refuses a root that contains several child projects and still maps a scoped child. `prepare_edit_on_this_repo_lists_api_dependents` copies this repo's `Cargo.toml`, `src/`, and `tests/` into a temp root, runs `update_maps`, and checks `prepare_edit` on `src/api.rs`. The dependent set is `src/lib.rs`, `src/mcp.rs`, `src/main.rs`, and the test files that import `wikifier::api` (`agent_args`, `cli_surface`, `contract_gaps`, `parity_fixes`, `protocol_v07`, `robustness`, `skip_maps`). diff --git a/tests/parity_fixes.rs b/tests/parity_fixes.rs index 674187a..0457348 100644 --- a/tests/parity_fixes.rs +++ b/tests/parity_fixes.rs @@ -104,9 +104,11 @@ fn js_comment_exports_are_not_public() { } #[test] -fn pending_line_uses_the_python_shape() { +fn pending_line_names_the_reason_and_the_next_step() { let root = scratch("pend"); fs::write(root.join("src/a.py"), "x = 1\n").unwrap(); + let empty = "# Pending Updates\n\n(no pending items — run check-changes after making edits)\n"; + fs::write(root.join("pending_updates.md"), empty).unwrap(); assert_eq!( api::record_change(&root, "src/a.py", "first reason") .get("success") @@ -115,9 +117,20 @@ fn pending_line_uses_the_python_shape() { ); let text = fs::read_to_string(root.join("pending_updates.md")).unwrap(); assert!( - text.contains("- src/a.py: LLM/agent edit — first reason"), + text.contains("- src/a.py: first reason — refresh the wiki, then mark-green"), "{text}" ); + assert!(!text.contains("no pending items"), "{text}"); + + fs::write(root.join("src/a.py.wiki.md"), "# a\n\nSets `x`.\n").unwrap(); + let marked = api::mark_green(&root, "src/a.py", "wiki (pend-1)", false); + assert_eq!( + marked.get("success").and_then(|v| v.as_bool()), + Some(true), + "{marked}" + ); + let text = fs::read_to_string(root.join("pending_updates.md")).unwrap(); + assert_eq!(text, empty); let _ = fs::remove_dir_all(&root); } diff --git a/tests/speed_paths.rs b/tests/speed_paths.rs new file mode 100644 index 0000000..a5c5f86 --- /dev/null +++ b/tests/speed_paths.rs @@ -0,0 +1,388 @@ +//! The fast paths must give the answers the slow paths gave: edges from the index, +//! map artifacts kept when the cache did not change, stat-first change checks, and a +//! capped bootstrap file list. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use wikifier::api; +use wikifier::cache; +use wikifier::{check_changes, mark_green, read_health, update_maps, Json}; + +fn scratch(name: &str) -> PathBuf { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("wk-speed-{name}-{n}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("src/pkg")).unwrap(); + fs::write(dir.join("map_paths.txt"), "src\n").unwrap(); + fs::write(dir.join("monitored_paths.txt"), "src\n").unwrap(); + dir +} + +/// `a` and `b` import each other, `c` imports `a` and itself. +fn cyclic_project(name: &str) -> PathBuf { + let root = scratch(name); + fs::write(root.join("src/pkg/__init__.py"), "").unwrap(); + fs::write(root.join("src/pkg/a.py"), "from . import b\n").unwrap(); + fs::write(root.join("src/pkg/b.py"), "from . import a\n").unwrap(); + fs::write( + root.join("src/pkg/c.py"), + "from . import a\nfrom . import c\n", + ) + .unwrap(); + let built = update_maps(&root); + assert_eq!(ok(&built), Some(true), "{built}"); + root +} + +fn ok(value: &Json) -> Option { + value.get("success").and_then(|v| v.as_bool()) +} + +fn by_src_edges(root: &Path) -> BTreeSet<(String, String)> { + let mut out = BTreeSet::new(); + for rel in cache::all_paths(root) { + for dst in cache::load_dependencies(root, &rel).unwrap() { + if dst != rel { + out.insert((rel.clone(), dst)); + } + } + } + out +} + +#[test] +fn edges_from_the_index_match_the_by_src_records() { + let root = cyclic_project("edges"); + let expected = by_src_edges(&root); + assert!(expected.contains(&("src/pkg/a.py".into(), "src/pkg/b.py".into()))); + assert_eq!(cache::edges(&root), expected); + + // An index written before targets were stored still yields the same edges. + let index = root.join(".wikifier_staging/wk_cache/index.tsv"); + let old_rows: String = fs::read_to_string(&index) + .unwrap() + .lines() + .map(|line| line.split('\t').take(4).collect::>().join("\t") + "\n") + .collect(); + fs::write(&index, old_rows).unwrap(); + assert_eq!(cache::edges(&root), expected); + + let cycles = api::cycles(&root); + assert_eq!(cycles.get("count"), Some(&Json::Int(1)), "{cycles}"); + assert!(cycles.to_string().contains("break_hint"), "{cycles}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn a_warm_run_keeps_map_artifacts_until_the_cache_changes() { + let root = cyclic_project("warm"); + let library = root.join("library.md"); + let marked = format!("{}\n", fs::read_to_string(&library).unwrap()); + fs::write(&library, &marked).unwrap(); + let warm = update_maps(&root); + assert_eq!(warm.get("files_parsed"), Some(&Json::Int(0)), "{warm}"); + assert_eq!(fs::read_to_string(&library).unwrap(), marked); + + // CRLF from a Windows checkout is still recognised as the generated file. + fs::write(&library, marked.replace('\n', "\r\n")).unwrap(); + update_maps(&root); + assert!(fs::read_to_string(&library) + .unwrap() + .contains("")); + + fs::write(root.join("src/pkg/d.py"), "from . import c\n").unwrap(); + update_maps(&root); + let text = fs::read_to_string(&library).unwrap(); + assert!(!text.contains(""), "{text}"); + assert!(text.contains("src/pkg/d.py"), "{text}"); + + // The init placeholder or a missing folder card is rebuilt even with no cache change. + fs::write( + &library, + "# Library & Imports Map\n\nRun `wikifier update-maps` to populate.\n", + ) + .unwrap(); + fs::remove_dir_all(root.join(".wikifier_staging/maps")).unwrap(); + update_maps(&root); + assert!(fs::read_to_string(&library) + .unwrap() + .contains("src/pkg/d.py")); + assert!(root.join(".wikifier_staging/maps/_root.json").is_file()); + let _ = fs::remove_dir_all(root); +} + +fn green(root: &Path, rel: &str) { + fs::write(root.join(format!("{rel}.wiki.md")), "# a\n\nImports `b`.\n").unwrap(); + let value = mark_green(root, rel, "wiki (speed-1)", false); + assert_eq!(ok(&value), Some(true), "{value}"); +} + +fn changes(root: &Path) -> i64 { + match check_changes(root).get("changes_detected") { + Some(Json::Int(n)) => *n, + other => panic!("{other:?}"), + } +} + +#[test] +fn check_changes_trusts_an_unchanged_stat_and_reads_everything_else() { + let root = cyclic_project("stat"); + let rel = "src/pkg/a.py"; + let path = root.join(rel); + green(&root, rel); + assert_eq!(changes(&root), 0); + + // Same size, mtime put back: the stat rule that update-maps also uses says unchanged. + let mtime = fs::metadata(&path).unwrap().modified().unwrap(); + fs::write(&path, "from . import c\n").unwrap(); + fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(mtime) + .unwrap(); + assert_eq!(changes(&root), 0); + + // A real edit moves the mtime and is caught. + fs::write(&path, "from . import b\nX = 2\n").unwrap(); + fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(mtime + Duration::from_secs(5)) + .unwrap(); + assert_eq!(changes(&root), 1); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn check_changes_reads_the_file_when_the_index_hash_is_not_the_baseline() { + let root = cyclic_project("baseline"); + let rel = "src/pkg/b.py"; + green(&root, rel); + // An index hash from an older hashing rule, with the stat still matching. + let index = root.join(".wikifier_staging/wk_cache/index.tsv"); + let rewritten: String = fs::read_to_string(&index) + .unwrap() + .lines() + .map(|line| { + let mut cols: Vec = line.split('\t').map(|c| c.to_string()).collect(); + if cols[0] == rel { + cols[3] = "sha256:0000".into(); + } + cols.join("\t") + "\n" + }) + .collect(); + fs::write(&index, rewritten).unwrap(); + assert_eq!(changes(&root), 0); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn seeded_rows_carry_the_file_content_hash() { + let root = cyclic_project("seed"); + for row in read_health(&root) { + let bytes = fs::read(root.join(&row.file)).unwrap(); + assert_eq!( + row.source_hash, + wikifier::sha256::content_sha256_prefixed(&bytes), + "{}", + row.file + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bootstrap_caps_its_file_list() { + let root = scratch("bootstrap"); + for i in 0..105 { + fs::write(root.join(format!("src/m{i:03}.py")), "X = 1\n").unwrap(); + } + update_maps(&root); + let mut flags = BTreeMap::new(); + let full = api::dispatch("session-bootstrap", &root, &[], &flags); + assert_eq!(files(&full), 105); + assert!(full.get("files_total").is_none()); + flags.insert("limit".to_string(), "3".to_string()); + let capped = api::dispatch("session-bootstrap", &root, &[], &flags); + assert_eq!(files(&capped), 3); + assert_eq!(capped.get("files_total"), Some(&Json::Int(105))); + + let mcp = |args: Vec<(&str, Json)>| { + let mut args = args; + args.push(( + "project_root", + Json::String(root.to_string_lossy().into_owned()), + )); + let msg = Json::obj(vec![ + ("jsonrpc", Json::String("2.0".into())), + ("id", Json::Int(1)), + ("method", Json::String("tools/call".into())), + ( + "params", + Json::obj(vec![ + ("name", Json::String("session_bootstrap".into())), + ("arguments", Json::obj(args)), + ]), + ), + ]); + wikifier::mcp::handle_message(&msg) + .unwrap() + .get("result") + .and_then(|r| r.get("structuredContent")) + .cloned() + .unwrap() + }; + let default = mcp(vec![]); + assert_eq!(files(&default), 100, "{default}"); + assert_eq!(default.get("files_total"), Some(&Json::Int(105))); + assert_eq!(files(&mcp(vec![("limit", Json::Int(0))])), 105); + let _ = fs::remove_dir_all(root); +} + +fn files(value: &Json) -> usize { + value + .get("files") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or_else(|| panic!("{value}")) +} + +/// Every cache file except the generation counter, by path under `wk_cache`. +fn cache_snapshot(root: &Path) -> BTreeMap> { + fn walk(dir: &Path, base: &Path, out: &mut BTreeMap>) { + for ent in fs::read_dir(dir).unwrap().flatten() { + let path = ent.path(); + if path.is_dir() { + walk(&path, base, out); + } else { + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel != "generation" { + out.insert(rel, fs::read(&path).unwrap()); + } + } + } + } + let base = root.join(".wikifier_staging/wk_cache"); + let mut out = BTreeMap::new(); + walk(&base, &base, &mut out); + out +} + +fn answers(root: &Path) -> Vec { + ["src/pkg/a.py", "src/pkg/b.py", "src/pkg/c.py"] + .iter() + .flat_map(|rel| { + [ + api::dependency_report(root, rel, true).to_string(), + wikifier::load_dependents(root, rel).to_string(), + ] + }) + .chain([api::cycles(root).to_string()]) + .collect() +} + +fn record_of(root: &Path, kind: &str, contains: &str) -> PathBuf { + let dir = root.join(".wikifier_staging/wk_cache").join(kind); + fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|ent| ent.path()) + .find(|path| String::from_utf8_lossy(&fs::read(path).unwrap()).contains(contains)) + .unwrap_or_else(|| panic!("no {kind} record mentioning {contains}")) +} + +#[test] +fn corrupt_and_truncated_cache_files_are_rebuilt_identically() { + let root = cyclic_project("heal"); + assert!(root.join(".wikifier_staging/wk_cache/sizes.tsv").is_file()); + let before = cache_snapshot(&root); + let answered = answers(&root); + + // Garbage of another length in a forward record, and a truncated reverse record. + let by_src = record_of(&root, "by_src", "@\tsrc/pkg/a.py\n"); + fs::write(&by_src, b"\0\0garbage").unwrap(); + let by_dst = record_of(&root, "by_dst", "@\tsrc/pkg/a.py\n"); + let bytes = fs::read(&by_dst).unwrap(); + fs::write(&by_dst, &bytes[..bytes.len() / 2]).unwrap(); + + // Lookups treat a bad record as a miss and answer from index.tsv. + assert_eq!( + cache::load_dependencies(&root, "src/pkg/a.py").unwrap(), + vec!["src/pkg/b.py".to_string()] + ); + let importers = cache::load_dependents(&root, "src/pkg/a.py").unwrap(); + assert_eq!( + importers, + vec!["src/pkg/b.py".to_string(), "src/pkg/c.py".to_string()] + ); + + let healed = update_maps(&root); + assert_eq!(healed.get("files_parsed"), Some(&Json::Int(1)), "{healed}"); + assert_eq!(cache_snapshot(&root), before); + assert_eq!(answers(&root), answered); + + // Same-size damage is caught by content once the recorded sizes are gone too. + let notes_free = record_of(&root, "by_src", "@\tsrc/pkg/b.py\n"); + let len = fs::read(¬es_free).unwrap().len(); + fs::write(¬es_free, vec![0u8; len]).unwrap(); + fs::remove_file(root.join(".wikifier_staging/wk_cache/sizes.tsv")).unwrap(); + update_maps(&root); + assert_eq!(cache_snapshot(&root), before); + assert_eq!(answers(&root), answered); + let _ = fs::remove_dir_all(root); +} + +/// Health, journal, and pending stay durable: each write replaces the whole file through a +/// synced temp file, and no temp file is left beside it. +#[test] +fn user_state_is_replaced_whole_and_leaves_no_temp_files() { + let root = cyclic_project("durable"); + let recorded = wikifier::record_change(&root, "src/pkg/a.py", "durable write (speed-2)"); + assert_eq!(ok(&recorded), Some(true), "{recorded}"); + let pending = fs::read_to_string(root.join("pending_updates.md")).unwrap(); + assert!( + pending.contains("- src/pkg/a.py: durable write (speed-2)"), + "{pending}" + ); + assert!(read_health(&root) + .iter() + .any(|row| row.file == "src/pkg/a.py" && row.reason == "durable write (speed-2)")); + let journal = wikifier::read_journal(&root, None); + assert!(journal + .iter() + .any(|e| e.reason == "durable write (speed-2)")); + let mut dirs = vec![root.clone()]; + while let Some(dir) = dirs.pop() { + for ent in fs::read_dir(&dir).unwrap().flatten() { + let path = ent.path(); + if path.is_dir() { + dirs.push(path); + } else { + assert!( + !path.to_string_lossy().ends_with(".tmp"), + "left behind: {}", + path.display() + ); + } + } + } + wikifier::artifacts::write_durable(&root.join("pending_updates.md"), b"# Pending Updates\n") + .unwrap(); + assert_eq!( + fs::read_to_string(root.join("pending_updates.md")).unwrap(), + "# Pending Updates\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/wikifier.bat b/wikifier.bat index 961104e..354fac9 100644 --- a/wikifier.bat +++ b/wikifier.bat @@ -1,9 +1,15 @@ @echo off -REM Launch the Rust wikifier binary. +REM Launch the Rust wikifier binary. Prefers WIKIFIER_BIN, then a local build, then cargo run. +REM No parenthesized blocks: %ERRORLEVEL% inside one expands before the command runs, +REM which used to turn every failure into exit code 0. setlocal set "HERE=%~dp0" -if not "%WIKIFIER_BIN%"=="" ("%WIKIFIER_BIN%" %* & exit /b %ERRORLEVEL%) -if exist "%HERE%target\release\wikifier.exe" ("%HERE%target\release\wikifier.exe" %* & exit /b %ERRORLEVEL%) -if exist "%HERE%target\debug\wikifier.exe" ("%HERE%target\debug\wikifier.exe" %* & exit /b %ERRORLEVEL%) +set "BIN=%WIKIFIER_BIN%" +if not defined BIN if exist "%HERE%target\release\wikifier.exe" set "BIN=%HERE%target\release\wikifier.exe" +if not defined BIN if exist "%HERE%target\debug\wikifier.exe" set "BIN=%HERE%target\debug\wikifier.exe" +if not defined BIN goto cargo +"%BIN%" %* +exit /b %ERRORLEVEL% +:cargo cargo run --quiet --manifest-path "%HERE%Cargo.toml" -- %* exit /b %ERRORLEVEL%