Skip to content

feat(backends): astgrep Python backend v0 — renderer + live-fired matrix + CI firing (python-backend-v0 S1) - #960

Merged
artyhoo merged 4 commits into
stagingfrom
feat/python-backend-s1-astgrep
Jul 10, 2026
Merged

feat(backends): astgrep Python backend v0 — renderer + live-fired matrix + CI firing (python-backend-v0 S1)#960
artyhoo merged 4 commits into
stagingfrom
feat/python-backend-s1-astgrep

Conversation

@artyhoo

@artyhoo artyhoo commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

S1 of the python-backend-v0 umbrella (kickoff, merged in #955): ships backends/astgrep/ — the PRIMARY Python render target of the Convention Compiler and the first non-TS/non-Rust backend. Three commits:

  1. fb07a5fc8 — renderer astgrep-python-yaml (syntax→rendered with native per-rule severity, type-aware/dep-graph→FF7001, malformed params→FF7002; message = node.claim) + SSOT rows fix(ai-laziness-traps): T20 inline-verdict-without-evidence #212 (ast-grep emission ADOPT) / research(planner-completeness): R-phase prior-art survey for L3/L4/L5 #213 (mypy DEFER) / feat(meta-orchestrator): L1 — extend discovery surface beyond kickoff.md #214 (import-linter DEFER).
  2. 00ccacdbb — firing harness: fixtures invalid/valid/valid-clean (canonical ban datetime.datetime.now, suppression # ast-grep-ignore), firing-contract.json (identity ruleId), shared JSON-array parser (backends/shared/json-array-parse.ts, second consumer = ruff in S2; cargo NDJSON untouched), capability-matrix.json with live-fired evidence + toolchain-freshness gate, byte-for-byte self-application drift test (T15).
  3. ecbba6228 — CI live-fire wiring: hard pinned npm install -g @ast-grep/cli@0.44.1 in audit-self.yml before test:backends; firing command reconciled to the bare-PATH binary model (an npx-pin would loud-skip on registry flake — converting the owner's "CI fires for real" STOP-line into #hope-as-gate; a failing install step is loud-red instead). NO !isCI guard (owner decision; cargo remains the documented exception).

§0 volatile facts re-verified live at ship time (T12): @ast-grep/cli@0.44.1 (npm latest), scan --json = JSON array with ruleId identity, suppression syntax, sgconfig.yml layout; ruff facts captured for S2.

Tests: test:backends 108/108 (live-fire genuinely firing), test:principles 303/303. Cold backward-sweep (T21 agent): 13/13 surfaces, no gaps. Final whole-branch review: MERGE-READY.

§1.7 Forward-check applied

This PR introduces the astgrep-python-yaml backend — the first non-TS/non-Rust rule-EMISSION target of the Convention Compiler — plus a shared JSON-array diagnostic parser and a bare-PATH pinned-install CI live-fire model. New conventions and where each is enforced at the earliest reachable channel:

  • New SSOT emission-target verdict (docs/meta-factory/prior-art-evaluations.md#212 ADOPT ast-grep; #213 mypy DEFER; #214 import-linter DEFER) — enforced by principle 08 (packages/core/principles/08-prior-art-cited.test.ts, broken-ref catch) + principle 11 F1 (Prior-art trailer present on all three capability commits).
  • Renderer total-resolution honesty (every node → exactly one RenderOutcome; syntax→rendered, type-aware/dep-graph→FF7001, malformed params→FF7002) — enforced by assertEveryNodeResolved (packages/core/backends/astgrep/render-astgrep.ts:971) + paired-negative tests (packages/core/backends/astgrep/render-astgrep.test.ts:706-784).
  • Capability-matrix mechanized honesty (no cell ≠ 'no' without evidence.kind === 'live-fired' whose captured ruleId matches the firing contract) — validateMatrix (packages/core/backends/shared/capability-matrix.ts) + packages/core/backends/astgrep/capability-matrix.test.ts:320, gated in CI via test:backends.
  • Toolchain-freshness gate (evidence version must equal the ast-grep that actually resolves at test time; no version literal in code) — checkToolchainFreshness (packages/core/backends/astgrep/capability-matrix.test.ts:178) derives the version at runtime and turns RED on a pin bump without evidence regen.
  • CI fires for real (owner STOP-line: no !isCI guard; tool via hard pinned global install, not a package.json dep) — .github/workflows/audit-self.yml:229 (npm install -g @ast-grep/cli@0.44.1, ci-tool-pinning.md Rule A) before the test:backends step at .github/workflows/audit-self.yml:238.
  • Self-application drift (committed fixture YAML must equal render(FIXTURE_NODE) byte-for-byte, T15) — always-on block in packages/core/backends/astgrep/firing.test.ts:557 (runs in CI without the tool).

§1.7 Backward-check applied

Class of this change = add a new per-toolchain rendering backend (renderer + params contract + capability matrix + firing harness + CI live-fire wiring + SSOT emission-target rows). Surfaces where this class occurs = the other backends under packages/core/backends/**, the shared contracts they depend on, and the CI workflows that gate them. Per-surface sweep (evidence = file:line; cold backward-sweep-auditor run 13/13 surfaces, no gaps):

  • packages/core/backends/cargo/** — SWEPT-CLEAN. Routing is the deliberate inverse (cargo syntax→refuse FF7001 packages/core/backends/cargo/render-clippy.ts:81-93; astgrep syntax→render packages/core/backends/astgrep/render-astgrep.ts:945-968); the message === node.claim invariant preserved on both (render-clippy.ts:112render-astgrep.ts:961); cargo's NDJSON parser untouched (packages/core/backends/shared/json-array-parse.ts:15 copies getByJsonPath rather than re-exporting the NDJSON module); cargo's documented !isCI live-fire exception left in place, not generalized. No gap.
  • packages/core/backends/npm/** — SWEPT-CLEAN. ast-grep's flat ruleId identity is the same family as npm/eslint's flat ruleId (packages/core/backends/astgrep/capability-matrix.test.ts:167); npm's own firing-contract shape ({command, expectedRuleId}) not regressed by astgrep's {command, jsonPath, expectedCode}. No gap.
  • packages/core/backends/shared/** — SWEPT-CLEAN. capability-matrix.ts::validateMatrix reused verbatim via the extractIdentity param; render-outcome.ts::assertEveryNodeResolved reused; json-array-parse.ts is net-new and shared by design under the kickoff §1 two-consumer condition (ruff S2 is the second consumer) — additive, disturbs no existing consumer.
  • .github/workflows/audit-self.yml — SWEPT-CLEAN. Pinned-install step added before test:backends (.github/workflows/audit-self.yml:229-238); adjacent cargo comment corrected for accuracy. No other job touched.
  • .github/workflows/discipline-self-check.yml — SWEPT-CLEAN (not edited). Already covers packages/core/backends/** (.github/workflows/discipline-self-check.yml:23) since fix(composition): CTX Stage 0 quick-wins — A12 separator · A14 orphan · X1 CI paths · §3.3 rule frontmatter · DN-4 FF8004 · DN-5 PR template · A15 auditor · A10 supersession #905, so this PR's §1.7 sections are gated. No change needed.
  • packages/core/ir/types.ts (ConventionNode) — SWEPT-CLEAN. Field-freeze honored, no new field (STOP line).
  • packages/core/validator/gate-rule-tester.ts — SWEPT-CLEAN. L4 ast-grep engine deferred-marker (packages/core/validator/gate-rule-tester.ts:87-88) intact, not wired (STOP line, T-PY-C).

Carried forward (non-blocking)

  • obs-2: replacement → fix: YAML emission unit-tested but not live-fired (no fixture with replacement) — carry into S2/S3 fixture coverage.
  • m3/m4: empty-artifact branch has no dedicated test + cosmetic dead computation in renderYaml — triaged OK-to-leave by final review.
  • Operator env note: dev machine's homebrew ast-grep upgraded 0.44.0→0.44.1 to match the pin (freshness gate REDs on mismatched PATH binary by design).

… FF7001/FF7002 refusals (S1 Task 1)

python-backend-v0 umbrella, S1 Task 1. Adds packages/core/backends/astgrep/:

- render-astgrep.ts: pure renderer, backend name astgrep-python-yaml,
  ToolchainBackend<string> conformance (astgrepBackend), mirroring
  backends/cargo/render-clippy.ts seams. Routing is the INVERSE of cargo:
  selectorClass 'syntax' renders (native per-rule severity — defaultSeverity
  projected directly, NO FF7003 degrade path); 'type-aware' refused FF7001
  (mypy backend deferred); 'dep-graph' refused FF7001 (import-linter backend
  deferred); malformed/missing params refused FF7002. Params contract:
  {kind: 'call'|'attribute'|'import', pattern, replacement?}. Rule message
  is ALWAYS node.claim (render-clippy.ts:112 parity). Artifact: ast-grep
  YAML rule-set string (header + one doc per rule, ---separated, id-sorted).
  Emission shape live-verified against @ast-grep/cli@0.44.1
  (scan --json: ruleId identity, exit 1 on error findings, exit 0 clean).
- test-fixtures.ts: FIXTURE_NODE = ban datetime.datetime.now (the P5
  live-fired case, research-patches/2026-07-02-multi-toolchain-generalization.md:409-423).
- render-astgrep.test.ts: 15 unit tests (TDD red-first) — T15 grammar-gate
  self-application, FF7001/FF7002 paired negatives, severity projection
  incl. note→hint, byte-for-byte golden, YAML shape + escaping + sort.

SSOT: appends rows #212 (ast-grep as Python rule-emission target, ADOPT),
#213 (mypy, DEFER — trigger: first type-aware Python node), #214
(import-linter, DEFER — trigger: first dep-graph Python node) to
docs/meta-factory/prior-art-evaluations.md (append-only §3 procedure).

Tests: npm --prefix packages/core run test:backends (9 files / 80 tests
green) + test:principles (33 files / 303 tests green).

Prior-art: prior-art-evaluations.md#212 (ast-grep @ast-grep/cli@0.44.1 as the Python syntax-class rule-emission target, ADOPT — #199 render-target lineage, #185 distinct search-surface capability, P5 live-fire evidence).
… parser + capability matrix (S1 Task 2)

Firing fixtures (invalid/valid/valid-clean) + firing-contract.json + live-fire
firing.test.ts + self-application drift gate + shared JSON-array diagnostic parser
+ capability-matrix.json with live-fired evidence, for the astgrep-python-yaml backend.

- Fixtures: canonical rule bans datetime.datetime.now (FIXTURE_NODE). invalid calls
  it; valid wraps it in an injected-clock accessor + the `# ast-grep-ignore: <rule-id>`
  suppression escape hatch; valid-clean is conformant with zero findings. Each fixture's
  committed rules/no-datetime-now.yml is byte-identical to render(FIXTURE_NODE) — the
  always-on self-application drift test gates it (runs in CI, no tool needed).
- firing.test.ts: presence-check + loud-skip when the pinned ast-grep is unavailable,
  but NO `!isCI` guard — CI fires for real (owner decision). Live-fire RED (invalid ->
  ruleId no-datetime-now) / GREEN (valid + valid-clean -> zero findings).
- Shared backends/shared/json-array-parse.ts: parseIdentitiesFromJsonArray for the
  JSON-array diagnostic shape. Justified per the kickoff §1 two-consumer condition —
  ast-grep (S1) and ruff (S2) both emit JSON arrays with a flat identity field. The
  cargo NDJSON parser is untouched.
- capability-matrix.json: syntax=yes (live-fired evidence, ruleId identity matches the
  firing contract), type-aware=no FF7001, dep-graph=no FF7001. Toolchain-freshness gate
  derives the resolving ast-grep version at run time (no version literal in code) and
  turns RED on a pin bump without evidence regen.

Live-fired against @ast-grep/cli@0.44.1 (2026-07-11): invalid -> 1 finding ruleId
no-datetime-now severity error (exit 1); valid + valid-clean -> [] (exit 0). Review
carry-forwards closed empirically: (m1) a 2-rule `---`-separated multi-document YAML
fired BOTH rules (no-datetime-now + no-os-system) — multi-doc parses; (m2) a
`severity: hint` finding DOES surface in `scan --json` default output (exit 0, not 1),
so the Task-1 note->hint mapping is safe (findings visible, just non-erroring).

ast-grep invocation detail encapsulated in fireContract: `scan` anchors its project
root to the enclosing git repo, so both `-c <config>` and the scan target are passed
as absolute paths under the fixture dir to scope the scan to that fixture's src.py.

Prior-art: prior-art-evaluations.md#212 (ast-grep as Python rule-emission target, ADOPT — this commit ships the S1 firing harness + capability matrix for that emission target; SSOT row added in Task 1 commit fb07a5f).
… firing model (S1 Task 3)

Wire the astgrep-python-yaml backend's live-fire to fire FOR REAL in CI, and
switch the firing model from an npx-resolved pin to a bare PATH binary backed by
a hard, exact-pinned CI install (owner binding decision, Task 2 review follow-up).

- firing-contract.json `command`: `npx -y -p @ast-grep/cli@0.44.1 ast-grep scan
  --json` -> `ast-grep scan --json`. The tool is NOT a package.json dependency; it
  comes via a workflow global install. deriveToolVersion/fireContract token
  handling is form-agnostic (splits on the `ast-grep` bin token), so no logic
  change was needed — only the recorded command and the surrounding rationale
  comments in firing-runner.ts / firing.test.ts / capability-matrix.test.ts.
- audit-self.yml (principles-meta-tests job): add `npm install -g
  @ast-grep/cli@0.44.1` BEFORE the `test:backends` step. Exact pin, no float, no
  `latest` (ci-tool-pinning.md Rule A); no secrets, no third-party action
  (zizmor/no-paid-llm clean).

Rationale (owner STOP-line): an npx-resolved pin converts a registry flake into a
loud-skip — CI goes green without ever firing the backend. A hard install step
fails loud-red instead, keeping "CI fires for real" honest. Kickoff: "Tool comes
via workflow install, NOT a package.json dependency". No `!isCI` guard is
re-introduced (cargo firing.test.ts:31-49 loud-skip shape, kept for the
local-absent-tool path only).

Evidence unchanged: capability-matrix.json still claims `ast-grep 0.44.1`; a live
`ast-grep 0.44.1 scan --json` run against the invalid fixture reproduces the
committed capturedDiagnostic byte-for-byte (byteOffset 38-61, ruleId
no-datetime-now, severity error) modulo the already-normalized repo-relative
`file` field — so no matrix regeneration was required.

Verified: `npm --prefix packages/core run test:backends` green with the 3
live-fire tests FIRING (0.44.1 on PATH; 108 passed) and loud-skipping when the
binary is absent (3 skipped + drift block green); `test:principles` 303 passed.

Prior-art: prior-art-evaluations.md#212 (ast-grep @ast-grep/cli@0.44.1 as the Python rule-EMISSION target, verdict ADOPT — this commit wires that ADOPTed backend's live-fire into CI via a pinned PATH install; no new capability, modifies existing astgrep backend files + the CI gate).
…lobal install

zizmor 1.26.1 (CI-pinned) flags any npm install outside a lockfile; a global
CLI tool has no lockfile surface and the exact @0.44.1 pin is the determinism
mechanism (ci-tool-pinning.md Rule A). Inline-ignore precedent:
demo-regen.yml:62. Verified clean against zizmor==1.26.1 locally.

Prior-art: skipped — CI suppression comment only, no new capability
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants