dsh-code-impact
Post-edit impact checks for DeepSeek Harness — a symbol index, a reference graph, and an inheritance model that answer "what did this edit touch?" instead of "grep the repo and re-run everything".
中文文档
Why
Every small edit — a default value, a rename, a deleted method — triggers the same ceremony:
search the whole repo, open a pile of files, run the full type-check and the full test suite.
The problem isn't wanting full coverage. The problem is doing it by scanning.
An index inverts that:
- the whole repo's relationships are already in memory, so a "full check" is one query, not a scan;
- after an edit, only the changed file is re-parsed, and the reference edges tell you exactly which sites must be revisited;
- answers never say "probably fine" — they give
file:line, a confidence, and an explicitly listed unverified part.
What you get
Two tools, registered into every agent session — plus a browsable page of the same index
(Settings → Code Impact).
code_query
op | Answers |
|---|
find | where is this symbol (addNode, Graph.addNode, ts:src/x.ts#Graph.addNode) |
outline | declaration skeleton of one file, with line ranges, exports, heritage clauses |
refs | who mentions this symbol — each site resolved individually, so a same-named local doesn't count |
hierarchy | extends / implements / subtypes / members (own, inherited, overridden) |
search | substring match over names and signatures |
status | index size, root, cache location, engine mode, parse failures |
find "parseTimeout" — 1 match
ts:src/config.ts#parseTimeout
variable src/config.ts:1-1 exported refs:3
parseTimeout = 30 ;
refs ts:src/config.ts#parseTimeout — 3 sites (exported, refs:3)
variable src/config.ts:1 parseTimeout = 30 ;
confidence: exact 3 · resolved 0 · heuristic 0 · unresolved mentions 0
src/policy.ts:4 value in RetryPolicy
timeout = parseTimeout;
src/service.ts:5 value in makeService
const policy = buildPolicy({ timeout: parseTimeout });
code_verify
Call it after editing instead of re-scanning. It re-parses only the files that changed on disk
(and discovers new ones), then reports a symbol-level diff:
verify — verdict: internal
1 file changed (0 deleted) · +0 -0 symbols · 1 body-only
body-only (signature unchanged, callers unaffected):
ts:src/config.ts#parseTimeout refs:6
verify — verdict: breaking
1 file changed (0 deleted) · +1 -1 symbols · 1 contract
symbols that no longer exist:
ts:src/config.ts#parseTimeout
references that no longer resolve:
dangling src/policy.ts:4 parseTimeout
verdict has four levels: clean (nothing changed), internal (implementation only),
contract (signature/export changed, callers may need edits), breaking (some reference no
longer resolves).
A reference is only called dangling when it used to resolve to the symbol that went away. A
mention that never resolved in the first place — filepath.Base, a package that is not installed,
a name the heuristic merely guessed at — is not reported, because a false alarm costs more than a
missed one.
The rule that makes it useful in practice: const timeout = 30 → = 60 is a value change,
reported as body-only, never as a contract change. Containers (class / interface / struct) are not
listed themselves — their members already express the change, so one memo edit doesn't drag the
whole class into the report.
code_verify is incremental: it reports changes since the index last synced. Ask twice without
editing in between and the second answer is clean. That's the intended loop — one question after
each edit, and the answer is about what you just did.
The index page
The same index is browsable: Settings → Code Impact. Three views over the workspace, drawn from
the index rather than from a fresh scan:
| view | what it answers |
|---|
| Architecture | a treemap of directories weighted by symbol count, coloured by language — where the code actually lives |
| Imports | the file-level import graph as a chord diagram; hover a file to isolate its edges |
| Inheritance | the type hierarchy by depth, implements drawn dashed |
| Sessions | which sessions used the plugin, what they asked, what came back |
Clicking a file lists its symbols with kind, signature and line; clicking a type shows its
supertypes, subtypes and the file that declares it. The header carries the honesty chips —
files / symbols / names / languages, when the index was built, and a warning when files were
skipped — plus a Refresh button that re-checks the disk before redrawing.
The Sessions tab is the plugin's own audit trail: every code_query / code_verify call is
recorded per session (id, working directory, call counts, and the last 40 calls with their target
and one-line outcome). It is written to $DSH_HOME/cache/code-index/usage.json, so it survives a
restart and covers sessions this process never saw. It records the plugin's own calls only — it is
not an authority on what the agent did — and Clear empties it without touching the index.
Open full page puts the same map in a browser tab (/code-impact/app) with more room and no
settings chrome behind it; the two views share the same routes and the same index.
The page draws one indexed workspace at a time, and the folder box under the header is how a
second one gets in: type an absolute path (D:\work\my-project), press Enter, and the page builds
an index for it and switches to it. The same workspace stays in the dropdown afterwards. This is
the only way a workspace appears without the agent using a tool in it first — a session whose cwd
is the project also indexes it, implicitly, the first time code_query runs.
It is a client half of the bundle (client.js, React, theme tokens only — no iframe, no inline
HTML), talking to the host through read routes, one build route, and the usage routes. The
standalone page (web/app.html + web/app.js, plain DOM and SVG) is served from the same host:
GET /code-impact/api/roots
GET /code-impact/api/overview?root=<abs path>&refresh=1
GET /code-impact/api/file?root=<abs path>&path=<repo-relative path>
POST /code-impact/api/index {"root": "<absolute path>"}
GET /code-impact/api/usage?limit=100
POST /code-impact/api/usage/clear {}
GET /code-impact/app the full-page view
GET /code-impact/app.js
Reads are allow-listed: the page can only read a directory that is already indexed, or the
session's working directory. POST /index is the one route that does work on request — it builds a
cache for the exact directory you named and nothing else, refuses relative paths, missing
directories and plain files with a 400 carrying the reason, and never writes inside your
repository.
Install
dsh-code-impact is a DSH host bundle: zero dependencies, plain ESM, one patch row.
# from GitHub (or any published tarball / registry package)
dsh plugin --profile desktop add github:Locael123/dsh-code-impact
dsh web # restart: the host half of a plugin is imported once, at boot
# from a local checkout
plugin_manager install_bundle <absolute path>/dsh-code-impact
Manual install — only if the package is already resolvable from the profile's node_modules
(plugin_manager does that for you; a bare cordis.yml row alone will not):
- id: dsh-code-impact
name: dsh-code-impact
config:
maxFiles: 20000
The bundle ships its own cordis.patch.yml, so the plugin manager path is preferred — it wires the
patch row, links the directory, and keeps files[] correct.
| config | default | meaning |
|---|
maxFiles | 20000 | cap on files per build; truncation is reported in status |
root | session working directory | override the indexed root |
Cache: $DSH_HOME/cache/code-index/<hash(root)>/ — content-addressed facts/ plus a
manifest.json. Editing one file rewrites one fact. It is a cache: delete it any time, it
rebuilds; it is never written inside the indexed repo.
Precision contract
Every answer carries its own bounds instead of a confident guess:
confidence: exact | resolved | heuristic | ambiguous — exact resolves to a concrete
declaration; heuristic is unique-name / nearest-path inference;
total / truncated — truncation is stated, not hidden;
unverified: — what this particular answer does not cover (cross-language edges, dynamic
references, type-level compatibility).
A file that fails to parse never disappears silently: code_query op=status reports
warning: N file(s) could not be parsed and are NOT indexed, and code_verify states it in
unverified:. If the index is under-counting, you get told.
When in doubt it returns candidates rather than "not found".
Scope & limits
- Languages: TypeScript / JavaScript / Go (
.ts .tsx .mts .cts .js .jsx .mjs .cjs .go).
- Module resolution: relative paths with extension and
/index probing, tsconfig/jsconfig
baseUrl + paths, go.mod module prefix with same-package directories.
- Skipped:
node_modules .git dist build out vendor target __pycache__ .venv
and friends, files over 512 KiB, binaries.
- Deliberately approximate: TS
implements and Go interface satisfaction are structural /
implicit, so they yield a candidate set marked heuristic. This is the only place the tool
knowingly guesses.
- Not modelled: cross-language edges (a Go interface to its implementors, a JSON schema to its
consumers), dynamic dispatch, generated code.
How it works
- Facts, not graphs. Each file stores only what this file says:
imports, exports,
decls (with heritage and members), refs (name + position + role + lexical scope). There is no
target ID on disk. Name → definition is resolved at query time, which is why editing one file
invalidates exactly one file.
- Stable SymbolId:
<lang>:<path>#<qualname>, no line numbers. A rename or move changes the ID,
and that change is itself the signal.
- Freshness: a query
stats the files in its answer before returning (mtime + size), so a
stale hit is impossible for anything it reports. code_verify drives incremental re-indexing;
external edits are caught by the same stat check; a structural change bumps a manifest version and
rebuilds.
- No native modules, no LSP, no subprocess. A hand-written lexer plus a scope-aware resolver per
language keeps cold start in the hundreds of milliseconds and the whole plugin dependency-free.
Measured on a 42-file mixed TS/Go workspace: cold build 276 ms, 883 symbols; queries 1–4 ms;
warm start from manifest 3 ms for a 5-file fixture.
Development
node test/selftest.mjs # index + tools, 36 assertions
node test/addroot.mjs # the page's "index a folder" path, including its refusals
node test/usage.mjs # the usage ledger: per-session records, persistence, clearing
node test/webcheck.mjs <repo> # the JSON routes the page calls
node test/clientsmoke.mjs # client half registers its settings section
The self-test copies test/fixture — a deliberately small TS + Go project with a tsconfig path
alias, a class hierarchy, and a two-package Go module — into a temp directory, builds a real index,
and runs 36 assertions: body-only edits are not reported as contract changes, a renamed export
surfaces its dangling references, a renamed directory does not, new files are picked up, deleted
files are reported, Go cross-package references resolve exact, warm start reloads from the
manifest, and a file that mentions constructor still parses (that one was a real bug: a plain
object used as a delimiter stack hit Object.prototype.constructor).
License
MIT