feat: make search() FTS-safe; complete Tier 2 helper-shape contract tests

search() falls back to safe per-token quoting on malformed FTS input instead of
crashing (documented in api-reference.md). Adds raw() to the doc-synced shape
contract. Full suite 107/107.
This commit is contained in:
tommy0103
2026-07-08 16:35:10 +08:00
parent 25537d3a53
commit a32461b2ab
5 changed files with 260 additions and 19 deletions
+10 -1
View File
@@ -76,6 +76,11 @@ Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower
FTS rank sorts earlier; prefer returned order unless deliberately inspecting
FTS ranking.
Valid FTS5 syntax in `text` is honored. Input that FTS5 would reject as
malformed (for example a hyphenated term like `foo-bar`) does not error: it
falls back to safe per-token quoting — the same tokenization `memories()` uses —
so ordinary text never crashes the query.
#### `context(uuid)`
Full indexed context around one message.
@@ -368,7 +373,11 @@ well as `Edit`/`Write`.
Returns:
```js
Array<{ toolCall, session, timestamp }>
Array<{
toolCall: { id, message_uuid, name, input_json },
session: { id, title, project },
timestamp
}>
```
Use raw SQL with `ORDER BY m.timestamp DESC` when you need newest-first file
+20 -10
View File
@@ -73,22 +73,32 @@ function createQueryApi(db) {
const search = (text, opts = {}) => {
const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts;
let where = 'WHERE mf.text MATCH ?';
const p = [text];
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
if (project) { where += ' AND s.project LIKE ?'; p.push(project); }
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; p.push(source); }
const filterParams = [];
if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); }
if (project) { where += ' AND s.project LIKE ?'; filterParams.push(project); }
if (after) { where += ' AND m.timestamp>?'; filterParams.push(after); }
if (before) { where += ' AND m.timestamp<?'; filterParams.push(before); }
if (cwd) { where += ' AND m.cwd LIKE ?'; filterParams.push(cwd); }
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); }
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
p.push(limit);
const rows = db.prepare(`
const stmt = db.prepare(`
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,m.source as m_source,
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
s.source as s_source,
rank
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
${where} ORDER BY rank LIMIT ?`).all(...p);
${where} ORDER BY rank LIMIT ?`);
const runMatch = (matchText) => stmt.all(matchText, ...filterParams, limit);
// Honor raw FTS5 syntax when the query is valid, but never crash on ordinary
// input (hyphens, punctuation) that FTS5 would parse as operators: fall back
// to safe per-token quoting, the same tokenization memories() uses.
let rows;
try {
rows = runMatch(text);
} catch {
const safe = buildSafeFtsQuery(text);
rows = safe ? runMatch(safe) : [];
}
return rows.map(r => {
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
const ctx = db.prepare(
+211
View File
@@ -0,0 +1,211 @@
// Tier 2 contract golden tests (see docs/adr/0002-two-tier-runtime-contract.md).
//
// These lock the *composite return shapes* that agents parse and that
// references/api-reference.md documents explicitly, so the upcoming TypeScript /
// runtime-core refactor cannot silently reshape a helper's output. Two guards
// work together:
//
// 1. Live-shape assertions: build a representative in-memory DB, call each
// helper, and assert the returned object's key set matches the contract.
// 2. Doc-sync guard: assert every contracted key name also appears in
// references/api-reference.md. If an implementation shape changes, guard (1)
// fails; if the test contract changes without updating the doc, guard (2)
// fails. Either way, changing a helper forces a doc change.
//
// Out of scope here (covered elsewhere):
// - Bare `Array<session_row>` helpers (sessions/recent/workflows/trace/thread)
// return `SELECT *` rows; their columns are pinned by db-schema.test.mjs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createQueryApi, createAttuneApi } from '../scripts/query.mjs';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
const API_REFERENCE = readFileSync(new URL('../references/api-reference.md', import.meta.url), 'utf8');
// Every key asserted below is recorded here so the doc-sync guard can confirm
// references/api-reference.md still documents it.
const assertedKeys = new Set();
function exactKeys(obj, expected, label) {
expected.forEach(k => assertedKeys.add(k));
assert.deepEqual(
Object.keys(obj).sort(),
[...expected].sort(),
`${label}: key set drifted from api-reference.md`,
);
}
function hasKeys(obj, expected, label) {
const keys = new Set(Object.keys(obj));
for (const k of expected) {
assertedKeys.add(k);
assert.ok(keys.has(k), `${label}: missing documented key "${k}"`);
}
}
function fixture() {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare(`INSERT INTO sessions (id, title, project, project_path, started_at, ended_at, git_branch, message_count, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
'sid-1', 'Contract session', 'quiet-zero', process.cwd(),
'2026-06-10T10:00:00Z', '2026-06-10T11:00:00Z', 'main', 5, 'claude',
);
const insertMsg = db.prepare(`INSERT INTO messages
(uuid, session_id, type, parent_uuid, timestamp, role, text, content_type, is_meta, model, agent_id, cwd, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
insertMsg.run('m-root', 'sid-1', 'user', null, '2026-06-10T10:00:00Z', 'user', 'contract needle root', 'text', 0, null, null, process.cwd(), 'claude');
insertMsg.run('m-child', 'sid-1', 'assistant', 'm-root', '2026-06-10T10:00:10Z', 'assistant', 'contract needle child', 'text', 0, 'claude-opus', 'sub-A', process.cwd(), 'claude');
insertMsg.run('m-fail', 'sid-1', 'assistant', 'm-child', '2026-06-10T10:00:20Z', 'assistant', 'ran a command', 'tool_use', 0, 'claude-opus', null, process.cwd(), 'claude');
insertMsg.run('m-after', 'sid-1', 'assistant', 'm-fail', '2026-06-10T10:00:30Z', 'assistant', 'recovered after failure', 'text', 0, 'claude-opus', null, process.cwd(), 'claude');
db.prepare(`INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json, file_path)
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-read', 'm-child', 'sid-1', 'Read', '{"file_path":"/x/file.ts"}', '/x/file.ts');
db.prepare(`INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json, file_path)
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-bash', 'm-fail', 'sid-1', 'Bash', '{"command":"boom"}', null);
db.prepare(`INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content, file_path, is_error)
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-bash', 'm-fail', 'sid-1', 'command failed', null, 1);
db.prepare(`INSERT INTO subagents (agent_id, session_id, parent_tool_use_id, agent_type, description, duration_ms, total_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('sub-A', 'sid-1', 'tc-read', 'general-purpose', 'a subagent', 100, 200);
db.prepare(`INSERT INTO workflows (run_id, session_id, task_id, script, result_json, timestamp, agent_count, status, workflow_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('wf-1', 'sid-1', 'task-1', 'export const meta={}', '{"ok":true}', '2026-06-10T10:05:00Z', 1, 'done', 'demo');
db.prepare(`INSERT INTO workflow_agents (agent_id, run_id, session_id, agent_type, description, phase, label, model, state, duration_ms, tokens, tool_calls)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('wa-1', 'wf-1', 'sid-1', 'worker', 'agent', 'Find', 'find:x', 'claude-opus', 'done', 50, 10, 2);
db.prepare(`INSERT INTO summaries (id, session_id, timestamp, source, content)
VALUES (?, ?, ?, ?, ?)`).run('su-1', 'sid-1', '2026-06-10T10:06:00Z', 'away_summary', 'a summary');
db.prepare(`INSERT INTO memories (id, session_id, project, path, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)`).run('mem-1', 'sid-1', 'quiet-zero', '.obelisk/memories/x.md', 'Decision: contract fixture memory.', '2026-06-10T10:07:00Z');
return db;
}
test('search() hit shape matches api-reference.md', () => {
const db = fixture();
const hit = createQueryApi(db).search('needle', { limit: 1 })[0];
exactKeys(hit, ['message', 'session', 'rank', 'context'], 'search() hit');
exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'source'], 'search() hit.message');
exactKeys(hit.session, ['id', 'title', 'project', 'started_at', 'source'], 'search() hit.session');
assert.ok(Array.isArray(hit.context), 'search() hit.context is an array');
db.close();
});
test('context() shape matches api-reference.md', () => {
const db = fixture();
const ctx = createQueryApi(db).context('m-child');
exactKeys(ctx, ['message', 'parentChain', 'session', 'subagent', 'workflow'], 'context()');
assert.ok(Array.isArray(ctx.parentChain), 'context() parentChain is an array');
assert.equal(ctx.parentChain[0].uuid, 'm-root');
db.close();
});
test('fileHistory() row shape matches api-reference.md', () => {
const db = fixture();
const row = createQueryApi(db).fileHistory('/x/file.ts')[0];
exactKeys(row, ['toolCall', 'session', 'timestamp'], 'fileHistory() row');
exactKeys(row.toolCall, ['id', 'message_uuid', 'name', 'input_json'], 'fileHistory() row.toolCall');
exactKeys(row.session, ['id', 'title', 'project'], 'fileHistory() row.session');
db.close();
});
test('failures() row shape matches api-reference.md', () => {
const db = fixture();
const row = createQueryApi(db).failures()[0];
exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages'], 'failures() row');
assert.ok(Array.isArray(row.nextMessages), 'failures() row.nextMessages is an array');
assert.equal(row.nextMessages[0].uuid, 'm-after');
db.close();
});
test('subagents() row carries messageCount', () => {
const db = fixture();
const row = createQueryApi(db).subagents()[0];
hasKeys(row, ['messageCount'], 'subagents() row');
assert.equal(row.messageCount, 1);
db.close();
});
test('workflowTree() shape carries result and agents with messageCount', () => {
const db = fixture();
const tree = createQueryApi(db).workflowTree('wf-1');
hasKeys(tree, ['result', 'agents'], 'workflowTree()');
assert.deepEqual(tree.result, { ok: true });
assert.ok(Array.isArray(tree.agents));
hasKeys(tree.agents[0], ['messageCount'], 'workflowTree() agent');
db.close();
});
test('summaries() row carries session_title and project', () => {
const db = fixture();
const row = createQueryApi(db).summaries()[0];
hasKeys(row, ['session_title', 'project'], 'summaries() row');
db.close();
});
test('overview() shape matches api-reference.md', () => {
const db = fixture();
const view = createQueryApi(db).overview({ limit: 5 });
exactKeys(view, ['current', 'current_project', 'projects', 'totals'], 'overview()');
exactKeys(view.current, ['cwd', 'project'], 'overview() current');
exactKeys(view.totals, ['projects', 'sessions', 'memories', 'sources'], 'overview() totals');
db.close();
});
test('forget() result shape matches api-reference.md', () => {
const db = fixture();
const api = createAttuneApi(db);
const forgotten = api.forget({ id: 'mem-1', reason: 'contract test' });
exactKeys(forgotten, ['id', 'deleted_at', 'deleted_reason'], 'forget()');
db.close();
});
test('raw() shape matches api-reference.md', () => {
const dir = mkdtempSync(join(tmpdir(), 'obelisk-raw-'));
const jsonlPath = join(dir, 'session.jsonl');
const line = JSON.stringify({ uuid: 'm-raw', type: 'user', message: { role: 'user', content: 'raw line body' } });
writeFileSync(jsonlPath, line + '\n');
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare(`INSERT INTO sessions (id, title, project, project_path, jsonl_path, source)
VALUES (?, ?, ?, ?, ?, ?)`).run('sid-raw', 'Raw session', 'quiet-zero', dir, jsonlPath, 'claude');
db.prepare(`INSERT INTO messages (uuid, session_id, type, role, text, content_type, source)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('m-raw', 'sid-raw', 'user', 'user', 'raw line body', 'text', 'claude');
const result = createQueryApi(db).raw('m-raw');
exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore'], 'raw()');
assert.equal(result.text, line);
assert.equal(result.totalLength, line.length);
db.close();
});
test('doc-sync guard: every asserted contract key appears in api-reference.md', () => {
// Runs after the shape tests have populated assertedKeys. Guarantees the test
// contract and the authoritative doc cannot drift apart silently.
assert.ok(assertedKeys.size > 0, 'expected contract keys to have been asserted');
const missing = [...assertedKeys].filter(k => !API_REFERENCE.includes(k));
assert.deepEqual(missing, [], `keys asserted in tests but absent from api-reference.md: ${missing.join(', ')}`);
});
+13
View File
@@ -65,6 +65,19 @@ function searchDb() {
return db;
}
test('search falls back to safe tokenization for FTS-special input instead of throwing', () => {
const db = searchDb();
const api = createQueryApi(db);
// 'needle-reply' is FTS5 operator syntax (a hyphen). Raw MATCH would throw;
// search() must fall back to safe per-token quoting ("needle" "reply") and
// still find the message that contains both tokens.
const rows = api.search('needle-reply', { limit: 5 });
assert.deepEqual(rows.map(r => r.message.uuid), ['msg-text']);
db.close();
});
test('search exposes content_type on hits and temporal context', () => {
const db = searchDb();
const api = createQueryApi(db);
+6 -8
View File
@@ -108,16 +108,14 @@ test('unknown verb prints usage to stderr and exits non-zero', () => {
assert.match(result.stderr, /--build/);
});
test('--search shares the { error, stack } envelope instead of crashing to stderr', () => {
// A hyphenated term is FTS5 syntax and makes search() throw. The uniform
// error envelope (see docs/adr/0002) requires this surface as { error, stack }
// on stdout with exit 1 — the same shape as --query/--attune — not a raw crash.
test('--search tolerates FTS-special input via safe tokenization', () => {
// A hyphenated term is FTS5 operator syntax. search() falls back to safe
// per-token quoting instead of crashing, so the CLI returns an array, not an
// error. (The uniform { error, stack } envelope is exercised via --query/--attune.)
const home = tempHome();
const result = runRuntime(['--search', 'foo-bar'], { home });
assert.equal(result.status, 1);
const payload = JSON.parse(result.stdout);
assert.equal(typeof payload.error, 'string');
assert.equal(typeof payload.stack, 'string');
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
});