@antoneeo/agentic-sdlc-skill 1.4.0 → 1.6.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/CHANGELOG.md +47 -12
- package/README.md +80 -85
- package/gemini-extension.json +2 -2
- package/package.json +6 -3
- package/references/analysis_template.md +39 -20
- package/references/architecture_template.md +11 -7
- package/references/existing_features_template.md +4 -0
- package/references/feature_vision_template.md +2 -0
- package/references/features_history_template.md +5 -6
- package/references/principles_template.md +3 -2
- package/references/project_vision_template.md +4 -3
- package/references/roadmap_template.md +3 -2
- package/scripts/init.js +63 -45
- package/scripts/postinstall.js +38 -12
- package/scripts/preuninstall.js +7 -4
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +55 -0
- package/skills/agentic-sdlc-skill/SKILL.md +170 -99
- package/skills/agentic-sdlc-skill/scripts/sdlc_check.py +621 -0
- package/skills/agentic-sdlc-skill/templates.md +192 -0
|
@@ -0,0 +1,621 @@
|
|
|
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())
|