@oscharko-dev/keiko-workspace 0.2.0

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 (83) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/binaryDetect.d.ts +6 -0
  3. package/dist/binaryDetect.d.ts.map +1 -0
  4. package/dist/binaryDetect.js +20 -0
  5. package/dist/contextPack.d.ts +24 -0
  6. package/dist/contextPack.d.ts.map +1 -0
  7. package/dist/contextPack.js +118 -0
  8. package/dist/detect.d.ts +5 -0
  9. package/dist/detect.d.ts.map +1 -0
  10. package/dist/detect.js +144 -0
  11. package/dist/discovery.d.ts +10 -0
  12. package/dist/discovery.d.ts.map +1 -0
  13. package/dist/discovery.js +199 -0
  14. package/dist/document-extraction.d.ts +44 -0
  15. package/dist/document-extraction.d.ts.map +1 -0
  16. package/dist/document-extraction.js +372 -0
  17. package/dist/errors.d.ts +3 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +4 -0
  20. package/dist/fs.d.ts +25 -0
  21. package/dist/fs.d.ts.map +1 -0
  22. package/dist/fs.js +69 -0
  23. package/dist/gitHistory.d.ts +3 -0
  24. package/dist/gitHistory.d.ts.map +1 -0
  25. package/dist/gitHistory.js +317 -0
  26. package/dist/ignore.d.ts +15 -0
  27. package/dist/ignore.d.ts.map +1 -0
  28. package/dist/ignore.js +248 -0
  29. package/dist/importGraph.d.ts +3 -0
  30. package/dist/importGraph.d.ts.map +1 -0
  31. package/dist/importGraph.js +131 -0
  32. package/dist/index.d.ts +27 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +25 -0
  35. package/dist/paths.d.ts +3 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +38 -0
  38. package/dist/realpath.d.ts +9 -0
  39. package/dist/realpath.d.ts.map +1 -0
  40. package/dist/realpath.js +72 -0
  41. package/dist/repoSearch.d.ts +46 -0
  42. package/dist/repoSearch.d.ts.map +1 -0
  43. package/dist/repoSearch.js +350 -0
  44. package/dist/repoSearchEntries.d.ts +15 -0
  45. package/dist/repoSearchEntries.d.ts.map +1 -0
  46. package/dist/repoSearchEntries.js +106 -0
  47. package/dist/repoSearchLineSelection.d.ts +18 -0
  48. package/dist/repoSearchLineSelection.d.ts.map +1 -0
  49. package/dist/repoSearchLineSelection.js +43 -0
  50. package/dist/repoSearchMatchers.d.ts +8 -0
  51. package/dist/repoSearchMatchers.d.ts.map +1 -0
  52. package/dist/repoSearchMatchers.js +414 -0
  53. package/dist/repoSearchPolicy.d.ts +34 -0
  54. package/dist/repoSearchPolicy.d.ts.map +1 -0
  55. package/dist/repoSearchPolicy.js +342 -0
  56. package/dist/repoSearchRegexSafety.d.ts +2 -0
  57. package/dist/repoSearchRegexSafety.d.ts.map +1 -0
  58. package/dist/repoSearchRegexSafety.js +15 -0
  59. package/dist/repoSearchScan.d.ts +62 -0
  60. package/dist/repoSearchScan.d.ts.map +1 -0
  61. package/dist/repoSearchScan.js +292 -0
  62. package/dist/retrieval.d.ts +10 -0
  63. package/dist/retrieval.d.ts.map +1 -0
  64. package/dist/retrieval.js +74 -0
  65. package/dist/stableId.d.ts +4 -0
  66. package/dist/stableId.d.ts.map +1 -0
  67. package/dist/stableId.js +49 -0
  68. package/dist/structuralAdapters.d.ts +27 -0
  69. package/dist/structuralAdapters.d.ts.map +1 -0
  70. package/dist/structuralAdapters.js +87 -0
  71. package/dist/summary.d.ts +4 -0
  72. package/dist/summary.d.ts.map +1 -0
  73. package/dist/summary.js +54 -0
  74. package/dist/testSourcePairing.d.ts +3 -0
  75. package/dist/testSourcePairing.d.ts.map +1 -0
  76. package/dist/testSourcePairing.js +179 -0
  77. package/dist/types.d.ts +3 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +4 -0
  80. package/dist/version.d.ts +2 -0
  81. package/dist/version.d.ts.map +1 -0
  82. package/dist/version.js +4 -0
  83. package/package.json +35 -0
