mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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'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.",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(" ", "-")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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=(",", ":"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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) == []
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user