diff --git a/docs/GRAFO.md b/docs/GRAFO.md index 2cf08d6..707008c 100644 --- a/docs/GRAFO.md +++ b/docs/GRAFO.md @@ -62,6 +62,10 @@ stanno in quel file. Sono archi EXTRACTED: il percorso è scritto nel testo. bun scripts/graphify-link-docs.ts # idempotente, gratis ``` +Collega anche i documenti alle RFC che nominano (`RFC 8188/8291`) e unisce i concetti dei +documenti che hanno lo stesso nome esatto di un simbolo del codice (`mediePagelle()`): questi +ultimi sono archi `describes` INFERRED, perché si basano solo sul nome. + 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. diff --git a/scripts/graphify-link-docs.ts b/scripts/graphify-link-docs.ts index 3772ec5..c525bf3 100644 --- a/scripts/graphify-link-docs.ts +++ b/scripts/graphify-link-docs.ts @@ -2,7 +2,8 @@ * 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 + * scritto nel testo. Collega anche i documenti alle RFC che nominano (`RFC 8188/8291`) e i + * concetti dei documenti che hanno lo stesso nome di un simbolo (`mediePagelle()`). 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 @@ -12,7 +13,12 @@ import { join } from "node:path"; const MARCA = "graphify-link-docs"; -type Nodo = { id: string; label?: string; source_file?: string; source_location?: string }; +type Nodo = { + id: string; + label?: string; + source_file?: string; + source_location?: string; +} & Record; type Arco = Record & { source: string; target: string; _via?: string }; type Grafo = { nodes: Nodo[]; links: Arco[] } & Record; @@ -42,10 +48,21 @@ export function idDocumento(percorso: string): string { .replace(/^_|_$/g, ""); } +/** + * Il nodo che rappresenta il documento. Di solito ha l'id derivato dal percorso, ma + * l'estrazione ne dà a volte uno diverso (`docs_decisions_dd_026_dd_026`): in quel caso vale il + * nodo `document` di quel file, e se ce ne sono più d'uno il primo. + */ +export function idNodoDocumento(grafo: Grafo, doc: string): string | undefined { + const id = idDocumento(doc); + if (grafo.nodes.some((n) => n.id === id)) return id; + return grafo.nodes.find((n) => n.source_file === doc && n["file_type"] === "document")?.id; +} + /** 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 idDoc = idNodoDocumento(grafo, doc); + if (!idDoc) return []; const archi: Arco[] = []; const base = (target: string, riga: number): Arco => ({ source: idDoc, @@ -72,6 +89,78 @@ export function archiDocumento(grafo: Grafo, doc: string, testo: string): Arco[] return archi; } +/** Numeri di RFC nominati nel testo: «RFC 8291», «RFC-8291», «RFC 8188/8291». */ +export function estraiRfc(testo: string): Set { + const numeri = new Set(); + for (const m of testo.matchAll(/RFC[ -]?(\d{4}(?:\/\d{4})*)/g)) + for (const n of (m[1] as string).split("/")) numeri.add(n); + return numeri; +} + +const senzaParentesi = (etichetta: string) => etichetta.replace(/\(\)$/, ""); +const arcoDa = ( + source: string, + target: string, + doc: string, + extra: Record, +): Arco => ({ + source, + target, + weight: 1.0, + _origin: "semantic", + _via: MARCA, + source_file: doc, + ...extra, +}); + +/** Archi dal documento ai nodi-RFC che nomina (le RFC sono nodi creati dall'estrazione). */ +export function archiRfc(grafo: Grafo, doc: string, testo: string): Arco[] { + const idDoc = idNodoDocumento(grafo, doc); + if (!idDoc) return []; + const numeri = estraiRfc(testo); + return grafo.nodes + .filter((n) => n.label?.startsWith("RFC-") && numeri.has(n.label.slice(4))) + .map((n) => + arcoDa(idDoc, n.id, doc, { + relation: "references", + confidence: "EXTRACTED", + confidence_score: 1.0, + }), + ); +} + +/** + * Concetti dei documenti con lo stesso nome di un simbolo del codice (`mediePagelle()` nel + * modulo, `mediePagelle` in `pagelle.ts`): l'arco `describes` li unisce. Solo corrispondenze + * esatte e uniche; è un'inferenza sul nome, quindi INFERRED. + */ +export function archiConcetti( + grafo: Grafo & { nodes: Array> }, +): Arco[] { + const codice = new Map(); + for (const n of grafo.nodes) + if (n["_origin"] === "ast" && n["file_type"] === "code" && n.label) { + const k = senzaParentesi(n.label); + codice.set(k, [...(codice.get(k) ?? []), n]); + } + const archi: Arco[] = []; + for (const n of grafo.nodes) { + if (n["_origin"] === "ast" || !n.label || !n.source_file?.endsWith(".md")) continue; + if (!["concept", "rationale", "code"].includes(n["file_type"] as string)) continue; + const trovati = codice.get(senzaParentesi(n.label)); + if (trovati?.length !== 1) continue; + archi.push( + arcoDa(n.id, (trovati[0] as Nodo).id, n.source_file, { + relation: "describes", + confidence: "INFERRED", + confidence_score: 0.95, + source_location: n.source_location ?? "L1", + }), + ); + } + return archi; +} + function documenti(dir: string, fuori: string[] = []): string[] { return readdirSync(dir).flatMap((nome) => { const p = join(dir, nome); @@ -96,7 +185,8 @@ if (import.meta.main) { let aggiunti = 0; let conArchi = 0; for (const doc of elenco) { - const nuovi = archiDocumento(grafo, doc, readFileSync(doc, "utf8")).filter( + const testo = readFileSync(doc, "utf8"); + const nuovi = [...archiDocumento(grafo, doc, testo), ...archiRfc(grafo, doc, testo)].filter( (a) => !gia.has(`${a.source}>${a.target}`), ); for (const a of nuovi) gia.add(`${a.source}>${a.target}`); @@ -104,6 +194,12 @@ if (import.meta.main) { grafo.links.push(...nuovi); aggiunti += nuovi.length; } + for (const a of archiConcetti(grafo)) { + if (gia.has(`${a.source}>${a.target}`)) continue; + gia.add(`${a.source}>${a.target}`); + grafo.links.push(a); + aggiunti++; + } 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 index dac6e95..5a7396d 100644 --- a/test/unit/graphify-link-docs.test.ts +++ b/test/unit/graphify-link-docs.test.ts @@ -1,7 +1,10 @@ /** Check del collegamento documenti ↔ codice nel grafo: `bun test/unit/graphify-link-docs.test.ts`. */ import assert from "node:assert/strict"; import { + archiConcetti, archiDocumento, + archiRfc, + estraiRfc, estraiPercorsi, estraiSimboli, idDocumento, @@ -68,4 +71,74 @@ assert.deepEqual( "file non nel grafo", ); +// --- RFC: tutte le forme, anche «8188/8291» ------------------------------------ +assert.deepEqual([...estraiRfc("cifrata (RFC 8188/8291), poi RFC-8030 e RFC 123")].sort(), [ + "8030", + "8188", + "8291", +]); +const conRfc = { + nodes: [ + { id: "docs_decisions_dd_026", label: "DD" }, + { id: "r8291", label: "RFC-8291" }, + { id: "r8030", label: "RFC-8030" }, + ], + links: [], +}; +assert.deepEqual( + archiRfc(conRfc, "docs/decisions/DD-026.md", "cifrata (RFC 8188/8291)").map((a) => a.target), + ["r8291"], + "solo le RFC che esistono come nodi", +); + +// --- concetti: nome esatto e unico, solo dai documenti -------------------------- +const conc = { + nodes: [ + { + id: "c1", + label: "mediePagelle()", + source_file: "docs/modules/pagelle.md", + file_type: "concept", + }, + { + id: "k1", + label: "mediePagelle()", + source_file: "src/lib/pagelle.ts", + file_type: "code", + _origin: "ast", + }, + { id: "c2", label: "Altra cosa", source_file: "docs/modules/pagelle.md", file_type: "concept" }, + { id: "c3", label: "dup()", source_file: "docs/x.md", file_type: "concept" }, + { id: "k2", label: "dup()", source_file: "src/a.ts", file_type: "code", _origin: "ast" }, + { id: "k3", label: "dup()", source_file: "src/b.ts", file_type: "code", _origin: "ast" }, + ], + links: [], +}; +const ac = archiConcetti(conc); +assert.deepEqual( + ac.map((a) => [a.source, a.target]), + [["c1", "k1"]], + "esatto e unico; ambiguo scartato", +); +assert.equal(ac[0]?.["confidence"], "INFERRED"); + +// --- nodo-documento con id diverso da quello derivato dal percorso ------------- +const altro = { + nodes: [ + { + id: "docs_decisions_dd_026_dd_026", + label: "DD-026", + source_file: "docs/decisions/DD-026.md", + file_type: "document", + }, + { id: "src_lib_rosa", label: "rosa.ts", source_file: "src/lib/rosa.ts", source_location: "L1" }, + ], + links: [], +}; +assert.equal( + archiDocumento(altro, "docs/decisions/DD-026.md", "src/lib/rosa.ts")[0]?.source, + "docs_decisions_dd_026_dd_026", + "usa il nodo document del file se l'id derivato non esiste", +); + console.log("graphify-link-docs: ok");