diff --git a/docs/guide/knowledge.md b/docs/guide/knowledge.md index 04c86161c..cd5068a8c 100644 --- a/docs/guide/knowledge.md +++ b/docs/guide/knowledge.md @@ -108,7 +108,7 @@ Local feedback affects ranking on the next index rebuild (for example, `teamai p Index rebuilds reuse unchanged files' parsed tokens. Changes, deletions, source selection and votes still take effect on every build. `~/.teamai/debug.log` reports how many files were reused, rebuilt or dropped. Older indexes and a different Node/ICU version trigger a full parse once. -When the log has runs, `teamai stats` lists the last 10 sessions that ran recall. +When the log has runs, `teamai stats` lists the last 10 sessions that ran recall: their runs, the docs recalled and adopted, and how many runs the session opened a `Related:` line's file from. ### Enabling / Disabling Recall diff --git a/docs/guide/zh-CN/knowledge.md b/docs/guide/zh-CN/knowledge.md index c704369e9..30d320bc2 100644 --- a/docs/guide/zh-CN/knowledge.md +++ b/docs/guide/zh-CN/knowledge.md @@ -92,7 +92,7 @@ teamai recall feedback --negative 重建索引会复用未变文件的解析和分词结果;文件修改、删除、来源选择和票数仍在每次构建时生效。`~/.teamai/debug.log` 会记录复用、重建和移除的文件数。旧索引或不同的 Node/ICU 版本会触发一次完整解析。 -`teamai stats` 在有记录时,会列出最近 10 个跑过 recall 的会话。 +`teamai stats` 在有记录时,会列出最近 10 个跑过 recall 的会话:各会话的运行次数、召回与采纳的文档数,以及会话从 `Related:` 行打开相关文件的运行次数。 ### 开启 / 关闭 Recall diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index dc87ebb4f..8364fc233 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -489,7 +489,7 @@ Generated: do not edit by hand. Regenerate with - `teamai codebase` — Inspect and maintain the teamwiki that `teamai import` writes - `--project ` (hidden) — Codebase slug under teamwiki/evidence/code/, required for --deep-enrich - `--upgrade-wiki` (hidden) — Migrate docs/team-codebase/ to teamwiki/ graph format - - `--lint` — Run global consistency lint over the teamwiki knowledge graph + - `--lint` — Run global consistency lint over the teamwiki knowledge graph, and count the member's asset relations (assets, relations, without a relation, unresolved) - `--reconcile` — Reconcile product and code knowledge in teamwiki - `--deep-enrich` — Generate deep knowledge docs from extracted evidence - `--fix` (hidden) — Deprecated: teamwiki lint has no autofix; runs lint in report-only mode diff --git a/src/__tests__/asset-relations.test.ts b/src/__tests__/asset-relations.test.ts index 38a566f11..3960d4023 100644 --- a/src/__tests__/asset-relations.test.ts +++ b/src/__tests__/asset-relations.test.ts @@ -25,7 +25,7 @@ import { import type { RelationSeverity, StoredRelation } from '../asset-relations.js'; import { indexInMemory } from '../utils/search-index.js'; import { localLinkTargets } from '../utils/markdown-links.js'; -import { buildRelationContext, collectCodePages, formatResults, relationLines, computeDerivedRelations } from '../recall.js'; +import { buildRelationContext, collectCodePages, formatResults, relationLines, computeDerivedRelations, relatedRefsOf } from '../recall.js'; import type { RelationContext } from '../recall.js'; import { matchGlob } from '../utils/glob.js'; import type { SearchIndex, SearchIndexEntry } from '../types.js'; @@ -533,6 +533,52 @@ describe('formatResults — Related: lines', () => { }); }); +describe('relatedRefsOf — the run line records what stdout shows (#1562)', () => { + it('returns one ref per printed Related: line, in print order, with direction', () => { + const docEntry = entry({ + filename: 'overview.md', treePath: 'docs/overview.md', path: '/repo/docs/overview.md', type: 'docs', + }); + const ruleHit = { + entry: entry({ + filename: 'payments.md', treePath: 'rules/payments.md', path: '/repo/rules/payments.md', + related: [{ relation: 'SUPERSEDES', target: 'docs/overview.md', via: 'declared' }], + }), + score: 5.1, scope: 'user' as const, + }; + const docHit = { entry: docEntry, score: 5.0, scope: 'user' as const }; + const relations = new Map([['user', ctx({ + byTreePath: new Map([ + ['docs/overview.md', docEntry], + ['rules/payments.md', ruleHit.entry], + ]), + incoming: new Map([['docs/overview.md', [{ from: 'rules/payments.md', relation: 'SUPERSEDES', via: 'declared' }]]]), + })]]); + + const output = formatResults([ruleHit, docHit], undefined, relations); + const refs = relatedRefsOf([ruleHit, docHit], relations); + + const printed = output.split('\n').filter((l) => l.startsWith('Related:')); + expect(printed).toHaveLength(2); + expect(refs).toHaveLength(2); + expect(refs[0]).toEqual([{ relation: 'SUPERSEDES', direction: 'out', path: '/repo/docs/overview.md' }]); + expect(refs[1]).toEqual([{ relation: 'SUPERSEDES', direction: 'in', path: '/repo/rules/payments.md' }]); + // Each ref rebuilds exactly the line stdout showed. + const lineOf = (r: { relation: string; direction: string; path: string }): string => + `Related: ${r.direction === 'out' ? '→' : '←'} ${r.relation} ${r.path}`; + expect(refs.flat().map(lineOf)).toEqual(printed); + }); + + it('returns no refs for a hit with no printed relations', () => { + const hit = { + entry: entry({ filename: 'overview.md', treePath: 'docs/overview.md', path: '/repo/docs/overview.md', type: 'docs' }), + score: 5.0, scope: 'user' as const, + }; + const relations = new Map([['user', ctx()]]); + expect(relatedRefsOf([hit], relations)).toEqual([[]]); + expect(formatResults([hit], undefined, relations)).not.toContain('Related:'); + }); +}); + describe('ASSET_RELATIONS', () => { it('is the four declarable relation names', () => { expect(ASSET_RELATIONS).toEqual(['MAPS_TO', 'REFERENCES', 'SUPERSEDES', 'CONFLICTS_WITH']); diff --git a/src/__tests__/dashboard-ui.test.ts b/src/__tests__/dashboard-ui.test.ts index 401c20c79..3c31396b6 100644 --- a/src/__tests__/dashboard-ui.test.ts +++ b/src/__tests__/dashboard-ui.test.ts @@ -79,6 +79,10 @@ describe('unified dashboard', () => { totalEntries: 1, totalRecalls: 0, overallCoveragePct: 0, contributorCount: 1, coverage: [{ type: 'docs', total: 1, covered: 0, coveragePct: 0 }], topRecalled: [], silent: [{ docId: 'x', title: '', author: '', type: 'docs', date: '', tags: [], recalledCount: 0, upvotedCount: 0, lastRecalledAt: null }], + relations: { + assetCount: 2, relationCount: 1, withoutRelation: ['docs/'], + unresolved: ['rules/x.md REFERENCES docs/'], silentAndWithoutRelation: [], + }, trend: [], authors: [{ author: '', entries: 1, totalRecalled: 0 }], maintenance: { promote: [], prune: [], stale: [] }, }; @@ -111,6 +115,10 @@ describe('unified dashboard', () => { coverage: [{ type: 'docs', total: 3, covered: 2, coveragePct: 60 }], topRecalled: [{ docId: 'a', title: 'A', author: 'x', type: 'docs', date: '', tags: [], recalledCount: 5, upvotedCount: 1, lastRecalledAt: null }], silent: [{ docId: 'b', title: 'B', author: 'x', type: 'docs', date: '', tags: [], recalledCount: 0, upvotedCount: 0, lastRecalledAt: null }], + relations: { + assetCount: 3, relationCount: 2, withoutRelation: ['docs/b.md'], + unresolved: ['rules/a.md MAPS_TO teamwiki/evidence/code/x/m.md'], silentAndWithoutRelation: ['docs/b.md'], + }, trend: [{ period: '2026-08', count: 1 }, { period: '2026-09', count: 2 }], authors: [{ author: 'x', entries: 3, totalRecalled: 5 }], maintenance: { diff --git a/src/__tests__/lint-asset-graph.test.ts b/src/__tests__/lint-asset-graph.test.ts new file mode 100644 index 000000000..93a6f9798 --- /dev/null +++ b/src/__tests__/lint-asset-graph.test.ts @@ -0,0 +1,534 @@ +/** + * Tests for `teamai codebase --lint` asset-relation counts (#1562 part 1): + * the pure counting function, and the command's text/JSON output against a + * team-clone fixture built the way `codebase-reconcile.test.ts` builds one. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { codebaseCmd } from '../codebase-cmd.js'; +import { autoDetectInit, detectProjectConfig, requireInit, type TeamaiInit } from '../config.js'; +import { computeLintAssetGraph } from '../lint-asset-graph.js'; +import type { LintScopeGraph } from '../lint-asset-graph.js'; +import type { RelationContext } from '../recall.js'; +import { LocalConfigSchema, TeamaiConfigSchema, type SearchIndexEntry } from '../types.js'; +import { createGit } from '../utils/git.js'; +import { indexInMemory } from '../utils/search-index.js'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + autoDetectInit: vi.fn(), + detectProjectConfig: vi.fn(), + requireInit: vi.fn(), +})); + +// ─── computeLintAssetGraph ────────────────────────────── + +function entry(overrides: Partial = {}): SearchIndexEntry { + return { + filename: 'x.md', title: 'X', author: '', date: '', tags: [], tokens: [], votes: 0, + type: 'rules', ...overrides, + }; +} + +function ctx(overrides: Partial = {}): RelationContext { + return { + byTreePath: new Map(), + incoming: new Map(), + teamwikiRoot: '/nonexistent-teamwiki', + withheld: new Set(), + ...overrides, + }; +} + +/** An existence check where every listed path exists on disk, nothing else does. */ +function existsOnly(existing: string[]): LintScopeGraph['targetExists'] { + return (_kind, treePath) => existing.includes(treePath); +} + +const PAGE = 'teamwiki/evidence/code/pay/modules/ledger-receipts.md'; + +/** The #1552 example assets: the payments rule, its doc, a skill and a learning. */ +function exampleScope(overrides: Partial = {}): LintScopeGraph { + const rule = entry({ + filename: 'payments.md', treePath: 'rules/payments.md', + related: [ + { relation: 'MAPS_TO', target: PAGE, via: 'declared' }, + { relation: 'REFERENCES', target: 'docs/payments-overview.md', via: 'declared' }, + { relation: 'REFERENCES', target: 'skills/release', via: 'declared' }, + // The typo from #1562 part 1: no such doc on disk. + { relation: 'REFERENCES', target: 'docs/payment-overview.md', via: 'declared' }, + ], + }); + const doc = entry({ filename: 'payments-overview.md', treePath: 'docs/payments-overview.md', type: 'docs' }); + const skill = entry({ filename: 'release.md', treePath: 'skills/release', type: 'skills' }); + const learning = entry({ + filename: 'ledger-timeout.md', treePath: 'learnings/ledger-timeout', type: 'learnings', + // The doc is superseded by the learning (#1552's example). + related: [{ relation: 'SUPERSEDES', target: 'docs/payments-overview.md', via: 'declared' }], + }); + return { + ctx: ctx({ + byTreePath: new Map([ + ['rules/payments.md', rule], + ['docs/payments-overview.md', doc], + ['skills/release', skill], + ['learnings/ledger-timeout', learning], + ]), + }), + targetExists: existsOnly(['docs/payments-overview.md', 'skills/release', 'learnings/ledger-timeout', PAGE]), + ...overrides, + }; +} + +describe('computeLintAssetGraph', () => { + it('counts the #1552 example plus the typo: 4 assets, 4 relations, 0 without, 1 unresolved', () => { + expect(computeLintAssetGraph([exampleScope()])).toEqual({ + assetCount: 4, + relationCount: 4, + withoutRelation: [], + unresolved: [ + { from: 'rules/payments.md', relation: 'REFERENCES', target: 'docs/payment-overview.md', reason: 'no-asset' }, + ], + }); + }); + + it('counts a relation to a teamwiki page when the page exists and is not withheld', () => { + // The MAPS_TO to the page is the only relation of a lone rule. + const rule = entry({ + filename: 'payments.md', treePath: 'rules/payments.md', + related: [{ relation: 'MAPS_TO', target: PAGE, via: 'declared' }], + }); + const visible = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/payments.md', rule]]) }), + targetExists: existsOnly([PAGE]), + }]); + expect(visible).toEqual({ + assetCount: 1, + relationCount: 1, + withoutRelation: [], + unresolved: [], + }); + // A withheld codebase's page gives no relation; a page the clone does not + // hold resolves for nobody, so the declared relation is unresolved. + const withheld = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/payments.md', rule]]), withheld: new Set(['pay']) }), + targetExists: existsOnly([PAGE]), + }]); + expect(withheld.relationCount).toBe(0); + expect(withheld.withoutRelation).toEqual(['rules/payments.md']); + const missing = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/payments.md', rule]]) }), + targetExists: existsOnly([]), + }]); + expect(missing.relationCount).toBe(0); + expect(missing.unresolved).toEqual([ + { from: 'rules/payments.md', relation: 'MAPS_TO', target: PAGE, reason: 'no-asset' }, + ]); + }); + + it('declared beats link by target: a link to a declared target is prose', () => { + const learning = entry({ + filename: 'ledger-timeout.md', treePath: 'learnings/ledger-timeout', type: 'learnings', + related: [ + { relation: 'SUPERSEDES', target: 'docs/payments-overview.md', via: 'declared' }, + { relation: 'REFERENCES', target: 'docs/payments-overview.md', via: 'link' }, + ], + }); + const doc = entry({ filename: 'payments-overview.md', treePath: 'docs/payments-overview.md', type: 'docs' }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['learnings/ledger-timeout', learning], ['docs/payments-overview.md', doc]]) }), + targetExists: existsOnly(['docs/payments-overview.md']), + }]); + expect(graph.relationCount).toBe(1); + expect(graph.withoutRelation).toEqual([]); + }); + + it('reports a declared relation to a teamwiki page that does not exist on disk as unresolved', () => { + const rule = entry({ + filename: 'payments.md', treePath: 'rules/payments.md', + related: [{ relation: 'MAPS_TO', target: PAGE, via: 'declared' }], + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/payments.md', rule]]) }), + targetExists: existsOnly([]), + }]); + expect(graph.unresolved).toEqual([ + { from: 'rules/payments.md', relation: 'MAPS_TO', target: PAGE, reason: 'no-asset' }, + ]); + }); + + it('lists assets with no resolved relation in either direction', () => { + const orphan = entry({ filename: 'orphan.md', treePath: 'rules/orphan.md' }); + const byTreePath = new Map(exampleScope().ctx.byTreePath); + byTreePath.set('rules/orphan.md', orphan); + const graph = computeLintAssetGraph([{ ...exampleScope(), ctx: ctx({ byTreePath }) }]); + expect(graph.assetCount).toBe(5); + expect(graph.withoutRelation).toEqual(['rules/orphan.md']); + }); + + it('counts a link-derived relation, with declared beating link between the same pair', () => { + const rule = entry({ + filename: 'a.md', treePath: 'rules/a.md', + related: [ + { relation: 'REFERENCES', target: 'docs/b.md', via: 'declared' }, + { relation: 'REFERENCES', target: 'docs/b.md', via: 'link' }, + ], + }); + const doc = entry({ filename: 'b.md', treePath: 'docs/b.md', type: 'docs' }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/a.md', rule], ['docs/b.md', doc]]) }), + targetExists: existsOnly(['docs/b.md']), + }]); + expect(graph.relationCount).toBe(1); + expect(graph.withoutRelation).toEqual([]); + }); + + it('counts A→B and B→A of the same relation as one relation', () => { + const a = entry({ + filename: 'a.md', treePath: 'rules/a.md', + related: [{ relation: 'REFERENCES', target: 'rules/b.md', via: 'declared' }], + }); + const b = entry({ + filename: 'b.md', treePath: 'rules/b.md', + related: [{ relation: 'REFERENCES', target: 'rules/a.md', via: 'declared' }], + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/a.md', a], ['rules/b.md', b]]) }), + targetExists: existsOnly([]), + }]); + expect(graph.relationCount).toBe(1); + expect(graph.withoutRelation).toEqual([]); + }); + + it('counts path-derived rule→page edges from the scope code-page pool, with declared beating derived', () => { + const pool = [{ treePath: PAGE, slug: 'pay', sources: ['src/ledger/receipts.ts'] }]; + const scoped = entry({ + filename: 'ledger-scoped.md', treePath: 'rules/ledger-scoped.md', + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ + byTreePath: new Map([['rules/ledger-scoped.md', scoped]]), + ruleGlobs: new Map([['rules/ledger-scoped.md', ['src/ledger/**']]]), + codePages: pool, + }), + targetExists: existsOnly([]), + }]); + expect(graph.relationCount).toBe(1); + expect(graph.withoutRelation).toEqual([]); + }); + + it('a glob spanning two codebases derives no counted edge, as recall renders none', () => { + const pool = [ + { treePath: PAGE, slug: 'pay', sources: ['src/ledger/receipts.ts'] }, + { treePath: 'teamwiki/evidence/code/shop/modules/till.md', slug: 'shop', sources: ['src/ledger/shop.ts'] }, + ]; + const scoped = entry({ filename: 'ledger-scoped.md', treePath: 'rules/ledger-scoped.md' }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ + byTreePath: new Map([['rules/ledger-scoped.md', scoped]]), + ruleGlobs: new Map([['rules/ledger-scoped.md', ['src/ledger/**']]]), + codePages: pool, + }), + targetExists: existsOnly([]), + }]); + expect(graph.relationCount).toBe(0); + expect(graph.withoutRelation).toEqual(['rules/ledger-scoped.md']); + }); + + it('a declared relation from the rule to the page beats the derived edge', () => { + const pool = [{ treePath: PAGE, slug: 'pay', sources: ['src/ledger/receipts.ts'] }]; + const rule = entry({ + filename: 'payments.md', treePath: 'rules/payments.md', + related: [{ relation: 'MAPS_TO', target: PAGE, via: 'declared' }], + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ + byTreePath: new Map([['rules/payments.md', rule]]), + ruleGlobs: new Map([['rules/payments.md', ['src/ledger/**']]]), + codePages: pool, + }), + targetExists: existsOnly([PAGE]), + }]); + expect(graph.relationCount).toBe(1); // declared only, not declared + derived + }); + + it('a declared REFERENCES the target links back to counts two: declared + link, as recall prints', () => { + const rule = entry({ + filename: 'a.md', treePath: 'rules/a.md', + related: [{ relation: 'REFERENCES', target: 'docs/b.md', via: 'declared' }], + }); + const doc = entry({ + filename: 'b.md', treePath: 'docs/b.md', type: 'docs', + related: [{ relation: 'REFERENCES', target: 'rules/a.md', via: 'link' }], + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/a.md', rule], ['docs/b.md', doc]]) }), + targetExists: existsOnly(['docs/b.md']), + }]); + // The count follows what recall renders — one per relation line — not + // one per unordered pair: the via stays in the key (#1637 review). + expect(graph.relationCount).toBe(2); + expect(graph.withoutRelation).toEqual([]); + }); + + it('does not resolve a target that exists on disk but is not in the index, and does not call it unresolved', () => { + const rule = entry({ + filename: 'a.md', treePath: 'rules/a.md', + related: [{ relation: 'REFERENCES', target: 'docs/hidden.md', via: 'declared' }], + }); + const graph = computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/a.md', rule]]) }), + // The doc exists in the team clone but is not delivered to this member's index. + targetExists: existsOnly(['docs/hidden.md']), + }]); + expect(graph).toEqual({ + assetCount: 1, + relationCount: 0, + withoutRelation: ['rules/a.md'], + unresolved: [], + }); + }); + + it('checks learnings against the learnings roots, with the .md the roots store them under', () => { + const seen: string[] = []; + const rule = entry({ + filename: 'a.md', treePath: 'rules/a.md', + related: [{ relation: 'REFERENCES', target: 'learnings/ledger-timeout', via: 'declared' }], + }); + const learning = entry({ filename: 'ledger-timeout.md', treePath: 'learnings/ledger-timeout', type: 'learnings' }); + computeLintAssetGraph([{ + ctx: ctx({ byTreePath: new Map([['rules/a.md', rule], ['learnings/ledger-timeout', learning]]) }), + targetExists: (kind, treePath) => { + seen.push(`${kind}:${treePath}`); + return false; // not in the roots → unresolved + }, + }]); + expect(seen).toEqual(['learning:learnings/ledger-timeout']); + }); +}); + +// ─── codebaseCmd integration ──────────────────────────── + +const temporaryDirectories: string[] = []; +let originalCwd: string; +let stubbedHome: string; + +function makeTmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-lint-graph-')); + temporaryDirectories.push(dir); + return dir; +} + +/** The #1552 example assets in a team clone, with the typo added to the rule (#1562). */ +function writeTeamAssets(clone: string, opts: { withTeamwiki?: boolean } = {}): void { + fs.mkdirSync(path.join(clone, 'rules'), { recursive: true }); + fs.mkdirSync(path.join(clone, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(clone, 'skills', 'release'), { recursive: true }); + if (opts.withTeamwiki !== false) { + fs.mkdirSync(path.join(clone, 'teamwiki', 'evidence', 'code', 'pay', 'modules'), { recursive: true }); + fs.writeFileSync( + path.join(clone, 'teamwiki', 'evidence', 'code', 'pay', 'modules', 'ledger-receipts.md'), + ['---', 'title: ledger-receipts', 'source:', ' - src/ledger/receipts.ts', '---', '', '# Ledger receipts module', ''].join('\n'), + ); + } + fs.writeFileSync(path.join(clone, 'rules', 'payments.md'), [ + '---', + 'related:', + ' MAPS_TO: teamwiki/evidence/code/pay/modules/ledger-receipts.md', + ' REFERENCES:', + ' - docs/payments-overview.md', + ' - skills/release', + // The typo from #1562 part 1. + ' - docs/payment-overview.md', + '---', + 'Every payment goes through ledger receipts; no direct writes to ledger.', + '', + ].join('\n')); + fs.writeFileSync(path.join(clone, 'docs', 'payments-overview.md'), 'How the payment flow works.\n'); + fs.writeFileSync( + path.join(clone, 'skills', 'release', 'SKILL.md'), + '---\nname: release\ndescription: Release workflow\n---\n\n# Release\n', + ); +} + +/** The pending learning queue of the member's install: beside the clone, for git teams. */ +function pendingDirOf(clone: string): string { + return path.join(path.dirname(clone), 'pending-learnings'); +} + +/** The expected assetGraph of the fixture: 4 assets, 4 relations, 0 without, 1 unresolved. */ +const EXPECTED_ASSET_GRAPH = { + assetCount: 4, + relationCount: 4, + withoutRelation: [], + unresolved: [ + { from: 'rules/payments.md', relation: 'REFERENCES', target: 'docs/payment-overview.md', reason: 'no-asset' }, + ], +}; + +/** + * A team clone of a bare remote whose main holds the example assets, a member + * user-scope config (mocked into the loaders), and the member's recall index + * built the way pull/contribute build it — the learning pending in the queue. + */ +async function createMemberFixture(withTeamwiki = true): Promise<{ clone: string; remote: string }> { + const root = makeTmpDir(); + const remote = path.join(root, 'remote.git'); + const clone = path.join(root, 'clone'); + await createGit(root).raw(['init', '--bare', '-b', 'main', remote]); + await createGit(root).clone(remote, clone); + const git = createGit(clone); + await git.addConfig('user.name', 'Test'); + await git.addConfig('user.email', 'test@example.com'); + writeTeamAssets(clone, { withTeamwiki }); + fs.writeFileSync(path.join(clone, 'teamai.yaml'), 'team: test\n'); + await git.add('.'); + await git.commit('team'); + await git.push(['-u', 'origin', 'main']); + + // The member's learning: pending locally, recall-indexed from the queue (#1562). + const pendingDir = pendingDirOf(clone); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(path.join(pendingDir, 'ledger-timeout.md'), [ + '---', + 'title: Ledger timeout', + 'related:', + ' SUPERSEDES: docs/payments-overview.md', + '---', + 'The receipt write times out at 3s; retry once.', + '', + ].join('\n')); + + const localConfig = LocalConfigSchema.parse({ repo: { localPath: clone, remote }, username: 'member', scope: 'user' }); + const init: TeamaiInit = { + localConfig, + teamConfig: TeamaiConfigSchema.parse({ team: 'test', repo: remote }), + }; + vi.mocked(autoDetectInit).mockResolvedValue(init); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(requireInit).mockResolvedValue(init); + + // The member's own recall index, at the user-scope path under the stubbed HOME. + const index = await indexInMemory({ + indexPath: path.join(stubbedHome, '.teamai', 'search-index.json'), + learningsDirs: [pendingDir], + docsDir: path.join(clone, 'docs'), + rulesDir: path.join(clone, 'rules'), + skills: { kind: 'dirs', dirs: [path.join(clone, 'skills', 'release')] }, + }); + fs.mkdirSync(path.join(stubbedHome, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(stubbedHome, '.teamai', 'search-index.json'), JSON.stringify(index)); + return { clone, remote }; +} + +beforeEach(() => { + originalCwd = process.cwd(); + stubbedHome = makeTmpDir(); + vi.stubEnv('HOME', stubbedHome); + vi.mocked(autoDetectInit).mockReset(); + vi.mocked(detectProjectConfig).mockReset(); + vi.mocked(requireInit).mockReset(); +}); + +afterEach(() => { + process.exitCode = undefined; + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.chdir(originalCwd); + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('codebase --lint asset counts (#1562 part 1)', () => { + it('prints the asset line after the report, with exactly the example counts', async () => { + await createMemberFixture(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true }); + + const out = log.mock.calls.flat().join('\n'); + expect(out).toContain('Assets: 4, relations: 4, without a relation: 0, unresolved: 1'); + // The graph checks ran too: the fixture's clone has no graph-index.json. + expect(out).toContain('[graph-missing]'); + }); + + it('embeds assetGraph in the JSON report', async () => { + await createMemberFixture(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true, json: true }); + + const report = JSON.parse(String(log.mock.calls[0][0])); + expect(report.assetGraph).toEqual(EXPECTED_ASSET_GRAPH); + expect(process.exitCode).toBe(1); // graph-missing stays a high finding; the exit code does not change + }); + + it('still prints the asset line without a teamwiki, and adds no graph-missing findings', async () => { + await createMemberFixture(false); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true }); + + // The declared MAPS_TO names a page this clone does not hold, so it is + // unresolved too — the graph checks are skipped, the counts are not. + expect(log.mock.calls.flat().join('\n')).toBe('Assets: 4, relations: 3, without a relation: 0, unresolved: 2'); + }); + + it('still embeds assetGraph in JSON without a teamwiki', async () => { + await createMemberFixture(false); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true, json: true }); + + const { assetGraph } = JSON.parse(String(log.mock.calls[0][0])); + expect(assetGraph.assetCount).toBe(4); + expect(assetGraph.relationCount).toBe(3); + expect(assetGraph.withoutRelation).toEqual([]); + expect(assetGraph.unresolved).toEqual([ + { from: 'rules/payments.md', relation: 'MAPS_TO', target: 'teamwiki/evidence/code/pay/modules/ledger-receipts.md', reason: 'no-asset' }, + { from: 'rules/payments.md', relation: 'REFERENCES', target: 'docs/payment-overview.md', reason: 'no-asset' }, + ]); + expect(process.exitCode).toBeUndefined(); + }); + + it('produces no assetGraph with standalone --output, and leaves the output unchanged', async () => { + const root = makeTmpDir(); + const wikiRoot = path.join(root, 'standalone', 'teamwiki'); + fs.mkdirSync(path.join(wikiRoot, 'evidence', 'code'), { recursive: true }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true, output: path.join(root, 'standalone'), json: true }); + + const report = JSON.parse(String(log.mock.calls[0][0])); + expect(report.assetGraph).toBeUndefined(); + expect(report.issues).toBeDefined(); + // No member was loaded: the fixture's index and pending queue stay unread counts, not sources. + expect(vi.mocked(autoDetectInit)).not.toHaveBeenCalled(); + }); + + it('lists the assets behind without a relation', async () => { + const { clone } = await createMemberFixture(); + // An unpublished rule of another member: indexed (the member saw it), unrelated. + fs.writeFileSync(path.join(clone, 'rules', 'unrelated.md'), 'A rule about something else.\n'); + const index = await indexInMemory({ + indexPath: path.join(stubbedHome, '.teamai', 'search-index.json'), + learningsDirs: [pendingDirOf(clone)], + docsDir: path.join(clone, 'docs'), + rulesDir: path.join(clone, 'rules'), + skills: { kind: 'dirs', dirs: [path.join(clone, 'skills', 'release')] }, + }); + fs.writeFileSync(path.join(stubbedHome, '.teamai', 'search-index.json'), JSON.stringify(index)); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ lint: true, json: true }); + + const report = JSON.parse(String(log.mock.calls[0][0])); + expect(report.assetGraph.assetCount).toBe(5); + expect(report.assetGraph.withoutRelation).toEqual(['rules/unrelated.md']); + expect(report.assetGraph.relationCount).toBe(4); + }); +}); diff --git a/src/__tests__/recall-attribution.test.ts b/src/__tests__/recall-attribution.test.ts index d54b1eea1..38ef90f72 100644 --- a/src/__tests__/recall-attribution.test.ts +++ b/src/__tests__/recall-attribution.test.ts @@ -672,7 +672,7 @@ class Harness { } const RECALL_SECTION = 'Recall (last 10 sessions):'; -const RECALL_HEADER = ['session', 'agent', 'runs', 'recalled', 'adopted']; +const RECALL_HEADER = ['session', 'agent', 'runs', 'recalled', 'adopted', 'via', 'relation']; /** The rows of `stats`' recall section, split into columns, or null when it has none. */ function recallRows(output: string): string[][] | null { @@ -2030,7 +2030,7 @@ const ROWS: Row[] = [ expect(output).toMatch(/No matching learnings found for "kubernetes"\. run=[0-9a-f-]{36}\n$/); expect(run).toMatch(/^[0-9a-f-]{36}$/); await h.stop(); - expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '0', '0']]); + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '0', '0', '0']]); }, project: {}, }, @@ -2039,7 +2039,7 @@ const ROWS: Row[] = [ trace: async (h) => { const { output } = await h.recall('kubernetes', { env: NESTED_ENV, claim: false }); await h.shell('teamai recall "kubernetes"', output.replace('ℹ ', '\u001b[34mℹ\u001b[39m '), { session: CODEX, tool: 'codex' }); - expect(recallRows(await h.stats())).toEqual([[CODEX.slice(0, 8), 'codex', '1', '0', '0']]); + expect(recallRows(await h.stats())).toEqual([[CODEX.slice(0, 8), 'codex', '1', '0', '0', '0']]); }, project: {}, }, @@ -2059,7 +2059,7 @@ const ROWS: Row[] = [ const { run } = await h.ompRecall('kubernetes', OMP, { agent: OMP_MAIN_AGENT }); expect(run).toBeDefined(); await h.bridge('omp', 'session_stop', {}, OMP, OMP_MAIN_AGENT); - expect(recallRows(await h.stats())).toEqual([['omp-main', 'omp', '1', '0', '0']]); + expect(recallRows(await h.stats())).toEqual([['omp-main', 'omp', '1', '0', '0', '0']]); }, project: {}, }, @@ -2428,8 +2428,8 @@ describe('recall attribution acceptance (#884)', () => { await h.recall('kubernetes'); const recalled = new Set([...first.files, ...second.files]).size; expect(recallRows(await h.stats())).toEqual([ - ['sess-mai', 'claude', '3', String(recalled), '1'], - ['sess-cod', 'codex', '1', String(codex.files.length), '0'], + ['sess-mai', 'claude', '3', String(recalled), '1', '0'], + ['sess-cod', 'codex', '1', String(codex.files.length), '0', '0'], ]); }); @@ -2449,11 +2449,11 @@ describe('recall attribution acceptance (#884)', () => { await h.recall('setup', { env: NESTED_ENV, claim: false }); const { files } = await h.recall('redis timeout'); await h.read(files[0]); - expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', String(files.length), '0']]); + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', String(files.length), '0', '0']]); await h.stop(); await h.read(files[0]); await h.stop(); - expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', String(files.length), '1']]); + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', String(files.length), '1', '0']]); }); it('shows a linked child session\'s runs under its root session only', async () => { @@ -2462,7 +2462,7 @@ describe('recall attribution acceptance (#884)', () => { await h.openCodeTask(OPENCODE, OPENCODE_CHILD); await h.openCodeRead(OPENCODE, files[0]); await h.openCodeIdle(OPENCODE); - expect(recallRows(await h.stats())).toEqual([['ses_pare', 'opencode', '1', String(files.length), '1']]); + expect(recallRows(await h.stats())).toEqual([['ses_pare', 'opencode', '1', String(files.length), '1', '0']]); }); it('names the agent whose hook claimed the run, even when the environment named another (Dan)', async () => { @@ -2471,7 +2471,7 @@ describe('recall attribution acceptance (#884)', () => { await h.shell('teamai recall "redis timeout"', output, { session: CODEX, tool: 'codex' }); await h.read(files[0], { session: CODEX }); await h.stop(CODEX); - expect(recallRows(await h.stats())).toEqual([[CODEX.slice(0, 8), 'codex', '1', String(files.length), '1']]); + expect(recallRows(await h.stats())).toEqual([[CODEX.slice(0, 8), 'codex', '1', String(files.length), '1', '0']]); }); it('prints the same output with a missing log, an empty one, and one without settled runs', async () => { @@ -2495,9 +2495,191 @@ describe('recall attribution acceptance (#884)', () => { process.chdir(process.env.HOME!); expect(recallRows(await h.stats())).toBeNull(); await h.recall('cache warmup', { env: { CLAUDE_CODE_SESSION_ID: 'sess-user' }, claim: false }); - expect(recallRows(await h.stats())).toEqual([['sess-use', 'claude', '1', '1', '0']]); + expect(recallRows(await h.stats())).toEqual([['sess-use', 'claude', '1', '1', '0', '0']]); process.chdir(h.root); - expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '1', '0']]); + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '1', '0', '0']]); + }); + }); + + describe('via relation (#1562)', () => { + /** A learning whose SUPERSEDES points at the setup learning, added to the project scope. */ + const relatedLearning = async (): Promise => { + await h.addLearning('redis-slow.md', [ + '---', + 'title: "Redis slow pool"', + 'author: tester', + 'date: 2026-05-01', + 'tags: [redis]', + 'related:', + ' SUPERSEDES: learnings/setup', + '---', + '', + 'The pool drains under burst traffic.', + '', + ].join('\n')); + }; + + it('the run line records exactly the printed Related: refs: present with a relation, absent without', async () => { + await h.setUp(); + await relatedLearning(); + const { output } = await h.recall('redis pool'); + expect(output).toContain(`Related: → SUPERSEDES ${h.docs.setup}`); + + const run = (await readRecallLog(h.project)).find((l) => l.kind === 'run'); + if (run?.kind !== 'run') throw new Error('expected a run line'); + const docOf = (key: string) => run.docs.find((d) => d.key === key); + expect(docOf('redis-slow')?.related) + .toEqual([{ relation: 'SUPERSEDES', direction: 'out', path: h.docs.setup }]); + // A doc with no Related: line records nothing. + expect(docOf('redis-timeout')?.related).toBeUndefined(); + }); + + it('opening a related path counts the run once, votes for nothing, and marks nothing consumed', async () => { + await h.setUp(); + await relatedLearning(); + await h.recall('redis pool'); + await h.read(h.docs.setup); + await h.stop(); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '2', '0', '1']]); + expect(await h.upvotes(h.project)).toEqual({}); + // The pending read of a related path is not consumed: the next trigger sees it. + await h.pull(); + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '2', '0', '1']]); + }); + + it('counts a run once however many of its related paths open', async () => { + await h.setUp(); + const notes = await h.addLearning('install-notes.md', doc('Install notes', ['install'], 'Run the installer twice.')); + await h.addLearning('redis-slow.md', [ + '---', + 'title: "Redis slow pool"', + 'author: tester', + 'date: 2026-05-01', + 'tags: [redis]', + 'related:', + ' SUPERSEDES: learnings/setup', + ' REFERENCES: learnings/install-notes', + '---', + '', + 'The pool drains under burst traffic.', + '', + ].join('\n')); + await h.recall('redis pool'); + await h.read(h.docs.setup); + await h.read(notes); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '2', '0', '1']]); + }); + + it('counts a related open only within the adoption window', async () => { + await h.setUp(); + await relatedLearning(); + await h.recall('redis pool'); + h.at(25); + await h.read(h.docs.setup); + await h.stop(); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '2', '0', '0']]); + }); + + it('reads a log written before related existed: docs without it contribute 0', async () => { + await h.setUp(); + const run = '00000000-0000-4000-8000-000000000001'; + const ts = new Date(T0).toISOString(); + fs.mkdirSync(path.dirname(recallLogPath(h.project)), { recursive: true }); + fs.appendFileSync(recallLogPath(h.project), [ + JSON.stringify({ + kind: 'run', ts, run, session: SESSION, agent: 'claude', via: 'env', unambiguous: true, + docs: [{ key: 'redis-timeout', type: 'learnings', scope: 'project', path: h.docs['redis-timeout'], score: 5.1, eligible: true }], + }), + JSON.stringify({ kind: 'claim', ts, run, session: SESSION, direct: true }), + ].join('\n') + '\n'); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '1', '0', '0']]); + }); + + it('a relative read that could name related paths of two runs credits neither', async () => { + await h.setUp(); + const ts = (offsetHours: number) => new Date(T0 + offsetHours * 3_600_000).toISOString(); + const line = (value: unknown): string => JSON.stringify(value); + fs.mkdirSync(path.dirname(recallLogPath(h.project)), { recursive: true }); + const relatedDoc = (root: string, run: string, offsetHours: number): string[] => [ + line({ + kind: 'run', ts: ts(offsetHours), run, session: SESSION, agent: 'claude', via: 'env', unambiguous: true, + docs: [{ + key: `doc-${run.slice(-1)}`, type: 'docs', scope: 'project', + path: `${root}/docs/setup.md`, score: 5.1, eligible: true, + related: [{ relation: 'REFERENCES', direction: 'out', path: `${root}/docs/setup.md` }], + }], + }), + line({ kind: 'claim', ts: ts(offsetHours), run, session: SESSION, direct: true }), + ]; + fs.appendFileSync(recallLogPath(h.project), [ + ...relatedDoc('/rootA', '00000000-0000-4000-8000-0000000000a1', 0), + ...relatedDoc('/rootB', '00000000-0000-4000-8000-0000000000b2', 1), + // A relative read matches the tail of both runs' related paths; pooled + // across the applicable runs it names nothing uniquely, so neither + // run counts — the same ambiguity rule adoption uses (#1562 review). + line({ kind: 'evidence', ts: ts(2), id: 'ev-rel', session: SESSION, path: 'docs/setup.md', status: 'success', simple: true }), + ].join('\n') + '\n'); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '2', '2', '0', '0']]); + }); + + it('a counted run stays in the ambiguity pool: an absolute read credits A, a later relative read credits no second run (#1637 review)', async () => { + await h.setUp(); + const ts = (offsetHours: number) => new Date(T0 + offsetHours * 3_600_000).toISOString(); + const line = (value: unknown): string => JSON.stringify(value); + fs.mkdirSync(path.dirname(recallLogPath(h.project)), { recursive: true }); + const relatedDoc = (root: string, run: string, offsetHours: number): string[] => [ + line({ + kind: 'run', ts: ts(offsetHours), run, session: SESSION, agent: 'claude', via: 'env', unambiguous: true, + docs: [{ + key: `doc-${run.slice(-1)}`, type: 'docs', scope: 'project', + path: `${root}/docs/setup.md`, score: 5.1, eligible: true, + related: [{ relation: 'REFERENCES', direction: 'out', path: `${root}/docs/setup.md` }], + }], + }), + line({ kind: 'claim', ts: ts(offsetHours), run, session: SESSION, direct: true }), + ]; + fs.appendFileSync(recallLogPath(h.project), [ + ...relatedDoc('/rootA', '00000000-0000-4000-8000-0000000000a1', 0), + ...relatedDoc('/rootB', '00000000-0000-4000-8000-0000000000b2', 1), + // Absolute read: run A's related path only, so A counts. + line({ kind: 'evidence', ts: ts(2), id: 'ev-abs', session: SESSION, path: '/rootA/docs/setup.md', status: 'success', simple: true }), + // Relative read: matches the tail of both runs' related paths. A is + // already counted but stays in the pool, so the read is ambiguous and + // B is not credited: 1 where dropping A from the pool would give 2. + line({ kind: 'evidence', ts: ts(3), id: 'ev-rel', session: SESSION, path: 'docs/setup.md', status: 'success', simple: true }), + ].join('\n') + '\n'); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '2', '2', '0', '1']]); + }); + + it('a relative read naming a recalled path and a related path credits nothing: it is plausibly the plain read (#1637 review)', async () => { + await h.setUp(); + const ts = (offsetHours: number) => new Date(T0 + offsetHours * 3_600_000).toISOString(); + const line = (value: unknown): string => JSON.stringify(value); + fs.mkdirSync(path.dirname(recallLogPath(h.project)), { recursive: true }); + fs.appendFileSync(recallLogPath(h.project), [ + line({ + kind: 'run', ts: ts(0), run: '00000000-0000-4000-8000-0000000000c3', session: SESSION, agent: 'claude', via: 'env', unambiguous: true, + docs: [{ + key: 'doc-b', type: 'docs', scope: 'project', + path: '/rootB/docs/setup.md', score: 5.1, eligible: true, + related: [{ relation: 'REFERENCES', direction: 'out', path: '/rootA/docs/setup.md' }], + }], + }), + line({ kind: 'claim', ts: ts(0), run: '00000000-0000-4000-8000-0000000000c3', session: SESSION, direct: true }), + // Relative read: the tail matches both the run's recalled doc and its + // related path, so the open is ambiguous — an ordinary read of the + // recalled doc, not a related open. Crediting it would count a read + // the doc's own recall already explains. + line({ kind: 'evidence', ts: ts(1), id: 'ev-rel', session: SESSION, path: 'docs/setup.md', status: 'success', simple: true }), + ].join('\n') + '\n'); + + expect(recallRows(await h.stats())).toEqual([['sess-mai', 'claude', '1', '1', '0', '0']]); }); }); }); diff --git a/src/__tests__/viz.test.ts b/src/__tests__/viz.test.ts index e98d76dbc..02f80dded 100644 --- a/src/__tests__/viz.test.ts +++ b/src/__tests__/viz.test.ts @@ -34,6 +34,13 @@ function makeVizData(overrides: Partial = {}): VizData { coverage: [], topRecalled: [], silent: [], + relations: { + assetCount: 0, + relationCount: 0, + withoutRelation: [], + unresolved: [], + silentAndWithoutRelation: [], + }, trend: [], authors: [], maintenance: { promote: [], prune: [], stale: [] }, @@ -235,6 +242,54 @@ describe('renderReport', () => { expect(html).toContain('All entries have been recalled at least once.'); }); + it('renders the relations section with the counts headline and the assets behind each count (#1562 part 2)', () => { + const html = renderReport(makeVizData({ + relations: { + assetCount: 4, + relationCount: 4, + withoutRelation: [], + unresolved: ['rules/payments.md REFERENCES docs/payment-overview.md'], + silentAndWithoutRelation: [], + }, + })); + expect(html).toContain('id="relations"'); + expect(html).toContain('4 assets · 4 relations · 0 without a relation · 1 unresolved'); + expect(html).toContain('rules/payments.md REFERENCES docs/payment-overview.md'); + expect(html).toContain('Silent and without a relation'); + // The counts say which scope they were resolved for, as the header labels its data. + expect(html).toContain('Resolved for'); + expect(html).toContain('source-local'); + }); + + it('prints "none" only for the silent-and-without-relation intersection, and lists the other counts', () => { + const html = renderReport(makeVizData({ + relations: { + assetCount: 3, + relationCount: 1, + withoutRelation: ['docs/orphan.md'], + unresolved: [], + silentAndWithoutRelation: ['docs/orphan.md'], + }, + })); + expect(html).toContain('3 assets · 1 relations · 1 without a relation · 0 unresolved'); + expect(html).toContain('docs/orphan.md'); + expect(html).not.toContain(': none'); + }); + + it('escapes XSS in relation list paths', () => { + const html = renderReport(makeVizData({ + relations: { + assetCount: 1, + relationCount: 0, + withoutRelation: ['docs/'], + unresolved: [], + silentAndWithoutRelation: [], + }, + })); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + it('reflects totalEntries and overallCoveragePct in the output', () => { const html = renderReport(makeVizData({ totalEntries: 42, overallCoveragePct: 75 })); expect(html).toContain('42'); @@ -296,6 +351,101 @@ describe('buildVizData with explicit --repo', () => { }); }); +// ─── Relation counts in the KB health view (#1562 part 2) ───────────────────── + +describe('buildVizData relation counts', () => { + let tmpRepo: string; + + afterEach(async () => { + if (tmpRepo) { + await fs.rm(tmpRepo, { recursive: true, force: true }); + } + }); + + const write = async (rel: string, content: string): Promise => { + const file = path.join(tmpRepo, rel); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content, 'utf-8'); + }; + + it('counts the #1552 example plus a typo\'d declared target like codebase --lint would', async () => { + tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-viz-relations-')); + // The #1552 fixture: a declared REFERENCES with the same link target + // (declared beats link), a MAPS_TO to a teamwiki page, a SUPERSEDES from a + // learning — plus a typo'd declared target that names nothing on disk. + await write('rules/payments.md', [ + '---', + 'related:', + ' MAPS_TO: teamwiki/evidence/code/pay/modules/ledger-receipts.md', + ' REFERENCES: [docs/payments-overview.md, docs/payment-overview.md, skills/pay/release]', + '---', + 'Every payment goes through ledger receipts.', + 'The flow is in [the overview](../docs/payments-overview.md).', + '', + ].join('\n')); + await write('teamwiki/evidence/code/pay/modules/ledger-receipts.md', 'Wiki page.\n'); + await write('docs/payments-overview.md', 'How the payment flow works.\n'); + await write('skills/pay/release/SKILL.md', '---\nname: release\ndescription: d\n---\n\n# Release\n'); + await write('learnings/ledger-timeout.md', [ + '---', + 'title: Ledger timeout', + 'related:', + ' SUPERSEDES: docs/payments-overview.md', + '---', + 'The receipt write times out at 3s; retry once.', + '', + ].join('\n')); + await fs.mkdir(path.join(tmpRepo, 'votes'), { recursive: true }); + + const data = await buildVizData(await resolveVizRoot({ repo: tmpRepo })); + + // rules/payments.md, docs/payments-overview.md, skills/pay/release, + // learnings/ledger-timeout — teamwiki pages are not assets. + expect(data.relations.assetCount).toBe(4); + // MAPS_TO to the page (it exists and is not withheld) + REFERENCES doc + + // skill + SUPERSEDES from the learning; the rule's link to the doc + // deduplicates under its declared REFERENCES. + expect(data.relations.relationCount).toBe(4); + expect(data.relations.withoutRelation).toEqual([]); + expect(data.relations.unresolved).toEqual(['rules/payments.md REFERENCES docs/payment-overview.md']); + // No votes anywhere: every asset is silent, and every asset has a relation. + expect(data.relations.silentAndWithoutRelation).toEqual([]); + + const html = renderReport(data); + expect(html).toContain('4 assets · 4 relations · 0 without a relation · 1 unresolved'); + expect(html).toContain('rules/payments.md REFERENCES docs/payment-overview.md'); + }); + + it('lists silent assets without a relation by team-tree path and honors votes', async () => { + tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-viz-relations-')); + await write('docs/a.md', [ + '---', + 'related:', + ' REFERENCES: docs/b.md', + '---', + 'A.', + '', + ].join('\n')); + await write('docs/b.md', 'B.\n'); + await write('docs/orphan.md', 'Orphan.\n'); + await fs.mkdir(path.join(tmpRepo, 'votes'), { recursive: true }); + await fs.writeFile( + path.join(tmpRepo, 'votes', 'user-a.yaml'), + votesYaml({ b: { recalled_count: 2, upvoted_count: 0, last_recalled_at: '2026-06-20T12:00:00Z' } }), + 'utf-8', + ); + + const data = await buildVizData(await resolveVizRoot({ repo: tmpRepo })); + + expect(data.relations.assetCount).toBe(3); + expect(data.relations.relationCount).toBe(1); + expect(data.relations.withoutRelation).toEqual(['docs/orphan.md']); + // docs/orphan.md is silent; docs/a.md is connected but silent too. + expect(data.silent.map((m) => m.docId).sort()).toEqual(['a', 'orphan']); + expect(data.relations.silentAndWithoutRelation).toEqual(['docs/orphan.md']); + }); +}); + /** * The dashboard's own index build, when none was built by pull (#823 item 16): * it reads the active projects' learnings namespaces, as recall and pull do, diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index addf5ddec..4072f82ac 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -70,7 +70,7 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { console.log('teamai codebase — team codebase knowledge management'); console.log(''); console.log('Usage:'); - console.log(' teamai codebase --lint Run teamwiki consistency lint'); + console.log(' teamai codebase --lint Run teamwiki consistency lint; also counts the member\'s asset relations (assets, relations, without a relation, unresolved)'); console.log(' teamai codebase --lint --json Output JSON report (for CI)'); console.log(' teamai codebase --lint --severity high Only report high-severity issues'); console.log(' teamai codebase --reconcile Reconcile product and code knowledge'); @@ -94,6 +94,10 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { } if (!target || !(await pathExists(target.wikiRoot))) { + if (opts.lint) { + await lintWithoutTeamwiki(opts); + return; + } console.log('No teamwiki found. Run `teamai import` first.'); if (opts.reconcile || opts.deepEnrich) process.exitCode = 1; return; @@ -240,7 +244,10 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { } const { lintTeamwiki, formatWikiLintReport } = await import('./codebase-wiki-lint.js'); + // Standalone `--output` names no member, so there is no asset data (#1562). + const assetGraph = opts.output ? null : await lintAssetGraphForMember(); const report = await lintTeamwiki({ wikiRoot: teamwikiDir, severity: opts.severity }); + if (assetGraph) report.assetGraph = assetGraph; if (opts.json) { console.log(JSON.stringify(report, null, 2)); } else { @@ -249,6 +256,40 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { if (report.summary.high > 0) process.exitCode = 1; } +/** + * `--lint` without a teamwiki: the graph checks are skipped, but the member's + * asset counts still print (#1562). With standalone `--output` there is no + * member, so there is no assetGraph and the output is unchanged. + */ +async function lintWithoutTeamwiki(opts: CodebaseCmdOptions): Promise { + const assetGraph = opts.output ? null : await lintAssetGraphForMember(); + if (!assetGraph) { + console.log('No teamwiki found. Run `teamai import` first.'); + return; + } + if (opts.json) { + // The full report shape, with the graph checks absent: graphHealth + // null reads as "not checked", never a measured 0%, and a script + // reading `.summary` sees the same structure with and without a + // teamwiki (#1562/#1637 review). + console.log(JSON.stringify({ + issues: [], + summary: { total: 0, high: 0, medium: 0, low: 0, info: 0 }, + graphHealth: null, + assetGraph, + }, null, 2)); + } else { + const { formatAssetGraphLine } = await import('./codebase-wiki-lint.js'); + console.log(formatAssetGraphLine(assetGraph)); + } +} + +// `--lint` only ever reads: the loaders must preview, not persist, a migration (#850). +async function lintAssetGraphForMember(): Promise { + const { lintAssetGraph } = await import('./lint-asset-graph.js'); + return lintAssetGraph({ dryRun: true }).catch(() => null); +} + /** * Print the git baseline recorded in teamwiki/source-manifest.json. * diff --git a/src/codebase-wiki-lint.ts b/src/codebase-wiki-lint.ts index bc80099ec..444eb9ef7 100644 --- a/src/codebase-wiki-lint.ts +++ b/src/codebase-wiki-lint.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import chalk from 'chalk'; import { pathExists } from './utils/fs.js'; +import type { LintAssetGraph } from './lint-asset-graph.js'; import type { GraphIndex } from './wiki-engine/adapters/index.js'; export type WikiLintSeverity = 'high' | 'medium' | 'low' | 'info'; @@ -24,12 +25,23 @@ export interface WikiLintReport { low: number; info: number; }; + /** + * Graph metrics from `.indices/graph-index.json`; `null` when the graph is + * missing or corrupt — "not checked", never a measured 0% (#1637 review). + * The field can be `null` in every mode, standalone `--output` included: + * scripts reading `graphHealth.connectivity` must handle it. + */ graphHealth: { nodeCount: number; edgeCount: number; orphanNodes: number; connectivity: number; - }; + } | null; + /** + * The member's asset-relation counts (#1562). Absent with standalone + * `--output` or when there is no member: then there is no asset data. + */ + assetGraph?: LintAssetGraph; } export async function lintTeamwiki(opts: { @@ -162,8 +174,9 @@ export async function lintTeamwiki(opts: { } } - // Graph health metrics - let graphHealth = { nodeCount: 0, edgeCount: 0, orphanNodes: 0, connectivity: 0 }; + // Graph health metrics; null stays "not checked" when the graph is missing + // or corrupt, so a JSON reader never mistakes it for a measured 0% (#1637). + let graphHealth: { nodeCount: number; edgeCount: number; orphanNodes: number; connectivity: number } | null = null; if (graph) { const connectedNodes = new Set(); for (const edge of graph.edges) { @@ -217,34 +230,46 @@ export function formatWikiLintReport(report: WikiLintReport): string { lines.push(chalk.bold('=== teamwiki/ knowledge graph health check ===')); lines.push(''); - lines.push(`Graph: ${report.graphHealth.nodeCount} nodes, ${report.graphHealth.edgeCount} edges, connectivity ${(report.graphHealth.connectivity * 100).toFixed(0)}%`); - if (report.graphHealth.orphanNodes > 0) { - lines.push(chalk.dim(` (${report.graphHealth.orphanNodes} orphan nodes)`)); + if (report.graphHealth === null) { + lines.push('graph: not checked (graph-index.json missing or corrupt)'); + } else { + lines.push(`Graph: ${report.graphHealth.nodeCount} nodes, ${report.graphHealth.edgeCount} edges, connectivity ${(report.graphHealth.connectivity * 100).toFixed(0)}%`); + if (report.graphHealth.orphanNodes > 0) { + lines.push(chalk.dim(` (${report.graphHealth.orphanNodes} orphan nodes)`)); + } } lines.push(''); if (report.issues.length === 0) { lines.push(chalk.green('✓ No issues')); - return lines.join('\n'); - } - - const byCategory = new Map(); - for (const issue of report.issues) { - const existing = byCategory.get(issue.category) ?? []; - existing.push(issue); - byCategory.set(issue.category, existing); - } + } else { + const byCategory = new Map(); + for (const issue of report.issues) { + const existing = byCategory.get(issue.category) ?? []; + existing.push(issue); + byCategory.set(issue.category, existing); + } - for (const [category, categoryIssues] of byCategory) { - lines.push(chalk.bold(`[${category}] (${categoryIssues.length})`)); - for (const issue of categoryIssues) { - const sevColor = issue.severity === 'high' ? chalk.red - : issue.severity === 'medium' ? chalk.yellow : chalk.dim; - lines.push(` ${sevColor(`[${issue.severity}]`)} ${issue.location}: ${issue.message}`); + for (const [category, categoryIssues] of byCategory) { + lines.push(chalk.bold(`[${category}] (${categoryIssues.length})`)); + for (const issue of categoryIssues) { + const sevColor = issue.severity === 'high' ? chalk.red + : issue.severity === 'medium' ? chalk.yellow : chalk.dim; + lines.push(` ${sevColor(`[${issue.severity}]`)} ${issue.location}: ${issue.message}`); + } + lines.push(''); } - lines.push(''); + + lines.push(`Total: ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info`); } - lines.push(`Total: ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info`); + if (report.assetGraph) { + lines.push(formatAssetGraphLine(report.assetGraph)); + } return lines.join('\n'); } + +/** The one asset-count line `--lint` prints after the report (#1562). */ +export function formatAssetGraphLine(graph: LintAssetGraph): string { + return `Assets: ${graph.assetCount}, relations: ${graph.relationCount}, without a relation: ${graph.withoutRelation.length}, unresolved: ${graph.unresolved.length}`; +} diff --git a/src/dashboard/locales.ts b/src/dashboard/locales.ts index a92baf1de..2ea55dcc2 100644 --- a/src/dashboard/locales.ts +++ b/src/dashboard/locales.ts @@ -276,6 +276,12 @@ export const dashboardMessages: Record = { "Top Recalled Entries": "高频召回知识", "Never-Recalled Entries": "从未召回知识", "All entries have been recalled at least once.": "全部知识均被召回过。", + "Relations": "关联关系", + "Resolved for": "解析范围", + "Without a relation": "无关联关系", + "Silent and without a relation": "既未召回也无关联关系", + "none": "无", + "Unresolved": "未解析目标", "Entries by Last-Recall Month": "按最近召回月份统计的知识条目", "Each entry is counted once, in the month it was last recalled.": "每条知识仅在其最近一次召回的月份计数一次。", "Not enough data (need at least 2 months of activity).": "数据不足,需要至少两个月的活动数据。", diff --git a/src/index.ts b/src/index.ts index 77be9a25b..cb8fe2aa9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1471,7 +1471,7 @@ program .description('Inspect and maintain the teamwiki that `teamai import` writes') .addOption(new Option('--project ', 'Codebase slug under teamwiki/evidence/code/, required for --deep-enrich').hideHelp()) .addOption(new Option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format').hideHelp()) - .option('--lint', 'Run global consistency lint over the teamwiki knowledge graph') + .option('--lint', 'Run global consistency lint over the teamwiki knowledge graph, and count the member\'s asset relations (assets, relations, without a relation, unresolved)') .option('--reconcile', 'Reconcile product and code knowledge in teamwiki') .option('--deep-enrich', 'Generate deep knowledge docs from extracted evidence') .addOption(new Option('--fix', 'Deprecated: teamwiki lint has no autofix; runs lint in report-only mode').hideHelp()) diff --git a/src/lint-asset-graph.ts b/src/lint-asset-graph.ts new file mode 100644 index 000000000..2ae621959 --- /dev/null +++ b/src/lint-asset-graph.ts @@ -0,0 +1,269 @@ +/** + * The asset-graph counts behind `teamai codebase --lint` (#1562 part 1). + * + * Lint reads the member's own recall indexes as they are — the same scopes + * recall selects, never a fresh build — and counts, over the assets visible + * there: resolved relations, assets without any relation, and declared + * relations whose target names no asset on disk. The counts cover this + * member's scope, not the whole team. + */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { classifyTreePath, normalizeTargetPath, teamwikiCodebaseSlug, type AssetKind, type AssetRelationType, type RelationVia } from './asset-relations.js'; +import { caseFoldKey } from './manifest-schema.js'; +import { detectProjectConfig, loadLocalConfigForScope, requireInit } from './config.js'; +import { buildRelationContext, type RelationContext } from './recall.js'; +import { getProjectSearchIndexPath, getUserSearchIndexPath, type LocalConfig, type SearchIndex } from './types.js'; +import { readWorkspaceChildren } from './workspace-children.js'; +import { indexableLearningsRoots } from './utils/learnings-roots.js'; +import { pendingLearningsDir } from './utils/pending-learnings.js'; +import { loadIndex } from './utils/search-index.js'; +import { matchGlob } from './utils/glob.js'; + +/** A declared relation whose target names no asset on disk. */ +export interface LintUnresolvedRelation { + /** The declaring asset's team-tree path. */ + from: string; + relation: AssetRelationType; + /** The target in canonical team-tree form. */ + target: string; + /** Always 'no-asset' today: the target is in neither the team clone nor the learnings roots. */ + reason: 'no-asset'; +} + +/** The counts `--lint` prints and embeds in its JSON report as `assetGraph`. */ +export interface LintAssetGraph { + assetCount: number; + relationCount: number; + /** Team-tree paths of assets with no resolved relation in either direction. */ + withoutRelation: string[]; + unresolved: LintUnresolvedRelation[]; +} + +/** One scope's inputs to the count: recall's resolution state, plus the contribute-style existence check. */ +export interface LintScopeGraph { + ctx: RelationContext; + targetExists: (kind: AssetKind, treePath: string) => boolean; +} + +/** + * Count the asset relations of the given scopes, with recall's rules: a + * relation counts when it resolves — for an asset target, visibility in the + * scope; for a teamwiki page, existence on disk and an unwithheld codebase — + * and a local link to a target that also has a declared relation from the + * same entry is prose, not a second edge. Existence, not visibility, decides + * `unresolved`: a declared target that exists on disk but is not visible to + * this member is neither counted nor unresolved, the same check `contribute` + * makes (#1552). + */ +export function computeLintAssetGraph(scopes: readonly LintScopeGraph[]): LintAssetGraph { + const assets = new Set(); + // Declared beats link by target, as recall renders it, before the + // unordered-pair dedup below; keyed by the ordered triple. + const ordered = new Map(); + const unresolved = new Map(); + + for (const { ctx, targetExists } of scopes) { + for (const [treePath, entry] of ctx.byTreePath) { + // A teamwiki page can hold a treePath only in exotic index builds; it + // is a target, never an asset. + if (classifyTreePath(treePath) === 'teamwiki') continue; + assets.add(treePath); + const own = entry.related ?? []; + // A local link to an asset that also has a declared relation from this + // entry is prose, not a second edge — the same declared-beats-link rule + // recall renders (#1562 review). + const declaredTargets = new Set(own.filter((r) => r.via === 'declared').map((r) => r.target)); + for (const r of own) { + const kind = classifyTreePath(r.target); + if (r.via === 'declared' && kind !== null) { + const normalized = normalizeTargetPath(r.target); + if (normalized !== null && !targetExists(kind, normalized)) { + unresolved.set(`${treePath}|${r.relation}|${normalized}`, { + from: treePath, relation: r.relation, target: normalized, reason: 'no-asset', + }); + } + } + if (r.via === 'link' && declaredTargets.has(r.target)) continue; + // A relation to a teamwiki page counts when the page exists on disk + // and its codebase is not withheld — the same visibility recall + // renders. The page stays out of `assets` and `withoutRelation`: a + // page is a target, not an asset (#1562). + if (kind === 'teamwiki') { + if (ctx.withheld === null) continue; + const slug = teamwikiCodebaseSlug(r.target); + if (slug !== null && ctx.withheld.has(caseFoldKey(slug))) continue; + if (!targetExists('teamwiki', r.target)) continue; + ordered.set(`${treePath}|${r.relation}|${r.target}`, { from: treePath, relation: r.relation, target: r.target, via: r.via }); + continue; + } + if (kind === null || !ctx.byTreePath.has(r.target)) continue; + const key = `${treePath}|${r.relation}|${r.target}`; + if (ordered.get(key)?.via === 'declared') continue; + ordered.set(key, { from: treePath, relation: r.relation, target: r.target, via: r.via }); + } + } + } + + // Each relation counts once per unordered pair, relation and via: A→B and + // the B←A view of the same edge are one relation, and recall's 5-line + // output cap does not apply here (#1562). The count follows what recall + // prints — one per relation line — so a declared REFERENCES the target + // links back to counts twice (declared + link), not once per pair (#1637 + // review). + const relations = new Set(); + const connected = new Set(); + for (const edge of ordered.values()) { + relations.add(`${edge.relation}|${[edge.from, edge.target].sort().join('|')}|${edge.via}`); + connected.add(edge.from); + connected.add(edge.target); + } + + // Path-derived rule→page edges count with the same visibility and + // precedence recall renders (#1636): each glob is judged per codebase over + // the scope's own code-page pool, and a declared relation from the rule to + // the page beats the derived one. Pages stay targets, not assets (#1562). + for (const { ctx } of scopes) { + for (const [rule, globs] of ctx.ruleGlobs ?? []) { + const declaredTargets = new Set( + (ctx.byTreePath.get(rule)?.related ?? []).filter((r) => r.via === 'declared').map((r) => r.target), + ); + for (const glob of globs) { + const matched = (ctx.codePages ?? []).filter((pg) => pg.sources.some((s) => matchGlob(glob, s))); + if (new Set(matched.map((m) => m.slug)).size !== 1) continue; + for (const pg of matched) { + if (declaredTargets.has(pg.treePath)) continue; + relations.add(`MAPS_TO|${[rule, pg.treePath].sort().join('|')}|derived`); + connected.add(rule); + connected.add(pg.treePath); + } + } + } + } + + return { + assetCount: assets.size, + relationCount: relations.size, + withoutRelation: [...assets].filter((a) => !connected.has(a)).sort(), + unresolved: [...unresolved.values()].sort((a, b) => + `${a.from}|${a.relation}|${a.target}`.localeCompare(`${b.from}|${b.relation}|${b.target}`)), + }; +} + +/** + * The contribute-style existence predicate for one scope: a target is in the + * team clone on disk (rules, docs, skills, teamwiki pages) or in the learnings + * roots (pending queue included), mirroring `relationWarningsForLearning` + * (`src/contribute.ts`) so lint and contribute never disagree about what + * names an asset (#1552). + */ +async function scopeTargetExists(config: LocalConfig): Promise<(kind: AssetKind, treePath: string) => boolean> { + let learningRoots: string[] = []; + try { + learningRoots = [pendingLearningsDir(config), ...await indexableLearningsRoots(config, [])]; + } catch { + learningRoots = [pendingLearningsDir(config)]; + } + const repoRoot = config.repo.localPath; + return (kind, treePath) => { + const rel = kind === 'learning' + ? `${treePath.slice('learnings/'.length)}.md` + : treePath; + const roots = kind === 'learning' ? learningRoots : [repoRoot]; + return roots.some((root) => existsSync(path.join(root, ...rel.split('/')))); + }; +} + +/** + * The member's scopes as counting inputs — the same selection recall makes: + * the project scope when a project config exists, plus the inherited user + * scope when `inheritUserScope` is true, else the user scope. Returns null + * when there is no member at all — standalone `--output` — and lint behaves + * as it did before #1562. + * + * `opts.config` pins the project scope to an already-resolved config: a + * caller reporting on an explicit scope (the viz dashboard) must not let + * this re-detect the current directory's (#1637 review). + * + * A missing or unreadable index means the member pulled nothing recall + * indexes; that scope contributes no assets, never a rebuild: lint only + * reads. + */ +export async function memberScopeGraphs(opts: { dryRun?: boolean; config?: LocalConfig } = {}): Promise { + const loadOpts = { dryRun: opts.dryRun ?? true }; + let projectConfig: LocalConfig | null = opts.config ?? null; + try { + if (!projectConfig) projectConfig = await detectProjectConfig(undefined, undefined, loadOpts); + // A workspace searches what its children select too (#913), as recall. + if (projectConfig) projectConfig = await readWorkspaceChildren(projectConfig); + } catch { + projectConfig = null; + } + + const scopeConfigs: Array<{ config: LocalConfig; scope: 'user' | 'project' }> = []; + if (projectConfig) { + // An explicitly supplied config may be user-scoped (the dashboard reports + // either); only a project scope can inherit the user one. + const scope = projectConfig.scope === 'project' ? 'project' : 'user'; + scopeConfigs.push({ config: projectConfig, scope }); + if (scope === 'project' && projectConfig.inheritUserScope === true) { + try { + const userConfig = await loadLocalConfigForScope('user', undefined, loadOpts); + if (userConfig) scopeConfigs.push({ config: userConfig, scope: 'user' }); + } catch { + // The inherited user scope is unavailable; the project scope still counts. + } + } + } else { + try { + const { localConfig } = await requireInit(loadOpts); + scopeConfigs.push({ config: localConfig, scope: 'user' }); + } catch { + return null; + } + } + + const loaded: Array<{ config: LocalConfig; scope: 'user' | 'project'; index: SearchIndex }> = []; + for (const { config, scope } of scopeConfigs) { + try { + // The project index is partition-aware (per checkout in self mode); + // without a project root a historical project config reads the user's. + const indexPath = config.scope === 'project' && config.projectRoot + ? getProjectSearchIndexPath(config) + : getUserSearchIndexPath(); + const index = await loadIndex(indexPath); + if (index) loaded.push({ config, scope, index }); + } catch { + // An unreadable index excludes its scope, as an unavailable one does in recall. + } + } + + // A user-scope entry the project shadows contributes nothing, as in recall. + const projectEntryKeys = new Set( + loaded + .filter((s) => s.scope === 'project') + .flatMap((s) => s.index.entries.map((e) => `${e.type}:${e.filename}`)), + ); + + const graphs: LintScopeGraph[] = []; + for (const { config, scope, index } of loaded) { + try { + const ctx = await buildRelationContext({ + index, + config, + hidden: scope === 'user' ? projectEntryKeys : undefined, + }); + graphs.push({ ctx, targetExists: await scopeTargetExists(config) }); + } catch { + // Relations are unavailable for this scope, as a failed build is in recall. + } + } + return graphs; +} + +/** Count the member's asset relations over {@link memberScopeGraphs}. */ +export async function lintAssetGraph(opts: { dryRun?: boolean } = {}): Promise { + const graphs = await memberScopeGraphs(opts); + return graphs ? computeLintAssetGraph(graphs) : null; +} diff --git a/src/recall-adoption.ts b/src/recall-adoption.ts index fe8ee7492..d2f4344b3 100644 --- a/src/recall-adoption.ts +++ b/src/recall-adoption.ts @@ -173,20 +173,26 @@ function segments(p: string): string[] { } /** - * The docs a read of `evidencePath` opened. An absolute path must name the - * printed one, however either is written (agent-path). A relative path had - * no base: it matches by its trailing segments (at least the parent and the - * file name), and only when a single printed path has them. + * The paths of `paths` a read of `evidencePath` opened. An absolute path must + * name the printed one, however either is written (agent-path). A relative + * path had no base: it matches by its trailing segments (at least the parent + * and the file name), and only when a single printed path has them. */ -function docsOpened(evidencePath: string, docs: RecalledDoc[]): RecalledDoc[] { - if (isAbsolutePath(evidencePath)) return docs.filter((d) => samePath(d.path, evidencePath)); +function openedPaths(evidencePath: string, paths: string[]): string[] { + if (isAbsolutePath(evidencePath)) return paths.filter((p) => samePath(p, evidencePath)); const tail = segments(evidencePath); if (tail.length < 2) return []; - const hits = docs.filter((d) => { - const segs = segments(d.path); + const hits = paths.filter((p) => { + const segs = segments(p); return segs.length >= tail.length && tail.every((s, i) => segs[segs.length - tail.length + i] === s); }); - return new Set(hits.map((d) => segments(d.path).join('/'))).size === 1 ? hits : []; + return new Set(hits.map((p) => segments(p).join('/'))).size === 1 ? hits : []; +} + +/** The docs a read of `evidencePath` opened. */ +function docsOpened(evidencePath: string, docs: RecalledDoc[]): RecalledDoc[] { + const opened = new Set(openedPaths(evidencePath, docs.map((d) => d.path))); + return docs.filter((d) => opened.has(d.path)); } /** What the reducer reads off the whole log, once per pass. */ @@ -276,6 +282,8 @@ interface RootView { recalled: number; /** Distinct docs consumed reads credited. */ adopted: number; + /** Runs a countable read opened a recorded related path of, within the run's window (#1562). */ + viaRelation: number; /** The root's reads whose status lets them count. */ evidence: EvidenceLine[]; /** The eligible docs a read opened, of the runs whose window it falls in and whose own reads it may count for. */ @@ -321,7 +329,32 @@ function viewRoot(index: LogIndex, target: string): RootView { && (e.status === 'success' || (e.status === 'unknown' && e.simple === true))); const credited = evidence.filter((e) => consumed.has(e.id)).flatMap(opened); const done = new Set(credited.map(creditOf)); - return { runs, recalled, adopted: new Set(credited.map((d) => d.key)).size, evidence, opened, creditOf, done }; + // A run counts once when any countable read — credited or still pending — + // opens any related path the run recorded, within its window and under the + // same marked-actor rule as adoption (#1562). Related opens never credit, + // so unlike `done` this set never feeds the votes ledger. + const relatedPathsOf = (r: RunLine): string[] => [...new Set(r.docs.flatMap((d) => (d.related ?? []).map((x) => x.path)))]; + const viaRelationRuns = new Set(); + for (const e of evidence) { + const at = Date.parse(e.ts); + // The ambiguity pool holds every in-window run's recalled paths and + // related paths: a relative read that could name one of each credits + // nothing — it is plausibly an ordinary read of the recalled doc + // itself, not a related open (#1637 review). Counted runs stay in the + // pool; only the adding loop skips them. + const pool = runs.filter((r) => { + if (!inWindow(at, r)) return false; + const actor = markedActor.get(r); + return !actor || actor.session !== e.session || actor.agentId !== (e.agentId ?? null); + }); + const poolPathsOf = (r: RunLine): string[] => [...new Set([...r.docs.map((d) => d.path), ...relatedPathsOf(r)])]; + const matched = new Set(openedPaths(e.path, pool.flatMap(poolPathsOf))); + for (const r of pool) { + if (viaRelationRuns.has(r.run)) continue; + if (relatedPathsOf(r).some((p) => matched.has(p))) viaRelationRuns.add(r.run); + } + } + return { runs, recalled, adopted: new Set(credited.map((d) => d.key)).size, viaRelation: viaRelationRuns.size, evidence, opened, creditOf, done }; } async function creditRoot(config: LocalConfig, index: LogIndex, sessionId: string): Promise { @@ -434,6 +467,12 @@ export interface RecallSessionSummary { recalled: number; /** Distinct docs the reducer credited: a read still pending is not counted. */ adopted: number; + /** + * Runs in which a countable read opened at least one of the related paths + * the run recorded, within its window (#1562). A run counts once, however + * many related paths open; a pending read counts as an open. + */ + viaRelation: number; /** The newest run's time. */ last: string; } @@ -455,14 +494,14 @@ export async function recallSessions(config: LocalConfig, limit: number): Promis if (!seen || line.ts > seen.ts) newest.set(root, line); } return [...newest].sort(([, a], [, b]) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0)).slice(0, limit).map(([session, last]) => { - const { runs, recalled, adopted } = viewRoot(index, session); + const { runs, recalled, adopted, viaRelation } = viewRoot(index, session); // A run's agent is the one whose hook claimed it, else its environment's // family when the run settled to the session that environment named. const agentOf = (r: RunLine) => index.claimOf.get(r.run)?.agent ?? (index.ownerOf(r) === r.session ? r.agent : undefined); const named = runs.filter((r) => agentOf(r)); const agent = named.reduce((a, r) => (!a || r.ts > a.ts ? r : a), undefined); const family = agent ? agentOf(agent) : undefined; - return { session, ...(family ? { agent: family } : {}), runs: runs.length, recalled, adopted, last: last.ts }; + return { session, ...(family ? { agent: family } : {}), runs: runs.length, recalled, adopted, viaRelation, last: last.ts }; }); } diff --git a/src/recall-log.ts b/src/recall-log.ts index 58628d270..c7f20ddd9 100644 --- a/src/recall-log.ts +++ b/src/recall-log.ts @@ -19,6 +19,19 @@ import path from 'node:path'; import { appendJsonlBatch, readJsonl } from './utils/jsonl-store.js'; import { getDataHome, getTeamaiHomeDir } from './types.js'; import type { KnowledgeType, LocalConfig } from './types.js'; +import type { AssetRelationType } from './asset-relations.js'; + +/** + * One `Related:` line's target, as a run records it for a doc (#1562): + * exactly what recall printed, `direction` naming the arrow. + */ +export interface RelatedRef { + relation: AssetRelationType; + /** `out` for a printed `→` line, `in` for `←`. */ + direction: 'out' | 'in'; + /** The resolved path the line printed. */ + path: string; +} /** A doc a run returned, as recall printed it. */ export interface RecalledDoc { @@ -32,6 +45,8 @@ export interface RecalledDoc { score: number; /** False for an inherited user-scope doc while a project is active: it stays read-only. */ eligible: boolean; + /** The `Related:` targets the run printed for this doc (#1562); absent when none. */ + related?: RelatedRef[]; } /** diff --git a/src/recall.ts b/src/recall.ts index b8ea855ac..cdd62d71b 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -20,6 +20,7 @@ import type { EnvAgentSession } from './utils/session-id.js'; import { classifyTreePath, teamwikiCodebaseSlug } from './asset-relations.js'; import type { AssetRelationType, RelationVia } from './asset-relations.js'; import { caseFoldKey } from './manifest-schema.js'; +import type { RelatedRef } from './recall-log.js'; import type { SearchIndexEntry } from './types.js'; import { expandBraces, rulePaths } from './resources/team-rule.js'; import { matchGlob } from './utils/glob.js'; @@ -493,8 +494,12 @@ export async function buildRelationContext(args: { }; } -/** The printed path of a relation's other end, or null when this scope cannot see it. */ -function resolveRelationTarget(ctx: RelationContext, target: string): string | null { +/** + * The printed path of a relation's other end, or null when this scope cannot + * see it. Exported for the viz health view's relation counts (#1562 part 2), + * which reuse it as the resolved predicate. + */ +export function resolveRelationTarget(ctx: RelationContext, target: string): string | null { if (classifyTreePath(target) === 'teamwiki') { if (ctx.withheld === null) return null; const slug = teamwikiCodebaseSlug(target); @@ -508,60 +513,118 @@ function resolveRelationTarget(ctx: RelationContext, target: string): string | n } /** - * The `Related:` lines for one hit: declared outgoing first, then declared - * incoming, then path-derived, then link outgoing, then link incoming; within - * each group outgoing keeps the file's order and incoming sorts by the - * source's tree path. A declared relation beats a derived or linked one - * between the same two assets in the same direction. Capped at - * {@link RELATION_LINES_CAP}. + * One relation of a hit, as printed: the stdout line and the ref a run + * records for it (#1562). The two are built together so the recall log can + * only ever hold exactly what stdout showed. */ -export function relationLines( +interface PrintedRelation { + /** `Related: → RELATION path` (or `←`). */ + line: string; + ref: RelatedRef; +} + +/** + * The printed relations for one hit, in print order (see + * {@link relationLines}), capped at {@link RELATION_LINES_CAP}. + */ +function printedRelations( ctx: RelationContext, entry: SearchIndexEntry, derived?: { out?: ReadonlySet; in?: ReadonlySet }, -): string[] { +): PrintedRelation[] { if (!entry.treePath) return []; - const lines: string[] = []; + const relations: PrintedRelation[] = []; const seen = new Set(); - const push = (arrow: '→' | '←', relation: AssetRelationType, otherEnd: string): void => { + const push = (direction: 'out' | 'in', relation: AssetRelationType, otherEnd: string): void => { const printed = resolveRelationTarget(ctx, otherEnd); if (printed === null) return; - const key = `${arrow}${relation}${otherEnd}`; + const key = `${direction}${relation}${otherEnd}`; if (seen.has(key)) return; seen.add(key); - lines.push(`Related: ${arrow} ${relation} ${printed}`); + relations.push({ + line: `Related: ${direction === 'out' ? '→' : '←'} ${relation} ${printed}`, + ref: { relation, direction, path: printed }, + }); }; const own = entry.related ?? []; const incoming = [...(ctx.incoming.get(entry.treePath) ?? [])].sort((a, b) => a.from.localeCompare(b.from)); - for (const r of own) if (r.via === 'declared') push('→', r.relation, r.target); - for (const r of incoming) if (r.via === 'declared') push('←', r.relation, r.from); + for (const r of own) if (r.via === 'declared') push('out', r.relation, r.target); + for (const r of incoming) if (r.via === 'declared') push('in', r.relation, r.from); // A declared relation to the same asset wins over a derived or linked one. const declaredOut = new Set(own.filter((r) => r.via === 'declared').map((r) => r.target)); const declaredIn = new Set(incoming.filter((r) => r.via === 'declared').map((r) => r.from)); for (const target of [...(derived?.out ?? [])].sort()) { - if (!declaredOut.has(target)) push('→', 'MAPS_TO', target); + if (!declaredOut.has(target)) push('out', 'MAPS_TO', target); } for (const from of [...(derived?.in ?? [])].sort()) { - if (!declaredIn.has(from)) push('←', 'MAPS_TO', from); + if (!declaredIn.has(from)) push('in', 'MAPS_TO', from); } - for (const r of own) if (r.via === 'link' && !declaredOut.has(r.target)) push('→', r.relation, r.target); - for (const r of incoming) if (r.via === 'link' && !declaredIn.has(r.from)) push('←', r.relation, r.from); - return lines.slice(0, RELATION_LINES_CAP); + for (const r of own) if (r.via === 'link' && !declaredOut.has(r.target)) push('out', r.relation, r.target); + for (const r of incoming) if (r.via === 'link' && !declaredIn.has(r.from)) push('in', r.relation, r.from); + return relations.slice(0, RELATION_LINES_CAP); +} + +/** + * The `Related:` lines for one hit: declared outgoing first, then declared + * incoming, then path-derived, then link outgoing, then link incoming; within + * each group outgoing keeps the file's order and incoming sorts by the + * source's tree path. A declared relation beats a derived or linked one + * between the same two assets in the same direction. Capped at + * {@link RELATION_LINES_CAP}. + */ +export function relationLines( + ctx: RelationContext, + entry: SearchIndexEntry, + derived?: { out?: ReadonlySet; in?: ReadonlySet }, +): string[] { + return printedRelations(ctx, entry, derived).map((p) => p.line); +} + +/** + * The printed relations of every result, in print order: what + * {@link formatResults} shows and a run records (#1562). Computed once per + * recall so stdout and the log cannot diverge. + */ +function resultsRelations( + results: ScopedSearchResult[], + relationContexts: ReadonlyMap, +): PrintedRelation[][] { + const fallbackCtx = relationContexts.values().next().value as RelationContext | undefined; + // Path-derived rule→page edges, computed once over the pages each scope + // can see (#1563 part 1). Never stored, and independent of which pages + // this query returned. + const derived = computeDerivedRelations(relationContexts); + return results.map(({ entry, scope }) => { + // Relations resolve inside the scope the hit came from. A hit with an + // explicit scope never borrows another scope's context: when that + // scope has no index there is nothing it can see, and falling back + // would attach a user relation to a workspace page. + const ctx = scope !== undefined ? relationContexts.get(scope) : fallbackCtx; + if (!ctx) return []; + // Derived edges belong to the hit's scope like every other relation. + const derivedForScope = (scope !== undefined ? derived.get(scope) : undefined) + ?? (scope === undefined ? derived.values().next().value as DerivedEdges | undefined : undefined); + return printedRelations(ctx, entry, derivedForScope && entry.treePath + ? { out: derivedForScope.out.get(entry.treePath), in: derivedForScope.in.get(entry.treePath) } + : undefined); + }); +} + +/** The {@link RelatedRef}s of every result, as the run line records them (#1562). */ +export function relatedRefsOf( + results: ScopedSearchResult[], + relationContexts: ReadonlyMap, +): RelatedRef[][] { + return resultsRelations(results, relationContexts).map((list) => list.map((p) => p.ref)); } /** * Path-derived relations (#1563 part 1): a rule's `paths:` glob that matches * the `source:` files of a code page this recall already loaded is an edge - * with an explicit basis, computed at render time and never stored. - * - * Edges are computed and kept PER SCOPE: a relation stays inside its owner - * (#1552), so a user rule's glob never marks a workspace rule or page — each - * scope's rules match independently, and a hit renders only its own scope's - * edges. Ambiguity is judged per glob: a glob matching pages of two - * codebases is repository-relative and ambiguous, so that glob gives no - * edge, while another glob of the same rule that matches one codebase still - * does. Pages of withheld codebases never appear here, because recall never - * loads them. + * with an explicit basis, computed at render time and never stored. A glob + * matching pages of two codebases is ambiguous (globs are + * repository-relative) and gives no edge; pages of withheld codebases never + * appear here, because recall never loads them. */ export interface DerivedEdges { /** rule tree path → page tree paths its globs derive a `MAPS_TO` to */ @@ -576,14 +639,14 @@ function addEdge(map: Map>, from: string, to: string): void map.set(from, set); } -/** The derived edges of each scope, keyed like `relationContexts`. */ export function computeDerivedRelations( contexts: ReadonlyMap, ): Map { // Edges are judged against the pages each scope can see (its own index), - // never against the pages this query returned: a query that returns only - // one of two same-glob pages must not flip a certain edge into ambiguity, - // and a rule hit must show its → MAPS_TO even when the page is not a hit. + // never against the pages this query returned: a query that + // returns only one of two same-glob pages must not flip a certain edge + // into ambiguity, and a rule hit must show its → MAPS_TO even when the + // page is not a hit. const byScope = new Map(); for (const [scope, ctx] of contexts) { const out = new Map>(); @@ -618,11 +681,11 @@ export function formatResults( const lines: string[] = []; lines.push(`--- [teamai:recall:start] --- (${results.length} result${results.length !== 1 ? 's' : ''})${runId ? ` run=${runId}` : ''}`); lines.push(''); - const fallbackCtx = relationContexts?.values().next().value as RelationContext | undefined; - // Path-derived rule→page edges, computed once over the pages each scope - // can see (#1563 part 1). Never stored, and independent of which pages - // this query returned. - const derived = relationContexts ? computeDerivedRelations(relationContexts) : undefined; + // What this render prints and a run records, per result (#1562). It also + // computes the path-derived rule→page edges, once over the pages each + // scope can see (#1563 part 1) — never stored, and independent of which + // pages this query returned. + const relations = relationContexts ? resultsRelations(results, relationContexts) : undefined; for (let i = 0; i < results.length; i++) { const { entry, score, scope, sources, matchedTerms, missingTerms } = results[i]; @@ -645,21 +708,8 @@ export function formatResults( lines.push(`Matched: ${matchedStr} | Missing: ${missingTerms.join(', ')}`); } lines.push(`File: ${printedPath(results[i])}`); - if (relationContexts) { - // Relations resolve inside the scope the hit came from. A hit with an - // explicit scope never borrows another scope's context: when that - // scope has no index there is nothing it can see, and falling back - // would attach a user relation to a workspace page. - const ctx = scope !== undefined ? relationContexts.get(scope) : fallbackCtx; - if (ctx) { - // Derived edges belong to the hit's scope like every other relation: - // a hit never borrows another scope's rules (#1552). - const derivedForScope = (scope !== undefined ? derived?.get(scope) : undefined) - ?? (scope === undefined ? derived?.values().next().value as DerivedEdges | undefined : undefined); - lines.push(...relationLines(ctx, entry, derivedForScope && entry.treePath - ? { out: derivedForScope.out.get(entry.treePath), in: derivedForScope.in.get(entry.treePath) } - : undefined)); - } + if (relations) { + lines.push(...relations[i].map((p) => p.line)); } if (sources && sources.length > 0) { lines.push(`Sources: ${sources.map((s) => s.desc ? `${s.path} (${s.desc})` : s.path).join(', ')}`); @@ -739,7 +789,8 @@ export async function autoUpvote( * environment names (its agent family, and whether it was the only * candidate), the `--caller` that ran it and each returned doc as * printed, never the query. While a project is active an inherited user-scope - * doc is not eligible: it stays read-only, as for recalled_count. Returns the + * doc is not eligible: it stays read-only, as for recalled_count. `related` + * carries, per result, the `Related:` refs stdout showed (#1562). Returns the * run id, or undefined when the line could not be written, so no id is printed * that nothing can join. */ @@ -749,6 +800,7 @@ async function recordRun( session: EnvAgentSession, caller: string | undefined, projectActive: boolean, + related?: ReadonlyArray, ): Promise { const { appendRecallLine, recallLogPath } = await import('./recall-log.js'); const run = randomUUID(); @@ -762,7 +814,7 @@ async function recordRun( via: session.id ? 'env' : 'none', unambiguous: session.unambiguous, ...(caller ? { caller } : {}), - docs: results.map((r) => { + docs: results.map((r, i) => { const scope = r.scope ?? config.scope; return { key: voteKey(r), @@ -771,6 +823,9 @@ async function recordRun( path: printedPath(r), score: Math.round(r.score * 10) / 10, eligible: !(projectActive && scope === 'user'), + // Exactly what stdout showed for this doc (#1562); nothing recorded + // for a doc with no Related: line, so old readers see no change. + ...(related?.[i] && related[i].length > 0 ? { related: [...related[i]] } : {}), }; }), }); @@ -1290,6 +1345,16 @@ export async function recall( } }); + // The relations this recall prints are computed before the run is recorded, + // so the log can hold exactly what stdout shows (#1562). No results: nothing + // to print or record, and the contexts are not built. + let relationContexts: Map | undefined; + let relatedByResult: RelatedRef[][] | undefined; + if (topResults.length > 0) { + relationContexts = await buildRelationContexts(scopeIndexes, projectEntryKeys); + relatedByResult = relatedRefsOf(topResults, relationContexts); + } + // Record quality signal for contribute-check's knowledge-gap detection, // misses included. The run, a miss included, goes to the active scope's // recall log, where the hooks join it to the docs the session then opens @@ -1301,7 +1366,7 @@ export async function recall( recordRecallQuality(session.id ?? deriveSessionId({}), topResults, query); const activeConfig = projectConfig ?? scopeIndexes[0]?.config; if (activeConfig && !projectUnreadable) { - runId = await recordRun(activeConfig, topResults, session, options.caller, projectConfig !== null); + runId = await recordRun(activeConfig, topResults, session, options.caller, projectConfig !== null, relatedByResult); } } @@ -1312,7 +1377,6 @@ export async function recall( } // Output results (STDOUT — AI reads this) - const relationContexts = await buildRelationContexts(scopeIndexes, projectEntryKeys); const output = formatResults(topResults, runId, relationContexts); process.stdout.write(output + '\n'); diff --git a/src/stats.ts b/src/stats.ts index c645621ce..81069f108 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -323,9 +323,9 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { console.log(`Recall (last ${RECALL_SESSIONS_SHOWN} sessions):`); console.log(''); const cells = recallRows.map((r) => [ - r.session.slice(0, 8), r.agent ?? '-', String(r.runs), String(r.recalled), String(r.adopted), + r.session.slice(0, 8), r.agent ?? '-', String(r.runs), String(r.recalled), String(r.adopted), String(r.viaRelation), ]); - const header = ['session', 'agent', 'runs', 'recalled', 'adopted']; + const header = ['session', 'agent', 'runs', 'recalled', 'adopted', 'via relation']; const widths = header.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length))); // Text columns left-aligned, counts right-aligned. const format = (row: string[]): string => ` ${row.map((c, i) => (i < 2 ? c.padEnd(widths[i]) : c.padStart(widths[i]))).join(' ')}`; diff --git a/src/viz-render.ts b/src/viz-render.ts index 20aa5139f..8f7107be4 100644 --- a/src/viz-render.ts +++ b/src/viz-render.ts @@ -264,6 +264,43 @@ function renderSilentSection(silent: EntryMetric[]): string { ].join(''); } +/** Render the asset-relation health section (#1562 part 2): the counts headline plus the assets behind each count, as an HTML string. */ +function renderRelationsSection(data: VizData): string { + const rel = data.relations; + const headline = + `${rel.assetCount} assets · ${rel.relationCount} relations · ` + + `${rel.withoutRelation.length} without a relation · ${rel.unresolved.length} unresolved`; + const details = (title: string, items: readonly string[]): string => { + if (items.length === 0) return ''; + const rows = items.map((item) => `
  • ${escapeHtml(item)}
  • `).join(''); + return [ + `
    `, + ` ${title} (${items.length})`, + `
      ${rows}
    `, + `
    `, + ].join(''); + }; + // The counts resolve against this report's own scope and say so: a team repo + // and a personal ~/.teamai resolve different relation sets. + const scopeNote = + `

    Resolved for ` + + `${escapeHtml(data.source.label)}

    `; + const silentLine = + rel.silentAndWithoutRelation.length === 0 + ? `

    Silent and without a relation: none

    ` + : details('Silent and without a relation', rel.silentAndWithoutRelation); + return [ + `
    `, + `

    Relations

    `, + ` ${scopeNote}`, + `

    ${escapeHtml(headline)}

    `, + ` ${details('Without a relation', rel.withoutRelation)}`, + ` ${silentLine}`, + ` ${details('Unresolved', rel.unresolved)}`, + `
    `, + ].join(''); +} + /** Render the monthly last-recall trend line chart section as an HTML string. */ function renderTrendSection(trend: TrendPoint[]): string { const chart = renderLineChart(trend); @@ -394,6 +431,7 @@ export function renderDashboardReport(data: VizData): { context: string; mainten return { context: renderOverviewCards(data) + renderCoverageSection(data.coverage) + renderTopRecalledSection(data.topRecalled) + renderSilentSection(data.silent) + + renderRelationsSection(data) + renderTrendSection(data.trend) + renderAuthorsSection(data.authors), maintenance: renderMaintenanceSection(data), }; @@ -748,6 +786,7 @@ export function renderReport(data: VizData): string { renderCoverageSection(data.coverage), renderTopRecalledSection(data.topRecalled), renderSilentSection(data.silent), + renderRelationsSection(data), renderTrendSection(data.trend), renderAuthorsSection(data.authors), renderMaintenanceSection(data), diff --git a/src/viz.ts b/src/viz.ts index 179e19650..4bf968994 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -5,13 +5,16 @@ * Resolves filesystem paths, aggregates per-user vote data, builds index entries, * and assembles the complete VizData payload used by the HTML renderer. */ -import { promises as fs } from 'node:fs'; +import { existsSync, promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { loadIndex, buildIndex, type BuildIndexOptions } from './utils/search-index.js'; import { readUserVotes } from './votes.js'; import { BROKEN_CONFIG_ADVICE, findUnreadableProjectConfig, resolveConfigForDir } from './config.js'; +import type { AssetKind } from './asset-relations.js'; +import { buildRelationContext } from './recall.js'; +import { computeLintAssetGraph, memberScopeGraphs } from './lint-asset-graph.js'; import { getTeamaiHomeDir, getUserVotesDir, @@ -21,7 +24,7 @@ import { getReportsDir, getProjectSearchIndexPath, } from './types.js'; -import type { LocalConfig, SearchIndexEntry } from './types.js'; +import type { LocalConfig, SearchIndex, SearchIndexEntry } from './types.js'; import { findPromotionCandidates, type PromotionCandidate } from './maintenance/promote.js'; import { findPruneCandidates, type PruneCandidate } from './maintenance/prune.js'; import { findStaleEntries, type StaleEntry } from './maintenance/quality-update.js'; @@ -90,6 +93,26 @@ export interface VizSource { label: string; } +/** + * Relation-derived health counts over the report's own index (#1562 part 2), + * computed with the same definitions as `codebase --lint`'s asset graph. + */ +export interface RelationStats { + /** Indexed rules, docs, learnings and skills; teamwiki pages are not assets. */ + assetCount: number; + /** + * Resolved relations, each counted once (not per direction), links included, + * declared beating a link between the same two assets, and no recall line cap. + */ + relationCount: number; + /** Team-tree paths of assets with no resolved relation in either direction, sorted. */ + withoutRelation: string[]; + /** ` ` lines for declared targets naming nothing on disk, sorted. */ + unresolved: string[]; + /** Intersection of withoutRelation with the never-recalled entries, sorted. */ + silentAndWithoutRelation: string[]; +} + /** Complete data payload for the HTML visualization report. */ export interface VizData { generatedAt: string; @@ -104,6 +127,8 @@ export interface VizData { topRecalled: EntryMetric[]; /** All entries with recalledCount === 0. */ silent: EntryMetric[]; + /** Relation counts and the assets behind each, resolved for this report's scope. */ + relations: RelationStats; /** Monthly recall activity, ascending by period. */ trend: TrendPoint[]; /** Top 15 authors by totalRecalled. */ @@ -423,6 +448,48 @@ function buildAuthors(metrics: EntryMetric[]): AuthorStat[] { .slice(0, 15); } +/** + * The #1562 part-2 relation counts. A report with a member scope counts + * through {@link memberScopeGraphs} — the same saved indexes and scope + * selection `codebase --lint` uses — so the health view and `--lint` cannot + * disagree about the inputs (#1637 review). A report without one (`--repo`, + * the ~/.teamai fallback) counts its own entries. The silent intersection + * still filters by what this view lists. + */ +async function computeRelationStats( + entries: SearchIndexEntry[], + paths: VizPaths, + isSilent: (entry: SearchIndexEntry) => boolean, +): Promise { + const memberGraphs = paths.scope ? await memberScopeGraphs({ dryRun: true, config: paths.scope }) : null; + const graph = memberGraphs + ? computeLintAssetGraph(memberGraphs) + : await (async () => { + const ctx = await buildRelationContext({ + index: { builtAt: '', elapsedMs: 0, entries } as SearchIndex, + // --repo and the ~/.teamai fallback have no LocalConfig; the knowledge root + // is the team-tree root in both, which is all relation resolution reads. + config: { repo: { localPath: paths.knowledgeRoot } } as LocalConfig, + }); + const targetExists = (kind: AssetKind, treePath: string): boolean => { + const rel = kind === 'learning' ? `${treePath.slice('learnings/'.length)}.md` : treePath; + const roots = kind === 'learning' ? paths.indexLearningsDirs : [paths.knowledgeRoot]; + return roots.some((root) => existsSync(path.join(root, ...rel.split('/')))); + }; + return computeLintAssetGraph([{ ctx, targetExists }]); + })(); + const silentPaths = new Set( + entries.filter(isSilent).map((e) => e.treePath).filter((p): p is string => typeof p === 'string'), + ); + return { + assetCount: graph.assetCount, + relationCount: graph.relationCount, + withoutRelation: graph.withoutRelation, + unresolved: graph.unresolved.map((u) => `${u.from} ${u.relation} ${u.target}`), + silentAndWithoutRelation: graph.withoutRelation.filter((p) => silentPaths.has(p)), + }; +} + // ─── Core build function ────────────────────────────────────────────────────── /** @@ -461,6 +528,11 @@ export async function buildVizData(paths: VizPaths): Promise { const silent = metrics.filter((m) => m.recalledCount === 0); const trend = buildTrend(metrics); const authors = buildAuthors(metrics); + const relations = await computeRelationStats( + entries, + paths, + (entry) => (votesAgg[entry.filename.replace(/\.md$/i, '')]?.recalledCount ?? 0) === 0, + ); const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; const [promote, prune, stale] = await Promise.all([ @@ -493,6 +565,7 @@ export async function buildVizData(paths: VizPaths): Promise { coverage, topRecalled, silent, + relations, trend, authors, maintenance: { promote, prune, stale }, @@ -554,6 +627,12 @@ export async function getDashboardContext(opts: VizOptions = {}) { totalEntries: data.totalEntries, overallCoveragePct: data.overallCoveragePct, silentCount: data.silent.length, + relationCounts: { + assets: data.relations.assetCount, + relations: data.relations.relationCount, + withoutRelation: data.relations.withoutRelation.length, + unresolved: data.relations.unresolved.length, + }, maintenanceCounts: { promote: data.maintenance.promote.length, prune: data.maintenance.prune.length,