@shidesheng0218/dsh-plugin-lint 0.1.0 → 0.4.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.
Files changed (66) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +515 -81
  3. package/bin/dsh-plugin-analyze.mjs +378 -0
  4. package/bin/dsh-plugin-lint.mjs +315 -27
  5. package/bin/dsh-plugin-matrix.mjs +78 -0
  6. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  7. package/package.json +12 -4
  8. package/schema/dsh-manifest.schema.json +134 -0
  9. package/scripts/lib/probe.mjs +28 -385
  10. package/scripts/probe-boot.mjs +103 -37
  11. package/src/composition.js +1 -0
  12. package/src/core/baseline.js +98 -0
  13. package/src/core/changed.js +41 -0
  14. package/src/core/load.js +145 -0
  15. package/src/core/mask.js +207 -0
  16. package/src/core/pack.js +95 -0
  17. package/src/core/packlist.js +186 -0
  18. package/src/core/peer.js +39 -0
  19. package/src/core/profile-scan.js +121 -0
  20. package/src/core/report.js +174 -0
  21. package/src/core/rules.js +581 -0
  22. package/src/core/semver.js +192 -0
  23. package/src/core/tarball.js +90 -0
  24. package/src/data/event-types.json +111 -9
  25. package/src/experimental/conflicts.js +315 -0
  26. package/src/experimental/index.js +4 -0
  27. package/src/experimental/performance.js +515 -0
  28. package/src/experimental/security.js +462 -0
  29. package/src/index.js +15 -17
  30. package/src/load.js +1 -116
  31. package/src/mask.js +1 -156
  32. package/src/matrix/cache.js +19 -0
  33. package/src/matrix/cell.js +79 -0
  34. package/src/matrix/cells.js +58 -0
  35. package/src/matrix/conflicts.js +210 -0
  36. package/src/matrix/export.js +155 -0
  37. package/src/matrix/index.js +71 -0
  38. package/src/matrix/manifest.js +17 -0
  39. package/src/matrix/migrate.js +41 -0
  40. package/src/matrix/run.js +241 -0
  41. package/src/matrix/sanitize.js +42 -0
  42. package/src/matrix/schema.js +178 -0
  43. package/src/matrix/site.js +61 -0
  44. package/src/matrix/sync.js +72 -0
  45. package/src/matrix/verify.js +152 -0
  46. package/src/performance.js +1 -0
  47. package/src/probe/artifact-resolver.js +161 -0
  48. package/src/probe/classify.js +8 -0
  49. package/src/probe/dsh-adapter.js +212 -0
  50. package/src/probe/environment/docker.js +270 -0
  51. package/src/probe/environment/host-unsafe.js +39 -0
  52. package/src/probe/environment/materialize.js +22 -0
  53. package/src/probe/environment.js +50 -0
  54. package/src/probe/installed-artifact.js +17 -0
  55. package/src/probe/probe.js +278 -0
  56. package/src/report.js +1 -79
  57. package/src/rules.js +1 -379
  58. package/src/security.js +1 -0
  59. package/src/shared/errors.js +7 -0
  60. package/src/shared/hash.js +31 -0
  61. package/src/shared/status.js +12 -0
  62. package/src/shared/validate.js +14 -0
  63. package/src/shared/version.js +4 -0
  64. package/src/upgrade/compatibility.js +14 -0
  65. package/src/upgrade/planner.js +119 -0
  66. package/src/upgrade/profile.js +82 -0
