Files
italiadatacenter/add-on/AgentPlatform/sts-spec-driven/scripts/spec_index.py
T
2026-10-26 00:25:18 +01:00

670 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())