@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.
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +178 -13
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +178 -13
- package/cli/dist/valbuild-cli-cli.esm.js +179 -14
- package/package.json +5 -5
- package/src/__fixtures__/basic/content/basic-inline-json-values-invalid.val.ts +18 -0
- package/src/__fixtures__/basic/content/basic-inline-json-values.val.ts +16 -0
- package/src/__fixtures__/basic/content/basic-json-values.val.ts +18 -0
- package/src/__fixtures__/basic/content/basic-nested-json-values.val.ts +13 -0
- package/src/__fixtures__/basic/content/json-entries/broken.val.json +4 -0
- package/src/__fixtures__/basic/content/json-entries/ok.val.json +4 -0
- package/src/__fixtures__/basic/val.modules.ts +4 -0
- package/src/runValidation.test.ts +172 -0
- package/src/runValidation.ts +77 -0
- package/src/utils/sourcePathToFileLocation.ts +136 -15
- package/tsconfig.json +2 -1
|
@@ -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 (
|
|
859
|
-
return
|
|
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
|
-
|
|
863
|
-
|
|
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 (
|
|
859
|
-
return
|
|
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
|
-
|
|
863
|
-
|
|
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 (
|
|
827
|
-
return
|
|
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
|
-
|
|
831
|
-
|
|
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.
|
|
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.
|
|
30
|
+
"@valbuild/core": "0.100.0",
|
|
31
31
|
"@valbuild/eslint-plugin": "0.93.0",
|
|
32
|
-
"@valbuild/language-server": "0.
|
|
33
|
-
"@valbuild/server": "0.
|
|
34
|
-
"@valbuild/shared": "0.
|
|
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
|
+
);
|
|
@@ -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
|
});
|
package/src/runValidation.ts
CHANGED
|
@@ -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 = {
|
|
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 (
|
|
133
|
-
return
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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;
|