chipgraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""`ReviewReport`: a review task's structured reply (DESIGN.md 4.8, 5.1).
|
|
2
|
+
|
|
3
|
+
A role that writes no files (the Critic) replies with one JSON object, a `ReviewReport`;
|
|
4
|
+
the engine checks it against what the role was shown and writes it as the task's one
|
|
5
|
+
output (the role's `engine_writes`). What it was shown is a `ReviewScope`: the target,
|
|
6
|
+
the base and head refs, the files of the diff and the new-side line ranges of its hunks
|
|
7
|
+
(`diff_hunks` reads them from a unified diff).
|
|
8
|
+
|
|
9
|
+
`review_problems(report, scope)` is the pure validator; it lists every problem at once:
|
|
10
|
+
|
|
11
|
+
- `target`, `base` or `head` other than the scope's;
|
|
12
|
+
- `reviewed` empty, or (with no comments) missing a file of the diff: "no findings" is a
|
|
13
|
+
claim about every file, so it must say it looked at them all;
|
|
14
|
+
- a duplicate comment id, an empty claim;
|
|
15
|
+
- an empty or unquoted `evidence`: it must quote what the claim rests on, as
|
|
16
|
+
`path:line text` (or `path:start-end text`, or `model:<key> text`);
|
|
17
|
+
- a comment on a file not in `reviewed`, or on a `file:line` outside the diff's hunks;
|
|
18
|
+
- verdict `approve` with a `blocker` or `major` comment, and `changes_requested` with no
|
|
19
|
+
comment.
|
|
20
|
+
|
|
21
|
+
`parse_review(raw)` turns the reply (an object or its JSON text) into a `ReviewReport`,
|
|
22
|
+
or the reasons it is not one. Generic: no harness, tool or project names.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import posixpath
|
|
29
|
+
import re
|
|
30
|
+
from collections.abc import Iterable, Mapping
|
|
31
|
+
from typing import Annotated, Any, Literal
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
|
|
34
|
+
|
|
35
|
+
Severity = Literal["blocker", "major", "minor", "nit"]
|
|
36
|
+
"""How much a comment matters: `blocker` and `major` request changes; `minor` and `nit` do not."""
|
|
37
|
+
|
|
38
|
+
BLOCKING_SEVERITIES: frozenset[str] = frozenset({"blocker", "major"})
|
|
39
|
+
"""Severities an `approve` verdict must not carry."""
|
|
40
|
+
|
|
41
|
+
Verdict = Literal["approve", "changes_requested"]
|
|
42
|
+
|
|
43
|
+
CommentId = Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")]
|
|
44
|
+
"""A comment id, unique in its review, e.g. `R1`."""
|
|
45
|
+
|
|
46
|
+
Category = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_]*$")]
|
|
47
|
+
"""A comment category, lower_snake_case; the review skill lists the ones to use."""
|
|
48
|
+
|
|
49
|
+
_EVIDENCE_RE = re.compile(
|
|
50
|
+
r"^\s*(?:(?P<path>[^\s:]+):(?P<line>\d+)(?:-(?P<end>\d+))?|model:(?P<key>\S+))\s+\S"
|
|
51
|
+
)
|
|
52
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReviewComment(BaseModel):
|
|
56
|
+
"""One finding: where it is, what is wrong, what it rests on, what to do."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
59
|
+
|
|
60
|
+
id: CommentId = Field(description="Unique in the review, e.g. 'R1'.")
|
|
61
|
+
severity: Severity = Field(description="blocker | major (request changes), minor | nit.")
|
|
62
|
+
category: Category = Field(
|
|
63
|
+
description="What kind of problem, lower_snake_case (the review skill lists them)."
|
|
64
|
+
)
|
|
65
|
+
file: str = Field(min_length=1, description="Repo-relative file the comment is on.")
|
|
66
|
+
line: int = Field(ge=1, description="Line in the new version of `file`, inside the diff.")
|
|
67
|
+
claim: str = Field(description="What is wrong, in one or two sentences.")
|
|
68
|
+
evidence: str = Field(
|
|
69
|
+
description=(
|
|
70
|
+
"What the claim rests on, quoted: 'path:line text' (a spec or code line), "
|
|
71
|
+
"'path:start-end text' or 'model:<key> text'. Mandatory."
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
suggestion: str = Field(default="", description="What to change, if known.")
|
|
75
|
+
req_id: str | None = Field(
|
|
76
|
+
default=None, description="The requirement id the comment is about, if any."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReviewReport(BaseModel):
|
|
81
|
+
"""A review task's reply: what was reviewed, the comments, the verdict."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
84
|
+
|
|
85
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
86
|
+
target: str = Field(min_length=1, description="What was reviewed (the block), as given.")
|
|
87
|
+
base: str = Field(min_length=1, description="The base ref of the diff, as given.")
|
|
88
|
+
head: str = Field(min_length=1, description="The head ref of the diff, as given.")
|
|
89
|
+
reviewed: tuple[str, ...] = Field(
|
|
90
|
+
description="Repo-relative files looked at; every file of the diff when no comments."
|
|
91
|
+
)
|
|
92
|
+
comments: tuple[ReviewComment, ...] = Field(
|
|
93
|
+
default=(), description="The findings; empty means no findings."
|
|
94
|
+
)
|
|
95
|
+
summary: str = Field(description="One short paragraph: what changed and the verdict's reason.")
|
|
96
|
+
verdict: Verdict = Field(description="approve, or changes_requested.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ReviewScope(BaseModel):
|
|
100
|
+
"""What a review task was shown: the refs and the diff's files and hunks."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
103
|
+
|
|
104
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
105
|
+
target: str = Field(description="What is reviewed (the block).")
|
|
106
|
+
base: str = Field(description="The base ref of the diff.")
|
|
107
|
+
head: str = Field(description="The head ref of the diff.")
|
|
108
|
+
hunks: dict[str, tuple[tuple[int, int], ...]] = Field(
|
|
109
|
+
default_factory=dict,
|
|
110
|
+
description="Each file of the diff to the new-side line ranges of its hunks (inclusive).",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def files(self) -> tuple[str, ...]:
|
|
115
|
+
"""The files of the diff, sorted."""
|
|
116
|
+
return tuple(sorted(self.hunks))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def diff_hunks(diff: str) -> dict[str, tuple[tuple[int, int], ...]]:
|
|
120
|
+
"""The new-side line ranges (inclusive, context lines included) per file of a diff.
|
|
121
|
+
|
|
122
|
+
`diff` is unified diff text (`git diff` output). A deleted file has no new side and is
|
|
123
|
+
left out; a hunk that only removes lines covers the line before and after the cut.
|
|
124
|
+
"""
|
|
125
|
+
hunks: dict[str, list[tuple[int, int]]] = {}
|
|
126
|
+
current: str | None = None
|
|
127
|
+
prev = ""
|
|
128
|
+
for line in diff.splitlines():
|
|
129
|
+
header, prev = line.startswith("+++ ") and prev.startswith("--- "), line
|
|
130
|
+
if header:
|
|
131
|
+
new = line[4:].strip()
|
|
132
|
+
if new == "/dev/null":
|
|
133
|
+
current = None
|
|
134
|
+
else:
|
|
135
|
+
current = new[2:] if new.startswith("b/") else new
|
|
136
|
+
hunks.setdefault(current, [])
|
|
137
|
+
continue
|
|
138
|
+
match = _HUNK_RE.match(line)
|
|
139
|
+
if match and current is not None:
|
|
140
|
+
start = int(match.group("start"))
|
|
141
|
+
count = int(match.group("count") or "1")
|
|
142
|
+
if count == 0:
|
|
143
|
+
hunks[current].append((max(start, 1), start + 1))
|
|
144
|
+
else:
|
|
145
|
+
hunks[current].append((start, start + count - 1))
|
|
146
|
+
return {path: tuple(ranges) for path, ranges in hunks.items()}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _clean_path(path: str) -> bool:
|
|
150
|
+
text = path.strip()
|
|
151
|
+
if not text or text.startswith("/") or "\\" in text:
|
|
152
|
+
return False
|
|
153
|
+
norm = posixpath.normpath(text)
|
|
154
|
+
return norm == text and norm not in (".", "..") and not norm.startswith("../")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ranges(ranges: Iterable[tuple[int, int]]) -> str:
|
|
158
|
+
return ", ".join(f"{a}-{b}" if a != b else str(a) for a, b in ranges)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def evidence_ok(evidence: str) -> bool:
|
|
162
|
+
"""Whether `evidence` quotes a source: `path:line text`, `path:a-b text`, `model:<key> text`."""
|
|
163
|
+
return bool(_EVIDENCE_RE.match(evidence))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def review_problems(report: ReviewReport, scope: ReviewScope) -> list[str]:
|
|
167
|
+
"""Every reason `report` is not a valid review of `scope` (empty: valid)."""
|
|
168
|
+
problems: list[str] = []
|
|
169
|
+
for name in ("target", "base", "head"):
|
|
170
|
+
got, want = getattr(report, name), getattr(scope, name)
|
|
171
|
+
if got != want:
|
|
172
|
+
problems.append(f"{name} {got!r} is not the task's {name} {want!r}")
|
|
173
|
+
|
|
174
|
+
reviewed = set(report.reviewed)
|
|
175
|
+
if not report.reviewed:
|
|
176
|
+
problems.append("reviewed is empty: list the files you looked at")
|
|
177
|
+
bad = [p for p in report.reviewed if not _clean_path(p)]
|
|
178
|
+
if bad:
|
|
179
|
+
problems.append(f"reviewed has paths that are not repo-relative: {', '.join(bad)}")
|
|
180
|
+
if not report.comments:
|
|
181
|
+
missing = [f for f in scope.files if f not in reviewed]
|
|
182
|
+
if missing:
|
|
183
|
+
problems.append(
|
|
184
|
+
"a review with no comments says the whole diff is fine, so reviewed must "
|
|
185
|
+
f"list every file of the diff; missing: {', '.join(missing)}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
for comment in report.comments:
|
|
190
|
+
where = f"comment {comment.id}"
|
|
191
|
+
if comment.id in seen:
|
|
192
|
+
problems.append(f"{where}: duplicate comment id {comment.id!r}")
|
|
193
|
+
seen.add(comment.id)
|
|
194
|
+
if not comment.claim.strip():
|
|
195
|
+
problems.append(f"{where}: claim is empty")
|
|
196
|
+
if not comment.evidence.strip():
|
|
197
|
+
problems.append(
|
|
198
|
+
f"{where}: evidence is empty; quote the spec line or code the claim rests "
|
|
199
|
+
"on, as 'path:line text'"
|
|
200
|
+
)
|
|
201
|
+
elif not evidence_ok(comment.evidence):
|
|
202
|
+
problems.append(
|
|
203
|
+
f"{where}: evidence {comment.evidence[:80]!r} does not quote a source; "
|
|
204
|
+
"write 'path:line text' (or 'path:start-end text', 'model:<key> text')"
|
|
205
|
+
)
|
|
206
|
+
if comment.file not in reviewed:
|
|
207
|
+
problems.append(f"{where}: file {comment.file!r} is not in reviewed")
|
|
208
|
+
ranges = scope.hunks.get(comment.file)
|
|
209
|
+
if ranges is None:
|
|
210
|
+
problems.append(
|
|
211
|
+
f"{where}: {comment.file}:{comment.line} is outside the diff "
|
|
212
|
+
f"({comment.file!r} is not one of its files: {', '.join(scope.files) or 'none'})"
|
|
213
|
+
)
|
|
214
|
+
elif not any(a <= comment.line <= b for a, b in ranges):
|
|
215
|
+
problems.append(
|
|
216
|
+
f"{where}: {comment.file}:{comment.line} is outside the diff (its hunks "
|
|
217
|
+
f"cover lines {_ranges(ranges)} of {comment.file})"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
blocking = [c.id for c in report.comments if c.severity in BLOCKING_SEVERITIES]
|
|
221
|
+
if report.verdict == "approve" and blocking:
|
|
222
|
+
problems.append(
|
|
223
|
+
f"verdict 'approve' with blocker or major comments ({', '.join(blocking)}); "
|
|
224
|
+
"use 'changes_requested' or lower their severity"
|
|
225
|
+
)
|
|
226
|
+
if report.verdict == "changes_requested" and not report.comments:
|
|
227
|
+
problems.append("verdict 'changes_requested' with no comments; say what to change")
|
|
228
|
+
return problems
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validation_lines(exc: ValidationError) -> list[str]:
|
|
232
|
+
lines = []
|
|
233
|
+
for error in exc.errors():
|
|
234
|
+
loc = ".".join(str(p) for p in error.get("loc", ())) or "review"
|
|
235
|
+
lines.append(f"review.{loc}: {error.get('msg', 'invalid')}")
|
|
236
|
+
return lines
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_review(raw: Mapping[str, Any] | str) -> tuple[ReviewReport | None, list[str]]:
|
|
240
|
+
"""The reply as a `ReviewReport`, or `None` and why it is not one.
|
|
241
|
+
|
|
242
|
+
`raw` is the reply object, or its JSON text (a fenced ```json block is unwrapped).
|
|
243
|
+
"""
|
|
244
|
+
data: Any = raw
|
|
245
|
+
if isinstance(raw, str):
|
|
246
|
+
text = raw.strip()
|
|
247
|
+
fenced = re.fullmatch(r"```(?:json)?\s*\n(?P<body>.*)\n```", text, flags=re.DOTALL)
|
|
248
|
+
if fenced:
|
|
249
|
+
text = fenced.group("body")
|
|
250
|
+
try:
|
|
251
|
+
data = json.loads(text)
|
|
252
|
+
except json.JSONDecodeError as exc:
|
|
253
|
+
return None, [f"review is not valid JSON: {exc}"]
|
|
254
|
+
if not isinstance(data, Mapping):
|
|
255
|
+
return None, [f"review must be a JSON object, not {type(data).__name__}"]
|
|
256
|
+
try:
|
|
257
|
+
return ReviewReport.model_validate(dict(data)), []
|
|
258
|
+
except ValidationError as exc:
|
|
259
|
+
return None, _validation_lines(exc)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = [
|
|
263
|
+
"BLOCKING_SEVERITIES",
|
|
264
|
+
"Category",
|
|
265
|
+
"CommentId",
|
|
266
|
+
"ReviewComment",
|
|
267
|
+
"ReviewReport",
|
|
268
|
+
"ReviewScope",
|
|
269
|
+
"Severity",
|
|
270
|
+
"Verdict",
|
|
271
|
+
"diff_hunks",
|
|
272
|
+
"evidence_ok",
|
|
273
|
+
"parse_review",
|
|
274
|
+
"review_problems",
|
|
275
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""JSON Schema files for the role and skill files and the review reply, under `schemas/roles/`.
|
|
2
|
+
|
|
3
|
+
python -m chipgraph.core.runtime.roles.schema [--check] [--out DIR]
|
|
4
|
+
|
|
5
|
+
`make schemas` runs it after the contract schemas; `make schemas-check` with `--check`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from chipgraph.core.runtime.roles.review import ReviewReport
|
|
16
|
+
from chipgraph.core.runtime.roles.skills import SkillSpec
|
|
17
|
+
from chipgraph.core.runtime.roles.spec import RoleSpec
|
|
18
|
+
|
|
19
|
+
SCHEMA_MODELS = (RoleSpec, SkillSpec, ReviewReport)
|
|
20
|
+
"""The models whose schemas are committed, as `<Model>.schema.json`."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate() -> dict[str, str]:
|
|
24
|
+
"""File name to content, for every schema file."""
|
|
25
|
+
files = {}
|
|
26
|
+
for model in SCHEMA_MODELS:
|
|
27
|
+
schema = model.model_json_schema()
|
|
28
|
+
files[f"{model.__name__}.schema.json"] = json.dumps(schema, indent=2, sort_keys=True) + "\n"
|
|
29
|
+
return files
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _repo_schemas_dir() -> Path:
|
|
33
|
+
here = Path(__file__).resolve()
|
|
34
|
+
for candidate in here.parents:
|
|
35
|
+
if (candidate / "pyproject.toml").is_file():
|
|
36
|
+
return candidate / "schemas" / "roles"
|
|
37
|
+
raise FileNotFoundError(f"no pyproject.toml found above {here}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(out_dir: Path) -> list[str]:
|
|
41
|
+
"""Problems with the committed files in `out_dir` (empty: up to date)."""
|
|
42
|
+
expected = generate()
|
|
43
|
+
problems = []
|
|
44
|
+
for name, content in expected.items():
|
|
45
|
+
path = out_dir / name
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
problems.append(f"missing: roles/{name}")
|
|
48
|
+
elif path.read_text(encoding="utf-8") != content:
|
|
49
|
+
problems.append(f"outdated: roles/{name}")
|
|
50
|
+
if out_dir.is_dir():
|
|
51
|
+
for path in sorted(out_dir.glob("*.schema.json")):
|
|
52
|
+
if path.name not in expected:
|
|
53
|
+
problems.append(f"extra: roles/{path.name}")
|
|
54
|
+
return problems
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str] | None = None) -> int:
|
|
58
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--check", action="store_true", help="check, do not write; exit 1 if out of date"
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("--out", type=Path, default=None, help="default: schemas/roles/")
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
out_dir: Path = args.out if args.out is not None else _repo_schemas_dir()
|
|
65
|
+
if args.check:
|
|
66
|
+
problems = check(out_dir)
|
|
67
|
+
for problem in problems:
|
|
68
|
+
print(f" {problem}", file=sys.stderr)
|
|
69
|
+
if problems:
|
|
70
|
+
print("role schemas out of date: run `make schemas`", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
print(f"role schemas up to date in {out_dir}")
|
|
73
|
+
return 0
|
|
74
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
for name, content in generate().items():
|
|
76
|
+
(out_dir / name).write_text(content, encoding="utf-8")
|
|
77
|
+
print(f"wrote role schemas to {out_dir}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Skills: per-artifact-kind instructions a role loads for a task (DESIGN.md 5.1, D8).
|
|
2
|
+
|
|
3
|
+
A skill is a markdown file with a YAML frontmatter::
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
id: lang-sv/rtl # <namespace>/<name>
|
|
7
|
+
description: One line.
|
|
8
|
+
roles: [author] # the roles that may use it
|
|
9
|
+
version: 0.1.0
|
|
10
|
+
---
|
|
11
|
+
The instructions (the body).
|
|
12
|
+
|
|
13
|
+
Packs list skill files, or directories of `*.md` skill files, in `provides.skills`.
|
|
14
|
+
`load_skills(packs)` reads every skill of the given packs; `SkillSet.resolve(ids, role)`
|
|
15
|
+
returns the skills a task names, and refuses an unknown id or a skill its role may not
|
|
16
|
+
use.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import MappingProxyType
|
|
24
|
+
from typing import Annotated, Literal
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
|
|
28
|
+
|
|
29
|
+
from chipgraph.core.plugin_api.pack import Pack
|
|
30
|
+
from chipgraph.core.runtime.roles.registry import find_role
|
|
31
|
+
from chipgraph.core.runtime.roles.spec import RoleId, split_frontmatter
|
|
32
|
+
|
|
33
|
+
SkillId = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9_-]*$")]
|
|
34
|
+
"""A skill id, `<namespace>/<name>`, e.g. `lang-sv/rtl`."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SkillError(Exception):
|
|
38
|
+
"""A broken skill file, a duplicate or unknown id, or a skill its role may not use."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SkillSpec(BaseModel):
|
|
42
|
+
"""One skill: its frontmatter plus the instructions (the file's body)."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
45
|
+
|
|
46
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
47
|
+
id: SkillId = Field(description="The skill id, '<namespace>/<name>'.")
|
|
48
|
+
description: str = Field(min_length=1, description="One line: what the skill is for.")
|
|
49
|
+
roles: tuple[RoleId, ...] = Field(min_length=1, description="The roles that may use it.")
|
|
50
|
+
version: Annotated[str, StringConstraints(pattern=r"^\d+\.\d+\.\d+$")] = Field(
|
|
51
|
+
description="The skill's version, MAJOR.MINOR.PATCH."
|
|
52
|
+
)
|
|
53
|
+
text: str = Field(min_length=1, description="The instructions: the file's body.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_skill_file(path: Path) -> SkillSpec:
|
|
57
|
+
"""Load one skill file. Raises `SkillError` for a bad file or an unknown role."""
|
|
58
|
+
try:
|
|
59
|
+
data, body = split_frontmatter(path.read_text(encoding="utf-8"), source=str(path))
|
|
60
|
+
if "text" in data:
|
|
61
|
+
raise ValueError(f"{path}: the text is the file's body, not a frontmatter key")
|
|
62
|
+
skill = SkillSpec.model_validate({**data, "text": body})
|
|
63
|
+
except (OSError, ValueError, ValidationError, yaml.YAMLError) as exc:
|
|
64
|
+
raise SkillError(f"invalid skill file {path}: {exc}") from exc
|
|
65
|
+
unknown = [r for r in skill.roles if find_role(r) is None]
|
|
66
|
+
if unknown:
|
|
67
|
+
raise SkillError(f"skill {skill.id!r} in {path} names unknown roles: {', '.join(unknown)}")
|
|
68
|
+
return skill
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _skill_files(path: Path) -> list[Path]:
|
|
72
|
+
return sorted(path.glob("*.md")) if path.is_dir() else [path]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SkillSet:
|
|
76
|
+
"""The skills of a set of packs, by id."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, skills: Mapping[str, SkillSpec], sources: Mapping[str, str]) -> None:
|
|
79
|
+
self.skills = MappingProxyType(dict(skills))
|
|
80
|
+
self._sources = MappingProxyType(dict(sources))
|
|
81
|
+
|
|
82
|
+
def source(self, skill_id: str) -> str:
|
|
83
|
+
"""Where a skill was loaded from (pack name and file)."""
|
|
84
|
+
return self._sources[skill_id]
|
|
85
|
+
|
|
86
|
+
def get(self, skill_id: str) -> SkillSpec:
|
|
87
|
+
"""The skill `skill_id`; raises `SkillError` if no loaded pack provides it."""
|
|
88
|
+
skill = self.skills.get(skill_id)
|
|
89
|
+
if skill is None:
|
|
90
|
+
known = ", ".join(sorted(self.skills)) or "none"
|
|
91
|
+
raise SkillError(
|
|
92
|
+
f"unknown skill {skill_id!r}: no pack of this project provides it "
|
|
93
|
+
f"(skills available: {known})"
|
|
94
|
+
)
|
|
95
|
+
return skill
|
|
96
|
+
|
|
97
|
+
def resolve(self, skill_ids: Iterable[str], role: str) -> tuple[SkillSpec, ...]:
|
|
98
|
+
"""The skills `skill_ids` for a task of `role`, in order.
|
|
99
|
+
|
|
100
|
+
Raises `SkillError` for an unknown id, or a skill whose `roles` does not list
|
|
101
|
+
`role` (a `<namespace>/` prefix on the role is ignored).
|
|
102
|
+
"""
|
|
103
|
+
role_id = role.rsplit("/", 1)[-1]
|
|
104
|
+
found: list[SkillSpec] = []
|
|
105
|
+
for skill_id in skill_ids:
|
|
106
|
+
skill = self.get(skill_id)
|
|
107
|
+
if role_id not in skill.roles:
|
|
108
|
+
raise SkillError(
|
|
109
|
+
f"skill {skill_id!r} is not for role {role_id!r} "
|
|
110
|
+
f"(it may be used by: {', '.join(skill.roles)})"
|
|
111
|
+
)
|
|
112
|
+
found.append(skill)
|
|
113
|
+
return tuple(found)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_skills(packs: Iterable[Pack]) -> SkillSet:
|
|
117
|
+
"""Every skill the packs provide. Raises `SkillError` for a broken file or a duplicate id."""
|
|
118
|
+
skills: dict[str, SkillSpec] = {}
|
|
119
|
+
sources: dict[str, str] = {}
|
|
120
|
+
for pack in packs:
|
|
121
|
+
for rel in pack.manifest.provides.skills:
|
|
122
|
+
for path in _skill_files(pack.path(rel)):
|
|
123
|
+
skill = parse_skill_file(path)
|
|
124
|
+
where = f"pack {pack.manifest.name!r} ({path})"
|
|
125
|
+
if skill.id in skills:
|
|
126
|
+
raise SkillError(
|
|
127
|
+
f"duplicate skill id {skill.id!r}: in {sources[skill.id]} and {where}"
|
|
128
|
+
)
|
|
129
|
+
skills[skill.id] = skill
|
|
130
|
+
sources[skill.id] = where
|
|
131
|
+
return SkillSet(skills, sources)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["SkillError", "SkillId", "SkillSet", "SkillSpec", "load_skills", "parse_skill_file"]
|