knodin 0.10.6 → 0.10.8
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/bin/cli.js +11 -4
- package/dist/src/credential-patterns.js +90 -0
- package/dist/src/diagnostics.js +12 -13
- package/dist/src/engine/git-layout.js +53 -0
- package/dist/src/engine/index.js +63 -16
- package/dist/src/output-compression.js +14 -13
- package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
- package/docs/releases/0.10.7.md +79 -0
- package/docs/releases/0.10.8.md +165 -0
- package/package.json +3 -1
package/dist/bin/cli.js
CHANGED
|
@@ -456,10 +456,17 @@ function formatStatusHuman(result) {
|
|
|
456
456
|
? result.missing.records[0]
|
|
457
457
|
: (result.missing.files[0] ?? result.missing.records[0]);
|
|
458
458
|
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
459
|
+
// A linked worktree with no database is the one case where `repair` is both
|
|
460
|
+
// the wrong first step and the expensive one: `init` seeds from an indexed
|
|
461
|
+
// sibling and reconciles only what differs, while `repair` builds from
|
|
462
|
+
// scratch. The engine has already worked out that this is that case, so
|
|
463
|
+
// defer to the step it wrote rather than recomputing the judgement here.
|
|
464
|
+
const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
|
|
465
|
+
const repairCommand = worktreeStep ??
|
|
466
|
+
(result.lifecycle?.status === "degraded" &&
|
|
467
|
+
result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
|
|
468
|
+
? "Run `knodin init`."
|
|
469
|
+
: "Run `knodin repair`.");
|
|
463
470
|
return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
|
|
464
471
|
}
|
|
465
472
|
function humanLabel(key) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One credential-pattern set, shared by every redactor.
|
|
3
|
+
*
|
|
4
|
+
* knodin previously carried two independent lists — one in `diagnostics.ts` for
|
|
5
|
+
* recorded failures, one in `output-compression.ts` for captured command output
|
|
6
|
+
* — and they drifted apart in OPPOSITE directions. Diagnostics knew Slack and
|
|
7
|
+
* OpenAI but not AWS or JWTs; output compression knew AWS and JWTs but missed
|
|
8
|
+
* Slack, OpenAI and GitHub's fine-grained tokens. Neither list was a superset of
|
|
9
|
+
* the other, so a credential redacted on one path was disclosed on the other,
|
|
10
|
+
* and patching either one alone would only let them drift again (KNODIN-11).
|
|
11
|
+
*
|
|
12
|
+
* Callers keep their own replacement text: diagnostics collapses a match to a
|
|
13
|
+
* single marker, output compression names the provider. `preservedGroups` is
|
|
14
|
+
* what makes that possible — the leading captures a caller may echo back
|
|
15
|
+
* verbatim, so `password=` survives while its value does not.
|
|
16
|
+
*
|
|
17
|
+
* Fresh `RegExp` objects are built per call. A shared global regex carries
|
|
18
|
+
* `lastIndex` between uses, which turns a redactor into something that depends
|
|
19
|
+
* on what it scrubbed previously.
|
|
20
|
+
*/
|
|
21
|
+
export function credentialPatterns() {
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
// `name=value` in any of its spellings. An optional auth scheme is
|
|
25
|
+
// consumed BEFORE the value, because `authorization: Bearer <token>`
|
|
26
|
+
// otherwise matches only the word "Bearer" — redacting the label and
|
|
27
|
+
// preserving the credential, exactly inverted (KNODIN-10).
|
|
28
|
+
label: "credential",
|
|
29
|
+
pattern: /\b(authorization|password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
|
|
30
|
+
preservedGroups: 2,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// GitHub issues every format with an UNDERSCORE. A hyphen-only pattern
|
|
34
|
+
// matched a shape GitHub never mints and missed every shape it does, so
|
|
35
|
+
// both separators are accepted. `github_pat` is listed explicitly: it
|
|
36
|
+
// does not fit the gh[pousr] shape.
|
|
37
|
+
label: "github-token",
|
|
38
|
+
pattern: /\b(?:gh[pousr]|github_pat)[-_]\w{20,}\b/g,
|
|
39
|
+
preservedGroups: 0,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
label: "slack-token",
|
|
43
|
+
pattern: /\bxox[baprse]-[A-Za-z0-9-]{10,}\b/g,
|
|
44
|
+
preservedGroups: 0,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: "openai-key",
|
|
48
|
+
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
49
|
+
preservedGroups: 0,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: "aws-access-key",
|
|
53
|
+
pattern: /\bAKIA[A-Z0-9]{16}\b/g,
|
|
54
|
+
preservedGroups: 0,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
label: "jwt",
|
|
58
|
+
pattern: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g,
|
|
59
|
+
preservedGroups: 0,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
// Bearer outside an `authorization:` assignment — a bare header line, or
|
|
63
|
+
// a curl command echoed into a log.
|
|
64
|
+
label: "bearer-token",
|
|
65
|
+
pattern: /\b(Bearer\s+)[-\w.~+/]{12,}=*/gi,
|
|
66
|
+
preservedGroups: 1,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Apply every shared pattern, letting the caller render the replacement.
|
|
72
|
+
*
|
|
73
|
+
* `marker` receives the provider label; preserved groups are prepended to
|
|
74
|
+
* whatever it returns.
|
|
75
|
+
*/
|
|
76
|
+
export function redactCredentials(text, marker) {
|
|
77
|
+
let result = text;
|
|
78
|
+
let count = 0;
|
|
79
|
+
for (const { label, pattern, preservedGroups } of credentialPatterns()) {
|
|
80
|
+
result = result.replace(pattern, (...values) => {
|
|
81
|
+
count++;
|
|
82
|
+
const kept = values
|
|
83
|
+
.slice(1, 1 + preservedGroups)
|
|
84
|
+
.map((value) => (typeof value === "string" ? value : ""))
|
|
85
|
+
.join("");
|
|
86
|
+
return `${kept}${marker(label)}`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { text: result, count };
|
|
90
|
+
}
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import zlib from "node:zlib";
|
|
8
8
|
import { compareBytes } from "./compare.js";
|
|
9
|
+
import { redactCredentials } from "./credential-patterns.js";
|
|
9
10
|
const CONFIG_PATH = ".knodin/diagnostics/config.json";
|
|
10
11
|
const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
|
|
11
12
|
const JOURNAL_LOCK_PATH = ".knodin/diagnostics/events.lock";
|
|
@@ -528,19 +529,17 @@ function scrubText(raw, repo) {
|
|
|
528
529
|
};
|
|
529
530
|
for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
530
531
|
replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
// `password=x next word` still redacts one value rather than eating prose.
|
|
543
|
-
replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi, "<secret>");
|
|
532
|
+
// Provider credentials, from the set shared with output-compression (see
|
|
533
|
+
// credential-patterns.ts). These used to be maintained here separately, and
|
|
534
|
+
// the two lists drifted: this one knew Slack and OpenAI but not AWS keys or
|
|
535
|
+
// JWTs, while output compression knew those and missed these. A credential
|
|
536
|
+
// safe on one path was disclosed on the other (KNODIN-11).
|
|
537
|
+
//
|
|
538
|
+
// `redactCredentials` counts its own replacements, so they are added to the
|
|
539
|
+
// running total rather than counted by the local `replace` helper.
|
|
540
|
+
const credentials = redactCredentials(value, () => "<secret>");
|
|
541
|
+
value = credentials.text;
|
|
542
|
+
redactions += credentials.count;
|
|
544
543
|
replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
|
|
545
544
|
replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
|
|
546
545
|
replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Git checkout-layout questions, kept out of the engine on purpose.
|
|
5
|
+
*
|
|
6
|
+
* `engine/index.ts` carries a pinned budget on direct `fs` reads
|
|
7
|
+
* (`sealed-reader-inventory.spec.ts`) because a sealed artifact answers with no
|
|
8
|
+
* working tree: a reader that touches the filesystem there gets ENOENT, which
|
|
9
|
+
* the nearest guard turns into `""` or `null`, and that reads downstream as
|
|
10
|
+
* "this symbol has no body" rather than "this was not covered".
|
|
11
|
+
*
|
|
12
|
+
* Git *metadata* is categorically outside that concern — a sealed artifact has
|
|
13
|
+
* no `.git` at all, and these functions are never on a source-content path — so
|
|
14
|
+
* routing them through the sealed resolver would add a branch that can never
|
|
15
|
+
* execute. Keeping them here says that in the layout rather than by spending
|
|
16
|
+
* budget the engine reserves for source reads, and it makes them directly
|
|
17
|
+
* unit-testable, which they are not as engine-private helpers.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* True only for a `git worktree add` checkout.
|
|
21
|
+
*
|
|
22
|
+
* A linked worktree's `.git` is a FILE pointing at an administrative directory
|
|
23
|
+
* under the main checkout, and that directory contains `commondir` naming the
|
|
24
|
+
* shared repository.
|
|
25
|
+
*
|
|
26
|
+
* The `.git` file alone is NOT sufficient and treating it as sufficient is a
|
|
27
|
+
* live bug: submodules and `--separate-git-dir` clones use one too, and neither
|
|
28
|
+
* has a main checkout whose graph could cover it, so per-worktree guidance would
|
|
29
|
+
* be actively wrong for them. `commondir` is the marker that actually
|
|
30
|
+
* distinguishes the case.
|
|
31
|
+
*/
|
|
32
|
+
export function isLinkedWorktree(repo) {
|
|
33
|
+
const marker = path.join(repo, ".git");
|
|
34
|
+
try {
|
|
35
|
+
// An ordinary checkout keeps a `.git` directory; `statSync` throws when
|
|
36
|
+
// there is no `.git` at all, which is an ordinary case — knodin indexes
|
|
37
|
+
// plain directories too.
|
|
38
|
+
if (!fs.statSync(marker).isFile())
|
|
39
|
+
return false;
|
|
40
|
+
const pointer = fs.readFileSync(marker, "utf8").trim();
|
|
41
|
+
const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
|
|
42
|
+
if (!gitDir)
|
|
43
|
+
return false;
|
|
44
|
+
return fs.existsSync(path.join(path.resolve(repo, gitDir), "commondir"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Every read is inside the guard on purpose. This is best-effort detection
|
|
48
|
+
// reached from the missing-database remediation path, and that path has to
|
|
49
|
+
// answer: an unreadable `.git`, or one that disappears between the stat and
|
|
50
|
+
// the read, must degrade to "not a worktree" rather than crash `status`.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/src/engine/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidat
|
|
|
32
32
|
import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
|
|
33
33
|
import { walkRepoFiles } from "./file-walker.js";
|
|
34
34
|
import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
|
|
35
|
+
import { isLinkedWorktree } from "./git-layout.js";
|
|
35
36
|
// Runtime import, but not a cycle: parse-pool imports only TYPES from here,
|
|
36
37
|
// which erase at compile time. parse-worker's runtime import of this module
|
|
37
38
|
// resolves inside the worker thread, never in this one.
|
|
@@ -8779,26 +8780,28 @@ export async function getOrInitDb(repoPath, options = {}) {
|
|
|
8779
8780
|
else {
|
|
8780
8781
|
const stateDir = resolveStateDir(normalizedPath);
|
|
8781
8782
|
await fs.promises.mkdir(stateDir, { recursive: true });
|
|
8782
|
-
// Ensure .knodin is
|
|
8783
|
-
//
|
|
8784
|
-
//
|
|
8783
|
+
// Ensure .knodin is ignored, through `.git/info/exclude` rather than
|
|
8784
|
+
// `.gitignore`.
|
|
8785
|
+
//
|
|
8786
|
+
// `.gitignore` is a SHARED file: it is tracked by convention, so
|
|
8787
|
+
// writing it put a change into someone's working tree that they did
|
|
8788
|
+
// not ask for and would carry into their next commit. That is the
|
|
8789
|
+
// one thing `knodin init --scope personal` promises not to do, and
|
|
8790
|
+
// this ran regardless of scope — a bare `knodin index` did it too
|
|
8791
|
+
// (KNODIN-1).
|
|
8792
|
+
//
|
|
8793
|
+
// `.git/info/exclude` has the same effect and is repo-local: git
|
|
8794
|
+
// never commits it, so nothing reaches a diff or a teammate.
|
|
8795
|
+
//
|
|
8796
|
+
// Skipped for mirrors: their state lives outside the clone, and the
|
|
8797
|
+
// clone must stay byte-identical to the remote so a refetch has
|
|
8798
|
+
// nothing of ours to discard.
|
|
8785
8799
|
if (mayWriteToRepository(normalizedPath)) {
|
|
8786
8800
|
try {
|
|
8787
|
-
|
|
8788
|
-
let gitignoreContent = "";
|
|
8789
|
-
if (fs.existsSync(gitignorePath)) {
|
|
8790
|
-
gitignoreContent = await fs.promises.readFile(gitignorePath, "utf-8");
|
|
8791
|
-
}
|
|
8792
|
-
const lines = gitignoreContent.split("\n").map((l) => l.trim());
|
|
8793
|
-
if (!lines.includes(".knodin") &&
|
|
8794
|
-
!lines.includes(".knodin/") &&
|
|
8795
|
-
!lines.includes("/.knodin")) {
|
|
8796
|
-
const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
|
|
8797
|
-
await fs.promises.appendFile(gitignorePath, `${prefix}\n# knodin\n.knodin\n`);
|
|
8798
|
-
}
|
|
8801
|
+
await excludeStateDirectoryLocally(normalizedPath);
|
|
8799
8802
|
}
|
|
8800
8803
|
catch (e) {
|
|
8801
|
-
console.error("Failed to
|
|
8804
|
+
console.error("Failed to exclude .knodin from git", e);
|
|
8802
8805
|
}
|
|
8803
8806
|
}
|
|
8804
8807
|
dbPath = path.join(stateDir, "db.sqlite");
|
|
@@ -10092,6 +10095,45 @@ function parseUnifiedDiff(stdout) {
|
|
|
10092
10095
|
}
|
|
10093
10096
|
return modifiedFiles;
|
|
10094
10097
|
}
|
|
10098
|
+
/**
|
|
10099
|
+
* Ignore `.knodin/` through `.git/info/exclude`, which git never commits.
|
|
10100
|
+
*
|
|
10101
|
+
* Resolved with `git rev-parse --git-path` rather than assembled by hand:
|
|
10102
|
+
* inside a linked worktree `.git` is a FILE pointing elsewhere, so
|
|
10103
|
+
* `<repo>/.git/info/exclude` is not a path that exists. Idempotent — a second
|
|
10104
|
+
* call over an already-excluded repository writes nothing.
|
|
10105
|
+
*/
|
|
10106
|
+
async function excludeStateDirectoryLocally(repoPath) {
|
|
10107
|
+
// A directory that is not a git repository has nothing to exclude, and git
|
|
10108
|
+
// exits non-zero there. That is an ordinary state — knodin indexes plain
|
|
10109
|
+
// directories — so it must not surface as an error. The previous
|
|
10110
|
+
// `.gitignore` write never had to ask git anything, which is why this case
|
|
10111
|
+
// only appears now.
|
|
10112
|
+
let raw;
|
|
10113
|
+
try {
|
|
10114
|
+
raw = runGit(repoPath, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
10115
|
+
}
|
|
10116
|
+
catch {
|
|
10117
|
+
return;
|
|
10118
|
+
}
|
|
10119
|
+
if (!raw)
|
|
10120
|
+
return;
|
|
10121
|
+
const excludePath = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw);
|
|
10122
|
+
let existing = "";
|
|
10123
|
+
try {
|
|
10124
|
+
existing = await fs.promises.readFile(excludePath, "utf-8");
|
|
10125
|
+
}
|
|
10126
|
+
catch (error) {
|
|
10127
|
+
if (error.code !== "ENOENT")
|
|
10128
|
+
throw error;
|
|
10129
|
+
}
|
|
10130
|
+
const entries = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
|
|
10131
|
+
if (entries.has(".knodin") || entries.has(".knodin/") || entries.has("/.knodin"))
|
|
10132
|
+
return;
|
|
10133
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
10134
|
+
await fs.promises.mkdir(path.dirname(excludePath), { recursive: true });
|
|
10135
|
+
await fs.promises.appendFile(excludePath, `${prefix}.knodin/\n`, "utf-8");
|
|
10136
|
+
}
|
|
10095
10137
|
function runGit(repoPath, args) {
|
|
10096
10138
|
return child_process.execFileSync("git", args, {
|
|
10097
10139
|
cwd: repoPath,
|
|
@@ -13042,6 +13084,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13042
13084
|
verification: { mode: "deep-audit", verifiedAt },
|
|
13043
13085
|
freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
|
|
13044
13086
|
repairSteps: [
|
|
13087
|
+
...(isLinkedWorktree(resolved)
|
|
13088
|
+
? [
|
|
13089
|
+
"Run `knodin init` here: graphs are per-worktree and this one has none of its own, so the main checkout's graph does not cover it. `init` seeds from an indexed sibling worktree where one exists rather than rebuilding from scratch.",
|
|
13090
|
+
]
|
|
13091
|
+
: []),
|
|
13045
13092
|
"Run `knodin repair` to create the local index.",
|
|
13046
13093
|
"Run `knodin status` again to verify health.",
|
|
13047
13094
|
],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { redactCredentials } from "./credential-patterns.js";
|
|
4
5
|
import { resolveStateDir } from "./engine/state-paths.js";
|
|
5
6
|
const DEFAULT_LINE_BUDGET = 200;
|
|
6
7
|
const DEFAULT_BYTE_BUDGET = 16_384;
|
|
@@ -90,20 +91,20 @@ function splitEvent(event) {
|
|
|
90
91
|
lines.push({ raw: "", sourceBytes: 0 });
|
|
91
92
|
return lines;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Redact credentials from captured output.
|
|
96
|
+
*
|
|
97
|
+
* The pattern set is shared with `diagnostics.ts` (see credential-patterns.ts).
|
|
98
|
+
* It used to be maintained here separately, and the two lists drifted: this one
|
|
99
|
+
* knew AWS keys and JWTs but missed Slack, OpenAI and GitHub's fine-grained
|
|
100
|
+
* tokens, while diagnostics missed AWS and JWTs. A credential safe on one path
|
|
101
|
+
* was disclosed on the other (KNODIN-11).
|
|
102
|
+
*
|
|
103
|
+
* The provider-named markers are kept, because a reader of compressed output
|
|
104
|
+
* benefits from knowing WHAT was removed.
|
|
105
|
+
*/
|
|
93
106
|
function redact(text) {
|
|
94
|
-
|
|
95
|
-
const replace = (pattern, replacement) => {
|
|
96
|
-
text = text.replace(pattern, (...values) => {
|
|
97
|
-
count++;
|
|
98
|
-
return typeof replacement === "string" ? replacement : replacement(...values);
|
|
99
|
-
});
|
|
100
|
-
};
|
|
101
|
-
replace(/\b(authorization|password|passwd|secret|token|api[_-]?key)\s*([:=])\s*([^\s,;]+)/gi, (_match, name, separator) => `${name}${separator}[REDACTED:credential]`);
|
|
102
|
-
replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED:aws-access-key]");
|
|
103
|
-
replace(/\bgh[pousr]_\w{20,}\b/g, "[REDACTED:github-token]");
|
|
104
|
-
replace(/\bBearer\s+[-\w.~+/]{12,}=*\b/gi, "Bearer [REDACTED:bearer-token]");
|
|
105
|
-
replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g, "[REDACTED:jwt]");
|
|
106
|
-
return { text, count };
|
|
107
|
+
return redactCredentials(text, (label) => `[REDACTED:${label}]`);
|
|
107
108
|
}
|
|
108
109
|
function csiSequenceEnd(text, escapeIndex) {
|
|
109
110
|
for (let index = escapeIndex + 2; index < text.length; index++) {
|
|
@@ -24,6 +24,36 @@ Results distinguish:
|
|
|
24
24
|
- stale linked-worktree metadata;
|
|
25
25
|
- an unrelated repository under the same discovery root.
|
|
26
26
|
|
|
27
|
+
### Graphs are per-worktree
|
|
28
|
+
|
|
29
|
+
A linked worktree carries its own `.knodin` with its own graph, `indexedHead`,
|
|
30
|
+
and freshness. A healthy main checkout does not cover it, so a worktree created
|
|
31
|
+
by `git worktree add` has **no graph until `knodin init` runs inside it**.
|
|
32
|
+
|
|
33
|
+
`git worktree add` creates no `.knodin` of its own — the directory is knodin's,
|
|
34
|
+
written by `init` and by the managed lifecycle hooks. Where those hooks are
|
|
35
|
+
already installed in the repository, a new worktree can therefore appear to have
|
|
36
|
+
a `.knodin` while holding no graph at all, which is the more confusing shape of
|
|
37
|
+
this: the directory exists, and it is empty of anything that can answer.
|
|
38
|
+
|
|
39
|
+
That does not mean a new worktree pays for a full index. `init` selects the
|
|
40
|
+
already-indexed sibling worktree closest in commit distance, reflink-copies its
|
|
41
|
+
baseline where the filesystem supports it, reconciles only the paths that differ
|
|
42
|
+
between that sibling's `indexedHead` and this HEAD, deep-audits the result, and
|
|
43
|
+
promotes it only if it is healthy — falling back to a full index when no
|
|
44
|
+
schema- and build-compatible sibling exists. `KNODIN_DISABLE_WORKTREE_SEED=1`
|
|
45
|
+
forces the full path.
|
|
46
|
+
|
|
47
|
+
Seeding is on the `init` path only, which is why an uninitialized worktree is
|
|
48
|
+
steered to `init` rather than `repair`: both produce a correct index, but
|
|
49
|
+
`repair` rebuilds from scratch.
|
|
50
|
+
|
|
51
|
+
Nothing silently answers from the wrong graph in that state: structural queries
|
|
52
|
+
refuse with `available: false`, `state: not-initialized`, and exit code 1 rather
|
|
53
|
+
than returning an empty result, and status leads its remediation with the
|
|
54
|
+
per-worktree model and `knodin init`. Read a refusal as "this checkout was never
|
|
55
|
+
indexed", never as "there is nothing here to find".
|
|
56
|
+
|
|
27
57
|
### Opt-in repository signals
|
|
28
58
|
|
|
29
59
|
`repos discover --json --signals` adds a deterministic `signals` object to
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# knodin 0.10.7
|
|
2
|
+
|
|
3
|
+
Two fixes, both about knodin doing something to your repository or your
|
|
4
|
+
credentials that you did not ask for.
|
|
5
|
+
|
|
6
|
+
## knodin no longer writes to your .gitignore
|
|
7
|
+
|
|
8
|
+
Indexing appended `.knodin` to `.gitignore`. That file is **shared**: tracked by
|
|
9
|
+
convention, so the edit landed in your working tree and would ride into your next
|
|
10
|
+
commit — and on a narrowly-scoped branch, `git add -A` swept it in.
|
|
11
|
+
|
|
12
|
+
It ran regardless of scope, so a bare `knodin index` did it too. That matters
|
|
13
|
+
because `knodin init --scope personal` exists precisely to promise that nothing
|
|
14
|
+
reaches git.
|
|
15
|
+
|
|
16
|
+
The same protection now goes to `.git/info/exclude`, which git never commits.
|
|
17
|
+
The rule is resolved through `git rev-parse --git-path` rather than assembled by
|
|
18
|
+
hand, because inside a linked worktree `.git` is a file and
|
|
19
|
+
`<repo>/.git/info/exclude` is not a path that exists. A directory that is not a
|
|
20
|
+
git repository has nothing to exclude and is left alone silently — knodin indexes
|
|
21
|
+
plain directories too.
|
|
22
|
+
|
|
23
|
+
### What this changed about freshness reporting
|
|
24
|
+
|
|
25
|
+
Worth knowing if you have ever read the `staleness` field.
|
|
26
|
+
|
|
27
|
+
Because every repository knodin touched carried that untracked `.gitignore`, its
|
|
28
|
+
working tree was permanently dirty, and a dirty tree forces the reconcile path.
|
|
29
|
+
So a branch switch reported `reconciled`. With a genuinely clean tree the same
|
|
30
|
+
switch reports `fresh`.
|
|
31
|
+
|
|
32
|
+
Nothing about the answer changed — measured directly, the query returns the
|
|
33
|
+
symbols of the branch you are on, with no trace of the one you left. `fresh` is
|
|
34
|
+
accurate: the graph does match the tree. Only the label is different, and it is
|
|
35
|
+
different because the tree is finally clean.
|
|
36
|
+
|
|
37
|
+
Three internal assertions had been reading that side effect rather than the
|
|
38
|
+
behaviour they named, and now measure the returned symbols instead.
|
|
39
|
+
|
|
40
|
+
## One credential-pattern set, not two
|
|
41
|
+
|
|
42
|
+
knodin had **two independent secret redactors** — one for diagnostics bundles,
|
|
43
|
+
one for compressed command output — each with its own list. They had drifted in
|
|
44
|
+
opposite directions:
|
|
45
|
+
|
|
46
|
+
| | diagnostics | output compression |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| GitHub classic | yes | yes |
|
|
49
|
+
| GitHub fine-grained | yes | **no** |
|
|
50
|
+
| Slack | yes | **no** |
|
|
51
|
+
| OpenAI | yes | **no** |
|
|
52
|
+
| AWS access key | **no** | yes |
|
|
53
|
+
| JWT | **no** | yes |
|
|
54
|
+
|
|
55
|
+
Neither list was a superset of the other, so the same log line was scrubbed on
|
|
56
|
+
one path and disclosed on the other. Redaction is on by default, so nothing
|
|
57
|
+
unusual had to be configured to hit this.
|
|
58
|
+
|
|
59
|
+
Both now use one shared set covering every row. Each caller keeps its own
|
|
60
|
+
replacement text: compressed output still names the provider, because a reader
|
|
61
|
+
benefits from knowing what was removed, while diagnostics collapses to a single
|
|
62
|
+
marker. Both preserve the key name and drop only the value, so `password=`
|
|
63
|
+
survives and its secret does not.
|
|
64
|
+
|
|
65
|
+
Tests assert every provider on **both** paths — a property no single-module test
|
|
66
|
+
could hold. What redaction must leave alone is pinned too: a 40-character hex
|
|
67
|
+
git SHA passes through untouched, because redacting commit SHAs would make diffs
|
|
68
|
+
and review output unreadable.
|
|
69
|
+
|
|
70
|
+
## Coverage
|
|
71
|
+
|
|
72
|
+
Measured across all sixteen shards: 78.22% branches, 87.77% statements, 91.20%
|
|
73
|
+
functions, 90.33% lines.
|
|
74
|
+
|
|
75
|
+
The 77.16% recorded in 0.10.6 came from fifteen shards, the sixteenth having
|
|
76
|
+
failed to write its report. That number was described at the time as a floor. It
|
|
77
|
+
was not — a missing shard shrinks the denominator faster than the numerator, so
|
|
78
|
+
it flattered the result. A partial-shard measurement is unusable rather than
|
|
79
|
+
conservative.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# knodin 0.10.8
|
|
2
|
+
|
|
3
|
+
One change, prompted by a field report from an agent session that built a
|
|
4
|
+
duplicate implementation of a hook that already existed. Most of that report
|
|
5
|
+
described behaviour knodin already has; the part it got right is fixed here.
|
|
6
|
+
|
|
7
|
+
## A linked worktree with no graph now says so in its remediation
|
|
8
|
+
|
|
9
|
+
`git worktree add` gives you a checkout with no graph in it. Graphs are
|
|
10
|
+
**per-worktree**, so a perfectly healthy main checkout tells you nothing about
|
|
11
|
+
the worktree you are standing in — and that makes the missing graph *more*
|
|
12
|
+
surprising, not less.
|
|
13
|
+
|
|
14
|
+
Git creates no `.knodin`; that directory is knodin's, written by `init` and by
|
|
15
|
+
the managed lifecycle hooks. Where those hooks are already installed, a fresh
|
|
16
|
+
worktree can present a `.knodin` that holds no graph — which is the more
|
|
17
|
+
confusing shape of this, and the one the original report hit.
|
|
18
|
+
|
|
19
|
+
Until now, status in that state produced generic remediation:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
remediation:
|
|
23
|
+
- Run `knodin repair` to create the local index.
|
|
24
|
+
- Run `knodin status` again to verify health.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That guidance works — `repair` does build the index from cold, and then tells
|
|
28
|
+
you to run `init` for lifecycle routing. What it does not do is explain *why*
|
|
29
|
+
there was no index in a repository whose main checkout is fine. A reader who
|
|
30
|
+
does not already know the per-worktree model has no way to tell "this checkout
|
|
31
|
+
was never indexed" from "there is nothing here to find", and the second reading
|
|
32
|
+
is the one that quietly confirms whatever hypothesis sent them looking.
|
|
33
|
+
|
|
34
|
+
A linked worktree now leads with the model and the fix, on both the
|
|
35
|
+
machine-readable `remediation` and the human `status` line:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Run `knodin init` here: graphs are per-worktree and this one has none of its
|
|
39
|
+
own, so the main checkout's graph does not cover it. `init` seeds from an
|
|
40
|
+
indexed sibling worktree where one exists rather than rebuilding from scratch.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Ordinary checkouts are unchanged. Detection reads the `.git` file's `gitdir:`
|
|
44
|
+
pointer and requires a `commondir` beside it — no subprocess, no registry read,
|
|
45
|
+
and it works on a repository too damaged to answer anything else.
|
|
46
|
+
|
|
47
|
+
A `.git` **file** alone is not enough, and treating it as enough was wrong in
|
|
48
|
+
the first cut of this fix: submodules and `--separate-git-dir` clones use one
|
|
49
|
+
too, and neither has a main checkout whose graph could cover it, so they would
|
|
50
|
+
have been handed an explanation that does not apply to them. `commondir` is
|
|
51
|
+
what actually distinguishes a linked worktree.
|
|
52
|
+
|
|
53
|
+
The check lives in a new `src/engine/git-layout.ts` rather than in the engine,
|
|
54
|
+
for a reason worth stating. `engine/index.ts` carries a pinned budget on direct
|
|
55
|
+
`fs` reads, because a sealed artifact answers with no working tree and a reader
|
|
56
|
+
that touches the filesystem there gets ENOENT — which the nearest guard turns
|
|
57
|
+
into `""` or `null`, and which reads downstream as "this symbol has no body"
|
|
58
|
+
rather than "this was not covered". Git *metadata* is categorically outside
|
|
59
|
+
that concern: a sealed artifact has no `.git` at all, so routing these reads
|
|
60
|
+
through the sealed resolver would add a branch that can never execute. Saying
|
|
61
|
+
so in the layout is more honest than spending budget the engine reserves for
|
|
62
|
+
source reads — and it makes the check directly unit-testable, which it was not
|
|
63
|
+
as an engine-private helper.
|
|
64
|
+
|
|
65
|
+
Its spec covers every branch (100% of statements and branches): a plain
|
|
66
|
+
directory, an ordinary checkout, a real linked worktree, a real submodule, a
|
|
67
|
+
real `--separate-git-dir` clone, a malformed `.git` file naming no gitdir, and a
|
|
68
|
+
dangling pointer whose administrative directory was pruned.
|
|
69
|
+
|
|
70
|
+
### Why `init` and not `repair` here
|
|
71
|
+
|
|
72
|
+
`repair` does build the index from cold, and for an ordinary checkout it stays
|
|
73
|
+
the right advice. In a linked worktree it is the *expensive* answer: seeding
|
|
74
|
+
lives on the `init` path only (`indexOrSeed`), so `repair` rebuilds from scratch
|
|
75
|
+
while `init` reflink-copies an indexed sibling's baseline, reconciles only the
|
|
76
|
+
paths that differ between that sibling's `indexedHead` and this HEAD,
|
|
77
|
+
deep-audits the candidate, and promotes it only if it passes — falling back to a
|
|
78
|
+
full index when no compatible sibling exists.
|
|
79
|
+
|
|
80
|
+
The human `status` renderer had been computing its own one-line advice and
|
|
81
|
+
ignoring `repairSteps` entirely, so the first fix reached the JSON surface and
|
|
82
|
+
not the line a person actually reads. It now defers to the step the engine
|
|
83
|
+
already wrote. Measured on a 20-file fixture, `init` in a fresh worktree with an
|
|
84
|
+
indexed sibling completes in about three seconds.
|
|
85
|
+
|
|
86
|
+
Worth stating plainly, because the surprise runs the other way from the cost:
|
|
87
|
+
the per-worktree model does **not** mean a new worktree pays for a full index.
|
|
88
|
+
|
|
89
|
+
## What the same report claimed that measurement did not support
|
|
90
|
+
|
|
91
|
+
Recorded because acting on any of these would have been a regression, and
|
|
92
|
+
because each was reported in good faith from accumulated notes rather than from
|
|
93
|
+
a run against this release.
|
|
94
|
+
|
|
95
|
+
**"Structural queries return empty rather than erroring on an uninitialized
|
|
96
|
+
graph."** They do not, and have not for some time. Against a committed
|
|
97
|
+
repository with no index, `knodin query callers_of <symbol>` returns
|
|
98
|
+
`available: false`, `status: unavailable`, `state: not-initialized`, the full
|
|
99
|
+
status envelope, and **exit code 1**. The same holds for `impact`, `dead_code`,
|
|
100
|
+
`tests_for`, and `file_summary`, on both the CLI and the MCP gateway — both
|
|
101
|
+
route through the same `inspectGraphQueryHealth` gate before and after the
|
|
102
|
+
operation runs. There was no empty result set to fix.
|
|
103
|
+
|
|
104
|
+
This one matters most, because an empty structural result that agrees with your
|
|
105
|
+
hypothesis is the worst failure this tool could have. It is worth restating that
|
|
106
|
+
the gate is there, and that it fails loudly on both surfaces.
|
|
107
|
+
|
|
108
|
+
**"`knodin init --scope personal` exits 0 having done nothing."** It exits **1**.
|
|
109
|
+
The refusal is thrown, and every non-`agent-event` throw reaches the top-level
|
|
110
|
+
handler, which prints to stderr and exits 1. Reproduced against a repository with
|
|
111
|
+
tracked team integration active.
|
|
112
|
+
|
|
113
|
+
**"A backgrounded `knodin repair` produces no progress output."** Repair emits
|
|
114
|
+
ordered per-phase progress on stderr — audit, planning, file reconciliation,
|
|
115
|
+
symbol identities, embeddings, orphan cleanup, verification — and finishes with a
|
|
116
|
+
verified summary naming indexed file and symbol counts.
|
|
117
|
+
|
|
118
|
+
Both of these turned out to have the same cause, since confirmed by the
|
|
119
|
+
reporter: the commands were run through `| tail`. A pipeline's exit status is the
|
|
120
|
+
last command's, so `init`'s 1 was recorded as `tail`'s 0; and `tail` flushes
|
|
121
|
+
nothing until the stream closes, so a capture file read mid-run looked empty
|
|
122
|
+
while repair was in fact reporting every phase. Neither surface changed. Worth
|
|
123
|
+
naming because one habit produced two bug reports against the wrong component.
|
|
124
|
+
|
|
125
|
+
## Not changed here: semantic search ranking
|
|
126
|
+
|
|
127
|
+
A follow-up from the same session measured something real and unaddressed: on a
|
|
128
|
+
healthy graph, `"hands-free voice conversation mode hook with barge-in"` ranked a
|
|
129
|
+
constant list (`BARGE_IN_PHRASES`, 0.588) and the caller's own minutes-old
|
|
130
|
+
duplicate (0.552) above the canonical 513-line implementation, which did not
|
|
131
|
+
appear in the top hits at all. Mechanism-shaped phrasing
|
|
132
|
+
(`"voice pipeline auto restart mic after TTS"`) put the canonical hook at #2,
|
|
133
|
+
0.517.
|
|
134
|
+
|
|
135
|
+
So feature-shaped phrasing — the phrasing a developer uses when asking "does this
|
|
136
|
+
already exist?" — ranked worst for exactly that question, and surfaced the new
|
|
137
|
+
duplicate as apparent confirmation. Centrality data the fix would need
|
|
138
|
+
(in-degree, community membership) is already in the graph and already in the
|
|
139
|
+
output; it is not weighted into ranking.
|
|
140
|
+
|
|
141
|
+
That is a ranking change with its own evaluation burden and it is not bundled
|
|
142
|
+
into a remediation-string fix. It is recorded here so it is not rediscovered from
|
|
143
|
+
scratch.
|
|
144
|
+
|
|
145
|
+
## The pre-push test gate was not gating
|
|
146
|
+
|
|
147
|
+
Found because this release's own broken commit sailed through it.
|
|
148
|
+
|
|
149
|
+
`lefthook.yml`'s `quality-gates` block ran `bun run test:coverage` and then
|
|
150
|
+
`sh scripts/run-sonar-scan.sh` as two lines of one shell block with no
|
|
151
|
+
`set -e`. A shell block's exit status is its **last** command's, so a failing
|
|
152
|
+
suite followed by a passing Sonar scan reported success, and the push was
|
|
153
|
+
allowed. The suite printed `1 failed | 2058 passed` and
|
|
154
|
+
`script "test:coverage" exited with code 1`, and the push completed anyway.
|
|
155
|
+
|
|
156
|
+
`set -e` now leads the block. Coverage thresholds and Sonar were unaffected;
|
|
157
|
+
what was broken is that a red suite could not stop a push.
|
|
158
|
+
|
|
159
|
+
## Verification
|
|
160
|
+
|
|
161
|
+
`npm test`, `npm run lint`, and `npm run typecheck` pass. The new behaviour is
|
|
162
|
+
pinned by a test in `src/__tests__/unit/index-health.spec.ts` that builds a real
|
|
163
|
+
linked worktree with `git worktree add`, asserts the per-worktree line leads its
|
|
164
|
+
remediation, and asserts the ordinary checkout keeps repair-first guidance with
|
|
165
|
+
no per-worktree line.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.8",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "compatible"
|
|
6
6
|
},
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"docs/releases/0.10.4.md",
|
|
62
62
|
"docs/releases/0.10.5.md",
|
|
63
63
|
"docs/releases/0.10.6.md",
|
|
64
|
+
"docs/releases/0.10.7.md",
|
|
65
|
+
"docs/releases/0.10.8.md",
|
|
64
66
|
"docs/releases/0.3.0.md",
|
|
65
67
|
"docs/releases/0.4.0.md",
|
|
66
68
|
"docs/releases/0.4.1.md",
|