projectkeeper 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The general code engine (D98; PRD R-61; Spec §1.16 row 7 "代码结构", §1.19): structure and references of every language
|
|
3
|
+
* the ledger's TypeScript path does not read, from @colbymchenry/codegraph (MIT, pinned in package.json). A project added
|
|
4
|
+
* is read as it is — no project configuration, no SDK, no package fetch, no network — and ProjectKeeper has no code of its
|
|
5
|
+
* own for any of these languages. TypeScript and JavaScript stay on the TypeScript compiler (code.ts `tsDeps`,
|
|
6
|
+
* ts-symbols.ts), which is exact there (the owner, 2026-09-28: 「typescript我们已经写好了,可以就用我们的,其他语言就用通用工具。」).
|
|
7
|
+
*
|
|
8
|
+
* Where it reads: a snapshot of the current version — HEAD's blobs of a repository, the files of a directory outside
|
|
9
|
+
* version control as read — mirrored into a folder ProjectKeeper owns next to the ledger,
|
|
10
|
+
* `<project dir>/code-index/<repository>/tree`: only changed contents are written and removed files deleted. The engine
|
|
11
|
+
* keeps its index in that folder too (`tree/.codegraph/`). Nothing is ever written into the project.
|
|
12
|
+
*
|
|
13
|
+
* How it runs: indexing in a child process of the rebuild (code-engine-child.ts), with V8's `--liftoff-only` — tree-sitter's
|
|
14
|
+
* WASM grammars otherwise exhaust V8's compilation zone (a large Flutter project peaked at 3.9 GB without it, 1.0 GB with it) — with
|
|
15
|
+
* `DO_NOT_TRACK=1`, and with git unable to see any repository around the snapshot; incrementally (`sync`) once an index
|
|
16
|
+
* exists. A symbol question opens the index read-only in the asking process: plain database reads, nothing parsed. Reading
|
|
17
|
+
* a historical version extracts in a child process too.
|
|
18
|
+
*
|
|
19
|
+
* What counts as a reference: one the engine resolved through an import, a path, a qualified name, a function reference or
|
|
20
|
+
* an instance method — never one matched by a name alone (`import { resolve } from 'node:path'` was matched to an unrelated
|
|
21
|
+
* `Ledger.resolve`). Two tiers the engine files under function-ref and instance-method are name matches in its own
|
|
22
|
+
* resolver and do not count either: a cross-file function reference taken as the only function of that name (confidence
|
|
23
|
+
* 0.8), and a method found by its name across the codebase (0.7, 0.65). Measured on a large Flutter project, 35–46% of those land outside the
|
|
24
|
+
* calling file's import closure, against 98% inside at 0.8 and above. A symbol answer still lists every hit, each with its
|
|
25
|
+
* method and confidence and whether it counts; a hit matched by name alone counts there when it is in the declaring file,
|
|
26
|
+
* or its file reaches the declaring file through counted imports (`through`: directly, or through a barrel that re-exports
|
|
27
|
+
* it) — the name is then the imported one. That is how Dart's constructor calls, which the engine matches by name, count as
|
|
28
|
+
* references of their class; on that project every file with such a reference to its store class reaches the class's file that way.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import type { CodeGraph, Edge as EngineEdge, Node as EngineNode } from '@colbymchenry/codegraph';
|
|
37
|
+
import { OWN_TS_NODE_ARGS } from '../util/own-ts.ts';
|
|
38
|
+
|
|
39
|
+
type EngineModule = typeof import('@colbymchenry/codegraph');
|
|
40
|
+
|
|
41
|
+
const requireHere = createRequire(import.meta.url);
|
|
42
|
+
|
|
43
|
+
export const ENGINE_PACKAGE = '@colbymchenry/codegraph';
|
|
44
|
+
export const ENGINE_VERSION: string = (requireHere(`${ENGINE_PACKAGE}/package.json`) as { version: string }).version;
|
|
45
|
+
/** What a snapshot and its index were made with: another engine or another counting rule reads the code again. */
|
|
46
|
+
export const ENGINE_SIGNATURE = `${ENGINE_PACKAGE}@${ENGINE_VERSION};counted-1`;
|
|
47
|
+
export const ENGINE_LABEL = `the code engine (${ENGINE_PACKAGE} ${ENGINE_VERSION})`;
|
|
48
|
+
|
|
49
|
+
let loaded: EngineModule | string | null = null;
|
|
50
|
+
/** The engine, loaded on first use (JavaScript only: loading it compiles no grammar); a string says why it cannot load. */
|
|
51
|
+
function engine(): EngineModule | string {
|
|
52
|
+
if (loaded === null) {
|
|
53
|
+
try {
|
|
54
|
+
loaded = requireHere(ENGINE_PACKAGE) as EngineModule;
|
|
55
|
+
loaded.setLogger(loaded.silentLogger);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
loaded = `${ENGINE_LABEL} cannot load on this machine: ${(error as Error).message.split('\n')[0]}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return loaded;
|
|
61
|
+
}
|
|
62
|
+
/** Why the engine is not available here, or null when it is. */
|
|
63
|
+
export function engineUnavailable(): string | null {
|
|
64
|
+
const e = engine();
|
|
65
|
+
return typeof e === 'string' ? e : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ───────────────────────── what counts ─────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The resolution methods whose references count, each from the confidence below which the engine's resolver matched by a
|
|
72
|
+
* name (see the module comment). An edge the extractor made itself carries no method and counts, unless it is marked a
|
|
73
|
+
* heuristic.
|
|
74
|
+
*/
|
|
75
|
+
export const COUNTED_FROM: Readonly<Record<string, number>> = { import: 0, 'file-path': 0, 'qualified-name': 0, 'function-ref': 0.85, 'instance-method': 0.8 };
|
|
76
|
+
export function counts(how: string | null | undefined, confidence: number | null | undefined, provenance?: string | null): boolean {
|
|
77
|
+
if (!how) return provenance !== 'heuristic';
|
|
78
|
+
const floor = COUNTED_FROM[how];
|
|
79
|
+
return floor !== undefined && (typeof confidence !== 'number' || confidence >= floor);
|
|
80
|
+
}
|
|
81
|
+
/** The words a person reads for how a reference was resolved. */
|
|
82
|
+
export const COUNTED_RULE = 'counted: resolved through an import, a path, a qualified name, a function reference or an instance method (the tiers where the engine matched by a name alone do not count)';
|
|
83
|
+
|
|
84
|
+
// ───────────────────────── the snapshot ─────────────────────────
|
|
85
|
+
|
|
86
|
+
/** A file of the snapshot: its repository path, what identifies its content (a git blob, a content hash) and the content. */
|
|
87
|
+
export interface SnapshotFile { readonly path: string; readonly key: string; readonly content: Buffer }
|
|
88
|
+
export interface MirrorStats { readonly files: number; readonly written: number; readonly removed: number; readonly failed: readonly string[] }
|
|
89
|
+
interface Manifest { readonly engine: string; readonly files: Readonly<Record<string, string>> }
|
|
90
|
+
|
|
91
|
+
/** The folder of one repository's (or one directory's) snapshot and index under the project's `code-index/`. */
|
|
92
|
+
export function engineDirOf(engineRoot: string, id: string): string {
|
|
93
|
+
const safe = id.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^\.+/, '_').slice(0, 48) || 'repo';
|
|
94
|
+
return join(engineRoot, `${safe}-${createHash('sha1').update(id).digest('hex').slice(0, 8)}`);
|
|
95
|
+
}
|
|
96
|
+
const treeOf = (dir: string): string => join(dir, 'tree');
|
|
97
|
+
const indexFileOf = (dir: string): string => join(dir, 'tree', '.codegraph', 'codegraph.db');
|
|
98
|
+
/** Whether a snapshot's index is there (a folder removed by hand is read again at the next rebuild). */
|
|
99
|
+
export const engineIndexExists = (dir: string): boolean => existsSync(indexFileOf(dir));
|
|
100
|
+
export const contentKey = (buf: Buffer): string => createHash('sha1').update(buf).digest('hex');
|
|
101
|
+
const osPath = (root: string, rel: string): string => join(root, ...rel.split('/'));
|
|
102
|
+
|
|
103
|
+
/** The engine's language for a path (its own extension table), or null when it reads no such file. */
|
|
104
|
+
export function engineLanguage(path: string): string | null {
|
|
105
|
+
const e = engine();
|
|
106
|
+
if (typeof e === 'string') return null;
|
|
107
|
+
const lang = e.detectLanguage(path);
|
|
108
|
+
return lang && lang !== 'unknown' ? lang : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A tracked path the snapshot does not take: the engine's own data directory, and ignore files — the snapshot already is
|
|
112
|
+
* exactly the tracked files, and a copied `.gitignore` would only drop some of them again.
|
|
113
|
+
*/
|
|
114
|
+
export const snapshotSkips = (path: string): boolean => /(^|\/)\.codegraph(-[^/]*)?\//i.test(path) || /(^|\/)\.gitignore$/i.test(path);
|
|
115
|
+
|
|
116
|
+
/** Everything of one snapshot and its index, removed (the repository left the scope, or it has no file the engine reads). */
|
|
117
|
+
export function dropEngineDir(dir: string): void {
|
|
118
|
+
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* the next rebuild tries again */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mirror the files into `<dir>/tree`: a file whose content is unchanged since the manifest is left alone, a changed or new
|
|
123
|
+
* one written, one no longer there deleted. A folder the manifest does not describe — never mirrored, interrupted, made by
|
|
124
|
+
* another engine — is started over, its index with it.
|
|
125
|
+
*/
|
|
126
|
+
export function mirrorSnapshot(dir: string, files: readonly SnapshotFile[]): MirrorStats {
|
|
127
|
+
const tree = treeOf(dir);
|
|
128
|
+
const manifestPath = join(dir, 'manifest.json');
|
|
129
|
+
let before: Manifest | null = null;
|
|
130
|
+
try { before = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest; } catch { before = null; }
|
|
131
|
+
if (!before || before.engine !== ENGINE_SIGNATURE || typeof before.files !== 'object' || before.files === null || !existsSync(tree)) {
|
|
132
|
+
dropEngineDir(dir);
|
|
133
|
+
before = { engine: ENGINE_SIGNATURE, files: {} };
|
|
134
|
+
}
|
|
135
|
+
mkdirSync(tree, { recursive: true });
|
|
136
|
+
const next: Record<string, string> = {};
|
|
137
|
+
const failed: string[] = [];
|
|
138
|
+
let written = 0;
|
|
139
|
+
for (const f of files) {
|
|
140
|
+
if (before.files[f.path] === f.key) { next[f.path] = f.key; continue; }
|
|
141
|
+
const target = osPath(tree, f.path);
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
144
|
+
writeFileSync(target, f.content);
|
|
145
|
+
next[f.path] = f.key;
|
|
146
|
+
written += 1;
|
|
147
|
+
} catch {
|
|
148
|
+
failed.push(f.path); // a name this file system cannot hold: left out of the snapshot, and said
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
let removed = 0;
|
|
152
|
+
const parents = new Set<string>();
|
|
153
|
+
for (const path of Object.keys(before.files)) {
|
|
154
|
+
if (path in next) continue;
|
|
155
|
+
try { unlinkSync(osPath(tree, path)); } catch { /* already gone */ }
|
|
156
|
+
removed += 1;
|
|
157
|
+
for (let d = path.lastIndexOf('/'); d > 0; d = path.lastIndexOf('/', d - 1)) parents.add(path.slice(0, d));
|
|
158
|
+
}
|
|
159
|
+
// Directories a removal emptied go too, deepest first; one still holding a file stays.
|
|
160
|
+
for (const d of [...parents].sort((a, b) => b.length - a.length)) { try { rmdirSync(osPath(tree, d)); } catch { /* not empty */ } }
|
|
161
|
+
const tmp = `${manifestPath}.tmp`;
|
|
162
|
+
writeFileSync(tmp, JSON.stringify({ engine: ENGINE_SIGNATURE, files: next } satisfies Manifest));
|
|
163
|
+
renameSync(tmp, manifestPath);
|
|
164
|
+
return { files: Object.keys(next).length, written, removed, failed };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ───────────────────────── indexing, in a child process ─────────────────────────
|
|
168
|
+
|
|
169
|
+
export interface EngineRun {
|
|
170
|
+
/** `full`: indexed from nothing; `sync`: brought up to date; `none`: nothing changed and the index stands. */
|
|
171
|
+
readonly ran: 'full' | 'sync' | 'none';
|
|
172
|
+
readonly ok: boolean;
|
|
173
|
+
readonly ms: number;
|
|
174
|
+
/** Peak memory of the indexing process. */
|
|
175
|
+
readonly peakMemoryMB: number | null;
|
|
176
|
+
readonly files: number;
|
|
177
|
+
readonly indexBytes: number;
|
|
178
|
+
readonly error: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const CHILD = fileURLToPath(new URL('./code-engine-child.ts', import.meta.url));
|
|
182
|
+
/** V8's baseline WASM compiler only (see the module comment), and node:sqlite's experimental notice kept quiet. */
|
|
183
|
+
export const ENGINE_NODE_FLAGS: readonly string[] = ['--liftoff-only', '--disable-warning=ExperimentalWarning'];
|
|
184
|
+
const INDEX_TIMEOUT_MS = 30 * 60_000;
|
|
185
|
+
|
|
186
|
+
/** The child's environment: no telemetry, no update check, and git blind to any repository around the snapshot. */
|
|
187
|
+
function childEnv(dir: string): NodeJS.ProcessEnv {
|
|
188
|
+
const env: NodeJS.ProcessEnv = {};
|
|
189
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
190
|
+
const k = key.toUpperCase();
|
|
191
|
+
if (k.startsWith('GIT_') || k === 'NODE_TEST_CONTEXT' || k === 'CODEGRAPH_DIR') continue;
|
|
192
|
+
env[key] = value;
|
|
193
|
+
}
|
|
194
|
+
env.DO_NOT_TRACK = '1';
|
|
195
|
+
env.CODEGRAPH_TELEMETRY = '0';
|
|
196
|
+
env.CODEGRAPH_NO_UPDATE_CHECK = '1';
|
|
197
|
+
env.GIT_CEILING_DIRECTORIES = dir;
|
|
198
|
+
return env;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function runChild(dir: string, args: readonly string[], input: string | null, timeoutMs: number): { json: Record<string, unknown> | null; error: string | null } {
|
|
202
|
+
const r = spawnSync(process.execPath, [...ENGINE_NODE_FLAGS, ...OWN_TS_NODE_ARGS, CHILD, ...args], {
|
|
203
|
+
cwd: dir, env: childEnv(dir), encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024,
|
|
204
|
+
...(input === null ? { stdio: ['ignore', 'pipe', 'pipe'] } : { input }),
|
|
205
|
+
});
|
|
206
|
+
const line = (r.stdout ?? '').trim().split('\n').reverse().find((l) => l.trim().startsWith('{'));
|
|
207
|
+
let json: Record<string, unknown> | null = null;
|
|
208
|
+
try { json = line ? JSON.parse(line) as Record<string, unknown> : null; } catch { json = null; }
|
|
209
|
+
if (r.error) return { json, error: r.error.message };
|
|
210
|
+
if (r.status !== 0 || !json || json.ok !== true) {
|
|
211
|
+
const why = (typeof json?.error === 'string' ? json.error : '') || (r.stderr ?? '').trim().split('\n').slice(-3).join(' ') || `exit ${r.status ?? r.signal}`;
|
|
212
|
+
return { json, error: why };
|
|
213
|
+
}
|
|
214
|
+
return { json, error: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Size of the engine's data directory in the snapshot. */
|
|
218
|
+
function indexBytesOf(dir: string): number {
|
|
219
|
+
const data = join(treeOf(dir), '.codegraph');
|
|
220
|
+
try { return readdirSync(data).reduce((n, f) => { try { return n + statSync(join(data, f)).size; } catch { return n; } }, 0); } catch { return 0; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Index the snapshot, or bring its index up to date, unless nothing changed and a complete index stands. */
|
|
224
|
+
export function indexSnapshot(dir: string, changed: boolean): EngineRun {
|
|
225
|
+
const unavailable = engineUnavailable();
|
|
226
|
+
if (unavailable) return { ran: 'none', ok: false, ms: 0, peakMemoryMB: null, files: 0, indexBytes: 0, error: unavailable };
|
|
227
|
+
if (!changed && existsSync(indexFileOf(dir))) return { ran: 'none', ok: true, ms: 0, peakMemoryMB: null, files: -1, indexBytes: indexBytesOf(dir), error: null };
|
|
228
|
+
const started = Date.now();
|
|
229
|
+
const { json, error } = runChild(dir, ['index', treeOf(dir)], null, INDEX_TIMEOUT_MS);
|
|
230
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
231
|
+
return {
|
|
232
|
+
ran: json?.mode === 'sync' ? 'sync' : 'full', ok: error === null, ms: Date.now() - started,
|
|
233
|
+
peakMemoryMB: typeof json?.peakMemoryMB === 'number' ? json.peakMemoryMB : null, files: num(json?.files), indexBytes: indexBytesOf(dir), error,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ───────────────────────── reading the index into the ledger ─────────────────────────
|
|
238
|
+
|
|
239
|
+
export interface EngineFile { readonly language: string; readonly code: boolean; readonly parseErrors: number }
|
|
240
|
+
export interface EngineDep { readonly src: string; readonly dst: string; readonly kind: string; readonly external: boolean }
|
|
241
|
+
export interface EngineLanguageReach {
|
|
242
|
+
readonly language: string;
|
|
243
|
+
/** Files it read references of, and files it only tracks (no symbol, no import: data such as YAML or a manifest). */
|
|
244
|
+
readonly files: number;
|
|
245
|
+
readonly trackedOnly: number;
|
|
246
|
+
readonly parseErrors: number;
|
|
247
|
+
/** Import specifiers resolved to a file of the repository, and not (packages, SDKs — and in-repo ones it missed). */
|
|
248
|
+
readonly imports: { readonly resolved: number; readonly unresolved: number; readonly unresolvedNamingRepoFiles: number; readonly sample: readonly string[] };
|
|
249
|
+
/** References between files by resolution method: counted, and matched by a name only (not counted). */
|
|
250
|
+
readonly counted: Readonly<Record<string, number>>;
|
|
251
|
+
readonly nameOnly: Readonly<Record<string, number>>;
|
|
252
|
+
}
|
|
253
|
+
export interface EngineRead {
|
|
254
|
+
readonly files: ReadonlyMap<string, EngineFile>;
|
|
255
|
+
readonly deps: readonly EngineDep[];
|
|
256
|
+
readonly languages: readonly EngineLanguageReach[];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const sqlite = (): typeof import('node:sqlite') => requireHere('node:sqlite') as typeof import('node:sqlite');
|
|
260
|
+
const norm = (p: string): string => p.split('\\').join('/').replace(/^\.\//, '');
|
|
261
|
+
const lastSegment = (spec: string): string => spec.split(/[/\\:]/).pop() ?? spec;
|
|
262
|
+
/** The engine says `imports`; the ledger's words for a file dependency are the TypeScript path's (`import`). */
|
|
263
|
+
const depKind = (k: string): string => (k === 'imports' ? 'import' : k);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The files, the counted references between files, the unresolved imports (the external dependencies) and the numbers per
|
|
267
|
+
* language, read from the index. An unresolved import that names a file of its own language by its stem (`specifierNames`)
|
|
268
|
+
* is counted apart: an in-repository import the engine did not resolve. A string when the index cannot be read.
|
|
269
|
+
*/
|
|
270
|
+
export function readEngineIndex(dir: string): EngineRead | string {
|
|
271
|
+
let db: import('node:sqlite').DatabaseSync;
|
|
272
|
+
try { db = new (sqlite().DatabaseSync)(indexFileOf(dir), { readOnly: true }); } catch (error) { return `the engine's index cannot be opened: ${(error as Error).message}`; }
|
|
273
|
+
try {
|
|
274
|
+
const files = new Map<string, EngineFile>();
|
|
275
|
+
for (const r of db.prepare(`SELECT f.path, f.language, f.errors, EXISTS (SELECT 1 FROM nodes n WHERE n.file_path = f.path AND n.kind <> 'file') code FROM files f`).all() as { path: string; language: string; errors: string | null; code: number }[]) {
|
|
276
|
+
let errors = 0;
|
|
277
|
+
try { const e = JSON.parse(r.errors ?? '[]') as { severity?: string }[]; errors = Array.isArray(e) ? e.filter((x) => x?.severity !== 'warning').length : 0; } catch { errors = 0; }
|
|
278
|
+
files.set(norm(r.path), { language: r.language, code: r.code === 1, parseErrors: errors });
|
|
279
|
+
}
|
|
280
|
+
const langOfFile = (p: string): string => files.get(p)?.language ?? 'unknown';
|
|
281
|
+
const reach = new Map<string, { files: number; trackedOnly: number; parseErrors: number; resolved: number; unresolved: number; naming: number; sample: string[]; counted: Record<string, number>; nameOnly: Record<string, number> }>();
|
|
282
|
+
const bucket = (lang: string) => {
|
|
283
|
+
let b = reach.get(lang);
|
|
284
|
+
if (!b) reach.set(lang, b = { files: 0, trackedOnly: 0, parseErrors: 0, resolved: 0, unresolved: 0, naming: 0, sample: [], counted: {}, nameOnly: {} });
|
|
285
|
+
return b;
|
|
286
|
+
};
|
|
287
|
+
for (const f of files.values()) { const b = bucket(f.language); if (f.code) b.files += 1; else b.trackedOnly += 1; b.parseErrors += f.parseErrors; }
|
|
288
|
+
|
|
289
|
+
const deps = new Map<string, EngineDep>();
|
|
290
|
+
const importTargetsByLine = new Map<string, Set<string>>();
|
|
291
|
+
const importTargetsByRef = new Map<string, Set<string>>();
|
|
292
|
+
const add = (m: Map<string, Set<string>>, k: string, v: string) => { const s = m.get(k); if (s) s.add(v); else m.set(k, new Set([v])); };
|
|
293
|
+
const edges = db.prepare(`SELECT e.kind, e.line, e.provenance prov, json_extract(e.metadata, '$.resolvedBy') how, json_extract(e.metadata, '$.confidence') conf,
|
|
294
|
+
json_extract(e.metadata, '$.refName') ref, s.file_path sf, t.file_path tf
|
|
295
|
+
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
|
|
296
|
+
WHERE e.kind <> 'contains' AND s.file_path <> t.file_path AND t.kind <> 'import'`).all() as { kind: string; line: number | null; prov: string | null; how: string | null; conf: number | null; ref: string | null; sf: string; tf: string }[];
|
|
297
|
+
for (const e of edges) {
|
|
298
|
+
const src = norm(e.sf);
|
|
299
|
+
const dst = norm(e.tf);
|
|
300
|
+
const b = bucket(langOfFile(src));
|
|
301
|
+
const method = e.how ?? 'extraction';
|
|
302
|
+
if (!counts(e.how, e.conf, e.prov)) { b.nameOnly[method] = (b.nameOnly[method] ?? 0) + 1; continue; }
|
|
303
|
+
b.counted[method] = (b.counted[method] ?? 0) + 1;
|
|
304
|
+
const kind = depKind(e.kind);
|
|
305
|
+
deps.set(`${src}\0${dst}\0${kind}`, { src, dst, kind, external: false });
|
|
306
|
+
if (e.kind === 'imports') {
|
|
307
|
+
if (e.line !== null) add(importTargetsByLine, `${src}\0${e.line}`, dst);
|
|
308
|
+
if (e.ref) add(importTargetsByRef, `${src}\0${e.ref}`, dst);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// The names and stems of each language's files, for an unresolved import that names one of them.
|
|
312
|
+
const names = new Map<string, Set<string>>();
|
|
313
|
+
const stems = new Map<string, Set<string>>();
|
|
314
|
+
for (const [path, f] of files) {
|
|
315
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
316
|
+
(names.get(f.language) ?? names.set(f.language, new Set()).get(f.language)!).add(name);
|
|
317
|
+
(stems.get(f.language) ?? stems.set(f.language, new Set()).get(f.language)!).add(name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name);
|
|
318
|
+
}
|
|
319
|
+
const namesARepoFile = (spec: string, lang: string): boolean => {
|
|
320
|
+
const n = names.get(lang);
|
|
321
|
+
const s = stems.get(lang);
|
|
322
|
+
if (!n || !s) return false;
|
|
323
|
+
if (n.has(spec)) return true;
|
|
324
|
+
if (/[/\\:]/.test(spec)) { const last = lastSegment(spec); return n.has(last) || s.has(last) || (last.includes('.') && s.has(last.slice(0, last.lastIndexOf('.')))); }
|
|
325
|
+
return s.has(spec.split('.').pop() ?? '');
|
|
326
|
+
};
|
|
327
|
+
// Each import statement: resolved when a counted import from its lines (or by its specifier) reaches another file.
|
|
328
|
+
for (const i of db.prepare("SELECT file_path f, name, start_line a, end_line b FROM nodes WHERE kind = 'import'").all() as { f: string; name: string; a: number; b: number }[]) {
|
|
329
|
+
const src = norm(i.f);
|
|
330
|
+
let hit = importTargetsByRef.has(`${src}\0${i.name}`);
|
|
331
|
+
for (let l = i.a; !hit && l <= Math.max(i.a, i.b); l++) hit = importTargetsByLine.has(`${src}\0${l}`);
|
|
332
|
+
const lang = langOfFile(src);
|
|
333
|
+
const b = bucket(lang);
|
|
334
|
+
if (hit) { b.resolved += 1; continue; }
|
|
335
|
+
b.unresolved += 1;
|
|
336
|
+
deps.set(`${src}\0${i.name}\0import`, { src, dst: i.name, kind: 'import', external: true });
|
|
337
|
+
if (namesARepoFile(i.name, lang)) { b.naming += 1; if (b.sample.length < 5) b.sample.push(`${src}: ${i.name}`); }
|
|
338
|
+
}
|
|
339
|
+
const languages: EngineLanguageReach[] = [...reach.entries()].sort((a, b) => b[1].files - a[1].files || a[0].localeCompare(b[0])).map(([language, b]) => ({
|
|
340
|
+
language, files: b.files, trackedOnly: b.trackedOnly, parseErrors: b.parseErrors,
|
|
341
|
+
imports: { resolved: b.resolved, unresolved: b.unresolved, unresolvedNamingRepoFiles: b.naming, sample: b.sample }, counted: b.counted, nameOnly: b.nameOnly,
|
|
342
|
+
}));
|
|
343
|
+
return { files, deps: [...deps.values()], languages };
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return `the engine's index has another shape than ${ENGINE_LABEL} writes: ${(error as Error).message}`;
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ───────────────────────── symbols, on demand ─────────────────────────
|
|
352
|
+
|
|
353
|
+
export type SymbolQueryKind = 'references' | 'implementations' | 'callers' | 'callees';
|
|
354
|
+
export interface EngineHit {
|
|
355
|
+
readonly file: string;
|
|
356
|
+
readonly line: number;
|
|
357
|
+
readonly character?: number;
|
|
358
|
+
readonly preview: string;
|
|
359
|
+
/** The symbol at the other end: what refers (references, callers), what is referred to (callees), what implements. */
|
|
360
|
+
readonly from?: string;
|
|
361
|
+
readonly to?: string;
|
|
362
|
+
/** The kind of reference (calls, references, imports, instantiates, extends, implements …). */
|
|
363
|
+
readonly edge: string;
|
|
364
|
+
/** How the engine resolved it, how sure it was, and whether the ledger counts it (see the module comment). */
|
|
365
|
+
readonly how: string;
|
|
366
|
+
readonly confidence: number | null;
|
|
367
|
+
readonly counted: boolean;
|
|
368
|
+
/**
|
|
369
|
+
* Why a hit the engine matched by name alone counts all the same: it is in the declaring file, or its file imports the
|
|
370
|
+
* declaring file (for a callee: the asking file imports the callee's) — the name is then the imported one.
|
|
371
|
+
*/
|
|
372
|
+
readonly through?: string;
|
|
373
|
+
}
|
|
374
|
+
export interface EngineSymbolResult {
|
|
375
|
+
readonly answeredBy: string;
|
|
376
|
+
readonly symbol: string;
|
|
377
|
+
readonly symbolKind: string;
|
|
378
|
+
readonly file: string;
|
|
379
|
+
readonly line: number;
|
|
380
|
+
readonly rule: string;
|
|
381
|
+
readonly locations?: readonly EngineHit[];
|
|
382
|
+
readonly incoming?: readonly EngineHit[];
|
|
383
|
+
readonly outgoing?: readonly EngineHit[];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const NOT_A_SYMBOL = new Set(['file', 'import', 'export', 'parameter']);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Through counted imports, for a hit matched by name: the files reaching `file` (directly: they import it; or through files
|
|
390
|
+
* that do, as a barrel that re-exports it), and the files `file` reaches — each with the number of hops.
|
|
391
|
+
*/
|
|
392
|
+
function importLinks(dir: string, file: string): { into: Map<string, number>; from: Map<string, number> } {
|
|
393
|
+
const db = new (sqlite().DatabaseSync)(indexFileOf(dir), { readOnly: true });
|
|
394
|
+
const forward = new Map<string, string[]>();
|
|
395
|
+
const backward = new Map<string, string[]>();
|
|
396
|
+
try {
|
|
397
|
+
for (const r of db.prepare(`SELECT DISTINCT s.file_path sf, t.file_path tf FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
|
|
398
|
+
WHERE e.kind = 'imports' AND s.file_path <> t.file_path AND t.kind <> 'import'
|
|
399
|
+
AND json_extract(e.metadata, '$.resolvedBy') IN ('import', 'file-path', 'qualified-name')`).all() as { sf: string; tf: string }[]) {
|
|
400
|
+
const a = norm(r.sf); const b = norm(r.tf);
|
|
401
|
+
forward.set(a, [...(forward.get(a) ?? []), b]);
|
|
402
|
+
backward.set(b, [...(backward.get(b) ?? []), a]);
|
|
403
|
+
}
|
|
404
|
+
} finally {
|
|
405
|
+
db.close();
|
|
406
|
+
}
|
|
407
|
+
const walk = (graph: Map<string, string[]>): Map<string, number> => {
|
|
408
|
+
const hops = new Map<string, number>([[file, 0]]);
|
|
409
|
+
const queue = [file];
|
|
410
|
+
while (queue.length) { const x = queue.shift()!; for (const y of graph.get(x) ?? []) if (!hops.has(y)) { hops.set(y, hops.get(x)! + 1); queue.push(y); } }
|
|
411
|
+
hops.delete(file);
|
|
412
|
+
return hops;
|
|
413
|
+
};
|
|
414
|
+
return { into: walk(backward), from: walk(forward) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A symbol question on a snapshot's index: `input.file` repository-relative, the symbol by name or by 1-based line and character. */
|
|
418
|
+
export function engineSymbol(dir: string, kind: SymbolQueryKind, input: { file: string; line?: number; character?: number; name?: string }): EngineSymbolResult | string {
|
|
419
|
+
const e = engine();
|
|
420
|
+
if (typeof e === 'string') return e;
|
|
421
|
+
if (!existsSync(indexFileOf(dir))) return `${ENGINE_LABEL} has no index for this repository yet: the round's first step builds it.`;
|
|
422
|
+
const file = norm(input.file);
|
|
423
|
+
let cg: CodeGraph;
|
|
424
|
+
try { cg = e.CodeGraph.openSync(treeOf(dir)); } catch (error) { return `The engine's index cannot be opened: ${(error as Error).message}`; }
|
|
425
|
+
try {
|
|
426
|
+
const nodes = cg.getNodesInFile(file).filter((n) => !NOT_A_SYMBOL.has(n.kind));
|
|
427
|
+
if (!nodes.length && !cg.getFile(file)) return `${file} is not in ${ENGINE_LABEL}'s index of the current version.`;
|
|
428
|
+
let target: EngineNode | undefined;
|
|
429
|
+
if (input.name) {
|
|
430
|
+
// A bare name, or one qualified by its container: `OrderStore.getOrder` or `OrderStore::getOrder`.
|
|
431
|
+
const want = input.name.trim();
|
|
432
|
+
const qualified = want.split('.').join('::');
|
|
433
|
+
const byName = nodes.filter((n) => (/::|\./.test(want)
|
|
434
|
+
? [want, qualified].some((w) => n.qualifiedName === w || n.qualifiedName.endsWith(`::${w}`) || n.qualifiedName.endsWith(`.${w}`))
|
|
435
|
+
: n.name === want));
|
|
436
|
+
target = byName.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
|
437
|
+
if (!target) return `No declaration named “${want}” in ${file}.`;
|
|
438
|
+
} else if (typeof input.line === 'number') {
|
|
439
|
+
const line = input.line;
|
|
440
|
+
const col = typeof input.character === 'number' ? input.character - 1 : null;
|
|
441
|
+
const inside = nodes.filter((n) => n.startLine <= line && line <= n.endLine && (col === null || ((n.startLine < line || n.startColumn <= col) && (n.endLine > line || n.endColumn >= col))));
|
|
442
|
+
target = inside.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine) || b.startLine - a.startLine || b.startColumn - a.startColumn)[0];
|
|
443
|
+
if (!target) return `No symbol at ${file}:${line}${col !== null ? `:${col + 1}` : ''}.`;
|
|
444
|
+
} else {
|
|
445
|
+
return 'Give either line + character (1-based) or a symbol name.';
|
|
446
|
+
}
|
|
447
|
+
const lines = new Map<string, string[]>();
|
|
448
|
+
const preview = (path: string, line: number): string => {
|
|
449
|
+
let ls = lines.get(path);
|
|
450
|
+
if (!ls) { try { ls = readFileSync(osPath(treeOf(dir), path), 'utf8').split(/\r?\n/); } catch { ls = []; } lines.set(path, ls); }
|
|
451
|
+
const t = (ls[line - 1] ?? '').trim();
|
|
452
|
+
return t.length > 200 ? `${t.slice(0, 200)}…` : t;
|
|
453
|
+
};
|
|
454
|
+
const targetFile = norm(target.filePath);
|
|
455
|
+
const links = importLinks(dir, targetFile);
|
|
456
|
+
/** `other` is the file at the far end: the referring one (in), or the referred one (out). */
|
|
457
|
+
const meta = (edge: EngineEdge, other: string, direction: 'in' | 'out') => {
|
|
458
|
+
const m = (edge.metadata ?? {}) as { resolvedBy?: string; confidence?: number };
|
|
459
|
+
const how = m.resolvedBy ?? null;
|
|
460
|
+
const confidence = typeof m.confidence === 'number' ? m.confidence : null;
|
|
461
|
+
const counted = counts(how, confidence, edge.provenance ?? null);
|
|
462
|
+
const hops = (direction === 'in' ? links.into : links.from).get(other);
|
|
463
|
+
const through = counted ? null
|
|
464
|
+
: other === targetFile ? 'in the declaring file'
|
|
465
|
+
: hops === 1 ? (direction === 'in' ? 'its file imports the declaring file' : 'the asking file imports its file')
|
|
466
|
+
: hops !== undefined ? (direction === 'in' ? `its file reaches the declaring file through imports (${hops} steps)` : `the asking file reaches its file through imports (${hops} steps)`)
|
|
467
|
+
: null;
|
|
468
|
+
return { edge: edge.kind, how: how ?? 'extraction', confidence, counted: counted || through !== null, ...(through ? { through } : {}) };
|
|
469
|
+
};
|
|
470
|
+
const name = (n: EngineNode) => `${n.kind} ${n.qualifiedName}`;
|
|
471
|
+
const order = (a: EngineHit, b: EngineHit) => Number(b.counted) - Number(a.counted) || a.file.localeCompare(b.file) || a.line - b.line;
|
|
472
|
+
const base = { answeredBy: ENGINE_LABEL, symbol: target.qualifiedName, symbolKind: target.kind, file: targetFile, line: target.startLine, rule: `${COUNTED_RULE}; a hit matched by name counts when it is in the declaring file or its file reaches the declaring file through imports (through)` };
|
|
473
|
+
if (kind === 'references') {
|
|
474
|
+
const hits = cg.findUsages(target.id).filter((u) => u.edge.kind !== 'contains').map((u): EngineHit => {
|
|
475
|
+
const f = norm(u.node.filePath);
|
|
476
|
+
const line = u.edge.line ?? u.node.startLine;
|
|
477
|
+
return { file: f, line, ...(typeof u.edge.column === 'number' ? { character: u.edge.column + 1 } : {}), preview: preview(f, line), from: name(u.node), ...meta(u.edge, f, 'in') };
|
|
478
|
+
});
|
|
479
|
+
return { ...base, locations: hits.sort(order) };
|
|
480
|
+
}
|
|
481
|
+
if (kind === 'callers') {
|
|
482
|
+
const hits = cg.getCallers(target.id, 1).map((c): EngineHit => {
|
|
483
|
+
const f = norm(c.node.filePath);
|
|
484
|
+
const line = c.edge.line ?? c.node.startLine;
|
|
485
|
+
return { file: f, line, preview: preview(f, line), from: name(c.node), ...meta(c.edge, f, 'in') };
|
|
486
|
+
});
|
|
487
|
+
return { ...base, incoming: hits.sort(order) };
|
|
488
|
+
}
|
|
489
|
+
if (kind === 'callees') {
|
|
490
|
+
const hits = cg.getCallees(target.id, 1).map((c): EngineHit => {
|
|
491
|
+
const f = norm(c.node.filePath);
|
|
492
|
+
return { file: f, line: c.node.startLine, preview: preview(f, c.node.startLine), to: name(c.node), ...meta(c.edge, f, 'out') };
|
|
493
|
+
});
|
|
494
|
+
return { ...base, outgoing: hits.sort(order) };
|
|
495
|
+
}
|
|
496
|
+
// Implementations: what extends or implements it, directly or through another that does — read from those edges.
|
|
497
|
+
// (The engine's own getTypeHierarchy marks the symbol visited on its way to the ancestors, so in 1.6.0 it never
|
|
498
|
+
// returns the descendants.)
|
|
499
|
+
const hits: EngineHit[] = [];
|
|
500
|
+
const seen = new Set<string>([target.id]);
|
|
501
|
+
const queue = [target.id];
|
|
502
|
+
while (queue.length) {
|
|
503
|
+
for (const edge of cg.getIncomingEdges(queue.shift()!)) {
|
|
504
|
+
if ((edge.kind !== 'extends' && edge.kind !== 'implements') || seen.has(edge.source)) continue;
|
|
505
|
+
seen.add(edge.source);
|
|
506
|
+
queue.push(edge.source);
|
|
507
|
+
const n = cg.getNode(edge.source);
|
|
508
|
+
if (!n) continue;
|
|
509
|
+
const f = norm(n.filePath);
|
|
510
|
+
hits.push({ file: f, line: n.startLine, preview: preview(f, n.startLine), from: name(n), ...meta(edge, f, 'in') });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return { ...base, locations: hits.sort(order) };
|
|
514
|
+
} finally {
|
|
515
|
+
cg.close();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ───────────────────────── history, in a child process ─────────────────────────
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The import specifiers of each version of one file, as the engine extracts them (one child process for all of them); a
|
|
523
|
+
* version given as null stays null. A string when the engine cannot read them.
|
|
524
|
+
*/
|
|
525
|
+
export function engineImportsOf(path: string, sources: readonly (string | null)[], workDir: string): (readonly string[] | null)[] | string {
|
|
526
|
+
const unavailable = engineUnavailable();
|
|
527
|
+
if (unavailable) return unavailable;
|
|
528
|
+
mkdirSync(workDir, { recursive: true });
|
|
529
|
+
const { json, error } = runChild(workDir, ['imports', path], JSON.stringify(sources), 10 * 60_000);
|
|
530
|
+
if (error) return `${ENGINE_LABEL} could not read the versions of ${path}: ${error}`;
|
|
531
|
+
const out = json?.imports;
|
|
532
|
+
return Array.isArray(out) ? out as (string[] | null)[] : `${ENGINE_LABEL} gave no answer for ${path}.`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Whether an import specifier names a file by its stem: a path or URI-like specifier by its last segment (after `/`, `\`
|
|
537
|
+
* or `:`), with or without the extension; a bare one by itself or by the last part of a dotted module path —
|
|
538
|
+
* `package:shop_app/core/x.dart`, `../util`, `pkg.sub`, `com.example.Bar` name `x.dart`, `util.ts`, `sub.py`, `Bar.kt`.
|
|
539
|
+
*/
|
|
540
|
+
export function specifierNames(spec: string, fileName: string): boolean {
|
|
541
|
+
const stem = fileName.includes('.') ? fileName.slice(0, fileName.lastIndexOf('.')) : fileName;
|
|
542
|
+
if (spec === fileName) return true;
|
|
543
|
+
if (/[/\\:]/.test(spec)) {
|
|
544
|
+
const last = lastSegment(spec);
|
|
545
|
+
return last === fileName || last === stem || (last.includes('.') && last.slice(0, last.lastIndexOf('.')) === stem);
|
|
546
|
+
}
|
|
547
|
+
return (spec.split('.').pop() ?? '') === stem;
|
|
548
|
+
}
|