@valbuild/server 0.123.0 → 0.123.3

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.
@@ -1402,6 +1402,43 @@ function resolveExistingJsonPath(moduleFilePath, importPath) {
1402
1402
  * only way to avoid loading every entry twice.
1403
1403
  */
1404
1404
 
1405
+ /**
1406
+ * The part of a `.jsonValues()` record this file calls. Named structurally
1407
+ * because the value may not be an instance of *this* copy of `RecordSchema` —
1408
+ * see {@link isJsonValuesRecord}.
1409
+ */
1410
+
1411
+ /**
1412
+ * Is this a `.jsonValues()` record?
1413
+ *
1414
+ * NOT `schema instanceof RecordSchema`, and the distinction is the whole point.
1415
+ * `loadValModules` evaluates the project's `*.val.ts` with a `require` rooted at
1416
+ * the PROJECT, deliberately, so the schemas are built by whatever
1417
+ * `@valbuild/core` the project resolves. When the CLI is itself installed in
1418
+ * that project, that is this very module and `instanceof` holds. When it is run
1419
+ * through `npx` / `pnpm dlx`, the tool gets its own copy of `@valbuild/core`
1420
+ * beside the project's, and `instanceof` is false for a record that is a record
1421
+ * in every way that matters — same class name, same serialized schema, same
1422
+ * methods, different realm.
1423
+ *
1424
+ * That failed OPEN: the whole check returned "no errors", so
1425
+ * `npx @valbuild/cli validate` called a module with un-extracted entries VALID
1426
+ * and a CI gate on it was green for the wrong reason. `loadValModules` avoids
1427
+ * constructor checks for exactly this reason and says so; this one was the
1428
+ * exception.
1429
+ *
1430
+ * `validateJsonEntryContent` is declared by `RecordSchema` and by nothing else,
1431
+ * so it is the brand — and checking it first keeps the cheap short-circuit
1432
+ * `instanceof` gave us, rather than serializing every non-record schema.
1433
+ */
1434
+ function isJsonValuesRecord(schema) {
1435
+ if (!("validateJsonEntryContent" in schema) || typeof schema.validateJsonEntryContent !== "function") {
1436
+ return false;
1437
+ }
1438
+ const serialized = schema["executeSerialize"]();
1439
+ return serialized.type === "record" && serialized.jsonValues === true;
1440
+ }
1441
+
1405
1442
  /**
1406
1443
  * Validates the content of every `.jsonValues()` entry in a module by loading
1407
1444
  * each backing `*.val.json` (via its lazy import thunk) and checking it against
@@ -1459,10 +1496,7 @@ async function validateJsonValuesEntries(schema, source, modulePath) {
1459
1496
  errors: out,
1460
1497
  loadedEntries
1461
1498
  };
1462
- if (!(schema instanceof core.RecordSchema)) {
1463
- return res;
1464
- }
1465
- if (!schema["executeSerialize"]().jsonValues) {
1499
+ if (!isJsonValuesRecord(schema)) {
1466
1500
  return res;
1467
1501
  }
1468
1502
  if (source === null || typeof source !== "object" || Array.isArray(source)) {
@@ -13506,6 +13540,72 @@ async function extractFileMetadata(filename, _input // TODO: use buffer to deter
13506
13540
  };
13507
13541
  }
13508
13542
 
13543
+ /**
13544
+ * A gallery entry's key, read as what it actually is.
13545
+ *
13546
+ * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
13547
+ * an uploaded entry by the remote URL instead — which encodes that same path —
13548
+ * so every entry has a local path, and the interesting question is whether the
13549
+ * bytes are expected to be at it.
13550
+ *
13551
+ * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
13552
+ * uploads the remote descriptors to the content host and copies only the local
13553
+ * ones into the working tree, so an image added through the Studio (or over MCP)
13554
+ * and published has no file in the repo, by design — putting one there is what
13555
+ * remote storage exists to avoid. A remote entry CAN have one, because
13556
+ * `val validate --fix` promotes a local file to a remote ref and leaves the file
13557
+ * where it was; so "remote" means "do not require it", never "there is not one".
13558
+ *
13559
+ * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
13560
+ * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
13561
+ * normalising the key correctly and each then treating the result as a file that
13562
+ * must exist. That agreement is what made a published remote image report as
13563
+ * missing from both sides at once.
13564
+ */
13565
+
13566
+ /**
13567
+ * A key that names somewhere else, whether or not it is a ref we can read.
13568
+ *
13569
+ * Case-insensitive, because a scheme is (RFC 3986) — and because
13570
+ * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
13571
+ * spellings that arrives here unparsed and must not be mistaken for a path.
13572
+ */
13573
+ function isRemoteUrl(key) {
13574
+ return /^https?:\/\//i.test(key);
13575
+ }
13576
+ function galleryEntryOf(key) {
13577
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
13578
+ if (remoteRefRes.status === "success") {
13579
+ return {
13580
+ localPath: `/${remoteRefRes.filePath}`,
13581
+ remote: true
13582
+ };
13583
+ }
13584
+ if (isRemoteUrl(key)) {
13585
+ // A URL that is not a ref this version can read: truncated by a hand edit,
13586
+ // a path outside `public/`, a `..` segment — or written by a core version
13587
+ // whose format `splitRemoteRef` rejects.
13588
+ //
13589
+ // Still not a path in the working tree, so the on-disk checks must not run
13590
+ // against it. They would find nothing at `<projectRoot>/https://…`, report
13591
+ // the entry as missing, and `--fix` would DELETE it — the exact failure
13592
+ // this module exists to prevent, arriving at the one moment the key is the
13593
+ // only remaining record of where the bytes went.
13594
+ //
13595
+ // Nothing is being hidden by this: a malformed key is already reported, by
13596
+ // the record schema's own "Invalid remote URL format". That is an error for
13597
+ // a person to look at, not a file for `--fix` to go missing.
13598
+ return {
13599
+ localPath: key,
13600
+ remote: true
13601
+ };
13602
+ }
13603
+ return {
13604
+ localPath: key,
13605
+ remote: false
13606
+ };
13607
+ }
13608
+
13509
13609
  /**
13510
13610
  * The media path a validation error is about, if it is about media at all.
13511
13611
  *
@@ -13701,14 +13801,6 @@ async function downloadFileFromRemote(ref, filePath) {
13701
13801
  // record-level fix expands into per-entry errors that should point at the
13702
13802
  // individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
13703
13803
 
13704
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
13705
- // entries by a remote URL while keeping the file on disk at its local path, so
13706
- // normalize a remote-URL key back to that local path for on-disk reads.
13707
- function galleryKeyToLocalPath(key) {
13708
- const res = core.Internal.remote.splitRemoteRef(key);
13709
- return res.status === "success" ? `/${res.filePath}` : key;
13710
- }
13711
-
13712
13804
  // Patch path of the record that holds a media entry. Media keys are file paths
13713
13805
  // that can contain dots, which sourceToPatchPath cannot round-trip, so strip
13714
13806
  // the (JSON-encoded) key segment off the entry source path and convert the
@@ -14017,7 +14109,18 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
14017
14109
  }
14018
14110
  const gallerySource = moduleSource;
14019
14111
  for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
14020
- const filename = path__namespace["default"].join(config.projectRoot, galleryKeyToLocalPath(entryKey));
14112
+ const entry = galleryEntryOf(entryKey);
14113
+ if (entry.remote) {
14114
+ // A remote entry's bytes are on the content host, so there may be no
14115
+ // local file to re-derive its metadata from — and where there is one,
14116
+ // rewriting the metadata from it would invalidate the ref, which has
14117
+ // that metadata baked into its validation hash. Whether a remote
14118
+ // entry is sound is `image:check-remote`'s question, and it already
14119
+ // asks it. Reading the file here reported every published remote
14120
+ // image as unreadable, and with `--fix` REMOVED it from the gallery.
14121
+ continue;
14122
+ }
14123
+ const filename = path__namespace["default"].join(config.projectRoot, entry.localPath);
14021
14124
  let buffer;
14022
14125
  try {
14023
14126
  buffer = fs__default["default"].readFileSync(filename);
@@ -14231,37 +14334,50 @@ async function getFileMetadata(projectRoot, validationError) {
14231
14334
  }
14232
14335
 
14233
14336
  /**
14234
- * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
14235
- * its own `*.val.json`, replacing the inline value with
14236
- * `c.json(() => import("./<key>.val.json"))`.
14337
+ * The two files an extraction touches, as text — nothing written yet.
14237
14338
  *
14238
- * This is the fix for the `jsonValues:extract-entry` validation error. It is not
14239
- * expressible as a patch (a patch edits one `.val.ts` and cannot create the
14240
- * backing JSON file), so it writes both files directly — JSON first, so a
14241
- * failure part-way through never leaves the module pointing at a file that does
14242
- * not exist.
14339
+ * Both paths are project-relative in the `ModuleFilePath` style (a leading
14340
+ * slash), because that is what the callers already hold and what
14341
+ * {@link getNewJsonEntryPaths} derives. Join them onto the project root to get
14342
+ * somewhere to write.
14343
+ */
14344
+
14345
+ /**
14346
+ * Works out what moving ONE inline `.jsonValues()` entry into its own
14347
+ * `*.val.json` would change, without changing anything.
14348
+ *
14349
+ * This is the whole fix for `jsonValues:extract-entry`, minus the writing. It
14350
+ * is split out because the two callers cannot share a way of applying it: the
14351
+ * CLI writes both files ({@link extractJsonValuesEntry}), while the editor has
14352
+ * to hand the same two changes back as a `WorkspaceEdit` so they go through the
14353
+ * undo stack and respect an unsaved buffer. Anything that lived in only one of
14354
+ * those would be a fix that behaves differently depending on where it was
14355
+ * invoked from, which is exactly the drift `codeActions.ts` exists to prevent.
14356
+ *
14357
+ * It does NOT check whether `jsonPath` already exists: that needs a filesystem,
14358
+ * and the editor's answer ("is there a buffer or a file here?") is not the
14359
+ * CLI's. Every caller must check before writing — overwriting an unrelated file
14360
+ * is not a fix.
14243
14361
  *
14244
14362
  * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
14245
14363
  * up in the module's root record/router object literal.
14246
14364
  */
14247
- function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
14248
- const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
14249
- const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
14250
- if (!sourceFile) {
14251
- throw Error(`Source file ${valTsPath} not found`);
14252
- }
14365
+ function planJsonValuesEntryExtraction({
14366
+ moduleFilePath,
14367
+ entryKey,
14368
+ content,
14369
+ valTsPath,
14370
+ valTsText
14371
+ }) {
14253
14372
  const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
14254
14373
  if (fp.result.isErr(pathsRes)) {
14255
- throw Error(formatPatchSourceError(pathsRes.error));
14374
+ return fp.result.err(formatPatchSourceError(pathsRes.error));
14256
14375
  }
14257
14376
  const {
14258
14377
  jsonPath,
14259
14378
  importPath
14260
14379
  } = pathsRes.value;
14261
- const absoluteJsonPath = path__namespace["default"].join(rootDir, jsonPath);
14262
- if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
14263
- throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
14264
- }
14380
+ const sourceFile = ts__default["default"].createSourceFile(valTsPath, valTsText, ts__default["default"].ScriptTarget.ES2020);
14265
14381
 
14266
14382
  // Remove the inline property, then add the `c.json(...)` reference back. The
14267
14383
  // entry moves to the end of the record: entry ORDER in a jsonValues record is
@@ -14269,14 +14385,70 @@ function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sour
14269
14385
  // insert-in-place would mean reimplementing insertValJsonEntry.
14270
14386
  const removed = removeValJsonEntry(sourceFile, [], entryKey);
14271
14387
  if (fp.result.isErr(removed)) {
14272
- throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
14388
+ return fp.result.err(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
14273
14389
  }
14274
14390
  const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
14275
14391
  if (fp.result.isErr(inserted)) {
14276
- throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
14392
+ return fp.result.err(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
14393
+ }
14394
+ return fp.result.ok({
14395
+ jsonPath,
14396
+ jsonContent: JSON.stringify(content, null, 2) + "\n",
14397
+ valTsPath,
14398
+ valTsContent: printSourceFile(inserted.value)
14399
+ });
14400
+ }
14401
+
14402
+ /**
14403
+ * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
14404
+ * its own `*.val.json`, replacing the inline value with
14405
+ * `c.json(() => import("./<key>.val.json"))`.
14406
+ *
14407
+ * The `val validate --fix` half of {@link planJsonValuesEntryExtraction}. It is
14408
+ * not expressible as a patch (a patch edits one `.val.ts` and cannot create the
14409
+ * backing JSON file), so it writes both files directly — JSON first, so a
14410
+ * failure part-way through never leaves the module pointing at a file that does
14411
+ * not exist.
14412
+ */
14413
+ function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
14414
+ const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
14415
+ const valTsText = sourceFileHandler.host.readFile(valTsPath);
14416
+ if (valTsText === undefined) {
14417
+ throw Error(`Source file ${valTsPath} not found`);
14277
14418
  }
14278
- sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
14279
- sourceFileHandler.writeSourceFile(inserted.value);
14419
+ const planned = planJsonValuesEntryExtraction({
14420
+ moduleFilePath,
14421
+ entryKey,
14422
+ content,
14423
+ valTsPath,
14424
+ valTsText
14425
+ });
14426
+ if (fp.result.isErr(planned)) {
14427
+ throw Error(planned.error);
14428
+ }
14429
+ const absoluteJsonPath = path__namespace["default"].join(rootDir, planned.value.jsonPath);
14430
+ if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
14431
+ throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${planned.value.jsonPath}' already exists`);
14432
+ }
14433
+ sourceFileHandler.writeFile(absoluteJsonPath, planned.value.jsonContent, "utf8");
14434
+ sourceFileHandler.writeFile(valTsPath, planned.value.valTsContent, "utf8");
14435
+ }
14436
+
14437
+ /**
14438
+ * The text of a source file the TS ops produced, exactly as
14439
+ * `ValSourceFileHandler.writeSourceFile` would have written it.
14440
+ *
14441
+ * The unescape undoes the printer's `\uXXXX` output for non-ASCII. Today the
14442
+ * ops splice printed text into `document.text` and this fix only ever prints an
14443
+ * ASCII `c.json(() => import("./..."))`, so nothing here is escaped and the
14444
+ * unescape is a no-op — but `neverAsciiEscape` is off in the printer, so that is
14445
+ * a property of what this fix happens to print, not a guarantee. Kept because
14446
+ * this replaced a `writeSourceFile` call and the text written must not change.
14447
+ *
14448
+ * https://github.com/microsoft/TypeScript/issues/36174
14449
+ */
14450
+ function printSourceFile(sourceFile) {
14451
+ return unescape(sourceFile.text.replace(/\\u/g, "%u"));
14280
14452
  }
