@functional-systems/srcx 0.1.0-dev.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/DESIGN.md +1057 -0
  4. package/LICENSE +202 -0
  5. package/README.md +209 -0
  6. package/RELEASING.md +137 -0
  7. package/VALIDATION.md +181 -0
  8. package/dist/build.d.ts +94 -0
  9. package/dist/build.js +433 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/chunk.d.ts +30 -0
  12. package/dist/chunk.js +296 -0
  13. package/dist/chunk.js.map +1 -0
  14. package/dist/cli.d.ts +2 -0
  15. package/dist/cli.js +276 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/common.d.ts +17 -0
  18. package/dist/common.js +74 -0
  19. package/dist/common.js.map +1 -0
  20. package/dist/git.d.ts +38 -0
  21. package/dist/git.js +224 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/publish.d.ts +60 -0
  24. package/dist/publish.js +341 -0
  25. package/dist/publish.js.map +1 -0
  26. package/dist/releases.d.ts +8 -0
  27. package/dist/releases.js +79 -0
  28. package/dist/releases.js.map +1 -0
  29. package/dist/remote.d.ts +53 -0
  30. package/dist/remote.js +136 -0
  31. package/dist/remote.js.map +1 -0
  32. package/dist/repository.d.ts +29 -0
  33. package/dist/repository.js +180 -0
  34. package/dist/repository.js.map +1 -0
  35. package/dist/search.d.ts +46 -0
  36. package/dist/search.js +181 -0
  37. package/dist/search.js.map +1 -0
  38. package/dist/settings.d.ts +10 -0
  39. package/dist/settings.js +35 -0
  40. package/dist/settings.js.map +1 -0
  41. package/package.json +58 -0
  42. package/src/build.ts +587 -0
  43. package/src/chunk.ts +328 -0
  44. package/src/cli.ts +366 -0
  45. package/src/common.ts +71 -0
  46. package/src/git.ts +286 -0
  47. package/src/publish.ts +487 -0
  48. package/src/releases.ts +108 -0
  49. package/src/remote.ts +230 -0
  50. package/src/repository.ts +306 -0
  51. package/src/search.ts +282 -0
  52. package/src/settings.ts +59 -0
