speckeeper 0.8.1 → 0.9.1
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/README.md +137 -66
- package/dist/cli.js +639 -102
- package/dist/cli.js.map +1 -1
- package/dist/{config-api-U2pt1aHJ.d.ts → config-api-CfxXt9Zt.d.ts} +110 -2
- package/dist/dsl/index.d.ts +50 -82
- package/dist/dsl/index.js +434 -453
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
4
|
-
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { dirname, join, resolve, isAbsolute } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import chalk4 from 'chalk';
|
|
7
7
|
import { parse } from 'yaml';
|
|
8
8
|
import 'crypto';
|
|
9
|
-
import 'glob';
|
|
9
|
+
import { glob } from 'glob';
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
+
import nodeSqlParser from 'node-sql-parser';
|
|
11
12
|
|
|
12
13
|
var defaultConfig = {
|
|
13
14
|
srcDir: "src",
|
|
@@ -410,6 +411,516 @@ function outputDriftResults(results, _options) {
|
|
|
410
411
|
console.log("");
|
|
411
412
|
console.log(chalk4.gray(` Summary: ${ok.length} ok, ${drifted.length} drifted, ${missing.length} missing`));
|
|
412
413
|
}
|
|
414
|
+
|
|
415
|
+
// src/core/dsl/type-compat.ts
|
|
416
|
+
var ALIASES = {
|
|
417
|
+
INTEGER: "INT",
|
|
418
|
+
BOOL: "BOOLEAN",
|
|
419
|
+
REAL: "FLOAT",
|
|
420
|
+
"DOUBLE PRECISION": "DOUBLE",
|
|
421
|
+
SERIAL: "INT",
|
|
422
|
+
BIGSERIAL: "BIGINT",
|
|
423
|
+
SMALLSERIAL: "SMALLINT",
|
|
424
|
+
TIMESTAMPTZ: "TIMESTAMP",
|
|
425
|
+
"TIMESTAMP WITH TIME ZONE": "TIMESTAMP",
|
|
426
|
+
"TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP",
|
|
427
|
+
"CHARACTER VARYING": "VARCHAR",
|
|
428
|
+
CHARACTER: "CHAR",
|
|
429
|
+
INT2: "SMALLINT",
|
|
430
|
+
INT4: "INT",
|
|
431
|
+
INT8: "BIGINT",
|
|
432
|
+
FLOAT4: "FLOAT",
|
|
433
|
+
FLOAT8: "DOUBLE"
|
|
434
|
+
};
|
|
435
|
+
var CONTAINMENT_CHAINS = [
|
|
436
|
+
["SMALLINT", "INT", "BIGINT"],
|
|
437
|
+
["FLOAT", "DOUBLE"],
|
|
438
|
+
["CHAR", "VARCHAR", "TEXT"],
|
|
439
|
+
["INTEGER", "NUMBER"]
|
|
440
|
+
];
|
|
441
|
+
function parseType(raw) {
|
|
442
|
+
const trimmed = raw.trim().toUpperCase();
|
|
443
|
+
const match = trimmed.match(/^([A-Z\s]+?)(?:\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\))?$/);
|
|
444
|
+
if (!match) {
|
|
445
|
+
return { base: trimmed };
|
|
446
|
+
}
|
|
447
|
+
const base = match[1].trim();
|
|
448
|
+
return {
|
|
449
|
+
base,
|
|
450
|
+
length: match[2] !== void 0 ? Number(match[2]) : void 0,
|
|
451
|
+
precision: match[2] !== void 0 ? Number(match[2]) : void 0,
|
|
452
|
+
scale: match[3] !== void 0 ? Number(match[3]) : void 0
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function normalize(base) {
|
|
456
|
+
return ALIASES[base] ?? base;
|
|
457
|
+
}
|
|
458
|
+
function findAllChainPositions(base, chains) {
|
|
459
|
+
const results = [];
|
|
460
|
+
const resolved = normalize(base);
|
|
461
|
+
for (const chain of chains) {
|
|
462
|
+
let idx = chain.indexOf(base);
|
|
463
|
+
if (idx === -1) idx = chain.indexOf(resolved);
|
|
464
|
+
if (idx !== -1) results.push({ chain, index: idx });
|
|
465
|
+
}
|
|
466
|
+
return results;
|
|
467
|
+
}
|
|
468
|
+
function isTypeContainedBy(specType, ddlType) {
|
|
469
|
+
const spec = parseType(specType);
|
|
470
|
+
const ddl = parseType(ddlType);
|
|
471
|
+
const specNorm = normalize(spec.base);
|
|
472
|
+
const ddlNorm = normalize(ddl.base);
|
|
473
|
+
if (specNorm === ddlNorm) {
|
|
474
|
+
if (specNorm === "DECIMAL" || specNorm === "NUMERIC") {
|
|
475
|
+
if (spec.precision !== void 0 && ddl.precision !== void 0) {
|
|
476
|
+
if (ddl.precision < spec.precision) return false;
|
|
477
|
+
}
|
|
478
|
+
if (spec.scale !== void 0 && ddl.scale !== void 0) {
|
|
479
|
+
if (ddl.scale < spec.scale) return false;
|
|
480
|
+
}
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
if (spec.length !== void 0 && ddl.length !== void 0) {
|
|
484
|
+
return ddl.length >= spec.length;
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
const specPositions = findAllChainPositions(spec.base, CONTAINMENT_CHAINS);
|
|
489
|
+
const ddlPositions = findAllChainPositions(ddl.base, CONTAINMENT_CHAINS);
|
|
490
|
+
for (const sp of specPositions) {
|
|
491
|
+
for (const dp of ddlPositions) {
|
|
492
|
+
if (sp.chain === dp.chain) {
|
|
493
|
+
return dp.index >= sp.index;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/core/global-scanner.ts
|
|
501
|
+
function resolveRef(doc, ref) {
|
|
502
|
+
if (!ref.startsWith("#/")) return void 0;
|
|
503
|
+
const parts = ref.substring(2).split("/");
|
|
504
|
+
let current = doc;
|
|
505
|
+
for (const part of parts) {
|
|
506
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
507
|
+
current = current[part];
|
|
508
|
+
}
|
|
509
|
+
return current;
|
|
510
|
+
}
|
|
511
|
+
function maybeResolveRef(doc, value) {
|
|
512
|
+
if (value && typeof value === "object" && typeof value.$ref === "string") {
|
|
513
|
+
return resolveRef(doc, value.$ref);
|
|
514
|
+
}
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
517
|
+
function findSpecIdInOpenAPI(doc, specId) {
|
|
518
|
+
const paths = doc.paths;
|
|
519
|
+
if (!paths || typeof paths !== "object") return null;
|
|
520
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
521
|
+
if (pathItem == null || typeof pathItem !== "object") continue;
|
|
522
|
+
const methods = ["get", "post", "put", "delete", "patch", "options", "head"];
|
|
523
|
+
for (const method of methods) {
|
|
524
|
+
const op = pathItem[method];
|
|
525
|
+
if (!op) continue;
|
|
526
|
+
if (op.operationId === specId) return { pathKey, operation: op, method };
|
|
527
|
+
if (op["x-spec-id"] === specId) return { pathKey, operation: op, method };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const pathKey of Object.keys(paths)) {
|
|
531
|
+
const segments = pathKey.split("/").filter(Boolean);
|
|
532
|
+
const cleanSegments = segments.map((s) => s.replace(/^\{|\}$/g, ""));
|
|
533
|
+
if (cleanSegments.includes(specId)) return { pathKey };
|
|
534
|
+
}
|
|
535
|
+
const schemas = doc.components?.schemas;
|
|
536
|
+
if (schemas && typeof schemas === "object") {
|
|
537
|
+
if (specId in schemas) return { pathKey: `#/components/schemas/${specId}` };
|
|
538
|
+
}
|
|
539
|
+
function searchXSpecId(obj) {
|
|
540
|
+
if (obj == null || typeof obj !== "object") return false;
|
|
541
|
+
if (obj["x-spec-id"] === specId) return true;
|
|
542
|
+
for (const val of Object.values(obj)) {
|
|
543
|
+
if (searchXSpecId(val)) return true;
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
if (searchXSpecId(doc)) return { pathKey: "(x-spec-id match)" };
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
var openapiScanner = {
|
|
551
|
+
findSpecIds(content, specIds, filePath) {
|
|
552
|
+
const doc = content;
|
|
553
|
+
if (!doc || typeof doc !== "object") return [];
|
|
554
|
+
const matches = [];
|
|
555
|
+
for (const specId of specIds) {
|
|
556
|
+
const match = findSpecIdInOpenAPI(doc, specId);
|
|
557
|
+
if (match) {
|
|
558
|
+
matches.push({
|
|
559
|
+
specId,
|
|
560
|
+
location: `${filePath}:${match.pathKey}`,
|
|
561
|
+
context: { doc, match }
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return matches;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function stripSchemaPrefix(name) {
|
|
569
|
+
const dotIndex = name.lastIndexOf(".");
|
|
570
|
+
return dotIndex >= 0 ? name.substring(dotIndex + 1) : name;
|
|
571
|
+
}
|
|
572
|
+
function parseDDLWithParser(content) {
|
|
573
|
+
const parser = new nodeSqlParser.Parser();
|
|
574
|
+
const tables = [];
|
|
575
|
+
try {
|
|
576
|
+
const ast = parser.astify(content);
|
|
577
|
+
const statements = Array.isArray(ast) ? ast : [ast];
|
|
578
|
+
for (const stmt of statements) {
|
|
579
|
+
if (stmt.type !== "create" || stmt.keyword !== "table") continue;
|
|
580
|
+
const rawName = typeof stmt.table === "string" ? stmt.table : stmt.table?.[0]?.table ?? "";
|
|
581
|
+
const tableName = stripSchemaPrefix(rawName);
|
|
582
|
+
const columns = (stmt.create_definitions || []).filter((def) => def.resource === "column").map((def) => {
|
|
583
|
+
const colName = def.column?.column ?? "";
|
|
584
|
+
let colType = def.definition?.dataType ?? "";
|
|
585
|
+
if (def.definition?.length) {
|
|
586
|
+
colType += `(${def.definition.length})`;
|
|
587
|
+
}
|
|
588
|
+
if (def.definition?.scale) {
|
|
589
|
+
colType = colType.replace(/\)$/, `,${def.definition.scale})`);
|
|
590
|
+
}
|
|
591
|
+
return { name: colName, type: colType };
|
|
592
|
+
});
|
|
593
|
+
if (tableName) {
|
|
594
|
+
tables.push({ name: tableName, columns });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return tables.length > 0 ? tables : null;
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function parseDDLWithRegex(content) {
|
|
603
|
+
const tables = [];
|
|
604
|
+
const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:(?:[`"']?\w+[`"']?\s*\.\s*)?)[`"']?(\w+)[`"']?\s*\(([\s\S]*?)\);/gi;
|
|
605
|
+
let match;
|
|
606
|
+
while ((match = tableRegex.exec(content)) !== null) {
|
|
607
|
+
const tableName = match[1];
|
|
608
|
+
const body = match[2];
|
|
609
|
+
const columns = [];
|
|
610
|
+
const lines = body.split(",").map((l) => l.trim()).filter(Boolean);
|
|
611
|
+
for (const line of lines) {
|
|
612
|
+
if (/^\s*(PRIMARY\s+KEY|UNIQUE|CHECK|CONSTRAINT|INDEX|FOREIGN\s+KEY)/i.test(line)) continue;
|
|
613
|
+
const colMatch = line.match(/^(?:`|"|')?(\w+)(?:`|"|')?\s+(\w+(?:\([^)]*\))?)/i);
|
|
614
|
+
if (colMatch) {
|
|
615
|
+
columns.push({ name: colMatch[1], type: colMatch[2] });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (tableName) {
|
|
619
|
+
tables.push({ name: tableName, columns });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return tables;
|
|
623
|
+
}
|
|
624
|
+
var ddlScanner = {
|
|
625
|
+
findSpecIds(content, specIds, filePath) {
|
|
626
|
+
if (typeof content !== "string") return [];
|
|
627
|
+
let tables = parseDDLWithParser(content);
|
|
628
|
+
if (!tables) {
|
|
629
|
+
tables = parseDDLWithRegex(content);
|
|
630
|
+
}
|
|
631
|
+
const matches = [];
|
|
632
|
+
for (const specId of specIds) {
|
|
633
|
+
const table = tables.find(
|
|
634
|
+
(t) => t.name.toLowerCase() === specId.toLowerCase()
|
|
635
|
+
);
|
|
636
|
+
if (table) {
|
|
637
|
+
matches.push({
|
|
638
|
+
specId,
|
|
639
|
+
location: `${filePath}:${table.name}`,
|
|
640
|
+
context: { table }
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return matches;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
function extractSpecIds(captured) {
|
|
648
|
+
return captured.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
649
|
+
}
|
|
650
|
+
var annotationScanner = {
|
|
651
|
+
findSpecIds(content, specIds, filePath) {
|
|
652
|
+
if (typeof content !== "string") return [];
|
|
653
|
+
const specIdSet = new Set(specIds);
|
|
654
|
+
const matches = [];
|
|
655
|
+
const defaultPattern = /@(?:verifies|implements|traces)\s+([\w-]+(?:[,\s]+[\w-]+)*)/g;
|
|
656
|
+
const lines = content.split("\n");
|
|
657
|
+
for (let lineNum = 1; lineNum <= lines.length; lineNum++) {
|
|
658
|
+
const line = lines[lineNum - 1];
|
|
659
|
+
const copy = new RegExp(defaultPattern.source, defaultPattern.flags);
|
|
660
|
+
let m;
|
|
661
|
+
while ((m = copy.exec(line)) !== null) {
|
|
662
|
+
const ids = extractSpecIds(m[1] ?? "");
|
|
663
|
+
for (const id of ids) {
|
|
664
|
+
if (specIdSet.has(id)) {
|
|
665
|
+
matches.push({
|
|
666
|
+
specId: id,
|
|
667
|
+
location: `${filePath}:${lineNum}`,
|
|
668
|
+
context: { filePath, line: lineNum }
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return matches;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
function createAnnotationScanner(contentPatterns) {
|
|
678
|
+
return {
|
|
679
|
+
findSpecIds(content, specIds, filePath) {
|
|
680
|
+
if (typeof content !== "string") return [];
|
|
681
|
+
const specIdSet = new Set(specIds);
|
|
682
|
+
const matches = [];
|
|
683
|
+
const lines = content.split("\n");
|
|
684
|
+
for (let lineNum = 1; lineNum <= lines.length; lineNum++) {
|
|
685
|
+
const line = lines[lineNum - 1];
|
|
686
|
+
for (const re of contentPatterns) {
|
|
687
|
+
const copy = new RegExp(re.source, re.flags);
|
|
688
|
+
let m;
|
|
689
|
+
while ((m = copy.exec(line)) !== null) {
|
|
690
|
+
const ids = extractSpecIds(m[1] ?? "");
|
|
691
|
+
for (const id of ids) {
|
|
692
|
+
if (specIdSet.has(id)) {
|
|
693
|
+
matches.push({
|
|
694
|
+
specId: id,
|
|
695
|
+
location: `${filePath}:${lineNum}`,
|
|
696
|
+
context: { filePath, line: lineNum }
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return matches;
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
var BUILT_IN_SCANNERS = {
|
|
708
|
+
openapi: openapiScanner,
|
|
709
|
+
ddl: ddlScanner,
|
|
710
|
+
annotation: annotationScanner
|
|
711
|
+
};
|
|
712
|
+
function getScannerForSource(source) {
|
|
713
|
+
if (source.scanner) return source.scanner;
|
|
714
|
+
if (source.type === "annotation" && source.contentPatterns && source.contentPatterns.length > 0) {
|
|
715
|
+
return createAnnotationScanner(source.contentPatterns);
|
|
716
|
+
}
|
|
717
|
+
return BUILT_IN_SCANNERS[source.type] ?? null;
|
|
718
|
+
}
|
|
719
|
+
function loadFileContent(filePath, sourceType) {
|
|
720
|
+
const content = readFileSync(filePath, "utf-8");
|
|
721
|
+
if (!content || content.length === 0) return null;
|
|
722
|
+
if (sourceType === "openapi") {
|
|
723
|
+
if (filePath.endsWith(".json")) {
|
|
724
|
+
return JSON.parse(content);
|
|
725
|
+
}
|
|
726
|
+
return parse(content);
|
|
727
|
+
}
|
|
728
|
+
return content;
|
|
729
|
+
}
|
|
730
|
+
function runGlobalScan(sources, specIds, basePath, lookupKeyMap) {
|
|
731
|
+
const cwd = basePath ?? process.cwd();
|
|
732
|
+
const result = /* @__PURE__ */ new Map();
|
|
733
|
+
const warnings = [];
|
|
734
|
+
for (const source of sources) {
|
|
735
|
+
const scanner = getScannerForSource(source);
|
|
736
|
+
if (!scanner) {
|
|
737
|
+
warnings.push({
|
|
738
|
+
message: `No scanner found for source type "${source.type}". Provide a custom scanner.`,
|
|
739
|
+
sourceType: source.type
|
|
740
|
+
});
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const keyToSpecId = /* @__PURE__ */ new Map();
|
|
744
|
+
for (const specId of specIds) {
|
|
745
|
+
const overrides = lookupKeyMap?.get(specId);
|
|
746
|
+
const key = overrides?.[source.type] ?? specId;
|
|
747
|
+
keyToSpecId.set(key, specId);
|
|
748
|
+
}
|
|
749
|
+
const keysToSearch = Array.from(keyToSpecId.keys());
|
|
750
|
+
const allFiles = /* @__PURE__ */ new Set();
|
|
751
|
+
for (const pattern of source.paths) {
|
|
752
|
+
const found = glob.sync(pattern, {
|
|
753
|
+
cwd,
|
|
754
|
+
ignore: source.exclude ?? []
|
|
755
|
+
});
|
|
756
|
+
for (const f of found) {
|
|
757
|
+
allFiles.add(f);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
for (const relPath of allFiles) {
|
|
761
|
+
const fullPath = isAbsolute(relPath) ? relPath : join(cwd, relPath);
|
|
762
|
+
if (!existsSync(fullPath)) continue;
|
|
763
|
+
let content;
|
|
764
|
+
try {
|
|
765
|
+
content = loadFileContent(fullPath, source.type);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
768
|
+
warnings.push({
|
|
769
|
+
message: `Failed to load ${relPath}: ${msg}`,
|
|
770
|
+
sourceType: source.type,
|
|
771
|
+
filePath: relPath
|
|
772
|
+
});
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (content == null) continue;
|
|
776
|
+
let matches;
|
|
777
|
+
try {
|
|
778
|
+
matches = scanner.findSpecIds(content, keysToSearch, relPath);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
781
|
+
warnings.push({
|
|
782
|
+
message: `Scanner error for ${relPath}: ${msg}`,
|
|
783
|
+
sourceType: source.type,
|
|
784
|
+
filePath: relPath
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
for (const match of matches) {
|
|
789
|
+
const originalSpecId = keyToSpecId.get(match.specId) ?? match.specId;
|
|
790
|
+
const globalMatch = {
|
|
791
|
+
...match,
|
|
792
|
+
specId: originalSpecId,
|
|
793
|
+
sourceType: source.type,
|
|
794
|
+
relation: source.relation,
|
|
795
|
+
filePath: relPath
|
|
796
|
+
};
|
|
797
|
+
const existing = result.get(originalSpecId);
|
|
798
|
+
if (existing) {
|
|
799
|
+
existing.push(globalMatch);
|
|
800
|
+
} else {
|
|
801
|
+
result.set(originalSpecId, [globalMatch]);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { matches: result, warnings };
|
|
807
|
+
}
|
|
808
|
+
function runDeepValidation(specId, matches, deepValidation, spec) {
|
|
809
|
+
const errors = [];
|
|
810
|
+
const warnings = [];
|
|
811
|
+
for (const match of matches) {
|
|
812
|
+
const rule = deepValidation[match.sourceType];
|
|
813
|
+
if (!rule) continue;
|
|
814
|
+
if (match.sourceType === "openapi") {
|
|
815
|
+
const mapping = rule.mapper(spec);
|
|
816
|
+
const ctx = match.context;
|
|
817
|
+
if (!ctx?.match?.operation) continue;
|
|
818
|
+
validateOpenAPIDeep(specId, mapping, ctx.doc, ctx.match, warnings);
|
|
819
|
+
} else if (match.sourceType === "ddl") {
|
|
820
|
+
const mapping = rule.mapper(spec);
|
|
821
|
+
const ctx = match.context;
|
|
822
|
+
if (!ctx?.table) continue;
|
|
823
|
+
validateDDLDeep(specId, mapping, ctx.table, warnings);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
success: errors.length === 0,
|
|
828
|
+
errors,
|
|
829
|
+
warnings
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function validateOpenAPIDeep(specId, mapping, doc, match, warnings) {
|
|
833
|
+
if (mapping.method && match.operation) {
|
|
834
|
+
const expectedMethod = mapping.method.toLowerCase();
|
|
835
|
+
const actualMethod = match.method?.toLowerCase();
|
|
836
|
+
if (actualMethod && actualMethod !== expectedMethod) {
|
|
837
|
+
warnings.push({
|
|
838
|
+
message: `Method mismatch for "${specId}": expected ${expectedMethod.toUpperCase()}, found ${actualMethod.toUpperCase()}`,
|
|
839
|
+
specId
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (match.operation && mapping.parameters) {
|
|
844
|
+
const opParams = (match.operation.parameters || []).map(
|
|
845
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
846
|
+
(p) => maybeResolveRef(doc, p)
|
|
847
|
+
);
|
|
848
|
+
for (const expectedParam of mapping.parameters) {
|
|
849
|
+
const found = opParams.find(
|
|
850
|
+
(p) => p?.name === expectedParam.name && (!expectedParam.in || p?.in === expectedParam.in)
|
|
851
|
+
);
|
|
852
|
+
if (!found) {
|
|
853
|
+
warnings.push({
|
|
854
|
+
message: `Parameter "${expectedParam.name}" not found in operation "${specId}"`,
|
|
855
|
+
specId,
|
|
856
|
+
field: expectedParam.name
|
|
857
|
+
});
|
|
858
|
+
} else if (expectedParam.type && found.schema?.type) {
|
|
859
|
+
if (!isTypeContainedBy(expectedParam.type, found.schema.type)) {
|
|
860
|
+
warnings.push({
|
|
861
|
+
message: `Parameter "${expectedParam.name}" type mismatch: expected ${expectedParam.type}, found ${found.schema.type}`,
|
|
862
|
+
specId,
|
|
863
|
+
field: expectedParam.name
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (match.operation && mapping.responseProperties) {
|
|
870
|
+
const successResponse = match.operation.responses?.["200"] || match.operation.responses?.["201"];
|
|
871
|
+
let responseSchema = successResponse?.content?.["application/json"]?.schema;
|
|
872
|
+
responseSchema = maybeResolveRef(doc, responseSchema);
|
|
873
|
+
if (responseSchema?.type === "array") {
|
|
874
|
+
responseSchema = maybeResolveRef(doc, responseSchema.items);
|
|
875
|
+
}
|
|
876
|
+
const responseProps = responseSchema?.properties || {};
|
|
877
|
+
for (const expectedProp of mapping.responseProperties) {
|
|
878
|
+
const foundProp = responseProps[expectedProp.name];
|
|
879
|
+
if (!foundProp) {
|
|
880
|
+
warnings.push({
|
|
881
|
+
message: `Response property "${expectedProp.name}" not found in operation "${specId}"`,
|
|
882
|
+
specId,
|
|
883
|
+
field: expectedProp.name
|
|
884
|
+
});
|
|
885
|
+
} else if (expectedProp.type && foundProp.type) {
|
|
886
|
+
if (!isTypeContainedBy(expectedProp.type, foundProp.type)) {
|
|
887
|
+
warnings.push({
|
|
888
|
+
message: `Response property "${expectedProp.name}" type mismatch: expected ${expectedProp.type}, found ${foundProp.type}`,
|
|
889
|
+
specId,
|
|
890
|
+
field: expectedProp.name
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function validateDDLDeep(specId, mapping, table, warnings) {
|
|
898
|
+
if (!mapping.columns) return;
|
|
899
|
+
for (const expectedCol of mapping.columns) {
|
|
900
|
+
const foundCol = table.columns.find(
|
|
901
|
+
(c) => c.name.toLowerCase() === expectedCol.name.toLowerCase()
|
|
902
|
+
);
|
|
903
|
+
if (!foundCol) {
|
|
904
|
+
warnings.push({
|
|
905
|
+
message: `Column "${expectedCol.name}" not found in table "${mapping.tableName}"`,
|
|
906
|
+
specId,
|
|
907
|
+
field: expectedCol.name
|
|
908
|
+
});
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (mapping.checkTypes && expectedCol.type && foundCol.type) {
|
|
912
|
+
if (!isTypeContainedBy(expectedCol.type, foundCol.type)) {
|
|
913
|
+
warnings.push({
|
|
914
|
+
message: `Column "${expectedCol.name}" type mismatch in table "${mapping.tableName}": expected ${expectedCol.type}, found ${foundCol.type}`,
|
|
915
|
+
specId,
|
|
916
|
+
field: expectedCol.name
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/cli/check.ts
|
|
413
924
|
async function checkCommand(type, options) {
|
|
414
925
|
console.log(chalk4.blue("speckeeper check"));
|
|
415
926
|
console.log("");
|
|
@@ -422,43 +933,101 @@ async function checkCommand(type, options) {
|
|
|
422
933
|
try {
|
|
423
934
|
const models = config.models || [];
|
|
424
935
|
const specs = config.specs;
|
|
936
|
+
const sources = config.sources ?? [];
|
|
425
937
|
if (options.verbose) {
|
|
426
938
|
console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
|
|
939
|
+
console.log(chalk4.gray(` Sources: ${sources.length} configured`));
|
|
427
940
|
}
|
|
428
941
|
const results = [];
|
|
942
|
+
const allSpecIds = [];
|
|
943
|
+
const specIdToModel = /* @__PURE__ */ new Map();
|
|
944
|
+
const lookupKeyMap = /* @__PURE__ */ new Map();
|
|
429
945
|
for (const model of models) {
|
|
430
946
|
const modelSpecs = getSpecsFromConfig(specs, model.id);
|
|
431
|
-
|
|
947
|
+
const modelLookupKeys = model.getLookupKeys?.();
|
|
432
948
|
for (const spec of modelSpecs) {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
continue;
|
|
949
|
+
const specId = spec.id;
|
|
950
|
+
allSpecIds.push(specId);
|
|
951
|
+
specIdToModel.set(specId, { modelId: model.id, spec });
|
|
952
|
+
if (modelLookupKeys) {
|
|
953
|
+
const overrides = {};
|
|
954
|
+
for (const [sourceType, mapper] of Object.entries(modelLookupKeys)) {
|
|
955
|
+
if (typeof mapper === "function") {
|
|
956
|
+
overrides[sourceType] = mapper(spec);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (Object.keys(overrides).length > 0) {
|
|
960
|
+
lookupKeyMap.set(specId, overrides);
|
|
961
|
+
}
|
|
447
962
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (sources.length > 0 && allSpecIds.length > 0) {
|
|
966
|
+
const filteredSources = checkType === "all" ? sources : sources.filter((s) => s.type === checkType);
|
|
967
|
+
const { matches, warnings: scanWarnings } = runGlobalScan(
|
|
968
|
+
filteredSources,
|
|
969
|
+
allSpecIds,
|
|
970
|
+
cwd,
|
|
971
|
+
lookupKeyMap.size > 0 ? lookupKeyMap : void 0
|
|
972
|
+
);
|
|
973
|
+
for (const sw of scanWarnings) {
|
|
974
|
+
results.push({
|
|
975
|
+
type: sw.sourceType,
|
|
976
|
+
success: true,
|
|
977
|
+
issues: [{
|
|
978
|
+
severity: "warning",
|
|
979
|
+
message: sw.message
|
|
980
|
+
}]
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
for (const [specId, entry] of specIdToModel) {
|
|
984
|
+
const specMatches = matches.get(specId);
|
|
985
|
+
if (specMatches && specMatches.length > 0) {
|
|
986
|
+
const model = models.find((m) => m.id === entry.modelId);
|
|
987
|
+
const deepValidation = model?.getDeepValidation?.();
|
|
988
|
+
if (deepValidation) {
|
|
989
|
+
const deepResult = runDeepValidation(specId, specMatches, deepValidation, entry.spec);
|
|
990
|
+
if (deepResult.errors.length > 0 || deepResult.warnings.length > 0) {
|
|
991
|
+
results.push({
|
|
992
|
+
type: entry.modelId,
|
|
993
|
+
success: deepResult.success,
|
|
994
|
+
issues: [
|
|
995
|
+
...deepResult.errors.map((e) => ({
|
|
996
|
+
severity: "error",
|
|
997
|
+
message: e.message,
|
|
998
|
+
specId: e.specId,
|
|
999
|
+
field: e.field
|
|
1000
|
+
})),
|
|
1001
|
+
...deepResult.warnings.map((w) => ({
|
|
1002
|
+
severity: "warning",
|
|
1003
|
+
message: w.message,
|
|
1004
|
+
specId: w.specId,
|
|
1005
|
+
field: w.field
|
|
1006
|
+
}))
|
|
1007
|
+
]
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (options.verbose) {
|
|
1014
|
+
for (const specId of allSpecIds) {
|
|
1015
|
+
if (!matches.has(specId)) {
|
|
1016
|
+
const entry = specIdToModel.get(specId);
|
|
1017
|
+
results.push({
|
|
1018
|
+
type: entry?.modelId ?? "unknown",
|
|
1019
|
+
success: true,
|
|
1020
|
+
issues: [{
|
|
1021
|
+
severity: "warning",
|
|
1022
|
+
message: `Spec ID "${specId}" not found in any configured source`,
|
|
1023
|
+
specId
|
|
1024
|
+
}]
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
459
1027
|
}
|
|
460
1028
|
}
|
|
461
1029
|
}
|
|
1030
|
+
runLegacyChecks(models, specs, cwd, results);
|
|
462
1031
|
outputCheckResults(results);
|
|
463
1032
|
if (options.coverage) {
|
|
464
1033
|
console.log("");
|
|
@@ -511,6 +1080,41 @@ function loadExternalData(filePath) {
|
|
|
511
1080
|
}
|
|
512
1081
|
return content;
|
|
513
1082
|
}
|
|
1083
|
+
function runLegacyChecks(models, specs, cwd, results) {
|
|
1084
|
+
for (const model of models) {
|
|
1085
|
+
const modelSpecs = getSpecsFromConfig(specs, model.id);
|
|
1086
|
+
if (modelSpecs.length === 0) continue;
|
|
1087
|
+
for (const spec of modelSpecs) {
|
|
1088
|
+
const sourcePath = model.getExternalSourcePath?.(spec);
|
|
1089
|
+
if (!sourcePath) continue;
|
|
1090
|
+
const fullPath = join(cwd, sourcePath);
|
|
1091
|
+
if (!existsSync(fullPath)) {
|
|
1092
|
+
results.push({
|
|
1093
|
+
type: model.id,
|
|
1094
|
+
success: false,
|
|
1095
|
+
issues: [{
|
|
1096
|
+
severity: "error",
|
|
1097
|
+
message: `External source not found: ${sourcePath}`,
|
|
1098
|
+
specId: spec.id
|
|
1099
|
+
}]
|
|
1100
|
+
});
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
const externalData = loadExternalData(fullPath);
|
|
1104
|
+
const checkResult = model.check(spec, externalData);
|
|
1105
|
+
if (!checkResult.success || checkResult.errors.length > 0 || checkResult.warnings.length > 0) {
|
|
1106
|
+
results.push({
|
|
1107
|
+
type: model.id,
|
|
1108
|
+
success: checkResult.success,
|
|
1109
|
+
issues: [
|
|
1110
|
+
...checkResult.errors.map((e) => ({ severity: "error", message: e.message, specId: e.specId, field: e.field })),
|
|
1111
|
+
...checkResult.warnings.map((w) => ({ severity: "warning", message: w.message, specId: w.specId, field: w.field }))
|
|
1112
|
+
]
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
514
1118
|
function outputCheckResults(results) {
|
|
515
1119
|
console.log("");
|
|
516
1120
|
if (results.length === 0) {
|
|
@@ -1224,9 +1828,6 @@ function resolveEdges(edges, nodes, speckeeperClassName) {
|
|
|
1224
1828
|
}
|
|
1225
1829
|
return { resolved, diagnostics };
|
|
1226
1830
|
}
|
|
1227
|
-
function isCheckEdge(entry) {
|
|
1228
|
-
return entry.category === "check";
|
|
1229
|
-
}
|
|
1230
1831
|
|
|
1231
1832
|
// src/scaffold/template-registry.ts
|
|
1232
1833
|
var LEVEL_PATTERNS = [
|
|
@@ -1270,56 +1871,11 @@ function toKebabCase(s) {
|
|
|
1270
1871
|
return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
|
|
1271
1872
|
}
|
|
1272
1873
|
|
|
1273
|
-
// src/scaffold/artifact-defaults.ts
|
|
1274
|
-
var SPECIALIZED_CHECKER_MAP = {
|
|
1275
|
-
openapi: "externalOpenAPIChecker",
|
|
1276
|
-
sqlschema: "externalSqlSchemaChecker"
|
|
1277
|
-
};
|
|
1278
|
-
function generateCheckerCode(bindings, modelName) {
|
|
1279
|
-
const dslImports = /* @__PURE__ */ new Set(["annotationChecker"]);
|
|
1280
|
-
for (const b of bindings) {
|
|
1281
|
-
const specialized = SPECIALIZED_CHECKER_MAP[b.targetClass];
|
|
1282
|
-
if (specialized) dslImports.add(specialized);
|
|
1283
|
-
}
|
|
1284
|
-
const checks = bindings.map((b) => {
|
|
1285
|
-
const specialized = SPECIALIZED_CHECKER_MAP[b.targetClass];
|
|
1286
|
-
const artifact = `'${b.targetClass}'`;
|
|
1287
|
-
const relationType = `'${b.edgeType}'`;
|
|
1288
|
-
if (specialized) {
|
|
1289
|
-
return `{ artifact: ${artifact}, relationType: ${relationType}, checker: ${specialized}() }`;
|
|
1290
|
-
}
|
|
1291
|
-
return `{ artifact: ${artifact}, relationType: ${relationType} }`;
|
|
1292
|
-
});
|
|
1293
|
-
let config;
|
|
1294
|
-
if (checks.length === 1) {
|
|
1295
|
-
config = checks[0];
|
|
1296
|
-
} else {
|
|
1297
|
-
config = `{
|
|
1298
|
-
checks: [
|
|
1299
|
-
${checks.join(",\n ")}
|
|
1300
|
-
],
|
|
1301
|
-
}`;
|
|
1302
|
-
}
|
|
1303
|
-
const code = `protected externalChecker = annotationChecker<${modelName}>(${config});`;
|
|
1304
|
-
return {
|
|
1305
|
-
code,
|
|
1306
|
-
dslImports: Array.from(dslImports)
|
|
1307
|
-
};
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
1874
|
// src/scaffold/templates/base.ts
|
|
1311
1875
|
function generateBaseModel(params) {
|
|
1312
1876
|
const schemaName = `${params.modelName}Schema`;
|
|
1313
1877
|
const className = `${params.modelName}Model`;
|
|
1314
|
-
const
|
|
1315
|
-
const checkerGen = hasCheckerBindings ? generateCheckerCode(params.checkerBindings, params.modelName) : null;
|
|
1316
|
-
const dslImportItems = ["requireField"];
|
|
1317
|
-
if (checkerGen) {
|
|
1318
|
-
dslImportItems.push(...checkerGen.dslImports);
|
|
1319
|
-
}
|
|
1320
|
-
const dslImportLine = `import { ${dslImportItems.join(", ")} } from 'speckeeper/dsl';`;
|
|
1321
|
-
const externalCheckerLine = checkerGen ? `
|
|
1322
|
-
${checkerGen.code}` : "";
|
|
1878
|
+
const dslImportLine = `import { requireField } from 'speckeeper/dsl';`;
|
|
1323
1879
|
return `/**
|
|
1324
1880
|
* ${params.modelName} Model Definition
|
|
1325
1881
|
*
|
|
@@ -1364,7 +1920,7 @@ class ${className} extends Model<typeof ${schemaName}> {
|
|
|
1364
1920
|
requireField<${params.modelName}>('description'),
|
|
1365
1921
|
];
|
|
1366
1922
|
|
|
1367
|
-
protected exporters: Exporter<${params.modelName}>[] = []
|
|
1923
|
+
protected exporters: Exporter<${params.modelName}>[] = [];
|
|
1368
1924
|
}
|
|
1369
1925
|
|
|
1370
1926
|
export { ${className} };
|
|
@@ -1377,34 +1933,15 @@ var MODEL_TEMPLATE_FUNCTIONS = {
|
|
|
1377
1933
|
};
|
|
1378
1934
|
|
|
1379
1935
|
// src/scaffold/model-generator.ts
|
|
1380
|
-
function
|
|
1381
|
-
const isSpk = (id) => nodes.get(id)?.classes.includes(speckeeperClassName) ?? false;
|
|
1382
|
-
const bindings = [];
|
|
1383
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1384
|
-
for (const edge of outgoingEdges) {
|
|
1385
|
-
if (!isCheckEdge(edge.vocabulary)) continue;
|
|
1386
|
-
if (isSpk(edge.targetId)) continue;
|
|
1387
|
-
if (seen.has(edge.targetId)) continue;
|
|
1388
|
-
seen.add(edge.targetId);
|
|
1389
|
-
const targetNode = nodes.get(edge.targetId);
|
|
1390
|
-
const targetClass = targetNode?.classes.find((c) => c !== speckeeperClassName) ?? edge.targetId.toLowerCase();
|
|
1391
|
-
const edgeType = edge.normalizedLabel === "verifiedBy" ? "verifiedBy" : "implements";
|
|
1392
|
-
bindings.push({ edgeType, targetNodeId: edge.targetId, targetClass });
|
|
1393
|
-
}
|
|
1394
|
-
return bindings;
|
|
1395
|
-
}
|
|
1396
|
-
function generateModelFile(node, _incomingEdges, outgoingEdges, allNodes) {
|
|
1936
|
+
function generateModelFile(node, _incomingEdges, _outgoingEdges, _allNodes) {
|
|
1397
1937
|
const templateInfo = resolveModelTemplate(node.id, node.classes, node.subgraph);
|
|
1398
1938
|
const templateFn = MODEL_TEMPLATE_FUNCTIONS[templateInfo.templateName];
|
|
1399
|
-
const bindings = allNodes ? resolveCheckerBindings(node.id, outgoingEdges, allNodes, "speckeeper") : [];
|
|
1400
|
-
const checkerBindings = bindings.length > 0 ? bindings.map((b) => ({ edgeType: b.edgeType, targetClass: b.targetClass })) : void 0;
|
|
1401
1939
|
const params = {
|
|
1402
1940
|
modelId: templateInfo.fileName,
|
|
1403
1941
|
modelName: templateInfo.modelName,
|
|
1404
1942
|
idPrefix: templateInfo.defaultIdPrefix,
|
|
1405
1943
|
level: templateInfo.defaultLevel,
|
|
1406
|
-
description: node.label ?? node.id
|
|
1407
|
-
checkerBindings
|
|
1944
|
+
description: node.label ?? node.id
|
|
1408
1945
|
};
|
|
1409
1946
|
const content = !templateFn ? MODEL_TEMPLATE_FUNCTIONS["base"](params) : templateFn(params);
|
|
1410
1947
|
return {
|
|
@@ -1492,9 +2029,9 @@ function generateAllModelFiles(speckeeperNodes, resolvedEdges, allNodes) {
|
|
|
1492
2029
|
generated.add(key);
|
|
1493
2030
|
const groupNodes = templateNodeGroups.get(key) ?? [node];
|
|
1494
2031
|
const groupNodeIds = new Set(groupNodes.map((n) => n.id));
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
files.push(generateModelFile(node
|
|
2032
|
+
resolvedEdges.filter((e) => groupNodeIds.has(e.targetId));
|
|
2033
|
+
resolvedEdges.filter((e) => groupNodeIds.has(e.sourceId));
|
|
2034
|
+
files.push(generateModelFile(node));
|
|
1498
2035
|
files.push(generateSpecDataFile(node));
|
|
1499
2036
|
}
|
|
1500
2037
|
files.push(generateDesignIndex(speckeeperNodes));
|