activate-agentmd 2.4.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 (44) hide show
  1. package/README.md +158 -0
  2. package/bin/agentmd.js +186 -0
  3. package/package.json +55 -0
  4. package/src/commands/agents.js +44 -0
  5. package/src/commands/analytics.js +128 -0
  6. package/src/commands/auth.js +144 -0
  7. package/src/commands/ci.js +296 -0
  8. package/src/commands/extract.js +254 -0
  9. package/src/commands/info.js +105 -0
  10. package/src/commands/init.js +166 -0
  11. package/src/commands/install.js +216 -0
  12. package/src/commands/link.js +206 -0
  13. package/src/commands/list.js +99 -0
  14. package/src/commands/outdated.js +92 -0
  15. package/src/commands/remove.js +72 -0
  16. package/src/commands/review.js +293 -0
  17. package/src/commands/search.js +110 -0
  18. package/src/commands/sync.js +245 -0
  19. package/src/commands/telemetry.js +82 -0
  20. package/src/commands/test.js +226 -0
  21. package/src/commands/update.js +115 -0
  22. package/src/commands/validate.js +125 -0
  23. package/src/config/license-public-keys.json +17 -0
  24. package/src/detect.json +452 -0
  25. package/src/lib/agents.js +301 -0
  26. package/src/lib/analytics.js +137 -0
  27. package/src/lib/coordinates.js +103 -0
  28. package/src/lib/credentials.js +115 -0
  29. package/src/lib/detect.js +352 -0
  30. package/src/lib/diff.js +142 -0
  31. package/src/lib/enterprise.js +140 -0
  32. package/src/lib/fetcher.js +174 -0
  33. package/src/lib/invocation.js +47 -0
  34. package/src/lib/license.js +234 -0
  35. package/src/lib/manifest.js +191 -0
  36. package/src/lib/patterns.js +135 -0
  37. package/src/lib/pro.js +149 -0
  38. package/src/lib/ranking.js +287 -0
  39. package/src/lib/registry.js +207 -0
  40. package/src/lib/star.js +105 -0
  41. package/src/lib/status.js +75 -0
  42. package/src/lib/telemetry.js +169 -0
  43. package/src/lib/versions.js +190 -0
  44. package/src/registry.json +33907 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * patterns.js
