@antoneeo/agentic-sdlc-skill 1.6.0 → 1.8.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.
@@ -1,621 +1,817 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """Validatore meccanico per la skill Agentic SDLC.
4
-
5
- Comandi:
6
- check gate unico di chiusura: validate + stale in un solo comando (exit 1 se uno dei due fallisce)
7
- validate verifica la coerenza strutturale di ai_docs/ (exit 1 se errori)
8
- index rigenera gli indici generati: strategic/features_history.md (dai frontmatter
9
- delle ANALYSIS_*.md) e ai_docs/INDEX.md (manifest dei documenti canonici)
10
- stale elenca le aree modificate dopo l'ultima analisi registrata in audit_plan.md (exit 1 se presenti)
11
- mark registra percorsi come ANALYZED con riferimento corrente (hash git, altrimenti timestamp UTC)
12
- gate hook PreToolUse: blocca scritture su percorsi protetti senza ANALYSIS IN_PROGRESS (exit 2)
13
-
14
- Solo libreria standard (Python >= 3.8). Compatibile Windows e POSIX.
15
- """
16
- import argparse
17
- import json
18
- import os
19
- import re
20
- import subprocess
21
- import sys
22
- from datetime import datetime, timedelta, timezone
23
- from pathlib import Path
24
-
25
- VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
26
- VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
27
- VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
28
- SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
29
- "dist", "build", ".idea", ".vs", "ai_docs"}
30
- INDEX_HEADER = ("<!-- GENERATO da sdlc_check.py index - non modificare a mano. "
31
- "Fonte di verita': frontmatter dei file ANALYSIS_*.md -->")
32
- MANIFEST_HEADER = ("<!-- GENERATO da sdlc_check.py index - non modificare a mano. "
33
- "Fonte di verita': gli header dei documenti canonici in ai_docs/. -->")
34
- # Directory i cui .md sono documenti canonici durevoli: vengono manifestati in INDEX.md.
35
- # audit/ e solutions/ restano discovery-by-grep (sessione / process artifact), non manifestati.
36
- MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
37
- # Stati riconosciuti: doc canonici (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
38
- # ADR (Accepted/Proposed/Rejected). Unione, per non dare falsi avvisi su convenzioni gia' in uso.
39
- CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
40
- "APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
41
- GENERATED_DOCS = {"features_history.md", "INDEX.md"} # generati: mai entrate del manifest
42
- MTIME_GRACE = timedelta(seconds=2)
43
-
44
- try:
45
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
46
- sys.stderr.reconfigure(encoding="utf-8", errors="replace")
47
- except Exception:
48
- pass
49
-
50
-
51
- # ----------------------------------------------------------------- utilità
52
-
53
- def utc_now_iso():
54
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
-
56
-
57
- def find_project_root(start=None):
58
- cur = Path(start or os.getcwd()).resolve()
59
- for p in [cur] + list(cur.parents):
60
- if (p / "ai_docs").is_dir():
61
- return p
62
- return cur
63
-
64
-
65
- def read_text(path):
66
- # utf-8-sig: scarta un eventuale BOM iniziale (file autorati su Windows) cosi'
67
- # il frontmatter '---' a riga 0 resta riconoscibile; legge utf-8 normale altrimenti.
68
- return path.read_text(encoding="utf-8-sig", errors="replace")
69
-
70
-
71
- def parse_iso(value):
72
- if not value:
73
- return None
74
- try:
75
- dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
76
- if dt.tzinfo is None:
77
- dt = dt.replace(tzinfo=timezone.utc)
78
- return dt
79
- except ValueError:
80
- return None
81
-
82
-
83
- def norm_text(s):
84
- return "\n".join(line.rstrip() for line in s.strip().splitlines())
85
-
86
-
87
- def load_frontmatter(lines):
88
- meta = {}
89
- if not lines or lines[0].strip() != "---":
90
- return meta
91
- for line in lines[1:60]:
92
- if line.strip() == "---":
93
- break
94
- m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
95
- if m:
96
- meta[m.group(1).strip().lower()] = m.group(2).strip()
97
- return meta
98
-
99
-
100
- def list_analyses(root):
101
- """Ritorna [(path, frontmatter, testo)] per le ANALYSIS_*.md (shadow escluse)."""
102
- sol = root / "ai_docs" / "solutions"
103
- out = []
104
- if not sol.is_dir():
105
- return out
106
- for p in sorted(sol.glob("ANALYSIS_*.md")):
107
- text = read_text(p)
108
- if "SHADOW" in text[:200]:
109
- continue
110
- out.append((p, load_frontmatter(text.splitlines()), text))
111
- return out
112
-
113
-
114
- def iter_files(target):
115
- if target.is_file():
116
- yield target
117
- return
118
- for dirpath, dirnames, filenames in os.walk(target):
119
- dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
120
- for name in filenames:
121
- yield Path(dirpath) / name
122
-
123
-
124
- # ---------------------------------------------------------------------- git
125
-
126
- def git_available(root):
127
- try:
128
- r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
129
- cwd=str(root), capture_output=True, text=True, timeout=10)
130
- return r.returncode == 0 and r.stdout.strip() == "true"
131
- except Exception:
132
- return False
133
-
134
-
135
- def git_head(root):
136
- try:
137
- r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
138
- cwd=str(root), capture_output=True, text=True, timeout=10)
139
- return r.stdout.strip() if r.returncode == 0 else ""
140
- except Exception:
141
- return ""
142
-
143
-
144
- def git_has_changes(root, rel_path):
145
- """True se ci sono modifiche tracked/untracked sotto rel_path."""
146
- try:
147
- rel = rel_path.replace("\\", "/")
148
- r = subprocess.run(["git", "status", "--porcelain", "--", rel],
149
- cwd=str(root), capture_output=True, text=True, timeout=30)
150
- return r.returncode == 0 and bool(r.stdout.strip())
151
- except Exception:
152
- return False
153
-
154
-
155
- def git_changed_since(root, ref, rel_path):
156
- """File cambiati (tracked + untracked) sotto rel_path rispetto a ref. None se ref non risolvibile."""
157
- try:
158
- r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
159
- cwd=str(root), capture_output=True, text=True, timeout=30)
160
- if r.returncode != 0:
161
- return None
162
- changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
163
- r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
164
- cwd=str(root), capture_output=True, text=True, timeout=30)
165
- if r2.returncode == 0:
166
- changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
167
- return sorted(set(changed))
168
- except Exception:
169
- return None
170
-
171
-
172
- # -------------------------------------------------------------------- index
173
-
174
- def build_index(root):
175
- rows = []
176
- for p, meta, _ in list_analyses(root):
177
- rows.append((
178
- meta.get("id", "?"),
179
- meta.get("feature", p.stem.replace("ANALYSIS_", "")),
180
- meta.get("livello", ""),
181
- meta.get("stato", "?"),
182
- meta.get("data_inizio", ""),
183
- meta.get("data_fine", ""),
184
- "solutions/" + p.name,
185
- ))
186
- rows.sort(key=lambda r: r[0])
187
- lines = [INDEX_HEADER,
188
- "# Storico Funzionalita' (generato)",
189
- "",
190
- "| ID | Feature | Livello | Stato | Inizio | Fine | Doc |",
191
- "|---|---|---|---|---|---|---|"]
192
- for r in rows:
193
- lines.append("| " + " | ".join(r) + " |")
194
- return "\n".join(lines) + "\n"
195
-
196
-
197
- # riga "Status:"/"Stato:" nel corpo (con o senza ** **), prefisso che precede la descrizione
198
- _STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
199
- # righe puramente di metadati da saltare quando si sceglie la descrizione di fallback
200
- _META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|created|creato|updated|aggiornato)\b", re.I)
201
-
202
-
203
- def extract_doc_meta(path):
204
- """(title, description, status, supersedes) di un doc canonico.
205
-
206
- Riconosce DUE convenzioni di header: il frontmatter YAML-lite
207
- (description/status/supersedes/title) e la riga in corpo `**Status:** X`
208
- (usata da ADR e doc legacy). In fallback deduce il titolo dal primo '# H1'
209
- e la descrizione dalla prima riga di prosa, saltando le righe di metadati.
210
- """
211
- text = read_text(path)
212
- lines = text.splitlines()
213
- meta = load_frontmatter(lines)
214
- body = lines
215
- if lines and lines[0].strip() == "---":
216
- for i in range(1, min(len(lines), 60)):
217
- if lines[i].strip() == "---":
218
- body = lines[i + 1:]
219
- break
220
-
221
- title = meta.get("title", "")
222
- if not title:
223
- for line in body:
224
- m = re.match(r"^#\s+(.*)$", line)
225
- if m:
226
- title = m.group(1).strip()
227
- break
228
- title = title or path.stem
229
-
230
- status = meta.get("status", "").upper()
231
- if not status:
232
- for line in body[:25]:
233
- m = _STATUS_LINE.match(line.strip())
234
- if m:
235
- status = m.group(1).upper()
236
- break
237
-
238
- desc = meta.get("description", "")
239
- if not desc:
240
- for line in body:
241
- s = line.strip()
242
- if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
243
- continue
244
- if s.startswith(">"):
245
- s = s.lstrip(">").strip()
246
- m = _STATUS_LINE.match(s)
247
- if m:
248
- # "Status: X — descrizione": tieni la parte dopo lo status; se vuota, salta
249
- rest = s[m.end():].strip(" *—–-:.")
250
- if not rest:
251
- continue
252
- s = rest
253
- if s:
254
- desc = s
255
- break
256
- desc = re.sub(r"\s+", " ", desc).strip()
257
- if len(desc) > 160:
258
- desc = desc[:157].rstrip() + "..."
259
- return title, desc, status, meta.get("supersedes", "").strip()
260
-
261
-
262
- def list_canonical_docs(root):
263
- """[(rel_to_ai_docs, path, (title, desc, status, supersedes))] per i doc canonici."""
264
- ai = root / "ai_docs"
265
- out = []
266
- for d in MANIFEST_DIRS:
267
- base = ai / d
268
- if not base.is_dir():
269
- continue
270
- for p in sorted(base.rglob("*.md")):
271
- if p.name in GENERATED_DOCS or p.name == "README.md":
272
- continue
273
- out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
274
- return out
275
-
276
-
277
- def build_manifest(root):
278
- docs = list_canonical_docs(root)
279
- lines = [MANIFEST_HEADER,
280
- "# Indice documenti `ai_docs/` (generato)",
281
- "",
282
- "Manifest completo dei documenti canonici. Per la priorita' di lettura (must-read)",
283
- "vedi il `README.md` curato a mano. Lo storico delle ANALYSIS e' in",
284
- "`strategic/features_history.md`. `audit/` e `solutions/` sono discovery-by-grep,",
285
- "non manifestate qui."]
286
- by_dir = {}
287
- for rel, _, meta in docs:
288
- by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
289
- for top in MANIFEST_DIRS:
290
- rows = by_dir.get(top)
291
- if not rows:
292
- continue
293
- lines += ["", f"## {top}/", "",
294
- "| Documento | Stato | Descrizione |", "|---|---|---|"]
295
- for rel, (title, desc, status, _sup) in rows:
296
- d = (desc or title).replace("|", "\\|")
297
- lines.append(f"| `{rel}` | {status or '-'} | {d} |")
298
- return "\n".join(lines).rstrip() + "\n"
299
-
300
-
301
- def cmd_index(root):
302
- hist = root / "ai_docs" / "strategic" / "features_history.md"
303
- hist.parent.mkdir(parents=True, exist_ok=True)
304
- hist.write_text(build_index(root), encoding="utf-8")
305
- print(f"[ok] indice ANALYSIS rigenerato: {hist}")
306
- # INDEX.md solo se esistono doc canonici: niente manifest vuoto su progetti minimali
307
- if list_canonical_docs(root):
308
- manifest = root / "ai_docs" / "INDEX.md"
309
- manifest.write_text(build_manifest(root), encoding="utf-8")
310
- print(f"[ok] manifest documenti rigenerato: {manifest}")
311
- else:
312
- print("[info] nessun documento canonico: INDEX.md non generato")
313
- return 0
314
-
315
-
316
- # ----------------------------------------------------------------- validate
317
-
318
- def cmd_validate(root):
319
- errors, warnings = [], []
320
- ai = root / "ai_docs"
321
- if not ai.is_dir():
322
- print(f"[info] {ai} non esiste: nulla da validare (progetto senza documentazione SDLC).")
323
- return 0
324
-
325
- # Vision: presenza e dichiarazione di stato
326
- for name in VISION_FILES:
327
- f = ai / "vision" / name
328
- if not f.is_file():
329
- warnings.append(f"vision/{name} mancante")
330
- continue
331
- head = "\n".join(read_text(f).splitlines()[:12])
332
- m = re.search(r"Stato:\s*(DRAFT|APPROVED)", head)
333
- if not m:
334
- errors.append(f"vision/{name}: manca 'Stato: DRAFT|APPROVED' nelle prime righe")
335
- elif m.group(1) == "DRAFT":
336
- warnings.append(f"vision/{name} in stato DRAFT: non e' autorita' di gating, da far validare all'utente")
337
-
338
- # ANALYSIS: frontmatter e sezioni obbligatorie
339
- seen_ids = {}
340
- analyses = list_analyses(root)
341
- for p, meta, text in analyses:
342
- rel = "solutions/" + p.name
343
- if not meta:
344
- errors.append(f"{rel}: frontmatter assente")
345
- continue
346
- fid = meta.get("id")
347
- if not fid:
348
- errors.append(f"{rel}: campo 'id' mancante")
349
- elif fid in seen_ids:
350
- errors.append(f"{rel}: id '{fid}' duplicato (gia' usato in {seen_ids[fid]})")
351
- else:
352
- seen_ids[fid] = rel
353
- stato = meta.get("stato", "")
354
- if stato not in VALID_STATES:
355
- errors.append(f"{rel}: stato '{stato}' non valido ({'/'.join(sorted(VALID_STATES))})")
356
- if not meta.get("data_inizio"):
357
- errors.append(f"{rel}: 'data_inizio' mancante")
358
- if stato == "COMPLETED" and not meta.get("data_fine"):
359
- errors.append(f"{rel}: COMPLETED senza 'data_fine'")
360
- livello = meta.get("livello")
361
- if livello and livello.upper() not in VALID_LEVELS:
362
- warnings.append(f"{rel}: livello '{livello}' non riconosciuto ({'/'.join(sorted(VALID_LEVELS))})")
363
- if "## Sicurezza" not in text:
364
- errors.append(f"{rel}: sezione '## Sicurezza e Threat Model' mancante (obbligatoria)")
365
- for sec in ("## Obiettivo", "## Vision della Feature", "## Impatto", "## Piano d'Azione", "## Strategia di Test", "## Diario"):
366
- if sec not in text:
367
- warnings.append(f"{rel}: sezione '{sec}' mancante")
368
-
369
- # Indice generato allineato
370
- hist = ai / "strategic" / "features_history.md"
371
- if analyses:
372
- if not hist.is_file():
373
- errors.append("strategic/features_history.md mancante: esegui 'sdlc_check.py index'")
374
- elif norm_text(read_text(hist)) != norm_text(build_index(root)):
375
- errors.append("strategic/features_history.md non allineato alle ANALYSIS: esegui 'sdlc_check.py index'")
376
-
377
- # Manifest dei documenti canonici allineato (Poka-Yoke: file non indicizzato = chiusura sporca)
378
- docs = list_canonical_docs(root)
379
- manifest = ai / "INDEX.md"
380
- if docs:
381
- if not manifest.is_file():
382
- errors.append("ai_docs/INDEX.md mancante: esegui 'sdlc_check.py index'")
383
- elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
384
- errors.append("ai_docs/INDEX.md non allineato ai documenti canonici: esegui 'sdlc_check.py index'")
385
-
386
- # Lifecycle dei documenti canonici: status dichiarato + coerenza supersedes
387
- canon_status = {rel: meta[2] for rel, _, meta in docs}
388
- for rel, _, (title, desc, status, supersedes) in docs:
389
- if not status:
390
- warnings.append(f"{rel}: manca 'status:' nell'header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
391
- elif status not in CANONICAL_STATES:
392
- warnings.append(f"{rel}: status '{status}' non riconosciuto ({'/'.join(sorted(CANONICAL_STATES))})")
393
- if supersedes:
394
- base = os.path.basename(supersedes)
395
- for other, ost in canon_status.items():
396
- if (other == supersedes or other.endswith("/" + supersedes)
397
- or os.path.basename(other) == base) and ost == "CURRENT":
398
- warnings.append(f"{other}: ancora CURRENT ma superseduto da {rel} (impostare status: SUPERSEDED)")
399
-
400
- # Handoff: intestazione e freschezza
401
- hand = ai / "audit" / "handoff.md"
402
- if hand.is_file():
403
- m = re.search(r"Data:\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
404
- if not m:
405
- warnings.append("audit/handoff.md senza intestazione 'Data: YYYY-MM-DD'")
406
- else:
407
- try:
408
- stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
409
- age = (datetime.now(timezone.utc) - stamp).days
410
- if age > 14:
411
- warnings.append(f"audit/handoff.md ha {age} giorni: trattarlo come storico, non come stato corrente")
412
- except ValueError:
413
- warnings.append("audit/handoff.md: data non interpretabile")
414
-
415
- for w in warnings:
416
- print(f"[warn] {w}")
417
- for e in errors:
418
- print(f"[ERROR] {e}")
419
- print(f"\nValidazione: {len(errors)} errori, {len(warnings)} avvisi.")
420
- return 1 if errors else 0
421
-
422
-
423
- # ------------------------------------------------------------- audit_plan
424
-
425
- def parse_audit_plan(root):
426
- f = root / "ai_docs" / "audit" / "audit_plan.md"
427
- rows, lines = [], []
428
- if f.is_file():
429
- lines = read_text(f).splitlines()
430
- for i, line in enumerate(lines):
431
- if not line.strip().startswith("|"):
432
- continue
433
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
434
- if len(cells) < 2:
435
- continue
436
- if cells[0].lower() == "percorso" or set(cells[0]) <= set("-: "):
437
- continue
438
- rows.append({
439
- "line": i,
440
- "path": cells[0],
441
- "stato": cells[1].upper(),
442
- "ref": cells[2] if len(cells) > 2 else "",
443
- "note": cells[3] if len(cells) > 3 else "",
444
- })
445
- return f, lines, rows
446
-
447
-
448
- def cmd_stale(root):
449
- f, _, rows = parse_audit_plan(root)
450
- if not rows:
451
- print(f"[info] nessuna riga in {f}: niente da controllare "
452
- "(audit non inizializzato, oppure modalita' Hybrid dove la mappatura e' delegata a devPNT).")
453
- return 0
454
- use_git = git_available(root)
455
- stale = []
456
- for row in rows:
457
- if row["stato"] != "ANALYZED":
458
- continue
459
- rel, ref = row["path"], row["ref"]
460
- target = root / rel
461
- if not target.exists():
462
- print(f"[warn] {rel}: percorso inesistente")
463
- continue
464
- changed = []
465
- if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
466
- res = git_changed_since(root, ref, rel.replace("\\", "/"))
467
- if res is None:
468
- print(f"[warn] {rel}: ref git '{ref}' non risolvibile, impossibile valutare")
469
- continue
470
- changed = res
471
- else:
472
- ts = parse_iso(ref)
473
- if ts is None:
474
- print(f"[warn] {rel}: riferimento '{ref}' non interpretabile (ne' hash git ne' ISO UTC)")
475
- continue
476
- for fp in iter_files(target):
477
- mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
478
- if mtime > ts + MTIME_GRACE:
479
- changed.append(str(fp.relative_to(root)).replace("\\", "/"))
480
- if changed:
481
- stale.append((rel, changed))
482
-
483
- if not stale:
484
- print("[ok] nessuna area analizzata risulta modificata dopo l'ultima analisi.")
485
- return 0
486
- print("Aree modificate dopo l'ultima analisi registrata:")
487
- for rel, changed in stale:
488
- print(f" {rel} ({len(changed)} file)")
489
- for c in changed[:10]:
490
- print(f" - {c}")
491
- if len(changed) > 10:
492
- print(f" ... e altri {len(changed) - 10}")
493
- print("\nDopo la ri-analisi, registra con: sdlc_check.py mark <percorso>")
494
- return 1
495
-
496
-
497
- def cmd_mark(root, paths):
498
- f, lines, rows = parse_audit_plan(root)
499
- use_git_ref = git_available(root) and not any(
500
- git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
501
- )
502
- ref = git_head(root) if use_git_ref else utc_now_iso()
503
- by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
504
-
505
- if not lines:
506
- lines = ["# Piano di Audit", "",
507
- "| Percorso | Stato | Riferimento | Note |",
508
- "|---|---|---|---|"]
509
- rows = []
510
-
511
- def row_text(path, note):
512
- return f"| {path} | ANALYZED | {ref} | {note} |"
513
-
514
- appended = []
515
- for raw in paths:
516
- key = raw.replace("\\", "/").rstrip("/")
517
- display = key + ("/" if (root / key).is_dir() else "")
518
- existing = by_path.get(key)
519
- if existing:
520
- lines[existing["line"]] = row_text(existing["path"], existing["note"])
521
- print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
522
- else:
523
- appended.append(row_text(display, ""))
524
- print(f"[ok] {display} aggiunto come ANALYZED ({ref})")
525
-
526
- if appended:
527
- insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
528
- lines[insert_at:insert_at] = appended
529
-
530
- f.parent.mkdir(parents=True, exist_ok=True)
531
- f.write_text("\n".join(lines) + "\n", encoding="utf-8")
532
- return 0
533
-
534
-
535
- def cmd_check(root):
536
- print("===== validate =====")
537
- rc_v = cmd_validate(root)
538
- print("\n===== stale =====")
539
- rc_s = cmd_stale(root)
540
- print(f"\ncheck: {'PULITO' if not (rc_v or rc_s) else 'NON PULITO'} "
541
- f"(validate rc={rc_v}, stale rc={rc_s})")
542
- return 1 if (rc_v or rc_s) else 0
543
-
544
-
545
- # --------------------------------------------------------------------- gate
546
-
547
- def cmd_gate(args):
548
- file_path = args.file or ""
549
- if args.hook:
550
- try:
551
- # bytes -> utf-8-sig: il payload degli hook e' JSON UTF-8 a prescindere
552
- # dalla code page della console; '-sig' scarta il BOM (pipe da PowerShell)
553
- raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
554
- payload = json.loads(raw)
555
- file_path = (payload.get("tool_input") or {}).get("file_path") or ""
556
- except Exception:
557
- return 0 # input non interpretabile: non bloccare
558
- if not file_path:
559
- return 0
560
- root = Path(args.root).resolve() if args.root else find_project_root()
561
- try:
562
- rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
563
- except ValueError:
564
- return 0 # fuori dal progetto: non di competenza del gate
565
- if rel.startswith(("ai_docs/", "tests/", "test/")):
566
- return 0
567
- protected = [p.strip().replace("\\", "/").rstrip("/")
568
- for p in (args.protected or "").split(";") if p.strip()]
569
- if not protected:
570
- return 0
571
- if not any(rel == p or rel.startswith(p + "/") for p in protected):
572
- return 0
573
- for _, meta, _ in list_analyses(root):
574
- if meta.get("stato") == "IN_PROGRESS":
575
- return 0
576
- sys.stderr.write(
577
- f"[sdlc gate] '{rel}' e' in un percorso protetto ma nessuna ANALYSIS_*.md e' IN_PROGRESS. "
578
- "Crea o riattiva l'analisi (Fase 3 di agentic-sdlc) prima di modificare questo file.\n")
579
- return 2
580
-
581
-
582
- # --------------------------------------------------------------------- main
583
-
584
- def main(argv=None):
585
- common = argparse.ArgumentParser(add_help=False)
586
- common.add_argument("--root", help="radice del progetto (default: risale fino a trovare ai_docs/)")
587
-
588
- ap = argparse.ArgumentParser(prog="sdlc_check.py",
589
- description="Validatore meccanico per Agentic SDLC")
590
- sub = ap.add_subparsers(dest="cmd", required=True)
591
- sub.add_parser("check", parents=[common], help="gate di chiusura: validate + stale in un solo comando")
592
- sub.add_parser("validate", parents=[common], help="verifica coerenza di ai_docs/")
593
- sub.add_parser("index", parents=[common], help="rigenera features_history.md + ai_docs/INDEX.md")
594
- sub.add_parser("stale", parents=[common], help="aree modificate dopo l'ultima analisi")
595
- mp = sub.add_parser("mark", parents=[common], help="registra percorsi come ANALYZED")
596
- mp.add_argument("paths", nargs="+", help="percorsi relativi alla radice del progetto")
597
- gp = sub.add_parser("gate", parents=[common], help="hook PreToolUse (exit 2 = blocca)")
598
- gp.add_argument("--hook", action="store_true", help="leggi il payload JSON dell'hook da stdin")
599
- gp.add_argument("--file", help="percorso file da valutare (alternativa a --hook)")
600
- gp.add_argument("--protected", default="", help="prefissi protetti separati da ';' (es. \"src/auth;src/crypto\")")
601
-
602
- args = ap.parse_args(argv)
603
- if args.cmd == "gate":
604
- return cmd_gate(args)
605
-
606
- root = Path(args.root).resolve() if args.root else find_project_root()
607
- if args.cmd == "check":
608
- return cmd_check(root)
609
- if args.cmd == "validate":
610
- return cmd_validate(root)
611
- if args.cmd == "index":
612
- return cmd_index(root)
613
- if args.cmd == "stale":
614
- return cmd_stale(root)
615
- if args.cmd == "mark":
616
- return cmd_mark(root, args.paths)
617
- return 0
618
-
619
-
620
- if __name__ == "__main__":
621
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Mechanical validator for the Agentic SDLC skill.
4
+
5
+ Commands:
6
+ check single closure gate: validate + stale in one command (exit 1 if either fails)
7
+ validate verify the structural coherence of ai_docs/ (exit 1 on errors; --strict also
8
+ fails on warnings or on a missing ai_docs/, for CI)
9
+ index regenerate the generated indexes: strategic/features_history.md (from the
10
+ frontmatter of ANALYSIS_*.md files) and ai_docs/INDEX.md (manifest of canonical docs)
11
+ stale list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
12
+ mark record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
13
+ gate PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)
14
+
15
+ Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
16
+ staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
17
+ E-TDD shadow, solutions/SHADOW_*tdd*.md, exists).
18
+
19
+ Canonical language is English. Legacy Italian frontmatter keys (stato, livello,
20
+ data_inizio, data_fine) and section headings are still accepted for existing projects,
21
+ but are deprecated: new documents should use the English forms.
22
+
23
+ Standard library only (Python >= 3.8). Windows and POSIX compatible.
24
+ """
25
+ import argparse
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from datetime import datetime, timedelta, timezone
33
+ from pathlib import Path
34
+
35
+ VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
36
+ VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
37
+ VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
38
+ SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
39
+ "dist", "build", ".idea", ".vs", "ai_docs"}
40
+ INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
41
+ "Source of truth: frontmatter of the ANALYSIS_*.md files -->")
42
+ MANIFEST_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
43
+ "Source of truth: the headers of the canonical documents in ai_docs/. -->")
44
+ # Directories whose .md files are durable canonical documents: manifested in INDEX.md.
45
+ # audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
46
+ MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
47
+ # Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
48
+ # ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
49
+ CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
50
+ "APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
51
+ GENERATED_DOCS = {"features_history.md", "INDEX.md"} # generated: never manifest entries
52
+ MTIME_GRACE = timedelta(seconds=2)
53
+ GUIDE_INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
54
+ "Source of truth: the headers of the GUIDE_*.md files in ai_docs/reference/. -->")
55
+ GUIDE_PROVENANCE_KEYS = ("source", "distilled_from", "source_hash") # source_version optional
56
+ # a guide section is "covered" when it carries a source marker or an explicit gap marker
57
+ GUIDE_MARKER_RE = re.compile(r"\[(?:source:[^\]]+|not covered by source)\]")
58
+
59
+ # Deprecated Italian frontmatter keys, mapped to the canonical English ones.
60
+ LEGACY_KEYS = {"stato": "status", "livello": "level",
61
+ "data_inizio": "start_date", "data_fine": "end_date"}
62
+
63
+ # ANALYSIS sections: (canonical English heading, legacy Italian heading).
64
+ SECURITY_SECTION = ("## Security", "## Sicurezza")
65
+ ANALYSIS_SECTIONS = (
66
+ ("## Objective", "## Obiettivo"),
67
+ ("## Feature Vision", "## Vision della Feature"),
68
+ ("## Impact", "## Impatto"),
69
+ ("## Action Plan", "## Piano d'Azione"),
70
+ ("## Test Strategy", "## Strategia di Test"),
71
+ ("## Diary", "## Diario"),
72
+ )
73
+
74
+ try:
75
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
76
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ # ----------------------------------------------------------------- utilities
82
+
83
+ def utc_now_iso():
84
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
85
+
86
+
87
+ def find_project_root(start=None):
88
+ cur = Path(start or os.getcwd()).resolve()
89
+ for p in [cur] + list(cur.parents):
90
+ if (p / "ai_docs").is_dir():
91
+ return p
92
+ return cur
93
+
94
+
95
+ def require_ai_docs(root, command):
96
+ """Fail fast when ai_docs/ is missing: prevents silently creating a second
97
+ documentation root in the wrong working directory."""
98
+ if not (root / "ai_docs").is_dir():
99
+ print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
100
+ "Run agentic-sdlc-init first, or pass --root <project_root>.")
101
+ return False
102
+ return True
103
+
104
+
105
+ def read_text(path):
106
+ # utf-8-sig: strips a leading BOM (files authored on Windows) so the
107
+ # frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
108
+ return path.read_text(encoding="utf-8-sig", errors="replace")
109
+
110
+
111
+ def sha256_file(path):
112
+ h = hashlib.sha256()
113
+ h.update(path.read_bytes())
114
+ return h.hexdigest()
115
+
116
+
117
+ def parse_iso(value):
118
+ if not value:
119
+ return None
120
+ try:
121
+ dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
122
+ if dt.tzinfo is None:
123
+ dt = dt.replace(tzinfo=timezone.utc)
124
+ return dt
125
+ except ValueError:
126
+ return None
127
+
128
+
129
+ def norm_text(s):
130
+ return "\n".join(line.rstrip() for line in s.strip().splitlines())
131
+
132
+
133
+ def load_frontmatter(lines):
134
+ meta = {}
135
+ if not lines or lines[0].strip() != "---":
136
+ return meta
137
+ for line in lines[1:60]:
138
+ if line.strip() == "---":
139
+ break
140
+ m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
141
+ if m:
142
+ meta[m.group(1).strip().lower()] = m.group(2).strip()
143
+ # Legacy Italian keys: accepted, normalized to canonical English (deprecated).
144
+ for legacy, canon in LEGACY_KEYS.items():
145
+ if legacy in meta and canon not in meta:
146
+ meta[canon] = meta[legacy]
147
+ return meta
148
+
149
+
150
+ def is_shadow(path, first_line):
151
+ """A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
152
+ Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
153
+ (legacy shadows saved under an ANALYSIS_* name)."""
154
+ return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")
155
+
156
+
157
+ def list_analyses(root):
158
+ """Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
159
+ sol = root / "ai_docs" / "solutions"
160
+ out = []
161
+ if not sol.is_dir():
162
+ return out
163
+ for p in sorted(sol.glob("ANALYSIS_*.md")):
164
+ text = read_text(p)
165
+ first_line = text.splitlines()[0] if text else ""
166
+ if is_shadow(p, first_line):
167
+ continue
168
+ out.append((p, load_frontmatter(text.splitlines()), text))
169
+ return out
170
+
171
+
172
+ def has_etdd_shadow(root):
173
+ """True if an E-TDD shadow exported from devPNT exists in solutions/.
174
+ In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
175
+ design authorization that replaces the IN_PROGRESS ANALYSIS."""
176
+ sol = root / "ai_docs" / "solutions"
177
+ if not sol.is_dir():
178
+ return False
179
+ return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
180
+
181
+
182
+ def iter_files(target):
183
+ if target.is_file():
184
+ yield target
185
+ return
186
+ for dirpath, dirnames, filenames in os.walk(target):
187
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
188
+ for name in filenames:
189
+ yield Path(dirpath) / name
190
+
191
+
192
+ # ---------------------------------------------------------------------- git
193
+
194
+ def git_available(root):
195
+ try:
196
+ r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
197
+ cwd=str(root), capture_output=True, text=True, timeout=10)
198
+ return r.returncode == 0 and r.stdout.strip() == "true"
199
+ except Exception:
200
+ return False
201
+
202
+
203
+ def git_head(root):
204
+ try:
205
+ r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
206
+ cwd=str(root), capture_output=True, text=True, timeout=10)
207
+ return r.stdout.strip() if r.returncode == 0 else ""
208
+ except Exception:
209
+ return ""
210
+
211
+
212
+ def git_has_changes(root, rel_path):
213
+ """True if there are tracked/untracked changes under rel_path."""
214
+ try:
215
+ rel = rel_path.replace("\\", "/")
216
+ r = subprocess.run(["git", "status", "--porcelain", "--", rel],
217
+ cwd=str(root), capture_output=True, text=True, timeout=30)
218
+ return r.returncode == 0 and bool(r.stdout.strip())
219
+ except Exception:
220
+ return False
221
+
222
+
223
+ def git_changed_since(root, ref, rel_path):
224
+ """Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
225
+ try:
226
+ r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
227
+ cwd=str(root), capture_output=True, text=True, timeout=30)
228
+ if r.returncode != 0:
229
+ return None
230
+ changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
231
+ r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
232
+ cwd=str(root), capture_output=True, text=True, timeout=30)
233
+ if r2.returncode == 0:
234
+ changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
235
+ return sorted(set(changed))
236
+ except Exception:
237
+ return None
238
+
239
+
240
+ # -------------------------------------------------------------------- index
241
+
242
+ def build_index(root):
243
+ rows = []
244
+ for p, meta, _ in list_analyses(root):
245
+ rows.append((
246
+ meta.get("id", "?"),
247
+ meta.get("feature", p.stem.replace("ANALYSIS_", "")),
248
+ meta.get("level", ""),
249
+ meta.get("status", "?"),
250
+ meta.get("start_date", ""),
251
+ meta.get("end_date", ""),
252
+ "solutions/" + p.name,
253
+ ))
254
+ rows.sort(key=lambda r: r[0])
255
+ lines = [INDEX_HEADER,
256
+ "# Feature History (generated)",
257
+ "",
258
+ "| ID | Feature | Level | Status | Started | Finished | Doc |",
259
+ "|---|---|---|---|---|---|---|"]
260
+ for r in rows:
261
+ lines.append("| " + " | ".join(r) + " |")
262
+ return "\n".join(lines) + "\n"
263
+
264
+
265
+ # "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
266
+ _STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
267
+ # pure metadata lines to skip when picking the fallback description
268
+ _META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)
269
+
270
+
271
+ def extract_doc_meta(path):
272
+ """(title, description, status, supersedes) of a canonical doc.
273
+
274
+ Recognizes TWO header conventions: the YAML-lite frontmatter
275
+ (description/status/supersedes/title) and the in-body `**Status:** X`
276
+ line (used by ADRs and legacy docs). As a fallback it derives the title
277
+ from the first '# H1' and the description from the first prose line,
278
+ skipping metadata lines.
279
+ """
280
+ text = read_text(path)
281
+ lines = text.splitlines()
282
+ meta = load_frontmatter(lines)
283
+ body = lines
284
+ if lines and lines[0].strip() == "---":
285
+ for i in range(1, min(len(lines), 60)):
286
+ if lines[i].strip() == "---":
287
+ body = lines[i + 1:]
288
+ break
289
+
290
+ title = meta.get("title", "")
291
+ if not title:
292
+ for line in body:
293
+ m = re.match(r"^#\s+(.*)$", line)
294
+ if m:
295
+ title = m.group(1).strip()
296
+ break
297
+ title = title or path.stem
298
+
299
+ status = meta.get("status", "").upper()
300
+ if not status:
301
+ for line in body[:25]:
302
+ m = _STATUS_LINE.match(line.strip())
303
+ if m:
304
+ status = m.group(1).upper()
305
+ break
306
+
307
+ desc = meta.get("description", "")
308
+ if not desc:
309
+ for line in body:
310
+ s = line.strip()
311
+ if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
312
+ continue
313
+ if s.startswith(">"):
314
+ s = s.lstrip(">").strip()
315
+ m = _STATUS_LINE.match(s)
316
+ if m:
317
+ # "Status: X — description": keep the part after the status; if empty, skip
318
+ rest = s[m.end():].strip(" *—–-:.")
319
+ if not rest:
320
+ continue
321
+ s = rest
322
+ if s:
323
+ desc = s
324
+ break
325
+ desc = re.sub(r"\s+", " ", desc).strip()
326
+ if len(desc) > 160:
327
+ desc = desc[:157].rstrip() + "..."
328
+ return title, desc, status, meta.get("supersedes", "").strip()
329
+
330
+
331
+ def list_canonical_docs(root):
332
+ """[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
333
+ ai = root / "ai_docs"
334
+ out = []
335
+ for d in MANIFEST_DIRS:
336
+ base = ai / d
337
+ if not base.is_dir():
338
+ continue
339
+ for p in sorted(base.rglob("*.md")):
340
+ rel_parts = p.relative_to(base).parts
341
+ if any(part.startswith(".") for part in rel_parts[:-1]):
342
+ continue # dot-subdirs (e.g. reference/.sources/) are never canonical
343
+ if p.name in GENERATED_DOCS or p.name == "README.md":
344
+ continue
345
+ out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
346
+ return out
347
+
348
+
349
+ def build_manifest(root):
350
+ docs = list_canonical_docs(root)
351
+ lines = [MANIFEST_HEADER,
352
+ "# `ai_docs/` document index (generated)",
353
+ "",
354
+ "Complete manifest of the canonical documents. For the reading priority",
355
+ "(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
356
+ "`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
357
+ "not manifested here."]
358
+ by_dir = {}
359
+ for rel, _, meta in docs:
360
+ by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
361
+ for top in MANIFEST_DIRS:
362
+ rows = by_dir.get(top)
363
+ if not rows:
364
+ continue
365
+ lines += ["", f"## {top}/", "",
366
+ "| Document | Status | Description |", "|---|---|---|"]
367
+ for rel, (title, desc, status, _sup) in rows:
368
+ d = (desc or title).replace("|", "\\|")
369
+ lines.append(f"| `{rel}` | {status or '-'} | {d} |")
370
+ return "\n".join(lines).rstrip() + "\n"
371
+
372
+
373
+ def list_guides(root):
374
+ """[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
375
+ ref = root / "ai_docs" / "reference"
376
+ out = []
377
+ if not ref.is_dir():
378
+ return out
379
+ for p in sorted(ref.glob("GUIDE_*.md")):
380
+ text = read_text(p)
381
+ out.append((p.relative_to(root / "ai_docs").as_posix(), p,
382
+ load_frontmatter(text.splitlines()), text))
383
+ return out
384
+
385
+
386
+ def build_guide_index(root):
387
+ lines = [GUIDE_INDEX_HEADER,
388
+ "# Operative guides (generated router)",
389
+ "",
390
+ "One row per guide. `description` is the when-to-consult line; provenance",
391
+ "shows what the guide was distilled from. Freshness: run `sdlc_check.py stale`.",
392
+ "",
393
+ "| Guide | Status | When to consult | Source | Source version |",
394
+ "|---|---|---|---|---|"]
395
+ for rel, p, meta, _ in list_guides(root):
396
+ lines.append("| `{}` | {} | {} | {} | {} |".format(
397
+ p.name, meta.get("status", "-") or "-",
398
+ (meta.get("description", "") or "-").replace("|", "\\|"),
399
+ (meta.get("source", "") or "-").replace("|", "\\|"),
400
+ meta.get("source_version", "") or "-"))
401
+ return "\n".join(lines) + "\n"
402
+
403
+
404
+ def cmd_index(root):
405
+ if not require_ai_docs(root, "index"):
406
+ return 1
407
+ hist = root / "ai_docs" / "strategic" / "features_history.md"
408
+ hist.parent.mkdir(parents=True, exist_ok=True)
409
+ hist.write_text(build_index(root), encoding="utf-8")
410
+ print(f"[ok] ANALYSIS index regenerated: {hist}")
411
+ # INDEX.md only if canonical docs exist: no empty manifest on minimal projects
412
+ if list_canonical_docs(root):
413
+ manifest = root / "ai_docs" / "INDEX.md"
414
+ manifest.write_text(build_manifest(root), encoding="utf-8")
415
+ print(f"[ok] document manifest regenerated: {manifest}")
416
+ else:
417
+ print("[info] no canonical documents: INDEX.md not generated")
418
+ guides = list_guides(root)
419
+ gidx = root / "ai_docs" / "reference" / "INDEX.md"
420
+ if guides:
421
+ gidx.write_text(build_guide_index(root), encoding="utf-8")
422
+ print(f"[ok] guide router regenerated: {gidx}")
423
+ elif gidx.is_file():
424
+ print(f"[warn] {gidx} exists but no GUIDE_*.md found: stale router, remove or add guides")
425
+ return 0
426
+
427
+
428
+ # ----------------------------------------------------------------- validate
429
+
430
+ def has_section(text, aliases):
431
+ return any(a in text for a in aliases)
432
+
433
+
434
+ def cmd_validate(root, strict=False):
435
+ errors, warnings = [], []
436
+ ai = root / "ai_docs"
437
+ if not ai.is_dir():
438
+ if strict:
439
+ print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
440
+ "fails so a wrong working directory cannot produce a green pipeline.")
441
+ return 1
442
+ print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
443
+ return 0
444
+
445
+ # Vision: presence and declared state
446
+ for name in VISION_FILES:
447
+ f = ai / "vision" / name
448
+ if not f.is_file():
449
+ warnings.append(f"vision/{name} missing")
450
+ continue
451
+ head = "\n".join(read_text(f).splitlines()[:12])
452
+ m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
453
+ if not m:
454
+ errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
455
+ elif m.group(1) == "DRAFT":
456
+ warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")
457
+
458
+ # ANALYSIS: frontmatter and mandatory sections
459
+ seen_ids = {}
460
+ analyses = list_analyses(root)
461
+ for p, meta, text in analyses:
462
+ rel = "solutions/" + p.name
463
+ if not meta:
464
+ errors.append(f"{rel}: frontmatter missing")
465
+ continue
466
+ fid = meta.get("id")
467
+ if not fid:
468
+ errors.append(f"{rel}: 'id' field missing")
469
+ elif fid in seen_ids:
470
+ errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
471
+ else:
472
+ seen_ids[fid] = rel
473
+ status = meta.get("status", "")
474
+ if status not in VALID_STATES:
475
+ errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
476
+ if not meta.get("start_date"):
477
+ errors.append(f"{rel}: 'start_date' missing")
478
+ if status == "COMPLETED" and not meta.get("end_date"):
479
+ errors.append(f"{rel}: COMPLETED without 'end_date'")
480
+ level = meta.get("level")
481
+ if level and level.upper() not in VALID_LEVELS:
482
+ warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
483
+ if not has_section(text, SECURITY_SECTION):
484
+ errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
485
+ for en, it in ANALYSIS_SECTIONS:
486
+ if not has_section(text, (en, it)):
487
+ warnings.append(f"{rel}: section '{en}' missing")
488
+
489
+ # Generated index aligned
490
+ hist = ai / "strategic" / "features_history.md"
491
+ if analyses:
492
+ if not hist.is_file():
493
+ errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
494
+ elif norm_text(read_text(hist)) != norm_text(build_index(root)):
495
+ errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")
496
+
497
+ # Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
498
+ docs = list_canonical_docs(root)
499
+ manifest = ai / "INDEX.md"
500
+ if docs:
501
+ if not manifest.is_file():
502
+ errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
503
+ elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
504
+ errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")
505
+
506
+ # Canonical document lifecycle: declared status + supersedes coherence
507
+ canon_status = {rel: meta[2] for rel, _, meta in docs}
508
+ for rel, _, (title, desc, status, supersedes) in docs:
509
+ if not status:
510
+ warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
511
+ elif status not in CANONICAL_STATES:
512
+ warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
513
+ if supersedes:
514
+ base = os.path.basename(supersedes)
515
+ for other, ost in canon_status.items():
516
+ if (other == supersedes or other.endswith("/" + supersedes)
517
+ or os.path.basename(other) == base) and ost == "CURRENT":
518
+ warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
519
+
520
+ # Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
521
+ guides = list_guides(root)
522
+ for rel, p, meta, text in guides:
523
+ missing = [k for k in GUIDE_PROVENANCE_KEYS if not meta.get(k)]
524
+ if missing:
525
+ warnings.append(f"{rel}: guide missing provenance key(s): {', '.join(missing)}")
526
+ # (b) per-section fidelity markers: every '## ' section body must carry a marker
527
+ body = text.split("---", 2)[-1]
528
+ sections = re.split(r"^##\s+", body, flags=re.M)[1:]
529
+ unmarked = [s.splitlines()[0].strip() for s in sections if not GUIDE_MARKER_RE.search(s)]
530
+ if unmarked:
531
+ warnings.append(f"{rel}: section(s) without [source: ...] / [not covered by source] marker: "
532
+ + "; ".join(unmarked[:5]))
533
+ # (c) distilled_from confinement — fail closed (P-TM T6, distilled_from vector)
534
+ df = meta.get("distilled_from", "")
535
+ if df:
536
+ if Path(df).is_absolute() or ".." in Path(df).parts:
537
+ errors.append(f"{rel}: distilled_from '{df}' is absolute or escapes the project (..): rejected")
538
+ else:
539
+ target = (root / df).resolve()
540
+ try:
541
+ target.relative_to(root.resolve())
542
+ except ValueError:
543
+ errors.append(f"{rel}: distilled_from '{df}' resolves outside the project root: rejected")
544
+ # guide-router alignment (mirror of the root-manifest check)
545
+ gidx = root / "ai_docs" / "reference" / "INDEX.md"
546
+ if guides:
547
+ if not gidx.is_file():
548
+ errors.append("ai_docs/reference/INDEX.md missing: run 'sdlc_check.py index'")
549
+ elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
550
+ errors.append("ai_docs/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
551
+
552
+ # Handoff: header and freshness
553
+ hand = ai / "audit" / "handoff.md"
554
+ if hand.is_file():
555
+ m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
556
+ if not m:
557
+ warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
558
+ else:
559
+ try:
560
+ stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
561
+ age = (datetime.now(timezone.utc) - stamp).days
562
+ if age > 14:
563
+ warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
564
+ except ValueError:
565
+ warnings.append("audit/handoff.md: date not parseable")
566
+
567
+ for w in warnings:
568
+ print(f"[warn] {w}")
569
+ for e in errors:
570
+ print(f"[ERROR] {e}")
571
+ print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
572
+ if strict and warnings and not errors:
573
+ print("[strict] warnings are failures in --strict mode.")
574
+ return 1 if errors or (strict and warnings) else 0
575
+
576
+
577
+ # ------------------------------------------------------------- audit_plan
578
+
579
+ def parse_audit_plan(root):
580
+ f = root / "ai_docs" / "audit" / "audit_plan.md"
581
+ rows, lines = [], []
582
+ if f.is_file():
583
+ lines = read_text(f).splitlines()
584
+ for i, line in enumerate(lines):
585
+ if not line.strip().startswith("|"):
586
+ continue
587
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
588
+ if len(cells) < 2:
589
+ continue
590
+ if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
591
+ continue
592
+ rows.append({
593
+ "line": i,
594
+ "path": cells[0],
595
+ "status": cells[1].upper(),
596
+ "ref": cells[2] if len(cells) > 2 else "",
597
+ "note": cells[3] if len(cells) > 3 else "",
598
+ })
599
+ return f, lines, rows
600
+
601
+
602
+ def cmd_stale(root, hybrid=False):
603
+ rc = 0
604
+ # --- guide freshness (source_hash vs snapshot) — runs in EVERY mode
605
+ drifted = []
606
+ for rel, p, meta, _ in list_guides(root):
607
+ df, rec = meta.get("distilled_from", ""), meta.get("source_hash", "")
608
+ if not df or not rec:
609
+ continue # structure problems are validate's job
610
+ src = root / df
611
+ if not src.is_file():
612
+ print(f"[warn] {rel}: distilled_from '{df}' not found — snapshot missing")
613
+ rc = 1
614
+ continue
615
+ if sha256_file(src) != rec:
616
+ drifted.append((rel, df))
617
+ for rel, df in drifted:
618
+ print(f"[stale] {rel}: source snapshot '{df}' changed since distillation — regenerate the guide")
619
+ if drifted:
620
+ rc = 1
621
+ # --- audit-plan staleness — delegated to devPNT/KL in hybrid
622
+ if hybrid:
623
+ print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
624
+ return rc # was: implicit skip-all; guide rc survives
625
+ f, _, rows = parse_audit_plan(root)
626
+ if not rows:
627
+ print(f"[info] no rows in {f}: nothing to check "
628
+ "(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
629
+ return rc # was: return 0 — MUST carry guide rc
630
+ use_git = git_available(root)
631
+ stale = []
632
+ for row in rows:
633
+ if row["status"] != "ANALYZED":
634
+ continue
635
+ rel, ref = row["path"], row["ref"]
636
+ target = root / rel
637
+ if not target.exists():
638
+ print(f"[warn] {rel}: path does not exist")
639
+ continue
640
+ changed = []
641
+ if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
642
+ res = git_changed_since(root, ref, rel.replace("\\", "/"))
643
+ if res is None:
644
+ print(f"[warn] {rel}: git ref '{ref}' unresolvable, cannot evaluate")
645
+ continue
646
+ changed = res
647
+ else:
648
+ ts = parse_iso(ref)
649
+ if ts is None:
650
+ print(f"[warn] {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
651
+ continue
652
+ for fp in iter_files(target):
653
+ mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
654
+ if mtime > ts + MTIME_GRACE:
655
+ changed.append(str(fp.relative_to(root)).replace("\\", "/"))
656
+ if changed:
657
+ stale.append((rel, changed))
658
+
659
+ if not stale:
660
+ print("[ok] no analyzed area was modified after its last recorded analysis.")
661
+ return rc # was: return 0 — MUST carry guide rc
662
+ print("Areas modified after the last recorded analysis:")
663
+ for rel, changed in stale:
664
+ print(f" {rel} ({len(changed)} files)")
665
+ for c in changed[:10]:
666
+ print(f" - {c}")
667
+ if len(changed) > 10:
668
+ print(f" ... and {len(changed) - 10} more")
669
+ print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
670
+ return 1 # stale areas dominate: rc already implied
671
+
672
+
673
+ def cmd_mark(root, paths):
674
+ if not require_ai_docs(root, "mark"):
675
+ return 1
676
+ f, lines, rows = parse_audit_plan(root)
677
+ use_git_ref = git_available(root) and not any(
678
+ git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
679
+ )
680
+ ref = git_head(root) if use_git_ref else utc_now_iso()
681
+ by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
682
+
683
+ if not lines:
684
+ lines = ["# Audit Plan", "",
685
+ "| Path | Status | Reference | Notes |",
686
+ "|---|---|---|---|"]
687
+ rows = []
688
+
689
+ def row_text(path, note):
690
+ return f"| {path} | ANALYZED | {ref} | {note} |"
691
+
692
+ appended = []
693
+ for raw in paths:
694
+ key = raw.replace("\\", "/").rstrip("/")
695
+ display = key + ("/" if (root / key).is_dir() else "")
696
+ existing = by_path.get(key)
697
+ if existing:
698
+ lines[existing["line"]] = row_text(existing["path"], existing["note"])
699
+ print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
700
+ else:
701
+ appended.append(row_text(display, ""))
702
+ print(f"[ok] {display} added as ANALYZED ({ref})")
703
+
704
+ if appended:
705
+ insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
706
+ lines[insert_at:insert_at] = appended
707
+
708
+ f.parent.mkdir(parents=True, exist_ok=True)
709
+ f.write_text("\n".join(lines) + "\n", encoding="utf-8")
710
+ return 0
711
+
712
+
713
+ def cmd_check(root, strict=False, hybrid=False):
714
+ print("===== validate =====")
715
+ rc_v = cmd_validate(root, strict=strict)
716
+ print("\n===== stale =====")
717
+ rc_s = cmd_stale(root, hybrid=hybrid)
718
+ print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
719
+ f"(validate rc={rc_v}, stale rc={rc_s})")
720
+ return 1 if (rc_v or rc_s) else 0
721
+
722
+
723
+ # --------------------------------------------------------------------- gate
724
+
725
+ def cmd_gate(args):
726
+ file_path = args.file or ""
727
+ if args.hook:
728
+ try:
729
+ # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
730
+ # console code page; '-sig' strips the BOM (PowerShell pipes)
731
+ raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
732
+ payload = json.loads(raw)
733
+ file_path = (payload.get("tool_input") or {}).get("file_path") or ""
734
+ except Exception:
735
+ return 0 # unparseable input: do not block
736
+ if not file_path:
737
+ return 0
738
+ root = Path(args.root).resolve() if args.root else find_project_root()
739
+ try:
740
+ rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
741
+ except ValueError:
742
+ return 0 # outside the project: not this gate's concern
743
+ if rel.startswith(("ai_docs/", "tests/", "test/")):
744
+ return 0
745
+ protected = [p.strip().replace("\\", "/").rstrip("/")
746
+ for p in (args.protected or "").split(";") if p.strip()]
747
+ if not protected:
748
+ return 0
749
+ if not any(rel == p or rel.startswith(p + "/") for p in protected):
750
+ return 0
751
+ for _, meta, _ in list_analyses(root):
752
+ if meta.get("status") == "IN_PROGRESS":
753
+ return 0
754
+ if args.hybrid and has_etdd_shadow(root):
755
+ return 0 # Hybrid design gate: an approved E-TDD shadow authorizes the change
756
+ if args.hybrid:
757
+ sys.stderr.write(
758
+ f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
759
+ "(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
760
+ "In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
761
+ return 2
762
+ sys.stderr.write(
763
+ f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
764
+ "Create or reactivate the analysis (agentic-sdlc Phase 3) before modifying this file.\n")
765
+ return 2
766
+
767
+
768
+ # --------------------------------------------------------------------- main
769
+
770
+ def main(argv=None):
771
+ common = argparse.ArgumentParser(add_help=False)
772
+ common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")
773
+
774
+ strict_opt = argparse.ArgumentParser(add_help=False)
775
+ strict_opt.add_argument("--strict", action="store_true",
776
+ help="fail on warnings and on missing ai_docs/ (for CI)")
777
+
778
+ hybrid_opt = argparse.ArgumentParser(add_help=False)
779
+ hybrid_opt.add_argument("--hybrid", action="store_true",
780
+ help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
781
+ "the gate also unlocks on an E-TDD shadow")
782
+
783
+ ap = argparse.ArgumentParser(prog="sdlc_check.py",
784
+ description="Mechanical validator for Agentic SDLC")
785
+ sub = ap.add_subparsers(dest="cmd", required=True)
786
+ sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
787
+ help="closure gate: validate + stale in one command")
788
+ sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
789
+ sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
790
+ sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
791
+ mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
792
+ mp.add_argument("paths", nargs="+", help="paths relative to the project root")
793
+ gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
794
+ gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
795
+ gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
796
+ gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
797
+
798
+ args = ap.parse_args(argv)
799
+ if args.cmd == "gate":
800
+ return cmd_gate(args)
801
+
802
+ root = Path(args.root).resolve() if args.root else find_project_root()
803
+ if args.cmd == "check":
804
+ return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
805
+ if args.cmd == "validate":
806
+ return cmd_validate(root, strict=args.strict)
807
+ if args.cmd == "index":
808
+ return cmd_index(root)
809
+ if args.cmd == "stale":
810
+ return cmd_stale(root, hybrid=args.hybrid)
811
+ if args.cmd == "mark":
812
+ return cmd_mark(root, args.paths)
813
+ return 0
814
+
815
+
816
+ if __name__ == "__main__":
817
+ sys.exit(main())