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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csd-intent = csd_intent.cli:main
@@ -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