@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 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 (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +734 -2
  4. package/bin/dsh-plugin-analyze.mjs +378 -0
  5. package/bin/dsh-plugin-lint.mjs +381 -0
  6. package/bin/dsh-plugin-matrix.mjs +78 -0
  7. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  8. package/package.json +62 -4
  9. package/schema/dsh-manifest.schema.json +134 -0
  10. package/scripts/lib/probe.mjs +34 -0
  11. package/scripts/probe-boot.mjs +123 -0
  12. package/src/composition.js +1 -0
  13. package/src/core/baseline.js +98 -0
  14. package/src/core/changed.js +41 -0
  15. package/src/core/load.js +145 -0
  16. package/src/core/mask.js +207 -0
  17. package/src/core/pack.js +95 -0
  18. package/src/core/packlist.js +186 -0
  19. package/src/core/peer.js +39 -0
  20. package/src/core/profile-scan.js +121 -0
  21. package/src/core/report.js +174 -0
  22. package/src/core/rules.js +581 -0
  23. package/src/core/semver.js +192 -0
  24. package/src/core/tarball.js +90 -0
  25. package/src/data/event-types.json +393 -0
  26. package/src/experimental/conflicts.js +315 -0
  27. package/src/experimental/index.js +4 -0
  28. package/src/experimental/performance.js +515 -0
  29. package/src/experimental/security.js +462 -0
  30. package/src/index.js +74 -0
  31. package/src/load.js +1 -0
  32. package/src/mask.js +1 -0
  33. package/src/matrix/cache.js +19 -0
  34. package/src/matrix/cell.js +79 -0
  35. package/src/matrix/cells.js +58 -0
  36. package/src/matrix/conflicts.js +210 -0
  37. package/src/matrix/export.js +155 -0
  38. package/src/matrix/index.js +71 -0
  39. package/src/matrix/manifest.js +17 -0
  40. package/src/matrix/migrate.js +41 -0
  41. package/src/matrix/run.js +241 -0
  42. package/src/matrix/sanitize.js +42 -0
  43. package/src/matrix/schema.js +178 -0
  44. package/src/matrix/site.js +61 -0
  45. package/src/matrix/sync.js +72 -0
  46. package/src/matrix/verify.js +152 -0
  47. package/src/performance.js +1 -0
  48. package/src/probe/artifact-resolver.js +161 -0
  49. package/src/probe/classify.js +8 -0
  50. package/src/probe/dsh-adapter.js +212 -0
  51. package/src/probe/environment/docker.js +270 -0
  52. package/src/probe/environment/host-unsafe.js +39 -0
  53. package/src/probe/environment/materialize.js +22 -0
  54. package/src/probe/environment.js +50 -0
  55. package/src/probe/installed-artifact.js +17 -0
  56. package/src/probe/probe.js +278 -0
  57. package/src/report.js +1 -0
  58. package/src/rules.js +1 -0
  59. package/src/security.js +1 -0
  60. package/src/shared/errors.js +7 -0
  61. package/src/shared/hash.js +31 -0
  62. package/src/shared/status.js +12 -0
  63. package/src/shared/validate.js +14 -0
  64. package/src/shared/version.js +4 -0
  65. package/src/upgrade/compatibility.js +14 -0
  66. package/src/upgrade/planner.js +119 -0
  67. package/src/upgrade/profile.js +82 -0
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@shidesheng0218/dsh-plugin-lint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "Conformance checks and a compatibility matrix for DeepSeek Harness plugins: what the code declares, what it publishes, and what the host does with it.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "lint",
10
+ "conformance",
11
+ "compatibility"
12
+ ],
13
+ "homepage": "https://github.com/shidesheng0218/dsh-plugin-lint",
14
+ "license": "Apache-2.0",
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "exports": {
20
+ ".": "./src/index.js",
21
+ "./experimental": "./src/experimental/index.js",
22
+ "./schema/dsh-manifest.schema.json": "./schema/dsh-manifest.schema.json",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "bin": {
26
+ "dsh-plugin-lint": "bin/dsh-plugin-lint.mjs",
27
+ "dsh-plugin-probe": "scripts/probe-boot.mjs",
28
+ "dsh-plugin-analyze": "bin/dsh-plugin-analyze.mjs",
29
+ "dsh-plugin-upgrade-plan": "bin/dsh-plugin-upgrade-plan.mjs",
30
+ "dsh-plugin-matrix": "bin/dsh-plugin-matrix.mjs"
31
+ },
32
+ "files": [
33
+ "bin/",
34
+ "src/",
35
+ "schema/",
36
+ "scripts/probe-boot.mjs",
37
+ "scripts/lib/",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ],
42
+ "scripts": {
43
+ "test": "node --test test/*.test.js",
44
+ "test:coverage": "node --experimental-test-coverage --test --test-coverage-lines=85 --test-coverage-branches=80 --test-coverage-functions=80 --test-coverage-exclude=\"**/node_modules/**\" --test-coverage-exclude=\"**/.cache/**\" test/*.test.js",
45
+ "package:smoke": "node scripts/package-smoke.mjs",
46
+ "e2e:official": "node scripts/e2e-official.mjs --expect 80",
47
+ "harvest:events": "node scripts/harvest-event-types.mjs",
48
+ "fetch:official": "node scripts/fetch-official-from-npm.mjs",
49
+ "matrix": "node scripts/matrix/run.mjs",
50
+ "site": "node scripts/matrix/site.mjs",
51
+ "panel": "node scripts/matrix/bundle-panel-data.mjs",
52
+ "notify": "node scripts/build-notification-pack.mjs"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/shidesheng0218/dsh-plugin-lint.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/shidesheng0218/dsh-plugin-lint/issues"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,134 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/shidesheng0218/dsh-plugin-lint/main/schema/dsh-manifest.schema.json",
4
+ "title": "DeepSeek Harness plugin package.json",
5
+ "description": "The package.json fields a DSH plugin author declares. Unknown fields are allowed; editors validate the parts the host reads before CI runs dsh-plugin-lint.",
6
+ "$comment": "schemaVersion 1, shipped with @shidesheng0218/dsh-plugin-lint 0.4.0",
7
+ "type": "object",
8
+ "required": ["name", "version"],
9
+ "properties": {
10
+ "name": {
11
+ "type": "string",
12
+ "pattern": "^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$"
13
+ },
14
+ "version": {
15
+ "type": "string"
16
+ },
17
+ "private": {
18
+ "type": "boolean"
19
+ },
20
+ "type": {
21
+ "type": "string",
22
+ "enum": ["module", "commonjs"]
23
+ },
24
+ "main": {
25
+ "type": "string"
26
+ },
27
+ "exports": {
28
+ "$ref": "#/definitions/exports"
29
+ },
30
+ "files": {
31
+ "type": "array",
32
+ "items": { "type": "string" }
33
+ },
34
+ "bin": {
35
+ "oneOf": [
36
+ { "type": "string" },
37
+ { "type": "object", "additionalProperties": { "type": "string" } }
38
+ ]
39
+ },
40
+ "icon": {
41
+ "type": "string"
42
+ },
43
+ "peerDependencies": {
44
+ "type": "object",
45
+ "additionalProperties": { "type": "string" }
46
+ },
47
+ "dsh": {
48
+ "$ref": "#/definitions/dsh"
49
+ }
50
+ },
51
+ "additionalProperties": true,
52
+ "definitions": {
53
+ "exports": {
54
+ "oneOf": [
55
+ { "type": "string" },
56
+ { "type": "array", "items": { "$ref": "#/definitions/exports" } },
57
+ { "type": "object", "additionalProperties": { "$ref": "#/definitions/exports" } }
58
+ ]
59
+ },
60
+ "dsh": {
61
+ "type": "object",
62
+ "description": "Everything the DSH host reads from package.json.",
63
+ "properties": {
64
+ "bundle": {
65
+ "type": "object",
66
+ "properties": {
67
+ "patch": {
68
+ "oneOf": [
69
+ { "type": "string" },
70
+ { "type": "array", "items": { "type": "string" } }
71
+ ]
72
+ }
73
+ },
74
+ "additionalProperties": true
75
+ },
76
+ "client": {
77
+ "description": "Declares a browser half; the host resolves exports[\"./client\"].",
78
+ "oneOf": [
79
+ { "type": "boolean" },
80
+ {
81
+ "type": "object",
82
+ "properties": {
83
+ "platform": { "type": "string" },
84
+ "immediately": { "type": "boolean" },
85
+ "inject": { "type": "array", "items": { "type": "string" } },
86
+ "external": { "type": "array", "items": { "type": "string" } }
87
+ },
88
+ "additionalProperties": true
89
+ }
90
+ ]
91
+ },
92
+ "profile": {
93
+ "type": "object",
94
+ "properties": {
95
+ "bundles": {
96
+ "type": "array",
97
+ "items": {
98
+ "oneOf": [
99
+ { "type": "string" },
100
+ {
101
+ "type": "object",
102
+ "properties": {
103
+ "name": { "type": "string" },
104
+ "id": { "type": "string" },
105
+ "package": { "type": "string" },
106
+ "bundle": { "type": "string" },
107
+ "plugin": { "type": "string" }
108
+ },
109
+ "additionalProperties": true
110
+ }
111
+ ]
112
+ }
113
+ }
114
+ },
115
+ "additionalProperties": true
116
+ },
117
+ "compatibility": {
118
+ "type": "object",
119
+ "properties": {
120
+ "dshReleases": {
121
+ "description": "A declaration, not the host gate; the host checks peerDependencies on @deepseek-ai/dsh*.",
122
+ "type": "object",
123
+ "additionalProperties": {
124
+ "oneOf": [{ "type": "string" }, { "type": "boolean" }]
125
+ }
126
+ }
127
+ },
128
+ "additionalProperties": true
129
+ }
130
+ },
131
+ "additionalProperties": true
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdtempSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { resolveArtifact } from '../../src/probe/artifact-resolver.js';
5
+ import { createProbeEnvironment } from '../../src/probe/environment.js';
6
+ import { probePackage as probePinnedPackage } from '../../src/probe/probe.js';
7
+
8
+ export { WEB_READY, resolveDsh, runDsh, runUntilReady, startStub } from '../../src/probe/dsh-adapter.js';
9
+ export { classify } from '../../src/probe/classify.js';
10
+ export {
11
+ capture,
12
+ redact,
13
+ parseInactive,
14
+ awaitedServices,
15
+ rejectionOf,
16
+ noteOf,
17
+ payloadOf,
18
+ } from '../../src/probe/probe.js';
19
+
20
+ export async function probePackage(options) {
21
+ const artifact = options.artifact || await resolveArtifact(options.target);
22
+ const environment = options.environment || await createProbeEnvironment({
23
+ backend: options.backend || 'auto',
24
+ workDir: options.workDir,
25
+ });
26
+ return probePinnedPackage({
27
+ ...options,
28
+ artifact,
29
+ environment,
30
+ composition: options.composition || options.mode || 'headless',
31
+ });
32
+ }
33
+
34
+ export const tmpHome = () => mkdtempSync(join(tmpdir(), 'dsh-probe-'));
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
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');
30
+
31
+ const argv = process.argv.slice(2);
32
+ if (argv.includes('-h') || argv.includes('--help')) {
33
+ console.log(USAGE);
34
+ process.exit(0);
35
+ }
36
+ const argOf = (name, fallback) => {
37
+ const index = argv.indexOf(name);
38
+ return index >= 0 ? argv[index + 1] : fallback;
39
+ };
40
+ const keep = argv.includes('--keep');
41
+ const withTools = argv.includes('--tools');
42
+ const withAudit = argv.includes('--audit');
43
+ const backend = argv.includes('--unsafe-host') ? 'host-unsafe' : 'auto';
44
+ const dshVersion = argOf('--dsh', undefined);
45
+ const modeArg = argOf('--mode', 'headless');
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
+ }
50
+ const modes = modeArg === 'both' ? ['headless', 'web'] : [modeArg];
51
+ const valueFlags = ['--dsh', '--mode', '--cache'];
52
+ const packages = argv.filter((arg, index) => !arg.startsWith('--') && !valueFlags.includes(argv[index - 1]));
53
+ if (packages.length === 0) {
54
+ console.error(USAGE);
55
+ process.exit(2);
56
+ }
57
+
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' : ''));
74
+
75
+ const results = [];
76
+ let failures = 0;
77
+ for (const name of packages) {
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 } });
119
+ }
120
+ }
121
+
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
+ }