devscout-rs 0.7.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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
276
277
278
279
280
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
347
348
349
350
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
376
377
378
379
380
381
382
383
384
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
# The `--json` answer contract

`refs`, `read`, `impact` and `tests` all answer the same way under `--json`: a single JSON
object, its first key always `schema_version`, one of its keys always `outcome`, and every hit
row inside it carrying a `why`. This document is the contract for those three things. Nothing
here documents the rest of each verb's shape -- that lives in `README.md`'s command table and in
`src/query/json.rs`'s own builder comments, which pin key order field by field.

**Forward compatibility.** A consumer should ignore any key it does not recognise. New keys are
added to these answers over time (`schema_version` and `why` are themselves two such additions);
an existing key is never renamed, removed, or given a different meaning without `schema_version`
changing.

## `schema_version`

`schema_version` is the version of the answer *shape* -- not of the graph (`graph.json` carries
its own, unrelated `GRAPH_SCHEMA_VERSION`), and not of the CLI. It is always the first key in
every object this document covers, so a consumer reading keys positionally sees it before
anything else.

Today it is always `1`. It bumps only when an existing key is renamed, removed, or given a
different meaning; adding a new key -- the ordinary way this contract grows -- never bumps it.

## `outcome`

Every answer names one of four outcomes, defined once in `src/query/outcome.rs` as the `Outcome`
enum so no caller can spell a fifth one into existence:

| `outcome` | Meaning |
| --- | --- |
| `hit` | The seed resolved (a type, a member, or a file) and the answer is the ordinary shape a caller reads. |
| `zero-hit` | The seed resolved but the answer itself is empty: `impact`'s "resolved seed, zero affected rows" case, or `refs`/`read` on a member declared by exactly one type with nothing referencing it -- `read`'s answer there is `refs`' own inbound list, so the two are byte-identical on stdout. Exit code 3; `impact` adds its zero-hit note on stderr, `refs`/`read` leave stderr empty. |
| `ambiguous` | The seed named more than one candidate (a type, or a member across more than one declaring type) and nothing was guessed between them. |
| `fallback-advised` | Nothing in the graph carries the seed at all; the caller's zero-hit note advises a text-search fallback. |

## The `ambiguous` envelope

