From 80d6ff2f5312079cbea1519af6593b321623703f Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Sun, 4 Oct 2026 00:40:49 +0530 Subject: [PATCH 01/21] feat(recall): scope teamwiki codebase recall by project/role (#912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs and learnings are already scoped by role/project namespace (#707); teamwiki was not. A checkout active on one project (`--project svc-a`) got `teamai recall` hits from every codebase's `evidence/code//`, including ones that belong to an unrelated project — recall would surface another team's codebase evidence just because it lived in the same wiki. Adds a hand-declared `wiki` resource type (manifest-schema.ts), following the exact pattern `docs` already uses: - `HAND_DECLARED_RESOURCE_TYPES` gains `'wiki'` — this alone threads it through the existing generic plumbing in roles.ts/projects.ts (NAMESPACED_RESOURCE_TYPES, mergeNamespaces, resolveRoleResourceNamespaces, resolveProjectResourceNamespaces all already loop over HAND_DECLARED_RESOURCE_TYPES generically) with no changes needed there. - resource-namespaces.ts computes `inactiveWikiNamespaces` the same way it already computes `inactiveDocsNamespaces`: a slug ANY role or project declares under `resources.wiki` is withheld unless this directory's active role/project selects it; an undeclared slug stays shared. - code-knowledge-recall.ts's `loadWikiPages`/`queryCodeKnowledge` take a new `withheldCodebases` option and filter `evidence/code//` directories by it (case-folded, matching how the directory lookup itself is effectively case-insensitive on Windows/macOS). - recall.ts wires the two together: computes `withheldCodebases` from the active project config via `resolveResourceNamespaces` and passes it to `queryCodeKnowledge`. The slug is whatever `teamai codebase --project ` wrote, unrelated to a manifest project id, so a team declares the one it already uses — no new convention needed. Docs: usage-guide.md (+ zh-CN) and the admin skill reference (manage-admin.md) each get a short "Wiki by namespace" paragraph mirroring the existing "Docs by namespace" one. Test plan: 12 new tests across 3 files — resource-namespaces-wiki.test.ts (inactiveWikiNamespaces resolution against real roles.yaml/projects.yaml fixtures), code-knowledge-recall-wiki-scope.test.ts (withheldCodebases filtering against real teamwiki fixtures, including case-fold matching), recall-wiki-scope.test.ts (recall()'s wiring, asserting the exact withheldCodebases argument queryCodeKnowledge receives). All 12 pass. Typecheck and lint (oxlint --type-aware) both clean. The 5 existing test files most directly touched by this change (recall.test.ts, recall-scope-isolation.test.ts, pull-docs-namespaces.test.ts, resource-namespaces-case-alias.test.ts, projects.test.ts — 71 tests) all still pass with zero regressions. A full project-wide suite run was attempted but the host machine was resource-starved from this session's own accumulated test-run processes (100+ orphaned node.exe, causing cascading 15s test timeouts and one literally-impossible "8514000ms" timer reading); killed rather than trust its output. recall-attribution.test.ts's one slow/timeout-prone test was independently confirmed pre-existing and unrelated via direct git-stash before/after comparison. Co-Authored-By: Claude Sonnet 5 --- docs/usage-guide.md | 12 ++ docs/usage-guide.zh-CN.md | 12 ++ skill-data/setup/references/manage-admin.md | 6 + .../code-knowledge-recall-wiki-scope.test.ts | 67 ++++++++++++ src/__tests__/recall-wiki-scope.test.ts | 103 ++++++++++++++++++ .../resource-namespaces-wiki.test.ts | 89 +++++++++++++++ src/code-knowledge-recall.ts | 22 +++- src/manifest-schema.ts | 8 +- src/recall.ts | 8 +- src/resource-namespaces.ts | 12 +- 10 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/code-knowledge-recall-wiki-scope.test.ts create mode 100644 src/__tests__/recall-wiki-scope.test.ts create mode 100644 src/__tests__/resource-namespaces-wiki.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 029152495..3ea79adc1 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1946,6 +1946,18 @@ When extract finds components, it writes `teamwiki/evidence/code//_mani Without `--project`, `` is the directory's name. At the root of a checkout, the main one or a linked git worktree, it is the repo's name: the main checkout's real name (also when opened through a symlink), or a bare repo's (`repo/.bare` or `repo.git` → `repo`). Every checkout of a repo writes the same entry. `teamai import --dir` picks its slug the same way. +**Wiki by namespace.** In a team repo with `manifest/projects.yaml`, `recall` scopes `teamwiki/evidence/code//` the same way it scopes docs: once any role or project lists a codebase slug under `resources.wiki`, it reaches only the members who have it active, and an undeclared slug stays shared: + +```yaml +# manifest/projects.yaml +projects: + - id: svc-a + resources: + wiki: [svc-a] # evidence/code/svc-a/ only where svc-a is active +``` + +The slug is whichever one `teamai codebase --project ` (or `teamai import`) wrote under `evidence/code/`; it has no required relationship to the manifest's project id, so declare the one the extraction actually used. Legacy mode (no role and no `projects.yaml`) searches every codebase, as before. + ### Dashboard ```bash diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 85f0f9cfb..1de183f11 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1791,6 +1791,18 @@ teamai codebase --lint --output /path/to/repo `.teamai/pending-review.jsonl` 中的待审改动可用 `teamai review` 查看。用 `teamai review --apply --dry-run`、`teamai review --reject --dry-run` 或 `teamai review --all-apply --max-risk medium --dry-run` 预览处理决定。应用预览会执行与真实应用相同的目标文件和托管章节校验,但不会修改文档或移除待审项;批量预览保留相同的类型与风险筛选。处理预览的 `--json` 输出包含 `dryRun: true`,其中 `ok` 表示通过校验,不表示已写入。去掉 `--dry-run` 才会执行处理。 +**按 namespace 分发 wiki。** 在带有 `manifest/projects.yaml` 的团队仓库中,`recall` 对 `teamwiki/evidence/code//` 采用与 docs 相同的作用域规则:只要有任一角色或项目在 `resources.wiki` 中列出某个 codebase slug,它就只分发给激活了它的成员;未声明的 slug 仍然共享: + +```yaml +# manifest/projects.yaml +projects: + - id: svc-a + resources: + wiki: [svc-a] # 只有激活 svc-a 时才能看到 evidence/code/svc-a/ +``` + +这个 slug 就是 `teamai codebase --project `(或 `teamai import`)写入 `evidence/code/` 时用的那个值,它与 manifest 的 project id 没有必然关系,按实际提取时用的那个值声明即可。旧式用法(没有角色、没有 `projects.yaml`)会搜索所有 codebase,和之前一样。 + ### Dashboard ```bash diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 507bff518..f1fc05f45 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -171,6 +171,12 @@ namespace, their next pull removes its docs that still match the team copy and keeps (and names) the ones they edited. Recall and `teamai doctor` follow the same filter. +Wiki codebase slugs follow the same rule under `resources.wiki`: once any role +or project lists a `teamwiki/evidence/code//` slug there, `recall` only +surfaces it for members with that namespace active; an undeclared slug stays +shared. The slug is whatever `teamai codebase --project ` wrote, not +necessarily the project's manifest id. + ## Team dashboard (web UI) ```bash diff --git a/src/__tests__/code-knowledge-recall-wiki-scope.test.ts b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts new file mode 100644 index 000000000..001a1db8e --- /dev/null +++ b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts @@ -0,0 +1,67 @@ +/** + * `queryCodeKnowledge`'s `withheldCodebases` (#912): a codebase slug under + * `teamwiki/evidence/code//` that a role or project declared but did not + * activate must not surface in recall, the same way an inactive docs namespace + * never reaches the search index. + */ +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { queryCodeKnowledge } from '../code-knowledge-recall.js'; + +let wikiRoot: string; + +function page(project: string, file: string, content: string): void { + const dir = path.join(wikiRoot, 'evidence', 'code', project); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, file), content, 'utf-8'); +} + +beforeEach(() => { + wikiRoot = mkdtempSync(path.join(os.tmpdir(), 'teamai-wiki-scope-')); + page('svc-a', 'overview.md', '---\ntitle: Svc A overview\n---\n\nnarwhal contract details for svc-a.\n'); + page('svc-b', 'overview.md', '---\ntitle: Svc B overview\n---\n\nnarwhal contract details for svc-b.\n'); +}); + +afterEach(() => { + rmSync(wikiRoot, { recursive: true, force: true }); +}); + +describe('queryCodeKnowledge: withheldCodebases', () => { + it('returns pages from every codebase when nothing is withheld, as before', async () => { + const results = await queryCodeKnowledge('narwhal', { wikiRoot, depth: 'lookup', limit: 10 }); + + const pages = results.map((r) => r.page).sort(); + expect(pages).toEqual(['evidence/code/svc-a/overview.md', 'evidence/code/svc-b/overview.md']); + }); + + it('excludes a withheld codebase slug, keeping the others', async () => { + const results = await queryCodeKnowledge('narwhal', { + wikiRoot, depth: 'lookup', limit: 10, withheldCodebases: ['svc-b'], + }); + + const pages = results.map((r) => r.page); + expect(pages).toEqual(['evidence/code/svc-a/overview.md']); + }); + + it('matches a withheld slug case-foldedly, as evidence/code// does on a case-insensitive filesystem', async () => { + const results = await queryCodeKnowledge('narwhal', { + wikiRoot, depth: 'lookup', limit: 10, withheldCodebases: ['SVC-B'], + }); + + const pages = results.map((r) => r.page); + expect(pages).toEqual(['evidence/code/svc-a/overview.md']); + }); + + it('withholds every listed codebase when more than one is inactive', async () => { + page('svc-c', 'overview.md', '---\ntitle: Svc C overview\n---\n\nnarwhal contract details for svc-c.\n'); + + const results = await queryCodeKnowledge('narwhal', { + wikiRoot, depth: 'lookup', limit: 10, withheldCodebases: ['svc-b', 'svc-c'], + }); + + const pages = results.map((r) => r.page); + expect(pages).toEqual(['evidence/code/svc-a/overview.md']); + }); +}); diff --git a/src/__tests__/recall-wiki-scope.test.ts b/src/__tests__/recall-wiki-scope.test.ts new file mode 100644 index 000000000..bce0466b0 --- /dev/null +++ b/src/__tests__/recall-wiki-scope.test.ts @@ -0,0 +1,103 @@ +/** + * `recall()` wires a project's inactive wiki namespaces into the codebase + * knowledge query (#912), the same way docs are already scoped: a codebase + * slug under `teamwiki/evidence/code//` that this directory's active + * project does not select must not reach `queryCodeKnowledge`. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + detectProjectConfig: vi.fn(), + requireInit: vi.fn(), + loadLocalConfigForScope: vi.fn(), +})); + +vi.mock('../code-knowledge-recall.js', () => ({ + queryCodeKnowledge: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../votes.js', () => ({ + incrementRecalled: vi.fn().mockResolvedValue(undefined), +})); + +import { recall } from '../recall.js'; +import { detectProjectConfig } from '../config.js'; +import { queryCodeKnowledge } from '../code-knowledge-recall.js'; +import type { LocalConfig } from '../types.js'; + +describe('recall: scopes codebase knowledge by the active project\'s wiki namespaces (#912)', () => { + let tmpDir: string; + let projectRoot: string; + let teamRepo: string; + let writeSpy: { mockRestore: () => void }; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recall-wiki-')); + projectRoot = path.join(tmpDir, 'proj'); + teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(teamRepo); + vi.stubEnv('HOME', path.join(tmpDir, 'home')); + + await fse.outputFile( + path.join(teamRepo, 'manifest', 'projects.yaml'), + 'version: 1\nprojects:\n' + + ' - id: svc-a\n name: Svc A\n resources: { wiki: [svc-a, payments] }\n' + + ' - id: svc-b\n name: Svc B\n resources: { wiki: [svc-b, payments] }\n', + ); + // hasWiki only checks the directory exists; queryCodeKnowledge itself is mocked. + await fse.ensureDir(path.join(teamRepo, 'teamwiki')); + + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + vi.mocked(queryCodeKnowledge).mockClear(); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await fse.remove(tmpDir); + }); + + function projectConfig(projects: string[]): LocalConfig { + return { + repo: { localPath: teamRepo, remote: 'https://example.test/acme/team.git' }, + username: 'tester', + additionalRoles: [], + scope: 'project', + projectRoot, + projects, + }; + } + + it('withholds a project-declared wiki namespace this directory did not activate', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig(['svc-a'])); + + await recall('narwhal', { dryRun: true }); + + const call = vi.mocked(queryCodeKnowledge).mock.calls[0]?.[1]; + expect(call?.withheldCodebases?.sort()).toEqual(['svc-b']); + }); + + it('does not withhold a namespace the active project selects, even when another project also declares it', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig(['svc-a'])); + + await recall('narwhal', { dryRun: true }); + + const call = vi.mocked(queryCodeKnowledge).mock.calls[0]?.[1]; + expect(call?.withheldCodebases).not.toContain('payments'); + }); + + it('passes no withheld namespaces when the directory has no projects manifest, as before', async () => { + await fse.remove(path.join(teamRepo, 'manifest', 'projects.yaml')); + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig([])); + + await recall('narwhal', { dryRun: true }); + + const call = vi.mocked(queryCodeKnowledge).mock.calls[0]?.[1]; + expect(call?.withheldCodebases).toEqual([]); + }); +}); diff --git a/src/__tests__/resource-namespaces-wiki.test.ts b/src/__tests__/resource-namespaces-wiki.test.ts new file mode 100644 index 000000000..3f0b17018 --- /dev/null +++ b/src/__tests__/resource-namespaces-wiki.test.ts @@ -0,0 +1,89 @@ +/** + * Wiki codebase slugs scoped by role/project (#912), the same declared-vs-active + * rule `inactiveDocsNamespaces` already applies to `docs//`: a slug ANY role + * or project lists under `resources.wiki` reaches only members who have it + * active; an undeclared slug stays shared (unfiltered, as before this feature). + */ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveResourceNamespaces } from '../resource-namespaces.js'; +import type { LocalConfig } from '../types.js'; + +function repoWith(roles: string, projects: string): string { + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-ns-wiki-')); + mkdirSync(path.join(repoDir, 'manifest'), { recursive: true }); + writeFileSync(path.join(repoDir, 'manifest', 'roles.yaml'), roles, 'utf-8'); + writeFileSync(path.join(repoDir, 'manifest', 'projects.yaml'), projects, 'utf-8'); + return repoDir; +} + +function localConfig(repoDir: string, overrides: Partial = {}): LocalConfig { + return { + repo: { localPath: repoDir, remote: 'https://github.com/acme/team.git' }, + username: 'e2e', + additionalRoles: [], + ...overrides, + } as LocalConfig; +} + +describe('resolveResourceNamespaces: inactiveWikiNamespaces', () => { + const NO_WIKI_ROLE = 'version: 1\nroles:\n - id: other\n resources: { knowledge: [], skills: [] }\n'; + const PROJECTS = 'version: 1\nprojects:\n' + + ' - id: svc-a\n name: Svc A\n resources: { wiki: [svc-a, payments] }\n' + + ' - id: svc-b\n name: Svc B\n resources: { wiki: [svc-b, payments] }\n'; + + it('withholds a declared slug no active role or project selects', async () => { + const repoDir = repoWith(NO_WIKI_ROLE, PROJECTS); + try { + const resolved = await resolveResourceNamespaces(localConfig(repoDir, { projects: ['svc-a'] })); + expect(resolved?.inactiveWikiNamespaces.sort()).toEqual(['svc-b']); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('does not withhold a slug a member\'s active project selects, even when another project also declares it', async () => { + const repoDir = repoWith(NO_WIKI_ROLE, PROJECTS); + try { + const resolved = await resolveResourceNamespaces(localConfig(repoDir, { projects: ['svc-a'] })); + expect(resolved?.inactiveWikiNamespaces).not.toContain('payments'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('withholds every declared slug for a member with no active project', async () => { + const repoDir = repoWith(NO_WIKI_ROLE, PROJECTS); + try { + const resolved = await resolveResourceNamespaces(localConfig(repoDir, { projects: [] })); + expect(resolved?.inactiveWikiNamespaces.sort()).toEqual(['payments', 'svc-a', 'svc-b']); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('leaves an undeclared slug alone: resolution returns null with no role, no projects and no manifest', async () => { + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-ns-wiki-none-')); + try { + const resolved = await resolveResourceNamespaces(localConfig(repoDir, { projects: [] })); + expect(resolved).toBeNull(); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('treats a role-declared wiki namespace as declared for a member who does not hold that role', async () => { + const roles = 'version: 1\nroles:\n' + + ' - id: fe\n resources: { knowledge: [], skills: [], wiki: [frontend-infra] }\n' + + ' - id: other\n resources: { knowledge: [], skills: [] }\n'; + const repoDir = repoWith(roles, 'version: 1\nprojects: []\n'); + try { + const resolved = await resolveResourceNamespaces(localConfig(repoDir, { primaryRole: 'other', projects: [] })); + expect(resolved?.inactiveWikiNamespaces).toEqual(['frontend-infra']); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/code-knowledge-recall.ts b/src/code-knowledge-recall.ts index 0753eb19f..651d1e78a 100644 --- a/src/code-knowledge-recall.ts +++ b/src/code-knowledge-recall.ts @@ -12,6 +12,7 @@ import matter from 'gray-matter'; import type { GraphIndex } from './wiki-engine/core/graph-index.schema.js'; import { tokenize, tokenCount, MAX_TOKENIZE_CHARS } from './utils/tokenizer.js'; +import { caseFoldKey } from './manifest-schema.js'; export interface SourceAnchor { path: string; @@ -227,7 +228,11 @@ function extractSnippet(content: string, queryTokens: string[], maxLen: number = return snippet; } -async function loadWikiPages(wikiRoot: string, depth: 'route' | 'context' | 'lookup'): Promise { +async function loadWikiPages( + wikiRoot: string, + depth: 'route' | 'context' | 'lookup', + withheldCodebases: string[] = [], +): Promise { const pages: PageDoc[] = []; if (depth === 'route') { @@ -259,6 +264,15 @@ async function loadWikiPages(wikiRoot: string, depth: 'route' | 'context' | 'loo return pages; } + // A codebase slug a role or project declared under `resources.wiki` but did + // not select stays out of recall for this directory (#912), the same way an + // inactive docs namespace stays out. Case-folded: `evidence/code/Payments/` + // and a declared `payments` are the same slug on a case-insensitive filesystem. + if (withheldCodebases.length > 0) { + const withheld = new Set(withheldCodebases.map(caseFoldKey)); + projectDirs = projectDirs.filter((project) => !withheld.has(caseFoldKey(project))); + } + for (const project of projectDirs) { const projectDir = path.join(evidenceDir, project); await loadPagesRecursive(projectDir, `evidence/code/${project}`, pages, depth); @@ -436,15 +450,17 @@ export interface QueryCodeKnowledgeOptions { limit?: number; /** route: 只返回路由建议;context: 搜索 overview+modules+docs;lookup: 全量搜索 */ depth?: 'route' | 'context' | 'lookup'; + /** Codebase slugs (evidence/code//) a role or project declared under `resources.wiki` but did not activate (#912). */ + withheldCodebases?: string[]; } export async function queryCodeKnowledge( query: string, options: QueryCodeKnowledgeOptions, ): Promise { - const { wikiRoot, limit = 5, depth = 'context' } = options; + const { wikiRoot, limit = 5, depth = 'context', withheldCodebases = [] } = options; - const pages = await loadWikiPages(wikiRoot, depth); + const pages = await loadWikiPages(wikiRoot, depth, withheldCodebases); if (pages.length === 0) return []; if (depth === 'route') { diff --git a/src/manifest-schema.ts b/src/manifest-schema.ts index 19302e277..8a5979c2c 100644 --- a/src/manifest-schema.ts +++ b/src/manifest-schema.ts @@ -71,7 +71,7 @@ export const NamespaceSegmentSchema = z.string().min(1).refine(isSafeNamespaceSe * writes back carries one only when an admin declared it. A new type is one * entry here plus one line in the shape below. */ -export const HAND_DECLARED_RESOURCE_TYPES = ['env', 'hooks', 'mcp', 'models', 'docs'] as const; +export const HAND_DECLARED_RESOURCE_TYPES = ['env', 'hooks', 'mcp', 'models', 'docs', 'wiki'] as const; export type HandDeclaredResourceType = typeof HAND_DECLARED_RESOURCE_TYPES[number]; @@ -99,6 +99,12 @@ export const HandDeclaredNamespacesShape = { mcp: OptionalNamespaceList, models: OptionalNamespaceList, docs: DocsNamespaceList, + // Scopes `teamwiki/evidence/code//` recall by project/role (#912), the + // same way `docs` already scopes `docs//`. The slug is a `teamai + // codebase --project ` output directory name, unrelated to a manifest + // project id — a team picks one it already uses so declaring it needs no + // new convention. + wiki: OptionalNamespaceList, } satisfies Record>>>; /** diff --git a/src/recall.ts b/src/recall.ts index b44c980a9..ee2f8a966 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -10,6 +10,7 @@ import type { GlobalOptions, SearchIndex, LocalConfig, KnowledgeDomain } from '. import { getProjectSearchIndexPath, getUserSearchIndexPath, getVotesDir } from './types.js'; import { queryCodeKnowledge } from './code-knowledge-recall.js'; import type { SourceAnchor } from './code-knowledge-recall.js'; +import { resolveResourceNamespaces } from './resource-namespaces.js'; import { recordRecallQuality } from './recall-quality.js'; import { agentSessionFromEnv, deriveSessionId } from './utils/session-id.js'; import type { EnvAgentSession } from './utils/session-id.js'; @@ -686,8 +687,13 @@ export async function recall( } // ── Codebase knowledge graph recall ────────────────────── + // A wiki codebase slug declared under `resources.wiki` but not active for + // this directory stays out of recall, the same way docs are scoped (#912). + const withheldCodebases = hasWiki && wikiConfig + ? (await resolveResourceNamespaces(wikiConfig))?.inactiveWikiNamespaces ?? [] + : []; try { - const codeResults = await queryCodeKnowledge(query, { wikiRoot, limit: 3, depth: options.depth }); + const codeResults = await queryCodeKnowledge(query, { wikiRoot, limit: 3, depth: options.depth, withheldCodebases }); // B11 fix: log-dampening instead of min-max normalization // Codebase BM25 scores (0-50+) mapped to learnings scale (0-10) via log curve for (const cr of codeResults) { diff --git a/src/resource-namespaces.ts b/src/resource-namespaces.ts index d7d1d5cc3..e72742729 100644 --- a/src/resource-namespaces.ts +++ b/src/resource-namespaces.ts @@ -130,7 +130,17 @@ export async function resolveResourceNamespaces(localConfig: LocalConfig) { const activeDocs = new Set(activeNamespaces.docs ?? []); const inactiveDocsNamespaces = [...declaredDocsNamespaces].filter((namespace) => !activeDocs.has(namespace)); - return { activeNamespaces, allSkillNamespaces, inactiveDocsNamespaces }; + // Wiki codebase slugs follow the same declared-vs-active rule as docs (#912): + // a slug ANY role or project lists under `resources.wiki` reaches only the + // members who have it active; an undeclared slug stays shared. + const declaredWikiNamespaces = new Set([ + ...(rolesManifest?.roles ?? []).flatMap((role) => role.resources.wiki ?? []), + ...(projectsManifest?.projects ?? []).flatMap((project) => project.resources.wiki ?? []), + ]); + const activeWiki = new Set(activeNamespaces.wiki ?? []); + const inactiveWikiNamespaces = [...declaredWikiNamespaces].filter((namespace) => !activeWiki.has(namespace)); + + return { activeNamespaces, allSkillNamespaces, inactiveDocsNamespaces, inactiveWikiNamespaces }; } /** From 066c8d7d41e18774603b8e6b3482de0e44593aaf Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Mon, 5 Oct 2026 10:10:08 +0530 Subject: [PATCH 02/21] fix(recall): scope route-depth router.md and the knowledge graph by wiki namespace (#974 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-review on PR #974 found two gaps where a withheld codebase still leaked through recall despite the page-level scoping: - `--depth route` returned the global `teamwiki/router.md` verbatim. Since that file lists every codebase (name, link, description, keywords) plus a `` comment aggregating all keywords, a directory scoped to one codebase could still see every other one's entry. Fixed by stripping withheld-codebase bullet lines and the anchor comment from the content before it's returned. - The knowledge graph (`.indices/graph-index.json`) was loaded unfiltered at `context`/`lookup` depth, so a withheld codebase's nodes could still match as BM25 entry nodes, boost scores via graph neighbors, or — most concretely — surface a withheld codebase's own node identifier through an allowed page's `relatedFiles` via a cross-repo DEPENDS_ON edge. A withheld codebase's fact-level graph nodes/edges (AST/heuristic) are keyed by raw file path with no `evidence/code//` prefix at all, so there's no reliable string to filter the already-merged graph by after the fact. Fixed at the root instead: `graph-aggregate.ts`'s existing per-repo aggregation (the one place that still knows, per per-repo graph file, which codebase it came from) now accepts an `excludeProjects` set and skips a withheld codebase's graph file entirely — including skipping cross-repo edge detection against it. `queryCodeKnowledge` calls this scoped rebuild instead of reading the flat merged file whenever any codebase is withheld. Verified via the real built CLI (`npm run build` + a hand-built team repo + project fixture), not just unit tests: - `recall "octopus" --depth lookup` with only `svc-a` active returns only svc-a's page; with both active, both return. - `recall "router" --depth route` with only `svc-a` active strips svc-b's bullet and the keyword anchor from router.md's snippet. - A synthetic cross-repo graph edge (`a/client -> b/service`) surfaces `b/service` in "Candidate change files" when both projects are active, and disappears when svc-b is withheld. Added 7 new tests (3 in graph-aggregate.test.ts for buildAggregatedGraph's excludeProjects, 4 in code-knowledge-recall-wiki-scope.test.ts for router filtering and cross-codebase relatedFiles leakage) plus the 12 from the original PR. All 39 tests across every touched/related file pass, typecheck and lint are clean. A full project-wide `npx vitest run` was killed by the harness due to low system memory (not a test failure) before completing; given the same resource constraint as the original PR, this round relies on the targeted evidence above rather than a full clean run. Co-Authored-By: Claude Sonnet 5 --- .../code-knowledge-recall-wiki-scope.test.ts | 70 +++++++++++++++++++ src/__tests__/graph-aggregate.test.ts | 49 ++++++++++++- src/code-knowledge-recall.ts | 48 +++++++++++-- src/graph-aggregate.ts | 31 ++++++-- 4 files changed, 187 insertions(+), 11 deletions(-) diff --git a/src/__tests__/code-knowledge-recall-wiki-scope.test.ts b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts index 001a1db8e..ee816d2aa 100644 --- a/src/__tests__/code-knowledge-recall-wiki-scope.test.ts +++ b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts @@ -9,6 +9,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { queryCodeKnowledge } from '../code-knowledge-recall.js'; +import { aggregateGlobalGraph } from '../graph-aggregate.js'; let wikiRoot: string; @@ -18,6 +19,12 @@ function page(project: string, file: string, content: string): void { writeFileSync(path.join(dir, file), content, 'utf-8'); } +function repoGraph(project: string, graph: object): void { + const dir = path.join(wikiRoot, 'evidence', 'code', project, '.indices'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'graph-index.json'), JSON.stringify(graph), 'utf-8'); +} + beforeEach(() => { wikiRoot = mkdtempSync(path.join(os.tmpdir(), 'teamai-wiki-scope-')); page('svc-a', 'overview.md', '---\ntitle: Svc A overview\n---\n\nnarwhal contract details for svc-a.\n'); @@ -64,4 +71,67 @@ describe('queryCodeKnowledge: withheldCodebases', () => { const pages = results.map((r) => r.page); expect(pages).toEqual(['evidence/code/svc-a/overview.md']); }); + + describe('route depth: router.md', () => { + function router(content: string): void { + writeFileSync(path.join(wikiRoot, 'router.md'), content, 'utf-8'); + } + + it('strips a withheld codebase\'s name, link, description and keywords from router.md', async () => { + router( + '# Team Wiki Router\n' + + '\n\n' + + '## 项目域入口\n\n' + + '- [[evidence/code/svc-a/index]] — Svc A desc [alpha]\n' + + '- [[evidence/code/svc-b/index]] — Svc B desc [payments-ledger]\n', + ); + + const [result] = await queryCodeKnowledge('router', { + wikiRoot, depth: 'route', withheldCodebases: ['svc-b'], + }); + expect(result.snippet).toContain('svc-a'); + expect(result.snippet).not.toContain('svc-b'); + expect(result.snippet).not.toContain('payments-ledger'); + }); + + it('returns router.md unfiltered when nothing is withheld, as before', async () => { + router('# Team Wiki Router\n\n- [[evidence/code/svc-b/index]] — Svc B desc\n'); + const [result] = await queryCodeKnowledge('router', { wikiRoot, depth: 'route' }); + expect(result.snippet).toContain('svc-b'); + }); + }); + + describe('graph scoping (#912 follow-up): a withheld codebase must not reach recall through the knowledge graph', () => { + beforeEach(() => { + // An svc-a file depends on a file whose basename-derived PascalCase + // matches a component title declared only in svc-b — the same + // cross-repo edge detection exercised by graph-aggregate.test.ts. + page('svc-a', 'overview.md', '---\ntitle: Svc A overview\nsource: a/client\n---\n\nnarwhal contract details for svc-a.\n'); + repoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/client', title: 'BalanceClient', type: 'component', confidence: 'EXTRACTED' }], + edges: [{ from: 'a/client', to: 'libs/balance_service.py', relation: 'imports' }], + }); + repoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/service', title: 'BalanceService', type: 'component', confidence: 'EXTRACTED' }], + edges: [], + }); + }); + + it('surfaces a cross-codebase relatedFile when nothing is withheld, as before', async () => { + await aggregateGlobalGraph(wikiRoot); + const results = await queryCodeKnowledge('narwhal', { wikiRoot, depth: 'lookup', limit: 10 }); + const svcA = results.find((r) => r.page === 'evidence/code/svc-a/overview.md'); + expect(svcA?.relatedFiles).toContain('b/service'); + }); + + it('never surfaces a withheld codebase\'s node as a relatedFile, even via a cross-codebase graph edge', async () => { + const results = await queryCodeKnowledge('narwhal', { + wikiRoot, depth: 'lookup', limit: 10, withheldCodebases: ['svc-b'], + }); + const svcA = results.find((r) => r.page === 'evidence/code/svc-a/overview.md'); + expect(svcA?.relatedFiles ?? []).not.toContain('b/service'); + }); + }); }); diff --git a/src/__tests__/graph-aggregate.test.ts b/src/__tests__/graph-aggregate.test.ts index 6a76c7ef9..72bab7d05 100644 --- a/src/__tests__/graph-aggregate.test.ts +++ b/src/__tests__/graph-aggregate.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import fs from 'fs-extra'; import os from 'node:os'; -import { aggregateGlobalGraph } from '../graph-aggregate.js'; +import { aggregateGlobalGraph, buildAggregatedGraph } from '../graph-aggregate.js'; describe('aggregateGlobalGraph', () => { let tmpDir: string; @@ -113,4 +113,51 @@ describe('aggregateGlobalGraph', () => { const result = await aggregateGlobalGraph(tmpDir); expect(result).toBeNull(); }); + + describe('buildAggregatedGraph: excludeProjects (#912)', () => { + it("omits an excluded project's nodes and edges entirely", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/svc', title: 'ServiceB', type: 'component', confidence: 'high' }], + edges: [{ from: 'b/svc', to: 'b/other', relation: 'DEPENDS_ON' }], + }); + + const graph = await buildAggregatedGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual(['a/svc']); + expect(graph?.edges).toHaveLength(0); + }); + + it('excludes case-foldedly, matching the evidence/code// directory on a case-insensitive filesystem', async () => { + writeRepoGraph('Svc-B', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/svc', title: 'ServiceB', type: 'component', confidence: 'high' }], + edges: [], + }); + + const graph = await buildAggregatedGraph(tmpDir, new Set(['svc-b'])); + expect(graph).toBeNull(); + }); + + it('does not run cross-repo edge detection against an excluded project', async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/client', title: 'BalanceClient', type: 'component', confidence: 'high' }], + edges: [{ from: 'a/client', to: 'libs/balance_service.py', relation: 'imports' }], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/service', title: 'BalanceService', type: 'component', confidence: 'high' }], + edges: [], + }); + + const graph = await buildAggregatedGraph(tmpDir, new Set(['svc-b'])); + const crossEdges = (graph?.edges ?? []).filter((e: { relation: string }) => e.relation === 'DEPENDS_ON'); + expect(crossEdges).toHaveLength(0); + }); + }); }); diff --git a/src/code-knowledge-recall.ts b/src/code-knowledge-recall.ts index 651d1e78a..da1cd7f95 100644 --- a/src/code-knowledge-recall.ts +++ b/src/code-knowledge-recall.ts @@ -228,6 +228,27 @@ function extractSnippet(content: string, queryTokens: string[], maxLen: number = return snippet; } +/** + * `router.md` (routerTemplate in wiki-engine/adapters/templates.ts) lists + * every codebase as a `- [[evidence/code//index]] — desc [keywords]` + * bullet, plus a `` comment aggregating every + * codebase's keywords. At `--depth route`, that single global file is the + * whole result, so a withheld codebase's name/link/description/keywords + * must be stripped from it the same way its evidence directory is excluded + * from `context`/`lookup` (#912). + */ +function filterRouterContent(content: string, withheldCodebases: string[]): string { + const withheld = new Set(withheldCodebases.map(caseFoldKey)); + const isWithheldLine = (line: string): boolean => { + const match = line.match(/evidence\/code\/([^/\]]+)/); + return !!match && withheld.has(caseFoldKey(match[1])); + }; + return content + .split('\n') + .filter((line) => !line.startsWith('` comment aggregating every - * codebase's keywords. At `--depth route`, that single global file is the - * whole result, so a withheld codebase's name/link/description/keywords - * must be stripped from it the same way its evidence directory is excluded - * from `context`/`lookup` (#912). + * `router.md` lists every codebase, one per line, in either of the two + * formats production code generates: `routerTemplate` (wiki-engine/adapters/ + * templates.ts) writes bullets linking `[[evidence/code//index]]`; + * `rebuildWikiIndex` (the path taken after an import) writes table rows + * linking `[[code//index]]` instead, carrying the slug's domain, name, + * description and keywords in the same row. Plus a `` comment aggregating every codebase's keywords. At `--depth + * route`, that single global file is the whole result, so a withheld + * codebase's line must be stripped from it the same way its evidence + * directory is excluded from `context`/`lookup` (#912). */ function filterRouterContent(content: string, withheldCodebases: string[]): string { const withheld = new Set(withheldCodebases.map(caseFoldKey)); const isWithheldLine = (line: string): boolean => { - const match = line.match(/evidence\/code\/([^/\]]+)/); + const match = line.match(/(?:evidence\/)?code\/([^/\]]+)/); return !!match && withheld.has(caseFoldKey(match[1])); }; return content @@ -465,22 +468,25 @@ async function loadPagesRecursive( // B7: Use protocol loadGraphIndex instead of local implementation // -// A withheld codebase's graph nodes never carry an `evidence/code//` -// prefix at the fact level (AST/heuristic nodes are keyed by raw file path, -// see wiki-engine/code-knowledge/code-graph.ts), so there is no reliable -// string to filter the already-merged `.indices/graph-index.json` by. The -// aggregation step (graph-aggregate.ts) is the one place that still knows, -// per per-repo graph file, which codebase it came from — so a withheld -// codebase is excluded by reusing that same aggregation (#912) rather than -// by guessing node ownership here. +// A withheld codebase must not reach recall through the graph either (#912): +// its nodes could otherwise still match as BM25 entry nodes, boost an +// allowed page's score via a graph neighbor, or surface through +// `relatedFiles` via a cross-repo edge. Rebuilding the graph from only the +// allowed per-repo files (the first fix) turned out to silently drop +// anything that lives only in the global file, e.g. `--reconcile`'s +// product<->code MAPS_TO edges — so this instead takes the real global +// graph and subtracts the withheld codebase's own content; see +// scopeGlobalGraph's doc comment in graph-aggregate.ts for why that also +// closes the cross-repo-edge leak despite fact-level nodes carrying no +// `evidence/code//` prefix to filter by. async function loadGraph(wikiRoot: string, withheldCodebases: string[] = []): Promise { if (withheldCodebases.length === 0) { const { loadGraphIndex } = await import('./wiki-engine/core/graph-index.schema.js'); return loadGraphIndex(wikiRoot); } - const { buildAggregatedGraph } = await import('./graph-aggregate.js'); - const excluded = new Set(withheldCodebases.map((slug) => caseFoldKey(slug))); - return (await buildAggregatedGraph(wikiRoot, excluded)) as GraphIndex | null; + const { scopeGlobalGraph } = await import('./graph-aggregate.js'); + const withheld = new Set(withheldCodebases.map((slug) => caseFoldKey(slug))); + return (await scopeGlobalGraph(wikiRoot, withheld)) as GraphIndex | null; } export interface QueryCodeKnowledgeOptions { diff --git a/src/graph-aggregate.ts b/src/graph-aggregate.ts index 5770ba9fa..5f5ba1615 100644 --- a/src/graph-aggregate.ts +++ b/src/graph-aggregate.ts @@ -6,25 +6,18 @@ import { log } from './utils/logger.js'; import { caseFoldKey } from './manifest-schema.js'; /** - * 聚合 teamwiki/evidence/code/ 下仓库的 per-repo graph。 + * 聚合 teamwiki/evidence/code/ 下所有仓库的 per-repo graph。 * - * 串行合并避免竞态;对每对仓库执行跨仓 edge 检测。`excludeProjects` 跳过的仓库 - * 既不贡献节点/边,也不参与跨仓 edge 检测——这是 #912 wiki 命名空间收紧 recall - * 时重建“仅含已激活 codebase”的图所复用的同一条聚合逻辑(而不是在消费端按前缀 - * 猜测节点归属,因为 AST/heuristic 节点的 slug 本来就不带项目前缀)。 + * 串行合并避免竞态;对每对仓库执行跨仓 edge 检测。 * * 注意:每次调用都会重新扫描所有 per-repo graph(O(n))。 * 单仓 import 时也会触发全量重聚合。仓库数量增大(>50)后 * 可考虑增量聚合优化。 * * @param teamwikiRoot teamwiki/ 根目录 - * @param excludeProjects 跳过的 codebase slug(大小写不敏感) * @returns 聚合后的图,无产出时返回 null */ -export async function buildAggregatedGraph( - teamwikiRoot: string, - excludeProjects: Set = new Set(), -) { +export async function buildAggregatedGraph(teamwikiRoot: string) { const evidenceBase = path.join(teamwikiRoot, 'evidence', 'code'); if (!(await fs.pathExists(evidenceBase))) return null; @@ -37,7 +30,6 @@ export async function buildAggregatedGraph( for (const dir of projectDirs) { if (!dir.isDirectory()) continue; - if (excludeProjects.has(caseFoldKey(dir.name))) continue; const graphPath = path.join(evidenceBase, dir.name, '.indices', 'graph-index.json'); if (!(await fs.pathExists(graphPath))) continue; @@ -60,6 +52,61 @@ export async function buildAggregatedGraph( return globalGraph; } +/** + * The merged `.indices/graph-index.json` minus a withheld codebase's own + * content (#912 review round 2). + * + * Rebuilding the graph from only the allowed per-repo files (the first + * attempt at this) silently dropped anything that only ever lived in the + * global file — `teamai codebase --reconcile`'s product↔code MAPS_TO edges + * chief among them, since the reconciler reads and writes the global graph + * directly and never a per-repo one. So this instead starts from the real + * global graph and subtracts: a withheld codebase's own per-repo graph file + * names exactly the node slugs it contributed (its AST/heuristic fact nodes, + * which carry no `evidence/code//` prefix, as well as its overlay hub + * node, which does) — remove those by slug, then drop any edge left dangling + * from a removed endpoint. That dangling-edge pass is what also removes a + * cross-repo `DEPENDS_ON` edge into a withheld node and a reconciler MAPS_TO + * edge into a withheld code page, without needing to know those edges came + * from aggregation/reconcile rather than a per-repo file. + * + * @param teamwikiRoot teamwiki/ 根目录 + * @param withheldProjects 排除的 codebase slug(大小写不敏感) + * @returns 过滤后的图;没有全局图时返回 null + */ +export async function scopeGlobalGraph( + teamwikiRoot: string, + withheldProjects: Set, +) { + const { loadGraphIndex } = await import('./wiki-engine/core/graph-index.schema.js'); + type GraphIndex = NonNullable>>; + const globalGraph = await loadGraphIndex(teamwikiRoot); + if (!globalGraph || withheldProjects.size === 0) return globalGraph; + + const evidenceBase = path.join(teamwikiRoot, 'evidence', 'code'); + const withheldNodeSlugs = new Set(); + const projectDirs = await readdir(evidenceBase, { withFileTypes: true }).catch(() => []); + for (const dir of projectDirs) { + if (!dir.isDirectory() || !withheldProjects.has(caseFoldKey(dir.name))) continue; + const graphPath = path.join(evidenceBase, dir.name, '.indices', 'graph-index.json'); + try { + const repoGraph = JSON.parse(await fs.readFile(graphPath, 'utf8')) as { nodes?: Array<{ slug?: string; id?: string }> }; + for (const node of repoGraph.nodes ?? []) { + const slug = node.slug ?? node.id; + if (slug) withheldNodeSlugs.add(slug); + } + } catch { /* no per-repo graph for this project; nothing to subtract */ } + } + + if (withheldNodeSlugs.size === 0) return globalGraph; + + const nodes = globalGraph.nodes.filter((n) => !withheldNodeSlugs.has(n.slug)); + const keptSlugs = new Set(nodes.map((n) => n.slug)); + const edges = globalGraph.edges.filter((e) => keptSlugs.has(e.from) && keptSlugs.has(e.to)); + + return { ...globalGraph, nodes, edges } satisfies GraphIndex; +} + /** * 聚合 teamwiki/evidence/code/ 下所有仓库的 per-repo graph 到全局 graph-index.json。 * diff --git a/src/recall.ts b/src/recall.ts index ee2f8a966..41f42561f 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -687,12 +687,16 @@ export async function recall( } // ── Codebase knowledge graph recall ────────────────────── - // A wiki codebase slug declared under `resources.wiki` but not active for - // this directory stays out of recall, the same way docs are scoped (#912). - const withheldCodebases = hasWiki && wikiConfig - ? (await resolveResourceNamespaces(wikiConfig))?.inactiveWikiNamespaces ?? [] - : []; try { + // A wiki codebase slug declared under `resources.wiki` but not active for + // this directory stays out of recall, the same way docs are scoped + // (#912). Resolved inside this try: an unreadable or malformed manifest + // must not reject the whole recall when a valid learnings index above + // was already searched safely — it should just skip code recall, same + // as a `queryCodeKnowledge` failure already does below. + const withheldCodebases = hasWiki && wikiConfig + ? (await resolveResourceNamespaces(wikiConfig))?.inactiveWikiNamespaces ?? [] + : []; const codeResults = await queryCodeKnowledge(query, { wikiRoot, limit: 3, depth: options.depth, withheldCodebases }); // B11 fix: log-dampening instead of min-max normalization // Codebase BM25 scores (0-50+) mapped to learnings scale (0-10) via log curve From 5ccde9ffe5f545fd2a283124cf70cc2e20ad0d8d Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Mon, 5 Oct 2026 10:59:40 +0530 Subject: [PATCH 05/21] fix(recall): scope graph edges by identity, not node survival, and finish wiki doc propagation (#974 review round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-review's fourth pass found scopeGlobalGraph itself (round 2's fix) had a real over-pruning bug, a real-but-smaller ambiguity, and three more stale doc enumerations: - [P1] The edge filter required BOTH endpoints to survive as nodes in the scoped graph (`keptSlugs.has(e.from) && keptSlugs.has(e.to)`). But an AST/heuristic edge is commonly file-to-file (`{from: 'src/a.ts', to: 'src/b.ts'}`) with neither endpoint present in `nodes[]` at all — so the moment anything was withheld, every such edge vanished for every codebase, allowed ones included, dropping their files from `relatedFiles`/"Candidate change files". Fixed by subtracting by identity instead: collect every node slug AND edge endpoint a withheld codebase's own per-repo file contributed, then drop only edges actually touching one of those — not edges that merely fail to resolve to a known node. - [P2] Fact-level node slugs are not repo-qualified (`buildCodeGraph` mints `component/App` the same way for any repo), so an allowed and a withheld repo can legitimately collide on one slug after merging; subtracting by slug alone would also remove the allowed repo's node. Fixed by also collecting every ALLOWED codebase's own identifiers and clearing any that overlap with the withheld set before subtracting — a shared identifier is never removed. - [P2] `docs/usage-guide.md` (+ zh-CN) still said `--namespaces` "neither touches env, hooks, mcp, models or docs", omitting `wiki`, and `CHANGELOG.md`'s #707 entry enumerating `resources:` keys was stale the same way. Added `wiki` to all three. Verified via the real built CLI again: a file-to-file edge with neither endpoint declared as a node now survives in "Candidate change files" even when an unrelated codebase is withheld (previously it vanished the moment anything was withheld, regardless of relation). Added 2 tests to graph-aggregate.test.ts for the two scopeGlobalGraph bugs (file-to-file edge preservation, slug-collision non-removal) and fixed a test-only bug in the existing cross-repo-edge test (it filtered by `relation === 'DEPENDS_ON'`, which also matches the surviving, unrelated import edge after `loadGraphIndex` normalizes the legacy `imports` relation — switched to filtering by the actual withheld endpoint). 132 tests total across every touched/related file pass; typecheck and lint are clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/graph-aggregate.test.ts | 49 +++++++++++++++++++++++-- src/graph-aggregate.ts | 52 ++++++++++++++++++--------- 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5fc26f3..eb2144bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to this project will be documented in this file. See [standa - **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection. A project uninstall keeps the global Pi, Oh My Pi and Hermes adapters and Codex's user hooks, which other installs on the machine may use, and names them; `teamai hooks remove` removes them (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. -- **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). +- **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models`, `docs` and `wiki`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707); `wiki` added for [#912](https://github.com/Tencent/teamai-cli/issues/912)). - Per-entry scoping of env variables, hooks and MCP servers gives way to namespace files (see Features). `projects:` on an `env/env.yaml` variable, a `hooks/hooks.yaml` hook or an `mcp/mcp.yaml` server, and `roles:` on an env variable, existed only in the 0.26.0 betas and are removed: such an entry now reaches nobody, and pull, the list commands and status warn with the namespace file to move it to, one per listed id, so a project-only value never falls through to the whole team. `teamai env add` also warns when it updates an existing variable carrying either removed key, and preserves the key. `roles:` on hooks and MCP servers, which 0.25.0 shipped, is deprecated: it keeps filtering for one more minor release as 0.25.0 did, a name repeated in one file under different `roles:` included, pull warns once per run and `teamai doctor` has a check, both naming every target file. There is no automatic migration; move each entry into the file the warning names and drop the key. A member with no role in a team with `roles.yaml` received every `roles:`-scoped hook and server; once they move into `hooks//` or `mcp//`, that member no longer does (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). - Each tool reads only its own `tool_extras.` from a YAML agent. Qoder, Qoder CN, ZCode and OMP used to receive `tool_extras.claude` and ignore their own key, which `teamai push` wrote their edits to, so an edit never reached them; a team that relied on Claude extras reaching these tools moves those fields to `tool_extras.qoder`, `tool_extras.qoder-cn`, `tool_extras.zcode` or `tool_extras.omp`. tclaude and tcodex read `tool_extras.tclaude` and `tool_extras.tcodex` and fill the fields those lack from `tool_extras.claude` and `tool_extras.codex`, and `teamai push` writes their edits to their own key instead of the Claude or Codex one. The first ordinary pull after updating re-renders a copy an older CLI wrote this way when that CLI recorded delivering it and the member has not changed it since; any other copy is left as it is until the team next changes the agent (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). - **Upgrade every member before a team introduces model aliases.** An older CLI ignores `models/aliases.yaml` and writes `model: strong` into every tool as is, a model no tool knows, and its `teamai push` reads a model changed in a deployed copy as an edit, so it can replace `model: strong` in the team's agent with a concrete model such as `opus`. Have everyone update first, then add `models/aliases.yaml` and move agents to an alias. TeamAI does not check versions; the first ordinary pull after a member updates replaces a literal `model: strong` with the resolved model (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3ea79adc1..9e178b91a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -383,7 +383,7 @@ teamai projects remove checkout `--namespaces` sets the same namespaces on every project resource type (`knowledge`, `skills`, `learnings`, `agents`); `update` adds or removes them on each type's own list, so a hand-edited per-type layout survives. Neither touches -`env`, `hooks`, `mcp`, `models` or `docs`: declare those by hand (see +`env`, `hooks`, `mcp`, `models`, `docs` or `wiki`: declare those by hand (see [Env, hooks and MCP servers by namespace](#env-hooks-and-mcp-servers-by-namespace)), because a member on an older CLI cannot read them. After `projects remove`, a directory that still has the project active warns on its diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1de183f11..0ae2de7b9 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -332,7 +332,7 @@ teamai projects remove checkout `--namespaces` 会把同一组 namespace 写入项目的每种资源类型(`knowledge`、`skills`、 `learnings`、`agents`);`update` 在每种类型各自的列表上增删,因此手工编辑过的按类型 -布局会被保留。两者都不会改动 `env`、`hooks`、`mcp`、`models` 或 `docs`:这些请手动声明(见 +布局会被保留。两者都不会改动 `env`、`hooks`、`mcp`、`models`、`docs` 或 `wiki`:这些请手动声明(见 [Env、hooks 与 MCP server 按 namespace 划分](#envhooks-与-mcp-server-按-namespace-划分)),因为旧版 CLI 的成员读不了它们。执行 `projects remove` 后,仍激活该项目的目录在下一次 pull 时会提示警告、 回退为仅按角色过滤,并清理已部署的该项目 skills、rules 和 agents——前提是该项目的内容 仍在团队仓库中,因为正是靠它识别已部署的副本。请在成员都 pull 过之后,再用单独的变更删除这些内容。 diff --git a/src/__tests__/graph-aggregate.test.ts b/src/__tests__/graph-aggregate.test.ts index da7392115..1fe6505d3 100644 --- a/src/__tests__/graph-aggregate.test.ts +++ b/src/__tests__/graph-aggregate.test.ts @@ -147,8 +147,12 @@ describe('aggregateGlobalGraph', () => { await aggregateGlobalGraph(tmpDir); const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); - const crossEdges = (graph?.edges ?? []).filter((e: { relation: string }) => e.relation === 'DEPENDS_ON'); - expect(crossEdges).toHaveLength(0); + // Not a relation-based filter: `loadGraphIndex` normalizes the legacy + // `imports` relation to `DEPENDS_ON` too, so the surviving, unrelated + // import edge (a/client -> libs/balance_service.py) would false-match. + const intoWithheld = (graph?.edges ?? []).filter((e: { to: string }) => e.to === 'b/service'); + expect(intoWithheld).toHaveLength(0); + expect(graph?.edges).toHaveLength(1); }); it("keeps content that lives only in the global file (e.g. --reconcile's MAPS_TO edges) when the withheld project is unrelated to it", async () => { @@ -179,6 +183,47 @@ describe('aggregateGlobalGraph', () => { expect(graph?.edges).toContainEqual({ from: 'docs/product/billing', to: 'a/svc', relation: 'MAPS_TO' }); }); + it("keeps an allowed repo's file-to-file edge whose endpoints are not graph nodes at all (#912 review round 3 P1)", async () => { + // AST/heuristic edges are commonly file-to-file with neither endpoint + // present in nodes[] — requiring both endpoints to "survive as nodes" + // (an earlier version of this filter) deleted these for every allowed + // repo too, the moment anything was withheld. + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', relation: 'DEPENDS_ON' }], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/svc', title: 'ServiceB', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.edges).toContainEqual(expect.objectContaining({ from: 'src/a.ts', to: 'src/b.ts' })); + }); + + it("does not remove an allowed repo's node when a withheld repo happens to mint the same unqualified slug (#912 review round 3 P2)", async () => { + // Fact-level slugs are not repo-qualified (buildCodeGraph mints + // `component/App` the same way for any repo), so two unrelated repos + // can legitimately collide on one slug after merging. + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'AppA', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'AppB', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toContain('component/App'); + }); + it('excludes case-foldedly, matching the evidence/code// directory on a case-insensitive filesystem', async () => { writeRepoGraph('Svc-B', { schemaVersion: 1, generatedAt: '2026-01-01', diff --git a/src/graph-aggregate.ts b/src/graph-aggregate.ts index 5f5ba1615..c627ae717 100644 --- a/src/graph-aggregate.ts +++ b/src/graph-aggregate.ts @@ -54,21 +54,29 @@ export async function buildAggregatedGraph(teamwikiRoot: string) { /** * The merged `.indices/graph-index.json` minus a withheld codebase's own - * content (#912 review round 2). + * content (#912 review). * * Rebuilding the graph from only the allowed per-repo files (the first * attempt at this) silently dropped anything that only ever lived in the * global file — `teamai codebase --reconcile`'s product↔code MAPS_TO edges * chief among them, since the reconciler reads and writes the global graph * directly and never a per-repo one. So this instead starts from the real - * global graph and subtracts: a withheld codebase's own per-repo graph file - * names exactly the node slugs it contributed (its AST/heuristic fact nodes, - * which carry no `evidence/code//` prefix, as well as its overlay hub - * node, which does) — remove those by slug, then drop any edge left dangling - * from a removed endpoint. That dangling-edge pass is what also removes a - * cross-repo `DEPENDS_ON` edge into a withheld node and a reconciler MAPS_TO - * edge into a withheld code page, without needing to know those edges came - * from aggregation/reconcile rather than a per-repo file. + * global graph and subtracts by identifier: a withheld codebase's own + * per-repo graph file names exactly the node slugs AND edge endpoints it + * contributed. Edge endpoints matter on their own — an AST/heuristic edge is + * commonly `{from: 'src/a.ts', to: 'src/b.ts'}` with neither side a node in + * `nodes[]` at all, so an earlier version that required both endpoints to + * survive as nodes deleted every such edge, allowed codebases included, the + * moment anything was withheld. Dropping an edge only when it actually + * touches a withheld identifier (by removal, not by node survival) is what + * leaves an allowed codebase's own file-to-file edges untouched while still + * removing a cross-repo `DEPENDS_ON` edge into a withheld node. + * + * A withheld identifier is cleared again if an ALLOWED codebase's own graph + * file also claims it: fact-level node slugs are not repo-qualified + * (`buildCodeGraph` mints `component/App` the same way for any repo), so two + * unrelated repos can legitimately collide on one slug after merging. In + * that case withholding one must not also take down the other's. * * @param teamwikiRoot teamwiki/ 根目录 * @param withheldProjects 排除的 codebase slug(大小写不敏感) @@ -84,25 +92,35 @@ export async function scopeGlobalGraph( if (!globalGraph || withheldProjects.size === 0) return globalGraph; const evidenceBase = path.join(teamwikiRoot, 'evidence', 'code'); - const withheldNodeSlugs = new Set(); + const withheldIds = new Set(); + const allowedIds = new Set(); const projectDirs = await readdir(evidenceBase, { withFileTypes: true }).catch(() => []); for (const dir of projectDirs) { - if (!dir.isDirectory() || !withheldProjects.has(caseFoldKey(dir.name))) continue; + if (!dir.isDirectory()) continue; + const isWithheld = withheldProjects.has(caseFoldKey(dir.name)); const graphPath = path.join(evidenceBase, dir.name, '.indices', 'graph-index.json'); try { - const repoGraph = JSON.parse(await fs.readFile(graphPath, 'utf8')) as { nodes?: Array<{ slug?: string; id?: string }> }; + const repoGraph = JSON.parse(await fs.readFile(graphPath, 'utf8')) as { + nodes?: Array<{ slug?: string; id?: string }>; + edges?: Array<{ from?: string; to?: string }>; + }; + const target = isWithheld ? withheldIds : allowedIds; for (const node of repoGraph.nodes ?? []) { const slug = node.slug ?? node.id; - if (slug) withheldNodeSlugs.add(slug); + if (slug) target.add(slug); + } + for (const edge of repoGraph.edges ?? []) { + if (edge.from) target.add(edge.from); + if (edge.to) target.add(edge.to); } } catch { /* no per-repo graph for this project; nothing to subtract */ } } + for (const id of allowedIds) withheldIds.delete(id); - if (withheldNodeSlugs.size === 0) return globalGraph; + if (withheldIds.size === 0) return globalGraph; - const nodes = globalGraph.nodes.filter((n) => !withheldNodeSlugs.has(n.slug)); - const keptSlugs = new Set(nodes.map((n) => n.slug)); - const edges = globalGraph.edges.filter((e) => keptSlugs.has(e.from) && keptSlugs.has(e.to)); + const nodes = globalGraph.nodes.filter((n) => !withheldIds.has(n.slug)); + const edges = globalGraph.edges.filter((e) => !withheldIds.has(e.from) && !withheldIds.has(e.to)); return { ...globalGraph, nodes, edges } satisfies GraphIndex; } From b56475790b41318a2f02de6430fff1d70ea77b63 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Mon, 5 Oct 2026 11:25:54 +0530 Subject: [PATCH 06/21] fix(recall): drop all-withheld router domain sections and reconcile-added withheld code-page nodes (#974 review round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-review's fifth pass found two more real leaks, both in the graph/router scoping this PR already added: - [P1] Router filtering only removed individual lines carrying a `code/` link. `routerTemplate()`'s AI-domain branch, though, emits a `### ` header line with no link at all, and an unresolved component (one whose name does not match any known project) as a bare `- ` line with no link either. Once the properly-linked sibling lines for a withheld codebase were removed, an all-withheld domain's header — and any such unlinked fallback line under it — survived untouched, still naming the domain and component. Fixed by grouping router.md into sections at each markdown header first: a section whose links are ALL withheld (and at least one was found) is now dropped whole, header and any unlinked lines included; a section mixing allowed and withheld links still keeps its header and drops only the withheld lines, as before. - [P1] `scopeGlobalGraph` identified withheld content solely from per-repo graph files, but `teamai codebase --reconcile` adds code-page nodes (`evidence/code//`) and their MAPS_TO edges straight to the global graph, the same way it adds product-page nodes — never to a per-repo file. After reconciling and then withholding that codebase, those nodes/edges survived, so a query matching the withheld page's title could still use it as an entry node or graph boost. Fixed with a second pass over the global graph's own nodes, matched by the `evidence/code//` prefix (which — unlike a bare fact-level slug — unambiguously names the codebase that owns it, so no allowed/withheld collision risk the way there is for prefix-less slugs). Verified via the real built CLI: an all-withheld domain section (header + unlinked fallback line) is now fully gone from `--depth route`'s snippet while a mixed/allowed domain's header and allowed line survive; a `scopeGlobalGraph` call against a global graph with an injected reconcile-style withheld code-page node and MAPS_TO edge confirms both are removed while an unrelated product-page node survives. 4 tests added (2 router domain-section tests in code-knowledge-recall-wiki-scope.test.ts; 1 reconcile-added-node test in graph-aggregate.test.ts). 135 tests total across every touched/related file pass; typecheck and lint are clean. Co-Authored-By: Claude Sonnet 5 --- .../code-knowledge-recall-wiki-scope.test.ts | 42 ++++++++++++++ src/__tests__/graph-aggregate.test.ts | 30 ++++++++++ src/code-knowledge-recall.ts | 58 ++++++++++++++----- src/graph-aggregate.ts | 18 ++++++ 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/src/__tests__/code-knowledge-recall-wiki-scope.test.ts b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts index d9af4f256..7433cc65b 100644 --- a/src/__tests__/code-knowledge-recall-wiki-scope.test.ts +++ b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts @@ -172,4 +172,46 @@ describe('queryCodeKnowledge: withheldCodebases', () => { expect(result.snippet).not.toContain('payments-ledger'); }); }); + + describe('route depth: router.md domain-grouped format (routerTemplate with AI domains, #912 review round 4)', () => { + it("drops an all-withheld domain's header entirely, including an unlinked fallback line with no match in projects[]", async () => { + writeFileSync( + path.join(wikiRoot, 'router.md'), + '# Team Wiki Router\n\n' + + '## 项目域入口\n\n' + + '### Checkout (4 APIs)\n\n' + + '- [[evidence/code/svc-a/index]] — Svc A desc [alpha]\n\n' + + '### Billing (2 APIs)\n\n' + + '- [[evidence/code/svc-b/index]] — Svc B desc [payments-ledger]\n' + + '- svc-b-legacy\n', + 'utf-8', + ); + + const [result] = await queryCodeKnowledge('router', { + wikiRoot, depth: 'route', withheldCodebases: ['svc-b'], + }); + expect(result.snippet).toContain('Checkout'); + expect(result.snippet).toContain('svc-a'); + expect(result.snippet).not.toContain('Billing'); + expect(result.snippet).not.toContain('svc-b'); + }); + + it('keeps a mixed domain header and only drops the withheld line inside it', async () => { + writeFileSync( + path.join(wikiRoot, 'router.md'), + '# Team Wiki Router\n\n' + + '### Platform (5 APIs)\n\n' + + '- [[evidence/code/svc-a/index]] — Svc A desc [alpha]\n' + + '- [[evidence/code/svc-b/index]] — Svc B desc [payments-ledger]\n', + 'utf-8', + ); + + const [result] = await queryCodeKnowledge('router', { + wikiRoot, depth: 'route', withheldCodebases: ['svc-b'], + }); + expect(result.snippet).toContain('Platform'); + expect(result.snippet).toContain('svc-a'); + expect(result.snippet).not.toContain('svc-b'); + }); + }); }); diff --git a/src/__tests__/graph-aggregate.test.ts b/src/__tests__/graph-aggregate.test.ts index 1fe6505d3..fb88ddacc 100644 --- a/src/__tests__/graph-aggregate.test.ts +++ b/src/__tests__/graph-aggregate.test.ts @@ -183,6 +183,36 @@ describe('aggregateGlobalGraph', () => { expect(graph?.edges).toContainEqual({ from: 'docs/product/billing', to: 'a/svc', relation: 'MAPS_TO' }); }); + it("removes a --reconcile-added code-page node and its MAPS_TO edge for a withheld codebase, even though neither ever lived in a per-repo file (#912 review round 4 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/svc', title: 'ServiceB', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + // Simulate `teamai codebase --reconcile`: it adds a code-page node for + // svc-b's overview.md and a MAPS_TO edge from a product page, straight + // to the global graph, never to svc-b's per-repo file. + const globalPath = path.join(tmpDir, '.indices', 'graph-index.json'); + const global = JSON.parse(await fs.readFile(globalPath, 'utf8')); + global.nodes.push({ slug: 'evidence/code/svc-b/overview', title: 'Svc B overview', type: 'architecture', confidence: 'high' }); + global.edges.push({ from: 'docs/product/billing', to: 'evidence/code/svc-b/overview', relation: 'MAPS_TO' }); + await fs.writeFile(globalPath, JSON.stringify(global)); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const slugs = graph?.nodes.map((n: { slug: string }) => n.slug) ?? []; + expect(slugs).not.toContain('evidence/code/svc-b/overview'); + expect(graph?.edges ?? []).not.toContainEqual( + expect.objectContaining({ to: 'evidence/code/svc-b/overview' }), + ); + }); + it("keeps an allowed repo's file-to-file edge whose endpoints are not graph nodes at all (#912 review round 3 P1)", async () => { // AST/heuristic edges are commonly file-to-file with neither endpoint // present in nodes[] — requiring both endpoints to "survive as nodes" diff --git a/src/code-knowledge-recall.ts b/src/code-knowledge-recall.ts index b1e827070..2524fa935 100644 --- a/src/code-knowledge-recall.ts +++ b/src/code-knowledge-recall.ts @@ -229,27 +229,57 @@ function extractSnippet(content: string, queryTokens: string[], maxLen: number = } /** - * `router.md` lists every codebase, one per line, in either of the two - * formats production code generates: `routerTemplate` (wiki-engine/adapters/ - * templates.ts) writes bullets linking `[[evidence/code//index]]`; - * `rebuildWikiIndex` (the path taken after an import) writes table rows - * linking `[[code//index]]` instead, carrying the slug's domain, name, - * description and keywords in the same row. Plus a `` comment aggregating every codebase's keywords. At `--depth * route`, that single global file is the whole result, so a withheld - * codebase's line must be stripped from it the same way its evidence - * directory is excluded from `context`/`lookup` (#912). + * codebase must be stripped from it the same way its evidence directory is + * excluded from `context`/`lookup` (#912). + * + * A line-only filter leaves an all-withheld domain's `### ` header + * (and any unlinked fallback line under it) behind with nothing linked left + * to filter it by, still naming the withheld domain. So this groups lines + * into sections at each markdown header first: a section whose links are + * ALL withheld (at least one found, none allowed) is dropped whole, header + * included — which also drops any bare, unlinked line in that same section, + * since nothing in an all-withheld section could plausibly belong to an + * allowed codebase. A section mixing allowed and withheld links keeps the + * header and only drops the withheld lines, as before. */ function filterRouterContent(content: string, withheldCodebases: string[]): string { const withheld = new Set(withheldCodebases.map(caseFoldKey)); - const isWithheldLine = (line: string): boolean => { + const lineSlug = (line: string): string | null => { const match = line.match(/(?:evidence\/)?code\/([^/\]]+)/); - return !!match && withheld.has(caseFoldKey(match[1])); + return match ? caseFoldKey(match[1]) : null; }; - return content - .split('\n') - .filter((line) => !line.startsWith('