@friehub/blueprint 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/.github/workflows/ci.yml +122 -0
- package/.github/workflows/publish.yml +24 -0
- package/README.md +266 -0
- package/adapters/analytics/amplitude.yaml +44 -0
- package/adapters/analytics/mixpanel.yaml +47 -0
- package/adapters/analytics/segment.yaml +40 -0
- package/adapters/auth/auth0.yaml +56 -0
- package/adapters/auth/clerk.yaml +53 -0
- package/adapters/auth/supertokens.yaml +55 -0
- package/adapters/billing/paddle.yaml +57 -0
- package/adapters/billing/stripe.yaml +49 -0
- package/adapters/caching/memcached.yaml +28 -0
- package/adapters/caching/redis.yaml +37 -0
- package/adapters/chargebacks/chargebacks911.yaml +45 -0
- package/adapters/chargebacks/stripe.yaml +45 -0
- package/adapters/crm_leads/hubspot.yaml +43 -0
- package/adapters/crm_leads/salesforce.yaml +60 -0
- package/adapters/customer_support/intercom.yaml +44 -0
- package/adapters/customer_support/zendesk.yaml +51 -0
- package/adapters/donations/paypal.yaml +47 -0
- package/adapters/donations/stripe.yaml +47 -0
- package/adapters/emails/mailgun.yaml +47 -0
- package/adapters/emails/resend.yaml +43 -0
- package/adapters/emails/sendgrid.yaml +43 -0
- package/adapters/error_tracking/bugsnag.yaml +52 -0
- package/adapters/error_tracking/sentry.yaml +58 -0
- package/adapters/feature_flags/flagsmith.yaml +41 -0
- package/adapters/feature_flags/launchdarkly.yaml +41 -0
- package/adapters/feature_flags/unleash.yaml +41 -0
- package/adapters/fraud_detection/riskified.yaml +41 -0
- package/adapters/fraud_detection/sift.yaml +40 -0
- package/adapters/fulfillment/easyship.yaml +51 -0
- package/adapters/fulfillment/shipengine.yaml +51 -0
- package/adapters/incident_management/opsgenie.yaml +49 -0
- package/adapters/incident_management/pagerduty.yaml +48 -0
- package/adapters/invoicing/freshbooks.yaml +54 -0
- package/adapters/invoicing/stripe.yaml +47 -0
- package/adapters/ip_intelligence/ipinfo.yaml +37 -0
- package/adapters/ip_intelligence/maxmind.yaml +39 -0
- package/adapters/jobs/bullmq.yaml +54 -0
- package/adapters/jobs/temporal.yaml +53 -0
- package/adapters/kyc/jumio.yaml +54 -0
- package/adapters/kyc/onfido.yaml +53 -0
- package/adapters/media/cloudinary.yaml +48 -0
- package/adapters/media/imgix.yaml +47 -0
- package/adapters/notifications/firebase.yaml +45 -0
- package/adapters/notifications/novu.yaml +46 -0
- package/adapters/notifications/onesignal.yaml +45 -0
- package/adapters/payments/adyen.yaml +46 -0
- package/adapters/payments/paystack.yaml +45 -0
- package/adapters/payments/stripe.yaml +49 -0
- package/adapters/payouts/paypal.yaml +49 -0
- package/adapters/payouts/stripe.yaml +49 -0
- package/adapters/projects/asana.yaml +49 -0
- package/adapters/projects/jira.yaml +58 -0
- package/adapters/projects/linear.yaml +49 -0
- package/adapters/queues/bullmq.yaml +47 -0
- package/adapters/queues/rabbitmq.yaml +51 -0
- package/adapters/queues/sqs.yaml +45 -0
- package/adapters/rate_limiting/cloudflare.yaml +37 -0
- package/adapters/rate_limiting/upstash.yaml +35 -0
- package/adapters/search/algolia.yaml +39 -0
- package/adapters/search/meilisearch.yaml +39 -0
- package/adapters/search/typesense.yaml +42 -0
- package/adapters/shipping/easyship.yaml +45 -0
- package/adapters/shipping/shipengine.yaml +45 -0
- package/adapters/sms/twilio.yaml +41 -0
- package/adapters/sms/vonage.yaml +41 -0
- package/adapters/storage/azure-blob.yaml +42 -0
- package/adapters/storage/gcs.yaml +41 -0
- package/adapters/storage/s3.yaml +49 -0
- package/adapters/subscriptions/chargebee.yaml +32 -0
- package/adapters/subscriptions/stripe.yaml +37 -0
- package/adapters/tasks/asana.yaml +50 -0
- package/adapters/tasks/jira.yaml +59 -0
- package/adapters/tasks/linear.yaml +50 -0
- package/adapters/taxation/avalara.yaml +52 -0
- package/adapters/taxation/taxjar.yaml +48 -0
- package/adapters/trace_query/datadog.yaml +49 -0
- package/adapters/trace_query/honeycomb.yaml +42 -0
- package/adapters/trace_query/jaeger.yaml +42 -0
- package/adapters/web_analytics/google-analytics.yaml +42 -0
- package/adapters/web_analytics/plausible.yaml +34 -0
- package/adapters/web_analytics/posthog.yaml +34 -0
- package/adapters/webhooks/relay.yaml +35 -0
- package/adapters/webhooks/svix.yaml +41 -0
- package/blueprint.json +5 -0
- package/blueprinter_system_design.svg +139 -0
- package/catalog.json +37943 -0
- package/dist/cli/commands.js +362 -0
- package/dist/cli/help.js +211 -0
- package/dist/cli/render.js +109 -0
- package/dist/cli.js +69 -0
- package/dist/core/adapters/adapter-audit.test.js +85 -0
- package/dist/core/adapters/adapter.test.js +66 -0
- package/dist/core/adapters/index.js +4 -0
- package/dist/core/adapters/load.js +131 -0
- package/dist/core/adapters/resolve.js +78 -0
- package/dist/core/adapters/select.js +80 -0
- package/dist/core/adapters/types.js +1 -0
- package/dist/core/adapters/validate.js +121 -0
- package/dist/core/catalog.js +3 -0
- package/dist/core/collectors.js +126 -0
- package/dist/core/discovery.js +57 -0
- package/dist/core/edge-cases.test.js +147 -0
- package/dist/core/envelope.js +1 -0
- package/dist/core/graph.js +123 -0
- package/dist/core/graph.test.js +62 -0
- package/dist/core/implement.js +48 -0
- package/dist/core/index.js +9 -0
- package/dist/core/load-catalog.js +24 -0
- package/dist/core/parse-document.js +114 -0
- package/dist/core/parse-document.test.js +104 -0
- package/dist/core/parser.js +6 -0
- package/dist/core/parser.test.js +134 -0
- package/dist/core/resolve.js +119 -0
- package/dist/core/resolve.test.js +108 -0
- package/dist/core/scanner.js +163 -0
- package/dist/core/search.js +34 -0
- package/dist/core/search.test.js +43 -0
- package/dist/core/section-body.js +258 -0
- package/dist/core/sections.js +53 -0
- package/dist/core/verify-implement.test.js +123 -0
- package/dist/core/verify.js +156 -0
- package/dist/generators/engine.js +86 -0
- package/dist/generators/generator.test.js +112 -0
- package/dist/generators/index.js +3 -0
- package/dist/generators/prototype/index.js +242 -0
- package/dist/generators/render.js +146 -0
- package/dist/generators/types.js +124 -0
- package/dist/generators/typescript/helpers.js +125 -0
- package/dist/generators/typescript/index.js +206 -0
- package/dist/index.js +8 -0
- package/dist/mcp/server.js +202 -0
- package/dist/mcp/server.test.js +136 -0
- package/dist/utils/args.js +142 -0
- package/package.json +38 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { SECTION_DEFINITIONS, normalizeSectionHeader } from "./sections.js";
|
|
2
|
+
const H1_RE = /^#\s+(.+)$/;
|
|
3
|
+
const H3_RE = /^###\s+(.+)$/;
|
|
4
|
+
const H2_RE = /^##\s+(.+)$/;
|
|
5
|
+
const SEPARATOR_RE = /^---\s*$/;
|
|
6
|
+
export function scanDocument(file, text) {
|
|
7
|
+
const lines = text.split(/\r?\n/);
|
|
8
|
+
const source = { file, startLine: 1, endLine: Math.max(1, lines.length) };
|
|
9
|
+
const envelope = parseEnvelope(file, lines);
|
|
10
|
+
const sections = [];
|
|
11
|
+
let preambleStart = -1;
|
|
12
|
+
let preambleEnd = -1;
|
|
13
|
+
let currentSection = null;
|
|
14
|
+
let seenFirstKnownSection = false;
|
|
15
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
16
|
+
const line = lines[index];
|
|
17
|
+
const lineNumber = index + 1;
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (lineNumber === envelope.titleLine || SEPARATOR_RE.test(trimmed)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const sectionHeader = detectSectionHeader(trimmed, envelope.kind);
|
|
23
|
+
if (sectionHeader) {
|
|
24
|
+
if (!seenFirstKnownSection) {
|
|
25
|
+
seenFirstKnownSection = true;
|
|
26
|
+
}
|
|
27
|
+
if (currentSection) {
|
|
28
|
+
currentSection.endLine = lineNumber - 1;
|
|
29
|
+
sections.push(currentSection);
|
|
30
|
+
}
|
|
31
|
+
currentSection = {
|
|
32
|
+
file,
|
|
33
|
+
name: sectionHeader.name,
|
|
34
|
+
aliases: sectionHeader.aliases,
|
|
35
|
+
kind: sectionHeader.kind,
|
|
36
|
+
content: sectionHeader.inlineContent ? `${sectionHeader.inlineContent}\n` : "",
|
|
37
|
+
startLine: lineNumber,
|
|
38
|
+
endLine: lineNumber,
|
|
39
|
+
};
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!seenFirstKnownSection) {
|
|
43
|
+
if (trimmed) {
|
|
44
|
+
if (preambleStart === -1)
|
|
45
|
+
preambleStart = lineNumber;
|
|
46
|
+
preambleEnd = lineNumber;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (currentSection) {
|
|
51
|
+
currentSection.content += line + "\n";
|
|
52
|
+
currentSection.endLine = lineNumber;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (currentSection) {
|
|
56
|
+
sections.push(currentSection);
|
|
57
|
+
}
|
|
58
|
+
const preamble = preambleStart === -1
|
|
59
|
+
? null
|
|
60
|
+
: {
|
|
61
|
+
content: lines.slice(preambleStart - 1, preambleEnd).join("\n").trim(),
|
|
62
|
+
startLine: preambleStart,
|
|
63
|
+
endLine: preambleEnd,
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
envelope,
|
|
67
|
+
preamble,
|
|
68
|
+
sections,
|
|
69
|
+
source,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function parseEnvelope(file, lines) {
|
|
73
|
+
const kind = /(^|[\\/])core([\\/]|$)/.test(file) ? "core" : "module";
|
|
74
|
+
const headingLine = lines.findIndex((line) => /^#{1,3}\s+/.test(line.trim()));
|
|
75
|
+
const titleLine = headingLine === -1 ? 1 : headingLine + 1;
|
|
76
|
+
const titleText = headingLine === -1 ? basenameWithoutExtension(file) : lines[headingLine].trim().replace(/^#{1,3}\s+/, "");
|
|
77
|
+
if (headingLine === -1) {
|
|
78
|
+
return {
|
|
79
|
+
kind,
|
|
80
|
+
name: basenameWithoutExtension(file),
|
|
81
|
+
title: basenameWithoutExtension(file),
|
|
82
|
+
titleLine,
|
|
83
|
+
source: { file, startLine: 1, endLine: 1 },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (kind === "module") {
|
|
87
|
+
const nameMatch = titleText.match(/`([^`]+)`/) ?? titleText.match(/Module Contract:\s*(.+)$/i);
|
|
88
|
+
return {
|
|
89
|
+
kind,
|
|
90
|
+
name: (nameMatch?.[1] ?? basenameWithoutExtension(file)).trim(),
|
|
91
|
+
title: titleText,
|
|
92
|
+
titleLine,
|
|
93
|
+
source: { file, startLine: 1, endLine: 1 },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
kind,
|
|
98
|
+
name: basenameWithoutExtension(file),
|
|
99
|
+
title: titleText,
|
|
100
|
+
titleLine,
|
|
101
|
+
source: { file, startLine: 1, endLine: 1 },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function detectSectionHeader(trimmedLine, kind) {
|
|
105
|
+
const inlineMatch = matchKnownHeaderWithInlineContent(trimmedLine);
|
|
106
|
+
if (inlineMatch)
|
|
107
|
+
return inlineMatch;
|
|
108
|
+
const headingText = trimmedLine.replace(/^#{2,3}\s+/, "").trim();
|
|
109
|
+
const normalized = normalizeSectionHeader(headingText);
|
|
110
|
+
if (normalized) {
|
|
111
|
+
return {
|
|
112
|
+
name: normalized,
|
|
113
|
+
aliases: [],
|
|
114
|
+
kind: "known",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (H2_RE.test(trimmedLine)) {
|
|
118
|
+
return {
|
|
119
|
+
name: headingText,
|
|
120
|
+
aliases: [],
|
|
121
|
+
kind: "unknown",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (H3_RE.test(trimmedLine)) {
|
|
125
|
+
if (kind === "core") {
|
|
126
|
+
return {
|
|
127
|
+
name: headingText,
|
|
128
|
+
aliases: [],
|
|
129
|
+
kind: "unknown",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function matchKnownHeaderWithInlineContent(line) {
|
|
137
|
+
for (const definition of SECTION_DEFINITIONS) {
|
|
138
|
+
const candidates = [definition.header, ...definition.aliases];
|
|
139
|
+
for (const candidate of candidates) {
|
|
140
|
+
const lowerCandidate = candidate.toLowerCase();
|
|
141
|
+
const lowerLine = line.toLowerCase();
|
|
142
|
+
if (!lowerLine.startsWith(lowerCandidate))
|
|
143
|
+
continue;
|
|
144
|
+
const remainder = line.slice(candidate.length).trim();
|
|
145
|
+
if (remainder && !/^\s/.test(line.slice(candidate.length, candidate.length + 1))) {
|
|
146
|
+
// If the next character is not whitespace, it's likely a different token.
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
name: definition.name,
|
|
151
|
+
aliases: [candidate],
|
|
152
|
+
kind: "known",
|
|
153
|
+
inlineContent: remainder,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function basenameWithoutExtension(file) {
|
|
160
|
+
const parts = file.split(/[\\/]/);
|
|
161
|
+
const filename = parts[parts.length - 1] ?? file;
|
|
162
|
+
return filename.replace(/\.md$/i, "");
|
|
163
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function searchModules(catalog, query) {
|
|
2
|
+
const results = [];
|
|
3
|
+
const q = query.toLowerCase();
|
|
4
|
+
for (const mod of catalog.modules) {
|
|
5
|
+
let score = 0;
|
|
6
|
+
let matchType = "summary";
|
|
7
|
+
if (mod.name.toLowerCase() === q) {
|
|
8
|
+
score = 100;
|
|
9
|
+
matchType = "name";
|
|
10
|
+
}
|
|
11
|
+
else if (mod.name.toLowerCase().includes(q)) {
|
|
12
|
+
score = 80;
|
|
13
|
+
matchType = "name";
|
|
14
|
+
}
|
|
15
|
+
else if (mod.summary?.toLowerCase().includes(q)) {
|
|
16
|
+
score = 60;
|
|
17
|
+
matchType = "summary";
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
for (const fn of mod.functions) {
|
|
21
|
+
if (fn.name.toLowerCase().includes(q)) {
|
|
22
|
+
score = 40;
|
|
23
|
+
matchType = "function";
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (score > 0) {
|
|
29
|
+
results.push({ module: mod, score, matchType });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
results.sort((a, b) => b.score - a.score || a.module.name.localeCompare(b.module.name));
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { loadCatalogFromRoot } from "./index.js";
|
|
4
|
+
import { searchModules } from "./search.js";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
7
|
+
describe("search", () => {
|
|
8
|
+
it("finds modules by exact name", async () => {
|
|
9
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
10
|
+
assert.notEqual(result.value, null);
|
|
11
|
+
const results = searchModules(result.value, "billing");
|
|
12
|
+
assert.ok(results.length > 0);
|
|
13
|
+
assert.equal(results[0].module.name, "billing");
|
|
14
|
+
assert.equal(results[0].score, 100);
|
|
15
|
+
});
|
|
16
|
+
it("finds modules by partial name", async () => {
|
|
17
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
18
|
+
assert.notEqual(result.value, null);
|
|
19
|
+
const results = searchModules(result.value, "bill");
|
|
20
|
+
assert.ok(results.length > 0);
|
|
21
|
+
assert.ok(results.some((r) => r.module.name === "billing"));
|
|
22
|
+
});
|
|
23
|
+
it("finds modules by summary", async () => {
|
|
24
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
25
|
+
assert.notEqual(result.value, null);
|
|
26
|
+
const results = searchModules(result.value, "subscription");
|
|
27
|
+
assert.ok(results.length > 0);
|
|
28
|
+
assert.ok(results.some((r) => r.module.name === "billing"));
|
|
29
|
+
});
|
|
30
|
+
it("returns empty for no matches", async () => {
|
|
31
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
32
|
+
assert.notEqual(result.value, null);
|
|
33
|
+
const results = searchModules(result.value, "zzzznonexistent");
|
|
34
|
+
assert.equal(results.length, 0);
|
|
35
|
+
});
|
|
36
|
+
it("sorts by score", async () => {
|
|
37
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
38
|
+
assert.notEqual(result.value, null);
|
|
39
|
+
const results = searchModules(result.value, "payment");
|
|
40
|
+
assert.ok(results.length > 1);
|
|
41
|
+
assert.equal(results[0].module.name, "payments");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const FENCED_BLOCK_RE = /^```(?:[a-zA-Z0-9_-]+)?\s*$/;
|
|
2
|
+
const FUNCTION_SIGNATURE_RE = /^([A-Za-z_][A-Za-z0-9_]*)(?:<[^>]+>)?\s*\((.*)\)\s*→\s*(.+)$/;
|
|
3
|
+
const TYPE_ALIAS_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/;
|
|
4
|
+
const TYPE_RECORD_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*(.+)\s*\}$/;
|
|
5
|
+
export function classifySectionBody(text, startLine, endLine) {
|
|
6
|
+
const lines = text.split(/\r?\n/);
|
|
7
|
+
const nonEmpty = lines.filter((line) => line.trim().length > 0);
|
|
8
|
+
const hasFence = nonEmpty.some((line) => FENCED_BLOCK_RE.test(line.trim()));
|
|
9
|
+
const hasBullets = nonEmpty.some((line) => /^[-*+]\s+/.test(line.trim()));
|
|
10
|
+
if (hasFence && hasBullets) {
|
|
11
|
+
return { kind: "mixed", raw: text, lines, startLine, endLine };
|
|
12
|
+
}
|
|
13
|
+
if (hasFence) {
|
|
14
|
+
return { kind: "fenced", raw: text, lines, startLine, endLine };
|
|
15
|
+
}
|
|
16
|
+
if (hasBullets) {
|
|
17
|
+
return { kind: "bullets", raw: text, lines, startLine, endLine };
|
|
18
|
+
}
|
|
19
|
+
if (nonEmpty.length > 1) {
|
|
20
|
+
return { kind: "paragraphs", raw: text, lines, startLine, endLine };
|
|
21
|
+
}
|
|
22
|
+
return { kind: "raw", raw: text, lines, startLine, endLine };
|
|
23
|
+
}
|
|
24
|
+
export function parseFunctionSignatureLine(line, source) {
|
|
25
|
+
const trimmed = line.trim().replace(/^#{2,3}\s+/, "").replace(/^`(.+)`$/, "$1");
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return null;
|
|
28
|
+
const match = trimmed.match(FUNCTION_SIGNATURE_RE);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
const [, name = "", paramsRaw = "", returns = ""] = match;
|
|
32
|
+
const params = paramsRaw.trim() ? parseParameters(paramsRaw) : [];
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
params,
|
|
36
|
+
returns: returns.trim(),
|
|
37
|
+
signature: trimmed,
|
|
38
|
+
raw: line,
|
|
39
|
+
source,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function parseTypeLine(line, source) {
|
|
43
|
+
const trimmed = line.trim().replace(/^#{2,3}\s+/, "");
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
return null;
|
|
46
|
+
const typeKeyword = trimmed.match(/^type\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
47
|
+
if (typeKeyword) {
|
|
48
|
+
return {
|
|
49
|
+
name: typeKeyword[1] ?? "",
|
|
50
|
+
raw: trimmed,
|
|
51
|
+
source,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const interfaceKeyword = trimmed.match(/^interface\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
|
|
55
|
+
if (interfaceKeyword) {
|
|
56
|
+
return {
|
|
57
|
+
name: interfaceKeyword[1] ?? "",
|
|
58
|
+
raw: trimmed,
|
|
59
|
+
source,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const alias = trimmed.match(TYPE_ALIAS_RE);
|
|
63
|
+
if (alias) {
|
|
64
|
+
return {
|
|
65
|
+
name: alias[1] ?? "",
|
|
66
|
+
raw: trimmed,
|
|
67
|
+
source,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const record = trimmed.match(TYPE_RECORD_RE);
|
|
71
|
+
if (record) {
|
|
72
|
+
return {
|
|
73
|
+
name: record[1] ?? "",
|
|
74
|
+
raw: trimmed,
|
|
75
|
+
source,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
export function collectMultiLineTypes(body, source) {
|
|
81
|
+
const items = [];
|
|
82
|
+
const lines = body.lines;
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < lines.length) {
|
|
85
|
+
const line = lines[i].trim();
|
|
86
|
+
if (!line || line.startsWith("```") || /^[-*+]\s+/.test(line)) {
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const typeMatch = line.match(/^type\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{/);
|
|
91
|
+
if (typeMatch) {
|
|
92
|
+
const name = typeMatch[1] ?? "";
|
|
93
|
+
const startLine = i;
|
|
94
|
+
let raw = line;
|
|
95
|
+
let braceDepth = 1;
|
|
96
|
+
i++;
|
|
97
|
+
while (i < lines.length && braceDepth > 0) {
|
|
98
|
+
const nextLine = lines[i].trim();
|
|
99
|
+
raw += "\n" + nextLine;
|
|
100
|
+
for (const ch of nextLine) {
|
|
101
|
+
if (ch === "{")
|
|
102
|
+
braceDepth++;
|
|
103
|
+
if (ch === "}")
|
|
104
|
+
braceDepth--;
|
|
105
|
+
}
|
|
106
|
+
if (braceDepth === 0)
|
|
107
|
+
break;
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
items.push({
|
|
111
|
+
name,
|
|
112
|
+
raw,
|
|
113
|
+
source: {
|
|
114
|
+
file: source.file,
|
|
115
|
+
startLine: source.startLine + startLine,
|
|
116
|
+
endLine: source.startLine + i,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const parsed = parseTypeLine(line, {
|
|
123
|
+
file: source.file,
|
|
124
|
+
startLine: source.startLine + i,
|
|
125
|
+
endLine: source.startLine + i,
|
|
126
|
+
});
|
|
127
|
+
if (parsed)
|
|
128
|
+
items.push(parsed);
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
export function parseParameters(paramsRaw) {
|
|
134
|
+
return splitTopLevel(paramsRaw, ",")
|
|
135
|
+
.map((chunk) => chunk.trim())
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.map((chunk) => {
|
|
138
|
+
const [left, defaultValue] = splitOnce(chunk, "=");
|
|
139
|
+
const [namePart, typePart] = splitOnce(left.trim(), ":");
|
|
140
|
+
const optional = namePart.trim().endsWith("?");
|
|
141
|
+
const name = namePart.trim().replace(/\?$/, "");
|
|
142
|
+
const parameter = {
|
|
143
|
+
name,
|
|
144
|
+
type: typePart ? typePart.trim() : inferParamType(name),
|
|
145
|
+
optional,
|
|
146
|
+
};
|
|
147
|
+
if (defaultValue !== undefined) {
|
|
148
|
+
parameter.defaultValue = defaultValue.trim();
|
|
149
|
+
}
|
|
150
|
+
return parameter;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export function splitOnce(input, delimiter) {
|
|
154
|
+
const index = input.indexOf(delimiter);
|
|
155
|
+
if (index === -1)
|
|
156
|
+
return [input, undefined];
|
|
157
|
+
return [input.slice(0, index), input.slice(index + delimiter.length)];
|
|
158
|
+
}
|
|
159
|
+
export function splitTopLevel(input, delimiter) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
let current = "";
|
|
162
|
+
let depth = 0;
|
|
163
|
+
const delimiterChar = delimiter[0] ?? ",";
|
|
164
|
+
for (const ch of input) {
|
|
165
|
+
if (ch === "<" || ch === "(" || ch === "[")
|
|
166
|
+
depth += 1;
|
|
167
|
+
if (ch === ">" || ch === ")" || ch === "]")
|
|
168
|
+
depth = Math.max(0, depth - 1);
|
|
169
|
+
if (ch === delimiterChar && depth === 0) {
|
|
170
|
+
parts.push(current);
|
|
171
|
+
current = "";
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
current += ch;
|
|
175
|
+
}
|
|
176
|
+
if (current.length > 0)
|
|
177
|
+
parts.push(current);
|
|
178
|
+
return parts;
|
|
179
|
+
}
|
|
180
|
+
export function parseSectionBody(section, body, source) {
|
|
181
|
+
if (section === "functions") {
|
|
182
|
+
const items = parseBodyLines(body, source, parseFunctionSignatureLine);
|
|
183
|
+
return { kind: "functions", items, raw: body.raw, source };
|
|
184
|
+
}
|
|
185
|
+
if (section === "types") {
|
|
186
|
+
const items = collectMultiLineTypes(body, source);
|
|
187
|
+
return { kind: "types", items, raw: body.raw, source };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
kind: "text",
|
|
191
|
+
items: body.lines.map((line) => line.trim()).filter(Boolean),
|
|
192
|
+
raw: body.raw,
|
|
193
|
+
source,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function parseBodyLines(body, source, parser) {
|
|
197
|
+
const items = [];
|
|
198
|
+
const lines = body.lines;
|
|
199
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
200
|
+
const line = lines[index].trim();
|
|
201
|
+
if (!line)
|
|
202
|
+
continue;
|
|
203
|
+
if (line.startsWith("```") || /^[-*+]\s+/.test(line))
|
|
204
|
+
continue;
|
|
205
|
+
const parsed = parser(line, {
|
|
206
|
+
file: source.file,
|
|
207
|
+
startLine: body.startLine + index,
|
|
208
|
+
endLine: body.startLine + index,
|
|
209
|
+
});
|
|
210
|
+
if (parsed)
|
|
211
|
+
items.push(parsed);
|
|
212
|
+
}
|
|
213
|
+
return items;
|
|
214
|
+
}
|
|
215
|
+
function inferParamType(paramName) {
|
|
216
|
+
const rules = [
|
|
217
|
+
{ pattern: /^id$/i, type: "string" },
|
|
218
|
+
{ pattern: /_id$/i, type: "string" },
|
|
219
|
+
{ pattern: /_at$/i, type: "Timestamp" },
|
|
220
|
+
{ pattern: /_count$/i, type: "number" },
|
|
221
|
+
{ pattern: /_amount$/i, type: "number" },
|
|
222
|
+
{ pattern: /_price$/i, type: "number" },
|
|
223
|
+
{ pattern: /_total$/i, type: "number" },
|
|
224
|
+
{ pattern: /is_/i, type: "boolean" },
|
|
225
|
+
{ pattern: /has_/i, type: "boolean" },
|
|
226
|
+
{ pattern: /_status$/i, type: "string" },
|
|
227
|
+
{ pattern: /_type$/i, type: "string" },
|
|
228
|
+
{ pattern: /_name$/i, type: "string" },
|
|
229
|
+
{ pattern: /_url$/i, type: "string" },
|
|
230
|
+
{ pattern: /_email$/i, type: "string" },
|
|
231
|
+
{ pattern: /_key$/i, type: "string" },
|
|
232
|
+
{ pattern: /_token$/i, type: "string" },
|
|
233
|
+
{ pattern: /_data$/i, type: "Record<string, unknown>" },
|
|
234
|
+
{ pattern: /_metadata$/i, type: "Record<string, unknown>" },
|
|
235
|
+
{ pattern: /_options$/i, type: "Record<string, unknown>" },
|
|
236
|
+
{ pattern: /^input$/i, type: "unknown" },
|
|
237
|
+
{ pattern: /^context$/i, type: "Record<string, unknown>" },
|
|
238
|
+
{ pattern: /^reason$/i, type: "string" },
|
|
239
|
+
{ pattern: /^currency$/i, type: "string" },
|
|
240
|
+
{ pattern: /^period$/i, type: "string" },
|
|
241
|
+
{ pattern: /^filters$/i, type: "Record<string, unknown>" },
|
|
242
|
+
{ pattern: /^code$/i, type: "string" },
|
|
243
|
+
{ pattern: /^message$/i, type: "string" },
|
|
244
|
+
{ pattern: /^content$/i, type: "string" },
|
|
245
|
+
{ pattern: /^status$/i, type: "string" },
|
|
246
|
+
{ pattern: /^method$/i, type: "string" },
|
|
247
|
+
{ pattern: /_method$/i, type: "string" },
|
|
248
|
+
{ pattern: /^reference$/i, type: "string" },
|
|
249
|
+
{ pattern: /^amount$/i, type: "number" },
|
|
250
|
+
{ pattern: /balance/i, type: "number" },
|
|
251
|
+
{ pattern: /[Rr]eference$/i, type: "string" },
|
|
252
|
+
];
|
|
253
|
+
for (const rule of rules) {
|
|
254
|
+
if (rule.pattern.test(paramName))
|
|
255
|
+
return rule.type;
|
|
256
|
+
}
|
|
257
|
+
return "unknown";
|
|
258
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const SECTION_DEFINITIONS = [
|
|
2
|
+
{
|
|
3
|
+
name: "functions",
|
|
4
|
+
header: "Functions",
|
|
5
|
+
aliases: ["**Functions**"],
|
|
6
|
+
requiredForModule: true,
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "types",
|
|
10
|
+
header: "Types",
|
|
11
|
+
aliases: ["**Types**"],
|
|
12
|
+
requiredForModule: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "invariants",
|
|
16
|
+
header: "Invariants",
|
|
17
|
+
aliases: ["**Invariants**"],
|
|
18
|
+
requiredForModule: false,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "providers",
|
|
22
|
+
header: "Providers",
|
|
23
|
+
aliases: ["**Providers:**"],
|
|
24
|
+
requiredForModule: false,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "system-integrations",
|
|
28
|
+
header: "System-Level Integrations",
|
|
29
|
+
aliases: ["System-Level Integrations & Constraints"],
|
|
30
|
+
requiredForModule: false,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
export const SECTION_BY_HEADER = new Map(SECTION_DEFINITIONS.flatMap((definition) => [
|
|
34
|
+
[definition.header.toLowerCase(), definition],
|
|
35
|
+
...definition.aliases.map((alias) => [alias.toLowerCase(), definition]),
|
|
36
|
+
]));
|
|
37
|
+
export function normalizeSectionHeader(header) {
|
|
38
|
+
const variants = [
|
|
39
|
+
header,
|
|
40
|
+
header.trim(),
|
|
41
|
+
header.trim().replace(/^\*\*(.+)\*\*$/, "$1"),
|
|
42
|
+
header.trim().replace(/^\*\*(.+)\*\*$/, "$1").replace(/:$/, ""),
|
|
43
|
+
];
|
|
44
|
+
for (const variant of variants) {
|
|
45
|
+
const definition = SECTION_BY_HEADER.get(variant.toLowerCase());
|
|
46
|
+
if (definition)
|
|
47
|
+
return definition.name;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
export function isRequiredModuleSection(name) {
|
|
52
|
+
return SECTION_DEFINITIONS.some((definition) => definition.name === name && definition.requiredForModule);
|
|
53
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { verifyImplementation } from "./verify.js";
|
|
4
|
+
import { generateImplementPrompts } from "./implement.js";
|
|
5
|
+
import { loadCatalogFromRoot } from "./load-catalog.js";
|
|
6
|
+
import { loadAdapters } from "./adapters/load.js";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
10
|
+
describe("verify edge cases", () => {
|
|
11
|
+
it("handles file not found", async () => {
|
|
12
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
13
|
+
const verify = await verifyImplementation("/tmp/nonexistent-file.ts", "payments", result.value);
|
|
14
|
+
assert.equal(verify.valid, false);
|
|
15
|
+
assert.ok(verify.issues.some((i) => i.kind === "file-not-found"));
|
|
16
|
+
});
|
|
17
|
+
it("handles unknown module", async () => {
|
|
18
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
19
|
+
const verify = await verifyImplementation("/tmp/any-file.ts", "nonexistent-module", result.value);
|
|
20
|
+
assert.equal(verify.valid, false);
|
|
21
|
+
assert.equal(verify.issues[0].kind, "file-not-found");
|
|
22
|
+
});
|
|
23
|
+
it("detects missing functions", async () => {
|
|
24
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
25
|
+
const verify = await verifyImplementation("/tmp/any-file.ts", "billing", result.value);
|
|
26
|
+
assert.equal(verify.valid, false);
|
|
27
|
+
assert.ok(verify.issues.length > 0, "should report all 10 missing functions");
|
|
28
|
+
assert.equal(verify.contractFunctions.length, 10, "billing has 10 functions");
|
|
29
|
+
});
|
|
30
|
+
it("parses class methods correctly", async () => {
|
|
31
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
32
|
+
const goodImpl = `
|
|
33
|
+
export class StripeAdapter implements PaymentsContract {
|
|
34
|
+
async initiatePayment(orderId: string, amount: number, currency: string, method: string): Promise<Payment> {
|
|
35
|
+
// implementation
|
|
36
|
+
return {} as Payment;
|
|
37
|
+
}
|
|
38
|
+
async verifyPayment(paymentId: string): Promise<Payment> {
|
|
39
|
+
return {} as Payment;
|
|
40
|
+
}
|
|
41
|
+
async getPaymentByOrder(orderId: string): Promise<Payment | undefined> {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
async getWallet(userId: string): Promise<Wallet> { throw new Error('Not supported'); }
|
|
45
|
+
async creditWallet(userId: string, amount: number, currency: string): Promise<WalletTransaction> { throw new Error('Not supported'); }
|
|
46
|
+
async debitWallet(userId: string, amount: number, currency: string): Promise<WalletTransaction> { throw new Error('Not supported'); }
|
|
47
|
+
async getWalletTransactions(userId: string, options?: Record<string, unknown>): Promise<PaginatedResult<WalletTransaction>> { throw new Error('Not supported'); }
|
|
48
|
+
async initiateRefund(paymentId: string, amount: number, reason: string): Promise<Refund> {
|
|
49
|
+
return {} as Refund;
|
|
50
|
+
}
|
|
51
|
+
async getRefundByOrder(orderId: string): Promise<Refund | undefined> {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
async getRefund(refundId: string): Promise<Refund> {
|
|
55
|
+
return {} as Refund;
|
|
56
|
+
}
|
|
57
|
+
}`;
|
|
58
|
+
const fs = await import("node:fs/promises");
|
|
59
|
+
await fs.writeFile("/tmp/test-stripe.ts", goodImpl);
|
|
60
|
+
const verify = await verifyImplementation("/tmp/test-stripe.ts", "payments", result.value);
|
|
61
|
+
await fs.unlink("/tmp/test-stripe.ts");
|
|
62
|
+
assert.ok(verify.valid, `expected valid, got ${verify.issues.length} issues: ${verify.issues.map((i) => i.message).join("; ")}`);
|
|
63
|
+
assert.ok(verify.implFunctions.length >= 10, `expected >= 10 functions, got ${verify.implFunctions.length}`);
|
|
64
|
+
});
|
|
65
|
+
it("detects mismatched return types", async () => {
|
|
66
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
67
|
+
const badImpl = `
|
|
68
|
+
class Bad implements PaymentsContract {
|
|
69
|
+
async initiatePayment(...args: any[]): Promise<any> { return {}; }
|
|
70
|
+
}`;
|
|
71
|
+
const fs = await import("node:fs/promises");
|
|
72
|
+
await fs.writeFile("/tmp/bad-stripe.ts", badImpl);
|
|
73
|
+
const verify = await verifyImplementation("/tmp/bad-stripe.ts", "payments", result.value);
|
|
74
|
+
await fs.unlink("/tmp/bad-stripe.ts");
|
|
75
|
+
assert.equal(verify.valid, false);
|
|
76
|
+
assert.ok(verify.issues.filter((i) => i.kind === "missing").length > 8, "should report many missing functions");
|
|
77
|
+
});
|
|
78
|
+
it("ignores commented-out code", async () => {
|
|
79
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
80
|
+
const commentedImpl = `
|
|
81
|
+
class Commented implements PaymentsContract {
|
|
82
|
+
// async initiatePayment(orderId: string): Promise<Payment> { return {}; }
|
|
83
|
+
async verifyPayment(paymentId: string): Promise<Payment> { return {} as Payment; }
|
|
84
|
+
}`;
|
|
85
|
+
const fs = await import("node:fs/promises");
|
|
86
|
+
await fs.writeFile("/tmp/commented.ts", commentedImpl);
|
|
87
|
+
const verify = await verifyImplementation("/tmp/commented.ts", "payments", result.value);
|
|
88
|
+
await fs.unlink("/tmp/commented.ts");
|
|
89
|
+
assert.equal(verify.valid, false);
|
|
90
|
+
assert.ok(verify.implFunctions.filter((f) => f === "initiatePayment").length === 0, "should not count commented-out function");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("implement edge cases", () => {
|
|
94
|
+
it("generates prompts for all implemented functions", async () => {
|
|
95
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
96
|
+
const { adapters } = await loadAdapters(join(ROOT, "adapters"));
|
|
97
|
+
const prompts = generateImplementPrompts(result.value, adapters, "payments", "stripe");
|
|
98
|
+
assert.ok(prompts.length > 0, "should generate prompts");
|
|
99
|
+
assert.equal(prompts.length, 6, "stripe implements 6 of 10 payments functions");
|
|
100
|
+
});
|
|
101
|
+
it("returns empty for unknown module", async () => {
|
|
102
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
103
|
+
const { adapters } = await loadAdapters(join(ROOT, "adapters"));
|
|
104
|
+
const prompts = generateImplementPrompts(result.value, adapters, "nonexistent", "stripe");
|
|
105
|
+
assert.equal(prompts.length, 0);
|
|
106
|
+
});
|
|
107
|
+
it("returns empty for missing adapter", async () => {
|
|
108
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
109
|
+
const { adapters } = await loadAdapters(join(ROOT, "adapters"));
|
|
110
|
+
const prompts = generateImplementPrompts(result.value, adapters, "payments", "nonexistent");
|
|
111
|
+
assert.equal(prompts.length, 0);
|
|
112
|
+
});
|
|
113
|
+
it("each prompt contains essential fields", async () => {
|
|
114
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
115
|
+
const { adapters } = await loadAdapters(join(ROOT, "adapters"));
|
|
116
|
+
const prompts = generateImplementPrompts(result.value, adapters, "payments", "stripe");
|
|
117
|
+
for (const p of prompts) {
|
|
118
|
+
assert.ok(p.function.length > 0);
|
|
119
|
+
assert.ok(p.prompt.includes(p.function));
|
|
120
|
+
assert.ok(p.prompt.includes("stripe"));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|