@veewo/gitnexus 1.5.0-rc.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/benchmark/analyze-runner.d.ts +1 -1
  2. package/dist/benchmark/analyze-runner.js +4 -3
  3. package/dist/benchmark/analyze-runner.test.js +7 -0
  4. package/dist/cli/ai-context.d.ts +0 -1
  5. package/dist/cli/ai-context.js +15 -6
  6. package/dist/cli/analyze-options.js +58 -34
  7. package/dist/cli/analyze-options.test.js +57 -0
  8. package/dist/cli/analyze-runtime-summary.js +1 -0
  9. package/dist/cli/analyze-runtime-summary.test.js +10 -0
  10. package/dist/cli/analyze-summary.d.ts +2 -0
  11. package/dist/cli/analyze-summary.js +19 -0
  12. package/dist/cli/analyze.d.ts +11 -0
  13. package/dist/cli/analyze.js +30 -5
  14. package/dist/cli/analyze.test.d.ts +1 -0
  15. package/dist/cli/analyze.test.js +25 -0
  16. package/dist/cli/benchmark-agent-context.js +1 -1
  17. package/dist/cli/benchmark-unity.js +1 -1
  18. package/dist/cli/benchmark-unity.test.js +5 -1
  19. package/dist/cli/index.js +4 -2
  20. package/dist/cli/scope-manifest-config.d.ts +9 -0
  21. package/dist/cli/scope-manifest-config.js +37 -0
  22. package/dist/cli/setup.js +40 -41
  23. package/dist/cli/setup.test.js +14 -14
  24. package/dist/cli/sync-manifest.d.ts +27 -0
  25. package/dist/cli/sync-manifest.js +200 -0
  26. package/dist/cli/sync-manifest.test.d.ts +1 -0
  27. package/dist/cli/sync-manifest.test.js +88 -0
  28. package/dist/core/config/unity-config.d.ts +1 -0
  29. package/dist/core/config/unity-config.js +1 -0
  30. package/dist/core/ingestion/call-processor.d.ts +2 -1
  31. package/dist/core/ingestion/call-processor.js +28 -6
  32. package/dist/core/ingestion/heritage-processor.d.ts +2 -1
  33. package/dist/core/ingestion/heritage-processor.js +30 -7
  34. package/dist/core/ingestion/import-processor.d.ts +2 -1
  35. package/dist/core/ingestion/import-processor.js +28 -6
  36. package/dist/core/ingestion/parsing-processor.d.ts +5 -3
  37. package/dist/core/ingestion/parsing-processor.js +46 -13
  38. package/dist/core/ingestion/pipeline.js +65 -13
  39. package/dist/core/ingestion/unity-runtime-binding-rules.d.ts +1 -1
  40. package/dist/core/ingestion/unity-runtime-binding-rules.js +21 -18
  41. package/dist/core/ingestion/workers/parse-worker.d.ts +2 -0
  42. package/dist/core/ingestion/workers/parse-worker.js +50 -6
  43. package/dist/core/tree-sitter/csharp-define-profile.d.ts +6 -0
  44. package/dist/core/tree-sitter/csharp-define-profile.js +43 -0
  45. package/dist/core/tree-sitter/csharp-preproc-normalizer.d.ts +14 -0
  46. package/dist/core/tree-sitter/csharp-preproc-normalizer.js +261 -0
  47. package/dist/core/tree-sitter/parser-loader.d.ts +10 -0
  48. package/dist/core/tree-sitter/parser-loader.js +19 -0
  49. package/dist/types/pipeline.d.ts +13 -0
  50. package/package.json +12 -12
  51. package/scripts/check-sync-manifest-traceability.mjs +203 -0
  52. package/scripts/tree-sitter-audit-classify.mjs +172 -0
  53. package/skills/gitnexus-cli.md +36 -4
  54. package/skills/gitnexus-unity-rule-gen.md +2 -2
package/dist/cli/setup.js CHANGED
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'url';
14
14
  import { getGlobalDir, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
15
15
  import { getGitRoot } from '../storage/git.js';
16
16
  import { glob } from 'glob';
17
- import { buildNpxCommand, resolveCliSpec } from '../config/cli-spec.js';
17
+ import { resolveCliSpec } from '../config/cli-spec.js';
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
20
20
  const execFileAsync = promisify(execFile);
@@ -47,25 +47,24 @@ async function installLegacyCursorSkills(result) {
47
47
  result.errors.push(`Cursor skills: ${err.message}`);
48
48
  }
49
49
  }
