sandtable 0.4.0 → 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 +1318 -834
- package/package.json +1 -1
- package/server.js +10 -1
- package/src/builder/build.js +121 -230
- package/src/check/check.js +137 -0
- package/src/cli/sandtable.js +36 -0
- 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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "sandtable-default",
|
|
3
|
+
"categories": [
|
|
4
|
+
{ "id": "roadmap", "label": "路线图与进度", "order": 1,
|
|
5
|
+
"paths": ["docs/plan/**", "docs/specs/*roadmap*", "docs/specs/*phase*", "docs/specs/*bug-collect*"] },
|
|
6
|
+
{ "id": "decision", "label": "决策记录", "order": 2,
|
|
7
|
+
"paths": ["docs/specs/decisions/**"] },
|
|
8
|
+
{ "id": "spec", "label": "业务规格", "order": 3,
|
|
9
|
+
"paths": ["docs/specs/**"] },
|
|
10
|
+
{ "id": "convention", "label": "协作纪律", "order": 4,
|
|
11
|
+
"paths": ["docs/skills/**", "docs/agents/**", "docs/conventions/**", "AGENTS.md", "CLAUDE.md"] },
|
|
12
|
+
{ "id": "ops", "label": "运维与基建", "order": 5,
|
|
13
|
+
"paths": ["docs/runbooks/**"] },
|
|
14
|
+
{ "id": "archive", "label": "历史档案", "order": 6,
|
|
15
|
+
"paths": ["docs/plans/**", "docs/journal/**", "docs/archive/**"], "defaultHidden": true }
|
|
16
|
+
],
|
|
17
|
+
"meta": [
|
|
18
|
+
"docs/*-reference.md",
|
|
19
|
+
"docs/*-glossary.md"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ---- Glob to Regex ----
|
|
8
|
+
const regexCache = {};
|
|
9
|
+
|
|
10
|
+
function globToRegex(pattern) {
|
|
11
|
+
if (regexCache[pattern]) return regexCache[pattern];
|
|
12
|
+
|
|
13
|
+
let re = '';
|
|
14
|
+
const parts = pattern.split('/');
|
|
15
|
+
let skipSep = false;
|
|
16
|
+
for (let i = 0; i < parts.length; i++) {
|
|
17
|
+
if (parts[i] === '**') {
|
|
18
|
+
if (i === 0 && parts.length > 1) {
|
|
19
|
+
// Leading ** with more parts: match zero or more leading directories
|
|
20
|
+
re += '(?:.*\\/)?';
|
|
21
|
+
skipSep = true;
|
|
22
|
+
} else if (i > 0 && i < parts.length - 1) {
|
|
23
|
+
// Middle **: required / + optional wildcard dirs including trailing /
|
|
24
|
+
re += '\\/(?:.*\\/)?';
|
|
25
|
+
skipSep = true;
|
|
26
|
+
} else {
|
|
27
|
+
if (i > 0) re += '\\/';
|
|
28
|
+
re += '.*';
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
if (i > 0 && !skipSep) {
|
|
32
|
+
re += '\\/';
|
|
33
|
+
}
|
|
34
|
+
skipSep = false;
|
|
35
|
+
let seg = parts[i]
|
|
36
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
37
|
+
.replace(/\?/g, '.')
|
|
38
|
+
.replace(/\*/g, '[^/]*');
|
|
39
|
+
re += seg;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (pattern.endsWith('/**')) {
|
|
43
|
+
re += '(?:\\/.*)?';
|
|
44
|
+
}
|
|
45
|
+
// Case-insensitive matching for cross-platform compatibility
|
|
46
|
+
const regex = new RegExp('^' + re + '$', 'i');
|
|
47
|
+
regexCache[pattern] = regex;
|
|
48
|
+
return regex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Load contract: user config > built-in default ----
|
|
52
|
+
function loadContract(projectRoot) {
|
|
53
|
+
const configPath = path.join(projectRoot, '.sandtable.json');
|
|
54
|
+
let userContract = null;
|
|
55
|
+
|
|
56
|
+
let config = {
|
|
57
|
+
_progressSources: [],
|
|
58
|
+
_events: { gitLog: true, maxGitLogEntries: 100 },
|
|
59
|
+
_exclude: null
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(configPath)) {
|
|
63
|
+
try {
|
|
64
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
65
|
+
if (config.docContract) {
|
|
66
|
+
userContract = config.docContract;
|
|
67
|
+
}
|
|
68
|
+
config._progressSources = config.progressSources || [];
|
|
69
|
+
config._events = config.events || { gitLog: true, maxGitLogEntries: 100 };
|
|
70
|
+
config._exclude = config.exclude || null;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Config parse error — fall through to default
|
|
73
|
+
console.warn('sandtable: .sandtable.json parse error, using default contract —', e.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: built-in default contract when no user contract found
|
|
78
|
+
if (!userContract) {
|
|
79
|
+
const defaultPath = path.join(__dirname, 'default-contract.json');
|
|
80
|
+
userContract = JSON.parse(fs.readFileSync(defaultPath, 'utf-8'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { contract: userContract, config };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Classify a single file by docContract ----
|
|
87
|
+
// Returns { category, elementType, isMeta } or null if excluded
|
|
88
|
+
function classifyFile(relativePath, contract, summaryBlock) {
|
|
89
|
+
if (!contract) return null;
|
|
90
|
+
|
|
91
|
+
// 1. Check meta exclusion
|
|
92
|
+
const meta = contract.meta || [];
|
|
93
|
+
for (const m of meta) {
|
|
94
|
+
const re = globToRegex(m);
|
|
95
|
+
if (re.test(relativePath)) {
|
|
96
|
+
return { category: 'meta', elementType: 'meta', isMeta: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. SUMMARY block explicit category override (highest priority)
|
|
101
|
+
if (summaryBlock && summaryBlock.category) {
|
|
102
|
+
const cat = contract.categories.find(c => c.id === summaryBlock.category);
|
|
103
|
+
if (cat) {
|
|
104
|
+
return {
|
|
105
|
+
category: cat.id,
|
|
106
|
+
elementType: summaryBlock.type || 'unknown',
|
|
107
|
+
isMeta: false
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Longest-match glob across all category paths
|
|
113
|
+
let bestMatch = null;
|
|
114
|
+
let bestLen = 0;
|
|
115
|
+
|
|
116
|
+
for (const cat of contract.categories) {
|
|
117
|
+
for (const pat of (cat.paths || [])) {
|
|
118
|
+
const re = globToRegex(pat);
|
|
119
|
+
if (re.test(relativePath)) {
|
|
120
|
+
if (pat.length > bestLen) {
|
|
121
|
+
bestLen = pat.length;
|
|
122
|
+
bestMatch = cat;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (bestMatch) {
|
|
129
|
+
return {
|
|
130
|
+
category: bestMatch.id,
|
|
131
|
+
elementType: inferElementType(relativePath, bestMatch.id),
|
|
132
|
+
isMeta: false
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Unknown
|
|
137
|
+
return { category: 'unknown', elementType: 'unknown', isMeta: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Element type inference map (module-level to avoid re-creation) ----
|
|
141
|
+
const ELEMENT_TYPE_MAP = {
|
|
142
|
+
roadmap: [
|
|
143
|
+
{ test: /roadmap|execution-plan/, type: 'roadmap' },
|
|
144
|
+
{ test: /bug-collect/, type: 'backlog' },
|
|
145
|
+
{ test: /phase/, type: 'phase' }
|
|
146
|
+
],
|
|
147
|
+
decision: [
|
|
148
|
+
{ test: /\d{4}-\d{2}-\d{2}/, type: 'decision' },
|
|
149
|
+
{ test: /refactor/i, type: 'refactor' }
|
|
150
|
+
],
|
|
151
|
+
spec: [
|
|
152
|
+
{ test: /intents?\//, type: 'intent' },
|
|
153
|
+
{ test: /refactors?\//, type: 'refactor' },
|
|
154
|
+
{ test: /testing\//, type: 'spec' },
|
|
155
|
+
{ test: /prompts?\//, type: 'prompt' },
|
|
156
|
+
{ test: /template/, type: 'template' },
|
|
157
|
+
{ test: /architecture/, type: 'spec' }
|
|
158
|
+
],
|
|
159
|
+
convention: [
|
|
160
|
+
{ test: /skills?\//, type: 'convention' },
|
|
161
|
+
{ test: /agents?\//, type: 'convention' },
|
|
162
|
+
{ test: /AGENTS\.md|CLAUDE\.md/, type: 'convention' }
|
|
163
|
+
],
|
|
164
|
+
ops: [
|
|
165
|
+
{ test: /runbooks?\//, type: 'runbook' }
|
|
166
|
+
],
|
|
167
|
+
archive: [
|
|
168
|
+
{ test: /journal/, type: 'journal' },
|
|
169
|
+
{ test: /plans?\//, type: 'plan_doc' },
|
|
170
|
+
{ test: /handover/, type: 'handover' }
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ---- Infer elementType from category + path ----
|
|
175
|
+
function inferElementType(relativePath, category) {
|
|
176
|
+
const rp = relativePath.toLowerCase();
|
|
177
|
+
const rules = ELEMENT_TYPE_MAP[category] || [];
|
|
178
|
+
for (const rule of rules) {
|
|
179
|
+
if (rule.test.test(rp)) return rule.type;
|
|
180
|
+
}
|
|
181
|
+
return category;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Get ordered list of categories for UI ----
|
|
185
|
+
function getCategoryList(contract) {
|
|
186
|
+
if (!contract) return [];
|
|
187
|
+
return [...contract.categories].sort((a, b) => a.order - b.order);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- Check if a file path should be excluded ----
|
|
191
|
+
function isExcluded(relativePath, excludeConfig) {
|
|
192
|
+
if (!excludeConfig) return false;
|
|
193
|
+
const patternRes = (excludeConfig.patterns || []).map(p => globToRegex(p));
|
|
194
|
+
for (const re of patternRes) {
|
|
195
|
+
if (re.test(relativePath)) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
loadContract, classifyFile, getCategoryList,
|
|
202
|
+
isExcluded, globToRegex, inferElementType
|
|
203
|
+
};
|
|
@@ -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
|
+
};
|