@valbuild/cli 0.99.1 → 0.100.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.
@@ -500,6 +500,51 @@ async function handleCheckAllFiles(ctx) {
500
500
  shouldApplyPatch: true
501
501
  };
502
502
  }
503
+ async function handleJsonValuesExtractEntry(ctx) {
504
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
505
+ const parts = core.Internal.splitModulePath(modulePath);
506
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
507
+ // direct child of the module's root record/router, so the path is one segment.
508
+ if (parts.length !== 1) {
509
+ return {
510
+ success: false,
511
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
512
+ };
513
+ }
514
+ const entryKey = parts[0];
515
+ const source = ctx.valModule.source;
516
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
517
+ return {
518
+ success: false,
519
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`
520
+ };
521
+ }
522
+ const content = source[entryKey];
523
+ if (content === undefined) {
524
+ return {
525
+ success: false,
526
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
527
+ };
528
+ }
529
+ if (!ctx.fix) {
530
+ return {
531
+ success: true,
532
+ fixableErrorMessage: ctx.validationError.message
533
+ };
534
+ }
535
+ try {
536
+ server.extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
537
+ } catch (err) {
538
+ return {
539
+ success: false,
540
+ errorMessage: err instanceof Error ? err.message : String(err)
541
+ };
542
+ }
543
+ return {
544
+ success: true,
545
+ appliedFix: true
546
+ };
547
+ }
503
548
 
504
549
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
505
550
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
@@ -522,7 +567,8 @@ const currentFixHandlers = {
522
567
  "images:check-unique-folder": handleUniqueFolderCheck,
523
568
  "files:check-unique-folder": handleUniqueFolderCheck,
524
569
  "images:check-all-files": handleCheckAllFiles,
525
- "files:check-all-files": handleCheckAllFiles
570
+ "files:check-all-files": handleCheckAllFiles,
571
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry
526
572
  };
527
573
  const deprecatedFixHandlers = {
528
574
  "image:replace-metadata": handleFileMetadata
@@ -696,6 +742,26 @@ async function* runValidation({
696
742
  fileErrors += 1;
697
743
  continue;
698
744
  }
745
+ if (result.appliedFix) {
746
+ fixedErrors += 1;
747
+ yield {
748
+ type: "fix-applied",
749
+ file,
750
+ sourcePath
751
+ };
752
+ }
753
+ if (result.fixableErrorMessage !== undefined) {
754
+ fileErrors += 1;
755
+ yield {
756
+ type: "validation-fixable-error",
757
+ sourcePath,
758
+ message: result.fixableErrorMessage,
759
+ fixable: true,
760
+ ...(v.keyError ? {
761
+ keyError: true
762
+ } : {})
763
+ };
764
+ }
699
765
 
700
766
  // Apply patch if needed
701
767
  if (result.shouldApplyPatch) {
@@ -855,19 +921,34 @@ function resolveRange(sourcePath, projectRoot, cache, target) {
855
921
  return undefined;
856
922
  }
857
923
  const range = server.getModulePathRange(modulePath, cached.map, target);
858
- if (!range) {
859
- return undefined;
924
+ if (range) {
925
+ return {
926
+ relativeFile: moduleFilePath.replace(/^\//, ""),
927
+ lines: cached.lines,
928
+ range: clampRange(range)
929
+ };
860
930
  }
931
+ // Nothing in the `.val.ts` matches — which is the normal case for a
932
+ // `.jsonValues()` entry, since the value lives in its own `*.val.json` and the
933
+ // `.val.ts` only holds the `c.json(() => import(...))` thunk. Follow it.
934
+ const entryResolved = resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cached);
935
+ // Clamped here too: an entry's range comes from the same resolver, run against
936
+ // the entry's `*.val.json`, so it can be just as bad.
937
+ return entryResolved && {
938
+ ...entryResolved,
939
+ range: clampRange(entryResolved.range)
940
+ };
941
+ }
942
+
943
+ /**
944
+ * Defensive: a bad range must degrade to an odd-looking frame, never a crash —
945
+ * the caret math in the code frame does `" ".repeat(start.character)`, which
946
+ * throws a RangeError on a negative count.
947
+ */
948
+ function clampRange(range) {
861
949
  return {
862
- relativeFile: moduleFilePath.replace(/^\//, ""),
863
- lines: cached.lines,
864
- // Defensive: a bad range must degrade to an odd-looking frame, never a
865
- // crash - the caret math below does `" ".repeat(start.character)`, which
866
- // throws a RangeError on a negative count.
867
- range: {
868
- start: clampPosition(range.start),
869
- end: clampPosition(range.end)
870
- }
950
+ start: clampPosition(range.start),
951
+ end: clampPosition(range.end)
871
952
  };
872
953
  }
873
954
  function clampPosition(position) {
@@ -876,6 +957,89 @@ function clampPosition(position) {
876
957
  character: Math.max(0, position.character)
877
958
  };
878
959
  }
960
+
961
+ /**
962
+ * Resolves the part of a source path BELOW a `.jsonValues()` entry key against
963
+ * the entry's backing `*.val.json`.
964
+ *
965
+ * Without this a validation error in an entry has no location at all: for a
966
+ * record with hundreds of entries the report would name the module and the rule
967
+ * but not which entry, which is barely a report.
968
+ */
969
+ function resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cachedModule) {
970
+ if (!modulePath || !cachedModule.sourceFile) {
971
+ return undefined;
972
+ }
973
+ let segments;
974
+ try {
975
+ segments = core.Internal.splitModulePath(modulePath);
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ const [entryKey, ...rest] = segments;
980
+ if (entryKey === undefined) {
981
+ return undefined;
982
+ }
983
+ const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, cachedModule.sourceFile, entryKey);
984
+ if (!jsonFilePath) {
985
+ return undefined;
986
+ }
987
+ const cachedJson = getCachedJsonFile(jsonFilePath, projectRoot, cache);
988
+ if (!cachedJson) {
989
+ return undefined;
990
+ }
991
+ const relativeFile = jsonFilePath.replace(/^\//, "");
992
+ if (rest.length === 0) {
993
+ // The error is on the entry as a whole (e.g. it failed to load): point at the
994
+ // top of its file rather than nowhere.
995
+ return {
996
+ relativeFile,
997
+ lines: cachedJson.lines,
998
+ range: {
999
+ start: {
1000
+ line: 0,
1001
+ character: 0
1002
+ },
1003
+ end: {
1004
+ line: 0,
1005
+ character: 0
1006
+ }
1007
+ }
1008
+ };
1009
+ }
1010
+ if (!cachedJson.map) {
1011
+ return undefined;
1012
+ }
1013
+ const range = server.getModulePathRange(rest.map(segment => JSON.stringify(segment)).join("."), cachedJson.map, target);
1014
+ if (!range) {
1015
+ return undefined;
1016
+ }
1017
+ return {
1018
+ relativeFile,
1019
+ lines: cachedJson.lines,
1020
+ range
1021
+ };
1022
+ }
1023
+ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
1024
+ const existing = cache.get(jsonFilePath);
1025
+ if (existing !== undefined) {
1026
+ return existing;
1027
+ }
1028
+ const filePath = path__default["default"].join(projectRoot, jsonFilePath);
1029
+ let fileContent;
1030
+ try {
1031
+ fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
1032
+ } catch {
1033
+ cache.set(jsonFilePath, null);
1034
+ return null;
1035
+ }
1036
+ const entry = {
1037
+ lines: fileContent.split(/\r?\n/),
1038
+ map: server.createJsonEntryPathMap(ts__default["default"].parseJsonText(filePath, fileContent))
1039
+ };
1040
+ cache.set(jsonFilePath, entry);
1041
+ return entry;
1042
+ }
879
1043
  function getCachedFile(moduleFilePath, projectRoot, cache) {
880
1044
  const existing = cache.get(moduleFilePath);
881
1045
  if (existing !== undefined) {
@@ -892,7 +1056,8 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
892
1056
  const sourceFile = ts__default["default"].createSourceFile(filePath, fileContent, ts__default["default"].ScriptTarget.ES2015);
893
1057
  const entry = {
894
1058
  lines: fileContent.split(/\r?\n/),
895
- map: server.createModulePathMap(sourceFile)
1059
+ map: server.createModulePathMap(sourceFile),
1060
+ sourceFile
896
1061
  };
897
1062
  cache.set(moduleFilePath, entry);
898
1063
  return entry;
@@ -500,6 +500,51 @@ async function handleCheckAllFiles(ctx) {
500
500
  shouldApplyPatch: true
501
501
  };
502
502
  }
503
+ async function handleJsonValuesExtractEntry(ctx) {
504
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
505
+ const parts = core.Internal.splitModulePath(modulePath);
506
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
507
+ // direct child of the module's root record/router, so the path is one segment.
508
+ if (parts.length !== 1) {
509
+ return {
510
+ success: false,
511
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
512
+ };
513
+ }
514
+ const entryKey = parts[0];
515
+ const source = ctx.valModule.source;
516
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
517
+ return {
518
+ success: false,
519
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`
520
+ };
521
+ }
522
+ const content = source[entryKey];
523
+ if (content === undefined) {
524
+ return {
525
+ success: false,
526
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
527
+ };
528
+ }
529
+ if (!ctx.fix) {
530
+ return {
531
+ success: true,
532
+ fixableErrorMessage: ctx.validationError.message
533
+ };
534
+ }
535
+ try {
536
+ server.extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
537
+ } catch (err) {
538
+ return {
539
+ success: false,
540
+ errorMessage: err instanceof Error ? err.message : String(err)
541
+ };
542
+ }
543
+ return {
544
+ success: true,
545
+ appliedFix: true
546
+ };
547
+ }
503
548
 
