ff
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user