From 166cd1258511efc28eaa944bd3f1372602f8fbcf Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Tue, 13 Oct 2026 12:13:26 +0200 Subject: [PATCH] Harden the codebase: types, module layers and checks catch wrong code Implement the 13 findings of the hardening audit. Each one moves a mistake from code review to a check that fails in CI: - Statuses, levels, verdicts and actions are Literal types. pyright (standard mode, exhaustive match) runs in `make check`. - Exit codes are one ExitCode enum. A usage error exits with 1. - Finding is frozen. Its `blocking` value is computed from the corpus and the verification. Only validate_findings and read_findings make a Finding. - git.py and claude.py are the only modules that start processes. import-linter checks that deterministic modules never import a model module and that only shall.jev imports the TypeSafe SDK. - The config rejects unknown keys and bad hooks.actions rows in the local shall.yaml. In the extended org config, shall drops a row that it cannot read and writes a note to stderr, so the hooks do not fail open. - Agents and the review model are pinned to claude-sonnet-5. A test checks that they agree. - tests/fakes.py gives a FakeJev that pyright checks against JevLike. - The JTOOL RFCs list the test or check for each statement. - CI runs `make check` and pins the Claude Code CLI. Code-review findings that were fixed (the code that showed each one): - cli.py cmd_list printed "None" for an invalid status: format of rfc.status. Now Rfc.status_label. - cli.py cmd_eval_verify ignored review.verify_model. Now Config.verify_model, also used by verify.py and triage.py. - config.py load_json crashed on a cache file that is not an object. - hooks.py action_for raised on an org row that it could not read, so the whole hook failed open. Now config.py drops the row at load. - ci.yml installed an unpinned Claude Code CLI. - pyproject.toml: pyright needed a network download of Node. Now pyright[nodejs]. Self-review: claude.py run_claude retried the `claude -p` call at once, with no delay (JRFC-0006.2). Now it waits with backoff and jitter. Verification: make test (200 passed), make check, make self-check, make test-plugin (7 passed), make self-review (0 findings), /verify (hook drives: force push and secret key denied, write and Stop fail-open; CLI probes for config and status). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 45 ++ .shall/index/index.json | 32 +- .shall/rfcs/JTOOL-0001-jev-usage.md | 8 + .shall/rfcs/JTOOL-0002-agent-boundaries.md | 10 + .shall/rfcs/JTOOL-0003-cli-contract.md | 11 + .shall/rfcs/JTOOL-0004-plugin-content.md | 7 + AGENTS.md | 6 +- Makefile | 19 +- docs/development.mdx | 36 +- eval/realpr/run.py | 2 +- eval/scale/propose_labels.py | 2 +- eval/scan/run.py | 2 +- plugins/shall/.claude-plugin/plugin.json | 2 +- .../shall/agents/shall-comment-verifier.md | 2 +- plugins/shall/agents/shall-reviewer.md | 2 +- plugins/shall/agents/shall-verifier.md | 2 +- plugins/shall/bin/shall-pr-review | 5 +- plugins/shall/tooling/pyproject.toml | 36 ++ plugins/shall/tooling/src/shall/artifact.py | 2 + plugins/shall/tooling/src/shall/claude.py | 164 +++++++ .../shall/tooling/src/shall/claude_judge.py | 22 +- plugins/shall/tooling/src/shall/cli.py | 221 ++++++---- plugins/shall/tooling/src/shall/codegraph.py | 39 +- plugins/shall/tooling/src/shall/config.py | 119 ++++- plugins/shall/tooling/src/shall/conflicts.py | 18 +- plugins/shall/tooling/src/shall/corpus.py | 48 ++- plugins/shall/tooling/src/shall/effects.py | 2 +- plugins/shall/tooling/src/shall/evaluate.py | 48 +-- plugins/shall/tooling/src/shall/fetch.py | 22 +- plugins/shall/tooling/src/shall/findings.py | 383 +++++++++++++++++ plugins/shall/tooling/src/shall/git.py | 55 +++ plugins/shall/tooling/src/shall/health.py | 9 +- plugins/shall/tooling/src/shall/hooks.py | 57 +-- plugins/shall/tooling/src/shall/jev.py | 52 ++- plugins/shall/tooling/src/shall/publish.py | 2 +- plugins/shall/tooling/src/shall/review.py | 405 +----------------- plugins/shall/tooling/src/shall/scan.py | 20 +- plugins/shall/tooling/src/shall/select.py | 11 +- plugins/shall/tooling/src/shall/triage.py | 90 ++-- plugins/shall/tooling/src/shall/verify.py | 169 ++++---- plugins/shall/tooling/tests/fakes.py | 66 +++ .../shall/tooling/tests/test_answer_cache.py | 12 +- .../shall/tooling/tests/test_boundaries.py | 324 ++++++++++++++ plugins/shall/tooling/tests/test_effects.py | 4 +- plugins/shall/tooling/tests/test_hooks.py | 26 +- plugins/shall/tooling/tests/test_layers.py | 20 + plugins/shall/tooling/tests/test_publish.py | 10 +- plugins/shall/tooling/tests/test_scan.py | 28 +- plugins/shall/tooling/tests/test_triage.py | 42 +- plugins/shall/tooling/tests/test_verify.py | 60 ++- plugins/shall/tooling/uv.lock | 250 ++++++++++- 51 files changed, 2078 insertions(+), 951 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 plugins/shall/tooling/src/shall/claude.py create mode 100644 plugins/shall/tooling/src/shall/findings.py create mode 100644 plugins/shall/tooling/src/shall/git.py create mode 100644 plugins/shall/tooling/tests/fakes.py create mode 100644 plugins/shall/tooling/tests/test_boundaries.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f374601 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +# This repository's own CI. Deterministic checks only: no model calls, no secrets. +# (ci/github/*.yml are templates for the repositories that use shall.) +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + tooling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: astral-sh/setup-uv@v6 + + - name: Unit and boundary tests + run: make test + + - name: Corpus lint, index is current, types (pyright), module layers (import-linter) + run: make check + + - name: Published statement ids are stable + if: github.event_name == 'pull_request' + run: | + if git show "origin/${{ github.base_ref }}:corpus/index/index.json" > /tmp/base-index.json 2>/dev/null; then + plugins/shall/bin/shall --config shall.yaml lint --quiet --against /tmp/base-index.json + fi + + - name: This repo's own rules (.shall/, prefix JTOOL) + run: make self-check + + plugin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + + - name: Install the claude CLI + run: npm install -g @anthropic-ai/claude-code@2.1.296 # pinned: a new CLI must not turn CI red + + - name: Plugin manifest and hook tests + run: make test-plugin diff --git a/.shall/index/index.json b/.shall/index/index.json index 4b3c143..dfdf824 100644 --- a/.shall/index/index.json +++ b/.shall/index/index.json @@ -1,5 +1,5 @@ { - "corpus_hash": "f96990cddff7f113", + "corpus_hash": "107b6b4976368374", "counts": { "domains": 3, "rfcs": 4, @@ -194,7 +194,7 @@ "level": "MUST", "not_applies_when": "The content does not call Jev and does not use Jev answers.", "rfc": "JTOOL-0001", - "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L32", + "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L40", "status": "enforced", "text": "Code MUST call Jev only through `shall.jev.Jev` (which caches answers, packs requests under the token budget and counts usage) and MUST NOT create a `TypeSafeClient` or `AsyncTypeSafeClient` anywhere else.", "title": "One client, one wrapper" @@ -217,7 +217,7 @@ "level": "MUST", "not_applies_when": "The content does not call Jev and does not use Jev answers.", "rfc": "JTOOL-0001", - "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L40", + "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L48", "status": "enforced", "text": "Jev state MUST contain only the text the questions judge; code MUST NOT put a whole corpus or list into the state and refer to one item by index (such as `existing[i]`) in the question text.", "title": "Small state, no indirection" @@ -240,7 +240,7 @@ "level": "MUST", "not_applies_when": "The content does not call Jev and does not use Jev answers.", "rfc": "JTOOL-0001", - "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L48", + "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L56", "status": "enforced", "text": "A pass/fail decision based on Jev MUST be gated on Noul probabilities or on the summed probability of all failing Choice options, not on the probability of the single top Choice option.", "title": "Absolute gates" @@ -263,7 +263,7 @@ "level": "MUST", "not_applies_when": "The content does not call Jev and does not use Jev answers.", "rfc": "JTOOL-0001", - "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L56", + "source": ".shall/rfcs/JTOOL-0001-jev-usage.md#L64", "status": "enforced", "text": "Model identifiers in configuration and code MUST be pinned versions, not moving aliases such as `jev-latest`, `jev-preview`, `sonnet` or `opus`.", "title": "Pinned models" @@ -286,7 +286,7 @@ "level": "MUST", "not_applies_when": "No agent runs, no agent output is used, and nothing is written to an external system.", "rfc": "JTOOL-0002", - "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L26", + "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L36", "status": "enforced", "text": "The review agent invoked by the pipeline MUST run with no tools (`--tools \"\"`), so that it can only return text.", "title": "Agents run without tools in the pipeline" @@ -309,7 +309,7 @@ "level": "MUST", "not_applies_when": "No agent runs, no agent output is used, and nothing is written to an external system.", "rfc": "JTOOL-0002", - "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L33", + "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L43", "status": "enforced", "text": "Findings returned by an agent MUST pass `validate_findings` (statement selected for the chunk, line or quote present in the artifact) before they reach a report or a comment.", "title": "Agent output is validated before use" @@ -332,7 +332,7 @@ "level": "MUST", "not_applies_when": "No agent runs, no agent output is used, and nothing is written to an external system.", "rfc": "JTOOL-0002", - "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L40", + "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L50", "status": "enforced", "text": "Writes to external systems (GitHub reviews, comments, thread resolution) MUST be performed by deterministic code, never by an agent, and the command that performs them MUST offer a `--dry-run` that performs no write.", "title": "External writes are deterministic and previewable" @@ -355,7 +355,7 @@ "level": "MUST", "not_applies_when": "No agent runs, no agent output is used, and nothing is written to an external system.", "rfc": "JTOOL-0002", - "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L48", + "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L58", "status": "enforced", "text": "Whether a finding is blocking MUST be computed by code from the corpus (RFC status and statement level) and the verification verdict; agent output MUST only lower a finding's severity, never raise it.", "title": "Severity comes from the corpus and verification" @@ -378,7 +378,7 @@ "level": "MUST", "not_applies_when": "No agent runs, no agent output is used, and nothing is written to an external system.", "rfc": "JTOOL-0002", - "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L56", + "source": ".shall/rfcs/JTOOL-0002-agent-boundaries.md#L66", "status": "enforced", "text": "A finding MUST NOT block a merge unless the verifier confirmed it from code or configuration it was shown, and a finding MUST only be dropped as refuted when the verdict cites evidence that exists.", "title": "Blocking needs seen evidence" @@ -401,7 +401,7 @@ "level": "MUST", "not_applies_when": "The content does not change CLI commands, exit codes or generated files.", "rfc": "JTOOL-0003", - "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L25", + "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L36", "status": "enforced", "text": "Commands MUST exit with 0 on success, 1 on errors, 2 on blocking findings and 3 when a Jev request fails after retries, and new failure cases MUST reuse one of these codes.", "title": "Stable exit codes" @@ -424,7 +424,7 @@ "level": "MUST", "not_applies_when": "The content does not change CLI commands, exit codes or generated files.", "rfc": "JTOOL-0003", - "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L32", + "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L43", "status": "enforced", "text": "The commands `lint`, `build`, `new`, `show`, `list`, `catalog`, `init`, `validate`, `bundle` and `publish` MUST NOT call Jev or Claude.", "title": "Deterministic commands stay model-free" @@ -447,7 +447,7 @@ "level": "MUST", "not_applies_when": "The content does not change CLI commands, exit codes or generated files.", "rfc": "JTOOL-0003", - "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L39", + "source": ".shall/rfcs/JTOOL-0003-cli-contract.md#L50", "status": "enforced", "text": "Generated files MUST be byte-for-byte reproducible from their inputs: no timestamps, no absolute paths, and sorted keys in JSON.", "title": "Reproducible generated files" @@ -469,7 +469,7 @@ "level": "MUST", "not_applies_when": "The content is not a skill, agent definition or plugin manifest.", "rfc": "JTOOL-0004", - "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L25", + "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L32", "status": "approved", "text": "The `description` of a skill or agent MUST state the situations in which it is used, not only what it does.", "title": "Descriptions say when to use" @@ -491,7 +491,7 @@ "level": "MUST", "not_applies_when": "The content is not a skill, agent definition or plugin manifest.", "rfc": "JTOOL-0004", - "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L32", + "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L39", "status": "approved", "text": "The review instructions MUST live only in `agents/shall-reviewer.md`, which both the in-session agent and the CI pipeline use; skills and code MUST NOT carry a second copy.", "title": "One reviewer prompt" @@ -513,7 +513,7 @@ "level": "SHOULD", "not_applies_when": "The content is not a skill, agent definition or plugin manifest.", "rfc": "JTOOL-0004", - "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L39", + "source": ".shall/rfcs/JTOOL-0004-plugin-content.md#L46", "status": "approved", "text": "Every `shall` command and flag shown in a skill SHOULD exist in the CLI (`shall -h`).", "title": "Commands in skills exist" diff --git a/.shall/rfcs/JTOOL-0001-jev-usage.md b/.shall/rfcs/JTOOL-0001-jev-usage.md index b2d6cff..89081a3 100644 --- a/.shall/rfcs/JTOOL-0001-jev-usage.md +++ b/.shall/rfcs/JTOOL-0001-jev-usage.md @@ -27,6 +27,14 @@ unrelated state. The first conflict checker put the whole corpus in one state an about `existing[i]`: almost every pair came back as a conflict. A Choice split 0.51 / 0.49 between two failing classes and hid a real weakening. These rules keep those lessons. +## Checks in code + +Code checks part of these rules. Review checks the rest. + +- JTOOL-0001.1: `make check` (import-linter contract "Only shall.jev imports the TypeSafe SDK"). +- JTOOL-0001.4: `plugins/shall/tooling/tests/test_boundaries.py` (`test_default_models_are_pinned_and_defined_once`). +- JTOOL-0001.2 and JTOOL-0001.3: review only. + ## Requirements ### JTOOL-0001.1 One client, one wrapper diff --git a/.shall/rfcs/JTOOL-0002-agent-boundaries.md b/.shall/rfcs/JTOOL-0002-agent-boundaries.md index 0995880..6343070 100644 --- a/.shall/rfcs/JTOOL-0002-agent-boundaries.md +++ b/.shall/rfcs/JTOOL-0002-agent-boundaries.md @@ -21,6 +21,16 @@ not_applies_when: > # JTOOL-0002: Agent boundaries +## Checks in code + +Code checks part of these rules. Review checks the rest. + +- JTOOL-0002.1: `plugins/shall/tooling/tests/test_boundaries.py` (`test_every_claude_call_is_tool_less`, `test_only_owner_modules_start_processes`). +- JTOOL-0002.2: `plugins/shall/tooling/tests/test_boundaries.py` (`test_findings_are_built_only_by_validation`, `test_findings_schema_matches_raw_finding`). +- JTOOL-0002.4: `plugins/shall/tooling/tests/test_publish.py` (`test_dry_run_reads_but_never_writes`). +- JTOOL-0002.5 and JTOOL-0002.6: `Finding.blocking` in `plugins/shall/tooling/src/shall/findings.py` is + computed, not stored. `plugins/shall/tooling/tests/test_verify.py` tests each verdict. + ## Requirements ### JTOOL-0002.1 Agents run without tools in the pipeline diff --git a/.shall/rfcs/JTOOL-0003-cli-contract.md b/.shall/rfcs/JTOOL-0003-cli-contract.md index 4e9a7c4..2ceacd2 100644 --- a/.shall/rfcs/JTOOL-0003-cli-contract.md +++ b/.shall/rfcs/JTOOL-0003-cli-contract.md @@ -20,6 +20,17 @@ not_applies_when: > # JTOOL-0003: shall CLI contract +## Checks in code + +Code checks part of these rules. Review checks the rest. + +- JTOOL-0003.1: `ExitCode` in `plugins/shall/tooling/src/shall/cli.py`, and `plugins/shall/tooling/tests/test_boundaries.py` + (`test_every_command_returns_an_exit_code`, `test_usage_error_exits_1_not_2`, + `test_scripts_exit_with_shall_codes_only`). +- JTOOL-0003.2: `make check` (import-linter contract "Deterministic modules call no model"), and `plugins/shall/tooling/tests/test_boundaries.py` + (`test_deterministic_commands_reach_no_model_module`, `test_deterministic_commands_do_not_load_the_sdk`). +- JTOOL-0003.3: `make check` and `make self-check` (`shall build --check`). + ## Requirements ### JTOOL-0003.1 Stable exit codes diff --git a/.shall/rfcs/JTOOL-0004-plugin-content.md b/.shall/rfcs/JTOOL-0004-plugin-content.md index b7676b9..ee8dc0f 100644 --- a/.shall/rfcs/JTOOL-0004-plugin-content.md +++ b/.shall/rfcs/JTOOL-0004-plugin-content.md @@ -20,6 +20,13 @@ not_applies_when: > # JTOOL-0004: Plugin skills and agents +## Checks in code + +Code checks part of these rules. Review checks the rest. + +- JTOOL-0004.3: `plugins/shall/tooling/tests/test_boundaries.py` (`test_docs_and_skills_use_existing_commands_and_flags`). +- JTOOL-0004.1 and JTOOL-0004.2: review only. + ## Requirements ### JTOOL-0004.1 Descriptions say when to use diff --git a/AGENTS.md b/AGENTS.md index da68737..d9565db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,10 @@ Plain `shall` resolves to `.shall/` (this repo's `JTOOL-` rules). The example or ## Commands ```bash -make test # unit tests, no network -make check # org corpus lint + index current, no model calls +make test # unit + boundary tests, no network +make typecheck # pyright (Literal types, exhaustive match) +make lint-imports # deterministic modules import no model module +make check # typecheck + lint-imports + org corpus lint + index current, no model calls make build # after editing corpus/ (index is committed) make self-check # this repo's own rules ``` diff --git a/Makefile b/Makefile index b7d1038..89f8338 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,12 @@ JRFC := plugins/shall/bin/shall # the root resolves to .shall/; organisation targets pass --config explicitly. ORG := $(JRFC) --config shall.yaml -.PHONY: check lint build test eval eval-scale eval-facts eval-hooks eval-hooks-claude eval-triage eval-realpr review-example self-check self-conflicts self-review +TOOLING := uv run --quiet --project plugins/shall/tooling -check: lint ## organisation corpus: deterministic CI checks (no model calls) +.PHONY: check lint build test typecheck lint-imports test-plugin eval eval-scale eval-facts eval-hooks eval-hooks-claude eval-triage eval-realpr review-example self-check self-conflicts self-review + +# test-plugin is not part of check: it needs the claude CLI, and check must run with uv only. +check: lint typecheck lint-imports ## deterministic CI checks: corpus, index, types, module layers (no model calls) $(ORG) build --check lint: @@ -16,7 +19,17 @@ build: $(ORG) build test: ## tooling unit tests (no network) - uv run --quiet --project plugins/shall/tooling pytest -q plugins/shall/tooling/tests + $(TOOLING) pytest -q plugins/shall/tooling/tests + +typecheck: ## pyright: Literal statuses/levels/verdicts, Optional narrowing, exhaustive match + $(TOOLING) --group check pyright -p plugins/shall/tooling/pyproject.toml + +lint-imports: ## import-linter: deterministic modules never import a model module (JTOOL-0003.2) + $(TOOLING) --group check lint-imports --config plugins/shall/tooling/pyproject.toml + +test-plugin: ## plugin manifest and TypeScript hook tests (needs the claude CLI) + claude plugin validate plugins/shall + claude plugin test plugins/shall eval: ## selection recall/precision on eval/cases (needs TYPESAFE_API_KEY) $(ORG) eval --out .shall-out/eval.json diff --git a/docs/development.mdx b/docs/development.mdx index 414cfdb..e2035cd 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -38,7 +38,7 @@ The code is in `plugins/shall/tooling/src/shall/`. The flow of `shall review`: ``` cli.py → config.py → corpus.py → artifact.py → effects.py → select.py → review.py - → verify.py (+ codegraph.py) → health.py → outputs + → findings.py (validate) → verify.py (+ codegraph.py) → health.py → outputs ``` | Module | What it does | @@ -49,8 +49,11 @@ cli.py → config.py → corpus.py → artifact.py → effects.py → select.py | `artifact.py` | Splits the input into `Chunk`s: one for each file of a diff, split by size. `Chunk.state()` is the Jev state. | | `effects.py` | Finds the known side effects of library calls, direct and through `codegraph.expand` | | `select.py` | The prefilter in code, then one Jev Noul for each statement (`flat`) or domain → RFC → statement (`layered`) | -| `jev.py` | The only access point to TypeSafe: cache, request packing, retries, usage | -| `review.py` | The reviewer prompt, `run_claude` (`claude -p` without tools), `validate_findings`, the rendering | +| `jev.py` | The only access point to TypeSafe: cache, request packing, retries, usage. Other code takes a `JevLike`. | +| `claude.py` | The only module that starts `claude`: `claude_command` adds `ISOLATION_FLAGS` (no tools), `run_claude`, `run_claude_text`, the answer cache, `agent_prompt` | +| `git.py` | The only module that starts `git`: a timeout on each call, no exception on a git failure | +| `review.py` | The reviewer prompt and `run_agents`. The output is `RawFinding` data that is not trusted. | +| `findings.py` | `RawFinding` → `validate_findings` → `Finding`. Only this module makes a `Finding`. `blocking` is a property: the corpus decides it, and verification can only make it lower. Also the Markdown and GitHub rendering. | | `verify.py`, `codegraph.py` | Evidence for blocking findings (tree-sitter, imports, `git grep`), the verifier agent, `apply_verdict` | | `health.py` | `health.json` and the warnings about silent failures | | `publish.py` | Posts to GitHub. Deterministic, with `--dry-run`. | @@ -67,14 +70,29 @@ cli.py → config.py → corpus.py → artifact.py → effects.py → select.py ## Tests and checks ```bash -make test # unit tests, no network -make check # example corpus: lint and index are current (no model calls) +make test # unit tests and boundary tests, no network +make typecheck # pyright: Literal types, Optional values, exhaustive match +make lint-imports # import-linter: deterministic modules import no model module +make test-plugin # plugin manifest and hook module tests (needs the claude CLI) +make check # typecheck, lint-imports, then example corpus lint and index (no model calls) make self-check # rules of this repository: lint and index make build # write corpus/index/ again after a change to corpus/ -claude plugin validate plugins/shall # the plugin manifest, hooks.json and the hooks module -claude plugin test plugins/shall # tests of the hooks module (plugins/shall/tests/*.test.ts) ``` +CI (`.github/workflows/ci.yml`) runs all of these on each pull request. + +Some rules are checked by code, not by review: + +| Check | What it stops | +| --- | --- | +| `make typecheck` | A status, level, verdict or hook action that is not in its `Literal` type. A `match` that does not cover each value. An `Optional` value that is used without a check (use `Corpus.require`, not `Corpus.statement`, for an id that code already checked). | +| `make lint-imports` | A deterministic module (`corpus`, `index`, `findings`, `publish`, ...) that imports a model module. A module other than `jev` that imports `typesafe_sdk`. | +| `tests/test_boundaries.py` | A process that starts outside `git.py`, `claude.py`, `publish.py` or `hooks.py`. A `Finding` that is made outside `findings.py`. A command without an `ExitCode`. A deterministic command that loads `shall.jev`. An exit code that is not 0 to 3. A model id outside `DEFAULTS`, or an alias that moves. A setting that has no default. A `shall` command or flag in the docs or skills that does not exist. | +| `load_config` | An unknown key in the local `shall.yaml` (not in the `extends` parent: a newer org config can add keys), or a `hooks.actions` row that is not `[min p, action]`. In the parent config, `load_config` removes such a row and writes a note to stderr. | + +Tests use the shared fakes in `tests/fakes.py`: `FakeJev` (subclass it and change `p` for scores that +depend on the state) and `make_finding`. + To run one test: ```bash @@ -88,8 +106,8 @@ If you develop with Claude Code, do this loop before each commit: 1. Run `/simplify`. This built-in skill cleans up the diff and applies the fixes. 2. Run `/code-review`. This built-in skill finds correctness bugs in the diff. Check each finding against the code, and fix the real ones. -3. Run `make test`, `make check` and `make self-check`. If you have `TYPESAFE_API_KEY` and a Claude login, also - run `make self-review`. +3. Run `make test`, `make check` and `make self-check`. If you have + `TYPESAFE_API_KEY` and a Claude login, also run `make self-review`. 4. Run `/verify` after a change under `plugins/shall/` or to a rule that the hooks apply. This built-in skill drives the change end to end. It has nothing to drive in a change to docs, tests or eval labels only. 5. If a step changes code, start again at step 1. diff --git a/eval/realpr/run.py b/eval/realpr/run.py index b2725e8..4a3faef 100644 --- a/eval/realpr/run.py +++ b/eval/realpr/run.py @@ -398,7 +398,7 @@ def label(args) -> None: import random as rnd from shall.config import load_config - from shall.review import run_claude + from shall.claude import run_claude cfg = load_config(str(ROOT / "shall.yaml")) path = OUT / "labels" / "proposed.jsonl" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/eval/scale/propose_labels.py b/eval/scale/propose_labels.py index e81d3e4..ded1144 100644 --- a/eval/scale/propose_labels.py +++ b/eval/scale/propose_labels.py @@ -12,7 +12,7 @@ from shall.jev import Jev from shall.artifact import build_artifact, detect_kind, read_source from shall.select import Selector, classify_document -from shall.review import run_claude +from shall.claude import run_claude SYSTEM = """You label an evaluation set for a classifier that selects engineering standards. A statement is APPLICABLE to an artifact when the artifact contains (or, for a task, the work will diff --git a/eval/scan/run.py b/eval/scan/run.py index 3d2c6f4..0c47366 100644 --- a/eval/scan/run.py +++ b/eval/scan/run.py @@ -36,7 +36,7 @@ from shall.corpus import load_corpus from shall.hooks import Hooks, redact from shall.jev import Jev -from shall.review import run_claude +from shall.claude import run_claude from shall.select import Selector ROOT = Path(__file__).resolve().parents[2] diff --git a/plugins/shall/.claude-plugin/plugin.json b/plugins/shall/.claude-plugin/plugin.json index 2222504..4efce36 100644 --- a/plugins/shall/.claude-plugin/plugin.json +++ b/plugins/shall/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shall", - "version": "0.4.0", + "version": "0.4.1", "description": "shall engineering standards: author and maintain RFC-2119 standards, build the deterministic index, find which statements apply to a diff/spec/doc/task with Jev, review against them, lint whole files (scan), and check an agent's tool calls against them while it works (hooks).", "author": { "name": "gpavanello" }, "keywords": ["standards", "rfc", "review", "governance", "jev", "typesafe"] diff --git a/plugins/shall/agents/shall-comment-verifier.md b/plugins/shall/agents/shall-comment-verifier.md index 05dd394..354bf86 100644 --- a/plugins/shall/agents/shall-comment-verifier.md +++ b/plugins/shall/agents/shall-comment-verifier.md @@ -2,7 +2,7 @@ name: shall-comment-verifier description: Checks whether one review comment written by another AI reviewer (Copilot, CodeRabbit and similar) is true, against evidence gathered from the repository, and returns confirmed, refuted or unknown. Use it in the agent step of `shall triage`, after code and Jev kept the comment as actionable. It does not search; it judges the evidence it is given. tools: Read -model: sonnet +model: claude-sonnet-5 --- You check one comment that an automated code reviewer left on a pull request. Many such diff --git a/plugins/shall/agents/shall-reviewer.md b/plugins/shall/agents/shall-reviewer.md index de5ca11..8b7169c 100644 --- a/plugins/shall/agents/shall-reviewer.md +++ b/plugins/shall/agents/shall-reviewer.md @@ -2,7 +2,7 @@ name: shall-reviewer description: Reviews one chunk of a diff, spec, doc or code file against a given list of shall standard statements and returns violations as JSON. Use it for the agent step of a shall review, after `shall select` chose the applicable statements. It does not choose standards itself. tools: Read -model: sonnet +model: claude-sonnet-5 --- You are a precise reviewer of engineering standards. You receive one chunk of an diff --git a/plugins/shall/agents/shall-verifier.md b/plugins/shall/agents/shall-verifier.md index e506c5e..0f21876 100644 --- a/plugins/shall/agents/shall-verifier.md +++ b/plugins/shall/agents/shall-verifier.md @@ -2,7 +2,7 @@ name: shall-verifier description: Verifies one shall review finding against evidence gathered from the repository and returns confirmed, refuted or unknown. Use it after the shall-reviewer produced a finding that would block a merge, or that depends on code outside the reviewed chunk. It does not search; it judges the evidence it is given. tools: Read -model: sonnet +model: claude-sonnet-5 --- You verify one finding of an engineering-standards review. You receive: diff --git a/plugins/shall/bin/shall-pr-review b/plugins/shall/bin/shall-pr-review index 1f76ef8..dbfdf7d 100755 --- a/plugins/shall/bin/shall-pr-review +++ b/plugins/shall/bin/shall-pr-review @@ -32,13 +32,14 @@ while [[ $# -gt 0 ]]; do --triage) triage=1; shift ;; --resolve-noise) resolve_noise="--resolve-noise"; shift ;; -h|--help) sed -n '2,14p' "$0"; exit 0 ;; - *) echo "unknown argument: $1" >&2; exit 64 ;; + *) echo "shall-pr-review: unknown argument: $1" >&2; exit 1 ;; # exit codes 0/1/2/3 only (JTOOL-0003.1) esac done mkdir -p "$out_dir" rm -f "$out_dir/triage.json" "$out_dir/triage.md" # never publish the triage of an earlier run -git diff --no-color "$base"...HEAD > "$out_dir/pr.diff" +git diff --no-color "$base"...HEAD > "$out_dir/pr.diff" \ + || { echo "shall-pr-review: git diff against $base failed" >&2; exit 1; } if [[ ! -s "$out_dir/pr.diff" ]]; then echo "shall-pr-review: empty diff against $base, nothing to review" exit 0 diff --git a/plugins/shall/tooling/pyproject.toml b/plugins/shall/tooling/pyproject.toml index be674ff..85c41f1 100644 --- a/plugins/shall/tooling/pyproject.toml +++ b/plugins/shall/tooling/pyproject.toml @@ -22,6 +22,42 @@ packages = ["src/shall"] [dependency-groups] dev = ["pytest>=8"] +# `make typecheck` / `make lint-imports` only (uv --group check): plugin users never install them +check = ["pyright[nodejs]>=1.1.400", "import-linter>=2.1"] # nodejs: Node from a wheel, no download at run time [tool.pytest.ini_options] testpaths = ["tests"] + + +[tool.pyright] +# `make typecheck`. Standard mode: Literal statuses, levels and verdicts are checked at every +# assignment, and an Optional must be narrowed before use (Corpus.require, not Corpus.statement). +include = ["src", "tests/fakes.py"] +typeCheckingMode = "standard" +pythonVersion = "3.11" +reportUnnecessaryComparison = "error" +reportMatchNotExhaustive = "error" + +[tool.importlinter] +# `make lint-imports`. JTOOL-0001.1 and JTOOL-0003.2: deterministic code never reaches +# a model, and only shall.jev talks to the TypeSafe SDK. +root_package = "shall" +include_external_packages = true + +[[tool.importlinter.contracts]] +# indirect imports count: a deterministic module that imports shall.select (which imports shall.jev) fails too +name = "Deterministic modules call no model (JTOOL-0003.2): move model calls to jev/claude callers" +type = "forbidden" +source_modules = [ + "shall.corpus", "shall.index", "shall.artifact", "shall.config", "shall.fetch", "shall.publish", + "shall.findings", "shall.checks", "shall.health", "shall.git", "shall.effects", "shall.codegraph", +] +forbidden_modules = ["shall.jev", "shall.claude", "typesafe_sdk"] + +[[tool.importlinter.contracts]] +name = "Only shall.jev imports the TypeSafe SDK (JTOOL-0001.1): use shall.jev.Jev / JevLike" +type = "forbidden" +source_modules = ["shall"] +forbidden_modules = ["typesafe_sdk"] +ignore_imports = ["shall.jev -> typesafe_sdk"] +allow_indirect_imports = true diff --git a/plugins/shall/tooling/src/shall/artifact.py b/plugins/shall/tooling/src/shall/artifact.py index 9a300c5..34f8371 100644 --- a/plugins/shall/tooling/src/shall/artifact.py +++ b/plugins/shall/tooling/src/shall/artifact.py @@ -186,6 +186,8 @@ def read_source(source: str | None, text: str | None) -> tuple[str, str]: """Return (name, raw text) for a file path, '-' (stdin) or inline text.""" if text is not None: return "task.txt", text + if source is None: + raise SystemExit("shall: give a file path, '-' for stdin, or --text") if source == "-": import sys return "stdin", sys.stdin.read() diff --git a/plugins/shall/tooling/src/shall/claude.py b/plugins/shall/tooling/src/shall/claude.py new file mode 100644 index 0000000..5f68b45 --- /dev/null +++ b/plugins/shall/tooling/src/shall/claude.py @@ -0,0 +1,164 @@ +"""The only module that runs `claude`. Every call is tool-less (JTOOL-0002.1). + +`claude_command` builds the command line with the flags that keep the agent tool-less and +isolated; `run_claude` (JSON with a schema) and `run_claude_text` (plain text) are the two ways +to call it. The agent prompts (`plugins/shall/agents/*.md`) are read by `agent_prompt`, one +prompt for interactive and CI use (JTOOL-0004.2). tests/test_boundaries.py fails when another +module starts a process with `review.command`. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import random +import re +import tempfile +import time +from pathlib import Path + +from .config import Config, load_json, merge_save_json + +# The flags that make the agent unable to act: no tools, no MCP servers, no user or project +# settings (hooks, permissions), no saved session. Every `claude` call gets all of them. +ISOLATION_FLAGS = ("--tools", "", "--no-session-persistence", "--strict-mcp-config", "--setting-sources", "") + +# one more try for agent failures that a second call usually does not repeat +RETRY_ERRORS = ("error_max_structured_output_retries", "no stdin data received") +RETRY_BACKOFF = 1.0 # seconds before the one retry, plus up to as much jitter + + +def plugin_root() -> Path: + env = os.environ.get("SHALL_PLUGIN_ROOT") + if env: + return Path(env) + return Path(__file__).resolve().parents[3] # tooling/src/shall/claude.py -> plugins/shall + + +def agent_prompt(name: str) -> str: + """The body of agents/.md: one prompt for interactive and CI use (JTOOL-0004.2).""" + text = (plugin_root() / "agents" / f"{name}.md").read_text(encoding="utf-8") + if text.startswith("---"): + text = text.split("---", 2)[2] + return text.strip() + + +def claude_command(cfg: Config, model: str, system: str, schema: dict | None = None) -> list[str]: + """`review.command` plus the model, the output form, the system prompt and ISOLATION_FLAGS.""" + output = ["--output-format", "json", "--json-schema", json.dumps(schema)] if schema is not None else \ + ["--output-format", "text"] + return [*cfg.get("review.command"), "--model", model, *output, "--system-prompt", system, *ISOLATION_FLAGS] + + +async def _spawn(cmd: list[str], prompt: str, timeout: float) -> tuple[int, bytes, bytes] | None: + """(exit code, stdout, stderr), or None after `timeout` seconds (the process is killed).""" + # the prompt is a file on stdin: it is there when the process starts. Written through + # the event loop, it could come after `claude -p` stopped to wait for it (3 s), when + # blocking work (git grep for evidence) held the loop. + with tempfile.TemporaryFile() as fh: + fh.write(prompt.encode()) + fh.seek(0) + proc = await asyncio.create_subprocess_exec( + *cmd, stdin=fh, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + # a pipeline agent never triggers shall hooks: its Stop would start another review + env={**os.environ, "SHALL_HOOKS_DISABLED": "1"}, + ) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return None + return proc.returncode or 0, out, err + + +class AnswerCache: + """Agent answers keyed by exactly what the agent was given (model, system prompt, schema, + prompt). A re-run on a new push reuses the answer for every chunk whose prompt did not + change (same diff, same selected statements, same known effects), and for every + verification whose evidence did not change. Only successful answers are stored; entries + unused for `MAX_AGE_DAYS` are dropped on save. Merge-safe like the Jev cache.""" + + MAX_AGE_DAYS = 30 + + def __init__(self, path: Path | None): + self.path = path + self.entries: dict = load_json(path) if path is not None else {} + self.reused = 0 + + @staticmethod + def key(model: str, system: str, schema: dict, prompt: str) -> str: + payload = json.dumps({"m": model, "s": system, "j": schema, "p": prompt}, sort_keys=True) + return hashlib.sha256(payload.encode()).hexdigest() + + def get(self, key: str) -> dict | None: + entry = self.entries.get(key) + if entry is None: + return None + entry["used"] = time.time() + self.reused += 1 + return entry["output"] + + def put(self, key: str, output: dict) -> None: + self.entries[key] = {"output": output, "used": time.time()} + + def save(self) -> None: + if self.path is None: + return + cutoff = time.time() - self.MAX_AGE_DAYS * 86400 + merge_save_json(self.path, self.entries, keep=lambda v: v.get("used", 0) >= cutoff) + + +def answer_cache(cfg: Config, enabled: bool = True) -> AnswerCache: + """The review answer cache next to the Jev cache (`review.cache: false` disables it).""" + if not enabled or not cfg.get("review.cache", True): + return AnswerCache(None) + return AnswerCache(cfg.cache_file.parent / "review.json") + + +async def run_claude(cfg: Config, prompt: str, system: str, schema: dict, + sem: asyncio.Semaphore, model: str | None = None, + cache: AnswerCache | None = None) -> tuple[dict | None, dict]: + """One tool-less Claude call with a JSON schema. Returns (output, meta). + With `cache`, an identical earlier call is reused (meta has `cached: True`, no cost).""" + model = model or str(cfg.get("review.model")) + key = AnswerCache.key(model, system, schema, prompt) + if cache is not None: + hit = cache.get(key) + if hit is not None: + return hit, {"cost_usd": 0.0, "cached": True} + cmd = claude_command(cfg, model, system, schema) + timeout = float(cfg.get("review.timeout")) + out = b"" + for attempt in range(2): + async with sem: + done = await _spawn(cmd, prompt, timeout) + if done is None: + return None, {"error": f"agent timed out after {timeout:g}s"} + code, out, err = done + if code == 0: + break + error = err.decode()[-2000:] or out.decode()[-2000:] + if attempt or not any(m in error for m in RETRY_ERRORS): + return None, {"error": error} + # JRFC-0006.2: exponential backoff with jitter, outside the semaphore + await asyncio.sleep(RETRY_BACKOFF * 2 ** attempt + random.uniform(0, RETRY_BACKOFF)) + data = json.loads(out.decode()) + result = data.get("structured_output") + if result is None: # fall back to parsing the text result + m = re.search(r"\{.*\}", data.get("result", ""), re.S) + result = json.loads(m.group(0)) if m else None + if cache is not None and result is not None: + cache.put(key, result) + return result, {"cost_usd": data.get("total_cost_usd", 0.0), "usage": data.get("usage") or {}, + "models": sorted(data.get("modelUsage") or {})} + + +async def run_claude_text(cfg: Config, model: str, system: str, prompt: str) -> str | None: + """One tool-less Claude call that answers in plain text; None on a failure or a timeout.""" + done = await _spawn(claude_command(cfg, model, system), prompt, float(cfg.get("review.timeout"))) + if done is None or done[0] != 0: + return None + return done[1].decode(errors="replace") diff --git a/plugins/shall/tooling/src/shall/claude_judge.py b/plugins/shall/tooling/src/shall/claude_judge.py index 15b3843..da089ff 100644 --- a/plugins/shall/tooling/src/shall/claude_judge.py +++ b/plugins/shall/tooling/src/shall/claude_judge.py @@ -14,15 +14,12 @@ from __future__ import annotations -import asyncio import hashlib import json -import os import re -import tempfile from .config import Config -from .review import AnswerCache +from .claude import AnswerCache, run_claude_text # The fixed scale and the p that code gives each answer. "likely" sits in the ask band of an # enforced MUST (0.5 <= p < 0.9) and at the warn line of a SHOULD (0.7). @@ -171,20 +168,5 @@ def claude_text(cfg: Config, model: str): """`run` for ClaudeJudge outside Claude Code (eval): one tool-less `claude -p` call (JTOOL-0002.1) with the request's system prompt; the reply text, or None on a failure.""" async def run(req: dict) -> str | None: - cmd = list(cfg.get("review.command")) + [ - "--model", model, "--output-format", "text", "--system-prompt", req["system"], "--tools", "", - "--no-session-persistence", "--strict-mcp-config", "--setting-sources", ""] - with tempfile.TemporaryFile() as fh: - fh.write(req["prompt"].encode()) - fh.seek(0) - proc = await asyncio.create_subprocess_exec( - *cmd, stdin=fh, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env={**os.environ, "SHALL_HOOKS_DISABLED": "1"}) - try: - out, _ = await asyncio.wait_for(proc.communicate(), timeout=float(cfg.get("review.timeout"))) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - return None - return out.decode(errors="replace") if proc.returncode == 0 else None + return await run_claude_text(cfg, model, req["system"], req["prompt"]) return run diff --git a/plugins/shall/tooling/src/shall/cli.py b/plugins/shall/tooling/src/shall/cli.py index d9186c3..e776cd1 100644 --- a/plugins/shall/tooling/src/shall/cli.py +++ b/plugins/shall/tooling/src/shall/cli.py @@ -8,13 +8,34 @@ import json import re import sys +from enum import IntEnum from pathlib import Path +from typing import TYPE_CHECKING from .artifact import build_artifact, detect_kind, read_source from .config import OUTPUT_DIRS, load_config, make_dir from .corpus import check_against, load_corpus -EXIT_OK, EXIT_ERROR, EXIT_BLOCKING, EXIT_SERVICE = 0, 1, 2, 3 +if TYPE_CHECKING: + from .artifact import Artifact + from .findings import Finding + + +class ExitCode(IntEnum): + """The only exit codes of shall (JTOOL-0003.1). Every `cmd_*` returns one of them.""" + OK = 0 + ERROR = 1 + BLOCKING = 2 # blocking findings: never use it for a usage error + SERVICE = 3 # a Jev request failed after retries + + +class Parser(argparse.ArgumentParser): + """argparse exits 2 on a usage error, which is ExitCode.BLOCKING here: CI would read a typo in a + flag as "blocked by a standard". A usage error is an error (exit 1).""" + + def error(self, message: str): # type: ignore[override] + self.print_usage(sys.stderr) + self.exit(ExitCode.ERROR, f"{self.prog}: error: {message}\n") def _dump(data, path: str | None) -> None: @@ -29,7 +50,7 @@ def _dump(data, path: str | None) -> None: # ---------------------------------------------------------------- corpus -def cmd_lint(args, cfg) -> int: +def cmd_lint(args, cfg) -> ExitCode: corpus = load_corpus(cfg) issues = [i for i in corpus.issues if args.all or i.layer == "local"] if args.against: @@ -43,29 +64,29 @@ def cmd_lint(args, cfg) -> int: parent = f" (+ {len(corpus.rfcs) - len(local)} from {cfg.parent.name})" if cfg.parent else "" print(f"shall lint: {len(local)} local RFCs{parent}, {len(corpus.local_statements())} local statements, " f"{errors} error(s), {warnings} warning(s)", file=sys.stderr) - return EXIT_ERROR if errors or (args.strict and warnings) else EXIT_OK + return ExitCode.ERROR if errors or (args.strict and warnings) else ExitCode.OK -def cmd_build(args, cfg) -> int: +def cmd_build(args, cfg) -> ExitCode: from .index import build_index, check_index, write_index corpus = load_corpus(cfg) if corpus.errors: for issue in corpus.errors: print(issue) print("shall build: fix lint errors first", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR index = build_index(corpus) if args.check: stale = check_index(index, cfg.index_dir) if stale: print(f"shall build --check: stale index files: {', '.join(stale)} — run `shall build`", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR print("shall build --check: index is up to date", file=sys.stderr) - return EXIT_OK + return ExitCode.OK import os for path in write_index(index, cfg.index_dir): print(f"wrote {os.path.relpath(path, Path.cwd())}") - return EXIT_OK + return ExitCode.OK TEMPLATE = """--- @@ -102,15 +123,15 @@ def cmd_build(args, cfg) -> int: """ -def cmd_new(args, cfg) -> int: +def cmd_new(args, cfg) -> ExitCode: corpus = load_corpus(cfg) prefix = cfg.prefix if prefix is None: print("this workspace has no `prefix:` in its shall.yaml; add one (e.g. prefix: BOOK)", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR if args.domain not in corpus.domains: print(f"unknown domain '{args.domain}'. Known: {', '.join(sorted(corpus.domains))}", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR used = [int(r.id.split("-")[1]) for r in corpus.rfcs.values() if r.layer == "local"] used += [int(m.group(1)) for p in cfg.corpus_dir.glob(f"{prefix}-*.md") if (m := re.match(rf"{prefix}-(\d{{4}})", p.name))] @@ -125,17 +146,17 @@ def cmd_new(args, cfg) -> int: ), encoding="utf-8") import os print(os.path.relpath(path, Path.cwd())) - return EXIT_OK + return ExitCode.OK -def cmd_show(args, cfg) -> int: +def cmd_show(args, cfg) -> ExitCode: corpus = load_corpus(cfg) for ident in args.ids: if "." in ident: found = corpus.statement(ident) if not found: print(f"{ident}: not found", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR rfc, st = found if args.json: _dump({"id": st.id, "rfc": rfc.id, "title": st.title, "text": st.text, "level": st.level, @@ -143,7 +164,7 @@ def cmd_show(args, cfg) -> int: "applies_when": st.applies_when or rfc.applies_when, "layer": rfc.layer, "source": corpus.source(rfc, st.line)}, None) else: - print(f"{st.id} [{st.level}, {rfc.status}{', blocking' if rfc.statement_blocking(st) else ''}] {st.title}") + print(f"{st.id} [{st.level}, {rfc.status_label}{', blocking' if rfc.statement_blocking(st) else ''}] {st.title}") print(f" {st.text}") print(f" Applies when: {st.applies_when or rfc.applies_when}") print(f" Source: {corpus.source(rfc, st.line)}") @@ -151,12 +172,12 @@ def cmd_show(args, cfg) -> int: rfc = corpus.rfcs.get(ident) if not rfc: print(f"{ident}: not found", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR print(rfc.path.read_text(encoding="utf-8")) - return EXIT_OK + return ExitCode.OK -def cmd_list(args, cfg) -> int: +def cmd_list(args, cfg) -> ExitCode: corpus = load_corpus(cfg) for rfc in sorted(corpus.rfcs.values(), key=lambda r: r.id): if args.domain and rfc.domain != args.domain: @@ -164,38 +185,38 @@ def cmd_list(args, cfg) -> int: if args.status and rfc.status != args.status: continue layer = "" if rfc.layer == "local" else f" [{rfc.layer}]" - print(f"{rfc.id:<10} {rfc.status:<10} {rfc.domain:<14} {rfc.title} ({len(rfc.statements)} statements){layer}") - return EXIT_OK + print(f"{rfc.id:<10} {rfc.status_label:<10} {rfc.domain:<14} {rfc.title} ({len(rfc.statements)} statements){layer}") + return ExitCode.OK -def cmd_prefetch(args) -> int: +def cmd_prefetch(args) -> ExitCode: """Download the tree-sitter grammars for the languages in this repository (CI caches them).""" from .codegraph import RepoIndex, treesitter_available if not treesitter_available(): print("tree-sitter is not installed; verification uses keyword search only", file=sys.stderr) - return EXIT_OK + return ExitCode.OK from tree_sitter_language_pack import cache_dir, detect_language_from_path, download langs = sorted({lang for f in RepoIndex(Path.cwd()).all_files() if (lang := detect_language_from_path(f))}) count = download(langs) if langs else 0 print(f"grammars for {', '.join(langs) or 'no language'} ({count} downloaded) in {cache_dir()}") - return EXIT_OK + return ExitCode.OK -def cmd_catalog(args, cfg) -> int: +def cmd_catalog(args, cfg) -> ExitCode: from .index import build_index, render_catalog corpus = load_corpus(cfg) print(render_catalog(build_index(corpus, {layer.name for layer in cfg.layers})), end="") - return EXIT_OK + return ExitCode.OK -def cmd_fetch(args, cfg) -> int: +def cmd_fetch(args, cfg) -> ExitCode: parent = cfg.parent if parent is None: print("no `extends:` in this workspace; nothing to fetch") - return EXIT_OK + return ExitCode.OK p = parent.parent print(f"{parent.name} -> {p.root}" + (f" (commit {p.sha[:12]})" if p.sha else "")) - return EXIT_OK + return ExitCode.OK INIT_CONFIG = """# shall workspace: repository-specific standards on top of the organisation corpus. @@ -209,14 +230,14 @@ def cmd_fetch(args, cfg) -> int: """ -def cmd_init(args) -> int: +def cmd_init(args) -> ExitCode: dot = Path.cwd() / ".shall" if (dot / "shall.yaml").exists(): print(f"{dot / 'shall.yaml'} already exists", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR if not re.fullmatch(r"[A-Z][A-Z0-9]{1,9}", args.prefix) or args.prefix == "JRFC": print("prefix must be 2-10 characters A-Z/0-9 and not JRFC (e.g. BOOK)", file=sys.stderr) - return EXIT_ERROR + return ExitCode.ERROR if args.path: import os # stored relative to .shall/, where the config is resolved from @@ -228,7 +249,7 @@ def cmd_init(args) -> int: (dot / ".gitignore").write_text("".join(f"{d}/\n" for d in OUTPUT_DIRS), encoding="utf-8") (dot / "rfcs" / ".gitkeep").write_text("", encoding="utf-8") print(f"created {dot}/ (shall.yaml, rfcs/, .gitignore). Next: shall fetch && shall new --domain --title ") - return EXIT_OK + return ExitCode.OK # ---------------------------------------------------------------- selection and review @@ -275,7 +296,7 @@ def _selection_summary(sel: dict) -> str: return "\n".join(lines) -def cmd_select(args, cfg) -> int: +def cmd_select(args, cfg) -> ExitCode: from .jev import Jev corpus = load_corpus(cfg) @@ -290,7 +311,7 @@ async def run(): _dump(sel, None) else: print(_selection_summary(sel)) - return EXIT_OK + return ExitCode.OK def _load_selection_artifact(args, cfg, selection: dict): @@ -301,11 +322,12 @@ def _load_selection_artifact(args, cfg, selection: dict): return artifact -def _write_outputs(cfg, out_dir: Path, artifact, selection, findings, dropped, fmt: str, - health: dict | None = None) -> int: - from .review import render_github, render_markdown +def _write_outputs(cfg, out_dir: Path, artifact, selection, findings: list[Finding], dropped: list[dict], + fmt: str, health: dict | None = None) -> int: + """Write findings.json, review.md (and github-review.json); return the number of blocking findings.""" + from .findings import render_github, render_markdown make_dir(out_dir) - _dump({"findings": findings, "dropped": dropped}, str(out_dir / "findings.json")) + _dump({"findings": [f.to_json() for f in findings], "dropped": dropped}, str(out_dir / "findings.json")) if health is not None: _dump(health, str(out_dir / "health.json")) md = render_markdown(artifact, selection, findings, dropped, health) @@ -313,20 +335,20 @@ def _write_outputs(cfg, out_dir: Path, artifact, selection, findings, dropped, f if fmt == "github": _dump(render_github(artifact, selection, findings, dropped, health), str(out_dir / "github-review.json")) print(md) - return sum(1 for f in findings if f["blocking"]) + return sum(1 for f in findings if f.blocking) -def cmd_bundle(args, cfg) -> int: - from .review import write_bundle +def cmd_bundle(args, cfg) -> ExitCode: + from .findings import write_bundle corpus = load_corpus(cfg) selection = json.loads(Path(args.selection).read_text(encoding="utf-8")) artifact = _load_selection_artifact(args, cfg, selection) print(write_bundle(corpus, artifact, selection, Path(args.out))) - return EXIT_OK + return ExitCode.OK -def cmd_validate(args, cfg) -> int: - from .review import validate_findings +def cmd_validate(args, cfg) -> ExitCode: + from .findings import validate_findings corpus = load_corpus(cfg) selection = json.loads(Path(args.selection).read_text(encoding="utf-8")) artifact = _load_selection_artifact(args, cfg, selection) @@ -334,12 +356,14 @@ def cmd_validate(args, cfg) -> int: raw = raw.get("findings", raw) if isinstance(raw, dict) else raw findings, dropped = validate_findings(corpus, artifact, selection, raw, int(cfg.get("review.max_comments"))) blocking = _write_outputs(cfg, Path(args.out_dir), artifact, selection, findings, dropped, args.format) - return EXIT_BLOCKING if (blocking and args.fail_on_blocking) else EXIT_OK + return ExitCode.BLOCKING if (blocking and args.fail_on_blocking) else ExitCode.OK -def cmd_review(args, cfg) -> int: +def cmd_review(args, cfg) -> ExitCode: + from .claude import answer_cache + from .findings import validate_findings from .jev import Jev - from .review import answer_cache, run_agents, validate_findings + from .review import run_agents from .verify import verify_findings corpus = load_corpus(cfg) out_dir = Path(args.out_dir) @@ -347,7 +371,7 @@ def cmd_review(args, cfg) -> int: # incremental re-review: unchanged chunks and unchanged evidence reuse earlier answers cache = answer_cache(cfg, enabled=not args.no_cache) - async def run(): + async def run() -> tuple[Artifact, dict, list[Finding], list[dict], dict]: async with Jev(cfg, use_cache=not args.no_cache) as jev: index = _index(cfg, corpus, args) artifact, selection = await _select(args, cfg, corpus, jev, index) @@ -373,9 +397,9 @@ async def run(): finally: cache.save() # also after a failure: answers already paid for are kept if args.dry_run: - from .review import write_bundle + from .findings import write_bundle print(write_bundle(corpus, artifact, selection, out_dir / "bundle.md")) - return EXIT_OK + return ExitCode.OK for err in stats.get("errors", []): print(f"shall review: agent error on {err['chunk']}: {err['error'][:300]}", file=sys.stderr) from .health import build_health @@ -391,11 +415,11 @@ async def run(): print(f"shall review: {stats['agent_calls']} review call(s){vtext}{rtext}, ${stats.get('cost_usd', 0):.4f}, " f"outputs in {out_dir}", file=sys.stderr) if stats.get("errors"): - return EXIT_ERROR - return EXIT_BLOCKING if (blocking and args.fail_on_blocking) else EXIT_OK + return ExitCode.ERROR + return ExitCode.BLOCKING if (blocking and args.fail_on_blocking) else ExitCode.OK -def cmd_scan(args, cfg) -> int: +def cmd_scan(args, cfg) -> ExitCode: """Lint whole files: secret scanner + Jev per file. Warnings never block; scanner hits can.""" from .jev import Jev from .scan import render_lines, render_markdown, run_scan @@ -422,38 +446,43 @@ async def run(): f"{s['files_with_warnings']} file(s), {s['blocking']} blocking; {s['jev_requests']} Jev request(s) " f"({s['jev_cached']} cached), ~${s['jev_cost_usd']:.4f}; outputs in {out_dir}", file=sys.stderr) if report["errors"]: - return EXIT_SERVICE + return ExitCode.SERVICE if (args.fail_on_blocking and s["blocking"]) or (args.fail_on_warn and s["warnings"]): - return EXIT_BLOCKING - return EXIT_OK + return ExitCode.BLOCKING + return ExitCode.OK -def cmd_verify(args, cfg) -> int: - """Verify already-validated findings (in-session flow: bundle -> agent -> validate -> verify).""" +def cmd_verify(args, cfg) -> ExitCode: + """Verify validated findings (in-session flow: bundle -> agent -> validate -> verify).""" + from .findings import validate_findings from .jev import Jev from .verify import verify_findings corpus = load_corpus(cfg) selection = json.loads(Path(args.selection).read_text(encoding="utf-8")) artifact = _load_selection_artifact(args, cfg, selection) data = json.loads(Path(args.findings).read_text(encoding="utf-8")) + # validated again: the file came from an agent session, and severity comes from the corpus + findings, dropped = validate_findings(corpus, artifact, selection, data["findings"], + int(cfg.get("review.max_comments"))) async def run(): async with Jev(cfg) as jev: - return await verify_findings(cfg, corpus, artifact, jev, data["findings"], + return await verify_findings(cfg, corpus, artifact, jev, findings, Path(args.search_root) if args.search_root else None) findings, refuted, stats = asyncio.run(run()) blocking = _write_outputs(cfg, Path(args.out_dir), artifact, selection, findings, - data.get("dropped", []) + refuted, args.format) + data.get("dropped", []) + dropped + refuted, args.format) print(f"shall verify: {stats}", file=sys.stderr) - return EXIT_BLOCKING if (blocking and args.fail_on_blocking) else EXIT_OK + return ExitCode.BLOCKING if (blocking and args.fail_on_blocking) else ExitCode.OK -def cmd_triage(args, cfg) -> int: +def cmd_triage(args, cfg) -> ExitCode: """Triage the comments of other AI reviewers on a PR: relevant, unverified, noise, outdated.""" import os + from .claude import answer_cache + from .findings import read_findings from .jev import Jev - from .review import answer_cache from .triage import bot_comments, load_pr_level, load_threads, pr_level_items, render_triage, triage_comments corpus = load_corpus(cfg) if bool(args.comments) == bool(args.pr): @@ -478,9 +507,8 @@ def cmd_triage(args, cfg) -> int: comments += [item for items in per_comment for item in items] skipped["pr_level_summary"] = sum(1 for items in per_comment if not items) findings = None - if args.findings: - findings = json.loads(Path(args.findings).read_text(encoding="utf-8")) - findings = findings.get("findings", findings) if isinstance(findings, dict) else findings + if args.findings: # shall's own findings.json: only compared with the comments, never published + findings = read_findings(Path(args.findings)) if args.no_verify: cfg.data.setdefault("triage", {})["verify"] = False cache = answer_cache(cfg, enabled=not args.no_cache) @@ -506,10 +534,10 @@ async def run(): print(f"shall triage: {len(items)} AI comment(s) ({', '.join(f'{v} {k}' for k, v in skipped.items() if v) or 'none'}" f" skipped), {stats.get('agent_calls', 0)} verification call(s), {stats.get('reused', 0)} reused, " f"${stats.get('cost_usd', 0):.4f}, outputs in {out_dir}", file=sys.stderr) - return EXIT_ERROR if stats.get("errors") else EXIT_OK + return ExitCode.ERROR if stats.get("errors") else ExitCode.OK -def cmd_publish(args, cfg) -> int: +def cmd_publish(args, cfg) -> ExitCode: import os from .publish import publish repo = args.repo or os.environ.get("GITHUB_REPOSITORY") @@ -539,10 +567,10 @@ def cmd_publish(args, cfg) -> int: t = tplan.to_json() print(f"shall publish ({repo}#{args.pr}): {verb} reply to and resolve {len(t['resolve'])} noise thread(s); " f"{len(t['human'])} left alone (a person replied), {len(t['done'])} already handled", file=sys.stderr) - return EXIT_OK + return ExitCode.OK -def cmd_conflicts(args, cfg) -> int: +def cmd_conflicts(args, cfg) -> ExitCode: from .conflicts import FAILING, find_conflicts, find_local_conflicts from .jev import Jev if bool(args.file) == bool(args.local): @@ -565,13 +593,13 @@ async def run(): mark = "FAIL" if f["relation"] in FAILING else "info" print(f"[{mark}] {f['new']} ~ {f['existing']}: {f['relation']} (p={f['p']:.2f})\n" f" {f['existing_text']}") - return EXIT_ERROR if any(f["relation"] in FAILING for f in found) else EXIT_OK + return ExitCode.ERROR if any(f["relation"] in FAILING for f in found) else ExitCode.OK -def cmd_eval_triage(args, cfg) -> int: +def cmd_eval_triage(args, cfg) -> ExitCode: + from .claude import answer_cache from .evaluate import render_triage_eval, run_triage_eval from .jev import Jev - from .review import answer_cache corpus = load_corpus(cfg) cache = answer_cache(cfg, enabled=not args.no_cache) @@ -586,10 +614,10 @@ async def run(): if args.out: _dump(report, args.out) print(render_triage_eval(report)) - return EXIT_OK + return ExitCode.OK -def cmd_eval(args, cfg) -> int: +def cmd_eval(args, cfg) -> ExitCode: from .evaluate import render_eval, run_eval from .jev import Jev corpus = load_corpus(cfg) @@ -603,10 +631,10 @@ async def run(): if args.out: _dump(report, args.out) print(render_eval(report)) - return EXIT_OK + return ExitCode.OK -def cmd_eval_hooks(args, cfg) -> int: +def cmd_eval_hooks(args, cfg) -> ExitCode: from .claude_judge import ClaudeJudge, claude_text from .evaluate import render_hooks_eval, run_hooks_eval from .jev import Jev @@ -628,13 +656,13 @@ async def run(): if args.out: _dump(report, args.out) print(render_hooks_eval(report)) - return EXIT_OK + return ExitCode.OK # ---------------------------------------------------------------- hooks -def cmd_hook(args, cfg) -> int: +def cmd_hook(args, cfg) -> ExitCode: """One Claude Code hook event on stdin (a `command` hook); the decision JSON on stdout.""" from .hooks import Hooks from .jev import Jev @@ -651,10 +679,10 @@ async def run(): print(json.dumps(out, ensure_ascii=False)) # JTOOL-0003.1: a failed Jev request exits 3, which Claude Code treats as a non-blocking # error, so the call runs (fail-open). A decision already made (secret scanner) exits 0. - return EXIT_SERVICE if hooks.jev_failed and not out.get("hookSpecificOutput") else EXIT_OK + return ExitCode.SERVICE if hooks.jev_failed and not out.get("hookSpecificOutput") else ExitCode.OK -def cmd_hookd(args, cfg) -> int: +def cmd_hookd(args, cfg) -> ExitCode: """Serve hook events over localhost HTTP with one warm Jev connection.""" from .hooks import Hooks, serve from .jev import Jev @@ -670,10 +698,10 @@ async def run(): asyncio.run(run()) except KeyboardInterrupt: pass - return EXIT_OK + return ExitCode.OK -def cmd_hook_config(args, cfg) -> int: +def cmd_hook_config(args, cfg) -> ExitCode: """Print the `hooks` block for .claude/settings.json. Deterministic: no model call.""" import os from .hooks import hook_settings @@ -681,14 +709,14 @@ def cmd_hook_config(args, cfg) -> int: command = str(Path(root) / "bin" / "shall") if root else "shall" port = args.port or int(cfg.get("hooks.port")) print(json.dumps(hook_settings(cfg, args.transport, port, command, not args.no_stop), indent=2)) - return EXIT_OK + return ExitCode.OK -def cmd_eval_verify(args, cfg) -> int: +def cmd_eval_verify(args, cfg) -> ExitCode: from .evaluate import render_verify_eval, run_verify_eval from .jev import Jev corpus = load_corpus(cfg) - models = [m for m in args.models.split(",") if m] + models = [m for m in (args.models or cfg.verify_model).split(",") if m] retrievals = ["keyword", "treesitter"] if args.retrieval == "all" else [args.retrieval] async def run(): @@ -699,14 +727,14 @@ async def run(): if args.out: _dump(report, args.out) print(render_verify_eval(report)) - return EXIT_OK + return ExitCode.OK # ---------------------------------------------------------------- parser def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="shall", description=( + p = Parser(prog="shall", description=( "shall: RFC-2119 engineering standards for humans and agents. " "Corpus: lint/build/new/show/list. Review: select (Jev) -> agent -> validate.")) p.add_argument("--config", help="path to shall.yaml (default: search upwards, or $SHALL_CONFIG)") @@ -775,7 +803,7 @@ def artifact_args(s): artifact_args(s) s.add_argument("--out-dir", default=".shall-out") s.add_argument("--format", choices=["markdown", "github"], default="markdown") - s.add_argument("--fail-on-blocking", action="store_true", help=f"exit {EXIT_BLOCKING} on blocking findings") + s.add_argument("--fail-on-blocking", action="store_true", help=f"exit {ExitCode.BLOCKING} on blocking findings") s.add_argument("--dry-run", action="store_true", help="select only and write bundle.md, no agent call") s.add_argument("--no-verify", action="store_true", help="skip verification of blocking findings") s.set_defaults(fn=cmd_review) @@ -787,8 +815,8 @@ def artifact_args(s): s.add_argument("--status", help="comma-separated RFC statuses to check (default: selection.include_status)") s.add_argument("--exclude", action="append", default=[], help="glob to skip (repeatable), on top of scan.exclude") s.add_argument("--fail-on-blocking", action="store_true", - help=f"exit {EXIT_BLOCKING} on a blocking finding (secret-scanner hit on an enforced MUST)") - s.add_argument("--fail-on-warn", action="store_true", help=f"exit {EXIT_BLOCKING} on any warning") + help=f"exit {ExitCode.BLOCKING} on a blocking finding (secret-scanner hit on an enforced MUST)") + s.add_argument("--fail-on-warn", action="store_true", help=f"exit {ExitCode.BLOCKING} on any warning") s.add_argument("--no-cache", action="store_true", help="ignore the Jev answer cache") s.set_defaults(fn=cmd_scan) @@ -850,7 +878,7 @@ def artifact_args(s): s = sub.add_parser("eval-verify", help="measure verification verdicts and evidence recall on fixture repos") s.add_argument("--cases", default="eval/verify") - s.add_argument("--models", default="claude-sonnet-5", help="comma-separated verifier models") + s.add_argument("--models", help="comma-separated verifier models (default: review.verify_model, else review.model)") s.add_argument("--retrieval", choices=["keyword", "treesitter", "all"], default="all") s.add_argument("--out") s.set_defaults(fn=cmd_eval_verify) @@ -906,13 +934,16 @@ def main(argv: list[str] | None = None) -> None: and args.cmd in ("lint", "build", "new", "list")): print("note: using .shall/ (this repository's own rules); " "pass --config shall.yaml for the corpus defined at the root", file=sys.stderr) - from typesafe_sdk import TypeSafeError try: sys.exit(args.fn(args, cfg)) - except TypeSafeError as err: + except Exception as err: + # a deterministic command never imports shall.jev (and the TypeSafe SDK, ~190 ms): look it up + jev = sys.modules.get("shall.jev") + if jev is None or not isinstance(err, jev.JevServiceError): + raise # distinct exit code: CI can tell "the service failed" from "the change is blocked" print(f"shall: Jev (TypeSafe) request failed after retries: {str(err)[:300]}", file=sys.stderr) - sys.exit(EXIT_SERVICE) + sys.exit(ExitCode.SERVICE) if __name__ == "__main__": diff --git a/plugins/shall/tooling/src/shall/codegraph.py b/plugins/shall/tooling/src/shall/codegraph.py index e3540e1..7a98ffa 100644 --- a/plugins/shall/tooling/src/shall/codegraph.py +++ b/plugins/shall/tooling/src/shall/codegraph.py @@ -28,9 +28,14 @@ from __future__ import annotations import re -import subprocess from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING + +from . import git + +if TYPE_CHECKING: + from tree_sitter import Node, Query MAX_DEPTH = 3 MAX_GLOBAL_DEFS = 3 # a name with more same-named definitions than this is too ambiguous @@ -43,7 +48,6 @@ # cache must not make an excerpt larger than the request budget. MAX_LINE_CHARS = 400 MAX_EXCERPT_CHARS = 12_000 -GIT_TIMEOUT = 30 SKIP_PARTS = {".git", ".venv", "venv", "node_modules", ".shall-out", ".shall-cache", "__pycache__", "dist", "build", "target", "vendor", ".gradle", ".idea"} @@ -178,7 +182,7 @@ def __init__(self, root: Path, excluded: set[str] | None = None, download_gramma self._grep_cache: dict[str, list[str]] = {} self._all_files: list[str] | None = None self._manifest_text: str | None = None - self._queries: dict[str, object] = {} + self._queries: dict[str, list[Query]] = {} # ------------------------------------------------------------ repository access @@ -189,13 +193,9 @@ def _skip(self, rel: str) -> bool: def all_files(self) -> list[str]: if self._all_files is None: files: list[str] = [] - try: - proc = subprocess.run(["git", "ls-files", "--cached", "--others", "--exclude-standard"], - cwd=self.root, capture_output=True, text=True, timeout=GIT_TIMEOUT) - if proc.returncode == 0: - files = proc.stdout.splitlines() - except (OSError, subprocess.TimeoutExpired): - pass + listed = git.git(self.root, "ls-files", "--cached", "--others", "--exclude-standard") + if listed is not None: + files = listed.splitlines() if not files: files = [str(p.relative_to(self.root)) for p in self.root.rglob("*") if p.is_file()] self._all_files = sorted(f for f in files if not self._skip(f)) @@ -205,14 +205,11 @@ def grep_files(self, name: str) -> list[str]: """Files that mention `name` as a word (candidates for its definition).""" if name not in self._grep_cache: found: list[str] = [] - try: - proc = subprocess.run(["git", "grep", "-l", "-I", "-w", "-F", "--untracked", "-e", name], - cwd=self.root, capture_output=True, text=True, timeout=GIT_TIMEOUT) - if proc.returncode in (0, 1): - found = proc.stdout.splitlines() - else: - raise OSError - except (OSError, subprocess.TimeoutExpired): + listed = git.git(self.root, "grep", "-l", "-I", "-w", "-F", "--untracked", "-e", name, + ok=(0, 1)) # 1: no match + if listed is not None: + found = listed.splitlines() + else: # not a git work tree, or git failed pattern = re.compile(rf"\b{re.escape(name)}\b") for rel in self.all_files(): try: @@ -267,7 +264,7 @@ def _grammar_ready(self, lang: str) -> bool: self._cached_grammars.add(lang) return True - def _tags_queries(self, lang: str) -> list: + def _tags_queries(self, lang: str) -> list[Query]: """The language's tags query plus its parents'; each compiled on its own so one pattern that does not fit the grammar cannot disable the rest.""" if lang not in self._queries: @@ -413,7 +410,7 @@ def text(self, node) -> str: return self.src[node.start_byte:node.end_byte].decode("utf-8", "replace") def run(self) -> None: - defs: list[tuple[object, object, str]] = [] # (range node, name node, kind) + defs: list[tuple[Node, Node | str, str]] = [] # (range node, name node or text, kind) from tree_sitter import QueryCursor for query in self.index._tags_queries(self.lang): for _, caps in QueryCursor(query).matches(self.tree.root_node): @@ -531,7 +528,7 @@ def _generic_defs(self, defs: list) -> None: continue # top-level constants and variables: `_session = make_session()`, `const http = ...` top = parent is not None and (parent == root or (parent.parent == root and "export" in parent.type)) - if top and any(k in t for k in ("assignment", "declaration", "expression_statement")): + if parent is not None and top and any(k in t for k in ("assignment", "declaration", "expression_statement")): for name in self._declared_names(n): defs.append((parent if "export" in parent.type else n, name, "definition.constant")) # class fields and properties diff --git a/plugins/shall/tooling/src/shall/config.py b/plugins/shall/tooling/src/shall/config.py index dfa8f6a..c0426a9 100644 --- a/plugins/shall/tooling/src/shall/config.py +++ b/plugins/shall/tooling/src/shall/config.py @@ -18,10 +18,12 @@ from __future__ import annotations import copy +import json import os +import sys from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Callable, Literal, get_args import yaml @@ -30,6 +32,11 @@ CONFIG_NAME = "shall.yaml" DOT_DIR = ".shall" SETTINGS = ("jev", "selection", "review", "conflicts", "codegraph", "triage", "hooks", "scan") +LAYER_KEYS = ("extends", "prefix", "corpus", "domains", "index", "effects") # see _layer +OPEN_KEYS = ("hooks.actions",) # keys are RFC statuses and levels, not setting names +# what a hook does with a statement at or above a threshold (hooks.actions), weakest first +Action = Literal["log", "warn", "ask", "deny"] +ACTIONS: tuple[Action, ...] = get_args(Action) OUTPUT_DIRS = (".shall-cache", ".shall-out") # shall's caches and outputs: never part of a change @@ -43,6 +50,27 @@ def make_dir(path: Path) -> Path: return path +def load_json(path: Path) -> dict: + """A JSON cache file, or {} when it is missing, broken or not an object.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def merge_save_json(path: Path, entries: dict, keep: Callable[[Any], bool] | None = None) -> None: + """Merge `entries` into the file on disk, then replace it atomically: parallel runs that share + one cache (CI matrix, two local commands) keep each other's entries. `keep` drops old ones.""" + make_dir(path.parent) + merged = {**load_json(path), **entries} + if keep is not None: + merged = {k: v for k, v in merged.items() if keep(v)} + tmp = path.with_suffix(f".{os.getpid()}.tmp") + tmp.write_text(json.dumps(merged, sort_keys=True), encoding="utf-8") + os.replace(tmp, path) + + DEFAULTS: dict[str, Any] = { "jev": { "model": "jev-1.13.0", @@ -116,6 +144,8 @@ def make_dir(path: Path) -> Path: "timeout": 300, # seconds per agent call "verify": True, # verify blocking findings (and those with depends_on) before they block "cache": True, # reuse agent answers when the prompt is identical (incremental re-review) + "verify_model": None, # None: review.model verifies too + "verify_retrieval": "treesitter", # treesitter (code graph + keyword) | keyword }, "triage": { # comments of other AI reviewers (`shall triage`): authors with GraphQL type Bot always @@ -129,6 +159,21 @@ def make_dir(path: Path) -> Path: } +def unknown_keys(raw: dict, base: Any = None, prefix: str = "") -> list[str]: + """Dotted keys of a shall.yaml that shall does not read: a typo (`reveiw.model`) would else + keep the default without a word.""" + if base is None: + base = {**DEFAULTS, **{k: None for k in LAYER_KEYS}} + out = [] + for key, value in raw.items(): + path = f"{prefix}{key}" + if not isinstance(base, dict) or key not in base: + out.append(path) + elif path not in OPEN_KEYS and isinstance(value, dict) and isinstance(base[key], dict): + out.extend(unknown_keys(value, base[key], path + ".")) + return out + + def _merge(base: dict, override: dict) -> dict: out = copy.deepcopy(base) for key, value in (override or {}).items(): @@ -182,6 +227,11 @@ def get(self, dotted: str, default: Any = None) -> Any: node = node[part] return node + @property + def verify_model(self) -> str: + """The model of the verifier agents: review.verify_model, else review.model.""" + return self.get("review.verify_model") or self.get("review.model") + @property def local(self) -> Layer: return next(layer for layer in self.layers if layer.local) @@ -237,6 +287,55 @@ def _read(path: Path) -> dict: return yaml.safe_load(path.read_text(encoding="utf-8")) or {} +def valid_action_row(row: Any) -> bool: + """A hooks.actions row: [min p, action].""" + return (isinstance(row, list) and len(row) == 2 and isinstance(row[0], (int, float)) + and not isinstance(row[0], bool) and row[1] in ACTIONS) + + +def _drop_bad_actions(raw: dict) -> list[str]: + """Remove from `raw` the hooks.actions entries that this version cannot read; return them. + After this, every row the hooks see is [min p, action]. A null entry stays (it means "none").""" + hooks = raw.get("hooks") + if not isinstance(hooks, dict) or hooks.get("actions") is None: + return [] + actions = hooks["actions"] + if not isinstance(actions, dict): + del hooks["actions"] + return [f"hooks.actions = {actions!r}"] + bad: list[str] = [] + for status, levels in list(actions.items()): + if levels is None: + continue + if not isinstance(levels, dict): + bad.append(f"hooks.actions.{status} = {levels!r}") + del actions[status] + continue + for level, rows in list(levels.items()): + if rows is None: + continue + if not isinstance(rows, list): + bad.append(f"hooks.actions.{status}.{level} = {rows!r}") + del levels[level] + continue + bad += [f"hooks.actions.{status}.{level} has {row!r}" for row in rows if not valid_action_row(row)] + levels[level] = [row for row in rows if valid_action_row(row)] + return bad + + +def _check_keys(path: Path | None, raw: dict) -> None: + unknown = unknown_keys(raw) + if unknown: + raise SystemExit(f"shall: {path or 'config'} has unknown key(s): {', '.join(unknown)}. " + "The keys shall reads and their defaults are DEFAULTS in " + "plugins/shall/tooling/src/shall/config.py.") + bad = _drop_bad_actions(copy.deepcopy(raw)) + if bad: + raise SystemExit(f"shall: {path or 'config'}: {bad[0]}; hooks.actions maps status -> level -> rows, " + f"each row is [min p, action] with action one of {', '.join(ACTIONS)} " + "(see hooks.actions in DEFAULTS, plugins/shall/tooling/src/shall/config.py)") + + def load_config(explicit: str | None = None, update: bool = False) -> Config: if explicit: os.environ["SHALL_CONFIG"] = explicit @@ -247,11 +346,11 @@ def load_config(explicit: str | None = None, update: bool = False) -> Config: f"{Path.cwd()} upwards, $SHALL_EXTENDS, and ~/.config/shall/config.yaml. " "Run `shall init` or pass --config." ) - has_local = kind == "workspace" + local = path if kind == "workspace" else None # without a workspace file there is no local corpus; relative paths anchor at the cwd - root = path.parent if has_local else Path.cwd() - in_dot = path.parent.name == DOT_DIR if has_local else True # no local corpus: .shall/rfcs is absent - if not has_local and not raw.get("extends"): + root = local.parent if local else Path.cwd() + in_dot = local.parent.name == DOT_DIR if local else True # no local corpus: .shall/rfcs is absent + if local is None and not raw.get("extends"): raise SystemExit(f"shall: {path} has no `extends:`; nothing to load") layers: list[Layer] = [] @@ -262,14 +361,22 @@ def load_config(explicit: str | None = None, update: bool = False) -> Config: if c.is_file()), None) if parent_cfg is None: raise SystemExit(f"shall: extended corpus {parent.name} has no {CONFIG_NAME}") + # not _check_keys: the org corpus may set a key or an action that a newer plugin reads. + # Its own CI checks it as a local config; here an action row this version cannot read is + # dropped, so one bad row does not make every hook fail open. parent_raw = _read(parent_cfg) + dropped = _drop_bad_actions(parent_raw) + if dropped: + print(f"shall: {parent.name}: ignored the hooks.actions entries that this version " + f"cannot read: {'; '.join(dropped)}", file=sys.stderr) if parent_raw.get("extends"): raise SystemExit(f"shall: {parent.name} extends another corpus; only one level is supported") layers.append(_layer(parent.name, False, parent_cfg.parent, parent_raw, parent_cfg.parent.name == DOT_DIR, "JRFC", parent)) - local_raw = raw if has_local else {} + local_raw = raw if local else {} layers.append(_layer("local", True, root, local_raw, in_dot, None if raw.get("extends") else "JRFC")) + _check_keys(path, raw) settings = copy.deepcopy(DEFAULTS) for source in (parent_raw, raw): settings = _merge(settings, {k: v for k, v in source.items() if k in SETTINGS}) diff --git a/plugins/shall/tooling/src/shall/conflicts.py b/plugins/shall/tooling/src/shall/conflicts.py index 8c9a2fb..0bbeb0c 100644 --- a/plugins/shall/tooling/src/shall/conflicts.py +++ b/plugins/shall/tooling/src/shall/conflicts.py @@ -8,14 +8,16 @@ from __future__ import annotations -import asyncio from pathlib import Path +from typing import Literal + from .config import Config from .corpus import Corpus, Issue, Rfc, Statement, parse_rfc -from .jev import Jev, choice, noul +from .jev import JevLike, choice, noul -RELATION = { +Relation = Literal["duplicate", "weakens", "conflict", "overlap", "unrelated"] +RELATION: dict[Relation, str] = { "duplicate": "Both statements require the same thing, or the new statement repeats the existing " "requirement for a narrower case that the existing statement already covers.", "weakens": "The new statement relaxes, delays or makes optional something the existing statement " @@ -25,10 +27,10 @@ "without relaxing either one.", "unrelated": "They govern different things; there is no meaningful overlap.", } -FAILING = ("duplicate", "weakens", "conflict") +FAILING: tuple[Relation, ...] = ("duplicate", "weakens", "conflict") -async def compare(jev: Jev, new: list[tuple[Rfc, Statement]], existing: list[tuple[Rfc, Statement]], +async def compare(jev: JevLike, new: list[tuple[Rfc, Statement]], existing: list[tuple[Rfc, Statement]], fail_p: float, report_p: float) -> list[dict]: """One small request per pair (packing the corpus into one state and pointing at `existing[i]` failed badly: indirection + distracting state). @@ -53,7 +55,7 @@ async def compare(jev: Jev, new: list[tuple[Rfc, Statement]], existing: list[tup probs = ans["relation"]["probabilities"] gate = max(sum(probs.get(k, 0.0) for k in FAILING), ans["permits"]["p"], ans["violates"]["p"]) if gate >= fail_p: - relation = max(FAILING, key=lambda k: probs.get(k, 0.0)) + relation: Relation = max(FAILING, key=lambda k: probs.get(k, 0.0)) p = gate else: # below the gate: report as info when the pair is related at all, using the @@ -87,7 +89,7 @@ def _thresholds(cfg: Config, fail_p: float | None) -> tuple[float, float]: float(cfg.get("conflicts.report_threshold"))) -async def find_conflicts(cfg: Config, corpus: Corpus, jev: Jev, path: Path, all_domains: bool, +async def find_conflicts(cfg: Config, corpus: Corpus, jev: JevLike, path: Path, all_domains: bool, fail_p: float | None = None) -> list[dict]: issues: list[Issue] = [] draft = parse_rfc(path.resolve(), cfg.root, issues, cfg.prefix or "JRFC") @@ -97,7 +99,7 @@ async def find_conflicts(cfg: Config, corpus: Corpus, jev: Jev, path: Path, all_ return await compare(jev, [(draft, s) for s in draft.statements], existing, *_thresholds(cfg, fail_p)) -async def find_local_conflicts(cfg: Config, corpus: Corpus, jev: Jev, all_domains: bool = True, +async def find_local_conflicts(cfg: Config, corpus: Corpus, jev: JevLike, all_domains: bool = True, fail_p: float | None = None) -> list[dict]: if cfg.parent is None: raise SystemExit("shall conflicts --local: this workspace does not extend another corpus") diff --git a/plugins/shall/tooling/src/shall/corpus.py b/plugins/shall/tooling/src/shall/corpus.py index 6e8d29d..4eee113 100644 --- a/plugins/shall/tooling/src/shall/corpus.py +++ b/plugins/shall/tooling/src/shall/corpus.py @@ -6,12 +6,15 @@ import re from dataclasses import dataclass, field from pathlib import Path +from typing import Literal, TypeGuard, get_args import yaml +from .checks import CHECKS from .config import Config, Layer -STATUSES = ("draft", "approved", "enforced", "deprecated") +Status = Literal["draft", "approved", "enforced", "deprecated"] +STATUSES: tuple[Status, ...] = get_args(Status) ARTIFACTS = ("diff", "code", "spec", "doc", "tool") # tool: an agent tool call (hooks.py) ENFORCEMENTS = ("agent", "linter", "human") REQUIRED_FRONT = ( @@ -31,19 +34,21 @@ ) CODE_SPAN_RE = re.compile(r"`[^`]*`") LOWER_KEYWORD_RE = re.compile(r"\b(must|shall|should)\b") -LEVEL_OF = { +Level = Literal["MUST", "SHOULD", "MAY"] +Severity = Literal["error", "warning"] +LEVEL_OF: dict[str, Level] = { "MUST": "MUST", "MUST NOT": "MUST", "SHALL": "MUST", "SHALL NOT": "MUST", "REQUIRED": "MUST", "SHOULD": "SHOULD", "SHOULD NOT": "SHOULD", "RECOMMENDED": "SHOULD", "NOT RECOMMENDED": "SHOULD", "MAY": "MAY", "OPTIONAL": "MAY", } -LEVEL_RANK = {"MUST": 3, "SHOULD": 2, "MAY": 1} +LEVEL_RANK: dict[Level, int] = {"MUST": 3, "SHOULD": 2, "MAY": 1} @dataclass class Issue: path: str line: int - severity: str # error | warning + severity: Severity code: str message: str layer: str = "local" @@ -69,7 +74,7 @@ class Statement: rfc_id: str title: str text: str - level: str | None + level: Level | None keywords: list[str] applies_when: str | None not_applies_when: str | None @@ -86,7 +91,7 @@ class Statement: class Rfc: id: str title: str - status: str + status: Status | None # None: not a valid status (a lint error); matches no status filter domain: str artifacts: list[str] languages: list[str] @@ -102,6 +107,10 @@ class Rfc: statements: list[Statement] = field(default_factory=list) layer: str = "local" + @property + def status_label(self) -> str: + return self.status or "invalid" + def statement_blocking(self, st: Statement) -> bool: return self.status == "enforced" and st.level == "MUST" @@ -118,6 +127,13 @@ class Corpus: def statements(self) -> list[tuple[Rfc, Statement]]: return [(r, s) for r in self.rfcs.values() for s in r.statements] + def require(self, sid: str) -> tuple[Rfc, Statement]: + """The statement of an id that code already checked (selected, validated); KeyError if not.""" + found = self.statement(sid) + if found is None: + raise KeyError(f"unknown statement {sid}") + return found + def statement(self, sid: str) -> tuple[Rfc, Statement] | None: rfc = self.rfcs.get(sid.split(".")[0]) if rfc: @@ -234,7 +250,8 @@ def _parse_statements(rfc_id: str, body: list[str], offset: int, rel: str, issue text = " ".join(text_lines) normative = CODE_SPAN_RE.sub("", text) # `MUST` in backticks is a mention, not a use keywords = KEYWORD_RE.findall(normative) - levels = sorted({LEVEL_OF[k] for k in keywords}, key=lambda lv: -LEVEL_RANK[lv]) + found: set[Level] = {LEVEL_OF[k] for k in keywords} + levels = sorted(found, key=lambda lv: -LEVEL_RANK[lv]) statements.append(Statement( id=sid, rfc_id=rfc_id, @@ -250,7 +267,8 @@ def _parse_statements(rfc_id: str, body: list[str], offset: int, rel: str, issue tools=meta.get("tools") or None, violated_when=meta.get("violated when") or None, pattern=meta.get("pattern") or None, - check=meta.get("check") or None, + # an unknown name is a lint error below; never stored, so CHECKS[st.check] cannot fail + check=meta.get("check") if meta.get("check") in CHECKS else None, )) # lint per statement line = offset + start + 1 @@ -283,7 +301,6 @@ def _parse_statements(rfc_id: str, body: list[str], offset: int, rel: str, issue issues.append(Issue(rel, line, "error", "pattern-enforcement", f"{sid} has a Pattern, so it is checked by code: use 'Enforcement: linter'")) if meta.get("check"): - from .checks import CHECKS if meta["check"] not in CHECKS: issues.append(Issue(rel, line, "error", "check", f"{sid} has unknown Check '{meta['check']}' (known: {', '.join(sorted(CHECKS))})")) @@ -295,6 +312,10 @@ def _parse_statements(rfc_id: str, body: list[str], offset: int, rel: str, issue return statements +def _is_status(value: str) -> TypeGuard[Status]: + return value in STATUSES + + def parse_rfc(path: Path, root: Path, issues: list[Issue], prefix: str | None = "JRFC") -> Rfc | None: rel = str(path.relative_to(root)) if path.is_relative_to(root) else str(path) front, body, offset = _split_front_matter(path.read_text(encoding="utf-8")) @@ -312,9 +333,10 @@ def parse_rfc(path: Path, root: Path, issues: list[Issue], prefix: str | None = issues.append(Issue(rel, 1, "error", "rfc-id", f"id '{rid}' must look like {prefix}-0000 in this corpus")) elif not path.name.startswith(rid): issues.append(Issue(rel, 1, "error", "file-name", f"file name must start with '{rid}'")) - status = str(front.get("status", "")) - if status not in STATUSES: - issues.append(Issue(rel, 1, "error", "status", f"status '{status}' not in {STATUSES}")) + raw_status = str(front.get("status", "")) + status: Status | None = raw_status if _is_status(raw_status) else None + if status is None: + issues.append(Issue(rel, 1, "error", "status", f"status '{raw_status}' not in {STATUSES}")) artifacts = _as_list(front.get("artifacts")) for a in artifacts: if a not in ARTIFACTS: @@ -419,7 +441,7 @@ def load_corpus(cfg: Config) -> Corpus: f"{rfc.id} and {sup} are in different corpus layers; a local RFC cannot " "supersede an organisation RFC (propose the change upstream)", rfc.layer)) from .effects import load_effects - effects = load_effects([layer.effects_file for layer in cfg.layers], issues) + effects = load_effects([layer.effects_file for layer in cfg.layers if layer.effects_file], issues) return Corpus(root=cfg.root, domains=domains, rfcs=rfcs, issues=issues, layers=cfg.layers, effects=effects) diff --git a/plugins/shall/tooling/src/shall/effects.py b/plugins/shall/tooling/src/shall/effects.py index fb73a53..7e53c8d 100644 --- a/plugins/shall/tooling/src/shall/effects.py +++ b/plugins/shall/tooling/src/shall/effects.py @@ -51,7 +51,7 @@ def load_effects(files: list[Path], issues: list | None = None) -> list[Effect]: out: list[Effect] = [] seen: set[str] = set() for path in files: - if path is None or not path.is_file(): + if not path.is_file(): continue data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} for i, raw in enumerate(data.get("effects") or [], 1): diff --git a/plugins/shall/tooling/src/shall/evaluate.py b/plugins/shall/tooling/src/shall/evaluate.py index c30e984..bd43af1 100644 --- a/plugins/shall/tooling/src/shall/evaluate.py +++ b/plugins/shall/tooling/src/shall/evaluate.py @@ -8,10 +8,11 @@ import yaml +from . import git from .artifact import build_artifact, detect_kind, read_source from .config import Config from .corpus import Corpus -from .jev import Jev +from .jev import JevLike from .select import Selector, classify_document SWEEP = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8] @@ -26,7 +27,6 @@ def _pr(expected: set[str], got: set[str]) -> tuple[float, float]: def _case_facts(cfg: Config, corpus: Corpus, base: Path, case: dict, artifact) -> dict: """known_effects as in a real run; `repo:` (a fixture directory) enables indirect facts.""" import shutil - import subprocess import tempfile from .codegraph import RepoIndex @@ -37,11 +37,11 @@ def _case_facts(cfg: Config, corpus: Corpus, base: Path, case: dict, artifact) - with tempfile.TemporaryDirectory(prefix="shall-eval-") as tmp: root = Path(tmp) / "repo" shutil.copytree(base / case["repo"], root) - subprocess.run(["git", "init", "-q"], cwd=root, check=True) # the index uses `git grep` + git.init(root) # the index uses `git grep` return chunk_facts(corpus.effects, artifact, RepoIndex(root), depth) -async def _run_case(cfg: Config, corpus: Corpus, jev: Jev, base: Path, case: dict, strategy: str, +async def _run_case(cfg: Config, corpus: Corpus, jev: JevLike, base: Path, case: dict, strategy: str, facts: bool = True) -> dict: name, raw = read_source(str(base / case["file"]), None) kind = case.get("kind") or detect_kind(name, raw) @@ -71,7 +71,7 @@ async def _run_case(cfg: Config, corpus: Corpus, jev: Jev, base: Path, case: dic def _lost_at(corpus: Corpus, results, sid: str) -> str: """The layer that dropped a missed statement: the deepest layer it reached in any chunk.""" - rfc, _ = corpus.statement(sid) + rfc, _ = corpus.require(sid) reached = "prefilter" for r in results: if sid in r.statements: @@ -83,7 +83,7 @@ def _lost_at(corpus: Corpus, results, sid: str) -> str: return reached -async def run_eval(cfg: Config, corpus: Corpus, jev: Jev, labels_path: Path, strategies: list[str], +async def run_eval(cfg: Config, corpus: Corpus, jev: JevLike, labels_path: Path, strategies: list[str], facts: bool = True) -> dict: labels = yaml.safe_load(labels_path.read_text(encoding="utf-8")) base = labels_path.parent @@ -148,32 +148,33 @@ def render_eval(report: dict) -> str: # ---------------------------------------------------------------- verification eval -async def _verify_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, model: str | None) -> dict: +async def _verify_case(cfg: Config, corpus: Corpus, jev: JevLike, case_dir: Path, model: str | None) -> dict: import shutil - import subprocess import tempfile import time from .artifact import build_artifact + from .findings import RawFinding, validate_findings from .verify import verify_findings meta = yaml.safe_load((case_dir / "case.yaml").read_text(encoding="utf-8")) with tempfile.TemporaryDirectory(prefix="shall-verify-") as tmp: root = Path(tmp) / "repo" shutil.copytree(case_dir / "repo", root) - subprocess.run(["git", "init", "-q"], cwd=root, check=True) # search runs `git grep` like in CI + git.init(root) # search runs `git grep` like in CI raw = (root / meta["artifact"]).read_text(encoding="utf-8") artifact = build_artifact(meta["artifact"], raw, meta.get("kind", "code"), int(cfg.get("jev.max_chunk_chars"))) chunk = artifact.chunks[0] f = meta["finding"] line = next(n for n, t in sorted(chunk.line_text.items()) if f["quote"] in t) - rfc, st = corpus.statement(f["statement_id"]) - finding = { - "statement_id": st.id, "rfc": rfc.id, "title": st.title, "level": st.level, - "blocking": rfc.statement_blocking(st), "path": meta["artifact"], "line": line, - "quote": f["quote"], "message": f["message"], "suggestion": "", "depends_on": f.get("depends_on", []), - "chunk": chunk.id, "statement_text": st.text, "source": corpus.source(rfc, st.line), - } + # the labelled finding goes through the same validation as the reviewer's output + selection = {"chunks": [{"id": chunk.id, "selected": [f["statement_id"]]}]} + labelled: RawFinding = {"chunk": chunk.id, "statement_id": f["statement_id"], "line": line, "quote": f["quote"], + "message": f["message"], "depends_on": f.get("depends_on", [])} + findings, rejected = validate_findings(corpus, artifact, selection, [labelled], 1) + if not findings: + raise ValueError(f"{case_dir.name}: the labelled finding is not valid: {rejected}") + finding = findings[0] # expected evidence is "path: text on the evidence line" -> (path, line number) targets = [] for spec in meta["expected"].get("evidence", []): @@ -184,7 +185,7 @@ async def _verify_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, mo kept, dropped, stats = await verify_findings(cfg, corpus, artifact, jev, [finding], search_root=root, model=model) seconds = time.perf_counter() - started - result = (kept or dropped)[0]["verification"] + result = kept[0].verification.to_json() if kept and kept[0].verification else dropped[0]["verification"] expected = meta["expected"] ranges = [] for shown in result.get("shown", []): @@ -202,7 +203,7 @@ async def _verify_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, mo } -async def run_verify_eval(cfg: Config, corpus: Corpus, jev: Jev, cases_root: Path, models: list[str], +async def run_verify_eval(cfg: Config, corpus: Corpus, jev: JevLike, cases_root: Path, models: list[str], retrievals: list[str]) -> dict: cases = sorted(p for p in cases_root.iterdir() if (p / "case.yaml").is_file()) report = {} @@ -247,9 +248,8 @@ def render_verify_eval(report: dict) -> str: # ---------------------------------------------------------------- triage of other AI reviewers' comments -async def _triage_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, cache=None) -> dict: +async def _triage_case(cfg: Config, corpus: Corpus, jev: JevLike, case_dir: Path, cache=None) -> dict: import shutil - import subprocess import tempfile from .artifact import build_artifact @@ -267,7 +267,7 @@ async def _triage_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, ca with tempfile.TemporaryDirectory(prefix="shall-triage-") as tmp: root = Path(tmp) / "repo" shutil.copytree(case_dir / "repo", root) - subprocess.run(["git", "init", "-q"], cwd=root, check=True) # the code graph runs `git grep` + git.init(root) # the code graph runs `git grep` index = repo_index(cfg, corpus, root) facts = {} if cfg.get("selection.facts") and corpus.effects: @@ -288,7 +288,7 @@ async def _triage_case(cfg: Config, corpus: Corpus, jev: Jev, case_dir: Path, ca "errors": stats.get("errors", [])} -async def run_triage_eval(cfg: Config, corpus: Corpus, jev: Jev, cases_root: Path, cache=None) -> dict: +async def run_triage_eval(cfg: Config, corpus: Corpus, jev: JevLike, cases_root: Path, cache=None) -> dict: """Per labelled comment: was a relevant one kept (relevant or unverified), was noise removed? The costly error is a relevant comment marked noise: it is counted separately.""" cases = sorted(p for p in cases_root.iterdir() if (p / "case.yaml").is_file()) @@ -342,7 +342,7 @@ def _pct(values: list[float], q: float) -> int | None: return round(ordered[min(len(ordered) - 1, int(q * len(ordered)))]) -async def run_hooks_eval(cfg: Config, corpus: Corpus, jev: Jev, cases_path: Path, score: str = "both", +async def run_hooks_eval(cfg: Config, corpus: Corpus, jev: JevLike, cases_path: Path, score: str = "both", claude=None) -> dict: """Jev (or, with `claude`, a ClaudeJudge) as the judge of tool calls and written code (eval/hooks/cases.yaml). @@ -412,7 +412,7 @@ async def run_hooks_eval(cfg: Config, corpus: Corpus, jev: Jev, cases_path: Path missed = [] for r in (r for r in rows if r["kind"] == kind): for sid, p in r["scores"].items(): - rfc, st = corpus.statement(sid) + rfc, st = corpus.require(sid) act = action_for(policy, rfc, st, p) if kind == "tool" else code_action(policy, code_warn, rfc, st, p) if act in acts: acts[act]["right" if sid in r["expected"] else "wrong"] += 1 diff --git a/plugins/shall/tooling/src/shall/fetch.py b/plugins/shall/tooling/src/shall/fetch.py index adce131..e454c6c 100644 --- a/plugins/shall/tooling/src/shall/fetch.py +++ b/plugins/shall/tooling/src/shall/fetch.py @@ -17,11 +17,12 @@ import os import re import shutil -import subprocess import tempfile from dataclasses import dataclass from pathlib import Path +from . import git + RESOLVED = ".shall-resolved" GIT_TIMEOUT = 120 # seconds per git command (a shallow fetch of the corpus) @@ -52,18 +53,15 @@ def parse_spec(spec) -> dict: def _git(args: list[str], cwd: Path, url: str | None = None) -> str: - cmd = ["git", "-c", "advice.detachedHead=false"] + options = ["-c", "advice.detachedHead=false"] token = os.environ.get("SHALL_GIT_TOKEN") if token and url and url.startswith("https://"): basic = base64.b64encode(f"x-access-token:{token}".encode()).decode() - cmd += ["-c", f"http.extraHeader=Authorization: Basic {basic}"] - try: - proc = subprocess.run(cmd + args, cwd=cwd, capture_output=True, text=True, timeout=GIT_TIMEOUT) - except subprocess.TimeoutExpired: - raise SystemExit(f"shall: git {args[0]} timed out after {GIT_TIMEOUT}s") - if proc.returncode != 0: - raise SystemExit(f"shall: git {args[0]} failed: {proc.stderr.strip()[-400:]}") - return proc.stdout.strip() + options += ["-c", f"http.extraHeader=Authorization: Basic {basic}"] + r = git.run(cwd, *args, options=tuple(options), timeout=GIT_TIMEOUT) + if not r.ok: + raise SystemExit(f"shall: git {args[0]} failed: {r.err.strip()[-400:]}") + return r.out.strip() def _fetch(url: str, ref: str, dest: Path) -> str: @@ -93,9 +91,7 @@ def resolve_parent(spec, base: Path, update: bool = False) -> Parent: raise SystemExit(f"shall: extends.path {root} does not exist") sha = None if (root / ".git").exists(): - proc = subprocess.run(["git", "rev-parse", "--verify", "-q", "HEAD"], cwd=root, - capture_output=True, text=True, timeout=GIT_TIMEOUT) - sha = proc.stdout.strip() if proc.returncode == 0 else None + sha = (git.git(root, "rev-parse", "--verify", "-q", "HEAD", timeout=GIT_TIMEOUT) or "").strip() or None return Parent(root=root, name=spec.get("name") or root.name, repo=None, ref=None, sha=sha, url_base=None) repo, ref = spec.get("repo"), spec.get("ref") if not repo or not ref: diff --git a/plugins/shall/tooling/src/shall/findings.py b/plugins/shall/tooling/src/shall/findings.py new file mode 100644 index 0000000..77cea90 --- /dev/null +++ b/plugins/shall/tooling/src/shall/findings.py @@ -0,0 +1,383 @@ +"""Findings: what the reviewer agent returns, what code accepts, and the reports made from them. + +Deterministic: no model call in this module (JTOOL-0003.2). The trust boundary: + + RawFinding agent JSON, untrusted (FINDINGS_SCHEMA) + -> validate_findings statement selected, line or quote found (JTOOL-0002.2) + Finding severity from the corpus (corpus_blocking) + -> Finding.with_verification(Verification) (verify.py) + Finding.blocking computed, never stored: verification can only lower it (JTOOL-0002.5, .6) + +Only this module makes a Finding from data (tests/test_boundaries.py checks it). `from_json` reads +back a findings.json that shall itself wrote; agent output always goes through `validate_findings`. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Literal, TypedDict, get_args + +from .artifact import Artifact, Chunk +from .corpus import Corpus, Level + +Verdict = Literal["confirmed", "refuted", "unknown"] +VERDICTS: tuple[Verdict, ...] = get_args(Verdict) + + +class RawFinding(TypedDict, total=False): + """One finding as the reviewer agent returns it (an item of FINDINGS_SCHEMA), plus the chunk + id that code adds. Untrusted: read it only in `validate_findings`.""" + chunk: str + statement_id: str + line: int | None + quote: str + message: str + suggestion: str + depends_on: list[str] + + +FINDINGS_SCHEMA = { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "statement_id": {"type": "string"}, + "line": {"type": ["integer", "null"]}, + "quote": {"type": "string"}, + "message": {"type": "string"}, + "suggestion": {"type": "string"}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["statement_id", "line", "quote", "message", "depends_on"], + }, + } + }, + "required": ["findings"], +} + + +@dataclass(frozen=True) +class Verification: + """A verifier verdict as code accepted it (verify.verification_of).""" + verdict: Verdict + reason: str + evidence: tuple[str, ...] = () # cited excerpts as path:start-end, or "chunk" + shown: tuple[str, ...] = () # audit: every excerpt the verifier saw + + def to_json(self) -> dict: + return {"verdict": self.verdict, "reason": self.reason, "evidence": list(self.evidence), + "shown": list(self.shown)} + + @classmethod + def from_json(cls, data: dict) -> Verification: + verdict = data.get("verdict") + return cls(verdict=verdict if verdict in VERDICTS else "unknown", reason=str(data.get("reason", "")), + evidence=tuple(data.get("evidence") or ()), shown=tuple(data.get("shown") or ())) + + +@dataclass(frozen=True) +class Finding: + """A finding that code validated. There is no `blocking` field: see the property.""" + statement_id: str + rfc: str + title: str + level: Level + corpus_blocking: bool # Rfc.statement_blocking: RFC status and statement level + path: str + line: int | None + quote: str + message: str + suggestion: str + statement_text: str + source: str + chunk: str + depends_on: tuple[str, ...] = () + verification: Verification | None = field(default=None) + + @property + def blocking(self) -> bool: + """The corpus decides; a verification that is not `confirmed` can only lower it.""" + return self.corpus_blocking and (self.verification is None or self.verification.verdict == "confirmed") + + @property + def downgraded(self) -> bool: + """Blocking by the corpus, advisory because its evidence was not seen.""" + return self.corpus_blocking and not self.blocking + + def with_verification(self, verification: Verification) -> Finding: + return replace(self, verification=verification) + + def to_json(self) -> dict: + out = { + "statement_id": self.statement_id, "rfc": self.rfc, "title": self.title, "level": self.level, + "blocking": self.blocking, "corpus_blocking": self.corpus_blocking, + "path": self.path, "line": self.line, "quote": self.quote, + "message": self.message, "suggestion": self.suggestion, "depends_on": list(self.depends_on), + "chunk": self.chunk, "statement_text": self.statement_text, "source": self.source, + } + if self.verification is not None: + out["verification"] = self.verification.to_json() + if self.downgraded: + out["downgraded"] = True # evidence not seen: a comment, never a failed check + return out + + @classmethod + def from_json(cls, data: dict) -> Finding: + """A finding from a findings.json that shall wrote (`to_json`). Never use it for agent output.""" + v = data.get("verification") + return cls( + statement_id=data["statement_id"], rfc=data["rfc"], title=data["title"], level=data["level"], + # findings.json before corpus_blocking existed: blocking, or blocking until downgraded + corpus_blocking=bool(data.get("corpus_blocking", data.get("blocking") or data.get("downgraded"))), + path=data["path"], line=data.get("line"), quote=data.get("quote", ""), + message=data.get("message", ""), suggestion=data.get("suggestion", ""), + statement_text=data.get("statement_text", ""), source=data.get("source", ""), + chunk=data.get("chunk", ""), depends_on=tuple(data.get("depends_on") or ()), + verification=Verification.from_json(v) if v else None, + ) + + +def read_findings(path: Path) -> list[Finding]: + """The findings in a findings.json that shall wrote: {"findings": [...]} or a bare list.""" + data = json.loads(path.read_text(encoding="utf-8")) + return [Finding.from_json(f) for f in (data.get("findings", []) if isinstance(data, dict) else data)] + + +def sort_key(f: Finding) -> tuple: + return (not f.blocking, f.path, f.line or 0, f.statement_id) + + +# ---------------------------------------------------------------- prompts and bundle + + +def render_statements(corpus: Corpus, ids: list[str]) -> str: + lines = [] + for sid in ids: + rfc, st = corpus.require(sid) + flag = "blocking" if rfc.statement_blocking(st) else "non-blocking" + lines.append(f"- {sid} [{st.level}, {flag}] {st.title}: {st.text}") + lines.append(f" Applies when: {st.applies_when or rfc.applies_when}") + return "\n".join(lines) + + +def chunk_prompt(corpus: Corpus, chunk: Chunk, ids: list[str], facts: list[str] | None = None) -> str: + anchor = ( + "Only lines marked '+' (added) can carry a finding; use their line number." + if chunk.kind == "diff" else + "Use the line number shown before '|'. Use line null for a finding about something missing from the whole document." + ) + return ( + f"\n" + f"{chunk.rendered}\n\n\n" + + (f"\nFacts found by code (effects registry and code graph) about what the " + f"code in the chunk calls:\n" + "\n".join(f"- {x}" for x in facts) + "\n\n\n" + if facts else "") + + f"\n{render_statements(corpus, ids)}\n\n\n" + f"{anchor}\nReturn only violations of the standards listed above. " + "Return an empty findings list when the chunk complies." + ) + + +def write_bundle(corpus: Corpus, artifact: Artifact, selection: dict, out: Path) -> Path: + """Human/agent-readable review bundle for interactive use inside Claude Code.""" + chunks = {c.id: c for c in artifact.chunks} + parts = [ + f"# shall review bundle — {artifact.source}", + "", + "For each chunk, check only the listed standards. Write all findings to one JSON file", + "with the shape `{\"findings\": [{\"chunk\": \"c0\", \"statement_id\": ..., \"line\": ..., " + "\"quote\": ..., \"message\": ..., \"suggestion\": ...}]}`, then run", + f"`shall validate --selection {artifact.source}`.", + "", + ] + for c in selection["chunks"]: + if not c["selected"]: + continue + parts += [f"## Chunk {c['id']} — {c['path']}", "", chunk_prompt(corpus, chunks[c["id"]], c["selected"], c.get("known_effects")), ""] + out.write_text("\n".join(parts), encoding="utf-8") + return out + + +# ---------------------------------------------------------------- validation + + +def _norm(text: str) -> str: + return " ".join((text or "").split()) + + +def _anchor(chunk: Chunk, line, quote: str) -> tuple[int | None, str | None]: + """Resolve the line a finding points at. The quote wins over the line number.""" + def text(n: int) -> str: + return _norm(chunk.line_text.get(n, "")) + if line in chunk.anchor_lines and (not quote or quote in text(line)): + return line, None + hits = [n for n in sorted(chunk.anchor_lines) if quote and quote in text(n)] + if hits: + return min(hits, key=lambda n: abs(n - (line or n))), None + if line in chunk.anchor_lines: + return line, None # quote paraphrased, but the line is a real changed line + if line is None and chunk.kind != "diff": + return None, None # document-level finding ("section X is missing") + return None, f"line {line} / quote not found among commentable lines of {chunk.path}" + + +def validate_findings(corpus: Corpus, artifact: Artifact, selection: dict, raw: list[RawFinding], + max_comments: int) -> tuple[list[Finding], list[dict]]: + """The only way from agent output to a Finding (JTOOL-0002.2). Returns (kept, dropped).""" + chunks = {c.id: c for c in artifact.chunks} + selected = {c["id"]: set(c["selected"]) for c in selection["chunks"]} + kept: list[Finding] = [] + dropped: list[dict] = [] + seen: set[tuple[str, str, int | None]] = set() + for f in raw: + cid = str(f.get("chunk", "")) + chunk = chunks.get(cid) + sid = str(f.get("statement_id", "")) + line = f.get("line") + quote = _norm(f.get("quote", "")) + found = corpus.statement(sid) + reason = None + if chunk is None: + reason = f"unknown chunk {cid!r}" + elif sid not in selected.get(cid, set()): + reason = f"{sid} was not selected for chunk {cid}" + elif found is None: + reason = f"{sid} does not exist in the corpus" + else: + line, reason = _anchor(chunk, line, quote) + if reason or chunk is None or found is None: + dropped.append({**f, "reason": reason}) + continue + rfc, st = found + key = (sid, chunk.path, line) + if key in seen: + dropped.append({**f, "reason": "duplicate"}) + continue + seen.add(key) + kept.append(Finding( + statement_id=sid, rfc=rfc.id, title=st.title, level=st.level or "MAY", + corpus_blocking=rfc.statement_blocking(st), # recomputed, never trusted from the model + path=chunk.path, line=line, quote=f.get("quote", ""), + message=f.get("message", ""), suggestion=f.get("suggestion", ""), + depends_on=tuple(str(d) for d in f.get("depends_on") or [])[:8], + chunk=cid, statement_text=st.text, source=corpus.source(rfc, st.line), + )) + kept.sort(key=sort_key) + if len(kept) > max_comments: + for k in kept[max_comments:]: + dropped.append({**k.to_json(), "reason": f"over max_comments={max_comments}"}) + kept = kept[:max_comments] + return kept, dropped + + +# ---------------------------------------------------------------- reports + + +def verification_line(f: Finding) -> str: + v = f.verification + if v is None: + return "" + where = f" ({', '.join(v.evidence)})" if v.evidence else "" + if v.verdict == "confirmed": + return f"_Verified{where}:_ {v.reason}" + note = "not blocking: the evidence it depends on was not seen" if f.downgraded else "could not be verified" + return f"_Unverified — {note}:_ {v.reason}" + + +def render_markdown(artifact: Artifact, selection: dict, findings: list[Finding], dropped: list[dict], + health: dict | None = None) -> str: + blocking = sum(1 for f in findings if f.blocking) + out = [ + f"## shall review — `{artifact.source}`", + "", + f"**{len(findings)} finding(s)**, {blocking} blocking · " + f"{len(selection['applicable'])} applicable statement(s) selected by `{selection['model']}` " + f"({selection['strategy']}) · {len(dropped)} finding(s) dropped by validation or verification", + "", + ] + if health is not None: + from .health import render_health + out += render_health(health) + if selection["applicable"]: + out += ["
Applicable standards", ""] + for a in selection["applicable"]: + out.append(f"- `{a['id']}` {a['title']} ({a['level']}, p={a['p_max']:.2f})") + out += ["", "
", ""] + for f in findings: + where = f"`{f.path}:{f.line}`" if f.line else f"`{f.path}` (whole document)" + tag = "🚫 blocking" if f.blocking else "💬 advisory" + out.append(f"- {tag} **{f.statement_id}** {f.title} — {where}") + out.append(f" {f.message}") + if f.suggestion: + out.append(f" _Suggestion:_ {f.suggestion}") + if f.verification: + out.append(f" {verification_line(f)}") + return "\n".join(out).rstrip() + "\n" + + +def line_hash(text: str) -> str: + """Hash of a line's content: stable when the line moves, new when the line changes.""" + return hashlib.sha1(_norm(text).encode()).hexdigest()[:12] + + +def finding_key(statement_id: str, path: str, lhash: str) -> str: + return hashlib.sha1(f"{statement_id}|{path}|{lhash}".encode()).hexdigest()[:16] + + +MARKER_RE = re.compile(r"") +SUMMARY_MARKER = "" + + +def comment_body(f: Finding, key: str | None = None, lhash: str | None = None) -> str: + tag = "🚫 **blocking**" if f.blocking else "💬 advisory" + body = f"{tag} · **{f.statement_id}** {f.title} ({f.level})\n\n{f.message}" + if f.suggestion: + body += f"\n\n**Suggestion:** {f.suggestion}" + if f.verification: + body += f"\n\n{verification_line(f)}" + body += f"\n\n> {f.statement_text}\n\nStandard: `{f.source}`" + if key: + body += f"\n\n" + return body + + +def render_github(artifact: Artifact, selection: dict, findings: list[Finding], dropped: list[dict], + health: dict | None = None) -> dict: + """Review payload plus the state `shall publish` needs to dedupe across pushes. + + Each inline comment carries a hidden key = hash(statement, path, line content). The + line content (not the line number) is used so a finding keeps its key when code above + it moves, and gets a new key when the line itself is changed. + """ + text_by_path: dict[str, dict[int, str]] = {} + added: dict[str, set[str]] = {} + for c in artifact.chunks: + text_by_path.setdefault(c.path, {}).update(c.line_text) + if artifact.kind == "diff": + added.setdefault(c.path, set()).update(line_hash(c.line_text.get(n, "")) for n in c.anchor_lines) + inline = [f for f in findings if f.line and artifact.kind == "diff"] + general = [f for f in findings if f not in inline] + comments = [] + for f in inline: + lh = line_hash(text_by_path.get(f.path, {}).get(f.line or 0, "")) + key = finding_key(f.statement_id, f.path, lh) + comments.append({"path": f.path, "line": f.line, "side": "RIGHT", + "body": comment_body(f, key, lh), + "shall_key": key, "shall_line": lh, "shall_blocking": f.blocking}) + return { + "event": "REQUEST_CHANGES" if any(f.blocking for f in findings) else "COMMENT", + "body": render_markdown(artifact, selection, general, dropped), + "comments": comments, + "shall": { + "summary": render_markdown(artifact, selection, findings, dropped, health), + "blocking": sum(1 for f in findings if f.blocking), + "added_line_hashes": {p: sorted(h) for p, h in sorted(added.items())}, + }, + } diff --git a/plugins/shall/tooling/src/shall/git.py b/plugins/shall/tooling/src/shall/git.py new file mode 100644 index 0000000..ef23fb6 --- /dev/null +++ b/plugins/shall/tooling/src/shall/git.py @@ -0,0 +1,55 @@ +"""The only module that runs `git`. Every call has a timeout and none raises on a git failure. + +Other modules call `run` (needs the exit code or stderr) or `git` (stdout or None). +tests/test_boundaries.py fails when another module starts a `git` process itself. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +TIMEOUT = 30 # seconds; a slow `git grep` on a large repository must not hang a hook or a review + + +@dataclass(frozen=True) +class GitResult: + code: int | None # None: git is not installed or timed out + out: str + err: str + + @property + def ok(self) -> bool: + return self.code == 0 + + +def run(cwd: str | Path, *args: str, options: tuple[str, ...] = (), timeout: float = TIMEOUT) -> GitResult: + """`git -C `. Output is decoded with surrogateescape: a path with + bytes that are not UTF-8 survives a round trip.""" + try: + proc = subprocess.run(["git", *options, "-C", str(cwd), *args], capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired: + return GitResult(None, "", f"timed out after {timeout:g}s") + except OSError as err: + return GitResult(None, "", str(err)) + return GitResult(proc.returncode, proc.stdout.decode("utf-8", "surrogateescape"), + proc.stderr.decode("utf-8", "replace")) + + +def git(cwd: str | Path, *args: str, ok: tuple[int, ...] = (0,), timeout: float = TIMEOUT) -> str | None: + """stdout, or None when git fails, is missing, times out or exits with a code not in `ok`.""" + r = run(cwd, *args, timeout=timeout) + return r.out if r.code is not None and r.code in ok else None + + +def init(path: Path) -> None: + """`git init` for a fixture copy (the code graph and the evidence search use `git grep`).""" + r = run(path, "init", "-q") + if not r.ok: + raise RuntimeError(f"git init {path} failed: {r.err.strip()[-300:]}") + + +def toplevel(start: str | Path, timeout: float = TIMEOUT) -> Path | None: + top = (git(start, "rev-parse", "--show-toplevel", timeout=timeout) or "").strip() + return Path(top) if top else None diff --git a/plugins/shall/tooling/src/shall/health.py b/plugins/shall/tooling/src/shall/health.py index 8577fb9..a12536e 100644 --- a/plugins/shall/tooling/src/shall/health.py +++ b/plugins/shall/tooling/src/shall/health.py @@ -7,8 +7,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from .config import Config +if TYPE_CHECKING: + from .findings import Finding + EVENT_TEXT = { "parse_error": "file(s) could not be parsed (less evidence and fewer facts)", "grammar_missing": "language(s) without a cached grammar (run `shall prefetch`)", @@ -29,7 +34,7 @@ def near_threshold(cfg: Config, selection: dict) -> list[dict]: return sorted(out, key=lambda x: -x["p"]) -def build_health(cfg: Config, selection: dict, stats: dict, findings: list[dict], dropped: list[dict], +def build_health(cfg: Config, selection: dict, stats: dict, findings: list[Finding], dropped: list[dict], events: list[dict]) -> dict: chunks = selection["chunks"] near = near_threshold(cfg, selection) @@ -53,7 +58,7 @@ def build_health(cfg: Config, selection: dict, stats: dict, findings: list[dict] "reused": stats.get("reused", 0) + int(v.get("reused", 0)), # incremental re-review "agent_errors": len(stats.get("errors", [])), "findings": len(findings), - "blocking": sum(1 for f in findings if f["blocking"]), + "blocking": sum(1 for f in findings if f.blocking), "dropped": len(dropped), }, "verification": { diff --git a/plugins/shall/tooling/src/shall/hooks.py b/plugins/shall/tooling/src/shall/hooks.py index b1fd1ed..2366b9d 100644 --- a/plugins/shall/tooling/src/shall/hooks.py +++ b/plugins/shall/tooling/src/shall/hooks.py @@ -32,19 +32,20 @@ import json import os import re -import subprocess import sys import time from dataclasses import dataclass from pathlib import Path +from . import git from .artifact import Chunk, parse_diff from .claude_judge import ClaudeJudge, NeedsReply -from .config import OUTPUT_DIRS, Config, make_dir +from .config import ACTIONS, OUTPUT_DIRS, Action, Config, make_dir from .corpus import Corpus, Rfc, Statement -from .jev import Jev, noul +from .findings import read_findings +from .jev import JevLike, noul -RANK = {"log": 1, "warn": 2, "ask": 3, "deny": 4} +RANK: dict[Action, int] = {action: n for n, action in enumerate(ACTIONS, 1)} # The Claude judge of the event being handled: one per event, also when `shall hookd` handles # several events at the same time. @@ -97,7 +98,7 @@ def scan_secrets(text: str) -> list[Secret]: for m in rx.finditer(text): value = m.group("v") # a value that repeats its own key name is a key (`jevTokens: 'jevTokens' in view`) - name = re.match(r"[\w.-]*", m.group(0)).group(0) + name = re.split(r"[^\w.-]", m.group(0), maxsplit=1)[0] if PLACEHOLDER_RE.search(value) or _norm(value) == _norm(name): continue found.append(Secret(kind, m.start("v"), m.end("v"))) @@ -249,14 +250,15 @@ def code_question(st: Statement): ) -def action_for(actions: dict, rfc: Rfc, st: Statement, p: float) -> str | None: - for threshold, action in (actions.get(rfc.status) or {}).get(st.level or "", []): - if p >= float(threshold): +def action_for(actions: dict, rfc: Rfc, st: Statement, p: float) -> Action | None: + # load_config checked the rows (local) or dropped the ones it cannot read (org config) + for threshold, action in (actions.get(rfc.status) or {}).get(st.level or "") or []: + if p >= threshold: return action return None -def code_action(actions: dict, code_warn: float, rfc: Rfc, st: Statement, p: float) -> str | None: +def code_action(actions: dict, code_warn: float, rfc: Rfc, st: Statement, p: float) -> Action | None: """After a write nothing can be denied, and a fragment is not evidence: at most a warning.""" action = action_for(actions, rfc, st, p) if action is None: @@ -266,7 +268,7 @@ def code_action(actions: dict, code_warn: float, rfc: Rfc, st: Statement, p: flo return action -def strongest(findings: list[dict]) -> str | None: +def strongest(findings: list[dict]) -> Action | None: acted = [f["action"] for f in findings if f.get("action")] return max(acted, key=RANK.__getitem__) if acted else None @@ -360,12 +362,8 @@ def synthetic_diff(rel: str, text: str, ranges: list[tuple[int, int]], context: # ---------------------------------------------------------------- git helpers -def _git(cwd: str | Path, *args: str, ok=(0,)) -> str | None: - try: - r = subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=10) - except (OSError, subprocess.TimeoutExpired): - return None - return r.stdout if r.returncode in ok else None +def _git(cwd: str | Path, *args: str, ok: tuple[int, ...] = (0,)) -> str | None: + return git.git(cwd, *args, ok=ok, timeout=10) # a hook answers in seconds # shall's own caches and outputs are never part of the change, even where .gitignore misses them @@ -384,7 +382,7 @@ def working_diff(root: Path) -> str: # ---------------------------------------------------------------- the hooks class Hooks: - def __init__(self, cfg: Config, corpus: Corpus, jev: Jev, statuses: list[str] | None = None, + def __init__(self, cfg: Config, corpus: Corpus, jev: JevLike, statuses: list[str] | None = None, timeout: float | None = -1, log: bool = True, score: str = "both", skip_when_denied: bool = True, claude: ClaudeJudge | None = None): self.cfg = cfg @@ -409,7 +407,7 @@ def __init__(self, cfg: Config, corpus: Corpus, jev: Jev, statuses: list[str] | # -- entry point shared by `shall hook` and `shall hookd` async def handle(self, event: dict) -> dict: if os.environ.get("SHALL_HOOKS_DISABLED") or not self.cfg.get("hooks.enabled", True): - return {} # env: set for the agents shall itself runs (review.run_claude) + return {} # env: set for the agents shall itself runs (claude.run_claude) name = event.get("hook_event_name") t0 = time.perf_counter() record: dict = {"event": name, "session": event.get("session_id")} @@ -419,6 +417,7 @@ async def handle(self, event: dict) -> dict: replies = event.get("shall_replies") judge = ClaudeJudge(self.cfg, replies if isinstance(replies, dict) else None) token = _event_judge.set(judge) + out: dict try: if name == "PreToolUse": out = await self.pre(event, record) @@ -456,7 +455,7 @@ def _log(self, record: dict) -> None: with self.log_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") - def _finding(self, rfc: Rfc, st: Statement, p: float, action: str | None, by: str, **extra) -> dict: + def _finding(self, rfc: Rfc, st: Statement, p: float, action: Action | None, by: str, **extra) -> dict: return {"id": st.id, "title": st.title, "level": st.level, "status": rfc.status, "text": st.text, "p": round(p, 4), "action": action, "by": by, **extra} @@ -678,10 +677,13 @@ async def judge_code(self, chunks: list[Chunk], record: dict, action, checked: set[tuple[str, str]] = set() for chunk in chunks: text = (texts or {}).get(chunk.path) - for rfc, st in (selector.eligible(chunk, checks=True) if text is not None else []): + if text is None: + continue + for rfc, st in selector.eligible(chunk, checks=True): if (chunk.path, st.id) in checked: continue # one check per file, whatever the number of chunks checked.add((chunk.path, st.id)) + assert st.check is not None # eligible(checks=True) keeps only statements with a known Check lines = CHECKS[st.check](chunk.path, text) if lines is None: continue # the check cannot judge this file @@ -713,10 +715,9 @@ async def stop(self, event: dict, record: dict) -> dict: if not self.cfg.get("hooks.stop.enabled"): return {} cwd = event.get("cwd") or "." - top = (_git(cwd, "rev-parse", "--show-toplevel") or "").strip() - if not top: + root = git.toplevel(cwd, timeout=10) + if root is None: return {} - root = Path(top) diff = working_diff(root) if not diff.strip(): return {} @@ -767,15 +768,15 @@ async def _stop_review(self, diff: str, sha: str, session: str, root: Path, stat if proc.returncode not in (0, 2) or not findings_path.is_file(): return {"systemMessage": f"shall: stop review failed (exit {proc.returncode}): " f"{redact(err.decode(errors='replace').strip()[-300:])[0]}"} - findings = json.loads(findings_path.read_text(encoding="utf-8"))["findings"] - blocking = [f for f in findings if f.get("blocking")] + findings = read_findings(findings_path) + blocking = [f for f in findings if f.blocking] state["reviewed"] = sha - record["findings"] = [{"id": f["statement_id"], "blocking": f["blocking"]} for f in findings] + record["findings"] = [{"id": f.statement_id, "blocking": f.blocking} for f in findings] out: dict = {} if blocking and state.get("blocks", 0) < int(self.cfg.get("hooks.stop.max_blocks")): state["blocks"] = state.get("blocks", 0) + 1 - lines = [f"- {f['statement_id']} {f['title']} at {f['path']}:{f.get('line') or '?'}: " - f"{f['message']}" + (f" Suggestion: {f['suggestion']}" if f.get("suggestion") else "") + lines = [f"- {f.statement_id} {f.title} at {f.path}:{f.line or '?'}: " + f"{f.message}" + (f" Suggestion: {f.suggestion}" if f.suggestion else "") for f in blocking] out = {"decision": "block", "reason": "shall review found verified blocking findings in the change. Fix them before " diff --git a/plugins/shall/tooling/src/shall/jev.py b/plugins/shall/tooling/src/shall/jev.py index e80a5d5..fe8eb2b 100644 --- a/plugins/shall/tooling/src/shall/jev.py +++ b/plugins/shall/tooling/src/shall/jev.py @@ -7,10 +7,14 @@ import json import os from pathlib import Path +from typing import Any, Awaitable, Iterable, Mapping, Protocol, TypeVar -from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, NoulCriteria, RetryPolicy +from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, NoulCriteria, RetryPolicy, TypeSafeError -from .config import Config, make_dir +from .config import Config, load_json, merge_save_json + +# A Jev request that failed after retries. Callers catch this name, never the SDK's own. +JevServiceError = TypeSafeError # The API budgets are 64k tokens per request and 32k for state + the longest question. STATE_PLUS_QUESTION_TOKENS = 30000 @@ -27,8 +31,27 @@ def noul(instructions: str, true: str | None = None, false: str | None = None) - return Noul(instructions=instructions) -def choice(instructions: str, options: dict[str, str | None]) -> Choice: - return Choice(instructions=instructions, criteria=options) +Option = TypeVar("Option", bound=str) + + +def choice(instructions: str, options: Mapping[Option, str | None]) -> Choice: + return Choice(instructions=instructions, criteria={str(k): v for k, v in options.items()}) + + +class JevLike(Protocol): + """What selection, verification, triage, scan and hooks need from Jev. Code takes a `JevLike`, + so the one test fake (tests/fakes.py) fits every caller without a cast.""" + + model: str + usage: dict + + def available(self) -> bool: ... + + def save(self) -> None: ... + + async def gather(self, aws: Iterable[Awaitable[Any]]) -> list: ... + + async def ask(self, state: Any, questions: dict) -> dict: ... class Jev: @@ -38,9 +61,7 @@ def __init__(self, cfg: Config, use_cache: bool = True): self.cache_path: Path = cfg.cache_file self.use_cache = use_cache self.sem = asyncio.Semaphore(int(cfg.get("jev.concurrency"))) - self.cache: dict = {} - if use_cache and self.cache_path.is_file(): - self.cache = json.loads(self.cache_path.read_text(encoding="utf-8")) + self.cache: dict = load_json(self.cache_path) if use_cache else {} # est_input_tokens counts cached calls too: a cache-independent cost measure for evals self.usage = {"requests": 0, "cached": 0, "input_tokens": 0, "est_input_tokens": 0} self._client: AsyncTypeSafeClient | None = None @@ -69,21 +90,8 @@ def client(self) -> AsyncTypeSafeClient: return self._client def save(self) -> None: - """Merge with what is on disk, then replace atomically: parallel runs sharing one - cache (CI matrix, two local commands) keep each other's answers.""" - if not self.use_cache: - return - make_dir(self.cache_path.parent) - merged = {} - if self.cache_path.is_file(): - try: - merged = json.loads(self.cache_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - merged = {} - merged.update(self.cache) - tmp = self.cache_path.with_suffix(f".{os.getpid()}.tmp") - tmp.write_text(json.dumps(merged, sort_keys=True), encoding="utf-8") - os.replace(tmp, self.cache_path) + if self.use_cache: + merge_save_json(self.cache_path, self.cache) def _key(self, state, questions: dict) -> str: payload = { diff --git a/plugins/shall/tooling/src/shall/publish.py b/plugins/shall/tooling/src/shall/publish.py index 2316f8b..bbdd808 100644 --- a/plugins/shall/tooling/src/shall/publish.py +++ b/plugins/shall/tooling/src/shall/publish.py @@ -25,7 +25,7 @@ from dataclasses import dataclass, field from typing import Callable -from .review import MARKER_RE, SUMMARY_MARKER +from .findings import MARKER_RE, SUMMARY_MARKER Runner = Callable[[list[str], str | None], str] diff --git a/plugins/shall/tooling/src/shall/review.py b/plugins/shall/tooling/src/shall/review.py index 6c4a08c..0da7360 100644 --- a/plugins/shall/tooling/src/shall/review.py +++ b/plugins/shall/tooling/src/shall/review.py @@ -1,244 +1,34 @@ -"""Agent review of the selected statements, then deterministic validation and reports. +"""The reviewer agent: one tool-less `claude -p` call per chunk with the selected statements. -The agent (Claude Code in print mode, with the plugin's shall-reviewer prompt) only sees -one chunk and the statements Jev selected for it. Code then checks every finding: -the statement was selected for that chunk, the line is a changed line (or the quote -exists), and severity is recomputed from the corpus instead of trusted from the model. +The agent (Claude Code in print mode, with the plugin's shall-reviewer prompt) only sees one +chunk and the statements Jev selected for it. Its output is RawFinding JSON; findings.py then +validates every finding and computes severity from the corpus instead of trusting the model. """ from __future__ import annotations import asyncio -import hashlib -import json -import os -import re -import tempfile -import time from pathlib import Path -from .artifact import Artifact, Chunk +from .artifact import Artifact +from .claude import AnswerCache, agent_prompt, run_claude from .config import Config, make_dir from .corpus import Corpus - -FINDINGS_SCHEMA = { - "type": "object", - "properties": { - "findings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "statement_id": {"type": "string"}, - "line": {"type": ["integer", "null"]}, - "quote": {"type": "string"}, - "message": {"type": "string"}, - "suggestion": {"type": "string"}, - "depends_on": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["statement_id", "line", "quote", "message", "depends_on"], - }, - } - }, - "required": ["findings"], -} - - -# one more try for agent failures that a second call usually does not repeat -RETRY_ERRORS = ("error_max_structured_output_retries", "no stdin data received") - - -def plugin_root() -> Path: - env = os.environ.get("SHALL_PLUGIN_ROOT") - if env: - return Path(env) - return Path(__file__).resolve().parents[3] # tooling/src/shall/review.py -> plugins/shall - - -def agent_prompt(name: str) -> str: - """The body of agents/.md: one prompt for interactive and CI use (JTOOL-0004.2).""" - text = (plugin_root() / "agents" / f"{name}.md").read_text(encoding="utf-8") - if text.startswith("---"): - text = text.split("---", 2)[2] - return text.strip() +from .findings import FINDINGS_SCHEMA, RawFinding, chunk_prompt def reviewer_prompt() -> str: return agent_prompt("shall-reviewer") -def render_statements(corpus: Corpus, ids: list[str]) -> str: - lines = [] - for sid in ids: - rfc, st = corpus.statement(sid) - flag = "blocking" if rfc.statement_blocking(st) else "non-blocking" - lines.append(f"- {sid} [{st.level}, {flag}] {st.title}: {st.text}") - lines.append(f" Applies when: {st.applies_when or rfc.applies_when}") - return "\n".join(lines) - - -def chunk_prompt(corpus: Corpus, chunk: Chunk, ids: list[str], facts: list[str] | None = None) -> str: - anchor = ( - "Only lines marked '+' (added) can carry a finding; use their line number." - if chunk.kind == "diff" else - "Use the line number shown before '|'. Use line null for a finding about something missing from the whole document." - ) - return ( - f"\n" - f"{chunk.rendered}\n\n\n" - + (f"\nFacts found by code (effects registry and code graph) about what the " - f"code in the chunk calls:\n" + "\n".join(f"- {x}" for x in facts) + "\n\n\n" - if facts else "") - + f"\n{render_statements(corpus, ids)}\n\n\n" - f"{anchor}\nReturn only violations of the standards listed above. " - "Return an empty findings list when the chunk complies." - ) - - -def write_bundle(corpus: Corpus, artifact: Artifact, selection: dict, out: Path) -> Path: - """Human/agent-readable review bundle for interactive use inside Claude Code.""" - chunks = {c.id: c for c in artifact.chunks} - parts = [ - f"# shall review bundle — {artifact.source}", - "", - "For each chunk, check only the listed standards. Write all findings to one JSON file", - "with the shape `{\"findings\": [{\"chunk\": \"c0\", \"statement_id\": ..., \"line\": ..., " - "\"quote\": ..., \"message\": ..., \"suggestion\": ...}]}`, then run", - f"`shall validate --selection {artifact.source}`.", - "", - ] - for c in selection["chunks"]: - if not c["selected"]: - continue - parts += [f"## Chunk {c['id']} — {c['path']}", "", chunk_prompt(corpus, chunks[c["id"]], c["selected"], c.get("known_effects")), ""] - out.write_text("\n".join(parts), encoding="utf-8") - return out - - -class AnswerCache: - """Agent answers keyed by exactly what the agent was given (model, system prompt, schema, - prompt). A re-run on a new push reuses the answer for every chunk whose prompt did not - change (same diff, same selected statements, same known effects), and for every - verification whose evidence did not change. Only successful answers are stored; entries - unused for `MAX_AGE_DAYS` are dropped on save. Merge-safe like the Jev cache.""" - - MAX_AGE_DAYS = 30 - - def __init__(self, path: Path | None): - self.path = path - self.entries: dict = {} - self.reused = 0 - if path is not None and path.is_file(): - try: - self.entries = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - self.entries = {} - - @staticmethod - def key(model: str, system: str, schema: dict, prompt: str) -> str: - payload = json.dumps({"m": model, "s": system, "j": schema, "p": prompt}, sort_keys=True) - return hashlib.sha256(payload.encode()).hexdigest() - - def get(self, key: str) -> dict | None: - entry = self.entries.get(key) - if entry is None: - return None - entry["used"] = time.time() - self.reused += 1 - return entry["output"] - - def put(self, key: str, output: dict) -> None: - self.entries[key] = {"output": output, "used": time.time()} - - def save(self) -> None: - if self.path is None: - return - make_dir(self.path.parent) - merged: dict = {} - if self.path.is_file(): - try: - merged = json.loads(self.path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - merged = {} - merged.update(self.entries) - cutoff = time.time() - self.MAX_AGE_DAYS * 86400 - merged = {k: v for k, v in merged.items() if v.get("used", 0) >= cutoff} - tmp = self.path.with_suffix(f".{os.getpid()}.tmp") - tmp.write_text(json.dumps(merged, sort_keys=True), encoding="utf-8") - os.replace(tmp, self.path) - - -def answer_cache(cfg: Config, enabled: bool = True) -> AnswerCache: - """The review answer cache next to the Jev cache (`review.cache: false` disables it).""" - if not enabled or not cfg.get("review.cache", True): - return AnswerCache(None) - return AnswerCache(cfg.cache_file.parent / "review.json") - - -async def run_claude(cfg: Config, prompt: str, system: str, schema: dict, - sem: asyncio.Semaphore, model: str | None = None, - cache: AnswerCache | None = None) -> tuple[dict | None, dict]: - """One tool-less Claude call with a JSON schema (JTOOL-0002.1). Returns (output, meta). - With `cache`, an identical earlier call is reused (meta has `cached: True`, no cost).""" - model = model or cfg.get("review.model") - key = AnswerCache.key(model, system, schema, prompt) if cache is not None else None - if key is not None: - hit = cache.get(key) - if hit is not None: - return hit, {"cost_usd": 0.0, "cached": True} - cmd = list(cfg.get("review.command")) + [ - "--model", model, - "--output-format", "json", - "--json-schema", json.dumps(schema), - "--system-prompt", system, - "--tools", "", - "--no-session-persistence", - "--strict-mcp-config", - "--setting-sources", "", - ] - for attempt in range(2): - async with sem: - # the prompt is a file on stdin: it is there when the process starts. Written through - # the event loop, it could come after `claude -p` stopped to wait for it (3 s), when - # blocking work (git grep for evidence) held the loop. - with tempfile.TemporaryFile() as fh: - fh.write(prompt.encode()) - fh.seek(0) - proc = await asyncio.create_subprocess_exec( - *cmd, stdin=fh, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - # a pipeline agent never triggers shall hooks: its Stop would start another review - env={**os.environ, "SHALL_HOOKS_DISABLED": "1"}, - ) - try: - out, err = await asyncio.wait_for(proc.communicate(), timeout=float(cfg.get("review.timeout"))) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - return None, {"error": f"agent timed out after {cfg.get('review.timeout')}s"} - if proc.returncode == 0: - break - error = err.decode()[-2000:] or out.decode()[-2000:] - if attempt or not any(m in error for m in RETRY_ERRORS): - return None, {"error": error} - data = json.loads(out.decode()) - result = data.get("structured_output") - if result is None: # fall back to parsing the text result - m = re.search(r"\{.*\}", data.get("result", ""), re.S) - result = json.loads(m.group(0)) if m else None - if key is not None and result is not None: - cache.put(key, result) - return result, {"cost_usd": data.get("total_cost_usd", 0.0), "usage": data.get("usage") or {}, - "models": sorted(data.get("modelUsage") or {})} - - async def _run_agent(cfg: Config, prompt: str, sem: asyncio.Semaphore, - cache: AnswerCache | None = None) -> tuple[list[dict], dict]: + cache: AnswerCache | None = None) -> tuple[list[RawFinding], dict]: result, meta = await run_claude(cfg, prompt, reviewer_prompt(), FINDINGS_SCHEMA, sem, cache=cache) return (result or {}).get("findings", []), meta async def run_agents(cfg: Config, corpus: Corpus, artifact: Artifact, selection: dict, - prompts_dir: Path | None = None, cache: AnswerCache | None = None) -> tuple[list[dict], dict]: + prompts_dir: Path | None = None, cache: AnswerCache | None = None) -> tuple[list[RawFinding], dict]: chunks = {c.id: c for c in artifact.chunks} sem = asyncio.Semaphore(int(cfg.get("review.concurrency"))) jobs = [] @@ -251,179 +41,16 @@ async def run_agents(cfg: Config, corpus: Corpus, artifact: Artifact, selection: (prompts_dir / f"{c['id']}.md").write_text(prompt, encoding="utf-8") jobs.append((c["id"], _run_agent(cfg, prompt, sem, cache))) results = await asyncio.gather(*(job for _, job in jobs)) - raw, stats = [], {"agent_calls": 0, "reused": 0, "cost_usd": 0.0, "errors": []} + raw: list[RawFinding] = [] + errors: list[dict] = [] + stats: dict = {"agent_calls": 0, "reused": 0, "cost_usd": 0.0, "errors": errors} for (cid, _), (findings, meta) in zip(jobs, results): stats["reused" if meta.get("cached") else "agent_calls"] += 1 for f in findings: - raw.append({"chunk": cid, **f}) + item = f.copy() + item["chunk"] = cid # code knows the chunk; an agent-supplied one is overwritten + raw.append(item) if "error" in meta: - stats["errors"].append({"chunk": cid, "error": meta["error"]}) + errors.append({"chunk": cid, "error": meta["error"]}) stats["cost_usd"] = round(stats["cost_usd"] + meta.get("cost_usd", 0.0), 6) return raw, stats - - -def _norm(text: str) -> str: - return " ".join((text or "").split()) - - -def _anchor(chunk: Chunk, line, quote: str) -> tuple[int | None, str | None]: - """Resolve the line a finding points at. The quote wins over the line number.""" - def text(n: int) -> str: - return _norm(chunk.line_text.get(n, "")) - if line in chunk.anchor_lines and (not quote or quote in text(line)): - return line, None - hits = [n for n in sorted(chunk.anchor_lines) if quote and quote in text(n)] - if hits: - return min(hits, key=lambda n: abs(n - (line or n))), None - if line in chunk.anchor_lines: - return line, None # quote paraphrased, but the line is a real changed line - if line is None and chunk.kind != "diff": - return None, None # document-level finding ("section X is missing") - return None, f"line {line} / quote not found among commentable lines of {chunk.path}" - - -def validate_findings(corpus: Corpus, artifact: Artifact, selection: dict, raw: list[dict], - max_comments: int) -> tuple[list[dict], list[dict]]: - chunks = {c.id: c for c in artifact.chunks} - selected = {c["id"]: set(c["selected"]) for c in selection["chunks"]} - kept, dropped, seen = [], [], set() - for f in raw: - reason = None - cid = f.get("chunk") - chunk = chunks.get(cid) - sid = f.get("statement_id", "") - line = f.get("line") - quote = _norm(f.get("quote", "")) - if chunk is None: - reason = f"unknown chunk {cid!r}" - elif sid not in selected.get(cid, set()): - reason = f"{sid} was not selected for chunk {cid}" - elif corpus.statement(sid) is None: - reason = f"{sid} does not exist in the corpus" - else: - line, reason = _anchor(chunk, line, quote) - if reason: - dropped.append({**f, "reason": reason}) - continue - rfc, st = corpus.statement(sid) - key = (sid, chunk.path, line) - if key in seen: - dropped.append({**f, "reason": "duplicate"}) - continue - seen.add(key) - kept.append({ - "statement_id": sid, "rfc": rfc.id, "title": st.title, "level": st.level, - "blocking": rfc.statement_blocking(st), # recomputed, never trusted from the model - "path": chunk.path, "line": line, "quote": f.get("quote", ""), - "message": f.get("message", ""), "suggestion": f.get("suggestion", ""), - "depends_on": [str(d) for d in f.get("depends_on") or []][:8], - "chunk": cid, "statement_text": st.text, - "source": corpus.source(rfc, st.line), - }) - kept.sort(key=lambda k: (not k["blocking"], k["path"], k["line"] or 0, k["statement_id"])) - if len(kept) > max_comments: - for k in kept[max_comments:]: - dropped.append({**k, "reason": f"over max_comments={max_comments}"}) - kept = kept[:max_comments] - return kept, dropped - - -def render_markdown(artifact: Artifact, selection: dict, findings: list[dict], dropped: list[dict], - health: dict | None = None) -> str: - blocking = sum(1 for f in findings if f["blocking"]) - out = [ - f"## shall review — `{artifact.source}`", - "", - f"**{len(findings)} finding(s)**, {blocking} blocking · " - f"{len(selection['applicable'])} applicable statement(s) selected by `{selection['model']}` " - f"({selection['strategy']}) · {len(dropped)} finding(s) dropped by validation or verification", - "", - ] - if health is not None: - from .health import render_health - out += render_health(health) - if selection["applicable"]: - out += ["
Applicable standards", ""] - for a in selection["applicable"]: - out.append(f"- `{a['id']}` {a['title']} ({a['level']}, p={a['p_max']:.2f})") - out += ["", "
", ""] - for f in findings: - where = f"`{f['path']}:{f['line']}`" if f["line"] else f"`{f['path']}` (whole document)" - tag = "🚫 blocking" if f["blocking"] else "💬 advisory" - out.append(f"- {tag} **{f['statement_id']}** {f['title']} — {where}") - out.append(f" {f['message']}") - if f.get("suggestion"): - out.append(f" _Suggestion:_ {f['suggestion']}") - if f.get("verification"): - out.append(f" {verification_line(f)}") - return "\n".join(out).rstrip() + "\n" - - -def line_hash(text: str) -> str: - """Hash of a line's content: stable when the line moves, new when the line changes.""" - return hashlib.sha1(_norm(text).encode()).hexdigest()[:12] - - -def finding_key(statement_id: str, path: str, lhash: str) -> str: - return hashlib.sha1(f"{statement_id}|{path}|{lhash}".encode()).hexdigest()[:16] - - -MARKER_RE = re.compile(r"") -SUMMARY_MARKER = "" - - -def verification_line(f: dict) -> str: - v = f["verification"] - where = f" ({', '.join(v['evidence'])})" if v.get("evidence") else "" - if v["verdict"] == "confirmed": - return f"_Verified{where}:_ {v['reason']}" - note = "not blocking: the evidence it depends on was not seen" if f.get("downgraded") else "could not be verified" - return f"_Unverified — {note}:_ {v['reason']}" - - -def comment_body(f: dict, key: str | None = None, lhash: str | None = None) -> str: - tag = "🚫 **blocking**" if f["blocking"] else "💬 advisory" - body = f"{tag} · **{f['statement_id']}** {f['title']} ({f['level']})\n\n{f['message']}" - if f.get("suggestion"): - body += f"\n\n**Suggestion:** {f['suggestion']}" - if f.get("verification"): - body += f"\n\n{verification_line(f)}" - body += f"\n\n> {f['statement_text']}\n\nStandard: `{f['source']}`" - if key: - body += f"\n\n" - return body - - -def render_github(artifact: Artifact, selection: dict, findings: list[dict], dropped: list[dict], - health: dict | None = None) -> dict: - """Review payload plus the state `shall publish` needs to dedupe across pushes. - - Each inline comment carries a hidden key = hash(statement, path, line content). The - line content (not the line number) is used so a finding keeps its key when code above - it moves, and gets a new key when the line itself is changed. - """ - text_by_path: dict[str, dict[int, str]] = {} - added: dict[str, set[str]] = {} - for c in artifact.chunks: - text_by_path.setdefault(c.path, {}).update(c.line_text) - if artifact.kind == "diff": - added.setdefault(c.path, set()).update(line_hash(c.line_text.get(n, "")) for n in c.anchor_lines) - inline = [f for f in findings if f["line"] and artifact.kind == "diff"] - general = [f for f in findings if f not in inline] - comments = [] - for f in inline: - lh = line_hash(text_by_path.get(f["path"], {}).get(f["line"], "")) - key = finding_key(f["statement_id"], f["path"], lh) - comments.append({"path": f["path"], "line": f["line"], "side": "RIGHT", - "body": comment_body(f, key, lh), - "shall_key": key, "shall_line": lh, "shall_blocking": f["blocking"]}) - return { - "event": "REQUEST_CHANGES" if any(f["blocking"] for f in findings) else "COMMENT", - "body": render_markdown(artifact, selection, general, dropped), - "comments": comments, - "shall": { - "summary": render_markdown(artifact, selection, findings, dropped, health), - "blocking": sum(1 for f in findings if f["blocking"]), - "added_line_hashes": {p: sorted(h) for p, h in sorted(added.items())}, - }, - } diff --git a/plugins/shall/tooling/src/shall/scan.py b/plugins/shall/tooling/src/shall/scan.py index 016a5f3..5adfe7a 100644 --- a/plugins/shall/tooling/src/shall/scan.py +++ b/plugins/shall/tooling/src/shall/scan.py @@ -17,26 +17,20 @@ import asyncio import fnmatch import os -import subprocess from pathlib import Path +from . import git from .artifact import DOC_EXTS, language_of, parse_diff from .config import Config from .corpus import Corpus from .hooks import Hooks, redact, scan_secrets, synthetic_diff -from .jev import Jev +from .jev import JevLike JEV_PRICE_IN = 0.042 # $ per 1M input tokens (evaluate.py uses the same rate) -def _git(root: Path, *args: str) -> str | None: - proc = subprocess.run(["git", "-C", str(root), *args], capture_output=True) - return proc.stdout.decode("utf-8", "surrogateescape") if proc.returncode == 0 else None - - def repo_root(start: Path) -> Path: - top = (_git(start, "rev-parse", "--show-toplevel") or "").strip() - return Path(top) if top else start + return git.toplevel(start) or start def excluded(rel: str, patterns: list[str]) -> bool: @@ -52,7 +46,9 @@ def list_files(root: Path, paths: list[str], exclude: list[str]) -> list[str]: if not full.is_relative_to(root): raise SystemExit(f"shall scan: {p} is outside the repository {root}") targets.append(full.relative_to(root).as_posix() or ".") - listed = _git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *targets) + # long timeout: the fallback walk ignores .gitignore, so it would scan build output + listed = git.git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *targets, + timeout=600) if listed is not None: names = [n for n in listed.split("\0") if n] else: # not a git work tree @@ -116,7 +112,7 @@ async def scan_file(hooks: Hooks, rel: str, text: str, threshold: float, secret_ texts={rel: text}) def blocking(f: dict) -> bool: # a code check is exact, as the secret scanner: the statement's severity - rfc, st = hooks.corpus.statement(f["id"]) + rfc, st = hooks.corpus.require(f["id"]) return f["by"] == "check" and rfc.statement_blocking(st) findings += [{**f, "lines": f["lines"] if f["by"] == "check" else None, "blocking": blocking(f)} @@ -129,7 +125,7 @@ def blocking(f: dict) -> bool: # a code check is exact, as the secret scanner: "findings": sorted(findings, key=lambda f: (-f["p"], f["id"]))} -async def run_scan(cfg: Config, corpus: Corpus, jev: Jev, paths: list[str], root: Path | None = None, +async def run_scan(cfg: Config, corpus: Corpus, jev: JevLike, paths: list[str], root: Path | None = None, threshold: float | None = None, statuses: list[str] | None = None, exclude: list[str] | None = None) -> dict: root = root or repo_root(Path.cwd()) diff --git a/plugins/shall/tooling/src/shall/select.py b/plugins/shall/tooling/src/shall/select.py index 90ac1d0..3af6d82 100644 --- a/plugins/shall/tooling/src/shall/select.py +++ b/plugins/shall/tooling/src/shall/select.py @@ -13,13 +13,12 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass, field from .artifact import Artifact, Chunk from .config import Config from .corpus import Corpus, Rfc, Statement -from .jev import Jev, choice, noul +from .jev import JevLike, choice, noul APPLIES_NOTE = ( "Answer yes when the content contains the kind of code or text this is about, " @@ -78,7 +77,7 @@ def statement_question(rfc: Rfc, st: Statement, kind: str = "diff"): ) -async def classify_document(jev: Jev, name: str, raw: str) -> tuple[str, dict]: +async def classify_document(jev: JevLike, name: str, raw: str) -> tuple[str, dict]: head = raw[:12000] ans = await jev.ask({"file": name, "content": head}, {"kind": DOC_KIND_QUESTION}) return ans["kind"]["choice"], ans["kind"] @@ -96,7 +95,7 @@ class ChunkSelection: class Selector: - def __init__(self, cfg: Config, corpus: Corpus, jev: Jev, strategy: str | None = None, + def __init__(self, cfg: Config, corpus: Corpus, jev: JevLike, strategy: str | None = None, include_status: list[str] | None = None, facts: dict[str, list[str]] | None = None): self.cfg = cfg self.corpus = corpus @@ -155,7 +154,7 @@ async def select(self, artifact: Artifact) -> list[ChunkSelection]: def selection_to_json(artifact: Artifact, results: list[ChunkSelection], corpus: Corpus, - strategy: str, cfg: Config, jev: Jev, extra: dict | None = None) -> dict: + strategy: str, cfg: Config, jev: JevLike, extra: dict | None = None) -> dict: applicable: dict[str, dict] = {} for r in results: for sid in r.selected: @@ -163,7 +162,7 @@ def selection_to_json(artifact: Artifact, results: list[ChunkSelection], corpus: entry["p_max"] = max(entry["p_max"], r.statements[sid]) entry["chunks"].append(r.chunk.id) for sid, entry in applicable.items(): - rfc, st = corpus.statement(sid) + rfc, st = corpus.require(sid) entry.update({"rfc": rfc.id, "title": st.title, "level": st.level, "blocking": rfc.statement_blocking(st), "status": rfc.status}) return { diff --git a/plugins/shall/tooling/src/shall/triage.py b/plugins/shall/tooling/src/shall/triage.py index 31a693a..f326989 100644 --- a/plugins/shall/tooling/src/shall/triage.py +++ b/plugins/shall/tooling/src/shall/triage.py @@ -24,17 +24,20 @@ import json import re from pathlib import Path +from typing import Literal, Sequence, get_args from .artifact import Artifact, Chunk from .config import Config from .corpus import Corpus -from .jev import Jev, noul +from .jev import JevLike, noul from .publish import is_bot, is_shall, login_matches -from .review import agent_prompt, run_claude +from .claude import agent_prompt, run_claude +from .findings import Finding, Verification from .select import Selector -from .verify import VERDICT_SCHEMA, gather_evidence, render_evidence, verification_scope +from .verify import VERDICT_SCHEMA, Claim, Excerpt, gather_evidence, render_evidence, verification_of, verification_scope -STATUSES = ("relevant", "unverified", "noise", "outdated") +TriageStatus = Literal["relevant", "unverified", "noise", "outdated"] +TRIAGE_STATUSES: tuple[TriageStatus, ...] = get_args(TriageStatus) MAX_JEV_TEXT = 4000 MAX_AGENT_TEXT = 8000 DUPLICATE_WINDOW = 5 # lines: two comments further apart are never compared @@ -188,7 +191,7 @@ def bot_comments(cfg: Config, threads: list[dict]) -> tuple[list[dict], dict]: return kept, skipped -def anchor(artifact: Artifact, c: dict, file_lines: list[str] | None = None) -> tuple[Chunk | None, str | None]: +def anchor(artifact: Artifact, c: dict, file_lines: list[str] | None = None) -> tuple[Chunk, None] | tuple[None, str]: """The chunk that shows the commented line, or why there is none (the comment is outdated). A PR-level item may point outside the diff hunks of its file: with the file's lines it keeps the file's first chunk (for the selection) and takes its code from the file.""" @@ -226,7 +229,7 @@ def read_lines(root: Path | None, path: str | None) -> list[str] | None: # ---------------------------------------------------------------- triage -def _item(c: dict, status: str, reason: str, **extra) -> dict: +def _item(c: dict, status: TriageStatus, reason: str, **extra) -> dict: return {"id": c["id"], "thread": c.get("thread"), "comment_id": c.get("comment_id"), "url": c.get("url"), "author": c["author"], "path": c.get("path"), "line": c.get("line"), "summary": summary_of(c["text"]), "status": status, "reason": reason, @@ -241,34 +244,32 @@ def comment_prompt(c: dict, context: str, standard: dict | None, evidence: str) f"\n{context}\n\n\n\n{evidence}\n") -def apply_comment_verdict(verdict: dict | None, excerpts) -> tuple[str, dict]: +def status_of(v: Verification) -> TriageStatus: + # exhaustive: a new Verdict without a case fails `make typecheck` (reportMatchNotExhaustive) + match v.verdict: + case "refuted": + return "noise" + case "confirmed": + return "relevant" + case "unknown": + return "unverified" + + +def apply_comment_verdict(verdict: dict | None, excerpts: Sequence[Excerpt]) -> tuple[TriageStatus, dict]: """(status, verification record). Noise needs a refutation that cites evidence that exists; relevant needs a confirmation that cites it; everything else stays as unverified.""" - known = {e.id: e for e in excerpts} - if verdict is None: - verdict = {"verdict": "unknown", "reason": "verification did not return a verdict", "evidence": []} - cited = [i for i in verdict.get("evidence", []) if i == "chunk" or i in known] - record = { - "verdict": verdict.get("verdict", "unknown"), - "reason": verdict.get("reason", ""), - "evidence": [i if i == "chunk" else f"{known[i].path}:{known[i].start}-{known[i].end}" for i in cited], - "shown": [f"{e.path}:{e.start}-{e.end}" for e in excerpts], - } - if record["verdict"] in ("refuted", "confirmed") and not cited: - record["reason"] = f"{record['verdict']} without citing evidence: " + record["reason"] - record["verdict"] = "unknown" - return {"refuted": "noise", "confirmed": "relevant"}.get(record["verdict"], "unverified"), record - - -async def triage_comments(cfg: Config, corpus: Corpus, artifact: Artifact, jev: Jev, comments: list[dict], - findings: list[dict] | None = None, agent=None, index=None, + v = verification_of(verdict, excerpts) + return status_of(v), v.to_json() + + +async def triage_comments(cfg: Config, corpus: Corpus, artifact: Artifact, jev: JevLike, comments: list[dict], + findings: list[Finding] | None = None, agent=None, index=None, cache=None, search_root: Path | None = None, facts: dict[str, list[str]] | None = None) -> tuple[list[dict], dict]: th = {"actionable": 0.5, "statement": 0.5, "duplicate": 0.7, **(cfg.get("triage.thresholds") or {})} verify = bool(cfg.get("triage.verify", True)) - model = cfg.get("review.verify_model") or None agent = agent or (lambda prompt, sem: run_claude(cfg, prompt, agent_prompt("shall-comment-verifier"), - VERDICT_SCHEMA, sem, model=model, cache=cache)) + VERDICT_SCHEMA, sem, model=cfg.verify_model, cache=cache)) # the same selection as `shall review` (same Jev questions, so cached answers are shared): # a comment is matched only against the statements that apply to its chunk selector = Selector(cfg, corpus, jev, facts=facts) @@ -284,12 +285,12 @@ async def triage_comments(cfg: Config, corpus: Corpus, artifact: Artifact, jev: for c in comments: c["text"] = clean_body(c.get("body", "")) lines = read_lines(file_root, c.get("path")) if c.get("pr_level") else None - chunk, reason = anchor(artifact, c, lines) - if reason: - items[c["id"]] = _item(c, "outdated", reason) + found = anchor(artifact, c, lines) + if found[0] is None: + items[c["id"]] = _item(c, "outdated", found[1]) else: - c["context"] = code_context(chunk, c.get("line"), file_lines=lines) - live.append((c, chunk)) + c["context"] = code_context(found[0], c.get("line"), file_lines=lines) + live.append((c, found[0])) # 2. Jev: which statements apply to the chunk (as in review)? is the comment actionable? # which of the applicable statements covers its concern? @@ -297,7 +298,7 @@ async def triage_comments(cfg: Config, corpus: Corpus, artifact: Artifact, jev: selections = dict(zip(chunks, await jev.gather(selector.select_chunk(ch) for ch in chunks.values()))) async def ask(c: dict, chunk: Chunk): - cands = [corpus.statement(sid)[1] for sid in selections[chunk.id].selected] + cands = [corpus.require(sid)[1] for sid in selections[chunk.id].selected] state = {"review_comment": c["text"][:MAX_JEV_TEXT], "file": c["path"], "language": chunk.language, "code": c["context"]} return await jev.ask(state, {"actionable": ACTIONABLE, **{st.id: covers_question(st) for st in cands}}) @@ -309,7 +310,7 @@ async def ask(c: dict, chunk: Chunk): standards = [] for sid, a in sorted(ans.items(), key=lambda kv: -kv[1].get("p", 0)): if sid != "actionable" and a["p"] >= th["statement"] and len(standards) < MAX_STANDARDS: - rfc, st = corpus.statement(sid) + rfc, st = corpus.require(sid) standards.append({"id": sid, "level": st.level, "title": st.title, "text": st.text, "p": round(a["p"], 2)}) c["p_actionable"], c["standards"] = round(p_act, 2), standards @@ -320,9 +321,9 @@ async def ask(c: dict, chunk: Chunk): actionable.append((c, chunk)) # 3. Jev: the same concern as a shall finding or an earlier comment? (nearby lines only) - earlier: list[dict] = [{"id": f"shall:{f['statement_id']}:{f['path']}:{f.get('line')}", "path": f["path"], - "line": f.get("line"), "text": f"{f['title']}: {f['message']}", - "label": f"shall finding {f['statement_id']}"} for f in findings or []] + earlier: list[dict] = [{"id": f"shall:{f.statement_id}:{f.path}:{f.line}", "path": f.path, + "line": f.line, "text": f"{f.title}: {f.message}", + "label": f"shall finding {f.statement_id}"} for f in findings or []] pairs = [] for c, _ in actionable: for e in earlier: @@ -362,11 +363,12 @@ async def ask(c: dict, chunk: Chunk): async def check(c: dict, chunk: Chunk): top = c["standards"][0] if c["standards"] else None - claim = {"path": c["path"], "line": c.get("line"), "chunk": chunk.id, - "quote": chunk.line_text.get(c.get("line"), "") if c.get("line") else "", - "message": c["text"][:MAX_JEV_TEXT], - "statement_text": top["text"] if top else c["text"][:MAX_JEV_TEXT], - "depends_on": [s for s in CODE_SPAN_RE.findall(c["text"]) if len(s) <= 80][:8]} + line = c.get("line") + claim = Claim(path=c["path"], line=line, chunk=chunk.id, + quote=chunk.line_text.get(line, "") if line else "", + message=c["text"][:MAX_JEV_TEXT], + statement_text=top["text"] if top else c["text"][:MAX_JEV_TEXT], + depends_on=tuple(s for s in CODE_SPAN_RE.findall(c["text"]) if len(s) <= 80)[:8]) excerpts, notes = await gather_evidence(artifact, jev, claim, root, excluded, index) verdict, meta = await agent(comment_prompt(c, c["context"], top, render_evidence(excerpts, notes)), sem) return excerpts, verdict, meta @@ -384,10 +386,10 @@ async def check(c: dict, chunk: Chunk): verification=record) stats["codegraph_events"] = list(index.events) if index is not None else [] - order = {s: i for i, s in enumerate(STATUSES)} + order = {s: i for i, s in enumerate(TRIAGE_STATUSES)} out = sorted(items.values(), key=lambda i: (order[i["status"]], not i.get("standards"), i["path"] or "", i["line"] or 0, i["id"])) - stats.update({s: sum(1 for i in out if i["status"] == s) for s in STATUSES}) + stats.update({s: sum(1 for i in out if i["status"] == s) for s in TRIAGE_STATUSES}) return out, stats diff --git a/plugins/shall/tooling/src/shall/verify.py b/plugins/shall/tooling/src/shall/verify.py index 7768b48..ffcfee8 100644 --- a/plugins/shall/tooling/src/shall/verify.py +++ b/plugins/shall/tooling/src/shall/verify.py @@ -20,21 +20,23 @@ import asyncio import re -import subprocess +from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path +from . import git from .artifact import Artifact from .codegraph import RepoIndex, expand, names_in, numbered_lines from .config import Config from .corpus import Corpus -from .jev import Jev, noul -from .review import agent_prompt, run_claude +from .claude import agent_prompt, run_claude +from .findings import VERDICTS, Finding, Verification, sort_key +from .jev import JevLike, noul VERDICT_SCHEMA = { "type": "object", "properties": { - "verdict": {"type": "string", "enum": ["confirmed", "refuted", "unknown"]}, + "verdict": {"type": "string", "enum": list(VERDICTS)}, "reason": {"type": "string"}, "evidence": {"type": "array", "items": {"type": "string"}}, }, @@ -55,7 +57,6 @@ MAX_EXCERPTS = 20 KEEP_EXCERPTS = 6 SELECT_THRESHOLD = 0.4 -GREP_TIMEOUT = 30 SKIP_DIRS = {".git", ".venv", "node_modules", ".shall-out", ".shall-cache", "__pycache__", "dist", "build"} @@ -70,8 +71,24 @@ class Excerpt: via: list[str] = field(default_factory=list) # graph path from the flagged line, e.g. [gateway.hold, request] -def needs_verification(finding: dict) -> bool: - return bool(finding.get("blocking") or finding.get("depends_on")) +@dataclass(frozen=True) +class Claim: + path: str + line: int | None + quote: str + chunk: str + message: str + statement_text: str + depends_on: tuple[str, ...] = () + + +# a claim about one line that evidence can confirm or refute: a Finding, or a Claim made from the +# comment of another reviewer (triage.py) +ClaimLike = Finding | Claim + + +def needs_verification(finding: Finding) -> bool: + return finding.blocking or bool(finding.depends_on) CODE_SPAN_RE = re.compile(r"`([^`]+)`") @@ -97,7 +114,7 @@ def yaml_key_path(line_text: dict[int, str], line: int | None) -> str | None: return ".".join(keys) if len(keys) > 1 else None -def search_terms(finding: dict, key_path: str | None = None) -> list[str]: +def search_terms(finding: ClaimLike, key_path: str | None = None) -> list[str]: """Most specific first: the config key path, the reviewer's depends_on, literal code spans of the statement (e.g. `--tools ""` -> --tools), then identifiers of the quote.""" out: list[str] = [] @@ -108,14 +125,14 @@ def add(term: str) -> None: if key_path: add(key_path) - for source in finding.get("depends_on", []): + for source in finding.depends_on: for token in IDENT_RE.findall(source): add(token) for part in token.split("."): # the receiver matters too: logger.info -> logger add(part) - for span in CODE_SPAN_RE.findall(finding.get("statement_text", "")): + for span in CODE_SPAN_RE.findall(finding.statement_text): add(span.split()[0].strip("\"'")) - for token in IDENT_RE.findall(finding.get("quote", "")): + for token in IDENT_RE.findall(finding.quote): add(token) for part in token.split("."): add(part) @@ -127,27 +144,16 @@ def _priority(path: str) -> int: return 1 if Path(path).suffix.lower() in DOC_EXTS else 0 -def _git_root(start: Path) -> Path | None: - try: - proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=start, capture_output=True, - text=True, timeout=GREP_TIMEOUT) - except (OSError, subprocess.TimeoutExpired): - return None - return Path(proc.stdout.strip()) if proc.returncode == 0 else None - - def _hits(root: Path, term: str, excluded: set[str]) -> list[tuple[str, int]]: """(relative path, line) for a fixed-string match; git grep in a repo, a walk otherwise.""" hits: list[tuple[str, int]] = [] if (root / ".git").exists(): - try: - proc = subprocess.run(["git", "grep", "-n", "-I", "-F", "--untracked", "-e", term], - cwd=root, capture_output=True, text=True, timeout=GREP_TIMEOUT) - for line in proc.stdout.splitlines(): + for line in (git.git(root, "grep", "-n", "-I", "-F", "--untracked", "-e", term, ok=(0, 1)) or "").splitlines(): + try: path, lineno, _ = line.split(":", 2) hits.append((path, int(lineno))) - except (OSError, subprocess.TimeoutExpired, ValueError): - pass + except ValueError: + continue else: for path in sorted(root.rglob("*")): if not path.is_file() or any(p in SKIP_DIRS for p in path.relative_to(root).parts): @@ -204,11 +210,11 @@ def relevance_question(): CHUNK_RADIUS = 20 # lines around the flagged line that the verifier sees as the chunk -def chunk_context(artifact: Artifact, finding: dict, radius: int = CHUNK_RADIUS) -> str: - chunk = next((c for c in artifact.chunks if c.id == finding.get("chunk")), None) +def chunk_context(artifact: Artifact, finding: ClaimLike, radius: int = CHUNK_RADIUS) -> str: + chunk = next((c for c in artifact.chunks if c.id == finding.chunk), None) if chunk is None: return "" - line = finding.get("line") + line = finding.line if line is None: return chunk.rendered[:8000] numbers = sorted(n for n in chunk.line_text if abs(n - line) <= radius) @@ -227,43 +233,48 @@ def render_evidence(excerpts: list[Excerpt], notes: list[str] | None = None) -> return evidence -def verifier_prompt(finding: dict, context: str, excerpts: list[Excerpt], notes: list[str] | None = None) -> str: +def verifier_prompt(finding: Finding, context: str, excerpts: list[Excerpt], notes: list[str] | None = None) -> str: evidence = render_evidence(excerpts, notes) return ( - f"\nstatement: {finding['statement_id']} [{finding['level']}] {finding['statement_text']}\n" - f"file: {finding['path']}:{finding['line']}\nquote: {finding['quote']}\n" - f"reviewer: {finding['message']}\ndepends_on: {', '.join(finding.get('depends_on') or []) or '-'}\n" - f"\n\n\n{context}\n\n\n\n{evidence}\n" + f"\nstatement: {finding.statement_id} [{finding.level}] {finding.statement_text}\n" + f"file: {finding.path}:{finding.line}\nquote: {finding.quote}\n" + f"reviewer: {finding.message}\ndepends_on: {', '.join(finding.depends_on) or '-'}\n" + f"\n\n\n{context}\n\n\n\n{evidence}\n" ) -def apply_verdict(finding: dict, verdict: dict | None, excerpts: list[Excerpt]) -> tuple[dict | None, dict | None]: - """Return (kept finding or None, dropped entry or None). Severity only goes down.""" +def verification_of(verdict: dict | None, excerpts: Sequence[Excerpt]) -> Verification: + """The verifier's answer as code accepts it. A refutation or a confirmation that cites no + evidence that exists becomes `unknown`: a drop and a block both need it (JTOOL-0002.6). + Used for findings (apply_verdict) and for the comments of other reviewers (triage.py).""" known = {e.id: e for e in excerpts} if verdict is None: verdict = {"verdict": "unknown", "reason": "verification did not return a verdict", "evidence": []} - cited = [i for i in verdict.get("evidence", []) if i == "chunk" or i in known] - record = { - "verdict": verdict.get("verdict", "unknown"), - "reason": verdict.get("reason", ""), - "evidence": [i if i == "chunk" else f"{known[i].path}:{known[i].start}-{known[i].end}" for i in cited], - "shown": [f"{e.path}:{e.start}-{e.end}" for e in excerpts], # audit: what the verifier saw - } - if record["verdict"] == "refuted" and cited: - return None, {**finding, "verification": record, "reason": f"refuted by verification: {record['reason']}"} - if record["verdict"] in ("refuted", "confirmed") and not cited: - # a drop and a block both need evidence that exists (JTOOL-0002.6) - record["reason"] = f"{record['verdict']} without citing evidence: " + record["reason"] - record["verdict"] = "unknown" - kept = {**finding, "verification": record} - if record["verdict"] != "confirmed" and kept["blocking"]: - kept["blocking"] = False - kept["downgraded"] = True # evidence not seen: a comment, never a failed check - return kept, None + cited = [i for i in verdict.get("evidence") or [] if i == "chunk" or i in known] + answer = verdict.get("verdict") + reason = str(verdict.get("reason", "")) + if answer in ("refuted", "confirmed") and not cited: + reason, answer = f"{answer} without citing evidence: {reason}", "unknown" + return Verification( + verdict=answer if answer in VERDICTS else "unknown", + reason=reason, + evidence=tuple(i if i == "chunk" else f"{known[i].path}:{known[i].start}-{known[i].end}" for i in cited), + shown=tuple(f"{e.path}:{e.start}-{e.end}" for e in excerpts), # audit: what the verifier saw + ) + + +def apply_verdict(finding: Finding, verdict: dict | None, + excerpts: Sequence[Excerpt]) -> tuple[Finding | None, dict | None]: + """Return (kept finding or None, dropped entry or None). Severity only goes down: a kept + finding blocks only when the corpus says so and the verdict is `confirmed` (Finding.blocking).""" + v = verification_of(verdict, excerpts) + if v.verdict == "refuted": + return None, {**finding.to_json(), "verification": v.to_json(), "reason": f"refuted by verification: {v.reason}"} + return finding.with_verification(v), None def repo_root(search_root: Path | None = None) -> Path: - return search_root or _git_root(Path.cwd()) or Path.cwd() + return search_root or git.toplevel(Path.cwd()) or Path.cwd() def excluded_paths(corpus: Corpus, root: Path) -> set[str]: @@ -284,39 +295,39 @@ def repo_index(cfg: Config, corpus: Corpus, root: Path) -> RepoIndex: download_grammars=bool(cfg.get("codegraph.download_grammars", True))) -async def gather_evidence(artifact: Artifact, jev: Jev, f: dict, root: Path, excluded: set[str], +async def gather_evidence(artifact: Artifact, jev: JevLike, f: ClaimLike, root: Path, excluded: set[str], index: RepoIndex | None) -> tuple[list[Excerpt], list[str]]: """Excerpts of the repository that help decide `f` (a finding, or any claim about a line): the code graph from the calls on the line, then keyword search; Jev keeps the relevant ones when there are too many. `f` needs path, line, quote, chunk, statement_text, message and depends_on.""" - chunk = next((c for c in artifact.chunks if c.id == f.get("chunk")), None) - key_path = yaml_key_path(chunk.line_text, f.get("line")) if chunk and chunk.language == "yaml" else None + chunk = next((c for c in artifact.chunks if c.id == f.chunk), None) + key_path = yaml_key_path(chunk.line_text, f.line) if chunk and chunk.language == "yaml" else None terms = search_terms(f, key_path) notes: list[str] = [] excerpts: list[Excerpt] = [] if index is not None: - seeds = (index.seeds_at(f["path"], f["line"], f.get("quote", "")) if f.get("line") else - names_in(f.get("quote", ""))) + [t for t in f.get("depends_on", []) if IDENT_RE.fullmatch(t)] - hide = {f["path"]} if artifact.kind != "diff" else set() # a whole file is already in the chunk - windows, notes = expand(index, seeds, f["path"], hide=hide) + seeds = (index.seeds_at(f.path, f.line, f.quote) if f.line else + names_in(f.quote)) + [t for t in f.depends_on if IDENT_RE.fullmatch(t)] + hide = {f.path} if artifact.kind != "diff" else set() # a whole file is already in the chunk + windows, notes = expand(index, seeds, f.path, hide=hide) excerpts = [Excerpt(id="", path=w.path, start=w.start, end=w.end, text=w.text, terms=[], via=w.via) for w in windows] taken = {(e.path, e.start) for e in excerpts} # a diff chunk shows only the changed hunks: the rest of the same file (where a variable is # computed, a client configured) is evidence too, except the lines the chunk already shows. # A whole-file chunk already has all of it. - own = {f["path"]} if artifact.kind != "diff" else set() - line = f.get("line") + own = {f.path} if artifact.kind != "diff" else set() + line = f.line shown = range(line - CHUNK_RADIUS, line + CHUNK_RADIUS + 1) if line else range(0) excerpts += [e for e in gather_excerpts(root, terms, excluded | own) if (e.path, e.start) not in taken - and not (e.path == f["path"] and e.start >= shown.start and e.end < shown.stop)] + and not (e.path == f.path and e.start >= shown.start and e.end < shown.stop)] for i, e in enumerate(excerpts[:MAX_EXCERPTS]): e.id = f"e{i}" excerpts = excerpts[:MAX_EXCERPTS] if len(excerpts) > KEEP_EXCERPTS: # Jev only cuts down; a short chain goes through whole - base = {"requirement": f["statement_text"], "quote": f["quote"], "message": f["message"]} + base = {"requirement": f.statement_text, "quote": f.quote, "message": f.message} answers = await jev.gather( jev.ask({"finding": base, "excerpt_file": e.path, "reached_via": _via(e), "excerpt": e.text}, {"relevant": relevance_question()}) @@ -339,38 +350,38 @@ def verification_scope(cfg: Config, corpus: Corpus, index: RepoIndex | None, return root, excluded_paths(corpus, root), index -async def verify_findings(cfg: Config, corpus: Corpus, artifact: Artifact, jev: Jev, findings: list[dict], +async def verify_findings(cfg: Config, corpus: Corpus, artifact: Artifact, jev: JevLike, findings: list[Finding], search_root: Path | None = None, agent=None, model: str | None = None, index: RepoIndex | None = None, - cache=None) -> tuple[list[dict], list[dict], dict]: - model = model or cfg.get("review.verify_model") or None + cache=None) -> tuple[list[Finding], list[dict], dict]: agent = agent or (lambda prompt, sem: run_claude(cfg, prompt, agent_prompt("shall-verifier"), - VERDICT_SCHEMA, sem, model=model, cache=cache)) + VERDICT_SCHEMA, sem, model=model or cfg.verify_model, cache=cache)) root, excluded, index = verification_scope(cfg, corpus, index, search_root) sem = asyncio.Semaphore(int(cfg.get("review.concurrency"))) stats = {"verified": 0, "confirmed": 0, "refuted": 0, "unknown": 0, "agent_calls": 0, "reused": 0, "cost_usd": 0.0} - async def one(f: dict): + async def one(f: Finding) -> tuple[Finding | None, dict | None]: if not needs_verification(f): return f, None excerpts, notes = await gather_evidence(artifact, jev, f, root, excluded, index) verdict, meta = await agent(verifier_prompt(f, chunk_context(artifact, f), excerpts, notes), sem) stats["reused" if meta.get("cached") else "agent_calls"] += 1 stats["cost_usd"] = round(stats["cost_usd"] + meta.get("cost_usd", 0.0), 6) - return apply_verdict(f, verdict, excerpts) + kept, dropped = apply_verdict(f, verdict, excerpts) + stats["verified"] += 1 + stats[kept.verification.verdict if kept and kept.verification else "refuted"] += 1 + return kept, dropped results = await asyncio.gather(*(one(f) for f in findings)) - kept, dropped = [], [] - for f, (k, d) in zip(findings, results): - if needs_verification(f): - stats["verified"] += 1 - stats[(k or d)["verification"]["verdict"] if (k or d).get("verification") else "unknown"] += 1 + kept: list[Finding] = [] + dropped: list[dict] = [] + for k, d in results: if k is not None: kept.append(k) if d is not None: dropped.append(d) - kept.sort(key=lambda k: (not k["blocking"], k["path"], k["line"] or 0, k["statement_id"])) - stats["downgraded"] = sum(1 for k in kept if k.get("downgraded")) + kept.sort(key=sort_key) + stats["downgraded"] = sum(1 for k in kept if k.downgraded) stats["codegraph_events"] = list(index.events) if index is not None else [] return kept, dropped, stats diff --git a/plugins/shall/tooling/tests/fakes.py b/plugins/shall/tooling/tests/fakes.py new file mode 100644 index 0000000..aa11091 --- /dev/null +++ b/plugins/shall/tooling/tests/fakes.py @@ -0,0 +1,66 @@ +"""The test doubles every test shares: one fake Jev (shall.jev.JevLike) and one Finding factory. + +Use these instead of a local copy: a copy drifts from the real interface and the tests then pass +against an interface that no longer exists. +""" + +import asyncio +from typing import TYPE_CHECKING, Any + +from shall.findings import Finding + + +class FakeJev: + """p per question id from `table` (or per file: {path: table}); `#applies` questions answer + 0.95, every other question 0.02. For scores that depend on the state, subclass and override `p`. + `states` records each state asked; `asked` records each question set.""" + + model = "fake-jev" + + def __init__(self, table=None, fail=None, delay=0.0, per_file=None): + self.table, self.fail, self.delay, self.per_file = table or {}, fail, delay, per_file or {} + self.usage = {"requests": 0, "cached": 0, "input_tokens": 0} + self.states = [] + self.asked = [] + + def available(self): + return True + + def save(self): + pass + + async def gather(self, aws): + return list(await asyncio.gather(*aws)) + + def p(self, state, qid): + table = {**self.table, **self.per_file.get(state.get("file"), {})} + return table.get(qid, 0.95 if qid.endswith("#applies") else 0.02) + + async def ask(self, state, questions): + if self.delay: + await asyncio.sleep(self.delay) + if self.fail: + raise self.fail + self.usage["requests"] += 1 + self.usage["input_tokens"] += 1000 + self.states.append(state) + self.asked.append(set(questions)) + return {q: {"p": self.p(state, q)} for q in questions} + + +def make_finding(**kw) -> Finding: + """A validated finding: blocking (enforced MUST) unless `corpus_blocking=False`.""" + base: dict[str, Any] = dict(statement_id="JTOOL-0002.1", title="t", level="MUST", corpus_blocking=True, + path="shall.yaml", line=2, quote="command: [claude, -p]", message="agent runs with tools", + suggestion="", statement_text="The review agent MUST run with no tools.", source="x#L1", + chunk="c0", depends_on=()) + base.update(kw) + base.setdefault("rfc", base["statement_id"].split(".")[0]) + base["depends_on"] = tuple(base["depends_on"]) + return Finding(**base) + + +if TYPE_CHECKING: # `make typecheck` fails when FakeJev drifts from the real interface + from shall.jev import JevLike + + _: JevLike = FakeJev() diff --git a/plugins/shall/tooling/tests/test_answer_cache.py b/plugins/shall/tooling/tests/test_answer_cache.py index 2ea874f..0f94ce5 100644 --- a/plugins/shall/tooling/tests/test_answer_cache.py +++ b/plugins/shall/tooling/tests/test_answer_cache.py @@ -3,7 +3,7 @@ import asyncio import sys -from shall.review import AnswerCache, run_claude +from shall.claude import AnswerCache, run_claude FAKE = """ import json, pathlib, sys @@ -86,3 +86,13 @@ def test_a_known_flaky_failure_is_retried_once_and_others_are_not(tmp_path): cfg, calls = setup(tmp_path / "x") if (tmp_path / "x").mkdir() is None else None out, meta = ask(cfg, "FAIL chunk", AnswerCache(None)) assert out is None and "error" in meta and n_calls(calls) == 1 # an unknown failure: no retry + + +def test_a_cache_file_that_is_not_an_object_starts_empty(tmp_path): + path = tmp_path / "review.json" + path.write_text("[]") + cache = AnswerCache(path) + assert cache.get("k") is None + cache.put("k", {"findings": []}) + cache.save() + assert AnswerCache(path).get("k") == {"findings": []} diff --git a/plugins/shall/tooling/tests/test_boundaries.py b/plugins/shall/tooling/tests/test_boundaries.py new file mode 100644 index 0000000..934e3d3 --- /dev/null +++ b/plugins/shall/tooling/tests/test_boundaries.py @@ -0,0 +1,324 @@ +"""Architecture rules that types and import-linter cannot express. Each failure message says what +is wrong, what to use instead, where a good example is, and why (the JTOOL statement). + +Layers checked elsewhere: `make typecheck` (pyright: Literal types, exhaustive match) and +`make lint-imports` (import-linter: deterministic modules never import a model module). +""" + +import argparse +import ast +import re +import subprocess +import sys +import typing +from pathlib import Path + +import pytest + +from shall import cli +from shall.claude import ISOLATION_FLAGS, claude_command +from shall.config import DEFAULTS, load_config, unknown_keys +from shall.findings import FINDINGS_SCHEMA, RawFinding + +SRC = Path(cli.__file__).resolve().parent +PLUGIN = SRC.parents[2] +REPO = PLUGIN.parents[1] + +# module -> the one program it may start (and why only there) +PROCESS_OWNERS = {"git.py": "git", "claude.py": "claude", "publish.py": "gh", "hooks.py": "shall (Stop review)"} +DETERMINISTIC_CMDS = ("lint", "build", "new", "show", "list", "catalog", "init", "validate", "bundle", "publish") + + +def sources() -> dict[str, str]: + return {p.name: p.read_text(encoding="utf-8") for p in sorted(SRC.glob("*.py"))} + + +# ---------------------------------------------------------------- one owner per external program + +def process_starts(text: str) -> list[int]: + tree = ast.parse(text) + lines = [] + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + names = [a.name for a in node.names] + ([node.module] if isinstance(node, ast.ImportFrom) else []) + if any(n and n.split(".")[0] == "subprocess" for n in names): + lines.append(node.lineno) + elif isinstance(node, ast.Attribute) and node.attr in ("create_subprocess_exec", "create_subprocess_shell", + "system", "popen", "Popen", "spawnv"): + lines.append(node.lineno) + return lines + + +def test_only_owner_modules_start_processes(): + bad = [f"{name}:{n}" for name, text in sources().items() if name not in PROCESS_OWNERS + for n in process_starts(text)] + assert not bad, (f"process started outside its owner module: {bad}. Use shall.git.run/git (git), " + "shall.claude.run_claude/run_claude_text (claude) or shall.publish (gh); see " + "plugins/shall/tooling/src/shall/git.py. Why: one place sets the timeout, the " + "tool-less flags and the error handling (JTOOL-0002.1, JTOOL-0003.1).") + + +def test_process_check_catches_a_bad_snippet(): + assert process_starts("import subprocess\nsubprocess.run(['git', 'status'])\n") == [1] + assert process_starts("import asyncio\nasyncio.create_subprocess_exec('claude')\n") == [2] + assert process_starts("from . import git\ngit.git('.', 'status')\n") == [] + + +def test_findings_are_built_only_by_validation(): + bad = [f"{name}:{node.lineno}" for name, text in sources().items() if name != "findings.py" + for node in ast.walk(ast.parse(text)) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Finding"] + assert not bad, (f"Finding(...) built outside findings.py: {bad}. Agent output is a RawFinding: " + "pass it to shall.findings.validate_findings; read a findings.json with " + "Finding.from_json. Why: severity comes from the corpus, never from an agent " + "(JTOOL-0002.2, JTOOL-0002.5).") + + +def test_findings_schema_matches_raw_finding(): + schema_keys = set(FINDINGS_SCHEMA["properties"]["findings"]["items"]["properties"]) + typed_keys = set(typing.get_type_hints(RawFinding)) - {"chunk"} # chunk: added by code + assert schema_keys == typed_keys, ("FINDINGS_SCHEMA and RawFinding (plugins/shall/tooling/src/shall/" + "findings.py) differ: change both. Why: the schema is what the " + "agent returns, RawFinding is how code reads it.") + + +def test_every_claude_call_is_tool_less(): + cfg = type("Cfg", (), {"get": lambda self, key: ["claude", "-p"]})() + for schema in (None, {"type": "object"}): + cmd = claude_command(cfg, "m", "system", schema) # type: ignore[arg-type] + tail = cmd[len(cmd) - len(ISOLATION_FLAGS):] + assert tuple(tail) == ISOLATION_FLAGS, ("claude_command must end with ISOLATION_FLAGS " + "(plugins/shall/tooling/src/shall/claude.py). Why: a " + "pipeline agent has no tools (JTOOL-0002.1).") + + +# ---------------------------------------------------------------- CLI: exit codes, model-free commands + +def test_every_command_returns_an_exit_code(): + bad = [name for name, fn in vars(cli).items() if name.startswith("cmd_") + and typing.get_type_hints(fn).get("return") is not cli.ExitCode] + assert not bad, (f"{bad} must be annotated `-> ExitCode` and return ExitCode.OK/ERROR/BLOCKING/SERVICE " + "(plugins/shall/tooling/src/shall/cli.py). Why: CI and hooks read 0/1/2/3 only " + "(JTOOL-0003.1).") + + +def test_usage_error_exits_1_not_2(): + # argparse exits 2 on a usage error, and 2 means "blocking findings" to CI (JTOOL-0003.1) + with pytest.raises(SystemExit) as err: + cli.main(["review", "--no-such-flag"]) + assert err.value.code == cli.ExitCode.ERROR + + +def test_scripts_exit_with_shall_codes_only(): + bad = [] + for script in (PLUGIN / "bin").iterdir(): + for n, line in enumerate(script.read_text(encoding="utf-8").splitlines(), 1): + bad += [f"{script.name}:{n}: exit {c}" for c in re.findall(r"\bexit\s+(\d+)", line) if int(c) > 3] + assert not bad, (f"{bad}: plugins/shall/bin scripts exit 0 (ok), 1 (error), 2 (blocking) or 3 " + "(service failed); see plugins/shall/bin/shall-pr-review. Why: JTOOL-0003.1.") + + +def _local_imports(fn: ast.FunctionDef, funcs: dict[str, ast.FunctionDef], seen: set[str]) -> set[str]: + """Modules imported inside `fn` and inside the cli helpers it calls.""" + out: set[str] = set() + for node in ast.walk(fn): + if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module: + out.add(node.module.split(".")[0]) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in funcs \ + and node.func.id not in seen: + seen.add(node.func.id) + out |= _local_imports(funcs[node.func.id], funcs, seen) + return out + + +def model_modules() -> set[str]: + """shall.jev, shall.claude and every module that imports them, directly or not.""" + imports = {} + for name, text in sources().items(): + mods = set() + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.ImportFrom) and node.level == 1: + mods |= {node.module.split(".")[0]} if node.module else {a.name for a in node.names} + imports[name.removesuffix(".py")] = mods + found = {"jev", "claude"} + while grown := {m for m, deps in imports.items() if deps & found} - found: + found |= grown + return found + + +def model_imports_of(cmd: str, text: str) -> set[str]: + tree = ast.parse(text) + funcs = {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)} + return _local_imports(funcs[f"cmd_{cmd}"], funcs, {f"cmd_{cmd}"}) & model_modules() + + +def test_deterministic_commands_reach_no_model_module(): + text = (SRC / "cli.py").read_text(encoding="utf-8") + bad = {cmd: sorted(mods) for cmd in DETERMINISTIC_CMDS if (mods := model_imports_of(cmd, text))} + assert not bad, (f"deterministic commands import model modules: {bad}. Move the model step to " + "its own command (see cmd_validate vs cmd_review in cli.py). Why: these " + "commands run in CI without keys (JTOOL-0003.2).") + + +def test_model_import_check_catches_a_bad_snippet(): + bad = "def cmd_lint(args, cfg):\n return helper()\n\ndef helper():\n from .select import Selector\n" + assert model_imports_of("lint", bad) == {"select"} # select imports jev + assert {"select", "review", "verify", "hooks", "scan"} <= model_modules() + assert not {"corpus", "config", "findings", "git"} & model_modules() + + +@pytest.mark.parametrize("cmd", [["list"], ["lint"], ["show", "JRFC-0001"]]) +def test_deterministic_commands_do_not_load_the_sdk(cmd): + """The lazy imports hold at run time too: no shall.jev, so no TypeSafe SDK (~190 ms) to load.""" + code = ("import sys\nfrom shall import cli\ntry:\n cli.main(sys.argv[1:])\nexcept SystemExit:\n pass\n" + "print('LOADED' if 'shall.jev' in sys.modules else 'CLEAN', file=sys.stderr)") + proc = subprocess.run([sys.executable, "-c", code, "--config", str(REPO / "shall.yaml"), *cmd], + capture_output=True, text=True, cwd=REPO) + assert proc.stderr.strip().endswith("CLEAN"), proc.stderr[-500:] + + +# ---------------------------------------------------------------- config: models, keys + +MOVING = re.compile(r"^(sonnet|opus|haiku|claude|jev)$|-(latest|preview)$") + + +def model_values(node=DEFAULTS, path=""): + for key, value in node.items(): + if isinstance(value, dict): + yield from model_values(value, f"{path}{key}.") + elif key.endswith("model") and isinstance(value, str): + yield f"{path}{key}", value + + +def test_default_models_are_pinned_and_defined_once(): + for path, value in model_values(): + assert not MOVING.search(value), f"{path}: {value} is a moving alias; pin a version (JTOOL-0001.4)" + literals =[(name, m) for name, text in sources().items() if name != "config.py" + for m in re.findall(r"""["']((?:claude|jev)-[a-z]+-[\w.-]+|jev-\d[\w.]*)["']""", text)] + assert not literals, (f"model ids hard-coded outside DEFAULTS: {literals}. Read them with " + "cfg.get('review.model') etc. (plugins/shall/tooling/src/shall/config.py). Why: " + "one pinned value per setting (JTOOL-0001.4).") + + +def test_agent_models_are_pinned_to_the_review_model(): + """The in-session agents use the model that CI uses (review.model), not a moving alias.""" + review_model = DEFAULTS["review"]["model"] + bad = {} + for path in sorted((PLUGIN / "agents").glob("*.md")): + m = re.search(r"^model:\s*(\S+)", path.read_text(encoding="utf-8").split("---", 2)[1], re.M) + if m and m.group(1) != review_model: + bad[path.name] = m.group(1) + assert not bad, (f"agent model differs from review.model ({review_model}): {bad}. Set `model: " + f"{review_model}` in the frontmatter. Why: the in-session review must match CI (JTOOL-0001.4).") + + +@pytest.mark.parametrize("value,moving", [("sonnet", True), ("jev-latest", True), ("claude-sonnet-5", False), + ("jev-1.13.0", False)]) +def test_moving_alias_pattern(value, moving): + assert bool(MOVING.search(value)) is moving + + +def config_paths_read(text: str) -> list[tuple[int, str]]: + return [(n, m) for n, line in enumerate(text.splitlines(), 1) + for m in re.findall(r"""\bcfg\.get\(\s*["']([a-z_]+\.[a-z_.]+)["']""", line)] + + +def test_every_setting_read_has_a_default(): + bad = [f"{name}:{n}: {path}" for name, text in sources().items() for n, path in config_paths_read(text) + if unknown_keys(_nested(path))] + assert not bad, (f"settings read but missing from DEFAULTS: {bad}. Add each with its default and a " + "comment to DEFAULTS in plugins/shall/tooling/src/shall/config.py. Why: DEFAULTS is " + "the list of keys shall.yaml may set; an unknown key fails at load.") + + +def _nested(path: str) -> dict: + out: dict = {} + node = out + *parents, last = path.split(".") + for part in parents: + node = node.setdefault(part, {}) + node[last] = 0 + return out + + +@pytest.mark.parametrize("yaml_text,error", [ + ("review:\n modle: x\n", "unknown key(s): review.modle"), + ("hooks:\n actions:\n enforced:\n MUST: [[0.9, block]]\n", "action one of log, warn, ask, deny"), + ("hooks:\n actions:\n enforced: [[0.9, deny]]\n", "hooks.actions.enforced = [[0.9, 'deny']]"), +]) +def test_bad_config_fails_at_load(tmp_path, monkeypatch, yaml_text, error): + monkeypatch.setenv("SHALL_CONFIG", "") # load_config sets it; monkeypatch restores it + (tmp_path / "shall.yaml").write_text(yaml_text) + with pytest.raises(SystemExit, match=re.escape(error)): + load_config(str(tmp_path / "shall.yaml")) + (tmp_path / "shall.yaml").write_text("hooks:\n actions:\n draft:\n MUST: [[0.9, deny]]\n") + assert load_config(str(tmp_path / "shall.yaml")).get("hooks.actions.draft.MUST") == [[0.9, "deny"]] + + +def rfc_with_a_typo_in_status(tmp_path, issues: list): + from shall.corpus import parse_rfc + rfc_file = tmp_path / "JRFC-0001-x.md" + rfc_file.write_text("---\nid: JRFC-0001\nstatus: enforcd\ndomain: d\ntitle: X\n---\n" + "### JRFC-0001.1 X\nCode MUST do x.\n") + return parse_rfc(rfc_file, tmp_path, issues) + + +def test_invalid_rfc_status_is_an_error_and_never_active(tmp_path): + issues: list = [] + rfc = rfc_with_a_typo_in_status(tmp_path, issues) + assert rfc is not None and rfc.status is None # in no include_status, no hooks.actions table + assert any(i.code == "status" for i in issues) + + +def test_list_shows_an_rfc_with_an_invalid_status(tmp_path, monkeypatch, capsys): + from types import SimpleNamespace + + rfc = rfc_with_a_typo_in_status(tmp_path, []) + monkeypatch.setattr(cli, "load_corpus", lambda cfg: SimpleNamespace(rfcs={"JRFC-0001": rfc})) + args = argparse.Namespace(domain=None, status=None) + assert cli.cmd_list(args, None) == cli.ExitCode.OK + assert "invalid" in capsys.readouterr().out + + +def test_unknown_config_keys_are_reported(): + assert unknown_keys({"review": {"model": "m", "modle": "m"}, "reveiw": {}}) == ["review.modle", "reveiw"] + assert unknown_keys({"extends": "org/x@v1", "prefix": "BOOK", "hooks": {"actions": {"draft": {}}}}) == [] + + +# ---------------------------------------------------------------- docs and skills name real commands + +CODE_RE = re.compile(r"```[^\n]*\n(.*?)```|`([^`\n]+)`", re.S) +SHALL_CMD_RE = re.compile(r"(?:^|[\s|;&])(?:\S*/)?shall(?:[ \t]+--config[ \t]+\S+)?[ \t]+([a-z][a-z-]+)([^\n|;&]*)", + re.M) + + +def documented_commands(text: str): + for m in CODE_RE.finditer(text): + code = m.group(1) or m.group(2) + for c in SHALL_CMD_RE.finditer(code): + yield c.group(1), re.findall(r"(? the GitHub payload shall renders.""" art = build_artifact("pr.diff", diff(lines, start), "diff", 40000) by_text = {t: n for n, t in art.chunks[0].line_text.items()} - kept = [{ - "statement_id": sid, "rfc": sid.split(".")[0], "title": "t", "level": "MUST", "blocking": True, - "path": "src/a.ts", "line": by_text[text], "quote": text, "message": "m", "suggestion": "", - "statement_text": "s", "source": "x#L1", - } for sid, text in findings] + kept = [make_finding(statement_id=sid, path="src/a.ts", line=by_text[text], quote=text, message="m", + statement_text="s") for sid, text in findings] return render_github(art, SELECTION, kept, []) diff --git a/plugins/shall/tooling/tests/test_scan.py b/plugins/shall/tooling/tests/test_scan.py index a1f61ec..6eaca47 100644 --- a/plugins/shall/tooling/tests/test_scan.py +++ b/plugins/shall/tooling/tests/test_scan.py @@ -2,11 +2,12 @@ import asyncio import copy -import subprocess from pathlib import Path import pytest +from fakes import FakeJev +from shall import git from shall.config import load_config from shall.corpus import load_corpus from shall.scan import excluded, list_files, render_lines, render_markdown, run_scan @@ -24,28 +25,6 @@ def org(): mp.undo() -class FakeJev: - """p per question id from a table (or per file: {path: table}); `#applies` answers 0.95, - violations 0.02 by default.""" - - def __init__(self, table=None, fail=None, per_file=None): - self.table, self.fail, self.per_file = table or {}, fail, per_file or {} - self.usage = {"requests": 0, "cached": 0, "input_tokens": 0} - self.states = [] - - def available(self): - return True - - async def ask(self, state, questions): - if self.fail: - raise self.fail - self.usage["requests"] += 1 - self.usage["input_tokens"] += 1000 - self.states.append(state) - table = {**self.table, **self.per_file.get(state.get("file"), {})} - return {q: {"p": table.get(q, 0.95 if q.endswith("#applies") else 0.02)} for q in questions} - - def write(root: Path, files: dict[str, str]) -> None: for rel, text in files.items(): (root / rel).parent.mkdir(parents=True, exist_ok=True) @@ -53,7 +32,8 @@ def write(root: Path, files: dict[str, str]) -> None: def git_repo(root: Path, files: dict[str, str]) -> Path: - subprocess.run(["git", "init", "-q", str(root)], check=True) + root.mkdir(parents=True, exist_ok=True) + git.init(root) write(root, files) return root diff --git a/plugins/shall/tooling/tests/test_triage.py b/plugins/shall/tooling/tests/test_triage.py index c6d5cd8..59a6d0a 100644 --- a/plugins/shall/tooling/tests/test_triage.py +++ b/plugins/shall/tooling/tests/test_triage.py @@ -3,9 +3,10 @@ import asyncio import json -import subprocess from pathlib import Path +from fakes import FakeJev, make_finding +from shall import git from shall.artifact import build_artifact from shall.config import load_config from shall.corpus import load_corpus @@ -170,30 +171,23 @@ def test_verdict_needs_cited_evidence(): assert apply_comment_verdict(None, [])[0] == "unverified" -class FakeJev: +class CommentJev(FakeJev): """Scores from keywords in the comment: enough to exercise every branch of the code.""" - def __init__(self): - self.asked = [] - - async def gather(self, aws): - return list(await asyncio.gather(*aws)) - - async def ask(self, state, questions): - self.asked.append(set(questions)) - if "same" in questions: + def p(self, state, qid): + if qid == "same": both = state["first"] + " | " + state["second"] - return {"same": {"p": 0.9 if both.count("SQL") == 2 or both.count("email") == 2 else 0.1}} + return 0.9 if both.count("SQL") == 2 or both.count("email") == 2 else 0.1 if "review_comment" not in state: # selection of the chunk, as in `shall review` - return {q: {"p": 0.9 if q in ("JRFC-0003.1", "JRFC-0006.1") else 0.1} for q in questions} + return 0.9 if qid in ("JRFC-0003.1", "JRFC-0006.1") else 0.1 text = state["review_comment"] - out = {q: {"p": 0.1} for q in questions} - out["actionable"] = {"p": 0.1 if ("Nice" in text or "rename" in text) else 0.9} - if "email" in text and "JRFC-0003.1" in out: - out["JRFC-0003.1"] = {"p": 0.95} - if "timeout" in text and "JRFC-0006.1" in out: - out["JRFC-0006.1"] = {"p": 0.9} - return out + if qid == "actionable": + return 0.1 if ("Nice" in text or "rename" in text) else 0.9 + if qid == "JRFC-0003.1" and "email" in text: + return 0.95 + if qid == "JRFC-0006.1" and "timeout" in text: + return 0.9 + return 0.1 def test_triage_end_to_end(tmp_path): @@ -203,7 +197,7 @@ def test_triage_end_to_end(tmp_path): (tmp_path / "src" / "gateway.ts").write_text("export const gateway = { hold: (b) => request('/hold', b) };\n" "function request(url, b) { return fetch(url, { signal: " "AbortSignal.timeout(5000) }); }\n") - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + git.init(tmp_path) art = build_artifact("pr.diff", DIFF, "diff", 40000) comments = [ thread(1, "copilot", 2, "SQL injection: `req.params.id` is concatenated into the query."), @@ -215,8 +209,8 @@ def test_triage_end_to_end(tmp_path): thread(7, "copilot", 6, "The handler never checks the guest owns the booking."), thread(8, "copilot", 99, "Old comment on removed code."), ] - shall_findings = [{"statement_id": "JRFC-0003.1", "title": "No personal data in logs", "path": "src/confirm.ts", - "line": 3, "message": "The guest email is logged."}] + shall_findings = [make_finding(statement_id="JRFC-0003.1", title="No personal data in logs", + path="src/confirm.ts", line=3, message="The guest email is logged.")] prompts = {} async def agent(prompt, sem): @@ -229,7 +223,7 @@ async def agent(prompt, sem): return {"verdict": "confirmed", "reason": "id interpolated", "evidence": ["chunk"]}, {"cost_usd": 0.01} return {"verdict": "unknown", "reason": "auth may be in a middleware", "evidence": []}, {} - jev = FakeJev() + jev = CommentJev() items, stats = asyncio.run(triage_comments(cfg, corpus, art, jev, comments, shall_findings, agent=agent, search_root=tmp_path)) status = {i["id"]: (i["status"], i["reason"].split(":")[0].split(" (")[0]) for i in items} diff --git a/plugins/shall/tooling/tests/test_verify.py b/plugins/shall/tooling/tests/test_verify.py index 4fcc21b..ae01f75 100644 --- a/plugins/shall/tooling/tests/test_verify.py +++ b/plugins/shall/tooling/tests/test_verify.py @@ -1,24 +1,20 @@ """Verification of findings: code picks what is verified and applies verdicts; severity only goes down.""" import asyncio -import subprocess +from dataclasses import replace from types import SimpleNamespace +from fakes import FakeJev +from fakes import make_finding as finding +from shall import git from shall.artifact import build_artifact from shall.verify import apply_verdict, gather_excerpts, needs_verification, search_terms, verify_findings -def finding(**kw): - base = {"statement_id": "JTOOL-0002.1", "level": "MUST", "blocking": True, "path": "shall.yaml", - "line": 2, "quote": "command: [claude, -p]", "message": "agent runs with tools", - "statement_text": "The review agent MUST run with no tools.", "depends_on": [], "chunk": "c0"} - return {**base, **kw} - - def test_what_gets_verified_is_decided_by_code(): assert needs_verification(finding()) # blocking: always - assert needs_verification(finding(blocking=False, depends_on=["x"])) # advisory with depends_on - assert not needs_verification(finding(blocking=False)) # advisory, self-contained + assert needs_verification(finding(corpus_blocking=False, depends_on=["x"])) # advisory with depends_on + assert not needs_verification(finding(corpus_blocking=False)) # advisory, self-contained def test_search_terms_use_depends_on_first_and_skip_noise(): @@ -39,7 +35,7 @@ def test_yaml_key_path_and_statement_code_spans_become_terms(): def test_code_ranks_before_docs(tmp_path): (tmp_path / "README.md").write_text("Run claude with review.command.\n") (tmp_path / "run.py").write_text("x = cfg.get('review.command')\n") - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + git.init(tmp_path) assert [e.path for e in gather_excerpts(tmp_path, ["review.command"], set())] == ["run.py", "README.md"] @@ -50,7 +46,7 @@ def test_gather_excerpts_finds_other_files_and_skips_own_file_and_standards(tmp_ (tmp_path / "src" / "review.py").write_text( "def run():\n cmd = list(cfg.get('review.command')) + [\n '--tools', '',\n ]\n") (tmp_path / "corpus" / "rule.md").write_text("The review.command MUST run with no tools.\n") - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + git.init(tmp_path) ex = gather_excerpts(tmp_path, ["review.command", "claude"], {"shall.yaml", "corpus"}) assert [e.path for e in ex] == ["src/review.py"] assert "'--tools', ''" in ex[0].text and ex[0].start == 1 @@ -61,7 +57,7 @@ def test_excerpts_stay_small_on_one_line_files(tmp_path): (tmp_path / "cache.json").write_text('{"review.command": "' + "x" * 400_000 + '"}\n') (tmp_path / "run.py").write_text("\n".join(f"cmd{i} = cfg.get('review.command') # {'y' * 300}" for i in range(200)) + "\n") - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + git.init(tmp_path) ex = {e.path: e for e in gather_excerpts(tmp_path, ["review.command"], set())} assert set(ex) == {"cache.json", "run.py"} assert len(ex["cache.json"].text) < 500 and "…[cut " in ex["cache.json"].text @@ -81,43 +77,45 @@ def test_refuted_with_evidence_is_dropped(): kept, dropped = apply_verdict(finding(), {"verdict": "refuted", "reason": "review.py adds --tools ''", "evidence": ["e0"]}, [SimpleNamespace(id="e0", path="src/review.py", start=1, end=4)]) - assert kept is None and dropped["verification"]["evidence"] == ["src/review.py:1-4"] + assert kept is None and dropped is not None and dropped["verification"]["evidence"] == ["src/review.py:1-4"] def test_refuted_without_evidence_is_kept_but_not_blocking(): kept, dropped = apply_verdict(finding(), {"verdict": "refuted", "reason": "trust me", "evidence": ["e9"]}, []) - assert dropped is None and kept["blocking"] is False and kept["verification"]["verdict"] == "unknown" + assert dropped is None and kept is not None and kept.blocking is False + assert kept.verification is not None and kept.verification.verdict == "unknown" def test_unknown_downgrades_a_blocking_finding(): kept, _ = apply_verdict(finding(), {"verdict": "unknown", "reason": "SDK default not shown", "evidence": []}, []) - assert kept["blocking"] is False and kept["downgraded"] is True + assert kept is not None and kept.blocking is False and kept.downgraded is True def test_confirmed_keeps_blocking_and_never_upgrades_an_advisory(): kept, _ = apply_verdict(finding(), {"verdict": "confirmed", "reason": "r", "evidence": ["chunk"]}, []) - assert kept["blocking"] is True - kept, _ = apply_verdict(finding(blocking=False, depends_on=["x"]), + assert kept is not None and kept.blocking is True + kept, _ = apply_verdict(finding(corpus_blocking=False, depends_on=["x"]), {"verdict": "confirmed", "reason": "r", "evidence": ["chunk"]}, []) - assert kept["blocking"] is False + assert kept is not None and kept.blocking is False def test_confirmed_without_existing_evidence_does_not_block(): kept, _ = apply_verdict(finding(), {"verdict": "confirmed", "reason": "r", "evidence": ["e7"]}, []) - assert kept["blocking"] is False and kept["verification"]["verdict"] == "unknown" + assert kept is not None and kept.blocking is False + assert kept.verification is not None and kept.verification.verdict == "unknown" def test_missing_verdict_counts_as_unknown(): kept, _ = apply_verdict(finding(), None, []) - assert kept["blocking"] is False and kept["verification"]["verdict"] == "unknown" + assert kept is not None and kept.blocking is False + assert kept.verification is not None and kept.verification.verdict == "unknown" -class FakeJev: - async def gather(self, aws): - return list(await asyncio.gather(*aws)) +class ExcerptJev(FakeJev): + """Relevant: an excerpt that shows `--tools`.""" - async def ask(self, state, questions): - return {"relevant": {"p": 0.9 if "--tools" in state["excerpt"] else 0.1}} + def p(self, state, qid): + return 0.9 if "--tools" in state["excerpt"] else 0.1 def test_end_to_end_only_verifies_what_code_selects(tmp_path): @@ -133,11 +131,11 @@ async def agent(prompt, sem): cfg = SimpleNamespace(get=lambda key: 2) corpus = SimpleNamespace(layers=[]) blocking = finding(depends_on=["review.command"]) - advisory = finding(statement_id="JTOOL-0001.4", blocking=False, line=1, quote="review:") - kept, dropped, stats = asyncio.run(verify_findings(cfg, corpus, art, FakeJev(), [blocking, advisory], + advisory = finding(statement_id="JTOOL-0001.4", corpus_blocking=False, line=1, quote="review:") + kept, dropped, stats = asyncio.run(verify_findings(cfg, corpus, art, ExcerptJev(), [blocking, advisory], search_root=tmp_path, agent=agent)) assert len(prompts) == 1 and "review.py" in prompts[0] # only the blocking one was verified - assert [k["statement_id"] for k in kept] == ["JTOOL-0001.4"] + assert [k.statement_id for k in kept] == ["JTOOL-0001.4"] assert dropped[0]["statement_id"] == "JTOOL-0002.1" assert stats["refuted"] == 1 and stats["verified"] == 1 @@ -152,10 +150,10 @@ def test_a_diff_finding_gets_evidence_from_the_rest_of_its_own_file(tmp_path): art = build_artifact("pr.diff", diff, "diff", 40000) f = finding(path="api.ts", line=83, quote="return client.get(`/items/${id}`);", depends_on=["client"], statement_text="Every outbound call MUST set a timeout.", chunk=art.chunks[0].id) - excerpts, _ = asyncio.run(gather_evidence(art, FakeJev(), f, tmp_path, set(), None)) + excerpts, _ = asyncio.run(gather_evidence(art, ExcerptJev(), f, tmp_path, set(), None)) assert any(e.path == "api.ts" and e.start == 1 for e in excerpts) # the client setup, 80 lines up assert not any(e.path == "api.ts" and e.start >= 63 for e in excerpts) # not the lines the chunk shows whole = build_artifact("api.ts", (tmp_path / "api.ts").read_text(), "code", 40000) - excerpts, _ = asyncio.run(gather_evidence(whole, FakeJev(), {**f, "chunk": whole.chunks[0].id}, + excerpts, _ = asyncio.run(gather_evidence(whole, ExcerptJev(), replace(f, chunk=whole.chunks[0].id), tmp_path, set(), None)) assert not any(e.path == "api.ts" for e in excerpts) # a whole file is already shown diff --git a/plugins/shall/tooling/uv.lock b/plugins/shall/tooling/uv.lock index 13504a3..f12e251 100644 --- a/plugins/shall/tooling/uv.lock +++ b/plugins/shall/tooling/uv.lock @@ -28,6 +28,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -37,6 +46,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "grimp" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7f/99365747c992a97e775829e859bde28993a1771fa932a5b160e3e629769e/grimp-3.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:aa1b4d439d18372390a61926a3c33d5de2f5dff7ac7a71b8b5a665644ce81bc6", size = 2143022, upload-time = "2026-09-04T11:28:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/84eb297e570f0eceb916f3d11fc399de78309e837f4a1c5fcff36af5e800/grimp-3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe6c3c9f111e900a43a3657d3b1d35b920af6ff943bc417bca4ebcd6140e1e50", size = 2101124, upload-time = "2026-09-04T11:28:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3c/abc9de477962e071480cd7f2fa4fbd171e1f3f2d2ebf7aeb704138b28f94/grimp-3.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5834e34cbf5ef52877dd6f6a3bcda137d0f07d44c65084d63913931b21951b2f", size = 2283264, upload-time = "2026-09-04T11:26:59.045Z" }, + { url = "https://files.pythonhosted.org/packages/12/2c/07f824f3d3776265831d555b458380fa32214b3ff1534a11b2d22b292c78/grimp-3.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d97de5133ae1588230d9c9c9724526e48e62be79caf32a85b8ce970db8ce46de", size = 2223945, upload-time = "2026-09-04T11:27:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c0/bcb08d1a5446a5618f129f0789fc2ea38d643974db1094d7eb68adb09508/grimp-3.17-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3cbb0a6c5e8bf38ecd2e6d051e9d0992f3cf4c65bb01d3ccf525f48586f456b", size = 2375571, upload-time = "2026-09-04T11:27:37.307Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f7/0a81e9cac29af4c88e73d7bf4d250bd01b04894b0bffa70ad5026846eaf3/grimp-3.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c423bcd7fd139553d184f194486e1a718e816b5898ff688c436456151eb70c61", size = 2633241, upload-time = "2026-09-04T11:27:17.639Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/c2d80fab1af8bf92ce86b18d30b9a7f1d2315e72e4205774590fb8d7ac55/grimp-3.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42ffb9e69af7bd09cab96786bc3f21716dced76d9daf6d553ef6eb4437b4cd7b", size = 2352338, upload-time = "2026-09-04T11:27:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/0e5aa9fc7b3d18620b36b68c2645b9cb12364e316bc827452e2447c90709/grimp-3.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c615cd6f08965831adb5383c9214054003eb2b5ee2f4c466c388a9b87835cb0e", size = 2288519, upload-time = "2026-09-04T11:27:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3b/d874409b8deacc419e847bff13e1706e7b45772cc2ad80fdf44d79f0d337/grimp-3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f6cc4ba9510691dd56111894da83bca78e407037f8149f808012dd67f0b5272d", size = 2460716, upload-time = "2026-09-04T11:28:17.05Z" }, + { url = "https://files.pythonhosted.org/packages/c1/08/667c2af86cdb8ed210e42ec4dfb4d0c3dd0f9e08818dfba6435742a14f60/grimp-3.17-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd72e76528837b634057689179aac014e3995f41d142c2e9eff8bf46f82c96e5", size = 2499499, upload-time = "2026-09-04T11:28:26.764Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5d93258181d6333ab01014884a2beca1ac39c25ddea3492b12364041eca8/grimp-3.17-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:58ce75a40a0817bf5725f7a67cb5cc38f76f23a028e624b1125959d06db76a36", size = 2533771, upload-time = "2026-09-04T11:28:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/d82b1c772509c80e1f0988694e30fb4fc4888c983273c2249a82effc15b0/grimp-3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e4dc31f50d695091fef8013e562470cd759123bd06fad2ebb91251850ba97a4", size = 2543062, upload-time = "2026-09-04T11:28:46.493Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f6/fec538a49b3297c0aba0386971e478522ec4b2f8fc25d6575f9579e57a36/grimp-3.17-cp311-cp311-win32.whl", hash = "sha256:802ea10b9d9e7b422c83f1277286be4225402bb4d2ab31b17ab43f3e850639d4", size = 1875122, upload-time = "2026-09-04T11:29:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/27/a4/b1a2a1d7c53e6f0976bc33e3225b82e8d45ad32ec528ac3e251d94172bc4/grimp-3.17-cp311-cp311-win_amd64.whl", hash = "sha256:11533e4afad7a516a4c2a6b8b3b24383fba9901c8fa0072b33450603eedc0649", size = 2002060, upload-time = "2026-09-04T11:29:07.629Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/3fb3e39ba1a34231bcdf27922121f864307f6b9a2f8145f17e285749d092/grimp-3.17-cp311-cp311-win_arm64.whl", hash = "sha256:488c0a70118c3600dd344455f280ab8d8894583f76e4091de770b019d9e30cde", size = 1926777, upload-time = "2026-09-04T11:28:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/ba/e2e37adeac007a42b074c9156b3a155692fc5a1a32008fb8385546e5c92f/grimp-3.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7375c3aea01495839360947df321d26dcebd037ef34a641627456f23fd91c63e", size = 2146861, upload-time = "2026-09-04T11:28:10.923Z" }, + { url = "https://files.pythonhosted.org/packages/97/d0/3ac947eb15b7c6fe615e01cdfef8152e8b2509c6d5439f7799fae1ca024c/grimp-3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7e75804d1d7f11813082b30a0cd7cf84f553c55d7c1edc28cde23656f1d1a40", size = 2090629, upload-time = "2026-09-04T11:28:03.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/df/53d3eb18c3197ae2bb6c8be118026870cb89b2c33f99c7157345bc5e0734/grimp-3.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f61d14aedfc0df57db6f7bc826df0f77f3c7115a2a5525aa76531ce2cb9fa6", size = 2277265, upload-time = "2026-09-04T11:27:00.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0", size = 2220699, upload-time = "2026-09-04T11:27:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/39/60/487bb910bb5092a392f8b23ae8c5fde6d766bfe02e2c7a064ae9a14da30a/grimp-3.17-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d55d03f5dc704b18fa7da04e7eebcc3b16b998b1e18818fb750e291b2991900", size = 2371257, upload-time = "2026-09-04T11:27:38.482Z" }, + { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20", size = 2627111, upload-time = "2026-09-04T11:27:19.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24", size = 2347083, upload-time = "2026-09-04T11:27:28.976Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844", size = 2284554, upload-time = "2026-09-04T11:27:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/eb/22/c00c7073a0fce1f9494c564e12705b42a1c421a50515dcf7ffd8e1543024/grimp-3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e205949a5fe6b83ac1ea28525bea2b4867f2b230a851e6dfcdfcfcee1e3dda09", size = 2455050, upload-time = "2026-09-04T11:28:18.529Z" }, + { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83", size = 2496303, upload-time = "2026-09-04T11:28:28.147Z" }, + { url = "https://files.pythonhosted.org/packages/02/54/b4dfc51d8c628f700512953c78656305a21ead767956a7fd7aa46f1021b1/grimp-3.17-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5abd95dc0a8f816d2816e847adb0ab09a7e3571fd0942ea31cf5248505862bac", size = 2528584, upload-time = "2026-09-04T11:28:37.526Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944", size = 2538538, upload-time = "2026-09-04T11:28:47.701Z" }, + { url = "https://files.pythonhosted.org/packages/9d/66/0ff96f79bb1c4ffff621f7b50babbea6151fbaad461327af961f1fa2e49e/grimp-3.17-cp312-cp312-win32.whl", hash = "sha256:b1ab648732d03fa2ec4d7619d934409399bf827009c4c50f429c8d0bdd0709fb", size = 1874539, upload-time = "2026-09-04T11:29:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/a7/72/e2544f4ba4f42f7732bdc13d60c344fd9da30eb083eb8cba2e7c155d222e/grimp-3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d10ae67a1ee0af6dee937e032f3acf707741d866a67f3c71e5ec6df3b758e5c0", size = 1999174, upload-time = "2026-09-04T11:29:09.19Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/9b2da1ff2951e13b636673814ec7df699660f892f284bf2f3d53e991283c/grimp-3.17-cp312-cp312-win_arm64.whl", hash = "sha256:ff161c9aed2ca3db9bc592157a36914ae8440d8d8f3c6986843dec11523cd700", size = 1923198, upload-time = "2026-09-04T11:28:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/b1c76982a239347bfccd2ae95b9f79d3390cd14170f62e7eb822a17d786d/grimp-3.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:eb8f09da4302f05fc92fe7564f27731c0b43789d8893ae5e6be5df2b01668923", size = 2145452, upload-time = "2026-09-04T11:28:12.121Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ab/2a1a9574b391c0ee62cb33a633c0d377dce1b7d19d04a7ac5d5e6872d395/grimp-3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f11566bcf388db4c79f1c7f1051f6ef150f778306350bb7d86d0c9aab32d297", size = 2091654, upload-time = "2026-09-04T11:28:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/e6/12/7dc086132ac10a2409f158f0b0d7cc528a2ebe0a4e95bca895afe7452fa6/grimp-3.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfda3e2ebb4e51beec4a3cb09bb771440f3192163107d9320e6d9ce8fe064e64", size = 2277300, upload-time = "2026-09-04T11:27:01.844Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c", size = 2220417, upload-time = "2026-09-04T11:27:11.418Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/e1b0ad0d0b3d9dff996bad306542f3cf000cb86c368bfe3ac6a8bb1e2e2f/grimp-3.17-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:187b092664e882d10b9f2c3da99251d0aeb99186d407ed5d20f52005832045f6", size = 2370754, upload-time = "2026-09-04T11:27:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5", size = 2628257, upload-time = "2026-09-04T11:27:20.491Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f", size = 2347569, upload-time = "2026-09-04T11:27:30.282Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503", size = 2284227, upload-time = "2026-09-04T11:27:52.462Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/91cd378c08494fd0130e5cc2e54d60d12f53b21850e0dbccf668b6e73d91/grimp-3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a22b5dcbf55f1c348885e09cbe91e63baedf6ce0f89c60bb01a0475c7d0a90b", size = 2454655, upload-time = "2026-09-04T11:28:19.837Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b", size = 2495852, upload-time = "2026-09-04T11:28:29.606Z" }, + { url = "https://files.pythonhosted.org/packages/8f/99/e0690b02be70f346aadfe2aa8bd7e496c773298ac58c0d0fa6376c1298b2/grimp-3.17-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c31660d30e79569faac05033637c1eef4595542f44ec2323af54e900030164a6", size = 2527887, upload-time = "2026-09-04T11:28:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd", size = 2539000, upload-time = "2026-09-04T11:28:49.037Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, + { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, + { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, + { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, + { url = "https://files.pythonhosted.org/packages/83/39/bb07084f0b97da66b0aa38077bd656c4f831b4aa3f167b1c060f6a65ea5e/grimp-3.17-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe3e2285a2bd4e62ddb8825de37152d2dc842394d419786d3d5a803b07bb84eb", size = 2372594, upload-time = "2026-09-04T11:27:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626", size = 2284577, upload-time = "2026-09-04T11:27:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/30/6a/40fb12b111c95b94db4930f35661a629532d9d12fb484db5b5ef1a6c3da3/grimp-3.17-cp315-cp315-win32.whl", hash = "sha256:65a1dc2741bef8a96ff3ff71252db8e99eac2a32e09d5e2b63597474706186ca", size = 1876437, upload-time = "2026-09-04T11:29:25.725Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/79af8d51961611e13b3dabc7a3b356b27aa696e5549bb439f1d6002be31a/grimp-3.17-cp315-cp315-win_amd64.whl", hash = "sha256:d20fbe5aab8b013b351313f8eaa3a46f4ac57677e357cb1c3d1012c606b565b4", size = 1999947, upload-time = "2026-09-04T11:29:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/96/d6/e1922c4c7e001210f98e0cf89bd76fea8564f590be3e9ccfe99435cfcd59/grimp-3.17-cp315-cp315-win_arm64.whl", hash = "sha256:a0288dc0e8947dead5c02a246e16f3f5e5cb486348171bdf1289885a13de36c1", size = 1923774, upload-time = "2026-09-04T11:29:02.897Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/f4f84e44a9237350c41e7c77343498cb1ad843491c18f29d6811021c617d/grimp-3.17-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:1bf67cb05a80796be99d1bf87e12d4421de101950c5246a8220b6c839cbf27c2", size = 2145245, upload-time = "2026-09-04T11:28:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/822287270c41360f66c4b5638e040afc6bbf11e9f04cf5005bbd5f3e4830/grimp-3.17-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d058fdb980475eb9e0143792d63757ddff86c5776e792866e8208cfe5e8413f9", size = 2090367, upload-time = "2026-09-04T11:28:07.277Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/32d303603157a0d98cb8bde8757e6ae8fa02d41f1d62cdf843fb2e7fe727/grimp-3.17-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92f1989ce9f9ce26b7d45381df164c62aff783add84cd7a4216a6ebba0f9f03a", size = 2371543, upload-time = "2026-09-04T11:27:45.108Z" }, + { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae", size = 2288003, upload-time = "2026-09-04T11:27:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/79161f05c900f7f7f1b0acd22a730b1be9f96680dd44efeef90aa45e6998/grimp-3.17-cp315-cp315t-win32.whl", hash = "sha256:a7235ccffb0350647ae2b01969f552a9859fea7027032f6cfed110801269a439", size = 1873249, upload-time = "2026-09-04T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/94/b7/6f9cdeba2c1263fb91b791ddd5e924d6c91c2485f4f16b56698802fdd540/grimp-3.17-cp315-cp315t-win_amd64.whl", hash = "sha256:473d0e0c016ca429b9ec17f34d276db454ba4d467e3c915a46279d86ab004716", size = 2000579, upload-time = "2026-09-04T11:29:15.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/16/8ae9028f046bf8f48cc13f6de2de40adbcbf38fa79a3a5f265753b248da0/grimp-3.17-cp315-cp315t-win_arm64.whl", hash = "sha256:478aa0b696e5dd79373943e75b998fbb4ef3d146f24fe3569fa9d106ce84991b", size = 1921829, upload-time = "2026-09-04T11:29:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/15/22/e89d58bc1c96ec118e371abca8c07d4c6ad53b263e9f00e552b93aa34797/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645f17b80f61e33969d5e8ab30c1fb1247b44d6372c64fc6b50703f438f18186", size = 2287565, upload-time = "2026-09-04T11:27:05.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/13/e1679bdcb44a2ae72300fb7358d2a9fe84de5b96d8d5ef4a605287f73613/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:047955683b4113ce265b00fa4704f92b93e248390768bb45f5968aaafff0c5be", size = 2225542, upload-time = "2026-09-04T11:27:15.167Z" }, + { url = "https://files.pythonhosted.org/packages/99/9d/b53c545f8bb34988d26a1f690ad18228d58fa0dfad29c0353193bf99fab6/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28e88c83f3772681e3aff138fd7865b551d436d25e30d85fbed05f92cf05fefd", size = 2380060, upload-time = "2026-09-04T11:27:46.477Z" }, + { url = "https://files.pythonhosted.org/packages/73/90/2d291b17529adb5fb3fc579dfeb0c7ec5e662e6a9d5eb77d3ea12300daf0/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8129c210bdc73547ec78d11b1dfa48bd74e9da62157b0458c0ed5fe698d6c403", size = 2631413, upload-time = "2026-09-04T11:27:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/a069e17c524d04f9a01d97ad8f2bacd3947af300e5d968dc6854278424b1/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:935beb0f0b920d3629b9da4fcc457f70e76a9fd159eefd7d2c57ab99e712d80e", size = 2351953, upload-time = "2026-09-04T11:27:34.314Z" }, + { url = "https://files.pythonhosted.org/packages/fe/af/8ff5b3ffeb062a5337a8e124a0909cb2866e6448858ffa14bef8cfb8e27d/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:788d5ecf1ab806e18b788952cb205e135e4da4ae00882f552dada144bc6f9b68", size = 2291463, upload-time = "2026-09-04T11:27:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b1/64de4ca73e8c9bd8eed7f6019f18e96f4f8e02417bdb1f9208a3b55662bb/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ff7dcc4746fd8f8b8e8104db19cd15665c61c6bd56dc492e632ac336b07efd77", size = 2463481, upload-time = "2026-09-04T11:28:23.798Z" }, + { url = "https://files.pythonhosted.org/packages/79/7a/dd2c5ef860cecfbcee7cc6eb00c1a2185a8ce12227aa4015b189d7ee1796/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:8575b7a82983f138fd06ebcafce89797fce36f69b6381f79b05714b8b9fcf2fe", size = 2500644, upload-time = "2026-09-04T11:28:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f7/9f3c7f2bef0fdde9a4a3627cf2c9c79e0f6fc3f637d694b384e3fd0237ff/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a88e38dc9e5410a951fd97595816396e3e6f05d2c2a43665a777a9558ba5dde5", size = 2537284, upload-time = "2026-09-04T11:28:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/16/d6/acf7dc5d08ecb24700071c6e807b396603148b98f111df412ea1502606c4/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1334e8ba5d1b57dec70e8990ba4c8c829fc7e999ac4345da45348488dc84b814", size = 2545938, upload-time = "2026-09-04T11:28:53.196Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -94,6 +208,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "import-linter" +version = "2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -104,31 +233,50 @@ wheels = [ ] [[package]] -name = "shall" -version = "0.1.0" -source = { editable = "." } +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, - { name = "tree-sitter" }, - { name = "tree-sitter-language-pack" }, - { name = "typesafe-sdk" }, + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[package.dev-dependencies] -dev = [ - { name = "pytest" }, +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[package.metadata] -requires-dist = [ - { name = "pyyaml", specifier = ">=6" }, - { name = "tree-sitter", specifier = ">=0.25" }, - { name = "tree-sitter-language-pack", specifier = ">=1.0" }, - { name = "typesafe-sdk", specifier = ">=0.7.2,<0.8" }, +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, +] [[package]] name = "packaging" @@ -274,6 +422,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -345,6 +511,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shall" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "tree-sitter" }, + { name = "tree-sitter-language-pack" }, + { name = "typesafe-sdk" }, +] + +[package.dev-dependencies] +check = [ + { name = "import-linter" }, + { name = "pyright", extra = ["nodejs"] }, +] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6" }, + { name = "tree-sitter", specifier = ">=0.25" }, + { name = "tree-sitter-language-pack", specifier = ">=1.0" }, + { name = "typesafe-sdk", specifier = ">=0.7.2,<0.8" }, +] + +[package.metadata.requires-dev] +check = [ + { name = "import-linter", specifier = ">=2.1" }, + { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.400" }, +] +dev = [{ name = "pytest", specifier = ">=8" }] + [[package]] name = "tenacity" version = "9.1.4"