diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 389fa1a..53c7452 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -308,7 +308,10 @@ export function createContextTimelineDefinition(config: Config, slim: () => bool // replacing the client-side conversation-window derivation. Cached rows // refold from the log, which rebuilds the op log for sessions started // under older plugin builds. - stateVersion: 14, + // 15: a search with the complete matched-file meta now books its call + // TARGET (the searched path / the pattern) in addition to the per-file + // hit rows — the op log's fold semantics changed, so cached rows refold. + stateVersion: 15, } return definition } diff --git a/src/shared/fileOps.ts b/src/shared/fileOps.ts index 9a2d03d..e070aeb 100644 --- a/src/shared/fileOps.ts +++ b/src/shared/fileOps.ts @@ -221,17 +221,34 @@ export function opsOfCall(input: { } // A search whose result meta carries the COMPLETE matched-file list rows // its ops per real file — even when the call's own arguments failed to - // parse (the detail line just drops out). + // parse (the detail line just drops out). The call's own target (the + // searched path, or the pattern for a workspace-wide search) rows TOO — + // "what was searched" and "what got hit" both count; only the degenerate + // single-file search (the target IS the sole matched file) skips the + // duplicate target row. if (kind === 'search') { const files = searchFilesOf(input.meta) if (files !== null) { const detail = searchDetailOf(args) - return files.map(f => ({ - ...stamp, - path: f.path, - ...(detail !== undefined ? { detail } : {}), - ...(f.hits > 0 ? { hits: f.hits } : {}), - })) + const target = args !== null ? pathOfArgs(input.tool, args) : null + const narrowed = args !== null && typeof args.path === 'string' && args.path !== '' + const targetOps: FileOpRecord[] = target !== null && !files.some(f => f.path === target) + ? [{ + ...stamp, + path: target, + ...(narrowed && detail !== undefined ? { detail } : {}), + ...(narrowed ? {} : { pattern: true as const }), + }] + : [] + return [ + ...targetOps, + ...files.map(f => ({ + ...stamp, + path: f.path, + ...(detail !== undefined ? { detail } : {}), + ...(f.hits > 0 ? { hits: f.hits } : {}), + })), + ] } } if (args === null) return [] diff --git a/tests/client/fileActivity.spec.ts b/tests/client/fileActivity.spec.ts index 2507b54..ab03f06 100644 --- a/tests/client/fileActivity.spec.ts +++ b/tests/client/fileActivity.spec.ts @@ -507,16 +507,21 @@ describe('activityOf — search meta attribution', () => { meta: { shape: 'paths', truncated: false, total: 1, paths: ['/old.ts'] }, }), ]) - assert.deepEqual(a.entries.map(e => e.path), ['/old.ts', 'broad', '/src/a.ts', '/src/b.ts']) - const first = a.entries[2] - assert.equal(first.searches, 1) - assert.equal(first.ops[0].hits, 2) - assert.equal(first.ops[0].detail, 'needle') - assert.equal(a.entries[3].ops[0].hits, 1) + // The searched target rows too: '/src' (the narrowed path) and the two + // pathless patterns ride ahead of their hit files. + assert.deepEqual(a.entries.map(e => e.path), ['old', '/old.ts', 'broad', '/src', '/src/a.ts', '/src/b.ts']) + const byPath = new Map(a.entries.map(e => [e.path, e])) + const srcA = byPath.get('/src/a.ts')! + assert.equal(srcA.searches, 1) + assert.equal(srcA.ops[0].hits, 2) + assert.equal(srcA.ops[0].detail, 'needle') + assert.equal(byPath.get('/src/b.ts')!.ops[0].hits, 1) + // The searched directory's row carries the pattern detail. + assert.equal(byPath.get('/src')!.ops[0].detail, 'needle') // The archived, timeless meta row carries the node's stamp (and no time). - assert.equal(a.entries[0].ops[0].gone, 20) - assert.equal(a.entries[0].ops[0].time, undefined) - assert.deepEqual(a.totals.search, { files: 4, ops: 4 }) + assert.equal(byPath.get('/old.ts')!.ops[0].gone, 20) + assert.equal(byPath.get('/old.ts')!.ops[0].time, undefined) + assert.deepEqual(a.totals.search, { files: 6, ops: 6 }) }) test('an include filter joins the detail; glob metas row without hit counts', () => { @@ -528,10 +533,13 @@ describe('activityOf — search meta attribution', () => { meta: { shape: 'paths', truncated: false, total: 2, paths: ['/r.md', '/o.md'] }, }), ]) - assert.deepEqual(a.entries.map(e => e.path), ['/r.md', '/o.md', '/a.ts']) - assert.equal(a.entries[2].ops[0].detail, 'needle (*.ts)') - assert.equal(a.entries[1].ops[0].hits, undefined) // a listed path matched no lines - assert.equal(a.entries[1].ops[0].detail, '**/*.md') + // Each call rows its target (the pattern) plus the hit files. + assert.deepEqual(a.entries.map(e => e.path), ['**/*.md', '/r.md', '/o.md', 'needle', '/a.ts']) + const byPath = new Map(a.entries.map(e => [e.path, e])) + assert.equal(byPath.get('/a.ts')!.ops[0].detail, 'needle (*.ts)') + assert.equal(byPath.get('/o.md')!.ops[0].hits, undefined) // a listed path matched no lines + assert.equal(byPath.get('/o.md')!.ops[0].detail, '**/*.md') + assert.equal(byPath.get('needle')!.ops[0].pattern, true, 'the pathless target is a pattern row') }) test('malformed or empty metas fall back to the pattern row', () => { @@ -557,7 +565,8 @@ describe('activityOf — search meta attribution', () => { const a = run([op(10, 'grep', { pattern: 'needle', path: '/src' }, {}, { meta: { shape: 'paths', truncated: false, total: 2, paths: ['/a.ts', ''] }, })]) - assert.deepEqual(a.entries.map(e => e.path), ['/a.ts']) + // The valid sibling attributes; the searched path rows as the call's target. + assert.deepEqual(a.entries.map(e => e.path), ['/src', '/a.ts']) }) test('a truncated flag is required — a missing one falls back', () => { diff --git a/tests/host/compat/registry.spec.ts b/tests/host/compat/registry.spec.ts index 5178c36..8aa39e4 100644 --- a/tests/host/compat/registry.spec.ts +++ b/tests/host/compat/registry.spec.ts @@ -151,7 +151,7 @@ for (const [index, baseline] of BASELINES.entries()) { assert.ok(rows !== undefined, 'checkpoint rows are losslessly JSON-serializable') for (const key of ['contextTimeline', 'contextHeaders']) { const row = rows[key] as { ver: number; seq: number } - assert.equal(row.ver, key === 'contextTimeline' ? 14 : 1) + assert.equal(row.ver, key === 'contextTimeline' ? 15 : 1) assert.equal(row.seq, 11) } // And the write-gate equivalent on every intermediate state of a fresh fold. @@ -291,7 +291,7 @@ for (const [index, baseline] of BASELINES.entries()) { driver.register(def) // Same key at the SAME stateVersion shares the registration (preset ref-counting). assert.doesNotThrow(() => driver.register(createContextTimelineDefinition({}, () => false))) - assert.throws(() => driver.register({ ...def, stateVersion: 15 }), RegistryViolationError) + assert.throws(() => driver.register({ ...def, stateVersion: 16 }), RegistryViolationError) }) }) } diff --git a/tests/host/fold-ops.spec.ts b/tests/host/fold-ops.spec.ts index 7c1e4bd..2f29941 100644 --- a/tests/host/fold-ops.spec.ts +++ b/tests/host/fold-ops.spec.ts @@ -43,6 +43,8 @@ describe('the file-op log — call/result pairing', () => { ]) assert.deepEqual(state.fileOps.map(o => [o.kind, o.path, o.added, o.removed, o.hits ?? 0]), [ ['write', 'a.ts', 1, 2, 0], + // The searched target (the pathless pattern) rows too, then the hit file. + ['search', 'TODO', 0, 0, 0], ['search', 'a.ts', 0, 0, 2], ]) }) diff --git a/tests/host/timeline.spec.ts b/tests/host/timeline.spec.ts index e2699da..e67b773 100644 --- a/tests/host/timeline.spec.ts +++ b/tests/host/timeline.spec.ts @@ -49,7 +49,7 @@ describe('createContextTimelineDefinition', () => { test('carries the supported projection contract on one unit', () => { const def = createContextTimelineDefinition({}, () => false) assert.equal(def.key, 'contextTimeline') - assert.equal(def.stateVersion, 14) + assert.equal(def.stateVersion, 15) assert.equal(typeof def.init, 'function') assert.equal(typeof def.apply, 'function') // The supported registry contract: stateSchema + a REQUIRED wire block. diff --git a/tests/shared/fileOps.spec.ts b/tests/shared/fileOps.spec.ts index 078d7d2..753f50d 100644 --- a/tests/shared/fileOps.spec.ts +++ b/tests/shared/fileOps.spec.ts @@ -83,27 +83,53 @@ describe('opsOfCall — one op per settled call', () => { assert.deepEqual([op.path, op.pattern, op.detail], ['**/*.md', true, undefined]) }) - test('the complete matches meta attributes one op per file with hit counts', () => { + test('the complete matches meta attributes the searched target AND one op per hit file', () => { const ops = opsOfCall({ seq: 5, tool: 'grep', argsRaw: JSON.stringify({ pattern: 'TODO' }), meta: grepMeta({ 'a.ts': 3, 'b.ts': 1 }), }) - assert.deepEqual(ops.map(o => [o.path, o.hits]), [['a.ts', 3], ['b.ts', 1]]) - assert.ok(ops.every(o => o.detail === 'TODO'), 'the pattern detail rides each attributed op') - assert.ok(ops.every(o => o.pattern === undefined)) + // The searched path/pattern rows first (here the workspace-wide pattern), + // then each hit file with its count. + assert.deepEqual(ops.map(o => [o.path, o.hits]), [['TODO', undefined], ['a.ts', 3], ['b.ts', 1]]) + assert.equal(ops[0].pattern, true, 'the pathless target rows as the pattern') + assert.ok(ops.slice(1).every(o => o.detail === 'TODO'), 'the pattern detail rides each hit op') + assert.ok(ops.slice(1).every(o => o.pattern === undefined)) }) - test('the complete paths meta attributes without hit counts', () => { + test('the complete paths meta attributes the target plus the hits without counts', () => { const ops = opsOfCall({ seq: 1, tool: 'glob', argsRaw: JSON.stringify({ pattern: '*.ts' }), meta: { shape: 'paths', truncated: false, total: 2, paths: ['a.ts', 'b.ts'] }, }) - assert.deepEqual(ops.map(o => o.path), ['a.ts', 'b.ts']) + assert.deepEqual(ops.map(o => o.path), ['*.ts', 'a.ts', 'b.ts']) assert.ok(ops.every(o => o.hits === undefined)) }) + test('a narrowed search rows the searched directory AND each hit file', () => { + const ops = opsOfCall({ + seq: 1, tool: 'grep', + argsRaw: JSON.stringify({ pattern: 'TODO', path: 'src/', include: '*.ts' }), + meta: grepMeta({ 'src/a.ts': 2, 'src/b.ts': 1 }), + }) + // The searched directory rows first with the pattern detail, then the hits. + assert.deepEqual(ops.map(o => [o.path, o.hits ?? 0]), [['src/', 0], ['src/a.ts', 2], ['src/b.ts', 1]]) + assert.equal(ops[0].detail, 'TODO (*.ts)') + assert.equal(ops[0].pattern, undefined, 'a real path is not a pattern row') + }) + + test('a single-file search does not double-row the target (it IS the matched file)', () => { + const ops = opsOfCall({ + seq: 1, tool: 'grep', + argsRaw: JSON.stringify({ pattern: 'TODO', path: 'src/a.ts' }), + meta: grepMeta({ 'src/a.ts': 2 }), + }) + assert.deepEqual(ops.map(o => o.path), ['src/a.ts'], 'one row, with its hit count') + assert.equal(ops[0].hits, 2) + assert.equal(ops[0].detail, 'TODO') + }) + test('a truncated or malformed meta falls back to the call target', () => { const capped = opsOfCall({ seq: 1, tool: 'grep',