dsh-plugin-upgrade 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +102 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +201 -0
- package/README.es.md +172 -0
- package/README.hi.md +172 -0
- package/README.md +172 -0
- package/README.pt.md +172 -0
- package/README.zh.md +172 -0
- package/SECURITY.md +43 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cordis.patch.yml +23 -0
- package/index.mjs +105 -0
- package/lib/scan.mjs +288 -0
- package/package.json +95 -0
- package/scripts/changelog-section.mjs +21 -0
- package/scripts/check-readme-sync.mjs +33 -0
- package/scripts/scan-0.1.5.mjs +15 -0
- package/scripts/sweep-all.mjs +36 -0
- package/scripts/verify-artifacts.mjs +86 -0
- package/scripts/verify-self-contained.mjs +59 -0
- package/skills/plugin-upgrade-015/SKILL.md +38 -0
- package/skills/plugin-upgrade-015/references/v0.1.3-alpha.1-to-v0.1.5-alpha.1.md +90 -0
- package/skills/plugin-upgrade-015/scripts/scan-0.1.5.mjs +14 -0
- package/types.d.ts +67 -0
package/README.zh.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ⬆️ dsh-plugin-upgrade
|
|
4
|
+
- **1024 商店通道**:先 `npm i -g dsh1024`,再执行 `dsh1024 plugin --profile web add dsh-plugin-upgrade`(计入 [deepseek1024.com](https://deepseek1024.com) 安装排行)。
|
|
5
|
+
[](https://gitee.com/perrylink/dsh-plugin-upgrade)
|
|
6
|
+
|
|
7
|
+
**DeepSeek Harness 插件升级技能(版本锁定)——`0.1.3-alpha.1` → `0.1.5-alpha.1`。**
|
|
8
|
+
|
|
9
|
+
*一张版本卡 + 一个零依赖接缝扫描器,让「typecheck 绿了」不再被误当成「插件还能跑」。*
|
|
10
|
+
|
|
11
|
+
> **官方仓库。** 这是 dsh-plugin-upgrade 唯一的官方仓库,由 PerryLink 维护。其他账号下的同名仓库与本项目无关。
|
|
12
|
+
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
[](https://github.com/topics/dsh-plugin)
|
|
15
|
+
[](https://github.com/PerryLink/dsh-plugin-doctor#verified-徽章)
|
|
16
|
+
[](#)
|
|
17
|
+
[](https://github.com/PerryLink/dsh-plugin-upgrade/actions)
|
|
18
|
+
[](https://github.com/PerryLink/dsh-plugin-upgrade/releases)
|
|
19
|
+
[](https://www.npmjs.com/package/dsh-plugin-upgrade)
|
|
20
|
+
[](https://www.npmjs.com/package/dsh-plugin-upgrade)
|
|
21
|
+
|
|
22
|
+
[English](README.md) · [简体中文](README.zh.md) · [Español](README.es.md) · [Português](README.pt.md) · [हिन्दी](README.hi.md)
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 兼容性
|
|
29
|
+
|
|
30
|
+
| 面 | 状态 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| 宿主 | DeepSeek Harness `0.1.5-alpha.1`(checkout `19d2e38480`,tag `dsh-v0.1.5-alpha.1` = `5dda764ed3`)。peer 区间 `@deepseek-ai/dsh-skill >=0.1.2-rc.1 <0.2.0 \|\| >=0.1.5-alpha.1 <0.2.0`、`@deepseek-ai/cordis ^4.0.2`、`@deepseek-ai/schemastery ^3.18.2`。 |
|
|
33
|
+
| Node | `^22.19.0 \|\| >=24.0.0` |
|
|
34
|
+
| 平台 | 有 Node 即可;扫描器只读文件系统,与平台无关 |
|
|
35
|
+
| 模型 | 纯文本模型完全支持;技能就是一段 Markdown,不要求工具或视觉能力 |
|
|
36
|
+
| 范围 | **只覆盖一条走廊**:`0.1.3-alpha.1` → `0.1.5-alpha.1`。它不是通用迁移框架。 |
|
|
37
|
+
|
|
38
|
+
## 你得到什么
|
|
39
|
+
|
|
40
|
+
两半,一套接缝目录:
|
|
41
|
+
|
|
42
|
+
- **一个随包发布的 agent 技能(`plugin-upgrade-015`)** —— 版本卡 + 「修-验」循环。只有任务真正需要时模型才会加载它;本包不贡献任何系统提示词段落,也不注册工具。
|
|
43
|
+
- **一个零依赖 CLI(`dsh-plugin-upgrade-scan`)** —— 按 `file:line` 报告十类接缝;这些接缝来自 2026-09-09 那轮对 40 个真实插件仓的实测。命中 error 级即退出码 `1`,可直接接进 CI。
|
|
44
|
+
|
|
45
|
+
它要消灭的失效模式是:**本地门禁绿了,不等于适配完成。** 有两类破坏能穿过 `typecheck` + `test`:
|
|
46
|
+
|
|
47
|
+
1. 已发布的类型线把接缝藏住,仓库对着旧类型编译(接缝 `M1`);
|
|
48
|
+
2. 测试是按旧形状 mock 的,于是测试通过而宿主拒收。
|
|
49
|
+
|
|
50
|
+
十类接缝中的 `S3`、`S8`、`S9`、`M1` 在本包成文时仍未被任何社区升级 PR 覆盖;其余六类已与该轮证据交叉核对。
|
|
51
|
+
|
|
52
|
+
## 快速开始
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
# 1. 把 bundle 装进 profile
|
|
56
|
+
dsh plugin --profile web add dsh-plugin-upgrade
|
|
57
|
+
|
|
58
|
+
# 2. 确认这一行挂上了
|
|
59
|
+
dsh --profile web --dump-config | grep -A3 'id: dsh-plugin-upgrade'
|
|
60
|
+
|
|
61
|
+
# 3. 扫描你正在升级的插件
|
|
62
|
+
npx dsh-plugin-upgrade-scan --repo ../my-plugin
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
然后让 agent 使用 `plugin-upgrade-015` 技能,或者自己照着版本卡走:
|
|
66
|
+
`skills/plugin-upgrade-015/references/v0.1.3-alpha.1-to-v0.1.5-alpha.1.md`。
|
|
67
|
+
|
|
68
|
+
## 安装与卸载
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
dsh plugin --profile web add dsh-plugin-upgrade # 从 npm 安装
|
|
72
|
+
dsh plugin --profile web add "github:PerryLink/dsh-plugin-upgrade#main" # 从源码安装
|
|
73
|
+
dsh plugin --profile web remove dsh-plugin-upgrade # 卸载(可逆)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
装 bundle 只是注册一个技能;删掉那一行就删掉技能。CLI 是普通的 `npx` 目标,完全不需要 profile。
|
|
77
|
+
|
|
78
|
+
## 配置
|
|
79
|
+
|
|
80
|
+
所有键都可选,写在 profile patch 里:
|
|
81
|
+
|
|
82
|
+
| 键 | 默认 | 含义 |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `enabled` | `true` | 是否注册随包技能。设为 `false` 可保留依赖但不生效。 |
|
|
85
|
+
| `skillName` | `plugin-upgrade-015` | `skillsRoot` 下要注册的目录名,也是目录里显示的名字。 |
|
|
86
|
+
| `skillsRoot` | 包自带的 `./skills` | `<skillName>/SKILL.md` 所在目录。指向你自己的卡即可复用整套管线。 |
|
|
87
|
+
| `userInvocable` | `true` | 除模型外,是否允许人按名字调用该技能。 |
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
- insert:
|
|
91
|
+
- id: dsh-plugin-upgrade
|
|
92
|
+
name: dsh-plugin-upgrade
|
|
93
|
+
config:
|
|
94
|
+
skillName: plugin-upgrade-015
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
挂载是「大声失败」的:`SKILL.md` 缺失、正文为空、或 frontmatter 没有 `name`,都会让挂载失败,而不是注册一个空技能。
|
|
98
|
+
|
|
99
|
+
## 暴露面
|
|
100
|
+
|
|
101
|
+
**技能** —— `plugin-upgrade-015`(默认模型可调用、人也可调用)。正文:6 步循环。引用:版本卡。脚本:扫描器,随技能目录一起发布,因此相对路径可解析。
|
|
102
|
+
|
|
103
|
+
**CLI** —— `dsh-plugin-upgrade-scan`:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
dsh-plugin-upgrade-scan [--repo <path>] [--json <out.json>] [--seams S3,S8,M1] [--quiet]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| 参数 | 含义 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `--repo <path>` | 要扫描的仓库(默认:当前目录)。 |
|
|
112
|
+
| `--json <out.json>` | 同时写出机器可读报告(`repo`、`scannedAt`、`files`、`hits[]`、`bySeam`)。 |
|
|
113
|
+
| `--seams S3,S8,M1` | 只扫指定接缝。 |
|
|
114
|
+
| `--quiet` | 不打印人类可读渲染(与 `--json` 搭配)。 |
|
|
115
|
+
|
|
116
|
+
退出码:`0` 无 error 级命中 · `1` 至少一个 error 级命中 · `2` 用法或扫描失败。扫描干净是必要条件而非充分条件 —— 卡上的出口标准是在临时 `DSH_HOME` 上做一次真机冒烟。
|
|
117
|
+
|
|
118
|
+
## 十类接缝
|
|
119
|
+
|
|
120
|
+
| Id | 级别 | `0.1.5-alpha.1` 线里变了什么 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `S1` | warn | 会话格式为 V3,日志文件名带世代(`session.v3.jsonl.zstd`)。硬编码 `session.jsonl.zstd` 会静默失效。 |
|
|
123
|
+
| `S2` | warn | `EpochHeader.system` 已移除;系统提示词是 surface 节点 0 的 `system/message`。读取方需要结构式回退。 |
|
|
124
|
+
| `S3` | error | `assistant/message` 必须带 `stream`;缺了会话能导入但无法续聊(`Session.fromRestore` 拒绝非法 settlement 字段)。 |
|
|
125
|
+
| `S4` | error | `tool/code-dispatch` 改名为 `tool/ptc-dispatch`。 |
|
|
126
|
+
| `S5` | error | `ctx.agent` 已移除;调用方改为显式接收 `Agent`。 |
|
|
127
|
+
| `S6` | error | `Inbox` 变成类型接口,不再可构造;请用 `agent.inbox` 与官方夹具形状。 |
|
|
128
|
+
| `S7` | warn | `SubprocessHandle.pid` 已移除(只有 `SubprocessTerminalHandle` 保留 `pid`)。 |
|
|
129
|
+
| `S8` | error | `SessionHandle.read()` 现在返回 `SessionHandleReadResult` —— 要解包 `.events`。 |
|
|
130
|
+
| `S9` | error | `SystemPrompt` 配置的 `persona` 改为 `personaPrefix` / `personaSuffix`。 |
|
|
131
|
+
| `M1` | error | `tsconfig` 的 `paths` 指向不存在的 checkout 目录时,TypeScript 会静默回退到已发布类型 —— 本地门禁变成**假绿**。 |
|
|
132
|
+
|
|
133
|
+
`S7`、`S1`、`S2`、`S10` 刻意保持「提示」级别:它们有合法命中(Node 自带的 `pid`、旧世代读取器、插件自建的自适应事件门),所以扫描器把它们报成待人工复核的线索,而不是失败。
|
|
134
|
+
|
|
135
|
+
## 本包不覆盖什么
|
|
136
|
+
|
|
137
|
+
- **其他走廊。** `0.1.1` → `0.1.2` 以及未来版本线不在范围内;版本卡刻意锁死版本 —— 会漂移的卡比没有卡更糟。
|
|
138
|
+
- **DSH 面向用户的升级路径。** 本包升级的是**插件源码**,不是用户的 harness 安装。
|
|
139
|
+
- **客户端/浏览器行为。** 扫描器是静态的;client 半边仍需真实浏览器断言。
|
|
140
|
+
- **证明。** 扫描干净只是假设。出口标准是真机冒烟(临时 `DSH_HOME`、目标 CLI、`plugin add <tarball>`、`--dump-config`,会话日志写入方还要加一次 resume 往返)。
|
|
141
|
+
|
|
142
|
+
## 安全边界
|
|
143
|
+
|
|
144
|
+
- **只读扫描。** CLI 绝不往被扫仓库里写文件;`--json` 只写你指定的路径。
|
|
145
|
+
- **无网络、无 shell。** 扫描器除 Node 标准库外不引入任何依赖,也从不创建进程。
|
|
146
|
+
- **不碰密钥。** 本包不读取凭据、环境令牌或会话数据。
|
|
147
|
+
- **沙箱化冒烟配方。** 卡上的真机检查使用 `mkdtemp` 出来的 `DSH_HOME`,绝不碰你真实的 `~/.dsh`。
|
|
148
|
+
|
|
149
|
+
## 开发
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
pnpm install
|
|
153
|
+
pnpm test # node --test(真实 Cordis + 真实 SkillRegistry)
|
|
154
|
+
pnpm run verify:self-contained # 所有 import 都在包内解析
|
|
155
|
+
pnpm run verify:artifacts # 打包产物带齐技能、CLI 与 patch
|
|
156
|
+
pnpm run check:readmes # 五语 README 一致性
|
|
157
|
+
pnpm pack
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
扫描器自带合成的 `fixtures/bad-repo` 与 `fixtures/good-repo`,外加一个针对已适配仓库的 live negative —— 目录回归会让测试套件失败,而不是让下游用户踩坑。
|
|
161
|
+
|
|
162
|
+
## 主题标签
|
|
163
|
+
|
|
164
|
+
`dsh`、`dsh-plugin`、`deepseek-harness`、`deepseek`、`cordis`、`plugin-upgrade`、`migration`、`skill`、`version-card`、`scanner`(与 `package.json` 的 keywords 一致;`dsh-plugin` 是生态的曝光通道)。
|
|
165
|
+
|
|
166
|
+
## PerryLink DSH 插件家族
|
|
167
|
+
|
|
168
|
+
属于 PerryLink DSH 插件家族 —— 40 个仓库,覆盖会话、记忆、权限、交付、可观测性与开发者工具。目录见 [perrylink-dsh-catalog.perrylink.workers.dev](https://perrylink-dsh-catalog.perrylink.workers.dev) 或 [`dsh-plugin` 主题](https://github.com/topics/dsh-plugin)。
|
|
169
|
+
|
|
170
|
+
## 许可证
|
|
171
|
+
|
|
172
|
+
Apache-2.0 —— 见 [LICENSE](LICENSE)。安装期依赖及其许可证列在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md);本包不打包任何第三方代码。
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Report privately through GitHub's [private vulnerability reporting](https://github.com/PerryLink/dsh-plugin-upgrade/security/advisories/new)
|
|
6
|
+
(Security → Advisories → Report a vulnerability). Please do not open a public issue for a
|
|
7
|
+
suspected vulnerability.
|
|
8
|
+
|
|
9
|
+
Include the version, the exact command or plugin config, the observed behavior and, when
|
|
10
|
+
possible, a minimal reproduction. You can expect an acknowledgement within a few days.
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
`dsh-plugin-upgrade` is a **read-only developer tool**:
|
|
15
|
+
|
|
16
|
+
- The scanner reads files under `--repo` and never writes inside the scanned repository.
|
|
17
|
+
`--json` writes only to the path you pass.
|
|
18
|
+
- It imports nothing beyond Node's standard library, opens no socket and spawns no process.
|
|
19
|
+
- It reads no credentials, no environment tokens and no session data.
|
|
20
|
+
- The bundled skill is a Markdown body plus a version card; it contributes no system-prompt
|
|
21
|
+
paragraph and registers no tool.
|
|
22
|
+
|
|
23
|
+
In scope:
|
|
24
|
+
|
|
25
|
+
- a crafted repository that makes the scanner write outside the requested paths, escape the
|
|
26
|
+
scanned root, or execute code;
|
|
27
|
+
- a path-traversal or symlink escape in `--repo` / `--json` handling;
|
|
28
|
+
- a plugin config that mounts the skill from outside the intended root, or that silently
|
|
29
|
+
registers a skill different from the packaged one;
|
|
30
|
+
- a published artifact that differs from this repository's source.
|
|
31
|
+
|
|
32
|
+
Out of scope:
|
|
33
|
+
|
|
34
|
+
- findings in the DeepSeek Harness itself (report those upstream);
|
|
35
|
+
- the *content* of the upgrade card being incomplete for a repository the maintainer has
|
|
36
|
+
never seen — that is a bug report, not a vulnerability;
|
|
37
|
+
- anything that requires the attacker to already control the machine or the repository
|
|
38
|
+
being scanned.
|
|
39
|
+
|
|
40
|
+
## Supported versions
|
|
41
|
+
|
|
42
|
+
The latest published `0.1.x` line receives fixes. Prerelease versions of the harness are
|
|
43
|
+
supported as declared in `package.json` peer ranges.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`dsh-plugin-upgrade` ships **no bundled third-party code**. The published tarball contains
|
|
4
|
+
only this repository's source, the packaged skill and its version card.
|
|
5
|
+
|
|
6
|
+
The dependencies below are declared as `peerDependencies` (the host provides them at
|
|
7
|
+
runtime) and as `devDependencies` (used only to run the test suite in this repository). None
|
|
8
|
+
of them is copied into the published artifact.
|
|
9
|
+
|
|
10
|
+
| Package | Range | Role | License |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| `@deepseek-ai/cordis` | `^4.0.2` (peer) | Plugin runtime (Context, effect, inject) | Apache-2.0 |
|
|
13
|
+
| `@deepseek-ai/dsh-skill` | `>=0.1.2-rc.1 <0.2.0 \|\| >=0.1.5-alpha.1 <0.2.0` (peer) | `skills` service and `SkillRegistry` used by the tests | Apache-2.0 |
|
|
14
|
+
| `@deepseek-ai/schemastery` | `^3.18.2` (peer) | `Config` schema | Apache-2.0 |
|
|
15
|
+
| `@deepseek-ai/dsh-skill` | `0.1.5-alpha.1` (dev) | Real-registry mount test | Apache-2.0 |
|
|
16
|
+
|
|
17
|
+
The scanner (`lib/scan.mjs`) and the CLI import only Node.js built-ins (`node:fs`,
|
|
18
|
+
`node:path`) and therefore add no transitive install-time dependency of their own.
|
|
19
|
+
|
|
20
|
+
License texts for the peer packages are distributed by those packages. This repository's own
|
|
21
|
+
code is Apache-2.0 — see [LICENSE](LICENSE).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# dsh-plugin-upgrade bundle patch layer.
|
|
3
|
+
#
|
|
4
|
+
# A loader patch file is a top-level YAML array; each entry inserts (or removes)
|
|
5
|
+
# composition rows. This bundle mounts one row and registers one bundled agent
|
|
6
|
+
# skill (`plugin-upgrade-015`) through the `skills` service. It contributes no
|
|
7
|
+
# system-prompt paragraph and no tool: the model sees the skill only when a task
|
|
8
|
+
# needs it, and the CLI (`dsh-plugin-upgrade-scan`) stays an explicit developer
|
|
9
|
+
# action. Every key below is a Config field (Schemastery schema); invalid values
|
|
10
|
+
# fail the profile load loudly. See README.md "Configuration".
|
|
11
|
+
- insert:
|
|
12
|
+
- id: dsh-plugin-upgrade
|
|
13
|
+
name: dsh-plugin-upgrade
|
|
14
|
+
config:
|
|
15
|
+
# Register the packaged skill. Default true.
|
|
16
|
+
enabled: true
|
|
17
|
+
# Skill name published to the model catalog. Default 'plugin-upgrade-015'.
|
|
18
|
+
skillName: plugin-upgrade-015
|
|
19
|
+
# Skill root inside the package; must contain <skillName>/SKILL.md.
|
|
20
|
+
# Default: the package's own ./skills directory.
|
|
21
|
+
# skillsRoot: /absolute/path/to/skills
|
|
22
|
+
# Mark the skill user-invocable in addition to model-invocable. Default true.
|
|
23
|
+
userInvocable: true
|
package/index.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// dsh-plugin-upgrade bundle entry point.
|
|
2
|
+
//
|
|
3
|
+
// Publishes the packaged 0.1.3-alpha.1 -> 0.1.5-alpha.1 plugin-upgrade skill as an
|
|
4
|
+
// on-demand agent skill named `plugin-upgrade-015`. The skill body is this
|
|
5
|
+
// package's `skills/plugin-upgrade-015/SKILL.md`; its relative references
|
|
6
|
+
// (`./references/...`) and scripts (`./scripts/...`) resolve against the packaged
|
|
7
|
+
// skills directory through the directory resourceBase, so the agent loads the
|
|
8
|
+
// version card and the scanner only when a task needs them.
|
|
9
|
+
//
|
|
10
|
+
// The package imports nothing from the harness beyond the injected `skills`
|
|
11
|
+
// service, so the cordis peer stays metadata-only.
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-plugin-upgrade'
|
|
18
|
+
export const inject = ['skills']
|
|
19
|
+
|
|
20
|
+
/** Package root, used as the resourceBase for relative skill references. */
|
|
21
|
+
const packageRoot = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Plugin configuration (Schemastery). Every knob is a deployment choice and is
|
|
25
|
+
* documented in cordis.patch.yml; there are no hardcoded tunables.
|
|
26
|
+
*/
|
|
27
|
+
export const Config = Schema.object({
|
|
28
|
+
/** Register the packaged skill (default true). */
|
|
29
|
+
enabled: Schema.boolean().default(true),
|
|
30
|
+
/** Skill name published to the model catalog. Defaults to the packaged corridor name. */
|
|
31
|
+
skillName: Schema.string().default('plugin-upgrade-015'),
|
|
32
|
+
/** Skill root inside the package; must contain `<skillName>/SKILL.md`. */
|
|
33
|
+
skillsRoot: Schema.string().default(join(packageRoot, 'skills')),
|
|
34
|
+
/** Mark the skill user-invocable in addition to model-invocable (default true). */
|
|
35
|
+
userInvocable: Schema.boolean().default(true),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip the YAML frontmatter block from SKILL.md and return its routing fields
|
|
40
|
+
* and body. A missing block falls back to the full text as the body.
|
|
41
|
+
* @param text - raw SKILL.md content.
|
|
42
|
+
* @returns the parsed description/whenToUse (when present) and the instruction body.
|
|
43
|
+
*/
|
|
44
|
+
export function splitFrontmatter(text) {
|
|
45
|
+
if (!text.startsWith('---\n')) return { description: undefined, whenToUse: undefined, body: text }
|
|
46
|
+
const end = text.indexOf('\n---', 4)
|
|
47
|
+
if (end < 0) return { description: undefined, whenToUse: undefined, body: text }
|
|
48
|
+
const meta = text.slice(4, end)
|
|
49
|
+
const body = text.slice(end + 4).replace(/^\n+/, '')
|
|
50
|
+
const scalar = (key) => new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(meta)?.[1]?.trim()
|
|
51
|
+
return { description: scalar('description'), whenToUse: scalar('whenToUse'), body }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read and validate the packaged skill bundle. Fails loud: a missing SKILL.md,
|
|
56
|
+
* an empty body, or a missing frontmatter `name` aborts the mount instead of
|
|
57
|
+
* registering an empty skill.
|
|
58
|
+
* @param skillsRoot - root directory holding `<skillName>/SKILL.md`.
|
|
59
|
+
* @param skillName - expected skill directory name.
|
|
60
|
+
* @returns the frontmatter name, routing fields, body, and the skill directory.
|
|
61
|
+
*/
|
|
62
|
+
export function readSkillBundle(skillsRoot, skillName) {
|
|
63
|
+
const skillPath = join(skillsRoot, skillName, 'SKILL.md')
|
|
64
|
+
let text
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(skillPath, 'utf8')
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`dsh-plugin-upgrade: cannot read skill bundle at ${skillPath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
69
|
+
}
|
|
70
|
+
const { description, whenToUse, body } = splitFrontmatter(text)
|
|
71
|
+
if (body.trim() === '') throw new Error(`dsh-plugin-upgrade: skill body is empty at ${skillPath}`)
|
|
72
|
+
const frontmatterName = /^name:\s*(\S+)\s*$/m.exec(text.slice(0, text.indexOf('\n---', 4) + 1))?.[1]
|
|
73
|
+
if (frontmatterName === undefined) throw new Error(`dsh-plugin-upgrade: skill frontmatter is missing a name at ${skillPath}`)
|
|
74
|
+
return { frontmatterName, description, whenToUse, body, skillDir: join(skillsRoot, skillName) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register the packaged skill. Registration is an effect: the disposer returned
|
|
79
|
+
* by `ctx.skills.register()` removes the contribution on unload.
|
|
80
|
+
* @param ctx - Cordis context with the injected `skills` service.
|
|
81
|
+
* @param config - validated plugin configuration.
|
|
82
|
+
*/
|
|
83
|
+
export function apply(ctx, config = {}) {
|
|
84
|
+
const resolved = {
|
|
85
|
+
enabled: config.enabled ?? true,
|
|
86
|
+
skillName: config.skillName ?? 'plugin-upgrade-015',
|
|
87
|
+
skillsRoot: config.skillsRoot ?? join(packageRoot, 'skills'),
|
|
88
|
+
userInvocable: config.userInvocable ?? true,
|
|
89
|
+
}
|
|
90
|
+
if (!resolved.enabled) return
|
|
91
|
+
const { frontmatterName, description, whenToUse, body, skillDir } = readSkillBundle(resolved.skillsRoot, resolved.skillName)
|
|
92
|
+
ctx.effect(() =>
|
|
93
|
+
ctx.skills.register({
|
|
94
|
+
name: frontmatterName,
|
|
95
|
+
source: 'bundled',
|
|
96
|
+
description: description ?? 'DSH plugin upgrade · 0.1.3-alpha.1 -> 0.1.5-alpha.1: seam scanner and version card.',
|
|
97
|
+
...whenToUse !== undefined ? { whenToUse } : {},
|
|
98
|
+
content: body,
|
|
99
|
+
// The base is the skill's own directory, so `./references/...` and
|
|
100
|
+
// `./scripts/...` in the body resolve inside the published tarball.
|
|
101
|
+
resourceBase: { kind: 'directory', path: skillDir },
|
|
102
|
+
invocation: { modelInvocable: true, userInvocable: resolved.userInvocable },
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
}
|
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scan-0.1.5.mjs — zero-dependency detector for the DSH `0.1.3-alpha.1 → 0.1.5-alpha.1`
|
|
4
|
+
* plugin-adaptation seams.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: typecheck passing is NOT evidence of adaptation. Two classes of
|
|
7
|
+
* failure survive a green local gate — (a) seams the published type line hides
|
|
8
|
+
* because the repo compiles against stale types, and (b) seams whose tests are
|
|
9
|
+
* mocked against the old shape. This scanner reports `file:line` facts for ten
|
|
10
|
+
* seams measured against 40 real plugin repos during the 2026-09-09 wave, and
|
|
11
|
+
* treats the "tsconfig silently resolves to the wrong types" case (M1) as a
|
|
12
|
+
* first-class defect, not a warning.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scan-0.1.5.mjs [--repo <path>] [--json <out.json>] [--seams S3,S8,M1] [--quiet]
|
|
16
|
+
*
|
|
17
|
+
* Exit codes: 0 = no error-severity hit, 1 = at least one error-severity hit,
|
|
18
|
+
* 2 = usage/scan failure.
|
|
19
|
+
*
|
|
20
|
+
* Provenance: evidence for every seam lives in the 2026-09-09 batch report
|
|
21
|
+
* (40 plugin repos) and the per-repo cards produced by that wave. The scanner
|
|
22
|
+
* ships in this package so a plugin author can re-measure their own repo; it
|
|
23
|
+
* imports nothing outside Node's standard library.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
29
|
+
const SKIP_DIRS = new Set(['node_modules', 'lib', 'dist', '.git', '.tmp', 'coverage', '_scratch', '_archive', 'downloads', 'upstream', 'dev'])
|
|
30
|
+
const SCAN_EXT = /\.(ts|tsx|mts|cts|mjs|cjs|js|jsx|json|yml|yaml)$/
|
|
31
|
+
|
|
32
|
+
/** @typedef {{ id: string, title: string, severity: 'error'|'warn'|'info', action: string, test: RegExp }} Seam */
|
|
33
|
+
|
|
34
|
+
/** @type {Seam[]} */
|
|
35
|
+
export const SEAMS = [
|
|
36
|
+
{
|
|
37
|
+
id: 'S3',
|
|
38
|
+
title: 'assistant/message 缺 stream(V3 必填)',
|
|
39
|
+
severity: 'error',
|
|
40
|
+
action: '写入会话日志的 assistant/message 必须带 `stream: []`(或真实分片);缺了导入成功但会话不可续聊(Session.fromRestore 抛 invalid settlement fields)。',
|
|
41
|
+
test: /assistant\/message/,
|
|
42
|
+
// Only object construction / append calls, never comparisons or prose.
|
|
43
|
+
lineFilter: line => /type\s*:\s*['"]assistant\/message['"]|append\(\s*['"]assistant\/message['"]|['"]assistant\/message['"]\s*,/.test(line),
|
|
44
|
+
// Only production code that actually writes session logs, and only when the file
|
|
45
|
+
// never mentions `stream` (comments excluded).
|
|
46
|
+
fileCheck: (text, file) => !/(^|[\\/])(tests?|__tests__)[\\/]/.test(file) &&
|
|
47
|
+
/(\.append\(|writeFileSync|writeFile\(|writeSync|persist)/.test(text) &&
|
|
48
|
+
/assistant\/message/.test(text) && !/\bstream\b/.test(stripComments(text)),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'S8',
|
|
52
|
+
title: 'SessionHandle.read() 返回形状({eventState,events})',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
action: 'handle.read() 现在返回 SessionHandleReadResult;数组操作要改成 `(await handle.read()).events`。',
|
|
55
|
+
test: /\.read\s*\([^)]*\)\s*\.\s*(filter|find|findLast|map|some|every|forEach|slice|length)\b|await\s+[\w.$]+\s*\.\s*read\s*\(/,
|
|
56
|
+
// Only session-persistence handles are in scope (Web Streams readers also expose read()).
|
|
57
|
+
fileCheck: text => /sessionPersistence|SessionHandle|session-persistence|handle\.read\b/.test(text),
|
|
58
|
+
// A file that unwraps `.events` (property, destructuring, or a shared normalizer) is adapted.
|
|
59
|
+
fileCheck2: text => {
|
|
60
|
+
const awaitsRead = /await\s+[\w.$]+\s*\.\s*read\s*\(/.test(text)
|
|
61
|
+
if (!awaitsRead) return true
|
|
62
|
+
return !(/\.events\b/.test(text) || /\{[^}]*\bevents\b[^}]*\}\s*=\s*await/.test(text) || /normalizeReadResult/.test(text))
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'S9',
|
|
67
|
+
title: 'SystemPrompt Config.persona 已改名',
|
|
68
|
+
severity: 'error',
|
|
69
|
+
action: '`persona` 改为 `personaPrefix` / `personaSuffix`(`includeHarnessIdentity: false` 不等价,会删 harness identity 段)。',
|
|
70
|
+
test: /\{\s*persona\s*:|\bpersona\s*:\s*['"`]/,
|
|
71
|
+
// `persona:` is only the SystemPrompt seam when the call site is a SystemPrompt mount.
|
|
72
|
+
windowFilter: (lines, i) => lines.slice(Math.max(0, i - 3), i + 1).join('\n').includes('SystemPrompt'),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'M1',
|
|
76
|
+
title: 'tsconfig 的 checkout 路径解析失败 → typecheck 静默回退',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
action: 'tsconfig `paths` 指向不存在的目录时 TypeScript 会静默回退到 node_modules 的已发布类型,本地门禁变成假绿。路径应为 `../../../../deepseek-harness/packages/...`(相对仓库根)。',
|
|
79
|
+
test: /__tsconfig_paths_probe__/,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'S4',
|
|
83
|
+
title: 'tool/code-dispatch 已改名 tool/ptc-dispatch',
|
|
84
|
+
severity: 'error',
|
|
85
|
+
action: '事件名改为 `tool/ptc-dispatch`;旧标签在 V3 会话里不再被识别(保留双标签需归一化器)。',
|
|
86
|
+
test: /tool\/code-dispatch/,
|
|
87
|
+
// A file that also names `tool/ptc-dispatch` is handling the legacy alias on purpose.
|
|
88
|
+
downgradeIf: text => /tool\/ptc-dispatch/.test(text),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'S5',
|
|
92
|
+
title: 'ctx.agent 已移除',
|
|
93
|
+
severity: 'error',
|
|
94
|
+
action: '调用方必须显式传 Agent(如 `setup(agentCtx, agent)` 的第 2 参),不要读 `ctx.agent`。',
|
|
95
|
+
test: /ctx\.agent\b|ctx\['agent'\]|ctx\.get\(\s*['"]agent['"]\s*\)/,
|
|
96
|
+
// Deliberate back-compat fallbacks are legitimate while the old peer band is supported.
|
|
97
|
+
downgradeIf: text => /legacy|0\.1\.4|backward|back-compat|compat/i.test(text),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'S6',
|
|
101
|
+
title: 'Inbox 已改为类型接口',
|
|
102
|
+
severity: 'error',
|
|
103
|
+
action: '`Inbox` 不再可构造;夹具改用官方 unsupportedInbox() 形状,运行时用 `agent.inbox`。',
|
|
104
|
+
test: /\bnew\s+Inbox\s*\(|import\s*\{[^}]*\bInbox\b[^}]*\}\s*from\s*['"]@deepseek-ai\/dsh-agent['"]/,
|
|
105
|
+
// A harness pinned to a pre-0.1.5 ruler may still construct Inbox on purpose.
|
|
106
|
+
downgradeIf: text => /0\.1\.3|0\.1\.2|pinned|legacy/i.test(text),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'S7',
|
|
110
|
+
title: 'SubprocessHandle.pid 已移除',
|
|
111
|
+
severity: 'warn',
|
|
112
|
+
action: 'handle 不再有 `pid`(只剩 SubprocessTerminalHandle.pid);测试夹具删掉该字段。',
|
|
113
|
+
test: /\bpid\s*:\s*\d+|\.pid\b/,
|
|
114
|
+
// Node ChildProcess.pid and plugin-owned process info also match: keep this advisory.
|
|
115
|
+
downgradeIf: () => true,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'S2',
|
|
119
|
+
title: 'EpochHeader.system 已移除(系统提示词进消息历史)',
|
|
120
|
+
severity: 'warn',
|
|
121
|
+
action: '系统提示词现在是 surface node 0 的 `system/message`;`request/header.system` 只在旧线存在,读取方需要结构式回退。',
|
|
122
|
+
test: /header\.system\b|EpochHeader/,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'S1',
|
|
126
|
+
title: '会话格式 V3 / 日志文件名世代化',
|
|
127
|
+
severity: 'warn',
|
|
128
|
+
action: '当前世代是 `session.v3.jsonl.zstd`;硬编码 `session.jsonl.zstd` 的读写脚本会静默失效(建议从新到旧枚举 vN)。',
|
|
129
|
+
test: /SESSION_FORMAT_VERSION|session\.jsonl(?!\.v\d)/,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'S10',
|
|
133
|
+
title: '插件自建会话事件必须走自适应门',
|
|
134
|
+
severity: 'warn',
|
|
135
|
+
action: '宿主事件词表 fail-closed 且 `Session.append` 无 `ignorable` 写入通道;无条件 append 会让会话不可读,请保留"探测后降级"的写法。',
|
|
136
|
+
test: /SessionEventMap|\.append\(/,
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
function* walk(dir, depth = 0) {
|
|
141
|
+
if (depth > 8) return
|
|
142
|
+
let ents
|
|
143
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
144
|
+
for (const e of ents) {
|
|
145
|
+
if (e.isDirectory()) {
|
|
146
|
+
if (SKIP_DIRS.has(e.name)) continue
|
|
147
|
+
yield* walk(path.join(dir, e.name), depth + 1)
|
|
148
|
+
} else if (SCAN_EXT.test(e.name)) {
|
|
149
|
+
yield path.join(dir, e.name)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Strip comment-only lines so prose never satisfies a code-level check. */
|
|
155
|
+
function stripComments(text) {
|
|
156
|
+
return text.split(/\r?\n/).filter(l => !/^\s*(?:\/\/|\/\*|\*|#)/.test(l)).join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** M1: resolve every `paths` entry; a missing target means silent fallback. */
|
|
160
|
+
function checkTsconfigPaths(repoDir, imports) {
|
|
161
|
+
const hits = []
|
|
162
|
+
const files = fs.readdirSync(repoDir).filter(f => /^tsconfig.*\.json$/.test(f))
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
const full = path.join(repoDir, f)
|
|
165
|
+
let json
|
|
166
|
+
try { json = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { continue }
|
|
167
|
+
const opts = json.compilerOptions || {}
|
|
168
|
+
const paths = opts.paths
|
|
169
|
+
if (!paths || typeof paths !== 'object') continue
|
|
170
|
+
const base = path.resolve(repoDir, opts.baseUrl || '.')
|
|
171
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
172
|
+
if (!Array.isArray(targets)) continue
|
|
173
|
+
for (const t of targets) {
|
|
174
|
+
if (typeof t !== 'string') continue
|
|
175
|
+
// Only checkout-style aliases are in scope: they must point at the harness tree.
|
|
176
|
+
const looksCheckout = /(?:^|\/)(?:packages|vendor)\//.test(t) || t.includes('deepseek-harness')
|
|
177
|
+
if (!looksCheckout) continue
|
|
178
|
+
// An alias the repo never imports cannot cause a false green: skip it.
|
|
179
|
+
const aliasBase = alias.replace(/\/\*$/, '')
|
|
180
|
+
if (imports && imports.size && !imports.has(aliasBase)) continue
|
|
181
|
+
// Wildcard aliases (`.../lib/types/*`) are checked at their static prefix.
|
|
182
|
+
const probe = t.includes('*') ? t.slice(0, t.indexOf('*')) : t
|
|
183
|
+
const resolved = path.resolve(base, probe)
|
|
184
|
+
if (!fs.existsSync(resolved)) {
|
|
185
|
+
hits.push({
|
|
186
|
+
seam: 'M1', severity: 'error', file: path.join(repoDir, f), line: 1,
|
|
187
|
+
snippet: `"${alias}": ["${t}"]`,
|
|
188
|
+
detail: `resolves to ${resolved} which does not exist → TypeScript silently falls back to node_modules (green gate is fake)`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return hits
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Scan one repo.
|
|
199
|
+
* @param {string} repoDir
|
|
200
|
+
* @param {{ seams?: string[] }} [options]
|
|
201
|
+
* @returns {{ repo: string, scannedAt: string, files: number, hits: any[], bySeam: Record<string, number> }}
|
|
202
|
+
*/
|
|
203
|
+
export function scanRepo(repoDir, options = {}) {
|
|
204
|
+
const wanted = options.seams && options.seams.length ? new Set(options.seams) : null
|
|
205
|
+
const hits = []
|
|
206
|
+
const imports = new Set()
|
|
207
|
+
let files = 0
|
|
208
|
+
for (const file of walk(repoDir)) {
|
|
209
|
+
files++
|
|
210
|
+
let text
|
|
211
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { continue }
|
|
212
|
+
for (const m of text.matchAll(/(?:from|require\()\s*['"](@deepseek-ai\/[^'"]+)['"]/g)) imports.add(m[1])
|
|
213
|
+
const lines = text.split(/\r?\n/)
|
|
214
|
+
for (const seam of SEAMS) {
|
|
215
|
+
if (wanted && !wanted.has(seam.id)) continue
|
|
216
|
+
if (seam.id === 'M1') continue // handled separately (structured, not regex)
|
|
217
|
+
if (seam.fileCheck && !seam.fileCheck(text, file)) continue
|
|
218
|
+
if (seam.fileCheck2 && !seam.fileCheck2(text, file)) continue
|
|
219
|
+
for (let i = 0; i < lines.length; i++) {
|
|
220
|
+
const trimmed = lines[i].trim()
|
|
221
|
+
// Comment-only lines carry prose, not code: never a seam hit.
|
|
222
|
+
if (/^(?:\/\/|\/\*|\*|#)/.test(trimmed)) continue
|
|
223
|
+
if (!seam.test.test(lines[i])) continue
|
|
224
|
+
if (seam.lineFilter && !seam.lineFilter(lines[i])) continue
|
|
225
|
+
if (seam.windowFilter && !seam.windowFilter(lines, i)) continue
|
|
226
|
+
// S7 is noisy: only report when the file is about subprocess handles.
|
|
227
|
+
if (seam.id === 'S7' && !/Subprocess|subprocess/.test(text)) continue
|
|
228
|
+
// S6 only for value imports / constructor calls, never type-only.
|
|
229
|
+
if (seam.id === 'S6' && /import\s+type\s*\{/.test(lines[i])) continue
|
|
230
|
+
// S9 only in a SystemPrompt-ish context or a test harness.
|
|
231
|
+
if (seam.id === 'S9' && !/SystemPrompt|system-prompt|systemPrompt/.test(text)) continue
|
|
232
|
+
const downgraded = seam.downgradeIf ? seam.downgradeIf(text) : false
|
|
233
|
+
hits.push({
|
|
234
|
+
seam: seam.id, severity: downgraded ? 'warn' : seam.severity, file, line: i + 1,
|
|
235
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
236
|
+
detail: downgraded ? `${seam.title} (legacy alias handling — verify it is intentional)` : seam.title,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!wanted || wanted.has('M1')) hits.push(...checkTsconfigPaths(repoDir, imports))
|
|
242
|
+
const bySeam = {}
|
|
243
|
+
for (const h of hits) bySeam[h.seam] = (bySeam[h.seam] || 0) + 1
|
|
244
|
+
return { repo: repoDir, scannedAt: new Date().toISOString(), files, hits, bySeam }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Human-readable rendering. */
|
|
248
|
+
export function render(report) {
|
|
249
|
+
const L = []
|
|
250
|
+
L.push(`# scan-0.1.5 · ${report.repo}`)
|
|
251
|
+
L.push(`files scanned: ${report.files} · hits: ${report.hits.length}`)
|
|
252
|
+
if (!report.hits.length) { L.push('no seam hits — still verify with real-host smoke (this scanner is necessary, not sufficient)'); return L.join('\n') }
|
|
253
|
+
const order = ['S3', 'S8', 'S9', 'M1', 'S4', 'S5', 'S6', 'S7', 'S2', 'S1', 'S10']
|
|
254
|
+
for (const id of order) {
|
|
255
|
+
const group = report.hits.filter(h => h.seam === id)
|
|
256
|
+
if (!group.length) continue
|
|
257
|
+
const seam = SEAMS.find(s => s.id === id)
|
|
258
|
+
L.push('')
|
|
259
|
+
L.push(`## ${id} [${seam.severity}] ${seam.title} — ${group.length} hit(s)`)
|
|
260
|
+
L.push(` action: ${seam.action}`)
|
|
261
|
+
for (const h of group.slice(0, 12)) L.push(` ${path.relative(process.cwd(), h.file)}:${h.line} ${h.snippet}`)
|
|
262
|
+
if (group.length > 12) L.push(` ... ${group.length - 12} more`)
|
|
263
|
+
}
|
|
264
|
+
return L.join('\n')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function main(argv) {
|
|
268
|
+
const args = { repo: process.cwd(), json: null, seams: null, quiet: false }
|
|
269
|
+
for (let i = 0; i < argv.length; i++) {
|
|
270
|
+
const a = argv[i]
|
|
271
|
+
if (a === '--repo') args.repo = argv[++i]
|
|
272
|
+
else if (a === '--json') args.json = argv[++i]
|
|
273
|
+
else if (a === '--seams') args.seams = String(argv[++i]).split(',').map(s => s.trim()).filter(Boolean)
|
|
274
|
+
else if (a === '--quiet') args.quiet = true
|
|
275
|
+
else if (a === '--help' || a === '-h') { console.log('usage: node scan-0.1.5.mjs [--repo <path>] [--json <out.json>] [--seams S3,S8,M1] [--quiet]'); return 0 }
|
|
276
|
+
else { console.error(`unknown argument: ${a}`); return 2 }
|
|
277
|
+
}
|
|
278
|
+
const repoDir = path.resolve(args.repo)
|
|
279
|
+
if (!fs.existsSync(repoDir)) { console.error(`repo not found: ${repoDir}`); return 2 }
|
|
280
|
+
const report = scanRepo(repoDir, { seams: args.seams })
|
|
281
|
+
if (!args.quiet) console.log(render(report))
|
|
282
|
+
if (args.json) fs.writeFileSync(path.resolve(args.json), JSON.stringify(report, null, 1), 'utf8')
|
|
283
|
+
return report.hits.some(h => h.severity === 'error') ? 1 : 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (process.argv[1]?.endsWith('scan.mjs') || process.argv[1]?.endsWith('scan-0.1.5.mjs')) {
|
|
287
|
+
process.exit(main(process.argv.slice(2)))
|
|
288
|
+
}
|