filegrc 0.12.2 → 0.12.4

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/src/validate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
2
3
  import { readFile, stat } from "node:fs/promises";
3
4
  import { performance } from "node:perf_hooks";
4
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
@@ -36,6 +37,17 @@ import {
36
37
  } from "./reporting-route-integrity.js";
37
38
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
38
39
 
40
+ let fingerprintFileReadObserver = null;
41
+
42
+ export function setFingerprintFileReadObserverForTests(observer) {
43
+ if (observer !== null && typeof observer !== "function") {
44
+ throw new TypeError("The fingerprint file-read observer must be a function or null.");
45
+ }
46
+ const previous = fingerprintFileReadObserver;
47
+ fingerprintFileReadObserver = observer;
48
+ return () => { fingerprintFileReadObserver = previous; };
49
+ }
50
+
39
51
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
40
52
  const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
41
53
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -218,14 +230,30 @@ async function validateWorkspaceUnmeasured(input) {
218
230
  }
219
231
  if (modelSupports(loaded.model, "guided-workflow")) {
220
232
  const { planReconciliation, validateReconciliationDismissals } = await import("./reconciliation.js");
221
- const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
222
- reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
223
- for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
224
- diagnostics.push(warning(
225
- "unreconciled-committed-transition",
226
- candidate.sourcePath,
227
- `${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
233
+ try {
234
+ const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
235
+ reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
236
+ for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
237
+ diagnostics.push(warning(
238
+ "unreconciled-committed-transition",
239
+ candidate.sourcePath,
240
+ `${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
241
+ ));
242
+ }
243
+ } catch (cause) {
244
+ if (cause?.code === "FILEGRC_GIT_DEADLINE") throw cause;
245
+ diagnostics.push(error(
246
+ "reconciliation-history-unavailable",
247
+ "data/workspace.json",
248
+ cause instanceof Error ? cause.message : "Git history is unavailable for reconciliation."
228
249
  ));
250
+ reconciliation = {
251
+ contractVersion: 1,
252
+ gitRevision: null,
253
+ changedPaths: [],
254
+ candidates: [],
255
+ unavailable: true
256
+ };
229
257
  }
230
258
  }
231
259
 
@@ -1209,19 +1237,27 @@ function validateCollectionReviewSet(resources, pathById, diagnostics) {
1209
1237
  }
1210
1238
  }
1211
1239
 
