antemortem 0.2.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.
- antemortem/__init__.py +6 -0
- antemortem/__main__.py +6 -0
- antemortem/api.py +159 -0
- antemortem/citations.py +135 -0
- antemortem/cli.py +44 -0
- antemortem/commands/__init__.py +1 -0
- antemortem/commands/init.py +84 -0
- antemortem/commands/lint.py +160 -0
- antemortem/commands/run.py +214 -0
- antemortem/parser.py +179 -0
- antemortem/prompts.py +156 -0
- antemortem/schema.py +126 -0
- antemortem/templates.py +352 -0
- antemortem-0.2.0.dist-info/METADATA +158 -0
- antemortem-0.2.0.dist-info/RECORD +18 -0
- antemortem-0.2.0.dist-info/WHEEL +4 -0
- antemortem-0.2.0.dist-info/entry_points.txt +2 -0
- antemortem-0.2.0.dist-info/licenses/LICENSE +21 -0
antemortem/__init__.py
ADDED
antemortem/__main__.py
ADDED
antemortem/api.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Anthropic Claude API wrapper for the classification step.
|
|
2
|
+
|
|
3
|
+
The ``run_classification`` function is the single boundary between the CLI
|
|
4
|
+
and the Anthropic SDK. Everything else in the package is framework-free and
|
|
5
|
+
testable without the network. Tests mock the ``client`` argument.
|
|
6
|
+
|
|
7
|
+
Caching strategy:
|
|
8
|
+
- The system prompt is rendered with ``cache_control={"type": "ephemeral"}``
|
|
9
|
+
on a top-level system text block. On Opus 4.7 this requires the prompt to
|
|
10
|
+
exceed the 4096-token cacheable-prefix minimum; we size ``SYSTEM_PROMPT``
|
|
11
|
+
accordingly and verify via ``usage.cache_read_input_tokens`` on each call.
|
|
12
|
+
- The user payload carries no caching control — it's volatile (different
|
|
13
|
+
traps each run). A second breakpoint on the files block is a v0.2.1
|
|
14
|
+
optimization when we add iterative-run UX.
|
|
15
|
+
|
|
16
|
+
Model and sampling:
|
|
17
|
+
- ``claude-opus-4-7`` is the only supported model in v0.2 (matches Antemortem
|
|
18
|
+
discipline + enforces a known behavioral contract for the prompt).
|
|
19
|
+
- No ``temperature`` / ``top_p`` / ``top_k`` — removed on Opus 4.7.
|
|
20
|
+
- ``thinking={"type": "adaptive"}`` — off by default on 4.7; explicitly
|
|
21
|
+
enabled because classification benefits from multi-file chain tracing.
|
|
22
|
+
- ``output_config={"effort": "high"}`` — minimum recommended for
|
|
23
|
+
intelligence-sensitive work per Anthropic's migration guide.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Protocol
|
|
29
|
+
|
|
30
|
+
from antemortem.prompts import SYSTEM_PROMPT
|
|
31
|
+
from antemortem.schema import AntemortemOutput
|
|
32
|
+
|
|
33
|
+
MODEL = "claude-opus-4-7"
|
|
34
|
+
DEFAULT_MAX_TOKENS = 16000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _MessagesNamespace(Protocol):
|
|
38
|
+
"""Duck-typed interface for the subset of ``anthropic.messages`` we use.
|
|
39
|
+
|
|
40
|
+
Accepting a Protocol rather than the concrete class keeps the hot path
|
|
41
|
+
testable without importing ``anthropic`` in unit tests.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def parse(self, **kwargs: Any) -> Any: ... # noqa: D401
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _AnthropicLike(Protocol):
|
|
48
|
+
"""Minimal interface we need from an Anthropic client instance."""
|
|
49
|
+
|
|
50
|
+
messages: _MessagesNamespace
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_user_content(
|
|
54
|
+
spec: str,
|
|
55
|
+
traps_table_md: str,
|
|
56
|
+
files: list[tuple[str, str]],
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Render the user-turn payload as the prompt expects."""
|
|
59
|
+
file_blocks: list[str] = []
|
|
60
|
+
for path, content in sorted(files, key=lambda item: item[0]):
|
|
61
|
+
# Normalize path separators so cache keys don't drift on Windows.
|
|
62
|
+
normalized = path.replace("\\", "/")
|
|
63
|
+
file_blocks.append(f'<file path="{normalized}">\n{content}\n</file>')
|
|
64
|
+
files_section = "\n".join(file_blocks)
|
|
65
|
+
return (
|
|
66
|
+
f"<files>\n{files_section}\n</files>\n\n"
|
|
67
|
+
f"<spec>\n{spec.strip()}\n</spec>\n\n"
|
|
68
|
+
f"<traps>\n{traps_table_md.strip()}\n</traps>"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _usage_to_dict(usage: Any) -> dict[str, int]:
|
|
73
|
+
"""Extract token counts from the SDK's usage object into a plain dict."""
|
|
74
|
+
return {
|
|
75
|
+
"input_tokens": getattr(usage, "input_tokens", 0) or 0,
|
|
76
|
+
"output_tokens": getattr(usage, "output_tokens", 0) or 0,
|
|
77
|
+
"cache_creation_input_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0,
|
|
78
|
+
"cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_classification(
|
|
83
|
+
client: _AnthropicLike,
|
|
84
|
+
spec: str,
|
|
85
|
+
traps_table_md: str,
|
|
86
|
+
files: list[tuple[str, str]],
|
|
87
|
+
*,
|
|
88
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
89
|
+
) -> tuple[AntemortemOutput, dict[str, int]]:
|
|
90
|
+
"""Call Claude Opus 4.7 to classify traps against provided files.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
client:
|
|
95
|
+
An ``anthropic.Anthropic`` instance (or duck-typed equivalent for
|
|
96
|
+
tests).
|
|
97
|
+
spec:
|
|
98
|
+
Text of the change description from the antemortem document.
|
|
99
|
+
traps_table_md:
|
|
100
|
+
The pre-recon Traps table as a markdown string (raw, including
|
|
101
|
+
header row).
|
|
102
|
+
files:
|
|
103
|
+
List of ``(path, content)`` pairs. Sorted internally so cache
|
|
104
|
+
behavior is deterministic regardless of caller ordering.
|
|
105
|
+
max_tokens:
|
|
106
|
+
Upper bound on output tokens. Defaults to 16000 — the ~8k-output
|
|
107
|
+
typical classification response has ample headroom.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
A tuple ``(output, usage)``:
|
|
112
|
+
|
|
113
|
+
- ``output``: a validated ``AntemortemOutput`` instance.
|
|
114
|
+
- ``usage``: ``{"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"}``.
|
|
115
|
+
"""
|
|
116
|
+
user_content = _build_user_content(spec, traps_table_md, files)
|
|
117
|
+
|
|
118
|
+
response = client.messages.parse(
|
|
119
|
+
model=MODEL,
|
|
120
|
+
max_tokens=max_tokens,
|
|
121
|
+
thinking={"type": "adaptive"},
|
|
122
|
+
output_config={"effort": "high"},
|
|
123
|
+
system=[
|
|
124
|
+
{
|
|
125
|
+
"type": "text",
|
|
126
|
+
"text": SYSTEM_PROMPT,
|
|
127
|
+
"cache_control": {"type": "ephemeral"},
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
messages=[{"role": "user", "content": user_content}],
|
|
131
|
+
output_format=AntemortemOutput,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
stop_reason = getattr(response, "stop_reason", None)
|
|
135
|
+
if stop_reason == "refusal":
|
|
136
|
+
text = ""
|
|
137
|
+
for block in getattr(response, "content", []) or []:
|
|
138
|
+
if getattr(block, "type", None) == "text":
|
|
139
|
+
text = getattr(block, "text", "")
|
|
140
|
+
break
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
"Claude refused the classification request. This usually means the "
|
|
143
|
+
"spec or traps contain content flagged by safety filters. "
|
|
144
|
+
f"Response text: {text!r}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
parsed: AntemortemOutput | None = getattr(response, "parsed_output", None)
|
|
148
|
+
if parsed is None:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"SDK returned no parsed_output. This indicates a schema mismatch or "
|
|
151
|
+
"a malformed response. Raw stop_reason: "
|
|
152
|
+
f"{stop_reason!r}"
|
|
153
|
+
)
|
|
154
|
+
if not isinstance(parsed, AntemortemOutput):
|
|
155
|
+
# Some SDK versions may pass through a dict — coerce defensively.
|
|
156
|
+
parsed = AntemortemOutput.model_validate(parsed)
|
|
157
|
+
|
|
158
|
+
usage = _usage_to_dict(getattr(response, "usage", None))
|
|
159
|
+
return parsed, usage
|
antemortem/citations.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Citation parsing and on-disk verification.
|
|
2
|
+
|
|
3
|
+
The discipline requires every classification to carry a ``path:line`` or
|
|
4
|
+
``path:line-line`` citation. This module parses those strings, resolves them
|
|
5
|
+
against a repository root, and verifies the cited line range lies within the
|
|
6
|
+
file's actual bounds. It does not execute any cited code — read-only checks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_CITATION_RE = re.compile(
|
|
16
|
+
r"""^\s*
|
|
17
|
+
(?P<path>[^\s:]+(?:[^\s:]+)*) # non-whitespace, non-colon path
|
|
18
|
+
:
|
|
19
|
+
(?P<start>\d+) # start line (required)
|
|
20
|
+
(?:-(?P<end>\d+))? # optional end line
|
|
21
|
+
\s*$""",
|
|
22
|
+
re.VERBOSE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ParsedCitation:
|
|
28
|
+
"""A parsed citation reference."""
|
|
29
|
+
|
|
30
|
+
path: str
|
|
31
|
+
start: int
|
|
32
|
+
end: int # equals start for single-line citations
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class VerificationResult:
|
|
37
|
+
"""Outcome of verifying a citation against disk."""
|
|
38
|
+
|
|
39
|
+
ok: bool
|
|
40
|
+
reason: str = ""
|
|
41
|
+
parsed: ParsedCitation | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_citation(citation: str) -> ParsedCitation | None:
|
|
45
|
+
"""Parse a ``path:line`` or ``path:line-line`` citation string.
|
|
46
|
+
|
|
47
|
+
Returns ``None`` when the citation does not match the expected shape.
|
|
48
|
+
"""
|
|
49
|
+
if not citation:
|
|
50
|
+
return None
|
|
51
|
+
# Normalize Windows backslashes to forward slashes for uniform handling.
|
|
52
|
+
normalized = citation.replace("\\", "/").strip()
|
|
53
|
+
match = _CITATION_RE.match(normalized)
|
|
54
|
+
if match is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
start = int(match.group("start"))
|
|
58
|
+
end_raw = match.group("end")
|
|
59
|
+
end = int(end_raw) if end_raw is not None else start
|
|
60
|
+
|
|
61
|
+
if start < 1 or end < start:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return ParsedCitation(path=match.group("path"), start=start, end=end)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def count_lines(path: Path) -> int:
|
|
68
|
+
"""Count newline-terminated lines in a file. Tolerates non-UTF-8 encodings."""
|
|
69
|
+
try:
|
|
70
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
71
|
+
return sum(1 for _ in fh)
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
74
|
+
return sum(1 for _ in fh)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_citation(citation: str, repo_root: Path) -> VerificationResult:
|
|
78
|
+
"""Check that ``citation`` resolves to a real file:line range within ``repo_root``.
|
|
79
|
+
|
|
80
|
+
Returns a ``VerificationResult`` whose ``ok`` field signals whether the
|
|
81
|
+
citation is valid. When ``ok`` is false, ``reason`` explains the failure in
|
|
82
|
+
one line suitable for CLI output.
|
|
83
|
+
"""
|
|
84
|
+
parsed = parse_citation(citation)
|
|
85
|
+
if parsed is None:
|
|
86
|
+
return VerificationResult(
|
|
87
|
+
ok=False,
|
|
88
|
+
reason=f"invalid format — expected 'path:line' or 'path:line-line', got {citation!r}",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
file_path = (repo_root / parsed.path).resolve()
|
|
92
|
+
try:
|
|
93
|
+
root_resolved = repo_root.resolve()
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return VerificationResult(
|
|
96
|
+
ok=False,
|
|
97
|
+
reason=f"--repo directory does not exist: {repo_root}",
|
|
98
|
+
parsed=parsed,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Refuse paths that escape the repo root via '..' traversal.
|
|
102
|
+
try:
|
|
103
|
+
file_path.relative_to(root_resolved)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return VerificationResult(
|
|
106
|
+
ok=False,
|
|
107
|
+
reason=f"cited path escapes repo root: {parsed.path!r}",
|
|
108
|
+
parsed=parsed,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not file_path.exists():
|
|
112
|
+
return VerificationResult(
|
|
113
|
+
ok=False,
|
|
114
|
+
reason=f"cited file does not exist: {parsed.path}",
|
|
115
|
+
parsed=parsed,
|
|
116
|
+
)
|
|
117
|
+
if not file_path.is_file():
|
|
118
|
+
return VerificationResult(
|
|
119
|
+
ok=False,
|
|
120
|
+
reason=f"cited path is not a regular file: {parsed.path}",
|
|
121
|
+
parsed=parsed,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
line_count = count_lines(file_path)
|
|
125
|
+
if parsed.start > line_count or parsed.end > line_count:
|
|
126
|
+
return VerificationResult(
|
|
127
|
+
ok=False,
|
|
128
|
+
reason=(
|
|
129
|
+
f"line {parsed.start}-{parsed.end} out of range "
|
|
130
|
+
f"(file {parsed.path} has {line_count} lines)"
|
|
131
|
+
),
|
|
132
|
+
parsed=parsed,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return VerificationResult(ok=True, parsed=parsed)
|
antemortem/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Top-level Typer application for the antemortem CLI."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from antemortem import __version__
|
|
6
|
+
from antemortem.commands import init as init_cmd
|
|
7
|
+
from antemortem.commands import lint as lint_cmd
|
|
8
|
+
from antemortem.commands import run as run_cmd
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="antemortem",
|
|
12
|
+
help="CLI for the Antemortem pre-implementation reconnaissance discipline.",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"antemortem {__version__}")
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def _root(
|
|
26
|
+
version: bool = typer.Option( # noqa: B008
|
|
27
|
+
False,
|
|
28
|
+
"--version",
|
|
29
|
+
"-V",
|
|
30
|
+
callback=_version_callback,
|
|
31
|
+
is_eager=True,
|
|
32
|
+
help="Show version and exit.",
|
|
33
|
+
),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Antemortem — scaffold, run, and lint pre-implementation recon documents."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app.command(name="init", help="Scaffold a new antemortem document from a template.")(init_cmd.init)
|
|
39
|
+
app.command(name="run", help="Run LLM-assisted classification on an antemortem document.")(run_cmd.run)
|
|
40
|
+
app.command(name="lint", help="Validate an antemortem document's schema and citations.")(lint_cmd.lint)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subcommand modules for the antemortem CLI."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""`antemortem init` — scaffold a new antemortem document from a template."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from antemortem.templates import get_template
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_frontmatter(
|
|
12
|
+
name: str,
|
|
13
|
+
today: str,
|
|
14
|
+
enhanced: bool,
|
|
15
|
+
) -> str:
|
|
16
|
+
template_label = "enhanced" if enhanced else "basic"
|
|
17
|
+
return (
|
|
18
|
+
"---\n"
|
|
19
|
+
f"name: {name}\n"
|
|
20
|
+
f"date: {today}\n"
|
|
21
|
+
"scope: change-local\n"
|
|
22
|
+
"reversibility: high\n"
|
|
23
|
+
"status: draft\n"
|
|
24
|
+
f"template: {template_label}\n"
|
|
25
|
+
"---\n\n"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init(
|
|
30
|
+
name: str = typer.Argument( # noqa: B008
|
|
31
|
+
...,
|
|
32
|
+
help="Short name for the change (used as filename). Example: my-feature, auth-refactor.",
|
|
33
|
+
),
|
|
34
|
+
enhanced: bool = typer.Option( # noqa: B008
|
|
35
|
+
False,
|
|
36
|
+
"--enhanced",
|
|
37
|
+
"-e",
|
|
38
|
+
help="Use the enhanced template (calibration dimensions, skeptic pass, decision-first output).",
|
|
39
|
+
),
|
|
40
|
+
output_dir: Path = typer.Option( # noqa: B008
|
|
41
|
+
Path("antemortem"),
|
|
42
|
+
"--output-dir",
|
|
43
|
+
"-o",
|
|
44
|
+
help="Directory to create the document in. Created if missing.",
|
|
45
|
+
file_okay=False,
|
|
46
|
+
dir_okay=True,
|
|
47
|
+
),
|
|
48
|
+
force: bool = typer.Option( # noqa: B008
|
|
49
|
+
False,
|
|
50
|
+
"--force",
|
|
51
|
+
"-f",
|
|
52
|
+
help="Overwrite existing document if present.",
|
|
53
|
+
),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create antemortem/<name>.md with YAML frontmatter and the chosen template."""
|
|
56
|
+
if not name or any(ch in name for ch in ("/", "\\", "..")):
|
|
57
|
+
typer.secho(
|
|
58
|
+
f"Invalid name {name!r}: use a simple identifier (letters, digits, hyphens).",
|
|
59
|
+
fg=typer.colors.RED,
|
|
60
|
+
err=True,
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(code=2)
|
|
63
|
+
|
|
64
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
target = output_dir / f"{name}.md"
|
|
66
|
+
|
|
67
|
+
if target.exists() and not force:
|
|
68
|
+
typer.secho(
|
|
69
|
+
f"Refusing to overwrite {target} — pass --force to replace.",
|
|
70
|
+
fg=typer.colors.RED,
|
|
71
|
+
err=True,
|
|
72
|
+
)
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
|
|
75
|
+
today = date.today().isoformat()
|
|
76
|
+
content = _build_frontmatter(name, today, enhanced) + get_template(enhanced)
|
|
77
|
+
target.write_text(content, encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
label = "enhanced" if enhanced else "basic"
|
|
80
|
+
typer.secho(f"Created {target} ({label} template)", fg=typer.colors.GREEN)
|
|
81
|
+
typer.secho(
|
|
82
|
+
"Next: fill in the spec, enumerate traps, then run `antemortem run` to classify.",
|
|
83
|
+
fg=typer.colors.BRIGHT_BLACK,
|
|
84
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""`antemortem lint` — schema and citation validation.
|
|
2
|
+
|
|
3
|
+
Two tiers of checks:
|
|
4
|
+
|
|
5
|
+
1. **Document schema**: YAML frontmatter parses, spec is non-empty, at least
|
|
6
|
+
one trap row is present, and at least one file is listed under the Recon
|
|
7
|
+
protocol. These apply to every antemortem document.
|
|
8
|
+
2. **Classification verification**: if a companion ``<doc>.json`` audit
|
|
9
|
+
artifact exists (produced by ``antemortem run``), validate that every
|
|
10
|
+
input trap has a classification, every citation parses, and every
|
|
11
|
+
``file:line`` points to an existing line within ``--repo``.
|
|
12
|
+
|
|
13
|
+
Exit 0 on pass, 1 on failure. Suitable for CI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from pydantic import ValidationError
|
|
24
|
+
|
|
25
|
+
from antemortem.citations import verify_citation
|
|
26
|
+
from antemortem.parser import DocumentParseError, parse_document
|
|
27
|
+
from antemortem.schema import AntemortemDocument, AntemortemOutput
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LintResult(NamedTuple):
|
|
31
|
+
"""Outcome of a lint pass."""
|
|
32
|
+
|
|
33
|
+
ok: bool
|
|
34
|
+
violations: list[str]
|
|
35
|
+
checked: int # number of checks that ran (passed or failed)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _lint_document(doc: AntemortemDocument) -> list[str]:
|
|
39
|
+
"""Pre-run schema checks that apply to every antemortem document."""
|
|
40
|
+
violations: list[str] = []
|
|
41
|
+
if not doc.spec.strip():
|
|
42
|
+
violations.append("spec: '## 1. The change' section is empty or missing")
|
|
43
|
+
if not doc.traps:
|
|
44
|
+
violations.append("traps: no rows parsed from the pre-recon Traps table")
|
|
45
|
+
if not doc.files_to_read:
|
|
46
|
+
violations.append("files_to_read: no files listed under 'Recon protocol'")
|
|
47
|
+
return violations
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _lint_artifact(
|
|
51
|
+
artifact_path: Path,
|
|
52
|
+
doc: AntemortemDocument,
|
|
53
|
+
repo_root: Path,
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
"""Post-run checks that apply when a JSON audit artifact exists."""
|
|
56
|
+
violations: list[str] = []
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
|
59
|
+
except json.JSONDecodeError as exc:
|
|
60
|
+
return [f"{artifact_path.name}: invalid JSON — {exc.msg} at line {exc.lineno}"]
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return [f"{artifact_path.name}: cannot read — {exc}"]
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
output = AntemortemOutput.model_validate(payload)
|
|
66
|
+
except ValidationError as exc:
|
|
67
|
+
return [f"{artifact_path.name}: schema validation failed — {exc.error_count()} issues"]
|
|
68
|
+
|
|
69
|
+
trap_ids = {t.id for t in doc.traps}
|
|
70
|
+
classified_ids = {c.id for c in output.classifications}
|
|
71
|
+
|
|
72
|
+
for missing in sorted(trap_ids - classified_ids):
|
|
73
|
+
violations.append(f"classification: missing for trap {missing}")
|
|
74
|
+
|
|
75
|
+
for c in output.classifications:
|
|
76
|
+
if c.id not in trap_ids:
|
|
77
|
+
violations.append(
|
|
78
|
+
f"classification {c.id}: refers to a trap id not present in the input table"
|
|
79
|
+
)
|
|
80
|
+
if c.label == "UNRESOLVED":
|
|
81
|
+
if c.citation is not None:
|
|
82
|
+
violations.append(
|
|
83
|
+
f"classification {c.id}: UNRESOLVED must have citation=null (got {c.citation!r})"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
if not c.citation:
|
|
87
|
+
violations.append(f"classification {c.id}: citation is required for {c.label}")
|
|
88
|
+
continue
|
|
89
|
+
result = verify_citation(c.citation, repo_root)
|
|
90
|
+
if not result.ok:
|
|
91
|
+
violations.append(f"classification {c.id}: {result.reason}")
|
|
92
|
+
|
|
93
|
+
for nt in output.new_traps:
|
|
94
|
+
result = verify_citation(nt.citation, repo_root)
|
|
95
|
+
if not result.ok:
|
|
96
|
+
violations.append(f"new_trap {nt.id}: {result.reason}")
|
|
97
|
+
|
|
98
|
+
return violations
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_lint(
|
|
102
|
+
document: Path,
|
|
103
|
+
repo_root: Path,
|
|
104
|
+
) -> LintResult:
|
|
105
|
+
"""Programmatic lint entry point. Returns ``LintResult`` without exiting."""
|
|
106
|
+
try:
|
|
107
|
+
doc = parse_document(document)
|
|
108
|
+
except DocumentParseError as exc:
|
|
109
|
+
return LintResult(ok=False, violations=[f"document: {exc}"], checked=1)
|
|
110
|
+
|
|
111
|
+
violations = _lint_document(doc)
|
|
112
|
+
|
|
113
|
+
artifact_path = document.with_suffix(".json")
|
|
114
|
+
ran_artifact = artifact_path.exists()
|
|
115
|
+
if ran_artifact:
|
|
116
|
+
violations.extend(_lint_artifact(artifact_path, doc, repo_root))
|
|
117
|
+
|
|
118
|
+
checked = 3 + (1 if ran_artifact else 0) # spec, traps, files, (+ artifact)
|
|
119
|
+
return LintResult(ok=len(violations) == 0, violations=violations, checked=checked)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lint(
|
|
123
|
+
document: Path = typer.Argument( # noqa: B008
|
|
124
|
+
...,
|
|
125
|
+
help="Path to the antemortem document to validate.",
|
|
126
|
+
exists=True,
|
|
127
|
+
file_okay=True,
|
|
128
|
+
dir_okay=False,
|
|
129
|
+
readable=True,
|
|
130
|
+
),
|
|
131
|
+
repo: Path = typer.Option( # noqa: B008
|
|
132
|
+
Path.cwd(),
|
|
133
|
+
"--repo",
|
|
134
|
+
"-r",
|
|
135
|
+
help="Repository root to resolve cited files against. Defaults to current directory.",
|
|
136
|
+
exists=True,
|
|
137
|
+
file_okay=False,
|
|
138
|
+
dir_okay=True,
|
|
139
|
+
readable=True,
|
|
140
|
+
),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Validate schema and verify file:line citations."""
|
|
143
|
+
result = run_lint(document, repo)
|
|
144
|
+
artifact_note = (
|
|
145
|
+
" (schema + classifications)"
|
|
146
|
+
if document.with_suffix(".json").exists()
|
|
147
|
+
else " (schema only; no audit artifact)"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if result.ok:
|
|
151
|
+
typer.secho(
|
|
152
|
+
f"PASS — {document.name} validates clean{artifact_note}",
|
|
153
|
+
fg=typer.colors.GREEN,
|
|
154
|
+
)
|
|
155
|
+
raise typer.Exit(code=0)
|
|
156
|
+
|
|
157
|
+
typer.secho(f"FAIL — {document.name}{artifact_note}", fg=typer.colors.RED, err=True)
|
|
158
|
+
for v in result.violations:
|
|
159
|
+
typer.secho(f" - {v}", fg=typer.colors.RED, err=True)
|
|
160
|
+
raise typer.Exit(code=1)
|