mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge remote-tracking branch 'origin/feat/unified-deployment-enhancement' into feat/unified-deployment-enhancement
This commit is contained in:
commit
0cbf1f25b4
69 changed files with 9510 additions and 149 deletions
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -15,3 +15,15 @@
|
|||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
||||
# TypeScript sources are always text for diff purposes. Git's binary
|
||||
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
|
||||
# file that carried one on a base commit still renders as "Binary files
|
||||
# differ" — with no hunks and no inline comments — long after the byte
|
||||
# itself is gone from the working tree. A head-side guard cannot see
|
||||
# that, by construction. This does not mark the files binary or change
|
||||
# how they are stored; it only stops the heuristic from hiding a diff.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.mts diff
|
||||
*.cts diff
|
||||
|
|
|
|||
|
|
@ -403,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
|
||||
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/antigravity-hook-e2e.test.ts': 7,
|
||||
'test/unit/index-lock.test.ts': 5,
|
||||
'test/unit/setup.test.ts': 5,
|
||||
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
|
||||
// one 4893-file pass over every tracked text file, plus an 830-file pass over
|
||||
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
|
||||
// Linux filesystem respectively, so the cost is entirely per-file open
|
||||
// latency, which is the term Windows inflates most (NTFS plus Defender on
|
||||
// every read). Scaled from the slower Linux figure to keep the split
|
||||
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
|
||||
// a file that touches more paths than anything else here. Replace with a real
|
||||
// figure after the first green Windows matrix run.
|
||||
'test/unit/source-control-bytes.test.ts': 15,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -208,6 +208,18 @@ const SPAWN_CLI = [
|
|||
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
|
||||
// now judgment-verified so a live holder is never displaced.
|
||||
'test/integration/analyze-index-lock-concurrency.test.ts',
|
||||
// The per-group sync lock (R9), same class of guarantee one level up: real
|
||||
// child processes contend for one group's lock while this process runs a real
|
||||
// `syncGroup`, and the CLI case spawns the real command. Everything that
|
||||
// varies here is platform-owned — which backend `selectBackend()` picks
|
||||
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
|
||||
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
|
||||
// `mkdir` over an occupied path. The fail-closed cases pin
|
||||
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
|
||||
// every OS rather than only where it is the default; no case is skipped on
|
||||
// any platform, because a skipped case turns "a sync that cannot be protected
|
||||
// does not run" into a claim that holds on Ubuntu only.
|
||||
'test/integration/group/group-sync-lock-concurrency.test.ts',
|
||||
// The three `dist/` module-load closure guards, all built on the shared
|
||||
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
|
||||
// the platform-varying part: it spawns `process.execPath` in array form,
|
||||
|
|
@ -261,6 +273,28 @@ const FILESYSTEM = [
|
|||
'test/integration/filesystem-walker.test.ts',
|
||||
'test/integration/markdown-processor-crlf.test.ts',
|
||||
'test/integration/ignore-and-skip-e2e.test.ts',
|
||||
// Pins that the bridge pairing verdict is measured before the database is
|
||||
// opened. The property it protects is about mtime behavior across OS and
|
||||
// filesystem, and the alternative — really opening the bridge — cannot run on
|
||||
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
|
||||
// documented limitation). Running it on every platform is the whole point:
|
||||
// Windows is where an unverified assumption about mtime would hurt most.
|
||||
'test/unit/group/bridge-pairing-precedes-open.test.ts',
|
||||
// The raw-control-byte guard reads every tracked text file `git ls-files`
|
||||
// reports — 4893 of them — and decides membership from the git path, which is
|
||||
// always `/`-separated no matter what the host separator is. Both halves of
|
||||
// that are platform-varying: the collector basename-matches with
|
||||
// `path.posix.basename` against `git ls-files -z` output while the reads go
|
||||
// through `path.join`, so on Windows the same string is consumed under two
|
||||
// separator conventions in one pass, and only a real windows-latest run
|
||||
// proves they agree. It is also the file-count-heaviest read loop in the
|
||||
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
|
||||
// macOS's slower stat path) would show up first. No case is skipped on any
|
||||
// platform: a guard that only holds on Ubuntu is not a guard on the file
|
||||
// whose NUL it exists to catch. Budget: the heaviest single case is one
|
||||
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
|
||||
// disk — against a 30 s testTimeout.
|
||||
'test/unit/source-control-bytes.test.ts',
|
||||
];
|
||||
|
||||
const ALL_CROSS_PLATFORM = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// gitnexus/src/cli/group.ts
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
import type { RegistryWriteOutcome } from '../core/group/sync.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
|
@ -120,16 +121,42 @@ export function registerGroupCommands(program: Command): void {
|
|||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
/**
|
||||
* Optional here on purpose: a payload produced before the split
|
||||
* carries no such key, and an absent one must degrade to the
|
||||
* label this command has always printed rather than to the new
|
||||
* one — an unrecorded cause is not evidence of a cause.
|
||||
*/
|
||||
unresolvable?: boolean;
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
>;
|
||||
missingRepos?: string[];
|
||||
unreadableRepos?: string[];
|
||||
};
|
||||
|
||||
console.log(' Repo index / contracts staleness:');
|
||||
for (const [repoPath, row] of Object.entries(st.repos || {})) {
|
||||
if (row.missing) {
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
|
||||
// Two different facts with two different remedies: a repo the
|
||||
// registry never heard of is fixed by indexing it, while an entry
|
||||
// the resolver choked on is fixed by repairing the registry.
|
||||
// Printing "no entry in the registry" for the second one states a
|
||||
// cause that was never measured, and points at the wrong repair.
|
||||
if (row.unresolvable) {
|
||||
// The reason can be multi-line — an ambiguous registry names
|
||||
// every colliding clone. Fold it onto this row's line rather
|
||||
// than truncating it: those paths are what the operator acts on,
|
||||
// and a table row that swallows half its own explanation is the
|
||||
// failure this label exists to stop.
|
||||
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
|
||||
continue;
|
||||
}
|
||||
const idx = row.indexStale
|
||||
|
|
@ -138,6 +165,26 @@ export function registerGroupCommands(program: Command): void {
|
|||
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
|
||||
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
|
||||
}
|
||||
// `undefined` and `[]` are different answers here: a registry written
|
||||
// before this was tracked has no opinion, while an empty array is a
|
||||
// measurement. Printing nothing for both would let an unmeasured sync
|
||||
// read as evidence that every index opened cleanly.
|
||||
//
|
||||
// `undefined` covers two ways of not knowing — the field is absent, or
|
||||
// it held something that was not a list of repo paths and `getStatus`
|
||||
// declined to guess. Naming only the first would make a corrupt
|
||||
// registry read as a merely old one, which is the same shape of wrong
|
||||
// answer this command exists to stop giving.
|
||||
const unreadable = st.unreadableRepos;
|
||||
if (unreadable === undefined) {
|
||||
console.log(
|
||||
`\n Last sync unreadable repos: not recorded` +
|
||||
`\n (the registry predates this field, or its value could not be read)` +
|
||||
`\n Re-run \`gitnexus group sync\` to record it.`,
|
||||
);
|
||||
} else if (unreadable.length > 0) {
|
||||
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
|
||||
}
|
||||
if ((st.missingRepos || []).length > 0) {
|
||||
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
|
||||
}
|
||||
|
|
@ -158,30 +205,93 @@ export function registerGroupCommands(program: Command): void {
|
|||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const { syncGroup } = await import('../core/group/sync.js');
|
||||
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
} catch (err) {
|
||||
// A sync that could not take the group's lock did NOT run and wrote
|
||||
// nothing (R9 fails closed). That is an operator-actionable outcome, not
|
||||
// a crash, so report it as a failed command rather than letting it
|
||||
// surface as an unhandled rejection with a stack trace — commander's
|
||||
// async actions have no error handler, so an uncaught throw here would
|
||||
// print exactly that.
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// Repos we could not read are the most likely explanation for a small
|
||||
// or empty contract count, so they are reported before the counts —
|
||||
// otherwise a run that read nothing looks exactly like a clean run.
|
||||
if (result.unreadableRepos.length > 0) {
|
||||
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
|
||||
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
|
||||
// already printed by this same run — raising the level to `warn` would
|
||||
// only suppress the surrounding `info` output.
|
||||
console.log(
|
||||
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
|
||||
`\n None of their contracts are included in this sync (the warning above says why),` +
|
||||
`\n or check \`gitnexus doctor\` in the affected repo.`,
|
||||
);
|
||||
}
|
||||
if (result.missingRepos.length > 0) {
|
||||
console.log(
|
||||
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
|
||||
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
|
||||
);
|
||||
}
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
// Driven by what actually happened to the file. This line used to be
|
||||
// unconditional, so a run that deliberately preserved the previous
|
||||
// registry still announced `Wrote contracts.json (0 contracts, 0
|
||||
// cross-links)` — a confident false statement about persisted state, on
|
||||
// the exact path this command exists to make legible.
|
||||
// Exhaustive by construction: a `Record` keyed on the union means a
|
||||
// new outcome fails the build here instead of printing nothing, which
|
||||
// is what previously pushed a distinct state into `preserved` and made
|
||||
// this summary false on one of the two branches it then covered.
|
||||
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
|
||||
written:
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
|
||||
`${result.crossLinks.length} cross-links)`,
|
||||
preserved:
|
||||
`\nKept the previous contracts.json — no repo in this group could be read.` +
|
||||
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
|
||||
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
|
||||
superseded:
|
||||
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
|
||||
`\n sync replaced the file while this one waited for the group lock. That sync's` +
|
||||
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
|
||||
`\n group state older than what is on disk. Fix the repos above and re-run.`,
|
||||
'no-prior-registry':
|
||||
`\nDid NOT write contracts.json — no repo in this group could be read,` +
|
||||
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
|
||||
`\n above and re-run.`,
|
||||
// Nothing to say: the caller asked for no write.
|
||||
'not-attempted': null,
|
||||
};
|
||||
const line = OUTCOME_LINE[result.registryOutcome];
|
||||
if (line) console.log(line);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -370,7 +480,7 @@ export function registerGroupCommands(program: Command): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const { contracts, crossLinks } = raw as {
|
||||
const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as {
|
||||
contracts: Array<{
|
||||
role: string;
|
||||
contractId: string;
|
||||
|
|
@ -384,10 +494,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
confidence: number;
|
||||
contractId: string;
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
unreadableRepos?: string[];
|
||||
missingRepos?: string[];
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
|
||||
// The whole payload, not a re-serialized subset. Destructuring the two
|
||||
// fields this command happens to print and rebuilding an object from
|
||||
// them dropped everything else the service returned — which is how the
|
||||
// completeness fields were invisible here while the MCP tool carried
|
||||
// them. Printing `raw` means a field added to the service reaches
|
||||
// `--json` without a matching edit in this file.
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Contracts (${contracts.length}):`);
|
||||
for (const c of contracts) {
|
||||
|
|
@ -399,6 +518,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
|
||||
);
|
||||
}
|
||||
if (truncated) {
|
||||
// Counts above are a floor, not a census. Name the repos when the
|
||||
// registry recorded them, and say so plainly when it did not — a
|
||||
// listing that cannot say what it is missing is still incomplete.
|
||||
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
|
||||
console.log(
|
||||
absent.length > 0
|
||||
? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
|
||||
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
|
||||
: `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` +
|
||||
`\n read, so the counts above are a lower bound. Re-run group sync.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import ignore, { type Ignore } from 'ignore';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import nodePath from 'path';
|
||||
import type { Path } from 'path-scurry';
|
||||
|
|
@ -31,12 +32,15 @@ const DEFAULT_IGNORE_LIST = new Set([
|
|||
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
|
||||
'venv',
|
||||
'.venv',
|
||||
'env',
|
||||
'.env',
|
||||
// Bare `env/` can be application source or a Python virtual environment.
|
||||
// Path-aware rules below prune it at the root and wherever pyvenv.cfg marks
|
||||
// a virtual environment, while preserving ordinary nested source folders.
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'site-packages',
|
||||
'dist-packages',
|
||||
'.tox',
|
||||
'eggs',
|
||||
'.eggs',
|
||||
|
|
@ -86,8 +90,9 @@ const DEFAULT_IGNORE_LIST = new Set([
|
|||
|
||||
// Generated/Compiled
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
// Bare `generated/` can contain tracked source-of-truth code. Build output
|
||||
// remains covered by .gitignore/.gitnexusignore and the unambiguous names.
|
||||
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
|
@ -106,6 +111,14 @@ const DEFAULT_IGNORE_LIST = new Set([
|
|||
'__snapshots__',
|
||||
]);
|
||||
|
||||
// Ambiguous names that conventionally denote generated artifacts only at the
|
||||
// repository root. Nested directories with these names are frequently source
|
||||
// modules (for example apps/web/src/env or packages/api/generated).
|
||||
const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']);
|
||||
|
||||
const isRootArtifactDirectory = (relativePath: string, name: string): boolean =>
|
||||
!relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name);
|
||||
|
||||
const IGNORED_EXTENSIONS = new Set([
|
||||
// Images
|
||||
'.png',
|
||||
|
|
@ -290,6 +303,10 @@ export const shouldIgnorePath = (filePath: string): boolean => {
|
|||
const fileName = parts[parts.length - 1];
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
|
||||
if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Laravel compiles Blade templates into generated PHP cache files under
|
||||
// storage/framework/views. Source templates live in resources/views and are
|
||||
// handled separately; compiled cache should not become source-of-truth. Keep
|
||||
|
|
@ -329,10 +346,8 @@ export const shouldIgnorePath = (filePath: string): boolean => {
|
|||
if (
|
||||
fileNameLower.includes('.bundle.') ||
|
||||
fileNameLower.includes('.chunk.') ||
|
||||
fileNameLower.includes('.generated.') ||
|
||||
fileNameLower.endsWith('.d.ts')
|
||||
fileNameLower.includes('.generated.')
|
||||
) {
|
||||
// TypeScript declaration files
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -344,6 +359,20 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => {
|
|||
return DEFAULT_IGNORE_LIST.has(name);
|
||||
};
|
||||
|
||||
/** Apply directory ignore rules that depend on repository-relative depth. */
|
||||
export const isHardcodedIgnoredDirectoryAtPath = (
|
||||
repoRoot: string,
|
||||
directoryPath: string,
|
||||
): boolean => {
|
||||
const name = nodePath.basename(directoryPath);
|
||||
if (isHardcodedIgnoredDirectory(name)) return true;
|
||||
|
||||
const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/');
|
||||
if (isRootArtifactDirectory(relative, name)) return true;
|
||||
|
||||
return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Load .gitignore and .gitnexusignore rules from the repo root.
|
||||
* Returns an `ignore` instance with all patterns, or null if no files found.
|
||||
|
|
@ -496,8 +525,10 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
|
|||
// last-match-wins: `!__tests__/` + `__tests__/generated/` still
|
||||
// blocks descent into `__tests__/generated/`.
|
||||
if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false;
|
||||
// Hardcoded list: block descent into well-known noise directories.
|
||||
if (DEFAULT_IGNORE_LIST.has(p.name)) return true;
|
||||
// Hardcoded and path-aware rules prune whole trees before glob walks them.
|
||||
if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) {
|
||||
return true;
|
||||
}
|
||||
// Check against .gitignore / .gitnexusignore patterns.
|
||||
// Since childrenIgnored is only called for directories, always test with
|
||||
// a trailing slash. This ensures directory-only negation patterns (e.g.
|
||||
|
|
|
|||
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Review findings → commits (PR #3012)
|
||||
|
||||
Every finding raised in review of this PR, and the commit that closes it. The
|
||||
Definition of Done claims each finding has exactly one commit and that reverting
|
||||
that commit reintroduces that finding and no other; this is what makes the claim
|
||||
checkable without the reviewer's report in hand.
|
||||
|
||||
**Not under `docs/`** — that path is gitignored, so a map written there would
|
||||
never reach the PR and nobody but its author could perform the audit. It lives
|
||||
beside the code it describes, as `PIPELINE.md` does.
|
||||
|
||||
## Revert contract
|
||||
|
||||
Revertability is **dependency-aware**. Where one commit extracts a helper that
|
||||
later commits consume, reverting the helper alone does not build. The contract
|
||||
is: reverting a commit reintroduces its own finding and no other _finding_, with
|
||||
its prerequisite commits retained.
|
||||
|
||||
One coupled set exists:
|
||||
|
||||
| Set | Commits | Why coupled |
|
||||
| -------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Shared completeness helper | `4c203ac7b` ← `79f6f5bcb`, `0fe6fc9d4`, `dbc3953b0` | The three consumers call `crossRepoCompleteness`; reverting it alone breaks the build. |
|
||||
|
||||
## Primary findings
|
||||
|
||||
| # | Finding | Commit |
|
||||
| --- | -------------------------------------------------------------------------------- | ----------- |
|
||||
| 1 | Malformed `meta.json` crashes cross-repo impact and leaks the bridge handle | `27b0069f2` |
|
||||
| 2 | Unreadable repos still contribute contracts through deferred manifest resolution | `7037e8441` |
|
||||
| 3 | Strict read accepts a registry row that cannot identify a repo | `5245b22d7` |
|
||||
| 4 | Unstamped bridge metadata is trusted without any check | `94f2a8757` |
|
||||
| 5 | A subgroup-scoped query is marked incomplete by repos it excluded | `79f6f5bcb` |
|
||||
| 6 | The preserved registry and the bridge disagree about the same sync | `4676abf03` |
|
||||
| 7 | Three surfaces compute completeness three different ways | `4c203ac7b` |
|
||||
| 8 | `group_contracts` has no channel for its own completeness | `0fe6fc9d4` |
|
||||
| 9 | `group status` cannot tell a missing entry from an unreadable registry | `a12b846c9` |
|
||||
| 10 | The sync summary describes a write that did not happen that way | `5a668455c` |
|
||||
| 11 | The total-failure log promises preservation where there is nothing to preserve | `c4b356b29` |
|
||||
| 12 | The bridge-failure warning promises a truncation the code never reports | `1df79bb9a` |
|
||||
| 13 | Two concurrent syncs of one group lose each other's writes | `4f07359bf` |
|
||||
| 14 | The bridge swap needs the lock its caller already holds | `3b6215862` |
|
||||
| 15 | The byte guard misses most tracked text files, and all extensionless ones | `07bf8be75` |
|
||||
| 16 | The byte guard reads the vendored grammar tree it does not need to judge | `3ef831a0a` |
|
||||
| 17 | The strict-read test cannot see which registry read ran | `eccc3c682` |
|
||||
| 18 | The CLI branches this PR introduced have no assertions | `535d2ad29` |
|
||||
| 19 | The MCP payloads have no assertions | `2c253b4a8` |
|
||||
| 20 | Corrupt-registry errors quote the file's bytes, credentials included | `24ba2a537` |
|
||||
| 21 | The mtime pairing's limits are recorded nowhere a reader will look | `ca0aca106` |
|
||||
| 22 | The bridge-input docstring narrows what `unreadableRepos` means | `8c930f470` |
|
||||
| 23 | The strict-read docstring's call-site count is wrong | `a95838954` |
|
||||
| 24 | Contract staging crashes on the engine's argument limit | `57eac7558` |
|
||||
| 25 | The sync tool's description names two of three reachable outcomes | `8bfd1a6ab` |
|
||||
| 26 | The impact tool and status resource do not explain incompleteness | `dbc3953b0` |
|
||||
| 27 | A lock timeout blames an `analyze` it cannot establish | `2d2a0119e` |
|
||||
| 28 | A losing sync downgrades the one that beat it to the lock | `e407f05cf` |
|
||||
|
||||
## Findings raised in review and deliberately not implemented as suggested
|
||||
|
||||
| Finding | Suggested fix | What shipped, and why |
|
||||
| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Unstamped metadata is trusted | Treat every absent stamp as incomplete | Rejected. It would mark every pre-existing bridge a lower bound until re-synced — a repo-wide regression traded for a narrow window. The write-order pairing in `94f2a8757` is the narrower fix. |
|
||||
| Stale bridge signal after a failed write | Re-stamp the metadata so the warning's promise becomes true | Rejected. Re-stamping recreates the metadata/database mis-pairing that stamping exists to prevent. `1df79bb9a` corrects the warning instead. |
|
||||
| Strict row gate | Require all three fields non-blank | Narrowed to `name` and `storagePath`. This gate rejects the whole registry, which is machine-wide, so a field tightened past what identification needs lets one blank value break every group sync on the machine. |
|
||||
|
||||
## Found during execution, not in the review
|
||||
|
||||
| What | Commit |
|
||||
| --------------------------------------------------------------------------------------------- | ----------- |
|
||||
| A half-written bridge stamp read as a verified match (found by the repo's own contract check) | `066f2d802` |
|
||||
| `readBridgeMeta`'s widened return type blocked the merge on contract drift | `a9d281dd4` |
|
||||
| `group contracts --json` discarded every field it did not re-serialize | `b7753575d` |
|
||||
| `sync.ts` renders as a binary diff because the base blob carries a NUL | `1667c24b4` |
|
||||
|
||||
## Corrections to the plan, found while executing it
|
||||
|
||||
Recorded because each was a claim in the plan that the code contradicted.
|
||||
|
||||
| Claim | Reality |
|
||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The strict gate should require the fields "the resolution path consumes" | `defaultResolveHandle` **does** consume `path`. The distinction is what _identifies_ the repo. |
|
||||
| Pass the trace's two endpoint repos as the scope predicate | A destination trace declares no `to`. Narrowing to `from` would report an unreadable provider as "no outgoing link". |
|
||||
| Filter the incomplete set by the subgroup prefix | The query's own repo must stay in scope, or an unreadable origin becomes a confident "nothing depends on this". |
|
||||
| `group status`'s third failure mode is a row that resolves but cannot be opened | Unreachable — `loadMeta` returns `null` on every error and `checkStaleness` catches everything. The reachable case is `resolveRepo` throwing. |
|
||||
| The mtime rule can only demote pairs already broken | False. `cp -r` and `rsync` without `-t` demote an intact pair. Recorded at the code in `ca0aca106`. |
|
||||
| `.scm` files are "edited constantly" here | Every tracked `.scm` is vendored. This repo writes tree-sitter queries inline in TypeScript. |
|
||||
|
||||
## Residual risks, recorded rather than closed
|
||||
|
||||
- **Credentials in the registry.** HTTPS remote URLs are persisted with their
|
||||
userinfo intact. `24ba2a537` stops one channel echoing them; it does not stop
|
||||
them being written. Pre-existing, tracked separately.
|
||||
- **`readRegistryFile`'s read error.** The ENOENT-guarded outer catch still
|
||||
rethrows the raw `fs.readFile` error into `unresolvableReason`. Node embeds
|
||||
the path, not file contents, so no registry bytes leak — but it is the one
|
||||
remaining foreign error object on that path.
|
||||
- **Abstract-socket lock scope.** Linux abstract sockets are
|
||||
network-namespace-scoped, so two containers sharing a bind-mounted group
|
||||
directory do not contend unless the file backend is forced. Recorded at
|
||||
`group-lock.ts`.
|
||||
- **Scope filter at depth > 1.** The declared-scope intersection is sound only
|
||||
while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2 an out-of-scope repo can
|
||||
sit between two in-scope ones. Recorded at the intersection site.
|
||||
- **R14 is unmet on this PR.** `.gitattributes` makes TypeScript diffs render as
|
||||
text, and it works locally — but GitHub resolves the attribute from the base
|
||||
side, which does not carry it. `sync.ts` renders as binary in this PR's web
|
||||
view and will render as text for every PR after this one merges.
|
||||
|
|
@ -5,12 +5,14 @@ import lbug from '@ladybugdb/core';
|
|||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import { recordedRepoList } from './completeness.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
import { withGroupSyncLock } from './group-lock.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
|
|
@ -650,13 +652,296 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi
|
|||
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `meta` still describe the `bridge.lbug` sitting next to it?
|
||||
*
|
||||
* `writeBridge` stamps the database's size and mtime into the metadata it
|
||||
* writes, so a metadata file left over from an earlier sync cannot match a
|
||||
* database that was replaced after it. Callers whose answer depends on the
|
||||
* metadata being true of THIS database (cross-repo impact reads completeness
|
||||
* from it) must not treat a mismatch as fact.
|
||||
*
|
||||
* When BOTH halves of the stamp are absent the metadata predates stamping, and
|
||||
* it is judged on the write order of the two files instead — see
|
||||
* {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
|
||||
* closed would mark all pre-existing bridges as incomplete until re-synced,
|
||||
* trading a narrow window for a repo-wide regression; accepting them all hands
|
||||
* back "verified" for the very window this pairing exists to catch.
|
||||
*
|
||||
* A stamp is a PAIR, so exactly one half present is rejected rather than waved
|
||||
* through. That is not the legacy shape: something wrote a stamp and did not
|
||||
* finish, which is the very condition stamping was added to detect. Joining the
|
||||
* two `undefined` checks with `||` returned "verified" for precisely the shape
|
||||
* that most deserves suspicion.
|
||||
*
|
||||
* Returns `false` when the database itself cannot be stat'd, on either path,
|
||||
* since metadata describing a file that is not there describes nothing.
|
||||
*
|
||||
* The checks are ORDERED by how strong their evidence is, strongest first, and
|
||||
* each later one is reached only because every earlier one had nothing to say.
|
||||
* `provenanceUnknown` therefore comes first: a metadata file whose own writer
|
||||
* says it cannot vouch for the database beside it has settled the question, and
|
||||
* neither the stamp nor the write-order heuristic may overturn that.
|
||||
*
|
||||
* The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
|
||||
* atomically without touching the database, which leaves `meta.mtime` newer —
|
||||
* the write order a paired write produces, and the one the unstamped branch
|
||||
* ACCEPTS. Reading the marker after that branch (or not at all) hands back
|
||||
* "verified" for a pair the same code path had just found broken.
|
||||
*/
|
||||
export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean> {
|
||||
if (meta.provenanceUnknown) return false;
|
||||
const stampedSize = meta.bridgeSize !== undefined;
|
||||
const stampedMtime = meta.bridgeMtimeMs !== undefined;
|
||||
if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir);
|
||||
if (!stampedSize || !stampedMtime) return false;
|
||||
try {
|
||||
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
|
||||
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Could the unstamped `meta.json` plausibly have been written by the sync that
|
||||
* put this `bridge.lbug` beside it?
|
||||
*
|
||||
* `writeBridge` renames the database into place and writes the metadata AFTER,
|
||||
* so `meta.mtime >= db.mtime` holds for any pair written together — including
|
||||
* pairs written by builds from before the stamp existed, which is what makes
|
||||
* this usable as back-compat rather than a repo-wide "re-sync everything".
|
||||
* The only way to reach a database strictly NEWER than the metadata beside it
|
||||
* is a swap whose metadata write did not land: the stale-meta-beside-a-new-
|
||||
* database window, whose completeness `runGroupImpact` would otherwise spend as
|
||||
* fact.
|
||||
*
|
||||
* This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were
|
||||
* these two written in the order a successful sync writes them?", and treats
|
||||
* that as a proxy for "do these two belong together". It is wrong in two
|
||||
* directions, and neither is theoretical:
|
||||
* - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not
|
||||
* monotonic, so an NTP step backwards, a VM snapshot restore or container
|
||||
* clock skew between the database write and the metadata write can leave a
|
||||
* genuinely mis-paired set reading as ordered. Anything that touches the
|
||||
* stale metadata after a swap does the same — a restore from backup, an
|
||||
* editor save, a copy that preserves only the database's times. The STAMP
|
||||
* is what actually closes this; a pair that has one never reaches here.
|
||||
*
|
||||
* Coarse filesystem mtime granularity is NOT this hazard, despite looking
|
||||
* like it: it collapses a pair written together to equal times, and equal
|
||||
* is accepted, which is the correct verdict for that pair.
|
||||
*
|
||||
* - FALSE REJECT, from anything that rewrites the database's mtime after the
|
||||
* metadata's — `cp -r`, `rsync` without `-t`, a machine move, a restore
|
||||
* that replays files in directory order. An intact legacy pair is then
|
||||
* demoted to a lower bound and stays there until the next successful sync
|
||||
* re-stamps it; there is no other recovery, because nothing on the read
|
||||
* path can distinguish it from the swap window it is imitating.
|
||||
*
|
||||
* This direction is the safe one — it degrades an answer to a floor rather
|
||||
* than vouching for one — but it is a real, reachable cost, not a
|
||||
* theoretical one, and it is NOT true that the rule can only ever demote
|
||||
* pairs that were already broken.
|
||||
*
|
||||
* Equality counts as paired. On a filesystem with coarse mtime granularity both
|
||||
* writes land in the same tick, and demanding a strictly newer metadata file
|
||||
* would reject every legacy bridge there for a reason that is about the
|
||||
* filesystem rather than about the bridge.
|
||||
*
|
||||
* A timestamp that cannot be measured is no match, the same convention the
|
||||
* read-only handle cache applies to a bridge it could not stat: a comparison
|
||||
* that could not be made is not a comparison that succeeded.
|
||||
*/
|
||||
async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise<boolean> {
|
||||
try {
|
||||
const [dbStat, metaStat] = await Promise.all([
|
||||
fsp.stat(path.join(groupDir, 'bridge.lbug')),
|
||||
fsp.stat(path.join(groupDir, 'meta.json')),
|
||||
]);
|
||||
return metaStat.mtimeMs >= dbStat.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `meta.json`, validating the SHAPE of what it holds.
|
||||
*
|
||||
* The read and the parse have always been guarded — an absent or unparseable
|
||||
* file answers `version: 0`, which every caller already treats as "no
|
||||
* provenance". What was not guarded is a file that parses into something that
|
||||
* is not this shape: `runGroupImpact` spread both repo lists directly into a
|
||||
* `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
|
||||
* query, from a point where the bridge lease had been taken and not yet
|
||||
* released. A malformed file is a reason to answer "provenance unknown", never
|
||||
* a reason to crash the question.
|
||||
*/
|
||||
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
|
||||
const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
|
||||
return JSON.parse(content) as BridgeMeta;
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { version: 0, generatedAt: '', missingRepos: [] };
|
||||
return unreadable;
|
||||
}
|
||||
// `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are
|
||||
// metadata. Reading `.version` off the first of those is a thrown TypeError;
|
||||
// reading it off the others silently yields `undefined`, which passes the
|
||||
// version gate as if the bridge had been vouched for.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable;
|
||||
|
||||
const raw = parsed as Partial<BridgeMeta>;
|
||||
const missingRepos = recordedRepoList(raw.missingRepos);
|
||||
const unreadableRepos = recordedRepoList(raw.unreadableRepos);
|
||||
// Each list is judged on its own: a file whose `unreadableRepos` is garbage
|
||||
// can still carry a `missingRepos` that was genuinely measured, and throwing
|
||||
// that away would turn one unknown into two.
|
||||
const repoListsUnreadable =
|
||||
(raw.missingRepos !== undefined && missingRepos === undefined) ||
|
||||
(raw.unreadableRepos !== undefined && unreadableRepos === undefined);
|
||||
|
||||
const meta: BridgeMeta = {
|
||||
...raw,
|
||||
// A version that is not a number cannot be compared against
|
||||
// BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance
|
||||
// unknown", which is exactly what such a file gives us.
|
||||
// `0` is this file's word for "no provenance". A version that is not a
|
||||
// positive integer is not a schema version, and letting one through splits
|
||||
// the four gates that read this field: `ensureBridgeReady` and
|
||||
// `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open
|
||||
// the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say
|
||||
// it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would
|
||||
// call the answer complete. Normalizing here keeps all four agreeing
|
||||
// instead of teaching each one the same new case.
|
||||
version:
|
||||
Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0,
|
||||
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '',
|
||||
missingRepos: missingRepos ?? [],
|
||||
};
|
||||
// Absent, not empty. `unreadableRepos` is optional and "not recorded" is a
|
||||
// distinct state from "measured none", so an unusable value is dropped rather
|
||||
// than carried through — `repoListsUnreadable` is what records that something
|
||||
// was there and could not be read.
|
||||
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
|
||||
else delete meta.unreadableRepos;
|
||||
if (repoListsUnreadable) meta.repoListsUnreadable = true;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* refreshPreservedBridgeMeta */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* What a refresh did to `meta.json`.
|
||||
*
|
||||
* - `restamped` — the pair still matched, so the lists were refreshed
|
||||
* and the stamp re-taken from the database on disk.
|
||||
* - `provenance-unknown` — the pair did NOT match (or there is no database to
|
||||
* match), so the lists were refreshed and the metadata
|
||||
* marked as unable to vouch for the file beside it.
|
||||
* - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so
|
||||
* there is no pair to keep honest and nothing written.
|
||||
*/
|
||||
export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
|
||||
* the bridge instead of rebuilding it, without ever making the metadata claim
|
||||
* more about the database than it did before.
|
||||
*
|
||||
* `syncGroup`'s total-failure path keeps the previous run's contracts and
|
||||
* deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are
|
||||
* the ones being preserved. But `runGroupImpact` reads completeness from
|
||||
* `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
|
||||
* the two files telling different stories: the registry said "this sync could
|
||||
* not read svc/users" while a cross-repo query answered "complete, nothing
|
||||
* depends on this" (R4/R6).
|
||||
*
|
||||
* The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
|
||||
* the file's mtime becomes now while the database's stays old — which is the
|
||||
* write order a paired write produces, and precisely what
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
|
||||
* is load-bearing:
|
||||
*
|
||||
* 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
|
||||
* write the question is unanswerable, because the write is what destroys
|
||||
* the evidence.
|
||||
* 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
|
||||
* failed would MANUFACTURE the provenance the failure just denied — the
|
||||
* same metadata/database mis-pairing stamping exists to prevent (KTD6).
|
||||
* 3. When it was no, record `provenanceUnknown` explicitly and carry the
|
||||
* existing stamp fields through verbatim. Writing "no stamp" instead is
|
||||
* worse, not better: an unstamped file is judged on the two file times,
|
||||
* and this write has just put them in the accepting order.
|
||||
*
|
||||
* Nothing here opens, reads, or writes the database. The only `stat` of it
|
||||
* happens on the branch where the pair was just verified.
|
||||
*
|
||||
* NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
|
||||
* deliberately. Its one caller is `syncGroup`'s preserve branch, which is
|
||||
* already inside `withGroupSyncLock` — so this write is ALREADY serialized
|
||||
* against every other sync of the group, and taking the lock here would be the
|
||||
* second acquisition of a non-reentrant primitive that the split exists to
|
||||
* avoid. An acquiring wrapper would therefore have zero production callers,
|
||||
* and no test calls this function at all: it would be dead code standing in for
|
||||
* a guarantee the caller already provides. If a caller outside the critical
|
||||
* section ever appears, it needs the same treatment `writeBridge` got — a
|
||||
* wrapper, not a lock moved down here.
|
||||
*/
|
||||
export async function refreshPreservedBridgeMeta(
|
||||
groupDir: string,
|
||||
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
|
||||
): Promise<PreservedBridgeMetaOutcome> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
const [metaOnDisk, dbOnDisk] = await Promise.all([
|
||||
fileExists(path.join(groupDir, 'meta.json')),
|
||||
fileExists(dbPath),
|
||||
]);
|
||||
// Nothing on either side of the pair. `readBridgeMeta` already answers
|
||||
// `version: 0` — provenance unknown — for an absent file, so a file written
|
||||
// here would say what the absence already says while inventing state for a
|
||||
// bridge that has never existed.
|
||||
if (!metaOnDisk && !dbOnDisk) return 'no-bridge';
|
||||
|
||||
const existing = await readBridgeMeta(groupDir);
|
||||
const paired = await bridgeMetaMatchesFile(groupDir, existing);
|
||||
|
||||
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
|
||||
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
|
||||
// a file, and this is the first code in the repo that reads metadata and
|
||||
// writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
|
||||
// would tell every future reader that the pair had been verified.
|
||||
delete refreshed.repoListsUnreadable;
|
||||
delete refreshed.pairedWithDatabase;
|
||||
|
||||
if (paired) {
|
||||
const stat = await fsp.stat(dbPath).catch(() => null);
|
||||
if (stat) {
|
||||
refreshed.bridgeSize = stat.size;
|
||||
refreshed.bridgeMtimeMs = stat.mtimeMs;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'restamped';
|
||||
}
|
||||
// The database disappeared between the pairing check and this stat. There
|
||||
// is nothing left to stamp, so fall through and say so rather than write a
|
||||
// stamp describing a file that is gone.
|
||||
}
|
||||
|
||||
refreshed.provenanceUnknown = true;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'provenance-unknown';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -668,6 +953,22 @@ export interface WriteBridgeInput {
|
|||
crossLinks: CrossLink[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Repos this sync could not extract from — see
|
||||
* `ContractRegistry.unreadableRepos` for the full definition, which this
|
||||
* field carries unchanged.
|
||||
*
|
||||
* Deliberately not restated here. The narrower wording this once had ("whose
|
||||
* index could not be opened") described one of the two causes and silently
|
||||
* excluded the other, an extractor that threw partway through — so the same
|
||||
* field meant one thing on the registry, another on the bridge input, and a
|
||||
* third on the result. One definition, referenced twice, cannot drift.
|
||||
*
|
||||
* Recorded in meta.json so cross-repo impact can tell "nothing depends on
|
||||
* this" from "we could not look": the bridge built here is missing every
|
||||
* contract those repos own.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -702,7 +1003,33 @@ function errMessage(err: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
export async function writeBridge(
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
|
||||
* THE GROUP SYNC LOCK for `groupDir` (R9).
|
||||
*
|
||||
* PRECONDITION — the group lock is held. There is exactly one production call
|
||||
* site, `syncGroup` in sync.ts, and it is already inside
|
||||
* `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
|
||||
* rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
|
||||
* in repo-manager.ts, which splits the same shape for the same reason.
|
||||
*
|
||||
* WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database
|
||||
* aside, temp database into place, then `meta.json` written as a SECOND
|
||||
* operation — is the write two concurrent syncs can interleave into a pairing
|
||||
* that never existed: one sync's metadata beside the other's database. That
|
||||
* needs mutual exclusion. But taking the lock HERE would be a second
|
||||
* acquisition of a non-reentrant primitive inside a region that already holds
|
||||
* it, and it would hang every single sync on the happy path, not some rare
|
||||
* interleave. So the exclusion is the caller's, and this function only states
|
||||
* the precondition. {@link writeBridge} is the acquiring wrapper for callers
|
||||
* who are not already inside that region.
|
||||
*
|
||||
* SCOPE — writer-writer only. The reader-side promotion of a leftover
|
||||
* `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
|
||||
* `bridgeMetaMatchesFile` remains the reader's defense there and is not
|
||||
* replaced by this lock.
|
||||
*/
|
||||
export async function writeBridgeUnlocked(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
|
|
@ -962,11 +1289,39 @@ export async function writeBridge(
|
|||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
// 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES.
|
||||
//
|
||||
// meta.json carries the bridge's completeness, and since #3011 that is
|
||||
// load-bearing: `runGroupImpact` folds `unreadableRepos ∪ missingRepos`
|
||||
// into its truncation fields. The swap above and this write are two
|
||||
// operations, so a sync that stops between them leaves the previous sync's
|
||||
// meta beside a new database — and reading that as fact is a confidently
|
||||
// wrong answer about the one thing this channel exists to make legible.
|
||||
//
|
||||
// Deleting the old meta before the swap would decide which way that window
|
||||
// fails, but at an unacceptable price: the rename of the old database is
|
||||
// wrapped in a catch that also swallows a FAILED rename (a held read-only
|
||||
// handle does this on Windows), so `writeBridge` can throw with the old,
|
||||
// perfectly good database still in place — and its metadata already gone,
|
||||
// unrecoverably, for as long as the swap keeps failing.
|
||||
//
|
||||
// So destroy nothing and pair the two instead: record the size and mtime of
|
||||
// the database this metadata describes, and let readers check that the pair
|
||||
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
|
||||
// a freshly renamed database, and a sync that fails before the swap leaves a
|
||||
// matching pair untouched.
|
||||
const finalStat = await fsp.stat(finalPath);
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
bridgeSize: finalStat.size,
|
||||
bridgeMtimeMs: finalStat.mtimeMs,
|
||||
missingRepos: input.missingRepos,
|
||||
// Persisted whenever the caller supplied it, `[]` included: an empty list
|
||||
// is the measurement "this sync accounted for every repo", and it is a
|
||||
// different claim from a bridge that never recorded the field. Omitted
|
||||
// only when the caller passed nothing to record.
|
||||
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
|
||||
});
|
||||
|
||||
return report;
|
||||
|
|
@ -982,6 +1337,33 @@ export async function writeBridge(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
|
||||
*
|
||||
* The acquiring half of the split described on {@link writeBridgeUnlocked}: for
|
||||
* callers that are NOT already inside the group's critical section, this takes
|
||||
* the group sync lock around the whole swap and releases it afterwards. Two
|
||||
* concurrent calls therefore run one after the other, so the `meta.json` left
|
||||
* on disk is stamped for the `bridge.lbug` left on disk instead of for the
|
||||
* loser's, which is the pairing the swap-plus-metadata sequence would otherwise
|
||||
* let them interleave into.
|
||||
*
|
||||
* NOT used by `syncGroup`, and it must not be: that path already holds this
|
||||
* lock, and `acquireIndexLock` is not reentrant, so routing it here would make
|
||||
* every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
|
||||
* against itself. It calls {@link writeBridgeUnlocked} directly.
|
||||
*
|
||||
* Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
|
||||
* acquired, a `GroupSyncLockError` is thrown and NOTHING is written —
|
||||
* `bridge.lbug` and `meta.json` are left as they were.
|
||||
*/
|
||||
export async function writeBridge(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
137
gitnexus/src/core/group/completeness.ts
Normal file
137
gitnexus/src/core/group/completeness.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* The one computation of "is this cross-repo answer complete?" (KTD10), and the
|
||||
* truncation vocabulary it speaks.
|
||||
*
|
||||
* A LEAF MODULE, deliberately, and that is the whole reason it exists apart from
|
||||
* `cross-impact.ts`. Three surfaces need this fold — impact, trace, and the
|
||||
* contract listing — but `cross-impact.ts` statically imports `bridge-db.ts`,
|
||||
* and through it the native LadybugDB binding. `service.ts` therefore had to
|
||||
* reach the fold through `await import('./cross-impact.js')`, which loaded that
|
||||
* entire module graph on the first `group_contracts` of every process — 44-51ms
|
||||
* and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation.
|
||||
*
|
||||
* Nothing here imports anything but types. Keep it that way: the moment this
|
||||
* file gains a runtime import, every consumer pays for it again.
|
||||
*/
|
||||
import type { GroupImpactTruncationReason } from './types.js';
|
||||
|
||||
/**
|
||||
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
|
||||
* distinguishable by their `truncated` discriminant: a caller that folds these
|
||||
* fields into its own result (see `crossRepoCompleteness`) can then read
|
||||
* `truncationReason` on the truncated branch without a fallback for a value
|
||||
* that cannot be absent there.
|
||||
*/
|
||||
export type TruncationFields =
|
||||
| { truncated: false }
|
||||
| {
|
||||
truncated: true;
|
||||
truncationReason: GroupImpactTruncationReason;
|
||||
riskEpistemic: 'lower-bound';
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
*/
|
||||
export function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): TruncationFields {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a caller needs in order to say whether a cross-repo answer is
|
||||
* complete — deliberately WITHOUT naming where any of it came from.
|
||||
*
|
||||
* `BridgeMeta` is not in this signature, and must not be: `groupContracts`
|
||||
* answers the same question from `contracts.json` (via
|
||||
* `loadContractRegistryResilient`) and never opens a bridge at all, so
|
||||
* `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that
|
||||
* path. Each caller computes its own `provenanceUnknown` from whatever
|
||||
* provenance IT has and passes the boolean in.
|
||||
*/
|
||||
export interface CrossRepoCompletenessInput {
|
||||
/**
|
||||
* Repos the sync could not extract from, and repos it found no entry for.
|
||||
* Two independent diagnostics with one consequence — none of those repos'
|
||||
* contracts are in the artifact — so they are folded into one set.
|
||||
*/
|
||||
unreadableRepos?: readonly string[];
|
||||
missingRepos?: readonly string[];
|
||||
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
|
||||
provenanceUnknown: boolean;
|
||||
/**
|
||||
* The query's DECLARED scope, not the set of repos the walk happened to
|
||||
* reach: the subgroup filter for an impact query, the two endpoint repos for
|
||||
* a trace, every member for a query that names none. An incomplete repo the
|
||||
* caller never asked about cannot make the caller's answer a floor, and
|
||||
* marking it anyway is how the marker stops meaning anything. Passing the
|
||||
* predicate in — rather than a repo list, or a subgroup — is what keeps
|
||||
* narrowing a scope a call-site change.
|
||||
*/
|
||||
inScope: (repoPath: string) => boolean;
|
||||
}
|
||||
|
||||
/** The structured triple, plus the in-scope repos that produced it. */
|
||||
export type CrossRepoCompleteness = TruncationFields & {
|
||||
/**
|
||||
* In-scope repos absent from the artifact, deduped, in first-seen order.
|
||||
* Empty on a provenance-unknown answer: nothing was measured there, and
|
||||
* inventing names out of an unreadable value is not a measurement.
|
||||
*/
|
||||
incompleteRepos: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
|
||||
*
|
||||
* Three surfaces can return a partial cross-repo answer — impact, trace, and
|
||||
* the contract listing — and each used to decide for itself, in its own
|
||||
* vocabulary, which is how two of them ended up saying it in prose only. The
|
||||
* answer is the same structured triple `GroupImpactResult` already carries, so
|
||||
* an agent reading any of them learns "complete" vs "floor" the same way.
|
||||
*
|
||||
* `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and
|
||||
* is reused here rather than re-implemented for the same reason it exists: the
|
||||
* marker that says "this is a floor, not a verdict" may never drift away from
|
||||
* the flag that says the answer was cut short (#2787).
|
||||
*/
|
||||
export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness {
|
||||
const incompleteRepos = [
|
||||
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
|
||||
].filter((repoPath) => input.inScope(repoPath));
|
||||
return {
|
||||
...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
|
||||
incompleteRepos,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded repo list is an array of strings. Anything else — a bare string, an
|
||||
* object, an array of objects — is a value we could not read, which is "not
|
||||
* recorded", not "none".
|
||||
*
|
||||
* ONE definition, deliberately. This gate is the predicate the whole
|
||||
* absent-vs-empty-vs-populated distinction rests on, and it applies to the same
|
||||
* two lists on both the registry and the bridge metadata. It lived in two files
|
||||
* verbatim, which meant tightening it — say, to reject blank strings — would
|
||||
* have fixed one surface and silently left the other.
|
||||
*
|
||||
* `Array.isArray` alone is not enough: only an array of strings survives
|
||||
* `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`.
|
||||
*/
|
||||
export function recordedRepoList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined;
|
||||
}
|
||||
|
|
@ -7,11 +7,11 @@ import fsp from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
ContractType,
|
||||
CrossRepoImpact,
|
||||
GroupConfig,
|
||||
GroupImpactResult,
|
||||
GroupImpactTruncationReason,
|
||||
MatchType,
|
||||
OutOfScopeLink,
|
||||
} from './types.js';
|
||||
|
|
@ -24,12 +24,23 @@ import {
|
|||
} from './group-path-utils.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import {
|
||||
bridgeMetaMatchesFile,
|
||||
closeBridgeDb,
|
||||
getCachedBridgeReadOnly,
|
||||
queryBridge,
|
||||
readBridgeMeta,
|
||||
} from './bridge-db.js';
|
||||
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
// Re-exported so the three surfaces keep one import site for the vocabulary,
|
||||
// while the fold itself stays in a leaf module no native binding reaches.
|
||||
export {
|
||||
truncationFields,
|
||||
crossRepoCompleteness,
|
||||
type TruncationFields,
|
||||
type CrossRepoCompleteness,
|
||||
type CrossRepoCompletenessInput,
|
||||
} from './completeness.js';
|
||||
import { truncationFields, crossRepoCompleteness } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
|
||||
// High limit for the local phase of group impact so collectImpactSymbolUids
|
||||
|
|
@ -381,23 +392,30 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
* Is this bridge's metadata unable to say where its contents came from?
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
* The three reads are all about a `BridgeMeta` and stay OUT of
|
||||
* `crossRepoCompleteness` on purpose (see its doc): they are how a caller that
|
||||
* opened a bridge computes `provenanceUnknown`, not how every caller does.
|
||||
*
|
||||
* - `version === 0` — no readable meta.json at all (`readBridgeMeta` answers
|
||||
* that for both "absent" and "unparseable");
|
||||
* - `repoListsUnreadable` — a meta.json that parsed but whose repo lists are
|
||||
* not repo lists. A value we could not read is not a measurement of zero,
|
||||
* so it may not be spent as one;
|
||||
* - `pairedWithDatabase === false` — a meta.json that does not describe the
|
||||
* database sitting beside it, which is what a sync interrupted between the
|
||||
* swap and the metadata write leaves behind. Measured by
|
||||
* `ensureBridgeReady` BEFORE the database is opened and carried on the
|
||||
* meta; this only reads the answer (#3012).
|
||||
*
|
||||
* Treating any of them as complete is the fail-open the completeness channel
|
||||
* exists to close.
|
||||
*/
|
||||
function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): Pick<GroupImpactResult, 'truncated' | 'truncationReason' | 'riskEpistemic'> {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean {
|
||||
return (
|
||||
meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false
|
||||
);
|
||||
}
|
||||
|
||||
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
|
||||
|
|
@ -418,7 +436,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v
|
|||
|
||||
export async function ensureBridgeReady(
|
||||
groupDir: string,
|
||||
): Promise<{ handle: BridgeHandle } | { error: string }> {
|
||||
): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> {
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return {
|
||||
|
|
@ -433,6 +451,13 @@ export async function ensureBridgeReady(
|
|||
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
|
||||
};
|
||||
}
|
||||
// Pair the metadata to the database BEFORE opening it, and carry the answer.
|
||||
// An unstamped pair is judged on the two files' write order, so any open that
|
||||
// touched `bridge.lbug`'s mtime would silently convert "legacy but intact"
|
||||
// into "provenance unknown" for every pre-stamp bridge on that platform. This
|
||||
// ordering removes the question rather than betting on the answer.
|
||||
meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta);
|
||||
|
||||
// Use the cached read-only handle if available — avoids reopening the same
|
||||
// bridge.lbug in a long-lived MCP server, which fails on Windows because
|
||||
// the OS handle isn't fully released before the next open races in.
|
||||
|
|
@ -442,7 +467,7 @@ export async function ensureBridgeReady(
|
|||
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
|
||||
};
|
||||
}
|
||||
return { handle };
|
||||
return { handle, meta };
|
||||
}
|
||||
|
||||
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
|
||||
|
|
@ -641,6 +666,25 @@ export async function runGroupImpact(
|
|||
if ('error' in bridgePrep) return { error: bridgePrep.error };
|
||||
|
||||
const handle = bridgePrep.handle;
|
||||
// Repos the sync that built this bridge could not account for. Their
|
||||
// contracts — and every cross-link touching them — are simply absent from
|
||||
// bridge.lbug, and nothing else in this walk can notice that: the only
|
||||
// incompleteness channel on the result is `truncationFields`, driven by
|
||||
// fan-out state. Without folding these in, a query about a symbol whose one
|
||||
// downstream consumer lives in an unreadable repo returns
|
||||
// `{ cross: [], truncated: false }` — "complete: nothing depends on this" —
|
||||
// which is a wrong answer, not an empty one, for a tool an agent uses to
|
||||
// license a delete or a rename.
|
||||
//
|
||||
// The metadata read that answers it (`bridgeProvenanceUnknown`) happens
|
||||
// INSIDE the `try` below, and the flag is initialized fail-closed here only
|
||||
// because it outlives that block. The lease taken by `ensureBridgeReady` is
|
||||
// released by the `finally` and nowhere else, so work done between the lease
|
||||
// and the `try` is work whose every throw leaks a refcount the cached handle
|
||||
// can never get back — which is how a malformed meta.json used to wedge the
|
||||
// handle as well as crash the query. (The repo lists are folded in after the
|
||||
// `finally`, where a throw can no longer strand the lease.)
|
||||
let provenanceUnknown = true;
|
||||
const cross: CrossRepoImpact[] = [];
|
||||
const outOfScope: OutOfScopeLink[] = [];
|
||||
const truncatedRepos: string[] = [];
|
||||
|
|
@ -650,6 +694,8 @@ export async function runGroupImpact(
|
|||
let fanoutTimedOut = false;
|
||||
|
||||
try {
|
||||
provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta);
|
||||
|
||||
const neighbors = await resolveBridgeNeighbors(handle, {
|
||||
localRepo: repoPath,
|
||||
uids,
|
||||
|
|
@ -782,7 +828,45 @@ export async function runGroupImpact(
|
|||
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
|
||||
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
|
||||
const localPartial = Boolean((local as { partial?: boolean }).partial);
|
||||
const truncated = truncatedRepos.length > 0 || localPartial;
|
||||
// The bridge's own incompleteness, in the shared vocabulary, read through
|
||||
// what this query DECLARED. The fan-out above already drops every neighbour
|
||||
// outside `subgroup`, so an incomplete repo the query excluded could not have
|
||||
// contributed a crossing to this answer — marking the answer a floor because
|
||||
// of it makes the marker fire on results it does not describe, which is how a
|
||||
// caller learns to ignore it. An unscoped query passes `subgroup: undefined`,
|
||||
// which `repoInSubgroup` answers true for, so the intersection is the whole
|
||||
// set and that path is byte-for-byte the old behaviour.
|
||||
//
|
||||
// The declared scope is the subgroup PLUS the query's own repo (`exact`
|
||||
// reuses the one membership helper for the equality, rather than growing a
|
||||
// second notion of it): the walk starts from `repoPath`'s contracts in the
|
||||
// bridge, so if THAT is the repo the sync could not read there are no
|
||||
// crossings to find for any scope, and a subgroup excluding it must not turn
|
||||
// that vacuum into a confident "complete".
|
||||
//
|
||||
// Declared scope, not traversed scope: an incomplete repo's contracts are
|
||||
// absent from the bridge by definition, so it is never in the set the walk
|
||||
// reached — filtering on what was traversed would empty the intersection on
|
||||
// every query and silently restore the fail-open.
|
||||
//
|
||||
// Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an
|
||||
// out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would
|
||||
// convert a genuine lower bound into a confident complete answer; widen this
|
||||
// predicate in the same change that raises the depth.
|
||||
const bridge = crossRepoCompleteness({
|
||||
unreadableRepos: bridgePrep.meta.unreadableRepos,
|
||||
missingRepos: bridgePrep.meta.missingRepos,
|
||||
provenanceUnknown,
|
||||
inScope: (candidate) =>
|
||||
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
|
||||
});
|
||||
// One predicate, read twice below. Written out at both sites, a third runtime
|
||||
// cause added to the flag and forgotten at the reason would label a
|
||||
// retry-able answer `incomplete-sync` — telling the operator to re-sync for
|
||||
// something a retry fixes. That reason-vs-flag drift is what `truncationFields`
|
||||
// exists to prevent.
|
||||
const runtimeTruncated = truncatedRepos.length > 0 || localPartial;
|
||||
const truncated = runtimeTruncated || bridge.truncated;
|
||||
|
||||
const result: GroupImpactResult = {
|
||||
local,
|
||||
|
|
@ -794,8 +878,17 @@ export async function runGroupImpact(
|
|||
// and under-reporting a blast radius is the unsafe direction (an agent told
|
||||
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
|
||||
// warning intact while making the incompleteness legible.
|
||||
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
|
||||
truncatedRepos: [...new Set(truncatedRepos)],
|
||||
// Runtime limits first — they are what the caller can retry. 'incomplete-sync'
|
||||
// is the remaining cause once nothing was merely cut short, and its remedy is
|
||||
// a different one: re-run `gitnexus group sync`, not the query. Computed
|
||||
// inline because `truncationFields` reads the reason ONLY on the truncated
|
||||
// branch — naming it in a variable invited reading it on the complete path,
|
||||
// where it would say 'incomplete-sync' about a complete result.
|
||||
...truncationFields(
|
||||
truncated,
|
||||
fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync',
|
||||
),
|
||||
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
|
||||
summary: {
|
||||
direct: localSum.direct ?? 0,
|
||||
processes_affected: localSum.processes_affected ?? 0,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,29 @@
|
|||
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
|
||||
import {
|
||||
bridgeProvenanceUnknown,
|
||||
crossRepoCompleteness,
|
||||
ensureBridgeReady,
|
||||
MAX_SUPPORTED_CROSS_DEPTH,
|
||||
} from './cross-impact.js';
|
||||
import type { CrossRepoCompleteness } from './completeness.js';
|
||||
import { truncationFields } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
import { closeBridgeDb, queryBridge } from './bridge-db.js';
|
||||
import { repoInSubgroup } from './group-path-utils.js';
|
||||
import type {
|
||||
GroupPdgFlowHop,
|
||||
GroupRepoHandle,
|
||||
GroupSymbolResolution,
|
||||
GroupToolPort,
|
||||
} from './service.js';
|
||||
import type { BridgeHandle, GroupConfig } from './types.js';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
GroupConfig,
|
||||
GroupImpactTruncationReason,
|
||||
} from './types.js';
|
||||
|
||||
// ── Result types (discriminated on `status`) ─────────────────────────────
|
||||
|
||||
|
|
@ -77,7 +90,29 @@ export interface GroupTraceEndpoint {
|
|||
repo: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult {
|
||||
/**
|
||||
* The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10).
|
||||
*
|
||||
* A cross-repo trace and a cross-repo impact can both be cut short by the same
|
||||
* two kinds of cause — a runtime limit inside this walk, or a bridge that never
|
||||
* held part of the group — and an agent must not have to learn a second
|
||||
* vocabulary (or parse a `notes` string) to tell "no path exists" from "we
|
||||
* could not have seen the path". Every field here means exactly what it means
|
||||
* on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them,
|
||||
* never the machine-readable channel.
|
||||
*/
|
||||
export interface GroupTraceCompleteness {
|
||||
/** True when this answer is a floor rather than a verdict. */
|
||||
truncated?: boolean;
|
||||
/** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */
|
||||
truncationReason?: GroupImpactTruncationReason;
|
||||
/** Set with `truncated`: the answer under-reports, it never over-reports. */
|
||||
riskEpistemic?: 'lower-bound';
|
||||
/** In-scope repos absent from the bridge; omitted when none were measured. */
|
||||
truncatedRepos?: string[];
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult extends GroupTraceCompleteness {
|
||||
status: 'ok';
|
||||
group: string;
|
||||
from: GroupTraceEndpoint;
|
||||
|
|
@ -89,7 +124,6 @@ export interface GroupTraceOkResult {
|
|||
edges: TraceEdge[];
|
||||
/** Present only when PDG enrichment ran for at least one segment. */
|
||||
dataFlow?: SegmentDataFlow[];
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
|
|
@ -101,23 +135,23 @@ export interface GroupTraceCandidate {
|
|||
startLine: number;
|
||||
}
|
||||
|
||||
export interface GroupTraceNotFoundResult {
|
||||
/**
|
||||
* `truncated: true` here means the answer is NOT authoritative — either the
|
||||
* crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink
|
||||
* ranked beyond it may have been skipped, or the bridge itself never held part
|
||||
* of the group. Both read as "unknown", not as "no path exists";
|
||||
* `truncationReason` says which.
|
||||
*/
|
||||
export interface GroupTraceNotFoundResult extends GroupTraceCompleteness {
|
||||
status: 'not_found';
|
||||
group: string;
|
||||
role?: 'from' | 'to';
|
||||
query?: string;
|
||||
/**
|
||||
* True when the answer is NOT authoritative: the crossing cap
|
||||
* (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond
|
||||
* the cap may have been skipped. A consumer should treat this as "unknown",
|
||||
* not "no path exists".
|
||||
*/
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceAmbiguousResult {
|
||||
export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness {
|
||||
status: 'ambiguous';
|
||||
group: string;
|
||||
role: 'from' | 'to';
|
||||
|
|
@ -187,6 +221,54 @@ export const TRACE_NOTES = {
|
|||
'The candidates are listed; trace from the exact calling function or pass `to_uid`.',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fold this bridge's completeness into the runtime-truncation flag a trace call
|
||||
* site already computed, and answer in the shared vocabulary.
|
||||
*
|
||||
* Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because
|
||||
* it is the cause the caller can act on (narrow the query, raise maxDepth),
|
||||
* while `'incomplete-sync'` needs a different remedy — `gitnexus group sync` —
|
||||
* and would otherwise mask it.
|
||||
*
|
||||
* Returns `{}` — not `{ truncated: false }` — when the answer is complete, so a
|
||||
* clean trace result keeps the exact shape it has always had.
|
||||
*/
|
||||
function traceCompleteness(
|
||||
bridge: CrossRepoCompleteness,
|
||||
runtimeTruncated: boolean,
|
||||
): GroupTraceCompleteness {
|
||||
const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {};
|
||||
// Through `truncationFields`, not hand-written: `riskEpistemic` must follow
|
||||
// `truncated` mechanically, and a third writer of that pair is how the
|
||||
// invariant drifts (#2787). The bridge branch re-spreads the helper's own
|
||||
// output rather than naming its fields.
|
||||
if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos };
|
||||
if (!bridge.truncated) return {};
|
||||
const { incompleteRepos: _incompleteRepos, ...fields } = bridge;
|
||||
return { ...fields, ...repos };
|
||||
}
|
||||
|
||||
/**
|
||||
* The trace's declared scope for `crossRepoCompleteness`.
|
||||
*
|
||||
* A symbol-to-symbol trace asks about exactly two repos, so an unreadable third
|
||||
* member cannot make its answer a floor. A DESTINATION trace declares no `to`
|
||||
* at all — the call may land in any member — so every repo is in scope there,
|
||||
* which is why the predicate is built per call site rather than derived from
|
||||
* the endpoints inside the helper.
|
||||
*/
|
||||
function bridgeCompletenessFor(
|
||||
meta: BridgeMeta,
|
||||
inScope: (repoPath: string) => boolean,
|
||||
): CrossRepoCompleteness {
|
||||
return crossRepoCompleteness({
|
||||
unreadableRepos: meta.unreadableRepos,
|
||||
missingRepos: meta.missingRepos,
|
||||
provenanceUnknown: bridgeProvenanceUnknown(meta),
|
||||
inScope,
|
||||
});
|
||||
}
|
||||
|
||||
/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */
|
||||
function sameFile(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
|
|
@ -873,6 +955,23 @@ async function stitchCrossRepo(
|
|||
if (p.pdg) notes.push(TRACE_NOTES.pdgRequested);
|
||||
|
||||
try {
|
||||
// Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by
|
||||
// `ensureBridgeReady` is released by this block's `finally` and nowhere
|
||||
// else, so anything computed between the lease and the `try` is work whose
|
||||
// every throw would strand a refcount the cached handle never gets back.
|
||||
//
|
||||
// Declared scope = the two endpoint repos. Whether either of them is a repo
|
||||
// this bridge could not read decides whether "no ContractLink connects
|
||||
// them" is a verdict or a floor.
|
||||
const bridge = bridgeCompletenessFor(
|
||||
bridgePrep.meta,
|
||||
// `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators
|
||||
// and strips trailing slashes, which bare equality does not, so the same
|
||||
// group.yaml spelling cannot be in scope for impact and out of scope here.
|
||||
(repoPath) =>
|
||||
repoInSubgroup(repoPath, fromEp.member.repoPath, true) ||
|
||||
repoInSubgroup(repoPath, toEp.member.repoPath, true),
|
||||
);
|
||||
const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween(
|
||||
handle,
|
||||
fromEp.member.repoPath,
|
||||
|
|
@ -883,6 +982,10 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
// No crossings at all is exactly the answer a bridge that never held an
|
||||
// endpoint's repo produces, so it is the one that most needs the floor
|
||||
// marker. (Nothing was capped: there were zero rows to cap.)
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion:
|
||||
'The endpoints live in different repos with no ContractLink between them. ' +
|
||||
|
|
@ -1016,6 +1119,13 @@ async function stitchCrossRepo(
|
|||
hopCount: edges.length,
|
||||
hops: [...hopsA, ...hopsB],
|
||||
edges,
|
||||
// A found path is still an answer from this bridge: if its provenance is
|
||||
// unknown, or an endpoint's repo never made it in, the path may be stale
|
||||
// and it is certainly not the only one. An incompleteness channel that
|
||||
// fires only on the empty answer teaches an agent that a non-empty one
|
||||
// is always complete. The crossing cap is NOT folded in here — a path
|
||||
// that connected is not a capped search — so this site passes `false`.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
...(dataFlow.length > 0 ? { dataFlow } : {}),
|
||||
};
|
||||
|
|
@ -1028,7 +1138,7 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
...(crossingsTruncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, crossingsTruncated),
|
||||
notes,
|
||||
suggestion: crossingsTruncated
|
||||
? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` +
|
||||
|
|
@ -1099,6 +1209,12 @@ async function stitchToDestination(
|
|||
if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped);
|
||||
|
||||
try {
|
||||
// Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A
|
||||
// destination trace declares NO `to`: the call may land in any member, so
|
||||
// every repo is in the query's scope and no incomplete one can be filtered
|
||||
// out. An unreadable provider repo is precisely how "no outgoing
|
||||
// ContractLink leaves this repo" becomes a wrong answer, not an empty one.
|
||||
const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true);
|
||||
const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath);
|
||||
if (crossings.length === 0) {
|
||||
notes.push(TRACE_NOTES.destinationNoLink);
|
||||
|
|
@ -1107,6 +1223,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
// Zero rows to cap, so only the bridge's own completeness can speak.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.',
|
||||
};
|
||||
|
|
@ -1224,7 +1342,9 @@ async function stitchToDestination(
|
|||
hopCount: edgesA.length + 1,
|
||||
hops: [...hopsA, providerHop],
|
||||
edges: [...edgesA, boundaryEdge],
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
// The cap already marked this result; the bridge's completeness folds
|
||||
// into the same fields rather than beside them.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: resultNotes,
|
||||
};
|
||||
};
|
||||
|
|
@ -1240,6 +1360,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(precise),
|
||||
// The candidate LIST is what an incomplete bridge shortens here.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationMultiple],
|
||||
};
|
||||
}
|
||||
|
|
@ -1255,6 +1377,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(fileLevel),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationAmbiguousFile],
|
||||
};
|
||||
}
|
||||
|
|
@ -1265,7 +1388,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes,
|
||||
suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
import {
|
||||
shouldIgnorePath,
|
||||
loadIgnoreRules,
|
||||
isHardcodedIgnoredDirectoryAtPath,
|
||||
} from '../../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
interface PythonPackageMeta {
|
||||
|
|
@ -161,9 +165,11 @@ async function findPythonFiles(repoPath: string): Promise<string[]> {
|
|||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
const childPath = path.join(dir, entry.name);
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
await walk(childPath, childRel);
|
||||
} else if (entry.name.endsWith('.py')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
|
|
|
|||
201
gitnexus/src/core/group/group-lock.ts
Normal file
201
gitnexus/src/core/group/group-lock.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Cross-process single-writer lock for one group's persisted state (R9).
|
||||
*
|
||||
* A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug`
|
||||
* from a snapshot it computed minutes earlier. Two syncs of the same group that
|
||||
* overlap therefore do not merge — the second one's write simply overwrites the
|
||||
* first one's, and whichever finishes last wins with a registry assembled from
|
||||
* repo state the other run never saw. Nothing detects it afterwards: both runs
|
||||
* report success, and the group's contracts silently describe a mixture that was
|
||||
* never true at any instant. This module serializes that section so one sync at
|
||||
* a time can be inside it.
|
||||
*
|
||||
* WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group
|
||||
* directory — mirroring `withRegistryLock`, which locks a `registry-lock`
|
||||
* directory beside the registry rather than the registry's own directory
|
||||
* (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file
|
||||
* backend writes `analyze.lock` into the directory it is handed, so pointing it
|
||||
* at a directory that some other code path might also lock — or that already
|
||||
* holds a per-repo index slot — reintroduces exactly the collision the registry
|
||||
* lock's own comment warns about. `<groupDir>/sync-lock` is a namespace nothing
|
||||
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
|
||||
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
|
||||
*
|
||||
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
|
||||
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
|
||||
* JSON read/merge/write on a latency-critical path (`augment` runs on every
|
||||
* editor tool call), and running unlocked is merely the pre-lock status quo. A
|
||||
* group sync is the opposite on every axis — it is long, expensive, operator-
|
||||
* initiated, and its lost update destroys contracts rather than a registry field.
|
||||
* A sync that cannot be protected must not run at all, and there are three
|
||||
* distinct ways it can fail to be protected; all three throw
|
||||
* {@link GroupSyncLockError}:
|
||||
*
|
||||
* 1. TIMEOUT — the holder is still alive when the ceiling elapses.
|
||||
* 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or
|
||||
* permission-denied filesystem with a no-op handle that is byte-identical
|
||||
* to a real one at the API boundary. That is a deliberate tolerance for
|
||||
* `analyze` (an unwritable index dir rejects every write anyway, so the
|
||||
* lock is moot), but here it would hand back a handle that protects
|
||||
* nothing while the sync went on to attempt its writes. The handle now
|
||||
* carries {@link IndexLockHandle.lockFree}, so we can see it and refuse.
|
||||
* 3. ANY OTHER ACQUIRE FAILURE — e.g. `sync-lock` cannot be created because a
|
||||
* regular file already occupies the path. Silently proceeding on an error
|
||||
* we did not anticipate is the same unprotected run under another name.
|
||||
*
|
||||
* WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point — 10
|
||||
* minutes deliberately matches `acquireIndexLock`'s own default, because a group
|
||||
* sync is analyze-shaped and a legitimately queued second sync must be able to
|
||||
* wait out a full first one (the registry lock's 5s is sized for a sub-second
|
||||
* merge and is the wrong model here). The reason to pass it is
|
||||
* `resolveTimeoutMs`: it prefers an explicit argument over
|
||||
* `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to
|
||||
* `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this
|
||||
* lock's fail-closed timeout into an unbounded hang.
|
||||
*
|
||||
* ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section.
|
||||
* Nothing it calls beneath that point — `writeContractRegistry`,
|
||||
* `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` — takes this lock; a
|
||||
* second acquisition would deadlock a non-reentrant primitive on the HAPPY
|
||||
* path, not on some edge case. `bridge-db.ts` exports the swap in both forms
|
||||
* for exactly that reason: `writeBridgeUnlocked` for the held-lock caller
|
||||
* (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct
|
||||
* callers that are outside the region. The same split `repo-manager.ts` uses
|
||||
* for `registerRepoUnlocked` / `registerRepo`.
|
||||
*
|
||||
* SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux
|
||||
* abstract sockets, which are network-namespace-scoped. Two containers that
|
||||
* share a bind-mounted group directory but sit in separate netns will NOT
|
||||
* contend, exactly as documented for the index lock itself; forcing
|
||||
* `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
type IndexLockHandle,
|
||||
} from '../../storage/index-lock.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/** Lock-directory name inside the group directory. Never the group dir itself. */
|
||||
export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock';
|
||||
|
||||
/** The dedicated lock namespace for one group: `<groupDir>/sync-lock`. */
|
||||
export const getGroupSyncLockDir = (groupDir: string): string =>
|
||||
path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME);
|
||||
|
||||
/**
|
||||
* Wait ceiling for the group sync lock (10 min). See the module header: the
|
||||
* magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the
|
||||
* reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`
|
||||
* (whose `<= 0` case means unbounded) from turning fail-closed into a hang.
|
||||
*/
|
||||
export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */
|
||||
export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable';
|
||||
|
||||
/**
|
||||
* A group sync could not be protected, so it did not run. One class for all
|
||||
* three exits so both callers — the CLI command and the MCP service — have a
|
||||
* single thing to catch and report.
|
||||
*/
|
||||
export class GroupSyncLockError extends Error {
|
||||
readonly reason: GroupSyncLockFailure;
|
||||
readonly groupDir: string;
|
||||
constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = 'GroupSyncLockError';
|
||||
this.reason = reason;
|
||||
this.groupDir = groupDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `operation` as the only group sync touching `groupDir`, or throw
|
||||
* {@link GroupSyncLockError} without running it at all.
|
||||
*
|
||||
* The lock is released in a `finally`, so it is dropped whether the operation
|
||||
* succeeds or throws.
|
||||
*/
|
||||
export const withGroupSyncLock = async <T>(
|
||||
groupDir: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
let handle: IndexLockHandle;
|
||||
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
|
||||
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
|
||||
// inside its inherited message string, so the figure has to be measured here
|
||||
// to be reported without that message. `Date.now()` matches how the primitive
|
||||
// measures its own wait.
|
||||
const acquireStartedAt = Date.now();
|
||||
try {
|
||||
handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), {
|
||||
timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS,
|
||||
// `acquireIndexLock`'s own `log` texts name an "analyze" holder, which
|
||||
// misattributes a group-sync wait — the same reason `withRegistryLock`
|
||||
// supplies its own line instead of passing `log` through.
|
||||
onWaitStart: () =>
|
||||
logger.info(
|
||||
{ groupDir },
|
||||
'Waiting for another GitNexus process to finish syncing this group…',
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
// The inherited message names "another gitnexus analyze" as the holder —
|
||||
// a cause this detection path cannot establish. Nothing but a group sync
|
||||
// ever locks `<groupDir>/sync-lock` (see the module header), and on the
|
||||
// socket backend the holder is not identifiable at all. Re-word it around
|
||||
// what IS known: which group, which operation, and how long we waited.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
throw new GroupSyncLockError(
|
||||
'timeout',
|
||||
groupDir,
|
||||
`Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` +
|
||||
`group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` +
|
||||
// `holderKnown` is false on the socket backend and on the file
|
||||
// backend's malformed/vanished-lock timeouts, where `holder` is a
|
||||
// placeholder (`pid -1`). Presenting that as a real owner would be the
|
||||
// same unestablished claim in a new form.
|
||||
(err.holderKnown
|
||||
? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` +
|
||||
`(invocation ${err.holder.invocationId}). `
|
||||
: `The lock stayed held for the whole wait, but this lock backend ` +
|
||||
`cannot identify the holder. `) +
|
||||
`Nothing was written and this group was not synced. ` +
|
||||
`Re-run once the other sync of this group has finished.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
throw new GroupSyncLockError(
|
||||
'unavailable',
|
||||
groupDir,
|
||||
`Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` +
|
||||
`${err instanceof Error ? err.message : String(err)}. Nothing was written.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.lockFree) {
|
||||
// A handle that owns nothing. Release it anyway (it is a no-op, but the
|
||||
// contract is that every handle is released) and refuse to run: this sync
|
||||
// would otherwise write `contracts.json` and `bridge.lbug` with no
|
||||
// protection at all against a concurrent sync doing the same.
|
||||
handle.release();
|
||||
throw new GroupSyncLockError(
|
||||
'lock-free',
|
||||
groupDir,
|
||||
`The sync lock for this group could not be created at ` +
|
||||
`${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` +
|
||||
`so this sync cannot be protected against a concurrent one. Nothing was written. ` +
|
||||
`Make the group directory writable and re-run.`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
handle.release();
|
||||
}
|
||||
};
|
||||
|
|
@ -6,7 +6,16 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
loadMeta,
|
||||
readRegistryStrict,
|
||||
registryPathEquals,
|
||||
type RegistryEntry,
|
||||
type RepoMeta,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { crossRepoCompleteness } from './completeness.js';
|
||||
import { recordedRepoList } from './completeness.js';
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import {
|
||||
fileMatchesServicePrefix,
|
||||
|
|
@ -222,6 +231,34 @@ function isCrossLink(raw: unknown): raw is CrossLink {
|
|||
return typeof o.contractId === 'string' && typeof o.type === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the global registry hold a row for this configured group member?
|
||||
*
|
||||
* Consulted only once resolution has ALREADY failed, to choose which of the
|
||||
* two failures `group status` reports. It mirrors the two tiers
|
||||
* `LocalBackend.resolveRepo` matches a bare group-config value on — the
|
||||
* registry `name`, case-insensitively, and the repo `path` — and deliberately
|
||||
* stops short of its hashed-id and partial-name tiers: those exist to be
|
||||
* generous about what an operator typed, while this predicate only decides
|
||||
* between two labels, and a looser match here would relabel a genuine registry
|
||||
* miss as an unresolvable row. That is the same conflation this reporting
|
||||
* exists to remove, pointed the other way.
|
||||
*/
|
||||
function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean {
|
||||
const wantedName = registryName.toLowerCase();
|
||||
// Path equality goes through the registry's own rule rather than a local
|
||||
// `resolve` + platform-case compare. `canonicalizePath` also follows symlinks,
|
||||
// so a row registered through one and looked up through the other still
|
||||
// matches — and there is one definition of registry path identity instead of
|
||||
// a third, weaker copy of it living in a group module nobody would grep.
|
||||
const wantedPath = canonicalizePath(registryName);
|
||||
return entries.some((entry) => {
|
||||
if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true;
|
||||
if (typeof entry.path !== 'string') return false;
|
||||
return registryPathEquals(canonicalizePath(entry.path), wantedPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContractRegistryResilient(
|
||||
groupDir: string,
|
||||
): Promise<
|
||||
|
|
@ -288,6 +325,8 @@ async function loadContractRegistryResilient(
|
|||
}
|
||||
}
|
||||
|
||||
// Bound once: the gate is a full array scan and the ternary below used it twice.
|
||||
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
|
||||
const registry: ContractRegistry = {
|
||||
version: typeof base.version === 'number' ? base.version : 0,
|
||||
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
|
||||
|
|
@ -295,7 +334,20 @@ async function loadContractRegistryResilient(
|
|||
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
|
||||
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
|
||||
: {},
|
||||
missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [],
|
||||
// Same gate as `groupStatus` uses on the same field, for the same reason:
|
||||
// `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts`
|
||||
// now returns this list AND folds it into its completeness answer, so a
|
||||
// value we could not read would be reported as a repo name. `missingRepos`
|
||||
// has always been required, so — unlike `unreadableRepos` below — there is
|
||||
// no "not recorded" state to preserve: an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(base.missingRepos) ?? [],
|
||||
// Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence
|
||||
// as "not recorded", and a registry written before the field existed has no
|
||||
// opinion about which indexes were readable. Normalizing that to `[]` hands
|
||||
// the caller "the last sync found none unreadable" — an unmeasured state
|
||||
// rendered as a clean result, which is the same conflation this whole
|
||||
// change removes.
|
||||
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
|
||||
contracts,
|
||||
crossLinks,
|
||||
};
|
||||
|
|
@ -347,18 +399,34 @@ export class GroupService {
|
|||
// MCP server startup entirely and off every non-sync group call. The CLI
|
||||
// already does exactly this at `cli/group.ts`'s sync command.
|
||||
const { syncGroup } = await import('./sync.js');
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
const { GroupSyncLockError } = await import('./group-lock.js');
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
} catch (err) {
|
||||
// Fails closed (R9): this sync could not be protected against a concurrent
|
||||
// one, so it did not run and wrote nothing. Return it through the same
|
||||
// error channel a missing group uses — NEVER as a success payload of zeroes,
|
||||
// which an agent would read as "the group genuinely has no contracts".
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
return { error: err.message };
|
||||
}
|
||||
return {
|
||||
contracts: result.contracts.length,
|
||||
crossLinks: result.crossLinks.length,
|
||||
unmatched: result.unmatched.length,
|
||||
missingRepos: result.missingRepos,
|
||||
unreadableRepos: result.unreadableRepos,
|
||||
// An agent that calls group_sync and then group_contracts a moment later
|
||||
// can otherwise see contract counts that disagree with this payload, with
|
||||
// nothing here explaining why the write was skipped.
|
||||
registryOutcome: result.registryOutcome,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +454,38 @@ export class GroupService {
|
|||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
|
||||
// `loadContractRegistryResilient` already applied `recordedRepoList` to
|
||||
// both: `undefined` here is "the last sync recorded no opinion" (a registry
|
||||
// written before the field existed, or a value we could not read), which is
|
||||
// NOT the same answer as the measured empty list.
|
||||
const { unreadableRepos, missingRepos } = registry;
|
||||
// `incompleteRepos` is dropped on this surface only because the two lists it
|
||||
// is derived from are returned verbatim right below; the truncation triple is
|
||||
// the part that has no other channel here.
|
||||
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
|
||||
unreadableRepos,
|
||||
missingRepos,
|
||||
// An unrecorded `unreadableRepos` means this listing cannot say which
|
||||
// repos the sync failed to read — so it cannot claim to be complete.
|
||||
provenanceUnknown: unreadableRepos === undefined,
|
||||
// A contract LISTING declares no scope to intersect with: it is the whole
|
||||
// registry, so every configured repo is in scope by construction. The
|
||||
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
|
||||
// not which repos the sync had to read to produce them.
|
||||
inScope: () => true,
|
||||
});
|
||||
const out: Record<string, unknown> = {
|
||||
contracts,
|
||||
crossLinks: registry.crossLinks,
|
||||
missingRepos,
|
||||
// Omitted rather than `[]` when the registry never recorded it — the same
|
||||
// convention `skippedCorrupt` follows below, and the difference between
|
||||
// "the sync measured zero unreadable repos" and "the sync never said".
|
||||
...(unreadableRepos ? { unreadableRepos } : {}),
|
||||
// The structured triple, verbatim from the impact surface (KTD10):
|
||||
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
|
||||
...truncation,
|
||||
};
|
||||
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
|
||||
return out;
|
||||
}
|
||||
|
|
@ -573,17 +672,80 @@ export class GroupService {
|
|||
}
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
/**
|
||||
* The STRICT global-registry read, deliberately — this is the one caller
|
||||
* that has to tell "the registry says nothing about this repo" apart from
|
||||
* "the registry could not be read at all", and only the strict mode can.
|
||||
* `readRegistry`'s `catch { return [] }` collapses a malformed registry
|
||||
* into an empty one, which is indistinguishable from a genuine absence and
|
||||
* would report every configured repo as having no entry — the exact
|
||||
* conflation the two labels below exist to remove.
|
||||
*
|
||||
* The consequence is accepted knowingly: the strict read rejects the WHOLE
|
||||
* registry when any single row fails to identify a repo, so one malformed
|
||||
* row renders every member of the group unresolvable, including members
|
||||
* whose own rows are fine. That is the honest verdict — a registry the
|
||||
* resolver cannot trust row-wise cannot be trusted about any row — and it
|
||||
* is reported as an unresolved state, never as a clean one.
|
||||
*
|
||||
* ENOENT is not a failure in either mode: no registry file genuinely means
|
||||
* nothing has been registered yet, so every repo is legitimately missing.
|
||||
*/
|
||||
let registryEntries: RegistryEntry[] | null = null;
|
||||
let registryReadError: string | null = null;
|
||||
try {
|
||||
registryEntries = await readRegistryStrict();
|
||||
} catch (err) {
|
||||
registryReadError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
/**
|
||||
* Unchanged meaning: this repo has no usable status. It stays `true`
|
||||
* for BOTH failures below, so a consumer written before the split
|
||||
* still sees every unusable repo flagged. Reporting an unresolvable
|
||||
* repo as `missing: false` would hand that consumer `indexStale:
|
||||
* false` for a repo nothing was ever read from — a false all-clear.
|
||||
*/
|
||||
missing: boolean;
|
||||
/**
|
||||
* Which failure `missing` means: `false` is a genuine registry miss,
|
||||
* `true` is an entry the resolver could not turn into a repo. Additive
|
||||
* — always present on every row, so an agent can branch on it without
|
||||
* having to treat an absent key as either answer.
|
||||
*/
|
||||
unresolvable: boolean;
|
||||
/** Set only when `unresolvable`; says what could not be resolved. */
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
if (registryEntries === null) {
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: true,
|
||||
unresolvableReason: `the global registry could not be read: ${registryReadError}`,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Only `resolveRepo` is inside the try that produces the
|
||||
// "did not resolve" label, so the label is earned rather than assumed.
|
||||
// `loadMeta` and `checkStaleness` cannot throw — the first returns null on
|
||||
// every error, the second catches everything — but the reading below them
|
||||
// can, and did: `registry.repoSnapshots` is read off a bare
|
||||
// `JSON.parse(...) as ContractRegistry` with no shape check, so a
|
||||
// contracts.json missing that field threw a TypeError into this catch and
|
||||
// reported every repo as an unresolvable GLOBAL-registry entry. That sent
|
||||
// the operator to repair the wrong file. The optional chain below closes
|
||||
// the crash; this split stops the next one being mislabelled the same way.
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
|
||||
|
|
@ -593,7 +755,7 @@ export class GroupService {
|
|||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
const snapshot = registry?.repoSnapshots[repoPath];
|
||||
const snapshot = registry?.repoSnapshots?.[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
|
||||
|
|
@ -601,17 +763,45 @@ export class GroupService {
|
|||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
unresolvable: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
} catch (err) {
|
||||
// The registry read succeeded, so its answer about this row is
|
||||
// trustworthy: a row that is there and still would not resolve is a
|
||||
// different fact from a row that was never there, and the operator's
|
||||
// next move differs (repair the entry vs. index the repo).
|
||||
const known = registryIdentifies(registryEntries, registryName);
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: known,
|
||||
...(known
|
||||
? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry?.generatedAt || null,
|
||||
missingRepos: registry?.missingRepos || [],
|
||||
// `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`,
|
||||
// so both of these are whatever the file happened to hold — the
|
||||
// validation in `loadContractRegistryResilient` never runs on this path.
|
||||
// A `contracts.json` carrying a string here reached `cli/group.ts` and
|
||||
// died in `.join(', ')`, i.e. an unreadable registry crashing the command
|
||||
// whose job is to explain unreadable things.
|
||||
//
|
||||
// `missingRepos` has always been required, so there is no "not recorded"
|
||||
// state to preserve for it — an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(registry?.missingRepos) ?? [],
|
||||
// `unreadableRepos` does have one: absent means "not recorded", not
|
||||
// "none" (see ContractRegistry), and a value we could not read is equally
|
||||
// unrecorded. Reporting either as an empty list is the same conflation.
|
||||
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
|
||||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as os from 'node:os';
|
|||
import type { ContractRegistry } from './types.js';
|
||||
import { writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
export const CONTRACTS_FILE = 'contracts.json';
|
||||
|
||||
export function getDefaultGitnexusDir(): string {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
|
|
@ -30,6 +30,11 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
|||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
|
||||
/** The registry path, so callers that stat or watch the file do not respell its name. */
|
||||
export function getContractRegistryPath(groupDir: string): string {
|
||||
return path.join(groupDir, CONTRACTS_FILE);
|
||||
}
|
||||
|
||||
export async function writeContractRegistry(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -100,7 +100,20 @@ export interface ContractRegistry {
|
|||
version: number;
|
||||
generatedAt: string;
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
/** Configured repos with no entry in the registry. */
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos that ARE registered but that this sync could not extract
|
||||
* from — the index would not open (version skew, lock, corruption), or an
|
||||
* extractor threw partway through. The two are one bucket because the
|
||||
* consequence is one thing: NONE of that repo's contracts are in this
|
||||
* registry. Distinct from `missingRepos`, which is "no entry in the
|
||||
* registry at all" and needs a different answer from the operator.
|
||||
*
|
||||
* Optional so a registry written before this field existed still parses —
|
||||
* absent means "not recorded", not "none".
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
}
|
||||
|
|
@ -117,8 +130,24 @@ export interface RepoHandle {
|
|||
storagePath: string;
|
||||
}
|
||||
|
||||
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
|
||||
export type GroupImpactTruncationReason = 'timeout' | 'partial';
|
||||
/**
|
||||
* Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted).
|
||||
*
|
||||
* `'timeout'` and `'partial'` are runtime limits — the same query can succeed on
|
||||
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
|
||||
* sync that could not read every configured repo, so those repos' contracts are
|
||||
* absent from every query against it until `gitnexus group sync` succeeds.
|
||||
*
|
||||
* A runtime array rather than a bare type union: every value here has to be
|
||||
* explained on the agent-facing surface that returns it, and only an enumerable
|
||||
* list lets a guard test assert that. A test that hand-lists the members passes
|
||||
* forever once a fourth is added — which is the exact drift the guard exists to
|
||||
* catch, so the list an agent is promised and the list the code can emit have
|
||||
* to come from the same place.
|
||||
*/
|
||||
export const GROUP_IMPACT_TRUNCATION_REASONS = ['timeout', 'partial', 'incomplete-sync'] as const;
|
||||
|
||||
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
|
||||
|
||||
export interface GroupImpactResult {
|
||||
local: unknown;
|
||||
|
|
@ -222,5 +251,110 @@ export interface BridgeHandle {
|
|||
export interface BridgeMeta {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
/**
|
||||
* Size and mtime of the `bridge.lbug` this metadata was written for, so a
|
||||
* reader can tell whether the two still belong together.
|
||||
*
|
||||
* `writeBridge` replaces the database and writes this file as two operations;
|
||||
* a sync that stops between them leaves the PREVIOUS sync's metadata beside a
|
||||
* new database, and `runGroupImpact` reads completeness from that metadata.
|
||||
* Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch
|
||||
* without anything having to be deleted — deleting the old metadata up front
|
||||
* would lose it permanently on a swap that fails with the old database still
|
||||
* in place, which is a normal Windows outcome when a read-only handle is held.
|
||||
*
|
||||
* Optional: metadata written before this existed carries no stamp. Such a
|
||||
* file is not waved through — `bridgeMetaMatchesFile` falls back to comparing
|
||||
* the two files' modification times, since a successful write orders the
|
||||
* database rename before the metadata write and a database NEWER than the
|
||||
* metadata beside it therefore cannot be the one it describes.
|
||||
*
|
||||
* That fallback proves WRITE ORDER, not provenance, and is wrong in both
|
||||
* directions — a non-monotonic clock can make a mis-paired set read as
|
||||
* ordered, and any copy or restore that rewrites the database's times after
|
||||
* the metadata's demotes an intact legacy pair to a lower bound until the
|
||||
* next sync re-stamps it. A stamped pair never reaches that fallback, which
|
||||
* is the reason to prefer stamping over widening the heuristic. Both
|
||||
* directions are spelled out at `bridgeMetaMatchesFile`.
|
||||
*/
|
||||
bridgeSize?: number;
|
||||
bridgeMtimeMs?: number;
|
||||
/**
|
||||
* Reader-side only: true when `meta.json` parsed but one of its repo lists
|
||||
* held a value that was not a list of repo paths.
|
||||
*
|
||||
* NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the
|
||||
* file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot
|
||||
* round-trip back to disk. It lives on this interface rather than on a
|
||||
* reader-only subtype so that `readBridgeMeta` keeps the exact signature
|
||||
* every caller already compiles against.
|
||||
*
|
||||
* The unusable value is dropped rather than normalized, so `missingRepos: []`
|
||||
* on such a result is inert filler — this flag, not the empty list, is what
|
||||
* says the bridge's provenance is unknown.
|
||||
*/
|
||||
repoListsUnreadable?: boolean;
|
||||
/**
|
||||
* Reader-side only: did this metadata pair with the `bridge.lbug` beside it,
|
||||
* measured BEFORE anything opened that database?
|
||||
*
|
||||
* NEVER PERSISTED, for the same reason as `repoListsUnreadable`.
|
||||
*
|
||||
* The measurement has to happen before the open, and the answer has to be
|
||||
* carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open
|
||||
* the bridge and only then ask about provenance, so a platform where a
|
||||
* read-only open advances the database's mtime would fail every unstamped
|
||||
* pair the moment it was read — turning back-compat for pre-stamp bridges
|
||||
* into a repo-wide "everything is a lower bound". Whether any given
|
||||
* LadybugDB build and OS does that is not something a reader should have to
|
||||
* know, and it cannot be observed on Windows, where the in-process
|
||||
* write→read reopen this would need is a documented limitation. Ordering the
|
||||
* check ahead of the open makes the question moot on every platform instead
|
||||
* of true on the ones that happen to be testable.
|
||||
*/
|
||||
pairedWithDatabase?: boolean;
|
||||
/**
|
||||
* PERSISTED, unlike the two fields above: the writer of this metadata could
|
||||
* not establish that it describes the `bridge.lbug` beside it, and no reader
|
||||
* may conclude otherwise from the files alone.
|
||||
*
|
||||
* Written by `refreshPreservedBridgeMeta` — the preserve path in `syncGroup`,
|
||||
* which refreshes the diagnostic lists of a bridge it deliberately does NOT
|
||||
* rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime
|
||||
* becomes now while the database's stays old; and "metadata newer than the
|
||||
* database beside it" is exactly the write order that
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the
|
||||
* old fields forward would therefore convert a pair that check had been
|
||||
* REJECTING into one it waves through — laundering unknown provenance into
|
||||
* verified provenance, which is the fail-open this whole channel exists to
|
||||
* close.
|
||||
*
|
||||
* "Just don't write a stamp" is not a substitute, and is worse: an unstamped
|
||||
* metadata file is judged on the two file times, and the refresh has already
|
||||
* moved them into the accepting order. The verdict has to be recorded IN the
|
||||
* file, because the write that records it is itself what destroys the
|
||||
* evidence a reader would otherwise use.
|
||||
*
|
||||
* `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the
|
||||
* write-order heuristic, so `ensureBridgeReady` answers
|
||||
* `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the
|
||||
* cross-repo answer as a lower bound. That is the ONE enforcement point; do
|
||||
* not add a second reader for this field.
|
||||
*
|
||||
* Self-clearing: a successful `writeBridge` builds fresh metadata from a
|
||||
* literal and never sets it, so the next good sync retires the marker without
|
||||
* anything having to delete it.
|
||||
*/
|
||||
provenanceUnknown?: boolean;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos the sync that produced this bridge could not extract from
|
||||
* (see `ContractRegistry.unreadableRepos`). Their contracts and every
|
||||
* cross-link touching them are absent from `bridge.lbug`, so a cross-repo
|
||||
* impact query against this bridge is a lower bound, not a verdict —
|
||||
* `runGroupImpact` folds a non-empty value into its truncation fields for
|
||||
* exactly that reason.
|
||||
* Optional: a bridge written before this field existed does not record it.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,27 @@ export interface FilePath {
|
|||
const READ_CONCURRENCY = 32;
|
||||
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
|
||||
|
||||
const DECLARATION_COMPANION_SUFFIXES = [
|
||||
{ declaration: '.d.ts', implementations: ['.ts', '.tsx'] },
|
||||
{ declaration: '.d.mts', implementations: ['.mts'] },
|
||||
{ declaration: '.d.cts', implementations: ['.cts'] },
|
||||
] as const;
|
||||
|
||||
const hasImplementationSibling = (
|
||||
declarationPath: string,
|
||||
scannedPaths: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) =>
|
||||
declarationPath.endsWith(declaration),
|
||||
);
|
||||
if (!companion) return false;
|
||||
|
||||
// Keep standalone declarations. Only suppress declaration output that sits
|
||||
// beside an implementation with the corresponding module suffix.
|
||||
const stem = declarationPath.slice(0, -companion.declaration.length);
|
||||
return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`));
|
||||
};
|
||||
|
||||
const warnLargeFileSkip = (message: string): void => {
|
||||
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
|
||||
// analyze.ts routes console.warn through the progress bar logger while
|
||||
|
|
@ -84,10 +105,17 @@ export const walkRepositoryPaths = async (
|
|||
}
|
||||
}
|
||||
|
||||
const scannedPaths = new Set(entries.map((entry) => entry.path));
|
||||
const deduplicatedEntries = entries.filter(
|
||||
(entry) => !hasImplementationSibling(entry.path, scannedPaths),
|
||||
);
|
||||
|
||||
// Filesystem/glob traversal order is not stable across filesystems or repeated
|
||||
// scans. Canonicalize once at the scan boundary so every downstream phase sees
|
||||
// the same repository order.
|
||||
entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
deduplicatedEntries.sort((left, right) =>
|
||||
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||
);
|
||||
|
||||
if (skippedLarge > 0) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
|
|
@ -123,7 +151,7 @@ export const walkRepositoryPaths = async (
|
|||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
return deduplicatedEntries;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js';
|
||||
import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { resolveFile } from '../languages/typescript/file-candidates.js';
|
||||
|
||||
|
|
@ -361,9 +361,10 @@ export async function loadNodeWorkspacePackages(
|
|||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (isHardcodedIgnoredDirectory(entry.name)) continue;
|
||||
const childDir = path.join(dir, entry.name);
|
||||
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue;
|
||||
if (depth < SCAN_MAX_DEPTH) {
|
||||
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
||||
queue.push({ dir: childDir, depth: depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,44 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
|||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
export type CaptureMap = Record<string, SyntaxNode | undefined>;
|
||||
|
||||
export interface DefinitionPropertiesContext {
|
||||
readonly nodeLabel: NodeLabel;
|
||||
readonly nodeName: string;
|
||||
readonly definitionNode: SyntaxNode;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
readonly isExported: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionPropertiesExtractor = (
|
||||
context: DefinitionPropertiesContext,
|
||||
) => Readonly<Record<string, unknown>> | undefined;
|
||||
|
||||
/** Run optional provider enrichment without allowing one hook failure to drop
|
||||
* the rest of the worker's language batch. */
|
||||
export function runDefinitionPropertiesExtractor(
|
||||
extractor: DefinitionPropertiesExtractor,
|
||||
context: DefinitionPropertiesContext,
|
||||
onError: (error: unknown) => void,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
try {
|
||||
return extractor(context);
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider metadata is additive; graph identity and source-location fields
|
||||
* supplied by the worker remain authoritative. */
|
||||
export function mergeCanonicalDefinitionProperties<
|
||||
TCanonical extends Readonly<Record<string, unknown>>,
|
||||
>(
|
||||
providerProperties: Readonly<Record<string, unknown>>,
|
||||
canonicalProperties: TCanonical,
|
||||
): Record<string, unknown> & TCanonical {
|
||||
return { ...providerProperties, ...canonicalProperties } as Record<string, unknown> & TCanonical;
|
||||
}
|
||||
|
||||
// ── Strategy tag types ─────────────────────────────────────────────────────
|
||||
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
|
||||
// so `core/ingestion/model/resolve.ts` can consume it without importing from
|
||||
|
|
@ -276,6 +314,10 @@ interface LanguageProviderConfig {
|
|||
* constant, and static declarations. Produces VariableInfo with type, visibility,
|
||||
* isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */
|
||||
readonly variableExtractor?: VariableExtractor;
|
||||
/** Add language-owned, structured properties to a definition node. Values
|
||||
* cross the worker boundary and must therefore be structured-clone-safe.
|
||||
* Shared ingestion code treats these properties as opaque. */
|
||||
readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor;
|
||||
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
|
||||
* Uses the same provider-driven strategy pattern as method/field extraction so
|
||||
* namespace/package/module rules stay language-specific. */
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ import {
|
|||
} from './javascript/index.js';
|
||||
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
|
||||
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
...extractDispatchGuardRoutes(...args),
|
||||
|
|
@ -418,6 +419,7 @@ export const typescriptProvider = defineLanguage({
|
|||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(typescriptVariableConfig),
|
||||
definitionPropertiesExtractor: extractConvexEndpointProperties,
|
||||
classExtractor: createClassExtractor(typescriptClassConfig),
|
||||
// ── JSDoc → description (issue #2270). An exported decl is captured as the
|
||||
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──
|
||||
|
|
@ -505,6 +507,7 @@ export const javascriptProvider = defineLanguage({
|
|||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(javascriptVariableConfig),
|
||||
definitionPropertiesExtractor: extractConvexEndpointProperties,
|
||||
classExtractor: createClassExtractor(javascriptClassConfig),
|
||||
// ── JSDoc → description (issue #2270). An exported decl is captured as the
|
||||
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { DefinitionPropertiesContext } from '../../language-provider.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { assertCloneable } from '../../workers/clone-safety.js';
|
||||
|
||||
const GENERATED_ENDPOINT_FACTORIES: ReadonlySet<string> = new Set([
|
||||
'query',
|
||||
'mutation',
|
||||
'action',
|
||||
'internalQuery',
|
||||
'internalMutation',
|
||||
'internalAction',
|
||||
'httpAction',
|
||||
]);
|
||||
|
||||
const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap<string, string> = new Map(
|
||||
[...GENERATED_ENDPOINT_FACTORIES].map((factory) => [`${factory}Generic`, factory]),
|
||||
);
|
||||
|
||||
const normalizeModuleTarget = (targetRaw: string): string =>
|
||||
targetRaw.replace(/\\/g, '/').replace(/\.(?:[cm]?[jt]s)$/, '');
|
||||
|
||||
const isGeneratedServerModule = (targetRaw: string): boolean =>
|
||||
/(?:^|\/)_generated\/server$/.test(normalizeModuleTarget(targetRaw));
|
||||
|
||||
function importedConvexFactory(
|
||||
imports: readonly ParsedImport[],
|
||||
localName: string,
|
||||
): string | undefined {
|
||||
for (const parsedImport of imports) {
|
||||
if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue;
|
||||
if (parsedImport.localName !== localName) continue;
|
||||
|
||||
const target = normalizeModuleTarget(parsedImport.targetRaw);
|
||||
if (target === 'convex/server') {
|
||||
return GENERIC_ENDPOINT_FACTORIES.get(parsedImport.importedName);
|
||||
}
|
||||
if (isGeneratedServerModule(target)) {
|
||||
return GENERATED_ENDPOINT_FACTORIES.has(parsedImport.importedName)
|
||||
? parsedImport.importedName
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchingDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
|
||||
if (node.type === 'variable_declarator' && node.childForFieldName('name')?.text === nodeName) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.type === 'export_statement') {
|
||||
const declaration = node.childForFieldName('declaration');
|
||||
return declaration ? matchingDeclarator(declaration, nodeName) : undefined;
|
||||
}
|
||||
if (node.type !== 'lexical_declaration' && node.type !== 'variable_declaration') {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (
|
||||
child?.type === 'variable_declarator' &&
|
||||
child.childForFieldName('name')?.text === nodeName
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current) {
|
||||
const declarator = matchingDeclarator(current, nodeName);
|
||||
if (declarator) return declarator;
|
||||
if (current.type === 'program' || current.type === 'statement_block') break;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp Convex runtime-dispatch metadata only when both the declaration shape
|
||||
* and the factory import provenance are known. The MCP layer consumes the
|
||||
* resulting property without reparsing lossy FTS text.
|
||||
*/
|
||||
export function extractConvexEndpointProperties(
|
||||
context: DefinitionPropertiesContext,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
if ((context.nodeLabel !== 'Const' && context.nodeLabel !== 'Function') || !context.isExported) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const declarator = findDeclarator(context.definitionNode, context.nodeName);
|
||||
const value = declarator?.childForFieldName('value');
|
||||
if (!value || value.type !== 'call_expression') return undefined;
|
||||
|
||||
const callee = value.childForFieldName('function');
|
||||
if (!callee || callee.type !== 'identifier') return undefined;
|
||||
const factory = importedConvexFactory(context.parsedImports, callee.text);
|
||||
if (factory === undefined) return undefined;
|
||||
|
||||
const args = value.childForFieldName('arguments');
|
||||
if (!args || args.namedChildCount !== 1) return undefined;
|
||||
const endpointDefinition = args.namedChild(0);
|
||||
if (
|
||||
!endpointDefinition ||
|
||||
!['object', 'arrow_function', 'function_expression'].includes(endpointDefinition.type)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return assertCloneable({ convexEndpointFactory: factory });
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js';
|
||||
import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
/** One `paths` entry, pattern and targets kept in declaration order. */
|
||||
|
|
@ -291,9 +291,9 @@ async function findTsconfigFiles(repoRoot: string): Promise<string[]> {
|
|||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (isHardcodedIgnoredDirectory(entry.name)) continue;
|
||||
if (depth < SCAN_MAX_DEPTH)
|
||||
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
||||
const childDir = path.join(dir, entry.name);
|
||||
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue;
|
||||
if (depth < SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 });
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ export function pickUniqueGlobalCallable(
|
|||
// because the list would then depend on the caller's scope, not just its file.
|
||||
const cacheKey =
|
||||
scopeDefsCache !== undefined && isCallerVisible === undefined
|
||||
? `${name} | ||||