@nazty_labs/common-ground 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/SETUP.md +219 -0
- package/dist/access.d.ts +172 -0
- package/dist/access.js +175 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +198 -0
- package/dist/commands.d.ts +189 -0
- package/dist/commands.js +202 -0
- package/dist/discovery.d.ts +73 -0
- package/dist/discovery.js +417 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +22 -0
- package/dist/export.d.ts +14 -0
- package/dist/export.js +86 -0
- package/dist/guidance.d.ts +11 -0
- package/dist/guidance.js +75 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +141 -0
- package/dist/init.d.ts +304 -0
- package/dist/init.js +150 -0
- package/dist/maintenance.d.ts +126 -0
- package/dist/maintenance.js +23 -0
- package/dist/matching.d.ts +17 -0
- package/dist/matching.js +32 -0
- package/dist/model.d.ts +974 -0
- package/dist/model.js +21 -0
- package/dist/navigation.d.ts +164 -0
- package/dist/navigation.js +164 -0
- package/dist/operations.d.ts +10 -0
- package/dist/operations.js +130 -0
- package/dist/paging.d.ts +5 -0
- package/dist/paging.js +32 -0
- package/dist/retrieval.d.ts +146 -0
- package/dist/retrieval.js +150 -0
- package/dist/review-files.d.ts +3 -0
- package/dist/review-files.js +106 -0
- package/dist/review.d.ts +86 -0
- package/dist/review.js +124 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +105 -0
- package/dist/source-search.d.ts +63 -0
- package/dist/source-search.js +245 -0
- package/dist/store.d.ts +452 -0
- package/dist/store.js +718 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/workflow.d.ts +450 -0
- package/dist/workflow.js +317 -0
- package/docs/architecture.md +65 -0
- package/docs/audit-0.4.0.md +42 -0
- package/docs/demo.md +42 -0
- package/docs/discovery.md +70 -0
- package/docs/knowledge-policy.md +51 -0
- package/docs/pillar-contract.md +98 -0
- package/docs/quiet-workflow.md +98 -0
- package/docs/releases.md +157 -0
- package/package.json +52 -0
- package/schemas/admission.schema.json +75 -0
- package/schemas/knowledge.schema.json +192 -0
- package/schemas/patch.schema.json +220 -0
- package/schemas/update.schema.json +218 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { operations, runOperation } from './operations.js';
|
|
2
|
+
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
|
|
3
|
+
import { ReadKnowledge, readKnowledge, listPillars, listChapters, readChapter, readFact, search, reviewChecklist } from './retrieval.js';
|
|
4
|
+
export { ReadKnowledge, readKnowledge, listPillars, listChapters, readChapter, readFact, search, reviewChecklist } from './retrieval.js';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Update, Fact, chapterKey, relativePath } from './model.js';
|
|
9
|
+
import { Workflow, Patch } from './workflow.js';
|
|
10
|
+
import { page } from './paging.js';
|
|
11
|
+
export { page } from './paging.js';
|
|
12
|
+
import { ownershipMap, pillarGraph, startHere, tidyPlan } from './navigation.js';
|
|
13
|
+
import { version } from './version.js';
|
|
14
|
+
import { GroundError, errorPayload } from './errors.js';
|
|
15
|
+
const result = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
16
|
+
export function createFullServer(store) {
|
|
17
|
+
const server = new McpServer({ name: 'common-ground', version });
|
|
18
|
+
const paging = { cursor: z.string().optional(), limit: z.number().int().min(1).max(20).optional() };
|
|
19
|
+
const register = (name, description, inputSchema, readOnly, fn) => server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: name === 'commit_update', openWorldHint: false } }, async (args) => { try {
|
|
20
|
+
return result(await fn(args));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return { ...result({ error: e.message }), isError: true };
|
|
24
|
+
} });
|
|
25
|
+
const routing = { path: relativePath.optional(), signal: z.string().trim().min(1).optional(), ...paging };
|
|
26
|
+
register('start_here', 'Start with a failing command, symptom, or source path. Returns an agent workflow and bounded ownership candidates; it does not diagnose bugs.', routing, true, args => startHere(store, args));
|
|
27
|
+
register('ownership_map', 'Map registered paths or keyword signals to owning pillars and chapters. Signal matches are hints; verify code.', routing, true, args => ownershipMap(store, args));
|
|
28
|
+
register('pillar_graph', 'Read the recorded pillar dependency graph derived from fact references. Direction is dependent to dependency; missing edges are unknown.', { pillarId: z.string().optional(), ...paging }, true, ({ pillarId, cursor, limit }) => pillarGraph(store, pillarId, cursor, limit));
|
|
29
|
+
register('tidy_plan', 'Preview cleanup scope for all knowledge, a pillar, chapter, or fact. No changes or approval are made; the calling agent must verify source and documentation.', { target: z.string().min(1), ...paging }, true, ({ target, cursor, limit }) => tidyPlan(store, target, cursor, limit));
|
|
30
|
+
register('review_checklist', 'List current source and documentation files to open before review, including no-op outcomes. Attest only after reading them this session; deleted files require a live absence check.', { chapterIds: z.array(chapterKey), touchedPaths: z.array(relativePath), ...paging }, true, ({ chapterIds, touchedPaths, cursor, limit }) => reviewChecklist(store, chapterIds, touchedPaths, cursor, limit));
|
|
31
|
+
register('list_pillars', 'List repository responsibility boundaries without loading facts.', paging, true, ({ cursor, limit }) => listPillars(store, cursor, limit));
|
|
32
|
+
register('list_chapters', 'Read a pillar chapter index; skim scopes to select the relevant chapter. Returns no facts.', { pillarId: z.string(), ...paging }, true, ({ pillarId, cursor, limit }) => listChapters(store, pillarId, cursor, limit));
|
|
33
|
+
register('read_chapter', 'Read one chapter in bounded fact pages. Follow nextCursor to review every fact before an update. Use read_fact for evidence.', { chapterId: chapterKey, ...paging }, true, ({ chapterId, cursor, limit }) => readChapter(store, chapterId, cursor, limit));
|
|
34
|
+
register('read_fact', 'Fetch the exact evidence for one fact, with chapter freshness.', { chapterId: chapterKey, factId: z.string() }, true, ({ chapterId, factId }) => readFact(store, chapterId, factId));
|
|
35
|
+
register('search_knowledge', 'Find relevant fact summaries; optional chapter scope. Fetch evidence with read_fact.', { query: z.string().min(1), limit: paging.limit, chapterId: chapterKey.optional() }, true, ({ query, limit, chapterId }) => search(store, query, limit, chapterId));
|
|
36
|
+
register('review_plan', 'List required chapter reviews, by tracing fact dependencies and dependents. Select factIds to narrow the impact graph. It does not authorize rewriting valid facts.', { chapterId: chapterKey, factIds: Update.shape.factIds }, true, ({ chapterId, factIds }) => store.reviewPlan(chapterId, factIds));
|
|
37
|
+
register('prepare_update', 'After authorized work: submit complete reviews for the chapter and every linked chapter from review_plan. Correct invalidated facts in place; explicitly reasoned maintenance may merge, remove, or tighten existing facts. Read source and documentation this session and supply verification. Uncertainty requires developer input.', Update.shape, false, args => store.prepare(args));
|
|
38
|
+
register('commit_update', 'Atomically publish changed chapters in a prepared review transaction. Rechecks evidence, source snapshots, and registry revision.', { proposalId: z.string() }, false, ({ proposalId }) => store.commit(proposalId));
|
|
39
|
+
registerOperations(server, store);
|
|
40
|
+
return server;
|
|
41
|
+
}
|
|
42
|
+
export async function serve(store, profile = 'compact') { await createServer(store, profile).connect(new StdioServerTransport()); }
|
|
43
|
+
export function createServer(store, profile = 'compact') {
|
|
44
|
+
if (profile === 'full')
|
|
45
|
+
return createFullServer(store);
|
|
46
|
+
if (profile !== 'compact')
|
|
47
|
+
throw new Error('Unknown MCP profile; use compact or full.');
|
|
48
|
+
const server = new McpServer({ name: 'common-ground', version }), workflow = new Workflow(store);
|
|
49
|
+
const register = (name, description, inputSchema, readOnly, fn) => server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: name === 'commit_update', openWorldHint: false } }, async (args) => { try {
|
|
50
|
+
return result(await fn(args));
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return { ...result({ error: e.message }), isError: true };
|
|
54
|
+
} });
|
|
55
|
+
register('task_context', 'Optional context for deferred additions and aggregate reporting. For routine work use cground lookup and assess without a task. If started, assess touched paths and finish once. Without a taskId, follow returned setup guidance and continue from source. Local state only.', {
|
|
56
|
+
action: z.enum(['start', 'assess', 'finish']), taskId: z.string().uuid().optional(), paths: z.array(relativePath).optional(), signal: z.string().optional(), refresh: z.boolean().optional(), ...paging,
|
|
57
|
+
}, false, async ({ action, taskId, paths, signal, cursor, limit, refresh }) => {
|
|
58
|
+
if (action === 'start')
|
|
59
|
+
return workflow.start(paths, signal);
|
|
60
|
+
if (!taskId)
|
|
61
|
+
throw new Error('taskId is required.');
|
|
62
|
+
if (action === 'finish')
|
|
63
|
+
return workflow.finish(taskId, cursor, limit);
|
|
64
|
+
if (!paths)
|
|
65
|
+
throw new Error('Supply actual task-touched paths (including new/deleted files).');
|
|
66
|
+
return workflow.assess(taskId, paths, cursor, limit, refresh);
|
|
67
|
+
});
|
|
68
|
+
register('read_knowledge', 'Read bounded knowledge. target is a pillar/chapter/fact ID. chapter + evidence:true batches full facts; read every page before editing. refresh:true resends cached context.', ReadKnowledge.shape, false, args => readKnowledge(store, args));
|
|
69
|
+
register('prepare_patch', 'Quiet existing-fact maintenance only; taskId is optional. Read POLICY.md, all required chapter pages and source first. reviewedAllFacts attests the whole revision. Send only changed records; unchanged records are preserved.', Patch.shape, false, args => workflow.prepare(args));
|
|
70
|
+
register('commit_update', 'Write a prepared correction to the Git working tree after rechecking revisions, source and documentation. Report at task completion.', { proposalId: z.string().uuid() }, false, ({ proposalId }) => store.commit(proposalId));
|
|
71
|
+
register('propose_facts', 'Queue verified new facts locally; no shared write. Batch for developer approval after task_context finish. Dependencies must already be admitted.', { taskId: z.string().uuid(), chapterId: chapterKey, facts: z.array(Fact).min(1) }, false, ({ taskId, chapterId, facts }) => workflow.propose(taskId, chapterId, facts));
|
|
72
|
+
registerOperations(server, store);
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
const paging = { cursor: z.string().optional(), limit: z.number().int().min(1).max(20).optional() };
|
|
76
|
+
export function registerOperations(server, store) {
|
|
77
|
+
const catalog = operations(store);
|
|
78
|
+
server.registerTool('cground', {
|
|
79
|
+
description: 'All Common Ground CLI workflows via structured MCP, no shell needed. Call operation:help for a paginated catalog or help with args.operation for its exact input schema. Use lookup for cheap stateless facts/source locations and assess for task-touched changes; neither requires start/finish. Supports check/validate/tidy, onboarding, approved admission, task workflows, hooks and export. Host approval settings apply. approved:true declares actual developer approval, never grants it. Cleanup verifies existing facts only; the calling agent reads source and submits reviewed corrections.',
|
|
80
|
+
inputSchema: { operation: z.enum(['help', ...Object.keys(catalog)]), args: z.record(z.unknown()).default({}) },
|
|
81
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
82
|
+
}, async ({ operation, args }) => {
|
|
83
|
+
try {
|
|
84
|
+
let value;
|
|
85
|
+
if (operation === 'help') {
|
|
86
|
+
const request = z.object({ operation: z.string().optional(), ...paging }).strict().parse(args);
|
|
87
|
+
if (request.operation) {
|
|
88
|
+
const entry = catalog[request.operation];
|
|
89
|
+
if (!entry)
|
|
90
|
+
throw new Error('Unknown operation.');
|
|
91
|
+
value = { operation: request.operation, description: entry.description, inputSchema: toJsonSchemaCompat(entry.schema) };
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
value = { ...page(Object.entries(catalog).map(([operation, entry]) => ({ operation, description: entry.description })), request.cursor, request.limit), transport: 'serve is the process entry point, not a nested operation. Repository root is fixed by the host configuration.' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
value = await runOperation(store, operation, args);
|
|
99
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.message, ...(e instanceof GroundError && e.code === 'INIT_INCOMPLETE' ? { diagnostic: errorPayload(e) } : {}) }) }], isError: true };
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
export declare const SourceSearch: z.ZodObject<{
|
|
4
|
+
query: z.ZodString;
|
|
5
|
+
paths: z.ZodArray<z.ZodPipeline<z.ZodEffects<z.ZodString, string, string>, z.ZodEffects<z.ZodString, string, string>>, "many">;
|
|
6
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
}, "strict", z.ZodTypeAny, {
|
|
8
|
+
paths: string[];
|
|
9
|
+
query: string;
|
|
10
|
+
limit: number;
|
|
11
|
+
}, {
|
|
12
|
+
paths: string[];
|
|
13
|
+
query: string;
|
|
14
|
+
limit?: number | undefined;
|
|
15
|
+
}>;
|
|
16
|
+
/** Pure eligibility check for suggestions; actual searches additionally reject symlinks. */
|
|
17
|
+
export declare function isSourceSearchPathAllowed(file: string): boolean;
|
|
18
|
+
/** Read-only lexical source evidence, intentionally separate from stored-knowledge lookup. */
|
|
19
|
+
export declare function sourceSearch(store: Store, input: unknown): Promise<{
|
|
20
|
+
kind: string;
|
|
21
|
+
state: string;
|
|
22
|
+
writesKnowledge: boolean;
|
|
23
|
+
query: string;
|
|
24
|
+
paths: string[];
|
|
25
|
+
items: {
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
excerpt: string;
|
|
29
|
+
matchedTerms: string[];
|
|
30
|
+
relevance: string;
|
|
31
|
+
}[];
|
|
32
|
+
total: number;
|
|
33
|
+
resultLimit: number;
|
|
34
|
+
resultsTruncated: boolean;
|
|
35
|
+
downweightedTerms: string[];
|
|
36
|
+
suppressedSharedTermMatches: number;
|
|
37
|
+
scan: {
|
|
38
|
+
truncated: boolean;
|
|
39
|
+
reachedLimits: string[];
|
|
40
|
+
limits: {
|
|
41
|
+
entries: number;
|
|
42
|
+
files: number;
|
|
43
|
+
fileBytes: number;
|
|
44
|
+
totalBytes: number;
|
|
45
|
+
excerptChars: number;
|
|
46
|
+
};
|
|
47
|
+
entries: number;
|
|
48
|
+
files: number;
|
|
49
|
+
bytes: number;
|
|
50
|
+
textFiles: number;
|
|
51
|
+
skipped: {
|
|
52
|
+
excluded: number;
|
|
53
|
+
symlinks: number;
|
|
54
|
+
binary: number;
|
|
55
|
+
oversize: number;
|
|
56
|
+
unreadable: number;
|
|
57
|
+
changed: number;
|
|
58
|
+
nonRegular: number;
|
|
59
|
+
missing: number;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
basis: string;
|
|
63
|
+
}>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { GroundError } from './errors.js';
|
|
5
|
+
import { relativePath } from './model.js';
|
|
6
|
+
import { isVersion, queryTerms, termMatch } from './matching.js';
|
|
7
|
+
export const SourceSearch = z.object({
|
|
8
|
+
query: z.string().trim().min(1).max(500),
|
|
9
|
+
paths: z.array(relativePath).min(1).max(20).describe('Explicit source files or directories to search; repository-wide and excluded paths are not supported.'),
|
|
10
|
+
limit: z.number().int().min(1).max(5).default(5),
|
|
11
|
+
}).strict();
|
|
12
|
+
const limits = { entries: 2000, files: 200, fileBytes: 1024 * 1024, totalBytes: 4 * 1024 * 1024, excerptChars: 300 };
|
|
13
|
+
const excluded = new Set(['node_modules', 'dist', 'build', 'target', 'out', 'coverage', 'generated', 'vendor', 'vendored', 'bundled', 'dep', 'deps', 'dependencies', 'third_party', 'third-party', 'thirdparty', 'external', 'extern', 'venv', '__pycache__', 'pods', 'carthage', 'deriveddata', 'bin', 'obj']);
|
|
14
|
+
const generated = /^(?:package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|Gemfile\.lock|composer\.lock)$|(?:\.min\.(?:js|css)|\.map|\.generated\.[^.]+)$/i;
|
|
15
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
16
|
+
const within = (file, scope) => file === scope || file.startsWith(`${scope}/`);
|
|
17
|
+
const excludedPath = (file) => file.split('/').some(part => (part.startsWith('.') && part !== '.github') || excluded.has(part.toLowerCase()) || generated.test(part));
|
|
18
|
+
/** Pure eligibility check for suggestions; actual searches additionally reject symlinks. */
|
|
19
|
+
export function isSourceSearchPathAllowed(file) {
|
|
20
|
+
const parsed = relativePath.safeParse(file);
|
|
21
|
+
return parsed.success && !excludedPath(parsed.data);
|
|
22
|
+
}
|
|
23
|
+
function matchOffset(text, terms) {
|
|
24
|
+
let offset = 0;
|
|
25
|
+
// Keep original offsets while applying the same camel boundaries and technical
|
|
26
|
+
// token grammar as words(). Check each token before locating a substring: 1.40
|
|
27
|
+
// must not anchor 1.4, and "special" must not anchor the short token "ci".
|
|
28
|
+
for (const segment of text.split(/(?<=[\p{Ll}\p{N}])(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})/gu)) {
|
|
29
|
+
for (const token of segment.matchAll(/v?\d+(?:\.\d+)+(?:[-+][\p{L}\p{N}]+(?:[.-][\p{L}\p{N}]+)*)?|[\p{L}\p{N}]+/giu)) {
|
|
30
|
+
const matched = terms.filter(term => termMatch(token[0], term) > 0);
|
|
31
|
+
if (matched.length) {
|
|
32
|
+
const positions = matched.map(term => token[0].toLowerCase().indexOf(term)).filter(index => index >= 0);
|
|
33
|
+
return offset + token.index + (positions.length ? Math.min(...positions) : 0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
offset += segment.length;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
function excerpt(line, terms) {
|
|
41
|
+
const text = line.replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ' ').trim();
|
|
42
|
+
if (text.length <= limits.excerptChars)
|
|
43
|
+
return text;
|
|
44
|
+
const versions = terms.filter(isVersion);
|
|
45
|
+
const start = Math.max(0, matchOffset(text, versions.length ? versions : terms) - 80);
|
|
46
|
+
return `${start ? '…' : ''}${text.slice(start, start + limits.excerptChars)}${start + limits.excerptChars < text.length ? '…' : ''}`;
|
|
47
|
+
}
|
|
48
|
+
/** Read-only lexical source evidence, intentionally separate from stored-knowledge lookup. */
|
|
49
|
+
export async function sourceSearch(store, input) {
|
|
50
|
+
const args = SourceSearch.parse(input), terms = queryTerms(args.query);
|
|
51
|
+
if (!terms.length || terms.length > 32)
|
|
52
|
+
throw new GroundError('SOURCE_SEARCH_QUERY', 'Use between one and 32 technical search terms.', ['query'], 'Narrow the query to a few source identifiers, concepts or versions.');
|
|
53
|
+
const requested = [...new Set(args.paths)].sort(compare);
|
|
54
|
+
for (const scope of requested) {
|
|
55
|
+
if (excludedPath(scope))
|
|
56
|
+
throw new GroundError('SOURCE_SEARCH_PATH_EXCLUDED', `Excluded source-search path: ${scope}`, ['paths'], 'Select source files or directories outside dependencies, generated output and hidden configuration. .github is supported.');
|
|
57
|
+
await store.safe(scope, true);
|
|
58
|
+
}
|
|
59
|
+
const scopes = requested.filter(scope => !requested.some(other => other !== scope && within(scope, other)));
|
|
60
|
+
const stats = { entries: 0, files: 0, bytes: 0, textFiles: 0, skipped: { excluded: 0, symlinks: 0, binary: 0, oversize: 0, unreadable: 0, changed: 0, nonRegular: 0, missing: 0 } };
|
|
61
|
+
const reached = new Set(), sources = [];
|
|
62
|
+
const stop = () => reached.has('entries') || reached.has('files') || reached.has('totalBytes');
|
|
63
|
+
const read = async (relative) => {
|
|
64
|
+
if (stats.files >= limits.files) {
|
|
65
|
+
reached.add('files');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
stats.files++;
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
const file = await store.safe(relative);
|
|
72
|
+
handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
73
|
+
const before = await handle.stat();
|
|
74
|
+
if (!before.isFile()) {
|
|
75
|
+
stats.skipped.nonRegular++;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (before.size > limits.fileBytes) {
|
|
79
|
+
stats.skipped.oversize++;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (before.size > limits.totalBytes - stats.bytes) {
|
|
83
|
+
reached.add('totalBytes');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const buffer = Buffer.alloc(Math.min(before.size + 1, limits.totalBytes - stats.bytes));
|
|
87
|
+
let size = 0;
|
|
88
|
+
while (size < buffer.length) {
|
|
89
|
+
const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
|
|
90
|
+
if (!bytesRead)
|
|
91
|
+
break;
|
|
92
|
+
size += bytesRead;
|
|
93
|
+
stats.bytes += bytesRead;
|
|
94
|
+
}
|
|
95
|
+
const after = await handle.stat();
|
|
96
|
+
if (before.size !== size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
97
|
+
stats.skipped.changed++;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const bytes = buffer.subarray(0, size);
|
|
101
|
+
let text;
|
|
102
|
+
try {
|
|
103
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
stats.skipped.binary++;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (bytes.includes(0)) {
|
|
110
|
+
stats.skipped.binary++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
stats.textFiles++;
|
|
114
|
+
sources.push({ path: relative, text, matchedTerms: terms.filter(term => termMatch(`${relative}\n${text}`, term) > 0) });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code === 'ELOOP' || String(error.message).startsWith('Symlinks are not supported'))
|
|
118
|
+
stats.skipped.symlinks++;
|
|
119
|
+
else if (error.code === 'ENOENT')
|
|
120
|
+
stats.skipped.missing++;
|
|
121
|
+
else
|
|
122
|
+
stats.skipped.unreadable++;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await handle?.close();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const visit = async (relative, enumerated = false) => {
|
|
129
|
+
if (stop())
|
|
130
|
+
return;
|
|
131
|
+
if (!enumerated) {
|
|
132
|
+
if (stats.entries >= limits.entries) {
|
|
133
|
+
reached.add('entries');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
stats.entries++;
|
|
137
|
+
}
|
|
138
|
+
if (excludedPath(relative)) {
|
|
139
|
+
stats.skipped.excluded++;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let stat;
|
|
143
|
+
try {
|
|
144
|
+
stat = await fs.lstat(path.join(store.root, relative));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.code === 'ENOENT')
|
|
148
|
+
stats.skipped.missing++;
|
|
149
|
+
else
|
|
150
|
+
stats.skipped.unreadable++;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (stat.isSymbolicLink()) {
|
|
154
|
+
stats.skipped.symlinks++;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (stat.isFile()) {
|
|
158
|
+
await read(relative);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!stat.isDirectory()) {
|
|
162
|
+
stats.skipped.nonRegular++;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Buffer only a bounded, complete directory listing before sorting. An overfull
|
|
166
|
+
// directory is not searched in filesystem-dependent enumeration order.
|
|
167
|
+
const children = [];
|
|
168
|
+
try {
|
|
169
|
+
const directory = await fs.opendir(await store.safe(relative));
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
const entry = await directory.read();
|
|
173
|
+
if (!entry)
|
|
174
|
+
break;
|
|
175
|
+
if (stats.entries >= limits.entries) {
|
|
176
|
+
reached.add('entries');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
stats.entries++;
|
|
180
|
+
children.push(`${relative}/${entry.name}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await directory.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (String(error.message).startsWith('Symlinks are not supported'))
|
|
189
|
+
stats.skipped.symlinks++;
|
|
190
|
+
else
|
|
191
|
+
stats.skipped.unreadable++;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const child of children.sort(compare)) {
|
|
195
|
+
await visit(child, true);
|
|
196
|
+
if (stop())
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
for (const scope of scopes) {
|
|
201
|
+
await visit(scope);
|
|
202
|
+
if (stop())
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const frequency = new Map(terms.map(term => [term, sources.filter(source => source.matchedTerms.includes(term)).length]));
|
|
206
|
+
const broad = new Set(terms.filter(term => !isVersion(term) && sources.length >= 3 && (frequency.get(term) ?? 0) / sources.length >= 0.75));
|
|
207
|
+
const weights = new Map(terms.map(term => [term, 1 + Math.log((sources.length + 1) / ((frequency.get(term) ?? 0) + 1))]));
|
|
208
|
+
const direct = [], shared = [];
|
|
209
|
+
let directCount = 0, sharedCount = 0;
|
|
210
|
+
const rank = (a, b) => b.score - a.score || compare(a.path, b.path) || a.line - b.line;
|
|
211
|
+
for (const source of sources) {
|
|
212
|
+
if (!source.matchedTerms.length)
|
|
213
|
+
continue;
|
|
214
|
+
let start = 0, lineNumber = 1;
|
|
215
|
+
while (start < source.text.length) {
|
|
216
|
+
const end = source.text.indexOf('\n', start), line = source.text.slice(start, end < 0 ? undefined : end);
|
|
217
|
+
const matched = terms.filter(term => termMatch(line, term) > 0);
|
|
218
|
+
if (matched.length) {
|
|
219
|
+
const distinctive = matched.some(term => !broad.has(term));
|
|
220
|
+
const score = matched.reduce((total, term) => total + (weights.get(term) ?? 1) * (termMatch(line, term) + termMatch(source.path, term)), 0);
|
|
221
|
+
const candidate = { path: source.path, line: lineNumber, excerpt: excerpt(line, matched), matchedTerms: matched, relevance: distinctive ? 'query-match' : 'shared-terms-only', score };
|
|
222
|
+
const candidates = distinctive ? direct : shared;
|
|
223
|
+
if (distinctive)
|
|
224
|
+
directCount++;
|
|
225
|
+
else
|
|
226
|
+
sharedCount++;
|
|
227
|
+
candidates.push(candidate);
|
|
228
|
+
candidates.sort(rank);
|
|
229
|
+
if (candidates.length > args.limit)
|
|
230
|
+
candidates.pop();
|
|
231
|
+
}
|
|
232
|
+
if (end < 0)
|
|
233
|
+
break;
|
|
234
|
+
start = end + 1;
|
|
235
|
+
lineNumber++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const candidates = directCount ? direct : shared, total = directCount || sharedCount;
|
|
239
|
+
return { kind: 'live-source-evidence', state: candidates.length ? 'matches' : 'no-matches', writesKnowledge: false, query: args.query, paths: scopes,
|
|
240
|
+
items: candidates.map(({ score, ...item }) => item), total, resultLimit: args.limit, resultsTruncated: total > args.limit,
|
|
241
|
+
downweightedTerms: [...broad], suppressedSharedTermMatches: directCount ? sharedCount : 0,
|
|
242
|
+
scan: { ...stats, truncated: reached.size > 0, reachedLimits: [...reached], limits },
|
|
243
|
+
basis: 'Live local source lines from a bounded lexical search, separate from stored facts. Matches are navigation evidence, not an architectural answer; open the surrounding code and documentation. No project code was executed.',
|
|
244
|
+
};
|
|
245
|
+
}
|