@mrciphersmith/keryx 0.2.83 → 0.2.88

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +29861 -20525
  3. package/dist/core.js +25871 -0
  4. package/package.json +16 -2
  5. package/src/gdgraph/dangling.ts +204 -0
  6. package/src/gdgraph/find.ts +529 -37
  7. package/src/gdgraph/repomap.ts +140 -12
  8. package/src/gdgraph/staleness.ts +22 -9
  9. package/src/gdgraph/symbol.ts +45 -6
  10. package/src/gdgraph/treesitter/extract.ts +153 -5
  11. package/src/gdgraph/wiki-layer.ts +32 -1
  12. package/src/gdskills/bundled/skills/core/reviewer-skill-creator/SKILL.md +29 -0
  13. package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.codex.md +1 -1
  14. package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.cursor.md +1 -1
  15. package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.md +1 -1
  16. package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.opencode.md +1 -1
  17. package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.zed.md +1 -1
  18. package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +50 -13
  19. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.codex.md +1 -1
  20. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.cursor.md +1 -1
  21. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.md +1 -1
  22. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.opencode.md +1 -1
  23. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.zed.md +1 -1
  24. package/src/gdskills/bundled/skills/quality/security-audit/SKILL.codex.md +52 -5
  25. package/src/gdskills/bundled/skills/quality/security-audit/SKILL.cursor.md +52 -5
  26. package/src/gdskills/bundled/skills/quality/security-audit/SKILL.md +52 -5
  27. package/src/gdskills/bundled/skills/review/review-pr-feedback/SKILL.md +12 -0
  28. package/src/gdgraph/affected.test.ts +0 -133
  29. package/src/gdgraph/build-integrity.test.ts +0 -193
  30. package/src/gdgraph/build-lang.test.ts +0 -406
  31. package/src/gdgraph/build.test.ts +0 -120
  32. package/src/gdgraph/config.test.ts +0 -47
  33. package/src/gdgraph/core-sources.test.ts +0 -99
  34. package/src/gdgraph/fallback.test.ts +0 -153
  35. package/src/gdgraph/find.test.ts +0 -78
  36. package/src/gdgraph/import-kind.test.ts +0 -525
  37. package/src/gdgraph/path.test.ts +0 -56
  38. package/src/gdgraph/repomap.test.ts +0 -110
  39. package/src/gdgraph/service.test.ts +0 -89
  40. package/src/gdgraph/staleness.test.ts +0 -208
  41. package/src/gdgraph/symbol.test.ts +0 -89
  42. package/src/gdgraph/symbols-capability.test.ts +0 -138
  43. package/src/gdgraph/treesitter/adapter.test.ts +0 -496
  44. package/src/gdgraph/treesitter/extract.test.ts +0 -278
  45. package/src/gdgraph/treesitter/no-treesitter-import.test.ts +0 -51
  46. package/src/gdgraph/treesitter/real-grammar-fixture.test.ts +0 -71
  47. package/src/gdgraph/treesitter/resolve-calls.test.ts +0 -38
  48. package/src/gdgraph/wiki-layer-no-git.test.ts +0 -73
  49. package/src/gdgraph/wiki-layer.test.ts +0 -211
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { mkdir, writeFile } from "node:fs/promises";
11
11
  import path from "node:path";
12
+ import type { ContextOverflow } from "../ctx/assembly";
13
+ import { computeAffected } from "./affected";
12
14
  import type { GdgraphConfig } from "./config";
13
15
  import { personalizedPageRank, type RankEdge } from "./pagerank";
14
16
  import type { GraphData, SymbolNode } from "./types";
@@ -17,6 +19,10 @@ export interface RepomapEntry {
17
19
  path: string;
18
20
  score: number;
19
21
  symbols: string[];
22
+ // AFC-12: true for a matched seed, or a direct (depth-1) consumer/test of a
23
+ // matched seed. A required entry is protected from budget/rank eviction —
24
+ // it is not merely another ranked candidate the tie-break can outvote.
25
+ required: boolean;
20
26
  }
21
27
 
