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.
- csd_intent-0.4.0/LICENSE +21 -0
- csd_intent-0.4.0/PKG-INFO +148 -0
- csd_intent-0.4.0/README.md +117 -0
- csd_intent-0.4.0/csd_intent/__init__.py +46 -0
- csd_intent-0.4.0/csd_intent/audit.py +194 -0
- csd_intent-0.4.0/csd_intent/cli.py +158 -0
- csd_intent-0.4.0/csd_intent/py.typed +0 -0
- csd_intent-0.4.0/csd_intent/schema.py +196 -0
- csd_intent-0.4.0/csd_intent/walker.py +211 -0
- csd_intent-0.4.0/csd_intent.egg-info/PKG-INFO +148 -0
- csd_intent-0.4.0/csd_intent.egg-info/SOURCES.txt +20 -0
- csd_intent-0.4.0/csd_intent.egg-info/dependency_links.txt +1 -0
- csd_intent-0.4.0/csd_intent.egg-info/entry_points.txt +2 -0
- csd_intent-0.4.0/csd_intent.egg-info/requires.txt +6 -0
- csd_intent-0.4.0/csd_intent.egg-info/top_level.txt +1 -0
- csd_intent-0.4.0/pyproject.toml +54 -0
- csd_intent-0.4.0/setup.cfg +4 -0
- csd_intent-0.4.0/tests/test_audit.py +239 -0
- csd_intent-0.4.0/tests/test_cli.py +105 -0
- csd_intent-0.4.0/tests/test_nested.py +268 -0
- csd_intent-0.4.0/tests/test_schema.py +208 -0
- csd_intent-0.4.0/tests/test_walker.py +104 -0
csd_intent-0.4.0/LICENSE
ADDED
|
@@ -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
|