knodin 0.12.0 → 0.12.1
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/dist/src/engine/index.js +96 -1
- package/dist/src/output-telemetry.js +80 -1
- package/docs/releases/0.12.1.md +98 -0
- package/package.json +3 -2
package/dist/src/engine/index.js
CHANGED
|
@@ -1728,6 +1728,34 @@ function createLanguageMemo() {
|
|
|
1728
1728
|
* `isLineComment` below, which also requires the `//` prefix.
|
|
1729
1729
|
*/
|
|
1730
1730
|
const LINE_COMMENT_NODE_TYPES = new Set(["comment", "line_comment", "hash_comment"]);
|
|
1731
|
+
/**
|
|
1732
|
+
* Repo-relative files that reference `symbol` and are test files, read from raw
|
|
1733
|
+
* reference rows rather than from resolved callers.
|
|
1734
|
+
*
|
|
1735
|
+
* Resolved callers cannot answer this. A call inside an anonymous callback --
|
|
1736
|
+
* `it("...", () => { target() })`, which is how essentially all test code is
|
|
1737
|
+
* written -- has no enclosing NAMED symbol, so it is stored with a null
|
|
1738
|
+
* `callerSymbol` and every caller-resolution path drops it. Measured on this
|
|
1739
|
+
* repository: 29,158 of 31,467 references from test files (92.7%) have a null
|
|
1740
|
+
* caller, against 390 of 18,399 (2.1%) from production files. So a coverage
|
|
1741
|
+
* question answered from callers is roughly 93% blind in exactly the files it is
|
|
1742
|
+
* asking about, and reported `untested: true` for 642 symbols that have tests
|
|
1743
|
+
* (KNODIN-36).
|
|
1744
|
+
*
|
|
1745
|
+
* The reference row itself carries `callerFile`, which is all this question
|
|
1746
|
+
* needs. Attributing the call to the file instead would be the other repair, and
|
|
1747
|
+
* `call-graph.spec.ts` deliberately forbids it: a file path must never appear as
|
|
1748
|
+
* a caller. So the fix belongs here, at the question, not in the graph.
|
|
1749
|
+
*
|
|
1750
|
+
* Shared by `explain`'s `untested`, `tests_for`, and `detectKnowledgeGaps` so the
|
|
1751
|
+
* definition of "covered by a test" cannot drift between them.
|
|
1752
|
+
*/
|
|
1753
|
+
function testCallerFilesFor(db, symbol, relativeFile) {
|
|
1754
|
+
const statement = db.query('SELECT DISTINCT callerFile FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL)');
|
|
1755
|
+
const rows = statement.all(symbol, relativeFile);
|
|
1756
|
+
statement.finalize();
|
|
1757
|
+
return rows.map((row) => row.callerFile).filter((file) => isTestFilePath(file));
|
|
1758
|
+
}
|
|
1731
1759
|
/** Clean up and format comment/docstring blocks in JavaScript/TypeScript. */
|
|
1732
1760
|
function getPrecedingComment(node) {
|
|
1733
1761
|
let target = node;
|
|
@@ -12449,7 +12477,20 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
12449
12477
|
})
|
|
12450
12478
|
: [];
|
|
12451
12479
|
const allBlastFiles = Array.from(new Set(allCallers.map((c) => c.filePath)));
|
|
12452
|
-
|
|
12480
|
+
// Asked of raw reference rows, not of resolved callers: a test that calls
|
|
12481
|
+
// this symbol from inside an anonymous `it(...)` callback has no caller
|
|
12482
|
+
// symbol and is invisible to `allCallers` (KNODIN-36). `untested: true`
|
|
12483
|
+
// is a positive assertion with no zero-count to invite doubt, so it has
|
|
12484
|
+
// to be answered from the evidence that actually exists.
|
|
12485
|
+
// Three values, not two. Without the defining repository's database this
|
|
12486
|
+
// question cannot be answered, and the previous fallback -- guessing from
|
|
12487
|
+
// resolved callers -- is precisely the collapse that made this field
|
|
12488
|
+
// wrong in the first place: it turned "cannot tell" into a confident
|
|
12489
|
+
// `true`. `untested` is now simply absent when the evidence is absent,
|
|
12490
|
+
// so a caller reading it gets a fact or nothing, never a guess.
|
|
12491
|
+
const untested = targetDb
|
|
12492
|
+
? testCallerFilesFor(targetDb, primaryDef.name, primaryDef.filePath).length === 0
|
|
12493
|
+
: undefined;
|
|
12453
12494
|
const minimal = detailLevel === "minimal";
|
|
12454
12495
|
const cap = minimal ? 25 : 200;
|
|
12455
12496
|
const truncated = allCallers.length > cap ||
|
|
@@ -15459,6 +15500,60 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15459
15500
|
seen.add(key);
|
|
15460
15501
|
rows.push({ symbol: r.symbol, file, line: r.lineNumber });
|
|
15461
15502
|
}
|
|
15503
|
+
// Resolved callers miss almost every real test. A call inside an
|
|
15504
|
+
// anonymous `it(...)` callback has no enclosing named symbol, so it
|
|
15505
|
+
// carries a null `callerSymbol` and `findCallersFederated` drops it
|
|
15506
|
+
// (92.7% of this repository's test-file references, against 2.1% of
|
|
15507
|
+
// production ones). Reading the raw rows recovers them (KNODIN-36).
|
|
15508
|
+
//
|
|
15509
|
+
// These rows carry no caller name because there genuinely is none,
|
|
15510
|
+
// and inventing one -- the file path, or the enclosing `describe`
|
|
15511
|
+
// title -- would put a non-symbol in a `symbol` field that
|
|
15512
|
+
// `call-graph.spec.ts` explicitly forbids. `symbol` is therefore
|
|
15513
|
+
// omitted and the file and line carry the answer, which is what the
|
|
15514
|
+
// question asked for anyway: which test covers this.
|
|
15515
|
+
for (const repo of allRepos) {
|
|
15516
|
+
if (defFile && repo.path !== defRepo)
|
|
15517
|
+
continue;
|
|
15518
|
+
// MIN(line) grouped by file, not DISTINCT(file, line). The rows are
|
|
15519
|
+
// deduped by file below, so a plain DISTINCT would leave SQLite free
|
|
15520
|
+
// to hand back whichever line its query plan reached first when a
|
|
15521
|
+
// test file references the symbol more than once -- a value that can
|
|
15522
|
+
// change between runs and plans, which is a flaky test waiting to be
|
|
15523
|
+
// written. The first reference is both stable and the more useful
|
|
15524
|
+
// one to report.
|
|
15525
|
+
// With no defining file the file filter is dropped rather than bound
|
|
15526
|
+
// to "", which would degrade the clause to `calleeFile IS NULL` and
|
|
15527
|
+
// silently discard every reference that does carry a file -- turning
|
|
15528
|
+
// "I do not know where this is defined" into "it is defined nowhere",
|
|
15529
|
+
// and under-reporting coverage for exactly the symbols we know least
|
|
15530
|
+
// about (ADR 008). findCallersFederated omits the filter in the same
|
|
15531
|
+
// situation; these two must not disagree.
|
|
15532
|
+
const anonymous = defFile
|
|
15533
|
+
? (() => {
|
|
15534
|
+
const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL) AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
|
|
15535
|
+
const out = stmt.all(target, defFile);
|
|
15536
|
+
stmt.finalize();
|
|
15537
|
+
return out;
|
|
15538
|
+
})()
|
|
15539
|
+
: (() => {
|
|
15540
|
+
const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
|
|
15541
|
+
const out = stmt.all(target);
|
|
15542
|
+
stmt.finalize();
|
|
15543
|
+
return out;
|
|
15544
|
+
})();
|
|
15545
|
+
for (const row of anonymous) {
|
|
15546
|
+
const file = formatPath(repo.path, row.callerFile);
|
|
15547
|
+
if (!isTestFilePath(file))
|
|
15548
|
+
continue;
|
|
15549
|
+
const key = `|${file}`;
|
|
15550
|
+
if (seen.has(key))
|
|
15551
|
+
continue;
|
|
15552
|
+
seen.add(key);
|
|
15553
|
+
rows.push({ file, line: row.line });
|
|
15554
|
+
}
|
|
15555
|
+
}
|
|
15556
|
+
rows.sort((a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0));
|
|
15462
15557
|
return finish(rows);
|
|
15463
15558
|
}
|
|
15464
15559
|
case "rename_preview": {
|
|
@@ -7,6 +7,17 @@ import { resolveDbPath } from "./engine/state-paths.js";
|
|
|
7
7
|
import { summarizeAdoption } from "./session-telemetry.js";
|
|
8
8
|
export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
|
|
9
9
|
export const countOutputTokens = (value) => encode(value).length;
|
|
10
|
+
/** Retrieval stages the engine can report; anything else is dropped. */
|
|
11
|
+
const RETRIEVAL_ROUTE_STEPS = new Set([
|
|
12
|
+
"stable-identity",
|
|
13
|
+
"exact-name",
|
|
14
|
+
"exact-path",
|
|
15
|
+
"lexical",
|
|
16
|
+
"bounded-graph",
|
|
17
|
+
"embeddings",
|
|
18
|
+
]);
|
|
19
|
+
/** Embedding-coverage states the engine can report; anything else is dropped. */
|
|
20
|
+
const SEMANTIC_READINESS_STATES = new Set(["ready", "partial", "absent"]);
|
|
10
21
|
export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
|
|
11
22
|
const DEFAULT_INPUT = ".knodin-telemetry.jsonl";
|
|
12
23
|
function lineCount(value) {
|
|
@@ -105,6 +116,28 @@ export function measureOutput(options) {
|
|
|
105
116
|
const status = typeof options.output.status === "string" ? options.output.status : undefined;
|
|
106
117
|
const availability = options.output.availability;
|
|
107
118
|
const freshness = options.output.freshness;
|
|
119
|
+
// Retrieval-quality fields, lifted so a repository where knodin underperforms
|
|
120
|
+
// is diagnosable from a shared bundle instead of from a hand-written field
|
|
121
|
+
// report (KNODIN-37). All four already travel in the response and all four are
|
|
122
|
+
// source-free metadata, so this persists nothing the privacy contract forbids.
|
|
123
|
+
//
|
|
124
|
+
// `semanticReadiness` is the most diagnostic of them: a partially embedded
|
|
125
|
+
// index returns confident SHORT answers, which is indistinguishable from the
|
|
126
|
+
// query being wrong unless the readiness is recorded beside the result.
|
|
127
|
+
const retrieval = options.output.retrieval;
|
|
128
|
+
const retrievalRoute = Array.isArray(retrieval?.route)
|
|
129
|
+
? retrieval.route.filter((step) => typeof step === "string")
|
|
130
|
+
: undefined;
|
|
131
|
+
const embeddingsUsed = typeof retrieval?.embeddingsUsed === "boolean" ? retrieval.embeddingsUsed : undefined;
|
|
132
|
+
const semanticReadiness = typeof options.output.semanticReadiness === "string"
|
|
133
|
+
? options.output.semanticReadiness
|
|
134
|
+
: undefined;
|
|
135
|
+
const results = options.output.results;
|
|
136
|
+
const resultCount = Array.isArray(results)
|
|
137
|
+
? results.length
|
|
138
|
+
: typeof options.output.count === "number"
|
|
139
|
+
? options.output.count
|
|
140
|
+
: undefined;
|
|
108
141
|
const fidelity = options.output.fidelity;
|
|
109
142
|
const database = resolveDbPath(fs.realpathSync(options.repo));
|
|
110
143
|
const latencyMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
|
|
@@ -137,6 +170,15 @@ export function measureOutput(options) {
|
|
|
137
170
|
compressionFidelity,
|
|
138
171
|
confidence: baseline ? "measured" : "unavailable",
|
|
139
172
|
truncated: options.truncated,
|
|
173
|
+
// Non-empty or absent, never `[]`. An empty route asserts that no retrieval
|
|
174
|
+
// stage ran, which is a claim about the query; a route that is unknown or
|
|
175
|
+
// wholly unreadable is the absence of one. The sanitizer downstream makes
|
|
176
|
+
// the same distinction, and a record that collapsed it here would hand the
|
|
177
|
+
// sanitizer a falsehood to faithfully preserve (ADR 008).
|
|
178
|
+
...(retrievalRoute && retrievalRoute.length > 0 ? { retrievalRoute } : {}),
|
|
179
|
+
...(embeddingsUsed !== undefined ? { embeddingsUsed } : {}),
|
|
180
|
+
...(semanticReadiness ? { semanticReadiness } : {}),
|
|
181
|
+
...(resultCount !== undefined ? { resultCount } : {}),
|
|
140
182
|
detailMode: options.detailMode,
|
|
141
183
|
tokenizer: TELEMETRY_TOKENIZER,
|
|
142
184
|
schemaTokens: options.schemaTokens,
|
|
@@ -232,7 +274,12 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
|
|
|
232
274
|
sessionEvents: sessionEvents.length,
|
|
233
275
|
};
|
|
234
276
|
}
|
|
235
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Exported for the retrieval-quality spec: the allowlist is the guarantee that
|
|
279
|
+
* an unexpected field never reaches a shareable file, and that guarantee is
|
|
280
|
+
* only worth anything if it is tested directly rather than through a caller.
|
|
281
|
+
*/
|
|
282
|
+
export function sanitizeTelemetryRecord(record) {
|
|
236
283
|
return {
|
|
237
284
|
schemaVersion: record.schemaVersion,
|
|
238
285
|
at: record.at,
|
|
@@ -252,6 +299,38 @@ function sanitizeTelemetryRecord(record) {
|
|
|
252
299
|
compressionFidelity: record.compressionFidelity,
|
|
253
300
|
confidence: record.confidence,
|
|
254
301
|
truncated: record.truncated,
|
|
302
|
+
// Retrieval-quality fields (KNODIN-37). Validated rather than copied: the
|
|
303
|
+
// allowlist is the reason an unexpected field can never reach the file, and
|
|
304
|
+
// a new entry that trusts its input would quietly defeat that. Route steps
|
|
305
|
+
// are constrained to a known vocabulary and the array is bounded, so a
|
|
306
|
+
// malformed response cannot smuggle arbitrary strings into a shareable
|
|
307
|
+
// record.
|
|
308
|
+
// Omitted when nothing survives the allowlist, not emitted as `[]`. An
|
|
309
|
+
// empty array reads as "no retrieval stage ran", which is a fact; a route
|
|
310
|
+
// whose every step was unrecognised is the absence of a readable fact, and
|
|
311
|
+
// the two must not share a representation (ADR 008). Caught in review of
|
|
312
|
+
// this very change, which is the telemetry meant to diagnose exactly this
|
|
313
|
+
// class of confusion.
|
|
314
|
+
...(() => {
|
|
315
|
+
if (!Array.isArray(record.retrievalRoute))
|
|
316
|
+
return {};
|
|
317
|
+
const route = record.retrievalRoute
|
|
318
|
+
.filter((step) => RETRIEVAL_ROUTE_STEPS.has(step))
|
|
319
|
+
.slice(0, 8);
|
|
320
|
+
return route.length > 0 ? { retrievalRoute: route } : {};
|
|
321
|
+
})(),
|
|
322
|
+
...(typeof record.embeddingsUsed === "boolean"
|
|
323
|
+
? { embeddingsUsed: record.embeddingsUsed }
|
|
324
|
+
: {}),
|
|
325
|
+
...(typeof record.semanticReadiness === "string" &&
|
|
326
|
+
SEMANTIC_READINESS_STATES.has(record.semanticReadiness)
|
|
327
|
+
? { semanticReadiness: record.semanticReadiness }
|
|
328
|
+
: {}),
|
|
329
|
+
...(typeof record.resultCount === "number" &&
|
|
330
|
+
Number.isInteger(record.resultCount) &&
|
|
331
|
+
record.resultCount >= 0
|
|
332
|
+
? { resultCount: record.resultCount }
|
|
333
|
+
: {}),
|
|
255
334
|
detailMode: record.detailMode,
|
|
256
335
|
tokenizer: record.tokenizer,
|
|
257
336
|
schemaTokens: record.schemaTokens,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# knodin 0.12.1
|
|
2
|
+
|
|
3
|
+
A P1 correctness fix, a field made honest, and telemetry that lets a badly
|
|
4
|
+
performing repository explain itself. No schema change and no re-index.
|
|
5
|
+
|
|
6
|
+
## `explain` said "untested" about symbols that have tests
|
|
7
|
+
|
|
8
|
+
`explain` reported `untested: true`, and `tests_for` returned `count: 0`, for
|
|
9
|
+
symbols with dedicated unit tests — on a healthy, fresh graph.
|
|
10
|
+
|
|
11
|
+
Test code lives almost entirely inside anonymous callbacks passed to `it(...)`
|
|
12
|
+
and `describe(...)`. Such a call has no enclosing *named* symbol, so it is
|
|
13
|
+
recorded with a null caller and every caller-resolution path drops it. Measured
|
|
14
|
+
on this repository: **29,158 of 31,467 references from test files (92.7%) had no
|
|
15
|
+
caller symbol, against 390 of 18,399 (2.1%) from production files.** A coverage
|
|
16
|
+
question answered from callers was therefore about 93% blind in exactly the files
|
|
17
|
+
it was asking about, and 642 symbols with real tests reported as untested.
|
|
18
|
+
|
|
19
|
+
This is the worst shape a wrong answer can take. An empty result invites a second
|
|
20
|
+
look; a confident positive does not. The field was written into a security
|
|
21
|
+
proposal as a precondition blocking a change and reached a pull request before a
|
|
22
|
+
reviewer caught it.
|
|
23
|
+
|
|
24
|
+
Both questions now read the raw reference rows, which carry the calling file.
|
|
25
|
+
|
|
26
|
+
Two alternatives were rejected on evidence rather than taste. Attributing the
|
|
27
|
+
call to the *file* would put a non-symbol into a `symbol` field, which a
|
|
28
|
+
checked-in contract forbids outright. Indexing `describe`/`it` titles as symbols
|
|
29
|
+
would add roughly ten thousand rows and perturb statistics, dead-code detection,
|
|
30
|
+
communities, embeddings, and the centrality term added in 0.12.0. Reading the
|
|
31
|
+
rows changes no caller, so search ranking is bit-identical — verified rather than
|
|
32
|
+
assumed.
|
|
33
|
+
|
|
34
|
+
Rows for anonymous callers carry a file and a line and no symbol, because there
|
|
35
|
+
genuinely is none.
|
|
36
|
+
|
|
37
|
+
## `untested` is now three-valued
|
|
38
|
+
|
|
39
|
+
The first version of that fix still collapsed "cannot tell" into a guess: without
|
|
40
|
+
the defining repository's database it inferred from resolved callers, which is
|
|
41
|
+
the same blindness that made the field wrong. `untested` is now simply **absent**
|
|
42
|
+
when the evidence is absent. A caller gets a fact or nothing, never a guess.
|
|
43
|
+
|
|
44
|
+
The control matters as much as the fix: a genuinely uncovered symbol still
|
|
45
|
+
reports `untested: true`. A field that is never true would be as broken as one
|
|
46
|
+
that is always true, and only that second test tells them apart.
|
|
47
|
+
|
|
48
|
+
## Telemetry can now explain a repository where knodin underperforms
|
|
49
|
+
|
|
50
|
+
knodin has been reported as performing poorly in other people's repositories,
|
|
51
|
+
and there was no way to find out why without someone writing a field report by
|
|
52
|
+
hand. The signals that explain it already travelled in every response and were
|
|
53
|
+
being discarded.
|
|
54
|
+
|
|
55
|
+
Four are now persisted with the existing opt-in local telemetry:
|
|
56
|
+
`retrievalRoute`, `embeddingsUsed`, `semanticReadiness`, and `resultCount`.
|
|
57
|
+
|
|
58
|
+
`semanticReadiness` is the most diagnostic of them. A partially embedded index
|
|
59
|
+
returns confident *short* answers, which is indistinguishable from a poorly
|
|
60
|
+
phrased query unless the readiness is recorded beside the result.
|
|
61
|
+
|
|
62
|
+
All four are source-free metadata, so the privacy contract is unchanged: no
|
|
63
|
+
source, no query text, no paths, no identities, still opt-in, still local-only,
|
|
64
|
+
still no transport. Query *shape* — the remaining diagnostic gap — is
|
|
65
|
+
deliberately excluded, because persisting query text would falsify the
|
|
66
|
+
no-source-egress property the product is built on.
|
|
67
|
+
|
|
68
|
+
The sanitizer's allowlist gained these fields with validation rather than
|
|
69
|
+
pass-through. That allowlist is the only reason an unexpected field cannot reach
|
|
70
|
+
a shareable file, and an entry that trusted its input would quietly defeat it.
|
|
71
|
+
|
|
72
|
+
## A design principle, written down
|
|
73
|
+
|
|
74
|
+
ADR 008 records what eleven defects across 0.12.0 and this release turned out to
|
|
75
|
+
have in common:
|
|
76
|
+
|
|
77
|
+
> A check that collapses "no" and "don't know" into a single answer will
|
|
78
|
+
> eventually return the wrong one, and it will do so confidently.
|
|
79
|
+
|
|
80
|
+
`untested: true`, `count: 0` for a misspelling, a review answering a different
|
|
81
|
+
range, staleness defaulting to `fresh` when it was missing. Two instances in the
|
|
82
|
+
ADR are deliberately not knodin bugs — another product's CI gate reporting a
|
|
83
|
+
network timeout as a security advisory, and an agent truncating its own terminal
|
|
84
|
+
output and concluding a remote did not exist. The shape belongs to any predicate
|
|
85
|
+
whose failure path and negative path are the same path.
|
|
86
|
+
|
|
87
|
+
## Measured and not shipped
|
|
88
|
+
|
|
89
|
+
Three attempts at the lexical retrieval channel were implemented, measured, and
|
|
90
|
+
reverted: asymmetric token floors, Porter stemming, and grading a partial match
|
|
91
|
+
by how many query words it echoed. On a 44-query real-model benchmark they
|
|
92
|
+
scored 0.7112, 0.7389, and "no measurable difference" against a 0.7635 baseline,
|
|
93
|
+
and **none bought a single point of recall** — including on four queries added
|
|
94
|
+
specifically to favour morphological matching.
|
|
95
|
+
|
|
96
|
+
The reproductions behind them were sound. The inference was not: a demonstrated
|
|
97
|
+
defect does not imply that removing it is an improvement. The vector channel was
|
|
98
|
+
already carrying what the lexical channel could not see.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"knodin": {
|
|
5
|
-
"compatibility": "
|
|
5
|
+
"compatibility": "compatible"
|
|
6
6
|
},
|
|
7
7
|
"description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
|
|
8
8
|
"license": "MIT",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"docs/releases/0.10.8.md",
|
|
66
66
|
"docs/releases/0.11.0.md",
|
|
67
67
|
"docs/releases/0.12.0.md",
|
|
68
|
+
"docs/releases/0.12.1.md",
|
|
68
69
|
"docs/releases/0.3.0.md",
|
|
69
70
|
"docs/releases/0.4.0.md",
|
|
70
71
|
"docs/releases/0.4.1.md",
|