mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* fix(group): fail sync when a member name is ambiguous Silent first-match bound the wrong clone when --allow-duplicate-name registered two paths under one alias. Refs #3028. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): apply --name on the already-up-to-date path A rename should not require --force when the index is already current. Register before the same-commit branch restamp. Refs #3028. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(group): hint member path when impact --repo is an alias $localRepo stays the yaml key; joining on the registry alias is a non-join. List matching keys so operators can retry. Refs #3028. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(group): keep injected sync and alias hints consistent Workspace-deps path maps reuse the resolved handle so duplicate names cannot throw after an injected resolver. Alias hints match case-insensitively. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
325 lines
9.5 KiB
TypeScript
325 lines
9.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { loadGroupConfig, parseGroupConfig } from '../../../src/core/group/config-parser.js';
|
|
|
|
const VALID_YAML = `
|
|
version: 1
|
|
name: company
|
|
description: "All company microservices"
|
|
repos:
|
|
hr/hiring/backend: hr-hiring-backend
|
|
hr/hiring/ui: hr-hiring-ui
|
|
links:
|
|
- from: hr/hiring/backend
|
|
to: hr/hiring/ui
|
|
type: http
|
|
contract: "/api/users"
|
|
role: provider
|
|
packages:
|
|
hr/common:
|
|
npm: "@hr/common"
|
|
detect:
|
|
http: true
|
|
grpc: false
|
|
topics: false
|
|
shared_libs: true
|
|
embedding_fallback: false
|
|
matching:
|
|
bm25_threshold: 0.7
|
|
embedding_threshold: 0.65
|
|
max_candidates_per_step: 3
|
|
`;
|
|
|
|
describe('parseGroupConfig', () => {
|
|
it('parses valid group.yaml', () => {
|
|
const config = parseGroupConfig(VALID_YAML);
|
|
expect(config.name).toBe('company');
|
|
expect(config.version).toBe(1);
|
|
expect(Object.keys(config.repos)).toHaveLength(2);
|
|
expect(config.repos['hr/hiring/backend']).toBe('hr-hiring-backend');
|
|
expect(config.links).toHaveLength(1);
|
|
expect(config.links[0].type).toBe('http');
|
|
expect(config.links[0].role).toBe('provider');
|
|
expect(config.packages['hr/common'].npm).toBe('@hr/common');
|
|
expect(config.detect.http).toBe(true);
|
|
expect(config.detect.grpc).toBe(false);
|
|
expect(config.detect.graphql).toBe(false);
|
|
});
|
|
|
|
it('applies defaults for missing optional fields', () => {
|
|
const minimal = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
`;
|
|
const config = parseGroupConfig(minimal);
|
|
expect(config.description).toBe('');
|
|
expect(config.links).toEqual([]);
|
|
expect(config.packages).toEqual({});
|
|
expect(config.detect.http).toBe(true);
|
|
expect(config.matching.exclude_links_paths).toEqual([]);
|
|
expect(config.matching.exclude_links_param_only_paths).toBe(false);
|
|
});
|
|
|
|
it('still parses a legacy config carrying the removed matching knobs', () => {
|
|
// `bm25_threshold`, `embedding_threshold` and `detect.embedding_fallback`
|
|
// were written into every generated group.yaml but read by no matcher, so
|
|
// they are gone from the schema and the template. Every group.yaml already
|
|
// on disk still has them, and must keep loading without complaint.
|
|
const legacy = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
detect:
|
|
http: true
|
|
embedding_fallback: true
|
|
shared_libs: true
|
|
matching:
|
|
bm25_threshold: 0.7
|
|
embedding_threshold: 0.65
|
|
max_candidates_per_step: 3
|
|
`;
|
|
const config = parseGroupConfig(legacy);
|
|
expect(config.name).toBe('test');
|
|
expect(config.repos).toEqual({ app: 'my-app' });
|
|
expect(config.detect.http).toBe(true);
|
|
|
|
// Pinned behavior: PRESERVE, not strip. The parser spreads the raw block
|
|
// over its defaults (`{ ...DEFAULT_MATCHING, ...raw.matching }`), so a key
|
|
// it no longer knows about survives into the returned config.
|
|
//
|
|
// Every assertion above is satisfied by the defaults alone, so without this
|
|
// the test only proves "does not throw" — it would stay green under a
|
|
// future strict validator that silently DROPPED the operator's legacy keys.
|
|
// That is not a harmless drop: `group add` and `group remove` in
|
|
// gitnexus/src/cli/group.ts round-trip the file through `loadGroupConfig`
|
|
// → `yaml.dump` → write, so anything the parser discards is deleted from
|
|
// the operator's checked-in group.yaml the next time they add a repo.
|
|
expect((config.matching as unknown as Record<string, unknown>).bm25_threshold).toBe(0.7);
|
|
expect((config.matching as unknown as Record<string, unknown>).embedding_threshold).toBe(0.65);
|
|
expect((config.detect as unknown as Record<string, unknown>).embedding_fallback).toBe(true);
|
|
// The two keys this commit removes, pinned the same way and for the same
|
|
// reason: an operator's group.yaml carries them today because
|
|
// `gitnexus group create` wrote them there.
|
|
expect((config.matching as unknown as Record<string, unknown>).max_candidates_per_step).toBe(3);
|
|
expect((config.detect as unknown as Record<string, unknown>).shared_libs).toBe(true);
|
|
});
|
|
|
|
it('defaults thrift detection to true', () => {
|
|
const minimal = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
`;
|
|
const config = parseGroupConfig(minimal);
|
|
expect(config.detect.thrift).toBe(true);
|
|
});
|
|
|
|
// PR #1156 Codex follow-up: include extraction is opt-in. Existing
|
|
// group.yaml files that do not declare `detect.includes` must not gain
|
|
// a wave of new include::* contracts on the next sync after upgrade.
|
|
describe('detect.includes opt-in default', () => {
|
|
it('defaults includes detection to false when detect block omits it', () => {
|
|
const minimal = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
`;
|
|
const config = parseGroupConfig(minimal);
|
|
expect(config.detect.includes).toBe(false);
|
|
});
|
|
|
|
it('defaults includes detection to false when detect block is present but omits the key', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
detect:
|
|
http: true
|
|
grpc: false
|
|
`;
|
|
const config = parseGroupConfig(yaml);
|
|
expect(config.detect.includes).toBe(false);
|
|
});
|
|
|
|
it('honors explicit detect.includes: true (opt-in works)', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
detect:
|
|
includes: true
|
|
`;
|
|
const config = parseGroupConfig(yaml);
|
|
expect(config.detect.includes).toBe(true);
|
|
});
|
|
|
|
it('honors explicit detect.includes: false', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
app: my-app
|
|
detect:
|
|
includes: false
|
|
`;
|
|
const config = parseGroupConfig(yaml);
|
|
expect(config.detect.includes).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('detect.graphql opt-in default', () => {
|
|
it('defaults GraphQL extraction to false', () => {
|
|
const config = parseGroupConfig(`version: 1\nname: test\nrepos: { app: my-app }\n`);
|
|
expect(config.detect.graphql).toBe(false);
|
|
});
|
|
|
|
it('honors explicit GraphQL extraction', () => {
|
|
const config = parseGroupConfig(
|
|
`version: 1\nname: test\nrepos: { app: my-app }\ndetect:\n graphql: true\n`,
|
|
);
|
|
expect(config.detect.graphql).toBe(true);
|
|
});
|
|
|
|
it('rejects string-like detect booleans instead of silently changing behavior', () => {
|
|
expect(() =>
|
|
parseGroupConfig(
|
|
`version: 1\nname: test\nrepos: { app: my-app }\ndetect:\n graphql: yes\n`,
|
|
),
|
|
).toThrow(/detect\.graphql must be true or false/i);
|
|
expect(() =>
|
|
parseGroupConfig(
|
|
`version: 1\nname: test\nrepos: { app: my-app }\ndetect:\n http: "false"\n`,
|
|
),
|
|
).toThrow(/detect\.http must be true or false/i);
|
|
});
|
|
|
|
it('rejects GraphQL manifest links until they can resolve real endpoint symbols', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
web: web-repo
|
|
api: api-repo
|
|
links:
|
|
- from: web
|
|
to: api
|
|
type: graphql
|
|
contract: query::health
|
|
role: consumer
|
|
`;
|
|
expect(() => parseGroupConfig(yaml)).toThrow(/type "graphql" is invalid/i);
|
|
});
|
|
});
|
|
|
|
it('parses thrift manifest links', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
gateway: gateway-repo
|
|
orders: orders-repo
|
|
links:
|
|
- from: gateway
|
|
to: orders
|
|
type: thrift
|
|
contract: billing.v1.OrderService/PlaceOrder
|
|
role: consumer
|
|
`;
|
|
const config = parseGroupConfig(yaml);
|
|
expect(config.links[0].type).toBe('thrift');
|
|
expect(config.links[0].contract).toBe('billing.v1.OrderService/PlaceOrder');
|
|
});
|
|
|
|
it('throws on missing required fields', () => {
|
|
expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i);
|
|
expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i);
|
|
expect(() => parseGroupConfig('version: 1\nname: test')).toThrow(/repos.*required/i);
|
|
});
|
|
|
|
it('throws when a repos value is not a string (YAML number/boolean)', () => {
|
|
expect(() =>
|
|
parseGroupConfig(`version: 1
|
|
name: test
|
|
repos:
|
|
app: 12
|
|
`),
|
|
).toThrow(/non-empty registry name string/);
|
|
});
|
|
|
|
it('trims padded registry aliases so they match the registry name', () => {
|
|
const config = parseGroupConfig(`version: 1
|
|
name: test
|
|
repos:
|
|
app: " my-app "
|
|
`);
|
|
expect(config.repos.app).toBe('my-app');
|
|
});
|
|
|
|
it('allows empty repos object (fresh group before first add)', () => {
|
|
const yaml = `version: 1
|
|
name: new-group
|
|
repos: {}
|
|
`;
|
|
const config = parseGroupConfig(yaml);
|
|
expect(Object.keys(config.repos)).toHaveLength(0);
|
|
});
|
|
|
|
it('loadGroupConfig reads group.yaml from disk', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-group-load-'));
|
|
const yaml = `version: 1
|
|
name: disk-test
|
|
repos:
|
|
a: repo-a
|
|
`;
|
|
await fs.writeFile(path.join(dir, 'group.yaml'), yaml, 'utf-8');
|
|
const config = await loadGroupConfig(dir);
|
|
expect(config.name).toBe('disk-test');
|
|
expect(config.repos.a).toBe('repo-a');
|
|
});
|
|
|
|
it('throws on invalid version', () => {
|
|
expect(() => parseGroupConfig('version: 2\nname: test\nrepos:\n a: b')).toThrow(/version/i);
|
|
});
|
|
|
|
it('throws on invalid link role', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
a: repo-a
|
|
b: repo-b
|
|
links:
|
|
- from: a
|
|
to: b
|
|
type: http
|
|
contract: "/api"
|
|
role: invalid
|
|
`;
|
|
expect(() => parseGroupConfig(yaml)).toThrow(/role/i);
|
|
});
|
|
|
|
it('throws when link references non-existent repo path', () => {
|
|
const yaml = `
|
|
version: 1
|
|
name: test
|
|
repos:
|
|
a: repo-a
|
|
links:
|
|
- from: a
|
|
to: nonexistent
|
|
type: http
|
|
contract: "/api"
|
|
role: provider
|
|
`;
|
|
expect(() => parseGroupConfig(yaml)).toThrow(/nonexistent/i);
|
|
});
|
|
});
|