From 55bcf7da29f0891d3626c8881d30a5e3b1456d60 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 2 Oct 2026 18:13:42 -0700 Subject: [PATCH 1/4] fix(nexus): bind graph queries to portable project identity T12472: preserve recorded legacy aliases while refusing ambiguous and foreign graph selectors. --- .changeset/t12472-nexus-portable-query.md | 15 ++ .../__tests__/doctor-db-substrate.test.ts | 11 +- .../nexus-status-files-bound.test.ts | 10 +- .../__tests__/nexus-status-scope.test.ts | 8 +- packages/cleo/src/cli/commands/graph.ts | 8 +- packages/cleo/src/cli/commands/nexus.ts | 64 +++---- packages/cleo/src/dispatch/domains/nexus.ts | 23 +-- packages/contracts/src/doctor.ts | 7 +- packages/core/src/doctor/db-substrate.ts | 27 ++- .../core/src/nexus/__tests__/registry.test.ts | 165 +++++++++++++++++- packages/core/src/nexus/api-contracts.ts | 43 +++-- .../api-extractors/http-extractor.test.ts | 24 ++- .../nexus/api-extractors/http-extractor.ts | 6 +- packages/core/src/nexus/diff.ts | 6 +- packages/core/src/nexus/identity.ts | 22 ++- packages/core/src/nexus/index.ts | 2 + packages/core/src/nexus/nexus-bridge.ts | 8 +- packages/core/src/nexus/registry.ts | 89 +++++++++- .../skills/skills/ct-codebase-mapper/SKILL.md | 12 +- packages/skills/skills/manifest.json | 2 +- 20 files changed, 443 insertions(+), 109 deletions(-) create mode 100644 .changeset/t12472-nexus-portable-query.md diff --git a/.changeset/t12472-nexus-portable-query.md b/.changeset/t12472-nexus-portable-query.md new file mode 100644 index 0000000000..53cc798066 --- /dev/null +++ b/.changeset/t12472-nexus-portable-query.md @@ -0,0 +1,15 @@ +--- +id: t12472-nexus-portable-query +tasks: [T12472] +kind: fix +summary: Nexus graph queries keep portable identity when projects move +--- + +Project-scoped CLI and dispatch queries read the declared portable project id +instead of deriving it from the checkout path. Recorded legacy aliases resolve +with a deprecation warning; ambiguous aliases and foreign overrides remain +refused. A substrate survey reports missing identity explicitly. + +Contract comparison resolves registered project paths and reads each HTTP graph +from its own checkout, retaining the canonical ids in its result. Moving a +checkout preserves its existing symbol graph without requiring another analysis. diff --git a/packages/cleo/src/cli/commands/__tests__/doctor-db-substrate.test.ts b/packages/cleo/src/cli/commands/__tests__/doctor-db-substrate.test.ts index d9a7fc35a4..e754b737cc 100644 --- a/packages/cleo/src/cli/commands/__tests__/doctor-db-substrate.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/doctor-db-substrate.test.ts @@ -204,7 +204,7 @@ describe('doctor db-substrate (T10307)', () => { expect(survey).toBeDefined(); if (!survey) throw new Error('survey absent'); expect(survey.projectRoot).toBe(projectRoot); - expect(survey.projectId.length).toBe(32); + expect(survey.projectId).toBeNull(); // Undeclared fixtures must not receive invented path ids. // Every inventory role MUST be keyed in the survey. for (const inventoryEntry of DB_INVENTORY) { @@ -1223,6 +1223,10 @@ describe('doctor db-substrate (T10307)', () => { const llmtxtDir = join(cleoDir, 'llmtxt'); mkdirSync(blobsDir, { recursive: true }); mkdirSync(llmtxtDir, { recursive: true }); + writeFileSync( + join(cleoDir, 'project-info.json'), + JSON.stringify({ projectId: `substrate-${name}` }), + ); seedTasksDbForCrossDb(join(cleoDir, 'tasks.db'), { taskIds: ['T100'], @@ -1240,6 +1244,7 @@ describe('doctor db-substrate (T10307)', () => { ]); // Nexus row uses an INTENTIONAL path mismatch so I3 fires. const expectedId = computeSubstrateProjectId(projectRoot); + if (expectedId === null) throw new Error('Fixture must declare its project identity'); const nexusPath = join(cleoHomeOverride, 'nexus.db'); seedNexusDbForCrossDb(nexusPath, [ { projectId: expectedId, projectPath: `${projectRoot}-MOVED` }, @@ -1353,7 +1358,9 @@ describe('doctor db-substrate (T10307)', () => { _resetCleoPlatformPathsCache(), ); const projectRoot = createProjectWithTasksDb('i3-match'); + writeFileSync(join(projectRoot, '.cleo/project-id'), 'portable-substrate-i3-match\n'); const expectedId = computeSubstrateProjectId(projectRoot); + if (expectedId === null) throw new Error('Fixture must declare its project identity'); seedNexusDbForCrossDb(join(cleoHomeOverride, 'nexus.db'), [ { projectId: expectedId, projectPath: projectRoot }, ]); @@ -1376,7 +1383,7 @@ describe('doctor db-substrate (T10307)', () => { // A sibling owns the shared legacy key at another path; this project's own // row, under its declared id, matches. seedNexusDbForCrossDb(join(cleoHomeOverride, 'nexus.db'), [ - { projectId: computeSubstrateProjectId(projectRoot), projectPath: `${projectRoot}-sibling` }, + { projectId: 'substrate-i3-sibling-row', projectPath: `${projectRoot}-sibling` }, { projectId: 'declared-T12589', projectPath: projectRoot }, ]); diff --git a/packages/cleo/src/cli/commands/__tests__/nexus-status-files-bound.test.ts b/packages/cleo/src/cli/commands/__tests__/nexus-status-files-bound.test.ts index 68781fc6b9..545141d9e1 100644 --- a/packages/cleo/src/cli/commands/__tests__/nexus-status-files-bound.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/nexus-status-files-bound.test.ts @@ -12,7 +12,10 @@ * @task T12560 */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import type { GraphIndexAssessment, GraphIndexFileReport } from '@cleocode/contracts'; +import { getProjectRoot } from '@cleocode/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const state = vi.hoisted(() => ({ assessment: null as GraphIndexAssessment | null })); @@ -99,7 +102,12 @@ function bytes(value: unknown): number { } describe('nexus status — bounded file report (T12560)', () => { - beforeEach(() => { + beforeEach(async () => { + // The bounded-output fixture represents an initialized project. Declare its + // identity so these assertions exercise pagination, not missing setup. + const stateDir = join(getProjectRoot(), '.cleo'); + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, 'project-id'), 'bounded-status-project\n'); vi.mocked(cliError).mockClear(); process.exitCode = undefined; }); diff --git a/packages/cleo/src/cli/commands/__tests__/nexus-status-scope.test.ts b/packages/cleo/src/cli/commands/__tests__/nexus-status-scope.test.ts index 0838b6c492..5afdd159c5 100644 --- a/packages/cleo/src/cli/commands/__tests__/nexus-status-scope.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/nexus-status-scope.test.ts @@ -76,7 +76,7 @@ describe('nexus status — scope honesty (gh#1329)', () => { expect(src).toContain('E_NEXUS_PATH_NOT_A_DIRECTORY'); // The guard must run BEFORE the id is derived, or it guards nothing. expect(src.indexOf('E_NEXUS_PATH_NOT_A_DIRECTORY')).toBeLessThan( - src.indexOf('const derivedProjectId'), + src.indexOf('projectId = await resolveNexusQueryProjectId'), ); }); @@ -85,9 +85,9 @@ describe('nexus status — scope honesty (gh#1329)', () => { // — the graph DB is project-scoped, so an override relabels this project's // counts as another's rather than selecting anything. const src = readFileSync(NEXUS_COMMAND, 'utf-8'); - expect(src).toContain('derivedProjectId'); - expect(src).toMatch( - /projectIdOverride !== undefined && projectIdOverride !== derivedProjectId/, + expect(src).toContain('await resolveNexusQueryProjectId(repoPath, projectIdOverride)'); + expect(src.indexOf('E_NEXUS_CROSS_PROJECT_STATUS')).toBeLessThan( + src.indexOf('const db = await getNexusDb()'), ); }); diff --git a/packages/cleo/src/cli/commands/graph.ts b/packages/cleo/src/cli/commands/graph.ts index 895a9c9b3e..173beee034 100644 --- a/packages/cleo/src/cli/commands/graph.ts +++ b/packages/cleo/src/cli/commands/graph.ts @@ -21,6 +21,7 @@ import path from 'node:path'; import { pushWarning } from '@cleocode/core'; +import { resolveNexusQueryProjectId } from '@cleocode/core/nexus/registry.js'; import { defineCommand, showUsage } from 'citty'; import { dispatchFromCli, dispatchRaw } from '../../dispatch/adapters/cli.js'; import { getFormatContext, setFormatContext } from '../format-context.js'; @@ -71,8 +72,7 @@ const statusCommand = defineCommand({ import('@cleocode/nexus/pipeline' as string), ]); - const projectId = - projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const db = await getNexusDb(); const tables = { nexusNodes: nexusSchema.nexusNodes, @@ -286,7 +286,7 @@ const contextCommand = defineCommand({ applyJsonFlag(args.json as boolean | undefined); const repoPath = args.path ? path.resolve(args.path as string) : process.cwd(); const projectIdOverride = args['project-id'] as string | undefined; - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const limit = Number.parseInt(args.limit as string, 10) || 20; const kind = (args.kind as string | undefined) ?? undefined; const params: Record = { projectId, limit }; @@ -432,7 +432,7 @@ const routeMapCommand = defineCommand({ applyJsonFlag(args.json as boolean | undefined); const repoPath = args.path ? path.resolve(args.path as string) : process.cwd(); const projectIdOverride = args['project-id'] as string | undefined; - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); await dispatchFromCli('query', 'nexus', 'route-map', { projectId }, { command: 'graph' }); }, }); diff --git a/packages/cleo/src/cli/commands/nexus.ts b/packages/cleo/src/cli/commands/nexus.ts index 8f79c2ce54..5b9e9e73b4 100644 --- a/packages/cleo/src/cli/commands/nexus.ts +++ b/packages/cleo/src/cli/commands/nexus.ts @@ -41,11 +41,13 @@ import { withNexusFreshnessMeta, } from '@cleocode/core/nexus/freshness.js'; import { KnowledgeSymbolAmbiguityError } from '@cleocode/core/nexus/knowledge.js'; +import { resolveNexusQueryProjectId } from '@cleocode/core/nexus/registry.js'; import { runNexusWiki } from '@cleocode/core/nexus/wiki-orchestrator.js'; import { getCleoStateDir } from '@cleocode/paths'; import { defineCommand, showUsage } from 'citty'; import { dispatchFromCli, dispatchRaw } from '../../dispatch/adapters/cli.js'; import { buildNexusMetaExtensions } from '../../dispatch/nexus-decorator.js'; +import { asCleoErrorLike, cleoErrorCodeName } from '../cleo-error-like.js'; import { getFormatContext, setFormatContext } from '../format-context.js'; import { cliError, cliOutput, humanInfo, humanWarn } from '../renderers/index.js'; @@ -274,32 +276,32 @@ const statusCommand = defineCommand({ } } - // gh#1329 — the SAME defect reached by the other flag. `--project-id` - // overrides the id we report while `getNexusDb()` still opens THIS - // project's store, so a foreign id is printed alongside this project's - // counts. `getIndexStats` documents its `_projectId` parameter as unused - // since ADR-090 · T11648 for exactly that reason: the graph DB is - // project-scoped, so the id cannot select anything. - // - // The id is derived from the path, so an override that MATCHES the derived - // id is a no-op and stays allowed — it is only a foreign id that asks a - // question this process cannot answer. - const derivedProjectId = Buffer.from(repoPath).toString('base64url').slice(0, 32); - if (projectIdOverride !== undefined && projectIdOverride !== derivedProjectId) { + // Resolve identity before querying the project-scoped store. A legacy alias + // may identify this checkout, but a foreign id cannot select another graph. + let projectId: string; + try { + projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); + } catch (error) { + const typed = asCleoErrorLike(error); + const code = typed?.code ?? ExitCode.INVALID_INPUT; + const name = + code === ExitCode.INVALID_INPUT && !typed?.codeName + ? 'E_NEXUS_CROSS_PROJECT_STATUS' + : typed + ? cleoErrorCodeName(typed) + : 'E_NEXUS_IDENTITY'; cliError( - `nexus status cannot report on project '${projectIdOverride}' from this project.\n` + - 'The code-intelligence graph is project-scoped (ADR-090 · T11648), so the counts ' + - 'always describe the store that is open — passing a different --project-id would ' + - "relabel this project's index as another's, which is the confident-but-wrong " + - 'answer this guard exists to prevent.', - ExitCode.INVALID_INPUT, + error instanceof Error ? error.message : String(error), + code, { - name: 'E_NEXUS_CROSS_PROJECT_STATUS', - fix: `cd && cleo nexus status (this project's id is ${derivedProjectId})`, + name, + fix: typed + ? typed.fix + : 'Run nexus status from the intended project using its portable project id.', }, { operation: 'nexus.status' }, ); - process.exitCode = ExitCode.INVALID_INPUT; + process.exitCode = code; return; } @@ -354,8 +356,6 @@ const statusCommand = defineCommand({ import('@cleocode/core/nexus/knowledge' as string), ]); - const projectId = - projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); const db = await getNexusDb(); const tables = { nexusNodes: nexusSchema.nexusNodes, @@ -961,7 +961,7 @@ const clustersCommand = defineCommand({ const startTime = Date.now(); const projectIdOverride = args['project-id'] as string | undefined; const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('query', 'nexus', 'clusters', { projectId, repoPath }); const durationMs = Date.now() - startTime; if (!response.success) { @@ -1007,7 +1007,7 @@ const flowsCommand = defineCommand({ const startTime = Date.now(); const projectIdOverride = args['project-id'] as string | undefined; const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('query', 'nexus', 'flows', { projectId, repoPath }); const durationMs = Date.now() - startTime; if (!response.success) { @@ -1054,7 +1054,7 @@ const contextCommand = defineCommand({ const startTime = Date.now(); const projectIdOverride = args['project-id'] as string | undefined; const repoPath = getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const limit = parseInt(args.limit as string, 10); const symbolName = args.symbol as string; const showContent = !!args.content; @@ -1120,7 +1120,7 @@ const impactCommand = defineCommand({ const whyFlag = !!args.why; const projectIdOverride = args['project-id'] as string | undefined; const repoPath = getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const maxDepth = Math.min(parseInt(args.depth as string, 10), 5); const symbolName = args.symbol as string; try { @@ -1946,7 +1946,7 @@ const refreshBridgeCommand = defineCommand({ const startTime = Date.now(); const projectIdOverride = args['project-id'] as string | undefined; const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('mutate', 'nexus', 'refresh-bridge', { repoPath, @@ -2225,7 +2225,7 @@ const routeMapCommand = defineCommand({ const startTime = Date.now(); const projectIdOverride = args['project-id'] as string | undefined; const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('query', 'nexus', 'route-map', { projectId }); const durationMs = Date.now() - startTime; if (!response.success) { @@ -2285,7 +2285,7 @@ const shapeCheckCommand = defineCommand({ const routeSymbol = args.routeSymbol as string; const projectIdOverride = args['project-id'] as string | undefined; const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('query', 'nexus', 'shape-check', { routeSymbol, projectId }); const durationMs = Date.now() - startTime; if (!response.success) { @@ -2762,7 +2762,7 @@ const contractsSyncCommand = defineCommand({ const startTime = Date.now(); const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); const projectIdOverride = args['project-id'] as string | undefined; - const projectId = projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, projectIdOverride); const response = await dispatchRaw('mutate', 'nexus', 'contracts-sync', { projectId, repoPath, @@ -2875,7 +2875,7 @@ const contractsLinkTasksCommand = defineCommand({ applyJsonFlag(args.json as boolean | undefined); const startTime = Date.now(); const repoPath = args.path ? path.resolve(args.path as string) : getProjectRoot(); - const projectId = Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath); const response = await dispatchRaw('mutate', 'nexus', 'contracts-link-tasks', { projectId, repoPath, diff --git a/packages/cleo/src/dispatch/domains/nexus.ts b/packages/cleo/src/dispatch/domains/nexus.ts index bd5f746bca..c36dcfd8a2 100644 --- a/packages/cleo/src/dispatch/domains/nexus.ts +++ b/packages/cleo/src/dispatch/domains/nexus.ts @@ -1,3 +1,4 @@ +import { resolveNexusQueryProjectId } from '@cleocode/core/nexus/registry.js'; /** * Nexus Domain Handler (Dispatch Layer) * @@ -306,8 +307,7 @@ const _nexusTypedHandler = defineTypedHandler('nexus', { 'route-map': async (params) => { const projectRoot = getProjectRoot(); - const projectId = - params.projectId ?? Buffer.from(projectRoot).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(projectRoot, params.projectId); return wrapCoreResult(await nexusRouteMap(projectId, projectRoot), 'route-map'); }, @@ -315,8 +315,7 @@ const _nexusTypedHandler = defineTypedHandler('nexus', { if (!params.routeSymbol) return lafsError('E_INVALID_INPUT', 'routeSymbol is required', 'shape-check'); const projectRoot = getProjectRoot(); - const projectId = - params.projectId ?? Buffer.from(projectRoot).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(projectRoot, params.projectId); return wrapCoreResult( await nexusShapeCheck(params.routeSymbol, projectId, projectRoot), 'shape-check', @@ -375,27 +374,21 @@ const _nexusTypedHandler = defineTypedHandler('nexus', { clusters: async (params) => { const projectRoot = getProjectRoot(); const repoPath = (params.repoPath as string | undefined) ?? projectRoot; - const projectId = - (params.projectId as string | undefined) ?? - Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, params.projectId); return wrapCoreResult(await nexusClusters(projectId, repoPath), 'clusters'); }, flows: async (params) => { const projectRoot = getProjectRoot(); const repoPath = (params.repoPath as string | undefined) ?? projectRoot; - const projectId = - (params.projectId as string | undefined) ?? - Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, params.projectId); return wrapCoreResult(await nexusFlows(projectId, repoPath), 'flows'); }, context: async (params) => { if (!params.symbol) return lafsError('E_INVALID_INPUT', 'symbol is required', 'context'); const projectRoot = getProjectRoot(); - const projectId = - (params.projectId as string | undefined) ?? - Buffer.from(projectRoot).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(projectRoot, params.projectId); const limit = typeof params.limit === 'number' ? params.limit : 20; const showContent = params.content === true; return wrapCoreResult( @@ -613,14 +606,14 @@ const _nexusTypedHandler = defineTypedHandler('nexus', { 'contracts-sync': async (params) => { const projectRoot = getProjectRoot(); const repoPath = params.repoPath ?? projectRoot; - const projectId = params.projectId ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, params.projectId); return wrapCoreResult(await nexusContractsSync(projectId, repoPath), 'contracts-sync'); }, 'contracts-link-tasks': async (params) => { const projectRoot = getProjectRoot(); const repoPath = params.repoPath ?? projectRoot; - const projectId = params.projectId ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, params.projectId); return wrapCoreResult( await nexusContractsLinkTasks(projectId, repoPath), 'contracts-link-tasks', diff --git a/packages/contracts/src/doctor.ts b/packages/contracts/src/doctor.ts index 299056101d..57b2981ccf 100644 --- a/packages/contracts/src/doctor.ts +++ b/packages/contracts/src/doctor.ts @@ -699,11 +699,10 @@ export interface DbSubstrateProjectSurvey { /** Absolute path to the project root that was surveyed. */ projectRoot: string; /** - * Stable identifier for the project — currently the `base64url(path)` - * truncated to 32 chars, matching the convention used by `cleo nexus` - * project resolution. + * Declared portable project identity, or null when identity is missing. + * A survey never creates an identity or derives it from a filesystem path. */ - projectId: string; + projectId: string | null; /** Per-role survey result, keyed by canonical role name. */ dbs: Readonly>; } diff --git a/packages/core/src/doctor/db-substrate.ts b/packages/core/src/doctor/db-substrate.ts index 63b324eecc..48ba049211 100644 --- a/packages/core/src/doctor/db-substrate.ts +++ b/packages/core/src/doctor/db-substrate.ts @@ -226,16 +226,13 @@ function composeSuggestedFix(role: string, quarantineDir: string | null): string } /** - * Compute the stable project-id used to identify a project in - * cross-project surveys. Matches the convention used by - * `cleo nexus`: `base64url(path).slice(0, 32)`. + * Read the declared portable identity for a cross-project survey. * - * @param projectRoot - Absolute path to the project root. - * @returns A 32-character base64url-encoded slice of the path. + * @param projectRoot - Absolute checkout path. + * @returns The declared id, or null for an undeclared project. No identity is minted. */ -export function computeSubstrateProjectId(projectRoot: string): string { - // Buffer is available in Node — no need for `crypto`. - return Buffer.from(projectRoot).toString('base64url').slice(0, 32); +export function computeSubstrateProjectId(projectRoot: string): string | null { + return readDeclaredProjectIdentity(projectRoot)?.projectId ?? null; } /** @@ -1036,9 +1033,8 @@ export function checkInvariantI2( * * @remarks * The registry key is the project's declared identity (`.cleo/project-id`, - * then `project-info.json`). Only a project that declares none falls back to - * the legacy `base64url(path).slice(0, 32)`, which several projects under one - * path prefix share (T12589). The invariant asserts that the nexus registry's + * then `project-info.json`). Undeclared identity is reported as incomplete; + * a path-derived alias cannot identify the project. The invariant asserts that the registry's * recorded `project_path` MATCHES the live project root for that id. * * Detected drift: a single nexus row whose `project_path` no longer @@ -1069,11 +1065,10 @@ export function checkInvariantI3( ); } - // T12589: registry rows are keyed by the DECLARED id. The legacy - // base64url(path) key encodes only 24 path bytes, so every project under a - // shared prefix derives it — look it up only when nothing is declared. - const expectedProjectId = - readDeclaredProjectIdentity(projectRoot)?.projectId ?? computeSubstrateProjectId(projectRoot); + const expectedProjectId = computeSubstrateProjectId(projectRoot); + if (expectedProjectId === null) { + return buildSkippedReport('I3', description, I3_FIX, 'project declares no portable identity'); + } type RegistryRow = { project_id: string; project_path: string }; let row: RegistryRow | undefined; diff --git a/packages/core/src/nexus/__tests__/registry.test.ts b/packages/core/src/nexus/__tests__/registry.test.ts index 0ceee4c40f..9d94e5086b 100644 --- a/packages/core/src/nexus/__tests__/registry.test.ts +++ b/packages/core/src/nexus/__tests__/registry.test.ts @@ -9,6 +9,7 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Task } from '@cleocode/contracts'; +import { WarningCollector, withWarningCollector } from '@cleocode/lafs'; import { readPortableProjectId, resolveProjectByCwd, @@ -21,13 +22,16 @@ import { awaitBackgroundOps } from '../../store/background-ops.js'; import * as dataAccessors from '../../store/data-accessor.js'; import { resolveDualScopeDbPath } from '../../store/dual-scope-db.js'; import { getNexusDb, getNexusNativeDb } from '../../store/nexus-sqlite.js'; -import { projectIdAliases, projectRegistry } from '../../store/schema/nexus-schema.js'; +import { nexusNodes, projectIdAliases, projectRegistry } from '../../store/schema/nexus-schema.js'; import { closeAllDatabases, resetDbState } from '../../store/sqlite.js'; import { createSqliteDataAccessor } from '../../store/sqlite-data-accessor.js'; +import { nexusContractsShow } from '../api-contracts.js'; +import { getSymbolContext } from '../context.js'; import { generateProjectHash } from '../hash.js'; -import { canonicalProjectId, projectPathFingerprint } from '../identity.js'; +import { canonicalProjectId, legacyProjectId, projectPathFingerprint } from '../identity.js'; import { nexusGetProject, + nexusGetProjectById, nexusInit, nexusList, nexusProjectExists, @@ -38,6 +42,7 @@ import { nexusUnregister, readRegistry, resetNexusDbState, + resolveNexusQueryProjectId, } from '../registry.js'; /** Create a test project with tasks in SQLite (tasks.db). */ @@ -111,6 +116,162 @@ afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); +describe('portable query identity (T12472)', () => { + const portableId = '7f6b09d3-0b65-487d-a35e-e251b6c5df50'; + + it('reads tracked identity before a conflicting cache, without registration', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + await writeFile( + join(projectDir, '.cleo/project-info.json'), + JSON.stringify({ projectId: 'stale-cache' }), + ); + expect(await resolveNexusQueryProjectId(projectDir)).toBe(portableId); + expect(await resolveNexusQueryProjectId(projectDir, portableId)).toBe(portableId); + expect(await nexusList()).toEqual([]); + }); + + it('refuses missing identity rather than manufacturing a path-based id', async () => { + await expect(resolveNexusQueryProjectId(projectDir)).rejects.toThrow('declares no identity'); + }); + + it('resolves a recorded legacy id with an envelope deprecation warning', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + await nexusRegister(projectDir, 'portable-project', 'read'); + const alias = legacyProjectId('/previous-device/portable-project'); + const db = await getNexusDb(); + db.insert(projectIdAliases) + .values({ legacyId: alias, canonicalId: portableId, createdAt: new Date().toISOString() }) + .run(); + const warnings = new WarningCollector(); + expect( + await withWarningCollector(warnings, () => resolveNexusQueryProjectId(projectDir, alias)), + ).toBe(portableId); + expect(warnings.drain()).toEqual([ + expect.objectContaining({ + code: 'W_NEXUS_LEGACY_PROJECT_ID', + context: { alias, projectId: portableId }, + }), + ]); + }); + + it('does not interpret a project name, hash, or unresolved id as --project-id', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + const hash = await nexusRegister(projectDir, 'portable-name', 'read'); + for (const selector of ['portable-name', hash, 'unregistered-id']) { + expect(await nexusGetProjectById(projectDir, selector)).toBeNull(); + await expect(resolveNexusQueryProjectId(projectDir, selector)).rejects.toThrow( + 'cannot select the graph', + ); + } + }); + + it('refuses a registered foreign id and its alias', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + await nexusRegister(projectDir, 'local-project', 'read'); + const foreign = join(testDir, 'foreign'); + await createTestProjectDb(foreign, []); + await writeFile(join(foreign, '.cleo/project-id'), 'foreign-project-id\n'); + await nexusRegister(foreign, 'foreign-project', 'read'); + const alias = legacyProjectId('/foreign-device/project'); + const db = await getNexusDb(); + db.insert(projectIdAliases) + .values({ + legacyId: alias, + canonicalId: 'foreign-project-id', + createdAt: new Date().toISOString(), + }) + .run(); + await expect(resolveNexusQueryProjectId(projectDir, 'foreign-project-id')).rejects.toThrow( + 'cannot select the graph', + ); + await expect(resolveNexusQueryProjectId(projectDir, alias)).rejects.toThrow( + 'cannot select the graph', + ); + }); + + it('refuses a legacy prefix collision instead of choosing the alias row owner', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + await nexusRegister(projectDir, 'first-project', 'read'); + const sibling = join(testDir, 'sibling'); + await createTestProjectDb(sibling, []); + await writeFile(join(sibling, '.cleo/project-id'), 'sibling-project-id\n'); + await nexusRegister(sibling, 'second-project', 'read'); + const alias = legacyProjectId(projectDir); + expect(legacyProjectId(sibling)).toBe(alias); + await expect(resolveNexusQueryProjectId(projectDir, alias)).rejects.toMatchObject({ + codeName: 'E_NEXUS_PROJECT_AMBIGUOUS', + }); + }); + + it('queries the retained graph after a checkout moves without analyzing again', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + vi.stubEnv('CLEO_ROOT', projectDir); + const db = await getNexusDb(projectDir); + db.insert(nexusNodes) + .values({ + id: 'src/record.ts::stopCapture', + kind: 'function', + label: 'stopCapture', + name: 'stopCapture', + filePath: 'src/record.ts', + }) + .run(); + const before = await getSymbolContext( + 'stopCapture', + await resolveNexusQueryProjectId(projectDir), + projectDir, + ); + await closeAllDatabases(); + resetDbState(); + resetNexusDbState(); + const moved = join(testDir, 'moved-checkout'); + await rename(projectDir, moved); + vi.stubEnv('CLEO_ROOT', moved); + const after = await getSymbolContext( + 'stopCapture', + await resolveNexusQueryProjectId(moved, portableId), + moved, + ); + expect(after).toEqual(before); + expect(after.projectId).toBe(portableId); + expect(after.matchCount).toBe(1); + }); + + it('compares separate registered graphs by portable id, without decoding ids into paths', async () => { + await writeFile(join(projectDir, '.cleo/project-id'), `${portableId}\n`); + await nexusRegister(projectDir, 'contract-a', 'read'); + const second = join(testDir, 'contracts-b'); + await createTestProjectDb(second, []); + await writeFile(join(second, '.cleo/project-id'), 'contract-project-b\n'); + await nexusRegister(second, 'contract-b', 'read'); + for (const [index, root] of [projectDir, second].entries()) { + const db = await getNexusDb(root); + db.insert(nexusNodes) + .values({ + id: `route-${index}`, + kind: 'route', + label: `route-${index}`, + metaJson: JSON.stringify({ + method: 'GET', + path: '/actions', + responseSchema: { project: index }, + }), + }) + .run(); + } + const result = await nexusContractsShow(portableId, 'contract-project-b', testDir); + expect(result.success).toBe(true); + if (!result.success || !result.data) throw new Error('Expected contract comparison result'); + expect(result.data.projectAId).toBe(portableId); + expect(result.data.projectBId).toBe('contract-project-b'); + expect(result.data.matches).toHaveLength(1); + expect(result.data.matches[0]?.contractA.sourceSymbolId).toBe('route-0'); + expect(result.data.matches[0]?.contractB.sourceSymbolId).toBe('route-1'); + const unresolved = await nexusContractsShow('missing-project-id', portableId, testDir); + expect(unresolved.success).toBe(false); + }); +}); + describe('generateProjectHash — within nexus registry', () => { it('returns a 12-character hex string', () => { const hash = generateProjectHash('/some/path'); diff --git a/packages/core/src/nexus/api-contracts.ts b/packages/core/src/nexus/api-contracts.ts index 3f7a03084b..030d34299f 100644 --- a/packages/core/src/nexus/api-contracts.ts +++ b/packages/core/src/nexus/api-contracts.ts @@ -29,6 +29,9 @@ import { matchContracts, } from './api-extractors/index.js'; import { assessKnowledgeCoverage, recordKnowledgeGap } from './knowledge.js'; +import { projectHolding } from './path-map.js'; +import { requirePermission } from './permissions.js'; +import { nexusGetProjectById } from './registry.js'; import { getTaskKnowledgeEvidence } from './task-evidence.js'; import { getSymbolsForTask, runGitLogTaskLinker } from './tasks-bridge.js'; @@ -86,16 +89,34 @@ export async function nexusContractsShow( projectRoot: string, ): Promise> { try { - const repoPathA = Buffer.from(projectAId, 'base64url').toString() || projectRoot; - const repoPathB = Buffer.from(projectBId, 'base64url').toString() || projectRoot; + const projectA = await nexusGetProjectById(projectRoot, projectAId); + const projectB = await nexusGetProjectById(projectRoot, projectBId); + if (!projectA || !projectB) { + // @sync-invariant none:local-only contract comparison requires registered query targets, never a guessed filesystem path + return engineError('E_NOT_FOUND', 'Both project ids must resolve to registered projects.'); + } + if ( + projectHolding(projectA.path, projectA.projectId) !== 'yes' || + projectHolding(projectB.path, projectB.projectId) !== 'yes' + ) { + // @sync-invariant none:local-only missing or mismatched local checkouts cannot supply graph evidence + return engineError( + 'E_NOT_FOUND', + 'A registered project checkout is unavailable or has a different identity. Run cleo doctor projects.', + ); + } + await requirePermission(projectA.projectId, 'read', 'contracts.show'); + await requirePermission(projectB.projectId, 'read', 'contracts.show'); - const [httpA, grpcA, topicA, httpB, grpcB, topicB] = await Promise.all([ - extractHttpContracts(projectAId, repoPathA), - extractGrpcContracts(projectAId, repoPathA), - extractTopicContracts(projectAId, repoPathA), - extractHttpContracts(projectBId, repoPathB), - extractGrpcContracts(projectBId, repoPathB), - extractTopicContracts(projectBId, repoPathB), + const [httpA, grpcA, topicA] = await Promise.all([ + extractHttpContracts(projectA.projectId, projectA.path), + extractGrpcContracts(projectA.projectId, projectA.path), + extractTopicContracts(projectA.projectId, projectA.path), + ]); + const [httpB, grpcB, topicB] = await Promise.all([ + extractHttpContracts(projectB.projectId, projectB.path), + extractGrpcContracts(projectB.projectId, projectB.path), + extractTopicContracts(projectB.projectId, projectB.path), ]); const contractsA = [...(httpA ?? []), ...(grpcA ?? []), ...(topicA ?? [])]; @@ -109,8 +130,8 @@ export async function nexusContractsShow( matches.length > 0 ? Math.round((compatibleCount / matches.length) * 100) : 0; const matrix: ContractCompatibilityMatrix = { - projectAId, - projectBId, + projectAId: projectA.projectId, + projectBId: projectB.projectId, matches, compatibleCount, incompatibleCount, diff --git a/packages/core/src/nexus/api-extractors/http-extractor.test.ts b/packages/core/src/nexus/api-extractors/http-extractor.test.ts index 5709bf9946..7f5fee2520 100644 --- a/packages/core/src/nexus/api-extractors/http-extractor.test.ts +++ b/packages/core/src/nexus/api-extractors/http-extractor.test.ts @@ -4,7 +4,7 @@ * @task T1065 */ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HttpContract } from '@cleocode/contracts/nexus-contract-ops.js'; @@ -179,4 +179,26 @@ describe('HTTP Contract Extractor', () => { expect(contracts[1].method).toBe('POST'); expect(contracts[2].path).toBe('/api/v1/tasks/:id'); }); + it('reads each explicit project graph rather than relabeling the ambient graph', async () => { + delete process.env['CLEO_DIR']; + const roots = [join(tmpDir, 'project-a'), join(tmpDir, 'project-b')]; + for (const [index, root] of roots.entries()) { + mkdirSync(join(root, '.cleo'), { recursive: true }); + const db = await getNexusDb(root); + db.insert(nexusSchema.nexusNodes) + .values({ + id: `route-${index}`, + kind: 'route', + label: `route-${index}`, + metaJson: JSON.stringify({ method: 'GET', path: `/project-${index}` }), + }) + .run(); + } + const first = await extractHttpContracts('portable-a', roots[0]!); + const second = await extractHttpContracts('portable-b', roots[1]!); + expect(first.map((contract) => contract.path)).toEqual(['/project-0']); + expect(second.map((contract) => contract.path)).toEqual(['/project-1']); + expect(first[0]?.projectId).toBe('portable-a'); + expect(second[0]?.projectId).toBe('portable-b'); + }); }); diff --git a/packages/core/src/nexus/api-extractors/http-extractor.ts b/packages/core/src/nexus/api-extractors/http-extractor.ts index 23dd202cbc..4d10425693 100644 --- a/packages/core/src/nexus/api-extractors/http-extractor.ts +++ b/packages/core/src/nexus/api-extractors/http-extractor.ts @@ -18,15 +18,15 @@ import type { NexusNodeRow } from '../../store/schema/nexus-schema.js'; * (path, method, request/response schemas) into HttpContract objects. * * @param projectId - Project identifier from registry - * @param _projectRoot - Root directory of the project (unused) + * @param projectRoot - Explicit checkout whose graph is queried. * @returns Promise resolving to array of HttpContract objects */ export async function extractHttpContracts( projectId: string, - _projectRoot: string, + projectRoot: string, ): Promise { const { getNexusDb, nexusSchema } = await import('../../store/nexus-sqlite.js'); - const db = await getNexusDb(); + const db = await getNexusDb(projectRoot); try { // Query all route nodes (project-scoped DB — ADR-090 · T11648: no project_id). diff --git a/packages/core/src/nexus/diff.ts b/packages/core/src/nexus/diff.ts index 13c45f3b5b..329b495c33 100644 --- a/packages/core/src/nexus/diff.ts +++ b/packages/core/src/nexus/diff.ts @@ -1,3 +1,4 @@ +import { resolveNexusQueryProjectId } from './registry.js'; /** * NEXUS index diff — compare relation/node counts between two git commits. * @@ -24,7 +25,7 @@ export interface NexusDiffOptions { beforeRef?: string; /** Git ref for the "after" snapshot (default: 'HEAD'). */ afterRef?: string; - /** Override the project ID (default: derived from repoPath). */ + /** Override the project ID (default: declared portable identity; legacy aliases are resolved). */ projectIdOverride?: string; } @@ -85,8 +86,7 @@ export async function diffNexusIndex( repoPath: string, opts: NexusDiffOptions = {}, ): Promise { - const projectId = - opts.projectIdOverride ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const projectId = await resolveNexusQueryProjectId(repoPath, opts.projectIdOverride); const { execFile: execFileNode } = await import('node:child_process'); const { promisify } = await import('node:util'); const execFileAsync = promisify(execFileNode); diff --git a/packages/core/src/nexus/identity.ts b/packages/core/src/nexus/identity.ts index bc8c647e37..961c168efe 100644 --- a/packages/core/src/nexus/identity.ts +++ b/packages/core/src/nexus/identity.ts @@ -20,11 +20,31 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { ExitCode } from '@cleocode/contracts'; import type { OperationExecutionContext } from '@cleocode/contracts/jobs'; -import { legacyProjectId } from '@cleocode/paths'; +import { legacyProjectId, readDeclaredProjectIdentity } from '@cleocode/paths'; +import { CleoError } from '../errors.js'; const execFileAsync = promisify(execFile); +/** + * Read the project's declared, portable identity without creating or migrating it. + * + * @param projectRoot - Checkout whose tracked identity (or legacy cache) is read. + * @returns The declared id, independent of the checkout's current path. + * @throws {CleoError} When identity is missing; a path fingerprint is never a fallback. + */ +export function requireNexusProjectId(projectRoot: string): string { + const identity = readDeclaredProjectIdentity(projectRoot); + if (identity) return identity.projectId; + // @sync-invariant none:local-only a query requires local declared identity; this does not create or re-key a synced row + throw new CleoError( + ExitCode.CONFIG_ERROR, + `Project at ${projectRoot} declares no identity; refusing to derive one from its path.`, + { fix: 'Run cleo doctor project-identity, or cleo init for a new project.' }, + ); +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/packages/core/src/nexus/index.ts b/packages/core/src/nexus/index.ts index 109b19b9c2..c8001d4a13 100644 --- a/packages/core/src/nexus/index.ts +++ b/packages/core/src/nexus/index.ts @@ -232,6 +232,7 @@ export { type NexusProjectStats, type NexusRegistryFile, nexusGetProject, + nexusGetProjectById, nexusInit, // EngineResult wrappers (T1569) nexusInitialize, @@ -261,6 +262,7 @@ export { readRegistry, readRegistryRequired, resetNexusDbState, + resolveNexusQueryProjectId, } from './registry.js'; // Registry errors - typed read failures (T12512 · T12513) export { diff --git a/packages/core/src/nexus/nexus-bridge.ts b/packages/core/src/nexus/nexus-bridge.ts index 3904c69ede..aec7a7ad9c 100644 --- a/packages/core/src/nexus/nexus-bridge.ts +++ b/packages/core/src/nexus/nexus-bridge.ts @@ -27,6 +27,7 @@ import type { DatabaseSync } from 'node:sqlite'; import { pushWarning } from '@cleocode/lafs'; import { type EngineResult, engineError, engineSuccess } from '../engine-result.js'; import { resolveBridgeMode } from '../system/bridge-mode.js'; +import { resolveNexusQueryProjectId } from './registry.js'; // ============================================================================ // Types @@ -365,10 +366,6 @@ export async function writeNexusBridge( const cleoDir = join(projectRoot, '.cleo'); const bridgePath = join(cleoDir, 'nexus-bridge.md'); - // Derive project ID from path if not provided (matches nexus analyze convention) - const resolvedProjectId = - projectId ?? Buffer.from(projectRoot).toString('base64url').slice(0, 32); - try { // Mode gate (T999 · T1013, extended in T9425): only write when mode='file'. // Aligns nexus-bridge with the existing memory-bridge gate so both siblings @@ -380,6 +377,7 @@ export async function writeNexusBridge( return { path: bridgePath, written: false }; } + const resolvedProjectId = await resolveNexusQueryProjectId(projectRoot, projectId); const content = await generateNexusBridgeContent(resolvedProjectId, projectRoot); if (!existsSync(cleoDir)) { @@ -475,7 +473,7 @@ export async function nexusRefreshBridge( ): Promise> { try { const result = await writeNexusBridge(repoPath, projectId); - const resolvedProjectId = projectId ?? Buffer.from(repoPath).toString('base64url').slice(0, 32); + const resolvedProjectId = await resolveNexusQueryProjectId(repoPath, projectId); return engineSuccess({ path: result.path, written: result.written, diff --git a/packages/core/src/nexus/registry.ts b/packages/core/src/nexus/registry.ts index c0839c0d68..9eb30a9ff0 100644 --- a/packages/core/src/nexus/registry.ts +++ b/packages/core/src/nexus/registry.ts @@ -55,7 +55,12 @@ import { import { ensureCheckoutNonce } from './checkout-nonce.js'; import { listNexusDevices } from './devices.js'; import { generateProjectHash } from './hash.js'; -import { collectCheckoutEvidence, legacyProjectId, projectPathFingerprint } from './identity.js'; +import { + collectCheckoutEvidence, + legacyProjectId, + projectPathFingerprint, + requireNexusProjectId, +} from './identity.js'; import { registryAliasClaimants, resolveProjectAlias } from './legacy-alias.js'; import { isSupersededRegistryPath, @@ -794,6 +799,88 @@ export class NexusProjectAmbiguityError extends CleoError { } } +/** + * Look up a portable project id or an unambiguous recorded legacy alias. + * Names and path hashes are not project ids and are not accepted here. + * + * @param projectRoot - Calling checkout (consistent with the registry API). + * @param projectId - Portable id, or a recorded alias retained for compatibility. + * @returns The registered project, or null when the id is not registered. + * @throws {NexusProjectAmbiguityError} When several projects claim an alias. + * @throws {NexusRegistryReadError} When registry access fails. + */ +export async function nexusGetProjectById( + _projectRoot: string, + projectId: string, +): Promise { + try { + const row = await withLiveNexusDb(async (db) => { + const exact = db + .select() + .from(projectRegistry) + .where(eq(projectRegistry.projectId, projectId)) + .get(); + if (exact) return exact; + const alias = resolveProjectAlias(db, projectId); + if (alias.status === 'none') return null; + if (alias.status === 'ambiguous') { + const claimants = db + .select() + .from(projectRegistry) + .where(inArray(projectRegistry.projectId, [...alias.claimants])) + .all(); + // @sync-invariant none:local-only ambiguous query selectors cannot select a local registry project + throw new NexusProjectAmbiguityError(projectId, claimants, 'alias'); + } + const canonical = db + .select() + .from(projectRegistry) + .where(eq(projectRegistry.projectId, alias.canonicalId)) + .get(); + if (canonical) { + pushWarning({ + // @sync-invariant none:local-only a successful legacy query resolution emits an advisory, not a synced write rejection + code: 'W_NEXUS_LEGACY_PROJECT_ID', + severity: 'warn', + message: `Project alias '${projectId}' is deprecated; use '${canonical.projectId}'.`, + context: { alias: projectId, projectId: canonical.projectId }, + }); + } + return canonical ?? null; + }); + return row ? rowToProject(row) : null; + } catch (error) { + // @sync-invariant none:local-only a registry query failure remains a typed read error, never an empty result + throw toRegistryReadError('get project by id', error); + } +} + +/** + * Bind a project-scoped graph query to its checkout's portable identity. + * An override may name that identity or its unique legacy alias, never another + * project: the graph store cannot be selected by relabeling its counts. + * + * @param projectRoot - Checkout whose project-scoped graph will be queried. + * @param requestedId - Optional explicit portable id or recorded legacy alias. + * @returns The current checkout's canonical id. + * @throws {CleoError} On missing identity or a foreign/unresolved override. + */ +export async function resolveNexusQueryProjectId( + projectRoot: string, + requestedId?: string, +): Promise { + const projectId = requireNexusProjectId(projectRoot); + if (requestedId === undefined || requestedId === projectId) return projectId; + const registered = await nexusGetProjectById(projectRoot, requestedId); + if (registered?.projectId === projectId) return projectId; + // @sync-invariant none:local-only a foreign query id cannot relabel the current project graph; no synced write occurs + throw new CleoError( + ExitCode.INVALID_INPUT, + `Project '${requestedId}' cannot select the graph for '${projectId}' at ${projectRoot}.`, + { fix: 'Run the query from the intended project, using its portable project id.' }, + ); +} + /** * Get a project by name or hash. * Returns null if not found. diff --git a/packages/skills/skills/ct-codebase-mapper/SKILL.md b/packages/skills/skills/ct-codebase-mapper/SKILL.md index 95ef12e6be..72310cee7d 100644 --- a/packages/skills/skills/ct-codebase-mapper/SKILL.md +++ b/packages/skills/skills/ct-codebase-mapper/SKILL.md @@ -1,6 +1,6 @@ --- name: ct-codebase-mapper -version: 2.1.0 +version: 2.1.1 description: Orient in an unfamiliar or large codebase with CLEO's code-intelligence graph (cleo nexus) and project map (cleo map). Use before planning or editing unfamiliar code, for brownfield onboarding, to find what a change would break, or to map a project's structure, communities and execution flows. Triggers on "map the codebase", "understand this project", "what calls X", "what would break", "brownfield analysis", "project structure". protocol: null dependencies: [] @@ -17,13 +17,13 @@ triggers: - project structure - blast radius metadata: - version: 2.1.0 + version: 2.1.1 tier: on-demand install: harness covers: - packages/cleo/src/cli/commands/nexus.ts - packages/cleo/src/cli/commands/map.ts - lastReviewed: 2026-09-29 + lastReviewed: 2026-10-02 stability: stable --- @@ -50,6 +50,12 @@ Queries auto-refresh up to 25 stale files; beyond that they warn rebuilds). An empty or stale index gives confident-looking but incomplete answers — say which you relied on. +Project-scoped queries use the declared portable project id. `--project-id` +may name that id or a recorded, unambiguous legacy alias; an alias warns +`W_NEXUS_LEGACY_PROJECT_ID`. A foreign or colliding id is refused: run the +query from the intended checkout. Moving a checkout retains its id and graph; +never derive a replacement id from the new path or re-analyze solely for a move. + ## 2. Survey the project ```bash diff --git a/packages/skills/skills/manifest.json b/packages/skills/skills/manifest.json index dec7417f1d..0cd0f36bfc 100644 --- a/packages/skills/skills/manifest.json +++ b/packages/skills/skills/manifest.json @@ -302,7 +302,7 @@ }, { "name": "ct-codebase-mapper", - "version": "2.1.0", + "version": "2.1.1", "description": "Orient in an unfamiliar or large codebase with CLEO's code-intelligence graph (cleo nexus) and project map (cleo map). Use before planning or editing unfamiliar code, for brownfield onboarding, to find what a change would break, or to map a project's structure, communities and execution flows. Triggers on \"map the codebase\", \"understand this project\", \"what calls X\", \"what would break\", \"brownfield analysis\", \"project structure\".", "path": "skills/ct-codebase-mapper", "tier": 1, From bc9022d313ed8a1ba48a58eb657a0f199b1e0f1c Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 2 Oct 2026 18:35:27 -0700 Subject: [PATCH 2/4] fix(nexus): open query stores at their declared checkout T12472: preserve dispatch and bridge regression assertions with initialized or explicit mock identities; require the warning test to reach EISDIR. --- .../nexus-code-intel-dispatch.test.ts | 23 +++++++++++--- ...nexus-contracts-ingestion-dispatch.test.ts | 14 +++++++-- .../__tests__/nexus-phase2-dispatch.test.ts | 7 +++++ .../__tests__/nexus-bridge-mode-gate.test.ts | 1 + .../__tests__/nexus-bridge-warnings.test.ts | 2 ++ .../core/src/nexus/__tests__/registry.test.ts | 30 +++++++++++++++++++ packages/core/src/nexus/clusters.ts | 2 +- packages/core/src/nexus/diff.ts | 2 +- packages/core/src/nexus/flows.ts | 2 +- packages/core/src/nexus/nexus-bridge.ts | 2 +- 10 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/cleo/src/dispatch/domains/__tests__/nexus-code-intel-dispatch.test.ts b/packages/cleo/src/dispatch/domains/__tests__/nexus-code-intel-dispatch.test.ts index ad77bbce42..ba068443c9 100644 --- a/packages/cleo/src/dispatch/domains/__tests__/nexus-code-intel-dispatch.test.ts +++ b/packages/cleo/src/dispatch/domains/__tests__/nexus-code-intel-dispatch.test.ts @@ -15,6 +15,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +// Engine wiring is tested here; registry.test.ts covers real identity reads, +// aliases and refusals. The mock checkout has a controlled canonical identity. +vi.mock('@cleocode/core/nexus/registry.js', async (original) => ({ + ...(await original()), + resolveNexusQueryProjectId: vi.fn(async () => 'mock-project-id'), +})); + // Mock core internals used by the handler // Mock the nexus-engine — stub all functions referenced by NexusHandler vi.mock('@cleocode/core/internal', async () => ({ @@ -68,6 +75,7 @@ import { nexusShapeCheck, nexusWiki, } from '@cleocode/core/internal'; +import { resolveNexusQueryProjectId } from '@cleocode/core/nexus/registry.js'; import { NexusHandler } from '../nexus.js'; // --------------------------------------------------------------------------- @@ -154,11 +162,10 @@ describe('NexusHandler — T1116 Code Intelligence CLI surface', () => { expect(result.data).toMatchObject({ routes: expect.arrayContaining([expect.objectContaining({ handlerName: 'getUserById' })]), }); - // projectId auto-derived from projectRoot when not provided - expect(vi.mocked(nexusRouteMap)).toHaveBeenCalledWith(expect.any(String), '/mock/project'); + expect(vi.mocked(nexusRouteMap)).toHaveBeenCalledWith('mock-project-id', '/mock/project'); }); - it('uses provided projectId when supplied', async () => { + it('resolves a provided selector before passing canonical identity to the engine', async () => { vi.mocked(nexusRouteMap).mockResolvedValue({ success: true, data: ROUTE_MAP_RESULT_FIXTURE, @@ -166,7 +173,8 @@ describe('NexusHandler — T1116 Code Intelligence CLI surface', () => { await handler.query('route-map', { projectId: 'explicit-id' }); - expect(vi.mocked(nexusRouteMap)).toHaveBeenCalledWith('explicit-id', '/mock/project'); + expect(resolveNexusQueryProjectId).toHaveBeenCalledWith('/mock/project', 'explicit-id'); + expect(vi.mocked(nexusRouteMap)).toHaveBeenCalledWith('mock-project-id', '/mock/project'); }); it('propagates engine error to LAFS envelope', async () => { @@ -180,6 +188,13 @@ describe('NexusHandler — T1116 Code Intelligence CLI surface', () => { expect(result.success).toBe(false); expect(result.error?.code).toBe('E_INTERNAL'); }); + + it('does not call the graph engine when identity resolution refuses the selector', async () => { + vi.mocked(resolveNexusQueryProjectId).mockRejectedValueOnce(new Error('identity refused')); + const result = await handler.query('route-map', { projectId: 'foreign-selector' }); + expect(result.success).toBe(false); + expect(nexusRouteMap).not.toHaveBeenCalled(); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/cleo/src/dispatch/domains/__tests__/nexus-contracts-ingestion-dispatch.test.ts b/packages/cleo/src/dispatch/domains/__tests__/nexus-contracts-ingestion-dispatch.test.ts index edcf57ff2a..56a7d97b04 100644 --- a/packages/cleo/src/dispatch/domains/__tests__/nexus-contracts-ingestion-dispatch.test.ts +++ b/packages/cleo/src/dispatch/domains/__tests__/nexus-contracts-ingestion-dispatch.test.ts @@ -18,6 +18,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +// These tests verify engine wiring with a mock checkout. Real-store identity +// and alias validation are covered in registry.test.ts. +vi.mock('@cleocode/core/nexus/registry.js', async (original) => ({ + ...(await original()), + resolveNexusQueryProjectId: vi.fn(async () => 'canonical-ingestion-id'), +})); + // Mock core internals used by the handler // Mock the nexus-engine — stub all functions referenced by NexusHandler vi.mock('@cleocode/core/internal', async () => ({ @@ -323,7 +330,7 @@ describe('NexusHandler — T1117 Contracts + Ingestion Bridge', () => { ); }); - it('uses explicit projectId when provided', async () => { + it('uses the resolved canonical projectId for an explicit selector', async () => { vi.mocked(nexusContractsSync).mockResolvedValue({ success: true, data: CONTRACTS_SYNC_FIXTURE, @@ -334,7 +341,10 @@ describe('NexusHandler — T1117 Contracts + Ingestion Bridge', () => { repoPath: '/mock/project', }); - expect(vi.mocked(nexusContractsSync)).toHaveBeenCalledWith('explicit-id', '/mock/project'); + expect(vi.mocked(nexusContractsSync)).toHaveBeenCalledWith( + 'canonical-ingestion-id', + '/mock/project', + ); }); it('propagates engine error to LAFS envelope', async () => { diff --git a/packages/cleo/src/dispatch/domains/__tests__/nexus-phase2-dispatch.test.ts b/packages/cleo/src/dispatch/domains/__tests__/nexus-phase2-dispatch.test.ts index dc162b49b9..12a8b8febb 100644 --- a/packages/cleo/src/dispatch/domains/__tests__/nexus-phase2-dispatch.test.ts +++ b/packages/cleo/src/dispatch/domains/__tests__/nexus-phase2-dispatch.test.ts @@ -27,6 +27,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +// These are dispatch wiring tests; identity validation has real-store coverage +// in registry.test.ts. Keep the new boundary explicit for the mock checkout. +vi.mock('@cleocode/core/nexus/registry.js', async (original) => ({ + ...(await original()), + resolveNexusQueryProjectId: vi.fn(async () => 'mock-proj'), +})); + // Mock core internals // Mock the nexus-engine — stub all functions referenced by NexusHandler vi.mock('@cleocode/core/internal', async () => ({ diff --git a/packages/core/src/nexus/__tests__/nexus-bridge-mode-gate.test.ts b/packages/core/src/nexus/__tests__/nexus-bridge-mode-gate.test.ts index 9c0c5a6735..4d856e1ace 100644 --- a/packages/core/src/nexus/__tests__/nexus-bridge-mode-gate.test.ts +++ b/packages/core/src/nexus/__tests__/nexus-bridge-mode-gate.test.ts @@ -25,6 +25,7 @@ function writeConfigWithMode(cleoDir: string, mode: 'cli' | 'file'): void { const configPath = join(cleoDir, 'config.json'); const config = { brain: { memoryBridge: { mode } } }; writeFileSync(configPath, JSON.stringify(config), 'utf-8'); + if (mode === 'file') writeFileSync(join(cleoDir, 'project-id'), 'bridge-mode-project\n'); } let tempDir: string; diff --git a/packages/core/src/nexus/__tests__/nexus-bridge-warnings.test.ts b/packages/core/src/nexus/__tests__/nexus-bridge-warnings.test.ts index f5cac9cf12..d68e90a40d 100644 --- a/packages/core/src/nexus/__tests__/nexus-bridge-warnings.test.ts +++ b/packages/core/src/nexus/__tests__/nexus-bridge-warnings.test.ts @@ -39,6 +39,7 @@ function setupBlockedProject(): string { const projectRoot = tempDir; const cleoDir = join(projectRoot, '.cleo'); mkdirSync(cleoDir, { recursive: true }); + writeFileSync(join(cleoDir, 'project-id'), 'blocked-bridge-project\n'); writeFileSync( join(cleoDir, 'config.json'), JSON.stringify({ brain: { memoryBridge: { mode: 'file' } } }), @@ -88,6 +89,7 @@ describe('T9771 — nexus-bridge warnings → meta.warnings', () => { expect(warning!.severity).toBe('warn'); expect(warning!.context?.['bridge']).toBe('nexus'); expect(typeof warning!.context?.['error']).toBe('string'); + expect(warning!.context?.['error']).toMatch(/EISDIR/); expect(bridgeStderrCalls(stderrSpy)).toEqual([]); expect(stdoutSpy).not.toHaveBeenCalled(); diff --git a/packages/core/src/nexus/__tests__/registry.test.ts b/packages/core/src/nexus/__tests__/registry.test.ts index 9d94e5086b..dca9a44b12 100644 --- a/packages/core/src/nexus/__tests__/registry.test.ts +++ b/packages/core/src/nexus/__tests__/registry.test.ts @@ -26,7 +26,10 @@ import { nexusNodes, projectIdAliases, projectRegistry } from '../../store/schem import { closeAllDatabases, resetDbState } from '../../store/sqlite.js'; import { createSqliteDataAccessor } from '../../store/sqlite-data-accessor.js'; import { nexusContractsShow } from '../api-contracts.js'; +import { getProjectClusters } from '../clusters.js'; import { getSymbolContext } from '../context.js'; +import { diffNexusIndex } from '../diff.js'; +import { getProjectFlows } from '../flows.js'; import { generateProjectHash } from '../hash.js'; import { canonicalProjectId, legacyProjectId, projectPathFingerprint } from '../identity.js'; import { @@ -270,6 +273,33 @@ describe('portable query identity (T12472)', () => { const unresolved = await nexusContractsShow('missing-project-id', portableId, testDir); expect(unresolved.success).toBe(false); }); + + it('reads clusters, flows and diff counts from the supplied checkout, preserving the other graph', async () => { + const second = join(testDir, 'graph-b'); + await createTestProjectDb(second, []); + for (const [index, root] of [projectDir, second].entries()) { + await writeFile(join(root, '.cleo/project-id'), `graph-project-${index}\n`); + const db = await getNexusDb(root); + db.insert(nexusNodes) + .values([ + { id: `community-${index}`, kind: 'community', label: `community-${index}` }, + { id: `process-${index}`, kind: 'process', label: `process-${index}` }, + ]) + .run(); + } + for (const [index, root] of [projectDir, second].entries()) { + const id = `graph-project-${index}`; + expect((await getProjectClusters(id, root)).communities.map((entry) => entry.id)).toEqual([ + `community-${index}`, + ]); + expect((await getProjectFlows(id, root)).flows.map((entry) => entry.id)).toEqual([ + `process-${index}`, + ]); + const result = await diffNexusIndex(root, { beforeRef: 'HEAD', afterRef: 'HEAD' }); + expect(result.projectId).toBe(id); + expect(result.nodesBefore).toBe(2); + } + }); }); describe('generateProjectHash — within nexus registry', () => { diff --git a/packages/core/src/nexus/clusters.ts b/packages/core/src/nexus/clusters.ts index a4e8c85507..a1cf72a950 100644 --- a/packages/core/src/nexus/clusters.ts +++ b/packages/core/src/nexus/clusters.ts @@ -50,7 +50,7 @@ export async function getProjectClusters( projectId: string, repoPath: string, ): Promise { - const db = await getNexusDb(); + const db = await getNexusDb(repoPath); let communities: Array> = []; try { diff --git a/packages/core/src/nexus/diff.ts b/packages/core/src/nexus/diff.ts index 329b495c33..e3799d945c 100644 --- a/packages/core/src/nexus/diff.ts +++ b/packages/core/src/nexus/diff.ts @@ -124,7 +124,7 @@ export async function diffNexusIndex( } const { getNexusDb, nexusSchema } = await import('@cleocode/core/store/nexus-sqlite' as string); - const db = await getNexusDb(); + const db = await getNexusDb(repoPath); let relationsBefore = 0; let nodesBefore = 0; diff --git a/packages/core/src/nexus/flows.ts b/packages/core/src/nexus/flows.ts index 9f91d0f47c..1f3e8f42f8 100644 --- a/packages/core/src/nexus/flows.ts +++ b/packages/core/src/nexus/flows.ts @@ -51,7 +51,7 @@ export async function getProjectFlows( projectId: string, repoPath: string, ): Promise { - const db = await getNexusDb(); + const db = await getNexusDb(repoPath); let rows: Array> = []; try { diff --git a/packages/core/src/nexus/nexus-bridge.ts b/packages/core/src/nexus/nexus-bridge.ts index aec7a7ad9c..d4af81268e 100644 --- a/packages/core/src/nexus/nexus-bridge.ts +++ b/packages/core/src/nexus/nexus-bridge.ts @@ -239,7 +239,7 @@ export async function generateNexusBridgeContent( const { getNexusDb, getNexusNativeDb } = await import('../store/nexus-sqlite.js'); // Ensure DB is initialized - await getNexusDb(); + await getNexusDb(repoPath); const nativeDb = getNexusNativeDb(); if (!nativeDb) { From 0abf5ae00b00933827662e4695749836503a100c Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 2 Oct 2026 18:41:07 -0700 Subject: [PATCH 3/4] test(store): isolate the abort-cleared writer lease fixture T12472 verification: use an owned temporary store instead of /tmp/cleo.db; preserve all abort assertions and schema guards. --- .../__tests__/exodus-abort-surface.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/core/src/store/__tests__/exodus-abort-surface.test.ts b/packages/core/src/store/__tests__/exodus-abort-surface.test.ts index 1b8f5e5bbb..f76c913e32 100644 --- a/packages/core/src/store/__tests__/exodus-abort-surface.test.ts +++ b/packages/core/src/store/__tests__/exodus-abort-surface.test.ts @@ -19,6 +19,9 @@ * @saga T11242 */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertWriteDurable, @@ -33,6 +36,7 @@ import { exodusAbortEvents, getRecordedExodusAbort, } from '../exodus/abort-events.js'; +import { closeAllDatabases } from '../sqlite.js'; import { makeWriterLeaseIdentity, registerDbIdentity } from '../writer-lease.js'; function makeDetail(over: Partial = {}): ExodusAbortDetail { @@ -169,10 +173,18 @@ describe('exodus abort surface (T11828)', () => { emitExodusAbort(makeDetail()); clearExodusAborts(); const spy = makeInsertSpyDb(); - registerDbIdentity(spy.db, makeWriterLeaseIdentity('project', '/tmp/cleo.db')); - const inserted = await insertIdempotent(spy.db, {} as never, {} as never, 'idempotencyKey'); - expect(inserted).toBe(1); - expect(spy.calls).toBe(1); + const projectRoot = await mkdtemp(join(tmpdir(), 'exodus-abort-write-')); + try { + const stateDir = join(projectRoot, '.cleo'); + await mkdir(stateDir); + registerDbIdentity(spy.db, makeWriterLeaseIdentity('project', join(stateDir, 'cleo.db'))); + const inserted = await insertIdempotent(spy.db, {} as never, {} as never, 'idempotencyKey'); + expect(inserted).toBe(1); + expect(spy.calls).toBe(1); + } finally { + await closeAllDatabases(); + await rm(projectRoot, { recursive: true, force: true }); + } }); }); }); From 21ae7059071682e6b091901e069c487646b39997 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 2 Oct 2026 18:53:54 -0700 Subject: [PATCH 4/4] fix(nexus): bind bridge native reads to the requested checkout T12472: extend the independent two-store regression to verify actual rendered bridge content and exclude the other checkout. --- packages/core/src/nexus/__tests__/registry.test.ts | 6 +++++- packages/core/src/nexus/nexus-bridge.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/nexus/__tests__/registry.test.ts b/packages/core/src/nexus/__tests__/registry.test.ts index dca9a44b12..006983c96b 100644 --- a/packages/core/src/nexus/__tests__/registry.test.ts +++ b/packages/core/src/nexus/__tests__/registry.test.ts @@ -32,6 +32,7 @@ import { diffNexusIndex } from '../diff.js'; import { getProjectFlows } from '../flows.js'; import { generateProjectHash } from '../hash.js'; import { canonicalProjectId, legacyProjectId, projectPathFingerprint } from '../identity.js'; +import { generateNexusBridgeContent } from '../nexus-bridge.js'; import { nexusGetProject, nexusGetProjectById, @@ -274,7 +275,7 @@ describe('portable query identity (T12472)', () => { expect(unresolved.success).toBe(false); }); - it('reads clusters, flows and diff counts from the supplied checkout, preserving the other graph', async () => { + it('reads clusters, flows, bridge and diff counts from the supplied checkout, preserving the other graph', async () => { const second = join(testDir, 'graph-b'); await createTestProjectDb(second, []); for (const [index, root] of [projectDir, second].entries()) { @@ -295,6 +296,9 @@ describe('portable query identity (T12472)', () => { expect((await getProjectFlows(id, root)).flows.map((entry) => entry.id)).toEqual([ `process-${index}`, ]); + const bridge = await generateNexusBridgeContent(id, root); + expect(bridge).toContain(`community-${index}`); + expect(bridge).not.toContain(`community-${1 - index}`); const result = await diffNexusIndex(root, { beforeRef: 'HEAD', afterRef: 'HEAD' }); expect(result.projectId).toBe(id); expect(result.nodesBefore).toBe(2); diff --git a/packages/core/src/nexus/nexus-bridge.ts b/packages/core/src/nexus/nexus-bridge.ts index d4af81268e..f184bb3e16 100644 --- a/packages/core/src/nexus/nexus-bridge.ts +++ b/packages/core/src/nexus/nexus-bridge.ts @@ -240,7 +240,7 @@ export async function generateNexusBridgeContent( // Ensure DB is initialized await getNexusDb(repoPath); - const nativeDb = getNexusNativeDb(); + const nativeDb = getNexusNativeDb(repoPath); if (!nativeDb) { return buildEmptyBridge(repoPath);