@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.
@@ -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: OwnershipMaps = {
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
- includeRule(domainKey, concept.governedBy ?? [], ownership, collections);
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
- includeRule(domainKey, endpoint.governedBy ?? [], ownership, collections);
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
- includeRule(domainKey, service.governedBy ?? [], ownership, collections);
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 includeRule(
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(function (returned) {
285
- return returned.concept === concept;
286
- })
284
+ endpoint.returns?.some((returned) => returned.concept === concept)
287
285
  );
288
286
  }
@@ -24,7 +24,7 @@ import type {
24
24
  ServiceDef,
25
25
  } from "@cortex/contracts/graph";
26
26
 
27
- export type GeneratedCypher = {
27
+ type GeneratedCypher = {
28
28
  nodes: string[];
29
29
  edges: string[];
30
30
  };
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, type GeneratedCypher } from "./generate-cypher";
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
- // Generate Cypher for all target domains
152
- const allCypher: [string, GeneratedCypher][] = targets.map(([name, data]) => [
153
- name,
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: Record<string, DomainSeedResult> = Object.fromEntries(
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
  );
@@ -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.3.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: Record<Database, 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: [note: string, config: string][] = [
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 = FEATURE_NAMES.filter((feature) => features.includes(feature))
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 = FEATURE_NAMES.filter((feature) => features.includes(feature)).map(
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 FEATURE_NAMES.filter((feature) => features.includes(feature))) {
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
- enabled.length === 0 ? "" : ` Also on: ${enabled.join(", ")}.`
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
- /** Relative path → contents. Nothing here touches the filesystem. */
386
- export function scaffoldFiles(name: string, database: Database, features: Feature[], ports: Ports) {
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: Record<string, string> = {
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 type { Endpoint, HttpMethod, Prop } from "./types";
4
- import {
5
- dedupe,
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?: boolean;
12
+ write: boolean;
21
13
  };
22
14
 
23
- const METHODS: Array<[string, HttpMethod]> = [
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, accept: (filePath: string) => boolean) {
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 if (accept(p)) out.push(p);
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, (p) => p.endsWith(".endpoint.ts"))) {
70
- const c = fs.readFileSync(filePath, "utf8");
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
- function extractEndpoint(
79
- doc: unknown,
80
- route: string,
81
- method: HttpMethod,
82
- pathItemIn: unknown,
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((x) => deref(doc, x))
93
- .filter((x) => typeof x.in === "string" && ["path", "query"].includes(x.in))
94
- .filter((x) => typeof x.name === "string");
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 x of rawParams) {
98
- const name = String(x.name);
99
- if (!name.includes(".")) continue;
100
- const root = name.split(".")[0]!;
101
- const rest = name.slice(root.length + 1);
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
- const params = dedupe(
105
+ return dedupe(
106
106
  rawParams
107
- .filter((x) => {
108
- const name = String(x.name);
109
- if (!name.includes(".")) return true;
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((x) =>
111
+ .map((param) =>
114
112
  toProp(
115
113
  doc,
116
- toCamelCase(String(x.name)),
117
- x.schema,
118
- String(x.in) === "path" || x.required === true,
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
- const body: Prop[] = [];
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 requestContent = pickContent(requestBody.content);
133
- if (requestContent) {
134
- const s = deref(doc, requestContent.schema);
135
- const t = schemaType(s);
136
- if (t === "object") body.push(...toProps(doc, s));
137
- else if (t === "array") body.push(toProp(doc, "items", s, requestBody.required === true));
138
- else body.push(toProp(doc, "body", s, requestBody.required === true));
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
- const parsed = parseResponse(doc, operation.responses);
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: dedupe(body),
150
- response: dedupe(parsed.response),
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, pathItem] of Object.entries(obj(doc.paths))) {
161
- for (const [openApiMethod, method] of METHODS) {
162
- const operation = obj(pathItem)[openApiMethod];
163
- if (!isObj(operation)) continue;
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 = false } = options;
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 (collectSwaggerUrls order).
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 isObj(v) ? v : {};
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((x) => (seen.has(x.name) ? false : (seen.add(x.name), true)));
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 (!isObj(cur) || !(key in cur)) return null;
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 (!isObj(v)) return {};
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 (isObj(s.properties)) return "object";
56
- if (isObj(s.items)) return "array";
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
- isObj(v) &&
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
- if (!mimes.length) return null;
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
- mimes[0];
160
- return { schema: obj(content[mime!]).schema, mimes };
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: ResponseBody = { responseKind: "none", response: [] };
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]);