`refs`, `read`, `impact` and `tests` share one JSON shape for `ambiguous`, whichever of the two
candidate vocabularies below produced it: `schema_version` first, `outcome: "ambiguous"`, the
`query` string, and a `candidates` array, at exit code 1. A type seed's candidates carry `{id,
file, line, kind}` (`ambiguous_candidates_out` in `src/cli.rs`); a member seed's carry `{owner,
name, file, line}` (`member_ambiguous_out`), naming the declaring type and the member instead of
a graph id.

The two examples below are real `--json` runs, each against its own small scratch repo built to
show the shape -- the shipped `fixtures/conformance/csharp/` corpus has no name that resolves
ambiguously.

A type seed (`Widget` declared in two namespaces):

```
devscout refs Widget --json
```

```json
{
  "schema_version": 1,
  "outcome": "ambiguous",
  "query": "Widget",
  "candidates": [
    { "id": "App.Alpha.Widget", "file": "src/A.cs", "line": 3, "kind": "class" },
    { "id": "App.Beta.Widget", "file": "src/B.cs", "line": 3, "kind": "class" }
  ]
}
```

A member seed (`Run` declared on both `Widget` types above):

```
devscout refs Run --json
```

```json
{
  "schema_version": 1,
  "outcome": "ambiguous",
  "query": "Run",
  "candidates": [
    { "owner": "App.Alpha.Widget", "name": "Run", "file": "src/A.cs", "line": 5 },
    { "owner": "App.Beta.Widget", "name": "Run", "file": "src/B.cs", "line": 5 }
  ]
}
```

## `why`

Every hit row -- an inbound/outbound/import row on `refs`/`read`, a row of `impact`'s blast
radius, a row of `tests`' coverage, and `read`'s declaration span -- carries a `why`: which rule
or tier produced it. The vocabulary is defined once, in `src/query/why.rs`, as the `Why` enum, the
same closed-vocabulary discipline `Outcome` uses. `why` is never stamped on an ambiguous or
candidate-count row: those already carry their own `origin`/`candidateCount` fields describing
why they are *unresolved*, a different question from which rule produced a settled hit.

| `why` | Meaning |
| --- | --- |
| `inherits` | A base-list (`inherits`) edge -- a class or interface named in a base list. |
| `uses-type` | A `uses-type` edge -- a type used as a field/property/parameter/return type, a generic argument, and similar. |
| `uses-member-precise` | A `uses-member` edge the resolver proved outright, with no guessing. |
| `uses-member-ext` | A `uses-member` edge found through C#'s own extension-method lookup, which can see the `(member, this-type)` pair but not the receiver's real members. |
| `uses-member-guess` | A `uses-member` edge the scored-guess tier picked because exactly one type in the graph declares a member of that name (or, for a heuristic edge from a graph written before tiers were recorded, an edge that cannot prove which of the two it was). |
| `ctor-di` | A constructor-injection edge: the row was reached through a parameter whose type is injected. |
| `imports` | A C# `using` directive. |
| `declaration` | `read`'s declaration span -- the def's own source, not an edge at all. |
| `test-attribute` | A `tests` row earned because a def declared in the file carries a test-runner attribute (`[Fact]`, `[Test]`, `[TestMethod]`, and their qualified/targeted/shared-bracket forms). |
| `test-project` | A `tests` row earned because the project model places the file's unit inside a project marked `test`, with no attributed def of its own. |
| `imported-edge` | An `impact` row reached only through an imported cross-repo edge -- never one of this graph's own -- carrying the foreign `repo` id and the export's `provenance` id. The weakest evidence this vocabulary names: a foreign, producer-asserted fact never outranks anything this crate resolved for itself. |

**Vocabulary growth.** `why` and `outcome` may each gain a new word over time without a
`schema_version` bump, the same way an added key never bumps it. A consumer must treat a `why`
or `outcome` value it does not recognise as opaque -- skip it rather than fail.

Four edge kinds this crate records never reach `why`: a TS/TSX-only edge (`import`/`call`/
`jsx-use`/`dispatch`) is never admitted into the inbound/outbound adjacency any of these four
verbs read (`query::index`'s `Edge::Import | Edge::Call | Edge::JsxUse | Edge::Dispatch => {}`
arm), so a row built from one -- and a word for it -- never exists.

`why` is derived entirely from what the graph already persists on the edge that produced the row:
its kind, and, for a `uses-member` edge, its `tier` (`ext`, `guess`, or absent, meaning precise).
An `impact` row can fold more than one edge kind into a single per-file summary; when it does,
the strongest evidence wins -- an explicit `ctor-di` site over a plain resolved reference, over
the broader interface hop, over a guess, in that order.

## `freshness`

`refs`, `read`, `impact` and `tests` (`find` has no `--json` shape) append a top-level `freshness`
object as their true last key, always after `outcome`. `occurrenceIndex` is a *row* key nested
inside `inbound`/`outbound` several levels down, never a top-level sibling of `freshness` -- the
two never share an adjacency to describe. `freshness` is always present under `--json`, on every
outcome, not only a resolved hit: it is a property of the index at query time, not of the specific
query.

`freshness.state` is one of three words:

| `state` | Meaning | Other keys |
| --- | --- | --- |
| `"fresh"` | The index was built at the working tree's current HEAD and no indexed file has gone dirty since. | none |
| `"stale"` | The index is behind the working tree. | `indexedHead`, `currentHead` (both full-length, unlike the truncated stderr warning), `changedFiles` (indexed files dirty now but not at index time) |
| `"unknown"` | Freshness could not be established either way. | `reason`: `"no-index-state"` (no `index-state.json` sidecar, or it did not parse -- an index built before this key existed, or a corrupt one) or `"git-unavailable"` (`git rev-parse HEAD` did not resolve) |

This exposes, to a programmatic consumer, exactly the condition
`src/manifest.rs::freshness_warning` already prints as a one-line stderr note for a human -- the
two are computed from the same primitives and can never disagree about the underlying facts. The
stderr note collapses "no index-state.json", "git unavailable" and "genuinely fresh" into the same
silence; `freshness.state` keeps them apart, because a script cannot read stderr silence as three
different things. No native precise tier is upgraded or downgraded by this key -- it says whether
the index is current, not whether any individual row is trustworthy.

## `occurrenceIndex`

Two rows of one inbound or outbound table on `refs`/`read` can otherwise serialize to
byte-identical JSON objects -- for example, two calls to the same target on the same line. When
that happens, each of the colliding rows carries `occurrenceIndex`: a 0-based integer, assigned in
the table's existing stable emission order, appended as the row's last key (after `why`) and
omitted (`Option::is_none`) on every row that has no such collision. It is present **only** when
two or more rows of the *same table* of the *same answer* would otherwise be indistinguishable on
every other field they carry.

`occurrenceIndex` is scoped to one table of one answer against one graph snapshot. It is **not** a
cross-query, cross-table or cross-snapshot identity: the same call site can carry a different
`occurrenceIndex` on a later run against a changed snapshot, or none at all once a sibling that
used to collide with it is edited away. A consumer that needs a stable identity across runs must
not read this field as one.

A row truncated away by an existing cap (`INBOUND_CAP`/`OUTBOUND_CAP`/`--all` lifts both) is
counted in the table's own `dropped`, exactly as before this field existed, and is never assigned
or implied an `occurrenceIndex` -- truncation stays truncation, never a silent merge.

This key is additive under the forward-compatibility rule above: it is new, appended last, and
`schema_version` does not move.

## One worked example per verb

Each example below is a real `--json` run against a fixture in this repository
(`fixtures/conformance/csharp/`, copied into its own throwaway git repo and mapped), reformatted
for readability. `_store`/`store` are the only two callers of `InMemoryCatalogStore.CountItems`;
`CatalogStoreTests` is the only test file; `ICatalogStore` is the interface `InMemoryCatalogStore`
implements. These four examples predate the `freshness` key and are kept focused on the
pre-existing shape, so its top-level object is elided from each -- see the `## freshness` section
above for its own shape and the three states it can carry; every example below still gains it as
its true last key on every real run.

