kibi-cli 2.32.0 → 2.36.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/commands/check-generated.d.ts +5 -1
- package/dist/commands/check-generated.d.ts.map +1 -1
- package/dist/commands/check-generated.js +24 -35
- package/dist/commands/init-helpers.d.ts.map +1 -1
- package/dist/commands/init-helpers.js +8 -5
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +23 -5
- package/dist/commands/sync/manifest.d.ts +10 -1
- package/dist/commands/sync/manifest.d.ts.map +1 -1
- package/dist/commands/sync/manifest.js +87 -14
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +19 -0
- package/dist/extractors/markdown.d.ts +1 -1
- package/dist/extractors/markdown.d.ts.map +1 -1
- package/dist/extractors/symbol-coordinates.js +2 -2
- package/dist/extractors/symbols-coordinator.d.ts +2 -4
- package/dist/extractors/symbols-coordinator.d.ts.map +1 -1
- package/dist/extractors/symbols-coordinator.js +1 -2
- package/dist/operations/migration/proof-contract-claims.d.ts +23 -10
- package/dist/operations/migration/proof-contract-claims.d.ts.map +1 -1
- package/dist/operations/migration/proof-contract-claims.js +35 -2
- package/dist/operations/migration/schema6.d.ts.map +1 -1
- package/dist/operations/migration/schema6.js +2 -4
- package/dist/operations/migration/symbol-coordinates-refresh.d.ts +3 -2
- package/dist/operations/migration/symbol-coordinates-refresh.d.ts.map +1 -1
- package/dist/operations/migration/symbol-coordinates-refresh.js +4 -3
- package/dist/operations/migration/untrack-symbol-coordinates.d.ts +23 -0
- package/dist/operations/migration/untrack-symbol-coordinates.d.ts.map +1 -0
- package/dist/operations/migration/untrack-symbol-coordinates.js +67 -0
- package/dist/operations/modeling/requirement-modeler.d.ts.map +1 -1
- package/dist/operations/modeling/requirement-modeler.js +48 -1
- package/dist/operations/modeling/requirement-types.d.ts +2 -1
- package/dist/operations/modeling/requirement-types.d.ts.map +1 -1
- package/dist/operations/modeling/requirement-utils.d.ts +1 -1
- package/dist/operations/modeling/requirement-utils.d.ts.map +1 -1
- package/dist/operations/modeling/requirement-utils.js +8 -1
- package/dist/operations/mutation/source-authoring.d.ts.map +1 -1
- package/dist/operations/mutation/source-authoring.js +39 -20
- package/dist/operations/proof/claim-bindings.d.ts +18 -0
- package/dist/operations/proof/claim-bindings.d.ts.map +1 -1
- package/dist/operations/proof/claim-bindings.js +157 -4
- package/dist/operations/proof/code-certificates.d.ts +34 -14
- package/dist/operations/proof/code-certificates.d.ts.map +1 -1
- package/dist/operations/proof/code-certificates.js +266 -157
- package/dist/operations/proof/code-models.d.ts +96 -0
- package/dist/operations/proof/code-models.d.ts.map +1 -0
- package/dist/operations/proof/code-models.js +601 -0
- package/dist/operations/proof/code-runner.d.ts +40 -0
- package/dist/operations/proof/code-runner.d.ts.map +1 -0
- package/dist/operations/proof/code-runner.js +164 -0
- package/dist/operations/proof/code-scope.d.ts +3 -0
- package/dist/operations/proof/code-scope.d.ts.map +1 -1
- package/dist/operations/proof/code-scope.js +26 -2
- package/dist/operations/proof/ingest-proof.d.ts.map +1 -1
- package/dist/operations/proof/ingest-proof.js +2 -1
- package/dist/operations/proof/regular-language.d.ts +89 -0
- package/dist/operations/proof/regular-language.d.ts.map +1 -0
- package/dist/operations/proof/regular-language.js +943 -0
- package/dist/operations/proof/source-code-readers.d.ts +12 -0
- package/dist/operations/proof/source-code-readers.d.ts.map +1 -0
- package/dist/operations/proof/source-code-readers.js +699 -0
- package/dist/operations/semantic-advisor/analyze-prose.d.ts.map +1 -1
- package/dist/operations/semantic-advisor/analyze-prose.js +2 -1
- package/dist/operations/semantic-advisor/strict-count-rules.d.ts.map +1 -1
- package/dist/operations/semantic-advisor/strict-count-rules.js +8 -2
- package/dist/operations/semantic-advisor/strict-rules.d.ts +8 -0
- package/dist/operations/semantic-advisor/strict-rules.d.ts.map +1 -1
- package/dist/operations/semantic-advisor/strict-rules.js +231 -1
- package/dist/operations/semantic-advisor/types.d.ts +1 -1
- package/dist/operations/semantic-advisor/types.d.ts.map +1 -1
- package/dist/prolog.d.ts.map +1 -1
- package/dist/prolog.js +13 -3
- package/dist/public/operations/catalog.d.ts +156 -13
- package/dist/public/operations/catalog.d.ts.map +1 -1
- package/dist/public/operations/evidence-floor.js +1 -1
- package/dist/public/operations/proof-contract-claims.js +1 -1
- package/dist/public/operations/specs/composite.d.ts +16 -3
- package/dist/public/operations/specs/composite.d.ts.map +1 -1
- package/dist/public/operations/specs/modeling.d.ts +16 -3
- package/dist/public/operations/specs/modeling.d.ts.map +1 -1
- package/dist/public/operations/specs/modeling.js +22 -3
- package/dist/public/operations/specs/mutation-schemas.d.ts +140 -10
- package/dist/public/operations/specs/mutation-schemas.d.ts.map +1 -1
- package/dist/public/operations/specs/mutation-schemas.js +14 -3
- package/dist/public/operations/specs/upsert.d.ts +140 -10
- package/dist/public/operations/specs/upsert.d.ts.map +1 -1
- package/dist/public/proof-certificates.d.ts +75 -1
- package/dist/public/proof-certificates.d.ts.map +1 -1
- package/dist/public/proof-certificates.js +212 -0
- package/dist/public/proof-protocol.d.ts +143 -10
- package/dist/public/proof-protocol.d.ts.map +1 -1
- package/dist/public/proof-protocol.js +301 -16
- package/dist/public/proof-receipt.d.ts +4 -3
- package/dist/public/proof-receipt.d.ts.map +1 -1
- package/dist/public/proof-receipt.js +6 -1
- package/dist/public/proof-sources.d.ts +102 -0
- package/dist/public/proof-sources.d.ts.map +1 -0
- package/dist/public/proof-sources.js +1292 -0
- package/dist/public/schemas/entity.d.ts.map +1 -1
- package/dist/public/schemas/entity.js +21 -1
- package/dist/public/skills/kibi-commit-review/SKILL.md +8 -8
- package/dist/public/skills/kibi-commit-review/resources/staged-gate.md +6 -6
- package/dist/public/skills/kibi-decisions/SKILL.md +5 -2
- package/dist/public/skills/kibi-decisions/resources/decision-records.md +4 -1
- package/dist/public/skills/kibi-freshness/SKILL.md +2 -2
- package/dist/public/skills/kibi-traceability/SKILL.md +3 -2
- package/dist/public/skills/kibi-usage/SKILL.md +5 -3
- package/dist/public/skills/kibi-usage/resources/proof.md +105 -9
- package/dist/report/html-report.css +45 -10
- package/dist/report/html-report.d.ts.map +1 -1
- package/dist/report/html-report.js +74 -17
- package/dist/report/report-view.d.ts +1 -1
- package/dist/report/report-view.d.ts.map +1 -1
- package/dist/report/report-view.js +1 -0
- package/dist/schemas/entity.schema.json +199 -8
- package/dist/traceability/evidence-model.d.ts +13 -12
- package/dist/traceability/evidence-model.d.ts.map +1 -1
- package/dist/traceability/evidence-model.js +1 -1
- package/dist/traceability/staged-diagnostics.d.ts.map +1 -1
- package/dist/traceability/staged-diagnostics.js +24 -20
- package/dist/traceability/staged-impact-contract.d.ts +4 -4
- package/dist/traceability/staged-impact-contract.js +9 -9
- package/dist/traceability/staged-symbols-manifest.d.ts +11 -3
- package/dist/traceability/staged-symbols-manifest.d.ts.map +1 -1
- package/dist/traceability/staged-symbols-manifest.js +47 -140
- package/dist/types/entities.d.ts +1 -1
- package/dist/types/entities.d.ts.map +1 -1
- package/dist/utils/rule-registry.generated.d.ts +1 -1
- package/dist/utils/rule-registry.generated.js +1 -1
- package/dist/utils/schema-version.d.ts +1 -1
- package/dist/utils/schema-version.d.ts.map +1 -1
- package/dist/utils/schema-version.js +3 -1
- package/dist/utils/strict-modeling.d.ts +3 -2
- package/dist/utils/strict-modeling.d.ts.map +1 -1
- package/dist/utils/strict-modeling.js +37 -4
- package/dist/workspace-cwd.d.ts +15 -0
- package/dist/workspace-cwd.d.ts.map +1 -0
- package/dist/workspace-cwd.js +86 -0
- package/package.json +2 -2
- package/schema/validation.pl +3 -1
- package/src/public/operations/evidence-floor.ts +1 -1
- package/src/public/operations/proof-contract-claims.ts +1 -1
- package/src/public/operations/specs/modeling.ts +24 -5
- package/src/public/operations/specs/mutation-schemas.ts +14 -3
- package/src/public/proof-certificates.ts +296 -1
- package/src/public/proof-protocol-types.d.ts +54 -2
- package/src/public/proof-protocol.ts +396 -20
- package/src/public/proof-receipt.ts +8 -2
- package/src/public/proof-sources.ts +1656 -0
- package/src/public/schemas/entity.ts +32 -2
- package/src/public/skills/kibi-commit-review/SKILL.md +8 -8
- package/src/public/skills/kibi-commit-review/resources/staged-gate.md +6 -6
- package/src/public/skills/kibi-decisions/SKILL.md +5 -2
- package/src/public/skills/kibi-decisions/resources/decision-records.md +4 -1
- package/src/public/skills/kibi-freshness/SKILL.md +2 -2
- package/src/public/skills/kibi-traceability/SKILL.md +3 -2
- package/src/public/skills/kibi-usage/SKILL.md +5 -3
- package/src/public/skills/kibi-usage/resources/proof.md +105 -9
- package/src/schemas/entity.schema.json +199 -8
- package/dist/traceability/reviewed-coordinate-refresh.d.ts +0 -10
- package/dist/traceability/reviewed-coordinate-refresh.d.ts.map +0 -1
- package/dist/traceability/reviewed-coordinate-refresh.js +0 -49
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Kibi — repo-local, per-branch, queryable long-term memory for software projects
|
|
3
|
+
Copyright (C) 2026 Piotr Franczyk
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU Affero General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Project sources Kibi reads by itself for the `source` proof kind, and for
|
|
21
|
+
* source-backed rule domains, rule predicates and count value sources: a
|
|
22
|
+
* JSON or YAML file, the files a glob matches, an OpenAPI document, the
|
|
23
|
+
* references the code makes (environment variables, imports, calls, string
|
|
24
|
+
* arguments, thrown error codes, property values), a data schema (drizzle,
|
|
25
|
+
* Prisma or SQL migrations), a web app's route table (Hono, Express) and a
|
|
26
|
+
* validator object (zod and anything with safeParse).
|
|
27
|
+
*
|
|
28
|
+
* Every reader is exact or refuses: a member Kibi cannot read statically (a
|
|
29
|
+
* computed environment variable name, a dynamic import, an ALTER TABLE form
|
|
30
|
+
* it does not know, a router mounted on a pattern) fails the read with its
|
|
31
|
+
* location, so a set claim is never decided over a set Kibi only partly saw.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import fg from "fast-glob";
|
|
38
|
+
import { parse as parseYaml } from "yaml";
|
|
39
|
+
|
|
40
|
+
// implements REQ-kibi-source-proof-kinds
|
|
41
|
+
export const SOURCE_READERS = [
|
|
42
|
+
"json",
|
|
43
|
+
"yaml",
|
|
44
|
+
"glob",
|
|
45
|
+
"openapi",
|
|
46
|
+
"references",
|
|
47
|
+
"schema",
|
|
48
|
+
"routes",
|
|
49
|
+
"validator",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export type SourceReader = (typeof SOURCE_READERS)[number];
|
|
53
|
+
|
|
54
|
+
/** A source spec as a proof contract states it; see sourceSpecErrors. */
|
|
55
|
+
// implements REQ-kibi-source-proof-kinds
|
|
56
|
+
export type SourceSpec = Readonly<Record<string, unknown>> &
|
|
57
|
+
Readonly<{ reader: SourceReader }>;
|
|
58
|
+
|
|
59
|
+
/** A column of a table, or a field of a validator object. */
|
|
60
|
+
export type SourceColumn = {
|
|
61
|
+
name: string;
|
|
62
|
+
nullable: boolean;
|
|
63
|
+
optional: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** A table (schema reader) or object (validator reader). */
|
|
67
|
+
export type SourceEntity = {
|
|
68
|
+
name: string;
|
|
69
|
+
columns: SourceColumn[];
|
|
70
|
+
foreign_keys: string[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** One endpoint of a route table and the middleware that runs before it. */
|
|
74
|
+
export type SourceRoute = {
|
|
75
|
+
method: string;
|
|
76
|
+
path: string;
|
|
77
|
+
middleware: string[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What Kibi read: the selected members (sorted, unique), and for schema,
|
|
82
|
+
* validator and routes readers the structure the field and middleware
|
|
83
|
+
* obligations are decided on.
|
|
84
|
+
*/
|
|
85
|
+
// implements REQ-kibi-source-proof-kinds
|
|
86
|
+
export type SourceModel = {
|
|
87
|
+
members: string[];
|
|
88
|
+
entities?: SourceEntity[];
|
|
89
|
+
routes?: SourceRoute[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type SourceRead =
|
|
93
|
+
| { ok: true; model: SourceModel }
|
|
94
|
+
| { ok: false; reason: string };
|
|
95
|
+
|
|
96
|
+
/** A place in a file Kibi could not read statically. */
|
|
97
|
+
export type UnresolvedReference = Readonly<{
|
|
98
|
+
file: string;
|
|
99
|
+
line: number;
|
|
100
|
+
text: string;
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
const SELECTS: Readonly<Partial<Record<SourceReader, readonly string[]>>> = {
|
|
104
|
+
json: ["keys", "values", "items"],
|
|
105
|
+
yaml: ["keys", "values", "items"],
|
|
106
|
+
openapi: [
|
|
107
|
+
"operations",
|
|
108
|
+
"unsecured_operations",
|
|
109
|
+
"security_schemes",
|
|
110
|
+
"responses",
|
|
111
|
+
],
|
|
112
|
+
schema: [
|
|
113
|
+
"tables",
|
|
114
|
+
"columns",
|
|
115
|
+
"required_columns",
|
|
116
|
+
"nullable_columns",
|
|
117
|
+
"foreign_keys",
|
|
118
|
+
],
|
|
119
|
+
routes: ["routes", "middleware"],
|
|
120
|
+
validator: ["fields", "required_fields", "optional_fields"],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const REFERENCE_EXTRACTS = [
|
|
124
|
+
"env",
|
|
125
|
+
"imports",
|
|
126
|
+
"calls",
|
|
127
|
+
"string_args",
|
|
128
|
+
"thrown",
|
|
129
|
+
"property",
|
|
130
|
+
] as const;
|
|
131
|
+
|
|
132
|
+
const KEYS: Readonly<Record<SourceReader, readonly string[]>> = {
|
|
133
|
+
json: ["reader", "file", "path", "select"],
|
|
134
|
+
yaml: ["reader", "file", "path", "select"],
|
|
135
|
+
glob: ["reader", "pattern", "capture", "ignore"],
|
|
136
|
+
openapi: ["reader", "file", "select"],
|
|
137
|
+
references: ["reader", "files", "extract", "callee", "key", "ignore"],
|
|
138
|
+
schema: ["reader", "format", "file", "files", "exports", "table", "select"],
|
|
139
|
+
routes: [
|
|
140
|
+
"reader",
|
|
141
|
+
"file",
|
|
142
|
+
"export",
|
|
143
|
+
"framework",
|
|
144
|
+
"path_prefix",
|
|
145
|
+
"names_from",
|
|
146
|
+
"select",
|
|
147
|
+
],
|
|
148
|
+
validator: ["reader", "file", "export", "select"],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
152
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
153
|
+
? (value as Record<string, unknown>)
|
|
154
|
+
: null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function nonEmpty(value: unknown): value is string {
|
|
158
|
+
return typeof value === "string" && value.trim() !== "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function stringList(value: unknown): value is string[] {
|
|
162
|
+
return Array.isArray(value) && value.length > 0 && value.every(nonEmpty);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function relativeSafe(value: string): boolean {
|
|
166
|
+
return !path.isAbsolute(value) && !value.split(/[\\/]/).includes("..");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Why a source spec is not one Kibi can read, or [] when it is. `label`
|
|
171
|
+
* names the field in the messages (proof_contract.source, ...).
|
|
172
|
+
*/
|
|
173
|
+
// implements REQ-kibi-source-proof-kinds
|
|
174
|
+
export function sourceSpecErrors(value: unknown, label: string): string[] {
|
|
175
|
+
const row = record(value);
|
|
176
|
+
if (!row || !(SOURCE_READERS as readonly unknown[]).includes(row.reader))
|
|
177
|
+
return [
|
|
178
|
+
`${label} must be an object whose reader is one of: ${SOURCE_READERS.join(", ")}`,
|
|
179
|
+
];
|
|
180
|
+
const reader = row.reader as SourceReader;
|
|
181
|
+
const errors: string[] = [];
|
|
182
|
+
const unknown = Object.keys(row).filter((key) => !KEYS[reader].includes(key));
|
|
183
|
+
if (unknown.length > 0)
|
|
184
|
+
errors.push(
|
|
185
|
+
`${label} (reader ${reader}) does not take ${unknown.join(", ")}; it takes ${KEYS[reader].join(", ")}`,
|
|
186
|
+
);
|
|
187
|
+
const file = (key: string, required: boolean) => {
|
|
188
|
+
const raw = row[key];
|
|
189
|
+
if (raw === undefined) {
|
|
190
|
+
if (required)
|
|
191
|
+
errors.push(`${label}.${key} is required for reader ${reader}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!nonEmpty(raw) || !relativeSafe(raw))
|
|
195
|
+
errors.push(
|
|
196
|
+
`${label}.${key} must be a repository-relative path inside the workspace`,
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
const globs = (key: string, required: boolean) => {
|
|
200
|
+
const raw = row[key];
|
|
201
|
+
if (raw === undefined) {
|
|
202
|
+
if (required)
|
|
203
|
+
errors.push(`${label}.${key} is required for reader ${reader}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const list = typeof raw === "string" ? [raw] : raw;
|
|
207
|
+
if (!stringList(list) || !list.every(relativeSafe))
|
|
208
|
+
errors.push(
|
|
209
|
+
`${label}.${key} must be a repository-relative glob or a list of them`,
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
const select = SELECTS[reader];
|
|
213
|
+
if (
|
|
214
|
+
row.select !== undefined &&
|
|
215
|
+
(select === undefined || !select.includes(String(row.select)))
|
|
216
|
+
)
|
|
217
|
+
errors.push(
|
|
218
|
+
select === undefined
|
|
219
|
+
? `${label}.select does not apply to reader ${reader}`
|
|
220
|
+
: `${label}.select must be one of: ${select.join(", ")}`,
|
|
221
|
+
);
|
|
222
|
+
switch (reader) {
|
|
223
|
+
case "json":
|
|
224
|
+
case "yaml":
|
|
225
|
+
file("file", true);
|
|
226
|
+
if (row.path !== undefined && typeof row.path !== "string")
|
|
227
|
+
errors.push(`${label}.path must be a dotted path or a JSON pointer`);
|
|
228
|
+
break;
|
|
229
|
+
case "glob":
|
|
230
|
+
globs("pattern", true);
|
|
231
|
+
globs("ignore", false);
|
|
232
|
+
if (row.capture !== undefined && typeof row.capture !== "boolean")
|
|
233
|
+
errors.push(`${label}.capture must be true or false`);
|
|
234
|
+
break;
|
|
235
|
+
case "openapi":
|
|
236
|
+
file("file", true);
|
|
237
|
+
break;
|
|
238
|
+
case "references":
|
|
239
|
+
globs("files", true);
|
|
240
|
+
globs("ignore", false);
|
|
241
|
+
if (!(REFERENCE_EXTRACTS as readonly unknown[]).includes(row.extract))
|
|
242
|
+
errors.push(
|
|
243
|
+
`${label}.extract must be one of: ${REFERENCE_EXTRACTS.join(", ")}`,
|
|
244
|
+
);
|
|
245
|
+
if (row.extract === "string_args" && !nonEmpty(row.callee))
|
|
246
|
+
errors.push(
|
|
247
|
+
`${label}.callee is required for extract string_args: name the function whose first argument is read`,
|
|
248
|
+
);
|
|
249
|
+
if (row.callee !== undefined && !nonEmpty(row.callee))
|
|
250
|
+
errors.push(`${label}.callee must be a function name`);
|
|
251
|
+
if (row.extract === "property" && !nonEmpty(row.key))
|
|
252
|
+
errors.push(
|
|
253
|
+
`${label}.key is required for extract property: name the property whose string values are read`,
|
|
254
|
+
);
|
|
255
|
+
if (row.key !== undefined && row.extract !== "property")
|
|
256
|
+
errors.push(`${label}.key applies only to extract property`);
|
|
257
|
+
break;
|
|
258
|
+
case "schema":
|
|
259
|
+
if (!["drizzle", "prisma", "sql"].includes(String(row.format)))
|
|
260
|
+
errors.push(`${label}.format must be drizzle, prisma or sql`);
|
|
261
|
+
if (row.format === "sql") {
|
|
262
|
+
globs("files", true);
|
|
263
|
+
if (row.file !== undefined)
|
|
264
|
+
errors.push(`${label}.file does not apply to format sql; use files`);
|
|
265
|
+
} else {
|
|
266
|
+
file("file", true);
|
|
267
|
+
if (row.files !== undefined)
|
|
268
|
+
errors.push(`${label}.files applies only to format sql`);
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
row.exports !== undefined &&
|
|
272
|
+
(!stringList(row.exports) || row.format !== "drizzle")
|
|
273
|
+
)
|
|
274
|
+
errors.push(
|
|
275
|
+
`${label}.exports must be a list of export names, for format drizzle only`,
|
|
276
|
+
);
|
|
277
|
+
if (row.table !== undefined && !nonEmpty(row.table))
|
|
278
|
+
errors.push(
|
|
279
|
+
`${label}.table must be a table name or a glob of table names`,
|
|
280
|
+
);
|
|
281
|
+
break;
|
|
282
|
+
case "routes":
|
|
283
|
+
file("file", true);
|
|
284
|
+
if (!nonEmpty(row.export))
|
|
285
|
+
errors.push(
|
|
286
|
+
`${label}.export is required for reader routes: name the app (or a function returning it)`,
|
|
287
|
+
);
|
|
288
|
+
if (
|
|
289
|
+
row.framework !== undefined &&
|
|
290
|
+
!["hono", "express"].includes(String(row.framework))
|
|
291
|
+
)
|
|
292
|
+
errors.push(`${label}.framework must be hono or express`);
|
|
293
|
+
if (
|
|
294
|
+
row.path_prefix !== undefined &&
|
|
295
|
+
(!nonEmpty(row.path_prefix) || !String(row.path_prefix).startsWith("/"))
|
|
296
|
+
)
|
|
297
|
+
errors.push(`${label}.path_prefix must be a path starting with /`);
|
|
298
|
+
if (
|
|
299
|
+
row.names_from !== undefined &&
|
|
300
|
+
(!stringList(row.names_from) || !row.names_from.every(relativeSafe))
|
|
301
|
+
)
|
|
302
|
+
errors.push(
|
|
303
|
+
`${label}.names_from must list repository-relative module files whose exports name middleware`,
|
|
304
|
+
);
|
|
305
|
+
break;
|
|
306
|
+
case "validator":
|
|
307
|
+
file("file", true);
|
|
308
|
+
if (!nonEmpty(row.export))
|
|
309
|
+
errors.push(
|
|
310
|
+
`${label}.export is required for reader validator: name the object schema`,
|
|
311
|
+
);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
return errors;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The spec with only the keys its reader takes, for contract hashing. */
|
|
318
|
+
// implements REQ-kibi-source-proof-kinds
|
|
319
|
+
export function normalizeSourceSpec(value: unknown): SourceSpec | undefined {
|
|
320
|
+
const row = record(value);
|
|
321
|
+
if (!row || !(SOURCE_READERS as readonly unknown[]).includes(row.reader))
|
|
322
|
+
return undefined;
|
|
323
|
+
const reader = row.reader as SourceReader;
|
|
324
|
+
const out: Record<string, unknown> = {};
|
|
325
|
+
for (const key of KEYS[reader])
|
|
326
|
+
if (row[key] !== undefined) out[key] = row[key];
|
|
327
|
+
return out as SourceSpec;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function globList(value: unknown): string[] {
|
|
331
|
+
if (typeof value === "string") return [value];
|
|
332
|
+
return Array.isArray(value) ? value.filter(nonEmpty) : [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**", "**/.kb/**"];
|
|
336
|
+
|
|
337
|
+
/** Workspace-relative files a glob list matches, sorted. */
|
|
338
|
+
// implements REQ-kibi-source-proof-kinds
|
|
339
|
+
export function matchWorkspaceFiles(
|
|
340
|
+
workspaceRoot: string,
|
|
341
|
+
patterns: readonly string[],
|
|
342
|
+
ignore: readonly string[] = [],
|
|
343
|
+
): string[] {
|
|
344
|
+
return fg
|
|
345
|
+
.sync([...patterns], {
|
|
346
|
+
cwd: workspaceRoot,
|
|
347
|
+
ignore: [...DEFAULT_IGNORE, ...ignore],
|
|
348
|
+
dot: true,
|
|
349
|
+
onlyFiles: true,
|
|
350
|
+
followSymbolicLinks: false,
|
|
351
|
+
})
|
|
352
|
+
.map((file) => file.split(path.sep).join("/"))
|
|
353
|
+
.sort();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readText(workspaceRoot: string, relative: string): string | null {
|
|
357
|
+
const absolute = path.resolve(workspaceRoot, relative);
|
|
358
|
+
const root = path.resolve(workspaceRoot);
|
|
359
|
+
if (absolute !== root && !absolute.startsWith(root + path.sep)) return null;
|
|
360
|
+
if (!existsSync(absolute) || !statSync(absolute).isFile()) return null;
|
|
361
|
+
return readFileSync(absolute, "utf8");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function sha256(text: string): string {
|
|
365
|
+
return createHash("sha256").update(text).digest("hex");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const MISSING_FILE_HASH = "missing";
|
|
369
|
+
|
|
370
|
+
export type SourceDependency = Readonly<{ id: string; hash: string }>;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* What a read of `spec` depends on, so a receipt goes stale when any of it
|
|
374
|
+
* changes: each file the reader parses (its contents), the set of files a
|
|
375
|
+
* glob matches (their paths), and for the readers that evaluate a module
|
|
376
|
+
* (drizzle schemas, route tables, validators) every workspace module that
|
|
377
|
+
* module imports through relative or tsconfig path imports.
|
|
378
|
+
*/
|
|
379
|
+
// implements REQ-kibi-source-proof-kinds
|
|
380
|
+
export function sourceDependencies(
|
|
381
|
+
workspaceRoot: string,
|
|
382
|
+
spec: SourceSpec,
|
|
383
|
+
): SourceDependency[] {
|
|
384
|
+
const files = new Set<string>();
|
|
385
|
+
const out: SourceDependency[] = [];
|
|
386
|
+
const addFile = (relative: string) =>
|
|
387
|
+
files.add(relative.split(path.sep).join("/"));
|
|
388
|
+
switch (spec.reader) {
|
|
389
|
+
case "json":
|
|
390
|
+
case "yaml":
|
|
391
|
+
case "openapi":
|
|
392
|
+
case "validator":
|
|
393
|
+
addFile(String(spec.file));
|
|
394
|
+
break;
|
|
395
|
+
case "glob": {
|
|
396
|
+
const matched = matchWorkspaceFiles(
|
|
397
|
+
workspaceRoot,
|
|
398
|
+
globList(spec.pattern),
|
|
399
|
+
globList(spec.ignore),
|
|
400
|
+
);
|
|
401
|
+
out.push({
|
|
402
|
+
id: `source-glob:${globList(spec.pattern).join(",")}`,
|
|
403
|
+
hash: sha256(matched.join("\n")),
|
|
404
|
+
});
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case "references":
|
|
408
|
+
for (const file of matchWorkspaceFiles(
|
|
409
|
+
workspaceRoot,
|
|
410
|
+
globList(spec.files),
|
|
411
|
+
globList(spec.ignore),
|
|
412
|
+
))
|
|
413
|
+
addFile(file);
|
|
414
|
+
out.push({
|
|
415
|
+
id: `source-glob:${globList(spec.files).join(",")}`,
|
|
416
|
+
hash: sha256(
|
|
417
|
+
matchWorkspaceFiles(
|
|
418
|
+
workspaceRoot,
|
|
419
|
+
globList(spec.files),
|
|
420
|
+
globList(spec.ignore),
|
|
421
|
+
).join("\n"),
|
|
422
|
+
),
|
|
423
|
+
});
|
|
424
|
+
break;
|
|
425
|
+
case "schema":
|
|
426
|
+
if (spec.format === "sql") {
|
|
427
|
+
const matched = matchWorkspaceFiles(
|
|
428
|
+
workspaceRoot,
|
|
429
|
+
globList(spec.files),
|
|
430
|
+
);
|
|
431
|
+
for (const file of matched) addFile(file);
|
|
432
|
+
out.push({
|
|
433
|
+
id: `source-glob:${globList(spec.files).join(",")}`,
|
|
434
|
+
hash: sha256(matched.join("\n")),
|
|
435
|
+
});
|
|
436
|
+
} else addFile(String(spec.file));
|
|
437
|
+
break;
|
|
438
|
+
case "routes":
|
|
439
|
+
addFile(String(spec.file));
|
|
440
|
+
for (const file of globList(spec.names_from)) addFile(file);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (
|
|
444
|
+
spec.reader === "routes" ||
|
|
445
|
+
spec.reader === "validator" ||
|
|
446
|
+
(spec.reader === "schema" && spec.format === "drizzle")
|
|
447
|
+
)
|
|
448
|
+
for (const file of [...files])
|
|
449
|
+
for (const imported of importClosure(workspaceRoot, file))
|
|
450
|
+
addFile(imported);
|
|
451
|
+
for (const file of [...files].sort()) {
|
|
452
|
+
const text = readText(workspaceRoot, file);
|
|
453
|
+
out.push({
|
|
454
|
+
id: `source-file:${file}`,
|
|
455
|
+
hash: text === null ? MISSING_FILE_HASH : sha256(text),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const IMPORT_SPECIFIER =
|
|
462
|
+
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*|\bexport\s+\*\s+from\s*)["']([^"']+)["']/g;
|
|
463
|
+
|
|
464
|
+
const MODULE_SUFFIXES = [
|
|
465
|
+
"",
|
|
466
|
+
".ts",
|
|
467
|
+
".tsx",
|
|
468
|
+
".mts",
|
|
469
|
+
".cts",
|
|
470
|
+
".js",
|
|
471
|
+
".jsx",
|
|
472
|
+
".mjs",
|
|
473
|
+
".cjs",
|
|
474
|
+
"/index.ts",
|
|
475
|
+
"/index.tsx",
|
|
476
|
+
"/index.js",
|
|
477
|
+
"/index.mjs",
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
type PathAliases = { base: string; paths: [string, string[]][] } | null;
|
|
481
|
+
|
|
482
|
+
function readJsonc(file: string): Record<string, unknown> | null {
|
|
483
|
+
try {
|
|
484
|
+
const text = readFileSync(file, "utf8")
|
|
485
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
486
|
+
.replace(/(^|[^:"])\/\/.*$/gm, "$1")
|
|
487
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
488
|
+
return record(JSON.parse(text));
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The paths a tsconfig declares, following `extends` (a relative file, or a
|
|
495
|
+
// list of them) the way tsc does: the nearest declaration of paths wins, and
|
|
496
|
+
// its targets resolve against baseUrl, else against the file declaring them.
|
|
497
|
+
function tsconfigPaths(file: string, depth = 0): PathAliases {
|
|
498
|
+
const config = readJsonc(file);
|
|
499
|
+
if (config === null || depth > 10) return null;
|
|
500
|
+
const dir = path.dirname(file);
|
|
501
|
+
const options = record(config.compilerOptions);
|
|
502
|
+
const paths = record(options?.paths);
|
|
503
|
+
if (paths)
|
|
504
|
+
return {
|
|
505
|
+
base: path.resolve(
|
|
506
|
+
dir,
|
|
507
|
+
typeof options?.baseUrl === "string" ? options.baseUrl : ".",
|
|
508
|
+
),
|
|
509
|
+
paths: Object.entries(paths).map(([key, targets]) => [
|
|
510
|
+
key,
|
|
511
|
+
Array.isArray(targets) ? targets.map(String) : [],
|
|
512
|
+
]),
|
|
513
|
+
};
|
|
514
|
+
const parents = (
|
|
515
|
+
Array.isArray(config.extends) ? config.extends : [config.extends]
|
|
516
|
+
).filter((parent): parent is string => typeof parent === "string");
|
|
517
|
+
for (const parent of parents.reverse()) {
|
|
518
|
+
if (!parent.startsWith(".")) continue;
|
|
519
|
+
const target = path.resolve(dir, parent);
|
|
520
|
+
const candidate = existsSync(target) ? target : `${target}.json`;
|
|
521
|
+
if (!existsSync(candidate)) continue;
|
|
522
|
+
const inherited = tsconfigPaths(candidate, depth + 1);
|
|
523
|
+
if (inherited === null) continue;
|
|
524
|
+
// A baseUrl in the child re-roots inherited paths.
|
|
525
|
+
return typeof options?.baseUrl === "string"
|
|
526
|
+
? { ...inherited, base: path.resolve(dir, options.baseUrl) }
|
|
527
|
+
: inherited;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function tsconfigAliases(workspaceRoot: string, fromFile: string): PathAliases {
|
|
533
|
+
const root = path.resolve(workspaceRoot);
|
|
534
|
+
let dir = path.dirname(path.resolve(root, fromFile));
|
|
535
|
+
for (;;) {
|
|
536
|
+
const config = path.join(dir, "tsconfig.json");
|
|
537
|
+
if (existsSync(config)) return tsconfigPaths(config);
|
|
538
|
+
if (dir === root || path.dirname(dir) === dir) return null;
|
|
539
|
+
dir = path.dirname(dir);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// A bare specifier that names a workspace package: node_modules/<name> is a
|
|
544
|
+
// link into the repository (pnpm, yarn and npm workspaces), so its source is
|
|
545
|
+
// part of the closure. A package installed from a registry is not.
|
|
546
|
+
function workspacePackageBases(
|
|
547
|
+
root: string,
|
|
548
|
+
fromFile: string,
|
|
549
|
+
specifier: string,
|
|
550
|
+
): string[] {
|
|
551
|
+
const parts = specifier.split("/");
|
|
552
|
+
const nameLength = specifier.startsWith("@") ? 2 : 1;
|
|
553
|
+
if (parts.length < nameLength || parts.some((part) => part === "")) return [];
|
|
554
|
+
const name = parts.slice(0, nameLength).join("/");
|
|
555
|
+
const subpath = parts.slice(nameLength).join("/");
|
|
556
|
+
let dir = path.dirname(path.resolve(root, fromFile));
|
|
557
|
+
for (;;) {
|
|
558
|
+
const link = path.join(dir, "node_modules", name);
|
|
559
|
+
if (existsSync(link)) {
|
|
560
|
+
let real: string;
|
|
561
|
+
try {
|
|
562
|
+
real = realpathSync(link);
|
|
563
|
+
} catch {
|
|
564
|
+
return [];
|
|
565
|
+
}
|
|
566
|
+
const relative = path.relative(root, real);
|
|
567
|
+
if (
|
|
568
|
+
relative.startsWith("..") ||
|
|
569
|
+
path.isAbsolute(relative) ||
|
|
570
|
+
relative.split(path.sep).includes("node_modules")
|
|
571
|
+
)
|
|
572
|
+
return [];
|
|
573
|
+
if (subpath !== "")
|
|
574
|
+
return [path.join(real, subpath), path.join(real, "src", subpath)];
|
|
575
|
+
const manifest = readJsonc(path.join(real, "package.json"));
|
|
576
|
+
const entries: string[] = [];
|
|
577
|
+
const collect = (value: unknown): void => {
|
|
578
|
+
if (typeof value === "string") entries.push(value);
|
|
579
|
+
else if (record(value))
|
|
580
|
+
for (const key of [
|
|
581
|
+
"source",
|
|
582
|
+
"import",
|
|
583
|
+
"module",
|
|
584
|
+
"default",
|
|
585
|
+
"require",
|
|
586
|
+
"node",
|
|
587
|
+
])
|
|
588
|
+
collect((value as Record<string, unknown>)[key]);
|
|
589
|
+
};
|
|
590
|
+
const exported = record(manifest?.exports)?.["."] ?? manifest?.exports;
|
|
591
|
+
collect(exported);
|
|
592
|
+
for (const key of ["source", "module", "main"]) collect(manifest?.[key]);
|
|
593
|
+
return [
|
|
594
|
+
...entries
|
|
595
|
+
.filter((entry) => !entry.endsWith(".d.ts"))
|
|
596
|
+
.map((entry) => path.resolve(real, entry)),
|
|
597
|
+
path.join(real, "src", "index"),
|
|
598
|
+
path.join(real, "index"),
|
|
599
|
+
];
|
|
600
|
+
}
|
|
601
|
+
if (path.resolve(dir) === root || path.dirname(dir) === dir) return [];
|
|
602
|
+
dir = path.dirname(dir);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function resolveModule(
|
|
607
|
+
workspaceRoot: string,
|
|
608
|
+
fromFile: string,
|
|
609
|
+
specifier: string,
|
|
610
|
+
aliases: PathAliases,
|
|
611
|
+
): string | null {
|
|
612
|
+
const root = path.resolve(workspaceRoot);
|
|
613
|
+
const bases: string[] = [];
|
|
614
|
+
if (specifier.startsWith("."))
|
|
615
|
+
bases.push(path.resolve(root, path.dirname(fromFile), specifier));
|
|
616
|
+
else if (aliases !== null)
|
|
617
|
+
for (const [key, targets] of aliases.paths) {
|
|
618
|
+
const star = key.indexOf("*");
|
|
619
|
+
const prefix = star === -1 ? key : key.slice(0, star);
|
|
620
|
+
const suffix = star === -1 ? "" : key.slice(star + 1);
|
|
621
|
+
if (
|
|
622
|
+
star === -1
|
|
623
|
+
? specifier !== key
|
|
624
|
+
: !specifier.startsWith(prefix) || !specifier.endsWith(suffix)
|
|
625
|
+
)
|
|
626
|
+
continue;
|
|
627
|
+
const middle =
|
|
628
|
+
star === -1
|
|
629
|
+
? ""
|
|
630
|
+
: specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
631
|
+
for (const target of targets)
|
|
632
|
+
bases.push(path.resolve(aliases.base, target.replace("*", middle)));
|
|
633
|
+
}
|
|
634
|
+
if (
|
|
635
|
+
bases.length === 0 &&
|
|
636
|
+
!specifier.startsWith(".") &&
|
|
637
|
+
!path.isAbsolute(specifier)
|
|
638
|
+
)
|
|
639
|
+
bases.push(...workspacePackageBases(root, fromFile, specifier));
|
|
640
|
+
for (const base of bases) {
|
|
641
|
+
const stripped = base.replace(/\.[cm]?js$/, "");
|
|
642
|
+
for (const candidate of [
|
|
643
|
+
base,
|
|
644
|
+
...MODULE_SUFFIXES.map((suffix) => stripped + suffix),
|
|
645
|
+
]) {
|
|
646
|
+
if (!existsSync(candidate) || !statSync(candidate).isFile()) continue;
|
|
647
|
+
let real = candidate;
|
|
648
|
+
try {
|
|
649
|
+
real = realpathSync(candidate);
|
|
650
|
+
} catch {
|
|
651
|
+
// A dangling link resolves to nothing.
|
|
652
|
+
}
|
|
653
|
+
const relative = path.relative(root, real);
|
|
654
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
655
|
+
if (relative.split(path.sep).includes("node_modules")) return null;
|
|
656
|
+
return relative.split(path.sep).join("/");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The workspace modules `file` imports, directly or not, through relative
|
|
664
|
+
* imports, the nearest tsconfig's `paths` aliases (following `extends`) and
|
|
665
|
+
* workspace packages linked into node_modules (packages installed from a
|
|
666
|
+
* registry are left out: a lockfile change stales nothing here).
|
|
667
|
+
*/
|
|
668
|
+
// implements REQ-kibi-source-proof-kinds
|
|
669
|
+
export function importClosure(workspaceRoot: string, file: string): string[] {
|
|
670
|
+
const seen = new Set<string>();
|
|
671
|
+
const queue = [file.split(path.sep).join("/")];
|
|
672
|
+
while (queue.length > 0 && seen.size < 5000) {
|
|
673
|
+
const current = queue.pop() as string;
|
|
674
|
+
if (seen.has(current)) continue;
|
|
675
|
+
seen.add(current);
|
|
676
|
+
const text = readText(workspaceRoot, current);
|
|
677
|
+
if (text === null) continue;
|
|
678
|
+
const aliases = tsconfigAliases(workspaceRoot, current);
|
|
679
|
+
for (const match of text.matchAll(IMPORT_SPECIFIER)) {
|
|
680
|
+
const resolved = resolveModule(
|
|
681
|
+
workspaceRoot,
|
|
682
|
+
current,
|
|
683
|
+
match[1] ?? "",
|
|
684
|
+
aliases,
|
|
685
|
+
);
|
|
686
|
+
if (resolved !== null && !seen.has(resolved)) queue.push(resolved);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
seen.delete(file.split(path.sep).join("/"));
|
|
690
|
+
return [...seen].sort();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function sortedUnique(values: Iterable<string>): string[] {
|
|
694
|
+
return [...new Set(values)].sort();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function scalarText(value: unknown): string | null {
|
|
698
|
+
if (typeof value === "string") return value;
|
|
699
|
+
if (
|
|
700
|
+
typeof value === "number" ||
|
|
701
|
+
typeof value === "boolean" ||
|
|
702
|
+
typeof value === "bigint"
|
|
703
|
+
)
|
|
704
|
+
return String(value);
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function navigate(
|
|
709
|
+
document: unknown,
|
|
710
|
+
rawPath: string,
|
|
711
|
+
): { ok: true; value: unknown } | { ok: false; reason: string } {
|
|
712
|
+
const trimmed = rawPath.trim();
|
|
713
|
+
if (trimmed === "" || trimmed === "/") return { ok: true, value: document };
|
|
714
|
+
const parts = trimmed.startsWith("/")
|
|
715
|
+
? trimmed
|
|
716
|
+
.slice(1)
|
|
717
|
+
.split("/")
|
|
718
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
719
|
+
: trimmed.split(".");
|
|
720
|
+
let value: unknown = document;
|
|
721
|
+
for (const part of parts) {
|
|
722
|
+
if (Array.isArray(value) && /^\d+$/.test(part)) value = value[Number(part)];
|
|
723
|
+
else if (
|
|
724
|
+
record(value) !== null &&
|
|
725
|
+
part in (value as Record<string, unknown>)
|
|
726
|
+
)
|
|
727
|
+
value = (value as Record<string, unknown>)[part];
|
|
728
|
+
else
|
|
729
|
+
return {
|
|
730
|
+
ok: false,
|
|
731
|
+
reason: `the document has nothing at ${rawPath} (no ${part})`,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return { ok: true, value };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function parseDocument(
|
|
738
|
+
workspaceRoot: string,
|
|
739
|
+
file: string,
|
|
740
|
+
yaml: boolean,
|
|
741
|
+
): { ok: true; value: unknown } | { ok: false; reason: string } {
|
|
742
|
+
const text = readText(workspaceRoot, file);
|
|
743
|
+
if (text === null)
|
|
744
|
+
return { ok: false, reason: `${file} does not exist in the workspace` };
|
|
745
|
+
try {
|
|
746
|
+
return {
|
|
747
|
+
ok: true,
|
|
748
|
+
value:
|
|
749
|
+
yaml || /\.ya?ml$/i.test(file) ? parseYaml(text) : JSON.parse(text),
|
|
750
|
+
};
|
|
751
|
+
} catch (error) {
|
|
752
|
+
return {
|
|
753
|
+
ok: false,
|
|
754
|
+
reason: `Kibi could not parse ${file}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function readDocumentMembers(
|
|
760
|
+
workspaceRoot: string,
|
|
761
|
+
spec: SourceSpec,
|
|
762
|
+
): SourceRead {
|
|
763
|
+
const parsed = parseDocument(
|
|
764
|
+
workspaceRoot,
|
|
765
|
+
String(spec.file),
|
|
766
|
+
spec.reader === "yaml",
|
|
767
|
+
);
|
|
768
|
+
if (!parsed.ok) return parsed;
|
|
769
|
+
const at = navigate(
|
|
770
|
+
parsed.value,
|
|
771
|
+
typeof spec.path === "string" ? spec.path : "",
|
|
772
|
+
);
|
|
773
|
+
if (!at.ok) return at;
|
|
774
|
+
const value = at.value;
|
|
775
|
+
const select = typeof spec.select === "string" ? spec.select : undefined;
|
|
776
|
+
let raw: unknown[];
|
|
777
|
+
if (Array.isArray(value)) {
|
|
778
|
+
if (select === "keys")
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
reason: `${spec.path ?? "the document"} is a list, which has no keys (use select items)`,
|
|
782
|
+
};
|
|
783
|
+
raw = value;
|
|
784
|
+
} else if (record(value) !== null) {
|
|
785
|
+
const object = value as Record<string, unknown>;
|
|
786
|
+
if (select === "items")
|
|
787
|
+
return {
|
|
788
|
+
ok: false,
|
|
789
|
+
reason: `${spec.path ?? "the document"} is an object, not a list (use select keys or values)`,
|
|
790
|
+
};
|
|
791
|
+
raw = select === "values" ? Object.values(object) : Object.keys(object);
|
|
792
|
+
} else if (scalarText(value) !== null) raw = [value];
|
|
793
|
+
else return { ok: false, reason: `${spec.path ?? "the document"} is empty` };
|
|
794
|
+
const members: string[] = [];
|
|
795
|
+
for (const item of raw) {
|
|
796
|
+
const text = scalarText(item);
|
|
797
|
+
if (text === null)
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
reason: `${spec.path ?? "the document"} holds a value that is not a string, number or boolean, so it cannot be a member (select keys, or point path at the list)`,
|
|
801
|
+
};
|
|
802
|
+
members.push(text);
|
|
803
|
+
}
|
|
804
|
+
return { ok: true, model: { members: sortedUnique(members) } };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function globCaptureRegex(pattern: string): RegExp {
|
|
808
|
+
let out = "";
|
|
809
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
810
|
+
const char = pattern[index] as string;
|
|
811
|
+
if (char === "*" && pattern[index + 1] === "*") {
|
|
812
|
+
index++;
|
|
813
|
+
// "**/" is any number of directories, captured without the slash.
|
|
814
|
+
if (pattern[index + 1] === "/") {
|
|
815
|
+
out += "(?:(.*)/)?";
|
|
816
|
+
index++;
|
|
817
|
+
} else out += "(.*)";
|
|
818
|
+
} else if (char === "*") out += "([^/]*)";
|
|
819
|
+
else if (char === "?") out += "([^/])";
|
|
820
|
+
else out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
821
|
+
}
|
|
822
|
+
return new RegExp(`^${out}$`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function readGlob(workspaceRoot: string, spec: SourceSpec): SourceRead {
|
|
826
|
+
const patterns = globList(spec.pattern);
|
|
827
|
+
const matched = matchWorkspaceFiles(
|
|
828
|
+
workspaceRoot,
|
|
829
|
+
patterns,
|
|
830
|
+
globList(spec.ignore),
|
|
831
|
+
);
|
|
832
|
+
// No match proves nothing: a mistyped pattern would pass every subset_of
|
|
833
|
+
// and excludes fact.
|
|
834
|
+
if (matched.length === 0)
|
|
835
|
+
return { ok: false, reason: `${patterns.join(", ")} matches no file` };
|
|
836
|
+
if (spec.capture !== true) return { ok: true, model: { members: matched } };
|
|
837
|
+
const members: string[] = [];
|
|
838
|
+
for (const file of matched) {
|
|
839
|
+
const match = patterns
|
|
840
|
+
.map((pattern) => file.match(globCaptureRegex(pattern)))
|
|
841
|
+
.find((result) => result !== null);
|
|
842
|
+
if (!match)
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
reason: `capture reads only *, ** and ? in the pattern; ${file} matched a pattern Kibi cannot capture from (braces or classes)`,
|
|
846
|
+
};
|
|
847
|
+
members.push(
|
|
848
|
+
match
|
|
849
|
+
.slice(1)
|
|
850
|
+
.filter((part): part is string => part !== undefined && part !== "")
|
|
851
|
+
.join("/"),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
return { ok: true, model: { members: sortedUnique(members) } };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const HTTP_METHODS = [
|
|
858
|
+
"get",
|
|
859
|
+
"put",
|
|
860
|
+
"post",
|
|
861
|
+
"delete",
|
|
862
|
+
"options",
|
|
863
|
+
"head",
|
|
864
|
+
"patch",
|
|
865
|
+
"trace",
|
|
866
|
+
];
|
|
867
|
+
|
|
868
|
+
function readOpenApi(workspaceRoot: string, spec: SourceSpec): SourceRead {
|
|
869
|
+
const parsed = parseDocument(workspaceRoot, String(spec.file), false);
|
|
870
|
+
if (!parsed.ok) return parsed;
|
|
871
|
+
const document = record(parsed.value);
|
|
872
|
+
if (
|
|
873
|
+
!document ||
|
|
874
|
+
(document.openapi === undefined && document.swagger === undefined)
|
|
875
|
+
)
|
|
876
|
+
return {
|
|
877
|
+
ok: false,
|
|
878
|
+
reason: `${spec.file} is not an OpenAPI or Swagger document`,
|
|
879
|
+
};
|
|
880
|
+
const select = typeof spec.select === "string" ? spec.select : "operations";
|
|
881
|
+
if (select === "security_schemes") {
|
|
882
|
+
const schemes =
|
|
883
|
+
record(record(document.components)?.securitySchemes) ??
|
|
884
|
+
record(document.securityDefinitions) ??
|
|
885
|
+
{};
|
|
886
|
+
return { ok: true, model: { members: sortedUnique(Object.keys(schemes)) } };
|
|
887
|
+
}
|
|
888
|
+
const rootSecurity = document.security;
|
|
889
|
+
const members: string[] = [];
|
|
890
|
+
for (const [route, rawItem] of Object.entries(record(document.paths) ?? {})) {
|
|
891
|
+
const item = record(rawItem) ?? {};
|
|
892
|
+
for (const method of HTTP_METHODS) {
|
|
893
|
+
const operation = record(item[method]);
|
|
894
|
+
if (!operation) continue;
|
|
895
|
+
const name = `${method.toUpperCase()} ${route}`;
|
|
896
|
+
if (select === "operations") members.push(name);
|
|
897
|
+
else if (select === "responses")
|
|
898
|
+
for (const code of Object.keys(record(operation.responses) ?? {}))
|
|
899
|
+
members.push(`${name} ${code}`);
|
|
900
|
+
else {
|
|
901
|
+
const security = operation.security ?? rootSecurity;
|
|
902
|
+
const unsecured =
|
|
903
|
+
!Array.isArray(security) ||
|
|
904
|
+
security.length === 0 ||
|
|
905
|
+
security.some(
|
|
906
|
+
(row) =>
|
|
907
|
+
record(row) !== null && Object.keys(row as object).length === 0,
|
|
908
|
+
);
|
|
909
|
+
if (unsecured) members.push(name);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return { ok: true, model: { members: sortedUnique(members) } };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ---------------------------------------------------------------- schemas
|
|
917
|
+
|
|
918
|
+
type Table = {
|
|
919
|
+
name: string;
|
|
920
|
+
columns: Map<string, SourceColumn>;
|
|
921
|
+
foreignKeys: string[];
|
|
922
|
+
/** Foreign keys by constraint name (lowercase), for DROP CONSTRAINT. */
|
|
923
|
+
constraints: Map<string, string>;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
function identifierName(raw: string): string {
|
|
927
|
+
const cleaned = raw.trim();
|
|
928
|
+
const parts = cleaned.match(
|
|
929
|
+
/("[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)/g,
|
|
930
|
+
) ?? [cleaned];
|
|
931
|
+
return (parts[parts.length - 1] ?? cleaned).replace(/^["`[]|["`\]]$/g, "");
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function splitTopLevel(body: string): string[] {
|
|
935
|
+
const parts: string[] = [];
|
|
936
|
+
let depth = 0;
|
|
937
|
+
let current = "";
|
|
938
|
+
let quote: string | null = null;
|
|
939
|
+
for (const char of body) {
|
|
940
|
+
if (quote !== null) {
|
|
941
|
+
current += char;
|
|
942
|
+
if (char === quote) quote = null;
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (char === "'" || char === '"' || char === "`") quote = char;
|
|
946
|
+
if (char === "(") depth++;
|
|
947
|
+
if (char === ")") depth--;
|
|
948
|
+
if (char === "," && depth === 0) {
|
|
949
|
+
parts.push(current.trim());
|
|
950
|
+
current = "";
|
|
951
|
+
} else current += char;
|
|
952
|
+
}
|
|
953
|
+
if (current.trim() !== "") parts.push(current.trim());
|
|
954
|
+
return parts;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function stripSqlComments(text: string): string {
|
|
958
|
+
return text.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Statements split at top-level semicolons. Quoted text and dollar-quoted
|
|
962
|
+
// bodies ($$ ... $$, $fn$ ... $fn$) stay whole, so a function body's
|
|
963
|
+
// semicolons do not split it.
|
|
964
|
+
function sqlStatements(text: string): string[] {
|
|
965
|
+
const statements: string[] = [];
|
|
966
|
+
let current = "";
|
|
967
|
+
let quote: string | null = null;
|
|
968
|
+
const source = stripSqlComments(text);
|
|
969
|
+
for (let index = 0; index < source.length; index++) {
|
|
970
|
+
const char = source[index] as string;
|
|
971
|
+
if (quote !== null) {
|
|
972
|
+
if (quote.length > 1) {
|
|
973
|
+
if (source.startsWith(quote, index)) {
|
|
974
|
+
current += quote;
|
|
975
|
+
index += quote.length - 1;
|
|
976
|
+
quote = null;
|
|
977
|
+
} else current += char;
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
current += char;
|
|
981
|
+
if (char === quote) quote = null;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (char === "$") {
|
|
985
|
+
const tag = source.slice(index).match(/^\$[A-Za-z_]*\$/)?.[0];
|
|
986
|
+
if (tag !== undefined) {
|
|
987
|
+
quote = tag;
|
|
988
|
+
current += tag;
|
|
989
|
+
index += tag.length - 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (char === "'" || char === '"' || char === "`") quote = char;
|
|
994
|
+
if (char === ";") {
|
|
995
|
+
if (current.trim() !== "") statements.push(current.trim());
|
|
996
|
+
current = "";
|
|
997
|
+
} else current += char;
|
|
998
|
+
}
|
|
999
|
+
if (current.trim() !== "") statements.push(current.trim());
|
|
1000
|
+
return statements;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// One name part, and a possibly schema-qualified name: users, public.users,
|
|
1004
|
+
// "public"."users", [dbo].[users].
|
|
1005
|
+
const SQL_NAME_PART = '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[\\w$]+)';
|
|
1006
|
+
const SQL_NAME = String.raw`(${SQL_NAME_PART}(?:\s*\.\s*${SQL_NAME_PART})*)`;
|
|
1007
|
+
|
|
1008
|
+
const COLUMN_CONSTRAINT_START =
|
|
1009
|
+
/^(?:constraint|primary\s+key|foreign\s+key|unique|check|key|index|exclude)\b/i;
|
|
1010
|
+
|
|
1011
|
+
function foreignKeyText(
|
|
1012
|
+
table: string,
|
|
1013
|
+
columns: string,
|
|
1014
|
+
target: string,
|
|
1015
|
+
targetColumns: string,
|
|
1016
|
+
rest: string,
|
|
1017
|
+
): string {
|
|
1018
|
+
const onDelete = rest.match(
|
|
1019
|
+
/\bon\s+delete\s+(cascade|restrict|set\s+null|set\s+default|no\s+action)/i,
|
|
1020
|
+
);
|
|
1021
|
+
const from = splitTopLevel(columns).map(identifierName).join(",");
|
|
1022
|
+
const to = splitTopLevel(targetColumns).map(identifierName).join(",");
|
|
1023
|
+
return `${table}.${from}->${identifierName(target)}.${to}${
|
|
1024
|
+
onDelete?.[1]
|
|
1025
|
+
? ` on delete ${onDelete[1].toLowerCase().replace(/\s+/g, " ")}`
|
|
1026
|
+
: ""
|
|
1027
|
+
}`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// A foreign key with the name a later DROP CONSTRAINT uses: the declared
|
|
1031
|
+
// name, or PostgreSQL's default <table>_<columns>_fkey.
|
|
1032
|
+
function addForeignKey(
|
|
1033
|
+
table: Table,
|
|
1034
|
+
constraint: string | undefined,
|
|
1035
|
+
columns: string,
|
|
1036
|
+
target: string,
|
|
1037
|
+
targetColumns: string,
|
|
1038
|
+
rest: string,
|
|
1039
|
+
): void {
|
|
1040
|
+
const text = foreignKeyText(table.name, columns, target, targetColumns, rest);
|
|
1041
|
+
table.foreignKeys.push(text);
|
|
1042
|
+
const name =
|
|
1043
|
+
constraint !== undefined
|
|
1044
|
+
? identifierName(constraint)
|
|
1045
|
+
: `${table.name}_${splitTopLevel(columns).map(identifierName).join("_")}_fkey`;
|
|
1046
|
+
table.constraints.set(name.toLowerCase(), text);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function sqlColumn(table: Table, definition: string): void {
|
|
1050
|
+
const head = definition.match(new RegExp(`^${SQL_NAME_PART}`))?.[0] ?? "";
|
|
1051
|
+
const name = identifierName(head);
|
|
1052
|
+
const rest = definition.slice(head.length);
|
|
1053
|
+
const notNull =
|
|
1054
|
+
/\bnot\s+null\b/i.test(rest) || /\bprimary\s+key\b/i.test(rest);
|
|
1055
|
+
table.columns.set(name, { name, nullable: !notNull, optional: false });
|
|
1056
|
+
const reference = rest.match(
|
|
1057
|
+
new RegExp(String.raw`\breferences\s+${SQL_NAME}\s*\(([^)]*)\)(.*)$`, "is"),
|
|
1058
|
+
);
|
|
1059
|
+
if (reference) {
|
|
1060
|
+
const constraint = rest.match(
|
|
1061
|
+
new RegExp(
|
|
1062
|
+
String.raw`\bconstraint\s+(${SQL_NAME_PART})\s+references\b`,
|
|
1063
|
+
"i",
|
|
1064
|
+
),
|
|
1065
|
+
)?.[1];
|
|
1066
|
+
addForeignKey(
|
|
1067
|
+
table,
|
|
1068
|
+
constraint,
|
|
1069
|
+
name,
|
|
1070
|
+
reference[1] ?? "",
|
|
1071
|
+
reference[2] ?? "",
|
|
1072
|
+
reference[3] ?? "",
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function setPrimaryKey(table: Table, columns: string): void {
|
|
1078
|
+
for (const column of splitTopLevel(columns).map(identifierName)) {
|
|
1079
|
+
const existing = table.columns.get(column);
|
|
1080
|
+
if (existing) existing.nullable = false;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ALTER TABLE actions that change neither the columns, their nullability nor
|
|
1085
|
+
// the foreign keys: row level security, triggers, ownership, storage,
|
|
1086
|
+
// defaults, column types, statistics and comments.
|
|
1087
|
+
const SCHEMA_NEUTRAL_ALTER =
|
|
1088
|
+
/^(?:(?:enable|disable|force|no\s+force)\s+row\s+level\s+security|(?:enable|disable)\s+(?:always\s+|replica\s+)?(?:trigger|rule)\b|replica\s+identity\b|owner\s+to\b|set\s+(?:schema|tablespace|logged|unlogged|without\s+(?:oids|cluster)|access\s+method)\b|set\s*\(|reset\s*\(|cluster\s+on\b|inherit\b|no\s+inherit\b|validate\s+constraint\b|rename\s+constraint\b|alter\s+constraint\b|alter\s+(?:column\s+)?\S+\s+(?:set\s+default|drop\s+default|(?:set\s+data\s+)?type|set\s+statistics|set\s+storage|set\s+compression|add\s+generated|set\s+generated|drop\s+identity|drop\s+expression|reset\s*\(|set\s*\(|options\s*\())/i;
|
|
1089
|
+
|
|
1090
|
+
function applyAlterAction(table: Table, action: string): string | null {
|
|
1091
|
+
const add = action.match(
|
|
1092
|
+
/^add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?(.*)$/is,
|
|
1093
|
+
);
|
|
1094
|
+
if (add && !COLUMN_CONSTRAINT_START.test(add[1] ?? "")) {
|
|
1095
|
+
sqlColumn(table, add[1] ?? "");
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
const dropColumn = action.match(
|
|
1099
|
+
new RegExp(
|
|
1100
|
+
String.raw`^drop\s+(?:column\s+)?(?:if\s+exists\s+)?(${SQL_NAME_PART})(?:\s+(?:cascade|restrict))?$`,
|
|
1101
|
+
"i",
|
|
1102
|
+
),
|
|
1103
|
+
);
|
|
1104
|
+
if (dropColumn && !/^drop\s+constraint\b/i.test(action)) {
|
|
1105
|
+
const column = identifierName(dropColumn[1] ?? "");
|
|
1106
|
+
table.columns.delete(column);
|
|
1107
|
+
table.foreignKeys = table.foreignKeys.filter(
|
|
1108
|
+
(fk) => !fk.startsWith(`${table.name}.${column}->`),
|
|
1109
|
+
);
|
|
1110
|
+
for (const [name, fk] of table.constraints)
|
|
1111
|
+
if (fk.startsWith(`${table.name}.${column}->`))
|
|
1112
|
+
table.constraints.delete(name);
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
const renameColumn = action.match(
|
|
1116
|
+
new RegExp(
|
|
1117
|
+
String.raw`^rename\s+(?:column\s+)?(${SQL_NAME_PART})\s+to\s+(${SQL_NAME_PART})$`,
|
|
1118
|
+
"i",
|
|
1119
|
+
),
|
|
1120
|
+
);
|
|
1121
|
+
if (renameColumn && !/^rename\s+constraint\b/i.test(action)) {
|
|
1122
|
+
const from = identifierName(renameColumn[1] ?? "");
|
|
1123
|
+
const to = identifierName(renameColumn[2] ?? "");
|
|
1124
|
+
const column = table.columns.get(from);
|
|
1125
|
+
if (column) {
|
|
1126
|
+
table.columns.delete(from);
|
|
1127
|
+
column.name = to;
|
|
1128
|
+
table.columns.set(to, column);
|
|
1129
|
+
}
|
|
1130
|
+
const renamed = (fk: string) =>
|
|
1131
|
+
fk.replace(`${table.name}.${from}->`, `${table.name}.${to}->`);
|
|
1132
|
+
table.foreignKeys = table.foreignKeys.map(renamed);
|
|
1133
|
+
for (const [name, fk] of table.constraints)
|
|
1134
|
+
table.constraints.set(name, renamed(fk));
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
const nullability = action.match(
|
|
1138
|
+
new RegExp(
|
|
1139
|
+
String.raw`^alter\s+(?:column\s+)?(${SQL_NAME_PART})\s+(set|drop)\s+not\s+null$`,
|
|
1140
|
+
"i",
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
if (nullability) {
|
|
1144
|
+
const column = table.columns.get(identifierName(nullability[1] ?? ""));
|
|
1145
|
+
if (column)
|
|
1146
|
+
column.nullable = (nullability[2] ?? "").toLowerCase() === "drop";
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
const addFk = action.match(
|
|
1150
|
+
new RegExp(
|
|
1151
|
+
String.raw`^add\s+(?:constraint\s+(${SQL_NAME_PART})\s+)?foreign\s+key\s*\(([^)]*)\)\s*references\s+${SQL_NAME}\s*\(([^)]*)\)(.*)$`,
|
|
1152
|
+
"is",
|
|
1153
|
+
),
|
|
1154
|
+
);
|
|
1155
|
+
if (addFk) {
|
|
1156
|
+
addForeignKey(
|
|
1157
|
+
table,
|
|
1158
|
+
addFk[1],
|
|
1159
|
+
addFk[2] ?? "",
|
|
1160
|
+
addFk[3] ?? "",
|
|
1161
|
+
addFk[4] ?? "",
|
|
1162
|
+
addFk[5] ?? "",
|
|
1163
|
+
);
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
const addPrimary = action.match(
|
|
1167
|
+
new RegExp(
|
|
1168
|
+
String.raw`^add\s+(?:constraint\s+${SQL_NAME_PART}\s+)?primary\s+key\s*\(([^)]*)\)`,
|
|
1169
|
+
"i",
|
|
1170
|
+
),
|
|
1171
|
+
);
|
|
1172
|
+
if (addPrimary) {
|
|
1173
|
+
setPrimaryKey(table, addPrimary[1] ?? "");
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
if (
|
|
1177
|
+
new RegExp(
|
|
1178
|
+
String.raw`^add\s+(?:constraint\s+${SQL_NAME_PART}\s+)?(?:unique|check|exclude)\b`,
|
|
1179
|
+
"i",
|
|
1180
|
+
).test(action)
|
|
1181
|
+
)
|
|
1182
|
+
return null;
|
|
1183
|
+
const dropConstraint = action.match(
|
|
1184
|
+
new RegExp(
|
|
1185
|
+
String.raw`^drop\s+constraint\s+(if\s+exists\s+)?(${SQL_NAME_PART})(?:\s+(?:cascade|restrict))?$`,
|
|
1186
|
+
"i",
|
|
1187
|
+
),
|
|
1188
|
+
);
|
|
1189
|
+
if (dropConstraint) {
|
|
1190
|
+
const name = identifierName(dropConstraint[2] ?? "").toLowerCase();
|
|
1191
|
+
const fk = table.constraints.get(name);
|
|
1192
|
+
if (fk !== undefined) {
|
|
1193
|
+
table.constraints.delete(name);
|
|
1194
|
+
const index = table.foreignKeys.indexOf(fk);
|
|
1195
|
+
if (index >= 0) table.foreignKeys.splice(index, 1);
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
// With IF EXISTS a constraint the replay never declared is not there.
|
|
1199
|
+
// Without it the database had it, so a foreign key Kibi did not read
|
|
1200
|
+
// would leave the schema unknown.
|
|
1201
|
+
if (dropConstraint[1] === undefined && /(?:_fkey|_fk)$/i.test(name))
|
|
1202
|
+
return `drops constraint ${name}, a foreign key Kibi did not read`;
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
if (SCHEMA_NEUTRAL_ALTER.test(action)) return null;
|
|
1206
|
+
return "cannot read this ALTER TABLE form";
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function applySqlStatement(
|
|
1210
|
+
tables: Map<string, Table>,
|
|
1211
|
+
statement: string,
|
|
1212
|
+
where: string,
|
|
1213
|
+
): string | null {
|
|
1214
|
+
const normalized = statement.replace(/\s+/g, " ").trim();
|
|
1215
|
+
const create = normalized.match(
|
|
1216
|
+
new RegExp(
|
|
1217
|
+
String.raw`^create\s+(?:(?:global|local)\s+)?(?:(?:temp(?:orary)?|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?${SQL_NAME}\s*\((.*)\)[^)]*$`,
|
|
1218
|
+
"is",
|
|
1219
|
+
),
|
|
1220
|
+
);
|
|
1221
|
+
if (create) {
|
|
1222
|
+
const name = identifierName(create[1] ?? "");
|
|
1223
|
+
const table: Table = {
|
|
1224
|
+
name,
|
|
1225
|
+
columns: new Map(),
|
|
1226
|
+
foreignKeys: [],
|
|
1227
|
+
constraints: new Map(),
|
|
1228
|
+
};
|
|
1229
|
+
for (const part of splitTopLevel(create[2] ?? "")) {
|
|
1230
|
+
if (COLUMN_CONSTRAINT_START.test(part)) {
|
|
1231
|
+
const fk = part.match(
|
|
1232
|
+
new RegExp(
|
|
1233
|
+
String.raw`^(?:constraint\s+(${SQL_NAME_PART})\s+)?foreign\s+key\s*\(([^)]*)\)\s*references\s+${SQL_NAME}\s*\(([^)]*)\)(.*)$`,
|
|
1234
|
+
"is",
|
|
1235
|
+
),
|
|
1236
|
+
);
|
|
1237
|
+
if (fk)
|
|
1238
|
+
addForeignKey(
|
|
1239
|
+
table,
|
|
1240
|
+
fk[1],
|
|
1241
|
+
fk[2] ?? "",
|
|
1242
|
+
fk[3] ?? "",
|
|
1243
|
+
fk[4] ?? "",
|
|
1244
|
+
fk[5] ?? "",
|
|
1245
|
+
);
|
|
1246
|
+
const primary = part.match(/primary\s+key\s*\(([^)]*)\)/i);
|
|
1247
|
+
if (primary) setPrimaryKey(table, primary[1] ?? "");
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
sqlColumn(table, part);
|
|
1251
|
+
}
|
|
1252
|
+
tables.set(name, table);
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
if (
|
|
1256
|
+
/^create\s+(?:(?:global|local)\s+)?(?:(?:temp(?:orary)?|unlogged)\s+)?table\b/i.test(
|
|
1257
|
+
normalized,
|
|
1258
|
+
)
|
|
1259
|
+
)
|
|
1260
|
+
return `${where}: Kibi cannot read this CREATE TABLE form (${normalized.slice(0, 80)})`;
|
|
1261
|
+
const drop = normalized.match(
|
|
1262
|
+
/^drop\s+table\s+(?:if\s+exists\s+)?(.*?)(?:\s+(?:cascade|restrict))?$/i,
|
|
1263
|
+
);
|
|
1264
|
+
if (drop) {
|
|
1265
|
+
for (const name of splitTopLevel(drop[1] ?? ""))
|
|
1266
|
+
tables.delete(identifierName(name));
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
const alter = normalized.match(
|
|
1270
|
+
new RegExp(
|
|
1271
|
+
String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${SQL_NAME}\s+(.*)$`,
|
|
1272
|
+
"i",
|
|
1273
|
+
),
|
|
1274
|
+
);
|
|
1275
|
+
if (!alter) return null;
|
|
1276
|
+
const tableName = identifierName(alter[1] ?? "");
|
|
1277
|
+
const action = alter[2] ?? "";
|
|
1278
|
+
const rename = action.match(
|
|
1279
|
+
new RegExp(String.raw`^rename\s+to\s+${SQL_NAME}$`, "i"),
|
|
1280
|
+
);
|
|
1281
|
+
const table = tables.get(tableName);
|
|
1282
|
+
// A statement-wide neutral form needs no table: RLS on a table another
|
|
1283
|
+
// tool created changes nothing Kibi reads.
|
|
1284
|
+
const actions = splitTopLevel(action);
|
|
1285
|
+
if (!table) {
|
|
1286
|
+
if (actions.every((part) => SCHEMA_NEUTRAL_ALTER.test(part))) return null;
|
|
1287
|
+
return `${where}: ALTER TABLE ${tableName} before Kibi read its CREATE TABLE`;
|
|
1288
|
+
}
|
|
1289
|
+
if (rename) {
|
|
1290
|
+
tables.delete(tableName);
|
|
1291
|
+
const renamedTo = identifierName(rename[1] ?? "");
|
|
1292
|
+
const prefix = (fk: string) =>
|
|
1293
|
+
fk.replace(new RegExp(`^${tableName}\\.`), `${renamedTo}.`);
|
|
1294
|
+
table.name = renamedTo;
|
|
1295
|
+
table.foreignKeys = table.foreignKeys.map(prefix);
|
|
1296
|
+
for (const [name, fk] of table.constraints)
|
|
1297
|
+
table.constraints.set(name, prefix(fk));
|
|
1298
|
+
tables.set(table.name, table);
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
for (const part of actions) {
|
|
1302
|
+
const problem = applyAlterAction(table, part);
|
|
1303
|
+
if (problem !== null)
|
|
1304
|
+
return `${where}: ALTER TABLE ${tableName} ${problem} (${normalized.slice(0, 80)}), so the schema after it is unknown`;
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function readSqlSchema(
|
|
1310
|
+
workspaceRoot: string,
|
|
1311
|
+
spec: SourceSpec,
|
|
1312
|
+
): Table[] | string {
|
|
1313
|
+
const files = matchWorkspaceFiles(workspaceRoot, globList(spec.files));
|
|
1314
|
+
if (files.length === 0)
|
|
1315
|
+
return `${globList(spec.files).join(", ")} matches no file`;
|
|
1316
|
+
const tables = new Map<string, Table>();
|
|
1317
|
+
let created = 0;
|
|
1318
|
+
for (const file of files) {
|
|
1319
|
+
const text = readText(workspaceRoot, file) ?? "";
|
|
1320
|
+
const statements = sqlStatements(
|
|
1321
|
+
text.replace(/-->\s*statement-breakpoint/g, ";"),
|
|
1322
|
+
);
|
|
1323
|
+
for (const statement of statements) {
|
|
1324
|
+
if (/^\s*create\s+(?:\S+\s+){0,2}table\b/i.test(statement)) created++;
|
|
1325
|
+
const problem = applySqlStatement(tables, statement, file);
|
|
1326
|
+
if (problem !== null) return problem;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
// A replay that read no table proves nothing about the schema: a
|
|
1330
|
+
// mistyped pattern or a folder of TypeScript migrations would otherwise
|
|
1331
|
+
// pass every subset_of and excludes fact.
|
|
1332
|
+
if (created === 0)
|
|
1333
|
+
return `${globList(spec.files).join(", ")} holds no CREATE TABLE statement Kibi can read (${files.length} file${files.length === 1 ? "" : "s"})`;
|
|
1334
|
+
return [...tables.values()];
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const PRISMA_SCALARS = new Set([
|
|
1338
|
+
"String",
|
|
1339
|
+
"Boolean",
|
|
1340
|
+
"Int",
|
|
1341
|
+
"BigInt",
|
|
1342
|
+
"Float",
|
|
1343
|
+
"Decimal",
|
|
1344
|
+
"DateTime",
|
|
1345
|
+
"Json",
|
|
1346
|
+
"Bytes",
|
|
1347
|
+
]);
|
|
1348
|
+
|
|
1349
|
+
function readPrismaSchema(
|
|
1350
|
+
workspaceRoot: string,
|
|
1351
|
+
spec: SourceSpec,
|
|
1352
|
+
): Table[] | string {
|
|
1353
|
+
const text = readText(workspaceRoot, String(spec.file));
|
|
1354
|
+
if (text === null) return `${spec.file} does not exist in the workspace`;
|
|
1355
|
+
const source = text.replace(/\/\/[^\n]*/g, "");
|
|
1356
|
+
const enums = new Set(
|
|
1357
|
+
[...source.matchAll(/\benum\s+(\w+)\s*\{/g)].map((m) => m[1] as string),
|
|
1358
|
+
);
|
|
1359
|
+
const types = new Set(
|
|
1360
|
+
[...source.matchAll(/\b(?:type)\s+(\w+)\s*\{/g)].map((m) => m[1] as string),
|
|
1361
|
+
);
|
|
1362
|
+
const tables: Table[] = [];
|
|
1363
|
+
for (const model of source.matchAll(/\bmodel\s+(\w+)\s*\{([^}]*)\}/g)) {
|
|
1364
|
+
const body = model[2] ?? "";
|
|
1365
|
+
const mapped = body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1];
|
|
1366
|
+
const name = mapped ?? (model[1] as string);
|
|
1367
|
+
const table: Table = {
|
|
1368
|
+
name,
|
|
1369
|
+
columns: new Map(),
|
|
1370
|
+
foreignKeys: [],
|
|
1371
|
+
constraints: new Map(),
|
|
1372
|
+
};
|
|
1373
|
+
const fieldNames = new Map<string, string>();
|
|
1374
|
+
for (const line of body.split("\n")) {
|
|
1375
|
+
const field = line.trim().match(/^(\w+)\s+(\w+)(\[\])?(\?)?(.*)$/);
|
|
1376
|
+
if (!field) continue;
|
|
1377
|
+
const [, fieldName = "", type = "", list, optional, attributes = ""] =
|
|
1378
|
+
field;
|
|
1379
|
+
const columnName =
|
|
1380
|
+
attributes.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName;
|
|
1381
|
+
fieldNames.set(fieldName, columnName);
|
|
1382
|
+
if (
|
|
1383
|
+
PRISMA_SCALARS.has(type) ||
|
|
1384
|
+
enums.has(type) ||
|
|
1385
|
+
types.has(type) ||
|
|
1386
|
+
list === "[]"
|
|
1387
|
+
) {
|
|
1388
|
+
if (list === "[]" && !PRISMA_SCALARS.has(type) && !enums.has(type))
|
|
1389
|
+
continue;
|
|
1390
|
+
table.columns.set(columnName, {
|
|
1391
|
+
name: columnName,
|
|
1392
|
+
nullable: optional === "?",
|
|
1393
|
+
optional: false,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
for (const line of body.split("\n")) {
|
|
1398
|
+
const relation = line.match(
|
|
1399
|
+
/^\s*\w+\s+(\w+)\??\s+.*@relation\(([^)]*)\)/,
|
|
1400
|
+
);
|
|
1401
|
+
if (!relation) continue;
|
|
1402
|
+
const fields = relation[2]?.match(/fields:\s*\[([^\]]*)\]/)?.[1];
|
|
1403
|
+
const references = relation[2]?.match(/references:\s*\[([^\]]*)\]/)?.[1];
|
|
1404
|
+
if (!fields || !references) continue;
|
|
1405
|
+
const target = relation[1] as string;
|
|
1406
|
+
const targetModel = source.match(
|
|
1407
|
+
new RegExp(`\\bmodel\\s+${target}\\s*\\{([^}]*)\\}`),
|
|
1408
|
+
);
|
|
1409
|
+
const targetName =
|
|
1410
|
+
targetModel?.[1]?.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? target;
|
|
1411
|
+
const onDelete = relation[2]?.match(/onDelete:\s*(\w+)/)?.[1];
|
|
1412
|
+
const columns = fields
|
|
1413
|
+
.split(",")
|
|
1414
|
+
.map((f) => fieldNames.get(f.trim()) ?? f.trim())
|
|
1415
|
+
.join(",");
|
|
1416
|
+
const targetColumns = references
|
|
1417
|
+
.split(",")
|
|
1418
|
+
.map((f) => f.trim())
|
|
1419
|
+
.join(",");
|
|
1420
|
+
table.foreignKeys.push(
|
|
1421
|
+
`${name}.${columns}->${targetName}.${targetColumns}${
|
|
1422
|
+
onDelete
|
|
1423
|
+
? ` on delete ${onDelete.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()}`
|
|
1424
|
+
: ""
|
|
1425
|
+
}`,
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
tables.push(table);
|
|
1429
|
+
}
|
|
1430
|
+
if (tables.length === 0) return `${spec.file} declares no model`;
|
|
1431
|
+
return tables;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function globMatcher(pattern: string | undefined): (name: string) => boolean {
|
|
1435
|
+
if (pattern === undefined || pattern === "*") return () => true;
|
|
1436
|
+
const regex = new RegExp(
|
|
1437
|
+
`^${pattern
|
|
1438
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
1439
|
+
.replace(/\*/g, ".*")
|
|
1440
|
+
.replace(/\?/g, ".")}$`,
|
|
1441
|
+
);
|
|
1442
|
+
return (name) => regex.test(name);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** The entities (tables or validator objects) a spec's table filter selects. */
|
|
1446
|
+
export function selectedEntities(
|
|
1447
|
+
spec: SourceSpec,
|
|
1448
|
+
model: SourceModel,
|
|
1449
|
+
): SourceEntity[] {
|
|
1450
|
+
const matches = globMatcher(
|
|
1451
|
+
typeof spec.table === "string" ? spec.table : undefined,
|
|
1452
|
+
);
|
|
1453
|
+
return (model.entities ?? []).filter((entity) => matches(entity.name));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function entityMembers(spec: SourceSpec, entities: SourceEntity[]): string[] {
|
|
1457
|
+
const select =
|
|
1458
|
+
typeof spec.select === "string"
|
|
1459
|
+
? spec.select
|
|
1460
|
+
: spec.reader === "validator"
|
|
1461
|
+
? "fields"
|
|
1462
|
+
: "tables";
|
|
1463
|
+
const prefix = (entity: SourceEntity, column: SourceColumn) =>
|
|
1464
|
+
spec.reader === "validator" ? column.name : `${entity.name}.${column.name}`;
|
|
1465
|
+
const members: string[] = [];
|
|
1466
|
+
for (const entity of entities) {
|
|
1467
|
+
if (select === "tables") members.push(entity.name);
|
|
1468
|
+
else if (select === "foreign_keys") members.push(...entity.foreign_keys);
|
|
1469
|
+
else
|
|
1470
|
+
for (const column of entity.columns) {
|
|
1471
|
+
const required = !column.nullable && !column.optional;
|
|
1472
|
+
if (
|
|
1473
|
+
select === "columns" ||
|
|
1474
|
+
select === "fields" ||
|
|
1475
|
+
((select === "required_columns" || select === "required_fields") &&
|
|
1476
|
+
required) ||
|
|
1477
|
+
(select === "nullable_columns" && column.nullable) ||
|
|
1478
|
+
(select === "optional_fields" && !required)
|
|
1479
|
+
)
|
|
1480
|
+
members.push(prefix(entity, column));
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return sortedUnique(members);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function finishEntities(
|
|
1487
|
+
spec: SourceSpec,
|
|
1488
|
+
tables: Table[] | SourceEntity[],
|
|
1489
|
+
): SourceRead {
|
|
1490
|
+
const entities: SourceEntity[] = tables
|
|
1491
|
+
.map((table) =>
|
|
1492
|
+
"columns" in table && table.columns instanceof Map
|
|
1493
|
+
? {
|
|
1494
|
+
name: table.name,
|
|
1495
|
+
columns: [...table.columns.values()]
|
|
1496
|
+
.map((column) => ({ ...column }))
|
|
1497
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
1498
|
+
foreign_keys: sortedUnique((table as Table).foreignKeys),
|
|
1499
|
+
}
|
|
1500
|
+
: (table as SourceEntity),
|
|
1501
|
+
)
|
|
1502
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1503
|
+
const model: SourceModel = { members: [], entities };
|
|
1504
|
+
const selected = selectedEntities(spec, model);
|
|
1505
|
+
if (typeof spec.table === "string" && selected.length === 0)
|
|
1506
|
+
return {
|
|
1507
|
+
ok: false,
|
|
1508
|
+
reason: `no ${spec.reader === "validator" ? "object" : "table"} matches ${spec.table} (${
|
|
1509
|
+
entities
|
|
1510
|
+
.map((e) => e.name)
|
|
1511
|
+
.slice(0, 10)
|
|
1512
|
+
.join(", ") || "none read"
|
|
1513
|
+
})`,
|
|
1514
|
+
};
|
|
1515
|
+
return {
|
|
1516
|
+
ok: true,
|
|
1517
|
+
model: { members: entityMembers(spec, selected), entities: selected },
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** The routes a spec's path_prefix selects. */
|
|
1522
|
+
export function selectedRoutes(
|
|
1523
|
+
spec: SourceSpec,
|
|
1524
|
+
routes: SourceRoute[],
|
|
1525
|
+
): SourceRoute[] {
|
|
1526
|
+
const prefix =
|
|
1527
|
+
typeof spec.path_prefix === "string"
|
|
1528
|
+
? spec.path_prefix.replace(/\/+$/, "")
|
|
1529
|
+
: "";
|
|
1530
|
+
return routes.filter(
|
|
1531
|
+
(route) =>
|
|
1532
|
+
prefix === "" ||
|
|
1533
|
+
route.path === prefix ||
|
|
1534
|
+
route.path.startsWith(`${prefix}/`),
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Read `spec` from the workspace. Readers that evaluate code (drizzle
|
|
1540
|
+
* schemas, route tables, validators) and the references reader go through
|
|
1541
|
+
* `evaluate`, which the CLI supplies; a read without it refuses those.
|
|
1542
|
+
*/
|
|
1543
|
+
// implements REQ-kibi-source-proof-kinds
|
|
1544
|
+
export async function readSource(
|
|
1545
|
+
workspaceRoot: string,
|
|
1546
|
+
spec: SourceSpec,
|
|
1547
|
+
evaluate?: SourceCodeEvaluator,
|
|
1548
|
+
): Promise<SourceRead> {
|
|
1549
|
+
const errors = sourceSpecErrors(spec, "source");
|
|
1550
|
+
if (errors.length > 0) return { ok: false, reason: errors.join("; ") };
|
|
1551
|
+
switch (spec.reader) {
|
|
1552
|
+
case "json":
|
|
1553
|
+
case "yaml":
|
|
1554
|
+
return readDocumentMembers(workspaceRoot, spec);
|
|
1555
|
+
case "glob":
|
|
1556
|
+
return readGlob(workspaceRoot, spec);
|
|
1557
|
+
case "openapi":
|
|
1558
|
+
return readOpenApi(workspaceRoot, spec);
|
|
1559
|
+
case "schema": {
|
|
1560
|
+
if (spec.format === "sql") {
|
|
1561
|
+
const tables = readSqlSchema(workspaceRoot, spec);
|
|
1562
|
+
return typeof tables === "string"
|
|
1563
|
+
? { ok: false, reason: tables }
|
|
1564
|
+
: finishEntities(spec, tables);
|
|
1565
|
+
}
|
|
1566
|
+
if (spec.format === "prisma") {
|
|
1567
|
+
const tables = readPrismaSchema(workspaceRoot, spec);
|
|
1568
|
+
return typeof tables === "string"
|
|
1569
|
+
? { ok: false, reason: tables }
|
|
1570
|
+
: finishEntities(spec, tables);
|
|
1571
|
+
}
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
default:
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
if (evaluate === undefined)
|
|
1578
|
+
return {
|
|
1579
|
+
ok: false,
|
|
1580
|
+
reason: `reader ${spec.reader} needs Kibi's code evaluator`,
|
|
1581
|
+
};
|
|
1582
|
+
const evaluated = await evaluate(workspaceRoot, spec);
|
|
1583
|
+
if (!evaluated.ok) return evaluated;
|
|
1584
|
+
if (spec.reader === "references")
|
|
1585
|
+
return {
|
|
1586
|
+
ok: true,
|
|
1587
|
+
model: { members: sortedUnique(evaluated.members ?? []) },
|
|
1588
|
+
};
|
|
1589
|
+
if (spec.reader === "routes") {
|
|
1590
|
+
const routes = selectedRoutes(spec, evaluated.routes ?? []).sort((a, b) =>
|
|
1591
|
+
`${a.path} ${a.method}` < `${b.path} ${b.method}` ? -1 : 1,
|
|
1592
|
+
);
|
|
1593
|
+
if (typeof spec.path_prefix === "string" && routes.length === 0)
|
|
1594
|
+
return {
|
|
1595
|
+
ok: false,
|
|
1596
|
+
reason: `no route of ${spec.export} is under ${spec.path_prefix}`,
|
|
1597
|
+
};
|
|
1598
|
+
const members =
|
|
1599
|
+
spec.select === "middleware"
|
|
1600
|
+
? routes.flatMap((route) =>
|
|
1601
|
+
route.middleware.map(
|
|
1602
|
+
(name) => `${route.method} ${route.path} ${name}`,
|
|
1603
|
+
),
|
|
1604
|
+
)
|
|
1605
|
+
: routes.map((route) => `${route.method} ${route.path}`);
|
|
1606
|
+
return { ok: true, model: { members: sortedUnique(members), routes } };
|
|
1607
|
+
}
|
|
1608
|
+
return finishEntities(spec, evaluated.entities ?? []);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** What the CLI's code evaluator returns for a reader that runs code. */
|
|
1612
|
+
export type SourceCodeEvaluation =
|
|
1613
|
+
| {
|
|
1614
|
+
ok: true;
|
|
1615
|
+
members?: string[];
|
|
1616
|
+
entities?: SourceEntity[];
|
|
1617
|
+
routes?: SourceRoute[];
|
|
1618
|
+
}
|
|
1619
|
+
| { ok: false; reason: string };
|
|
1620
|
+
|
|
1621
|
+
export type SourceCodeEvaluator = (
|
|
1622
|
+
workspaceRoot: string,
|
|
1623
|
+
spec: SourceSpec,
|
|
1624
|
+
) => Promise<SourceCodeEvaluation>;
|
|
1625
|
+
|
|
1626
|
+
/** A short description of a spec, for messages. */
|
|
1627
|
+
export function describeSource(spec: SourceSpec): string {
|
|
1628
|
+
switch (spec.reader) {
|
|
1629
|
+
case "json":
|
|
1630
|
+
case "yaml":
|
|
1631
|
+
case "openapi":
|
|
1632
|
+
return `${spec.reader} ${spec.file}${spec.path ? ` at ${spec.path}` : ""}`;
|
|
1633
|
+
case "glob":
|
|
1634
|
+
return `files matching ${globList(spec.pattern).join(", ")}`;
|
|
1635
|
+
case "references":
|
|
1636
|
+
return `${spec.extract} references in ${globList(spec.files).join(", ")}`;
|
|
1637
|
+
case "schema":
|
|
1638
|
+
return `${spec.format} schema ${spec.file ?? globList(spec.files).join(", ")}${spec.table ? ` (tables ${spec.table})` : ""}`;
|
|
1639
|
+
case "routes":
|
|
1640
|
+
return `routes of ${spec.export} in ${spec.file}${spec.path_prefix ? ` under ${spec.path_prefix}` : ""}`;
|
|
1641
|
+
case "validator":
|
|
1642
|
+
return `validator ${spec.export} in ${spec.file}`;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Unresolved references, as one rejection reason naming the first few. */
|
|
1647
|
+
export function unresolvedReason(
|
|
1648
|
+
what: string,
|
|
1649
|
+
unresolved: readonly UnresolvedReference[],
|
|
1650
|
+
): string {
|
|
1651
|
+
const shown = unresolved
|
|
1652
|
+
.slice(0, 5)
|
|
1653
|
+
.map((row) => `${row.file}:${row.line} ${row.text}`)
|
|
1654
|
+
.join("; ");
|
|
1655
|
+
return `Kibi cannot read ${unresolved.length} ${what} statically, so the set is not known exactly: ${shown}${unresolved.length > 5 ? "; ..." : ""}`;
|
|
1656
|
+
}
|