670 lines
26 KiB
Python
670 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
STS Lab Srl — Spec-Driven Reader · spec_index.py
|
||
Copyright (c) 2026 STS Lab Srl. Tutti i diritti riservati.
|
||
|
||
Strumento della skill «sts-spec-driven» per leggere e scrivere documentazione spec-driven.
|
||
|
||
Lettura (non scrive nulla):
|
||
overview <docs> Che forma ha la documentazione? Quali spec, in che stato?
|
||
find <docs> <ID> Dove è definito un ID e chi lo cita? (REQ-, SPEC-, T-, D-, DIF-)
|
||
context <docs> <ID> Definizione, ID collegati a un passo, righe di TODO e TRACEABILITY.
|
||
check <docs> Coerenza: ID orfani, spec senza criteri/task, task fuori dal TODO,
|
||
spec non approvate con lavoro già fatto, segnaposto da compilare.
|
||
next-id <docs> <TIPO> Prossimo ID libero: REQ (con --area), SPEC, D, DIF, T (con --spec
|
||
o --prefisso).
|
||
|
||
Scrittura (non sovrascrive mai un file esistente):
|
||
scaffold <docs> --layout strutturato|piatto --progetto NOME
|
||
Crea lo scheletro della documentazione dai modelli.
|
||
new-spec <docs> --titolo "…" Crea una nuova spec (strutturato: 03-specs/SPEC-nnn-slug.md;
|
||
piatto: <NOME>_MODULE.md, serve --prefisso).
|
||
|
||
Solo libreria standard Python 3.8+.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import re
|
||
import shutil
|
||
import sys
|
||
import unicodedata
|
||
from collections import defaultdict
|
||
from pathlib import Path
|
||
|
||
BRAND = "STS Lab Srl · Spec-Driven Reader"
|
||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||
TEMPLATES = SKILL_DIR / "templates"
|
||
LAYOUTS = ("strutturato", "piatto")
|
||
|
||
ID_RE = re.compile(
|
||
r"(?<![A-Za-z0-9_-])("
|
||
r"REQ-[A-Z]{2,5}-\d{1,3}[a-z]?"
|
||
r"|SPEC-\d{3}"
|
||
r"|DIF-\d{1,3}"
|
||
r"|D-\d{3}"
|
||
r"|T-[A-Z]{0,2}\d{1,4}[a-z]?"
|
||
r")(?![A-Za-z0-9_])"
|
||
)
|
||
|
||
KEY_DOCS = [
|
||
"00-INDEX.md", "INDEX.md", "README.md",
|
||
"PROJECT_MEMORY.md", "REQUIREMENTS.md", "DECISIONS.md", "TODO.md",
|
||
"TRACEABILITY.md", "ARCHITECTURE.md", "API.md", "openapi.yaml",
|
||
"DATABASE.md", "DATA_MODEL.md", "CHANGELOG.md", "DEVELOPMENT_LOG.md", "ROADMAP.md",
|
||
"GLOSSARY.md", "ACCEPTANCE_CRITERIA.md", "TEST_STRATEGY.md",
|
||
]
|
||
PLACEHOLDER = "DA COMPILARE"
|
||
|
||
|
||
def out(text: str = "") -> None:
|
||
print(text)
|
||
|
||
|
||
# ---------------------------------------------------------------- caricamento
|
||
|
||
def load(docs: Path) -> dict[Path, list[str]]:
|
||
files: dict[Path, list[str]] = {}
|
||
for p in sorted(docs.rglob("*")):
|
||
if p.is_file() and p.suffix.lower() in {".md", ".yaml", ".yml"}:
|
||
try:
|
||
files[p] = p.read_text(encoding="utf-8-sig", errors="replace").splitlines()
|
||
except OSError:
|
||
continue
|
||
return files
|
||
|
||
|
||
def strip_comments(lines: list[str]) -> list[str]:
|
||
"""Svuota i commenti HTML (<!-- … -->, anche su più righe) mantenendo i numeri di riga:
|
||
i segnaposto dei modelli non devono contare come ID."""
|
||
res, inside = [], False
|
||
for line in lines:
|
||
buf, i = "", 0
|
||
while i < len(line):
|
||
if inside:
|
||
j = line.find("-->", i)
|
||
if j < 0:
|
||
i = len(line)
|
||
else:
|
||
inside, i = False, j + 3
|
||
else:
|
||
j = line.find("<!--", i)
|
||
if j < 0:
|
||
buf += line[i:]
|
||
i = len(line)
|
||
else:
|
||
buf += line[i:j]
|
||
inside, i = True, j + 4
|
||
res.append(buf)
|
||
return res
|
||
|
||
|
||
def rel(p: Path, docs: Path) -> str:
|
||
return p.relative_to(docs).as_posix()
|
||
|
||
|
||
def kind(id_: str) -> str:
|
||
for prefix in ("REQ", "SPEC", "DIF"):
|
||
if id_.startswith(prefix + "-"):
|
||
return prefix
|
||
return id_.split("-")[0]
|
||
|
||
|
||
def is_definition(line: str, id_: str, path: Path) -> bool:
|
||
"""Una riga *definisce* l'ID se è un titolo che inizia con l'ID o una riga di
|
||
tabella il cui primo campo è l'ID (anche in **grassetto** o ~~barrato~~)."""
|
||
s = line.strip()
|
||
if s.startswith("#"):
|
||
# solo titoli Markdown che *iniziano* con l'ID ("## D-014 — …", "# SPEC-004 — …")
|
||
return path.suffix.lower() == ".md" and re.match(r"#+\s+\**" + re.escape(id_) + r"(?![0-9A-Za-z])", s) is not None
|
||
if s.startswith("|"):
|
||
first = s.strip("|").split("|")[0]
|
||
first = re.sub(r"[*~`\[\]]", "", first).strip()
|
||
# [D-001](#ancora) -> "D-001(#ancora)" : prendiamo il primo token
|
||
first = re.split(r"[\s(]", first)[0] if first else first
|
||
return first == id_
|
||
return False
|
||
|
||
|
||
def scan(files: dict[Path, list[str]], docs: Path):
|
||
defs: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
|
||
refs: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
|
||
for p, raw in files.items():
|
||
r = rel(p, docs)
|
||
lines = strip_comments(raw) if p.suffix.lower() == ".md" else raw
|
||
m = re.match(r"(SPEC-\d{3})", p.name)
|
||
if m:
|
||
defs[m.group(1)].append((r, 1, raw[0] if raw else p.name))
|
||
for n, line in enumerate(lines, 1):
|
||
for id_ in set(ID_RE.findall(line)):
|
||
if is_definition(line, id_, p):
|
||
defs[id_].append((r, n, line.strip()))
|
||
else:
|
||
refs[id_].append((r, n, line.strip()))
|
||
return defs, refs
|
||
|
||
|
||
def detect_layout(docs: Path, files: dict[Path, list[str]] | None = None) -> str:
|
||
if any(d.is_dir() and re.match(r"\d{2}-", d.name) for d in docs.iterdir()):
|
||
return "strutturato"
|
||
if (docs / "03-specs").exists() or any(re.match(r"SPEC-\d{3}", p.name) for p in (files or {})):
|
||
return "strutturato"
|
||
return "piatto"
|
||
|
||
|
||
def spec_status(lines: list[str]) -> str:
|
||
for line in lines[:12]:
|
||
m = re.match(r"\s*Stato\s*:\s*(.+)", line)
|
||
if m:
|
||
return re.sub(r"[*`]", "", m.group(1)).strip()
|
||
return "— (riga «Stato:» assente)"
|
||
|
||
|
||
def index_status(files: dict[Path, list[str]], spec_id: str) -> str:
|
||
"""Ripiego: lo stato della spec letto dalla sua riga nell'indice (ultima cella)."""
|
||
for p, lines in files.items():
|
||
if p.name in ("00-INDEX.md", "INDEX.md", "README.md"):
|
||
for line in strip_comments(lines):
|
||
if line.lstrip().startswith("|") and spec_id in line:
|
||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||
if cells:
|
||
return "dall'indice: " + re.sub(r"[*`]", "", cells[-1])
|
||
return ""
|
||
|
||
|
||
def status_flag(st: str) -> str:
|
||
if re.search(r"da approvare|bozza|proposta|proposed|draft", st, re.I):
|
||
return " <-- NON approvata: non implementare"
|
||
if re.search(r"approvat|✅|accettat|accepted", st, re.I):
|
||
return ""
|
||
return " <-- stato incerto: verificare prima di implementare"
|
||
|
||
|
||
def is_approved(st: str) -> bool:
|
||
return status_flag(st) == ""
|
||
|
||
|
||
def last_update(lines: list[str]) -> str:
|
||
for line in lines[:12]:
|
||
m = re.search(r"Ultimo aggiornamento\s*:\s*\**([0-9]{4}-[0-9]{2}-[0-9]{2})", line)
|
||
if m:
|
||
return m.group(1)
|
||
return ""
|
||
|
||
|
||
def spec_docs(files: dict[Path, list[str]], layout: str) -> list[Path]:
|
||
"""Spec del layout strutturato (SPEC-nnn-*.md) o documenti di modulo del layout piatto
|
||
(qualunque .md non chiave con una riga «Stato:» in testa)."""
|
||
res = []
|
||
for p, lines in files.items():
|
||
if p.suffix.lower() != ".md":
|
||
continue
|
||
if re.match(r"SPEC-\d{3}", p.name):
|
||
res.append(p)
|
||
elif layout == "piatto" and p.name not in KEY_DOCS and any(re.match(r"\s*Stato\s*:", l) for l in lines[:12]):
|
||
res.append(p)
|
||
return sorted(res)
|
||
|
||
|
||
RANGE_RE = re.compile(r"REQ-([A-Z]{2,5})-(\d{1,3})`?\s*(?:…|\.\.\.|–)\s*`?(?:REQ-[A-Z]{2,5}-)?(\d{1,3})")
|
||
SHORT_RE = re.compile(r"(?<![A-Za-z0-9-])([A-Z]{2,5})-(\d{2,3})(?![0-9])")
|
||
|
||
|
||
def covered_reqs(spec_texts: list[str], trace_lines: list[str]) -> set[str]:
|
||
"""Requisiti coperti: citati in una spec (anche come intervallo «REQ-X-01 … 05») o su una
|
||
riga di TRACEABILITY che nomina una spec (anche in forma breve «PRI-04»)."""
|
||
cov: set[str] = set()
|
||
for t in spec_texts:
|
||
cov |= {i for i in ID_RE.findall(t) if kind(i) == "REQ"}
|
||
for area, a, b in RANGE_RE.findall(t):
|
||
cov |= {"REQ-%s-%02d" % (area, k) for k in range(int(a), int(b) + 1)}
|
||
for line in trace_lines:
|
||
if "SPEC-" in line:
|
||
cov |= {i for i in ID_RE.findall(line) if kind(i) == "REQ"}
|
||
cov |= {"REQ-%s-%s" % (a, n) for a, n in SHORT_RE.findall(line)
|
||
if a not in ("SPEC", "REQ", "DIF")}
|
||
return cov
|
||
|
||
|
||
# ---------------------------------------------------------------- lettura
|
||
|
||
def cmd_overview(docs: Path) -> None:
|
||
files = load(docs)
|
||
defs, refs = scan(files, docs)
|
||
out(f"== {BRAND} ==")
|
||
out(f"Cartella : {docs}")
|
||
out(f"Layout : {detect_layout(docs, files)}")
|
||
out(f"File : {len(files)} (md/yaml)")
|
||
out()
|
||
out("-- Documenti chiave (ordine di lettura consigliato) --")
|
||
by_name = defaultdict(list)
|
||
for p in files:
|
||
by_name[p.name].append(p)
|
||
for name in KEY_DOCS:
|
||
for p in by_name.get(name, []):
|
||
lines = files[p]
|
||
upd = last_update(lines)
|
||
out(f" {rel(p, docs):45s} {len(lines):6d} righe {('agg. ' + upd) if upd else ''}")
|
||
big = [(p, len(l)) for p, l in files.items() if len(l) > 800]
|
||
if big:
|
||
out()
|
||
out("-- File grandi: NON leggerli interi, cerca la sezione per ID/data --")
|
||
for p, n in sorted(big, key=lambda x: -x[1]):
|
||
out(f" {rel(p, docs):45s} {n:6d} righe")
|
||
specs = spec_docs(files, detect_layout(docs, files))
|
||
if specs:
|
||
out()
|
||
out("-- Specifiche / documenti di modulo --")
|
||
for p in specs:
|
||
st = spec_status(files[p])
|
||
if st.startswith("—"):
|
||
st = index_status(files, p.stem[:8]) or st
|
||
out(f" {rel(p, docs):45s} {st}{status_flag(st)}")
|
||
todo = [(p, sum(PLACEHOLDER in l for l in ls)) for p, ls in files.items()]
|
||
todo = [(p, n) for p, n in todo if n]
|
||
if todo:
|
||
out()
|
||
out(f"-- Segnaposto «{PLACEHOLDER}» ancora presenti ({sum(n for _, n in todo)}) --")
|
||
for p, n in sorted(todo, key=lambda x: rel(x[0], docs)):
|
||
out(f" {rel(p, docs):45s} {n:4d}")
|
||
out()
|
||
out("-- ID trovati (definiti / citati) --")
|
||
counts = defaultdict(lambda: [0, 0])
|
||
for id_ in set(defs) | set(refs):
|
||
k = kind(id_)
|
||
counts[k][0] += 1 if id_ in defs else 0
|
||
counts[k][1] += 1
|
||
for k in ("REQ", "SPEC", "T", "D", "DIF"):
|
||
if k in counts:
|
||
out(f" {k:5s} {counts[k][0]:5d} definiti su {counts[k][1]:5d} distinti")
|
||
orphans = sorted(i for i in refs if i not in defs and kind(i) in {"REQ", "SPEC", "D"})
|
||
if orphans:
|
||
out()
|
||
out(f"-- Citati ma mai definiti ({len(orphans)}) — possibile deriva della documentazione --")
|
||
out(" " + ", ".join(orphans[:60]) + (" …" if len(orphans) > 60 else ""))
|
||
|
||
|
||
def cmd_find(docs: Path, id_: str, limit: int) -> None:
|
||
files = load(docs)
|
||
defs, refs = scan(files, docs)
|
||
out(f"== {BRAND} · {id_} ==")
|
||
if not defs.get(id_):
|
||
out("Definizione: NON TROVATA (ID inesistente, rinumerato o definito con formato insolito)")
|
||
else:
|
||
out("Definizione:")
|
||
for r, n, line in defs[id_]:
|
||
out(f" {r}:{n} {line[:220]}")
|
||
rs = refs.get(id_, [])
|
||
out(f"Citazioni: {len(rs)}")
|
||
by_file = defaultdict(list)
|
||
for r, n, line in rs:
|
||
by_file[r].append((n, line))
|
||
for r, items in sorted(by_file.items(), key=lambda x: -len(x[1])):
|
||
out(f" {r} ({len(items)})")
|
||
for n, line in items[:limit]:
|
||
out(f" :{n} {line[:180]}")
|
||
if len(items) > limit:
|
||
out(f" … altre {len(items) - limit}")
|
||
|
||
|
||
def section(lines: list[str], start: int, max_lines: int = 60) -> list[str]:
|
||
"""Dal titolo alla riga 'start' (1-based) fino al prossimo titolo di pari o superiore livello."""
|
||
head = lines[start - 1]
|
||
if not head.lstrip().startswith("#"):
|
||
return [head]
|
||
level = len(head) - len(head.lstrip("#"))
|
||
body = [head]
|
||
for line in lines[start:]:
|
||
s = line.lstrip()
|
||
if s.startswith("#") and (len(s) - len(s.lstrip("#"))) <= level:
|
||
break
|
||
body.append(line)
|
||
if len(body) >= max_lines:
|
||
body.append("… (sezione troncata: aprire il file per il resto)")
|
||
break
|
||
return body
|
||
|
||
|
||
def cmd_context(docs: Path, id_: str) -> None:
|
||
files = load(docs)
|
||
defs, refs = scan(files, docs)
|
||
by_rel = {rel(p, docs): l for p, l in files.items()}
|
||
out(f"== {BRAND} · contesto di {id_} ==")
|
||
|
||
if id_.startswith("SPEC-"):
|
||
spec_file = next((p for p in files if p.name.startswith(id_)), None)
|
||
if spec_file is None:
|
||
out("Spec non trovata.")
|
||
return
|
||
lines = files[spec_file]
|
||
out(f"File : {rel(spec_file, docs)} ({len(lines)} righe)")
|
||
out(f"Stato : {spec_status(lines)}")
|
||
out()
|
||
out("Indice delle sezioni:")
|
||
for n, line in enumerate(lines, 1):
|
||
if line.startswith("## ") or line.startswith("### "):
|
||
out(f" :{n} {line}")
|
||
linked = sorted({i for line in strip_comments(lines) for i in ID_RE.findall(line)} - {id_})
|
||
else:
|
||
linked = set()
|
||
for r, n, line in defs.get(id_, []):
|
||
out(f"Definizione: {r}:{n}")
|
||
body = section(by_rel[r], n) if line.startswith("#") else [line]
|
||
for b in body:
|
||
out(" " + b)
|
||
for b in strip_comments(body):
|
||
linked |= set(ID_RE.findall(b))
|
||
if not defs.get(id_):
|
||
out("Definizione: NON TROVATA")
|
||
for r, n, line in refs.get(id_, []):
|
||
if "SPEC-" in Path(r).name:
|
||
linked.add(Path(r).name[:8])
|
||
linked = sorted(linked - {id_})
|
||
|
||
out()
|
||
out("ID collegati (un passo):")
|
||
groups = defaultdict(list)
|
||
for i in linked:
|
||
groups[kind(i)].append(i)
|
||
for k in ("REQ", "SPEC", "T", "D", "DIF"):
|
||
if groups.get(k):
|
||
out(f" {k:5s} " + ", ".join(groups[k]))
|
||
|
||
out()
|
||
out("Definizioni sintetiche dei collegati (righe di tabella / titoli):")
|
||
for i in linked:
|
||
for r, n, line in defs.get(i, [])[:1]:
|
||
out(f" [{i}] {r}:{n} {line[:200]}")
|
||
|
||
for target in ("TODO.md", "TRACEABILITY.md"):
|
||
hits = [(r, n, l) for r, n, l in refs.get(id_, []) + defs.get(id_, []) if Path(r).name == target]
|
||
if hits:
|
||
out()
|
||
out(f"Righe in {target}:")
|
||
for r, n, line in hits[:25]:
|
||
out(f" {r}:{n} {line[:220]}")
|
||
|
||
|
||
def cmd_check(docs: Path) -> int:
|
||
files = load(docs)
|
||
defs, refs = scan(files, docs)
|
||
issues: list[tuple[str, str]] = []
|
||
|
||
for i in sorted(refs):
|
||
if i not in defs and kind(i) in {"REQ", "SPEC", "D", "DIF"}:
|
||
issues.append(("ID orfano", f"{i} è citato ma non definito (es. {refs[i][0][0]}:{refs[i][0][1]})"))
|
||
|
||
specs = spec_docs(files, detect_layout(docs, files))
|
||
spec_text = {p: "\n".join(strip_comments(files[p])) for p in specs}
|
||
for p, text in spec_text.items():
|
||
r = rel(p, docs)
|
||
if not re.search(r"^#{2,3}\s.*criteri", text, re.I | re.M):
|
||
issues.append(("Spec incompleta", f"{r}: manca la sezione «Criteri di accettazione»"))
|
||
if not re.search(r"^#{2,3}\s.*task", text, re.I | re.M):
|
||
issues.append(("Spec incompleta", f"{r}: manca la sezione «Task»"))
|
||
if re.search(r"^#{2,3}\s.*domande ancora aperte", text, re.I | re.M):
|
||
tail = text.split("omande ancora aperte", 1)[1]
|
||
body = [l for l in tail.splitlines()[1:] if l.strip() and not l.startswith("#")]
|
||
if body and not re.search(r"nessuna", " ".join(body), re.I):
|
||
issues.append(("Spec non approvabile", f"{r}: «Domande ancora aperte» non vuota"))
|
||
|
||
trace = [l for p, ls in files.items() if p.name == "TRACEABILITY.md" for l in strip_comments(ls)]
|
||
req_in_specs = covered_reqs(list(spec_text.values()), trace)
|
||
req_defined = {i for i in defs if kind(i) == "REQ"}
|
||
for i in sorted(req_defined - req_in_specs):
|
||
if specs:
|
||
issues.append(("Requisito scoperto", f"{i} non è coperto da nessuna spec"))
|
||
|
||
todo_files = [p for p in files if p.name == "TODO.md"]
|
||
todo_text = "\n".join("\n".join(strip_comments(files[p])) for p in todo_files)
|
||
if todo_files:
|
||
for p, text in spec_text.items():
|
||
for t in sorted({i for i in ID_RE.findall(text) if kind(i) == "T"}):
|
||
if any(d[0] == rel(p, docs) for d in defs.get(t, [])) and t not in todo_text:
|
||
issues.append(("Task fuori dal TODO", f"{t} definito in {rel(p, docs)} ma assente dal TODO"))
|
||
|
||
for p in specs:
|
||
st = spec_status(files[p])
|
||
if st.startswith("—"):
|
||
st = index_status(files, p.stem[:8]) or st
|
||
if is_approved(st):
|
||
continue
|
||
tasks = {i for i in ID_RE.findall(spec_text[p]) if kind(i) == "T"}
|
||
done = []
|
||
for line in todo_text.splitlines():
|
||
ids = set(ID_RE.findall(line))
|
||
if ids & tasks and re.search(r"✅|\bdone\b", line):
|
||
done.extend(sorted(ids & tasks))
|
||
if done:
|
||
issues.append(("Deriva di stato", f"{rel(p, docs)} è «{st}» ma ha task fatti: {', '.join(sorted(set(done))[:8])}"))
|
||
|
||
ph = [(rel(p, docs), sum(PLACEHOLDER in l for l in ls)) for p, ls in files.items()]
|
||
for r, n in sorted(ph):
|
||
if n:
|
||
issues.append(("Da compilare", f"{r}: {n} segnaposto «{PLACEHOLDER}»"))
|
||
|
||
out(f"== {BRAND} · controllo di coerenza ==")
|
||
out(f"Cartella: {docs}")
|
||
if not issues:
|
||
out("Nessun problema rilevato.")
|
||
return 0
|
||
by_kind = defaultdict(list)
|
||
for k, msg in issues:
|
||
by_kind[k].append(msg)
|
||
for k in ("ID orfano", "Requisito scoperto", "Spec incompleta", "Spec non approvabile",
|
||
"Task fuori dal TODO", "Deriva di stato", "Da compilare"):
|
||
if by_kind.get(k):
|
||
out()
|
||
out(f"-- {k} ({len(by_kind[k])}) --")
|
||
for msg in by_kind[k][:40]:
|
||
out(" " + msg)
|
||
if len(by_kind[k]) > 40:
|
||
out(f" … altri {len(by_kind[k]) - 40}")
|
||
return 1
|
||
|
||
|
||
# ---------------------------------------------------------------- numerazione
|
||
|
||
def _max_num(ids, pattern: str) -> int:
|
||
nums = [int(m.group(1)) for i in ids for m in [re.fullmatch(pattern, i)] if m]
|
||
return max(nums, default=0)
|
||
|
||
|
||
def next_id(docs: Path, tipo: str, area: str | None = None, spec: str | None = None,
|
||
prefisso: str | None = None) -> str:
|
||
files = load(docs) if docs.exists() else {}
|
||
defs, refs = scan(files, docs) if files else ({}, {})
|
||
known = set(defs) | set(refs)
|
||
for p in files:
|
||
m = re.match(r"(SPEC-\d{3})", p.name)
|
||
if m:
|
||
known.add(m.group(1))
|
||
tipo = tipo.upper()
|
||
if tipo == "SPEC":
|
||
return "SPEC-%03d" % (_max_num(known, r"SPEC-(\d{3})") + 1)
|
||
if tipo == "D":
|
||
return "D-%03d" % (_max_num(known, r"D-(\d{3})") + 1)
|
||
if tipo == "DIF":
|
||
return "DIF-%02d" % (_max_num(known, r"DIF-(\d{1,3})") + 1)
|
||
if tipo == "REQ":
|
||
if not area:
|
||
raise SystemExit("next-id REQ richiede --area (es. --area AUT)")
|
||
a = area.upper()
|
||
return "REQ-%s-%02d" % (a, _max_num(known, r"REQ-" + a + r"-(\d{1,3})") + 1)
|
||
if tipo == "T":
|
||
if prefisso:
|
||
pf = prefisso.upper()
|
||
return "T-%s%02d" % (pf, _max_num(known, r"T-" + pf + r"(\d{1,4})") + 1)
|
||
if spec:
|
||
n = int(re.sub(r"\D", "", spec))
|
||
block = [i for i in known if re.fullmatch(r"T-\d{3}", i) and int(i[2:]) // 10 == n]
|
||
if not block:
|
||
return f"T-{n * 10:03d}"
|
||
last = max(int(i[2:]) for i in block)
|
||
if last % 10 < 9:
|
||
return f"T-{last + 1:03d}"
|
||
sfx = [i for i in known if re.fullmatch("T-%03d[b-z]" % last, i)]
|
||
return f"T-{last:03d}{chr(ord(max(sfx)[-1]) + 1) if sfx else 'b'}"
|
||
return "T-%03d" % (_max_num(known, r"T-(\d{3})") + 1)
|
||
raise SystemExit(f"Tipo sconosciuto: {tipo} (REQ, SPEC, T, D, DIF)")
|
||
|
||
|
||
# ---------------------------------------------------------------- scrittura
|
||
|
||
def fill(text: str, values: dict[str, str]) -> str:
|
||
for k, v in values.items():
|
||
text = text.replace("{{" + k + "}}", v)
|
||
return text
|
||
|
||
|
||
def slugify(s: str) -> str:
|
||
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
|
||
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
|
||
|
||
|
||
def cmd_scaffold(docs: Path, layout: str, progetto: str, data: str) -> int:
|
||
src = TEMPLATES / layout
|
||
if not src.is_dir():
|
||
raise SystemExit(f"Modelli non trovati: {src}")
|
||
created, skipped = [], []
|
||
for t in sorted(src.rglob("*")):
|
||
if t.is_dir():
|
||
continue
|
||
dest = docs / t.relative_to(src)
|
||
if dest.exists():
|
||
skipped.append(dest)
|
||
continue
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
text = t.read_text(encoding="utf-8")
|
||
dest.write_text(fill(text, {"PROGETTO": progetto, "DATA": data}), encoding="utf-8")
|
||
created.append(dest)
|
||
if layout == "strutturato":
|
||
(docs / "03-specs").mkdir(parents=True, exist_ok=True)
|
||
out(f"== {BRAND} · scaffold «{layout}» ==")
|
||
out(f"Cartella: {docs}")
|
||
out(f"Creati : {len(created)}")
|
||
for p in created:
|
||
out(f" + {rel(p, docs)}")
|
||
if skipped:
|
||
out(f"Già esistenti, NON toccati: {len(skipped)}")
|
||
for p in skipped:
|
||
out(f" = {rel(p, docs)}")
|
||
out()
|
||
out("Prossimo passo: compilare i segnaposto «DA COMPILARE» seguendo references/scrittura.md,")
|
||
out("poi `check` per verificare la coerenza.")
|
||
return 0
|
||
|
||
|
||
def criterion_letter(n: int) -> str:
|
||
return chr(64 + n) if 1 <= n <= 26 else f"S{n}"
|
||
|
||
|
||
def cmd_new_spec(docs: Path, titolo: str, data: str, slug: str | None, prefisso: str | None,
|
||
layout: str | None) -> int:
|
||
files = load(docs) if docs.exists() else {}
|
||
layout = layout or detect_layout(docs, files)
|
||
if layout == "strutturato":
|
||
sid = next_id(docs, "SPEC")
|
||
n = int(sid[5:])
|
||
slug = slug or slugify(titolo)
|
||
dest = docs / "03-specs" / f"{sid}-{slug}.md"
|
||
values = {"SPEC_ID": sid, "TITOLO": titolo, "DATA": data,
|
||
"LETTERA": criterion_letter(n),
|
||
"T0": f"T-{n * 10:03d}", "T1": f"T-{n * 10 + 1:03d}"}
|
||
tpl = TEMPLATES / "spec.md"
|
||
else:
|
||
if not prefisso:
|
||
raise SystemExit("Nel layout piatto serve --prefisso (lettera dei task del modulo, es. W)")
|
||
pf = prefisso.upper()
|
||
used = {i for p, ls in files.items() for l in strip_comments(ls) for i in ID_RE.findall(l)
|
||
if re.fullmatch(r"T-" + pf + r"\d+[a-z]?", i)}
|
||
if used:
|
||
raise SystemExit(f"Il prefisso T-{pf} è già usato ({', '.join(sorted(used)[:5])}): sceglierne un altro.")
|
||
name = (slug or slugify(titolo)).upper().replace("-", "_")
|
||
if not name.endswith("_MODULE"):
|
||
name += "_MODULE"
|
||
dest = docs / f"{name}.md"
|
||
values = {"TITOLO": titolo, "DATA": data, "PREFISSO": pf}
|
||
tpl = TEMPLATES / "modulo.md"
|
||
if dest.exists():
|
||
raise SystemExit(f"Esiste già: {dest} — non lo sovrascrivo.")
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
dest.write_text(fill(tpl.read_text(encoding="utf-8"), values), encoding="utf-8")
|
||
out(f"== {BRAND} · nuova spec ==")
|
||
out(f"Creata: {dest}")
|
||
out("Stato : da approvare")
|
||
if layout == "strutturato":
|
||
out(f"Task : blocco {values['T0']}…T-{n * 10 + 9:03d} · criteri {values['LETTERA']}1…")
|
||
out("Ricorda: riga nella mappa di 00-INDEX §03, task in TODO, righe in TRACEABILITY.")
|
||
else:
|
||
out("Ricorda: riga in PROJECT_MEMORY §4, lettera del modulo e task in TODO.")
|
||
return 0
|
||
|
||
|
||
# ---------------------------------------------------------------- main
|
||
|
||
def main() -> int:
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
today = dt.date.today().isoformat()
|
||
ap = argparse.ArgumentParser(description=f"{BRAND} — documentazione spec-driven")
|
||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
o = sub.add_parser("overview")
|
||
o.add_argument("docs")
|
||
f = sub.add_parser("find")
|
||
f.add_argument("docs")
|
||
f.add_argument("id")
|
||
f.add_argument("--limit", type=int, default=8)
|
||
c = sub.add_parser("context")
|
||
c.add_argument("docs")
|
||
c.add_argument("id")
|
||
k = sub.add_parser("check")
|
||
k.add_argument("docs")
|
||
n = sub.add_parser("next-id")
|
||
n.add_argument("docs")
|
||
n.add_argument("tipo", help="REQ | SPEC | T | D | DIF")
|
||
n.add_argument("--area")
|
||
n.add_argument("--spec")
|
||
n.add_argument("--prefisso")
|
||
s = sub.add_parser("scaffold")
|
||
s.add_argument("docs")
|
||
s.add_argument("--layout", choices=LAYOUTS, required=True)
|
||
s.add_argument("--progetto", required=True)
|
||
s.add_argument("--data", default=today)
|
||
ns = sub.add_parser("new-spec")
|
||
ns.add_argument("docs")
|
||
ns.add_argument("--titolo", required=True)
|
||
ns.add_argument("--slug")
|
||
ns.add_argument("--prefisso", help="solo layout piatto: lettera dei task del modulo")
|
||
ns.add_argument("--layout", choices=LAYOUTS)
|
||
ns.add_argument("--data", default=today)
|
||
a = ap.parse_args()
|
||
docs = Path(a.docs).resolve()
|
||
|
||
if a.cmd == "scaffold":
|
||
return cmd_scaffold(docs, a.layout, a.progetto, a.data)
|
||
if a.cmd == "new-spec":
|
||
return cmd_new_spec(docs, a.titolo, a.data, a.slug, a.prefisso, a.layout)
|
||
if not docs.is_dir():
|
||
print(f"Cartella non trovata: {docs}", file=sys.stderr)
|
||
return 2
|
||
if a.cmd == "overview":
|
||
cmd_overview(docs)
|
||
elif a.cmd == "find":
|
||
cmd_find(docs, a.id, a.limit)
|
||
elif a.cmd == "context":
|
||
cmd_context(docs, a.id)
|
||
elif a.cmd == "check":
|
||
return cmd_check(docs)
|
||
elif a.cmd == "next-id":
|
||
out(next_id(docs, a.tipo, a.area, a.spec, a.prefisso))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|