@mrciphersmith/keryx 0.2.48 → 0.2.50

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -12,13 +12,17 @@
12
12
  import { mkdir, writeFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import {
15
- resolveCapability,
16
15
  runCapabilityOrFallback,
17
16
  type CapabilityAdapter,
18
17
  type CapabilitySpec,
19
18
  } from "../capability/seam";
20
19
  import { loadGdgraphConfig } from "./config";
21
- import { createTreesitterSpec, type BuildInput, type FileRecord } from "./treesitter/adapter";
20
+ import {
21
+ createTreesitterSpec,
22
+ resolveTreesitterCapability,
23
+ type BuildInput,
24
+ type FileRecord,
25
+ } from "./treesitter/adapter";
22
26
  import type { SymbolLayer } from "./types";
23
27
 
24
28
  export interface EnrichResult {
@@ -37,18 +41,31 @@ export type CapabilityResolver = (
37
41
  export async function enrichBuildWithSymbols(
38
42
  cwd: string,
39
43
  files: FileRecord[],
40
- resolve: CapabilityResolver = resolveCapability,
44
+ // `undefined` (the default) selects the T6 literal-import fast path via
45
+ // `resolveTreesitterCapability`; tests pass an explicit `CapabilityResolver`
46
+ // (e.g. a mock adapter, or the raw seam) to bypass it.
47
+ resolve?: CapabilityResolver,
41
48
  ): Promise<EnrichResult> {
42
49
  const config = await loadGdgraphConfig(cwd);
43
- const spec = createTreesitterSpec(cwd, {
50
+ const treesitterConfig = {
44
51
  languages: config.treesitter.languages,
45
52
  grammarsPath: config.treesitter.grammarsPath,
46
- });
53
+ };
47
54
 
48
55
  // Gate 1 (manifest-enabled) + dep + grammar + isAvailable. `null` ⇒ degrade
49
56
  // with NO symbol files written; the seam emits the single warn-once on an
50
- // enabled-but-unavailable ceiling.
51
- const adapter = await resolve(cwd, spec);
57
+ // enabled-but-unavailable ceiling. The default resolver additionally tries a
58
+ // literal `await import("web-tree-sitter")` fast path first (T6) so a
59
+ // `bun build --compile` binary can genuinely parse instead of always
60
+ // degrading — see `resolveTreesitterCapability`.
61
+ //
62
+ // `createTreesitterSpec` is only built here when an injected `resolve` is
63
+ // supplied (tests): `resolveTreesitterCapability` builds its own equivalent
64
+ // spec internally from `treesitterConfig`, so building one unconditionally
65
+ // on the default path would be dead work on every real `gdgraph build` call.
66
+ const adapter = resolve
67
+ ? await resolve(cwd, createTreesitterSpec(cwd, treesitterConfig))
68
+ : await resolveTreesitterCapability(cwd, treesitterConfig);
52
69
  if (!adapter) {
53
70
  return { enriched: false, symbols: 0, calls: 0 };
54
71
  }
@@ -2,12 +2,40 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
- import { expect, test } from "bun:test";
6
- import { createTreesitterSpec, type BuildInput } from "./adapter";
5
+ import { beforeEach, expect, mock, test } from "bun:test";
6
+ import { createTreesitterSpec, resolveTreesitterCapability, type BuildInput } from "./adapter";
7
7
  import type { TsNode } from "./extract";
8
8
  import { enrichBuildWithSymbols } from "../enrich";
9
+ import { hasWarned, resetWarnOnce } from "../../capability/warn-once";
10
+ import { setTreesitterEnabled } from "../symbols-capability";
9
11
  import type { CallEdge, SymbolLayer, SymbolNode } from "../types";
10
12
 
13
+ // Registered at MODULE TOP LEVEL, before any `test()` body runs: Bun loads
14
+ // every test file's static imports/module body in a resolution pass before
15
+ // running any test's body, so a `mock.module` call placed here reliably wins
16
+ // the specifier UNTIL some test anywhere in the same `bun test` invocation
17
+ // performs a real `await import("web-tree-sitter")` first (e.g.
18
+ // `fallback.test.ts`'s AC4.3 test does exactly that with the capability
19
+ // enabled) — once that happens, Bun's module cache is warmed with the real
20
+ // module and this mock's factory is no longer invoked for later imports in
21
+ // the same process. That makes `webTreeSitterImportAttempts` a reliable
22
+ // "definitely zero attempts" signal (nothing increments it unless OUR mock's
23
+ // factory ran) but NOT a reliable "definitely attempted" signal when run
24
+ // alongside the rest of the suite — the enabled-path test below therefore
25
+ // proves "gate 1 passed and we reached the real isAvailable() probe" via the
26
+ // process-scoped warn-once tracker instead, which is correct regardless of
27
+ // which physical module served the import.
28
+ let webTreeSitterImportAttempts = 0;
29
+ mock.module("web-tree-sitter", () => {
30
+ webTreeSitterImportAttempts += 1;
31
+ function MockParser(this: unknown): void {}
32
+ (MockParser as unknown as { init: () => Promise<void> }).init = async () => {};
33
+ (MockParser as unknown as { Language: { load: (p: string) => Promise<unknown> } }).Language = {
34
+ load: async () => ({}),
35
+ };
36
+ return { default: MockParser };
37
+ });
38
+
11
39
  // --- tiny structural mock tree: one top-level function `boot` that calls `tick` ---
12
40
  function mk(o: {
13
41
  type: string;
@@ -247,3 +275,118 @@ test("Python grammar resolution — grammarForFile selects python for .py files"
247
275
  await rm(root, { recursive: true, force: true });
248
276
  }
249
277
  });
278
+
279
+ // --- Real default (no-injected-resolver) production path (T6 review fix) ---
280
+ //
281
+ // Every test above injects an explicit `CapabilityResolver`/`dep`, bypassing
282
+ // `resolveTreesitterCapability` entirely. The REAL call site
283
+ // (`build.ts` → `enrichBuildWithSymbols(projectRoot, fileRecords)`, no
284
+ // resolver argument) goes through `resolveTreesitterCapability`'s literal
285
+ // `await import("web-tree-sitter")` fast path with NO injected resolver at
286
+ // all. These tests exercise that exact default path and, via the
287
+ // module-top-level `mock.module` above, directly observe whether the literal
288
+ // import was attempted — proving gate 1 (manifest-enabled) runs BEFORE the
289
+ // import, not after (the bug this ordering fixes).
290
+
291
+ beforeEach(() => {
292
+ webTreeSitterImportAttempts = 0;
293
+ });
294
+
295
+ async function makeDisabledOrAbsentWorkspace(enabled: "absent" | false): Promise<string> {
296
+ const root = await mkdtemp(path.join(tmpdir(), "keryx-ts-default-"));
297
+ if (enabled === false) {
298
+ await mkdir(path.join(root, ".metaproject"), { recursive: true });
299
+ const manifest = setTreesitterEnabled({}, false);
300
+ await writeFile(
301
+ path.join(root, ".metaproject", "metaproject.json"),
302
+ JSON.stringify(manifest),
303
+ "utf8",
304
+ );
305
+ }
306
+ // enabled === "absent" ⇒ no .metaproject/metaproject.json at all (missing
307
+ // manifest = off, per seam.ts's own contract).
308
+ return root;
309
+ }
310
+
311
+ test("default path, capability DISABLED (missing manifest) — resolveTreesitterCapability never attempts the web-tree-sitter import", async () => {
312
+ const root = await makeDisabledOrAbsentWorkspace("absent");
313
+ try {
314
+ const adapter = await resolveTreesitterCapability(root, {
315
+ languages: ["typescript"],
316
+ grammarsPath: null,
317
+ });
318
+ expect(adapter).toBeNull();
319
+ // The core assertion (Fix 1): a disabled/missing-manifest ceiling must
320
+ // resolve to null WITHOUT ever attempting the literal dep import.
321
+ expect(webTreeSitterImportAttempts).toBe(0);
322
+ } finally {
323
+ await rm(root, { recursive: true, force: true });
324
+ }
325
+ });
326
+
327
+ test("default path, capability DISABLED (enabled: false) — resolveTreesitterCapability never attempts the web-tree-sitter import", async () => {
328
+ const root = await makeDisabledOrAbsentWorkspace(false);
329
+ try {
330
+ const adapter = await resolveTreesitterCapability(root, {
331
+ languages: ["typescript"],
332
+ grammarsPath: null,
333
+ });
334
+ expect(adapter).toBeNull();
335
+ expect(webTreeSitterImportAttempts).toBe(0);
336
+ } finally {
337
+ await rm(root, { recursive: true, force: true });
338
+ }
339
+ });
340
+
341
+ test("default path, capability DISABLED — enrichBuildWithSymbols (no injected resolver) degrades cleanly with no writes and no dep-load attempt", async () => {
342
+ const root = await makeDisabledOrAbsentWorkspace("absent");
343
+ try {
344
+ // No third argument ⇒ the real production default path (build.ts calls
345
+ // enrichBuildWithSymbols with exactly two arguments).
346
+ const result = await enrichBuildWithSymbols(root, [{ path: "src/x.ts", content: "" }]);
347
+ expect(result).toEqual({ enriched: false, symbols: 0, calls: 0 });
348
+ expect(webTreeSitterImportAttempts).toBe(0);
349
+
350
+ const storage = path.join(root, ".metaproject", "data", "gdgraph", "storage");
351
+ await expect(readFile(path.join(storage, "symbols.jsonl"), "utf8")).rejects.toThrow();
352
+ } finally {
353
+ await rm(root, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("default path, capability ENABLED — resolveTreesitterCapability gets past gate 1 and reaches the real isAvailable() probe", async () => {
358
+ resetWarnOnce();
359
+ const root = await mkdtemp(path.join(tmpdir(), "keryx-ts-default-enabled-"));
360
+ try {
361
+ await mkdir(path.join(root, ".metaproject"), { recursive: true });
362
+ const manifest = setTreesitterEnabled({}, true);
363
+ await writeFile(
364
+ path.join(root, ".metaproject", "metaproject.json"),
365
+ JSON.stringify(manifest),
366
+ "utf8",
367
+ );
368
+
369
+ // No grammarsPath and no lockfile entries ⇒ resolveGrammars deterministically
370
+ // resolves zero grammars ⇒ isAvailable() is false, so this proves the
371
+ // manifest gate short-circuits correctly on the enabled side too, without
372
+ // requiring a real compiled binary or a real WASM grammar asset.
373
+ const adapter = await resolveTreesitterCapability(root, {
374
+ languages: ["typescript"],
375
+ grammarsPath: null,
376
+ });
377
+
378
+ // No grammar resolves ⇒ isAvailable() is false ⇒ degrades to null, same
379
+ // AC0-8 catch-and-degrade contract as the seam.
380
+ expect(adapter).toBeNull();
381
+ // Gate 1 passed (unlike the disabled cases above) and execution reached
382
+ // the real `isAvailable()` probe, which reported unavailable and warned
383
+ // once — the process-scoped warn-once tracker is a reliable,
384
+ // ordering-independent witness of this (unlike counting the mock
385
+ // factory's own invocations, which only fires when THIS file's mock wins
386
+ // the module-cache race against any real `web-tree-sitter` import
387
+ // elsewhere in a full-suite run — see the top-of-file comment).
388
+ expect(hasWarned("gdgraph.treesitter")).toBe(true);
389
+ } finally {
390
+ await rm(root, { recursive: true, force: true });
391
+ }
392
+ });
@@ -10,7 +10,8 @@
10
10
  // It NEVER throws out (C0-11): every parse error is caught and the file is
11
11
  // skipped, so an opt-in ceiling can never break the deterministic seam.
12
12
 
13
- import type { CapabilityAdapter, CapabilitySpec } from "../../capability/seam";
13
+ import { isCapabilityEnabled, resolveCapability, type CapabilityAdapter, type CapabilitySpec } from "../../capability/seam";
14
+ import { warnCapabilityDegraded } from "../../capability/warn-once";
14
15
  import type { CallEdge, SymbolLayer, SymbolNode } from "../types";
15
16
  import { extractSymbolLayer, resolveCrossFileCalls, type TsNode } from "./extract";
16
17
  import {
@@ -65,6 +66,89 @@ export function createTreesitterSpec(
65
66
  };
66
67
  }
67
68
 
69
+ // Compiled-binary fast path (T6, keryx-native-distribution).
70
+ //
71
+ // `bun build --compile` cannot statically trace `src/capability/seam.ts`'s
72
+ // generic `await import(spec.optionalDependency)` (a runtime string variable —
73
+ // oven-sh/bun#11732), so in a compiled binary gate 2 of `resolveCapability`
74
+ // always throws for THIS capability even when `web-tree-sitter` is genuinely
75
+ // bundled in. A LITERAL `await import("web-tree-sitter")` DOES bundle and work
76
+ // in a compiled binary (verified empirically this flow).
77
+ //
78
+ // This function tries the literal import first. When it succeeds, it drives
79
+ // the SAME gates the seam applies (1: manifest-enabled, 4: adapter build +
80
+ // isAvailable, with the identical warn-once-on-degrade contract) — it only
81
+ // replaces the ONE line that loads the dependency itself. When the literal
82
+ // import throws (dependency genuinely not installed, e.g. a minimal npm
83
+ // install without optional deps, or `bun run` dev mode where the package is
84
+ // simply missing from node_modules), it falls through UNCHANGED to
85
+ // `resolveCapability(cwd, spec)` — today's exact dev-mode behavior, including
86
+ // the seam's own variable-based `await import(spec.optionalDependency)`
87
+ // attempt and its warn-once messaging. `seam.ts` itself is never modified.
88
+ async function loadTreesitterDepLiteral(): Promise<unknown | undefined> {
89
+ try {
90
+ return await import("web-tree-sitter");
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ export async function resolveTreesitterCapability(
97
+ cwd: string,
98
+ config: TreesitterAdapterConfig,
99
+ resolve: (
100
+ cwd: string,
101
+ spec: CapabilitySpec<BuildInput, SymbolLayer>,
102
+ ) => Promise<CapabilityAdapter<BuildInput, SymbolLayer> | null> = resolveCapability,
103
+ ): Promise<CapabilityAdapter<BuildInput, SymbolLayer> | null> {
104
+ const spec = createTreesitterSpec(cwd, config);
105
+
106
+ try {
107
+ // Gate 1 FIRST — identical check the seam performs, no dep load, no
108
+ // warning when disabled (the normal default path per `seam.ts`'s own
109
+ // contract: "Disabled ⇒ null with NO dep load, NO asset touch, and NO
110
+ // warning"). This MUST run before the literal `import("web-tree-sitter")`
111
+ // below: with the check after, every `gdgraph build` call paid the
112
+ // literal-import cost even with the capability disabled — the bug this
113
+ // ordering fixes.
114
+ if (!(await isCapabilityEnabled(cwd, spec.id))) {
115
+ return null;
116
+ }
117
+
118
+ const literalDep = await loadTreesitterDepLiteral();
119
+ if (literalDep === undefined) {
120
+ // Literal import failed (dependency not actually installed) — defer to
121
+ // the seam's normal variable-based gate 2, unchanged. The seam
122
+ // re-checks gate 1, which is cheap and idempotent.
123
+ return await resolve(cwd, spec);
124
+ }
125
+
126
+ // Gate 2 replaced: the dependency is already loaded via the literal
127
+ // import above, so it is supplied directly instead of re-resolving
128
+ // `spec.optionalDependency` through the seam.
129
+ const adapter = spec.load({ dep: literalDep, asset: null });
130
+
131
+ // Gate 4: `isAvailable()` — same catch-and-degrade contract as the seam
132
+ // (AC0-8): a probe that throws degrades to the deterministic fallback.
133
+ let available: boolean;
134
+ try {
135
+ available = await adapter.isAvailable();
136
+ } catch {
137
+ warnCapabilityDegraded(spec.id, "adapter availability check threw");
138
+ return null;
139
+ }
140
+ if (!available) {
141
+ warnCapabilityDegraded(spec.id, "adapter reported unavailable");
142
+ return null;
143
+ }
144
+
145
+ return adapter;
146
+ } catch {
147
+ // Absolute backstop, mirrors the seam's own contract: never throw out.
148
+ return null;
149
+ }
150
+ }
151
+
68
152
  class TreesitterAdapter implements CapabilityAdapter<BuildInput, SymbolLayer> {
69
153
  readonly id = "gdgraph.treesitter";
70
154
  private grammars: ResolvedGrammar[] = [];
@@ -92,6 +92,46 @@
92
92
  "inherit": { "type": "boolean" }
93
93
  }
94
94
  },
95
+ "runtime": {
96
+ "description": "Which child runtime executes this dispatch (flow 176; docs/requirements/keryx-external-agent-runtime). Additive and optional: an absent block means the native keryx runtime, so every dispatch authored before this package stays valid. Constraints this schema cannot express — the agent resolving in the registry, the sandbox appearing in that agent's supported modes, and read-only versus allowed_actions — are enforced fail-closed by the pure validator in src/harness/external/dispatch.ts.",
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": ["kind"],
100
+ "properties": {
101
+ "kind": {
102
+ "description": "`keryx` runs the in-process agent loop; `external` spawns a vendor CLI subprocess.",
103
+ "type": "string",
104
+ "enum": ["keryx", "external"]
105
+ },
106
+ "agent": {
107
+ "description": "Registry id of the external agent. Required when kind is `external`.",
108
+ "type": "string",
109
+ "minLength": 1
110
+ },
111
+ "sandbox": {
112
+ "description": "Permission level for the external child. `worktree-write` is schema-valid but refused at runtime in the read-only release, with a reason distinguishable from an unsupported-by-this-agent refusal.",
113
+ "type": "string",
114
+ "enum": ["read-only", "worktree-write"]
115
+ },
116
+ "model": {
117
+ "description": "Model id passed to the external CLI. Omit or null to let the CLI resolve its own default under the operator's active subscription — keryx never pins a model the account may not be entitled to.",
118
+ "type": ["string", "null"],
119
+ "minLength": 1
120
+ },
121
+ "timeoutMs": { "type": ["integer", "null"], "minimum": 1000 },
122
+ "maxCostUnits": { "type": ["number", "null"], "exclusiveMinimum": 0 }
123
+ },
124
+ "allOf": [
125
+ {
126
+ "if": { "properties": { "kind": { "const": "external" } }, "required": ["kind"] },
127
+ "then": { "required": ["agent", "sandbox"] }
128
+ },
129
+ {
130
+ "if": { "properties": { "kind": { "const": "keryx" } }, "required": ["kind"] },
131
+ "then": { "not": { "required": ["agent"] } }
132
+ }
133
+ ]
134
+ },
95
135
  "provenance": {
96
136
  "type": "object",
97
137
  "additionalProperties": true,