This commit is contained in:
alessandro barucci
2026-10-26 00:25:18 +01:00
parent 1df884da84
commit 1bc033c329
46 changed files with 2958 additions and 0 deletions
@@ -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())