knodin 0.7.6 → 0.8.2
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 +19 -7
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2164 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +14238 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +106 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +120 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/response-budget.js +196 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1640 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +59 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +127 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
6
|
+
import { gitExecutable } from "./git-executable.js";
|
|
7
|
+
const MAX_FILES = 5_000;
|
|
8
|
+
const MAX_CONTRACT_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
export function trackedRepositoryFiles(repository) {
|
|
10
|
+
try {
|
|
11
|
+
return (childProcess
|
|
12
|
+
.execFileSync(gitExecutable(), ["ls-files", "-z"], {
|
|
13
|
+
cwd: repository,
|
|
14
|
+
encoding: "utf-8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
})
|
|
17
|
+
.split("\0")
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
// Byte order: this sort decides which files survive the MAX_FILES cap,
|
|
20
|
+
// so collation would change which files are scanned at all.
|
|
21
|
+
.sort(compareBytes)
|
|
22
|
+
.slice(0, MAX_FILES));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function lineNumber(content, offset) {
|
|
29
|
+
return content.slice(0, offset).split("\n").length;
|
|
30
|
+
}
|
|
31
|
+
function matchAll(content, pattern, build) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
for (const match of content.matchAll(pattern)) {
|
|
34
|
+
findings.push({ ...build(match), line: lineNumber(content, match.index ?? 0) });
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
function packageFindings(file, content) {
|
|
39
|
+
if (file === "package.json") {
|
|
40
|
+
try {
|
|
41
|
+
const manifest = JSON.parse(content);
|
|
42
|
+
const findings = [];
|
|
43
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
44
|
+
const dependencies = manifest[section];
|
|
45
|
+
if (typeof dependencies !== "object" || dependencies === null)
|
|
46
|
+
continue;
|
|
47
|
+
for (const name of Object.keys(dependencies).sort(compareBytes)) {
|
|
48
|
+
findings.push({
|
|
49
|
+
type: "depends_on",
|
|
50
|
+
target: `pkg:npm/${encodeURIComponent(name)}`,
|
|
51
|
+
line: lineNumber(content, Math.max(content.indexOf(`"${name}"`), 0)),
|
|
52
|
+
adapter: "package-manifest",
|
|
53
|
+
confidence: 1,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (file === "go.mod") {
|
|
64
|
+
return content.split(/\r?\n/).flatMap((line, index) => {
|
|
65
|
+
const [moduleName, version] = line.trim().split(/\s+/, 2);
|
|
66
|
+
if (!moduleName?.includes(".") || !version?.startsWith("v"))
|
|
67
|
+
return [];
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
type: "depends_on",
|
|
71
|
+
target: `pkg:golang/${moduleName}`,
|
|
72
|
+
line: index + 1,
|
|
73
|
+
adapter: "package-manifest",
|
|
74
|
+
confidence: 1,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (file === "Cargo.toml" || file === "pyproject.toml") {
|
|
80
|
+
return matchAll(content, /^([A-Za-z0-9_.-]+)[ \t]*=[ \t]*(?:["'{])/gm, (match) => ({
|
|
81
|
+
type: "depends_on",
|
|
82
|
+
target: `pkg:${file === "Cargo.toml" ? "cargo" : "pypi"}/${match[1]}`,
|
|
83
|
+
adapter: "package-manifest",
|
|
84
|
+
confidence: 0.9,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
function contractFindings(file, content) {
|
|
90
|
+
const lower = file.toLowerCase();
|
|
91
|
+
if (/openapi|swagger/.test(lower) && /\.(json|ya?ml)$/.test(lower)) {
|
|
92
|
+
return matchAll(content, /^[ \t]{0,8}(\/[^:\s]+)[ \t]*:/gm, (match) => ({
|
|
93
|
+
type: "provides_api",
|
|
94
|
+
target: `openapi:${match[1]}`,
|
|
95
|
+
adapter: "openapi",
|
|
96
|
+
confidence: 1,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
if (/asyncapi/.test(lower)) {
|
|
100
|
+
return matchAll(content, /^[ \t]{2,8}([^:\s]+)[ \t]*:/gm, (match) => ({
|
|
101
|
+
type: "publishes_event",
|
|
102
|
+
target: `asyncapi:${match[1]}`,
|
|
103
|
+
adapter: "asyncapi",
|
|
104
|
+
confidence: 0.9,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
if (lower.endsWith(".graphql") || lower.endsWith(".gql")) {
|
|
108
|
+
return matchAll(content, /^[ \t]*(?:type|interface|input)[ \t]+([A-Za-z_]\w*)/gm, (match) => ({
|
|
109
|
+
type: "provides_api",
|
|
110
|
+
target: `graphql:${match[1]}`,
|
|
111
|
+
adapter: "graphql",
|
|
112
|
+
confidence: 1,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
if (lower.endsWith(".proto")) {
|
|
116
|
+
return matchAll(content, /^[ \t]*service[ \t]+([A-Za-z_]\w*)/gm, (match) => ({
|
|
117
|
+
type: "provides_api",
|
|
118
|
+
target: `protobuf:${match[1]}`,
|
|
119
|
+
adapter: "protobuf",
|
|
120
|
+
confidence: 1,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
function infrastructureFindings(file, content) {
|
|
126
|
+
const lower = file.toLowerCase();
|
|
127
|
+
const findings = [];
|
|
128
|
+
if (lower.endsWith(".tf")) {
|
|
129
|
+
findings.push(...matchAll(content, /^[ \t]*source[ \t]*=[ \t]*"([^"\r\n]+)"/gm, (match) => ({
|
|
130
|
+
type: "depends_on",
|
|
131
|
+
target: `terraform:${match[1]}`,
|
|
132
|
+
adapter: "terraform",
|
|
133
|
+
confidence: 1,
|
|
134
|
+
})), ...matchAll(content, /^[ \t]*backend[ \t]+"([^"\r\n]+)"/gm, (match) => ({
|
|
135
|
+
type: "reads_output",
|
|
136
|
+
target: `terraform-state:${match[1]}`,
|
|
137
|
+
adapter: "terraform",
|
|
138
|
+
confidence: 0.9,
|
|
139
|
+
})), ...matchAll(content, /^[ \t]*output[ \t]+"([^"\r\n]+)"/gm, (match) => ({
|
|
140
|
+
type: "provisions",
|
|
141
|
+
target: `terraform-output:${match[1]}`,
|
|
142
|
+
adapter: "terraform",
|
|
143
|
+
confidence: 1,
|
|
144
|
+
})));
|
|
145
|
+
}
|
|
146
|
+
if (/\.(ya?ml|tpl)$/.test(lower)) {
|
|
147
|
+
findings.push(...matchAll(content, /^[ \t]*image:[ \t]*["']?([^"'\s]+)["']?/gm, (match) => ({
|
|
148
|
+
type: "uses_image",
|
|
149
|
+
target: `oci:${match[1]}`,
|
|
150
|
+
adapter: lower.includes("helm") || lower.includes("chart") ? "helm" : "kubernetes",
|
|
151
|
+
confidence: 1,
|
|
152
|
+
})));
|
|
153
|
+
}
|
|
154
|
+
return findings;
|
|
155
|
+
}
|
|
156
|
+
function provenanceFindings(file, content) {
|
|
157
|
+
const lower = file.toLowerCase();
|
|
158
|
+
if (lower.endsWith(".map")) {
|
|
159
|
+
try {
|
|
160
|
+
const sourceMap = JSON.parse(content);
|
|
161
|
+
if (!Array.isArray(sourceMap.sources))
|
|
162
|
+
return [];
|
|
163
|
+
return sourceMap.sources.flatMap((source) => typeof source === "string"
|
|
164
|
+
? [
|
|
165
|
+
{
|
|
166
|
+
type: "generated_from",
|
|
167
|
+
target: `file:${source}`,
|
|
168
|
+
line: 1,
|
|
169
|
+
adapter: "source-map",
|
|
170
|
+
confidence: 1,
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
: []);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (/(?:^|\/)(?:bom|sbom)(?:\.[^/]*)?\.json$/.test(lower)) {
|
|
180
|
+
try {
|
|
181
|
+
const sbom = JSON.parse(content);
|
|
182
|
+
if (!Array.isArray(sbom.components))
|
|
183
|
+
return [];
|
|
184
|
+
return sbom.components.flatMap((component) => {
|
|
185
|
+
if (!component || typeof component !== "object")
|
|
186
|
+
return [];
|
|
187
|
+
const purl = component.purl;
|
|
188
|
+
return typeof purl === "string"
|
|
189
|
+
? [
|
|
190
|
+
{
|
|
191
|
+
type: "depends_on",
|
|
192
|
+
target: purl,
|
|
193
|
+
line: lineNumber(content, Math.max(content.indexOf(purl), 0)),
|
|
194
|
+
adapter: "cyclonedx-sbom",
|
|
195
|
+
confidence: 1,
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
: [];
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (lower.endsWith(".intoto.jsonl") || lower.includes("provenance")) {
|
|
206
|
+
return matchAll(content, /"uri"[ \t]*:[ \t]*"([^"\r\n]+)"/g, (match) => ({
|
|
207
|
+
type: "generated_from",
|
|
208
|
+
target: match[1] ?? "unknown",
|
|
209
|
+
adapter: "slsa-provenance",
|
|
210
|
+
confidence: 1,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
function sourceFindings(content, repository, knownPaths) {
|
|
216
|
+
const findings = [];
|
|
217
|
+
for (const [knownPath, identity] of knownPaths) {
|
|
218
|
+
for (const match of content.matchAll(/(["'`])([^"'`\n]+)\1/g)) {
|
|
219
|
+
const literal = match[2];
|
|
220
|
+
if (!literal || path.resolve(repository, literal) !== path.resolve(knownPath))
|
|
221
|
+
continue;
|
|
222
|
+
findings.push({
|
|
223
|
+
type: "references_path",
|
|
224
|
+
target: identity,
|
|
225
|
+
line: lineNumber(content, match.index ?? 0),
|
|
226
|
+
adapter: "exact-path-literal",
|
|
227
|
+
confidence: 1,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
findings.push(...matchAll(content, /\b(?:process\.env\.|os\.environ\[["'])([A-Z][A-Z0-9_]{2,})/g, (match) => ({
|
|
232
|
+
type: "reads_config",
|
|
233
|
+
target: `config:${match[1]}`,
|
|
234
|
+
adapter: "configuration-key",
|
|
235
|
+
confidence: 0.85,
|
|
236
|
+
})));
|
|
237
|
+
return findings;
|
|
238
|
+
}
|
|
239
|
+
/** Extract bounded, source-evidenced non-symbol relationships from one checkout. */
|
|
240
|
+
export function extractRepositoryRelationships(repositoryId, repository, knownPaths = new Map()) {
|
|
241
|
+
const relationships = [];
|
|
242
|
+
for (const file of trackedRepositoryFiles(repository)) {
|
|
243
|
+
const absolute = path.join(repository, file);
|
|
244
|
+
let stat;
|
|
245
|
+
try {
|
|
246
|
+
stat = fs.statSync(absolute);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!stat.isFile() || stat.size > MAX_CONTRACT_BYTES)
|
|
252
|
+
continue;
|
|
253
|
+
let content;
|
|
254
|
+
try {
|
|
255
|
+
content = fs.readFileSync(absolute, "utf-8");
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const findings = [
|
|
261
|
+
...packageFindings(file, content),
|
|
262
|
+
...contractFindings(file, content),
|
|
263
|
+
...infrastructureFindings(file, content),
|
|
264
|
+
...provenanceFindings(file, content),
|
|
265
|
+
...sourceFindings(content, repository, knownPaths),
|
|
266
|
+
];
|
|
267
|
+
for (const finding of findings) {
|
|
268
|
+
relationships.push({
|
|
269
|
+
type: finding.type,
|
|
270
|
+
from: repositoryId,
|
|
271
|
+
to: finding.target,
|
|
272
|
+
targetIdentity: finding.target,
|
|
273
|
+
evidence: {
|
|
274
|
+
location: `${file}:${finding.line}`,
|
|
275
|
+
line: finding.line,
|
|
276
|
+
adapter: finding.adapter,
|
|
277
|
+
kind: "extracted",
|
|
278
|
+
confidence: finding.confidence,
|
|
279
|
+
freshness: "current",
|
|
280
|
+
indexGeneration: null,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Byte order over a NUL-joined composite key: collation may ignore the NUL
|
|
286
|
+
// separators entirely, which blurs the field boundaries this key depends on.
|
|
287
|
+
return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.type}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.type}\0${right.to}\0${right.evidence.location}`));
|
|
288
|
+
}
|
|
289
|
+
/** Stream a file hash so duplicate detection never buffers an entire artifact. */
|
|
290
|
+
export async function hashFileStream(file) {
|
|
291
|
+
const digest = crypto.createHash("sha256");
|
|
292
|
+
await new Promise((resolve, reject) => {
|
|
293
|
+
const stream = fs.createReadStream(file);
|
|
294
|
+
stream.on("data", (chunk) => digest.update(chunk));
|
|
295
|
+
stream.on("error", reject);
|
|
296
|
+
stream.on("end", resolve);
|
|
297
|
+
});
|
|
298
|
+
return digest.digest("hex");
|
|
299
|
+
}
|
|
300
|
+
function filesGroupedBySize(repositories) {
|
|
301
|
+
const bySize = new Map();
|
|
302
|
+
const orderedRepositories = [...repositories].sort((left, right) => compareBytes(left.id, right.id));
|
|
303
|
+
for (const repository of orderedRepositories) {
|
|
304
|
+
for (const file of trackedRepositoryFiles(repository.path)) {
|
|
305
|
+
const absolute = path.join(repository.path, file);
|
|
306
|
+
try {
|
|
307
|
+
const stat = fs.statSync(absolute);
|
|
308
|
+
if (!stat.isFile())
|
|
309
|
+
continue;
|
|
310
|
+
const candidates = bySize.get(stat.size) ?? [];
|
|
311
|
+
candidates.push({ repositoryId: repository.id, file, absolute });
|
|
312
|
+
bySize.set(stat.size, candidates);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// A concurrently removed file is absent from this evidence snapshot.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return bySize;
|
|
320
|
+
}
|
|
321
|
+
async function filesGroupedByHash(candidates) {
|
|
322
|
+
const byHash = new Map();
|
|
323
|
+
for (const candidate of candidates) {
|
|
324
|
+
const hash = await hashFileStream(candidate.absolute);
|
|
325
|
+
const matches = byHash.get(hash) ?? [];
|
|
326
|
+
matches.push(candidate);
|
|
327
|
+
byHash.set(hash, matches);
|
|
328
|
+
}
|
|
329
|
+
return byHash;
|
|
330
|
+
}
|
|
331
|
+
function duplicateRelationships(matches) {
|
|
332
|
+
const repositoryIds = new Set(matches.map(({ repositoryId }) => repositoryId));
|
|
333
|
+
if (repositoryIds.size < 2)
|
|
334
|
+
return [];
|
|
335
|
+
// Byte order: `ordered[0]` becomes the canonical side of every emitted
|
|
336
|
+
// duplicate_candidate, so collation would change the relationship direction.
|
|
337
|
+
const ordered = [...matches].sort((left, right) => compareBytes(`${left.repositoryId}\0${left.file}`, `${right.repositoryId}\0${right.file}`));
|
|
338
|
+
const canonical = ordered[0];
|
|
339
|
+
if (!canonical)
|
|
340
|
+
return [];
|
|
341
|
+
return ordered.slice(1).flatMap((duplicate) => {
|
|
342
|
+
if (duplicate.repositoryId === canonical.repositoryId)
|
|
343
|
+
return [];
|
|
344
|
+
return [
|
|
345
|
+
{
|
|
346
|
+
type: "duplicate_candidate",
|
|
347
|
+
from: duplicate.repositoryId,
|
|
348
|
+
to: canonical.repositoryId,
|
|
349
|
+
targetIdentity: canonical.repositoryId,
|
|
350
|
+
evidence: {
|
|
351
|
+
location: duplicate.file,
|
|
352
|
+
adapter: "streaming-sha256",
|
|
353
|
+
kind: "inferred",
|
|
354
|
+
confidence: 1,
|
|
355
|
+
freshness: "current",
|
|
356
|
+
indexGeneration: null,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Detect exact tracked-file matches across repositories. Equality is reported
|
|
364
|
+
* only as a duplicate candidate; it never asserts generated/vendor lineage.
|
|
365
|
+
*/
|
|
366
|
+
export async function detectDuplicateCandidates(repositories) {
|
|
367
|
+
const relationships = [];
|
|
368
|
+
for (const candidates of filesGroupedBySize(repositories).values()) {
|
|
369
|
+
if (candidates.length < 2)
|
|
370
|
+
continue;
|
|
371
|
+
const byHash = await filesGroupedByHash(candidates);
|
|
372
|
+
for (const matches of byHash.values()) {
|
|
373
|
+
relationships.push(...duplicateRelationships(matches));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.to}\0${right.evidence.location}`));
|
|
377
|
+
}
|