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.
Files changed (51) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +230 -31
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/backup-retention.js +20 -6
  6. package/dist/src/class-consumer-contract.js +18 -0
  7. package/dist/src/class-consumer-cursor.js +91 -0
  8. package/dist/src/class-consumer-delivery.js +22 -0
  9. package/dist/src/class-consumer-page.js +148 -0
  10. package/dist/src/cli-model.js +9 -3
  11. package/dist/src/docs-sections.js +1 -0
  12. package/dist/src/doctor.js +59 -6
  13. package/dist/src/engine/apex-class-uses.js +430 -0
  14. package/dist/src/engine/apex-entry-points.js +98 -0
  15. package/dist/src/engine/apex-receiver.js +301 -0
  16. package/dist/src/engine/embedding-reuse.js +57 -0
  17. package/dist/src/engine/embeddings.js +22 -0
  18. package/dist/src/engine/index-coverage.js +215 -0
  19. package/dist/src/engine/index.js +2507 -254
  20. package/dist/src/engine/salesforce-components.js +460 -0
  21. package/dist/src/engine/seal.js +3 -0
  22. package/dist/src/engine/sqlite.js +44 -0
  23. package/dist/src/evidence-bundle.js +283 -0
  24. package/dist/src/evidence-graph.js +163 -0
  25. package/dist/src/failure-diagnosis.js +80 -5
  26. package/dist/src/file-dependency.js +35 -0
  27. package/dist/src/graph-query-health.js +47 -1
  28. package/dist/src/implementation-search.js +69 -0
  29. package/dist/src/index-coverage-read.js +33 -0
  30. package/dist/src/init.js +91 -13
  31. package/dist/src/investigation.js +195 -0
  32. package/dist/src/lifecycle-health.js +62 -2
  33. package/dist/src/mcp-reliability.js +4 -0
  34. package/dist/src/mcp-worker-supervisor.js +122 -6
  35. package/dist/src/node-runtime.js +96 -0
  36. package/dist/src/progressive-evidence.js +4 -4
  37. package/dist/src/response-budget.js +129 -3
  38. package/dist/src/server.js +18 -5
  39. package/dist/src/shared-index/publisher.js +41 -1
  40. package/dist/src/tools/knodin-tools.js +224 -41
  41. package/docs/CLI.md +92 -0
  42. package/docs/MCP.md +67 -0
  43. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  44. package/docs/SALESFORCE-BINDINGS.md +121 -0
  45. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  46. package/docs/SCOPED-INDEXING.md +76 -0
  47. package/docs/apex-receiver-resolution.md +41 -0
  48. package/docs/releases/0.12.2.md +88 -0
  49. package/docs/releases/0.13.0.md +62 -0
  50. package/docs/structural-only-indexing.md +20 -0
  51. 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
+ }
@@ -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 scope: all, test, or production")
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> <file>", "deliver progressive source evidence", capture)
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.
@@ -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 trackedResult = run("git", ["-C", repo, "ls-files", "--error-unmatch", "--", relative]);
179
- const tracked = trackedResult.status === 0;
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,