sandtable 0.3.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -22
- package/dashboard/dashboard.html +1320 -834
- package/harness/install-hooks.sh +40 -4
- package/package.json +1 -1
- package/server.js +54 -3
- package/src/builder/build.js +121 -230
- package/src/check/check.js +137 -0
- package/src/cli/sandtable.js +202 -8
- package/src/contract/default-contract.json +21 -0
- package/src/contract/loader.js +203 -0
- package/src/progress/parser.js +302 -0
- package/src/scanner/scan.js +47 -251
- package/src/scanner/scan.js.v0.4.bak +415 -0
- package/templates/.sandtable.template.json +24 -26
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { globToRegex } = require('../contract/loader');
|
|
7
|
+
|
|
8
|
+
// ---- Parse a Markdown table anchored at a specific heading ----
|
|
9
|
+
function parseTableAtAnchor(content, anchor) {
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
let inTarget = false;
|
|
12
|
+
let header = null;
|
|
13
|
+
const rows = [];
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < lines.length; i++) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
|
|
18
|
+
// Detect anchor heading (e.g. "## 5.2")
|
|
19
|
+
if (anchor && line.trim().startsWith(anchor)) {
|
|
20
|
+
inTarget = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Stop at next same-level or higher heading
|
|
25
|
+
if (inTarget && /^##\s/.test(line) && !line.trim().startsWith(anchor)) {
|
|
26
|
+
if (rows.length > 0) break;
|
|
27
|
+
inTarget = false; // No table found in this section, stop looking
|
|
28
|
+
}
|
|
29
|
+
if (inTarget && /^#\s/.test(line) && rows.length > 0) break;
|
|
30
|
+
|
|
31
|
+
if (!inTarget) continue;
|
|
32
|
+
|
|
33
|
+
// Parse table rows
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (/^\|.+\|$/.test(trimmed) && !/^\|[-| ]+\|$/.test(trimmed)) {
|
|
36
|
+
if (!header) {
|
|
37
|
+
header = trimmed.split('|').map(c => c.trim()).filter(Boolean);
|
|
38
|
+
} else {
|
|
39
|
+
const cols = trimmed.split('|').map(c => c.trim()).filter(Boolean);
|
|
40
|
+
if (cols.length >= 2) {
|
|
41
|
+
const row = {};
|
|
42
|
+
header.forEach((h, idx) => { row[h] = cols[idx] || ''; });
|
|
43
|
+
rows.push(row);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} else if (!/^\|[-| ]+\|$/.test(trimmed)) {
|
|
47
|
+
// Non-table line — reset header if we're past the table
|
|
48
|
+
if (header && rows.length > 0) break;
|
|
49
|
+
header = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { header, rows };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- Normalize status string to standard enum ----
|
|
57
|
+
function normalizeStatus(rawStatus, statusMap) {
|
|
58
|
+
if (!rawStatus) return 'pending';
|
|
59
|
+
|
|
60
|
+
const trimmed = rawStatus.trim();
|
|
61
|
+
|
|
62
|
+
// Direct match in map
|
|
63
|
+
if (statusMap && statusMap[trimmed]) return statusMap[trimmed];
|
|
64
|
+
|
|
65
|
+
// Pattern match in map keys
|
|
66
|
+
if (statusMap) {
|
|
67
|
+
for (const [key, value] of Object.entries(statusMap)) {
|
|
68
|
+
if (trimmed.includes(key.replace(/[*_]/g, ''))) return value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Built-in fallback patterns
|
|
73
|
+
if (/✅|completed|complete|完成|完工/i.test(trimmed)) return 'completed';
|
|
74
|
+
if (/⏳|in.progress|进行中|next/i.test(trimmed)) return 'in_progress';
|
|
75
|
+
if (/🚫|blocked|阻塞/i.test(trimmed)) return 'blocked';
|
|
76
|
+
if (/❌|cancel|取消/i.test(trimmed)) return 'cancelled';
|
|
77
|
+
|
|
78
|
+
return 'pending';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- Parse progress from a table source ----
|
|
82
|
+
function parseTableSource(source, projectRoot) {
|
|
83
|
+
const { file, anchor } = source;
|
|
84
|
+
const filePath = path.join(projectRoot, file);
|
|
85
|
+
if (!fs.existsSync(filePath)) return null;
|
|
86
|
+
|
|
87
|
+
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
88
|
+
const { header, rows } = parseTableAtAnchor(fileContent, anchor || null);
|
|
89
|
+
|
|
90
|
+
if (!header || rows.length === 0) return null;
|
|
91
|
+
|
|
92
|
+
return { header, rows };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- Parse progress from a statusLine source ----
|
|
96
|
+
function parseStatusLineSource(source, projectRoot) {
|
|
97
|
+
const results = [];
|
|
98
|
+
|
|
99
|
+
if (source.file) {
|
|
100
|
+
// Single file mode
|
|
101
|
+
const filePath = path.join(projectRoot, source.file);
|
|
102
|
+
if (!fs.existsSync(filePath)) return results;
|
|
103
|
+
|
|
104
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
const { regex, itemPattern, statusMap } = source.statusLinePattern || {};
|
|
107
|
+
|
|
108
|
+
if (!regex) return results;
|
|
109
|
+
const re = new RegExp(regex, 'g');
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
let match;
|
|
113
|
+
while ((match = re.exec(line)) !== null) {
|
|
114
|
+
const itemId = match[1] || '';
|
|
115
|
+
const statusRaw = match[2] || match[0];
|
|
116
|
+
results.push({
|
|
117
|
+
id: itemId,
|
|
118
|
+
status: normalizeStatus(statusRaw, statusMap)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if (source.glob) {
|
|
123
|
+
// Multi-file glob mode
|
|
124
|
+
const re = globToRegex(source.glob);
|
|
125
|
+
|
|
126
|
+
// Walk docs/ for matching files
|
|
127
|
+
function walk(dir) {
|
|
128
|
+
const out = [];
|
|
129
|
+
if (!fs.existsSync(dir)) return out;
|
|
130
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (entry.name.startsWith('.')) continue;
|
|
133
|
+
const full = path.join(dir, entry.name);
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
out.push(...walk(full));
|
|
136
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
137
|
+
out.push(full);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const docsDir = path.join(projectRoot, 'docs');
|
|
144
|
+
const allFiles = walk(docsDir);
|
|
145
|
+
|
|
146
|
+
const { regex: sRegex, itemPattern: sItemPattern, statusMap } = source.statusLinePattern || {};
|
|
147
|
+
if (!sRegex) return results;
|
|
148
|
+
|
|
149
|
+
for (const filePath of allFiles) {
|
|
150
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
151
|
+
if (!re.test(rel)) continue;
|
|
152
|
+
|
|
153
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
|
|
156
|
+
// Extract date from filename
|
|
157
|
+
const dateMatch = path.basename(filePath).match(/(\d{4}-\d{2}-\d{2})/);
|
|
158
|
+
const date = dateMatch ? dateMatch[1] : null;
|
|
159
|
+
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
// Look for status line: "> **状态**:..." or "> **状态**: ..."
|
|
162
|
+
if (/^\s*>\s*\*\*状态\*\*[::]/.test(line)) {
|
|
163
|
+
const statusRaw = line.replace(/^\s*>\s*\*\*状态\*\*[::]\s*/, '').trim();
|
|
164
|
+
// Extract item ID from file content
|
|
165
|
+
let itemId = '';
|
|
166
|
+
if (sItemPattern) {
|
|
167
|
+
const itemRe = new RegExp(sItemPattern, 'g');
|
|
168
|
+
// Search context around the status line
|
|
169
|
+
const ctxStart = Math.max(0, lines.indexOf(line) - 5);
|
|
170
|
+
const ctxEnd = Math.min(lines.length, lines.indexOf(line) + 5);
|
|
171
|
+
const ctx = lines.slice(ctxStart, ctxEnd).join('\n');
|
|
172
|
+
let itemMatch;
|
|
173
|
+
while ((itemMatch = itemRe.exec(ctx)) !== null) {
|
|
174
|
+
// Prefer capture group if present, otherwise full match
|
|
175
|
+
itemId = itemMatch[1] || itemMatch[0];
|
|
176
|
+
break; // Take first match
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!itemId && date) itemId = path.basename(filePath, '.md');
|
|
180
|
+
|
|
181
|
+
results.push({
|
|
182
|
+
id: itemId || path.basename(filePath, '.md'),
|
|
183
|
+
status: normalizeStatus(statusRaw, statusMap),
|
|
184
|
+
date: date,
|
|
185
|
+
source: rel
|
|
186
|
+
});
|
|
187
|
+
break; // One status per file
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return results;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- Build tracks from progressSources config ----
|
|
197
|
+
function buildTracks(progressSources, projectRoot) {
|
|
198
|
+
const sources = progressSources || [];
|
|
199
|
+
const tracks = [];
|
|
200
|
+
|
|
201
|
+
for (const ps of sources) {
|
|
202
|
+
const track = {
|
|
203
|
+
id: ps.track,
|
|
204
|
+
label: ps.label || ps.track,
|
|
205
|
+
source: ps.source,
|
|
206
|
+
phases: []
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (ps.source && ps.tableMap) {
|
|
210
|
+
// Table source (e.g. execution-plan §5.2)
|
|
211
|
+
const parsed = parseTableSource(ps.source, projectRoot);
|
|
212
|
+
if (parsed) {
|
|
213
|
+
const { rows } = parsed;
|
|
214
|
+
const { milestoneCol, taskCol, titleCol, actorCol, statusCol, batchCol } = ps.tableMap;
|
|
215
|
+
const statusMap = ps.statusNormalize || {};
|
|
216
|
+
const hierarchyRules = ps.hierarchyRules || {};
|
|
217
|
+
|
|
218
|
+
// Separate milestone rows from task rows
|
|
219
|
+
const parentPattern = hierarchyRules.parentPattern
|
|
220
|
+
? new RegExp(hierarchyRules.parentPattern)
|
|
221
|
+
: null;
|
|
222
|
+
const childPattern = hierarchyRules.childPattern
|
|
223
|
+
? new RegExp(hierarchyRules.childPattern)
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
const milestones = [];
|
|
227
|
+
let currentMilestone = null;
|
|
228
|
+
|
|
229
|
+
for (const row of rows) {
|
|
230
|
+
const id = (row[milestoneCol] || '').trim();
|
|
231
|
+
if (!id) continue;
|
|
232
|
+
|
|
233
|
+
if (parentPattern && parentPattern.test(id)) {
|
|
234
|
+
// This is a milestone row
|
|
235
|
+
currentMilestone = {
|
|
236
|
+
id: id,
|
|
237
|
+
name: (row[titleCol] || id).trim(),
|
|
238
|
+
status: normalizeStatus(row[statusCol], statusMap),
|
|
239
|
+
batch: batchCol ? (row[batchCol] || '').trim() : '',
|
|
240
|
+
tasks: []
|
|
241
|
+
};
|
|
242
|
+
milestones.push(currentMilestone);
|
|
243
|
+
} else if (childPattern && childPattern.test(id) && currentMilestone) {
|
|
244
|
+
// This is a task row — attach to current milestone
|
|
245
|
+
currentMilestone.tasks.push({
|
|
246
|
+
id: id,
|
|
247
|
+
title: (row[titleCol] || id).trim(),
|
|
248
|
+
status: normalizeStatus(row[statusCol], statusMap),
|
|
249
|
+
actor: actorCol ? (row[actorCol] || '').trim() : ''
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Sort milestones: extract number from ID for ordering
|
|
255
|
+
milestones.sort((a, b) => {
|
|
256
|
+
const na = parseInt(a.id.replace(/[^0-9]/g, '')) || 0;
|
|
257
|
+
const nb = parseInt(b.id.replace(/[^0-9]/g, '')) || 0;
|
|
258
|
+
return na - nb;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
track.phases = milestones;
|
|
262
|
+
}
|
|
263
|
+
} else if (ps.source && ps.statusLinePattern) {
|
|
264
|
+
// StatusLine source (e.g. decision files)
|
|
265
|
+
const items = parseStatusLineSource(ps.source, projectRoot);
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
track.phases.push({
|
|
268
|
+
id: item.id,
|
|
269
|
+
name: item.id,
|
|
270
|
+
status: item.status,
|
|
271
|
+
date: item.date,
|
|
272
|
+
source: item.source,
|
|
273
|
+
tasks: []
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
tracks.push(track);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Extract current node from execution-plan content
|
|
282
|
+
let currentNode = null;
|
|
283
|
+
const epSource = sources.find(ps =>
|
|
284
|
+
ps.source && ps.source.file &&
|
|
285
|
+
ps.source.file.includes('execution-plan')
|
|
286
|
+
);
|
|
287
|
+
if (epSource) {
|
|
288
|
+
const fp = path.join(projectRoot, epSource.source.file);
|
|
289
|
+
if (fs.existsSync(fp)) {
|
|
290
|
+
const content = fs.readFileSync(fp, 'utf-8');
|
|
291
|
+
const cnMatch = content.match(/当前节点[::]\s*(T\d+)/);
|
|
292
|
+
if (cnMatch) currentNode = cnMatch[1];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { tracks, currentNode };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
parseTableAtAnchor, normalizeStatus,
|
|
301
|
+
parseTableSource, parseStatusLineSource, buildTracks
|
|
302
|
+
};
|
package/src/scanner/scan.js
CHANGED
|
@@ -3,198 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { loadContract, classifyFile } = require('../contract/loader');
|
|
6
7
|
|
|
7
|
-
// ----
|
|
8
|
-
const PATTERNS = [
|
|
9
|
-
{
|
|
10
|
-
name: 'decision',
|
|
11
|
-
test: (filename) => /^\d{4}-\d{2}-\d{2}-.+\.md$/.test(filename),
|
|
12
|
-
classify: () => ({ kind: 'primary', elementType: 'decision', category: 'decision' }),
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
name: 'agent',
|
|
16
|
-
test: (filename) => /^\d{2}-.+\.md$/.test(filename),
|
|
17
|
-
classify: () => ({ kind: 'primary', elementType: 'agent', category: 'ops' }),
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
name: 'prompt',
|
|
21
|
-
test: (filename) => /^[A-Z][A-Z_]+\.md$/.test(filename),
|
|
22
|
-
classify: () => ({ kind: 'secondary', elementType: 'prompt', category: 'spec' }),
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
name: 'template',
|
|
26
|
-
test: (filename) => filename.startsWith('_') && filename.endsWith('.md'),
|
|
27
|
-
classify: () => ({ kind: 'secondary', elementType: 'template', category: 'template' }),
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'refactor',
|
|
31
|
-
test: (filename) => /^r\d+-op\d+-.+\.md$/i.test(filename),
|
|
32
|
-
classify: () => ({ kind: 'primary', elementType: 'refactor', category: 'decision' }),
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
name: 'intent',
|
|
36
|
-
test: (filename) => /^[a-z][a-z_]+\.md$/.test(filename),
|
|
37
|
-
classify: () => ({ kind: 'secondary', elementType: 'intent', category: 'spec' }),
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
// ---- Primary types (structural, always-show) ----
|
|
42
|
-
const PRIMARY_TYPES = new Set([
|
|
43
|
-
'agent', 'decision', 'milestone', 'roadmap', 'refactor',
|
|
44
|
-
'phase', 'task', 'subtask', 'conclusion',
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
// ---- MECE display categories (PM 6 大分类 + template) ----
|
|
48
|
-
// §10 libero PM 视角:路线图(在做什么)/决策(为什么)/规格(长什么样)/协作(怎么协作)/运维(跑在哪)/档案(走过什么路)
|
|
49
|
-
// Primary-only 类别 (roadmap, decision) 不出现在 filterType checkbox 中
|
|
50
|
-
const DISPLAY_CATEGORIES = {
|
|
51
|
-
roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog'] },
|
|
52
|
-
decision: { label: '决策记录', elementTypes: ['decision', 'refactor'] },
|
|
53
|
-
spec: { label: '业务规格', elementTypes: ['spec', 'intent', 'prompt'] },
|
|
54
|
-
convention: { label: '协作纪律', elementTypes: ['convention'] },
|
|
55
|
-
ops: { label: '运维与基建', elementTypes: ['agent', 'runbook'] },
|
|
56
|
-
archive: { label: '历史档案', elementTypes: ['journal', 'handover', 'plan_doc'] },
|
|
57
|
-
template: { label: '工具模板', elementTypes: ['template'] },
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
// Primary-only categories (all elementTypes in these categories are kind=primary, show always)
|
|
61
|
-
const PRIMARY_CATEGORIES = new Set(['roadmap', 'decision']);
|
|
62
|
-
|
|
63
|
-
function normalizeCategory(elementType) {
|
|
64
|
-
for (const [cat, def] of Object.entries(DISPLAY_CATEGORIES)) {
|
|
65
|
-
if (def.elementTypes.includes(elementType)) return cat;
|
|
66
|
-
}
|
|
67
|
-
return 'unknown'; // 标记未知类型,不猜测
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---- Path-based refinement (directory semantics) ----
|
|
71
|
-
// Path hints refine category/elementType but never downgrade kind (primary → secondary)
|
|
72
|
-
// Sets _pathRefined flag to prevent content from overriding path-based decisions
|
|
73
|
-
function refineByPath(relativePath, current) {
|
|
74
|
-
const rp = relativePath.toLowerCase();
|
|
75
|
-
let result = null;
|
|
76
|
-
|
|
77
|
-
// Archive paths: journal/, handover/, plans/, archive/ → always secondary
|
|
78
|
-
if (/(^|\/)(journal|handover|plans|archive)(\/|$)/.test(rp)) {
|
|
79
|
-
result = { kind: 'secondary', elementType: 'journal', category: 'archive' };
|
|
80
|
-
}
|
|
81
|
-
// Runbook paths
|
|
82
|
-
else if (/(^|\/)runbooks?(\/|$)/.test(rp)) {
|
|
83
|
-
result = { kind: 'secondary', elementType: 'runbook', category: 'ops' };
|
|
84
|
-
}
|
|
85
|
-
// Decision paths → upgrade to primary
|
|
86
|
-
else if (/(^|\/)decisions?(\/|$)/.test(rp)) {
|
|
87
|
-
result = { kind: 'primary', elementType: 'decision', category: 'decision' };
|
|
88
|
-
}
|
|
89
|
-
// Skill paths + AGENTS.md + agents/ → collaboration (secondary)
|
|
90
|
-
else if (/(^|\/)skills?(\/|$)/.test(rp) || /(^|\/)agents?(\/|$)/.test(rp) || /^AGENTS\.md$/i.test(rp)) {
|
|
91
|
-
result = { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
92
|
-
}
|
|
93
|
-
// Spec/refactor paths only refine when current type is weak or unknown
|
|
94
|
-
else if (/(^|\/)specs?(\/|$)/.test(rp) && current.elementType === 'unknown') {
|
|
95
|
-
result = { kind: 'secondary', elementType: 'spec', category: 'spec' };
|
|
96
|
-
}
|
|
97
|
-
else if (/(^|\/)refactors?(\/|$)/.test(rp) && current.elementType === 'unknown') {
|
|
98
|
-
result = { kind: 'primary', elementType: 'refactor', category: 'decision' };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (result) {
|
|
102
|
-
result._pathRefined = true;
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
return current;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---- Content-based refinement (auto-detection, no SUMMARY block) ----
|
|
109
|
-
function refineByContent(content, current, relativePath) {
|
|
110
|
-
const rp = relativePath || '';
|
|
111
|
-
|
|
112
|
-
// NEVER override archive classification (path-based takes priority)
|
|
113
|
-
if (current.category === 'archive') return current;
|
|
114
|
-
// Path-based reclassification is stronger than content guesswork
|
|
115
|
-
const canUpgradeKind = !current._pathRefined;
|
|
116
|
-
|
|
117
|
-
// H1 heading analysis
|
|
118
|
-
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
119
|
-
if (h1Match) {
|
|
120
|
-
const h1 = h1Match[1];
|
|
121
|
-
|
|
122
|
-
if (canUpgradeKind && /@(PM|Architect|Coder|Tester|CloudOps|PromptAuthor)/.test(h1)) {
|
|
123
|
-
return { kind: 'primary', elementType: 'agent', category: 'ops' };
|
|
124
|
-
}
|
|
125
|
-
if (canUpgradeKind && /(Phase|第\s*\d+\s*批|阶段\s*\d+|Batch\s*\d+|^S\d+)/i.test(h1)) {
|
|
126
|
-
return { kind: 'primary', elementType: 'phase', category: 'roadmap' };
|
|
127
|
-
}
|
|
128
|
-
if (canUpgradeKind && /M\d+/.test(h1) && /(里程碑|Milestone|迁移)/i.test(h1)) {
|
|
129
|
-
return { kind: 'primary', elementType: 'milestone', category: 'roadmap' };
|
|
130
|
-
}
|
|
131
|
-
if (canUpgradeKind && /(结论|Conclusion|总结|Post-mortem|复盘)/.test(h1)) {
|
|
132
|
-
return { kind: 'primary', elementType: 'conclusion', category: 'roadmap' };
|
|
133
|
-
}
|
|
134
|
-
if (canUpgradeKind && /\bR\d+\b/.test(h1) && /\b(roadmap|实施|计划|Plan)\b/i.test(h1)) {
|
|
135
|
-
return { kind: 'primary', elementType: 'roadmap', category: 'roadmap' };
|
|
136
|
-
}
|
|
137
|
-
if (canUpgradeKind && /\bR\d+\b/.test(h1) && /\b(OP-\d+|spec|重构)\b/i.test(h1)) {
|
|
138
|
-
return { kind: 'primary', elementType: 'refactor', category: 'decision' };
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 5-field journal detection → secondary
|
|
143
|
-
// Real journals have date in path (e.g. docs/journal/2026-05/2026-05-23-debug.md)
|
|
144
|
-
// Files like README.md that mention the 5-field format are conventions, not journals
|
|
145
|
-
const first500 = content.substring(0, 500);
|
|
146
|
-
const has5Fields =
|
|
147
|
-
/场景|卡点|错误/.test(first500) &&
|
|
148
|
-
/尝试/.test(first500) &&
|
|
149
|
-
/解法/.test(first500) &&
|
|
150
|
-
/harness.*建议/.test(first500);
|
|
151
|
-
if (has5Fields && /\d{4}-\d{2}/.test(rp)) {
|
|
152
|
-
return { kind: 'secondary', elementType: 'journal', category: 'archive' };
|
|
153
|
-
}
|
|
154
|
-
// If 5-field matched but no date in path → likely a journal README/convention
|
|
155
|
-
if (has5Fields && !/\d{4}-\d{2}/.test(rp)) {
|
|
156
|
-
return { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Convention table detection → secondary
|
|
160
|
-
if (/(简称|缩写|abbreviation|术语|term|定义|def)/i.test(first500)) {
|
|
161
|
-
return { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return current;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Apply SUMMARY block as fallback correction (only when auto-detection returned 'unknown')
|
|
168
|
-
function applySummaryCorrection(summary, current) {
|
|
169
|
-
if (!summary || !summary.type) return current;
|
|
170
|
-
// Correct when: auto-detection is unknown, OR SUMMARY declares a primary type (overrides filename pattern)
|
|
171
|
-
if (current.elementType === 'unknown' || PRIMARY_TYPES.has(summary.type)) {
|
|
172
|
-
const kind = summary.kind || (PRIMARY_TYPES.has(summary.type) ? 'primary' : 'secondary');
|
|
173
|
-
return { kind, elementType: summary.type, category: mapTypeToCategory(summary.type) };
|
|
174
|
-
}
|
|
175
|
-
// If auto-detection gave a secondary type but SUMMARY has kind override
|
|
176
|
-
if (summary.kind) {
|
|
177
|
-
return { kind: summary.kind, elementType: current.elementType, category: current.category };
|
|
178
|
-
}
|
|
179
|
-
return current;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function mapTypeToCategory(type) {
|
|
183
|
-
const map = {
|
|
184
|
-
phase: 'roadmap', milestone: 'roadmap', task: 'roadmap', subtask: 'roadmap',
|
|
185
|
-
roadmap: 'roadmap', backlog: 'roadmap', conclusion: 'roadmap',
|
|
186
|
-
decision: 'decision', refactor: 'decision',
|
|
187
|
-
spec: 'spec', intent: 'spec', prompt: 'spec',
|
|
188
|
-
convention: 'convention',
|
|
189
|
-
agent: 'ops', runbook: 'ops',
|
|
190
|
-
journal: 'archive', handover: 'archive', plan_doc: 'archive',
|
|
191
|
-
template: 'template',
|
|
192
|
-
};
|
|
193
|
-
return map[type] || 'unknown';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ---- Exclusion list (禁读清单) ----
|
|
197
|
-
// Simple glob → regex: supports ** (any depth), * (within segment), ? (single char)
|
|
8
|
+
// ---- Glob to Regex ----
|
|
198
9
|
function globToRegex(pattern) {
|
|
199
10
|
let re = '';
|
|
200
11
|
const parts = pattern.split('/');
|
|
@@ -210,13 +21,13 @@ function globToRegex(pattern) {
|
|
|
210
21
|
re += seg;
|
|
211
22
|
}
|
|
212
23
|
}
|
|
213
|
-
// '**' at end should match everything under that directory
|
|
214
24
|
if (pattern.endsWith('/**')) {
|
|
215
25
|
re += '(?:\\/.*)?';
|
|
216
26
|
}
|
|
217
27
|
return new RegExp('^' + re + '$', 'i');
|
|
218
28
|
}
|
|
219
29
|
|
|
30
|
+
// ---- Exclusion list ----
|
|
220
31
|
function applyExclusions(files, excludeConfig, projectRoot) {
|
|
221
32
|
if (!excludeConfig) return files;
|
|
222
33
|
|
|
@@ -294,31 +105,37 @@ function extractTimeInfo(content, filename, fileModified) {
|
|
|
294
105
|
return { date: mtimeISO, dateSource: 'mtime' };
|
|
295
106
|
}
|
|
296
107
|
|
|
297
|
-
// ----
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
108
|
+
// ---- Build display categories from contract ----
|
|
109
|
+
function buildDisplayCategories(contract) {
|
|
110
|
+
if (!contract || !contract.categories) return {};
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const cat of contract.categories) {
|
|
113
|
+
result[cat.id] = {
|
|
114
|
+
label: cat.label,
|
|
115
|
+
elementTypes: [],
|
|
116
|
+
defaultHidden: cat.defaultHidden || false
|
|
117
|
+
};
|
|
303
118
|
}
|
|
304
|
-
return
|
|
119
|
+
return result;
|
|
305
120
|
}
|
|
306
121
|
|
|
307
122
|
// ---- Main scan ----
|
|
308
123
|
function scan(projectRoot) {
|
|
309
|
-
const config =
|
|
124
|
+
const { contract, config } = loadContract(projectRoot);
|
|
310
125
|
const mdFiles = walkMd(projectRoot);
|
|
311
126
|
|
|
312
|
-
// Build file objects with relative paths for exclusion checking
|
|
313
127
|
let fileEntries = mdFiles.map(f => ({
|
|
314
128
|
full: f,
|
|
315
129
|
relative: path.relative(projectRoot, f).replace(/\\/g, '/'),
|
|
316
130
|
}));
|
|
317
131
|
|
|
318
|
-
// Apply exclusion list
|
|
319
|
-
fileEntries = applyExclusions(fileEntries, config
|
|
132
|
+
// Apply exclusion list
|
|
133
|
+
fileEntries = applyExclusions(fileEntries, config._exclude, projectRoot);
|
|
320
134
|
|
|
321
|
-
const files =
|
|
135
|
+
const files = [];
|
|
136
|
+
const metaFiles = [];
|
|
137
|
+
|
|
138
|
+
for (const fe of fileEntries) {
|
|
322
139
|
const f = fe.full;
|
|
323
140
|
const relative = fe.relative;
|
|
324
141
|
const filename = path.basename(f);
|
|
@@ -326,40 +143,16 @@ function scan(projectRoot) {
|
|
|
326
143
|
const content = fs.readFileSync(f, 'utf-8');
|
|
327
144
|
const summary = extractSummary(content);
|
|
328
145
|
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
let meta = {}; // carries flags like _pathRefined across the chain
|
|
332
|
-
|
|
333
|
-
// Step 2: path-based refinement (directory semantics)
|
|
334
|
-
const pathRefined = refineByPath(relative, { kind, elementType, category });
|
|
335
|
-
kind = pathRefined.kind;
|
|
336
|
-
elementType = pathRefined.elementType;
|
|
337
|
-
category = pathRefined.category;
|
|
338
|
-
if (pathRefined._pathRefined) meta._pathRefined = true;
|
|
339
|
-
|
|
340
|
-
// Step 3: auto-detection by content signals
|
|
341
|
-
const refined = refineByContent(content, { kind, elementType, category, _pathRefined: meta._pathRefined }, relative);
|
|
342
|
-
kind = refined.kind;
|
|
343
|
-
elementType = refined.elementType;
|
|
344
|
-
category = refined.category;
|
|
146
|
+
// docContract-driven classification
|
|
147
|
+
const classification = classifyFile(relative, contract, summary);
|
|
345
148
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
elementType = corrected.elementType;
|
|
350
|
-
category = corrected.category;
|
|
351
|
-
|
|
352
|
-
// Step 5: config paths override category
|
|
353
|
-
if (config && config.paths) {
|
|
354
|
-
for (const [cat, cfgPath] of Object.entries(config.paths)) {
|
|
355
|
-
const normalizedCfg = cfgPath.replace(/\\/g, '/');
|
|
356
|
-
if (relative.startsWith(normalizedCfg) || relative === normalizedCfg) {
|
|
357
|
-
category = cat;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
149
|
+
if (!classification || classification.isMeta) {
|
|
150
|
+
metaFiles.push({ path: relative, reason: classification ? 'meta' : 'excluded' });
|
|
151
|
+
continue;
|
|
361
152
|
}
|
|
362
153
|
|
|
154
|
+
const { category, elementType } = classification;
|
|
155
|
+
|
|
363
156
|
// Extract time info
|
|
364
157
|
const timeInfo = extractTimeInfo(content, filename, stat.mtime.toISOString());
|
|
365
158
|
|
|
@@ -367,24 +160,27 @@ function scan(projectRoot) {
|
|
|
367
160
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
368
161
|
const title = titleMatch ? titleMatch[1] : filename.replace(/\.md$/, '');
|
|
369
162
|
|
|
370
|
-
|
|
163
|
+
// Determine kind: roadmap, decision, todo → primary; others → secondary
|
|
164
|
+
const kind = (category === 'roadmap' || category === 'decision' || category === 'todo')
|
|
165
|
+
? 'primary' : 'secondary';
|
|
166
|
+
|
|
167
|
+
files.push({
|
|
371
168
|
path: relative,
|
|
372
169
|
filename,
|
|
373
170
|
category,
|
|
374
|
-
kind,
|
|
375
|
-
elementType,
|
|
376
|
-
type: elementType,
|
|
171
|
+
kind,
|
|
172
|
+
elementType,
|
|
173
|
+
type: elementType,
|
|
377
174
|
title,
|
|
378
175
|
size: stat.size,
|
|
379
176
|
modified: stat.mtime.toISOString(),
|
|
380
|
-
date: timeInfo.date,
|
|
381
|
-
dateSource: timeInfo.dateSource
|
|
177
|
+
date: timeInfo.date,
|
|
178
|
+
dateSource: timeInfo.dateSource,
|
|
382
179
|
hasSummary: summary !== null,
|
|
383
180
|
summary: summary || null,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
386
183
|
|
|
387
|
-
// Collect unique element types (v2 additions)
|
|
388
184
|
const primaryCount = files.filter(f => f.kind === 'primary').length;
|
|
389
185
|
const secondaryCount = files.filter(f => f.kind === 'secondary').length;
|
|
390
186
|
|
|
@@ -392,20 +188,20 @@ function scan(projectRoot) {
|
|
|
392
188
|
scannedAt: new Date().toISOString(),
|
|
393
189
|
projectRoot,
|
|
394
190
|
totalFiles: files.length,
|
|
395
|
-
primaryCount,
|
|
396
|
-
secondaryCount,
|
|
191
|
+
primaryCount,
|
|
192
|
+
secondaryCount,
|
|
397
193
|
filesWithSummary: files.filter(f => f.hasSummary).length,
|
|
398
194
|
categories: [...new Set(files.map(f => f.category))],
|
|
399
|
-
elementTypes: [...new Set(files.map(f => f.elementType))],
|
|
195
|
+
elementTypes: [...new Set(files.map(f => f.elementType))],
|
|
196
|
+
metaExcluded: metaFiles.length,
|
|
400
197
|
files,
|
|
401
198
|
};
|
|
402
199
|
}
|
|
403
200
|
|
|
404
201
|
module.exports = {
|
|
405
|
-
scan, walkMd,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
PRIMARY_CATEGORIES,
|
|
202
|
+
scan, walkMd, extractSummary, extractTimeInfo,
|
|
203
|
+
applyExclusions, loadConfig, buildDisplayCategories,
|
|
204
|
+
classifyFile: require('../contract/loader').classifyFile
|
|
409
205
|
};
|
|
410
206
|
|
|
411
207
|
if (require.main === module) {
|