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,232 @@
|
|
|
1
|
+
"""`RoleSpec`: one agent role as data (DESIGN.md 5.1, D8).
|
|
2
|
+
|
|
3
|
+
A role is a system prompt plus a **tool table**: the abstract capabilities it is granted,
|
|
4
|
+
what it may write, what it may read, and its model tier with the tier it escalates to.
|
|
5
|
+
An agent runtime maps the capabilities to its own tools (the runtime `claude-code`
|
|
6
|
+
renders them into its subagent definitions); the engine and the runtime's guard enforce
|
|
7
|
+
the write scope and the read policy.
|
|
8
|
+
|
|
9
|
+
A role file is markdown with a YAML frontmatter (the fields below, without `prompt`);
|
|
10
|
+
the body is the prompt. The prompt names tools only through placeholders,
|
|
11
|
+
`{tool:<capability>}`, which the runtime replaces with its own tool names, so this data
|
|
12
|
+
stays free of any harness's tool names.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, Literal, Self
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from pydantic import (
|
|
23
|
+
BaseModel,
|
|
24
|
+
ConfigDict,
|
|
25
|
+
Field,
|
|
26
|
+
StringConstraints,
|
|
27
|
+
ValidationError,
|
|
28
|
+
model_validator,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from chipgraph.core.contracts import ArtifactKind, ModelTier
|
|
32
|
+
|
|
33
|
+
Capability = Literal[
|
|
34
|
+
"read_files",
|
|
35
|
+
"search_files",
|
|
36
|
+
"write_outputs",
|
|
37
|
+
"engine_context",
|
|
38
|
+
"engine_decisions",
|
|
39
|
+
]
|
|
40
|
+
"""An abstract tool capability a role may be granted.
|
|
41
|
+
|
|
42
|
+
- `read_files`: read a project file by path.
|
|
43
|
+
- `search_files`: list files by pattern and search their content.
|
|
44
|
+
- `write_outputs`: create and edit files (only the task's outputs: the guard enforces it).
|
|
45
|
+
- `engine_context`: ask the engine for the task's context (`get_context`).
|
|
46
|
+
- `engine_decisions`: read the engine's pending multiple-choice questions.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
CAPABILITIES: tuple[Capability, ...] = (
|
|
50
|
+
"read_files",
|
|
51
|
+
"search_files",
|
|
52
|
+
"write_outputs",
|
|
53
|
+
"engine_context",
|
|
54
|
+
"engine_decisions",
|
|
55
|
+
)
|
|
56
|
+
"""Every capability, in the order runtimes list their tools."""
|
|
57
|
+
|
|
58
|
+
WriteScope = Literal["outputs", "plan", "proposal", "none"]
|
|
59
|
+
"""What a role may write: its rule's outputs, only its plan file, only its proposal file,
|
|
60
|
+
or nothing. `plan` and `proposal` rules have exactly one output (the rule validator checks
|
|
61
|
+
it); every write is still bounded by the task's outputs."""
|
|
62
|
+
|
|
63
|
+
RoleId = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")]
|
|
64
|
+
"""A role id: lower case words joined by `-`, e.g. `author`, `tb-author`."""
|
|
65
|
+
|
|
66
|
+
_PLACEHOLDER_RE = re.compile(r"\{tool:([a-z_]+)\}")
|
|
67
|
+
|
|
68
|
+
_TIER_ORDER: dict[ModelTier, int] = {"small": 0, "medium": 1, "large": 2}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RoleError(Exception):
|
|
72
|
+
"""An unknown role, a broken role file, or a rule that breaks its role's tool table."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ReadPolicy(BaseModel):
|
|
76
|
+
"""What a role may read with its own tools (the engine's context is checked apart)."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
79
|
+
|
|
80
|
+
mode: Literal["any", "deny"] = Field(
|
|
81
|
+
default="any",
|
|
82
|
+
description="'any': no read restriction; 'deny': never read the kinds and globs below.",
|
|
83
|
+
)
|
|
84
|
+
deny_kinds: tuple[ArtifactKind, ...] = Field(
|
|
85
|
+
default=(),
|
|
86
|
+
description=(
|
|
87
|
+
"Artifact kinds the role must not see: every artifact of these kinds in the "
|
|
88
|
+
"build graph, and the profile's layout paths for them."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
deny_globs: tuple[str, ...] = Field(
|
|
92
|
+
default=(), description="Extra repo-relative path globs the role must not read."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def _check_mode(self) -> Self:
|
|
97
|
+
denies = bool(self.deny_kinds or self.deny_globs)
|
|
98
|
+
if self.mode == "any" and denies:
|
|
99
|
+
raise ValueError("read_policy mode 'any' takes no deny_kinds or deny_globs")
|
|
100
|
+
if self.mode == "deny" and not denies:
|
|
101
|
+
raise ValueError("read_policy mode 'deny' needs deny_kinds or deny_globs")
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RoleSpec(BaseModel):
|
|
106
|
+
"""One agent role: its prompt and its tool table (DESIGN.md 5.1)."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
109
|
+
|
|
110
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
111
|
+
id: RoleId = Field(description="The role id rules name in `role:`, e.g. 'author'.")
|
|
112
|
+
description: str = Field(
|
|
113
|
+
min_length=1,
|
|
114
|
+
description="One line: what the role does and how it is started. No line breaks.",
|
|
115
|
+
)
|
|
116
|
+
prompt: str = Field(
|
|
117
|
+
min_length=1,
|
|
118
|
+
description="The role's system prompt; names tools only as {tool:<capability>}.",
|
|
119
|
+
)
|
|
120
|
+
default_tier: ModelTier = Field(description="Model tier of the first attempt.")
|
|
121
|
+
escalate_to: ModelTier | None = Field(
|
|
122
|
+
default=None, description="Model tier of later attempts, after a rejected one."
|
|
123
|
+
)
|
|
124
|
+
tools: tuple[Capability, ...] = Field(
|
|
125
|
+
description="Abstract capabilities granted; the runtime maps them to its tools."
|
|
126
|
+
)
|
|
127
|
+
write_scope: WriteScope = Field(description="What the role may write.")
|
|
128
|
+
read_policy: ReadPolicy = Field(
|
|
129
|
+
default_factory=ReadPolicy, description="What the role may read with its own tools."
|
|
130
|
+
)
|
|
131
|
+
shell: Literal[False] = Field(
|
|
132
|
+
default=False, description="Roles never get a shell (the guard refuses one)."
|
|
133
|
+
)
|
|
134
|
+
dispatch: bool = Field(
|
|
135
|
+
default=True,
|
|
136
|
+
description=(
|
|
137
|
+
"Whether agent rules may use this role (handed out as tasks); false for a role "
|
|
138
|
+
"served by another path, such as the fast decision layer."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
engine_writes: tuple[ArtifactKind, ...] = Field(
|
|
142
|
+
default=(),
|
|
143
|
+
description=(
|
|
144
|
+
"For a role that writes no files (write_scope 'none'): the artifact kinds of the "
|
|
145
|
+
"one output the engine writes from the role's structured reply. A rule of this "
|
|
146
|
+
"role then declares exactly that one output; the role itself never writes it."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@model_validator(mode="after")
|
|
151
|
+
def _check_table(self) -> Self:
|
|
152
|
+
if "\n" in self.description:
|
|
153
|
+
raise ValueError("description must be one line")
|
|
154
|
+
if self.engine_writes and self.write_scope != "none":
|
|
155
|
+
raise ValueError("engine_writes is only for a role whose write_scope is 'none'")
|
|
156
|
+
if len(set(self.engine_writes)) != len(self.engine_writes):
|
|
157
|
+
raise ValueError("engine_writes lists a kind twice")
|
|
158
|
+
if len(set(self.tools)) != len(self.tools):
|
|
159
|
+
raise ValueError("tools lists a capability twice")
|
|
160
|
+
if self.escalate_to is not None and (
|
|
161
|
+
_TIER_ORDER[self.escalate_to] <= _TIER_ORDER[self.default_tier]
|
|
162
|
+
):
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"escalate_to {self.escalate_to!r} must be higher than "
|
|
165
|
+
f"default_tier {self.default_tier!r}"
|
|
166
|
+
)
|
|
167
|
+
writes = "write_outputs" in self.tools
|
|
168
|
+
if writes != (self.write_scope != "none"):
|
|
169
|
+
raise ValueError("write_outputs is granted exactly when write_scope is not 'none'")
|
|
170
|
+
for name in _PLACEHOLDER_RE.findall(self.prompt):
|
|
171
|
+
if name not in self.tools:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"prompt names {{tool:{name}}}, a capability the role is not granted"
|
|
174
|
+
)
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def can_read_files(self) -> bool:
|
|
179
|
+
"""Whether the role has its own file-reading or file-search tools."""
|
|
180
|
+
return "read_files" in self.tools or "search_files" in self.tools
|
|
181
|
+
|
|
182
|
+
def render_prompt(self, tool_names: dict[str, tuple[str, ...]]) -> str:
|
|
183
|
+
"""The prompt with each `{tool:<capability>}` replaced by the runtime's tool names."""
|
|
184
|
+
|
|
185
|
+
def _replace(match: re.Match[str]) -> str:
|
|
186
|
+
names = tool_names.get(match.group(1))
|
|
187
|
+
if not names:
|
|
188
|
+
raise RoleError(f"role {self.id!r}: no tool for capability {match.group(1)!r}")
|
|
189
|
+
return ", ".join(f"`{name}`" for name in names)
|
|
190
|
+
|
|
191
|
+
return _PLACEHOLDER_RE.sub(_replace, self.prompt)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def split_frontmatter(text: str, *, source: str) -> tuple[dict[str, object], str]:
|
|
195
|
+
"""Split a markdown file into its YAML frontmatter mapping and its body."""
|
|
196
|
+
if not text.startswith("---\n"):
|
|
197
|
+
raise ValueError(f"{source}: the frontmatter must open with '---' on the first line")
|
|
198
|
+
head, sep, body = text[4:].partition("\n---\n")
|
|
199
|
+
if not sep:
|
|
200
|
+
raise ValueError(f"{source}: the frontmatter is not closed with '---'")
|
|
201
|
+
data = yaml.safe_load(head)
|
|
202
|
+
if not isinstance(data, dict):
|
|
203
|
+
raise ValueError(f"{source}: the frontmatter is not a mapping")
|
|
204
|
+
text = body.strip("\n")
|
|
205
|
+
return {str(k): v for k, v in data.items()}, (text + "\n") if text.strip() else ""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_role_file(path: Path) -> RoleSpec:
|
|
209
|
+
"""Load one role file (frontmatter + prompt body). Raises `RoleError`."""
|
|
210
|
+
try:
|
|
211
|
+
data, body = split_frontmatter(path.read_text(encoding="utf-8"), source=str(path))
|
|
212
|
+
if "prompt" in data:
|
|
213
|
+
raise ValueError(f"{path}: the prompt is the file's body, not a frontmatter key")
|
|
214
|
+
role = RoleSpec.model_validate({**data, "prompt": body})
|
|
215
|
+
except (OSError, ValueError, ValidationError, yaml.YAMLError) as exc:
|
|
216
|
+
raise RoleError(f"invalid role file {path}: {exc}") from exc
|
|
217
|
+
if role.id != path.stem:
|
|
218
|
+
raise RoleError(f"role file {path} defines role {role.id!r}; name it {role.id}.md")
|
|
219
|
+
return role
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
__all__ = [
|
|
223
|
+
"CAPABILITIES",
|
|
224
|
+
"Capability",
|
|
225
|
+
"ReadPolicy",
|
|
226
|
+
"RoleError",
|
|
227
|
+
"RoleId",
|
|
228
|
+
"RoleSpec",
|
|
229
|
+
"WriteScope",
|
|
230
|
+
"parse_role_file",
|
|
231
|
+
"split_frontmatter",
|
|
232
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine state: journal, run directories, locks, and the artifact store."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Content hashing, data labels, and the file-based artifact store.
|
|
2
|
+
|
|
3
|
+
Pure Python: no subprocess calls and no knowledge of git or any other VCS. The store
|
|
4
|
+
only ever hashes bytes it reads itself; `chipgraph.adapters.vcs` is what talks to git.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
from chipgraph.core.contracts.artifact import Artifact, ArtifactRef, ProducedBy
|
|
14
|
+
from chipgraph.core.contracts.types import ArtifactKind, DataLabel, Sha256
|
|
15
|
+
|
|
16
|
+
_CHUNK_SIZE = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_bytes(data: bytes) -> str:
|
|
20
|
+
"""Return the lowercase hex sha256 digest of `data`."""
|
|
21
|
+
return hashlib.sha256(data).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_file(path: Path) -> str:
|
|
25
|
+
"""Return the lowercase hex sha256 digest of the file at `path`, read in chunks."""
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
with path.open("rb") as handle:
|
|
28
|
+
while chunk := handle.read(_CHUNK_SIZE):
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
return digest.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hash_inputs(hashes: Iterable[tuple[str, str]]) -> str:
|
|
34
|
+
"""Return a stable sha256 over `(key, hash)` pairs, independent of iteration order.
|
|
35
|
+
|
|
36
|
+
Each pair is typically `("<repo>:<path or model_key>", content_hash)`. Pairs are
|
|
37
|
+
sorted before hashing so the same set of inputs always yields the same digest,
|
|
38
|
+
regardless of the order they were produced or discovered in.
|
|
39
|
+
"""
|
|
40
|
+
digest = hashlib.sha256()
|
|
41
|
+
for key, value in sorted(hashes):
|
|
42
|
+
digest.update(key.encode("utf-8"))
|
|
43
|
+
digest.update(b"\0")
|
|
44
|
+
digest.update(value.encode("utf-8"))
|
|
45
|
+
digest.update(b"\n")
|
|
46
|
+
return digest.hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LabelRules:
|
|
50
|
+
"""Maps a repo-relative path to a `DataLabel` by matching POSIX globs.
|
|
51
|
+
|
|
52
|
+
Built from an ordered mapping of `glob -> DataLabel` plus a default label used
|
|
53
|
+
when nothing matches. The most specific matching glob wins: the longest pattern
|
|
54
|
+
string; ties are broken by preferring the entry that appears later in `globs`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
globs: Mapping[str, DataLabel] | None = None,
|
|
60
|
+
*,
|
|
61
|
+
default: DataLabel = "internal",
|
|
62
|
+
) -> None:
|
|
63
|
+
self._globs: tuple[tuple[str, DataLabel], ...] = tuple((globs or {}).items())
|
|
64
|
+
self._default = default
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def default(self) -> DataLabel:
|
|
68
|
+
"""The label used when no glob matches."""
|
|
69
|
+
return self._default
|
|
70
|
+
|
|
71
|
+
def label_for(self, rel_path: str) -> DataLabel:
|
|
72
|
+
"""Return the label for `rel_path`, applying the most specific matching glob."""
|
|
73
|
+
path = PurePosixPath(rel_path)
|
|
74
|
+
best_pattern: str | None = None
|
|
75
|
+
best_label: DataLabel = self._default
|
|
76
|
+
for pattern, label in self._globs:
|
|
77
|
+
if not _glob_matches(path, pattern):
|
|
78
|
+
continue
|
|
79
|
+
if best_pattern is None or len(pattern) >= len(best_pattern):
|
|
80
|
+
best_pattern = pattern
|
|
81
|
+
best_label = label
|
|
82
|
+
return best_label
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _glob_matches(path: PurePosixPath, pattern: str) -> bool:
|
|
86
|
+
"""Match `path` against a POSIX glob `pattern`, where `**` spans directories."""
|
|
87
|
+
return path.full_match(pattern)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ArtifactStore:
|
|
91
|
+
"""Reads and hashes file-based artifacts under `root`, applying `labels`."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, root: Path, labels: LabelRules, repo: str = ".") -> None:
|
|
94
|
+
self.root = root
|
|
95
|
+
self.labels = labels
|
|
96
|
+
self.repo = repo
|
|
97
|
+
|
|
98
|
+
def ref(self, rel_path: str, kind: ArtifactKind) -> ArtifactRef:
|
|
99
|
+
"""Build an `ArtifactRef` for `rel_path`, applying the configured data label."""
|
|
100
|
+
return ArtifactRef(
|
|
101
|
+
repo=self.repo,
|
|
102
|
+
kind=kind,
|
|
103
|
+
path=rel_path,
|
|
104
|
+
label=self.labels.label_for(rel_path),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def artifact(
|
|
108
|
+
self,
|
|
109
|
+
rel_path: str,
|
|
110
|
+
kind: ArtifactKind,
|
|
111
|
+
produced_by: ProducedBy | None = None,
|
|
112
|
+
inputs_hash: Sha256 | None = None,
|
|
113
|
+
) -> Artifact:
|
|
114
|
+
"""Build an `Artifact` for `rel_path`, hashing its current content.
|
|
115
|
+
|
|
116
|
+
Raises `FileNotFoundError` with a clear message if the file does not exist
|
|
117
|
+
under `root`.
|
|
118
|
+
"""
|
|
119
|
+
ref = self.ref(rel_path, kind)
|
|
120
|
+
full_path = self.root / rel_path
|
|
121
|
+
if not full_path.is_file():
|
|
122
|
+
raise FileNotFoundError(
|
|
123
|
+
f"cannot hash artifact {rel_path!r}: no such file under {self.root}"
|
|
124
|
+
)
|
|
125
|
+
return Artifact(
|
|
126
|
+
ref=ref,
|
|
127
|
+
content_hash=hash_file(full_path),
|
|
128
|
+
produced_by=produced_by,
|
|
129
|
+
inputs_hash=inputs_hash,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def exists(self, ref: ArtifactRef) -> bool:
|
|
133
|
+
"""Return whether `ref` currently exists on disk (path-based refs only)."""
|
|
134
|
+
rel_path = self._require_path(ref)
|
|
135
|
+
return (self.root / rel_path).is_file()
|
|
136
|
+
|
|
137
|
+
def current_hashes(self, refs: Iterable[ArtifactRef]) -> dict[str, str]:
|
|
138
|
+
"""Return `{"<repo>:<path>": content_hash}` for every `ref` that exists.
|
|
139
|
+
|
|
140
|
+
Refs whose file is missing are skipped (not included in the result), rather
|
|
141
|
+
than mapped to an empty-string hash.
|
|
142
|
+
"""
|
|
143
|
+
result: dict[str, str] = {}
|
|
144
|
+
for ref in refs:
|
|
145
|
+
rel_path = self._require_path(ref)
|
|
146
|
+
full_path = self.root / rel_path
|
|
147
|
+
if not full_path.is_file():
|
|
148
|
+
continue
|
|
149
|
+
result[f"{ref.repo}:{rel_path}"] = hash_file(full_path)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _require_path(ref: ArtifactRef) -> str:
|
|
154
|
+
if ref.path is None:
|
|
155
|
+
raise ValueError("model artifacts are hashed by the Design Model")
|
|
156
|
+
return ref.path
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""State backends: where `.chipgraph/state/` actually lives (DESIGN.md 6.1).
|
|
2
|
+
|
|
3
|
+
This task implements only the `local` backend, which keeps state on the local disk under
|
|
4
|
+
the project repo, hidden from git via `.git/info/exclude` (never by editing a tracked
|
|
5
|
+
`.gitignore`). The `branch:<name>` (and `repo:...`) backends, which push run state to an
|
|
6
|
+
orphan branch or a separate state repository so a run can be resumed or shared from
|
|
7
|
+
another machine, need git operations and are left to a later task in the adapters layer —
|
|
8
|
+
`chipgraph.core` must not shell out to git (see AGENTS.md: "no subprocess, no git commands
|
|
9
|
+
in this code").
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from chipgraph.core.state.layout import StateLayout
|
|
18
|
+
|
|
19
|
+
_EXCLUDE_LINE = "/.chipgraph/state/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class StateBackend(Protocol):
|
|
24
|
+
"""Where a project's run state is stored, and how to make sure it's ready to use."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def prepare(self) -> None:
|
|
29
|
+
"""Make sure the backend is ready to use (e.g. create directories, hide from git)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def layout(self) -> StateLayout:
|
|
34
|
+
"""The paths this backend stores state under."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_common_dir(root: Path) -> Path | None:
|
|
39
|
+
"""Resolve `root/.git`'s common git dir, without shelling out to git.
|
|
40
|
+
|
|
41
|
+
Handles both a plain repo (`.git` is a directory) and a worktree (`.git` is a file
|
|
42
|
+
containing `gitdir: <path>`, whose target directory has a `commondir` file pointing at
|
|
43
|
+
the real common git dir, relative to that `gitdir` path).
|
|
44
|
+
"""
|
|
45
|
+
git_path = root / ".git"
|
|
46
|
+
if git_path.is_dir():
|
|
47
|
+
return git_path
|
|
48
|
+
if not git_path.is_file():
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
content = git_path.read_text(encoding="utf-8").strip()
|
|
52
|
+
prefix = "gitdir:"
|
|
53
|
+
if not content.startswith(prefix):
|
|
54
|
+
return None
|
|
55
|
+
gitdir = Path(content[len(prefix) :].strip())
|
|
56
|
+
if not gitdir.is_absolute():
|
|
57
|
+
gitdir = (root / gitdir).resolve()
|
|
58
|
+
|
|
59
|
+
commondir_file = gitdir / "commondir"
|
|
60
|
+
if not commondir_file.exists():
|
|
61
|
+
# Not a linked worktree: gitdir *is* the common dir.
|
|
62
|
+
return gitdir
|
|
63
|
+
|
|
64
|
+
common = Path(commondir_file.read_text(encoding="utf-8").strip())
|
|
65
|
+
if not common.is_absolute():
|
|
66
|
+
common = (gitdir / common).resolve()
|
|
67
|
+
return common
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LocalBackend:
|
|
71
|
+
"""Keeps run state on local disk under the project repo, hidden from git.
|
|
72
|
+
|
|
73
|
+
`prepare()` creates the state directory and, if the project is a git repo, appends
|
|
74
|
+
`/.chipgraph/state/` to that repo's `.git/info/exclude` — never to a tracked
|
|
75
|
+
`.gitignore` — so state never shows up in `git status` for anyone. It is idempotent:
|
|
76
|
+
calling it again does not duplicate the exclude line or otherwise change tracked files.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name = "local"
|
|
80
|
+
|
|
81
|
+
def __init__(self, root: Path) -> None:
|
|
82
|
+
self.root = root
|
|
83
|
+
self._layout = StateLayout(root)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def layout(self) -> StateLayout:
|
|
87
|
+
return self._layout
|
|
88
|
+
|
|
89
|
+
def prepare(self) -> None:
|
|
90
|
+
self._layout.state_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
common_dir = _git_common_dir(self.root)
|
|
92
|
+
if common_dir is None:
|
|
93
|
+
return
|
|
94
|
+
exclude_path = common_dir / "info" / "exclude"
|
|
95
|
+
exclude_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
existing = ""
|
|
97
|
+
if exclude_path.exists():
|
|
98
|
+
existing = exclude_path.read_text(encoding="utf-8")
|
|
99
|
+
lines = existing.splitlines()
|
|
100
|
+
if _EXCLUDE_LINE in lines:
|
|
101
|
+
return
|
|
102
|
+
with open(exclude_path, "a", encoding="utf-8") as f:
|
|
103
|
+
if existing and not existing.endswith("\n"):
|
|
104
|
+
f.write("\n")
|
|
105
|
+
f.write(_EXCLUDE_LINE + "\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def backend_from_config(value: str, root: Path) -> StateBackend:
|
|
109
|
+
"""Build a `StateBackend` from a `.chipgraph.yml` `state.backend` value."""
|
|
110
|
+
if value == "local":
|
|
111
|
+
return LocalBackend(root)
|
|
112
|
+
if value.startswith("branch:") or value.startswith("repo:"):
|
|
113
|
+
raise NotImplementedError(f"state backend {value!r} lands with the git state backend task")
|
|
114
|
+
raise ValueError(f"unknown state backend: {value!r}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["LocalBackend", "StateBackend", "backend_from_config"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The finding store, and how a waiver's currency is computed (DESIGN.md 4.8, 6.1).
|
|
2
|
+
|
|
3
|
+
Findings are machine-local state, not a repo artifact (DESIGN.md 6.1 "State chạy máy"):
|
|
4
|
+
one JSON file per `fingerprint` under ``<state_dir>/findings/``. A waiver, by contrast,
|
|
5
|
+
is a person's decision and lives in ``.chipgraph/decisions/`` as an `Approval` (through
|
|
6
|
+
the `file` review adapter); this module only defines what a waiver *is* -- an
|
|
7
|
+
`Approval` with `decision == "waive"`, bound by `gate_id` to a finding's id -- and how to
|
|
8
|
+
tell whether one is still current. Actually writing a waiver decision (which needs the
|
|
9
|
+
`ReviewAdapter`) is an app-layer concern (`chipgraph.app.findings.waive`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import builtins
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Iterable, Mapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from chipgraph.core.contracts import Approval
|
|
20
|
+
from chipgraph.core.contracts.finding import Finding, FindingSeverity, FindingStatus
|
|
21
|
+
from chipgraph.core.state.layout import StateLayout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def waiver_gate_id(finding_id: str) -> str:
|
|
25
|
+
"""The `Approval.gate_id` a waiver for `finding_id` is recorded (and looked up) under."""
|
|
26
|
+
return f"finding:{finding_id}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def effective_status(
|
|
30
|
+
finding: Finding, waivers: Iterable[Approval], current_hashes: Mapping[str, str]
|
|
31
|
+
) -> FindingStatus:
|
|
32
|
+
"""The finding's status right now, resolving waiver currency against `current_hashes`.
|
|
33
|
+
|
|
34
|
+
- `"fixed"` if the stored finding is already marked fixed.
|
|
35
|
+
- `"waived"` only if at least one of `waivers` has `decision == "waive"` and is
|
|
36
|
+
still current: every artifact hash it is bound to still matches `current_hashes`
|
|
37
|
+
(`Approval.is_current`). A waiver bound to a hash that no longer matches -- the
|
|
38
|
+
artifact changed -- no longer counts: the finding is reported `"open"` again, and
|
|
39
|
+
the expiry is visible to the caller by simply not finding a current waiver.
|
|
40
|
+
- `"open"` otherwise.
|
|
41
|
+
"""
|
|
42
|
+
if finding.status == "fixed":
|
|
43
|
+
return "fixed"
|
|
44
|
+
for waiver in waivers:
|
|
45
|
+
if waiver.decision == "waive" and waiver.is_current(current_hashes):
|
|
46
|
+
return "waived"
|
|
47
|
+
return "open"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FindingStore:
|
|
51
|
+
"""Stores `Finding`s as one JSON file per fingerprint, under the state backend."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, layout: StateLayout) -> None:
|
|
54
|
+
self.layout = layout
|
|
55
|
+
self._dir = layout.state_dir / "findings"
|
|
56
|
+
|
|
57
|
+
def _path(self, fingerprint: str) -> Path:
|
|
58
|
+
return self._dir / f"{fingerprint}.json"
|
|
59
|
+
|
|
60
|
+
def get(self, fingerprint: str) -> Finding | None:
|
|
61
|
+
"""Return the stored finding for `fingerprint`, or `None` if there is none."""
|
|
62
|
+
path = self._path(fingerprint)
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
return None
|
|
65
|
+
return Finding.model_validate_json(path.read_text(encoding="utf-8"))
|
|
66
|
+
|
|
67
|
+
def get_by_id(self, finding_id: str) -> Finding | None:
|
|
68
|
+
"""Return the stored finding whose `id` is `finding_id`, or `None`."""
|
|
69
|
+
for finding in self.list():
|
|
70
|
+
if finding.id == finding_id:
|
|
71
|
+
return finding
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def _put(self, finding: Finding) -> None:
|
|
75
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path = self._path(finding.fingerprint)
|
|
77
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
78
|
+
tmp_path.write_text(finding.model_dump_json(indent=2), encoding="utf-8")
|
|
79
|
+
os.replace(tmp_path, path)
|
|
80
|
+
|
|
81
|
+
def upsert(self, findings: Iterable[Finding]) -> list[Finding]:
|
|
82
|
+
"""Insert or refresh `findings`, keyed by `fingerprint`.
|
|
83
|
+
|
|
84
|
+
`last_seen` is taken from each incoming `finding` as given (the caller decides
|
|
85
|
+
what "now" means, e.g. a run's start time). For a finding already on disk:
|
|
86
|
+
`first_seen` is kept from the existing record, and `status` is kept as
|
|
87
|
+
`"waived"` if it already was (a waiver survives re-detection; its currency is
|
|
88
|
+
decided separately by `effective_status`), otherwise the incoming finding's
|
|
89
|
+
status is used (so a `"fixed"` finding that is detected again goes back to
|
|
90
|
+
`"open"`).
|
|
91
|
+
"""
|
|
92
|
+
stored: list[Finding] = []
|
|
93
|
+
for finding in findings:
|
|
94
|
+
existing = self.get(finding.fingerprint)
|
|
95
|
+
if existing is not None:
|
|
96
|
+
status = "waived" if existing.status == "waived" else finding.status
|
|
97
|
+
finding = finding.model_copy(
|
|
98
|
+
update={"first_seen": existing.first_seen, "status": status}
|
|
99
|
+
)
|
|
100
|
+
self._put(finding)
|
|
101
|
+
stored.append(finding)
|
|
102
|
+
return stored
|
|
103
|
+
|
|
104
|
+
def list(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
status: FindingStatus | None = None,
|
|
108
|
+
layer: int | None = None,
|
|
109
|
+
severity: FindingSeverity | None = None,
|
|
110
|
+
source: str | None = None,
|
|
111
|
+
) -> list[Finding]:
|
|
112
|
+
"""Return every stored finding matching the given filters, sorted by `id`."""
|
|
113
|
+
if not self._dir.is_dir():
|
|
114
|
+
return []
|
|
115
|
+
results: list[Finding] = []
|
|
116
|
+
for path in sorted(self._dir.glob("*.json")):
|
|
117
|
+
finding = Finding.model_validate_json(path.read_text(encoding="utf-8"))
|
|
118
|
+
if status is not None and finding.status != status:
|
|
119
|
+
continue
|
|
120
|
+
if layer is not None and finding.layer != layer:
|
|
121
|
+
continue
|
|
122
|
+
if severity is not None and finding.severity != severity:
|
|
123
|
+
continue
|
|
124
|
+
if source is not None and finding.source != source:
|
|
125
|
+
continue
|
|
126
|
+
results.append(finding)
|
|
127
|
+
results.sort(key=lambda f: f.id)
|
|
128
|
+
return results
|
|
129
|
+
|
|
130
|
+
def mark_fixed(
|
|
131
|
+
self, seen_fingerprints: Iterable[str], *, source: str
|
|
132
|
+
) -> builtins.list[Finding]:
|
|
133
|
+
"""Mark open findings from `source` as `"fixed"` when not in `seen_fingerprints`.
|
|
134
|
+
|
|
135
|
+
Meant to be called after a full run of `source` over the whole project (e.g.
|
|
136
|
+
`/audit`, M1-20): any of that source's open findings not rediscovered in this
|
|
137
|
+
run are no longer present, so they are fixed. Returns the findings that were
|
|
138
|
+
just marked fixed.
|
|
139
|
+
"""
|
|
140
|
+
seen = set(seen_fingerprints)
|
|
141
|
+
fixed: builtins.list[Finding] = []
|
|
142
|
+
for finding in self.list(source=source, status="open"):
|
|
143
|
+
if finding.fingerprint in seen:
|
|
144
|
+
continue
|
|
145
|
+
updated = finding.model_copy(update={"status": "fixed"})
|
|
146
|
+
self._put(updated)
|
|
147
|
+
fixed.append(updated)
|
|
148
|
+
return fixed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["FindingStore", "effective_status", "waiver_gate_id"]
|