From 859537aa2c9e565a8b26f10105d8bf91018eccb6 Mon Sep 17 00:00:00 2001 From: Vojin Jovanovic Date: Tue, 6 Oct 2026 03:45:42 +0200 Subject: [PATCH 1/5] Specify the Python API and pin complete binding parity --- docs/architecture/AR-bindings.md | 36 ++- docs/architecture/AR-goal-measurement.md | 6 + docs/functional-spec/FS-distribution.md | 188 ++++++++++- grund.toml | 2 +- tests/bindings/README.md | 38 +++ tests/bindings/corpus.py | 85 +++++ tests/bindings/run.py | 13 + tests/bindings/support.py | 159 +++++++++ tests/bindings/test_api.py | 303 ++++++++++++++++++ tests/bindings/test_boundary.py | 145 +++++++++ tests/bindings/test_build.py | 110 +++++++ tests/bindings/test_isolation.py | 43 +++ tests/bindings/test_parity.py | 127 ++++++++ tests/integration/functional_spec_coverage.rs | 19 +- .../functional_spec_coverage_policy.rs | 1 - 15 files changed, 1267 insertions(+), 8 deletions(-) create mode 100644 tests/bindings/README.md create mode 100644 tests/bindings/corpus.py create mode 100644 tests/bindings/run.py create mode 100644 tests/bindings/support.py create mode 100644 tests/bindings/test_api.py create mode 100644 tests/bindings/test_boundary.py create mode 100644 tests/bindings/test_build.py create mode 100644 tests/bindings/test_isolation.py create mode 100644 tests/bindings/test_parity.py diff --git a/docs/architecture/AR-bindings.md b/docs/architecture/AR-bindings.md index 7ba3117b2..dace39b3a 100644 --- a/docs/architecture/AR-bindings.md +++ b/docs/architecture/AR-bindings.md @@ -14,6 +14,11 @@ api ──┼─► [ grund-lsp ] ─► LSP over stdio The frontends' side of [§AR-system.3](README.md#3-frontends) and the api's contract of [§AR-system.2.9](README.md#29-api). Every frontend takes the data the api returns and gives back a rendering or a transport of it; none holds a regex, a walk or a rule, and none depends on another. The engine's side of the contract is section 2; each frontend has a section of its own below. +The approved Python addition is an independent frontend over this boundary +([§FS-distribution.3.3](../functional-spec/FS-distribution.md#33-python-grund-pypi-package)); its acceptance tests initially fail until the local extension +exists. Node coverage and registry distribution remain pending. No Python operation +delegates to a CLI process or imports another frontend. + ## terms: Terms Leans on [§FS-terms.terms.1](../functional-spec/FS-terms.md#terms1-declarations-and-coordinates) (section), [§FS-terms.terms.4](../functional-spec/FS-terms.md#terms4-scanning-and-project-structure) (scan), and @@ -39,10 +44,22 @@ grund/ All four frontend crates take their engine logic from `grund-core` alone and depend on none of each other. `tests/integration/test_frontend_isolation.py` holds this on the resolved dependency graph `cargo metadata` reports rather than on the manifests' intent: the CLI's tree carries no LSP transport, the server's no CLI, and the engine's no frontend. That is what lets [§DA-lsp-optional](../decisions/architectural/DA-lsp-optional.md#da-lsp-optional-lsp-server-ships-as-a-separate-optional-binary) hold: no JSON-RPC machinery or LSP type reaches `grund-core`, so none is in `grund-cli`'s tree. +Python joins the resolved graph proof in `tests/bindings/test_isolation.py`: +grund-py depends directly on core and transitively on no frontend. The ordinary +Cargo CLI graph excludes PyO3/Python build dependencies ([§FS-distribution.3.3.7](../functional-spec/FS-distribution.md#337-local-source-and-typing-handoff)). +This extends, rather than replaces, the existing CLI/LSP isolation proof. + ## 2. grund-core: the only place logic lives Every check, every show, every regex, every walker invocation lives in `grund-core`. The crate exposes: +Bindings reuse current warning-preserving scoped APIs, selection and batch queries, +size/coverage/config/completion APIs, snapshot materialization and managed writers. +Additive failure carriers classify errors at source without parsing Display text; +core owns non-Unicode workspace preflight and integration-install orchestration. +Existing Rust entry points and CLI rendering/defaults stay compatible +([§FS-distribution.3.1](../functional-spec/FS-distribution.md#31-rust-grund-core-crate)). Historical signatures below are not a frozen Python schema. + - `grund_core::scan(root: &Path) -> Result` - `grund_core::check(root: &Path) -> Result` - `grund_core::check_with_opts(opts: CheckOpts) -> Result` @@ -78,7 +95,24 @@ Prebuilt platform binaries are uploaded as separate npm packages (`@grund-cli/li ## 6. grund-py: the PyO3 binding -Same operations, exposed as Python functions. Built and packaged via `maturin`. Wheels are produced by `cibuildwheel` in CI for each release. Source distributions are also uploaded so unsupported platforms can build from source. +The planned grund-py crate owns only PyO3 conversion and exception policy over supported core +data APIs ([§FS-distribution.3.3](../functional-spec/FS-distribution.md#33-python-grund-pypi-package)). Frozen dataclasses, tuples and read-only config +mappings in `python/grund` expose the public schema; private `grund._native` holds +the abi3-py310 extension. Root `pyproject.toml` selects maturin, with source inclusion +for independently installing an unpacked sdist and accurate types/py.typed. + +Rust work runs with the GIL released; calls check Python interrupts on return and +carry per-call scope rather than changing cwd ([§FS-distribution.3.3.4](../functional-spec/FS-distribution.md#334-silent-synchronous-calls)). +Core supplies warning/failure/path/install adapters; Python does not walk, resolve, +check, parse or reproduce managed-write behavior. No dependency on CLI/LSP/Node is +allowed. `tests/bindings/` compares complete data and canonical bytes, separately +from the frozen CLI wire projection ([§FS-distribution.3.0.3](../functional-spec/FS-distribution.md#303-complete-data-and-canonical-parity)). + +Local build readiness does not claim published wheels. #471 owns cibuildwheel +release assembly and CLI payload placement; the binding adds no console entrypoint. +Carry “Adapt Python marshalling to #466/#453/#454” without changing the approved +Python schema. #469 supplies the later Node adapter; neither coordination is a +prerequisite for this local frontend. ## 7. Why this shape diff --git a/docs/architecture/AR-goal-measurement.md b/docs/architecture/AR-goal-measurement.md index 70705d0d6..85173cc3c 100644 --- a/docs/architecture/AR-goal-measurement.md +++ b/docs/architecture/AR-goal-measurement.md @@ -46,6 +46,12 @@ entry instead of allowing the baseline to conceal it. ## 2. Goal meters +`tests/bindings/` is the executable Rust/Python complete-data and canonical-byte +meter required by [§FS-distribution.3.0.3](../functional-spec/FS-distribution.md#303-complete-data-and-canonical-parity). Until the missing frontend and Rust oracle +adapter are supplied, its entry failure is evidence of absence, not measured parity. +After it passes it measures Rust/Python only; Node remains pending until #469 adds +its adapter. CLI goldens remain a separate authoritative projection. + | Goal | Meter | |---|---| | [§GOAL-agent-grounding](../goals.md#goal-agent-grounding-agents-stay-cited-as-they-work) | Agent entrypoint fixtures ([§FS-init.2.3](../functional-spec/FS-init.md#23-generated-agent-entrypoints)), grounding checks ([§FS-check.3.6](../functional-spec/FS-check.md#36-ungrounded-unit-opt-in)), coverage index ([§FS-cover](../functional-spec/FS-cover.md#fs-cover-grund-groups-citations-by-scanned-file)), and the co-change recipe ([§RM-cochange-gate](../roadmap.md#rm-cochange-gate-a-pre-commit--ci-recipe--no-impl-change-without-spec-and-test)). | diff --git a/docs/functional-spec/FS-distribution.md b/docs/functional-spec/FS-distribution.md index 19f268138..a55b421ff 100644 --- a/docs/functional-spec/FS-distribution.md +++ b/docs/functional-spec/FS-distribution.md @@ -14,6 +14,11 @@ alias), and [§FS-terms.terms.5](FS-terms.md#terms5-findings) (finding, severity ## 1. Targets +Local Python API support is required by [§FS-distribution.3.3](FS-distribution.md#33-python-grund-pypi-package) independently of registry +availability. The initial binding supplies a locally installable extension and source +distribution; the PyPI release matrix, CLI payload and publication remain separate work. +An approved contract or a failing acceptance test does not establish implemented support. + | Registry | Package name | Status | Contents | |----------|---------------------|-------------|---------------------------------------------------------------------------| | cargo | `grund-core` | implemented | Shared engine library used by the CLI, LSP, and future bindings. | @@ -56,12 +61,28 @@ CLI reports use logical paths, relative to the base `relative_paths` selects ([ Each binding exposes the same conceptual operations as the CLI subcommands, plus a programmatic check-and-iterate path so the engine can be embedded inside test runners and editor servers. +The initial Python inventory is normative in [§FS-distribution.3.3.5](FS-distribution.md#335-complete-initial-inventory). Process +transport and editor-only exclusions must be explicit; an absent operation cannot be +advertised as supported. Node is a later adapter to the same parity contract. + ### 3.0 Language-neutral data shapes -Every binding returns the same data, only spelled idiomatically: a report and its findings ([§FS-distribution.3.0.1](FS-distribution.md#301-report-and-finding)), and the options a `show` takes ([§FS-distribution.3.0.2](FS-distribution.md#302-showopts)). These fields are normative. The byte-for-byte JSON form the CLI emits under `--format=json`, and IDE/agent integrations consume, follows the same shape and is the cross-binding equivalence test for [§GOAL-multi-language](../goals.md#goal-multi-language-same-engine-three-platforms), tracked in [AR-goal-measurement.2](../architecture/AR-goal-measurement.md#2-goal-meters). +Every binding returns the same data, only spelled idiomatically: a report and its +findings ([§FS-distribution.3.0.1](FS-distribution.md#301-report-and-finding)), and show +options ([§FS-distribution.3.0.2](FS-distribution.md#302-showopts)). These fields are +normative. Complete host data and the frozen CLI JSON projection are compared +separately under [§FS-distribution.3.0.3](FS-distribution.md#303-complete-data-and-canonical-parity), serving +[§GOAL-multi-language](../goals.md#goal-multi-language-same-engine-three-platforms) +and tracked in [AR-goal-measurement.2](../architecture/AR-goal-measurement.md#2-goal-meters). #### 3.0.1 Report and Finding +Host results retain all engine data, including nullable columns, multi-site locations, +rule authority, scan-error status, output-format metadata and run cautions. Run cautions +remain distinct from report warnings, including when setup fails. The CLI projection +below stays frozen: it omits columns and projects empty sites/authority to null; it is +not the complete host schema. [§FS-distribution.3.0.3](FS-distribution.md#303-complete-data-and-canonical-parity) compares both forms separately. + ``` Report { errors: [Finding] @@ -94,6 +115,12 @@ Finding { #### 3.0.2 ShowOpts +Python takes required query operands positionally and section/mode/format/root as +keywords ([§FS-distribution.3.3.5](FS-distribution.md#335-complete-initial-inventory)). Roots and query failures follow +[§FS-distribution.3.3.3](FS-distribution.md#333-per-call-scope-and-path-encoding) and [§FS-distribution.3.3.2](FS-distribution.md#332-operational-failures-and-invalid-arguments). Batch setup failure raises +once; individual failed queries remain ordered outcomes. An empty batch succeeds +without loading configuration. + ``` ShowOpts { section: string? // dotted section path, e.g. "3.1.2" @@ -105,8 +132,47 @@ ShowOpts { } ``` +#### 3.0.3 Complete data and canonical parity + +The shared `tests/bindings/` corpus compares complete Rust and Python result, +failure and run-caution data, preserving every field and engine array order. A future +Node adapter joins this harness; Rust/Python evidence must never claim Node coverage. +Adapters use supported data APIs, not CLI subprocesses or message-only comparisons. + +The canonical UTF-8 JSON envelope has exactly `failure`, `result`, `run_cautions`: +one of failure/result is null, except that an empty successful result may be null. +Objects sort keys recursively by UTF-8 bytes; arrays keep engine order. Nullable +fields are present, empty collections are arrays, separators are compact, and exactly +one LF ends the record. Unicode and `/` remain literal. Quote, backslash, LF, CR and +tab use JSON escapes; every other U+0000–U+001F character uses lowercase `\u00xx` +(including backspace and form feed). No raw Rust serialization substitutes for this +specified projection. Full structured equality and exact canonical bytes are separate +assertions. + +Compare the frozen CLI projection separately to existing stdout/stderr JSON goldens. +It keeps global stable path/line/message ordering (null path first, absent line as +zero, warning/error/suggestion tie order), suggestion channel instead of severity, +null empty sites/authority, authority last, and no column key. Run cautions are +accounted for separately from report warning records and raw stderr cautions. + +Corpus acceptance includes clean/error trees, config failures, caution-only runs and +cautions before failure, suggestions enabled/disabled, selector authority/safety, +missing/ambiguous single and batch queries, workspaces, Unicode/control characters +and logical separators. Every operation has success/refusal coverage. Writers run +on isolated copies and compare preview/no-execution behavior and resulting bytes +with core, including isolated integration user homes. + ### 3.1 Rust (`grund-core` crate) +The Python frontend may require additive warning-preserving, structured-failure, +workspace-path-preflight and managed-integration adapters. They belong in core and +preserve supported Rust entry points and existing CLI defaults, verdicts and bytes. +Classification originates where the failure occurs; frontends do not parse Display +messages for diagnostic fields. The public Python schema does not freeze the internal +Config or Findings layout. Carry “Adapt Python marshalling to #466/#453/#454” when +Python precedes that transition: replace internal adapters and rerun parity while +preserving the approved Python schema. + ```rust let report = grund_core::check(&path)?; let body = grund_core::show("FS-check", ShowOpts::default())?; @@ -134,11 +200,125 @@ The Node binding is built with `napi-rs`. Native binaries are prebuilt for the p ```python from grund import check, show -report = check("./repo") -body = show("FS-check", mode="brief") +repo = "tests/e2e/cases/json-report/repo" +result = check(repo) +for finding in result.report: + print(finding.code, finding.line) # dangling 3 +assert "FS-999-missing" in show("FS-001-alpha", root=repo, mode="brief").body ``` -The Python binding is built with `PyO3` and packaged with `maturin`. Wheels are built for CPython 3.10+ across the platforms covered by `cibuildwheel`. The distribution package and import module are both named `grund` ([§DA-pypi-uses-grund-as-the-package-name](../decisions/architectural/DA-pypi-uses-grund-as-the-package-name.md#da-pypi-uses-grund-as-the-package-name-pypi-uses-grund-as-the-package-name)). +The Python binding requires PyO3 and maturin. Future release wheels use cibuildwheel; +the local source/build handoff is [§FS-distribution.3.3.7](FS-distribution.md#337-local-source-and-typing-handoff). Distribution and import +names are both `grund` ([§DA-pypi-uses-grund-as-the-package-name](../decisions/architectural/DA-pypi-uses-grund-as-the-package-name.md#da-pypi-uses-grund-as-the-package-name-pypi-uses-grund-as-the-package-name)). + +The following is the required local contract; registry availability remains pending. + +#### 3.3.1 Typed immutable results + +Results/options are frozen dataclasses; collections are tuples, with explicit None +for optional fields. Effective config is a read-only schema-keyed mapping, including +nested mappings, rather than an exported internal Rust Config record. CheckResult +carries `report`, `selected_report`, `had_scan_errors`, `output_format` and separate +`run_cautions`. Report has errors/warnings/suggestions tuples; iteration yields those +groups in that order, preserving engine order within each. Findings retain +severity/channel, code, path, line, column, message, sites and authority. Scan returns +a catalog/citations/scan-diagnostic snapshot. Other operation records retain every +supported engine output field and expose run_cautions separately. ShowQuery is a +frozen record with `id` and nullable `section=None`. Batch records have nullable +`result`/`failure` fields. Refs includes file summaries and site/file totals; list +includes summaries. Empty collections never become None. + +#### 3.3.2 Operational failures and invalid arguments + +GrundError has ConfigError, FilesystemError, QueryError and fallback OperationError +subclasses. Each carries typed `.failure`: code, message, nullable path/line/column, +sites, authority, causes, run_cautions, partial_output and structured details (such +as candidates and OS error codes). Locations come from engine data, never text +parsing. Completed checks containing findings return normally. Single unsuccessful +queries raise QueryError; a batch continues with the same failure per record, while +setup failure raises once. Empty batch input succeeds without config loading. +Unknown ID kind in propose_id raises OperationError; rejected queries raise QueryError. +Wrong Python types raise TypeError; invalid option values raise ValueError. +PathEncodingError is a ValueError subclass for rejected path encodings. + +#### 3.3.3 Per-call scope and path encoding + +Paths accept str or os.PathLike[str]. root=None snapshots cwd at entry and means +omitted scope; explicit files/directories keep explicit-path engine semantics. +Reuse config discovery without config injection. Calls against different roots are +independent. Report paths are engine logical strings with `/`, independently of +native filesystem inputs. Reject bytes/byte-returning PathLike with TypeError, +surrogates with PathEncodingError and embedded NUL with ValueError before work. +Core preflight reuses workspace discovery to reject the known unnamed non-Unicode +member alias case with PathEncodingError before unsafe alias derivation. This bounded +protection changes no CLI behavior and promises no arbitrary Rust panic recovery. + +#### 3.3.4 Silent synchronous calls + +Calls read no process argv, print to neither stdout nor stderr, never exit Python +and never change global cwd. Synchronous Rust work releases the GIL. Independent +read calls may overlap; callers serialize writers to the same files. Pending Python +interrupts are checked on return, so Rust work may complete before KeyboardInterrupt. +No async API, mid-call cancellation or additional rollback is promised. + +#### 3.3.5 Complete initial inventory + +Required operands are positional; all other arguments are keywords. Every disk-tree +operation below also accepts root=None; check accepts root positionally. scan requires +its root positionally. init uses target in place of root; integrations and setup +instructions have no root. Defaults below are normative, not merely examples. + +| Operation signature (besides common root) | Result/core meaning | +| --- | --- | +| `check(root=None, *, require_grounding=False, suggestions=False, full=False, rule=None, only=(), ignore=(), only_rule=False)` | Complete and selected report, warning-preserving check | +| `scan(root)` | Raw scanner snapshot | +| `show(id, *, section=None, mode="lead", format="text")` | Scoped show result | +| `show_batch(queries=None, *, mode="lead")` | None means all; ordered strings/ShowQuery records | +| `refs(id, *, section=None, descendants=False)` | Warning-preserving metadata/totals | +| `list_ids(*, kinds=(), projects=(), unused=False, selector=None)` | Entries and summaries | +| `list_sizes(*, kinds=(), projects=(), unused=False, selector=None, units=("lines","words","bytes"), top=None)` | Lead/full sizes | +| `cover(*, text=False)` | Structured or text coverage data | +| `fmt(*, write=False, marker=False, cross_refs=False)` | Format preview or managed writes | +| `propose_id(kind, title, *, width=3)` | Warning-preserving ID proposal | +| `init(target=None, *, name=None, description=None, docs=False, force=False, write=False, check=False, no_vcs=False, agents=None)` | Scaffold output; None agents auto-selects | +| `effective_config()` / `validate_config()` | Config/schema data and cautions | +| `fetch(id, *, write=False)` | Materialized snapshot; explicit write required | +| `integrations(client=None, *, write=False, conversation=None, conversation_target=None, agent=None)` | Detection/artifacts or managed install | +| `complete_ids(prefix="", *, sections=False)` / `reference_style()` | Completion/style data | +| `agent_setup_instructions()` | Canonical setup payload | + +Modes are lead/brief/toc/full; formats text/md/json. Filters/selectors are string +sequences. Units are lines/words/bytes; top is positive, width non-negative. +Selectors filter only selected_report; ignore wins, rule selection requires rule, +and safety io findings remain. Kind overrides, workspace aliases, exclusions and +scope remain engine decisions. Config-enabled cross-reference formatting applies +even when cross_refs=False. No unimplemented inventory entry counts as support. + +Shell scripts, stdin/NDJSON transport, watch/process lifecycle, exit codes and LSP +transport are frontend concerns. Optional overlay/on-type/hover editor utilities +are excluded from the initial disk-backed API; no CLI conceptual operation is dropped. + +#### 3.3.6 Explicit mutation opt-ins + +fmt previews by default. init maps write=False to dry-run; check=True suppresses +writes and reports pending changes. Force never replaces config. fetch refuses +write=False with ValueError before execution; it promises no preview. Integration +reads return artifacts/detection; writes preserve preference validation, agent gates, +managed ownership and manual steps. Reads never execute fetchers. Existing engine +data-preservation contracts and CLI defaults remain unchanged. + +#### 3.3.7 Local source and typing handoff + +Require CPython 3.10+ with GIL and abi3-py310. PyPy and free-threaded Python support +are not promised. Root pyproject.toml selects maturin; python/grund supplies the +public API/types/py.typed and grund._native is private. Clean checkout and independently +unpacked sdist both install importable grund without registry credentials. The sdist +includes necessary workspace manifests, lockfile, Rust sources/assets, Python/types +and licence. Python-only dependencies do not become ordinary Cargo CLI requirements. +Add no competing CLI console entrypoint; release matrix, prebuilt CLI placement and +publication belong to #471. Accurate signatures/type information and runnable +check/iteration/show examples belong in user documentation and example indexes. +Local support is described independently of unpublished PyPI availability. ## 4. Release process diff --git a/grund.toml b/grund.toml index 4026f40cb..c20b18f45 100644 --- a/grund.toml +++ b/grund.toml @@ -168,7 +168,7 @@ title = "Init scaffold templates: what grund init writes, verbatim" # home is walked by construction (FS-config.3.5), so the test homes, `examples`, # `.github/workflows`, and `scripts` are not repeated here; `skills` and # `templates` are homes too, and ones the config lists without walking. -include = ["docs", "src", "crates", "README.md", "AGENTS.md"] +include = ["docs", "src", "crates", "tests/bindings", "README.md", "AGENTS.md"] # `repo` / `expected.repo`: the e2e fixture trees under `tests/e2e/cases/*/` are # test inputs, not this project's code — keep them out of the host scan so strict # mode and `require_grounding` (above) apply to grund's own sources, not the diff --git a/tests/bindings/README.md b/tests/bindings/README.md new file mode 100644 index 000000000..29e44a3bd --- /dev/null +++ b/tests/bindings/README.md @@ -0,0 +1,38 @@ +# Python acceptance and shared binding protocol + +Required by [§FS-distribution.3.0.3](../../docs/functional-spec/FS-distribution.md#303-complete-data-and-canonical-parity) and [§FS-distribution.3.3](../../docs/functional-spec/FS-distribution.md#33-python-grund-pypi-package). Run from the checkout +after locally installing this checkout's distribution: + +```sh +python tests/bindings/run.py +``` + +Before implementation this entrypoint fails with ModuleNotFoundError. No deeper +assertion is claimed to have executed behind that failure. The existing integration +gate stays independent; it proves baseline behavior, not Python availability. + +The implementer supplies a core-only test driver at +`target/debug/grund-binding-oracle` (with `.exe` on Windows). It reads one JSON +request from stdin with `operation`, native `root`, positional `args`, keyword +`options`, and optional isolated `home`. It uses supported core data-returning APIs +and the approved additive adapters. It must not call a CLI or a Python converter. +Build it with an explicit checkout target directory before running acceptance. + +It returns one JSON response with `data` (the complete public envelope) and +`canonical` (base64 of independently encoded canonical UTF-8 bytes). For checks +it also returns `cli_stdout`/`cli_stderr`, the frozen finding projection; tests +compare these to existing authoritative goldens. Raw caution text is kept separate. +For writer requests it executes against only the supplied isolated root/home, +mapping Python write defaults to the corresponding engine preview or opt-in. +Host TypeError/ValueError validation is tested directly, outside engine parity. + +The Python adapter recursively enumerates every dataclass field and tuple; it +does not whitelist report fields. Deep equality and canonical byte equality are +both mandatory. The canonical comparator is a test-only encoder, not a shipped +conversion implementation. The operation/fixture matrix lives in corpus.py. +Node later joins ADAPTERS in support.py with the same request/response contract; +there is no Node coverage claim today. + +Local build tests copy only git-tracked source to scratch under `~/ag/tmp`, install +in fresh environments, build an sdist and install its unpacked sources separately. +No upload, release command, registry credential or CLI console script is required. diff --git a/tests/bindings/corpus.py b/tests/bindings/corpus.py new file mode 100644 index 000000000..a8c4bc667 --- /dev/null +++ b/tests/bindings/corpus.py @@ -0,0 +1,85 @@ +"""Shared operation requests (§FS-distribution.3.3.5, §FS-distribution.3.0.3).""" + +# Each tuple is (fixture, operation, positional operands, keyword options). +# Use existing fixtures as authoritative inputs, without editing their goldens. +READ_CASES = ( + ("json-report", "check", (), {}), + ("clean", "check", (), {}), + ("check-empty-json", "check", (), {}), + ("check-invalid-config-json", "check", (), {}), + ("check-empty-scan-warning", "check", (), {}), + ("workspace-include-root-false-unread-json", "check", (), {}), + ("workspace-nested-include-root-false-list-project", "list_ids", (), {"projects": ("root",)}), + ("check-rules-chapter-absent-suggestion", "check", (), {"suggestions": False}), + ("check-rules-chapter-absent-suggestion", "check", (), {"suggestions": True}), + ("check-only-rule-code-keeps-invalid-rule-json", "check", (), + {"only": ("chapter-cardinality",)}), + ("workspace-check-json-broken", "check", (), {}), + ("json-report", "check", (), {"only": ("dangling",), "ignore": ("dangling",)}), + ("json-report", "check", (), {"require_grounding": True, "full": True}), + ("json-report", "check", (), {"rule": "Each FS must have exactly one Terms chapter.", "only_rule": True}), + ("json-report", "scan", (), {}), + ("check-invalid-config-json", "scan", (), {}), + ("json-report", "show", ("FS-001-alpha",), {}), + ("json-report", "show", ("FS-999-missing",), {}), + ("show-ambiguous-id-json", "show", ("FS-001-login",), {}), + ("show-ambiguous-section-json", "show", ("FS-001-login",), {"section": "1"}), + ("json-report", "show_batch", (("FS-001-alpha", "FS-999-missing"),), {}), + ("check-invalid-config-json", "show_batch", ((),), {}), + ("check-invalid-config-json", "show_batch", (("FS-001-alpha",),), {}), + ("json-report", "show_batch", (), {}), + ("json-report", "refs", ("FS-001-alpha",), {}), + ("json-report", "refs", ("FS-999-missing",), {}), + ("show-ambiguous-id-json", "refs", ("FS-001-login",), {}), + ("refs-ambiguous-shorthand-json", "refs", ("FS-042",), {}), + ("workspace-refs-ambiguous-section-json", "refs", ("api/FS-001-alpha",), + {"section": "1", "descendants": True}), + ("json-report", "list_ids", (), {}), + ("json-report", "list_ids", (), {"kinds": ("FS",), "unused": True}), + ("json-report", "list_ids", (), {"selector": "FS-001-alpha"}), + ("check-invalid-config-json", "list_ids", (), {}), + ("workspace-list-unknown-project", "list_ids", (), {"projects": ("missing",)}), + ("json-report", "list_sizes", (), {}), + ("json-report", "list_sizes", (), {"units": ("words",), "top": 1}), + ("check-invalid-config-json", "list_sizes", (), {}), + ("json-report", "cover", (), {}), + ("json-report", "cover", (), {"text": True}), + ("check-invalid-config-json", "cover", (), {}), + ("json-report", "propose_id", ("FS", "Python façade"), {}), + ("json-report", "propose_id", ("UNKNOWN", "Title"), {}), + ("json-report", "propose_id", ("FS", ""), {}), + ("check-invalid-config-json", "propose_id", ("FS", "Title"), {}), + ("json-report", "effective_config", (), {}), + ("check-invalid-config-json", "effective_config", (), {}), + ("json-report", "validate_config", (), {}), + ("check-invalid-config-json", "validate_config", (), {}), + ("json-report", "complete_ids", ("FS-",), {"sections": True}), + ("workspace-complete-ids", "complete_ids", (), {}), + ("check-invalid-config-json", "complete_ids", (), {}), + ("json-report", "reference_style", (), {}), + ("check-invalid-config-json", "reference_style", (), {}), + ("json-report", "integrations", (), {}), + ("json-report", "agent_setup_instructions", (), {}), +) + +MUTATIONS = ( + ("fmt-shorthand-write", "fmt", (), {"write": False}), + ("fmt-shorthand-write", "fmt", (), {"write": True}), + ("fmt-shorthand-write", "fmt", (), {"write": True, "marker": True}), + ("fmt-cross-refs-idempotent", "fmt", (), {"write": True, "cross_refs": False}), + ("fmt-cross-refs-config-disabled", "fmt", (), {"write": True, "cross_refs": True}), + ("fmt-embedded-value-refusal-stable", "fmt", (), {"write": True}), + ("init-dry-run-default", "init", (), {"write": False, "no_vcs": True}), + ("init-dry-run-default", "init", (), {"write": True, "no_vcs": True}), + ("init-dry-run-default", "init", (), {"write": True, "check": True, "no_vcs": True}), + ("init-invalid-config-is-an-error", "init", (), {"write": True}), + ("init-force-overwrites", "init", (), {"write": True, "force": True}), + ("json-report", "init", (), {"write": True, "force": True}), + ("fetch-workspace-folder", "fetch", ("alpha/TICKET-1234",), {"write": True}), + ("fetch-folder-refusal", "fetch", ("TICKET-1234",), {"write": True}), + ("json-report", "integrations", ("kitty",), {"write": False}), + ("json-report", "integrations", ("kitty",), {"write": True}), + ("json-report", "integrations", ("iterm2",), {"write": True}), + ("json-report", "integrations", (), + {"write": True, "conversation": "link", "conversation_target": "vscode", "agent": "codex"}), +) diff --git a/tests/bindings/run.py b/tests/bindings/run.py new file mode 100644 index 000000000..fc53e04fd --- /dev/null +++ b/tests/bindings/run.py @@ -0,0 +1,13 @@ +"""Full Python acceptance entrypoint (§FS-distribution.3.3.7).""" + +import sys +import unittest +from pathlib import Path +from support import binding + +# Fail before collection if the capability is missing. No unconditional skips, +# installed-package fallback, or setup failure masquerading as a test failure. +binding() +suite = unittest.defaultTestLoader.discover(str(Path(__file__).parent), "test_*.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(not result.wasSuccessful()) diff --git a/tests/bindings/support.py b/tests/bindings/support.py new file mode 100644 index 000000000..ae0729b92 --- /dev/null +++ b/tests/bindings/support.py @@ -0,0 +1,159 @@ +"""Acceptance helpers and future Node seam (§FS-distribution.3.0.3). + +The oracle is a test driver over core, supplied by the implementer, never a CLI +adapter. It reads one JSON request and returns data plus independently encoded +canonical bytes. Tests intentionally do not implement the missing core adapters. +""" + +import base64 +import dataclasses +import importlib +import importlib.metadata +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Mapping + +REPO = Path(__file__).resolve().parents[2] +SCRATCH = Path.home() / "ag/tmp" +SCRATCH.mkdir(parents=True, exist_ok=True) +ADAPTERS = ("rust", "python") # Node adds an adapter here when #469 lands. + + +def binding(): + module = importlib.import_module("grund") + location = Path(module.__file__).resolve() + if location.name != "__init__.py" or location.parent.name != "grund": + raise AssertionError(f"not the approved mixed Python package: {location}") + native = importlib.import_module("grund._native") + if not Path(native.__file__).resolve().is_relative_to(location.parent): + raise AssertionError("native extension and Python package have different origins") + direct = importlib.metadata.distribution("grund").read_text("direct_url.json") + from urllib.parse import urlparse + from urllib.request import url2pathname + if not direct: + raise AssertionError("acceptance requires a local-source install, not a registry package") + source = Path(url2pathname(urlparse(json.loads(direct)["url"]).path)).resolve() + expected = Path(os.environ.get("GRUND_ACCEPTANCE_SOURCE", REPO)).resolve() + if source != expected: + raise AssertionError(f"unrelated installed grund: {source}; expected {expected}") + return module + + +def temporary(): + return tempfile.TemporaryDirectory(prefix="grund-python-contract-", dir=SCRATCH) + + +def fixture(parent, case="json-report"): + root = Path(parent) / "repo" + source = "json-report" if case == "clean" else case + shutil.copytree(REPO / "tests/e2e/cases" / source / "repo", root) + if case == "clean": + declaration = root / "docs/functional-spec/FS-001-alpha.md" + declaration.write_text(declaration.read_text().replace("FS-999-missing", "FS-002-beta")) + return root + + +def tree_bytes(root): + return {str(p.relative_to(root)): p.read_bytes() + for p in sorted(Path(root).rglob("*")) if p.is_file()} + + +def plain(value): + """Lossless host records: no selected-field or message-only projection.""" + if dataclasses.is_dataclass(value): + return {f.name: plain(getattr(value, f.name)) for f in dataclasses.fields(value)} + if isinstance(value, Mapping): + return {k: plain(v) for k, v in value.items()} + if isinstance(value, tuple): + return [plain(v) for v in value] + if value is None or type(value) in (str, bool, int, float): + return value + raise AssertionError(f"untyped/non-contract result: {type(value)!r}") + + +def canonical(value): + def encode(item): + if isinstance(item, str): + escapes = {'"': '\\"', '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t'} + return '"' + ''.join(escapes.get(c, f'\\u{ord(c):04x}' if ord(c) < 32 else c) + for c in item) + '"' + if isinstance(item, dict): + return '{' + ','.join(encode(k) + ':' + encode(item[k]) + for k in sorted(item, key=lambda k: k.encode())) + '}' + if isinstance(item, list): + return '[' + ','.join(encode(v) for v in item) + ']' + return json.dumps(item, allow_nan=False, separators=(',', ':')) + return (encode(value) + "\n").encode() + + +def python_call(module, operation, root, args=(), options=None): + # Capture native file descriptors as well as Python writes on every corpus + # operation. This helper is used serially; concurrency tests call the API directly. + with temporary() as temp, open(Path(temp) / "streams", "w+b") as streams: + sys.stdout.flush() + sys.stderr.flush() + saved = [os.dup(fd) for fd in (1, 2)] + cwd = Path.cwd() + try: + for fd in (1, 2): + os.dup2(streams.fileno(), fd) + result = _python_call(module, operation, root, args, options) + sys.stdout.flush() + sys.stderr.flush() + finally: + for fd, original in zip((1, 2), saved): + os.dup2(original, fd) + os.close(original) + streams.seek(0) + output = streams.read() + if output: + raise AssertionError(f"{operation} wrote process streams: {output!r}") + if Path.cwd() != cwd: + raise AssertionError(f"{operation} changed global cwd") + return result + + +def _python_call(module, operation, root, args=(), options=None): + keywords = dict(options or {}) + if operation not in ("integrations", "agent_setup_instructions"): + if operation == "scan": + args = (root,) + elif operation == "init": + args = (root,) + else: + keywords["root"] = root + try: + result = getattr(module, operation)(*args, **keywords) + except module.GrundError as error: + failure = plain(error.failure) + return {"failure": failure, "result": None, + "run_cautions": failure["run_cautions"]} + data = plain(result) + return {"failure": None, "result": data, + "run_cautions": data.get("run_cautions", [])} + + +def rust_call(operation, root, args=(), options=None, home=None): + oracle = REPO / "target/debug/grund-binding-oracle" + if os.name == "nt": + oracle = oracle.with_suffix(".exe") + if not oracle.is_file(): + raise AssertionError("missing core-only test driver target/debug/grund-binding-oracle; " + "implement the tests/bindings/README.md protocol") + request = {"operation": operation, "root": str(root), "args": list(args), + "options": options or {}, "home": str(home) if home else None} + run = subprocess.run([str(oracle)], input=json.dumps(request), text=True, + capture_output=True, check=True, cwd=REPO) + if run.stderr: + raise AssertionError(f"oracle wrote stderr: {run.stderr}") + response = json.loads(run.stdout) + data = response["data"] + wire = base64.b64decode(response["canonical"], validate=True) + if wire != canonical(data): + raise AssertionError("Rust canonical bytes violate the specified encoding") + return data, wire, response diff --git a/tests/bindings/test_api.py b/tests/bindings/test_api.py new file mode 100644 index 000000000..95bfcca6c --- /dev/null +++ b/tests/bindings/test_api.py @@ -0,0 +1,303 @@ +"""Public Python signatures/data/errors (§FS-distribution.3.3.1, §FS-distribution.3.3.2, +§FS-distribution.3.3.3, §FS-distribution.3.3.5, §FS-distribution.3.3.6).""" + +import dataclasses +import inspect +from pathlib import Path +import unittest +import typing + +from support import binding, fixture, plain, temporary, tree_bytes + +# (positional defaults: Ellipsis means required, keyword defaults). root is added +# to tree operations. This is the approved inventory, not native signature guesses. +SIGNATURES = { + "check": ({"root": None}, dict(require_grounding=False, suggestions=False, full=False, + rule=None, only=(), ignore=(), only_rule=False)), + "scan": ({"root": ...}, {}), + "show": ({"id": ...}, dict(section=None, mode="lead", format="text")), + "show_batch": ({"queries": None}, dict(mode="lead")), + "refs": ({"id": ...}, dict(section=None, descendants=False)), + "list_ids": ({}, dict(kinds=(), projects=(), unused=False, selector=None)), + "list_sizes": ({}, dict(kinds=(), projects=(), unused=False, selector=None, + units=("lines", "words", "bytes"), top=None)), + "cover": ({}, dict(text=False)), + "fmt": ({}, dict(write=False, marker=False, cross_refs=False)), + "propose_id": ({"kind": ..., "title": ...}, dict(width=3)), + "init": ({"target": None}, dict(name=None, description=None, docs=False, force=False, + write=False, check=False, no_vcs=False, agents=None)), + "effective_config": ({}, {}), "validate_config": ({}, {}), + "fetch": ({"id": ...}, dict(write=False)), + "integrations": ({"client": None}, dict(write=False, conversation=None, + conversation_target=None, agent=None)), + "complete_ids": ({"prefix": ""}, dict(sections=False)), + "reference_style": ({}, {}), "agent_setup_instructions": ({}, {}), +} + + +class ApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.g = binding() + + def test_exact_callable_defaults_keyword_policy_and_types(self): + for name, (positional, keywords) in SIGNATURES.items(): + with self.subTest(operation=name): + function = getattr(self.g, name) + self.assertFalse(inspect.iscoroutinefunction(function)) + sig = inspect.signature(function) + expected = dict(positional, **keywords) + if name not in ("check", "scan", "init", "integrations", "agent_setup_instructions"): + expected["root"] = None + self.assertEqual(set(expected), set(sig.parameters)) + for key, value in expected.items(): + parameter = sig.parameters[key] + self.assertEqual(inspect.Parameter.empty if value is ... else value, + parameter.default) + self.assertEqual(inspect.Parameter.POSITIONAL_OR_KEYWORD if key in positional + else inspect.Parameter.KEYWORD_ONLY, parameter.kind) + hints = typing.get_type_hints(function) + self.assertTrue(set(sig.parameters) <= set(hints), "all operands have type hints") + self.assertIn("return", hints) + + def test_check_is_immutable_complete_and_iterable(self): + with temporary() as temp: + root = fixture(temp) + result = self.g.check(root) + self.assertTrue(dataclasses.is_dataclass(result)) + self.assertEqual("CheckResult", type(result).__name__) + for name in ("report", "selected_report", "had_scan_errors", "output_format", "run_cautions"): + self.assertTrue(hasattr(result, name), name) + with self.assertRaises(dataclasses.FrozenInstanceError): + result.had_scan_errors = True + report = result.report + self.assertEqual(tuple(report), report.errors + report.warnings + report.suggestions) + self.assertEqual(("dangling", 3), (report.errors[0].code, report.errors[0].line)) + for group in (report.errors, report.warnings, report.suggestions, result.run_cautions): + self.assertIsInstance(group, tuple) + finding = report.errors[0] + for field in ("path", "line", "column", "sites", "authority"): + self.assertIn(field, plain(finding)) + self.assertIsInstance(finding.sites, tuple) + self.assertIsInstance(finding.authority, tuple) + self.assertEqual((), finding.authority) + with self.assertRaises(dataclasses.FrozenInstanceError): + finding.code = "hidden" + with temporary() as temp: + clean = self.g.check(fixture(temp, "clean")) + self.assertEqual((), tuple(clean.report)) + self.assertEqual((), clean.run_cautions) + + def test_effective_config_mapping_is_read_only(self): + from collections.abc import Mapping + with temporary() as temp: + result = self.g.effective_config(root=fixture(temp)) + values = [getattr(result, f.name) for f in dataclasses.fields(result)] + mappings = [v for v in values if isinstance(v, Mapping)] + self.assertTrue(mappings, "effective config has a schema mapping") + def immutable(mapping): + with self.assertRaises(TypeError): + mapping["new"] = True + for value in mapping.values(): + if isinstance(value, Mapping): + immutable(value) + for mapping in mappings: + immutable(mapping) + + def assert_failure(self, error): + failure = error.exception.failure + self.assertTrue(dataclasses.is_dataclass(failure)) + for name in ("code", "message", "path", "line", "column", "sites", "authority", + "causes", "run_cautions", "partial_output", "details"): + self.assertTrue(hasattr(failure, name), name) + self.assertIsInstance(failure.sites, tuple) + self.assertIsInstance(failure.causes, tuple) + self.assertIsInstance(failure.run_cautions, tuple) + return failure + + def test_structured_query_errors_and_batch_outcomes(self): + with temporary() as temp: + root = fixture(temp) + with self.assertRaises(self.g.QueryError) as error: + self.g.show("FS-999-missing", root=root) + failure = self.assert_failure(error) + self.assertEqual("not-found", failure.code) + self.assertIsNone(failure.path) + self.assertIsNone(failure.line) + self.assertIsNone(failure.column) + self.assertEqual((), failure.authority) + batch = self.g.show_batch((self.g.ShowQuery("FS-001-alpha"), "FS-999-missing"), root=root) + values = [getattr(batch, f.name) for f in dataclasses.fields(batch)] + records = [v for v in values if isinstance(v, tuple) + and v and hasattr(v[0], "failure")] + self.assertEqual(1, len(records)) + self.assertIsNone(records[0][0].failure) + self.assertIsNotNone(records[0][0].result) + self.assertIsNone(records[0][1].result) + self.assertEqual(plain(failure), plain(records[0][1].failure)) + with temporary() as temp: + root = fixture(temp, "show-ambiguous-id-json") + with self.assertRaises(self.g.QueryError) as error: + self.g.show("FS-001-login", root=root) + self.assertGreater(len(self.assert_failure(error).sites), 1) + with temporary() as temp: + root = fixture(temp, "refs-ambiguous-shorthand-json") + with self.assertRaises(self.g.QueryError) as error: + self.g.refs("FS-042", root=root) + failure = self.assert_failure(error) + self.assertEqual((), failure.sites) + self.assertTrue(failure.details, "number-only ambiguity retains structured candidates") + + def test_config_filesystem_and_operation_failures_have_payloads(self): + with temporary() as temp: + root = fixture(temp, "check-invalid-config-json") + with self.assertRaises(self.g.ConfigError) as error: + self.g.check(root) + self.assert_failure(error) + self.g.show_batch((), root=root) # Must not load invalid configuration. + with self.assertRaises(self.g.ConfigError): + self.g.show_batch(("FS-001-alpha",), root=root) + with self.assertRaises(self.g.FilesystemError) as error: + self.g.scan(Path(temp) / "does-not-exist") + self.assert_failure(error) + with temporary() as temp: + with self.assertRaises(self.g.OperationError) as error: + root = fixture(temp) + self.g.propose_id("UNKNOWN", "Title", root=root) + self.assert_failure(error) + with self.assertRaises(self.g.QueryError) as error: + self.g.propose_id("FS", "", root=root) + self.assert_failure(error) + for name in ("ConfigError", "FilesystemError", "QueryError", "OperationError"): + self.assertTrue(issubclass(getattr(self.g, name), self.g.GrundError)) + + def test_invalid_python_arguments_and_fetch_opt_in(self): + with temporary() as temp: + root = fixture(temp) + before = tree_bytes(root) + for function, args, keywords, exception in ( + ("check", (b"bytes",), {}, TypeError), + ("check", (42,), {}, TypeError), + ("check", ("bad\0path",), {}, ValueError), + ("check", ("bad\udcffpath",), {}, self.g.PathEncodingError), + ("show", ("FS-001-alpha",), {"mode": "unknown"}, ValueError), + ("show", ("FS-001-alpha",), {"format": "unknown"}, ValueError), + ("show", (42,), {}, TypeError), + ("show_batch", (42,), {}, TypeError), + ("list_ids", (), {"kinds": 42}, TypeError), + ("list_sizes", (), {"units": ("pixels",)}, ValueError), + ("list_sizes", (), {"top": 0}, ValueError), + ("propose_id", ("FS", "Title"), {"width": -1}, ValueError), + ("check", (), {"only_rule": True}, ValueError), + ("fetch", ("FS-001-alpha",), {}, ValueError), + ): + with self.subTest(operation=function, args=ascii(args), options=keywords): + if function != "integrations" and (function != "check" or not args): + keywords = dict(keywords, root=root) + with self.assertRaises(exception): + getattr(self.g, function)(*args, **keywords) + class BytesPath: + def __fspath__(self): + return b"bytes" + with self.assertRaises(TypeError): + self.g.check(BytesPath()) + self.assertEqual(before, tree_bytes(root)) + + def test_integration_invalid_preferences_refuse_without_writes(self): + import os + from unittest.mock import patch + with temporary() as temp: + home = Path(temp) / "home" + home.mkdir() + environment = {"HOME": str(home), "USERPROFILE": str(home), + "XDG_CONFIG_HOME": str(home / ".config")} + with patch.dict(os.environ, environment): + for args, keywords in ( + (("unknown-client",), {}), + (("kitty",), {"conversation": "plain"}), + (("kitty",), {"write": True, "conversation": "invalid-preference"}), + (("kitty",), {"write": True, "conversation_target": "unknown"}), + (("kitty",), {"write": True, "agent": "unknown"}), + ((), {"write": True}), + ): + with self.subTest(args=args, options=keywords), self.assertRaises(ValueError): + self.g.integrations(*args, **keywords) + self.assertEqual({}, tree_bytes(home)) + + def test_fetch_opt_in_precedes_config_loading_and_fetcher_execution(self): + with temporary() as temp: + root = fixture(temp, "check-invalid-config-json") + with self.assertRaises(ValueError): + self.g.fetch("TICKET-1234", root=root) + with temporary() as temp: + import shlex + root = fixture(temp, "fetch-workspace-folder") + script = root / "packages/alpha/scripts/fetch-ticket" + sentinel = Path(temp) / "fetcher-executed" + script.write_text(script.read_text().replace("set -eu", "set -eu\nprintf executed > " + + shlex.quote(str(sentinel)))) + before = tree_bytes(root) + with self.assertRaises(ValueError): + self.g.fetch("alpha/TICKET-1234", root=root, write=False) + self.g.check(root) + self.g.scan(root) + self.g.list_ids(root=root) + with self.assertRaises(self.g.QueryError): + self.g.show("alpha/TICKET-1234", root=root) + self.assertFalse(sentinel.exists()) + self.assertEqual(before, tree_bytes(root)) + + def test_selection_retains_complete_report_and_ignore_wins(self): + with temporary() as temp: + root = fixture(temp) + complete = self.g.check(root) + selected = self.g.check(root, only=("dangling",), ignore=("dangling",)) + self.assertEqual(plain(complete.report), plain(selected.report)) + self.assertEqual((), selected.selected_report.errors) + + def test_rule_authority_is_retained_in_the_selected_view(self): + with temporary() as temp: + root = fixture(temp) + result = self.g.check(root, rule="Each FS must have exactly one Terms chapter.", + only_rule=True) + authored = [f for f in result.selected_report if "--rule" in f.authority] + self.assertTrue(authored) + self.assertTrue(all(f in tuple(result.report) for f in authored)) + self.assertTrue(all(tuple(sorted(f.authority)) == f.authority for f in authored)) + + def test_safety_io_findings_survive_selectors(self): + import os + if os.name != "posix": + self.skipTest("broken symlink safety fixture requires POSIX symlinks") + with temporary() as temp: + root = fixture(temp) + (root / "docs/functional-spec/FS-003-broken.md").symlink_to("missing.md") + result = self.g.check(root, only=("dangling",), ignore=("io",)) + self.assertTrue(result.had_scan_errors) + self.assertTrue(any(f.code == "io" for f in result.report)) + self.assertTrue(any(f.code == "io" for f in result.selected_report)) + + def test_show_ladder_formats_and_typed_queries(self): + with temporary() as temp: + root = fixture(temp) + for mode in ("lead", "brief", "toc", "full"): + for format_name in ("text", "md", "json"): + with self.subTest(mode=mode, format=format_name): + self.assertTrue(dataclasses.is_dataclass(self.g.show( + "FS-001-alpha", root=root, mode=mode, format=format_name))) + query = self.g.ShowQuery("FS-001-alpha") + self.assertIsNone(query.section) + with self.assertRaises(dataclasses.FrozenInstanceError): + query.id = "changed" + + def test_cautions_survive_success_and_failure(self): + with temporary() as temp: + result = self.g.check(fixture(temp, "workspace-include-root-false-unread-json")) + self.assertTrue(result.run_cautions) + self.assertEqual((), result.report.errors) + self.assertEqual((), result.report.warnings) + with temporary() as temp: + root = fixture(temp, "workspace-nested-include-root-false-list-project") + with self.assertRaises(self.g.GrundError) as error: + self.g.list_ids(root=root, projects=("root",)) + self.assertTrue(self.assert_failure(error).run_cautions) diff --git a/tests/bindings/test_boundary.py b/tests/bindings/test_boundary.py new file mode 100644 index 000000000..da479e864 --- /dev/null +++ b/tests/bindings/test_boundary.py @@ -0,0 +1,145 @@ +"""Host isolation and concurrency (§FS-distribution.3.3.3, §FS-distribution.3.3.4).""" + +from concurrent.futures import ThreadPoolExecutor +import contextlib +import io +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +import unittest +from unittest.mock import patch + +from support import REPO, binding, fixture, plain, temporary, tree_bytes + + +def workload(parent): + root = fixture(parent) + # Long comment input, few result objects: exercise scanning rather than Python + # conversion, with enough work to distinguish progress during Rust from entry. + folder = root / "src" + folder.mkdir(exist_ok=True) + block = '# Ordinary non-citation text for scan work.\n' * 10000 + for number in range(400): + (folder / f"file_{number}.py").write_text(block) + return root + + +class BoundaryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.g = binding() + + def test_silent_no_argv_no_exit_no_cwd_and_independent_roots(self): + with temporary() as first, temporary() as second: + root = fixture(first) + clean = fixture(second, "clean") + cwd, argv = Path.cwd(), sys.argv[:] + out, err = io.StringIO(), io.StringIO() + with patch.object(sys, "argv", ["unrelated", "--garbage"]), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err), \ + patch.object(sys, "exit", side_effect=AssertionError("API called sys.exit")), \ + patch.object(os, "chdir", side_effect=AssertionError("API called chdir")): + for _ in range(3): + self.assertTrue(self.g.check(root).report.errors) + self.assertFalse(self.g.check(clean).report.errors) + with self.assertRaises(self.g.QueryError): + self.g.show("FS-999-missing", root=root) + self.assertEqual("", out.getvalue()) + self.assertEqual("", err.getvalue()) + self.assertEqual(cwd, Path.cwd()) + self.assertEqual(argv, sys.argv) + # Capture OS file descriptors too; Python redirects alone miss native prints. + code = 'import grund; grund.check(__import__("pathlib").Path(__import__("sys").argv[1])); print("alive")' + run = subprocess.run([sys.executable, "-c", code, str(root)], capture_output=True, + text=True, cwd=REPO, check=True) + self.assertEqual("alive\n", run.stdout) + self.assertEqual("", run.stderr) + + def test_omitted_root_snapshots_cwd_and_explicit_file_scope(self): + with temporary() as temp: + root = fixture(temp) + code = ('import grund, os, pathlib; before=os.getcwd(); ' + 'assert grund.check().report.errors; ' + 'assert grund.show("FS-001-alpha").body; ' + 'assert os.getcwd()==before') + subprocess.run([sys.executable, "-c", code], cwd=root, check=True, + capture_output=True, text=True) + source = root / "docs/functional-spec/FS-001-alpha.md" + result = self.g.check(source) + self.assertTrue(result.report.errors) + self.assertTrue(all(f.path.endswith("FS-001-alpha.md") for f in result.report + if f.path)) + + def test_concurrent_reads_keep_per_call_scope(self): + with temporary() as first, temporary() as second: + roots = [fixture(first), fixture(second, "clean")] + expected = [plain(self.g.check(root)) for root in roots] + inputs = roots * 20 + with ThreadPoolExecutor(max_workers=4) as pool: + actual = list(pool.map(lambda root: plain(self.g.check(root)), inputs)) + self.assertEqual(expected * 20, actual) + + def test_gil_is_released_during_long_rust_work(self): + with temporary() as temp: + root = workload(temp) + stop = threading.Event() + beats = [] + def heartbeat(): + while not stop.wait(0.002): + beats.append(time.monotonic()) + worker = threading.Thread(target=heartbeat) + worker.start() + started = time.monotonic() + try: + self.g.check(root) + finally: + ended = time.monotonic() + stop.set() + worker.join() + self.assertGreater(ended - started, 0.1, "fixture too short to establish GIL evidence") + self.assertGreater(len([t for t in beats if started + .025 < t < ended - .025]), 2, + "Python thread made no progress during Rust work") + + @unittest.skipUnless(os.name == "posix", "POSIX signal delivery test") + def test_pending_interrupt_is_delivered_when_operation_returns(self): + with temporary() as temp: + root = workload(temp) + # Sender is a separate process, so GIL behavior cannot prevent delivery. + code = '''import grund, os, signal, subprocess, sys, time +sender = subprocess.Popen([sys.executable, '-c', + 'import os,signal,time; time.sleep(.1); os.kill(int(__import__("sys").argv[1]), signal.SIGINT)', str(os.getpid())]) +started = time.monotonic() +try: + grund.check(sys.argv[1]) +except KeyboardInterrupt: + assert time.monotonic() - started >= .1 + assert grund.check(sys.argv[2]).report is not None +else: + raise AssertionError('pending interrupt was swallowed') +finally: + sender.wait() +''' + with temporary() as other: + small = fixture(other) + result = subprocess.run([sys.executable, "-c", code, str(root), str(small)], + capture_output=True, text=True, timeout=120) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stdout) + self.assertEqual("", result.stderr) + + @unittest.skipUnless(os.name == "posix", "non-Unicode filesystem names require POSIX bytes paths") + def test_unsafe_unnamed_non_unicode_workspace_alias_is_rejected(self): + with temporary() as temp: + root = Path(temp) / "workspace" + root.mkdir() + (root / "grund.toml").write_text('grund_config_version = 1\n[workspace]\nmembers = ["packages/*"]\n') + (root / "packages").mkdir() + member = os.fsencode(root / "packages") + b"/bad-\xff" + os.mkdir(member) + with open(member + b"/grund.toml", "wb") as config: + config.write(b"grund_config_version = 1\n") + with self.assertRaises(self.g.PathEncodingError): + self.g.check(root) diff --git a/tests/bindings/test_build.py b/tests/bindings/test_build.py new file mode 100644 index 000000000..fadb1336d --- /dev/null +++ b/tests/bindings/test_build.py @@ -0,0 +1,110 @@ +"""Clean source/sdist/type/docs handoff (§FS-distribution.3.3.7).""" + +import os +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import unittest +import venv + +from support import REPO, temporary + + +def run(arguments, cwd, env=None): + return subprocess.run([str(a) for a in arguments], cwd=cwd, env=env, + capture_output=True, text=True, check=True) + + +def environment(parent): + directory = Path(parent) / "venv" + venv.EnvBuilder(with_pip=True).create(directory) + python = directory / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + return python + + +class BuildTests(unittest.TestCase): + def test_clean_checkout_and_unpacked_sdist_install_independently(self): + with temporary() as temp: + source = Path(temp) / "source" + source.mkdir() + files = run(["git", "ls-files", "-z"], REPO).stdout.split("\0") + for name in filter(None, files): + original = REPO / name + if not original.exists() and not original.is_symlink(): + continue + destination = source / name + destination.parent.mkdir(parents=True, exist_ok=True) + if original.is_symlink(): + destination.symlink_to(os.readlink(original)) + else: + shutil.copy2(original, destination) + python = environment(temp) + env = dict(os.environ, CARGO_TARGET_DIR=str(Path(temp) / "cargo-target"), + GRUND_ACCEPTANCE_SOURCE=str(source)) + run([python, "-m", "pip", "install", str(source)], source, env) + smoke = ('import grund; r=grund.check("tests/e2e/cases/json-report/repo"); ' + 'assert [(f.code,f.line) for f in r.report]==[("dangling",3)]; ' + 'assert "FS-999-missing" in grund.show("FS-001-alpha", ' + 'root="tests/e2e/cases/json-report/repo", mode="brief").body; ' + 'import grund._native; from importlib.metadata import distribution; ' + 'assert not [e for e in distribution("grund").entry_points ' + 'if e.group=="console_scripts"]') + run([python, "-c", smoke], source, env) + run([python, "-m", "pip", "install", "build"], source, env) + run([python, "-m", "build", "--sdist", "--outdir", Path(temp) / "dist"], source, env) + archive, = (Path(temp) / "dist").glob("*.tar.gz") + unpacked = Path(temp) / "unpacked" + unpacked.mkdir() + with tarfile.open(archive) as package: + # This locally built artifact is still constrained to its destination. + for member in package.getmembers(): + self.assertTrue((unpacked / member.name).resolve().is_relative_to(unpacked)) + self.assertFalse(member.issym() or member.islnk()) + package.extractall(unpacked) + sdist, = unpacked.iterdir() + for name in ("Cargo.toml", "Cargo.lock", "pyproject.toml", "crates/grund-core/src/lib.rs", + "crates/grund-py/Cargo.toml", "python/grund/py.typed", "LICENSE"): + self.assertTrue((sdist / name).is_file(), f"sdist omitted {name}") + separate = Path(temp) / "independent" + separate.mkdir() + other = environment(separate) + # Install while clean checkout is absent: no accidental workspace reach-through. + shutil.rmtree(source) + other_env = dict(env, CARGO_TARGET_DIR=str(separate / "cargo-target"), + GRUND_ACCEPTANCE_SOURCE=str(sdist)) + run([other, "-m", "pip", "install", str(sdist)], separate, other_env) + run([other, "-c", 'import grund, grund._native; assert grund.agent_setup_instructions()'], + separate, other_env) + + def test_native_abi_source_metadata_and_type_marker(self): + self.assertTrue((REPO / "python/grund/py.typed").is_file()) + metadata = json.loads(run(["cargo", "metadata", "--locked", "--format-version", "1"], REPO).stdout) + identities = {p["id"] for p in metadata["packages"] if p["name"] == "pyo3"} + features = {f for n in metadata["resolve"]["nodes"] if n["id"] in identities + for f in n["features"]} + self.assertIn("abi3-py310", features, "resolved PyO3 feature policy") + project = (REPO / "pyproject.toml").read_text() + self.assertIn("maturin", project) + self.assertIn("grund._native", project) + self.assertRegex(project, r'requires-python\s*=\s*["\']>=3\.10') + self.assertTrue((REPO / "crates/grund-py/README.md").is_file()) + + def test_user_documentation_and_examples_execute(self): + document = REPO / "docs/user-facing/python-api.md" + self.assertTrue(document.is_file()) + content = document.read_text() + examples = re.findall(r"```python\n(.*?)```", content, re.S) + runnable = [code for code in examples if "check(" in code and "show(" in code] + self.assertTrue(runnable, "documentation needs a runnable check/iteration/show block") + for code in runnable: + run([sys.executable, "-c", code], REPO) + example_root = REPO / "examples/python-api" + scripts = list(example_root.glob("*.py")) + self.assertTrue(scripts, "publish the tested example workflow") + for script in scripts: + run([sys.executable, script], REPO) + self.assertIn("python-api", (REPO / "README.md").read_text()) diff --git a/tests/bindings/test_isolation.py b/tests/bindings/test_isolation.py new file mode 100644 index 000000000..3bcb557b3 --- /dev/null +++ b/tests/bindings/test_isolation.py @@ -0,0 +1,43 @@ +"""Resolved Python/frontend isolation (§AR-bindings.1, §FS-distribution.3.3.7).""" + +import json +import subprocess +import unittest +from support import REPO + + +class IsolationTests(unittest.TestCase): + def test_python_is_an_independent_core_frontend(self): + metadata = json.loads(subprocess.run( + ["cargo", "metadata", "--locked", "--format-version", "1"], + cwd=REPO, capture_output=True, text=True, check=True).stdout) + names = {p["id"]: p["name"] for p in metadata["packages"]} + ids = {names[i]: i for i in metadata["workspace_members"]} + self.assertIn("grund-py", ids, "the missing Python frontend must join the workspace") + nodes = {n["id"]: n for n in metadata["resolve"]["nodes"]} + def deps(identity): + return {d["pkg"] for d in nodes[identity]["deps"] + if any(k["kind"] is None for k in d["dep_kinds"])} + def closure(name): + seen, pending = set(), [ids[name]] + while pending: + new = deps(pending.pop()) - seen + seen |= new + pending.extend(new) + return {names[i] for i in seen} + frontends = {"grund", "grund-lsp", "grund-py", "grund-node"} & set(ids) + self.assertIn("grund-core", {names[i] for i in deps(ids["grund-py"])}) + for frontend in frontends: + self.assertFalse(closure(frontend) & (frontends - {frontend})) + self.assertFalse(closure("grund-core") & frontends) + for name in ("grund", "grund-core", "grund-lsp"): + self.assertFalse(any(p.startswith(("pyo3", "python", "maturin")) for p in closure(name))) + # Default Cargo CLI selection must still exclude Python entirely. + self.assertNotIn(ids["grund-py"], metadata["workspace_default_members"]) + + def test_default_cli_build_does_not_need_python(self): + import os + environment = dict(os.environ, PYO3_PYTHON="/no/python/available", + PYTHON_SYS_EXECUTABLE="/no/python/available") + subprocess.run(["cargo", "check", "--locked", "-p", "grund"], cwd=REPO, + capture_output=True, text=True, check=True, env=environment) diff --git a/tests/bindings/test_parity.py b/tests/bindings/test_parity.py new file mode 100644 index 000000000..76d5292ed --- /dev/null +++ b/tests/bindings/test_parity.py @@ -0,0 +1,127 @@ +"""Complete data/byte parity (§FS-distribution.3.0.3, §FS-distribution.3.3.6).""" + +import os +import json +from pathlib import Path +import shutil +import unittest +from unittest.mock import patch + +from corpus import READ_CASES, MUTATIONS +from support import REPO, binding, canonical, fixture, plain, python_call, rust_call, temporary, tree_bytes + + +class ParityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = binding() + + def compare(self, operation, root, args, options, home=None): + expected, wire, response = rust_call(operation, root, args, options, home) + actual = python_call(self.module, operation, root, args, options) + self.assertEqual(expected, actual, "complete field/order/null parity") + self.assertEqual(wire, canonical(actual), "independently encoded canonical bytes") + return actual, response + + def test_shared_read_corpus_complete_data_and_bytes(self): + for case, operation, args, options in READ_CASES: + with self.subTest(case=case, operation=operation, options=options), temporary() as temp: + root = fixture(temp, case) + before = tree_bytes(root) + actual, response = self.compare(operation, root, args, options) + if operation == "check" and actual["result"] is not None: + self.assert_frozen_cli_projection(actual["result"]["report"], response) + self.assertEqual(before, tree_bytes(root), "read operation wrote files") + + def assert_frozen_cli_projection(self, report, response): + # Construct expected records from every host field the frozen wire uses, + # not from the Rust driver's projection. Stable sort preserves channel ties. + rows = [] + for group in ("warnings", "errors", "suggestions"): + for finding in report[group]: + row = {"channel": "suggestion"} if group == "suggestions" else { + "severity": finding["severity"]} + row.update(path=finding["path"], line=finding["line"], code=finding["code"], + message=finding["message"], sites=finding["sites"] or None, + authority=finding["authority"] or None) + rows.append(row) + rows.sort(key=lambda row: (row["path"] is not None, row["path"] or "", + row["line"] or 0, row["message"])) + actual = [json.loads(line) for line in response["cli_stdout"].splitlines()] + self.assertEqual(rows, actual) + for row in actual: + self.assertEqual([next(iter(row)), "path", "line", "code", "message", "sites", "authority"], + list(row)) + self.assertNotIn("column", row) + + def test_cli_json_goldens_remain_authoritative(self): + for case in ("json-report", "check-invalid-config-json", + "workspace-check-json-broken"): + with self.subTest(case=case), temporary() as temp: + root = fixture(temp, case) + _, response = self.compare("check", root, (), {}) + source = REPO / "tests/e2e/cases" / case + self.assertEqual((source / "expected.stdout").read_text(), response["cli_stdout"]) + self.assertEqual((source / "expected.stderr").read_text(), response["cli_stderr"]) + + def test_mutation_preview_write_and_refusal_bytes_match_core(self): + for case, operation, args, options in MUTATIONS: + with self.subTest(case=case, operation=operation, options=options), temporary() as temp: + root = fixture(temp, case) + backup = Path(temp) / "backup" + shutil.copytree(root, backup) + home = Path(temp) / "home" + home.mkdir() + # Preserve manual user content; give the agent gate a real in-use + # marker so writes exercise both accepted and downgraded targets. + (home / ".codex").mkdir() + (home / ".codex/AGENTS.md").write_text("Personal instruction to preserve.\n") + (home / ".config/kitty").mkdir(parents=True) + (home / ".config/kitty/kitty.conf").write_text("font_size 13\n") + home_backup = Path(temp) / "home-backup" + shutil.copytree(home, home_backup) + before_home = tree_bytes(home) + before = tree_bytes(root) + environment = {"HOME": str(home), "USERPROFILE": str(home), + "XDG_CONFIG_HOME": str(home / ".config")} + with patch.dict(os.environ, environment): + expected, wire, _ = rust_call(operation, root, args, options, home) + expected_files, expected_home = tree_bytes(root), tree_bytes(home) + shutil.rmtree(root) + shutil.copytree(backup, root) + shutil.rmtree(home) + shutil.copytree(home_backup, home) + actual = python_call(self.module, operation, root, args, options) + self.assertEqual(expected, actual) + self.assertEqual(wire, canonical(actual)) + self.assertEqual(expected_files, tree_bytes(root)) + self.assertEqual(expected_home, tree_bytes(home)) + if not options.get("write") or options.get("check"): + self.assertEqual(before, tree_bytes(root)) + self.assertEqual(before_home, tree_bytes(home)) + if operation == "init" and (backup / "grund.toml").exists(): + self.assertEqual((backup / "grund.toml").read_bytes(), + (root / "grund.toml").read_bytes(), "force replaced config") + + def test_unicode_control_characters_and_logical_paths(self): + with temporary() as temp: + root = fixture(temp) + source = root / "docs/functional-spec/FS-001-alpha.md" + target = source.with_name("FS-001-façade-東京.md") + source.rename(target) + target.write_text('# FS-001-alpha: façade 東京 "\\\t\n\n' + + chr(167) + 'FS-999-missing\n\nBody \b\f\x01\r\n') + for operation, args, options in (("check", (), {}), + ("show", ("FS-001-alpha",), {"mode": "full"}), + ("scan", (), {})): + actual, _ = self.compare(operation, root, args, options) + if operation == "check": + findings = actual["result"]["report"]["errors"] + self.assertTrue(findings) + self.assertTrue(all("\\" not in f["path"] for f in findings if f["path"])) + + def test_canonical_test_encoder_has_exact_escaping(self): + # Baseline guard: it passes before native support, and proves only scaffolding. + value = {"z": "é/東京", "a": [None, "\b\f\x01\n\r\t\\b\""]} + self.assertEqual(b'{"a":[null,"\\u0008\\u000c\\u0001\\n\\r\\t\\\\b\\\""],' + b'"z":"\xc3\xa9/\xe6\x9d\xb1\xe4\xba\xac"}\n', canonical(value)) diff --git a/tests/integration/functional_spec_coverage.rs b/tests/integration/functional_spec_coverage.rs index 72bdd07d5..589f68d1c 100644 --- a/tests/integration/functional_spec_coverage.rs +++ b/tests/integration/functional_spec_coverage.rs @@ -35,6 +35,14 @@ fn is_live_test_source(root: &Path, file: &Path) -> bool { if path.starts_with("tests/integration/") { return true; } + // §FS-distribution.3.0.3: binding acceptance is live test source too, + // even while its recorded entry failure still demonstrates the missing API. + if path.starts_with("tests/bindings/") && path.ends_with(".py") { + return relative + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("test_")); + } if !path.starts_with("crates/") || !path.ends_with(".rs") { return false; } @@ -164,7 +172,7 @@ folder = "docs" title = "Behavior" [scan] -extensions = ["md", "rs"] +extensions = ["md", "rs", "py"] "#, )?; fs::write( @@ -334,6 +342,14 @@ fn repository_evidence_counts_sources_and_excludes_its_own_synthetic_proofs() { "tests/integration/behavior.rs", "// \u{a7}FS-proof.integration\n", ), + ( + "tests/bindings/test_behavior.py", + "\"\"\"\u{a7}FS-proof.binding\"\"\"\n", + ), + ( + "tests/bindings/support.py", + "\"\"\"\u{a7}FS-proof.helper\"\"\"\n", + ), ( "tests/integration/functional_spec_coverage.rs", "// \u{a7}FS-proof.gate\nconst INVENTORY: &str = \"FS-proof.inventory\";\n", @@ -378,6 +394,7 @@ fn repository_evidence_counts_sources_and_excludes_its_own_synthetic_proofs() { "FS-proof.unit", "FS-proof.crate", "FS-proof.integration", + "FS-proof.binding", "FS-proof.manifest", ] .map(str::to_string) diff --git a/tests/integration/functional_spec_coverage_policy.rs b/tests/integration/functional_spec_coverage_policy.rs index fb906317d..d84d6869c 100644 --- a/tests/integration/functional_spec_coverage_policy.rs +++ b/tests/integration/functional_spec_coverage_policy.rs @@ -187,7 +187,6 @@ pub(super) const PERMANENT_EXCEPTIONS: &[Exception<'static>] = &[ Exception { id: "FS-workspace.8.4.5", reason: "permitted interim fallback, obliging the tool to nothing" }, Exception { id: "FS-distribution.2", reason: "distribution target description" }, Exception { id: "FS-distribution.3.2", reason: "packaging target" }, - Exception { id: "FS-distribution.3.3", reason: "packaging target" }, Exception { id: "FS-distribution.4.11", reason: "planned full-ecosystem release" }, Exception { id: "FS-distribution.5", reason: "distribution target description" }, Exception { id: "FS-init.2.3.4.1", reason: "covered by the byte-exact generated init block" }, From fa5a72e82e55e60f38bc1d8dc035e8a38241dd0e Mon Sep 17 00:00:00 2001 From: Vojin Jovanovic Date: Tue, 6 Oct 2026 04:22:43 +0200 Subject: [PATCH 2/5] Add the Python API over grund-core and the shared parity oracle --- Cargo.lock | 132 ++++++ Cargo.toml | 2 +- README.md | 2 +- crates/grund-core/Cargo.toml | 7 +- .../examples/binding_oracle/canonical.rs | 82 ++++ .../grund-core/examples/binding_oracle/cli.rs | 114 +++++ .../examples/grund-binding-oracle.rs | 48 ++ crates/grund-core/src/api/cover.rs | 24 + crates/grund-core/src/api/embedding.rs | 136 ++++++ crates/grund-core/src/api/embedding_config.rs | 89 ++++ crates/grund-core/src/api/embedding_data.rs | 104 +++++ .../grund-core/src/api/embedding_failure.rs | 62 +++ .../grund-core/src/api/embedding_queries.rs | 254 +++++++++++ .../grund-core/src/api/embedding_writers.rs | 85 ++++ crates/grund-core/src/api/fmt.rs | 12 + crates/grund-core/src/api/mod.rs | 13 +- crates/grund-core/src/api/refs_query.rs | 34 +- crates/grund-core/src/api/show.rs | 43 +- crates/grund-core/src/config/call_scope.rs | 34 ++ crates/grund-core/src/config/discovery.rs | 27 +- crates/grund-core/src/config/mod.rs | 4 + crates/grund-core/src/config/parse.rs | 20 +- crates/grund-core/src/grammar/shorthand.rs | 18 + crates/grund-core/src/lib.rs | 22 +- crates/grund-core/src/model/failure.rs | 92 ++++ crates/grund-core/src/model/mod.rs | 3 + crates/grund-core/src/queries/batch.rs | 104 ++++- crates/grund-core/src/queries/mod.rs | 9 +- crates/grund-core/src/queries/show.rs | 97 ++-- crates/grund-core/src/queries/size_output.rs | 88 ++++ crates/grund-core/src/queries/sizes.rs | 100 +---- .../grund-core/src/resolver/id_candidates.rs | 13 +- crates/grund-core/src/scanner/legacy.rs | 19 +- crates/grund-core/src/workspace/id_arg.rs | 7 +- crates/grund-core/src/workspace/mod.rs | 2 + crates/grund-core/src/workspace/preflight.rs | 59 +++ crates/grund-core/src/workspace/scope.rs | 12 +- crates/grund-core/src/writers/fetch.rs | 37 +- crates/grund-core/src/writers/init.rs | 247 +++++------ crates/grund-core/src/writers/init_output.rs | 131 ++++++ .../src/writers/integrations_api.rs | 208 +++++++++ .../src/writers/integrations_guidance.rs | 156 +++++++ crates/grund-core/src/writers/mod.rs | 12 + crates/grund-py/Cargo.toml | 23 + crates/grund-py/README.md | 59 +++ crates/grund-py/build.rs | 16 + crates/grund-py/src/lib.rs | 53 +++ docs/file-size-agent-exceptions.toml | 4 +- docs/user-facing/README.md | 1 + docs/user-facing/python-api.md | 127 ++++++ examples/README.md | 4 + examples/python-api/README.md | 18 + examples/python-api/read_fixture.py | 11 + pyproject.toml | 27 ++ python/grund/__init__.py | 9 + python/grund/_api.py | 264 +++++++++++ python/grund/_convert.py | 45 ++ python/grund/errors.py | 31 ++ python/grund/py.typed | 0 python/grund/types.py | 415 ++++++++++++++++++ 60 files changed, 3532 insertions(+), 339 deletions(-) create mode 100644 crates/grund-core/examples/binding_oracle/canonical.rs create mode 100644 crates/grund-core/examples/binding_oracle/cli.rs create mode 100644 crates/grund-core/examples/grund-binding-oracle.rs create mode 100644 crates/grund-core/src/api/embedding.rs create mode 100644 crates/grund-core/src/api/embedding_config.rs create mode 100644 crates/grund-core/src/api/embedding_data.rs create mode 100644 crates/grund-core/src/api/embedding_failure.rs create mode 100644 crates/grund-core/src/api/embedding_queries.rs create mode 100644 crates/grund-core/src/api/embedding_writers.rs create mode 100644 crates/grund-core/src/config/call_scope.rs create mode 100644 crates/grund-core/src/model/failure.rs create mode 100644 crates/grund-core/src/queries/size_output.rs create mode 100644 crates/grund-core/src/workspace/preflight.rs create mode 100644 crates/grund-core/src/writers/init_output.rs create mode 100644 crates/grund-core/src/writers/integrations_api.rs create mode 100644 crates/grund-core/src/writers/integrations_guidance.rs create mode 100644 crates/grund-py/Cargo.toml create mode 100644 crates/grund-py/README.md create mode 100644 crates/grund-py/build.rs create mode 100644 crates/grund-py/src/lib.rs create mode 100644 docs/user-facing/python-api.md create mode 100644 examples/python-api/README.md create mode 100644 examples/python-api/read_fixture.py create mode 100644 pyproject.toml create mode 100644 python/grund/__init__.py create mode 100644 python/grund/_api.py create mode 100644 python/grund/_convert.py create mode 100644 python/grund/errors.py create mode 100644 python/grund/py.typed create mode 100644 python/grund/types.py diff --git a/Cargo.lock b/Cargo.lock index c30a41c5a..1f0a02357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bincode" version = "1.3.3" @@ -156,6 +162,7 @@ dependencies = [ "rayon", "regex", "regex-syntax", + "serde_json", "unicode-normalization", ] @@ -181,6 +188,22 @@ dependencies = [ "url", ] +[[package]] +name = "grund-py" +version = "0.16.2-dev" +dependencies = [ + "grund-core", + "pyo3", + "pyo3-build-config", + "serde_json", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "iai-callgrind" version = "0.16.1" @@ -336,12 +359,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + [[package]] name = "litemap" version = "0.8.2" @@ -386,6 +424,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -398,6 +445,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -438,6 +491,67 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -505,6 +619,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "same-file" version = "1.0.6" @@ -608,6 +728,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tinystr" version = "0.8.3" @@ -648,6 +774,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index ce254acd7..f8c88b725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/grund-core", "crates/grund-cli", "crates/grund-lsp", "tests/integration"] +members = ["crates/grund-core", "crates/grund-cli", "crates/grund-lsp", "crates/grund-py", "tests/integration"] default-members = ["crates/grund-cli"] resolver = "3" diff --git a/README.md b/README.md index 11ca2b4a8..04ef23350 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ See the [review guide](docs/user-facing/reviewing.md) for citation blast radius cargo install grund ``` -That installs the `grund` binary from the [`grund` crate on crates.io](https://crates.io/crates/grund) onto your `PATH`. npm and PyPI bindings are planned — see [`FS-distribution`](docs/functional-spec/FS-distribution.md). +That installs the `grund` binary from the [`grund` crate on crates.io](https://crates.io/crates/grund) onto your `PATH`. The [Python API](docs/user-facing/python-api.md) installs locally with `python -m pip install .`; [its example](examples/python-api/) shows `import grund`. npm support and PyPI publication remain planned ([§FS-distribution.3.3.7](docs/functional-spec/FS-distribution.md#337-local-source-and-typing-handoff)). This README is itself under spec: [§REQ-readme](docs/requirements/REQ-readme.md#req-readme-the-readme-is-the-grounded-shop-window) — every example above is captured from this repository, and the citations here are checked by `grund check` like any other scanned file's. diff --git a/crates/grund-core/Cargo.toml b/crates/grund-core/Cargo.toml index 31a33f05c..6046eb56d 100644 --- a/crates/grund-core/Cargo.toml +++ b/crates/grund-core/Cargo.toml @@ -9,7 +9,7 @@ license.workspace = true repository.workspace = true homepage.workspace = true readme = "README.md" -include = ["src/**", "assets/**", "Cargo.toml", "README.md"] +include = ["src/**", "assets/**", "examples/**", "Cargo.toml", "README.md"] [dependencies] ignore = "0.4" @@ -22,6 +22,11 @@ anyhow = "1" once_cell = "1" rayon = "1" unicode-normalization = "0.1" +serde_json = "1" + +[[example]] +name = "grund-binding-oracle" +path = "examples/grund-binding-oracle.rs" [features] # Black-box CLI tests use this opt-in observer to prove a batch loads one diff --git a/crates/grund-core/examples/binding_oracle/canonical.rs b/crates/grund-core/examples/binding_oracle/canonical.rs new file mode 100644 index 000000000..a7e13c6ee --- /dev/null +++ b/crates/grund-core/examples/binding_oracle/canonical.rs @@ -0,0 +1,82 @@ +//! Independent canonical-byte encoder (§FS-distribution.3.0.3). + +use serde_json::Value; + +pub(super) fn encode(value: &Value) -> Vec { + let mut output = String::new(); + append(value, &mut output); + output.push('\n'); + output.into_bytes() +} + +fn string(value: &str, out: &mut String) { + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < '\u{20}' => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +fn append(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => out.push_str(&n.to_string()), + Value::String(s) => string(s, out), + Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + append(value, out); + } + out.push(']'); + } + Value::Object(values) => { + let mut keys = values.keys().collect::>(); + keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes())); + out.push('{'); + for (index, key) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + string(key, out); + out.push(':'); + append(&values[key], out); + } + out.push('}'); + } + } +} + +pub(super) fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + for chunk in bytes.chunks(3) { + let a = chunk[0] as usize; + let b = chunk.get(1).copied().unwrap_or(0) as usize; + let c = chunk.get(2).copied().unwrap_or(0) as usize; + result.push(ALPHABET[a >> 2] as char); + result.push(ALPHABET[((a & 3) << 4) | (b >> 4)] as char); + result.push(if chunk.len() > 1 { + ALPHABET[((b & 15) << 2) | (c >> 6)] as char + } else { + '=' + }); + result.push(if chunk.len() > 2 { + ALPHABET[c & 63] as char + } else { + '=' + }); + } + result +} diff --git a/crates/grund-core/examples/binding_oracle/cli.rs b/crates/grund-core/examples/binding_oracle/cli.rs new file mode 100644 index 000000000..62b72db1e --- /dev/null +++ b/crates/grund-core/examples/binding_oracle/cli.rs @@ -0,0 +1,114 @@ +//! Independent frozen CLI check projection (§FS-distribution.3.0.3). + +use serde_json::Value; + +fn scalar(value: &Value) -> String { + if let Value::String(s) = value { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out + } else { + value.to_string() + } +} + +/// Preserve CLI channel order, authority-last keys, and stream placement. +pub(super) fn project(data: &Value) -> (String, String) { + let mut stdout = String::new(); + let mut stderr = String::new(); + if !data["failure"].is_null() { + stderr.push_str(&format!( + "error: {}\n", + data["failure"]["message"].as_str().unwrap_or("") + )); + return (stdout, stderr); + } + if let Some(cautions) = data["run_cautions"].as_array() { + for f in cautions { + stderr.push_str(&format!( + "warning: {}\n", + f["message"].as_str().unwrap_or("") + )); + } + } + let report = &data["result"]["report"]; + let mut rows = Vec::new(); + for (channel, group) in [ + ("warning", "warnings"), + ("error", "errors"), + ("suggestion", "suggestions"), + ] { + if let Some(findings) = report[group].as_array() { + rows.extend(findings.iter().map(|f| (channel, f))); + } + } + rows.sort_by(|(_, a), (_, b)| { + a["path"] + .as_str() + .cmp(&b["path"].as_str()) + .then_with(|| { + a["line"] + .as_u64() + .unwrap_or(0) + .cmp(&b["line"].as_u64().unwrap_or(0)) + }) + .then_with(|| a["message"].as_str().cmp(&b["message"].as_str())) + }); + for (channel, f) in rows { + let tag = if channel == "suggestion" { + "channel" + } else { + "severity" + }; + let nullable = |key: &str| { + if f[key].as_array().is_some_and(Vec::is_empty) { + "null".into() + } else { + scalar(&f[key]) + } + }; + let line = format!( + "{{\"{tag}\":\"{channel}\",\"path\":{},\"line\":{},\"code\":{},\"message\":{},\"sites\":{},\"authority\":{}}}\n", + scalar(&f["path"]), + scalar(&f["line"]), + scalar(&f["code"]), + scalar(&f["message"]), + if f["sites"].as_array().is_some_and(Vec::is_empty) { + "null".into() + } else { + format!( + "[{}]", + f["sites"] + .as_array() + .unwrap() + .iter() + .map(|s| format!( + "{{\"path\":{},\"line\":{}}}", + scalar(&s["path"]), + scalar(&s["line"]) + )) + .collect::>() + .join(",") + ) + }, + nullable("authority") + ); + if f["line"].is_null() { + stderr.push_str(&line); + } else { + stdout.push_str(&line); + } + } + (stdout, stderr) +} diff --git a/crates/grund-core/examples/grund-binding-oracle.rs b/crates/grund-core/examples/grund-binding-oracle.rs new file mode 100644 index 000000000..2a0ef52f3 --- /dev/null +++ b/crates/grund-core/examples/grund-binding-oracle.rs @@ -0,0 +1,48 @@ +//! Core-only parity process; no host converter (§FS-distribution.3.0.3). + +#[path = "binding_oracle/canonical.rs"] +mod canonical; +#[path = "binding_oracle/cli.rs"] +mod cli; + +use grund_core::{EmbeddingRequest, embedding_call}; +use serde_json::{Value, json}; +use std::io::{self, Read, Write}; + +fn main() -> Result<(), Box> { + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + let request: Value = serde_json::from_str(&input)?; + if let Some(home) = request["home"].as_str() { + // SAFETY: the oracle is a fresh process and has not started engine threads. + // §FS-distribution.3.0.3: isolate only this writer process's user targets. + unsafe { + std::env::set_var("HOME", home); + std::env::set_var("USERPROFILE", home); + std::env::set_var( + "XDG_CONFIG_HOME", + std::path::Path::new(home).join(".config"), + ); + } + } + let operation = request["operation"] + .as_str() + .ok_or("missing operation")? + .to_owned(); + let data = embedding_call(EmbeddingRequest { + operation: operation.clone(), + root: request["root"].as_str().ok_or("missing root")?.into(), + explicit: true, + args: request["args"].as_array().cloned().unwrap_or_default(), + options: request["options"].clone(), + }); + let canonical = canonical::encode(&data); + let mut response = json!({"data":data,"canonical":canonical::base64(&canonical)}); + if operation == "check" { + let (out, err) = cli::project(&response["data"]); + response["cli_stdout"] = json!(out); + response["cli_stderr"] = json!(err); + } + writeln!(io::stdout().lock(), "{response}")?; + Ok(()) +} diff --git a/crates/grund-core/src/api/cover.rs b/crates/grund-core/src/api/cover.rs index 1cc9ba0c8..ddd23c89e 100644 --- a/crates/grund-core/src/api/cover.rs +++ b/crates/grund-core/src/api/cover.rs @@ -268,7 +268,19 @@ fn cover_scan_errors(context: &WorkspaceContext) -> Vec { /// project the run loaded, without choosing a CLI output format or process exit /// code (§AR-bindings.2, §FS-workspace.8.6). pub fn cover(opts: CoverOpts) -> Result { + cover_with_run_warnings(opts).1 +} + +/// Additive cautions survive a later refusal (§FS-distribution.3.1). +pub fn cover_with_run_warnings(opts: CoverOpts) -> (Vec, Result) { + let mut cautions = Vec::new(); + let result = cover_run(opts, &mut cautions); + (cautions, result) +} + +fn cover_run(opts: CoverOpts, cautions: &mut Vec) -> Result { let context = cover_context(&opts)?; + *cautions = context_run_warnings(&context); let entries = cover_rows(&context) .into_iter() .map(|row| CoverEntry { @@ -310,7 +322,19 @@ pub fn cover(opts: CoverOpts) -> Result { /// the text view carries no alias because the path already renders from the /// workspace root and the token is printed verbatim (§FS-cover.3.1). pub fn cover_text(opts: CoverOpts) -> Result { + cover_text_with_run_warnings(opts).1 +} + +/// Additive cautions survive a later refusal (§FS-distribution.3.1). +pub fn cover_text_with_run_warnings(opts: CoverOpts) -> (Vec, Result) { + let mut cautions = Vec::new(); + let result = cover_text_run(opts, &mut cautions); + (cautions, result) +} + +fn cover_text_run(opts: CoverOpts, cautions: &mut Vec) -> Result { let context = cover_context(&opts)?; + *cautions = context_run_warnings(&context); let entries = cover_rows(&context) .into_iter() .map(|row| CoverTextEntry { diff --git a/crates/grund-core/src/api/embedding.rs b/crates/grund-core/src/api/embedding.rs new file mode 100644 index 000000000..be8b9e982 --- /dev/null +++ b/crates/grund-core/src/api/embedding.rs @@ -0,0 +1,136 @@ +//! Additive language-neutral disk API (§FS-distribution.3.1, §AR-bindings.2). +//! Existing exhaustively constructible options/results and CLI defaults stay intact. + +use super::embedding_data::Data; +use super::embedding_failure::{envelope, error_data, failure}; +use super::{embedding_config, embedding_queries, embedding_writers}; +use crate::*; +use serde_json::{Value, json}; +use std::path::PathBuf; + +/// One per-call request. Host frontends validate their own types; options use +/// schema names, never internal Config layouts (§FS-distribution.3.3.5). +pub struct EmbeddingRequest { + pub operation: String, + pub root: PathBuf, + pub explicit: bool, + pub args: Vec, + pub options: Value, +} + +impl EmbeddingRequest { + pub(super) fn flag(&self, name: &str) -> bool { + self.options[name].as_bool().unwrap_or(false) + } + pub(super) fn string(&self, name: &str) -> Option { + self.options[name].as_str().map(str::to_owned) + } + pub(super) fn strings(&self, name: &str) -> Vec { + self.options[name] + .as_array() + .map(|v| { + v.iter() + .filter_map(|s| s.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + } + pub(super) fn arg(&self, index: usize) -> &str { + self.args + .get(index) + .and_then(Value::as_str) + .unwrap_or_default() + } +} + +/// Complete results or structured failures, never streams/process state +/// (§FS-distribution.3.3.1, §FS-distribution.3.3.2, §FS-distribution.3.3.4). +pub fn embedding_call(request: EmbeddingRequest) -> Value { + crate::config::with_embedding_base(&request.root, || envelope(run(&request))) +} + +fn run(r: &EmbeddingRequest) -> Result { + // §FS-distribution.3.3.5: empty input succeeds without config discovery. + if r.operation == "show_batch" + && r.args + .first() + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + { + return Ok(json!({"records": [], "run_cautions": []})); + } + let tree = !matches!( + r.operation.as_str(), + "integrations" | "agent_setup_instructions" + ); + if tree { + if r.operation != "init" { + std::fs::metadata(&r.root).map_err(|error| { + let mut source = OperationDiagnostic::new("filesystem", "io", error.to_string()); + source.path = Some(crate::model::format_path(&r.root)); + error_data(anyhow::Error::new(error).context(source), &[]) + })?; + } + if r.root.exists() { + crate::workspace::preflight_embedding_paths(&r.root).map_err(|e| error_data(e, &[]))?; + } + } + match r.operation.as_str() { + "check" => check_data(r), + "scan" => embedding_queries::scan_data(r), + "show" | "show_batch" | "refs" | "list_ids" | "list_sizes" | "cover" | "propose_id" + | "complete_ids" => embedding_queries::query(r), + "effective_config" | "validate_config" | "reference_style" => embedding_config::config(r), + "fmt" | "init" | "fetch" | "integrations" => embedding_writers::write(r), + "agent_setup_instructions" => Ok( + json!({"text": canonical_template_text(AGENT_SETUP_INSTRUCTIONS), "run_cautions": []}), + ), + _ => Err(failure( + "operation", + "unsupported-operation", + format!("unknown operation {}", r.operation), + &[], + )), + } +} + +fn check_data(r: &EmbeddingRequest) -> Result { + let mut selection = CheckFindingSelection::default(); + for code in r.strings("only") { + selection.add_only(&code).map_err(|e| error_data(e, &[]))?; + } + for code in r.strings("ignore") { + selection + .add_ignore(&code) + .map_err(|e| error_data(e, &[]))?; + } + if r.flag("only_rule") { + selection.scope_to_trial_rule(); + } + let (cautions, output) = check_with_run_warnings(CheckOpts { + path: r.root.clone(), + path_provided: r.explicit, + require_grounding: r.flag("require_grounding"), + include_suggestions: r.flag("suggestions"), + full: r.flag("full"), + rule: r.string("rule"), + }); + let output = output.map_err(|e| error_data(e, &cautions))?; + let select = |values: &[Finding]| { + values + .iter() + .filter(|f| selection.retains(f.code, &f.authority)) + .cloned() + .collect::>() + }; + let selected = Report { + errors: select(&output.report.errors), + warnings: select(&output.report.warnings), + suggestions: select(&output.report.suggestions), + }; + Ok( + json!({"report": output.report.data(), "selected_report": selected.data(), + "had_scan_errors": output.had_scan_errors, "output_format": output.output_format, + "run_cautions": cautions.data()}), + ) +} diff --git a/crates/grund-core/src/api/embedding_config.rs b/crates/grund-core/src/api/embedding_config.rs new file mode 100644 index 000000000..003b795cc --- /dev/null +++ b/crates/grund-core/src/api/embedding_config.rs @@ -0,0 +1,89 @@ +//! Schema-keyed configuration snapshots (§FS-distribution.3.3.1). + +use super::embedding::EmbeddingRequest; +use super::embedding_data::Data; +use super::embedding_failure::error_data; +use crate::*; +use serde_json::{Value, json}; + +pub(super) fn config(r: &EmbeddingRequest) -> Result { + let c = if r.operation == "validate_config" { + validate_config(&r.root) + } else { + effective_config(&r.root) + } + .map_err(|e| error_data(e, &[]))?; + let cautions = config_run_warnings(&c).data(); + if r.operation == "reference_style" { + let s = reference_style(&r.root).map_err(|e| error_data(e, &[]))?; + return Ok(json!({"marker":s.marker,"trigger":s.trigger,"run_cautions":cautions})); + } + Ok(json!({"config": schema(&c), "warnings": config_warnings(&c), "run_cautions": cautions})) +} + +fn level(v: CitationLevel) -> &'static str { + match v { + CitationLevel::Must => "must", + CitationLevel::Should => "should", + CitationLevel::May => "may", + CitationLevel::ShouldNot => "should-not", + CitationLevel::MustNot => "must-not", + } +} +fn disjunctions(values: &[CitationDisjunction]) -> Vec { + values + .iter() + .map(|v| { + v.targets + .iter() + .map(|t| match &t.namespace { + NamespaceMatch::Local => t.kind.clone(), + NamespaceMatch::Alias(a) => format!("{a}/{}", t.kind), + NamespaceMatch::Any => format!("*/{}", t.kind), + }) + .collect::>() + .join(" | ") + }) + .collect() +} + +/// Every persisted setting, with derived engine caches excluded from the public +/// schema so #466 can change those caches independently (§FS-distribution.3.1). +fn schema(c: &Config) -> Value { + let mut citations = serde_json::Map::new(); + citations.insert( + "default".into(), + json!(c.citations.global_default.map(level)), + ); + for (kind, r) in &c.citations.per_kind { + citations.insert(kind.clone(),json!({ + "default":r.default.map(level), "must":disjunctions(&r.must),"should":disjunctions(&r.should), + "may":disjunctions(&r.may),"should_not":disjunctions(&r.should_not),"must_not":disjunctions(&r.must_not)})); + } + json!({"grund_config_version":1,"project_name":c.project_name,"project_description":c.project_description, + "reference": {"marker":c.marker,"trigger":c.trigger,"strict":c.strict, + "shorthand":c.shorthand.as_str(),"require_grounding":c.require_grounding, + "grounding_level":c.grounding_level,"conversation":c.conversation, + "lead_size_warning":c.lead_size_warning.as_ref().map(|s|json!({"max":s.max,"unit":s.unit.as_str()})), + "inline_style":c.inline_style,"inline_note_suggested_lines":c.inline_note_suggested_lines, + "inline_note_max_lines":c.inline_note_max_lines,"inline_note_max_columns":c.inline_note_max_columns, + "inline_note_layout":c.inline_note_layout,"inline_note_layout_check":c.inline_note_layout_check, + "warn_on_suggested":c.warn_on_suggested}, + "id":{"format":c.id_format,"section_separator":c.section_separator, + "number_pattern":c.number_pattern,"slug_pattern":c.slug_pattern, + "named_sections":c.named_sections,"section_heading_levels":c.section_heading_levels}, + "scan":{"include":c.include,"exclude":c.exclude,"extensions":c.extensions, + "comment_prefixes":c.comment_prefixes,"docstring_python":c.docstring_python, + "respect_gitignore":c.respect_gitignore}, + "output":{"format":c.output_format,"relative_paths":c.relative_paths,"color":"auto"}, + "fmt":{"exclude":c.fmt_exclude,"cross_refs":{"enabled":c.fmt_cross_refs_enabled, + "anchor_format":c.cross_ref_anchor_format}}, + "workspace":{"members":c.workspace_members,"optional_members":c.workspace_optional_members, + "include_root":c.workspace_include_root}, + "kinds":c.kinds.iter().map(|k|json!({"kind":k.kind,"folder":k.folder,"file":k.file, + "title":k.title,"index":match &k.index {KindIndex::Default=>json!("README.md"),KindIndex::Disabled=>json!(false),KindIndex::Named(s)=>json!(s)}, + "citable":k.citable,"scan":k.scan,"require_grounding":k.require_grounding, + "grounding_level":k.grounding_level,"values":k.values,"value_chapter":k.value_chapter, + "rules":k.rules,"format":k.format,"resolve":k.resolve.map(|r|match r{KindResolution::Must=>"must",KindResolution::Should=>"should"}),"fetch":k.fetch})).collect::>(), + "citations":citations}) +} diff --git a/crates/grund-core/src/api/embedding_data.rs b/crates/grund-core/src/api/embedding_data.rs new file mode 100644 index 000000000..9205eb6f4 --- /dev/null +++ b/crates/grund-core/src/api/embedding_data.rs @@ -0,0 +1,104 @@ +//! Complete host records, independent of CLI projection (§FS-distribution.3.0.1). + +use crate::*; +use serde_json::{Value, json}; + +pub(super) trait Data { + fn data(&self) -> Value; +} + +macro_rules! scalar { + ($($ty:ty),*) => { $(impl Data for $ty { + fn data(&self) -> Value { json!(self) } + })* }; +} +scalar!(String, str, bool, usize, u32); +impl Data for &T { + fn data(&self) -> Value { + (*self).data() + } +} +impl Data for Option { + fn data(&self) -> Value { + self.as_ref().map(Data::data).unwrap_or(Value::Null) + } +} +impl Data for Vec { + fn data(&self) -> Value { + Value::Array(self.iter().map(Data::data).collect()) + } +} +impl Data for std::path::PathBuf { + fn data(&self) -> Value { + crate::model::format_path(self).data() + } +} +macro_rules! record { + ($ty:ty; $($field:ident),* $(,)?) => { + impl Data for $ty { + fn data(&self) -> Value { json!({ $(stringify!($field): self.$field.data()),* }) } + } + }; +} +record!(FindingSite; path, line); +record!(Report; errors, warnings, suggestions); +// §FS-distribution.3.3.1: channel is explicit, and suggestion severity is nullable. +impl Data for Finding { + fn data(&self) -> Value { + let suggestion = self.severity == "suggestion"; + json!({"severity": if suggestion { None } else { Some(self.severity) }, + "channel": if suggestion { Some("suggestion") } else { None }, + "code": self.code, "path": self.path, "line": self.line, + "column": self.column, "message": self.message, + "sites": self.sites.data(), "authority": self.authority}) + } +} +record!(ApiScanError; path, message); +record!(ShowSection; path, title, depth); +record!(ShowOutput; body, path, line, json, sections); +record!(RefHit; project, path, line, column, id, section, marker, text, + enclosing_declaration, enclosing_section); +record!(ListEntry; project, id, section, section_separator, kind, path, line, title, + stub, defines, refs, duplicate, value_roots); +record!(ListValueRoot; id, valid); +record!(ListSummary; project, kind, title, home, count); +record!(CoverCitation; project, path, line, column, id, section, marker, text, + enclosing_declaration, enclosing_section); +record!(CoverEntry; project, path, citations); +record!(CoverTextCitation; line, column, text); +record!(CoverTextEntry; path, citations); +record!(FmtChange; path, line, label); +record!(IdProposal; id, kind, number, slug, folder, file, e2e_case_dir, + file_holds_single_declaration); +record!(ListSizeEntry; project, id, section, section_separator, kind, path, line, + stub, defines, duplicate, measurements); +impl Data for ListSizeMeasurement { + fn data(&self) -> Value { + json!({"unit": self.unit.as_str(), + "lead": self.lead, "full": self.full}) + } +} +record!(InitEvent; verb, path); +record!(InitNext; docs, entrypoint, fs_home, scan_reads_file); +impl Data for InitFsHome { + fn data(&self) -> Value { + match self { + Self::File { + path, + heading_name, + heading_marker, + } => json!({ + "kind": "file", "path": path, "heading_name": heading_name, + "heading_marker": heading_marker}), + Self::Folder { path } => json!({"kind": "folder", "path": path, + "heading_name": null, "heading_marker": null}), + } + } +} +impl Data for InitOutput { + fn data(&self) -> Value { + json!({"events": self.events.data(), + "errors": self.errors.data(), "notes": self.notes, "next": self.next.data(), + "run_cautions": self.warnings.data(), "has_pending_changes": self.has_pending_changes()}) + } +} diff --git a/crates/grund-core/src/api/embedding_failure.rs b/crates/grund-core/src/api/embedding_failure.rs new file mode 100644 index 000000000..2037c688e --- /dev/null +++ b/crates/grund-core/src/api/embedding_failure.rs @@ -0,0 +1,62 @@ +//! Lossless failure envelopes for hosts (§FS-distribution.3.3.2). + +use super::embedding_data::Data; +use crate::{Finding, OperationDiagnostic, RefsOutput, ShowQueryError}; +use serde_json::{Value, json}; + +pub(super) fn failure(kind: &str, code: &str, message: String, cautions: &[Finding]) -> Value { + json!({"kind": kind, "code": code, "message": message, + "path": null, "line": null, "column": null, "sites": [], "authority": [], + "causes": [], "run_cautions": cautions.to_vec().data(), + "partial_output": null, "details": {}}) +} + +pub(super) fn error_data(error: anyhow::Error, cautions: &[Finding]) -> Value { + let mut result = failure("operation", "operation", format!("{error:#}"), cautions); + if let Some(source) = error.downcast_ref::() { + result["kind"] = json!(source.class); + result["code"] = json!(source.code); + result["path"] = json!(source.path); + result["line"] = json!(source.line); + result["column"] = json!(source.column); + result["details"] = source.details.clone(); + } + if let Some(query) = error.downcast_ref::() { + result["kind"] = json!("query"); + result["code"] = json!(query.code); + result["sites"] = query.sites.data(); + } + if let Some(io) = error.downcast_ref::() { + if result["kind"] == "operation" { + result["kind"] = json!("filesystem"); + } + result["details"]["os_error"] = json!(io.raw_os_error()); + } + if let Some(refs) = error.downcast_ref::() { + result["run_cautions"] = refs.warnings.data(); + result["partial_output"] = json!({"scan_errors": refs.scan_errors.data(), + "output_format": refs.output_format, "workspace": refs.workspace}); + } + result["causes"] = json!( + error + .chain() + .skip(1) + .map(ToString::to_string) + .collect::>() + ); + if let Some(context) = error.downcast_ref::() { + result["message"] = json!(context.message); + result["run_cautions"] = context.cautions.clone(); + result["partial_output"] = context.partial_output.clone(); + } + result +} + +pub(super) fn envelope(outcome: Result) -> Value { + match outcome { + Ok(result) => json!({"failure": null, + "run_cautions": result["run_cautions"], "result": result}), + Err(failure) => json!({"result": null, + "run_cautions": failure["run_cautions"], "failure": failure}), + } +} diff --git a/crates/grund-core/src/api/embedding_queries.rs b/crates/grund-core/src/api/embedding_queries.rs new file mode 100644 index 000000000..9e94d744a --- /dev/null +++ b/crates/grund-core/src/api/embedding_queries.rs @@ -0,0 +1,254 @@ +//! Delegation for the complete read/query inventory (§FS-distribution.3.3.5). + +use super::embedding::EmbeddingRequest; +use super::embedding_data::Data; +use super::embedding_failure::{error_data, failure}; +use crate::config::display_path; +use crate::*; +use serde_json::{Value, json}; + +fn show_opts(r: &EmbeddingRequest) -> ShowOpts { + ShowOpts { + path: r.root.clone(), + section: r.string("section"), + mode: match r.string("mode").as_deref() { + Some("brief") => ShowMode::Brief, + Some("toc") => ShowMode::Toc, + Some("full") => ShowMode::Full, + _ => ShowMode::Lead, + }, + format: match r.string("format").as_deref() { + Some("md") => ShowFormat::Markdown, + Some("json") => ShowFormat::Json, + _ => ShowFormat::Text, + }, + } +} + +/// The scanner snapshot owns catalog/citation/diagnostic data, independently +/// of a check verdict (§FS-distribution.3.3.1). +pub(super) fn scan_data(r: &EmbeddingRequest) -> Result { + let config = + crate::workspace::resolve_workspace_config(&r.root).map_err(|e| error_data(e, &[]))?; + let (findings, errors) = crate::scanner::scan_tree(&config, Some(&r.root), true) + .map_err(|e| error_data(e, &config_run_warnings(&config)))?; + let catalog = findings.declarations.iter().flat_map(|(id, decls)| { + decls.iter().map(|d| json!({"id": crate::grammar::render_id(&config.grammar, id), + "path": display_path(&config, &d.file), "line": d.line, + "heading_level": d.heading_level, "title": d.title, "stub": d.is_stub, + "defines": d.defined_in.as_ref().map(|p| crate::model::format_path(p)), + "body_start": d.body_start, "body_end": d.body_end, + "body_has_content": d.body_has_content, "value_valid": d.value_valid, + "sections": d.sections.iter().map(|(path,s)| json!({"path":path, + "title":s.title, "line":s.line, "heading_level":s.heading_level})).collect::>(), + "duplicate_sections": d.duplicate_sections.iter().map(|(path,s)| json!({"path":path, + "title":s.title, "line":s.line, "heading_level":s.heading_level})).collect::>() })) + }).collect::>(); + let citations = findings.citations.iter().map(|c| json!({ + "project": c.namespace, "id": crate::grammar::render_id(&config.grammar,&c.id), + "section": c.section, "path": display_path(&config,&c.file), + "line": c.line, "column": c.column, "marker": c.has_marker, "text": c.text, + "shorthand": c.shorthand, "local_section": c.local_section, + "shorthand_rewritable": c.shorthand_rewritable, "numeric_run": c.numeric_run, + "source_kind": c.source_kind, + "enclosing_declaration": c.enclosing_declaration.as_ref().map(|id|crate::grammar::render_id(&config.grammar,id)), + "enclosing_section": c.enclosing_section, + })).collect::>(); + Ok(json!({"catalog":catalog, "citations":citations, + "scanned_files": findings.scanned_files.iter().map(|p|display_path(&config,p)).collect::>(), + "scan_errors":errors.iter().map(|(p,m)|json!({"path":display_path(&config,p),"message":m})).collect::>(), + "run_cautions":config_run_warnings(&config).data()})) +} + +pub(super) fn query(r: &EmbeddingRequest) -> Result { + match r.operation.as_str() { + "show" => { + let (cautions, result) = show_with_scope(r.arg(0), show_opts(r), r.explicit); + let output = result.map_err(|e| error_data(e, &cautions))?; + let mut data = output.data(); + // §FS-distribution.3.3.3: output paths are logical, never native absolutes. + let config = crate::workspace::resolve_workspace_config(&r.root) + .map_err(|e| error_data(e, &cautions))?; + data["path"] = json!(display_path(&config, &output.path)); + data["run_cautions"] = cautions.data(); + Ok(data) + } + "show_batch" => { + let queries = r + .args + .first() + .filter(|v| !v.is_null()) + .and_then(Value::as_array) + .map(|queries| { + queries + .iter() + .map(|q| BatchShowQuery { + id: q + .as_str() + .or_else(|| q["id"].as_str()) + .unwrap_or_default() + .to_owned(), + section: q["section"].as_str().map(str::to_owned), + }) + .collect() + }); + let (cautions, result) = + crate::queries::show_batch_data(queries, show_opts(r), r.explicit); + let records = result + .map_err(|e| error_data(e, &cautions))? + .into_iter() + .map(|record| { + let (result, refusal) = match record.result { + Ok(value) => { + let mut data = value.data(); + data["run_cautions"] = cautions.data(); + (data, Value::Null) + } + Err(e) => (Value::Null, error_data(e, &cautions)), + }; + json!({"query":{"id":record.query.id,"section":record.query.section}, + "result":result,"failure":refusal}) + }) + .collect::>(); + Ok(json!({"records":records,"run_cautions":cautions.data()})) + } + "refs" => refs_data(r), + "list_ids" => { + let (cautions, result) = list_with_run_warnings(ListOpts { + path: r.root.clone(), + path_provided: r.explicit, + kind_filter: r.strings("kinds").into_iter().collect(), + project_filter: r.strings("projects").into_iter().collect(), + unused_only: r.flag("unused"), + selector: r.string("selector"), + }); + let out = result.map_err(|e| error_data(e, &cautions))?; + Ok( + json!({"output_format":out.output_format,"workspace":out.workspace, + "entries":out.entries.data(),"summaries":out.summaries.data(), + "scan_errors":out.scan_errors.data(),"run_cautions":cautions.data()}), + ) + } + "list_sizes" => { + let mut opts = ListSizeOpts { + path: r.root.clone(), + path_provided: r.explicit, + kind_filter: r.strings("kinds").into_iter().collect(), + project_filter: r.strings("projects").into_iter().collect(), + unused_only: r.flag("unused"), + selector: r.string("selector"), + top: r.options["top"].as_u64().map(|n| n as usize), + ..Default::default() + }; + if r.options["units"].is_array() { + opts.units = r + .strings("units") + .iter() + .filter_map(|u| PointSizeUnit::parse(u)) + .collect(); + } + let (cautions, result) = list_sizes_with_run_warnings(opts); + let out = result.map_err(|e| error_data(e, &cautions))?; + Ok( + json!({"output_format":out.output_format,"workspace":out.workspace, + "entries":out.entries.data(),"scan_errors":out.scan_errors.data(),"run_cautions":out.warnings.data()}), + ) + } + "cover" => { + let opts = CoverOpts { + path: r.root.clone(), + path_provided: r.explicit, + }; + if r.flag("text") { + let (cautions, result) = cover_text_with_run_warnings(opts); + let out = result.map_err(|e| error_data(e, &cautions))?; + Ok( + json!({"output_format":out.output_format,"entries":out.entries.data(),"scan_errors":out.scan_errors.data(),"run_cautions":out.warnings.data()}), + ) + } else { + let (cautions, result) = cover_with_run_warnings(opts); + let out = result.map_err(|e| error_data(e, &cautions))?; + Ok( + json!({"output_format":out.output_format,"entries":out.entries.data(),"scan_errors":out.scan_errors.data(),"run_cautions":out.warnings.data()}), + ) + } + } + "complete_ids" => { + let (cautions, result) = complete_ids_with_run_warnings(CompleteIdsOpts { + path: r.root.clone(), + path_provided: r.explicit, + prefix: r.arg(0).to_owned(), + sections: r.flag("sections"), + }); + Ok( + json!({"candidates":result.map_err(|e|error_data(e,&cautions))?,"run_cautions":cautions.data()}), + ) + } + "propose_id" => { + let (cautions, result) = propose_id_with_run_warnings( + r.arg(0), + r.arg(1), + IdOpts { + path: r.root.clone(), + path_provided: r.explicit, + width: r.options["width"].as_u64().unwrap_or(3) as usize, + }, + ); + match result.map_err(|e| error_data(e, &cautions))? { + IdProposalOutcome::Proposed(out) => { + let mut data = out.data(); + data["run_cautions"] = cautions.data(); + Ok(data) + } + IdProposalOutcome::UnknownKind { headline, known } => { + let mut f = failure("operation", "unknown-kind", headline, &cautions); + f["details"] = json!({"known":known}); + Err(f) + } + IdProposalOutcome::Rejected { message } => { + Err(failure("query", "query-failed", message, &cautions)) + } + } + } + _ => unreachable!("read operation selected by core dispatch"), + } +} + +fn refs_data(r: &EmbeddingRequest) -> Result { + let mut details = json!({}); + let mut cautions = Vec::new(); + let meta = super::refs_query::refs_impl_with_details( + RefsOpts { + path: r.root.clone(), + path_provided: r.explicit, + id: r.arg(0).to_owned(), + section: r.string("section"), + descendants: r.flag("descendants"), + }, + &mut details, + &mut cautions, + ) + .map_err(|e| error_data(e, &cautions))?; + let out = meta.outcome.output; + if let Some(e) = meta.outcome.query_failure { + let mut f = failure("query", e.kind.code(), e.message, &out.warnings); + // The resolver carrier retains structured candidates at its source. + f["details"] = details; + if let Some(hint) = e.format_hint { + f["details"]["format_hint"] = json!(hint); + } + return Err(f); + } + let mut files = std::collections::BTreeMap::<(Option, String), usize>::new(); + for hit in &out.hits { + *files + .entry((hit.project.clone(), hit.path.clone())) + .or_default() += 1; + } + Ok( + json!({"output_format":out.output_format,"workspace":out.workspace,"hits":out.hits.data(), + "note":out.note,"scan_errors":out.scan_errors.data(),"run_cautions":out.warnings.data(), + "kind_title":meta.kind_title,"site_total":out.hits.len(),"file_total":files.len(), + "file_summaries":files.into_iter().map(|((project,path),sites)|json!({"project":project,"path":path,"sites":sites})).collect::>() }), + ) +} diff --git a/crates/grund-core/src/api/embedding_writers.rs b/crates/grund-core/src/api/embedding_writers.rs new file mode 100644 index 000000000..269c31b91 --- /dev/null +++ b/crates/grund-core/src/api/embedding_writers.rs @@ -0,0 +1,85 @@ +//! Writer defaults and complete outcomes (§FS-distribution.3.3.6). + +use super::embedding::EmbeddingRequest; +use super::embedding_data::Data; +use super::embedding_failure::{error_data, failure}; +use crate::*; +use serde_json::{Value, json}; + +pub(super) fn write(r: &EmbeddingRequest) -> Result { + match r.operation.as_str() { + "fmt" => { + let (cautions, result) = format_references_with_run_warnings(FmtOpts { + path: r.root.clone(), + path_provided: r.explicit, + write: r.flag("write"), + add_marker: r.flag("marker"), + cross_refs: r.flag("cross_refs"), + }); + let out = result.map_err(|e| error_data(e, &cautions))?; + Ok( + json!({"changes":out.changes.data(),"scan_errors":out.scan_errors.data(), + "refused_writes":out.refused_writes,"run_cautions":out.warnings.data()}), + ) + } + "init" => { + let mut agents = InitAgentEntrypointSelection::default(); + for agent in r.strings("agents") { + match agent.as_str() { + "canonical" | "codex" | "agents" => agents.canonical = true, + "claude" => agents.claude = true, + "gemini" => agents.gemini = true, + "pi" => agents.pi = true, + "copilot" => agents.copilot = true, + "cursor" => agents.cursor = true, + "windsurf" => agents.windsurf = true, + "zed" => agents.zed = true, + _ => {} + } + } + let (result, diagnostic, cautions) = crate::writers::init_with_diagnostics(InitOpts { + target: r.root.clone(), + name: r.string("name"), + description: r.string("description"), + docs: r.flag("docs"), + force: r.flag("force"), + dry_run: !r.flag("write"), + check: r.flag("check"), + no_vcs: r.flag("no_vcs"), + agent_selection: agents, + }); + result.map(|out| out.data()).map_err(|e| { + let mut f = match diagnostic { + Some(source) => error_data(source, &cautions), + None => failure("operation", "init", e.message, &cautions), + }; + f["partial_output"] = e.output.data(); + f + }) + } + "fetch" => { + // §FS-distribution.3.3.6: host validation refuses before this entry; + // also protect language-neutral callers from accidental execution. + if !r.flag("write") { + return Err(failure( + "operation", + "write-required", + "fetch requires write=True".into(), + &[], + )); + } + let (cautions, out) = crate::writers::fetch_with_diagnostics(r.arg(0), &r.root); + out.map_err(|e| error_data(e, &cautions))?; + Ok(json!({"run_cautions":cautions.data()})) + } + "integrations" => crate::writers::integrations_data( + r.arg(0), + r.flag("write"), + r.string("conversation").as_deref(), + r.string("conversation_target").as_deref(), + r.string("agent").as_deref(), + ) + .map_err(|e| error_data(e, &[])), + _ => unreachable!("writer selected by core dispatch"), + } +} diff --git a/crates/grund-core/src/api/fmt.rs b/crates/grund-core/src/api/fmt.rs index 73a23ddae..fbadaac1c 100644 --- a/crates/grund-core/src/api/fmt.rs +++ b/crates/grund-core/src/api/fmt.rs @@ -73,7 +73,19 @@ pub struct FmtOutput { /// Programmatic `fmt`: run the normalizer and return the changed locations /// without printing the CLI report or mapping the exit code (§AR-bindings.2). pub fn format_references(opts: FmtOpts) -> Result { + format_references_with_run_warnings(opts).1 +} + +/// Additive cautions survive a later refusal (§FS-distribution.3.1). +pub fn format_references_with_run_warnings(opts: FmtOpts) -> (Vec, Result) { + let mut cautions = Vec::new(); + let result = format_references_run(opts, &mut cautions); + (cautions, result) +} + +fn format_references_run(opts: FmtOpts, cautions: &mut Vec) -> Result { let context = load_workspace_context(&opts.path, opts.path_provided)?; + *cautions = context_run_warnings(&context); let config = context.render_config().clone(); let explicit_cross_refs = opts.cross_refs; let workspace_for_wrap = if context.workspace_loaded { diff --git a/crates/grund-core/src/api/mod.rs b/crates/grund-core/src/api/mod.rs index 9d6c048cc..bdcd65dc1 100644 --- a/crates/grund-core/src/api/mod.rs +++ b/crates/grund-core/src/api/mod.rs @@ -37,6 +37,12 @@ mod complete_ids; mod config; mod config_findings; mod cover; +mod embedding; +mod embedding_config; +mod embedding_data; +mod embedding_failure; +mod embedding_queries; +mod embedding_writers; mod fmt; mod id; mod list; @@ -59,9 +65,12 @@ pub use config::{ }; pub use cover::{ CoverCitation, CoverEntry, CoverOpts, CoverOutput, CoverTextCitation, CoverTextEntry, - CoverTextOutput, cover, cover_text, + CoverTextOutput, cover, cover_text, cover_text_with_run_warnings, cover_with_run_warnings, +}; +pub use embedding::{EmbeddingRequest, embedding_call}; +pub use fmt::{ + FmtChange, FmtOpts, FmtOutput, format_references, format_references_with_run_warnings, }; -pub use fmt::{FmtChange, FmtOpts, FmtOutput, format_references}; pub use id::{IdOpts, IdProposal, IdProposalOutcome, propose_id, propose_id_with_run_warnings}; pub use list::{ ListEntry, ListOpts, ListOutput, ListSummary, ListValueRoot, list, list_with_run_warnings, diff --git a/crates/grund-core/src/api/refs_query.rs b/crates/grund-core/src/api/refs_query.rs index e63b2dc25..7e08a3c0f 100644 --- a/crates/grund-core/src/api/refs_query.rs +++ b/crates/grund-core/src/api/refs_query.rs @@ -36,10 +36,20 @@ fn section_in_scope(cited: Option<&str>, requested: &str, descendants: bool) -> /// Resolve and refuse recorded target ambiguities before filtering any citations /// (§FS-refs.4), retaining absent-target queries (§FS-refs.1). pub(super) fn refs_impl(opts: RefsOpts) -> Result { + refs_impl_with_details(opts, &mut serde_json::json!({}), &mut Vec::new()) +} + +/// Additive source diagnostics without changing RefsOutput (§FS-distribution.3.1). +pub(super) fn refs_impl_with_details( + opts: RefsOpts, + details: &mut serde_json::Value, + cautions: &mut Vec, +) -> Result { // §AR-scanner.2.4.2: the classifying loader, not the plain one — `refs` // publishes each hit's enclosing declaration and section (§FS-refs.3.2), and // the plain wrapper is shared by the five queries that do not. let context = load_classifying_workspace_context(&opts.path, opts.path_provided)?; + *cautions = context_run_warnings(&context); let current_config = context .current_project() .map(|project| &project.config) @@ -53,24 +63,24 @@ pub(super) fn refs_impl(opts: RefsOpts) -> Result { let target_project = match alias.as_deref() { Some(name) => context.project_by_alias(name).ok_or_else(|| { if !context.workspace_loaded { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nnote: workspace aliases are defined in the root grund.toml under [workspace]" - ) + ))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nknown aliases: {}", context.aliases().join(", ") - ) + ))) } })?, None => context.current_project().ok_or_else(|| { let known = context.aliases().join(", "); if known.is_empty() { - anyhow!("unqualified ID requires a project alias when include_root = false") + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!("unqualified ID requires a project alias when include_root = false"))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!( "unqualified ID requires a project alias when include_root = false\nknown aliases: {known}" - ) + ))) } })?, }; @@ -92,6 +102,8 @@ pub(super) fn refs_impl(opts: RefsOpts) -> Result { { Ok(resolved) => resolved, Err(error) => { + // §FS-distribution.3.3.2: retain original resolver data. + *details = error.diagnostic().details; return Ok(RefsWithMetadata { kind_title: None, outcome: RefsOutcome { @@ -112,9 +124,11 @@ pub(super) fn refs_impl(opts: RefsOpts) -> Result { } }; if opts.section.is_some() && inline_section.is_some() { - return Err(anyhow!( - "--section cannot be combined with an inline section" - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "query-failed", + format!("--section cannot be combined with an inline section") + ))); } let section = opts.section.or(inline_section); diff --git a/crates/grund-core/src/api/show.rs b/crates/grund-core/src/api/show.rs index 1ea997e84..e89171a18 100644 --- a/crates/grund-core/src/api/show.rs +++ b/crates/grund-core/src/api/show.rs @@ -83,43 +83,52 @@ fn show_run( .project_by_alias(name) .ok_or_else(|| { if !context.workspace_loaded { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nnote: workspace aliases are defined in the root grund.toml under [workspace]" - ) + ))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nknown aliases: {}", context.aliases().join(", ") - ) + ))) } })?, None => context.current_project().ok_or_else(|| { let known = context.aliases().join(", "); if known.is_empty() { - anyhow!("unqualified ID requires a project alias when include_root = false") + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!("unqualified ID requires a project alias when include_root = false"))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!( "unqualified ID requires a project alias when include_root = false\nknown aliases: {known}" - ) + ))) } })?, }; // §FS-workspace.8.7.3: rendered against the run's config, not the target // project's — the same spelling `check` uses for the same tree. if let Some((file, message)) = project.scan_errors.first() { - return Err(anyhow!( - "{}: {}", - display_path(context.render_config(), file), - message - )); + // §FS-distribution.3.3.2: scan failures are located filesystem outcomes. + let path = display_path(context.render_config(), file); + let mut failure = crate::model::OperationDiagnostic::new( + "filesystem", + "io", + format!("{path}: {message}"), + ); + failure.path = Some(path); + failure.details = serde_json::json!({"scan_errors": project.scan_errors.iter() + .map(|(path,message)| serde_json::json!({"path": display_path(context.render_config(),path),"message":message})) + .collect::>()}); + return Err(failure.into()); } let config = &project.config; - let (id, inline_section) = - resolve_id_arg(raw_id, config, &project.findings).map_err(|err| anyhow!("{err}"))?; + let (id, inline_section) = resolve_id_arg(raw_id, config, &project.findings) + .map_err(|error| anyhow::Error::new(error.diagnostic()))?; if opts.section.is_some() && inline_section.is_some() { - return Err(anyhow!( - "--section cannot be combined with an inline section" - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "query-failed", + format!("--section cannot be combined with an inline section") + ))); } let section = opts.section.or(inline_section); let mut output = show_declaration_with_overlays( diff --git a/crates/grund-core/src/config/call_scope.rs b/crates/grund-core/src/config/call_scope.rs new file mode 100644 index 000000000..fc9eba8b7 --- /dev/null +++ b/crates/grund-core/src/config/call_scope.rs @@ -0,0 +1,34 @@ +//! Embedding's zero-config discovery base (§FS-distribution.3.3.3). +//! +//! Existing options do not carry a fallback cwd. Scope that one discovery input +//! to the synchronous embedding call, instead of changing process cwd or existing +//! Rust/CLI defaults. The guard restores prior scope on both return and unwind. +//! Config objects handed to worker threads already contain the resolved root; +//! workspace members use direct load_config_at and do not need this fallback. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +thread_local! { + static EMBEDDING_BASE: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn embedding_base() -> Option { + EMBEDDING_BASE.with(|base| base.borrow().clone()) +} + +pub(crate) fn with_embedding_base(path: &Path, operation: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + EMBEDDING_BASE.with(|base| *base.borrow_mut() = self.0.take()); + } + } + let base = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + }; + let _restore = Restore(EMBEDDING_BASE.with(|slot| slot.replace(Some(base.to_path_buf())))); + operation() +} diff --git a/crates/grund-core/src/config/discovery.rs b/crates/grund-core/src/config/discovery.rs index eba71b889..70f37df76 100644 --- a/crates/grund-core/src/config/discovery.rs +++ b/crates/grund-core/src/config/discovery.rs @@ -113,10 +113,14 @@ pub(crate) fn load_config(start: &Path) -> Result { // Zero-config (§GOAL-zero-config): the "project root" is the current working // directory, never the path passed on the command line. Reports stay relative to // `cli_base` (the resolved path arg) when `relative_paths` is off (§FS-config.3.6.1). - let root = std::env::current_dir() - .ok() - .and_then(|cwd| fs::canonicalize(&cwd).ok()) - .unwrap_or_else(|| walk_start.clone()); + // §FS-distribution.3.3.3: embedding scopes this fallback to its supplied root. + let root = match super::call_scope::embedding_base() { + Some(base) => fs::canonicalize(&base).unwrap_or(base), + None => std::env::current_dir() + .ok() + .and_then(|cwd| fs::canonicalize(&cwd).ok()) + .unwrap_or_else(|| walk_start.clone()), + }; let mut config = Config::default_for(root); config.cli_base = walk_start; Ok(config) @@ -167,7 +171,20 @@ pub(crate) fn load_config_at_with_report_base( if let Some(candidate) = candidate { let report_path = report_relative(&candidate); config.config_file = Some(report_path.clone()); - parse_config_file(&candidate, &report_path, &mut config)?; + // §FS-distribution.3.3.2: classify all config-load failures at discovery. + parse_config_file(&candidate, &report_path, &mut config).map_err(|error| { + if error + .downcast_ref::() + .is_some() + { + error + } else { + let mut diagnostic = + crate::model::OperationDiagnostic::from_error("config", "config", error); + diagnostic.path = Some(crate::model::format_path(&report_path)); + diagnostic.into() + } + })?; } Ok(config) } diff --git a/crates/grund-core/src/config/mod.rs b/crates/grund-core/src/config/mod.rs index 40d3ca968..c35fc7d4d 100644 --- a/crates/grund-core/src/config/mod.rs +++ b/crates/grund-core/src/config/mod.rs @@ -34,6 +34,7 @@ //! alone and went further down, to `model/paths.rs`; this is the half that needs //! a `Config`. +mod call_scope; mod citations; mod discovery; mod fmt_block; @@ -104,3 +105,6 @@ mod tests_kind_index; mod tests_non_citable_kinds; #[cfg(test)] mod tests_validation; + +// §FS-distribution.3.3.3: explicit embedding roots never change process cwd. +pub(crate) use call_scope::with_embedding_base; diff --git a/crates/grund-core/src/config/parse.rs b/crates/grund-core/src/config/parse.rs index 9de42e54b..2ef44c234 100644 --- a/crates/grund-core/src/config/parse.rs +++ b/crates/grund-core/src/config/parse.rs @@ -462,7 +462,15 @@ pub(crate) fn strip_comment(line: &str) -> &str { /// Fail config parsing with a `path:line: message` error — the located-finding /// shape applied to a malformed `grund.toml` (§FS-config.4.3, §FS-errors.2.1). pub(super) fn bail_config(path: &Path, line: usize, message: String) -> Result { - Err(anyhow!("{}:{}: {}", format_path(path), line, message)) + // §FS-distribution.3.3.2: preserve the parser's location without parsing Display. + let mut failure = crate::model::OperationDiagnostic::new( + "config", + "config", + format!("{}:{}: {}", format_path(path), line, message), + ); + failure.path = Some(format_path(path)); + failure.line = Some(line); + Err(failure.into()) } pub(super) fn parse_string(path: &Path, line: usize, value: &str) -> Result { @@ -507,13 +515,9 @@ pub(super) fn parse_bool(path: &Path, line: usize, value: &str) -> Result } pub(super) fn parse_usize(path: &Path, line: usize, value: &str) -> Result { - value.parse::().map_err(|_| { - anyhow!( - "{}:{}: expected non-negative integer", - format_path(path), - line - ) - }) + value + .parse::() + .or_else(|_| bail_config(path, line, "expected non-negative integer".to_string())) } pub(crate) fn parse_string_list(path: &Path, line: usize, value: &str) -> Result> { diff --git a/crates/grund-core/src/grammar/shorthand.rs b/crates/grund-core/src/grammar/shorthand.rs index 76c3337a2..3db4eefe2 100644 --- a/crates/grund-core/src/grammar/shorthand.rs +++ b/crates/grund-core/src/grammar/shorthand.rs @@ -107,6 +107,24 @@ pub(crate) enum IdArgError { } impl IdArgError { + /// Source-classified query data for hosts (§FS-distribution.3.3.2). + pub(crate) fn diagnostic(&self) -> crate::model::OperationDiagnostic { + if let Some(existing) = self + .error() + .downcast_ref::() + { + return existing.clone(); + } + crate::model::OperationDiagnostic::new( + "query", + match self { + Self::Unparsable(_) => "invalid-id", + Self::Ambiguous(_) => "ambiguous", + }, + self.to_string(), + ) + } + fn error(&self) -> &anyhow::Error { match self { Self::Unparsable(err) | Self::Ambiguous(err) => err, diff --git a/crates/grund-core/src/lib.rs b/crates/grund-core/src/lib.rs index b06ae9a27..6dd8bf305 100644 --- a/crates/grund-core/src/lib.rs +++ b/crates/grund-core/src/lib.rs @@ -65,7 +65,7 @@ pub(crate) mod testing; pub use model::{ Citation, Declaration, DeclarationSource, DocCommentBlock, E2eCase, E2eSpecRef, EmbeddedValueRoot, FileHeading, FileStructure, Finding, FindingSite, Findings, Id, - InlineCitationSite, InvalidValueSite, NearMissHeading, Report, + InlineCitationSite, InvalidValueSite, NearMissHeading, OperationDiagnostic, Report, SectionHeadingOutsideDeclaration, SectionInfo, ShowOutput, ShowSection, UnmarkedHeading, ValueBinding, ValueComponent, ValueComponentKind, ValueRootOrigin, canonical_snapshot_path, }; @@ -105,8 +105,8 @@ pub use queries::{ LspCompletionContext, LspDeclaration, LspFindingRange, LspSnapshot, LspSnapshotOpts, LspSnapshotWithCompletion, LspSnapshotWithMetadata, LspStub, LspUsage, ShowFormat, ShowMode, ShowOpts, ShowQueryError, can_replace_trigger_at, citation_under_title, list_sizes, - lsp_hover_with_kind_title, lsp_title_hover_body, on_type_line_edits, show_batch_with_scope, - usage_clause, usage_over_paths, + list_sizes_with_run_warnings, lsp_hover_with_kind_title, lsp_title_hover_body, + on_type_line_edits, show_batch_with_scope, usage_clause, usage_over_paths, }; // §AR-system.2.11 templates: the setup skill a command prints byte-for-byte and @@ -142,13 +142,15 @@ pub use writers::{ // (§AR-bindings.2, §FS-distribution.3). pub use api::{ CheckOpts, CheckOutput, CompleteIdsOpts, CoverCitation, CoverEntry, CoverOpts, CoverOutput, - CoverTextCitation, CoverTextEntry, CoverTextOutput, FmtChange, FmtOpts, FmtOutput, IdOpts, - IdProposal, IdProposalOutcome, ListEntry, ListOpts, ListOutput, ListSummary, ListValueRoot, - RefHit, ReferenceStyle, RefsOpts, RefsOutcome, RefsOutput, RefsQueryFailure, + CoverTextCitation, CoverTextEntry, CoverTextOutput, EmbeddingRequest, FmtChange, FmtOpts, + FmtOutput, IdOpts, IdProposal, IdProposalOutcome, ListEntry, ListOpts, ListOutput, ListSummary, + ListValueRoot, RefHit, ReferenceStyle, RefsOpts, RefsOutcome, RefsOutput, RefsQueryFailure, RefsQueryFailureKind, RefsWithMetadata, check, check_with_opts, check_with_run_warnings, complete_ids, complete_ids_with_run_warnings, config_run_warnings, config_warnings, cover, - cover_text, effective_config, format_references, list, list_with_run_warnings, lsp_snapshot, - lsp_snapshot_with_completion, lsp_snapshot_with_metadata, propose_id, - propose_id_with_run_warnings, reference_style, refs, refs_outcome, refs_with_metadata, - render_finding_sites_json, scan, show, show_with_overlays, show_with_scope, validate_config, + cover_text, cover_text_with_run_warnings, cover_with_run_warnings, effective_config, + embedding_call, format_references, format_references_with_run_warnings, list, + list_with_run_warnings, lsp_snapshot, lsp_snapshot_with_completion, lsp_snapshot_with_metadata, + propose_id, propose_id_with_run_warnings, reference_style, refs, refs_outcome, + refs_with_metadata, render_finding_sites_json, scan, show, show_with_overlays, show_with_scope, + validate_config, }; diff --git a/crates/grund-core/src/model/failure.rs b/crates/grund-core/src/model/failure.rs new file mode 100644 index 000000000..30437351b --- /dev/null +++ b/crates/grund-core/src/model/failure.rs @@ -0,0 +1,92 @@ +//! Source-classified operational errors for embedders (§FS-distribution.3.1). + +use serde_json::{Value, json}; + +/// Additive context: Display stays verbatim, while locations and candidates +/// cross the binding boundary as data (§FS-distribution.3.3.2). +#[derive(Clone, Debug)] +pub struct OperationDiagnostic { + pub class: &'static str, + pub code: &'static str, + pub message: String, + pub path: Option, + pub line: Option, + pub column: Option, + pub details: Value, + source: Option>, +} + +impl OperationDiagnostic { + /// A refusal without a source location (§FS-distribution.3.3.2). + pub fn new(class: &'static str, code: &'static str, message: impl Into) -> Self { + Self { + class, + code, + message: message.into(), + path: None, + line: None, + column: None, + details: json!({}), + source: None, + } + } + + /// Replace only the original error's head, keeping its Display and source + /// chain verbatim for existing callers (§FS-distribution.3.1). + pub(crate) fn from_error( + class: &'static str, + code: &'static str, + error: anyhow::Error, + ) -> Self { + let mut diagnostic = Self::new(class, code, error.to_string()); + if let Some(io) = error.downcast_ref::() { + diagnostic.details = json!({"os_error": io.raw_os_error()}); + } + diagnostic.source = Some(std::sync::Arc::new(error)); + diagnostic + } + + /// Preserve OS classification where a writer still has the original error + /// (§FS-distribution.3.3.2). + pub(crate) fn filesystem( + path: &std::path::Path, + error: &std::io::Error, + message: String, + ) -> Self { + let mut diagnostic = Self::new("filesystem", "io", message); + diagnostic.path = Some(super::paths::format_path(path)); + diagnostic.details = json!({"os_error":error.raw_os_error()}); + diagnostic + } +} + +impl std::fmt::Display for OperationDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for OperationDiagnostic { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .and_then(|error| error.as_ref().source()) + } +} + +/// Run metadata beside a late refusal, independent of host exception policy +/// (§FS-distribution.3.1, §FS-distribution.3.3.2). +#[derive(Debug)] +pub(crate) struct OperationContext { + pub(crate) message: String, + pub(crate) cautions: Value, + pub(crate) partial_output: Value, +} + +impl std::fmt::Display for OperationContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for OperationContext {} diff --git a/crates/grund-core/src/model/mod.rs b/crates/grund-core/src/model/mod.rs index 9c5802fd7..3c856bb91 100644 --- a/crates/grund-core/src/model/mod.rs +++ b/crates/grund-core/src/model/mod.rs @@ -29,6 +29,7 @@ //! rebase a path against it. mod e2e; +mod failure; mod headings; mod paths; mod records; @@ -37,6 +38,8 @@ mod text; mod values; pub use e2e::{E2eCase, E2eSpecRef}; +pub(crate) use failure::OperationContext; +pub use failure::OperationDiagnostic; pub use headings::{NearMissHeading, SectionHeadingOutsideDeclaration, UnmarkedHeading}; pub use paths::canonical_snapshot_path; pub use records::{ diff --git a/crates/grund-core/src/queries/batch.rs b/crates/grund-core/src/queries/batch.rs index ac0d1691d..d2f58b06e 100644 --- a/crates/grund-core/src/queries/batch.rs +++ b/crates/grund-core/src/queries/batch.rs @@ -38,6 +38,74 @@ pub struct BatchShowRecord { pub result: std::result::Result, } +/// Rich batch records preserve all query diagnostics and ShowOutput fields +/// without changing the existing JSON batch API (§FS-distribution.3.1). +pub(crate) struct BatchDataRecord { + pub(crate) query: BatchShowQuery, + pub(crate) result: Result, +} + +/// The same shared context and query implementation, with source-typed failures +/// retained rather than projected to CLI JSON (§FS-distribution.3.3.2). +pub(crate) fn show_batch_data( + queries: Option>, + mut opts: ShowOpts, + path_provided: bool, +) -> (Vec, Result>) { + let mut cautions = Vec::new(); + let result = (|| { + if queries.as_ref().is_some_and(Vec::is_empty) { + return Ok(Vec::new()); + } + let context = load_workspace_context(&opts.path, path_provided)?; + cautions = run_warning_findings(context.render_config(), context.run_warnings.clone()); + if let Some((file, message)) = context.projects.iter().find_map(|p| p.scan_errors.first()) { + let mut diagnostic = + crate::model::OperationDiagnostic::new("filesystem", "io", message); + diagnostic.path = Some(display_path(context.render_config(), file)); + return Err(diagnostic.into()); + } + let queries = queries.unwrap_or_else(|| exhaustive_batch_queries(&context)); + opts.format = ShowFormat::Json; + queries + .into_iter() + .map(|query| { + let result = show_batch_query_in_context( + &context, + &query.id, + ShowOpts { + section: query.section.clone(), + ..opts.clone() + }, + &TextOverlays::new(), + ); + match result { + Ok(mut output) => { + output.path = display_path(context.render_config(), &output.path).into(); + Ok(BatchDataRecord { + query, + result: Ok(output), + }) + } + Err(error) + if error.downcast_ref::().is_some() + || error + .downcast_ref::() + .is_some_and(|e| e.class == "query") => + { + Ok(BatchDataRecord { + query, + result: Err(error), + }) + } + Err(error) => Err(error), + } + }) + .collect() + })(); + (cautions, result) +} + /// Run an explicit query list (`Some`) or exhaustive discovery (`None`) against /// one shared workspace context. This is intentionally a CLI adapter rather /// than a replacement for the stable one-query core API (§FS-show.2.6). @@ -120,34 +188,36 @@ fn show_batch_query_in_context( let project = match alias.as_deref() { Some(name) => context.project_by_alias(name).ok_or_else(|| { if !context.workspace_loaded { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nnote: workspace aliases are defined in the root grund.toml under [workspace]" - ) + ))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "unknown-project", format!( "unknown project alias `{name}`\nknown aliases: {}", context.aliases().join(", ") - ) + ))) } })?, None => context.current_project().ok_or_else(|| { let known = context.aliases().join(", "); if known.is_empty() { - anyhow!("unqualified ID requires a project alias when include_root = false") + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!("unqualified ID requires a project alias when include_root = false"))) } else { - anyhow!( + anyhow!(crate::model::OperationDiagnostic::new("query", "query-failed", format!( "unqualified ID requires a project alias when include_root = false\nknown aliases: {known}" - ) + ))) } })?, }; let config = &project.config; - let (id, inline_section) = - resolve_id_arg(raw_id, config, &project.findings).map_err(|error| anyhow!("{error}"))?; + let (id, inline_section) = resolve_id_arg(raw_id, config, &project.findings) + .map_err(|error| anyhow::Error::new(error.diagnostic()))?; if opts.section.is_some() && inline_section.is_some() { - return Err(anyhow!( - "--section cannot be combined with an inline section" - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "query-failed", + format!("--section cannot be combined with an inline section") + ))); } let section = opts.section.or(inline_section); let mut output = show_declaration_with_overlays( @@ -222,6 +292,16 @@ fn exhaustive_batch_queries(context: &WorkspaceContext) -> Vec { /// Convert only coordinate-level refusals into envelopes. An unexpected body /// read or other operational error remains a run-level abort (§FS-show.2.6.3). fn batch_query_failure(error: &anyhow::Error) -> Option { + // §FS-distribution.3.3.2: new source carriers need no message classification. + if let Some(carrier) = error.downcast_ref::() + && carrier.class == "query" + { + return Some(BatchShowFailure { + code: carrier.code, + message: carrier.message.clone(), + sites: Vec::new(), + }); + } if let Some(carrier) = error.downcast_ref::() { return Some(BatchShowFailure { code: carrier.code, diff --git a/crates/grund-core/src/queries/mod.rs b/crates/grund-core/src/queries/mod.rs index 6071f442d..d89faee04 100644 --- a/crates/grund-core/src/queries/mod.rs +++ b/crates/grund-core/src/queries/mod.rs @@ -55,6 +55,7 @@ mod editor_on_type; mod editor_snapshot; mod show; mod show_query; +mod size_output; mod sizes; pub use batch::{BatchShowFailure, BatchShowQuery, BatchShowRecord, show_batch_with_scope}; @@ -69,7 +70,10 @@ pub use editor_snapshot::{ LspSnapshotWithCompletion, LspSnapshotWithMetadata, LspStub, }; pub use show_query::{ShowFormat, ShowMode, ShowOpts, ShowQueryError}; -pub use sizes::{ListSizeEntry, ListSizeMeasurement, ListSizeOpts, ListSizeOutput, list_sizes}; +pub use sizes::{ + ListSizeEntry, ListSizeMeasurement, ListSizeOpts, ListSizeOutput, list_sizes, + list_sizes_with_run_warnings, +}; // What the other components read, each by this module's path (§AR-system.4): // the whole of what crosses this boundary, and the only thing outside the @@ -94,3 +98,6 @@ mod tests_editor_completion_workspace; mod tests_lsp_hover; #[cfg(test)] mod tests_workspace_message_paths; + +// §FS-distribution.3.1: the additive full-data batch adapter. +pub(crate) use batch::show_batch_data; diff --git a/crates/grund-core/src/queries/show.rs b/crates/grund-core/src/queries/show.rs index 194de37f7..fbc45cb40 100644 --- a/crates/grund-core/src/queries/show.rs +++ b/crates/grund-core/src/queries/show.rs @@ -49,10 +49,13 @@ pub(crate) fn show_declaration_with_overlays( overlays: &TextOverlays, ) -> Result { let root = &config.root; - let decls = findings - .declarations - .get(id) - .ok_or_else(|| anyhow!("ID not found: {}", render_id(&config.grammar, id)))?; + let decls = findings.declarations.get(id).ok_or_else(|| { + anyhow!(crate::model::OperationDiagnostic::new( + "query", + "not-found", + format!("ID not found: {}", render_id(&config.grammar, id)) + )) + })?; // §FS-show.2.2.1: share the independent-home refusal with refs (§FS-refs.4). if let Some(refusal) = ambiguous_id_refusal(config, path_config, decls, id) { return Err(refusal.into()); @@ -71,23 +74,31 @@ pub(crate) fn show_declaration_with_overlays( }; if decl.is_stub { if !file.exists() { - return Err(anyhow!( - "broken stub: {} (stub at {}:{} points at {}, which does not exist)", - render_id(&config.grammar, id), - display_path(path_config, &decl.file), - decl.line, - format_path(decl.defined_in.as_ref().unwrap()) - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "broken-stub", + format!( + "broken stub: {} (stub at {}:{} points at {}, which does not exist)", + render_id(&config.grammar, id), + display_path(path_config, &decl.file), + decl.line, + format_path(decl.defined_in.as_ref().unwrap()) + ) + ))); } if !file_declares_inline_home(&file, id, config).unwrap_or(false) { - return Err(anyhow!( - "broken stub: {} (stub at {}:{} points at {}, which contains no inline declaration of {})", - render_id(&config.grammar, id), - display_path(path_config, &decl.file), - decl.line, - format_path(decl.defined_in.as_ref().unwrap()), - render_id(&config.grammar, id) - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "broken-stub", + format!( + "broken stub: {} (stub at {}:{} points at {}, which contains no inline declaration of {})", + render_id(&config.grammar, id), + display_path(path_config, &decl.file), + decl.line, + format_path(decl.defined_in.as_ref().unwrap()), + render_id(&config.grammar, id) + ) + ))); } } let body_decl = if decl.is_stub { @@ -107,12 +118,16 @@ pub(crate) fn show_declaration_with_overlays( if let Some(section) = section && !body_decl.sections.contains_key(section) { - return Err(anyhow!( - "section not found: {}{}{}", - render_id(&config.grammar, id), - config.section_separator, - section - )); + return Err(anyhow!(crate::model::OperationDiagnostic::new( + "query", + "missing-section", + format!( + "section not found: {}{}{}", + render_id(&config.grammar, id), + config.section_separator, + section + ) + ))); } extract_declaration_body( &file, @@ -138,20 +153,28 @@ fn show_json_value( let (body, line) = match section { Some(section) => { let info = decl.sections.get(section).ok_or_else(|| { - anyhow!( - "section not found: {}{}{}", - render_id(&config.grammar, id), - config.section_separator, - section - ) + anyhow!(crate::model::OperationDiagnostic::new( + "query", + "missing-section", + format!( + "section not found: {}{}{}", + render_id(&config.grammar, id), + config.section_separator, + section + ) + )) })?; let value = info.value.as_ref().ok_or_else(|| { - anyhow!( - "section not found: {}{}{}", - render_id(&config.grammar, id), - config.section_separator, - section - ) + anyhow!(crate::model::OperationDiagnostic::new( + "query", + "missing-section", + format!( + "section not found: {}{}{}", + render_id(&config.grammar, id), + config.section_separator, + section + ) + )) })?; (value.source_slice.clone(), info.line) } diff --git a/crates/grund-core/src/queries/size_output.rs b/crates/grund-core/src/queries/size_output.rs new file mode 100644 index 000000000..900280249 --- /dev/null +++ b/crates/grund-core/src/queries/size_output.rs @@ -0,0 +1,88 @@ +//! Point-size catalog options and records (§FS-list.3.4). +use crate::config::PointSizeUnit; +use crate::model::Finding; +use crate::scanner::ApiScanError; +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// Options for the additive per-point size catalog (§FS-list.1, §FS-list.3.4). +#[derive(Clone)] +pub struct ListSizeOpts { + pub path: PathBuf, + pub path_provided: bool, + pub kind_filter: BTreeSet, + pub project_filter: BTreeSet, + pub unused_only: bool, + /// Optional declaration/chapter selector (§FS-rules.8). + pub selector: Option, + pub units: Vec, + pub top: Option, +} + +impl Default for ListSizeOpts { + fn default() -> Self { + Self { + path: PathBuf::from("."), + path_provided: false, + kind_filter: BTreeSet::new(), + project_filter: BTreeSet::new(), + unused_only: false, + selector: None, + units: vec![ + PointSizeUnit::Lines, + PointSizeUnit::Words, + PointSizeUnit::Bytes, + ], + top: None, + } + } +} + +/// One selected unit's lead/full pair, kept in caller order (§FS-list.3.4). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListSizeMeasurement { + pub unit: PointSizeUnit, + pub lead: Option, + pub full: Option, +} + +/// One declaration or section site in the size catalog (§FS-list.2, +/// §FS-list.3.4). Unlike [`ListEntry`](crate::ListEntry), it deliberately carries no title/ref +/// fields and never collapses ambiguous sites. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListSizeEntry { + pub project: Option, + pub id: String, + pub section: Option, + /// The owning project's configured separator, used to render the text + /// coordinate without adding a wire field (§FS-list.3.4.3). + pub section_separator: String, + pub kind: String, + pub path: String, + pub line: usize, + pub stub: bool, + pub defines: Option, + pub duplicate: bool, + pub measurements: Vec, +} + +/// Structured result for one deterministic size-catalog scan (§FS-list.3.4). +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ListSizeOutput { + pub output_format: String, + pub workspace: bool, + pub entries: Vec, + pub scan_errors: Vec, + /// The run's warning channel (§FS-distribution.3.1): the three `[workspace]` + /// cautions of §FS-check.3.29.15, §FS-check.4.10.11 and + /// §FS-workspace.6.1.7. A frontend renders each as one CLI-level `warning:` + /// on stderr (§FS-check.2.1.1). + /// + /// Three keep the anchor the engine gave them — the `grund.toml` line their + /// own message already names — and an editor publishes those on that line + /// (§FS-lsp.1.1.3). §FS-check.3.29.15's is the exception: it carries no + /// location field at all, states its location inside its own text + /// (§FS-check.3.29.7), and reaches an editor off `check`'s report instead, + /// located and as an error (§FS-check.3.29.13). + pub warnings: Vec, +} diff --git a/crates/grund-core/src/queries/sizes.rs b/crates/grund-core/src/queries/sizes.rs index 557211e9b..8e3d48985 100644 --- a/crates/grund-core/src/queries/sizes.rs +++ b/crates/grund-core/src/queries/sizes.rs @@ -1,11 +1,9 @@ use anyhow::{Result, anyhow}; use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; use super::citation_counts::ListCitationCounts; use crate::config::{ - Config, PointSizeUnit, display_path, measure_point_text, non_citable_kind_error, - run_warning_findings, + Config, display_path, measure_point_text, non_citable_kind_error, run_warning_findings, }; use crate::grammar::render_id; use crate::model::{ @@ -14,95 +12,26 @@ use crate::model::{ }; use crate::resolver::{PointBodyCache, WorkspaceContext, load_workspace_context, point_body_pair}; use crate::rules::sentence::{RuleSubject, RuleVocabulary, parse_selector}; -use crate::scanner::{ApiScanError, api_scan_error}; +use crate::scanner::api_scan_error; -/// Options for the additive per-point size catalog (§FS-list.1, §FS-list.3.4). -#[derive(Clone)] -pub struct ListSizeOpts { - pub path: PathBuf, - pub path_provided: bool, - pub kind_filter: BTreeSet, - pub project_filter: BTreeSet, - pub unused_only: bool, - /// Optional declaration/chapter selector (§FS-rules.8). - pub selector: Option, - pub units: Vec, - pub top: Option, -} - -impl Default for ListSizeOpts { - fn default() -> Self { - Self { - path: PathBuf::from("."), - path_provided: false, - kind_filter: BTreeSet::new(), - project_filter: BTreeSet::new(), - unused_only: false, - selector: None, - units: vec![ - PointSizeUnit::Lines, - PointSizeUnit::Words, - PointSizeUnit::Bytes, - ], - top: None, - } - } -} - -/// One selected unit's lead/full pair, kept in caller order (§FS-list.3.4). -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ListSizeMeasurement { - pub unit: PointSizeUnit, - pub lead: Option, - pub full: Option, -} - -/// One declaration or section site in the size catalog (§FS-list.2, -/// §FS-list.3.4). Unlike [`ListEntry`](crate::ListEntry), it deliberately carries no title/ref -/// fields and never collapses ambiguous sites. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ListSizeEntry { - pub project: Option, - pub id: String, - pub section: Option, - /// The owning project's configured separator, used to render the text - /// coordinate without adding a wire field (§FS-list.3.4.3). - pub section_separator: String, - pub kind: String, - pub path: String, - pub line: usize, - pub stub: bool, - pub defines: Option, - pub duplicate: bool, - pub measurements: Vec, -} - -/// Structured result for one deterministic size-catalog scan (§FS-list.3.4). -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct ListSizeOutput { - pub output_format: String, - pub workspace: bool, - pub entries: Vec, - pub scan_errors: Vec, - /// The run's warning channel (§FS-distribution.3.1): the three `[workspace]` - /// cautions of §FS-check.3.29.15, §FS-check.4.10.11 and - /// §FS-workspace.6.1.7. A frontend renders each as one CLI-level `warning:` - /// on stderr (§FS-check.2.1.1). - /// - /// Three keep the anchor the engine gave them — the `grund.toml` line their - /// own message already names — and an editor publishes those on that line - /// (§FS-lsp.1.1.3). §FS-check.3.29.15's is the exception: it carries no - /// location field at all, states its location inside its own text - /// (§FS-check.3.29.7), and reaches an editor off `check`'s report instead, - /// located and as an error (§FS-check.3.29.13). - pub warnings: Vec, -} +pub use super::size_output::{ListSizeEntry, ListSizeMeasurement, ListSizeOpts, ListSizeOutput}; /// Programmatic point-size catalog. It selects the same declaration set as /// [`list`](crate::list), adds scanner-recorded section sites, and measures show-identical /// lead/full bodies through one per-file cache (§FS-list.2, §FS-list.3.4.1, /// §FS-workspace.8.3.3). pub fn list_sizes(opts: ListSizeOpts) -> Result { + list_sizes_with_run_warnings(opts).1 +} + +/// Additive cautions survive a later refusal (§FS-distribution.3.1). +pub fn list_sizes_with_run_warnings(opts: ListSizeOpts) -> (Vec, Result) { + let mut cautions = Vec::new(); + let result = list_sizes_run(opts, &mut cautions); + (cautions, result) +} + +fn list_sizes_run(opts: ListSizeOpts, cautions: &mut Vec) -> Result { if opts.units.is_empty() { return Err(anyhow!("at least one size unit is required")); } @@ -110,6 +39,7 @@ pub fn list_sizes(opts: ListSizeOpts) -> Result { return Err(anyhow!("top must be positive")); } let context = load_workspace_context(&opts.path, opts.path_provided)?; + *cautions = run_warning_findings(context.render_config(), context.run_warnings.clone()); validate_list_scope_filters(&context, &opts.project_filter, &opts.kind_filter)?; let selected_projects = || { context.projects.iter().filter(|project| { diff --git a/crates/grund-core/src/resolver/id_candidates.rs b/crates/grund-core/src/resolver/id_candidates.rs index 1f3550af2..63a4ff290 100644 --- a/crates/grund-core/src/resolver/id_candidates.rs +++ b/crates/grund-core/src/resolver/id_candidates.rs @@ -71,7 +71,18 @@ pub(crate) fn with_member_id_candidates( return err; } match member_id_candidate_clause(context, raw_id) { - Some(clause) => anyhow!("{message}{clause}"), + Some(clause) => { + // §FS-distribution.3.3.2: retain source classification and candidates. + if let Some(source) = err.downcast_ref::() { + let mut diagnostic = source.clone(); + diagnostic.message = format!("{message}{clause}"); + diagnostic.details = + serde_json::json!({"candidates": member_id_candidates(context, raw_id)}); + diagnostic.into() + } else { + anyhow!("{message}{clause}") + } + } None => err, } } diff --git a/crates/grund-core/src/scanner/legacy.rs b/crates/grund-core/src/scanner/legacy.rs index 84e8f8186..72bad6501 100644 --- a/crates/grund-core/src/scanner/legacy.rs +++ b/crates/grund-core/src/scanner/legacy.rs @@ -52,13 +52,20 @@ pub(crate) fn resolve_id_arg( Ok(parsed) => Ok((parsed.id, parsed.section)), Err(err) => Err(IdArgError::Unparsable(err)), }, - many => Err(IdArgError::Ambiguous(anyhow!( - "ambiguous ID: {raw} (matches {})", - many.iter() + many => { + // §FS-distribution.3.3.2: candidates are resolver data, not parsed prose. + let candidates = many + .iter() .map(|(id, _)| render_id(&config.grammar, id)) - .collect::>() - .join(", ") - ))), + .collect::>(); + let mut diagnostic = crate::model::OperationDiagnostic::new( + "query", + "ambiguous", + format!("ambiguous ID: {raw} (matches {})", candidates.join(", ")), + ); + diagnostic.details = serde_json::json!({"candidates": candidates}); + Err(IdArgError::Ambiguous(anyhow!(diagnostic))) + } } } diff --git a/crates/grund-core/src/workspace/id_arg.rs b/crates/grund-core/src/workspace/id_arg.rs index fdd0e8a5e..9974cc4a0 100644 --- a/crates/grund-core/src/workspace/id_arg.rs +++ b/crates/grund-core/src/workspace/id_arg.rs @@ -10,7 +10,7 @@ //! caller parses it with the *target* project's grammar rather than its own //! (§AR-workspace.2). -use anyhow::{Result, anyhow}; +use anyhow::Result; use crate::config::{INVALID_ALIAS_PATH_EXPECTED, invalid_alias_path_segment}; @@ -23,7 +23,10 @@ use crate::config::{INVALID_ALIAS_PATH_EXPECTED, invalid_alias_path_segment}; pub(crate) fn split_qualified_id_arg(raw: &str) -> Result<(Option, &str)> { if let Some((alias, rest)) = raw.rsplit_once('/') { if let Some(message) = invalid_alias_path_message(alias) { - return Err(anyhow!("{message}")); + // §FS-distribution.3.3.2: alias refusals retain source classification. + return Err( + crate::model::OperationDiagnostic::new("query", "query-failed", message).into(), + ); } return Ok((Some(alias.to_string()), rest)); } diff --git a/crates/grund-core/src/workspace/mod.rs b/crates/grund-core/src/workspace/mod.rs index 4709f2077..0114f4911 100644 --- a/crates/grund-core/src/workspace/mod.rs +++ b/crates/grund-core/src/workspace/mod.rs @@ -36,6 +36,7 @@ mod findings; mod id_arg; mod members; mod optional_members; +mod preflight; mod scope; mod unlisted; @@ -54,6 +55,7 @@ pub(crate) use members::AncestorWorkspaces; pub(crate) use optional_members::{ absent_only_workspace_caution, absent_optional_member_warnings, namespace_is_unverified, }; +pub(crate) use preflight::preflight_embedding_paths; pub(crate) use scope::{ apply_workspace_boundary, populate_workspace_boundary, resolve_workspace_config, scope_is_config_root, diff --git a/crates/grund-core/src/workspace/preflight.rs b/crates/grund-core/src/workspace/preflight.rs new file mode 100644 index 000000000..88333e6b2 --- /dev/null +++ b/crates/grund-core/src/workspace/preflight.rs @@ -0,0 +1,59 @@ +//! Bounded host path protection, before alias derivation (§FS-distribution.3.3.3). + +use super::members::expand_workspace_member_list; +use crate::config::{Config, load_config, load_config_at}; +use crate::model::OperationDiagnostic; +use anyhow::Result; +use std::collections::BTreeSet; +use std::path::Path; + +/// Use the actual workspace member discovery; never invent a second glob walker. +/// Existing process entrypoints do not opt into this preflight (§FS-distribution.3.1). +pub(crate) fn preflight_embedding_paths(path: &Path) -> Result<()> { + let config = load_config(path)?; + let mut seen = BTreeSet::new(); + visit(config.clone(), &mut seen)?; + // §FS-distribution.3.3.3: narrowed calls can derive aliases from claiming ancestors. + let mut claims = super::members::AncestorWorkspaces::quiet_for_run_at(&config.root); + let mut child = config.root.clone(); + for ancestor in config.root.ancestors().skip(1) { + let canonical = super::members::canonical_workspace_path(&child); + if let Ok(Some(parent)) = + claims.claiming_block(ancestor, &child, &canonical, &config.cli_base) + { + let parent = parent.clone(); + child = parent.root.clone(); + visit(parent, &mut seen)?; + } + } + Ok(()) +} + +fn visit(config: Config, seen: &mut BTreeSet) -> Result<()> { + if !config.workspace_declared || !seen.insert(config.root.clone()) { + return Ok(()); + } + // Ordinary discovery/validation refusals remain the operation's responsibility: + // they stop before alias derivation and must keep that operation's cautions. + let Ok(expanded) = expand_workspace_member_list(&config) else { + return Ok(()); + }; + for member in expanded.members { + let Ok(child) = load_config_at(&member.root, &config.cli_base) else { + continue; + }; + if !member.optional + && child.project_name.is_none() + && member.root.file_name().and_then(|n| n.to_str()).is_none() + { + return Err(OperationDiagnostic::new( + "path-encoding", + "path-encoding", + "unnamed workspace member has a non-Unicode basename; configure project_name", + ) + .into()); + } + visit(child, seen)?; + } + Ok(()) +} diff --git a/crates/grund-core/src/workspace/scope.rs b/crates/grund-core/src/workspace/scope.rs index 564c8eb49..4d23a585e 100644 --- a/crates/grund-core/src/workspace/scope.rs +++ b/crates/grund-core/src/workspace/scope.rs @@ -8,7 +8,7 @@ //! config work every walking command shares, not something `check` owns //! (§AR-core-module-layout.1). -use anyhow::{Result, anyhow}; +use anyhow::Result; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -171,7 +171,15 @@ pub(super) fn config_location_error( source: Option<&ConfigLocation>, message: String, ) -> anyhow::Error { - anyhow!("{}", config_location_message(source, message)) + // §FS-distribution.3.3.2: the config key owns the location and classification. + let mut failure = crate::model::OperationDiagnostic::new( + "config", + "config", + config_location_message(source, message), + ); + failure.path = source.map(|s| format_path(&s.path)); + failure.line = source.map(|s| s.line); + failure.into() } /// The breadcrumb every diagnostic about a config key wears — `::` diff --git a/crates/grund-core/src/writers/fetch.rs b/crates/grund-core/src/writers/fetch.rs index d35317819..2825c5e16 100644 --- a/crates/grund-core/src/writers/fetch.rs +++ b/crates/grund-core/src/writers/fetch.rs @@ -63,17 +63,41 @@ pub fn fetch_snapshot_with_run_warnings( path: &Path, ) -> (Vec, std::result::Result<(), FetchFailure>) { let mut run_warnings = Vec::new(); - let result = fetch_run(raw, path, &mut run_warnings); + let result = fetch_run(raw, path, &mut run_warnings, &mut None); (run_warnings, result) } +/// Structured source errors without changing FetchFailure (§FS-distribution.3.1). +pub(crate) fn fetch_with_diagnostics(raw: &str, path: &Path) -> (Vec, anyhow::Result<()>) { + let mut warnings = Vec::new(); + let mut diagnostic = None; + let result = fetch_run(raw, path, &mut warnings, &mut diagnostic).map_err(|failure| { + diagnostic.unwrap_or_else(|| { + crate::model::OperationDiagnostic::new( + match failure.kind { + FetchFailureKind::Query => "query", + FetchFailureKind::Operational => "operation", + }, + "fetch", + failure.message, + ) + .into() + }) + }); + (warnings, result) +} + fn fetch_run( raw: &str, path: &Path, run_warnings: &mut Vec, + diagnostic: &mut Option, ) -> std::result::Result<(), FetchFailure> { - let mut root_config = - resolve_workspace_config(path).map_err(|err| fetch_operational(format!("{err:#}")))?; + let mut root_config = resolve_workspace_config(path).map_err(|err| { + let message = format!("{err:#}"); + *diagnostic = Some(err); + fetch_operational(message) + })?; let (namespace, local) = raw .rsplit_once('/') .map_or((None, raw), |(ns, id)| (Some(ns), id)); @@ -83,8 +107,11 @@ fn fetch_run( "unknown project alias `{namespace}` for fetch" ))); } - let projects = expand_workspace_tree(&mut root_config) - .map_err(|err| fetch_operational(format!("{err:#}")))?; + let projects = expand_workspace_tree(&mut root_config).map_err(|err| { + let message = format!("{err:#}"); + *diagnostic = Some(err); + fetch_operational(message) + })?; *run_warnings = run_warning_findings(&root_config, settled_run_warnings(&root_config)); projects .into_iter() diff --git a/crates/grund-core/src/writers/init.rs b/crates/grund-core/src/writers/init.rs index e15680302..4c3a5af2c 100644 --- a/crates/grund-core/src/writers/init.rs +++ b/crates/grund-core/src/writers/init.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use super::init_block::{ AgentsUpdateResult, update_agents_block, write_or_update_canonical_agent_entrypoint, @@ -7,7 +7,7 @@ use super::init_block::{ pub(crate) use super::init_guidance::init_fs_home; use super::init_guidance::{InitNext, docs_scaffold_for_config}; use super::init_notes::{duplicate_agent_entrypoint_notes, shadowed_claude_entrypoint_note}; -use super::init_plan::{InitAgentEntrypointSelection, selected_init_agent_entrypoints}; +use super::init_plan::selected_init_agent_entrypoints; use super::init_render::{agents_workspace_members_section, init_pending_effective_config}; use super::init_target::{refuse_init_global_instruction_paths, refuse_init_target}; use crate::checker::{ @@ -24,131 +24,7 @@ use crate::templates::{ }; use crate::workspace::populate_workspace_boundary; -#[derive(Clone)] -pub struct InitOpts { - pub target: PathBuf, - /// Explicit generated project identity. When absent, `init` uses the - /// target-local configured name before the basename (§FS-init.2.3.8). - pub name: Option, - /// `--description` — pending one-line `project_description` for a freshly - /// written config (§FS-init.1, §DF-workspace-member-descriptions). - pub description: Option, - pub docs: bool, - pub force: bool, - pub dry_run: bool, - /// `--check` — the `--dry-run` preview taken as a verdict (§FS-init.1): - /// writes nothing, reports what `--dry-run` reports, and leaves the caller - /// to exit `1` when any reported event is a change (§FS-init.4.1). It implies - /// `dry_run` inside `init` rather than opening a second path through it. - pub check: bool, - /// `--no-vcs` — scaffold into a target no version-control marker covers - /// (§FS-init.1.2.3). Lifts that rule and only that one; it is not `--force`, - /// which decides whether files `init` owns get overwritten (§FS-init.3). - pub no_vcs: bool, - pub agent_selection: InitAgentEntrypointSelection, -} - -impl Default for InitOpts { - fn default() -> Self { - Self { - target: PathBuf::from("."), - name: None, - description: None, - docs: false, - force: false, - dry_run: false, - check: false, - no_vcs: false, - agent_selection: InitAgentEntrypointSelection::default(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InitEvent { - pub verb: &'static str, - pub path: String, -} - -impl InitEvent { - /// Whether this event reports work rather than a path that was already - /// current. Every verb but `exists` is a change — `wrote`/`appended`/ - /// `updated` and their `would-` forms alike. The one definition of the - /// predicate: it suppresses the `next:` block (§FS-init.2.2.2) and it decides - /// the `--check` exit code (§FS-init.4.1), which is why those two agree. - pub fn is_change(&self) -> bool { - self.verb != "exists" - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct InitOutput { - pub events: Vec, - /// Located validation findings discovered before any write (§FS-rules.4). - /// They are ordinary report rows and make `init` exit 1, not operational - /// failures that would exit 2. - pub errors: Vec, - /// Things the run could not do that the caller would otherwise have to - /// notice for itself (§FS-init.2.3.4.17.4). Reported, never fatal. - pub notes: Vec, - pub next: Option, - /// The run's warning channel (§FS-distribution.3.1): the `[workspace]` - /// cautions the walk-up settled — §FS-check.4.10's unread opted-out block - /// and §FS-workspace.6.1.7.5's undecidable ancestor claim; an absorbed scan - /// fails the expansion instead, which leaves the section out - /// (§FS-check.3.30.2). `init` expands the outermost workspace above - /// its target to teach the alias set, so it resolves a block's member - /// boundary like every other walking command and owes the reader the same - /// lines (§FS-check.2.1.1). - pub warnings: Vec, -} - -impl InitOutput { - /// Whether the run reported anything left to do — the verdict `--check` - /// draws from the report it just printed (§FS-init.4.1). Notes and the - /// `next:` block are deliberately not consulted: a note is a report, not a - /// finding. - pub fn has_pending_changes(&self) -> bool { - self.events.iter().any(InitEvent::is_change) - } - - pub fn has_errors(&self) -> bool { - !self.errors.is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InitError { - pub output: InitOutput, - pub message: String, -} - -impl InitError { - fn new(message: impl Into) -> Self { - Self { - output: InitOutput::default(), - message: message.into(), - } - } - - fn with_events(events: Vec, message: impl Into) -> Self { - Self { - output: InitOutput { - events, - ..InitOutput::default() - }, - message: message.into(), - } - } -} - -impl std::fmt::Display for InitError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.message.fmt(f) - } -} - -impl std::error::Error for InitError {} +pub use super::init_output::{InitError, InitEvent, InitOpts, InitOutput}; /// Insert the shared, entrypoint-relative chapter-rule section while leaving /// non-rule projects untouched (§FS-init.2.3.5.10). @@ -211,6 +87,29 @@ fn render_chapter_rules(mut block: String, section: Option<&str>) -> String { /// `AGENTS.md` is the canonical file — which every other agent reads too, so it /// must keep the plain form. pub fn init(opts: InitOpts) -> std::result::Result { + init_run(opts, &mut None, &mut Vec::new()) +} + +/// Keep failure sources beside partial output without changing InitError +/// (§FS-distribution.3.1, §FS-distribution.3.3.2). +pub(crate) fn init_with_diagnostics( + opts: InitOpts, +) -> ( + std::result::Result, + Option, + Vec, +) { + let mut diagnostic = None; + let mut cautions = Vec::new(); + let result = init_run(opts, &mut diagnostic, &mut cautions); + (result, diagnostic, cautions) +} + +fn init_run( + opts: InitOpts, + diagnostic: &mut Option, + cautions: &mut Vec, +) -> std::result::Result { let InitOpts { target, name, @@ -248,8 +147,13 @@ pub fn init(opts: InitOpts) -> std::result::Result { // §FS-init.2.1.1.1, §FS-init.2.3.4.17: do both before the entrypoint plan, // so each renderer consumes the same identity and effective grammar. let (mut init_config, resolved_name) = - init_pending_effective_config(&target, name.as_deref(), description.as_deref()) - .map_err(|err| InitError::new(err.to_string()))?; + init_pending_effective_config(&target, name.as_deref(), description.as_deref()).map_err( + |err| { + let message = err.to_string(); + *diagnostic = Some(err); + InitError::new(message) + }, + )?; // §FS-init.2.2.2: the guidance probe consumes the exact §AR-workspace.6 // boundary used by scanner commands. Keep this best-effort: the existing // workspace renderer owns init's diagnostics and error-tolerant behavior. @@ -259,9 +163,20 @@ pub fn init(opts: InitOpts) -> std::result::Result { // any entrypoint write, then reuse their exact titles in managed guidance. let rule_kind_enabled = init_config.kinds.iter().any(|kind| kind.rules); let (rule_rows, rule_errors, rule_findings) = if rule_kind_enabled { - let (findings, errors) = scan_tree(&init_config, Some(&target), true) - .map_err(|err| InitError::new(err.to_string()))?; + let (findings, errors) = scan_tree(&init_config, Some(&target), true).map_err(|err| { + let message = err.to_string(); + *diagnostic = Some(err); + InitError::new(message) + })?; if let Some((path, message)) = errors.first() { + // §FS-distribution.3.3.2: scanner errors carry a source path. + let mut source = crate::model::OperationDiagnostic::new( + "filesystem", + "io", + format!("{}: {message}", path.display()), + ); + source.path = Some(format_path(path)); + *diagnostic = Some(source.into()); return Err(InitError::new(format!("{}: {message}", path.display()))); } // §FS-rules.4.1: the workspace this project's own config declares, which @@ -321,6 +236,8 @@ pub fn init(opts: InitOpts) -> std::result::Result { &target, agent_entrypoints.canonical, ); + // §FS-distribution.3.3.2: later write failures retain these run cautions. + *cautions = run_warnings.clone(); // Render the base once per conversation surface (§FS-init.2.3.4.17.2). // §FS-init.2.3.5.10: the shared rule renderer adds destinations per file. let render_block = |surface| { @@ -416,6 +333,21 @@ pub fn init(opts: InitOpts) -> std::result::Result { path: rel, }), Err(err) => { + // §FS-distribution.3.3.2: classify while the original source is held. + let mut source = crate::model::OperationDiagnostic::new( + if err.downcast_ref::().is_some() { + "filesystem" + } else { + "operation" + }, + "init", + format!("update {}: {err}", format_path(&path)), + ); + source.path = Some(format_path(&path)); + if let Some(io) = err.downcast_ref::() { + source.details = serde_json::json!({"os_error":io.raw_os_error()}); + } + *diagnostic = Some(source.into()); return Err(InitError::with_events( events, // Forward slashes on every platform, like report @@ -431,12 +363,30 @@ pub fn init(opts: InitOpts) -> std::result::Result { && let Some(parent) = path.parent() && let Err(err) = fs::create_dir_all(parent) { + // §FS-distribution.3.3.2: retain source I/O data before string projection. + *diagnostic = Some( + crate::model::OperationDiagnostic::filesystem( + &parent, + &err, + format!("create {}: {err}", parent.display()), + ) + .into(), + ); return Err(InitError::with_events( events, format!("create {}: {err}", parent.display()), )); } if !dry_run && let Err(err) = fs::write(&path, entrypoint_block) { + // §FS-distribution.3.3.2: retain source I/O data before string projection. + *diagnostic = Some( + crate::model::OperationDiagnostic::filesystem( + &path, + &err, + format!("write {}: {err}", path.display()), + ) + .into(), + ); return Err(InitError::with_events( events, format!("write {}: {err}", path.display()), @@ -477,6 +427,15 @@ pub fn init(opts: InitOpts) -> std::result::Result { render_grund_toml(&resolved_name, description.as_deref()), ) { + // §FS-distribution.3.3.2: retain source I/O data before string projection. + *diagnostic = Some( + crate::model::OperationDiagnostic::filesystem( + &config_dest, + &err, + format!("write {}: {err}", config_dest.display()), + ) + .into(), + ); return Err(InitError::with_events( events, format!("write {}: {err}", config_dest.display()), @@ -508,12 +467,30 @@ pub fn init(opts: InitOpts) -> std::result::Result { && let Some(parent) = dest.parent() && let Err(err) = fs::create_dir_all(parent) { + // §FS-distribution.3.3.2: retain source I/O data before string projection. + *diagnostic = Some( + crate::model::OperationDiagnostic::filesystem( + &parent, + &err, + format!("create {}: {err}", parent.display()), + ) + .into(), + ); return Err(InitError::with_events( events, format!("create {}: {err}", parent.display()), )); } if !dry_run && let Err(err) = fs::write(&dest, contents) { + // §FS-distribution.3.3.2: retain source I/O data before string projection. + *diagnostic = Some( + crate::model::OperationDiagnostic::filesystem( + &dest, + &err, + format!("write {}: {err}", dest.display()), + ) + .into(), + ); return Err(InitError::with_events( events, format!("write {}: {err}", dest.display()), @@ -555,6 +532,14 @@ pub fn init(opts: InitOpts) -> std::result::Result { // inspect: this note is the only place the state is visible, so // dropping it on an unreadable link would report a clean run. Err((path, message)) => { + // §FS-distribution.3.3.2: inspect failures keep their known path. + let mut source = crate::model::OperationDiagnostic::new( + "filesystem", + "io", + format!("inspect {}: {message}", path.display()), + ); + source.path = Some(format_path(&path)); + *diagnostic = Some(source.into()); return Err(InitError::with_events( events, format!("inspect {}: {message}", path.display()), diff --git a/crates/grund-core/src/writers/init_output.rs b/crates/grund-core/src/writers/init_output.rs new file mode 100644 index 000000000..e3a99330d --- /dev/null +++ b/crates/grund-core/src/writers/init_output.rs @@ -0,0 +1,131 @@ +//! The init options and report vocabulary (§FS-init.1, §FS-init.4.1). +use super::init_guidance::InitNext; +use super::init_plan::InitAgentEntrypointSelection; +use crate::model::Finding; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct InitOpts { + pub target: PathBuf, + /// Explicit generated project identity. When absent, `init` uses the + /// target-local configured name before the basename (§FS-init.2.3.8). + pub name: Option, + /// `--description` — pending one-line `project_description` for a freshly + /// written config (§FS-init.1, §DF-workspace-member-descriptions). + pub description: Option, + pub docs: bool, + pub force: bool, + pub dry_run: bool, + /// `--check` — the `--dry-run` preview taken as a verdict (§FS-init.1): + /// writes nothing, reports what `--dry-run` reports, and leaves the caller + /// to exit `1` when any reported event is a change (§FS-init.4.1). It implies + /// `dry_run` inside `init` rather than opening a second path through it. + pub check: bool, + /// `--no-vcs` — scaffold into a target no version-control marker covers + /// (§FS-init.1.2.3). Lifts that rule and only that one; it is not `--force`, + /// which decides whether files `init` owns get overwritten (§FS-init.3). + pub no_vcs: bool, + pub agent_selection: InitAgentEntrypointSelection, +} + +impl Default for InitOpts { + fn default() -> Self { + Self { + target: PathBuf::from("."), + name: None, + description: None, + docs: false, + force: false, + dry_run: false, + check: false, + no_vcs: false, + agent_selection: InitAgentEntrypointSelection::default(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitEvent { + pub verb: &'static str, + pub path: String, +} + +impl InitEvent { + /// Whether this event reports work rather than a path that was already + /// current. Every verb but `exists` is a change — `wrote`/`appended`/ + /// `updated` and their `would-` forms alike. The one definition of the + /// predicate: it suppresses the `next:` block (§FS-init.2.2.2) and it decides + /// the `--check` exit code (§FS-init.4.1), which is why those two agree. + pub fn is_change(&self) -> bool { + self.verb != "exists" + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct InitOutput { + pub events: Vec, + /// Located validation findings discovered before any write (§FS-rules.4). + /// They are ordinary report rows and make `init` exit 1, not operational + /// failures that would exit 2. + pub errors: Vec, + /// Things the run could not do that the caller would otherwise have to + /// notice for itself (§FS-init.2.3.4.17.4). Reported, never fatal. + pub notes: Vec, + pub next: Option, + /// The run's warning channel (§FS-distribution.3.1): the `[workspace]` + /// cautions the walk-up settled — §FS-check.4.10's unread opted-out block + /// and §FS-workspace.6.1.7.5's undecidable ancestor claim; an absorbed scan + /// fails the expansion instead, which leaves the section out + /// (§FS-check.3.30.2). `init` expands the outermost workspace above + /// its target to teach the alias set, so it resolves a block's member + /// boundary like every other walking command and owes the reader the same + /// lines (§FS-check.2.1.1). + pub warnings: Vec, +} + +impl InitOutput { + /// Whether the run reported anything left to do — the verdict `--check` + /// draws from the report it just printed (§FS-init.4.1). Notes and the + /// `next:` block are deliberately not consulted: a note is a report, not a + /// finding. + pub fn has_pending_changes(&self) -> bool { + self.events.iter().any(InitEvent::is_change) + } + + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitError { + pub output: InitOutput, + pub message: String, +} + +impl InitError { + pub(super) fn new(message: impl Into) -> Self { + Self { + output: InitOutput::default(), + message: message.into(), + } + } + + pub(super) fn with_events(events: Vec, message: impl Into) -> Self { + Self { + output: InitOutput { + events, + ..InitOutput::default() + }, + message: message.into(), + } + } +} + +impl std::fmt::Display for InitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.message.fmt(f) + } +} + +impl std::error::Error for InitError {} diff --git a/crates/grund-core/src/writers/integrations_api.rs b/crates/grund-core/src/writers/integrations_api.rs new file mode 100644 index 000000000..3cee9b5a1 --- /dev/null +++ b/crates/grund-core/src/writers/integrations_api.rs @@ -0,0 +1,208 @@ +//! Data-returning managed integration orchestration (§FS-distribution.3.1, +//! §FS-distribution.3.3.6), using the existing owned-block splices and probes. + +use super::*; +use crate::model::{OperationDiagnostic, format_path}; +use anyhow::Result; +use serde_json::{Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(super) fn located(path: &Path, message: String) -> OperationDiagnostic { + let mut e = OperationDiagnostic::new("filesystem", "io", message); + e.path = Some(format_path(path)); + e +} +pub(super) fn tuple_error((path, message): (PathBuf, String)) -> anyhow::Error { + located(&path, message).into() +} + +pub(super) fn save(path: &Path, text: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + let diagnostic = located(parent, e.to_string()); + anyhow::Error::new(e).context(diagnostic) + })?; + } + fs::write(path, text).map_err(|e| { + let diagnostic = located(path, e.to_string()); + anyhow::Error::new(e).context(diagnostic) + })?; + Ok(()) +} + +pub(super) fn event(path: &Path, verb: &str, note: Option) -> Value { + json!({"path":format_path(path),"verb":verb,"note":note}) +} + +/// Both inspection and installation are engine operations. No output stream, +/// process arguments, or cwd mutation is involved (§FS-distribution.3.3.4). +pub(crate) fn integrations_data( + client: &str, + write: bool, + conversation: Option<&str>, + target: Option<&str>, + agent: Option<&str>, +) -> Result { + let client = if client.is_empty() { + None + } else { + Some(IntegrationClient::from_name(client).ok_or_else(|| { + OperationDiagnostic::new("operation", "invalid-client", "unknown integration client") + })?) + }; + let conversation = conversation + .map(|s| { + ConversationRendering::from_name(s).ok_or_else(|| { + OperationDiagnostic::new( + "operation", + "invalid-preference", + "unknown conversation preference", + ) + }) + }) + .transpose()?; + let target = target + .map(|s| { + ConversationTarget::from_name(s).ok_or_else(|| { + OperationDiagnostic::new( + "operation", + "invalid-preference", + "unknown conversation target", + ) + }) + }) + .transpose()?; + let agent = agent + .map(|s| { + known_agent(s).ok_or_else(|| { + OperationDiagnostic::new("operation", "invalid-agent", "unknown agent") + }) + }) + .transpose()?; + if (!write && (conversation.is_some() || target.is_some() || agent.is_some())) + || (agent.is_some() && target.is_none()) + || (write && client.is_none() && conversation.is_none() && target.is_none()) + { + return Err(OperationDiagnostic::new( + "operation", + "invalid-preference", + "invalid integration preference combination", + ) + .into()); + } + let detected = detect_clients(); + let clients = IntegrationClient::ALL + .iter() + .map(|c| { + json!({"client":c.name(), + "detected":detected.contains(c),"installed":integration_is_current(*c), + "install_kind":c.install_kind().name(),"install":c.install_command(), + "config_target":c.config_target()}) + }) + .collect::>(); + let artifact = client.map(|c| { + json!({"client":c.name(),"snippet":c.snippet(), + "resolver":if c.is_terminal(){Some(GRUND_OPEN_RESOLVER)}else{None}, + "package_json":if c.is_terminal(){None}else{Some(VSCODE_PACKAGE_JSON)}, + "extension_js":if c.is_terminal(){None}else{Some(VSCODE_EXTENSION_JS)}}) + }); + let mut events = Vec::new(); + let mut manual_steps = Vec::new(); + let mut cautions = Vec::new(); + if write { + // §FS-integrations.4.3.7: read user configuration before any artifact write. + let guidance = super::integrations_guidance::load()?; + cautions = guidance.cautions.clone(); + let result: Result<()> = (|| { + if let Some(client) = client { + install(client, &mut events, &mut manual_steps)?; + } + super::integrations_guidance::install( + guidance, + conversation, + target, + agent, + &mut events, + )?; + Ok(()) + })(); + if let Err(error) = result { + let context = crate::model::OperationContext { + message: error.to_string(), + cautions: json!(cautions), + partial_output: json!({"events":events, "manual_steps":manual_steps}), + }; + return Err(error.context(context)); + } + } + Ok( + json!({"detected":detected.iter().map(|c|c.name()).collect::>(),"clients":clients, + "artifact":artifact,"events":events,"manual_steps":manual_steps,"run_cautions":cautions}), + ) +} + +fn install( + client: IntegrationClient, + events: &mut Vec, + manual_steps: &mut Vec, +) -> Result<()> { + match client.install_kind() { + InstallKind::Manual => manual_steps.push(client.snippet().unwrap_or("").to_owned()), + InstallKind::Block => { + let path = expand_target(client.config_target()).ok_or_else(|| { + located( + Path::new(client.config_target()), + "cannot resolve home directory".into(), + ) + })?; + let existing = read_optional_text(&path).map_err(tuple_error)?; + let fresh = existing.is_empty(); + let (mut updated, outcome) = install_managed_block( + client.comment_prefix(), + client.prepends_block(), + &existing, + client.snippet().unwrap_or(""), + ) + .map_err(|e| located(&path, e))?; + if fresh && let Some(scaffold) = client.fresh_config_scaffold() { + updated.push_str(scaffold); + } + if outcome != BlockOutcome::Unchanged { + save(&path, &updated)?; + } + let note = needs_wezterm_wiring(client, &updated) + .then(|| format!("add {WEZTERM_APPLY_CALL}config) where you build your config")); + events.push(event(&path, block_outcome_verb(outcome), note)); + } + InstallKind::Vscode => { + let dir = expand_target(client.config_target()).ok_or_else(|| { + located( + Path::new(client.config_target()), + "cannot resolve home directory".into(), + ) + })?; + let exists = dir.join(".grund-version").is_file(); + if vscode_integration_is_current(&dir) { + events.push(event(&dir, "exists", None)); + return Ok(()); + } + for (name, body) in [ + ("package.json", VSCODE_PACKAGE_JSON.to_owned()), + ("extension.js", VSCODE_EXTENSION_JS.to_owned()), + ( + ".grund-version", + crate::grammar::INTEGRATIONS_BLOCK_VERSION.to_string(), + ), + ] { + save(&dir.join(name), &body)?; + } + events.push(event(&dir, if exists { "updated" } else { "wrote" }, None)); + return Ok(()); + } + } + if let Some(path) = write_resolver_script().map_err(tuple_error)? { + events.push(event(&path, "wrote", None)); + } + Ok(()) +} diff --git a/crates/grund-core/src/writers/integrations_guidance.rs b/crates/grund-core/src/writers/integrations_guidance.rs new file mode 100644 index 000000000..aaeb83db8 --- /dev/null +++ b/crates/grund-core/src/writers/integrations_guidance.rs @@ -0,0 +1,156 @@ +//! User preferences, agent gates and managed ownership (§FS-distribution.3.3.6). + +use super::integrations_api::{event, located, save, tuple_error}; +use super::*; +use anyhow::Result; +use serde_json::{Value, json}; +use std::path::PathBuf; + +pub(super) struct Guidance { + path: PathBuf, + text: String, + scan: UserConfigScan, + pub(super) cautions: Vec, +} + +pub(super) fn load() -> Result { + let path = user_grund_config_path().ok_or_else(|| { + located( + std::path::Path::new(USER_CONFIG_TARGET), + "cannot resolve user configuration directory".into(), + ) + })?; + let text = read_optional_text(&path).map_err(tuple_error)?; + let scan = scan_user_config(&text); + let cautions = scan + .problems + .iter() + .map(|(line, message)| { + json!({"severity":"warning","channel":null, + "code":"user-config","path":crate::model::format_path(&path),"line":line,"column":null, + "message":message,"sites":[],"authority":[]}) + }) + .collect(); + Ok(Guidance { + path, + text, + scan, + cautions, + }) +} + +pub(super) fn install( + g: Guidance, + requested: Option, + requested_target: Option, + scoped_agent: Option<&str>, + events: &mut Vec, +) -> Result<()> { + let Guidance { + path, + text, + mut scan, + .. + } = g; + let effective = requested + .or(scan.preference) + .unwrap_or(ConversationRendering::Plain); + let machine_target = if scoped_agent.is_some() { + scan.target.unwrap_or_default() + } else { + requested_target.or(scan.target).unwrap_or_default() + }; + let (updated, first) = install_reference_key( + &text, + "reference", + "conversation", + effective.name(), + scan.preference == Some(effective), + ); + let (mut updated, second) = install_reference_key( + &updated, + "reference", + "conversation_target", + machine_target.name(), + scan.target == Some(machine_target), + ); + let mut outcome = merge_outcomes(first, second); + if let (Some(agent), Some(target)) = (scoped_agent, requested_target) { + let previous = scan + .agent_targets + .iter() + .find(|(name, _)| name == agent) + .map(|(_, value)| *value); + let (text, result) = install_reference_key( + &updated, + &agent_override_table(agent), + "conversation_target", + target.name(), + previous == Some(target), + ); + updated = text; + outcome = merge_outcomes(outcome, result); + if let Some(entry) = scan + .agent_targets + .iter_mut() + .find(|(name, _)| name == agent) + { + entry.1 = target; + } else { + scan.agent_targets.push((agent.into(), target)); + } + } + // §FS-integrations.4.3.8: plan every instruction splice before writing any guidance. + let mut plans = vec![(path, Some((updated, outcome)), None)]; + for target in GLOBAL_AGENT_INSTRUCTION_TARGETS { + let path = expand_target(target.file).ok_or_else(|| { + located( + std::path::Path::new(target.file), + "cannot resolve home directory".into(), + ) + })?; + let in_use = expand_target(target.home).is_some_and(|p| p.is_dir()) || path.is_file(); + if !in_use { + plans.push((path, None, Some(format!("no {}", target.home)))); + continue; + } + let overridden = scan + .agent_targets + .iter() + .find(|(name, _)| name == target.agent) + .map(|(_, value)| *value); + let requested = overridden.unwrap_or(machine_target); + let gated = target.link_support.resolve(requested); + let existing = read_optional_text(&path).map_err(tuple_error)?; + let (updated, outcome) = install_agent_guidance_block(&existing, effective, gated) + .map_err(|e| located(&path, e))?; + let note = if effective == ConversationRendering::Plain { + "plain".to_owned() + } else { + format!( + "link → {}{}", + gated.name(), + if gated != requested { + "; unverified here" + } else if overridden.is_some() { + "; agent override" + } else { + "" + } + ) + }; + plans.push((path, Some((updated, outcome)), Some(note))); + } + for (path, write, note) in plans { + let verb = if let Some((updated, outcome)) = write { + if outcome != BlockOutcome::Unchanged { + save(&path, &updated)?; + } + block_outcome_verb(outcome) + } else { + "skipped" + }; + events.push(event(&path, verb, note)); + } + Ok(()) +} diff --git a/crates/grund-core/src/writers/mod.rs b/crates/grund-core/src/writers/mod.rs index db50e4ae9..d15271173 100644 --- a/crates/grund-core/src/writers/mod.rs +++ b/crates/grund-core/src/writers/mod.rs @@ -85,13 +85,16 @@ mod init; mod init_block; mod init_guidance; mod init_notes; +mod init_output; mod init_plan; mod init_render; mod init_target; mod init_workspace_members; mod integrations_agents; +mod integrations_api; mod integrations_clients; mod integrations_detect; +mod integrations_guidance; mod integrations_install; mod integrations_user_config; @@ -170,3 +173,12 @@ mod tests_local_section_citations; mod tests_open_resolver; #[cfg(test)] mod tests_workspace_members; + +// §FS-distribution.3.1: additive managed-install orchestration. +pub(crate) use integrations_api::integrations_data; + +// §FS-distribution.3.1: preserve init's source error beside its partial output. +pub(crate) use init::init_with_diagnostics; + +// §FS-distribution.3.1: additive structured fetch failures. +pub(crate) use fetch::fetch_with_diagnostics; diff --git a/crates/grund-py/Cargo.toml b/crates/grund-py/Cargo.toml new file mode 100644 index 000000000..8ab9045b3 --- /dev/null +++ b/crates/grund-py/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "grund-py" +description = "Thin CPython frontend over the grund-core engine" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib", "rlib"] + +[dependencies] +grund-core = { path = "../grund-core" } +pyo3 = { version = "0.27", features = ["abi3-py310"] } +serde_json = "1" + +[build-dependencies] +pyo3-build-config = "0.27" + +[features] +extension-module = ["pyo3/extension-module"] diff --git a/crates/grund-py/README.md b/crates/grund-py/README.md new file mode 100644 index 000000000..e601d148f --- /dev/null +++ b/crates/grund-py/README.md @@ -0,0 +1,59 @@ +# grund Python frontend + +The local Python API embeds `grund-core` through PyO3, as specified by +[§FS-distribution.3.3](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-distribution.md#33-python-grund-pypi-package). +PyPI publication is pending. Build/install from the repository root: + +```sh +python -m pip install . +``` + +See the [Python API guide](https://github.com/agent-grounds/grund/blob/main/docs/user-facing/python-api.md) +for runnable examples and signatures. Calls are synchronous, silent and return +immutable typed results. Completed checks return normally even with errors; +operational refusals carry structured exceptions. + +## Source and ABI handoff + +The root `pyproject.toml` uses maturin's mixed package layout. `python/grund` +contains public functions, frozen dataclasses and `py.typed`; the private native +library is `grund._native`. Distribution and import names are both `grund`. +The extension uses `abi3-py310` and requires CPython 3.10+ with the GIL. Its build +script refuses PyPy, GraalPy and free-threaded interpreters. Normal Cargo builds +default to the CLI and have no Python dependency. + +The `extension-module` Cargo feature is enabled by maturin rather than ordinary +Cargo workspace builds, so Cargo can also link its normal targets. The sdist +includes workspace manifests/lockfile, required Rust source/assets, integration +workspace metadata/source, Python modules/types and the MIT licence. Build with +`python -m build --sdist`; install the unpacked archive with `python -m pip install .`. +No source path outside that archive is required. + +Issue #471 owns the release wheel matrix, prebuilt CLI payload and publication. +This frontend adds no console script. A future `grund` executable can coexist +with the public package directory and private `_native` library. `grund-lsp` +remains a separate installation. No registry credentials, tags or release +dispatch are needed for this local handoff. + +## Engine transition + +Carry **Adapt Python marshalling to #466/#453/#454**: replace internal Rust +adapters when that core transition lands, preserve the approved Python schema, +and rerun the complete parity corpus. The additive `EmbeddingRequest`/envelope +surface uses schema fields instead of exposing Config or Findings layouts. +Node (#469) adds its adapter to the same corpus; current parity infrastructure +targets Rust/Python and does not claim Node has been implemented or checked. + +## Checking locally + +After installing the checkout into the checking interpreter: + +```sh +cargo build -p grund-core --example grund-binding-oracle --target-dir target +cp target/debug/examples/grund-binding-oracle target/debug/grund-binding-oracle +python tests/bindings/run.py +``` + +On Windows copy the corresponding `.exe`. The core-only oracle has independent +canonical encoding and frozen CLI projection. The checking step also owns +clean-checkout/sdist installs and the complete repository gate. diff --git a/crates/grund-py/build.rs b/crates/grund-py/build.rs new file mode 100644 index 000000000..84ed6a389 --- /dev/null +++ b/crates/grund-py/build.rs @@ -0,0 +1,16 @@ +//! Enforce the approved interpreter/ABI boundary (§FS-distribution.3.3.7). + +fn main() { + let config = pyo3_build_config::get(); + assert!( + matches!( + config.implementation, + pyo3_build_config::PythonImplementation::CPython + ), + "grund supports CPython 3.10+ only" + ); + assert!( + !config.is_free_threaded(), + "grund requires CPython with the GIL" + ); +} diff --git a/crates/grund-py/src/lib.rs b/crates/grund-py/src/lib.rs new file mode 100644 index 000000000..17fe415f5 --- /dev/null +++ b/crates/grund-py/src/lib.rs @@ -0,0 +1,53 @@ +//! Python conversion only; all behavior is core-owned (§AR-bindings.6). + +use grund_core::{EmbeddingRequest, embedding_call}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// The private native transport releases the GIL for the entire engine call +/// and delivers pending interrupts when it returns (§FS-distribution.3.3.4). +#[pyfunction] +fn call( + py: Python<'_>, + operation: String, + root: String, + explicit: bool, + args: String, + options: String, +) -> PyResult { + let request = EmbeddingRequest { + operation, + root: root.into(), + explicit, + args: serde_json::from_str(&args).map_err(|e| PyValueError::new_err(e.to_string()))?, + options: serde_json::from_str(&options) + .map_err(|e| PyValueError::new_err(e.to_string()))?, + }; + let result = py.detach(move || embedding_call(request)); + py.check_signals()?; + Ok(result.to_string()) +} + +/// Reuse engine vocabularies for host-side option validation (§FS-distribution.3.3.5). +#[pyfunction] +fn valid_option(name: &str, value: &str) -> bool { + match name { + "code" => grund_core::CHECK_FINDING_CODES + .binary_search(&value) + .is_ok(), + "client" => grund_core::IntegrationClient::from_name(value).is_some(), + "conversation" => grund_core::ConversationRendering::from_name(value).is_some(), + "conversation_target" => grund_core::ConversationTarget::from_name(value).is_some(), + "agent" => grund_core::known_agent(value).is_some(), + _ => false, + } +} + +/// Private extension, deliberately without a competing CLI entrypoint +/// (§FS-distribution.3.3.7). +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(call, module)?)?; + module.add_function(wrap_pyfunction!(valid_option, module)?)?; + Ok(()) +} diff --git a/docs/file-size-agent-exceptions.toml b/docs/file-size-agent-exceptions.toml index f085aeba0..407577086 100644 --- a/docs/file-size-agent-exceptions.toml +++ b/docs/file-size-agent-exceptions.toml @@ -67,10 +67,10 @@ path = "crates/grund-core/src/config/parse.rs" match = "exact" rules = ["core-source"] kind = "deferred" -max_accepted = { value = 450, unit = "lines" } +max_accepted = { value = 453, unit = "lines" } until = "extract the parser with its state made explicit, leaving discovery here" reason = """ -`parse_config_file` is a hand-written TOML reader: one line-oriented state machine over section and key context, correct only because every branch observes the same state. Splitting it by key group would thread that state through a wider interface, trading a long function for a worse one. Missing boundary: the parser as its own module with its state made explicit, leaving discovery here. This is the argument the hard entry in docs/file-size-human-exceptions.toml carried at 732 lines while the whole config category was one file; that entry retired when config became a Rust module (§AR-system.2.3) took the file to 499, under the hard budget. What left: the `[citations]` section for config/citations.rs with the rule records it fills, beside the `[[kinds]]` and grounding sections that were already their own files, and the `[scan]` defaults for config/record.rs beside the `Config` they start. The `[workspace]` section followed when workspace became a module (§AR-system.2.4) and the block's entry grammar came down to the reader that validates it — both member lists, the alias slug predicate and one entry point the reader calls, in config/workspace_block.rs — which took the file 499 -> 447. The `[fmt]` section went the same way when the writers became a module (§AR-system.2.8): the `exclude` glob compiler and the validator this reader refuses a malformed pattern with are config/fmt_block.rs, read downward instead of up through the crate root, which costs this file one import line and moves the ceiling 447 -> 448. The `inline_style` rejection then came to name its value and the accepted set (§FS-config.3.1.8), in the `format!` form its sibling enums use, which rustfmt wraps over three lines where the bare string took one, and that moves the ceiling 448 -> 450. Nothing of the state machine left, which is why the boundary is still named here. +`parse_config_file` is a hand-written TOML reader: one line-oriented state machine over section and key context, correct only because every branch observes the same state. Splitting it by key group would thread that state through a wider interface, trading a long function for a worse one. Missing boundary: the parser as its own module with its state made explicit, leaving discovery here. This is the argument the hard entry in docs/file-size-human-exceptions.toml carried at 732 lines while the whole config category was one file; that entry retired when config became a Rust module (§AR-system.2.3) took the file to 499, under the hard budget. What left: the `[citations]` section for config/citations.rs with the rule records it fills, beside the `[[kinds]]` and grounding sections that were already their own files, and the `[scan]` defaults for config/record.rs beside the `Config` they start. The `[workspace]` section followed when workspace became a module (§AR-system.2.4) and the block's entry grammar came down to the reader that validates it — both member lists, the alias slug predicate and one entry point the reader calls, in config/workspace_block.rs — which took the file 499 -> 447. The `[fmt]` section went the same way when the writers became a module (§AR-system.2.8): the `exclude` glob compiler and the validator this reader refuses a malformed pattern with are config/fmt_block.rs, read downward instead of up through the crate root, which costs this file one import line and moves the ceiling 447 -> 448. The `inline_style` rejection then came to name its value and the accepted set (§FS-config.3.1.8), in the `format!` form its sibling enums use, which rustfmt wraps over three lines where the bare string took one, and that moves the ceiling 448 -> 450. Nothing of the state machine left, which is why the boundary is still named here. The additive structured configuration diagnostic (§FS-distribution.3.3.2) adds three net code lines at the existing bail_config source, now also used for integers; the exception rises 450 -> 453 to preserve the parser state machine and existing CLI text rather than split branches for the binding. """ [[exceptions]] path = "scripts/local-benchmark-report.py" diff --git a/docs/user-facing/README.md b/docs/user-facing/README.md index ca840e897..38b917393 100644 --- a/docs/user-facing/README.md +++ b/docs/user-facing/README.md @@ -8,6 +8,7 @@ command's own `--help` page links the guide and example that cover it | Guide | Example | |---|---| +| [Python API](python-api.md) | [`examples/python-api`](../../examples/python-api/) | | [Citation directions](citation-directions.md) | — | | [Clickable citations](clickable-citations.md) | — | | [Coordinate sizes](coordinate-sizes.md) | — | diff --git a/docs/user-facing/python-api.md b/docs/user-facing/python-api.md new file mode 100644 index 000000000..f65cc4614 --- /dev/null +++ b/docs/user-facing/python-api.md @@ -0,0 +1,127 @@ +# Embed grund from Python + +Build the local extension from a checkout with CPython 3.10+ and Rust: + +```sh +python -m pip install . +``` + +The Python API is supplied locally; PyPI publication and release wheels remain +pending ([§FS-distribution.3.3.7](../functional-spec/FS-distribution.md#337-local-source-and-typing-handoff)). +This runnable example uses the repository's existing erroneous-citation fixture: + +```python +from grund import check, show + +repo = "tests/e2e/cases/json-report/repo" +result = check(repo) +for finding in result.report: + print(finding.code, finding.line) # dangling 3 +assert "FS-999-missing" in show("FS-001-alpha", root=repo, mode="brief").body +``` + +The API prints nothing itself. A completed check returns `CheckResult` even +when its report has findings. `report` is the complete report; `selected_report` +applies selectors. Both contain errors, warnings and suggestions tuples. +Iteration yields those groups in order. Findings retain severity/channel, code, +nullable path/line/column, message, every site, and rule authority. Suggestions +have channel `suggestion` and severity None. `run_cautions` carries separate +run warnings; it is never folded into the report +([§FS-distribution.3.3.1](../functional-spec/FS-distribution.md#331-typed-immutable-results)). + +## Functions and defaults + +Required operands are positional. Other options are keywords. Every disk-tree +operation below also accepts `root=None`, except `scan(root)` (required) and +`init(target=None)`. `check` accepts root positionally. `integrations` is +user-global and setup instructions have no root. The callable definitions and +inline annotations are in `python/grund`; `py.typed` marks them for type checkers. +The full inventory implements +[§FS-distribution.3.3.5](../functional-spec/FS-distribution.md#335-complete-initial-inventory). + +| Function (besides common root) | Returned record | +| --- | --- | +| `check(root=None, *, require_grounding=False, suggestions=False, full=False, rule=None, only=(), ignore=(), only_rule=False)` | `CheckResult`: reports, scan-error status, output format, cautions | +| `scan(root)` | `ScanResult`: catalog, citations, scanned files, scan errors, cautions | +| `show(id, *, section=None, mode="lead", format="text")` | `ShowResult`: body, logical path, line, nullable rendered JSON, sections, cautions | +| `show_batch(queries=None, *, mode="lead")` | `BatchResult`: ordered records with query, nullable result/failure, cautions | +| `refs(id, *, section=None, descendants=False)` | `RefsResult`: hits, scan errors, note, kind title, file summaries, site/file totals, cautions | +| `list_ids(*, kinds=(), projects=(), unused=False, selector=None)` | `ListResult`: entries, kind summaries, scan errors, cautions | +| `list_sizes(*, kinds=(), projects=(), unused=False, selector=None, units=("lines","words","bytes"), top=None)` | `SizesResult`: ordered lead/full measurements, scan errors, cautions | +| `cover(*, text=False)` | `CoverResult` or `CoverTextResult`: file-grouped coverage, scan errors, cautions | +| `fmt(*, write=False, marker=False, cross_refs=False)` | `FmtResult`: changes, scan errors, refused writes, cautions | +| `propose_id(kind, title, *, width=3)` | `IdProposal`: ID, kind, nullable number/home hints, slug, cautions | +| `init(target=None, *, name=None, description=None, docs=False, force=False, write=False, check=False, no_vcs=False, agents=None)` | `InitResult`: events, errors, notes, nullable next guidance, pending-change status, cautions | +| `effective_config()` / `validate_config()` | `ConfigResult`: effective schema mapping, config warnings, cautions | +| `fetch(id, *, write=False)` | `FetchResult`: completion and cautions; requires opt-in | +| `integrations(client=None, *, write=False, conversation=None, conversation_target=None, agent=None)` | `IntegrationsResult`: detection, client descriptors, nullable artifact, events, manual steps, cautions | +| `complete_ids(prefix="", *, sections=False)` | `CompletionResult`: candidates, cautions | +| `reference_style()` | `ReferenceStyle`: marker, typing trigger, cautions | +| `agent_setup_instructions()` | `SetupInstructions`: canonical text, cautions | + +Results are frozen dataclasses, collections are tuples, and optional fields are +present as None. Effective config, failure details and partial opaque payloads +are recursively read-only mappings. `ShowQuery(id, section=None)` is frozen. +Batch queries accept strings or ShowQuery records; None discovers every +coordinate, while an empty sequence succeeds without loading config. +Batch success records contain the complete ShowResult, with JSON supplied by +the engine's batch read; each unsuccessful query retains a Failure in its record. + +`mode` accepts lead/brief/toc/full; `format` accepts text/md/json. Filters and +selectors take string sequences. Size units are lines/words/bytes, top is +positive and width is non-negative. Ignore wins over only; `only_rule` requires +rule. Selectors never suppress safety `io` findings. All resolution, scope, +scan exclusions and kind overrides are engine decisions. + +Shell scripts, stdin/NDJSON, process/watch lifecycle, exit codes and LSP +transport remain process frontend concerns. Overlay/on-type/hover utilities +are outside this initial disk-backed API. + +## Failures, paths and concurrency + +`GrundError` has `ConfigError`, `FilesystemError`, `QueryError` and fallback +`OperationError` subclasses. `.failure` retains kind, code/message, nullable +location, sites, authority, causes, cautions, partial output and details such as +candidates or OS error codes. Single unsuccessful queries raise QueryError; +batch query failures continue in records, while a batch setup failure raises +once. Unknown ID kinds raise OperationError and rejected ID proposals raise +QueryError. Wrong Python types raise TypeError; invalid options raise ValueError +([§FS-distribution.3.3.2](../functional-spec/FS-distribution.md#332-operational-failures-and-invalid-arguments)). + +Paths accept str or os.PathLike[str]. Omitted root snapshots cwd; explicit files +and directories retain explicit engine scope. Bytes raise TypeError, surrogate +paths raise PathEncodingError (a ValueError), and NUL raises ValueError before +work. Logical result paths use `/`. The core preflight also refuses the known +unsafe unnamed non-Unicode workspace alias before deriving it; setting an +explicit project_name can make that member representable. Calls do not change +cwd, read process argv or exit the interpreter +([§FS-distribution.3.3.3](../functional-spec/FS-distribution.md#333-per-call-scope-and-path-encoding)). + +Calls are synchronous and release the GIL during Rust work. Independent reads +may overlap. Serialize writers to the same files. Pending interrupts are checked +on return: the operation may finish before KeyboardInterrupt arrives. No async +API, mid-call cancellation, extra rollback or arbitrary panic recovery is +promised. CPython with the GIL is required; PyPy and free-threaded builds are +unsupported ([§FS-distribution.3.3.4](../functional-spec/FS-distribution.md#334-silent-synchronous-calls)). + +## Writes and integrations + +`fmt()` previews. `fmt(write=True)` applies managed rewrites; config-enabled +cross-reference formatting still applies when cross_refs=False. `init()` +previews, `init(write=True)` scaffolds, and `init(check=True)` suppresses writes +even if write=True. Force never replaces existing config. Fetch has no preview: +`fetch(id, write=True)` explicitly authorizes materialization; omission refuses +before loading config or executing a fetcher. Reads execute no fetcher +([§FS-distribution.3.3.6](../functional-spec/FS-distribution.md#336-explicit-mutation-opt-ins)). + +`integrations()` inspects detected clients. A client operand returns its +artifact. Writes use existing managed ownership and agent-in-use gates, retain +manual instructions, and preserve manual text. Clients, preferences and agents +use the engine's current vocabularies; invalid combinations refuse before +writing. A scoped agent requires a conversation_target. Conversation options +require write=True; a clientless write requires a conversation option. + +The [runnable example](../../examples/python-api/) and +[native source handoff](../../crates/grund-py/README.md) accompany this guide. +The parity corpus targets Rust/Python; Node joins through #469. Local compilation +alone does not establish passing parity or clean-source install evidence. diff --git a/examples/README.md b/examples/README.md index 6848a525f..bbf778272 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,6 +35,10 @@ summarizes when to reach for each. | [`values/`](values/) | Markdown/JSON value declarations and explicit consistency bindings ([§FS-values](../docs/functional-spec/FS-values.md#fs-values-opted-in-kinds-bind-authored-components-to-one-declared-value)) | | [`external-tickets/`](external-tickets/) | Explicitly materialized external facts resolved from committed snapshots ([§FS-fetch](../docs/functional-spec/FS-fetch.md#fs-fetch-grund-materializes-one-external-fact-snapshot)) | +The [Python API workflow](python-api/) embeds the same engine using a local +extension installation ([§FS-distribution.3.3.7](../docs/functional-spec/FS-distribution.md#337-local-source-and-typing-handoff)); its native acceptance +coverage is in the shared binding corpus. + ## Run an example From the repo root, with a built `grund` binary on `$PATH` (or invoked diff --git a/examples/python-api/README.md b/examples/python-api/README.md new file mode 100644 index 000000000..35b0fd166 --- /dev/null +++ b/examples/python-api/README.md @@ -0,0 +1,18 @@ +# Read grounding data in Python + +This workflow embeds the engine through the locally installed Python package +([§FS-distribution.3.3.7](../../docs/functional-spec/FS-distribution.md#337-local-source-and-typing-handoff)). +From the repository root: + +```sh +python -m pip install . +python examples/python-api/read_fixture.py +``` + +Expected output is `dangling 3`. The script reads the existing json-report +fixture, iterates its immutable report and shows the declaration's brief body. +It changes no file. Its coverage lives in the shared binding acceptance corpus, +which also checks native parity and the local-source build contract. +See the [Python API guide](../../docs/user-facing/python-api.md) for defaults, +structured exceptions, threading and explicit mutation options. PyPI publication +remains pending. diff --git a/examples/python-api/read_fixture.py b/examples/python-api/read_fixture.py new file mode 100644 index 000000000..eba9f670a --- /dev/null +++ b/examples/python-api/read_fixture.py @@ -0,0 +1,11 @@ +"""Runnable check, iteration and show (§FS-distribution.3.3.7).""" + +from pathlib import Path +from grund import check, show + +repo = Path(__file__).resolve().parents[2] / "tests/e2e/cases/json-report/repo" +result = check(repo) +assert [(finding.code, finding.line) for finding in result.report] == [("dangling", 3)] +for finding in result.report: + print(finding.code, finding.line) +assert "FS-999-missing" in show("FS-001-alpha", root=repo, mode="brief").body diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..47f0fdd09 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +# Local mixed package and #471 source handoff (§FS-distribution.3.3.7). +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[project] +name = "grund" +version = "0.16.2.dev0" +requires-python = ">=3.10" +description = "Embed the grund documentation grounding engine in Python" +readme = "crates/grund-py/README.md" +license = "MIT" +classifiers = ["Programming Language :: Python :: Implementation :: CPython"] + +[tool.maturin] +manifest-path = "crates/grund-py/Cargo.toml" +python-source = "python" +module-name = "grund._native" +features = ["extension-module"] +include = [ + { path = "Cargo.toml", format = "sdist" }, + { path = "Cargo.lock", format = "sdist" }, + { path = "crates/**/*", format = "sdist" }, + { path = "tests/integration/**/*", format = "sdist" }, + { path = "python/**/*", format = "sdist" }, + { path = "LICENSE", format = "sdist" }, +] diff --git a/python/grund/__init__.py b/python/grund/__init__.py new file mode 100644 index 000000000..f4eb605b5 --- /dev/null +++ b/python/grund/__init__.py @@ -0,0 +1,9 @@ +"""Embed grund locally; PyPI publication is pending (§FS-distribution.3.3.7).""" + +from ._api import (agent_setup_instructions, check, complete_ids, cover, + effective_config, fetch, fmt, init, integrations, list_ids, + list_sizes, propose_id, reference_style, refs, scan, show, + show_batch, validate_config) +from .errors import (ConfigError, FilesystemError, GrundError, OperationError, + PathEncodingError, QueryError) +from .types import * diff --git a/python/grund/_api.py b/python/grund/_api.py new file mode 100644 index 000000000..ca7831998 --- /dev/null +++ b/python/grund/_api.py @@ -0,0 +1,264 @@ +"""Disk-backed synchronous API (§FS-distribution.3.3.5).""" + +import json +import os +from collections.abc import Sequence +from typing import Any + +from . import _native +from ._convert import convert +from .errors import (ConfigError, FilesystemError, OperationError, QueryError, + PathEncodingError) +from .types import (BatchResult, CheckResult, CompletionResult, ConfigResult, + CoverResult, CoverTextResult, Failure, FetchResult, FmtResult, + IdProposal, InitResult, IntegrationsResult, ListResult, SizesResult, ReferenceStyle, + RefsResult, ScanResult, SetupInstructions, ShowQuery, ShowResult) + +PathInput = str | os.PathLike[str] + + +def _path(value: PathInput | None) -> tuple[str, bool]: + """Snapshot cwd once and reject invalid encodings (§FS-distribution.3.3.3).""" + explicit = value is not None + cwd = os.getcwd() + text = cwd if value is None else os.fspath(value) + if not isinstance(text, str): + raise TypeError("paths must be str or os.PathLike[str]") + if any(0xD800 <= ord(c) <= 0xDFFF for c in text): + raise PathEncodingError("paths must contain Unicode scalar values") + if "\0" in text: + raise ValueError("paths must not contain NUL") + return os.path.normpath(os.path.join(cwd, text)), explicit + + +def _str(value: Any, name: str, optional: bool = False) -> None: + if optional and value is None: + return + if not isinstance(value, str): + raise TypeError(f"{name} must be str") + if any(0xD800 <= ord(c) <= 0xDFFF for c in value): + raise ValueError(f"{name} must contain Unicode scalar values") + if "\0" in value: + raise ValueError(f"{name} must not contain NUL") + + +def _strings(value: Any, name: str) -> tuple[str, ...]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{name} must be a sequence of strings") + for item in value: + _str(item, name) + return tuple(value) + + +def _call(operation: str, cls: type, root: PathInput | None, + args: tuple = (), **options: Any) -> Any: + """Conversion only; engine outcomes choose exceptions (§FS-distribution.3.3.2).""" + path, explicit = ("", False) if operation in ("integrations", "agent_setup_instructions") else _path(root) + for name, value in options.items(): + if name in ("write", "check", "docs", "force", "no_vcs", "suggestions", "full", + "require_grounding", "only_rule", "unused", "sections", "text", + "marker", "cross_refs", "descendants") and type(value) is not bool: + raise TypeError(f"{name} must be bool") + if name in ("section", "rule", "selector", "name", "description", + "conversation", "conversation_target", "agent"): + _str(value, name, optional=True) + envelope = json.loads(_native.call(operation, path, explicit, + json.dumps(args), json.dumps(options))) + if envelope["failure"] is not None: + failure = convert(Failure, envelope["failure"]) + if failure.kind == "path-encoding": + raise PathEncodingError(failure.message) + exception = {"config": ConfigError, "filesystem": FilesystemError, + "query": QueryError}.get(failure.kind, OperationError) + raise exception(failure) + return convert(cls, envelope["result"]) + + +def check(root: PathInput | None = None, *, require_grounding: bool = False, + suggestions: bool = False, full: bool = False, rule: str | None = None, + only: Sequence[str] = (), ignore: Sequence[str] = (), + only_rule: bool = False) -> CheckResult: + """Return complete and selected reports; findings are normal results.""" + if type(only_rule) is not bool: + raise TypeError("only_rule must be bool") + if only_rule and rule is None: + raise ValueError("only_rule requires rule") + only, ignore = _strings(only, "only"), _strings(ignore, "ignore") + if any(not _native.valid_option("code", code) for code in only + ignore): + raise ValueError("unknown check finding code") + return _call("check", CheckResult, root, require_grounding=require_grounding, + suggestions=suggestions, full=full, rule=rule, only=only, + ignore=ignore, only_rule=only_rule) + + +def scan(root: PathInput) -> ScanResult: + """Read a scanner snapshot at an explicit path.""" + if root is None: + raise TypeError("scan requires an explicit path") + return _call("scan", ScanResult, root) + + +def _mode(mode: str) -> None: + _str(mode, "mode") + if mode not in ("lead", "brief", "toc", "full"): + raise ValueError("mode must be lead, brief, toc or full") + + +def show(id: str, *, section: str | None = None, mode: str = "lead", + format: str = "text", root: PathInput | None = None) -> ShowResult: + """Read one coordinate, or raise a structured QueryError.""" + _str(id, "id") + _mode(mode) + _str(format, "format") + if format not in ("text", "md", "json"): + raise ValueError("format must be text, md or json") + return _call("show", ShowResult, root, (id,), section=section, mode=mode, format=format) + + +def show_batch(queries: Sequence[str | ShowQuery] | None = None, *, + mode: str = "lead", root: PathInput | None = None) -> BatchResult: + """None discovers all coordinates; empty input avoids config loading.""" + _mode(mode) + if queries is not None: + if isinstance(queries, (str, bytes)) or not isinstance(queries, Sequence): + raise TypeError("queries must be a sequence of strings or ShowQuery") + converted = [] + for query in queries: + if isinstance(query, ShowQuery): + _str(query.id, "id") + _str(query.section, "section", optional=True) + converted.append({"id": query.id, "section": query.section}) + else: + _str(query, "query") + converted.append(query) + operands = (converted,) + else: + operands = () + return _call("show_batch", BatchResult, root, operands, mode=mode) + + +def refs(id: str, *, section: str | None = None, descendants: bool = False, + root: PathInput | None = None) -> RefsResult: + """Read citation sites, file summaries and totals.""" + _str(id, "id") + return _call("refs", RefsResult, root, (id,), section=section, descendants=descendants) + + +def list_ids(*, kinds: Sequence[str] = (), projects: Sequence[str] = (), + unused: bool = False, selector: str | None = None, + root: PathInput | None = None) -> ListResult: + """Read the declaration/chapter catalog and kind summaries.""" + return _call("list_ids", ListResult, root, kinds=_strings(kinds, "kinds"), + projects=_strings(projects, "projects"), unused=unused, selector=selector) + + +def list_sizes(*, kinds: Sequence[str] = (), projects: Sequence[str] = (), + unused: bool = False, selector: str | None = None, + units: Sequence[str] = ("lines", "words", "bytes"), + top: int | None = None, root: PathInput | None = None) -> SizesResult: + """Read lead/full measurements, preserving requested unit order.""" + units = _strings(units, "units") + if not units or any(u not in ("lines", "words", "bytes") for u in units): + raise ValueError("units must contain lines, words or bytes") + if top is not None: + if type(top) is not int: + raise TypeError("top must be int") + if top <= 0: + raise ValueError("top must be positive") + return _call("list_sizes", SizesResult, root, kinds=_strings(kinds, "kinds"), + projects=_strings(projects, "projects"), unused=unused, selector=selector, + units=units, top=top) + + +def cover(*, text: bool = False, root: PathInput | None = None) -> CoverResult | CoverTextResult: + """Read file-grouped coverage, with an optional text-oriented record.""" + return _call("cover", CoverTextResult if text else CoverResult, root, text=text) + + +def fmt(*, write: bool = False, marker: bool = False, cross_refs: bool = False, + root: PathInput | None = None) -> FmtResult: + """Preview formatting; write=True applies only engine-owned edits.""" + return _call("fmt", FmtResult, root, write=write, marker=marker, cross_refs=cross_refs) + + +def propose_id(kind: str, title: str, *, width: int = 3, + root: PathInput | None = None) -> IdProposal: + """Propose a conflict-free ID without writing.""" + _str(kind, "kind") + _str(title, "title") + if type(width) is not int: + raise TypeError("width must be int") + if width < 0: + raise ValueError("width must be non-negative") + return _call("propose_id", IdProposal, root, (kind, title), width=width) + + +def init(target: PathInput | None = None, *, name: str | None = None, + description: str | None = None, docs: bool = False, force: bool = False, + write: bool = False, check: bool = False, no_vcs: bool = False, + agents: Sequence[str] | None = None) -> InitResult: + """Preview scaffolding; check always suppresses writes; force preserves config.""" + if agents is not None: + agents = _strings(agents, "agents") + if any(a not in ("canonical", "codex", "agents", "claude", "gemini", "pi", + "copilot", "cursor", "windsurf", "zed") for a in agents): + raise ValueError("unknown init agent") + return _call("init", InitResult, target, name=name, description=description, docs=docs, + force=force, write=write, check=check, no_vcs=no_vcs, agents=agents) + + +def effective_config(*, root: PathInput | None = None) -> ConfigResult: + """Read effective settings as recursively read-only schema mappings.""" + return _call("effective_config", ConfigResult, root) + + +def validate_config(*, root: PathInput | None = None) -> ConfigResult: + """Validate config and workspace members without scanning source files.""" + return _call("validate_config", ConfigResult, root) + + +def fetch(id: str, *, write: bool = False, root: PathInput | None = None) -> FetchResult: + """Materialize a snapshot only with explicit execution opt-in.""" + _str(id, "id") + if type(write) is not bool: + raise TypeError("write must be bool") + if not write: + raise ValueError("fetch requires write=True; it has no preview") + return _call("fetch", FetchResult, root, (id,), write=write) + + +def integrations(client: str | None = None, *, write: bool = False, + conversation: str | None = None, conversation_target: str | None = None, + agent: str | None = None) -> IntegrationsResult: + """Inspect or install user-global integration artifacts and guidance.""" + _str(client, "client", optional=True) + if type(write) is not bool: + raise TypeError("write must be bool") + for name, value in (("client", client), ("conversation", conversation), + ("conversation_target", conversation_target), ("agent", agent)): + _str(value, name, optional=True) + if value is not None and not _native.valid_option(name, value): + raise ValueError(f"unknown {name}") + if (not write and any(v is not None for v in (conversation, conversation_target, agent))) \ + or (agent is not None and conversation_target is None) \ + or (write and client is None and conversation is None and conversation_target is None): + raise ValueError("invalid integration preference combination") + return _call("integrations", IntegrationsResult, None, (client or "",), write=write, + conversation=conversation, conversation_target=conversation_target, agent=agent) + + +def complete_ids(prefix: str = "", *, sections: bool = False, + root: PathInput | None = None) -> CompletionResult: + """Complete IDs, aliases and optionally sections.""" + _str(prefix, "prefix") + return _call("complete_ids", CompletionResult, root, (prefix,), sections=sections) + + +def reference_style(*, root: PathInput | None = None) -> ReferenceStyle: + """Read this path's marker and typing trigger.""" + return _call("reference_style", ReferenceStyle, root) + + +def agent_setup_instructions() -> SetupInstructions: + """Return the engine's canonical setup payload.""" + return _call("agent_setup_instructions", SetupInstructions, None) diff --git a/python/grund/_convert.py b/python/grund/_convert.py new file mode 100644 index 000000000..2476d98d5 --- /dev/null +++ b/python/grund/_convert.py @@ -0,0 +1,45 @@ +"""Lossless dataclass conversion, with no engine decisions (§AR-bindings.6).""" + +from dataclasses import fields, is_dataclass +from collections.abc import Mapping +from types import MappingProxyType, UnionType +from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints + +T = TypeVar("T") + + +def immutable(value: Any) -> Any: + """Freeze opaque schema/details payloads recursively (§FS-distribution.3.3.1).""" + if isinstance(value, dict): + return MappingProxyType({k: immutable(v) for k, v in value.items()}) + if isinstance(value, list): + return tuple(immutable(v) for v in value) + return value + + +def convert(cls: type[T], value: Any) -> T: + """Enumerate the declared type; every field is mandatory in native data.""" + return _convert(cls, value) + + +def _convert(hint: Any, value: Any) -> Any: + if value is None: + return None + origin = get_origin(hint) + args = get_args(hint) + if origin in (Union, UnionType): + alternatives = [a for a in args if a is not type(None)] + if len(alternatives) == 1: + return _convert(alternatives[0], value) + return immutable(value) + if origin is tuple: + return tuple(_convert(args[0], v) for v in value) + if origin is Mapping: + return immutable(value) + if is_dataclass(hint): + hints = get_type_hints(hint) + expected = {f.name for f in fields(hint)} + if expected != set(value): + raise RuntimeError(f"native {hint.__name__} fields differ: {expected ^ set(value)}") + return hint(**{k: _convert(hints[k], v) for k, v in value.items()}) + return immutable(value) diff --git a/python/grund/errors.py b/python/grund/errors.py new file mode 100644 index 000000000..91568b981 --- /dev/null +++ b/python/grund/errors.py @@ -0,0 +1,31 @@ +"""Operational failures and path rejection (§FS-distribution.3.3.2).""" + +from .types import Failure + + +class GrundError(Exception): + """An operation could not complete; .failure retains its engine payload.""" + + def __init__(self, failure: Failure) -> None: + self.failure = failure + super().__init__(failure.message) + + +class ConfigError(GrundError): + """Configuration discovery, parsing or validation failed.""" + + +class FilesystemError(GrundError): + """A required filesystem operation failed.""" + + +class QueryError(GrundError): + """A single query could not resolve.""" + + +class OperationError(GrundError): + """Another engine operation failed.""" + + +class PathEncodingError(ValueError): + """The supplied path or an unnamed workspace alias cannot cross this API.""" diff --git a/python/grund/py.typed b/python/grund/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/python/grund/types.py b/python/grund/types.py new file mode 100644 index 000000000..c3c707fe4 --- /dev/null +++ b/python/grund/types.py @@ -0,0 +1,415 @@ +"""Immutable host records (§FS-distribution.3.3.1, §FS-distribution.3.3.2).""" + +from dataclasses import dataclass +from collections.abc import Iterator, Mapping +from typing import TypeAlias, Union + +Json: TypeAlias = Union[None, bool, int, float, str, tuple["Json", ...], Mapping[str, "Json"]] + + +@dataclass(frozen=True) +class FindingSite: + path: str + line: int + + +@dataclass(frozen=True) +class Finding: + severity: str | None + channel: str | None + code: str + path: str | None + line: int | None + column: int | None + message: str + sites: tuple[FindingSite, ...] + authority: tuple[str, ...] + + +@dataclass(frozen=True) +class Report: + errors: tuple[Finding, ...] + warnings: tuple[Finding, ...] + suggestions: tuple[Finding, ...] + + def __iter__(self) -> Iterator[Finding]: + """Yield channels in engine order (§FS-distribution.3.3.1).""" + return iter(self.errors + self.warnings + self.suggestions) + + +@dataclass(frozen=True, kw_only=True) +class Cautioned: + run_cautions: tuple[Finding, ...] + + +@dataclass(frozen=True) +class CheckResult(Cautioned): + report: Report + selected_report: Report + had_scan_errors: bool + output_format: str + + +@dataclass(frozen=True) +class Failure(Cautioned): + kind: str + code: str + message: str + path: str | None + line: int | None + column: int | None + sites: tuple[FindingSite, ...] + authority: tuple[str, ...] + causes: tuple[str, ...] + partial_output: Json + details: Mapping[str, Json] + + +@dataclass(frozen=True) +class ScanError: + path: str + message: str + + +@dataclass(frozen=True) +class ScanSection: + path: str + title: str + line: int + heading_level: int + + +@dataclass(frozen=True) +class ScanDeclaration: + id: str + path: str + line: int + heading_level: int + title: str | None + stub: bool + defines: str | None + body_start: int + body_end: int + body_has_content: bool + value_valid: bool | None + sections: tuple[ScanSection, ...] + duplicate_sections: tuple[ScanSection, ...] + + +@dataclass(frozen=True) +class ScanCitation: + project: str | None + id: str + section: str | None + path: str + line: int + column: int + marker: bool + text: str + shorthand: bool + local_section: bool + shorthand_rewritable: bool + numeric_run: bool + source_kind: str + enclosing_declaration: str | None + enclosing_section: str | None + + +@dataclass(frozen=True) +class ScanResult(Cautioned): + catalog: tuple[ScanDeclaration, ...] + citations: tuple[ScanCitation, ...] + scanned_files: tuple[str, ...] + scan_errors: tuple[ScanError, ...] + + +@dataclass(frozen=True) +class ShowQuery: + id: str + section: str | None = None + + +@dataclass(frozen=True) +class ShowSection: + path: str + title: str + depth: int + + +@dataclass(frozen=True) +class ShowResult(Cautioned): + body: str + path: str + line: int + json: str | None + sections: tuple[ShowSection, ...] + + +@dataclass(frozen=True) +class BatchRecord: + query: ShowQuery + result: ShowResult | None + failure: Failure | None + + +@dataclass(frozen=True) +class BatchResult(Cautioned): + records: tuple[BatchRecord, ...] + + +@dataclass(frozen=True) +class RefHit: + project: str | None + path: str + line: int + column: int + id: str + section: str | None + marker: bool + text: str + enclosing_declaration: str | None + enclosing_section: str | None + + +@dataclass(frozen=True) +class FileSummary: + project: str | None + path: str + sites: int + + +@dataclass(frozen=True) +class RefsResult(Cautioned): + output_format: str + workspace: bool + hits: tuple[RefHit, ...] + note: str | None + scan_errors: tuple[ScanError, ...] + kind_title: str | None + site_total: int + file_total: int + file_summaries: tuple[FileSummary, ...] + + +@dataclass(frozen=True) +class ValueRoot: + id: str + valid: bool + + +@dataclass(frozen=True) +class ListEntry: + project: str | None + id: str + section: str | None + section_separator: str + kind: str + path: str + line: int + title: str | None + stub: bool + defines: str | None + refs: int + duplicate: bool + value_roots: tuple[ValueRoot, ...] + + +@dataclass(frozen=True) +class ListSummary: + project: str | None + kind: str + title: str + home: str + count: int + + +@dataclass(frozen=True) +class ListResult(Cautioned): + output_format: str + workspace: bool + entries: tuple[ListEntry, ...] + summaries: tuple[ListSummary, ...] + scan_errors: tuple[ScanError, ...] + + +@dataclass(frozen=True) +class SizeMeasurement: + unit: str + lead: int | None + full: int | None + + +@dataclass(frozen=True) +class SizeEntry: + project: str | None + id: str + section: str | None + section_separator: str + kind: str + path: str + line: int + stub: bool + defines: str | None + duplicate: bool + measurements: tuple[SizeMeasurement, ...] + + +@dataclass(frozen=True) +class SizesResult(Cautioned): + output_format: str + workspace: bool + entries: tuple[SizeEntry, ...] + scan_errors: tuple[ScanError, ...] + + +@dataclass(frozen=True) +class CoverEntry: + project: str | None + path: str + citations: tuple[RefHit, ...] + + +@dataclass(frozen=True) +class CoverTextCitation: + line: int + column: int + text: str + + +@dataclass(frozen=True) +class CoverTextEntry: + path: str + citations: tuple[CoverTextCitation, ...] + + +@dataclass(frozen=True) +class CoverResult(Cautioned): + output_format: str + entries: tuple[CoverEntry, ...] + scan_errors: tuple[ScanError, ...] + + +@dataclass(frozen=True) +class CoverTextResult(Cautioned): + output_format: str + entries: tuple[CoverTextEntry, ...] + scan_errors: tuple[ScanError, ...] + + +@dataclass(frozen=True) +class FmtChange: + path: str + line: int + label: str + + +@dataclass(frozen=True) +class FmtResult(Cautioned): + changes: tuple[FmtChange, ...] + scan_errors: tuple[ScanError, ...] + refused_writes: tuple[str, ...] + + +@dataclass(frozen=True) +class IdProposal(Cautioned): + id: str + kind: str + number: int | None + slug: str + folder: str | None + file: str | None + e2e_case_dir: str | None + file_holds_single_declaration: bool + + +@dataclass(frozen=True) +class InitEvent: + verb: str + path: str + + +@dataclass(frozen=True) +class InitFsHome: + kind: str + path: str + heading_name: str | None + heading_marker: str | None + + +@dataclass(frozen=True) +class InitNext: + docs: bool + entrypoint: str + fs_home: InitFsHome + scan_reads_file: bool + + +@dataclass(frozen=True) +class InitResult(Cautioned): + events: tuple[InitEvent, ...] + errors: tuple[Finding, ...] + notes: tuple[str, ...] + next: InitNext | None + has_pending_changes: bool + + +@dataclass(frozen=True) +class ConfigResult(Cautioned): + config: Mapping[str, Json] + warnings: tuple[str, ...] + + +@dataclass(frozen=True) +class CompletionResult(Cautioned): + candidates: tuple[str, ...] + + +@dataclass(frozen=True) +class ReferenceStyle(Cautioned): + marker: str + trigger: str + + +@dataclass(frozen=True) +class FetchResult(Cautioned): + pass + + +@dataclass(frozen=True) +class IntegrationClient: + client: str + detected: bool + installed: bool | None + install_kind: str + install: str + config_target: str + + +@dataclass(frozen=True) +class IntegrationArtifact: + client: str + snippet: str | None + resolver: str | None + package_json: str | None + extension_js: str | None + + +@dataclass(frozen=True) +class IntegrationEvent: + path: str + verb: str + note: str | None + + +@dataclass(frozen=True) +class IntegrationsResult(Cautioned): + detected: tuple[str, ...] + clients: tuple[IntegrationClient, ...] + artifact: IntegrationArtifact | None + events: tuple[IntegrationEvent, ...] + manual_steps: tuple[str, ...] + + +@dataclass(frozen=True) +class SetupInstructions(Cautioned): + text: str From f05ef7dddcfd90e7fe4add243c8268fc1f875c9f Mon Sep 17 00:00:00 2001 From: Vojin Jovanovic Date: Tue, 6 Oct 2026 04:40:07 +0200 Subject: [PATCH 3/5] Preserve Python fetch diagnostics and filesystem root semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain fetch I/O sources and locations through the additive failure carrier (§FS-distribution.3.3.2), and preserve symlink/.. inputs for engine resolution (§FS-distribution.3.3.3). Correct independent CLI stream and golden comparisons (§FS-distribution.3.0.3), maintain frontend isolation (§AR-bindings.1), and separate the discovery citation note (§FS-inline-citation-style.2.3.1). Add binding regressions for both behavior corrections. --- crates/grund-core/src/config/discovery.rs | 1 + crates/grund-core/src/writers/fetch.rs | 43 ++++++-- crates/grund-core/src/writers/fetch_write.rs | 101 ++++++++++++------- python/grund/_api.py | 3 +- tests/bindings/test_parity.py | 52 ++++++++-- tests/bindings/test_regressions.py | 93 +++++++++++++++++ tests/integration/test_frontend_isolation.py | 2 +- 7 files changed, 238 insertions(+), 57 deletions(-) create mode 100644 tests/bindings/test_regressions.py diff --git a/crates/grund-core/src/config/discovery.rs b/crates/grund-core/src/config/discovery.rs index 70f37df76..0ef128be3 100644 --- a/crates/grund-core/src/config/discovery.rs +++ b/crates/grund-core/src/config/discovery.rs @@ -113,6 +113,7 @@ pub(crate) fn load_config(start: &Path) -> Result { // Zero-config (§GOAL-zero-config): the "project root" is the current working // directory, never the path passed on the command line. Reports stay relative to // `cli_base` (the resolved path arg) when `relative_paths` is off (§FS-config.3.6.1). + // §FS-distribution.3.3.3: embedding scopes this fallback to its supplied root. let root = match super::call_scope::embedding_base() { Some(base) => fs::canonicalize(&base).unwrap_or(base), diff --git a/crates/grund-core/src/writers/fetch.rs b/crates/grund-core/src/writers/fetch.rs index 2825c5e16..1f3a9387c 100644 --- a/crates/grund-core/src/writers/fetch.rs +++ b/crates/grund-core/src/writers/fetch.rs @@ -48,6 +48,19 @@ pub(super) fn fetch_operational(message: impl Into) -> FetchFailure { } } +/// Retain the source beside the unchanged fetch refusal (§FS-distribution.3.3.2). +pub(super) fn fetch_io( + diagnostic: &mut Option, + path: &Path, + error: std::io::Error, + context: String, +) -> FetchFailure { + let failure = fetch_operational(format!("{context}: {error}")); + let source = crate::model::OperationDiagnostic::filesystem(path, &error, context); + *diagnostic = Some(anyhow::Error::new(error).context(source)); + failure +} + /// Materialize exactly one local or qualified external ID (§FS-fetch.1). pub fn fetch_snapshot(raw: &str, path: &Path) -> std::result::Result<(), FetchFailure> { fetch_snapshot_with_run_warnings(raw, path).1 @@ -154,9 +167,12 @@ fn fetch_run( .current_dir(&selected.root) .output() .map_err(|err| { - fetch_operational(format!( - "cannot run fetch integration `{fetch}` for {raw}: {err}" - )) + fetch_io( + diagnostic, + &integration, + err, + format!("cannot run fetch integration `{fetch}` for {raw}"), + ) })?; if !output.status.success() { let status = output @@ -185,12 +201,21 @@ fn fetch_run( }; validate_fetched_declaration(snapshot, local, depth)?; match home { - FetchHome::File(path) => { - write_file_home(&path, &selected.grammar, &id, snapshot.as_bytes()) - } - FetchHome::Folder(path) => { - write_folder_home(&path, &selected, &id, local, snapshot.as_bytes()) - } + FetchHome::File(path) => write_file_home( + &path, + &selected.grammar, + &id, + snapshot.as_bytes(), + diagnostic, + ), + FetchHome::Folder(path) => write_folder_home( + &path, + &selected, + &id, + local, + snapshot.as_bytes(), + diagnostic, + ), } } diff --git a/crates/grund-core/src/writers/fetch_write.rs b/crates/grund-core/src/writers/fetch_write.rs index b5094d1eb..3d0726d2c 100644 --- a/crates/grund-core/src/writers/fetch_write.rs +++ b/crates/grund-core/src/writers/fetch_write.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; -use super::fetch::{FetchFailure, fetch_operational}; +use super::fetch::{FetchFailure, fetch_io, fetch_operational}; use crate::config::Config; use crate::grammar::{ Grammar, markdown_fence_delimiter, near_miss_heading, parse_id_arg, parse_longest_id_prefix, @@ -110,15 +110,18 @@ pub(super) fn write_file_home( grammar: &Grammar, requested: &Id, snapshot: &[u8], + diagnostic: &mut Option, ) -> std::result::Result<(), FetchFailure> { let original = match fs::read(path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(err) => { - return Err(fetch_operational(format!( - "cannot read {}: {err}", - path.display() - ))); + return Err(fetch_io( + diagnostic, + path, + err, + format!("cannot read {}", path.display()), + )); } }; let declarations = declarations_at_depth(&original, grammar, 2)?; @@ -164,7 +167,7 @@ pub(super) fn write_file_home( replacement.extend_from_slice(line_ending); } replacement.extend_from_slice(&original[end..]); - atomic_install(path, &replacement) + atomic_install(path, &replacement, diagnostic) } /// §FS-fetch.5: replace the unique declaring file or create `.md`. @@ -174,6 +177,7 @@ pub(super) fn write_folder_home( requested: &Id, local: &str, snapshot: &[u8], + diagnostic: &mut Option, ) -> std::result::Result<(), FetchFailure> { let mut matches = Vec::new(); if folder.exists() { @@ -203,7 +207,12 @@ pub(super) fn write_folder_home( continue; } let bytes = fs::read(&path).map_err(|err| { - fetch_operational(format!("cannot read {}: {err}", path.display())) + fetch_io( + diagnostic, + &path, + err, + format!("cannot read {}", path.display()), + ) })?; let declarations = declarations_at_depth(&bytes, &config.grammar, 1)?; let contains_requested = declarations @@ -250,12 +259,16 @@ pub(super) fn write_folder_home( target.display() ))); } - atomic_install(&target, snapshot) + atomic_install(&target, snapshot, diagnostic) } /// §FS-fetch.4 / §FS-fetch.5: install complete bytes by same-directory rename, /// leaving an unchanged target untouched. -fn atomic_install(path: &Path, bytes: &[u8]) -> std::result::Result<(), FetchFailure> { +fn atomic_install( + path: &Path, + bytes: &[u8], + diagnostic: &mut Option, +) -> std::result::Result<(), FetchFailure> { if fs::read(path).ok().as_deref() == Some(bytes) { return Ok(()); } @@ -263,16 +276,18 @@ fn atomic_install(path: &Path, bytes: &[u8]) -> std::result::Result<(), FetchFai Ok(metadata) => Some(metadata.permissions()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => { - return Err(fetch_operational(format!( - "cannot read metadata for {}: {err}", - path.display() - ))); + return Err(fetch_io( + diagnostic, + path, + err, + format!("cannot read metadata for {}", path.display()), + )); } }; let parent = path .parent() .ok_or_else(|| fetch_operational(format!("cannot write {}", path.display())))?; - let created_directories = create_parent_directories(parent, path)?; + let created_directories = create_parent_directories(parent, path, diagnostic)?; let mut temporary = None; for attempt in 0..100u32 { let candidate = parent.join(format!(".grund-fetch-{}-{attempt}.tmp", std::process::id())); @@ -288,10 +303,12 @@ fn atomic_install(path: &Path, bytes: &[u8]) -> std::result::Result<(), FetchFai Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(err) => { rollback_created_directories(&created_directories); - return Err(fetch_operational(format!( - "cannot write {}: {err}", - path.display() - ))); + return Err(fetch_io( + diagnostic, + path, + err, + format!("cannot write {}", path.display()), + )); } } } @@ -313,19 +330,23 @@ fn atomic_install(path: &Path, bytes: &[u8]) -> std::result::Result<(), FetchFai drop(file); let _ = fs::remove_file(&temporary_path); rollback_created_directories(&created_directories); - return Err(fetch_operational(format!( - "cannot write {}: {err}", - path.display() - ))); + return Err(fetch_io( + diagnostic, + path, + err, + format!("cannot write {}", path.display()), + )); } drop(file); if let Err(err) = fs::rename(&temporary_path, path) { let _ = fs::remove_file(&temporary_path); rollback_created_directories(&created_directories); - return Err(fetch_operational(format!( - "cannot atomically replace {}: {err}", - path.display() - ))); + return Err(fetch_io( + diagnostic, + path, + err, + format!("cannot atomically replace {}", path.display()), + )); } Ok(()) } @@ -336,6 +357,7 @@ fn atomic_install(path: &Path, bytes: &[u8]) -> std::result::Result<(), FetchFai fn create_parent_directories( parent: &Path, target: &Path, + diagnostic: &mut Option, ) -> std::result::Result, FetchFailure> { let mut missing = Vec::new(); let mut cursor = parent; @@ -352,14 +374,21 @@ fn create_parent_directories( Err(err) if err.kind() == std::io::ErrorKind::NotFound => { missing.push(cursor.to_path_buf()); cursor = cursor.parent().ok_or_else(|| { - fetch_operational(format!("cannot write {}: {err}", target.display())) + fetch_io( + diagnostic, + target, + err, + format!("cannot write {}", target.display()), + ) })?; } Err(err) => { - return Err(fetch_operational(format!( - "cannot write {}: {err}", - target.display() - ))); + return Err(fetch_io( + diagnostic, + target, + err, + format!("cannot write {}", target.display()), + )); } } } @@ -368,10 +397,12 @@ fn create_parent_directories( for directory in missing.into_iter().rev() { if let Err(err) = fs::create_dir(&directory) { rollback_created_directories(&created); - return Err(fetch_operational(format!( - "cannot write {}: {err}", - target.display() - ))); + return Err(fetch_io( + diagnostic, + target, + err, + format!("cannot write {}", target.display()), + )); } created.push(directory); } diff --git a/python/grund/_api.py b/python/grund/_api.py index ca7831998..b5cd6dbfb 100644 --- a/python/grund/_api.py +++ b/python/grund/_api.py @@ -28,7 +28,8 @@ def _path(value: PathInput | None) -> tuple[str, bool]: raise PathEncodingError("paths must contain Unicode scalar values") if "\0" in text: raise ValueError("paths must not contain NUL") - return os.path.normpath(os.path.join(cwd, text)), explicit + # §FS-distribution.3.3.3: symlink/.. must reach engine filesystem resolution. + return os.path.join(cwd, text), explicit def _str(value: Any, name: str, optional: bool = False) -> None: diff --git a/tests/bindings/test_parity.py b/tests/bindings/test_parity.py index 76d5292ed..bd9fe69ac 100644 --- a/tests/bindings/test_parity.py +++ b/tests/bindings/test_parity.py @@ -5,12 +5,27 @@ from pathlib import Path import shutil import unittest +import unicodedata from unittest.mock import patch from corpus import READ_CASES, MUTATIONS from support import REPO, binding, canonical, fixture, plain, python_call, rust_call, temporary, tree_bytes +def cli_json(value): + """Ordered frozen CLI JSON, including its control escapes (§FS-distribution.3.0.3).""" + if isinstance(value, str): + escapes = {'"': '\\"', '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t'} + return '"' + ''.join(escapes.get(c, f'\\u{ord(c):04x}' + if unicodedata.category(c) == "Cc" else c) + for c in value) + '"' + if isinstance(value, dict): + return '{' + ','.join(cli_json(k) + ':' + cli_json(v) for k, v in value.items()) + '}' + if isinstance(value, list): + return '[' + ','.join(cli_json(v) for v in value) + ']' + return json.dumps(value, allow_nan=False, separators=(',', ':')) + + class ParityTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -30,10 +45,11 @@ def test_shared_read_corpus_complete_data_and_bytes(self): before = tree_bytes(root) actual, response = self.compare(operation, root, args, options) if operation == "check" and actual["result"] is not None: - self.assert_frozen_cli_projection(actual["result"]["report"], response) + self.assert_frozen_cli_projection(actual["result"]["report"], + actual["run_cautions"], response) self.assertEqual(before, tree_bytes(root), "read operation wrote files") - def assert_frozen_cli_projection(self, report, response): + def assert_frozen_cli_projection(self, report, cautions, response): # Construct expected records from every host field the frozen wire uses, # not from the Rust driver's projection. Stable sort preserves channel ties. rows = [] @@ -41,18 +57,29 @@ def assert_frozen_cli_projection(self, report, response): for finding in report[group]: row = {"channel": "suggestion"} if group == "suggestions" else { "severity": finding["severity"]} + sites = [{"path": site["path"], "line": site["line"]} + for site in finding["sites"]] row.update(path=finding["path"], line=finding["line"], code=finding["code"], - message=finding["message"], sites=finding["sites"] or None, + message=finding["message"], sites=sites or None, authority=finding["authority"] or None) rows.append(row) rows.sort(key=lambda row: (row["path"] is not None, row["path"] or "", row["line"] or 0, row["message"])) - actual = [json.loads(line) for line in response["cli_stdout"].splitlines()] - self.assertEqual(rows, actual) - for row in actual: - self.assertEqual([next(iter(row)), "path", "line", "code", "message", "sites", "authority"], - list(row)) - self.assertNotIn("column", row) + # §FS-distribution.3.0.3: cautions precede stderr findings as text. + caution_text = "".join("warning: " + f["message"] + "\n" for f in cautions) + for stream, located in (("stdout", True), ("stderr", False)): + expected = [row for row in rows if (row["line"] is not None) == located] + prefix = caution_text if stream == "stderr" else "" + wire = response["cli_" + stream] + self.assertTrue(wire.startswith(prefix)) + actual = [json.loads(line) for line in wire[len(prefix):].splitlines()] + self.assertEqual(expected, actual) + for row in actual: + self.assertEqual([next(iter(row)), "path", "line", "code", "message", + "sites", "authority"], list(row)) + self.assertNotIn("column", row) + serialized = "".join(cli_json(row) + "\n" for row in expected) + self.assertEqual((prefix + serialized).encode("utf-8"), wire.encode("utf-8")) def test_cli_json_goldens_remain_authoritative(self): for case in ("json-report", "check-invalid-config-json", @@ -61,8 +88,11 @@ def test_cli_json_goldens_remain_authoritative(self): root = fixture(temp, case) _, response = self.compare("check", root, (), {}) source = REPO / "tests/e2e/cases" / case - self.assertEqual((source / "expected.stdout").read_text(), response["cli_stdout"]) - self.assertEqual((source / "expected.stderr").read_text(), response["cli_stderr"]) + for stream in ("stdout", "stderr"): + golden = (source / ("expected." + stream)).read_bytes() + # §FS-distribution.3.0.3: the CLI golden reader's lone-LF sentinel. + expected = b"" if golden == b"\n" else golden + self.assertEqual(expected, response["cli_" + stream].encode("utf-8")) def test_mutation_preview_write_and_refusal_bytes_match_core(self): for case, operation, args, options in MUTATIONS: diff --git a/tests/bindings/test_regressions.py b/tests/bindings/test_regressions.py new file mode 100644 index 000000000..67dab3f6a --- /dev/null +++ b/tests/bindings/test_regressions.py @@ -0,0 +1,93 @@ +"""Fetch sources and filesystem roots (§FS-distribution.3.3.2, §FS-distribution.3.3.3).""" + +import errno +import os +from pathlib import Path +import unittest + +from support import binding, canonical, fixture, plain, python_call, rust_call, temporary, tree_bytes + + +class RegressionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = binding() + + def fetch_fixture(self, temp): + root = fixture(temp, "fetch-workspace-folder") + config = root / "grund.toml" + config.write_text(config.read_text().replace( + "[workspace]", '[scan]\ninclude = ["unread"]\n\n[workspace]') + + "include_root = false\n") + (root / "unread").mkdir() + (root / "unread/note.md").write_text("This block is intentionally not scanned.\n") + return root + + def assert_fetch_filesystem_failure(self, root, path, os_error): + args, options = ("alpha/TICKET-1234",), {"write": True} + before = tree_bytes(root) + expected, wire, _ = rust_call("fetch", root, args, options) + actual = python_call(self.module, "fetch", root, args, options) + self.assertEqual(expected, actual, "complete failure/caution parity") + self.assertEqual(wire, canonical(actual), "canonical failure bytes") + with self.assertRaises(self.module.FilesystemError) as caught: + self.module.fetch(*args, root=root, **options) + failure = caught.exception.failure + self.assertEqual(actual["failure"], plain(failure)) + self.assertEqual("filesystem", failure.kind) + self.assertEqual("io", failure.code) + self.assertEqual(path.as_posix(), failure.path) + self.assertIsNone(failure.line) + self.assertIsNone(failure.column) + self.assertEqual(os_error, failure.details["os_error"]) + self.assertTrue(failure.causes, "the original I/O source must survive") + self.assertTrue(any(os.strerror(os_error) in cause for cause in failure.causes)) + self.assertTrue(failure.run_cautions, "workspace cautions must survive the I/O failure") + self.assertEqual(actual["run_cautions"], plain(failure.run_cautions)) + self.assertEqual(before, tree_bytes(root), "failed fetch must preserve bytes") + + @unittest.skipUnless(os.name == "posix", "fixture fetcher is a POSIX shell executable") + def test_missing_fetch_executable_retains_io_source(self): + with temporary() as temp: + root = self.fetch_fixture(temp) + executable = root / "packages/alpha/scripts/fetch-ticket" + executable.unlink() + self.assert_fetch_filesystem_failure(root, executable, errno.ENOENT) + + @unittest.skipUnless(os.name == "posix", "requires POSIX directory write permissions") + def test_denied_fetch_snapshot_write_retains_io_source(self): + if os.geteuid() == 0: + self.skipTest("root bypasses directory write permissions; run as an unprivileged user") + with temporary() as temp: + root = self.fetch_fixture(temp) + home = root / "packages/alpha/docs/tickets" + home.mkdir(parents=True, exist_ok=True) + permissions = home.stat().st_mode + home.chmod(0o555) + try: + # Unexpected success is a failure, never a platform skip. + self.assert_fetch_filesystem_failure(root, home / "TICKET-1234.md", errno.EACCES) + finally: + home.chmod(permissions) + + @unittest.skipUnless(os.name == "posix", "requires POSIX directory symlink semantics") + def test_explicit_symlink_parent_root_matches_engine(self): + with temporary() as first, temporary() as second: + clean = fixture(first, "clean") + erroneous = fixture(second) + (erroneous / "subdir").mkdir() + (clean / "link").symlink_to(erroneous / "subdir", target_is_directory=True) + # Keep the literal operand: resolve/abspath would erase the regression. + absolute = str(clean / "link") + "/.." + expected, wire, _ = rust_call("check", absolute) + self.assertIn("dangling", [f["code"] for f in expected["result"]["report"]["errors"]]) + cwd = Path.cwd() + try: + os.chdir(clean) + for operand in (absolute, "link/..", Path("link/..")): + with self.subTest(operand=str(operand)): + actual = python_call(self.module, "check", operand) + self.assertEqual(expected, actual, "filesystem root parity") + self.assertEqual(wire, canonical(actual), "canonical root bytes") + finally: + os.chdir(cwd) diff --git a/tests/integration/test_frontend_isolation.py b/tests/integration/test_frontend_isolation.py index 8b50e8c7f..a09c9c923 100644 --- a/tests/integration/test_frontend_isolation.py +++ b/tests/integration/test_frontend_isolation.py @@ -12,7 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ENGINE = "grund-core" -FRONTENDS = {"grund", "grund-lsp"} +FRONTENDS = {"grund", "grund-lsp", "grund-py"} LSP_TRANSPORT = {"lsp-server", "lsp-types"} From fb4529612621ab965a9e61143ac0fef4b79688a1 Mon Sep 17 00:00:00 2001 From: Vojin Jovanovic Date: Tue, 6 Oct 2026 04:56:18 +0200 Subject: [PATCH 4/5] Preserve Python fetch traversal error sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain the original snapshot-folder traversal error and its embedded I/O source through the additive fetch diagnostic carrier (§FS-distribution.3.3.2). Preserve scanner records, refusal ordering, and CLI failure text, and retain the fallible walk source alongside its location. Pin denied traversal with direct FilesystemError, location, original cause, OS-code, caution, canonical-parity, and restored-permission byte assertions. Move the unchanged reporting pass into its scanner sibling module to satisfy the staged file-size check. Behavioral verification remains with green. --- crates/grund-core/src/scanner/mod.rs | 8 +- crates/grund-core/src/scanner/walk.rs | 313 +---------------- .../grund-core/src/scanner/walk_reporting.rs | 331 ++++++++++++++++++ crates/grund-core/src/writers/fetch_write.rs | 75 +++- tests/bindings/test_regressions.py | 34 +- 5 files changed, 435 insertions(+), 326 deletions(-) create mode 100644 crates/grund-core/src/scanner/walk_reporting.rs diff --git a/crates/grund-core/src/scanner/mod.rs b/crates/grund-core/src/scanner/mod.rs index e8c649026..bf04e797d 100644 --- a/crates/grund-core/src/scanner/mod.rs +++ b/crates/grund-core/src/scanner/mod.rs @@ -77,6 +77,7 @@ mod values; mod walk; mod walk_boundaries; mod walk_errors; +mod walk_reporting; pub use scan_error::ApiScanError; @@ -102,10 +103,11 @@ pub(crate) use scope_probe::effective_scope_reads_any_file; pub(crate) use tree::{ ScanError, overlay_text, scan_tree, scan_tree_strict, scan_tree_with_workspace_overlays, }; -pub(crate) use walk::{ - scan_roots_for, walk_reads_any_file, walk_scannable_files, walk_scannable_files_reporting, -}; +pub(crate) use walk::{scan_roots_for, walk_reads_any_file}; pub(crate) use walk_boundaries::is_scannable; +pub(crate) use walk_reporting::{ + walk_scannable_files, walk_scannable_files_reporting, walk_scannable_files_with_sources, +}; // What another component's tests read (§AR-core-module-layout.1.3): the // workspace-wide tree entry point, which the cross-project citation cases diff --git a/crates/grund-core/src/scanner/walk.rs b/crates/grund-core/src/scanner/walk.rs index 301229488..4b4ed569e 100644 --- a/crates/grund-core/src/scanner/walk.rs +++ b/crates/grund-core/src/scanner/walk.rs @@ -1,276 +1,24 @@ use anyhow::{Result, anyhow}; use ignore::WalkBuilder; -use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use super::e2e::e2e_id_from_case_dir_name; -use super::tree::ScanError; use super::walk_boundaries::{ is_directory_symlink, is_scannable, outward_directory_link_root, owned_by_another_project, }; -use super::walk_errors::{symlink_loop_report, walk_error_report}; +pub(super) use super::walk_reporting::walk_scannable_files_reporting; use crate::config::{Config, canonical_config_root, root_scope_roots, unwalked_homes}; use crate::model::{ - configured_home_path_key, is_hidden, normalize_path_lexically, physical_path_key, - scanned_decl_relative_path, sort_path_key, + configured_home_path_key, is_hidden, normalize_path_lexically, scanned_decl_relative_path, }; -/// The tree walk for the callers that ask a yes/no question about the tree and -/// nothing else — today the `--cross-refs` auto-enable probe, which wants to know -/// whether the scope holds any Markdown (§FS-fmt.6.6). Every caller that *reports* -/// takes `walk_scannable_files_reporting`, so an unresolvable link reaches the -/// report rather than being dropped here (§FS-config.3.5.5, §FS-check.2.4): this one -/// is walking a tree that the reporting walk is about to walk again and account -/// for, so repeating its errors would print each of them twice. -/// -/// The tree walk: which roots a scan starts from, which files it yields, and what -/// it does with a path it cannot read (§AR-scanner.1). It sits beside -/// `file_pass.rs` rather than inside it because the two are different machines — -/// that one is a line-by-line pass over a file's text, this one is a directory -/// traversal — and they meet only at the file list one hands the other. -pub(crate) fn walk_scannable_files( - config: &Config, - scope: Option<&Path>, - explicit_scope: bool, -) -> Result> { - Ok(walk_scannable_files_reporting(config, scope, explicit_scope)?.files) -} - -/// What one walk of the tree produced (§AR-scanner.1). -pub(crate) struct WalkedTree { - /// The scannable files, sorted, one entry per physical file (§FS-errors.4.1). - pub(crate) files: Vec, - /// The paths the walk could not read, for the caller to report (§FS-check.2.4). - pub(crate) errors: Vec, - /// The files that are in this tree only by a link: their physical path is - /// outside the config root. Read like any other file (§FS-config.3.5.1) — - /// `fmt --write` is the caller that treats them differently, because - /// rewriting one edits a file the project does not own (§FS-fmt.2.3.2). - pub(crate) outside_root: BTreeSet, - /// Every directory the walk descended into, scan roots included, sorted and - /// deduplicated (§FS-errors.4.1). Carried out rather than asked about here: the - /// scanner never asks "am I in a workspace?" (§AR-workspace.1), and the one - /// caller of this list — the unlisted-`[workspace]` rule of §FS-check.3.29 — - /// probes each directory for a config and answers the claim above the walk. - pub(crate) dirs: Vec, -} - -/// The tree walk (§AR-scanner.1): from each scan root, descend skipping hidden and -/// `[scan] exclude` directories, honouring `.gitignore` and friends unless -/// `respect_gitignore = false` (§AR-scanner.1.1, §FS-config.3.5.15), following -/// symlinks, keeping only scannable files, in a sorted order so findings are -/// deterministic (§FS-errors.4.1). Returns the paths it could not read beside the -/// files, for the caller to report (§FS-check.2.4). -/// -/// Why `aliasable` is a list and not a flag: the identity pass resolves what is in -/// it and compares everything else by path, so one link in a repository costs one -/// `realpath` and not one per file. -/// -/// Paths stay in-tree. Following a symlink does not change the entry's path, and -/// that spelling is what the directory filter below and every finding are expressed -/// in. The boundary filter can therefore compare precomputed suffixes: `strip_prefix` -/// only removes the root, so the descendant suffix is invariant under symlink -/// resolution — the compare works even if `scan_root` itself is a symlink. -/// -/// Why the other-project test after resolution is cheap: the alias resolution is -/// already paid for, so the boundary costs a prefix test over the files that can -/// wear a second name, and nothing at all for a run that loaded no workspace. -/// -/// Why the error list is sorted and deduplicated here: the text report sorts before -/// printing while the API surface hands the list over as it stands, so one sort -/// serves both; and overlapping roots — plus `--full`, which walks every `include` -/// root beside the config root that contains it — meet the same broken link once per -/// root, so an undeduplicated list prints every such error twice. -/// -/// Why `outside_root` compares physical paths on both sides: a config root reached -/// through a link contains none of the paths its own files resolve to, and -/// `fmt --write` would then refuse to rewrite the whole repository. -pub(crate) fn walk_scannable_files_reporting( - config: &Config, - scope: Option<&Path>, - explicit_scope: bool, -) -> Result { - let roots = scan_roots(config, scope, explicit_scope)?; - // §FS-config.3.5.4: an aliased root, or a symlink met on the way down, is what - // hands the same file to the walk under two spellings — nothing else does, so - // a tree with neither never pays for the identity pass (§GOAL-fast-feedback). - let mut aliasable = BTreeSet::new(); - let mut files = Vec::new(); - let mut errors = Vec::new(); - // §FS-check.3.29.11: the directories the walk met, for the rule that asks which of - // them carries a `[workspace]` block nothing claims. Collected here because the - // entries are already being enumerated — no second traversal (§GOAL-fast-feedback). - let mut dirs = Vec::new(); - // Where this project physically is, for every comparison below that reads a - // resolved path. Equal to `config.root` for the roots `grund` discovers, which - // are canonical already (§FS-config.1) — one `stat` per run either way. - let physical_root = canonical_config_root(config); - for scan_root in roots { - if !scan_root.exists() { - continue; - } - let canonical_scan_root = - fs::canonicalize(&scan_root).unwrap_or_else(|_| scan_root.to_path_buf()); - // §FS-config.3.5.1: a scan root reached through a directory link uses - // the same canonical project-root boundary as a link met during descent. - - // §AR-workspace.6: a root scan starts outside member namespaces; an - // included path at or below a member boundary belongs to the member scan, - // and one in another project belongs there (§FS-workspace.6.2). - if outward_directory_link_root(&scan_root, &canonical_scan_root, &physical_root) - || config - .workspace_boundary_roots - .iter() - .any(|root| canonical_scan_root.starts_with(root)) - || owned_by_another_project(config, &physical_root, &canonical_scan_root) - { - continue; - } - // A root that resolves elsewhere reaches every one of its files under a - // spelling that is not the file's own, so all of them can alias (§FS-check.1.3.2). - let root_is_aliased = canonical_scan_root != scan_root; - if scan_root.is_file() { - if is_scannable(&scan_root, config) { - // §FS-check.1.3.6.1: a file handed as the path is walked beside roots - // that reach it under its own name, so a link's spelling has to - // resolve to the same one read (§FS-config.3.5.4). - if root_is_aliased { - aliasable.insert(scan_root.clone()); - } - files.push(scan_root); - } - continue; - } - // The directory links the filter met, shared with the loop below: a file - // under one of them is reached under a spelling that is not its own, the - // same as a file that is a link itself (§AR-scanner.1.8). - let link_roots = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - // §FS-config.3.5.5: the directory links the filter pruned as loops, for the - // report to be raised from without the descent the walker would need to - // notice them (§AR-scanner.1.9). - let looping_links = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let walker = scannable_walker( - config, - &scan_root, - &canonical_scan_root, - &physical_root, - &link_roots, - &looping_links, - ); - let mut root_files = Vec::new(); - for entry in walker { - let entry = match entry { - Ok(entry) => entry, - // §FS-config.3.5.5: a link the walk cannot resolve is a file the scan - // cannot read — reported at its own path, the walk continuing past it - // (§FS-check.2.4). Failing the scan would let it take the whole report. - Err(err) => { - errors.extend(walk_error_report(&err, config, &scan_root)); - continue; - } - }; - // §FS-check.3.29.11: a directory is not a scannable file, so it falls out - // one line below. Its path is what the unlisted-`[workspace]` rule needs, - // and the scan root itself — the entry at depth 0 — is one of them. - if entry - .file_type() - .is_some_and(|file_type| file_type.is_dir()) - { - dirs.push(entry.path().to_path_buf()); - } - if !entry - .file_type() - .is_some_and(|file_type| file_type.is_file()) - || !is_scannable(entry.path(), config) - { - continue; - } - if root_is_aliased || entry.path_is_symlink() || under_link(&link_roots, entry.path()) { - aliasable.insert(entry.path().to_path_buf()); - } - root_files.push(entry.path().to_path_buf()); - } - // §FS-config.3.5.5: the loops the filter pruned. The link is owed the same - // report a loop the walker found earns, and by the same gates — it is the - // descent, not the report, that pruning removes. - for (link, target) in looping_links - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .drain(..) - { - // The in-tree name of the directory the link reaches back into, or - // nothing where the target reaches over the walk root. - let ancestor = target - .strip_prefix(&canonical_scan_root) - .ok() - .map(|rest| scan_root.join(rest)); - errors.extend(symlink_loop_report(&link, ancestor.as_deref(), config)); - } - // §FS-errors.4.1: within one root the order is the filesystem's, and the - // first-seen rule below turns that into a choice of *spelling*. Sorting each - // root's list first makes the choice ours: earlier root wins, then lexicographic. - root_files.sort_by_key(|path| sort_path_key(path)); - files.append(&mut root_files); - } - // One file, one read (§FS-check.1.3.2, §FS-config.3.5.4). Two spellings first, - // while the list is still in walk order and first-seen wins; then the - // byte-identical ones, which the sort has just brought together. - let resolved = resolve_aliasable(&aliasable); - // §FS-workspace.6.2: the directory filter stops a *directory* link at another - // project's root, and a link straight onto one of its files is the same - // crossing one entry lower down. - if !config.workspace_project_roots.is_empty() { - files.retain(|file| { - !resolved - .get(file.as_path()) - .is_some_and(|physical| owned_by_another_project(config, &physical_root, physical)) - }); - } - if !resolved.is_empty() { - dedup_by_file_identity(&mut files, &resolved); - } - files.sort_by_key(|path| sort_path_key(path)); - // The roots may overlap — `include = ["docs", "docs/api"]` names one subtree - // twice, and under `--full` every `include` root is walked beside the config root - // containing it (§FS-check.1.3.2). A file read twice duplicates its own declaration. - files.dedup(); - // §FS-errors.4.1: the walk meets its unreadable paths in readdir order, so they - // are sorted once here for both surfaces, then deduplicated — printing a scan - // error twice is what the additivity rule of §FS-check.1.3.4 forbids. - errors.sort_by_key(|(path, message)| (sort_path_key(path), message.clone())); - errors.dedup(); - // §FS-fmt.2.3.2: a file whose physical path is not under the config root is in - // this tree only by the link that reaches it. The resolution is already paid - // for above, so this is a prefix test over the links and nothing more. - let outside_root = files - .iter() - .filter(|file| { - resolved - .get(file.as_path()) - .is_some_and(|physical| !physical.starts_with(&physical_root)) - }) - .cloned() - .collect(); - // §FS-errors.4.1: overlapping roots — and `--full`, which walks every `include` - // root beside the config root containing it — meet the same directory once per - // root, so one sort and one dedup make the candidate list a set. - dirs.sort_by_key(|path| sort_path_key(path)); - dirs.dedup(); - Ok(WalkedTree { - files, - errors, - outside_root, - dirs, - }) -} - /// The walker one scan root is traversed with (§AR-scanner.1): the hidden-name and /// ignore-file rules the builder carries, and the workspace boundary, `[scan] /// exclude`, canonical project-root boundary, unwalked-home and E2E-case prunes /// [`WalkDirFilter`] carries. /// -/// Built apart from the walk above so [`walk_reads_any_file`] traverses through the +/// Built apart from the reporting pass so [`walk_reads_any_file`] uses the /// same one. That is not a tidiness point: §FS-check.4.10 reports a tree *because* /// no scan reads it, so a probe that pruned differently from the scan would caution /// a repository about content the scan would have skipped anyway — the one outcome @@ -278,7 +26,7 @@ pub(crate) fn walk_scannable_files_reporting( /// /// `link_roots` and `looping_links` are the filter's two outputs and belong to the /// caller, because the reporting walk reads both after the traversal. -fn scannable_walker( +pub(super) fn scannable_walker( config: &Config, scan_root: &Path, canonical_scan_root: &Path, @@ -565,59 +313,6 @@ impl WalkDirFilter { } } -/// Whether the walk reached this path *through* one of the directory links it -/// recorded — which makes the file's spelling not its own, exactly as being a -/// link itself would (§AR-scanner.1.8). -fn under_link(link_roots: &std::sync::Mutex>, path: &Path) -> bool { - link_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .any(|root| path.starts_with(root)) -} - -/// Where each file that can wear a second name physically is: the in-tree path it -/// was walked under, mapped to the path `canonicalize` resolves it to. -type AliasTargets = std::collections::HashMap; - -/// Resolve the files the walk saw arrive under a spelling that is not their own — -/// a link, a file below a directory link, or any file of an aliased root. This is -/// the only `canonicalize` the walk spends: everything else is answered by -/// comparing a path against what these resolved to (§AR-scanner.1.8, -/// §GOAL-fast-feedback). -fn resolve_aliasable(aliasable: &BTreeSet) -> AliasTargets { - aliasable - .iter() - .map(|file| (file.clone(), physical_path_key(file))) - .collect() -} - -/// Collapse the files reached under two spellings, keeping the **first** -/// (§FS-check.1.3.2, §FS-config.3.5.4). `root_scope_roots` walks the `include` roots -/// before the config root `--full` adds, so the surviving spelling is the one the -/// plain run reports, and `--full` stays purely additive: it appends out-of-scope -/// lines and never restates an in-scope one under a second name. Within a single -/// root the caller has already sorted, so "first" there is the lexicographically -/// first path rather than whatever readdir happened to say (§FS-errors.4.1). -/// -/// `resolved` covers the files that can wear a second name, and the paths they -/// resolve to are the only ones another file can turn out to be — so every other -/// file is answered by a lookup in that small target set and is never resolved at -/// all. A repository with one symlink pays one `realpath` and not one per file, -/// which is what a flag saying "this tree has a link in it" could not do -/// (§GOAL-fast-feedback, §AR-scanner.1.8). -fn dedup_by_file_identity(files: &mut Vec, resolved: &AliasTargets) { - let targets: std::collections::HashSet<&Path> = - resolved.values().map(PathBuf::as_path).collect(); - let mut seen = BTreeSet::new(); - files.retain(|file| { - let key = resolved - .get(file.as_path()) - .map_or(file.as_path(), PathBuf::as_path); - !targets.contains(key) || seen.insert(key.to_path_buf()) - }); -} - /// Direct `e2e/cases//` directories are E2E manifest declarations /// (§AR-scanner.6.1), so the ordinary file walk must not scan their fixture repos. pub(super) fn is_direct_e2e_case_dir( diff --git a/crates/grund-core/src/scanner/walk_reporting.rs b/crates/grund-core/src/scanner/walk_reporting.rs new file mode 100644 index 000000000..c353ceb76 --- /dev/null +++ b/crates/grund-core/src/scanner/walk_reporting.rs @@ -0,0 +1,331 @@ +//! Reporting traversal and its source-retaining adapter (§AR-scanner.1, +//! §FS-distribution.3.3.2). Walk setup and filtering remain in `walk.rs`. + +use anyhow::Result; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::tree::ScanError; +use super::walk::{scan_roots, scannable_walker}; +use super::walk_boundaries::{is_scannable, outward_directory_link_root, owned_by_another_project}; +use super::walk_errors::{symlink_loop_report, walk_error_report}; +use crate::config::{Config, canonical_config_root}; +use crate::model::{physical_path_key, sort_path_key}; + +/// The tree walk for the callers that ask a yes/no question about the tree and +/// nothing else — today the `--cross-refs` auto-enable probe, which wants to know +/// whether the scope holds any Markdown (§FS-fmt.6.6). Every caller that *reports* +/// takes `walk_scannable_files_reporting`, so an unresolvable link reaches the +/// report rather than being dropped here (§FS-config.3.5.5, §FS-check.2.4): this one +/// is walking a tree that the reporting walk is about to walk again and account +/// for, so repeating its errors would print each of them twice. +/// +/// The tree walk: which roots a scan starts from, which files it yields, and what +/// it does with a path it cannot read (§AR-scanner.1). It sits beside +/// `file_pass.rs` rather than inside it because the two are different machines — +/// that one is a line-by-line pass over a file's text, this one is a directory +/// traversal — and they meet only at the file list one hands the other. +pub(crate) fn walk_scannable_files( + config: &Config, + scope: Option<&Path>, + explicit_scope: bool, +) -> Result> { + Ok(walk_scannable_files_reporting(config, scope, explicit_scope)?.files) +} + +/// What one walk of the tree produced (§AR-scanner.1). +pub(crate) struct WalkedTree { + /// The scannable files, sorted, one entry per physical file (§FS-errors.4.1). + pub(crate) files: Vec, + /// The paths the walk could not read, for the caller to report (§FS-check.2.4). + pub(crate) errors: Vec, + /// The files that are in this tree only by a link: their physical path is + /// outside the config root. Read like any other file (§FS-config.3.5.1) — + /// `fmt --write` is the caller that treats them differently, because + /// rewriting one edits a file the project does not own (§FS-fmt.2.3.2). + pub(crate) outside_root: BTreeSet, + /// Every directory the walk descended into, scan roots included, sorted and + /// deduplicated (§FS-errors.4.1). Carried out rather than asked about here: the + /// scanner never asks "am I in a workspace?" (§AR-workspace.1), and the one + /// caller of this list — the unlisted-`[workspace]` rule of §FS-check.3.29 — + /// probes each directory for a config and answers the claim above the walk. + pub(crate) dirs: Vec, +} + +/// The tree walk (§AR-scanner.1): from each scan root, descend skipping hidden and +/// `[scan] exclude` directories, honouring `.gitignore` and friends unless +/// `respect_gitignore = false` (§AR-scanner.1.1, §FS-config.3.5.15), following +/// symlinks, keeping only scannable files, in a sorted order so findings are +/// deterministic (§FS-errors.4.1). Returns the paths it could not read beside the +/// files, for the caller to report (§FS-check.2.4). +/// +/// Why `aliasable` is a list and not a flag: the identity pass resolves what is in +/// it and compares everything else by path, so one link in a repository costs one +/// `realpath` and not one per file. +/// +/// Paths stay in-tree. Following a symlink does not change the entry's path, and +/// that spelling is what the directory filter below and every finding are expressed +/// in. The boundary filter can therefore compare precomputed suffixes: `strip_prefix` +/// only removes the root, so the descendant suffix is invariant under symlink +/// resolution — the compare works even if `scan_root` itself is a symlink. +/// +/// Why the other-project test after resolution is cheap: the alias resolution is +/// already paid for, so the boundary costs a prefix test over the files that can +/// wear a second name, and nothing at all for a run that loaded no workspace. +/// +/// Why the error list is sorted and deduplicated here: the text report sorts before +/// printing while the API surface hands the list over as it stands, so one sort +/// serves both; and overlapping roots — plus `--full`, which walks every `include` +/// root beside the config root that contains it — meet the same broken link once per +/// root, so an undeduplicated list prints every such error twice. +/// +/// Why `outside_root` compares physical paths on both sides: a config root reached +/// through a link contains none of the paths its own files resolve to, and +/// `fmt --write` would then refuse to rewrite the whole repository. +pub(crate) fn walk_scannable_files_reporting( + config: &Config, + scope: Option<&Path>, + explicit_scope: bool, +) -> Result { + walk_scannable_files_with_sources(config, scope, explicit_scope, &mut |_, _| {}) +} + +/// The same reporting walk, retaining each admitted error's original source +/// beside its unchanged scan record (§FS-distribution.3.3.2). The callback runs +/// before sorting/deduplication; callers match sources by the complete record. +pub(crate) fn walk_scannable_files_with_sources( + config: &Config, + scope: Option<&Path>, + explicit_scope: bool, + on_error: &mut dyn FnMut(&ScanError, ignore::Error), +) -> Result { + let roots = scan_roots(config, scope, explicit_scope)?; + // §FS-config.3.5.4: an aliased root, or a symlink met on the way down, is what + // hands the same file to the walk under two spellings — nothing else does, so + // a tree with neither never pays for the identity pass (§GOAL-fast-feedback). + let mut aliasable = BTreeSet::new(); + let mut files = Vec::new(); + let mut errors = Vec::new(); + // §FS-check.3.29.11: the directories the walk met, for the rule that asks which of + // them carries a `[workspace]` block nothing claims. Collected here because the + // entries are already being enumerated — no second traversal (§GOAL-fast-feedback). + let mut dirs = Vec::new(); + // Where this project physically is, for every comparison below that reads a + // resolved path. Equal to `config.root` for the roots `grund` discovers, which + // are canonical already (§FS-config.1) — one `stat` per run either way. + let physical_root = canonical_config_root(config); + for scan_root in roots { + if !scan_root.exists() { + continue; + } + let canonical_scan_root = + fs::canonicalize(&scan_root).unwrap_or_else(|_| scan_root.to_path_buf()); + // §FS-config.3.5.1: a scan root reached through a directory link uses + // the same canonical project-root boundary as a link met during descent. + + // §AR-workspace.6: a root scan starts outside member namespaces; an + // included path at or below a member boundary belongs to the member scan, + // and one in another project belongs there (§FS-workspace.6.2). + if outward_directory_link_root(&scan_root, &canonical_scan_root, &physical_root) + || config + .workspace_boundary_roots + .iter() + .any(|root| canonical_scan_root.starts_with(root)) + || owned_by_another_project(config, &physical_root, &canonical_scan_root) + { + continue; + } + // A root that resolves elsewhere reaches every one of its files under a + // spelling that is not the file's own, so all of them can alias (§FS-check.1.3.2). + let root_is_aliased = canonical_scan_root != scan_root; + if scan_root.is_file() { + if is_scannable(&scan_root, config) { + // §FS-check.1.3.6.1: a file handed as the path is walked beside roots + // that reach it under its own name, so a link's spelling has to + // resolve to the same one read (§FS-config.3.5.4). + if root_is_aliased { + aliasable.insert(scan_root.clone()); + } + files.push(scan_root); + } + continue; + } + // The directory links the filter met, shared with the loop below: a file + // under one of them is reached under a spelling that is not its own, the + // same as a file that is a link itself (§AR-scanner.1.8). + let link_roots = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + // §FS-config.3.5.5: the directory links the filter pruned as loops, for the + // report to be raised from without the descent the walker would need to + // notice them (§AR-scanner.1.9). + let looping_links = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let walker = scannable_walker( + config, + &scan_root, + &canonical_scan_root, + &physical_root, + &link_roots, + &looping_links, + ); + let mut root_files = Vec::new(); + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + // §FS-config.3.5.5: a link the walk cannot resolve is a file the scan + // cannot read — reported at its own path, the walk continuing past it + // (§FS-check.2.4). Failing the scan would let it take the whole report. + Err(err) => { + if let Some(report) = walk_error_report(&err, config, &scan_root) { + on_error(&report, err); + errors.push(report); + } + continue; + } + }; + // §FS-check.3.29.11: a directory is not a scannable file, so it falls out + // one line below. Its path is what the unlisted-`[workspace]` rule needs, + // and the scan root itself — the entry at depth 0 — is one of them. + if entry + .file_type() + .is_some_and(|file_type| file_type.is_dir()) + { + dirs.push(entry.path().to_path_buf()); + } + if !entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + || !is_scannable(entry.path(), config) + { + continue; + } + if root_is_aliased || entry.path_is_symlink() || under_link(&link_roots, entry.path()) { + aliasable.insert(entry.path().to_path_buf()); + } + root_files.push(entry.path().to_path_buf()); + } + // §FS-config.3.5.5: the loops the filter pruned. The link is owed the same + // report a loop the walker found earns, and by the same gates — it is the + // descent, not the report, that pruning removes. + for (link, target) in looping_links + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + { + // The in-tree name of the directory the link reaches back into, or + // nothing where the target reaches over the walk root. + let ancestor = target + .strip_prefix(&canonical_scan_root) + .ok() + .map(|rest| scan_root.join(rest)); + errors.extend(symlink_loop_report(&link, ancestor.as_deref(), config)); + } + // §FS-errors.4.1: within one root the order is the filesystem's, and the + // first-seen rule below turns that into a choice of *spelling*. Sorting each + // root's list first makes the choice ours: earlier root wins, then lexicographic. + root_files.sort_by_key(|path| sort_path_key(path)); + files.append(&mut root_files); + } + // One file, one read (§FS-check.1.3.2, §FS-config.3.5.4). Two spellings first, + // while the list is still in walk order and first-seen wins; then the + // byte-identical ones, which the sort has just brought together. + let resolved = resolve_aliasable(&aliasable); + // §FS-workspace.6.2: the directory filter stops a *directory* link at another + // project's root, and a link straight onto one of its files is the same + // crossing one entry lower down. + if !config.workspace_project_roots.is_empty() { + files.retain(|file| { + !resolved + .get(file.as_path()) + .is_some_and(|physical| owned_by_another_project(config, &physical_root, physical)) + }); + } + if !resolved.is_empty() { + dedup_by_file_identity(&mut files, &resolved); + } + files.sort_by_key(|path| sort_path_key(path)); + // The roots may overlap — `include = ["docs", "docs/api"]` names one subtree + // twice, and under `--full` every `include` root is walked beside the config root + // containing it (§FS-check.1.3.2). A file read twice duplicates its own declaration. + files.dedup(); + // §FS-errors.4.1: the walk meets its unreadable paths in readdir order, so they + // are sorted once here for both surfaces, then deduplicated — printing a scan + // error twice is what the additivity rule of §FS-check.1.3.4 forbids. + errors.sort_by_key(|(path, message)| (sort_path_key(path), message.clone())); + errors.dedup(); + // §FS-fmt.2.3.2: a file whose physical path is not under the config root is in + // this tree only by the link that reaches it. The resolution is already paid + // for above, so this is a prefix test over the links and nothing more. + let outside_root = files + .iter() + .filter(|file| { + resolved + .get(file.as_path()) + .is_some_and(|physical| !physical.starts_with(&physical_root)) + }) + .cloned() + .collect(); + // §FS-errors.4.1: overlapping roots — and `--full`, which walks every `include` + // root beside the config root containing it — meet the same directory once per + // root, so one sort and one dedup make the candidate list a set. + dirs.sort_by_key(|path| sort_path_key(path)); + dirs.dedup(); + Ok(WalkedTree { + files, + errors, + outside_root, + dirs, + }) +} + +/// Whether the walk reached this path *through* one of the directory links it +/// recorded — which makes the file's spelling not its own, exactly as being a +/// link itself would (§AR-scanner.1.8). +fn under_link(link_roots: &std::sync::Mutex>, path: &Path) -> bool { + link_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|root| path.starts_with(root)) +} + +/// Where each file that can wear a second name physically is: the in-tree path it +/// was walked under, mapped to the path `canonicalize` resolves it to. +type AliasTargets = std::collections::HashMap; + +/// Resolve the files the walk saw arrive under a spelling that is not their own — +/// a link, a file below a directory link, or any file of an aliased root. This is +/// the only `canonicalize` the walk spends: everything else is answered by +/// comparing a path against what these resolved to (§AR-scanner.1.8, +/// §GOAL-fast-feedback). +fn resolve_aliasable(aliasable: &BTreeSet) -> AliasTargets { + aliasable + .iter() + .map(|file| (file.clone(), physical_path_key(file))) + .collect() +} + +/// Collapse the files reached under two spellings, keeping the **first** +/// (§FS-check.1.3.2, §FS-config.3.5.4). `root_scope_roots` walks the `include` roots +/// before the config root `--full` adds, so the surviving spelling is the one the +/// plain run reports, and `--full` stays purely additive: it appends out-of-scope +/// lines and never restates an in-scope one under a second name. Within a single +/// root the caller has already sorted, so "first" there is the lexicographically +/// first path rather than whatever readdir happened to say (§FS-errors.4.1). +/// +/// `resolved` covers the files that can wear a second name, and the paths they +/// resolve to are the only ones another file can turn out to be — so every other +/// file is answered by a lookup in that small target set and is never resolved at +/// all. A repository with one symlink pays one `realpath` and not one per file, +/// which is what a flag saying "this tree has a link in it" could not do +/// (§GOAL-fast-feedback, §AR-scanner.1.8). +fn dedup_by_file_identity(files: &mut Vec, resolved: &AliasTargets) { + let targets: std::collections::HashSet<&Path> = + resolved.values().map(PathBuf::as_path).collect(); + let mut seen = BTreeSet::new(); + files.retain(|file| { + let key = resolved + .get(file.as_path()) + .map_or(file.as_path(), PathBuf::as_path); + !targets.contains(key) || seen.insert(key.to_path_buf()) + }); +} diff --git a/crates/grund-core/src/writers/fetch_write.rs b/crates/grund-core/src/writers/fetch_write.rs index 3d0726d2c..4a5747fe7 100644 --- a/crates/grund-core/src/writers/fetch_write.rs +++ b/crates/grund-core/src/writers/fetch_write.rs @@ -9,8 +9,49 @@ use crate::config::Config; use crate::grammar::{ Grammar, markdown_fence_delimiter, near_miss_heading, parse_id_arg, parse_longest_id_prefix, }; -use crate::model::Id; -use crate::scanner::{markdown_heading_level, walk_scannable_files_reporting}; +use crate::model::{Id, OperationDiagnostic, format_path}; +use crate::scanner::{markdown_heading_level, walk_scannable_files_with_sources}; + +/// `ignore::Error` owns the I/O error but does not expose it through `source()`. +/// Retain both its original context and that original I/O cause for embedders +/// (§FS-distribution.3.3.2), without changing the scanner's rendered reason. +#[derive(Debug)] +struct SnapshotWalkSource(ignore::Error); + +impl std::fmt::Display for SnapshotWalkSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for SnapshotWalkSource { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.io_error().map(|io| io as &dyn std::error::Error) + } +} + +/// Locate the fallible walk or an admitted traversal source while preserving the +/// existing fetch refusal text (§FS-distribution.3.3.2, §FS-fetch.7). +fn fetch_walk_failure( + diagnostic: &mut Option, + path: &Path, + error: anyhow::Error, + message: String, +) -> FetchFailure { + let source = match error + .chain() + .find_map(|source| source.downcast_ref::()) + { + Some(io) => OperationDiagnostic::filesystem(path, io, message.clone()), + None => { + let mut source = OperationDiagnostic::new("operation", "fetch", message.clone()); + source.path = Some(format_path(path)); + source + } + }; + *diagnostic = Some(error.context(source)); + fetch_operational(message) +} #[derive(Clone)] struct SnapshotDeclaration { @@ -189,18 +230,30 @@ pub(super) fn write_folder_home( } // §FS-fetch.5: discovery uses the scanner's recursive folder traversal, // including its ignore, exclusion, hidden-file, and symlink semantics. - let walked = walk_scannable_files_reporting(config, Some(folder), true).map_err(|err| { - fetch_operational(format!( - "cannot read snapshot folder {}: {err:#}", - folder.display() - )) - })?; - if let Some((path, message)) = walked.errors.first() { - return Err(fetch_operational(format!( + let mut sources = std::collections::BTreeMap::new(); + let walked = + walk_scannable_files_with_sources(config, Some(folder), true, &mut |report, error| { + sources.entry(report.clone()).or_insert(error); + }) + .map_err(|err| { + let message = format!("cannot read snapshot folder {}: {err:#}", folder.display()); + fetch_walk_failure(diagnostic, folder, err, message) + })?; + if let Some(report @ (path, message)) = walked.errors.first() { + let message = format!( "cannot read snapshot folder {} at {}: {message}", folder.display(), path.display() - ))); + ); + return Err(match sources.remove(report) { + Some(source) => fetch_walk_failure( + diagnostic, + path, + anyhow::Error::new(SnapshotWalkSource(source)), + message, + ), + None => fetch_operational(message), + }); } for path in walked.files { if path.extension().and_then(|extension| extension.to_str()) != Some("md") { diff --git a/tests/bindings/test_regressions.py b/tests/bindings/test_regressions.py index 67dab3f6a..668061df8 100644 --- a/tests/bindings/test_regressions.py +++ b/tests/bindings/test_regressions.py @@ -23,9 +23,9 @@ def fetch_fixture(self, temp): (root / "unread/note.md").write_text("This block is intentionally not scanned.\n") return root - def assert_fetch_filesystem_failure(self, root, path, os_error): + def assert_fetch_filesystem_failure(self, root, path, os_error, *, check_bytes=True): args, options = ("alpha/TICKET-1234",), {"write": True} - before = tree_bytes(root) + before = tree_bytes(root) if check_bytes else None expected, wire, _ = rust_call("fetch", root, args, options) actual = python_call(self.module, "fetch", root, args, options) self.assertEqual(expected, actual, "complete failure/caution parity") @@ -44,7 +44,9 @@ def assert_fetch_filesystem_failure(self, root, path, os_error): self.assertTrue(any(os.strerror(os_error) in cause for cause in failure.causes)) self.assertTrue(failure.run_cautions, "workspace cautions must survive the I/O failure") self.assertEqual(actual["run_cautions"], plain(failure.run_cautions)) - self.assertEqual(before, tree_bytes(root), "failed fetch must preserve bytes") + if check_bytes: + self.assertEqual(before, tree_bytes(root), "failed fetch must preserve bytes") + return failure @unittest.skipUnless(os.name == "posix", "fixture fetcher is a POSIX shell executable") def test_missing_fetch_executable_retains_io_source(self): @@ -70,6 +72,32 @@ def test_denied_fetch_snapshot_write_retains_io_source(self): finally: home.chmod(permissions) + @unittest.skipUnless(os.name == "posix", "requires POSIX directory traversal permissions") + def test_denied_fetch_snapshot_traversal_retains_io_source(self): + if os.geteuid() == 0: + self.skipTest("root bypasses traversal permissions; run as an unprivileged user") + with temporary() as temp: + root = self.fetch_fixture(temp) + home = root / "packages/alpha/docs/tickets" + home.mkdir(parents=True, exist_ok=True) + before = tree_bytes(root) + permissions = home.stat().st_mode + home.chmod(0o000) + try: + with self.assertRaises(PermissionError) as caught: + with os.scandir(home) as entries: + list(entries) + self.assertEqual(errno.EACCES, caught.exception.errno) + # Inspect bytes only after restoring the unreadable directory. + failure = self.assert_fetch_filesystem_failure( + root, home, caught.exception.errno, check_bytes=False) + self.assertIn( + f"{os.strerror(caught.exception.errno)} (os error {caught.exception.errno})", + failure.causes, "retain the original I/O cause, not only the scan reason") + finally: + home.chmod(permissions) + self.assertEqual(before, tree_bytes(root), "failed traversal must preserve bytes") + @unittest.skipUnless(os.name == "posix", "requires POSIX directory symlink semantics") def test_explicit_symlink_parent_root_matches_engine(self): with temporary() as first, temporary() as second: From 984baf6ca391af56ee67f40bf60e658ee1818cd2 Mon Sep 17 00:00:00 2001 From: Vojin Jovanovic Date: Tue, 6 Oct 2026 05:13:43 +0200 Subject: [PATCH 5/5] Preserve Python OS error codes and complete the core API inventory --- .../grund-core/src/api/embedding_failure.rs | 8 ++++- crates/grund-py/README.md | 2 +- ...-22-grund-core-public-surface-inventory.md | 29 ++++++++++++------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/grund-core/src/api/embedding_failure.rs b/crates/grund-core/src/api/embedding_failure.rs index 2037c688e..d6d290e0d 100644 --- a/crates/grund-core/src/api/embedding_failure.rs +++ b/crates/grund-core/src/api/embedding_failure.rs @@ -26,7 +26,13 @@ pub(super) fn error_data(error: anyhow::Error, cautions: &[Finding]) -> Value { result["code"] = json!(query.code); result["sites"] = query.sites.data(); } - if let Some(io) = error.downcast_ref::() { + // §FS-distribution.3.3.2: an I/O wrapper can retain the OS cause in its source. + if let Some(io) = error + .chain() + .filter_map(|source| source.downcast_ref::()) + .find(|io| io.raw_os_error().is_some()) + .or_else(|| error.downcast_ref::()) + { if result["kind"] == "operation" { result["kind"] = json!("filesystem"); } diff --git a/crates/grund-py/README.md b/crates/grund-py/README.md index e601d148f..75227eda3 100644 --- a/crates/grund-py/README.md +++ b/crates/grund-py/README.md @@ -1,7 +1,7 @@ # grund Python frontend The local Python API embeds `grund-core` through PyO3, as specified by -[§FS-distribution.3.3](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-distribution.md#33-python-grund-pypi-package). +[§FS-distribution.3.3](../../docs/functional-spec/FS-distribution.md#33-python-grund-pypi-package). PyPI publication is pending. Build/install from the repository root: ```sh diff --git a/docs/discussions/proposals/2026-09-22-grund-core-public-surface-inventory.md b/docs/discussions/proposals/2026-09-22-grund-core-public-surface-inventory.md index 2d47d2cfe..9fa2573e8 100644 --- a/docs/discussions/proposals/2026-09-22-grund-core-public-surface-inventory.md +++ b/docs/discussions/proposals/2026-09-22-grund-core-public-surface-inventory.md @@ -2,7 +2,7 @@ The evidence [§DISC-grund-core-public-surface](2026-09-22-grund-core-public-surface.md#disc-grund-core-public-surface-what-grund-cores-public-root-surface-is-and-what-it-should-be) argues from. It classifies nothing on its own authority: the counting convention is [§DISC-grund-core-public-surface.3](2026-09-22-grund-core-public-surface.md#3-what-counts-as-a-public-root-name), the columns are [§DISC-grund-core-public-surface.4](2026-09-22-grund-core-public-surface.md#4-what-every-inventory-row-carries), and `tests/integration/test_public_surface_inventory.py` holds the first column equal to the crate root's export list ([§DISC-grund-core-public-surface.5](2026-09-22-grund-core-public-surface.md#5-what-is-checked)). -The original 182-name audit uses the baseline [§DISC-grund-core-public-surface.2](2026-09-22-grund-core-public-surface.md#2-the-audited-baseline) records — `7283e23bb0`, workspace `0.14.2-dev`, `v0.14.1-26-g7283e23bb0`. Four additive completion exports were read at `abba38146e`, workspace `0.16.2-dev`; the existing rows retain their original evidence. **186 rows, one per public root name.** The counts below include those additions. +The original 182-name audit uses the baseline [§DISC-grund-core-public-surface.2](2026-09-22-grund-core-public-surface.md#2-the-audited-baseline) records — `7283e23bb0`, workspace `0.14.2-dev`, `v0.14.1-26-g7283e23bb0`. Four additive completion exports were read at `abba38146e` and seven additive Python-binding exports at `fa57931b17`, both workspace `0.16.2-dev`; the existing rows retain their original evidence. **193 rows, one per public root name.** The counts below include both additions; the Python frontend now supplies consumer evidence alongside CLI and LSP. ## How a cell reads @@ -14,7 +14,7 @@ Paths are relative to `crates/`, and an evidence location is one site, not every **Spec** — how [§FS-distribution.3.1](../../functional-spec/FS-distribution.md#31-rust-grund-core-crate) reaches the name. `named`: its text or its example writes the symbol. `related API`: a Rustdoc-visible function of the `api` component, which [§AR-system.2.9](../../architecture/README.md#29-api) makes the embedding surface and which that point's "and related APIs" reaches. `data type of X`: it appears in the signature of a `named` or `related API` entry point, transitively — one such path is named, not all of them. `no`: outside that closure, so the specification supports embedding it nowhere. -**Consumers** — a textual `use grund_core::…` or `grund_core::…` in `grund-cli`, `grund-lsp` or their test crates. `none found` is the result of searching **this repository** and never a claim about embedders outside it ([§DISC-grund-core-public-surface.4](2026-09-22-grund-core-public-surface.md#4-what-every-inventory-row-carries)). +**Consumers** — a textual `use grund_core::…` or `grund_core::…` in `grund-cli`, `grund-lsp`, `grund-py` or their test crates. `none found` is the result of searching **this repository** and never a claim about embedders outside it ([§DISC-grund-core-public-surface.4](2026-09-22-grund-core-public-surface.md#4-what-every-inventory-row-carries)). **Structural** — for a name no frontend spells: the chain from something one does call, so `via check_with_run_warnings → CheckOutput` means the CLI receives the type without naming it. `—` where the consumer column already answers. `none found` in both columns means no repository consumer of either kind was found. @@ -24,22 +24,22 @@ Paths are relative to `crates/`, and an evidence location is one site, not every | Reading | Count | | --- | --- | -| Public root names | 186 | -| Rustdoc-visible | 173 | +| Public root names | 193 | +| Rustdoc-visible | 180 | | `#[doc(hidden)]` | 13 | -| Named, a related API, or a data type of one | 107 | -| Outside that closure | 79 | -| Named by a frontend or its tests | 114 | +| Named, a related API, or a data type of one | 112 | +| Outside that closure | 81 | +| Named by a frontend or its tests | 116 | | Reached structurally only | 34 | -| No repository consumer of either kind | 38 | -| Disposition keep | 112 | +| No repository consumer of either kind | 43 | +| Disposition keep | 117 | | Disposition keep, hidden | 13 | -| Disposition keep, name in the spec | 16 | +| Disposition keep, name in the spec | 18 | | Disposition hide | 11 | | Disposition retire with the ramp | 0 | | Disposition facade, then retire | 34 | -The two count columns do not line up, and they are not meant to: 38 names have no repository consumer while 79 sit outside what the specification reaches, and the two sets overlap only in part. A name can be specification-supported and unused here — that is most of what `scan` returns — or used by a frontend and supported nowhere, which is what a seam is. +The two count columns do not line up, and they are not meant to: 43 names have no repository consumer while 81 sit outside what the specification reaches, and the two sets overlap only in part. A name can be specification-supported and unused here — that is most of what `scan` returns — or used by a frontend and supported nowhere, which is what a seam is. ## The inventory @@ -82,6 +82,8 @@ The two count columns do not line up, and they are not meant to: 38 names have n | `ConversationTarget` | writers | visible | no | grund-cli `grund-cli/src/lib.rs:32` | — | facade, then retire | | `cover` | api | visible | related API | grund-cli `grund-cli/src/lib.rs:13` | — | keep | | `cover_text` | api | visible | related API | none found | none found | keep | +| `cover_text_with_run_warnings` | api | visible | related API | none found | none found | keep | +| `cover_with_run_warnings` | api | visible | related API | none found | none found | keep | | `CoverCitation` | api | visible | data type of `cover` | grund-cli `grund-cli/src/lib.rs:13` | — | keep | | `CoverEntry` | api | visible | data type of `cover` | grund-cli `grund-cli/src/cli_cover.rs:106` | — | keep | | `CoverOpts` | api | visible | data type of `cover` | grund-cli `grund-cli/src/lib.rs:13` | — | keep | @@ -98,6 +100,8 @@ The two count columns do not line up, and they are not meant to: 38 names have n | `E2eSpecRef` | model | visible | data type of `Findings` | none found | none found | keep | | `effective_config` | api | visible | related API | grund-cli `grund-cli/src/lib.rs:13`; grund-lsp `grund-lsp/src/integrations.rs:103` | — | keep | | `EmbeddedValueRoot` | model | visible | data type of `Findings` | none found | none found | keep | +| `embedding_call` | api | visible | related API | grund-py `grund-py/src/lib.rs:3` | — | keep | +| `EmbeddingRequest` | api | visible | data type of `embedding_call` | grund-py `grund-py/src/lib.rs:3` | — | keep | | `expand_target` | writers | visible | no | grund-cli `grund-cli/src/lib.rs:32` | — | facade, then retire | | `fetch_snapshot` | writers | visible | no | none found | none found | keep, name in the spec | | `fetch_snapshot_with_run_warnings` | writers | `#[doc(hidden)]` | no | grund-cli `grund-cli/src/lib.rs:13` | — | keep, hidden | @@ -113,6 +117,7 @@ The two count columns do not line up, and they are not meant to: 38 names have n | `FmtOutput` | api | visible | data type of `format_references` | none found | via format_references → FmtOutput `grund-cli/src/cli_fmt.rs:35` | keep | | `FmtScanAbort` | writers | visible | no | grund-cli `grund-cli/src/lib.rs:13` | — | keep | | `format_references` | api | visible | related API | grund-cli `grund-cli/src/lib.rs:13` | — | keep | +| `format_references_with_run_warnings` | api | visible | related API | none found | none found | keep | | `GLOBAL_AGENT_INSTRUCTION_TARGETS` | writers | visible | no | grund-cli `grund-cli/src/lib.rs:32` | — | facade, then retire | | `GlobalAgentTarget` | writers | visible | no | none found | via GLOBAL_AGENT_INSTRUCTION_TARGETS → GlobalAgentTarget `grund-cli/src/lib.rs:32` | facade, then retire | | `Grammar` | grammar | visible | data type of `validate_config` | none found | via Config → Grammar `grund-cli/src/lib.rs:13` | keep | @@ -150,6 +155,7 @@ The two count columns do not line up, and they are not meant to: 38 names have n | `LinkSupport` | writers | visible | no | none found | via GLOBAL_AGENT_INSTRUCTION_TARGETS → GlobalAgentTarget → LinkSupport `grund-cli/src/lib.rs:32` | facade, then retire | | `list` | api | visible | related API | none found | none found | keep | | `list_sizes` | queries | visible | no | grund-cli `grund-cli/src/lib.rs:13` | — | keep, name in the spec | +| `list_sizes_with_run_warnings` | queries | visible | no | none found | none found | keep, name in the spec | | `list_with_run_warnings` | api | `#[doc(hidden)]` | no | grund-cli `grund-cli/src/lib.rs:13` | — | keep, hidden | | `ListEntry` | api | visible | data type of `list` | grund-cli `grund-cli/src/lib.rs:13` | — | keep | | `ListOpts` | api | visible | data type of `list` | grund-cli `grund-cli/src/lib.rs:13` | — | keep | @@ -181,6 +187,7 @@ The two count columns do not line up, and they are not meant to: 38 names have n | `NearMissHeading` | model | visible | data type of `Findings` | none found | none found | keep | | `needs_wezterm_wiring` | writers | visible | no | grund-cli `grund-cli/src/lib.rs:32` | — | facade, then retire | | `on_type_line_edits` | queries | visible | no | grund-lsp `grund-lsp/src/lib.rs:4` | — | hide | +| `OperationDiagnostic` | model | visible | no | none found | none found | keep, name in the spec | | `PointSizeUnit` | config | visible | data type of `validate_config` | grund-cli `grund-cli/src/lib.rs:13` | — | keep | | `propose_id` | api | visible | related API | none found | none found | keep | | `propose_id_with_run_warnings` | api | `#[doc(hidden)]` | no | grund-cli `grund-cli/src/lib.rs:13` | — | keep, hidden |