dsh-plugin-compat-check 0.1.6 → 0.1.7

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.
Files changed (46) hide show
  1. package/README.md +17 -30
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/index.js +1295 -550
  4. package/kernel/lib/api-semantics.mjs +223 -0
  5. package/kernel/lib/author-ci-budget.mjs +25 -0
  6. package/kernel/lib/author-ci-output.mjs +22 -0
  7. package/kernel/lib/author-ci-policy.mjs +6 -1
  8. package/kernel/lib/baseline.mjs +109 -38
  9. package/kernel/lib/catalog.mjs +11 -3
  10. package/kernel/lib/contracts.mjs +16 -1
  11. package/kernel/lib/execution-environment.mjs +52 -0
  12. package/kernel/lib/extract.mjs +57 -13
  13. package/kernel/lib/finding-guidance.mjs +6 -0
  14. package/kernel/lib/finding-review-store.mjs +11 -23
  15. package/kernel/lib/issue-report.mjs +2 -1
  16. package/kernel/lib/patch-mounts.mjs +232 -0
  17. package/kernel/lib/report-identity-worker.mjs +47 -26
  18. package/kernel/lib/report-identity.mjs +4 -3
  19. package/kernel/lib/report-service.mjs +118 -38
  20. package/kernel/lib/report.mjs +13 -17
  21. package/kernel/lib/source-analysis.mjs +258 -42
  22. package/kernel/lib/source-graph.mjs +353 -75
  23. package/kernel/lib/state-lock.mjs +206 -0
  24. package/kernel/lib/state-store.mjs +41 -30
  25. package/kernel/lib/static-input.mjs +111 -0
  26. package/kernel/lib/syntax.mjs +242 -20
  27. package/kernel/lib/upgrade-contract.mjs +1 -0
  28. package/kernel/lib/upgrade-input.mjs +9 -4
  29. package/kernel/lib/upgrade-preview.mjs +11 -15
  30. package/kernel/lib/upgrade-worker.mjs +10 -1
  31. package/kernel/lib/verdict.mjs +28 -5
  32. package/kernel/lib/version.mjs +2 -34
  33. package/kernel/lib/worker-analysis.mjs +74 -3
  34. package/kernel/rules/README.md +30 -208
  35. package/kernel/rules/dsh-0.2.1-rc.3-review.json +63 -0
  36. package/kernel/rules/dsh-api-catalog-0.2.1-rc.3.json +743 -0
  37. package/kernel/rules/severity.json +77 -0
  38. package/lib/client.js +4668 -3423
  39. package/lib/finding-guidance.js +6 -0
  40. package/lib/local-request.js +44 -0
  41. package/lib/report-contract.js +16 -1
  42. package/lib/upgrade-contract.js +1 -0
  43. package/package.json +3 -2
  44. package/scripts/prepublish-check.mjs +1 -22
  45. package/scripts/stage-contract.mjs +7 -0
  46. package/skill/SKILL.md +13 -12
@@ -1,6 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { patchMounts } from "./patch-mounts.mjs";
3
4
  import { localImports } from "./syntax.mjs";
5
+ import {
6
+ readStaticFile,
7
+ readStaticDirectory,
8
+ inputError,
9
+ lowerInputLimits,
10
+ } from "./static-input.mjs";
4
11
 
5
12
  const sourceFile = (file) =>
6
13
  /\.[cm]?[jt]sx?$/.test(file) && !file.endsWith(".d.ts");
@@ -8,25 +15,98 @@ const portable = (file) => file.split(path.sep).join("/");
8
15
  const inside = (root, file) =>
9
16
  file === root || file.startsWith(root + path.sep);
10
17
 