### `refs`

```
devscout refs CountItems --json
```

```json
{
  "schema_version": 1,
  "status": "members",
  "query": "CountItems",
  "members": [
    {
      "schema_version": 1,
      "status": "resolved",
      "query": "CountItems",
      "id": "Widgetworks.Catalog.InMemoryCatalogStore.CountItems",
      "kind": "member",
      "sites": [{ "file": "InMemoryCatalogStore.cs", "line": 9 }],
      "inbound": {
        "inherits": { "total": 0, "dropped": 0, "rows": [] },
        "uses-type": { "total": 0, "dropped": 0, "rows": [] },
        "uses-member": {
          "total": 2,
          "dropped": 0,
          "rows": [
            {
              "file": "CatalogController.cs",
              "line": 9,
              "source": "return _store.CountItems();",
              "why": "uses-member-precise"
            },
            {
              "file": "CatalogStoreTests.cs",
              "line": 12,
              "source": "Assert.Equal(0, store.CountItems());",
              "why": "uses-member-precise"
            }
          ]
        }
      },
      "ambiguous": {
        "inbound": { "total": 0, "dropped": 0, "rows": [] },
        "outbound": { "total": 0, "dropped": 0, "rows": [] }
      },
      "manifestGap": 0,
      "outcome": "hit"
    }
  ],
  "outcome": "hit"
}
```

`CountItems` is a bare member name, so `refs` answers under the `members` wrapper (one entry per
declaring type -- here, just `InMemoryCatalogStore`); the wrapper and each member both lead with
their own `schema_version`.

### `read`

```
devscout read InMemoryCatalogStore --json
```

