@antoneeo/agentic-sdlc-skill 1.5.0 → 1.7.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 +69 -25
- package/gemini-extension.json +2 -2
- package/package.json +6 -8
- package/scripts/init.js +125 -176
- package/scripts/lib.js +138 -0
- package/scripts/postinstall.js +17 -113
- package/scripts/preuninstall.js +5 -14
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +56 -55
- package/skills/agentic-sdlc-skill/SKILL.md +244 -141
- package/skills/agentic-sdlc-skill/scripts/sdlc_check.py +713 -474
- package/skills/agentic-sdlc-skill/templates.md +212 -169
- package/references/analysis_template.md +0 -44
- package/references/architecture_template.md +0 -19
- package/references/existing_features_template.md +0 -4
- package/references/feature_vision_template.md +0 -21
- package/references/features_history_template.md +0 -5
- package/references/principles_template.md +0 -13
- package/references/project_vision_template.md +0 -26
- package/references/roadmap_template.md +0 -9
|
@@ -1,474 +1,713 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
check
|
|
7
|
-
validate
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import datetime, timedelta, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
|
|
35
|
+
VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
|
|
36
|
+
VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
|
|
37
|
+
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
|
38
|
+
"dist", "build", ".idea", ".vs", "ai_docs"}
|
|
39
|
+
INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
|
|
40
|
+
"Source of truth: frontmatter of the ANALYSIS_*.md files -->")
|
|
41
|
+
MANIFEST_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
|
|
42
|
+
"Source of truth: the headers of the canonical documents in ai_docs/. -->")
|
|
43
|
+
# Directories whose .md files are durable canonical documents: manifested in INDEX.md.
|
|
44
|
+
# audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
|
|
45
|
+
MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
|
|
46
|
+
# Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
|
|
47
|
+
# ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
|
|
48
|
+
CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
|
|
49
|
+
"APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
|
|
50
|
+
GENERATED_DOCS = {"features_history.md", "INDEX.md"} # generated: never manifest entries
|
|
51
|
+
MTIME_GRACE = timedelta(seconds=2)
|
|
52
|
+
|
|
53
|
+
# Deprecated Italian frontmatter keys, mapped to the canonical English ones.
|
|
54
|
+
LEGACY_KEYS = {"stato": "status", "livello": "level",
|
|
55
|
+
"data_inizio": "start_date", "data_fine": "end_date"}
|
|
56
|
+
|
|
57
|
+
# ANALYSIS sections: (canonical English heading, legacy Italian heading).
|
|
58
|
+
SECURITY_SECTION = ("## Security", "## Sicurezza")
|
|
59
|
+
ANALYSIS_SECTIONS = (
|
|
60
|
+
("## Objective", "## Obiettivo"),
|
|
61
|
+
("## Feature Vision", "## Vision della Feature"),
|
|
62
|
+
("## Impact", "## Impatto"),
|
|
63
|
+
("## Action Plan", "## Piano d'Azione"),
|
|
64
|
+
("## Test Strategy", "## Strategia di Test"),
|
|
65
|
+
("## Diary", "## Diario"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
70
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ----------------------------------------------------------------- utilities
|
|
76
|
+
|
|
77
|
+
def utc_now_iso():
|
|
78
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_project_root(start=None):
|
|
82
|
+
cur = Path(start or os.getcwd()).resolve()
|
|
83
|
+
for p in [cur] + list(cur.parents):
|
|
84
|
+
if (p / "ai_docs").is_dir():
|
|
85
|
+
return p
|
|
86
|
+
return cur
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def require_ai_docs(root, command):
|
|
90
|
+
"""Fail fast when ai_docs/ is missing: prevents silently creating a second
|
|
91
|
+
documentation root in the wrong working directory."""
|
|
92
|
+
if not (root / "ai_docs").is_dir():
|
|
93
|
+
print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
|
|
94
|
+
"Run agentic-sdlc-init first, or pass --root <project_root>.")
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_text(path):
|
|
100
|
+
# utf-8-sig: strips a leading BOM (files authored on Windows) so the
|
|
101
|
+
# frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
|
|
102
|
+
return path.read_text(encoding="utf-8-sig", errors="replace")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_iso(value):
|
|
106
|
+
if not value:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
110
|
+
if dt.tzinfo is None:
|
|
111
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
112
|
+
return dt
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def norm_text(s):
|
|
118
|
+
return "\n".join(line.rstrip() for line in s.strip().splitlines())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_frontmatter(lines):
|
|
122
|
+
meta = {}
|
|
123
|
+
if not lines or lines[0].strip() != "---":
|
|
124
|
+
return meta
|
|
125
|
+
for line in lines[1:60]:
|
|
126
|
+
if line.strip() == "---":
|
|
127
|
+
break
|
|
128
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
129
|
+
if m:
|
|
130
|
+
meta[m.group(1).strip().lower()] = m.group(2).strip()
|
|
131
|
+
# Legacy Italian keys: accepted, normalized to canonical English (deprecated).
|
|
132
|
+
for legacy, canon in LEGACY_KEYS.items():
|
|
133
|
+
if legacy in meta and canon not in meta:
|
|
134
|
+
meta[canon] = meta[legacy]
|
|
135
|
+
return meta
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_shadow(path, first_line):
|
|
139
|
+
"""A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
|
|
140
|
+
Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
|
|
141
|
+
(legacy shadows saved under an ANALYSIS_* name)."""
|
|
142
|
+
return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_analyses(root):
|
|
146
|
+
"""Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
|
|
147
|
+
sol = root / "ai_docs" / "solutions"
|
|
148
|
+
out = []
|
|
149
|
+
if not sol.is_dir():
|
|
150
|
+
return out
|
|
151
|
+
for p in sorted(sol.glob("ANALYSIS_*.md")):
|
|
152
|
+
text = read_text(p)
|
|
153
|
+
first_line = text.splitlines()[0] if text else ""
|
|
154
|
+
if is_shadow(p, first_line):
|
|
155
|
+
continue
|
|
156
|
+
out.append((p, load_frontmatter(text.splitlines()), text))
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def has_etdd_shadow(root):
|
|
161
|
+
"""True if an E-TDD shadow exported from devPNT exists in solutions/.
|
|
162
|
+
In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
|
|
163
|
+
design authorization that replaces the IN_PROGRESS ANALYSIS."""
|
|
164
|
+
sol = root / "ai_docs" / "solutions"
|
|
165
|
+
if not sol.is_dir():
|
|
166
|
+
return False
|
|
167
|
+
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def iter_files(target):
|
|
171
|
+
if target.is_file():
|
|
172
|
+
yield target
|
|
173
|
+
return
|
|
174
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
175
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
176
|
+
for name in filenames:
|
|
177
|
+
yield Path(dirpath) / name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------- git
|
|
181
|
+
|
|
182
|
+
def git_available(root):
|
|
183
|
+
try:
|
|
184
|
+
r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
|
|
185
|
+
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
186
|
+
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def git_head(root):
|
|
192
|
+
try:
|
|
193
|
+
r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
|
|
194
|
+
cwd=str(root), capture_output=True, text=True, timeout=10)
|
|
195
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
196
|
+
except Exception:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def git_has_changes(root, rel_path):
|
|
201
|
+
"""True if there are tracked/untracked changes under rel_path."""
|
|
202
|
+
try:
|
|
203
|
+
rel = rel_path.replace("\\", "/")
|
|
204
|
+
r = subprocess.run(["git", "status", "--porcelain", "--", rel],
|
|
205
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
206
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def git_changed_since(root, ref, rel_path):
|
|
212
|
+
"""Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
|
|
213
|
+
try:
|
|
214
|
+
r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
|
|
215
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
216
|
+
if r.returncode != 0:
|
|
217
|
+
return None
|
|
218
|
+
changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
|
|
219
|
+
r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
|
|
220
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
221
|
+
if r2.returncode == 0:
|
|
222
|
+
changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
|
|
223
|
+
return sorted(set(changed))
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# -------------------------------------------------------------------- index
|
|
229
|
+
|
|
230
|
+
def build_index(root):
|
|
231
|
+
rows = []
|
|
232
|
+
for p, meta, _ in list_analyses(root):
|
|
233
|
+
rows.append((
|
|
234
|
+
meta.get("id", "?"),
|
|
235
|
+
meta.get("feature", p.stem.replace("ANALYSIS_", "")),
|
|
236
|
+
meta.get("level", ""),
|
|
237
|
+
meta.get("status", "?"),
|
|
238
|
+
meta.get("start_date", ""),
|
|
239
|
+
meta.get("end_date", ""),
|
|
240
|
+
"solutions/" + p.name,
|
|
241
|
+
))
|
|
242
|
+
rows.sort(key=lambda r: r[0])
|
|
243
|
+
lines = [INDEX_HEADER,
|
|
244
|
+
"# Feature History (generated)",
|
|
245
|
+
"",
|
|
246
|
+
"| ID | Feature | Level | Status | Started | Finished | Doc |",
|
|
247
|
+
"|---|---|---|---|---|---|---|"]
|
|
248
|
+
for r in rows:
|
|
249
|
+
lines.append("| " + " | ".join(r) + " |")
|
|
250
|
+
return "\n".join(lines) + "\n"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
|
|
254
|
+
_STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
|
|
255
|
+
# pure metadata lines to skip when picking the fallback description
|
|
256
|
+
_META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def extract_doc_meta(path):
|
|
260
|
+
"""(title, description, status, supersedes) of a canonical doc.
|
|
261
|
+
|
|
262
|
+
Recognizes TWO header conventions: the YAML-lite frontmatter
|
|
263
|
+
(description/status/supersedes/title) and the in-body `**Status:** X`
|
|
264
|
+
line (used by ADRs and legacy docs). As a fallback it derives the title
|
|
265
|
+
from the first '# H1' and the description from the first prose line,
|
|
266
|
+
skipping metadata lines.
|
|
267
|
+
"""
|
|
268
|
+
text = read_text(path)
|
|
269
|
+
lines = text.splitlines()
|
|
270
|
+
meta = load_frontmatter(lines)
|
|
271
|
+
body = lines
|
|
272
|
+
if lines and lines[0].strip() == "---":
|
|
273
|
+
for i in range(1, min(len(lines), 60)):
|
|
274
|
+
if lines[i].strip() == "---":
|
|
275
|
+
body = lines[i + 1:]
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
title = meta.get("title", "")
|
|
279
|
+
if not title:
|
|
280
|
+
for line in body:
|
|
281
|
+
m = re.match(r"^#\s+(.*)$", line)
|
|
282
|
+
if m:
|
|
283
|
+
title = m.group(1).strip()
|
|
284
|
+
break
|
|
285
|
+
title = title or path.stem
|
|
286
|
+
|
|
287
|
+
status = meta.get("status", "").upper()
|
|
288
|
+
if not status:
|
|
289
|
+
for line in body[:25]:
|
|
290
|
+
m = _STATUS_LINE.match(line.strip())
|
|
291
|
+
if m:
|
|
292
|
+
status = m.group(1).upper()
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
desc = meta.get("description", "")
|
|
296
|
+
if not desc:
|
|
297
|
+
for line in body:
|
|
298
|
+
s = line.strip()
|
|
299
|
+
if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
|
|
300
|
+
continue
|
|
301
|
+
if s.startswith(">"):
|
|
302
|
+
s = s.lstrip(">").strip()
|
|
303
|
+
m = _STATUS_LINE.match(s)
|
|
304
|
+
if m:
|
|
305
|
+
# "Status: X — description": keep the part after the status; if empty, skip
|
|
306
|
+
rest = s[m.end():].strip(" *—–-:.")
|
|
307
|
+
if not rest:
|
|
308
|
+
continue
|
|
309
|
+
s = rest
|
|
310
|
+
if s:
|
|
311
|
+
desc = s
|
|
312
|
+
break
|
|
313
|
+
desc = re.sub(r"\s+", " ", desc).strip()
|
|
314
|
+
if len(desc) > 160:
|
|
315
|
+
desc = desc[:157].rstrip() + "..."
|
|
316
|
+
return title, desc, status, meta.get("supersedes", "").strip()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def list_canonical_docs(root):
|
|
320
|
+
"""[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
|
|
321
|
+
ai = root / "ai_docs"
|
|
322
|
+
out = []
|
|
323
|
+
for d in MANIFEST_DIRS:
|
|
324
|
+
base = ai / d
|
|
325
|
+
if not base.is_dir():
|
|
326
|
+
continue
|
|
327
|
+
for p in sorted(base.rglob("*.md")):
|
|
328
|
+
if p.name in GENERATED_DOCS or p.name == "README.md":
|
|
329
|
+
continue
|
|
330
|
+
out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_manifest(root):
|
|
335
|
+
docs = list_canonical_docs(root)
|
|
336
|
+
lines = [MANIFEST_HEADER,
|
|
337
|
+
"# `ai_docs/` document index (generated)",
|
|
338
|
+
"",
|
|
339
|
+
"Complete manifest of the canonical documents. For the reading priority",
|
|
340
|
+
"(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
|
|
341
|
+
"`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
|
|
342
|
+
"not manifested here."]
|
|
343
|
+
by_dir = {}
|
|
344
|
+
for rel, _, meta in docs:
|
|
345
|
+
by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
|
|
346
|
+
for top in MANIFEST_DIRS:
|
|
347
|
+
rows = by_dir.get(top)
|
|
348
|
+
if not rows:
|
|
349
|
+
continue
|
|
350
|
+
lines += ["", f"## {top}/", "",
|
|
351
|
+
"| Document | Status | Description |", "|---|---|---|"]
|
|
352
|
+
for rel, (title, desc, status, _sup) in rows:
|
|
353
|
+
d = (desc or title).replace("|", "\\|")
|
|
354
|
+
lines.append(f"| `{rel}` | {status or '-'} | {d} |")
|
|
355
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_index(root):
|
|
359
|
+
if not require_ai_docs(root, "index"):
|
|
360
|
+
return 1
|
|
361
|
+
hist = root / "ai_docs" / "strategic" / "features_history.md"
|
|
362
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
hist.write_text(build_index(root), encoding="utf-8")
|
|
364
|
+
print(f"[ok] ANALYSIS index regenerated: {hist}")
|
|
365
|
+
# INDEX.md only if canonical docs exist: no empty manifest on minimal projects
|
|
366
|
+
if list_canonical_docs(root):
|
|
367
|
+
manifest = root / "ai_docs" / "INDEX.md"
|
|
368
|
+
manifest.write_text(build_manifest(root), encoding="utf-8")
|
|
369
|
+
print(f"[ok] document manifest regenerated: {manifest}")
|
|
370
|
+
else:
|
|
371
|
+
print("[info] no canonical documents: INDEX.md not generated")
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ----------------------------------------------------------------- validate
|
|
376
|
+
|
|
377
|
+
def has_section(text, aliases):
|
|
378
|
+
return any(a in text for a in aliases)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cmd_validate(root, strict=False):
|
|
382
|
+
errors, warnings = [], []
|
|
383
|
+
ai = root / "ai_docs"
|
|
384
|
+
if not ai.is_dir():
|
|
385
|
+
if strict:
|
|
386
|
+
print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
|
|
387
|
+
"fails so a wrong working directory cannot produce a green pipeline.")
|
|
388
|
+
return 1
|
|
389
|
+
print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
# Vision: presence and declared state
|
|
393
|
+
for name in VISION_FILES:
|
|
394
|
+
f = ai / "vision" / name
|
|
395
|
+
if not f.is_file():
|
|
396
|
+
warnings.append(f"vision/{name} missing")
|
|
397
|
+
continue
|
|
398
|
+
head = "\n".join(read_text(f).splitlines()[:12])
|
|
399
|
+
m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
|
|
400
|
+
if not m:
|
|
401
|
+
errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
|
|
402
|
+
elif m.group(1) == "DRAFT":
|
|
403
|
+
warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")
|
|
404
|
+
|
|
405
|
+
# ANALYSIS: frontmatter and mandatory sections
|
|
406
|
+
seen_ids = {}
|
|
407
|
+
analyses = list_analyses(root)
|
|
408
|
+
for p, meta, text in analyses:
|
|
409
|
+
rel = "solutions/" + p.name
|
|
410
|
+
if not meta:
|
|
411
|
+
errors.append(f"{rel}: frontmatter missing")
|
|
412
|
+
continue
|
|
413
|
+
fid = meta.get("id")
|
|
414
|
+
if not fid:
|
|
415
|
+
errors.append(f"{rel}: 'id' field missing")
|
|
416
|
+
elif fid in seen_ids:
|
|
417
|
+
errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
|
|
418
|
+
else:
|
|
419
|
+
seen_ids[fid] = rel
|
|
420
|
+
status = meta.get("status", "")
|
|
421
|
+
if status not in VALID_STATES:
|
|
422
|
+
errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
|
|
423
|
+
if not meta.get("start_date"):
|
|
424
|
+
errors.append(f"{rel}: 'start_date' missing")
|
|
425
|
+
if status == "COMPLETED" and not meta.get("end_date"):
|
|
426
|
+
errors.append(f"{rel}: COMPLETED without 'end_date'")
|
|
427
|
+
level = meta.get("level")
|
|
428
|
+
if level and level.upper() not in VALID_LEVELS:
|
|
429
|
+
warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
|
|
430
|
+
if not has_section(text, SECURITY_SECTION):
|
|
431
|
+
errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
|
|
432
|
+
for en, it in ANALYSIS_SECTIONS:
|
|
433
|
+
if not has_section(text, (en, it)):
|
|
434
|
+
warnings.append(f"{rel}: section '{en}' missing")
|
|
435
|
+
|
|
436
|
+
# Generated index aligned
|
|
437
|
+
hist = ai / "strategic" / "features_history.md"
|
|
438
|
+
if analyses:
|
|
439
|
+
if not hist.is_file():
|
|
440
|
+
errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
|
|
441
|
+
elif norm_text(read_text(hist)) != norm_text(build_index(root)):
|
|
442
|
+
errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")
|
|
443
|
+
|
|
444
|
+
# Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
|
|
445
|
+
docs = list_canonical_docs(root)
|
|
446
|
+
manifest = ai / "INDEX.md"
|
|
447
|
+
if docs:
|
|
448
|
+
if not manifest.is_file():
|
|
449
|
+
errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
|
|
450
|
+
elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
|
|
451
|
+
errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")
|
|
452
|
+
|
|
453
|
+
# Canonical document lifecycle: declared status + supersedes coherence
|
|
454
|
+
canon_status = {rel: meta[2] for rel, _, meta in docs}
|
|
455
|
+
for rel, _, (title, desc, status, supersedes) in docs:
|
|
456
|
+
if not status:
|
|
457
|
+
warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
|
|
458
|
+
elif status not in CANONICAL_STATES:
|
|
459
|
+
warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
|
|
460
|
+
if supersedes:
|
|
461
|
+
base = os.path.basename(supersedes)
|
|
462
|
+
for other, ost in canon_status.items():
|
|
463
|
+
if (other == supersedes or other.endswith("/" + supersedes)
|
|
464
|
+
or os.path.basename(other) == base) and ost == "CURRENT":
|
|
465
|
+
warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")
|
|
466
|
+
|
|
467
|
+
# Handoff: header and freshness
|
|
468
|
+
hand = ai / "audit" / "handoff.md"
|
|
469
|
+
if hand.is_file():
|
|
470
|
+
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
471
|
+
if not m:
|
|
472
|
+
warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
|
|
473
|
+
else:
|
|
474
|
+
try:
|
|
475
|
+
stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
476
|
+
age = (datetime.now(timezone.utc) - stamp).days
|
|
477
|
+
if age > 14:
|
|
478
|
+
warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
|
|
479
|
+
except ValueError:
|
|
480
|
+
warnings.append("audit/handoff.md: date not parseable")
|
|
481
|
+
|
|
482
|
+
for w in warnings:
|
|
483
|
+
print(f"[warn] {w}")
|
|
484
|
+
for e in errors:
|
|
485
|
+
print(f"[ERROR] {e}")
|
|
486
|
+
print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
|
|
487
|
+
if strict and warnings and not errors:
|
|
488
|
+
print("[strict] warnings are failures in --strict mode.")
|
|
489
|
+
return 1 if errors or (strict and warnings) else 0
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# ------------------------------------------------------------- audit_plan
|
|
493
|
+
|
|
494
|
+
def parse_audit_plan(root):
|
|
495
|
+
f = root / "ai_docs" / "audit" / "audit_plan.md"
|
|
496
|
+
rows, lines = [], []
|
|
497
|
+
if f.is_file():
|
|
498
|
+
lines = read_text(f).splitlines()
|
|
499
|
+
for i, line in enumerate(lines):
|
|
500
|
+
if not line.strip().startswith("|"):
|
|
501
|
+
continue
|
|
502
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
503
|
+
if len(cells) < 2:
|
|
504
|
+
continue
|
|
505
|
+
if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
|
|
506
|
+
continue
|
|
507
|
+
rows.append({
|
|
508
|
+
"line": i,
|
|
509
|
+
"path": cells[0],
|
|
510
|
+
"status": cells[1].upper(),
|
|
511
|
+
"ref": cells[2] if len(cells) > 2 else "",
|
|
512
|
+
"note": cells[3] if len(cells) > 3 else "",
|
|
513
|
+
})
|
|
514
|
+
return f, lines, rows
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def cmd_stale(root, hybrid=False):
|
|
518
|
+
if hybrid:
|
|
519
|
+
print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
|
|
520
|
+
return 0
|
|
521
|
+
f, _, rows = parse_audit_plan(root)
|
|
522
|
+
if not rows:
|
|
523
|
+
print(f"[info] no rows in {f}: nothing to check "
|
|
524
|
+
"(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
|
|
525
|
+
return 0
|
|
526
|
+
use_git = git_available(root)
|
|
527
|
+
stale = []
|
|
528
|
+
for row in rows:
|
|
529
|
+
if row["status"] != "ANALYZED":
|
|
530
|
+
continue
|
|
531
|
+
rel, ref = row["path"], row["ref"]
|
|
532
|
+
target = root / rel
|
|
533
|
+
if not target.exists():
|
|
534
|
+
print(f"[warn] {rel}: path does not exist")
|
|
535
|
+
continue
|
|
536
|
+
changed = []
|
|
537
|
+
if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
|
|
538
|
+
res = git_changed_since(root, ref, rel.replace("\\", "/"))
|
|
539
|
+
if res is None:
|
|
540
|
+
print(f"[warn] {rel}: git ref '{ref}' unresolvable, cannot evaluate")
|
|
541
|
+
continue
|
|
542
|
+
changed = res
|
|
543
|
+
else:
|
|
544
|
+
ts = parse_iso(ref)
|
|
545
|
+
if ts is None:
|
|
546
|
+
print(f"[warn] {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
|
|
547
|
+
continue
|
|
548
|
+
for fp in iter_files(target):
|
|
549
|
+
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
|
|
550
|
+
if mtime > ts + MTIME_GRACE:
|
|
551
|
+
changed.append(str(fp.relative_to(root)).replace("\\", "/"))
|
|
552
|
+
if changed:
|
|
553
|
+
stale.append((rel, changed))
|
|
554
|
+
|
|
555
|
+
if not stale:
|
|
556
|
+
print("[ok] no analyzed area was modified after its last recorded analysis.")
|
|
557
|
+
return 0
|
|
558
|
+
print("Areas modified after the last recorded analysis:")
|
|
559
|
+
for rel, changed in stale:
|
|
560
|
+
print(f" {rel} ({len(changed)} files)")
|
|
561
|
+
for c in changed[:10]:
|
|
562
|
+
print(f" - {c}")
|
|
563
|
+
if len(changed) > 10:
|
|
564
|
+
print(f" ... and {len(changed) - 10} more")
|
|
565
|
+
print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
|
|
566
|
+
return 1
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def cmd_mark(root, paths):
|
|
570
|
+
if not require_ai_docs(root, "mark"):
|
|
571
|
+
return 1
|
|
572
|
+
f, lines, rows = parse_audit_plan(root)
|
|
573
|
+
use_git_ref = git_available(root) and not any(
|
|
574
|
+
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
575
|
+
)
|
|
576
|
+
ref = git_head(root) if use_git_ref else utc_now_iso()
|
|
577
|
+
by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}
|
|
578
|
+
|
|
579
|
+
if not lines:
|
|
580
|
+
lines = ["# Audit Plan", "",
|
|
581
|
+
"| Path | Status | Reference | Notes |",
|
|
582
|
+
"|---|---|---|---|"]
|
|
583
|
+
rows = []
|
|
584
|
+
|
|
585
|
+
def row_text(path, note):
|
|
586
|
+
return f"| {path} | ANALYZED | {ref} | {note} |"
|
|
587
|
+
|
|
588
|
+
appended = []
|
|
589
|
+
for raw in paths:
|
|
590
|
+
key = raw.replace("\\", "/").rstrip("/")
|
|
591
|
+
display = key + ("/" if (root / key).is_dir() else "")
|
|
592
|
+
existing = by_path.get(key)
|
|
593
|
+
if existing:
|
|
594
|
+
lines[existing["line"]] = row_text(existing["path"], existing["note"])
|
|
595
|
+
print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
|
|
596
|
+
else:
|
|
597
|
+
appended.append(row_text(display, ""))
|
|
598
|
+
print(f"[ok] {display} added as ANALYZED ({ref})")
|
|
599
|
+
|
|
600
|
+
if appended:
|
|
601
|
+
insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
|
|
602
|
+
lines[insert_at:insert_at] = appended
|
|
603
|
+
|
|
604
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
606
|
+
return 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def cmd_check(root, strict=False, hybrid=False):
|
|
610
|
+
print("===== validate =====")
|
|
611
|
+
rc_v = cmd_validate(root, strict=strict)
|
|
612
|
+
print("\n===== stale =====")
|
|
613
|
+
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
614
|
+
print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
|
|
615
|
+
f"(validate rc={rc_v}, stale rc={rc_s})")
|
|
616
|
+
return 1 if (rc_v or rc_s) else 0
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# --------------------------------------------------------------------- gate
|
|
620
|
+
|
|
621
|
+
def cmd_gate(args):
|
|
622
|
+
file_path = args.file or ""
|
|
623
|
+
if args.hook:
|
|
624
|
+
try:
|
|
625
|
+
# bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
|
|
626
|
+
# console code page; '-sig' strips the BOM (PowerShell pipes)
|
|
627
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
628
|
+
payload = json.loads(raw)
|
|
629
|
+
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
630
|
+
except Exception:
|
|
631
|
+
return 0 # unparseable input: do not block
|
|
632
|
+
if not file_path:
|
|
633
|
+
return 0
|
|
634
|
+
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
635
|
+
try:
|
|
636
|
+
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
637
|
+
except ValueError:
|
|
638
|
+
return 0 # outside the project: not this gate's concern
|
|
639
|
+
if rel.startswith(("ai_docs/", "tests/", "test/")):
|
|
640
|
+
return 0
|
|
641
|
+
protected = [p.strip().replace("\\", "/").rstrip("/")
|
|
642
|
+
for p in (args.protected or "").split(";") if p.strip()]
|
|
643
|
+
if not protected:
|
|
644
|
+
return 0
|
|
645
|
+
if not any(rel == p or rel.startswith(p + "/") for p in protected):
|
|
646
|
+
return 0
|
|
647
|
+
for _, meta, _ in list_analyses(root):
|
|
648
|
+
if meta.get("status") == "IN_PROGRESS":
|
|
649
|
+
return 0
|
|
650
|
+
if args.hybrid and has_etdd_shadow(root):
|
|
651
|
+
return 0 # Hybrid design gate: an approved E-TDD shadow authorizes the change
|
|
652
|
+
if args.hybrid:
|
|
653
|
+
sys.stderr.write(
|
|
654
|
+
f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
|
|
655
|
+
"(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
|
|
656
|
+
"In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
|
|
657
|
+
return 2
|
|
658
|
+
sys.stderr.write(
|
|
659
|
+
f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
|
|
660
|
+
"Create or reactivate the analysis (agentic-sdlc Phase 3) before modifying this file.\n")
|
|
661
|
+
return 2
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# --------------------------------------------------------------------- main
|
|
665
|
+
|
|
666
|
+
def main(argv=None):
|
|
667
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
668
|
+
common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")
|
|
669
|
+
|
|
670
|
+
strict_opt = argparse.ArgumentParser(add_help=False)
|
|
671
|
+
strict_opt.add_argument("--strict", action="store_true",
|
|
672
|
+
help="fail on warnings and on missing ai_docs/ (for CI)")
|
|
673
|
+
|
|
674
|
+
hybrid_opt = argparse.ArgumentParser(add_help=False)
|
|
675
|
+
hybrid_opt.add_argument("--hybrid", action="store_true",
|
|
676
|
+
help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
|
|
677
|
+
"the gate also unlocks on an E-TDD shadow")
|
|
678
|
+
|
|
679
|
+
ap = argparse.ArgumentParser(prog="sdlc_check.py",
|
|
680
|
+
description="Mechanical validator for Agentic SDLC")
|
|
681
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
682
|
+
sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
|
|
683
|
+
help="closure gate: validate + stale in one command")
|
|
684
|
+
sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
|
|
685
|
+
sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
|
|
686
|
+
sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
|
|
687
|
+
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
688
|
+
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
689
|
+
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
690
|
+
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
691
|
+
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
692
|
+
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
693
|
+
|
|
694
|
+
args = ap.parse_args(argv)
|
|
695
|
+
if args.cmd == "gate":
|
|
696
|
+
return cmd_gate(args)
|
|
697
|
+
|
|
698
|
+
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
699
|
+
if args.cmd == "check":
|
|
700
|
+
return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
|
|
701
|
+
if args.cmd == "validate":
|
|
702
|
+
return cmd_validate(root, strict=args.strict)
|
|
703
|
+
if args.cmd == "index":
|
|
704
|
+
return cmd_index(root)
|
|
705
|
+
if args.cmd == "stale":
|
|
706
|
+
return cmd_stale(root, hybrid=args.hybrid)
|
|
707
|
+
if args.cmd == "mark":
|
|
708
|
+
return cmd_mark(root, args.paths)
|
|
709
|
+
return 0
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
if __name__ == "__main__":
|
|
713
|
+
sys.exit(main())
|