11
- function hostEntry(meta) {
12
- const exp = meta.exports;
13
- const value = typeof exp === "string" ? exp : exp?.["."];
14
- if (typeof value === "string") return value;
15
- if (value && typeof value === "object")
16
- for (const key of ["import", "node", "default", "require"]) {
17
- if (typeof value[key] === "string") return value[key];
18
- if (value[key] && typeof value[key] === "object")
19
- for (const v of Object.values(value[key]))
20
- if (typeof v === "string") return v;
18
+ /** DSH imports host plugins with Node's normal ESM conditions, never types. */
19
+ function hostEntry(meta, uncertain) {
20
+ if (meta.exports == null)
21
+ return typeof meta.main === "string" ? meta.main : "index.js";
22
+ let steps = 0;
23
+ const active = new Set([
24
+ "node",
25
+ "import",
26
+ "node-addons",
27
+ "module-sync",
28
+ "default",
29
+ ]);
30
+ const inactive = new Set(["types", "browser", "require"]);
31
+ const invalid = (message, code) => {
32
+ throw Object.assign(new Error(message), { code });
33
+ };
34
+ const select = (value, depth = 0) => {
35
+ if (++steps > 128 || depth > 32)
36
+ return invalid("Export resolution budget exceeded");
37
+ if (value === null) return null;
38
+ if (typeof value === "string") {
39
+ if (
40
+ !value.startsWith("./") ||
41
+ value
42
+ .slice(2)
43
+ .split("/")
44
+ .some((p) => p === "." || p === ".." || p === "node_modules")
45
+ )
46
+ return invalid(
47
+ "Unsupported or invalid export target",
48
+ "INVALID_EXPORT_TARGET",
49
+ );
50
+ if (/[%?#\\]/.test(value))
51
+ return invalid(
52
+ "URL-style export target cannot be mapped to source with certainty",
53
+ );
54
+ return value;
55
+ }
56
+ if (Array.isArray(value)) {
57
+ let error;
58
+ for (const entry of value) {
59
+ try {
60
+ const target = select(entry, depth + 1);
61
+ if (target != null) return target;
62
+ } catch (cause) {
63
+ if (cause.code !== "INVALID_EXPORT_TARGET") throw cause;
64
+ error = cause;
65
+ }
66
+ }
67
+ if (error) throw error;
68
+ return null;
69
+ }
70
+ if (!value || typeof value !== "object")
71
+ return invalid("Unsupported export value", "INVALID_EXPORT_TARGET");
72
+ for (const [condition, target] of Object.entries(value)) {
73
+ if (/^\d+$/.test(condition) || condition.startsWith("."))
74
+ return invalid("Invalid export condition");
75
+ if (active.has(condition)) {
76
+ const result = select(target, depth + 1);
77
+ if (result !== undefined) return result;
78
+ } else if (!inactive.has(condition))
79
+ return invalid(
80
+ "Custom export conditions are not established for this host",
81
+ );
82
+ }
83
+ return undefined;
84
+ };
85
+ try {
86
+ let value = meta.exports;
87
+ if (value && typeof value === "object" && !Array.isArray(value)) {
88
+ const keys = Object.keys(value);
89
+ if (keys.some((k) => k.startsWith("."))) {
90
+ if (keys.some((k) => !k.startsWith(".")) || !Object.hasOwn(value, "."))
91
+ return invalid("Package root export is unavailable or invalid");
92
+ value = value["."];
93
+ }
21
94
  }
22
- return typeof meta.main === "string" ? meta.main : "index.js";
95
+ const target = select(value);
96
+ if (!target) return invalid("No host import target can be established");
97
+ return target;
98
+ } catch (error) {
99
+ uncertain(error.message);
100
+ return null;
101
+ }
23
102
  }
24
103
  export function clientEntry(meta) {
104
+ // client-modules at all three reviewed tags uses its own one-level loader
105
+ // contract, not Node/browser conditional export resolution.
25
106
  const value = meta.exports?.["./client"];
26
107
  if (typeof value === "string") return value;
27
- if (value && typeof value === "object")
28
- for (const key of ["browser", "import", "default"])
29
- if (typeof value[key] === "string") return value[key];
108
+ if (value && typeof value === "object" && typeof value.default === "string")
109
+ return value.default;
30
110
  return null;
31
111
  }
32
112
 
@@ -39,14 +119,21 @@ export function collectPackageSources(pkg, meta, options = {}) {
39
119
  files = [],
40
120
  buckets = new Map(),
41
121
  fileSides = {};
42
- const limits = {
43
- files: 2000,
44
- bytes: 32 * 1024 * 1024,
45
- fileBytes: 2 * 1024 * 1024,
46
- directories: 10000,
47
- timeMs: 5000,
48
- ...options.limits,
49
- };
122
+ const limits = lowerInputLimits(
123
+ {
124
+ files: 2000,
125
+ bytes: 32 * 1024 * 1024,
126
+ fileBytes: 2 * 1024 * 1024,
127
+ directories: 10000,
128
+ entries: 10000,
129
+ timeMs: 5000,
130
+ yamlNodes: 10000,
131
+ yamlDepth: 32,
132
+ yamlFiles: 64,
133
+ yamlBytes: 65536,
134
+ },
135
+ options.limits,
136
+ );
50
137
  const texts = new Map(),
51
138
  resolvedFiles = new Map(),
52
139
  moduleLinks = new Map();
@@ -54,7 +141,30 @@ export function collectPackageSources(pkg, meta, options = {}) {
54
141
  const note = (kind, file, message) => {
55
142
  if (diagnostics.length < 100) diagnostics.push({ kind, file, message });
56
143
  };
144
+ let hostResolved = false,
145
+ hostTarget;
146
+ const hostEntryFor = () => {
147
+ if (!hostResolved) {
148
+ hostResolved = true;
149
+ hostTarget = hostEntry(meta, (message) =>
150
+ note("entry-resolution-uncertain", "package.json", message),
151
+ );
152
+ if (hostTarget) {
153
+ // Only legacy package main resolution has extension/index fallbacks.
154
+ // An exports target, like an explicit import URL, is an exact file.
155
+ hostTarget =
156
+ meta.exports == null
157
+ ? resolveLegacyMain(hostTarget)
158
+ : resolveFile(hostTarget, true);
159
+ }
160
+ }
161
+ return hostTarget;
162
+ };
57
163
  const expired = () => Date.now() - started > limits.timeMs;
164
+ const checkTime = () => {
165
+ if (expired())
166
+ throw inputError("STATIC_INPUT_BUDGET", "Source time budget exceeded");
167
+ };
58
168
  const resolveFile = (rel, required = false) => {
59
169
  if (typeof rel !== "string") return null;
60
170
  const abs = path.resolve(root, rel);
@@ -66,24 +176,9 @@ export function collectPackageSources(pkg, meta, options = {}) {
66
176
  );
67
177
  return null;
68
178
  }
69
- for (const name of [
70
- abs,
71
- ...[
72
- ".js",
73
- ".mjs",
74
- ".cjs",
75
- ".ts",
76
- ".tsx",
77
- ".jsx",
78
- ".mts",
79
- ".cts",
80
- "/index.js",
81
- "/index.mjs",
82
- "/index.ts",
83
- ].map((e) => abs + e),
84
- ]) {
179
+ if (!rel.endsWith("/") && !rel.endsWith(path.sep)) {
85
180
  try {
86
- const canonical = fs.realpathSync(name);
181
+ const canonical = fs.realpathSync(abs);
87
182
  if (!inside(root, canonical)) {
88
183
  note(
89
184
  "path-outside-package",
@@ -95,7 +190,7 @@ export function collectPackageSources(pkg, meta, options = {}) {
95
190
  if (fs.statSync(canonical).isFile())
96
191
  return portable(path.relative(root, canonical));
97
192
  } catch {
98
- /* try next supported resolution */
193
+ /* The caller decides whether a different resolution mode may try more. */
99
194
  }
100
195
  }
101
196
  if (required)
@@ -106,24 +201,117 @@ export function collectPackageSources(pkg, meta, options = {}) {
106
201
  );
107
202
  return null;
108
203
  };
109
- const read = (rel) => {
204
+ const nodeFile = (rel) => {
205
+ if (rel.endsWith("/") || rel.endsWith(path.sep)) return null;
206
+ for (const suffix of ["", ".js", ".json", ".node"]) {
207
+ const found = resolveFile(rel + suffix);
208
+ if (found) return found;
209
+ }
210
+ return null;
211
+ };
212
+ const directoryIndex = (rel) => {
213
+ for (const suffix of [".js", ".json", ".node"]) {
214
+ const found = resolveFile(path.join(rel, "index" + suffix));
215
+ if (found) return found;
216
+ }
217
+ return null;
218
+ };
219
+ const unresolved = (rel) => {
220
+ note(
221
+ "source-unresolved",
222
+ portable(rel),
223
+ "Node entry or relative module cannot be resolved",
224
+ );
225
+ return null;
226
+ };
227
+ const resolveLegacyMain = (rel) =>
228
+ nodeFile(rel) ||
229
+ directoryIndex(rel) ||
230
+ directoryIndex(".") ||
231
+ unresolved(rel);
232
+ const resolveRequire = (rel) => {
233
+ checkTime();
234
+ const found = nodeFile(rel);
235
+ if (found) return found;
236
+ const abs = path.resolve(root, rel);
237
+ if (!inside(root, abs)) return resolveFile(rel, true);
238
+ let directory;
239
+ try {
240
+ directory = fs.realpathSync(abs);
241
+ if (!inside(root, directory)) return resolveFile(rel, true);
242
+ if (!fs.statSync(directory).isDirectory()) return null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ let manifest;
247
+ try {
248
+ // A present but unsafe manifest is not equivalent to a missing one.
249
+ const candidate = path.join(rel, "package.json");
250
+ fs.lstatSync(path.join(root, candidate));
251
+ manifest = portable(candidate);
252
+ } catch (error) {
253
+ if (!["ENOENT", "ENOTDIR"].includes(error.code)) {
254
+ note(
255
+ "source-unreadable",
256
+ portable(rel),
257
+ "CommonJS package metadata cannot be inspected",
258
+ );
259
+ return null;
260
+ }
261
+ }
262
+ if (manifest) {
263
+ const text = read(manifest, 65536);
264
+ if (text === null) return null;
265
+ try {
266
+ const metadata = JSON.parse(text);
267
+ if (
268
+ !metadata ||
269
+ typeof metadata !== "object" ||
270
+ Array.isArray(metadata)
271
+ )
272
+ throw new Error("Invalid CommonJS package object");
273
+ if (typeof metadata?.main === "string" && metadata.main) {
274
+ const main = path.join(rel, metadata.main);
275
+ // Node LOAD_AS_DIRECTORY tries main as file, then main/index;
276
+ // it does not recursively read another directory's package.json.
277
+ const resolved = nodeFile(main) || directoryIndex(main);
278
+ if (resolved) return resolved;
279
+ }
280
+ } catch {
281
+ note(
282
+ "entry-resolution-uncertain",
283
+ manifest,
284
+ "Invalid CommonJS package metadata",
285
+ );
286
+ return null;
287
+ }
288
+ }
289
+ return directoryIndex(rel);
290
+ };
291
+ const read = (rel, fileLimit = limits.fileBytes) => {
110
292
  if (texts.has(rel)) return texts.get(rel);
111
293
  if (expired() || texts.size >= limits.files) {
112
294
  note("scan-budget-exceeded", rel, "File/time limit reached");
113
295
  return null;
114
296
  }
115
297
  try {
116
- const stat = fs.statSync(path.join(root, rel));
117
- if (stat.size > limits.fileBytes || bytes + stat.size > limits.bytes) {
118
- note("scan-budget-exceeded", rel, "Source byte limit reached");
119
- return null;
120
- }
121
- const text = fs.readFileSync(path.join(root, rel), "utf8");
122
- bytes += stat.size;
298
+ const data = readStaticFile(
299
+ path.join(root, rel),
300
+ Math.min(fileLimit, limits.fileBytes, limits.bytes - bytes),
301
+ checkTime,
302
+ );
303
+ const text = data.toString("utf8");
304
+ bytes += data.length;
123
305
  texts.set(rel, text);
124
306
  return text;
125
307
  } catch (error) {
126
- note("source-unreadable", rel, error.message);
308
+ note(
309
+ error.code === "STATIC_INPUT_BUDGET"
310
+ ? "scan-budget-exceeded"
311
+ : "source-unreadable",
312
+ rel,
313
+ error.message,
314
+ );
127
315
  return null;
128
316
  }
129
317
  };
@@ -136,18 +324,50 @@ export function collectPackageSources(pkg, meta, options = {}) {
136
324
  break;
137
325
  }
138
326
  const rel = resolveFile(queue.pop(), true);
139
- if (!rel || seen.has(rel) || !sourceFile(rel)) continue;
327
+ if (!rel || seen.has(rel)) continue;
328
+ if (!sourceFile(rel)) {
329
+ note(
330
+ "source-format-uncertain",
331
+ rel,
332
+ "Resolved module is not supported JavaScript/TypeScript source",
333
+ );
334
+ continue;
335
+ }
140
336
  seen.add(rel);
141
337
  const text = read(rel);
142
338
  if (text === null) continue;
143
- const parsed = localImports(text, rel);
339
+ let parsed;
340
+ try {
341
+ parsed = localImports(text, rel, checkTime);
342
+ } catch (error) {
343
+ if (error.code !== "STATIC_INPUT_BUDGET") throw error;
344
+ note("scan-budget-exceeded", rel, error.message);
345
+ break;
346
+ }
347
+ for (const diagnostic of parsed.diagnostics)
348
+ note(diagnostic.kind, rel, diagnostic.message);
144
349
  if (parsed.error) note("parse-error", rel, parsed.error.message);
145
- for (const spec of parsed.imports) {
350
+ for (const { source: spec, kind, start } of parsed.edges) {
146
351
  if (!moduleLinks.has(rel)) moduleLinks.set(rel, new Map());
352
+ const local = path.join(path.dirname(rel), spec);
353
+ let target;
354
+ try {
355
+ if (kind === "require")
356
+ target = resolveRequire(local) || unresolved(local);
357
+ else if (/[%?#\\]/.test(spec)) target = unresolved(local);
358
+ else target = resolveFile(local, true);
359
+ } catch (error) {
360
+ if (error.code !== "STATIC_INPUT_BUDGET") throw error;
361
+ note("scan-budget-exceeded", rel, error.message);
362
+ break;
363
+ }
364
+ // The same spelling may resolve for require but fail for import.
147
365
  moduleLinks
148
366
  .get(rel)
149
- .set(spec, resolveFile(path.join(path.dirname(rel), spec)));
150
- queue.push(path.join(path.dirname(rel), spec));
367
+ .set(kind === "require" ? "require:" + spec : spec, target);
368
+ if (kind === "require")
369
+ moduleLinks.get(rel).set("require@" + start, target);
370
+ if (target) queue.push(target);
151
371
  }
152
372
  }
153
373
  return seen;
@@ -156,6 +376,7 @@ export function collectPackageSources(pkg, meta, options = {}) {
156
376
  const out = [],
157
377
  queue = [root],
158
378
  visited = new Set();
379
+ let entriesSeen = 0;
159
380
  while (queue.length) {
160
381
  if (
161
382
  expired() ||
@@ -184,19 +405,33 @@ export function collectPackageSources(pkg, meta, options = {}) {
184
405
  visited.add(real);
185
406
  let entries;
186
407
  try {
187
- entries = fs.readdirSync(real, { withFileTypes: true });
408
+ entries = readStaticDirectory(
409
+ real,
410
+ limits.entries - entriesSeen,
411
+ checkTime,
412
+ );
413
+ entriesSeen += entries.length;
188
414
  } catch (error) {
189
415
  note(
190
- "source-unreadable",
416
+ error.code === "STATIC_INPUT_BUDGET"
417
+ ? "scan-budget-exceeded"
418
+ : "source-unreadable",
191
419
  portable(path.relative(root, real)),
192
420
  error.message,
193
421
  );
422
+ if (error.code === "STATIC_INPUT_BUDGET") break;
194
423
  continue;
195
424
  }
196
- for (const entry of entries.sort((a, b) =>
197
- a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
198
- )) {
199
- const file = path.join(real, entry.name);
425
+ for (const entry of entries) {
426
+ if (expired()) {
427
+ note(
428
+ "scan-budget-exceeded",
429
+ null,
430
+ "Directory walk time limit reached",
431
+ );
432
+ return out;
433
+ }
434
+ const file = path.join(real, entry);
200
435
  try {
201
436
  const resolved = fs.realpathSync(file);
202
437
  if (!inside(root, resolved)) {
@@ -218,20 +453,63 @@ export function collectPackageSources(pkg, meta, options = {}) {
218
453
  }
219
454
  return [...new Set(out)].sort();
220
455
  };
221
- const mounted = [];
222
456
  const patch = meta.dsh?.bundle?.patch;
223
- if (typeof patch === "string") {
224
- const rel = resolveFile(patch);
225
- if (rel)
226
- try {
227
- const txt = fs.readFileSync(path.join(root, rel), "utf8");
228
- for (const m of txt.matchAll(/^\s*-\s*name:\s*['"]?([^\s'"]+)/gm))
229
- mounted.push(m[1].startsWith(".") ? m[1] : hostEntry(meta));
230
- } catch (error) {
231
- note("patch-unreadable", rel, error.message);
232
- }
233
- }
234
- const hostSeeds = mounted.length ? mounted : [hostEntry(meta)];
457
+ let hostSeeds,
458
+ patchComplete = false;
459
+ if (patch !== undefined) {
460
+ const patchFiles = typeof patch === "string" ? [patch] : patch;
461
+ if (
462
+ !Array.isArray(patchFiles) ||
463
+ !patchFiles.every((file) => typeof file === "string")
464
+ ) {
465
+ note(
466
+ "patch-unreadable",
467
+ "package.json",
468
+ "Patch declaration must be a path or path list",
469
+ );
470
+ hostSeeds = [];
471
+ } else {
472
+ const result = patchMounts(patchFiles, {
473
+ root,
474
+ limits,
475
+ check: checkTime,
476
+ note,
477
+ read(file) {
478
+ const rel = resolveFile(file, true);
479
+ if (!rel) throw Error("Declared patch is missing or outside package");
480
+ const data = readStaticFile(
481
+ path.join(root, rel),
482
+ Math.min(limits.fileBytes, limits.yamlBytes, limits.bytes - bytes),
483
+ checkTime,
484
+ );
485
+ bytes += data.length;
486
+ return data.toString("utf8");
487
+ },
488
+ });
489
+ patchComplete = result.complete;
490
+ hostSeeds = result.mounts.map((name) => {
491
+ if (name.startsWith(".") || path.isAbsolute(name))
492
+ return resolveFile(name, true);
493
+ if (name === meta.name) return hostEntryFor();
494
+ if (typeof meta.name === "string" && name.startsWith(meta.name + "/")) {
495
+ const subpath = name.slice(meta.name.length + 1);
496
+ if (meta.exports == null) return resolveFile(subpath, true);
497
+ const target = hostEntry(
498
+ { exports: { ".": meta.exports?.["./" + subpath] } },
499
+ (message) =>
500
+ note("entry-resolution-uncertain", "package.json", message),
501
+ );
502
+ return target ? resolveFile(target, true) : null;
503
+ }
504
+ note(
505
+ "patch-resolution-uncertain",
506
+ name,
507
+ "Mount belongs to another package; cannot use this package root",
508
+ );
509
+ return null;
510
+ });
511
+ }
512
+ } else hostSeeds = [hostEntryFor()];
235
513
  const entryFiles = [
236
514
  ...new Set(
237
515
  [...hostSeeds, clientEntry(meta)]
@@ -241,7 +519,7 @@ export function collectPackageSources(pkg, meta, options = {}) {
241
519
  ),
242
520
  ];
243
521
  const host = graph(hostSeeds);
244
- const fallback = host.size === 0;
522
+ const fallback = host.size === 0 && (!patchComplete || hostSeeds.length > 0);
245
523
  const all = walk();
246
524
  const add = (rel, bucket, side) => {
247
525
  const text = read(rel);
@@ -256,7 +534,7 @@ export function collectPackageSources(pkg, meta, options = {}) {
256
534
  if (fallback) {
257
535
  note(
258
536
  "entry-fallback",
259
- hostEntry(meta),
537
+ hostTarget ?? null,
260
538
  "No resolved host entry; candidate source coverage is uncertain",
261
539
  );
262
540
  for (const rel of all)