diff --git a/CHANGELOG.md b/CHANGELOG.md index e34f6fbc2..d40d73d24 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/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 027c79148..5468a39f5 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -415,7 +415,7 @@ settings, and pull says it `is no longer active in your namespaces`. ### Manifest and per-entry keys -`resources:` gains `env`, `hooks`, `mcp`, `models` and `docs`. They are optional +`resources:` gains `env`, `hooks`, `mcp`, `models`, `docs` and (#912) `wiki`. They are optional and never defaulted: saving a manifest writes back the parsed object, so a default would add `env: []` to every manifest an admin edits and break members on an older CLI. For the same reason `--namespaces` on `teamai roles` and diff --git a/docs/product-overview.md b/docs/product-overview.md index 619ab3978..9f1d08774 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -69,6 +69,8 @@ Each resource is delivered to every agent: Skills, rules, CLAUDE.md, agents, env, hooks, MCP, models and docs can also live under a `/` subdirectory, which ships only to the roles and projects that list it in `resources:` (rules and CLAUDE.md under `knowledge:`). A namespace item replaces the root item of the same name; a docs namespace replaces nothing. With roles or projects set, root skills reach a member only through a tag subscription. +The Team Context knowledge base below is scoped the same way: a `teamwiki/evidence/code//` codebase reaches only the roles and projects that list it under `resources.wiki`, and an undeclared slug stays shared — see [Wiki by namespace](usage-guide.md#codebase-knowledge-graph). + For file formats and full workflows, see the [Usage Guide](usage-guide.md). ## Team Context (beta) diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index e29cb0fad..68db5c78b 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -69,6 +69,8 @@ teamai push → 创建分支 + MR → reviewer 审批合并 Skills、rules、CLAUDE.md、agents、env、hooks、MCP、models 和 docs 也可以放在 `/` 子目录下,只同步给在 `resources:` 中列出它的角色和项目(rules 与 CLAUDE.md 列在 `knowledge:` 下)。namespace 中的条目会替换根目录中同名的条目;docs namespace 不替换任何内容。配置了角色或项目后,根目录的 skills 只通过标签订阅送达成员。 +下方的 Team Context 知识库采用同样的作用域规则:`teamwiki/evidence/code//` 代码库只分发给在 `resources.wiki` 中列出它的角色和项目,未声明的 slug 仍然共享——详见[按命名空间分发 wiki](usage-guide.zh-CN.md#代码知识图谱)。 + 文件格式与完整工作流见[使用指南](usage-guide.zh-CN.md)。 ## Team Context (beta) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d16b5ba08..39a27a01e 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 @@ -1959,6 +1959,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.** `recall` scopes `teamwiki/evidence/code//` the same way it scopes docs: once any role (in `manifest/roles.yaml`) or project (in `manifest/projects.yaml`) 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 498229d9e..8a0a79638 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 过之后,再用单独的变更删除这些内容。 @@ -1804,6 +1804,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。** `recall` 对 `teamwiki/evidence/code//` 采用与 docs 相同的作用域规则:只要有任一角色(`manifest/roles.yaml`)或项目(`manifest/projects.yaml`)在 `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/core/references/commands.md b/skill-data/core/references/commands.md index 0cc5ed3eb..94d1b73d1 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -125,7 +125,7 @@ Generated: do not edit by hand. Regenerate with - `teamai projects list` — List defined projects and the ones active in this directory - `teamai projects set [ids...]` — Set the projects active in this directory (comma-separated or repeated; empty to clear) - `teamai projects add ` — Add a project to manifest/projects.yaml, creating the file if needed (admin) - - `--namespaces ` — Comma-separated namespaces for knowledge, skills, learnings and agents (e.g. common,checkout); env, hooks, mcp, models and docs are declared by hand + - `--namespaces ` — Comma-separated namespaces for knowledge, skills, learnings and agents (e.g. common,checkout); env, hooks, mcp, models, docs and wiki are declared by hand - `--name ` — Display name for the project - `-d, --description ` — Description for the project - `teamai projects update ` — Update a project in manifest/projects.yaml (admin) diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index bb614c808..28e9bb708 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -141,7 +141,7 @@ keep the project's content in the team repo until members have pulled: that is what lets their next pull clean up the copies they deployed. Every namespace that names a directory — `knowledge`, `skills`, `agents`, `env`, -`hooks`, `mcp`, `models` and `docs` in either manifest, and `learnings` in `projects.yaml` (a role's `learnings:` is +`hooks`, `mcp`, `models`, `docs` and `wiki` in either manifest, and `learnings` in `projects.yaml` (a role's `learnings:` is ignored and unchecked) — must be a single path segment: no `/`, `\`, `:` or control character, no trailing `.` or space, and not a Windows device name (`CON`, `NUL`, `COM1`, …). `team-codebase` cannot be a `docs` namespace (`docs/team-codebase/` is the legacy codebase output). Two @@ -173,6 +173,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 @@ -253,7 +259,7 @@ and push it with git. `teamai doctor` lists each override. and also set in `env.yaml` is a secret: its `env.yaml` value is not delivered. A secrets file that does not parse keeps `env.sh` and MCP servers as they were, and `teamai doctor` fails a check naming the file. -- Have every member upgrade before declaring `env`, `hooks`, `mcp`, `models` or `docs` in a +- Have every member upgrade before declaring `env`, `hooks`, `mcp`, `models`, `docs` or `wiki` in a manifest: teamai 0.25.0 and the 0.26.0 betas reject those keys and their pull stops. ## When sync fails 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..3cfc20ad0 --- /dev/null +++ b/src/__tests__/code-knowledge-recall-wiki-scope.test.ts @@ -0,0 +1,256 @@ +/** + * `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, readFileSync, 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; + +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'); +} + +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'); + 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']); + }); + + 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 () => { + await aggregateGlobalGraph(wikiRoot); + 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'); + }); + + it('preserves a global-only forward edge (the kind --reconcile adds directly to the global graph) when the withheld codebase is unrelated to it', async () => { + await aggregateGlobalGraph(wikiRoot); + const globalPath = path.join(wikiRoot, '.indices', 'graph-index.json'); + const global = JSON.parse(readFileSync(globalPath, 'utf-8')); + global.nodes.push({ slug: 'docs/product/billing', title: 'Billing product page', type: 'architecture', confidence: 'EXTRACTED' }); + global.edges.push({ from: 'a/client', to: 'docs/product/billing', relation: 'MAPS_TO' }); + writeFileSync(globalPath, JSON.stringify(global), 'utf-8'); + + 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).toContain('docs/product/billing'); + expect(svcA?.relatedFiles).not.toContain('b/service'); + }); + }); + + describe('route depth: router.md table-row format (rebuildWikiIndex, #912 review round 2)', () => { + it('strips a withheld codebase\'s table row, generated by rebuildWikiIndex\'s [[code//index]] link format', async () => { + writeFileSync( + path.join(wikiRoot, 'router.md'), + '# Team Wiki Router\n\n' + + '| 域 | 入口 | 核心职责 | 路由关键词 |\n' + + '|---|---|---|---|\n' + + '| 计费 | [[code/svc-a/index]] | Svc A duty | alpha |\n' + + '| 计费 | [[code/svc-b/index]] | Svc B duty | payments-ledger |\n', + 'utf-8', + ); + + 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'); + }); + }); + + 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("drops a domain header whose every component is an unmatched bare line, even though none of them carries a code/ link to detect as withheld (#912 review round 9 P1)", async () => { + writeFileSync( + path.join(wikiRoot, 'router.md'), + '# Team Wiki Router\n\n' + + '### Checkout (4 APIs)\n\n' + + '- [[evidence/code/svc-a/index]] — Svc A desc [alpha]\n\n' + + '### Billing (2 APIs)\n\n' + + '- svc-b-legacy\n' + + '- svc-b-other\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'); + }); + + it("drops an unresolved component's bare, unlinked fallback line in a MIXED domain too, since it can't be attributed to either side (#912 review round 6 P1)", 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' + + '- svc-b-unmatched\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-unmatched'); + }); + }); +}); diff --git a/src/__tests__/codebase-extract-fallback.test.ts b/src/__tests__/codebase-extract-fallback.test.ts index 23adc043d..0fbdda627 100644 --- a/src/__tests__/codebase-extract-fallback.test.ts +++ b/src/__tests__/codebase-extract-fallback.test.ts @@ -18,6 +18,8 @@ import { callClaudeParallel } from '../utils/ai-client.js'; import { extractCodebase } from '../codebase-extract.js'; import { codebaseCmd } from '../codebase-cmd.js'; import { runHiddenDeepEnrich } from '../deep-enrich.js'; +import { scopeGlobalGraph } from '../graph-aggregate.js'; +import { loadGraphIndex } from '../wiki-engine/core/graph-index.schema.js'; import { buildFallbackManifest, describeEvidenceManifest, @@ -315,3 +317,45 @@ describe('extract writes a fallback evidence manifest (#508)', () => { expect(fs.existsSync(path.join(root, 'teamwiki', 'evidence', 'code', 'empty', '_manifest.json'))).toBe(false); }); }); + +describe('a direct `teamai codebase --extract` stamps origin on the graph it writes (#974 review round 19 P1)', () => { + it('tags every node and edge it writes straight to teamwiki/.indices/graph-index.json with the project slug', async () => { + const root = createWidgetFixture(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // A standalone extract (no `teamai import`) writes directly to the real + // teamwiki root's global graph file — there is no later + // aggregateGlobalGraph call to tag it, since this bypasses import's + // orchestration entirely. + await extractCodebase({ path: root, project: 'widget', json: true, skipEnrich: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(graph?.nodes.length).toBeGreaterThan(0); + for (const node of graph?.nodes ?? []) expect(node.origin).toBe('widget'); + for (const edge of graph?.edges ?? []) expect(edge.origin).toBe('widget'); + }); + + it("lets scopeGlobalGraph withhold the freshly re-extracted content correctly even though the project's own evidence/code/widget/.indices/graph-index.json per-repo file was never touched by this direct extract and stays stale", async () => { + const root = createWidgetFixture(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // Simulate a stale per-repo file left over from an earlier `teamai + // import` run, describing different (older) content than what this + // direct re-extraction is about to write to the global graph. + const staleRepoGraphDir = path.join(root, 'teamwiki', 'evidence', 'code', 'widget', '.indices'); + fs.mkdirSync(staleRepoGraphDir, { recursive: true }); + fs.writeFileSync(path.join(staleRepoGraphDir, 'graph-index.json'), JSON.stringify({ + schemaVersion: 1, generatedAt: '2025-01-01', + nodes: [{ slug: 'old/stale-component', title: 'StaleComponent', type: 'component', confidence: 'high' }], + edges: [], + })); + + await extractCodebase({ path: root, project: 'widget', json: true, skipEnrich: true }); + + const scoped = await scopeGlobalGraph(path.join(root, 'teamwiki'), new Set(['widget'])); + // Fully tag-covered by the fresh extract's own origin stamps — the + // stale per-repo file is never even read for this. + expect(scoped?.nodes ?? []).toHaveLength(0); + expect(scoped?.edges ?? []).toHaveLength(0); + }); +}); diff --git a/src/__tests__/codebase-reconcile.test.ts b/src/__tests__/codebase-reconcile.test.ts index 31f795a56..901cbefd5 100644 --- a/src/__tests__/codebase-reconcile.test.ts +++ b/src/__tests__/codebase-reconcile.test.ts @@ -280,6 +280,38 @@ describe('codebase reconciliation', () => { ])); }); + it('tags a repaired endpoint node with the same origin as the code-ast/code-heuristic edge that required it (#912 review round 11 P1)', async () => { + const root = createWikiFixture(); + const repoGraphPath = path.join(root, 'teamwiki', 'evidence', 'code', 'auth', '.indices', 'graph-index.json'); + fs.mkdirSync(path.dirname(repoGraphPath), { recursive: true }); + fs.writeFileSync(repoGraphPath, JSON.stringify({ + schemaVersion: 1, + generatedAt: '2026-01-01', + nodes: [{ slug: 'component/a', title: 'a', type: 'component', confidence: 'EXTRACTED' }], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', relation: 'DEPENDS_ON', source: 'code-ast' }], + })); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await aggregateGlobalGraph(path.join(root, 'teamwiki')); + + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + // Both endpoints were missing from the per-repo graph's nodes[] and got + // minted here by the reconciler; each must carry the SAME origin as the + // edge that required it, since both sides of an intra-repo AST/heuristic + // edge belong to that one codebase. + expect(graph?.nodes).toEqual(expect.arrayContaining([ + expect.objectContaining({ slug: 'src/a.ts', origin: 'auth' }), + expect.objectContaining({ slug: 'src/b.ts', origin: 'auth' }), + ])); + + const { scopeGlobalGraph } = await import('../graph-aggregate.js'); + const scoped = await scopeGlobalGraph(path.join(root, 'teamwiki'), new Set(['auth'])); + const survivingSlugs = scoped?.nodes.map((node) => node.slug) ?? []; + expect(survivingSlugs).not.toContain('src/a.ts'); + expect(survivingSlugs).not.toContain('src/b.ts'); + }); + it('replaces legacy bridge edges that upstream persisted without endpoint nodes', async () => { const root = createWikiFixture(); const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); diff --git a/src/__tests__/cross-repo-edges.test.ts b/src/__tests__/cross-repo-edges.test.ts index 671827427..93da38d0d 100644 --- a/src/__tests__/cross-repo-edges.test.ts +++ b/src/__tests__/cross-repo-edges.test.ts @@ -75,6 +75,82 @@ describe('detectCrossRepoEdges with GraphIndex format (slug/title)', () => { expect(edges.length).toBeGreaterThan(0); }); + it('tags a cross-repo edge with BOTH sides\' origins — the overlay side whose import produced the match, and the matched existing side (#974 review round 13 P1)', () => { + const repoA = { + nodes: [ + { slug: 'hai-api/balance-client', title: 'BalanceClient', type: 'component', origin: 'svc-a' }, + ], + edges: [], + }; + const repoB = { + nodes: [ + { slug: 'hai-flow/flow-engine', title: 'FlowCaller', type: 'component', origin: 'svc-b' }, + ], + edges: [ + { from: 'hai-flow/flow-engine', to: 'api/balance_client.py', relation: 'imports' }, + ], + }; + + // repoB (overlay) imports balance_client → matches repoA's (existing) BalanceClient. + // The relationship depends on BOTH repoB's own import statement AND repoA's + // component existing — withholding either one must be able to remove this edge, + // so both origins are recorded, not just whichever side the label lookup matched. + const edges = detectCrossRepoEdges(repoB, repoA); + const depEdge = edges.find(e => e.relation === 'DEPENDS_ON'); + expect(depEdge?.crossOriginPairs).toEqual([expect.arrayContaining(['svc-a', 'svc-b'])]); + expect(depEdge?.crossOriginPairs?.[0]).toHaveLength(2); + }); + + it('omits a side from the pair when that node predates origin tagging, instead of a placeholder', () => { + const repoA = { + nodes: [{ slug: 'hai-api/balance-client', title: 'BalanceClient', type: 'component' }], + edges: [], + }; + const repoB = { + nodes: [{ slug: 'hai-flow/flow-engine', title: 'FlowCaller', type: 'component', origin: 'svc-b' }], + edges: [{ from: 'hai-flow/flow-engine', to: 'api/balance_client.py', relation: 'imports' }], + }; + + const edges = detectCrossRepoEdges(repoB, repoA); + const depEdge = edges.find(e => e.relation === 'DEPENDS_ON'); + expect(depEdge?.crossOriginPairs).toEqual([['svc-b']]); + }); + + it("uses the import edge's own origin tag for the importing (reverse) side, not a fresh lookup of whichever node CURRENTLY sits at its slug — a later, unrelated collision can silently swap that node out without ever touching the edge's own tag (#974 review round 14 P1)", () => { + const existing = { + // `shared/client` collided with a DIFFERENT repo (svc-x) that was + // aggregated after svc-b and won the merge — the node here is no + // longer svc-b's, but the import edge below is still svc-b's own, + // tagged at the time IT was aggregated, unaffected by that collision. + nodes: [{ slug: 'shared/client', title: 'ClientX', type: 'component', origin: 'svc-x' }], + edges: [{ from: 'shared/client', to: 'libs/balance_service.py', relation: 'imports', origin: 'svc-b' }], + }; + const overlay = { + nodes: [{ slug: 'a/service', title: 'BalanceService', type: 'component', origin: 'svc-a' }], + edges: [], + }; + + const edges = detectCrossRepoEdges(overlay, existing); + const depEdge = edges.find(e => e.relation === 'DEPENDS_ON'); + expect(depEdge?.crossOriginPairs).toEqual([expect.arrayContaining(['svc-b', 'svc-a'])]); + expect(depEdge?.crossOriginPairs?.[0]).not.toContain('svc-x'); + }); + + it('falls back to the current from-node\'s origin for the importing side only when the import edge itself predates origin tagging', () => { + const existing = { + nodes: [{ slug: 'shared/client', title: 'ClientX', type: 'component', origin: 'svc-x' }], + edges: [{ from: 'shared/client', to: 'libs/balance_service.py', relation: 'imports' }], + }; + const overlay = { + nodes: [{ slug: 'a/service', title: 'BalanceService', type: 'component', origin: 'svc-a' }], + edges: [], + }; + + const edges = detectCrossRepoEdges(overlay, existing); + const depEdge = edges.find(e => e.relation === 'DEPENDS_ON'); + expect(depEdge?.crossOriginPairs).toEqual([expect.arrayContaining(['svc-x', 'svc-a'])]); + }); + it('returns empty for repos with no shared names', () => { const repoA = { nodes: [{ slug: 'a/foo', title: 'FooService', type: 'component' }], diff --git a/src/__tests__/graph-aggregate.test.ts b/src/__tests__/graph-aggregate.test.ts index 6a76c7ef9..6e2528134 100644 --- a/src/__tests__/graph-aggregate.test.ts +++ b/src/__tests__/graph-aggregate.test.ts @@ -3,7 +3,8 @@ 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, scopeGlobalGraph } from '../graph-aggregate.js'; +import { mergeGraphs, createGraphIndex } from '../wiki-engine/adapters/index.js'; describe('aggregateGlobalGraph', () => { let tmpDir: string; @@ -113,4 +114,798 @@ describe('aggregateGlobalGraph', () => { const result = await aggregateGlobalGraph(tmpDir); expect(result).toBeNull(); }); + + describe('mergeGraphs: crossOriginPairs union on colliding edge identity (#974 review rounds 14 P2 / 15 P2)', () => { + it("keeps BOTH independent repo-pairs' provenance as separate pairs when two DIFFERENT codebase pairs produce a cross-repo edge with the identical from/to/relation, instead of the later one silently erasing the earlier one's", () => { + const base = createGraphIndex([], [ + { from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-w', 'svc-z']] }, + ]); + const overlay = createGraphIndex([], [ + { from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-y', 'svc-z']] }, + ]); + + const merged = mergeGraphs(base, overlay); + expect(merged.edges).toHaveLength(1); + expect(merged.edges[0].crossOriginPairs).toHaveLength(2); + expect(merged.edges[0].crossOriginPairs).toEqual(expect.arrayContaining([ + expect.arrayContaining(['svc-w', 'svc-z']), + expect.arrayContaining(['svc-y', 'svc-z']), + ])); + }); + + it('dedupes an identical pair contributed by both sides instead of keeping a duplicate', () => { + const base = createGraphIndex([], [{ from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-w', 'svc-z']] }]); + const overlay = createGraphIndex([], [{ from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-w', 'svc-z']] }]); + + expect(mergeGraphs(base, overlay).edges[0].crossOriginPairs).toHaveLength(1); + }); + + it('produces the identical union regardless of which side is base vs. overlay', () => { + const a = createGraphIndex([], [{ from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-w', 'svc-z']] }]); + const b = createGraphIndex([], [{ from: 'client/main', to: 'z/service', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-y', 'svc-z']] }]); + + expect(mergeGraphs(a, b).edges[0].crossOriginPairs).toHaveLength(2); + expect(mergeGraphs(b, a).edges[0].crossOriginPairs).toHaveLength(2); + }); + + it("preserves BOTH plain origins as independent pairs when two ordinary (non-cross-repo) edges collide on the same identity, instead of the losing side's origin vanishing outright", () => { + // Two unrelated repos' own per-repo files can each independently + // produce the identical from/to/relation edge (same fact-level-slug + // collision risk as nodes). The loser's `origin` must survive as + // its own provenance pair, not be silently dropped just because it + // isn't the object that won the evidence tie-break. + const base = createGraphIndex([], [{ from: 'a', to: 'b', relation: 'DEPENDS_ON', origin: 'svc-a' }]); + const overlay = createGraphIndex([], [{ from: 'a', to: 'b', relation: 'DEPENDS_ON', origin: 'svc-a2' }]); + + const merged = mergeGraphs(base, overlay); + expect(merged.edges).toHaveLength(1); + expect(merged.edges[0].origin).toBe('svc-a2'); // overlay still wins ties for ordinary fields, as before + expect(merged.edges[0].crossOriginPairs).toEqual(expect.arrayContaining([['svc-a'], ['svc-a2']])); + }); + + it("preserves the LOSING side's plain origin as an independent pair when an allowed repo's ordinary edge collides with a newly synthesized cross-repo edge that wins the tie (#974 review round 18 P2)", () => { + const ordinary = createGraphIndex([], [{ from: 'a', to: 'b', relation: 'DEPENDS_ON', origin: 'svc-a' }]); + const crossEdge = createGraphIndex([], [{ from: 'a', to: 'b', relation: 'DEPENDS_ON', crossOriginPairs: [['svc-w', 'svc-z']] }]); + + // Whichever order they merge in, svc-a's independent claim on this + // edge identity must not be lost just because it has no + // crossOriginPairs of its own and isn't the object that wins. + const mergedA = mergeGraphs(ordinary, crossEdge); + expect(mergedA.edges[0].crossOriginPairs).toEqual(expect.arrayContaining([['svc-w', 'svc-z'], ['svc-a']])); + const mergedB = mergeGraphs(crossEdge, ordinary); + expect(mergedB.edges[0].crossOriginPairs).toEqual(expect.arrayContaining([['svc-w', 'svc-z'], ['svc-a']])); + }); + }); + + describe('scopeGlobalGraph (#912 review round 2)', () => { + it("subtracts a withheld project's nodes and its dangling edges from the real global graph", 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' }], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual(['a/svc']); + expect(graph?.edges).toHaveLength(0); + }); + + it('removes a cross-repo edge into a withheld node as a dangling edge, even though the edge only ever lived in the merged file', 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: [], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + // 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 () => { + 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 reads+writes the global + // graph directly, adding a product-page node and a MAPS_TO edge that + // never exist in any 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: 'docs/product/billing', title: 'Billing product page', type: 'architecture', confidence: 'high' }); + global.edges.push({ from: 'docs/product/billing', to: 'a/svc', 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).toContain('docs/product/billing'); + expect(graph?.edges).toContainEqual({ from: 'docs/product/billing', to: 'a/svc', relation: 'MAPS_TO' }); + }); + + it("fails closed (returns null) when a withheld codebase's per-repo graph file is missing — e.g. extracted via `teamai codebase --extract` directly, which never writes one (#912 review round 5 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [], + }); + // svc-b has evidence pages (and so is a legitimate wiki namespace) but + // no evidence/code/svc-b/.indices/graph-index.json — simulating a + // codebase that was extracted directly, not through `teamai import`. + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph).toBeNull(); + }); + + it("removes a withheld codebase's tagged content by origin even after its per-repo file goes structurally wrong post-aggregation, instead of failing the whole query closed (#912 review round 10 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); + + // A truncated or otherwise corrupted write can leave valid JSON that + // is not a valid graph (no nodes[]/edges[] at all) — JSON.parse alone + // would not catch this. But svc-b was already tagged `origin: 'svc-b'` + // on its node when the aggregation above merged it in, so this no + // longer needs svc-b's (now-broken) per-repo file read at all: the + // whole query stays usable, scoped precisely by the tag. + fs.writeFileSync(path.join(tmpDir, 'evidence', 'code', 'svc-b', '.indices', 'graph-index.json'), '{}'); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual(['a/svc']); + }); + + it("fails closed when a withheld codebase's per-repo graph file has nodes[]/edges[] arrays but a node that doesn't validate against the graph schema, and it was never successfully aggregated before (#912 review round 8 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [], + }); + // Array.isArray(nodes) && Array.isArray(edges) alone would wave this + // through: the node object is missing every field GraphNodeSchema + // requires (type, confidence, title), so it is not a valid node even + // though the file is syntactically and shape-wise fine. svc-b has + // never been successfully aggregated, so it carries no origin tag + // either — there is nothing to fall back on. + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ bogus: true }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph).toBeNull(); + }); + + it("removes a withheld codebase's tagged content by origin even after its per-repo file goes stale (schema-valid but empty) post-aggregation, instead of failing the whole query closed (#912 review round 9 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); + + // svc-b's per-repo file goes stale/truncated to a schema-valid but + // empty graph after aggregation. The origin tag the aggregation above + // already stamped on svc-b's node makes this file irrelevant to + // scoping now — it is only read at all for a codebase tagging never + // covered in the first place. + writeRepoGraph('svc-b', { schemaVersion: 1, generatedAt: '2026-01-02', nodes: [], edges: [] }); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual(['a/svc']); + }); + + it("removes a withheld codebase's OLD tagged nodes by origin even when its per-repo file has since been replaced by a newer, valid, non-empty but non-overlapping graph before re-aggregation ran (#912 review round 10 P1)", async () => { + // The scenario the review named directly: `teamai import` re-extracts + // svc-b, writing a brand new per-repo graph with different node slugs + // than last time, then is interrupted before the next + // aggregateGlobalGraph() call folds that new content into the global + // graph. The global graph still only has svc-b's OLD, already-tagged + // nodes — reading svc-b's (valid, non-empty, but now describing + // something else entirely) per-repo file would contribute the WRONG + // identifiers for subtracting what the global graph actually has. + 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/old-svc', title: 'ServiceB Old', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + // Interrupted re-import: svc-b's per-repo file now describes an + // entirely different (but still valid, non-empty) node — the global + // graph has not been re-aggregated to match yet. + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-02', + nodes: [{ slug: 'b/new-svc', title: 'ServiceB New', type: 'component', confidence: 'high' }], + edges: [], + }); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const slugs = graph?.nodes.map((n: { slug: string }) => n.slug) ?? []; + expect(slugs).toEqual(['a/svc']); + expect(slugs).not.toContain('b/old-svc'); + }); + + it("restores the allowed repo's title even when its own per-repo file used the legacy `label` field instead of `title` (#912 review round 8 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + // Legacy node shape: id/label/kind instead of slug/title/type. + // GraphNodeSchema's preprocess step normalizes this to slug/title/ + // type on load — restoring from the RAW per-repo object instead of + // the schema-normalized one would carry `label`, not `title`, and + // never actually override the surviving global node's title. + nodes: [{ id: 'component/App', label: 'AppA', kind: '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); + + // Force the deterministic outcome where the WITHHELD repo's version + // is the one that survived the merge. + const globalPath = path.join(tmpDir, '.indices', 'graph-index.json'); + const global = JSON.parse(await fs.readFile(globalPath, 'utf8')); + const node = global.nodes.find((n: { slug: string }) => n.slug === 'component/App'); + node.title = 'AppB'; + await fs.writeFile(globalPath, JSON.stringify(global)); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const survivor = graph?.nodes.find((n: { slug: string }) => n.slug === 'component/App') as { title?: string } | undefined; + expect(survivor?.title).toBe('AppA'); + }); + + it("does not fail closed when EVERY withheld codebase's per-repo graph file is readable, even if other (allowed) codebases have none", 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: [], + }); + // svc-c is allowed and has no per-repo graph file at all — must not + // block scoping, since only withheld codebases need accounting for. + fs.ensureDirSync(path.join(tmpDir, 'evidence', 'code', 'svc-c')); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual(['a/svc']); + }); + + it("restores an allowed repo's own title/domain for a colliding slug, even when the withheld repo's version won the merge (#912 review round 6 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'AppA', domain: 'svc-a', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'AppB', domain: 'svc-b', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + // mergeGraphs lets the later-processed repo's node win outright; force + // the deterministic outcome where the WITHHELD repo's version is the + // one that survived the merge, regardless of actual readdir order. + const globalPath = path.join(tmpDir, '.indices', 'graph-index.json'); + const global = JSON.parse(await fs.readFile(globalPath, 'utf8')); + const node = global.nodes.find((n: { slug: string }) => n.slug === 'component/App'); + node.title = 'AppB'; + node.domain = 'svc-b'; + await fs.writeFile(globalPath, JSON.stringify(global)); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const survivor = graph?.nodes.find((n: { slug: string }) => n.slug === 'component/App') as { title?: string; domain?: string } | undefined; + expect(survivor?.title).toBe('AppA'); + expect(survivor?.domain).toBe('svc-a'); + }); + + it('subtracts a withheld-only edge by its exact relation, keeping an allowed edge between the same two colliding endpoints under a different relation (#912 review round 6 P2)', async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppA', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigA', type: 'config', confidence: 'high' }, + ], + edges: [{ from: 'component/App', to: 'component/Config', relation: 'REFERENCES' }], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppB', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigB', type: 'config', confidence: 'high' }, + ], + edges: [{ from: 'component/App', to: 'component/Config', relation: 'DEPENDS_ON' }], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const betweenAppAndConfig = (graph?.edges ?? []).filter( + (e: { from: string; to: string }) => e.from === 'component/App' && e.to === 'component/Config', + ); + expect(betweenAppAndConfig.map((e: { relation: string }) => e.relation)).toEqual(['REFERENCES']); + }); + + it("subtracts a withheld-only edge even when its per-repo file uses the legacy `imports` relation name loadGraphIndex normalizes to DEPENDS_ON (#912 review round 7 P1)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppA', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigA', type: 'config', confidence: 'high' }, + ], + edges: [{ from: 'component/App', to: 'component/Config', relation: 'REFERENCES' }], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppB', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigB', type: 'config', confidence: 'high' }, + ], + // "imports" is the legacy relation name loadGraphIndex normalizes to + // DEPENDS_ON on read; scopeGlobalGraph reads this raw per-repo file + // with a plain JSON.parse, so it must normalize it the same way + // itself or this key will never match the normalized global edge. + edges: [{ from: 'component/App', to: 'component/Config', relation: 'imports' }], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + const betweenAppAndConfig = (graph?.edges ?? []).filter( + (e: { from: string; to: string }) => e.from === 'component/App' && e.to === 'component/Config', + ); + expect(betweenAppAndConfig.map((e: { relation: string }) => e.relation)).toEqual(['REFERENCES']); + }); + + it("subtracts an edge that only exists in the withheld repo's own graph, even when both endpoint names collide with an allowed repo's (#912 review round 5 P2)", async () => { + // svc-a and svc-b both define component/App and component/Config + // (unqualified slugs collide across repos), but only svc-b's graph + // has an edge directly between them. + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppA', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigA', type: 'config', confidence: 'high' }, + ], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/App', title: 'AppB', type: 'component', confidence: 'high' }, + { slug: 'component/Config', title: 'ConfigB', type: 'config', confidence: 'high' }, + ], + edges: [{ from: 'component/App', to: 'component/Config', relation: 'DEPENDS_ON' }], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + // Both colliding slugs survive (an allowed repo also claims them)... + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toContain('component/App'); + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toContain('component/Config'); + // ...but the edge that only ever existed in the withheld repo is gone. + expect(graph?.edges ?? []).not.toContainEqual( + expect.objectContaining({ from: 'component/App', to: 'component/Config' }), + ); + }); + + 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("removes a cross-repo edge whose MATCHED (target) node was the withheld repo's, even after a later allowed repo wins the slug collision and the node itself survives (#974 review round 12 P1)", 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: [], + }); + // svc-c is processed after svc-b (alphabetically) and mints the exact + // same unqualified slug svc-b's matched node used — `mergeGraphs` + // lets svc-c's write win, so the surviving `b/service` node ends up + // allowed, even though the cross-repo edge below was detected because + // of svc-b's (withheld) component, not svc-c's. + writeRepoGraph('svc-c', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/service', title: 'UnrelatedService', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const global = JSON.parse(await fs.readFile(path.join(tmpDir, '.indices', 'graph-index.json'), 'utf8')); + const crossEdge = global.edges.find((e: { relation: string }) => e.relation === 'DEPENDS_ON'); + expect(crossEdge).toBeDefined(); + // Both sides: svc-a's own node produced the import, svc-b's was matched. + expect(crossEdge.crossOriginPairs).toEqual([expect.arrayContaining(['svc-a', 'svc-b'])]); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + // The slug survives — svc-c (allowed) also claims it. + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toContain('b/service'); + // But the cross-repo edge, tied to svc-b's own component at detection + // time, must not survive just because the node it points at was later + // reattributed to an allowed repo. (svc-a's own imports->DEPENDS_ON + // edge into libs/balance_service.py is unrelated and must stay.) + expect(graph?.edges ?? []).not.toContainEqual(expect.objectContaining({ to: 'b/service' })); + expect(graph?.edges ?? []).toContainEqual(expect.objectContaining({ to: 'libs/balance_service.py' })); + }); + + it("removes a cross-repo edge whose SOURCE (importer) node was the withheld repo's, even after a later allowed repo wins that slug collision and the matched (target) side is allowed (#974 review round 13 P1)", async () => { + // The review's own example: withheld svc-b imports allowed svc-a, + // while allowed svc-c shares svc-b's unqualified SOURCE-node slug — + // the collision this time is on the importer's own side, not the + // matched target's. Tagging the edge with only the matched side's + // origin (round 12's fix) would leave it carrying svc-a's (allowed) + // origin alone, surviving even though it only exists because of + // svc-b's own import. + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/client', title: 'BalanceClient', type: 'component', confidence: 'high' }], + edges: [{ from: 'b/client', to: 'libs/balance_service.py', relation: 'imports' }], + }); + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/service', title: 'BalanceService', type: 'component', confidence: 'high' }], + edges: [], + }); + // svc-c mints the identical unqualified slug svc-b's IMPORTER node + // used, and is processed last, so its write wins that collision. + writeRepoGraph('svc-c', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'b/client', title: 'UnrelatedClient', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const global = JSON.parse(await fs.readFile(path.join(tmpDir, '.indices', 'graph-index.json'), 'utf8')); + const crossEdge = global.edges.find((e: { relation: string }) => e.relation === 'DEPENDS_ON'); + expect(crossEdge).toBeDefined(); + expect(crossEdge.crossOriginPairs).toEqual([expect.arrayContaining(['svc-a', 'svc-b'])]); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + // The slug survives — svc-c (allowed) also claims it — and svc-a's + // own node is untouched (it was never withheld). + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toEqual( + expect.arrayContaining(['b/client', 'a/service']), + ); + // The cross-repo edge depended on svc-b's own import; it must not + // survive just because the node at its source slug was reattributed. + expect(graph?.edges ?? []).not.toContainEqual(expect.objectContaining({ from: 'b/client', to: 'a/service' })); + }); + + it("fails closed when a legacy (not tag-covered) withheld codebase's contested slug is the endpoint of a cross-repo edge that predates origin/crossOriginPairs tagging entirely, since the fallback per-repo-file scan can never discover it (#974 review round 14 P1)", async () => { + // Hand-written, pre-upgrade-style data: a global graph with a + // synthesized cross-repo edge that has no `origin`/`crossOriginPairs`/ + // `source` at all (as detectCrossRepoEdges produced before this field + // existed), plus per-repo files for both the withheld codebase this + // edge actually depends on and an allowed one that happens to share + // its unqualified node slug. No aggregateGlobalGraph call here — it + // would re-tag everything, defeating the point of the fixture. + const globalDir = path.join(tmpDir, '.indices'); + fs.ensureDirSync(globalDir); + fs.writeFileSync(path.join(globalDir, 'graph-index.json'), JSON.stringify({ + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [ + { slug: 'old/client', title: 'NewClient', type: 'component', confidence: 'high' }, + { slug: 'allowed/target', title: 'AllowedTarget', type: 'component', confidence: 'high' }, + ], + edges: [{ from: 'old/client', to: 'allowed/target', relation: 'DEPENDS_ON' }], + })); + writeRepoGraph('svc-old', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'old/client', title: 'OldClient', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-new', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'old/client', title: 'NewClient', type: 'component', confidence: 'high' }], + edges: [], + }); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-old'])); + expect(graph).toBeNull(); + }); + + it("fails closed the same way for a legacy, fully untagged import-iwiki.ts MAPS_TO edge into a contested fact-level slug — the check isn't scoped to DEPENDS_ON (#974 review round 16 P1)", async () => { + const globalDir = path.join(tmpDir, '.indices'); + fs.ensureDirSync(globalDir); + fs.writeFileSync(path.join(globalDir, 'graph-index.json'), JSON.stringify({ + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'NewApp', type: 'component', confidence: 'high' }], + edges: [{ from: 'iwiki/p/1', to: 'component/App', relation: 'MAPS_TO', term: 'App', confidence: 0.8 }], + })); + writeRepoGraph('svc-old', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'OldApp', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-new', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'NewApp', type: 'component', confidence: 'high' }], + edges: [], + }); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-old'])); + expect(graph).toBeNull(); + }); + + it("treats an edge's plain `origin` as one more independent provenance alongside `crossOriginPairs`, not something the nullish fallback discards, when mergeGraphs collides an allowed repo's own edge onto a synthesized cross-repo edge sharing the same identity (#974 review round 17 P2)", async () => { + const globalDir = path.join(tmpDir, '.indices'); + fs.ensureDirSync(globalDir); + fs.writeFileSync(path.join(globalDir, 'graph-index.json'), JSON.stringify({ + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [], + edges: [{ from: 'x', to: 'y', relation: 'DEPENDS_ON', origin: 'svc-a', crossOriginPairs: [['svc-w', 'svc-z']] }], + })); + + // svc-w's cross-repo pair is fully withheld, but the SAME edge also + // carries svc-a's own, independently-allowed `origin` tag — the edge + // must survive, not be discarded just because crossOriginPairs took + // precedence over origin. + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-w'])); + expect(graph?.edges ?? []).toContainEqual(expect.objectContaining({ from: 'x', to: 'y' })); + }); + + it('still removes that same edge once its `origin` AND every `crossOriginPairs` entry are withheld', async () => { + const globalDir = path.join(tmpDir, '.indices'); + fs.ensureDirSync(globalDir); + fs.writeFileSync(path.join(globalDir, 'graph-index.json'), JSON.stringify({ + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [], + edges: [{ from: 'x', to: 'y', relation: 'DEPENDS_ON', origin: 'svc-a', crossOriginPairs: [['svc-w', 'svc-z']] }], + })); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-a', 'svc-w', 'svc-z'])); + expect(graph?.edges ?? []).not.toContainEqual(expect.objectContaining({ from: 'x', to: 'y' })); + }); + + it("keeps an allowed repo's own ordinary edge claim alive through the REAL aggregation pipeline, when a later-detected cross-repo edge happens to share its exact identity and wins the merge tie-break (#974 review round 18 P2)", async () => { + // svc-a declares a plain fact-level edge directly (not import-derived). + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [], + edges: [{ from: 'a/x', to: 'a/y', relation: 'DEPENDS_ON' }], + }); + // svc-w (withheld) imports something matching svc-z's component — not + // detected yet, since svc-z doesn't exist when svc-w is processed. + writeRepoGraph('svc-w', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/x', title: 'X', type: 'component', confidence: 'high' }], + edges: [{ from: 'a/x', to: 'y.py', relation: 'imports' }], + }); + // svc-z, processed last, is what the match resolves against — the + // resulting synthesized edge has the IDENTICAL from/to/relation as + // svc-a's own plain edge above, purely by slug coincidence. + writeRepoGraph('svc-z', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/y', title: 'Y', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const global = JSON.parse(await fs.readFile(path.join(tmpDir, '.indices', 'graph-index.json'), 'utf8')); + const edge = global.edges.find((e: { from: string; to: string }) => e.from === 'a/x' && e.to === 'a/y'); + expect(edge).toBeDefined(); + // svc-a's plain origin must have survived the merge as its own pair, + // not been silently dropped because the cross edge won the tie. + expect(edge.crossOriginPairs).toEqual(expect.arrayContaining([['svc-a'], expect.arrayContaining(['svc-w', 'svc-z'])])); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-w'])); + // svc-a's independent claim keeps the edge alive even though svc-w + // (withheld) is one half of the OTHER pair sharing this identity. + expect(graph?.edges ?? []).toContainEqual(expect.objectContaining({ from: 'a/x', to: 'a/y' })); + }); + + it("keeps a cross-repo edge that remains independently producible by a fully-allowed repo pair, even though withholding one repo removes ITS pair's claim on the identical edge identity (#974 review round 15 P2)", async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/service', title: 'Service', type: 'component', confidence: 'high' }], + edges: [], + }); + writeRepoGraph('svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'shared/client', title: 'ClientB', type: 'component', confidence: 'high' }], + edges: [{ from: 'shared/client', to: 'service.py', relation: 'imports' }], + }); + // svc-c mints the same unqualified importer slug as svc-b AND + // produces the exact same cross-repo edge shape independently — + // the two repos' relationships to svc-a merge onto one edge + // identity but must remain separately attributable. + writeRepoGraph('svc-c', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'shared/client', title: 'ClientC', type: 'component', confidence: 'high' }], + edges: [{ from: 'shared/client', to: 'service.py', relation: 'imports' }], + }); + await aggregateGlobalGraph(tmpDir); + + const global = JSON.parse(await fs.readFile(path.join(tmpDir, '.indices', 'graph-index.json'), 'utf8')); + const crossEdge = global.edges.find((e: { relation: string }) => e.relation === 'DEPENDS_ON'); + expect(crossEdge).toBeDefined(); + // Routed through mergeGraphs (not appended raw), so the two + // independent detections land as TWO pairs on ONE edge object. + expect(crossEdge.crossOriginPairs).toHaveLength(2); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + // svc-c (allowed) wins the node collision, as always. + expect(graph?.nodes.map((n: { slug: string }) => n.slug)).toContain('shared/client'); + // The edge survives: svc-c+svc-a's pair is fully allowed, even though + // svc-b+svc-a's pair — the identical edge identity — is withheld. + expect(graph?.edges ?? []).toContainEqual(expect.objectContaining({ from: 'shared/client', to: 'a/service' })); + }); + + it("removes an originless MAPS_TO edge pointing at evidence/code//.md even when no node was ever created for that page (#974 review round 12 P2)", 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 import-iwiki.ts's own reconciler: it writes a MAPS_TO edge + // straight to the global graph's edges[] pointing at a code PAGE path + // (not a graph node slug) whenever a doc term merely appears in that + // page's body — no node is ever created for the page itself. + const globalPath = path.join(tmpDir, '.indices', 'graph-index.json'); + const global = JSON.parse(await fs.readFile(globalPath, 'utf8')); + global.edges.push({ from: 'iwiki/p/123', to: 'evidence/code/svc-b/component.md', relation: 'MAPS_TO', term: 'ServiceB', confidence: 0.6 }); + await fs.writeFile(globalPath, JSON.stringify(global)); + + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph?.edges ?? []).not.toContainEqual( + expect.objectContaining({ to: 'evidence/code/svc-b/component.md' }), + ); + }); + + 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', + 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?.nodes).toHaveLength(0); + }); + + it('returns the graph unchanged when nothing is withheld', async () => { + writeRepoGraph('svc-a', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'a/svc', title: 'ServiceA', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(tmpDir); + + const graph = await scopeGlobalGraph(tmpDir, new Set()); + expect(graph?.nodes).toHaveLength(1); + }); + + it('returns null when there is no global graph to scope', async () => { + const graph = await scopeGlobalGraph(tmpDir, new Set(['svc-b'])); + expect(graph).toBeNull(); + }); + }); }); diff --git a/src/__tests__/import-iwiki-reconcile.test.ts b/src/__tests__/import-iwiki-reconcile.test.ts new file mode 100644 index 000000000..9983dceff --- /dev/null +++ b/src/__tests__/import-iwiki-reconcile.test.ts @@ -0,0 +1,92 @@ +// -*- coding: utf-8 -*- +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { reconcileIwikiWithCodebase } from '../import-iwiki.js'; +import { aggregateGlobalGraph, scopeGlobalGraph } from '../graph-aggregate.js'; + +const temporaryDirectories: string[] = []; + +function makeWikiRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-iwiki-reconcile-')); + temporaryDirectories.push(root); + fs.mkdirSync(path.join(root, 'teamwiki'), { recursive: true }); + return path.join(root, 'teamwiki'); +} + +function writeRepoGraph(teamwikiRoot: string, slug: string, graph: object): void { + const dir = path.join(teamwikiRoot, 'evidence', 'code', slug, '.indices'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'graph-index.json'), JSON.stringify(graph)); +} + +afterEach(() => { + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('reconcileIwikiWithCodebase: direct-match MAPS_TO edges (#974 review round 16 P1)', () => { + it("tags a direct-match edge with the matched node's own origin, not left untagged", async () => { + const teamwikiRoot = makeWikiRoot(); + writeRepoGraph(teamwikiRoot, 'svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'App', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(teamwikiRoot); + + const edges = await reconcileIwikiWithCodebase( + [{ docid: '1', title: 'Doc', content: 'See the `App` component for details.', url: 'https://example.com/1' }], + teamwikiRoot, + ); + + const directMatch = edges.find((e) => e.to === 'component/App'); + expect(directMatch).toBeDefined(); + expect(directMatch?.origin).toBe('svc-b'); + }); + + it("scopeGlobalGraph removes the persisted, origin-tagged edge once that codebase is withheld — even after its matched node is later reattributed to an allowed repo by a slug collision", async () => { + const teamwikiRoot = makeWikiRoot(); + writeRepoGraph(teamwikiRoot, 'svc-b', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'App', type: 'component', confidence: 'high' }], + edges: [], + }); + await aggregateGlobalGraph(teamwikiRoot); + + // `reconcileIwikiWithCodebase` persists its own edges directly to the + // global graph file — at this point `component/App` is unambiguously + // svc-b's, so that's what the edge gets tagged with. + await reconcileIwikiWithCodebase( + [{ docid: '1', title: 'Doc', content: 'See the `App` component for details.', url: 'https://example.com/1' }], + teamwikiRoot, + ); + + // Simulate a LATER collision reattributing the NODE (not re-running + // aggregateGlobalGraph, which would also wipe the iwiki edge just + // persisted — it never lived in any per-repo file to be re-discovered). + // svc-c independently claims the identical unqualified slug. + const graphPath = path.join(teamwikiRoot, '.indices', 'graph-index.json'); + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf-8')); + const appNode = graph.nodes.find((n: { slug: string }) => n.slug === 'component/App'); + appNode.origin = 'svc-c'; + appNode.title = 'UnrelatedApp'; + fs.writeFileSync(graphPath, JSON.stringify(graph)); + writeRepoGraph(teamwikiRoot, 'svc-c', { + schemaVersion: 1, generatedAt: '2026-01-01', + nodes: [{ slug: 'component/App', title: 'UnrelatedApp', type: 'component', confidence: 'high' }], + edges: [], + }); + + const scoped = await scopeGlobalGraph(teamwikiRoot, new Set(['svc-b'])); + // The node survives — svc-c (allowed) now owns that slug. + expect(scoped?.nodes.map((n) => n.slug)).toContain('component/App'); + // But the MAPS_TO edge, tagged with svc-b's origin at the moment it + // was created, must not survive just because the node it points at + // was later reattributed. + expect(scoped?.edges ?? []).not.toContainEqual(expect.objectContaining({ to: 'component/App', relation: 'MAPS_TO' })); + }); +}); diff --git a/src/__tests__/recall-wiki-scope.test.ts b/src/__tests__/recall-wiki-scope.test.ts new file mode 100644 index 000000000..5fdf155e9 --- /dev/null +++ b/src/__tests__/recall-wiki-scope.test.ts @@ -0,0 +1,111 @@ +/** + * `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([]); + }); + + it('skips code recall instead of rejecting the whole call when the projects manifest is unreadable (#912 review round 2)', async () => { + await fse.outputFile(path.join(teamRepo, 'manifest', 'projects.yaml'), '{ not: valid: yaml'); + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig(['svc-a'])); + + await expect(recall('narwhal', { dryRun: true })).resolves.not.toThrow(); + expect(queryCodeKnowledge).not.toHaveBeenCalled(); + }); +}); 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..c5dbc51b0 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,14 +228,100 @@ function extractSnippet(content: string, queryTokens: string[], maxLen: number = return snippet; } -async function loadWikiPages(wikiRoot: string, depth: 'route' | 'context' | 'lookup'): Promise { +/** + * `router.md` lists every codebase in either of the two formats production + * code generates: `routerTemplate` (wiki-engine/adapters/templates.ts) + * writes bullets linking `[[evidence/code//index]]` — grouped under a + * `### ` header when AI domain classification ran, with an + * unresolved component falling back to a bare `- ` line with no link + * at all; `rebuildWikiIndex` (the path taken after an import) writes table + * rows linking `[[code//index]]` instead. Plus a `` comment aggregating every codebase's keywords. At `--depth + * route`, that single global file is the whole result, so a withheld + * 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. + * + * A mixed section — some links allowed, some withheld — keeps its header + * and drops only the withheld-linked lines, same as before. But an + * unresolved component's bare `- ` fallback line carries no link at + * all, so `lineSlug` can never attribute it to either side; nothing marks + * it as the withheld codebase's own stray line versus the allowed one's. + * This function only ever runs when at least one codebase IS withheld (the + * caller skips it otherwise), so that ambiguity can't be resolved safely — + * failing closed means dropping every such unattributable bullet + * unconditionally, the same rule `scopeGlobalGraph` applies to graph + * ownership it cannot verify, rather than trusting an unlinked line is + * innocent just because something else nearby has an allowed link. + * + * A domain can ALSO end up with every one of its bullets unlinked — an AI + * domain classification that groups only components routerTemplate failed + * to match to any known project. `slugs` is then empty for that section, so + * the all-withheld check above never triggers (there is nothing linked to + * call "all withheld"), yet the per-line bare-bullet rule still drops every + * one of those bullets — leaving the bare `### ` header behind with + * nothing under it, naming the domain regardless of whether it was the + * withheld codebase's own. So a section whose bullets existed but NONE + * survived line filtering is dropped whole (header included) the same way + * an all-withheld one is, rather than only checking whether any were linked. + */ +function filterRouterContent(content: string, withheldCodebases: string[]): string { + const withheld = new Set(withheldCodebases.map(caseFoldKey)); + const lineSlug = (line: string): string | null => { + const match = line.match(/(?:evidence\/)?code\/([^/\]]+)/); + return match ? caseFoldKey(match[1]) : null; + }; + const isBullet = (line: string): boolean => /^-\s/.test(line); + const survivesFilter = (line: string): boolean => { + const slug = lineSlug(line); + if (slug) return !withheld.has(slug); + return !isBullet(line); + }; + + const lines = content.split('\n'); + const sections: string[][] = [[]]; + for (const line of lines) { + if (/^#{1,6}\s/.test(line) && sections[sections.length - 1].length > 0) { + sections.push([]); + } + sections[sections.length - 1].push(line); + } + + const kept: string[] = []; + for (const section of sections) { + const bullets = section.filter(isBullet); + const dropWhole = bullets.length > 0 && bullets.every((line) => !survivesFilter(line)); + if (dropWhole) continue; + for (const line of section) { + if (line.startsWith('