22
28
  export interface RepomapOptions {
@@ -30,6 +36,17 @@ export interface RepomapResult {
30
36
  entries: RepomapEntry[];
31
37
  tokens: number;
32
38
  omitted: number;
39
+ // AFC-12: paths of OPTIONAL (non-required) entries dropped for budget —
40
+ // the visible-loss list, named rather than just counted.
41
+ omittedOptional: string[];
42
+ // True when omittedOptional is non-empty — an optional-entry loss occurred.
43
+ partial: boolean;
44
+ // Present only when the required set (seed + known consumers/tests) does
45
+ // not fit the budget as a whole. Mirrors `src/ctx/assembly.ts`'s
46
+ // `ContextOverflow` vocabulary (required-vs-optional, `context_overflow`,
47
+ // `requiredId`) rather than inventing a second one. When set, `entries` is
48
+ // empty: a truncated required set is never reported as an ordinary success.
49
+ overflow?: ContextOverflow;
33
50
  }
34
51
 
35
52
  // Local token estimator — the documented `chars-div-4` default (AC3.2). Kept
@@ -38,11 +55,44 @@ export function estimateTokens(text: string): number {
38
55
  return Math.ceil(text.length / 4);
39
56
  }
40
57
 
41
- // The stable omission marker (AC3.2). `count` varies; the shape is fixed.
58
+ // The stable omission marker (AC3.2). `count` varies; the shape is fixed —
59
+ // UNCHANGED from before AFC-12, so the hard token budget and the existing
60
+ // pop-until-it-fits loop keep their original, tested convergence behavior
61
+ // regardless of how many optional entries are omitted.
42
62
  function omissionMarker(count: number): string {
43
63
  return `\n… ${count} entries omitted …\n`;
44
64
  }
45
65
 
66
+ // AFC-12 visible-loss hint: a SHORT, length-bounded (not proportional to how
67
+ // many paths were dropped) pointer to the full `omittedOptional` list on the
68
+ // result, plus how to continue. Appended best-effort, only if it still fits
69
+ // the budget — it never forces additional entries to be popped. The full,
70
+ // unbounded list of dropped optional paths always lives in the structured
71
+ // `RepomapResult.omittedOptional` field, never only in this rendered text.
72
+ function omissionHint(omittedOptional: readonly string[]): string {
73
+ const noun = omittedOptional.length === 1 ? "entry" : "entries";
74
+ return `${omittedOptional.length} optional ${noun} omitted for budget — see \`omittedOptional\`. Increase --budget or narrow --seed to include them.\n`;
75
+ }
76
+
77
+ // A required entry (seed / known consumer / test) that does not fit the
78
+ // budget as a whole (AFC-12): "budget-exceeded, not success with a truncated
79
+ // required set." No entries are rendered — a partial required set is never
80
+ // presented as legitimate.
81
+ function overflowContent(required: readonly RepomapEntry[], missing: RepomapEntry, budget: number): string {
82
+ const requiredList = required.map((entry) => `- ${entry.path}`).join("\n");
83
+ return [
84
+ HEADER.trimEnd(),
85
+ "",
86
+ `context_overflow: required entry "${missing.path}" does not fit within the ${budget}-token budget.`,
87
+ "",
88
+ "Required (seed + known consumers/tests):",
89
+ requiredList,
90
+ "",
91
+ "Increase --budget, or narrow --seed, and retry.",
92
+ "",
93
+ ].join("\n");
94
+ }
95
+
46
96
  function repomapArtifactPath(cwd: string): string {
47
97
  return path.join(cwd, ".metaproject", "data", "gdgraph", "artifacts", "repomap.md");
48
98
  }
@@ -117,14 +167,36 @@ export function computeRepomap(
117
167
 
118
168
  // Rank file nodes (the stable, always-present layer).
119
169
  const fileNodes = graph.nodes.filter((node) => node.kind === "file").map((node) => node.path);
170
+ const fileNodeSet = new Set(fileNodes);
120
171
  const rankEdges = buildRankEdges(graph, config);
121
172
 
173
+ // Resolve seeds to real graph nodes. A seed matching no node is silently
174
+ // dropped from personalization here, unchanged from prior behavior —
175
+ // resolution diagnostics for an unmatched seed are a separate concern.
176
+ const matchedSeeds: string[] = [];
122
177
  const personalization = new Map<string, number>();
123
178
  for (const seed of options.seed ?? []) {
124
179
  const normalized = seed.replace(/^\.\//, "");
125
180
  const match = fileNodes.find((file) => file === normalized || file.endsWith(normalized));
126
181
  if (match) {
127
182
  personalization.set(match, (personalization.get(match) ?? 0) + 1);
183
+ matchedSeeds.push(match);
184
+ }
185
+ }
186
+
187
+ // AFC-12 required set: a seed is not merely another ranked candidate — the
188
+ // ranking must not be able to outvote an explicit request. A matched seed,
189
+ // plus every direct (depth-1) dependent the graph already knows about
190
+ // (consumers AND tests alike — a test is simply a file that imports the
191
+ // seed), is protected from budget/rank eviction. Reuses `computeAffected`'s
192
+ // existing reverse-dependent closure rather than re-deriving one.
193
+ const requiredSet = new Set<string>(matchedSeeds);
194
+ for (const seed of matchedSeeds) {
195
+ const affected = computeAffected(graph, seed, { depth: 1 });
196
+ for (const dependent of affected.dependents) {
197
+ if (fileNodeSet.has(dependent)) {
198
+ requiredSet.add(dependent);
199
+ }
128
200
  }
129
201
  }
130
202
 
@@ -135,16 +207,51 @@ export function computeRepomap(
135
207
  ...(personalization.size > 0 ? { personalization } : {}),
136
208
  });
137
209
 
138
- const allEntries: RepomapEntry[] = ranked.map((node) => ({
139
- path: node.id,
140
- score: node.score,
141
- symbols: topSymbols(graph, node.id, config.repomap.maxSymbolsPerFile),
142
- }));
210
+ // AFC-12 zero-value ballast: an entry the personalized rank never reached
211
+ // (score exactly 0 — no organic centrality, no path from any seed) carries
212
+ // no information and must not fill the budget. A required entry is kept
213
+ // regardless of its score — required-ness, not score, decides survival,
214
+ // since a genuine consumer/test with no other inbound edges scores 0 too.
215
+ const allEntries: RepomapEntry[] = ranked
216
+ .filter((node) => node.score > 0 || requiredSet.has(node.id))
217
+ .map((node) => ({
218
+ path: node.id,
219
+ score: node.score,
220
+ symbols: topSymbols(graph, node.id, config.repomap.maxSymbolsPerFile),
221
+ required: requiredSet.has(node.id),
222
+ }));
223
+
224
+ const requiredEntries = allEntries.filter((entry) => entry.required);
225
+ const optionalEntries = allEntries.filter((entry) => !entry.required);
143
226
 
144
- // Greedily fill within the budget, always leaving room for the marker.
145
227
  let out = HEADER;
146
228
  const rendered: RepomapEntry[] = [];
147
- for (const entry of allEntries) {
229
+
230
+ // Required entries render first, and ALL of them, or none (AFC-12): "if
231
+ // the seed and its required constraints do not fit as a whole,
232
+ // budget-exceeded, not success with a truncated required set."
233
+ for (const entry of requiredEntries) {
234
+ const trial = out + renderEntryBlock(entry);
235
+ if (estimateTokens(trial) <= budget) {
236
+ out = trial;
237
+ rendered.push(entry);
238
+ } else {
239
+ return {
240
+ path: repomapArtifactPath("."),
241
+ content: overflowContent(requiredEntries, entry, budget),
242
+ entries: [],
243
+ tokens: 0,
244
+ omitted: allEntries.length,
245
+ omittedOptional: [],
246
+ partial: false,
247
+ overflow: { code: "context_overflow", requiredId: entry.path },
248
+ };
249
+ }
250
+ }
251
+
252
+ // Optional entries fill the remaining budget, greedily in rank order
253
+ // (unchanged semantics for the non-required pool).
254
+ for (const entry of optionalEntries) {
148
255
  const trial = out + renderEntryBlock(entry);
149
256
  if (estimateTokens(trial) <= budget) {
150
257
  out = trial;
@@ -154,18 +261,37 @@ export function computeRepomap(
154
261
  }
155
262
  }
156
263
 
157
- let omitted = allEntries.length - rendered.length;
264
+ const renderedPaths = new Set(rendered.map((entry) => entry.path));
265
+ let omittedOptional = optionalEntries
266
+ .filter((entry) => !renderedPaths.has(entry.path))
267
+ .map((entry) => entry.path);
268
+ let omitted = omittedOptional.length;
269
+
158
270
  if (omitted > 0) {
159
- // Ensure the final content (incl. marker) stays within budget; pop entries
160
- // until the marker fits (AC3.2 hard bound).
161
- while (rendered.length > 0 && estimateTokens(out + omissionMarker(omitted)) > budget) {
271
+ // Ensure the final content (incl. marker) stays within budget; pop only
272
+ // OPTIONAL entries until the marker fits (AC3.2 hard bound, UNCHANGED) —
273
+ // a required entry is never sacrificed to make room for the omission
274
+ // notice.
275
+ while (
276
+ rendered.length > 0 &&
277
+ !rendered[rendered.length - 1]!.required &&
278
+ estimateTokens(out + omissionMarker(omitted)) > budget
279
+ ) {
162
280
  const popped = rendered.pop();
163
281
  if (popped) {
164
282
  out = out.slice(0, out.length - renderEntryBlock(popped).length);
283
+ omittedOptional = [...omittedOptional, popped.path];
165
284
  omitted += 1;
166
285
  }
167
286
  }
168
287
  out += omissionMarker(omitted);
288
+
289
+ // Best-effort, length-bounded visible-loss hint (AFC-12) — appended only
290
+ // if it still fits; never forces another entry to be popped.
291
+ const withHint = out + omissionHint(omittedOptional);
292
+ if (estimateTokens(withHint) <= budget) {
293
+ out = withHint;
294
+ }
169
295
  }
170
296
 
171
297
  return {
@@ -174,6 +300,8 @@ export function computeRepomap(
174
300
  entries: rendered,
175
301
  tokens: estimateTokens(out),
176
302
  omitted,
303
+ omittedOptional,
304
+ partial: omittedOptional.length > 0,
177
305
  };
178
306
  }
179
307
 
@@ -190,13 +190,26 @@ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck>
190
190
  return reasons.length > 0 ? { status: "stale", reasons } : { status: "fresh", reasons: [] };
191
191
  }
192
192
 
193
- // Back-compat boolean surface for existing callers (`commands/gdgraph.ts`,
194
- // `wiki/staleness.ts`): "not demonstrably fresh" -> true. `"stale"` and
195
- // `"unknown"` both map to `true` so a git failure can never read as `false`
196
- // ("fresh") the way the old mtime-diff implementation did.
197
- export async function graphMaybeStale(cwd: string): Promise<boolean> {
198
- const result = await checkGraphStaleness(cwd);
199
- return result.status !== "fresh";
200
- }
201
-
193
+ // Flow 237 T11 (F4): the boolean wrapper `graphMaybeStale` used to live here.
194
+ // It mapped BOTH `"stale"` and `"unknown"` to `true`, which was safe for the
195
+ // callers it had (nothing could read a git failure as "fresh") but lossy: the
196
+ // difference between "the repo moved" and "we could not tell" — the whole
197
+ // point of the tri-state, and the difference between STALE_NOTE and
198
+ // UNKNOWN_NOTE below — did not survive the call. T6 moved the last two
199
+ // production callers (`commands/gdgraph.ts`, `wiki/staleness.ts`) onto
200
+ // `checkGraphStaleness`, leaving the wrapper with no caller but its own test:
201
+ // a collapse sitting in the codebase waiting for the next caller to pick it
202
+ // up by accident. There is no boolean surface any more. Callers that only
203
+ // want a yes/no write `(await checkGraphStaleness(cwd)).status !== "fresh"`
204
+ // at the call site, where the discarded distinction is visible in the diff.
202
205
  export const STALE_NOTE = "note: repo moved since the last graph build — `keryx gdgraph build` to refresh.";
206
+
207
+ // Flow 237 T6 (AFC-28/AC-28, "a check that could not run is unknown rather
208
+ // than passed"): a git failure means staleness genuinely could not be
209
+ // determined — it is not evidence the repo moved. Printing `STALE_NOTE`
210
+ // ("repo moved...") for this case asserted something the check never
211
+ // established. Every live caller must print THIS note (with the tri-state's
212
+ // own reasons) for `status: "unknown"`, and reserve `STALE_NOTE` for a
213
+ // confirmed `status: "stale"`.
214
+ export const UNKNOWN_NOTE =
215
+ "note: could not determine whether the graph is stale (see reasons below) — run `keryx gdgraph build` if unsure.";
@@ -10,27 +10,64 @@ export interface SymbolRef {
10
10
  resolved: boolean;
11
11
  }
12
12
 
13
+ /**
14
+ * Which of the three resolution tiers produced a candidate (AC6 / AFC-M04,
15
+ * flow 235: "объяснимые candidates").
16
+ *
17
+ * The tiers always existed inside `resolveSymbols` — exact, then
18
+ * case-insensitive, then substring — but the function returned bare
19
+ * `SymbolNode`s, so the tier that selected each one was computed and thrown
20
+ * away. Measured consequence: `keryx gdgraph symbol "wikiAsk"` listed three
21
+ * definitions with nothing to say which was a real name match and which merely
22
+ * contains the query as a substring. A caller reading that list cannot tell an
23
+ * answer from a coincidence.
24
+ */
25
+ export type SymbolMatchTier = "exact-name" | "case-insensitive-name" | "name-contains-query";
26
+
27
+ export interface SymbolCandidate {
28
+ symbol: SymbolNode;
29
+ tier: SymbolMatchTier;
30
+ }
31
+
13
32
  export interface SymbolQueryResult {
14
33
  query: string;
15
34
  definitions: SymbolNode[];
35
+ /** The tier every definition came from, or `undefined` when nothing matched. */
36
+ matchTier?: SymbolMatchTier;
16
37
  callers: SymbolRef[];
17
38
  callees: SymbolRef[];
18
39
  }
19
40
 
20
41
  // Resolve a name to matching symbols: exact name, then case-insensitive, then
21
42
  // substring — stopping at the first tier that yields hits so precise names win.
22
- export function resolveSymbols(symbols: SymbolNode[], name: string, limit = 25): SymbolNode[] {
43
+ // Each candidate carries the tier that selected it.
44
+ export function resolveSymbolCandidates(
45
+ symbols: SymbolNode[],
46
+ name: string,
47
+ limit = 25,
48
+ ): SymbolCandidate[] {
23
49
  const q = name.trim();
24
50
  if (!q) return [];
25
51
  const lower = q.toLowerCase();
26
52
 
53
+ const tag = (matches: SymbolNode[], tier: SymbolMatchTier): SymbolCandidate[] =>
54
+ matches.slice(0, limit).map((symbol) => ({ symbol, tier }));
55
+
27
56
  const exact = symbols.filter((s) => s.name === q);
28
- if (exact.length > 0) return exact.slice(0, limit);
57
+ if (exact.length > 0) return tag(exact, "exact-name");
29
58
 
30
59
  const ci = symbols.filter((s) => s.name.toLowerCase() === lower);
31
- if (ci.length > 0) return ci.slice(0, limit);
60
+ if (ci.length > 0) return tag(ci, "case-insensitive-name");
32
61
 
33
- return symbols.filter((s) => s.name.toLowerCase().includes(lower)).slice(0, limit);
62
+ return tag(
63
+ symbols.filter((s) => s.name.toLowerCase().includes(lower)),
64
+ "name-contains-query",
65
+ );
66
+ }
67
+
68
+ // The bare-node view, unchanged for every existing caller.
69
+ export function resolveSymbols(symbols: SymbolNode[], name: string, limit = 25): SymbolNode[] {
70
+ return resolveSymbolCandidates(symbols, name, limit).map((candidate) => candidate.symbol);
34
71
  }
35
72
 
36
73
  function labelFor(token: string, byId: Map<string, SymbolNode>): SymbolRef {
@@ -107,7 +144,9 @@ export function transitiveCallers(
107
144
  export function querySymbol(graph: GraphData, name: string): SymbolQueryResult {
108
145
  const symbols = graph.symbols ?? [];
109
146
  const calls = graph.calls ?? [];
110
- const definitions = resolveSymbols(symbols, name);
147
+ const candidates = resolveSymbolCandidates(symbols, name);
148
+ const definitions = candidates.map((candidate) => candidate.symbol);
149
+ const matchTier = candidates[0]?.tier;
111
150
  const ids = new Set(definitions.map((s) => s.id));
112
151
  const byId = new Map(symbols.map((s) => [s.id, s]));
113
152
 
@@ -119,5 +158,5 @@ export function querySymbol(graph: GraphData, name: string): SymbolQueryResult {
119
158
  callEdges.filter((c) => ids.has(c.from)).map((c) => labelFor(c.to, byId)),
120
159
  ).sort((a, b) => a.label.localeCompare(b.label));
121
160
 
122
- return { query: name, definitions, callers, callees };
161
+ return { query: name, definitions, ...(matchTier ? { matchTier } : {}), callers, callees };
123
162
  }
@@ -68,6 +68,151 @@ interface RawSymbol {
68
68
  node: SymbolNode;
69
69
  }
70
70
 
71
+ // --- Built-in method surface -------------------------------------------------
72
+ //
73
+ // Both resolution passes below match a call to a project symbol BY NAME, using
74
+ // only the last dotted segment of the call expression. That threw the receiver
75
+ // away, so `SEMVER_RE.test(value)` — the RegExp method — was recorded as a call
76
+ // to whichever project symbol happens to be named `test`. Measured on this
77
+ // repository before the fix: 1,567 resolved call edges came from member
78
+ // expressions and 873 of them (56%) named a built-in method, including 272
79
+ // `.test` edges all pointing at `SearchController.test`
80
+ // (src/harness/search/controller.ts:40), 254 `console.log`/`Math.log` edges and
81
+ // 174 `Object.entries` edges. `keryx gdgraph symbol wikiAsk` listed
82
+ // `test (src/harness/search/controller.ts:40)` among its callees for exactly
83
+ // this reason: there is no such call.
84
+ //
85
+ // A member call is NOT noise in general — `service.wikiAsk(...)` is a real edge
86
+ // worth keeping — so the fix is not "drop member calls". What a structural walk
87
+ // cannot do is type the receiver: `x.get(k)` is `Map.prototype.get` or a project
88
+ // `get`, and nothing in the syntax says which. The rule below is therefore about
89
+ // OWNERSHIP of the name, not about the shape of the call:
90
+ //
91
+ // * bare identifier call (`helper()`) -> resolve by name (unchanged)
92
+ // * `this` / `self` / `super` receiver -> resolve by name; the
93
+ // receiver is the enclosing
94
+ // object, so the method is
95
+ // project-defined by
96
+ // construction
97
+ // * any other receiver + a name owned by an
98
+ // ECMAScript intrinsic or `console` -> DO NOT resolve
99
+ // * any other receiver + any other name -> resolve by name (unchanged)
100
+ //
101
+ // A call we refuse to resolve is NOT dropped: it stays an `unresolved-call`
102
+ // edge carrying the full source expression (`SEMVER_RE.test`), which
103
+ // `querySymbol` renders through `SymbolRef.resolved:false` and
104
+ // `src/commands/gdgraph.ts` prints as `SEMVER_RE.test (unresolved)`. Silently
105
+ // dropping it would make a real call vanish; resolving it makes a non-call look
106
+ // real. Marking it unresolved is the honest third option, and it reuses the
107
+ // marker the CLI already reads rather than adding a new one.
108
+ //
109
+ // The list is an EXPLICIT frozen set, not `Object.getOwnPropertyNames(...)` of
110
+ // the live intrinsics: the graph is a checked-in artifact and must not shift
111
+ // with the engine (Bun alone adds `console.screenshot`, `console.write`,
112
+ // `Math.sumPrecise`, `Map.prototype.getOrInsert`). Scope is the ECMAScript
113
+ // intrinsic surface plus `console`; host APIs (`stream.write`,
114
+ // `emitter.on`) are deliberately out — see the residuals in the flow report.
115
+ const BUILTIN_METHOD_NAMES: ReadonlySet<string> = new Set([
116
+ // Object (statics + Object.prototype)
117
+ "assign", "create", "defineProperties", "defineProperty", "entries", "freeze",
118
+ "fromEntries", "getOwnPropertyDescriptor", "getOwnPropertyDescriptors",
119
+ "getOwnPropertyNames", "getOwnPropertySymbols", "getPrototypeOf", "groupBy",
120
+ "hasOwn", "hasOwnProperty", "is", "isExtensible", "isFrozen", "isPrototypeOf",
121
+ "isSealed", "keys", "preventExtensions", "propertyIsEnumerable", "seal",
122
+ "setPrototypeOf", "toLocaleString", "toString", "valueOf", "values",
123
+ // Array (statics + Array.prototype)
124
+ "at", "concat", "copyWithin", "every", "fill", "filter", "find", "findIndex",
125
+ "findLast", "findLastIndex", "flat", "flatMap", "forEach", "from", "includes",
126
+ "indexOf", "isArray", "join", "lastIndexOf", "map", "of", "pop", "push",
127
+ "reduce", "reduceRight", "reverse", "shift", "slice", "some", "sort", "splice",
128
+ "toReversed", "toSorted", "toSpliced", "unshift", "with",
129
+ // String (statics + String.prototype)
130
+ "charAt", "charCodeAt", "codePointAt", "endsWith", "fromCharCode",
131
+ "fromCodePoint", "isWellFormed", "localeCompare", "match", "matchAll",
132
+ "normalize", "padEnd", "padStart", "raw", "repeat", "replace", "replaceAll",
133
+ "search", "split", "startsWith", "substr", "substring", "toLocaleLowerCase",
134
+ "toLocaleUpperCase", "toLowerCase", "toUpperCase", "toWellFormed", "trim",
135
+ "trimEnd", "trimStart",
136
+ // Number / Math
137
+ "abs", "acos", "acosh", "asin", "asinh", "atan", "atan2", "atanh", "cbrt",
138
+ "ceil", "clz32", "cos", "cosh", "exp", "expm1", "floor", "fround", "hypot",
139
+ "imul", "isFinite", "isInteger", "isNaN", "isSafeInteger", "log", "log10",
140
+ "log1p", "log2", "max", "min", "parseFloat", "parseInt", "pow", "random",
141
+ "round", "sign", "sin", "sinh", "sqrt", "tan", "tanh", "toExponential",
142
+ "toFixed", "toPrecision", "trunc",
143
+ // JSON
144
+ "parse", "stringify",
145
+ // Map / Set / WeakMap / WeakSet
146
+ "add", "clear", "delete", "difference", "get", "has", "intersection",
147
+ "isDisjointFrom", "isSubsetOf", "isSupersetOf", "set", "symmetricDifference",
148
+ "union",
149
+ // Promise
150
+ "all", "allSettled", "any", "catch", "finally", "race", "reject", "resolve",
151
+ "then", "withResolvers",
152
+ // RegExp
153
+ "compile", "exec", "test",
154
+ // Date (statics + the get*/set*/to* surface)
155
+ "getDate", "getDay", "getFullYear", "getHours", "getMilliseconds",
156
+ "getMinutes", "getMonth", "getSeconds", "getTime", "getTimezoneOffset",
157
+ "getUTCDate", "getUTCDay", "getUTCFullYear", "getUTCHours",
158
+ "getUTCMilliseconds", "getUTCMinutes", "getUTCMonth", "getUTCSeconds", "now",
159
+ "setDate", "setFullYear", "setHours", "setMilliseconds", "setMinutes",
160
+ "setMonth", "setSeconds", "setTime", "setUTCDate", "setUTCFullYear",
161
+ "setUTCHours", "setUTCMilliseconds", "setUTCMinutes", "setUTCMonth",
162
+ "setUTCSeconds", "toDateString", "toISOString", "toJSON",
163
+ "toLocaleDateString", "toLocaleTimeString", "toTimeString", "toUTCString",
164
+ "UTC",
165
+ // Function.prototype / Reflect
166
+ "apply", "bind", "call", "construct", "deleteProperty", "ownKeys",
167
+ // Symbol
168
+ "for", "keyFor",
169
+ // console
170
+ "assert", "count", "countReset", "debug", "dir", "dirxml", "error", "group",
171
+ "groupCollapsed", "groupEnd", "info", "table", "time", "timeEnd", "timeLog",
172
+ "trace", "warn",
173
+ ]);
174
+
175
+ // Receivers that are the enclosing object itself: a method reached through one
176
+ // of these is project-defined by construction, so the built-in guard above must
177
+ // never apply to it (`this.emit(...)` stays a resolved edge).
178
+ const SELF_RECEIVERS: ReadonlySet<string> = new Set(["this", "self", "super"]);
179
+
180
+ interface CalleeShape {
181
+ /** Last dotted segment — the method/function name being invoked. */
182
+ name: string;
183
+ /** Everything before it, or `null` for a bare identifier call. */
184
+ receiver: string | null;
185
+ }
186
+
187
+ // Split a raw callee expression (`SEMVER_RE.test`, `this.emit`, `helper`,
188
+ // `foo.bar(a, b)` from the Java/Python whole-node fallback) into receiver +
189
+ // name, using the same "cut at the first `(`" normalization `lastSegment` has
190
+ // always used.
191
+ function calleeShape(callee: string): CalleeShape {
192
+ const withoutCall = callee.replace(/\(.*$/s, "").trim();
193
+ const dot = withoutCall.lastIndexOf(".");
194
+ if (dot < 0) {
195
+ return { name: withoutCall, receiver: null };
196
+ }
197
+ // `a?.b` / `a!.b` — the optional/non-null marker belongs to the receiver.
198
+ const receiver = withoutCall.slice(0, dot).trim().replace(/[?!]+$/, "");
199
+ return { name: withoutCall.slice(dot + 1).trim(), receiver };
200
+ }
201
+
202
+ // Whether this call expression may be matched to a project symbol by name.
203
+ // See BUILTIN_METHOD_NAMES for the reasoning; `false` leaves the call as an
204
+ // `unresolved-call` edge that still carries the full source expression.
205
+ function isNameResolvableCallee(callee: string): boolean {
206
+ const { name, receiver } = calleeShape(callee);
207
+ if (!name) {
208
+ return false;
209
+ }
210
+ if (receiver === null || SELF_RECEIVERS.has(receiver)) {
211
+ return true;
212
+ }
213
+ return !BUILTIN_METHOD_NAMES.has(name);
214
+ }
215
+
71
216
  // Extract the symbol layer from a parsed tree root. `filePath` is the owning
72
217
  // file path (matches a file GraphNode.path).
73
218
  export function extractSymbolLayer(root: TsNode, filePath: string, language: Language): SymbolLayer {
@@ -131,6 +276,8 @@ export function extractSymbolLayer(root: TsNode, filePath: string, language: Lan
131
276
  }));
132
277
 
133
278
  // Resolve CALL targets to same-file symbols by name; else unresolved-call.
279
+ // A call on a receiver we cannot type (`SEMVER_RE.test`) is never matched by
280
+ // name when the name belongs to a built-in — see BUILTIN_METHOD_NAMES.
134
281
  const nameToId = new Map<string, string>();
135
282
  for (const symbol of symbolNodes) {
136
283
  if (!nameToId.has(symbol.name)) {
@@ -139,7 +286,7 @@ export function extractSymbolLayer(root: TsNode, filePath: string, language: Lan
139
286
  }
140
287
  const resolvedCalls: CallEdge[] = calls.map((call, index) => {
141
288
  const calleeName = lastSegment(call.to);
142
- const target = nameToId.get(calleeName);
289
+ const target = isNameResolvableCallee(call.to) ? nameToId.get(calleeName) : undefined;
143
290
  if (target) {
144
291
  return {
145
292
  id: `call:${call.from}->${target}:${index}`,
@@ -204,7 +351,10 @@ export function resolveCrossFileCalls(symbols: SymbolNode[], calls: CallEdge[]):
204
351
  const out: CallEdge[] = [];
205
352
  for (const call of calls) {
206
353
  let edge = call;
207
- if (call.kind === "unresolved-call") {
354
+ // The same built-in guard the per-file pass applies: without it this pass
355
+ // is strictly WORSE, because a unique project `test` anywhere in the graph
356
+ // captures every `<regexp>.test(...)` in every file.
357
+ if (call.kind === "unresolved-call" && isNameResolvableCallee(call.to)) {
208
358
  const name = lastSegment(call.to);
209
359
  const target = nameCount.get(name) === 1 ? nameToId.get(name) : undefined;
210
360
  if (target && target !== call.from) {
@@ -359,9 +509,7 @@ function renderSignature(node: TsNode, kind: SymbolKind, container: string | nul
359
509
  }
360
510
 
361
511
  function lastSegment(callee: string): string {
362
- const withoutCall = callee.replace(/\(.*$/s, "");
363
- const parts = withoutCall.split(".");
364
- return (parts[parts.length - 1] ?? withoutCall).trim();
512
+ return calleeShape(callee).name;
365
513
  }
366
514
 
367
515
  function firstLine(text: string): string {
@@ -18,6 +18,7 @@ import path from "node:path";
18
18
  import { collectPages } from "../wiki/collect";
19
19
  import { computeModuleKeyFiles } from "../wiki/collect";
20
20
  import { resolveDescribeSet } from "../wiki/describes";
21
+ import { resolveWikiPageIdentity, type PageIdentity } from "../wiki/section-index";
21
22
  import type { DescribesEdge, FileFingerprint, GraphData, WikiLayer, WikiPageNode } from "./types";
22
23
 
23
24
  export const WIKI_PAGES_FILE = "wiki-pages.jsonl";
@@ -28,11 +29,41 @@ function storageDir(projectRoot: string): string {
28
29
  return path.join(projectRoot, ".metaproject", "data", "gdgraph", "storage");
29
30
  }
30
31
 
31
- /** Stable id for a page node. */
32
+ /**
33
+ * Path-derived page id. **Not stable across a rename**, despite what this
34
+ * function's doc comment claimed for two phases.
35
+ *
36
+ * Measured on a real page of this repository's wiki (flow 235, T5), before any
37
+ * change: renaming `architecture/os-sandbox.md` to
38
+ * `architecture/operating-system-sandbox.md` with byte-identical content turned
39
+ * `wiki:architecture/os-sandbox.md` into
40
+ * `wiki:architecture/operating-system-sandbox.md`, and a DIFFERENT page written
41
+ * at the vacated path then answered to the old id — a stale reference resolving
42
+ * to whatever now occupies the position, with no diagnostic anywhere.
43
+ *
44
+ * Kept because it is the correct answer for a page that has never been given an
45
+ * identity, and because it is the id shape already written into
46
+ * `storage/wiki-pages.jsonl`. Use {@link wikiPageIdFor} when the page's content
47
+ * is available: it prefers the authored `keryx:page` marker, which does survive
48
+ * a rename.
49
+ */
32
50
  export function wikiPageId(relativePath: string): string {
33
51
  return `wiki:${relativePath}`;
34
52
  }
35
53
 
54
+ /**
55
+ * The page's identity, preferring the authored marker over the path.
56
+ *
57
+ * This is the single resolver — `../wiki/section-index.ts`'s
58
+ * `resolveWikiPageIdentity` — rather than a second copy of the rule living in
59
+ * the graph layer. A page with a `keryx:page` marker keeps its id through a
60
+ * file rename; a page without one is still addressed by path, and the returned
61
+ * `stability` says so out loud instead of letting a caller assume otherwise.
62
+ */
63
+ export function wikiPageIdFor(relativePath: string, content: string): PageIdentity {
64
+ return resolveWikiPageIdentity(relativePath, content);
65
+ }
66
+
36
67
  export interface BuildWikiLayerInput {
37
68
  projectRoot: string;
38
69
  graph: GraphData;
@@ -17,6 +17,8 @@ triggers:
17
17
  - "make a reviewer from this profile"
18
18
  - "создай ревьюера"
19
19
  - "создай нового ревьюера на основании"
20
+ - "import vantage reviewers"
21
+ - "import overlay reviewers"
20
22
  metadata:
21
23
  author: "MrCipherSmith"
22
24
  version: "1.0.0"
@@ -49,6 +51,31 @@ The parallel is the whole convention. A project reviewer is a project-skill whos
49
51
  finds it by that alone. Anyone who knows where bundled reviewers live knows where
50
52
  these go.
51
53
 
54
+ ## Bulk import of overlay reviewers
55
+
56
+ When the overlays already exist as `review-vantage-*` skill packages (for
57
+ example an overlay skill tree with `review-vantage-*` packages), do not recreate them one by one.
58
+
59
+ ```bash
60
+ keryx skills import --from <overlay-home> --module review
61
+ # alias: keryx review import --from <overlay-home>
62
+ ```
63
+
64
+ That copies every `review-vantage-*` package into
65
+ `.metaproject/project-skills/review/`, stamps Origin on each SKILL.md, and is
66
+ the same discovery `review-orchestrator` uses. Generic copies of keryx
67
+ reviewers (`review-logic`, `review-frontend`, …) are skipped on purpose —
68
+ those would shadow the bundled engine.
69
+
70
+ One overlay package:
71
+
72
+ ```bash
73
+ keryx review import --from <overlay-home>/skills/review-vantage-frontend
74
+ ```
75
+
76
+ Then `keryx review reviewers`. Until that list shows the names, they are not
77
+ wired.
78
+
52
79
  ---
53
80
 
54
81
  ## Workflow
@@ -212,3 +239,5 @@ undocumented is drift that will be silently "fixed" by whoever refreshes next.
212
239
  | Change a reviewer keryx ships | NO | edit `src/gdskills/bundled/skills/review/` and open a PR |
213
240
  | Update a skill from review findings | NO | `entity-skill-learner`, `keryx skills learn` |
214
241
  | Decide which reviewers a round dispatches | NO | `review-orchestrator` |
242
+ | Import a tree of overlay reviewers | YES — `keryx skills import --from <dir> --module review` (`keryx review import` alias) | — |
243
+ | Import a non-review SKILL.md / GitHub URL | NO | `entity-skill-creator` / `keryx skills import` |
@@ -68,7 +68,7 @@ Auto-detect the project stack and available verification tools.
68
68
  ```bash
69
69
  cd <codebase_path>
70
70
 
71
- if [ -f bun.lockb ]; then PM=bun; RUNNER="bun run"
71
+ if [ -f bun.lock ] || [ -f bun.lockb ]; then PM=bun; RUNNER="bun run"
72
72
  elif [ -f pnpm-lock.yaml ]; then PM=pnpm; RUNNER="pnpm run"
73
73
  elif [ -f yarn.lock ]; then PM=yarn; RUNNER="yarn"
74
74
  elif [ -f package-lock.json ]; then PM=npm; RUNNER="npm run"