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.
@@ -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
+ };