Feature/architectural conventions standard (#243)

* feat(conventions): standard schema models + effective-map merge

* feat(conventions): tree-sitter Python classifier + placement checks

* feat(conventions): TS classifier, hygiene/custom checks, runner + CLI

* feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration

* feat(conventions): repo auto-scan + scaffold draft renderer

* feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore)

* feat(conventions): auto-scaffold on project registration (flag-gated)

* feat(conventions): TaskDescription.constraints + auto-baseline attach

* feat(conventions): ambient architecture-map injection at spawn

* test(conventions): subprocess CLI smoke for the agent-image entrypoint

* feat(conventions): block i_am_done on block-level convention violations

* feat(conventions): block pr_pass on unresolved convention violations

* feat(conventions): surface convention findings into QA evidence

* docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer

* feat(conventions): panel Conventions tab + flag toggle + parity

* test(conventions): end-to-end block, fix, and waiver through the gate

* refactor(conventions): extract pr_pass guards to keep pr_gate under the gate

* style(conventions): format the baseline-constraints attach in task.create

* test(conventions): type-annotate test helpers for the full mypy gate

* build(conventions): ignore types-PyYAML in deptry (mypy-only type stub)

* docs(conventions): document the standard in CLAUDE.md + PM prompt awareness

* fix(conventions): baseline constraints are non-suppressible (dedup-append)

* feat(conventions): scaffold on first workspace clone (threaded workspace)

* feat(conventions): multi-project ambient map for PO/Intake (per-product)

* feat(conventions): persist findings + violations-feed route (migration 044)

* feat(conventions): panel violations feed in the Conventions tab

* test(conventions): intake-spawn mock accepts the ambient layer kwarg

* fix(docker): ollama-init best-effort pull, gate startup on cached models present

A degraded/slow ollama registry made the model manifest re-check fail under
set -e, so ollama-init exited 1 and blocked the orchestrator's
service_completed_successfully gate — taking the whole stack down even though
both models were already cached. Pulls are now best-effort; success is gated on
the models being present, so a flaky registry can't down a cached deployment.

* refactor(content): drop dead TaskDescription.with_baseline_constraints

