@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/search.ts ADDED
@@ -0,0 +1,282 @@
1
+ import { join } from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+ import { atomic, hash, invariant, json, lineAt, type Doc } from "./common.js";
4
+ import { one, tag, type CollectionStore } from "./remote.js";
5
+ import { stateRoot, type Settings } from "./settings.js";
6
+ import type { Repository } from "./repository.js";
7
+ import type { Published } from "./publish.js";
8
+ import type { InventoryItem } from "./build.js";
9
+ export type Handle = {
10
+ id: string;
11
+ endpoint: string;
12
+ project: string;
13
+ repository: Repository;
14
+ version: Published;
15
+ fileId: string;
16
+ path: string;
17
+ contentHash: string;
18
+ chunkId?: string;
19
+ chunkHash?: string;
20
+ startLine: number;
21
+ endLine: number;
22
+ };
23
+ export async function inventoryAt(
24
+ store: CollectionStore,
25
+ r: Repository,
26
+ v: Published,
27
+ ): Promise<InventoryItem[]> {
28
+ const current = (await store.tags()).find((t) => t.name === v.tagName);
29
+ invariant(
30
+ current?.snapshotId === v.snapshotId,
31
+ "Pinned Tag was deleted or recreated.",
32
+ );
33
+ const root = await one(store, tag(v.tagName), "__manifest__");
34
+ invariant(
35
+ root?.repoId === r.repoId &&
36
+ root.indexId === r.indexId &&
37
+ root.configHash === r.configHash &&
38
+ root.commitOid === v.commitOid &&
39
+ root.recordsHash === v.recordsHash &&
40
+ root.inventoryHash === v.inventoryHash,
41
+ "Pinned manifest identity mismatch.",
42
+ );
43
+ invariant(
44
+ Array.isArray(root.inventoryPartIds),
45
+ "Manifest has no inventory part list.",
46
+ );
47
+ const entries: InventoryItem[] = [];
48
+ for (const [n, id] of (root.inventoryPartIds as string[]).entries()) {
49
+ const part = await one(store, tag(v.tagName), id);
50
+ invariant(
51
+ part?.role === "inventory" &&
52
+ part.configHash === r.configHash &&
53
+ part.partOrdinal === n &&
54
+ Array.isArray(part.entries) &&
55
+ hash(part.entries) === part.partHash,
56
+ "Invalid inventory part.",
57
+ );
58
+ entries.push(...(part.entries as InventoryItem[]));
59
+ }
60
+ invariant(hash(entries) === root.inventoryHash, "Inventory hash mismatch.");
61
+ return entries;
62
+ }
63
+ export function lexicalQuery(
64
+ query: string,
65
+ filters: { path?: string; language?: string } = {},
66
+ ): Record<string, unknown> {
67
+ invariant(
68
+ query.trim().length > 0 && query.length <= 4096,
69
+ "Query must contain 1–4096 characters.",
70
+ );
71
+ const literal = (field: string, value: string) => ({
72
+ queryString: { query: value, defaultField: field, skipSyntax: true },
73
+ occur: "filter",
74
+ });
75
+ return {
76
+ bool: [
77
+ literal("kind", "chunk"),
78
+ {
79
+ queryString: { query, defaultField: "searchText", skipSyntax: true },
80
+ occur: "must",
81
+ },
82
+ ...(filters.path ? [literal("path", filters.path)] : []),
83
+ ...(filters.language ? [literal("language", filters.language)] : []),
84
+ ],
85
+ };
86
+ }
87
+ export async function search(
88
+ store: CollectionStore,
89
+ s: Settings,
90
+ r: Repository,
91
+ v: Published,
92
+ query: string,
93
+ size = 10,
94
+ filters: { path?: string; language?: string } = {},
95
+ ): Promise<unknown[]> {
96
+ invariant(
97
+ Number.isInteger(size) && size > 0 && size <= 100,
98
+ "Limit must be an integer from 1 to 100.",
99
+ );
100
+ const entries = await inventoryAt(store, r, v);
101
+ const files = new Map(
102
+ entries.filter((e) => e.status === "included").map((e) => [e.fileId, e]),
103
+ );
104
+ const result = [];
105
+ for (const hit of await store.query(
106
+ v.tagName,
107
+ lexicalQuery(query, filters),
108
+ size,
109
+ )) {
110
+ const d = hit.doc,
111
+ e = files.get(d.fileId as string);
112
+ invariant(
113
+ d.kind === "chunk" &&
114
+ d.configHash === r.configHash &&
115
+ e?.chunkIds?.includes(d.id) &&
116
+ d.contentHash === e.contentHash &&
117
+ d.path === e.path,
118
+ "Search returned a record outside the pinned corpus.",
119
+ );
120
+ const id = randomUUID();
121
+ const handle: Handle = {
122
+ id,
123
+ endpoint: s.endpoint,
124
+ project: s.project,
125
+ repository: r,
126
+ version: v,
127
+ fileId: String(d.fileId),
128
+ path: String(d.path),
129
+ contentHash: String(d.contentHash),
130
+ chunkId: d.id,
131
+ chunkHash: hash(d),
132
+ startLine: Number(d.startLine),
133
+ endLine: Number(d.endLine),
134
+ };
135
+ // Check the exact source/ranges before persisting an evidence handle.
136
+ const evidence = await readHandle(store, s, handle);
137
+ await atomic(join(stateRoot(), "results", id + ".json"), handle);
138
+ result.push({
139
+ resultId: id,
140
+ repository: r.repoKey,
141
+ commitOid: v.commitOid,
142
+ version: v.tagName,
143
+ snapshotId: v.snapshotId,
144
+ path: d.path,
145
+ startLine: d.startLine,
146
+ endLine: d.endLine,
147
+ symbol: d.symbol,
148
+ score: hit.score,
149
+ excerpt: evidence.sourceText.slice(0, 600),
150
+ citation: evidence.citation,
151
+ });
152
+ }
153
+ return result;
154
+ }
155
+ export async function loadHandle(id: string): Promise<Handle> {
156
+ invariant(/^[a-f0-9-]{36}$/.test(id), "Invalid result handle ID.");
157
+ const h = await json<Handle>(join(stateRoot(), "results", id + ".json"));
158
+ invariant(h.id === id, "Result handle mismatch.");
159
+ return h;
160
+ }
161
+ export async function directHandle(
162
+ store: CollectionStore,
163
+ s: Settings,
164
+ r: Repository,
165
+ v: Published,
166
+ path: string,
167
+ ): Promise<Handle> {
168
+ const e = (await inventoryAt(store, r, v)).find((e) => e.path === path);
169
+ invariant(
170
+ e?.status === "included" && e.fileId && e.contentHash,
171
+ `Path is unavailable${e?.reason ? `: ${e.reason}` : ""}.`,
172
+ );
173
+ return {
174
+ id: "direct",
175
+ endpoint: s.endpoint,
176
+ project: s.project,
177
+ repository: r,
178
+ version: v,
179
+ fileId: e.fileId,
180
+ path,
181
+ contentHash: e.contentHash,
182
+ startLine: 1,
183
+ endLine: 1,
184
+ };
185
+ }
186
+ export async function readHandle(
187
+ store: CollectionStore,
188
+ s: Settings,
189
+ h: Handle,
190
+ options: {
191
+ context?: number;
192
+ fullFile?: boolean;
193
+ lines?: [number, number];
194
+ } = {},
195
+ ) {
196
+ invariant(
197
+ h.endpoint === s.endpoint && h.project === s.project,
198
+ "Result belongs to another endpoint/project; restore that connection before reading.",
199
+ );
200
+ const v = h.version,
201
+ current = (await store.tags()).find((t) => t.name === v.tagName);
202
+ invariant(
203
+ current?.snapshotId === v.snapshotId,
204
+ "Pinned Tag is missing or has been recreated.",
205
+ );
206
+ const file = await one(store, tag(v.tagName), h.fileId);
207
+ invariant(
208
+ file?.kind === "file" &&
209
+ file.configHash === h.repository.configHash &&
210
+ file.path === h.path &&
211
+ typeof file.sourceText === "string" &&
212
+ file.contentHash === h.contentHash &&
213
+ hash(Buffer.from(file.sourceText)) === h.contentHash,
214
+ "Original source is missing or its hash does not match.",
215
+ );
216
+ const raw = Buffer.from(file.sourceText);
217
+ const total =
218
+ raw.length === 0
219
+ ? 0
220
+ : lineAt(raw, raw.length - (raw.at(-1) === 10 ? 1 : 0));
221
+ if (h.chunkId) {
222
+ const d = await one(store, tag(v.tagName), h.chunkId);
223
+ invariant(
224
+ d &&
225
+ hash(d) === h.chunkHash &&
226
+ d.fileId === h.fileId &&
227
+ d.contentHash === h.contentHash,
228
+ "Pinned chunk has changed or is missing.",
229
+ );
230
+ const a = Number(d.startByte),
231
+ z = Number(d.endByte);
232
+ invariant(
233
+ Number.isInteger(a) &&
234
+ Number.isInteger(z) &&
235
+ a >= 0 &&
236
+ z > a &&
237
+ z <= raw.length &&
238
+ lineAt(raw, a) === h.startLine &&
239
+ lineAt(raw, z - 1) === h.endLine,
240
+ "Invalid source range.",
241
+ );
242
+ }
243
+ const context = options.context ?? 0;
244
+ invariant(
245
+ Number.isInteger(context) && context >= 0,
246
+ "Context must be a nonnegative integer.",
247
+ );
248
+ let start = options.lines?.[0] ?? h.startLine,
249
+ end = options.lines?.[1] ?? h.endLine;
250
+ if (options.fullFile) {
251
+ start = 1;
252
+ end = total;
253
+ } else {
254
+ invariant(
255
+ Number.isInteger(start) &&
256
+ Number.isInteger(end) &&
257
+ start >= 1 &&
258
+ end >= start &&
259
+ start <= total &&
260
+ end <= total,
261
+ "Line range is outside the file.",
262
+ );
263
+ start = Math.max(1, start - context);
264
+ end = Math.min(total, end + context);
265
+ }
266
+ const lines = file.sourceText.match(/[^\n]*\n|[^\n]+$/g) ?? [];
267
+ const sourceText = options.fullFile
268
+ ? file.sourceText
269
+ : lines.slice(start - 1, end).join("");
270
+ return {
271
+ repository: h.repository.repoKey,
272
+ commitOid: v.commitOid,
273
+ version: v.tagName,
274
+ snapshotId: v.snapshotId,
275
+ path: h.path,
276
+ startLine: start,
277
+ endLine: end,
278
+ contentHash: h.contentHash,
279
+ sourceText,
280
+ citation: `${h.repository.repoKey}@${v.commitOid}:${h.path}:${start}-${end}`,
281
+ };
282
+ }
@@ -0,0 +1,59 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { APP, atomic, invariant, json } from "./common.js";
4
+ export type Settings = { endpoint: string; project: string; apiKeyEnv: string };
5
+ export function stateRoot(): string {
6
+ return resolve(
7
+ process.env.SRCX_STATE_DIR ??
8
+ join(
9
+ process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
10
+ APP,
11
+ ),
12
+ );
13
+ }
14
+ export function configPath(): string {
15
+ return resolve(
16
+ process.env.SRCX_CONFIG ??
17
+ join(
18
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
19
+ APP,
20
+ "config.json",
21
+ ),
22
+ );
23
+ }
24
+ export function validateSettings(s: Settings): Settings {
25
+ const u = new URL(s.endpoint);
26
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
27
+ invariant(
28
+ (u.protocol === "https:" || (local && u.protocol === "http:")) &&
29
+ !u.username &&
30
+ !u.password &&
31
+ !u.search &&
32
+ !u.hash &&
33
+ u.pathname === "/",
34
+ "Endpoint must be an HTTPS origin (HTTP loopback is allowed).",
35
+ );
36
+ invariant(/^[A-Za-z0-9_-]+$/.test(s.project), "Invalid project name.");
37
+ invariant(
38
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(s.apiKeyEnv),
39
+ "Invalid API-key environment variable name.",
40
+ );
41
+ return { endpoint: u.origin, project: s.project, apiKeyEnv: s.apiKeyEnv };
42
+ }
43
+ export async function configure(
44
+ endpoint: string,
45
+ project: string,
46
+ apiKeyEnv = "LAMBDADB_API_KEY",
47
+ ): Promise<Settings> {
48
+ const s = validateSettings({ endpoint, project, apiKeyEnv });
49
+ await atomic(configPath(), s);
50
+ return s;
51
+ }
52
+ export async function loadSettings(): Promise<Settings> {
53
+ const s = await json<Settings>(configPath());
54
+ invariant(
55
+ Object.keys(s).sort().join(",") === "apiKeyEnv,endpoint,project",
56
+ "Invalid config; never save secrets in this file.",
57
+ );
58
+ return validateSettings(s);
59
+ }