```json
{
  "schema_version": 1,
  "status": "resolved",
  "query": "InMemoryCatalogStore",
  "id": "Widgetworks.Catalog.InMemoryCatalogStore",
  "kind": "class",
  "span": {
    "file": "InMemoryCatalogStore.cs",
    "startLine": 5,
    "endLine": 18,
    "source": "public class InMemoryCatalogStore : ICatalogStore\n{\n    private readonly List<string> _items = new();\n\n    public int CountItems()\n    {\n        return _items.Count;\n    }\n\n    public void AddItem(string name)\n    {\n        _items.Add(name);\n    }\n}",
    "why": "declaration"
  },
  "sites": [{ "file": "InMemoryCatalogStore.cs", "line": 5 }],
  "inbound": {
    "inherits": { "total": 0, "dropped": 0, "rows": [] },
    "uses-type": {
      "total": 3,
      "dropped": 0,
      "rows": [
        {
          "file": "CatalogController.cs",
          "line": 5,
          "source": "private readonly InMemoryCatalogStore _store = new InMemoryCatalogStore();",
          "why": "uses-type"
        },
        {
          "file": "CatalogController.cs",
          "line": 5,
          "source": "private readonly InMemoryCatalogStore _store = new InMemoryCatalogStore();",
          "why": "uses-type"
        },
        {
          "file": "CatalogStoreTests.cs",
          "line": 11,
          "source": "var store = new InMemoryCatalogStore();",
          "why": "uses-type"
        }
      ]
    },
    "uses-member": {
      "total": 2,
      "dropped": 0,
      "rows": [
        {
          "file": "CatalogController.cs",
          "line": 9,
          "source": "return _store.CountItems();",
          "why": "uses-member-precise"
        },
        {
          "file": "CatalogStoreTests.cs",
          "line": 12,
          "source": "Assert.Equal(0, store.CountItems());",
          "why": "uses-member-precise"
        }
      ]
    }
  },
  "ambiguous": {
    "inbound": { "total": 0, "dropped": 0, "rows": [] },
    "outbound": { "total": 0, "dropped": 0, "rows": [] }
  },
  "manifestGap": 0,
  "outcome": "hit"
}
```

The declaration span carries `"why": "declaration"` -- it quotes the def's own source, not an
edge -- while the inbound rows below it carry the ordinary edge-derived words.

### `impact`

```
devscout impact ICatalogStore --json
```

```json
{
  "schema_version": 1,
  "query": "ICatalogStore",
  "status": "resolved",
  "kind": "symbol",
  "seedFiles": ["ICatalogStore.cs"],
  "hops": 2,
  "totalAffected": 3,
  "rows": [
    {
      "file": "InMemoryCatalogStore.cs",
      "hop": 1,
      "viaCount": 1,
      "ambiguousCount": 0,
      "topSymbols": ["ICatalogStore"],
      "topSymbolsMore": 0,
      "score": 0.3195320656137759,
      "fromLines": { "direct": 5 },
      "why": "inherits"
    },
    {
      "file": "CatalogController.cs",
      "hop": 2,
      "viaCount": 3,
      "ambiguousCount": 0,
      "topSymbols": ["InMemoryCatalogStore"],
      "topSymbolsMore": 0,
      "score": 0.15227392859677333,
      "fromLines": { "direct": 5 },
      "why": "uses-type"
    },
    {
      "file": "CatalogStoreTests.cs",
      "hop": 2,
      "viaCount": 2,
      "ambiguousCount": 0,
      "topSymbols": ["InMemoryCatalogStore"],
      "topSymbolsMore": 0,
      "score": 0.15227392859677333,
      "fromLines": { "direct": 11 },
      "why": "uses-type"
    }
  ],
  "dropped": 0,
  "manifestGap": 0,
  "heuristicAffected": 0,
  "testsAffected": 1,
  "outcome": "hit"
}
```

`InMemoryCatalogStore.cs` is affected at hop 1 because it declares the base-list edge to
`ICatalogStore` itself (`why: "inherits"`); the other two files are affected at hop 2 because
they use `InMemoryCatalogStore`, the hop-1 file, as a type (`why: "uses-type"`).

### `tests`

```
devscout tests InMemoryCatalogStore --json
```

```json
{
  "schema_version": 1,
  "status": "resolved",
  "query": "InMemoryCatalogStore",
  "symbol": "Widgetworks.Catalog.InMemoryCatalogStore",
  "defFiles": ["InMemoryCatalogStore.cs"],
  "rows": [
    {
      "file": "CatalogStoreTests.cs",
      "testDefs": ["Widgetworks.Catalog.Tests.CatalogStoreTests"],
      "lines": [11, 12],
      "refCount": 2,
      "why": "test-attribute"
    }
  ],
  "testFileCount": 1,
  "refCount": 2,
  "heuristicFileCount": 0,
  "heuristicRefCount": 0,
  "outcome": "hit"
}
```

`CatalogStoreTests` earns its row because it declares `CountItems_StartsAtZero`, a def carrying
xUnit's `[Fact]` attribute -- `why: "test-attribute"`, the other of the two ways a `tests` row
is earned being `test-project` (the file's unit is in a project marked `test`, with no attributed
def of its own).