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.
Files changed (130) hide show
  1. package/README.md +19 -7
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +2164 -108
  4. package/dist/bin/launcher.js +25 -3
  5. package/dist/src/agent-integration.js +304 -0
  6. package/dist/src/artifact-refresh.js +82 -0
  7. package/dist/src/cli-args.js +292 -0
  8. package/dist/src/cli-model.js +384 -0
  9. package/dist/src/codeflow-replay.js +81 -0
  10. package/dist/src/compact-structural.js +96 -0
  11. package/dist/src/compare.js +39 -0
  12. package/dist/src/competitive-cold-mcp.js +40 -0
  13. package/dist/src/competitive-constraints.js +21 -0
  14. package/dist/src/competitive-manifest.js +411 -0
  15. package/dist/src/competitive-measurement.js +183 -0
  16. package/dist/src/competitive-runner.js +487 -0
  17. package/dist/src/competitive-sandbox.js +108 -0
  18. package/dist/src/context-export.js +423 -0
  19. package/dist/src/context.js +102 -0
  20. package/dist/src/deterministic-random.js +34 -0
  21. package/dist/src/diagnostics-write-helper.js +473 -0
  22. package/dist/src/diagnostics.js +1476 -0
  23. package/dist/src/docs-sections.js +141 -0
  24. package/dist/src/doctor.js +382 -0
  25. package/dist/src/engine/ann-hnsw.js +261 -0
  26. package/dist/src/engine/embeddings.js +193 -0
  27. package/dist/src/engine/file-walker.js +49 -0
  28. package/dist/src/engine/git-history.js +289 -0
  29. package/dist/src/engine/index.js +14238 -0
  30. package/dist/src/engine/perf.js +115 -0
  31. package/dist/src/engine/prune.js +112 -0
  32. package/dist/src/engine/sarif-import.js +341 -0
  33. package/dist/src/engine/scip-import.js +423 -0
  34. package/dist/src/engine/source-policy.js +85 -0
  35. package/dist/src/engine/sqlite.js +71 -0
  36. package/dist/src/engine/state-paths.js +175 -0
  37. package/dist/src/engine/symbol-delete.js +58 -0
  38. package/dist/src/execution-profile.js +208 -0
  39. package/dist/src/failure-diagnosis.js +655 -0
  40. package/dist/src/fleet.js +7 -0
  41. package/dist/src/git-executable.js +31 -0
  42. package/dist/src/graph-layout.js +173 -0
  43. package/dist/src/graph-query-health.js +115 -0
  44. package/dist/src/hook-manager-integration.js +156 -0
  45. package/dist/src/index-activity.js +126 -0
  46. package/dist/src/init-progress-worker.js +106 -2
  47. package/dist/src/init-progress.js +155 -0
  48. package/dist/src/init.js +1295 -0
  49. package/dist/src/lifecycle-health.js +282 -0
  50. package/dist/src/lsp-readonly.js +217 -0
  51. package/dist/src/mcp-graph-worker.js +69 -0
  52. package/dist/src/mcp-reliability.js +154 -0
  53. package/dist/src/mcp-worker-supervisor.js +350 -0
  54. package/dist/src/mirror.js +290 -0
  55. package/dist/src/node-runtime.js +157 -0
  56. package/dist/src/output-compression.js +630 -0
  57. package/dist/src/output-telemetry.js +368 -0
  58. package/dist/src/pr-triage.js +638 -0
  59. package/dist/src/progressive-evidence.js +477 -0
  60. package/dist/src/pure-compression-cli.js +102 -0
  61. package/dist/src/relationship-adapters.js +377 -0
  62. package/dist/src/release-attestation.js +533 -0
  63. package/dist/src/release-preflight.js +513 -0
  64. package/dist/src/repair-lease.js +85 -0
  65. package/dist/src/repair-progress-worker.js +120 -2
  66. package/dist/src/repair-progress.js +262 -0
  67. package/dist/src/repository-init-process.js +177 -0
  68. package/dist/src/repository-management.js +1261 -0
  69. package/dist/src/response-budget.js +196 -0
  70. package/dist/src/server.js +217 -0
  71. package/dist/src/structural-fast-path.js +344 -0
  72. package/dist/src/structural-snapshot.js +37 -0
  73. package/dist/src/system-config.js +638 -0
  74. package/dist/src/terminal-help.js +83 -0
  75. package/dist/src/tools/knodin-tools.js +1640 -0
  76. package/dist/src/update-ceremony.js +162 -0
  77. package/dist/src/update-policy.js +944 -0
  78. package/dist/src/update-trust.js +504 -0
  79. package/dist/src/version.js +13 -0
  80. package/dist/src/visualization.js +515 -0
  81. package/dist/src/wait-for-fresh.js +98 -0
  82. package/dist/src/worktree-lifecycle.js +234 -0
  83. package/docs/BEHAVIORAL-CONTRACT.md +72 -0
  84. package/docs/CLI.md +20 -1
  85. package/docs/COMPARISON.md +403 -0
  86. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  87. package/docs/CONTAINED-EXECUTION.md +77 -0
  88. package/docs/DIAGNOSTICS.md +80 -0
  89. package/docs/GIT-HISTORY-REVIEW.md +39 -0
  90. package/docs/HANDOFF.md +180 -0
  91. package/docs/INSTALLATION.md +21 -18
  92. package/docs/MCP.md +59 -8
  93. package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
  94. package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
  95. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  96. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  97. package/docs/SCIP-IMPORT.md +62 -0
  98. package/docs/SIGNED-UPDATES.md +151 -0
  99. package/docs/TELEMETRY.md +46 -0
  100. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  101. package/docs/assets/knodin-favicon.svg +4 -0
  102. package/docs/releases/0.3.0.md +46 -0
  103. package/docs/releases/0.4.0.md +68 -0
  104. package/docs/releases/0.4.1.md +28 -0
  105. package/docs/releases/0.4.2.md +27 -0
  106. package/docs/releases/0.4.3.md +23 -0
  107. package/docs/releases/0.5.0.md +29 -0
  108. package/docs/releases/0.5.1.md +17 -0
  109. package/docs/releases/0.6.0.md +18 -0
  110. package/docs/releases/0.7.0.md +24 -0
  111. package/docs/releases/0.7.1.md +21 -0
  112. package/docs/releases/0.7.2.md +21 -0
  113. package/docs/releases/0.7.3.md +23 -0
  114. package/docs/releases/0.7.4.md +17 -0
  115. package/docs/releases/0.7.5.md +20 -0
  116. package/docs/releases/0.8.0.md +74 -0
  117. package/docs/releases/0.8.2.md +34 -0
  118. package/package.json +127 -4
  119. package/roadmap/competitive-roadmap.md +3801 -0
  120. package/schemas/release-attestation-v1.schema.json +210 -0
  121. package/schemas/support-bundle-v2.schema.json +212 -0
  122. package/dist/chunks/chunk-DMQAGX77.js +0 -654
  123. package/dist/chunks/chunk-F4Z3Z766.js +0 -4
  124. package/dist/chunks/chunk-SIJAQVSX.js +0 -3
  125. package/dist/chunks/chunk-X6M4HUUE.js +0 -2
  126. package/dist/chunks/chunk-YPRMY2LP.js +0 -8
  127. package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
  128. package/dist/chunks/server-7EDF4CBY.js +0 -14
  129. package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
  130. package/docs/releases/0.7.6.md +0 -25
