@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,126 @@
|
|
|
1
|
+
import { classifySectionBody, parseSectionBody } from "./section-body.js";
|
|
2
|
+
export function collectFunctions(file, sections, mode) {
|
|
3
|
+
const items = [];
|
|
4
|
+
for (const section of sections) {
|
|
5
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
6
|
+
const parsed = parseSectionBody("functions", body, { file: section.file, startLine: section.startLine, endLine: section.endLine });
|
|
7
|
+
if (parsed.kind === "functions")
|
|
8
|
+
items.push(...parsed.items);
|
|
9
|
+
}
|
|
10
|
+
return { items, issues: [] };
|
|
11
|
+
}
|
|
12
|
+
export function collectTypes(file, sections, mode) {
|
|
13
|
+
const items = [];
|
|
14
|
+
for (const section of sections) {
|
|
15
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
16
|
+
const parsed = parseSectionBody("types", body, { file: section.file, startLine: section.startLine, endLine: section.endLine });
|
|
17
|
+
if (parsed.kind === "types")
|
|
18
|
+
items.push(...parsed.items);
|
|
19
|
+
}
|
|
20
|
+
return { items, issues: [] };
|
|
21
|
+
}
|
|
22
|
+
export function collectTextSections(sections, _sectionName) {
|
|
23
|
+
const items = [];
|
|
24
|
+
for (const section of sections) {
|
|
25
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
26
|
+
items.push(...body.lines.map((line) => stripTextLine(line)).filter(Boolean));
|
|
27
|
+
}
|
|
28
|
+
return items;
|
|
29
|
+
}
|
|
30
|
+
export function collectProviders(sections) {
|
|
31
|
+
const items = [];
|
|
32
|
+
for (const section of sections) {
|
|
33
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
34
|
+
for (const line of body.lines) {
|
|
35
|
+
const text = stripTextLine(line);
|
|
36
|
+
if (!text)
|
|
37
|
+
continue;
|
|
38
|
+
for (const chunk of text.split(",")) {
|
|
39
|
+
if (chunk.trim())
|
|
40
|
+
items.push(chunk.trim());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
export function parseDepList(raw) {
|
|
47
|
+
return raw.split(",").map((item) => item.replace(/\(.*?\)/g, "").trim()).filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
export function groupSections(sections) {
|
|
50
|
+
const grouped = new Map();
|
|
51
|
+
for (const section of sections) {
|
|
52
|
+
const list = grouped.get(section.name) ?? [];
|
|
53
|
+
list.push(section);
|
|
54
|
+
grouped.set(section.name, list);
|
|
55
|
+
}
|
|
56
|
+
return grouped;
|
|
57
|
+
}
|
|
58
|
+
export function extractSummary(preamble) {
|
|
59
|
+
if (!preamble)
|
|
60
|
+
return null;
|
|
61
|
+
const lines = preamble.split(/\r?\n/).map((line) => line.trim());
|
|
62
|
+
return lines.find((line) => line && !line.startsWith("#") && line !== "---" && !line.includes("**Version:**") && !line.includes("**Part:**")) ?? null;
|
|
63
|
+
}
|
|
64
|
+
export function extractVersion(preamble) {
|
|
65
|
+
if (!preamble)
|
|
66
|
+
return null;
|
|
67
|
+
const match = preamble.match(/\*\*Version:\*\*\s*([\d.]+)/);
|
|
68
|
+
return match ? match[1] ?? null : null;
|
|
69
|
+
}
|
|
70
|
+
export function stripTextLine(line) {
|
|
71
|
+
return line.trim().replace(/^[-*+]\s+/, "").trim();
|
|
72
|
+
}
|
|
73
|
+
export function extractDependencies(sections) {
|
|
74
|
+
const hardDeps = [];
|
|
75
|
+
const softDeps = [];
|
|
76
|
+
for (const section of sections) {
|
|
77
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
78
|
+
for (const line of body.lines) {
|
|
79
|
+
const text = stripTextLine(line);
|
|
80
|
+
if (!text)
|
|
81
|
+
continue;
|
|
82
|
+
const dependsMatch = text.match(/\*\*Depends On:\*\*\s*(.+)/i);
|
|
83
|
+
if (dependsMatch) {
|
|
84
|
+
const raw = dependsMatch[1]?.trim() ?? "";
|
|
85
|
+
if (raw && !raw.startsWith("(none"))
|
|
86
|
+
hardDeps.push(...parseDepList(raw));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const recommendsMatch = text.match(/\*\*Recommends:\*\*\s*(.+)/i);
|
|
90
|
+
if (recommendsMatch) {
|
|
91
|
+
const raw = recommendsMatch[1]?.trim() ?? "";
|
|
92
|
+
if (raw && !raw.startsWith("(none"))
|
|
93
|
+
softDeps.push(...parseDepList(raw));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { hardDeps, softDeps };
|
|
98
|
+
}
|
|
99
|
+
export function extractCoreInherits(sections) {
|
|
100
|
+
const inherits = [];
|
|
101
|
+
for (const section of sections) {
|
|
102
|
+
const body = classifySectionBody(section.content, section.startLine, section.endLine);
|
|
103
|
+
for (const line of body.lines) {
|
|
104
|
+
const text = stripTextLine(line);
|
|
105
|
+
if (!text)
|
|
106
|
+
continue;
|
|
107
|
+
const runtimeMatch = text.match(/\*\*Runtime Standards:\*\*\s*Inherits\s+`([^`]+)`/i);
|
|
108
|
+
if (runtimeMatch) {
|
|
109
|
+
const name = (runtimeMatch[1] ?? "").replace(/.*\//, "").replace(/\.md$/i, "");
|
|
110
|
+
if (name && !inherits.includes(name))
|
|
111
|
+
inherits.push(name);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const globalMatch = text.match(/\*\*Global Standards:\*\*\s*Inherits\s+`([^`]+)`/i);
|
|
115
|
+
if (globalMatch) {
|
|
116
|
+
const name = (globalMatch[1] ?? "").replace(/.*\//, "").replace(/\.md$/i, "");
|
|
117
|
+
if (name && !inherits.includes(name))
|
|
118
|
+
inherits.push(name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return inherits;
|
|
123
|
+
}
|
|
124
|
+
export function issue(file, section, startLine, endLine, message, code, mode) {
|
|
125
|
+
return { code, file, section, startLine, endLine, message, severity: mode === "strict" ? "error" : "warning" };
|
|
126
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
export async function discoverContractFiles(rootDir) {
|
|
4
|
+
const contractsDir = join(rootDir, "contracts");
|
|
5
|
+
const warnings = [];
|
|
6
|
+
if (!(await dirExists(contractsDir))) {
|
|
7
|
+
warnings.push({ message: `contracts directory not found: ${contractsDir}` });
|
|
8
|
+
return { files: [], warnings };
|
|
9
|
+
}
|
|
10
|
+
const files = await walkMarkdown(contractsDir);
|
|
11
|
+
return {
|
|
12
|
+
files: files.map((file) => {
|
|
13
|
+
const rel = relative(contractsDir, file);
|
|
14
|
+
const isCore = rel.startsWith("core" + sep) || rel.startsWith("core/");
|
|
15
|
+
return {
|
|
16
|
+
file,
|
|
17
|
+
kind: (isCore ? "core" : "module"),
|
|
18
|
+
name: basenameWithoutExtension(file),
|
|
19
|
+
};
|
|
20
|
+
}),
|
|
21
|
+
warnings,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function dirExists(dir) {
|
|
25
|
+
try {
|
|
26
|
+
const s = await stat(dir);
|
|
27
|
+
return s.isDirectory();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function walkMarkdown(dir) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const fullPath = join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
results.push(...(await walkMarkdown(fullPath)));
|
|
46
|
+
}
|
|
47
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
48
|
+
results.push(fullPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results.sort();
|
|
52
|
+
}
|
|
53
|
+
function basenameWithoutExtension(file) {
|
|
54
|
+
const parts = file.split(/[\\/]/);
|
|
55
|
+
const filename = parts[parts.length - 1] ?? file;
|
|
56
|
+
return filename.replace(/\.md$/i, "");
|
|
57
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseDocument } from "./parse-document.js";
|
|
4
|
+
import { resolve, detectCycles } from "./resolve.js";
|
|
5
|
+
import { implicitCores } from "./catalog.js";
|
|
6
|
+
describe("parser edge cases", () => {
|
|
7
|
+
it("handles empty markdown file", () => {
|
|
8
|
+
const result = parseDocument("empty.md", "", "loose");
|
|
9
|
+
assert.notEqual(result.value, null, "should not crash on empty file");
|
|
10
|
+
assert.ok(result.issues.length >= 0, "may have issues for empty file");
|
|
11
|
+
});
|
|
12
|
+
it("handles file with only a heading", () => {
|
|
13
|
+
const result = parseDocument("minimal.md", "# Hello", "loose");
|
|
14
|
+
assert.notEqual(result.value, null, "should not crash on heading-only file");
|
|
15
|
+
});
|
|
16
|
+
it("handles malformed function signatures gracefully", () => {
|
|
17
|
+
const content = `# Test\n\n**Functions**\n\`\`\`\nnotAFunction\nbroken( → \ngoodOne(x) → string\n\`\`\``;
|
|
18
|
+
const result = parseDocument("bad.md", content, "loose");
|
|
19
|
+
assert.notEqual(result.value, null);
|
|
20
|
+
const fnCount = result.value && "functions" in result.value ? result.value.functions.length : 0;
|
|
21
|
+
assert.ok(fnCount >= 0, "should handle malformed signatures without crashing");
|
|
22
|
+
});
|
|
23
|
+
it("handles duplicate section names", () => {
|
|
24
|
+
const content = `# Duplicate\n\n## Functions\nfoo() → string\n\n## Functions\nbar() → string`;
|
|
25
|
+
const result = parseDocument("dup.md", content, "loose");
|
|
26
|
+
assert.ok(result.issues.length > 0, "should report duplicate sections");
|
|
27
|
+
});
|
|
28
|
+
it("handles missing required sections", () => {
|
|
29
|
+
const content = `# NoFunctions\n\n**Types**\n\`\`\`\nFoo = string\n\`\`\``;
|
|
30
|
+
const result = parseDocument("missing.md", content, "strict");
|
|
31
|
+
assert.ok(result.issues.some((i) => i.severity === "error"), "should fail strict on missing functions");
|
|
32
|
+
});
|
|
33
|
+
it("handles very long contract names", () => {
|
|
34
|
+
const longName = "a".repeat(200);
|
|
35
|
+
const content = `# ${longName}\n\n**Functions**\n\`\`\`\nfoo() → string\n\`\`\``;
|
|
36
|
+
const result = parseDocument(`${longName}.md`, content, "loose");
|
|
37
|
+
assert.notEqual(result.value, null, "should handle long names");
|
|
38
|
+
});
|
|
39
|
+
it("handles types section with no types", () => {
|
|
40
|
+
const content = `# EmptyTypes\n\n**Functions**\n\`\`\`\nfoo() → void\n\`\`\`\n\n**Types**\n\`\`\`\n\`\`\``;
|
|
41
|
+
const result = parseDocument("empty-types.md", content, "loose");
|
|
42
|
+
assert.notEqual(result.value, null);
|
|
43
|
+
const typeCount = result.value && "types" in result.value ? result.value.types.length : 0;
|
|
44
|
+
assert.equal(typeCount, 0, "should handle empty types section");
|
|
45
|
+
});
|
|
46
|
+
it("handles file with no preamble", () => {
|
|
47
|
+
const content = `## Functions\nfoo() → string\n\n## Types\nBar = string`;
|
|
48
|
+
const result = parseDocument("no-preamble.md", content, "loose");
|
|
49
|
+
assert.notEqual(result.value, null, "should not crash without preamble");
|
|
50
|
+
});
|
|
51
|
+
it("handles multiple system-integrations sections", () => {
|
|
52
|
+
const content = `# Multi\n\n**Functions**\n\`\`\`\nfoo() → string\n\`\`\`\n\n**System-Level Integrations & Constraints**\nSome text\n\n**System-Level Integrations**\nMore text`;
|
|
53
|
+
const result = parseDocument("multi-sys.md", content, "loose");
|
|
54
|
+
assert.ok(result.issues.length > 0, "should report duplicate system-integrations");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("resolver edge cases", () => {
|
|
58
|
+
it("handles empty module list", () => {
|
|
59
|
+
const catalog = { modules: [], core: [] };
|
|
60
|
+
const result = resolve(catalog, []);
|
|
61
|
+
assert.equal(result.modules.length, 0);
|
|
62
|
+
assert.equal(result.errors.length, 0);
|
|
63
|
+
});
|
|
64
|
+
it("handles all unknown modules", () => {
|
|
65
|
+
const catalog = { modules: [], core: [] };
|
|
66
|
+
const result = resolve(catalog, ["nonexistent", "also-gone"]);
|
|
67
|
+
assert.equal(result.modules.length, 0);
|
|
68
|
+
assert.equal(result.errors.length, 2);
|
|
69
|
+
});
|
|
70
|
+
it("handles chain of 50 hard deps", () => {
|
|
71
|
+
const modules = [];
|
|
72
|
+
for (let i = 0; i < 50; i++) {
|
|
73
|
+
modules.push({
|
|
74
|
+
name: `mod_${i}`,
|
|
75
|
+
title: `mod_${i}`,
|
|
76
|
+
version: null,
|
|
77
|
+
summary: null,
|
|
78
|
+
functions: [{ name: "test", params: [], returns: "void", signature: "test()", raw: "test()", source: { file: "", startLine: 1, endLine: 1 } }],
|
|
79
|
+
types: [],
|
|
80
|
+
hardDeps: i > 0 ? [`mod_${i - 1}`] : [],
|
|
81
|
+
softDeps: [],
|
|
82
|
+
coreInherits: [],
|
|
83
|
+
invariants: [],
|
|
84
|
+
providers: [],
|
|
85
|
+
integrations: [],
|
|
86
|
+
rawSections: [],
|
|
87
|
+
profile: "module-v1",
|
|
88
|
+
source: { file: "", startLine: 1, endLine: 1 },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const catalog = { modules, core: [] };
|
|
92
|
+
const result = resolve(catalog, ["mod_49"]);
|
|
93
|
+
assert.equal(result.modules.length, 50, "should resolve 50-module chain");
|
|
94
|
+
assert.equal(result.errors.length, 0);
|
|
95
|
+
});
|
|
96
|
+
it("handles self-referencing module gracefully", () => {
|
|
97
|
+
const modules = [{
|
|
98
|
+
name: "self",
|
|
99
|
+
title: "self",
|
|
100
|
+
version: null,
|
|
101
|
+
summary: null,
|
|
102
|
+
functions: [{ name: "test", params: [], returns: "void", signature: "test()", raw: "test()", source: { file: "", startLine: 1, endLine: 1 } }],
|
|
103
|
+
types: [],
|
|
104
|
+
hardDeps: ["self"],
|
|
105
|
+
softDeps: [],
|
|
106
|
+
coreInherits: [],
|
|
107
|
+
invariants: [],
|
|
108
|
+
providers: [],
|
|
109
|
+
integrations: [],
|
|
110
|
+
rawSections: [],
|
|
111
|
+
profile: "module-v1",
|
|
112
|
+
source: { file: "", startLine: 1, endLine: 1 },
|
|
113
|
+
}];
|
|
114
|
+
const catalog = { modules, core: [] };
|
|
115
|
+
const cycles = detectCycles(catalog);
|
|
116
|
+
assert.ok(cycles.length > 0, "should detect self-reference cycle");
|
|
117
|
+
const result = resolve(catalog, ["self"]);
|
|
118
|
+
assert.ok(result.modules.length >= 1, "should still resolve self-referencing module");
|
|
119
|
+
});
|
|
120
|
+
it("handles dependency on nonexistent module with error", () => {
|
|
121
|
+
const modules = [{
|
|
122
|
+
name: "real",
|
|
123
|
+
title: "real",
|
|
124
|
+
version: null,
|
|
125
|
+
summary: null,
|
|
126
|
+
functions: [{ name: "test", params: [], returns: "void", signature: "test()", raw: "test()", source: { file: "", startLine: 1, endLine: 1 } }],
|
|
127
|
+
types: [],
|
|
128
|
+
hardDeps: ["ghost"],
|
|
129
|
+
softDeps: [],
|
|
130
|
+
coreInherits: [],
|
|
131
|
+
invariants: [],
|
|
132
|
+
providers: [],
|
|
133
|
+
integrations: [],
|
|
134
|
+
rawSections: [],
|
|
135
|
+
profile: "module-v1",
|
|
136
|
+
source: { file: "", startLine: 1, endLine: 1 },
|
|
137
|
+
}];
|
|
138
|
+
const catalog = { modules, core: [] };
|
|
139
|
+
const result = resolve(catalog, ["real"]);
|
|
140
|
+
assert.equal(result.modules.length, 1);
|
|
141
|
+
assert.ok(result.errors.length > 0, "should error on missing hard dep");
|
|
142
|
+
});
|
|
143
|
+
it("implicitCores returns empty when no implicit cores", () => {
|
|
144
|
+
const catalog = { modules: [], core: [{ name: "sagas", title: "", version: null, implicit: false, summary: null, sections: [], rawSections: [], profile: "core-v1", source: { file: "", startLine: 1, endLine: 1 } }] };
|
|
145
|
+
assert.equal(implicitCores(catalog).length, 0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export function buildGraph(catalog, moduleName) {
|
|
2
|
+
const moduleByName = new Map();
|
|
3
|
+
for (const mod of catalog.modules) {
|
|
4
|
+
moduleByName.set(mod.name, mod);
|
|
5
|
+
}
|
|
6
|
+
const nodes = new Map();
|
|
7
|
+
const edges = [];
|
|
8
|
+
const mod = moduleByName.get(moduleName);
|
|
9
|
+
if (!mod) {
|
|
10
|
+
return { nodes: [], edges: [] };
|
|
11
|
+
}
|
|
12
|
+
nodes.set(moduleName, { name: moduleName, kind: "module", source: "explicit" });
|
|
13
|
+
for (const dep of mod.hardDeps) {
|
|
14
|
+
if (!nodes.has(dep)) {
|
|
15
|
+
nodes.set(dep, { name: dep, kind: "module", source: "hard-dep" });
|
|
16
|
+
}
|
|
17
|
+
edges.push({ from: moduleName, to: dep, kind: "hard" });
|
|
18
|
+
}
|
|
19
|
+
for (const dep of mod.softDeps) {
|
|
20
|
+
if (!nodes.has(dep)) {
|
|
21
|
+
nodes.set(dep, { name: dep, kind: "module", source: "soft-dep" });
|
|
22
|
+
}
|
|
23
|
+
edges.push({ from: moduleName, to: dep, kind: "soft" });
|
|
24
|
+
}
|
|
25
|
+
for (const coreName of mod.coreInherits) {
|
|
26
|
+
if (!nodes.has(coreName)) {
|
|
27
|
+
nodes.set(coreName, { name: coreName, kind: "core", source: "inherited" });
|
|
28
|
+
}
|
|
29
|
+
edges.push({ from: moduleName, to: coreName, kind: "inherit" });
|
|
30
|
+
}
|
|
31
|
+
for (const dep of mod.hardDeps) {
|
|
32
|
+
const depMod = moduleByName.get(dep);
|
|
33
|
+
if (!depMod)
|
|
34
|
+
continue;
|
|
35
|
+
for (const transitive of depMod.hardDeps) {
|
|
36
|
+
if (!nodes.has(transitive)) {
|
|
37
|
+
nodes.set(transitive, { name: transitive, kind: "module", source: "hard-dep" });
|
|
38
|
+
}
|
|
39
|
+
edges.push({ from: dep, to: transitive, kind: "hard" });
|
|
40
|
+
}
|
|
41
|
+
for (const coreName of depMod.coreInherits) {
|
|
42
|
+
if (!nodes.has(coreName)) {
|
|
43
|
+
nodes.set(coreName, { name: coreName, kind: "core", source: "inherited" });
|
|
44
|
+
}
|
|
45
|
+
edges.push({ from: dep, to: coreName, kind: "inherit" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
nodes: [...nodes.values()],
|
|
50
|
+
edges,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function renderAscii(graph, rootName) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.name, n]));
|
|
56
|
+
lines.push(`Dependency graph for: ${rootName}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
const hardChildren = graph.edges
|
|
59
|
+
.filter((e) => e.from === rootName && e.kind === "hard")
|
|
60
|
+
.map((e) => e.to);
|
|
61
|
+
const softChildren = graph.edges
|
|
62
|
+
.filter((e) => e.from === rootName && e.kind === "soft")
|
|
63
|
+
.map((e) => e.to);
|
|
64
|
+
const inheritChildren = graph.edges
|
|
65
|
+
.filter((e) => e.from === rootName && e.kind === "inherit")
|
|
66
|
+
.map((e) => e.to);
|
|
67
|
+
const allChildren = [];
|
|
68
|
+
for (const c of hardChildren)
|
|
69
|
+
allChildren.push({ name: c, kind: "hard" });
|
|
70
|
+
for (const c of softChildren)
|
|
71
|
+
allChildren.push({ name: c, kind: "soft" });
|
|
72
|
+
for (const c of inheritChildren)
|
|
73
|
+
allChildren.push({ name: c, kind: "inherit" });
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const uniqueChildren = allChildren.filter((c) => {
|
|
76
|
+
if (seen.has(c.name))
|
|
77
|
+
return false;
|
|
78
|
+
seen.add(c.name);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
lines.push(`${rootName} *`);
|
|
82
|
+
for (let i = 0; i < uniqueChildren.length; i++) {
|
|
83
|
+
const child = uniqueChildren[i];
|
|
84
|
+
const isLast = i === uniqueChildren.length - 1;
|
|
85
|
+
const prefix = isLast ? "└── " : "├── ";
|
|
86
|
+
const label = child.kind === "hard" ? " (hard)" : child.kind === "soft" ? " (soft)" : " [core]";
|
|
87
|
+
lines.push(`${prefix}${child.name}${label}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
lines.push("* = explicitly requested");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
export function renderMermaid(graph, rootName) {
|
|
94
|
+
const lines = [];
|
|
95
|
+
lines.push("graph TD");
|
|
96
|
+
for (const node of graph.nodes) {
|
|
97
|
+
const id = node.name.replace(/[^a-zA-Z0-9]/g, "_");
|
|
98
|
+
if (node.kind === "core") {
|
|
99
|
+
lines.push(` ${id}["${node.name}\\n[core]"]`);
|
|
100
|
+
}
|
|
101
|
+
else if (node.source === "explicit") {
|
|
102
|
+
lines.push(` ${id}["${node.name}"]`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
lines.push(` ${id}("${node.name}")`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
lines.push("");
|
|
109
|
+
for (const edge of graph.edges) {
|
|
110
|
+
const fromId = edge.from.replace(/[^a-zA-Z0-9]/g, "_");
|
|
111
|
+
const toId = edge.to.replace(/[^a-zA-Z0-9]/g, "_");
|
|
112
|
+
if (edge.kind === "hard") {
|
|
113
|
+
lines.push(` ${fromId} -->|hard| ${toId}`);
|
|
114
|
+
}
|
|
115
|
+
else if (edge.kind === "soft") {
|
|
116
|
+
lines.push(` ${fromId} -.->|soft| ${toId}`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(` ${fromId} -.->|inherits| ${toId}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { loadCatalogFromRoot } from "./index.js";
|
|
4
|
+
import { buildGraph, renderAscii, renderMermaid } from "./graph.js";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
7
|
+
describe("graph", () => {
|
|
8
|
+
it("builds graph for a module with deps", async () => {
|
|
9
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
10
|
+
assert.notEqual(result.value, null);
|
|
11
|
+
const graph = buildGraph(result.value, "billing");
|
|
12
|
+
assert.ok(graph.nodes.length > 0, "should have nodes");
|
|
13
|
+
assert.ok(graph.edges.length > 0, "should have edges");
|
|
14
|
+
const nodeNames = graph.nodes.map((n) => n.name);
|
|
15
|
+
assert.ok(nodeNames.includes("billing"), "should include root module");
|
|
16
|
+
assert.ok(nodeNames.includes("payments"), "should include hard dep");
|
|
17
|
+
assert.ok(nodeNames.includes("users"), "should include hard dep");
|
|
18
|
+
});
|
|
19
|
+
it("builds graph for a module with no deps", async () => {
|
|
20
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
21
|
+
assert.notEqual(result.value, null);
|
|
22
|
+
const graph = buildGraph(result.value, "catalog");
|
|
23
|
+
const nodeNames = graph.nodes.map((n) => n.name);
|
|
24
|
+
assert.ok(nodeNames.includes("catalog"), "should include root module");
|
|
25
|
+
assert.ok(!nodeNames.includes("payments"), "should not include unrelated modules");
|
|
26
|
+
});
|
|
27
|
+
it("returns empty graph for unknown module", async () => {
|
|
28
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
29
|
+
assert.notEqual(result.value, null);
|
|
30
|
+
const graph = buildGraph(result.value, "nonexistent");
|
|
31
|
+
assert.equal(graph.nodes.length, 0);
|
|
32
|
+
assert.equal(graph.edges.length, 0);
|
|
33
|
+
});
|
|
34
|
+
it("renders ASCII graph", async () => {
|
|
35
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
36
|
+
assert.notEqual(result.value, null);
|
|
37
|
+
const graph = buildGraph(result.value, "billing");
|
|
38
|
+
const ascii = renderAscii(graph, "billing");
|
|
39
|
+
assert.ok(ascii.includes("billing"), "should include root module name");
|
|
40
|
+
assert.ok(ascii.includes("payments"), "should include hard dep");
|
|
41
|
+
assert.ok(ascii.includes("users"), "should include hard dep");
|
|
42
|
+
assert.ok(ascii.includes("hard"), "should label hard deps");
|
|
43
|
+
});
|
|
44
|
+
it("renders Mermaid graph", async () => {
|
|
45
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
46
|
+
assert.notEqual(result.value, null);
|
|
47
|
+
const graph = buildGraph(result.value, "billing");
|
|
48
|
+
const mermaid = renderMermaid(graph, "billing");
|
|
49
|
+
assert.ok(mermaid.includes("graph TD"), "should start with graph TD");
|
|
50
|
+
assert.ok(mermaid.includes("-->"), "should have directed edges");
|
|
51
|
+
assert.ok(mermaid.includes("hard"), "should label hard deps");
|
|
52
|
+
});
|
|
53
|
+
it("includes core inheritance in graph", async () => {
|
|
54
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
55
|
+
assert.notEqual(result.value, null);
|
|
56
|
+
const graph = buildGraph(result.value, "assignments");
|
|
57
|
+
const nodeNames = graph.nodes.map((n) => n.name);
|
|
58
|
+
assert.ok(nodeNames.includes("runtime_standards"), "should include inherited core");
|
|
59
|
+
const coreNode = graph.nodes.find((n) => n.name === "runtime_standards");
|
|
60
|
+
assert.equal(coreNode?.kind, "core", "runtime_standards should be marked as core");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { camelCase } from "../generators/types.js";
|
|
2
|
+
export function generateImplementPrompts(catalog, adapters, moduleName, adapterName) {
|
|
3
|
+
const mod = catalog.modules.find((m) => m.name === moduleName);
|
|
4
|
+
if (!mod)
|
|
5
|
+
return [];
|
|
6
|
+
const adapter = adapters.find((a) => a.name === adapterName && a.module === moduleName);
|
|
7
|
+
if (!adapter)
|
|
8
|
+
return [];
|
|
9
|
+
const prompts = [];
|
|
10
|
+
for (const fn of mod.functions) {
|
|
11
|
+
if (!adapter.implements.includes(fn.name))
|
|
12
|
+
continue;
|
|
13
|
+
if (adapter.does_not_implement?.includes(fn.name))
|
|
14
|
+
continue;
|
|
15
|
+
prompts.push({
|
|
16
|
+
module: moduleName,
|
|
17
|
+
adapter: adapterName,
|
|
18
|
+
function: fn.name,
|
|
19
|
+
prompt: buildPrompt(fn, adapter, mod),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return prompts;
|
|
23
|
+
}
|
|
24
|
+
function buildPrompt(fn, adapter, mod) {
|
|
25
|
+
const params = fn.params.map((p) => {
|
|
26
|
+
const typeStr = p.type ? `: ${p.type}` : "";
|
|
27
|
+
return `${p.name}${p.optional ? "?" : ""}${typeStr}`;
|
|
28
|
+
}).join(", ");
|
|
29
|
+
const signature = `${camelCase(fn.name)}(${params}) → ${fn.returns}`;
|
|
30
|
+
const config = adapter.config.required.map((f) => `${f.name}: ${f.type}`).join(", ");
|
|
31
|
+
let prompt = `// Implement: ${mod.name}.${fn.name}
|
|
32
|
+
// Contract: contracts/${mod.name}.md v${mod.version ?? "0.1.0"}
|
|
33
|
+
// Adapter: ${adapter.name} → ${mod.name}
|
|
34
|
+
// Signature: ${signature}
|
|
35
|
+
|
|
36
|
+
Instructions:
|
|
37
|
+
1. Implement ${camelCase(fn.name)} using the ${adapter.name} SDK
|
|
38
|
+
2. The adapter is configured with: { ${config} }
|
|
39
|
+
3. Follow the contract rules in contracts/${mod.name}.md
|
|
40
|
+
4. Returns must match the contract type exactly`;
|
|
41
|
+
if (fn.returns !== "void") {
|
|
42
|
+
prompt += `\n5. Return type: ${fn.returns}`;
|
|
43
|
+
}
|
|
44
|
+
if (fn.params.length > 0) {
|
|
45
|
+
prompt += `\n6. Parameters: ${fn.params.map((p) => p.name).join(", ")}`;
|
|
46
|
+
}
|
|
47
|
+
return prompt;
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./catalog.js";
|
|
2
|
+
export * from "./sections.js";
|
|
3
|
+
export * from "./envelope.js";
|
|
4
|
+
export * from "./section-body.js";
|
|
5
|
+
export * from "./scanner.js";
|
|
6
|
+
export * from "./discovery.js";
|
|
7
|
+
export * from "./parse-document.js";
|
|
8
|
+
export * from "./load-catalog.js";
|
|
9
|
+
export * from "./collectors.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { discoverContractFiles } from "./discovery.js";
|
|
3
|
+
import { parseCatalogFromDocuments } from "./parse-document.js";
|
|
4
|
+
export async function loadCatalogFromRoot(rootDir, mode) {
|
|
5
|
+
const { files, warnings: discoveryWarnings } = await discoverContractFiles(rootDir);
|
|
6
|
+
const documents = await Promise.all(files.map(async (file) => ({
|
|
7
|
+
file: file.file,
|
|
8
|
+
text: await readFile(file.file, "utf8"),
|
|
9
|
+
})));
|
|
10
|
+
const result = parseCatalogFromDocuments(documents, mode);
|
|
11
|
+
const discoveryIssues = discoveryWarnings.map((w) => ({
|
|
12
|
+
code: "UNSUPPORTED_SECTION",
|
|
13
|
+
file: "",
|
|
14
|
+
startLine: 0,
|
|
15
|
+
endLine: 0,
|
|
16
|
+
message: w.message,
|
|
17
|
+
severity: "warning",
|
|
18
|
+
}));
|
|
19
|
+
return {
|
|
20
|
+
value: result.value,
|
|
21
|
+
issues: [...discoveryIssues, ...result.issues],
|
|
22
|
+
discoveryWarnings,
|
|
23
|
+
};
|
|
24
|
+
}
|