speckeeper 0.8.1 → 0.9.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.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,507 @@ 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) {
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 allFiles = /* @__PURE__ */ new Set();
744
+ for (const pattern of source.paths) {
745
+ const found = glob.sync(pattern, {
746
+ cwd,
747
+ ignore: source.exclude ?? []
748
+ });
749
+ for (const f of found) {
750
+ allFiles.add(f);
751
+ }
752
+ }
753
+ for (const relPath of allFiles) {
754
+ const fullPath = isAbsolute(relPath) ? relPath : join(cwd, relPath);
755
+ if (!existsSync(fullPath)) continue;
756
+ let content;
757
+ try {
758
+ content = loadFileContent(fullPath, source.type);
759
+ } catch (e) {
760
+ const msg = e instanceof Error ? e.message : String(e);
761
+ warnings.push({
762
+ message: `Failed to load ${relPath}: ${msg}`,
763
+ sourceType: source.type,
764
+ filePath: relPath
765
+ });
766
+ continue;
767
+ }
768
+ if (content == null) continue;
769
+ let matches;
770
+ try {
771
+ matches = scanner.findSpecIds(content, specIds, relPath);
772
+ } catch (e) {
773
+ const msg = e instanceof Error ? e.message : String(e);
774
+ warnings.push({
775
+ message: `Scanner error for ${relPath}: ${msg}`,
776
+ sourceType: source.type,
777
+ filePath: relPath
778
+ });
779
+ continue;
780
+ }
781
+ for (const match of matches) {
782
+ const globalMatch = {
783
+ ...match,
784
+ sourceType: source.type,
785
+ relation: source.relation,
786
+ filePath: relPath
787
+ };
788
+ const existing = result.get(match.specId);
789
+ if (existing) {
790
+ existing.push(globalMatch);
791
+ } else {
792
+ result.set(match.specId, [globalMatch]);
793
+ }
794
+ }
795
+ }
796
+ }
797
+ return { matches: result, warnings };
798
+ }
799
+ function runDeepValidation(specId, matches, deepValidation, spec) {
800
+ const errors = [];
801
+ const warnings = [];
802
+ for (const match of matches) {
803
+ const rule = deepValidation[match.sourceType];
804
+ if (!rule) continue;
805
+ if (match.sourceType === "openapi") {
806
+ const mapping = rule.mapper(spec);
807
+ const ctx = match.context;
808
+ if (!ctx?.match?.operation) continue;
809
+ validateOpenAPIDeep(specId, mapping, ctx.doc, ctx.match, warnings);
810
+ } else if (match.sourceType === "ddl") {
811
+ const mapping = rule.mapper(spec);
812
+ const ctx = match.context;
813
+ if (!ctx?.table) continue;
814
+ validateDDLDeep(specId, mapping, ctx.table, warnings);
815
+ }
816
+ }
817
+ return {
818
+ success: errors.length === 0,
819
+ errors,
820
+ warnings
821
+ };
822
+ }
823
+ function validateOpenAPIDeep(specId, mapping, doc, match, warnings) {
824
+ if (mapping.method && match.operation) {
825
+ const expectedMethod = mapping.method.toLowerCase();
826
+ const actualMethod = match.method?.toLowerCase();
827
+ if (actualMethod && actualMethod !== expectedMethod) {
828
+ warnings.push({
829
+ message: `Method mismatch for "${specId}": expected ${expectedMethod.toUpperCase()}, found ${actualMethod.toUpperCase()}`,
830
+ specId
831
+ });
832
+ }
833
+ }
834
+ if (match.operation && mapping.parameters) {
835
+ const opParams = (match.operation.parameters || []).map(
836
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
837
+ (p) => maybeResolveRef(doc, p)
838
+ );
839
+ for (const expectedParam of mapping.parameters) {
840
+ const found = opParams.find(
841
+ (p) => p?.name === expectedParam.name && (!expectedParam.in || p?.in === expectedParam.in)
842
+ );
843
+ if (!found) {
844
+ warnings.push({
845
+ message: `Parameter "${expectedParam.name}" not found in operation "${specId}"`,
846
+ specId,
847
+ field: expectedParam.name
848
+ });
849
+ } else if (expectedParam.type && found.schema?.type) {
850
+ if (!isTypeContainedBy(expectedParam.type, found.schema.type)) {
851
+ warnings.push({
852
+ message: `Parameter "${expectedParam.name}" type mismatch: expected ${expectedParam.type}, found ${found.schema.type}`,
853
+ specId,
854
+ field: expectedParam.name
855
+ });
856
+ }
857
+ }
858
+ }
859
+ }
860
+ if (match.operation && mapping.responseProperties) {
861
+ const successResponse = match.operation.responses?.["200"] || match.operation.responses?.["201"];
862
+ let responseSchema = successResponse?.content?.["application/json"]?.schema;
863
+ responseSchema = maybeResolveRef(doc, responseSchema);
864
+ if (responseSchema?.type === "array") {
865
+ responseSchema = maybeResolveRef(doc, responseSchema.items);
866
+ }
867
+ const responseProps = responseSchema?.properties || {};
868
+ for (const expectedProp of mapping.responseProperties) {
869
+ const foundProp = responseProps[expectedProp.name];
870
+ if (!foundProp) {
871
+ warnings.push({
872
+ message: `Response property "${expectedProp.name}" not found in operation "${specId}"`,
873
+ specId,
874
+ field: expectedProp.name
875
+ });
876
+ } else if (expectedProp.type && foundProp.type) {
877
+ if (!isTypeContainedBy(expectedProp.type, foundProp.type)) {
878
+ warnings.push({
879
+ message: `Response property "${expectedProp.name}" type mismatch: expected ${expectedProp.type}, found ${foundProp.type}`,
880
+ specId,
881
+ field: expectedProp.name
882
+ });
883
+ }
884
+ }
885
+ }
886
+ }
887
+ }
888
+ function validateDDLDeep(specId, mapping, table, warnings) {
889
+ if (!mapping.columns) return;
890
+ for (const expectedCol of mapping.columns) {
891
+ const foundCol = table.columns.find(
892
+ (c) => c.name.toLowerCase() === expectedCol.name.toLowerCase()
893
+ );
894
+ if (!foundCol) {
895
+ warnings.push({
896
+ message: `Column "${expectedCol.name}" not found in table "${mapping.tableName}"`,
897
+ specId,
898
+ field: expectedCol.name
899
+ });
900
+ continue;
901
+ }
902
+ if (mapping.checkTypes && expectedCol.type && foundCol.type) {
903
+ if (!isTypeContainedBy(expectedCol.type, foundCol.type)) {
904
+ warnings.push({
905
+ message: `Column "${expectedCol.name}" type mismatch in table "${mapping.tableName}": expected ${expectedCol.type}, found ${foundCol.type}`,
906
+ specId,
907
+ field: expectedCol.name
908
+ });
909
+ }
910
+ }
911
+ }
912
+ }
913
+
914
+ // src/cli/check.ts
413
915
  async function checkCommand(type, options) {
414
916
  console.log(chalk4.blue("speckeeper check"));
415
917
  console.log("");
@@ -422,43 +924,83 @@ async function checkCommand(type, options) {
422
924
  try {
423
925
  const models = config.models || [];
424
926
  const specs = config.specs;
927
+ const sources = config.sources ?? [];
425
928
  if (options.verbose) {
426
929
  console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
930
+ console.log(chalk4.gray(` Sources: ${sources.length} configured`));
427
931
  }
428
932
  const results = [];
933
+ const allSpecIds = [];
934
+ const specIdToModel = /* @__PURE__ */ new Map();
429
935
  for (const model of models) {
430
936
  const modelSpecs = getSpecsFromConfig(specs, model.id);
431
- if (modelSpecs.length === 0) continue;
432
937
  for (const spec of modelSpecs) {
433
- const sourcePath = model.getExternalSourcePath(spec);
434
- if (!sourcePath) continue;
435
- const fullPath = join(cwd, sourcePath);
436
- if (!existsSync(fullPath)) {
437
- results.push({
438
- type: model.id,
439
- success: false,
440
- issues: [{
441
- severity: "error",
442
- message: `External source not found: ${sourcePath}`,
443
- specId: spec.id
444
- }]
445
- });
446
- continue;
938
+ const specId = spec.id;
939
+ allSpecIds.push(specId);
940
+ specIdToModel.set(specId, { modelId: model.id, spec });
941
+ }
942
+ }
943
+ if (sources.length > 0 && allSpecIds.length > 0) {
944
+ const filteredSources = checkType === "all" ? sources : sources.filter((s) => s.type === checkType);
945
+ const { matches, warnings: scanWarnings } = runGlobalScan(filteredSources, allSpecIds, cwd);
946
+ for (const sw of scanWarnings) {
947
+ results.push({
948
+ type: sw.sourceType,
949
+ success: true,
950
+ issues: [{
951
+ severity: "warning",
952
+ message: sw.message
953
+ }]
954
+ });
955
+ }
956
+ for (const [specId, entry] of specIdToModel) {
957
+ const specMatches = matches.get(specId);
958
+ if (specMatches && specMatches.length > 0) {
959
+ const model = models.find((m) => m.id === entry.modelId);
960
+ const deepValidation = model?.getDeepValidation?.();
961
+ if (deepValidation) {
962
+ const deepResult = runDeepValidation(specId, specMatches, deepValidation, entry.spec);
963
+ if (deepResult.errors.length > 0 || deepResult.warnings.length > 0) {
964
+ results.push({
965
+ type: entry.modelId,
966
+ success: deepResult.success,
967
+ issues: [
968
+ ...deepResult.errors.map((e) => ({
969
+ severity: "error",
970
+ message: e.message,
971
+ specId: e.specId,
972
+ field: e.field
973
+ })),
974
+ ...deepResult.warnings.map((w) => ({
975
+ severity: "warning",
976
+ message: w.message,
977
+ specId: w.specId,
978
+ field: w.field
979
+ }))
980
+ ]
981
+ });
982
+ }
983
+ }
447
984
  }
448
- const externalData = loadExternalData(fullPath);
449
- const checkResult = model.check(spec, externalData);
450
- if (!checkResult.success || checkResult.errors.length > 0 || checkResult.warnings.length > 0) {
451
- results.push({
452
- type: model.id,
453
- success: checkResult.success,
454
- issues: [
455
- ...checkResult.errors.map((e) => ({ severity: "error", message: e.message, specId: e.specId, field: e.field })),
456
- ...checkResult.warnings.map((w) => ({ severity: "warning", message: w.message, specId: w.specId, field: w.field }))
457
- ]
458
- });
985
+ }
986
+ if (options.verbose) {
987
+ for (const specId of allSpecIds) {
988
+ if (!matches.has(specId)) {
989
+ const entry = specIdToModel.get(specId);
990
+ results.push({
991
+ type: entry?.modelId ?? "unknown",
992
+ success: true,
993
+ issues: [{
994
+ severity: "warning",
995
+ message: `Spec ID "${specId}" not found in any configured source`,
996
+ specId
997
+ }]
998
+ });
999
+ }
459
1000
  }
460
1001
  }
461
1002
  }
1003
+ runLegacyChecks(models, specs, cwd, results);
462
1004
  outputCheckResults(results);
463
1005
  if (options.coverage) {
464
1006
  console.log("");
@@ -511,6 +1053,41 @@ function loadExternalData(filePath) {
511
1053
  }
512
1054
  return content;
513
1055
  }
1056
+ function runLegacyChecks(models, specs, cwd, results) {
1057
+ for (const model of models) {
1058
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
1059
+ if (modelSpecs.length === 0) continue;
1060
+ for (const spec of modelSpecs) {
1061
+ const sourcePath = model.getExternalSourcePath?.(spec);
1062
+ if (!sourcePath) continue;
1063
+ const fullPath = join(cwd, sourcePath);
1064
+ if (!existsSync(fullPath)) {
1065
+ results.push({
1066
+ type: model.id,
1067
+ success: false,
1068
+ issues: [{
1069
+ severity: "error",
1070
+ message: `External source not found: ${sourcePath}`,
1071
+ specId: spec.id
1072
+ }]
1073
+ });
1074
+ continue;
1075
+ }
1076
+ const externalData = loadExternalData(fullPath);
1077
+ const checkResult = model.check(spec, externalData);
1078
+ if (!checkResult.success || checkResult.errors.length > 0 || checkResult.warnings.length > 0) {
1079
+ results.push({
1080
+ type: model.id,
1081
+ success: checkResult.success,
1082
+ issues: [
1083
+ ...checkResult.errors.map((e) => ({ severity: "error", message: e.message, specId: e.specId, field: e.field })),
1084
+ ...checkResult.warnings.map((w) => ({ severity: "warning", message: w.message, specId: w.specId, field: w.field }))
1085
+ ]
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+ }
514
1091
  function outputCheckResults(results) {
515
1092
  console.log("");
516
1093
  if (results.length === 0) {
@@ -1224,9 +1801,6 @@ function resolveEdges(edges, nodes, speckeeperClassName) {
1224
1801
  }
1225
1802
  return { resolved, diagnostics };
1226
1803
  }
1227
- function isCheckEdge(entry) {
1228
- return entry.category === "check";
1229
- }
1230
1804
 
1231
1805
  // src/scaffold/template-registry.ts
1232
1806
  var LEVEL_PATTERNS = [
@@ -1270,56 +1844,11 @@ function toKebabCase(s) {
1270
1844
  return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
1271
1845
  }
1272
1846
 
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
1847
  // src/scaffold/templates/base.ts
1311
1848
  function generateBaseModel(params) {
1312
1849
  const schemaName = `${params.modelName}Schema`;
1313
1850
  const className = `${params.modelName}Model`;
1314
- const hasCheckerBindings = params.checkerBindings && params.checkerBindings.length > 0;
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}` : "";
1851
+ const dslImportLine = `import { requireField } from 'speckeeper/dsl';`;
1323
1852
  return `/**
1324
1853
  * ${params.modelName} Model Definition
1325
1854
  *
@@ -1364,7 +1893,7 @@ class ${className} extends Model<typeof ${schemaName}> {
1364
1893
  requireField<${params.modelName}>('description'),
1365
1894
  ];
1366
1895
 
1367
- protected exporters: Exporter<${params.modelName}>[] = [];${externalCheckerLine}
1896
+ protected exporters: Exporter<${params.modelName}>[] = [];
1368
1897
  }
1369
1898
 
1370
1899
  export { ${className} };
@@ -1377,34 +1906,15 @@ var MODEL_TEMPLATE_FUNCTIONS = {
1377
1906
  };
1378
1907
 
1379
1908
  // src/scaffold/model-generator.ts
1380
- function resolveCheckerBindings(_nodeId, outgoingEdges, nodes, speckeeperClassName) {
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) {
1909
+ function generateModelFile(node, _incomingEdges, _outgoingEdges, _allNodes) {
1397
1910
  const templateInfo = resolveModelTemplate(node.id, node.classes, node.subgraph);
1398
1911
  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
1912
  const params = {
1402
1913
  modelId: templateInfo.fileName,
1403
1914
  modelName: templateInfo.modelName,
1404
1915
  idPrefix: templateInfo.defaultIdPrefix,
1405
1916
  level: templateInfo.defaultLevel,
1406
- description: node.label ?? node.id,
1407
- checkerBindings
1917
+ description: node.label ?? node.id
1408
1918
  };
1409
1919
  const content = !templateFn ? MODEL_TEMPLATE_FUNCTIONS["base"](params) : templateFn(params);
1410
1920
  return {
@@ -1492,9 +2002,9 @@ function generateAllModelFiles(speckeeperNodes, resolvedEdges, allNodes) {
1492
2002
  generated.add(key);
1493
2003
  const groupNodes = templateNodeGroups.get(key) ?? [node];
1494
2004
  const groupNodeIds = new Set(groupNodes.map((n) => n.id));
1495
- const incoming = resolvedEdges.filter((e) => groupNodeIds.has(e.targetId));
1496
- const outgoing = resolvedEdges.filter((e) => groupNodeIds.has(e.sourceId));
1497
- files.push(generateModelFile(node, incoming, outgoing, allNodes));
2005
+ resolvedEdges.filter((e) => groupNodeIds.has(e.targetId));
2006
+ resolvedEdges.filter((e) => groupNodeIds.has(e.sourceId));
2007
+ files.push(generateModelFile(node));
1498
2008
  files.push(generateSpecDataFile(node));
1499
2009
  }
1500
2010
  files.push(generateDesignIndex(speckeeperNodes));