@@ -1,57 +1,123 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * Probe one plugin's host half.
4
- *
5
- * node scripts/probe-boot.mjs <npm-package|path> [...] [--tools] [--dsh <version>] [--keep]
6
- *
7
- * Without --dsh the installed desktop app is used, which is the fastest path on
8
- * this machine. With --dsh <version> the CLI comes from npm and runs under plain
9
- * node, which is how the matrix runs on Linux CI and how a plugin author can run
10
- * the same probe locally.
11
- *
12
- * See docs/10-m4-verification.md and docs/11-m4-capability-probe.md for the
13
- * method and the failure taxonomy.
14
- */
15
- import { fileURLToPath } from 'node:url';
16
- import { probePackage, resolveDsh, startStub } from './lib/probe.mjs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { downloadArtifact, resolveArtifact } from '../src/probe/artifact-resolver.js';
5
+ import { resolveDsh, startStub } from '../src/probe/dsh-adapter.js';
6
+ import { createProbeEnvironment } from '../src/probe/environment.js';
7
+ import { probePackage } from '../src/probe/probe.js';
8
+
9
+ const USAGE = [
10
+ 'dsh-plugin-probe — isolated host-half probe for DeepSeek Harness plugins',
11
+ '',
12
+ 'Usage',
13
+ ' dsh-plugin-probe <npm-package|path> [...] [options]',
14
+ '',
15
+ 'Options',
16
+ ' --dsh <version> use @deepseek-ai/dsh at an exact npm version (required for the Docker backend)',
17
+ ' --mode <mode> headless, web, or both (default: headless)',
18
+ ' --cache <dir> cache root for DSH installs and temporary environments (default: system temp)',
19
+ ' --tools capture tool inventory through the isolated stub',
20
+ ' --audit include the static capability inventory',
21
+ ' --keep accepted for CLI compatibility; isolation is always destroyed',
22
+ ' --unsafe-host run the desktop DSH without isolation; results remain self-reported',
23
+ ' -h, --help show this message',
24
+ '',
25
+ 'There is no silent host fallback: the Docker backend requires --dsh <version>,',
26
+ 'and the desktop DSH can only be probed with --unsafe-host.',
27
+ '',
28
+ 'Exit codes: 0 all observations completed · 2 an infrastructure error occurred',
29
+ ].join('\n');
17
30
 
18
31
  const argv = process.argv.slice(2);
32
+ if (argv.includes('-h') || argv.includes('--help')) {
33
+ console.log(USAGE);
34
+ process.exit(0);
35
+ }
19
36
  const argOf = (name, fallback) => {
20
- const i = argv.indexOf(name);
21
- return i >= 0 ? argv[i + 1] : fallback;
37
+ const index = argv.indexOf(name);
38
+ return index >= 0 ? argv[index + 1] : fallback;
22
39
  };
23
40
  const keep = argv.includes('--keep');
24
41
  const withTools = argv.includes('--tools');
42
+ const withAudit = argv.includes('--audit');
43
+ const backend = argv.includes('--unsafe-host') ? 'host-unsafe' : 'auto';
25
44
  const dshVersion = argOf('--dsh', undefined);
26
45
  const modeArg = argOf('--mode', 'headless');
27
- // 'both' expands the same way the matrix runner expands it, so one flag means one
28
- // thing across the tool.
46
+ if (!['headless', 'web', 'both'].includes(modeArg)) {
47
+ console.error('dsh-plugin-probe: --mode must be headless, web, or both');
48
+ process.exit(2);
49
+ }
29
50
  const modes = modeArg === 'both' ? ['headless', 'web'] : [modeArg];
30
- const VALUE_FLAGS = ['--dsh', '--mode', '--cache'];
31
- const packages = argv.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.includes(argv[i - 1]));
51
+ const valueFlags = ['--dsh', '--mode', '--cache'];
52
+ const packages = argv.filter((arg, index) => !arg.startsWith('--') && !valueFlags.includes(argv[index - 1]));
32
53
  if (packages.length === 0) {
33
- console.error('usage: node scripts/probe-boot.mjs <npm-package|path> [...] [--tools] [--dsh <version>] [--keep]');
54
+ console.error(USAGE);
34
55
  process.exit(2);
35
56
  }
36
57
 
37
- const cacheRoot = argOf('--cache', fileURLToPath(new URL('../.matrix-cache', import.meta.url)));
38
- const dsh = await resolveDsh(dshVersion, cacheRoot);
39
- const stub = withTools ? await startStub() : undefined;
40
- console.log('probing with ' + dsh.id + (withTools ? ' + stub provider' : ''));
58
+ const cacheRoot = argOf('--cache', process.env.DSH_PROBE_CACHE || join(tmpdir(), 'dsh-plugin-lint-cache'));
59
+ let dsh;
60
+ try {
61
+ dsh = dshVersion
62
+ ? { id: 'npm:' + dshVersion, kind: 'npm', version: dshVersion, extraArgs: [] }
63
+ : await resolveDsh(undefined, cacheRoot);
64
+ } catch (error) {
65
+ console.error((error.code || 'PROBE_FAILED') + ': ' + (error.message || 'The DSH runtime could not be resolved.'));
66
+ process.exit(2);
67
+ }
68
+ if (dsh.kind === 'desktop' && backend !== 'host-unsafe') {
69
+ console.error('dsh-plugin-probe: the desktop DSH can only be probed with --unsafe-host '
70
+ + '(no container isolation), or pass --dsh <npm-version> to use the Docker backend.');
71
+ process.exit(2);
72
+ }
73
+ console.log('probing with ' + dsh.id + ' [' + backend + ']' + (withTools ? ' + isolated stub' : ''));
41
74
 