3
+ *
4
+ * The free, offline half of `review`: deterministic checks that run over the
5
+ * added lines of a diff in milliseconds, with no API key and nothing leaving
6
+ * the machine. Each pattern is high-precision on purpose — a false positive
7
+ * in CI costs more trust than a missed catch — and names the standard that
8
+ * explains the rule.
9
+ *
10
+ * `files` limits a pattern to extensions; `not` suppresses lines that also
11
+ * match a safe form (a parameterised query still contains "SELECT").
12
+ */
13
+
14
+ "use strict";
15
+
16
+ const PATTERNS = [
17
+ {
18
+ id: "sql-string-interpolation", standard: "Security/sql-injection", severity: "high",
19
+ files: [".js", ".ts", ".tsx", ".jsx", ".py", ".go", ".rb", ".php"],
20
+ test: /(SELECT|INSERT|UPDATE|DELETE)\b[^\n]*(\$\{|" ?\+ ?[a-zA-Z_]|' ?\+ ?[a-zA-Z_]|%s|\.format\(|f"[^"]*\{)/i,
21
+ message: "SQL built by string interpolation — use a parameterised query.",
22
+ },
23
+ {
24
+ id: "select-star", standard: "Database/query-optimization", severity: "low",
25
+ files: [".js", ".ts", ".tsx", ".py", ".go", ".sql", ".rb", ".php"],
26
+ test: /\bSELECT\s+\*\s+FROM\b/i,
27
+ message: "`SELECT *` — name the columns you need.",
28
+ },
29
+ {
30
+ id: "pydantic-v1-api", standard: "Backend/pydantic", severity: "medium", files: [".py"],
31
+ test: /\.(dict|json|parse_obj|parse_raw)\(|@(validator|root_validator)\b|class Config:/,
32
+ not: /model_dump|model_validate/,
33
+ message: "Pydantic v1 API (`.dict()`, `.json()`, `@validator`, `class Config`) — use `model_dump`, `model_validate`, `field_validator`, `ConfigDict`.",
34
+ },
35
+ {
36
+ id: "eval-exec", standard: "Security/command-injection", severity: "high",
37
+ files: [".js", ".ts", ".tsx", ".py", ".rb", ".php"],
38
+ test: /(^|[^.\w])(eval|exec)\s*\(/,
39
+ message: "`eval`/`exec` on runtime data is code injection — parse, don't evaluate.",
40
+ },
41
+ {
42
+ id: "child-process-shell", standard: "Security/command-injection", severity: "high", files: [".js", ".ts"],
43
+ test: /\b(exec|execSync)\s*\(\s*(`[^`]*\$\{|["'][^"']*["']\s*\+)/,
44
+ message: "Shell command built from variables — use `execFile`/`spawn` with an argument array.",
45
+ },
46
+ {
47
+ id: "subprocess-shell", standard: "Security/command-injection", severity: "high", files: [".py"],
48
+ test: /subprocess\.[a-z_]+\([^)]*shell\s*=\s*True/,
49
+ message: "`shell=True` with a built string is command injection — pass an argument list.",
50
+ },
51
+ {
52
+ id: "weak-hash-for-secrets", standard: "Security/passwords", severity: "high",
53
+ files: [".js", ".ts", ".py", ".go", ".rb", ".php"],
54
+ test: /\b(md5|sha1)\s*\(|createHash\(\s*["'](md5|sha1)["']|hashlib\.(md5|sha1)\(/i,
55
+ message: "MD5/SHA-1 — not for passwords or tokens; use bcrypt/argon2 for passwords, SHA-256+ elsewhere.",
56
+ },
57
+ {
58
+ id: "math-random-token", standard: "Security/secret-management", severity: "high", files: [".js", ".ts", ".tsx"],
59
+ test: /(token|secret|nonce|otp|session|password)[^\n]*Math\.random\(/i,
60
+ message: "`Math.random()` is not a CSPRNG — use `crypto.randomBytes` / `crypto.randomUUID`.",
61
+ },
62
+ {
63
+ id: "jwt-decode-without-verify", standard: "Security/jwt", severity: "high", files: [".js", ".ts", ".py"],
64
+ test: /jwt\.decode\(/,
65
+ not: /verify/,
66
+ message: "`jwt.decode` does not verify the signature — use `jwt.verify` (or `decode(..., verify=True)`) before trusting claims.",
67
+ },
68
+ {
69
+ id: "dangerously-set-inner-html", standard: "Security/xss", severity: "medium", files: [".tsx", ".jsx"],
70
+ test: /dangerouslySetInnerHTML/,
71
+ message: "`dangerouslySetInnerHTML` — sanitise the HTML (DOMPurify) or render text.",
72
+ },
73
+ {
74
+ id: "cors-wildcard-credentials", standard: "Security/cors", severity: "high", files: [".js", ".ts", ".py", ".go"],
75
+ test: /Access-Control-Allow-Origin["']?\s*[:,]\s*["']\*["']|origin:\s*["']\*["']|allow_origins\s*=\s*\[\s*["']\*["']\s*\]/,
76
+ message: "Wildcard CORS origin — allow-list the origins that need access.",
77
+ },
78
+ {
79
+ id: "hardcoded-secret", standard: "Security/secret-management", severity: "high",
80
+ files: [".js", ".ts", ".tsx", ".py", ".go", ".rb", ".php", ".yml", ".yaml", ".env"],
81
+ test: /(api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9_\-/+]{16,}["']/i,
82
+ not: /process\.env|os\.environ|getenv|\$\{|<[A-Z_]+>|example|placeholder|changeme|xxx/i,
83
+ message: "Looks like a hard-coded secret — read it from the environment or a secret store.",
84
+ },
85
+ {
86
+ id: "next-sync-request-api", standard: "Frontend/nextjs", severity: "medium", files: [".ts", ".tsx"],
87
+ test: /\b(cookies|headers)\(\)\.(get|set|getAll|has)\(/,
88
+ message: "Next.js 15+: `cookies()` / `headers()` are async — `(await cookies()).get(...)`.",
89
+ },
90
+ {
91
+ id: "console-log-in-server", standard: "Backend/logging", severity: "low", files: [".ts", ".js"],
92
+ test: /^\s*console\.log\(/,
93
+ message: "`console.log` in server code — use the structured logger.",
94
+ },
95
+ ];
96
+
97
+ /**
98
+ * Run the patterns over a unified diff. Only added lines are checked, and the
99
+ * file's extension must be in the pattern's list. Returns review-shaped
100
+ * findings: { file, line, severity, standard, rule, issue, fix, pattern }.
101
+ */
102
+ function runPatterns(diff, installedStandardIds) {
103
+ const findings = [];
104
+ let file = null, ext = "", line = 0;
105
+ for (const raw of diff.split("\n")) {
106
+ if (raw.startsWith("+++ ")) {
107
+ file = raw.replace(/^\+\+\+ (b\/)?/, "").trim();
108
+ ext = file.includes(".") ? file.slice(file.lastIndexOf(".")) : "";
109
+ continue;
110
+ }
111
+ if (raw.startsWith("@@")) {
112
+ const m = /\+(\d+)/.exec(raw);
113
+ line = m ? Number(m[1]) - 1 : 0;
114
+ continue;
115
+ }
116
+ if (!file || raw.startsWith("---")) continue;
117
+ if (raw.startsWith("-")) continue;
118
+ line++;
119
+ if (!raw.startsWith("+")) continue;
120
+ const code = raw.slice(1);
121
+ if (/^\s*(\/\/|#|\*|<!--)/.test(code)) continue; // comments
122
+ for (const p of PATTERNS) {
123
+ if (!p.files.includes(ext)) continue;
124
+ if (!p.test.test(code)) continue;
125
+ if (p.not && p.not.test(code)) continue;
126
+ // Only report against standards the project actually installed, unless
127
+ // the caller passes null to mean "everything".
128
+ if (installedStandardIds && !installedStandardIds.has(p.standard)) continue;
129
+ findings.push({ file, line, severity: p.severity, standard: p.standard, rule: p.id, issue: p.message, fix: null, pattern: true });
130
+ }
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ module.exports = { PATTERNS, runPatterns };
package/src/lib/pro.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * pro.js
3
+ *
4
+ * The free/Pro boundary, in one file so it can be read in one sitting.
5
+ *
6
+ * The rule the boundary follows: **anything that runs entirely on your
7
+ * machine against the public registry is free, forever.** `init`, `link`,
8
+ * `list`, `search`, `install`, `update`, `review --fast`, `review --local` —
9
+ * the whole local loop, no account, no key, no telemetry. Pro is for the
10
+ * things that cost us money to run or that only a company needs: a private
11
+ * registry, cloud enforcement, and the deep analysis commands.
12
+ *
13
+ * Gating is a wrapper rather than a check inside each command, so the list
14
+ * below is the single source of truth and a command cannot half-gate itself.
15
+ */
16
+
17
+ "use strict";
18
+
19
+ const pc = require("picocolors");
20
+ const { verify, fingerprint, daysLeft } = require("./license");
21
+ const { resolveKey } = require("./credentials");
22
+ const { cmd } = require("./invocation");
23
+
24
+ const UPGRADE_URL = process.env.AGENTMD_PRO_URL || "https://agent-dot-md.vercel.app/pro";
25
+
26
+ /**
27
+ * Every gated capability, with the sentence shown when it is reached without
28
+ * a license. Free commands are deliberately absent: a command that is not in
29
+ * here is free, and adding a key to this table is the only way to gate one.
30
+ */
31
+ const PRO_FEATURES = {
32
+ extract: {
33
+ label: "agentmd extract",
34
+ pitch: "Reads your repo and writes down the conventions your team repeats in every review.",
35
+ },
36
+ "sync:private": {
37
+ label: "agentmd sync --private",
38
+ pitch: "Push and pull your company's own standards to a private registry.",
39
+ },
40
+ analytics: {
41
+ label: "agentmd analytics",
42
+ pitch: "What your standards actually caught, per rule and per week.",
43
+ },
44
+ "ci:cloud": {
45
+ label: "agentmd ci --cloud --preview",
46
+ pitch: "Hosted pull-request enforcement, with no API key in your CI secrets. Private preview.",
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Current entitlement.
52
+ *
53
+ * `{ pro, plan, claims, source, reason }`. `reason` is set only when a key was
54
+ * found and rejected, so "no key" and "bad key" stay distinguishable — they
55
+ * need different advice.
56
+ */
57
+ function status(cwd = process.cwd()) {
58
+ const { key, source } = resolveKey(cwd);
59
+ if (!key) return { pro: false, plan: "free", claims: null, source: null, reason: null, key: null };
60
+
61
+ const result = verify(key);
62
+ if (!result.valid) {
63
+ return { pro: false, plan: "free", claims: result.claims ?? null, source, reason: result.reason, key };
64
+ }
65
+ return { pro: true, plan: result.claims.plan, claims: result.claims, source, reason: null, key };
66
+ }
67
+
68
+ const isPro = (cwd) => status(cwd).pro;
69
+
70
+ /** Open a URL in the user's browser. Best effort: never fails the command. */
71
+ function openBrowser(url) {
72
+ // Opening a browser is a courtesy, not the point of the command. A headless
73
+ // box, a locked-down CI runner or a missing xdg-open must not turn a
74
+ // paywall message into a crash, so failures are swallowed and the URL is
75
+ // always printed for the user to copy.
76
+ if (process.env.AGENTMD_NO_BROWSER || process.env.CI) return false;
77
+ const opener =
78
+ process.platform === "darwin" ? ["open", [url]]
79
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
80
+ : ["xdg-open", [url]];
81
+ try {
82
+ const { spawn } = require("child_process");
83
+ const child = spawn(opener[0], opener[1], { stdio: "ignore", detached: true });
84
+ child.on("error", () => {});
85
+ child.unref();
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** The paywall itself: what it is, why it costs, and how to get past it. */
93
+ function printUpgrade(featureKey, state) {
94
+ const feature = PRO_FEATURES[featureKey] || { label: featureKey, pitch: "" };
95
+ const A = cmd();
96
+
97
+ console.log("");
98
+ console.log(` ${pc.yellow("✨")} ${pc.bold(feature.label)} is an Agent.md Pro feature.`);
99
+ if (feature.pitch) console.log(` ${pc.dim(feature.pitch)}`);
100
+ console.log("");
101
+
102
+ if (state.reason) {
103
+ // A key was found and rejected. Say which key, and what is wrong with it,
104
+ // or the user re-pastes the same one and gets the same wall.
105
+ console.log(` ${pc.red("✗")} The key from ${pc.white(state.source)} ${pc.red(state.reason)}.`);
106
+ console.log(` ${pc.dim(`Key ${fingerprint(state.key)} · replace it with '${A} login'`)}`);
107
+ } else {
108
+ console.log(` ${pc.dim(`Start a free trial: ${pc.underline(UPGRADE_URL)}`)}`);
109
+ console.log(` ${pc.dim(`Already have a key? '${A} login' — or set AGENTMD_PRO_KEY in CI.`)}`);
110
+ }
111
+
112
+ console.log("");
113
+ console.log(` ${pc.dim("Free, and staying free: init · link · install · update · list · search · review --fast · review --local")}`);
114
+ console.log("");
115
+
116
+ if (!state.reason) openBrowser(UPGRADE_URL);
117
+ }
118
+
119
+ /**
120
+ * Wrap a command so it only runs with a license.
121
+ *
122
+ * Returns a function with the same signature, so the dispatcher in
123
+ * bin/agentmd.js does not need to know which commands are gated.
124
+ *
125
+ * `process.exitCode` rather than `process.exit()`: the paywall is not a crash,
126
+ * and an abrupt exit truncates buffered stdout on Windows. 2 distinguishes
127
+ * "you may not run this" from a command that ran and found problems (1).
128
+ */
129
+ function requirePro(featureKey, run) {
130
+ return async function gated(...args) {
131
+ const state = status();
132
+ if (!state.pro) {
133
+ printUpgrade(featureKey, state);
134
+ process.exitCode = 2;
135
+ return;
136
+ }
137
+
138
+ // A license nearing its end is worth one line, once it is close enough to
139
+ // matter. Renewal failing silently is how a CI pipeline breaks on a Monday.
140
+ const left = daysLeft(state.claims);
141
+ if (left !== null && left <= 14) {
142
+ console.log(pc.yellow(` ! Your Agent.md ${state.plan} license expires in ${left} day${left === 1 ? "" : "s"}.`));
143
+ }
144
+
145
+ return run(...args);
146
+ };
147
+ }
148
+
149
+ module.exports = { status, isPro, requirePro, printUpgrade, openBrowser, PRO_FEATURES, UPGRADE_URL };
@@ -0,0 +1,287 @@
1
+ /**
2
+ * ranking.js
3
+ *
4
+ * Query ranking for `agentmd search`.
5
+ *
6
+ * The CLI previously matched substrings against names and categories. Measured
7
+ * against ten realistic queries it failed six, including the registry's own
8
+ * flagship content:
9
+ *
10
+ * BMW design → nothing PostgreSQL → nothing TDD → nothing
11
+ * JWT security → nothing AI agents → nothing premium SaaS → nothing
12
+ *
13
+ * This is the same algorithm the website uses, ported to CommonJS. It handles
14
+ * stemming, domain synonyms, transposition typos and multi-word queries where
15
+ * no single package matches every term.
16
+ *
17
+ * Known duplication: src/app/lib/search.ts holds an equivalent implementation
18
+ * for the browser, because that one is TypeScript, ESM, and reads a different
19
+ * (smaller) index. Duplicating a pure scoring function is tolerable; the two
20
+ * must not be allowed to duplicate *data*, which is what caused the registry
21
+ * to drift from the extension previously.
22
+ */
23
+
24
+ "use strict";
25
+
26
+ /**
27
+ * Domain synonyms — what people type on the left, what the registry calls it
28
+ * on the right. Deliberately small: a broad thesaurus makes results worse.
29
+ */
30
+ const SYNONYMS = {
31
+ auth: ["authentication", "authorization", "oauth", "jwt"],
32
+ login: ["authentication", "oauth"],
33
+ signin: ["authentication", "oauth"],
34
+ sso: ["oauth", "authentication"],
35
+ token: ["jwt", "oauth"],
36
+ password: ["passwords", "authentication"],
37
+ rbac: ["authorization"],
38
+
39
+ db: ["database", "postgres", "mysql", "mongodb", "schema-design"],
40
+ sql: ["postgres", "mysql", "sql-injection", "query-optimization"],
41
+ postgresql: ["postgres"],
42
+ mongo: ["mongodb"],
43
+ index: ["indexes"],
44
+ query: ["query-optimization", "queries"],
45
+
46
+ k8s: ["kubernetes"],
47
+ container: ["docker", "docker-compose"],
48
+ compose: ["docker-compose"],
49
+ ci: ["cicd", "github-actions"],
50
+ cd: ["cicd", "deployment"],
51
+ pipeline: ["cicd", "github-actions"],
52
+ deploy: ["deployment", "rollback"],
53
+ infra: ["kubernetes", "docker", "aws"],
54
+
55
+ a11y: ["accessibility"],
56
+ perf: ["performance", "optimization", "caching"],
57
+ speed: ["performance", "optimization"],
58
+ slow: ["performance", "query-optimization", "caching"],
59
+ bundle: ["bundle-size", "code-splitting"],
60
+ cache: ["caching"],
61
+
62
+ sec: ["security", "owasp"],
63
+ vuln: ["owasp", "security-review", "sql-injection", "xss"],
64
+ vulnerability: ["owasp", "security-review"],
65
+ injection: ["sql-injection", "command-injection"],
66
+ secrets: ["secret-management"],
67
+ env: ["environments", "secret-management"],
68
+ ratelimit: ["rate-limiting"],
69
+ throttle: ["rate-limiting"],
70
+
71
+ hallucinate: ["hallucination", "verification", "reasoning"],
72
+ hallucinating: ["hallucination", "verification"],
73
+ hallucinations: ["hallucination"],
74
+ prompt: ["prompt-engineering", "context"],
75
+ prompting: ["prompt-engineering"],
76
+ rag: ["context", "memory"],
77
+ agent: ["agent-rules", "multi-agent", "tool-use"],
78
+ agents: ["agent-rules", "multi-agent"],
79
+ tools: ["tool-use"],
80
+
81
+ test: ["unit", "integration", "e2e", "test-strategy"],
82
+ tests: ["unit", "integration", "e2e", "test-strategy"],
83
+ testing: ["test-strategy", "unit", "e2e"],
84
+ tdd: ["unit", "test-strategy"],
85
+
86
+ ts: ["typescript"],
87
+ next: ["nextjs"],
88
+ "next.js": ["nextjs"],
89
+ css: ["tailwind"],
90
+ ui: ["client-components", "tailwind"],
91
+ state: ["state-management"],
92
+ form: ["forms", "validation"],
93
+ ssr: ["server-components", "hydration"],
94
+ rsc: ["server-components"],
95
+
96
+ api: ["rest", "graphql", "open-api", "api-security"],
97
+ gql: ["graphql"],
98
+ swagger: ["open-api"],
99
+ webhook: ["webhooks"],
100
+ queue: ["queues", "background-jobs"],
101
+ cron: ["background-jobs", "workers"],
102
+ job: ["background-jobs", "workers"],
103
+ log: ["logging"],
104
+ logs: ["logging"],
105
+ monitor: ["monitoring"],
106
+ observability: ["monitoring", "logging"],
107
+ error: ["error-handling"],
108
+ errors: ["error-handling"],
109
+
110
+ docs: ["readme", "api-docs", "architecture-docs"],
111
+ license: ["license-check"],
112
+ review: ["code-review", "security-review", "architecture-review"],
113
+ refactor: ["refactoring", "modernization"],
114
+ migrate: ["migration", "modernization"],
115
+ scale: ["horizontal-scaling", "vertical-scaling", "load-balancing"],
116
+ micro: ["microservices"],
117
+ arch: ["architecture", "clean-architecture"],
118
+ saas: ["saas-template", "monetization", "pricing"],
119
+ premium: ["saas-template", "pricing"],
120
+ };
121
+
122
+ /** Words carrying no signal in a query like "my app is slow". */
123
+ const STOPWORDS = new Set([
124
+ "a", "an", "and", "the", "is", "are", "was", "be", "to", "of", "in", "on", "for", "with",
125
+ "my", "our", "your", "i", "we", "it", "its", "how", "do", "does", "should", "can", "what",
126
+ "when", "why", "me", "you", "app", "apps", "project", "code", "keeps", "keep", "get",
127
+ "getting", "make", "makes", "need", "want", "please", "help", "best", "practice", "practices",
128
+ ]);
129
+
130
+ /** Strip common suffixes so "hallucinating" reaches "hallucination". */
131
+ function stem(word) {
132
+ if (word.length <= 4) return word;
133
+ for (const suffix of ["ingly", "ating", "ations", "ation", "ings", "ing", "ies", "ied", "es", "ed", "ly", "s"]) {
134
+ if (word.length - suffix.length >= 4 && word.endsWith(suffix)) return word.slice(0, -suffix.length);
135
+ }
136
+ return word;
137
+ }
138
+
139
+ function normalize(text) {
140
+ return String(text || "")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9.\-\s]/g, " ")
143
+ .replace(/\s+/g, " ")
144
+ .trim();
145
+ }
146
+
147
+ /**
148
+ * Damerau-Levenshtein, abandoned past `max`.
149
+ * Transposition costs one edit — "owsap" is one swap from "owasp", and plain
150
+ * Levenshtein scored it two, dropping it below the threshold.
151
+ */
152
+ function editDistance(a, b, max) {
153
+ if (a === b) return 0;
154
+ if (Math.abs(a.length - b.length) > max) return max + 1;
155
+
156
+ let twoAgo = [];
157
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
158
+ for (let i = 1; i <= a.length; i++) {
159
+ const curr = [i];
160
+ let best = i;
161
+ for (let j = 1; j <= b.length; j++) {
162
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
163
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
164
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
165
+ curr[j] = Math.min(curr[j], twoAgo[j - 2] + 1);
166
+ }
167
+ if (curr[j] < best) best = curr[j];
168
+ }
169
+ if (best > max) return max + 1;
170
+ twoAgo = prev;
171
+ prev = curr;
172
+ }
173
+ return prev[b.length];
174
+ }
175
+
176
+ /** Typo tolerance scales with word length; short words get none. */
177
+ function fuzzyThreshold(word) {
178
+ if (word.length <= 4) return 0;
179
+ if (word.length <= 7) return 1;
180
+ return 2;
181
+ }
182
+
183
+ /**
184
+ * A query token plus everything it should also match, weighted.
185
+ * Without weights a synonym scored as strongly as the typed word, so
186
+ * "hallucinating" ranked `verification` above `hallucination`.
187
+ */
188
+ function expand(word) {
189
+ const weights = new Map();
190
+ const consider = (text, weight) => {
191
+ if (text) weights.set(text, Math.max(weights.get(text) || 0, weight));
192
+ };
193
+
194
+ consider(word, 1);
195
+ const stemmed = stem(word);
196
+ if (stemmed !== word) consider(stemmed, 0.95);
197
+
198
+ for (const key of [word, stemmed]) {
199
+ (SYNONYMS[key] || []).forEach((s, i) => consider(s, 0.88 - i * 0.06));
200
+ }
201
+ if (word.includes("-")) {
202
+ for (const part of word.split("-")) if (part.length > 2) consider(part, 0.8);
203
+ }
204
+
205
+ return { raw: word, variants: [...weights].map(([text, weight]) => ({ text, weight })) };
206
+ }
207
+
208
+ /** Score one token against one field. 0 means no match. */
209
+ function scoreField(token, field, weights) {
210
+ const [exact, prefix, contains, fuzzy] = weights;
211
+ let best = 0;
212
+
213
+ for (const { text, weight } of token.variants) {
214
+ if (field === text) {
215
+ best = Math.max(best, exact * weight);
216
+ continue;
217
+ }
218
+ if (field.startsWith(text)) best = Math.max(best, prefix * weight);
219
+ else if (field.includes(text)) best = Math.max(best, contains * weight);
220
+ else {
221
+ for (const word of field.split(/[\s\-.]+/)) {
222
+ if (word === text) best = Math.max(best, (exact - 10) * weight);
223
+ else if (word.startsWith(text) && text.length >= 3) best = Math.max(best, (prefix - 10) * weight);
224
+ }
225
+ }
226
+ }
227
+ if (best > 0) return best;
228
+
229
+ const threshold = fuzzyThreshold(token.raw);
230
+ if (threshold > 0) {
231
+ for (const word of field.split(/[\s\-.]+/)) {
232
+ if (Math.abs(word.length - token.raw.length) > threshold) continue;
233
+ if (editDistance(token.raw, word, threshold) <= threshold) return fuzzy;
234
+ }
235
+ }
236
+ return 0;
237
+ }
238
+
239
+ /**
240
+ * Rank `entries` (registry rows with .preset, .category, .description) for a query.
241
+ *
242
+ * Scoring is coordination-weighted rather than a strict AND. Requiring every
243
+ * token to match returned nothing for "postgres index" and "nextjs server
244
+ * components", because no single package covers every word.
245
+ */
246
+ function rank(entries, query) {
247
+ const normalized = normalize(query);
248
+ if (!normalized) return [];
249
+
250
+ const terms = normalized.split(" ").filter((w) => w && !STOPWORDS.has(w));
251
+ if (terms.length === 0) return [];
252
+
253
+ const tokens = terms.map(expand);
254
+ const results = [];
255
+
256
+ for (const entry of entries) {
257
+ const name = entry.preset.toLowerCase();
258
+ const category = entry.category.toLowerCase();
259
+ const description = (entry.description || "").toLowerCase();
260
+
261
+ let total = 0;
262
+ let matched = 0;
263
+
264
+ for (const token of tokens) {
265
+ const best = Math.max(
266
+ scoreField(token, name, [100, 72, 52, 38]),
267
+ scoreField(token, category, [46, 32, 24, 16]),
268
+ scoreField(token, description, [18, 14, 11, 0])
269
+ );
270
+ if (best === 0) continue;
271
+ matched++;
272
+ total += best;
273
+ }
274
+
275
+ if (matched === 0) continue;
276
+
277
+ total *= 0.45 + 0.55 * (matched / tokens.length);
278
+ // Shorter names are likelier to be the thing meant: "cors" over "cors-preflight".
279
+ total += Math.max(0, 12 - name.length / 2);
280
+
281
+ results.push({ ...entry, score: total });
282
+ }
283
+
284
+ return results.sort((a, b) => b.score - a.score || a.preset.localeCompare(b.preset));
285
+ }
286
+
287
+ module.exports = { rank, stem, expand, editDistance, normalize, SYNONYMS, STOPWORDS };