Skip to content

Commit 6b48660

Browse files
authored
fix(cli): emptyHint statico dei tool ora arriva anche via CLI (#49)
emit() scriveva solo result.hint dinamico, mai l'emptyHint statico del tool: scollamento col path MCP. runTool() ora applica withEmptyHint() (helper puro, precedenza nullish result.hint ?? emptyHint). Beneficiano i 6 tool con emptyHint statico. Include artefatti spec-driven e test.
1 parent 1cadd5f commit 6b48660

10 files changed

Lines changed: 243 additions & 2 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-07-11
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## Context
2+
3+
I tool del progetto espongono opzionalmente un `emptyHint?: string` statico (definito in `src/tools/*.ts`, tipizzato in `src/tools/types.ts`) e possono restituire un `hint?: string` dinamico nel `ToolResult`. Il server MCP li unisce con la precedenza `result.hint ?? emptyHint ?? DEFAULT_EMPTY` (`src/server.ts`). La CLI invece, in `emit()` (`src/cli.ts`), scrive su stderr solo `result.hint`, ignorando l'`emptyHint` statico. `emit(result, format)` non riceve il tool, quindi non ha accesso all'`emptyHint`; viene inoltre invocato da ~50 call site, ciascuno preceduto da `runTool(<tool>, input)`. `runTool()` invece riceve già l'oggetto tool.
4+
5+
## Goals / Non-Goals
6+
7+
**Goals:**
8+
- Allineare la CLI al path MCP: su risultato vuoto senza hint dinamico, comunicare l'`emptyHint` statico del tool su stderr.
9+
- Fix a punto singolo, senza modificare i ~50 call site di `emit()`.
10+
- Preservare stdout parsabile (CSV/JSONL) ed exit code.
11+
12+
**Non-Goals:**
13+
- Modificare le stringhe di `emptyHint` esistenti o aggiungerne di nuove.
14+
- Cambiare il comportamento del server MCP (già corretto).
15+
- Introdurre un `DEFAULT_EMPTY` lato CLI: se non c'è né hint dinamico né `emptyHint`, la CLI resta silenziosa su stderr (comportamento attuale).
16+
17+
## Decisions
18+
19+
- **Applicare il fallback in `runTool()`, non in `emit()`.** `runTool()` ha già il tool in mano; `emit()` no. Allargare la firma di `runTool` per includere `emptyHint?: string` e, dopo `tool.execute(parsed)`, se `result.rows.length === 0 && result.hint == null && tool.emptyHint`, restituire `{ ...result, hint: tool.emptyHint }`. `emit()` resta invariato: già scrive `result.hint` su stderr quando il risultato è vuoto. Un solo punto di modifica, nessun tocco ai call site.
20+
- **Precedenza identica al server**: l'hint dinamico vince sull'`emptyHint` statico (`result.hint ?? emptyHint`), replicata dalla guardia `result.hint == null` (nullish, non truthy `!result.hint`: così un hint dinamico stringa vuota non viene sovrascritto, restando fedele al `??`).
21+
- **Immutabilità del risultato**: si restituisce un nuovo oggetto (`{ ...result, hint }`) invece di mutare `result`, coerente con lo stile del codice.
22+
23+
## Risks / Trade-offs
24+
25+
- **Rischio basso**: la modifica tocca solo il ramo "risultato vuoto"; il flusso con righe è invariato. stdout ed exit code non cambiano.
26+
- **Trade-off**: si aggiunge `emptyHint?` alla firma inline di `runTool`; accettabile e coerente con `Tool<>` in `types.ts`. In alternativa si sarebbe potuto tipizzare `runTool` sul tipo `Tool`, ma la firma inline attuale è minima e la si estende di un solo campo per non allargare la superficie del cambiamento.
27+
- **Verifica**: serve un test che copra i quattro casi (vuoto+emptyHint→stderr; vuoto+hint dinamico→precede; non vuoto→niente; vuoto+niente→silenzio) e la parità con MCP, per evitare regressioni future su questo scollamento.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
Sei tool definiscono un `emptyHint` statico — un messaggio che spiega perché un risultato potrebbe essere vuoto (es. gap noto del dataset, filtro da riformulare). Il server MCP lo mostra (`src/server.ts` usa `result.hint ?? emptyHint ?? DEFAULT_EMPTY`), ma la CLI no: `emit()` in `src/cli.ts` scrive solo l'hint dinamico (`result.hint`) e non arriva mai all'`emptyHint` statico del tool. Chi usa la CLI su una finestra vuota (caso reale: le fiducie COVID 2020 assenti dal LOD Senato) riceve un output vuoto, exit code 0, senza alcuna spiegazione — pur avendola già scritta nel codice. È uno scollamento CLI/MCP che viola la convenzione di progetto "CLI e MCP allineati".
4+
5+
## What Changes
6+
7+
- La CLI, quando un risultato è vuoto e il tool non fornisce un hint dinamico, usa come fallback l'`emptyHint` statico del tool e lo scrive su stderr (stessa semantica del path MCP: `result.hint ?? emptyHint`).
8+
- Il fallback resta su **stderr**, così l'output parsabile (CSV/JSONL) su stdout di pipeline e redirezioni non viene sporcato; l'exit code non cambia.
9+
- Beneficiano i 6 tool con `emptyHint` statico: `senato-votes`, `bill-progress`, `amendments`, `bills`, `sindacato-ispettivo`, `votes`.
10+
- Nessun cambio di comportamento quando il risultato non è vuoto o quando esiste già un hint dinamico (che mantiene la precedenza).
11+
12+
## Capabilities
13+
14+
### New Capabilities
15+
- `cli-empty-result-hint`: comportamento della CLI nel comunicare, su risultato vuoto, il messaggio esplicativo del tool (hint dinamico se presente, altrimenti `emptyHint` statico) su stderr, allineandolo al path MCP.
16+
17+
### Modified Capabilities
18+
<!-- Nessuna spec esistente in openspec/specs/: nessuna capability modificata. -->
19+
20+
## Impact
21+
22+
- Codice: `src/cli.ts` — funzione `runTool()` (fallback dell'hint) e/o `emit()`. Nessuna modifica ai ~50 call site di `emit()`.
23+
- Nessun impatto su `src/server.ts` (già corretto) né sulle definizioni dei tool (l'`emptyHint` esiste già).
24+
- Nessun breaking change: stdout invariato, exit code invariato; cambia solo un messaggio informativo su stderr in caso di risultato vuoto.
25+
- Test: aggiungere copertura sul fallback CLI (risultato vuoto → `emptyHint` su stderr; hint dinamico ha precedenza; risultato non vuoto → nessun hint).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
## ADDED Requirements
2+
3+
### Requirement: La CLI comunica il messaggio esplicativo su risultato vuoto
4+
5+
Quando l'esecuzione di un comando produce un risultato senza righe, la CLI SHALL scrivere su stderr un messaggio esplicativo, senza alterare l'output su stdout né l'exit code. La sorgente del messaggio SHALL seguire la precedenza: l'hint dinamico del risultato (`result.hint`) se presente, altrimenti l'`emptyHint` statico definito dal tool. Se nessuno dei due è disponibile, la CLI SHALL non scrivere alcun messaggio — a differenza del server MCP, la CLI non applica un messaggio di default. La **precedenza** tra hint dinamico ed `emptyHint` statico SHALL coincidere con quella del server MCP (`result.hint ?? emptyHint`, nullish); il default finale del path MCP (`?? DEFAULT_EMPTY`) resta specifico dell'MCP e fuori da questo requisito.
6+
7+
#### Scenario: Risultato vuoto senza hint dinamico usa l'emptyHint statico
8+
9+
- **WHEN** un comando la cui definizione tool espone un `emptyHint` statico restituisce zero righe e nessun `result.hint`
10+
- **THEN** la CLI scrive l'`emptyHint` statico del tool su stderr
11+
- **AND** stdout contiene solo l'output vuoto formattato (CSV/JSONL), senza il messaggio
12+
- **AND** l'exit code resta invariato
13+
14+
#### Scenario: L'hint dinamico ha precedenza sull'emptyHint statico
15+
16+
- **WHEN** un comando restituisce zero righe e un `result.hint` dinamico valorizzato, e il tool espone anche un `emptyHint` statico
17+
- **THEN** la CLI scrive su stderr l'hint dinamico
18+
- **AND** non scrive l'`emptyHint` statico
19+
20+
#### Scenario: Risultato non vuoto non produce alcun messaggio
21+
22+
- **WHEN** un comando restituisce almeno una riga
23+
- **THEN** la CLI non scrive alcun hint su stderr, indipendentemente dalla presenza di `emptyHint` statico o `result.hint`
24+
25+
#### Scenario: Nessun hint disponibile non produce messaggio
26+
27+
- **WHEN** un comando restituisce zero righe, senza `result.hint` dinamico e senza `emptyHint` statico definito dal tool
28+
- **THEN** la CLI non scrive alcun messaggio su stderr
29+
30+
#### Scenario: Parità di comportamento tra CLI e MCP
31+
32+
- **WHEN** lo stesso tool con `emptyHint` statico produce un risultato vuoto senza hint dinamico
33+
- **THEN** il messaggio comunicato dalla CLI su stderr coincide con quello restituito dal server MCP
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## 1. Fix del fallback nella CLI
2+
3+
- [x] 1.1 In `src/cli.ts`, allargare la firma di `runTool()` per includere `emptyHint?: string` nell'oggetto tool accettato.
4+
- [x] 1.2 In `runTool()`, dopo `tool.execute(parsed)`, se il risultato è vuoto e senza hint dinamico ma il tool ha un `emptyHint`, restituire `{ ...result, hint: tool.emptyHint }` (precedenza `result.hint ?? emptyHint`, senza mutare `result`).
5+
- [x] 1.3 Verificare che `emit()` resti invariato (già scrive `result.hint` su stderr su risultato vuoto) e che i ~50 call site non richiedano modifiche.
6+
7+
## 2. Verifica manuale sui tool con emptyHint
8+
9+
- [x] 2.1 Build (`npm run build`) e prova su finestra vuota (10 mar–16 apr 2020, buco Cura Italia): stdout solo header, `emptyHint` su stderr, exit 0.
10+
- [x] 2.2 Confrontare il messaggio con quello restituito dal path MCP per lo stesso tool/input (parità CLI↔MCP): stessa fonte `result.hint ?? emptyHint`.
11+
- [x] 2.3 Spot check sugli altri tool con `emptyHint` statico: `bill-progress`, `bills`, `sindacato-ispettivo`, `votes` verificati (emptyHint su stderr); `amendments` stesso meccanismo.
12+
13+
## 3. Test automatici
14+
15+
- [x] 3.1 Aggiunto test: risultato vuoto + `emptyHint` statico → `hint` valorizzato (`src/core/empty-hint.test.ts`).
16+
- [x] 3.2 Test: hint dinamico presente → precede l'`emptyHint` statico.
17+
- [x] 3.3 Test: risultato non vuoto → nessun hint.
18+
- [x] 3.4 Test: risultato vuoto senza hint né `emptyHint` → nessun hint; più test di non-mutazione e parità con MCP.
19+
- [x] 3.5 Suite completa con `npm test -- --run`: 136/136 passano; tsc pulito.

openspec/config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
schema: spec-driven
2+
3+
# Project context (optional)
4+
# This is shown to AI when creating artifacts.
5+
# Add your tech stack, conventions, style guides, domain knowledge, etc.
6+
# Example:
7+
# context: |
8+
# Tech stack: TypeScript, React, Node.js
9+
# We use conventional commits
10+
# Domain: e-commerce platform
11+
12+
# Per-artifact rules (optional)
13+
# Add custom rules for specific artifacts.
14+
# Example:
15+
# rules:
16+
# proposal:
17+
# - Keep proposals under 500 words
18+
# - Always include a "Non-goals" section
19+
# tasks:
20+
# - Break tasks into chunks of max 2 hours
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# cli-empty-result-hint Specification
2+
3+
## Purpose
4+
Garantire che la CLI, su un risultato vuoto, comunichi lo stesso messaggio esplicativo del server MCP: l'hint dinamico se presente, altrimenti l'`emptyHint` statico del tool. Evita che l'utente CLI riceva un output vuoto senza spiegazione (es. gap noto del dataset) e mantiene allineati i due path (CLI e MCP).
5+
6+
## Requirements
7+
### Requirement: La CLI comunica il messaggio esplicativo su risultato vuoto
8+
9+
Quando l'esecuzione di un comando produce un risultato senza righe, la CLI SHALL scrivere su stderr un messaggio esplicativo, senza alterare l'output su stdout né l'exit code. La sorgente del messaggio SHALL seguire la precedenza: l'hint dinamico del risultato (`result.hint`) se presente, altrimenti l'`emptyHint` statico definito dal tool. Se nessuno dei due è disponibile, la CLI SHALL non scrivere alcun messaggio — a differenza del server MCP, la CLI non applica un messaggio di default. La **precedenza** tra hint dinamico ed `emptyHint` statico SHALL coincidere con quella del server MCP (`result.hint ?? emptyHint`, nullish); il default finale del path MCP (`?? DEFAULT_EMPTY`) resta specifico dell'MCP e fuori da questo requisito.
10+
11+
#### Scenario: Risultato vuoto senza hint dinamico usa l'emptyHint statico
12+
13+
- **WHEN** un comando la cui definizione tool espone un `emptyHint` statico restituisce zero righe e nessun `result.hint`
14+
- **THEN** la CLI scrive l'`emptyHint` statico del tool su stderr
15+
- **AND** stdout contiene solo l'output vuoto formattato (CSV/JSONL), senza il messaggio
16+
- **AND** l'exit code resta invariato
17+
18+
#### Scenario: L'hint dinamico ha precedenza sull'emptyHint statico
19+
20+
- **WHEN** un comando restituisce zero righe e un `result.hint` dinamico valorizzato, e il tool espone anche un `emptyHint` statico
21+
- **THEN** la CLI scrive su stderr l'hint dinamico
22+
- **AND** non scrive l'`emptyHint` statico
23+
24+
#### Scenario: Risultato non vuoto non produce alcun messaggio
25+
26+
- **WHEN** un comando restituisce almeno una riga
27+
- **THEN** la CLI non scrive alcun hint su stderr, indipendentemente dalla presenza di `emptyHint` statico o `result.hint`
28+
29+
#### Scenario: Nessun hint disponibile non produce messaggio
30+
31+
- **WHEN** un comando restituisce zero righe, senza `result.hint` dinamico e senza `emptyHint` statico definito dal tool
32+
- **THEN** la CLI non scrive alcun messaggio su stderr
33+
34+
#### Scenario: Parità di comportamento tra CLI e MCP
35+
36+
- **WHEN** lo stesso tool con `emptyHint` statico produce un risultato vuoto senza hint dinamico
37+
- **THEN** il messaggio comunicato dalla CLI su stderr coincide con quello restituito dal server MCP
38+

src/cli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { formatRows, type Format } from "./core/format.js";
4848
import { SparqlError } from "./core/client.js";
4949
import { ZodError } from "zod";
5050
import type { ToolResult } from "./tools/types.js";
51+
import { withEmptyHint } from "./core/empty-hint.js";
5152
import { createRequire } from "module";
5253
const require = createRequire(import.meta.url);
5354
const { version } = require("../package.json") as { version: string };
@@ -77,7 +78,7 @@ function emit(result: ToolResult, format: Format): void {
7778
// errati (--vote-type, --rank-by, ...) producono un ZodError con i valori validi
7879
// invece di scivolare nella query come stringa.
7980
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80-
function runTool(tool: { inputSchema: { parse(i: unknown): any }; execute(i: any): Promise<ToolResult> }, input: unknown): Promise<ToolResult> {
81+
async function runTool(tool: { inputSchema: { parse(i: unknown): any }; execute(i: any): Promise<ToolResult>; emptyHint?: string }, input: unknown): Promise<ToolResult> {
8182
let parsed: unknown;
8283
try {
8384
parsed = tool.inputSchema.parse(input);
@@ -93,7 +94,10 @@ function runTool(tool: { inputSchema: { parse(i: unknown): any }; execute(i: any
9394
}
9495
throw e;
9596
}
96-
return tool.execute(parsed);
97+
// Fallback allineato al path MCP (result.hint ?? emptyHint): su risultato
98+
// vuoto senza hint dinamico, usa l'emptyHint statico del tool così emit()
99+
// lo scrive su stderr.
100+
return withEmptyHint(await tool.execute(parsed), tool.emptyHint);
97101
}
98102

99103
function parseFormat(raw: string): Format {

src/core/empty-hint.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from "vitest";
2+
import { withEmptyHint } from "./empty-hint.js";
3+
import type { ToolResult } from "../tools/types.js";
4+
5+
const empty = (hint?: string): ToolResult => ({ rows: [], columns: ["a"], hint });
6+
const filled = (hint?: string): ToolResult => ({ rows: [{ a: "1" }], columns: ["a"], hint });
7+
8+
// Riproduce la risoluzione del path MCP (server.ts formatResult, escluso DEFAULT_EMPTY)
9+
const mcpHint = (result: ToolResult, emptyHint?: string) => result.hint ?? emptyHint;
10+
11+
describe("withEmptyHint", () => {
12+
it("risultato vuoto senza hint dinamico usa l'emptyHint statico", () => {
13+
const out = withEmptyHint(empty(), "STATICO");
14+
expect(out.hint).toBe("STATICO");
15+
expect(out.rows).toEqual([]);
16+
});
17+
18+
it("l'hint dinamico ha precedenza sull'emptyHint statico", () => {
19+
const out = withEmptyHint(empty("DINAMICO"), "STATICO");
20+
expect(out.hint).toBe("DINAMICO");
21+
});
22+
23+
it("risultato non vuoto non riceve alcun hint", () => {
24+
const out = withEmptyHint(filled(), "STATICO");
25+
expect(out.hint).toBeUndefined();
26+
});
27+
28+
it("vuoto senza hint dinamico né emptyHint non produce hint", () => {
29+
const out = withEmptyHint(empty(), undefined);
30+
expect(out.hint).toBeUndefined();
31+
});
32+
33+
it("non muta l'oggetto risultato in ingresso", () => {
34+
const input = empty();
35+
const out = withEmptyHint(input, "STATICO");
36+
expect(input.hint).toBeUndefined();
37+
expect(out).not.toBe(input);
38+
});
39+
40+
it("hint dinamico stringa vuota (falsy ma non nullish) non viene sovrascritto", () => {
41+
const out = withEmptyHint(empty(""), "STATICO");
42+
expect(out.hint).toBe("");
43+
});
44+
45+
it("parità con il path MCP (result.hint ?? emptyHint)", () => {
46+
const cases: Array<[ToolResult, string | undefined]> = [
47+
[empty(), "STATICO"],
48+
[empty("DINAMICO"), "STATICO"],
49+
[empty(""), "STATICO"],
50+
[empty(), undefined],
51+
];
52+
for (const [result, emptyHint] of cases) {
53+
expect(withEmptyHint(result, emptyHint).hint).toBe(mcpHint(result, emptyHint));
54+
}
55+
});
56+
});

src/core/empty-hint.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { ToolResult } from "../tools/types.js";
2+
3+
/**
4+
* Su risultato vuoto senza hint dinamico, valorizza `hint` con l'emptyHint
5+
* statico del tool (precedenza `result.hint ?? emptyHint`, come il path MCP in
6+
* server.ts). Non muta l'input: restituisce un nuovo oggetto solo se serve.
7+
* Così la CLI (emit → stderr) e l'MCP comunicano lo stesso messaggio.
8+
*/
9+
export function withEmptyHint(result: ToolResult, emptyHint?: string): ToolResult {
10+
// `result.hint == null` (nullish) e non `!result.hint`: così un hint dinamico
11+
// valido ma falsy (stringa vuota) non viene sovrascritto, restando fedele
12+
// alla precedenza `result.hint ?? emptyHint` del path MCP.
13+
if (result.rows.length === 0 && result.hint == null && emptyHint) {
14+
return { ...result, hint: emptyHint };
15+
}
16+
return result;
17+
}

0 commit comments

Comments
 (0)