50
- const DEFAULT_MCP_PACKAGE_SPEC = resolveCliSpec().packageSpec;
51
50
  /**
52
51
  * The MCP server entry for all editors.
53
- * On Windows, npx must be invoked via cmd /c since it's a .cmd script.
52
+ * Uses the locally installed gitnexus binary.
54
53
  */
55
- function getMcpEntry(mcpPackageSpec) {
54
+ function getMcpEntry() {
56
55
  if (process.platform === 'win32') {
57
56
  return {
58
57
  command: 'cmd',
59
- args: ['/c', 'npx', '-y', mcpPackageSpec, 'mcp'],
58
+ args: ['/c', 'gitnexus', 'mcp'],
60
59
  };
61
60
  }
62
61
  return {
63
- command: 'npx',
64
- args: ['-y', mcpPackageSpec, 'mcp'],
62
+ command: 'gitnexus',
63
+ args: ['mcp'],
65
64
  };
66
65
  }
67
- function getOpenCodeMcpEntry(mcpPackageSpec) {
68
- const entry = getMcpEntry(mcpPackageSpec);
66
+ function getOpenCodeMcpEntry() {
67
+ const entry = getMcpEntry();
69
68
  return {
70
69
  type: 'local',
71
70
  command: [entry.command, ...entry.args],
@@ -75,28 +74,28 @@ function getOpenCodeMcpEntry(mcpPackageSpec) {
75
74
  * Merge gitnexus entry into an existing MCP config JSON object.
76
75
  * Returns the updated config.
77
76
  */
78
- function mergeMcpConfig(existing, mcpPackageSpec) {
77
+ function mergeMcpConfig(existing) {
79
78
  if (!existing || typeof existing !== 'object') {
80
79
  existing = {};
81
80
  }
82
81
  if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
83
82
  existing.mcpServers = {};
84
83
  }
85
- existing.mcpServers.gitnexus = getMcpEntry(mcpPackageSpec);
84
+ existing.mcpServers.gitnexus = getMcpEntry();
86
85
  return existing;
87
86
  }
88
87
  /**
89
88
  * Merge gitnexus entry into an OpenCode config JSON object.
90
89
  * Returns the updated config.
91
90
  */
92
- function mergeOpenCodeConfig(existing, mcpPackageSpec) {
91
+ function mergeOpenCodeConfig(existing) {
93
92
  if (!existing || typeof existing !== 'object') {
94
93
  existing = {};
95
94
  }
96
95
  if (!existing.mcp || typeof existing.mcp !== 'object') {
97
96
  existing.mcp = {};
98
97
  }
99
- existing.mcp.gitnexus = getOpenCodeMcpEntry(mcpPackageSpec);
98
+ existing.mcp.gitnexus = getOpenCodeMcpEntry();
100
99
  return existing;
101
100
  }
102
101
  /**
@@ -148,16 +147,16 @@ async function fileExists(filePath) {
148
147
  function toTomlString(value) {
149
148
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
150
149
  }
151
- function buildCodexMcpTable(mcpPackageSpec) {
152
- const entry = getMcpEntry(mcpPackageSpec);
150
+ function buildCodexMcpTable() {
151
+ const entry = getMcpEntry();
153
152
  return [
154
153
  '[mcp_servers.gitnexus]',
155
154
  `command = ${toTomlString(entry.command)}`,
156
155
  `args = [${entry.args.map(toTomlString).join(', ')}]`,
157
156
  ].join('\n');
158
157
  }
159
- function mergeCodexConfig(existingRaw, mcpPackageSpec) {
160
- const table = buildCodexMcpTable(mcpPackageSpec);
158
+ function mergeCodexConfig(existingRaw) {
159
+ const table = buildCodexMcpTable();
161
160
  const normalized = existingRaw.replace(/\r\n/g, '\n');
162
161
  const tablePattern = /^\[mcp_servers\.gitnexus\][\s\S]*?(?=^\[[^\]]+\]|(?![\s\S]))/m;
163
162
  if (tablePattern.test(normalized)) {
@@ -179,7 +178,7 @@ async function resolveOpenCodeConfigPath(opencodeDir) {
179
178
  return preferredPath;
180
179
  }
181
180
  // ─── Editor-specific setup ─────────────────────────────────────────
182
- async function setupCursor(result, mcpPackageSpec) {
181
+ async function setupCursor(result) {
183
182
  const cursorDir = path.join(os.homedir(), '.cursor');
184
183
  if (!(await dirExists(cursorDir))) {
185
184
  result.skipped.push('Cursor (not installed)');
@@ -188,7 +187,7 @@ async function setupCursor(result, mcpPackageSpec) {
188
187
  const mcpPath = path.join(cursorDir, 'mcp.json');
189
188
  try {
190
189
  const existing = await readJsonFile(mcpPath);
191
- const updated = mergeMcpConfig(existing, mcpPackageSpec);
190
+ const updated = mergeMcpConfig(existing);
192
191
  await writeJsonFile(mcpPath, updated);
193
192
  result.configured.push('Cursor');
194
193
  }
@@ -196,7 +195,7 @@ async function setupCursor(result, mcpPackageSpec) {
196
195
  result.errors.push(`Cursor: ${err.message}`);
197
196
  }
198
197
  }
199
- async function setupClaudeCode(result, mcpPackageSpec) {
198
+ async function setupClaudeCode(result) {
200
199
  const claudeDir = path.join(os.homedir(), '.claude');
201
200
  const hasClaude = await dirExists(claudeDir);
202
201
  if (!hasClaude) {
@@ -207,7 +206,7 @@ async function setupClaudeCode(result, mcpPackageSpec) {
207
206
  console.log('');
208
207
  console.log(' Claude Code detected. Run this command to add GitNexus MCP:');
209
208
  console.log('');
210
- console.log(` claude mcp add gitnexus -- ${buildNpxCommand(mcpPackageSpec, 'mcp')}`);
209
+ console.log(` claude mcp add gitnexus -- gitnexus mcp`);
211
210
  console.log('');
212
211
  result.configured.push('Claude Code (MCP manual step printed)');
213
212
  }
@@ -243,7 +242,7 @@ async function installProjectAgentSkills(repoRoot, result) {
243
242
  * Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
244
243
  * Merges hook config without overwriting existing hooks.
245
244
  */
246
- async function installClaudeCodeHooks(result, mcpPackageSpec) {
245
+ async function installClaudeCodeHooks(result) {
247
246
  const claudeDir = path.join(os.homedir(), '.claude');
248
247
  if (!(await dirExists(claudeDir)))
249
248
  return;
@@ -295,7 +294,7 @@ async function installClaudeCodeHooks(result, mcpPackageSpec) {
295
294
  result.errors.push(`Claude Code hooks: ${err.message}`);
296
295
  }
297
296
  }
298
- async function setupOpenCode(result, mcpPackageSpec) {
297
+ async function setupOpenCode(result) {
299
298
  const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
300
299
  if (!(await dirExists(opencodeDir))) {
301
300
  result.skipped.push('OpenCode (not installed)');
@@ -304,7 +303,7 @@ async function setupOpenCode(result, mcpPackageSpec) {
304
303
  const configPath = await resolveOpenCodeConfigPath(opencodeDir);
305
304
  try {
306
305
  const existing = await readJsonFile(configPath);
307
- const config = mergeOpenCodeConfig(existing, mcpPackageSpec);
306
+ const config = mergeOpenCodeConfig(existing);
308
307
  await writeJsonFile(configPath, config);
309
308
  result.configured.push(`OpenCode (${path.basename(configPath)})`);
310
309
  }
@@ -312,8 +311,8 @@ async function setupOpenCode(result, mcpPackageSpec) {
312
311
  result.errors.push(`OpenCode: ${err.message}`);
313
312
  }
314
313
  }
315
- async function setupCodex(result, mcpPackageSpec) {
316
- const entry = getMcpEntry(mcpPackageSpec);
314
+ async function setupCodex(result) {
315
+ const entry = getMcpEntry();
317
316
  try {
318
317
  await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], { timeout: 15000 });
319
318
  result.configured.push('Codex');
@@ -326,11 +325,11 @@ async function setupCodex(result, mcpPackageSpec) {
326
325
  result.errors.push(`Codex: ${err.message}`);
327
326
  }
328
327
  }
329
- async function setupProjectMcp(repoRoot, result, mcpPackageSpec) {
328
+ async function setupProjectMcp(repoRoot, result) {
330
329
  const mcpPath = path.join(repoRoot, '.mcp.json');
331
330
  try {
332
331
  const existing = await readJsonFile(mcpPath);
333
- const updated = mergeMcpConfig(existing, mcpPackageSpec);
332
+ const updated = mergeMcpConfig(existing);
334
333
  await writeJsonFile(mcpPath, updated);
335
334
  result.configured.push(`Project MCP (${path.relative(repoRoot, mcpPath)})`);
336
335
  }
@@ -338,7 +337,7 @@ async function setupProjectMcp(repoRoot, result, mcpPackageSpec) {
338
337
  result.errors.push(`Project MCP: ${err.message}`);
339
338
  }
340
339
  }
341
- async function setupProjectCodex(repoRoot, result, mcpPackageSpec) {
340
+ async function setupProjectCodex(repoRoot, result) {
342
341
  const codexConfigPath = path.join(repoRoot, '.codex', 'config.toml');
343
342
  try {
344
343
  let existingRaw = '';
@@ -349,7 +348,7 @@ async function setupProjectCodex(repoRoot, result, mcpPackageSpec) {
349
348
  if (err?.code !== 'ENOENT')
350
349
  throw err;
351
350
  }
352
- const merged = mergeCodexConfig(existingRaw, mcpPackageSpec);
351
+ const merged = mergeCodexConfig(existingRaw);
353
352
  await fs.mkdir(path.dirname(codexConfigPath), { recursive: true });
354
353
  await fs.writeFile(codexConfigPath, merged, 'utf-8');
355
354
  result.configured.push(`Project Codex MCP (${path.relative(repoRoot, codexConfigPath)})`);
@@ -358,11 +357,11 @@ async function setupProjectCodex(repoRoot, result, mcpPackageSpec) {
358
357
  result.errors.push(`Project Codex MCP: ${err.message}`);
359
358
  }
360
359
  }
361
- async function setupProjectOpenCode(repoRoot, result, mcpPackageSpec) {
360
+ async function setupProjectOpenCode(repoRoot, result) {
362
361
  const opencodePath = path.join(repoRoot, 'opencode.json');
363
362
  try {
364
363
  const existing = await readJsonFile(opencodePath);
365
- const merged = mergeOpenCodeConfig(existing, mcpPackageSpec);
364
+ const merged = mergeOpenCodeConfig(existing);
366
365
  await writeJsonFile(opencodePath, merged);
367
366
  result.configured.push(`Project OpenCode MCP (${path.relative(repoRoot, opencodePath)})`);
368
367
  }
@@ -510,7 +509,7 @@ export const setupCommand = async (options = {}) => {
510
509
  explicitVersion: options.cliVersion,
511
510
  config: existingConfig,
512
511
  });
513
- const mcpPackageSpec = resolvedCliSpec.packageSpec || DEFAULT_MCP_PACKAGE_SPEC;
512
+ const mcpPackageSpec = resolvedCliSpec.packageSpec;
514
513
  const result = {
515
514
  configured: [],
516
515
  skipped: [],
@@ -518,7 +517,7 @@ export const setupCommand = async (options = {}) => {
518
517
  };
519
518
  if (scope === 'global') {
520
519
  if (legacyCursorMode) {
521
- await setupCursor(result, mcpPackageSpec);
520
+ await setupCursor(result);
522
521
  await installLegacyCursorSkills(result);
523
522
  await saveSetupConfig(scope, mcpPackageSpec, result);
524
523
  agent = LEGACY_CURSOR_AGENT;
@@ -526,15 +525,15 @@ export const setupCommand = async (options = {}) => {
526
525
  else {
527
526
  // Configure only the selected agent MCP
528
527
  if (agent === 'claude') {
529
- await setupClaudeCode(result, mcpPackageSpec);
528
+ await setupClaudeCode(result);
530
529
  // Claude-only hooks should only be installed when Claude is selected.
531
- await installClaudeCodeHooks(result, mcpPackageSpec);
530
+ await installClaudeCodeHooks(result);
532
531
  }
533
532
  else if (agent === 'opencode') {
534
- await setupOpenCode(result, mcpPackageSpec);
533
+ await setupOpenCode(result);
535
534
  }
536
535
  else if (agent === 'codex') {
537
- await setupCodex(result, mcpPackageSpec);
536
+ await setupCodex(result);
538
537
  }
539
538
  // Install shared global skills once
540
539
  await installGlobalAgentSkills(result);
@@ -549,13 +548,13 @@ export const setupCommand = async (options = {}) => {
549
548
  return;
550
549
  }
551
550
  if (agent === 'claude') {
552
- await setupProjectMcp(repoRoot, result, mcpPackageSpec);
551
+ await setupProjectMcp(repoRoot, result);
553
552
  }
554
553
  else if (agent === 'codex') {
555
- await setupProjectCodex(repoRoot, result, mcpPackageSpec);
554
+ await setupProjectCodex(repoRoot, result);
556
555
  }
557
556
  else if (agent === 'opencode') {
558
- await setupProjectOpenCode(repoRoot, result, mcpPackageSpec);
557
+ await setupProjectOpenCode(repoRoot, result);
559
558
  }
560
559
  await installProjectAgentSkills(repoRoot, result);
561
560
  await saveSetupConfig(scope, mcpPackageSpec, result);
@@ -10,9 +10,6 @@ const execFileAsync = promisify(execFile);
10
10
  const here = path.dirname(fileURLToPath(import.meta.url));
11
11
  const packageRoot = path.resolve(here, '..', '..');
12
12
  const cliPath = path.join(packageRoot, 'dist', 'cli', 'index.js');
13
- const packageName = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
14
- const expectedMcpPackage = `${packageName.name || 'gitnexus'}@latest`;
15
- const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
13
  async function runSetup(args, env, cwd = packageRoot) {
17
14
  return execFileAsync(process.execPath, [cliPath, 'setup', ...args], { cwd, env });
18
15
  }
@@ -30,8 +27,8 @@ test('setup without --agent uses legacy Cursor install path', async () => {
30
27
  const configPath = path.join(fakeHome, '.gitnexus', 'config.json');
31
28
  const cursorMcpRaw = await fs.readFile(cursorMcpPath, 'utf-8');
32
29
  const cursorMcp = JSON.parse(cursorMcpRaw);
33
- assert.equal(cursorMcp.mcpServers?.gitnexus?.command, 'npx');
34
- assert.deepEqual(cursorMcp.mcpServers?.gitnexus?.args, ['-y', expectedMcpPackage, 'mcp']);
30
+ assert.equal(cursorMcp.mcpServers?.gitnexus?.command, 'gitnexus');
31
+ assert.deepEqual(cursorMcp.mcpServers?.gitnexus?.args, ['mcp']);
35
32
  await fs.access(cursorSkillPath);
36
33
  const configRaw = await fs.readFile(configPath, 'utf-8');
37
34
  const config = JSON.parse(configRaw);
@@ -142,7 +139,7 @@ process.exit(0);
142
139
  const raw = await fs.readFile(outputPath, 'utf-8');
143
140
  const parsed = JSON.parse(raw);
144
141
  assert.deepEqual(parsed.args.slice(0, 4), ['mcp', 'add', 'gitnexus', '--']);
145
- assert.ok(parsed.args.includes(expectedMcpPackage));
142
+ assert.ok(parsed.args.includes('gitnexus'));
146
143
  assert.ok(parsed.args.includes('mcp'));
147
144
  }
148
145
  finally {
@@ -164,7 +161,7 @@ test('setup configures OpenCode MCP in ~/.config/opencode/opencode.json', async
164
161
  const opencodeRaw = await fs.readFile(opencodeConfigPath, 'utf-8');
165
162
  const opencodeConfig = JSON.parse(opencodeRaw);
166
163
  assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
167
- assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
164
+ assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['gitnexus', 'mcp']);
168
165
  }
169
166
  finally {
170
167
  await fs.rm(fakeHome, { recursive: true, force: true });
@@ -186,7 +183,8 @@ test('setup --cli-version pins MCP package spec and persists it in config', asyn
186
183
  const configPath = path.join(fakeHome, '.gitnexus', 'config.json');
187
184
  const savedConfigRaw = await fs.readFile(configPath, 'utf-8');
188
185
  const savedConfig = JSON.parse(savedConfigRaw);
189
- assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', '@veewo/gitnexus@1.4.7-rc', 'mcp']);
186
+ assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['gitnexus', 'mcp']);
187
+ // Version is persisted to config, not MCP entry:
190
188
  assert.equal(savedConfig.cliPackageSpec, '@veewo/gitnexus@1.4.7-rc');
191
189
  assert.equal(savedConfig.cliVersion, '1.4.7-rc');
192
190
  }
@@ -211,7 +209,7 @@ test('setup keeps using legacy ~/.config/opencode/config.json when it already ex
211
209
  const legacyConfig = JSON.parse(legacyRaw);
212
210
  assert.equal(legacyConfig.existing, true);
213
211
  assert.equal(legacyConfig.mcp?.gitnexus?.type, 'local');
214
- assert.deepEqual(legacyConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
212
+ assert.deepEqual(legacyConfig.mcp?.gitnexus?.command, ['gitnexus', 'mcp']);
215
213
  await assert.rejects(fs.access(preferredConfigPath));
216
214
  }
217
215
  finally {
@@ -254,7 +252,8 @@ test('setup --scope project --agent claude writes only .mcp.json', async () => {
254
252
  const opencodeConfigPath = path.join(fakeRepo, 'opencode.json');
255
253
  const projectMcpRaw = await fs.readFile(projectMcpPath, 'utf-8');
256
254
  const projectMcp = JSON.parse(projectMcpRaw);
257
- assert.equal(projectMcp.mcpServers?.gitnexus?.command, 'npx');
255
+ assert.equal(projectMcp.mcpServers?.gitnexus?.command, 'gitnexus');
256
+ assert.deepEqual(projectMcp.mcpServers?.gitnexus?.args, ['mcp']);
258
257
  await assert.rejects(fs.access(codexConfigPath));
259
258
  await assert.rejects(fs.access(opencodeConfigPath));
260
259
  }
@@ -278,7 +277,8 @@ test('setup --scope project --agent codex writes only .codex/config.toml', async
278
277
  const opencodeConfigPath = path.join(fakeRepo, 'opencode.json');
279
278
  const codexConfigRaw = await fs.readFile(codexConfigPath, 'utf-8');
280
279
  assert.match(codexConfigRaw, /\[mcp_servers\.gitnexus\]/);
281
- assert.match(codexConfigRaw, /command = "npx"/);
280
+ assert.match(codexConfigRaw, /command = "gitnexus"/);
281
+ assert.match(codexConfigRaw, /args = \["mcp"\]/);
282
282
  await assert.rejects(fs.access(projectMcpPath));
283
283
  await assert.rejects(fs.access(opencodeConfigPath));
284
284
  }
@@ -316,7 +316,7 @@ test('setup --scope project --agent codex replaces existing gitnexus table witho
316
316
  const gitnexusTable = gitnexusTableMatch[0];
317
317
  assert.equal((gitnexusTable.match(/^command\s*=/gm) || []).length, 1);
318
318
  assert.equal((gitnexusTable.match(/^args\s*=/gm) || []).length, 1);
319
- assert.match(gitnexusTable, new RegExp(escapeRegExp(expectedMcpPackage)));
319
+ assert.match(gitnexusTable, /command = "gitnexus"/);
320
320
  assert.doesNotMatch(gitnexusTable, /oldpkg@latest/);
321
321
  assert.match(codexConfigRaw, /^\[profiles\.default\]$/m);
322
322
  }
@@ -346,7 +346,7 @@ test('setup --scope project --agent codex is idempotent across repeated runs', a
346
346
  const gitnexusTable = gitnexusTableMatch[0];
347
347
  assert.equal((gitnexusTable.match(/^command\s*=/gm) || []).length, 1);
348
348
  assert.equal((gitnexusTable.match(/^args\s*=/gm) || []).length, 1);
349
- assert.match(gitnexusTable, new RegExp(escapeRegExp(expectedMcpPackage)));
349
+ assert.match(gitnexusTable, /command = "gitnexus"/);
350
350
  }
351
351
  finally {
352
352
  await fs.rm(fakeHome, { recursive: true, force: true });
@@ -374,7 +374,7 @@ test('setup --scope project --agent opencode writes only opencode.json', async (
374
374
  const configRaw = await fs.readFile(configPath, 'utf-8');
375
375
  const config = JSON.parse(configRaw);
376
376
  assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
377
- assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
377
+ assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['gitnexus', 'mcp']);
378
378
  await assert.rejects(fs.access(projectMcpPath));
379
379
  await assert.rejects(fs.access(codexConfigPath));
380
380
  await fs.access(localSkillPath);
@@ -0,0 +1,27 @@
1
+ export interface SyncManifestScopeOptions {
2
+ scopeManifest?: string;
3
+ scopePrefix?: string[] | string;
4
+ }
5
+ export type SyncManifestPolicy = 'ask' | 'update' | 'keep' | 'error';
6
+ export interface SyncManifestDiffEntry {
7
+ directive: 'extensions' | 'repoAlias' | 'embeddings';
8
+ manifestValue?: string;
9
+ cliValue: string;
10
+ }
11
+ export interface EnforceSyncManifestConsistencyInput {
12
+ manifestPath?: string;
13
+ extensions?: string;
14
+ repoAlias?: string;
15
+ embeddings?: boolean;
16
+ policy?: SyncManifestPolicy;
17
+ stdinIsTTY?: boolean;
18
+ prompt?: (message: string) => Promise<'update' | 'keep'>;
19
+ }
20
+ export interface EnforceSyncManifestConsistencyResult {
21
+ decision: 'none' | 'update' | 'keep';
22
+ diff: SyncManifestDiffEntry[];
23
+ }
24
+ export declare function resolveDefaultSyncManifestPath(repoPath: string): string;
25
+ export declare function shouldAutoUseSyncManifest(options?: SyncManifestScopeOptions): boolean;
26
+ export declare function resolveScopeManifestForAnalyze(repoPath: string, options?: SyncManifestScopeOptions, pathExists?: (candidatePath: string) => Promise<boolean>): Promise<string | undefined>;
27
+ export declare function enforceSyncManifestConsistency(input: EnforceSyncManifestConsistencyInput): Promise<EnforceSyncManifestConsistencyResult>;
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { stdin as input, stdout as output } from 'node:process';
5
+ import { parseScopeManifestConfig } from './scope-manifest-config.js';
6
+ import { normalizeRepoAlias, parseExtensionList } from './analyze-options.js';
7
+ export function resolveDefaultSyncManifestPath(repoPath) {
8
+ return path.join(repoPath, '.gitnexus', 'sync-manifest.txt');
9
+ }
10
+ export function shouldAutoUseSyncManifest(options) {
11
+ if (options?.scopeManifest)
12
+ return false;
13
+ return parseScopePrefixCount(options?.scopePrefix) === 0;
14
+ }
15
+ export async function resolveScopeManifestForAnalyze(repoPath, options, pathExists = fileExists) {
16
+ if (options?.scopeManifest) {
17
+ return options.scopeManifest;
18
+ }
19
+ if (!shouldAutoUseSyncManifest(options)) {
20
+ return undefined;
21
+ }
22
+ const defaultManifestPath = resolveDefaultSyncManifestPath(repoPath);
23
+ if (await pathExists(defaultManifestPath)) {
24
+ return defaultManifestPath;
25
+ }
26
+ return undefined;
27
+ }
28
+ export async function enforceSyncManifestConsistency(input) {
29
+ if (!input.manifestPath) {
30
+ return { decision: 'none', diff: [] };
31
+ }
32
+ ensureConcreteManifestPath(input.manifestPath);
33
+ const raw = await fs.readFile(input.manifestPath, 'utf-8');
34
+ const parsed = parseScopeManifestConfig(raw);
35
+ const normalizedDirectives = normalizeManifestDirectives(parsed.directives);
36
+ const diff = computeDiff(normalizedDirectives, input);
37
+ const policy = normalizePolicy(input.policy);
38
+ if (diff.length === 0) {
39
+ if (policy === 'update') {
40
+ throw new Error('Sync manifest rewrite requires non-empty diff entries.');
41
+ }
42
+ return { decision: 'none', diff };
43
+ }
44
+ const decision = await resolveDecision(policy, input.manifestPath, diff, input.stdinIsTTY, input.prompt);
45
+ if (decision === 'update') {
46
+ const nextDirectives = mergeDirectivesForUpdate(normalizedDirectives, input);
47
+ const rewritten = renderSyncManifest(parsed.scopeRules, nextDirectives);
48
+ await fs.writeFile(input.manifestPath, rewritten, 'utf-8');
49
+ }
50
+ return {
51
+ decision,
52
+ diff,
53
+ };
54
+ }
55
+ function parseScopePrefixCount(scopePrefix) {
56
+ if (Array.isArray(scopePrefix))
57
+ return scopePrefix.length;
58
+ if (typeof scopePrefix === 'string')
59
+ return scopePrefix.trim() ? 1 : 0;
60
+ return 0;
61
+ }
62
+ async function fileExists(candidatePath) {
63
+ try {
64
+ await fs.stat(candidatePath);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function normalizePolicy(raw) {
72
+ if (!raw)
73
+ return 'ask';
74
+ if (raw === 'ask' || raw === 'update' || raw === 'keep' || raw === 'error')
75
+ return raw;
76
+ throw new Error(`Invalid --sync-manifest-policy value: ${raw}. Use ask|update|keep|error.`);
77
+ }
78
+ function normalizeManifestDirectives(directives) {
79
+ return {
80
+ extensions: normalizeExtensions(directives.extensions),
81
+ repoAlias: normalizeAlias(directives.repoAlias),
82
+ embeddings: normalizeEmbeddings(directives.embeddings),
83
+ };
84
+ }
85
+ function computeDiff(manifest, input) {
86
+ const diff = [];
87
+ if (input.extensions !== undefined) {
88
+ const cliValue = normalizeExtensions(input.extensions);
89
+ if (cliValue !== manifest.extensions) {
90
+ diff.push({ directive: 'extensions', manifestValue: manifest.extensions, cliValue: cliValue || '' });
91
+ }
92
+ }
93
+ if (input.repoAlias !== undefined) {
94
+ const cliValue = normalizeAlias(input.repoAlias);
95
+ if (cliValue !== manifest.repoAlias) {
96
+ diff.push({ directive: 'repoAlias', manifestValue: manifest.repoAlias, cliValue: cliValue || '' });
97
+ }
98
+ }
99
+ if (input.embeddings !== undefined) {
100
+ const cliValue = input.embeddings ? 'true' : 'false';
101
+ if (cliValue !== manifest.embeddings) {
102
+ diff.push({ directive: 'embeddings', manifestValue: manifest.embeddings, cliValue });
103
+ }
104
+ }
105
+ return diff;
106
+ }
107
+ async function resolveDecision(policy, manifestPath, diff, stdinIsTTY, prompt) {
108
+ if (policy === 'update' || policy === 'keep')
109
+ return policy;
110
+ if (policy === 'error') {
111
+ throw new Error(`${formatMismatchHeader(manifestPath)}\n${formatDiff(diff)}`);
112
+ }
113
+ if (stdinIsTTY === undefined) {
114
+ throw new Error('TTY prompt branch requires concrete stdin.isTTY evidence.');
115
+ }
116
+ const interactive = stdinIsTTY;
117
+ if (!interactive) {
118
+ throw new Error(`${formatMismatchHeader(manifestPath)}\n${formatDiff(diff)}\n` +
119
+ 'Non-interactive mode requires --sync-manifest-policy ask|update|keep|error.');
120
+ }
121
+ const promptFn = prompt || defaultPrompt;
122
+ return promptFn([
123
+ formatMismatchHeader(manifestPath),
124
+ formatDiff(diff),
125
+ 'Choose: update (rewrite sync-manifest) or keep (continue without rewrite).',
126
+ ].join('\n'));
127
+ }
128
+ function mergeDirectivesForUpdate(manifest, input) {
129
+ const merged = { ...manifest };
130
+ if (input.extensions !== undefined) {
131
+ merged.extensions = normalizeExtensions(input.extensions);
132
+ }
133
+ if (input.repoAlias !== undefined) {
134
+ merged.repoAlias = normalizeAlias(input.repoAlias);
135
+ }
136
+ if (input.embeddings !== undefined) {
137
+ merged.embeddings = input.embeddings ? 'true' : 'false';
138
+ }
139
+ return merged;
140
+ }
141
+ function renderSyncManifest(scopeRules, directives) {
142
+ const lines = [...scopeRules];
143
+ if (directives.extensions)
144
+ lines.push(`@extensions=${directives.extensions}`);
145
+ if (directives.repoAlias)
146
+ lines.push(`@repoAlias=${directives.repoAlias}`);
147
+ if (directives.embeddings)
148
+ lines.push(`@embeddings=${directives.embeddings}`);
149
+ return `${lines.join('\n')}\n`;
150
+ }
151
+ function normalizeExtensions(raw) {
152
+ if (raw === undefined)
153
+ return undefined;
154
+ const parsed = parseExtensionList(raw);
155
+ return parsed.length > 0 ? parsed.join(',') : undefined;
156
+ }
157
+ function normalizeAlias(raw) {
158
+ if (raw === undefined)
159
+ return undefined;
160
+ return normalizeRepoAlias(raw);
161
+ }
162
+ function normalizeEmbeddings(raw) {
163
+ if (raw === undefined)
164
+ return undefined;
165
+ const normalized = raw.trim().toLowerCase();
166
+ if (normalized === 'true')
167
+ return 'true';
168
+ if (normalized === 'false')
169
+ return 'false';
170
+ throw new Error(`Invalid @embeddings directive value: ${raw}. Expected true or false.`);
171
+ }
172
+ function formatMismatchHeader(manifestPath) {
173
+ return `Explicit analyze options differ from sync manifest directives: ${manifestPath}`;
174
+ }
175
+ function formatDiff(diff) {
176
+ return diff
177
+ .map((entry) => `- @${entry.directive}: ${entry.manifestValue ?? '<unset>'} -> ${entry.cliValue}`)
178
+ .join('\n');
179
+ }
180
+ async function defaultPrompt(message) {
181
+ const rl = readline.createInterface({ input, output });
182
+ try {
183
+ const answer = await rl.question(`${message}\nUpdate sync-manifest now? [y/N] `);
184
+ return /^y(es)?$/i.test(answer.trim()) ? 'update' : 'keep';
185
+ }
186
+ finally {
187
+ rl.close();
188
+ }
189
+ }
190
+ function ensureConcreteManifestPath(manifestPath) {
191
+ const normalized = manifestPath.trim();
192
+ if (!normalized) {
193
+ throw new Error('Invalid placeholder manifest path: empty value.');
194
+ }
195
+ if (/placeholder/i.test(normalized) ||
196
+ /<\s*path\s*>/i.test(normalized) ||
197
+ /todo/i.test(normalized)) {
198
+ throw new Error(`Invalid placeholder manifest path: ${manifestPath}`);
199
+ }
200
+ }
@@ -0,0 +1 @@
1
+ export {};