ts-flp 1.0.5 → 1.1.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/README.md CHANGED
@@ -160,6 +160,14 @@ interface PluginRef {
160
160
  | `getFlVersion(parsed)` | Get the FL Studio version string |
161
161
  | `getPPQ(parsed)` | Get the PPQ (Pulses Per Quarter note) |
162
162
 
163
+ ### Write behavior
164
+
165
+ - `undefined` leaves the corresponding value unchanged.
166
+ - `null` clears text metadata, leaves BPM unchanged, and resets time values to zero.
167
+ - Missing metadata and timestamp events are created in the project preamble.
168
+ - BPM values are validated against the limits supported by the detected FL Studio version.
169
+ - Invalid dates, negative work times, and non-finite numeric values throw an error.
170
+
163
171
  ## Constraints and limitations
164
172
 
165
173
  ### What this library does
@@ -197,6 +205,9 @@ src/
197
205
  # Install dependencies
198
206
  pnpm install
199
207
 
208
+ # Add at least one private .flp file directly to test_projs/
209
+ # These fixtures are ignored by Git and are required by the integration tests.
210
+
200
211
  # Type checking
201
212
  pnpm run typecheck
202
213
 
@@ -215,7 +226,7 @@ pnpm run build
215
226
 
216
227
  ## License
217
228
 
218
- [GPL-3.0](LICENSE)
229
+ [MIT](LICENSE)
219
230
 
220
231
  ---
221
232
 
@@ -224,3 +235,40 @@ pnpm run build
224
235
  A huge thank you to **[@demberto](https://github.com/demberto)** ([PyFLP](https://github.com/demberto/PyFLP)), **[@monadgroup](https://github.com/monadgroup)** ([FLParser](https://github.com/monadgroup/FLParser)), and **FLPEdit** for their incredible reverse-engineering work on the FL Studio `.flp` format.
225
236
 
226
237
  This project would never have been possible without them.
238
+
239
+ ## Optional sample inspection
240
+
241
+ Existing `listSamples(parsed)` remains synchronous and returns the same
242
+ `{ eventIndex, path }` entries without accessing disk. Use the additive API:
243
+
244
+ ```typescript
245
+ import { inspectSamples } from 'ts-flp';
246
+ const samples = await inspectSamples(parsed, {
247
+ mode: 'check', // default: 'resolve', which does not access the filesystem
248
+ projectPath: '/music/Project/Song.flp',
249
+ searchRoots: [],
250
+ });
251
+ ```
252
+
253
+ Results preserve occurrences (`referenceIndex` is the original event index) and
254
+ include `recordedPath`, `pathType`, `relativePath`, `candidates`, `resolvedPath`,
255
+ `status`, and `reason`. Status is `unchecked`, `found`, `missing`, `unresolved`,
256
+ `ambiguous`, or `inaccessible`. Found means a regular file, not validated audio.
257
+ Missing means absent at the checked locations, not absent everywhere.
258
+
259
+ Relative paths use explicit `projectRoot`, or the parent of `projectPath`.
260
+ Context paths must be native absolute paths; no implicit cwd resolution.
261
+ Foreign Windows paths on POSIX and unexpanded DAW placeholders remain unresolved.
262
+ `searchRoots` opt into recursive discovery in check mode only; name-only matches
263
+ remain suggestions (`confidence: 'name-only'`) and never resolve automatically.
264
+ Multiple existing exact candidates also remain ambiguous.
265
+
266
+ Checks are asynchronous with `concurrency` (default 8, maximum 64), optional
267
+ `signal` cancellation, per-call caching and `maxSearchEntries` (default 100,000).
268
+ Directory symlinks encountered during search are not traversed. No project or
269
+ sample files are modified. Dependency files hidden inside plugin state are not
270
+ inventoried by this API. The result is a filesystem snapshot and can become stale.
271
+
272
+ Release preparation: after building, run `pnpm run check:package` to inspect a
273
+ real npm archive, install it in isolation and verify ESM/CommonJS imports.
274
+ This does not publish a package or change its version.
package/dist/index.cjs CHANGED
@@ -46,6 +46,7 @@ __export(index_exports, {
46
46
  getEventString: () => getEventString,
47
47
  getFlVersion: () => getFlVersion,
48
48
  getPPQ: () => getPPQ,
49
+ inspectSamples: () => inspectSamples,
49
50
  listPlugins: () => listPlugins,
50
51
  listSamples: () => listSamples,
51
52
  parseFlp: () => parseFlp,
@@ -65,6 +66,9 @@ var WORD = 64;
65
66
  var DWORD = 128;
66
67
  var TEXT = 192;
67
68
  var DATA = 208;
69
+ var FIXED_SIZE_OVERRIDES = {
70
+ 172: 3
71
+ };
68
72
  var NEW_TEXT_IDS = [
69
73
  TEXT + 49,
70
74
  // ArrangementID.Name
@@ -211,6 +215,7 @@ var EVENT_ID = {
211
215
  // 236
212
216
  };
213
217
  var EVENT_KIND = {
218
+ [172]: "unknown",
214
219
  // Project events
215
220
  [EVENT_ID.PROJECT_LOOP_ACTIVE]: "u8",
216
221
  [EVENT_ID.PROJECT_SHOW_INFO]: "u8",
@@ -319,6 +324,10 @@ function getEventKind(eventId) {
319
324
  }
320
325
  }
321
326
  function getEventFixedSize(eventId) {
327
+ const override = FIXED_SIZE_OVERRIDES[eventId];
328
+ if (override !== void 0) {
329
+ return override;
330
+ }
322
331
  if (eventId < WORD) {
323
332
  return 1;
324
333
  } else if (eventId < DWORD) {
@@ -595,62 +604,9 @@ function varIntSize(value) {
595
604
  }
596
605
 
597
606
  // src/parser/FlpParser.ts
598
- var KNOWN_DWORD_IDS = new Set(
599
- Object.keys(EVENT_KIND).map(Number).filter((id) => id >= DWORD && id < TEXT)
600
- );
601
607
  var FLP_HEADER_MAGIC = "FLhd";
602
608
  var FLP_DATA_MAGIC = "FLdt";
603
609
  var FLP_HEADER_SIZE = 6;
604
- var LOOKAHEAD_BYTES = 200;
605
- var LOOKAHEAD_VARINT_MAX = 1e5;
606
- function readVarIntRaw(buf, off, end) {
607
- let shift = 0;
608
- let size = 0;
609
- const start = off;
610
- while (true) {
611
- if (off >= end) return { size: -1, bytesRead: 0 };
612
- const byte = buf[off];
613
- off++;
614
- size |= (byte & 127) << shift;
615
- shift += 7;
616
- if (!(byte & 128)) break;
617
- }
618
- return { size, bytesRead: off - start };
619
- }
620
- function scoreAlignment(buf, off, end, bytesToCheck) {
621
- const limit = Math.min(off + bytesToCheck, end);
622
- let textDataCount = 0;
623
- let consecutiveSmallByte = 0;
624
- let maxConsecutiveSmallByte = 0;
625
- while (off < limit) {
626
- const id = buf[off];
627
- if (id < WORD) {
628
- off += 2;
629
- if (id < 32 && !KNOWN_DWORD_IDS.has(id)) {
630
- consecutiveSmallByte++;
631
- maxConsecutiveSmallByte = Math.max(maxConsecutiveSmallByte, consecutiveSmallByte);
632
- } else {
633
- consecutiveSmallByte = 0;
634
- }
635
- } else if (id < DWORD) {
636
- off += 3;
637
- consecutiveSmallByte = 0;
638
- } else if (id < TEXT) {
639
- off += 5;
640
- consecutiveSmallByte = 0;
641
- } else {
642
- const vi = readVarIntRaw(buf, off + 1, end);
643
- if (vi.size >= 0 && vi.size < LOOKAHEAD_VARINT_MAX && off + 1 + vi.bytesRead + vi.size <= end) {
644
- textDataCount++;
645
- off = off + 1 + vi.bytesRead + vi.size;
646
- } else {
647
- return -100;
648
- }
649
- consecutiveSmallByte = 0;
650
- }
651
- }
652
- return textDataCount * 10 - maxConsecutiveSmallByte * 3;
653
- }
654
610
  function parseFlp(buffer) {
655
611
  const reader = new BinaryReader(buffer);
656
612
  const headerMagic = reader.readBytes(4).toString("ascii");
@@ -662,8 +618,8 @@ function parseFlp(buffer) {
662
618
  throw new Error(`Invalid header size: expected ${FLP_HEADER_SIZE}, got ${headerSize}`);
663
619
  }
664
620
  const format = reader.readI16LE();
665
- const channelCount = reader.readU16LE();
666
- const ppq = reader.readU16LE();
621
+ reader.readU16LE();
622
+ reader.readU16LE();
667
623
  if (format < -1 || format > 80) {
668
624
  throw new Error(`Invalid file format: ${format}`);
669
625
  }
@@ -690,39 +646,10 @@ function parseFlp(buffer) {
690
646
  const eventId = reader.readU8();
691
647
  let payloadSize;
692
648
  let headerSize2;
693
- if (eventId < WORD) {
694
- payloadSize = 1;
695
- headerSize2 = 1;
696
- } else if (eventId < DWORD) {
697
- payloadSize = 2;
649
+ const fixedSize = getEventFixedSize(eventId);
650
+ if (fixedSize >= 0) {
651
+ payloadSize = fixedSize;
698
652
  headerSize2 = 1;
699
- } else if (eventId < TEXT) {
700
- if (KNOWN_DWORD_IDS.has(eventId)) {
701
- payloadSize = 4;
702
- headerSize2 = 1;
703
- } else {
704
- const afterDword = eventStart + 1 + 4;
705
- const vi = readVarIntRaw(buffer, eventStart + 1, eventsEnd);
706
- if (vi.size < 0 || vi.size > LOOKAHEAD_VARINT_MAX || eventStart + 1 + vi.bytesRead + vi.size > eventsEnd) {
707
- payloadSize = 4;
708
- headerSize2 = 1;
709
- } else if (vi.size === 3) {
710
- payloadSize = 4;
711
- headerSize2 = 1;
712
- } else {
713
- const afterVarInt = eventStart + 1 + vi.bytesRead + vi.size;
714
- const dwordScore = scoreAlignment(buffer, afterDword, eventsEnd, LOOKAHEAD_BYTES);
715
- const varIntScore = scoreAlignment(buffer, afterVarInt, eventsEnd, LOOKAHEAD_BYTES);
716
- if (varIntScore > dwordScore + 2) {
717
- payloadSize = vi.size;
718
- reader.seek(eventStart + 1 + vi.bytesRead);
719
- headerSize2 = 1 + vi.bytesRead;
720
- } else {
721
- payloadSize = 4;
722
- headerSize2 = 1;
723
- }
724
- }
725
- }
726
653
  } else {
727
654
  const sizeStart = reader.tell();
728
655
  payloadSize = reader.readVarInt();
@@ -905,6 +832,46 @@ function createNumberPayload(value, kind) {
905
832
  }
906
833
 
907
834
  // src/api/ProjectApi.ts
835
+ var PROJECT_EVENT_ORDER = [
836
+ EVENT_ID.PROJECT_TEMPO,
837
+ EVENT_ID.PROJECT_TITLE,
838
+ EVENT_ID.PROJECT_GENRE,
839
+ EVENT_ID.PROJECT_ARTISTS,
840
+ EVENT_ID.PROJECT_COMMENTS,
841
+ EVENT_ID.PROJECT_TIMESTAMP
842
+ ];
843
+ function insertProjectEvent(parsed, event) {
844
+ const orderIndex = PROJECT_EVENT_ORDER.indexOf(event.id);
845
+ let insertionIndex = -1;
846
+ if (orderIndex >= 0) {
847
+ const laterIds = new Set(PROJECT_EVENT_ORDER.slice(orderIndex + 1));
848
+ insertionIndex = parsed.events.findIndex((candidate) => laterIds.has(candidate.id));
849
+ if (insertionIndex < 0) {
850
+ const previousIds = new Set(PROJECT_EVENT_ORDER.slice(0, orderIndex + 1));
851
+ for (let index = parsed.events.length - 1; index >= 0; index--) {
852
+ if (previousIds.has(parsed.events[index].id)) {
853
+ insertionIndex = index + 1;
854
+ break;
855
+ }
856
+ }
857
+ }
858
+ }
859
+ if (insertionIndex < 0) {
860
+ insertionIndex = parsed.events.findIndex((candidate) => candidate.id === EVENT_ID.CHANNEL_NEW);
861
+ }
862
+ if (insertionIndex < 0) {
863
+ const versionIndex = parsed.events.findIndex(
864
+ (candidate) => candidate.id === EVENT_ID.PROJECT_FL_VERSION
865
+ );
866
+ insertionIndex = versionIndex >= 0 ? versionIndex + 1 : parsed.events.length;
867
+ }
868
+ const events = [...parsed.events];
869
+ events.splice(insertionIndex, 0, event);
870
+ return { ...parsed, events };
871
+ }
872
+ function replaceEventPayload(parsed, target, payload) {
873
+ return patchEvents(parsed, (event) => event === target ? { ...event, payload } : event);
874
+ }
908
875
  function readBpm(parsed) {
909
876
  const tempoEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
910
877
  if (tempoEvent) {
@@ -934,48 +901,97 @@ function readProjectMeta(parsed) {
934
901
  bpm: readBpm(parsed)
935
902
  };
936
903
  }
904
+ function compareVersion(version, expected) {
905
+ const actual = version.split(".").map((part) => Number.parseInt(part, 10));
906
+ const length = Math.max(actual.length, expected.length);
907
+ for (let index = 0; index < length; index++) {
908
+ const difference = (actual[index] ?? 0) - (expected[index] ?? 0);
909
+ if (difference !== 0) return Math.sign(difference);
910
+ }
911
+ return 0;
912
+ }
913
+ function validateBpm(parsed, bpm) {
914
+ if (!Number.isFinite(bpm)) {
915
+ throw new RangeError("BPM must be a finite number");
916
+ }
917
+ const legacyHighTempo = compareVersion(parsed.flVersion, [1, 4, 2]) >= 0 && compareVersion(parsed.flVersion, [11]) < 0;
918
+ const maximum = legacyHighTempo ? 999 : 522;
919
+ if (bpm < 10 || bpm > maximum) {
920
+ throw new RangeError(`BPM must be between 10 and ${maximum} for FL Studio ${parsed.flVersion}`);
921
+ }
922
+ if (parsed.flVersion !== "0.0.0" && compareVersion(parsed.flVersion, [3, 4]) < 0 && !Number.isInteger(bpm)) {
923
+ throw new TypeError(`FL Studio ${parsed.flVersion} does not support decimal BPM values`);
924
+ }
925
+ }
926
+ function writeTextProjectEvent(parsed, eventId, value) {
927
+ if (value === void 0) return parsed;
928
+ const payload = createTextPayload(value ?? "", parsed.useUnicode);
929
+ const existing = findFirstEvent(parsed, eventId);
930
+ return existing ? replaceEventPayload(parsed, existing, payload) : insertProjectEvent(parsed, createEvent(eventId, payload));
931
+ }
932
+ function writeProjectBpm(parsed, bpm) {
933
+ if (bpm === void 0 || bpm === null) return parsed;
934
+ validateBpm(parsed, bpm);
935
+ const scaledBpm = Math.round(bpm * 1e3);
936
+ const coarseBpm = Math.floor(scaledBpm / 1e3);
937
+ const fineBpm = scaledBpm % 1e3;
938
+ const modernEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
939
+ const coarseEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_COARSE);
940
+ const fineEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_FINE);
941
+ let result = parsed;
942
+ if (modernEvent) {
943
+ result = replaceEventPayload(result, modernEvent, createNumberPayload(scaledBpm, "u32"));
944
+ }
945
+ if (coarseEvent) {
946
+ result = replaceEventPayload(result, coarseEvent, createNumberPayload(coarseBpm, "u16"));
947
+ }
948
+ if (fineEvent) {
949
+ result = replaceEventPayload(result, fineEvent, createNumberPayload(fineBpm, "u16"));
950
+ }
951
+ if (!modernEvent && !coarseEvent && !fineEvent) {
952
+ result = insertProjectEvent(
953
+ result,
954
+ createEvent(EVENT_ID.PROJECT_TEMPO, createNumberPayload(scaledBpm, "u32"))
955
+ );
956
+ } else if (!modernEvent && coarseEvent && !fineEvent && fineBpm > 0) {
957
+ const updatedCoarse = findFirstEvent(result, EVENT_ID.PROJECT__TEMPO_COARSE);
958
+ const index = result.events.indexOf(updatedCoarse) + 1;
959
+ const events = [...result.events];
960
+ events.splice(
961
+ index,
962
+ 0,
963
+ createEvent(EVENT_ID.PROJECT__TEMPO_FINE, createNumberPayload(fineBpm, "u16"))
964
+ );
965
+ result = { ...result, events };
966
+ } else if (!modernEvent && !coarseEvent && fineEvent) {
967
+ const updatedFine = findFirstEvent(result, EVENT_ID.PROJECT__TEMPO_FINE);
968
+ const index = result.events.indexOf(updatedFine);
969
+ const events = [...result.events];
970
+ events.splice(
971
+ index,
972
+ 0,
973
+ createEvent(EVENT_ID.PROJECT__TEMPO_COARSE, createNumberPayload(coarseBpm, "u16"))
974
+ );
975
+ result = { ...result, events };
976
+ }
977
+ return result;
978
+ }
937
979
  function writeProjectMeta(parsed, meta) {
938
- return patchEvents(parsed, (event) => {
939
- if (meta.name !== void 0 && event.id === EVENT_ID.PROJECT_TITLE) {
940
- return {
941
- ...event,
942
- payload: createTextPayload(meta.name ?? "", parsed.useUnicode)
943
- };
944
- }
945
- if (meta.description !== void 0 && event.id === EVENT_ID.PROJECT_COMMENTS) {
946
- return {
947
- ...event,
948
- payload: createTextPayload(meta.description ?? "", parsed.useUnicode)
949
- };
950
- }
951
- if (meta.artist !== void 0 && event.id === EVENT_ID.PROJECT_ARTISTS) {
952
- return {
953
- ...event,
954
- payload: createTextPayload(meta.artist ?? "", parsed.useUnicode)
955
- };
956
- }
957
- if (meta.genre !== void 0 && event.id === EVENT_ID.PROJECT_GENRE) {
958
- return {
959
- ...event,
960
- payload: createTextPayload(meta.genre ?? "", parsed.useUnicode)
961
- };
962
- }
963
- if (meta.bpm && Math.abs(meta.bpm) > 0 && event.id === EVENT_ID.PROJECT_TEMPO) {
964
- return {
965
- ...event,
966
- payload: createNumberPayload(Math.round(Math.abs(meta.bpm) * 1e3), "u32")
967
- };
968
- }
969
- return event;
970
- });
980
+ let result = parsed;
981
+ result = writeProjectBpm(result, meta.bpm);
982
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_TITLE, meta.name);
983
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_GENRE, meta.genre);
984
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_ARTISTS, meta.artist);
985
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_COMMENTS, meta.description);
986
+ return result;
971
987
  }
972
- var DELPHI_EPOCH = new Date(1899, 11, 30);
988
+ var DELPHI_EPOCH_MS = Date.UTC(1899, 11, 30);
973
989
  function delphiToDate(days) {
974
990
  const ms = days * 24 * 60 * 60 * 1e3;
975
- return new Date(DELPHI_EPOCH.getTime() + ms);
991
+ return new Date(DELPHI_EPOCH_MS + ms);
976
992
  }
977
993
  function dateToDelphiDays(date) {
978
- const ms = date.getTime() - DELPHI_EPOCH.getTime();
994
+ const ms = date.getTime() - DELPHI_EPOCH_MS;
979
995
  return ms / (24 * 60 * 60 * 1e3);
980
996
  }
981
997
  function readProjectTimeInfo(parsed) {
@@ -996,41 +1012,33 @@ function readProjectTimeInfo(parsed) {
996
1012
  };
997
1013
  }
998
1014
  function writeProjectTimeInfo(parsed, info) {
1015
+ if (info.creationDate === void 0 && info.workTimeSeconds === void 0) {
1016
+ return parsed;
1017
+ }
999
1018
  const current = readProjectTimeInfo(parsed);
1000
- return patchEvents(parsed, (event) => {
1001
- if (event.id !== EVENT_ID.PROJECT_TIMESTAMP) {
1002
- return event;
1003
- }
1004
- const writer = new BinaryWriter();
1005
- if (info.creationDate !== void 0 && info.creationDate !== null) {
1006
- writer.writeF64LE(dateToDelphiDays(info.creationDate));
1007
- } else if (current.creationDate !== null) {
1008
- writer.writeF64LE(dateToDelphiDays(current.creationDate));
1009
- } else {
1010
- writer.writeF64LE(0);
1011
- }
1012
- if (info.workTimeSeconds !== void 0 && info.workTimeSeconds !== null) {
1013
- writer.writeF64LE(info.workTimeSeconds / (24 * 60 * 60));
1014
- } else if (current.workTimeSeconds !== null) {
1015
- writer.writeF64LE(current.workTimeSeconds / (24 * 60 * 60));
1016
- } else {
1017
- writer.writeF64LE(0);
1018
- }
1019
- return {
1020
- ...event,
1021
- payload: writer.toBuffer()
1022
- };
1023
- });
1019
+ if (info.creationDate !== void 0 && info.creationDate !== null && Number.isNaN(info.creationDate.getTime())) {
1020
+ throw new RangeError("creationDate must be a valid Date");
1021
+ }
1022
+ if (info.workTimeSeconds !== void 0 && info.workTimeSeconds !== null && (!Number.isFinite(info.workTimeSeconds) || info.workTimeSeconds < 0)) {
1023
+ throw new RangeError("workTimeSeconds must be a finite, non-negative number");
1024
+ }
1025
+ const creationDateDays = info.creationDate === null ? 0 : info.creationDate !== void 0 ? dateToDelphiDays(info.creationDate) : current.creationDate !== null ? dateToDelphiDays(current.creationDate) : 0;
1026
+ const workTimeDays = info.workTimeSeconds === null ? 0 : info.workTimeSeconds !== void 0 ? info.workTimeSeconds / (24 * 60 * 60) : current.workTimeSeconds !== null ? current.workTimeSeconds / (24 * 60 * 60) : 0;
1027
+ const writer = new BinaryWriter();
1028
+ writer.writeF64LE(creationDateDays);
1029
+ writer.writeF64LE(workTimeDays);
1030
+ const timestampEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TIMESTAMP);
1031
+ return timestampEvent ? replaceEventPayload(parsed, timestampEvent, writer.toBuffer()) : insertProjectEvent(parsed, createEvent(EVENT_ID.PROJECT_TIMESTAMP, writer.toBuffer()));
1024
1032
  }
1025
1033
  function listSamples(parsed) {
1026
1034
  const samples = [];
1027
1035
  parsed.events.forEach((event, index) => {
1028
1036
  if (event.id === EVENT_ID.CHANNEL_SAMPLE_PATH) {
1029
- const path = getEventString(event, parsed.useUnicode);
1030
- if (path && path.length > 0) {
1037
+ const path2 = getEventString(event, parsed.useUnicode);
1038
+ if (path2 && path2.length > 0) {
1031
1039
  samples.push({
1032
1040
  eventIndex: index,
1033
- path
1041
+ path: path2
1034
1042
  });
1035
1043
  }
1036
1044
  }
@@ -1158,6 +1166,147 @@ function getPPQ(parsed) {
1158
1166
  reader.seek(12);
1159
1167
  return reader.readU16LE();
1160
1168
  }
1169
+
1170
+ // src/samples/inspectReferences.ts
1171
+ var import_node_path = __toESM(require("path"), 1);
1172
+ var fs = __toESM(require("fs/promises"), 1);
1173
+ function pathType(p) {
1174
+ if (!p || Array.from(p).some((c) => c.charCodeAt(0) < 32)) return "unknown";
1175
+ if (/^[A-Za-z]:/.test(p) || p.startsWith("\\")) return "windows";
1176
+ if (p.startsWith("/")) return "posix";
1177
+ if (/^[A-Za-z][\w+.-]*:/.test(p) || /[%$]/.test(p)) return "daw";
1178
+ return "relative";
1179
+ }
1180
+ function nativeAbsolute(p) {
1181
+ const kind = pathType(p);
1182
+ return process.platform === "win32" ? kind === "windows" && import_node_path.default.win32.isAbsolute(p) && !/^[\\/][^\\/]/.test(p) : kind === "posix";
1183
+ }
1184
+ function localRelative(p) {
1185
+ return pathType(p) === "relative";
1186
+ }
1187
+ function abort(signal) {
1188
+ if (signal?.aborted) {
1189
+ const e = new Error("Sample inspection aborted");
1190
+ e.name = "AbortError";
1191
+ throw e;
1192
+ }
1193
+ }
1194
+ async function inspectReferences(refs, options = {}) {
1195
+ const mode = options.mode ?? "resolve";
1196
+ if (mode !== "resolve" && mode !== "check") throw new TypeError("Unknown sample inspection mode");
1197
+ const concurrency = options.concurrency ?? 8;
1198
+ const maxEntries = options.maxSearchEntries ?? 1e5;
1199
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) throw new RangeError("Concurrency must be between 1 and 64");
1200
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new RangeError("Search limit must be a positive safe integer");
1201
+ for (const p of [options.projectPath, options.projectRoot, ...Object.values(options.relativeRoots ?? {}), ...options.searchRoots ?? []]) {
1202
+ if (p !== void 0 && !nativeAbsolute(p)) throw new TypeError("Inspection context paths must be native absolute paths");
1203
+ }
1204
+ abort(options.signal);
1205
+ const results = refs.map((ref) => {
1206
+ const kind = pathType(ref.path);
1207
+ const candidates = [];
1208
+ const add = (p, source) => {
1209
+ const normalized = import_node_path.default.normalize(p);
1210
+ if (!candidates.some((c) => c.path === normalized)) candidates.push({ path: normalized, source, confidence: "exact" });
1211
+ };
1212
+ if (ref.supported !== false) {
1213
+ if (nativeAbsolute(ref.path)) add(ref.path, "declared");
1214
+ const base = ref.relativeBase === "project-file" ? options.projectRoot ?? (options.projectPath ? import_node_path.default.dirname(options.projectPath) : void 0) : options.relativeRoots?.[ref.relativeType ?? ""] ?? (ref.relativeType === "3" ? options.projectRoot : void 0);
1215
+ const relative = ref.relativePath || (kind === "relative" ? ref.path : null);
1216
+ if (base && relative && localRelative(relative)) add(import_node_path.default.resolve(base, relative.replace(/\\/g, import_node_path.default.sep)), "relative");
1217
+ }
1218
+ return {
1219
+ referenceIndex: ref.referenceIndex,
1220
+ recordedPath: ref.path,
1221
+ pathType: ref.supported === false ? "unknown" : kind,
1222
+ relativePath: ref.relativePath ?? (kind === "relative" ? ref.path : null),
1223
+ candidates,
1224
+ resolvedPath: null,
1225
+ status: candidates.length ? "unchecked" : "unresolved",
1226
+ reason: candidates.length ? null : "No interpretable path in the supplied context"
1227
+ };
1228
+ });
1229
+ if (mode === "resolve") return results;
1230
+ const checks = /* @__PURE__ */ new Map();
1231
+ const check = (p) => {
1232
+ let pending = checks.get(p);
1233
+ if (!pending) {
1234
+ pending = fs.stat(p).then((s) => s.isFile() ? "file" : "absent", (e) => e.code === "ENOENT" || e.code === "ENOTDIR" ? "absent" : "inaccessible");
1235
+ checks.set(p, pending);
1236
+ }
1237
+ return pending;
1238
+ };
1239
+ let index = 0;
1240
+ await Promise.all(Array.from({ length: Math.min(concurrency, results.length) }, async () => {
1241
+ while (index < results.length) {
1242
+ abort(options.signal);
1243
+ const result = results[index++];
1244
+ const found = [];
1245
+ let inaccessible = false;
1246
+ for (const candidate of result.candidates) {
1247
+ abort(options.signal);
1248
+ const status = await check(candidate.path);
1249
+ if (status === "file") found.push(candidate.path);
1250
+ if (status === "inaccessible") inaccessible = true;
1251
+ }
1252
+ result.status = found.length > 1 ? "ambiguous" : found.length === 1 ? "found" : inaccessible ? "inaccessible" : result.candidates.length ? "missing" : "unresolved";
1253
+ result.resolvedPath = result.status === "found" ? found[0] : null;
1254
+ if (result.status === "inaccessible") result.reason = "A candidate could not be checked";
1255
+ }
1256
+ }));
1257
+ abort(options.signal);
1258
+ const unresolved = results.filter((r) => r.status !== "found");
1259
+ if (unresolved.length && options.searchRoots?.length) {
1260
+ const names = /* @__PURE__ */ new Map();
1261
+ const needed = new Set(unresolved.map((r) => import_node_path.default.posix.basename(r.recordedPath.replace(/\\/g, "/")).normalize("NFC").toLowerCase()));
1262
+ const queue = [...new Set(options.searchRoots.map((r) => import_node_path.default.normalize(r)))];
1263
+ const visited = /* @__PURE__ */ new Set();
1264
+ let entries = 0;
1265
+ let incomplete = false;
1266
+ while (queue.length) {
1267
+ abort(options.signal);
1268
+ const directory = queue.pop();
1269
+ if (visited.has(directory)) continue;
1270
+ visited.add(directory);
1271
+ try {
1272
+ const handle = await fs.opendir(directory);
1273
+ for await (const entry of handle) {
1274
+ abort(options.signal);
1275
+ if (++entries > maxEntries) throw new RangeError("Sample search entry limit exceeded");
1276
+ const p = import_node_path.default.join(directory, entry.name);
1277
+ if (entry.isDirectory()) queue.push(p);
1278
+ else if (entry.isFile()) {
1279
+ const name = entry.name.normalize("NFC").toLowerCase();
1280
+ if (needed.has(name)) names.set(name, [...names.get(name) ?? [], p]);
1281
+ }
1282
+ }
1283
+ } catch (e) {
1284
+ if (e instanceof RangeError || e instanceof Error && e.name === "AbortError") throw e;
1285
+ incomplete = true;
1286
+ }
1287
+ }
1288
+ for (const result of unresolved) {
1289
+ const name = import_node_path.default.posix.basename(result.recordedPath.replace(/\\/g, "/")).normalize("NFC").toLowerCase();
1290
+ const matches = names.get(name) ?? [];
1291
+ for (const p of matches) if (!result.candidates.some((c) => c.path === p)) result.candidates.push({ path: p, source: "search", confidence: "name-only" });
1292
+ if (result.status === "ambiguous") continue;
1293
+ if (matches.length) {
1294
+ result.status = matches.length > 1 ? "ambiguous" : "unresolved";
1295
+ result.reason = "Name-only candidates require caller confirmation";
1296
+ } else if (incomplete && result.status !== "inaccessible") {
1297
+ result.status = "inaccessible";
1298
+ result.reason = "Search could not inspect every requested directory";
1299
+ }
1300
+ }
1301
+ }
1302
+ abort(options.signal);
1303
+ return results;
1304
+ }
1305
+
1306
+ // src/api/inspectSamples.ts
1307
+ function inspectSamples(parsed, options = {}) {
1308
+ return inspectReferences(listSamples(parsed).map((s) => ({ referenceIndex: s.eventIndex, path: s.path, relativeBase: "project-file" })), options);
1309
+ }
1161
1310
  // Annotate the CommonJS export names for ESM import in node:
1162
1311
  0 && (module.exports = {
1163
1312
  BinaryReader,
@@ -1176,6 +1325,7 @@ function getPPQ(parsed) {
1176
1325
  getEventString,
1177
1326
  getFlVersion,
1178
1327
  getPPQ,
1328
+ inspectSamples,
1179
1329
  listPlugins,
1180
1330
  listSamples,
1181
1331
  parseFlp,