@valbuild/language-server 0.116.0 → 0.117.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import ts from 'typescript';
4
4
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
5
- import { fixHandlers, createService, analyzeValModule, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, createFixPatch, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
5
+ import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, extractImageMetadata, extractFileMetadata } from '@valbuild/server';
6
6
  import { DiagnosticSeverity, ShowDocumentRequest, ApplyWorkspaceEditRequest, CodeActionKind, CodeAction, MarkupContent, CompletionItemKind, DidChangeWatchedFilesNotification } from 'vscode-languageserver';
7
7
  import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures } from 'vscode-languageserver/node';
8
8
  import { TextDocument } from 'vscode-languageserver-textdocument';
@@ -846,6 +846,314 @@ function traverseObjectLiteral(node, sourceFile) {
846
846
  return map;
847
847
  }
848
848
 
849
+ /**
850
+ * Adjudicating the media metadata placeholders core emits unconditionally.
851
+ *
852
+ * `ImageSchema.validate` cannot read bytes, so it never answers "does the
853
+ * stored width/height/mimeType match the file". It defers instead: any
854
+ * `s.image()` carrying metadata gets an `image:check-metadata` error whether
855
+ * or not anything is wrong (`packages/core/src/schema/image.ts`), and
856
+ * `s.file()` does the same with `file:check-metadata`.
857
+ *
858
+ * `val validate` resolves those by running the fix handler and then
859
+ * `createFixPatch` in report mode, which compares each field against the file
860
+ * and returns one error per field that disagrees. The Studio cannot do that at
861
+ * all -- a browser has no filesystem -- so it drops them wholesale
862
+ * (`partitionValidationErrors` in `@valbuild/shared`).
863
+ *
864
+ * An editor is in the CLI's position, not the browser's, and publishing the
865
+ * placeholders raw put a permanent warning on every image in the project. So
866
+ * they are adjudicated here, by the same comparison `val validate` makes, and
867
+ * only a real disagreement is shown.
868
+ */
869
+ /**
870
+ * The fixes that carry an unconditional metadata placeholder.
871
+ *
872
+ * `*:add-metadata` is deliberately absent: it is emitted only when every
873
+ * metadata field is missing, which needs no adjudication -- there is nothing
874
+ * stored to compare, and the error stands on its own.
875
+ */
876
+ const METADATA_CHECK_FIXES = ["image:check-metadata", "file:check-metadata"];
877
+
878
+ /** One field of a media value that disagrees with the file behind it. */
879
+
880
+ /**
881
+ * The verdict on one placeholder. Empty means the metadata agrees with the
882
+ * file and nothing should be published.
883
+ */
884
+
885
+ /**
886
+ * Whether this error is the unconditional deferral rather than a real finding.
887
+ *
888
+ * This is the whole difficulty of the change. `image:check-metadata` is
889
+ * attached to FIVE different image errors, and only the last is a placeholder:
890
+ *
891
+ * 1. `Invalid mime type format` -- `accept` set, mimeType has no `/`
892
+ * 2. `Mime type mismatch` -- `accept` set and not satisfied
893
+ * 3. `Could not determine mime type from file extension`
894
+ * 4. `Mime type and file extension not matching`
895
+ * 5. the fall-through deferral
896
+ *
897
+ * The first four are real: they are about the mime type disagreeing with the
898
+ * schema or with the filename, neither of which reading the file can settle.
899
+ * Adjudicating one of them would find the stored metadata matches the bytes and
900
+ * drop a genuine error -- so they have to be told apart before any file is read.
901
+ *
902
+ * There is no flag on `ValidationError` saying which is which, so the four
903
+ * conditions are re-derived here from the same inputs core used. They are
904
+ * transcribed from `ImageSchema.executeValidate`, in its order, and
905
+ * `mediaMetadataChecks.test.ts` drives real core through all five cases to
906
+ * check that this agrees with it. If core grows a sixth condition, that test is
907
+ * what catches it.
908
+ *
909
+ * `FileSchema` needs none of this: its four equivalent errors carry no `fixes`
910
+ * at all, so `file:check-metadata` is unambiguous. The test pins that too.
911
+ */
912
+ function isDeferredMediaMetadataCheck({
913
+ error,
914
+ schema
915
+ }) {
916
+ const fixes = error.fixes ?? [];
917
+ if (!fixes.some(fix => METADATA_CHECK_FIXES.includes(fix))) {
918
+ return false;
919
+ }
920
+ if (fixes.includes("file:check-metadata")) {
921
+ return true;
922
+ }
923
+ const value = mediaValueOf(error.value);
924
+ if (typeof value?.path !== "string") {
925
+ // Nothing to re-derive the conditions from. Keep the error: claiming an
926
+ // image is fine on no evidence is the worse failure.
927
+ return false;
928
+ }
929
+ const accept = acceptOf(schema);
930
+ // Core reads `src.mimeType ?? ""`, and every condition below is guarded on
931
+ // it being non-empty, so an absent mimeType passes all four.
932
+ const mimeType = typeof value.mimeType === "string" ? value.mimeType : "";
933
+ if (accept && mimeType && !mimeType.includes("/")) {
934
+ return false;
935
+ }
936
+ if (accept && mimeType && mimeType.includes("/") && !Internal.mimeTypeMatchesAccept(mimeType, accept)) {
937
+ return false;
938
+ }
939
+ const fileMimeType = Internal.filenameToMimeType(value.path);
940
+ if (!fileMimeType) {
941
+ return false;
942
+ }
943
+ if (mimeType && fileMimeType !== mimeType) {
944
+ return false;
945
+ }
946
+ return true;
947
+ }
948
+
949
+ /**
950
+ * {@link isDeferredMediaMetadataCheck} for an error in a module, resolving the
951
+ * schema it needs.
952
+ *
953
+ * Both the adjudicator and `createValDiagnostics` have to make the same call,
954
+ * on the same inputs -- one to decide what to adjudicate, the other to decide
955
+ * what to publish -- so it lives here rather than being written out twice.
956
+ */
957
+ function isDeferredMediaMetadataCheckAt({
958
+ sourcePath,
959
+ error,
960
+ content
961
+ }) {
962
+ return isDeferredMediaMetadataCheck({
963
+ error,
964
+ schema: resolveSchemaAt$1(sourcePath, content)
965
+ });
966
+ }
967
+
968
+ /**
969
+ * Adjudicate every deferred metadata placeholder in `validation`.
970
+ *
971
+ * Async, and therefore separate from `createValDiagnostics`, which stays
972
+ * synchronous so it can be tested without a project -- the same split
973
+ * `resolveGalleryChecks` uses. The caller runs this first and passes the
974
+ * result in.
975
+ */
976
+ async function resolveMediaMetadataChecks({
977
+ validation,
978
+ content,
979
+ valRoot,
980
+ remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST
981
+ }) {
982
+ const verdicts = new Map();
983
+ for (const [sourcePath, errors] of Object.entries(validation)) {
984
+ for (const error of errors) {
985
+ if (!isDeferredMediaMetadataCheckAt({
986
+ sourcePath,
987
+ error,
988
+ content
989
+ })) {
990
+ continue;
991
+ }
992
+ const key = mediaMetadataCheckKey(sourcePath, error);
993
+ try {
994
+ verdicts.set(key, await adjudicate({
995
+ sourcePath,
996
+ error,
997
+ content,
998
+ valRoot,
999
+ remoteHost
1000
+ }));
1001
+ } catch {
1002
+ // This runs inside `validate`, which publishes NOTHING if it throws --
1003
+ // one bad image would silently clear every Val diagnostic in the file.
1004
+ // Keep the placeholder rather than claiming the metadata is fine.
1005
+ verdicts.set(key, keep(error));
1006
+ }
1007
+ }
1008
+ }
1009
+ return verdicts;
1010
+ }
1011
+
1012
+ /**
1013
+ * Key for one placeholder. A value can in principle carry more than one, so
1014
+ * the fix names are part of the key -- as they are for the gallery checks.
1015
+ */
1016
+ function mediaMetadataCheckKey(sourcePath, error) {
1017
+ return `${sourcePath}|${(error.fixes ?? []).join(",")}`;
1018
+ }
1019
+
1020
+ /** Keeping the placeholder: what to publish when we learned nothing. */
1021
+ function keep(error) {
1022
+ return [{
1023
+ message: error.message,
1024
+ ...(error.fixes ? {
1025
+ fixes: error.fixes
1026
+ } : {}),
1027
+ ...(error.value !== undefined ? {
1028
+ value: error.value
1029
+ } : {})
1030
+ }];
1031
+ }
1032
+
1033
+ /**
1034
+ * What `createFixPatch` says about one placeholder, in report mode.
1035
+ *
1036
+ * `val validate` runs the fix handler first and then `createFixPatch`, but for
1037
+ * these four fixes the handler is `handleFileMetadata`, whose whole job is the
1038
+ * precondition "the ref resolves and the file is on disk". That precondition is
1039
+ * checked directly here instead, for two reasons:
1040
+ *
1041
+ * - `createValDiagnostics` already reports a missing file as
1042
+ * `val/file-not-found`, which is a better diagnostic than a metadata
1043
+ * mismatch and is produced before this verdict is consulted.
1044
+ * - `handleFileMetadata` resolves the source path through the module, and that
1045
+ * throws outright for a `.jsonValues()` entry ("Cannot resolve path into a
1046
+ * jsonValues entry until its content is loaded") -- so routing through it
1047
+ * would leave every entry-backed image stuck on the placeholder.
1048
+ *
1049
+ * The comparison itself is still `createFixPatch`, unchanged, which is the
1050
+ * parity that matters: the editor's wording is the CLI's wording because it is
1051
+ * the CLI's code -- including for bytes it cannot measure, where it reports
1052
+ * what `val validate` reports rather than a second opinion of our own.
1053
+ *
1054
+ * `createFixPatch` reads and decodes the image itself, so this is one file read
1055
+ * per media field per validation pass, and nothing here should add another.
1056
+ */
1057
+ async function adjudicate({
1058
+ sourcePath,
1059
+ error,
1060
+ content,
1061
+ valRoot,
1062
+ remoteHost
1063
+ }) {
1064
+ const ref = mediaValueOf(error.value)?.path;
1065
+ if (typeof ref !== "string") {
1066
+ return keep(error);
1067
+ }
1068
+ // A remote ref is a URL and is not expected on disk. Core does not emit these
1069
+ // fixes for one, but a stale ref should not be reported as a local mismatch.
1070
+ if (Internal.remote.splitRemoteRef(ref).status === "success") {
1071
+ return keep(error);
1072
+ }
1073
+ if (!fs.existsSync(path.join(valRoot, ref))) {
1074
+ // Reported as `val/file-not-found` instead; keeping the placeholder here
1075
+ // means this verdict never has the last word on a file that is not there.
1076
+ return keep(error);
1077
+ }
1078
+ let fixed;
1079
+ try {
1080
+ fixed = await createFixPatch({
1081
+ projectRoot: valRoot,
1082
+ remoteHost
1083
+ },
1084
+ // `false`: this is a question, not a fix. Asking for the patch would have
1085
+ // createFixPatch read and rewrite files behind the editor's back.
1086
+ false, sourcePath, error, {}, content.source, content.schema);
1087
+ } catch {
1088
+ return keep(error);
1089
+ }
1090
+ return (fixed?.remainingErrors ?? []).map(remaining => ({
1091
+ message: remaining.message,
1092
+ // `createFixPatch` clears `fixes` on the per-field errors it reports, but
1093
+ // the fix is still available and still the remedy -- it is what the
1094
+ // placeholder was asking for. Carrying the placeholder's own fixes is what
1095
+ // keeps the "update image metadata" quick fix offered, and the diagnostic
1096
+ // a Warning rather than an Error.
1097
+ ...(error.fixes ? {
1098
+ fixes: error.fixes
1099
+ } : {}),
1100
+ ...(error.value !== undefined ? {
1101
+ value: error.value
1102
+ } : {})
1103
+ }));
1104
+ }
1105
+
1106
+ /**
1107
+ * The `accept` a media schema declares, if any.
1108
+ *
1109
+ * Read from the serialized schema (`SerializedImageSchema.options`) rather
1110
+ * than from a schema instance: this runs against what `Service.get` returns.
1111
+ */
1112
+ function acceptOf(schema) {
1113
+ if (typeof schema !== "object" || schema === null || !("options" in schema)) {
1114
+ return undefined;
1115
+ }
1116
+ const {
1117
+ options
1118
+ } = schema;
1119
+ if (typeof options !== "object" || options === null || !("accept" in options)) {
1120
+ return undefined;
1121
+ }
1122
+ const {
1123
+ accept
1124
+ } = options;
1125
+ return typeof accept === "string" ? accept : undefined;
1126
+ }
1127
+
1128
+ /** A media value, read as a plain record. */
1129
+ function mediaValueOf(value) {
1130
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1131
+ return undefined;
1132
+ }
1133
+ return value;
1134
+ }
1135
+
1136
+ /**
1137
+ * The serialized schema at a source path.
1138
+ *
1139
+ * Only the schema is taken from resolution -- `accept` is not on the value, and
1140
+ * the value itself comes from the error. Same call `missingFileRef` in
1141
+ * `diagnostics.ts` makes, and it can fail the same ways (a schema that failed
1142
+ * to serialize, a path that no longer resolves), so failure is not an error
1143
+ * here, just an absence.
1144
+ */
1145
+ function resolveSchemaAt$1(sourcePath, content) {
1146
+ if (!content.source || !content.schema) {
1147
+ return undefined;
1148
+ }
1149
+ try {
1150
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
1151
+ return Internal.resolvePath(modulePath, content.source, content.schema).schema;
1152
+ } catch {
1153
+ return undefined;
1154
+ }
1155
+ }
1156
+
849
1157
  /** Marks diagnostics as ours, so a client can filter on it. */
