Merge branch 'feat/cpp-ue-macro-preprocessor' of https://github.com/hiKareeem/GitNexus into feat/cpp-ue-macro-preprocessor

This commit is contained in:
Kareem Albaba 2026-05-09 07:00:00 -04:00
commit f63fca3f7a
22 changed files with 2314 additions and 4 deletions

View file

@ -214,6 +214,7 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
gitnexus publish # Notify the understand-quickly registry (opt-in, see below)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
@ -228,6 +229,12 @@ gitnexus group status <name> # Check staleness of repos in a group
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Publishing to understand-quickly (opt-in)
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract.
### What Your AI Agent Gets
**16 tools** exposed via MCP (11 per-repo + 5 group):

View file

@ -143,6 +143,18 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
// Understand-Quickly registry integration (opt-in)
export {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_EVENT_TYPE,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
stripGitSuffix,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {

View file

@ -0,0 +1,151 @@
/**
* Understand-Quickly registry integration helpers.
*
* Pure, runtime-agnostic logic for opting in to publishing a GitNexus
* index to the [`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly)
* registry. Lives in `gitnexus-shared` so both the Node CLI and any
* future browser-side surface can construct identical dispatch payloads.
*
* Network I/O lives in the CLI command (`gitnexus/src/cli/publish.ts`)
* to keep this module free of Node-only imports — see the comment at
* the top of `gitnexus-shared/src/graph/types.ts`.
*
* The protocol contract (single dispatch event, no graph upload) is
* documented at:
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*/
/**
* URL of the registry repo's repository_dispatch endpoint. Hardcoded
* because the registry is the canonical home for this integration —
* users who want a private registry can fork and patch.
*/
export const UNDERSTAND_QUICKLY_DISPATCH_URL =
'https://api.github.com/repos/looptech-ai/understand-quickly/dispatches';
/**
* Event type the registry's sync workflow listens for.
* See `looptech-ai/understand-quickly/.github/workflows/sync.yml`.
*/
export const UNDERSTAND_QUICKLY_EVENT_TYPE = 'sync-entry';
/** Environment variable that gates the dispatch. */
export const UNDERSTAND_QUICKLY_TOKEN_ENV = 'UNDERSTAND_QUICKLY_TOKEN';
export interface UqDispatchPayload {
event_type: typeof UNDERSTAND_QUICKLY_EVENT_TYPE;
client_payload: {
/** `<owner>/<repo>` shape — must match the registered entry. */
id: string;
};
}
/**
* Build the JSON body for the `repository_dispatch` ping. Pure — no
* env reads, no network. Validates that `id` looks like `owner/repo`
* (one slash, no whitespace, both halves non-empty) so a misconfigured
* caller fails loudly before the round-trip.
*/
export function buildUqDispatchPayload(id: string): UqDispatchPayload {
if (!isValidOwnerRepo(id)) {
throw new Error(
`[understand-quickly] expected id of the form "owner/repo", got "${id}". ` +
`The registry uses this string to look up your entry in registry.json — ` +
`it must match the GitHub owner/repo of the source code, not a local path.`,
);
}
return {
event_type: UNDERSTAND_QUICKLY_EVENT_TYPE,
client_payload: { id },
};
}
/**
* `owner/repo` validation. Conservative on purpose: GitHub's actual
* naming rules are looser, but we want to catch local paths
* (`/Users/...`), bare slugs (`my-repo`), and accidental whitespace.
*
* Matches GitHub's published slug rules:
* owner: starts with alnum, then alnum/hyphen only, must end with
* alnum (no trailing hyphen — GitHub rejects this at account
* creation, so a `my-org-/repo` input would otherwise pass us
* and 422 from GitHub). No underscore, no dot. Length cap 39.
* repo: any of alnum/dot/hyphen/underscore. Length cap 100.
*/
export function isValidOwnerRepo(id: string): boolean {
return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/.test(id);
}
/**
* Strip a single trailing `.git` (case-insensitive) and any trailing
* slashes from a URL-ish string. Bounded linear: each character is
* visited at most twice, no backtracking.
*
* Replaces `s.replace(/\.git\/*$/i, '').replace(/\/+$/, '')` which
* CodeQL's polynomial-regex check (codeql/js/polynomial-redos) flags as
* a worst-case O(n²) on adversarial input like "////.../x".
*/
export function stripGitSuffix(input: string): string {
let end = input.length;
// Trim trailing '/'.
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
// Drop one trailing '.git' (case-insensitive).
if (end >= 4) {
const tail = input.slice(end - 4, end).toLowerCase();
if (tail === '.git') end -= 4;
}
// Trim trailing '/' that may have sat between '.git' and the rest.
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
return input.slice(0, end);
}
/**
* Parse `owner/repo` out of a git remote URL. Mirrors the heuristic in
* `gitnexus/src/storage/git.ts:parseRepoNameFromUrl` but keeps both
* halves so we can build a registry id. Returns `null` on shapes we
* don't recognise.
*
* Examples:
* git@github.com:looptech-ai/understand-quickly.git
* https://github.com/looptech-ai/understand-quickly
* ssh://git@github.com/looptech-ai/understand-quickly.git
*/
export function parseOwnerRepoFromRemote(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip a trailing `.git` (case-insensitive) and any trailing slashes
// so https://h/o/r and https://h/o/r.git collapse to the same id.
// Bounded-linear helper avoids the polynomial-regex CodeQL alert.
const stripped = stripGitSuffix(trimmed);
// SCP-form SSH (`git@host:owner/repo`). Capture host so we can reject
// non-GitHub remotes — a GitLab origin like
// `https://gitlab.example.com/group/sub/project.git` would otherwise
// silently dispatch the wrong id (LOW 9).
const ssh = stripped.match(/^[^@]+@([^:]+):([^/]+)\/([^/]+)$/);
if (ssh) {
const host = ssh[1].toLowerCase();
if (host !== 'github.com' && host !== 'www.github.com') return null;
return `${ssh[2]}/${ssh[3]}`;
}
// URL forms (https://, ssh://, git://, file://) — last two path segments.
const url2 = stripped.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/]+)\/(.+)$/);
if (url2) {
// Strip optional `userinfo@` (e.g. `ssh://git@github.com/...`).
const authority = url2[1];
const atIdx = authority.lastIndexOf('@');
const hostAndPort = atIdx >= 0 ? authority.slice(atIdx + 1) : authority;
// Strip `:port` suffix if present.
const colonIdx = hostAndPort.indexOf(':');
const host = (colonIdx >= 0 ? hostAndPort.slice(0, colonIdx) : hostAndPort).toLowerCase();
if (host !== 'github.com' && host !== 'www.github.com') return null;
const segments = url2[2].split('/').filter(Boolean);
if (segments.length >= 2) {
const [owner, repo] = segments.slice(-2);
return `${owner}/${repo}`;
}
}
return null;
}

View file

@ -160,6 +160,18 @@ program
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
program
.command('publish [path]')
.description(
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' +
'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' +
'`Repository dispatches: write` on looptech-ai/understand-quickly). ' +
'No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
)
.option('--id <owner/repo>', 'Override the registry id (defaults to the origin remote)')
.option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery')
.action(createLazyAction(() => import('./publish.js'), 'publishCommand'));
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.

232
gitnexus/src/cli/publish.ts Normal file
View file