@@ -0,0 +1,292 @@
1
+ // Candidate gathering and the per-file scan loop for the repo-search facade (Issue #179).
2
+ // Kept separate from the public API surface so repoSearch.ts stays inside the 400-LOC cap.
3
+ // Every file system touch goes through the injected WorkspaceFs port; nothing here calls
4
+ // node:fs directly.
5
+ import { CONNECTED_CONTEXT_SCHEMA_VERSION, isValidScopePath, } from "@oscharko-dev/keiko-contracts/connected-context";
6
+ import { discoverWithStats, readWorkspaceFile } from "./discovery.js";
7
+ import { FileTooLargeError, RepoSearchInvalidQueryError } from "./errors.js";
8
+ import { isDenied } from "./ignore.js";
9
+ import { resolveWithinWorkspace } from "./paths.js";
10
+ import { containedRealPathInfo } from "./realpath.js";
11
+ import { looksBinary } from "./binaryDetect.js";
12
+ import { collectFromEntries } from "./repoSearchEntries.js";
13
+ import { collectBestLines } from "./repoSearchLineSelection.js";
14
+ import { evidenceAtomStableId } from "./stableId.js";
15
+ import { extraIgnoreLinesForSearch, legacyDiscoveryPolicy, orderCandidatesForSearch, policyOmissionReason, resolveSearchPolicy, shouldScoreContent, } from "./repoSearchPolicy.js";
16
+ const BINARY_PROBE_BYTES = 512;
17
+ const IMAGE_EXTENSIONS = new Set([
18
+ ".avif",
19
+ ".bmp",
20
+ ".gif",
21
+ ".heic",
22
+ ".heif",
23
+ ".ico",
24
+ ".jpeg",
25
+ ".jpg",
26
+ ".png",
27
+ ".tif",
28
+ ".tiff",
29
+ ".webp",
30
+ ]);
31
+ function normalizeScopePath(scopePath) {
32
+ return scopePath.split("\\").join("/");
33
+ }
34
+ export function isImageScopePath(scopePath) {
35
+ const lastSlash = Math.max(scopePath.lastIndexOf("/"), scopePath.lastIndexOf("\\"));
36
+ const basename = scopePath.slice(lastSlash + 1).toLowerCase();
37
+ const dot = basename.lastIndexOf(".");
38
+ return dot >= 0 && IMAGE_EXTENSIONS.has(basename.slice(dot));
39
+ }
40
+ export function buildAtom(shape) {
41
+ const stableId = evidenceAtomStableId({
42
+ scopeId: shape.scopeId,
43
+ scopePath: shape.scopePath,
44
+ lineRange: shape.lineRange,
45
+ provenanceKind: shape.provenanceKind,
46
+ provenanceTool: shape.tool,
47
+ queryFingerprint: shape.queryFingerprint,
48
+ });
49
+ return {
50
+ schemaVersion: CONNECTED_CONTEXT_SCHEMA_VERSION,
51
+ stableId,
52
+ scopePath: shape.scopePath,
53
+ lineRange: shape.lineRange,
54
+ score: shape.score,
55
+ provenance: {
56
+ kind: shape.provenanceKind,
57
+ tool: shape.tool,
58
+ queryFingerprint: shape.queryFingerprint,
59
+ },
60
+ redactionState: "redacted",
61
+ emittedAtMs: shape.emittedAtMs,
62
+ ledgerRef: undefined,
63
+ };
64
+ }
65
+ export function buildCandidate(scopePath, omitted) {
66
+ return { scopePath, score: 0, signals: [], omitted };
67
+ }
68
+ function collectFromDirectory(scope, limits, fs, policy) {
69
+ const extraIgnoreLines = extraIgnoreLinesForSearch(policy);
70
+ const workspace = extraIgnoreLines.length === 0
71
+ ? scope.workspace
72
+ : { ...scope.workspace, ignoreLines: [...scope.workspace.ignoreLines, ...extraIgnoreLines] };
73
+ const result = discoverWithStats(workspace, {
74
+ maxDepth: 12,
75
+ maxFiles: limits.maxFilesScanned + 1,
76
+ applyGitignore: policy.applyGitignore,
77
+ }, fs);
78
+ const files = result.files;
79
+ return {
80
+ files: files.slice(0, limits.maxFilesScanned),
81
+ truncated: files.length > limits.maxFilesScanned,
82
+ ignored: result.stats.ignored,
83
+ denied: result.stats.denied,
84
+ };
85
+ }
86
+ const DEFAULT_GATHER_QUERY = {
87
+ kind: "natural-language",
88
+ text: "generic repository search",
89
+ caseSensitive: false,
90
+ maxResults: 100,
91
+ emittedAtMs: 0,
92
+ };
93
+ function isRetrievalQuery(value) {
94
+ return typeof value === "object" && value !== null && "kind" in value && "text" in value;
95
+ }
96
+ function resolveGatherInputs(scope, queryOrLimits, limitsOrFs, fsOrPolicy, policy) {
97
+ if (isRetrievalQuery(queryOrLimits)) {
98
+ return {
99
+ query: queryOrLimits,
100
+ limits: limitsOrFs,
101
+ fs: fsOrPolicy,
102
+ policy: policy ?? resolveSearchPolicy(scope.relativePaths.length > 0, undefined),
103
+ };
104
+ }
105
+ return {
106
+ query: DEFAULT_GATHER_QUERY,
107
+ limits: queryOrLimits,
108
+ fs: limitsOrFs,
109
+ policy: legacyDiscoveryPolicy(scope.relativePaths.length > 0),
110
+ };
111
+ }
112
+ export function gatherCandidates(scope, queryOrLimits, limitsOrFs, fsOrPolicy, policy) {
113
+ const inputs = resolveGatherInputs(scope, queryOrLimits, limitsOrFs, fsOrPolicy, policy);
114
+ // Defense in depth alongside the realpath gate: validate scope.relativePaths against the
115
+ // contracts-layer shape rules (no absolute paths, no `..`, no drive letters, no backslashes).
116
+ // resolveWithinWorkspace + assertContainedRealPath already provide a complete barrier; this
117
+ // pre-check rejects shape-invalid inputs at the API boundary with a typed error rather than
118
+ // letting a normalization quirk slip past unnoticed.
119
+ for (const entry of scope.relativePaths) {
120
+ if (!isValidScopePath(entry, { mustBeRelative: true })) {
121
+ throw new RepoSearchInvalidQueryError(`invalid scope.relativePaths entry: ${entry}`);
122
+ }
123
+ }
124
+ if (scope.relativePaths.length === 0) {
125
+ const result = collectFromDirectory(scope, inputs.limits, inputs.fs, inputs.policy);
126
+ const ordered = orderCandidatesForSearch(result.files, inputs.query, inputs.policy, result.ignored, result.denied);
127
+ return {
128
+ files: ordered.files,
129
+ truncated: result.truncated,
130
+ diagnostics: ordered.diagnostics,
131
+ };
132
+ }
133
+ const result = collectFromEntries(scope, inputs.limits, inputs.fs);
134
+ const ordered = orderCandidatesForSearch(result.files, inputs.query, inputs.policy, 0, 0);
135
+ return {
136
+ files: ordered.files,
137
+ truncated: result.truncated,
138
+ diagnostics: ordered.diagnostics,
139
+ };
140
+ }
141
+ export async function probeBinary(fs, abs, size) {
142
+ const cap = Math.min(BINARY_PROBE_BYTES, size);
143
+ if (cap === 0) {
144
+ return false;
145
+ }
146
+ if (fs.readFileBytes !== undefined) {
147
+ return looksBinary(await fs.readFileBytes(abs, cap));
148
+ }
149
+ const text = fs.readFileUtf8(abs);
150
+ return looksBinary(new TextEncoder().encode(text.slice(0, cap)));
151
+ }
152
+ export function elapsed(runner) {
153
+ return runner.nowMs() - runner.startMs;
154
+ }
155
+ function isRunnerAborted(runner) {
156
+ return runner.signal?.aborted === true;
157
+ }
158
+ export function hitLimit(runner, state) {
159
+ if (isRunnerAborted(runner)) {
160
+ state.truncated = true;
161
+ return true;
162
+ }
163
+ if (state.filesScanned >= runner.limits.maxFilesScanned) {
164
+ state.truncated = true;
165
+ return true;
166
+ }
167
+ if (state.matchesReturned >= runner.limits.maxMatchesReturned) {
168
+ state.truncated = true;
169
+ return true;
170
+ }
171
+ if (elapsed(runner) > runner.limits.elapsedMsMax) {
172
+ state.truncated = true;
173
+ return true;
174
+ }
175
+ return false;
176
+ }
177
+ function hitEmissionLimit(runner, state) {
178
+ if (isRunnerAborted(runner)) {
179
+ state.truncated = true;
180
+ return true;
181
+ }
182
+ if (state.matchesReturned >= runner.limits.maxMatchesReturned) {
183
+ state.truncated = true;
184
+ return true;
185
+ }
186
+ if (elapsed(runner) > runner.limits.elapsedMsMax) {
187
+ state.truncated = true;
188
+ return true;
189
+ }
190
+ return false;
191
+ }
192
+ // Returns true for NodeJS.ErrnoException (EACCES, ENOENT, EIO, …). Checked by the presence of a
193
+ // string `code` property so TypeError and other programmer errors are NOT swallowed.
194
+ export function isIoError(err) {
195
+ if (err === null || typeof err !== "object" || !("code" in err)) {
196
+ return false;
197
+ }
198
+ const { code } = err;
199
+ return typeof code === "string";
200
+ }
201
+ function readForScan(runner, relativePath, candidates) {
202
+ try {
203
+ return readWorkspaceFile(runner.scope.workspace, relativePath, { maxBytes: runner.limits.maxBytesPerFileScanned }, runner.fs).text;
204
+ }
205
+ catch (err) {
206
+ if (err instanceof FileTooLargeError) {
207
+ candidates.push(buildCandidate(relativePath, "size-exceeded"));
208
+ return undefined;
209
+ }
210
+ // TOCTOU: permissions or availability may change between discovery and read.
211
+ // A single unreadable file must degrade to a skip, not crash the whole scan.
212
+ if (isIoError(err)) {
213
+ candidates.push(buildCandidate(relativePath, "tool-unavailable"));
214
+ return undefined;
215
+ }
216
+ throw err;
217
+ }
218
+ }
219
+ function emitBestLines(runner, relativePath, state, atoms, best) {
220
+ for (const match of best) {
221
+ if (hitEmissionLimit(runner, state)) {
222
+ return;
223
+ }
224
+ atoms.push(buildAtom({
225
+ scopeId: runner.scope.scopeId,
226
+ scopePath: relativePath,
227
+ lineRange: { startLine: match.line, endLine: match.line },
228
+ provenanceKind: "lexical-search",
229
+ tool: "repo.searchText",
230
+ queryFingerprint: runner.fingerprint,
231
+ score: match.score,
232
+ emittedAtMs: runner.nowMs(),
233
+ }));
234
+ state.matchesReturned += 1;
235
+ }
236
+ }
237
+ function scanLines(runner, relativePath, text, state, atoms) {
238
+ emitBestLines(runner, relativePath, state, atoms, collectBestLines(runner, text, state));
239
+ }
240
+ function filePolicyOmission(runner, file) {
241
+ if (isImageScopePath(file.relativePath)) {
242
+ return { omitted: "binary" };
243
+ }
244
+ if (isDenied(file.relativePath)) {
245
+ return { omitted: "ignored" };
246
+ }
247
+ const abs = resolveWithinWorkspace(runner.scope.workspace.root, file.relativePath);
248
+ const contained = containedRealPathInfo(runner.fs, runner.scope.workspace.root, abs);
249
+ const realRel = normalizeScopePath(contained.realRelative);
250
+ if (isDenied(realRel)) {
251
+ return { omitted: "ignored" };
252
+ }
253
+ return { omitted: policyOmissionReason(file.relativePath, runner.policy), path: contained.path };
254
+ }
255
+ async function binaryOmission(runner, file, path) {
256
+ try {
257
+ return (await probeBinary(runner.fs, path, file.sizeBytes)) ? "binary" : undefined;
258
+ }
259
+ catch (err) {
260
+ // TOCTOU: file may have become unreadable (EACCES, ENOENT, …) between discovery and probe.
261
+ if (isIoError(err)) {
262
+ return "tool-unavailable";
263
+ }
264
+ throw err;
265
+ }
266
+ }
267
+ export async function scanFile(runner, file, state, atoms, candidates) {
268
+ if (isRunnerAborted(runner)) {
269
+ state.truncated = true;
270
+ return;
271
+ }
272
+ const policy = filePolicyOmission(runner, file);
273
+ if (policy.omitted !== undefined) {
274
+ candidates.push(buildCandidate(file.relativePath, policy.omitted));
275
+ return;
276
+ }
277
+ const binary = policy.path === undefined ? "binary" : await binaryOmission(runner, file, policy.path);
278
+ if (binary !== undefined) {
279
+ candidates.push(buildCandidate(file.relativePath, binary));
280
+ return;
281
+ }
282
+ if (isRunnerAborted(runner)) {
283
+ state.truncated = true;
284
+ return;
285
+ }
286
+ state.filesScanned += 1;
287
+ const text = readForScan(runner, file.relativePath, candidates);
288
+ if (text === undefined || !shouldScoreContent(runner.query, text, runner.policy)) {
289
+ return;
290
+ }
291
+ scanLines(runner, file.relativePath, text, state, atoms);
292
+ }
@@ -0,0 +1,10 @@
1
+ import { type DiscoveredFile, type SelectionReason } from "./types.js";
2
+ export interface RankedFile {
3
+ readonly file: DiscoveredFile;
4
+ readonly selectionReason: SelectionReason;
5
+ }
6
+ export interface RetrievalStrategy {
7
+ readonly rank: (files: readonly DiscoveredFile[], task: string | undefined) => readonly RankedFile[];
8
+ }
9
+ export declare const lexicalRetrievalStrategy: RetrievalStrategy;
10
+ //# sourceMappingURL=retrieval.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retrieval.d.ts","sourceRoot":"","sources":["../src/retrieval.ts"],"names":[],"mappings":"AAKA,OAAO,EAA6B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAElG,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,eAAe,EAAE,eAAe,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAGhC,QAAQ,CAAC,IAAI,EAAE,CACb,KAAK,EAAE,SAAS,cAAc,EAAE,EAChC,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,SAAS,UAAU,EAAE,CAAC;CAC5B;AAoED,eAAO,MAAM,wBAAwB,EAAE,iBAatC,CAAC"}
@@ -0,0 +1,74 @@
1
+ // Retrieval seam (ADR-0005 D5). `RetrievalStrategy` is the typed extension point a future
2
+ // embedding ranker (e.g. `example-embedding-model`) plugs into. Wave-1 ships ONLY the seam and
3
+ // a deterministic lexical default — no embeddings, no vector DB, no new dependency. The
4
+ // default ranker is pure and clock/RNG-free so context packs are reproducible.
5
+ import { SELECTION_REASON_PRIORITY } from "./types.js";
6
+ const ENTRYPOINT_BASENAMES = new Set([
7
+ "index.ts",
8
+ "index.js",
9
+ "main.ts",
10
+ "main.js",
11
+ "cli.ts",
12
+ "cli.js",
13
+ ]);
14
+ const MANIFEST_BASENAMES = new Set([
15
+ "package.json",
16
+ "tsconfig.json",
17
+ "tsconfig.build.json",
18
+ ]);
19
+ const DOC_EXTENSIONS = new Set([".md", ".mdx", ".rst", ".txt"]);
20
+ const CONFIG_EXTENSIONS = new Set([".json", ".yml", ".yaml", ".toml"]);
21
+ const CONFIG_BASENAME_HINTS = [".config.", "eslint", "prettier", "vitest"];
22
+ function basename(path) {
23
+ const idx = path.lastIndexOf("/");
24
+ return idx === -1 ? path : path.slice(idx + 1);
25
+ }
26
+ function extension(name) {
27
+ const idx = name.lastIndexOf(".");
28
+ return idx <= 0 ? "" : name.slice(idx);
29
+ }
30
+ function isTest(name) {
31
+ return /\.(test|spec)\.[cm]?[jt]sx?$/.test(name);
32
+ }
33
+ function isConfig(name) {
34
+ return (CONFIG_EXTENSIONS.has(extension(name)) || CONFIG_BASENAME_HINTS.some((h) => name.includes(h)));
35
+ }
36
+ function classify(path) {
37
+ const name = basename(path);
38
+ if (isTest(name) || path.startsWith("tests/") || path.startsWith("test/")) {
39
+ return "test";
40
+ }
41
+ if (ENTRYPOINT_BASENAMES.has(name)) {
42
+ return "entrypoint";
43
+ }
44
+ if (MANIFEST_BASENAMES.has(name)) {
45
+ return "manifest";
46
+ }
47
+ if (DOC_EXTENSIONS.has(extension(name))) {
48
+ return "documentation";
49
+ }
50
+ if (isConfig(name)) {
51
+ return "config";
52
+ }
53
+ return "source";
54
+ }
55
+ function priorityIndex(reason) {
56
+ return SELECTION_REASON_PRIORITY.indexOf(reason);
57
+ }
58
+ // Deterministic lexical ranking: by selection-reason priority, then by path (ascending).
59
+ export const lexicalRetrievalStrategy = {
60
+ rank: (files) => {
61
+ const ranked = files.map((file) => ({ file, selectionReason: classify(file.relativePath) }));
62
+ return [...ranked].sort((a, b) => {
63
+ const byReason = priorityIndex(a.selectionReason) - priorityIndex(b.selectionReason);
64
+ if (byReason !== 0) {
65
+ return byReason;
66
+ }
67
+ if (a.file.relativePath < b.file.relativePath)
68
+ return -1;
69
+ if (a.file.relativePath > b.file.relativePath)
70
+ return 1;
71
+ return 0;
72
+ });
73
+ },
74
+ };
@@ -0,0 +1,4 @@
1
+ import type { ConnectedContextPackStableIdInput, EvidenceAtomStableIdInput } from "@oscharko-dev/keiko-contracts/connected-context";
2
+ export declare function evidenceAtomStableId(input: EvidenceAtomStableIdInput): string;
3
+ export declare function connectedContextPackStableId(input: ConnectedContextPackStableIdInput): string;
4
+ //# sourceMappingURL=stableId.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stableId.d.ts","sourceRoot":"","sources":["../src/stableId.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,iCAAiC,EACjC,yBAAyB,EAC1B,MAAM,iDAAiD,CAAC;AA8BzD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,yBAAyB,GAAG,MAAM,CAa7E;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,iCAAiC,GAAG,MAAM,CAS7F"}
@@ -0,0 +1,49 @@
1
+ // Deterministic SHA-256 stable IDs for connected-context evidence atoms and packs
2
+ // (Epic #177, Issue #179). Inputs are the DTOs defined in keiko-contracts; the producer
3
+ // here owns the canonicalisation rules (key-sorted JSON, omit `undefined`, no whitespace,
4
+ // caller-sorted-or-here-sorted arrays) so every downstream package hashes the same shape.
5
+ // Prefixes `a-` / `p-` keep the two namespaces visually separable in logs.
6
+ import { createHash } from "node:crypto";
7
+ function canonicalize(value) {
8
+ if (value === undefined) {
9
+ return "null";
10
+ }
11
+ if (value === null || typeof value !== "object") {
12
+ return JSON.stringify(value);
13
+ }
14
+ if (Array.isArray(value)) {
15
+ const items = value;
16
+ return `[${items.map((item) => canonicalize(item)).join(",")}]`;
17
+ }
18
+ const entries = Object.entries(value)
19
+ .filter((pair) => pair[1] !== undefined)
20
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1));
21
+ const body = entries.map(([key, val]) => `${JSON.stringify(key)}:${canonicalize(val)}`).join(",");
22
+ return `{${body}}`;
23
+ }
24
+ function sha256Hex(canonical) {
25
+ return createHash("sha256").update(canonical).digest("hex");
26
+ }
27
+ export function evidenceAtomStableId(input) {
28
+ const shape = {
29
+ scopeId: input.scopeId,
30
+ scopePath: input.scopePath,
31
+ lineRange: input.lineRange === undefined
32
+ ? undefined
33
+ : { startLine: input.lineRange.startLine, endLine: input.lineRange.endLine },
34
+ provenanceKind: input.provenanceKind,
35
+ provenanceTool: input.provenanceTool,
36
+ queryFingerprint: input.queryFingerprint,
37
+ };
38
+ return `a-${sha256Hex(canonicalize(shape))}`;
39
+ }
40
+ export function connectedContextPackStableId(input) {
41
+ const sortedAtomIds = [...input.atomStableIds].sort((a, b) => (a < b ? -1 : 1));
42
+ const shape = {
43
+ scopeId: input.scopeId,
44
+ queryKind: input.queryKind,
45
+ queryText: input.queryText,
46
+ atomStableIds: sortedAtomIds,
47
+ };
48
+ return `p-${sha256Hex(canonicalize(shape))}`;
49
+ }
@@ -0,0 +1,27 @@
1
+ import type { EvidenceAtom, RetrievalQuery } from "@oscharko-dev/keiko-contracts/connected-context";
2
+ import type { WorkspaceFs } from "./fs.js";
3
+ import type { SearchLimits, SearchScope } from "./repoSearch.js";
4
+ export interface StructuralAdapterDeps {
5
+ readonly nowMs?: () => number;
6
+ }
7
+ export interface StructuralAdapter {
8
+ readonly name: string;
9
+ readonly isAvailable: (scope: SearchScope, fs: WorkspaceFs) => Promise<boolean>;
10
+ readonly lookup: (scope: SearchScope, query: RetrievalQuery, limits: SearchLimits, fs: WorkspaceFs, deps?: StructuralAdapterDeps) => Promise<readonly EvidenceAtom[]>;
11
+ }
12
+ export interface StructuralAdapterRegistry {
13
+ readonly adapters: readonly StructuralAdapter[];
14
+ }
15
+ export interface AdapterError {
16
+ readonly name: string;
17
+ readonly message: string;
18
+ }
19
+ export interface RunAllResult {
20
+ readonly atoms: readonly EvidenceAtom[];
21
+ readonly unavailable: readonly string[];
22
+ readonly errored: readonly AdapterError[];
23
+ readonly elapsedMs: number;
24
+ }
25
+ export declare function createDefaultStructuralRegistry(): StructuralAdapterRegistry;
26
+ export declare function runStructuralAdapters(registry: StructuralAdapterRegistry, scope: SearchScope, query: RetrievalQuery, limits: SearchLimits, fs: WorkspaceFs, deps?: StructuralAdapterDeps): Promise<RunAllResult>;
27
+ //# sourceMappingURL=structuralAdapters.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structuralAdapters.d.ts","sourceRoot":"","sources":["../src/structuralAdapters.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iDAAiD,CAAC;AAEpG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAOjE,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,QAAQ,CAAC,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAGhF,QAAQ,CAAC,MAAM,EAAE,CACf,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,YAAY,EACpB,EAAE,EAAE,WAAW,EACf,IAAI,CAAC,EAAE,qBAAqB,KACzB,OAAO,CAAC,SAAS,YAAY,EAAE,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IACxC,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,QAAQ,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAID,wBAAgB,+BAA+B,IAAI,yBAAyB,CAI3E;AA+ED,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,yBAAyB,EACnC,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,YAAY,EACpB,EAAE,EAAE,WAAW,EACf,IAAI,CAAC,EAAE,qBAAqB,GAC3B,OAAO,CAAC,YAAY,CAAC,CA2BvB"}
@@ -0,0 +1,87 @@
1
+ // Optional structural exploration adapter contract + default registry (Epic #177, Issue #180).
2
+ // Adapters surface structural signals (test/source pairing, import graph, git history) and
3
+ // degrade gracefully when their data sources are missing. Output is normalized to EvidenceAtom
4
+ // from @oscharko-dev/keiko-contracts; the runner merges, dedupes, and caps adapter output so
5
+ // one broken adapter never blocks the rest. Stays within ADR-0019 rule 3b.
6
+ import { RepoSearchInvalidQueryError, RepoSearchInvalidRangeError } from "./errors.js";
7
+ import { testSourcePairingAdapter } from "./testSourcePairing.js";
8
+ import { importGraphAdapter } from "./importGraph.js";
9
+ import { gitHistoryAdapter } from "./gitHistory.js";
10
+ // ─── Default registry ─────────────────────────────────────────────────────────
11
+ export function createDefaultStructuralRegistry() {
12
+ return {
13
+ adapters: [testSourcePairingAdapter, importGraphAdapter, gitHistoryAdapter],
14
+ };
15
+ }
16
+ async function probeAvailability(adapters, scope, fs) {
17
+ return Promise.all(adapters.map(async (adapter) => ({
18
+ adapter,
19
+ available: await adapter.isAvailable(scope, fs).catch(() => false),
20
+ })));
21
+ }
22
+ function isTypedAdapterError(error) {
23
+ return (error instanceof RepoSearchInvalidQueryError || error instanceof RepoSearchInvalidRangeError);
24
+ }
25
+ function describeError(error) {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+ async function runOne(adapter, scope, query, limits, fs, deps) {
29
+ try {
30
+ const atoms = await adapter.lookup(scope, query, limits, fs, deps);
31
+ return { name: adapter.name, atoms, error: undefined };
32
+ }
33
+ catch (error) {
34
+ if (isTypedAdapterError(error)) {
35
+ throw error;
36
+ }
37
+ return {
38
+ name: adapter.name,
39
+ atoms: [],
40
+ error: { name: adapter.name, message: describeError(error) },
41
+ };
42
+ }
43
+ }
44
+ function mergeAtoms(outcomes, cap) {
45
+ const seen = new Set();
46
+ const merged = [];
47
+ for (const outcome of outcomes) {
48
+ for (const atom of outcome.atoms) {
49
+ if (merged.length >= cap) {
50
+ return merged;
51
+ }
52
+ if (seen.has(atom.stableId)) {
53
+ continue;
54
+ }
55
+ seen.add(atom.stableId);
56
+ merged.push(atom);
57
+ }
58
+ }
59
+ return merged;
60
+ }
61
+ export async function runStructuralAdapters(registry, scope, query, limits, fs, deps) {
62
+ const nowMs = deps?.nowMs ?? Date.now;
63
+ const startMs = nowMs();
64
+ const availability = await probeAvailability(registry.adapters, scope, fs);
65
+ const unavailable = [];
66
+ const available = [];
67
+ for (const row of availability) {
68
+ if (row.available) {
69
+ available.push(row.adapter);
70
+ }
71
+ else {
72
+ unavailable.push(row.adapter.name);
73
+ }
74
+ }
75
+ const outcomes = await Promise.all(available.map((adapter) => runOne(adapter, scope, query, limits, fs, deps)));
76
+ const errored = outcomes
77
+ .map((outcome) => outcome.error)
78
+ .filter((error) => error !== undefined);
79
+ const cap = Math.min(limits.maxMatchesReturned, query.maxResults);
80
+ const atoms = mergeAtoms(outcomes, cap);
81
+ return {
82
+ atoms,
83
+ unavailable,
84
+ errored,
85
+ elapsedMs: nowMs() - startMs,
86
+ };
87
+ }
@@ -0,0 +1,4 @@
1
+ import type { AuditSummary, ContextPack, DiscoveryStats, WorkspaceInfo, WorkspaceSummary } from "./types.js";
2
+ export declare function buildWorkspaceSummary(workspace: WorkspaceInfo, pack?: ContextPack, stats?: DiscoveryStats): WorkspaceSummary;
3
+ export declare function summarizeForAudit(pack: ContextPack): AuditSummary;
4
+ //# sourceMappingURL=summary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"summary.d.ts","sourceRoot":"","sources":["../src/summary.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EAEX,cAAc,EACd,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAuBpB,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,aAAa,EACxB,IAAI,CAAC,EAAE,WAAW,EAClB,KAAK,CAAC,EAAE,cAAc,GACrB,gBAAgB,CAYlB;AAID,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,GAAG,YAAY,CAejE"}
@@ -0,0 +1,54 @@
1
+ // The structured, redacted view CLI/SDK/UI render WITHOUT touching the filesystem. Every
2
+ // string that could carry file content is already redacted upstream (context excerpts pass
3
+ // through redact() in discovery/contextPack). This module only reshapes already-safe data;
4
+ // it performs no IO and adds no raw file contents. Pure and deterministic.
5
+ function toContextSummary(pack) {
6
+ return {
7
+ totalCandidates: pack.totalCandidates,
8
+ usedBytes: pack.usedBytes,
9
+ budgetBytes: pack.budgetBytes,
10
+ droppedForBudget: pack.droppedForBudget,
11
+ entries: pack.selected.map((entry) => ({
12
+ path: entry.path,
13
+ sizeBytes: entry.sizeBytes,
14
+ excerptBytes: entry.excerptBytes,
15
+ selectionReason: entry.selectionReason,
16
+ truncated: entry.truncated,
17
+ excerpt: entry.excerpt,
18
+ })),
19
+ };
20
+ }
21
+ function statsFor(pack) {
22
+ return { discovered: pack?.totalCandidates ?? 0, denied: 0, ignored: 0 };
23
+ }
24
+ export function buildWorkspaceSummary(workspace, pack, stats) {
25
+ return {
26
+ root: workspace.root,
27
+ name: workspace.name,
28
+ version: workspace.version,
29
+ testFramework: workspace.testFramework,
30
+ sourceDirs: workspace.sourceDirs,
31
+ testDirs: workspace.testDirs,
32
+ languages: workspace.languages,
33
+ counts: stats ?? statsFor(pack),
34
+ context: pack === undefined ? undefined : toContextSummary(pack),
35
+ };
36
+ }
37
+ // Selected-context metadata for audit evidence: paths, sizes, reasons, and budget usage.
38
+ // Deliberately excludes excerpt TEXT so an audit record carries no file content at all.
39
+ export function summarizeForAudit(pack) {
40
+ return {
41
+ workspaceRoot: pack.workspaceRoot,
42
+ totalCandidates: pack.totalCandidates,
43
+ usedBytes: pack.usedBytes,
44
+ budgetBytes: pack.budgetBytes,
45
+ droppedForBudget: pack.droppedForBudget,
46
+ entries: pack.selected.map((entry) => ({
47
+ path: entry.path,
48
+ sizeBytes: entry.sizeBytes,
49
+ excerptBytes: entry.excerptBytes,
50
+ selectionReason: entry.selectionReason,
51
+ truncated: entry.truncated,
52
+ })),
53
+ };
54
+ }
@@ -0,0 +1,3 @@
1
+ import type { StructuralAdapter } from "./structuralAdapters.js";
2
+ export declare const testSourcePairingAdapter: StructuralAdapter;
3
+ //# sourceMappingURL=testSourcePairing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testSourcePairing.d.ts","sourceRoot":"","sources":["../src/testSourcePairing.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,iBAAiB,EAAyB,MAAM,yBAAyB,CAAC;AAkKxF,eAAO,MAAM,wBAAwB,EAAE,iBAoBtC,CAAC"}