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
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -36,17 +36,26 @@ const MIME = {
|
|
|
36
36
|
'.svg': 'image/svg+xml',
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
// Only serve from these
|
|
39
|
+
// Only serve from these directories — project sources are not exposed
|
|
40
40
|
const ALLOWED_PREFIXES = [
|
|
41
41
|
path.join(ROOT, 'dashboard'),
|
|
42
42
|
path.join(ROOT, 'data'),
|
|
43
|
+
path.join(ROOT, 'docs'),
|
|
43
44
|
];
|
|
44
45
|
|
|
46
|
+
// Root-level files that are safe to serve (documentation, config)
|
|
47
|
+
const ROOT_ALLOWED = new Set([
|
|
48
|
+
'AGENTS.md', 'CLAUDE.md', 'README.md', 'INSTALL.md',
|
|
49
|
+
'CHANGELOG.md', 'CONTRIBUTING.md',
|
|
50
|
+
]);
|
|
51
|
+
|
|
45
52
|
function isAllowed(filePath) {
|
|
46
53
|
if (!filePath.startsWith(ROOT)) return false;
|
|
47
54
|
for (const prefix of ALLOWED_PREFIXES) {
|
|
48
55
|
if (filePath.startsWith(prefix)) return true;
|
|
49
56
|
}
|
|
57
|
+
// Allow specific root-level documentation files
|
|
58
|
+
if (ROOT_ALLOWED.has(path.relative(ROOT, filePath))) return true;
|
|
50
59
|
return false;
|
|
51
60
|
}
|
|
52
61
|
|
package/src/builder/build.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { scan
|
|
6
|
+
const { scan } = require('../scanner/scan');
|
|
7
|
+
const { buildTracks } = require('../progress/parser');
|
|
8
|
+
const { loadContract } = require('../contract/loader');
|
|
7
9
|
|
|
8
10
|
// ---- Markdown Table Parser (unchanged from v1) ----
|
|
9
11
|
function parseMarkdownTables(content) {
|
|
@@ -30,201 +32,45 @@ function parseMarkdownTables(content) {
|
|
|
30
32
|
return results;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
// ----
|
|
34
|
-
function
|
|
35
|
+
// ---- Tracks to Elements (converts parser output to timeline element format) ----
|
|
36
|
+
function tracksToElements(tracks) {
|
|
35
37
|
const elements = [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const fp = path.join(projectRoot, pf.path);
|
|
41
|
-
if (!fs.existsSync(fp)) continue;
|
|
42
|
-
const content = fs.readFileSync(fp, 'utf-8');
|
|
43
|
-
const tables = parseMarkdownTables(content);
|
|
44
|
-
const hasTaskCols = tables.length > 0 &&
|
|
45
|
-
(Object.keys(tables[0]).some(k => /任务|task|#/.test(k)));
|
|
46
|
-
if (hasTaskCols) {
|
|
47
|
-
for (const row of tables) {
|
|
48
|
-
taskRows.push({ ...row, _source: pf.path });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Build phases from milestone/plan files with markdown tables
|
|
54
|
-
const milestoneFiles = planFiles.filter(f =>
|
|
55
|
-
f.elementType === 'milestone' ||
|
|
56
|
-
(f.summary && f.summary.type === 'milestone')
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
for (const mf of milestoneFiles) {
|
|
60
|
-
const fp = path.join(projectRoot, mf.path);
|
|
61
|
-
if (!fs.existsSync(fp)) continue;
|
|
62
|
-
const content = fs.readFileSync(fp, 'utf-8');
|
|
63
|
-
const rows = parseMarkdownTables(content);
|
|
64
|
-
|
|
65
|
-
const milestones = rows.map((row, i) => {
|
|
66
|
-
const keys = Object.keys(row);
|
|
67
|
-
const milestoneName = row[keys[0]] || '';
|
|
68
|
-
const version = row[keys[1]] || '';
|
|
69
|
-
const signal = row[keys[2]] || '';
|
|
70
|
-
const statusRaw = (row[keys[3]] || 'pending').toLowerCase();
|
|
71
|
-
const status = statusRaw.includes('in_progress') ? 'in_progress'
|
|
72
|
-
: statusRaw.includes('complete') ? 'completed'
|
|
73
|
-
: statusRaw.includes('block') ? 'blocked'
|
|
74
|
-
: 'pending';
|
|
75
|
-
|
|
76
|
-
// Match subtasks from task board
|
|
77
|
-
const subtasks = taskRows
|
|
78
|
-
.filter(tr => {
|
|
79
|
-
const taskName = tr[Object.keys(tr)[1]] || tr[Object.keys(tr)[0]] || '';
|
|
80
|
-
return taskName.includes(milestoneName) ||
|
|
81
|
-
milestoneName.includes(taskName.substring(0, 6));
|
|
82
|
-
})
|
|
83
|
-
.slice(0, 3)
|
|
84
|
-
.map(tr => {
|
|
85
|
-
const tKeys = Object.keys(tr);
|
|
86
|
-
const statusKey = tKeys.find(k => /状态|status/i.test(k));
|
|
87
|
-
const statusCol = statusKey ? tr[statusKey] : (tr[tKeys[4]] || tr[tKeys[3]] || '');
|
|
88
|
-
const isCompleted = /✅|\[x\]|completed|complete/i.test(statusCol);
|
|
89
|
-
const isInProgress = /⏳|\[\s*\]|in.progress/i.test(statusCol);
|
|
90
|
-
const typeKey = tKeys.find(k => /类型|type/i.test(k));
|
|
91
|
-
const typeCol = typeKey ? tr[typeKey] : (tr[tKeys[3]] || tr[tKeys[2]] || '');
|
|
92
|
-
return {
|
|
93
|
-
id: tr[tKeys[0]] || '',
|
|
94
|
-
name: tr[tKeys[1]] || tr[tKeys[0]] || '',
|
|
95
|
-
status: isCompleted ? 'completed' : isInProgress ? 'in_progress' : 'pending',
|
|
96
|
-
kind: 'primary',
|
|
97
|
-
elementType: 'subtask',
|
|
98
|
-
timeGroup: 'current',
|
|
99
|
-
timeLabel: '',
|
|
100
|
-
date: null,
|
|
101
|
-
source: { file: tr._source || mf.path, title: mf.title },
|
|
102
|
-
summary: '',
|
|
103
|
-
tags: [],
|
|
104
|
-
related: [],
|
|
105
|
-
children: [],
|
|
106
|
-
};
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
id: 'ms-' + (milestoneName.replace(/^M/, '').substring(0, 10) || `M${i + 1}`),
|
|
111
|
-
kind: 'primary',
|
|
112
|
-
elementType: 'milestone',
|
|
113
|
-
category: 'roadmap',
|
|
114
|
-
name: version ? `${milestoneName} (${version})` : milestoneName,
|
|
115
|
-
status,
|
|
116
|
-
timeGroup: 'current',
|
|
117
|
-
timeLabel: signal,
|
|
118
|
-
date: mf.date || null,
|
|
119
|
-
source: { file: mf.path, title: mf.title },
|
|
120
|
-
summary: '',
|
|
121
|
-
tags: [],
|
|
122
|
-
related: [],
|
|
123
|
-
children: subtasks,
|
|
124
|
-
order: i + 1,
|
|
125
|
-
};
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
elements.push({
|
|
129
|
-
id: 'phase-' + (elements.length + 1),
|
|
130
|
-
kind: 'primary',
|
|
131
|
-
elementType: 'phase',
|
|
132
|
-
category: 'roadmap',
|
|
133
|
-
name: mf.summary ? mf.summary.summary : mf.title,
|
|
134
|
-
status: mf.summary ? mf.summary.status : 'in_progress',
|
|
135
|
-
timeGroup: 'current',
|
|
136
|
-
timeLabel: '',
|
|
137
|
-
date: mf.date || null,
|
|
138
|
-
source: { file: mf.path, title: mf.title },
|
|
139
|
-
summary: mf.summary ? (mf.summary.summary || '') : '',
|
|
140
|
-
tags: mf.summary ? (mf.summary.tags || []) : [],
|
|
141
|
-
related: mf.summary ? (mf.summary.related || []) : [],
|
|
142
|
-
children: milestones,
|
|
143
|
-
order: elements.length + 1,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Fallback: default phase from any plan-category files with summaries
|
|
148
|
-
if (elements.length === 0 && planFiles.length > 0) {
|
|
149
|
-
const tasks = planFiles
|
|
150
|
-
.filter(f => f.summary)
|
|
151
|
-
.map(f => ({
|
|
152
|
-
id: 'task-' + f.path.replace(/[/.]/g, '-'),
|
|
153
|
-
kind: 'primary',
|
|
154
|
-
elementType: 'task',
|
|
155
|
-
category: 'roadmap',
|
|
156
|
-
name: f.summary.summary,
|
|
157
|
-
status: f.summary.status || 'pending',
|
|
158
|
-
timeGroup: 'current',
|
|
159
|
-
timeLabel: '',
|
|
160
|
-
date: f.date || null,
|
|
161
|
-
source: { file: f.path, title: f.title },
|
|
162
|
-
summary: f.summary.summary || '',
|
|
163
|
-
tags: f.summary.tags || [],
|
|
164
|
-
related: f.summary.related || [],
|
|
165
|
-
children: [],
|
|
166
|
-
}));
|
|
167
|
-
|
|
168
|
-
elements.push({
|
|
169
|
-
id: 'phase-default',
|
|
170
|
-
kind: 'primary',
|
|
171
|
-
elementType: 'phase',
|
|
172
|
-
category: 'roadmap',
|
|
173
|
-
name: '当前阶段',
|
|
174
|
-
status: 'in_progress',
|
|
175
|
-
timeGroup: 'current',
|
|
176
|
-
timeLabel: '',
|
|
177
|
-
date: null,
|
|
178
|
-
source: { file: '', title: '' },
|
|
179
|
-
summary: '',
|
|
180
|
-
tags: [],
|
|
181
|
-
related: [],
|
|
182
|
-
children: [{
|
|
183
|
-
id: 'ms-current',
|
|
38
|
+
for (const track of tracks) {
|
|
39
|
+
for (const phase of track.phases) {
|
|
40
|
+
elements.push({
|
|
41
|
+
id: 'ms-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
|
|
184
42
|
kind: 'primary',
|
|
185
43
|
elementType: 'milestone',
|
|
186
44
|
category: 'roadmap',
|
|
187
|
-
name:
|
|
188
|
-
status:
|
|
45
|
+
name: phase.name,
|
|
46
|
+
status: phase.status,
|
|
189
47
|
timeGroup: 'current',
|
|
190
|
-
timeLabel: '',
|
|
191
|
-
date: null,
|
|
192
|
-
source: { file:
|
|
48
|
+
timeLabel: phase.batch || '',
|
|
49
|
+
date: phase.date || null,
|
|
50
|
+
source: { file: track.source, title: phase.name },
|
|
193
51
|
summary: '',
|
|
194
|
-
tags: [],
|
|
52
|
+
tags: [track.id],
|
|
195
53
|
related: [],
|
|
196
|
-
children: tasks
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
status: cf.summary ? (cf.summary.status || 'completed') : 'completed',
|
|
216
|
-
timeGroup: 'past',
|
|
217
|
-
timeLabel: cf.date || '',
|
|
218
|
-
date: cf.date || null,
|
|
219
|
-
source: { file: cf.path, title: cf.title },
|
|
220
|
-
summary: cf.summary ? (cf.summary.summary || '') : '',
|
|
221
|
-
tags: cf.summary ? (cf.summary.tags || []) : [],
|
|
222
|
-
related: cf.summary ? (cf.summary.related || []) : [],
|
|
223
|
-
children: [],
|
|
224
|
-
order: 0,
|
|
225
|
-
});
|
|
54
|
+
children: (phase.tasks || []).map(t => ({
|
|
55
|
+
id: 'task-' + t.id.replace(/[^a-zA-Z0-9]/g, '-'),
|
|
56
|
+
kind: 'primary',
|
|
57
|
+
elementType: 'subtask',
|
|
58
|
+
category: 'roadmap',
|
|
59
|
+
name: t.title,
|
|
60
|
+
status: t.status,
|
|
61
|
+
timeGroup: 'current',
|
|
62
|
+
timeLabel: '',
|
|
63
|
+
date: null,
|
|
64
|
+
source: { file: track.source, title: t.title },
|
|
65
|
+
summary: '',
|
|
66
|
+
tags: [track.id, t.actor].filter(Boolean),
|
|
67
|
+
related: [],
|
|
68
|
+
children: [],
|
|
69
|
+
})),
|
|
70
|
+
order: elements.length + 1,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
226
73
|
}
|
|
227
|
-
|
|
228
74
|
return elements;
|
|
229
75
|
}
|
|
230
76
|
|
|
@@ -278,25 +124,29 @@ function classifyTimeGroup(element, timeRules, today) {
|
|
|
278
124
|
|
|
279
125
|
// ---- Filter Type Manifest Builder ----
|
|
280
126
|
function buildFilterTypes(files, secondaryTypesDefaultOff) {
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
for (const f of
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
result.push({ type: cat, label: def.label, count, defaultEnabled: !secondaryTypesDefaultOff });
|
|
127
|
+
// Derive filter types from actual file data instead of hardcoded constants
|
|
128
|
+
const filterTypes = [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const f of files) {
|
|
131
|
+
const key = f.category + ':' + f.elementType;
|
|
132
|
+
if (!seen.has(key)) {
|
|
133
|
+
seen.add(key);
|
|
134
|
+
filterTypes.push({
|
|
135
|
+
category: f.category,
|
|
136
|
+
type: f.elementType,
|
|
137
|
+
label: (f.category || 'unknown') + ' › ' + (f.elementType || 'unknown'),
|
|
138
|
+
count: 0,
|
|
139
|
+
defaultEnabled: !secondaryTypesDefaultOff
|
|
140
|
+
});
|
|
296
141
|
}
|
|
297
142
|
}
|
|
298
|
-
|
|
299
|
-
|
|
143
|
+
// Count occurrences
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
const key = f.category + ':' + f.elementType;
|
|
146
|
+
const ft = filterTypes.find(ft => (ft.category + ':' + ft.type) === key);
|
|
147
|
+
if (ft) ft.count++;
|
|
148
|
+
}
|
|
149
|
+
return filterTypes;
|
|
300
150
|
}
|
|
301
151
|
|
|
302
152
|
// ---- Conventions Builder (auto-extract from docs/conventions/) ----
|
|
@@ -768,21 +618,20 @@ function buildTimeline(files, projectRoot, config) {
|
|
|
768
618
|
const displayCfg = (config && config.display) ? config.display : {};
|
|
769
619
|
const maxBriefLength = displayCfg.maxBriefLength || 200;
|
|
770
620
|
|
|
771
|
-
// Step 1: Build
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
621
|
+
// Step 1: Build tracks from progressSources (replaces buildPrimaryTree)
|
|
622
|
+
const progressSources = (config && config._progressSources) ? config._progressSources : [];
|
|
623
|
+
const { tracks, currentNode } = buildTracks(progressSources, projectRoot);
|
|
624
|
+
|
|
625
|
+
// Convert tracks to elements format for backward compatibility
|
|
626
|
+
const primaryTree = tracksToElements(tracks);
|
|
776
627
|
|
|
777
628
|
// Also include decision/refactor/conclusion type primary files
|
|
778
629
|
const otherPrimary = files.filter(f =>
|
|
779
|
-
f.kind === 'primary' &&
|
|
630
|
+
f.kind === 'primary' &&
|
|
780
631
|
(f.elementType === 'decision' || f.elementType === 'conclusion' ||
|
|
781
632
|
f.elementType === 'refactor' || f.category === 'decision')
|
|
782
633
|
);
|
|
783
634
|
|
|
784
|
-
const primaryTree = buildPrimaryTree(planFiles, files, projectRoot);
|
|
785
|
-
|
|
786
635
|
// Build primary flat list for non-plan primary files
|
|
787
636
|
const primaryFlat = otherPrimary
|
|
788
637
|
.filter(f => f.hasSummary || f.elementType !== 'unknown')
|
|
@@ -844,6 +693,8 @@ function buildTimeline(files, projectRoot, config) {
|
|
|
844
693
|
elements: allElements,
|
|
845
694
|
events,
|
|
846
695
|
filterTypes,
|
|
696
|
+
tracks,
|
|
697
|
+
currentNode,
|
|
847
698
|
display: {
|
|
848
699
|
secondaryTypesDefaultOff: displayCfg.secondaryTypesDefaultOff !== false,
|
|
849
700
|
conventionsManualOnly: displayCfg.conventionsManualOnly !== false,
|
|
@@ -854,34 +705,73 @@ function buildTimeline(files, projectRoot, config) {
|
|
|
854
705
|
|
|
855
706
|
// ---- Backward Compatibility: timeline → roadmap.json ----
|
|
856
707
|
function timelineToRoadmapCompat(timeline) {
|
|
857
|
-
|
|
858
|
-
|
|
708
|
+
var phases = [];
|
|
709
|
+
|
|
710
|
+
// New: source phases from tracks when available
|
|
711
|
+
if (timeline.tracks && timeline.tracks.length > 0) {
|
|
712
|
+
for (var ti = 0; ti < timeline.tracks.length; ti++) {
|
|
713
|
+
var track = timeline.tracks[ti];
|
|
714
|
+
for (var pi = 0; pi < track.phases.length; pi++) {
|
|
715
|
+
var phase = track.phases[pi];
|
|
716
|
+
phases.push({
|
|
717
|
+
id: 'phase-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
|
|
718
|
+
name: phase.name,
|
|
719
|
+
status: phase.status,
|
|
720
|
+
order: phases.length + 1,
|
|
721
|
+
trackId: track.id,
|
|
722
|
+
milestones: [{
|
|
723
|
+
id: 'ms-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
|
|
724
|
+
name: phase.name,
|
|
725
|
+
status: phase.status,
|
|
726
|
+
owner: phase.batch || '',
|
|
727
|
+
subtasks: (phase.tasks || []).map(function(t) {
|
|
728
|
+
return {
|
|
729
|
+
id: 'task-' + t.id.replace(/[^a-zA-Z0-9]/g, '-'),
|
|
730
|
+
name: t.title,
|
|
731
|
+
status: t.status,
|
|
732
|
+
type: t.actor || 'hand',
|
|
733
|
+
};
|
|
734
|
+
}),
|
|
735
|
+
}],
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Fallback: old phase-based filtering (for non-track elements)
|
|
742
|
+
for (var i = 0; i < timeline.elements.length; i++) {
|
|
743
|
+
var el = timeline.elements[i];
|
|
859
744
|
if (el.kind === 'primary' && el.elementType === 'phase') {
|
|
860
745
|
phases.push({
|
|
861
746
|
id: el.id,
|
|
862
747
|
name: el.name,
|
|
863
748
|
status: el.status,
|
|
864
749
|
order: el.order || phases.length + 1,
|
|
865
|
-
milestones:
|
|
866
|
-
id:
|
|
867
|
-
name:
|
|
868
|
-
status:
|
|
869
|
-
owner:
|
|
870
|
-
subtasks: (
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
750
|
+
milestones: [{
|
|
751
|
+
id: el.id,
|
|
752
|
+
name: el.name,
|
|
753
|
+
status: el.status,
|
|
754
|
+
owner: el.source ? el.source.file : '',
|
|
755
|
+
subtasks: (el.children || []).map(function(st) {
|
|
756
|
+
return {
|
|
757
|
+
id: st.id,
|
|
758
|
+
name: st.name,
|
|
759
|
+
status: st.status,
|
|
760
|
+
type: st.elementType || 'hand',
|
|
761
|
+
};
|
|
762
|
+
}),
|
|
763
|
+
}],
|
|
877
764
|
});
|
|
878
765
|
}
|
|
879
766
|
}
|
|
767
|
+
|
|
880
768
|
return {
|
|
881
769
|
project: timeline.project,
|
|
882
770
|
updated: timeline.updated,
|
|
883
771
|
brief: timeline.brief,
|
|
884
|
-
|
|
772
|
+
tracks: timeline.tracks || [],
|
|
773
|
+
currentNode: timeline.currentNode || null,
|
|
774
|
+
phases: phases,
|
|
885
775
|
};
|
|
886
776
|
}
|
|
887
777
|
|
|
@@ -947,7 +837,7 @@ function buildTokenSummary(projectRoot, config) {
|
|
|
947
837
|
function build(projectRoot) {
|
|
948
838
|
const scanResult = scan(projectRoot);
|
|
949
839
|
const { files } = scanResult;
|
|
950
|
-
const config =
|
|
840
|
+
const { config } = loadContract(projectRoot);
|
|
951
841
|
|
|
952
842
|
const timeline = buildTimeline(files, projectRoot, config);
|
|
953
843
|
const conventions = buildConventions(files, projectRoot);
|
|
@@ -1006,10 +896,11 @@ function build(projectRoot) {
|
|
|
1006
896
|
}
|
|
1007
897
|
|
|
1008
898
|
module.exports = {
|
|
1009
|
-
build, buildTimeline,
|
|
899
|
+
build, buildTimeline, buildSecondaryList,
|
|
1010
900
|
classifyTimeGroup, buildFilterTypes, buildConventions,
|
|
1011
901
|
buildAgents, timelineToRoadmapCompat, timelineToJournalCompat,
|
|
1012
902
|
EVENT_TYPES, classifyEventPriority, buildTokenSummary,
|
|
903
|
+
buildTracks, tracksToElements,
|
|
1013
904
|
};
|
|
1014
905
|
|
|
1015
906
|
if (require.main === module) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { scan } = require('../scanner/scan');
|
|
7
|
+
const { loadContract } = require('../contract/loader');
|
|
8
|
+
const { buildTracks } = require('../progress/parser');
|
|
9
|
+
|
|
10
|
+
function check(projectRoot) {
|
|
11
|
+
try {
|
|
12
|
+
const results = [];
|
|
13
|
+
const { contract, config } = loadContract(projectRoot);
|
|
14
|
+
const scanResult = scan(projectRoot);
|
|
15
|
+
|
|
16
|
+
// Check 1: Classification completeness
|
|
17
|
+
const unclassified = scanResult.files.filter(f => f.category === 'unknown');
|
|
18
|
+
results.push({
|
|
19
|
+
id: 'classification',
|
|
20
|
+
status: unclassified.length === 0 ? 'pass' : 'warning',
|
|
21
|
+
detail: unclassified.length > 0
|
|
22
|
+
? unclassified.length + ' files unclassified: ' + unclassified.map(f => f.path).slice(0, 10).join(', ') + (unclassified.length > 10 ? '...' : '')
|
|
23
|
+
: 'All files classified'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Check 2: MECE completeness
|
|
27
|
+
const catCounts = {};
|
|
28
|
+
for (const f of scanResult.files) {
|
|
29
|
+
catCounts[f.category] = (catCounts[f.category] || 0) + 1;
|
|
30
|
+
}
|
|
31
|
+
const emptyCats = (contract.categories || []).filter(c => !catCounts[c.id]);
|
|
32
|
+
results.push({
|
|
33
|
+
id: 'mece',
|
|
34
|
+
status: emptyCats.length === 0 ? 'pass' : 'warning',
|
|
35
|
+
detail: emptyCats.length > 0
|
|
36
|
+
? 'Empty categories: ' + emptyCats.map(c => c.label).join(', ')
|
|
37
|
+
: 'All categories have files'
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Check 3: Progress consistency (if progressSources configured)
|
|
41
|
+
const progressSources = config._progressSources || [];
|
|
42
|
+
if (progressSources.length > 0) {
|
|
43
|
+
const { tracks } = buildTracks(progressSources, projectRoot);
|
|
44
|
+
results.push({
|
|
45
|
+
id: 'progress',
|
|
46
|
+
status: 'pass',
|
|
47
|
+
detail: tracks.length + ' tracks parsed'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Check for status conflicts between tracks
|
|
51
|
+
const conflicts = [];
|
|
52
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
53
|
+
for (let j = i + 1; j < tracks.length; j++) {
|
|
54
|
+
const idsA = new Set(tracks[i].phases.map(p => p.id));
|
|
55
|
+
for (const phase of tracks[j].phases) {
|
|
56
|
+
if (idsA.has(phase.id)) {
|
|
57
|
+
const other = tracks[i].phases.find(p => p.id === phase.id);
|
|
58
|
+
if (other && other.status !== phase.status) {
|
|
59
|
+
conflicts.push(phase.id + ': ' + tracks[i].id + '=' + other.status + ' vs ' + tracks[j].id + '=' + phase.status);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (conflicts.length > 0) {
|
|
66
|
+
results.push({
|
|
67
|
+
id: 'consistency',
|
|
68
|
+
status: 'warning',
|
|
69
|
+
detail: 'Status conflicts: ' + conflicts.join('; ')
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check 4: SUMMARY coverage
|
|
75
|
+
const withSummary = scanResult.files.filter(f => f.hasSummary).length;
|
|
76
|
+
const total = scanResult.files.length;
|
|
77
|
+
const pct = Math.round(withSummary / Math.max(total, 1) * 100);
|
|
78
|
+
results.push({
|
|
79
|
+
id: 'summary',
|
|
80
|
+
status: pct > 20 ? 'pass' : 'info',
|
|
81
|
+
detail: withSummary + '/' + total + ' (' + pct + '%) files have SUMMARY'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check 5: Meta exclusion
|
|
85
|
+
results.push({
|
|
86
|
+
id: 'exclusion',
|
|
87
|
+
status: 'pass',
|
|
88
|
+
detail: (scanResult.metaExcluded || 0) + ' meta files excluded'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Overall
|
|
92
|
+
const hasWarning = results.some(r => r.status === 'warning');
|
|
93
|
+
const hasError = results.some(r => r.status === 'error');
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
passed: !hasWarning && !hasError,
|
|
97
|
+
project: path.basename(projectRoot),
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
results
|
|
100
|
+
};
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return {
|
|
103
|
+
passed: false,
|
|
104
|
+
project: path.basename(projectRoot),
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
results: [{
|
|
107
|
+
id: 'fatal',
|
|
108
|
+
status: 'error',
|
|
109
|
+
detail: 'Check failed: ' + e.message
|
|
110
|
+
}]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Human-readable output
|
|
116
|
+
function printCheck(report) {
|
|
117
|
+
console.log('\n=== Sandtable Check ===');
|
|
118
|
+
for (const r of report.results) {
|
|
119
|
+
const icon = r.status === 'pass' ? '\u2713' : r.status === 'warning' ? '\u26A0' : '\u2717';
|
|
120
|
+
console.log(icon + ' ' + r.id + ': ' + r.detail);
|
|
121
|
+
}
|
|
122
|
+
console.log('\nexit code: ' + (report.passed ? 0 : 1));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { check, printCheck };
|
|
126
|
+
|
|
127
|
+
if (require.main === module) {
|
|
128
|
+
const root = process.argv[2] || process.cwd();
|
|
129
|
+
const report = check(root);
|
|
130
|
+
printCheck(report);
|
|
131
|
+
|
|
132
|
+
// Write JSON report
|
|
133
|
+
const dataDir = path.join(root, 'data');
|
|
134
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
135
|
+
fs.writeFileSync(path.join(dataDir, 'check-report.json'), JSON.stringify(report, null, 2));
|
|
136
|
+
process.exit(report.passed ? 0 : 1);
|
|
137
|
+
}
|
package/src/cli/sandtable.js
CHANGED
|
@@ -814,6 +814,40 @@ switch (command) {
|
|
|
814
814
|
initCommand(process.cwd(), applyMode, lang, hooksMode, injectAgentsMd);
|
|
815
815
|
break;
|
|
816
816
|
}
|
|
817
|
+
case 'check': {
|
|
818
|
+
const { check, printCheck } = require('../check/check');
|
|
819
|
+
const projectRoot = process.argv[3] || process.cwd();
|
|
820
|
+
const report = check(projectRoot);
|
|
821
|
+
printCheck(report);
|
|
822
|
+
|
|
823
|
+
// Write JSON report
|
|
824
|
+
const dataDir = path.join(projectRoot, 'data');
|
|
825
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
826
|
+
fs.writeFileSync(path.join(dataDir, 'check-report.json'), JSON.stringify(report, null, 2));
|
|
827
|
+
process.exit(report.passed ? 0 : 1);
|
|
828
|
+
}
|
|
829
|
+
case 'test': {
|
|
830
|
+
const fixtureIdx = process.argv.indexOf('--fixture');
|
|
831
|
+
if (fixtureIdx === -1) {
|
|
832
|
+
console.log('用法: sandtable test --fixture <项目路径>');
|
|
833
|
+
console.log(' 以指定项目为 golden fixture 运行回归验证');
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
const fixturePath = process.argv[fixtureIdx + 1];
|
|
837
|
+
if (!fixturePath) {
|
|
838
|
+
console.log('错误:--fixture 需要指定项目路径');
|
|
839
|
+
process.exit(1);
|
|
840
|
+
}
|
|
841
|
+
// Run the fixture test
|
|
842
|
+
const testScript = require('path').join(__dirname, '..', '..', 'tests', 'run-libero-validation.js');
|
|
843
|
+
if (require('fs').existsSync(testScript)) {
|
|
844
|
+
require(testScript).run(fixturePath);
|
|
845
|
+
} else {
|
|
846
|
+
console.log('Fixture test script not found (' + testScript + '). Run from Sandtable repo root.');
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
817
851
|
case 'summarize': {
|
|
818
852
|
const scanResult = scan(root);
|
|
819
853
|
const missing = scanResult.files.filter(f => !f.hasSummary);
|
|
@@ -1291,6 +1325,8 @@ case 'token-log': {
|
|
|
1291
1325
|
console.log(' sandtable scan [projectRoot] 扫描文档目录');
|
|
1292
1326
|
console.log(' sandtable build [projectRoot] 生成 data/*.json');
|
|
1293
1327
|
console.log(' sandtable serve [port] [--watch] [--host <ip>] [--token <token>] 启动服务 + 打开浏览器');
|
|
1328
|
+
console.log(' sandtable check [projectRoot] 一致性校验');
|
|
1329
|
+
console.log(' sandtable test --fixture <path> golden fixture 回归测试');
|
|
1294
1330
|
console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
|
|
1295
1331
|
console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
|
|
1296
1332
|
console.log(' sandtable token-log <skill> <in> <out> [...] 追加 token 消耗日志');
|