1212
- export async function fingerprintWorkspace(input = process.cwd()) {
1240
+ export async function fingerprintWorkspace(input = process.cwd(), options = {}) {
1213
1241
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
1214
1242
  const hash = createHash("sha256");
1215
1243
  hash.update(`model\0${loaded.model.modelVersion}\0`);
1216
- for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1244
+ const sourceEntries = loaded.sourceEntries || loaded.entries;
1245
+ const fingerprintBudget = { uncachedBytes: 0 };
1246
+ for (const entry of [...sourceEntries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1217
1247
  hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
1248
+ }
1249
+ for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1218
1250
  const definition = loaded.model.resources[entry.record?.type];
1219
1251
  if (!definition) continue;
1220
1252
  for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
1221
1253
  try {
1222
- const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
1223
- hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
1224
- } catch {
1254
+ const resolvedPath = resolveDataPath(loaded.root, item.path);
1255
+ const file = await stat(resolvedPath);
1256
+ if (!file.isFile()) throw new Error("Markdown path is not a file.");
1257
+ const content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1258
+ hash.update(`markdown\0${item.path}\0${content.size}\0${content.digest}\0`);
1259
+ } catch (error) {
1260
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1225
1261
  hash.update(`markdown-missing\0${item.path}\0`);
1226
1262
  }
1227
1263
  }
@@ -1232,9 +1268,18 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1232
1268
  const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
1233
1269
  for (const path of [...paths].sort()) {
1234
1270
  try {
1235
- const file = await stat(resolveDataPath(loaded.root, path));
1236
- hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
1237
- } catch {
1271
+ const resolvedPath = resolveDataPath(loaded.root, path);
1272
+ const file = await stat(resolvedPath);
1273
+ if (file.isFile()) {
1274
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1275
+ let content = options.fileDigestCache?.get(resolvedPath);
1276
+ if (!content || content.identity !== identity) {
1277
+ content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1278
+ }
1279
+ hash.update(`data-path\0${path}\0file\0${content.size}\0${content.digest}\0`);
1280
+ } else hash.update(`data-path\0${path}\0other\0`);
1281
+ } catch (error) {
1282
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1238
1283
  hash.update(`data-path-missing\0${path}\0`);
1239
1284
  }
1240
1285
  }
@@ -1243,6 +1288,41 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1243
1288
  return { fingerprint: hash.digest("hex"), loaded };
1244
1289
  }
1245
1290
 
1291
+ async function fingerprintFileContent(path, file, options, budget) {
1292
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1293
+ const cached = options.fileDigestCache?.get(path);
1294
+ if (cached?.identity === identity) return cached;
1295
+ assertFingerprintBudget(options, budget.uncachedBytes + file.size);
1296
+ budget.uncachedBytes += file.size;
1297
+ const contentHash = createHash("sha256");
1298
+ let size = 0;
1299
+ await fingerprintFileReadObserver?.(path);
1300
+ for await (const chunk of createReadStream(path)) {
1301
+ assertFingerprintBudget(options, budget.uncachedBytes - file.size + size + chunk.length);
1302
+ size += chunk.length;
1303
+ contentHash.update(chunk);
1304
+ }
1305
+ const content = { identity, size, digest: contentHash.digest("hex") };
1306
+ if (options.fileDigestCache?.size < 20_000 || options.fileDigestCache?.has(path)) {
1307
+ options.fileDigestCache.set(path, content);
1308
+ }
1309
+ return content;
1310
+ }
1311
+
1312
+ function assertFingerprintBudget(options, uncachedBytes) {
1313
+ if (options.deadlineAt !== undefined && performance.now() >= options.deadlineAt) {
1314
+ const error = new Error("Workspace attachment verification exceeded its time limit. Reload and try again.");
1315
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1316
+ throw error;
1317
+ }
1318
+ if (options.maxUncachedFileBytes !== undefined
1319
+ && uncachedBytes > options.maxUncachedFileBytes) {
1320
+ const error = new Error("Workspace file verification exceeded its byte limit. Reload and try again.");
1321
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1322
+ throw error;
1323
+ }
1324
+ }
1325
+
1246
1326
  function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
1247
1327
  if (record.type !== "control" || record.status !== "implemented") return;
1248
1328
  const schedules = obligationsByControl.get(record.id) || [];
@@ -2134,6 +2214,23 @@ function validateLocation(record, definition, relativePath, diagnostics) {
2134
2214
  }
2135
2215
  }
2136
2216
 
