feat: count the searched target alongside the matched files in the op log
A grep/glob with the complete matched-file meta rowed ONLY per hit file — the searched path (or the workspace-wide pattern) vanished from the File Activity card entirely. Both now book: the call's target row first (the searched path, carrying the pattern(+include) detail; a pathless search rows the pattern itself, marked as a pattern), then one row per matched file with its hit count. The degenerate single-file search (the target IS the matched file) keeps one row. Truncated or malformed metas keep the honest fallback (the call target only). The fold's op-log semantics change, so stateVersion bumps to 15 and cached rows refold. Verified: 1147 tests, per-file 100% coverage; live on a grep-heavy session the card counts 80 search ops — the searched directories row alongside their per-file hits (plus a deterministic fold over the real log: 6 target rows + 107 hit rows).
This commit is contained in:
1 parent
5c99a476f3
commit
6faf8f16bf
7 files changed
+88
-31
No files matched your search
@@ -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
|
||||
}
|
||||
+24
-7
@@ -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 []
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user