diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4b5dd1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repo. (Humans: see +[`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`docs/`](./docs/index.md).) + +Surface is a deterministic gate that surfaces divergence between docs and code: you anchor a +sentence to the code it describes, and `surf check` blocks when that code's logic changes out +from under the prose. **This repo dogfoods Surface on its own source** — the gate runs on +`surf-core`/`surf-cli`. + +## Where the context lives: `hubs/` + + +[`hubs/`](./hubs/) is the governed context for this codebase. Each hub is markdown prose +describing an invariant, with frontmatter anchoring the claim to a specific symbol. Unlike code +comments, **hub prose is sealed by `surf check`** — if the anchored code changed since a human +last confirmed the prose, the gate fails. So the hubs are trustworthy and current in a way +comments are not, and they are the fastest accurate way to understand a part of the system. + +**Read only the hubs you need — not the whole directory.** The hubs together describe the entire +codebase; reading all of them is wasteful context. They are split per module +(`hubs/cli-check.md`, `hubs/hash.md`, `hubs/resolve.md`, …), and each starts with a one-line +`summary:` in its frontmatter. Scan the filenames and summaries, then open only the hub(s) +covering the area you're working on. + + +`surf lint` enforces that this block stays — pointing at the hubs directory, never duplicating +or enumerating individual hubs. + +Caveat (the tool's own honest limit): a green gate means *the anchored code hasn't changed +since last verified* — not that every sentence is true, and nothing about code no hub anchored. +If you read a hub claim, sanity-check it against the code it points at before relying on it. + +## When you add or change a feature — keep the hubs honest + +Run the loop (binary builds to `target/debug/surf`; see `CONTRIBUTING.md` for build commands): + +1. Make the change. +2. `surf lint` — every anchor must resolve. Consider the advisory granularity warnings + (over/under-anchoring); they are nudges, not blocks. +3. `surf check` — if you touched code a hub anchors, it will report `DIVERGED`. Re-read the + claim. If the prose **still holds**, `surf verify` re-seals it (writes the new hash); if the + prose is **now false**, fix the prose first, then verify. +4. Added public behavior? Add a hub claim for it — the under-coverage warning flags public + functions with no claim. When you update a hub, update its *prose* to stay accurate, not just + the hash. +5. Record user-facing changes in [`CHANGELOG.md`](./CHANGELOG.md) under `[Unreleased]`. + +Do not blindly `surf verify` to make the gate green — that is the rubber-stamping failure the +tool exists to prevent. Verify means "I read the prose and it is still true." + +## Pointers + +- [`hubs/`](./hubs/) — governed context, split per module; read only the hub(s) you need. +- [`CHANGELOG.md`](./CHANGELOG.md) — what changed; update `[Unreleased]`. +- [`docs/index.md`](./docs/index.md) — documentation map (guides, reference, concepts). +- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — build, test, format, lint commands and layout. +- [`docs/surface-proposal.md`](./docs/surface-proposal.md) — the product spec (the `§` references in hubs). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b3db89 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to Surface are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-06-07 + +### Added +- `surf check` scoping: `--files ` evaluates only claims whose anchored file(s) match, + and an explicit `--base ` diff-scopes the gate to claims whose files changed since the + merge base. Omitting `--base` keeps a full check (enrichment falls back to `HEAD`). +- `--format json` for `surf lint` and `surf verify`, mirroring `surf check`. `lint` emits + structured findings; `verify` emits per-anchor outcomes (stamped/followed/unchanged/skipped) + plus counts. +- Advisory `surf lint` granularity warnings (never blocking): a near-whole-file anchor span, a + hub with too many anchors, and public functions in an anchored file that no claim covers. +- `surf lint` enforces the `AGENTS.md` pointer block: when `AGENTS.md` carries a + `` block, it must link the hubs directory (and that directory must exist) so + agents are pointed at the hubs to search — never duplicating or enumerating them. Opt-in; + blocks on a missing/dangling pointer. +- `surf suggest ` — scans source for top-level public functions that no hub anchors yet + and prints a copy-pasteable starter hub. Suggestions only: never writes a file or stamps a hash. +- `surf_core::public_fns` — enumerates a file's top-level public functions (backs the + under-coverage warning and `surf suggest`). + +### Changed +- An explicit `surf check --base ` now **diff-scopes** the gate to claims whose files + changed, in addition to feeding advisory `old_code`/`magnitude`. Previously `--base` only fed + enrichment and every claim was still checked. CI that passes `--base` explicitly will now check + fewer claims; omit `--base` for a full check. + +## [0.1.1] - 2026-06-06 + +### Fixed +- `resolve`: resolve const-bound call-expression functions (TS/JS). +- `verify`: skip unchanged anchors instead of re-stamping, so a no-op verify leaves hub files + byte-identical. + +### Changed +- Releases: dropped Intel macOS (`x86_64-apple-darwin`); prebuilt binaries cover macOS (Apple + Silicon) and Linux (x86_64). Other targets build from source. + +## [0.1.0] - 2026-06-06 + +Initial release — the MVP gate that surfaces docs↔code divergence. + +### Added +- AST-canonical hashing via bundled, version-pinned tree-sitter grammars: quiet on cosmetics + (formatting, comments, consistent renames), loud on logic. Advisory tree-edit `magnitude`. +- Anchor resolution: the `file > A > B` path grammar with `@N` disambiguation; scope-set + resolution so `Type > method` resolves uniquely. +- Hub format (markdown frontmatter), `surf.toml` workspace discovery, and per-claim combined + hashing across multi-site `at:` lists. +- Commands: `surf lint`, `surf check` (the gate), `surf verify` (re-seal, with `--follow` for + renames), plus `surf init` and `surf new` for authoring ergonomics. +- Language support: TypeScript/TSX, JavaScript/JSX, Rust, Python, and Go. +- Distribution: GitHub Action, pre-commit hook, and `install.sh`; Apache-2.0 license. + +[Unreleased]: https://github.com/Connorrmcd6/surface/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/Connorrmcd6/surface/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/Connorrmcd6/surface/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Connorrmcd6/surface/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 754aac8..3df255a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,5 +40,9 @@ cargo run -q -p surf-cli -- verify "surf-core/src/hash.rs > emit" - `surf-cli/` — the `surf` binary: workspace discovery, the commands, all I/O. - `docs/phases/` — how the MVP was built, one self-contained file per phase. Start with `docs/phases/OVERVIEW.md`. The product spec is `docs/surface-proposal.md`. +- `docs/index.md` — the documentation map (guides, reference, concepts). `AGENTS.md` is the + on-ramp for AI coding agents. Keep `surf-core` free of I/O so it stays reusable; put filesystem/git work in `surf-cli`. + +When a change is user-facing, add a line to `CHANGELOG.md` under `[Unreleased]`. diff --git a/Cargo.lock b/Cargo.lock index 876e348..0cded54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,7 +519,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "surf-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -533,7 +533,7 @@ dependencies = [ [[package]] name = "surf-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "serde", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index f18c889..de2bbaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["surf-core", "surf-cli"] resolver = "2" [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2021" license = "Apache-2.0" repository = "https://github.com/Connorrmcd6/surface" diff --git a/README.md b/README.md index f33d8ef..09da4e7 100644 --- a/README.md +++ b/README.md @@ -131,14 +131,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # plain checkout — do NOT set fetch-depth: 0 - - uses: Connorrmcd6/surface@v0.1.0 + - uses: Connorrmcd6/surface@v0.2.0 ``` pre-commit — `.pre-commit-config.yaml`: ```yaml - repo: https://github.com/Connorrmcd6/surface - rev: v0.1.0 + rev: v0.2.0 hooks: - id: surf-check ``` @@ -235,10 +235,21 @@ surf check --format json - `surf init` — bootstrap a workspace: write `surf.toml` and create the hubs directory (idempotent). - `surf new ` — scaffold a new empty hub under your hubs directory. +- `surf suggest [--format human|json]` — scan source globs for top-level public functions no hub anchors yet and print a copy-pasteable starter hub. Suggestions only — never writes or stamps. - `surf lint [--format human|json]` — validate frontmatter and that every `at:` resolves to exactly one symbol. Warns on a renamed symbol (suggests `verify --follow`); blocks on ambiguous or vanished anchors. Also emits advisory granularity warnings (never blocking, §8): a near-whole-file anchor span, a hub with too many anchors, and public functions in an anchored file that no claim covers. - `surf check [--format human|json] [--base ] [--files ]` — the gate. AST-canonical-hash each anchored span and compare to the stored hash; non-zero exit on any divergence. By default every claim is checked. `--base ` scopes to claims whose anchored files changed since the merge base **and** recovers the advisory `old_code` / `magnitude` fields from that ref (omit it for a full check with enrichment against `HEAD`). `--files ` scopes to claims whose anchored file(s) match a comma-separated glob (e.g. `surf-core/**`). - `surf verify [] [--follow] [--format human|json]` — re-seal after you've confirmed the prose still holds; writes the hash into the frontmatter. `` limits to one anchor; `--follow` re-points a renamed single-symbol anchor and re-hashes in one step. +## Documentation + +Full docs live in [`docs/`](docs/index.md): + +- [Authoring hubs](docs/guides/authoring-hubs.md) — writing claims and anchors, choosing granularity, the verify loop. +- [CI integration](docs/guides/ci-integration.md) — the Action, the pre-commit hook, and scoping the gate to a PR. +- [Examples](docs/examples.md) — a minimal worked hub in each supported language. + +Release history is in [`CHANGELOG.md`](CHANGELOG.md). AI agents working in this repo: see [`AGENTS.md`](AGENTS.md). + ## Configuration A `surf.toml` at the repo root marks the workspace — `surf` walks up from the current diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..c99b385 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,68 @@ +# Examples + +One minimal hub per supported language. Each shows an `at:` anchor and the rule that always +holds: **quiet on cosmetics (formatting, comments, consistent renames), loud on logic.** Run +`surf verify` once to seal the `hash`, then `surf check` gates it. + +The anchor grammar and the verify loop are covered in [Authoring hubs](./guides/authoring-hubs.md). + +## TypeScript + +```yaml +anchors: + - claim: rotation is single-use; a reused token triggers global logout + at: src/auth/refresh.ts > TokenService > rotate +``` + +- Rename a local, reformat, add a comment → **no fire.** +- Change `if (token.used)` to `if (token.used || token.expired)` → **fires.** + +## JavaScript / JSX + +```yaml +anchors: + - claim: cart total applies the member discount before tax + at: src/cart.js > computeTotal +``` + +JS/JSX is parsed by the TS-family grammar, so `const computeTotal = (...) => { ... }` resolves +the same as a `function` declaration. + +- Drop an `await`, flip `*` to `+` → **fires.** + +## Rust + +```yaml +anchors: + - claim: combining site hashes is order-sensitive + at: surf-core/src/hash.rs > combine_site_hashes +``` + +`Type > method` walks into an `impl`: `at: surf-cli/src/workspace.rs > Workspace > discover`. + +- Change `<` to `<=`, alter a literal value → **fires.** + +## Python + +```yaml +anchors: + - claim: retries use exponential backoff capped at 30s + at: api/client.py > Client > _request +``` + +Decorators are transparent — `@retry` above `def _request` doesn't change resolution. + +- Change the backoff base or the cap → **fires.** + +## Go + +```yaml +anchors: + - claim: the receiver validates the signature before decoding + at: internal/webhook.go > Handler > Verify +``` + +Go methods attach by receiver, so the anchor is `Type > Method` even though the method isn't +nested in the type. Top-level funcs/types use a single segment: `cmd/main.go > Run`. + +- Reorder unrelated declarations → **no fire.** Change the comparison in `Verify` → **fires.** diff --git a/docs/guides/authoring-hubs.md b/docs/guides/authoring-hubs.md new file mode 100644 index 0000000..a295c7a --- /dev/null +++ b/docs/guides/authoring-hubs.md @@ -0,0 +1,123 @@ +# Authoring hubs + +A **hub** is a markdown file whose frontmatter anchors sentences ("claims") to the code they +describe. This guide covers writing claims, the anchor grammar, choosing the right granularity, +and the verify loop. For the end-to-end first run, see the [Quickstart](../../README.md#quickstart). + +## Anatomy of a hub + +```yaml +--- +summary: How auth refresh rotation works. +anchors: + - claim: refresh rotation is single-use; reuse triggers global logout + at: src/auth/refresh.ts > rotateRefreshToken + hash: 9b1c33ade8f1 # written by `surf verify`, not by hand +refs: [] +--- + +# Auth + +Prose a human (or agent) reads to understand this domain. +``` + +- **`claim`** — one sentence stating an invariant. Write what must stay true, not how the code + is structured. A claim that restates the implementation rots as fast as a comment. +- **`at`** — the anchor: where the claim's logic lives (grammar below). +- **`hash`** — the seal. Absent until you `surf verify`; the gate treats a hashless claim as + *unverified*. + +Where hubs live is configured by the `hubs` glob in `surf.toml` (default `hubs/*.md`); keep them +central or co-locate them with code (`["**/_hub.md"]`). + +## Bootstrapping with `surf suggest` + +Authoring claims by hand is the main adoption cost. To get a head start, point `surf suggest` at +your source and it lists the top-level public functions no hub anchors yet, as a copy-pasteable +starter hub: + +```sh +surf suggest "src/**/*.ts" # or --format json for tooling +``` + +It only suggests — it never writes a file or stamps a hash. Paste the block into a hub (or +`surf new `), write a real claim sentence for each anchor you keep, delete the rest, then +`surf verify`. Treat it as a checklist of undocumented surface, not a mandate to anchor everything +(see granularity below). + +## The anchor grammar + +An anchor is a file path, then a `>`-separated symbol path: + +``` +src/service.ts > TokenService > rotate +``` + +- **One segment** points at a top-level symbol: `src/m.rs > parse_anchor`. +- **Nested segments** walk into scopes: a type and its `impl`/methods share a name, so + `Type` alone may be ambiguous while `Type > method` is unique. +- **`@N`** disambiguates genuine name collisions (1-based), e.g. two overloads: + `src/api.ts > handler@2`. +- **Multiple sites** — an `at:` list combines its sites into one hash, so the claim is stale if + *any* listed span changes: + ```yaml + at: + - src/a.rs > foo + - src/b.rs > bar + ``` + +Run `surf lint` to confirm every anchor resolves to exactly one symbol. Ambiguous or vanished +anchors **block**; a symbol that was merely renamed only **warns** and points you at +`surf verify --follow`. + +## Choosing granularity + +This is the central tension (proposal §8): + +- **Under-anchor** → real drift slips through, because the changed logic wasn't anchored. +- **Over-anchor** → every incidental edit re-triggers verification, and humans start + rubber-stamping `verify` without reading — which defeats the tool. + +`surf lint` emits advisory warnings (never blocking) to nudge you toward the middle: + +- **Near-whole-file span** — the anchored symbol covers most of its file. Anchor a narrower + symbol so unrelated edits don't trip the claim. +- **Too many anchors in one hub** — split the hub; a long verify list invites rubber-stamping. +- **Uncovered public function** — a public function in a file the hub already anchors has no + claim. Either add one, or accept it as intentionally undocumented. + +Rule of thumb: anchor the **smallest symbol whose logic the sentence is actually about.** + +## The verify loop + +`surf verify` is the human escape hatch: it re-seals a claim after *you* confirm the prose still +holds, writing the hash into the frontmatter (and touching only that line). + +```sh +surf check # DIVERGED? a claim's anchored logic changed +# re-read the claim: +# still true → surf verify [] (re-seal) +# now false → fix the prose first, then verify +surf verify --follow # renamed symbol: re-point the anchor and re-hash in one step +``` + +Verifying without reading is the failure mode the whole tool exists to prevent. A green gate +promises only "nothing anchored changed since last sign-off" — never that the prose is true. + +## Hubs and `AGENTS.md` + +Hubs are *declarative* domain briefings; `AGENTS.md` is *imperative* operating instructions for +coding agents. Keep them separate — don't copy hub prose into `AGENTS.md`. Instead, give +`AGENTS.md` a pointer block that sends agents to the hubs directory to search for what they need: + +```markdown + +Context lives in [`hubs/`](./hubs/) — read only the hub(s) you need. + +``` + +When that block is present, `surf lint` checks it links the configured hubs directory and that +the directory exists. It deliberately does **not** enumerate individual hubs — that would push an +agent to read everything instead of the one hub it needs. + +See also: [CI integration](./ci-integration.md) · [Examples](../examples.md). diff --git a/docs/guides/ci-integration.md b/docs/guides/ci-integration.md new file mode 100644 index 0000000..6e42a36 --- /dev/null +++ b/docs/guides/ci-integration.md @@ -0,0 +1,63 @@ +# CI integration + +`surf check` is the gate: it exits non-zero when an anchored span diverged, so it blocks a merge +the same way a failing test does. Most repos never install the binary — they run the Action or +the pre-commit hook. + +## GitHub Action + +`.github/workflows/surface.yml`: + +```yaml +name: Surface +on: pull_request +jobs: + surface: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 # plain checkout — do NOT set fetch-depth: 0 + - uses: Connorrmcd6/surface@v0.2.0 +``` + +The action takes `args` (default `check`), `version` (default `latest`), and +`working-directory` (default `.`). To emit machine-readable output for another job or a reviewer +bot, set `args: check --format json`. + +### Checkout depth + +The verdict hashes your **working tree** and compares it to the hash committed in the +frontmatter — it does not need git history, so a plain `actions/checkout@v4` is enough. **Do not +set `fetch-depth: 0`.** The advisory `old_code`/`magnitude` fields use a single `git show` of the +base ref; with no history available the verdict is unchanged and those fields are simply omitted. + +The one exception: if you diff-scope with `--base ` (below), fetch enough history to reach +the merge base — a shallow `git fetch ` is plenty, still not `fetch-depth: 0`. + +## pre-commit + +`.pre-commit-config.yaml`: + +```yaml +- repo: https://github.com/Connorrmcd6/surface + rev: v0.2.0 + hooks: + - id: surf-check +``` + +This runs the same gate locally at commit time, catching drift before it reaches CI. + +## Scoping the gate to a PR + +By default `check` evaluates every claim in every hub. On large repos or big PRs you can narrow +it: + +- **`--base `** — evaluate only claims whose anchored files changed since the merge base + with `` (e.g. `surf check --base origin/main`). This also recovers the advisory + `old_code`/`magnitude` from that ref. +- **`--files `** — evaluate only claims whose anchored file(s) match a comma-separated + glob (e.g. `surf check --files "src/auth/**"`). + +Both filters intersect when combined. With neither flag, the full check runs (enrichment against +`HEAD`). A bad ref or non-repo falls back to a full check rather than silently checking nothing. + +See also: [Authoring hubs](./authoring-hubs.md) · [Command reference](../../README.md#commands). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6905134 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# Surface documentation + +The map of Surface's docs. As content grows it lands in this tree rather than the README; a +dedicated docs site will later mirror this structure. For the pitch and a quick look, start with +the [README](../README.md). + +> Reference material (commands, configuration, the hashing mechanism) currently lives in the +> README and is linked below. It will migrate into dedicated `docs/reference/` pages when the +> docs site is built — for now the README stays the single source of truth, to avoid two copies +> drifting apart. + +## Overview +- [What Surface is and the problem it solves](../README.md#the-problem) +- [What it does that tests don't](../README.md#what-surface-does-that-tests-dont) +- [Is Surface for you?](../README.md#is-surface-for-you) + +## Getting started +- [Install](../README.md#install) +- [Quickstart](../README.md#quickstart) — `init` → `new` → `lint` → `check` → `verify` + +## Guides +- [Authoring hubs](./guides/authoring-hubs.md) — writing good claims and anchors, choosing + granularity, and the verify loop. +- [CI integration](./guides/ci-integration.md) — the GitHub Action, the pre-commit hook, and + scoping the gate to a PR. +- [Examples](./examples.md) — a minimal worked hub in each supported language. + +## Reference *(currently in the README)* +- [Commands](../README.md#commands) +- [Configuration](../README.md#configuration) +- [How the gate works (technical) + the JSON report](../README.md#how-the-gate-works-technical) + +## Concepts +- [What Surface does NOT do](../README.md#what-surface-does-not-do) — the honest limits. +- [The honest limit (§7) and the claim-maintenance risk (§8)](./surface-proposal.md) — in the spec. + +## Development *(internal — not part of the published site)* +- [Product spec / proposal](./surface-proposal.md) — the source of the `§` references in hubs. +- [Build phases](./phases/README.md) — how the MVP was built, one file per phase. +- [Contributing](../CONTRIBUTING.md) — build, test, dogfood. +- [AGENTS.md](../AGENTS.md) — on-ramp for AI coding agents. diff --git a/hubs/cli-lint.md b/hubs/cli-lint.md index ad40485..2e0d179 100644 --- a/hubs/cli-lint.md +++ b/hubs/cli-lint.md @@ -13,6 +13,13 @@ anchors: whose anchors all resolved cleanly, so coverage nags never pile onto broken anchors. at: surf-cli/src/lint.rs > lint_under_coverage hash: dad7767e0594 + - claim: > + AGENTS.md enforcement is opt-in (§11.6): only when the file carries a surf:hubs marker + block does lint require it to link the configured hubs directory (which must exist), + blocking otherwise. It points agents at the directory to search — never enumerating + individual hubs, which would push an agent to read everything. + at: surf-cli/src/lint.rs > lint_agents_pointer + hash: 938380798f7a refs: [] --- @@ -21,4 +28,5 @@ refs: [] `lint_workspace` loads every hub and runs `lint_site` over each anchor; `run` prints the findings and chooses the exit code. Beyond resolution, lint emits advisory warnings (§8) that nudge granularity: a near-whole-file span (`lint_coarse_span`), too many anchors in one hub, -and public functions with no covering claim (`lint_under_coverage`). +and public functions with no covering claim (`lint_under_coverage`). It also validates the +`AGENTS.md` pointer block (`lint_agents_pointer`). diff --git a/hubs/cli-scaffold.md b/hubs/cli-scaffold.md index 5ef5abe..a773178 100644 --- a/hubs/cli-scaffold.md +++ b/hubs/cli-scaffold.md @@ -10,7 +10,7 @@ anchors: new derives the target directory from the literal prefix of the first hub glob, then writes a hub with no anchors so it is lint-clean immediately; it refuses to overwrite. at: surf-cli/src/new.rs > hub_dir - hash: dc1a3a8a00af + hash: 598296b19fb6 refs: [] --- diff --git a/hubs/cli-suggest.md b/hubs/cli-suggest.md new file mode 100644 index 0000000..4ef3ef8 --- /dev/null +++ b/hubs/cli-suggest.md @@ -0,0 +1,18 @@ +--- +summary: surf suggest — propose anchors for unanchored public functions; read-only, never stamps. +anchors: + - claim: > + surf suggest is read-only: run scans the given globs, lists each top-level public function + no hub already anchors, and prints them (a starter hub in human mode, or JSON). It never + writes a file and never computes or stamps a hash — the author edits the claims and verifies. + at: surf-cli/src/suggest.rs > run + hash: cf02ef9af242 +refs: [] +--- + +# surf suggest + +Lowers the §8 authoring cost. `run` reads existing hub coverage, scans the requested source +globs via `scan` (which reuses `surf_core::public_fns` and skips already-anchored symbols), and +prints suggestions. Suggestions only: the author turns them into real claims and runs +`surf verify`. diff --git a/surf-cli/src/lint.rs b/surf-cli/src/lint.rs index 0f42562..eb2ead3 100644 --- a/surf-cli/src/lint.rs +++ b/surf-cli/src/lint.rs @@ -145,9 +145,60 @@ fn lint_workspace(ws: &Workspace) -> Result> { lint_under_coverage(ws, &rel, file, covered_names, &mut findings); } } + + lint_agents_pointer(ws, &mut findings); Ok(findings) } +/// §11.6: `AGENTS.md` (imperative agent instructions) must point at the hubs *directory* and +/// tell the agent to search it — not duplicate hub prose, and not enumerate every hub (which +/// would push an agent to read everything). Opt-in: enforced only when the file carries a +/// `` … `` block. The block must link the configured hubs +/// directory, and that directory must exist. +fn lint_agents_pointer(ws: &Workspace, findings: &mut Vec) { + const OPEN: &str = ""; + const CLOSE: &str = ""; + + let Ok(text) = std::fs::read_to_string(ws.root.join("AGENTS.md")) else { + return; // no AGENTS.md → nothing to enforce + }; + let Some(block) = text + .split_once(OPEN) + .and_then(|(_, rest)| rest.split_once(CLOSE)) + .map(|(block, _)| block) + else { + return; // no pointer block → opt-out + }; + + let dir = crate::new::hub_dir(&ws.config.hubs); + let dir_str = dir.to_string_lossy(); + let want = dir_str.trim_end_matches('/'); + + let links_dir = link_targets(block).any(|t| { + let t = t.trim_start_matches("./").trim_end_matches('/'); + t == want + }); + + if !links_dir || !ws.root.join(&dir).is_dir() { + findings.push(Finding { + severity: Severity::Block, + hub: "AGENTS.md".to_string(), + claim: String::new(), + at: String::new(), + message: format!( + "`surf:hubs` block must link the hubs directory `{want}/` and it must exist — agents read it to find context" + ), + }); + } +} + +/// Markdown link targets (`](target)`) in a fragment of text. +fn link_targets(text: &str) -> impl Iterator { + text.split("](") + .skip(1) + .filter_map(|after| after.split_once(')').map(|(target, _)| target.trim())) +} + /// What `lint_site` learned about one anchor site: which file/symbol it names and whether it /// resolved cleanly. `None` when the site can't even be attributed to a file (unparseable). struct SiteInfo { @@ -486,4 +537,46 @@ mod tests { "expected a too-many-anchors warning, got {f:?}" ); } + + fn agents_findings(ws: &Workspace) -> Vec { + lint_workspace(ws) + .unwrap() + .into_iter() + .filter(|f| f.hub == "AGENTS.md") + .collect() + } + + #[test] + fn agents_pointer_valid_is_silent() { + // ws_with creates the `hubs/` dir; the block links it. + let (_t, ws) = ws_with(&[( + "AGENTS.md", + "# Agents\n\nContext lives in [`hubs/`](./hubs/) — search it.\n\n", + )]); + assert!(agents_findings(&ws).is_empty()); + } + + #[test] + fn agents_no_markers_is_silent() { + // A link to hubs but no markers → opt-out, no enforcement. + let (_t, ws) = ws_with(&[("AGENTS.md", "# Agents\nsee [hubs](./hubs/)\n")]); + assert!(agents_findings(&ws).is_empty()); + } + + #[test] + fn agents_no_file_is_silent() { + let (_t, ws) = ws_with(&[("src/m.rs", "pub fn a() {}\n")]); + assert!(agents_findings(&ws).is_empty()); + } + + #[test] + fn agents_pointer_to_wrong_dir_blocks() { + let (_t, ws) = ws_with(&[( + "AGENTS.md", + "\nsee [stuff](./nothubs/)\n\n", + )]); + let f = agents_findings(&ws); + assert_eq!(f.len(), 1); + assert_eq!(f[0].severity, Severity::Block); + } } diff --git a/surf-cli/src/main.rs b/surf-cli/src/main.rs index edb5c81..2e8d2b0 100644 --- a/surf-cli/src/main.rs +++ b/surf-cli/src/main.rs @@ -5,6 +5,7 @@ mod format; mod init; mod lint; mod new; +mod suggest; mod verify; mod workspace; @@ -68,6 +69,15 @@ enum Command { #[arg(long, value_enum, default_value_t = Format::Human)] format: Format, }, + /// Propose anchors for public functions no hub covers yet (suggestions only; never writes). + Suggest { + /// Source globs to scan, relative to the workspace root (e.g. "surf-core/src/**/*.rs"). + #[arg(required = true)] + globs: Vec, + /// Output format for the suggestions. + #[arg(long, value_enum, default_value_t = Format::Human)] + format: Format, + }, } fn main() -> std::process::ExitCode { @@ -104,5 +114,6 @@ fn run() -> anyhow::Result { follow, format, } => verify::run(&ws, target.as_deref(), follow, format), + Command::Suggest { globs, format } => suggest::run(&ws, &globs, format), } } diff --git a/surf-cli/src/new.rs b/surf-cli/src/new.rs index 3b6ed90..5e9970e 100644 --- a/surf-cli/src/new.rs +++ b/surf-cli/src/new.rs @@ -24,7 +24,7 @@ pub fn run(ws: &Workspace, name: &str) -> Result { /// The directory a new hub should go in: the literal prefix of the first hub glob, up to the /// first glob metacharacter. `hubs/*.md` -> `hubs`, `docs/hubs/*.md` -> `docs/hubs`. -fn hub_dir(globs: &[String]) -> PathBuf { +pub(crate) fn hub_dir(globs: &[String]) -> PathBuf { let glob = globs.first().map(String::as_str).unwrap_or("hubs/*.md"); let literal = glob.split(['*', '?', '[']).next().unwrap_or(""); match literal.rfind('/') { diff --git a/surf-cli/src/suggest.rs b/surf-cli/src/suggest.rs new file mode 100644 index 0000000..52d3d8a --- /dev/null +++ b/surf-cli/src/suggest.rs @@ -0,0 +1,184 @@ +//! `surf suggest ` — propose anchors for public functions no hub covers yet (§8, #18). +//! Scans the given source files, lists each top-level public function that isn't already +//! anchored, and prints a copy-pasteable starter hub. Suggestions only: it never writes a file +//! and never stamps a hash — the author edits the claims and runs `surf verify`. + +use crate::format::Format; +use crate::workspace::Workspace; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::collections::HashSet; +use std::process::ExitCode; +use surf_core::{parse_anchor, parse_hub, public_fns, Lang}; + +#[derive(Debug, Clone, Serialize)] +struct Suggestion { + file: String, + symbol: String, + at: String, +} + +pub fn run(ws: &Workspace, globs: &[String], format: Format) -> Result { + let covered = covered_symbols(ws)?; + let suggestions = scan(ws, globs, &covered)?; + + match format { + Format::Json => println!("{}", serde_json::to_string_pretty(&suggestions)?), + Format::Human => print_human(&suggestions), + } + Ok(ExitCode::SUCCESS) +} + +/// `(file, first-segment)` pairs already anchored by some hub — the same coverage notion lint's +/// under-coverage check uses. A public fn matching one of these is already documented. +fn covered_symbols(ws: &Workspace) -> Result> { + let mut covered = HashSet::new(); + for hub_path in ws.hub_paths()? { + let content = std::fs::read_to_string(&hub_path) + .with_context(|| format!("reading {}", hub_path.display()))?; + let Ok(hub) = parse_hub(&content) else { + continue; + }; + for claim in &hub.frontmatter.anchors { + for site in claim.at.sites() { + if let Ok(anchor) = parse_anchor(site) { + if let Some(seg) = anchor.segments.first() { + covered.insert((anchor.file, seg.name.clone())); + } + } + } + } + } + Ok(covered) +} + +fn scan( + ws: &Workspace, + globs: &[String], + covered: &HashSet<(String, String)>, +) -> Result> { + let mut out = Vec::new(); + for pattern in globs { + let joined = ws.root.join(pattern); + let pattern = joined + .to_str() + .with_context(|| format!("glob is not valid UTF-8: {}", joined.display()))?; + for entry in glob::glob(pattern).context("invalid glob pattern")? { + let path = entry?; + if !path.is_file() { + continue; + } + let rel = path + .strip_prefix(&ws.root) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + let Some(lang) = Lang::from_path(&rel) else { + continue; + }; + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + for symbol in public_fns(&source, lang) { + if covered.contains(&(rel.clone(), symbol.clone())) { + continue; + } + let at = format!("{rel} > {symbol}"); + out.push(Suggestion { + file: rel.clone(), + symbol, + at, + }); + } + } + } + out.sort_by(|a, b| (&a.file, &a.symbol).cmp(&(&b.file, &b.symbol))); + out.dedup_by(|a, b| a.at == b.at); + Ok(out) +} + +fn print_human(suggestions: &[Suggestion]) { + if suggestions.is_empty() { + println!("surf suggest: no unanchored public functions found."); + return; + } + println!( + "# {} unanchored public function(s). Paste into a hub (or `surf new `), write the", + suggestions.len() + ); + println!( + "# claims, then `surf verify`. These are suggestions — nothing was written or stamped." + ); + println!("---"); + println!("summary: TODO one-line summary of this domain."); + println!("anchors:"); + for s in suggestions { + println!( + " - claim: TODO the invariant {} guarantees, in prose", + s.symbol + ); + println!(" at: {}", s.at); + } + println!("refs: []"); + println!("---"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn ws_with(files: &[(&str, &str)]) -> (tempfile::TempDir, Workspace) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join("surf.toml"), "").unwrap(); + fs::create_dir_all(root.join("hubs")).unwrap(); + for (rel, content) in files { + let p = root.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, content).unwrap(); + } + let ws = Workspace::discover(root).unwrap(); + (tmp, ws) + } + + #[test] + fn suggests_only_unanchored_public_fns() { + let (_t, ws) = ws_with(&[ + ("src/m.rs", "pub fn a() {}\npub fn b() {}\nfn c() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\nanchors:\n - claim: a does\n at: src/m.rs > a\n---\n", + ), + ]); + let covered = covered_symbols(&ws).unwrap(); + let s = scan(&ws, &["src/*.rs".to_string()], &covered).unwrap(); + // `a` is anchored, `c` is private — only `b` remains. + assert_eq!(s.len(), 1); + assert_eq!(s[0].at, "src/m.rs > b"); + assert_eq!(s[0].symbol, "b"); + } + + #[test] + fn unsupported_files_are_skipped() { + let (_t, ws) = ws_with(&[("notes.txt", "pub fn a() {}\n")]); + let covered = covered_symbols(&ws).unwrap(); + let s = scan(&ws, &["*.txt".to_string()], &covered).unwrap(); + assert!(s.is_empty()); + } + + #[test] + fn json_shape_has_no_hash() { + let (_t, ws) = ws_with(&[("src/m.rs", "pub fn solo() {}\n")]); + let covered = covered_symbols(&ws).unwrap(); + let s = scan(&ws, &["src/*.rs".to_string()], &covered).unwrap(); + let json = serde_json::to_value(&s).unwrap(); + let obj = json[0].as_object().unwrap(); + for key in ["file", "symbol", "at"] { + assert!(obj.contains_key(key), "missing `{key}` in {obj:?}"); + } + assert!(!obj.contains_key("hash")); + } +}