dsh-plugin-compat-check 0.1.0 → 0.1.2

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.
@@ -0,0 +1,297 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { localImports } from "./syntax.mjs";
4
+
5
+ const sourceFile = (file) =>
6
+ /\.[cm]?[jt]sx?$/.test(file) && !file.endsWith(".d.ts");
7
+ const portable = (file) => file.split(path.sep).join("/");
8
+ const inside = (root, file) =>
9
+ file === root || file.startsWith(root + path.sep);
10
+
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;
21
+ }
22
+ return typeof meta.main === "string" ? meta.main : "index.js";
23
+ }
24
+ export function clientEntry(meta) {
25
+ const value = meta.exports?.["./client"];
26
+ 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];
30
+ return null;
31
+ }
32
+
33
+ /** Package-root links (pnpm) are resolved once; descendants stay in that root. */
34
+ export function collectPackageSources(pkg, meta, options = {}) {
35
+ const root = fs.realpathSync(pkg),
36
+ started = Date.now();
37
+ const diagnostics = [],
38
+ skipped = [],
39
+ files = [],
40
+ buckets = new Map(),
41
+ 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
+ };
50
+ const texts = new Map(),
51
+ resolvedFiles = new Map(),
52
+ moduleLinks = new Map();
53
+ let bytes = 0;
54
+ const note = (kind, file, message) => {
55
+ if (diagnostics.length < 100) diagnostics.push({ kind, file, message });
56
+ };
57
+ const expired = () => Date.now() - started > limits.timeMs;
58
+ const resolveFile = (rel, required = false) => {
59
+ if (typeof rel !== "string") return null;
60
+ const abs = path.resolve(root, rel);
61
+ if (!inside(root, abs)) {
62
+ note(
63
+ "path-outside-package",
64
+ portable(rel),
65
+ "Relative path leaves canonical package root",
66
+ );
67
+ return null;
68
+ }
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
+ ]) {
85
+ try {
86
+ const canonical = fs.realpathSync(name);
87
+ if (!inside(root, canonical)) {
88
+ note(
89
+ "path-outside-package",
90
+ portable(rel),
91
+ "Symlink leaves canonical package root",
92
+ );
93
+ return null;
94
+ }
95
+ if (fs.statSync(canonical).isFile())
96
+ return portable(path.relative(root, canonical));
97
+ } catch {
98
+ /* try next supported resolution */
99
+ }
100
+ }
101
+ if (required)
102
+ note(
103
+ "source-unresolved",
104
+ portable(rel),
105
+ "Entry or relative import cannot be read",
106
+ );
107
+ return null;
108
+ };
109
+ const read = (rel) => {
110
+ if (texts.has(rel)) return texts.get(rel);
111
+ if (expired() || texts.size >= limits.files) {
112
+ note("scan-budget-exceeded", rel, "File/time limit reached");
113
+ return null;
114
+ }
115
+ 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;
123
+ texts.set(rel, text);
124
+ return text;
125
+ } catch (error) {
126
+ note("source-unreadable", rel, error.message);
127
+ return null;
128
+ }
129
+ };
130
+ const graph = (seeds) => {
131
+ const seen = new Set(),
132
+ queue = seeds.slice();
133
+ while (queue.length) {
134
+ if (expired()) {
135
+ note("scan-budget-exceeded", null, "Source graph time limit reached");
136
+ break;
137
+ }
138
+ const rel = resolveFile(queue.pop(), true);
139
+ if (!rel || seen.has(rel) || !sourceFile(rel)) continue;
140
+ seen.add(rel);
141
+ const text = read(rel);
142
+ if (text === null) continue;
143
+ const parsed = localImports(text, rel);
144
+ if (parsed.error) note("parse-error", rel, parsed.error.message);
145
+ for (const spec of parsed.imports) {
146
+ if (!moduleLinks.has(rel)) moduleLinks.set(rel, new Map());
147
+ moduleLinks
148
+ .get(rel)
149
+ .set(spec, resolveFile(path.join(path.dirname(rel), spec)));
150
+ queue.push(path.join(path.dirname(rel), spec));
151
+ }
152
+ }
153
+ return seen;
154
+ };
155
+ const walk = () => {
156
+ const out = [],
157
+ queue = [root],
158
+ visited = new Set();
159
+ while (queue.length) {
160
+ if (
161
+ expired() ||
162
+ visited.size >= limits.directories ||
163
+ out.length >= limits.files * 4
164
+ ) {
165
+ note("scan-budget-exceeded", null, "Directory walk limit reached");
166
+ break;
167
+ }
168
+ const abs = queue.pop();
169
+ let real;
170
+ try {
171
+ real = fs.realpathSync(abs);
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (!inside(root, real)) {
176
+ note(
177
+ "path-outside-package",
178
+ portable(path.relative(root, abs)),
179
+ "Directory link leaves package",
180
+ );
181
+ continue;
182
+ }
183
+ if (visited.has(real)) continue;
184
+ visited.add(real);
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(real, { withFileTypes: true });
188
+ } catch (error) {
189
+ note(
190
+ "source-unreadable",
191
+ portable(path.relative(root, real)),
192
+ error.message,
193
+ );
194
+ continue;
195
+ }
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);
200
+ try {
201
+ const resolved = fs.realpathSync(file);
202
+ if (!inside(root, resolved)) {
203
+ note(
204
+ "path-outside-package",
205
+ portable(path.relative(root, file)),
206
+ "File link leaves package",
207
+ );
208
+ continue;
209
+ }
210
+ const stat = fs.statSync(resolved);
211
+ if (stat.isDirectory()) queue.push(resolved);
212
+ else if (stat.isFile() && sourceFile(resolved))
213
+ out.push(portable(path.relative(root, resolved)));
214
+ } catch {
215
+ /* broken links have no readable source */
216
+ }
217
+ }
218
+ }
219
+ return [...new Set(out)].sort();
220
+ };
221
+ const mounted = [];
222
+ 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)];
235
+ const entryFiles = [
236
+ ...new Set(
237
+ [...hostSeeds, clientEntry(meta)]
238
+ .filter(Boolean)
239
+ .map((s) => resolveFile(s))
240
+ .filter(Boolean),
241
+ ),
242
+ ];
243
+ const host = graph(hostSeeds);
244
+ const fallback = host.size === 0;
245
+ const all = walk();
246
+ const add = (rel, bucket, side) => {
247
+ const text = read(rel);
248
+ if (text === null) return;
249
+ if (!fileSides[rel]) fileSides[rel] = [];
250
+ if (!fileSides[rel].includes(side)) fileSides[rel].push(side);
251
+ if (resolvedFiles.has(rel)) return;
252
+ resolvedFiles.set(rel, true);
253
+ files.push([rel, text]);
254
+ buckets.set(rel, bucket);
255
+ };
256
+ if (fallback) {
257
+ note(
258
+ "entry-fallback",
259
+ hostEntry(meta),
260
+ "No resolved host entry; candidate source coverage is uncertain",
261
+ );
262
+ for (const rel of all)
263
+ add(
264
+ rel,
265
+ "fallback",
266
+ rel.toLowerCase().includes("client") ? "client" : "host",
267
+ );
268
+ } else {
269
+ for (const rel of [...host].sort()) add(rel, "host-entry", "host");
270
+ const client = clientEntry(meta);
271
+ if (client)
272
+ for (const rel of [...graph([client])].sort())
273
+ add(rel, "client-entry", "client");
274
+ const seeds = all.filter((rel) => rel.toLowerCase().includes("client"));
275
+ const skip =
276
+ options.skipClientExtra === undefined
277
+ ? (rel) => rel.startsWith("src/") || rel.includes("/src/")
278
+ : options.skipClientExtra;
279
+ for (const rel of [...graph(seeds)].sort()) {
280
+ if (!resolvedFiles.has(rel) && skip?.(rel)) {
281
+ skipped.push(rel);
282
+ continue;
283
+ }
284
+ add(rel, "client-extra", "client");
285
+ }
286
+ }
287
+ return [
288
+ files,
289
+ fallback,
290
+ buckets,
291
+ skipped,
292
+ diagnostics,
293
+ fileSides,
294
+ entryFiles,
295
+ moduleLinks,
296
+ ];
297
+ }
@@ -0,0 +1,126 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { diffAgainstBaseline, badgeFor } from "./baseline.mjs";
6
+ import { TOOL_VERSION } from "./version.mjs";
7
+
8
+ export const digest = (value) =>
9
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
+ export function baselineIdentity(nm, mode, rules, catalog) {
11
+ let profileId = path.resolve(nm);
12
+ try {
13
+ profileId = fs.realpathSync(nm);
14
+ } catch {}
15
+ return {
16
+ profileId,
17
+ mode,
18
+ toolVersion: TOOL_VERSION,
19
+ rulesDigest: digest(rules),
20
+ catalogDigest: digest(catalog),
21
+ };
22
+ }
23
+ export function baselinePath(nm, mode = "catalogue", profile = null) {
24
+ const home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
25
+ const parts = path.resolve(nm).split(path.sep),
26
+ i = parts.lastIndexOf("profiles");
27
+ const key = (
28
+ profile ||
29
+ (i >= 0 ? parts[i + 1] : "unknown") ||
30
+ "unknown"
31
+ ).replace(/[^\w.-]/g, "_");
32
+ let canonical = path.resolve(nm);
33
+ try {
34
+ canonical = fs.realpathSync(nm);
35
+ } catch {}
36
+ return path.join(
37
+ home,
38
+ "dsh-plugin-compat-check",
39
+ `baseline.${key}.${digest(canonical).slice(0, 12)}.${mode}.json`,
40
+ );
41
+ }
42
+ export function readBaseline(file) {
43
+ try {
44
+ if (!fs.existsSync(file))
45
+ return { prev: null, note: "还没有基线(这是第一次体检)" };
46
+ return { prev: JSON.parse(fs.readFileSync(file, "utf8")), note: null };
47
+ } catch {
48
+ return { prev: null, note: "基线读不出来,这次不做比较" };
49
+ }
50
+ }
51
+ /** Atomic replace never follows an existing output-file symlink. */
52
+ export function writeBaseline(file, snapshot) {
53
+ const temporary = path.join(
54
+ path.dirname(file),
55
+ `.baseline-${randomUUID()}.tmp`,
56
+ );
57
+ try {
58
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
59
+ fs.writeFileSync(temporary, JSON.stringify(snapshot, null, 2) + "\n", {
60
+ flag: "wx",
61
+ mode: 0o600,
62
+ });
63
+ fs.renameSync(temporary, file);
64
+ return null;
65
+ } catch (error) {
66
+ return `基线写不进去(${error.message})`;
67
+ } finally {
68
+ try {
69
+ fs.rmSync(temporary, { force: true });
70
+ } catch {}
71
+ }
72
+ }
73
+
74
+ /** Serialize read/compare/replace across processes. Busy locks never overwrite newer state. */
75
+ export function baselineTransaction(file, snapshot, { advance = true } = {}) {
76
+ let fd = null;
77
+ const lock = file + ".lock";
78
+ try {
79
+ const dir = path.dirname(file);
80
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
81
+ const stat = fs.lstatSync(dir);
82
+ if (
83
+ !stat.isDirectory() ||
84
+ stat.isSymbolicLink() ||
85
+ (typeof process.getuid === "function" && stat.uid !== process.getuid())
86
+ )
87
+ throw new Error("Unsafe baseline directory");
88
+ if (stat.mode & 0o077) fs.chmodSync(dir, 0o700);
89
+ fd = fs.openSync(
90
+ lock,
91
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
92
+ 0o600,
93
+ );
94
+ const { prev, note } = readBaseline(file),
95
+ diff = diffAgainstBaseline(prev, snapshot);
96
+ const out = { file, note, diff, badge: badgeFor(diff), wrote: false };
97
+ if (advance) {
98
+ const error = writeBaseline(file, snapshot);
99
+ if (error) out.writeError = error;
100
+ else out.wrote = true;
101
+ } else
102
+ out.note = [note, "本次扫描不完整或仅预览,保留上次基线"]
103
+ .filter(Boolean)
104
+ .join(";");
105
+ return out;
106
+ } catch (error) {
107
+ return {
108
+ file,
109
+ note: "基线不可用,本次不比较",
110
+ diff: null,
111
+ badge: badgeFor(null),
112
+ wrote: false,
113
+ writeError:
114
+ error.code === "EEXIST"
115
+ ? "另一个进程正在更新基线;本次保留上次状态"
116
+ : error.message,
117
+ };
118
+ } finally {
119
+ if (fd !== null) {
120
+ fs.closeSync(fd);
121
+ try {
122
+ fs.unlinkSync(lock);
123
+ } catch {}
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,73 @@
1
+ import { parse } from "@babel/parser";
2
+
3
+ /** Parse data only. Never import, evaluate, or execute the inspected module. */
4
+ export function parseSource(text, file = "") {
5
+ const typescript = /\.[cm]?tsx?$/.test(file);
6
+ try {
7
+ const ast = parse(text, {
8
+ sourceType: "unambiguous",
9
+ attachComment: false,
10
+ errorRecovery: false,
11
+ allowReturnOutsideFunction: true,
12
+ allowAwaitOutsideFunction: true,
13
+ allowUndeclaredExports: true,
14
+ createImportExpressions: true,
15
+ plugins: [
16
+ ...(typescript ? ["typescript"] : []),
17
+ "jsx",
18
+ "decorators-legacy",
19
+ ],
20
+ });
21
+ return { ast, error: null };
22
+ } catch (error) {
23
+ return {
24
+ ast: null,
25
+ error: {
26
+ kind: "parse-error",
27
+ file,
28
+ line: error.loc?.line ?? null,
29
+ column: error.loc?.column ?? null,
30
+ message: error.message,
31
+ },
32
+ };
33
+ }
34
+ }
35
+
36
+ export function childNodes(node) {
37
+ const out = [];
38
+ for (const [key, value] of Object.entries(node || {})) {
39
+ if (["loc", "extra", "tokens", "comments", "errors"].includes(key))
40
+ continue;
41
+ if (Array.isArray(value)) {
42
+ for (const item of value)
43
+ if (item && typeof item.type === "string") out.push(item);
44
+ } else if (value && typeof value.type === "string") out.push(value);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export function localImports(text, file) {
50
+ const { ast, error } = parseSource(text, file);
51
+ if (!ast) return { imports: [], error };
52
+ const imports = new Set(),
53
+ queue = [ast];
54
+ while (queue.length) {
55
+ const n = queue.pop();
56
+ const source = [
57
+ "ImportDeclaration",
58
+ "ExportNamedDeclaration",
59
+ "ExportAllDeclaration",
60
+ "ImportExpression",
61
+ ].includes(n.type)
62
+ ? n.source
63
+ : n.type === "CallExpression" &&
64
+ n.callee?.type === "Identifier" &&
65
+ n.callee.name === "require"
66
+ ? n.arguments[0]
67
+ : null;
68
+ if (source?.type === "StringLiteral" && source.value.startsWith("."))
69
+ imports.add(source.value);
70
+ queue.push(...childNodes(n));
71
+ }
72
+ return { imports: [...imports].sort(), error: null };
73
+ }