@kira905/dsh-diagnostics-extra 0.0.0-stage → 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/CHANGELOG.md +54 -0
- package/LICENSE +661 -0
- package/README.en.md +269 -0
- package/README.md +248 -2
- package/attachments-audit.mjs +333 -0
- package/attachments-fetch.mjs +507 -0
- package/dsh-why.mjs +499 -0
- package/lib/attach-refs.mjs +224 -0
- package/lib/dsh-home.mjs +95 -0
- package/lib/generated-docs.mjs +397 -0
- package/lib/powershell-exe.mjs +188 -0
- package/lib/redact.mjs +157 -0
- package/lib/session-file-name.mjs +160 -0
- package/owner-todo-scan.mjs +425 -0
- package/package.json +45 -4
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools\lib\generated-docs.mjs —— 「自动生成档」判据的**唯一权威源**(SCHEMA_VERSION = 1)
|
|
3
|
+
//
|
|
4
|
+
// 权威性(方案 §2.5 冲突裁决表):
|
|
5
|
+
// 凡「哪些档是自动生成档 · 某档有没有豁免资格 · 档级标记字面 · 插入点规则」**一律以本模块为准**。
|
|
6
|
+
// —— `tools\docs-index-gen.mjs` 的 AUTO_DIR_RULES 已改为 **import 本模块派生**(不再独立维护);
|
|
7
|
+
// —— `docs\规划-自动生成档判据清单-20260925.md` §3.1 表 = 本模块的**派生视图**(注 as-of);
|
|
8
|
+
// —— 三者不一致 ⇒ 以本模块为准;改本模块须同步 SCHEMA_VERSION。
|
|
9
|
+
//
|
|
10
|
+
// 依据(逐条落点):<DSH_ROOT>\docs\规划-自动生成档判据清单-20260925.md(v3c · 第五轮判「✅ 构成合格证」)
|
|
11
|
+
// §2.2 档级标记规范 + 插入点统一规则(N-1 索引口径)
|
|
12
|
+
// §2.3 N = 5 的理由(现有各族档头标识行全落在前 4 行内 + 留 1 行余量)
|
|
13
|
+
// §2.4 判据实现形态(字面量 + String.includes + 行首锚定;不给正则)
|
|
14
|
+
// §2.5 唯一权威源与冲突裁决(findFamily 匹配口径 M3-3 写死 · generator 字段口径 M-3 · 命名澄清)
|
|
15
|
+
// §3.1 家族登记表(15 族)· §3.2 反向锚点(watch / external 登记但不豁免)
|
|
16
|
+
// §4.3 豁免只认白名单(逃生舱 env DSH_CITES_EXEMPT=off)· §6.1-8 白名单支路本轮不实现(Non-goal)
|
|
17
|
+
//
|
|
18
|
+
// 纪律:本模块**纯常量 + 纯函数**(不读文件、不写文件、不碰开关、无副作用)
|
|
19
|
+
// ⇒ 可被 15 个生成器 / 检查器 / 对账脚本 / 单测夹具共用。收尾一律 process.exitCode(禁 process.exit)。
|
|
20
|
+
//
|
|
21
|
+
// [impl-2 扩展 · 2026-10-08 · 卡 `impl-2`]:新增 **2 个导出** —— `firstH1Index()` / `stampDocMark()`
|
|
22
|
+
// (供 15 个生成器在写盘前盖档级标记;纯函数、幂等、无副作用)。
|
|
23
|
+
// **家族表 / 标记字面 / 既有导出逐字未改** ⇒ 按上方口径 **SCHEMA_VERSION 保持 1**
|
|
24
|
+
// (口径 = 本文件顶部第 21 行:「改家族表或标记字面 = 必须 +1」,本次两者都没改)。
|
|
25
|
+
|
|
26
|
+
/** 权威源 schema 版本:**改家族表或标记字面 = 必须 +1**(报告里打印,供跨机一致性追溯) */
|
|
27
|
+
export const SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/** 档级标记的命名空间前缀(§2.2)——**判据只认这一段**,不依赖 `family=<id>` 的具体值 */
|
|
30
|
+
export const DOC_MARK_PREFIX = '<!-- dsh-generated-doc:auto';
|
|
31
|
+
|
|
32
|
+
/** 档级标记扫描窗口 N = 5(§2.2 位置口径 / §2.3 取值理由)——只扫档头前 N 行、且必须行首 */
|
|
33
|
+
export const DOC_MARK_SCAN_LINES = 5;
|
|
34
|
+
|
|
35
|
+
/** 档级标记完整字面(生成器写入用;`<family-id>` 取 §3.1 家族表的 id)
|
|
36
|
+
* 规范字面量:`<!-- dsh-generated-doc:auto family=<family-id> -->`(一字不改) */
|
|
37
|
+
export const DOC_MARK_TEMPLATE = '<!-- dsh-generated-doc:auto family=<family-id> -->';
|
|
38
|
+
|
|
39
|
+
/** 豁免判定总开关(§4.3 逃生舱)——false ⇒ 检查器跳过豁免判定,结果与"豁免上线前"规范化一致。
|
|
40
|
+
* ⚠️ 与 env 的关系:`isExemptEnabled()` = 本常量 **且** env 未置 off(二者取最严)。 */
|
|
41
|
+
export const EXEMPT_ENABLED = true;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- 判据原语(§2.2 / §2.4)
|
|
44
|
+
|
|
45
|
+
/** 行首锚定判据(§2.4 字面):允许前置空白(缩进),**不做全库子串搜索**(防自举 / 误伤)
|
|
46
|
+
* @param {string} line 单行文本
|
|
47
|
+
* @returns {boolean} 是否为档级标记行 */
|
|
48
|
+
export function isDocMarkLine(line) {
|
|
49
|
+
return typeof line === 'string' && line.trimStart().startsWith(DOC_MARK_PREFIX);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 从标记行读家族 id(**供对账用;判定本身不依赖它** —— §2.2)
|
|
53
|
+
* @param {string} line 单行文本
|
|
54
|
+
* @returns {string|null} family id,读不到 ⇒ null */
|
|
55
|
+
export function readFamilyId(line) {
|
|
56
|
+
const m = /family=([a-z0-9-]+)/.exec(line ?? '');
|
|
57
|
+
return m ? m[1] : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 插入点统一规则(§2.2 · B-1 · v3c/N-1 索引口径)——由生成器调用,保证与预演同口径
|
|
61
|
+
* @param {number} firstH1 0-based 索引 = `lines.findIndex(l => /^#\s+\S/.test(l))`;**无 H1 传 -1**
|
|
62
|
+
* @returns {number} 0-based 数组索引(= 行号 - 1);恒定 ∈ [0, DOC_MARK_SCAN_LINES - 1]
|
|
63
|
+
* ⚠️ 实现者**不得按 1-based 行号传入**(§2.2 参数语义写死):H1 在 L1 时传 1 ⇒ 得索引 2 = 第 3 行,
|
|
64
|
+
* 与各族"插 L2"的落点表差 1 行。14 族(H1 索引 0)⇒ L2;`upstream`(无 H1)⇒ L1;
|
|
65
|
+
* `pitfall`(H1 索引 44)⇒ 夹紧 ⇒ L5。 */
|
|
66
|
+
export function insertAtFor(firstH1) {
|
|
67
|
+
const at = firstH1 >= 0 ? firstH1 + 1 : 0;
|
|
68
|
+
return Math.min(Math.max(at, 0), DOC_MARK_SCAN_LINES - 1); // 0-based
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 档全文 → **首个 H1 的 0-based 行索引**(= `insertAtFor()` 的入参口径 · §2.2 参数语义写死)。
|
|
72
|
+
* 判据 = `/^#\s+\S/`(与 §2.2 逐字一致);**无 H1 ⇒ -1**(⇒ `insertAtFor(-1)` = 0 ⇒ 插第 1 行,
|
|
73
|
+
* 实测 `upstream-*` 就是这种档)。生成器侧**一律走本函数**,禁自造正则(15 处自造 = 口径漂移面)。
|
|
74
|
+
* @param {string} text 档全文
|
|
75
|
+
* @returns {number} 0-based 索引;无 H1 ⇒ -1 */
|
|
76
|
+
export function firstH1Index(text) {
|
|
77
|
+
return String(text ?? '').split(/\r?\n/).findIndex((l) => /^#\s+\S/.test(l));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** **生成器侧**:给档全文盖上档级标记(§2.2 唯一形态)—— 15 个生成器在写盘前调用。
|
|
81
|
+
* · **插入点由调用方 `insertAtFor(firstH1Index(text))` 算好传入**(§2.2:由生成器调用权威源定位,
|
|
82
|
+
* 不许各写一份 clamp —— §3.3-2b 的静态断言 A 核的就是生成器里的 `insertAtFor(` 调用点);
|
|
83
|
+
* · **幂等**:档头前 N 行内已有档级标记 ⇒ **原位替换**(绝不重复插入;家族写错也在原位纠正);
|
|
84
|
+
* · 纯函数(不读文件、不写文件、无副作用);**行尾跟随文档**(CRLF 档插入的标记行也带 `\r`,
|
|
85
|
+
* 避免同一文件混用 LF/CRLF)。
|
|
86
|
+
* @param {string} text 档全文
|
|
87
|
+
* @param {string} familyId §3.1 家族 id(写入 `family=<id>`;判定只认前缀,此值供对账)
|
|
88
|
+
* @param {number} at 0-based 插入索引(**必须** = `insertAtFor(...)` 的返回值)
|
|
89
|
+
* @returns {string} 盖好标记的档全文 */
|
|
90
|
+
export function stampDocMark(text, familyId, at) {
|
|
91
|
+
const src = String(text ?? '');
|
|
92
|
+
const crlf = /\r\n/.test(src); // 行尾风格跟随文档(不混 LF/CRLF)
|
|
93
|
+
const lines = src.split('\n');
|
|
94
|
+
const mark = DOC_MARK_TEMPLATE.replace('<family-id>', String(familyId)) + (crlf ? '\r' : '');
|
|
95
|
+
const win = Math.min(DOC_MARK_SCAN_LINES, lines.length);
|
|
96
|
+
for (let i = 0; i < win; i++) {
|
|
97
|
+
if (isDocMarkLine(lines[i])) { lines[i] = mark; return lines.join('\n'); } // 幂等:原位替换
|
|
98
|
+
}
|
|
99
|
+
const n = Number.isInteger(at) ? at : 0;
|
|
100
|
+
const idx = Math.min(Math.max(n, 0), DOC_MARK_SCAN_LINES - 1, lines.length);
|
|
101
|
+
lines.splice(idx, 0, mark);
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 档头前 N 行(§2.4 检查器侧口径)。**唯一权威源**:检查器 / 对账脚本 / 夹具一律走这里,禁自造。 */
|
|
106
|
+
export function headLinesOf(text, n = DOC_MARK_SCAN_LINES) {
|
|
107
|
+
return String(text ?? '').split(/\r?\n/, n);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 文本 → 「是不是自动生成档」(§2.4 检查器侧字面:`head.some(isDocMarkLine)`) */
|
|
111
|
+
export function isGeneratedDocText(text) {
|
|
112
|
+
return headLinesOf(text).some(isDocMarkLine);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------- 路径归一化 + 家族匹配(§2.5)
|
|
116
|
+
|
|
117
|
+
/** 路径归一化(§2.5-1 写死三步):① `/` ⇒ `\\`;② 去盘符前缀 `^[a-zA-Z]:[\\/]dsh[\\/]`;③ 全小写
|
|
118
|
+
* ⇒ 统一成「**反斜杠 + 无盘符 + 全小写**」的相对路径。
|
|
119
|
+
* @param {string} file 相对或绝对路径(清单 `rel` 形态 = 反斜杠 + 无盘符)
|
|
120
|
+
* @returns {string} 归一化路径;非字符串 ⇒ 空串(调用方据此判 null) */
|
|
121
|
+
export function normalizePath(file) {
|
|
122
|
+
if (typeof file !== 'string') return '';
|
|
123
|
+
let s = file.replace(/\//g, '\\');
|
|
124
|
+
s = s.replace(/^[a-zA-Z]:[\\/]dsh[\\/]/i, '');
|
|
125
|
+
s = s.replace(/^\\+/, '');
|
|
126
|
+
return s.toLowerCase();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 家族表 `match` 模式 → **锚定正则**(§2.5-2 写死):
|
|
130
|
+
* `*` ⇒ `[^\\]*`(不跨目录分隔符)· `<host>` ⇒ `([a-z][a-z0-9-]*)` · `<日期>` ⇒ `\d{4}-\d{2}-\d{2}`
|
|
131
|
+
* · `<月>` ⇒ `\d{4}-\d{2}` · `<设备>` ⇒ `[^\\]+`;其余字符**按字面**(正则元字符一律转义)。
|
|
132
|
+
* 返回 `^…$` 锚定的 RegExp(大小写不敏感;输入通常已归一化) */
|
|
133
|
+
export function familyMatchToRe(match) {
|
|
134
|
+
if (typeof match !== 'string' || match === '') return null;
|
|
135
|
+
let src = match.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); // 先转义元字符(`*` 与 `<>` 故意不转义)
|
|
136
|
+
src = src.replace(/\*/g, '[^\\\\]*');
|
|
137
|
+
src = src.replace(/<日期>/g, '\\d{4}-\\d{2}-\\d{2}');
|
|
138
|
+
src = src.replace(/<月>/g, '\\d{4}-\\d{2}');
|
|
139
|
+
src = src.replace(/<host>/g, '([a-z][a-z0-9-]*)');
|
|
140
|
+
src = src.replace(/<设备>/g, '[^\\\\]+');
|
|
141
|
+
return new RegExp(`^${src}$`, 'i');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 多族命中裁决(§2.5-3):**按登记表顺序取第一个命中族**;未命中任何族 ⇒ `null`
|
|
145
|
+
* (**绝不是"未登记即默认豁免"** —— null ⇒ `isExemptEligible` = false ⇒ 不豁免)
|
|
146
|
+
* ⚠️ 登记表把 `progress` 排在 `daily` 之前 = §2.5 冲突裁决表 M-6 的落地形态
|
|
147
|
+
* (`daily` 的 `<日期>-<host>.md` 形态本身不含 `-progress-`,两条口径等价,双保险)。
|
|
148
|
+
* @param {string} file 清单 `rel` 形态(反斜杠 + 无盘符)或带盘符的绝对路径
|
|
149
|
+
* @returns {object|null} 家族对象(GENERATED_DOC_FAMILIES 的元素) */
|
|
150
|
+
export function findFamily(file) {
|
|
151
|
+
const hits = findFamilies(file);
|
|
152
|
+
return hits.length ? hits[0].family : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 全部命中族(诊断 / 歧义检测用;判定只取 `findFamily()` 的第一个)
|
|
156
|
+
* @returns {Array<{id:string, family:object, dirLen:number}>} */
|
|
157
|
+
export function findFamilies(file) {
|
|
158
|
+
const rel = normalizePath(file);
|
|
159
|
+
if (!rel) return [];
|
|
160
|
+
const base = rel.slice(rel.lastIndexOf('\\') + 1);
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const family of GENERATED_DOC_FAMILIES) {
|
|
163
|
+
const dir = normalizePath(family.dir);
|
|
164
|
+
if (dir !== '' && !rel.startsWith(dir + '\\')) continue; // §2.5-2:dir 必须是归一化路径的目录前缀
|
|
165
|
+
const re = familyMatchToRe(family.match);
|
|
166
|
+
if (re && !re.test(base)) continue; // §2.5-2:只看最后一段文件名,不看中间层级
|
|
167
|
+
out.push({ id: family.id, family, dirLen: dir.length });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 豁免资格(M-1 接线点 · §4.2):**只"有标记"不够,还必须"有资格"**
|
|
173
|
+
* @param {string} file 清单 `rel` 形态的路径
|
|
174
|
+
* @returns {boolean} 家族表 `exemptEligible`;未命中族 / 命中 `watch`·`external` ⇒ false */
|
|
175
|
+
export function isExemptEligible(file) {
|
|
176
|
+
const fam = findFamily(file); // 查 GENERATED_DOC_FAMILIES
|
|
177
|
+
return fam?.exemptEligible === true; // 未命中 ⇒ null ⇒ false(= 不豁免)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 逃生舱总开关(§4.3 · M-5):env `DSH_CITES_EXEMPT=off`(or 0/false)⇒ 关;常量 false ⇒ 关(取最严) */
|
|
181
|
+
export function isExemptEnabled(env = process.env) {
|
|
182
|
+
const v = String(env?.DSH_CITES_EXEMPT ?? '').trim().toLowerCase();
|
|
183
|
+
if (v === 'off' || v === '0' || v === 'false' || v === 'no') return false;
|
|
184
|
+
return EXEMPT_ENABLED === true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 例外白名单支路(§4.3-2)——**v3c 占位骨架 · 本轮不实现**(§6.1 Non-goal 第 8 条)
|
|
188
|
+
* 口径:本轮只实现主路径(生成器写在档头的档级标记);P4 定案后按 §4.3-2 的白名单口径补实现
|
|
189
|
+
* (结构 = `generated-docs-whitelist.json` 的 `{ path, reason, expiresAt }`,到期未复核即失效,默认 90 天)。
|
|
190
|
+
* ⚠️ 检查器侧判据字面已写成 `(isGeneratedDoc(file) || isListedExempt(file))`(§4.2-1)
|
|
191
|
+
* ⇒ 若删掉本函数,检查器会 ReferenceError(第三轮残留-b)。**保留占位、恒 false、行为等价。** */
|
|
192
|
+
export function isListedExempt() {
|
|
193
|
+
return false; // v3c 占位:白名单支路本轮不实现(§6.1-8)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------- 家族登记表(§3.1 全 15 族)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 家族登记表(权威源)。字段口径:
|
|
200
|
+
* · `id` 家族 id(= 档级标记 `family=<id>` 的取值;只供对账,判定只认前缀)
|
|
201
|
+
* · `dir` 归一化目录前缀(`docs` / `docs\管家报告` …;`docs` 段含子目录)
|
|
202
|
+
* · `match` 文件名模式(`*` / `<host>` / `<日期>` / `<月>` / `<设备>` 占位符,见 familyMatchToRe)
|
|
203
|
+
* · `label` 人类标签(对账报告用)
|
|
204
|
+
* · `generator` **「路径#符号名」**(§2.5 M-3:**只存路径 + 符号名,不承载行号** —— 行号会无声腐化;
|
|
205
|
+
* 顶层脚本写 `#(顶层)`;符号名供 R5 轻量自检 grep,不核行号)
|
|
206
|
+
* · `driftMode` 漂移强度(§3.1 第 4 列)
|
|
207
|
+
* · `exemptEligible` **豁免资格**(§3.1 第 5 列 / §3.2 反向锚点)
|
|
208
|
+
* ⚠️ **命名空间澄清(§2.5)**:本字段与 `tools\dsh-nurse\lib\nurse-profile-report.mjs`
|
|
209
|
+
* 中的**同名字段无语义关系**(后者 = 护士档案的豁免资格);两模块无任何调用关系,
|
|
210
|
+
* 判定为「C 类不改」。此处改字段名会与方案 §2.5/§3.1 的字面口径脱钩 ⇒ **保持同名 + 注释区分**。
|
|
211
|
+
* · `sample` 该族样本路径(§2.5-5 构造性断言的输入:形态 = 清单 `rel`,反斜杠 + 无盘符)
|
|
212
|
+
*/
|
|
213
|
+
export const GENERATED_DOC_FAMILIES = [
|
|
214
|
+
{
|
|
215
|
+
id: 'todo',
|
|
216
|
+
dir: 'docs',
|
|
217
|
+
match: '待你操作-<设备>.md',
|
|
218
|
+
label: 'docs\\待你操作-<设备>.md',
|
|
219
|
+
generator: 'tools\\owner-todo-scan.mjs#(顶层)',
|
|
220
|
+
driftMode: '全量重写',
|
|
221
|
+
exemptEligible: true,
|
|
222
|
+
sample: 'docs\\待你操作-example-host.md',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'index',
|
|
226
|
+
dir: 'docs',
|
|
227
|
+
match: '文档总索引.md',
|
|
228
|
+
label: 'docs\\文档总索引.md',
|
|
229
|
+
generator: 'tools\\docs-index-gen.mjs#buildIndexContent',
|
|
230
|
+
driftMode: '自动段全量重写',
|
|
231
|
+
exemptEligible: true,
|
|
232
|
+
sample: 'docs\\文档总索引.md',
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
// ⚠️ M-6 冲突裁决:`progress` 排在 `daily` 之前(`<日期>-progress-<host>.md` 是独立家族)
|
|
236
|
+
id: 'progress',
|
|
237
|
+
dir: 'docs\\管家报告',
|
|
238
|
+
match: '<日期>-progress-<host>.md',
|
|
239
|
+
label: 'docs\\管家报告\\<日期>-progress-<host>.md',
|
|
240
|
+
generator: 'tools\\dsh-butler\\progress-status.mjs#writeReport',
|
|
241
|
+
driftMode: '全量重写',
|
|
242
|
+
exemptEligible: true,
|
|
243
|
+
sample: 'docs\\管家报告\\2026-09-28-progress-example-host.md',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
// daily 的 `<日期>-<host>.md` 形态**本身不含** `-progress-`(锚定 + host 紧跟)
|
|
247
|
+
// ⇒ 与 §2.5 裁决表的 `daily = (?!-progress-)` 等价,双保险
|
|
248
|
+
id: 'daily',
|
|
249
|
+
dir: 'docs\\管家报告',
|
|
250
|
+
match: '<日期>-<host>.md',
|
|
251
|
+
label: 'docs\\管家报告\\<日期>-<Host>.md',
|
|
252
|
+
generator: 'tools\\dsh-butler-panel\\report.mjs#(顶层)',
|
|
253
|
+
driftMode: '全量重写(历史档只读)',
|
|
254
|
+
exemptEligible: true,
|
|
255
|
+
sample: 'docs\\管家报告\\2026-10-08-ExampleHost.md',
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
// §3.2 反向锚点②:登记但**明确不豁免**(纯追加型,已发布行不会移动 ⇒ 不需要豁免)
|
|
259
|
+
id: 'watch',
|
|
260
|
+
dir: 'docs\\管家报告',
|
|
261
|
+
match: 'watch-*.md',
|
|
262
|
+
label: 'docs\\管家报告\\watch-*.md',
|
|
263
|
+
generator: 'tools\\dsh-butler\\lib\\watch-notify.mjs#writeReport',
|
|
264
|
+
driftMode: '纯追加(仅在有事件时写)',
|
|
265
|
+
exemptEligible: false,
|
|
266
|
+
sample: 'docs\\管家报告\\watch-2026-10-08-example-host.md',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
// §3.2 反向锚点②:登记但**明确不豁免**
|
|
270
|
+
id: 'external',
|
|
271
|
+
dir: 'docs\\管家报告',
|
|
272
|
+
match: 'external-*.md',
|
|
273
|
+
label: 'docs\\管家报告\\external-*.md',
|
|
274
|
+
generator: 'tools\\dsh-butler\\butler-external.mjs#writeExternalReport',
|
|
275
|
+
driftMode: '纯追加(按源节流)',
|
|
276
|
+
exemptEligible: false,
|
|
277
|
+
sample: 'docs\\管家报告\\external-2026-10-08-ExampleHost.md',
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'upstream',
|
|
281
|
+
dir: 'docs\\管家报告',
|
|
282
|
+
match: 'upstream-<日期>.md',
|
|
283
|
+
label: 'docs\\管家报告\\upstream-<日期>.md',
|
|
284
|
+
generator: 'tools\\dsh-upstream-watch.mjs#main',
|
|
285
|
+
driftMode: '全量重写',
|
|
286
|
+
exemptEligible: true,
|
|
287
|
+
sample: 'docs\\管家报告\\upstream-2026-10-08.md',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: 'shadow48h',
|
|
291
|
+
dir: 'docs\\管家报告',
|
|
292
|
+
match: 'shadow48h-*.md',
|
|
293
|
+
label: 'docs\\管家报告\\shadow48h-*.md',
|
|
294
|
+
generator: 'tools\\dsh-butler\\lib\\shadow-contrast.mjs#renderConclusion',
|
|
295
|
+
driftMode: '一次性产物(生成后不改)',
|
|
296
|
+
exemptEligible: true,
|
|
297
|
+
sample: 'docs\\管家报告\\shadow48h-queue-wake-20260920.md',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'ledger',
|
|
301
|
+
dir: 'docs\\管家报告',
|
|
302
|
+
match: '会话台账-<日期>.md',
|
|
303
|
+
label: 'docs\\管家报告\\会话台账-<日期>.md',
|
|
304
|
+
generator: 'tools\\dsh-butler\\session-ledger.mjs#writeReportFile',
|
|
305
|
+
driftMode: '全量重写',
|
|
306
|
+
exemptEligible: true,
|
|
307
|
+
sample: 'docs\\管家报告\\会话台账-2026-10-08.md',
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'weekly',
|
|
311
|
+
dir: 'docs\\管家报告',
|
|
312
|
+
match: '周报-*.md',
|
|
313
|
+
label: 'docs\\管家报告\\周报-*.md',
|
|
314
|
+
generator: 'tools\\dsh-butler-panel\\report-weekly.mjs#(顶层)',
|
|
315
|
+
driftMode: '全量重写',
|
|
316
|
+
exemptEligible: true,
|
|
317
|
+
sample: 'docs\\管家报告\\周报-2026-09-21-ExampleHost.md',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: 'relaywatch',
|
|
321
|
+
dir: 'docs\\管家报告',
|
|
322
|
+
match: 'relay-handoff-watch-<日期>.md',
|
|
323
|
+
label: 'docs\\管家报告\\relay-handoff-watch-<日期>.md',
|
|
324
|
+
generator: 'tools\\dsh-butler\\lib\\relay-handoff-watch.mjs#renderObserveDoc',
|
|
325
|
+
driftMode: '全量重写(每 1 分钟)',
|
|
326
|
+
exemptEligible: true,
|
|
327
|
+
sample: 'docs\\管家报告\\relay-handoff-watch-2026-10-08.md',
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'patrol',
|
|
331
|
+
dir: 'docs\\管家报告',
|
|
332
|
+
match: 'upgrade-patrol-*.md',
|
|
333
|
+
label: 'docs\\管家报告\\upgrade-patrol-*.md',
|
|
334
|
+
generator: 'tools\\dsh-upgrade-orchestrator.mjs#renderPatrolReport',
|
|
335
|
+
driftMode: '全量重写',
|
|
336
|
+
exemptEligible: true,
|
|
337
|
+
sample: 'docs\\管家报告\\upgrade-patrol-2026-09-17.md',
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'pitfall',
|
|
341
|
+
dir: 'docs',
|
|
342
|
+
match: '踩坑大全-<host>.md',
|
|
343
|
+
label: 'docs\\踩坑大全-<host>.md',
|
|
344
|
+
generator: 'tools\\dsh-butler\\pitfall-tick.mjs#runTick',
|
|
345
|
+
driftMode: '全量重写(每轮)',
|
|
346
|
+
exemptEligible: true,
|
|
347
|
+
sample: 'docs\\踩坑大全-example-host.md',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
// §3.2 反向锚点①:`坑库\` 下的人工档(P4候选分类…/P4待办合并…)**必须排除** ⇒ match 收窄到 host 后缀形态
|
|
351
|
+
id: 'pitlibrary',
|
|
352
|
+
dir: 'docs\\坑库',
|
|
353
|
+
match: '*-<host>.md',
|
|
354
|
+
label: 'docs\\坑库\\*-<host>.md + README-<host>.md',
|
|
355
|
+
generator: 'tools\\dsh-butler\\lib\\pitfall-render.mjs#renderAll',
|
|
356
|
+
driftMode: '段内重写',
|
|
357
|
+
exemptEligible: true,
|
|
358
|
+
sample: 'docs\\坑库\\守护鱼-example-host.md',
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: 'changelog',
|
|
362
|
+
dir: 'docs\\变更流水',
|
|
363
|
+
match: '<月>.md',
|
|
364
|
+
label: 'docs\\变更流水\\<月>.md',
|
|
365
|
+
generator: 'tools\\dsh-butler\\changelog-add.mjs#renderChangelog',
|
|
366
|
+
driftMode: '段内插入 + 整档重渲染',
|
|
367
|
+
exemptEligible: true,
|
|
368
|
+
sample: 'docs\\变更流水\\2026-10.md',
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------- 构造性断言辅助(§2.5-5 / §4.5-5)
|
|
373
|
+
|
|
374
|
+
/** 每族样本 + 三种等价变体(`/` 分隔 · 带盘符 · 带盘符且 `/`)——归一化后必须落同一族 */
|
|
375
|
+
export function familySampleVariants(family) {
|
|
376
|
+
const s = family.sample;
|
|
377
|
+
return [s, s.replace(/\\/g, '/'), `X:\\DSH\\${s}`, `x:/dsh/${s.replace(/\\/g, '/')}`];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 全族样本清单(对账脚本 §4.1-6② / 第 5 类夹具 / 单测共用) */
|
|
381
|
+
export function familySamples() {
|
|
382
|
+
return GENERATED_DOC_FAMILIES.map((f) => ({
|
|
383
|
+
id: f.id,
|
|
384
|
+
dir: f.dir,
|
|
385
|
+
match: f.match,
|
|
386
|
+
exemptEligible: f.exemptEligible === true,
|
|
387
|
+
sample: f.sample,
|
|
388
|
+
variants: familySampleVariants(f),
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 家族表指纹(报告里打印,供跨机 / 跨版本一致性追溯) */
|
|
393
|
+
export function familyTableDigest() {
|
|
394
|
+
return GENERATED_DOC_FAMILIES
|
|
395
|
+
.map((f) => `${f.id}|${f.dir}|${f.match}|${f.exemptEligible ? 'Y' : 'N'}`)
|
|
396
|
+
.join('\n');
|
|
397
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// tools/lib/powershell-exe.mjs —— PowerShell 解释器选择「唯一真源」
|
|
3
|
+
// ------------------------------------------------------------
|
|
4
|
+
// 为什么存在(2026-10-09 · 卡「360弹窗治本-pwsh7」):
|
|
5
|
+
// 360 企业版 EDR(公司下发、终端只读)对 **Windows PowerShell 5.1**
|
|
6
|
+
// (%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe)的每次
|
|
7
|
+
// `-Command` 调用判定为「PowerShell 命令执行攻击」并弹窗拦截;本机
|
|
8
|
+
// DSH 生态里有几十处 spawn 5.1 的点,watch job 每 5.5 分钟一条 ⇒ 主人被反复弹。
|
|
9
|
+
// 对策 = 全部改走 **pwsh 7**(同样被 360 放行),本模块即该选择的单一实现。
|
|
10
|
+
//
|
|
11
|
+
// 口径(写进坑库,新增代码必须遵守):
|
|
12
|
+
// **新增脚本/代码调 PowerShell 一律 pwsh 7 优先,禁裸 powershell.exe(5.1)**。
|
|
13
|
+
// 只用「解析 + spawn 薄封装」两个能力,不改变调用方的参数与输出解析口径。
|
|
14
|
+
//
|
|
15
|
+
// 实测(2026-10-09 · 某 Windows 主机):
|
|
16
|
+
// · `where pwsh` → %ProgramFiles%\WindowsApps\Microsoft.PowerShell_7.x_x64__…\pwsh.exe
|
|
17
|
+
// + %LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe(两个都指向 7.6.6 别名)
|
|
18
|
+
// · `%ProgramFiles%\PowerShell\7\pwsh.exe` **不存在**(本机是 Store/MSIX 安装)
|
|
19
|
+
// · `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe`:**fs.existsSync() = false**
|
|
20
|
+
// 但直接 spawn 该全路径 **可跑**(status 0 / 7.6.6)⇒ 别名形态**必须真跑探测**,
|
|
21
|
+
// 只看 existsSync 会漏判 ⇒ 本模块对「绝对路径候选」是 exists 或 probe 二者之一命中即选。
|
|
22
|
+
// · 空 PATH 下 `spawn('pwsh')` = **ENOENT** ⇒ PATH 候选失败必须能回退(见 spawnPowerShellSync)。
|
|
23
|
+
// · 5.1 与 7.6 的 Get-NetTCPConnection / Get-CimInstance / Get-Process / Start-Sleep 实测等价;
|
|
24
|
+
// 控制台输出编码两代都跟随代码页(GB2312),**换 7 不改变中文解码口径**(保持既有解析行为)。
|
|
25
|
+
//
|
|
26
|
+
// 设计要点:
|
|
27
|
+
// · 纯解析函数可注入(env / exists / probe / candidates)⇒ 单测能造假件覆盖回退分支,不必真删 pwsh;
|
|
28
|
+
// · PATH 候选默认「信任」(不为它多跑一次探测),失败由 spawn 层的 ENOENT 回退兜底;
|
|
29
|
+
// · 绝对路径候选:existsSync 命中即选;否则**真跑探测**(专治 WindowsApps 别名)。
|
|
30
|
+
// ============================================================
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
export const VERSION = '1.0.0';
|
|
36
|
+
|
|
37
|
+
/** pwsh 7(首选)。 */
|
|
38
|
+
export const KIND_PWSH = 'pwsh';
|
|
39
|
+
/** Windows PowerShell 5.1(兜底)。 */
|
|
40
|
+
export const KIND_WINPS = 'windows-powershell';
|
|
41
|
+
/** 连 5.1 都解析不到时的硬兜底 exe 名。 */
|
|
42
|
+
export const HARD_FALLBACK_EXE = 'powershell.exe';
|
|
43
|
+
|
|
44
|
+
function systemRoot(env) { return env.SystemRoot || env.windir || ((env.SystemDrive || 'C:') + '\\Windows'); }
|
|
45
|
+
function localAppData(env) {
|
|
46
|
+
return env.LOCALAPPDATA || path.join(env.USERPROFILE || path.join((env.SystemDrive || 'C:') + '\\', 'Users', 'Default'), 'AppData', 'Local');
|
|
47
|
+
}
|
|
48
|
+
function programFilesDir(env) { return env.ProgramFiles || ((env.SystemDrive || 'C:') + '\\Program Files'); }
|
|
49
|
+
|
|
50
|
+
/** 路径比较(Windows 大小写不敏感;容忍引号与斜杠差异)。 */
|
|
51
|
+
export function sameExe(a, b) {
|
|
52
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
53
|
+
const n = (s) => s.trim().replace(/^"|"$/g, '').replace(/\//g, '\\').toLowerCase();
|
|
54
|
+
return n(a) === n(b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 候选解释器(**有序**:pwsh 优先,5.1 最后)。
|
|
59
|
+
* 顺序 = 环境变量 → PATH 的 pwsh → Program Files 的 pwsh → WindowsApps 别名 pwsh → PATH 的 5.1 → System32 的 5.1。
|
|
60
|
+
* @param {{env?:object}} [opts]
|
|
61
|
+
* @returns {Array<{exe:string,kind:string,source:string,pathed:boolean}>}
|
|
62
|
+
*/
|
|
63
|
+
export function powerShellCandidates(opts = {}) {
|
|
64
|
+
const env = opts.env ?? process.env;
|
|
65
|
+
const out = [];
|
|
66
|
+
const push = (c) => { if (typeof c.exe === 'string' && c.exe.trim()) out.push(c); };
|
|
67
|
+
if (env.DSH_PWSH) {
|
|
68
|
+
push({ exe: env.DSH_PWSH, kind: KIND_PWSH, source: 'env:DSH_PWSH', pathed: !/[\\/]/.test(env.DSH_PWSH) });
|
|
69
|
+
}
|
|
70
|
+
push({ exe: 'pwsh', kind: KIND_PWSH, source: 'path', pathed: true });
|
|
71
|
+
push({ exe: path.join(programFilesDir(env), 'PowerShell', '7', 'pwsh.exe'), kind: KIND_PWSH, source: 'program-files-7', pathed: false });
|
|
72
|
+
push({ exe: path.join(localAppData(env), 'Microsoft', 'WindowsApps', 'pwsh.exe'), kind: KIND_PWSH, source: 'windowsapps-alias', pathed: false });
|
|
73
|
+
push({ exe: HARD_FALLBACK_EXE, kind: KIND_WINPS, source: 'path-fallback', pathed: true });
|
|
74
|
+
push({ exe: path.join(systemRoot(env), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), kind: KIND_WINPS, source: 'system32-fallback', pathed: false });
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 默认探测 = **真跑一次** `-NoProfile -NonInteractive -Command exit 0`(专治 WindowsApps 别名:
|
|
80
|
+
* existsSync=false 但能跑)。任何异常都当"不可用",绝不抛。
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function defaultProbe(exe, { spawnImpl = spawnSync, timeoutMs = 20_000, env } = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const r = spawnImpl(exe, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'],
|
|
86
|
+
{ windowsHide: true, timeout: timeoutMs, stdio: 'ignore', env });
|
|
87
|
+
return !r?.error && r?.status === 0;
|
|
88
|
+
} catch { return false; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 解析优先解释器(纯函数,可注入)。
|
|
93
|
+
* @param {{
|
|
94
|
+
* env?:object, exists?:(p:string)=>boolean, probe?:Function|null,
|
|
95
|
+
* candidates?:Array, spawnImpl?:Function, probeTimeoutMs?:number
|
|
96
|
+
* }} [opts]
|
|
97
|
+
* · `probe` 缺省(undefined)= 用 {@link defaultProbe} 真跑;`null` = 完全不探测(PATH 候选直接信任、绝对路径只看 exists)
|
|
98
|
+
* · `exists` 缺省 = `fs.existsSync`
|
|
99
|
+
* @returns {{exe:string,kind:string,source:string,pathed:boolean,verified:boolean,reason:string}|null}
|
|
100
|
+
*/
|
|
101
|
+
export function resolvePowerShell(opts = {}) {
|
|
102
|
+
const exists = opts.exists ?? fs.existsSync;
|
|
103
|
+
const rawProbe = opts.probe;
|
|
104
|
+
const probe = rawProbe === undefined
|
|
105
|
+
? (exe) => defaultProbe(exe, { spawnImpl: opts.spawnImpl, timeoutMs: opts.probeTimeoutMs, env: opts.env })
|
|
106
|
+
: (typeof rawProbe === 'function' ? rawProbe : null);
|
|
107
|
+
const cands = opts.candidates ?? powerShellCandidates(opts);
|
|
108
|
+
for (const c of cands) {
|
|
109
|
+
if (c.pathed) {
|
|
110
|
+
if (!probe) return { ...c, verified: false, reason: 'path-trusted(no-probe)' };
|
|
111
|
+
if (probe(c.exe)) return { ...c, verified: true, reason: 'path-probe-ok' };
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (exists(c.exe)) return { ...c, verified: false, reason: 'exists' };
|
|
115
|
+
if (probe && probe(c.exe)) return { ...c, verified: true, reason: 'probe-ok(alias)' };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let CACHE = null; // { exe, kind, source, verified, reason, at }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 优选解释器 exe(带进程内缓存;**永不返回 null** —— 全找不到时给 `powershell.exe` 现状兜底)。
|
|
124
|
+
* @param {{noCache?:boolean} & Parameters<typeof resolvePowerShell>[0]} [opts]
|
|
125
|
+
*/
|
|
126
|
+
export function powerShellExe(opts = {}) {
|
|
127
|
+
if (opts.noCache !== true && CACHE) return CACHE.exe;
|
|
128
|
+
const hit = resolvePowerShell(opts);
|
|
129
|
+
const picked = hit
|
|
130
|
+
? { ...hit, at: Date.now() }
|
|
131
|
+
: { exe: HARD_FALLBACK_EXE, kind: KIND_WINPS, source: 'hard-fallback', pathed: true, verified: false, reason: 'none-resolved', at: Date.now() };
|
|
132
|
+
if (opts.noCache !== true) CACHE = picked;
|
|
133
|
+
return picked.exe;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 清缓存(spawn 撞 ENOENT / 单测需要重解析时用)。 */
|
|
137
|
+
export function resetPowerShellCache() { CACHE = null; }
|
|
138
|
+
|
|
139
|
+
/** 只读诊断快照(报告 / 自检 / 面板用)。 */
|
|
140
|
+
export function describePowerShell(opts = {}) {
|
|
141
|
+
const hit = resolvePowerShell(opts);
|
|
142
|
+
const picked = hit ?? { exe: HARD_FALLBACK_EXE, kind: KIND_WINPS, source: 'hard-fallback', verified: false, reason: 'none-resolved' };
|
|
143
|
+
return {
|
|
144
|
+
exe: picked.exe, kind: picked.kind, source: picked.source, verified: picked.verified, reason: picked.reason,
|
|
145
|
+
cached: CACHE ? { ...CACHE } : null,
|
|
146
|
+
candidates: powerShellCandidates(opts).map((c) => ({
|
|
147
|
+
...c,
|
|
148
|
+
exists: c.pathed ? null : Boolean((opts.exists ?? fs.existsSync)(c.exe)),
|
|
149
|
+
})),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* spawn 薄封装:**参数与返回值与 `spawnSync(exe, args, spawnOpts)` 兼容**,
|
|
155
|
+
* 只是 exe 自动优选,并在 `ENOENT`(PATH 里没有 pwsh / 别名失效)时**自动回退到下一个候选**。
|
|
156
|
+
* 非 ENOENT 的错误(超时 / 被杀)**不回退** —— 语义是「解释器找到了,只是命令失败」。
|
|
157
|
+
*
|
|
158
|
+
* @param {string[]} args 原样透传给解释器的参数(如 ['-NoProfile','-Command', cmd])
|
|
159
|
+
* @param {object} [spawnOpts] spawnSync 选项(encoding / timeout / windowsHide / maxBuffer / env …)
|
|
160
|
+
* @param {{spawnImpl?:Function, candidates?:Array, exe?:string, noCache?:boolean,
|
|
161
|
+
* env?:object, exists?:Function, probe?:Function|null}} [resolveOpts]
|
|
162
|
+
* @returns {object} spawnSync 结果 + `exe` / `kind` / `attempts`(诊断用)
|
|
163
|
+
*/
|
|
164
|
+
export function spawnPowerShellSync(args, spawnOpts = {}, resolveOpts = {}) {
|
|
165
|
+
const spawnImpl = resolveOpts.spawnImpl ?? spawnSync;
|
|
166
|
+
const cands = resolveOpts.candidates ?? powerShellCandidates(resolveOpts);
|
|
167
|
+
const target = resolveOpts.exe ?? (resolveOpts.noCache === true ? null : CACHE?.exe) ?? powerShellExe(resolveOpts);
|
|
168
|
+
let start = cands.findIndex((c) => sameExe(c.exe, target));
|
|
169
|
+
if (start < 0) start = 0;
|
|
170
|
+
const attempts = [];
|
|
171
|
+
let last = null;
|
|
172
|
+
for (let i = start; i < cands.length; i++) {
|
|
173
|
+
const c = cands[i];
|
|
174
|
+
let r;
|
|
175
|
+
try { r = spawnImpl(c.exe, args, spawnOpts); } catch (e) { r = { error: e }; }
|
|
176
|
+
attempts.push({ exe: c.exe, kind: c.kind, status: r?.status ?? null, errCode: r?.error?.code ?? null });
|
|
177
|
+
last = r;
|
|
178
|
+
if (!r?.error) return { ...r, exe: c.exe, kind: c.kind, attempts };
|
|
179
|
+
if (r.error?.code !== 'ENOENT') return { ...r, exe: c.exe, kind: c.kind, attempts };
|
|
180
|
+
if (resolveOpts.noCache !== true) resetPowerShellCache(); // PATH 候选失效 ⇒ 缓存作废,下一候选顶上
|
|
181
|
+
}
|
|
182
|
+
return { ...(last ?? {}), error: last?.error ?? new Error('no-usable-powershell'), exe: null, kind: null, attempts };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 便捷:跑一条 `-Command`(自动 `-NoProfile -NonInteractive`)。 */
|
|
186
|
+
export function runPowerShell(command, spawnOpts = {}, resolveOpts = {}) {
|
|
187
|
+
return spawnPowerShellSync(['-NoProfile', '-NonInteractive', '-Command', String(command)], spawnOpts, resolveOpts);
|
|
188
|
+
}
|