@checkstack/ai-backend 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.
- package/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DocsIndexEntry } from "../generated/docs-index";
|
|
3
|
+
import {
|
|
4
|
+
SNIPPET_MAX_CHARS,
|
|
5
|
+
buildSnippet,
|
|
6
|
+
rankDocs,
|
|
7
|
+
tokenize,
|
|
8
|
+
} from "./rank-docs";
|
|
9
|
+
|
|
10
|
+
function entry(over: Partial<DocsIndexEntry> & { slug: string }): DocsIndexEntry {
|
|
11
|
+
return {
|
|
12
|
+
title: over.title ?? over.slug,
|
|
13
|
+
headings: over.headings ?? [],
|
|
14
|
+
content: over.content ?? "",
|
|
15
|
+
truncated: over.truncated ?? false,
|
|
16
|
+
...(over.description ? { description: over.description } : {}),
|
|
17
|
+
slug: over.slug,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("tokenize", () => {
|
|
22
|
+
test("lowercases, splits on non-alphanumerics, drops <2-char tokens", () => {
|
|
23
|
+
expect(tokenize("Health-Check a HTTP 200!")).toEqual([
|
|
24
|
+
"health",
|
|
25
|
+
"check",
|
|
26
|
+
"http",
|
|
27
|
+
"200",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("buildSnippet", () => {
|
|
33
|
+
test("returns the whole content when under the budget", () => {
|
|
34
|
+
expect(buildSnippet({ content: "short text", queryTerms: ["text"] })).toBe(
|
|
35
|
+
"short text",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("windows around the first query-term match with ellipses", () => {
|
|
40
|
+
const content = "x".repeat(600) + " NEEDLE " + "y".repeat(600);
|
|
41
|
+
const snippet = buildSnippet({
|
|
42
|
+
content,
|
|
43
|
+
queryTerms: ["needle"],
|
|
44
|
+
maxChars: 100,
|
|
45
|
+
});
|
|
46
|
+
expect(snippet.toLowerCase()).toContain("needle");
|
|
47
|
+
expect(snippet.length).toBeLessThanOrEqual(100 + 2); // + ellipses
|
|
48
|
+
expect(snippet.startsWith("…")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("falls back to the head when no term occurs inline", () => {
|
|
52
|
+
const content = "a".repeat(1000);
|
|
53
|
+
const snippet = buildSnippet({
|
|
54
|
+
content,
|
|
55
|
+
queryTerms: ["zzz"],
|
|
56
|
+
maxChars: 80,
|
|
57
|
+
});
|
|
58
|
+
expect(snippet.endsWith("…")).toBe(true);
|
|
59
|
+
expect(snippet.length).toBeLessThanOrEqual(81);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("rankDocs", () => {
|
|
64
|
+
const index: DocsIndexEntry[] = [
|
|
65
|
+
entry({
|
|
66
|
+
slug: "title-hit",
|
|
67
|
+
title: "Health checks",
|
|
68
|
+
content: "Unrelated body about widgets.",
|
|
69
|
+
}),
|
|
70
|
+
entry({
|
|
71
|
+
slug: "heading-hit",
|
|
72
|
+
title: "Operations",
|
|
73
|
+
headings: ["Configuring health checks"],
|
|
74
|
+
content: "Body about operating things.",
|
|
75
|
+
}),
|
|
76
|
+
entry({
|
|
77
|
+
slug: "content-hit",
|
|
78
|
+
title: "Misc",
|
|
79
|
+
content: "Somewhere in here we mention a health check probe.",
|
|
80
|
+
}),
|
|
81
|
+
entry({
|
|
82
|
+
slug: "no-hit",
|
|
83
|
+
title: "Billing",
|
|
84
|
+
content: "Invoices and payments.",
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
test("returns [] for an empty / whitespace-only query", () => {
|
|
89
|
+
expect(rankDocs({ index, query: "", limit: 5 })).toEqual([]);
|
|
90
|
+
expect(rankDocs({ index, query: " ", limit: 5 })).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns [] when limit <= 0", () => {
|
|
94
|
+
expect(rankDocs({ index, query: "health", limit: 0 })).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("excludes pages with no matching term", () => {
|
|
98
|
+
const slugs = rankDocs({ index, query: "health check", limit: 10 }).map(
|
|
99
|
+
(h) => h.slug,
|
|
100
|
+
);
|
|
101
|
+
expect(slugs).not.toContain("no-hit");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("title match outranks heading match outranks content match", () => {
|
|
105
|
+
const hits = rankDocs({ index, query: "health check", limit: 10 });
|
|
106
|
+
const order = hits.map((h) => h.slug);
|
|
107
|
+
expect(order.indexOf("title-hit")).toBeLessThan(
|
|
108
|
+
order.indexOf("heading-hit"),
|
|
109
|
+
);
|
|
110
|
+
expect(order.indexOf("heading-hit")).toBeLessThan(
|
|
111
|
+
order.indexOf("content-hit"),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("honours the limit cap", () => {
|
|
116
|
+
const hits = rankDocs({ index, query: "health check", limit: 2 });
|
|
117
|
+
expect(hits.length).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("attaches the matching heading when the hit is a sub-section", () => {
|
|
121
|
+
const hit = rankDocs({ index, query: "configuring", limit: 5 }).find(
|
|
122
|
+
(h) => h.slug === "heading-hit",
|
|
123
|
+
);
|
|
124
|
+
expect(hit?.heading).toBe("Configuring health checks");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("snippets never exceed the budget", () => {
|
|
128
|
+
const big = entry({
|
|
129
|
+
slug: "big",
|
|
130
|
+
title: "Big",
|
|
131
|
+
content: ("health check ".repeat(500)).trim(),
|
|
132
|
+
});
|
|
133
|
+
const hit = rankDocs({ index: [big], query: "health", limit: 1 })[0];
|
|
134
|
+
expect(hit?.snippet.length).toBeLessThanOrEqual(SNIPPET_MAX_CHARS + 2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("ordering is stable / deterministic across runs", () => {
|
|
138
|
+
const a = rankDocs({ index, query: "health check", limit: 10 });
|
|
139
|
+
const b = rankDocs({ index, query: "health check", limit: 10 });
|
|
140
|
+
expect(a.map((h) => h.slug)).toEqual(b.map((h) => h.slug));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("ties break by slug (deterministic ordering)", () => {
|
|
144
|
+
const tied: DocsIndexEntry[] = [
|
|
145
|
+
entry({ slug: "zebra", title: "Foo", content: "foo" }),
|
|
146
|
+
entry({ slug: "alpha", title: "Foo", content: "foo" }),
|
|
147
|
+
];
|
|
148
|
+
const order = rankDocs({ index: tied, query: "foo", limit: 5 }).map(
|
|
149
|
+
(h) => h.slug,
|
|
150
|
+
);
|
|
151
|
+
expect(order).toEqual(["alpha", "zebra"]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { DocsIndexEntry } from "../generated/docs-index";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One ranked documentation hit produced by {@link rankDocs}. Mirrors the
|
|
5
|
+
* `DocHitSchema` wire shape in `@checkstack/ai-common` (plan §2.5) — kept as a
|
|
6
|
+
* plain interface here so the ranking module is a PURE, dependency-light,
|
|
7
|
+
* unit-testable function over the bundled index.
|
|
8
|
+
*/
|
|
9
|
+
export interface RankedDocHit {
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
/** Section heading the snippet came from, when the best match is in a heading. */
|
|
13
|
+
heading?: string;
|
|
14
|
+
/** Bounded snippet around the best match, highlighting why the page matched. */
|
|
15
|
+
snippet: string;
|
|
16
|
+
/** BM25-ish relevance score (opaque ordering hint). */
|
|
17
|
+
score: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Max characters in a returned snippet (plan §3.4 size budget). */
|
|
21
|
+
export const SNIPPET_MAX_CHARS = 500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Field weights for the BM25-ish term-frequency score. A query term in the
|
|
25
|
+
* title outranks the same term in a heading, which outranks one in the body.
|
|
26
|
+
*/
|
|
27
|
+
const TITLE_WEIGHT = 8;
|
|
28
|
+
const HEADING_WEIGHT = 4;
|
|
29
|
+
const DESCRIPTION_WEIGHT = 3;
|
|
30
|
+
const CONTENT_WEIGHT = 1;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* BM25 term-frequency saturation constant. Diminishing returns past a few
|
|
34
|
+
* occurrences of a term, so a page that merely repeats a word does not dominate
|
|
35
|
+
* a page that genuinely covers the topic.
|
|
36
|
+
*/
|
|
37
|
+
const BM25_K1 = 1.5;
|
|
38
|
+
|
|
39
|
+
/** Lowercases and splits text into alphanumeric tokens (length >= 2). */
|
|
40
|
+
export function tokenize(text: string): string[] {
|
|
41
|
+
return text
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.split(/[^a-z0-9]+/)
|
|
44
|
+
.filter((t) => t.length >= 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Counts occurrences of each token in a token list. */
|
|
48
|
+
function termCounts(tokens: string[]): Map<string, number> {
|
|
49
|
+
const counts = new Map<string, number>();
|
|
50
|
+
for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
51
|
+
return counts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* BM25-style term-frequency saturation: tf * (k1 + 1) / (tf + k1). Bounded and
|
|
56
|
+
* monotonic, so more occurrences help but with diminishing returns.
|
|
57
|
+
*/
|
|
58
|
+
function saturate(tf: number): number {
|
|
59
|
+
if (tf <= 0) return 0;
|
|
60
|
+
return (tf * (BM25_K1 + 1)) / (tf + BM25_K1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ScorableEntry {
|
|
64
|
+
entry: DocsIndexEntry;
|
|
65
|
+
titleTokens: Map<string, number>;
|
|
66
|
+
headingTokensByHeading: { heading: string; counts: Map<string, number> }[];
|
|
67
|
+
descriptionTokens: Map<string, number>;
|
|
68
|
+
contentTokens: Map<string, number>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function prepare(entry: DocsIndexEntry): ScorableEntry {
|
|
72
|
+
return {
|
|
73
|
+
entry,
|
|
74
|
+
titleTokens: termCounts(tokenize(entry.title)),
|
|
75
|
+
headingTokensByHeading: entry.headings.map((heading) => ({
|
|
76
|
+
heading,
|
|
77
|
+
counts: termCounts(tokenize(heading)),
|
|
78
|
+
})),
|
|
79
|
+
descriptionTokens: termCounts(tokenize(entry.description ?? "")),
|
|
80
|
+
contentTokens: termCounts(tokenize(entry.content)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Picks the best-matching heading for a query (the heading containing the most
|
|
86
|
+
* query terms, by saturated term frequency). Returns `undefined` when no
|
|
87
|
+
* heading matches any query term.
|
|
88
|
+
*/
|
|
89
|
+
function bestHeading(
|
|
90
|
+
scorable: ScorableEntry,
|
|
91
|
+
queryTerms: string[],
|
|
92
|
+
): string | undefined {
|
|
93
|
+
let best: string | undefined;
|
|
94
|
+
let bestScore = 0;
|
|
95
|
+
for (const { heading, counts } of scorable.headingTokensByHeading) {
|
|
96
|
+
let score = 0;
|
|
97
|
+
for (const term of queryTerms) score += saturate(counts.get(term) ?? 0);
|
|
98
|
+
if (score > bestScore) {
|
|
99
|
+
bestScore = score;
|
|
100
|
+
best = heading;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds a bounded snippet around the first occurrence of any query term in the
|
|
108
|
+
* content; falls back to the content head when no term is found inline (e.g. the
|
|
109
|
+
* match was purely in the title). Whitespace is collapsed for compactness.
|
|
110
|
+
*/
|
|
111
|
+
export function buildSnippet({
|
|
112
|
+
content,
|
|
113
|
+
queryTerms,
|
|
114
|
+
maxChars = SNIPPET_MAX_CHARS,
|
|
115
|
+
}: {
|
|
116
|
+
content: string;
|
|
117
|
+
queryTerms: string[];
|
|
118
|
+
maxChars?: number;
|
|
119
|
+
}): string {
|
|
120
|
+
const normalized = content.replaceAll(/\s+/g, " ").trim();
|
|
121
|
+
if (normalized.length <= maxChars) return normalized;
|
|
122
|
+
|
|
123
|
+
const lower = normalized.toLowerCase();
|
|
124
|
+
let matchAt = -1;
|
|
125
|
+
for (const term of queryTerms) {
|
|
126
|
+
const idx = lower.indexOf(term);
|
|
127
|
+
if (idx !== -1 && (matchAt === -1 || idx < matchAt)) matchAt = idx;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (matchAt === -1) {
|
|
131
|
+
return normalized.slice(0, maxChars).trimEnd() + "…";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Center the window on the match, then clamp to the content bounds.
|
|
135
|
+
const half = Math.floor(maxChars / 2);
|
|
136
|
+
let start = Math.max(0, matchAt - half);
|
|
137
|
+
const end = Math.min(normalized.length, start + maxChars);
|
|
138
|
+
start = Math.max(0, end - maxChars);
|
|
139
|
+
|
|
140
|
+
const prefix = start > 0 ? "…" : "";
|
|
141
|
+
const suffix = end < normalized.length ? "…" : "";
|
|
142
|
+
return prefix + normalized.slice(start, end).trim() + suffix;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pure BM25-ish ranking over the bundled docs index. Tokenizes the query and
|
|
147
|
+
* scores each entry by saturated term frequency across title (boosted),
|
|
148
|
+
* headings (boosted), description, and content; returns the top-`limit` hits
|
|
149
|
+
* with a bounded snippet around the best match.
|
|
150
|
+
*
|
|
151
|
+
* Deterministic: ties break by score then slug, so the order is stable.
|
|
152
|
+
* Returns `[]` for an empty/whitespace query or when nothing matches.
|
|
153
|
+
*/
|
|
154
|
+
export function rankDocs({
|
|
155
|
+
index,
|
|
156
|
+
query,
|
|
157
|
+
limit,
|
|
158
|
+
}: {
|
|
159
|
+
index: readonly DocsIndexEntry[];
|
|
160
|
+
query: string;
|
|
161
|
+
limit: number;
|
|
162
|
+
}): RankedDocHit[] {
|
|
163
|
+
const queryTerms = [...new Set(tokenize(query))];
|
|
164
|
+
if (queryTerms.length === 0 || limit <= 0) return [];
|
|
165
|
+
|
|
166
|
+
const scored = index.map((entry) => {
|
|
167
|
+
const scorable = prepare(entry);
|
|
168
|
+
let score = 0;
|
|
169
|
+
for (const term of queryTerms) {
|
|
170
|
+
score += TITLE_WEIGHT * saturate(scorable.titleTokens.get(term) ?? 0);
|
|
171
|
+
score +=
|
|
172
|
+
DESCRIPTION_WEIGHT * saturate(scorable.descriptionTokens.get(term) ?? 0);
|
|
173
|
+
score += CONTENT_WEIGHT * saturate(scorable.contentTokens.get(term) ?? 0);
|
|
174
|
+
// Headings: sum the term's saturated tf across every heading.
|
|
175
|
+
let headingTf = 0;
|
|
176
|
+
for (const { counts } of scorable.headingTokensByHeading) {
|
|
177
|
+
headingTf += counts.get(term) ?? 0;
|
|
178
|
+
}
|
|
179
|
+
score += HEADING_WEIGHT * saturate(headingTf);
|
|
180
|
+
}
|
|
181
|
+
return { scorable, score };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return scored
|
|
185
|
+
.filter((s) => s.score > 0)
|
|
186
|
+
.toSorted((a, b) =>
|
|
187
|
+
b.score === a.score
|
|
188
|
+
? a.scorable.entry.slug < b.scorable.entry.slug
|
|
189
|
+
? -1
|
|
190
|
+
: a.scorable.entry.slug > b.scorable.entry.slug
|
|
191
|
+
? 1
|
|
192
|
+
: 0
|
|
193
|
+
: b.score - a.score,
|
|
194
|
+
)
|
|
195
|
+
.slice(0, limit)
|
|
196
|
+
.map(({ scorable, score }) => {
|
|
197
|
+
const heading = bestHeading(scorable, queryTerms);
|
|
198
|
+
return {
|
|
199
|
+
slug: scorable.entry.slug,
|
|
200
|
+
title: scorable.entry.title,
|
|
201
|
+
...(heading ? { heading } : {}),
|
|
202
|
+
snippet: buildSnippet({
|
|
203
|
+
content: scorable.entry.content,
|
|
204
|
+
queryTerms,
|
|
205
|
+
}),
|
|
206
|
+
score,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SDK_EDITOR_BUNDLE_DTS } from "@checkstack/sdk/editor-bundle";
|
|
3
|
+
import {
|
|
4
|
+
resolveScriptContext,
|
|
5
|
+
extractDeclareModuleBlock,
|
|
6
|
+
ScriptContextExtractionError,
|
|
7
|
+
HEALTHCHECK_SHELL_ENV,
|
|
8
|
+
AUTOMATION_SHELL_ENV,
|
|
9
|
+
} from "./script-context-extract";
|
|
10
|
+
|
|
11
|
+
describe("extractDeclareModuleBlock (pure)", () => {
|
|
12
|
+
test("returns the full declare-module block for a known module", () => {
|
|
13
|
+
const block = extractDeclareModuleBlock({
|
|
14
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
15
|
+
moduleName: "@checkstack/sdk/healthcheck",
|
|
16
|
+
});
|
|
17
|
+
expect(block.startsWith('declare module "@checkstack/sdk/healthcheck"')).toBe(
|
|
18
|
+
true,
|
|
19
|
+
);
|
|
20
|
+
expect(block.trimEnd().endsWith("}")).toBe(true);
|
|
21
|
+
// It must contain the helper + context types, and NOT bleed into the next module.
|
|
22
|
+
expect(block).toContain("defineHealthCheck");
|
|
23
|
+
expect(block).toContain("HealthCheckScriptContext");
|
|
24
|
+
expect(block).not.toContain("defineIntegration");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("brace-matches a nested block correctly (no early termination)", () => {
|
|
28
|
+
const bundle =
|
|
29
|
+
'declare module "x" {\n interface A { readonly b: { readonly c: string } }\n}\ndeclare module "y" { export const z: number; }';
|
|
30
|
+
const block = extractDeclareModuleBlock({ bundle, moduleName: "x" });
|
|
31
|
+
expect(block).toContain("readonly c: string");
|
|
32
|
+
expect(block).not.toContain("export const z");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("throws a clear error for a missing module", () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
extractDeclareModuleBlock({
|
|
38
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
39
|
+
moduleName: "@checkstack/sdk/does-not-exist",
|
|
40
|
+
}),
|
|
41
|
+
).toThrow(ScriptContextExtractionError);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveScriptContext (pure)", () => {
|
|
46
|
+
test("healthcheck-script extracts the healthcheck module + helper", () => {
|
|
47
|
+
const resolved = resolveScriptContext({
|
|
48
|
+
context: "healthcheck-script",
|
|
49
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
50
|
+
});
|
|
51
|
+
expect(resolved.language).toBe("typescript");
|
|
52
|
+
expect(resolved.sdkModule).toBe("@checkstack/sdk/healthcheck");
|
|
53
|
+
expect(resolved.helper).toBe("defineHealthCheck");
|
|
54
|
+
expect(resolved.declarations).toContain("HealthCheckScriptResult");
|
|
55
|
+
expect(resolved.shellEnv).toBeUndefined();
|
|
56
|
+
expect(resolved.allowsManagedPackages).toBe(true);
|
|
57
|
+
expect(resolved.starterExample).toContain("defineHealthCheck");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("automation-action-script extracts the integration module + helper", () => {
|
|
61
|
+
const resolved = resolveScriptContext({
|
|
62
|
+
context: "automation-action-script",
|
|
63
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
64
|
+
});
|
|
65
|
+
expect(resolved.sdkModule).toBe("@checkstack/sdk/integration");
|
|
66
|
+
expect(resolved.helper).toBe("defineIntegration");
|
|
67
|
+
expect(resolved.declarations).toContain("IntegrationScriptContext");
|
|
68
|
+
expect(resolved.declarations).not.toContain("defineHealthCheck");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("healthcheck-shell returns the env table, no SDK module", () => {
|
|
72
|
+
const resolved = resolveScriptContext({
|
|
73
|
+
context: "healthcheck-shell",
|
|
74
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
75
|
+
});
|
|
76
|
+
expect(resolved.language).toBe("shell");
|
|
77
|
+
expect(resolved.sdkModule).toBeUndefined();
|
|
78
|
+
expect(resolved.helper).toBeUndefined();
|
|
79
|
+
expect(resolved.shellEnv).toBe(HEALTHCHECK_SHELL_ENV);
|
|
80
|
+
expect(resolved.declarations).toContain("CHECKSTACK_CHECK_ID");
|
|
81
|
+
expect(resolved.allowsManagedPackages).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("automation-action-shell returns the automation env table", () => {
|
|
85
|
+
const resolved = resolveScriptContext({
|
|
86
|
+
context: "automation-action-shell",
|
|
87
|
+
bundle: SDK_EDITOR_BUNDLE_DTS,
|
|
88
|
+
});
|
|
89
|
+
expect(resolved.language).toBe("shell");
|
|
90
|
+
expect(resolved.shellEnv).toBe(AUTOMATION_SHELL_ENV);
|
|
91
|
+
expect(resolved.declarations).toContain("CHECKSTACK_EVENT_ID");
|
|
92
|
+
});
|
|
93
|
+
});
|