graphlin 0.1.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/plugin.json +12 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/adapters/README.md +32 -0
- package/adapters/claude/hooks.json +10 -0
- package/adapters/claude/profile.json +18 -0
- package/adapters/codex/hooks.json +9 -0
- package/adapters/codex/profile.json +22 -0
- package/adapters/kiro/profile.json +8 -0
- package/mcp.json +11 -0
- package/package.json +114 -0
- package/plugin.json +20 -0
- package/runtime/collector/index.mjs +23 -0
- package/runtime/core/candidates.mjs +300 -0
- package/runtime/core/common.mjs +69 -0
- package/runtime/core/evidence.mjs +150 -0
- package/runtime/core/graph.mjs +398 -0
- package/runtime/core/index.mjs +4 -0
- package/runtime/core/lexical.mjs +255 -0
- package/runtime/core/privacy.mjs +206 -0
- package/runtime/core/tool-discovery.mjs +122 -0
- package/runtime/daemon/auth.mjs +50 -0
- package/runtime/daemon/connection-info.mjs +249 -0
- package/runtime/daemon/demo.mjs +195 -0
- package/runtime/daemon/diagnostics.mjs +404 -0
- package/runtime/daemon/export.mjs +7 -0
- package/runtime/daemon/ipc.mjs +28 -0
- package/runtime/daemon/lock.mjs +137 -0
- package/runtime/daemon/manager.mjs +320 -0
- package/runtime/daemon/paths.mjs +108 -0
- package/runtime/daemon/persistence.mjs +64 -0
- package/runtime/daemon/server.mjs +292 -0
- package/runtime/daemon/settings.mjs +103 -0
- package/runtime/jev/fixture.mjs +99 -0
- package/runtime/jev/index.mjs +784 -0
- package/runtime/jev/questions.mjs +268 -0
- package/runtime/jev/wire.mjs +152 -0
- package/runtime/pipeline.mjs +1071 -0
- package/runtime/web/app.js +2596 -0
- package/runtime/web/index.html +265 -0
- package/runtime/web/layout.js +336 -0
- package/runtime/web/sidebar.js +525 -0
- package/runtime/web/sketch.js +347 -0
- package/runtime/web/style.css +593 -0
- package/schemas/bundle.schema.json +243 -0
- package/schemas/event.schema.json +108 -0
- package/schemas/graph.schema.json +449 -0
- package/schemas/patch.schema.json +111 -0
- package/scripts/arguments.mjs +37 -0
- package/scripts/build-packages.mjs +160 -0
- package/scripts/collect.sh +23 -0
- package/scripts/collector.mjs +11 -0
- package/scripts/control.mjs +80 -0
- package/scripts/daemon.mjs +28 -0
- package/scripts/graphlin.mjs +112 -0
- package/scripts/onboarding.mjs +413 -0
- package/scripts/validate-packages.mjs +118 -0
- package/skills/graphlin/SKILL.md +103 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVITY_STATES, CLASSIFICATIONS, EVIDENCE_STATES, LIMITS, RELATIONS, ROLES, ROLE_LABELS, ROLE_SHAPES, SHAPES, VALIDITIES,
|
|
3
|
+
clone, equal, exactKeys, fail, freeze, hash, integer, isHash, isId, opaque, plain, probability,
|
|
4
|
+
} from './common.mjs';
|
|
5
|
+
import { createPolicy, metadataEvent, safeLabel, safeText } from './privacy.mjs';
|
|
6
|
+
import { proposalId, validBundle, validSourceRef } from './candidates.mjs';
|
|
7
|
+
|
|
8
|
+
const NODE = ['id', 'label', 'kind', 'shape', 'x', 'y', 'evidenceState', 'activityState', 'classification', 'validity', 'sourceRefs'];
|
|
9
|
+
const EDGE = ['id', 'source', 'target', 'relation', 'label', 'evidenceState', 'classification', 'validity', 'sourceRefs'];
|
|
10
|
+
const REF = ['artifactId', 'hash', 'generation', 'eventId', 'startLine', 'endLine', 'sourceClass', 'basis'];
|
|
11
|
+
// Experimental graph-admission floor v1. Below this, a judgment is not a
|
|
12
|
+
// drawable claim, even when its role is known or its classification says
|
|
13
|
+
// "accepted". This is separate from A's unchanged privacy/relevance gates.
|
|
14
|
+
const EXPERIMENTAL_SUPPORT_FLOOR = 0.5;
|
|
15
|
+
const patchHistory = new WeakMap();
|
|
16
|
+
// Only hashes/versions, never a content or path lookup. Unknown/restored claims
|
|
17
|
+
// cannot regain source labels merely by flipping a display/persistence switch.
|
|
18
|
+
const approvalVersions = new Map();
|
|
19
|
+
const contentKey = item => hash(item);
|
|
20
|
+
const bytes = value => Buffer.byteLength(JSON.stringify(value));
|
|
21
|
+
function approve(item, version) {
|
|
22
|
+
approvalVersions.set(contentKey(item), version);
|
|
23
|
+
if (approvalVersions.size > 8192) approvalVersions.delete(approvalVersions.keys().next().value);
|
|
24
|
+
}
|
|
25
|
+
function preserveApproval(before, after) {
|
|
26
|
+
const version = approvalVersions.get(contentKey(before));
|
|
27
|
+
if (version) approve(after, version);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validReference(ref) {
|
|
31
|
+
if (!exactKeys(ref, REF, ['excerpt', 'sourceRef']) || !isId(ref.artifactId) || !isHash(ref.hash) ||
|
|
32
|
+
!integer(ref.generation, 1) || !isId(ref.eventId) || !integer(ref.startLine, 1, 10000000) ||
|
|
33
|
+
!integer(ref.endLine, ref.startLine, 10000000) || ref.endLine - ref.startLine >= LIMITS.snippetLines ||
|
|
34
|
+
!['source', 'public_intent'].includes(ref.sourceClass) || ref.basis !== 'jev_interpretation' ||
|
|
35
|
+
(Object.hasOwn(ref, 'excerpt') && !safeText(ref.excerpt, LIMITS.excerptChars))) return false;
|
|
36
|
+
if (ref.sourceClass === 'public_intent' && !ref.sourceRef) return false;
|
|
37
|
+
if (ref.sourceRef) {
|
|
38
|
+
if (!validSourceRef(ref.sourceRef)) return false;
|
|
39
|
+
const source = ref.sourceRef;
|
|
40
|
+
if (source.hash !== ref.hash || (source.artifactId ?? source.messageId) !== ref.artifactId ||
|
|
41
|
+
(source.generation ?? source.contentVersion) !== ref.generation ||
|
|
42
|
+
(source.type === 'artifact' ? 'source' : 'public_intent') !== ref.sourceClass) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
function validClaim(item) {
|
|
47
|
+
return isId(item.id) && EVIDENCE_STATES.includes(item.evidenceState) &&
|
|
48
|
+
CLASSIFICATIONS.includes(item.classification) && VALIDITIES.includes(item.validity) &&
|
|
49
|
+
(!Object.hasOwn(item, 'confidence') || probability(item.confidence)) &&
|
|
50
|
+
Array.isArray(item.sourceRefs) && item.sourceRefs.length > 0 && item.sourceRefs.length <= LIMITS.refs &&
|
|
51
|
+
item.sourceRefs.every(validReference) &&
|
|
52
|
+
// Public intent can never certify code or runtime, including through replay.
|
|
53
|
+
(!item.sourceRefs.some(ref => ref.sourceClass === 'public_intent') || ['proposed', 'removed'].includes(item.evidenceState));
|
|
54
|
+
}
|
|
55
|
+
function validNode(node) {
|
|
56
|
+
// Role and shape membership are independent so older persisted combinations
|
|
57
|
+
// remain valid. Only fresh compilation applies the current default mapping.
|
|
58
|
+
return exactKeys(node, NODE, ['confidence']) && validClaim(node) && safeLabel(node.label) &&
|
|
59
|
+
ROLES.includes(node.kind) && SHAPES.includes(node.shape) &&
|
|
60
|
+
Number.isFinite(node.x) && Number.isFinite(node.y) &&
|
|
61
|
+
Math.abs(node.x) <= LIMITS.coordinate && Math.abs(node.y) <= LIMITS.coordinate &&
|
|
62
|
+
ACTIVITY_STATES.includes(node.activityState);
|
|
63
|
+
}
|
|
64
|
+
function validEdge(edge) {
|
|
65
|
+
return exactKeys(edge, EDGE, ['confidence']) && validClaim(edge) &&
|
|
66
|
+
isId(edge.source) && isId(edge.target) && edge.source !== edge.target &&
|
|
67
|
+
RELATIONS.includes(edge.relation) && edge.label === edge.relation;
|
|
68
|
+
}
|
|
69
|
+
function validGraph(graph) {
|
|
70
|
+
if (!exactKeys(graph, ['schemaVersion', 'revision', 'nodes', 'edges']) || graph.schemaVersion !== 1 ||
|
|
71
|
+
!integer(graph.revision) || !Array.isArray(graph.nodes) || graph.nodes.length > LIMITS.nodes ||
|
|
72
|
+
!Array.isArray(graph.edges) || graph.edges.length > LIMITS.edges ||
|
|
73
|
+
!graph.nodes.every(validNode) || !graph.edges.every(validEdge)) return false;
|
|
74
|
+
const nodes = new Set(graph.nodes.map(n => n.id)), edges = new Set(graph.edges.map(e => e.id));
|
|
75
|
+
return nodes.size === graph.nodes.length && edges.size === graph.edges.length &&
|
|
76
|
+
graph.edges.every(edge => nodes.has(edge.source) && nodes.has(edge.target)) && bytes(graph) <= LIMITS.graphBytes;
|
|
77
|
+
}
|
|
78
|
+
function assertGraph(graph) { if (!validGraph(graph)) fail('INVALID_GRAPH'); }
|
|
79
|
+
export function emptyGraph() { return { schemaVersion: 1, revision: 0, nodes: [], edges: [] }; }
|
|
80
|
+
|
|
81
|
+
function validOperation(op) {
|
|
82
|
+
if (op?.op === 'node.upsert') return exactKeys(op, ['op', 'node']) && validNode(op.node);
|
|
83
|
+
if (op?.op === 'edge.upsert') return exactKeys(op, ['op', 'edge']) && validEdge(op.edge);
|
|
84
|
+
return ['node.remove', 'edge.remove'].includes(op?.op) && exactKeys(op, ['op', 'id']) && isId(op.id);
|
|
85
|
+
}
|
|
86
|
+
function validPatch(patch) {
|
|
87
|
+
return exactKeys(patch, ['schemaVersion', 'id', 'baseRevision', 'revision', 'causedBy', 'operations']) &&
|
|
88
|
+
patch.schemaVersion === 1 && (isId(patch.id) || patch.id === 'restore') &&
|
|
89
|
+
integer(patch.baseRevision) && integer(patch.revision, 1) && patch.revision === patch.baseRevision + 1 &&
|
|
90
|
+
Array.isArray(patch.causedBy) && patch.causedBy.length <= LIMITS.candidates && patch.causedBy.every(isId) &&
|
|
91
|
+
Array.isArray(patch.operations) && patch.operations.length > 0 && patch.operations.length <= LIMITS.operations &&
|
|
92
|
+
patch.operations.every(validOperation);
|
|
93
|
+
}
|
|
94
|
+
function reduce(graph, operations) {
|
|
95
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, clone(node)]));
|
|
96
|
+
const edges = new Map(graph.edges.map(edge => [edge.id, clone(edge)]));
|
|
97
|
+
for (const op of operations) {
|
|
98
|
+
if (op.op === 'node.upsert') nodes.set(op.node.id, clone(op.node));
|
|
99
|
+
else if (op.op === 'edge.upsert') edges.set(op.edge.id, clone(op.edge));
|
|
100
|
+
else if (op.op === 'edge.remove') edges.delete(op.id);
|
|
101
|
+
else {
|
|
102
|
+
nodes.delete(op.id);
|
|
103
|
+
for (const [id, edge] of edges) if (edge.source === op.id || edge.target === op.id) edges.delete(id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { schemaVersion: 1, revision: graph.revision + 1, nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
107
|
+
}
|
|
108
|
+
export function applyPatch(graph, patch) {
|
|
109
|
+
assertGraph(graph);
|
|
110
|
+
if (!validPatch(patch)) fail('INVALID_PATCH');
|
|
111
|
+
const history = patchHistory.get(graph) ?? new Map(), fingerprint = hash(patch);
|
|
112
|
+
if (history.has(patch.id)) {
|
|
113
|
+
if (history.get(patch.id) !== fingerprint) fail('PATCH_ID_CONFLICT');
|
|
114
|
+
return graph;
|
|
115
|
+
}
|
|
116
|
+
if (patch.baseRevision !== graph.revision) fail('REVISION_CONFLICT');
|
|
117
|
+
// All operations and final endpoints are checked before publishing a result.
|
|
118
|
+
const result = reduce(graph, patch.operations);
|
|
119
|
+
assertGraph(result);
|
|
120
|
+
const nextHistory = new Map(history);
|
|
121
|
+
nextHistory.set(patch.id, fingerprint);
|
|
122
|
+
if (nextHistory.size > 1024) nextHistory.delete(nextHistory.keys().next().value);
|
|
123
|
+
patchHistory.set(result, nextHistory);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makePatch(graph, operations, causedBy = []) {
|
|
128
|
+
if (!operations.length || graph.revision === Number.MAX_SAFE_INTEGER) return null;
|
|
129
|
+
return freeze({
|
|
130
|
+
schemaVersion: 1, id: opaque('patch', graph.revision, causedBy, operations),
|
|
131
|
+
baseRevision: graph.revision, revision: graph.revision + 1, causedBy, operations,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function sourceReference(candidate, event, policy) {
|
|
135
|
+
const ref = {
|
|
136
|
+
artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation, eventId: event.id,
|
|
137
|
+
startLine: candidate.startLine, endLine: candidate.endLine, sourceClass: candidate.sourceClass,
|
|
138
|
+
basis: 'jev_interpretation', sourceRef: clone(candidate.sourceRef),
|
|
139
|
+
};
|
|
140
|
+
if (policy.displayEvidence || policy.persistEvidence) {
|
|
141
|
+
const excerpt = candidate.text.slice(0, LIMITS.excerptChars);
|
|
142
|
+
if (safeText(excerpt, LIMITS.excerptChars)) ref.excerpt = excerpt;
|
|
143
|
+
}
|
|
144
|
+
return ref;
|
|
145
|
+
}
|
|
146
|
+
const refKey = ref => JSON.stringify([ref.artifactId, ref.hash, ref.generation, ref.startLine, ref.endLine, ref.sourceClass]);
|
|
147
|
+
function mergeReferences(old, fresh) {
|
|
148
|
+
const replaced = new Set(fresh.map(ref => ref.artifactId));
|
|
149
|
+
const merged = new Map();
|
|
150
|
+
for (const ref of old.filter(ref => !replaced.has(ref.artifactId))) merged.set(refKey(ref), ref);
|
|
151
|
+
for (const ref of fresh) merged.set(refKey(ref), ref);
|
|
152
|
+
return [...merged.values()].slice(-LIMITS.refs);
|
|
153
|
+
}
|
|
154
|
+
function validNodeJudgment(node, ids) {
|
|
155
|
+
if (!exactKeys(node, ['candidateId', 'role', 'supportProbability', 'roleProbability', 'roleConfidence', 'roleProbabilities', 'classification']) ||
|
|
156
|
+
!ids.has(node.candidateId) || ![...ROLES, 'unknown'].includes(node.role) ||
|
|
157
|
+
!probability(node.supportProbability) || !probability(node.roleProbability) || !probability(node.roleConfidence) ||
|
|
158
|
+
!['accepted', 'tentative'].includes(node.classification) || !plain(node.roleProbabilities)) return false;
|
|
159
|
+
const entries = Object.entries(node.roleProbabilities);
|
|
160
|
+
return entries.length > 0 && entries.length <= ROLES.length + 1 && entries.every(([role, p]) => [...ROLES, 'unknown'].includes(role) && probability(p)) &&
|
|
161
|
+
Math.abs(entries.reduce((sum, [, p]) => sum + p, 0) - 1) <= 0.0100000001 &&
|
|
162
|
+
node.roleProbabilities[node.role] === node.roleProbability &&
|
|
163
|
+
entries.every(([, p]) => p <= node.roleProbability + 1e-12);
|
|
164
|
+
}
|
|
165
|
+
function validEdgeJudgment(edge, bundle, ids) {
|
|
166
|
+
if (!exactKeys(edge, ['proposalId', 'sourceCandidateId', 'targetCandidateId', 'relation', 'evidenceCandidateIds',
|
|
167
|
+
'supportProbability', 'missingContextProbability', 'classification']) ||
|
|
168
|
+
!ids.has(edge.sourceCandidateId) || !ids.has(edge.targetCandidateId) ||
|
|
169
|
+
edge.sourceCandidateId === edge.targetCandidateId || !RELATIONS.includes(edge.relation) ||
|
|
170
|
+
!Array.isArray(edge.evidenceCandidateIds) || edge.evidenceCandidateIds.length < 2 ||
|
|
171
|
+
edge.evidenceCandidateIds.length > LIMITS.candidates ||
|
|
172
|
+
new Set(edge.evidenceCandidateIds).size !== edge.evidenceCandidateIds.length ||
|
|
173
|
+
!edge.evidenceCandidateIds.every(id => ids.has(id)) ||
|
|
174
|
+
![edge.sourceCandidateId, edge.targetCandidateId].every(id => edge.evidenceCandidateIds.includes(id)) ||
|
|
175
|
+
!probability(edge.supportProbability) || !probability(edge.missingContextProbability) ||
|
|
176
|
+
!['accepted', 'tentative'].includes(edge.classification)) return false;
|
|
177
|
+
return edge.proposalId === proposalId(bundle, edge.sourceCandidateId, edge.targetCandidateId, edge.relation, edge.evidenceCandidateIds);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Diagnostics contain only fixed outcomes and opaque IDs. Buffer them until
|
|
181
|
+
// the patch is complete so a callback cannot interfere with later admissions.
|
|
182
|
+
// The bound is 12 candidate outcomes + 7 proposal outcomes + one null summary.
|
|
183
|
+
function compilerAudit(onDiagnostic) {
|
|
184
|
+
const outcomes = typeof onDiagnostic === 'function' ? [] : null;
|
|
185
|
+
function record(status, reason, field, id) {
|
|
186
|
+
if (!outcomes || outcomes.length >= LIMITS.candidates + LIMITS.proposals + 1) return;
|
|
187
|
+
const prefix = field === 'candidateId' ? 'candidate-' : 'proposal-';
|
|
188
|
+
outcomes.push({ ...(field && isId(id) && id.startsWith(prefix) ? { [field]: id } : {}), status, reason });
|
|
189
|
+
}
|
|
190
|
+
function dataValue(object, key) {
|
|
191
|
+
// Rejecting malformed inputs must not invoke additional caller getters.
|
|
192
|
+
try { return Object.getOwnPropertyDescriptor(object, key)?.value; } catch { return undefined; }
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
record,
|
|
196
|
+
reject(decision, reason) {
|
|
197
|
+
if (outcomes) for (const [key, field, limit] of [
|
|
198
|
+
['nodes', 'candidateId', LIMITS.candidates], ['edges', 'proposalId', LIMITS.proposals],
|
|
199
|
+
]) {
|
|
200
|
+
const judgments = dataValue(decision, key);
|
|
201
|
+
if (Array.isArray(judgments)) for (let i = 0; i < Math.min(judgments.length, limit); i++) {
|
|
202
|
+
record('skipped', reason, field, dataValue(dataValue(judgments, i), field));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
record('skipped', reason);
|
|
206
|
+
return null;
|
|
207
|
+
},
|
|
208
|
+
finish(patch, empty) {
|
|
209
|
+
if (!patch && outcomes) {
|
|
210
|
+
if (outcomes.some(outcome => ['added', 'updated'].includes(outcome.status))) {
|
|
211
|
+
// makePatch refuses an exhausted revision even if upserts were built.
|
|
212
|
+
for (const outcome of outcomes) if (['added', 'updated'].includes(outcome.status)) {
|
|
213
|
+
outcome.status = 'skipped'; outcome.reason = 'revision_limit';
|
|
214
|
+
}
|
|
215
|
+
record('skipped', 'revision_limit');
|
|
216
|
+
} else if (empty) record('skipped', 'empty_decision');
|
|
217
|
+
else if (outcomes.some(outcome => outcome.status === 'unchanged')) record('unchanged', 'no_change');
|
|
218
|
+
else record('skipped', 'no_drawable_change');
|
|
219
|
+
}
|
|
220
|
+
return patch;
|
|
221
|
+
},
|
|
222
|
+
flush() {
|
|
223
|
+
for (const outcome of outcomes ?? []) {
|
|
224
|
+
try {
|
|
225
|
+
// Do not await observers; also contain rejected async callbacks.
|
|
226
|
+
Promise.resolve(onDiagnostic(Object.freeze(outcome))).catch(() => {});
|
|
227
|
+
} catch { /* Observability cannot change the compiler result. */ }
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function compileDecision(graph, { event, decision, policy, onDiagnostic } = {}) {
|
|
234
|
+
const audit = compilerAudit(onDiagnostic);
|
|
235
|
+
try { return compileAuditedDecision(graph, { event, decision, policy }, audit); }
|
|
236
|
+
finally { audit.flush(); }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function compileAuditedDecision(graph, { event, decision, policy }, audit) {
|
|
240
|
+
try { assertGraph(graph); }
|
|
241
|
+
catch (error) { audit.record('skipped', 'invalid_graph'); throw error; }
|
|
242
|
+
policy = createPolicy(policy);
|
|
243
|
+
if (!['accepted', 'abstained'].includes(decision?.status)) return audit.reject(decision, 'decision_not_compilable');
|
|
244
|
+
if (!validBundle(decision.bundle, policy)) return audit.reject(decision, 'invalid_bundle');
|
|
245
|
+
if (!plain(event) || ['tool.requested', 'capture.gap'].includes(event.kind)) return audit.reject(decision, 'invalid_event');
|
|
246
|
+
event = metadataEvent(event);
|
|
247
|
+
const bundle = decision.bundle, candidates = new Map(bundle.candidates.map(c => [c.id, c]));
|
|
248
|
+
if (!Array.isArray(decision.nodes) || !Array.isArray(decision.edges)) return audit.reject(decision, 'invalid_judgments');
|
|
249
|
+
if (decision.nodes.length > LIMITS.candidates || decision.edges.length > LIMITS.proposals) return audit.reject(decision, 'judgment_limit');
|
|
250
|
+
if (new Set(decision.nodes.map(n => n?.candidateId)).size !== decision.nodes.length ||
|
|
251
|
+
new Set(decision.edges.map(e => e?.proposalId)).size !== decision.edges.length) return audit.reject(decision, 'duplicate_judgments');
|
|
252
|
+
if (!decision.nodes.every(n => validNodeJudgment(n, candidates)) ||
|
|
253
|
+
!decision.edges.every(e => validEdgeJudgment(e, bundle, candidates))) return audit.reject(decision, 'invalid_judgments');
|
|
254
|
+
const existingNodes = new Map(graph.nodes.map(n => [n.id, n]));
|
|
255
|
+
const existingEdges = new Map(graph.edges.map(e => [e.id, e]));
|
|
256
|
+
const admitted = new Map(), operations = [];
|
|
257
|
+
let projected = graph;
|
|
258
|
+
function add(op) {
|
|
259
|
+
const next = reduce(projected, [op]);
|
|
260
|
+
next.revision = graph.revision + 1;
|
|
261
|
+
if (next.nodes.length > LIMITS.nodes) return 'node_limit';
|
|
262
|
+
if (next.edges.length > LIMITS.edges) return 'edge_limit';
|
|
263
|
+
if (bytes(next) > LIMITS.admissionBytes) return 'graph_byte_limit';
|
|
264
|
+
operations.push(op); projected = next; return null;
|
|
265
|
+
}
|
|
266
|
+
for (const judgment of decision.nodes) {
|
|
267
|
+
const report = (status, reason) => audit.record(status, reason, 'candidateId', judgment.candidateId);
|
|
268
|
+
if (judgment.role === 'unknown') { report('skipped', 'unknown_role'); continue; }
|
|
269
|
+
if (judgment.supportProbability < EXPERIMENTAL_SUPPORT_FLOOR) { report('skipped', 'support_below_floor'); continue; }
|
|
270
|
+
const candidate = candidates.get(judgment.candidateId);
|
|
271
|
+
const id = opaque('node', event.projectId, event.sessionId, candidate.entityKey), old = existingNodes.get(id);
|
|
272
|
+
// A stale record with the same version needs a newly captured generation.
|
|
273
|
+
// A delayed answer cannot revive it merely because its bytes match again.
|
|
274
|
+
if (old && approvalVersions.has(contentKey(old)) && old.sourceRefs.some(ref => ref.artifactId === candidate.artifactId &&
|
|
275
|
+
(ref.generation > candidate.generation || (old.validity !== 'current' && ref.generation === candidate.generation)))) {
|
|
276
|
+
report('skipped', 'stale_generation'); continue;
|
|
277
|
+
}
|
|
278
|
+
const classification = judgment.classification === 'accepted' && candidate.complete && !event.incomplete &&
|
|
279
|
+
judgment.supportProbability >= 0.85 && judgment.roleProbability >= 0.8 && judgment.roleConfidence >= 0.6 ? 'accepted' : 'tentative';
|
|
280
|
+
const index = projected.nodes.length;
|
|
281
|
+
const refs = mergeReferences(old?.sourceRefs ?? [], [sourceReference(candidate, event, policy)]);
|
|
282
|
+
const node = {
|
|
283
|
+
id, label: candidate.label, kind: judgment.role, shape: ROLE_SHAPES[judgment.role],
|
|
284
|
+
x: old?.x ?? 80 + (index % 6) * 220, y: old?.y ?? 80 + Math.floor(index / 6) * 140,
|
|
285
|
+
evidenceState: refs.some(ref => ref.sourceClass === 'public_intent') ? 'proposed' : 'observed',
|
|
286
|
+
activityState: 'unknown', classification, validity: 'current', sourceRefs: refs, confidence: judgment.roleConfidence,
|
|
287
|
+
};
|
|
288
|
+
if (old && equal(old, node)) { admitted.set(candidate.id, old); report('unchanged', 'already_current'); continue; }
|
|
289
|
+
const rejected = add({ op: 'node.upsert', node });
|
|
290
|
+
if (rejected) report('skipped', rejected);
|
|
291
|
+
else { approve(node, policy.version); admitted.set(candidate.id, node); report(old ? 'updated' : 'added', 'admitted'); }
|
|
292
|
+
}
|
|
293
|
+
for (const judgment of decision.edges) {
|
|
294
|
+
const report = (status, reason) => audit.record(status, reason, 'proposalId', judgment.proposalId);
|
|
295
|
+
if (judgment.supportProbability < EXPERIMENTAL_SUPPORT_FLOOR) { report('skipped', 'support_below_floor'); continue; }
|
|
296
|
+
const source = admitted.get(judgment.sourceCandidateId), target = admitted.get(judgment.targetCandidateId);
|
|
297
|
+
if (!source || !target || source.id === target.id) { report('skipped', 'endpoints_not_drawable'); continue; }
|
|
298
|
+
const id = opaque('edge', source.id, target.id, judgment.relation), old = existingEdges.get(id);
|
|
299
|
+
const evidence = judgment.evidenceCandidateIds.map(id => candidates.get(id));
|
|
300
|
+
if (old && approvalVersions.has(contentKey(old)) && old.sourceRefs.some(ref => evidence.some(c => ref.artifactId === c.artifactId &&
|
|
301
|
+
(ref.generation > c.generation || (old.validity !== 'current' && ref.generation === c.generation))))) {
|
|
302
|
+
report('skipped', 'stale_generation'); continue;
|
|
303
|
+
}
|
|
304
|
+
const refs = mergeReferences([], evidence.map(c => sourceReference(c, event, policy)));
|
|
305
|
+
// Every relation retains all dependencies; never silently drop evidence
|
|
306
|
+
// when the reference budget is exceeded.
|
|
307
|
+
if (refs.length !== new Set(evidence.map(c => refKey(sourceReference(c, event, policy)))).size) {
|
|
308
|
+
report('skipped', 'reference_limit'); continue;
|
|
309
|
+
}
|
|
310
|
+
const classification = judgment.classification === 'accepted' && source.classification === 'accepted' &&
|
|
311
|
+
target.classification === 'accepted' && evidence.every(c => c.complete) && !event.incomplete &&
|
|
312
|
+
judgment.supportProbability >= 0.85 && judgment.missingContextProbability <= 0.1 ? 'accepted' : 'tentative';
|
|
313
|
+
const edge = {
|
|
314
|
+
id, source: source.id, target: target.id, relation: judgment.relation, label: judgment.relation,
|
|
315
|
+
evidenceState: refs.some(ref => ref.sourceClass === 'public_intent') ? 'proposed' : 'observed',
|
|
316
|
+
classification, validity: 'current', sourceRefs: refs,
|
|
317
|
+
};
|
|
318
|
+
if (old && equal(old, edge)) { report('unchanged', 'already_current'); continue; }
|
|
319
|
+
const rejected = add({ op: 'edge.upsert', edge });
|
|
320
|
+
if (rejected) report('skipped', rejected);
|
|
321
|
+
else { approve(edge, policy.version); report(old ? 'updated' : 'added', 'admitted'); }
|
|
322
|
+
}
|
|
323
|
+
return audit.finish(makePatch(graph, operations, [event.id]), !decision.nodes.length && !decision.edges.length);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function invalidateArtifacts(graph, artifacts = []) {
|
|
327
|
+
assertGraph(graph);
|
|
328
|
+
if (!Array.isArray(artifacts)) return null;
|
|
329
|
+
const observations = new Map();
|
|
330
|
+
for (const artifact of artifacts.slice(0, LIMITS.trackedPaths)) {
|
|
331
|
+
if (!plain(artifact) || !isId(artifact.id) || !integer(artifact.generation, 1) ||
|
|
332
|
+
!['present', 'missing', 'unavailable', 'partial'].includes(artifact.status)) continue;
|
|
333
|
+
const previous = observations.get(artifact.id);
|
|
334
|
+
if (!previous || artifact.generation > previous.generation) observations.set(artifact.id, artifact);
|
|
335
|
+
}
|
|
336
|
+
const operations = [];
|
|
337
|
+
for (const [kind, items] of [['node', graph.nodes], ['edge', graph.edges]]) for (const item of items) {
|
|
338
|
+
let changed = false;
|
|
339
|
+
const refs = item.sourceRefs.filter(ref => {
|
|
340
|
+
if (ref.sourceClass !== 'source') return true;
|
|
341
|
+
const artifact = observations.get(ref.artifactId);
|
|
342
|
+
if (!artifact || artifact.generation < ref.generation) return true;
|
|
343
|
+
if (artifact.status === 'missing' && artifact.exists === false) { changed = true; return false; }
|
|
344
|
+
if (artifact.status !== 'present' || artifact.hash !== ref.hash || artifact.generation !== ref.generation) changed = true;
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
if (!changed) continue;
|
|
348
|
+
if (!refs.length) { operations.push({ op: `${kind}.remove`, id: item.id }); continue; }
|
|
349
|
+
const updated = {
|
|
350
|
+
...clone(item), sourceRefs: clone(refs), classification: 'stale', validity: 'stale',
|
|
351
|
+
evidenceState: refs.some(ref => ref.sourceClass === 'public_intent') ? 'proposed' : 'observed',
|
|
352
|
+
...(kind === 'node' ? { activityState: 'unknown' } : {}),
|
|
353
|
+
};
|
|
354
|
+
if (!equal(item, updated)) {
|
|
355
|
+
preserveApproval(item, updated);
|
|
356
|
+
operations.push({ op: `${kind}.upsert`, [kind]: updated });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Node removal cascades. Do not reinsert an incident edge later in the patch.
|
|
360
|
+
const removed = new Set(operations.filter(op => op.op === 'node.remove').map(op => op.id));
|
|
361
|
+
let effective = operations.filter(op => op.op !== 'edge.upsert' || !removed.has(op.edge.source) && !removed.has(op.edge.target));
|
|
362
|
+
const next = reduce(graph, effective);
|
|
363
|
+
if (bytes(next) > LIMITS.graphBytes) {
|
|
364
|
+
// Maintenance gets reserved headroom; even a graph restored at the hard
|
|
365
|
+
// limit can shed optional excerpts instead of blocking invalidation.
|
|
366
|
+
const byOperation = new Map(effective.map(op => [`${op.op.split('.')[0]}:${op.id ?? op.node?.id ?? op.edge?.id}`, op]));
|
|
367
|
+
for (const [kind, items] of [['node', next.nodes], ['edge', next.edges]]) for (const item of items) {
|
|
368
|
+
const compact = { ...item, sourceRefs: item.sourceRefs.map(({ excerpt, ...ref }) => ref) };
|
|
369
|
+
if (!equal(item, compact)) {
|
|
370
|
+
preserveApproval(item, compact);
|
|
371
|
+
byOperation.set(`${kind}:${item.id}`, { op: `${kind}.upsert`, [kind]: compact });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
effective = [...byOperation.values()];
|
|
375
|
+
}
|
|
376
|
+
return makePatch(graph, effective);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function projectGraph(graph, policy, { persistent = false } = {}) {
|
|
380
|
+
assertGraph(graph);
|
|
381
|
+
policy = createPolicy(policy);
|
|
382
|
+
const permitted = policy.transmitSource && (persistent ? policy.persistEvidence : policy.displayEvidence);
|
|
383
|
+
function project(item, node) {
|
|
384
|
+
const approved = permitted && approvalVersions.get(contentKey(item)) === policy.version;
|
|
385
|
+
const result = clone(item);
|
|
386
|
+
if (node && !approved) result.label = ROLE_LABELS[item.kind];
|
|
387
|
+
result.sourceRefs = result.sourceRefs.map(ref => {
|
|
388
|
+
if (!approved) delete ref.excerpt;
|
|
389
|
+
return ref;
|
|
390
|
+
});
|
|
391
|
+
if (approved) approve(result, policy.version);
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
schemaVersion: 1, revision: graph.revision,
|
|
396
|
+
nodes: graph.nodes.map(item => project(item, true)), edges: graph.edges.map(item => project(item, false)),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createPolicy, normalizeHostEvent, metadataEvent } from './privacy.mjs';
|
|
2
|
+
export { EvidenceStore } from './evidence.mjs';
|
|
3
|
+
export { buildCandidates, materializeBundle, buildRelationProposals } from './candidates.mjs';
|
|
4
|
+
export { emptyGraph, compileDecision, invalidateArtifacts, applyPatch, projectGraph } from './graph.mjs';
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { LIMITS } from './common.mjs';
|
|
2
|
+
|
|
3
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
4
|
+
const DECLARATIONS = new Set(['function', 'class', 'interface', 'def', 'fn', 'func', 'fun', 'struct', 'enum', 'type', 'module', 'namespace', 'trait']);
|
|
5
|
+
const FUNCTIONS = new Set(['function', 'def', 'fn', 'func', 'fun']);
|
|
6
|
+
const BINDINGS = new Set(['const', 'let', 'var', 'val']);
|
|
7
|
+
const BUILTINS = new Set(['this', 'self', 'process', 'console', 'Object', 'Array', 'String', 'Number', 'Boolean',
|
|
8
|
+
'Promise', 'JSON', 'Math', 'Date', 'Error', 'return', 'export', 'import', 'from', 'const', 'let', 'var',
|
|
9
|
+
'true', 'false', 'null', 'undefined', 'async', 'await', 'function', 'class', 'if', 'else', 'new',
|
|
10
|
+
'require', 'super', 'env', 'environ', 'meta', 'Propose', 'Use', 'Add', 'Create', 'Build', 'Update',
|
|
11
|
+
'The', 'A', 'An', 'We', 'I', 'Implement', 'Connect', 'Read', 'Write']);
|
|
12
|
+
const TOKEN_LIMIT = 32768;
|
|
13
|
+
const ENTITY_LIMIT = 1024;
|
|
14
|
+
const PAIR_LIMIT = 1024;
|
|
15
|
+
|
|
16
|
+
// This is a bounded lexical selector, not a parser or semantic analyzer.
|
|
17
|
+
// Offsets always refer to the untouched input. Literal/comment bodies cannot
|
|
18
|
+
// contribute identifiers. Template interpolation is conservatively skipped too.
|
|
19
|
+
export function tokenize(text) {
|
|
20
|
+
const tokens = [];
|
|
21
|
+
const length = Math.min(text.length, LIMITS.fileBytes);
|
|
22
|
+
let i = 0;
|
|
23
|
+
const add = (kind, start, end, value = text.slice(start, end)) => tokens.push({ kind, start, end, value });
|
|
24
|
+
while (i < length && tokens.length < TOKEN_LIMIT) {
|
|
25
|
+
const ch = text[i], start = i;
|
|
26
|
+
if (/\s/.test(ch)) { i++; continue; }
|
|
27
|
+
if (text.startsWith('//', i) || ch === '#') {
|
|
28
|
+
while (i < length && text[i] !== '\n') i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (text.startsWith('/*', i)) {
|
|
32
|
+
i += 2;
|
|
33
|
+
let depth = 1;
|
|
34
|
+
while (i < length && depth) {
|
|
35
|
+
if (text.startsWith('/*', i)) { depth++; i += 2; }
|
|
36
|
+
else if (text.startsWith('*/', i)) { depth--; i += 2; }
|
|
37
|
+
else i++;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
42
|
+
const delimiter = ch !== '`' && text.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch;
|
|
43
|
+
i += delimiter.length;
|
|
44
|
+
while (i < length) {
|
|
45
|
+
if (text[i] === '\\') { i = Math.min(length, i + 2); continue; }
|
|
46
|
+
if (text.startsWith(delimiter, i)) { i += delimiter.length; break; }
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
add('literal', start, i, '');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Skip JS regex bodies in expression positions. Ambiguous slash syntax is
|
|
53
|
+
// left as punctuation, without guessing any language-level meaning.
|
|
54
|
+
const previous = tokens.at(-1)?.value;
|
|
55
|
+
if (ch === '/' && (previous === undefined || ['=', '(', '[', '{', ':', ',', 'return', '=>'].includes(previous))) {
|
|
56
|
+
let end = i + 1, characterClass = false;
|
|
57
|
+
while (end < length && text[end] !== '\n') {
|
|
58
|
+
if (text[end] === '\\') { end += 2; continue; }
|
|
59
|
+
if (text[end] === '[') characterClass = true;
|
|
60
|
+
else if (text[end] === ']') characterClass = false;
|
|
61
|
+
else if (text[end] === '/' && !characterClass) break;
|
|
62
|
+
end++;
|
|
63
|
+
}
|
|
64
|
+
if (text[end] === '/') {
|
|
65
|
+
i = end + 1;
|
|
66
|
+
while (i < length && /[a-z]/i.test(text[i])) i++;
|
|
67
|
+
add('literal', start, i, '');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
72
|
+
i++;
|
|
73
|
+
while (i < length && /[\w$]/.test(text[i])) i++;
|
|
74
|
+
add('identifier', start, i);
|
|
75
|
+
} else {
|
|
76
|
+
const pair = text.slice(i, i + 2);
|
|
77
|
+
i += ['=>', '?.', '::', ':='].includes(pair) ? 2 : 1;
|
|
78
|
+
add('punctuation', start, i);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tokens;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function delimiters(tokens) {
|
|
85
|
+
const matching = new Map(), stack = [];
|
|
86
|
+
const closing = { ')': '(', ']': '[', '}': '{' };
|
|
87
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
88
|
+
const value = tokens[i].value;
|
|
89
|
+
if (['(', '[', '{'].includes(value)) stack.push(i);
|
|
90
|
+
else if (closing[value]) {
|
|
91
|
+
if (tokens[stack.at(-1)]?.value !== closing[value]) continue;
|
|
92
|
+
const start = stack.pop();
|
|
93
|
+
matching.set(start, i);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return matching;
|
|
97
|
+
}
|
|
98
|
+
const isName = token => token?.kind === 'identifier' && IDENTIFIER.test(token.value);
|
|
99
|
+
const property = (tokens, i) => ['.', '?.', '::'].includes(tokens[i - 1]?.value);
|
|
100
|
+
|
|
101
|
+
function bareEnvironmentLookup(tokens, start, text) {
|
|
102
|
+
let end;
|
|
103
|
+
if (tokens[start]?.value === 'process' && tokens[start + 1]?.value === '.' &&
|
|
104
|
+
tokens[start + 2]?.value === 'env') end = start + 3;
|
|
105
|
+
else if (tokens[start]?.value === 'import' && tokens[start + 1]?.value === '.' &&
|
|
106
|
+
tokens[start + 2]?.value === 'meta' && tokens[start + 3]?.value === '.' &&
|
|
107
|
+
tokens[start + 4]?.value === 'env') end = start + 5;
|
|
108
|
+
else return false;
|
|
109
|
+
|
|
110
|
+
if (tokens[end]?.value === '.' && isName(tokens[end + 1])) end += 2;
|
|
111
|
+
else if (tokens[end]?.value === '[' && ['identifier', 'literal'].includes(tokens[end + 1]?.kind) &&
|
|
112
|
+
tokens[end + 2]?.value === ']') end += 3;
|
|
113
|
+
else return false;
|
|
114
|
+
|
|
115
|
+
// Only a complete bare lookup is lexical noise. A following conditional,
|
|
116
|
+
// operator, call, or wrapper can initialize a different named binding.
|
|
117
|
+
const next = tokens[end];
|
|
118
|
+
return !next || [';', ',', ')', ']', '}'].includes(next.value) ||
|
|
119
|
+
(text.slice(tokens[end - 1].end, next.start).includes('\n') &&
|
|
120
|
+
(BINDINGS.has(next.value) || DECLARATIONS.has(next.value) || next.value === 'export'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function lexicalHints(text) {
|
|
124
|
+
const tokens = tokenize(text), matching = delimiters(tokens);
|
|
125
|
+
const entities = new Map(), scopes = [], constructions = [], pairs = new Map();
|
|
126
|
+
function add(token, rank) {
|
|
127
|
+
if (!isName(token) || BUILTINS.has(token.value) || token.value.length > LIMITS.labelChars) return;
|
|
128
|
+
const old = entities.get(token.value);
|
|
129
|
+
if (old && old.rank <= rank || !old && entities.size >= ENTITY_LIMIT) return;
|
|
130
|
+
entities.set(token.value, { label: token.value, start: token.start, end: token.end, rank });
|
|
131
|
+
}
|
|
132
|
+
function addScope(name, open, end) {
|
|
133
|
+
if (scopes.length < 256 && end !== undefined) scopes.push({ name, start: tokens[open].start, end: tokens[end]?.end ?? text.length });
|
|
134
|
+
}
|
|
135
|
+
function functionScope(name, afterName) {
|
|
136
|
+
// Bounded parameter/type syntax. An unmatched or clipped body supplies no
|
|
137
|
+
// enclosing-function hint instead of attributing later unrelated code.
|
|
138
|
+
for (let i = afterName; i < Math.min(tokens.length, afterName + 128); i++) {
|
|
139
|
+
if (tokens[i].value === '(') {
|
|
140
|
+
const end = matching.get(i);
|
|
141
|
+
if (end === undefined) return;
|
|
142
|
+
i = end;
|
|
143
|
+
} else if (tokens[i].value === '{') {
|
|
144
|
+
addScope(name, i, matching.get(i));
|
|
145
|
+
return;
|
|
146
|
+
} else if (tokens[i].value === ';' || DECLARATIONS.has(tokens[i].value)) return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
150
|
+
const token = tokens[i];
|
|
151
|
+
if (!DECLARATIONS.has(token.value) || property(tokens, i)) continue;
|
|
152
|
+
let next = i + 1;
|
|
153
|
+
if (tokens[next]?.value === '*') next++;
|
|
154
|
+
// Go receiver declarations: func (s *Service) SaveNote(...).
|
|
155
|
+
if (token.value === 'func' && tokens[next]?.value === '(') next = (matching.get(next) ?? next) + 1;
|
|
156
|
+
if (!isName(tokens[next])) continue;
|
|
157
|
+
add(tokens[next], 0);
|
|
158
|
+
if (FUNCTIONS.has(token.value)) functionScope(tokens[next].value, next + 1);
|
|
159
|
+
}
|
|
160
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
161
|
+
const token = tokens[i];
|
|
162
|
+
if (!BINDINGS.has(token.value) || property(tokens, i)) continue;
|
|
163
|
+
const name = tokens[i + 1];
|
|
164
|
+
if (name?.value === '{' || name?.value === '[') {
|
|
165
|
+
const end = matching.get(i + 1);
|
|
166
|
+
if (end !== undefined) for (let j = i + 2; j < end; j++) {
|
|
167
|
+
if (tokens[j + 1]?.value !== ':' && !property(tokens, j)) add(tokens[j], 1);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!isName(name)) continue;
|
|
172
|
+
let equal = i + 2;
|
|
173
|
+
// Skip a short type annotation before a binding's initializer.
|
|
174
|
+
if (tokens[equal]?.value === ':') {
|
|
175
|
+
while (equal < Math.min(tokens.length, i + 24) && !['=', ';'].includes(tokens[equal].value)) equal++;
|
|
176
|
+
}
|
|
177
|
+
if (tokens[equal]?.value !== '=') { add(name, 1); continue; }
|
|
178
|
+
let rhs = equal + 1;
|
|
179
|
+
if (bareEnvironmentLookup(tokens, rhs, text)) continue;
|
|
180
|
+
add(name, 1);
|
|
181
|
+
if (tokens[rhs]?.value === 'async') rhs++;
|
|
182
|
+
if (tokens[rhs]?.value === 'function') {
|
|
183
|
+
add(name, 0);
|
|
184
|
+
functionScope(name.value, rhs + 1);
|
|
185
|
+
}
|
|
186
|
+
let arrow = rhs;
|
|
187
|
+
if (tokens[arrow]?.value === '(') arrow = (matching.get(arrow) ?? arrow) + 1;
|
|
188
|
+
else if (isName(tokens[arrow])) arrow++;
|
|
189
|
+
if (tokens[arrow]?.value === '=>') {
|
|
190
|
+
add(name, 0);
|
|
191
|
+
const body = arrow + 1;
|
|
192
|
+
if (tokens[body]?.value === '{') addScope(name.value, body, matching.get(body));
|
|
193
|
+
else {
|
|
194
|
+
let end = body;
|
|
195
|
+
while (end + 1 < tokens.length && tokens[end].value !== ';' &&
|
|
196
|
+
!text.slice(tokens[end].end, tokens[end + 1].start).includes('\n')) end++;
|
|
197
|
+
addScope(name.value, body, end);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const constructor = tokens[rhs]?.value === 'new';
|
|
201
|
+
const target = tokens[rhs + (constructor ? 1 : 0)];
|
|
202
|
+
if (isName(target) && (constructor || tokens[rhs + 1]?.value === '(') && target.value !== name.value) {
|
|
203
|
+
add(target, 3);
|
|
204
|
+
if (constructions.length < PAIR_LIMIT) constructions.push({ source: name.value, target: target.value,
|
|
205
|
+
rank: constructor ? 2 : 3, offset: name.start, kind: 'binding' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
209
|
+
if (tokens[i].value !== 'import' || property(tokens, i) || tokens[i + 1]?.kind === 'literal') continue;
|
|
210
|
+
// JS default/named/namespace imports and Python import lists. String module
|
|
211
|
+
// paths are optional candidates and are deliberately omitted in this MVP.
|
|
212
|
+
for (let j = i + 1; j < Math.min(tokens.length, i + 128); j++) {
|
|
213
|
+
if (tokens[j].kind === 'literal' || [';', 'from', '='].includes(tokens[j].value) ||
|
|
214
|
+
j > i + 1 && text.slice(tokens[j - 1].end, tokens[j].start).includes('\n') &&
|
|
215
|
+
!['{', ','].includes(tokens[j - 1].value)) break;
|
|
216
|
+
if (['type', 'as'].includes(tokens[j].value) || property(tokens, j)) continue;
|
|
217
|
+
if (tokens[j + 1]?.value === 'as') { add(tokens[j + 2], 2); j += 2; }
|
|
218
|
+
else add(tokens[j], 2);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function pair(source, target, rank, offset, kind) {
|
|
222
|
+
if (source === target || !entities.has(source) || !entities.has(target)) return;
|
|
223
|
+
const key = JSON.stringify([source, target]), old = pairs.get(key);
|
|
224
|
+
if (old && old.rank <= rank || !old && pairs.size >= PAIR_LIMIT) return;
|
|
225
|
+
pairs.set(key, { source, target, rank, offset, kind });
|
|
226
|
+
}
|
|
227
|
+
const calls = [];
|
|
228
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
229
|
+
const token = tokens[i];
|
|
230
|
+
if (!isName(token) || property(tokens, i) || BUILTINS.has(token.value)) continue;
|
|
231
|
+
let next = i + 1, member = false;
|
|
232
|
+
while (['.', '?.', '::'].includes(tokens[next]?.value) && isName(tokens[next + 1])) { member = true; next += 2; }
|
|
233
|
+
const called = tokens[next]?.value === '(';
|
|
234
|
+
if (member && called) add(token, 3);
|
|
235
|
+
// Retain other-language/type-name fallback only outside literals and
|
|
236
|
+
// property chains. Shouting constants are not inferred component names.
|
|
237
|
+
if (!['as', ':'].includes(tokens[i + 1]?.value) &&
|
|
238
|
+
/^[A-Z][A-Za-z0-9_$]*[a-z][A-Za-z0-9_$]*$/.test(token.value)) add(token, 4);
|
|
239
|
+
if (called && !DECLARATIONS.has(tokens[i - 1]?.value) && calls.length < PAIR_LIMIT) {
|
|
240
|
+
calls.push({ token, member });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const { token, member } of calls) {
|
|
244
|
+
// Innermost lexical function owns the selection hint. This asserts neither
|
|
245
|
+
// an architectural relation nor execution; all relation kinds go to Jev.
|
|
246
|
+
const scope = scopes.filter(s => s.start < token.start && token.end < s.end)
|
|
247
|
+
.sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
|
|
248
|
+
if (scope) pair(scope.name, token.value, member ? 0 : 1, token.start, 'call');
|
|
249
|
+
}
|
|
250
|
+
for (const hint of constructions) pair(hint.source, hint.target, hint.rank, hint.offset, hint.kind);
|
|
251
|
+
return {
|
|
252
|
+
entities: [...entities.values()].sort((a, b) => a.rank - b.rank || a.start - b.start),
|
|
253
|
+
pairs: [...pairs.values()].sort((a, b) => a.rank - b.rank || a.offset - b.offset),
|
|
254
|
+
};
|
|
255
|
+
}
|