14281
14453
  function formatOpsError(error, sourceFile) {
14282
14454
  if (error instanceof patch.PatchError) {
@@ -14683,15 +14855,48 @@ async function handleUniqueFolderCheck(ctx) {
14683
14855
  };
14684
14856
  }
14685
14857
 
14686
- // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
14687
- // entries by a remote URL while keeping the file on disk; everything else is
14688
- // already a local path and is returned unchanged.
14689
- function remoteKeyToLocalPath(key) {
14690
- const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
14691
- if (remoteRefRes.status === "success") {
14692
- return `/${remoteRefRes.filePath}`;
14858
+ /**
14859
+ * What is out of step between a gallery's entries and its directory.
14860
+ *
14861
+ * Two questions, and they treat a remote entry differently — which is the whole
14862
+ * reason this is separate from the handler around it:
14863
+ *
14864
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
14865
+ * entries only. A remote entry's bytes live on the content host, and nothing
14866
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
14867
+ * descriptors and copies only the local ones into the tree, so a remote entry
14868
+ * added through the Studio (or over MCP) has no local file by design, and
14869
+ * demanding one would mean committing remote bytes to git — which is what
14870
+ * remote storage exists to avoid. Whether those bytes really are on the host
14871
+ * is a different check, `image:check-remote`, which already runs for exactly
14872
+ * these entries.
14873
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
14874
+ * entry, remote included, and that is why they are normalised to their local
14875
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
14876
+ * the file where it was, so a remote entry can perfectly well have one.
14877
+ */
14878
+ function checkGalleryFiles(input) {
14879
+ const {
14880
+ directory,
14881
+ projectRoot,
14882
+ fs
14883
+ } = input;
14884
+ const entries = input.entryKeys.map(galleryEntryOf);
14885
+ const trackedFiles = new Set(entries.map(entry => entry.localPath));
14886
+ const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__namespace["default"].join(projectRoot, entry.localPath))).map(entry => entry.localPath);
14887
+ const filesInDir = [];
14888
+ try {
14889
+ const found = fs.readDirectory(path__namespace["default"].join(projectRoot, directory), undefined, undefined, ["**/*"]);
14890
+ for (const entry of found) {
14891
+ filesInDir.push("/" + path__namespace["default"].relative(projectRoot, entry).split(path__namespace["default"].sep).join("/"));
14892
+ }
14893
+ } catch {
14894
+ // directory doesn't exist — no untracked files possible
14693
14895
  }
14694
- return key;
14896
+ return {
14897
+ missingTrackedFiles,
14898
+ untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
14899
+ };
14695
14900
  }
14696
14901
  async function handleCheckAllFiles(ctx) {
14697
14902
  const value = ctx.validationError.value;
@@ -14711,14 +14916,14 @@ async function handleCheckAllFiles(ctx) {
14711
14916
  errorMessage: `Could not get source for ${ctx.sourcePath}`
14712
14917
  };
14713
14918
  }
14714
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
14715
- // entries by a remote URL, but the file is kept on disk at its local path, so
14716
- // normalize remote-URL keys back to that local path for the on-disk checks.
14717
- const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
14718
-
14719
- // Check that all tracked files exist on disk
14720
- const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
14721
- return !ctx.fs.fileExists(path__namespace["default"].join(ctx.projectRoot, f));
14919
+ const {
14920
+ missingTrackedFiles,
14921
+ untrackedFiles
14922
+ } = checkGalleryFiles({
14923
+ entryKeys: Object.keys(source),
14924
+ directory,
14925
+ projectRoot: ctx.projectRoot,
14926
+ fs: ctx.fs
14722
14927
  });
14723
14928
  if (missingTrackedFiles.length > 0) {
14724
14929
  if (!ctx.fix) {
@@ -14733,18 +14938,6 @@ async function handleCheckAllFiles(ctx) {
14733
14938
  shouldApplyPatch: true
14734
14939
  };
14735
14940
  }
14736
- const dirPath = path__namespace["default"].join(ctx.projectRoot, directory);
14737
- const filesInDir = [];
14738
- try {
14739
- const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
14740
- for (const entry of entries) {
14741
- const relPath = "/" + path__namespace["default"].relative(ctx.projectRoot, entry).split(path__namespace["default"].sep).join("/");
14742
- filesInDir.push(relPath);
14743
- }
14744
- } catch {
14745
- // directory doesn't exist — no untracked files possible
14746
- }
14747
- const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
14748
14941
  if (untrackedFiles.length > 0) {
14749
14942
  return {
14750
14943
  success: false,
@@ -15565,6 +15758,7 @@ exports.ok = ok;
15565
15758
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
15566
15759
  exports.patchSourceFile = patchSourceFile;
15567
15760
  exports.persistPersonalAccessToken = persistPersonalAccessToken;
15761
+ exports.planJsonValuesEntryExtraction = planJsonValuesEntryExtraction;
15568
15762
  exports.readCapturedReport = readCapturedReport;
15569
15763
  exports.readPatchStore = readPatchStore;
15570
15764
  exports.rebaseContentOp = rebaseContentOp;