csd-intent 0.4.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.
- csd_intent/__init__.py +46 -0
- csd_intent/audit.py +194 -0
- csd_intent/cli.py +158 -0
- csd_intent/py.typed +0 -0
- csd_intent/schema.py +196 -0
- csd_intent/walker.py +211 -0
- csd_intent-0.4.0.dist-info/METADATA +148 -0
- csd_intent-0.4.0.dist-info/RECORD +12 -0
- csd_intent-0.4.0.dist-info/WHEEL +5 -0
- csd_intent-0.4.0.dist-info/entry_points.txt +2 -0
- csd_intent-0.4.0.dist-info/licenses/LICENSE +21 -0
- csd_intent-0.4.0.dist-info/top_level.txt +1 -0
csd_intent/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""csd-intent - cross-runtime auditor for CSD intent specifications.
|
|
2
|
+
|
|
3
|
+
Validates intent.yaml against CSD-INTENT-01 (schema check), confirms every
|
|
4
|
+
@intent / intent() marker references a real claim (orphan check), and reports
|
|
5
|
+
unattested claims by walking every test file in the project (coverage check).
|
|
6
|
+
|
|
7
|
+
Nested projects: a repo may contain nested intent projects (a subdirectory with its
|
|
8
|
+
own intent.yaml). ``audit_tree()`` discovers and audits each as its own project, so a
|
|
9
|
+
nested project's markers are checked against its own intent.yaml rather than orphaning
|
|
10
|
+
against the outer project's claims.
|
|
11
|
+
|
|
12
|
+
Public API:
|
|
13
|
+
audit(project_dir, intent_path=None, test_dirs=None) -> AuditReport
|
|
14
|
+
audit_tree(project_dir) -> list[AuditReport]
|
|
15
|
+
AuditReport, AuditViolation
|
|
16
|
+
parse_intent_yaml(path) # raises DuplicateKeyError on a repeated key
|
|
17
|
+
check_schema(claims)
|
|
18
|
+
collect_attestations(test_dirs)
|
|
19
|
+
find_nested_intent_projects(root)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
25
|
+
|
|
26
|
+
from .audit import AuditReport, AuditViolation, audit, audit_tree
|
|
27
|
+
from .schema import DuplicateKeyError, check_schema, parse_intent_yaml
|
|
28
|
+
from .walker import collect_attestations, find_nested_intent_projects
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = version("csd-intent")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
__version__ = "0.0.0+unknown"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AuditReport",
|
|
37
|
+
"AuditViolation",
|
|
38
|
+
"DuplicateKeyError",
|
|
39
|
+
"__version__",
|
|
40
|
+
"audit",
|
|
41
|
+
"audit_tree",
|
|
42
|
+
"check_schema",
|
|
43
|
+
"collect_attestations",
|
|
44
|
+
"find_nested_intent_projects",
|
|
45
|
+
"parse_intent_yaml",
|
|
46
|
+
]
|
csd_intent/audit.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Orchestrates schema + orphan + coverage checks into a single audit report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .schema import DuplicateKeyError, check_schema, parse_intent_yaml, top_level_keys
|
|
10
|
+
from .walker import collect_attestations, find_nested_intent_projects
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AuditReport",
|
|
14
|
+
"AuditViolation",
|
|
15
|
+
"ViolationKind",
|
|
16
|
+
"audit",
|
|
17
|
+
"audit_tree",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ViolationKind(str, Enum):
|
|
22
|
+
SCHEMA = "schema"
|
|
23
|
+
ORPHAN = "orphan"
|
|
24
|
+
UNATTESTED = "unattested"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AuditViolation:
|
|
29
|
+
kind: ViolationKind
|
|
30
|
+
claim_id: str | None
|
|
31
|
+
message: str
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
prefix = self.claim_id or "intent.yaml"
|
|
35
|
+
return f"[{self.kind.value}] {prefix}: {self.message}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AuditReport:
|
|
40
|
+
"""The result of a project audit. Empty `violations` = clean."""
|
|
41
|
+
|
|
42
|
+
intent_path: Path
|
|
43
|
+
claim_count: int
|
|
44
|
+
attested_claims: set[str] = field(default_factory=set)
|
|
45
|
+
orphan_refs: dict[str, list[str]] = field(default_factory=dict)
|
|
46
|
+
violations: list[AuditViolation] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def ok(self) -> bool:
|
|
50
|
+
return not self.violations
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def unattested(self) -> list[str]:
|
|
54
|
+
return sorted(
|
|
55
|
+
v.claim_id
|
|
56
|
+
for v in self.violations
|
|
57
|
+
if v.kind == ViolationKind.UNATTESTED and v.claim_id
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def format(self) -> str:
|
|
61
|
+
if self.ok:
|
|
62
|
+
return (
|
|
63
|
+
f"intent.yaml ({self.intent_path}): {self.claim_count} claims, "
|
|
64
|
+
f"{len(self.attested_claims)} attested. CLEAN."
|
|
65
|
+
)
|
|
66
|
+
by_kind: dict[ViolationKind, list[AuditViolation]] = {}
|
|
67
|
+
for v in self.violations:
|
|
68
|
+
by_kind.setdefault(v.kind, []).append(v)
|
|
69
|
+
lines = [
|
|
70
|
+
f"intent.yaml ({self.intent_path}): {self.claim_count} claims, "
|
|
71
|
+
f"{len(self.violations)} violation(s)."
|
|
72
|
+
]
|
|
73
|
+
for kind in (ViolationKind.SCHEMA, ViolationKind.ORPHAN, ViolationKind.UNATTESTED):
|
|
74
|
+
items = by_kind.get(kind, [])
|
|
75
|
+
if not items:
|
|
76
|
+
continue
|
|
77
|
+
lines.append(f"\n{kind.value.upper()} ({len(items)}):")
|
|
78
|
+
for v in items:
|
|
79
|
+
lines.append(f" {v}")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def audit(
|
|
84
|
+
project_dir: Path,
|
|
85
|
+
intent_path: Path | None = None,
|
|
86
|
+
test_dirs: list[Path] | None = None,
|
|
87
|
+
) -> AuditReport:
|
|
88
|
+
"""Audit a project's intent spec + test attestations.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project_dir: project root (used to resolve defaults).
|
|
92
|
+
intent_path: explicit intent.yaml location; defaults to ``project_dir/intent.yaml``.
|
|
93
|
+
test_dirs: directories to scan for @intent / intent() markers; defaults to
|
|
94
|
+
``[project_dir]`` (the walker handles exclude rules).
|
|
95
|
+
"""
|
|
96
|
+
project_dir = project_dir.resolve()
|
|
97
|
+
intent_path = (intent_path or project_dir / "intent.yaml").resolve()
|
|
98
|
+
test_dirs = test_dirs or [project_dir]
|
|
99
|
+
|
|
100
|
+
report = AuditReport(intent_path=intent_path, claim_count=0)
|
|
101
|
+
|
|
102
|
+
if not intent_path.exists():
|
|
103
|
+
report.violations.append(
|
|
104
|
+
AuditViolation(ViolationKind.SCHEMA, None, f"intent.yaml not found at {intent_path}")
|
|
105
|
+
)
|
|
106
|
+
return report
|
|
107
|
+
|
|
108
|
+
# A repeated key resolves last-wins in YAML itself, so the earlier claim is gone
|
|
109
|
+
# before any check runs and its markers now attest the survivor. Nothing further
|
|
110
|
+
# in this audit would be true of the spec its author wrote, so stop here (#12).
|
|
111
|
+
try:
|
|
112
|
+
claims = parse_intent_yaml(intent_path)
|
|
113
|
+
except DuplicateKeyError as exc:
|
|
114
|
+
report.violations.append(AuditViolation(ViolationKind.SCHEMA, None, str(exc)))
|
|
115
|
+
return report
|
|
116
|
+
|
|
117
|
+
report.claim_count = len(claims)
|
|
118
|
+
|
|
119
|
+
# A file present with nothing the parser recognises is schema drift, not an
|
|
120
|
+
# empty-but-valid project: report it loudly rather than a silent CLEAN (#5).
|
|
121
|
+
if not claims:
|
|
122
|
+
keys = top_level_keys(intent_path)
|
|
123
|
+
found = f"found keys: {', '.join(keys)}" if keys else "the file has no top-level keys"
|
|
124
|
+
report.violations.append(
|
|
125
|
+
AuditViolation(
|
|
126
|
+
ViolationKind.SCHEMA,
|
|
127
|
+
None,
|
|
128
|
+
f"intent.yaml has no top-level INT-* claims; {found}",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Schema
|
|
133
|
+
for msg in check_schema(claims):
|
|
134
|
+
cid = msg.split(":", 1)[0] if ":" in msg else None
|
|
135
|
+
rest = msg.split(":", 1)[1].strip() if ":" in msg else msg
|
|
136
|
+
report.violations.append(AuditViolation(ViolationKind.SCHEMA, cid, rest))
|
|
137
|
+
|
|
138
|
+
# Attestations across all test dirs
|
|
139
|
+
attestations = collect_attestations(test_dirs)
|
|
140
|
+
claim_ids = set(claims.keys())
|
|
141
|
+
attested_ids = set(attestations.keys())
|
|
142
|
+
report.attested_claims = claim_ids & attested_ids
|
|
143
|
+
|
|
144
|
+
# Orphans: marker references a claim that does not exist
|
|
145
|
+
for cid in sorted(attested_ids - claim_ids):
|
|
146
|
+
refs = attestations.get(cid, [])
|
|
147
|
+
report.orphan_refs[cid] = refs
|
|
148
|
+
report.violations.append(
|
|
149
|
+
AuditViolation(
|
|
150
|
+
ViolationKind.ORPHAN,
|
|
151
|
+
cid,
|
|
152
|
+
f"test marker references unknown claim ({len(refs)} ref(s): {refs[:3]}{'...' if len(refs) > 3 else ''})",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Unattested: claim has no marker anywhere.
|
|
157
|
+
# Only enforce for ACTIVE claims. Deprecated claims document a behaviour
|
|
158
|
+
# that no longer holds (no tests expected). Draft claims are pre-implementation
|
|
159
|
+
# placeholders (tests may not yet exist) - surface as informational but don't
|
|
160
|
+
# treat as a failing violation.
|
|
161
|
+
for cid in sorted(claim_ids - attested_ids):
|
|
162
|
+
status = str(claims[cid].get("status", "active"))
|
|
163
|
+
if status == "deprecated":
|
|
164
|
+
continue
|
|
165
|
+
if status == "draft":
|
|
166
|
+
continue
|
|
167
|
+
report.violations.append(
|
|
168
|
+
AuditViolation(
|
|
169
|
+
ViolationKind.UNATTESTED, cid, "no @intent / intent() marker references this claim"
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return report
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def audit_tree(project_dir: Path) -> list[AuditReport]:
|
|
177
|
+
"""Discover and audit every intent project at or below ``project_dir``.
|
|
178
|
+
|
|
179
|
+
The starting ``project_dir`` is audited as the root project (its marker scan is
|
|
180
|
+
bounded by any nested project subtrees). Each subdirectory that carries its own
|
|
181
|
+
``intent.yaml`` is then audited as an independent project against the markers in
|
|
182
|
+
its own subtree (bounded, in turn, by any still-deeper nested projects). The result
|
|
183
|
+
is a complete, non-overlapping partition of the tree into projects.
|
|
184
|
+
|
|
185
|
+
Returns one :class:`AuditReport` per project, root first, then nested projects in a
|
|
186
|
+
deterministic (sorted) order. This is the auto-discovery path used by the CLI when no
|
|
187
|
+
explicit ``--intent`` / ``--tests-dir`` override is given. For the explicit
|
|
188
|
+
single-project case, call :func:`audit` directly.
|
|
189
|
+
"""
|
|
190
|
+
project_dir = project_dir.resolve()
|
|
191
|
+
reports = [audit(project_dir)]
|
|
192
|
+
for nested in find_nested_intent_projects(project_dir):
|
|
193
|
+
reports.append(audit(nested))
|
|
194
|
+
return reports
|
csd_intent/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CLI entry point for csd-intent.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
csd-intent [PROJECT_DIR]
|
|
5
|
+
[--intent PATH]
|
|
6
|
+
[--tests-dir DIR]...
|
|
7
|
+
[--fail-on schema|orphan|unattested|any|none]
|
|
8
|
+
[--quiet]
|
|
9
|
+
[--version]
|
|
10
|
+
|
|
11
|
+
Default ``PROJECT_DIR`` is the cwd. Default ``--fail-on`` is ``any`` (exit non-zero
|
|
12
|
+
on any violation). Use ``--fail-on schema`` to only fail on schema problems and
|
|
13
|
+
treat coverage gaps as warnings (matches the "intent before test" CSD workflow).
|
|
14
|
+
|
|
15
|
+
Nested projects: when PROJECT_DIR contains nested intent projects (subdirectories with
|
|
16
|
+
their own intent.yaml), each is discovered and audited as its own project against the
|
|
17
|
+
markers in its own subtree. A per-project summary is printed and the process exits
|
|
18
|
+
non-zero if *any* project has a failing violation. Passing ``--intent`` or
|
|
19
|
+
``--tests-dir`` switches to an explicit single-project audit (no auto-discovery).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .audit import AuditReport, ViolationKind, audit, audit_tree
|
|
29
|
+
|
|
30
|
+
_FAIL_MAP = {
|
|
31
|
+
"schema": {ViolationKind.SCHEMA},
|
|
32
|
+
"orphan": {ViolationKind.ORPHAN},
|
|
33
|
+
"unattested": {ViolationKind.UNATTESTED},
|
|
34
|
+
"any": {ViolationKind.SCHEMA, ViolationKind.ORPHAN, ViolationKind.UNATTESTED},
|
|
35
|
+
"none": set(),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="csd-intent",
|
|
42
|
+
description="Audit a CSD intent project: schema + orphan + coverage checks.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"project_dir",
|
|
46
|
+
nargs="?",
|
|
47
|
+
default=".",
|
|
48
|
+
type=Path,
|
|
49
|
+
help="Project root (default: cwd).",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--intent",
|
|
53
|
+
type=Path,
|
|
54
|
+
default=None,
|
|
55
|
+
help=(
|
|
56
|
+
"Path to intent.yaml; a relative path is anchored to PROJECT_DIR "
|
|
57
|
+
"(default: PROJECT_DIR/intent.yaml)."
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--tests-dir",
|
|
62
|
+
dest="tests_dirs",
|
|
63
|
+
action="append",
|
|
64
|
+
type=Path,
|
|
65
|
+
default=None,
|
|
66
|
+
help=(
|
|
67
|
+
"Directory to scan for test markers; a relative path is anchored to "
|
|
68
|
+
"PROJECT_DIR (repeatable; default: PROJECT_DIR)."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--fail-on",
|
|
73
|
+
choices=tuple(_FAIL_MAP.keys()),
|
|
74
|
+
default="any",
|
|
75
|
+
help="Which violation kinds cause a non-zero exit (default: any).",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--quiet",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="Suppress the per-violation report; only print the summary line.",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("--version", action="store_true", help="Print version and exit.")
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_report(report: AuditReport, quiet: bool) -> None:
|
|
87
|
+
if quiet:
|
|
88
|
+
print(
|
|
89
|
+
f"{report.intent_path}: {report.claim_count} claims, "
|
|
90
|
+
f"{len(report.violations)} violation(s)."
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
print(report.format())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _has_failing_violation(report: AuditReport, fail_kinds: set[ViolationKind]) -> bool:
|
|
97
|
+
return any(v.kind in fail_kinds for v in report.violations)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _anchored(project_dir: Path, path: Path) -> Path:
|
|
101
|
+
"""Anchor a relative path to the project root rather than the caller's cwd."""
|
|
102
|
+
return path if path.is_absolute() else project_dir / path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
args = _build_parser().parse_args(argv)
|
|
107
|
+
|
|
108
|
+
if args.version:
|
|
109
|
+
from . import __version__
|
|
110
|
+
|
|
111
|
+
print(__version__)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
fail_kinds = _FAIL_MAP[args.fail_on]
|
|
115
|
+
|
|
116
|
+
# Explicit single-project mode: --intent / --tests-dir disables auto-discovery.
|
|
117
|
+
if args.intent is not None or args.tests_dirs is not None:
|
|
118
|
+
report = audit(
|
|
119
|
+
project_dir=args.project_dir,
|
|
120
|
+
intent_path=(
|
|
121
|
+
None if args.intent is None else _anchored(args.project_dir, args.intent)
|
|
122
|
+
),
|
|
123
|
+
test_dirs=(
|
|
124
|
+
None
|
|
125
|
+
if args.tests_dirs is None
|
|
126
|
+
else [_anchored(args.project_dir, d) for d in args.tests_dirs]
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
_print_report(report, args.quiet)
|
|
130
|
+
return 1 if _has_failing_violation(report, fail_kinds) else 0
|
|
131
|
+
|
|
132
|
+
# Auto-discovery mode: audit the root project plus any nested intent projects.
|
|
133
|
+
reports = audit_tree(args.project_dir)
|
|
134
|
+
|
|
135
|
+
# Backward-compatible single-project output when there is no nesting: identical
|
|
136
|
+
# to the pre-nesting behaviour (one report, no per-project banner).
|
|
137
|
+
if len(reports) == 1:
|
|
138
|
+
_print_report(reports[0], args.quiet)
|
|
139
|
+
return 1 if _has_failing_violation(reports[0], fail_kinds) else 0
|
|
140
|
+
|
|
141
|
+
# Multiple projects: per-project report plus an aggregate summary.
|
|
142
|
+
failed = 0
|
|
143
|
+
for i, report in enumerate(reports):
|
|
144
|
+
if i:
|
|
145
|
+
print()
|
|
146
|
+
_print_report(report, args.quiet)
|
|
147
|
+
if _has_failing_violation(report, fail_kinds):
|
|
148
|
+
failed += 1
|
|
149
|
+
|
|
150
|
+
clean = len(reports) - failed
|
|
151
|
+
print(
|
|
152
|
+
f"\n{len(reports)} project(s) audited: {clean} clean, {failed} with violation(s)."
|
|
153
|
+
)
|
|
154
|
+
return 1 if failed else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
csd_intent/py.typed
ADDED
|
File without changes
|
csd_intent/schema.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""CSD-INTENT-01 schema validation for intent.yaml claims.
|
|
2
|
+
|
|
3
|
+
Validates the canonical claim shape:
|
|
4
|
+
|
|
5
|
+
INT-NNN:
|
|
6
|
+
version: X.Y.Z
|
|
7
|
+
status: draft | active | deprecated
|
|
8
|
+
statement: "..." # required
|
|
9
|
+
rationale: "..." # optional in CSD; recommended
|
|
10
|
+
test:
|
|
11
|
+
scope: unit | integration | e2e
|
|
12
|
+
component: "..."
|
|
13
|
+
type: invariant | behavior | contract
|
|
14
|
+
criticality: critical | high | medium | low
|
|
15
|
+
|
|
16
|
+
The legacy flat shape (top-level `scope:` instead of nested `test.scope`) is
|
|
17
|
+
accepted with a warning so projects can migrate incrementally.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Hashable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ID_PATTERN",
|
|
31
|
+
"VALID_CRITICALITY",
|
|
32
|
+
"VALID_SCOPE",
|
|
33
|
+
"VALID_STATUS",
|
|
34
|
+
"VALID_TYPE",
|
|
35
|
+
"VERSION_PATTERN",
|
|
36
|
+
"DuplicateKeyError",
|
|
37
|
+
"check_schema",
|
|
38
|
+
"parse_intent_yaml",
|
|
39
|
+
"top_level_keys",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Accept either canonical `INT-NNN` (CSD-INTENT-01 §3.1) or our extended
|
|
43
|
+
# `INT-PREFIX-NNN` style for multi-module monorepos. Both are valid here.
|
|
44
|
+
ID_PATTERN = re.compile(r"^INT-[A-Z0-9-]+$")
|
|
45
|
+
VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
|
46
|
+
|
|
47
|
+
VALID_STATUS = {"draft", "active", "deprecated"}
|
|
48
|
+
VALID_SCOPE = {"unit", "integration", "e2e"}
|
|
49
|
+
VALID_TYPE = {"invariant", "behavior", "contract"}
|
|
50
|
+
VALID_CRITICALITY = {"critical", "high", "medium", "low"}
|
|
51
|
+
|
|
52
|
+
# Top-level fields required on every claim per CSD-INTENT-01 §4.1.
|
|
53
|
+
# (id is implicit from the YAML key, so we don't check it separately.)
|
|
54
|
+
REQUIRED_TOP = {"version", "status", "statement", "criticality"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_MERGE_TAG = "tag:yaml.org,2002:merge"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DuplicateKeyError(ValueError):
|
|
61
|
+
"""A mapping in intent.yaml declares the same key twice.
|
|
62
|
+
|
|
63
|
+
YAML's own resolution is last-wins and silent, so a repeated claim id destroys
|
|
64
|
+
the earlier claim before any check can see it: the claim stops being enforced
|
|
65
|
+
and every marker written for it silently re-points at the survivor (issue #12).
|
|
66
|
+
A duplicate id has no valid interpretation, so the parse refuses it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, key: object, first_line: int, second_line: int) -> None:
|
|
70
|
+
self.key = key
|
|
71
|
+
self.first_line = first_line
|
|
72
|
+
self.second_line = second_line
|
|
73
|
+
super().__init__(
|
|
74
|
+
f"duplicate key `{key}` (first declared on line {first_line}, "
|
|
75
|
+
f"declared again on line {second_line}); YAML would silently keep only "
|
|
76
|
+
f"the last one"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
|
81
|
+
"""SafeLoader that refuses a repeated mapping key instead of resolving last-wins."""
|
|
82
|
+
|
|
83
|
+
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
84
|
+
# Scan the raw key nodes before the base class flattens merge keys (`<<`),
|
|
85
|
+
# so a legitimate merge-plus-override is not mistaken for a duplicate.
|
|
86
|
+
seen: dict[Any, int] = {}
|
|
87
|
+
for key_node, _ in node.value:
|
|
88
|
+
if key_node.tag == _MERGE_TAG:
|
|
89
|
+
continue # `<<` is expanded by the base class, and an override is legal
|
|
90
|
+
key = self.construct_object(key_node, deep=deep)
|
|
91
|
+
if not isinstance(key, Hashable):
|
|
92
|
+
continue # the base constructor reports unhashable keys itself
|
|
93
|
+
line = key_node.start_mark.line + 1
|
|
94
|
+
if key in seen:
|
|
95
|
+
raise DuplicateKeyError(key, seen[key], line)
|
|
96
|
+
seen[key] = line
|
|
97
|
+
return super().construct_mapping(node, deep=deep)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load(path: Path) -> Any:
|
|
101
|
+
"""Load a YAML document, refusing duplicate mapping keys (`DuplicateKeyError`)."""
|
|
102
|
+
text = path.read_text(encoding="utf-8")
|
|
103
|
+
return yaml.load(text, Loader=_UniqueKeySafeLoader)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_intent_yaml(path: Path) -> dict[str, dict[str, Any]]:
|
|
107
|
+
"""Parse an intent.yaml. Returns {claim_id: claim_dict}. Non-INT keys ignored.
|
|
108
|
+
|
|
109
|
+
Raises `DuplicateKeyError` when the file declares the same key twice: dropping
|
|
110
|
+
one of them silently is never the right answer (issue #12).
|
|
111
|
+
"""
|
|
112
|
+
data = _load(path) or {}
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
return {}
|
|
115
|
+
out: dict[str, dict[str, Any]] = {}
|
|
116
|
+
for key, value in data.items():
|
|
117
|
+
if isinstance(key, str) and key.startswith("INT-") and isinstance(value, dict):
|
|
118
|
+
out[key] = value
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def top_level_keys(path: Path) -> list[str]:
|
|
123
|
+
"""Sorted top-level YAML keys, for diagnosing a zero-claims schema mismatch."""
|
|
124
|
+
data = _load(path) or {}
|
|
125
|
+
if not isinstance(data, dict):
|
|
126
|
+
return []
|
|
127
|
+
return sorted(str(key) for key in data.keys())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _scope_and_test(claim: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
|
|
131
|
+
"""Return (effective_scope, nested_test_dict_or_None)."""
|
|
132
|
+
test = claim.get("test")
|
|
133
|
+
if isinstance(test, dict):
|
|
134
|
+
scope = test.get("scope")
|
|
135
|
+
return (scope if isinstance(scope, str) else None, test)
|
|
136
|
+
legacy = claim.get("scope")
|
|
137
|
+
return (legacy if isinstance(legacy, str) else None, None)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_schema(claims: dict[str, dict[str, Any]]) -> list[str]:
|
|
141
|
+
"""Return a list of human-readable schema violations. Empty list = pass."""
|
|
142
|
+
violations: list[str] = []
|
|
143
|
+
|
|
144
|
+
for cid, claim in claims.items():
|
|
145
|
+
if not ID_PATTERN.match(cid):
|
|
146
|
+
violations.append(f"{cid}: id does not match {ID_PATTERN.pattern}")
|
|
147
|
+
|
|
148
|
+
missing = REQUIRED_TOP - set(claim.keys())
|
|
149
|
+
if missing:
|
|
150
|
+
violations.append(f"{cid}: missing required fields {sorted(missing)}")
|
|
151
|
+
|
|
152
|
+
version = claim.get("version")
|
|
153
|
+
if version is not None and (
|
|
154
|
+
not isinstance(version, str) or not VERSION_PATTERN.match(version)
|
|
155
|
+
):
|
|
156
|
+
violations.append(f"{cid}: version `{version!r}` is not semver X.Y.Z")
|
|
157
|
+
|
|
158
|
+
status = claim.get("status")
|
|
159
|
+
if status is not None and status not in VALID_STATUS:
|
|
160
|
+
violations.append(
|
|
161
|
+
f"{cid}: invalid status `{status!r}` (allowed {sorted(VALID_STATUS)})"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
criticality = claim.get("criticality")
|
|
165
|
+
if criticality is not None and criticality not in VALID_CRITICALITY:
|
|
166
|
+
violations.append(
|
|
167
|
+
f"{cid}: invalid criticality `{criticality!r}` "
|
|
168
|
+
f"(allowed {sorted(VALID_CRITICALITY)})"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
statement = claim.get("statement")
|
|
172
|
+
if statement is not None and (not isinstance(statement, str) or len(statement) < 10):
|
|
173
|
+
violations.append(f"{cid}: statement must be a string of >=10 characters")
|
|
174
|
+
|
|
175
|
+
scope, test = _scope_and_test(claim)
|
|
176
|
+
if scope is None:
|
|
177
|
+
violations.append(f"{cid}: missing scope (must appear as `test.scope` or top-level `scope`)")
|
|
178
|
+
elif scope not in VALID_SCOPE:
|
|
179
|
+
violations.append(
|
|
180
|
+
f"{cid}: invalid scope `{scope!r}` (allowed {sorted(VALID_SCOPE)})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Test object validation (only meaningful when present)
|
|
184
|
+
if test is not None:
|
|
185
|
+
if "component" not in test:
|
|
186
|
+
violations.append(f"{cid}: test.component is required")
|
|
187
|
+
if "type" not in test:
|
|
188
|
+
violations.append(f"{cid}: test.type is required")
|
|
189
|
+
else:
|
|
190
|
+
ttype = test["type"]
|
|
191
|
+
if ttype not in VALID_TYPE:
|
|
192
|
+
violations.append(
|
|
193
|
+
f"{cid}: invalid test.type `{ttype!r}` (allowed {sorted(VALID_TYPE)})"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return violations
|
csd_intent/walker.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Multi-language walker that finds @intent / intent() markers in test files.
|
|
2
|
+
|
|
3
|
+
Two strategies:
|
|
4
|
+
- .py → AST walk for @intent("INT-...") decorators on test_ functions
|
|
5
|
+
- .ts/.tsx/.js/.jsx/.mts/.cts → regex for intent('INT-...', name, fn) calls
|
|
6
|
+
|
|
7
|
+
Both produce {claim_id: [test_ref, ...]} where test_ref is "<rel-path>::<name>".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_EXCLUDE_DIRS",
|
|
19
|
+
"INTENT_FILENAME",
|
|
20
|
+
"JS_EXTS",
|
|
21
|
+
"PY_EXT",
|
|
22
|
+
"TEST_FILE_RE",
|
|
23
|
+
"collect_attestations",
|
|
24
|
+
"find_nested_intent_projects",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
DEFAULT_EXCLUDE_DIRS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"node_modules",
|
|
30
|
+
".venv",
|
|
31
|
+
"venv",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
".git",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
".pytest_cache",
|
|
37
|
+
".mypy_cache",
|
|
38
|
+
".ruff_cache",
|
|
39
|
+
".tox",
|
|
40
|
+
".egg-info",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
PY_EXT = ".py"
|
|
45
|
+
JS_EXTS = frozenset({".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"})
|
|
46
|
+
|
|
47
|
+
# A directory that contains this file (other than the scan root) is a *nested
|
|
48
|
+
# intent project* - a separate project boundary. The marker walk must not descend
|
|
49
|
+
# into it, so its markers do not orphan against the outer project's claims.
|
|
50
|
+
INTENT_FILENAME = "intent.yaml"
|
|
51
|
+
TEST_FILE_RE = re.compile(r"(^|[._-])(test|spec)([._-]|$)|test_|_test\.|\.test\.|\.spec\.")
|
|
52
|
+
|
|
53
|
+
# Matches intent('INT-XXX', "name", ...) OR intent("INT-XXX", "name", ...)
|
|
54
|
+
# Also array form: intent(['INT-X', 'INT-Y'], 'name', ...)
|
|
55
|
+
_JS_INTENT_RE = re.compile(
|
|
56
|
+
r"\bintent\s*\(\s*"
|
|
57
|
+
r"(?:\[\s*(?P<list>[^\]]+?)\s*\]|(?P<q1>['\"`])(?P<id>INT-[A-Z0-9-]+)(?P=q1))"
|
|
58
|
+
r"\s*,\s*(?P<q2>['\"`])(?P<name>[\s\S]*?)(?P=q2)\s*,"
|
|
59
|
+
)
|
|
60
|
+
_JS_ID_IN_LIST_RE = re.compile(r"['\"`](INT-[A-Z0-9-]+)['\"`]")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect_attestations(
|
|
64
|
+
test_dirs: Iterable[Path],
|
|
65
|
+
exclude_dirs: frozenset[str] = DEFAULT_EXCLUDE_DIRS,
|
|
66
|
+
respect_nested_projects: bool = True,
|
|
67
|
+
) -> dict[str, list[str]]:
|
|
68
|
+
"""Walk `test_dirs` and return {claim_id: [test_ref, ...]} for every intent marker.
|
|
69
|
+
|
|
70
|
+
test_refs are formatted ``<rel-path>::<test-name>`` for readability in error output.
|
|
71
|
+
|
|
72
|
+
When ``respect_nested_projects`` is True (the default), any subdirectory below a
|
|
73
|
+
scanned root that contains its own ``intent.yaml`` is treated as a separate project
|
|
74
|
+
boundary: the walk does not descend into it, so the nested project's markers do not
|
|
75
|
+
orphan against the outer project's claims. The scanned root itself is never skipped,
|
|
76
|
+
even though it normally contains an ``intent.yaml`` (it *is* the project under audit).
|
|
77
|
+
"""
|
|
78
|
+
out: dict[str, list[str]] = {}
|
|
79
|
+
for root in test_dirs:
|
|
80
|
+
if not root.exists():
|
|
81
|
+
continue
|
|
82
|
+
base = root.resolve()
|
|
83
|
+
for path in _walk_files(base, exclude_dirs, respect_nested_projects):
|
|
84
|
+
if not _is_test_file(path):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
text = path.read_text(encoding="utf-8")
|
|
88
|
+
except (OSError, UnicodeDecodeError):
|
|
89
|
+
continue
|
|
90
|
+
rel = path.relative_to(base).as_posix()
|
|
91
|
+
if path.suffix == PY_EXT:
|
|
92
|
+
_collect_from_python(text, rel, out)
|
|
93
|
+
elif path.suffix in JS_EXTS:
|
|
94
|
+
_collect_from_js(text, rel, out)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _walk_files(
|
|
99
|
+
root: Path,
|
|
100
|
+
exclude_dirs: frozenset[str],
|
|
101
|
+
respect_nested_projects: bool = True,
|
|
102
|
+
) -> Iterable[Path]:
|
|
103
|
+
"""Recursively yield files under ``root``, skipping excluded directory names.
|
|
104
|
+
|
|
105
|
+
Stops descending into any nested intent-project subtree (a subdirectory that
|
|
106
|
+
contains its own ``intent.yaml``) when ``respect_nested_projects`` is True. The
|
|
107
|
+
starting ``root`` is always scanned even if it holds an ``intent.yaml``.
|
|
108
|
+
"""
|
|
109
|
+
for entry in root.iterdir():
|
|
110
|
+
if entry.is_symlink():
|
|
111
|
+
continue
|
|
112
|
+
if entry.is_dir():
|
|
113
|
+
if entry.name in exclude_dirs:
|
|
114
|
+
continue
|
|
115
|
+
if respect_nested_projects and (entry / INTENT_FILENAME).is_file():
|
|
116
|
+
# A nested intent project - a separate boundary. Do not descend.
|
|
117
|
+
continue
|
|
118
|
+
yield from _walk_files(entry, exclude_dirs, respect_nested_projects)
|
|
119
|
+
elif entry.is_file():
|
|
120
|
+
yield entry
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_nested_intent_projects(
|
|
124
|
+
root: Path,
|
|
125
|
+
exclude_dirs: frozenset[str] = DEFAULT_EXCLUDE_DIRS,
|
|
126
|
+
) -> list[Path]:
|
|
127
|
+
"""Return directories *strictly below* ``root`` that hold their own ``intent.yaml``.
|
|
128
|
+
|
|
129
|
+
The ``root`` itself is never included (it is the project being audited). Results are
|
|
130
|
+
sorted for deterministic output. Each returned directory is the root of a separate
|
|
131
|
+
intent project; auditing it with ``respect_nested_projects=True`` bounds its marker
|
|
132
|
+
scan against any still-deeper nested projects, giving a complete, non-overlapping
|
|
133
|
+
partition of the tree.
|
|
134
|
+
"""
|
|
135
|
+
base = root.resolve()
|
|
136
|
+
found: list[Path] = []
|
|
137
|
+
|
|
138
|
+
def _descend(current: Path) -> None:
|
|
139
|
+
for entry in sorted(current.iterdir()):
|
|
140
|
+
if entry.is_symlink() or not entry.is_dir():
|
|
141
|
+
continue
|
|
142
|
+
if entry.name in exclude_dirs:
|
|
143
|
+
continue
|
|
144
|
+
if (entry / INTENT_FILENAME).is_file():
|
|
145
|
+
found.append(entry)
|
|
146
|
+
# Always descend further so deeper-nested projects are also discovered.
|
|
147
|
+
_descend(entry)
|
|
148
|
+
|
|
149
|
+
if base.is_dir():
|
|
150
|
+
_descend(base)
|
|
151
|
+
return found
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_test_file(path: Path) -> bool:
|
|
155
|
+
"""Is this file a test by conventional naming?"""
|
|
156
|
+
name = path.name
|
|
157
|
+
if path.suffix == PY_EXT:
|
|
158
|
+
return name.startswith("test_") or name.endswith("_test.py")
|
|
159
|
+
return bool(TEST_FILE_RE.search(name))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect_from_python(text: str, rel_path: str, out: dict[str, list[str]]) -> None:
|
|
163
|
+
"""AST-walk a .py file looking for @intent("INT-...") decorators."""
|
|
164
|
+
try:
|
|
165
|
+
tree = ast.parse(text)
|
|
166
|
+
except SyntaxError:
|
|
167
|
+
return
|
|
168
|
+
for node in ast.walk(tree):
|
|
169
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
170
|
+
continue
|
|
171
|
+
if not node.name.startswith("test_"):
|
|
172
|
+
continue
|
|
173
|
+
for dec in node.decorator_list:
|
|
174
|
+
for cid in _extract_ids_from_decorator(dec):
|
|
175
|
+
ref = f"{rel_path}::{node.name}"
|
|
176
|
+
out.setdefault(cid, []).append(ref)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _extract_ids_from_decorator(node: ast.expr) -> list[str]:
|
|
180
|
+
"""If `node` is a call to `intent(...)`, return the claim-ID string args."""
|
|
181
|
+
if not isinstance(node, ast.Call):
|
|
182
|
+
return []
|
|
183
|
+
func = node.func
|
|
184
|
+
name = (
|
|
185
|
+
func.id
|
|
186
|
+
if isinstance(func, ast.Name)
|
|
187
|
+
else func.attr
|
|
188
|
+
if isinstance(func, ast.Attribute)
|
|
189
|
+
else None
|
|
190
|
+
)
|
|
191
|
+
if name != "intent":
|
|
192
|
+
return []
|
|
193
|
+
ids: list[str] = []
|
|
194
|
+
for arg in node.args:
|
|
195
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.startswith("INT-"):
|
|
196
|
+
ids.append(arg.value)
|
|
197
|
+
return ids
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _collect_from_js(text: str, rel_path: str, out: dict[str, list[str]]) -> None:
|
|
201
|
+
"""Regex-scan a JS/TS file for intent('INT-...', name, fn) calls."""
|
|
202
|
+
for match in _JS_INTENT_RE.finditer(text):
|
|
203
|
+
name = match.group("name")
|
|
204
|
+
ids: list[str] = []
|
|
205
|
+
if match.group("list"):
|
|
206
|
+
ids.extend(_JS_ID_IN_LIST_RE.findall(match.group("list")))
|
|
207
|
+
elif match.group("id"):
|
|
208
|
+
ids.append(match.group("id"))
|
|
209
|
+
for cid in ids:
|
|
210
|
+
ref = f"{rel_path}::{name}"
|
|
211
|
+
out.setdefault(cid, []).append(ref)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csd-intent
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Cross-runtime audit tool for CSD intent specifications. Validates intent.yaml against CSD-INTENT-01 and verifies each claim is attested by at least one test across pytest, vitest, Playwright, or any other runner using the standard intent() marker.
|
|
5
|
+
Author: Rafael Pires
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PSA-Department-of-Engineering/csd-library
|
|
8
|
+
Project-URL: Documentation, https://github.com/PSA-Department-of-Engineering/csd-library#readme
|
|
9
|
+
Project-URL: CSD methodology, https://github.com/PSA-Department-of-Engineering/cognitive-software-delivery
|
|
10
|
+
Keywords: csd,intent,testing,specification,audit,cross-language
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: PyYAML>=6.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# csd-intent
|
|
33
|
+
|
|
34
|
+
Cross-runtime audit tool for CSD intent specifications. Validates `intent.yaml`
|
|
35
|
+
against [CSD-INTENT-01](https://github.com/PSA-Department-of-Engineering/cognitive-software-delivery) and confirms every
|
|
36
|
+
claim is attested by at least one test marker across **any** test runner -
|
|
37
|
+
pytest, vitest, Playwright, Jest, or anything that uses the standard `intent()`
|
|
38
|
+
/ `@intent()` marker shape.
|
|
39
|
+
|
|
40
|
+
This tool is **standalone**. It does not run inside your project's test suite; it
|
|
41
|
+
is invoked separately (CLI, CI step, or pre-commit hook).
|
|
42
|
+
|
|
43
|
+
## Why it exists
|
|
44
|
+
|
|
45
|
+
`pytest-intent`, `vitest-intent`, and `playwright-intent` provide the test-side
|
|
46
|
+
marker helpers - they let a test declare which intent claim it attests. They
|
|
47
|
+
deliberately know nothing about other runtimes, schema validation, or whether a
|
|
48
|
+
claim is unattested.
|
|
49
|
+
|
|
50
|
+
`csd-intent` owns the cross-runtime auditing: it walks every test file in the
|
|
51
|
+
project (Python via AST, TS/JS via regex), reads `intent.yaml`, and answers:
|
|
52
|
+
|
|
53
|
+
1. **Schema** - does every claim match CSD-INTENT-01?
|
|
54
|
+
2. **Orphan** - does every test marker reference a real claim?
|
|
55
|
+
3. **Coverage** - does every claim have at least one attesting test, anywhere?
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# From PyPI:
|
|
61
|
+
pip install csd-intent
|
|
62
|
+
|
|
63
|
+
# Local dev against a csd-library checkout:
|
|
64
|
+
pip install -e path/to/csd-library/csd-intent
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Use
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Audit the current directory (expects intent.yaml at the root):
|
|
71
|
+
csd-intent
|
|
72
|
+
|
|
73
|
+
# Audit a specific project:
|
|
74
|
+
csd-intent /path/to/project
|
|
75
|
+
|
|
76
|
+
# Audit but tolerate unattested claims (the "intent before test" workflow):
|
|
77
|
+
csd-intent --fail-on schema
|
|
78
|
+
|
|
79
|
+
# Constrain the scan to specific directories:
|
|
80
|
+
csd-intent --tests-dir backend/tests --tests-dir frontend/src --tests-dir e2e
|
|
81
|
+
|
|
82
|
+
# Quiet summary only:
|
|
83
|
+
csd-intent --quiet
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit code is `0` on a clean audit, `1` when any violation falls into the
|
|
87
|
+
configured `--fail-on` set (`any` by default).
|
|
88
|
+
|
|
89
|
+
## Output
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
intent.yaml (/path/to/project/intent.yaml): 24 claims, 2 violation(s).
|
|
93
|
+
|
|
94
|
+
UNATTESTED (2):
|
|
95
|
+
[unattested] INT-SB-018: no @intent / intent() marker references this claim
|
|
96
|
+
[unattested] INT-SB-029: no @intent / intent() marker references this claim
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## What it scans
|
|
100
|
+
|
|
101
|
+
- **Python**: any file matching `test_*.py` or `*_test.py`, walked via AST for
|
|
102
|
+
`@intent("INT-...")` decorators on functions starting with `test_`.
|
|
103
|
+
- **JS/TS**: any file matching `*.test.{ts,tsx,js,jsx,mts,cts}` or
|
|
104
|
+
`*.spec.{ts,...}`, scanned by regex for `intent('INT-...', 'name', fn)` calls
|
|
105
|
+
(single-ID and array-of-IDs forms both supported).
|
|
106
|
+
- **Excluded directories**: `node_modules`, `.venv`, `venv`, `dist`, `build`,
|
|
107
|
+
`.git`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox`.
|
|
108
|
+
|
|
109
|
+
## CSD-INTENT-01 conformance
|
|
110
|
+
|
|
111
|
+
The schema check validates each claim against the canonical fields per
|
|
112
|
+
[CSD-INTENT-01 §4.1](https://github.com/PSA-Department-of-Engineering/cognitive-software-delivery):
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
INT-NNN:
|
|
116
|
+
version: 1.0.0
|
|
117
|
+
status: active # draft | active | deprecated
|
|
118
|
+
statement: "..." # >= 10 chars, RFC 2119 language
|
|
119
|
+
rationale: "..." # optional but recommended
|
|
120
|
+
test:
|
|
121
|
+
scope: integration # unit | integration | e2e
|
|
122
|
+
component: SwitchFlow
|
|
123
|
+
type: behavior # invariant | behavior | contract
|
|
124
|
+
criticality: critical # critical | high | medium | low
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For projects still on the legacy flat-`scope:` shape, the tool accepts it (with
|
|
128
|
+
no warning) - that's a migration concession, not a recommendation.
|
|
129
|
+
|
|
130
|
+
### Duplicate keys are refused
|
|
131
|
+
|
|
132
|
+
`intent.yaml` is parsed with a loader that raises on a repeated key rather than
|
|
133
|
+
resolving it last-wins. A claim id reused by accident would otherwise delete the
|
|
134
|
+
earlier claim before any check could see it - and every marker written for that
|
|
135
|
+
claim would silently start attesting the survivor, with the audit still printing
|
|
136
|
+
`CLEAN`. The duplicate is reported as a schema violation naming both lines:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
SCHEMA (1):
|
|
140
|
+
[schema] intent.yaml: duplicate key `INT-GATE-006` (first declared on line 42,
|
|
141
|
+
declared again on line 187); YAML would silently keep only the last one
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Merge keys still work: `<<: *base` plus an explicit override is not a duplicate.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
csd_intent/__init__.py,sha256=nFHCLz3m9SAMIbAmOrjYhHzAAA87uWQ_ohcErQUradQ,1592
|
|
2
|
+
csd_intent/audit.py,sha256=25DTzlA2CrwCcVL7BydICFEgUe8VLtXV6SV81owLYkA,7079
|
|
3
|
+
csd_intent/cli.py,sha256=IBU9M3O0XGacdPF4Ew454hKPIZ9-NGxpUu6IKCymv6M,5130
|
|
4
|
+
csd_intent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
csd_intent/schema.py,sha256=d490KC-rotQ_B_QYjcwZTDDESxDYxthxuB_L9eCB2uo,7434
|
|
6
|
+
csd_intent/walker.py,sha256=YsiGDvLhIv6Cki1NufV35TY0wrxNQecIw_iyP_U8yOA,7453
|
|
7
|
+
csd_intent-0.4.0.dist-info/licenses/LICENSE,sha256=ngjAHPmFeQOm2fm-eMuriVgRfTA8aX8FpdnP9NgoGD0,1069
|
|
8
|
+
csd_intent-0.4.0.dist-info/METADATA,sha256=XjKahOTzUZQnfnZPM9G5DGNJu6-DZTruuiVvwDCeb3U,5670
|
|
9
|
+
csd_intent-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
csd_intent-0.4.0.dist-info/entry_points.txt,sha256=L1L37Zg8hh6jMNlWMiBQQp27YstvYrGE7XVLj4sRRHw,51
|
|
11
|
+
csd_intent-0.4.0.dist-info/top_level.txt,sha256=RMKlGhAFmT6IF0uknN5jv2ElnxWLkPsQwS-jh19B2bQ,11
|
|
12
|
+
csd_intent-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rafael Pires
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
csd_intent
|