2217
+ export function assertHistoricalRecordValid(record, model, relativePath) {
2218
+ const displayPath = `data/${relativePath}`;
2219
+ const diagnostics = [];
2220
+ let definition;
2221
+ try {
2222
+ definition = getResourceDefinition(model, record?.type);
2223
+ } catch {
2224
+ throw new Error(`Unknown historical resource type "${record?.type ?? ""}" at ${displayPath}.`);
2225
+ }
2226
+ validateLocation(record, definition, relativePath, diagnostics);
2227
+ validateRecord(record, definition, model, displayPath, diagnostics);
2228
+ validateDateRanges(record, displayPath, diagnostics);
2229
+ if (diagnostics.some(({ severity }) => severity === "error")) {
2230
+ throw new Error(`Invalid historical resource at ${displayPath}: ${diagnostics.map(({ message }) => message).join(" ")}`);
2231
+ }
2232
+ }
2233
+
2137
2234
  function validateRecord(record, definition, model, path, diagnostics) {
2138
2235
  const fields = { ...model.commonFields, ...definition.fields };
2139
2236
  const required = new Set([
package/src/web.js CHANGED
@@ -95,6 +95,7 @@ let repositorySyncPollInFlight = false;
95
95
  let mutationStateRefreshInFlight = false;
96
96
  let mutationStateRefreshTimer = null;
97
97
  let programSelectionGeneration = 0;
98
+ let expiredStateRefresh = null;
98
99
 
99
100
  start().catch((error) => {
100
101
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -193,14 +194,22 @@ function loadStateForRoute() {
193
194
  for (const section of desiredStateSections(route)) loadStateSection(section);
194
195
  }
195
196
 
196
- function loadStateSection(section) {
197
+ function loadStateSection(section, refreshExpired = true) {
197
198
  if (!state.stateToken || state.sections?.[section] === "complete") return Promise.resolve();
198
199
  if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
199
200
  state.sections[section] = "loading";
200
201
  const token = state.stateToken;
201
202
  const programQuery = state.selectedProgramId ? "&programId=" + encodeURIComponent(state.selectedProgramId) : "";
202
- const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
203
- .then((result) => {
203
+ const request = localFetch("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
204
+ .then(async (response) => {
205
+ if (response.status === 409 && refreshExpired && state.stateToken === token) {
206
+ await refreshExpiredAppState(token);
207
+ render();
208
+ if (stateSectionRequests.get(section) === request) stateSectionRequests.delete(section);
209
+ return loadStateSection(section, false);
210
+ }
211
+ if (!response.ok) throw new Error(await responseMessage(response));
212
+ const result = await response.json();
204
213
  if (state.stateToken !== result.stateToken) return;
205
214
  Object.assign(state, result.state);
206
215
  state.sections[section] = "complete";
@@ -2909,17 +2918,35 @@ function evidenceAttachmentPanel(entry) {
2909
2918
  }
2910
2919
 
2911
2920
  async function loadResourceDetail(type, id) {
2912
- const key = type + "\0" + id;
2921
+ const key = (state.stateToken || "live") + "\0" + type + "\0" + id;
2913
2922
  if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
2914
2923
  const request = (async () => {
2915
2924
  try {
2916
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id));
2917
- if (!response.ok) throw new Error(await responseMessage(response));
2918
- const detail = await response.json();
2925
+ let token = state.stateToken;
2926
+ let detail = null;
2927
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2928
+ const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
2929
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
2930
+ if (response.status === 409 && token) {
2931
+ await refreshExpiredAppState(token);
2932
+ token = state.stateToken;
2933
+ continue;
2934
+ }
2935
+ if (!response.ok) throw new Error(await responseMessage(response));
2936
+ detail = await response.json();
2937
+ if (!token || (detail.stateToken === token && state.stateToken === token)) break;
2938
+ token = state.stateToken;
2939
+ detail = null;
2940
+ }
2941
+ if (!detail) throw new Error("The workspace state changed while loading this record. Reload and try again.");
2942
+ delete detail.stateToken;
2919
2943
  const index = state.resources.findIndex(({ record }) => record.type === type && record.id === id);
2920
2944
  if (index >= 0) state.resources[index] = detail;
2921
2945
  const route = parseRoute();
2922
- if (route.name === "detail" && route.type === type && route.id === id) render();
2946
+ if (route.name === "detail" && route.type === type && route.id === id) {
2947
+ render();
2948
+ loadStateForRoute();
2949
+ }
2923
2950
  } catch (error) {
2924
2951
  const route = parseRoute();
2925
2952
  if (route.name === "detail" && route.type === type && route.id === id) {
@@ -2927,13 +2954,52 @@ async function loadResourceDetail(type, id) {
2927
2954
  if (main) main.innerHTML = '<div class="page"><section class="panel"><div class="dialog-error" role="alert">' + esc(error.message) + '</div></section></div>';
2928
2955
  }
2929
2956
  } finally {
2930
- resourceDetailRequests.delete(key);
2957
+ for (const [requestKey, pending] of resourceDetailRequests) {
2958
+ if (pending === request) resourceDetailRequests.delete(requestKey);
2959
+ }
2931
2960
  }
2932
2961
  })();
2933
2962
  resourceDetailRequests.set(key, request);
2934
2963
  return request;
2935
2964
  }
2936
2965
 
2966
+ function refreshExpiredAppState(expectedToken) {
2967
+ if (expiredStateRefresh) {
2968
+ if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
2969
+ return expiredStateRefresh.promise
2970
+ .catch(() => {})
2971
+ .then(() => refreshExpiredAppState(expectedToken));
2972
+ }
2973
+ if (expectedToken && state.stateToken !== expectedToken) return Promise.resolve();
2974
+ const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
2975
+ const holder = { token: expectedToken, promise: null };
2976
+ const refresh = (async () => {
2977
+ const response = await fetch("/api/state/bootstrap" + programQuery);
2978
+ if (!response.ok) throw new Error(await responseMessage(response));
2979
+ const next = normalizeAppState(await response.json());
2980
+ if (expectedToken && state.stateToken !== expectedToken) return;
2981
+ rekeyResourceDetailRequests(state.stateToken, next.stateToken);
2982
+ state = next;
2983
+ stateSectionRequests.clear();
2984
+ })().finally(() => {
2985
+ if (expiredStateRefresh === holder) expiredStateRefresh = null;
2986
+ });
2987
+ holder.promise = refresh;
2988
+ expiredStateRefresh = holder;
2989
+ return refresh;
2990
+ }
2991
+
2992
+ function rekeyResourceDetailRequests(previousToken, nextToken) {
2993
+ if (!previousToken || !nextToken || previousToken === nextToken) return;
2994
+ const prefix = previousToken + "\0";
2995
+ for (const [key, request] of resourceDetailRequests) {
2996
+ if (!key.startsWith(prefix)) continue;
2997
+ const nextKey = nextToken + key.slice(previousToken.length);
2998
+ resourceDetailRequests.delete(key);
2999
+ if (!resourceDetailRequests.has(nextKey)) resourceDetailRequests.set(nextKey, request);
3000
+ }
3001
+ }
3002
+
2937
3003
  function issueSeed(type, source) {
2938
3004
  const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
2939
3005
  if (type === "finding") {
@@ -116,7 +116,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
116
116
  entry.record.type === "exception"
117
117
  && historical.record.reportingRouteSetId
118
118
  && historical.commit !== commits.find((commit) => {
119
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
119
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
120
120
  })
121
121
  ) {
122
122
  const records = recordsAtRevision(loaded, historical.commit);
@@ -146,7 +146,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
146
146
  }
147
147
 
148
148
  const firstV10Commit = commits.find((commit) => {
149
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
149
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
150
150
  });
151
151
  const protectedRouteSources = new Map();
152
152
  for (const [commit, records] of revisionRecords) {
@@ -307,7 +307,8 @@ function legacyCollectionReviewTransition(loaded, previous, current) {
307
307
  try {
308
308
  const workspace = JSON.parse(getFileAtRevision(loaded.root, current.commit, "data/workspace.json"));
309
309
  return Number(workspace?.dataModelVersion) < 9;
310
- } catch {
310
+ } catch (error) {
311
+ rethrowGitDeadline(error);
311
312
  return false;
312
313
  }
313
314
  }
@@ -319,7 +320,8 @@ function historicalState(loaded) {
319
320
  try {
320
321
  const workspace = JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"));
321
322
  return Number(workspace?.dataModelVersion) >= 9;
322
- } catch {
323
+ } catch (error) {
324
+ rethrowGitDeadline(error);
323
325
  return false;
324
326
  }
325
327
  });
@@ -392,7 +394,8 @@ function legacyCollectionReview(loaded, historical) {
392
394
  let workspace;
393
395
  try {
394
396
  workspace = JSON.parse(getFileAtRevision(loaded.root, historical.commit, "data/workspace.json"));
395
- } catch {
397
+ } catch (error) {
398
+ rethrowGitDeadline(error);
396
399
  return false;
397
400
  }
398
401
  return Number(workspace?.dataModelVersion) < 9
@@ -408,7 +411,8 @@ function recordAtRevision(loaded, commit, id, identityHistory) {
408
411
  try {
409
412
  const record = JSON.parse(source);
410
413
  return record?.id === id ? { record, path: item.path } : null;
411
- } catch {
414
+ } catch (error) {
415
+ rethrowGitDeadline(error);
412
416
  return null;
413
417
  }
414
418
  }
@@ -454,7 +458,7 @@ async function validateReportingRouteAuthorityHistory(loaded, commits, revisionR
454
458
  ));
455
459
  if (!routeSets.length) return;
456
460
  const firstV10Commit = commits.find((commit) => {
457
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
461
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
458
462
  });
459
463
  for (const route of routeSets) {
460
464
  const versions = changedVersions(route.id, commits, revisionRecords, historiesById);
@@ -733,7 +737,8 @@ function authorityAppointmentOverlapsRoute(appointment, route, workspaceId, time
733
737
  routeEnd = route.cancellation?.canceledAt
734
738
  ? currentCalendarDate(timezone, new Date(route.cancellation.canceledAt))
735
739
  : null;
736
- } catch {
740
+ } catch (error) {
741
+ rethrowGitDeadline(error);
737
742
  return false;
738
743
  }
739
744
  return (!routeEnd || appointment.startsOn <= routeEnd)
@@ -786,7 +791,8 @@ function historicalWorkspaceTimezoneAtRevision(loaded, commit) {
786
791
  if (typeof workspace?.timezone !== "string" || !workspace.timezone.trim()) return null;
787
792
  new Intl.DateTimeFormat("en", { timeZone: workspace.timezone }).format();
788
793
  return workspace.timezone;
789
- } catch {
794
+ } catch (error) {
795
+ rethrowGitDeadline(error);
790
796
  return null;
791
797
  }
792
798
  }
@@ -901,3 +907,7 @@ function integrityError(entry, message) {
901
907
  message
902
908
  };
903
909
  }
910
+
911
+ function rethrowGitDeadline(error) {
912
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
913
+ }
package/src/workflow.js CHANGED
@@ -141,7 +141,9 @@ async function assessWorkflowUnmeasured(input, options = {}) {
141
141
  asOf,
142
142
  evaluatedAt,
143
143
  programId: programRecord.type === "program" ? programRecord.id : undefined,
144
- controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds
144
+ controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds,
145
+ historyDeadlineAt: options.historyDeadlineAt,
146
+ strictHistory: options.strictHistory
145
147
  });
146
148
  const guidedFindings = [
147
149
  ...programFindings(program),
@@ -732,7 +734,10 @@ async function assessPeriodHealth(loaded, options) {
732
734
  "component"
733
735
  ].includes(record.type));
734
736
  const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`);
735
- const histories = getWorkspaceHistories(loaded.root, paths, 50);
737
+ const histories = getWorkspaceHistories(loaded.root, paths, 50, {
738
+ deadlineAt: options.historyDeadlineAt,
739
+ strict: options.strictHistory === true
740
+ });
736
741
  const allHistory = [...histories.values()].flat().filter(Boolean);
737
742
  const earliestCommitDate = allHistory
738
743
  .map(({ timestamp }) => timestamp?.slice(0, 10))
package/src/workspace.js CHANGED
@@ -14,11 +14,13 @@ async function loadWorkspaceUnmeasured(input) {
14
14
  const diagnostics = [];
15
15
  const files = await collectJsonFiles(dataRoot);
16
16
  const resources = [];
17
+ const sourceEntries = [];
17
18
 
18
19
  for (const path of files) {
19
20
  const relativePath = relative(dataRoot, path).split(sep).join("/");
20
21
  try {
21
22
  const source = await readFile(path, "utf8");
23
+ sourceEntries.push({ path, relativePath, source });
22
24
  const record = JSON.parse(source);
23
25
  resources.push({ record, path, relativePath, source });
24
26
  } catch (error) {
@@ -68,6 +70,7 @@ async function loadWorkspaceUnmeasured(input) {
68
70
  model,
69
71
  workspace: workspaceEntry?.record ?? null,
70
72
  entries: resources,
73
+ sourceEntries,
71
74
  resources: resources.map(({ record }) => record),
72
75
  diagnostics
73
76
  };