850
1158
  const VAL_DIAGNOSTIC_SOURCE = "val";
851
1159
 
@@ -937,7 +1245,8 @@ function createValDiagnostics({
937
1245
  text,
938
1246
  valRoot,
939
1247
  snapshot,
940
- galleryChecks
1248
+ galleryChecks,
1249
+ mediaMetadataChecks
941
1250
  }) {
942
1251
  if (content.errors === false) {
943
1252
  return [];
@@ -1009,6 +1318,33 @@ function createValDiagnostics({
1009
1318
  continue;
1010
1319
  }
1011
1320
 
1321
+ // An unconditional media metadata placeholder: core reports
1322
+ // `image:check-metadata` on every `s.image()` carrying metadata, whether
1323
+ // or not it disagrees with the file. Show only what the adjudication
1324
+ // actually found, and nothing when it found nothing. Deliberately after
1325
+ // the missing-file branch above, so a deleted file is still reported as
1326
+ // `val/file-not-found` rather than as a metadata mismatch.
1327
+ if (isDeferredMediaMetadataCheckAt({
1328
+ sourcePath,
1329
+ error,
1330
+ content
1331
+ })) {
1332
+ const verdict = mediaMetadataChecks?.get(mediaMetadataCheckKey(sourcePath, error));
1333
+ for (const finding of verdict ?? []) {
1334
+ diagnostics.push(build(rangeOf(sourcePath, modulePathMap), finding.message, {
1335
+ code: "val/validation",
1336
+ sourcePath,
1337
+ ...(finding.fixes ? {
1338
+ fixes: finding.fixes
1339
+ } : {}),
1340
+ ...(finding.value !== undefined ? {
1341
+ value: finding.value
1342
+ } : {})
1343
+ }));
1344
+ }
1345
+ continue;
1346
+ }
1347
+
1012
1348
  // A gallery-backed field whose path the gallery does not track. Core
1013
1349
  // reports it with no fix because the remedy is elsewhere; giving it its
1014
1350
  // own code is what lets the editor offer the two remedies.
@@ -2099,6 +2435,17 @@ async function createValCodeActions({
2099
2435
  remoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST
2100
2436
  }) {
2101
2437
  const actions = [];
2438
+ /**
2439
+ * Fixes already offered, as `<fix target>|<fix>`.
2440
+ *
2441
+ * One problem can be reported as several diagnostics that share a fix: a
2442
+ * stale image reports its width and its height separately, and a gallery
2443
+ * check reports one finding per entry. The fix is the same patch each time --
2444
+ * `createFixPatch` corrects every field, and the gallery branch walks every
2445
+ * entry -- so without this the editor offers the identical "Val: update image
2446
+ * metadata" twice, and recomputes it (re-reading the image) to do so.
2447
+ */
2448
+ const offered = new Set();
2102
2449
  for (const diagnostic of diagnostics) {
2103
2450
  const data = diagnostic.data;
2104
2451
  if (!data?.fixes?.length) {
@@ -2137,6 +2484,10 @@ async function createValCodeActions({
2137
2484
  if (!isLocalFix(fix)) {
2138
2485
  continue;
2139
2486
  }
2487
+ const fixTarget = data.fixSourcePath ?? data.sourcePath;
2488
+ if (offered.has(`${fixTarget}|${fix}`)) {
2489
+ continue;
2490
+ }
2140
2491
  const fixEdit = await computeFixEdit({
2141
2492
  document,
2142
2493
  // A gallery check is reported on the entry but fixed against the record
@@ -2158,6 +2509,7 @@ async function createValCodeActions({
2158
2509
  if (!fixEdit) {
2159
2510
  continue;
2160
2511
  }
2512
+ offered.add(`${fixTarget}|${fix}`);
2161
2513
  actions.push(CodeAction.create(FIX_TITLES[fix] ?? `Val: ${fix}`,
2162
2514
  // Not always `document.uri`: a fix inside a `.jsonValues()` entry
2163
2515
  // edits the entry's own `*.val.json`, which is a different file from
@@ -3519,6 +3871,14 @@ function createValLanguageServer(connection) {
3519
3871
  runFixHandler: args => activeProject.runFixHandler(args)
3520
3872
  })
3521
3873
  }) : undefined;
3874
+ // Core also defers "does this image's stored metadata match its file",
3875
+ // reporting it on every `s.image()` that carries any metadata. Same
3876
+ // treatment: adjudicate with the fix machinery before showing anything.
3877
+ const mediaMetadataChecks = result.content.errors !== false && result.content.errors.validation ? await resolveMediaMetadataChecks({
3878
+ validation: result.content.errors.validation,
3879
+ content: result.content,
3880
+ valRoot: activeProject.valRoot
3881
+ }) : undefined;
3522
3882
  const diagnostics = createValDiagnostics({
3523
3883
  moduleFilePath,
3524
3884
  content: result.content,
@@ -3529,6 +3889,9 @@ function createValLanguageServer(connection) {
3529
3889
  } : {}),
3530
3890
  ...(galleryChecks ? {
3531
3891
  galleryChecks
3892
+ } : {}),
3893
+ ...(mediaMetadataChecks ? {
3894
+ mediaMetadataChecks
3532
3895
  } : {})
3533
3896
  });
3534
3897
  const unregistered = findMissingModuleDiagnostic(project.valRoot, moduleFilePath, readOpenDocument);
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.116.0",
14
+ "version": "0.117.1",
15
15
  "bin": {
16
16
  "val-language-server": "./bin.js"
17
17
  },
@@ -29,9 +29,9 @@
29
29
  "typescript": "^6.0.3",
30
30
  "vscode-languageserver": "^10.1.0",
31
31
  "vscode-languageserver-textdocument": "^1.0.14",
32
- "@valbuild/server": "0.116.0",
33
- "@valbuild/core": "0.111.0",
34
- "@valbuild/shared": "0.116.0"
32
+ "@valbuild/core": "0.117.0",
33
+ "@valbuild/server": "0.117.1",
34
+ "@valbuild/shared": "0.117.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",
@@ -41,6 +41,7 @@
41
41
  "node": "^20.19.0 || >=22"
42
42
  },
43
43
  "files": [
44
+ "CHANGELOG.md",
44
45
  "dist",
45
46
  "bin.js",
46
47
  "README.md"