504
549
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
505
550
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
@@ -522,7 +567,8 @@ const currentFixHandlers = {
522
567
  "images:check-unique-folder": handleUniqueFolderCheck,
523
568
  "files:check-unique-folder": handleUniqueFolderCheck,
524
569
  "images:check-all-files": handleCheckAllFiles,
525
- "files:check-all-files": handleCheckAllFiles
570
+ "files:check-all-files": handleCheckAllFiles,
571
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry
526
572
  };
527
573
  const deprecatedFixHandlers = {
528
574
  "image:replace-metadata": handleFileMetadata
@@ -696,6 +742,26 @@ async function* runValidation({
696
742
  fileErrors += 1;
697
743
  continue;
698
744
  }
745
+ if (result.appliedFix) {
746
+ fixedErrors += 1;
747
+ yield {
748
+ type: "fix-applied",
749
+ file,
750
+ sourcePath
751
+ };
752
+ }
753
+ if (result.fixableErrorMessage !== undefined) {
754
+ fileErrors += 1;
755
+ yield {
756
+ type: "validation-fixable-error",
757
+ sourcePath,
758
+ message: result.fixableErrorMessage,
759
+ fixable: true,
760
+ ...(v.keyError ? {
761
+ keyError: true
762
+ } : {})
763
+ };
764
+ }
699
765
 
700
766
  // Apply patch if needed
701
767
  if (result.shouldApplyPatch) {
@@ -855,19 +921,34 @@ function resolveRange(sourcePath, projectRoot, cache, target) {
855
921
  return undefined;
856
922
  }
857
923
  const range = server.getModulePathRange(modulePath, cached.map, target);
858
- if (!range) {
859
- return undefined;
924
+ if (range) {
925
+ return {
926
+ relativeFile: moduleFilePath.replace(/^\//, ""),
927
+ lines: cached.lines,
928
+ range: clampRange(range)
929
+ };
860
930
  }
931
+ // Nothing in the `.val.ts` matches — which is the normal case for a
932
+ // `.jsonValues()` entry, since the value lives in its own `*.val.json` and the
933
+ // `.val.ts` only holds the `c.json(() => import(...))` thunk. Follow it.
934
+ const entryResolved = resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cached);
935
+ // Clamped here too: an entry's range comes from the same resolver, run against
936
+ // the entry's `*.val.json`, so it can be just as bad.
937
+ return entryResolved && {
938
+ ...entryResolved,
939
+ range: clampRange(entryResolved.range)
940
+ };
941
+ }
942
+
943
+ /**
944
+ * Defensive: a bad range must degrade to an odd-looking frame, never a crash —
945
+ * the caret math in the code frame does `" ".repeat(start.character)`, which
946
+ * throws a RangeError on a negative count.
947
+ */
948
+ function clampRange(range) {
861
949
  return {
862
- relativeFile: moduleFilePath.replace(/^\//, ""),
863
- lines: cached.lines,
864
- // Defensive: a bad range must degrade to an odd-looking frame, never a
865
- // crash - the caret math below does `" ".repeat(start.character)`, which
866
- // throws a RangeError on a negative count.
867
- range: {
868
- start: clampPosition(range.start),
869
- end: clampPosition(range.end)
870
- }
950
+ start: clampPosition(range.start),
951
+ end: clampPosition(range.end)
871
952
  };
872
953
  }
873
954
  function clampPosition(position) {
@@ -876,6 +957,89 @@ function clampPosition(position) {
876
957
  character: Math.max(0, position.character)
877
958
  };
878
959
  }
960
+
961
+ /**
962
+ * Resolves the part of a source path BELOW a `.jsonValues()` entry key against
963
+ * the entry's backing `*.val.json`.
964
+ *
965
+ * Without this a validation error in an entry has no location at all: for a
966
+ * record with hundreds of entries the report would name the module and the rule
967
+ * but not which entry, which is barely a report.
968
+ */
969
+ function resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cachedModule) {
970
+ if (!modulePath || !cachedModule.sourceFile) {
971
+ return undefined;
972
+ }
973
+ let segments;
974
+ try {
975
+ segments = core.Internal.splitModulePath(modulePath);
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ const [entryKey, ...rest] = segments;
980
+ if (entryKey === undefined) {
981
+ return undefined;
982
+ }
983
+ const jsonFilePath = server.findJsonEntryFilePath(moduleFilePath, cachedModule.sourceFile, entryKey);
984
+ if (!jsonFilePath) {
985
+ return undefined;
986
+ }
987
+ const cachedJson = getCachedJsonFile(jsonFilePath, projectRoot, cache);
988
+ if (!cachedJson) {
989
+ return undefined;
990
+ }
991
+ const relativeFile = jsonFilePath.replace(/^\//, "");
992
+ if (rest.length === 0) {
993
+ // The error is on the entry as a whole (e.g. it failed to load): point at the
994
+ // top of its file rather than nowhere.
995
+ return {
996
+ relativeFile,
997
+ lines: cachedJson.lines,
998
+ range: {
999
+ start: {
1000
+ line: 0,
1001
+ character: 0
1002
+ },
1003
+ end: {
1004
+ line: 0,
1005
+ character: 0
1006
+ }
1007
+ }
1008
+ };
1009
+ }
1010
+ if (!cachedJson.map) {
1011
+ return undefined;
1012
+ }
1013
+ const range = server.getModulePathRange(rest.map(segment => JSON.stringify(segment)).join("."), cachedJson.map, target);
1014
+ if (!range) {
1015
+ return undefined;
1016
+ }
1017
+ return {
1018
+ relativeFile,
1019
+ lines: cachedJson.lines,
1020
+ range
1021
+ };
1022
+ }
1023
+ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
1024
+ const existing = cache.get(jsonFilePath);
1025
+ if (existing !== undefined) {
1026
+ return existing;
1027
+ }
1028
+ const filePath = path__default["default"].join(projectRoot, jsonFilePath);
1029
+ let fileContent;
1030
+ try {
1031
+ fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
1032
+ } catch {
1033
+ cache.set(jsonFilePath, null);
1034
+ return null;
1035
+ }
1036
+ const entry = {
1037
+ lines: fileContent.split(/\r?\n/),
1038
+ map: server.createJsonEntryPathMap(ts__default["default"].parseJsonText(filePath, fileContent))
1039
+ };
1040
+ cache.set(jsonFilePath, entry);
1041
+ return entry;
1042
+ }
879
1043
  function getCachedFile(moduleFilePath, projectRoot, cache) {
880
1044
  const existing = cache.get(moduleFilePath);
881
1045
  if (existing !== undefined) {
@@ -892,7 +1056,8 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
892
1056
  const sourceFile = ts__default["default"].createSourceFile(filePath, fileContent, ts__default["default"].ScriptTarget.ES2015);
893
1057
  const entry = {
894
1058
  lines: fileContent.split(/\r?\n/),
895
- map: server.createModulePathMap(sourceFile)
1059
+ map: server.createModulePathMap(sourceFile),
1060
+ sourceFile
896
1061
  };
897
1062
  cache.set(moduleFilePath, entry);
898
1063
  return entry;
@@ -5,7 +5,7 @@ import pc from 'picocolors';
5
5
  import fs$1 from 'fs/promises';
6
6
  import { glob } from 'fast-glob';
7
7
  import { Internal, FILE_REF_PROP, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST, VAL_EXTENSION } from '@valbuild/core';
8
- import { createService, createFixPatch, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getModulePathRange, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, formatPatchSourceError } from '@valbuild/server';
8
+ import { createService, createFixPatch, extractJsonValuesEntry, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, formatPatchSourceError } from '@valbuild/server';
9
9
  import { resolveSchemaSourceFixes } from '@valbuild/shared/internal';
10
10
  import ts from 'typescript';
11
11
  import fs from 'fs';
@@ -468,6 +468,51 @@ async function handleCheckAllFiles(ctx) {
468
468
  shouldApplyPatch: true
469
469
  };
470
470
  }
471
+ async function handleJsonValuesExtractEntry(ctx) {
472
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
473
+ const parts = Internal.splitModulePath(modulePath);
474
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
475
+ // direct child of the module's root record/router, so the path is one segment.
476
+ if (parts.length !== 1) {
477
+ return {
478
+ success: false,
479
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
480
+ };
481
+ }
482
+ const entryKey = parts[0];
483
+ const source = ctx.valModule.source;
484
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
485
+ return {
486
+ success: false,
487
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`
488
+ };
489
+ }
490
+ const content = source[entryKey];
491
+ if (content === undefined) {
492
+ return {
493
+ success: false,
494
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
495
+ };
496
+ }
497
+ if (!ctx.fix) {
498
+ return {
499
+ success: true,
500
+ fixableErrorMessage: ctx.validationError.message
501
+ };
502
+ }
503
+ try {
504
+ extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
505
+ } catch (err) {
506
+ return {
507
+ success: false,
508
+ errorMessage: err instanceof Error ? err.message : String(err)
509
+ };
510
+ }
511
+ return {
512
+ success: true,
513
+ appliedFix: true
514
+ };
515
+ }
471
516
 
472
517
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
473
518
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
@@ -490,7 +535,8 @@ const currentFixHandlers = {
490
535
  "images:check-unique-folder": handleUniqueFolderCheck,
491
536
  "files:check-unique-folder": handleUniqueFolderCheck,
492
537
  "images:check-all-files": handleCheckAllFiles,
493
- "files:check-all-files": handleCheckAllFiles
538
+ "files:check-all-files": handleCheckAllFiles,
539
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry
494
540
  };
495
541
  const deprecatedFixHandlers = {
496
542
  "image:replace-metadata": handleFileMetadata
@@ -664,6 +710,26 @@ async function* runValidation({
664
710
  fileErrors += 1;
665
711
  continue;
666
712
  }
713
+ if (result.appliedFix) {
714
+ fixedErrors += 1;
715
+ yield {
716
+ type: "fix-applied",
717
+ file,
718
+ sourcePath
719
+ };
720
+ }
721
+ if (result.fixableErrorMessage !== undefined) {
722
+ fileErrors += 1;
723
+ yield {
724
+ type: "validation-fixable-error",
725
+ sourcePath,
726
+ message: result.fixableErrorMessage,
727
+ fixable: true,
728
+ ...(v.keyError ? {
729
+ keyError: true
730
+ } : {})
731
+ };
732
+ }
667
733
 
668
734
  // Apply patch if needed
669
735
  if (result.shouldApplyPatch) {
@@ -823,19 +889,34 @@ function resolveRange(sourcePath, projectRoot, cache, target) {
823
889
  return undefined;
824
890
  }
825
891
  const range = getModulePathRange(modulePath, cached.map, target);
826
- if (!range) {
827
- return undefined;
892
+ if (range) {
893
+ return {
894
+ relativeFile: moduleFilePath.replace(/^\//, ""),
895
+ lines: cached.lines,
896
+ range: clampRange(range)
897
+ };
828
898
  }
899
+ // Nothing in the `.val.ts` matches — which is the normal case for a
900
+ // `.jsonValues()` entry, since the value lives in its own `*.val.json` and the
901
+ // `.val.ts` only holds the `c.json(() => import(...))` thunk. Follow it.
902
+ const entryResolved = resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cached);
903
+ // Clamped here too: an entry's range comes from the same resolver, run against
904
+ // the entry's `*.val.json`, so it can be just as bad.
905
+ return entryResolved && {
906
+ ...entryResolved,
907
+ range: clampRange(entryResolved.range)
908
+ };
909
+ }
910
+
911
+ /**
912
+ * Defensive: a bad range must degrade to an odd-looking frame, never a crash —
913
+ * the caret math in the code frame does `" ".repeat(start.character)`, which
914
+ * throws a RangeError on a negative count.
915
+ */
916
+ function clampRange(range) {
829
917
  return {
830
- relativeFile: moduleFilePath.replace(/^\//, ""),
831
- lines: cached.lines,
832
- // Defensive: a bad range must degrade to an odd-looking frame, never a
833
- // crash - the caret math below does `" ".repeat(start.character)`, which
834
- // throws a RangeError on a negative count.
835
- range: {
836
- start: clampPosition(range.start),
837
- end: clampPosition(range.end)
838
- }
918
+ start: clampPosition(range.start),
919
+ end: clampPosition(range.end)
839
920
  };
840
921
  }
841
922
  function clampPosition(position) {
@@ -844,6 +925,89 @@ function clampPosition(position) {
844
925
  character: Math.max(0, position.character)
845
926
  };
846
927
  }
928
+
929
+ /**
930
+ * Resolves the part of a source path BELOW a `.jsonValues()` entry key against
931
+ * the entry's backing `*.val.json`.
932
+ *
933
+ * Without this a validation error in an entry has no location at all: for a
934
+ * record with hundreds of entries the report would name the module and the rule
935
+ * but not which entry, which is barely a report.
936
+ */
937
+ function resolveJsonEntryRange(moduleFilePath, modulePath, projectRoot, cache, target, cachedModule) {
938
+ if (!modulePath || !cachedModule.sourceFile) {
939
+ return undefined;
940
+ }
941
+ let segments;
942
+ try {
943
+ segments = Internal.splitModulePath(modulePath);
944
+ } catch {
945
+ return undefined;
946
+ }
947
+ const [entryKey, ...rest] = segments;
948
+ if (entryKey === undefined) {
949
+ return undefined;
950
+ }
951
+ const jsonFilePath = findJsonEntryFilePath(moduleFilePath, cachedModule.sourceFile, entryKey);
952
+ if (!jsonFilePath) {
953
+ return undefined;
954
+ }
955
+ const cachedJson = getCachedJsonFile(jsonFilePath, projectRoot, cache);
956
+ if (!cachedJson) {
957
+ return undefined;
958
+ }
959
+ const relativeFile = jsonFilePath.replace(/^\//, "");
960
+ if (rest.length === 0) {
961
+ // The error is on the entry as a whole (e.g. it failed to load): point at the
962
+ // top of its file rather than nowhere.
963
+ return {
964
+ relativeFile,
965
+ lines: cachedJson.lines,
966
+ range: {
967
+ start: {
968
+ line: 0,
969
+ character: 0
970
+ },
971
+ end: {
972
+ line: 0,
973
+ character: 0
974
+ }
975
+ }
976
+ };
977
+ }
978
+ if (!cachedJson.map) {
979
+ return undefined;
980
+ }
981
+ const range = getModulePathRange(rest.map(segment => JSON.stringify(segment)).join("."), cachedJson.map, target);
982
+ if (!range) {
983
+ return undefined;
984
+ }
985
+ return {
986
+ relativeFile,
987
+ lines: cachedJson.lines,
988
+ range
989
+ };
990
+ }
991
+ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
992
+ const existing = cache.get(jsonFilePath);
993
+ if (existing !== undefined) {
994
+ return existing;
995
+ }
996
+ const filePath = path.join(projectRoot, jsonFilePath);
997
+ let fileContent;
998
+ try {
999
+ fileContent = fs.readFileSync(filePath, "utf-8");
1000
+ } catch {
1001
+ cache.set(jsonFilePath, null);
1002
+ return null;
1003
+ }
1004
+ const entry = {
1005
+ lines: fileContent.split(/\r?\n/),
1006
+ map: createJsonEntryPathMap(ts.parseJsonText(filePath, fileContent))
1007
+ };
1008
+ cache.set(jsonFilePath, entry);
1009
+ return entry;
1010
+ }
847
1011
  function getCachedFile(moduleFilePath, projectRoot, cache) {
848
1012
  const existing = cache.get(moduleFilePath);
849
1013
  if (existing !== undefined) {
@@ -860,7 +1024,8 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
860
1024
  const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.ES2015);
861
1025
  const entry = {
862
1026
  lines: fileContent.split(/\r?\n/),
863
- map: createModulePathMap(sourceFile)
1027
+ map: createModulePathMap(sourceFile),
1028
+ sourceFile
864
1029
  };
865
1030
  cache.set(moduleFilePath, entry);
866
1031
  return entry;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.99.1",
4
+ "version": "0.100.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,11 +27,11 @@
27
27
  "open": "^9.1.0",
28
28
  "picocolors": "^1.1.1",
29
29
  "zod": "^4.3.5",
30
- "@valbuild/core": "0.99.1",
30
+ "@valbuild/core": "0.100.0",
31
31
  "@valbuild/eslint-plugin": "0.93.0",
32
- "@valbuild/language-server": "0.99.1",
33
- "@valbuild/server": "0.99.1",
34
- "@valbuild/shared": "0.99.1"
32
+ "@valbuild/language-server": "0.100.0",
33
+ "@valbuild/server": "0.100.0",
34
+ "@valbuild/shared": "0.100.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "prettier": "*",
@@ -0,0 +1,18 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ /**
4
+ * A `.jsonValues()` record with an inline entry whose value ALSO fails the item
5
+ * schema at the entry's own path: the string is shorter than the minimum.
6
+ *
7
+ * Two validations report at the same source path — the record-level one (which
8
+ * checks the inline value against the item schema) and the jsonValues one (which
9
+ * reports the inlining). Both have to survive being merged into the module's
10
+ * validation errors.
11
+ */
12
+ export default c.define(
13
+ "/content/basic-inline-json-values-invalid.val.ts",
14
+ s.record(s.string().minLength(5)).jsonValues(),
15
+ {
16
+ "/inline": "ab",
17
+ },
18
+ );
@@ -0,0 +1,16 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ /**
4
+ * A `.jsonValues()` record with one entry hand-authored INLINE instead of
5
+ * `c.json(() => import(...))`. The types allow it on purpose (a type error there
6
+ * is a dead end for the author); validation reports it as
7
+ * `jsonValues:extract-entry` and `--fix` moves it into its own `*.val.json`.
8
+ */
9
+ export default c.define(
10
+ "/content/basic-inline-json-values.val.ts",
11
+ s.record(s.object({ title: s.string(), order: s.number() })).jsonValues(),
12
+ {
13
+ "/ok": c.json(() => import("./json-entries/ok.val.json")),
14
+ "/inline": { title: "Written inline", order: 3 },
15
+ },
16
+ );
@@ -0,0 +1,18 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ /**
4
+ * A `.jsonValues()` record whose entry content lives in separate files. The
5
+ * point of the fixture: the `.val.ts` itself is always structurally valid — the
6
+ * only thing that can be wrong is INSIDE an entry, which is exactly what
7
+ * `val validate` used to miss.
8
+ */
9
+ export default c.define(
10
+ "/content/basic-json-values.val.ts",
11
+ s
12
+ .record(s.object({ title: s.string().minLength(2), order: s.number() }))
13
+ .jsonValues(),
14
+ {
15
+ "/ok": c.json(() => import("./json-entries/ok.val.json")),
16
+ "/broken": c.json(() => import("./json-entries/broken.val.json")),
17
+ },
18
+ );
@@ -0,0 +1,13 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ /**
4
+ * Rejected at validation time: `.jsonValues()` is root-only, and a nested one
5
+ * would silently get no content validation at all.
6
+ */
7
+ export default c.define(
8
+ "/content/basic-nested-json-values.val.ts",
9
+ s.object({
10
+ nested: s.record(s.object({ title: s.string() })).jsonValues(),
11
+ }),
12
+ { nested: {} },
13
+ );
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "x",
3
+ "order": 2
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "A valid entry",
3
+ "order": 1
4
+ }
@@ -15,4 +15,8 @@ export default modules(config, [
15
15
  { def: () => import("./content/basic-gallery-wrong-metadata.val") },
16
16
  { def: () => import("./content/basic-gallery-remote.val") },
17
17
  { def: () => import("./content/basic-gallery-remote-existing.val") },
18
+ { def: () => import("./content/basic-json-values.val") },
19
+ { def: () => import("./content/basic-nested-json-values.val") },
20
+ { def: () => import("./content/basic-inline-json-values.val") },
21
+ { def: () => import("./content/basic-inline-json-values-invalid.val") },
18
22
  ]);
@@ -484,4 +484,176 @@ describe("runValidation", () => {
484
484
  expect(events.at(-1)).toEqual({ type: "summary-success" });
485
485
  expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0);
486
486
  });
487
+
488
+ describe("jsonValues", () => {
489
+ const runOn = async (valFiles: string[]) => {
490
+ const events: ValidationEvent[] = [];
491
+ for await (const event of runValidation({
492
+ root: tmpDir,
493
+ fix: false,
494
+ valFiles,
495
+ project: undefined,
496
+ remote: mockRemote,
497
+ fs: createDefaultValFSHost(),
498
+ })) {
499
+ events.push(event);
500
+ }
501
+ return events;
502
+ };
503
+
504
+ test("validates entry CONTENT, which lives outside the .val.ts", async () => {
505
+ // Regression: `val validate` reported a jsonValues module as valid no matter
506
+ // what its entries contained. The record-level schema only asserts the
507
+ // marker shape — deep validation is deferred to whoever loads the entry, and
508
+ // the CLI never did. Any project gating CI on `val validate` was blind to it.
509
+ //
510
+ // The violation here is a VALUE one (minLength), like the other fixtures in
511
+ // this directory: with `resolveJsonModule` on, tsc catches type-level
512
+ // mistakes in a hand-authored entry by itself — constraints are what only
513
+ // validation can see.
514
+ const events = await runOn(["content/basic-json-values.val.ts"]);
515
+ const errors = events.filter((e) => e.type === "validation-error");
516
+
517
+ // The path names the ENTRY, not just the module: for a record with
518
+ // hundreds of entries, "something in here is wrong" is not a report.
519
+ expect(errors).toEqual(
520
+ expect.arrayContaining([
521
+ expect.objectContaining({
522
+ sourcePath: '/content/basic-json-values.val.ts?p="/broken"."title"',
523
+ message: expect.stringContaining("2 characters"),
524
+ }),
525
+ ]),
526
+ );
527
+ // ...and the entry that IS valid produces nothing.
528
+ expect(
529
+ errors.filter(
530
+ (e) => "sourcePath" in e && e.sourcePath.includes('"/ok"'),
531
+ ),
532
+ ).toHaveLength(0);
533
+ });
534
+
535
+ test("reports an inlined entry as a fixable error", async () => {
536
+ // The types accept an inline entry (see JsonValuesRecordSrc), so validation
537
+ // is the thing that has to catch it — otherwise a hand-authored entry
538
+ // quietly stays in the `.val.ts`, where the Studio cannot edit it and the
539
+ // lazy-loading the record opted into does not apply.
540
+ const events = await runOn(["content/basic-inline-json-values.val.ts"]);
541
+
542
+ expect(events).toEqual(
543
+ expect.arrayContaining([
544
+ expect.objectContaining({
545
+ type: "validation-fixable-error",
546
+ sourcePath: '/content/basic-inline-json-values.val.ts?p="/inline"',
547
+ fixable: true,
548
+ message: expect.stringContaining("written inline"),
549
+ }),
550
+ ]),
551
+ );
552
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
553
+ });
554
+
555
+ test("--fix moves an inlined entry into its own *.val.json", async () => {
556
+ const events: ValidationEvent[] = [];
557
+ for await (const event of runValidation({
558
+ root: tmpDir,
559
+ fix: true,
560
+ valFiles: ["content/basic-inline-json-values.val.ts"],
561
+ project: undefined,
562
+ remote: mockRemote,
563
+ fs: createDefaultValFSHost(),
564
+ })) {
565
+ events.push(event);
566
+ }
567
+ expect(events).toEqual(
568
+ expect.arrayContaining([
569
+ expect.objectContaining({
570
+ type: "fix-applied",
571
+ sourcePath: '/content/basic-inline-json-values.val.ts?p="/inline"',
572
+ }),
573
+ ]),
574
+ );
575
+
576
+ // The content moved to the conventional path for the key...
577
+ const jsonPath = path.join(
578
+ tmpDir,
579
+ "content/basic-inline-json-values/inline.val.json",
580
+ );
581
+ expect(JSON.parse(fs.readFileSync(jsonPath, "utf8"))).toEqual({
582
+ title: "Written inline",
583
+ order: 3,
584
+ });
585
+ // ...and the module now references it lazily, like every other entry.
586
+ const valTs = fs.readFileSync(
587
+ path.join(tmpDir, "content/basic-inline-json-values.val.ts"),
588
+ "utf8",
589
+ );
590
+ expect(valTs).toContain(
591
+ 'c.json(() => import("./basic-inline-json-values/inline.val.json"))',
592
+ );
593
+ expect(valTs).not.toContain("Written inline");
594
+
595
+ // Re-validating the fixed project is clean: the fix is not just silencing
596
+ // the error, it produces a module that loads and validates.
597
+ const afterFix: ValidationEvent[] = [];
598
+ for await (const event of runValidation({
599
+ root: tmpDir,
600
+ fix: false,
601
+ valFiles: ["content/basic-inline-json-values.val.ts"],
602
+ project: undefined,
603
+ remote: mockRemote,
604
+ fs: createDefaultValFSHost(),
605
+ })) {
606
+ afterFix.push(event);
607
+ }
608
+ expect(afterFix.at(-1)).toEqual({ type: "summary-success" });
609
+ });
610
+
611
+ test("keeps the item-schema error alongside the inline-entry error", async () => {
612
+ // Both validations report at the SAME source path: the record-level one
613
+ // checks the inline value against the item schema, and the jsonValues one
614
+ // reports the inlining. Merging them with a spread drops one of the two,
615
+ // so the author fixes the inlining and only then learns the value was
616
+ // never valid.
617
+ const events = await runOn([
618
+ "content/basic-inline-json-values-invalid.val.ts",
619
+ ]);
620
+
621
+ const sourcePath =
622
+ '/content/basic-inline-json-values-invalid.val.ts?p="/inline"';
623
+ expect(events).toEqual(
624
+ expect.arrayContaining([
625
+ expect.objectContaining({
626
+ type: "validation-fixable-error",
627
+ sourcePath,
628
+ message: expect.stringContaining("written inline"),
629
+ }),
630
+ expect.objectContaining({
631
+ type: "validation-error",
632
+ sourcePath,
633
+ message: expect.stringContaining("at least 5"),
634
+ }),
635
+ ]),
636
+ );
637
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 2 });
638
+ });
639
+
640
+ test("rejects a nested .jsonValues() instead of reporting it valid", async () => {
641
+ // Root-only is a hard contract: a nested one would silently get NO content
642
+ // validation. The Studio refuses to load such a project, so the CLI saying
643
+ // "valid" was the two entry points disagreeing.
644
+ const events = await runOn(["content/basic-nested-json-values.val.ts"]);
645
+
646
+ expect(events).toEqual(
647
+ expect.arrayContaining([
648
+ expect.objectContaining({
649
+ type: "fatal-error",
650
+ message: expect.stringContaining(
651
+ "Nested .jsonValues() records are not supported",
652
+ ),
653
+ }),
654
+ ]),
655
+ );
656
+ expect(events.at(-1)).not.toEqual({ type: "summary-success" });
657
+ });
658
+ });
487
659
  });
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import {
3
3
  createFixPatch,
4
4
  createService,
5
+ extractJsonValuesEntry,
5
6
  getPersonalAccessTokenPath,
6
7
  parsePersonalAccessTokenFile,
7
8
  Service,
@@ -93,6 +94,12 @@ export type FixHandlerResult = {
93
94
  success: boolean;
94
95
  errorMessage?: string;
95
96
  shouldApplyPatch?: boolean;
97
+ // The handler repaired the source itself (it could not be expressed as a
98
+ // patch, so `shouldApplyPatch` does not apply): count it as fixed.
99
+ appliedFix?: boolean;
100
+ // The handler did nothing because `--fix` was off, but the error IS fixable:
101
+ // report it as such instead of as a plain validation error.
102
+ fixableErrorMessage?: string;
96
103
  // Updated shared state
97
104
  publicProjectId?: string;
98
105
  remoteFileBuckets?: string[];
@@ -670,6 +677,59 @@ export async function handleCheckAllFiles(
670
677
  return { success: true, shouldApplyPatch: true };
671
678
  }
672
679
 
680
+ export async function handleJsonValuesExtractEntry(
681
+ ctx: FixHandlerContext,
682
+ ): Promise<FixHandlerResult> {
683
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
684
+ ctx.sourcePath,
685
+ );
686
+ const parts = Internal.splitModulePath(modulePath);
687
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
688
+ // direct child of the module's root record/router, so the path is one segment.
689
+ if (parts.length !== 1) {
690
+ return {
691
+ success: false,
692
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`,
693
+ };
694
+ }
695
+ const entryKey = parts[0];
696
+ const source = ctx.valModule.source;
697
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
698
+ return {
699
+ success: false,
700
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`,
701
+ };
702
+ }
703
+ const content = (source as Record<string, Json>)[entryKey];
704
+ if (content === undefined) {
705
+ return {
706
+ success: false,
707
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`,
708
+ };
709
+ }
710
+ if (!ctx.fix) {
711
+ return {
712
+ success: true,
713
+ fixableErrorMessage: ctx.validationError.message,
714
+ };
715
+ }
716
+ try {
717
+ extractJsonValuesEntry(
718
+ ctx.moduleFilePath,
719
+ ctx.projectRoot,
720
+ entryKey,
721
+ content,
722
+ ctx.service.sourceFileHandler,
723
+ );
724
+ } catch (err) {
725
+ return {
726
+ success: false,
727
+ errorMessage: err instanceof Error ? err.message : String(err),
728
+ };
729
+ }
730
+ return { success: true, appliedFix: true };
731
+ }
732
+
673
733
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
674
734
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
675
735
  // this registry, so they're excluded from the key set.
@@ -695,6 +755,7 @@ export const currentFixHandlers: Record<
695
755
  "files:check-unique-folder": handleUniqueFolderCheck,
696
756
  "images:check-all-files": handleCheckAllFiles,
697
757
  "files:check-all-files": handleCheckAllFiles,
758
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry,
698
759
  };
699
760
  const deprecatedFixHandlers: Record<string, FixHandler> = {
700
761
  "image:replace-metadata": handleFileMetadata,
@@ -881,6 +942,22 @@ export async function* runValidation({
881
942
  continue;
882
943
  }
883
944
 
945
+ if (result.appliedFix) {
946
+ fixedErrors += 1;
947
+ yield { type: "fix-applied", file, sourcePath };
948
+ }
949
+
950
+ if (result.fixableErrorMessage !== undefined) {
951
+ fileErrors += 1;
952
+ yield {
953
+ type: "validation-fixable-error",
954
+ sourcePath,
955
+ message: result.fixableErrorMessage,
956
+ fixable: true,
957
+ ...(v.keyError ? { keyError: true } : {}),
958
+ };
959
+ }
960
+
884
961
  // Apply patch if needed
885
962
  if (result.shouldApplyPatch) {
886
963
  const fixPatch = await createFixPatch(
@@ -2,14 +2,21 @@ import path from "path";
2
2
  import nodeFs from "fs";
3
3
  import ts from "typescript";
4
4
  import picocolors from "picocolors";
5
- import { Internal, type SourcePath } from "@valbuild/core";
5
+ import { Internal, type ModulePath, type SourcePath } from "@valbuild/core";
6
6
  import {
7
+ createJsonEntryPathMap,
7
8
  createModulePathMap,
9
+ findJsonEntryFilePath,
8
10
  getModulePathRange,
9
11
  type ModulePathMap,
10
12
  } from "@valbuild/server";
11
13
 
12
- type CachedFile = { lines: string[]; map: ModulePathMap | undefined };
14
+ type CachedFile = {
15
+ lines: string[];
16
+ map: ModulePathMap | undefined;
17
+ /** The parsed `.val.ts`, kept so a jsonValues entry's file can be located. */
18
+ sourceFile?: ts.SourceFile;
19
+ };
13
20
 
14
21
  /**
15
22
  * Per-run cache mapping a moduleFilePath to its parsed source. `null` means the
@@ -129,21 +136,41 @@ function resolveRange(
129
136
  }
130
137
 
131
138
  const range = getModulePathRange(modulePath, cached.map, target);
132
- if (!range) {
133
- return undefined;
139
+ if (range) {
140
+ return {
141
+ relativeFile: moduleFilePath.replace(/^\//, ""),
142
+ lines: cached.lines,
143
+ range: clampRange(range),
144
+ };
134
145
  }
146
+ // Nothing in the `.val.ts` matches — which is the normal case for a
147
+ // `.jsonValues()` entry, since the value lives in its own `*.val.json` and the
148
+ // `.val.ts` only holds the `c.json(() => import(...))` thunk. Follow it.
149
+ const entryResolved = resolveJsonEntryRange(
150
+ moduleFilePath,
151
+ modulePath,
152
+ projectRoot,
153
+ cache,
154
+ target,
155
+ cached,
156
+ );
157
+ // Clamped here too: an entry's range comes from the same resolver, run against
158
+ // the entry's `*.val.json`, so it can be just as bad.
159
+ return (
160
+ entryResolved && {
161
+ ...entryResolved,
162
+ range: clampRange(entryResolved.range),
163
+ }
164
+ );
165
+ }
135
166
 
136
- return {
137
- relativeFile: moduleFilePath.replace(/^\//, ""),
138
- lines: cached.lines,
139
- // Defensive: a bad range must degrade to an odd-looking frame, never a
140
- // crash - the caret math below does `" ".repeat(start.character)`, which
141
- // throws a RangeError on a negative count.
142
- range: {
143
- start: clampPosition(range.start),
144
- end: clampPosition(range.end),
145
- },
146
- };
167
+ /**
168
+ * Defensive: a bad range must degrade to an odd-looking frame, never a crash —
169
+ * the caret math in the code frame does `" ".repeat(start.character)`, which
170
+ * throws a RangeError on a negative count.
171
+ */
172
+ function clampRange(range: Range): Range {
173
+ return { start: clampPosition(range.start), end: clampPosition(range.end) };
147
174
  }
148
175
 
149
176
  function clampPosition(position: Position): Position {
@@ -153,6 +180,99 @@ function clampPosition(position: Position): Position {
153
180
  };
154
181
  }
155
182
 
183
+ /**
184
+ * Resolves the part of a source path BELOW a `.jsonValues()` entry key against
185
+ * the entry's backing `*.val.json`.
186
+ *
187
+ * Without this a validation error in an entry has no location at all: for a
188
+ * record with hundreds of entries the report would name the module and the rule
189
+ * but not which entry, which is barely a report.
190
+ */
191
+ function resolveJsonEntryRange(
192
+ moduleFilePath: string,
193
+ modulePath: string,
194
+ projectRoot: string,
195
+ cache: SourceFileCache,
196
+ target: "key" | "value",
197
+ cachedModule: CachedFile,
198
+ ): { relativeFile: string; lines: string[]; range: Range } | undefined {
199
+ if (!modulePath || !cachedModule.sourceFile) {
200
+ return undefined;
201
+ }
202
+ let segments: string[];
203
+ try {
204
+ segments = Internal.splitModulePath(modulePath as ModulePath);
205
+ } catch {
206
+ return undefined;
207
+ }
208
+ const [entryKey, ...rest] = segments;
209
+ if (entryKey === undefined) {
210
+ return undefined;
211
+ }
212
+ const jsonFilePath = findJsonEntryFilePath(
213
+ moduleFilePath,
214
+ cachedModule.sourceFile,
215
+ entryKey,
216
+ );
217
+ if (!jsonFilePath) {
218
+ return undefined;
219
+ }
220
+ const cachedJson = getCachedJsonFile(jsonFilePath, projectRoot, cache);
221
+ if (!cachedJson) {
222
+ return undefined;
223
+ }
224
+ const relativeFile = jsonFilePath.replace(/^\//, "");
225
+ if (rest.length === 0) {
226
+ // The error is on the entry as a whole (e.g. it failed to load): point at the
227
+ // top of its file rather than nowhere.
228
+ return {
229
+ relativeFile,
230
+ lines: cachedJson.lines,
231
+ range: {
232
+ start: { line: 0, character: 0 },
233
+ end: { line: 0, character: 0 },
234
+ },
235
+ };
236
+ }
237
+ if (!cachedJson.map) {
238
+ return undefined;
239
+ }
240
+ const range = getModulePathRange(
241
+ rest.map((segment) => JSON.stringify(segment)).join("."),
242
+ cachedJson.map,
243
+ target,
244
+ );
245
+ if (!range) {
246
+ return undefined;
247
+ }
248
+ return { relativeFile, lines: cachedJson.lines, range };
249
+ }
250
+
251
+ function getCachedJsonFile(
252
+ jsonFilePath: string,
253
+ projectRoot: string,
254
+ cache: SourceFileCache,
255
+ ): CachedFile | null {
256
+ const existing = cache.get(jsonFilePath);
257
+ if (existing !== undefined) {
258
+ return existing;
259
+ }
260
+ const filePath = path.join(projectRoot, jsonFilePath);
261
+ let fileContent: string;
262
+ try {
263
+ fileContent = nodeFs.readFileSync(filePath, "utf-8");
264
+ } catch {
265
+ cache.set(jsonFilePath, null);
266
+ return null;
267
+ }
268
+ const entry: CachedFile = {
269
+ lines: fileContent.split(/\r?\n/),
270
+ map: createJsonEntryPathMap(ts.parseJsonText(filePath, fileContent)),
271
+ };
272
+ cache.set(jsonFilePath, entry);
273
+ return entry;
274
+ }
275
+
156
276
  function getCachedFile(
157
277
  moduleFilePath: string,
158
278
  projectRoot: string,
@@ -178,6 +298,7 @@ function getCachedFile(
178
298
  const entry: CachedFile = {
179
299
  lines: fileContent.split(/\r?\n/),
180
300
  map: createModulePathMap(sourceFile),
301
+ sourceFile,
181
302
  };
182
303
  cache.set(moduleFilePath, entry);
183
304
  return entry;
package/tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "module": "esnext",
8
8
  "moduleResolution": "node",
9
9
  "skipLibCheck": true,
10
- "target": "ESNext"
10
+ "target": "ESNext",
11
+ "resolveJsonModule": true
11
12
  }
12
13
  }