@event4u/agent-config 9.7.0 → 9.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +144 -0
- package/README.md +5 -5
- package/dist/agent-src/commands/commit/in-chunks.md +8 -0
- package/dist/agent-src/commands/commit.md +17 -0
- package/dist/agent-src/commands/pr/create.md +4 -0
- package/dist/agent-src/contexts/override-system.md +75 -5
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +2 -0
- package/dist/agent-src/rules/language-and-tone.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +3 -0
- package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +3 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/telegraph-speak.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/verify-before-complete.md +2 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
- package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
- package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
- package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
- package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
- package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
- package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
- package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
- package/dist/cli/configRoot.js +112 -0
- package/dist/cli/configRoot.js.map +1 -0
- package/dist/cli/configRoot.test.js +97 -0
- package/dist/cli/configRoot.test.js.map +1 -0
- package/dist/cli/help.js +4 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +19 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +161 -44
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -8
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +11 -6
- package/dist/install/install.mjs +16 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/routes/ping.js +18 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/schemas/settings.js +8 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/serverInfo.js +6 -2
- package/dist/server/serverInfo.js.map +1 -1
- package/dist/server/token.js +11 -4
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +15 -1
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/shared/capabilities.js +33 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/capabilities.test.js +39 -0
- package/dist/shared/capabilities.test.js.map +1 -0
- package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
- package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
- package/dist/ui/index.html +14 -5
- package/docs/CLAIMS.md +62 -37
- package/docs/architecture.md +2 -2
- package/docs/benchmark.md +137 -0
- package/docs/catalog.md +8 -3
- package/docs/comparison.yaml +44 -2
- package/docs/contracts/local-server-ports.md +53 -0
- package/docs/contracts/no-runtime-boundary.md +1 -1
- package/docs/contracts/subagent-boundary.md +15 -0
- package/docs/customization.md +14 -3
- package/docs/decisions/ADR-016-installer-architecture.md +2 -0
- package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
- package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
- package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
- package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
- package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
- package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
- package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
- package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
- package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
- package/docs/decisions/INDEX.md +21 -0
- package/docs/decisions/engine-reclassification-2026-07.md +104 -0
- package/docs/distribution/awesome-list-submission.md +1 -1
- package/docs/distribution/registries.md +36 -27
- package/docs/enforcement-by-host.md +7 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guides/gated-platform-reads.md +221 -0
- package/docs/positioning-evidence.md +1 -1
- package/docs/proof.md +92 -16
- package/docs/threat-model.md +1 -0
- package/package.json +5 -2
- package/src/config/agent-settings.template.yml +43 -0
- package/src/config/reach-channels.yml +217 -0
- package/src/config/reach-prescriptions-intake.yml +200 -0
- package/src/scripts/_dispatch.bash +15 -0
- package/src/scripts/_lib/exec_evidence.ts +251 -0
- package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
- package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
- package/src/scripts/_lib/kernel_rules.ts +46 -0
- package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
- package/src/scripts/_lib/risk_paths.ts +160 -0
- package/src/scripts/_lib/secret_detector.test.ts +103 -0
- package/src/scripts/_lib/secret_detector.ts +319 -0
- package/src/scripts/_lib/tool_probe.ts +359 -0
- package/src/scripts/adr/regenerate_index.ts +81 -6
- package/src/scripts/audit_adr_coverage.ts +33 -0
- package/src/scripts/build_proof.ts +162 -0
- package/src/scripts/check_adr_frontmatter.ts +195 -0
- package/src/scripts/check_artefact_count_messaging.ts +4 -0
- package/src/scripts/check_backstop_debt.ts +200 -0
- package/src/scripts/check_claims.ts +188 -8
- package/src/scripts/check_comparison.ts +12 -0
- package/src/scripts/check_enforcement_coverage.ts +592 -0
- package/src/scripts/check_kernel_rule_bundle.ts +16 -12
- package/src/scripts/check_reach_channels.ts +407 -0
- package/src/scripts/check_reach_staleness.ts +364 -0
- package/src/scripts/check_secret_leak.test.ts +47 -0
- package/src/scripts/check_secret_leak.ts +290 -0
- package/src/scripts/code_graph/build.ts +0 -0
- package/src/scripts/code_graph/cli.ts +207 -0
- package/src/scripts/code_graph/detect.ts +134 -0
- package/src/scripts/code_graph/extract.ts +386 -0
- package/src/scripts/code_graph/loader.ts +123 -0
- package/src/scripts/code_graph/query.ts +176 -0
- package/src/scripts/code_graph/sanitize.ts +30 -0
- package/src/scripts/code_graph/types.ts +83 -0
- package/src/scripts/code_graph/validate.ts +63 -0
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/generate_subagent_floor.ts +194 -0
- package/src/scripts/hook_manifest.yaml +13 -3
- package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
- package/src/scripts/iron_law_sha.ts +3 -12
- package/src/scripts/lint_override_kernel_guard.ts +219 -0
- package/src/scripts/measure_rule_budget.ts +3 -12
- package/src/scripts/postinstall_gui.ts +129 -0
- package/src/scripts/reach_doctor.ts +1651 -0
- package/src/scripts/release.ts +24 -3
- package/src/scripts/risk_escalation_shadow.ts +160 -0
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/reach-channels.schema.json +134 -0
- package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/skill_linter.ts +55 -0
- package/src/scripts/validate_reach_prescriptions.ts +751 -0
- package/src/server/schemas/settings.ts +16 -0
- package/src/shared/capabilities.test.ts +43 -0
- package/src/shared/capabilities.ts +75 -0
- package/src/templates/minimal/agents-overrides-readme.md +20 -0
- package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-file extraction — WASM tree-sitter, LLM-free, deterministic.
|
|
3
|
+
*
|
|
4
|
+
* Emits definition nodes and *raw* edges; the second pass in `build.ts`
|
|
5
|
+
* resolves raw-edge targets against the whole-repo symbol table and finalises
|
|
6
|
+
* the honest confidence taxonomy:
|
|
7
|
+
*
|
|
8
|
+
* EXTRACTED — definitions, `use`/`import`, extends/implements, trait use,
|
|
9
|
+
* `new X()`, direct free-function calls.
|
|
10
|
+
* INFERRED — `$this->`/`self::`/`static::`/`parent::` and TS `this.`
|
|
11
|
+
* receivers resolved against the parsed class hierarchy.
|
|
12
|
+
* AMBIGUOUS — dynamic method calls, facades, scoped calls to an unresolved
|
|
13
|
+
* name: kept with candidates, never dropped. This is the
|
|
14
|
+
* expected majority for dynamic-dispatch code (Laravel etc.).
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
type CodeNode,
|
|
18
|
+
type EdgeConfidence,
|
|
19
|
+
type Lang,
|
|
20
|
+
MAX_FILE_BYTES,
|
|
21
|
+
type Relation,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
import { getParser, nodeHasError, type TsNode } from './loader.js';
|
|
24
|
+
|
|
25
|
+
export type CallStyle = 'new' | 'free' | 'this' | 'self' | 'static' | 'parent' | 'scoped' | 'dynamic';
|
|
26
|
+
|
|
27
|
+
export interface RawEdge {
|
|
28
|
+
/** caller / container scope node id (method, function, class, or file). */
|
|
29
|
+
sourceId: string;
|
|
30
|
+
relation: Relation;
|
|
31
|
+
/** bare symbol or method name to resolve in the second pass. */
|
|
32
|
+
targetName: string;
|
|
33
|
+
confidenceHint: EdgeConfidence;
|
|
34
|
+
callStyle?: CallStyle;
|
|
35
|
+
/** enclosing class id, for this/self/static/parent resolution. */
|
|
36
|
+
enclosingClassId?: string;
|
|
37
|
+
/** scope token for `Scope::m()` scoped calls (a class name / self / static). */
|
|
38
|
+
scopeName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileExtract {
|
|
42
|
+
file: string;
|
|
43
|
+
lang: Lang;
|
|
44
|
+
nodes: CodeNode[];
|
|
45
|
+
rawEdges: RawEdge[];
|
|
46
|
+
/** local class id → its declared parent/interface names (2nd-pass inherits). */
|
|
47
|
+
inherits: Array<{ classId: string; parentName: string }>;
|
|
48
|
+
parseError: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loc(n: TsNode): number[] {
|
|
52
|
+
return [
|
|
53
|
+
n.startPosition.row + 1,
|
|
54
|
+
n.startPosition.column + 1,
|
|
55
|
+
n.endPosition.row + 1,
|
|
56
|
+
n.endPosition.column + 1,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function namedChildren(n: TsNode): TsNode[] {
|
|
61
|
+
const out: TsNode[] = [];
|
|
62
|
+
for (let i = 0; i < n.namedChildCount; i += 1) {
|
|
63
|
+
const c = n.namedChild(i);
|
|
64
|
+
if (c) out.push(c);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** First descendant (DFS, named) whose type is in `types`. */
|
|
70
|
+
function firstNamed(n: TsNode, types: ReadonlySet<string>): TsNode | null {
|
|
71
|
+
for (const c of namedChildren(n)) {
|
|
72
|
+
if (types.has(c.type)) return c;
|
|
73
|
+
const deep = firstNamed(c, types);
|
|
74
|
+
if (deep) return deep;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nameField(n: TsNode): string | null {
|
|
80
|
+
const nm = n.childForFieldName('name');
|
|
81
|
+
if (nm) return nm.text;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const NAME_TYPES = new Set(['name', 'identifier', 'qualified_name', 'type_identifier']);
|
|
86
|
+
/** last `\`- or `.`-separated segment of a qualified name. */
|
|
87
|
+
function baseName(raw: string): string {
|
|
88
|
+
const s = raw.trim().replace(/^\\+/, '');
|
|
89
|
+
const parts = s.split(/[\\.]/);
|
|
90
|
+
return parts[parts.length - 1] ?? s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── PHP ──────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function extractPhp(root: TsNode, file: string, out: FileExtract): void {
|
|
96
|
+
const fileId = file;
|
|
97
|
+
|
|
98
|
+
const walk = (n: TsNode, classId: string | null, scopeId: string): void => {
|
|
99
|
+
switch (n.type) {
|
|
100
|
+
case 'namespace_use_declaration': {
|
|
101
|
+
for (const clause of namedChildren(n)) {
|
|
102
|
+
const nameNode = firstNamed(clause, new Set(['qualified_name', 'name']));
|
|
103
|
+
if (nameNode) {
|
|
104
|
+
out.rawEdges.push({
|
|
105
|
+
sourceId: fileId,
|
|
106
|
+
relation: 'imports',
|
|
107
|
+
targetName: baseName(nameNode.text),
|
|
108
|
+
confidenceHint: 'EXTRACTED',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
case 'class_declaration':
|
|
115
|
+
case 'interface_declaration':
|
|
116
|
+
case 'trait_declaration': {
|
|
117
|
+
const nm = nameField(n) ?? '<anon>';
|
|
118
|
+
const kind =
|
|
119
|
+
n.type === 'class_declaration'
|
|
120
|
+
? 'class'
|
|
121
|
+
: n.type === 'interface_declaration'
|
|
122
|
+
? 'interface'
|
|
123
|
+
: 'trait';
|
|
124
|
+
const id = `${file}#${nm}`;
|
|
125
|
+
out.nodes.push({ id, label: nm, kind, source_file: file, source_location: loc(n) });
|
|
126
|
+
out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
|
|
127
|
+
// extends / implements
|
|
128
|
+
for (const c of namedChildren(n)) {
|
|
129
|
+
if (c.type === 'base_clause' || c.type === 'class_interface_clause') {
|
|
130
|
+
for (const nameNode of namedChildren(c)) {
|
|
131
|
+
if (NAME_TYPES.has(nameNode.type)) {
|
|
132
|
+
out.inherits.push({ classId: id, parentName: baseName(nameNode.text) });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const c of namedChildren(n)) walk(c, id, id);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
case 'use_declaration': {
|
|
141
|
+
// trait use inside a class body
|
|
142
|
+
if (classId) {
|
|
143
|
+
for (const nameNode of namedChildren(n)) {
|
|
144
|
+
if (NAME_TYPES.has(nameNode.type)) {
|
|
145
|
+
out.rawEdges.push({
|
|
146
|
+
sourceId: classId,
|
|
147
|
+
relation: 'uses',
|
|
148
|
+
targetName: baseName(nameNode.text),
|
|
149
|
+
confidenceHint: 'EXTRACTED',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
case 'method_declaration':
|
|
157
|
+
case 'function_definition': {
|
|
158
|
+
const nm = nameField(n) ?? '<anon>';
|
|
159
|
+
const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
|
|
160
|
+
out.nodes.push({
|
|
161
|
+
id,
|
|
162
|
+
label: nm,
|
|
163
|
+
kind: n.type === 'method_declaration' ? 'method' : 'function',
|
|
164
|
+
source_file: file,
|
|
165
|
+
source_location: loc(n),
|
|
166
|
+
});
|
|
167
|
+
out.rawEdges.push({
|
|
168
|
+
sourceId: classId ?? fileId,
|
|
169
|
+
relation: 'member',
|
|
170
|
+
targetName: nm,
|
|
171
|
+
confidenceHint: 'EXTRACTED',
|
|
172
|
+
});
|
|
173
|
+
const body = n.childForFieldName('body');
|
|
174
|
+
if (body) for (const c of namedChildren(body)) walk(c, classId, id);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
case 'object_creation_expression': {
|
|
178
|
+
const nameNode = firstNamed(n, new Set(['qualified_name', 'name']));
|
|
179
|
+
if (nameNode)
|
|
180
|
+
out.rawEdges.push({
|
|
181
|
+
sourceId: scopeId,
|
|
182
|
+
relation: 'calls',
|
|
183
|
+
targetName: baseName(nameNode.text),
|
|
184
|
+
confidenceHint: 'EXTRACTED',
|
|
185
|
+
callStyle: 'new',
|
|
186
|
+
});
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'function_call_expression': {
|
|
190
|
+
const fn = n.childForFieldName('function');
|
|
191
|
+
if (fn && NAME_TYPES.has(fn.type))
|
|
192
|
+
out.rawEdges.push({
|
|
193
|
+
sourceId: scopeId,
|
|
194
|
+
relation: 'calls',
|
|
195
|
+
targetName: baseName(fn.text),
|
|
196
|
+
confidenceHint: 'EXTRACTED',
|
|
197
|
+
callStyle: 'free',
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'member_call_expression': {
|
|
202
|
+
const obj = n.childForFieldName('object');
|
|
203
|
+
const nm = n.childForFieldName('name');
|
|
204
|
+
if (nm) {
|
|
205
|
+
const objText = obj?.text ?? '';
|
|
206
|
+
const isThis = objText === '$this';
|
|
207
|
+
out.rawEdges.push({
|
|
208
|
+
sourceId: scopeId,
|
|
209
|
+
relation: 'calls',
|
|
210
|
+
targetName: nm.text,
|
|
211
|
+
confidenceHint: isThis && classId ? 'INFERRED' : 'AMBIGUOUS',
|
|
212
|
+
callStyle: isThis ? 'this' : 'dynamic',
|
|
213
|
+
...(classId ? { enclosingClassId: classId } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'scoped_call_expression': {
|
|
219
|
+
const scope = n.childForFieldName('scope');
|
|
220
|
+
const nm = n.childForFieldName('name');
|
|
221
|
+
if (nm) {
|
|
222
|
+
const scopeText = scope?.text ?? '';
|
|
223
|
+
const isSelf = /^(self|static|parent)$/.test(scopeText);
|
|
224
|
+
out.rawEdges.push({
|
|
225
|
+
sourceId: scopeId,
|
|
226
|
+
relation: 'calls',
|
|
227
|
+
targetName: nm.text,
|
|
228
|
+
confidenceHint: isSelf && classId ? 'INFERRED' : 'AMBIGUOUS',
|
|
229
|
+
callStyle: isSelf ? (scopeText as CallStyle) : 'scoped',
|
|
230
|
+
...(classId ? { enclosingClassId: classId } : {}),
|
|
231
|
+
...(isSelf ? {} : { scopeName: baseName(scopeText) }),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
default:
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
for (const c of namedChildren(n)) walk(c, classId, scopeId);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
out.nodes.push({ id: fileId, label: file, kind: 'file', source_file: file, source_location: [] });
|
|
243
|
+
walk(root, null, fileId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── TypeScript / JavaScript ───────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
|
|
249
|
+
const fileId = file;
|
|
250
|
+
|
|
251
|
+
const walk = (n: TsNode, classId: string | null, scopeId: string): void => {
|
|
252
|
+
switch (n.type) {
|
|
253
|
+
case 'import_statement': {
|
|
254
|
+
const clause = firstNamed(n, new Set(['import_clause']));
|
|
255
|
+
if (clause) {
|
|
256
|
+
for (const spec of namedChildren(clause)) {
|
|
257
|
+
for (const idn of [spec, ...namedChildren(spec)])
|
|
258
|
+
if (idn.type === 'identifier' || idn.type === 'import_specifier') {
|
|
259
|
+
const nm = idn.type === 'import_specifier' ? nameField(idn) ?? idn.text : idn.text;
|
|
260
|
+
out.rawEdges.push({
|
|
261
|
+
sourceId: fileId,
|
|
262
|
+
relation: 'imports',
|
|
263
|
+
targetName: baseName(nm),
|
|
264
|
+
confidenceHint: 'EXTRACTED',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case 'class_declaration': {
|
|
272
|
+
const nm = nameField(n) ?? '<anon>';
|
|
273
|
+
const id = `${file}#${nm}`;
|
|
274
|
+
out.nodes.push({ id, label: nm, kind: 'class', source_file: file, source_location: loc(n) });
|
|
275
|
+
out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
|
|
276
|
+
const heritage = firstNamed(n, new Set(['class_heritage']));
|
|
277
|
+
if (heritage)
|
|
278
|
+
for (const cl of namedChildren(heritage))
|
|
279
|
+
for (const idn of namedChildren(cl))
|
|
280
|
+
if (idn.type === 'identifier' || idn.type === 'type_identifier')
|
|
281
|
+
out.inherits.push({ classId: id, parentName: baseName(idn.text) });
|
|
282
|
+
const body = n.childForFieldName('body');
|
|
283
|
+
if (body) for (const c of namedChildren(body)) walk(c, id, id);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case 'method_definition': {
|
|
287
|
+
const nm = nameField(n) ?? '<anon>';
|
|
288
|
+
const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
|
|
289
|
+
out.nodes.push({ id, label: nm, kind: 'method', source_file: file, source_location: loc(n) });
|
|
290
|
+
out.rawEdges.push({
|
|
291
|
+
sourceId: classId ?? fileId,
|
|
292
|
+
relation: 'member',
|
|
293
|
+
targetName: nm,
|
|
294
|
+
confidenceHint: 'EXTRACTED',
|
|
295
|
+
});
|
|
296
|
+
const body = n.childForFieldName('body');
|
|
297
|
+
if (body) for (const c of namedChildren(body)) walk(c, classId, id);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
case 'function_declaration': {
|
|
301
|
+
const nm = nameField(n) ?? '<anon>';
|
|
302
|
+
const id = `${file}#${nm}`;
|
|
303
|
+
out.nodes.push({ id, label: nm, kind: 'function', source_file: file, source_location: loc(n) });
|
|
304
|
+
out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
|
|
305
|
+
const body = n.childForFieldName('body');
|
|
306
|
+
if (body) for (const c of namedChildren(body)) walk(c, classId, id);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
case 'new_expression': {
|
|
310
|
+
const ctor = n.childForFieldName('constructor');
|
|
311
|
+
if (ctor && (ctor.type === 'identifier' || ctor.type === 'type_identifier' || ctor.type === 'member_expression'))
|
|
312
|
+
out.rawEdges.push({
|
|
313
|
+
sourceId: scopeId,
|
|
314
|
+
relation: 'calls',
|
|
315
|
+
targetName: baseName(ctor.text.split('.').pop() ?? ctor.text),
|
|
316
|
+
confidenceHint: 'EXTRACTED',
|
|
317
|
+
callStyle: 'new',
|
|
318
|
+
});
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case 'call_expression': {
|
|
322
|
+
const fn = n.childForFieldName('function');
|
|
323
|
+
if (fn) {
|
|
324
|
+
if (fn.type === 'identifier') {
|
|
325
|
+
out.rawEdges.push({
|
|
326
|
+
sourceId: scopeId,
|
|
327
|
+
relation: 'calls',
|
|
328
|
+
targetName: fn.text,
|
|
329
|
+
confidenceHint: 'EXTRACTED',
|
|
330
|
+
callStyle: 'free',
|
|
331
|
+
});
|
|
332
|
+
} else if (fn.type === 'member_expression') {
|
|
333
|
+
const obj = fn.childForFieldName('object');
|
|
334
|
+
const prop = fn.childForFieldName('property');
|
|
335
|
+
if (prop) {
|
|
336
|
+
const isThis = obj?.type === 'this';
|
|
337
|
+
out.rawEdges.push({
|
|
338
|
+
sourceId: scopeId,
|
|
339
|
+
relation: 'calls',
|
|
340
|
+
targetName: prop.text,
|
|
341
|
+
confidenceHint: isThis && classId ? 'INFERRED' : 'AMBIGUOUS',
|
|
342
|
+
callStyle: isThis ? 'this' : 'dynamic',
|
|
343
|
+
...(classId ? { enclosingClassId: classId } : {}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
default:
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
for (const c of namedChildren(n)) walk(c, classId, scopeId);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
out.nodes.push({ id: fileId, label: file, kind: 'file', source_file: file, source_location: [] });
|
|
357
|
+
walk(root, null, fileId);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Extract one file. `source` is the file's UTF-8 content; `relPath` is the
|
|
362
|
+
* repo-relative POSIX path used to build collision-free node ids.
|
|
363
|
+
*/
|
|
364
|
+
export async function extractFile(relPath: string, source: string, lang: Lang): Promise<FileExtract> {
|
|
365
|
+
const out: FileExtract = { file: relPath, lang, nodes: [], rawEdges: [], inherits: [], parseError: false };
|
|
366
|
+
if (Buffer.byteLength(source, 'utf-8') > MAX_FILE_BYTES) {
|
|
367
|
+
out.nodes.push({
|
|
368
|
+
id: relPath,
|
|
369
|
+
label: relPath,
|
|
370
|
+
kind: 'skipped',
|
|
371
|
+
source_file: relPath,
|
|
372
|
+
source_location: [],
|
|
373
|
+
});
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
const parser = await getParser(lang);
|
|
377
|
+
const tree = parser.parse(source);
|
|
378
|
+
try {
|
|
379
|
+
out.parseError = nodeHasError(tree.rootNode);
|
|
380
|
+
if (lang === 'php') extractPhp(tree.rootNode, relPath, out);
|
|
381
|
+
else extractTsJs(tree.rootNode, relPath, out);
|
|
382
|
+
} finally {
|
|
383
|
+
tree.delete(); // delete the tree, never the cached parser (loader.ts)
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASM tree-sitter loader for the code-graph engine.
|
|
3
|
+
*
|
|
4
|
+
* Pinned pair (ABI-verified 2026-07-23): `web-tree-sitter@0.24.7` +
|
|
5
|
+
* `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test
|
|
6
|
+
* (`tests/scripts/code_graph_abi.test.ts`) asserts this pairing loads and
|
|
7
|
+
* parses before merge; a bump to either dependency re-runs it. No network,
|
|
8
|
+
* no node-gyp — grammars load from `node_modules` via the resolved path.
|
|
9
|
+
*
|
|
10
|
+
* web-tree-sitter 0.24.x is CJS with a single default export (the Parser
|
|
11
|
+
* class) and the classic `Parser.Language.load()` API; we reach it through
|
|
12
|
+
* `createRequire` so this ESM module stays portable under the repo's tsx
|
|
13
|
+
* runner.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { EXPECTED_GRAMMAR_ABI, GRAMMAR_WASM, type Lang } from './types.js';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
// Minimal structural types for the 0.24.x surface we use (the package ships
|
|
23
|
+
// its own .d.ts, but the CJS-default shape is simplest to pin locally).
|
|
24
|
+
export interface TsNode {
|
|
25
|
+
type: string;
|
|
26
|
+
text: string;
|
|
27
|
+
startPosition: { row: number; column: number };
|
|
28
|
+
endPosition: { row: number; column: number };
|
|
29
|
+
namedChildCount: number;
|
|
30
|
+
childCount: number;
|
|
31
|
+
namedChild(i: number): TsNode | null;
|
|
32
|
+
child(i: number): TsNode | null;
|
|
33
|
+
childForFieldName(field: string): TsNode | null;
|
|
34
|
+
hasError: boolean | (() => boolean);
|
|
35
|
+
}
|
|
36
|
+
export interface TsTree {
|
|
37
|
+
rootNode: TsNode;
|
|
38
|
+
delete(): void;
|
|
39
|
+
}
|
|
40
|
+
export interface TsLanguage {
|
|
41
|
+
version: number;
|
|
42
|
+
}
|
|
43
|
+
export interface TsParser {
|
|
44
|
+
setLanguage(l: TsLanguage): void;
|
|
45
|
+
parse(src: string): TsTree;
|
|
46
|
+
delete(): void;
|
|
47
|
+
}
|
|
48
|
+
interface ParserStatic {
|
|
49
|
+
init(): Promise<void>;
|
|
50
|
+
new (): TsParser;
|
|
51
|
+
Language: { load(wasmPath: string): Promise<TsLanguage> };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let _wasmDir: string | null = null;
|
|
55
|
+
function wasmDir(): string {
|
|
56
|
+
if (_wasmDir === null) {
|
|
57
|
+
_wasmDir = path.join(path.dirname(require.resolve('tree-sitter-wasms/package.json')), 'out');
|
|
58
|
+
}
|
|
59
|
+
return _wasmDir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let _initialized = false;
|
|
63
|
+
const _langCache = new Map<Lang, TsLanguage>();
|
|
64
|
+
const _parserCache = new Map<Lang, TsParser>();
|
|
65
|
+
|
|
66
|
+
export function grammarWasmPath(lang: Lang): string {
|
|
67
|
+
return path.join(wasmDir(), GRAMMAR_WASM[lang]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let _mod: ParserStatic | null = null;
|
|
71
|
+
function _Parser(): ParserStatic {
|
|
72
|
+
// Require exactly once and cache the class reference — repeated
|
|
73
|
+
// createRequire calls under tsx can hand back a module view whose static
|
|
74
|
+
// `Language` is not yet populated, which breaks the second grammar load.
|
|
75
|
+
if (_mod === null) _mod = require('web-tree-sitter') as ParserStatic;
|
|
76
|
+
return _mod;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function _init(): Promise<void> {
|
|
80
|
+
if (!_initialized) {
|
|
81
|
+
await _Parser().init();
|
|
82
|
+
_initialized = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Load (and cache) a grammar; asserts the ABI matches the pinned expectation. */
|
|
87
|
+
export async function loadLanguage(lang: Lang): Promise<TsLanguage> {
|
|
88
|
+
const cached = _langCache.get(lang);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
await _init();
|
|
91
|
+
const language = await _Parser().Language.load(grammarWasmPath(lang));
|
|
92
|
+
if (language.version !== EXPECTED_GRAMMAR_ABI) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`code-graph: grammar ABI drift for ${lang} — got ${language.version}, ` +
|
|
95
|
+
`pinned ${EXPECTED_GRAMMAR_ABI}. Re-pin web-tree-sitter/tree-sitter-wasms ` +
|
|
96
|
+
`and re-run the ABI smoke test.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
_langCache.set(lang, language);
|
|
100
|
+
return language;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A parser bound to `lang`, cached per language for the process lifetime.
|
|
105
|
+
* Callers MUST NOT `.delete()` it — emscripten's runtime is torn down on
|
|
106
|
+
* parser deletion, breaking every subsequent `new Parser()`. Delete only the
|
|
107
|
+
* per-parse `tree` (extract.ts does), so the emscripten heap does not balloon.
|
|
108
|
+
*/
|
|
109
|
+
export async function getParser(lang: Lang): Promise<TsParser> {
|
|
110
|
+
const cached = _parserCache.get(lang);
|
|
111
|
+
if (cached) return cached;
|
|
112
|
+
await _init();
|
|
113
|
+
const language = await loadLanguage(lang);
|
|
114
|
+
const parser = new (_Parser())();
|
|
115
|
+
parser.setLanguage(language);
|
|
116
|
+
_parserCache.set(lang, parser);
|
|
117
|
+
return parser;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Normalise the 0.24.x `hasError` (property) vs newer (method). */
|
|
121
|
+
export function nodeHasError(n: TsNode): boolean {
|
|
122
|
+
return typeof n.hasError === 'function' ? n.hasError() : n.hasError;
|
|
123
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query tier — one engine, two sources (ADR-124 § 2). Runs identically over
|
|
3
|
+
* the native cache and a consumer-shipped `graph.json`; every answer names
|
|
4
|
+
* which source answered. BFS/DFS with a token budget; hybrid seed-matching
|
|
5
|
+
* (exact id / label → BM25 fallback via `_lib/lexical_index.ts`). All string
|
|
6
|
+
* fields pass the retrieved-content sanitizer before returning.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { LexicalIndex, tokenize } from '../_lib/lexical_index.js';
|
|
11
|
+
import { sanitizeLabel } from './sanitize.js';
|
|
12
|
+
import type { CodeEdge, CodeGraph } from './types.js';
|
|
13
|
+
import { validateGraph } from './validate.js';
|
|
14
|
+
|
|
15
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
16
|
+
|
|
17
|
+
export interface LoadedGraph {
|
|
18
|
+
graph: CodeGraph;
|
|
19
|
+
source: string; // attribution path
|
|
20
|
+
byId: Map<string, CodeGraph['nodes'][number]>;
|
|
21
|
+
out: Map<string, CodeEdge[]>;
|
|
22
|
+
in: Map<string, CodeEdge[]>;
|
|
23
|
+
lex: LexicalIndex;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(graphPath, 'utf-8')) as unknown;
|
|
28
|
+
const v = validateGraph(parsed);
|
|
29
|
+
if (!v.ok) throw new Error(`invalid graph at ${graphPath}: ${v.errors.slice(0, 3).join('; ')}`);
|
|
30
|
+
const graph = parsed as CodeGraph;
|
|
31
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
32
|
+
const outM = new Map<string, CodeEdge[]>();
|
|
33
|
+
const inM = new Map<string, CodeEdge[]>();
|
|
34
|
+
const add = (m: Map<string, CodeEdge[]>, k: string, e: CodeEdge): void => {
|
|
35
|
+
const arr = m.get(k);
|
|
36
|
+
if (arr) arr.push(e);
|
|
37
|
+
else m.set(k, [e]);
|
|
38
|
+
};
|
|
39
|
+
for (const e of graph.edges) {
|
|
40
|
+
add(outM, e.source, e);
|
|
41
|
+
add(inM, e.target, e);
|
|
42
|
+
}
|
|
43
|
+
const lex = new LexicalIndex(graph.nodes.map((n) => ({ id: n.id, text: `${n.label} ${n.id}` })));
|
|
44
|
+
return { graph, source, byId, out: outM, in: inM, lex };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolve a free-text seed to node ids: exact id → exact label → BM25. */
|
|
48
|
+
export function resolveSeeds(g: LoadedGraph, seed: string, limit = 5): string[] {
|
|
49
|
+
if (g.byId.has(seed)) return [seed];
|
|
50
|
+
const exactLabel = g.graph.nodes.filter((n) => n.label === seed).map((n) => n.id);
|
|
51
|
+
if (exactLabel.length) return exactLabel.slice(0, limit);
|
|
52
|
+
const ranked = g.lex.rank(tokenize(seed)).filter((r) => r.score > 0);
|
|
53
|
+
return ranked.slice(0, limit).map((r) => r.id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function label(g: LoadedGraph, id: string): string {
|
|
57
|
+
return sanitizeLabel(g.byId.get(id)?.label ?? id);
|
|
58
|
+
}
|
|
59
|
+
function edgeLine(g: LoadedGraph, e: CodeEdge): string {
|
|
60
|
+
const cand = e.candidates?.length ? ` [${e.candidates.map((c) => sanitizeLabel(c)).join(', ')}]` : '';
|
|
61
|
+
return `${e.confidence} ${sanitizeLabel(e.source)} --${e.relation}--> ${sanitizeLabel(e.target)}${cand}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface QueryResult {
|
|
65
|
+
source: string;
|
|
66
|
+
seeds: string[];
|
|
67
|
+
lines: string[];
|
|
68
|
+
truncated: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function budgetedLines(source: string, seeds: string[], lines: string[], budget: number): QueryResult {
|
|
72
|
+
const cap = budget * APPROX_CHARS_PER_TOKEN;
|
|
73
|
+
const kept: string[] = [];
|
|
74
|
+
let used = 0;
|
|
75
|
+
let truncated = false;
|
|
76
|
+
for (const l of lines) {
|
|
77
|
+
if (used + l.length > cap) {
|
|
78
|
+
truncated = true;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
kept.push(l);
|
|
82
|
+
used += l.length + 1;
|
|
83
|
+
}
|
|
84
|
+
return { source, seeds, lines: kept, truncated };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `query <seed>` — the seed's direct (1-hop) outgoing relations. */
|
|
88
|
+
export function query(g: LoadedGraph, seed: string, budget = 1500): QueryResult {
|
|
89
|
+
const seeds = resolveSeeds(g, seed);
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
for (const s of seeds) for (const e of g.out.get(s) ?? []) lines.push(edgeLine(g, e));
|
|
92
|
+
return budgetedLines(g.source, seeds.map((s) => label(g, s)), lines, budget);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `explain <seed>` — 2-hop neighbourhood (out-edges of seeds + their targets). */
|
|
96
|
+
export function explain(g: LoadedGraph, seed: string, budget = 1500): QueryResult {
|
|
97
|
+
const seeds = resolveSeeds(g, seed);
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
const frontier = [...seeds];
|
|
101
|
+
for (let hop = 0; hop < 2 && frontier.length; hop += 1) {
|
|
102
|
+
const next: string[] = [];
|
|
103
|
+
for (const s of frontier.splice(0)) {
|
|
104
|
+
if (seen.has(s)) continue;
|
|
105
|
+
seen.add(s);
|
|
106
|
+
for (const e of g.out.get(s) ?? []) {
|
|
107
|
+
lines.push(edgeLine(g, e));
|
|
108
|
+
next.push(e.target);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
frontier.push(...next);
|
|
112
|
+
}
|
|
113
|
+
return budgetedLines(g.source, seeds.map((s) => label(g, s)), lines, budget);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `affected <target>` — reverse BFS: who calls / references the target. */
|
|
117
|
+
export function affected(g: LoadedGraph, target: string, depth = 2, budget = 1500): QueryResult {
|
|
118
|
+
const seeds = resolveSeeds(g, target);
|
|
119
|
+
const seen = new Set<string>(seeds);
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
let frontier = [...seeds];
|
|
122
|
+
for (let d = 0; d < depth && frontier.length; d += 1) {
|
|
123
|
+
const next: string[] = [];
|
|
124
|
+
for (const t of frontier) {
|
|
125
|
+
for (const e of g.in.get(t) ?? []) {
|
|
126
|
+
lines.push(edgeLine(g, e));
|
|
127
|
+
if (!seen.has(e.source)) {
|
|
128
|
+
seen.add(e.source);
|
|
129
|
+
next.push(e.source);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
frontier = next;
|
|
134
|
+
}
|
|
135
|
+
return budgetedLines(g.source, seeds.map((s) => label(g, s)), lines, budget);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `path <a> <b>` — shortest undirected path (direction preserved per edge). */
|
|
139
|
+
export function path(g: LoadedGraph, a: string, b: string, budget = 1500): QueryResult {
|
|
140
|
+
const starts = resolveSeeds(g, a, 1);
|
|
141
|
+
const goals = new Set(resolveSeeds(g, b, 1));
|
|
142
|
+
if (!starts.length || !goals.size) return { source: g.source, seeds: [], lines: [], truncated: false };
|
|
143
|
+
const start = starts[0] as string;
|
|
144
|
+
const prev = new Map<string, CodeEdge>();
|
|
145
|
+
const seen = new Set<string>([start]);
|
|
146
|
+
let frontier = [start];
|
|
147
|
+
let hit: string | null = goals.has(start) ? start : null;
|
|
148
|
+
while (frontier.length && !hit) {
|
|
149
|
+
const next: string[] = [];
|
|
150
|
+
for (const cur of frontier) {
|
|
151
|
+
const adj = [...(g.out.get(cur) ?? []), ...(g.in.get(cur) ?? [])];
|
|
152
|
+
for (const e of adj) {
|
|
153
|
+
const nb = e.source === cur ? e.target : e.source;
|
|
154
|
+
if (seen.has(nb)) continue;
|
|
155
|
+
seen.add(nb);
|
|
156
|
+
prev.set(nb, e);
|
|
157
|
+
if (goals.has(nb)) {
|
|
158
|
+
hit = nb;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
next.push(nb);
|
|
162
|
+
}
|
|
163
|
+
if (hit) break;
|
|
164
|
+
}
|
|
165
|
+
frontier = next;
|
|
166
|
+
}
|
|
167
|
+
if (!hit) return { source: g.source, seeds: [label(g, start)], lines: ['(no path found)'], truncated: false };
|
|
168
|
+
const chain: CodeEdge[] = [];
|
|
169
|
+
let cur = hit;
|
|
170
|
+
while (prev.has(cur)) {
|
|
171
|
+
const e = prev.get(cur) as CodeEdge;
|
|
172
|
+
chain.unshift(e);
|
|
173
|
+
cur = e.source === cur ? e.target : e.source;
|
|
174
|
+
}
|
|
175
|
+
return budgetedLines(g.source, [label(g, start), label(g, hit)], chain.map((e) => edgeLine(g, e)), budget);
|
|
176
|
+
}
|