@ -0,0 +1,232 @@
/**
* `gitnexus publish` — opt-in ping to the understand-quickly registry.
*
* Fires a single `repository_dispatch` event at
* `looptech-ai/understand-quickly` so the registry knows to refresh its
* entry for the current repo. Does NOT upload anything: per the
* understand-quickly protocol, the registry pulls the graph from a
* raw-GitHub URL the user controls.
*
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*
* Defaults:
* - Without `UNDERSTAND_QUICKLY_TOKEN` in the env, this is a no-op
* (prints one informational line, exit 0). Same shape as the
* `--publish` patterns in sibling tools.
* - With the token, fires the dispatch and reports the response code.
*
* The `id` is derived from the repo's `origin` remote unless the caller
* passes `--id <owner/repo>` explicitly. We deliberately do NOT auto-add
* the repo to the registry — registration is one-time and uses the
* `npx @understand-quickly/cli add` path documented in the protocol.
*/
import path from 'path';
import {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
} from 'gitnexus-shared';
import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js';
import { hasIndex } from '../storage/repo-manager.js';
import { cliInfo, cliError } from './cli-message.js';
export interface PublishOptions {
/** Override the auto-derived `owner/repo` id. */
id?: string;
/** Treat the cwd as the repo root (skip git-root walk). */
skipGit?: boolean;
}
const REGISTER_HINT =
'Register your repo once with: npx @understand-quickly/cli add\n' +
'Or use the wizard: https://looptech-ai.github.io/understand-quickly/add.html';
/**
* Hard cap on the dispatch fetch to keep CI publish steps from stalling
* for the OS TCP timeout (~2 min) when api.github.com is unreachable.
* Matches the pattern used in `src/core/embeddings/http-client.ts`.
*/
const DISPATCH_TIMEOUT_MS = 15_000;
export const publishCommand = async (
inputPath?: string,
options: PublishOptions = {},
): Promise<void> => {
// ── 0. Token gate FIRST — guarantees true no-op without the token. ──
// The README, CLI --help, and PR body all promise "exit 0 without
// UNDERSTAND_QUICKLY_TOKEN". Doing the index/repo-root checks before
// the token gate would make those promises false for users who haven't
// run `gitnexus analyze` yet but want to verify the command is wired.
const token = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
if (!token) {
cliInfo(
`[understand-quickly] ${UNDERSTAND_QUICKLY_TOKEN_ENV} is not set — skipping dispatch.\n` +
`Set it to a fine-grained PAT with "Repository dispatches: write" on ` +
`looptech-ai/understand-quickly to enable instant resync.\n` +
`(Without the token, the registry's nightly sync still picks up your entry.)`,
{ skipped: 'no-token' },
);
return;
}
// ── 1. Resolve the repo root (same precedence as `analyze`) ──────────
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (options.skipGit) {
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
cliError(
'[understand-quickly] not inside a git repository.\n' +
'Run from a repo, or pass --skip-git to publish from the current directory.',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
// ── 2. Confirm a GitNexus index exists ───────────────────────────────
// Publishing without an index is almost always a mistake — the
// registry's nightly sync would fetch a stale or missing graph file
// and mark the entry `missing`. Refuse loudly with a fix-it hint.
if (!(await hasIndex(repoPath))) {
cliError(
`[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` +
'Run `gitnexus analyze` first, then re-run `gitnexus publish`.',
);
process.exitCode = 1;
return;
}
// ── 3. Derive the registry id ─────────────────────────────────────────
const id =
options.id ?? parseOwnerRepoFromRemote(getRemoteOriginUrl(repoPath) ?? undefined) ?? null;
if (!id || !isValidOwnerRepo(id)) {
cliError(
`[understand-quickly] could not derive a registry id from this repo.\n` +
`Pass --id <owner/repo> explicitly (e.g. --id looptech-ai/${path.basename(repoPath)}).\n` +
REGISTER_HINT,
);
process.exitCode = 1;
return;
}
// ── 4. Fire the dispatch ─────────────────────────────────────────────
const payload = buildUqDispatchPayload(id);
let response: Response;
try {
response = await fetch(UNDERSTAND_QUICKLY_DISPATCH_URL, {
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
'User-Agent': 'gitnexus-cli',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
});
} catch (err) {
// `AbortSignal.timeout()` throws a `DOMException` with `name ===
// 'TimeoutError'` on Node 18.14+ (and on browsers/Bun). It is NOT
// a plain `AbortError`. Match the pattern used in
// gitnexus/src/core/embeddings/http-client.ts so the user sees the
// targeted "timed out" message instead of a generic "operation
// was aborted".
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
if (isTimeout) {
cliError(
`[understand-quickly] dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms. ` +
`Check network access to api.github.com and retry.`,
{ id },
);
} else {
const msg = err instanceof Error ? err.message : String(err);
cliError(`[understand-quickly] dispatch network error: ${msg}`, { id });
}
process.exitCode = 1;
return;
}
// GitHub returns 204 on success. Distinct branches for 401/403/404/422
// so users debug without checking the docs.
if (response.status === 204) {
await response.body?.cancel().catch(() => {});
// `getCurrentCommit` is only meaningful in the success path — moving
// it inside this branch removes a wasted child-process spawn on every
// error response (LOW 7).
const commit = getCurrentCommit(repoPath);
cliInfo(
`[understand-quickly] dispatched sync-entry for ${id}` +
(commit ? ` @ ${commit.slice(0, 7)}` : '') +
'.\n' +
`Note: a 204 only confirms GitHub accepted the dispatch. Whether the ` +
`registry workflow finds an entry for "${id}" is logged at ` +
`https://github.com/looptech-ai/understand-quickly/actions/workflows/sync.yml`,
{ id, commit, status: response.status },
);
return;
}
if (response.status === 401) {
cliError(
`[understand-quickly] dispatch returned 401 — the ${UNDERSTAND_QUICKLY_TOKEN_ENV} value is invalid or expired.\n` +
`Regenerate a fine-grained PAT at https://github.com/settings/personal-access-tokens ` +
`with Repository access scoped to looptech-ai/understand-quickly and the ` +
`"Repository dispatches: write" permission, then retry.`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 403) {
cliError(
`[understand-quickly] dispatch returned 403 — the token authenticated but ` +
`lacks the "Repository dispatches: write" permission on ` +
`looptech-ai/understand-quickly. Edit the PAT scopes and retry.`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 404) {
cliError(
`[understand-quickly] dispatch returned 404 — the token cannot reach ` +
`looptech-ai/understand-quickly. Verify the PAT has Repository access to ` +
`that exact repo (not just your own org).`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
if (response.status === 422) {
// Malformed event_type / client_payload — a code bug in this CLI,
// not a user mistake. Surface so we get bug reports.
const body422 = await response.text().catch(() => '');
cliError(
`[understand-quickly] dispatch returned 422 (this is a CLI bug; please report).\n` +
`Body: ${body422 || '(empty)'}`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
// 5xx and anything else → bubble the body so the user has something to act on.
const body = await response.text().catch(() => '');
cliError(
`[understand-quickly] dispatch failed with HTTP ${response.status}: ${body || '(empty body)'}`,
{ id, status: response.status },
);
process.exitCode = 1;
};

View file

@ -25,6 +25,8 @@ const DEFAULT_IGNORE_LIST = new Set([
'bower_components',
'jspm_packages',
'vendor', // PHP/Go
'third_party', // C/C++ (Google-style vendored dependencies)
'3rdparty', // C/C++ (alternate spelling, also Qt convention)
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
'venv',
'.venv',

View file

@ -4,9 +4,26 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'thrift', 'topic', 'lib', 'custom'];
const VALID_CONTRACT_TYPES: ContractType[] = [
'http',
'grpc',
'thrift',
'topic',
'lib',
'custom',
'include',
];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
// Defaults matter for backward compatibility: any group.yaml that omits a
// `detect.<type>` key inherits its value from this constant. Adding a new
// extractor that defaults to `true` silently changes the behavior of every
// existing group on the next sync. New extractors must default to `false`
// (opt-in) so operators consciously enable them via group.yaml.
//
// `includes`: opt-in. The C/C++ IncludeExtractor (PR #1156) ships disabled by
// default; enable with `detect.includes: true` for groups containing C/C++
// repos that need cross-repo header tracking.
const DEFAULT_DETECT = {
http: true,
grpc: true,
@ -14,6 +31,7 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: false,
workspace_deps: false,
};

View file

@ -0,0 +1,610 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
import Cpp from 'tree-sitter-cpp';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
import { logger } from '../../logger.js';
/**
* Cross-repo C/C++ `#include` dependency extractor.
*
* **Provider side:** registers every `.h/.hpp/.hxx/.hh` file in the repo
* as a provider contract with `include::<relative-path>`.
*
* **Consumer side:** parses all C/C++ source/header files for `#include "…"`
* directives, attempts suffix-based resolution against the repo's own file
* list (reusing the same algorithm as the single-repo ingestion pipeline),
* and emits unresolved include paths as consumer contracts.
*
* Matching: a consumer's `include::map/base/dice_map_view.h` in repo A
* matches a provider's `include::map/base/dice_map_view.h` in repo B via
* exact contract-id equality in `runExactMatch`.
*/
// ---------- constants ----------
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx).
// Spread keeps the subset relationship explicit so a future contributor adding
// a new header extension to HEADER_EXTENSIONS does not have to remember to
// also add it here.
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
/**
* Well-known C/C++ standard library headers that can appear in `#include "…"`
* form (some projects use quotes for system headers).
*/
const SYSTEM_HEADERS = new Set([
// C standard
'assert.h',
'complex.h',
'ctype.h',
'errno.h',
'fenv.h',
'float.h',
'inttypes.h',
'iso646.h',
'limits.h',
'locale.h',
'math.h',
'setjmp.h',
'signal.h',
'stdalign.h',
'stdarg.h',
'stdatomic.h',
'stdbool.h',
'stddef.h',
'stdint.h',
'stdio.h',
'stdlib.h',
'stdnoreturn.h',
'string.h',
'tgmath.h',
'threads.h',
'time.h',
'uchar.h',
'wchar.h',
'wctype.h',
// C++ standard (extensionless)
'algorithm',
'any',
'array',
'atomic',
'barrier',
'bit',
'bitset',
'cassert',
'cctype',
'cerrno',
'cfenv',
'cfloat',
'charconv',
'chrono',
'cinttypes',
'climits',
'clocale',
'cmath',
'codecvt',
'compare',
'complex',
'concepts',
'condition_variable',
'coroutine',
'csetjmp',
'csignal',
'cstdarg',
'cstddef',
'cstdint',
'cstdio',
'cstdlib',
'cstring',
'ctime',
'cuchar',
'cwchar',
'cwctype',
'deque',
'exception',
'execution',
'expected',
'filesystem',
'format',
'forward_list',
'fstream',
'functional',
'future',
'generator',
'initializer_list',
'iomanip',
'ios',
'iosfwd',
'iostream',
'istream',
'iterator',
'latch',
'limits',
'list',
'locale',
'map',
'mdspan',
'memory',
'memory_resource',
'mutex',
'new',
'numbers',
'numeric',
'optional',
'ostream',
'print',
'queue',
'random',
'ranges',
'ratio',
'regex',
'scoped_allocator',
'semaphore',
'set',
'shared_mutex',
'source_location',
'span',
'spanstream',
'sstream',
'stack',
'stacktrace',
'stdexcept',
'stdfloat',
'stop_token',
'streambuf',
'string',
'string_view',
'strstream',
'syncstream',
'system_error',
'thread',
'tuple',
'type_traits',
'typeindex',
'typeinfo',
'unordered_map',
'unordered_set',
'utility',
'valarray',
'variant',
'vector',
'version',
]);
/** Path prefixes that indicate system/kernel headers. */
const SYSTEM_PATH_PREFIXES = [
'sys/',
'net/',
'netinet/',
'arpa/',
'linux/',
'asm/',
'bits/',
'gnu/',
'mach/',
'machine/',
'xlocale/',
];
/** Regex fallback for files that exceed tree-sitter's 32 KB parse limit. */
const INCLUDE_REGEX = /^[ \t]*#\s*include\s*"([^"]+)"/gm;
// ---------- helpers ----------
/**
* Normalize an include path to a canonical lowercase forward-slash form.
*
* IMPORTANT — case-folding caveat (PR #1156 review finding #3):
* Header paths are lowercased so consumer `#include "Foo/Bar.h"` and
* provider file `Foo/Bar.h` normalize to the same contract-id. This is
* the right trade-off on case-insensitive filesystems (macOS, Windows)
* but on case-sensitive Linux filesystems two distinct headers `Foo.h`
* and `foo.h` in the same repo will collide onto the same provider
* contract-id; only one survives `dedupe()`. The gain (reliable
* cross-platform matching) outweighs the cost (extremely rare header
* casing collisions inside a single repo).
*/
function normalizeIncludePath(raw: string): string {
return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase();
}
/**
* Strip C/C++ block comments from a source blob. Used only by the
* regex-fallback path to avoid emitting consumer contracts for
* commented-out #include directives. Line comments (`// …`) cannot hide
* #include directives because the regex anchors on start-of-line.
* See PR #1156 review finding #5.
*/
function stripBlockComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}
function isAngleBracketInclude(rawNodeText: string): boolean {
const trimmed = rawNodeText.trim();
return trimmed.startsWith('<') && trimmed.endsWith('>');
}
function isSystemHeader(cleanedPath: string): boolean {
// Check well-known standard headers
if (SYSTEM_HEADERS.has(cleanedPath)) return true;
// Check system path prefixes
const lower = cleanedPath.toLowerCase();
return SYSTEM_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
function isHeaderFile(filePath: string): boolean {
return HEADER_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function getLanguageForFile(filePath: string): unknown | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.c':
case '.h':
return C;
case '.cpp':
case '.cc':
case '.cxx':
case '.hpp':
case '.hxx':
case '.hh':
return Cpp;
default:
return null;
}
}
/**
* Check whether an include path resolves to a file inside the local repo.
*
* Uses *exact full-path* matching on the suffix index — we never accept a
* truncated suffix match. For `#include "foo/bar.h"` this checks:
* (a) a file whose path ends with the full `foo/bar.h`
* (b) if the include omitted the extension, a file whose path ends with
* the include + one of the C/C++ header extensions
*
* Returns `true` when a local file matches — caller should suppress the
* cross-repo consumer contract.
*
* See PR #1156 review finding #4 (suffixResolve ambiguity).
*/
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
const candidates = [cleaned];
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
}
for (const c of candidates) {
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
}
return false;
}
// ---------- main class ----------
export class IncludeExtractor implements ContractExtractor {
type = 'include' as const;
/**
* Always returns `true`. NOT called by `sync.ts`, which gates extraction via
* `config.detect.includes` instead (see `sync.ts:174`). Kept solely to satisfy
* the `ContractExtractor` interface so the type stays uniform across extractors.
*/
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
// 1. Build the local file list using the same discovery as ingestion
// (createIgnoreFilter + getMaxFileSizeBytes). This guarantees the
// universe of provider/consumer paths matches the universe of File
// nodes in the LadybugDB graph — so no cross-link points at a UID
// that group impact cannot fan out to.
// (PR #1156 Codex follow-up: discovery aligned with ingestion.)
const allFiles = await this.discoverIndexableFiles(repoPath);
const normalizedFiles = allFiles.map((f) => f.replace(/\\/g, '/'));
const suffixIndex = buildSuffixIndex(normalizedFiles, allFiles);
// 2. Provider: register all header files
const providers = await this.extractProviders(dbExecutor, repoPath, allFiles);
// 3. Consumer: filter the shared discovery list for source extensions
// and parse #include directives in those files.
const sourceFiles = allFiles.filter((f) =>
SOURCE_EXTENSIONS.has(path.extname(f).toLowerCase()),
);
const consumers = await this.extractConsumers(repoPath, sourceFiles, suffixIndex);
return this.dedupe([...providers, ...consumers]);
}
/**
* Discover repo-relative file paths using exactly the same rules the
* ingestion pipeline uses (`walkRepositoryPaths` in
* `gitnexus/src/core/ingestion/filesystem-walker.ts`):
* - `createIgnoreFilter` honors `.gitignore`, `.gitnexusignore`, the
* hardcoded ignore list, and `.gitnexusignore` last-match-wins
* negation.
* - `getMaxFileSizeBytes()` drops files larger than the cap so we
* never emit `File:<rel>` UIDs for files ingestion would skip.
*
* Uses sequential stat — there is no `READ_CONCURRENCY` batching here
* because group sync runs at startup-time, not the ingestion hot path,
* and parallelism gains are not worth the import-graph weight.
*
* MAINTENANCE: if `walkRepositoryPaths` changes its glob options, ignore
* filter shape, or size-cap logic, mirror those changes here. The two
* implementations exist because the consumers need different return
* shapes (string[] vs ScannedFile[]) and different concurrency, but
* they MUST agree on which files are reachable — that is what makes
* `File:<rel>` UIDs in cross-links correspond to graph File nodes.
*/
private async discoverIndexableFiles(repoPath: string): Promise<string[]> {
const ignoreFilter = await createIgnoreFilter(repoPath);
const maxFileSizeBytes = getMaxFileSizeBytes();
const candidates = await glob('**/*', {
cwd: repoPath,
nodir: true,
dot: false,
ignore: ignoreFilter,
});
const survivors: string[] = [];
for (const rel of candidates) {
try {
const stat = await fs.stat(path.join(repoPath, rel));
if (stat.size > maxFileSizeBytes) continue;
survivors.push(rel);
} catch (err) {
// ENOENT is the documented benign race (glob enumerated a file
// that was deleted before we stat'd it — same race
// walkRepositoryPaths absorbs via Promise.allSettled). Anything
// else (EACCES, EMFILE, EIO) deserves a warning so an operator
// can spot a permission/resource problem instead of silently
// shipping fewer contracts than expected.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ENOENT') {
logger.warn(
{ err: (err as Error).message, file: rel, repoPath },
'⚠️ IncludeExtractor: stat failed during discovery; skipping file',
);
}
}
}
return survivors;
}
// ---------- provider extraction ----------
private async extractProviders(
dbExecutor: CypherExecutor | null,
repoPath: string,
allFiles: string[],
): Promise<ExtractedContract[]> {
// Strategy A: graph-assisted
if (dbExecutor) {
const graphProviders = await this.extractProvidersGraph(dbExecutor, repoPath);
if (graphProviders.length > 0) return graphProviders;
}
// Strategy B: filesystem fallback
return this.extractProvidersFallback(repoPath, allFiles);
}
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
try {
const rows = await db(
`MATCH (f:File)
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
RETURN f.filePath AS filePath, f.id AS fileId`,
);
// gitnexus analyze stores absolute paths in the File.filePath column.
// Provider contract IDs MUST be repo-relative — otherwise the consumer
// emits `include::map/base/view.h` and the provider emits
// `include::/abs/path/to/repo/map/base/view.h`, which never match
// through runExactMatch and the cross-link silently disappears.
// (PR #1156 follow-up review: graph provider absolute-path bug.)
const normalizedRepoPath = path.resolve(repoPath);
const out: ExtractedContract[] = [];
for (const r of rows) {
if (typeof r.filePath !== 'string' || !r.filePath) continue;
const absolute = r.filePath as string;
const rel = path.relative(normalizedRepoPath, absolute);
// Skip rows that resolve outside the repo (e.g., system headers
// somehow indexed, or stale absolute paths from a different machine).
// path.relative returns a `..`-prefixed path or an absolute path
// when the target is outside the base — both are wrong for our IDs.
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
const normalizedRel = rel.replace(/\\/g, '/');
out.push({
contractId: `include::${normalizeIncludePath(normalizedRel)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: String(r.fileId ?? ''),
symbolRef: { filePath: normalizedRel, name: path.basename(normalizedRel) },
symbolName: path.basename(normalizedRel),
confidence: 1.0,
meta: { source: 'graph' },
});
}
return out;
} catch {
return [];
}
}
private extractProvidersFallback(_repoPath: string, allFiles: string[]): ExtractedContract[] {
return allFiles
.filter((f) => isHeaderFile(f))
.map((f) => {
const filePath = f.replace(/\\/g, '/');
return {
contractId: `include::${normalizeIncludePath(filePath)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: `File:${filePath}`,
symbolRef: { filePath, name: path.basename(filePath) },
symbolName: path.basename(filePath),
confidence: 0.95,
meta: { source: 'filesystem' },
};
});
}
// ---------- consumer extraction ----------
private async extractConsumers(
repoPath: string,
sourceFiles: string[],
suffixIndex: SuffixIndex,
): Promise<ExtractedContract[]> {
const parser = new Parser();
const out: ExtractedContract[] = [];
// Compile the include query once per grammar to avoid re-compilation per file
const queryCache = new Map<unknown, Parser.Query>();
for (const rel of sourceFiles) {
const lang = getLanguageForFile(rel);
if (!lang) continue;
const content = readSafe(repoPath, rel);
if (!content) continue;
let query = queryCache.get(lang);
if (!query) {
try {
query = new Parser.Query(lang, INCLUDE_QUERY_SRC);
queryCache.set(lang, query);
} catch {
continue;
}
}
// Collect raw include paths: tree-sitter first, regex fallback for large files.
// `extractionSource` is stamped on each emitted consumer contract so
// regex-fallback contracts stay auditable post-hoc (PR #1156 review finding #6).
let rawIncludes: string[];
let extractionSource: 'tree_sitter' | 'regex_fallback';
try {
parser.setLanguage(lang);
const tree = parser.parse(content);
let matches: Parser.QueryMatch[];
try {
matches = query.matches(tree.rootNode);
} catch {
matches = [];
}
rawIncludes = [];
extractionSource = 'tree_sitter';
for (const match of matches) {
const sourceNode = match.captures.find((c) => c.name === 'import.source');
if (!sourceNode) continue;
const rawText = sourceNode.node.text;
if (isAngleBracketInclude(rawText)) continue;
const cleaned = rawText.replace(/['"<>]/g, '');
if (cleaned && cleaned.length <= 2048) rawIncludes.push(cleaned);
}
} catch {
// tree-sitter failed (e.g. file > 32 KB) — fall back to regex.
// Strip block comments first so we don't emit a consumer contract
// for a commented-out #include (PR #1156 review finding #5).
rawIncludes = [];
extractionSource = 'regex_fallback';
const scanTarget = stripBlockComments(content);
INCLUDE_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = INCLUDE_REGEX.exec(scanTarget)) !== null) {
if (m[1] && m[1].length <= 2048) rawIncludes.push(m[1]);
}
}
for (const cleaned of rawIncludes) {
// Filter: skip known system headers and system path prefixes
if (isSystemHeader(cleaned)) continue;
// Skip relative-up includes: `#include "../include/foo.h"` is
// almost always an intra-repo reference. The suffix index is built
// from repo-relative paths, so isLocalInclude can never match
// `../foo.h`, and emitting it as a consumer contract just pollutes
// the registry with an entry no provider can ever satisfy.
// (PR #1156 follow-up review: `../` relative includes produce
// spurious consumer contracts.)
if (cleaned.startsWith('../') || cleaned.startsWith('..\\')) continue;
// Skip macro-style includes: `#include PLATFORM_HEADER` parses as an
// identifier under tree-sitter's `(_) @import.source` wildcard. The
// identifier text passes the strip/clean step unchanged, so without
// this guard we would emit `include::platform_header` as a consumer
// contract — and no provider in any repo will ever expose a contract
// for a macro identifier (no file is named `PLATFORM_HEADER`). The
// contract would sit permanently orphaned in the registry. Real
// header references always contain a path separator (`/`, `\`) or an
// extension dot (`foo.h`), so an absent both is a reliable signal we
// are looking at a macro identifier. (PR #1156 follow-up review:
// macro includes emit orphaned consumer contracts.)
if (!/[./\\]/.test(cleaned)) continue;
// Local resolution (PR #1156 review finding #4): only accept an
// exact-suffix match on the *full* include path. The generic
// suffixResolve() iterates all truncated suffixes, which would
// silently suppress a cross-repo `#include "map/base/view.h"`
// when the local repo has any `internal/view.h` — a realistic
// false-negative in large C++ codebases. Here we only resolve
// locally if a file path ends with the complete include string
// (optionally re-appending one of the C/C++ header extensions
// when the include already omits it).
if (isLocalInclude(cleaned, suffixIndex)) continue;
// Unresolved: emit as consumer contract
const normalizedRel = rel.replace(/\\/g, '/');
out.push({
contractId: `include::${normalizeIncludePath(cleaned)}`,
type: 'include' as const,
role: 'consumer' as const,
symbolUid: `File:${normalizedRel}`,
symbolRef: { filePath: normalizedRel, name: cleaned },
symbolName: cleaned,
confidence: 0.85,
meta: {
source: extractionSource,
includePath: cleaned,
},
});
}
}
return out;
}
// ---------- deduplication ----------
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}

View file

@ -274,6 +274,14 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'include') {
rows = await executor(
`MATCH (f:File) WHERE f.filePath = $contract
RETURN f.id AS uid, f.name AS name, f.filePath AS filePath
ORDER BY f.filePath ASC
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// Workspace extractors produce qualified contracts like "mathlex::Expression".
// Graph nodes store the unqualified symbol name ("Expression"), so strip
@ -358,6 +366,8 @@ export class ManifestExtractor {
return `lib::${contract}`;
case 'custom':
return `custom::${contract}`;
case 'include':
return `include::${contract}`;
default: {
const _exhaustive: never = type;
throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`);

View file

@ -217,6 +217,10 @@ export async function buildThriftContext(repoPath: string): Promise<ThriftContex
cwd: repoPath,
absolute: false,
nodir: true,
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
});
const namespacesByThrift = new Map<string, string>();
@ -290,6 +294,10 @@ export class ThriftExtractor implements ContractExtractor {
cwd: repoPath,
absolute: false,
nodir: true,
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
});

View file

@ -107,6 +107,8 @@ export function normalizeContractId(id: string): string {
return `topic::${rest.trim().toLowerCase()}`;
case 'lib':
return `lib::${rest.toLowerCase()}`;
case 'include':
return `include::${rest.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase()}`;
default:
return id;
}

View file

@ -4,6 +4,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { randomBytes } from 'node:crypto';
import type { ContractRegistry } from './types.js';
import { retryRename } from './bridge-db.js';
/**
* Build an unpredictable suffix for atomic-write tmp files. Replaces the
@ -59,7 +60,13 @@ export async function writeContractRegistry(
} finally {
await handle.close();
}
await fsp.rename(tmpPath, targetPath);
// retryRename absorbs the documented Windows EPERM/EBUSY/EACCES race that
// fires when AV scanners or another concurrent rename briefly hold the
// destination handle between rename calls. Same helper bridge-db.ts uses
// (lines 304, 583, 587, 595, 605, 677) for the bridge.lbug atomic swap —
// single source of truth for the Windows-rename pattern across the group
// package.
await retryRename(tmpPath, targetPath);
}
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {

View file

@ -8,12 +8,14 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import { writeBridge } from './bridge-db.js';
import type { ContractRegistry } from './types.js';
import { logger } from '../logger.js';
@ -100,6 +102,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
const includeEx = new IncludeExtractor();
dbExecutors = new Map<string, CypherExecutor>();
const openPoolIds: string[] = [];
@ -168,6 +171,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.includes) {
const extracted = await includeEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
const metaPath = path.join(handle.storagePath, 'meta.json');
try {
const raw = await fs.readFile(metaPath, 'utf-8');
@ -270,6 +284,28 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
// writeBridge failure (disk full, schema error, permission denied) must
// not mask the registry — contracts.json was just written successfully
// and is the canonical source of truth. A stale or absent bridge
// degrades impact queries to empty results, which is recoverable on
// the next sync. Surface the failure as a warning so operators can
// act, but do not propagate it.
// (PR #1156 follow-up review: writeBridge error in sync.ts propagates
// uncaught.)
try {
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
{ err: msg, groupDir: opts.groupDir },
'⚠️ writeBridge failed; contracts.json is intact but bridge.lbug is stale. Re-run `gitnexus group sync` to retry.',
);
}
}
return {

View file

@ -1,4 +1,4 @@
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom';
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
@ -28,6 +28,7 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}

View file

@ -0,0 +1,195 @@
/**
* Integration test: IncludeExtractor output → group matching → bridge DB.
*
* Covers PR #1156 review finding #7: verifies that the full runtime path
* (IncludeExtractor → StoredContract → runExactMatch → CrossLinks → writeBridge)
* stays wired up. A regression in either normalizeContractId or the include
* branch of ManifestExtractor.resolveSymbol would produce 0 cross-links and
* fail this test.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseGroupConfig } from '../../../src/core/group/config-parser.js';
import { syncGroup } from '../../../src/core/group/sync.js';
import type { StoredContract } from '../../../src/core/group/types.js';
import { IncludeExtractor } from '../../../src/core/group/extractors/include-extractor.js';
import { normalizeContractId } from '../../../src/core/group/matching.js';
const GROUP_YAML = [
'version: 1',
'name: include-test-group',
'description: "IncludeExtractor integration test"',
'',
'repos:',
' app/provider: include-provider',
' app/consumer: include-consumer',
'',
'links: []',
'packages: {}',
'',
'detect:',
' http: false',
' grpc: false',
' topics: false',
' shared_libs: false',
' includes: true',
' embedding_fallback: false',
'',
'matching:',
' bm25_threshold: 0.7',
' embedding_threshold: 0.65',
' max_candidates_per_step: 3',
].join('\n');
describe('IncludeExtractor → syncGroup integration (finding #7)', () => {
it('produces a CrossLink when provider and consumer emit the same include contract-id', async () => {
const config = parseGroupConfig(GROUP_YAML);
// Mock the IncludeExtractor output directly — a header provider in one
// repo and a quoted #include consumer in the other, both normalized to
// the same include::map/base/view.h contract-id.
const mockContracts: StoredContract[] = [
{
contractId: 'include::map/base/view.h',
type: 'include',
role: 'provider',
symbolUid: 'File:map/base/view.h',
symbolRef: { filePath: 'map/base/view.h', name: 'view.h' },
symbolName: 'view.h',
confidence: 0.95,
meta: { source: 'filesystem' },
repo: 'app/provider',
},
{
contractId: 'include::map/base/view.h',
type: 'include',
role: 'consumer',
symbolUid: 'File:src/controller.cpp',
symbolRef: { filePath: 'src/controller.cpp', name: 'map/base/view.h' },
symbolName: 'map/base/view.h',
confidence: 0.85,
meta: { source: 'tree_sitter', includePath: 'map/base/view.h' },
repo: 'app/consumer',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => mockContracts,
skipWrite: true,
});
const includeLinks = result.crossLinks.filter((l) => l.type === 'include');
expect(includeLinks.length).toBeGreaterThanOrEqual(1);
const link = includeLinks[0];
expect(link.contractId).toBe('include::map/base/view.h');
expect(link.matchType).toBe('exact');
expect(link.from.repo).toBe('app/consumer');
expect(link.to.repo).toBe('app/provider');
});
it('normalizes mixed-case / backslash include paths to the same contract-id end-to-end', async () => {
const config = parseGroupConfig(GROUP_YAML);
// Provider writes the canonical form; consumer's include has mixed case
// and a backslash. After normalizeContractId they must still match.
const providerId = 'include::map/base/view.h';
const rawConsumerId = 'include::Map\\Base\\View.h';
// Sanity — normalizeContractId must collapse them.
expect(normalizeContractId(rawConsumerId)).toBe(providerId);
const mockContracts: StoredContract[] = [
{
contractId: providerId,
type: 'include',
role: 'provider',
symbolUid: 'File:map/base/view.h',
symbolRef: { filePath: 'map/base/view.h', name: 'view.h' },
symbolName: 'view.h',
confidence: 0.95,
meta: { source: 'filesystem' },
repo: 'app/provider',
},
{
contractId: rawConsumerId,
type: 'include',
role: 'consumer',
symbolUid: 'File:src/controller.cpp',
symbolRef: { filePath: 'src/controller.cpp', name: 'Map/Base/View.h' },
symbolName: 'Map/Base/View.h',
confidence: 0.85,
meta: { source: 'tree_sitter', includePath: 'Map\\Base\\View.h' },
repo: 'app/consumer',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => mockContracts,
skipWrite: true,
});
const includeLinks = result.crossLinks.filter((l) => l.type === 'include');
expect(includeLinks.length).toBeGreaterThanOrEqual(1);
});
it('round-trip: extractor output from two real temp repos produces matching contract-ids', async () => {
// Drives the extractor directly (no `syncGroup`) against two on-disk
// fixture repos, then hands the StoredContract-shaped output to
// syncGroup via extractorOverride. This exercises the real extraction
// code + the matching pipeline together.
const providerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-int-provider-'));
const consumerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-int-consumer-'));
try {
fs.mkdirSync(path.join(providerDir, 'shared/api'), { recursive: true });
fs.writeFileSync(
path.join(providerDir, 'shared/api/client.h'),
'#pragma once\nstruct Client {};',
);
fs.mkdirSync(path.join(consumerDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(consumerDir, 'src/main.cpp'),
'#include "shared/api/client.h"\nint main(){return 0;}',
);
const extractor = new IncludeExtractor();
const providerOutput = await extractor.extract(null, providerDir, {
id: 'provider',
path: 'app/provider',
repoPath: providerDir,
storagePath: path.join(providerDir, '.gitnexus'),
});
const consumerOutput = await extractor.extract(null, consumerDir, {
id: 'consumer',
path: 'app/consumer',
repoPath: consumerDir,
storagePath: path.join(consumerDir, '.gitnexus'),
});
const stored: StoredContract[] = [
...providerOutput
.filter((c) => c.role === 'provider')
.map((c) => ({ ...c, repo: 'app/provider' })),
...consumerOutput
.filter((c) => c.role === 'consumer')
.map((c) => ({ ...c, repo: 'app/consumer' })),
];
const config = parseGroupConfig(GROUP_YAML);
const result = await syncGroup(config, {
extractorOverride: async () => stored,
skipWrite: true,
});
const includeLinks = result.crossLinks.filter((l) => l.type === 'include');
expect(includeLinks.length).toBeGreaterThanOrEqual(1);
expect(includeLinks[0].contractId).toBe('include::shared/api/client.h');
expect(includeLinks[0].matchType).toBe('exact');
} finally {
fs.rmSync(providerDir, { recursive: true, force: true });
fs.rmSync(consumerDir, { recursive: true, force: true });
}
});
});

View file

@ -10,6 +10,9 @@ vi.mock('../../src/cli/mcp.js', () => ({
vi.mock('../../src/cli/setup.js', () => ({
setupCommand: vi.fn(),
}));
vi.mock('../../src/cli/publish.js', () => ({
publishCommand: vi.fn(),
}));
describe('CLI commands', () => {
describe('version', () => {
@ -84,4 +87,11 @@ describe('CLI commands', () => {
expect(typeof setupCommand).toBe('function');
});
});
describe('publishCommand', () => {
it('is a function', async () => {
const { publishCommand } = await import('../../src/cli/publish.js');
expect(typeof publishCommand).toBe('function');
});
});
});

View file

@ -63,4 +63,17 @@ describe('CLI help surface', () => {
expect(result.stdout).toContain('--model <model>');
expect(result.stdout).toContain('--gist');
});
it('publish help names the registry, the token env var, and the opt-out behaviour', () => {
const result = runHelp('publish');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--id <owner/repo>');
expect(result.stdout).toContain('--skip-git');
// Discoverability contract: a contributor scanning `--help` must see
// (a) which registry this dispatches to, and (b) the env var that
// gates the opt-in. Both are part of the no-token contract.
expect(result.stdout).toContain('understand-quickly');
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
});
});

View file

@ -75,6 +75,62 @@ repos:
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);
});
});
it('parses thrift manifest links', () => {
const yaml = `
version: 1

View file

@ -0,0 +1,563 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { IncludeExtractor } from '../../../src/core/group/extractors/include-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
import { normalizeContractId } from '../../../src/core/group/matching.js';
describe('IncludeExtractor', () => {
let tmpDir: string;
let extractor: IncludeExtractor;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-'));
extractor = new IncludeExtractor();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeFile(relPath: string, content: string): void {
const full = path.join(tmpDir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: 'test/app',
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
});
// ---- Provider detection ----
describe('provider extraction', () => {
it('registers .h files as providers', async () => {
writeFile('map/base/view.h', '#pragma once\nclass View {};');
writeFile('map/base/types.h', '#pragma once\nstruct Point {};');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(2);
const ids = providers.map((p) => p.contractId).sort();
expect(ids).toEqual(['include::map/base/types.h', 'include::map/base/view.h']);
expect(providers[0].type).toBe('include');
expect(providers[0].confidence).toBeGreaterThanOrEqual(0.95);
});
it('registers .hpp files as providers', async () => {
writeFile('utils/helper.hpp', '#pragma once\ntemplate<class T> T id(T x) { return x; }');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('include::utils/helper.hpp');
});
it('does not register .cpp files as providers', async () => {
writeFile('src/main.cpp', 'int main() { return 0; }');
writeFile('src/utils.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('include::src/utils.h');
});
});
// ---- Consumer detection ----
describe('consumer extraction', () => {
it('emits unresolved includes as consumers', async () => {
writeFile(
'src/main.cpp',
`#include "map/base/view.h"
#include "map/base/types.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(2);
const ids = consumers.map((c) => c.contractId).sort();
expect(ids).toEqual(['include::map/base/types.h', 'include::map/base/view.h']);
expect(consumers[0].type).toBe('include');
expect(consumers[0].confidence).toBe(0.85);
});
it('skips locally resolved includes', async () => {
writeFile('map/base/view.h', '#pragma once\nclass View {};');
writeFile(
'src/main.cpp',
`#include "map/base/view.h"
#include "external/lib.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Only external/lib.h should be a consumer — map/base/view.h resolves locally
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::external/lib.h');
});
it('skips angle-bracket includes', async () => {
writeFile(
'src/main.cpp',
`#include <stdio.h>
#include <vector>
#include "app/interface.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::app/interface.h');
});
it('skips well-known system headers in quotes', async () => {
writeFile(
'src/main.cpp',
`#include "stdio.h"
#include "stdlib.h"
#include "app/config.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::app/config.h');
});
it('skips system path prefixes', async () => {
writeFile(
'src/main.c',
`#include "sys/types.h"
#include "linux/input.h"
#include "mylib/types.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::mylib/types.h');
});
});
// ---- Cross-repo matching scenario ----
describe('cross-repo matching', () => {
it('provider and consumer produce matching contractIds', async () => {
// Simulate provider repo (header-only)
const providerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-provider-'));
const providerFile = path.join(providerDir, 'map/base/dice_map_view.h');
fs.mkdirSync(path.dirname(providerFile), { recursive: true });
fs.writeFileSync(providerFile, '#pragma once\nclass DiceMapView {};');
// Simulate consumer repo
const consumerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-consumer-'));
const consumerFile = path.join(consumerDir, 'src/controller.cpp');
fs.mkdirSync(path.dirname(consumerFile), { recursive: true });
fs.writeFileSync(consumerFile, '#include "map/base/dice_map_view.h"\nvoid init() {}');
try {
const providerContracts = await extractor.extract(null, providerDir, makeRepo(providerDir));
const consumerContracts = await extractor.extract(null, consumerDir, makeRepo(consumerDir));
const providers = providerContracts.filter((c) => c.role === 'provider');
const consumers = consumerContracts.filter((c) => c.role === 'consumer');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(consumers.length).toBeGreaterThanOrEqual(1);
const providerIds = new Set(providers.map((p) => normalizeContractId(p.contractId)));
const consumerIds = consumers.map((c) => normalizeContractId(c.contractId));
// The consumer's include path should match a provider's file path
expect(providerIds.has(consumerIds[0])).toBe(true);
} finally {
fs.rmSync(providerDir, { recursive: true, force: true });
fs.rmSync(consumerDir, { recursive: true, force: true });
}
});
});
// ---- Review finding #4: suffixResolve ambiguity ----
describe('finding #4: suffix-ambiguity does not silently suppress cross-repo include', () => {
it('emits a cross-repo contract when the include path does not match any local file (even if a shorter suffix does)', async () => {
// local repo has `internal/api.h` but NOT `ext/api.h`
writeFile('internal/api.h', '#pragma once');
writeFile(
'src/main.cpp',
`#include "ext/api.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Previously suffixResolve would match `api.h` against `internal/api.h`
// and drop the cross-repo contract. After finding #4 fix, we only
// accept exact full-path matches — so `ext/api.h` must still be
// emitted as a consumer contract.
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::ext/api.h');
});
it('still suppresses a local include when the FULL path matches', async () => {
writeFile('ext/api.h', '#pragma once');
writeFile('src/main.cpp', '#include "ext/api.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
it('resolves locally when include omits extension and a matching .h exists', async () => {
writeFile('foo/bar.h', '#pragma once');
writeFile('src/main.cpp', '#include "foo/bar"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
});
// ---- Review finding #5: regex fallback must strip block comments ----
describe('finding #5: regex fallback ignores block-commented includes', () => {
it('does not emit a contract for an #include inside /* ... */', async () => {
// Force regex fallback by producing a file larger than tree-sitter's
// 32 KB hard cap. The include we care about lives inside a block
// comment that spans the file.
const filler = 'int dummy_' + 'x'.repeat(32) + ' = 0;\n'.repeat(1200);
const content = `/*
* Historical include, kept for reference only:
* #include "legacy/old-api.h"
*/
${filler}
#include "real/api.h"
int main(){return 0;}`;
writeFile('src/huge.cpp', content);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const ids = consumers.map((c) => c.contractId);
// The live include should appear; the commented-out one must NOT.
expect(ids).toContain('include::real/api.h');
expect(ids).not.toContain('include::legacy/old-api.h');
});
});
// ---- Review finding #6: meta.source must reflect which extraction path ran ----
describe('finding #6: meta.source reflects extraction path', () => {
it('stamps `tree_sitter` on contracts produced via AST walking', async () => {
writeFile('src/main.cpp', '#include "app/small.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect((consumers[0].meta as { source?: string } | undefined)?.source).toBe('tree_sitter');
});
it('meta.source is one of the two documented values (tree_sitter | regex_fallback)', async () => {
// Regex fallback is a defensive branch that only fires if
// parser.setLanguage() or parser.parse() throws. In practice
// tree-sitter-c/cpp handles realistic inputs, so we only assert
// the meta.source contract: it is always present and always one of
// the two documented values. This guards against future regressions
// that might hard-code the wrong string.
writeFile('src/main.cpp', '#include "ext/whatever.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumer = contracts.find((c) => c.role === 'consumer');
expect(consumer).toBeDefined();
const src = (consumer?.meta as { source?: string } | undefined)?.source;
expect(['tree_sitter', 'regex_fallback']).toContain(src);
});
});
// ---- Review finding #3: provider id collision on case-sensitive FS ----
describe('finding #3: case-folding is documented and deterministic', () => {
it('collapses `Foo.h` and `foo.h` onto the same provider contract-id (documented trade-off)', async () => {
writeFile('Foo.h', '#pragma once\n// Capital Foo');
// On case-insensitive filesystems (macOS default) the second writeFile
// will overwrite the first, so we only create this when distinct files
// can coexist (case-sensitive FS, e.g. Linux CI).
try {
fs.writeFileSync(path.join(tmpDir, 'foo.h'), '#pragma once\n// lowercase foo');
} catch {
// Ignore — some FS won't allow both names to coexist.
}
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
const ids = providers.map((p) => p.contractId);
// Both files (if they coexist) must normalize to the same id.
// dedupe() keeps only one; caller code must be aware of this.
expect(ids).toContain('include::foo.h');
// Never see a mixed-case contract-id leak out.
expect(ids.every((id) => id === id.toLowerCase())).toBe(true);
});
});
// ---- Deduplication ----
describe('deduplication', () => {
it('deduplicates same include from multiple source files', async () => {
writeFile('src/a.cpp', '#include "ext/api.h"\nvoid a() {}');
writeFile('src/b.cpp', '#include "ext/api.h"\nvoid b() {}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Both files include "ext/api.h" — each should produce a separate
// consumer contract (different symbolRef.filePath)
expect(consumers).toHaveLength(2);
const files = consumers.map((c) => c.symbolRef.filePath).sort();
expect(files).toEqual(['src/a.cpp', 'src/b.cpp']);
});
});
// ---- normalizeContractId ----
describe('normalizeContractId for include', () => {
it('lowercases the path', () => {
expect(normalizeContractId('include::Map/Base/Foo.h')).toBe('include::map/base/foo.h');
});
it('normalizes backslashes', () => {
expect(normalizeContractId('include::map\\base\\foo.h')).toBe('include::map/base/foo.h');
});
it('strips leading ./', () => {
expect(normalizeContractId('include::./foo.h')).toBe('include::foo.h');
});
it('collapses consecutive slashes', () => {
expect(normalizeContractId('include::map//base///foo.h')).toBe('include::map/base/foo.h');
});
});
// ---- PR #1156 follow-up: `../` relative includes ----
describe('follow-up: `../` relative includes are skipped', () => {
it('does not emit a consumer contract for `#include "../foo.h"`', async () => {
// Producer: a header that exists locally but only via parent reference
writeFile('include/foo.h', '#pragma once');
writeFile(
'src/sub/main.cpp',
`#include "../../include/foo.h"
#include "real/cross_repo.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Only `real/cross_repo.h` should remain — the `..`-prefixed include
// is intra-repo noise that no provider can ever satisfy.
expect(consumers.map((c) => c.contractId)).toEqual(['include::real/cross_repo.h']);
});
it('skips backslash-form `..\\` for completeness', async () => {
writeFile(
'src/main.cpp',
`#include "..\\\\sibling\\\\foo.h"
#include "remote/header.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const ids = consumers.map((c) => c.contractId);
expect(ids).toContain('include::remote/header.h');
expect(ids.some((id) => id.includes('..'))).toBe(false);
});
});
// ---- PR #1156 follow-up: macro-style includes ----
describe('follow-up: macro-style #include emits no consumer contract', () => {
it('does not emit a consumer contract for `#include PLATFORM_HEADER` (no separator, no dot)', async () => {
// `#include PLATFORM_HEADER` parses under tree-sitter as an identifier
// node, slips past the existing system-header / `..` filters, and used
// to leak through as a permanently orphaned consumer contract because
// no file is ever named `PLATFORM_HEADER`. Verify the macro guard
// suppresses it while preserving the real cross-repo include.
writeFile(
'src/main.cpp',
`#include PLATFORM_HEADER
#include "real/api.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.map((c) => c.contractId)).toEqual(['include::real/api.h']);
});
it('skips multiple macro identifiers in the same translation unit', async () => {
writeFile(
'src/cfg.cpp',
`#include CONFIG_HEADER
#include PLATFORM_HEADER
#include ASSERT_H_
int main(){return 0;}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
});
// ---- PR #1156 follow-up: graph provider absolute paths ----
describe('follow-up: extractProvidersGraph strips repo root from absolute paths', () => {
it('produces repo-relative contract IDs when the graph returns absolute paths', async () => {
writeFile('map/base/view.h', '#pragma once\nclass View {};');
writeFile('utils/types.hpp', '#pragma once');
// Stub the Cypher executor to return absolute paths the way
// gitnexus analyze actually persists them.
const absolute1 = path.join(tmpDir, 'map/base/view.h');
const absolute2 = path.join(tmpDir, 'utils/types.hpp');
const stubDb = async () => [
{ filePath: absolute1, fileId: 'File:abs:1' },
{ filePath: absolute2, fileId: 'File:abs:2' },
];
const contracts = await extractor.extract(stubDb, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
const ids = providers.map((p) => p.contractId).sort();
expect(ids).toEqual(['include::map/base/view.h', 'include::utils/types.hpp']);
expect(providers.every((p) => p.meta?.source === 'graph')).toBe(true);
});
it('drops graph rows whose path resolves outside the repo root', async () => {
writeFile('local/header.h', '#pragma once');
const absoluteLocal = path.join(tmpDir, 'local/header.h');
const stubDb = async () => [
{ filePath: absoluteLocal, fileId: 'File:1' },
// Stale absolute path from a different machine — must be skipped.
{ filePath: '/some/other/repo/foreign.h', fileId: 'File:2' },
];
const contracts = await extractor.extract(stubDb, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.map((p) => p.contractId)).toEqual(['include::local/header.h']);
});
});
// ---- PR #1156 Codex follow-up: discovery aligned with ingestion ----
describe('follow-up: file discovery honors createIgnoreFilter and getMaxFileSizeBytes', () => {
it('does not emit a provider contract for a header excluded by .gitignore', async () => {
writeFile('.gitignore', 'vendor-headers/\n');
writeFile('vendor-headers/blocked.h', '#pragma once');
writeFile('src/wanted.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::src/wanted.h');
expect(providerIds).not.toContain('include::vendor-headers/blocked.h');
});
it('does not emit a provider contract for a header excluded by .gitnexusignore', async () => {
writeFile('.gitnexusignore', 'legacy/\n');
writeFile('legacy/old.h', '#pragma once');
writeFile('src/current.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::src/current.h');
expect(providerIds).not.toContain('include::legacy/old.h');
});
it('does not parse #include directives in a source file excluded by .gitignore', async () => {
// The ignored source file references a header that would otherwise be
// a cross-repo consumer. After alignment, the ignored file is invisible
// to the consumer scan — no consumer contract should appear.
writeFile('.gitignore', 'generated/\n');
writeFile(
'generated/auto.cpp',
`#include "remote/should_not_appear.h"
int auto_main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumerIds = contracts.filter((c) => c.role === 'consumer').map((c) => c.contractId);
expect(consumerIds).not.toContain('include::remote/should_not_appear.h');
});
it('skips a provider header whose size exceeds GITNEXUS_MAX_FILE_SIZE', async () => {
const previous = process.env.GITNEXUS_MAX_FILE_SIZE;
process.env.GITNEXUS_MAX_FILE_SIZE = '1'; // 1 KB cap
try {
// 4 KB header — comfortably exceeds the cap.
const oversized = '#pragma once\n' + 'x'.repeat(4 * 1024);
writeFile('huge/big.h', oversized);
writeFile('small/tiny.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::small/tiny.h');
expect(providerIds).not.toContain('include::huge/big.h');
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
else process.env.GITNEXUS_MAX_FILE_SIZE = previous;
}
});
it('skips parsing #include directives in source files exceeding GITNEXUS_MAX_FILE_SIZE', async () => {
const previous = process.env.GITNEXUS_MAX_FILE_SIZE;
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
try {
const oversized =
'#include "remote/should_not_appear.h"\n' +
'// padding to push the file past 1 KB\n' +
'x'.repeat(4 * 1024);
writeFile('big/main.cpp', oversized);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumerIds = contracts.filter((c) => c.role === 'consumer').map((c) => c.contractId);
expect(consumerIds).not.toContain('include::remote/should_not_appear.h');
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
else process.env.GITNEXUS_MAX_FILE_SIZE = previous;
}
});
});
});

View file

@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js';
import { cleanupTempDir } from '../../helpers/test-db.js';
import { _captureLogger } from '../../../src/core/logger.js';
import type {
GroupConfig,
@ -583,6 +584,47 @@ service OrderService {
}
});
it('does not extract include contracts during real sync when includes detection is disabled', async () => {
// PR #1156 Codex follow-up: ce-code-review T1 — verifies the gate at
// sync.ts:174 honors `detect.includes: false`. Mirrors the existing
// thrift-off pattern at sync.test.ts:545.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-includes-off-'));
const storageDir = path.join(tmpDir, '.gitnexus');
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.mkdirSync(storageDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src', 'view.h'), '#pragma once\nclass View {};');
const config = makeConfig({ 'app/cpp-lib': 'cpp-lib-repo' });
config.detect.http = false;
config.detect.grpc = false;
config.detect.thrift = false;
config.detect.topics = false;
config.detect.includes = false;
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
try {
const result = await syncGroup(config, {
resolveRepoHandle: async (_name, groupPath) => ({
id: 'cpp-lib-repo',
path: groupPath,
repoPath: tmpDir,
storagePath: storageDir,
}),
skipWrite: true,
});
expect(result.missingRepos).toHaveLength(0);
expect(result.contracts.filter((c) => c.type === 'include')).toHaveLength(0);
} finally {
initSpy.mockRestore();
closeSpy.mockRestore();
await cleanupTempDir(tmpDir);
}
});
it('dedupes duplicate wildcard cross-links during sync', async () => {
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
const provider: StoredContract = {
@ -689,7 +731,12 @@ service OrderService {
expect(registry.version).toBe(1);
expect(registry.contracts).toHaveLength(0);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
// syncGroup now writes bridge.lbug + WAL/shadow sidecars when
// skipWrite is false. On Windows, LadybugDB's checkpoint thread can
// briefly outlive closeBridgeDb, holding a Win32 lock on the file.
// cleanupTempDir tolerates the documented Windows-native lock codes
// (EBUSY/EPERM/EACCES/ENOTEMPTY) with bounded retries.
await cleanupTempDir(tmpDir);
}
});

View file

@ -28,6 +28,8 @@ describe('shouldIgnorePath', () => {
it.each([
'node_modules',
'vendor',
'third_party',
'3rdparty',
'venv',
'.venv',
'__pycache__',

View file

@ -0,0 +1,316 @@
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { performance } from 'node:perf_hooks';
import {
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
stripGitSuffix,
UNDERSTAND_QUICKLY_TOKEN_ENV,
} from 'gitnexus-shared';
describe('understand-quickly helpers (gitnexus-shared)', () => {
describe('isValidOwnerRepo', () => {
it.each([
['looptech-ai/understand-quickly', true],
['abhigyanpatwari/GitNexus', true],
// LOW 8: GitHub user/org slugs are alnum/hyphen only — no underscore.
['Some_Org/Some.Repo-2', false],
['', false],
['just-a-name', false],
['/Users/me/code/repo', false],
['org/with spaces', false],
['org//double', false],
// LOW 8 additions:
['some_org/repo', false], // underscore in owner — invalid
['-org/repo', false], // leading hyphen — invalid
['org-/repo', false], // trailing hyphen — GitHub rejects at account creation; we mirror that here
['org/repo_with_underscore', true],
['org/.dotfile', true], // repos may start with dot
])('returns %s for %j', (id, expected) => {
expect(isValidOwnerRepo(id as string)).toBe(expected);
});
});
describe('stripGitSuffix (BLOCKER 1 — ReDoS-safe)', () => {
it.each([
['https://github.com/o/r.git', 'https://github.com/o/r'],
['https://github.com/o/r.git/', 'https://github.com/o/r'],
['https://github.com/o/r/', 'https://github.com/o/r'],
['https://github.com/o/r', 'https://github.com/o/r'],
['https://github.com/o/r.GIT', 'https://github.com/o/r'],
['https://github.com/o/r//', 'https://github.com/o/r'],
['', ''],
['/', ''],
])('strips %j -> %j', (input, expected) => {
expect(stripGitSuffix(input)).toBe(expected);
});
test('linear time on adversarial trailing slashes (regression for ReDoS)', () => {
const adversarial = 'https://github.com/o/r' + '/'.repeat(10_000);
const start = performance.now();
const result = stripGitSuffix(adversarial);
const elapsed = performance.now() - start;
expect(result).toBe('https://github.com/o/r');
expect(elapsed).toBeLessThan(50); // generous; should be sub-millisecond
});
test('parseOwnerRepoFromRemote terminates quickly on adversarial input', () => {
const adversarial = 'https://github.com/o/r.git' + '/'.repeat(10_000);
const start = performance.now();
const result = parseOwnerRepoFromRemote(adversarial);
const elapsed = performance.now() - start;
expect(result).toBe('o/r');
expect(elapsed).toBeLessThan(50);
});
});
describe('parseOwnerRepoFromRemote', () => {
it.each([
['git@github.com:looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['ssh://git@github.com/abhigyanpatwari/GitNexus.git', 'abhigyanpatwari/GitNexus'],
])('parses %s -> %s', (url, expected) => {
expect(parseOwnerRepoFromRemote(url)).toBe(expected);
});
// LOW 9: non-GitHub remotes must be rejected — a wrong id is worse
// than no id, since the user can always pass --id explicitly.
it.each([
['https://gitlab.example.com/group/sub/project.git'],
['git@gitlab.example.com:group/sub/project.git'],
['https://bitbucket.org/team/repo.git'],
])('returns null for non-GitHub host %j', (input) => {
expect(parseOwnerRepoFromRemote(input)).toBeNull();
});
it.each([null, undefined, '', ' ', 'not-a-url', 'https://github.com/'])(
'returns null for %j',
(input) => {
expect(parseOwnerRepoFromRemote(input as string | null | undefined)).toBeNull();
},
);
});
describe('buildUqDispatchPayload', () => {
it('wraps the id in the registry-expected event shape', () => {
expect(buildUqDispatchPayload('looptech-ai/understand-quickly')).toEqual({
event_type: 'sync-entry',
client_payload: { id: 'looptech-ai/understand-quickly' },
});
});
it('throws on a malformed id rather than building an invalid payload', () => {
expect(() => buildUqDispatchPayload('just-a-name')).toThrow(/owner\/repo/);
expect(() => buildUqDispatchPayload('/Users/me/repo')).toThrow(/owner\/repo/);
});
});
});
describe('publishCommand (no-token no-op)', () => {
let tempDir: string;
let originalToken: string | undefined;
let exitCodeBefore: number | undefined;
beforeEach(async () => {
vi.resetModules();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-'));
// Simulate an existing index so hasIndex() returns true.
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
await fs.writeFile(
path.join(tempDir, '.gitnexus', 'meta.json'),
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
'utf-8',
);
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
exitCodeBefore = process.exitCode;
process.exitCode = 0;
});
afterEach(async () => {
if (originalToken !== undefined) {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
} else {
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
}
process.exitCode = exitCodeBefore;
await fs.rm(tempDir, { recursive: true, force: true });
});
it('exits 0 without firing a network call when the token is unset', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new Error('publishCommand should NOT call fetch when the token is missing');
});
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, { id: 'looptech-ai/understand-quickly', skipGit: true });
expect(fetchSpy).not.toHaveBeenCalled();
expect(process.exitCode ?? 0).toBe(0);
fetchSpy.mockRestore();
});
it('exits 0 with no token even when no index/repo exists (BLOCKER 2)', async () => {
// Per the README, CLI --help, and PR body: without a token, the
// command must be a no-op even if the repo lacks `.gitnexus/`.
const noIndexDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-noidx-'));
try {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new Error('publishCommand should NOT call fetch when the token is missing');
});
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(noIndexDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(process.exitCode ?? 0).toBe(0);
fetchSpy.mockRestore();
} finally {
await fs.rm(noIndexDir, { recursive: true, force: true });
}
});
});
describe('publishCommand response branches (MEDIUM 5)', () => {
let tempDir: string;
let originalToken: string | undefined;
let exitCodeBefore: number | undefined;
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.resetModules();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-resp-'));
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
await fs.writeFile(
path.join(tempDir, '.gitnexus', 'meta.json'),
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
'utf-8',
);
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_test';
exitCodeBefore = process.exitCode;
process.exitCode = 0;
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(async () => {
if (originalToken !== undefined) {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
} else {
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
}
process.exitCode = exitCodeBefore;
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
function mockResponse(status: number, body = '') {
fetchSpy.mockResolvedValueOnce({
status,
ok: status >= 200 && status < 300,
text: async () => body,
body: { cancel: async () => {} },
headers: new Headers(),
} as unknown as Response);
}
it('204 → exit 0 with success message', async () => {
mockResponse(204);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(process.exitCode ?? 0).toBe(0);
});
it('401 → exit 1 with PAT-invalid hint', async () => {
mockResponse(401, '{"message":"Bad credentials"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('403 → exit 1 with scope-missing hint', async () => {
mockResponse(403, '{"message":"Resource not accessible"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('404 → exit 1 with repo-access hint', async () => {
mockResponse(404, '{"message":"Not Found"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('5xx → exit 1 with raw body', async () => {
mockResponse(503, 'gateway timeout');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('network throw → exit 1', async () => {
fetchSpy.mockRejectedValueOnce(new Error('ECONNRESET'));
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('TimeoutError (HIGH 4 — fetch timeout) → exit 1 with timed-out message', async () => {
// `AbortSignal.timeout()` throws a real `DOMException` with
// `name === 'TimeoutError'`. Faking it as `Error{name:'AbortError'}`
// (the previous shape of this test) hid a mismatch in publish.ts —
// the catch branch only matched 'AbortError' and the user-facing
// "timed out" message never fired in production.
const abort = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
fetchSpy.mockRejectedValueOnce(abort);
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
expect(written).toMatch(/timed out/i);
errSpy.mockRestore();
});
it('token never appears in any logged output', async () => {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_secret_value';
mockResponse(401, '');
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
expect(written).not.toContain('pat_secret_value');
errSpy.mockRestore();
});
});