@@ -0,0 +1,115 @@
1
+ /** Opt-in phase timing used by the R43 benchmark harness. */
2
+ export const PERF_PHASES = [
3
+ "db_open_migration",
4
+ "file_collection",
5
+ "parser_wasm_init",
6
+ "read",
7
+ "parse_extract",
8
+ "sqlite_write",
9
+ "model_load",
10
+ "embedding_inference_write",
11
+ "search_row_fetch_decode",
12
+ "similarity",
13
+ "fts",
14
+ "map_community",
15
+ "fusion",
16
+ "freshness_probe",
17
+ "graph_analytics",
18
+ "traversal_snapshot",
19
+ "architecture_facets",
20
+ "architecture_edge_materialization",
21
+ "context_composition",
22
+ "status_audit",
23
+ "pack_walk",
24
+ "pack_serialize",
25
+ "artifact_read",
26
+ "artifact_grep",
27
+ "review_diff_discovery",
28
+ "review_churn",
29
+ "review_parsing",
30
+ "review_flow_lookup",
31
+ "review_serialization",
32
+ ];
33
+ let active = null;
34
+ function emptyPhases() {
35
+ return Object.fromEntries(PERF_PHASES.map((phase) => [phase, 0]));
36
+ }
37
+ function peakRss() {
38
+ try {
39
+ const kilobytes = process.resourceUsage().maxRSS;
40
+ return Number.isFinite(kilobytes) && kilobytes >= 0 ? kilobytes * 1024 : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Start a measurement explicitly. Starting while active resets that measurement. */
47
+ export function beginPerfSession() {
48
+ active = { startedAt: performance.now(), phases: emptyPhases(), peakRssBytes: peakRss() };
49
+ }
50
+ /** Finish the active measurement, or return null when instrumentation is disabled. */
51
+ export function endPerfSession() {
52
+ const session = active;
53
+ if (!session)
54
+ return null;
55
+ active = null;
56
+ const currentRss = peakRss();
57
+ if (currentRss !== undefined) {
58
+ session.peakRssBytes = Math.max(session.peakRssBytes ?? 0, currentRss);
59
+ }
60
+ return {
61
+ phases: { ...session.phases },
62
+ totalMs: Math.max(0, performance.now() - session.startedAt),
63
+ ...(session.peakRssBytes === undefined ? {} : { peakRssBytes: session.peakRssBytes }),
64
+ };
65
+ }
66
+ function record(phase, startedAt) {
67
+ const session = active;
68
+ if (!session)
69
+ return;
70
+ session.phases[phase] += Math.max(0, performance.now() - startedAt);
71
+ const currentRss = peakRss();
72
+ if (currentRss !== undefined)
73
+ session.peakRssBytes = Math.max(session.peakRssBytes ?? 0, currentRss);
74
+ }
75
+ /** Start a phase that spans an existing try/finally block. The returned closer is idempotent. */
76
+ export function beginPerfPhase(phase) {
77
+ if (!active)
78
+ return () => undefined;
79
+ const startedAt = performance.now();
80
+ let closed = false;
81
+ return () => {
82
+ if (closed)
83
+ return;
84
+ closed = true;
85
+ record(phase, startedAt);
86
+ };
87
+ }
88
+ /** Time an async phase. Nested phases are retained independently and errors still close timers. */
89
+ export async function measurePerfPhase(phase, work) {
90
+ if (!active)
91
+ return work();
92
+ const startedAt = performance.now();
93
+ try {
94
+ return await work();
95
+ }
96
+ finally {
97
+ record(phase, startedAt);
98
+ }
99
+ }
100
+ /** Synchronous counterpart for filesystem, parsing, and SQLite calls. */
101
+ export function measurePerfPhaseSync(phase, work) {
102
+ if (!active)
103
+ return work();
104
+ const startedAt = performance.now();
105
+ try {
106
+ return work();
107
+ }
108
+ finally {
109
+ record(phase, startedAt);
110
+ }
111
+ }
112
+ /** Test-only state reset; deliberately does not enable timing. */
113
+ export function resetPerfForTests() {
114
+ active = null;
115
+ }
@@ -0,0 +1,112 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Single source of truth for "which directories does knodin ignore" (R14).
4
+ *
5
+ * Before this module, the same intent — skip node_modules, .git, .knodin,
6
+ * build output, caches, VCS internals — was encoded THREE separate times:
7
+ * 1. src/__tests__/setup.ts's `PRUNE_DIRS` regex (glob-shim exclude filter)
8
+ * 2. the indexer's own directory filter (`EXCLUDED_DIR_SEGMENTS` / `isIndexablePath`)
9
+ * 3. the chokidar watcher's `makeWatchIgnorePredicate` (added by R13)
10
+ *
11
+ * They drifted. R13's bug was exactly this shape: the watcher's copy silently
12
+ * stopped matching anything for an entire chokidar major version while the
13
+ * indexer's copy kept working, so indexing looked correct and only the
14
+ * watcher melted (it registered ~12.6k files and starved the event loop under
15
+ * Bun). This module is the ONE list; every consumer below is a thin adapter
16
+ * over it, so a future drift is structurally impossible rather than merely
17
+ * discouraged.
18
+ */
19
+ /**
20
+ * Directory segments never worth indexing or watching: build output, deps,
21
+ * VCS/tool state, caches. Matched by whole path SEGMENT, never substring, so
22
+ * a real file/dir like `distributor.ts` or `build-graph.ts` is never wrongly
23
+ * pruned.
24
+ */
25
+ export const PRUNE_DIR_NAMES = new Set([
26
+ "node_modules",
27
+ ".git",
28
+ ".knodin",
29
+ // Local state maintained by other code-intelligence tools. It is neither
30
+ // repository source nor stable project configuration, and indexing it makes
31
+ // a healthy checkout look perpetually stale as those tools update databases
32
+ // and caches in the background.
33
+ ".gitnexus",
34
+ ".codegraph",
35
+ ".code-review-graph",
36
+ ".aider.tags.cache.v4",
37
+ "dist",
38
+ "build",
39
+ "coverage",
40
+ ".next",
41
+ ".turbo",
42
+ ".svelte-kit",
43
+ "vendor",
44
+ "__pycache__",
45
+ ]);
46
+ /** True when a single path segment (one directory name) is a pruned directory. */
47
+ export function isPrunedSegment(segment) {
48
+ return PRUNE_DIR_NAMES.has(segment);
49
+ }
50
+ /**
51
+ * Agent worktrees are checkouts of this same repo living under
52
+ * `.claude/worktrees/*`; indexing/watching them would double-count every
53
+ * symbol. `segments` is the full split relative path; `i` is the segment
54
+ * index currently being tested against the two-segment `.claude/worktrees`
55
+ * pattern.
56
+ */
57
+ function isClaudeWorktreeSegment(segments, i) {
58
+ return segments[i] === ".claude" && segments[i + 1] === "worktrees";
59
+ }
60
+ /**
61
+ * True when ANY segment of an already-split repo-relative path trips the
62
+ * ignore policy (a pruned directory name, or the `.claude/worktrees` root).
63
+ * This is the one walk both `isIndexablePath` (indexer) and
64
+ * `makeWatchIgnorePredicate` (watcher) run — the code that used to be
65
+ * duplicated between them.
66
+ */
67
+ export function pathSegmentsAreIgnored(segments) {
68
+ for (let i = 0; i < segments.length; i++) {
69
+ if (isPrunedSegment(segments[i]))
70
+ return true;
71
+ if (isClaudeWorktreeSegment(segments, i))
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Indexer adapter: true when a repo-relative path (either OS separator)
78
+ * should be indexed.
79
+ */
80
+ export function isIndexablePath(relPath) {
81
+ return !pathSegmentsAreIgnored(relPath.split(/[/\\]/));
82
+ }
83
+ /**
84
+ * Watcher adapter: builds chokidar's `ignored` predicate for a given repo
85
+ * root.
86
+ *
87
+ * chokidar v4 removed glob support; v5 accepts only a function, a regex, or a
88
+ * literal path. Matching here is done on the path RELATIVE to the repo, so a
89
+ * repo that happens to live under a directory named e.g. `build/` does not
90
+ * prune its own root (the R13 fix this preserves).
91
+ */
92
+ export function makeWatchIgnorePredicate(repoPath) {
93
+ return (targetPath) => {
94
+ const relative = path.relative(repoPath, targetPath);
95
+ // The watch root itself ("") and anything outside it are never pruned.
96
+ if (relative === "" || relative.startsWith(".."))
97
+ return false;
98
+ return pathSegmentsAreIgnored(relative.split(path.sep));
99
+ };
100
+ }
101
+ /**
102
+ * Policy-test adapter: a regex equivalent to the segment-based policy above.
103
+ * Anchored on `/` boundaries so it only matches whole segments — same guarantee
104
+ * as `isPrunedSegment` — never a substring inside an unrelated name.
105
+ */
106
+ export function makePruneDirsRegex() {
107
+ const escaped = [...PRUNE_DIR_NAMES].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
108
+ // `.claude/worktrees` is a two-segment pattern, expressed directly rather
109
+ // than through the single-segment name list.
110
+ escaped.push("\\.claude/worktrees");
111
+ return new RegExp(`(?:^|/)(?:${escaped.join("|")})(?:/|$)`);
112
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Bounded SARIF import.
3
+ *
4
+ * SARIF (OASIS "Static Analysis Results Interchange Format") is what most
5
+ * analyzers can emit — Salesforce Code Analyzer, ESLint, Semgrep, CodeQL, PMD,
6
+ * Trivy. Importing the format rather than any one tool's native output means one
7
+ * reader covers all of them.
8
+ *
9
+ * Findings are NOT graph facts. knodin resolves references from source; a
10
+ * finding is somebody else's judgement about a location. It is stored in its own
11
+ * table, tagged with the tool that produced it, and never merged into symbols or
12
+ * references. What the graph adds is the join: a finding on a hub matters more
13
+ * than the same finding on a leaf, and only the graph knows which is which.
14
+ *
15
+ * Everything here is bounded and fail-closed, matching the SCIP importer: a file
16
+ * over the byte ceiling, a malformed document, or a finding count over the cap
17
+ * is refused rather than partially applied.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { compareBytes } from "../compare.js";
22
+ // Calibrated against real output rather than guessed: one Salesforce Code
23
+ // Analyzer run over a mid-sized org (pmd + regex + retire-js) produced a 97 MB
24
+ // SARIF log with ~75k findings. A 64 MB ceiling would have refused it.
25
+ export const SARIF_DEFAULT_LIMITS = {
26
+ maxBytes: 256 * 1024 * 1024,
27
+ maxFindings: 250_000,
28
+ timeoutMs: 60_000,
29
+ };
30
+ /**
31
+ * The ceiling knodin cannot raise. `JSON.parse` needs the document as a single
32
+ * string and V8 caps string length at 2^29 - 24 bytes on 64-bit, so a log past
33
+ * this size cannot be read whole regardless of the configured limit. Kept
34
+ * slightly under the hard maximum to leave room for decoding overhead.
35
+ */
36
+ const MAX_PARSEABLE_BYTES = 512 * 1024 * 1024;
37
+ /** Byte counts appear in operator-facing errors, so render them readably. */
38
+ function formatBytes(value) {
39
+ if (value < 1024)
40
+ return `${value} bytes`;
41
+ const units = ["KiB", "MiB", "GiB"];
42
+ let scaled = value / 1024;
43
+ let unit = 0;
44
+ while (scaled >= 1024 && unit < units.length - 1) {
45
+ scaled /= 1024;
46
+ unit++;
47
+ }
48
+ return `${scaled.toFixed(1)} ${units[unit]}`;
49
+ }
50
+ /** SARIF level is optional; analyzers often carry severity on the rule instead. */
51
+ function normalizeLevel(value) {
52
+ if (value === "error" || value === "warning" || value === "note" || value === "none")
53
+ return value;
54
+ // SARIF's own default when level is absent and no rule default applies.
55
+ return "warning";
56
+ }
57
+ /**
58
+ * Resolves a SARIF artifact location to a repository-relative POSIX path.
59
+ *
60
+ * SARIF permits `file:///abs/path`, a bare absolute path, or a path relative to
61
+ * some base URI. Anything landing outside the repository is rejected: a findings
62
+ * file is external input, and a `../` escape must not attach a finding to a path
63
+ * the caller never indexed.
64
+ */
65
+ function resolveArtifactPath(repoRoot, uri, uriBase) {
66
+ if (!uri)
67
+ return null;
68
+ let raw = uri;
69
+ if (raw.startsWith("file://")) {
70
+ try {
71
+ raw = decodeURIComponent(new URL(raw).pathname);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ else {
78
+ try {
79
+ raw = decodeURIComponent(raw);
80
+ }
81
+ catch {
82
+ // Not percent-encoded; use as-is.
83
+ }
84
+ }
85
+ // A relative uri may be anchored by an originalUriBaseId value.
86
+ const anchor = uriBase && path.isAbsolute(uriBase) ? uriBase : repoRoot;
87
+ const candidate = path.isAbsolute(raw) ? raw : path.resolve(anchor, raw);
88
+ // Compare real paths. The analyzer may have recorded a route through a
89
+ // symlink — /var vs /private/var on macOS, or a symlinked checkout — and a
90
+ // textual prefix check would reject those valid locations. Resolving also
91
+ // means a symlink pointing outside the repository is still caught, because
92
+ // the real path is what gets compared.
93
+ let resolved = path.resolve(candidate);
94
+ try {
95
+ resolved = fs.realpathSync(resolved);
96
+ }
97
+ catch {
98
+ // Not on disk: the analyzer may report a file since deleted. Keep the
99
+ // lexically resolved path so containment is still enforced below.
100
+ }
101
+ if (resolved !== repoRoot && !resolved.startsWith(`${repoRoot}${path.sep}`))
102
+ return null;
103
+ const relative = path.relative(repoRoot, resolved);
104
+ if (!relative || relative.startsWith(".."))
105
+ return null;
106
+ return relative.split(path.sep).join("/");
107
+ }
108
+ /** Collects `originalUriBaseId` -> absolute path, used to anchor relative uris. */
109
+ function readUriBases(run) {
110
+ const bases = new Map();
111
+ const raw = run.originalUriBaseIds;
112
+ if (!raw || typeof raw !== "object")
113
+ return bases;
114
+ for (const [id, entry] of Object.entries(raw)) {
115
+ const uri = entry?.uri;
116
+ if (typeof uri !== "string")
117
+ continue;
118
+ let value = uri;
119
+ if (value.startsWith("file://")) {
120
+ try {
121
+ value = decodeURIComponent(new URL(value).pathname);
122
+ }
123
+ catch {
124
+ continue;
125
+ }
126
+ }
127
+ bases.set(id, value);
128
+ }
129
+ return bases;
130
+ }
131
+ /** Rule metadata by id, so a finding can inherit a helpUri it does not carry. */
132
+ function readRuleHelp(run) {
133
+ const help = new Map();
134
+ const driver = run.tool?.driver;
135
+ const rules = driver?.rules;
136
+ if (!Array.isArray(rules))
137
+ return help;
138
+ for (const rule of rules) {
139
+ const id = rule.id;
140
+ const uri = rule.helpUri;
141
+ if (typeof id === "string" && typeof uri === "string")
142
+ help.set(id, uri);
143
+ }
144
+ return help;
145
+ }
146
+ /** Stable order so repeated imports of an unchanged log agree exactly. */
147
+ function compareFindings(left, right) {
148
+ if (left.filePath !== right.filePath)
149
+ return compareBytes(left.filePath, right.filePath);
150
+ if (left.startLine !== right.startLine)
151
+ return left.startLine - right.startLine;
152
+ return compareBytes(left.ruleId, right.ruleId);
153
+ }
154
+ /**
155
+ * Builds one finding from a SARIF result, or null when the result cannot be
156
+ * placed inside the repository. The caller counts those rather than guessing
157
+ * at a path.
158
+ */
159
+ function readResult(result, context) {
160
+ const location = Array.isArray(result.locations) ? result.locations[0] : undefined;
161
+ const physical = location
162
+ ?.physicalLocation;
163
+ const artifact = physical?.artifactLocation;
164
+ const uri = typeof artifact?.uri === "string" ? artifact.uri : "";
165
+ const baseId = typeof artifact?.uriBaseId === "string" ? artifact.uriBaseId : undefined;
166
+ // Spec says uriBaseId is a key into originalUriBaseIds. Salesforce Code
167
+ // Analyzer instead puts the absolute workspace path there directly and
168
+ // omits originalUriBaseIds entirely, so accept either form.
169
+ const base = baseId ? (context.uriBases.get(baseId) ?? baseId) : undefined;
170
+ const filePath = resolveArtifactPath(context.repoRoot, uri, base);
171
+ if (!filePath)
172
+ return null;
173
+ const region = (physical?.region ?? {});
174
+ const startLine = Number.isInteger(region.startLine) ? region.startLine : 1;
175
+ const startColumn = Number.isInteger(region.startColumn) ? region.startColumn : 1;
176
+ const ruleIdRaw = result.ruleId;
177
+ const ruleId = typeof ruleIdRaw === "string" && ruleIdRaw ? ruleIdRaw : "unknown-rule";
178
+ const messageText = result.message?.text;
179
+ const helpUri = context.ruleHelp.get(ruleId);
180
+ return {
181
+ ruleId,
182
+ tool: context.tool,
183
+ level: normalizeLevel(result.level),
184
+ message: typeof messageText === "string" ? messageText : "",
185
+ filePath,
186
+ startLine,
187
+ startColumn,
188
+ // SARIF omits end positions for single-point results; collapse to start.
189
+ endLine: Number.isInteger(region.endLine) ? region.endLine : startLine,
190
+ endColumn: Number.isInteger(region.endColumn) ? region.endColumn : startColumn,
191
+ ...(helpUri ? { helpUri } : {}),
192
+ };
193
+ }
194
+ /**
195
+ * Validates the input against the size ceilings and parses it, returning the
196
+ * SARIF runs. Every refusal names the flag and a value that would admit the
197
+ * input, because a ceiling the operator cannot move is just a failure.
198
+ */
199
+ function readRuns(displayPath, inputPath, size, maxBytes) {
200
+ if (size > maxBytes)
201
+ throw new Error(`knodin index --sarif: ${displayPath} is ${formatBytes(size)}, over the ` +
202
+ `${formatBytes(maxBytes)} ceiling. Raise it with ` +
203
+ `--sarif-max-bytes ${size}, or narrow the analysis that produced the log.`);
204
+ // A ceiling knodin cannot raise. JSON.parse needs the whole document as one
205
+ // string, and V8 refuses strings beyond roughly 512 MiB, so a larger log
206
+ // fails inside readFileSync no matter what --sarif-max-bytes says. Saying so
207
+ // is more useful than letting "Invalid string length" surface.
208
+ if (size > MAX_PARSEABLE_BYTES)
209
+ throw new Error(`knodin index --sarif: ${displayPath} is ${formatBytes(size)}, beyond the ` +
210
+ `${formatBytes(MAX_PARSEABLE_BYTES)} limit this runtime can parse as one document. ` +
211
+ `Raising --sarif-max-bytes will not help. Split the analysis by rule selector or ` +
212
+ `workspace and import each log, which is additive across tools.`);
213
+ let document;
214
+ try {
215
+ document = JSON.parse(fs.readFileSync(inputPath, "utf8"));
216
+ }
217
+ catch (error) {
218
+ const detail = error instanceof Error ? error.message : String(error);
219
+ // A resource failure is not a malformed document, and reporting it as one
220
+ // sends the reader to inspect a file that is probably fine.
221
+ if (error instanceof RangeError || /heap|memory|string length/i.test(detail))
222
+ throw new Error(`knodin index --sarif: ran out of memory reading ${displayPath} ` +
223
+ `(${formatBytes(size)}). The document is held whole while parsing, so this is a ` +
224
+ `runtime limit rather than a knodin one. Retry with a larger heap, for example ` +
225
+ `NODE_OPTIONS=--max-old-space-size=8192, or split the analysis into several logs.`);
226
+ throw new Error(`knodin index --sarif: ${displayPath} is not valid JSON (${detail})`);
227
+ }
228
+ const runs = document?.runs;
229
+ if (!Array.isArray(runs))
230
+ throw new Error("knodin index --sarif: input has no runs array; not a SARIF log");
231
+ return runs;
232
+ }
233
+ /** Applies the defaults for any ceiling the caller left unset. */
234
+ function resolveLimits(options) {
235
+ return {
236
+ maxBytes: options.maxBytes ?? SARIF_DEFAULT_LIMITS.maxBytes,
237
+ maxFindings: options.maxFindings ?? SARIF_DEFAULT_LIMITS.maxFindings,
238
+ timeoutMs: options.timeoutMs ?? SARIF_DEFAULT_LIMITS.timeoutMs,
239
+ };
240
+ }
241
+ /**
242
+ * Reads one SARIF run into findings, or null when the value is not a run that
243
+ * carries results at all.
244
+ *
245
+ * `skipped` counts results whose location could not be placed inside the
246
+ * repository; the caller reports that total rather than discarding it.
247
+ *
248
+ * `budget.carried` is how many findings earlier runs already contributed, so
249
+ * the ceiling can be enforced against the running total from inside the loop.
250
+ * It is checked on every finding rather than once per run because the ceiling
251
+ * exists to stop the work, not to report on it afterwards: a SARIF log commonly
252
+ * holds exactly one run, so a per-run check would only fire once the entire log
253
+ * was already accumulated — the case the ceiling is there to prevent.
254
+ */
255
+ function readRun(rawRun, repoRoot, budget) {
256
+ if (!rawRun || typeof rawRun !== "object")
257
+ return null;
258
+ const run = rawRun;
259
+ const results = run.results;
260
+ if (!Array.isArray(results))
261
+ return null;
262
+ const driverName = run.tool?.driver?.name;
263
+ const tool = typeof driverName === "string" && driverName ? driverName : "unknown";
264
+ const context = {
265
+ repoRoot,
266
+ tool,
267
+ uriBases: readUriBases(run),
268
+ ruleHelp: readRuleHelp(run),
269
+ };
270
+ const findings = [];
271
+ let skipped = 0;
272
+ for (const rawResult of results) {
273
+ if (!rawResult || typeof rawResult !== "object")
274
+ continue;
275
+ const finding = readResult(rawResult, context);
276
+ // A finding we cannot place inside the repository is counted, not guessed at.
277
+ if (!finding) {
278
+ skipped++;
279
+ continue;
280
+ }
281
+ findings.push(finding);
282
+ if (budget.carried + findings.length > budget.maxFindings)
283
+ throw new Error(`knodin index --sarif: more than ${budget.maxFindings.toLocaleString()} findings. ` +
284
+ `Raise it with --sarif-max-findings, or narrow the analysis that produced the log.`);
285
+ }
286
+ return { tool, findings, skipped };
287
+ }
288
+ /**
289
+ * Reads and validates a SARIF log, returning findings resolved against the
290
+ * repository. Throws rather than returning partial facts.
291
+ */
292
+ export function readSarifLog(repoPath, options) {
293
+ const started = Date.now();
294
+ const limits = resolveLimits(options);
295
+ const repoRoot = fs.realpathSync(path.resolve(repoPath));
296
+ const inputPath = path.resolve(options.path);
297
+ const stat = fs.statSync(inputPath, { throwIfNoEntry: false });
298
+ if (!stat?.isFile())
299
+ throw new Error(`knodin index --sarif: ${options.path} is not a file`);
300
+ const runs = readRuns(options.path, inputPath, stat.size, limits.maxBytes);
301
+ const findings = [];
302
+ const tools = new Set();
303
+ const rules = new Set();
304
+ const files = new Set();
305
+ let skippedUnresolved = 0;
306
+ for (const rawRun of runs) {
307
+ if (Date.now() - started > limits.timeoutMs)
308
+ throw new Error(`knodin index --sarif: exceeded the ${limits.timeoutMs}ms read budget. ` +
309
+ `Raise it with --sarif-timeout-ms.`);
310
+ // readRun enforces the findings ceiling against this running total as it
311
+ // reads, so it throws on the offending finding rather than after the run.
312
+ const run = readRun(rawRun, repoRoot, {
313
+ carried: findings.length,
314
+ maxFindings: limits.maxFindings,
315
+ });
316
+ if (!run)
317
+ continue;
318
+ skippedUnresolved += run.skipped;
319
+ // A tool is only recorded once it has placed a finding, so a run that
320
+ // resolved nothing does not advertise coverage it did not deliver.
321
+ if (run.findings.length === 0)
322
+ continue;
323
+ tools.add(run.tool);
324
+ for (const finding of run.findings) {
325
+ findings.push(finding);
326
+ rules.add(finding.ruleId);
327
+ files.add(finding.filePath);
328
+ }
329
+ }
330
+ findings.sort(compareFindings);
331
+ return {
332
+ inputPath,
333
+ bytes: stat.size,
334
+ tools: [...tools].sort(compareBytes),
335
+ rules: rules.size,
336
+ files: files.size,
337
+ findings,
338
+ skippedUnresolved,
339
+ elapsedMs: Date.now() - started,
340
+ };
341
+ }