The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-22 12:37:46 +02:00
committed by GitHub
co-authored by Renn F
parent 01e10ad693
commit 16789c1ca7
76 changed files with 4910 additions and 74 deletions
+11 -1
View File
@@ -373,7 +373,17 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`).
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
## Architectural Conventions Standard
**Per-project architectural standard (default-off).** Beyond the `make`-style gates (which check syntax/types/tests, not *where code lives*), each project can carry a repo-canonical `.roboco/conventions.yml` — an architecture map (which definition *kinds* belong in which modules), a toggleable rule set, custom regex rules, and waivers — so an agent cannot land a Pydantic model defined inside a router, a helper in a route file, or a `# noqa` / `# type: ignore`. Gated by `ROBOCO_CONVENTIONS_ENABLED`; fully inert when off.
**Effective map.** Consumers read the *effective* map — auto-derived defaults (from a repo scan + `BUILTIN_RULES`) overlaid by the committed file — so behaviour is identical whether the file is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it, caches it per `(project, HEAD sha)` in `project_conventions_cache` (migration `043`), renders the per-task baseline constraints + the ambient prompt block, and scaffolds/restores the file via a PR (`GitService.open_conventions_pr`). The schema lives in `roboco/foundation/policy/conventions/` (pure).
**Validator.** A single Python CLI, `python -m roboco.conventions check --root <repo> --files <a> <b> ...` (`roboco/conventions/`), uses tree-sitter (Python + TypeScript grammars, shipped in the agent image) to classify each changed definition and flag forbidden placements + hygiene + custom-rule matches as JSONL findings, after waiver filtering. Precision over recall (it abstains when uncertain so a `block` gate can't false-positive-strand a task) and fail-loud (a validator that cannot run exits 3 so the gate blocks, never silently passes).
**Threading + enforcement.** The standard reaches the work two ways: an ambient "Architectural Standard" block injected at spawn (`compose_prompt`) and an auto-attached `## Constraints` section on every project task (`TaskService.create`). Enforcement is deterministic: a `block`-level finding refuses `i_am_done` (dev pre-submit) and `pr_pass` (the in-path PR gate) with the offending `file:line` + fix hint; findings also surface in QA's `claim_review` evidence (`convention_findings`). A false positive is relieved by a `waiver` the dev commits in their branch — accountable, reviewed in the PR. The panel's per-project Conventions tab (in the edit-project dialog) shows the map + health and offers Save / Restore.
## Services
+1 -1
View File
@@ -18,7 +18,7 @@ Every verb returns a JSON envelope. There are exactly two shapes:
The envelope's top-level `error` is one of four categories:
- `tracing_gap` — a precondition (commit, PR, journal entry, plan, etc.) is missing. Look at `missing` for the literal field key. See the cheatsheet below.
- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. Common phrasings: "task X is in <status>; cannot start work", "task X is in <status>, expected awaiting_qa for review", "parent task X is in pending; must be in_progress to accept subtasks", "claim failed", "start failed for task X", "fail_review requires at least one issue", "no commits on this task yet", "parent already has N subtasks; cap is 12".
- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. Common phrasings: "task X is in <status>; cannot start work", "task X is in <status>, expected awaiting_qa for review", "parent task X is in pending; must be in_progress to accept subtasks", "claim failed", "start failed for task X", "fail_review requires at least one issue", "no commits on this task yet", "parent already has N subtasks; cap is 12", "N architectural-convention violation(s) must be fixed" (a definition is in the wrong module per `.roboco/conventions.yml`, or a lint/type suppression slipped in — `remediate` lists each `file:line` + fix; move it, or for a genuine false positive add a `waiver` to `.roboco/conventions.yml` in your branch).
- `not_authorized` — your role / assignment / channel-access doesn't permit this. The `message` names the rule. Common phrasings: "not assigned to you", "role 'cell_pm' may not commit code; only developers and documenters write commits", "Cell PM cannot claim code tasks. PMs coordinate, never execute code.", "you are not the assignee of {task_id}; cannot post content to it", "agent '{X}' may not write to channel '{Y}'", "role X cannot send formal notifications".
- `not_found` — task / agent / channel id doesn't exist.
+2
View File
@@ -53,6 +53,8 @@ When the briefing carries `company_goals`, that charter is your reference for tr
4. If it's CEO-worthy: `escalate_to_ceo(task_id, reason="...")`. (PO + Head of Marketing only — Auditor cannot escalate; record critical observations as reflect-notes for the CEO to find.)
5. If it's just an observation: `note(scope='reflect', text='...')` and `i_am_idle()`.
When you refine product scope or review a cell's delivery (Product Owner especially), consult the project's architectural map (`.roboco/conventions.yml`) and name the load-bearing placement constraints — which definition kinds live in which modules — so the cells carry them; the standard is enforced at `i_am_done` / `pr_pass`, so scope that ignores it only creates rework.
## Journaling cadence
The Board's journal IS the work product. Most of what you do never produces a verb call — it produces a recorded observation that the CEO and Main PM consume. **Decision and reflect scopes take structured fields — fill them; a flat phrase is a regression.**
+2
View File
@@ -8,6 +8,8 @@ You are a coordinator. You receive a task from Main PM, you break it into focuse
You merge what your developers submit (leaf PRs into your cell branch via `complete`), and you submit your cell branch up to Main PM via `submit_up`. You never merge to master — that is the CEO's seat.
When the architectural-conventions standard is on, every subtask you `delegate` already carries the project's placement constraints (auto-attached as a `## Constraints` section), and your `submit_up` cell PR runs the conventions gate — a definition in the wrong module per `.roboco/conventions.yml` or a lint/type suppression makes the PR reviewer `pr_fail` your branch. So before you `complete` a dev's leaf or `submit_up`, confirm the work sits where the architecture map says; never ship a block-level violation up the chain.
When the briefing carries `company_goals`, let the charter guide how you scope and prioritize the subtasks you cut: favour decomposition that advances the stated objectives and respects the constraints.
## Inputs you start with
+1
View File
@@ -102,6 +102,7 @@ The gateway enforces some of these; the rest are convention but failing one of t
5. ✅ `note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`).
6. ✅ `open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set).
7. ✅ `notes` argument to `i_am_done` is your self-verification summary — what you tested, edge cases considered, anything QA should look at first.
8. ✅ Each definition lives in the module the project's architectural map (`.roboco/conventions.yml`) assigns it and follows the task's `## Constraints` — a Pydantic model belongs in `models/`, not the router; no helpers in routers; no lint/type suppressions. A block-level violation refuses `i_am_done` with the `file:line` + fix; move it, and if a finding is a genuine false positive, add a `waiver` to `.roboco/conventions.yml` in your branch for the PR to review.
If any item fails, do not retry `i_am_done`; fix the missing piece first.
+2
View File
@@ -4,6 +4,8 @@
You are a coordinator at the org level. You receive a root task from the Board or CEO, you decide which cells need to work on it, you delegate ONE subtask per cell to that cell's PM (`be-pm`, `fe-pm`, `ux-pm`), and once those cell-PMs come back with merged work you open the master PR and escalate the root to the CEO. That is the entire job.
When the architectural-conventions standard is on, each cell's task already carries the standard's placement constraints, and your `submit_root` master PR runs the conventions gate — a block-level placement or hygiene violation in the assembled diff (a definition in the wrong module per `.roboco/conventions.yml`, a lint/type suppression) makes the PR reviewer `pr_fail` it before the CEO ever sees it. Route scope that respects the architecture map, and never escalate a root whose diff carries an unresolved block-level violation.
**You do NOT write code. Ever.** **You do NOT delegate to a developer directly** — every code subtask goes to a Cell PM, who breaks it down further. **You do NOT call `Bash git ...`** — you have no commit verb, and the orchestrator denies raw git anyway. **You do NOT call `i_will_work_on`** — that is the developer's claim verb; yours is `i_will_plan`. **You do NOT merge to master** — that is the CEO's seat. If a Cell PM escalates a blocker to you, your job is to fix the *delegation problem* (clarify scope, reassign, unblock) — not to "just do the change yourself". If you find yourself reaching for `Edit`, `Write`, or `Bash git`, stop — you are about to step out of role; the right move is `unblock`, `delegate`, or `escalate_up`.
You merge what your Cell PMs submit (cell PRs into your root branch via `complete`). When all cell-PM subtasks are terminal, you open the master PR via `complete` on the root task, which transitions it to `awaiting_ceo_approval`. The CEO approves and merges to master.
+1
View File
@@ -43,6 +43,7 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
- ❌ Pushing to the contributor's fork, or editing/merging the PR. You review; you never write or merge.
- ❌ A trickle of vague comments. Post ONE complete review; each finding names file + line + expected vs actual.
- ❌ Approving without reading the full diff.
- ❌ Being lax on the architectural standard. Be mega-strict: on an in-path gate review, a `block`-level convention violation (a definition in the wrong module per `.roboco/conventions.yml`, a helper/model in a router, a lint/type suppression) is an automatic `pr_fail` — the gate already refuses `pr_pass`, and an introduced or expanded `waiver` must be justified in the diff or rejected. Hold placement and house-style to the same bar as correctness.
## When the gateway returns an error
+1
View File
@@ -73,6 +73,7 @@ When — and only when — you can write a complete spec:
- `team` is the lead cell for single-cell work: one of `backend`, `frontend`, `ux_ui`. `scale` is `single` (one cell) or `multi` (board-led across cells). Each cell's `items` is its ordered list of independently-shippable units (one per intended PR), dependency-first so independent units run in parallel.
- Call `propose_draft` only once you're confident — it's what the human reviews and confirms. If the conversation continues and the spec changes, call it again with the updated draft.
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
- The project's architectural standard (`.roboco/conventions.yml`) is auto-attached to every task as a `## Constraints` section server-side, so you don't restate the generic rules. Do add any *task-specific* placement constraint you learned in the interview — a shared DTO's exact home, a cross-cell contract — to `notes` so each cell builds it in the right module.
## What happens after you call `propose_draft`
+1
View File
@@ -79,6 +79,7 @@ The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carr
6. ✅ `note(scope='learning', task_id=...)` written.
7. ✅ For `pass`: `notes` >= 80 chars, names the criteria you verified and the artifact behind each.
8. ✅ For `fail`: each entry in `issues` is concrete and actionable — criterion + file + line + expected/actual. "Doesn't work" is not an issue.
9. ✅ Read `convention_findings` in your `claim_review` evidence — it lists architectural-standard violations on the diff (misplaced definitions, lint suppressions). Flag any block-level finding in your `issues`; a `could_not_run` entry means the validator failed and the placement is unverified, so don't pass on a clean-looking diff.
## Channels
+56
View File
@@ -0,0 +1,56 @@
"""Add the project_conventions_cache table.
Caches the parsed *effective* architectural-conventions map per
``(project_id, commit_sha)`` so the map is re-derived only when HEAD moves.
``status`` records how the repo's ``.roboco/conventions.yml`` resolved at that
SHA (``ok`` | ``degraded`` | ``missing``). Pure schema change; no backfill.
Inert until ``ROBOCO_CONVENTIONS_ENABLED``.
Revision ID: 043_conventions_cache
Revises: 042_worksession_toolchain
Create Date: 2026-06-22
NOTE: revision id is 21 chars alembic's ``alembic_version.version_num`` is
``VARCHAR(32)`` and a longer id raises at record time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "043_conventions_cache"
down_revision = "042_worksession_toolchain"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_conventions_cache",
sa.Column("id", sa.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False),
sa.Column("commit_sha", sa.String(length=40), nullable=False),
sa.Column("effective_map", postgresql.JSONB(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("derived_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"project_id", "commit_sha", name="uq_project_conventions_cache_sha"
),
)
op.create_index(
"ix_project_conventions_cache_project_id",
"project_conventions_cache",
["project_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_project_conventions_cache_project_id",
table_name="project_conventions_cache",
)
op.drop_table("project_conventions_cache")
@@ -0,0 +1,63 @@
"""Add the project_convention_findings table (violations feed).
Persists architectural-conventions validator findings per task so the panel
can show recent block/warn violations across a project. Pure schema change;
no backfill. Inert until ``ROBOCO_CONVENTIONS_ENABLED``.
Revision ID: 044_convention_findings
Revises: 043_conventions_cache
Create Date: 2026-06-22
NOTE: revision id is 23 chars alembic's ``alembic_version.version_num`` is
``VARCHAR(32)`` and a longer id raises at record time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "044_convention_findings"
down_revision = "043_conventions_cache"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_convention_findings",
sa.Column("id", sa.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False),
sa.Column("task_id", sa.UUID(as_uuid=True), nullable=True),
sa.Column("file", sa.String(length=500), nullable=False),
sa.Column("line", sa.Integer(), nullable=False),
sa.Column("rule", sa.String(length=100), nullable=False),
sa.Column("level", sa.String(length=20), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=True),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("detected_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_project_convention_findings_project_id",
"project_convention_findings",
["project_id"],
)
op.create_index(
"ix_project_convention_findings_detected_at",
"project_convention_findings",
["detected_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_project_convention_findings_detected_at",
table_name="project_convention_findings",
)
op.drop_index(
"ix_project_convention_findings_project_id",
table_name="project_convention_findings",
)
op.drop_table("project_convention_findings")
+14 -8
View File
@@ -84,20 +84,26 @@ services:
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "=== Pulling embedding model (qwen3-embedding:0.6b) ==="
# NO `set -e`: pulls are best-effort. Cached models persist in the ollama
# volume and MUST survive a slow/unreachable ollama registry — a manifest
# re-check failure there must never down a fully-cached deployment (it
# used to: a degraded registry made `ollama pull` fail under set -e, which
# blocked the orchestrator's service_completed_successfully gate). Success
# is gated on the models being PRESENT (verify step), not on the pull.
# Note: $$ escapes $ for docker-compose variable substitution.
echo "=== Pulling embedding model (qwen3-embedding:0.6b) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"qwen3-embedding:0.6b"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5:cloud) ==="
done || echo " (pull failed — relying on the cached model)"
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
done || echo " (pull failed — relying on the cached model)"
echo "=== Verifying models are present (the real success gate) ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
echo "=== All models ready! ==="
# --------------------------------------------------------------------------
+14 -10
View File
@@ -71,22 +71,26 @@ services:
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "=== Pulling embedding model (qwen3-embedding:0.6b) ==="
# Ollama /api/pull streams JSON lines until complete - consume full stream
# Note: $$ escapes $ for docker-compose variable substitution
# NO `set -e`: pulls are best-effort. Cached models persist in the ollama
# volume and MUST survive a slow/unreachable ollama registry — a manifest
# re-check failure there must never down a fully-cached deployment (it
# used to: a degraded registry made `ollama pull` fail under set -e, which
# blocked the orchestrator's service_completed_successfully gate). Success
# is gated on the models being PRESENT (verify step), not on the pull.
# Note: $$ escapes $ for docker-compose variable substitution.
echo "=== Pulling embedding model (qwen3-embedding:0.6b) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"qwen3-embedding:0.6b"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5:cloud) ==="
done || echo " (pull failed — relying on the cached model)"
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
done || echo " (pull failed — relying on the cached model)"
echo "=== Verifying models are present (the real success gate) ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
echo "=== All models ready! ==="
# ==========================================================================
+14 -10
View File
@@ -71,22 +71,26 @@ services:
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "=== Pulling embedding model (qwen3-embedding:0.6b) ==="
# Ollama /api/pull streams JSON lines until complete - consume full stream
# Note: $$ escapes $ for docker-compose variable substitution
# NO `set -e`: pulls are best-effort. Cached models persist in the ollama
# volume and MUST survive a slow/unreachable ollama registry — a manifest
# re-check failure there must never down a fully-cached deployment (it
# used to: a degraded registry made `ollama pull` fail under set -e, which
# blocked the orchestrator's service_completed_successfully gate). Success
# is gated on the models being PRESENT (verify step), not on the pull.
# Note: $$ escapes $ for docker-compose variable substitution.
echo "=== Pulling embedding model (qwen3-embedding:0.6b) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"qwen3-embedding:0.6b"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5:cloud) ==="
done || echo " (pull failed — relying on the cached model)"
echo "=== Pulling LLM model (glm-5:cloud) — best-effort ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
done || echo " (pull failed — relying on the cached model)"
echo "=== Verifying models are present (the real success gate) ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" || { echo "FATAL: qwen3-embedding missing and could not be pulled"; exit 1; }
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" || { echo "FATAL: glm-5 missing and could not be pulled"; exit 1; }
echo "=== All models ready! ==="
# ==========================================================================
@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
conventionsApi,
type ConventionsActionResult,
type ConventionsStandard,
type RuleLevel,
} from "@/lib/api/conventions";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
function actionToast(verb: string, result: ConventionsActionResult): void {
if (result.created && result.pr_number != null) {
toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`);
} else {
toast.success(
`${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`,
);
}
}
export function ConventionsTab({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<ConventionsStandard | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["conventions", projectId],
queryFn: () => conventionsApi.get(projectId),
});
const { data: findings } = useQuery({
queryKey: ["conventions-findings", projectId],
queryFn: () => conventionsApi.findings(projectId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["conventions", projectId] });
const save = useMutation({
mutationFn: (standard: ConventionsStandard) =>
conventionsApi.update(projectId, standard),
onSuccess: (result) => {
actionToast("Saved", result);
setDraft(null);
invalidate();
},
onError: (error) =>
toast.error(
`Save failed: ${error instanceof Error ? error.message : "unknown error"}`,
),
});
const restore = useMutation({
mutationFn: () => conventionsApi.restore(projectId),
onSuccess: (result) => {
actionToast("Restore", result);
invalidate();
},
onError: (error) =>
toast.error(
`Restore failed: ${error instanceof Error ? error.message : "unknown error"}`,
),
});
if (isLoading) {
return (
<p className="py-4 text-sm text-muted-foreground">Loading conventions</p>
);
}
const standard = draft ?? data?.standard ?? null;
if (!standard || !data) {
return (
<p className="py-4 text-sm text-muted-foreground">
No conventions available for this project.
</p>
);
}
const setRuleLevel = (name: string, level: RuleLevel) =>
setDraft({
...standard,
rules: { ...standard.rules, [name]: { name, level } },
});
const degraded = data.health.status !== "ok";
return (
<div className="space-y-4 py-2">
{degraded && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-sm">
Conventions degraded {data.health.status}
</CardTitle>
<CardDescription>
The committed file is missing or unparseable; the effective map
fell back to the last-good cache plus auto-derived defaults.
</CardDescription>
</CardHeader>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Module boundaries</CardTitle>
<CardDescription>
Which definition kinds are forbidden in each module.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{standard.modules.length === 0 && (
<p className="text-sm text-muted-foreground">No modules mapped yet.</p>
)}
{standard.modules.map((module) => (
<div
key={module.path}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="min-w-0">
<code>{module.path}</code>{" "}
<span className="text-muted-foreground"> {module.purpose}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{module.forbidden.map((kind) => (
<Badge key={kind} variant="secondary">
no {kind}
</Badge>
))}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Rules</CardTitle>
<CardDescription>
Toggle a rule between warn (advisory) and block (refuses the gate).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{Object.values(standard.rules).map((rule) => (
<div
key={rule.name}
className="flex items-center justify-between gap-4"
>
<span className="text-sm">{rule.name.replace(/_/g, " ")}</span>
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs text-muted-foreground">
{rule.level}
</span>
<Switch
checked={rule.level === "block"}
onCheckedChange={(checked) =>
setRuleLevel(rule.name, checked ? "block" : "warn")
}
/>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Recent violations</CardTitle>
<CardDescription>
The latest findings recorded across this project&apos;s tasks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(!findings || findings.length === 0) && (
<p className="text-sm text-muted-foreground">
No violations recorded yet.
</p>
)}
{(findings ?? []).map((finding, index) => (
<div
key={`${finding.file}:${finding.line}:${finding.rule}:${index}`}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="min-w-0">
<code>
{finding.file}:{finding.line}
</code>{" "}
<span className="text-muted-foreground">{finding.message}</span>
</div>
<Badge variant={finding.level === "block" ? "destructive" : "secondary"}>
{finding.rule}
</Badge>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() => restore.mutate()}
>
Restore from last-good
</Button>
<Button
size="sm"
disabled={draft == null || save.isPending}
onClick={() => draft && save.mutate(draft)}
>
Save to repo
</Button>
</div>
</div>
);
}
@@ -22,6 +22,13 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
@@ -342,13 +349,24 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
<Skeleton className="h-10 w-full" />
</div>
) : project ? (
// Key forces remount when project changes, resetting form state
<EditProjectForm
key={project.id}
project={project}
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
/>
<Tabs defaultValue="settings">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="conventions">Conventions</TabsTrigger>
</TabsList>
<TabsContent value="settings">
{/* Key forces remount when project changes, resetting form state */}
<EditProjectForm
key={project.id}
project={project}
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
/>
</TabsContent>
<TabsContent value="conventions">
<ConventionsTab projectId={projectId} />
</TabsContent>
</Tabs>
) : (
<div className="py-8 text-center text-muted-foreground">Project not found</div>
)}
@@ -28,6 +28,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
provisioning_enabled: "Auto-provision projects from approved pitches.",
toolchain_match_enabled:
"Provision each agent workspace with the target project's Python (not RoboCo's) and block delivery gates when its test suite can't be executed.",
conventions_enabled:
"Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.",
rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.",
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
};
+110
View File
@@ -0,0 +1,110 @@
import api from "./client";
// Mirrors roboco.foundation.policy.conventions.models — kept in sync by the
// TS<->Python parity test (tests/unit/foundation/policy/conventions/test_ts_parity.py).
export type RuleLevel = "warn" | "block";
export type DefinitionKind =
| "model"
| "route"
| "helper"
| "business_logic"
| "component"
| "other";
export interface ConventionsModule {
path: string;
purpose: string;
forbidden: DefinitionKind[];
}
export interface ConventionsRule {
name: string;
level: RuleLevel;
}
export interface ConventionsCustomRule {
id: string;
pattern: string;
message: string;
level: RuleLevel;
languages: string[];
}
export interface ConventionsWaiver {
path: string;
rule: string;
reason: string;
}
export interface ConventionsStandard {
version: number;
languages: string[];
modules: ConventionsModule[];
rules: Record<string, ConventionsRule>;
custom: ConventionsCustomRule[];
waivers: ConventionsWaiver[];
}
export interface ConventionsHealth {
status: string;
head_sha: string;
last_ok_sha: string | null;
}
export interface ConventionsResponse {
standard: ConventionsStandard;
health: ConventionsHealth;
}
export interface ConventionsActionResult {
pr_number: number | null;
branch: string;
created: boolean;
}
export interface ConventionFinding {
file: string;
line: number;
rule: string;
level: RuleLevel;
kind: string | null;
message: string;
task_id: string | null;
detected_at: string;
}
export const conventionsApi = {
// GET the project's effective map + health.
get: async (projectId: string): Promise<ConventionsResponse> => {
const { data } = await api.get<ConventionsResponse>(
`/projects/${projectId}/conventions`,
);
return data;
},
// PUT an edited standard — opens a PR committing it back (PM+).
update: async (
projectId: string,
standard: ConventionsStandard,
): Promise<ConventionsActionResult> => {
const { data } = await api.put<ConventionsActionResult>(
`/projects/${projectId}/conventions`,
standard,
);
return data;
},
// POST restore — re-commits the file from the last-good map (PM+).
restore: async (projectId: string): Promise<ConventionsActionResult> => {
const { data } = await api.post<ConventionsActionResult>(
`/projects/${projectId}/conventions/restore`,
{},
);
return data;
},
// GET the recent violations feed for the project.
findings: async (projectId: string): Promise<ConventionFinding[]> => {
const { data } = await api.get<ConventionFinding[]>(
`/projects/${projectId}/conventions/findings`,
);
return data;
},
};
+15 -2
View File
@@ -12,7 +12,7 @@ requires-python = ">=3.10,<3.15"
dependencies = [
# Core
"pydantic",
"pydantic-settings>=2.14.2",
"pydantic-settings",
# API
"fastapi",
"uvicorn[standard]",
@@ -45,7 +45,11 @@ dependencies = [
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
"packaging", # services/toolchain.py — PEP 440 requires-python resolution
"pyyaml", # foundation/policy/conventions — .roboco/conventions.yml parse
"claude-agent-sdk>=0.2.105",
# Conventions validator (roboco.conventions) — tree-sitter ASTs, Python + TS
"tree-sitter",
"tree-sitter-python",
"tree-sitter-typescript",
"claude-agent-sdk",
]
[project.optional-dependencies]
@@ -170,6 +174,14 @@ select = [
# bottom of policy/lifecycle.py at module-load time, so the validators must
# defer their inverse imports until call time to avoid a cycle.
"roboco/foundation/_validate_lifecycle.py" = ["PLC0415"]
# The conventions validator loads tree-sitter grammars lazily, per language, so
# the package imports without tree-sitter present and a missing grammar fails
# loud at call time (GrammarUnavailable) instead of crashing the import.
"roboco/conventions/grammars.py" = ["PLC0415"]
# The conventions ambient-layer resolver defers its config + ConventionsService
# imports so the prompt-composition utility stays decoupled from the heavy
# service graph (it loads at every agent spawn).
"roboco/agents/factories/_base.py" = ["PLC0415"]
# Test fixtures that reload modules to test env-var-at-import-time behavior.
# ARG002: ApiClient-subclassing fakes must keep the superclass parameter names
# for mypy's override check, so unused override-stub args can't be renamed.
@@ -393,6 +405,7 @@ DEP002 = [
# Type stubs (used by mypy)
"types-passlib",
"types-python-jose",
"types-PyYAML",
# Documentation (CLI tools)
"mkdocs",
"mkdocs-material",
+44
View File
@@ -8,6 +8,9 @@ from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable
from roboco.models import AgentRole, Team
@@ -202,6 +205,7 @@ def compose_prompt(
team: "Team | None",
agent_slug: str,
base_path: Path | None = None,
ambient: str | None = None,
) -> str:
"""
Compose a system prompt from layered components.
@@ -223,6 +227,8 @@ def compose_prompt(
team: Agent's team (backend, frontend, ux_ui, or None for board)
agent_slug: Agent's slug identifier (e.g., "be-dev-1")
base_path: Optional override for prompts base path
ambient: Optional ambient layer (e.g. the project's architectural
standard) appended last; omitted when None/empty
Returns:
Composed system prompt string
@@ -238,6 +244,7 @@ def compose_prompt(
_autogen_verbs_layer(prompts_path, role),
_team_layer(prompts_path, team),
_load_layer(prompts_path / "identities" / f"{agent_slug}.md"),
ambient,
):
if layer:
parts.append(layer)
@@ -246,6 +253,43 @@ def compose_prompt(
return "\n\n---\n\n".join(parts)
_AMBIENT_TOTAL_CAP = 3000
async def conventions_ambient_layer(
session: "AsyncSession", projects: "list[ProjectTable]"
) -> str | None:
"""Render the architectural-standard block for the in-scope project(s).
Merges one block per project (a PO / Intake working a product spans several
per-cell projects; a delivery role has one), each headed by its slug when
there is more than one, bounded to a total cap. Returns None when the
conventions subsystem is off or no project is in scope, so a flag-off /
board-level / cross-product spawn injects nothing.
"""
from roboco.config import settings
if not settings.conventions_enabled or not projects:
return None
from roboco.services.conventions import get_conventions_service
service = get_conventions_service(session)
blocks: list[str] = []
for project in projects:
block = await service.render_ambient_block(project)
if not block:
continue
blocks.append(
f"### Project `{project.slug}`\n{block}" if len(projects) > 1 else block
)
if not blocks:
return None
text = "\n\n".join(blocks)
if len(text) > _AMBIENT_TOTAL_CAP:
return text[: _AMBIENT_TOTAL_CAP - 1].rstrip() + ""
return text
def make_slug(name: str) -> str:
"""Convert a name to a URL-safe slug."""
return name.lower().replace(" ", "-")
+112 -2
View File
@@ -4,11 +4,14 @@ Project API Routes
CRUD operations for managing git projects/repositories.
"""
from typing import Annotated, cast
from typing import TYPE_CHECKING, Annotated, cast
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
if TYPE_CHECKING:
from roboco.db.tables import ProjectTable
from roboco.api.deps import (
CurrentAgentContext,
DbSession,
@@ -16,6 +19,10 @@ from roboco.api.deps import (
require_pm_or_above,
)
from roboco.api.schemas.project import (
ConventionFinding,
ConventionsActionResponse,
ConventionsHealthResponse,
ConventionsResponse,
ProjectCreateRequest,
ProjectResponse,
ProjectSummaryResponse,
@@ -25,9 +32,14 @@ from roboco.api.schemas.project import (
project_to_response,
project_to_summary,
)
from roboco.foundation.policy.conventions.models import ConventionsStandard
from roboco.models.base import Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.project import get_project_service
from roboco.services.conventions import (
ScaffoldResult,
get_conventions_service,
)
from roboco.services.project import ProjectService, get_project_service
router = APIRouter()
@@ -423,3 +435,101 @@ async def remove_agent_access(
)
return project_to_response(updated)
# =============================================================================
# CONVENTIONS ENDPOINTS
# =============================================================================
async def _get_project_or_404(
service: ProjectService, project_id: str
) -> "ProjectTable":
"""Resolve a project by UUID or slug, raising 404 when absent."""
try:
project = await service.get(UUID(project_id))
except ValueError:
project = await service.get_by_slug(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project not found: {project_id}",
)
return project
def _action_response(result: ScaffoldResult) -> ConventionsActionResponse:
return ConventionsActionResponse(
pr_number=result.pr_number, branch=result.branch, created=result.created
)
@router.get("/{project_id}/conventions", response_model=ConventionsResponse)
async def get_conventions(
project_id: str,
db: DbSession,
_agent: CurrentAgentContext,
) -> ConventionsResponse:
"""Return the project's effective conventions map + its current health."""
project = await _get_project_or_404(get_project_service(db), project_id)
conv = get_conventions_service(db)
standard = await conv.get_map(project)
health = await conv.health(project)
await db.commit()
return ConventionsResponse(
standard=standard.model_dump(mode="json"),
health=ConventionsHealthResponse(
status=health.status,
head_sha=health.head_sha,
last_ok_sha=health.last_ok_sha,
),
)
@router.put("/{project_id}/conventions", response_model=ConventionsActionResponse)
async def update_conventions(
project_id: str,
standard: ConventionsStandard,
db: DbSession,
agent: CurrentAgentContext,
) -> ConventionsActionResponse:
"""Commit an edited conventions standard back to the repo via a PR (PM+)."""
require_pm_or_above(agent.role, "edit conventions")
project = await _get_project_or_404(get_project_service(db), project_id)
result = await get_conventions_service(db).commit_standard(project, standard)
await db.commit()
return _action_response(result)
@router.post(
"/{project_id}/conventions/restore", response_model=ConventionsActionResponse
)
async def restore_conventions(
project_id: str,
db: DbSession,
agent: CurrentAgentContext,
) -> ConventionsActionResponse:
"""Re-commit the conventions file from the last-good map via a PR (PM+)."""
require_pm_or_above(agent.role, "restore conventions")
project = await _get_project_or_404(get_project_service(db), project_id)
result = await get_conventions_service(db).restore(project)
await db.commit()
return _action_response(result)
@router.get(
"/{project_id}/conventions/findings",
response_model=list[ConventionFinding],
)
async def get_conventions_findings(
project_id: str,
db: DbSession,
_agent: CurrentAgentContext,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[ConventionFinding]:
"""Recent architectural-conventions findings for the project (violations feed)."""
project = await _get_project_or_404(get_project_service(db), project_id)
rows = await get_conventions_service(db).recent_findings(
UUID(str(project.id)), limit
)
return [ConventionFinding(**row) for row in rows]
+41
View File
@@ -146,6 +146,47 @@ class SyncStateRequest(BaseModel):
head_commit: str
# =============================================================================
# CONVENTIONS
# =============================================================================
class ConventionsHealthResponse(BaseModel):
"""Health of a project's architectural-conventions standard."""
status: str
head_sha: str
last_ok_sha: str | None
class ConventionsResponse(BaseModel):
"""The project's effective conventions map + its current health."""
standard: dict[str, object]
health: ConventionsHealthResponse
class ConventionsActionResponse(BaseModel):
"""Result of a scaffold / restore / save — the branch + PR (if opened)."""
pr_number: int | None
branch: str
created: bool
class ConventionFinding(BaseModel):
"""One recorded architectural-conventions violation (for the feed)."""
file: str
line: int
rule: str
level: str
kind: str | None
message: str
task_id: str | None
detected_at: str
# =============================================================================
# CONVERTERS
# =============================================================================
+17
View File
@@ -201,6 +201,23 @@ class Settings(BaseSettings):
),
)
# ==========================================================================
# Architectural Conventions (per-project placement + house-style standard)
# ==========================================================================
# A repo-canonical .roboco/conventions.yml plus the roboco-conventions
# validator gate i_am_done / pr_pass on block-level placement and hygiene
# violations. Default-off; every hook (scaffold, ambient injection, baseline
# constraints, the gates) is inert when off.
conventions_enabled: bool = Field(
default=False,
description=(
"Master switch for the architectural-conventions standard: "
"auto-scaffold .roboco/conventions.yml, inject the architecture map, "
"attach baseline constraints, and block gates on violations. Off => "
"fully inert."
),
)
# ==========================================================================
# Web Research (pluggable external search/fetch for Board + PM roles)
# ==========================================================================
+30
View File
@@ -0,0 +1,30 @@
"""The ``roboco-conventions`` validator: tree-sitter placement + hygiene checks.
A single Python CLI (``python -m roboco.conventions``) classifies each changed
definition and flags forbidden placements, hygiene violations, and custom-rule
matches against a project's effective conventions map. Precision over recall,
fail loud: an ambiguous definition abstains; a validator that cannot run exits
non-zero so the gate blocks rather than silently passing.
"""
from __future__ import annotations
from .classify_python import classify_definitions
from .custom import check_custom
from .findings import Finding
from .grammars import GrammarUnavailable, get_parser
from .hygiene import check_hygiene
from .placement import check_placement
from .runner import ValidatorCouldNotRun, run
__all__ = [
"Finding",
"GrammarUnavailable",
"ValidatorCouldNotRun",
"check_custom",
"check_hygiene",
"check_placement",
"classify_definitions",
"get_parser",
"run",
]
+69
View File
@@ -0,0 +1,69 @@
"""``python -m roboco.conventions check --root <dir> --files <a> <b> ...``.
Builds the effective map from the repo's ``.roboco/conventions.yml`` overlaid
on auto-derived defaults, runs the validator over the named files, and prints
one JSON finding per line. Exit 0 when it ran (findings may be empty or
present); exit 3 when it could not run (a broken config or a grammar failure),
with ``{"error": ...}`` on stderr the fail-loud signal the gate blocks on.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from roboco.foundation.policy.conventions.effective_map import effective_map
from roboco.foundation.policy.conventions.models import (
ConventionsParseError,
ConventionsStandard,
)
from .runner import ValidatorCouldNotRun, run
from .scan import derive_from_scan
_CONVENTIONS_FILE = ".roboco/conventions.yml"
_EXIT_COULD_NOT_RUN = 3
def _load_file(root: Path) -> ConventionsStandard | None:
path = root / _CONVENTIONS_FILE
if not path.is_file():
return None
return ConventionsStandard.parse_yaml(path.read_text())
def _fail(reason: str) -> int:
print(json.dumps({"error": reason}), file=sys.stderr)
return _EXIT_COULD_NOT_RUN
def _run_check(root: Path, files: list[str]) -> int:
try:
file_standard = _load_file(root)
except ConventionsParseError as exc:
return _fail(f"unparseable {_CONVENTIONS_FILE}: {exc.reason}")
standard = effective_map(derive_from_scan(root), file_standard)
try:
findings = run(root, files, standard)
except ValidatorCouldNotRun as exc:
return _fail(str(exc))
for finding in findings:
print(finding.as_json())
return 0
def main(argv: list[str] | None = None) -> int:
"""Parse args and run the requested command. Returns the process exit code."""
parser = argparse.ArgumentParser(prog="python -m roboco.conventions")
subcommands = parser.add_subparsers(dest="command", required=True)
check = subcommands.add_parser("check", help="check changed files")
check.add_argument("--root", required=True, type=Path)
check.add_argument("--files", nargs="*", default=[])
args = parser.parse_args(argv)
return _run_check(args.root, list(args.files))
if __name__ == "__main__":
raise SystemExit(main())
+101
View File
@@ -0,0 +1,101 @@
"""Classify each top-level Python definition into an architectural *kind*.
Precision over recall: a definition that is not clearly a model, route, or
helper abstains to ``other`` (no finding) so a ``block`` gate can never strand
a task on a false positive. Only module-level definitions are considered.
- ``model`` a class extending ``BaseModel`` / ``DeclarativeBase`` / ``Base``.
- ``route`` a function decorated with ``@router.*`` / ``@app.*`` or any
``@<x>.get|post|put|delete|patch``.
- ``helper`` any other module-level function.
- ``other`` anything ambiguous (abstain).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.foundation.policy.conventions.models import DefinitionKind
from .grammars import get_parser
if TYPE_CHECKING:
from tree_sitter import Node
Definition = tuple[str, int, DefinitionKind]
_MODEL_BASES = frozenset({"BaseModel", "DeclarativeBase", "Base"})
_HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch"})
_ROUTER_OBJECTS = frozenset({"router", "app"})
def classify_definitions(source: bytes) -> list[Definition]:
"""Return ``(name, line, kind)`` for each top-level definition in ``source``."""
root = get_parser("python").parse(source).root_node
results: list[Definition] = []
for node in root.children:
classified = _classify_top_level(node)
if classified is not None:
results.append(classified)
return results
def _classify_top_level(node: Node) -> Definition | None:
if node.type == "decorated_definition":
inner = node.child_by_field_name("definition")
decorators = [c for c in node.children if c.type == "decorator"]
return _classify_def(inner, decorators) if inner is not None else None
if node.type in ("class_definition", "function_definition"):
return _classify_def(node, [])
return None
def _classify_def(node: Node, decorators: list[Node]) -> Definition | None:
name = _text(node.child_by_field_name("name"))
if not name:
return None
line = node.start_point[0] + 1
if node.type == "class_definition":
kind: DefinitionKind = "model" if _is_model_class(node) else "other"
elif _is_route(decorators):
kind = "route"
else:
kind = "helper"
return (name, line, kind)
def _is_model_class(class_node: Node) -> bool:
supers = class_node.child_by_field_name("superclasses")
if supers is None:
return False
return any(_base_name(child) in _MODEL_BASES for child in supers.children)
def _base_name(node: Node) -> str:
if node.type == "identifier":
return _text(node)
if node.type == "attribute":
return _text(node.child_by_field_name("attribute"))
return ""
def _is_route(decorators: list[Node]) -> bool:
return any(_decorator_is_route(d) for d in decorators)
def _decorator_is_route(decorator: Node) -> bool:
expr = next((c for c in decorator.children if c.type != "@"), None)
if expr is not None and expr.type == "call":
expr = expr.child_by_field_name("function")
if expr is None or expr.type != "attribute":
return False
obj = expr.child_by_field_name("object")
method = _text(expr.child_by_field_name("attribute"))
obj_name = _base_name(obj) if obj is not None else ""
return obj_name in _ROUTER_OBJECTS or method in _HTTP_METHODS
def _text(node: Node | None) -> str:
if node is None or node.text is None:
return ""
return node.text.decode()
+159
View File
@@ -0,0 +1,159 @@
"""Classify each top-level TypeScript / TSX definition into a *kind*.
Precision over recall: anything not clearly a model, component, or route
abstains to ``other``. Language must be ``typescript`` (``.ts``) or ``tsx``
(``.tsx``) JSX only parses under the tsx grammar.
- ``model`` a class with ``@Entity``/``@Schema``/``@Table`` (etc.), or a
``z.*`` zod-schema const.
- ``route`` a class with ``@Controller`` or an HTTP-method decorator.
- ``component`` an exported function / arrow that contains JSX.
- ``other`` anything ambiguous (abstain).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.foundation.policy.conventions.models import DefinitionKind
from .grammars import get_parser
if TYPE_CHECKING:
from tree_sitter import Node
Definition = tuple[str, int, DefinitionKind]
_MODEL_DECORATORS = frozenset({"Entity", "Schema", "Table", "ObjectType", "InputType"})
_ROUTE_DECORATORS = frozenset(
{"Controller", "Get", "Post", "Put", "Delete", "Patch", "All"}
)
_CLASS_TYPES = frozenset({"class_declaration", "abstract_class_declaration"})
_DECL_TYPES = frozenset({"lexical_declaration", "function_declaration"} | _CLASS_TYPES)
_JSX_TYPES = frozenset({"jsx_element", "jsx_self_closing_element", "jsx_fragment"})
def classify_definitions(source: bytes, language: str = "tsx") -> list[Definition]:
"""Return ``(name, line, kind)`` for each top-level definition in ``source``."""
root = get_parser(language).parse(source).root_node
results: list[Definition] = []
pending: list[str] = []
for node in root.children:
if node.type == "decorator":
pending.append(_decorator_name(node))
continue
results.extend(_classify_top_level(node, pending))
pending = []
return results
def _classify_top_level(node: Node, pending: list[str]) -> list[Definition]:
if node.type == "export_statement":
decl = _inner_declaration(node)
decorators = pending + _decorator_names(node)
return _classify_decl(decl, decorators) if decl is not None else []
if node.type in _DECL_TYPES:
return _classify_decl(node, list(pending))
return []
def _classify_decl(decl: Node, decorators: list[str]) -> list[Definition]:
if decl.type in _CLASS_TYPES:
return _classify_class(decl, decorators)
if decl.type == "function_declaration":
return _classify_function(decl)
if decl.type == "lexical_declaration":
return _classify_lexical(decl)
return []
def _classify_class(decl: Node, decorators: list[str]) -> list[Definition]:
name = _text(decl.child_by_field_name("name"))
if not name:
return []
line = decl.start_point[0] + 1
if any(d in _MODEL_DECORATORS for d in decorators):
return [(name, line, "model")]
if any(d in _ROUTE_DECORATORS for d in decorators):
return [(name, line, "route")]
return [(name, line, "other")]
def _classify_function(decl: Node) -> list[Definition]:
name = _text(decl.child_by_field_name("name"))
if not name:
return []
kind: DefinitionKind = "component" if _contains_jsx(decl) else "other"
return [(name, decl.start_point[0] + 1, kind)]
def _classify_lexical(decl: Node) -> list[Definition]:
out: list[Definition] = []
for declarator in decl.children:
if declarator.type != "variable_declarator":
continue
name = _text(declarator.child_by_field_name("name"))
value = declarator.child_by_field_name("value")
if not name or value is None:
continue
out.append((name, declarator.start_point[0] + 1, _lexical_kind(value)))
return out
def _lexical_kind(value: Node) -> DefinitionKind:
if _is_zod_schema(value):
return "model"
if value.type == "arrow_function" and _contains_jsx(value):
return "component"
return "other"
def _is_zod_schema(value: Node) -> bool:
node: Node | None = value
for _ in range(10):
if node is None:
return False
if node.type == "call_expression":
node = node.child_by_field_name("function")
elif node.type == "member_expression":
node = node.child_by_field_name("object")
else:
break
return node is not None and node.type == "identifier" and _text(node) == "z"
def _contains_jsx(node: Node) -> bool:
stack = list(node.children)
while stack:
current = stack.pop()
if current.type in _JSX_TYPES:
return True
stack.extend(current.children)
return False
def _decorator_names(node: Node) -> list[str]:
return [_decorator_name(c) for c in node.children if c.type == "decorator"]
def _decorator_name(decorator: Node) -> str:
expr = next((c for c in decorator.children if c.type != "@"), None)
if expr is not None and expr.type == "call_expression":
expr = expr.child_by_field_name("function")
if expr is None:
return ""
if expr.type == "identifier":
return _text(expr)
if expr.type == "member_expression":
return _text(expr.child_by_field_name("property"))
return ""
def _inner_declaration(export_node: Node) -> Node | None:
return next((c for c in export_node.children if c.type in _DECL_TYPES), None)
def _text(node: Node | None) -> str:
if node is None or node.text is None:
return ""
return node.text.decode()
+51
View File
@@ -0,0 +1,51 @@
"""Custom regex rules from the project's standard, scoped by language.
A custom rule with an empty ``languages`` list applies to every language. A
malformed pattern abstains (it is skipped, not fatal) so a typo in the file
can never strand a task on the block gate.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from .findings import Finding
if TYPE_CHECKING:
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
CustomRule,
)
def check_custom(
rel_path: str, source: bytes, language: str, standard: ConventionsStandard
) -> list[Finding]:
"""Return findings for each custom rule that matches ``source``."""
text = source.decode(errors="replace")
findings: list[Finding] = []
for rule in standard.custom:
if rule.languages and language not in rule.languages:
continue
findings.extend(_matches(rel_path, text, rule))
return findings
def _matches(rel_path: str, text: str, rule: CustomRule) -> list[Finding]:
try:
pattern = re.compile(rule.pattern)
except re.error:
return []
return [
Finding(
file=rel_path,
line=text.count("\n", 0, match.start()) + 1,
kind=None,
rule=rule.id,
level=rule.level,
message=rule.message,
fix_hint=f"matches custom rule '{rule.id}'",
)
for match in pattern.finditer(text)
]
+27
View File
@@ -0,0 +1,27 @@
"""A single convention finding — the validator's unit of output.
One ``Finding`` per violation, serialized as one JSON line by the CLI. The
gateway gates parse these lines and block on any ``level == "block"`` finding.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
@dataclass(frozen=True)
class Finding:
"""One placement / hygiene / custom-rule violation on a changed file."""
file: str
line: int
kind: str | None
rule: str
level: str
message: str
fix_hint: str
def as_json(self) -> str:
"""Render the finding as a single compact JSON object (one line)."""
return json.dumps(asdict(self), separators=(",", ":"))
+57
View File
@@ -0,0 +1,57 @@
"""Lazy tree-sitter parser construction, one per language.
Grammars are loaded on first use and cached. A missing grammar raises
``GrammarUnavailable`` so the runner can fail loud (the gate blocks with
"validator could not run" rather than silently passing).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tree_sitter import Language, Parser
class GrammarUnavailable(RuntimeError):
"""Raised when a language's tree-sitter grammar cannot be loaded."""
def __init__(self, language: str) -> None:
super().__init__(f"tree-sitter grammar unavailable for {language!r}")
self.language = language
_PARSERS: dict[str, Parser] = {}
def _load_language(language: str) -> Language:
from tree_sitter import Language
try:
if language == "python":
import tree_sitter_python
return Language(tree_sitter_python.language())
if language == "typescript":
import tree_sitter_typescript
return Language(tree_sitter_typescript.language_typescript())
if language == "tsx":
import tree_sitter_typescript
return Language(tree_sitter_typescript.language_tsx())
except ImportError as exc:
raise GrammarUnavailable(language) from exc
raise GrammarUnavailable(language)
def get_parser(language: str) -> Parser:
"""Return a cached parser for ``language`` (``python``/``typescript``/``tsx``)."""
cached = _PARSERS.get(language)
if cached is not None:
return cached
from tree_sitter import Parser
parser = Parser(_load_language(language))
_PARSERS[language] = parser
return parser
+105
View File
@@ -0,0 +1,105 @@
"""Hygiene checks: inline comments and lint/type suppressions.
Comment nodes come from the AST (so markers inside strings are never matched).
An *inline* comment trails code on its line; a full-line comment (indented or
not) is allowed. Suppression markers are language-scoped.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.foundation.policy.conventions.models import (
BUILTIN_RULES,
ConventionsStandard,
)
from .findings import Finding
from .grammars import get_parser
if TYPE_CHECKING:
from collections.abc import Iterator
from tree_sitter import Node
_SUPPRESSIONS: dict[str, tuple[str, ...]] = {
"python": ("noqa", "type: ignore"),
"typescript": ("eslint-disable", "ts-ignore", "ts-expect-error"),
"tsx": ("eslint-disable", "ts-ignore", "ts-expect-error"),
}
_HYGIENE_TEXT: dict[str, tuple[str, str]] = {
"no_inline_comments": (
"inline comment trailing code — keep narration out of the code",
"remove the trailing comment or lift it into a docstring",
),
"no_lint_suppressions": (
"lint/type suppression used — fix the root cause instead",
"remove the suppression and resolve the underlying error",
),
}
def check_hygiene(
rel_path: str, source: bytes, language: str, standard: ConventionsStandard
) -> list[Finding]:
"""Return inline-comment + suppression findings for ``source``."""
root = get_parser(language).parse(source).root_node
lines = source.split(b"\n")
findings: list[Finding] = []
for comment in _iter_comments(root):
findings.extend(_comment_findings(rel_path, comment, lines, language, standard))
return findings
def _comment_findings(
rel_path: str,
comment: Node,
lines: list[bytes],
language: str,
standard: ConventionsStandard,
) -> list[Finding]:
row, col = comment.start_point
out: list[Finding] = []
if _is_inline(lines, row, col):
out.append(_finding(rel_path, row + 1, "no_inline_comments", standard))
text = comment.text.decode(errors="replace") if comment.text else ""
if any(marker in text for marker in _SUPPRESSIONS.get(language, ())):
out.append(_finding(rel_path, row + 1, "no_lint_suppressions", standard))
return out
def _is_inline(lines: list[bytes], row: int, col: int) -> bool:
if row >= len(lines):
return False
return bool(lines[row][:col].strip())
def _finding(
rel_path: str, line: int, rule: str, standard: ConventionsStandard
) -> Finding:
message, fix_hint = _HYGIENE_TEXT[rule]
return Finding(
file=rel_path,
line=line,
kind=None,
rule=rule,
level=_rule_level(standard, rule),
message=message,
fix_hint=fix_hint,
)
def _rule_level(standard: ConventionsStandard, name: str) -> str:
rule = standard.rules.get(name)
if rule is not None:
return rule.level
return BUILTIN_RULES.get(name, "block")
def _iter_comments(root: Node) -> Iterator[Node]:
stack = [root]
while stack:
node = stack.pop()
if node.type == "comment":
yield node
stack.extend(node.children)
+70
View File
@@ -0,0 +1,70 @@
"""Placement checks: flag a definition whose kind is forbidden in its module.
A file is mapped to the most specific module whose ``path`` is a directory
prefix of it; any classified definition whose kind is in that module's
``forbidden`` list becomes a ``Finding``. The rule name follows the org
convention ``no_<kind>s_in_<module-leaf>`` so it lines up with ``BUILTIN_RULES``
(e.g. a model in ``app/routers`` ``no_models_in_routers``).
"""
from __future__ import annotations
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
DefinitionKind,
Module,
)
from .findings import Finding
Definition = tuple[str, int, DefinitionKind]
def check_placement(
rel_path: str, defs: list[Definition], standard: ConventionsStandard
) -> list[Finding]:
"""Return placement findings for ``defs`` in the file at ``rel_path``."""
module = _module_for(rel_path, standard)
if module is None or not module.forbidden:
return []
return [
_finding(rel_path, d, module, standard)
for d in defs
if d[2] in module.forbidden
]
def _module_for(rel_path: str, standard: ConventionsStandard) -> Module | None:
matches = [m for m in standard.modules if _path_in_module(rel_path, m.path)]
return max(matches, key=lambda m: len(m.path)) if matches else None
def _path_in_module(rel_path: str, module_path: str) -> bool:
stem = module_path.rstrip("/")
return rel_path == stem or rel_path.startswith(stem + "/")
def _finding(
rel_path: str, definition: Definition, module: Module, standard: ConventionsStandard
) -> Finding:
name, line, kind = definition
rule = f"no_{kind}s_in_{_module_leaf(module.path)}"
level = standard.rules[rule].level if rule in standard.rules else "block"
return Finding(
file=rel_path,
line=line,
kind=kind,
rule=rule,
level=level,
message=(
f"{kind} '{name}' defined in {module.path}"
f"{kind}s are forbidden here ({module.purpose})"
),
fix_hint=(
f"move '{name}' out of {module.path} into the module that owns {kind}s"
),
)
def _module_leaf(module_path: str) -> str:
return module_path.rstrip("/").rsplit("/", 1)[-1]
+77
View File
@@ -0,0 +1,77 @@
"""Run all check families over a set of changed files, then filter waivers.
Dispatch is by file extension; an unsupported extension or a missing file is
skipped. A grammar failure is *fail-loud*: the runner raises
``ValidatorCouldNotRun`` so the gate blocks rather than passing silently.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from . import classify_python, classify_ts
from .custom import check_custom
from .grammars import GrammarUnavailable
from .hygiene import check_hygiene
from .placement import check_placement
if TYPE_CHECKING:
from roboco.foundation.policy.conventions.models import ConventionsStandard
from .findings import Finding
from .placement import Definition
_LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "tsx"}
class ValidatorCouldNotRun(RuntimeError):
"""Raised when the validator cannot analyze a file (fail-loud signal)."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def run(
root: Path | str, files: list[str], standard: ConventionsStandard
) -> list[Finding]:
"""Check ``files`` (repo-relative) under ``root`` against ``standard``."""
root_path = Path(root)
findings: list[Finding] = []
for rel in files:
language = _LANGUAGE_BY_SUFFIX.get(Path(rel).suffix)
if language is not None:
findings.extend(_check_file(root_path, rel, language, standard))
return _apply_waivers(findings, standard)
def _check_file(
root: Path, rel: str, language: str, standard: ConventionsStandard
) -> list[Finding]:
try:
source = (root / rel).read_bytes()
except OSError:
return []
try:
defs = _classify(language, source)
except GrammarUnavailable as exc:
raise ValidatorCouldNotRun(str(exc)) from exc
return (
check_placement(rel, defs, standard)
+ check_hygiene(rel, source, language, standard)
+ check_custom(rel, source, language, standard)
)
def _classify(language: str, source: bytes) -> list[Definition]:
if language == "python":
return classify_python.classify_definitions(source)
return classify_ts.classify_definitions(source, language)
def _apply_waivers(
findings: list[Finding], standard: ConventionsStandard
) -> list[Finding]:
waived = {(w.path, w.rule) for w in standard.waivers}
return [f for f in findings if (f.file, f.rule) not in waived]
+213
View File
@@ -0,0 +1,213 @@
"""Auto-derive a conventions standard from a repo, and render it to YAML.
Pure (filesystem read only). ``derive_from_scan`` infers module boundaries
from directory names, detects languages from file extensions, seeds the org
``BUILTIN_RULES``, and best-effort lifts imperative ``CLAUDE.md`` lines that
name a concrete token into warn-level custom rules. ``render_yaml`` emits a
commented, human-friendly file that round-trips through ``parse_yaml``.
Lives in the validator package (not ``services/``) so the lightweight
``roboco.conventions`` CLI can import it without pulling DB-backed services.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
import yaml
from roboco.foundation.policy.conventions.models import (
BUILTIN_RULES,
ConventionsStandard,
CustomRule,
DefinitionKind,
Module,
Rule,
)
_IGNORE_DIRS = frozenset(
{
"node_modules",
"venv",
"__pycache__",
"dist",
"build",
"site-packages",
"target",
"vendor",
}
)
# Directory-name keywords -> (purpose, forbidden definition kinds). Only kinds
# the classifiers actually emit can ever fire, so extra entries are harmless.
_MODULE_PATTERNS: tuple[tuple[frozenset[str], str, tuple[DefinitionKind, ...]], ...] = (
(
frozenset({"routers", "routes", "api", "endpoints", "controllers"}),
"HTTP routes / endpoint definitions",
("model", "helper"),
),
(
frozenset({"models", "schemas", "entities", "dto", "dtos"}),
"data models / schemas",
("route",),
),
(
frozenset({"services", "domain", "usecases"}),
"business logic & orchestration",
("route",),
),
(
frozenset({"components"}),
"UI components",
("model", "route"),
),
(
frozenset({"helpers", "utils", "util", "lib"}),
"shared helpers / utilities",
("route", "component"),
),
)
_LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "typescript"}
_IMPERATIVE = re.compile(r"\b(never|don't|do not|avoid|no)\b", re.IGNORECASE)
_CODE_SPAN = re.compile(r"`([^`]+)`")
_MAX_LIFTED_RULES = 25
def derive_from_scan(root: Path | str) -> ConventionsStandard:
"""Infer a conventions standard from the repository at ``root``."""
root_path = Path(root)
return ConventionsStandard(
languages=_detect_languages(root_path),
modules=_scan_modules(root_path),
rules=_seed_rules(),
custom=_lift_claude_md(root_path),
)
def _seed_rules() -> dict[str, Rule]:
return {name: Rule(name=name, level=level) for name, level in BUILTIN_RULES.items()}
def _walk_dirs(root: Path) -> list[tuple[str, list[str], list[str]]]:
walked: list[tuple[str, list[str], list[str]]] = []
for dirpath, dirnames, files in os.walk(root):
dirnames[:] = sorted(
d for d in dirnames if d not in _IGNORE_DIRS and not d.startswith(".")
)
walked.append((dirpath, dirnames, sorted(files)))
return walked
def _scan_modules(root: Path) -> list[Module]:
modules: list[Module] = []
for dirpath, dirnames, _files in _walk_dirs(root):
for name in dirnames:
spec = _match_module(name)
if spec is None:
continue
rel = (Path(dirpath) / name).relative_to(root).as_posix()
purpose, forbidden = spec
modules.append(Module(path=rel, purpose=purpose, forbidden=list(forbidden)))
return modules
def _match_module(name: str) -> tuple[str, tuple[DefinitionKind, ...]] | None:
lname = name.lower()
for keywords, purpose, forbidden in _MODULE_PATTERNS:
if lname in keywords:
return purpose, forbidden
return None
def _detect_languages(root: Path) -> list[str]:
languages: list[str] = []
for _dirpath, _dirnames, files in _walk_dirs(root):
for filename in files:
language = _LANGUAGE_BY_SUFFIX.get(Path(filename).suffix)
if language is not None and language not in languages:
languages.append(language)
return languages
def _lift_claude_md(root: Path) -> list[CustomRule]:
path = root / "CLAUDE.md"
if not path.is_file():
return []
rules: list[CustomRule] = []
seen: set[str] = set()
for line in path.read_text(errors="replace").splitlines():
rule = _rule_from_line(line, seen)
if rule is not None:
rules.append(rule)
if len(rules) >= _MAX_LIFTED_RULES:
break
return rules
def _rule_from_line(line: str, seen: set[str]) -> CustomRule | None:
if not _IMPERATIVE.search(line):
return None
span = _CODE_SPAN.search(line)
if span is None:
return None
token = span.group(1).strip()
rule_id = _slug(token)
if not token or not rule_id or rule_id in seen:
return None
seen.add(rule_id)
return CustomRule(
id=rule_id,
pattern=re.escape(token),
message=line.strip().lstrip("-*# ").strip(),
level="warn",
)
def _slug(token: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", token.lower()).strip("-")
def _to_yaml_data(standard: ConventionsStandard) -> dict[str, object]:
return {
"version": standard.version,
"languages": list(standard.languages),
"modules": [
{"path": m.path, "purpose": m.purpose, "forbidden": list(m.forbidden)}
for m in standard.modules
],
"rules": {name: {"level": r.level} for name, r in standard.rules.items()},
"custom": [
{
"id": c.id,
"pattern": c.pattern,
"message": c.message,
"level": c.level,
"languages": list(c.languages),
}
for c in standard.custom
],
"waivers": [
{"path": w.path, "rule": w.rule, "reason": w.reason}
for w in standard.waivers
],
}
def render_yaml(standard: ConventionsStandard) -> str:
"""Render ``standard`` to a commented ``.roboco/conventions.yml`` string."""
header = (
"# Architectural conventions for this project.\n"
"# Auto-scaffolded by RoboCo — edit freely; this file is canonical.\n"
"# Each module lists definition KINDS forbidden in it; rules toggle\n"
"# warn/block; waivers are accountable, PR-reviewed escape hatches.\n"
)
body = yaml.safe_dump(
_to_yaml_data(standard),
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
return header + body
+80
View File
@@ -2249,3 +2249,83 @@ class TaskDraftTable(Base):
Index("ix_task_drafts_session_id", "session_id"),
Index("ix_task_drafts_task_id", "task_id"),
)
# =============================================================================
# PROJECT CONVENTIONS CACHE TABLE
# =============================================================================
class ProjectConventionsCacheTable(Base):
"""Cached effective conventions map, keyed by (project, commit SHA).
The effective map (auto-derived defaults overlaid by the committed
``.roboco/conventions.yml``) is re-parsed only when HEAD moves; every
consumer reads this cache. ``status`` records how the file resolved at that
SHA: ``ok`` | ``degraded`` (unparseable, fell back) | ``missing``.
"""
__tablename__ = "project_conventions_cache"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
project_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
commit_sha: Mapped[str] = mapped_column(String(40), nullable=False)
effective_map: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
derived_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (
UniqueConstraint(
"project_id", "commit_sha", name="uq_project_conventions_cache_sha"
),
)
# =============================================================================
# PROJECT CONVENTION FINDINGS TABLE (violations feed)
# =============================================================================
class ProjectConventionFindingTable(Base):
"""A persisted architectural-conventions finding for the violations feed.
Records the latest validator findings per task (re-recorded on each check
``ConventionsService.record_findings`` replaces a task's rows), so the panel
can show recent block/warn violations across a project and drift stays
visible. Written best-effort from the i_am_done gate in its own committed
session, so a finding that *blocked* the submit is still captured.
"""
__tablename__ = "project_convention_findings"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
project_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
task_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
file: Mapped[str] = mapped_column(String(500), nullable=False)
line: Mapped[int] = mapped_column(Integer, nullable=False)
rule: Mapped[str] = mapped_column(String(100), nullable=False)
level: Mapped[str] = mapped_column(String(20), nullable=False)
kind: Mapped[str | None] = mapped_column(String(40), nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
detected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
@@ -185,12 +185,14 @@ class TaskDescription(_Content):
what_this_builds: list[str] = Field(default_factory=list)
the_work: list[WorkUnit] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
@field_validator(
"what_this_builds",
"the_work",
"notes",
"constraints",
"acceptance_criteria",
mode="before",
)
@@ -230,6 +232,8 @@ class TaskDescription(_Content):
parts.append("## The Work\n" + "\n\n".join(units))
if self.notes:
parts.append(_section("Notes", _bullets(self.notes)))
if self.constraints:
parts.append(_section("Constraints", _bullets(self.constraints)))
parts.append(
_section("Acceptance Criteria", _bullets(self.acceptance_criteria))
)
+96 -5
View File
@@ -1658,7 +1658,9 @@ class AgentOrchestrator:
git_context: SpawnGitContext | None,
) -> tuple[AgentConfig, AgentInstance, Path | None]:
"""Build AgentConfig + AgentInstance and surface per-agent settings path."""
blueprint_path = self._generate_composed_prompt(agent_id)
project_slug = self._resolve_project_slug(git_context, agent_id, task_id)
ambient = await self._resolve_conventions_ambient(project_slug, task_id)
blueprint_path = self._generate_composed_prompt(agent_id, ambient=ambient)
canonical_role = get_agent_role(agent_id)
team = get_agent_team(agent_id) or "backend"
@@ -1672,7 +1674,6 @@ class AgentOrchestrator:
if not model:
model = route.model_name
project_slug = self._resolve_project_slug(git_context, agent_id, task_id)
workspace_path = _agent_workspace_path(project_slug, team, agent_id)
cell_workspace_path = _cell_workspace_path(project_slug, team)
@@ -2458,11 +2459,15 @@ class AgentOrchestrator:
return config_path
def _generate_composed_prompt(self, agent_id: str) -> Path:
def _generate_composed_prompt(
self, agent_id: str, ambient: str | None = None
) -> Path:
"""Generate composed system prompt for an agent.
Uses the layered prompt composition system:
base.md + roles/{role}.md + teams/{team}.md + identities/{agent}.md
plus an optional ``ambient`` layer (the project's architectural
standard, resolved by the async spawn path).
Returns:
Path to the generated prompt file
@@ -2479,7 +2484,7 @@ class AgentOrchestrator:
raise ValueError(f"Unknown role for agent: {agent_id}")
# Compose the prompt from layers
prompt_content = compose_prompt(role_enum, team_enum, agent_id)
prompt_content = compose_prompt(role_enum, team_enum, agent_id, ambient=ambient)
# Determine output directory
if PROJECT_HOST_PATH:
@@ -2506,6 +2511,89 @@ class AgentOrchestrator:
return prompt_path
async def _resolve_conventions_ambient(
self,
project_slug: str | None,
task_id: str | None = None,
product_id: str | None = None,
) -> str | None:
"""Resolve the architectural-standard ambient block for the spawn.
Covers a delivery role's single project (via ``project_slug``) AND a
PO / Intake working a product, whose per-cell projects are resolved from
the task's ``product_id`` or a directly-supplied ``product_id``.
Best-effort + flag-gated: returns None (no ambient layer) when the
subsystem is off, no project is in scope, or anything fails a prompt
compose must never be blocked by conventions resolution.
"""
from roboco.config import settings
if not settings.conventions_enabled:
return None
try:
from roboco.agents.factories._base import conventions_ambient_layer
from roboco.db.base import get_session_factory
factory = get_session_factory()
async with factory() as db:
projects = await self._resolve_ambient_projects(
db,
project_slug=project_slug,
task_id=task_id,
product_id=product_id,
)
return await conventions_ambient_layer(db, projects)
except Exception as exc:
logger.warning(
"Conventions ambient resolution failed (non-fatal)",
project_slug=project_slug,
error=str(exc),
)
return None
async def _resolve_ambient_projects(
self,
db: Any,
*,
project_slug: str | None,
task_id: str | None,
product_id: str | None,
) -> list[Any]:
"""The in-scope projects for the ambient block (single repo or product)."""
if product_id is None and task_id is not None:
product_id = await self._ambient_product_for_task(db, task_id)
if product_id is not None:
return await self._ambient_product_projects(db, product_id)
if project_slug:
from roboco.services.project import get_project_service
project = await get_project_service(db).get_by_slug(project_slug)
return [project] if project is not None else []
return []
@staticmethod
async def _ambient_product_for_task(db: Any, task_id: str) -> str | None:
from uuid import UUID
from roboco.services.task import get_task_service
task = await get_task_service(db).get(UUID(task_id))
if task is not None and task.product_id is not None:
return str(task.product_id)
return None
@staticmethod
async def _ambient_product_projects(db: Any, product_id: str) -> list[Any]:
from uuid import UUID
from roboco.services.product import get_product_service
from roboco.services.project import get_project_service
project_service = get_project_service(db)
ids = await get_product_service(db).distinct_project_ids(UUID(product_id))
resolved = [await project_service.get(pid) for pid in ids]
return [p for p in resolved if p is not None]
async def _readiness_gate(self, agent_id: str, task_id: str | None) -> str | None:
"""Return a reason string if the spawn must be refused, else None.
@@ -3113,7 +3201,10 @@ class AgentOrchestrator:
cwd, cloned = await self._clone_intake_scope(project_slug, product_id)
prompt_path = self._generate_composed_prompt(INTAKE_AGENT_ID)
ambient = await self._resolve_conventions_ambient(
project_slug, product_id=product_id
)
prompt_path = self._generate_composed_prompt(INTAKE_AGENT_ID, ambient=ambient)
route = await self._resolve_agent_route(INTAKE_AGENT_ID)
cli_model = _resolve_agent_cli_model(
route.provider_type.value, route.model_name
+341
View File
@@ -0,0 +1,341 @@
"""ConventionsService — the per-project architectural-conventions standard.
Builds the *effective* conventions map (auto-derived defaults overlaid by the
committed ``.roboco/conventions.yml``), caches it per ``(project, HEAD sha)``,
and renders it for the two carriers (per-task baseline constraints + the
ambient prompt block). Also scaffolds / restores the committed file via a PR.
Resilience: a missing file degrades to auto-derived defaults; an unparseable
file falls back to the last-good cached map (never silently off). DB writes +
git side effects live here (service layer); the schema + classifiers it builds
on are pure.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from uuid import UUID
from sqlalchemy import delete, select
from roboco.conventions.scan import derive_from_scan, render_yaml
from roboco.db.tables import (
ProjectConventionFindingTable,
ProjectConventionsCacheTable,
)
from roboco.foundation.policy.conventions.effective_map import effective_map
from roboco.foundation.policy.conventions.models import (
ConventionsParseError,
ConventionsStandard,
)
from roboco.services.base import BaseService
from roboco.services.git import CONVENTIONS_SCAFFOLD_BRANCH, get_git_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable
_SCAFFOLD_BRANCH = CONVENTIONS_SCAFFOLD_BRANCH
_AMBIENT_CHAR_CAP = 1200
@dataclass(frozen=True)
class ScaffoldResult:
"""Outcome of a scaffold / restore: the branch + PR (if one was opened)."""
pr_number: int | None
branch: str
created: bool
@dataclass(frozen=True)
class ConventionsHealth:
"""Health of a project's standard: current status + last-good SHA."""
status: str
head_sha: str
last_ok_sha: str | None
class ConventionsService(BaseService):
"""Cache, render, scaffold, and restore a project's conventions standard."""
async def get_map(self, project: ProjectTable) -> ConventionsStandard:
"""Return the effective standard for ``project`` at its current HEAD."""
pid = self._pid(project)
head = self._head_sha(project)
cached = await self._cache_get(pid, head)
if cached is not None:
return ConventionsStandard.model_validate(cached.effective_map)
file_standard, status = self._read_committed_standard(project)
if status == "degraded":
last_good = await self._latest_ok_map(pid)
if last_good is not None:
await self._cache_put(pid, head, last_good, status)
return last_good
mapping = effective_map(self._derive(project), file_standard)
await self._cache_put(pid, head, mapping, status)
return mapping
async def baseline_constraints(self, project: ProjectTable) -> list[str]:
"""Render the project's block rules + module boundaries as constraints."""
mapping = await self.get_map(project)
constraints = [
f"Convention (block): {name.replace('_', ' ')}"
for name, rule in mapping.rules.items()
if rule.level == "block"
]
constraints += [
f"Place code per the map — {m.path} is for {m.purpose} "
f"(no {', '.join(m.forbidden)} here)"
for m in mapping.modules
if m.forbidden
]
return constraints
async def render_ambient_block(self, project: ProjectTable) -> str:
"""Render a compact, bounded 'Architectural Standard' prompt block."""
mapping = await self.get_map(project)
lines = [
"## Architectural Standard",
"Place each definition in the module that owns its kind:",
]
for module in mapping.modules:
suffix = (
f" — forbidden: {', '.join(module.forbidden)}"
if module.forbidden
else ""
)
lines.append(f"- `{module.path}`: {module.purpose}{suffix}")
block = sorted(n for n, r in mapping.rules.items() if r.level == "block")
if block:
lines.append("Block-level rules: " + ", ".join(block) + ".")
text = "\n".join(lines)
if len(text) > _AMBIENT_CHAR_CAP:
return text[: _AMBIENT_CHAR_CAP - 1].rstrip() + ""
return text
async def scaffold(
self, project: ProjectTable, *, workspace: Path | None = None
) -> ScaffoldResult:
"""Open a PR adding the auto-scaffolded ``.roboco/conventions.yml``."""
mapping = await self.get_map(project)
return await self._publish(
project, render_yaml(mapping), restore=False, workspace=workspace
)
async def restore(
self, project: ProjectTable, *, workspace: Path | None = None
) -> ScaffoldResult:
"""Open a PR re-committing the file from the last-good map (or a scan)."""
last_good = await self._latest_ok_map(self._pid(project))
mapping = last_good if last_good is not None else self._derive(project)
return await self._publish(
project, render_yaml(mapping), restore=True, workspace=workspace
)
async def commit_standard(
self,
project: ProjectTable,
standard: ConventionsStandard,
*,
workspace: Path | None = None,
) -> ScaffoldResult:
"""Open a PR committing an externally-edited standard (panel save)."""
return await self._publish(
project, render_yaml(standard), restore=False, workspace=workspace
)
async def health(self, project: ProjectTable) -> ConventionsHealth:
"""Report the standard's status at HEAD + the last-good commit SHA."""
pid = self._pid(project)
head = self._head_sha(project)
current = await self._cache_get(pid, head)
last_ok = await self._latest_ok_row(pid)
return ConventionsHealth(
status=current.status if current is not None else "unknown",
head_sha=head,
last_ok_sha=last_ok.commit_sha if last_ok is not None else None,
)
async def record_findings(
self, project_id: UUID, task_id: UUID, findings: list[dict[str, Any]]
) -> None:
"""Replace a task's recorded findings with the latest set. Caller commits."""
await self.session.execute(
delete(ProjectConventionFindingTable).where(
ProjectConventionFindingTable.project_id == project_id,
ProjectConventionFindingTable.task_id == task_id,
)
)
for finding in findings:
if not finding.get("file") or not finding.get("rule"):
continue
self.session.add(
ProjectConventionFindingTable(
project_id=project_id,
task_id=task_id,
file=str(finding.get("file", "")),
line=int(finding.get("line", 0)),
rule=str(finding.get("rule", "")),
level=str(finding.get("level", "")),
kind=finding.get("kind"),
message=str(finding.get("message", "")),
)
)
await self.session.flush()
async def recent_findings(
self, project_id: UUID, limit: int = 50
) -> list[dict[str, Any]]:
"""Recent findings across the project, newest first (for the panel feed)."""
result = await self.session.execute(
select(ProjectConventionFindingTable)
.where(ProjectConventionFindingTable.project_id == project_id)
.order_by(ProjectConventionFindingTable.detected_at.desc())
.limit(limit)
)
return [
{
"file": row.file,
"line": row.line,
"rule": row.rule,
"level": row.level,
"kind": row.kind,
"message": row.message,
"task_id": str(row.task_id) if row.task_id is not None else None,
"detected_at": row.detected_at.isoformat(),
}
for row in result.scalars().all()
]
# -- internals ---------------------------------------------------------- #
@staticmethod
def _pid(project: ProjectTable) -> UUID:
# ProjectTable.id is typed as the SQLAlchemy UUID column; normalize to a
# plain uuid.UUID for the cache-row helpers.
return UUID(str(project.id))
@staticmethod
def _head_sha(project: ProjectTable) -> str:
return project.head_commit or "HEAD"
@staticmethod
def _workspace_root(project: ProjectTable) -> Path | None:
if not project.workspace_path:
return None
path = Path(project.workspace_path)
return path if path.exists() else None
def _derive(self, project: ProjectTable) -> ConventionsStandard:
root = self._workspace_root(project)
return derive_from_scan(root) if root is not None else ConventionsStandard()
def _read_committed_standard(
self, project: ProjectTable
) -> tuple[ConventionsStandard | None, str]:
root = self._workspace_root(project)
if root is None:
return None, "missing"
path = root / ".roboco" / "conventions.yml"
if not path.is_file():
return None, "missing"
try:
text = path.read_text()
except OSError:
return None, "missing"
try:
return ConventionsStandard.parse_yaml(text), "ok"
except ConventionsParseError:
return None, "degraded"
async def _publish(
self,
project: ProjectTable,
content: str,
*,
restore: bool,
workspace: Path | None = None,
) -> ScaffoldResult:
action = "restore" if restore else "scaffold"
title = f"chore(conventions): {action} .roboco/conventions.yml"
body = (
"Auto-generated by RoboCo's architectural-conventions standard. "
"This file is repo-canonical — review, edit, or close as you like."
)
git = get_git_service(self.session)
result = await git.open_conventions_pr(
project.slug,
content=content,
title=title,
body=body,
workspace=workspace,
)
if result is None:
return ScaffoldResult(
pr_number=None, branch=_SCAFFOLD_BRANCH, created=False
)
return ScaffoldResult(
pr_number=result.get("pr_number"),
branch=result.get("branch", _SCAFFOLD_BRANCH),
created=True,
)
async def _cache_get(
self, project_id: UUID, commit_sha: str
) -> ProjectConventionsCacheTable | None:
result = await self.session.execute(
select(ProjectConventionsCacheTable).where(
ProjectConventionsCacheTable.project_id == project_id,
ProjectConventionsCacheTable.commit_sha == commit_sha,
)
)
return result.scalar_one_or_none()
async def _cache_put(
self,
project_id: UUID,
commit_sha: str,
mapping: ConventionsStandard,
status: str,
) -> None:
self.session.add(
ProjectConventionsCacheTable(
project_id=project_id,
commit_sha=commit_sha,
effective_map=mapping.model_dump(mode="json"),
status=status,
)
)
await self.session.flush()
async def _latest_ok_row(
self, project_id: UUID
) -> ProjectConventionsCacheTable | None:
result = await self.session.execute(
select(ProjectConventionsCacheTable)
.where(
ProjectConventionsCacheTable.project_id == project_id,
ProjectConventionsCacheTable.status == "ok",
)
.order_by(ProjectConventionsCacheTable.derived_at.desc())
.limit(1)
)
return result.scalars().first()
async def _latest_ok_map(self, project_id: UUID) -> ConventionsStandard | None:
row = await self._latest_ok_row(project_id)
if row is None:
return None
return ConventionsStandard.model_validate(row.effective_map)
def get_conventions_service(session: AsyncSession) -> ConventionsService:
"""Construct a ConventionsService bound to ``session``."""
return ConventionsService(session)
+110 -14
View File
@@ -1651,20 +1651,19 @@ class Choreographer:
Returns the rejection envelope if any gate fails; None on pass. Shared
by the normal and resume-from-verifying paths so both push.
"""
if rejection := await self._check_tracing_gates(
ctx.agent_id, ctx.task_id, ctx.task
):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_submit_qa_field_gates(
ctx.agent_id, ctx.task_id, ctx.task
):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._ensure_branch_pushed(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_quality_gate(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._toolchain_broken_guard(ctx.agent_id, ctx.task):
return await self._reject_i_am_done(ctx, rejection)
guards = (
lambda: self._check_tracing_gates(ctx.agent_id, ctx.task_id, ctx.task),
lambda: self._check_submit_qa_field_gates(
ctx.agent_id, ctx.task_id, ctx.task
),
lambda: self._ensure_branch_pushed(ctx),
lambda: self._check_quality_gate(ctx),
lambda: self._toolchain_broken_guard(ctx.agent_id, ctx.task),
lambda: self._conventions_gate(ctx),
)
for guard in guards:
if rejection := await guard():
return await self._reject_i_am_done(ctx, rejection)
# Pre-gateway parity: persist per-criterion
# status now that all gates have passed. The write runs AFTER the
# verdict so it cannot change i_am_done's rejection behavior.
@@ -1729,6 +1728,103 @@ class Choreographer:
context_briefing={},
)
async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Block i_am_done on unresolved block-level architectural violations.
Also persists the findings for the panel's violations feed — even the
ones that block this submit.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task)
await self._record_convention_findings(ctx.task, result)
return self._conventions_rejection(result, ctx.briefing)
@staticmethod
async def _record_convention_findings(task: Any, result: dict[str, Any]) -> None:
"""Persist the task's findings for the feed, best-effort.
Runs in its OWN committed session so a finding that *blocks* the submit
is captured regardless of the verb's transaction outcome.
"""
project_id = getattr(task, "project_id", None)
task_id = getattr(task, "id", None)
if project_id is None or task_id is None:
return
try:
from roboco.db.base import get_session_factory
from roboco.services.conventions import get_conventions_service
factory = get_session_factory()
async with factory() as db:
await get_conventions_service(db).record_findings(
UUID(str(project_id)),
UUID(str(task_id)),
result.get("findings", []),
)
await db.commit()
except Exception as exc:
logger.warning(
"Recording convention findings failed (non-fatal)", error=str(exc)
)
async def _conventions_guard(
self, agent_id: UUID, task: Any, briefing: dict[str, Any]
) -> Envelope | None:
"""Run the conventions validator on the actor's changed files (gated).
A ``block`` finding (a misplaced definition, a lint suppression) or a
validator that could not run returns a rejection with the offending
``file:line`` + fix hint. ``warn`` findings never block. Inert when the
flag is off. Shared by the i_am_done and pr_pass gates.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(agent_id, task)
return self._conventions_rejection(result, briefing)
@staticmethod
def _conventions_rejection(
result: dict[str, Any], briefing: dict[str, Any]
) -> Envelope | None:
"""Turn a validator result into a rejection Envelope, or None to pass."""
if result.get("could_not_run"):
return Envelope.invalid_state(
message=(
"the architectural-conventions validator could not run on "
"your changed files — this blocks rather than passing silently"
),
remediate=(
"the validator failed to analyze the diff (a parse or grammar "
"error). resolve it and call the verb again; if it persists, "
"call i_am_blocked"
),
context_briefing=briefing,
)
blocks = [f for f in result.get("findings", []) if f.get("level") == "block"]
if not blocks:
return None
listing = "\n".join(
f"- {f.get('file')}:{f.get('line')}{f.get('fix_hint')}" for f in blocks
)
return Envelope.invalid_state(
message=(
f"{len(blocks)} architectural-convention violation(s) must be "
"fixed before this can proceed"
),
remediate=(
"place each definition in the module the architecture map assigns "
"it, then commit and call the verb again. if a finding is a false "
"positive, add a waiver to .roboco/conventions.yml in your branch "
"for the PR to review:\n\n" + listing
),
context_briefing=briefing,
)
async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Push the task branch to origin before it reaches awaiting_qa.
@@ -58,6 +58,11 @@ class ChoreographerHelpers:
) -> Envelope | None:
raise NotImplementedError
async def _conventions_guard(
self, agent_id: UUID, task: Any, briefing: dict[str, Any]
) -> Envelope | None:
raise NotImplementedError
@classmethod
def _free_text_soup(
cls, checks: tuple[tuple[str, Any, int], ...]
@@ -234,17 +234,12 @@ class PRGateMixin(_Base):
)
if gate is not None:
return gate
# A reviewer must not PASS an assembled PR whose suite cannot be run in
# the workspace (interpreter mismatch). pr_fail stays available.
if verb == "pr_pass" and (
toolchain := await self._toolchain_broken_guard(reviewer_agent_id, t)
):
return await self._emit_rejection(
toolchain.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
if verb == "pr_pass":
blocked = await self._pr_pass_blocked(
reviewer_agent_id, task_id, t, role_str, briefing
)
if blocked is not None:
return blocked
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -271,6 +266,36 @@ class PRGateMixin(_Base):
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _pr_pass_blocked(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""Refuse pr_pass on a broken toolchain or a block-level violation.
A reviewer must not PASS an assembled PR whose suite can't run in the
workspace, or that carries unresolved architectural-convention
violations; pr_fail stays available. Returns the emitted rejection or
None to proceed. Both guards are inert when their flag is off.
"""
guards = (
lambda: self._toolchain_broken_guard(reviewer_agent_id, t),
lambda: self._conventions_guard(reviewer_agent_id, t, briefing),
)
for guard in guards:
rejection = await guard()
if rejection is not None:
return await self._emit_rejection(
rejection.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
)
return None
async def _post_gate_review_to_pr(
self, t: Any, verb: str, reviewer_slug: str, notes: str
) -> None:
+23 -2
View File
@@ -164,7 +164,7 @@ class QAMixin(_Base):
t = await self.task.qa_claim(qa_agent_id, task_id)
await self.task.mark_evidence_inspected(task_id)
ev = await self._build_qa_claim_evidence(t, task_id)
ev = await self._build_qa_claim_evidence(qa_agent_id, t, task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
@@ -173,7 +173,26 @@ class QAMixin(_Base):
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _build_qa_claim_evidence(self, t: Any, task_id: UUID) -> Any:
async def _qa_convention_findings(
self, qa_agent_id: UUID, t: Any
) -> list[dict[str, Any]]:
"""Convention-validator findings on the task's changed files (flag-gated).
Empty when the subsystem is off; a validator that could not run surfaces
a single explicit ``could_not_run`` entry rather than being dropped, so
QA never mistakes a silent failure for a clean diff.
"""
if not settings.conventions_enabled:
return []
result = await self.git.conventions_check_for_task(qa_agent_id, t)
if result.get("could_not_run"):
reason = result.get("reason") or "validator could not run"
return [{"could_not_run": True, "reason": reason}]
return list(result.get("findings", []))
async def _build_qa_claim_evidence(
self, qa_agent_id: UUID, t: Any, task_id: UUID
) -> Any:
"""Assemble the inline evidence payload returned by claim_review.
Bundles files_changed + pr_diff_summary (both from git, the
@@ -194,11 +213,13 @@ class QAMixin(_Base):
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
convention_findings = await self._qa_convention_findings(qa_agent_id, t)
return build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff_summary,
convention_findings=convention_findings,
)
async def _verify_qa_owner(
+7 -1
View File
@@ -10,7 +10,7 @@ The choreographer queries the data via existing services and passes it in.
from __future__ import annotations
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from typing import Any
BRIEFING_LIST_CAP = 10
@@ -26,6 +26,10 @@ class EvidencePayload:
dev_summary: str | None
journal_highlights: list[dict[str, Any]]
acceptance_criteria_status: list[dict[str, Any]]
# Architectural-conventions validator findings on the changed files, so QA
# can flag a misplaced definition / suppression. Empty when the subsystem
# is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly.
convention_findings: list[dict[str, Any]] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -57,6 +61,7 @@ def build_evidence_for_task(
journal_highlights: list[dict[str, Any]],
files_changed: list[str],
pr_diff_summary: str | None = None,
convention_findings: list[dict[str, Any]] | None = None,
) -> EvidencePayload:
"""Compose an EvidencePayload from a Task model + supplemental data."""
return EvidencePayload(
@@ -68,6 +73,7 @@ def build_evidence_for_task(
dev_summary=task.dev_notes,
journal_highlights=list(journal_highlights),
acceptance_criteria_status=list(task.acceptance_criteria_status or []),
convention_findings=list(convention_findings or []),
)
+164
View File
@@ -9,9 +9,11 @@ from __future__ import annotations
import asyncio
import base64
import json
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
@@ -3720,6 +3722,168 @@ class GitService(BaseService):
"deletions": deletions,
}
async def conventions_check_for_task(
self, actor_agent_id: UUID | None, task: Any
) -> dict[str, Any]:
"""Run the conventions validator on a task's changed files.
Resolves the acting agent's workspace + the branch's changed files and
runs ``python -m roboco.conventions`` over them. Fail-open on a
resolution error (returns no findings, ``could_not_run=False``); the
validator's OWN fail-loud (exit 3) sets ``could_not_run=True`` so the
gate blocks rather than passing on an unanalyzable diff.
"""
try:
branch = task.branch_name
if not branch:
return {"findings": [], "could_not_run": False}
workspace = await self._workspace_for_branch(
branch, actor_agent_id=actor_agent_id
)
changed = await self.list_changed_files(
branch_name=branch, actor_agent_id=actor_agent_id
)
except Exception:
return {"findings": [], "could_not_run": False}
if not changed:
return {"findings": [], "could_not_run": False}
return await self._run_conventions_validator(workspace, changed)
async def _run_conventions_validator(
self, workspace: Path, files: list[str]
) -> dict[str, Any]:
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"roboco.conventions",
"check",
"--root",
str(workspace),
"--files",
*files,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
reason = err.decode(errors="replace").strip() or "validator crashed"
return {"findings": [], "could_not_run": True, "reason": reason[:300]}
findings: list[dict[str, Any]] = []
for line in out.decode(errors="replace").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
findings.append(json.loads(stripped))
except json.JSONDecodeError:
continue
return {"findings": findings, "could_not_run": False, "reason": None}
async def open_conventions_pr(
self,
project_slug: str,
*,
content: str,
title: str,
body: str,
workspace: Path | None = None,
) -> dict[str, Any] | None:
"""Commit ``.roboco/conventions.yml`` on the scaffold branch + open a PR.
Best-effort and project-level (no task): writes ``content`` on a fresh
``CONVENTIONS_SCAFFOLD_BRANCH`` cut from the default branch in
``workspace`` (an agent's fresh clone) or the project's configured
``workspace_path``, commits it (always), then pushes + opens a PR (only
with a git token + remote), and restores the clone to its original
branch so a shared workspace is never stranded on the scaffold branch.
Returns ``{"branch", "pr_number", "pr_url"}`` (``pr_number=None`` when no
remote PR was opened) or ``None`` when there is no usable workspace.
"""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if project is None:
return None
ws = workspace or (
Path(project.workspace_path) if project.workspace_path else None
)
if ws is None or not ws.exists():
return None
base = project.default_branch or "master"
spec = _ConventionsPr(
content=content,
branch=CONVENTIONS_SCAFFOLD_BRANCH,
title=title,
body=body,
)
original = await self.get_current_branch(ws)
try:
await self._commit_conventions_file(ws, base, spec)
return await self._push_and_open_conventions_pr(
project_slug, ws, base, spec
)
finally:
await self._run_git(ws, ["checkout", original], check=False)
async def _commit_conventions_file(
self, workspace: Path, base: str, spec: _ConventionsPr
) -> None:
await self._run_git(workspace, ["checkout", base], check=False)
await self._run_git(workspace, ["checkout", "-B", spec.branch])
target = workspace / ".roboco" / "conventions.yml"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(spec.content)
await self._run_git(workspace, ["add", ".roboco/conventions.yml"])
await self._run_git(workspace, ["commit", "-m", spec.title])
async def _push_and_open_conventions_pr(
self, project_slug: str, workspace: Path, base: str, spec: _ConventionsPr
) -> dict[str, Any]:
unopened: dict[str, Any] = {
"branch": spec.branch,
"pr_number": None,
"pr_url": None,
}
token = await self._token_for_project(project_slug)
if not token:
return unopened
try:
await self.push(workspace)
owner, repo = self._parse_github_remote(workspace)
resp = await self._post_pr(
owner,
repo,
token,
{
"title": spec.title,
"head": spec.branch,
"base": base,
"body": spec.body,
},
)
except GitError:
return unopened
if not resp.is_success:
return unopened
data = resp.json()
return {
"branch": spec.branch,
"pr_number": data.get("number"),
"pr_url": data.get("html_url"),
}
CONVENTIONS_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold"
@dataclass(frozen=True)
class _ConventionsPr:
"""The fields for a project-level conventions scaffold/restore PR."""
content: str
branch: str
title: str
body: str
def get_git_service(session: AsyncSession) -> GitService:
"""Factory function to get git service."""
+20
View File
@@ -120,8 +120,28 @@ class ProjectService(BaseService):
if isinstance(data.assigned_cell, Team)
else data.assigned_cell,
)
await self._maybe_scaffold_conventions(project)
return project
async def _maybe_scaffold_conventions(self, project: ProjectTable) -> None:
"""Best-effort: open the conventions scaffold PR (flag-gated, never fatal).
Imported lazily to avoid the project -> conventions -> git -> project
import cycle. A scaffold hiccup must never fail project registration.
"""
if not settings.conventions_enabled:
return
from roboco.services.conventions import get_conventions_service
try:
await get_conventions_service(self.session).scaffold(project)
except Exception as exc:
self.log.warning(
"Conventions scaffold failed (non-fatal)",
project_id=str(project.id),
error=str(exc),
)
async def get(self, project_id: UUID) -> ProjectTable | None:
"""Get a project by ID."""
result = await self.session.execute(
+1
View File
@@ -52,6 +52,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("self_heal_originate_enabled", "Self-healing — open fix tasks"),
("provisioning_enabled", "Pitch auto-provisioning"),
("toolchain_match_enabled", "Agent runtime toolchain matching"),
("conventions_enabled", "Architectural conventions standard"),
("rag_auto_update_enabled", "RAG auto-update"),
("transcript_prune_enabled", "Transcript pruning"),
)
+53
View File
@@ -659,8 +659,61 @@ class TaskService(BaseService):
title=req.title,
team=req.team if isinstance(req.team, str) else req.team.value,
)
await self._attach_baseline_constraints(task)
return task
async def _attach_baseline_constraints(self, task: TaskTable) -> None:
"""Append the project's block-rule constraints to the task description.
Server-derived backstop (flag-gated): every project task carries the
hard conventions even if nothing upstream added them the layer the
design calls "can't be skipped". Idempotent and non-suppressible: it
appends only baseline constraints not already present (dedup by exact
string), so a second pass adds nothing AND an agent-authored
``## Constraints`` section can never suppress the mandatory baseline.
Best-effort: a failure never blocks task creation.
"""
baseline = await self._project_baseline_constraints(task)
if not baseline:
return
existing = task.description or ""
missing = [item for item in baseline if item not in existing]
if not missing:
return
section = "## Constraints\n" + "\n".join(f"- {item}" for item in missing)
task.description = f"{existing}\n\n{section}" if existing else section
await self.session.flush()
async def _project_baseline_constraints(self, task: TaskTable) -> list[str]:
"""The project's baseline constraints, or ``[]`` (flag-off / none / error).
Imported lazily to avoid the task -> conventions -> git import chain.
"""
from roboco.config import settings
if not settings.conventions_enabled or task.project_id is None:
return []
from roboco.services.conventions import get_conventions_service
from roboco.services.project import get_project_service
project = await get_project_service(self.session).get(
UUID(str(task.project_id))
)
if project is None:
return []
try:
return await get_conventions_service(self.session).baseline_constraints(
project
)
except Exception as exc:
self.log.warning(
"Baseline-constraints attach failed (non-fatal)",
task_id=str(task.id),
error=str(exc),
)
return []
async def external_review_task_exists(
self, project_id: UUID, pr_number: int, head_sha: str | None = None
) -> bool:
+37
View File
@@ -188,6 +188,10 @@ def _monotonic() -> float:
# trying to clone into the same directory.
_ENSURE_WORKSPACE_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
# Process-level guard so the conventions scaffold (opened on a project's first
# clone) is attempted at most once per project per process, even across agents.
_SCAFFOLD_ATTEMPTED: set[str] = set()
def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock:
"""Return the asyncio.Lock for a (project, agent) pair, creating lazily."""
@@ -731,8 +735,41 @@ class WorkspaceService:
git_token,
agent=agent,
)
await self._maybe_scaffold_conventions(project, project_slug, workspace)
return workspace
async def _maybe_scaffold_conventions(
self, project: Any, project_slug: str, workspace: Path
) -> None:
"""Open the conventions scaffold PR on a project's first clone.
Flag-gated, best-effort, once-per-process: fires only when the standard
file is absent in the fresh clone and we have not already tried for this
project, so a newly registered project gets a starter
``.roboco/conventions.yml`` and any failure is swallowed so it can
never affect the clone path. Imported lazily to avoid the
workspace -> conventions -> git import chain.
"""
from roboco.config import settings
if not settings.conventions_enabled or project_slug in _SCAFFOLD_ATTEMPTED:
return
_SCAFFOLD_ATTEMPTED.add(project_slug)
if (workspace / ".roboco" / "conventions.yml").exists():
return
try:
from roboco.services.conventions import get_conventions_service
await get_conventions_service(self.session).scaffold(
project, workspace=workspace
)
except Exception as exc:
logger.warning(
"Conventions scaffold on first clone failed (non-fatal)",
project=project_slug,
error=str(exc),
)
async def _clone_repo(
self,
workspace: Path,
@@ -0,0 +1,81 @@
"""conventions_ambient_layer: flag-gated, project-aware ambient-block resolver."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from roboco.agents.factories._base import conventions_ambient_layer
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
if TYPE_CHECKING:
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db.add(project)
await db.flush()
return project
async def test_ambient_block_when_flag_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
project = await _seed_project(db_session)
block = await conventions_ambient_layer(db_session, [project])
assert block is not None
assert block.startswith("## Architectural Standard")
async def test_merges_multiple_projects_with_slug_headers(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
p1 = await _seed_project(db_session)
p2 = await _seed_project(db_session)
block = await conventions_ambient_layer(db_session, [p1, p2])
assert block is not None
assert f"### Project `{p1.slug}`" in block
assert f"### Project `{p2.slug}`" in block
async def test_none_when_flag_off(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
project = await _seed_project(db_session)
assert await conventions_ambient_layer(db_session, [project]) is None
async def test_none_when_no_project(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
assert await conventions_ambient_layer(db_session, []) is None
@@ -0,0 +1,106 @@
"""The project_conventions_cache table round-trips JSONB and enforces uniqueness."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import (
AgentTable,
ProjectConventionsCacheTable,
ProjectTable,
)
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db.add(project)
await db.flush()
return project
async def test_cache_row_round_trips_jsonb(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
row = ProjectConventionsCacheTable(
id=uuid4(),
project_id=project.id,
commit_sha="abc1234",
effective_map={
"version": 1,
"rules": {
"no_models_in_routers": {
"name": "no_models_in_routers",
"level": "block",
}
},
},
status="ok",
)
db_session.add(row)
await db_session.flush()
await db_session.refresh(row)
fetched = (
await db_session.execute(
select(ProjectConventionsCacheTable).where(
ProjectConventionsCacheTable.project_id == project.id
)
)
).scalar_one()
assert fetched.status == "ok"
assert fetched.effective_map["rules"]["no_models_in_routers"]["level"] == "block"
assert fetched.derived_at is not None
async def test_project_sha_uniqueness_is_enforced(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
db_session.add(
ProjectConventionsCacheTable(
id=uuid4(),
project_id=project.id,
commit_sha="dup",
effective_map={},
status="ok",
)
)
await db_session.flush()
db_session.add(
ProjectConventionsCacheTable(
id=uuid4(),
project_id=project.id,
commit_sha="dup",
effective_map={},
status="ok",
)
)
with pytest.raises(IntegrityError):
await db_session.flush()
@@ -0,0 +1,91 @@
"""End-to-end: the real validator subprocess feeds the real gate decision.
Exercises the whole enforcement path against a real repo on disk effective
map (auto-derived committed file), tree-sitter placement, waiver filtering,
and the gateway's block/pass decision — without the orchestrator plumbing:
1. a model defined in a router blocks the submit with the offending file:line;
2. after the model moves to ``app/models``, the submit passes;
3. a committed waiver lets a deliberately-kept model through the gate.
"""
from __future__ import annotations
import json
import subprocess
import sys
from typing import TYPE_CHECKING, Any
from roboco.services.gateway.choreographer import Choreographer
if TYPE_CHECKING:
from pathlib import Path
_MODEL_SRC = (
"from pydantic import BaseModel\nclass UserCreate(BaseModel):\n x: int\n"
)
_FORBID_MODEL = (
"modules:\n - path: app/routers\n purpose: routes\n forbidden: [model]\n"
)
def _run_validator(root: Path, files: list[str]) -> dict[str, Any]:
proc = subprocess.run(
[
sys.executable,
"-m",
"roboco.conventions",
"check",
"--root",
str(root),
"--files",
*files,
],
capture_output=True,
text=True,
check=False,
)
findings = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()]
return {"findings": findings, "could_not_run": proc.returncode != 0}
def _write(root: Path, rel: str, content: str) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def test_block_then_fix_then_waiver(tmp_path: Path) -> None:
_write(tmp_path, ".roboco/conventions.yml", _FORBID_MODEL)
_write(tmp_path, "app/routers/users.py", _MODEL_SRC)
# 1. A Pydantic model in the router blocks, naming the offending file:line.
blocked = _run_validator(tmp_path, ["app/routers/users.py"])
rejection = Choreographer._conventions_rejection(blocked, {})
assert rejection is not None
assert "app/routers/users.py:2" in rejection.as_dict()["remediate"]
# 2. Move the model to app/models; the router now only holds a route → passes.
_write(
tmp_path,
"app/routers/users.py",
"@router.get('/users')\ndef list_users():\n return []\n",
)
_write(tmp_path, "app/models/user.py", _MODEL_SRC)
fixed = _run_validator(tmp_path, ["app/routers/users.py", "app/models/user.py"])
assert Choreographer._conventions_rejection(fixed, {}) is None
# 3. A deliberately-kept model in a router blocks — until a committed waiver
# (reviewed in the PR) suppresses exactly that finding.
_write(tmp_path, "app/routers/legacy.py", _MODEL_SRC)
still_blocked = _run_validator(tmp_path, ["app/routers/legacy.py"])
assert Choreographer._conventions_rejection(still_blocked, {}) is not None
_write(
tmp_path,
".roboco/conventions.yml",
_FORBID_MODEL + "waivers:\n - path: app/routers/legacy.py\n"
" rule: no_models_in_routers\n reason: extraction tracked\n",
)
waived = _run_validator(tmp_path, ["app/routers/legacy.py"])
assert Choreographer._conventions_rejection(waived, {}) is None
@@ -0,0 +1,160 @@
"""Convention-findings persistence + the violations-feed route."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.project import router as project_router
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.permissions import AgentContext
from roboco.services.conventions import get_conventions_service
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
_FINDINGS = [
{
"file": "app/routers/u.py",
"line": 2,
"kind": "model",
"rule": "no_models_in_routers",
"level": "block",
"message": "model in router",
"fix_hint": "move it",
},
{
"file": "app/routers/u.py",
"line": 9,
"kind": None,
"rule": "no_inline_comments",
"level": "warn",
"message": "inline comment",
"fix_hint": "remove",
},
]
async def _seed_project(db: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db.add(project)
await db.flush()
return project
async def test_record_then_recent_findings(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
svc = get_conventions_service(db_session)
pid = UUID(str(project.id))
await svc.record_findings(pid, uuid4(), _FINDINGS)
recent = await svc.recent_findings(pid)
assert len(recent) == len(_FINDINGS)
rules = {f["rule"] for f in recent}
assert rules == {"no_models_in_routers", "no_inline_comments"}
assert all(f["detected_at"] for f in recent)
async def test_record_replaces_prior_findings_for_task(
db_session: AsyncSession,
) -> None:
project = await _seed_project(db_session)
svc = get_conventions_service(db_session)
pid = UUID(str(project.id))
task = uuid4()
await svc.record_findings(pid, task, _FINDINGS)
await svc.record_findings(pid, task, _FINDINGS[:1]) # latest wins
recent = await svc.recent_findings(pid)
assert len(recent) == len(_FINDINGS[:1])
assert recent[0]["rule"] == "no_models_in_routers"
async def test_record_skips_malformed_entries(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
svc = get_conventions_service(db_session)
pid = UUID(str(project.id))
# a could_not_run entry (no file/rule) must not be recorded
await svc.record_findings(pid, uuid4(), [{"could_not_run": True, "reason": "x"}])
assert await svc.recent_findings(pid) == []
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
agent = AgentTable(
id=uuid4(),
name="MainPM",
slug=f"main-pm-{uuid4().hex[:8]}",
role=AgentRole.MAIN_PM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
app = FastAPI()
app.include_router(project_router, prefix="/api/projects")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", agent.id), role=AgentRole.MAIN_PM, team=None
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
async def test_findings_route_returns_recorded(
db_session: AsyncSession, client: AsyncClient
) -> None:
project = await _seed_project(db_session)
pid = UUID(str(project.id))
await get_conventions_service(db_session).record_findings(pid, uuid4(), _FINDINGS)
resp = await client.get(
f"/api/projects/{project.id}/conventions/findings", headers=_HDR
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == len(_FINDINGS)
assert {f["rule"] for f in body} == {"no_models_in_routers", "no_inline_comments"}
@@ -0,0 +1,187 @@
"""ConventionsService: cache-by-SHA, fallback, baseline/ambient, scaffold/restore."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.services.conventions import get_conventions_service
if TYPE_CHECKING:
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
_AMBIENT_CAP = 1200
_FAKE_PR_NUMBER = 7
class _FakeGit:
"""Captures the scaffold/restore publish call instead of hitting git."""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def open_conventions_pr(
self, project_slug: str, *, content: str, **_kwargs: object
) -> dict[str, Any]:
self.calls.append({"slug": project_slug, "content": content})
return {
"branch": "chore/roboco-conventions-scaffold",
"pr_number": _FAKE_PR_NUMBER,
"pr_url": "u",
}
async def _seed_project(
db: AsyncSession, *, head_commit: str, workspace_path: str
) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
head_commit=head_commit,
workspace_path=workspace_path,
)
db.add(project)
await db.flush()
return project
async def test_get_map_caches_per_head_sha(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="sha1", workspace_path=str(tmp_path)
)
svc = get_conventions_service(db_session)
first = await svc.get_map(project)
# Mutate the workspace AFTER the first call — a cache hit must ignore it.
(tmp_path / "app" / "routers").mkdir(parents=True)
second = await svc.get_map(project)
assert second == first
assert [m.path for m in second.modules] == []
async def test_missing_file_yields_missing_status_and_derived_map(
db_session: AsyncSession, tmp_path: Path
) -> None:
(tmp_path / "app" / "routers").mkdir(parents=True)
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
svc = get_conventions_service(db_session)
mapping = await svc.get_map(project)
assert any(m.path == "app/routers" for m in mapping.modules)
health = await svc.health(project)
assert health.status == "missing"
async def test_corrupt_file_falls_back_to_last_ok(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special things\n"
)
svc = get_conventions_service(db_session)
ok_map = await svc.get_map(project)
assert any(m.path == "lib/special" for m in ok_map.modules)
project.head_commit = "bad1"
await db_session.flush()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
degraded = await svc.get_map(project)
assert any(m.path == "lib/special" for m in degraded.modules)
health = await svc.health(project)
assert health.status == "degraded"
assert health.last_ok_sha == "ok1"
async def test_baseline_constraints_include_block_rules(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
constraints = await get_conventions_service(db_session).baseline_constraints(
project
)
assert any("no models in routers" in c for c in constraints)
async def test_render_ambient_block_is_bounded(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
block = await get_conventions_service(db_session).render_ambient_block(project)
assert block.startswith("## Architectural Standard")
assert len(block) <= _AMBIENT_CAP
async def test_scaffold_opens_pr_with_rendered_map(
db_session: AsyncSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "app" / "routers").mkdir(parents=True)
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
fake = _FakeGit()
monkeypatch.setattr(
"roboco.services.conventions.get_git_service", lambda _session: fake
)
result = await get_conventions_service(db_session).scaffold(project)
assert result.created is True
assert result.pr_number == _FAKE_PR_NUMBER
assert fake.calls and "app/routers" in fake.calls[0]["content"]
async def test_restore_uses_last_good_map(
db_session: AsyncSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special\n"
)
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches an 'ok' row containing lib/special
fake = _FakeGit()
monkeypatch.setattr(
"roboco.services.conventions.get_git_service", lambda _session: fake
)
result = await svc.restore(project)
assert result.created is True
assert "lib/special" in fake.calls[0]["content"]
@@ -0,0 +1,102 @@
"""GitService.open_conventions_pr commits the file locally; PR is best-effort."""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from uuid import uuid4
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.services.git import get_git_service
if TYPE_CHECKING:
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold"
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True)
async def _seed_project(db: AsyncSession, workspace_path: str) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="G-Proj",
slug=f"g-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
default_branch="master",
assigned_cell=Team.BACKEND,
created_by=agent.id,
workspace_path=workspace_path,
)
db.add(project)
await db.flush()
return project
async def test_open_conventions_pr_commits_locally_without_remote(
db_session: AsyncSession, tmp_path: Path
) -> None:
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-b", "master")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "README.md").write_text("# r\n")
_git(repo, "add", "README.md")
_git(repo, "commit", "-m", "init")
project = await _seed_project(db_session, str(repo))
git = get_git_service(db_session)
result = await git.open_conventions_pr(
project.slug,
content="version: 1\n",
title="scaffold",
body="b",
)
assert result is not None
assert result["pr_number"] is None # no git token / remote → PR not opened
show = subprocess.run(
["git", "show", f"{_SCAFFOLD_BRANCH}:.roboco/conventions.yml"],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
assert show.returncode == 0
assert show.stdout == "version: 1\n"
async def test_open_conventions_pr_returns_none_without_workspace(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(db_session, str(tmp_path / "does-not-exist"))
git = get_git_service(db_session)
result = await git.open_conventions_pr(
project.slug,
content="version: 1\n",
title="t",
body="b",
)
assert result is None
@@ -0,0 +1,120 @@
"""Conventions API routes: GET map+health, PUT commit-back, POST restore."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.project import router as project_router
from roboco.db.tables import AgentTable
from roboco.models import AgentRole, AgentStatus
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
agent = AgentTable(
id=uuid4(),
name="MainPM",
slug=f"main-pm-{uuid4().hex[:8]}",
role=AgentRole.MAIN_PM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
app = FastAPI()
app.include_router(project_router, prefix="/api/projects")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", agent.id), role=AgentRole.MAIN_PM, team=None
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
async def _make_project(client: AsyncClient) -> str:
resp = await client.post(
"/api/projects",
headers=_HDR,
json={
"name": f"Project {uuid4().hex[:6]}",
"slug": f"proj-{uuid4().hex[:6]}",
"git_url": "https://github.com/example/foo.git",
"default_branch": "master",
"assigned_cell": "backend",
},
)
assert resp.status_code == HTTPStatus.CREATED
return str(resp.json()["id"])
async def test_get_conventions_returns_map_and_health(client: AsyncClient) -> None:
project_id = await _make_project(client)
resp = await client.get(f"/api/projects/{project_id}/conventions", headers=_HDR)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["standard"]["rules"]["no_models_in_routers"]["level"] == "block"
assert body["health"]["status"] in {"missing", "unknown", "ok", "degraded"}
async def test_put_conventions_commits_back(client: AsyncClient) -> None:
project_id = await _make_project(client)
resp = await client.put(
f"/api/projects/{project_id}/conventions",
headers=_HDR,
json={
"version": 1,
"languages": ["python"],
"modules": [
{"path": "app/models", "purpose": "models", "forbidden": ["route"]}
],
"rules": {"no_inline_comments": {"level": "warn"}},
"custom": [],
"waivers": [],
},
)
assert resp.status_code == HTTPStatus.OK
# No workspace on the test project → PR not opened, but the call succeeds.
assert resp.json()["created"] is False
async def test_restore_conventions(client: AsyncClient) -> None:
project_id = await _make_project(client)
resp = await client.post(
f"/api/projects/{project_id}/conventions/restore", headers=_HDR
)
assert resp.status_code == HTTPStatus.OK
assert "branch" in resp.json()
async def test_get_conventions_unknown_project_404(client: AsyncClient) -> None:
resp = await client.get(f"/api/projects/{uuid4()}/conventions", headers=_HDR)
assert resp.status_code == HTTPStatus.NOT_FOUND
@@ -0,0 +1,97 @@
"""Project registration triggers a best-effort conventions scaffold (flag-gated)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest_asyncio
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectCreate
from roboco.services.project import ProjectService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
class _SpyConventions:
def __init__(self) -> None:
self.scaffolded: list[ProjectTable] = []
async def scaffold(self, project: ProjectTable) -> None:
self.scaffolded.append(project)
class _BoomConventions:
async def scaffold(self, _project: ProjectTable) -> None:
raise RuntimeError("boom")
@pytest_asyncio.fixture
async def setup(db_session: AsyncSession) -> AsyncIterator[dict]:
agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
yield {"svc": ProjectService(db_session), "creator_id": agent.id}
def _payload() -> ProjectCreate:
return ProjectCreate(
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url="https://github.com/example/r.git",
assigned_cell=Team.BACKEND,
)
async def test_scaffold_invoked_when_flag_on(
setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _SpyConventions()
monkeypatch.setattr(
"roboco.services.conventions.get_conventions_service", lambda _s: spy
)
project = await setup["svc"].create(_payload(), setup["creator_id"])
assert spy.scaffolded == [project]
async def test_scaffold_not_invoked_when_flag_off(
setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
spy = _SpyConventions()
monkeypatch.setattr(
"roboco.services.conventions.get_conventions_service", lambda _s: spy
)
await setup["svc"].create(_payload(), setup["creator_id"])
assert spy.scaffolded == []
async def test_scaffold_failure_does_not_fail_registration(
setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
monkeypatch.setattr(
"roboco.services.conventions.get_conventions_service",
lambda _s: _BoomConventions(),
)
project = await setup["svc"].create(_payload(), setup["creator_id"])
assert project.id is not None
@@ -0,0 +1,110 @@
"""TaskService.create appends the project's baseline constraints (flag-gated)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskNature
from roboco.models.task import TaskCreateRequest, TaskType
from roboco.services.task import TaskService
if TYPE_CHECKING:
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed(db: AsyncSession) -> tuple[AgentTable, ProjectTable]:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db.add(project)
await db.flush()
return agent, project
def _req(
agent: AgentTable, project: ProjectTable, description: str
) -> TaskCreateRequest:
return TaskCreateRequest(
title="A task",
description=description,
acceptance_criteria=["it works"],
team=Team.BACKEND,
created_by=UUID(str(agent.id)),
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=UUID(str(project.id)),
)
async def test_baseline_attached_when_flag_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
assert task.description is not None
assert "## Constraints" in task.description
assert "no models in routers" in task.description
async def test_flag_off_attaches_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
agent, project = await _seed(db_session)
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
assert task.description == "Do the work"
async def test_baseline_not_suppressed_by_agent_constraints_section(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# An agent-authored ## Constraints section must NOT suppress the mandatory
# server baseline — both are present.
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
seeded = "Do the work\n\n## Constraints\n- a task-specific note"
task = await TaskService(db_session).create(_req(agent, project, seeded))
assert task.description is not None
assert "a task-specific note" in task.description
assert "no models in routers" in task.description
async def test_baseline_attach_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
svc = TaskService(db_session)
task = await svc.create(_req(agent, project, "Do the work"))
before = task.description
await svc._attach_baseline_constraints(task)
assert task.description == before
assert task.description is not None
assert task.description.count("no models in routers") == 1
@@ -0,0 +1,25 @@
"""compose_prompt appends the architectural-standard ambient layer when given."""
from __future__ import annotations
from roboco.agents.factories._base import compose_prompt
from roboco.models import AgentRole, Team
_AMBIENT = "## Architectural Standard\n- `app/routers`: HTTP routes"
def test_ambient_layer_included_when_provided() -> None:
prompt = compose_prompt(
AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1", ambient=_AMBIENT
)
assert "## Architectural Standard" in prompt
def test_ambient_absent_when_none() -> None:
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1")
assert "## Architectural Standard" not in prompt
def test_empty_ambient_not_injected() -> None:
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1", ambient="")
assert "## Architectural Standard" not in prompt
@@ -0,0 +1,17 @@
"""The architectural-conventions subsystem is gated by a default-off flag."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
def test_conventions_disabled_by_default() -> None:
assert Settings().conventions_enabled is False
def test_conventions_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_CONVENTIONS_ENABLED": "true"}):
assert Settings().conventions_enabled is True
@@ -0,0 +1,81 @@
"""Python definition-kind classification (tree-sitter), precision-over-recall."""
from __future__ import annotations
from roboco.conventions.classify_python import classify_definitions
def test_pydantic_model_is_classified_model() -> None:
src = b"from pydantic import BaseModel\nclass UserCreate(BaseModel):\n x: int\n"
defs = classify_definitions(src)
assert ("UserCreate", 2, "model") in defs
def test_dotted_base_model_is_classified_model() -> None:
defs = classify_definitions(b"class M(pydantic.BaseModel):\n pass\n")
assert defs == [("M", 1, "model")]
def test_sqlalchemy_declarative_base_is_model() -> None:
defs = classify_definitions(b"class Account(Base):\n pass\n")
assert defs == [("Account", 1, "model")]
def test_router_decorated_function_is_route() -> None:
src = b"@router.get('/x')\ndef list_x():\n return 1\n"
defs = classify_definitions(src)
assert defs == [("list_x", 2, "route")]
def test_app_post_decorated_function_is_route() -> None:
src = b"@app.post('/y')\ndef create_y():\n return 1\n"
assert classify_definitions(src) == [("create_y", 2, "route")]
def test_blueprint_get_decorated_function_is_route() -> None:
# Any object with an HTTP-method attribute counts as a route handler.
src = b"@bp.delete('/z')\ndef drop_z():\n return 1\n"
assert classify_definitions(src) == [("drop_z", 2, "route")]
def test_plain_function_is_helper() -> None:
assert classify_definitions(b"def helper():\n pass\n") == [
("helper", 1, "helper")
]
def test_non_route_decorated_function_is_helper() -> None:
# A decorator that is not an HTTP route still leaves a plain function.
src = b"@functools.cache\ndef compute():\n return 1\n"
assert classify_definitions(src) == [("compute", 2, "helper")]
def test_ambiguous_class_abstains_to_other() -> None:
assert classify_definitions(b"class Thing:\n pass\n") == [("Thing", 1, "other")]
def test_class_with_unknown_base_abstains() -> None:
assert classify_definitions(b"class Widget(Gadget):\n pass\n") == [
("Widget", 1, "other")
]
def test_multiple_top_level_defs_in_order() -> None:
src = (
b"from pydantic import BaseModel\n"
b"class Req(BaseModel):\n x: int\n"
b"@router.put('/u')\ndef upd():\n return 1\n"
b"def util():\n pass\n"
)
defs = classify_definitions(src)
assert defs == [
("Req", 2, "model"),
("upd", 5, "route"),
("util", 7, "helper"),
]
def test_nested_defs_are_not_top_level() -> None:
# Only module-level definitions are classified (precision).
src = b"def outer():\n def inner():\n pass\n return inner\n"
assert classify_definitions(src) == [("outer", 1, "helper")]
@@ -0,0 +1,50 @@
"""TypeScript / TSX definition-kind classification, precision-over-recall."""
from __future__ import annotations
from roboco.conventions.classify_ts import classify_definitions
def test_zod_schema_const_is_model() -> None:
src = b"export const UserSchema = z.object({ id: z.string() });\n"
assert ("UserSchema", 1, "model") in classify_definitions(src, "typescript")
def test_chained_zod_schema_is_model() -> None:
src = b"export const P = z.object({}).partial();\n"
assert ("P", 1, "model") in classify_definitions(src, "typescript")
def test_entity_class_is_model() -> None:
src = b"@Entity()\nexport class User {}\n"
assert ("User", 2, "model") in classify_definitions(src, "typescript")
def test_controller_class_is_route() -> None:
src = b"@Controller('users')\nexport class UsersController {}\n"
assert ("UsersController", 2, "route") in classify_definitions(src, "typescript")
def test_arrow_component_is_component() -> None:
src = b"export const Btn = () => <div/>;\n"
assert ("Btn", 1, "component") in classify_definitions(src, "tsx")
def test_function_component_is_component() -> None:
src = b"export function Card() { return <span/>; }\n"
assert ("Card", 1, "component") in classify_definitions(src, "tsx")
def test_plain_function_abstains_to_other() -> None:
src = b"export function add(a: number, b: number) { return a + b; }\n"
assert classify_definitions(src, "typescript") == [("add", 1, "other")]
def test_plain_const_abstains_to_other() -> None:
src = b"export const TAX = 0.2;\n"
assert classify_definitions(src, "typescript") == [("TAX", 1, "other")]
def test_unparseable_source_abstains_quietly() -> None:
# tree-sitter yields ERROR nodes; we must not crash or invent findings.
assert classify_definitions(b"export const = = =;\n", "typescript") == []
+75
View File
@@ -0,0 +1,75 @@
"""CLI: JSONL findings on stdout, exit 0 when it ran, exit 3 when it could not."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from roboco.conventions.__main__ import main
from roboco.conventions.runner import ValidatorCouldNotRun
if TYPE_CHECKING:
from pathlib import Path
import pytest
_EXIT_COULD_NOT_RUN = 3
def _seed_repo(root: Path) -> None:
routers = root / "app" / "routers"
routers.mkdir(parents=True)
(routers / "u.py").write_text(
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
)
conv = root / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
)
def test_cli_prints_jsonl_and_exits_zero(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
_seed_repo(tmp_path)
rc = main(["check", "--root", str(tmp_path), "--files", "app/routers/u.py"])
assert rc == 0
lines = capsys.readouterr().out.strip().splitlines()
assert lines
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
def test_cli_exits_zero_with_no_findings(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "clean.py").write_text("def helper():\n return 1\n")
rc = main(["check", "--root", str(tmp_path), "--files", "clean.py"])
assert rc == 0
assert capsys.readouterr().out.strip() == ""
def test_cli_exits_three_on_unparseable_config(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text("modules: [oops\n")
rc = main(["check", "--root", str(tmp_path), "--files"])
assert rc == _EXIT_COULD_NOT_RUN
assert "error" in capsys.readouterr().err
def test_cli_exits_three_when_validator_cannot_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "x.py").write_text("x = 1\n")
def boom(*_args: object, **_kw: object) -> list:
raise ValidatorCouldNotRun("no grammar")
monkeypatch.setattr("roboco.conventions.__main__.run", boom)
rc = main(["check", "--root", str(tmp_path), "--files", "x.py"])
assert rc == _EXIT_COULD_NOT_RUN
payload = json.loads(capsys.readouterr().err)
assert "error" in payload
+49
View File
@@ -0,0 +1,49 @@
"""Smoke the real ``python -m roboco.conventions`` entrypoint as a subprocess.
Guards the contract the agent image depends on: the module runs, loads its
tree-sitter grammars, and emits JSONL findings with exit 0.
"""
from __future__ import annotations
import json
import subprocess
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
def test_cli_module_entrypoint_emits_jsonl(tmp_path: Path) -> None:
routers = tmp_path / "app" / "routers"
routers.mkdir(parents=True)
(routers / "u.py").write_text(
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
)
result = subprocess.run(
[
sys.executable,
"-m",
"roboco.conventions",
"check",
"--root",
str(tmp_path),
"--files",
"app/routers/u.py",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
lines = [line for line in result.stdout.strip().splitlines() if line]
assert lines
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
+49
View File
@@ -0,0 +1,49 @@
"""Custom regex rules, scoped by language."""
from __future__ import annotations
from roboco.conventions.custom import check_custom
from roboco.foundation.policy.conventions.models import ConventionsStandard, CustomRule
_NO_PRINT = CustomRule(
id="no-print",
pattern=r"\bprint\(",
message="use the logger, not print()",
level="warn",
languages=["python"],
)
def test_custom_rule_matches_in_scoped_language() -> None:
std = ConventionsStandard(custom=[_NO_PRINT])
findings = check_custom("a.py", b"print('x')\n", "python", std)
assert len(findings) == 1
assert findings[0].rule == "no-print"
assert findings[0].level == "warn"
assert findings[0].message == "use the logger, not print()"
def test_custom_rule_skips_other_language() -> None:
std = ConventionsStandard(custom=[_NO_PRINT])
assert check_custom("a.ts", b"print('x')\n", "typescript", std) == []
def test_unscoped_custom_rule_applies_to_all_languages() -> None:
rule = CustomRule(
id="no-log", pattern=r"console\.log", message="no console.log", level="warn"
)
std = ConventionsStandard(custom=[rule])
assert check_custom("a.ts", b"console.log(1)\n", "typescript", std)
def test_custom_rule_reports_correct_line() -> None:
print_line = 3
std = ConventionsStandard(custom=[_NO_PRINT])
findings = check_custom("a.py", b"x = 1\ny = 2\nprint(x)\n", "python", std)
assert findings[0].line == print_line
def test_bad_regex_abstains_without_crashing() -> None:
rule = CustomRule(id="bad", pattern=r"(unclosed", message="m", level="block")
std = ConventionsStandard(custom=[rule])
assert check_custom("a.py", b"anything\n", "python", std) == []
+67
View File
@@ -0,0 +1,67 @@
"""Hygiene checks: inline comments + lint/type suppressions."""
from __future__ import annotations
from roboco.conventions.hygiene import check_hygiene
from roboco.foundation.policy.conventions.models import ConventionsStandard, Rule
_STD = ConventionsStandard()
def _rules(findings: list, rule: str) -> list:
return [f for f in findings if f.rule == rule]
def test_trailing_comment_is_flagged_inline() -> None:
findings = check_hygiene("a.py", b"x = 1 # set x\n", "python", _STD)
inline = _rules(findings, "no_inline_comments")
assert inline and inline[0].level == "warn"
assert inline[0].line == 1
def test_full_line_comment_is_not_inline() -> None:
findings = check_hygiene("a.py", b"# a heading\nx = 1\n", "python", _STD)
assert _rules(findings, "no_inline_comments") == []
def test_indented_full_line_comment_is_not_inline() -> None:
src = b"def f():\n # explain\n return 1\n"
findings = check_hygiene("a.py", src, "python", _STD)
assert _rules(findings, "no_inline_comments") == []
def test_python_type_ignore_flags_suppression_block() -> None:
findings = check_hygiene("a.py", b"y = bad() # type: ignore\n", "python", _STD)
sup = _rules(findings, "no_lint_suppressions")
assert sup and sup[0].level == "block"
def test_python_noqa_flags_suppression() -> None:
findings = check_hygiene("a.py", b"import os # noqa: F401\n", "python", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_ts_eslint_disable_flags_suppression() -> None:
src = b"// eslint-disable-next-line\nconst x = 1;\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_ts_ignore_flags_suppression() -> None:
src = b"// @ts-ignore\nconst x: number = 'no';\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_python_marker_not_applied_to_typescript() -> None:
src = b"// noqa is a python thing\nconst x = 1;\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions") == []
def test_rule_level_override_from_standard() -> None:
std = ConventionsStandard(
rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")}
)
findings = check_hygiene("a.py", b"x = 1 # c\n", "python", std)
assert _rules(findings, "no_inline_comments")[0].level == "block"
+93
View File
@@ -0,0 +1,93 @@
"""Placement checks: a def whose kind is forbidden in its module is flagged."""
from __future__ import annotations
import json
from roboco.conventions.placement import Definition, check_placement
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
Module,
Rule,
)
_MODEL_LINE = 2
_DEFS: list[Definition] = [("UserCreate", _MODEL_LINE, "model")]
def test_forbidden_kind_in_module_is_flagged() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert len(findings) == 1
f = findings[0]
assert f.kind == "model"
assert f.rule == "no_models_in_routers"
assert f.level == "block"
assert f.line == _MODEL_LINE
assert "app/routers" in f.message
def test_allowed_kind_in_module_is_not_flagged() -> None:
std = ConventionsStandard(
modules=[Module(path="app/models", purpose="models", forbidden=["route"])]
)
assert check_placement("app/models/user.py", _DEFS, std) == []
def test_no_matching_module_yields_no_finding() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
assert check_placement("lib/helpers.py", _DEFS, std) == []
def test_rule_level_from_standard_is_respected() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])],
rules={"no_models_in_routers": Rule(name="no_models_in_routers", level="warn")},
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert findings[0].level == "warn"
def test_longest_matching_module_wins() -> None:
std = ConventionsStandard(
modules=[
Module(path="app", purpose="root", forbidden=[]),
Module(path="app/routers", purpose="routes", forbidden=["model"]),
]
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert len(findings) == 1
assert findings[0].kind == "model"
def test_prefix_must_be_on_a_path_boundary() -> None:
# "app/routers" must not match "app/routers_legacy/..." spuriously.
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
assert check_placement("app/routers_legacy/users.py", _DEFS, std) == []
def test_finding_serializes_to_json_line() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
f = check_placement("app/routers/users.py", _DEFS, std)[0]
payload = json.loads(f.as_json())
assert payload["rule"] == "no_models_in_routers"
assert payload["file"] == "app/routers/users.py"
assert payload["line"] == _MODEL_LINE
assert payload["level"] == "block"
assert set(payload) == {
"file",
"line",
"kind",
"rule",
"level",
"message",
"fix_hint",
}
+78
View File
@@ -0,0 +1,78 @@
"""Runner: per-file dispatch, waiver filtering, fail-loud on grammar failure."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.conventions.grammars import GrammarUnavailable
from roboco.conventions.runner import ValidatorCouldNotRun, run
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
Module,
Waiver,
)
if TYPE_CHECKING:
from pathlib import Path
_MODEL_PY = b"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
def _write(root: Path, rel: str, content: bytes) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def test_runner_flags_python_model_in_router(tmp_path: Path) -> None:
_write(tmp_path, "app/routers/users.py", _MODEL_PY)
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])]
)
findings = run(tmp_path, ["app/routers/users.py"], std)
assert [f.rule for f in findings] == ["no_models_in_routers"]
def test_runner_drops_waived_finding(tmp_path: Path) -> None:
_write(tmp_path, "app/routers/legacy.py", _MODEL_PY)
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])],
waivers=[
Waiver(
path="app/routers/legacy.py", rule="no_models_in_routers", reason="x"
)
],
)
assert run(tmp_path, ["app/routers/legacy.py"], std) == []
def test_runner_flags_ts_component_in_wrong_module(tmp_path: Path) -> None:
_write(tmp_path, "src/pages/Home.tsx", b"export const Home = () => <div/>;\n")
std = ConventionsStandard(
modules=[Module(path="src/pages", purpose="pages", forbidden=["component"])]
)
findings = run(tmp_path, ["src/pages/Home.tsx"], std)
assert any(f.kind == "component" for f in findings)
def test_runner_skips_unsupported_extension(tmp_path: Path) -> None:
_write(tmp_path, "README.md", b"# hi\n")
assert run(tmp_path, ["README.md"], ConventionsStandard()) == []
def test_runner_skips_missing_file(tmp_path: Path) -> None:
assert run(tmp_path, ["gone.py"], ConventionsStandard()) == []
def test_runner_is_fail_loud_on_grammar_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write(tmp_path, "x.py", b"x = 1\n")
def boom(_source: bytes) -> list:
raise GrammarUnavailable("python")
monkeypatch.setattr("roboco.conventions.classify_python.classify_definitions", boom)
with pytest.raises(ValidatorCouldNotRun):
run(tmp_path, ["x.py"], ConventionsStandard())
+68
View File
@@ -0,0 +1,68 @@
"""Repo auto-scan + scaffold-draft renderer."""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.conventions.scan import derive_from_scan, render_yaml
from roboco.foundation.policy.conventions.models import ConventionsStandard
if TYPE_CHECKING:
from pathlib import Path
def _sample_repo(root: Path) -> None:
(root / "app" / "routers").mkdir(parents=True)
(root / "app" / "models").mkdir(parents=True)
(root / "app" / "services").mkdir(parents=True)
(root / "app" / "routers" / "users.py").write_text("x = 1\n")
(root / "app" / "models" / "user.py").write_text("y = 2\n")
(root / "app" / "services" / "logic.py").write_text("z = 3\n")
def test_scan_derives_router_module_forbidding_models(tmp_path: Path) -> None:
_sample_repo(tmp_path)
std = derive_from_scan(tmp_path)
routers = [m for m in std.modules if m.path == "app/routers"]
assert routers and "model" in routers[0].forbidden
def test_scan_detects_python_language(tmp_path: Path) -> None:
_sample_repo(tmp_path)
assert "python" in derive_from_scan(tmp_path).languages
def test_scan_seeds_builtin_rules(tmp_path: Path) -> None:
_sample_repo(tmp_path)
std = derive_from_scan(tmp_path)
assert std.rules["no_models_in_routers"].level == "block"
assert std.rules["no_inline_comments"].level == "warn"
def test_scan_ignores_vendored_directories(tmp_path: Path) -> None:
(tmp_path / "node_modules" / "pkg" / "routers").mkdir(parents=True)
(tmp_path / ".venv" / "lib" / "models").mkdir(parents=True)
std = derive_from_scan(tmp_path)
assert std.modules == []
def test_scan_lifts_claude_md_imperative_into_custom_rule(tmp_path: Path) -> None:
_sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("- Never use `print()`; use the logger.\n")
custom = derive_from_scan(tmp_path).custom
assert custom
assert custom[0].level == "warn"
assert "print" in custom[0].pattern
def test_render_yaml_round_trips_through_parse(tmp_path: Path) -> None:
_sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("Do not call `eval()` anywhere.\n")
std = derive_from_scan(tmp_path)
reparsed = ConventionsStandard.parse_yaml(render_yaml(std))
assert reparsed == std
def test_render_yaml_round_trips_empty_standard() -> None:
std = ConventionsStandard()
assert ConventionsStandard.parse_yaml(render_yaml(std)) == std
@@ -0,0 +1,28 @@
"""TaskDescription.constraints: renders as a section when present, else absent."""
from __future__ import annotations
from typing import Any
from roboco.foundation.identity import Team
from roboco.foundation.policy.content.models import TaskDescription, WorkUnit
def _desc(**overrides: Any) -> TaskDescription:
fields: dict[str, Any] = {
"objective": "Build the thing properly",
"the_work": [WorkUnit(team=Team.BACKEND, summary="do the work", items=["a"])],
"acceptance_criteria": ["it works"],
}
fields.update(overrides)
return TaskDescription(**fields)
def test_constraints_render_as_section() -> None:
md = _desc(constraints=["no models in routers"]).render_markdown()
assert "## Constraints" in md
assert "no models in routers" in md
def test_no_constraints_section_when_empty() -> None:
assert "## Constraints" not in _desc().render_markdown()
@@ -0,0 +1,28 @@
"""The panel TS ConventionsStandard type mirrors the Python model fields.
A drift here means the panel editor and the backend disagree on the shape of
``.roboco/conventions.yml`` caught at test time, not in production.
"""
from __future__ import annotations
import re
from pathlib import Path
from roboco.foundation.policy.conventions.models import ConventionsStandard
_REPO_ROOT = Path(__file__).resolve().parents[5]
_TS_FILE = _REPO_ROOT / "panel" / "src" / "lib" / "api" / "conventions.ts"
def _ts_interface_fields(text: str, name: str) -> set[str]:
match = re.search(rf"export interface {name} \{{(.+?)\n\}}", text, re.DOTALL)
assert match, f"interface {name} not found in conventions.ts"
return set(re.findall(r"^\s*(\w+)\s*[?:]", match.group(1), re.MULTILINE))
def test_ts_standard_matches_python_fields() -> None:
text = _TS_FILE.read_text()
ts_keys = _ts_interface_fields(text, "ConventionsStandard")
py_keys = set(ConventionsStandard.model_fields.keys())
assert ts_keys == py_keys
@@ -0,0 +1,111 @@
"""The i_am_done conventions gate: block-level violations refuse the submit.
With the flag on, a ``block`` finding (or a validator that could not run) on the
dev's changed files refuses i_am_done with the offending ``file:line`` + a fix
hint. ``warn`` findings never block; the flag-off path is fully inert.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_BLOCK_RESULT: dict[str, Any] = {
"findings": [
{
"file": "app/routers/u.py",
"line": 2,
"level": "block",
"fix_hint": "move it into models/",
}
],
"could_not_run": False,
}
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
def _ctx() -> MagicMock:
ctx = MagicMock()
ctx.briefing = {}
return ctx
@pytest.mark.asyncio
async def test_block_finding_refuses_with_location(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result=_BLOCK_RESULT)
env = await c._conventions_gate(_ctx())
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "app/routers/u.py:2" in body["remediate"]
assert "move it into models/" in body["remediate"]
@pytest.mark.asyncio
async def test_warn_only_does_not_block(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={
"findings": [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}],
"could_not_run": False,
}
)
assert await c._conventions_gate(_ctx()) is None
@pytest.mark.asyncio
async def test_could_not_run_blocks_loud(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": True})
env = await c._conventions_gate(_ctx())
assert env is not None
assert "could not run" in env.as_dict()["message"]
@pytest.mark.asyncio
async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(check_result=_BLOCK_RESULT)
assert await c._conventions_gate(_ctx()) is None
def test_no_findings_passes() -> None:
result: dict[str, Any] = {"findings": [], "could_not_run": False}
assert Choreographer._conventions_rejection(result, {}) is None
@pytest.mark.asyncio
async def test_gate_records_findings_even_when_blocking(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
recorded: list[dict[str, Any]] = []
async def _spy(_task: Any, result: dict[str, Any]) -> None:
recorded.append(result)
c = _make_choreographer(check_result=_BLOCK_RESULT)
monkeypatch.setattr(c, "_record_convention_findings", _spy)
env = await c._conventions_gate(_ctx())
assert env is not None # still blocks
assert recorded and recorded[0] is _BLOCK_RESULT
@@ -0,0 +1,87 @@
"""The pr_pass conventions gate: a reviewer can't PASS a PR with block violations.
``_gate_decision`` runs ``_conventions_guard`` for ``verb == "pr_pass"`` only
(pr_fail stays available), exactly like the toolchain guard. These exercise the
shared guard the pr_pass path invokes: a ``block`` finding (or a validator that
could not run) refuses; ``warn`` passes; flag-off is inert.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_BLOCK_RESULT: dict[str, Any] = {
"findings": [
{
"file": "app/routers/u.py",
"line": 2,
"level": "block",
"fix_hint": "move it into models/",
}
],
"could_not_run": False,
}
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
@pytest.mark.asyncio
async def test_pr_pass_guard_blocks_on_block_finding(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result=_BLOCK_RESULT)
env = await c._conventions_guard(uuid4(), MagicMock(), {})
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "app/routers/u.py:2" in body["remediate"]
assert "waiver" in body["remediate"]
@pytest.mark.asyncio
async def test_pr_pass_guard_allows_warn(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={
"findings": [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}],
"could_not_run": False,
}
)
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None
@pytest.mark.asyncio
async def test_pr_pass_guard_blocks_when_validator_cannot_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": True})
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is not None
@pytest.mark.asyncio
async def test_pr_pass_guard_inert_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(check_result=_BLOCK_RESULT)
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None
@@ -0,0 +1,83 @@
"""QA claim_review evidence carries the conventions validator findings (gated)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.evidence_builder import build_evidence_for_task
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
@pytest.mark.asyncio
async def test_findings_surfaced_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
findings = [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}]
c = _make_choreographer(check_result={"findings": findings, "could_not_run": False})
assert await c._qa_convention_findings(uuid4(), MagicMock()) == findings
@pytest.mark.asyncio
async def test_empty_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(
check_result={"findings": [{"file": "x"}], "could_not_run": False}
)
assert await c._qa_convention_findings(uuid4(), MagicMock()) == []
@pytest.mark.asyncio
async def test_could_not_run_surfaced_as_single_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={"findings": [], "could_not_run": True, "reason": "boom"}
)
out = await c._qa_convention_findings(uuid4(), MagicMock())
assert len(out) == 1
assert out[0]["could_not_run"] is True
assert out[0]["reason"] == "boom"
def _stub_task() -> MagicMock:
task = MagicMock()
task.pr_number = None
task.pr_url = None
task.commits = []
task.dev_notes = None
task.acceptance_criteria_status = []
return task
def test_evidence_payload_includes_convention_findings() -> None:
findings = [{"file": "x", "line": 1}]
ev = build_evidence_for_task(
_stub_task(),
journal_highlights=[],
files_changed=[],
convention_findings=findings,
)
assert ev.as_dict()["convention_findings"] == findings
def test_evidence_payload_convention_findings_default_empty() -> None:
ev = build_evidence_for_task(_stub_task(), journal_highlights=[], files_changed=[])
assert ev.as_dict()["convention_findings"] == []
+1 -1
View File
@@ -230,7 +230,7 @@ def _wire_spawn_mocks(
monkeypatch.setattr(
orch,
"_generate_composed_prompt",
lambda _aid: Path("/tmp/intake-1-prompt.md"),
lambda *_args, **_kwargs: Path("/tmp/intake-1-prompt.md"),
)
monkeypatch.setattr(
orch,
@@ -0,0 +1,75 @@
"""The first-clone conventions scaffold hook: flag-gated, file-absent, once."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
import roboco.services.workspace as ws_mod
from roboco.config import settings
from roboco.services.workspace import WorkspaceService
if TYPE_CHECKING:
from pathlib import Path
import pytest
class _SpyConventions:
def __init__(self) -> None:
self.scaffolded: list[Any] = []
async def scaffold(self, project: Any, *, workspace: Path) -> None:
self.scaffolded.append((project, workspace))
def _install_spy(monkeypatch: pytest.MonkeyPatch) -> _SpyConventions:
ws_mod._SCAFFOLD_ATTEMPTED.clear()
spy = _SpyConventions()
monkeypatch.setattr(
"roboco.services.conventions.get_conventions_service", lambda _s: spy
)
return spy
async def test_scaffold_fires_when_flag_on_and_file_absent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-a", tmp_path)
assert len(spy.scaffolded) == 1
async def test_no_scaffold_when_flag_off(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-b", tmp_path)
assert spy.scaffolded == []
async def test_no_scaffold_when_file_already_present(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
(tmp_path / ".roboco").mkdir()
(tmp_path / ".roboco" / "conventions.yml").write_text("version: 1\n")
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-c", tmp_path)
assert spy.scaffolded == []
async def test_scaffold_attempted_once_per_project(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-d", tmp_path)
await svc._maybe_scaffold_conventions(object(), "proj-d", tmp_path)
assert len(spy.scaffolded) == 1
Generated
+80
View File
@@ -3011,6 +3011,9 @@ dependencies = [
{ name = "tenacity" },
{ name = "tiktoken" },
{ name = "tomli-w" },
{ name = "tree-sitter" },
{ name = "tree-sitter-python" },
{ name = "tree-sitter-typescript" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
@@ -3099,6 +3102,9 @@ requires-dist = [
{ name = "tenacity" },
{ name = "tiktoken" },
{ name = "tomli-w" },
{ name = "tree-sitter", specifier = ">=0.22" },
{ name = "tree-sitter-python" },
{ name = "tree-sitter-typescript" },
{ name = "types-passlib", marker = "extra == 'dev'" },
{ name = "types-python-jose", marker = "extra == 'dev'" },
{ name = "types-pyyaml", marker = "extra == 'dev'" },
@@ -3695,6 +3701,80 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
]
[[package]]
name = "tree-sitter"
version = "0.25.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/d4/f7ffb855cb039b7568aba4911fbe42e4c39c0e4398387c8e0d8251489992/tree_sitter-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72a510931c3c25f134aac2daf4eb4feca99ffe37a35896d7150e50ac3eee06c7", size = 146749, upload-time = "2025-09-25T17:37:16.475Z" },
{ url = "https://files.pythonhosted.org/packages/9a/58/f8a107f9f89700c0ab2930f1315e63bdedccbb5fd1b10fcbc5ebadd54ac8/tree_sitter-0.25.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44488e0e78146f87baaa009736886516779253d6d6bac3ef636ede72bc6a8234", size = 137766, upload-time = "2025-09-25T17:37:18.138Z" },
{ url = "https://files.pythonhosted.org/packages/19/fb/357158d39f01699faea466e8fd5a849f5a30252c68414bddc20357a9ac79/tree_sitter-0.25.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2f8e7d6b2f8489d4a9885e3adcaef4bc5ff0a275acd990f120e29c4ab3395c5", size = 599809, upload-time = "2025-09-25T17:37:19.169Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/68ae301626f2393a62119481cb660eb93504a524fc741a6f1528a4568cf6/tree_sitter-0.25.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b570690f87f1da424cd690e51cc56728d21d63f4abd4b326d382a30353acc7", size = 627676, upload-time = "2025-09-25T17:37:20.715Z" },
{ url = "https://files.pythonhosted.org/packages/69/fe/4c1bef37db5ca8b17ca0b3070f2dff509468a50b3af18f17665adcab42b9/tree_sitter-0.25.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a0ec41b895da717bc218a42a3a7a0bfcfe9a213d7afaa4255353901e0e21f696", size = 624281, upload-time = "2025-09-25T17:37:21.823Z" },
{ url = "https://files.pythonhosted.org/packages/d4/30/3283cb7fa251cae2a0bf8661658021a789810db3ab1b0569482d4a3671fd/tree_sitter-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:7712335855b2307a21ae86efe949c76be36c6068d76df34faa27ce9ee40ff444", size = 127295, upload-time = "2025-09-25T17:37:22.977Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/ceb05e6de281aebe82b68662890619580d4ffe09283ebd2ceabcf5df7b4a/tree_sitter-0.25.2-cp310-cp310-win_arm64.whl", hash = "sha256:a925364eb7fbb9cdce55a9868f7525a1905af512a559303bd54ef468fd88cb37", size = 113991, upload-time = "2025-09-25T17:37:23.854Z" },
{ url = "https://files.pythonhosted.org/packages/7c/22/88a1e00b906d26fa8a075dd19c6c3116997cb884bf1b3c023deb065a344d/tree_sitter-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ca72d841215b6573ed0655b3a5cd1133f9b69a6fa561aecad40dca9029d75b", size = 146752, upload-time = "2025-09-25T17:37:24.775Z" },
{ url = "https://files.pythonhosted.org/packages/57/1c/22cc14f3910017b7a76d7358df5cd315a84fe0c7f6f7b443b49db2e2790d/tree_sitter-0.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0351cfe5022cec5a77645f647f92a936b38850346ed3f6d6babfbeeeca4d26", size = 137765, upload-time = "2025-09-25T17:37:26.103Z" },
{ url = "https://files.pythonhosted.org/packages/1c/0c/d0de46ded7d5b34631e0f630d9866dab22d3183195bf0f3b81de406d6622/tree_sitter-0.25.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1799609636c0193e16c38f366bda5af15b1ce476df79ddaae7dd274df9e44266", size = 604643, upload-time = "2025-09-25T17:37:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/34/38/b735a58c1c2f60a168a678ca27b4c1a9df725d0bf2d1a8a1c571c033111e/tree_sitter-0.25.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e65ae456ad0d210ee71a89ee112ac7e72e6c2e5aac1b95846ecc7afa68a194c", size = 632229, upload-time = "2025-09-25T17:37:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/32/f6/cda1e1e6cbff5e28d8433578e2556d7ba0b0209d95a796128155b97e7693/tree_sitter-0.25.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49ee3c348caa459244ec437ccc7ff3831f35977d143f65311572b8ba0a5f265f", size = 629861, upload-time = "2025-09-25T17:37:29.593Z" },
{ url = "https://files.pythonhosted.org/packages/f9/19/427e5943b276a0dd74c2a1f1d7a7393443f13d1ee47dedb3f8127903c080/tree_sitter-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:56ac6602c7d09c2c507c55e58dc7026b8988e0475bd0002f8a386cce5e8e8adc", size = 127304, upload-time = "2025-09-25T17:37:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d9/eef856dc15f784d85d1397a17f3ee0f82df7778efce9e1961203abfe376a/tree_sitter-0.25.2-cp311-cp311-win_arm64.whl", hash = "sha256:b3d11a3a3ac89bb8a2543d75597f905a9926f9c806f40fcca8242922d1cc6ad5", size = 113990, upload-time = "2025-09-25T17:37:31.852Z" },
{ url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" },
{ url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" },
{ url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" },
{ url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" },
{ url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" },
{ url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" },
{ url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" },
{ url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" },
{ url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" },
{ url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" },
{ url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" },
{ url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" },
{ url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" },
{ url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" },
{ url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" },
{ url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" },
{ url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" },
{ url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" },
]
[[package]]
name = "tree-sitter-python"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845, upload-time = "2025-09-11T06:47:58.159Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790, upload-time = "2025-09-11T06:47:47.652Z" },
{ url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691, upload-time = "2025-09-11T06:47:49.038Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133, upload-time = "2025-09-11T06:47:50.499Z" },
{ url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603, upload-time = "2025-09-11T06:47:51.985Z" },
{ url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998, upload-time = "2025-09-11T06:47:53.046Z" },
{ url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268, upload-time = "2025-09-11T06:47:54.388Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073, upload-time = "2025-09-11T06:47:55.773Z" },
{ url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" },
]
[[package]]
name = "tree-sitter-typescript"
version = "0.23.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053, upload-time = "2024-11-11T02:36:11.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677, upload-time = "2024-11-11T02:35:58.839Z" },
{ url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008, upload-time = "2024-11-11T02:36:00.733Z" },
{ url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987, upload-time = "2024-11-11T02:36:02.669Z" },
{ url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960, upload-time = "2024-11-11T02:36:04.443Z" },
{ url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245, upload-time = "2024-11-11T02:36:06.473Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015, upload-time = "2024-11-11T02:36:07.631Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052, upload-time = "2024-11-11T02:36:09.514Z" },
]
[[package]]
name = "types-passlib"
version = "1.7.7.20260211"