knodin 0.12.1 → 0.13.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/README.md +16 -1
- package/dist/bin/cli.js +230 -31
- package/dist/src/agent-events.js +25 -7
- package/dist/src/authenticated-cursor.js +81 -0
- package/dist/src/backup-retention.js +20 -6
- package/dist/src/class-consumer-contract.js +18 -0
- package/dist/src/class-consumer-cursor.js +91 -0
- package/dist/src/class-consumer-delivery.js +22 -0
- package/dist/src/class-consumer-page.js +148 -0
- package/dist/src/cli-model.js +9 -3
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/doctor.js +59 -6
- package/dist/src/engine/apex-class-uses.js +430 -0
- package/dist/src/engine/apex-entry-points.js +98 -0
- package/dist/src/engine/apex-receiver.js +301 -0
- package/dist/src/engine/embedding-reuse.js +57 -0
- package/dist/src/engine/embeddings.js +22 -0
- package/dist/src/engine/index-coverage.js +215 -0
- package/dist/src/engine/index.js +2507 -254
- package/dist/src/engine/salesforce-components.js +460 -0
- package/dist/src/engine/seal.js +3 -0
- package/dist/src/engine/sqlite.js +44 -0
- package/dist/src/evidence-bundle.js +283 -0
- package/dist/src/evidence-graph.js +163 -0
- package/dist/src/failure-diagnosis.js +80 -5
- package/dist/src/file-dependency.js +35 -0
- package/dist/src/graph-query-health.js +47 -1
- package/dist/src/implementation-search.js +69 -0
- package/dist/src/index-coverage-read.js +33 -0
- package/dist/src/init.js +91 -13
- package/dist/src/investigation.js +195 -0
- package/dist/src/lifecycle-health.js +62 -2
- package/dist/src/mcp-reliability.js +4 -0
- package/dist/src/mcp-worker-supervisor.js +122 -6
- package/dist/src/node-runtime.js +96 -0
- package/dist/src/progressive-evidence.js +4 -4
- package/dist/src/response-budget.js +129 -3
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/publisher.js +41 -1
- package/dist/src/tools/knodin-tools.js +224 -41
- package/docs/CLI.md +92 -0
- package/docs/MCP.md +67 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
- package/docs/SALESFORCE-BINDINGS.md +121 -0
- package/docs/SALESFORCE-DEAD-CODE.md +45 -0
- package/docs/SCOPED-INDEXING.md +76 -0
- package/docs/apex-receiver-resolution.md +41 -0
- package/docs/releases/0.12.2.md +88 -0
- package/docs/releases/0.13.0.md +62 -0
- package/docs/structural-only-indexing.md +20 -0
- package/package.json +12 -5
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Matches SQLite TEXT COLLATE BINARY for canonical UTF-8 paths and ASCII role names. */
|
|
2
|
+
export function compareClassConsumerKeys(left, right) {
|
|
3
|
+
return (Buffer.compare(Buffer.from(left.file), Buffer.from(right.file)) ||
|
|
4
|
+
left.startByte - right.startByte ||
|
|
5
|
+
left.endByte - right.endByte ||
|
|
6
|
+
Buffer.compare(Buffer.from(left.kind), Buffer.from(right.kind)));
|
|
7
|
+
}
|
|
8
|
+
/** Adapters translate these typed failures; never manufacture a successful target identity. */
|
|
9
|
+
export class ClassConsumerQueryError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
candidates;
|
|
12
|
+
constructor(code, message, candidates = []) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.candidates = candidates;
|
|
16
|
+
this.name = "ClassConsumerQueryError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { signAuthenticatedCursor, verifyAuthenticatedCursor } from "./authenticated-cursor.js";
|
|
2
|
+
import { canonicalCoveragePath } from "./engine/index-coverage.js";
|
|
3
|
+
const PREFIX = "cc1";
|
|
4
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
5
|
+
const KINDS = [
|
|
6
|
+
"static_field_read",
|
|
7
|
+
"constructor",
|
|
8
|
+
"variable_type",
|
|
9
|
+
"cast",
|
|
10
|
+
"class_literal",
|
|
11
|
+
"parameter_type",
|
|
12
|
+
"generic_parameter_type",
|
|
13
|
+
"loop_variable_type",
|
|
14
|
+
"generic_type_argument",
|
|
15
|
+
];
|
|
16
|
+
function record(value) {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18
|
+
return false;
|
|
19
|
+
const prototype = Object.getPrototypeOf(value);
|
|
20
|
+
return prototype === Object.prototype || prototype === null;
|
|
21
|
+
}
|
|
22
|
+
function exactKeys(value, keys) {
|
|
23
|
+
return (Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)));
|
|
24
|
+
}
|
|
25
|
+
function validPayload(value) {
|
|
26
|
+
if (!record(value) || !exactKeys(value, ["v", "type", "queryDigest", "snapshotDigest", "after"]))
|
|
27
|
+
return false;
|
|
28
|
+
if (value.v !== 1 ||
|
|
29
|
+
value.type !== "class-consumers" ||
|
|
30
|
+
typeof value.queryDigest !== "string" ||
|
|
31
|
+
!DIGEST.test(value.queryDigest) ||
|
|
32
|
+
typeof value.snapshotDigest !== "string" ||
|
|
33
|
+
!DIGEST.test(value.snapshotDigest))
|
|
34
|
+
return false;
|
|
35
|
+
const key = value.after;
|
|
36
|
+
return (record(key) &&
|
|
37
|
+
exactKeys(key, ["file", "startByte", "endByte", "kind"]) &&
|
|
38
|
+
typeof key.file === "string" &&
|
|
39
|
+
canonicalCoveragePath(key.file) &&
|
|
40
|
+
Buffer.from(key.file).toString("utf8") === key.file &&
|
|
41
|
+
typeof key.startByte === "number" &&
|
|
42
|
+
Number.isSafeInteger(key.startByte) &&
|
|
43
|
+
key.startByte >= 0 &&
|
|
44
|
+
typeof key.endByte === "number" &&
|
|
45
|
+
Number.isSafeInteger(key.endByte) &&
|
|
46
|
+
key.endByte > key.startByte &&
|
|
47
|
+
typeof key.kind === "string" &&
|
|
48
|
+
KINDS.includes(key.kind));
|
|
49
|
+
}
|
|
50
|
+
function invalid() {
|
|
51
|
+
return new Error("invalid class-consumer continuation; restart the query without continuation");
|
|
52
|
+
}
|
|
53
|
+
/** Only call after the final wire envelope is fitted, using its last delivered site key. */
|
|
54
|
+
export function signClassConsumerCursor(payload, secret) {
|
|
55
|
+
if (!validPayload(payload))
|
|
56
|
+
throw invalid();
|
|
57
|
+
// Copy validated fields explicitly: signing never invokes a caller-provided toJSON hook.
|
|
58
|
+
return signAuthenticatedCursor({
|
|
59
|
+
v: 1,
|
|
60
|
+
type: "class-consumers",
|
|
61
|
+
queryDigest: payload.queryDigest,
|
|
62
|
+
snapshotDigest: payload.snapshotDigest,
|
|
63
|
+
after: {
|
|
64
|
+
file: payload.after.file,
|
|
65
|
+
startByte: payload.after.startByte,
|
|
66
|
+
endByte: payload.after.endByte,
|
|
67
|
+
kind: payload.after.kind,
|
|
68
|
+
},
|
|
69
|
+
}, secret, PREFIX);
|
|
70
|
+
}
|
|
71
|
+
/** Authenticates and validates shape. Backend must still bind both digests to its current lease. */
|
|
72
|
+
export function readClassConsumerCursor(cursor, secret) {
|
|
73
|
+
let payload;
|
|
74
|
+
try {
|
|
75
|
+
payload = verifyAuthenticatedCursor(cursor, secret, PREFIX);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw invalid();
|
|
79
|
+
}
|
|
80
|
+
if (!validPayload(payload))
|
|
81
|
+
throw invalid();
|
|
82
|
+
return payload;
|
|
83
|
+
}
|
|
84
|
+
export function assertClassConsumerCursorBinding(cursor, queryDigest, snapshotDigest) {
|
|
85
|
+
if (!validPayload(cursor) || !DIGEST.test(queryDigest) || !DIGEST.test(snapshotDigest))
|
|
86
|
+
throw invalid();
|
|
87
|
+
if (cursor.queryDigest !== queryDigest)
|
|
88
|
+
throw new Error("class-consumer continuation belongs to another query; restart without continuation");
|
|
89
|
+
if (cursor.snapshotDigest !== snapshotDigest)
|
|
90
|
+
throw new Error("class-consumer evidence changed; restart the query without continuation");
|
|
91
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ClassConsumerQueryError, } from "./class-consumer-contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run once after the captured post-read health probe and before page packing.
|
|
4
|
+
* Revalidates the backend facts/coverage lease, not only freshness timestamps.
|
|
5
|
+
* Never replace the original page's rows or freshness with the validation read.
|
|
6
|
+
* Current seam repeats a limit-one raw read; measure before optimizing it into
|
|
7
|
+
* a dedicated lease check. Do not call it per candidate in the packing loop.
|
|
8
|
+
*/
|
|
9
|
+
export async function validateClassConsumerDelivery(page, request, read) {
|
|
10
|
+
const checked = await read({
|
|
11
|
+
...request,
|
|
12
|
+
limit: 1,
|
|
13
|
+
expectedQueryDigest: page.queryDigest,
|
|
14
|
+
expectedSnapshotDigest: page.snapshotDigest,
|
|
15
|
+
});
|
|
16
|
+
if (checked.queryDigest !== page.queryDigest ||
|
|
17
|
+
checked.targetIdentity !== page.targetIdentity ||
|
|
18
|
+
checked.targetKind !== page.targetKind)
|
|
19
|
+
throw new ClassConsumerQueryError("CLASS_CONSUMER_QUERY_MISMATCH", "The class-consumer query changed before delivery.");
|
|
20
|
+
if (checked.snapshotDigest !== page.snapshotDigest)
|
|
21
|
+
throw new ClassConsumerQueryError("CLASS_CONSUMER_STALE_CURSOR", "The class-consumer evidence changed before delivery; restart the query.");
|
|
22
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { compareClassConsumerKeys, } from "./class-consumer-contract.js";
|
|
3
|
+
import { signClassConsumerCursor } from "./class-consumer-cursor.js";
|
|
4
|
+
const positiveInt = (value, fallback) => value == null || !Number.isFinite(value) ? fallback : Math.max(1, Math.trunc(value));
|
|
5
|
+
const size = (value) => Buffer.byteLength(JSON.stringify(value));
|
|
6
|
+
const invalid = () => new Error("invalid class-consumer page contract");
|
|
7
|
+
function validatePage(page) {
|
|
8
|
+
if ((page.targetKind !== "class" && page.targetKind !== "interface") ||
|
|
9
|
+
page.returnedCount !== page.results.length ||
|
|
10
|
+
!Number.isSafeInteger(page.count) ||
|
|
11
|
+
page.count < page.results.length ||
|
|
12
|
+
!Number.isSafeInteger(page.consumerFileCount) ||
|
|
13
|
+
page.consumerFileCount < page.files.length ||
|
|
14
|
+
(page.hasMore && page.results.length === 0))
|
|
15
|
+
throw invalid();
|
|
16
|
+
const files = new Map(page.files.map((file) => [file.file, file]));
|
|
17
|
+
if (files.size !== page.files.length)
|
|
18
|
+
throw invalid();
|
|
19
|
+
const counts = new Map();
|
|
20
|
+
for (let index = 0; index < page.results.length; index++) {
|
|
21
|
+
const row = page.results[index];
|
|
22
|
+
if (row.file !== row.key.file ||
|
|
23
|
+
row.start.byte !== row.key.startByte ||
|
|
24
|
+
row.end.byte !== row.key.endByte ||
|
|
25
|
+
row.kind !== row.key.kind ||
|
|
26
|
+
(index > 0 && compareClassConsumerKeys(page.results[index - 1].key, row.key) >= 0))
|
|
27
|
+
throw invalid();
|
|
28
|
+
const file = files.get(row.file);
|
|
29
|
+
if (!file || file.isTest !== row.isTest)
|
|
30
|
+
throw invalid();
|
|
31
|
+
counts.set(row.file, (counts.get(row.file) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
for (const file of files.values()) {
|
|
34
|
+
const count = counts.get(file.file);
|
|
35
|
+
if (count === undefined ||
|
|
36
|
+
file.returnedSiteCount !== count ||
|
|
37
|
+
!Number.isSafeInteger(file.siteCount) ||
|
|
38
|
+
file.siteCount < count)
|
|
39
|
+
throw invalid();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fits complete rows and all qualifications, never truncating evidence fields.
|
|
44
|
+
* The caller must return this result unchanged: no later generic budget reduction.
|
|
45
|
+
* Backend lease checks precede this pure step; continuation always names the last
|
|
46
|
+
* actually delivered key, not the last row fetched from storage.
|
|
47
|
+
*/
|
|
48
|
+
export function finalizeClassConsumerPage(page, options) {
|
|
49
|
+
validatePage(page);
|
|
50
|
+
const request = options.budget ?? {};
|
|
51
|
+
const tokenLimit = positiveInt(request.tokens, 16384);
|
|
52
|
+
const byteLimit = Math.min(positiveInt(request.bytes, 65536), tokenLimit * 4);
|
|
53
|
+
const itemLimit = positiveInt(request.items, 100);
|
|
54
|
+
if (byteLimit < 256)
|
|
55
|
+
throw new Error("class-consumer response budget must be at least 256 bytes");
|
|
56
|
+
let maxRows = Math.min(page.results.length, itemLimit);
|
|
57
|
+
const minimumRows = page.results.length ? 1 : 0;
|
|
58
|
+
// Protected rows alone are a strict lower bound on the complete wire size.
|
|
59
|
+
// Skip impossible prefixes without cloning/decorating/serializing the entire
|
|
60
|
+
// page for each one. Do not assume complete candidate sizes are monotonic:
|
|
61
|
+
// the last-key cursor can get shorter when crossing a filename boundary.
|
|
62
|
+
let rowArrayBytes = 2;
|
|
63
|
+
for (let index = 0; index < maxRows; index++) {
|
|
64
|
+
rowArrayBytes += size(page.results[index]) + (index > 0 ? 1 : 0);
|
|
65
|
+
if (rowArrayBytes > byteLimit) {
|
|
66
|
+
maxRows = Math.max(minimumRows, index);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let requiredBytes = 0;
|
|
71
|
+
for (let length = maxRows; length >= minimumRows; length--) {
|
|
72
|
+
const results = page.results.slice(0, length);
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const row of results)
|
|
75
|
+
counts.set(row.file, (counts.get(row.file) ?? 0) + 1);
|
|
76
|
+
const hasMore = page.hasMore || length < page.results.length;
|
|
77
|
+
const last = results.at(-1);
|
|
78
|
+
const continuation = hasMore && last
|
|
79
|
+
? signClassConsumerCursor({
|
|
80
|
+
v: 1,
|
|
81
|
+
type: "class-consumers",
|
|
82
|
+
queryDigest: page.queryDigest,
|
|
83
|
+
snapshotDigest: page.snapshotDigest,
|
|
84
|
+
after: last.key,
|
|
85
|
+
}, options.secret)
|
|
86
|
+
: null;
|
|
87
|
+
const metadata = {
|
|
88
|
+
byteLimit,
|
|
89
|
+
tokenLimit,
|
|
90
|
+
itemLimit,
|
|
91
|
+
serializedBytes: 0,
|
|
92
|
+
estimatedTokens: 0,
|
|
93
|
+
truncated: hasMore,
|
|
94
|
+
totals: { "$/results": page.count, "$/files": page.consumerFileCount },
|
|
95
|
+
};
|
|
96
|
+
const candidate = structuredClone({
|
|
97
|
+
pattern: "class_consumers",
|
|
98
|
+
targetIdentity: page.targetIdentity,
|
|
99
|
+
targetKind: page.targetKind,
|
|
100
|
+
count: page.count,
|
|
101
|
+
consumerFileCount: page.consumerFileCount,
|
|
102
|
+
returnedCount: length,
|
|
103
|
+
results,
|
|
104
|
+
files: page.files
|
|
105
|
+
.filter((file) => counts.has(file.file))
|
|
106
|
+
.map((file) => ({
|
|
107
|
+
...file,
|
|
108
|
+
returnedSiteCount: counts.get(file.file),
|
|
109
|
+
})),
|
|
110
|
+
hasMore,
|
|
111
|
+
continuation,
|
|
112
|
+
classConsumerAnalysis: page.analysis,
|
|
113
|
+
indexCoverage: page.indexCoverage,
|
|
114
|
+
freshness: page.freshness,
|
|
115
|
+
responseBudget: metadata,
|
|
116
|
+
});
|
|
117
|
+
const output = options.decorate ? options.decorate(structuredClone(candidate)) : candidate;
|
|
118
|
+
if (!output || Array.isArray(output) || typeof output !== "object")
|
|
119
|
+
throw invalid();
|
|
120
|
+
if (output.available === false ||
|
|
121
|
+
(output.availability &&
|
|
122
|
+
typeof output.availability === "object" &&
|
|
123
|
+
["unavailable", "repair-needed"].includes(String(output.availability.state))))
|
|
124
|
+
throw new Error("class-consumer evidence became unavailable; restart the query");
|
|
125
|
+
for (const key of Object.keys(candidate)) {
|
|
126
|
+
if (!isDeepStrictEqual(candidate[key], output[key]))
|
|
127
|
+
throw new Error("class-consumer decoration changed protected evidence");
|
|
128
|
+
}
|
|
129
|
+
output.responseBudget = metadata;
|
|
130
|
+
// Metadata changes its own serialized size. Recompute until the byte/token digits stabilize.
|
|
131
|
+
let stable = false;
|
|
132
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
133
|
+
const actual = size(output), tokens = Math.ceil(actual / 4);
|
|
134
|
+
if (metadata.serializedBytes === actual && metadata.estimatedTokens === tokens) {
|
|
135
|
+
stable = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
metadata.serializedBytes = actual;
|
|
139
|
+
metadata.estimatedTokens = tokens;
|
|
140
|
+
}
|
|
141
|
+
if (!stable)
|
|
142
|
+
throw new Error("class-consumer response serialization did not stabilize");
|
|
143
|
+
requiredBytes = metadata.serializedBytes;
|
|
144
|
+
if (requiredBytes <= byteLimit)
|
|
145
|
+
return output;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`class-consumer response budget ${byteLimit} cannot preserve qualifications and one complete page item; use at least ${requiredBytes} bytes and ${Math.ceil(requiredBytes / 4)} estimated tokens or narrow the query`);
|
|
148
|
+
}
|
package/dist/src/cli-model.js
CHANGED
|
@@ -122,7 +122,10 @@ function addRepositoryCommands(program, capture) {
|
|
|
122
122
|
.option("--dry-run", "report actions without mutation");
|
|
123
123
|
}
|
|
124
124
|
function addGraphCommands(program, capture) {
|
|
125
|
-
leaf(program, "context <task> [base]", "build compact task orientation", capture)
|
|
125
|
+
leaf(program, "context <task> [base]", "build compact task orientation or a target investigation", capture)
|
|
126
|
+
.option("--symbol <name>", "investigate a target's source, change sites, impact and tests")
|
|
127
|
+
.option("--implementations", "rank possible implementations for the task; do not select a target")
|
|
128
|
+
.addOption(option("--offset <count>", "implementation candidate page offset", "integer"));
|
|
126
129
|
leaf(program, "explain <symbol> [detail]", "explain one ambiguity-safe symbol", capture);
|
|
127
130
|
leaf(program, "review [base] [detail]", "review an explicit Git diff scope", capture)
|
|
128
131
|
.option("--scope <scope>", "unstaged, staged, all, or compare")
|
|
@@ -165,7 +168,8 @@ function addGraphCommands(program, capture) {
|
|
|
165
168
|
.option("--kinds <values>", "comma-separated symbol kinds")
|
|
166
169
|
.option("--path <prefix>", "repo-relative path prefix")
|
|
167
170
|
.option("--base <ref>", "compare file_metrics with the merge-base of this Git ref")
|
|
168
|
-
.option("--test-scope <scope>", "file_metrics
|
|
171
|
+
.option("--test-scope <scope>", "file_metrics/class_consumers: all, test, or production")
|
|
172
|
+
.option("--continuation <handle>", "class_consumers: resume the same target and filters")
|
|
169
173
|
.option("--format <format>", "file_metrics machine output format; currently json")
|
|
170
174
|
.option("--variable <name>", "flow-analysis variable")
|
|
171
175
|
.option("--facets <values>", "comma-separated architecture facets")
|
|
@@ -199,7 +203,9 @@ function addBackupCommands(program, capture) {
|
|
|
199
203
|
leaf(retention, "run", "run the installed retention policy", capture);
|
|
200
204
|
}
|
|
201
205
|
function addArtifactCommands(program, capture) {
|
|
202
|
-
leaf(program, "evidence <level>
|
|
206
|
+
leaf(program, "evidence <level> [file]", "deliver progressive source evidence", capture)
|
|
207
|
+
.option("--files <paths>", "comma-separated bundle files; evidence or expand level")
|
|
208
|
+
.option("--already-present <handles>", "comma-separated verified bundle file handles")
|
|
203
209
|
.option("--continuation <handle>", "resume an exact prior response")
|
|
204
210
|
.option("--baseline-hash <sha256>", "complete baseline SHA-256")
|
|
205
211
|
.addOption(option("--baseline-bytes <count>", "complete baseline UTF-8 bytes", "integer"))
|
|
@@ -24,6 +24,7 @@ The \`query\` operation lets you ask precise, structured questions about your co
|
|
|
24
24
|
|
|
25
25
|
- **callers_of <target>**: Who invokes this symbol (transitive/direct).
|
|
26
26
|
- **callees_of <target>**: What other symbols does this symbol invoke.
|
|
27
|
+
- **class_consumers <target>**: Direct, typed Apex class-use sites, separate from method callers and Java configuration consumers. Count means observed sites; consumerFileCount means distinct files. All tests are included by default; testScope can select test or production files. Use limit (1–1000 sites), canonical path prefix, and signed continuation with the same target/filters. Pages preserve full site ranges, source-owner identities and scope/extraction qualifications; stale continuations are rejected. Nine supported syntax roles only, not complete class-use or runtime reachability proof.
|
|
27
28
|
- **tests_for <target>**: Find test files that cover this symbol.
|
|
28
29
|
- **file_summary <target>**: Lists all symbols defined in a file.
|
|
29
30
|
- **shortest_path <from> <to>**: Find the call-graph chain connecting two symbols.
|
package/dist/src/doctor.js
CHANGED
|
@@ -171,15 +171,68 @@ function managedConfigurationRemediation(repo, run) {
|
|
|
171
171
|
paths.push(relative);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
// One `ls-files` and one `diff` for every managed path together: the previous
|
|
175
|
+
// per-path pair spawned ~2 git processes per entry, and each `git diff` must
|
|
176
|
+
// load and refresh the repository index — seconds apiece on a 900k-file
|
|
177
|
+
// checkout, for identical answers (KNODIN-39).
|
|
178
|
+
const trackedResult = run("git", ["-C", repo, "ls-files", "-z", "--", ...paths]);
|
|
179
|
+
const trackedFiles = trackedResult.status === 0 ? trackedResult.stdout.split("\0").filter(Boolean) : [];
|
|
180
|
+
// A managed DIRECTORY (a receipted skill dir) never appears in ls-files
|
|
181
|
+
// output itself — only files under it do — so membership alone would read
|
|
182
|
+
// every tracked skill dir as untracked, matching neither the old
|
|
183
|
+
// `--error-unmatch` semantics nor reality (review on KNODIN-39). The output
|
|
184
|
+
// is already limited to the managed pathspecs, so the prefix scan is tiny.
|
|
185
|
+
const isTrackedManaged = (relative) => trackedFiles.includes(relative) || trackedFiles.some((file) => file.startsWith(`${relative}/`));
|
|
186
|
+
const trackedManaged = paths.filter(isTrackedManaged);
|
|
187
|
+
// Ignored paths need no "add to .git/info/exclude" advice: the instruction
|
|
188
|
+
// previously fired for every present, untracked, knodin-looking file whether
|
|
189
|
+
// or not the exclude entry already existed (KNODIN-40). One batched
|
|
190
|
+
// check-ignore answers for all of them; a non-zero status just means none
|
|
191
|
+
// are ignored.
|
|
192
|
+
const ignoredPaths = new Set();
|
|
193
|
+
const untrackedPresent = paths.filter((relative) => !isTrackedManaged(relative) && fs.existsSync(path.join(repo, relative)));
|
|
194
|
+
if (untrackedPresent.length > 0) {
|
|
195
|
+
const checkIgnore = run("git", ["-C", repo, "check-ignore", "-z", "--stdin"], {
|
|
196
|
+
input: `${untrackedPresent.join("\0")}\0`,
|
|
197
|
+
});
|
|
198
|
+
for (const relative of checkIgnore.stdout.split("\0").filter(Boolean))
|
|
199
|
+
ignoredPaths.add(relative);
|
|
200
|
+
}
|
|
201
|
+
// Guarding on trackedManaged (not the raw ls-files output) matters: with no
|
|
202
|
+
// tracked managed paths, `git diff --` with an empty pathspec list would
|
|
203
|
+
// diff the entire repository (review on KNODIN-39).
|
|
204
|
+
const diffSections = [];
|
|
205
|
+
if (trackedManaged.length > 0) {
|
|
206
|
+
const batchedDiff = run("git", [
|
|
207
|
+
"-C",
|
|
208
|
+
repo,
|
|
209
|
+
"diff",
|
|
210
|
+
"--no-ext-diff",
|
|
211
|
+
"--unified=3",
|
|
212
|
+
"--",
|
|
213
|
+
...trackedManaged,
|
|
214
|
+
]);
|
|
215
|
+
if (batchedDiff.status === 0 && batchedDiff.stdout) {
|
|
216
|
+
for (const section of batchedDiff.stdout.split(/^(?=diff --git )/m)) {
|
|
217
|
+
const header = /^diff --git a\/(.+) b\//.exec(section);
|
|
218
|
+
if (header?.[1])
|
|
219
|
+
diffSections.push({ file: header[1], section });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// A managed directory owns every section for files beneath it; a managed
|
|
224
|
+
// file owns only its own. This mirrors what the old per-path `git diff -- p`
|
|
225
|
+
// returned for each shape.
|
|
226
|
+
const diffFor = (relative) => diffSections
|
|
227
|
+
.filter(({ file }) => file === relative || file.startsWith(`${relative}/`))
|
|
228
|
+
.map(({ section }) => section)
|
|
229
|
+
.join("");
|
|
174
230
|
return paths
|
|
175
231
|
.map((relative) => {
|
|
176
232
|
const absolute = path.join(repo, relative);
|
|
177
233
|
const present = fs.existsSync(absolute);
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const diffResult = tracked
|
|
181
|
-
? run("git", ["-C", repo, "diff", "--no-ext-diff", "--unified=3", "--", relative])
|
|
182
|
-
: { status: 0, stdout: "", stderr: "" };
|
|
234
|
+
const tracked = isTrackedManaged(relative);
|
|
235
|
+
const diffResult = { status: 0, stdout: diffFor(relative), stderr: "" };
|
|
183
236
|
const receipted = fs.existsSync(path.join(absolute, ".knodin-managed.json"));
|
|
184
237
|
let entryOwned = false;
|
|
185
238
|
if (present && !receipted) {
|
|
@@ -195,7 +248,7 @@ function managedConfigurationRemediation(repo, run) {
|
|
|
195
248
|
instructions.push(`Review the diff, then restore manually with: git restore --source=HEAD -- ${relative}`);
|
|
196
249
|
if (!tracked && receipted)
|
|
197
250
|
instructions.push(`Review the Knodin receipt, then remove manually if unwanted: ${relative}`);
|
|
198
|
-
if (!tracked && present && (receipted || entryOwned))
|
|
251
|
+
if (!tracked && present && (receipted || entryOwned) && !ignoredPaths.has(relative))
|
|
199
252
|
instructions.push(`To retain it locally without committing, add ${relative} to .git/info/exclude manually.`);
|
|
200
253
|
return {
|
|
201
254
|
path: relative,
|