@grifhinz/logics-manager 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/VERSION +1 -0
- package/logics_manager/__init__.py +5 -0
- package/logics_manager/__main__.py +9 -0
- package/logics_manager/assist.py +2211 -0
- package/logics_manager/audit.py +990 -0
- package/logics_manager/bootstrap.py +123 -0
- package/logics_manager/cli.py +183 -0
- package/logics_manager/config.py +251 -0
- package/logics_manager/doctor.py +127 -0
- package/logics_manager/flow.py +1449 -0
- package/logics_manager/index.py +142 -0
- package/logics_manager/lint.py +622 -0
- package/logics_manager/sync.py +604 -0
- package/package.json +162 -0
- package/pyproject.toml +15 -0
- package/scripts/logics-manager.py +15 -0
- package/scripts/npm/logics-manager.mjs +96 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
from .config import find_repo_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CURRENT_WORKFLOW_SCHEMA_VERSION = "1.0"
|
|
15
|
+
|
|
16
|
+
DOC_KINDS = {
|
|
17
|
+
"request": ("logics/request", "req", False),
|
|
18
|
+
"backlog": ("logics/backlog", "item", True),
|
|
19
|
+
"task": ("logics/tasks", "task", True),
|
|
20
|
+
"product": ("logics/product", "prod", False),
|
|
21
|
+
"architecture": ("logics/architecture", "adr", False),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
REF_PREFIXES = ("req", "item", "task", "prod", "adr", "spec")
|
|
25
|
+
STATUS_IN_PROGRESS = {"draft", "ready", "in progress", "blocked"}
|
|
26
|
+
STATUS_DONE = {"done", "archived"}
|
|
27
|
+
|
|
28
|
+
COMPANION_PLACEHOLDERS: dict[str, tuple[str, ...]] = {
|
|
29
|
+
"product": (
|
|
30
|
+
"Summarize the product direction, the targeted user value, and the main expected outcomes.",
|
|
31
|
+
"Describe the user or business problem this brief resolves.",
|
|
32
|
+
"Primary user or segment",
|
|
33
|
+
"Primary product goal",
|
|
34
|
+
"Main open product question to resolve",
|
|
35
|
+
),
|
|
36
|
+
"architecture": (
|
|
37
|
+
"Summarize the chosen direction, what changes, and the main impacted areas.",
|
|
38
|
+
"Describe the problem, constraints, and drivers.",
|
|
39
|
+
"State the chosen option and rationale.",
|
|
40
|
+
"Describe the rollout or migration step.",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
TOKEN_HYGIENE_PLACEHOLDERS = (
|
|
44
|
+
"Summarize the need, scope, and expected outcome",
|
|
45
|
+
"logics, workflow",
|
|
46
|
+
"Use when framing scope, context, and acceptance checks",
|
|
47
|
+
)
|
|
48
|
+
TOKEN_HYGIENE_SECTION_LIMITS: dict[str, dict[str, int]] = {
|
|
49
|
+
"request": {"Context": 24},
|
|
50
|
+
"backlog": {"Problem": 16, "Notes": 24},
|
|
51
|
+
"task": {"Context": 16, "Report": 16},
|
|
52
|
+
}
|
|
53
|
+
GOVERNANCE_PROFILES = {
|
|
54
|
+
"relaxed": {
|
|
55
|
+
"stale_days": 0,
|
|
56
|
+
"require_gates": False,
|
|
57
|
+
"require_ac_traceability": False,
|
|
58
|
+
"token_hygiene": False,
|
|
59
|
+
},
|
|
60
|
+
"standard": {
|
|
61
|
+
"stale_days": 45,
|
|
62
|
+
"require_gates": True,
|
|
63
|
+
"require_ac_traceability": True,
|
|
64
|
+
"token_hygiene": False,
|
|
65
|
+
},
|
|
66
|
+
"strict": {
|
|
67
|
+
"stale_days": 30,
|
|
68
|
+
"require_gates": True,
|
|
69
|
+
"require_ac_traceability": True,
|
|
70
|
+
"token_hygiene": True,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
HYBRID_CACHE_JSONL_FILES = (
|
|
75
|
+
Path("logics/.cache/hybrid_assist_audit.jsonl"),
|
|
76
|
+
Path("logics/.cache/hybrid_assist_measurements.jsonl"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class DocKind:
|
|
82
|
+
kind: str
|
|
83
|
+
directory: str
|
|
84
|
+
prefix: str
|
|
85
|
+
has_progress: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DOC_KIND_OBJECTS = {
|
|
89
|
+
name: DocKind(name, directory, prefix, has_progress)
|
|
90
|
+
for name, (directory, prefix, has_progress) in DOC_KINDS.items()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class DocMeta:
|
|
96
|
+
kind: DocKind
|
|
97
|
+
path: Path
|
|
98
|
+
ref: str
|
|
99
|
+
status: str | None
|
|
100
|
+
progress: int | None
|
|
101
|
+
from_version: tuple[int, int, int] | None
|
|
102
|
+
text: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AuditIssue:
|
|
107
|
+
code: str
|
|
108
|
+
path: Path | None
|
|
109
|
+
message: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _indicator_value(lines: list[str], key: str) -> str | None:
|
|
113
|
+
pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+)\s*$")
|
|
114
|
+
for line in lines:
|
|
115
|
+
match = pattern.match(line)
|
|
116
|
+
if match:
|
|
117
|
+
return match.group(1).strip()
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _status_normalized(value: str | None) -> str | None:
|
|
122
|
+
if value is None:
|
|
123
|
+
return None
|
|
124
|
+
return " ".join(value.split()).lower()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _canonical_status(value: str | None) -> str | None:
|
|
128
|
+
if value is None:
|
|
129
|
+
return None
|
|
130
|
+
normalized = _status_normalized(value)
|
|
131
|
+
allowed = ("Draft", "Ready", "In progress", "Blocked", "Done", "Archived")
|
|
132
|
+
for candidate in allowed:
|
|
133
|
+
if normalized == candidate.lower():
|
|
134
|
+
return candidate
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _progress_value(value: str | None) -> int | None:
|
|
139
|
+
if value is None:
|
|
140
|
+
return None
|
|
141
|
+
match = re.search(r"(\d{1,3})", value)
|
|
142
|
+
if match is None:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
parsed = int(match.group(1))
|
|
146
|
+
except ValueError:
|
|
147
|
+
return None
|
|
148
|
+
return max(0, min(100, parsed))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_semver(value: str | None) -> tuple[int, int, int] | None:
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", value.strip())
|
|
155
|
+
if match is None:
|
|
156
|
+
return None
|
|
157
|
+
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extract_refs(text: str, prefix: str) -> set[str]:
|
|
161
|
+
text = re.sub(r"```mermaid\s*\n.*?\n```", "", text, flags=re.DOTALL)
|
|
162
|
+
pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
|
|
163
|
+
return {match.group(0) for match in pattern.finditer(text)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _has_mermaid_block(text: str) -> bool:
|
|
167
|
+
return "```mermaid" in text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decision_framing_value(text: str, label: str) -> str | None:
|
|
171
|
+
pattern = re.compile(rf"^\s*-\s*{re.escape(label)}\s*:\s*(.+)\s*$", re.MULTILINE)
|
|
172
|
+
match = pattern.search(text)
|
|
173
|
+
if match is None:
|
|
174
|
+
return None
|
|
175
|
+
return match.group(1).strip()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_section_lines(text: str, heading_title: str) -> list[str]:
|
|
179
|
+
lines = text.splitlines()
|
|
180
|
+
start_idx = None
|
|
181
|
+
target = heading_title.strip().lower()
|
|
182
|
+
for idx, line in enumerate(lines):
|
|
183
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
184
|
+
start_idx = idx + 1
|
|
185
|
+
break
|
|
186
|
+
if start_idx is None:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
section: list[str] = []
|
|
190
|
+
for idx in range(start_idx, len(lines)):
|
|
191
|
+
line = lines[idx]
|
|
192
|
+
if line.startswith("# "):
|
|
193
|
+
break
|
|
194
|
+
section.append(line)
|
|
195
|
+
return section
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _extract_section_bounds(lines: list[str], heading_title: str) -> tuple[int, int] | None:
|
|
199
|
+
start_idx = None
|
|
200
|
+
target = heading_title.strip().lower()
|
|
201
|
+
for idx, line in enumerate(lines):
|
|
202
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
203
|
+
start_idx = idx
|
|
204
|
+
break
|
|
205
|
+
if start_idx is None:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
end_idx = len(lines)
|
|
209
|
+
for idx in range(start_idx + 1, len(lines)):
|
|
210
|
+
if lines[idx].startswith("# "):
|
|
211
|
+
end_idx = idx
|
|
212
|
+
break
|
|
213
|
+
return start_idx, end_idx
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_checkboxes(section_lines: Iterable[str]) -> list[tuple[bool, str]]:
|
|
217
|
+
out: list[tuple[bool, str]] = []
|
|
218
|
+
pattern = re.compile(r"^\s*-\s*\[([ xX])\]\s*(.+)$")
|
|
219
|
+
for line in section_lines:
|
|
220
|
+
match = pattern.match(line)
|
|
221
|
+
if match:
|
|
222
|
+
out.append((match.group(1).lower() == "x", match.group(2).strip()))
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _section_content_line_count(text: str, heading: str) -> int:
|
|
227
|
+
return sum(1 for line in _extract_section_lines(text, heading) if line.strip())
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extract_request_ac_ids(request: DocMeta) -> list[str]:
|
|
231
|
+
section = _extract_section_lines(request.text, "Acceptance criteria")
|
|
232
|
+
ids: set[str] = set()
|
|
233
|
+
pattern = re.compile(r"\b(AC\d+[a-z]?)\b", re.IGNORECASE)
|
|
234
|
+
for line in section:
|
|
235
|
+
for match in pattern.finditer(line):
|
|
236
|
+
ids.add(match.group(1).upper())
|
|
237
|
+
return sorted(ids)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _extract_ai_context_fields(text: str) -> dict[str, str]:
|
|
241
|
+
section = _extract_section_lines(text, "AI Context")
|
|
242
|
+
fields: dict[str, str] = {}
|
|
243
|
+
pattern = re.compile(r"^\s*-\s*([^:]+)\s*:\s*(.+?)\s*$")
|
|
244
|
+
for line in section:
|
|
245
|
+
match = pattern.match(line.strip())
|
|
246
|
+
if match is None:
|
|
247
|
+
continue
|
|
248
|
+
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
|
249
|
+
return fields
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_done(doc: DocMeta) -> bool:
|
|
253
|
+
if doc.status is not None and doc.status in STATUS_DONE:
|
|
254
|
+
return True
|
|
255
|
+
if doc.kind.has_progress and doc.progress == 100:
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_repo_root_from(start: Path) -> Path:
|
|
261
|
+
try:
|
|
262
|
+
return find_repo_root(start)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
raise SystemExit(str(exc)) from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _collect_docs(repo_root: Path) -> dict[str, DocMeta]:
|
|
268
|
+
docs: dict[str, DocMeta] = {}
|
|
269
|
+
for kind in DOC_KIND_OBJECTS.values():
|
|
270
|
+
directory = repo_root / kind.directory
|
|
271
|
+
if not directory.is_dir():
|
|
272
|
+
continue
|
|
273
|
+
for path in sorted(directory.glob("*.md")):
|
|
274
|
+
text = path.read_text(encoding="utf-8")
|
|
275
|
+
lines = text.splitlines()
|
|
276
|
+
docs[path.stem] = DocMeta(
|
|
277
|
+
kind=kind,
|
|
278
|
+
path=path,
|
|
279
|
+
ref=path.stem,
|
|
280
|
+
status=_status_normalized(_indicator_value(lines, "Status")),
|
|
281
|
+
progress=_progress_value(_indicator_value(lines, "Progress")),
|
|
282
|
+
from_version=_parse_semver(_indicator_value(lines, "From version")),
|
|
283
|
+
text=text,
|
|
284
|
+
)
|
|
285
|
+
return docs
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _scope_by_paths(docs: dict[str, DocMeta], repo_root: Path, raw_paths: list[str]) -> set[str]:
|
|
289
|
+
included: set[str] = set()
|
|
290
|
+
resolved_targets = [(repo_root / raw_path).resolve() for raw_path in raw_paths]
|
|
291
|
+
for ref, doc in docs.items():
|
|
292
|
+
doc_path = doc.path.resolve()
|
|
293
|
+
for target in resolved_targets:
|
|
294
|
+
if doc_path == target or target in doc_path.parents:
|
|
295
|
+
included.add(ref)
|
|
296
|
+
break
|
|
297
|
+
return included
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _scope_by_refs(docs: dict[str, DocMeta], seed_refs: set[str]) -> set[str]:
|
|
301
|
+
included: set[str] = set()
|
|
302
|
+
queue = list(seed_refs)
|
|
303
|
+
while queue:
|
|
304
|
+
ref = queue.pop()
|
|
305
|
+
if ref in included:
|
|
306
|
+
continue
|
|
307
|
+
doc = docs.get(ref)
|
|
308
|
+
if doc is None:
|
|
309
|
+
continue
|
|
310
|
+
included.add(ref)
|
|
311
|
+
|
|
312
|
+
linked_refs: set[str] = set()
|
|
313
|
+
for prefix in REF_PREFIXES:
|
|
314
|
+
linked_refs.update(_extract_refs(doc.text, prefix))
|
|
315
|
+
for candidate in docs.values():
|
|
316
|
+
if ref in candidate.text:
|
|
317
|
+
linked_refs.add(candidate.ref)
|
|
318
|
+
|
|
319
|
+
for linked_ref in linked_refs:
|
|
320
|
+
if linked_ref not in included:
|
|
321
|
+
queue.append(linked_ref)
|
|
322
|
+
return included
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _apply_scope(
|
|
326
|
+
docs: dict[str, DocMeta],
|
|
327
|
+
repo_root: Path,
|
|
328
|
+
scope_paths: list[str],
|
|
329
|
+
scope_refs: list[str],
|
|
330
|
+
scope_since_version: tuple[int, int, int] | None,
|
|
331
|
+
) -> dict[str, DocMeta]:
|
|
332
|
+
allowed_refs = set(docs)
|
|
333
|
+
if scope_paths:
|
|
334
|
+
allowed_refs &= _scope_by_paths(docs, repo_root, scope_paths)
|
|
335
|
+
if scope_refs:
|
|
336
|
+
allowed_refs &= _scope_by_refs(docs, set(scope_refs))
|
|
337
|
+
if scope_since_version is not None:
|
|
338
|
+
allowed_refs &= {
|
|
339
|
+
ref
|
|
340
|
+
for ref, doc in docs.items()
|
|
341
|
+
if doc.from_version is not None and doc.from_version >= scope_since_version
|
|
342
|
+
}
|
|
343
|
+
return {ref: doc for ref, doc in docs.items() if ref in allowed_refs}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _linked_items_for_request(request: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
|
|
347
|
+
refs = _extract_refs(request.text, DOC_KIND_OBJECTS["backlog"].prefix)
|
|
348
|
+
return [docs[ref] for ref in sorted(refs) if ref in docs and docs[ref].kind.kind == "backlog"]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _linked_tasks_for_item(item: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
|
|
352
|
+
linked: list[DocMeta] = []
|
|
353
|
+
for doc in docs.values():
|
|
354
|
+
if doc.kind.kind != "task":
|
|
355
|
+
continue
|
|
356
|
+
if item.ref in doc.text:
|
|
357
|
+
linked.append(doc)
|
|
358
|
+
return linked
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _linked_requests_for_item(item: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
|
|
362
|
+
refs = _extract_refs(item.text, DOC_KIND_OBJECTS["request"].prefix)
|
|
363
|
+
return [docs[ref] for ref in sorted(refs) if ref in docs and docs[ref].kind.kind == "request"]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _last_modified_age_days(path: Path) -> float:
|
|
367
|
+
return (time.time() - path.stat().st_mtime) / 86400.0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_strict_scope(doc: DocMeta, cutoff: tuple[int, int, int] | None) -> bool:
|
|
371
|
+
if cutoff is None:
|
|
372
|
+
return True
|
|
373
|
+
if doc.from_version is None:
|
|
374
|
+
return False
|
|
375
|
+
return doc.from_version >= cutoff
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _has_ac_with_proof(text: str, ac_id: str) -> bool:
|
|
379
|
+
return (ac_id in text.upper()) and ("proof:" in text.lower())
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _upsert_indicator(lines: list[str], key: str, value: str) -> None:
|
|
383
|
+
pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+)\s*$")
|
|
384
|
+
heading_idx = next((idx for idx, line in enumerate(lines) if line.startswith("## ")), None)
|
|
385
|
+
if heading_idx is None:
|
|
386
|
+
return
|
|
387
|
+
for idx, line in enumerate(lines):
|
|
388
|
+
if pattern.match(line):
|
|
389
|
+
lines[idx] = f"> {key}: {value}"
|
|
390
|
+
return
|
|
391
|
+
insert_at = heading_idx + 1
|
|
392
|
+
while insert_at < len(lines) and lines[insert_at].lstrip().startswith(">"):
|
|
393
|
+
insert_at += 1
|
|
394
|
+
lines.insert(insert_at, f"> {key}: {value}")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _insert_section(lines: list[str], heading: str, body: list[str]) -> None:
|
|
398
|
+
bounds = _extract_section_bounds(lines, heading)
|
|
399
|
+
if bounds is not None:
|
|
400
|
+
start_idx, end_idx = bounds
|
|
401
|
+
lines[start_idx:end_idx] = [f"# {heading}", *body]
|
|
402
|
+
return
|
|
403
|
+
lines.append("")
|
|
404
|
+
lines.extend([f"# {heading}", *body])
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _autofix_structure(path: Path, doc_kind: str) -> bool:
|
|
408
|
+
original = path.read_text(encoding="utf-8")
|
|
409
|
+
lines = original.splitlines()
|
|
410
|
+
modified = False
|
|
411
|
+
|
|
412
|
+
status_value = _indicator_value(lines, "Status")
|
|
413
|
+
canonical_status = _canonical_status(status_value)
|
|
414
|
+
if canonical_status and canonical_status != status_value:
|
|
415
|
+
_upsert_indicator(lines, "Status", canonical_status)
|
|
416
|
+
modified = True
|
|
417
|
+
|
|
418
|
+
schema_value = _indicator_value(lines, "Schema version")
|
|
419
|
+
if schema_value != CURRENT_WORKFLOW_SCHEMA_VERSION:
|
|
420
|
+
_upsert_indicator(lines, "Schema version", CURRENT_WORKFLOW_SCHEMA_VERSION)
|
|
421
|
+
modified = True
|
|
422
|
+
|
|
423
|
+
text = "\n".join(lines).rstrip() + "\n"
|
|
424
|
+
|
|
425
|
+
if doc_kind == "request":
|
|
426
|
+
if not _extract_checkboxes(_extract_section_lines(text, "Definition of Ready (DoR)")):
|
|
427
|
+
_insert_section(
|
|
428
|
+
lines,
|
|
429
|
+
"Definition of Ready (DoR)",
|
|
430
|
+
[
|
|
431
|
+
"- [ ] Problem statement is explicit and user impact is clear.",
|
|
432
|
+
"- [ ] Scope boundaries (in/out) are explicit.",
|
|
433
|
+
"- [ ] Acceptance criteria are testable.",
|
|
434
|
+
"- [ ] Dependencies and known risks are listed.",
|
|
435
|
+
],
|
|
436
|
+
)
|
|
437
|
+
modified = True
|
|
438
|
+
|
|
439
|
+
if doc_kind == "task":
|
|
440
|
+
if not _extract_checkboxes(_extract_section_lines(text, "Definition of Done (DoD)")):
|
|
441
|
+
_insert_section(
|
|
442
|
+
lines,
|
|
443
|
+
"Definition of Done (DoD)",
|
|
444
|
+
[
|
|
445
|
+
"- [ ] Scope implemented and acceptance criteria covered.",
|
|
446
|
+
"- [ ] Validation commands executed and results captured.",
|
|
447
|
+
"- [ ] Linked request/backlog/task docs updated during completed waves and at closure.",
|
|
448
|
+
"- [ ] Each completed wave left a commit-ready checkpoint or an explicit exception is documented.",
|
|
449
|
+
"- [ ] Status is `Done` and progress is `100%`.",
|
|
450
|
+
],
|
|
451
|
+
)
|
|
452
|
+
modified = True
|
|
453
|
+
|
|
454
|
+
if not modified:
|
|
455
|
+
return False
|
|
456
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
457
|
+
return True
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _autofix_ac_traceability(path: Path, ac_ids: set[str]) -> bool:
|
|
461
|
+
if not ac_ids:
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
465
|
+
section_bounds = _extract_section_bounds(lines, "AC Traceability")
|
|
466
|
+
if section_bounds is None:
|
|
467
|
+
if lines and lines[-1].strip():
|
|
468
|
+
lines.append("")
|
|
469
|
+
lines.append("# AC Traceability")
|
|
470
|
+
section_bounds = _extract_section_bounds(lines, "AC Traceability")
|
|
471
|
+
if section_bounds is None:
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
modified = False
|
|
475
|
+
for ac_id in sorted(ac_ids):
|
|
476
|
+
section_bounds = _extract_section_bounds(lines, "AC Traceability")
|
|
477
|
+
if section_bounds is None:
|
|
478
|
+
break
|
|
479
|
+
start_idx, end_idx = section_bounds
|
|
480
|
+
body_start = start_idx + 1
|
|
481
|
+
handled = False
|
|
482
|
+
for idx in range(body_start, end_idx):
|
|
483
|
+
line = lines[idx]
|
|
484
|
+
if ac_id not in line.upper():
|
|
485
|
+
continue
|
|
486
|
+
if "proof:" in line.lower():
|
|
487
|
+
handled = True
|
|
488
|
+
break
|
|
489
|
+
lines[idx] = line.rstrip() + " Proof: TODO."
|
|
490
|
+
modified = True
|
|
491
|
+
handled = True
|
|
492
|
+
break
|
|
493
|
+
if handled:
|
|
494
|
+
continue
|
|
495
|
+
insert_at = end_idx
|
|
496
|
+
while insert_at > body_start and not lines[insert_at - 1].strip():
|
|
497
|
+
insert_at -= 1
|
|
498
|
+
lines.insert(insert_at, f"- {ac_id} -> TODO: map this acceptance criterion to scope. Proof: TODO.")
|
|
499
|
+
modified = True
|
|
500
|
+
|
|
501
|
+
if not modified:
|
|
502
|
+
return False
|
|
503
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _rel(repo_root: Path, path: Path | None) -> str:
|
|
508
|
+
if path is None:
|
|
509
|
+
return "(global)"
|
|
510
|
+
return path.relative_to(repo_root).as_posix()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _sorted_issues(issues: Iterable[AuditIssue], repo_root: Path) -> list[AuditIssue]:
|
|
514
|
+
unique: dict[tuple[str, str, str], AuditIssue] = {}
|
|
515
|
+
for issue in issues:
|
|
516
|
+
key = (_rel(repo_root, issue.path), issue.code, issue.message)
|
|
517
|
+
unique.setdefault(key, issue)
|
|
518
|
+
return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.code, issue.message))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _scan_hybrid_cache_for_credentials(repo_root: Path) -> list[AuditIssue]:
|
|
522
|
+
issues: list[AuditIssue] = []
|
|
523
|
+
for rel_path in HYBRID_CACHE_JSONL_FILES:
|
|
524
|
+
cache_path = repo_root / rel_path
|
|
525
|
+
if not cache_path.exists():
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
content = cache_path.read_text(encoding="utf-8")
|
|
529
|
+
except OSError as error:
|
|
530
|
+
issues.append(
|
|
531
|
+
AuditIssue(
|
|
532
|
+
code="hybrid_cache_unreadable",
|
|
533
|
+
path=cache_path,
|
|
534
|
+
message=f"could not read cache file: {error}",
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
continue
|
|
538
|
+
if "credential_value" in content:
|
|
539
|
+
issues.append(
|
|
540
|
+
AuditIssue(
|
|
541
|
+
code="hybrid_cache_contains_credential_value",
|
|
542
|
+
path=cache_path,
|
|
543
|
+
message="cache file contains credential_value and must not store secrets",
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
return issues
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def audit_payload(
|
|
550
|
+
repo_root: Path,
|
|
551
|
+
*,
|
|
552
|
+
stale_days: int = 45,
|
|
553
|
+
skip_ac_traceability: bool = False,
|
|
554
|
+
skip_gates: bool = False,
|
|
555
|
+
legacy_cutoff_version: str | None = None,
|
|
556
|
+
group_by_doc: bool = False,
|
|
557
|
+
autofix_ac_traceability: bool = False,
|
|
558
|
+
paths: list[str] | None = None,
|
|
559
|
+
refs: list[str] | None = None,
|
|
560
|
+
since_version: str | None = None,
|
|
561
|
+
token_hygiene: bool = False,
|
|
562
|
+
autofix_structure: bool = False,
|
|
563
|
+
governance_profile: str = "standard",
|
|
564
|
+
) -> dict[str, object]:
|
|
565
|
+
profile = GOVERNANCE_PROFILES[governance_profile]
|
|
566
|
+
if stale_days == 45:
|
|
567
|
+
stale_days = int(profile["stale_days"])
|
|
568
|
+
if not token_hygiene and profile["token_hygiene"]:
|
|
569
|
+
token_hygiene = True
|
|
570
|
+
if profile["require_gates"] is False:
|
|
571
|
+
skip_gates = True
|
|
572
|
+
if profile["require_ac_traceability"] is False:
|
|
573
|
+
skip_ac_traceability = True
|
|
574
|
+
|
|
575
|
+
cutoff = _parse_semver(legacy_cutoff_version)
|
|
576
|
+
if legacy_cutoff_version and cutoff is None:
|
|
577
|
+
raise SystemExit(f"Invalid --legacy-cutoff-version `{legacy_cutoff_version}`. Expected semantic version like 1.3.0.")
|
|
578
|
+
|
|
579
|
+
scope_since = _parse_semver(since_version)
|
|
580
|
+
if since_version and scope_since is None:
|
|
581
|
+
raise SystemExit(f"Invalid --since-version `{since_version}`. Expected semantic version like 1.3.0.")
|
|
582
|
+
|
|
583
|
+
all_docs = _collect_docs(repo_root)
|
|
584
|
+
docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
|
|
585
|
+
|
|
586
|
+
issues: list[AuditIssue] = []
|
|
587
|
+
autofix_targets: dict[Path, set[str]] = {}
|
|
588
|
+
autofix_modified: list[Path] = []
|
|
589
|
+
|
|
590
|
+
for doc in docs.values():
|
|
591
|
+
if doc.kind.kind != "task" or not _is_done(doc):
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
item_refs = _extract_refs(doc.text, DOC_KIND_OBJECTS["backlog"].prefix)
|
|
595
|
+
if not item_refs:
|
|
596
|
+
issues.append(AuditIssue(code="task_missing_backlog_ref", path=doc.path, message="done task has no linked backlog item reference"))
|
|
597
|
+
continue
|
|
598
|
+
|
|
599
|
+
for item_ref in sorted(item_refs):
|
|
600
|
+
item_doc = all_docs.get(item_ref)
|
|
601
|
+
if item_doc is None or item_doc.kind.kind != "backlog":
|
|
602
|
+
issues.append(AuditIssue(code="task_refs_missing_backlog", path=doc.path, message=f"references missing backlog item `{item_ref}`"))
|
|
603
|
+
continue
|
|
604
|
+
if not _is_done(item_doc):
|
|
605
|
+
issues.append(AuditIssue(code="task_links_open_backlog", path=doc.path, message=f"done task linked to backlog item not closed `{item_ref}`"))
|
|
606
|
+
for request_doc in _linked_requests_for_item(item_doc, all_docs):
|
|
607
|
+
request_items = _linked_items_for_request(request_doc, all_docs)
|
|
608
|
+
if request_items and all(_is_done(item) for item in request_items) and not _is_done(request_doc):
|
|
609
|
+
issues.append(
|
|
610
|
+
AuditIssue(
|
|
611
|
+
code="request_not_closed_after_backlog_done",
|
|
612
|
+
path=request_doc.path,
|
|
613
|
+
message="all backlog items are done but request is not closed",
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
for doc in docs.values():
|
|
618
|
+
if doc.kind.kind != "backlog":
|
|
619
|
+
continue
|
|
620
|
+
if not _extract_refs(doc.text, DOC_KIND_OBJECTS["request"].prefix):
|
|
621
|
+
issues.append(AuditIssue(code="backlog_orphan_no_request", path=doc.path, message="orphan backlog item (no linked request)"))
|
|
622
|
+
|
|
623
|
+
for doc in docs.values():
|
|
624
|
+
if doc.kind.kind not in {"backlog", "task"}:
|
|
625
|
+
continue
|
|
626
|
+
product_framing = _decision_framing_value(doc.text, "Product framing")
|
|
627
|
+
architecture_framing = _decision_framing_value(doc.text, "Architecture framing")
|
|
628
|
+
product_refs = _extract_refs(doc.text, "prod")
|
|
629
|
+
architecture_refs = _extract_refs(doc.text, "adr")
|
|
630
|
+
if product_framing == "Required" and not product_refs:
|
|
631
|
+
issues.append(
|
|
632
|
+
AuditIssue(
|
|
633
|
+
code="product_brief_required_missing_ref",
|
|
634
|
+
path=doc.path,
|
|
635
|
+
message="product framing is required but no linked product brief was found",
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
if architecture_framing == "Required" and not architecture_refs:
|
|
639
|
+
issues.append(
|
|
640
|
+
AuditIssue(
|
|
641
|
+
code="architecture_decision_required_missing_ref",
|
|
642
|
+
path=doc.path,
|
|
643
|
+
message="architecture framing is required but no linked ADR was found",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
for doc in docs.values():
|
|
648
|
+
if doc.kind.kind not in {"product", "architecture"}:
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
linked_refs: set[str] = set()
|
|
652
|
+
for prefix in ("req", "item", "task", "prod", "adr"):
|
|
653
|
+
linked_refs.update(_extract_refs(doc.text, prefix))
|
|
654
|
+
|
|
655
|
+
if not any(ref.startswith(("req_", "item_", "task_")) for ref in linked_refs):
|
|
656
|
+
issues.append(
|
|
657
|
+
AuditIssue(
|
|
658
|
+
code="companion_doc_missing_primary_link",
|
|
659
|
+
path=doc.path,
|
|
660
|
+
message="companion doc has no linked request, backlog item, or task reference",
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
if not _has_mermaid_block(doc.text):
|
|
664
|
+
issues.append(
|
|
665
|
+
AuditIssue(
|
|
666
|
+
code="companion_doc_missing_mermaid",
|
|
667
|
+
path=doc.path,
|
|
668
|
+
message="companion doc is missing its overview Mermaid diagram",
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
placeholders = COMPANION_PLACEHOLDERS.get(doc.kind.kind, ())
|
|
672
|
+
if any(snippet in doc.text for snippet in placeholders):
|
|
673
|
+
issues.append(
|
|
674
|
+
AuditIssue(
|
|
675
|
+
code="companion_doc_contains_placeholders",
|
|
676
|
+
path=doc.path,
|
|
677
|
+
message="companion doc still contains generator placeholder content",
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
for ref in sorted(linked_refs):
|
|
681
|
+
if ref == doc.ref:
|
|
682
|
+
continue
|
|
683
|
+
if ref not in all_docs:
|
|
684
|
+
issues.append(
|
|
685
|
+
AuditIssue(
|
|
686
|
+
code="companion_doc_refs_missing_target",
|
|
687
|
+
path=doc.path,
|
|
688
|
+
message=f"companion doc references missing target `{ref}`",
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
for doc in docs.values():
|
|
693
|
+
if doc.kind.kind != "request" or _is_done(doc) is False:
|
|
694
|
+
continue
|
|
695
|
+
request_items = _linked_items_for_request(doc, all_docs)
|
|
696
|
+
if not request_items:
|
|
697
|
+
issues.append(AuditIssue(code="request_done_without_backlog", path=doc.path, message="delivered request has no linked backlog items"))
|
|
698
|
+
continue
|
|
699
|
+
for item in request_items:
|
|
700
|
+
if not _is_done(item):
|
|
701
|
+
issues.append(
|
|
702
|
+
AuditIssue(
|
|
703
|
+
code="request_done_with_open_backlog",
|
|
704
|
+
path=doc.path,
|
|
705
|
+
message=f"delivered request linked to incomplete backlog item `{item.ref}`",
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
if stale_days > 0:
|
|
710
|
+
for doc in docs.values():
|
|
711
|
+
if doc.status not in STATUS_IN_PROGRESS:
|
|
712
|
+
continue
|
|
713
|
+
age_days = _last_modified_age_days(doc.path)
|
|
714
|
+
if age_days >= stale_days:
|
|
715
|
+
issues.append(
|
|
716
|
+
AuditIssue(
|
|
717
|
+
code="stale_pending_doc",
|
|
718
|
+
path=doc.path,
|
|
719
|
+
message=f"stale pending doc ({age_days:.1f} days, status={doc.status})",
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
if not skip_ac_traceability:
|
|
724
|
+
for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
|
|
725
|
+
if not _is_strict_scope(request, cutoff):
|
|
726
|
+
continue
|
|
727
|
+
ac_ids = _extract_request_ac_ids(request)
|
|
728
|
+
if not ac_ids:
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
linked_items = _linked_items_for_request(request, all_docs)
|
|
732
|
+
if not linked_items:
|
|
733
|
+
issues.append(AuditIssue(code="ac_no_linked_backlog", path=request.path, message="request has ACs but no linked backlog items"))
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
linked_tasks: list[DocMeta] = []
|
|
737
|
+
for item in linked_items:
|
|
738
|
+
linked_tasks.extend(_linked_tasks_for_item(item, all_docs))
|
|
739
|
+
|
|
740
|
+
if not linked_tasks:
|
|
741
|
+
issues.append(AuditIssue(code="ac_no_linked_tasks", path=request.path, message="request has ACs but no linked tasks"))
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
for ac_id in ac_ids:
|
|
745
|
+
item_has_mapping = any(_has_ac_with_proof(item.text, ac_id) for item in linked_items)
|
|
746
|
+
if not item_has_mapping:
|
|
747
|
+
if autofix_ac_traceability and linked_items:
|
|
748
|
+
autofix_targets.setdefault(linked_items[0].path, set()).add(ac_id)
|
|
749
|
+
else:
|
|
750
|
+
issues.append(AuditIssue(code="ac_missing_item_traceability", path=request.path, message=f"`{ac_id}` missing item-level traceability with proof"))
|
|
751
|
+
|
|
752
|
+
task_has_mapping = any(_has_ac_with_proof(task.text, ac_id) for task in linked_tasks)
|
|
753
|
+
if not task_has_mapping:
|
|
754
|
+
if autofix_ac_traceability and linked_tasks:
|
|
755
|
+
autofix_targets.setdefault(linked_tasks[0].path, set()).add(ac_id)
|
|
756
|
+
else:
|
|
757
|
+
issues.append(AuditIssue(code="ac_missing_task_traceability", path=request.path, message=f"`{ac_id}` missing task-level traceability with proof"))
|
|
758
|
+
|
|
759
|
+
if not skip_gates:
|
|
760
|
+
for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
|
|
761
|
+
if not _is_strict_scope(request, cutoff) or request.status not in {"ready", "in progress", "done"}:
|
|
762
|
+
continue
|
|
763
|
+
dor_checks = _extract_checkboxes(_extract_section_lines(request.text, "Definition of Ready (DoR)"))
|
|
764
|
+
if not dor_checks:
|
|
765
|
+
issues.append(AuditIssue(code="request_missing_dor", path=request.path, message="missing DoR checklist"))
|
|
766
|
+
elif any(not checked for checked, _label in dor_checks):
|
|
767
|
+
issues.append(AuditIssue(code="request_dor_unchecked", path=request.path, message="DoR checklist contains unchecked items"))
|
|
768
|
+
|
|
769
|
+
for task in [doc for doc in docs.values() if doc.kind.kind == "task"]:
|
|
770
|
+
if not _is_strict_scope(task, cutoff) or not _is_done(task):
|
|
771
|
+
continue
|
|
772
|
+
dod_checks = _extract_checkboxes(_extract_section_lines(task.text, "Definition of Done (DoD)"))
|
|
773
|
+
if not dod_checks:
|
|
774
|
+
issues.append(AuditIssue(code="task_missing_dod", path=task.path, message="missing DoD checklist"))
|
|
775
|
+
elif any(not checked for checked, _label in dod_checks):
|
|
776
|
+
issues.append(AuditIssue(code="task_dod_unchecked", path=task.path, message="DoD checklist contains unchecked items"))
|
|
777
|
+
|
|
778
|
+
if token_hygiene:
|
|
779
|
+
for doc in docs.values():
|
|
780
|
+
if doc.kind.kind not in {"request", "backlog", "task"}:
|
|
781
|
+
continue
|
|
782
|
+
ai_fields = _extract_ai_context_fields(doc.text)
|
|
783
|
+
if not ai_fields:
|
|
784
|
+
issues.append(
|
|
785
|
+
AuditIssue(
|
|
786
|
+
code="token_hygiene_missing_ai_context",
|
|
787
|
+
path=doc.path,
|
|
788
|
+
message="missing `# AI Context` section for compact handoff metadata",
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
else:
|
|
792
|
+
summary = ai_fields.get("summary", "")
|
|
793
|
+
if not summary or any(snippet.lower() in summary.lower() for snippet in TOKEN_HYGIENE_PLACEHOLDERS):
|
|
794
|
+
issues.append(AuditIssue(code="token_hygiene_ai_summary_weak", path=doc.path, message="AI summary is missing or still contains placeholder text"))
|
|
795
|
+
keywords = ai_fields.get("keywords", "")
|
|
796
|
+
keyword_count = len([part for part in re.split(r"[,;]", keywords) if part.strip()])
|
|
797
|
+
if keyword_count > 10:
|
|
798
|
+
issues.append(AuditIssue(code="token_hygiene_ai_keywords_too_many", path=doc.path, message=f"AI keywords should stay compact (found {keyword_count}, limit 10)"))
|
|
799
|
+
use_when = ai_fields.get("use when", "")
|
|
800
|
+
skip_when = ai_fields.get("skip when", "")
|
|
801
|
+
if not use_when or not skip_when:
|
|
802
|
+
issues.append(AuditIssue(code="token_hygiene_ai_usage_incomplete", path=doc.path, message="AI Context must define both `Use when` and `Skip when` guidance"))
|
|
803
|
+
|
|
804
|
+
section_limits = TOKEN_HYGIENE_SECTION_LIMITS.get(doc.kind.kind, {})
|
|
805
|
+
for heading, max_lines in section_limits.items():
|
|
806
|
+
line_count = _section_content_line_count(doc.text, heading)
|
|
807
|
+
if line_count > max_lines:
|
|
808
|
+
issues.append(AuditIssue(code="token_hygiene_section_too_long", path=doc.path, message=f"`# {heading}` is too verbose for lean handoffs ({line_count} lines, limit {max_lines})"))
|
|
809
|
+
|
|
810
|
+
if autofix_ac_traceability and autofix_targets:
|
|
811
|
+
for path, ac_ids in sorted(autofix_targets.items(), key=lambda pair: pair[0].as_posix()):
|
|
812
|
+
if _autofix_ac_traceability(path, ac_ids):
|
|
813
|
+
autofix_modified.append(path)
|
|
814
|
+
|
|
815
|
+
if autofix_modified:
|
|
816
|
+
all_docs = _collect_docs(repo_root)
|
|
817
|
+
docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
|
|
818
|
+
issues = [issue for issue in issues if issue.code not in {"ac_missing_item_traceability", "ac_missing_task_traceability"}]
|
|
819
|
+
|
|
820
|
+
for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
|
|
821
|
+
if skip_ac_traceability or not _is_strict_scope(request, cutoff):
|
|
822
|
+
continue
|
|
823
|
+
ac_ids = _extract_request_ac_ids(request)
|
|
824
|
+
if not ac_ids:
|
|
825
|
+
continue
|
|
826
|
+
linked_items = _linked_items_for_request(request, all_docs)
|
|
827
|
+
linked_tasks: list[DocMeta] = []
|
|
828
|
+
for item in linked_items:
|
|
829
|
+
linked_tasks.extend(_linked_tasks_for_item(item, all_docs))
|
|
830
|
+
for ac_id in ac_ids:
|
|
831
|
+
if linked_items and not any(_has_ac_with_proof(item.text, ac_id) for item in linked_items):
|
|
832
|
+
issues.append(AuditIssue(code="ac_missing_item_traceability", path=request.path, message=f"`{ac_id}` missing item-level traceability with proof"))
|
|
833
|
+
if linked_tasks and not any(_has_ac_with_proof(task.text, ac_id) for task in linked_tasks):
|
|
834
|
+
issues.append(AuditIssue(code="ac_missing_task_traceability", path=request.path, message=f"`{ac_id}` missing task-level traceability with proof"))
|
|
835
|
+
|
|
836
|
+
if autofix_structure:
|
|
837
|
+
for doc in docs.values():
|
|
838
|
+
if doc.kind.kind not in {"request", "backlog", "task"}:
|
|
839
|
+
continue
|
|
840
|
+
if _autofix_structure(doc.path, doc.kind.kind):
|
|
841
|
+
autofix_modified.append(doc.path)
|
|
842
|
+
|
|
843
|
+
if autofix_modified:
|
|
844
|
+
all_docs = _collect_docs(repo_root)
|
|
845
|
+
docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
|
|
846
|
+
issues = []
|
|
847
|
+
|
|
848
|
+
issues.extend(_scan_hybrid_cache_for_credentials(repo_root))
|
|
849
|
+
sorted_issues = _sorted_issues(issues, repo_root)
|
|
850
|
+
|
|
851
|
+
by_code: dict[str, int] = {}
|
|
852
|
+
by_path: dict[str, int] = {}
|
|
853
|
+
serialized: list[dict[str, str]] = []
|
|
854
|
+
for issue in sorted_issues:
|
|
855
|
+
rel_path = _rel(repo_root, issue.path)
|
|
856
|
+
by_code[issue.code] = by_code.get(issue.code, 0) + 1
|
|
857
|
+
by_path[rel_path] = by_path.get(rel_path, 0) + 1
|
|
858
|
+
serialized.append({"code": issue.code, "path": rel_path, "message": issue.message})
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
"ok": not sorted_issues,
|
|
862
|
+
"issue_count": len(sorted_issues),
|
|
863
|
+
"issues": serialized,
|
|
864
|
+
"counts": {
|
|
865
|
+
"by_code": dict(sorted(by_code.items())),
|
|
866
|
+
"by_path": dict(sorted(by_path.items())),
|
|
867
|
+
},
|
|
868
|
+
"autofix": {
|
|
869
|
+
"enabled": autofix_ac_traceability or autofix_structure,
|
|
870
|
+
"modified_files": [_rel(repo_root, path) for path in sorted(set(autofix_modified))],
|
|
871
|
+
},
|
|
872
|
+
"workflow_doc_count": sum(1 for directory in ("logics/request", "logics/backlog", "logics/tasks") for _ in (repo_root / directory).glob("*.md") if (repo_root / directory).is_dir()),
|
|
873
|
+
"group_by_doc": group_by_doc,
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def render_audit(
|
|
878
|
+
repo_root: Path,
|
|
879
|
+
*,
|
|
880
|
+
stale_days: int = 45,
|
|
881
|
+
skip_ac_traceability: bool = False,
|
|
882
|
+
skip_gates: bool = False,
|
|
883
|
+
legacy_cutoff_version: str | None = None,
|
|
884
|
+
output_format: str = "text",
|
|
885
|
+
group_by_doc: bool = False,
|
|
886
|
+
autofix_ac_traceability: bool = False,
|
|
887
|
+
paths: list[str] | None = None,
|
|
888
|
+
refs: list[str] | None = None,
|
|
889
|
+
since_version: str | None = None,
|
|
890
|
+
token_hygiene: bool = False,
|
|
891
|
+
autofix_structure: bool = False,
|
|
892
|
+
governance_profile: str = "standard",
|
|
893
|
+
) -> str:
|
|
894
|
+
payload = audit_payload(
|
|
895
|
+
repo_root,
|
|
896
|
+
stale_days=stale_days,
|
|
897
|
+
skip_ac_traceability=skip_ac_traceability,
|
|
898
|
+
skip_gates=skip_gates,
|
|
899
|
+
legacy_cutoff_version=legacy_cutoff_version,
|
|
900
|
+
group_by_doc=group_by_doc,
|
|
901
|
+
autofix_ac_traceability=autofix_ac_traceability,
|
|
902
|
+
paths=paths,
|
|
903
|
+
refs=refs,
|
|
904
|
+
since_version=since_version,
|
|
905
|
+
token_hygiene=token_hygiene,
|
|
906
|
+
autofix_structure=autofix_structure,
|
|
907
|
+
governance_profile=governance_profile,
|
|
908
|
+
)
|
|
909
|
+
if output_format == "json":
|
|
910
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
911
|
+
|
|
912
|
+
lines = ["Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED", f"Workflow docs inspected: {payload['workflow_doc_count']}"]
|
|
913
|
+
issues = payload["issues"]
|
|
914
|
+
if not issues:
|
|
915
|
+
return "\n".join(lines)
|
|
916
|
+
if not group_by_doc:
|
|
917
|
+
for issue in issues:
|
|
918
|
+
if issue["path"] == "(global)":
|
|
919
|
+
lines.append(f"- [{issue['code']}] {issue['message']}")
|
|
920
|
+
else:
|
|
921
|
+
lines.append(f"- {issue['path']}: [{issue['code']}] {issue['message']}")
|
|
922
|
+
return "\n".join(lines)
|
|
923
|
+
|
|
924
|
+
grouped: dict[str, list[dict[str, str]]] = {}
|
|
925
|
+
for issue in issues:
|
|
926
|
+
grouped.setdefault(issue["path"], []).append(issue)
|
|
927
|
+
for rel_path in sorted(grouped):
|
|
928
|
+
lines.append(f"- {rel_path}")
|
|
929
|
+
for issue in sorted(grouped[rel_path], key=lambda item: (item["code"], item["message"])):
|
|
930
|
+
lines.append(f" - [{issue['code']}] {issue['message']}")
|
|
931
|
+
return "\n".join(lines)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
935
|
+
parser = argparse.ArgumentParser(
|
|
936
|
+
prog="logics-manager audit",
|
|
937
|
+
description="Audit request/backlog/task workflow consistency and traceability.",
|
|
938
|
+
)
|
|
939
|
+
parser.add_argument("--stale-days", type=int, default=45, help="Threshold for stale pending docs.")
|
|
940
|
+
parser.add_argument("--skip-ac-traceability", action="store_true", help="Skip AC mapping/proof checks between request/backlog/task.")
|
|
941
|
+
parser.add_argument("--skip-gates", action="store_true", help="Skip DoR/DoD gate checks.")
|
|
942
|
+
parser.add_argument("--legacy-cutoff-version", help="Only enforce AC traceability and DoR/DoD gates for docs with `From version` >= this semantic version (example: 1.3.0).")
|
|
943
|
+
parser.add_argument("--format", choices=("text", "json"), default="text", help="Output format for audit results.")
|
|
944
|
+
parser.add_argument("--group-by-doc", action="store_true", help="Group text output by document path.")
|
|
945
|
+
parser.add_argument("--autofix-ac-traceability", action="store_true", help="Auto-add missing AC traceability skeleton entries in linked backlog/tasks docs.")
|
|
946
|
+
parser.add_argument("--paths", nargs="*", default=[], help="Limit the audit to docs under these relative paths.")
|
|
947
|
+
parser.add_argument("--refs", nargs="*", default=[], help="Limit the audit to these refs and their directly linked workflow neighborhood.")
|
|
948
|
+
parser.add_argument("--since-version", help="Limit the audit to docs with `From version` >= this semantic version.")
|
|
949
|
+
parser.add_argument("--token-hygiene", action="store_true", help="Enable compact AI context and verbosity checks for workflow docs.")
|
|
950
|
+
parser.add_argument("--autofix-structure", action="store_true", help="Deterministically repair missing schema metadata, AI Context, and missing gate sections.")
|
|
951
|
+
parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile when resolving default audit strictness.")
|
|
952
|
+
return parser
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def main(argv: list[str]) -> int:
|
|
956
|
+
args = build_parser().parse_args(argv)
|
|
957
|
+
repo_root = _find_repo_root_from(Path.cwd())
|
|
958
|
+
payload = audit_payload(
|
|
959
|
+
repo_root,
|
|
960
|
+
stale_days=args.stale_days,
|
|
961
|
+
skip_ac_traceability=args.skip_ac_traceability,
|
|
962
|
+
skip_gates=args.skip_gates,
|
|
963
|
+
legacy_cutoff_version=args.legacy_cutoff_version,
|
|
964
|
+
group_by_doc=args.group_by_doc,
|
|
965
|
+
autofix_ac_traceability=args.autofix_ac_traceability,
|
|
966
|
+
paths=args.paths,
|
|
967
|
+
refs=args.refs,
|
|
968
|
+
since_version=args.since_version,
|
|
969
|
+
token_hygiene=args.token_hygiene,
|
|
970
|
+
autofix_structure=args.autofix_structure,
|
|
971
|
+
governance_profile=args.governance_profile,
|
|
972
|
+
)
|
|
973
|
+
output = json.dumps(payload, indent=2, sort_keys=True) if args.format == "json" else render_audit(
|
|
974
|
+
repo_root,
|
|
975
|
+
stale_days=args.stale_days,
|
|
976
|
+
skip_ac_traceability=args.skip_ac_traceability,
|
|
977
|
+
skip_gates=args.skip_gates,
|
|
978
|
+
legacy_cutoff_version=args.legacy_cutoff_version,
|
|
979
|
+
output_format=args.format,
|
|
980
|
+
group_by_doc=args.group_by_doc,
|
|
981
|
+
autofix_ac_traceability=args.autofix_ac_traceability,
|
|
982
|
+
paths=args.paths,
|
|
983
|
+
refs=args.refs,
|
|
984
|
+
since_version=args.since_version,
|
|
985
|
+
token_hygiene=args.token_hygiene,
|
|
986
|
+
autofix_structure=args.autofix_structure,
|
|
987
|
+
governance_profile=args.governance_profile,
|
|
988
|
+
)
|
|
989
|
+
print(output)
|
|
990
|
+
return 0 if payload["ok"] else 1
|