csd-intent 0.4.0__tar.gz

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.
@@ -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,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,117 @@
1
+ # csd-intent
2
+
3
+ Cross-runtime audit tool for CSD intent specifications. Validates `intent.yaml`
4
+ against [CSD-INTENT-01](https://github.com/PSA-Department-of-Engineering/cognitive-software-delivery) and confirms every
5
+ claim is attested by at least one test marker across **any** test runner -
6
+ pytest, vitest, Playwright, Jest, or anything that uses the standard `intent()`
7
+ / `@intent()` marker shape.
8
+
9
+ This tool is **standalone**. It does not run inside your project's test suite; it
10
+ is invoked separately (CLI, CI step, or pre-commit hook).
11
+
12
+ ## Why it exists
13
+
14
+ `pytest-intent`, `vitest-intent`, and `playwright-intent` provide the test-side
15
+ marker helpers - they let a test declare which intent claim it attests. They
16
+ deliberately know nothing about other runtimes, schema validation, or whether a
17
+ claim is unattested.
18
+
19
+ `csd-intent` owns the cross-runtime auditing: it walks every test file in the
20
+ project (Python via AST, TS/JS via regex), reads `intent.yaml`, and answers:
21
+
22
+ 1. **Schema** - does every claim match CSD-INTENT-01?
23
+ 2. **Orphan** - does every test marker reference a real claim?
24
+ 3. **Coverage** - does every claim have at least one attesting test, anywhere?
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ # From PyPI:
30
+ pip install csd-intent
31
+
32
+ # Local dev against a csd-library checkout:
33
+ pip install -e path/to/csd-library/csd-intent
34
+ ```
35
+
36
+ ## Use
37
+
38
+ ```bash
39
+ # Audit the current directory (expects intent.yaml at the root):
40
+ csd-intent
41
+
42
+ # Audit a specific project:
43
+ csd-intent /path/to/project
44
+
45
+ # Audit but tolerate unattested claims (the "intent before test" workflow):
46
+ csd-intent --fail-on schema
47
+
48
+ # Constrain the scan to specific directories:
49
+ csd-intent --tests-dir backend/tests --tests-dir frontend/src --tests-dir e2e
50
+
51
+ # Quiet summary only:
52
+ csd-intent --quiet
53
+ ```
54
+
55
+ Exit code is `0` on a clean audit, `1` when any violation falls into the
56
+ configured `--fail-on` set (`any` by default).
57
+
58
+ ## Output
59
+
60
+ ```
61
+ intent.yaml (/path/to/project/intent.yaml): 24 claims, 2 violation(s).
62
+
63
+ UNATTESTED (2):
64
+ [unattested] INT-SB-018: no @intent / intent() marker references this claim
65
+ [unattested] INT-SB-029: no @intent / intent() marker references this claim
66
+ ```
67
+
68
+ ## What it scans
69
+
70
+ - **Python**: any file matching `test_*.py` or `*_test.py`, walked via AST for
71
+ `@intent("INT-...")` decorators on functions starting with `test_`.
72
+ - **JS/TS**: any file matching `*.test.{ts,tsx,js,jsx,mts,cts}` or
73
+ `*.spec.{ts,...}`, scanned by regex for `intent('INT-...', 'name', fn)` calls
74
+ (single-ID and array-of-IDs forms both supported).
75
+ - **Excluded directories**: `node_modules`, `.venv`, `venv`, `dist`, `build`,
76
+ `.git`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox`.
77
+
78
+ ## CSD-INTENT-01 conformance
79
+
80
+ The schema check validates each claim against the canonical fields per
81
+ [CSD-INTENT-01 §4.1](https://github.com/PSA-Department-of-Engineering/cognitive-software-delivery):
82
+
83
+ ```yaml
84
+ INT-NNN:
85
+ version: 1.0.0
86
+ status: active # draft | active | deprecated
87
+ statement: "..." # >= 10 chars, RFC 2119 language
88
+ rationale: "..." # optional but recommended
89
+ test:
90
+ scope: integration # unit | integration | e2e
91
+ component: SwitchFlow
92
+ type: behavior # invariant | behavior | contract
93
+ criticality: critical # critical | high | medium | low
94
+ ```
95
+
96
+ For projects still on the legacy flat-`scope:` shape, the tool accepts it (with
97
+ no warning) - that's a migration concession, not a recommendation.
98
+
99
+ ### Duplicate keys are refused
100
+
101
+ `intent.yaml` is parsed with a loader that raises on a repeated key rather than
102
+ resolving it last-wins. A claim id reused by accident would otherwise delete the
103
+ earlier claim before any check could see it - and every marker written for that
104
+ claim would silently start attesting the survivor, with the audit still printing
105
+ `CLEAN`. The duplicate is reported as a schema violation naming both lines:
106
+
107
+ ```
108
+ SCHEMA (1):
109
+ [schema] intent.yaml: duplicate key `INT-GATE-006` (first declared on line 42,
110
+ declared again on line 187); YAML would silently keep only the last one
111
+ ```
112
+
113
+ Merge keys still work: `<<: *base` plus an explicit override is not a duplicate.
114
+
115
+ ## License
116
+
117
+ MIT.
@@ -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
+ ]
@@ -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
@@ -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())
File without changes