dsh-rule-engine 0.6.3 → 0.6.5
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/README.md +458 -425
- package/lib/core/authorization.js +12 -4
- package/lib/core/guard-core.js +55 -18
- package/lib/core/intent.js +1 -1
- package/lib/core/llm-intent.js +13 -5
- package/lib/core/matcher.js +19 -16
- package/lib/core/measure-kinds.js +127 -0
- package/lib/core/patterns.js +51 -8
- package/lib/core/state.js +12 -2
- package/lib/core/text-detect.js +20 -2
- package/lib/core/tool-catalog.js +16 -3
- package/lib/core/understander.js +23 -34
- package/lib/index.js +127 -19
- package/lib/messages.js +5 -0
- package/lib/service.js +43 -0
- package/package.json +4 -10
- package/scripts/audit-mount-consistency.mjs +0 -198
- package/scripts/build.sh +0 -13
- package/scripts/check-real.mjs +0 -24
- package/scripts/check-tool-coverage.mjs +0 -65
- package/scripts/dualtrack-check.mjs +0 -335
- package/scripts/dualtrack-whitelist.json +0 -7
- package/scripts/health-audit.mjs +0 -66
- package/scripts/lib/pnpm-exempt.mjs +0 -56
- package/scripts/local-residue-scan.mjs +0 -46
- package/scripts/plugins.json +0 -10
- package/scripts/probe-home.mjs +0 -3
- package/scripts/probe-intent.mjs +0 -14
- package/scripts/publish-aptitude-check.mjs +0 -102
- package/scripts/readme-version-check.mjs +0 -41
- package/scripts/release-plugin.mjs +0 -401
- package/scripts/rules-health.mjs +0 -55
- package/scripts/verify-all.mjs +0 -219
- package/scripts/verify-guard-live.mjs +0 -30
- package/scripts/verify-v474.mjs +0 -14
- package/upgrade-impact.json +0 -55
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
// FULL mount-consistency audit: bundles vs user patch inserts vs bundle-internal patches vs dependencies
|
|
2
|
-
// Goal: detect ANY loader entry id that would be mounted more than once (duplicate loader entry crash)
|
|
3
|
-
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
|
-
import { join, dirname } from 'node:path'
|
|
6
|
-
|
|
7
|
-
const DSH_HOME = process.env.DSH_HOME || 'D:/example workspace/.dsh'
|
|
8
|
-
const DEFAULT_PROFILE = 'web'
|
|
9
|
-
function profileNameFromArgs() {
|
|
10
|
-
const argv = process.argv
|
|
11
|
-
let idx = argv.indexOf('--profile')
|
|
12
|
-
if (idx >= 0 && argv[idx + 1]) return argv[idx + 1]
|
|
13
|
-
const eq = argv.find((a) => a.startsWith('--profile='))
|
|
14
|
-
if (eq) return eq.slice('--profile='.length)
|
|
15
|
-
return DEFAULT_PROFILE
|
|
16
|
-
}
|
|
17
|
-
const PROFILE_NAME = profileNameFromArgs()
|
|
18
|
-
const PROFILE = join(DSH_HOME, 'profiles', PROFILE_NAME)
|
|
19
|
-
console.log(`Profile: ${PROFILE_NAME} -> ${PROFILE}`)
|
|
20
|
-
if (!existsSync(join(PROFILE, 'package.json'))) {
|
|
21
|
-
console.error(`[ERROR] profile package.json not found: ${join(PROFILE, 'package.json')}`)
|
|
22
|
-
process.exit(2)
|
|
23
|
-
}
|
|
24
|
-
const profilePkg = JSON.parse(readFileSync(join(PROFILE, 'package.json'), 'utf8'))
|
|
25
|
-
const requireFromProfile = createRequire(join(PROFILE, 'package.json'))
|
|
26
|
-
|
|
27
|
-
/** 从 patch 文本提取 insert 块中的 loader entry id(支持块状和行内两种写法) */
|
|
28
|
-
function extractInsertIds(text) {
|
|
29
|
-
const ids = []
|
|
30
|
-
const lines = String(text || '').split('\n')
|
|
31
|
-
for (let i = 0; i < lines.length; i++) {
|
|
32
|
-
const line = lines[i]
|
|
33
|
-
if (!/^-\s*insert:/i.test(line)) continue
|
|
34
|
-
const rest = line.replace(/^-\s*insert:\s*/i, '')
|
|
35
|
-
if (rest.trim()) {
|
|
36
|
-
// 行内形式:- insert: { id: xxx } / - insert: - id: xxx
|
|
37
|
-
for (const m of rest.matchAll(/id:\s*["']?([^\s#"']+)/gi)) ids.push(m[1])
|
|
38
|
-
continue
|
|
39
|
-
}
|
|
40
|
-
// 块状形式:- insert: 后跟缩进的 - id: xxx
|
|
41
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
42
|
-
const l = lines[j]
|
|
43
|
-
if (/^\S/.test(l) && !/^\s/.test(l)) break // 回到顶层条目
|
|
44
|
-
const m = l.match(/^\s+- id:\s*["']?([^\s#"']+)/i)
|
|
45
|
-
if (m) ids.push(m[1])
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return [...new Set(ids)]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 解析 bundle 包路径:优先 profile/node_modules,再 profiles/node_modules,最后用 Node 解析 */
|
|
52
|
-
function resolveBundlePkg(b) {
|
|
53
|
-
const candidates = [
|
|
54
|
-
join(PROFILE, 'node_modules', b, 'package.json'),
|
|
55
|
-
join(DSH_HOME, 'profiles', 'node_modules', b, 'package.json')
|
|
56
|
-
]
|
|
57
|
-
if (b.startsWith('@')) {
|
|
58
|
-
const [scope, name] = b.split('/')
|
|
59
|
-
candidates.push(join(DSH_HOME, 'profiles', 'node_modules', scope, name, 'package.json'))
|
|
60
|
-
}
|
|
61
|
-
for (const c of candidates) if (existsSync(c)) return c
|
|
62
|
-
try {
|
|
63
|
-
return requireFromProfile.resolve(`${b}/package.json`)
|
|
64
|
-
} catch {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 读取一个 patch 文件并把其中的 insert id 记入 sources */
|
|
70
|
-
function collectPatchInserts(patchPath, viaLabel) {
|
|
71
|
-
if (!existsSync(patchPath)) return []
|
|
72
|
-
const ids = extractInsertIds(readFileSync(patchPath, 'utf8'))
|
|
73
|
-
for (const id of ids) sources.push({ id, via: viaLabel, file: patchPath })
|
|
74
|
-
return ids
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ---- collect all sources of loader entry ids ----
|
|
78
|
-
const sources = [] // {id, via, file}
|
|
79
|
-
|
|
80
|
-
// 1. profile bundles array (each bundle's internal cordis.patch.yml inserts + the bundle row itself)
|
|
81
|
-
const bundles = profilePkg.dsh?.profile?.bundles || []
|
|
82
|
-
console.log('=== 1. profile bundles array (' + bundles.length + ') ===')
|
|
83
|
-
for (const b of bundles) {
|
|
84
|
-
console.log(' bundle:', b)
|
|
85
|
-
const pkgPath = resolveBundlePkg(b)
|
|
86
|
-
if (!pkgPath) { console.log(' [WARN] package.json not found for ' + b); continue }
|
|
87
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
88
|
-
const patchRel = pkg.dsh?.bundle?.patch
|
|
89
|
-
if (patchRel) {
|
|
90
|
-
const patchPath = join(dirname(pkgPath), patchRel)
|
|
91
|
-
const inserts = collectPatchInserts(patchPath, `bundle-internal patch of ${b}`)
|
|
92
|
-
if (existsSync(patchPath)) {
|
|
93
|
-
console.log(` internal patch ${patchRel}: inserts = ${inserts.length ? inserts.join(',') : '(none)'}`)
|
|
94
|
-
} else {
|
|
95
|
-
console.log(' [WARN] bundle patch missing: ' + patchPath)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// the bundle package itself may be a plugin row too (via loader) — record its name as entry candidate
|
|
99
|
-
// (bundle packages themselves are not loader entries; only their patch rows are)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 2. user cordis.patch.yml inserts (profile layer)
|
|
103
|
-
const userPatchPath = join(PROFILE, 'cordis.patch.yml')
|
|
104
|
-
const userPatch = readFileSync(userPatchPath, 'utf8')
|
|
105
|
-
console.log('\n=== 2. user cordis.patch.yml (profile layer) ===')
|
|
106
|
-
const userInserts = collectPatchInserts(userPatchPath, 'user patch insert')
|
|
107
|
-
// also non-insert top-level entries (override/disable by id) — these do NOT create entries but list them
|
|
108
|
-
const topLevelIds = [...userPatch.matchAll(/^-\s+id:\s+([^\s#]+)/gm)].map((m) => m[1])
|
|
109
|
-
console.log(' user patch top-level ids:', topLevelIds.join(',') || '(none)')
|
|
110
|
-
console.log(' user patch inserts:', userInserts.join(',') || '(none)')
|
|
111
|
-
|
|
112
|
-
// 2.5 root user cordis.patch.yml inserts (if present)
|
|
113
|
-
const rootPatchPath = join(DSH_HOME, 'cordis.patch.yml')
|
|
114
|
-
const rootInserts = []
|
|
115
|
-
if (existsSync(rootPatchPath)) {
|
|
116
|
-
console.log('\n=== 2.5 root user cordis.patch.yml ===')
|
|
117
|
-
rootInserts.push(...collectPatchInserts(rootPatchPath, 'root user patch insert'))
|
|
118
|
-
console.log(' root user patch inserts:', rootInserts.join(',') || '(none)')
|
|
119
|
-
} else {
|
|
120
|
-
console.log('\n=== 2.5 root user cordis.patch.yml ===')
|
|
121
|
-
console.log(' (no root cordis.patch.yml)')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 3. dependencies with link/github/http (local dev packages) — check their type
|
|
125
|
-
console.log('\n=== 3. dependencies type check ===')
|
|
126
|
-
const deps = profilePkg.dependencies || {}
|
|
127
|
-
for (const [name, spec] of Object.entries(deps)) {
|
|
128
|
-
const isLocal = typeof spec === 'string' && (spec.startsWith('link:') || spec.startsWith('github:') || spec.startsWith('http') || spec.startsWith('file:'))
|
|
129
|
-
console.log(` ${name}: ${spec} ${isLocal ? '(LOCAL/dev)' : ''}`)
|
|
130
|
-
if (isLocal) {
|
|
131
|
-
// resolve local dir and check dsh type
|
|
132
|
-
let dir = null
|
|
133
|
-
if (spec.startsWith('link:')) dir = spec.slice(5)
|
|
134
|
-
else if (spec.startsWith('file:')) dir = spec.slice(5)
|
|
135
|
-
else if (spec.startsWith('github:') || spec.startsWith('http')) dir = join(PROFILE, 'node_modules', name)
|
|
136
|
-
if (dir) {
|
|
137
|
-
const lp = join(dir.replace(/\\/g, '/'), 'package.json')
|
|
138
|
-
if (existsSync(lp)) {
|
|
139
|
-
const lpj = JSON.parse(readFileSync(lp, 'utf8'))
|
|
140
|
-
const dshType = lpj.dsh?.bundle ? 'dsh.bundle' : (lpj.dsh?.plugin ? 'dsh.plugin' : (lpj.dsh ? JSON.stringify(lpj.dsh) : 'no dsh field'))
|
|
141
|
-
const inBundles = bundles.includes(name)
|
|
142
|
-
const inUserPatch = userInserts.includes(name) || rootInserts.includes(name) || topLevelIds.includes(name)
|
|
143
|
-
console.log(` -> type: ${dshType} | inBundles: ${inBundles} | inUserPatch: ${inUserPatch}`)
|
|
144
|
-
// rule 24: dsh.plugin must NOT be in bundles; dsh.bundle SHOULD be in bundles not in user patch insert
|
|
145
|
-
if (dshType === 'dsh.plugin' && inBundles) {
|
|
146
|
-
console.log(' [VIOLATION rule24] dsh.plugin in bundles!')
|
|
147
|
-
}
|
|
148
|
-
if (dshType === 'dsh.bundle' && inUserPatch) {
|
|
149
|
-
console.log(' [WARN] dsh.bundle also present in user patch — check duplicate')
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// 3.5 runtime injection registry (example-injector) — also produces loader entries
|
|
157
|
-
console.log('\n=== 3.5 runtime injection registry ===')
|
|
158
|
-
const superInjectorRegistry = join(DSH_HOME, 'example-injector', 'registry.json')
|
|
159
|
-
try {
|
|
160
|
-
if (existsSync(superInjectorRegistry)) {
|
|
161
|
-
const reg = JSON.parse(readFileSync(superInjectorRegistry, 'utf8'))
|
|
162
|
-
if (!Array.isArray(reg)) throw new Error('registry.json is not an array')
|
|
163
|
-
const names = reg.map((e) => e && e.name).filter(Boolean)
|
|
164
|
-
for (const name of names) {
|
|
165
|
-
sources.push({ id: name, via: 'runtime injection (example-injector registry)', file: superInjectorRegistry })
|
|
166
|
-
}
|
|
167
|
-
console.log(' runtime injected:', names.join(', ') || '(none)')
|
|
168
|
-
} else {
|
|
169
|
-
console.log(' (no example-injector registry found; if dev_inject_plugin has been used, run dev_injected_list to confirm)')
|
|
170
|
-
}
|
|
171
|
-
} catch (e) {
|
|
172
|
-
console.log(' [WARN] failed to read example-injector registry:', e instanceof Error ? e.message : String(e))
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 4. duplicate detection across ALL sources
|
|
176
|
-
console.log('\n=== 4. DUPLICATE LOADER ENTRY DETECTION ===')
|
|
177
|
-
const byId = new Map()
|
|
178
|
-
for (const s of sources) {
|
|
179
|
-
if (!byId.has(s.id)) byId.set(s.id, [])
|
|
180
|
-
byId.get(s.id).push(s)
|
|
181
|
-
}
|
|
182
|
-
let dupFound = false
|
|
183
|
-
for (const [id, list] of byId) {
|
|
184
|
-
if (list.length > 1) {
|
|
185
|
-
dupFound = true
|
|
186
|
-
console.log(` [DUPLICATE] id "${id}" mounted ${list.length} times:`)
|
|
187
|
-
for (const s of list) console.log(` - via ${s.via} (${s.file})`)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
if (!dupFound) console.log(' NO duplicate loader entry ids found — clean')
|
|
191
|
-
|
|
192
|
-
// 5. also check bundle rows vs their internal patch first insert (self-reference is fine)
|
|
193
|
-
console.log('\n=== 5. summary ===')
|
|
194
|
-
console.log(' bundles:', bundles.join(', '))
|
|
195
|
-
console.log(' user inserts:', userInserts.join(',') || '(none)')
|
|
196
|
-
console.log(' root user inserts:', rootInserts.join(',') || '(none)')
|
|
197
|
-
console.log(dupFound ? ' RESULT: DUPLICATES FOUND — MUST FIX BEFORE RESTART' : ' RESULT: MOUNT CONSISTENT — SAFE TO RESTART')
|
|
198
|
-
process.exit(dupFound ? 1 : 0)
|
package/scripts/build.sh
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
cd "$(dirname "$0")/.."
|
|
4
|
-
node --check lib/index.js
|
|
5
|
-
node --check lib/core/parser.js
|
|
6
|
-
node --check lib/core/understander.js
|
|
7
|
-
node --check lib/core/patterns.js
|
|
8
|
-
node --check lib/core/state.js
|
|
9
|
-
node --check lib/core/audit.js
|
|
10
|
-
node --check lib/core/guard-core.js
|
|
11
|
-
node --check lib/core/text-detect.js
|
|
12
|
-
node --check lib/core/matcher.js
|
|
13
|
-
echo "build check ok"
|
package/scripts/check-real.mjs
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// check-real.mjs - 只读校验:用真实 ~/.dsh/AGENTS.md 跑解析+理解,不写任何文件。
|
|
2
|
-
import { loadRules } from "../lib/core/parser.js";
|
|
3
|
-
import { understandAll } from "../lib/core/understander.js";
|
|
4
|
-
|
|
5
|
-
const parsed = loadRules();
|
|
6
|
-
if (!parsed.ok) {
|
|
7
|
-
console.error(JSON.stringify({ ok: false, error: parsed.error }, null, 2));
|
|
8
|
-
process.exit(1);
|
|
9
|
-
}
|
|
10
|
-
const configs = understandAll(parsed.rules);
|
|
11
|
-
const summary = {
|
|
12
|
-
ok: true,
|
|
13
|
-
ruleCount: parsed.rules.length,
|
|
14
|
-
sections: [...new Set(parsed.rules.map((r) => r.section))],
|
|
15
|
-
confidence: configs.reduce((acc, c) => {
|
|
16
|
-
acc[c.confidence] = (acc[c.confidence] || 0) + 1;
|
|
17
|
-
return acc;
|
|
18
|
-
}, {}),
|
|
19
|
-
actions: configs.reduce((acc, c) => {
|
|
20
|
-
for (const a of c.actions) acc[a] = (acc[a] || 0) + 1;
|
|
21
|
-
return acc;
|
|
22
|
-
}, {})
|
|
23
|
-
};
|
|
24
|
-
console.log(JSON.stringify(summary, null, 2));
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// check-tool-coverage.mjs — 0.5.9 工具覆盖门禁(K-01/K-06 依据,仿官方 verify-tool-catalog):
|
|
2
|
-
// 官方 tool-catalog(生成器产物=权威全集)中的每个工具名都必须在分类表/前缀规则内被识别,
|
|
3
|
-
// 任何一个 unknown = 该工具调用会被未知工具首调处置(ask/deny)→ 工程失败。
|
|
4
|
-
// 0.6.0(02 B2′ 裁决 v1.4,2026-09-04 第三方裁示+用户确认):
|
|
5
|
-
// - 素材来源优先级:--catalog <path> → CHECK_TOOL_CATALOG 环境变量 → 均缺 = FAIL(诊断含获取方式,禁 ENOENT 死路径);
|
|
6
|
-
// - --skip-catalog 显式出口:输出 WARNING 并计入汇总(发布流水线永远不传该参数);
|
|
7
|
-
// - 素材版本须与本机 DSH 版本对齐(不一致 WARNING 不 FAIL);
|
|
8
|
-
// - 校验逻辑与判据不变(官方全集 ⊆ 分类表,任一 unknown 即红)。
|
|
9
|
-
// 用法:node scripts/check-tool-coverage.mjs [--catalog <path>] [--skip-catalog]
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { toolClass } from "../lib/core/tool-catalog.js";
|
|
12
|
-
|
|
13
|
-
function argVal(name) {
|
|
14
|
-
const i = process.argv.indexOf(name);
|
|
15
|
-
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : null;
|
|
16
|
-
}
|
|
17
|
-
const SKIP_CATALOG = process.argv.includes("--skip-catalog");
|
|
18
|
-
const CATALOG = argVal("--catalog") || process.env.CHECK_TOOL_CATALOG || null;
|
|
19
|
-
|
|
20
|
-
if (!CATALOG) {
|
|
21
|
-
if (SKIP_CATALOG) {
|
|
22
|
-
console.warn("WARNING:--skip-catalog 已传——工具箱覆盖层跳过(素材缺失;发布流水线禁止此参数)");
|
|
23
|
-
process.exit(0);
|
|
24
|
-
}
|
|
25
|
-
console.error("TOOL-CATALOG-MISSING:未指定官方 tool-catalog.txt——请用 --catalog <path> 或环境变量 CHECK_TOOL_CATALOG 指定(官方文档产物可从 DSH 官方文档仓库 docs-site-text 获取);FAIL,禁止静默跳过");
|
|
26
|
-
if (process.env.LOCAL_CATALOG_HINT) {
|
|
27
|
-
console.error(`(本机提示:${process.env.LOCAL_CATALOG_HINT})`);
|
|
28
|
-
}
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function parseNames() {
|
|
33
|
-
const names = new Set();
|
|
34
|
-
for (const line of readFileSync(CATALOG, "utf8").split("\n")) {
|
|
35
|
-
if (!line.includes("@deepseek-ai/dsh-")) continue;
|
|
36
|
-
const cols = line.split("|").map((c) => c.trim());
|
|
37
|
-
// 表列格式:| | <package> | <model-visible names> | <requires> | <writes> | <shipped aliases> | <note> |
|
|
38
|
-
if (!cols[2] || !cols[2].startsWith("@deepseek-ai")) continue;
|
|
39
|
-
const add = (s) => {
|
|
40
|
-
if (!s || s === "-") return;
|
|
41
|
-
// 分隔符:英文文档用逗号「,」,中文文档用顿号「、」(0.6.3 修复:曾只按逗号分割,
|
|
42
|
-
// 致中文素材的多工具名整串被丢弃——59 个工具名只剩 11 个,且仍打印 COVERAGE-OK 的静默弱化)
|
|
43
|
-
for (const x of s.split(/[,、,]/)) {
|
|
44
|
-
const n = x.trim();
|
|
45
|
-
if (/^[A-Za-z_][A-Za-z0-9_:.-]*$/.test(n)) names.add(n);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
add(cols[3]);
|
|
49
|
-
add(cols[6]);
|
|
50
|
-
}
|
|
51
|
-
return names;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const official = parseNames();
|
|
55
|
-
const missing = [];
|
|
56
|
-
for (const n of official) {
|
|
57
|
-
if (toolClass(n, {}) === "unknown") missing.push(n);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (missing.length) {
|
|
61
|
-
console.error(`COVERAGE-FAIL:官方 tool-catalog 有 ${missing.length} 个工具未分类(会被未知工具首调处置):`);
|
|
62
|
-
for (const n of missing) console.error(" - " + n);
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
console.log(`COVERAGE-OK:官方 tool-catalog 工具全覆盖(${official.size} 个工具名均被分类表/前缀规则识别)`);
|
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
// dualtrack-check.mjs - 分层残留扫描闸(dualtrack-check)
|
|
2
|
-
//
|
|
3
|
-
// 依据:本机使用手册 SKILL.md:113「修改双轨制(2026-08-31 用户定稿)——
|
|
4
|
-
// 铁律=代码层零本机内容(发布门禁扫描红);机器校验(dualtrack-check)为验收兜底」。
|
|
5
|
-
// 本脚本是那句承诺的落地实现。
|
|
6
|
-
//
|
|
7
|
-
// 扫描范围(lib/**/*.js)——判据 A(2026-09-09 用户拍板,与方案 §一 一致):
|
|
8
|
-
// 闸只扫「**会随发布者/规则集/环境变化**」的内容,不扫通用中文文案。
|
|
9
|
-
// ① 本机标识:发布者私有词表(rule-engine.json 的 dualtrack.markers)命中的字符串
|
|
10
|
-
// ② 映射表键:对象字面量键名匹配 ^\d+[A-Z]?$ 或含 CJK(规则号索引=某人的规则体系)
|
|
11
|
-
// —— 通用中文文案(字符串字面量内的 CJK)**不计入**:第三方判据明示「中文≠个人化,
|
|
12
|
-
// 通用功能词/提示语是产品能力」。runtime 计数仍在 --report 里显示,供参考。
|
|
13
|
-
//
|
|
14
|
-
// 棘轮(ratchet):基线记录各文件计数,只许降不许升。
|
|
15
|
-
// node scripts/dualtrack-check.mjs # 比对基线(CI/发布门禁用)
|
|
16
|
-
// node scripts/dualtrack-check.mjs --init # 首次生成基线
|
|
17
|
-
// node scripts/dualtrack-check.mjs --update # 手动更新基线(须在提交说明里写清改了什么)
|
|
18
|
-
// node scripts/dualtrack-check.mjs --report # 只打印各文件计数
|
|
19
|
-
//
|
|
20
|
-
// 白名单(scripts/dualtrack-whitelist.json):
|
|
21
|
-
// files —— 整文件豁免(如 lib/lang/**,第 3 批语言包)
|
|
22
|
-
// strings —— 通用功能词精确豁免(第三方 §一:中文≠个人化,许可词/时间词是产品能力)
|
|
23
|
-
import fs from "node:fs";
|
|
24
|
-
import os from "node:os";
|
|
25
|
-
import path from "node:path";
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
27
|
-
import { loadMarkers } from "../lib/core/dualtrack-markers.js";
|
|
28
|
-
|
|
29
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
-
const LIB = path.join(ROOT, "lib");
|
|
31
|
-
const BASELINE_FILE = path.join(ROOT, "scripts", "dualtrack-baseline.json");
|
|
32
|
-
const WHITELIST_FILE = path.join(ROOT, "scripts", "dualtrack-whitelist.json");
|
|
33
|
-
const RESIDUE_FILE = path.join(ROOT, "scripts", "local-residue-markers.txt");
|
|
34
|
-
|
|
35
|
-
const CJK = /[\u4e00-\u9fff]/;
|
|
36
|
-
const MAP_KEY_RE = /^\d+[A-Z]?$/;
|
|
37
|
-
|
|
38
|
-
// ── 白名单 ──
|
|
39
|
-
/** 白名单两层(2026-09-09):包内通用白名单 + 本机 rule-engine.json 的 dualtrack.whitelist 合并。
|
|
40
|
-
* 包内那份随包发布(如 lib/lang/**);本机那份记录「我豁免我自己的某条」,不进包。 */
|
|
41
|
-
function loadWhitelist() {
|
|
42
|
-
const files = [];
|
|
43
|
-
const strings = new Set();
|
|
44
|
-
// ① 包内通用白名单
|
|
45
|
-
if (fs.existsSync(WHITELIST_FILE)) {
|
|
46
|
-
try {
|
|
47
|
-
const w = JSON.parse(fs.readFileSync(WHITELIST_FILE, "utf8"));
|
|
48
|
-
for (const f of w.files || []) files.push(f);
|
|
49
|
-
for (const s of w.strings || []) strings.add(s);
|
|
50
|
-
} catch (e) {
|
|
51
|
-
throw new Error(`白名单解析失败:${WHITELIST_FILE} — ${e.message}`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
// ② 本机白名单(rule-engine.json 的 dualtrack.whitelist)
|
|
55
|
-
try {
|
|
56
|
-
const p = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "rule-engine.json");
|
|
57
|
-
if (fs.existsSync(p)) {
|
|
58
|
-
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
59
|
-
const w = cfg?.dualtrack?.whitelist;
|
|
60
|
-
if (w && typeof w === "object") {
|
|
61
|
-
for (const f of w.files || []) files.push(f);
|
|
62
|
-
for (const s of w.strings || []) strings.add(s);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
} catch {
|
|
66
|
-
// 本机配置不可读 → 只用包内白名单(不阻断)
|
|
67
|
-
}
|
|
68
|
-
return { files, strings };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 整文件豁免匹配(支持 ** 与 * 通配,路径用 / 分隔,相对仓库根) */
|
|
72
|
-
function fileExempt(relPath, patterns) {
|
|
73
|
-
for (const p of patterns) {
|
|
74
|
-
const re = new RegExp("^" + p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*") + "$");
|
|
75
|
-
if (re.test(relPath)) return true;
|
|
76
|
-
}
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── 词法扫描:剔除注释,收集字符串字面量与对象键 ──
|
|
81
|
-
/**
|
|
82
|
-
* @returns {{strings: string[], mapKeys: string[]}}
|
|
83
|
-
* strings = 字符串字面量的**内容**(模板串按整段处理)
|
|
84
|
-
* mapKeys = 形如 "14": / '12E': / 14: 的对象键
|
|
85
|
-
*/
|
|
86
|
-
function tokenize(src) {
|
|
87
|
-
const strings = [];
|
|
88
|
-
const mapKeys = [];
|
|
89
|
-
let i = 0;
|
|
90
|
-
const n = src.length;
|
|
91
|
-
// 上一个有意义的 token 类型:用于判断 `/` 是正则还是除号(简化:只看是否可能在正则位置)
|
|
92
|
-
let prevSig = "";
|
|
93
|
-
while (i < n) {
|
|
94
|
-
const c = src[i];
|
|
95
|
-
// 行注释
|
|
96
|
-
if (c === "/" && src[i + 1] === "/") {
|
|
97
|
-
while (i < n && src[i] !== "\n") i++;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
// 块注释(含 JSDoc)
|
|
101
|
-
if (c === "/" && src[i + 1] === "*") {
|
|
102
|
-
i += 2;
|
|
103
|
-
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
104
|
-
i += 2;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
// 字符串 / 模板串
|
|
108
|
-
if (c === '"' || c === "'" || c === "`") {
|
|
109
|
-
const q = c;
|
|
110
|
-
const start = i;
|
|
111
|
-
i++;
|
|
112
|
-
let buf = "";
|
|
113
|
-
if (q === "`") {
|
|
114
|
-
// 模板串:整段作为一个字符串,但 `${...}` 内的表达式跳过(不计入内容,也不当键)
|
|
115
|
-
while (i < n) {
|
|
116
|
-
const ch = src[i];
|
|
117
|
-
if (ch === "\\") { buf += ch + (src[i + 1] || ""); i += 2; continue; }
|
|
118
|
-
if (ch === "$" && src[i + 1] === "{") {
|
|
119
|
-
let depth = 1;
|
|
120
|
-
i += 2;
|
|
121
|
-
while (i < n && depth > 0) {
|
|
122
|
-
const d = src[i];
|
|
123
|
-
if (d === "\\") { i += 2; continue; }
|
|
124
|
-
if (d === "{") depth++;
|
|
125
|
-
else if (d === "}") depth--;
|
|
126
|
-
else if (d === '"' || d === "'" || d === "`") {
|
|
127
|
-
const qq = d; i++;
|
|
128
|
-
while (i < n && src[i] !== qq) { if (src[i] === "\\") i++; i++; }
|
|
129
|
-
}
|
|
130
|
-
i++;
|
|
131
|
-
}
|
|
132
|
-
buf += "\u0000"; // 表达式占位(不含 CJK)
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (ch === "`") break;
|
|
136
|
-
buf += ch;
|
|
137
|
-
i++;
|
|
138
|
-
}
|
|
139
|
-
} else {
|
|
140
|
-
while (i < n && src[i] !== q) {
|
|
141
|
-
if (src[i] === "\\") { buf += src[i] + (src[i + 1] || ""); i += 2; continue; }
|
|
142
|
-
buf += src[i];
|
|
143
|
-
i++;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
i++; // 收尾引号
|
|
147
|
-
strings.push(buf);
|
|
148
|
-
// 对象键判定(2026-09-09 修正):必须「前面是 { 或 ,」且「后面(跳空白)是 :」
|
|
149
|
-
// —— 否则模板串分段与三元表达式会被误判成键(20 处误报的根因)。
|
|
150
|
-
let j = i;
|
|
151
|
-
while (j < n && /\s/.test(src[j])) j++;
|
|
152
|
-
if (src[j] === ":") {
|
|
153
|
-
let p = start - 1;
|
|
154
|
-
while (p >= 0 && /\s/.test(src[p])) p--;
|
|
155
|
-
if (src[p] === "{" || src[p] === ",") {
|
|
156
|
-
const plain = buf.replace(/\\(.)/g, "$1");
|
|
157
|
-
if (MAP_KEY_RE.test(plain) || CJK.test(plain)) mapKeys.push(plain);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
prevSig = "str";
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
// 正则字面量(跳过,不计入)
|
|
164
|
-
if (c === "/" && prevSig !== "ident" && prevSig !== ")" && prevSig !== "]") {
|
|
165
|
-
let j = i + 1;
|
|
166
|
-
let inClass = false;
|
|
167
|
-
let closed = false;
|
|
168
|
-
while (j < n) {
|
|
169
|
-
const ch = src[j];
|
|
170
|
-
if (ch === "\\") { j += 2; continue; }
|
|
171
|
-
if (ch === "\n") break;
|
|
172
|
-
if (ch === "[") inClass = true;
|
|
173
|
-
else if (ch === "]") inClass = false;
|
|
174
|
-
else if (ch === "/" && !inClass) { closed = true; break; }
|
|
175
|
-
j++;
|
|
176
|
-
}
|
|
177
|
-
if (closed) {
|
|
178
|
-
i = j + 1;
|
|
179
|
-
while (i < n && /[a-z]/i.test(src[i])) i++;
|
|
180
|
-
prevSig = "regex";
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
i++;
|
|
184
|
-
prevSig = "op";
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
if (/[A-Za-z_$]/.test(c)) {
|
|
188
|
-
let j = i;
|
|
189
|
-
while (j < n && /[A-Za-z0-9_$]/.test(src[j])) j++;
|
|
190
|
-
i = j;
|
|
191
|
-
prevSig = "ident";
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (c === ")") { prevSig = ")"; i++; continue; }
|
|
195
|
-
if (c === "]") { prevSig = "]"; i++; continue; }
|
|
196
|
-
if (!/\s/.test(c)) prevSig = "op";
|
|
197
|
-
i++;
|
|
198
|
-
}
|
|
199
|
-
return { strings, mapKeys };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── 单文件扫描 ──
|
|
203
|
-
function scanFile(absPath, relPath, whitelist, residueMarks) {
|
|
204
|
-
const src = fs.readFileSync(absPath, "utf8");
|
|
205
|
-
const { strings, mapKeys } = tokenize(src);
|
|
206
|
-
let runtime = 0;
|
|
207
|
-
const runtimeHits = [];
|
|
208
|
-
for (const s of strings) {
|
|
209
|
-
if (!CJK.test(s)) continue;
|
|
210
|
-
const plain = s.replace(/\\(.)/g, "$1").trim();
|
|
211
|
-
if (whitelist.strings.has(plain)) continue; // 通用功能词豁免
|
|
212
|
-
runtime++;
|
|
213
|
-
if (runtimeHits.length < 3) runtimeHits.push(plain.slice(0, 40));
|
|
214
|
-
}
|
|
215
|
-
const mapHits = mapKeys.filter((k) => MAP_KEY_RE.test(k) || CJK.test(k));
|
|
216
|
-
let local = 0;
|
|
217
|
-
const localHits = [];
|
|
218
|
-
for (const s of strings) {
|
|
219
|
-
for (const mark of residueMarks) {
|
|
220
|
-
if (mark && s.includes(mark)) {
|
|
221
|
-
local++;
|
|
222
|
-
if (localHits.length < 3) localHits.push(`${mark} ← ${s.slice(0, 40)}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return {
|
|
227
|
-
relPath,
|
|
228
|
-
runtime,
|
|
229
|
-
mapKeys: mapHits.length,
|
|
230
|
-
local,
|
|
231
|
-
// 判据 A:total 只计「本机性」两类;通用中文文案(runtime)不计入闸
|
|
232
|
-
total: mapHits.length + local,
|
|
233
|
-
runtimeHits,
|
|
234
|
-
mapHits: mapHits.slice(0, 5),
|
|
235
|
-
localHits
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function walk(dir, base = dir, out = []) {
|
|
240
|
-
for (const e of fs.readdirSync(dir)) {
|
|
241
|
-
const p = path.join(dir, e);
|
|
242
|
-
const st = fs.statSync(p);
|
|
243
|
-
if (st.isDirectory()) walk(p, base, out);
|
|
244
|
-
else if (/\.js$/.test(e)) out.push(path.relative(base, p).replace(/\\/g, "/"));
|
|
245
|
-
}
|
|
246
|
-
return out;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ── 主流程 ──
|
|
250
|
-
const args = new Set(process.argv.slice(2));
|
|
251
|
-
const whitelist = loadWhitelist();
|
|
252
|
-
// 本机标识词表:与 B2 扫描器共用唯一加载器(本机配置优先 → 环境变量 → 包内示例)
|
|
253
|
-
const { markers: residueMarks, source: markersSource } = loadMarkers({ root: ROOT });
|
|
254
|
-
if (residueMarks.length === 0) {
|
|
255
|
-
console.error("REFUSED: 本机标识词表为空——请在 rule-engine.json 配置 dualtrack.markers,或设置 DUALTRACK_MARKERS 环境变量");
|
|
256
|
-
process.exit(1);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const files = walk(LIB).filter((rel) => !fileExempt(rel, whitelist.files));
|
|
260
|
-
const results = files.map((rel) => scanFile(path.join(LIB, rel), rel, whitelist, residueMarks));
|
|
261
|
-
const counts = {};
|
|
262
|
-
for (const r of results) counts[`lib/${r.relPath}`] = r.total;
|
|
263
|
-
const grandTotal = results.reduce((a, r) => a + r.total, 0);
|
|
264
|
-
|
|
265
|
-
if (args.has("--report")) {
|
|
266
|
-
for (const r of results.sort((a, b) => b.total - a.total)) {
|
|
267
|
-
if (r.total === 0) continue;
|
|
268
|
-
console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)]`);
|
|
269
|
-
if (r.mapHits.length) console.log(` 命中键:${r.mapHits.join(" / ")}`);
|
|
270
|
-
}
|
|
271
|
-
console.log(`\nDUALTRACK REPORT:${results.length} 文件,合计 ${grandTotal}`);
|
|
272
|
-
process.exit(0);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (args.has("--init") || args.has("--update")) {
|
|
276
|
-
const isUpdate = args.has("--update");
|
|
277
|
-
if (isUpdate && !fs.existsSync(BASELINE_FILE)) {
|
|
278
|
-
console.error("REFUSED: --update 需要已存在的基线;首次请用 --init");
|
|
279
|
-
process.exit(1);
|
|
280
|
-
}
|
|
281
|
-
if (!isUpdate && fs.existsSync(BASELINE_FILE)) {
|
|
282
|
-
console.error(
|
|
283
|
-
`REFUSED: 基线已存在(${BASELINE_FILE})——--init 仅用于首次生成。\n` +
|
|
284
|
-
" --init 会把当前计数覆盖为基线(棘轮失效);确需更新请用 --update(须在提交说明里写清改了什么)"
|
|
285
|
-
);
|
|
286
|
-
process.exit(1);
|
|
287
|
-
}
|
|
288
|
-
const payload = {
|
|
289
|
-
generatedAt: new Date().toISOString(),
|
|
290
|
-
note: "dualtrack 棘轮基线:各文件「分层残留」计数,只许降不许升。--init 首次生成,--update 手动更新(须在提交说明里写清改了什么)。",
|
|
291
|
-
total: grandTotal,
|
|
292
|
-
files: counts
|
|
293
|
-
};
|
|
294
|
-
fs.writeFileSync(BASELINE_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
295
|
-
console.log(`${isUpdate ? "UPDATED" : "INITIALIZED"} ${BASELINE_FILE}`);
|
|
296
|
-
console.log(`基线合计:${grandTotal}(${Object.keys(counts).length} 个文件)`);
|
|
297
|
-
process.exit(0);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// 默认:比对基线
|
|
301
|
-
if (!fs.existsSync(BASELINE_FILE)) {
|
|
302
|
-
console.error(`FAIL 基线不存在:${BASELINE_FILE}\n 首次请运行:node scripts/dualtrack-check.mjs --init`);
|
|
303
|
-
process.exit(1);
|
|
304
|
-
}
|
|
305
|
-
const baseline = JSON.parse(fs.readFileSync(BASELINE_FILE, "utf8"));
|
|
306
|
-
const base = baseline.files || {};
|
|
307
|
-
const regressions = [];
|
|
308
|
-
const improvements = [];
|
|
309
|
-
for (const [file, count] of Object.entries(counts)) {
|
|
310
|
-
const b = base[file];
|
|
311
|
-
if (b === undefined) {
|
|
312
|
-
if (count > 0) regressions.push(`${file}: 新增文件含残留 ${count} 处(基线无此文件)`);
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (count > b) {
|
|
316
|
-
const r = results.find((x) => `lib/${x.relPath}` === file);
|
|
317
|
-
const detail = r ? [...r.runtimeHits, ...r.localHits].slice(0, 2).join(";") : "";
|
|
318
|
-
regressions.push(`${file}: ${count} > 基线 ${b}${detail ? `(如:${detail})` : ""}`);
|
|
319
|
-
} else if (count < b) {
|
|
320
|
-
improvements.push(`${file}: ${count} < 基线 ${b}`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
for (const [file, b] of Object.entries(base)) {
|
|
324
|
-
if (counts[file] === undefined && b > 0) improvements.push(`${file}: 文件已删除(基线 ${b})`);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (regressions.length > 0) {
|
|
328
|
-
console.error(`DUALTRACK FAIL:${regressions.length} 项超出基线(只许降不许升)`);
|
|
329
|
-
for (const r of regressions) console.error(` ✗ ${r}`);
|
|
330
|
-
console.error("\n 处置:把新增内容迁个人层/配置层,或(确属通用功能词)登记 scripts/dualtrack-whitelist.json");
|
|
331
|
-
process.exit(1);
|
|
332
|
-
}
|
|
333
|
-
console.log(`DUALTRACK OK:合计 ${grandTotal}(基线 ${baseline.total ?? "?"})${improvements.length ? `,${improvements.length} 个文件下降可 --update` : ""}`);
|
|
334
|
-
for (const im of improvements.slice(0, 5)) console.log(` ↓ ${im}`);
|
|
335
|
-
process.exit(0);
|