@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,114 @@
|
|
|
1
|
+
import { SECTION_DEFINITIONS } from "./sections.js";
|
|
2
|
+
import { scanDocument } from "./scanner.js";
|
|
3
|
+
import { collectFunctions, collectTypes, collectTextSections, collectProviders, extractDependencies, extractCoreInherits, groupSections, extractSummary, extractVersion, issue, } from "./collectors.js";
|
|
4
|
+
export function parseDocument(file, text, mode) {
|
|
5
|
+
const scanned = scanDocument(file, text);
|
|
6
|
+
const issues = [];
|
|
7
|
+
if (scanned.envelope.kind === "module") {
|
|
8
|
+
const result = parseModuleDocument(scanned.envelope, scanned.preamble?.content ?? null, scanned.sections, mode);
|
|
9
|
+
issues.push(...result.issues);
|
|
10
|
+
if (mode === "strict" && issues.some((issue) => issue.severity === "error")) {
|
|
11
|
+
return { value: null, issues };
|
|
12
|
+
}
|
|
13
|
+
return { value: result.value, issues };
|
|
14
|
+
}
|
|
15
|
+
const result = parseCoreDocument(scanned.envelope, scanned.preamble?.content ?? null, scanned.sections, mode);
|
|
16
|
+
issues.push(...result.issues);
|
|
17
|
+
if (mode === "strict" && issues.some((issue) => issue.severity === "error")) {
|
|
18
|
+
return { value: null, issues };
|
|
19
|
+
}
|
|
20
|
+
return { value: result.value, issues };
|
|
21
|
+
}
|
|
22
|
+
export function parseCatalogFromDocuments(documents, mode) {
|
|
23
|
+
const modules = [];
|
|
24
|
+
const core = [];
|
|
25
|
+
const issues = [];
|
|
26
|
+
for (const document of documents) {
|
|
27
|
+
const result = parseDocument(document.file, document.text, mode);
|
|
28
|
+
issues.push(...result.issues);
|
|
29
|
+
if (!result.value)
|
|
30
|
+
continue;
|
|
31
|
+
if (result.value.profile === "module-v1") {
|
|
32
|
+
modules.push(result.value);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
core.push(result.value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (mode === "strict" && issues.some((issue) => issue.severity === "error")) {
|
|
39
|
+
return { value: null, issues };
|
|
40
|
+
}
|
|
41
|
+
return { value: { modules, core }, issues };
|
|
42
|
+
}
|
|
43
|
+
function parseModuleDocument(envelope, preamble, sections, mode) {
|
|
44
|
+
const issues = [];
|
|
45
|
+
const summary = extractSummary(preamble);
|
|
46
|
+
const version = extractVersion(preamble);
|
|
47
|
+
const grouped = groupSections(sections);
|
|
48
|
+
const required = SECTION_DEFINITIONS.filter((definition) => definition.requiredForModule).map((definition) => definition.name);
|
|
49
|
+
for (const sectionName of required) {
|
|
50
|
+
if (!grouped.has(sectionName)) {
|
|
51
|
+
issues.push(issue(envelope.source.file, sectionName, 1, 1, `Missing required section: ${sectionName}`, "MISSING_SECTION", mode));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const title = envelope.name;
|
|
55
|
+
const name = envelope.name;
|
|
56
|
+
const functionResult = collectFunctions(envelope.source.file, grouped.get("functions") ?? [], mode);
|
|
57
|
+
const typeResult = collectTypes(envelope.source.file, grouped.get("types") ?? [], mode);
|
|
58
|
+
issues.push(...functionResult.issues, ...typeResult.issues);
|
|
59
|
+
const functions = functionResult.items;
|
|
60
|
+
const types = typeResult.items;
|
|
61
|
+
const invariants = collectTextSections(grouped.get("invariants") ?? [], "invariants");
|
|
62
|
+
const providers = collectProviders(grouped.get("providers") ?? []);
|
|
63
|
+
const integrations = collectTextSections(grouped.get("system-integrations") ?? [], "system-integrations");
|
|
64
|
+
const { hardDeps, softDeps } = extractDependencies(grouped.get("system-integrations") ?? []);
|
|
65
|
+
const coreInherits = extractCoreInherits(grouped.get("system-integrations") ?? []);
|
|
66
|
+
const rawSections = sections;
|
|
67
|
+
for (const [sectionName, groupedSections] of grouped.entries()) {
|
|
68
|
+
if (groupedSections.length > 1) {
|
|
69
|
+
const first = groupedSections[0];
|
|
70
|
+
const last = groupedSections[groupedSections.length - 1];
|
|
71
|
+
issues.push(issue(envelope.source.file, sectionName, first.startLine, last.endLine, `Duplicate section: ${sectionName}`, "DUPLICATE_SECTION", mode));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
value: {
|
|
76
|
+
name,
|
|
77
|
+
title,
|
|
78
|
+
version,
|
|
79
|
+
summary,
|
|
80
|
+
functions,
|
|
81
|
+
types,
|
|
82
|
+
invariants,
|
|
83
|
+
providers,
|
|
84
|
+
integrations,
|
|
85
|
+
hardDeps,
|
|
86
|
+
softDeps,
|
|
87
|
+
coreInherits,
|
|
88
|
+
rawSections,
|
|
89
|
+
profile: "module-v1",
|
|
90
|
+
source: { file: envelope.source.file, startLine: 1, endLine: Math.max(1, sections.at(-1)?.endLine ?? 1) },
|
|
91
|
+
},
|
|
92
|
+
issues,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const IMPLICIT_CORE_NAMES = new Set(["global_standards", "runtime_standards"]);
|
|
96
|
+
function parseCoreDocument(envelope, preamble, sections, _mode) {
|
|
97
|
+
const summary = extractSummary(preamble);
|
|
98
|
+
const version = extractVersion(preamble);
|
|
99
|
+
const title = envelope.title;
|
|
100
|
+
return {
|
|
101
|
+
value: {
|
|
102
|
+
name: envelope.name,
|
|
103
|
+
title,
|
|
104
|
+
version,
|
|
105
|
+
summary,
|
|
106
|
+
sections,
|
|
107
|
+
rawSections: sections,
|
|
108
|
+
implicit: IMPLICIT_CORE_NAMES.has(envelope.name),
|
|
109
|
+
profile: "core-v1",
|
|
110
|
+
source: { file: envelope.source.file, startLine: 1, endLine: Math.max(1, sections.at(-1)?.endLine ?? 1) },
|
|
111
|
+
},
|
|
112
|
+
issues: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { strict as assert } from "node:assert";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { loadCatalogFromRoot, parseDocument } from "./index.js";
|
|
7
|
+
import { implicitCores } from "./catalog.js";
|
|
8
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
9
|
+
describe("contract parser against real corpus files", () => {
|
|
10
|
+
it("parses a legacy module doc with runtime integrations", async () => {
|
|
11
|
+
const file = join(ROOT, "contracts", "billing.md");
|
|
12
|
+
const text = await readFile(file, "utf8");
|
|
13
|
+
const result = parseDocument(file, text, "strict");
|
|
14
|
+
assert.equal(result.issues.some((issue) => issue.severity === "error"), false);
|
|
15
|
+
assert.notEqual(result.value, null);
|
|
16
|
+
assert.equal(result.value?.profile, "module-v1");
|
|
17
|
+
assert.ok((result.value && "functions" in result.value ? result.value.functions.length : 0) > 0);
|
|
18
|
+
assert.ok((result.value && "providers" in result.value ? result.value.providers.length : 0) > 0);
|
|
19
|
+
assert.ok((result.value && "rawSections" in result.value ? result.value.rawSections.length : 0) > 0);
|
|
20
|
+
});
|
|
21
|
+
it("parses a module doc with inline provider text", async () => {
|
|
22
|
+
const file = join(ROOT, "contracts", "feature_flags.md");
|
|
23
|
+
const text = await readFile(file, "utf8");
|
|
24
|
+
const result = parseDocument(file, text, "strict");
|
|
25
|
+
assert.equal(result.issues.some((issue) => issue.severity === "error"), false);
|
|
26
|
+
assert.notEqual(result.value, null);
|
|
27
|
+
assert.equal(result.value?.profile, "module-v1");
|
|
28
|
+
assert.ok((result.value && "functions" in result.value ? result.value.functions.length : 0) > 0);
|
|
29
|
+
assert.ok((result.value && "providers" in result.value ? result.value.providers.length : 0) > 0);
|
|
30
|
+
});
|
|
31
|
+
it("parses a core doc with H3 section structure", async () => {
|
|
32
|
+
const file = join(ROOT, "contracts", "core", "sagas.md");
|
|
33
|
+
const text = await readFile(file, "utf8");
|
|
34
|
+
const result = parseDocument(file, text, "strict");
|
|
35
|
+
assert.equal(result.issues.some((issue) => issue.severity === "error"), false);
|
|
36
|
+
assert.notEqual(result.value, null);
|
|
37
|
+
assert.equal(result.value?.profile, "core-v1");
|
|
38
|
+
assert.ok((result.value && "rawSections" in result.value ? result.value.rawSections.length : 0) > 0);
|
|
39
|
+
});
|
|
40
|
+
it("loads the full catalog from the repository root", async () => {
|
|
41
|
+
const result = await loadCatalogFromRoot(ROOT, "strict");
|
|
42
|
+
assert.equal(result.issues.some((issue) => issue.severity === "error"), false);
|
|
43
|
+
assert.notEqual(result.value, null);
|
|
44
|
+
assert.ok((result.value?.modules.length ?? 0) > 0);
|
|
45
|
+
assert.ok((result.value?.core.length ?? 0) > 0);
|
|
46
|
+
});
|
|
47
|
+
it("marks global_standards and runtime_standards as implicit", async () => {
|
|
48
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
49
|
+
assert.notEqual(result.value, null);
|
|
50
|
+
const implicit = implicitCores(result.value);
|
|
51
|
+
const implicitNames = implicit.map((c) => c.name);
|
|
52
|
+
assert.ok(implicitNames.includes("global_standards"), "global_standards should be implicit");
|
|
53
|
+
assert.ok(implicitNames.includes("runtime_standards"), "runtime_standards should be implicit");
|
|
54
|
+
assert.ok(!implicitNames.includes("sagas"), "sagas should not be implicit");
|
|
55
|
+
assert.equal(implicit.length, 2, "exactly 2 implicit cores");
|
|
56
|
+
});
|
|
57
|
+
it("extracts hardDeps and softDeps from billing", async () => {
|
|
58
|
+
const file = join(ROOT, "contracts", "billing.md");
|
|
59
|
+
const text = await readFile(file, "utf8");
|
|
60
|
+
const result = parseDocument(file, text, "strict");
|
|
61
|
+
assert.notEqual(result.value, null);
|
|
62
|
+
assert.equal(result.value?.profile, "module-v1");
|
|
63
|
+
const mod = result.value;
|
|
64
|
+
assert.deepEqual(mod.hardDeps, ["payments", "users"]);
|
|
65
|
+
assert.deepEqual(mod.softDeps, ["notifications", "audit_log", "usage_metering"]);
|
|
66
|
+
});
|
|
67
|
+
it("returns empty hardDeps for users (none)", async () => {
|
|
68
|
+
const file = join(ROOT, "contracts", "users.md");
|
|
69
|
+
const text = await readFile(file, "utf8");
|
|
70
|
+
const result = parseDocument(file, text, "strict");
|
|
71
|
+
assert.notEqual(result.value, null);
|
|
72
|
+
const mod = result.value;
|
|
73
|
+
assert.deepEqual(mod.hardDeps, []);
|
|
74
|
+
assert.deepEqual(mod.softDeps, ["audit_log", "notifications", "permissions"]);
|
|
75
|
+
});
|
|
76
|
+
it("strips parenthetical notes from deps in cart", async () => {
|
|
77
|
+
const file = join(ROOT, "contracts", "cart.md");
|
|
78
|
+
const text = await readFile(file, "utf8");
|
|
79
|
+
const result = parseDocument(file, text, "strict");
|
|
80
|
+
assert.notEqual(result.value, null);
|
|
81
|
+
const mod = result.value;
|
|
82
|
+
assert.deepEqual(mod.hardDeps, ["catalog", "inventory", "promotions"]);
|
|
83
|
+
assert.deepEqual(mod.softDeps, ["caching"]);
|
|
84
|
+
});
|
|
85
|
+
it("extracts coreInherits from assignments", async () => {
|
|
86
|
+
const file = join(ROOT, "contracts", "assignments.md");
|
|
87
|
+
const text = await readFile(file, "utf8");
|
|
88
|
+
const result = parseDocument(file, text, "strict");
|
|
89
|
+
assert.notEqual(result.value, null);
|
|
90
|
+
const mod = result.value;
|
|
91
|
+
assert.ok(mod.coreInherits.includes("runtime_standards"), "assignments should inherit runtime_standards");
|
|
92
|
+
});
|
|
93
|
+
it("parses all modules and extracts coreInherits", async () => {
|
|
94
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
95
|
+
assert.notEqual(result.value, null);
|
|
96
|
+
const withInherits = result.value.modules.filter((m) => m.coreInherits.length > 0);
|
|
97
|
+
assert.ok(withInherits.length > 0, "some modules should have coreInherits");
|
|
98
|
+
for (const mod of withInherits) {
|
|
99
|
+
for (const name of mod.coreInherits) {
|
|
100
|
+
assert.ok(["runtime_standards", "global_standards"].includes(name), `coreInherits should be a known core contract: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { strict as assert } from "node:assert";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { loadCatalogFromRoot, parseDocument } from "./index.js";
|
|
7
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
8
|
+
describe("parser accuracy", () => {
|
|
9
|
+
it("parses all 108 contracts without errors", async () => {
|
|
10
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
11
|
+
assert.notEqual(result.value, null);
|
|
12
|
+
assert.equal(result.value.modules.length, 108);
|
|
13
|
+
assert.equal(result.issues.filter((i) => i.severity === "error").length, 0);
|
|
14
|
+
});
|
|
15
|
+
it("parses all 3 core contracts", async () => {
|
|
16
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
17
|
+
assert.notEqual(result.value, null);
|
|
18
|
+
assert.equal(result.value.core.length, 3);
|
|
19
|
+
});
|
|
20
|
+
it("extracts functions from every module", async () => {
|
|
21
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
22
|
+
assert.notEqual(result.value, null);
|
|
23
|
+
for (const mod of result.value.modules) {
|
|
24
|
+
assert.ok(mod.functions.length > 0, `${mod.name} has no functions`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it("extracts hardDeps from every module that has them", async () => {
|
|
28
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
29
|
+
assert.notEqual(result.value, null);
|
|
30
|
+
const modulesWithDeps = ["billing", "orders", "auth", "cart", "subscriptions"];
|
|
31
|
+
for (const name of modulesWithDeps) {
|
|
32
|
+
const mod = result.value.modules.find((m) => m.name === name);
|
|
33
|
+
assert.ok(mod, `${name} exists`);
|
|
34
|
+
assert.ok(mod.hardDeps.length > 0, `${name} has hardDeps`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it("extracts softDeps from every module that has them", async () => {
|
|
38
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
39
|
+
assert.notEqual(result.value, null);
|
|
40
|
+
const modulesWithSoftDeps = ["billing", "orders", "auth", "users"];
|
|
41
|
+
for (const name of modulesWithSoftDeps) {
|
|
42
|
+
const mod = result.value.modules.find((m) => m.name === name);
|
|
43
|
+
assert.ok(mod, `${name} exists`);
|
|
44
|
+
assert.ok(mod.softDeps.length > 0, `${name} has softDeps`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it("extracts coreInherits from modules that reference runtime_standards", async () => {
|
|
48
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
49
|
+
assert.notEqual(result.value, null);
|
|
50
|
+
const modulesWithInherits = result.value.modules.filter((m) => m.coreInherits.length > 0);
|
|
51
|
+
assert.ok(modulesWithInherits.length > 10, "multiple modules inherit core");
|
|
52
|
+
});
|
|
53
|
+
it("parses multi-line type definitions", async () => {
|
|
54
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
55
|
+
assert.notEqual(result.value, null);
|
|
56
|
+
const ab_testing = result.value.modules.find((m) => m.name === "ab_testing");
|
|
57
|
+
assert.ok(ab_testing, "ab_testing exists");
|
|
58
|
+
const variant = ab_testing.types.find((t) => t.name === "Variant");
|
|
59
|
+
assert.ok(variant, "Variant type exists");
|
|
60
|
+
assert.ok(variant.raw.length > 50, "Variant type has full definition");
|
|
61
|
+
});
|
|
62
|
+
it("parses shorthand type definitions", async () => {
|
|
63
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
64
|
+
assert.notEqual(result.value, null);
|
|
65
|
+
const billing = result.value.modules.find((m) => m.name === "billing");
|
|
66
|
+
assert.ok(billing, "billing exists");
|
|
67
|
+
const subscription = billing.types.find((t) => t.name === "Subscription");
|
|
68
|
+
assert.ok(subscription, "Subscription type exists");
|
|
69
|
+
assert.ok(subscription.raw.includes("id"), "Subscription has id field");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("parser consistency", () => {
|
|
73
|
+
it("produces same output on multiple runs", async () => {
|
|
74
|
+
const result1 = await loadCatalogFromRoot(ROOT, "loose");
|
|
75
|
+
const result2 = await loadCatalogFromRoot(ROOT, "loose");
|
|
76
|
+
assert.equal(result1.value.modules.length, result2.value.modules.length);
|
|
77
|
+
assert.equal(result1.value.core.length, result2.value.core.length);
|
|
78
|
+
for (let i = 0; i < result1.value.modules.length; i++) {
|
|
79
|
+
const mod1 = result1.value.modules[i];
|
|
80
|
+
const mod2 = result2.value.modules[i];
|
|
81
|
+
assert.equal(mod1.name, mod2.name);
|
|
82
|
+
assert.equal(mod1.functions.length, mod2.functions.length);
|
|
83
|
+
assert.equal(mod1.hardDeps.length, mod2.hardDeps.length);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
it("preserves line numbers for all extracted items", async () => {
|
|
87
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
88
|
+
assert.notEqual(result.value, null);
|
|
89
|
+
for (const mod of result.value.modules) {
|
|
90
|
+
for (const fn of mod.functions) {
|
|
91
|
+
assert.ok(fn.source.startLine > 0, `${mod.name}.${fn.name} has valid startLine`);
|
|
92
|
+
assert.ok(fn.source.endLine >= fn.source.startLine, `${mod.name}.${fn.name} has valid endLine`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("specific contract accuracy", () => {
|
|
98
|
+
it("billing has correct functions", async () => {
|
|
99
|
+
const file = join(ROOT, "contracts", "billing.md");
|
|
100
|
+
const text = await readFile(file, "utf8");
|
|
101
|
+
const result = parseDocument(file, text, "strict");
|
|
102
|
+
assert.equal(result.issues.length, 0);
|
|
103
|
+
const mod = result.value;
|
|
104
|
+
const fnNames = mod.functions.map((f) => f.name);
|
|
105
|
+
assert.ok(fnNames.includes("createSubscription"));
|
|
106
|
+
assert.ok(fnNames.includes("getSubscription"));
|
|
107
|
+
assert.ok(fnNames.includes("cancelSubscription"));
|
|
108
|
+
assert.ok(fnNames.includes("getInvoices"));
|
|
109
|
+
});
|
|
110
|
+
it("orders has correct functions", async () => {
|
|
111
|
+
const file = join(ROOT, "contracts", "orders.md");
|
|
112
|
+
const text = await readFile(file, "utf8");
|
|
113
|
+
const result = parseDocument(file, text, "strict");
|
|
114
|
+
assert.equal(result.issues.length, 0);
|
|
115
|
+
const mod = result.value;
|
|
116
|
+
const fnNames = mod.functions.map((f) => f.name);
|
|
117
|
+
assert.ok(fnNames.includes("createOrder"));
|
|
118
|
+
assert.ok(fnNames.includes("getOrder"));
|
|
119
|
+
assert.ok(fnNames.includes("transitionOrderStatus"));
|
|
120
|
+
assert.ok(fnNames.includes("cancelOrder"));
|
|
121
|
+
});
|
|
122
|
+
it("auth has correct functions", async () => {
|
|
123
|
+
const file = join(ROOT, "contracts", "auth.md");
|
|
124
|
+
const text = await readFile(file, "utf8");
|
|
125
|
+
const result = parseDocument(file, text, "strict");
|
|
126
|
+
assert.equal(result.issues.length, 0);
|
|
127
|
+
const mod = result.value;
|
|
128
|
+
const fnNames = mod.functions.map((f) => f.name);
|
|
129
|
+
assert.ok(fnNames.includes("signUp"));
|
|
130
|
+
assert.ok(fnNames.includes("signIn"));
|
|
131
|
+
assert.ok(fnNames.includes("signOut"));
|
|
132
|
+
assert.ok(fnNames.includes("verifyToken"));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { implicitCores } from "./catalog.js";
|
|
2
|
+
export function detectCycles(catalog) {
|
|
3
|
+
const moduleByName = new Map();
|
|
4
|
+
for (const mod of catalog.modules) {
|
|
5
|
+
moduleByName.set(mod.name, mod);
|
|
6
|
+
}
|
|
7
|
+
const cycles = [];
|
|
8
|
+
const visited = new Set();
|
|
9
|
+
const inStack = new Set();
|
|
10
|
+
const path = [];
|
|
11
|
+
function dfs(name) {
|
|
12
|
+
if (inStack.has(name)) {
|
|
13
|
+
const cycleStart = path.indexOf(name);
|
|
14
|
+
cycles.push(path.slice(cycleStart).concat(name));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (visited.has(name))
|
|
18
|
+
return;
|
|
19
|
+
visited.add(name);
|
|
20
|
+
inStack.add(name);
|
|
21
|
+
path.push(name);
|
|
22
|
+
const mod = moduleByName.get(name);
|
|
23
|
+
if (mod) {
|
|
24
|
+
for (const dep of mod.hardDeps) {
|
|
25
|
+
if (moduleByName.has(dep)) {
|
|
26
|
+
dfs(dep);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
path.pop();
|
|
31
|
+
inStack.delete(name);
|
|
32
|
+
}
|
|
33
|
+
for (const mod of catalog.modules) {
|
|
34
|
+
dfs(mod.name);
|
|
35
|
+
}
|
|
36
|
+
return cycles;
|
|
37
|
+
}
|
|
38
|
+
export function resolve(catalog, requestedModules) {
|
|
39
|
+
const moduleByName = new Map();
|
|
40
|
+
for (const mod of catalog.modules) {
|
|
41
|
+
moduleByName.set(mod.name, mod);
|
|
42
|
+
}
|
|
43
|
+
const resolved = new Map();
|
|
44
|
+
const errors = [];
|
|
45
|
+
const warnings = [];
|
|
46
|
+
for (const name of requestedModules) {
|
|
47
|
+
if (!moduleByName.has(name)) {
|
|
48
|
+
errors.push(`Module not found in catalog: ${name}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
resolved.set(name, { name, source: "explicit" });
|
|
52
|
+
}
|
|
53
|
+
const queue = requestedModules
|
|
54
|
+
.filter((name) => moduleByName.has(name))
|
|
55
|
+
.map((name) => ({ name, via: "explicit" }));
|
|
56
|
+
const inQueue = new Set(queue.map((q) => q.name));
|
|
57
|
+
while (queue.length > 0) {
|
|
58
|
+
const current = queue.shift();
|
|
59
|
+
inQueue.delete(current.name);
|
|
60
|
+
const mod = moduleByName.get(current.name);
|
|
61
|
+
if (!mod)
|
|
62
|
+
continue;
|
|
63
|
+
for (const dep of mod.hardDeps) {
|
|
64
|
+
if (resolved.has(dep))
|
|
65
|
+
continue;
|
|
66
|
+
if (!moduleByName.has(dep)) {
|
|
67
|
+
errors.push(`Hard dependency not found in catalog: ${dep} (required by ${current.name})`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
resolved.set(dep, { name: dep, source: "hard-dep" });
|
|
71
|
+
if (!inQueue.has(dep)) {
|
|
72
|
+
queue.push({ name: dep, via: "hard-dep" });
|
|
73
|
+
inQueue.add(dep);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (current.via === "explicit") {
|
|
77
|
+
for (const dep of mod.softDeps) {
|
|
78
|
+
if (resolved.has(dep))
|
|
79
|
+
continue;
|
|
80
|
+
if (!moduleByName.has(dep))
|
|
81
|
+
continue;
|
|
82
|
+
resolved.set(dep, { name: dep, source: "soft-dep" });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const resolvedModules = [...resolved.values()].map((entry) => {
|
|
87
|
+
const mod = moduleByName.get(entry.name);
|
|
88
|
+
return {
|
|
89
|
+
name: entry.name,
|
|
90
|
+
source: entry.source,
|
|
91
|
+
hardDeps: mod.hardDeps,
|
|
92
|
+
softDeps: mod.softDeps,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
resolvedModules.sort((a, b) => {
|
|
96
|
+
const order = { explicit: 0, "hard-dep": 1, "soft-dep": 2 };
|
|
97
|
+
return (order[a.source] ?? 3) - (order[b.source] ?? 3) || a.name.localeCompare(b.name);
|
|
98
|
+
});
|
|
99
|
+
const implicitCore = implicitCores(catalog);
|
|
100
|
+
const coreByName = new Map();
|
|
101
|
+
for (const c of implicitCore) {
|
|
102
|
+
coreByName.set(c.name, c);
|
|
103
|
+
}
|
|
104
|
+
for (const entry of resolved.values()) {
|
|
105
|
+
const mod = moduleByName.get(entry.name);
|
|
106
|
+
if (!mod)
|
|
107
|
+
continue;
|
|
108
|
+
for (const coreName of mod.coreInherits) {
|
|
109
|
+
if (coreByName.has(coreName))
|
|
110
|
+
continue;
|
|
111
|
+
const coreContract = catalog.core.find((c) => c.name === coreName);
|
|
112
|
+
if (coreContract) {
|
|
113
|
+
coreByName.set(coreName, coreContract);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const core = [...coreByName.values()];
|
|
118
|
+
return { modules: resolvedModules, core, errors, warnings };
|
|
119
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { loadCatalogFromRoot } from "./index.js";
|
|
4
|
+
import { resolve, detectCycles } from "./resolve.js";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
7
|
+
function renderList(catalog) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
lines.push("Modules:");
|
|
10
|
+
for (const mod of catalog.modules.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
11
|
+
const deps = mod.hardDeps.length > 0 ? mod.hardDeps.join(", ") : "(none)";
|
|
12
|
+
const soft = mod.softDeps.length > 0 ? mod.softDeps.join(", ") : "(none)";
|
|
13
|
+
const summary = mod.summary ? ` - ${mod.summary}` : "";
|
|
14
|
+
lines.push(` ${mod.name}${summary}`);
|
|
15
|
+
lines.push(` deps: ${deps}`);
|
|
16
|
+
lines.push(` recommends: ${soft}`);
|
|
17
|
+
}
|
|
18
|
+
lines.push("");
|
|
19
|
+
lines.push("Core contracts:");
|
|
20
|
+
for (const c of catalog.core.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
21
|
+
lines.push(` ${c.name}${c.implicit ? " (implicit)" : ""}`);
|
|
22
|
+
}
|
|
23
|
+
return lines.join("\n");
|
|
24
|
+
}
|
|
25
|
+
describe("resolver", () => {
|
|
26
|
+
it("resolves a module with no deps", async () => {
|
|
27
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
28
|
+
assert.notEqual(result.value, null);
|
|
29
|
+
const resolved = resolve(result.value, ["users"]);
|
|
30
|
+
assert.equal(resolved.warnings.length, 0);
|
|
31
|
+
assert.ok(resolved.modules.some((m) => m.name === "users" && m.source === "explicit"));
|
|
32
|
+
assert.ok(resolved.core.length > 0, "should include implicit cores");
|
|
33
|
+
assert.ok(resolved.core.every((c) => c.implicit), "all cores should be implicit");
|
|
34
|
+
});
|
|
35
|
+
it("resolves hard deps transitively", async () => {
|
|
36
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
37
|
+
assert.notEqual(result.value, null);
|
|
38
|
+
const resolved = resolve(result.value, ["billing"]);
|
|
39
|
+
const names = resolved.modules.map((m) => m.name);
|
|
40
|
+
assert.ok(names.includes("billing"), "explicit module present");
|
|
41
|
+
assert.ok(names.includes("payments"), "hard dep of billing");
|
|
42
|
+
assert.ok(names.includes("users"), "hard dep of billing");
|
|
43
|
+
assert.ok(resolved.modules.find((m) => m.name === "payments")?.source === "hard-dep");
|
|
44
|
+
assert.ok(resolved.modules.find((m) => m.name === "users")?.source === "hard-dep");
|
|
45
|
+
});
|
|
46
|
+
it("includes soft deps of explicitly requested modules", async () => {
|
|
47
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
48
|
+
assert.notEqual(result.value, null);
|
|
49
|
+
const resolved = resolve(result.value, ["billing"]);
|
|
50
|
+
const softDeps = resolved.modules.filter((m) => m.source === "soft-dep").map((m) => m.name);
|
|
51
|
+
assert.ok(softDeps.includes("notifications"), "soft dep of billing");
|
|
52
|
+
assert.ok(softDeps.includes("audit_log"), "soft dep of billing");
|
|
53
|
+
});
|
|
54
|
+
it("does not include soft deps of hard-dep modules", async () => {
|
|
55
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
56
|
+
assert.notEqual(result.value, null);
|
|
57
|
+
const resolved = resolve(result.value, ["billing"]);
|
|
58
|
+
const names = resolved.modules.map((m) => m.name);
|
|
59
|
+
assert.ok(!names.includes("fraud_detection"), "fraud_detection is a soft dep of payments (hard-dep), should not be pulled");
|
|
60
|
+
});
|
|
61
|
+
it("errors on unknown module names", async () => {
|
|
62
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
63
|
+
assert.notEqual(result.value, null);
|
|
64
|
+
const resolved = resolve(result.value, ["nonexistent"]);
|
|
65
|
+
assert.ok(resolved.errors.some((e) => e.includes("nonexistent")));
|
|
66
|
+
assert.equal(resolved.modules.length, 0);
|
|
67
|
+
});
|
|
68
|
+
it("always includes implicit core contracts", async () => {
|
|
69
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
70
|
+
assert.notEqual(result.value, null);
|
|
71
|
+
const resolved = resolve(result.value, ["catalog"]);
|
|
72
|
+
const coreNames = resolved.core.map((c) => c.name);
|
|
73
|
+
assert.ok(coreNames.includes("global_standards"));
|
|
74
|
+
assert.ok(coreNames.includes("runtime_standards"));
|
|
75
|
+
});
|
|
76
|
+
it("includes core contracts inherited by resolved modules", async () => {
|
|
77
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
78
|
+
assert.notEqual(result.value, null);
|
|
79
|
+
const resolved = resolve(result.value, ["assignments"]);
|
|
80
|
+
const coreNames = resolved.core.map((c) => c.name);
|
|
81
|
+
assert.ok(coreNames.includes("runtime_standards"), "assignments inherits runtime_standards");
|
|
82
|
+
});
|
|
83
|
+
it("sorts explicit before hard-dep before soft-dep", async () => {
|
|
84
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
85
|
+
assert.notEqual(result.value, null);
|
|
86
|
+
const resolved = resolve(result.value, ["billing"]);
|
|
87
|
+
const sources = resolved.modules.map((m) => m.source);
|
|
88
|
+
const explicitIdx = sources.indexOf("explicit");
|
|
89
|
+
const hardIdx = sources.indexOf("hard-dep");
|
|
90
|
+
const softIdx = sources.indexOf("soft-dep");
|
|
91
|
+
assert.ok(explicitIdx < hardIdx, "explicit comes before hard-dep");
|
|
92
|
+
assert.ok(hardIdx < softIdx, "hard-dep comes before soft-dep");
|
|
93
|
+
});
|
|
94
|
+
it("detects no cycles in the real corpus", async () => {
|
|
95
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
96
|
+
assert.notEqual(result.value, null);
|
|
97
|
+
const cycles = detectCycles(result.value);
|
|
98
|
+
assert.equal(cycles.length, 0, "no cycles expected in the real corpus");
|
|
99
|
+
});
|
|
100
|
+
it("list command output contains module names", async () => {
|
|
101
|
+
const result = await loadCatalogFromRoot(ROOT, "loose");
|
|
102
|
+
assert.notEqual(result.value, null);
|
|
103
|
+
const output = renderList(result.value);
|
|
104
|
+
assert.ok(output.includes("billing"), "should list billing");
|
|
105
|
+
assert.ok(output.includes("users"), "should list users");
|
|
106
|
+
assert.ok(output.includes("Core contracts:"), "should list core section");
|
|
107
|
+
});
|
|
108
|
+
});
|