Skip to content

Commit bc69490

Browse files
aborrusoclaude
andcommitted
fix(vote-detail): escape del letterale SPARQL + test sul path execute
Da review PR #80: - la sigla risolta finiva interpolata cruda nel FILTER: ora passa per sparqlStringLiteral, estratto in src/core (era duplicato in bill-progress) con i suoi test. Il valore viene dal grafo, non dall'utente, ma una virgoletta nel dato romperebbe comunque la query. - l'hint sul caso "nessuna sigla registrata" citava il flag --group-acronym, che per un client MCP non significa nulla: formulazione neutra. - due test di integrazione su voteDetailTool.execute: match risolto (sigla minuscola → righe, nessun hint) e sigla di groups list → vuoto con hint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8ff8365 commit bc69490

5 files changed

Lines changed: 72 additions & 16 deletions

File tree

src/core/sparql-literal.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, it, expect } from "vitest";
2+
import { sparqlStringLiteral } from "./sparql-literal.js";
3+
4+
describe("sparqlStringLiteral", () => {
5+
it("avvolge il valore tra virgolette", () => {
6+
expect(sparqlStringLiteral("FDI")).toBe('"FDI"');
7+
});
8+
9+
it("scappa le virgolette interne", () => {
10+
expect(sparqlStringLiteral('AL"T')).toBe('"AL\\"T"');
11+
});
12+
13+
it("scappa i backslash", () => {
14+
expect(sparqlStringLiteral("A\\B")).toBe('"A\\\\B"');
15+
});
16+
17+
it("scappa i ritorni a capo", () => {
18+
expect(sparqlStringLiteral("a\nb\rc")).toBe('"a\\nb\\rc"');
19+
});
20+
21+
it("lascia intatti gli altri caratteri, accenti compresi", () => {
22+
expect(sparqlStringLiteral("M-+EUR è così")).toBe('"M-+EUR è così"');
23+
});
24+
});

src/core/sparql-literal.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Escapa solo i caratteri che la grammatica STRING_LITERAL2 vieta (\, ",
2+
// newline, CR). Non usare JSON.stringify: emette \uXXXX per i caratteri di
3+
// controllo, sequenza che alcuni parser SPARQL rifiutano nel corpo del
4+
// letterale (o cercano un valore diverso), rompendo il filtro invece di fare
5+
// il match atteso.
6+
const SPARQL_ESC: Record<string, string> = {
7+
"\\": "\\\\",
8+
'"': '\\"',
9+
"\n": "\\n",
10+
"\r": "\\r",
11+
};
12+
13+
/**
14+
* Serializza un valore come string literal SPARQL, virgolette comprese.
15+
* Serve ovunque un valore finisca dentro una query: anche i valori che
16+
* arrivano dal grafo (etichette, sigle) possono contenere virgolette o
17+
* backslash e renderebbero la query invalida.
18+
*/
19+
export const sparqlStringLiteral = (value: string): string =>
20+
`"${value.replace(/[\\"\n\r]/g, (c) => SPARQL_ESC[c] ?? c)}"`;

src/tools/bill-progress.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,9 @@ import { decodeHtml } from "../core/decode-html.js";
66
import { htmlEntityKeywordVariants } from "../core/html-entity-variants.js";
77
import { ddlRssUrl } from "../core/html-url.js";
88
import { currentLegislature } from "../core/current-legislature.js";
9+
import { sparqlStringLiteral } from "../core/sparql-literal.js";
910
import type { Tool } from "./types.js";
1011

11-
// Costruisce un letterale-stringa SPARQL valido escapando solo i caratteri che
12-
// la grammatica STRING_LITERAL2 vieta (\, ", newline, CR). Non usare
13-
// JSON.stringify: emette \uXXXX per i caratteri di controllo, sequenza che
14-
// alcuni parser SPARQL rifiutano nel corpo del letterale (o cercano un valore
15-
// diverso), rompendo il filtro keyword invece di fare il match atteso.
16-
const SPARQL_ESC: Record<string, string> = {
17-
"\\": "\\\\",
18-
'"': '\\"',
19-
"\n": "\\n",
20-
"\r": "\\r",
21-
};
22-
const sparqlStringLiteral = (value: string): string =>
23-
`"${value.replace(/[\\"\n\r]/g, (c) => SPARQL_ESC[c] ?? c)}"`;
24-
2512
const inputSchema = z.object({
2613
ddlUri: z
2714
.string()

src/tools/tools.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,30 @@ describe("Camera tools", () => {
452452
expect(result.rows[0]).toHaveProperty("group_acronym");
453453
}, 30000);
454454

455+
it("vote-detail: --group-acronym resolves against the sigle present in the vote (#77)", async () => {
456+
// Sigla scritta in minuscolo: deve risolvere e filtrare, non restare vuota.
457+
const result = await voteDetailTool.execute({
458+
voteUri: "http://dati.camera.it/ocd/votazione.rdf/vs19_456_018",
459+
groupAcronym: "fdi",
460+
limit: 700,
461+
});
462+
expect(result.rows.length).toBeGreaterThan(0);
463+
expect(new Set(result.rows.map((r) => r.group_acronym))).toEqual(new Set(["FDI"]));
464+
expect(result.hint).toBeUndefined();
465+
}, 30000);
466+
467+
it("vote-detail: sigla di groups list non presente nel voto → vuoto con hint, non silenzio (#77)", async () => {
468+
const result = await voteDetailTool.execute({
469+
voteUri: "http://dati.camera.it/ocd/votazione.rdf/vs19_456_018",
470+
groupAcronym: "AZ-PER-RE",
471+
limit: 700,
472+
});
473+
expect(result.rows.length).toBe(0);
474+
expect(result.hint).toContain("AZ-PER-RE");
475+
// L'elenco viene dalla query, non da una lista scritta nel codice.
476+
expect(result.hint).toContain("APERRE");
477+
}, 30000);
478+
455479
it("group-members: returns members for legislature 19", async () => {
456480
const result = await groupMembersTool.execute({ legislature: 19, limit: 3, offset: 0 });
457481
expect(result.rows.length).toBe(3);

src/tools/vote-detail.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cdQuery } from "../core/client.js";
33
import { OCD_PREFIXES } from "../core/prefixes.js";
44
import { flattenBindings } from "../core/flatten.js";
55
import { personHtmlUrl } from "../core/html-url.js";
6+
import { sparqlStringLiteral } from "../core/sparql-literal.js";
67
import type { Tool } from "./types.js";
78

89
const inputSchema = z.object({
@@ -76,7 +77,7 @@ function similarity(a: string, b: string): number {
7677
/** Nota mostrata quando la sigla richiesta non esiste in quella votazione. */
7778
export function buildGroupAcronymHint(requested: string, available: string[]): string {
7879
if (available.length === 0) {
79-
return `Nessuna sigla di gruppo registrata in questa votazione: il filtro --group-acronym non è applicabile.`;
80+
return `Nessuna sigla di gruppo registrata in questa votazione: il filtro per gruppo non è applicabile.`;
8081
}
8182
const norm = normalizeAcronym(requested);
8283
const ranked = [...available].sort(
@@ -135,7 +136,7 @@ export const voteDetailTool: Tool<typeof inputSchema> = {
135136
if (!resolved) {
136137
return { rows: [], columns, hint: buildGroupAcronymHint(input.groupAcronym, available) };
137138
}
138-
filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${resolved}")`);
139+
filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = ${sparqlStringLiteral(resolved)})`);
139140
}
140141
if (input.voteType) filters.push(`FILTER(?type = "${input.voteType}")`);
141142

0 commit comments

Comments
 (0)