@curiousnerd/keel 0.1.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/data/capability-buckets.json +15 -0
  4. package/dist/analyze/docDrift.d.ts +9 -0
  5. package/dist/analyze/docDrift.js +116 -0
  6. package/dist/analyze/docDrift.js.map +1 -0
  7. package/dist/analyze/drift.d.ts +4 -0
  8. package/dist/analyze/drift.js +134 -0
  9. package/dist/analyze/drift.js.map +1 -0
  10. package/dist/analyze/duplication.d.ts +7 -0
  11. package/dist/analyze/duplication.js +46 -0
  12. package/dist/analyze/duplication.js.map +1 -0
  13. package/dist/analyze/index.d.ts +10 -0
  14. package/dist/analyze/index.js +28 -0
  15. package/dist/analyze/index.js.map +1 -0
  16. package/dist/analyze/libConflicts.d.ts +9 -0
  17. package/dist/analyze/libConflicts.js +36 -0
  18. package/dist/analyze/libConflicts.js.map +1 -0
  19. package/dist/analyze/nearDup.d.ts +11 -0
  20. package/dist/analyze/nearDup.js +67 -0
  21. package/dist/analyze/nearDup.js.map +1 -0
  22. package/dist/analyze/score.d.ts +6 -0
  23. package/dist/analyze/score.js +39 -0
  24. package/dist/analyze/score.js.map +1 -0
  25. package/dist/analyze/shared.d.ts +19 -0
  26. package/dist/analyze/shared.js +53 -0
  27. package/dist/analyze/shared.js.map +1 -0
  28. package/dist/cache/hashCache.d.ts +19 -0
  29. package/dist/cache/hashCache.js +49 -0
  30. package/dist/cache/hashCache.js.map +1 -0
  31. package/dist/claims/parseBlock.d.ts +4 -0
  32. package/dist/claims/parseBlock.js +66 -0
  33. package/dist/claims/parseBlock.js.map +1 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +136 -0
  36. package/dist/cli.js.map +1 -0
  37. package/dist/config.d.ts +32 -0
  38. package/dist/config.js +37 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/extract/imports.d.ts +12 -0
  41. package/dist/extract/imports.js +74 -0
  42. package/dist/extract/imports.js.map +1 -0
  43. package/dist/extract/index.d.ts +24 -0
  44. package/dist/extract/index.js +117 -0
  45. package/dist/extract/index.js.map +1 -0
  46. package/dist/extract/language.d.ts +3 -0
  47. package/dist/extract/language.js +13 -0
  48. package/dist/extract/language.js.map +1 -0
  49. package/dist/extract/naming.d.ts +11 -0
  50. package/dist/extract/naming.js +57 -0
  51. package/dist/extract/naming.js.map +1 -0
  52. package/dist/extract/packageJson.d.ts +3 -0
  53. package/dist/extract/packageJson.js +43 -0
  54. package/dist/extract/packageJson.js.map +1 -0
  55. package/dist/extract/python.d.ts +11 -0
  56. package/dist/extract/python.js +244 -0
  57. package/dist/extract/python.js.map +1 -0
  58. package/dist/extract/scan.d.ts +12 -0
  59. package/dist/extract/scan.js +16 -0
  60. package/dist/extract/scan.js.map +1 -0
  61. package/dist/extract/symbols.d.ts +9 -0
  62. package/dist/extract/symbols.js +120 -0
  63. package/dist/extract/symbols.js.map +1 -0
  64. package/dist/extract/walk.d.ts +10 -0
  65. package/dist/extract/walk.js +115 -0
  66. package/dist/extract/walk.js.map +1 -0
  67. package/dist/llm/cache.d.ts +17 -0
  68. package/dist/llm/cache.js +50 -0
  69. package/dist/llm/cache.js.map +1 -0
  70. package/dist/llm/claimsFromDocs.d.ts +16 -0
  71. package/dist/llm/claimsFromDocs.js +95 -0
  72. package/dist/llm/claimsFromDocs.js.map +1 -0
  73. package/dist/llm/explain.d.ts +10 -0
  74. package/dist/llm/explain.js +63 -0
  75. package/dist/llm/explain.js.map +1 -0
  76. package/dist/llm/improve.d.ts +9 -0
  77. package/dist/llm/improve.js +37 -0
  78. package/dist/llm/improve.js.map +1 -0
  79. package/dist/llm/provider.d.ts +24 -0
  80. package/dist/llm/provider.js +210 -0
  81. package/dist/llm/provider.js.map +1 -0
  82. package/dist/mcp/server.d.ts +7 -0
  83. package/dist/mcp/server.js +43 -0
  84. package/dist/mcp/server.js.map +1 -0
  85. package/dist/mcp/tools.d.ts +9 -0
  86. package/dist/mcp/tools.js +173 -0
  87. package/dist/mcp/tools.js.map +1 -0
  88. package/dist/report/json.d.ts +3 -0
  89. package/dist/report/json.js +5 -0
  90. package/dist/report/json.js.map +1 -0
  91. package/dist/report/markdown.d.ts +9 -0
  92. package/dist/report/markdown.js +97 -0
  93. package/dist/report/markdown.js.map +1 -0
  94. package/dist/report/text.d.ts +11 -0
  95. package/dist/report/text.js +76 -0
  96. package/dist/report/text.js.map +1 -0
  97. package/dist/suppress.d.ts +22 -0
  98. package/dist/suppress.js +80 -0
  99. package/dist/suppress.js.map +1 -0
  100. package/dist/types.d.ts +144 -0
  101. package/dist/types.js +9 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/util/fingerprint.d.ts +12 -0
  104. package/dist/util/fingerprint.js +60 -0
  105. package/dist/util/fingerprint.js.map +1 -0
  106. package/dist/util/hash.d.ts +4 -0
  107. package/dist/util/hash.js +15 -0
  108. package/dist/util/hash.js.map +1 -0
  109. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aditya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # keel
