@azure-id/orc 1.8.1 → 1.8.2
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/CHANGELOG.md +220 -0
- package/README-id.md +90 -71
- package/README.md +77 -31
- package/bin/cli.js +45460 -44867
- package/bin/graph-extract.js +2409 -120
- package/bin/graph-gain.js +404 -0
- package/bin/graph-map.js +232 -0
- package/bin/graph-notes.js +49 -8
- package/bin/graph-query.js +1770 -808
- package/bin/graph-resolve.js +93 -16
- package/bin/graph-shard.js +325 -0
- package/bin/graph.js +658 -605
- package/bin/verify-contracts.js +140 -0
- package/bin/verify-package.js +14 -0
- package/bin/webui/api.js +6 -0
- package/bin/webui/fixtures/index.js +6 -1
- package/bin/webui/fixtures/knowledge.js +41 -1
- package/bin/webui/fixtures/stats.js +107 -104
- package/bin/webui/i18n/en/knowledge.json +16 -1
- package/bin/webui/i18n/id/knowledge.json +16 -1
- package/bin/webui/js/panels/knowledge.js +68 -3
- package/package.json +1 -1
- package/templates/agents/orc-executor-haiku-4-5.md +8 -13
- package/templates/agents/orc-executor-opus-4-7-high.md +8 -13
- package/templates/agents/orc-executor-opus-4-7-med.md +8 -13
- package/templates/agents/orc-executor-opus-4-8-high.md +8 -13
- package/templates/agents/orc-executor-opus-5-high.md +8 -13
- package/templates/agents/orc-executor-opus-5-low.md +8 -13
- package/templates/agents/orc-executor-opus-5-med.md +8 -13
- package/templates/agents/orc-executor-sonnet-4-6-high.md +8 -13
- package/templates/agents/orc-executor-sonnet-4-6-med.md +8 -13
- package/templates/agents/orc-executor-sonnet-5-high.md +8 -13
- package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +15 -12
- package/templates/hooks/README.md +13 -3
- package/templates/hooks/orc-graph-hook.js +148 -13
- package/templates/skills/_shared/code-graph.md +148 -20
- package/templates/skills/_shared/phases/execution.md +13 -11
- package/templates/skills/_shared/phases/planning.md +8 -1
- package/templates/skills/_shared/phases/ship.md +5 -1
- package/templates/skills/_shared/phases/trace.md +2 -0
- package/templates/skills/_shared/phases/wiki-consult.md +1 -1
- package/templates/skills/_shared/read-ladder.md +10 -2
- package/templates/skills/orc/SKILL.md +1 -1
- package/templates/skills/orc-analyze/SKILL.md +13 -13
- package/templates/skills/orc-diy/references/flow-schema.md +1 -1
- package/templates/skills/orc-wiki/references/staleness.md +1 -1
package/bin/graph-query.js
CHANGED
|
@@ -1,808 +1,1770 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// ── orc graph — RESOLUTION and the READ commands (v1.8.0 W3) ────────────────
|
|
3
|
-
//
|
|
4
|
-
// `ctx`, `impact` and `path` answer from the stored records. Resolution is
|
|
5
|
-
// COMPUTED ON READ, never stored — a deliberate change from plan §3 step 5.
|
|
6
|
-
// W0 measured the whole resolve pass at 175 ms on Django (7K files); storing
|
|
7
|
-
// edges would add ~12 MB to every parse AND an invalidation path that can go
|
|
8
|
-
// stale in silence. The standing rule wins: a state is computed, not kept.
|
|
9
|
-
//
|
|
10
|
-
// THE STATES — never a fuzzy guess
|
|
11
|
-
// LOCAL defined in the same file (a `this.`/`self.` call prefers the caller's class)
|
|
12
|
-
// IMPORT the name, or its receiver, is bound by an import whose path resolves here
|
|
13
|
-
// UNIQUE exactly one symbol in the repo has that name (and the receiver agrees)
|
|
14
|
-
// AMBIGUOUS two or more candidates, OR a receiver that no candidate confirms — all listed
|
|
15
|
-
// UNRESOLVED nothing in this repo (a builtin, an external package, dynamic dispatch)
|
|
16
|
-
// (
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
return bySuffix(model, `${
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (model.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function
|
|
279
|
-
if (model.
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── orc graph — RESOLUTION and the READ commands (v1.8.0 W3) ────────────────
|
|
3
|
+
//
|
|
4
|
+
// `ctx`, `impact` and `path` answer from the stored records. Resolution is
|
|
5
|
+
// COMPUTED ON READ, never stored — a deliberate change from plan §3 step 5.
|
|
6
|
+
// W0 measured the whole resolve pass at 175 ms on Django (7K files); storing
|
|
7
|
+
// edges would add ~12 MB to every parse AND an invalidation path that can go
|
|
8
|
+
// stale in silence. The standing rule wins: a state is computed, not kept.
|
|
9
|
+
//
|
|
10
|
+
// THE STATES — never a fuzzy guess
|
|
11
|
+
// LOCAL defined in the same file (a `this.`/`self.` call prefers the caller's class)
|
|
12
|
+
// IMPORT the name, or its receiver, is bound by an import whose path resolves here
|
|
13
|
+
// UNIQUE exactly one symbol in the repo has that name (and the receiver agrees)
|
|
14
|
+
// AMBIGUOUS two or more candidates, OR a receiver that no candidate confirms — all listed
|
|
15
|
+
// UNRESOLVED nothing in this repo (a builtin, an external package, dynamic dispatch)
|
|
16
|
+
// ROUTE (v1.8.2 W1) a URL literal reaches exactly one route symbol — the
|
|
17
|
+
// mount chain is known and the full path matches, or the path's tail
|
|
18
|
+
// matches one route and no other
|
|
19
|
+
// (EXACT is reserved for a type-checker-backed resolution; nothing emits it yet.)
|
|
20
|
+
//
|
|
21
|
+
// Every read is BUDGETED. A card never exceeds its budget and always says what
|
|
22
|
+
// it hid — a graph tool that returned 33K tokens is the failure this prevents.
|
|
23
|
+
//
|
|
24
|
+
// v1.8.2 W1 — URLS ARE EDGES. R3-C measured the defect: a card listed every
|
|
25
|
+
// caller that NAMED a symbol and declared eleven route-level tests absent. They
|
|
26
|
+
// reached it through `request(app).get("/orders/search")`. The extractor now
|
|
27
|
+
// records every URL literal a symbol sends (`urls[]`) and every mount
|
|
28
|
+
// (`mounts[]`); this file resolves a URL to a route symbol on read:
|
|
29
|
+
// full path = the mount chain's prefix (followed across files, depth ≤ 4,
|
|
30
|
+
// exactly one chain) + the route's own path, with `:id` `{id}` `<id>` `[id]`
|
|
31
|
+
// as one segment and `*` as anything; trailing slash optional; query stripped.
|
|
32
|
+
// A URL whose prefix is unknown (`BASE + "/p"`) matches by its tail.
|
|
33
|
+
|
|
34
|
+
const fs = require("fs");
|
|
35
|
+
const path = require("path");
|
|
36
|
+
const posix = path.posix;
|
|
37
|
+
const { spawnSync } = require("child_process");
|
|
38
|
+
const G = require("./graph.js");
|
|
39
|
+
const N = require("./graph-notes.js");
|
|
40
|
+
|
|
41
|
+
const CONFIDENT = new Set(["LOCAL", "IMPORT", "UNIQUE", "ROUTE"]);
|
|
42
|
+
const MOUNT_DEPTH = 4;
|
|
43
|
+
// W5 (G6) adds the five new languages' own conventions: RSpec and minitest
|
|
44
|
+
// (`_spec.rb`, `_test.rb`), Rust (`*_test.rs`, and `mod tests` lives inside
|
|
45
|
+
// the file it tests, which no path pattern can see), Kotlin (`*Test.kt`,
|
|
46
|
+
// `*Spec.kt`) and a `*_test.cpp` beside its unit.
|
|
47
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|specs?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py|rs|c|cc|cpp)$|(^|\/)test_[^/]*\.py$|Tests?\.(java|cs)$|Test\.php$|_(spec|test)\.rb$|(Test|Spec)\.kts?$/;
|
|
48
|
+
const JS_EXT = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte"];
|
|
49
|
+
const tok = (s) => Math.ceil(String(s).length / 4);
|
|
50
|
+
const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
51
|
+
|
|
52
|
+
function readJson(file) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
55
|
+
} catch (_) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// EW2: `opts.noCache` is for the WRITER — `graph-resolve.build` must compute,
|
|
61
|
+
// never read back the cache it is about to replace.
|
|
62
|
+
function loadModel(claudeDir, root, opts) {
|
|
63
|
+
const p = G.graphPaths(claudeDir);
|
|
64
|
+
const meta = readJson(p.meta);
|
|
65
|
+
// S2: the writer hands over the index it holds; a reader parses the file.
|
|
66
|
+
const index = (opts && opts.index) || readJson(p.index);
|
|
67
|
+
if (!meta || !index || !index.by_file) return null;
|
|
68
|
+
// The stored resolution, and ONLY when it names this exact generation.
|
|
69
|
+
// Anything else — a crash between two writes, an older engine, a hand
|
|
70
|
+
// deletion — leaves `cached` null and every answer is computed as before.
|
|
71
|
+
const cached = opts && opts.noCache ? null : require("./graph-resolve.js").load(claudeDir, meta);
|
|
72
|
+
const byFile = index.by_file;
|
|
73
|
+
const symbols = new Map();
|
|
74
|
+
const byName = new Map();
|
|
75
|
+
for (const [rel, f] of Object.entries(byFile)) {
|
|
76
|
+
for (const s of f.symbols || []) {
|
|
77
|
+
s.file = rel;
|
|
78
|
+
symbols.set(s.id, s);
|
|
79
|
+
if (s.kind === "module") continue;
|
|
80
|
+
const a = byName.get(s.name);
|
|
81
|
+
if (a) a.push(s);
|
|
82
|
+
else byName.set(s.name, [s]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
root,
|
|
87
|
+
meta,
|
|
88
|
+
notes: N.noteIndex(claudeDir),
|
|
89
|
+
byFile,
|
|
90
|
+
fileSet: new Set(Object.keys(byFile)),
|
|
91
|
+
symbols,
|
|
92
|
+
byName,
|
|
93
|
+
bindings: new Map(),
|
|
94
|
+
resolved: new Map(),
|
|
95
|
+
// The REVERSE direction only. EW2 measured storing the whole memo as a net
|
|
96
|
+
// loss (21.7 MB to parse, to save less than that), while this half is the
|
|
97
|
+
// one the 437 ms walk was computing.
|
|
98
|
+
callersCache: cached ? cached.callers || {} : null,
|
|
99
|
+
maybeCache: cached ? cached.maybe || {} : null,
|
|
100
|
+
generation: meta.generation || 0,
|
|
101
|
+
suffix: new Map(),
|
|
102
|
+
dirs: null,
|
|
103
|
+
goModule: undefined,
|
|
104
|
+
callIndex: null,
|
|
105
|
+
// W1: the route table and the URL memo, both built on first use.
|
|
106
|
+
routes: null,
|
|
107
|
+
urlIndex: null,
|
|
108
|
+
urlMemo: new Map(),
|
|
109
|
+
// S3: blob freshness, batched by `warmBlobs` (one git call per read).
|
|
110
|
+
blobStates: new Map(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── import targets ──────────────────────────────────────────────────────────
|
|
115
|
+
function bySuffix(model, suffix) {
|
|
116
|
+
if (model.suffix.has(suffix)) return model.suffix.get(suffix);
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const f of model.fileSet) if (f === suffix || f.endsWith("/" + suffix)) out.push(f);
|
|
119
|
+
model.suffix.set(suffix, out);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function filesInDir(model, dir, ext) {
|
|
124
|
+
if (!model.dirs) {
|
|
125
|
+
model.dirs = new Map();
|
|
126
|
+
for (const f of model.fileSet) {
|
|
127
|
+
const d = posix.dirname(f);
|
|
128
|
+
const a = model.dirs.get(d);
|
|
129
|
+
if (a) a.push(f);
|
|
130
|
+
else model.dirs.set(d, [f]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return (model.dirs.get(dir) || []).filter((f) => f.endsWith(ext));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveJs(model, from, spec) {
|
|
137
|
+
let base;
|
|
138
|
+
if (spec.startsWith(".")) base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
139
|
+
else if (/^[@~]\//.test(spec)) base = "src/" + spec.slice(2);
|
|
140
|
+
else if (spec.startsWith("/")) base = spec.slice(1);
|
|
141
|
+
else return [];
|
|
142
|
+
const cands = [base, ...JS_EXT.map((e) => base + e), ...JS_EXT.map((e) => `${base}/index${e}`)];
|
|
143
|
+
// TS ESM writes `./x.js` for a file that is `x.ts` on disk.
|
|
144
|
+
if (/\.(js|jsx|mjs|cjs)$/.test(base)) {
|
|
145
|
+
const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, "");
|
|
146
|
+
cands.push(...JS_EXT.map((e) => stem + e));
|
|
147
|
+
}
|
|
148
|
+
for (const c of cands) if (model.fileSet.has(c)) return [c];
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolvePy(model, from, spec) {
|
|
153
|
+
const lvl = /^\.*/.exec(spec)[0].length;
|
|
154
|
+
const parts = spec.slice(lvl).split(".").filter(Boolean);
|
|
155
|
+
let dir = "";
|
|
156
|
+
if (lvl) {
|
|
157
|
+
dir = posix.dirname(from);
|
|
158
|
+
for (let i = 1; i < lvl; i++) dir = posix.dirname(dir);
|
|
159
|
+
if (dir === ".") dir = "";
|
|
160
|
+
}
|
|
161
|
+
const p = [dir, ...parts].filter(Boolean).join("/");
|
|
162
|
+
for (const c of p ? [`${p}.py`, `${p}/__init__.py`] : [`${dir ? dir + "/" : ""}__init__.py`]) if (model.fileSet.has(c)) return [c];
|
|
163
|
+
if (!lvl && parts.length) {
|
|
164
|
+
const a = bySuffix(model, `${parts.join("/")}.py`);
|
|
165
|
+
if (a.length) return a;
|
|
166
|
+
return bySuffix(model, `${parts.join("/")}/__init__.py`);
|
|
167
|
+
}
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveGo(model, spec) {
|
|
172
|
+
if (model.goModule === undefined) {
|
|
173
|
+
model.goModule = null;
|
|
174
|
+
try {
|
|
175
|
+
const mm = /^module\s+(\S+)/m.exec(fs.readFileSync(path.join(model.root, "go.mod"), "utf8"));
|
|
176
|
+
if (mm) model.goModule = mm[1];
|
|
177
|
+
} catch (_) {}
|
|
178
|
+
}
|
|
179
|
+
if (!model.goModule || !spec.startsWith(model.goModule + "/")) return [];
|
|
180
|
+
return filesInDir(model, spec.slice(model.goModule.length + 1), ".go").filter((f) => !f.endsWith("_test.go"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// W5 (G6). Every file whose DIRECTORY ends with this package path — Kotlin
|
|
184
|
+
// names a package, not a file, and a Kotlin file name need not match its class.
|
|
185
|
+
function filesInPackage(model, pkg, ext) {
|
|
186
|
+
if (!pkg) return [];
|
|
187
|
+
const key = `pkg\0${pkg}\0${ext}`;
|
|
188
|
+
if (model.suffix.has(key)) return model.suffix.get(key);
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const f of model.fileSet) {
|
|
191
|
+
if (!f.endsWith(ext)) continue;
|
|
192
|
+
const d = posix.dirname(f);
|
|
193
|
+
if (d === pkg || d.endsWith("/" + pkg)) out.push(f);
|
|
194
|
+
}
|
|
195
|
+
model.suffix.set(key, out);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// W5 (G6). `mod store;` is a sibling file or a folder with `mod.rs`;
|
|
200
|
+
// `use crate::store::OrderStore` names a path from the crate root.
|
|
201
|
+
function resolveRs(model, from, imp) {
|
|
202
|
+
const dir = posix.dirname(from);
|
|
203
|
+
const spec = imp.from || "";
|
|
204
|
+
if (imp.mod) {
|
|
205
|
+
for (const c of [`${dir}/${spec}.rs`, `${dir}/${spec}/mod.rs`]) if (model.fileSet.has(c)) return [c];
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const parts = spec.split("::").filter(Boolean);
|
|
209
|
+
if (!parts.length) return [];
|
|
210
|
+
// An integration test under `tests/` names the crate by its PACKAGE name,
|
|
211
|
+
// not by `crate` — the same fact `go.mod` carries for Go.
|
|
212
|
+
if (model.rustCrate === undefined) {
|
|
213
|
+
model.rustCrate = null;
|
|
214
|
+
try {
|
|
215
|
+
const mm = /^\s*name\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(path.join(model.root, "Cargo.toml"), "utf8"));
|
|
216
|
+
if (mm) model.rustCrate = mm[1].replace(/-/g, "_");
|
|
217
|
+
} catch (_) {}
|
|
218
|
+
}
|
|
219
|
+
let base = parts;
|
|
220
|
+
if (parts[0] === "crate" || parts[0] === "self" || (model.rustCrate && parts[0] === model.rustCrate)) base = parts.slice(1);
|
|
221
|
+
else if (parts[0] === "super") {
|
|
222
|
+
const up = posix.dirname(dir);
|
|
223
|
+
base = parts.slice(1);
|
|
224
|
+
const p = [up === "." ? "" : up, ...base].filter(Boolean).join("/");
|
|
225
|
+
for (const c of [`${p}.rs`, `${p}/mod.rs`]) if (model.fileSet.has(c)) return [c];
|
|
226
|
+
return [];
|
|
227
|
+
} else return []; // an external crate
|
|
228
|
+
const p = base.join("/");
|
|
229
|
+
for (const c of [`${p}.rs`, `${p}/mod.rs`, `src/${p}.rs`, `src/${p}/mod.rs`]) if (model.fileSet.has(c)) return [c];
|
|
230
|
+
const a = bySuffix(model, `${p}.rs`);
|
|
231
|
+
return a.length ? a : bySuffix(model, `${p}/mod.rs`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function importTargets(model, from, lang, imp) {
|
|
235
|
+
const spec = imp.from || "";
|
|
236
|
+
if (lang === "js" || lang === "ts" || lang === "vue" || lang === "svelte") return resolveJs(model, from, spec);
|
|
237
|
+
if (lang === "rb") {
|
|
238
|
+
if (imp.include) {
|
|
239
|
+
const rel = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
240
|
+
const hit = [rel, `${rel}.rb`].find((c) => model.fileSet.has(c));
|
|
241
|
+
if (hit) return [hit];
|
|
242
|
+
}
|
|
243
|
+
return bySuffix(model, /\.rb$/.test(spec) ? spec : `${spec}.rb`);
|
|
244
|
+
}
|
|
245
|
+
if (lang === "rs") return resolveRs(model, from, imp);
|
|
246
|
+
if (lang === "kt") {
|
|
247
|
+
const parts = spec.split(".");
|
|
248
|
+
const last = parts[parts.length - 1];
|
|
249
|
+
const pkg = parts.slice(0, -1).join("/");
|
|
250
|
+
// `import a.b.OrderStore` — the file is usually `OrderStore.kt`, but a
|
|
251
|
+
// Kotlin file may hold several classes, so the package folder is the
|
|
252
|
+
// fallback, never a guess at a same-named file anywhere in the tree.
|
|
253
|
+
if (imp.star) return filesInPackage(model, spec.replace(/\./g, "/"), ".kt");
|
|
254
|
+
const byFile = bySuffix(model, `${pkg ? pkg + "/" : ""}${last}.kt`);
|
|
255
|
+
return byFile.length ? byFile : filesInPackage(model, pkg, ".kt");
|
|
256
|
+
}
|
|
257
|
+
if (lang === "c") {
|
|
258
|
+
const rel = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
259
|
+
return model.fileSet.has(rel) ? [rel] : bySuffix(model, spec.replace(/^(\.\.?\/)+/, ""));
|
|
260
|
+
}
|
|
261
|
+
if (lang === "py") return resolvePy(model, from, spec);
|
|
262
|
+
if (lang === "go") return resolveGo(model, spec);
|
|
263
|
+
if (lang === "java") {
|
|
264
|
+
// A wildcard import names a package, not a file; the UNIQUE rung covers it.
|
|
265
|
+
if (imp.star) return [];
|
|
266
|
+
return bySuffix(model, `${spec.replace(/\./g, "/")}.java`);
|
|
267
|
+
}
|
|
268
|
+
if (lang === "php") {
|
|
269
|
+
if (imp.include) {
|
|
270
|
+
const rel = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
271
|
+
return model.fileSet.has(rel) ? [rel] : bySuffix(model, spec.replace(/^(\.\.?\/)+/, ""));
|
|
272
|
+
}
|
|
273
|
+
return bySuffix(model, `${spec.replace(/\\/g, "/")}.php`);
|
|
274
|
+
}
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function fileBindings(model, rel) {
|
|
279
|
+
if (model.bindings.has(rel)) return model.bindings.get(rel);
|
|
280
|
+
const b = new Map();
|
|
281
|
+
const includes = [];
|
|
282
|
+
const f = model.byFile[rel] || {};
|
|
283
|
+
for (const imp of f.imports || []) {
|
|
284
|
+
const files = importTargets(model, rel, f.lang, imp);
|
|
285
|
+
if (imp.include) includes.push(...files);
|
|
286
|
+
for (const bd of imp.bindings || []) b.set(bd.local, { ...bd, from: imp.from, files });
|
|
287
|
+
}
|
|
288
|
+
const out = { map: b, includes };
|
|
289
|
+
model.bindings.set(rel, out);
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── resolution ──────────────────────────────────────────────────────────────
|
|
294
|
+
function classOf(sym) {
|
|
295
|
+
const q = sym.qname.split(".");
|
|
296
|
+
return q.length > 1 ? q.slice(0, -1).join(".") : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── W2 (G4): barrels ────────────────────────────────────────────────────────
|
|
300
|
+
// `import { OrderService } from "./orders"` reaches `orders/index.ts`, which
|
|
301
|
+
// defines nothing and re-exports. Follow its re-exports (depth ≤ 3, cycle-safe)
|
|
302
|
+
// to every file that may define `name`, remembering the name it is defined
|
|
303
|
+
// under there (`export { a as b }` renames). The head of the list is always
|
|
304
|
+
// the file the import named, so a barrel that DOES define the name still wins.
|
|
305
|
+
const BARREL_DEPTH = 3;
|
|
306
|
+
function barrelTargets(model, files, name, depth, seen) {
|
|
307
|
+
const out = [];
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
const k = `${file}\0${name}`;
|
|
310
|
+
if (seen.has(k)) continue;
|
|
311
|
+
seen.add(k);
|
|
312
|
+
out.push({ file, name });
|
|
313
|
+
if (depth >= BARREL_DEPTH) continue;
|
|
314
|
+
const f = model.byFile[file] || {};
|
|
315
|
+
for (const rx of f.reexports || []) {
|
|
316
|
+
let orig = null;
|
|
317
|
+
if (rx.names === "*") orig = name;
|
|
318
|
+
else {
|
|
319
|
+
const hitName = (rx.names || []).find((n) => n.local === name);
|
|
320
|
+
if (hitName) orig = hitName.orig;
|
|
321
|
+
}
|
|
322
|
+
if (orig == null) continue;
|
|
323
|
+
const targets = importTargets(model, file, f.lang, { from: rx.from });
|
|
324
|
+
out.push(...barrelTargets(model, targets, orig, depth + 1, seen));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── W2 (G3): inherited members ──────────────────────────────────────────────
|
|
331
|
+
// The class named `name` as seen from `rel`: in this file (LOCAL), through an
|
|
332
|
+
// import (IMPORT, barrels followed), or the only class of that name (UNIQUE).
|
|
333
|
+
function classSymbol(model, rel, name) {
|
|
334
|
+
const f = model.byFile[rel] || {};
|
|
335
|
+
const local = (f.symbols || []).find((s) => s.kind === "class" && s.name === name);
|
|
336
|
+
if (local) return { sym: local, state: "LOCAL" };
|
|
337
|
+
const bd = fileBindings(model, rel).map.get(name);
|
|
338
|
+
if (bd && bd.files.length) {
|
|
339
|
+
for (const t of barrelTargets(model, bd.files, bd.orig === "default" || bd.orig === "*" ? name : bd.orig, 0, new Set())) {
|
|
340
|
+
const s = ((model.byFile[t.file] || {}).symbols || []).find((x) => x.kind === "class" && x.name === t.name);
|
|
341
|
+
if (s) return { sym: s, state: "IMPORT" };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (f.lang === "go") {
|
|
345
|
+
const s = filesInDir(model, posix.dirname(rel), ".go").flatMap((x) => ((model.byFile[x] || {}).symbols || []).filter((y) => y.kind === "class" && y.name === name));
|
|
346
|
+
if (s.length === 1) return { sym: s[0], state: "LOCAL" };
|
|
347
|
+
}
|
|
348
|
+
const all = (model.byName.get(name) || []).filter((s) => s.kind === "class");
|
|
349
|
+
if (all.length === 1) return { sym: all[0], state: "UNIQUE" };
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// `this.m()` in a class with no `m` of its own: walk the base chain (depth ≤
|
|
354
|
+
// 4, ≤ 8 bases per class, cycle-safe) and return the base member with the
|
|
355
|
+
// state the BASE CLASS resolved with from the subclass's file.
|
|
356
|
+
const INHERIT_DEPTH = 4;
|
|
357
|
+
function inheritedMember(model, rel, className, member, depth, seen) {
|
|
358
|
+
if (depth > INHERIT_DEPTH) return null;
|
|
359
|
+
const own = classSymbol(model, rel, className);
|
|
360
|
+
if (!own) return null;
|
|
361
|
+
for (const base of (own.sym.bases || []).slice(0, 8)) {
|
|
362
|
+
if (seen.has(base)) continue;
|
|
363
|
+
seen.add(base);
|
|
364
|
+
const b = classSymbol(model, own.sym.file, base);
|
|
365
|
+
if (!b) continue;
|
|
366
|
+
const mem = ((model.byFile[b.sym.file] || {}).symbols || []).find((s) => s.kind !== "module" && s.name === member && classOf(s) && classOf(s).split(".").pop() === b.sym.name);
|
|
367
|
+
if (mem) return { sym: mem, state: b.state };
|
|
368
|
+
const deeper = inheritedMember(model, b.sym.file, b.sym.name, member, depth + 1, seen);
|
|
369
|
+
if (deeper) return deeper;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolveCall(model, rel, caller, call) {
|
|
375
|
+
const cls = caller ? classOf(caller) : null;
|
|
376
|
+
const key = `${rel}\0${cls || ""}\0${call.name}\0${call.self ? 1 : 0}\0${call.ref ? 1 : 0}\0${call.sup ? 1 : 0}\0${call.alias ? 1 : 0}`;
|
|
377
|
+
if (model.resolved.has(key)) return model.resolved.get(key);
|
|
378
|
+
const parts = call.name.split(".");
|
|
379
|
+
const last = parts[parts.length - 1];
|
|
380
|
+
const head = parts.length > 1 ? parts[0] : null;
|
|
381
|
+
const f = model.byFile[rel] || {};
|
|
382
|
+
const locals = (f.symbols || []).filter((s) => s.kind !== "module" && s.name === last);
|
|
383
|
+
const { map: binds, includes } = fileBindings(model, rel);
|
|
384
|
+
const lang = f.lang;
|
|
385
|
+
let r = null;
|
|
386
|
+
const hit = (state, s) => ({ state, target: s.id });
|
|
387
|
+
|
|
388
|
+
// `super.m()` never resolves to the class's own member.
|
|
389
|
+
if (!call.sup && !head && locals.length) {
|
|
390
|
+
const same = call.self && cls ? locals.find((s) => classOf(s) === cls) : null;
|
|
391
|
+
// W2: a self call on a class that has no such member is not a local
|
|
392
|
+
// function of the same name — it is inherited, or nothing.
|
|
393
|
+
const pick = same || (call.self && cls ? null : locals.find((s) => !classOf(s) || !call.self) || locals[0]);
|
|
394
|
+
if (pick) r = hit("LOCAL", pick);
|
|
395
|
+
}
|
|
396
|
+
if (!r && !call.sup && head) {
|
|
397
|
+
const m2 = locals.filter((s) => s.qname === `${head}.${last}` || s.qname.endsWith(`.${head}.${last}`));
|
|
398
|
+
if (m2.length) r = hit("LOCAL", m2[0]);
|
|
399
|
+
}
|
|
400
|
+
// W2 (G3): a self call with no own member walks the base chain.
|
|
401
|
+
if (!r && call.self && !head && cls) {
|
|
402
|
+
const inh = inheritedMember(model, rel, cls.split(".").pop(), last, 0, new Set());
|
|
403
|
+
if (inh) r = { state: inh.state, target: inh.sym.id, inherited: true };
|
|
404
|
+
}
|
|
405
|
+
if (!r) {
|
|
406
|
+
const bd = binds.get(head || last);
|
|
407
|
+
if (bd && !bd.files.length && lang !== "cs") {
|
|
408
|
+
// Bound by an import that does not resolve to a file here — an external
|
|
409
|
+
// package. Matching it against a same-named repo function would invent
|
|
410
|
+
// an edge (axios.get → some repo `get`).
|
|
411
|
+
if (!(lang === "py" && bd.kind === "named")) r = { state: "UNRESOLVED", external: bd.from };
|
|
412
|
+
}
|
|
413
|
+
if (!r && bd && bd.files.length) {
|
|
414
|
+
// W2 (G4): the files the binding names, and every file a barrel among
|
|
415
|
+
// them re-exports the name from — with the name it carries there.
|
|
416
|
+
const wanted = bd.kind === "named" ? bd.orig : bd.local;
|
|
417
|
+
const pairs = barrelTargets(model, bd.files, wanted, 0, new Set());
|
|
418
|
+
let s = null;
|
|
419
|
+
for (const pair of pairs) {
|
|
420
|
+
const tsyms = ((model.byFile[pair.file] || {}).symbols || []).filter((x) => x.kind !== "module");
|
|
421
|
+
const orig = bd.kind === "named" ? pair.name : bd.orig;
|
|
422
|
+
const localName = bd.kind === "named" ? pair.name : pair.name;
|
|
423
|
+
if (head) {
|
|
424
|
+
s =
|
|
425
|
+
tsyms.find((x) => x.qname === `${orig}.${last}` || x.qname === `${localName}.${last}`) ||
|
|
426
|
+
(bd.kind === "namespace" || bd.kind === "default" ? tsyms.find((x) => x.name === last && !classOf(x)) : null) ||
|
|
427
|
+
tsyms.find((x) => x.name === last && x.qname.startsWith(orig + "."));
|
|
428
|
+
} else if (bd.kind === "named") {
|
|
429
|
+
s = tsyms.find((x) => x.qname === orig) || tsyms.find((x) => x.name === orig && !classOf(x));
|
|
430
|
+
} else {
|
|
431
|
+
const exported = tsyms.filter((x) => x.exported && !classOf(x));
|
|
432
|
+
s = tsyms.find((x) => x.qname === localName) || (exported.length === 1 ? exported[0] : null);
|
|
433
|
+
}
|
|
434
|
+
if (s) break;
|
|
435
|
+
}
|
|
436
|
+
// `from pkg import module` — the name is a module, not a symbol in pkg.
|
|
437
|
+
if (!s && lang === "py" && bd.kind === "named") {
|
|
438
|
+
const sub = resolvePy(model, rel, `${bd.from}.${bd.orig}`);
|
|
439
|
+
const ss = sub.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module"));
|
|
440
|
+
s = head ? ss.find((x) => x.name === last && !classOf(x)) : null;
|
|
441
|
+
}
|
|
442
|
+
if (s) r = hit("IMPORT", s);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// A file this one INCLUDES by path names its symbols directly — a bare name
|
|
446
|
+
// (PHP, C) and, since W5, a member of a class declared in that header.
|
|
447
|
+
if (!r && includes.length) {
|
|
448
|
+
const s = includes.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module" && (head ? x.qname === `${head}.${last}` : x.name === last && !classOf(x))))[0];
|
|
449
|
+
if (s) r = hit("IMPORT", s);
|
|
450
|
+
}
|
|
451
|
+
// W1: Go's package scope is the DIRECTORY. A bare name defined in a sibling
|
|
452
|
+
// `.go` file is as local as one in this file — no import names it, and the
|
|
453
|
+
// UNIQUE rung would call a second `New` elsewhere in the module ambiguous.
|
|
454
|
+
// W5 (G6) gives Rust, Kotlin and C/C++ the same rung, for the same reason: a
|
|
455
|
+
// `mod` sibling, a class in the same Kotlin package, and a translation unit
|
|
456
|
+
// beside this one are all named without an import.
|
|
457
|
+
const DIR_SCOPE = { go: ".go", rs: ".rs", kt: ".kt", c: ".cpp" };
|
|
458
|
+
if (!r && DIR_SCOPE[lang] && (!head || call.alias)) {
|
|
459
|
+
const isTest = /_test\.go$/.test(rel);
|
|
460
|
+
const exts = lang === "c" ? [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"] : [DIR_SCOPE[lang]];
|
|
461
|
+
const s = exts
|
|
462
|
+
.flatMap((e) => filesInDir(model, posix.dirname(rel), e))
|
|
463
|
+
.filter((f) => f !== rel && (isTest || !/_test\.go$/.test(f)))
|
|
464
|
+
.flatMap((f) => ((model.byFile[f] || {}).symbols || []).filter((x) => x.kind !== "module" && (head ? x.qname === `${head}.${last}` : x.name === last && !classOf(x))));
|
|
465
|
+
if (s.length === 1) r = hit("LOCAL", s[0]);
|
|
466
|
+
}
|
|
467
|
+
// W5 (G6). C++ reaches a member — its own, or one it inherits — with no
|
|
468
|
+
// `this->`. Every rung above has already run, so a free function of that
|
|
469
|
+
// name still wins; this is the last step before the repository-wide guess.
|
|
470
|
+
if (!r && !head && !call.self && lang === "c" && cls) {
|
|
471
|
+
const own = (f.symbols || []).find((s) => s.name === last && classOf(s) === cls);
|
|
472
|
+
if (own) r = hit("LOCAL", own);
|
|
473
|
+
else {
|
|
474
|
+
const inh = inheritedMember(model, rel, cls.split(".").pop(), last, 0, new Set());
|
|
475
|
+
if (inh) r = { state: inh.state, target: inh.sym.id, inherited: true };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!r) {
|
|
479
|
+
// W1: a bare `get(…)` is never a class METHOD — a method is reached through
|
|
480
|
+
// a receiver, `this`, or inheritance. Before this, `request(app).get("/p")`
|
|
481
|
+
// resolved UNIQUE to the only method named `get` in the repository.
|
|
482
|
+
const all = model.byName.get(last) || [];
|
|
483
|
+
const cands = head || call.self ? all : all.filter((s) => !classOf(s));
|
|
484
|
+
if (head) {
|
|
485
|
+
const hl = head.toLowerCase();
|
|
486
|
+
const byClass = cands.filter((s) => {
|
|
487
|
+
const c = classOf(s);
|
|
488
|
+
return c && c.split(".").pop().toLowerCase() === hl;
|
|
489
|
+
});
|
|
490
|
+
if (byClass.length === 1) r = hit("UNIQUE", byClass[0]);
|
|
491
|
+
else if (byClass.length > 1) r = { state: "AMBIGUOUS", candidates: byClass.map((s) => s.id) };
|
|
492
|
+
// W2 (G2): the head came from an alias, so it IS a class name. A class
|
|
493
|
+
// named `Router` with no `get` is not "some `get` somewhere" — it is
|
|
494
|
+
// outside this repository (an external package), and saying AMBIGUOUS
|
|
495
|
+
// over every `get` in the tree was the nestjs/nest 118-maybe noise.
|
|
496
|
+
else if (call.alias) r = { state: "UNRESOLVED", external: head };
|
|
497
|
+
else if (cands.length) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
|
|
498
|
+
} else if (cands.length === 1) r = hit("UNIQUE", cands[0]);
|
|
499
|
+
else if (cands.length > 1) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
|
|
500
|
+
}
|
|
501
|
+
if (!r) r = { state: "UNRESOLVED" };
|
|
502
|
+
// A name passed as an argument is only an edge when this file defines it or
|
|
503
|
+
// imports it. `res.json(order)` must never find a repo function named `order`.
|
|
504
|
+
if (call.ref && r.state !== "LOCAL" && r.state !== "IMPORT") r = { state: "UNRESOLVED" };
|
|
505
|
+
model.resolved.set(key, r);
|
|
506
|
+
return r;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function callIndex(model) {
|
|
510
|
+
if (model.callIndex) return model.callIndex;
|
|
511
|
+
const idx = new Map();
|
|
512
|
+
for (const [rel, f] of Object.entries(model.byFile)) {
|
|
513
|
+
for (const s of f.symbols || []) {
|
|
514
|
+
for (const c of s.calls || []) {
|
|
515
|
+
const last = c.name.split(".").pop();
|
|
516
|
+
const a = idx.get(last);
|
|
517
|
+
const row = { rel, sym: s, call: c };
|
|
518
|
+
if (a) a.push(row);
|
|
519
|
+
else idx.set(last, [row]);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
model.callIndex = idx;
|
|
524
|
+
return idx;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── W1: routes and URLs ─────────────────────────────────────────────────────
|
|
528
|
+
const PARAM_SEG = /:\w+\??|\{[^}]*\}|<[^>]*>|\[[^\]]*\]/g;
|
|
529
|
+
const isLiteralPath = (p) => !/[:{<\[*]/.test(p);
|
|
530
|
+
|
|
531
|
+
// A route path → a regular expression over a URL path. One segment per
|
|
532
|
+
// parameter, `*` is anything, the trailing slash is optional.
|
|
533
|
+
function pathSource(p) {
|
|
534
|
+
const s = String(p).replace(/\/+$/, "");
|
|
535
|
+
return s
|
|
536
|
+
.split("/")
|
|
537
|
+
.map((seg) => {
|
|
538
|
+
if (seg === "*" || seg === "**") return ".*";
|
|
539
|
+
let out = "";
|
|
540
|
+
let last = 0;
|
|
541
|
+
for (const mm of seg.matchAll(PARAM_SEG)) {
|
|
542
|
+
out += seg.slice(last, mm.index).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + (mm[0].endsWith("?") ? "[^/]*" : "[^/]+");
|
|
543
|
+
last = mm.index + mm[0].length;
|
|
544
|
+
}
|
|
545
|
+
out += seg.slice(last).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*");
|
|
546
|
+
return out;
|
|
547
|
+
})
|
|
548
|
+
.join("/");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const joinPrefix = (a, b) => (String(a || "") + "/" + String(b || "")).replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
552
|
+
|
|
553
|
+
// Which files a mount's TARGET names: an import binding (`ordersRouter`,
|
|
554
|
+
// `orders.router`), an inline `require("./x")`, a Django `include("a.urls")`.
|
|
555
|
+
function mountTargets(model, rel, mt) {
|
|
556
|
+
const f = model.byFile[rel] || {};
|
|
557
|
+
const lang = f.lang;
|
|
558
|
+
const t = String(mt.target || "");
|
|
559
|
+
if (!t) return [];
|
|
560
|
+
if (mt.module) return lang === "py" ? resolvePy(model, rel, t) : resolveJs(model, rel, t);
|
|
561
|
+
if (/^[./]/.test(t)) return lang === "py" ? resolvePy(model, rel, t) : resolveJs(model, rel, t);
|
|
562
|
+
const head = t.split(".")[0];
|
|
563
|
+
const bd = fileBindings(model, rel).map.get(head);
|
|
564
|
+
if (!bd) return [];
|
|
565
|
+
// `from app.routers import orders` + `orders.router`: the binding names a
|
|
566
|
+
// MODULE, and the package `__init__.py` it also resolves to is not the file
|
|
567
|
+
// that holds the router.
|
|
568
|
+
if (lang === "py" && bd.kind === "named" && t.includes(".")) {
|
|
569
|
+
const sub = resolvePy(model, rel, `${bd.from}.${bd.orig}`);
|
|
570
|
+
if (sub.length) return sub;
|
|
571
|
+
}
|
|
572
|
+
if (bd.files.length) return bd.files;
|
|
573
|
+
if (lang === "py" && bd.kind === "named") return resolvePy(model, rel, `${bd.from}.${bd.orig}`);
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// file → the mounts that reach it, built once per model.
|
|
578
|
+
function mountsInto(model) {
|
|
579
|
+
if (model.mountsInto) return model.mountsInto;
|
|
580
|
+
const into = new Map();
|
|
581
|
+
for (const [rel, f] of Object.entries(model.byFile)) {
|
|
582
|
+
for (const s of f.symbols || []) {
|
|
583
|
+
for (const mt of s.mounts || []) {
|
|
584
|
+
for (const target of mountTargets(model, rel, mt)) {
|
|
585
|
+
const a = into.get(target) || [];
|
|
586
|
+
a.push({ prefix: mt.prefix || "", from: rel });
|
|
587
|
+
into.set(target, a);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
model.mountsInto = into;
|
|
593
|
+
return into;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Every full prefix under which `rel` is reachable, following mounts across
|
|
597
|
+
// files. Exactly one is a KNOWN mount; none means unmounted (the route's own
|
|
598
|
+
// path is the full path); several means the file is mounted more than once.
|
|
599
|
+
function prefixesOf(model, rel, depth, seen) {
|
|
600
|
+
const ins = mountsInto(model).get(rel) || [];
|
|
601
|
+
if (!ins.length || depth >= MOUNT_DEPTH) return [""];
|
|
602
|
+
const out = new Set();
|
|
603
|
+
for (const m of ins) {
|
|
604
|
+
if (seen.has(m.from)) continue;
|
|
605
|
+
for (const p of prefixesOf(model, m.from, depth + 1, new Set([...seen, rel]))) out.add(joinPrefix(p, m.prefix));
|
|
606
|
+
}
|
|
607
|
+
return out.size ? [...out] : [""];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function routeTable(model) {
|
|
611
|
+
if (model.routes) return model.routes;
|
|
612
|
+
const rows = [];
|
|
613
|
+
for (const s of model.symbols.values()) {
|
|
614
|
+
if (s.kind !== "route") continue;
|
|
615
|
+
const mm = /^([A-Z]+) (.+)$/.exec(s.name);
|
|
616
|
+
if (!mm) continue;
|
|
617
|
+
const own = mm[2];
|
|
618
|
+
const prefixes = prefixesOf(model, s.file, 0, new Set());
|
|
619
|
+
const full = prefixes.length === 1 ? joinPrefix(prefixes[0], own) || "/" : null;
|
|
620
|
+
rows.push({
|
|
621
|
+
sym: s,
|
|
622
|
+
method: mm[1],
|
|
623
|
+
own,
|
|
624
|
+
full,
|
|
625
|
+
mounted: prefixes.length === 1 ? (prefixes[0] ? "known" : "none") : "ambiguous",
|
|
626
|
+
literal: isLiteralPath(own) && (full == null || isLiteralPath(full)),
|
|
627
|
+
reFull: full != null ? new RegExp("^" + pathSource(full) + "/?$") : null,
|
|
628
|
+
reTail: new RegExp("(^|/)" + pathSource(own) + "/?$"),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
model.routes = rows;
|
|
632
|
+
return rows;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const methodOk = (a, b) => a === "ANY" || b === "ANY" || a === b;
|
|
636
|
+
|
|
637
|
+
// A URL (with its method) → the route it reaches. Order: an exact literal full
|
|
638
|
+
// path; a full-path pattern; the path's TAIL against every route's own path
|
|
639
|
+
// (a suffix URL, or a route whose mount is unknown). One candidate → ROUTE;
|
|
640
|
+
// several → AMBIGUOUS with all of them; none → UNRESOLVED.
|
|
641
|
+
function resolveUrl(model, u) {
|
|
642
|
+
const method = String(u.method || "ANY").toUpperCase();
|
|
643
|
+
const url = String(u.url || "").split("?")[0].split("#")[0].replace(/\/+$/, "") || "/";
|
|
644
|
+
const key = `${method} ${url} ${u.suffix ? 1 : 0}`;
|
|
645
|
+
if (model.urlMemo.has(key)) return model.urlMemo.get(key);
|
|
646
|
+
const cands = routeTable(model).filter((r) => methodOk(r.method, method));
|
|
647
|
+
const pick = (list) => {
|
|
648
|
+
const lit = list.filter((r) => r.literal);
|
|
649
|
+
return lit.length ? lit : list;
|
|
650
|
+
};
|
|
651
|
+
let found = [];
|
|
652
|
+
let mount = "known";
|
|
653
|
+
if (!u.suffix) found = pick(cands.filter((r) => r.reFull && r.reFull.test(url)));
|
|
654
|
+
if (!found.length) {
|
|
655
|
+
found = pick(cands.filter((r) => r.reTail.test(url)));
|
|
656
|
+
mount = "unknown";
|
|
657
|
+
}
|
|
658
|
+
let r;
|
|
659
|
+
if (found.length === 1) r = { state: "ROUTE", target: found[0].sym.id, ...(mount === "unknown" ? { mount } : {}) };
|
|
660
|
+
else if (found.length > 1) r = { state: "AMBIGUOUS", candidates: found.map((x) => x.sym.id) };
|
|
661
|
+
else r = { state: "UNRESOLVED" };
|
|
662
|
+
model.urlMemo.set(key, r);
|
|
663
|
+
return r;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Every URL any symbol sends, once per model.
|
|
667
|
+
function urlIndex(model) {
|
|
668
|
+
if (model.urlIndex) return model.urlIndex;
|
|
669
|
+
const rows = [];
|
|
670
|
+
for (const [rel, f] of Object.entries(model.byFile)) for (const s of f.symbols || []) for (const u of s.urls || []) rows.push({ rel, sym: s, u });
|
|
671
|
+
model.urlIndex = rows;
|
|
672
|
+
return rows;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Who calls `target`: confident callers, and a count of AMBIGUOUS ones that
|
|
676
|
+
// list it among their candidates. A route symbol is also REACHED by URL (W1).
|
|
677
|
+
function callersOf(model, target) {
|
|
678
|
+
const confident = [];
|
|
679
|
+
const seen = new Set();
|
|
680
|
+
let maybe = 0;
|
|
681
|
+
// The stored reverse map. EW0 measured this walk at 437 ms for one file card
|
|
682
|
+
// on django/django; the lookup is a property read.
|
|
683
|
+
const cachedRows = model.callersCache && Object.prototype.hasOwnProperty.call(model.callersCache, target.id) ? model.callersCache[target.id] : model.callersCache ? [] : null;
|
|
684
|
+
if (cachedRows) {
|
|
685
|
+
for (const r of cachedRows) {
|
|
686
|
+
const sym = model.symbols.get(r[0]);
|
|
687
|
+
if (!sym) continue;
|
|
688
|
+
const k = `${r[0]}${r[1]}`;
|
|
689
|
+
if (seen.has(k)) continue;
|
|
690
|
+
seen.add(k);
|
|
691
|
+
confident.push({
|
|
692
|
+
sym,
|
|
693
|
+
line: r[1],
|
|
694
|
+
state: r[2],
|
|
695
|
+
...(r[3] === 1 ? { ref: true } : {}),
|
|
696
|
+
...(r[3] === 2 && r[4] ? { url: r[4] } : {}),
|
|
697
|
+
...(r[3] === 3 ? { inherited: true } : {}),
|
|
698
|
+
...(r[3] === 0 && r[4] ? { alias: r[4] } : {}),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (model.callersCache) {
|
|
703
|
+
stableCallers(confident);
|
|
704
|
+
const m = model.maybeCache;
|
|
705
|
+
if (m && Object.prototype.hasOwnProperty.call(m, target.id)) for (const row of m[target.id]) maybe += row[1];
|
|
706
|
+
return { confident, maybe };
|
|
707
|
+
}
|
|
708
|
+
for (const row of callIndex(model).get(target.name) || []) {
|
|
709
|
+
const r = resolveCall(model, row.rel, row.sym, row.call);
|
|
710
|
+
if (r.target === target.id && CONFIDENT.has(r.state)) {
|
|
711
|
+
const k = `${row.sym.id} ${row.call.line}`;
|
|
712
|
+
if (seen.has(k)) continue;
|
|
713
|
+
seen.add(k);
|
|
714
|
+
confident.push({
|
|
715
|
+
sym: row.sym,
|
|
716
|
+
line: row.call.line,
|
|
717
|
+
state: r.state,
|
|
718
|
+
...(row.call.ref ? { ref: true } : {}),
|
|
719
|
+
...(r.inherited ? { inherited: true } : {}),
|
|
720
|
+
...(!row.call.ref && !r.inherited && row.call.alias ? { alias: row.call.alias } : {}),
|
|
721
|
+
});
|
|
722
|
+
} else if (r.state === "AMBIGUOUS" && r.candidates.includes(target.id)) maybe++;
|
|
723
|
+
}
|
|
724
|
+
if (target.kind === "route") {
|
|
725
|
+
for (const row of urlIndex(model)) {
|
|
726
|
+
const r = resolveUrl(model, row.u);
|
|
727
|
+
if (r.state === "ROUTE" && r.target === target.id) {
|
|
728
|
+
const k = `${row.sym.id} ${row.u.line}`;
|
|
729
|
+
if (seen.has(k)) continue;
|
|
730
|
+
seen.add(k);
|
|
731
|
+
confident.push({ sym: row.sym, line: row.u.line, state: "ROUTE", url: `${row.u.method} ${row.u.url}` });
|
|
732
|
+
} else if (r.state === "AMBIGUOUS" && r.candidates.includes(target.id)) maybe++;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
stableCallers(confident);
|
|
736
|
+
return { confident, maybe };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// EW2: the stored reverse map and the on-read walk visit callers in different
|
|
740
|
+
// orders. Same rows, and a card that reorders itself depending on whether a
|
|
741
|
+
// cache file happens to exist is a card nobody can diff. One total order, both
|
|
742
|
+
// ways.
|
|
743
|
+
function stableCallers(rows) {
|
|
744
|
+
rows.sort((a, b) => a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id)));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function calleesOf(model, sym) {
|
|
748
|
+
const out = [];
|
|
749
|
+
const seen = new Set();
|
|
750
|
+
// S1 (v1.8.2 W8): on the sharded fast path the resolutions were computed by
|
|
751
|
+
// the update and stored, so this loop reads instead of resolving. The DEDUPE
|
|
752
|
+
// and the order are unchanged — the rows are keyed by the call's INDEX, so
|
|
753
|
+
// the same calls survive in the same order and the card is the same bytes.
|
|
754
|
+
const fwd = model.callsFor ? model.callsFor(sym) : null;
|
|
755
|
+
const stored = fwd ? new Map((fwd.r || []).map((row) => [row[0], row])) : null;
|
|
756
|
+
const calls = sym.calls || [];
|
|
757
|
+
for (let i = 0; i < calls.length; i++) {
|
|
758
|
+
const c = calls[i];
|
|
759
|
+
if (seen.has(c.name)) continue;
|
|
760
|
+
seen.add(c.name);
|
|
761
|
+
if (stored) {
|
|
762
|
+
const row = stored.get(i);
|
|
763
|
+
const S = require("./graph-shard.js");
|
|
764
|
+
out.push({
|
|
765
|
+
call: c,
|
|
766
|
+
res: row
|
|
767
|
+
? row[1] === S.STATE_CODE.AMBIGUOUS
|
|
768
|
+
? { state: "AMBIGUOUS", target: null, candidates: (fwd.cands || [])[row[4]] || [] }
|
|
769
|
+
: { state: S.CODE_STATE[row[1]], target: row[2], ...(row[3] === 1 ? { inherited: true } : {}) }
|
|
770
|
+
: { state: "UNRESOLVED", target: null, candidates: [] },
|
|
771
|
+
});
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
out.push({ call: c, res: resolveCall(model, sym.file, sym, c) });
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── targets ─────────────────────────────────────────────────────────────────
|
|
780
|
+
function findTarget(model, q) {
|
|
781
|
+
const s = norm(q);
|
|
782
|
+
const fl = /^(.+?):(\d+)$/.exec(s);
|
|
783
|
+
if (fl && model.fileSet.has(fl[1])) {
|
|
784
|
+
const line = Number(fl[2]);
|
|
785
|
+
const inside = (model.byFile[fl[1]].symbols || [])
|
|
786
|
+
.filter((x) => x.kind !== "module" && x.lines[0] <= line && line <= x.lines[1])
|
|
787
|
+
.sort((a, b) => a.lines[1] - a.lines[0] - (b.lines[1] - b.lines[0]));
|
|
788
|
+
return inside.length ? { kind: "symbol", sym: inside[0] } : { kind: "file", rel: fl[1] };
|
|
789
|
+
}
|
|
790
|
+
if (model.fileSet.has(s)) return { kind: "file", rel: s };
|
|
791
|
+
if (model.symbols.has(s)) return { kind: "symbol", sym: model.symbols.get(s) };
|
|
792
|
+
const exact = [];
|
|
793
|
+
for (const x of model.symbols.values()) if (x.kind !== "module" && x.qname === s) exact.push(x);
|
|
794
|
+
if (exact.length === 1) return { kind: "symbol", sym: exact[0] };
|
|
795
|
+
// W1: `GET /orders/search` or `/orders/search` — a URL, resolved like one a
|
|
796
|
+
// test would send, so a route is found by its FULL path, mounts included.
|
|
797
|
+
const asUrl = /^(?:([A-Za-z]+) )?(\/\S*)$/.exec(s);
|
|
798
|
+
if (!exact.length && asUrl) {
|
|
799
|
+
const r = resolveUrl(model, { method: asUrl[1] ? asUrl[1].toUpperCase() : "ANY", url: asUrl[2] });
|
|
800
|
+
if (r.state === "ROUTE") return { kind: "symbol", sym: model.symbols.get(r.target) };
|
|
801
|
+
if (r.state === "AMBIGUOUS") return { kind: "ambiguous", candidates: r.candidates.map((id) => model.symbols.get(id)).filter(Boolean) };
|
|
802
|
+
}
|
|
803
|
+
// Same qname, same file: one symbol declared more than once (a Go build tag,
|
|
804
|
+
// a Python redefinition). The widest one is the implementation.
|
|
805
|
+
if (exact.length > 1 && exact.every((x) => x.file === exact[0].file)) {
|
|
806
|
+
return { kind: "symbol", sym: exact.slice().sort((a, b) => b.lines[1] - b.lines[0] - (a.lines[1] - a.lines[0]))[0] };
|
|
807
|
+
}
|
|
808
|
+
if (exact.length > 1) return { kind: "ambiguous", candidates: exact };
|
|
809
|
+
const named = model.byName.get(s) || [];
|
|
810
|
+
if (named.length === 1) return { kind: "symbol", sym: named[0] };
|
|
811
|
+
if (named.length > 1) return { kind: "ambiguous", candidates: named };
|
|
812
|
+
const ql = s.toLowerCase();
|
|
813
|
+
const nearest = [];
|
|
814
|
+
for (const [n, arr] of model.byName) if (n.toLowerCase().includes(ql)) nearest.push(...arr);
|
|
815
|
+
return { kind: "none", nearest: nearest.slice(0, 5) };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const where = (sym, line) => `${sym.file}:${line || sym.lines[0]}`;
|
|
819
|
+
const brief = (sym) => ({ id: sym.id, qname: sym.qname, kind: sym.kind, file: sym.file, lines: sym.lines });
|
|
820
|
+
|
|
821
|
+
function blobState(model, rel) {
|
|
822
|
+
if (model.blobStates.has(rel)) return model.blobStates.get(rel);
|
|
823
|
+
let state;
|
|
824
|
+
if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) state = "deleted";
|
|
825
|
+
else {
|
|
826
|
+
const r = spawnSync("git", ["hash-object", "--", rel], { cwd: model.root, encoding: "utf8" });
|
|
827
|
+
state = r.status !== 0 ? "unknown" : r.stdout.trim() === (model.byFile[rel] || {}).blob ? "current" : "changed";
|
|
828
|
+
}
|
|
829
|
+
model.blobStates.set(rel, state);
|
|
830
|
+
return state;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// S3 (v1.8.2 W2): one `git hash-object --stdin-paths` for every file the
|
|
834
|
+
// cards of this read will name. `ctx` on five targets spawned git five times,
|
|
835
|
+
// and a Windows process start is ~30 ms each.
|
|
836
|
+
function warmBlobs(model, queries) {
|
|
837
|
+
const rels = new Set();
|
|
838
|
+
for (const q of queries || []) {
|
|
839
|
+
const t = findTarget(model, q);
|
|
840
|
+
if (t.kind === "symbol") rels.add(t.sym.file);
|
|
841
|
+
else if (t.kind === "file") rels.add(t.rel);
|
|
842
|
+
}
|
|
843
|
+
const onDisk = [...rels].filter((rel) => !model.blobStates.has(rel) && fs.existsSync(path.join(model.root, ...rel.split("/"))));
|
|
844
|
+
for (const rel of rels) if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) model.blobStates.set(rel, "deleted");
|
|
845
|
+
if (!onDisk.length) return;
|
|
846
|
+
const h = spawnSync("git", ["hash-object", "--stdin-paths"], { cwd: model.root, encoding: "utf8", input: onDisk.join("\n") + "\n" });
|
|
847
|
+
if (h.status !== 0) return;
|
|
848
|
+
const out = h.stdout.split(/\r?\n/).filter(Boolean);
|
|
849
|
+
onDisk.forEach((rel, i) => {
|
|
850
|
+
if (out[i]) model.blobStates.set(rel, out[i] === (model.byFile[rel] || {}).blob ? "current" : "changed");
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// EW1: what the extractor saw of this file, for the card header. A card that
|
|
855
|
+
// hides a gap is worse than no card — the reader takes silence for absence.
|
|
856
|
+
function coverageTag(model, rel) {
|
|
857
|
+
const f = model.byFile[rel] || {};
|
|
858
|
+
if (f.skipped) return ` · coverage skipped:${f.skipped}`;
|
|
859
|
+
if (f.coverage !== "partial") return "";
|
|
860
|
+
const r = (f.partial || []).map((g) => `${g[0]}-${g[1]}`).join(",");
|
|
861
|
+
return ` · coverage partial ${r}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const BLOB_LABEL = {
|
|
865
|
+
current: "current",
|
|
866
|
+
changed: "CHANGED since index — hints only (run: orc graph update)",
|
|
867
|
+
deleted: "DELETED since index (run: orc graph update)",
|
|
868
|
+
unknown: "freshness unknown",
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// Pick lines by priority until the budget is spent, then print them in display
|
|
872
|
+
// order. The footer always says what the card hid.
|
|
873
|
+
//
|
|
874
|
+
// v1.8.2 W3 — TWO changes, both about the tokens the card itself adds:
|
|
875
|
+
// · the footer is printed ONLY when something was hidden, a call was
|
|
876
|
+
// unresolved, or `--source` was cut. A card that hid nothing used to spend
|
|
877
|
+
// a whole line saying so, on every card, on every later turn of the agent
|
|
878
|
+
// that received it. `budget` stays in the JSON either way.
|
|
879
|
+
// · `tail` is the `--source` block (D1). It is charged against the SAME
|
|
880
|
+
// budget and it goes LAST, so the rows always win: source takes what the
|
|
881
|
+
// card left, line by line, and the footer says how many lines it cut.
|
|
882
|
+
function fit(items, budget, footer, tail) {
|
|
883
|
+
// The reserve is the LONGEST footer this card could print — one hidden
|
|
884
|
+
// count per section present — so the final `used` can never pass the
|
|
885
|
+
// budget, whatever ends up hidden. (W2: a card with five sections overran a
|
|
886
|
+
// 100-token budget by five tokens on the footer alone.)
|
|
887
|
+
const worst = {};
|
|
888
|
+
for (const it of items) if (it.pri > 0) worst[it.section] = 99;
|
|
889
|
+
const reserve = tok(footer(0, worst, tail ? { kept: 0, want: tail.want, cut: tail.want } : null)) + 2;
|
|
890
|
+
let used = 0;
|
|
891
|
+
const keep = new Set();
|
|
892
|
+
const hidden = {};
|
|
893
|
+
const order = items.map((it, i) => i).sort((a, b) => items[a].pri - items[b].pri || a - b);
|
|
894
|
+
for (const i of order) {
|
|
895
|
+
const t = tok(items[i].text) + 1;
|
|
896
|
+
if (items[i].pri === 0 || used + t + reserve <= budget) {
|
|
897
|
+
keep.add(i);
|
|
898
|
+
used += t;
|
|
899
|
+
} else hidden[items[i].section] = (hidden[items[i].section] || 0) + 1;
|
|
900
|
+
}
|
|
901
|
+
const lines = items.filter((_, i) => keep.has(i)).map((it) => it.text);
|
|
902
|
+
let source = null;
|
|
903
|
+
if (tail) {
|
|
904
|
+
const open = " ```" + (tail.fence || "");
|
|
905
|
+
const close = " ```";
|
|
906
|
+
let left = budget - used - reserve - tok(tail.head) - tok(open) - tok(close) - 3;
|
|
907
|
+
const body = [];
|
|
908
|
+
for (const ln of tail.lines) {
|
|
909
|
+
const t = tok(ln) + 1;
|
|
910
|
+
if (t > left) break;
|
|
911
|
+
left -= t;
|
|
912
|
+
body.push(ln);
|
|
913
|
+
}
|
|
914
|
+
if (body.length) {
|
|
915
|
+
const block = [tail.head, open, ...body, close];
|
|
916
|
+
used += block.reduce((a, l) => a + tok(l) + 1, 0);
|
|
917
|
+
lines.push(...block);
|
|
918
|
+
}
|
|
919
|
+
source = { kept: body.length, want: tail.want, cut: tail.want - body.length };
|
|
920
|
+
}
|
|
921
|
+
const foot = footer(used, hidden, source);
|
|
922
|
+
if (foot) lines.push(foot);
|
|
923
|
+
return { card: lines.join("\n"), kept: keep, hidden, source, used: used + tok(foot) };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ── E6: the tree format ─────────────────────────────────────────────────────
|
|
927
|
+
// The prose card repeats a label on every row — `function`, `:12-40`,
|
|
928
|
+
// `exported`, `fan-in 3`, `(via X)`. A card is sent again on every later turn of
|
|
929
|
+
// the agent that received it, so a word repeated on forty rows is paid forty
|
|
930
|
+
// times, forty turns over. The tree format names each column ONCE in a header
|
|
931
|
+
// and then prints only values.
|
|
932
|
+
//
|
|
933
|
+
// It is a FORMAT, never a filter: the same rows, the same order, the same JSON.
|
|
934
|
+
// `--format prose` stays the default, because a shorter card that is read wrong
|
|
935
|
+
// costs more than it saves.
|
|
936
|
+
const KIND_SHORT = { function: "fn", method: "me", class: "cl", route: "rt", module: "mo", const: "co", var: "va", type: "ty", interface: "in", enum: "en", struct: "st" };
|
|
937
|
+
const STATE_SHORT = { LOCAL: "L", IMPORT: "I", UNIQUE: "U", ROUTE: "R", AMBIGUOUS: "A", UNRESOLVED: "-" };
|
|
938
|
+
const shortKind = (k) => KIND_SHORT[k] || String(k).slice(0, 2);
|
|
939
|
+
|
|
940
|
+
// The longest directory every path shares. A prefix line is only worth writing
|
|
941
|
+
// when it saves REAL bytes — CBM's rule, and the reason is that a header nobody
|
|
942
|
+
// needs is a header everybody still reads.
|
|
943
|
+
function dirPrefix(paths) {
|
|
944
|
+
if (paths.length < 2) return "";
|
|
945
|
+
const parts = paths.map((p) => p.split("/").slice(0, -1));
|
|
946
|
+
let n = 0;
|
|
947
|
+
outer: for (; n < parts[0].length; n++) {
|
|
948
|
+
for (const q of parts) if (q[n] !== parts[0][n]) break outer;
|
|
949
|
+
}
|
|
950
|
+
if (!n) return "";
|
|
951
|
+
const pre = parts[0].slice(0, n).join("/") + "/";
|
|
952
|
+
const saved = pre.length * paths.length - (pre.length + 6); // " dir " + the prefix
|
|
953
|
+
const total = paths.reduce((a, p) => a + p.length, 0);
|
|
954
|
+
return saved >= 64 && saved >= total * 0.15 ? pre : "";
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function hiddenText(hidden) {
|
|
958
|
+
const parts = Object.entries(hidden).map(([k, v]) => `${v} ${k}`);
|
|
959
|
+
return parts.length ? ` · hidden: ${parts.join(", ")} (raise --budget)` : "";
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── D1 (v1.8.2 W3): the card and the range in ONE call ──────────────────────
|
|
963
|
+
// A card is a LOCATOR: it names a range and the agent then reads it. That is
|
|
964
|
+
// two round trips for one question, and the second one is a `Read` the CLI
|
|
965
|
+
// could have answered. `--source` prints the range the card just named.
|
|
966
|
+
//
|
|
967
|
+
// It does NOT change the read ladder. A file you are about to EDIT is still
|
|
968
|
+
// read in full with `Read` first (exception 1) — an `old_string` rebuilt from
|
|
969
|
+
// a CLI print is the same corruption bug as one rebuilt from an outline.
|
|
970
|
+
// `--source` is for the CALLER'S range, the callee's, the neighbour you will
|
|
971
|
+
// not touch. The hook never prints source; this is a CLI answer to a CLI call.
|
|
972
|
+
const SOURCE_DEFAULT = 80;
|
|
973
|
+
const SOURCE_CAP = 200;
|
|
974
|
+
const FILE_HEAD_LINES = 40;
|
|
975
|
+
const FENCE_BY_EXT = {
|
|
976
|
+
".ts": "ts", ".tsx": "tsx", ".js": "js", ".jsx": "jsx", ".mjs": "js", ".cjs": "js", ".mts": "ts", ".cts": "ts",
|
|
977
|
+
".py": "python", ".go": "go", ".rb": "ruby", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".cs": "csharp",
|
|
978
|
+
".php": "php", ".sh": "bash", ".sql": "sql", ".json": "json", ".yml": "yaml", ".yaml": "yaml", ".md": "md",
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
function sourceLimit(want) {
|
|
982
|
+
const n = Number(want);
|
|
983
|
+
if (!Number.isFinite(n) || n <= 0) return SOURCE_DEFAULT;
|
|
984
|
+
return Math.min(SOURCE_CAP, Math.floor(n));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// The lines are read from the CURRENT bytes on disk, and the range comes from
|
|
988
|
+
// the index. When the two disagree the card header already says CHANGED, and
|
|
989
|
+
// the source head repeats it — a range that has moved is a wrong answer given
|
|
990
|
+
// confidently, which is the one thing this whole feature must never do.
|
|
991
|
+
function sourceTail(model, rel, from, to, want, fresh) {
|
|
992
|
+
if (fresh === "deleted") return null;
|
|
993
|
+
let text;
|
|
994
|
+
try {
|
|
995
|
+
text = fs.readFileSync(path.join(model.root, ...rel.split("/")), "utf8");
|
|
996
|
+
} catch (_) {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
const all = text.split(/\r?\n/);
|
|
1000
|
+
const start = Math.max(1, Number(from) || 1);
|
|
1001
|
+
const end = Math.max(start, Math.min(all.length, Number(to) || all.length));
|
|
1002
|
+
const limit = sourceLimit(want);
|
|
1003
|
+
const stop = Math.min(end, start + limit - 1);
|
|
1004
|
+
const width = String(stop).length;
|
|
1005
|
+
const lines = [];
|
|
1006
|
+
for (let i = start; i <= stop; i++) lines.push(` ${String(i).padStart(width)} ${all[i - 1] === undefined ? "" : all[i - 1]}`);
|
|
1007
|
+
if (!lines.length) return null;
|
|
1008
|
+
const moved = fresh !== "current" ? " — CHANGED since index, the range may have moved" : "";
|
|
1009
|
+
return {
|
|
1010
|
+
head: ` source ${rel}:${start}-${stop}${moved}`,
|
|
1011
|
+
fence: FENCE_BY_EXT[posix.extname(rel)] || "",
|
|
1012
|
+
want: end - start + 1,
|
|
1013
|
+
lines,
|
|
1014
|
+
file: rel,
|
|
1015
|
+
from: start,
|
|
1016
|
+
to: stop,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function sourceText(tail, kept) {
|
|
1021
|
+
return tail.lines.slice(0, kept).join("\n");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function sourceNote(source) {
|
|
1025
|
+
if (!source) return "";
|
|
1026
|
+
if (!source.kept) return ` · source not shown (${source.want} lines, raise --budget)`;
|
|
1027
|
+
return source.cut ? ` · source ${source.kept} lines · ${source.cut} cut (raise --budget)` : "";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ── §6 (v1.8.2 W3): the state word, once ────────────────────────────────────
|
|
1031
|
+
// `LOCAL` / `IMPORT` / `UNIQUE` is spelled on every row of every card, and a
|
|
1032
|
+
// card is sent again on each later turn of the agent that received it. The
|
|
1033
|
+
// one-letter form plus ONE legend line says the same thing — but only when the
|
|
1034
|
+
// card has enough rows to pay for the legend. So both bodies are built and the
|
|
1035
|
+
// SMALLER one is printed, the same rule `--format tree` already follows, and
|
|
1036
|
+
// the answer says which one it used. It can never be worse than the long form.
|
|
1037
|
+
const STATE_LEGEND = " states: L local · I import · U unique · R route · A ambiguous";
|
|
1038
|
+
const stateWord = (s, short) => (short ? STATE_SHORT[s] || s : s);
|
|
1039
|
+
|
|
1040
|
+
// Rewrites `items` in place to the short body and inserts the legend, but only
|
|
1041
|
+
// when the short body PLUS the legend is smaller than the long one. Returns
|
|
1042
|
+
// which body was chosen.
|
|
1043
|
+
function pickShort(items) {
|
|
1044
|
+
let long = 0;
|
|
1045
|
+
let short = STATE_LEGEND.length;
|
|
1046
|
+
for (const it of items) {
|
|
1047
|
+
long += it.text.length;
|
|
1048
|
+
short += it.short.length;
|
|
1049
|
+
}
|
|
1050
|
+
if (short >= long) return false;
|
|
1051
|
+
for (const it of items) it.text = it.short;
|
|
1052
|
+
items.splice(1, 0, { pri: 0, section: "header", text: STATE_LEGEND, short: STATE_LEGEND, data: null });
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ── ctx ─────────────────────────────────────────────────────────────────────
|
|
1057
|
+
function ctx(model, query, opts) {
|
|
1058
|
+
const depth = Math.max(1, Math.min(4, Number(opts.depth) || 2));
|
|
1059
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
|
|
1060
|
+
const t = findTarget(model, query);
|
|
1061
|
+
if (t.kind === "none") return { ok: false, state: "not-found", query, nearest: t.nearest.map(brief), exit: 4 };
|
|
1062
|
+
if (t.kind === "ambiguous") return { ok: false, state: "ambiguous-target", query, candidates: t.candidates.map(brief), exit: 4 };
|
|
1063
|
+
if (t.kind === "file") return fileCard(model, t.rel, budget, opts);
|
|
1064
|
+
|
|
1065
|
+
const sym = t.sym;
|
|
1066
|
+
const fresh = blobState(model, sym.file);
|
|
1067
|
+
const items = [];
|
|
1068
|
+
// `short` is the same row with the one-letter state word. Both bodies are
|
|
1069
|
+
// built; the smaller one is printed (§6).
|
|
1070
|
+
const push = (pri, section, text, data, short) => items.push({ pri, section, text, short: short == null ? text : short, data });
|
|
1071
|
+
push(0, "header", `${sym.qname} ${sym.file}:${sym.lines[0]}-${sym.lines[1]} [blob ${String((model.byFile[sym.file] || {}).blob || "").slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, sym.file)}]`);
|
|
1072
|
+
// A note is shown ONLY while the body still hashes the same. An old
|
|
1073
|
+
// sentence about code that has since changed is never printed.
|
|
1074
|
+
const note = N.noteFor(model.notes, sym);
|
|
1075
|
+
if (note)
|
|
1076
|
+
push(
|
|
1077
|
+
1,
|
|
1078
|
+
"note",
|
|
1079
|
+
note.current ? ` ${note.source === "doc" ? "doc " : "note"} ${note.text} (${note.model} · current)` : " note: stale (body changed) — it is re-noted at the next batch",
|
|
1080
|
+
{ item: "note", ...note }
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
const callers = callersOf(model, sym);
|
|
1084
|
+
callers.confident.sort((a, b) => (a.sym.file + a.line).localeCompare(b.sym.file + b.line));
|
|
1085
|
+
for (const c of callers.confident) {
|
|
1086
|
+
const row = (short) =>
|
|
1087
|
+
c.url
|
|
1088
|
+
? ` ← reached via ${c.url} ${where(c.sym, c.line)} ${stateWord("ROUTE", short)}`
|
|
1089
|
+
: ` ← ${c.ref ? "used by " : "called by"} ${c.sym.qname} ${where(c.sym, c.line)} ${stateWord(c.state, short)}${c.inherited ? " (inherited)" : c.alias ? ` (via ${c.alias})` : ""}`;
|
|
1090
|
+
push(
|
|
1091
|
+
2,
|
|
1092
|
+
"callers",
|
|
1093
|
+
row(false),
|
|
1094
|
+
{
|
|
1095
|
+
item: "caller",
|
|
1096
|
+
id: c.sym.id,
|
|
1097
|
+
qname: c.sym.qname,
|
|
1098
|
+
file: c.sym.file,
|
|
1099
|
+
line: c.line,
|
|
1100
|
+
state: c.state,
|
|
1101
|
+
...(c.ref ? { ref: true } : {}),
|
|
1102
|
+
...(c.url ? { url: c.url } : {}),
|
|
1103
|
+
...(c.inherited ? { inherited: true } : {}),
|
|
1104
|
+
...(c.alias ? { alias: c.alias } : {}),
|
|
1105
|
+
},
|
|
1106
|
+
row(true)
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
if (callers.maybe) push(4, "callers", ` ← maybe ${callers.maybe} more caller(s) name it among AMBIGUOUS candidates`, { item: "maybe_callers", n: callers.maybe });
|
|
1110
|
+
|
|
1111
|
+
let unresolved = 0;
|
|
1112
|
+
const visited = new Set([sym.id]);
|
|
1113
|
+
const walk = (s, level, indent) => {
|
|
1114
|
+
for (const { call, res } of calleesOf(model, s)) {
|
|
1115
|
+
if (res.state === "UNRESOLVED") {
|
|
1116
|
+
if (level === 1) unresolved++;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (res.state === "AMBIGUOUS") {
|
|
1120
|
+
if (level > 1) continue;
|
|
1121
|
+
const cs = res.candidates.map((id) => model.symbols.get(id)).filter(Boolean);
|
|
1122
|
+
const shown = cs.slice(0, 3).map((x) => where(x)).join(", ");
|
|
1123
|
+
const amb = (short) => `${indent}→ calls ${call.name} ${stateWord("AMBIGUOUS", short)} (${cs.length}): ${shown}${cs.length > 3 ? `, +${cs.length - 3}` : ""}`;
|
|
1124
|
+
push(4, "calls", amb(false), { item: "call", name: call.name, line: call.line, state: "AMBIGUOUS", candidates: cs.map((x) => x.id) }, amb(true));
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const target = model.symbols.get(res.target);
|
|
1128
|
+
if (!target) continue;
|
|
1129
|
+
const mark = res.inherited ? " (inherited)" : call.alias ? ` (via ${call.alias})` : "";
|
|
1130
|
+
const row = (short) => `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${stateWord(res.state, short)}${mark}`;
|
|
1131
|
+
push(level === 1 ? 2 : 3 + level, "calls", row(false), {
|
|
1132
|
+
item: "call",
|
|
1133
|
+
name: call.name,
|
|
1134
|
+
line: call.line,
|
|
1135
|
+
state: res.state,
|
|
1136
|
+
target: brief(target),
|
|
1137
|
+
level,
|
|
1138
|
+
...(call.ref ? { ref: true } : {}),
|
|
1139
|
+
...(res.inherited ? { inherited: true } : {}),
|
|
1140
|
+
...(call.alias ? { alias: call.alias } : {}),
|
|
1141
|
+
}, row(true));
|
|
1142
|
+
if (level < depth && !visited.has(target.id)) {
|
|
1143
|
+
visited.add(target.id);
|
|
1144
|
+
const cn = N.noteFor(model.notes, target);
|
|
1145
|
+
if (cn && cn.current) push(2 + level, "notes", `${indent} └ ${cn.source === "doc" ? "doc" : "note"} ${cn.text}`, { item: "callee_note", of: target.id, text: cn.text, source: cn.source });
|
|
1146
|
+
for (const e of target.effects || []) push(2 + level, "effects", `${indent} └ effect ${e.type} "${e.text}" ${target.file}:${e.line}`, { item: "effect", of: target.id, ...e });
|
|
1147
|
+
walk(target, level + 1, indent + " ");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
walk(sym, 1, " ");
|
|
1152
|
+
for (const e of sym.effects || []) push(1, "effects", ` effect ${e.type} "${e.text}" ${sym.file}:${e.line}`, { item: "effect", of: sym.id, ...e });
|
|
1153
|
+
|
|
1154
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([sym.file]) : [];
|
|
1155
|
+
if (wiki.length) push(6, "wiki", ` wiki ${wiki.join(", ")}`, { item: "wiki", docs: wiki });
|
|
1156
|
+
const tests = [...new Set(callers.confident.filter((c) => TEST_FILE.test(c.sym.file)).map((c) => c.sym.file))];
|
|
1157
|
+
if (tests.length) push(5, "tests", ` tests ${tests.join(", ")}`, { item: "tests", files: tests });
|
|
1158
|
+
|
|
1159
|
+
const short = pickShort(items);
|
|
1160
|
+
// The footer is printed ONLY when the card hid something, dropped an
|
|
1161
|
+
// unresolved call, or cut the source block (§6).
|
|
1162
|
+
const footer = (used, hidden, source) => {
|
|
1163
|
+
const why = `${unresolved ? ` · ${unresolved} unresolved call(s) not shown` : ""}${hiddenText(hidden)}${sourceNote(source)}`;
|
|
1164
|
+
return why ? ` budget ${used}/${budget} tokens${why}` : "";
|
|
1165
|
+
};
|
|
1166
|
+
const tail = opts.source === undefined ? null : sourceTail(model, sym.file, sym.lines[0], sym.lines[1], opts.source, fresh);
|
|
1167
|
+
const f = fit(items, budget, footer, tail);
|
|
1168
|
+
const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
1169
|
+
return {
|
|
1170
|
+
ok: true,
|
|
1171
|
+
state: "found",
|
|
1172
|
+
target: brief(sym),
|
|
1173
|
+
blob: fresh,
|
|
1174
|
+
exported: sym.exported,
|
|
1175
|
+
states: short ? "short" : "long",
|
|
1176
|
+
note: note || null,
|
|
1177
|
+
callers: kept.filter((d) => d.item ==="caller"),
|
|
1178
|
+
maybe_callers: callers.maybe,
|
|
1179
|
+
calls: kept.filter((d) => d.item ==="call"),
|
|
1180
|
+
effects: kept.filter((d) => d.item ==="effect"),
|
|
1181
|
+
wiki,
|
|
1182
|
+
tests,
|
|
1183
|
+
unresolved,
|
|
1184
|
+
source: tail && f.source && f.source.kept ? { file: tail.file, from: tail.from, to: tail.from + f.source.kept - 1, text: sourceText(tail, f.source.kept), cut: f.source.cut } : null,
|
|
1185
|
+
hidden: f.hidden,
|
|
1186
|
+
budget: { used: f.used, max: budget },
|
|
1187
|
+
card: f.card,
|
|
1188
|
+
exit: 0,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function importersOf(model, rel) {
|
|
1193
|
+
const out = [];
|
|
1194
|
+
for (const other of model.fileSet) {
|
|
1195
|
+
if (other === rel) continue;
|
|
1196
|
+
const { map, includes } = fileBindings(model, other);
|
|
1197
|
+
let hit = includes.includes(rel);
|
|
1198
|
+
if (!hit) for (const bd of map.values()) if (bd.files.includes(rel)) { hit = true; break; }
|
|
1199
|
+
if (!hit) for (const imp of (model.byFile[other].imports || [])) if (!(imp.bindings || []).length && importTargets(model, other, model.byFile[other].lang, imp).includes(rel)) { hit = true; break; }
|
|
1200
|
+
if (hit) out.push(other);
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function fileCard(model, rel, budget, opts) {
|
|
1206
|
+
const f = model.byFile[rel];
|
|
1207
|
+
const fresh = blobState(model, rel);
|
|
1208
|
+
const items = [];
|
|
1209
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
1210
|
+
const tree = opts.format === "tree";
|
|
1211
|
+
const allSyms = (f.symbols || []).filter((s) => s.kind !== "module");
|
|
1212
|
+
push(0, "header", `${rel} (${allSyms.length} symbols) [blob ${String(f.blob).slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, rel)}]`);
|
|
1213
|
+
// E7: ordered by IMPORTANCE, not by raw fan-in. A private helper called from
|
|
1214
|
+
// forty places is not more useful to a reader than the exported entry point
|
|
1215
|
+
// called from three, and a card that leads with the helper buries the answer.
|
|
1216
|
+
const SIG = require("./graph-signals.js");
|
|
1217
|
+
const allRows = allSyms.map((s) => {
|
|
1218
|
+
const fanIn = callersOf(model, s).confident.length;
|
|
1219
|
+
return { s, fanIn, imp: SIG.importance(model, s, fanIn) };
|
|
1220
|
+
});
|
|
1221
|
+
allRows.sort((a, b) => b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0]);
|
|
1222
|
+
// `--offset` pages the SYMBOL rows. Paging is not truncation: `has_more` says
|
|
1223
|
+
// there is a next page, and the budget footer still says what it hid.
|
|
1224
|
+
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
1225
|
+
const rows = allRows.slice(offset);
|
|
1226
|
+
const syms = rows.map((r) => r.s);
|
|
1227
|
+
// Both bodies are built, and the SMALLER one is printed. A column header and
|
|
1228
|
+
// a directory line are FIXED costs: on a three-row card they cost more than
|
|
1229
|
+
// the columns save, which is exactly what the first measurement showed. So
|
|
1230
|
+
// `--format tree` means "use the tree WHEN it is smaller", and the answer says
|
|
1231
|
+
// which one it used. It can never be worse than the prose card.
|
|
1232
|
+
const symRows = rows.map(({ s, fanIn, imp }) => ({
|
|
1233
|
+
prose: ` ${s.kind.padEnd(8)} ${s.qname} :${s.lines[0]}-${s.lines[1]}${s.exported ? " exported" : ""} fan-in ${fanIn}`,
|
|
1234
|
+
tree: ` ${shortKind(s.kind)} ${s.qname} ${s.lines[0]}-${s.lines[1]} ${s.exported ? "E" : "-"} ${fanIn}`,
|
|
1235
|
+
data: { item: "symbol", ...brief(s), fan_in: fanIn, importance: imp },
|
|
1236
|
+
}));
|
|
1237
|
+
const SYM_HEAD = " kind name lines exp fan-in";
|
|
1238
|
+
const useTree =
|
|
1239
|
+
tree && symRows.length > 0 &&
|
|
1240
|
+
SYM_HEAD.length + symRows.reduce((a, r) => a + r.tree.length, 0) < symRows.reduce((a, r) => a + r.prose.length, 0);
|
|
1241
|
+
if (useTree) push(1, "symbols", SYM_HEAD);
|
|
1242
|
+
for (const r of symRows) push(2, "symbols", useTree ? r.tree : r.prose, r.data);
|
|
1243
|
+
const imports = [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))];
|
|
1244
|
+
const importers = importersOf(model, rel);
|
|
1245
|
+
if (useTree) {
|
|
1246
|
+
const pre = dirPrefix([...imports, ...importers]);
|
|
1247
|
+
const strip = (a) => a.map((x) => (pre && x.startsWith(pre) ? x.slice(pre.length) : x));
|
|
1248
|
+
if (pre) push(3, "imports", ` dir ${pre}`, null);
|
|
1249
|
+
if (imports.length) push(3, "imports", ` in ${strip(imports).join(" ")}`, { item: "imports", files: imports });
|
|
1250
|
+
if (importers.length) push(3, "importers", ` out ${strip(importers).join(" ")}`, { item: "importers", files: importers });
|
|
1251
|
+
} else {
|
|
1252
|
+
if (imports.length) push(3, "imports", ` imports ${imports.join(", ")}`, { item: "imports", files: imports });
|
|
1253
|
+
if (importers.length) push(3, "importers", ` imported by ${importers.join(", ")}`, { item: "importers", files: importers });
|
|
1254
|
+
}
|
|
1255
|
+
// E7: the tests that reach this file — by CALL (an edge into one of its
|
|
1256
|
+
// symbols from a test file) and by NAME (`x.test.js` beside `x.js`). Both,
|
|
1257
|
+
// because a test that imports a module without naming a symbol is still its
|
|
1258
|
+
// test, and the graph alone would never find it.
|
|
1259
|
+
const fileTests = [
|
|
1260
|
+
...new Set([
|
|
1261
|
+
...allSyms.flatMap((sym) => callersOf(model, sym).confident.map((c) => c.sym.file)).filter((x) => TEST_FILE.test(x)),
|
|
1262
|
+
...importers.filter((x) => TEST_FILE.test(x)),
|
|
1263
|
+
...SIG.testsByName(model, rel),
|
|
1264
|
+
]),
|
|
1265
|
+
].sort();
|
|
1266
|
+
if (fileTests.length) push(4, "tests", ` tests ${fileTests.join(useTree ? " " : ", ")}`, { item: "tests", files: fileTests });
|
|
1267
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([rel]) : [];
|
|
1268
|
+
if (wiki.length) push(5, "wiki", ` wiki ${wiki.join(useTree ? " " : ", ")}`, { item: "wiki", docs: wiki });
|
|
1269
|
+
const footer = (used, hidden, source) => {
|
|
1270
|
+
const why = `${hiddenText(hidden)}${sourceNote(source)}`;
|
|
1271
|
+
return why ? ` budget ${used}/${budget} tokens${why}` : "";
|
|
1272
|
+
};
|
|
1273
|
+
// D1 on a FILE card is the file's head — the imports, the exports and the
|
|
1274
|
+
// first declaration, which is what a reader opening an unknown file looks at.
|
|
1275
|
+
const tail = opts.source === undefined ? null : sourceTail(model, rel, 1, Math.min(FILE_HEAD_LINES, sourceLimit(opts.source)), opts.source, fresh);
|
|
1276
|
+
const fitted = fit(items, budget, footer, tail);
|
|
1277
|
+
const kept = items.filter((_, i) => fitted.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
1278
|
+
const keptSymbols = kept.filter((d) => d.item === "symbol");
|
|
1279
|
+
return {
|
|
1280
|
+
ok: true,
|
|
1281
|
+
state: "found",
|
|
1282
|
+
format: useTree ? "tree" : "prose",
|
|
1283
|
+
offset,
|
|
1284
|
+
has_more: offset + keptSymbols.length < allRows.length,
|
|
1285
|
+
total_symbols: allRows.length,
|
|
1286
|
+
file: rel,
|
|
1287
|
+
blob: fresh,
|
|
1288
|
+
symbols: kept.filter((d) => d.item ==="symbol"),
|
|
1289
|
+
tests: fileTests,
|
|
1290
|
+
imports,
|
|
1291
|
+
importers,
|
|
1292
|
+
wiki,
|
|
1293
|
+
source: tail && fitted.source && fitted.source.kept ? { file: tail.file, from: tail.from, to: tail.from + fitted.source.kept - 1, text: sourceText(tail, fitted.source.kept), cut: fitted.source.cut } : null,
|
|
1294
|
+
hidden: fitted.hidden,
|
|
1295
|
+
budget: { used: fitted.used, max: budget },
|
|
1296
|
+
card: fitted.card,
|
|
1297
|
+
exit: 0,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// ── D2 (v1.8.2 W3): `ctx --for-slice` — the OUTSIDE view ────────────────────
|
|
1302
|
+
// An executor reads every declared file IN FULL before editing it (read-ladder
|
|
1303
|
+
// exception 1). A file card for a declared file therefore repeats what the
|
|
1304
|
+
// executor is about to read anyway — the symbol table, the line ranges, the
|
|
1305
|
+
// fan-in — on every turn of that agent, for the whole run.
|
|
1306
|
+
//
|
|
1307
|
+
// What the executor CANNOT see from inside the file is the outside: who imports
|
|
1308
|
+
// it, who calls its exported symbols from elsewhere, which route reaches it, and
|
|
1309
|
+
// which tests cover it. `--for-slice` prints ONLY that. No symbol table, no
|
|
1310
|
+
// callee tree, no notes.
|
|
1311
|
+
//
|
|
1312
|
+
// It is one block per declared file, always tree format, and the caller rows are
|
|
1313
|
+
// sorted by the IMPORTANCE of the symbol they reach — the exported entry point
|
|
1314
|
+
// before the private helper, the same rule the file card already follows.
|
|
1315
|
+
const SLICE_MAX_TARGETS = 10;
|
|
1316
|
+
const STATE_RANK = { LOCAL: 0, IMPORT: 1, ROUTE: 2, UNIQUE: 3 };
|
|
1317
|
+
|
|
1318
|
+
function sliceBlock(model, rel, budget, opts) {
|
|
1319
|
+
const f = model.byFile[rel];
|
|
1320
|
+
const fresh = blobState(model, rel);
|
|
1321
|
+
const items = [];
|
|
1322
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, short: text, data });
|
|
1323
|
+
push(0, "header", `${rel} [blob ${String(f.blob).slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, rel)}]`);
|
|
1324
|
+
|
|
1325
|
+
const SIG = require("./graph-signals.js");
|
|
1326
|
+
const allSyms = (f.symbols || []).filter((s) => s.kind !== "module");
|
|
1327
|
+
// A route symbol is named by its own path; the MOUNTED path is the one a
|
|
1328
|
+
// reader outside the file recognises, and it is the one thing the file itself
|
|
1329
|
+
// cannot tell them.
|
|
1330
|
+
const fullRoute = new Map();
|
|
1331
|
+
for (const r of routeTable(model)) if (r.sym.file === rel) fullRoute.set(r.sym.id, `${r.method} ${r.full || r.own}`);
|
|
1332
|
+
// ONE ROW PER CALLING FILE, naming every symbol of this file it reaches. The
|
|
1333
|
+
// first measurement of this view listed one row per (symbol, caller) pair and
|
|
1334
|
+
// came out LARGER than the file card it replaces — six symbols and four
|
|
1335
|
+
// callers is twenty-four rows for four relationships. The outside question is
|
|
1336
|
+
// "who reaches this file, and through what", and that is one row per file.
|
|
1337
|
+
const OUT_NAMES = 8;
|
|
1338
|
+
const byFile = new Map();
|
|
1339
|
+
for (const s of allSyms) {
|
|
1340
|
+
const cs = callersOf(model, s).confident;
|
|
1341
|
+
const imp = SIG.importance(model, s, cs.length);
|
|
1342
|
+
const reaches = fullRoute.get(s.id) || s.name;
|
|
1343
|
+
for (const c of cs) {
|
|
1344
|
+
if (c.sym.file === rel) continue; // inside the file — the executor reads it
|
|
1345
|
+
let row = byFile.get(c.sym.file);
|
|
1346
|
+
if (!row) byFile.set(c.sym.file, (row = { file: c.sym.file, line: c.line, imp, state: c.state, names: new Map() }));
|
|
1347
|
+
if (c.line < row.line) row.line = c.line;
|
|
1348
|
+
if (imp > row.imp) row.imp = imp;
|
|
1349
|
+
// One state per row: the strongest one this file reached the target with.
|
|
1350
|
+
if (STATE_RANK[c.state] < STATE_RANK[row.state]) row.state = c.state;
|
|
1351
|
+
if (!row.names.has(reaches)) row.names.set(reaches, imp);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
const rows = [...byFile.values()]
|
|
1355
|
+
.map((r) => ({ ...r, names: [...r.names.entries()].sort((a, b) => b[1] - a[1]).map((x) => x[0]) }))
|
|
1356
|
+
.sort((a, b) => b.imp - a.imp || a.file.localeCompare(b.file));
|
|
1357
|
+
for (const r of rows) {
|
|
1358
|
+
const shown = r.names.slice(0, OUT_NAMES);
|
|
1359
|
+
push(
|
|
1360
|
+
2,
|
|
1361
|
+
"callers",
|
|
1362
|
+
` ${r.file}:${r.line} ${STATE_SHORT[r.state] || r.state} ${shown.join(" ")}${r.names.length > OUT_NAMES ? ` +${r.names.length - OUT_NAMES}` : ""}`,
|
|
1363
|
+
{ item: "caller", file: r.file, line: r.line, state: r.state, reaches: r.names }
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const importers = importersOf(model, rel);
|
|
1368
|
+
// Only the importers that call NOTHING — the ones the caller rows already
|
|
1369
|
+
// name are the same fact printed twice.
|
|
1370
|
+
const quiet = importers.filter((x) => !byFile.has(x));
|
|
1371
|
+
if (quiet.length) {
|
|
1372
|
+
const pre = dirPrefix(quiet);
|
|
1373
|
+
if (pre) push(1, "importers", ` dir ${pre}`, null);
|
|
1374
|
+
push(1, "importers", ` imp ${quiet.map((x) => (pre && x.startsWith(pre) ? x.slice(pre.length) : x)).join(" ")} (imports it, calls nothing)`, { item: "importers", files: quiet });
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// The routes this file answers, with the mount chain already folded in — the
|
|
1378
|
+
// one fact a reader of the file itself cannot get without opening the mount.
|
|
1379
|
+
// A route a caller row already named is the same fact twice, so only the ones
|
|
1380
|
+
// NOTHING reaches are printed — which is also the more interesting half.
|
|
1381
|
+
const reached = new Set(rows.flatMap((r) => r.names));
|
|
1382
|
+
const routes = routeTable(model)
|
|
1383
|
+
.filter((r) => r.sym.file === rel)
|
|
1384
|
+
.map((r) => `${r.method} ${r.full || r.own}${r.mounted === "known" || r.mounted === "none" ? "" : " (mount ambiguous)"}`);
|
|
1385
|
+
const unreached = routes.filter((x) => !reached.has(x));
|
|
1386
|
+
if (unreached.length) push(1, "routes", ` rt ${unreached.join(" ")} (no caller in the graph)`, { item: "routes", routes: unreached });
|
|
1387
|
+
|
|
1388
|
+
const shownFiles = new Set(rows.map((r) => r.file));
|
|
1389
|
+
const allTests = [
|
|
1390
|
+
...new Set([...rows.map((r) => r.file).filter((x) => TEST_FILE.test(x)), ...importers.filter((x) => TEST_FILE.test(x)), ...SIG.testsByName(model, rel)]),
|
|
1391
|
+
].sort();
|
|
1392
|
+
const moreTests = allTests.filter((x) => !shownFiles.has(x));
|
|
1393
|
+
if (moreTests.length) push(1, "tests", ` tst ${moreTests.join(" ")}`, { item: "tests", files: moreTests });
|
|
1394
|
+
|
|
1395
|
+
const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
|
|
1396
|
+
const fitted = fit(items, budget, footer);
|
|
1397
|
+
const kept = items.filter((_, i) => fitted.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
1398
|
+
return {
|
|
1399
|
+
file: rel,
|
|
1400
|
+
blob: fresh,
|
|
1401
|
+
callers: kept.filter((d) => d.item === "caller"),
|
|
1402
|
+
total_callers: rows.length,
|
|
1403
|
+
importers,
|
|
1404
|
+
routes,
|
|
1405
|
+
tests: allTests,
|
|
1406
|
+
names: [...new Set([...rows.flatMap((r) => r.names), ...routes])],
|
|
1407
|
+
hidden: fitted.hidden,
|
|
1408
|
+
used: fitted.used,
|
|
1409
|
+
card: fitted.card,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function forSlice(model, files, opts) {
|
|
1414
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
|
|
1415
|
+
const all = [...new Set(files.map(norm))];
|
|
1416
|
+
const known = all.filter((r) => model.fileSet.has(r)).slice(0, SLICE_MAX_TARGETS);
|
|
1417
|
+
const skipped = all.filter((r) => model.fileSet.has(r)).slice(SLICE_MAX_TARGETS);
|
|
1418
|
+
const missing = all.filter((r) => !model.fileSet.has(r));
|
|
1419
|
+
if (!known.length) return { ok: false, state: "not-found", missing, exit: 4 };
|
|
1420
|
+
const each = Math.max(100, Math.floor(budget / known.length));
|
|
1421
|
+
const blocks = known.map((rel) => sliceBlock(model, rel, each, opts));
|
|
1422
|
+
const body = blocks.map((b) => b.card).join("\n\n");
|
|
1423
|
+
const note = missing.length ? `\n not in the graph: ${missing.join(", ")}` : "";
|
|
1424
|
+
// The column header is named ONCE for the whole answer, not once per block —
|
|
1425
|
+
// the tree format's own rule, applied one level up.
|
|
1426
|
+
return {
|
|
1427
|
+
ok: true,
|
|
1428
|
+
state: "found",
|
|
1429
|
+
view: "for-slice",
|
|
1430
|
+
format: "tree",
|
|
1431
|
+
files: known,
|
|
1432
|
+
missing,
|
|
1433
|
+
skipped,
|
|
1434
|
+
blocks: blocks.map((b) => ({ file: b.file, blob: b.blob, callers: b.callers, total_callers: b.total_callers, importers: b.importers, routes: b.routes, tests: b.tests, hidden: b.hidden })),
|
|
1435
|
+
names: [...new Set(blocks.flatMap((b) => b.names))],
|
|
1436
|
+
budget: { used: blocks.reduce((a, b) => a + b.used, 0), max: budget },
|
|
1437
|
+
card: `graph outside view — read each file itself before you edit it · rows are from:line state reaches\n${body}${note}`,
|
|
1438
|
+
exit: 0,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
// ── impact ──────────────────────────────────────────────────────────────────
|
|
1444
|
+
function impact(model, files, opts) {
|
|
1445
|
+
const depth = Math.max(1, Math.min(6, Number(opts.depth) || 3));
|
|
1446
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1500));
|
|
1447
|
+
const rels = [...new Set(files.map(norm))].filter((f) => model.fileSet.has(f));
|
|
1448
|
+
const missing = files.map(norm).filter((f) => !model.fileSet.has(f));
|
|
1449
|
+
if (!rels.length) return { ok: false, state: "not-found", missing, exit: 4 };
|
|
1450
|
+
const targets = rels.flatMap((r) => (model.byFile[r].symbols || []).filter((s) => s.kind !== "module"));
|
|
1451
|
+
const inTargets = new Set(targets.map((s) => s.id));
|
|
1452
|
+
const seen = new Map();
|
|
1453
|
+
let frontier = targets;
|
|
1454
|
+
let maybe = 0;
|
|
1455
|
+
for (let d = 1; d <= depth && frontier.length; d++) {
|
|
1456
|
+
const next = [];
|
|
1457
|
+
for (const t of frontier) {
|
|
1458
|
+
const c = callersOf(model, t);
|
|
1459
|
+
if (d === 1) maybe += c.maybe;
|
|
1460
|
+
for (const row of c.confident) {
|
|
1461
|
+
if (inTargets.has(row.sym.id) || seen.has(row.sym.id)) continue;
|
|
1462
|
+
seen.set(row.sym.id, { sym: row.sym, depth: d, line: row.line, state: row.state, via: row.url ? `${t.qname} ← ${row.url}` : t.qname, ...(row.url ? { url: row.url } : {}) });
|
|
1463
|
+
if (row.sym.kind !== "module") next.push(row.sym);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
frontier = next;
|
|
1467
|
+
}
|
|
1468
|
+
const hits = [...seen.values()].sort(
|
|
1469
|
+
(a, b) => a.depth - b.depth || a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id))
|
|
1470
|
+
);
|
|
1471
|
+
const importerTests = rels.flatMap((r) => importersOf(model, r)).filter((f) => TEST_FILE.test(f));
|
|
1472
|
+
const SIG = require("./graph-signals.js");
|
|
1473
|
+
const tests = [
|
|
1474
|
+
...new Set([
|
|
1475
|
+
...hits.filter((h) => TEST_FILE.test(h.sym.file)).map((h) => h.sym.file),
|
|
1476
|
+
...importerTests,
|
|
1477
|
+
...rels.flatMap((r) => SIG.testsByName(model, r)),
|
|
1478
|
+
]),
|
|
1479
|
+
].sort();
|
|
1480
|
+
const touched = [...new Set([...rels, ...hits.map((h) => h.sym.file)])];
|
|
1481
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor(touched) : [];
|
|
1482
|
+
|
|
1483
|
+
const tree = opts.format === "tree";
|
|
1484
|
+
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
1485
|
+
const page = hits.slice(offset);
|
|
1486
|
+
const items = [];
|
|
1487
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
1488
|
+
push(0, "header", `impact of ${rels.join(", ")} — ${targets.length} symbols · depth ${depth}`);
|
|
1489
|
+
// The same never-worse rule as the file card. The fixed cost here is a column
|
|
1490
|
+
// header PLUS a directory line, so a two-row impact card stays prose.
|
|
1491
|
+
const pre = dirPrefix(page.map((h) => h.sym.file));
|
|
1492
|
+
const CALL_HEAD = " d qname file:line state via";
|
|
1493
|
+
const callRows = page.map((h) => {
|
|
1494
|
+
const file = pre && h.sym.file.startsWith(pre) ? h.sym.file.slice(pre.length) : h.sym.file;
|
|
1495
|
+
return {
|
|
1496
|
+
pri: h.depth,
|
|
1497
|
+
prose: ` d${h.depth} ${h.sym.qname} ${where(h.sym, h.line)} ${h.state} (via ${h.via})`,
|
|
1498
|
+
tree: ` ${h.depth} ${h.sym.qname} ${file}:${h.line || h.sym.lines[0]} ${STATE_SHORT[h.state] || h.state} ${h.via}`,
|
|
1499
|
+
data: { item: "caller", depth: h.depth, ...brief(h.sym), line: h.line, state: h.state, via: h.via, ...(h.url ? { url: h.url } : {}) },
|
|
1500
|
+
};
|
|
1501
|
+
});
|
|
1502
|
+
const fixed = CALL_HEAD.length + (pre ? pre.length + 6 : 0);
|
|
1503
|
+
const useTree =
|
|
1504
|
+
tree && callRows.length > 0 &&
|
|
1505
|
+
fixed + callRows.reduce((a, r) => a + r.tree.length, 0) < callRows.reduce((a, r) => a + r.prose.length, 0);
|
|
1506
|
+
if (useTree) {
|
|
1507
|
+
if (pre) push(1, "callers", ` dir ${pre}`, null);
|
|
1508
|
+
push(1, "callers", CALL_HEAD);
|
|
1509
|
+
}
|
|
1510
|
+
for (const r of callRows) push(r.pri, "callers", useTree ? r.tree : r.prose, r.data);
|
|
1511
|
+
const j = useTree ? " " : ", ";
|
|
1512
|
+
if (tests.length) push(1, "tests", ` tests${useTree ? "" : " "} ${tests.join(j)}`, { item: "tests" });
|
|
1513
|
+
if (wiki.length) push(2, "wiki", ` wiki${useTree ? "" : " "} ${wiki.join(j)}`, { item: "wiki" });
|
|
1514
|
+
if (maybe) push(2, "maybe", useTree ? ` maybe ${maybe} ambiguous, not followed` : ` maybe ${maybe} AMBIGUOUS caller(s) not followed`, { item: "maybe" });
|
|
1515
|
+
if (missing.length) push(1, "missing", useTree ? ` absent ${missing.join(" ")}` : ` not in the graph: ${missing.join(", ")}`, { item: "missing" });
|
|
1516
|
+
const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
|
|
1517
|
+
const f = fit(items, budget, footer);
|
|
1518
|
+
const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
1519
|
+
const keptCallers = kept.filter((d) => d.item === "caller");
|
|
1520
|
+
return {
|
|
1521
|
+
ok: true,
|
|
1522
|
+
state: "found",
|
|
1523
|
+
format: useTree ? "tree" : "prose",
|
|
1524
|
+
offset,
|
|
1525
|
+
has_more: offset + keptCallers.length < hits.length,
|
|
1526
|
+
files: rels,
|
|
1527
|
+
missing,
|
|
1528
|
+
symbols: targets.length,
|
|
1529
|
+
callers: kept.filter((d) => d.item ==="caller"),
|
|
1530
|
+
total_callers: hits.length,
|
|
1531
|
+
maybe_callers: maybe,
|
|
1532
|
+
tests,
|
|
1533
|
+
wiki,
|
|
1534
|
+
hidden: f.hidden,
|
|
1535
|
+
budget: { used: f.used, max: budget },
|
|
1536
|
+
card: f.card,
|
|
1537
|
+
exit: 0,
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// ── path ────────────────────────────────────────────────────────────────────
|
|
1542
|
+
function pathBetween(model, fromQ, toQ) {
|
|
1543
|
+
const a = findTarget(model, fromQ);
|
|
1544
|
+
const b = findTarget(model, toQ);
|
|
1545
|
+
if (a.kind !== "symbol" || b.kind !== "symbol") {
|
|
1546
|
+
return { ok: false, state: "not-found", from: a.kind, to: b.kind, candidates: [...(a.candidates || []), ...(b.candidates || [])].map(brief), exit: 4 };
|
|
1547
|
+
}
|
|
1548
|
+
const prev = new Map([[a.sym.id, null]]);
|
|
1549
|
+
let frontier = [a.sym];
|
|
1550
|
+
for (let d = 0; d < 8 && frontier.length && !prev.has(b.sym.id); d++) {
|
|
1551
|
+
const next = [];
|
|
1552
|
+
for (const s of frontier) {
|
|
1553
|
+
for (const { call, res } of calleesOf(model, s)) {
|
|
1554
|
+
if (!CONFIDENT.has(res.state) || prev.has(res.target)) continue;
|
|
1555
|
+
const t = model.symbols.get(res.target);
|
|
1556
|
+
if (!t) continue;
|
|
1557
|
+
prev.set(t.id, { from: s, line: call.line, state: res.state });
|
|
1558
|
+
next.push(t);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
frontier = next;
|
|
1562
|
+
}
|
|
1563
|
+
if (!prev.has(b.sym.id)) return { ok: true, state: "none", from: brief(a.sym), to: brief(b.sym), exit: 4, card: `no confident call path from ${a.sym.qname} to ${b.sym.qname} (AMBIGUOUS edges are not followed)` };
|
|
1564
|
+
const chain = [];
|
|
1565
|
+
for (let id = b.sym.id; id; ) {
|
|
1566
|
+
const p = prev.get(id);
|
|
1567
|
+
chain.unshift({ sym: model.symbols.get(id), step: p });
|
|
1568
|
+
id = p ? p.from.id : null;
|
|
1569
|
+
}
|
|
1570
|
+
const lines = chain.map((c, i) => (i === 0 ? `${c.sym.qname} ${where(c.sym)}` : ` → ${c.sym.qname} ${where(c.sym)} ${c.step.state} (called at ${where(c.step.from, c.step.line)})`));
|
|
1571
|
+
return {
|
|
1572
|
+
ok: true,
|
|
1573
|
+
state: "found",
|
|
1574
|
+
hops: chain.length - 1,
|
|
1575
|
+
chain: chain.map((c) => ({ ...brief(c.sym), state: c.step ? c.step.state : null, called_at: c.step ? where(c.step.from, c.step.line) : null })),
|
|
1576
|
+
card: lines.join("\n"),
|
|
1577
|
+
exit: 0,
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// ── D4 (v1.8.2 W7): `orc graph map` — the ranked repository map ─────────────
|
|
1582
|
+
// The planner's FIRST question, answered without a single Read: which files
|
|
1583
|
+
// are the landmarks here, and — with `--focus` — which ones surround the thing
|
|
1584
|
+
// the request already names.
|
|
1585
|
+
//
|
|
1586
|
+
// The rank comes from `bin/graph-map.js`. This half owns what a row says and
|
|
1587
|
+
// how the list is cut, which is the same contract every other card keeps: rows
|
|
1588
|
+
// in a fixed order, the budget cuts from the BOTTOM, and a card that hid
|
|
1589
|
+
// something says so.
|
|
1590
|
+
const MAP_MAX_ROWS = 200;
|
|
1591
|
+
const MAP_FOCUS_MASS = 100;
|
|
1592
|
+
const MAP_CAVEAT = " rank is a HINT about where to look first — it is never proof a file matters to this change";
|
|
1593
|
+
|
|
1594
|
+
function mapRow(model, rel, fan, pre) {
|
|
1595
|
+
const SIG = require("./graph-signals.js");
|
|
1596
|
+
const syms = (model.byFile[rel].symbols || []).filter((s) => s.kind !== "module");
|
|
1597
|
+
const ranked = syms
|
|
1598
|
+
.map((s) => ({ s, imp: SIG.importance(model, s, fan.get(s.id) || 0) }))
|
|
1599
|
+
.sort((a, b) => b.imp - a.imp || a.s.lines[0] - b.s.lines[0]);
|
|
1600
|
+
// Chosen by importance, then PRINTED in line order: a file is read top to
|
|
1601
|
+
// bottom, and a list that jumps about costs the reader more than it saves.
|
|
1602
|
+
const M = require("./graph-map.js");
|
|
1603
|
+
const shown = ranked.slice(0, M.SYMS_PER_FILE).sort((a, b) => a.s.lines[0] - b.s.lines[0]);
|
|
1604
|
+
const rest = ranked.length - shown.length;
|
|
1605
|
+
const name = pre && rel.startsWith(pre) ? rel.slice(pre.length) : rel;
|
|
1606
|
+
const parts = shown.map(({ s }) => `${s.qname}:${s.lines[0]}-${s.lines[1]}${s.exported ? " E" : ""}`);
|
|
1607
|
+
if (rest > 0) parts.push(`+${rest}`);
|
|
1608
|
+
return {
|
|
1609
|
+
text: ` ${name}${parts.length ? " " + parts.join(" · ") : ""}`,
|
|
1610
|
+
data: { item: "file", file: rel, symbols: shown.map(({ s }) => ({ qname: s.qname, kind: s.kind, lines: s.lines, exported: !!s.exported })), more: Math.max(0, rest) },
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// A focus token is a FILE when the graph holds that path, and a NAME otherwise.
|
|
1615
|
+
// A name contributes every file that DEFINES it — which is how "focus on
|
|
1616
|
+
// `createOrder`" reaches the file a request never spelled out.
|
|
1617
|
+
function focusFiles(model, tokens) {
|
|
1618
|
+
const files = [];
|
|
1619
|
+
const missing = [];
|
|
1620
|
+
for (const t of tokens) {
|
|
1621
|
+
const rel = norm(t).replace(/:\d+$/, "");
|
|
1622
|
+
if (model.fileSet.has(rel)) {
|
|
1623
|
+
files.push(rel);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
const defs = model.byName.get(String(t).split(".").pop()) || [];
|
|
1627
|
+
if (defs.length) for (const s of defs) files.push(s.file);
|
|
1628
|
+
else missing.push(t);
|
|
1629
|
+
}
|
|
1630
|
+
return { files: [...new Set(files)], missing };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function graphMap(claudeDir, model, opts) {
|
|
1634
|
+
const M = require("./graph-map.js");
|
|
1635
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
|
|
1636
|
+
const { files, edges, base, cached } = M.edgesFor(claudeDir, model);
|
|
1637
|
+
const idOf = new Map(files.map((f, i) => [f, i]));
|
|
1638
|
+
const tokens = (opts.focus || []).filter(Boolean);
|
|
1639
|
+
const foc = tokens.length ? focusFiles(model, tokens) : { files: [], missing: [] };
|
|
1640
|
+
|
|
1641
|
+
let rank;
|
|
1642
|
+
if (foc.files.length) {
|
|
1643
|
+
// W7-focus-baseline: EVERY file keeps a baseline share of 1 and the focus
|
|
1644
|
+
// gets `MAP_FOCUS_MASS / n` ON TOP of it. Giving the focus the whole vector
|
|
1645
|
+
// — the literal reading of "focus files get 100/n" — leaves every file the
|
|
1646
|
+
// focus does not reach at exactly zero, and a hundred tied zeroes sort
|
|
1647
|
+
// alphabetically. That turns the rest of the map into a directory listing,
|
|
1648
|
+
// which is the one thing it must never be.
|
|
1649
|
+
//
|
|
1650
|
+
// The mass is FIXED, so a fifth focus file dilutes the other four rather
|
|
1651
|
+
// than making the focus louder overall. That is Aider's rule and the reason
|
|
1652
|
+
// is the same: a focus list is a hint about where to start, not a filter.
|
|
1653
|
+
const pers = new Float64Array(files.length).fill(1);
|
|
1654
|
+
const each = MAP_FOCUS_MASS / foc.files.length;
|
|
1655
|
+
for (const f of foc.files) {
|
|
1656
|
+
const i = idOf.get(f);
|
|
1657
|
+
if (i !== undefined) pers[i] += each;
|
|
1658
|
+
}
|
|
1659
|
+
rank = M.pageRank(files.length, edges, pers);
|
|
1660
|
+
} else rank = base || M.pageRank(files.length, edges, null);
|
|
1661
|
+
|
|
1662
|
+
const scored = files
|
|
1663
|
+
.map((rel, i) => ({ rel, score: rank[i] * M.multiplier(model, rel) }))
|
|
1664
|
+
.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
|
|
1665
|
+
const candidates = scored.slice(0, MAP_MAX_ROWS);
|
|
1666
|
+
const fan = M.fanCounts(model);
|
|
1667
|
+
const pre = dirPrefix(candidates.map((c) => c.rel));
|
|
1668
|
+
const rows = candidates.map((c, i) => {
|
|
1669
|
+
const row = mapRow(model, c.rel, fan, pre);
|
|
1670
|
+
return { text: row.text, data: { ...row.data, rank: i + 1 } };
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
const focusNote = foc.files.length ? ` · focus ${foc.files.length} file(s)` : "";
|
|
1674
|
+
const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
|
|
1675
|
+
// W7-map-prefix: the map is cut to a PREFIX of the ranking, never to whatever
|
|
1676
|
+
// happens to fit. `fit()` is greedy — after skipping one long row it will
|
|
1677
|
+
// still take a shorter row further down — which is right for a card whose
|
|
1678
|
+
// sections are independent and wrong for a list whose whole meaning is the
|
|
1679
|
+
// ORDER. A reader who sees rank 10 present and rank 7 missing cannot use the
|
|
1680
|
+
// order for anything.
|
|
1681
|
+
//
|
|
1682
|
+
// So the row count is chosen by binary search, exactly as Aider chooses its
|
|
1683
|
+
// map size, and `fit` is then handed a list it can keep whole. Its budget
|
|
1684
|
+
// arithmetic and its footer reserve are unchanged and still do the accounting.
|
|
1685
|
+
const itemsFor = (k) => {
|
|
1686
|
+
const items = [];
|
|
1687
|
+
items.push({ pri: 0, section: "header", text: `graph map (gen ${model.meta.generation}) — ${files.length} file(s) by rank${focusNote}`, data: null });
|
|
1688
|
+
if (pre) items.push({ pri: 0, section: "header", text: ` dir ${pre}`, data: null });
|
|
1689
|
+
if (foc.missing.length) items.push({ pri: 0, section: "focus", text: ` focus not in the graph: ${foc.missing.join(", ")}`, data: { item: "focus-missing" } });
|
|
1690
|
+
for (let i = 0; i < k; i++) items.push({ pri: 1, section: "files", text: rows[i].text, data: rows[i].data });
|
|
1691
|
+
// pri 0: never cut, and CHARGED to the budget like everything else. It is
|
|
1692
|
+
// the map's whole equivalent of "the graph is a LOCATOR" — a card that
|
|
1693
|
+
// dropped it under budget pressure would be a ranking with no warning
|
|
1694
|
+
// label on it.
|
|
1695
|
+
items.push({ pri: 0, section: "caveat", text: MAP_CAVEAT, data: null });
|
|
1696
|
+
return items;
|
|
1697
|
+
};
|
|
1698
|
+
const fits = (k) => {
|
|
1699
|
+
const f = fit(itemsFor(k), budget, footer);
|
|
1700
|
+
return !f.hidden.files;
|
|
1701
|
+
};
|
|
1702
|
+
let lo = 0;
|
|
1703
|
+
let hi = rows.length;
|
|
1704
|
+
if (!fits(hi)) {
|
|
1705
|
+
while (lo < hi) {
|
|
1706
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1707
|
+
if (fits(mid)) lo = mid;
|
|
1708
|
+
else hi = mid - 1;
|
|
1709
|
+
}
|
|
1710
|
+
} else lo = hi;
|
|
1711
|
+
|
|
1712
|
+
const shownRows = rows.slice(0, lo);
|
|
1713
|
+
const items = itemsFor(lo);
|
|
1714
|
+
// The footer has to report what the PREFIX left out, which `fit` cannot know
|
|
1715
|
+
// once the rows are gone. A map that silently stops at rank 12 is a map that
|
|
1716
|
+
// claims the repository has twelve files.
|
|
1717
|
+
const cut = files.length - shownRows.length;
|
|
1718
|
+
const footerFinal = (used) => (cut > 0 ? ` budget ${used}/${budget} tokens · hidden: ${cut} files (raise --budget)` : "");
|
|
1719
|
+
const f = fit(items, budget, footerFinal);
|
|
1720
|
+
return {
|
|
1721
|
+
ok: true,
|
|
1722
|
+
state: "found",
|
|
1723
|
+
focus: foc.files,
|
|
1724
|
+
focus_missing: foc.missing,
|
|
1725
|
+
cached_map: !!cached,
|
|
1726
|
+
total_files: files.length,
|
|
1727
|
+
edges: edges.length,
|
|
1728
|
+
files: shownRows.map((r) => r.data),
|
|
1729
|
+
shown: shownRows.length,
|
|
1730
|
+
has_more: cut > 0,
|
|
1731
|
+
hidden: cut > 0 ? { files: cut } : {},
|
|
1732
|
+
budget: { used: f.used, max: budget },
|
|
1733
|
+
card: f.card,
|
|
1734
|
+
generation: model.meta.generation,
|
|
1735
|
+
exit: 0,
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// EW2, for the resolution cache only: which files this one's import specifiers
|
|
1740
|
+
// actually reach, and whether any RELATIVE specifier reaches nothing. A file
|
|
1741
|
+
// added later can only ever satisfy a relative specifier, so that flag is the
|
|
1742
|
+
// complete set of files an addition can move.
|
|
1743
|
+
function importsOf(model, rel) {
|
|
1744
|
+
const f = model.byFile[rel] || {};
|
|
1745
|
+
const files = new Set();
|
|
1746
|
+
let unresolvedRelative = false;
|
|
1747
|
+
for (const imp of f.imports || []) {
|
|
1748
|
+
const t = importTargets(model, rel, f.lang, imp);
|
|
1749
|
+
for (const x of t) files.add(x);
|
|
1750
|
+
if (!t.length && /^\.{1,2}\//.test(String(imp.from || ""))) unresolvedRelative = true;
|
|
1751
|
+
}
|
|
1752
|
+
return { files: [...files].sort(), unresolvedRelative };
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// K2 (v1.8.2 W4b): how many lines in this repository NAME this symbol — the
|
|
1756
|
+
// exact size of the Grep the card replaced. The graph knows it for free: every
|
|
1757
|
+
// call site is already in the call index, and every definition in `byName`.
|
|
1758
|
+
function nameHits(model, name) {
|
|
1759
|
+
// S1 (v1.8.2 W8): the sharded model holds a dozen files, not the repository,
|
|
1760
|
+
// so it CANNOT count the lines a grep would have returned. It says so rather
|
|
1761
|
+
// than returning a confident zero — K2's whole rule is that the
|
|
1762
|
+
// counterfactual is written down, and a made-up basis is worse than none.
|
|
1763
|
+
if (model.fast) return null;
|
|
1764
|
+
const bare = String(name || "").split(".").pop();
|
|
1765
|
+
const calls = (callIndex(model).get(bare) || []).length;
|
|
1766
|
+
const defs = (model.byName.get(bare) || []).length;
|
|
1767
|
+
return calls + defs;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
module.exports = { loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP };
|