sillyspec 3.26.6 → 3.26.8
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/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.husky/pre-push +9 -2
- package/bin/sillyspec.js +1 -1
- package/package.json +3 -3
- package/packages/dashboard/server/index.js +8 -1
- package/src/config-schema.js +305 -287
- package/src/docs-check.js +333 -0
- package/src/docs-debt.js +226 -0
- package/src/docs-gate.js +106 -0
- package/src/doctor-diagnostics.js +23 -2
- package/src/index.js +1880 -1750
- package/src/init.js +81 -14
- package/src/modules.js +490 -486
- package/src/progress.js +37 -13
- package/src/quicklog.js +19 -4
- package/src/run/command.js +54 -24
- package/src/run/complete-handlers.js +1254 -1128
- package/src/run/complete.js +8 -34
- package/src/run/gates.js +32 -3
- package/src/run/prompt.js +713 -633
- package/src/run/quick-audit.js +137 -123
- package/src/run/shared.js +1109 -929
- package/src/run/stage.js +8 -3
- package/src/scan-staleness.js +90 -0
- package/src/setup.js +27 -9
- package/src/sillyhub-mcp/client.js +149 -14
- package/src/stage-contract-spec.js +15 -0
- package/src/stage-contract.js +8 -5
- package/src/stages/brainstorm-auto.js +0 -1
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/execute.js +985 -967
- package/src/stages/plan-postcheck.js +93 -36
- package/src/stages/verify.js +1 -1
- package/src/sync.js +60 -32
- package/src/task-review.js +38 -6
- package/src/verify-postcheck.js +22 -5
- package/src/worktree-apply.js +6 -3
- package/src/worktree-deps.js +5 -3
- package/src/worktree.js +4 -2
|
@@ -31,6 +31,7 @@ import { validateScriptCommands } from './cmd-existence.js'
|
|
|
31
31
|
* @returns {string[]}
|
|
32
32
|
*/
|
|
33
33
|
function parseDependsOn(content) {
|
|
34
|
+
content = content.replace(/\r\n/g, '\n') // 外部调用方(worktree-apply 等)原生 readFileSync 喂 CRLF 时 ^---\n 锚点失配
|
|
34
35
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
35
36
|
if (!fmMatch) return []
|
|
36
37
|
const fm = fmMatch[1]
|
|
@@ -38,9 +39,11 @@ function parseDependsOn(content) {
|
|
|
38
39
|
if (inlineMatch) {
|
|
39
40
|
return inlineMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
// 块列表:列表项允许顶格或缩进([ \t]*,坑6——原 \s* 贪婪吃掉换行与前导空白,
|
|
43
|
+
// 顶格 `depends_on:\n- task-01` 标准 YAML 写法永远失配,静默判「无依赖」)
|
|
44
|
+
const blockMatch = fm.match(/depends_on:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
|
|
42
45
|
if (blockMatch) {
|
|
43
|
-
return blockMatch[1].match(
|
|
46
|
+
return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => s.replace(/^[ \t]*-[ \t]+/, '').trim().replace(/['"]/g, '')) || []
|
|
44
47
|
}
|
|
45
48
|
return []
|
|
46
49
|
}
|
|
@@ -67,16 +70,25 @@ function parseTaskId(content, filename) {
|
|
|
67
70
|
* @returns {string[]}
|
|
68
71
|
*/
|
|
69
72
|
export function parseAllowedPaths(content) {
|
|
73
|
+
content = content.replace(/\r\n/g, '\n') // 同 parseDependsOn:入口归一,保护喂原始 CRLF 内容的外部调用方
|
|
70
74
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
71
75
|
if (!fmMatch) return []
|
|
72
76
|
const fm = fmMatch[1]
|
|
73
77
|
const inlineMatch = fm.match(/allowed_paths:\s*\[([^\]]*)\]/)
|
|
74
78
|
if (inlineMatch) {
|
|
75
|
-
return inlineMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
|
|
79
|
+
return inlineMatch[1].split(',').map(s => s.trim().replace(/['"`]/g, '')).filter(Boolean)
|
|
76
80
|
}
|
|
77
|
-
|
|
81
|
+
// 块列表:列表项允许顶格或缩进(坑6①——原 `\s*\n` 的 \s 贪婪匹配换行+前导空白,
|
|
82
|
+
// 标准 YAML 块列表 `allowed_paths:\n- src/a.js`(顶格)永远失配,静默判「缺 allowed_paths」;
|
|
83
|
+
// 缩进式 \s+- 恰可命中是巧合幸存。改 [ \t]*(不吃换行)+ 行内 [ \t]*-[ \t]+ 顶格缩进通吃)
|
|
84
|
+
const blockMatch = fm.match(/allowed_paths:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
|
|
78
85
|
if (blockMatch) {
|
|
79
|
-
|
|
86
|
+
// 剥首尾成对反引号(坑7 家族:`src/a.js` 整项以反引号开头时 js-yaml/正则路径带 ` 匹配失败报未覆盖;
|
|
87
|
+
// 与 change-list.js 的 cell 归一化同语义——剥两端包裹引号,不动路径内部字符)
|
|
88
|
+
return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => {
|
|
89
|
+
const raw = s.replace(/^[ \t]*-[ \t]+/, '').trim()
|
|
90
|
+
return raw.replace(/^`([^`]*)`$/, '$1').replace(/^['"]|['"]$/g, '').trim()
|
|
91
|
+
}).filter(Boolean) || []
|
|
80
92
|
}
|
|
81
93
|
return []
|
|
82
94
|
}
|
|
@@ -96,6 +108,7 @@ export function parseAllowedPaths(content) {
|
|
|
96
108
|
* @returns {string|null}
|
|
97
109
|
*/
|
|
98
110
|
function parseFrontmatterScalar(content, field) {
|
|
111
|
+
content = content.replace(/\r\n/g, '\n') // 同 parseAllowedPaths:入口归一 CRLF
|
|
99
112
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
100
113
|
if (!fmMatch) return null
|
|
101
114
|
let fm
|
|
@@ -439,6 +452,39 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
439
452
|
}
|
|
440
453
|
}
|
|
441
454
|
|
|
455
|
+
// plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b):
|
|
456
|
+
// plan 列 17 任务只出 12 卡(尾部文档同步类任务漏卡)时,无卡任务不进 Wave、
|
|
457
|
+
// 不受 execute 审计 → 零失败信号漏做。口径与 plan.js parseTaskCount/parseTaskNames 一致
|
|
458
|
+
// (`- [ ] task-NN` checkbox;file 命名 task-NN.md + 卡内 id task-NN 均取,缺一即漏卡)。
|
|
459
|
+
const planMdPath = pJoin(changeDir, 'plan.md')
|
|
460
|
+
if (existsSync(planMdPath)) {
|
|
461
|
+
const declaredIds = new Set()
|
|
462
|
+
for (const line of readFileSync(planMdPath, 'utf8').split('\n')) {
|
|
463
|
+
const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
|
|
464
|
+
if (m) declaredIds.add(m[1].toLowerCase())
|
|
465
|
+
}
|
|
466
|
+
if (declaredIds.size > 0) {
|
|
467
|
+
const tprRule = getRule('plan.task-plan-reconciliation')
|
|
468
|
+
const cardIds = new Set([...taskInfo.keys()].map(id => id.toLowerCase()))
|
|
469
|
+
const missing = [...declaredIds].filter(id => !cardIds.has(id))
|
|
470
|
+
if (missing.length > 0) {
|
|
471
|
+
errors.push(
|
|
472
|
+
tprRule.data.messageMissingCards
|
|
473
|
+
.replaceAll('${declared}', declaredIds.size)
|
|
474
|
+
.replaceAll('${missing}', missing.join(', '))
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
const orphans = [...cardIds].filter(id => !declaredIds.has(id))
|
|
478
|
+
if (orphans.length > 0) {
|
|
479
|
+
errors.push(
|
|
480
|
+
tprRule.data.messageOrphanCards.replaceAll('${orphans}', orphans.join(', '))
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// declaredIds.size === 0:plan.md 无 task checkbox 声明(极端格式)→ 跳过对账,
|
|
485
|
+
// 由 validatePlanArtifacts/validatePlanForExecute 把关,不在此误伤。
|
|
486
|
+
}
|
|
487
|
+
|
|
442
488
|
// 拓扑排序 + 循环依赖
|
|
443
489
|
const depMap = new Map()
|
|
444
490
|
for (const [taskId, info] of taskInfo) {
|
|
@@ -691,8 +737,10 @@ function parseDesignCoverageByRepo(designPath) {
|
|
|
691
737
|
}
|
|
692
738
|
|
|
693
739
|
if (!hasSegmentHeader) {
|
|
694
|
-
// 无段头 → 整章节归 main(等价原 parseFileChangeList
|
|
695
|
-
|
|
740
|
+
// 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)。
|
|
741
|
+
// keepSillyspecDocs=true:.sillyspec/docs/ 模块文档 = 交付物,与 apply 阶段
|
|
742
|
+
// resolveApplyAllowSet 口径一致(债单 D-2)——design 声明的模块文档更新必须被 task 认领。
|
|
743
|
+
const main = new Set(parseFileChangeList(designPath, { keepSillyspecDocs: true }))
|
|
696
744
|
if (main.size > 0) byRepo.set('main', main)
|
|
697
745
|
for (const p of main) allFiles.push(p)
|
|
698
746
|
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
@@ -731,7 +779,7 @@ function parseDesignCoverageByRepo(designPath) {
|
|
|
731
779
|
const virtualDesign = ['## 文件变更清单', '', ...segLines, ''].join('\n')
|
|
732
780
|
const tmpPath = pJoin(tmpDir, `${repo}.md`)
|
|
733
781
|
writeFileSync(tmpPath, virtualDesign)
|
|
734
|
-
const segFiles = [...parseFileChangeList(tmpPath)]
|
|
782
|
+
const segFiles = [...parseFileChangeList(tmpPath, { keepSillyspecDocs: true })]
|
|
735
783
|
if (segFiles.length === 0) continue
|
|
736
784
|
const set = byRepo.get(repo) || new Set()
|
|
737
785
|
for (const p of segFiles) { set.add(p); allFiles.push(p) }
|
|
@@ -1062,41 +1110,41 @@ export async function executePlanPostcheck(context) {
|
|
|
1062
1110
|
|
|
1063
1111
|
console.log(` 📂 变更目录: ${changeDir}`)
|
|
1064
1112
|
|
|
1113
|
+
// ── 报错聚合(坑6③):六个检查全部跑完后统一输出全部失败项,一轮 --done 暴露全部问题,
|
|
1114
|
+
// 不再「失败一个抛一个、修一个冒一个」的迭代盲盒。各检查相互独立(都只读 changeDir 产物),
|
|
1115
|
+
// 前置检查失败不使后续检查失真——唯一例外是 tasks/ 目录缺失时后续检查都无意义,仍提前短路。
|
|
1116
|
+
const failures = [] // { name, errors, hint }
|
|
1117
|
+
const printSectionWarnings = (label, warnings) => {
|
|
1118
|
+
if (warnings.length > 0) {
|
|
1119
|
+
console.warn(`\n⚠️ ${label}警告(不阻断):`)
|
|
1120
|
+
for (const w of warnings) console.warn(` - ${w}`)
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1065
1124
|
// ── 1. 一致性校验 ──
|
|
1066
1125
|
const consistency = validateBlueprintConsistency(changeDir)
|
|
1067
1126
|
if (consistency.errors.length > 0) {
|
|
1068
|
-
|
|
1069
|
-
for (const err of consistency.errors) console.error(` - ${err}`)
|
|
1070
|
-
console.error('\n 请修复上述问题后重新完成此步骤。')
|
|
1071
|
-
throw new Error('planPostcheck: blueprint consistency check failed')
|
|
1072
|
-
}
|
|
1073
|
-
if (consistency.warnings.length > 0) {
|
|
1074
|
-
console.warn('\n⚠️ 蓝图一致性警告(不阻断):')
|
|
1075
|
-
for (const w of consistency.warnings) console.warn(` - ${w}`)
|
|
1127
|
+
failures.push({ name: '蓝图一致性校验', errors: consistency.errors, hint: '请修复上述问题后重新完成此步骤。' })
|
|
1076
1128
|
}
|
|
1129
|
+
printSectionWarnings('蓝图一致性', consistency.warnings)
|
|
1077
1130
|
|
|
1078
1131
|
// ── 1b. 可行性校验 ──
|
|
1079
1132
|
const feasibility = validatePlanFeasibility(changeDir, context.cwd)
|
|
1080
1133
|
if (feasibility.errors.length > 0) {
|
|
1081
|
-
|
|
1082
|
-
for (const err of feasibility.errors) console.error(` - ${err}`)
|
|
1083
|
-
throw new Error('planPostcheck: feasibility check failed')
|
|
1084
|
-
}
|
|
1085
|
-
if (feasibility.warnings.length > 0) {
|
|
1086
|
-
console.warn('\n⚠️ Plan 可行性警告(不阻断):')
|
|
1087
|
-
for (const w of feasibility.warnings) console.warn(` - ${w}`)
|
|
1134
|
+
failures.push({ name: 'Plan 可行性校验', errors: feasibility.errors, hint: null })
|
|
1088
1135
|
}
|
|
1136
|
+
printSectionWarnings('Plan 可行性', feasibility.warnings)
|
|
1089
1137
|
|
|
1090
1138
|
// ── 1c. 跨任务契约校验 ──
|
|
1091
1139
|
// 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
|
|
1092
1140
|
// 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
|
|
1093
1141
|
const crossTask = validateCrossTaskContracts(changeDir)
|
|
1094
1142
|
if (crossTask.errors.length > 0) {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1143
|
+
failures.push({
|
|
1144
|
+
name: '跨任务契约校验(consumer 期望的字段未被 provider 承诺)',
|
|
1145
|
+
errors: crossTask.errors,
|
|
1146
|
+
hint: '修复方式:要么在 provider task 的 provides.fields 补上缺失字段,要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。',
|
|
1147
|
+
})
|
|
1100
1148
|
}
|
|
1101
1149
|
|
|
1102
1150
|
// ── 1c-b. TaskCard 命令存在性校验 ──
|
|
@@ -1111,11 +1159,11 @@ export async function executePlanPostcheck(context) {
|
|
|
1111
1159
|
}
|
|
1112
1160
|
const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
|
|
1113
1161
|
if (taskCmds.errors.length > 0) {
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1162
|
+
failures.push({
|
|
1163
|
+
name: 'TaskCard 命令存在性校验(verify/implementation 的 npm/pnpm/yarn run <script> 不存在)',
|
|
1164
|
+
errors: taskCmds.errors,
|
|
1165
|
+
hint: '修复方式:要么在 package.json scripts 补上缺失命令(monorepo 注意子包路径),要么修正 TaskCard 的 verify/implementation(确认命令是否真实、是否需 cd 子包)。',
|
|
1166
|
+
})
|
|
1119
1167
|
}
|
|
1120
1168
|
|
|
1121
1169
|
// ── 1d. design 文件覆盖对账 ──
|
|
@@ -1124,14 +1172,23 @@ export async function executePlanPostcheck(context) {
|
|
|
1124
1172
|
const coverage = validateDesignFileCoverage(changeDir)
|
|
1125
1173
|
for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
|
|
1126
1174
|
if (coverage.errors.length > 0) {
|
|
1127
|
-
|
|
1128
|
-
for (const err of coverage.errors) console.error(` - ${err}`)
|
|
1129
|
-
throw new Error('planPostcheck: design file coverage check failed')
|
|
1175
|
+
failures.push({ name: 'design.md 文件覆盖对账(清单中的文件未被任何 task 覆盖)', errors: coverage.errors, hint: null })
|
|
1130
1176
|
}
|
|
1131
1177
|
if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
|
|
1132
1178
|
console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
|
|
1133
1179
|
}
|
|
1134
1180
|
|
|
1181
|
+
// ── 聚合输出:一轮 --done 暴露全部失败项(坑6③)──
|
|
1182
|
+
if (failures.length > 0) {
|
|
1183
|
+
console.error(`\n❌ plan postcheck 失败(${failures.length} 类问题,已全部列出——一次修复后重跑,无需逐个迭代):`)
|
|
1184
|
+
for (const f of failures) {
|
|
1185
|
+
console.error(`\n 【${f.name}】`)
|
|
1186
|
+
for (const err of f.errors) console.error(` - ${err}`)
|
|
1187
|
+
if (f.hint) console.error(` ${f.hint}`)
|
|
1188
|
+
}
|
|
1189
|
+
throw new Error(`planPostcheck: ${failures.length} 组校验失败(${failures.map(f => f.name).join(';')})`)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1135
1192
|
// ── 2. Wave 重排 ──
|
|
1136
1193
|
const tasksDir = pJoin(changeDir, 'tasks')
|
|
1137
1194
|
if (existsSync(tasksDir)) {
|
package/src/stages/verify.js
CHANGED
|
@@ -134,7 +134,7 @@ export const definition = {
|
|
|
134
134
|
3. 数据模型是否符合
|
|
135
135
|
4. API 设计是否符合
|
|
136
136
|
5. **Reverse Sync 检查**:如果发现实现合理但 design.md 未覆盖,先更新 design.md 补充遗漏
|
|
137
|
-
6. **模块文档一致性检查**:如果在"加载规范并锚定"
|
|
137
|
+
6. **模块文档一致性检查**:如果在"加载规范并锚定"步骤中加载了模块文档,检查实现是否符合模块文档描述的当前设计(特别关注接口签名、数据流、依赖关系)。不一致时**当场同步模块文档**(这是 verify 的收尾义务,不是可选项);module-impact.md「更新结果」表的文档同步项也须在本阶段回填 done/skipped——CLI 在 verify --done 时硬校验该表无 pending/待办死信行,未清即阻断完成
|
|
138
138
|
7. **决策链路检查**:如果存在 decisions.md,输出 D-xxx@vN → FR-xxx → task-xx → evidence 的追踪矩阵;缺失项必须列为风险(CLI 只校验每个 D-xxx@vN ID 字面出现在 verify-result.md,warning 不阻断;矩阵的 D→FR→task→evidence 映射完整性供人类追溯,CLI 不校验——是否真覆盖由你诚实判定)
|
|
139
139
|
|
|
140
140
|
### 输出
|
package/src/sync.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlink
|
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { resolvePlatformSpecDir } from './progress.js';
|
|
14
14
|
import { safeGit } from './git-helper.js';
|
|
15
|
+
import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
|
|
15
16
|
|
|
16
17
|
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
17
18
|
function safePlatformSpecDir(cwd) {
|
|
@@ -27,9 +28,10 @@ const LOCAL_YAML = '.sillyspec/local.yaml';
|
|
|
27
28
|
const CHANGES_DIR = '.sillyspec/changes';
|
|
28
29
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
29
30
|
|
|
30
|
-
// 未连接平台是本地独立用户的合法默认状态。sync /
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
|
|
32
|
+
// 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
|
|
33
|
+
// 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
|
|
34
|
+
// 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
|
|
33
35
|
function debugLog(msg) {
|
|
34
36
|
if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
|
|
35
37
|
}
|
|
@@ -136,6 +138,13 @@ function writeLocalYamlRaw(cwd, text) {
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
function parseSimpleYaml(content) {
|
|
141
|
+
// 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
|
|
142
|
+
const unquote = (v) => {
|
|
143
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
144
|
+
return v.slice(1, -1);
|
|
145
|
+
}
|
|
146
|
+
return v;
|
|
147
|
+
};
|
|
139
148
|
const result = {};
|
|
140
149
|
let currentSection = null;
|
|
141
150
|
for (const line of content.split('\n')) {
|
|
@@ -147,7 +156,7 @@ function parseSimpleYaml(content) {
|
|
|
147
156
|
const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
|
|
148
157
|
if (m) {
|
|
149
158
|
const key = m[1];
|
|
150
|
-
const val = m[2].trim();
|
|
159
|
+
const val = unquote(m[2].trim());
|
|
151
160
|
if (val) {
|
|
152
161
|
result[key] = val;
|
|
153
162
|
currentSection = null;
|
|
@@ -159,7 +168,7 @@ function parseSimpleYaml(content) {
|
|
|
159
168
|
} else if (currentSection) {
|
|
160
169
|
const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
|
|
161
170
|
if (m && result[currentSection] && typeof result[currentSection] === 'object') {
|
|
162
|
-
result[currentSection][m[1]] = m[2].trim();
|
|
171
|
+
result[currentSection][m[1]] = unquote(m[2].trim());
|
|
163
172
|
}
|
|
164
173
|
}
|
|
165
174
|
}
|
|
@@ -296,46 +305,60 @@ export class SyncManager {
|
|
|
296
305
|
|
|
297
306
|
// 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
|
|
298
307
|
// (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
|
|
308
|
+
// YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
|
|
309
|
+
// 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
|
|
310
|
+
const yamlStr = (v) => JSON.stringify(String(v));
|
|
299
311
|
const platformEntries = [
|
|
300
|
-
` url: ${normalizedUrl}`,
|
|
301
|
-
` token: ${effectiveToken}`,
|
|
302
|
-
` last_connected: ${new Date().toISOString()}`,
|
|
312
|
+
` url: ${yamlStr(normalizedUrl)}`,
|
|
313
|
+
` token: ${yamlStr(effectiveToken)}`,
|
|
314
|
+
` last_connected: ${yamlStr(new Date().toISOString())}`,
|
|
303
315
|
];
|
|
304
316
|
if (resolvedUser) {
|
|
305
|
-
platformEntries.push(` user: ${resolvedUser}`);
|
|
317
|
+
platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
|
|
306
318
|
}
|
|
307
319
|
let text = readLocalYamlRaw(this.cwd);
|
|
308
320
|
text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
|
|
309
|
-
// mcp 段同源假设(design §7.4
|
|
321
|
+
// mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
|
|
322
|
+
// (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
|
|
323
|
+
// 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
|
|
310
324
|
if (findTopLevelSectionRange(text, 'mcp') === null) {
|
|
311
|
-
text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
|
|
325
|
+
text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
|
|
312
326
|
}
|
|
313
327
|
writeLocalYamlRaw(this.cwd, text);
|
|
314
328
|
}
|
|
315
329
|
|
|
316
330
|
/**
|
|
317
|
-
*
|
|
318
|
-
* 从 local.yaml 删除 platform
|
|
331
|
+
* 断开平台连接(三清,D-C@v2)。
|
|
332
|
+
* 1. 从 local.yaml 删除 platform 配置段;
|
|
333
|
+
* 2. 删恢复指针 <cwd>/.sillyspec-platform.json;
|
|
334
|
+
* 3. 删平台接管声明 <cwd>/.sillyspec-platform-managed。
|
|
335
|
+
* 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
|
|
336
|
+
* 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
|
|
319
337
|
*/
|
|
320
338
|
disconnect() {
|
|
321
339
|
const p = join(this.cwd, LOCAL_YAML);
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
340
|
+
if (existsSync(p)) {
|
|
341
|
+
const text = readFileSync(p, 'utf8');
|
|
342
|
+
if (findTopLevelSectionRange(text, 'platform') !== null) {
|
|
343
|
+
// 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
|
|
344
|
+
const newText = replaceTopLevelSection(text, 'platform', null);
|
|
345
|
+
if (newText.trim() === '') {
|
|
346
|
+
// 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
|
|
347
|
+
try { unlinkSync(p); } catch { /* best effort */ }
|
|
348
|
+
} else {
|
|
349
|
+
writeFileSync(p, newText, 'utf8');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
330
352
|
}
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
353
|
+
// 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)
|
|
354
|
+
let cleaned = [];
|
|
355
|
+
for (const f of ['.sillyspec-platform.json', PLATFORM_MANAGED_FILENAME]) {
|
|
356
|
+
const fp = join(this.cwd, f);
|
|
357
|
+
if (existsSync(fp)) {
|
|
358
|
+
try { unlinkSync(fp); cleaned.push(f); } catch { /* best effort */ }
|
|
359
|
+
}
|
|
338
360
|
}
|
|
361
|
+
if (cleaned.length > 0) console.log(`[sync] 已清理平台指针与接管声明: ${cleaned.join(', ')}`);
|
|
339
362
|
console.log('[sync] 已断开连接');
|
|
340
363
|
}
|
|
341
364
|
|
|
@@ -390,8 +413,8 @@ export class SyncManager {
|
|
|
390
413
|
if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
|
|
391
414
|
headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
|
|
392
415
|
|
|
393
|
-
// POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)
|
|
394
|
-
const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
|
|
416
|
+
// POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
|
|
417
|
+
const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
|
|
395
418
|
const res = await fetchJsonWithStatus(syncUrl, {
|
|
396
419
|
method: 'POST',
|
|
397
420
|
headers,
|
|
@@ -475,7 +498,7 @@ export class SyncManager {
|
|
|
475
498
|
return { synced: 0, errors: [...errors, '无可用文档'] };
|
|
476
499
|
}
|
|
477
500
|
|
|
478
|
-
const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
|
|
501
|
+
const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
|
|
479
502
|
const result = await fetchJson(docUrl, {
|
|
480
503
|
method: 'POST',
|
|
481
504
|
headers: {
|
|
@@ -510,7 +533,7 @@ export class SyncManager {
|
|
|
510
533
|
return { status: 'pending', reason: '未指定变更名称' };
|
|
511
534
|
}
|
|
512
535
|
|
|
513
|
-
const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
|
|
536
|
+
const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
|
|
514
537
|
const result = await fetchJson(approvalUrl, {
|
|
515
538
|
headers: { Authorization: `Bearer ${platform.token}` },
|
|
516
539
|
});
|
|
@@ -924,6 +947,11 @@ export async function collectStatus(cwd) {
|
|
|
924
947
|
return new SyncManager(cwd).collectStatus();
|
|
925
948
|
}
|
|
926
949
|
|
|
950
|
+
// 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
|
|
951
|
+
export function listConflictFiles(cwd) {
|
|
952
|
+
return new SyncManager(cwd).listConflictFiles();
|
|
953
|
+
}
|
|
954
|
+
|
|
927
955
|
// TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
|
|
928
956
|
// 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
|
|
929
957
|
/**
|
package/src/task-review.js
CHANGED
|
@@ -118,10 +118,16 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
118
118
|
report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// runId 解析:marker →
|
|
121
|
+
// runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
|
|
122
122
|
let runId = null
|
|
123
123
|
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
124
|
-
try {
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(marker)) {
|
|
126
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
127
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
128
|
+
if (isValidExecuteRunId(c)) runId = c
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
125
131
|
if (!runId) {
|
|
126
132
|
try {
|
|
127
133
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
@@ -436,7 +442,8 @@ export function validateTaskReviews(opts) {
|
|
|
436
442
|
} else if (repoKey !== 'main') {
|
|
437
443
|
// 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
|
|
438
444
|
// 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
|
|
439
|
-
|
|
445
|
+
// 坑7 教训:此分支发生时后续主仓校验必报「疑似伪造」,文案须直接指向真实排查方向。
|
|
446
|
+
warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验——下方若报 commit 不真实/疑似伪造,先查 repo 声明(plan.md 内联块或 tasks/task-NN.md 的 repo:)与 local.yaml repos: 注册,非 review 伪造`)
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
if (reviewGitDir) {
|
|
@@ -657,6 +664,18 @@ export function generateExecuteRunId() {
|
|
|
657
664
|
return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
658
665
|
}
|
|
659
666
|
|
|
667
|
+
/**
|
|
668
|
+
* execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
|
|
669
|
+
* marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
|
|
670
|
+
* join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
|
|
671
|
+
* (对齐 stage-review marker 的 review- 前缀校验范式)。
|
|
672
|
+
* @param {string} runId
|
|
673
|
+
* @returns {boolean}
|
|
674
|
+
*/
|
|
675
|
+
export function isValidExecuteRunId(runId) {
|
|
676
|
+
return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
|
|
677
|
+
}
|
|
678
|
+
|
|
660
679
|
/**
|
|
661
680
|
* 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
|
|
662
681
|
* marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
|
|
@@ -699,7 +718,13 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
|
699
718
|
|
|
700
719
|
export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
701
720
|
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
702
|
-
try {
|
|
721
|
+
try {
|
|
722
|
+
if (existsSync(marker)) {
|
|
723
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
724
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
725
|
+
if (isValidExecuteRunId(c)) return c
|
|
726
|
+
}
|
|
727
|
+
} catch {}
|
|
703
728
|
try {
|
|
704
729
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
705
730
|
if (existsSync(runsDir)) {
|
|
@@ -758,7 +783,13 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
758
783
|
// exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
|
|
759
784
|
const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
|
|
760
785
|
let executeRunId = ''
|
|
761
|
-
try {
|
|
786
|
+
try {
|
|
787
|
+
if (existsSync(runIdFile)) {
|
|
788
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
789
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
|
|
790
|
+
if (isValidExecuteRunId(c)) executeRunId = c
|
|
791
|
+
}
|
|
792
|
+
} catch {}
|
|
762
793
|
if (!executeRunId) {
|
|
763
794
|
executeRunId = generateExecuteRunId()
|
|
764
795
|
try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
|
|
@@ -915,7 +946,8 @@ export function getLatestExecuteRunId(runtimeRoot) {
|
|
|
915
946
|
try {
|
|
916
947
|
if (existsSync(markerPath)) {
|
|
917
948
|
const content = readFileSync(markerPath, 'utf8').trim()
|
|
918
|
-
if (content)
|
|
949
|
+
if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
|
|
950
|
+
if (isValidExecuteRunId(content)) return content
|
|
919
951
|
}
|
|
920
952
|
} catch {}
|
|
921
953
|
|
package/src/verify-postcheck.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 测试,避免 monorepo 全量测试超 gate timeout。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { execSync } from 'child_process'
|
|
18
|
+
import { execSync, execFileSync } from 'child_process'
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
20
20
|
import { join } from 'path'
|
|
21
21
|
import { verifyApiParity } from './contract-matrix.js'
|
|
@@ -367,14 +367,28 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
|
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
// refSpec 来源 meta.json(agent 可写),仅放行 git ref/range 安全字符(含 HEAD~1..HEAD 类区间),
|
|
371
|
+
// 防经 shell 的命令/参数注入。剥空白与引号/分号/反引号/$ 等 shell 元字符。
|
|
372
|
+
// 注:允许 ".."/"../.."(相对路径 ref 语法上合法)不影响安全——execFileSync 数组形式下它只是
|
|
373
|
+
// 一个 git rev 参数,git 自会报 not a valid ref,无 shell 解释面。
|
|
374
|
+
function assertSafeRefSpec(refSpec) {
|
|
375
|
+
if (typeof refSpec !== 'string') return null
|
|
376
|
+
if (!/^[A-Za-z0-9._~/^-]+$/.test(refSpec)) return null
|
|
377
|
+
if (/~~/.test(refSpec)) return null // 连续 ~(垃圾串)
|
|
378
|
+
return refSpec
|
|
379
|
+
}
|
|
380
|
+
|
|
370
381
|
/**
|
|
371
382
|
* 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
|
|
372
383
|
* git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
|
|
373
384
|
*/
|
|
374
385
|
function runGitDiffNameOnly(cwd, refSpec) {
|
|
375
386
|
try {
|
|
376
|
-
const
|
|
377
|
-
|
|
387
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
388
|
+
if (refSpec && !ref) return null
|
|
389
|
+
const args = ['diff', '--name-only']
|
|
390
|
+
if (ref) args.push(ref)
|
|
391
|
+
const out = execFileSync('git', args, {
|
|
378
392
|
cwd,
|
|
379
393
|
encoding: 'utf8',
|
|
380
394
|
timeout: 30 * 1000,
|
|
@@ -392,8 +406,11 @@ function runGitDiffNameOnly(cwd, refSpec) {
|
|
|
392
406
|
*/
|
|
393
407
|
function runGitDiffNameStatus(cwd, refSpec) {
|
|
394
408
|
try {
|
|
395
|
-
const
|
|
396
|
-
|
|
409
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
410
|
+
if (refSpec && !ref) return null
|
|
411
|
+
const args = ['diff', '--name-status']
|
|
412
|
+
if (ref) args.push(ref)
|
|
413
|
+
return execFileSync('git', args, {
|
|
397
414
|
cwd,
|
|
398
415
|
encoding: 'utf8',
|
|
399
416
|
timeout: 30 * 1000,
|
package/src/worktree-apply.js
CHANGED
|
@@ -877,7 +877,8 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
-
// 检查 6: diff
|
|
880
|
+
// 检查 6: diff 规模异常(两档:>5000 行 BLOCKED;2000~5000 行 WARNING——正常规模 change
|
|
881
|
+
// 常超 2000 行(如 +2368),单档硬拦把正常变更逼进 rescue cp 手动路径,见 ql-20260815-001 复盘)
|
|
881
882
|
const wtPath = meta?.worktreePath;
|
|
882
883
|
const diffBase = meta?.baselineCommit || meta?.baseHash;
|
|
883
884
|
let additions = 0, deletions = 0;
|
|
@@ -888,8 +889,10 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
888
889
|
const delMatch = shortstat?.match(/(\d+) deletion/);
|
|
889
890
|
additions = insMatch ? parseInt(insMatch[1]) : 0;
|
|
890
891
|
deletions = delMatch ? parseInt(delMatch[1]) : 0;
|
|
891
|
-
if (additions + deletions >
|
|
892
|
-
reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions}
|
|
892
|
+
if (additions + deletions > 5000) {
|
|
893
|
+
reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions} 行,超过 5000 硬上限)`);
|
|
894
|
+
} else if (additions + deletions > 2000) {
|
|
895
|
+
warnings.push(`diff 规模偏大(${additions} additions + ${deletions} deletions = ${additions + deletions} 行,2000~5000 区间放行为 WARNING,请确认属正常变更规模)`);
|
|
893
896
|
}
|
|
894
897
|
} catch {}
|
|
895
898
|
}
|
package/src/worktree-deps.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
|
-
import { execSync } from 'child_process';
|
|
13
|
+
import { execSync, execFileSync } from 'child_process';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
|
|
16
16
|
const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
|
|
@@ -118,10 +118,12 @@ function tryLink(mainNodeModules, linkPath) {
|
|
|
118
118
|
}
|
|
119
119
|
try {
|
|
120
120
|
if (process.platform === 'win32') {
|
|
121
|
-
|
|
121
|
+
// execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
|
|
122
|
+
// linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
|
|
123
|
+
execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
122
124
|
return { ok: true, method: 'junction' };
|
|
123
125
|
}
|
|
124
|
-
|
|
126
|
+
execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
125
127
|
return { ok: true, method: 'symlink' };
|
|
126
128
|
} catch (e) {
|
|
127
129
|
return { ok: false, error: e.message };
|
package/src/worktree.js
CHANGED
|
@@ -813,7 +813,8 @@ export class WorktreeManager {
|
|
|
813
813
|
try {
|
|
814
814
|
if (process.platform === 'win32') {
|
|
815
815
|
// Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
|
|
816
|
-
|
|
816
|
+
// execFileSync 数组形式:路径含引号/特殊字符时不会经 shell 解析(安全收敛,与 git-helper 同范式)
|
|
817
|
+
execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
|
|
817
818
|
} else {
|
|
818
819
|
unlinkSync(wtNodeModules);
|
|
819
820
|
}
|
|
@@ -946,7 +947,8 @@ export class WorktreeManager {
|
|
|
946
947
|
if (isLink) {
|
|
947
948
|
try {
|
|
948
949
|
if (process.platform === 'win32') {
|
|
949
|
-
|
|
950
|
+
// execFileSync 数组形式:不经 shell,防 meta.worktreePath 注入(安全收敛)
|
|
951
|
+
execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
|
|
950
952
|
} else {
|
|
951
953
|
unlinkSync(wtNodeModules);
|
|
952
954
|
}
|