42
75
  const results = [];
76
+ let failures = 0;
43
77
  for (const name of packages) {
44
- for (const mode of modes) {
45
- const cell = await probePackage({ target: name, dsh, stub: mode === 'web' ? undefined : stub, tools: withTools, keep, mode });
46
- results.push({ package: name, mode, ...cell });
47
- console.log(cell.status.padEnd(16) + name + ' [' + mode + '] (' + cell.durationMs + ' ms)');
48
- for (const e of cell.entries) console.log(' ' + e.id + ': ' + e.reason);
49
- if (cell.tools && !cell.tools.error) {
50
- console.log(' tools ' + cell.tools.baseline + ' -> ' + cell.tools.total
51
- + (cell.tools.added.length ? ' +' + cell.tools.added.length + ': ' + cell.tools.added.join(', ') : ' (none)'));
52
- console.log(' system prompt ' + cell.systemPrompt.baselineChars + ' -> ' + cell.systemPrompt.chars + ' chars');
53
- }
78
+ try {
79
+ let artifact = await resolveArtifact(name);
80
+ if (!artifact.local) artifact = await downloadArtifact(artifact);
81
+ for (const mode of modes) {
82
+ const environment = await createProbeEnvironment({
83
+ backend,
84
+ workDir: cacheRoot,
85
+ dshDir: dsh.kind === 'npm' && dsh.version !== 'desktop' ? join(cacheRoot, 'dsh', dsh.version) : undefined,
86
+ });
87
+ const stub = withTools && mode !== 'web' && backend === 'host-unsafe' ? await startStub() : undefined;
88
+ try {
89
+ const observation = await probePackage({ artifact, dsh, environment, stub, tools: withTools, keep, composition: mode, audit: withAudit });
90
+ results.push({ package: name, mode, ...observation });
91
+ if (observation.outcome === 'error') {
92
+ failures += 1;
93
+ console.error(`${observation.error.code}: ${observation.error.message}`);
94
+ continue;
95
+ }
96
+ console.log(observation.status.padEnd(16) + name + ' [' + mode + '] (' + observation.durationMs + ' ms)');
97
+ for (const entry of observation.entries) console.log(' ' + entry.id + ': ' + entry.reason);
98
+ if (observation.tools && !observation.tools.error) {
99
+ console.log(' tools ' + observation.tools.baseline + ' -> ' + observation.tools.total
100
+ + (observation.tools.added.length ? ' +' + observation.tools.added.length + ': ' + observation.tools.added.join(', ') : ' (none)'));
101
+ console.log(' system prompt ' + observation.systemPrompt.baselineChars + ' -> ' + observation.systemPrompt.chars + ' chars');
102
+ }
103
+ if (observation.audit) {
104
+ console.log(' audit: ' + observation.audit.observed.length + ' capabilities observed');
105
+ for (const item of observation.audit.observed) {
106
+ console.log(' ' + item.capability + ' (' + item.sites + ' site' + (item.sites === 1 ? '' : 's') + ', first: ' + item.first.file + ')');
107
+ }
108
+ }
109
+ } finally {
110
+ if (stub) stub.server.close();
111
+ }
112
+ }
113
+ } catch (error) {
114
+ failures += 1;
115
+ const code = error.code || 'PROBE_FAILED';
116
+ const message = code === 'PROBE_FAILED' ? 'The probe could not complete.' : error.message;
117
+ console.error(`${code}: ${message}`);
118
+ results.push({ package: name, outcome: 'error', error: { code, message } });
54
119
  }
55
120
  }
56
- if (stub) stub.server.close();
121
+
57
122
  console.log('\n' + JSON.stringify(results, null, 2));
123
+ process.exitCode = failures > 0 ? 2 : 0;
@@ -0,0 +1 @@
1
+ export * from './experimental/conflicts.js';
@@ -0,0 +1,98 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { sha256, stableStringify } from '../shared/hash.js';
3
+ import { EVENT_CATALOG, RULES } from './rules.js';
4
+
5
+ export const BASELINE_SCHEMA_VERSION = 1;
6
+
7
+ /** Evidence strings can move with formatting; keep the stable part only. */
8
+ export function normalizeEvidence(value) {
9
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
10
+ }
11
+
12
+ /** A fingerprint deliberately excludes the line number. */
13
+ export function fingerprint(finding) {
14
+ return 'sha256:' + sha256(stableStringify({
15
+ rule: finding.rule,
16
+ file: finding.file || 'package.json',
17
+ evidence: normalizeEvidence(finding.evidence),
18
+ }));
19
+ }
20
+
21
+ export function rulesetIdentity() {
22
+ return {
23
+ hash: sha256(stableStringify(RULES.map((rule) => ({ id: rule.id, level: rule.level })))),
24
+ rules: RULES.map((rule) => rule.id),
25
+ eventCatalog: { harvestedAt: EVENT_CATALOG.harvestedAt, dshVersions: EVENT_CATALOG.dshVersions },
26
+ };
27
+ }
28
+
29
+ export function createBaseline(reports, { generatedAt = new Date().toISOString() } = {}) {
30
+ const seen = new Set();
31
+ const findings = [];
32
+ for (const report of reports) {
33
+ for (const finding of report.findings) {
34
+ const print = fingerprint(finding);
35
+ if (seen.has(print)) continue;
36
+ seen.add(print);
37
+ findings.push({
38
+ fingerprint: print,
39
+ rule: finding.rule,
40
+ level: finding.level,
41
+ file: finding.file || 'package.json',
42
+ evidence: normalizeEvidence(finding.evidence),
43
+ });
44
+ }
45
+ }
46
+ return {
47
+ schemaVersion: BASELINE_SCHEMA_VERSION,
48
+ generatedAt,
49
+ ruleset: rulesetIdentity(),
50
+ findings,
51
+ };
52
+ }
53
+
54
+ export function readBaseline(path) {
55
+ const value = JSON.parse(readFileSync(path, 'utf8'));
56
+ if (!value || value.schemaVersion !== BASELINE_SCHEMA_VERSION || !Array.isArray(value.findings)) {
57
+ throw new Error('not a dsh-plugin-lint baseline: ' + path);
58
+ }
59
+ for (const entry of value.findings) {
60
+ if (typeof entry.fingerprint !== 'string' || typeof entry.rule !== 'string') {
61
+ throw new Error('baseline entry is missing fingerprint/rule: ' + path);
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+
67
+ export function writeBaseline(path, baseline) {
68
+ writeFileSync(path, JSON.stringify(baseline, null, 2) + '\n');
69
+ }
70
+
71
+ /** Filter baseline fingerprints out of each report and recompute its summary. */
72
+ export function applyBaseline(reports, baseline, { path } = {}) {
73
+ const known = new Set(baseline.findings.map((entry) => entry.fingerprint));
74
+ let suppressed = 0;
75
+ const filtered = reports.map((report) => {
76
+ const findings = [];
77
+ for (const finding of report.findings) {
78
+ if (known.has(fingerprint(finding))) suppressed += 1;
79
+ else findings.push(finding);
80
+ }
81
+ const summary = { error: 0, warn: 0, info: 0 };
82
+ for (const finding of findings) summary[finding.level] += 1;
83
+ return { ...report, findings, summary, baseline: { suppressed: report.findings.length - findings.length, path: path || null } };
84
+ });
85
+ return { reports: filtered, suppressed };
86
+ }
87
+
88
+ /** Keep only baseline entries that still occur in the current run. */
89
+ export function pruneBaseline(baseline, reports, { generatedAt = baseline.generatedAt } = {}) {
90
+ const current = new Set();
91
+ for (const report of reports) for (const finding of report.findings) current.add(fingerprint(finding));
92
+ return {
93
+ ...baseline,
94
+ generatedAt,
95
+ ruleset: rulesetIdentity(),
96
+ findings: baseline.findings.filter((entry) => current.has(entry.fingerprint)),
97
+ };
98
+ }
@@ -0,0 +1,41 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+
5
+ /** Files changed against `ref` (default HEAD) or staged in the index. */
6
+ export function changedFiles({ cwd = process.cwd(), ref, staged = false } = {}) {
7
+ const args = ['diff', '--name-only', '--diff-filter=ACMR'];
8
+ if (staged) args.push('--cached');
9
+ args.push(ref || 'HEAD');
10
+ const output = execFileSync('git', args, { cwd, encoding: 'utf8' });
11
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
12
+ }
13
+
14
+ /**
15
+ * Map changed files to the nearest enclosing package.json. Deleted files map
16
+ * to their former directory; files outside any package are ignored.
17
+ */
18
+ export function packageRoots(files, cwd = process.cwd()) {
19
+ const base = resolve(cwd);
20
+ const roots = new Set();
21
+ for (const file of files) {
22
+ let dir = resolve(base, file);
23
+ try {
24
+ if (!statSync(dir).isDirectory()) dir = dirname(dir);
25
+ } catch {
26
+ dir = dirname(dir);
27
+ }
28
+ while (true) {
29
+ const rel = relative(base, dir);
30
+ if (rel.startsWith('..') || isAbsolute(rel)) break;
31
+ if (existsSync(join(dir, 'package.json'))) {
32
+ roots.add(dir);
33
+ break;
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir) break;
37
+ dir = parent;
38
+ }
39
+ }
40
+ return [...roots].sort();
41
+ }
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { maskSource } from './mask.js';
4
+ import { globToRegExp } from './packlist.js';
5
+
6
+ /** Directories that never hold shipped plugin code. */
7
+ const SKIP_DIRS = new Set([
8
+ 'node_modules', '.git', '.github', '.husky', '.dsh-testkit', 'coverage', 'fixtures',
9
+ 'test', 'tests', '__tests__', '__snapshots__', 'dist-test',
10
+ ]);
11
+
12
+ /** Guard rails so one huge vendored file cannot stall a run. */
13
+ const MAX_FILE_BYTES = 3 * 1024 * 1024;
14
+ const MAX_FILES = 3000;
15
+
16
+ export function loadPackage(dir, options = {}) {
17
+ const manifestPath = join(dir, 'package.json');
18
+ if (!existsSync(manifestPath)) throw new Error('not a package directory (no package.json): ' + dir);
19
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
20
+
21
+ const norm = (p) => (p === undefined ? undefined : String(p).replace(/^\.\//, ''));
22
+ // Node resolves an undeclared package entry to index.js, so the fallback is
23
+ // correct for resolution — but "nothing was declared" is not the same as
24
+ // "index.js was declared and is missing". A static asset package (measured:
25
+ // @deepseek-ai/dsh-web-frontend exports only ./dist/*) has no host entry at all,
26
+ // and flagging it would be a false positive.
27
+ // Node resolves exports["."] before main; the loader imports the package by
28
+ // name and follows the same order. Keeping main first made a stale main an
29
+ // error even when exports["."] was the entry that actually loaded.
30
+ const mainEntry = firstString(manifest.main);
31
+ const declaredHost = exportTarget(manifest.exports, '.') || mainEntry;
32
+ const entries = {
33
+ host: norm(declaredHost || 'index.js'),
34
+ // A configuration-only bundle (dsh.bundle) needs no host entry at all
35
+ // (references/mcp-bundle.md): only exports["."]/main make the host file
36
+ // mandatory, and only they may turn a missing index.js into an E3 error.
37
+ hostDeclared: Boolean(declaredHost),
38
+ main: norm(mainEntry),
39
+ mainDeclared: Boolean(mainEntry),
40
+ // The browser half resolves only a string or a string default; import and
41
+ // require conditions are Node-only and the client registry rejects them.
42
+ client: norm(clientExportTarget(manifest.exports, './client') || (manifest.dsh && manifest.dsh.client ? 'client.js' : undefined)),
43
+ patch: norm(manifest.dsh && manifest.dsh.bundle ? firstString(manifest.dsh.bundle.patch) : undefined),
44
+ icon: norm(firstString(manifest.icon)),
45
+ localeDir: existsSync(join(dir, 'locale')) ? 'locale' : undefined,
46
+ };
47
+
48
+ const jsFiles = [];
49
+ const authorPhase = options.phase === 'author';
50
+ collect(dir, dir, jsFiles, authorPhase);
51
+
52
+ return {
53
+ dir,
54
+ manifest,
55
+ manifestPath,
56
+ phase: options.phase || 'publish',
57
+ buildScripts: Boolean(manifest.scripts && (manifest.scripts.build || manifest.scripts.prepack || manifest.scripts.prepare)),
58
+ entries,
59
+ jsFiles,
60
+ files: Array.isArray(manifest.files) ? manifest.files : undefined,
61
+ exists: (rel) => rel !== undefined && existsSync(join(dir, rel)),
62
+ text: (rel) => (existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined),
63
+ json: (rel) => {
64
+ const t = existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined;
65
+ if (t === undefined) return undefined;
66
+ try { return JSON.parse(t); } catch { return undefined; }
67
+ },
68
+ };
69
+ }
70
+
71
+ /** Artifacts a build step produces; an author-phase run may legitimately lack them. */
72
+ export function isBuildOutput(rel) {
73
+ const value = String(rel || '').replace(/^\.\//, '');
74
+ return /^(lib|dist|build|out)\//.test(value) || /\.(js|mjs|cjs|jsx)$/.test(value);
75
+ }
76
+
77
+ function collect(root, current, out, authorPhase = false) {
78
+ const pattern = authorPhase ? /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/ : /\.(js|mjs|cjs|jsx)$/;
79
+ let names;
80
+ try { names = readdirSync(current, { withFileTypes: true }); } catch { return; }
81
+ for (const dirent of names) {
82
+ const abs = join(current, dirent.name);
83
+ if (dirent.isDirectory()) {
84
+ if (SKIP_DIRS.has(dirent.name)) continue;
85
+ collect(root, abs, out, authorPhase);
86
+ continue;
87
+ }
88
+ if (!pattern.test(dirent.name)) continue;
89
+ let size = 0;
90
+ try { size = statSync(abs).size; } catch { continue; }
91
+ if (size > MAX_FILE_BYTES) continue;
92
+ if (out.length >= MAX_FILES) return;
93
+ const text = readFileSync(abs, 'utf8');
94
+ const rel = relative(root, abs).split(sep).join('/');
95
+ const { masked, structural, strings, lineStarts } = maskSource(text);
96
+ out.push({ path: rel, text, masked, structural, strings, lineStarts, bytes: size });
97
+ }
98
+ }
99
+
100
+ function firstString(value) {
101
+ return typeof value === 'string' && value !== '' ? value : undefined;
102
+ }
103
+
104
+ /** Resolve an exports subpath to a file path, accepting the Node entry forms. */
105
+ export function exportTarget(exportsField, subpath) {
106
+ if (exportsField === undefined || exportsField === null) return undefined;
107
+ if (typeof exportsField === 'string') return subpath === '.' ? exportsField : undefined;
108
+ const entry = exportsField[subpath];
109
+ if (entry === undefined) return undefined;
110
+ if (typeof entry === 'string') return entry;
111
+ if (entry && typeof entry === 'object') return firstString(entry.default) || firstString(entry.import) || firstString(entry.require);
112
+ return undefined;
113
+ }
114
+
115
+ /**
116
+ * Resolve the browser client entry the way the host's ClientModuleRegistry does:
117
+ * a string, or an object with a string `default`. import/require are Node-only
118
+ * conditions and are rejected by the client resolver.
119
+ */
120
+ export function clientExportTarget(exportsField, subpath = './client') {
121
+ if (exportsField === undefined || exportsField === null) return undefined;
122
+ if (typeof exportsField === 'string') return subpath === '.' ? exportsField : undefined;
123
+ const entry = exportsField[subpath];
124
+ if (entry === undefined) return undefined;
125
+ if (typeof entry === 'string') return entry;
126
+ if (entry && typeof entry === 'object') return firstString(entry.default);
127
+ return undefined;
128
+ }
129
+
130
+ export function filesCover(files, relPath) {
131
+ if (files === undefined || files.length === 0) return true;
132
+ relPath = String(relPath).replace(/^\.\//, '');
133
+ let covered = false;
134
+ for (const raw of files) {
135
+ let pattern = String(raw).trim().replace(/^\.\//, '').replace(/\/+$/, '');
136
+ const negated = pattern.startsWith('!');
137
+ if (negated) pattern = pattern.slice(1);
138
+ if (!pattern) continue;
139
+ const matches = pattern === relPath
140
+ || relPath.startsWith(pattern + '/')
141
+ || ((pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) && globToRegExp(pattern).test(relPath));
142
+ if (matches) covered = !negated;
143
+ }
144
+ return covered;
145
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Comment- and string-aware scanner.
3
+ *
4
+ * Rules must never match text inside a comment (real plugins document the very
5
+ * patterns we look for, e.g. "this listener returns next()"). Two views:
6
+ *
7
+ * masked comments blanked, string literals INTACT — for matching quoted
8
+ * event names and reading literal values with exact line numbers.
9
+ * structural masked AND string interiors blanked — for brace-depth analysis.
10
+ */
11
+
12
+ const BACKTICK = '\u0060';
13
+
14
+ /** @returns {{ masked: string, structural: string, strings: Array<{value:string,start:number,end:number,line:number}>, lineStarts: number[] }} */
15
+ export function maskSource(text) {
16
+ const maskedChars = text.split('');
17
+ const structuralChars = text.slice().split('');
18
+ const strings = [];
19
+ const lineStarts = [0];
20
+ for (let k = 0; k < text.length; k++) if (text[k] === '\n') lineStarts.push(k + 1);
21
+
22
+ const NL = '\n';
23
+ const SP = ' ';
24
+ const blank = (from, to, chars) => {
25
+ for (let k = from; k < to; k++) if (chars[k] !== NL) chars[k] = SP;
26
+ };
27
+ // A '/' starts a regex only where a value may begin; otherwise it is division.
28
+ // Two cases the naive "previous character" test gets wrong:
29
+ // a++ / b - the first slash is division, and treating it as a regex would
30
+ // swallow the following "//" comment into the masked view.
31
+ // return /re/ - the previous character is a letter, but a keyword allows a
32
+ // regex here.
33
+ const REGEX_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|instanceof|case|delete|void|throw|new|in|of|do|else|yield|await)$/;
34
+ const regexAllowed = (idx) => {
35
+ let k = idx - 1;
36
+ while (k >= 0 && (maskedChars[k] === SP || maskedChars[k] === NL || maskedChars[k] === '\t')) k -= 1;
37
+ if (k < 0) return true;
38
+ const prev = maskedChars[k];
39
+ if ('(,=:[!&|?{};+-*%<>~^'.indexOf(prev) >= 0) {
40
+ if ((prev === '+' || prev === '-') && maskedChars[k - 1] === prev) return false;
41
+ return true;
42
+ }
43
+ if (!/[\w$]/.test(prev)) return false;
44
+ let wordStart = k;
45
+ while (wordStart >= 0 && /[\w$]/.test(maskedChars[wordStart])) wordStart -= 1;
46
+ return REGEX_KEYWORDS.test(maskedChars.slice(wordStart + 1, k + 1).join(''));
47
+ };
48
+
49
+ let i = 0;
50
+ const n = text.length;
51
+ while (i < n) {
52
+ const c = text[i];
53
+ const next = text[i + 1];
54
+
55
+ if (c === '/' && next === '/') {
56
+ const from = i;
57
+ while (i < n && text[i] !== NL) i++;
58
+ blank(from, i, maskedChars);
59
+ blank(from, i, structuralChars);
60
+ continue;
61
+ }
62
+ if (c === '/' && next === '*') {
63
+ const from = i;
64
+ i += 2;
65
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
66
+ i = Math.min(n, i + 2);
67
+ blank(from, i, maskedChars);
68
+ blank(from, i, structuralChars);
69
+ continue;
70
+ }
71
+
72
+ // The literal delimiter itself, not a boolean: the closing delimiter must
73
+ // compare equal to it.
74
+ const quote = c === '"' ? '"' : c === "'" ? "'" : c === BACKTICK ? BACKTICK : undefined;
75
+ if (quote !== undefined) {
76
+ const from = i;
77
+ i += 1;
78
+ const contentFrom = i;
79
+ while (i < n) {
80
+ if (text[i] === '\\') { i += 2; continue; }
81
+ if (text[i] === quote) break;
82
+ if (text[i] === NL && quote !== BACKTICK) break; // unterminated literal
83
+ i += 1;
84
+ }
85
+ const contentTo = Math.min(i, n);
86
+ const end = Math.min(i + 1, n);
87
+ strings.push({ value: text.slice(contentFrom, contentTo), start: from, end, line: lineOfIndex(lineStarts, from) });
88
+ blank(contentFrom, contentTo, structuralChars);
89
+ i = end;
90
+ continue;
91
+ }
92
+
93
+ if (c === '/' && regexAllowed(i)) {
94
+ const from = i;
95
+ i += 1;
96
+ let inClass = false;
97
+ while (i < n) {
98
+ if (text[i] === '\\') { i += 2; continue; }
99
+ if (text[i] === '[') inClass = true;
100
+ else if (text[i] === ']') inClass = false;
101
+ else if (text[i] === '/' && !inClass) { i += 1; break; }
102
+ else if (text[i] === NL) break;
103
+ i += 1;
104
+ }
105
+ // A regex body is data, not code: braces and quotes inside it must not
106
+ // reach the structural view, and a literal "session.append(" inside it
107
+ // must not look like a call site.
108
+ blank(from, i, maskedChars);
109
+ blank(from, i, structuralChars);
110
+ continue;
111
+ }
112
+ i += 1;
113
+ }
114
+ return { masked: maskedChars.join(''), structural: structuralChars.join(''), strings, lineStarts };
115
+ }
116
+
117
+ export function lineOfIndex(lineStarts, index) {
118
+ let lo = 0;
119
+ let hi = lineStarts.length - 1;
120
+ while (lo < hi) {
121
+ const mid = (lo + hi + 1) >> 1;
122
+ if (lineStarts[mid] <= index) lo = mid;
123
+ else hi = mid - 1;
124
+ }
125
+ return lo + 1;
126
+ }
127
+
128
+ /** The first string literal starting at or after \`from\` within \`window\` characters. */
129
+ export function stringAfter(strings, from, window) {
130
+ for (const s of strings) {
131
+ if (s.start >= from) return s.start - from <= window ? s : undefined;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ /** Literal types passed to Session.append(type, data). */
137
+ export function appendCallSites(masked, strings) {
138
+ const out = [];
139
+ // Session receivers the host exposes: bare `session`, ctx.session,
140
+ // agent.session and this.session. A property named `session` on an unrelated
141
+ // object (formData.session) is not a Session, so only these names count.
142
+ const re = /(?<![\w$.])(?:(?:ctx|agent|this)\s*\.\s*)?session\s*\.\s*append\s*\(\s*/g;
143
+ let m;
144
+ while ((m = re.exec(masked)) !== null) {
145
+ if (insideString(strings, m.index)) continue;
146
+ const s = stringAt(strings, m.index + m[0].length);
147
+ if (!s) continue;
148
+ if (s.value.includes('${')) continue;
149
+ out.push({ type: s.value, index: s.start, line: s.line });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** True when `index` falls inside a string literal (where the text is data). */
155
+ function insideString(strings, index) {
156
+ let lo = 0;
157
+ let hi = strings.length - 1;
158
+ while (lo <= hi) {
159
+ const mid = (lo + hi) >> 1;
160
+ const s = strings[mid];
161
+ if (index < s.start) hi = mid - 1;
162
+ else if (index >= s.end) lo = mid + 1;
163
+ else return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ /** The string literal whose opening quote is exactly at `index`, if any. */
169
+ function stringAt(strings, index) {
170
+ let lo = 0;
171
+ let hi = strings.length - 1;
172
+ while (lo <= hi) {
173
+ const mid = (lo + hi) >> 1;
174
+ const s = strings[mid];
175
+ if (s.start < index) lo = mid + 1;
176
+ else if (s.start > index) hi = mid - 1;
177
+ else return s;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** The balanced brace block that starts at or after `index`, on the structural view. */
183
+ export function blockAfter(structural, index) {
184
+ const open = structural.indexOf('{', index);
185
+ if (open < 0) return undefined;
186
+ let depth = 0;
187
+ for (let i = open; i < structural.length; i++) {
188
+ const c = structural[i];
189
+ if (c === '{') depth += 1;
190
+ else if (c === '}') {
191
+ depth -= 1;
192
+ if (depth === 0) return { start: open, end: i + 1, body: structural.slice(open + 1, i) };
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ /** Brace depth at a character index, computed on the structural view. */
199
+ export function braceDepthAt(structural, index) {
200
+ let depth = 0;
201
+ for (let k = 0; k < index && k < structural.length; k++) {
202
+ const c = structural[k];
203
+ if (c === '{' || c === '(' || c === '[') depth += 1;
204
+ else if (c === '}' || c === ')' || c === ']') depth -= 1;
205
+ }
206
+ return depth;
207
+ }