2
+
3
+ **A small tool that keeps your AI coding assistant honest.**
4
+
5
+ When you build with an AI agent (Claude Code, Cursor, Copilot…), it forgets things
6
+ between sessions. It picks a different library than the one you agreed on. It writes
7
+ the same helper function twice under two names. It does the opposite of what your
8
+ `CLAUDE.md` says. You often can't see it, because you didn't write the code.
9
+
10
+ keel reads your code and points out exactly where this has happened:
11
+
12
+ > Your `CLAUDE.md` says you use **Zod**. Your code imports **Yup**. You have
13
+ > `formatDate` written twice. You're fetching HTTP with both **axios** and **ky**.
14
+ > **keel finds this in milliseconds, for $0** — no AI, no cloud, no API key.
15
+
16
+ It gives you one number — a **Coherence Score** out of 100 — plus a short list of
17
+ exactly what's wrong and where.
18
+
19
+ ---
20
+
21
+ ## What it catches
22
+
23
+ | | What it means | Example |
24
+ |---|---|---|
25
+ | **Drift** | Your notes say one thing, the code does another | "Says Zod, but 7 files import Yup" |
26
+ | **Library conflicts** | Two libraries doing the same job | "axios in 4 files, ky in 1 — pick one" |
27
+ | **Exact duplicates** | The same function written more than once | "`formatDate` and `dateToString` are identical" |
28
+ | **Near duplicates** | Almost-the-same function copy-pasted and tweaked | "`buildSettingsA` is ~91% similar to `buildSettingsB`" |
29
+ | **Doc drift** | Your docs reference files that no longer exist | "`docs/api.md` links to `src/old-parser.ts` — it's gone" |
30
+
31
+ Everything is computed by reading the code directly — no AI model is involved, so the
32
+ results are exact, repeatable, and free.
33
+
34
+ ---
35
+
36
+ ## What you'll see
37
+
38
+ ```
39
+ $ keel check
40
+
41
+ Coherence Score: 84 / 100
42
+
43
+ Drift -7
44
+ Library conflicts -5
45
+ Duplication -4
46
+
47
+ Findings
48
+ ✗ DRIFT CLAUDE.md: "validation: Zod" — but code imports yup (1 file)
49
+ ✗ CONFLICT http: axios (1 file), ky (1 file)
50
+ ✗ DUP formatDate / dateToString are identical implementations
51
+ ~ DUP~ buildSettingsA() ~91% similar to buildSettingsB()
52
+
53
+ scanned 5 files · 3.9ms · $0.00
54
+ ```
55
+
56
+ Loud findings (`✗`) are high-confidence. Gentle findings (`~`) are advisory — they
57
+ might be intentional. keel **never blocks your work** unless you explicitly ask it to.
58
+
59
+ The terminal shows a capped summary. For the **complete** picture — every finding with its
60
+ exact location and cause, plus a prioritized "how to improve" — write a Markdown report:
61
+
62
+ ```bash
63
+ keel check --output-md # writes keel-report.md
64
+ keel check --llm --output-md # + plain-English explanations and an AI improvement plan
65
+ ```
66
+
67
+ The report is written to where you run keel, never into the scanned repo.
68
+
69
+ ---
70
+
71
+ ## Getting started
72
+
73
+ You'll need [Node.js](https://nodejs.org) version 20 or newer.
74
+
75
+ ```bash
76
+ npm install -g @curiousnerd/keel
77
+ keel check /path/to/your/project # any JavaScript, TypeScript, or Python project
78
+ ```
79
+
80
+ That's it. Run it again any time — it remembers what it already scanned, so repeat
81
+ runs are nearly instant.
82
+
83
+ **From source** (for contributing):
84
+
85
+ ```bash
86
+ git clone <this-repo> keel && cd keel
87
+ npm install
88
+ npm run build
89
+ node dist/cli.js check /path/to/your/project
90
+ ```
91
+
92
+ ### Commands and options
93
+
94
+ ```bash
95
+ keel check [path] # scan a project (defaults to the current folder)
96
+ ```
97
+
98
+ | Option | What it does |
99
+ |---|---|
100
+ | `-v`, `--verbose` | Show extra detail under each finding (file + line). |
101
+ | `--json` | Print the result as JSON, for scripts or CI. |
102
+ | `--output-md [file]` | Write a **full** Markdown report (every finding + locations + causes + how-to-improve) to a file (default `keel-report.md`). |
103
+ | `--limit <n>` | Max findings to print in text mode (default 25). |
104
+ | `--no-cache` | Don't read or write the `.keel` cache — a fully read-only run. |
105
+ | `--no-gitignore` | Scan files even if they're listed in `.gitignore`. |
106
+ | `--no-python` | Skip Python files (don't load the Python parser). |
107
+ | `--llm` | Add plain-language explanations to findings (off by default — see below). |
108
+ | `--fail-under <n>` | Exit with an error if the score is below `<n>`. Off by default. |
109
+ | `--facts` | Print the raw data keel extracted (for debugging). |
110
+
111
+ The score is **graduated**: each category (drift, conflicts, duplication) adds up but
112
+ eases off as problems pile up, so a repo with 30 duplicates scores low without instantly
113
+ hitting zero.
114
+
115
+ keel scans only the code you actually maintain. It **respects your `.gitignore`** (root
116
+ and nested), and on top of that skips generated/minified files (bundles, Prisma runtimes,
117
+ etc.) and `node_modules`, `dist`, `build`, and similar. Duplicate detection only considers
118
+ *named* functions, so inline callbacks don't create noise.
119
+
120
+ ---
121
+
122
+ ## Telling keel about your decisions
123
+
124
+ To catch **drift**, keel needs to know what you decided. Add a short block to your
125
+ `CLAUDE.md` or `AGENTS.md` (keel reads both):
126
+
127
+ ```markdown
128
+ ## Stack
129
+ - Validation: Zod
130
+ - Database: PostgreSQL
131
+ - Package manager: pnpm
132
+ - Naming: camelCase
133
+
134
+ ## Constraints
135
+ - never use `any`
136
+ ```
137
+
138
+ keel checks each line it understands against the real code. Lines it can't check, it
139
+ simply ignores — it never guesses.
140
+
141
+ ---
142
+
143
+ ## Silencing a finding you don't care about
144
+
145
+ False alarms are the fastest way to make a tool annoying, so keel makes them easy to
146
+ silence — three ways, from most specific to broadest:
147
+
148
+ **1. A comment in the code** (best for an intentional duplicate):
149
+
150
+ ```ts
151
+ // keel-ignore: this copy is intentional
152
+ export function dateToString(value: Date): string { /* ... */ }
153
+ ```
154
+
155
+ **2. A `.keelignore` file** in your project root:
156
+
157
+ ```
158
+ # We're mid-migration from axios to ky — don't flag it yet.
159
+ bucket:http
160
+
161
+ # Don't look at old code.
162
+ src/legacy/**
163
+ ```
164
+
165
+ **3. The config file** `keel.config.json`:
166
+
167
+ ```json
168
+ {
169
+ "duplication": {
170
+ "minTokens": 20,
171
+ "near": { "enabled": true, "threshold": 0.85 }
172
+ },
173
+ "libConflicts": { "ignoreBuckets": ["test"] }
174
+ }
175
+ ```
176
+
177
+ - `minTokens` — ignore functions smaller than this (avoids flagging tiny one-liners).
178
+ - `near.threshold` — how similar (0–1) two functions must be to count as near-duplicates.
179
+ Higher = stricter. `0.85` is the conservative default.
180
+ - `ignoreBuckets` — capability groups (like `http`, `date`) to never flag.
181
+
182
+ ---
183
+
184
+ ## Why no AI?
185
+
186
+ It would seem natural for a tool like this to *use* an AI model. It deliberately
187
+ doesn't. A study from ETH Zurich found that AI-**generated** context files actually
188
+ made coding agents *worse* — lower success rate, higher cost. So keel does the
189
+ opposite: it reads the real code with plain static analysis. The answers are exact and
190
+ cost nothing.
191
+
192
+ The *detection* is always deterministic. `keel check --llm` adds two **opt-in** extras:
193
+
194
+ 1. A one-line plain-English **explanation** under each finding.
195
+ 2. **Doc-claim drift** — it reads your README/CLAUDE.md *prose* (not just a structured
196
+ block), extracts the stack you say you use ("validation: Zod", "database: Postgres"),
197
+ and checks it against the code. Here the LLM only turns English into a claim — the same
198
+ **deterministic** drift engine does the verifying, so **the score never depends on the
199
+ LLM's judgement** and stays reproducible whether the LLM is on or off. By default it shells out to the `claude` or `codex` CLI you already
200
+ have (no API key, no separate bill); set `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` to use an
201
+ API instead. Responses are cached, and if nothing's available keel just skips them.
202
+
203
+ ---
204
+
205
+ ## Use it as an MCP server (catch problems *before* they're written)
206
+
207
+ `keel check` is a verifier you run *after* the fact. The MCP server flips that around:
208
+ it lets your AI agent ask keel **before** it writes code, so duplication and
209
+ inconsistency are prevented at generation time instead of caught later. It exposes three
210
+ tools, all answered from live, deterministic facts about your code — **no AI, no cost**:
211
+
212
+ | Tool | What the agent gets |
213
+ |---|---|
214
+ | `check_before_write(intent)` | Existing functions it might be about to reinvent, and the libraries already in use for that job ("you already have `formatDate`; you already use axios — don't add ky"). |
215
+ | `get_conventions(path)` | The real conventions for that area — naming style, language, libraries in use — derived from the code, not a stale doc. |
216
+ | `report_drift()` | The project's current drift + library conflicts, so the agent doesn't add to them. |
217
+
218
+ **Register it with Claude Code** (after `npm install -g @curiousnerd/keel`):
219
+
220
+ ```bash
221
+ claude mcp add keel -- keel mcp
222
+ ```
223
+
224
+ Or drop a `.mcp.json` in your project (works in Cursor and other MCP hosts too):
225
+
226
+ ```json
227
+ { "mcpServers": { "keel": { "command": "keel", "args": ["mcp"] } } }
228
+ ```
229
+
230
+ The server serves the current directory by default; pass a path (`mcp /some/repo`) to
231
+ serve another. It re-reads the code on every call (the file-hash cache keeps that
232
+ near-instant), so answers always reflect the current state.
233
+
234
+ ---
235
+
236
+ ## Status
237
+
238
+ **Working today.** Drift + doc drift, library conflicts, exact + near duplicates,
239
+ Coherence Score, suppressions, caching, an opt-in LLM layer, and an **MCP server** —
240
+ across **JavaScript, TypeScript, and Python**. Python is parsed with tree-sitter
241
+ (WebAssembly), so there's nothing to compile at install time.
242
+
243
+ **Coming next:** a pre-commit hook and GitHub Action · semantic duplication · more
244
+ languages.
245
+
246
+ Want to help or understand the internals? See [CONTRIBUTING.md](CONTRIBUTING.md).
247
+
248
+ ## License
249
+
250
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ {
2
+ "http": ["axios", "got", "node-fetch", "ky", "superagent", "request", "undici", "requests", "httpx", "aiohttp", "urllib3", "httplib2"],
3
+ "date": ["moment", "dayjs", "date-fns", "luxon", "arrow", "pendulum", "dateutil"],
4
+ "validation": ["zod", "yup", "joi", "ajv", "superstruct", "valibot", "pydantic", "marshmallow", "cerberus", "voluptuous", "schema"],
5
+ "state": ["redux", "zustand", "jotai", "recoil", "mobx", "@reduxjs/toolkit"],
6
+ "orm": ["prisma", "drizzle-orm", "typeorm", "sequelize", "knex", "mongoose", "sqlalchemy", "peewee", "tortoise", "pony"],
7
+ "test": ["jest", "vitest", "mocha", "ava", "jasmine", "tape", "pytest", "nose", "nose2"],
8
+ "logging": ["winston", "pino", "bunyan", "loglevel", "signale", "loguru", "structlog"],
9
+ "env": ["dotenv", "dotenv-flow", "env-var", "envalid", "environs", "decouple"],
10
+ "uuid": ["uuid", "nanoid", "cuid", "ulid"],
11
+ "styling": ["styled-components", "@emotion/react", "tailwindcss"],
12
+ "forms": ["react-hook-form", "formik", "react-final-form"],
13
+ "data-fetch": ["@tanstack/react-query", "swr", "apollo-client", "@apollo/client", "urql"],
14
+ "web-framework": ["express", "fastify", "koa", "hapi", "flask", "django", "fastapi", "tornado", "bottle", "sanic", "falcon"]
15
+ }
@@ -0,0 +1,9 @@
1
+ import type { KeelConfig } from "../config.js";
2
+ import type { Facts, Finding } from "../types.js";
3
+ /**
4
+ * Deterministic doc/knowledge drift: flag file-path references in Markdown docs
5
+ * that no longer exist in the repo. High precision — only concrete, local,
6
+ * checkable paths are verified; URLs, placeholders, and build/runtime paths are
7
+ * skipped, and a line carrying `keel-ignore` suppresses its references.
8
+ */
9
+ export declare function detectDocDrift(facts: Facts, config: KeelConfig): Finding[];
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DEFAULT_IGNORE_DIRS, walkMarkdownFiles } from "../extract/walk.js";
4
+ const DOC_REF_PENALTY = 4;
5
+ /** File extensions worth verifying — avoids flagging prose like `example.com` or `v1.2`. */
6
+ const CHECKABLE_EXT = new Set([
7
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "php", "cs",
8
+ "c", "cpp", "h", "hpp", "swift", "scala", "md", "mdx", "json", "yaml", "yml", "toml",
9
+ "ini", "env", "sh", "bash", "css", "scss", "less", "html", "xml", "sql", "prisma",
10
+ "graphql", "gql", "vue", "svelte", "proto", "lock", "cfg", "conf",
11
+ ]);
12
+ function hasCheckableExtension(p) {
13
+ const m = /\.([A-Za-z0-9]+)$/.exec(p);
14
+ return m !== null && CHECKABLE_EXT.has(m[1].toLowerCase());
15
+ }
16
+ /** URL, mail/tel, protocol-relative, or pure in-page anchor — not a repo path. */
17
+ function isUrlOrAnchor(s) {
18
+ return /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(s);
19
+ }
20
+ /** Placeholder/glob/template markers mean it isn't a concrete path. */
21
+ function hasPlaceholder(s) {
22
+ return /[<>{}*]|\.\.\./.test(s);
23
+ }
24
+ /** Names like `yourCheck.ts`, `myModule.ts`, `foo.ts` are doc templates, not real files. */
25
+ function looksTemplated(ref) {
26
+ const base = (ref.split("/").pop() ?? "").replace(/\.[A-Za-z0-9]+$/, "");
27
+ if (/^(?:your|my)[A-Z]/.test(base))
28
+ return true; // yourCheck, myModule
29
+ return ["foo", "bar", "baz", "qux", "example", "sample", "placeholder"].includes(base.toLowerCase());
30
+ }
31
+ /** First path segment, for skipping references into build/runtime/ignored dirs. */
32
+ function firstSegment(ref) {
33
+ return ref.replace(/^\.?\//, "").split("/")[0] ?? "";
34
+ }
35
+ /** Extract candidate repo-path references (markdown links + inline code spans) from one line. */
36
+ function refsInLine(line, lineNo) {
37
+ const refs = [];
38
+ // Markdown links: [text](target)
39
+ for (const m of line.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
40
+ const target = m[1].trim().split(/[#?\s]/)[0] ?? "";
41
+ if (target &&
42
+ !isUrlOrAnchor(target) &&
43
+ !hasPlaceholder(target) &&
44
+ !looksTemplated(target) &&
45
+ hasCheckableExtension(target)) {
46
+ refs.push({ ref: target, line: lineNo });
47
+ }
48
+ }
49
+ // Inline code spans: `path/to/file.ext` — require a slash to stay high-precision.
50
+ for (const m of line.matchAll(/`([^`]+)`/g)) {
51
+ const span = m[1].trim();
52
+ if (span.includes("/") &&
53
+ !/\s/.test(span) &&
54
+ !isUrlOrAnchor(span) &&
55
+ !hasPlaceholder(span) &&
56
+ !looksTemplated(span) &&
57
+ hasCheckableExtension(span)) {
58
+ refs.push({ ref: span, line: lineNo });
59
+ }
60
+ }
61
+ return refs;
62
+ }
63
+ /** Does `ref` resolve to a file, relative to either the repo root or the doc's directory? */
64
+ function refResolves(root, docDir, ref) {
65
+ const clean = ref.replace(/^\.\//, "");
66
+ return existsSync(join(root, clean)) || existsSync(join(root, docDir, clean));
67
+ }
68
+ /**
69
+ * Deterministic doc/knowledge drift: flag file-path references in Markdown docs
70
+ * that no longer exist in the repo. High precision — only concrete, local,
71
+ * checkable paths are verified; URLs, placeholders, and build/runtime paths are
72
+ * skipped, and a line carrying `keel-ignore` suppresses its references.
73
+ */
74
+ export function detectDocDrift(facts, config) {
75
+ if (!config.docs.enabled)
76
+ return [];
77
+ const root = facts.root;
78
+ const docs = walkMarkdownFiles(root, { respectGitignore: config.scan.respectGitignore });
79
+ const findings = [];
80
+ for (const docPath of docs) {
81
+ let content;
82
+ try {
83
+ content = readFileSync(join(root, docPath), "utf8");
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ const docDir = dirname(docPath);
89
+ const lines = content.split(/\r?\n/);
90
+ const seen = new Set();
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ if (/keel-ignore/i.test(line))
94
+ continue; // inline suppression
95
+ for (const { ref, line: lineNo } of refsInLine(line, i + 1)) {
96
+ if (DEFAULT_IGNORE_DIRS.has(firstSegment(ref)))
97
+ continue; // build/runtime artifact
98
+ if (seen.has(ref))
99
+ continue;
100
+ if (refResolves(root, docDir, ref))
101
+ continue;
102
+ seen.add(ref);
103
+ findings.push({
104
+ kind: "drift",
105
+ confidence: "high",
106
+ title: `${docPath}: references missing path \`${ref}\``,
107
+ detail: `The docs point at \`${ref}\`, but no such file exists in the repo — it was likely moved, renamed, or deleted.`,
108
+ location: `${docPath}:${lineNo}`,
109
+ penalty: DOC_REF_PENALTY,
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return findings;
115
+ }
116
+ //# sourceMappingURL=docDrift.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docDrift.js","sourceRoot":"","sources":["../../src/analyze/docDrift.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { Claims, Facts, Finding } from "../types.js";
2
+ import { type CapabilityBuckets } from "./shared.js";
3
+ /** Compare parsed Claims against extracted Facts and emit drift findings. */
4
+ export declare function detectDrift(claims: Claims, facts: Facts, buckets?: CapabilityBuckets): Finding[];
@@ -0,0 +1,134 @@
1
+ import { filesByPackage, loadCapabilityBuckets } from "./shared.js";
2
+ const LIB_PENALTY = 8;
3
+ const NAMING_PENALTY = 5;
4
+ /** Known database drivers grouped by family, for the "declared DB" check. */
5
+ const DB_FAMILIES = {
6
+ postgres: ["pg", "postgres", "pg-promise", "postgresql"],
7
+ sqlite: ["sqlite3", "better-sqlite3"],
8
+ mysql: ["mysql", "mysql2"],
9
+ mongodb: ["mongodb", "mongoose"],
10
+ mssql: ["mssql", "tedious"],
11
+ };
12
+ function dbFamilyOf(value) {
13
+ const v = value.toLowerCase();
14
+ if (v.includes("postgres"))
15
+ return "postgres";
16
+ if (v.includes("sqlite"))
17
+ return "sqlite";
18
+ if (v.includes("mysql") || v.includes("maria"))
19
+ return "mysql";
20
+ if (v.includes("mongo"))
21
+ return "mongodb";
22
+ if (v.includes("mssql") || v.includes("sql server"))
23
+ return "mssql";
24
+ return null;
25
+ }
26
+ const countFor = (byPkg, pkg) => byPkg.get(pkg)?.length ?? 0;
27
+ const summarize = (entries) => entries.map((e) => `${e.pkg} (${e.n} file${e.n === 1 ? "" : "s"})`).join(", ");
28
+ function checkLibrary(claim, byPkg, buckets) {
29
+ const value = claim.value.toLowerCase().trim();
30
+ for (const [, members] of Object.entries(buckets)) {
31
+ if (!members.includes(value))
32
+ continue;
33
+ if (countFor(byPkg, value) > 0)
34
+ return null; // claimed lib is actually used
35
+ const competitors = members
36
+ .filter((m) => m !== value && countFor(byPkg, m) > 0)
37
+ .map((m) => ({ pkg: m, n: countFor(byPkg, m) }));
38
+ if (competitors.length === 0)
39
+ return null; // nothing contradicts it
40
+ return {
41
+ kind: "drift",
42
+ confidence: "high",
43
+ title: `${claim.source}: "${claim.key}: ${claim.value}" — but code imports ${summarize(competitors)}`,
44
+ detail: `Declared ${claim.key} is "${claim.value}" but it is not imported anywhere; ${competitors[0].pkg} is used instead.`,
45
+ location: `${claim.source}:${claim.line}`,
46
+ penalty: LIB_PENALTY,
47
+ };
48
+ }
49
+ return null;
50
+ }
51
+ function checkDatabase(claim, byPkg) {
52
+ const claimedFamily = dbFamilyOf(claim.value);
53
+ if (!claimedFamily)
54
+ return null;
55
+ const isImported = (family) => DB_FAMILIES[family].some((d) => countFor(byPkg, d) > 0);
56
+ if (isImported(claimedFamily))
57
+ return null;
58
+ const others = Object.keys(DB_FAMILIES).filter((f) => f !== claimedFamily && isImported(f));
59
+ if (others.length === 0)
60
+ return null; // can't verify — no DB driver imported
61
+ const drivers = others.flatMap((f) => DB_FAMILIES[f].filter((d) => countFor(byPkg, d) > 0));
62
+ return {
63
+ kind: "drift",
64
+ confidence: "high",
65
+ title: `${claim.source}: "${claim.key}: ${claim.value}" — but code imports ${drivers.join(", ")}`,
66
+ location: `${claim.source}:${claim.line}`,
67
+ penalty: LIB_PENALTY,
68
+ };
69
+ }
70
+ function checkPackageManager(claim, facts) {
71
+ if (!facts.pkg || facts.pkg.packageManager === "unknown")
72
+ return null; // unverifiable
73
+ const claimed = claim.value.toLowerCase().trim().split(/\s|@/)[0];
74
+ const detected = facts.pkg.packageManager;
75
+ if (claimed === detected)
76
+ return null;
77
+ return {
78
+ kind: "drift",
79
+ confidence: "high",
80
+ title: `${claim.source}: "${claim.key}: ${claim.value}" — but lockfile indicates ${detected}`,
81
+ location: `${claim.source}:${claim.line}`,
82
+ penalty: LIB_PENALTY,
83
+ };
84
+ }
85
+ function checkNaming(claim, facts) {
86
+ const v = claim.value.toLowerCase();
87
+ // The casing that VIOLATES the claimed convention. Single lowercase words
88
+ // (`lower`) are valid under both camelCase and snake_case, so they're neutral.
89
+ const violatingStyle = v.includes("camel")
90
+ ? "snake_case"
91
+ : v.includes("snake")
92
+ ? "camelCase"
93
+ : null;
94
+ if (!violatingStyle)
95
+ return null;
96
+ let compatible = 0; // names consistent with the claim (claimed style + neutral)
97
+ let violating = 0;
98
+ for (const f of facts.files) {
99
+ violating += f.naming[violatingStyle] ?? 0;
100
+ compatible += (f.naming.lower ?? 0) + (violatingStyle === "snake_case" ? f.naming.camelCase ?? 0 : f.naming.snake_case ?? 0);
101
+ }
102
+ if (compatible + violating < 10)
103
+ return null; // too little signal
104
+ if (!(violating > compatible))
105
+ return null; // claimed style prevails (also guards NaN)
106
+ return {
107
+ kind: "drift",
108
+ confidence: "medium",
109
+ title: `${claim.source}: "${claim.key}: ${claim.value}" — but ${violatingStyle} prevails (${violating} vs ${compatible})`,
110
+ location: `${claim.source}:${claim.line}`,
111
+ penalty: NAMING_PENALTY,
112
+ };
113
+ }
114
+ /** Compare parsed Claims against extracted Facts and emit drift findings. */
115
+ export function detectDrift(claims, facts, buckets = loadCapabilityBuckets()) {
116
+ const byPkg = filesByPackage(facts);
117
+ const findings = [];
118
+ for (const claim of claims.stack) {
119
+ const key = claim.key;
120
+ let finding = null;
121
+ if (key.includes("package manager"))
122
+ finding = checkPackageManager(claim, facts);
123
+ else if (key === "database" || key === "db")
124
+ finding = checkDatabase(claim, byPkg);
125
+ else if (key.includes("naming"))
126
+ finding = checkNaming(claim, facts);
127
+ else
128
+ finding = checkLibrary(claim, byPkg, buckets);
129
+ if (finding)
130
+ findings.push(finding);
131
+ }
132
+ return findings;
133
+ }
134
+ //# sourceMappingURL=drift.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drift.js","sourceRoot":"","sources":["../../src/analyze/drift.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import type { Facts, Finding } from "../types.js";
2
+ /**
3
+ * Group functions by normalized-body hash; any group of 2+ is an exact-clone
4
+ * finding. Trivial bodies were already excluded at extraction (null hash).
5
+ * Near-duplicate detection is deferred to v0.5.
6
+ */
7
+ export declare function detectDuplication(facts: Facts): Finding[];
@@ -0,0 +1,46 @@
1
+ import { buildFunctionIgnoreCheck } from "./shared.js";
2
+ const DUP_PENALTY = 4;
3
+ /**
4
+ * Group functions by normalized-body hash; any group of 2+ is an exact-clone
5
+ * finding. Trivial bodies were already excluded at extraction (null hash).
6
+ * Near-duplicate detection is deferred to v0.5.
7
+ */
8
+ export function detectDuplication(facts) {
9
+ // A function is suppressed when its declaration line (or the line above it)
10
+ // carries an inline `// keel-ignore` marker.
11
+ const isSuppressed = buildFunctionIgnoreCheck(facts);
12
+ const groups = new Map();
13
+ for (const file of facts.files) {
14
+ for (const fn of file.functions) {
15
+ // Only named functions/methods — anonymous inline callbacks aren't
16
+ // "utilities you reinvented", and produce unactionable noise.
17
+ if (fn.bodyHash === null || fn.name === null || isSuppressed(fn))
18
+ continue;
19
+ let group = groups.get(fn.bodyHash);
20
+ if (!group)
21
+ groups.set(fn.bodyHash, (group = []));
22
+ group.push(fn);
23
+ }
24
+ }
25
+ const findings = [];
26
+ for (const group of groups.values()) {
27
+ if (group.length < 2)
28
+ continue;
29
+ const locations = group.map((fn) => `${fn.name ?? "(anonymous)"} @ ${fn.filePath}:${fn.startLine}`);
30
+ const names = [...new Set(group.map((fn) => fn.name ?? "(anonymous)"))];
31
+ const title = names.length === 1
32
+ ? `${names[0]}() duplicated across ${group.length} locations`
33
+ : `${names.join(" / ")} are identical implementations`;
34
+ findings.push({
35
+ kind: "dup",
36
+ confidence: "high",
37
+ title,
38
+ detail: locations.join("\n "),
39
+ location: `${group[0].filePath}:${group[0].startLine}`,
40
+ penalty: DUP_PENALTY,
41
+ });
42
+ }
43
+ // Stable order: most-duplicated first, then by title.
44
+ return findings.sort((a, b) => (b.penalty - a.penalty) || a.title.localeCompare(b.title));
45
+ }
46
+ //# sourceMappingURL=duplication.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplication.js","sourceRoot":"","sources":["../../src/analyze/duplication.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAEvD,MAAM,WAAW,GAAG,CAAC,CAAC;AAEtB;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAY;IAC5C,4EAA4E;IAC5E,6CAA6C;IAC7C,MAAM,YAAY,GAAG,wBAAwB,CAAC,KAAK,CAAC,CAAC;IAErD,MAAM,MAAM,GAAG,IAAI,GAAG,EAA0B,CAAC;IACjD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;QAC/B,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAChC,mEAAmE;YACnE,8DAA8D;YAC9D,IAAI,EAAE,CAAC,QAAQ,KAAK,IAAI,IAAI,EAAE,CAAC,IAAI,KAAK,IAAI,IAAI,YAAY,CAAC,EAAE,CAAC;gBAAE,SAAS;YAC3E,IAAI,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC;YACpC,IAAI,CAAC,KAAK;gBAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC,CAAC;YAClD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC;QACpC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS;QAE/B,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,aAAa,MAAM,EAAE,CAAC,QAAQ,IAAI,EAAE,CAAC,SAAS,EAAE,CAAC,CAAC;QACpG,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,IAAI,aAAa,CAAC,CAAC,CAAC,CAAC;QACxE,MAAM,KAAK,GACT,KAAK,CAAC,MAAM,KAAK,CAAC;YAChB,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,wBAAwB,KAAK,CAAC,MAAM,YAAY;YAC7D,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,gCAAgC,CAAC;QAE3D,QAAQ,CAAC,IAAI,CAAC;YACZ,IAAI,EAAE,KAAK;YACX,UAAU,EAAE,MAAM;YAClB,KAAK;YACL,MAAM,EAAE,SAAS,CAAC,IAAI,CAAC,eAAe,CAAC;YACvC,QAAQ,EAAE,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC,CAAE,CAAC,SAAS,EAAE;YACxD,OAAO,EAAE,WAAW;SACrB,CAAC,CAAC;IACL,CAAC;IAED,sDAAsD;IACtD,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;AAC5F,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { KeelConfig } from "../config.js";
2
+ import type { Claims, Facts, Finding, ScoreBreakdown } from "../types.js";
3
+ import { type Suppressions } from "../suppress.js";
4
+ export interface Analysis {
5
+ findings: Finding[];
6
+ score: number;
7
+ breakdown: ScoreBreakdown;
8
+ }
9
+ /** Run every v0 engine against the Facts + Claims, applying suppressions. */
10
+ export declare function analyze(facts: Facts, claims: Claims, config: KeelConfig, suppressions?: Suppressions): Analysis;