Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/t12472-nexus-portable-query.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'],
Expand All @@ -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` },
Expand Down Expand Up @@ -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 },
]);
Expand All @@ -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 },
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});

Expand All @@ -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()'),
);
});

Expand Down
8 changes: 4 additions & 4 deletions packages/cleo/src/cli/commands/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = { projectId, limit };
Expand Down Expand Up @@ -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' });
},
});
Expand Down
64 changes: 32 additions & 32 deletions packages/cleo/src/cli/commands/nexus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 <that project> && 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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@cleocode/core/nexus/registry.js')>()),
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 () => ({
Expand Down Expand Up @@ -68,6 +75,7 @@ import {
nexusShapeCheck,
nexusWiki,
} from '@cleocode/core/internal';
import { resolveNexusQueryProjectId } from '@cleocode/core/nexus/registry.js';
import { NexusHandler } from '../nexus.js';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,19 +162,19 @@ 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,
});

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 () => {
Expand All @@ -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();
});
});

// -------------------------------------------------------------------------
Expand Down
Loading
Loading