From f9c6263662a7b0ee79187c5a74f74a54192ef294 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 15 Nov 2026 18:55:46 +0100 Subject: [PATCH] Grafo: collega documenti e codice, esclude .claude/ scripts/graphify-link-docs.ts aggiunge gli archi references dai percorsi e dai simboli citati nei .md ai file e ai simboli del codice. .graphifyignore toglie le skill dal grafo. Documentato in docs/GRAFO.md. Co-Authored-By: Claude Sonnet 5.5 --- .graphifyignore | 2 + docs/GRAFO.md | 16 ++++ scripts/graphify-link-docs.ts | 109 +++++++++++++++++++++++++++ test/unit/graphify-link-docs.test.ts | 71 +++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 .graphifyignore create mode 100644 scripts/graphify-link-docs.ts create mode 100644 test/unit/graphify-link-docs.test.ts diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 0000000..e841031 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,2 @@ +# Skill e impostazioni di Claude Code: non sono documentazione del progetto +.claude/ diff --git a/docs/GRAFO.md b/docs/GRAFO.md index 5870c8e..575e2ea 100644 --- a/docs/GRAFO.md +++ b/docs/GRAFO.md @@ -50,6 +50,22 @@ In Claude Code: `/graphify query "..."`, oppure leggere `graphify-out/GRAPH_REPO Il grafo riporta il commit da cui è stato costruito (`GRAPH_REPORT.md`, sezione «Graph Freshness»): confrontalo con `git rev-parse HEAD` per sapere se è vecchio. +## Collegamento documenti ↔ codice + +L'estrazione semantica dei documenti crea pochissimi archi verso il codice, anche quando il +documento cita il file. Lo script [scripts/graphify-link-docs.ts](../scripts/graphify-link-docs.ts) +li aggiunge in modo deterministico: per ogni percorso citato nei `.md` (`src/lib/rosa.ts`) +crea l'arco `references` dal documento al file, e ai simboli citati tra backtick (`useIo`) se +stanno in quel file. Sono archi EXTRACTED: il percorso è scritto nel testo. + +```bash +bun scripts/graphify-link-docs.ts # idempotente, gratis +``` + +Va rilanciato dopo ogni `/graphify . --update`, perché rielaborare un documento ne cancella +gli archi. Sopravvive invece a `graphify update .`. Un documento che cita un file solo per nome +(`rosa.ts`, senza `src/lib/`) non viene collegato: scrivi il percorso completo. + Nota: `graphify update .` su un grafo già completo conserva i nodi semantici dei documenti (verificato). I `.md` nuovi o modificati entrano però solo come struttura (titoli e sezioni): per l'estrazione semantica serve `/graphify . --update`, che rielabora solo quei file. diff --git a/scripts/graphify-link-docs.ts b/scripts/graphify-link-docs.ts new file mode 100644 index 0000000..3772ec5 --- /dev/null +++ b/scripts/graphify-link-docs.ts @@ -0,0 +1,109 @@ +/** + * Collega i documenti al codice nel grafo graphify: dove un `.md` cita un file (`src/lib/rosa.ts`) + * aggiunge l'arco `references` dal documento al file, e dal documento ai simboli che cita in + * backtick (`useIo`) se stanno in uno dei file citati. Gli archi sono EXTRACTED: il percorso è + * scritto nel testo. Idempotente: rimuove i propri archi e li rifà. Va rilanciato dopo ogni + * `/graphify . --update` (rielabora i documenti e perde gli archi di quelli cambiati). + * + * bun scripts/graphify-link-docs.ts # aggiorna graphify-out/graph.json + */ +import { readFileSync, readdirSync, writeFileSync, existsSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const MARCA = "graphify-link-docs"; + +type Nodo = { id: string; label?: string; source_file?: string; source_location?: string }; +type Arco = Record & { source: string; target: string; _via?: string }; +type Grafo = { nodes: Nodo[]; links: Arco[] } & Record; + +/** Percorsi di file del repo citati nel testo, con la prima riga in cui compaiono. */ +export function estraiPercorsi(testo: string): Map { + const trovati = new Map(); + const re = /\b(?:src|test|supabase|mailer|public)\/[\w./$@-]+?\.(?:tsx?|mjs|js|sql)\b/g; + testo.split("\n").forEach((riga, i) => { + for (const m of riga.matchAll(re)) if (!trovati.has(m[0])) trovati.set(m[0], i + 1); + }); + return trovati; +} + +/** Identificatori citati tra backtick (`useIo`, `inRosa()`), senza parentesi. */ +export function estraiSimboli(testo: string): Set { + const s = new Set(); + for (const m of testo.matchAll(/`([A-Za-z_$][\w$]*)(?:\(\))?`/g)) s.add(m[1] as string); + return s; +} + +/** Id del nodo-documento, come lo costruisce graphify: percorso senza estensione, in minuscolo. */ +export function idDocumento(percorso: string): string { + return percorso + .replace(/\.[^./]+$/, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); +} + +/** Archi `references` da un documento ai file e ai simboli di codice che cita. */ +export function archiDocumento(grafo: Grafo, doc: string, testo: string): Arco[] { + const idDoc = idDocumento(doc); + if (!grafo.nodes.some((n) => n.id === idDoc)) return []; + const archi: Arco[] = []; + const base = (target: string, riga: number): Arco => ({ + source: idDoc, + target, + relation: "references", + confidence: "EXTRACTED", + confidence_score: 1.0, + weight: 1.0, + _origin: "semantic", + _via: MARCA, + source_file: doc, + source_location: `L${riga}`, + }); + const simboli = estraiSimboli(testo); + for (const [percorso, riga] of estraiPercorsi(testo)) { + const file = grafo.nodes.find((n) => n.source_file === percorso && n.source_location === "L1"); + if (!file) continue; + archi.push(base(file.id, riga)); + for (const n of grafo.nodes) { + if (n.source_file !== percorso || n.id === file.id) continue; + if (n.label && simboli.has(n.label.replace(/\(\)$/, ""))) archi.push(base(n.id, riga)); + } + } + return archi; +} + +function documenti(dir: string, fuori: string[] = []): string[] { + return readdirSync(dir).flatMap((nome) => { + const p = join(dir, nome); + if (fuori.some((f) => p.startsWith(f))) return []; + return statSync(p).isDirectory() ? documenti(p, fuori) : p.endsWith(".md") ? [p] : []; + }); +} + +if (import.meta.main) { + const file = "graphify-out/graph.json"; + if (!existsSync(file)) + throw new Error("graphify-out/graph.json assente: lancia `graphify update .`"); + const grafo: Grafo = JSON.parse(readFileSync(file, "utf8")); + grafo.links = grafo.links.filter((a) => a._via !== MARCA); + const gia = new Set(grafo.links.map((a) => `${a.source}>${a.target}`)); + const elenco = [ + ...documenti("docs", ["docs/archive"]), + "AGENTS.md", + "PROJECT_STATE.md", + "README.md", + ]; + let aggiunti = 0; + let conArchi = 0; + for (const doc of elenco) { + const nuovi = archiDocumento(grafo, doc, readFileSync(doc, "utf8")).filter( + (a) => !gia.has(`${a.source}>${a.target}`), + ); + for (const a of nuovi) gia.add(`${a.source}>${a.target}`); + if (nuovi.length) conArchi++; + grafo.links.push(...nuovi); + aggiunti += nuovi.length; + } + writeFileSync(file, JSON.stringify(grafo)); + console.log(`graphify-link-docs: ${aggiunti} archi da ${conArchi}/${elenco.length} documenti`); +} diff --git a/test/unit/graphify-link-docs.test.ts b/test/unit/graphify-link-docs.test.ts new file mode 100644 index 0000000..dac6e95 --- /dev/null +++ b/test/unit/graphify-link-docs.test.ts @@ -0,0 +1,71 @@ +/** Check del collegamento documenti ↔ codice nel grafo: `bun test/unit/graphify-link-docs.test.ts`. */ +import assert from "node:assert/strict"; +import { + archiDocumento, + estraiPercorsi, + estraiSimboli, + idDocumento, +} from "../../scripts/graphify-link-docs"; + +// --- percorsi: solo file del repo, con la prima riga in cui compaiono --------- +const testo = + "Intro\nVedi `src/lib/rosa.ts` e src/routes/partita.$id.tsx.\nDi nuovo src/lib/rosa.ts, www.x.it/a.ts"; +const p = estraiPercorsi(testo); +assert.deepEqual([...p.keys()], ["src/lib/rosa.ts", "src/routes/partita.$id.tsx"]); +assert.equal(p.get("src/lib/rosa.ts"), 2, "tiene la prima riga"); + +// --- simboli: solo tra backtick, senza parentesi ------------------------------ +assert.deepEqual( + [...estraiSimboli("`useIo` e `inRosa()` ma non useRosa né `a b`")], + ["useIo", "inRosa"], +); + +// --- id del documento come lo costruisce graphify ----------------------------- +assert.equal(idDocumento("docs/modules/allenatore.md"), "docs_modules_allenatore"); +assert.equal(idDocumento("docs/decisions/DD-034.md"), "docs_decisions_dd_034"); + +// --- archi: file citato + simboli citati che ci stanno dentro ----------------- +const grafo = { + nodes: [ + { id: "docs_modules_allenatore", label: "Modulo" }, + { id: "src_lib_rosa", label: "rosa.ts", source_file: "src/lib/rosa.ts", source_location: "L1" }, + { + id: "src_lib_rosa_useio", + label: "useIo()", + source_file: "src/lib/rosa.ts", + source_location: "L160", + }, + { + id: "src_lib_rosa_altro", + label: "altro()", + source_file: "src/lib/rosa.ts", + source_location: "L9", + }, + { id: "src_lib_x", label: "x.ts", source_file: "src/lib/x.ts", source_location: "L1" }, + ], + links: [], +}; +const archi = archiDocumento( + grafo, + "docs/modules/allenatore.md", + "File: `src/lib/rosa.ts` (`useIo`)", +); +assert.deepEqual( + archi.map((a) => a.target), + ["src_lib_rosa", "src_lib_rosa_useio"], + "il file e solo il simbolo citato; non altro() né file non citati", +); +assert.equal(archi[0]?.source, "docs_modules_allenatore"); +assert.equal(archi[0]?.["confidence"], "EXTRACTED"); +assert.deepEqual( + archiDocumento(grafo, "docs/non-nel-grafo.md", "src/lib/rosa.ts"), + [], + "documento ignoto: nessun arco", +); +assert.deepEqual( + archiDocumento(grafo, "docs/modules/allenatore.md", "src/lib/inesistente.ts"), + [], + "file non nel grafo", +); + +console.log("graphify-link-docs: ok");