@xulthekl/team-flow 0.64.0 → 0.67.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/.claude/always/phase-guard.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/marketplace.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +2 -2
- package/CHANGELOG.md +60 -0
- package/GEMINI.md +1 -1
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/agents/architecture-design.md +1 -1
- package/agents/architecture-reviewer.md +6 -2
- package/agents/prd-completeness-reviewer.md +10 -0
- package/agents/prd-writer.md +1 -0
- package/agents/release-archivist.md +2 -0
- package/docs/README_en.md +1 -1
- package/docs/team-flow /344/275/277/347/224/250/350/257/264/346/230/216/357/274/210/347/240/224/345/217/221/345/233/242/351/230/237/347/211/210/357/274/211.md" +8 -6
- package/gemini-extension.json +1 -1
- package/hooks/session-start +2 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/prd/v1/prd.md +1 -1
- package/scripts/guard/checks/arch-gate-exemptions.mjs +5 -3
- package/scripts/guard/checks/arch-readiness.mjs +1 -1
- package/scripts/guard/checks/arch-snapshot.mjs +5 -3
- package/scripts/guard/checks/history-risk.mjs +132 -0
- package/scripts/guard/checks/prd-clarity-state.mjs +41 -0
- package/scripts/guard/checks/prd-clarity.mjs +176 -0
- package/scripts/guard/guard.mjs +16 -8
- package/scripts/infer-workflow.mjs +20 -0
- package/scripts/lib/arch-merge.mjs +384 -50
- package/scripts/lib/arch-parse.mjs +5 -2
- package/scripts/lib/arch-registry.mjs +523 -0
- package/scripts/lib/arch-scan-code.mjs +518 -0
- package/scripts/lib/cmd-arch.mjs +9 -1
- package/scripts/lib/cmd-doctor.mjs +3 -3
- package/scripts/lib/cmd-prd.mjs +84 -1
- package/scripts/lib/cmd-solutions.mjs +3 -0
- package/scripts/lib/cmd-state.mjs +31 -1
- package/scripts/lib/config-loader.mjs +20 -0
- package/scripts/lib/solutions-capture.mjs +5 -0
- package/scripts/lib/solutions-index-gen.mjs +33 -3
- package/scripts/lib/solutions-inject.mjs +34 -9
- package/scripts/lib/state-loader.mjs +13 -0
- package/scripts/team-flow.mjs +3 -0
- package/skills/architecture-design/SKILL.md +29 -11
- package/skills/architecture-design/chapters/ch04-entity-to-aggregate.md +18 -7
- package/skills/architecture-design/chapters/ch06-integration.md +12 -3
- package/skills/architecture-design/glossary.md +5 -1
- package/skills/architecture-design/references/adr-templates.md +56 -0
- package/skills/architecture-design/references/context-map-8.md +47 -0
- package/skills/architecture-design/references/ddd-evented-playbook.md +41 -0
- package/skills/architecture-design/references/s3.5-architecture-template.md +36 -4
- package/skills/architecture-design/references/s3.5-loading-protocol.md +5 -4
- package/skills/architecture-design/references/s3.5-product-architecture.md +6 -6
- package/skills/architecture-design/templates/architecture.md +20 -0
- package/skills/ce-brainstorm/SKILL.md +3 -3
- package/skills/ce-brainstorm/references/grounding.md +1 -1
- package/skills/ce-brainstorm/references/prd-84-authoring-spec.md +26 -3
- package/skills/ce-brainstorm/references/prototype-loop.md +8 -0
- package/skills/ce-compound/references/concepts-vocabulary.md +1 -1
- package/skills/ce-compound/references/full-mode-workflow.md +2 -2
- package/skills/ce-compound/references/lightweight-mode.md +1 -1
- package/skills/ce-compound/references/promotion-rules.md +1 -1
- package/skills/ce-compound/references/three-tier-index.md +1 -1
- package/skills/ce-plan/references/research-workflow.md +1 -1
- package/skills/jarvis/references/protocols.md +1 -0
- package/skills/release-archivist/SKILL.md +21 -0
- package/skills/release-archivist/references/closing-procedures.md +1 -1
- package/skills/workflow-orchestrator/SKILL.md +8 -4
- package/skills/workflow-orchestrator/references/s1-path-router.md +7 -0
- package/skills/workflow-orchestrator/references/s2-prd-prototype-loop.md +16 -2
- package/skills/workflow-orchestrator/references/s3-plan-pipeline.md +1 -1
- package/skills/workflow-start/SKILL.md +1 -1
- package/templates/prd.md +9 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// scripts/lib/arch-scan-code.mjs — tf arch scan-code:C4 代码注解扫描(v1.4 §5.4-4b)
|
|
2
|
+
//
|
|
3
|
+
// 定位:本机制**唯一的「A 级 · 真独立」锚**。
|
|
4
|
+
//
|
|
5
|
+
// 为什么必须是代码本体:C2 的锚(change 内手写 DDL)与 C3 的锚(api.md)**都产自
|
|
6
|
+
// 同一条流水线**,与 registry 同源、受同一缺陷影响;对 emp-auth 的「56 表 → 1 表」
|
|
7
|
+
// 崩溃**零检出**(§5.4-4 已实证)。唯一在被验证机制的**写路径之外**产生的制品是
|
|
8
|
+
// **代码本体**——它由开发者提交,不经过 `arch-merge`,也不由本机制的 LLM 段产出。
|
|
9
|
+
//
|
|
10
|
+
// 本命令只读、**零运行时依赖**(不新增 npm 依赖——核心扫描只用 node:fs / node:path;
|
|
11
|
+
// CLI 入口经 config-loader 复用既有配置读取)、纯文本正则(无需 AST)。
|
|
12
|
+
// 实测依据:emp-auth 的 56 张表**全部为字面串 `@TableName("…")`**
|
|
13
|
+
// (`service/infra-emp-auth` 51 处 + `service/adapter-emp-auth` 5 处 = 56,
|
|
14
|
+
// 见 `docs/plan/ddd-purity-and-arch-merge-design.md` §5.4-4b 与附录 B 第 3 轮 R3-F4)。
|
|
15
|
+
//
|
|
16
|
+
// 用途:
|
|
17
|
+
// ① 独立命令 `tf arch scan-code`(opt-in,只读、退出码恒 0)
|
|
18
|
+
// ② 未来 `tf arch rebuild` 的**第 4 级取源**(§5.6 M1)
|
|
19
|
+
//
|
|
20
|
+
// ★ 诚实边界(四条,不得省略 —— 见 §5.4-4b):
|
|
21
|
+
// 1. 它不是「修复效果验证」,而是**新引入的一项扫描能力**(56 表只有靠它才可达)。
|
|
22
|
+
// 2. 只能在**有代码**的仓库跑:纯设计阶段 / 无代码存量项目**拿不到** →
|
|
23
|
+
// `status='na'`(**不适用 ≠ 通过**,消费方须显式报 NA,不得当 PASS);
|
|
24
|
+
// repos 部分缺失 / IO 失败 → `status='partial'`(**部分覆盖 ≠ ok**,count 为下界,
|
|
25
|
+
// P3 IMP-1——漏扫方向会使未来 A4-b 超集断言 vacuous pass)。
|
|
26
|
+
// 3. 只覆盖 `tables[]` 一类:聚合 / BC / 子域 / 上下文映射 / 事件**仍无独立锚**
|
|
27
|
+
// → 这几类本轮只能靠人审。
|
|
28
|
+
// 4. 覆盖率受代码形态影响:XML mapper、JPA、手写 SQL、动态表名**扫不到**
|
|
29
|
+
// → 报告须**列出 `uncovered` 清单,不静默**(同 A5 纪律)。
|
|
30
|
+
//
|
|
31
|
+
// ★ 扫描域与排除清单(v1.4 新增,处置 R3-8 —— 必须显式定义,否则会重复计数或漏扫):
|
|
32
|
+
// - 扫描根 = 工作区根;多仓场景经 `arch.scan.repos[]` 显式配置
|
|
33
|
+
// (emp-auth 恰把两仓放在同一工作树 `service/infra-emp-auth` + `service/adapter-emp-auth`,
|
|
34
|
+
// 故单根可跑;通用场景两仓分属不同 git 仓库时须配置)。
|
|
35
|
+
// - 排除清单(必配):`.worktrees/`、`.git/`、`node_modules/`、`target/`、`build/`、`dist/`。
|
|
36
|
+
// **★ 实测风险已确认**:emp-auth 根下存在 4 个 `.worktrees/` 副本
|
|
37
|
+
// (v1-C2 / v2-C1 / v2-C2 / v3-C1),若不排除会**把同一批 `@TableName` 重复计入**
|
|
38
|
+
// (56 → 数百)。
|
|
39
|
+
// - fixture 须覆盖「副本目录去重」(见 tests/lib/arch-scan-code.test.mjs)。
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
/** 默认排除目录(按 basename 精确匹配,任意层级生效)。 */
|
|
44
|
+
export const DEFAULT_EXCLUDE_DIRS = Object.freeze([
|
|
45
|
+
'.worktrees', '.git', 'node_modules', 'target', 'build', 'dist',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** 默认注解名(MyBatis-Plus)。 */
|
|
49
|
+
export const DEFAULT_ANNOTATION = 'TableName';
|
|
50
|
+
|
|
51
|
+
/** 默认扫描的文件扩展名。 */
|
|
52
|
+
export const DEFAULT_EXTENSIONS = Object.freeze(['.java']);
|
|
53
|
+
|
|
54
|
+
/** 未覆盖形态探针(诚实边界 ④)——发现即登记,不静默。 */
|
|
55
|
+
const UNCOVERED_PROBES = Object.freeze([
|
|
56
|
+
{ id: 'jpa', label: 'JPA / Hibernate 注解', exts: ['.java', '.kt'],
|
|
57
|
+
re: /@(?:Entity|Table)\b|(?:javax|jakarta)\.persistence/ },
|
|
58
|
+
{ id: 'mybatis-xml', label: 'MyBatis XML mapper', exts: ['.xml'],
|
|
59
|
+
re: /<(?:mapper|resultMap|select|insert|update|delete)\b/ },
|
|
60
|
+
{ id: 'sql-ddl', label: '手写 SQL DDL', exts: ['.sql'],
|
|
61
|
+
re: /CREATE\s+TABLE/i },
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function escapeRe(s) {
|
|
65
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 构造注解扫描正则。`pattern`(自定义正则源)优先于 `annotation`(注解名)。
|
|
70
|
+
*
|
|
71
|
+
* 匹配 `@TableName("x")` / `@TableName(value="x")` / `@TableName(value = "x")` /
|
|
72
|
+
* `@TableName(autoResultMap = true, value = "x")` —— 即括号内**任意位置**的首个字符串字面量,
|
|
73
|
+
* 但**仅当**未显式给出 `value=` 时接受裸串;给出 `value=` 时只认该字段。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} annotation 注解名(可带或不带 `@` 前缀)
|
|
76
|
+
* @param {string|null} pattern 自定义正则源(覆盖 annotation)
|
|
77
|
+
* @returns {{ re: RegExp, source: string, mode: 'pattern'|'annotation' }}
|
|
78
|
+
*/
|
|
79
|
+
export function buildAnnotationMatcher(annotation, pattern) {
|
|
80
|
+
if (pattern) {
|
|
81
|
+
let re;
|
|
82
|
+
try {
|
|
83
|
+
re = new RegExp(pattern, 'g');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(`Invalid arch.scan.pattern (${pattern}): ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
return { re, source: pattern, mode: 'pattern' };
|
|
88
|
+
}
|
|
89
|
+
const name = String(annotation || DEFAULT_ANNOTATION).replace(/^@/, '');
|
|
90
|
+
// 捕获括号内全部参数,再在参数内找字符串字面量。
|
|
91
|
+
const source = `@${escapeRe(name)}\\s*\\(([^)]*)\\)`;
|
|
92
|
+
return {
|
|
93
|
+
re: new RegExp(source, 'g'),
|
|
94
|
+
source,
|
|
95
|
+
mode: 'annotation',
|
|
96
|
+
// 参数体内取值:优先 `value = "x"`,否则首个裸串。
|
|
97
|
+
// 以回调形式暴露,避免调用方重复实现。
|
|
98
|
+
valueFromArgs: extractAnnotationValue,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从注解参数体(`@X(...)` 的括号内容)中提取表名字面量。
|
|
104
|
+
* @param {string} argsBody
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function extractAnnotationValue(argsBody) {
|
|
108
|
+
const explicit = /\bvalue\s*=\s*"([^"]+)"/.exec(argsBody);
|
|
109
|
+
if (explicit) return explicit[1];
|
|
110
|
+
const bare = /(?:^|,)\s*"([^"]+)"/.exec(argsBody);
|
|
111
|
+
return bare ? bare[1] : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 行是否为可忽略的注释行(行首 `//`、`*`、`/*`)。 */
|
|
115
|
+
function isCommentLine(text, matchIndex) {
|
|
116
|
+
const lineStart = text.lastIndexOf('\n', matchIndex - 1) + 1;
|
|
117
|
+
const head = text.slice(lineStart, matchIndex).trimStart();
|
|
118
|
+
return head.startsWith('//') || head.startsWith('*') || head.startsWith('/*');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 从一段源码文本中提取注解表名,**并统计无法静态解出表名的注解数**。
|
|
123
|
+
*
|
|
124
|
+
* 为什么必须回报 `unresolved`(P2 审查修正,诚实边界 ④「不静默」):
|
|
125
|
+
* `@TableName(MyTables.SYS_USER)`(常量)、`@TableName()`(空参,取类名策略)、
|
|
126
|
+
* SpEL 动态表名等形态**静态解不出字面量**。旧实现把它们**静默丢弃** → 计数偏低
|
|
127
|
+
* 却仍报 `status='ok'`,等于对覆盖率缺陷撒谎。emp-auth 全为字面串故不受影响,
|
|
128
|
+
* 但通用场景必须让这些形态进入 `uncovered` 清单,由人审兜底。
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text
|
|
131
|
+
* @param {object} matcher buildAnnotationMatcher() 的返回值
|
|
132
|
+
* @returns {{ names: string[], unresolved: number }}
|
|
133
|
+
*/
|
|
134
|
+
export function extractAnnotationDetails(text, matcher) {
|
|
135
|
+
const names = [];
|
|
136
|
+
let unresolved = 0;
|
|
137
|
+
const { re, mode, valueFromArgs } = matcher;
|
|
138
|
+
re.lastIndex = 0;
|
|
139
|
+
let m;
|
|
140
|
+
while ((m = re.exec(text)) !== null) {
|
|
141
|
+
if (isCommentLine(text, m.index)) continue;
|
|
142
|
+
let name = null;
|
|
143
|
+
if (mode === 'pattern') {
|
|
144
|
+
// 自定义正则:约定 group(1) 为表名;缺失则回退整段匹配。
|
|
145
|
+
name = m[1] ?? m[0];
|
|
146
|
+
} else {
|
|
147
|
+
name = valueFromArgs(m[1]);
|
|
148
|
+
}
|
|
149
|
+
if (name && typeof name === 'string') {
|
|
150
|
+
const trimmed = name.trim();
|
|
151
|
+
if (trimmed && !/\s/.test(trimmed)) names.push(trimmed);
|
|
152
|
+
else unresolved += 1;
|
|
153
|
+
} else {
|
|
154
|
+
unresolved += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { names, unresolved };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 从一段源码文本中提取注解表名(`extractAnnotationDetails` 的简写形式)。
|
|
162
|
+
* @param {string} text
|
|
163
|
+
* @param {object} matcher buildAnnotationMatcher() 的返回值
|
|
164
|
+
* @returns {string[]}
|
|
165
|
+
*/
|
|
166
|
+
export function extractTableNames(text, matcher) {
|
|
167
|
+
return extractAnnotationDetails(text, matcher).names;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 归一化配置:把 `arch.scan` 配置块折算为可直接使用的扫描参数。
|
|
172
|
+
* @param {object} scanConfig
|
|
173
|
+
* @returns {{ annotation: string, pattern: string|null, extensions: Set<string>,
|
|
174
|
+
* exclude: Set<string>, repos: string[] }}
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeScanOptions(scanConfig = {}) {
|
|
177
|
+
const cfg = scanConfig && typeof scanConfig === 'object' ? scanConfig : {};
|
|
178
|
+
// ★ 强制排除清单**恒生效**:配置项只能**追加**,不得覆盖(fail-closed)。
|
|
179
|
+
//
|
|
180
|
+
// 为什么不能让配置覆盖(P2 审查修正;★ P3 MIN-2 勘误归因方向):若 `arch.scan.exclude`
|
|
181
|
+
// 被设为 `["vendor"]` 这类值,旧实现会**整体替换**默认清单 → `.worktrees/` 与分支副本
|
|
182
|
+
// 被重新扫入 → 锚的**正确性与可比性被污染**(副本/分支独有表名混入,emp-auth 多分支
|
|
183
|
+
// 场景下 count 可从 56 膨胀到数百)。C4 是 A4-b 断言(`registry.tables[]` ⊇ 代码扫描)
|
|
184
|
+
// 的**唯一锚**:膨胀方向使超集断言**更难成立**(误报方向);真正使超集恒成立的
|
|
185
|
+
// fail-open 通道是**漏扫**(scan 偏小 → 超集 vacuous pass,由本文件的 partial 降级
|
|
186
|
+
// 登记堵截,见 P3 IMP-1)。故并集防线的正当理由 = 防污染锚 + 不静默,
|
|
187
|
+
// 与 §5.4-4b「排除清单(必配)」及 §8 红牌 9「A 级锚不得被架空」一致。
|
|
188
|
+
const exclude = [...new Set([
|
|
189
|
+
...DEFAULT_EXCLUDE_DIRS,
|
|
190
|
+
...(Array.isArray(cfg.exclude) ? cfg.exclude : []),
|
|
191
|
+
])];
|
|
192
|
+
const extRaw = Array.isArray(cfg.extensions) && cfg.extensions.length > 0
|
|
193
|
+
? cfg.extensions
|
|
194
|
+
: DEFAULT_EXTENSIONS;
|
|
195
|
+
return {
|
|
196
|
+
annotation: typeof cfg.annotation === 'string' && cfg.annotation.trim()
|
|
197
|
+
? cfg.annotation.trim()
|
|
198
|
+
: DEFAULT_ANNOTATION,
|
|
199
|
+
pattern: typeof cfg.pattern === 'string' && cfg.pattern.trim() ? cfg.pattern.trim() : null,
|
|
200
|
+
extensions: new Set(extRaw.map(e => (e.startsWith('.') ? e : `.${e}`).toLowerCase())),
|
|
201
|
+
exclude: new Set(exclude.map(s => String(s).replace(/^\.\//, '').replace(/\/$/, ''))),
|
|
202
|
+
repos: Array.isArray(cfg.repos) ? cfg.repos.filter(r => typeof r === 'string' && r.trim()) : [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 合并「配置文件 `arch.scan` + CLI 旗标」得到最终扫描配置。
|
|
208
|
+
*
|
|
209
|
+
* CLI 覆盖项:`--annotation`(注解名)、`--repos`(逗号分隔扫描根)、
|
|
210
|
+
* `--exclude`(逗号分隔,**只追加**,不得移除强制项)。
|
|
211
|
+
*
|
|
212
|
+
* 为什么必须有 CLI 入口(P2 实测驱动):emp-auth 属**只读目标**(不可改其 config),
|
|
213
|
+
* 而其「扫全仓根」在本环境**未被实测通过**(见 §5.4-4b 修正),唯一可用姿势是
|
|
214
|
+
* 限定扫描根 → 若无 CLI 旗标就只能去改目标仓库的配置,与纪律冲突。
|
|
215
|
+
*
|
|
216
|
+
* 抽为独立函数以便单测(`run()` 会 `process.exit`,不便直接断言)。
|
|
217
|
+
* @param {object} scanConfig `arch.scan` 配置块
|
|
218
|
+
* @param {object} values parseArgs 结果
|
|
219
|
+
*/
|
|
220
|
+
export function resolveScanConfig(scanConfig = {}, values = {}) {
|
|
221
|
+
const cfg = scanConfig && typeof scanConfig === 'object' ? { ...scanConfig } : {};
|
|
222
|
+
if (values.annotation) cfg.annotation = values.annotation;
|
|
223
|
+
if (values.repos) {
|
|
224
|
+
cfg.repos = String(values.repos).split(',').map(s => s.trim()).filter(Boolean);
|
|
225
|
+
}
|
|
226
|
+
if (values.exclude) {
|
|
227
|
+
const extra = String(values.exclude).split(',').map(s => s.trim()).filter(Boolean);
|
|
228
|
+
cfg.exclude = [...(Array.isArray(cfg.exclude) ? cfg.exclude : []), ...extra];
|
|
229
|
+
}
|
|
230
|
+
return cfg;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 探针扩展名并集(用于收集"非主扩展名但需探测未覆盖形态"的文件)。 */
|
|
234
|
+
const PROBE_EXTS = new Set(UNCOVERED_PROBES.flatMap(p => p.exts));
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 递归遍历目录,收集目标扩展名文件;按 basename 排除目录并登记命中项。
|
|
238
|
+
*
|
|
239
|
+
* 分两类收集:`files` = 主扫描扩展名内(跑注解扫描);`probeFiles` = 仅探针扩展名内
|
|
240
|
+
* (如 `.xml` / `.sql`,只用于"未覆盖形态"报告,不参与表名计数)。
|
|
241
|
+
*
|
|
242
|
+
* @returns {{ files: object[], probeFiles: object[], excludedDirs: string[], dirs: number }}
|
|
243
|
+
*/
|
|
244
|
+
function walk(root, opts, relBase = '') {
|
|
245
|
+
const files = [];
|
|
246
|
+
const probeFiles = [];
|
|
247
|
+
const excludedDirs = [];
|
|
248
|
+
const readErrors = []; // ★ P3 IMP-1:目录读取失败不得静默——登记后 scanCode 降级 partial
|
|
249
|
+
let dirs = 0;
|
|
250
|
+
|
|
251
|
+
const stack = [{ abs: root, rel: relBase }];
|
|
252
|
+
while (stack.length > 0) {
|
|
253
|
+
const { abs, rel } = stack.pop();
|
|
254
|
+
let entries;
|
|
255
|
+
try {
|
|
256
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
257
|
+
} catch (e) {
|
|
258
|
+
readErrors.push(`${rel || '.'}: ${e.code || e.message}`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
dirs += 1;
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
264
|
+
if (entry.isDirectory()) {
|
|
265
|
+
if (opts.exclude.has(entry.name)) {
|
|
266
|
+
excludedDirs.push(childRel);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
stack.push({ abs: path.join(abs, entry.name), rel: childRel });
|
|
270
|
+
} else if (entry.isFile()) {
|
|
271
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
272
|
+
const item = { abs: path.join(abs, entry.name), rel: childRel };
|
|
273
|
+
if (opts.extensions.has(ext)) files.push(item);
|
|
274
|
+
else if (PROBE_EXTS.has(ext)) probeFiles.push(item);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { files, probeFiles, excludedDirs, readErrors, dirs };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 在给定文本上跑未覆盖形态探针,登记命中(去重累计)。 */
|
|
282
|
+
function probeUncovered(text, rel, ext, uncoveredHits) {
|
|
283
|
+
for (const probe of UNCOVERED_PROBES) {
|
|
284
|
+
if (!probe.exts.includes(ext)) continue;
|
|
285
|
+
if (!probe.re.test(text)) continue;
|
|
286
|
+
if (!uncoveredHits.has(probe.id)) {
|
|
287
|
+
uncoveredHits.set(probe.id, { id: probe.id, label: probe.label, files: [] });
|
|
288
|
+
}
|
|
289
|
+
uncoveredHits.get(probe.id).files.push(rel);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 汇总 `uncovered` 清单:形态探针命中 + 「注解存在但表名非字面量」。
|
|
295
|
+
* **按 `id` 固定排序** —— 不依赖目录遍历顺序,满足红牌 10 的可重放要求
|
|
296
|
+
* (遍历用栈,`readdirSync` 顺序不保证跨平台一致)。
|
|
297
|
+
*/
|
|
298
|
+
function buildUncovered(uncoveredHits, unresolvedFiles, annotation, inputErrors = []) {
|
|
299
|
+
const list = [...uncoveredHits.values()].map(u => ({ ...u, files: u.files.sort() }));
|
|
300
|
+
if (unresolvedFiles.length > 0) {
|
|
301
|
+
list.push({
|
|
302
|
+
id: 'annotation-non-literal',
|
|
303
|
+
label: `@${annotation} 存在但表名非字面量(常量 / SpEL / 空参)`,
|
|
304
|
+
files: [...new Set(unresolvedFiles)].sort(),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// ★ P3 IMP-1:输入缺失/读取失败登记(结构化可见,驱动 status=partial)
|
|
308
|
+
if (inputErrors.length > 0) {
|
|
309
|
+
list.push({
|
|
310
|
+
id: 'scan-input-missing',
|
|
311
|
+
label: '扫描输入缺失或读取失败(漏扫方向:count 为下界,非完整覆盖)',
|
|
312
|
+
files: [...new Set(inputErrors)].sort(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return list.sort((a, b) => a.id.localeCompare(b.id));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* ★ 核心:扫描代码仓库的持久化注解,产出表名集合(C4 的锚)。
|
|
320
|
+
*
|
|
321
|
+
* @param {string} root 工作区根(绝对路径)
|
|
322
|
+
* @param {object} [scanConfig] `arch.scan` 配置块
|
|
323
|
+
* @returns {{
|
|
324
|
+
* status: 'ok'|'na'|'partial', reason: string|null, root: string, scanRoots: string[],
|
|
325
|
+
* annotation: string, pattern: string|null, tables: string[], count: number,
|
|
326
|
+
* sources: {file: string, table: string}[], excludedDirs: string[],
|
|
327
|
+
* scanned: {files: number, probeFiles: number, dirs: number},
|
|
328
|
+
* uncovered: {id: string, label: string, files: string[]}[]
|
|
329
|
+
* }}
|
|
330
|
+
*/
|
|
331
|
+
export function scanCode(root, scanConfig = {}) {
|
|
332
|
+
const absRoot = path.resolve(root);
|
|
333
|
+
const opts = normalizeScanOptions(scanConfig);
|
|
334
|
+
const matcher = buildAnnotationMatcher(opts.annotation, opts.pattern);
|
|
335
|
+
|
|
336
|
+
// 扫描根:显式 repos 优先,否则工作区根。
|
|
337
|
+
// ★ P3 IMP-1(fail-closed 输入维度):缺失的 repos 条目**不得静默丢弃**——
|
|
338
|
+
// 部分缺失 = 只扫子集 = 漏扫方向,会让未来 A4-b 超集断言 vacuous pass,
|
|
339
|
+
// 故登记 missingRoots 并驱动 status 降级 partial(全部缺失仍走既有 na)。
|
|
340
|
+
const requestedRoots = opts.repos.length > 0 ? opts.repos : ['.'];
|
|
341
|
+
const scanRoots = [];
|
|
342
|
+
const missingRoots = [];
|
|
343
|
+
for (const r of requestedRoots) {
|
|
344
|
+
const abs = path.resolve(absRoot, r);
|
|
345
|
+
if (fs.existsSync(abs)) scanRoots.push(abs);
|
|
346
|
+
else missingRoots.push(r);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const files = [];
|
|
350
|
+
const probeFiles = [];
|
|
351
|
+
const excludedDirs = [];
|
|
352
|
+
const readErrors = []; // 文件级读取失败(同 P3 IMP-1,不静默)
|
|
353
|
+
let dirs = 0;
|
|
354
|
+
for (const sr of scanRoots) {
|
|
355
|
+
const rel = path.relative(absRoot, sr);
|
|
356
|
+
const w = walk(sr, opts, rel === '' ? '' : rel.split(path.sep).join('/'));
|
|
357
|
+
files.push(...w.files);
|
|
358
|
+
probeFiles.push(...w.probeFiles);
|
|
359
|
+
excludedDirs.push(...w.excludedDirs);
|
|
360
|
+
readErrors.push(...w.readErrors);
|
|
361
|
+
dirs += w.dirs;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const tables = new Set();
|
|
365
|
+
const sources = [];
|
|
366
|
+
const uncoveredHits = new Map();
|
|
367
|
+
// ★ 注解存在但表名非字面量(常量 / SpEL / 空参)——须进 uncovered,不静默(诚实边界 ④)。
|
|
368
|
+
const unresolvedFiles = [];
|
|
369
|
+
|
|
370
|
+
for (const { abs, rel } of files) {
|
|
371
|
+
let text;
|
|
372
|
+
try {
|
|
373
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
374
|
+
} catch (e) {
|
|
375
|
+
readErrors.push(`${rel}: ${e.code || e.message}`); // P3 IMP-1:不静默
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const { names: hits, unresolved } = extractAnnotationDetails(text, matcher);
|
|
379
|
+
for (const name of hits) {
|
|
380
|
+
tables.add(name);
|
|
381
|
+
sources.push({ file: rel, table: name });
|
|
382
|
+
}
|
|
383
|
+
if (unresolved > 0) unresolvedFiles.push(rel);
|
|
384
|
+
// 未覆盖形态探针:仅在**未命中任何注解**的文件上探测,避免噪声。
|
|
385
|
+
if (hits.length === 0) {
|
|
386
|
+
probeUncovered(text, rel, path.extname(abs).toLowerCase(), uncoveredHits);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// 仅探针扩展名的文件(.xml / .sql 等):不参与表名计数,只用于"未覆盖形态"报告。
|
|
390
|
+
for (const { abs, rel } of probeFiles) {
|
|
391
|
+
let text;
|
|
392
|
+
try {
|
|
393
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
394
|
+
} catch (e) {
|
|
395
|
+
readErrors.push(`${rel}: ${e.code || e.message}`); // P3 IMP-1:不静默
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
probeUncovered(text, rel, path.extname(abs).toLowerCase(), uncoveredHits);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
sources.sort((a, b) => (a.file === b.file ? a.table.localeCompare(b.table) : a.file.localeCompare(b.file)));
|
|
402
|
+
const sortedTables = [...tables].sort((a, b) => a.localeCompare(b));
|
|
403
|
+
excludedDirs.sort();
|
|
404
|
+
|
|
405
|
+
let status = 'ok';
|
|
406
|
+
let reason = null;
|
|
407
|
+
if (scanRoots.length === 0) {
|
|
408
|
+
status = 'na';
|
|
409
|
+
reason = 'no scan root available (workspace root or arch.scan.repos[] paths do not exist)';
|
|
410
|
+
} else if (files.length === 0) {
|
|
411
|
+
status = 'na';
|
|
412
|
+
reason = `no scannable file (extensions: ${[...opts.extensions].join(', ')}) under the scan root`;
|
|
413
|
+
} else if (sortedTables.length === 0) {
|
|
414
|
+
status = 'na';
|
|
415
|
+
reason = `no @${opts.annotation} annotation found in ${files.length} scanned file(s)`
|
|
416
|
+
+ ' — 若项目使用其他持久化框架,请配置 arch.scan.annotation / arch.scan.pattern';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ★ P3 IMP-1:输入维度 fail-closed——repos 部分缺失 / IO 失败 = 漏扫方向,
|
|
420
|
+
// `status=ok` 不得照报(部分覆盖 ≠ ok)。na 已是显式不适用,优先级更高,不重复降级。
|
|
421
|
+
const inputErrors = [
|
|
422
|
+
...missingRoots.map(r => `missing root: ${r}`),
|
|
423
|
+
...readErrors,
|
|
424
|
+
];
|
|
425
|
+
if (inputErrors.length > 0 && status === 'ok') {
|
|
426
|
+
status = 'partial';
|
|
427
|
+
reason = `partial scan: ${inputErrors.length} input error(s) — `
|
|
428
|
+
+ inputErrors.slice(0, 5).join('; ')
|
|
429
|
+
+ (inputErrors.length > 5 ? `; …(+${inputErrors.length - 5} more)` : '')
|
|
430
|
+
+ '(漏扫方向:count 为下界,消费方不得当完整覆盖)';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
status,
|
|
435
|
+
reason,
|
|
436
|
+
root: absRoot,
|
|
437
|
+
scanRoots,
|
|
438
|
+
annotation: `@${opts.annotation}`,
|
|
439
|
+
pattern: opts.pattern,
|
|
440
|
+
tables: sortedTables,
|
|
441
|
+
count: sortedTables.length,
|
|
442
|
+
sources,
|
|
443
|
+
excludedDirs: [...new Set(excludedDirs)].sort(),
|
|
444
|
+
scanned: { files: files.length, probeFiles: probeFiles.length, dirs },
|
|
445
|
+
uncovered: buildUncovered(uncoveredHits, unresolvedFiles, opts.annotation, inputErrors),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 渲染人类可读输出。
|
|
451
|
+
* @param {object} result scanCode() 返回值
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
export function renderReport(result) {
|
|
455
|
+
const lines = [];
|
|
456
|
+
lines.push(`arch scan-code — ${result.status.toUpperCase()}`
|
|
457
|
+
+ (result.status === 'ok' ? `(${result.count} 张表)` : ''));
|
|
458
|
+
lines.push(` scan root : ${result.root}`);
|
|
459
|
+
if (result.scanRoots.length > 1 || result.scanRoots[0] !== result.root) {
|
|
460
|
+
lines.push(` scan roots: ${result.scanRoots.join(', ')}`);
|
|
461
|
+
}
|
|
462
|
+
lines.push(` matcher : ${result.annotation}${result.pattern ? ` (pattern: ${result.pattern})` : ''}`);
|
|
463
|
+
lines.push(` scanned : ${result.scanned.files} file(s) / ${result.scanned.dirs} dir(s)`
|
|
464
|
+
+ `(另有 ${result.scanned.probeFiles} 个探针文件,仅计入未覆盖统计)`);
|
|
465
|
+
if (result.status === 'ok' || result.status === 'partial') {
|
|
466
|
+
lines.push(` tables : ${result.tables.join(', ')}`);
|
|
467
|
+
}
|
|
468
|
+
if (result.reason) {
|
|
469
|
+
lines.push(` reason : ${result.reason}`);
|
|
470
|
+
}
|
|
471
|
+
if (result.excludedDirs.length > 0) {
|
|
472
|
+
lines.push(` excluded : ${result.excludedDirs.join(', ')} ← 副本去重生效`);
|
|
473
|
+
}
|
|
474
|
+
if (result.uncovered.length > 0) {
|
|
475
|
+
lines.push(' uncovered : 以下形态可能存在表定义,但未被本次注解扫描捕获(须人审):');
|
|
476
|
+
for (const u of result.uncovered) {
|
|
477
|
+
lines.push(` - ${u.label} (${u.files.length} file(s)): ${u.files.slice(0, 5).join(', ')}`
|
|
478
|
+
+ (u.files.length > 5 ? ' …' : ''));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
lines.push(' note: C4 是唯一 A 级锚(代码本体);只覆盖 tables[] 一类;');
|
|
482
|
+
lines.push(' 聚合 / BC / 子域 / 上下文映射 / 事件仍无独立锚,本轮只能靠人审。');
|
|
483
|
+
if (result.status === 'na') {
|
|
484
|
+
lines.push(' ★ status=na 表示「不适用」,不是「通过」——消费方须报 NA,不得当 PASS。');
|
|
485
|
+
}
|
|
486
|
+
if (result.status === 'partial') {
|
|
487
|
+
lines.push(' ★ status=partial 表示「部分覆盖」(输入缺失/读取失败,漏扫方向)——count 为下界,不得当完整覆盖。');
|
|
488
|
+
}
|
|
489
|
+
return lines.join('\n');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* CLI 入口。`values` 由 `cmd-arch.run` 的 parseArgs 统一解析后传入。
|
|
494
|
+
* @param {{ root?: string, 'project-root'?: string, json?: boolean,
|
|
495
|
+
* annotation?: string, repos?: string, exclude?: string }} values
|
|
496
|
+
*/
|
|
497
|
+
export async function run(values = {}) {
|
|
498
|
+
const root = path.resolve(values.root || values['project-root'] || process.cwd());
|
|
499
|
+
|
|
500
|
+
let scanConfig = {};
|
|
501
|
+
try {
|
|
502
|
+
const { loadConfig } = await import('./config-loader.mjs');
|
|
503
|
+
const config = loadConfig(root);
|
|
504
|
+
scanConfig = config?.arch?.scan ?? {};
|
|
505
|
+
} catch {
|
|
506
|
+
scanConfig = {};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const result = scanCode(root, resolveScanConfig(scanConfig, values));
|
|
510
|
+
|
|
511
|
+
if (values.json) {
|
|
512
|
+
console.log(JSON.stringify(result, null, 2));
|
|
513
|
+
} else {
|
|
514
|
+
console.log(renderReport(result));
|
|
515
|
+
}
|
|
516
|
+
// 证据工具:恒以 0 退出,不阻断流程(同 arch precheck 语义)。
|
|
517
|
+
process.exit(0);
|
|
518
|
+
}
|
package/scripts/lib/cmd-arch.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
import * as archPrecheck from './arch-precheck.mjs';
|
|
10
|
+
import * as archScanCode from './arch-scan-code.mjs';
|
|
10
11
|
import { scaffoldGlobalLedger } from './arch-merge.mjs';
|
|
11
12
|
|
|
12
13
|
const ARCH_STATE_FILE = '.team-flow/arch-state.json';
|
|
@@ -16,6 +17,11 @@ export async function run(args) {
|
|
|
16
17
|
args,
|
|
17
18
|
options: {
|
|
18
19
|
'project-root': { type: 'string' },
|
|
20
|
+
root: { type: 'string' },
|
|
21
|
+
// v1.4 §5.4-4b:scan-code 的 CLI 覆盖项(只读目标仓库无法改 config 时必需)
|
|
22
|
+
repos: { type: 'string' },
|
|
23
|
+
annotation: { type: 'string' },
|
|
24
|
+
exclude: { type: 'string' },
|
|
19
25
|
mode: { type: 'string', default: 'reconstruction' },
|
|
20
26
|
'baseline-ref': { type: 'string', default: 'prd/vN/' },
|
|
21
27
|
json: { type: 'boolean', default: false },
|
|
@@ -30,7 +36,9 @@ export async function run(args) {
|
|
|
30
36
|
if (sub === 'scaffold') return scaffold(values);
|
|
31
37
|
// v0.22 §88.3.2:架构门判据的确定性证据工具(证据 only,退出码恒 0)
|
|
32
38
|
if (sub === 'precheck') return archPrecheck.run(positionals.slice(1), values);
|
|
33
|
-
|
|
39
|
+
// v1.4 §5.4-4b:C4 代码注解扫描(唯一 A 级锚;只读、零依赖、证据 only,退出码恒 0)
|
|
40
|
+
if (sub === 'scan-code') return archScanCode.run(values);
|
|
41
|
+
console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch scaffold | tf arch precheck <change-dir> [--json] | tf arch scan-code [--root <path>|--project-root <path>] [--repos <a,b>] [--annotation <name>] [--exclude <a,b>] [--json]');
|
|
34
42
|
process.exit(2);
|
|
35
43
|
}
|
|
36
44
|
|
|
@@ -331,7 +331,7 @@ function checkArchState(root) {
|
|
|
331
331
|
}
|
|
332
332
|
if (existsSync(iterationsDir)) {
|
|
333
333
|
for (const d of readdirSync(iterationsDir, { withFileTypes: true }).filter(x => x.isDirectory())) {
|
|
334
|
-
const hasSkip = existsSync(join(iterationsDir, d.name, 'SKIPPED'));
|
|
334
|
+
const hasSkip = existsSync(join(iterationsDir, d.name, 'SKIPPED.md')) || existsSync(join(iterationsDir, d.name, 'SKIPPED')); // P3 C-3 双形态
|
|
335
335
|
const hasSnap = existsSync(join(iterationsDir, d.name, 'architecture.md'));
|
|
336
336
|
if (hasSkip && hasSnap) warnings.push(`${d}: SKIPPED 与快照并存,语义矛盾`);
|
|
337
337
|
}
|
|
@@ -382,9 +382,9 @@ function checkSolutions(root) {
|
|
|
382
382
|
for (const line of readFileSync(indexPath, 'utf-8').split('\n')) {
|
|
383
383
|
if (!line.startsWith('|') || line.startsWith('| date') || line.startsWith('|--')) continue;
|
|
384
384
|
const cells = parseTableRow(line);
|
|
385
|
-
// v0.57.0 P4:`source` 列新增后列数为 7
|
|
385
|
+
// v0.57.0 P4:`source` 列新增后列数为 7(存量)/ 8;P2-2 `flags` 列后 9(本版重建);
|
|
386
386
|
// 另校验 `file` 列形状——列数在界内仍可能因未转义的 `|` 而错位。
|
|
387
|
-
if (cells.length < 7 || cells.length >
|
|
387
|
+
if (cells.length < 7 || cells.length > 9) continue;
|
|
388
388
|
if (!/^[a-z][a-z-]*\/.+\.md$/.test(cells[6])) continue;
|
|
389
389
|
indexRows.set(cells[6], { date: cells[0], phase: cells[1], domain: cells[2], type: cells[3], severity: cells[4] });
|
|
390
390
|
}
|