@m6d/cortex-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +22 -5
- package/src/contracts/egress/index.ts +8 -47
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +101 -9
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +19 -25
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
|
@@ -84,12 +84,12 @@ export function expandDomains(domains: Record<string, DomainDef>) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function createOwnershipMaps(domains: Record<string, DomainDef>) {
|
|
87
|
-
const ownership
|
|
87
|
+
const ownership = {
|
|
88
88
|
concepts: new WeakMap<ConceptDef, string>(),
|
|
89
89
|
endpoints: new WeakMap<EndpointDef, string>(),
|
|
90
90
|
services: new WeakMap<ServiceDef, string>(),
|
|
91
91
|
rules: new WeakMap<RuleDef, string>(),
|
|
92
|
-
};
|
|
92
|
+
} satisfies OwnershipMaps;
|
|
93
93
|
|
|
94
94
|
for (const [domainKey, domain] of Object.entries(domains)) {
|
|
95
95
|
for (const concept of domain.concepts ?? []) {
|
|
@@ -138,7 +138,7 @@ function includeConcept(options: {
|
|
|
138
138
|
const { domainKey, concept, ownership, collections, pendingConcepts, pendingEndpoints } =
|
|
139
139
|
options;
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
includeRules(domainKey, concept.governedBy ?? [], ownership, collections);
|
|
142
142
|
|
|
143
143
|
if (shouldAttach(ownership.concepts, concept, domainKey)) {
|
|
144
144
|
pushUnique(collections.concepts, concept);
|
|
@@ -182,7 +182,7 @@ function includeEndpoint(options: {
|
|
|
182
182
|
pushUnique(collections.endpoints, endpoint);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
includeRules(domainKey, endpoint.governedBy ?? [], ownership, collections);
|
|
186
186
|
|
|
187
187
|
// An endpoint reaches concepts three ways and other endpoints one way, but the
|
|
188
188
|
// handling is identical: take what this domain owns, and queue whatever is new.
|
|
@@ -240,7 +240,7 @@ function includeService(options: {
|
|
|
240
240
|
pushUnique(collections.services, service);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
includeRules(domainKey, service.governedBy ?? [], ownership, collections);
|
|
244
244
|
|
|
245
245
|
if (
|
|
246
246
|
shouldAttach(ownership.concepts, service.belongsTo, domainKey) &&
|
|
@@ -250,7 +250,7 @@ function includeService(options: {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
function
|
|
253
|
+
function includeRules(
|
|
254
254
|
domainKey: string,
|
|
255
255
|
rules: readonly RuleDef[],
|
|
256
256
|
ownership: OwnershipMaps,
|
|
@@ -281,8 +281,6 @@ function referencesConcept(endpoint: EndpointDef, concept: ConceptDef) {
|
|
|
281
281
|
return (
|
|
282
282
|
endpoint.queries?.includes(concept) ||
|
|
283
283
|
endpoint.mutates?.includes(concept) ||
|
|
284
|
-
endpoint.returns?.some(
|
|
285
|
-
return returned.concept === concept;
|
|
286
|
-
})
|
|
284
|
+
endpoint.returns?.some((returned) => returned.concept === concept)
|
|
287
285
|
);
|
|
288
286
|
}
|
package/src/graph/seed.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "@cortex/contracts/graph";
|
|
13
13
|
|
|
14
14
|
import { expandDomains } from "./expand-domains";
|
|
15
|
-
import { generateCypher
|
|
15
|
+
import { generateCypher } from "./generate-cypher";
|
|
16
16
|
import { validateDomain } from "./validate";
|
|
17
17
|
|
|
18
18
|
type SeedEmbedding = {
|
|
@@ -95,7 +95,6 @@ async function generateEmbeddings(
|
|
|
95
95
|
embeddingConfig: SeedEmbedding,
|
|
96
96
|
s: GraphSchema,
|
|
97
97
|
) {
|
|
98
|
-
// 1. Create the vector index (idempotent)
|
|
99
98
|
await client.query(
|
|
100
99
|
`CREATE VECTOR INDEX ${s.conceptEmbeddingsIndex} IF NOT EXISTS
|
|
101
100
|
FOR (c:${s.label.concept}) ON (c.${s.prop.embedding})
|
|
@@ -105,7 +104,6 @@ async function generateEmbeddings(
|
|
|
105
104
|
}}`,
|
|
106
105
|
);
|
|
107
106
|
|
|
108
|
-
// 2. Fetch all concepts
|
|
109
107
|
const concepts = (await client.query(
|
|
110
108
|
`MATCH (c:${s.label.concept}) RETURN c.${s.prop.name} AS name, c.${s.prop.description} AS description`,
|
|
111
109
|
)) as { name: string; description: string }[];
|
|
@@ -114,10 +112,8 @@ async function generateEmbeddings(
|
|
|
114
112
|
return { concepts: 0, dimension: embeddingConfig.dimension };
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
// 3. Generate embeddings for every concept in one request
|
|
118
115
|
const embeddings = await embeddingConfig.embed(concepts.map((c) => c.description));
|
|
119
116
|
|
|
120
|
-
// 4. Store the embeddings in the graph
|
|
121
117
|
await Promise.all(
|
|
122
118
|
concepts.map((concept, idx) =>
|
|
123
119
|
client.query(
|
|
@@ -134,7 +130,6 @@ async function generateEmbeddings(
|
|
|
134
130
|
export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
|
|
135
131
|
const expandedDomains = expandDomains(domains);
|
|
136
132
|
|
|
137
|
-
// Validate all domains
|
|
138
133
|
const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
|
|
139
134
|
validateDomain(domain).map((err) => `[${name}] ${err}`),
|
|
140
135
|
);
|
|
@@ -148,11 +143,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
148
143
|
const client = config.neo4j;
|
|
149
144
|
const targets = Object.entries(expandedDomains);
|
|
150
145
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
generateCypher(data, config.schema),
|
|
155
|
-
]);
|
|
146
|
+
const allCypher = targets.map(
|
|
147
|
+
([name, data]) => [name, generateCypher(data, config.schema)] as const,
|
|
148
|
+
);
|
|
156
149
|
|
|
157
150
|
// Stamped before anything else is written, so an interrupted seed still
|
|
158
151
|
// leaves the graph labelled with the schema it was written against.
|
|
@@ -169,10 +162,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
169
162
|
const allEdges = allCypher.flatMap(([domain, c]) => tag(domain, c.edges));
|
|
170
163
|
const edges = await runPhase(client, allEdges);
|
|
171
164
|
|
|
172
|
-
// Embeddings for every concept.
|
|
173
165
|
const embeddings = await generateEmbeddings(client, config.embedding, config.schema);
|
|
174
166
|
|
|
175
|
-
const domainResults
|
|
167
|
+
const domainResults = Object.fromEntries(
|
|
176
168
|
targets.map(([name]) => {
|
|
177
169
|
const failures = [
|
|
178
170
|
...(nodes.failures.get(name) ?? []),
|
|
@@ -185,7 +177,7 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
185
177
|
edges: edges.succeeded.get(name) ?? 0,
|
|
186
178
|
failed: failures.length,
|
|
187
179
|
failures,
|
|
188
|
-
},
|
|
180
|
+
} satisfies DomainSeedResult,
|
|
189
181
|
];
|
|
190
182
|
}),
|
|
191
183
|
);
|
package/src/scaffold/files.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
|
|
|
27
27
|
// sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
|
|
28
28
|
// them to that.
|
|
29
29
|
export const DEPENDENCIES = {
|
|
30
|
-
"@m6d/cortex-server": "~0.
|
|
30
|
+
"@m6d/cortex-server": "~0.4.0",
|
|
31
31
|
"@tanstack/ai": "^0.55.0",
|
|
32
32
|
"@tanstack/ai-openai": "^0.22.8",
|
|
33
33
|
hono: "^4.12.3",
|
|
@@ -50,10 +50,10 @@ const DEV_DEPENDENCIES = {
|
|
|
50
50
|
|
|
51
51
|
// Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
|
|
52
52
|
// existing instance nobody moved, which is why it keeps 1433 and has no service.
|
|
53
|
-
const CONNECTION_STRING
|
|
53
|
+
const CONNECTION_STRING = {
|
|
54
54
|
postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
|
|
55
55
|
mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
|
|
56
|
-
}
|
|
56
|
+
} satisfies Record<Database, string>;
|
|
57
57
|
|
|
58
58
|
function packageJson(name: string) {
|
|
59
59
|
return `${JSON.stringify(
|
|
@@ -89,7 +89,7 @@ function packageJson(name: string) {
|
|
|
89
89
|
* defaulted, and the other two are what you reach for once a real problem shows
|
|
90
90
|
* up — noisy graph retrieval, a cheap-model path — not on day one.
|
|
91
91
|
*/
|
|
92
|
-
const NEVER_SCAFFOLDED
|
|
92
|
+
const NEVER_SCAFFOLDED = [
|
|
93
93
|
[
|
|
94
94
|
"A cheaper, faster model for auxiliary passes. Same shape as `model`.",
|
|
95
95
|
`fastModel: {
|
|
@@ -112,7 +112,7 @@ const NEVER_SCAFFOLDED: [note: string, config: string][] = [
|
|
|
112
112
|
recentMessagesToKeep: 6,
|
|
113
113
|
},`,
|
|
114
114
|
],
|
|
115
|
-
];
|
|
115
|
+
] satisfies [note: string, config: string][];
|
|
116
116
|
|
|
117
117
|
/** Fragments are authored at zero indentation and placed by these two. */
|
|
118
118
|
function indented(block: string) {
|
|
@@ -132,9 +132,7 @@ function cortexConfig(database: Database, features: Feature[]) {
|
|
|
132
132
|
const fragment = (feature: Feature) =>
|
|
133
133
|
block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
|
|
134
134
|
|
|
135
|
-
const on =
|
|
136
|
-
.map(fragment)
|
|
137
|
-
.join("");
|
|
135
|
+
const on = features.map(fragment).join("");
|
|
138
136
|
const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
|
|
139
137
|
.map(fragment)
|
|
140
138
|
.join("");
|
|
@@ -192,9 +190,7 @@ function hasServices(database: Database, features: Feature[]) {
|
|
|
192
190
|
}
|
|
193
191
|
|
|
194
192
|
function env(database: Database, features: Feature[]) {
|
|
195
|
-
const blocks =
|
|
196
|
-
(feature) => `\n${FEATURES[feature].env}\n`,
|
|
197
|
-
);
|
|
193
|
+
const blocks = features.map((feature) => `\n${FEATURES[feature].env}\n`);
|
|
198
194
|
|
|
199
195
|
// Only the URLs pointing at scaffolded services carry a chosen port, and those
|
|
200
196
|
// are exactly the ones docker-compose.yml publishes.
|
|
@@ -248,7 +244,7 @@ function dockerCompose(name: string, database: Database, features: Feature[]) {
|
|
|
248
244
|
volumes.push("postgres-data");
|
|
249
245
|
}
|
|
250
246
|
|
|
251
|
-
for (const feature of
|
|
247
|
+
for (const feature of features) {
|
|
252
248
|
const { compose, volume } = FEATURES[feature];
|
|
253
249
|
if (compose === undefined) continue;
|
|
254
250
|
services.push(compose);
|
|
@@ -279,12 +275,6 @@ ${services.join("\n\n")}
|
|
|
279
275
|
${volumeBlock}`;
|
|
280
276
|
}
|
|
281
277
|
|
|
282
|
-
/**
|
|
283
|
-
* `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
|
|
284
|
-
* makes the convention true by construction: `cortex swagger sync` finds it with
|
|
285
|
-
* no config key.
|
|
286
|
-
*/
|
|
287
|
-
|
|
288
278
|
function cspellConfig() {
|
|
289
279
|
return `${JSON.stringify(
|
|
290
280
|
{
|
|
@@ -337,12 +327,10 @@ function cspellConfig() {
|
|
|
337
327
|
}
|
|
338
328
|
|
|
339
329
|
function readme(name: string, database: Database, features: Feature[], compose: boolean) {
|
|
340
|
-
const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
|
|
341
|
-
|
|
342
330
|
return `# ${name}
|
|
343
331
|
|
|
344
332
|
A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
|
|
345
|
-
|
|
333
|
+
features.length === 0 ? "" : ` Also on: ${features.join(", ")}.`
|
|
346
334
|
}
|
|
347
335
|
|
|
348
336
|
## Getting started
|
|
@@ -382,12 +370,16 @@ pre-commit hook or CI job should run. Each is also available on its own: \`forma
|
|
|
382
370
|
`;
|
|
383
371
|
}
|
|
384
372
|
|
|
385
|
-
/**
|
|
386
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Relative path → contents. Nothing here touches the filesystem. Every
|
|
375
|
+
* generator lists features in catalogue order, whatever order they came in.
|
|
376
|
+
*/
|
|
377
|
+
export function scaffoldFiles(name: string, database: Database, selected: Feature[], ports: Ports) {
|
|
378
|
+
const features = FEATURE_NAMES.filter((feature) => selected.includes(feature));
|
|
387
379
|
const envFile = env(database, features);
|
|
388
380
|
const compose = dockerCompose(name, database, features);
|
|
389
381
|
|
|
390
|
-
const files
|
|
382
|
+
const files = {
|
|
391
383
|
"cortex.config.ts": cortexConfig(database, features),
|
|
392
384
|
"index.ts": indexTs,
|
|
393
385
|
"package.json": packageJson(name),
|
|
@@ -401,8 +393,10 @@ export function scaffoldFiles(name: string, database: Database, features: Featur
|
|
|
401
393
|
"cspell.json": cspellConfig(),
|
|
402
394
|
".fallowrc.jsonc": fallowrc,
|
|
403
395
|
...(compose === undefined ? {} : { "docker-compose.yml": compose }),
|
|
396
|
+
// The directory `domainsDir` defaults to, which makes the convention true
|
|
397
|
+
// by construction: `cortex swagger sync` finds it with no config key.
|
|
404
398
|
...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme } : {}),
|
|
405
|
-
}
|
|
399
|
+
} satisfies Record<string, string>;
|
|
406
400
|
|
|
407
401
|
// One pass at the end, over everything: no generator holds a port number of
|
|
408
402
|
// its own, so compose, .env and the README cannot disagree about one.
|
|
@@ -1,31 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
deref,
|
|
7
|
-
isObj,
|
|
8
|
-
obj,
|
|
9
|
-
pickContent,
|
|
10
|
-
schemaType,
|
|
11
|
-
toProp,
|
|
12
|
-
toProps,
|
|
13
|
-
} from "./openapi-schema";
|
|
3
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
4
|
+
import type { Endpoint, HttpMethod, Obj } from "./types";
|
|
5
|
+
import { dedupe, deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-schema";
|
|
14
6
|
import { parseResponse } from "./parse-response";
|
|
15
7
|
import { withGeneratedBlock } from "./generated-block";
|
|
16
8
|
|
|
17
9
|
type ExtractEndpointsOptions = {
|
|
18
10
|
swaggerUrls: string[];
|
|
19
11
|
domainsDir: string;
|
|
20
|
-
write
|
|
12
|
+
write: boolean;
|
|
21
13
|
};
|
|
22
14
|
|
|
23
|
-
const METHODS
|
|
24
|
-
["get", "GET"],
|
|
25
|
-
["post", "POST"],
|
|
26
|
-
["put", "PUT"],
|
|
27
|
-
["delete", "DELETE"],
|
|
28
|
-
];
|
|
15
|
+
const METHODS = ["GET", "POST", "PUT", "DELETE"] as const satisfies readonly HttpMethod[];
|
|
29
16
|
|
|
30
17
|
/**
|
|
31
18
|
* ASP.NET conventions the extractor normalizes away so an `.endpoint.ts` path
|
|
@@ -44,78 +31,89 @@ function toCamelCase(s: string) {
|
|
|
44
31
|
.join(".");
|
|
45
32
|
}
|
|
46
33
|
|
|
34
|
+
/** `Root` for a dotted parameter like `Root.Options`; undefined for a plain one. */
|
|
35
|
+
function dottedRoot(name: string) {
|
|
36
|
+
const dot = name.indexOf(".");
|
|
37
|
+
return dot < 0 ? undefined : name.slice(0, dot);
|
|
38
|
+
}
|
|
39
|
+
|
|
47
40
|
function normalizePath(raw: string) {
|
|
48
41
|
let p = raw.startsWith("/") ? raw : `/${raw}`;
|
|
49
42
|
p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(API_PREFIX, "") || "/";
|
|
50
43
|
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
function walkFiles(dir: string
|
|
46
|
+
function walkFiles(dir: string) {
|
|
54
47
|
if (!fs.existsSync(dir)) return [];
|
|
55
48
|
const out: string[] = [];
|
|
56
49
|
function walk(d: string) {
|
|
57
50
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
58
51
|
const p = path.join(d, e.name);
|
|
59
52
|
if (e.isDirectory()) walk(p);
|
|
60
|
-
else
|
|
53
|
+
else out.push(p);
|
|
61
54
|
}
|
|
62
55
|
}
|
|
63
56
|
walk(dir);
|
|
64
57
|
return out;
|
|
65
58
|
}
|
|
66
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The `method` and `path` an endpoint file declares, read the way the server's
|
|
62
|
+
* own file walk reads them.
|
|
63
|
+
*/
|
|
64
|
+
export function declaredRoute(source: string) {
|
|
65
|
+
return {
|
|
66
|
+
method: source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2],
|
|
67
|
+
path: source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
67
71
|
function endpointFiles(root: string) {
|
|
68
72
|
const out = new Map<string, string>();
|
|
69
|
-
for (const filePath of walkFiles(root
|
|
70
|
-
const
|
|
71
|
-
const method = c.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
72
|
-
const route = c.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
73
|
+
for (const filePath of walkFiles(root).filter((p) => p.endsWith(".endpoint.ts"))) {
|
|
74
|
+
const { method, path: route } = declaredRoute(fs.readFileSync(filePath, "utf8"));
|
|
73
75
|
if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
|
|
74
76
|
}
|
|
75
77
|
return out;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
operationIn: unknown,
|
|
84
|
-
) {
|
|
85
|
-
const pathItem = obj(pathItemIn);
|
|
86
|
-
const operation = obj(operationIn);
|
|
87
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Path and query parameters, path-level first, with each `JsonNode` parameter
|
|
82
|
+
* collapsed back to one object.
|
|
83
|
+
*/
|
|
84
|
+
function extractParams(doc: unknown, pathItem: Obj, operation: Obj) {
|
|
88
85
|
const rawParams = [
|
|
89
86
|
...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
|
|
90
87
|
...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
|
|
91
88
|
]
|
|
92
|
-
.map((
|
|
93
|
-
.filter(
|
|
94
|
-
|
|
89
|
+
.map((param) => deref(doc, param))
|
|
90
|
+
.filter(
|
|
91
|
+
(param): param is Obj & { name: string; in: string } =>
|
|
92
|
+
typeof param.name === "string" &&
|
|
93
|
+
typeof param.in === "string" &&
|
|
94
|
+
["path", "query"].includes(param.in),
|
|
95
|
+
);
|
|
95
96
|
|
|
96
97
|
const jsonNodeRoots = new Set<string>();
|
|
97
|
-
for (const
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (JSON_NODE_MEMBER.test(rest)) jsonNodeRoots.add(root);
|
|
98
|
+
for (const { name } of rawParams) {
|
|
99
|
+
const root = dottedRoot(name);
|
|
100
|
+
if (root !== undefined && JSON_NODE_MEMBER.test(name.slice(root.length + 1))) {
|
|
101
|
+
jsonNodeRoots.add(root);
|
|
102
|
+
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
return dedupe(
|
|
106
106
|
rawParams
|
|
107
|
-
.filter((
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const root = name.split(".")[0]!;
|
|
111
|
-
return !jsonNodeRoots.has(root);
|
|
107
|
+
.filter((param) => {
|
|
108
|
+
const root = dottedRoot(param.name);
|
|
109
|
+
return root === undefined || !jsonNodeRoots.has(root);
|
|
112
110
|
})
|
|
113
|
-
.map((
|
|
111
|
+
.map((param) =>
|
|
114
112
|
toProp(
|
|
115
113
|
doc,
|
|
116
|
-
toCamelCase(
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
toCamelCase(param.name),
|
|
115
|
+
param.schema,
|
|
116
|
+
param.in === "path" || param.required === true,
|
|
119
117
|
),
|
|
120
118
|
)
|
|
121
119
|
.concat(
|
|
@@ -126,41 +124,46 @@ function extractEndpoint(
|
|
|
126
124
|
})),
|
|
127
125
|
),
|
|
128
126
|
);
|
|
127
|
+
}
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
/** An object body contributes its own fields; anything else is one `items` or `body` field. */
|
|
130
|
+
function extractBody(doc: unknown, operation: Obj) {
|
|
131
131
|
const requestBody = deref(doc, operation.requestBody);
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
const content = pickContent(requestBody.content);
|
|
133
|
+
if (!content) return [];
|
|
134
|
+
const schema = deref(doc, content.schema);
|
|
135
|
+
const type = schemaType(schema);
|
|
136
|
+
if (type === "object") return toProps(doc, schema);
|
|
137
|
+
const name = type === "array" ? "items" : "body";
|
|
138
|
+
return [toProp(doc, name, schema, requestBody.required === true)];
|
|
139
|
+
}
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
function extractEndpoint(
|
|
142
|
+
doc: unknown,
|
|
143
|
+
route: string,
|
|
144
|
+
method: HttpMethod,
|
|
145
|
+
pathItem: Obj,
|
|
146
|
+
operation: Obj,
|
|
147
|
+
) {
|
|
142
148
|
const normalizedPath = normalizePath(route);
|
|
143
|
-
|
|
144
149
|
return {
|
|
145
150
|
key: `${method}:${normalizedPath}`,
|
|
146
151
|
method,
|
|
147
152
|
path: normalizedPath,
|
|
148
|
-
params,
|
|
149
|
-
body:
|
|
150
|
-
|
|
151
|
-
responseKind: parsed.responseKind,
|
|
152
|
-
successStatus: parsed.successStatus,
|
|
153
|
-
errorStatuses: parsed.errorStatuses,
|
|
153
|
+
params: extractParams(doc, pathItem, operation),
|
|
154
|
+
body: extractBody(doc, operation),
|
|
155
|
+
...parseResponse(doc, operation.responses),
|
|
154
156
|
} satisfies Endpoint;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function parseSwaggerEndpoints(swagger: unknown) {
|
|
158
160
|
const out = new Map<string, Endpoint>();
|
|
159
161
|
const doc = obj(swagger);
|
|
160
|
-
for (const [route,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
for (const [route, pathItemIn] of Object.entries(obj(doc.paths))) {
|
|
163
|
+
const pathItem = obj(pathItemIn);
|
|
164
|
+
for (const method of METHODS) {
|
|
165
|
+
const operation = pathItem[method.toLowerCase()];
|
|
166
|
+
if (!isRecord(operation)) continue;
|
|
164
167
|
const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
|
|
165
168
|
out.set(endpoint.key, endpoint);
|
|
166
169
|
}
|
|
@@ -217,11 +220,11 @@ function sameModuloFormatting(a: string, b: string) {
|
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
export async function extractEndpoints(options: ExtractEndpointsOptions) {
|
|
220
|
-
const { swaggerUrls, domainsDir, write
|
|
223
|
+
const { swaggerUrls, domainsDir, write } = options;
|
|
221
224
|
|
|
222
225
|
// Merge every document first: a file missing from one swagger doc may be
|
|
223
226
|
// matched by another. Last write wins on duplicate keys, so per-agent docs
|
|
224
|
-
// deliberately override the server-level one (
|
|
227
|
+
// deliberately override the server-level one (`swaggerUrls` lists it first).
|
|
225
228
|
const extracted = new Map<string, Endpoint>();
|
|
226
229
|
for (const swaggerUrl of swaggerUrls) {
|
|
227
230
|
for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
|
|
@@ -3,25 +3,27 @@
|
|
|
3
3
|
* a schema into the flat property list the generator emits. Nothing here knows what
|
|
4
4
|
* the properties are for.
|
|
5
5
|
*/
|
|
6
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
6
7
|
import type { Obj, Prop } from "./types";
|
|
7
8
|
|
|
8
9
|
const MAX_DEPTH = 8;
|
|
9
10
|
|
|
10
|
-
export function isObj(v: unknown): v is Obj {
|
|
11
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
11
|
export function obj(v: unknown) {
|
|
15
|
-
return
|
|
12
|
+
return isRecord(v) ? v : {};
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
function strings(v: unknown) {
|
|
19
16
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
20
17
|
}
|
|
21
18
|
|
|
19
|
+
/** The first prop of each name. */
|
|
22
20
|
export function dedupe(items: Prop[]) {
|
|
23
21
|
const seen = new Set<string>();
|
|
24
|
-
return items.filter((
|
|
22
|
+
return items.filter((prop) => {
|
|
23
|
+
if (seen.has(prop.name)) return false;
|
|
24
|
+
seen.add(prop.name);
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
function resolvePointer(doc: unknown, ref: string) {
|
|
@@ -29,14 +31,14 @@ function resolvePointer(doc: unknown, ref: string) {
|
|
|
29
31
|
let cur: unknown = doc;
|
|
30
32
|
for (const part of ref.slice(2).split("/")) {
|
|
31
33
|
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
32
|
-
if (!
|
|
34
|
+
if (!isRecord(cur) || !(key in cur)) return null;
|
|
33
35
|
cur = cur[key];
|
|
34
36
|
}
|
|
35
37
|
return cur;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export function deref(doc: unknown, v: unknown) {
|
|
39
|
-
if (!
|
|
41
|
+
if (!isRecord(v)) return {};
|
|
40
42
|
let x = v;
|
|
41
43
|
const seen = new Set<string>();
|
|
42
44
|
while (typeof x.$ref === "string") {
|
|
@@ -52,14 +54,14 @@ export function schemaType(s: Obj) {
|
|
|
52
54
|
? (s.type as unknown[]).find((x) => typeof x === "string" && x !== "null")
|
|
53
55
|
: s.type;
|
|
54
56
|
if (typeof t === "string") return t;
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
57
|
+
if (isRecord(s.properties)) return "object";
|
|
58
|
+
if (isRecord(s.items)) return "array";
|
|
57
59
|
return "";
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function isJsonNodeRef(v: unknown) {
|
|
61
63
|
return (
|
|
62
|
-
|
|
64
|
+
isRecord(v) &&
|
|
63
65
|
typeof v.$ref === "string" &&
|
|
64
66
|
/\/System\.Text\.Json\.Nodes\.JsonNode$/.test(v.$ref)
|
|
65
67
|
);
|
|
@@ -149,13 +151,18 @@ export function toProp(
|
|
|
149
151
|
return { name, required: req, type: scalarType(s) };
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The schema under `application/json`, else any JSON type, else the first
|
|
156
|
+
* type, along with every type offered.
|
|
157
|
+
*/
|
|
152
158
|
export function pickContent(contentIn: unknown) {
|
|
153
159
|
const content = obj(contentIn);
|
|
154
160
|
const mimes = Object.keys(content);
|
|
155
|
-
|
|
161
|
+
const [first] = mimes;
|
|
162
|
+
if (first === undefined) return null;
|
|
156
163
|
const mime =
|
|
157
164
|
mimes.find((x) => x.toLowerCase() === "application/json") ??
|
|
158
165
|
mimes.find((x) => x.toLowerCase().includes("json")) ??
|
|
159
|
-
|
|
160
|
-
return { schema: obj(content[mime
|
|
166
|
+
first;
|
|
167
|
+
return { schema: obj(content[mime]).schema, mimes };
|
|
161
168
|
}
|
|
@@ -8,7 +8,7 @@ import { deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-
|
|
|
8
8
|
|
|
9
9
|
type ResponseBody = { responseKind: ResponseKind; response: Prop[] };
|
|
10
10
|
|
|
11
|
-
const NO_BODY
|
|
11
|
+
const NO_BODY = { responseKind: "none", response: [] } satisfies ResponseBody;
|
|
12
12
|
|
|
13
13
|
const FILE_MIME = /(application\/octet-stream|application\/pdf|application\/vnd\.|text\/csv)/i;
|
|
14
14
|
|
package/src/ui/report.ts
CHANGED
|
@@ -31,12 +31,12 @@ function describe(error: unknown) {
|
|
|
31
31
|
// anything opened the box above it.
|
|
32
32
|
let open = false;
|
|
33
33
|
|
|
34
|
-
/** `┌ cortex graph seed` — opens a run. */
|
|
35
34
|
/** `1 node` / `3 nodes` — every count the box prints goes through this. */
|
|
36
35
|
export function plural(count: number, noun: string) {
|
|
37
36
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
/** `┌ cortex graph seed` — opens a run. */
|
|
40
40
|
export function header(title: string) {
|
|
41
41
|
open = true;
|
|
42
42
|
write(process.stdout, ["", `${pc.gray("┌")} ${title}`, BAR]);
|