sillyspec 3.28.7 → 3.28.9
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-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
package/src/progress.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
|
-
import { tmpdir } from 'os';
|
|
15
|
+
import { tmpdir, hostname } from 'os';
|
|
16
16
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
17
17
|
import { DB } from './db.js';
|
|
18
18
|
import { checkExecuteCodeEvidence } from './stage-contract.js';
|
|
@@ -81,6 +81,61 @@ export class PlatformManagedError extends PointerUnreachableError {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ── 会话标识三级解析(2026-09-14-change-ownership-guards task-02,FR-01 / D-001@v1)──
|
|
85
|
+
// 优先级:--session flag > env SILLYSPEC_SESSION_ID > quick 场景 changeName(quick-<8hex>,
|
|
86
|
+
// 既有 sessionId 机制——guard 按 change 名落盘天然跨进程)> anon@<hostname> 机器级降级。
|
|
87
|
+
// 降级时打一次性教学 warning:指引 export SILLYSPEC_SESSION_ID=<唯一标识> 或 --session,
|
|
88
|
+
// 明示无标识时同机并行不设防(R-01 明示局限,防线=显式标识铁律+归档收口+--takeover 摩擦)。
|
|
89
|
+
// 「一次性」双层降频:进程内 memo(同进程多次解析只报一次)+ 跨进程 marker 文件窗口
|
|
90
|
+
// (warnSelfRefPointerOnce 同款模式;CLI 每次调用即新进程,纯进程 memo 会退化成每命令必报)。
|
|
91
|
+
const ANON_SESSION_WARN_MARKER = 'anon-session-warn.json';
|
|
92
|
+
const ANON_SESSION_WARN_WINDOW_MS = 24 * 60 * 60 * 1000; // 教学指引 24h 窗口(非告警,不设短窗)
|
|
93
|
+
const QUICK_SID_PATTERN_FOR_IDENTITY = /^quick-[0-9a-f]{8}$/;
|
|
94
|
+
let _anonSessionWarnedThisProcess = false;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析本会话的所有权标识(纯函数 + 降级侧的一次性教学 warn)。
|
|
98
|
+
* @param {{ flagSession?: string|null, quickChangeName?: string|null, cwd?: string|null, warn?: boolean }} [opts]
|
|
99
|
+
* - flagSession:--session flag 值(最高优先级;空串/空白视为未提供)
|
|
100
|
+
* - quickChangeName:quick 场景的 changeName(匹配 quick-<8hex> 才启用第三级)
|
|
101
|
+
* - cwd:教学 warning marker 落盘根(不提供则跳过跨进程降频,仅进程内 memo)
|
|
102
|
+
* - warn:false 关闭教学 warning(机器消费路径)
|
|
103
|
+
* @returns {{session: string, source: 'flag'|'env'|'quick-session'|'anon-host'}}
|
|
104
|
+
*/
|
|
105
|
+
export function resolveSessionIdentity({ flagSession = null, quickChangeName = null, cwd = null, warn = true } = {}) {
|
|
106
|
+
const flag = typeof flagSession === 'string' ? flagSession.trim() : '';
|
|
107
|
+
if (flag) return { session: flag, source: 'flag' };
|
|
108
|
+
const env = typeof process.env.SILLYSPEC_SESSION_ID === 'string' ? process.env.SILLYSPEC_SESSION_ID.trim() : '';
|
|
109
|
+
if (env) return { session: env, source: 'env' };
|
|
110
|
+
if (quickChangeName && QUICK_SID_PATTERN_FOR_IDENTITY.test(quickChangeName)) {
|
|
111
|
+
return { session: quickChangeName, source: 'quick-session' };
|
|
112
|
+
}
|
|
113
|
+
const session = `anon@${hostname()}`;
|
|
114
|
+
if (warn && !_anonSessionWarnedThisProcess) {
|
|
115
|
+
let suppressed = false;
|
|
116
|
+
if (cwd) {
|
|
117
|
+
try {
|
|
118
|
+
const markerDir = join(cwd, SPEC_DIR_NAME, RUNTIME_SUBDIR);
|
|
119
|
+
mkdirSync(markerDir, { recursive: true });
|
|
120
|
+
const markerPath = join(markerDir, ANON_SESSION_WARN_MARKER);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
try {
|
|
123
|
+
const m = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
124
|
+
if (m && Number.isFinite(m.at) && now - m.at < ANON_SESSION_WARN_WINDOW_MS) suppressed = true;
|
|
125
|
+
} catch { /* 无 marker / 损坏 → 首报 */ }
|
|
126
|
+
if (!suppressed) writeFileSync(markerPath, JSON.stringify({ at: now }) + '\n');
|
|
127
|
+
} catch { /* marker 落盘失败 → 照常 warn(降频是优化不是语义) */ }
|
|
128
|
+
}
|
|
129
|
+
if (!suppressed) {
|
|
130
|
+
_anonSessionWarnedThisProcess = true;
|
|
131
|
+
console.warn(`⚠️ 未显式配置会话标识,所有权护栏已降级为机器级标识 ${session}(只拦他机,同机并行会话不设防)`);
|
|
132
|
+
console.warn(` 指引:会话启动时 export SILLYSPEC_SESSION_ID=<唯一标识>(如 agent 名+任务名),或在接管类命令上带 --session <唯一标识>(三级解析最高优先级)`);
|
|
133
|
+
console.warn(` (24 小时内不重复提示)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { session, source: 'anon-host' };
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/**
|
|
85
140
|
* 平台感知的 specDir 解析(fail-closed,所有 CLI 子命令统一入口)。
|
|
86
141
|
*
|
|
@@ -435,7 +490,7 @@ export class ProgressManager {
|
|
|
435
490
|
}
|
|
436
491
|
|
|
437
492
|
const result = {
|
|
438
|
-
_version:
|
|
493
|
+
_version: 6,
|
|
439
494
|
project: projectName,
|
|
440
495
|
currentChange: cName,
|
|
441
496
|
currentStage: currentStage || '',
|
|
@@ -460,7 +515,9 @@ export class ProgressManager {
|
|
|
460
515
|
* 输出契约(字段名 = DB 列名 snake_case,import 直接对列写回,round-trip 无映射损耗):
|
|
461
516
|
* project { name, schema_version } —— 全局单行稳定字段(id/created_at/updated_at 本地元数据不同步)
|
|
462
517
|
* changes [{ name, current_stage, status, last_active,
|
|
463
|
-
* last_synced_platform_ts, last_local_modified_ts }]
|
|
518
|
+
* last_synced_platform_ts, last_local_modified_ts, owner_session }] —— 只投影流程进度列
|
|
519
|
+
* + 所有权列 owner_session(v6,2026-09-14-change-ownership-guards D-005@v1;
|
|
520
|
+
* NULL=无主随 payload 带出,平台消费端不强制);
|
|
464
521
|
* 排除 isolation_* 系列 与 platform_* 系列(platform_change_id/workspace_id/last_sync/sync_enabled)
|
|
465
522
|
* 以及 created_at(本地强相关,不同步,B2)
|
|
466
523
|
* stages [{ change_name, stage, status, started_at, completed_at,
|
|
@@ -490,9 +547,9 @@ export class ProgressManager {
|
|
|
490
547
|
const db = this._ensureDB(cwd);
|
|
491
548
|
const sqlDb = db.getDb();
|
|
492
549
|
|
|
493
|
-
// 1. changes 行(投影流程进度列 + name
|
|
550
|
+
// 1. changes 行(投影流程进度列 + name 标识 + 所有权列 owner_session)
|
|
494
551
|
const changeRow = sqlDb.prepare(
|
|
495
|
-
`SELECT id, name, current_stage, status, last_active, last_synced_platform_ts, last_local_modified_ts
|
|
552
|
+
`SELECT id, name, current_stage, status, last_active, last_synced_platform_ts, last_local_modified_ts, owner_session
|
|
496
553
|
FROM changes WHERE name = ?`
|
|
497
554
|
).get(changeName);
|
|
498
555
|
if (changeRow === undefined) return null;
|
|
@@ -504,6 +561,7 @@ export class ProgressManager {
|
|
|
504
561
|
last_active: changeRow.last_active,
|
|
505
562
|
last_synced_platform_ts: changeRow.last_synced_platform_ts ?? null,
|
|
506
563
|
last_local_modified_ts: changeRow.last_local_modified_ts ?? null,
|
|
564
|
+
owner_session: changeRow.owner_session ?? null,
|
|
507
565
|
};
|
|
508
566
|
|
|
509
567
|
// 2. project 全局单行(只投影稳定字段 name/schema_version;created_at/updated_at 是本地库元数据,
|
|
@@ -600,7 +658,8 @@ export class ProgressManager {
|
|
|
600
658
|
*
|
|
601
659
|
* 语义:
|
|
602
660
|
* - 单个 DB.transaction() 包裹,原子重建 stages/steps/batch_progress/approvals 四表(任一失败整体回滚)
|
|
603
|
-
* - changes 行用 UPDATE 选择投影列(current_stage/status/last_active/last_synced_platform_ts/last_local_modified_ts
|
|
661
|
+
* - changes 行用 UPDATE 选择投影列(current_stage/status/last_active/last_synced_platform_ts/last_local_modified_ts
|
|
662
|
+
* + owner_session 容错回写——payload 含该列才写),
|
|
604
663
|
* 保留 isolation_* / platform_* / created_at(本地强相关状态不被覆盖,B2;change 不存在时 INSERT 兜底平台新增)
|
|
605
664
|
* - import 后 last_synced_platform_ts 与 last_local_modified_ts 均置为 progressObj.pushed_at(D-013 例外:
|
|
606
665
|
* 不更新 now()——否则 now()>base_ts 下次 pull 误判冲突;pushed_at 由 sync.js pull() 从响应 header attach)
|
|
@@ -673,6 +732,12 @@ export class ProgressManager {
|
|
|
673
732
|
ch && ch.last_active != null ? ch.last_active : now,
|
|
674
733
|
pushedAt, pushedAt, cn
|
|
675
734
|
);
|
|
735
|
+
// owner_session 回写容错(v6,2026-09-14-change-ownership-guards task-01):payload 含
|
|
736
|
+
// 该列(非 null)才写——他端旧版本快照无此列 → 跳过不报错,且本地已有 owner 不被置空
|
|
737
|
+
// (平台权威态随 payload 往返;serializeForSync↔import 互逆在非 null 值上成立)。
|
|
738
|
+
if (ch && ch.owner_session != null) {
|
|
739
|
+
sqlDb.prepare('UPDATE changes SET owner_session = ? WHERE name = ?').run(ch.owner_session, cn);
|
|
740
|
+
}
|
|
676
741
|
const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
|
|
677
742
|
const changeId = changeRow.id;
|
|
678
743
|
|
|
@@ -806,6 +871,22 @@ export class ProgressManager {
|
|
|
806
871
|
* 3. 单进程内调用串行(design §9),事务窗口短(毫秒级),全量写正确性远高于 diff 的边际收益。
|
|
807
872
|
* 结论:全量 UPSERT 的简单正确性 > 持锁窗口优化的边际收益,不强行改 diff。
|
|
808
873
|
*/
|
|
874
|
+
/** .bak 快照节流间隔(坑2②):5 分钟——损坏回退最坏丢 5 分钟进度,换取每次写零额外负担 */
|
|
875
|
+
static SNAPSHOT_INTERVAL_MS = 5 * 60_000;
|
|
876
|
+
|
|
877
|
+
_maybeSnapshotBak(cwd) {
|
|
878
|
+
try {
|
|
879
|
+
const dbPath = this._runtimePath(cwd, 'sillyspec.db');
|
|
880
|
+
const bakPath = dbPath + '.bak';
|
|
881
|
+
const st = existsSync(bakPath) ? statSync(bakPath) : null;
|
|
882
|
+
if (st && Date.now() - st.mtimeMs < ProgressManager.SNAPSHOT_INTERVAL_MS) return;
|
|
883
|
+
const tmpPath = bakPath + '.tmp';
|
|
884
|
+
try { unlinkSync(tmpPath) } catch { /* 不存在即无事 */ }
|
|
885
|
+
this._ensureDB(cwd).getDb().exec(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`);
|
|
886
|
+
renameSync(tmpPath, bakPath);
|
|
887
|
+
} catch { /* best-effort:快照失败只损失恢复源新鲜度,不影响写入主流程 */ }
|
|
888
|
+
}
|
|
889
|
+
|
|
809
890
|
_write(cwd, data, changeName = null) {
|
|
810
891
|
const cn = changeName || data.currentChange;
|
|
811
892
|
if (!cn) {
|
|
@@ -813,6 +894,13 @@ export class ProgressManager {
|
|
|
813
894
|
return;
|
|
814
895
|
}
|
|
815
896
|
|
|
897
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑2②(2026-09-08 实证):并发写
|
|
898
|
+
// 把主库打成 0 字节时 .bak 全空——fail-loud 有其设计正当性但无恢复出路,只能删库重放。
|
|
899
|
+
// 定期 .bak 快照(5 分钟龄控)让 db.js _openWithFallback 的既有回退链获得新鲜恢复源:
|
|
900
|
+
// 损坏最坏回退到 ≤5min 前的进度,而不是从零重放。VACUUM INTO 产出一致性快照(对 WAL
|
|
901
|
+
// 并发写安全;比裸 copyFileSync 正确),best-effort 失败不影响写入主流程。
|
|
902
|
+
this._maybeSnapshotBak(cwd);
|
|
903
|
+
|
|
816
904
|
const db = this._ensureDB(cwd);
|
|
817
905
|
db.transaction(() => {
|
|
818
906
|
const sqlDb = db.getDb();
|
|
@@ -915,7 +1003,7 @@ export class ProgressManager {
|
|
|
915
1003
|
// 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
|
|
916
1004
|
getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }
|
|
917
1005
|
|
|
918
|
-
registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
|
|
1006
|
+
registerChange(cwd, changeName, opts) { return this._changeRegistry.registerChange(cwd, changeName, opts); }
|
|
919
1007
|
|
|
920
1008
|
updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
|
|
921
1009
|
|
|
@@ -949,6 +1037,28 @@ export class ProgressManager {
|
|
|
949
1037
|
// quick --done 兜底复用启动 ql-ID(坑 platform-takeover-phantom-progress-db 同日变体)
|
|
950
1038
|
getQuicklogId(cwd, changeName) { return this._changeRegistry.getQuicklogId(cwd, changeName); }
|
|
951
1039
|
|
|
1040
|
+
// ── change 所有权读写 API(2026-09-14-change-ownership-guards task-01 数据载体,D-001/D-005@v1)──
|
|
1041
|
+
// 只铺数据读写;所有权判定/心跳窗口/接管语义(assertChangeOwnership)归 task-02 接线。
|
|
1042
|
+
|
|
1043
|
+
/** 读变更所有者会话标识;无行/未登记/无主 → null */
|
|
1044
|
+
getChangeOwner(cwd, changeName) { return this._changeRegistry.getChangeOwner(cwd, changeName); }
|
|
1045
|
+
|
|
1046
|
+
/** 认领所有权:首建写 own、已有值不覆盖;返回实际 owner(string|null) */
|
|
1047
|
+
claimChangeOwner(cwd, changeName, session) { return this._changeRegistry.claimChangeOwner(cwd, changeName, session); }
|
|
1048
|
+
|
|
1049
|
+
// task-02 接线(FR-01 / D-001@v1):判定/接管/心跳窗——会话标识三级解析见模块级
|
|
1050
|
+
// resolveSessionIdentity;接线点=index.js apply/cleanup/assess 自动 apply(锁内、实际动作前)。
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* 所有权判定(纯读不写库,详见 change-registry.assertChangeOwnership):
|
|
1054
|
+
* 四分支 self/no-owner/takeover-stale/takeover-forced 放行;他人且活跃窗内 →
|
|
1055
|
+
* { allowed:false, action:'blocked-active-owner' }(owner+lastActive+heartbeatMs 供结构化错误)。
|
|
1056
|
+
*/
|
|
1057
|
+
assertChangeOwnership(cwd, changeName, opts) { return this._changeRegistry.assertChangeOwnership(cwd, changeName, opts); }
|
|
1058
|
+
|
|
1059
|
+
/** 强制重写所有者+心跳起算(接管放行后的写库侧;claimChangeOwner 是「已有值不覆盖」守卫版) */
|
|
1060
|
+
setChangeOwner(cwd, changeName, session) { return this._changeRegistry.setChangeOwner(cwd, changeName, session); }
|
|
1061
|
+
|
|
952
1062
|
// ── CLI 命令 ──
|
|
953
1063
|
|
|
954
1064
|
init(cwd) {
|
|
@@ -995,10 +1105,15 @@ export class ProgressManager {
|
|
|
995
1105
|
}
|
|
996
1106
|
// quick 会话 id(quick-<uuid8>,见 run.js QUICK_SID_RE)只作 progress 的跨进程 session key,
|
|
997
1107
|
// 进度存 SQL 不需要实体 change 目录——跳过避免 changes/quick-<uuid>/ 空目录残留。
|
|
1108
|
+
// default 兜底 key 同款待遇(坑 default-empty-dir-materialized,2026-09-14 用户反馈):
|
|
1109
|
+
// default 行是辅助阶段(explore 等)无实体产物的进度容器,物化 changes/default/ 空目录
|
|
1110
|
+
// 纯污染——next.js 会报「变更目录为空→清理该空目录」误导、resolveChangeNameAuto 的目录
|
|
1111
|
+
// 计数被搅动、spec 树上行空目录(本仓实证 changes/default/ 建于 09-11 零文件)。进度真相
|
|
1112
|
+
// 在 DB 行;辅助阶段续跑锚定改由 run/command.js 的 default 行阶段亲和解析承担。
|
|
998
1113
|
// 注:日期前缀门禁(assertDatedChangeName)只在 CLI 边界强制(run --change / change-rename,
|
|
999
1114
|
// 见 run/command.js + index.js)——initChange 是库函数,测试/平台工具合法用任意名建 fixture,
|
|
1000
1115
|
// 不在此设门(2026-09-11 决策,避免 ~30 处测试 fixture 非日期名被拦)。
|
|
1001
|
-
if (!/^quick-[0-9a-f]{8}$/.test(changeName)) {
|
|
1116
|
+
if (!/^quick-[0-9a-f]{8}$/.test(changeName) && changeName !== 'default') {
|
|
1002
1117
|
this._ensureChangeDir(cwd, changeName);
|
|
1003
1118
|
}
|
|
1004
1119
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-gate-profile.js — quick 出口分级门禁画像(信号层,纯函数零 IO)
|
|
3
|
+
*
|
|
4
|
+
* quick --done 分级门禁的信号单一来源(2026-09-14-quick-exit-tiered-gates task-01 / FR-02):
|
|
5
|
+
* 输入 CLI 审计链的 git 事实 changedFiles(非 --files 自声明)+ 调用方传入的 moduleIndex
|
|
6
|
+
* (_module-map.yaml 解析结果),输出 L0/L1/L2 画像与检查项,供 Wave 2 接线消费
|
|
7
|
+
* (run/shared.js 挂 review.gateProfile → complete-handlers 落账 / quick-audit 打印 /
|
|
8
|
+
* scope-audit 表格与 --json 双出口)。本模块零 IO、零子进程:moduleIndex、风险表、阈值
|
|
9
|
+
* 全部由参数或默认值提供(D-007 独立纯函数模块;R-04 画像=纯字符串运算)。
|
|
10
|
+
*
|
|
11
|
+
* 判级(阈值只引用 THRESHOLDS 常量,不散落字面量):
|
|
12
|
+
* 正常态(moduleIndex 可用):L2 = 跨 ≥L2_SPAN 模块 或 风险命中;L1 = 跨 ≥L1_SPAN 模块
|
|
13
|
+
* 或 ≥L1_FILES 文件;否则 L0。
|
|
14
|
+
* 降级态(moduleIndex 为 null/undefined/空,如未 scan / 无 module-map 的存量项目):
|
|
15
|
+
* span 退出判级,L2 = ≥L2_FILES_DEGRADED 文件 或 风险命中;L1 = ≥L1_FILES 文件。
|
|
16
|
+
* 全部 advisory(D-003:升 blocking 是另立变更的显式决策),不抛错不阻断。
|
|
17
|
+
*
|
|
18
|
+
* 计数口径(design 接口定义,勿自创字段):
|
|
19
|
+
* fileCount = 全部变更文件数(判级用的「文件数」信号与之一致——与旧「≤3 文件」规则
|
|
20
|
+
* 及 sillyhub 校准统计同基);
|
|
21
|
+
* testFileCount = 非文档文件中:路径含 __tests__/、/tests/ 目录段、basename test_ 前缀
|
|
22
|
+
* (python 风格)或 .test./_test./.spec. 命名(对齐仓内既有测试判定先例);
|
|
23
|
+
* codeFileCount = 其余非文档文件。文档口径沿用 docSyncHint 的 isDoc(run/shared.js
|
|
24
|
+
* auditQuickCompletion 内 isDocFile 同款:.md/.yaml/.yml 扩展或 docs/、
|
|
25
|
+
* .sillyspec/docs/ 前缀——模块卡与 changelog sidecar 均 .md 天然覆盖),
|
|
26
|
+
* 文档文件不计入 testFileCount/codeFileCount,也不参与模块归属与风险命中。
|
|
27
|
+
*/
|
|
28
|
+
import { QUICK_RISK_PATH_PATTERNS } from './change-risk-profile.js'
|
|
29
|
+
|
|
30
|
+
/** 代码内默认阈值(task-05 真实图谱校准定稿,缺省行为的单一事实源;local.yaml quick-gate 段可覆写,D-009)。
|
|
31
|
+
* 定稿依据(2026-09-14,sillyhub 919 条 quicklog × 5 份真实 _module-map.yaml 重算交叉表,
|
|
32
|
+
* 证据全文见主仓 changes/2026-09-14-quick-exit-tiered-gates/design.md「阈值校准记录」):
|
|
33
|
+
* L1_SPAN=2 落在文档同步率断崖上(≤3 文件档 span1 33.3% → span2-3 5.9%,近期口径);
|
|
34
|
+
* L2_SPAN=4 边界内格子全档最差(近期 span4+×4-10 文件 0-14.3%);L1_FILES=4 维持旧规则
|
|
35
|
+
* 「≤3 文件」边界连续性(文件维度无同步率断崖——量错维度的实证本身);L2_FILES_DEGRADED=8
|
|
36
|
+
* 覆盖降级档最差文件桶 7-10(32.9-35.3%)。四值与 brainstorm 初值一致,维持不调。 */
|
|
37
|
+
export const THRESHOLDS = {
|
|
38
|
+
L1_SPAN: 2, // 跨 ≥2 模块 → L1
|
|
39
|
+
L1_FILES: 4, // 或 ≥4 文件 → L1
|
|
40
|
+
L2_SPAN: 4, // 跨 ≥4 模块 → L2
|
|
41
|
+
L2_FILES_DEGRADED: 8, // module-map 缺失降级档:≥8 文件 → L2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** local.yaml quick-gate 键 → THRESHOLDS 常量键(覆写面与代码默认值的单一对照)。 */
|
|
45
|
+
const GATE_KEY_TO_THRESHOLD = {
|
|
46
|
+
l1_span: 'L1_SPAN',
|
|
47
|
+
l1_files: 'L1_FILES',
|
|
48
|
+
l2_span: 'L2_SPAN',
|
|
49
|
+
l2_files_degraded: 'L2_FILES_DEGRADED',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 阈值合法性:≥1 的有限整数(0/负数会让一切改动升 L1、浮点/字符串无意义)。 */
|
|
53
|
+
function isValidThreshold(v) {
|
|
54
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 合并 local.yaml quick-gate 段覆写与代码默认值(D-009)。
|
|
59
|
+
* @param {object} config local.yaml 解析结果(无 quick-gate 段 → 全默认;键非法回退默认并 warn)
|
|
60
|
+
* @returns {{ l1_span: number, l1_files: number, l2_span: number, l2_files_degraded: number }}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveGateThresholds(config) {
|
|
63
|
+
const merged = {
|
|
64
|
+
l1_span: THRESHOLDS.L1_SPAN,
|
|
65
|
+
l1_files: THRESHOLDS.L1_FILES,
|
|
66
|
+
l2_span: THRESHOLDS.L2_SPAN,
|
|
67
|
+
l2_files_degraded: THRESHOLDS.L2_FILES_DEGRADED,
|
|
68
|
+
}
|
|
69
|
+
const section = config == null ? undefined : config['quick-gate']
|
|
70
|
+
if (section == null) return merged
|
|
71
|
+
if (typeof section !== 'object' || Array.isArray(section)) {
|
|
72
|
+
console.warn('[quick-gate] local.yaml quick-gate 段须为键值对象,已忽略(使用默认阈值)')
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
for (const [key, constName] of Object.entries(GATE_KEY_TO_THRESHOLD)) {
|
|
76
|
+
if (!(key in section)) continue
|
|
77
|
+
const v = section[key]
|
|
78
|
+
if (isValidThreshold(v)) merged[key] = v
|
|
79
|
+
else console.warn(`[quick-gate] local.yaml 配置键 quick-gate.${key}=${JSON.stringify(v)} 非法(须为 ≥1 整数),回退默认 ${THRESHOLDS[constName]}`)
|
|
80
|
+
}
|
|
81
|
+
return merged
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 计数口径判定(文档/测试/代码三分;口径见文件头注释) ──
|
|
85
|
+
|
|
86
|
+
/** 文档文件口径:沿用 docSyncHint 的 isDoc(run/shared.js auditQuickCompletion 内 isDocFile 逐款同源)。 */
|
|
87
|
+
function isDocPath(p) {
|
|
88
|
+
return p.endsWith('.md') || p.endsWith('.yaml') || p.endsWith('.yml') ||
|
|
89
|
+
p.startsWith('docs/') || p.startsWith('.sillyspec/docs/')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** __tests__/ 或 tests/ 目录段(/test_ 前缀与命名判定在 isTestPath 内)。 */
|
|
93
|
+
const TEST_DIR_RE = /(^|\/)(__tests__|tests)\//i
|
|
94
|
+
|
|
95
|
+
/** 测试文件判定:目录段、basename test_ 前缀、.test./_test./.spec. 命名三信号任一命中。 */
|
|
96
|
+
function isTestPath(p) {
|
|
97
|
+
if (TEST_DIR_RE.test(p)) return true
|
|
98
|
+
const base = p.slice(p.lastIndexOf('/') + 1)
|
|
99
|
+
if (/^test_/i.test(base)) return true
|
|
100
|
+
return /[._](test|spec)\.[^.]+$/i.test(base)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** moduleIndex 兼容两种形态:parseModuleMapSimple 扁平 { id: {...} } 或 { modules: {...} } 包装;空 → null(degraded)。 */
|
|
104
|
+
function resolveModulesObject(moduleIndex) {
|
|
105
|
+
if (moduleIndex == null || typeof moduleIndex !== 'object') return null
|
|
106
|
+
const wrapped = moduleIndex.modules
|
|
107
|
+
const obj = wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped) ? wrapped : moduleIndex
|
|
108
|
+
return Object.keys(obj).length > 0 ? obj : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 一级归属:paths/core_files 字面量或目录前缀命中(与 docs-debt matchFilesToModules 一级口径同款,纯内存零 IO)。 */
|
|
112
|
+
function matchModuleForFile(posix, modulesObj) {
|
|
113
|
+
for (const [id, m] of Object.entries(modulesObj)) {
|
|
114
|
+
if (m == null || typeof m !== 'object') continue
|
|
115
|
+
const prefixes = [...(Array.isArray(m.paths) ? m.paths : []), ...(Array.isArray(m.core_files) ? m.core_files : [])]
|
|
116
|
+
for (const raw of prefixes) {
|
|
117
|
+
const p = String(raw).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
118
|
+
if (!p) continue
|
|
119
|
+
if (posix === p || posix.startsWith(p + '/')) return id
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 卡片认领判定:模块 doc 字段(如 modules/core-engine.md)对应卡片文件在 changedFiles 内。
|
|
126
|
+
* changedFiles 为仓库根相对路径(如 .sillyspec/docs/<project>/modules/core-engine.md),
|
|
127
|
+
* 本函数无 cwd/project 入参,按字面量或后缀对齐。 */
|
|
128
|
+
function cardInFiles(doc, fileSet) {
|
|
129
|
+
const d = doc.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
130
|
+
for (const f of fileSet) {
|
|
131
|
+
if (f === d || f.endsWith('/' + d)) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* quick 出口门禁画像(纯函数:无 IO、无子进程,moduleIndex/风险表/阈值全由参数或默认值)。
|
|
138
|
+
* @param {string[]} changedFiles 变更文件(CLI 审计链 git 事实;反斜杠自动归一 POSIX)
|
|
139
|
+
* @param {object|null} moduleIndex _module-map.yaml 解析结果(null/undefined/空 → degraded)
|
|
140
|
+
* @param {object} [opts]
|
|
141
|
+
* @param {Array<{pattern: string, re: RegExp}>} [opts.riskTable] 路径模式风险表(默认 QUICK_RISK_PATH_PATTERNS)
|
|
142
|
+
* @param {object} [opts.thresholds] 已合并阈值(resolveGateThresholds 产物;缺省用 THRESHOLDS,D-009)
|
|
143
|
+
* @param {Array<{path: string}>} [opts.fileNotes] --file-notes 解析结果(perFileNotes 覆盖率判定)
|
|
144
|
+
* @param {boolean} [opts.noDocs] --no-docs 显式豁免(docClaim → exempt-no-docs)
|
|
145
|
+
* @returns 画像对象(字段与判级规则见 design.md 接口定义)
|
|
146
|
+
*/
|
|
147
|
+
export function computeGateProfile(changedFiles, moduleIndex, opts = {}) {
|
|
148
|
+
const riskTable = Array.isArray(opts.riskTable) ? opts.riskTable : QUICK_RISK_PATH_PATTERNS
|
|
149
|
+
|
|
150
|
+
// 阈值:opts.thresholds 逐键取合法值,否则回 THRESHOLDS 默认(warn 责任在 resolveGateThresholds)
|
|
151
|
+
const th = {}
|
|
152
|
+
for (const [key, constName] of Object.entries(GATE_KEY_TO_THRESHOLD)) {
|
|
153
|
+
const v = opts.thresholds ? opts.thresholds[key] : undefined
|
|
154
|
+
th[key] = isValidThreshold(v) ? v : THRESHOLDS[constName]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const files = (Array.isArray(changedFiles) ? changedFiles : [])
|
|
158
|
+
.map((f) => String(f).replace(/\\/g, '/'))
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
|
|
161
|
+
const notedPaths = new Set(
|
|
162
|
+
(Array.isArray(opts.fileNotes) ? opts.fileNotes : [])
|
|
163
|
+
.filter((n) => n != null && typeof n.path === 'string' && typeof n.note === 'string' && n.note.trim() !== '')
|
|
164
|
+
.map((n) => n.path.replace(/\\/g, '/')),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const codeFiles = []
|
|
168
|
+
const testFiles = []
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
if (isDocPath(f)) continue // 文档文件不计入两者、不参与归属与风险命中(口径见文件头)
|
|
171
|
+
if (isTestPath(f)) testFiles.push(f)
|
|
172
|
+
else codeFiles.push(f)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const modulesObj = resolveModulesObject(moduleIndex)
|
|
176
|
+
const degraded = modulesObj === null
|
|
177
|
+
|
|
178
|
+
// 模块归属(非文档文件 × paths/core_files 前缀聚类);degraded 时不算,modules=[]、unmappedFiles=[]
|
|
179
|
+
const modules = []
|
|
180
|
+
const unmappedFiles = []
|
|
181
|
+
if (!degraded) {
|
|
182
|
+
const byModule = new Map()
|
|
183
|
+
for (const f of [...codeFiles, ...testFiles]) {
|
|
184
|
+
const id = matchModuleForFile(f, modulesObj)
|
|
185
|
+
if (id == null) { unmappedFiles.push(f); continue }
|
|
186
|
+
const entry = byModule.get(id)
|
|
187
|
+
if (entry) entry.files.push(f)
|
|
188
|
+
else byModule.set(id, { id, files: [f] })
|
|
189
|
+
}
|
|
190
|
+
modules.push(...byModule.values())
|
|
191
|
+
}
|
|
192
|
+
const moduleSpan = degraded ? null : modules.length
|
|
193
|
+
|
|
194
|
+
// 风险命中:非文档文件 × 路径模式表(v1 仅路径模式,无 diff 关键词维度;元素只含 pattern/file)
|
|
195
|
+
const riskHits = []
|
|
196
|
+
for (const f of [...codeFiles, ...testFiles]) {
|
|
197
|
+
for (const entry of riskTable) {
|
|
198
|
+
const re = entry == null ? null : entry.re
|
|
199
|
+
if (!(re instanceof RegExp)) continue
|
|
200
|
+
if (re.global) re.lastIndex = 0 // 防调用方传入 /g 正则跨文件携带 lastIndex 状态
|
|
201
|
+
if (re.test(f)) riskHits.push({ pattern: entry.pattern, file: f })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 判级:正常态 span 参与;degraded 态 span 退出(L1=≥l1_files、L2=≥l2_files_degraded 或风险命中)
|
|
206
|
+
let level = 'L0'
|
|
207
|
+
if (degraded) {
|
|
208
|
+
if (files.length >= th.l2_files_degraded || riskHits.length > 0) level = 'L2'
|
|
209
|
+
else if (files.length >= th.l1_files) level = 'L1'
|
|
210
|
+
} else if (moduleSpan >= th.l2_span || riskHits.length > 0) level = 'L2'
|
|
211
|
+
else if (moduleSpan >= th.l1_span || files.length >= th.l1_files) level = 'L1'
|
|
212
|
+
|
|
213
|
+
// L1:--file-notes 覆盖率(changedFiles 全集均有非空注记 → true;`path::` 空括注不算——防形式化绕过;
|
|
214
|
+
// 空集空真 true——L0 不消费)
|
|
215
|
+
const perFileNotes = files.every((f) => notedPaths.has(f))
|
|
216
|
+
|
|
217
|
+
// L1 机械规则:codeFileCount ≥2 且 testFileCount===0 → missing;codeFileCount ≤1 → na;其余 ok
|
|
218
|
+
let testDelta
|
|
219
|
+
if (codeFiles.length <= 1) testDelta = 'na'
|
|
220
|
+
else if (testFiles.length === 0) testDelta = 'missing'
|
|
221
|
+
else testDelta = 'ok'
|
|
222
|
+
|
|
223
|
+
// L2:模块文档认领(触及模块中带 doc 卡片的模块,其卡片文件全在 changedFiles → claimed;
|
|
224
|
+
// 无带卡片的触及模块(含 degraded)→ 无可认领,空真 claimed 不出假 advisory;--no-docs 优先豁免)
|
|
225
|
+
let docClaim
|
|
226
|
+
if (opts.noDocs) docClaim = 'exempt-no-docs'
|
|
227
|
+
else {
|
|
228
|
+
const fileSet = new Set(files)
|
|
229
|
+
const claimable = modules.filter((e) => {
|
|
230
|
+
const m = modulesObj == null ? null : modulesObj[e.id]
|
|
231
|
+
return m != null && typeof m === 'object' && typeof m.doc === 'string' && m.doc.trim() !== ''
|
|
232
|
+
})
|
|
233
|
+
docClaim = claimable.every((e) => cardInFiles(modulesObj[e.id].doc, fileSet)) ? 'claimed' : 'missing'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// L2 风险命中:运行时证据要求(advisory 提示,不引入 quick 没有的 wait 机制)
|
|
237
|
+
const runtimeEvidence = riskHits.length > 0 ? 'required' : 'na'
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
fileCount: files.length,
|
|
241
|
+
codeFileCount: codeFiles.length,
|
|
242
|
+
testFileCount: testFiles.length,
|
|
243
|
+
moduleSpan,
|
|
244
|
+
modules,
|
|
245
|
+
unmappedFiles,
|
|
246
|
+
riskHits,
|
|
247
|
+
level,
|
|
248
|
+
degraded,
|
|
249
|
+
checks: { perFileNotes, testDelta, docClaim, runtimeEvidence },
|
|
250
|
+
}
|
|
251
|
+
}
|