@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.
- package/README.md +1 -1
- package/dist/cli.js +29861 -20525
- package/dist/core.js +25871 -0
- package/package.json +16 -2
- package/src/gdgraph/dangling.ts +204 -0
- package/src/gdgraph/find.ts +529 -37
- package/src/gdgraph/repomap.ts +140 -12
- package/src/gdgraph/staleness.ts +22 -9
- package/src/gdgraph/symbol.ts +45 -6
- package/src/gdgraph/treesitter/extract.ts +153 -5
- package/src/gdgraph/wiki-layer.ts +32 -1
- package/src/gdskills/bundled/skills/core/reviewer-skill-creator/SKILL.md +29 -0
- package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.codex.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.cursor.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.opencode.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/code-verifier/SKILL.zed.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +50 -13
- package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.codex.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.cursor.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.opencode.md +1 -1
- package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.zed.md +1 -1
- package/src/gdskills/bundled/skills/quality/security-audit/SKILL.codex.md +52 -5
- package/src/gdskills/bundled/skills/quality/security-audit/SKILL.cursor.md +52 -5
- package/src/gdskills/bundled/skills/quality/security-audit/SKILL.md +52 -5
- package/src/gdskills/bundled/skills/review/review-pr-feedback/SKILL.md +12 -0
- package/src/gdgraph/affected.test.ts +0 -133
- package/src/gdgraph/build-integrity.test.ts +0 -193
- package/src/gdgraph/build-lang.test.ts +0 -406
- package/src/gdgraph/build.test.ts +0 -120
- package/src/gdgraph/config.test.ts +0 -47
- package/src/gdgraph/core-sources.test.ts +0 -99
- package/src/gdgraph/fallback.test.ts +0 -153
- package/src/gdgraph/find.test.ts +0 -78
- package/src/gdgraph/import-kind.test.ts +0 -525
- package/src/gdgraph/path.test.ts +0 -56
- package/src/gdgraph/repomap.test.ts +0 -110
- package/src/gdgraph/service.test.ts +0 -89
- package/src/gdgraph/staleness.test.ts +0 -208
- package/src/gdgraph/symbol.test.ts +0 -89
- package/src/gdgraph/symbols-capability.test.ts +0 -138
- package/src/gdgraph/treesitter/adapter.test.ts +0 -496
- package/src/gdgraph/treesitter/extract.test.ts +0 -278
- package/src/gdgraph/treesitter/no-treesitter-import.test.ts +0 -51
- package/src/gdgraph/treesitter/real-grammar-fixture.test.ts +0 -71
- package/src/gdgraph/treesitter/resolve-calls.test.ts +0 -38
- package/src/gdgraph/wiki-layer-no-git.test.ts +0 -73
- package/src/gdgraph/wiki-layer.test.ts +0 -211
package/src/gdgraph/repomap.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
// until the marker fits (AC3.2 hard bound)
|
|
161
|
-
|
|
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
|
|
package/src/gdgraph/staleness.ts
CHANGED
|
@@ -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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.";
|
package/src/gdgraph/symbol.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
60
|
+
if (ci.length > 0) return tag(ci, "case-insensitive-name");
|
|
32
61
|
|
|
33
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 ];
|
|
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"
|