package/src/chunk.ts ADDED
@@ -0,0 +1,328 @@
1
+ import { createRequire } from "node:module";
2
+ import { Parser, Language, type Node } from "web-tree-sitter";
3
+ import { getEncoding } from "js-tiktoken";
4
+ import { invariant, lineAt } from "./common.js";
5
+ const require = createRequire(import.meta.url);
6
+ const encoding = getEncoding("cl100k_base");
7
+ export function tokens(text: string): number {
8
+ return encoding.encode(text, [], []).length;
9
+ }
10
+ export const CHUNKER = {
11
+ version: 1,
12
+ parser: "web-tree-sitter@0.25.10",
13
+ grammars: "tree-sitter-wasms@0.1.13",
14
+ tokenizer: "js-tiktoken@1.0.21/cl100k_base",
15
+ targetTokens: 800,
16
+ maxTokens: 1500,
17
+ fallbackOverlap: 0.1,
18
+ enrichment: "path-scope-symbol-v1",
19
+ policy: "source-v1",
20
+ } as const;
21
+ export type Span = {
22
+ startByte: number;
23
+ endByte: number;
24
+ startLine: number;
25
+ endLine: number;
26
+ chunkKind: string;
27
+ scope?: string;
28
+ symbol?: string;
29
+ signature?: string;
30
+ searchText: string;
31
+ tokenCount: number;
32
+ };
33
+ type Unit = {
34
+ start: number;
35
+ end: number;
36
+ kind: string;
37
+ scope?: string;
38
+ symbol?: string;
39
+ signature?: string;
40
+ };
41
+ const languages = new Map<string, Language>();
42
+ let initialized: Promise<void> | undefined;
43
+ async function language(name: string): Promise<Language> {
44
+ initialized ??= Parser.init();
45
+ await initialized;
46
+ let l = languages.get(name);
47
+ if (!l) {
48
+ l = await Language.load(
49
+ require.resolve(`tree-sitter-wasms/out/tree-sitter-${name}.wasm`),
50
+ );
51
+ languages.set(name, l);
52
+ }
53
+ return l;
54
+ }
55
+ export function detectLanguage(path: string): string {
56
+ const ext = path.split(".").at(-1)?.toLowerCase();
57
+ return (
58
+ (
59
+ {
60
+ java: "java",
61
+ ts: "typescript",
62
+ tsx: "tsx",
63
+ js: "javascript",
64
+ jsx: "javascript",
65
+ mjs: "javascript",
66
+ cjs: "javascript",
67
+ md: "markdown",
68
+ mdx: "markdown",
69
+ json: "json",
70
+ yaml: "yaml",
71
+ yml: "yaml",
72
+ toml: "toml",
73
+ ini: "ini",
74
+ } as Record<string, string>
75
+ )[ext ?? ""] ?? "text"
76
+ );
77
+ }
78
+ function enrichment(path: string, u: Unit): string {
79
+ return (
80
+ [path.slice(0, 240), u.scope?.slice(0, 160), u.symbol?.slice(0, 160)]
81
+ .filter(Boolean)
82
+ .join("\n") + "\n"
83
+ );
84
+ }
85
+ function classify(type: string): string {
86
+ if (/import|package/.test(type)) return "imports";
87
+ if (/comment/.test(type)) return "documentation";
88
+ if (/function|method|constructor/.test(type)) return "function";
89
+ return "declaration";
90
+ }
91
+ function scopeName(n: Node): string | undefined {
92
+ const names: string[] = [];
93
+ let p = n.parent;
94
+ while (p) {
95
+ if (/class|interface|function|method/.test(p.type)) {
96
+ const name = p.childForFieldName("name");
97
+ if (name) names.unshift(name.text);
98
+ }
99
+ p = p.parent;
100
+ }
101
+ return names.length ? names.join(".") : undefined;
102
+ }
103
+ /** Bound strings without slicing a surrogate pair; prefer line ends where possible. */
104
+ function splitUnit(
105
+ source: string,
106
+ path: string,
107
+ u: Unit,
108
+ overlap: number,
109
+ ): Unit[] {
110
+ const result: Unit[] = [];
111
+ let start = u.start;
112
+ const prefix = enrichment(path, u);
113
+ invariant(
114
+ tokens(prefix) < CHUNKER.maxTokens,
115
+ "Path/scope enrichment exceeds token limit.",
116
+ );
117
+ while (start < u.end) {
118
+ let end = u.end;
119
+ if (tokens(prefix + source.slice(start, end)) > CHUNKER.maxTokens) {
120
+ let lo = start + 1,
121
+ hi = end,
122
+ best = start;
123
+ while (lo <= hi) {
124
+ const probe = Math.floor((lo + hi) / 2);
125
+ let mid = probe;
126
+ if (mid < u.end && /[\uDC00-\uDFFF]/.test(source[mid] ?? "")) mid--;
127
+ if (mid <= start) {
128
+ lo = probe + 1;
129
+ continue;
130
+ }
131
+ if (tokens(prefix + source.slice(start, mid)) <= CHUNKER.targetTokens) {
132
+ best = mid;
133
+ lo = probe + 1;
134
+ } else hi = probe - 1;
135
+ }
136
+ invariant(best > start, "Cannot fit source unit in token budget.");
137
+ end = best;
138
+ const newline = source.lastIndexOf("\n", end - 1);
139
+ if (newline >= start + (end - start) / 2) end = newline + 1;
140
+ }
141
+ result.push({ ...u, start, end });
142
+ if (end === u.end) break;
143
+ let next = end;
144
+ if (overlap) {
145
+ next = Math.max(start + 1, end - Math.floor((end - start) * overlap));
146
+ if (/[\uDC00-\uDFFF]/.test(source[next] ?? "")) next++;
147
+ }
148
+ invariant(next > start, "Chunker made no progress.");
149
+ start = next;
150
+ }
151
+ return result;
152
+ }
153
+ function textUnits(source: string, lang: string): Unit[] {
154
+ // Heading/paragraph/fence units for Markdown; config section/line units otherwise.
155
+ const units: Unit[] = [];
156
+ let start = 0,
157
+ pos = 0,
158
+ fenced = false;
159
+ for (const line of source.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
160
+ const fence = /^\s*(```|~~~)/.test(line);
161
+ const boundary =
162
+ lang === "markdown"
163
+ ? !fenced && (/^#{1,6}\s/.test(line) || line.trim() === "")
164
+ : /^\s*(\[|[\w.-]+\s*[:=])/.test(line);
165
+ if (boundary && pos > start) {
166
+ units.push({
167
+ start,
168
+ end: pos,
169
+ kind: lang === "markdown" ? "documentation" : "configuration",
170
+ });
171
+ start = pos;
172
+ }
173
+ if (fence) fenced = !fenced;
174
+ pos += line.length;
175
+ }
176
+ if (pos > start)
177
+ units.push({
178
+ start,
179
+ end: pos,
180
+ kind: lang === "markdown" ? "documentation" : "configuration",
181
+ });
182
+ return units;
183
+ }
184
+ export async function chunk(
185
+ source: string,
186
+ path: string,
187
+ mode: "syntax" | "window" = "syntax",
188
+ ): Promise<{ spans: Span[]; parseStatus: string; language: string }> {
189
+ const lang = detectLanguage(path);
190
+ if (!source.length)
191
+ return { spans: [], parseStatus: "empty", language: lang };
192
+ let units: Unit[] = [];
193
+ let status = "parsed";
194
+ if (mode === "window") {
195
+ status = "window-baseline";
196
+ units = [{ start: 0, end: source.length, kind: "fallback" }];
197
+ } else if (["java", "typescript", "tsx", "javascript"].includes(lang)) {
198
+ const grammar = await language(lang);
199
+ const parser = new Parser();
200
+ let tree: ReturnType<Parser["parse"]> = null;
201
+ try {
202
+ parser.setLanguage(grammar);
203
+ tree = parser.parse(source);
204
+ invariant(tree, "Parser returned no tree.");
205
+ if (tree.rootNode.hasError) {
206
+ status = "parse-error-fallback";
207
+ units = [{ start: 0, end: source.length, kind: "fallback" }];
208
+ } else {
209
+ function visit(n: Node): void {
210
+ const container =
211
+ /^(program|source_file|class_body|interface_body|class_declaration|interface_declaration|export_statement)$/.test(
212
+ n.type,
213
+ );
214
+ const u: Unit = {
215
+ start: n.startIndex,
216
+ end: n.endIndex,
217
+ kind: classify(n.type),
218
+ scope: scopeName(n),
219
+ };
220
+ const name = n.childForFieldName("name");
221
+ if (name) u.symbol = name.text.slice(0, 200);
222
+ if (u.kind === "function")
223
+ u.signature = n.text.split(/[\n{]/, 1)[0]?.slice(0, 240);
224
+ if (
225
+ n.namedChildCount &&
226
+ (container ||
227
+ tokens(enrichment(path, u) + n.text) > CHUNKER.maxTokens)
228
+ ) {
229
+ for (const child of n.namedChildren) if (child) visit(child);
230
+ } else if (n.endIndex > n.startIndex) units.push(u);
231
+ }
232
+ visit(tree.rootNode);
233
+ // Attach every gap (punctuation, BOM, comments, whitespace) without altering source bytes.
234
+ let cursor = 0;
235
+ const covered: Unit[] = [];
236
+ for (const u of units.sort((a, b) => a.start - b.start)) {
237
+ if (covered.length) {
238
+ const whitespace =
239
+ source.slice(cursor, u.start).match(/^\s*/)?.[0].length ?? 0;
240
+ cursor += whitespace;
241
+ covered[covered.length - 1]!.end = cursor;
242
+ }
243
+ covered.push({ ...u, start: cursor });
244
+ cursor = u.end;
245
+ }
246
+ units = covered;
247
+ if (cursor < source.length) {
248
+ if (units.length) units[units.length - 1]!.end = source.length;
249
+ else units = [{ start: 0, end: source.length, kind: "declaration" }];
250
+ }
251
+ }
252
+ } finally {
253
+ tree?.delete();
254
+ parser.delete();
255
+ }
256
+ } else if (["markdown", "json", "yaml", "toml", "ini"].includes(lang)) {
257
+ status =
258
+ lang === "markdown" ? "markdown-boundaries" : "config-text-fallback";
259
+ units = textUnits(source, lang);
260
+ } else {
261
+ status = "unsupported-language-fallback";
262
+ units = [{ start: 0, end: source.length, kind: "fallback" }];
263
+ }
264
+ const fallback = mode === "window" || status.includes("fallback");
265
+ // Merge small neighboring non-function declarations only within the same scope/kind.
266
+ const merged: Unit[] = [];
267
+ for (const u of units) {
268
+ const prev = merged.at(-1);
269
+ if (
270
+ prev &&
271
+ prev.kind !== "function" &&
272
+ u.kind === prev.kind &&
273
+ u.scope === prev.scope &&
274
+ tokens(enrichment(path, prev) + source.slice(prev.start, u.end)) <=
275
+ CHUNKER.targetTokens
276
+ ) {
277
+ prev.end = u.end;
278
+ delete prev.symbol;
279
+ delete prev.signature;
280
+ } else merged.push({ ...u });
281
+ }
282
+ const bounded = merged.flatMap((u) =>
283
+ splitUnit(source, path, u, fallback ? CHUNKER.fallbackOverlap : 0),
284
+ );
285
+ const offsets = new Uint32Array(source.length + 1);
286
+ let byte = 0;
287
+ for (let i = 0; i < source.length;) {
288
+ const cp = source.codePointAt(i)!;
289
+ const chars = cp > 0xffff ? 2 : 1;
290
+ offsets[i] = byte;
291
+ if (chars === 2) offsets[i + 1] = byte;
292
+ byte += Buffer.byteLength(String.fromCodePoint(cp));
293
+ i += chars;
294
+ offsets[i] = byte;
295
+ }
296
+ const raw = Buffer.from(source);
297
+ const spans = bounded.map((u) => {
298
+ const startByte = offsets[u.start]!,
299
+ endByte = offsets[u.end]!;
300
+ const text = source.slice(u.start, u.end);
301
+ const searchText = enrichment(path, u) + text;
302
+ const kind = text.trim() === "" ? "structural" : u.kind;
303
+ return {
304
+ startByte,
305
+ endByte,
306
+ startLine: lineAt(raw, startByte),
307
+ endLine: lineAt(raw, Math.max(startByte, endByte - 1)),
308
+ chunkKind: kind,
309
+ scope: u.scope,
310
+ symbol: u.symbol,
311
+ signature: u.signature,
312
+ searchText,
313
+ tokenCount: tokens(searchText),
314
+ };
315
+ });
316
+ let covered = 0;
317
+ for (const s of spans) {
318
+ invariant(
319
+ s.startByte <= covered &&
320
+ s.endByte > s.startByte &&
321
+ s.tokenCount <= CHUNKER.maxTokens,
322
+ "Invalid chunk coverage or size.",
323
+ );
324
+ covered = Math.max(covered, s.endByte);
325
+ }
326
+ invariant(covered === raw.length, "Incomplete source coverage.");
327
+ return { spans, parseStatus: status, language: lang };
328
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { Command, InvalidArgumentError } from "commander";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { configure, loadSettings, stateRoot } from "./settings.js";
8
+ import { identity } from "./git.js";
9
+ import { loadBuild, materialize, validateBuild, type Build } from "./build.js";
10
+ import { invariant, optionalJson } from "./common.js";
11
+ import { LambdaRemote } from "./remote.js";
12
+ import {
13
+ attachment,
14
+ destination,
15
+ discover,
16
+ register,
17
+ selectRepository,
18
+ } from "./repository.js";
19
+ import { exclusive, publish, published, type Published } from "./publish.js";
20
+ import { resolveVersion, syncTags } from "./releases.js";
21
+ import { directHandle, loadHandle, readHandle, search } from "./search.js";
22
+ const cli = new Command()
23
+ .name("srcx")
24
+ .description("Version-aware code search on LambdaDB")
25
+ .enablePositionalOptions()
26
+ .version(
27
+ JSON.parse(
28
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
29
+ ).version,
30
+ );
31
+ const output = (value: unknown): void => {
32
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
33
+ };
34
+ const integer = (value: string) => {
35
+ if (!/^\d+$/.test(value))
36
+ throw new InvalidArgumentError("Expected a nonnegative integer.");
37
+ const n = Number(value);
38
+ if (!Number.isSafeInteger(n))
39
+ throw new InvalidArgumentError("Integer is too large.");
40
+ return n;
41
+ };
42
+ async function connected() {
43
+ const settings = await loadSettings();
44
+ return { settings, remote: new LambdaRemote(settings) };
45
+ }
46
+ cli
47
+ .command("configure")
48
+ .requiredOption("--endpoint <origin>")
49
+ .requiredOption("--project <name>")
50
+ .option(
51
+ "--api-key-env <name>",
52
+ "Environment variable containing the API key",
53
+ "LAMBDADB_API_KEY",
54
+ )
55
+ .action(async (o) =>
56
+ output(await configure(o.endpoint, o.project, o.apiKeyEnv)),
57
+ );
58
+ cli
59
+ .command("doctor")
60
+ .description(
61
+ "Check authentication/read access; does not establish write/query readiness",
62
+ )
63
+ .action(async () => {
64
+ const { remote, settings } = await connected();
65
+ const collections = await remote.collections();
66
+ output({
67
+ endpoint: settings.endpoint,
68
+ project: settings.project,
69
+ readAccess: true,
70
+ collections: collections.length,
71
+ writeAccess: "not checked",
72
+ queryReadiness: "not checked",
73
+ });
74
+ });
75
+ const repo = cli
76
+ .command("repo")
77
+ .description("Discover and attach Git repositories");
78
+ repo
79
+ .command("add")
80
+ .requiredOption("--path <directory>")
81
+ .option("--remote <name>")
82
+ .option("--description <text>")
83
+ .option("--tag <key=value>", "One optional Collection metadata tag")
84
+ .action(async (o) => {
85
+ const { remote } = await connected();
86
+ let labels: Record<string, string> | undefined;
87
+ if (o.tag) {
88
+ const at = o.tag.indexOf("=");
89
+ invariant(at > 0, "Use --tag key=value.");
90
+ labels = { [o.tag.slice(0, at)]: o.tag.slice(at + 1) };
91
+ }
92
+ output(
93
+ await register(remote, {
94
+ path: o.path,
95
+ remote: o.remote,
96
+ description: o.description,
97
+ labels,
98
+ }),
99
+ );
100
+ });
101
+ repo.command("list").action(async () => {
102
+ const { remote } = await connected();
103
+ output(await discover(remote));
104
+ });
105
+ repo
106
+ .command("show")
107
+ .requiredOption("--repo <name>")
108
+ .action(async (o) => {
109
+ const { remote } = await connected();
110
+ output(await selectRepository(remote, o.repo));
111
+ });
112
+ cli
113
+ .command("import")
114
+ .description(
115
+ "Build a pinned Git commit; --dry-run exports locally without credentials",
116
+ )
117
+ .option(
118
+ "--path <directory>",
119
+ "Local checkout (required with credential-free --dry-run)",
120
+ )
121
+ .option("--remote <name>")
122
+ .option("--repo <name>", "Registered remote repository")
123
+ .option("--ref <ref>", "Local branch, Git tag, or commit OID")
124
+ .option("--dry-run", "Only materialize and validate a local build")
125
+ .option("--output <directory>", "New artifact directory")
126
+ .option(
127
+ "--previous <directory>",
128
+ "Previous build artifact for a dry-run diff",
129
+ )
130
+ .option("--artifact <directory>", "Import a previously previewed artifact")
131
+ .option(
132
+ "--resume",
133
+ "Resume the same pending artifact after inspecting its journal",
134
+ )
135
+ .option("--timeout <seconds>", "Validation wait deadline", integer, 120)
136
+ .action(async (o) => {
137
+ invariant(!(o.path && o.repo), "Choose --path or --repo.");
138
+ invariant(
139
+ !o.previous || o.dryRun,
140
+ "--previous is only accepted with --dry-run.",
141
+ );
142
+ invariant(!o.resume || !o.dryRun, "--resume requires a connected import.");
143
+ invariant(
144
+ !(o.artifact && (o.ref || o.path || o.output || o.previous)),
145
+ "--artifact cannot be combined with --ref/--path/--output/--previous.",
146
+ );
147
+ const buildLocal = async (
148
+ source: Awaited<ReturnType<typeof identity>>,
149
+ previous?: Build,
150
+ ) => {
151
+ invariant(o.ref, "--ref is required to build a commit.");
152
+ const parent = join(stateRoot(), "builds");
153
+ await mkdir(parent, { recursive: true, mode: 0o700 });
154
+ const path = o.output ? resolve(o.output) : join(parent, randomUUID());
155
+ return materialize({
156
+ identity: source,
157
+ ref: o.ref,
158
+ output: path,
159
+ previous,
160
+ });
161
+ };
162
+ if (o.dryRun) {
163
+ let b: Build;
164
+ if (o.artifact) {
165
+ b = await loadBuild(o.artifact);
166
+ await validateBuild(b);
167
+ } else {
168
+ let source;
169
+ if (o.path) source = await identity(o.path, o.remote);
170
+ else {
171
+ invariant(o.repo, "Use --path for a credential-free dry run.");
172
+ const { remote, settings } = await connected();
173
+ source = await attachment(
174
+ settings,
175
+ await selectRepository(remote, o.repo),
176
+ );
177
+ }
178
+ b = await buildLocal(
179
+ source,
180
+ o.previous ? await loadBuild(o.previous) : undefined,
181
+ );
182
+ }
183
+ output({
184
+ status: "locally-validated",
185
+ artifact: b.directory,
186
+ commitOid: b.commitOid,
187
+ configHash: b.configHash,
188
+ counts: b.counts,
189
+ changes: b.changes,
190
+ obsoleteIds: b.obsoleteIds,
191
+ coverage: b.inventory.map((e) => ({
192
+ path: e.path,
193
+ status: e.status,
194
+ reason: e.reason,
195
+ parseStatus: e.parseStatus,
196
+ })),
197
+ uploaded: false,
198
+ });
199
+ return;
200
+ }
201
+ invariant(
202
+ o.repo,
203
+ "Connected import requires --repo. Register it with repo add first.",
204
+ );
205
+ invariant(o.timeout > 0, "Timeout must be positive.");
206
+ const { remote, settings } = await connected();
207
+ const r = await selectRepository(remote, o.repo),
208
+ state = destination(settings, r.collection),
209
+ store = remote.store(r.collection);
210
+ output(
211
+ await exclusive(state, async () => {
212
+ // Pending attempts reconcile against the journal's immutable remote
213
+ // baseline; they do not need the previous build's local files.
214
+ const pending = await optionalJson(join(state, "pending.json"));
215
+ let baseline: { version: Published; build: Build } | undefined;
216
+ if (!pending) {
217
+ const last = await optionalJson<{
218
+ artifact: string;
219
+ version: Published;
220
+ }>(join(state, "last-build.json"));
221
+ if (last) {
222
+ const b = await loadBuild(last.artifact);
223
+ baseline = { version: last.version, build: b };
224
+ }
225
+ }
226
+ const b = o.artifact
227
+ ? await loadBuild(o.artifact)
228
+ : await buildLocal(await attachment(settings, r), baseline?.build);
229
+ return publish({
230
+ store,
231
+ binding: r,
232
+ build: b,
233
+ state,
234
+ baseline,
235
+ resume: o.resume,
236
+ timeoutMs: o.timeout * 1000,
237
+ });
238
+ }),
239
+ );
240
+ });
241
+ cli
242
+ .command("versions")
243
+ .requiredOption("--repo <name>")
244
+ .action(async (o) => {
245
+ const { remote } = await connected();
246
+ const r = await selectRepository(remote, o.repo);
247
+ output(await published(remote.store(r.collection), r));
248
+ });
249
+ cli
250
+ .command("resolve")
251
+ .requiredOption("--repo <name>")
252
+ .requiredOption("--ref <commit-or-release>")
253
+ .action(async (o) => {
254
+ const { remote } = await connected();
255
+ const r = await selectRepository(remote, o.repo);
256
+ output(await resolveVersion(remote.store(r.collection), r, o.ref));
257
+ });
258
+ cli
259
+ .command("search")
260
+ .requiredOption("--repo <name>")
261
+ .requiredOption("--version <commit-or-release>")
262
+ .requiredOption("--query <text>")
263
+ .option("--limit <count>", "Maximum hits", integer, 10)
264
+ .option("--path <path>", "Exact path filter")
265
+ .option("--language <name>")
266
+ .action(async (o) => {
267
+ const { remote, settings } = await connected();
268
+ const r = await selectRepository(remote, o.repo),
269
+ store = remote.store(r.collection),
270
+ v = await resolveVersion(store, r, o.version);
271
+ output(
272
+ await search(store, settings, r, v, o.query, o.limit, {
273
+ path: o.path,
274
+ language: o.language,
275
+ }),
276
+ );
277
+ });
278
+ cli
279
+ .command("read")
280
+ .option("--result <id>")
281
+ .option("--repo <name>")
282
+ .option("--version <commit-or-release>")
283
+ .option("--path <path>")
284
+ .option("--lines <start:end>")
285
+ .option("--context <lines>", "Expand an evidence span", integer, 0)
286
+ .option("--full-file")
287
+ .action(async (o) => {
288
+ invariant(
289
+ !(o.fullFile && (o.lines || o.context)),
290
+ "--full-file cannot be combined with --lines or --context.",
291
+ );
292
+ invariant(
293
+ !(o.result && (o.repo || o.version || o.path || o.lines)),
294
+ "Use result mode or direct path mode.",
295
+ );
296
+ const { settings, remote } = await connected();
297
+ let handle;
298
+ if (o.result) {
299
+ handle = await loadHandle(o.result);
300
+ invariant(
301
+ handle.endpoint === settings.endpoint &&
302
+ handle.project === settings.project,
303
+ "Result belongs to another endpoint/project.",
304
+ );
305
+ } else {
306
+ invariant(
307
+ o.repo && o.version && o.path,
308
+ "Provide --result, or --repo/--version/--path.",
309
+ );
310
+ const r = await selectRepository(remote, o.repo),
311
+ store = remote.store(r.collection);
312
+ handle = await directHandle(
313
+ store,
314
+ settings,
315
+ r,
316
+ await resolveVersion(store, r, o.version),
317
+ o.path,
318
+ );
319
+ }
320
+ let lines: [number, number] | undefined;
321
+ if (o.lines) {
322
+ invariant(/^\d+:\d+$/.test(o.lines), "Use --lines start:end.");
323
+ lines = o.lines.split(":").map(Number) as [number, number];
324
+ }
325
+ output(
326
+ await readHandle(
327
+ remote.store(handle.repository.collection),
328
+ settings,
329
+ handle,
330
+ {
331
+ context: o.context,
332
+ fullFile: o.fullFile || (!o.result && !o.lines),
333
+ lines,
334
+ },
335
+ ),
336
+ );
337
+ });
338
+ cli
339
+ .command("git")
340
+ .command("sync-tags")
341
+ .description(
342
+ "Synchronize currently observed local Git tags; no fetch/import/pruning",
343
+ )
344
+ .requiredOption("--repo <name>")
345
+ .action(async (o) => {
346
+ const { remote, settings } = await connected();
347
+ const r = await selectRepository(remote, o.repo),
348
+ source = await attachment(settings, r),
349
+ state = destination(settings, r.collection);
350
+ output(
351
+ await exclusive(state, async () => {
352
+ invariant(
353
+ !(await optionalJson(join(state, "pending.json"))),
354
+ "An import is pending; resume it before tag synchronization.",
355
+ );
356
+ return syncTags(remote.store(r.collection), r, source.path);
357
+ }),
358
+ );
359
+ });
360
+ try {
361
+ await cli.parseAsync();
362
+ } catch (e) {
363
+ const message = e instanceof Error ? e.message : "Operation failed.";
364
+ process.stderr.write(`srcx: ${message}\n`);
365
+ process.exitCode = 1;
366
+ }