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.
@@ -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
+ }
@@ -8,7 +8,7 @@ const { exec } = require('child_process');
8
8
  const { scan } = require('../scanner/scan');
9
9
  const { build, EVENT_TYPES, classifyEventPriority } = require('../builder/build');
10
10
 
11
- const VERSION = '0.3.1';
11
+ const VERSION = '0.4.0';
12
12
  const command = process.argv[2] || 'help';
13
13
  const root = process.argv[3] || process.cwd();
14
14
 
@@ -279,6 +279,79 @@ function removeFromMainDoc(filePath, dryRun) {
279
279
  return true;
280
280
  }
281
281
 
282
+ // ---- appendToGitignore: 自动追加 sandtable 条目到 .gitignore ----
283
+ // 使用 # sandtable:begin/end 标记包裹,uninstall 时精确擦除
284
+ function appendToGitignore(projectRoot, dryRun) {
285
+ var gitignorePath = path.join(projectRoot, '.gitignore');
286
+ var beginMark = '# sandtable:begin';
287
+ var endMark = '# sandtable:end';
288
+ var existing = '';
289
+ if (fs.existsSync(gitignorePath)) {
290
+ existing = fs.readFileSync(gitignorePath, 'utf-8');
291
+ if (existing.indexOf(beginMark) !== -1) {
292
+ if (!dryRun) console.log(' - .gitignore 已有 sandtable 标记,跳过');
293
+ return false;
294
+ }
295
+ }
296
+
297
+ var block = [
298
+ '',
299
+ beginMark,
300
+ '# Sandtable 自动生成条目 — 请勿手动编辑此块',
301
+ 'data/',
302
+ '.sandtable/token-log.jsonl',
303
+ '.sandtable/audit.log',
304
+ '.sandtable/.token',
305
+ endMark,
306
+ '',
307
+ ].join('\n');
308
+
309
+ if (dryRun) {
310
+ console.log(' [DRY-RUN] 将追加到 .gitignore:');
311
+ console.log(' --- diff ---');
312
+ console.log(' + ' + block.replace(/\n/g, '\n + '));
313
+ console.log(' --- end ---');
314
+ return true;
315
+ }
316
+
317
+ writeWithBackup(gitignorePath, existing + block);
318
+ return true;
319
+ }
320
+
321
+ // ---- removeFromGitignore: uninstall 时擦除 sandtable gitignore 块 ----
322
+ function removeFromGitignore(projectRoot, dryRun) {
323
+ var gitignorePath = path.join(projectRoot, '.gitignore');
324
+ var beginMark = '# sandtable:begin';
325
+ var endMark = '# sandtable:end';
326
+
327
+ if (!fs.existsSync(gitignorePath)) return false;
328
+
329
+ var content = fs.readFileSync(gitignorePath, 'utf-8');
330
+ var beginIdx = content.indexOf(beginMark);
331
+ var endIdx = content.indexOf(endMark);
332
+
333
+ if (beginIdx === -1 || endIdx === -1) return false;
334
+
335
+ var beforeBegin = content.lastIndexOf('\n', beginIdx);
336
+ if (beforeBegin === -1) beforeBegin = 0;
337
+
338
+ var afterEnd = endIdx + endMark.length;
339
+ if (content[afterEnd] === '\n') afterEnd++;
340
+ if (content[afterEnd] === '\n') afterEnd++; // consume trailing blank line
341
+
342
+ if (dryRun) {
343
+ console.log(' [DRY-RUN] 将从 .gitignore 移除 sandtable 块:');
344
+ console.log(' --- to remove ---');
345
+ console.log(content.substring(beforeBegin, afterEnd).replace(/^/gm, ' - '));
346
+ console.log(' --- end ---');
347
+ return true;
348
+ }
349
+
350
+ var cleaned = content.substring(0, beforeBegin) + content.substring(afterEnd);
351
+ writeWithBackup(gitignorePath, cleaned);
352
+ return true;
353
+ }
354
+
282
355
  // ---- writeClaudeCodeHook: 创建 SessionStart 强提醒 hook ----
283
356
  function writeClaudeCodeHook(projectRoot, lang) {
284
357
  var hooksDir = path.join(projectRoot, '.claude', 'hooks');
@@ -432,7 +505,7 @@ function initCommand(projectRoot, applyMode, lang, hooksMode, injectAgentsMd) {
432
505
 
433
506
  // 2. Write .sandtable/config.json
434
507
  var config = {
435
- version: '0.3.1',
508
+ version: '0.4.0',
436
509
  createdAt: new Date().toISOString(),
437
510
  projectRoot: projectRoot,
438
511
  paths: {
@@ -510,6 +583,13 @@ function initCommand(projectRoot, applyMode, lang, hooksMode, injectAgentsMd) {
510
583
  console.log(' ✓ 安装 SessionStart 强提醒 hook → .claude/hooks/sandtable-reminder.sh');
511
584
  }
512
585
 
586
+ // 8. Append sandtable entries to .gitignore (auto, always)
587
+ var gitignoreDryRun = !applyMode;
588
+ appendToGitignore(projectRoot, gitignoreDryRun);
589
+ if (applyMode && fs.existsSync(path.join(projectRoot, '.gitignore'))) {
590
+ console.log(' ✓ 追加 sandtable 条目至 .gitignore');
591
+ }
592
+
513
593
  console.log('');
514
594
  console.log('✓ 初始化完成。运行 sandtable build 生成数据,然后 sandtable serve 查看仪表盘。');
515
595
 
@@ -592,7 +672,9 @@ function enableWatchMode(projectRoot) {
592
672
  }
593
673
  }
594
674
 
595
- function startServer(projectRoot, port, openBrowser, watchMode) {
675
+ function startServer(projectRoot, port, openBrowser, watchMode, host, token) {
676
+ host = host || '127.0.0.1';
677
+ token = token || '';
596
678
  const MIME = {
597
679
  '.html': 'text/html; charset=utf-8',
598
680
  '.css': 'text/css; charset=utf-8',
@@ -629,6 +711,27 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
629
711
  }
630
712
 
631
713
  http.createServer(function(req, res) {
714
+ // Token authentication (when binding to public interface)
715
+ if (token) {
716
+ var qIdx = req.url.indexOf('?');
717
+ var hasToken = false;
718
+ if (qIdx !== -1) {
719
+ var qs = req.url.substring(qIdx + 1);
720
+ var pairs = qs.split('&');
721
+ for (var pi = 0; pi < pairs.length; pi++) {
722
+ var kv = pairs[pi].split('=');
723
+ if (decodeURIComponent(kv[0]) === 'token' && kv[1] === token) {
724
+ hasToken = true;
725
+ break;
726
+ }
727
+ }
728
+ }
729
+ if (!hasToken) {
730
+ res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
731
+ return res.end('401 Unauthorized — 请在 URL 后添加 ?token=' + token);
732
+ }
733
+ }
734
+
632
735
  var url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
633
736
  url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
634
737
 
@@ -666,9 +769,12 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
666
769
  res.writeHead(500, { 'Content-Type': 'text/plain' });
667
770
  res.end('500: ' + e.message);
668
771
  }
669
- }).listen(port, function() {
670
- console.log('Sandtable v' + VERSION + ' — http://localhost:' + port);
772
+ }).listen(port, host, function() {
773
+ console.log('Sandtable v' + VERSION + ' — http://' + host + ':' + port);
671
774
  console.log('Project: ' + projectRoot);
775
+ if (token) {
776
+ console.log('Token: ' + token + ' (访问需携带 ?token=' + token + ')');
777
+ }
672
778
 
673
779
  if (watchMode) enableWatchMode(projectRoot);
674
780
 
@@ -708,6 +814,40 @@ switch (command) {
708
814
  initCommand(process.cwd(), applyMode, lang, hooksMode, injectAgentsMd);
709
815
  break;
710
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
+ }
711
851
  case 'summarize': {
712
852
  const scanResult = scan(root);
713
853
  const missing = scanResult.files.filter(f => !f.hasSummary);
@@ -731,7 +871,40 @@ switch (command) {
731
871
  case 'serve': {
732
872
  var port = parseInt(process.argv[3], 10) || 5199;
733
873
  var watchMode = process.argv.indexOf('--watch') !== -1;
734
- startServer(process.cwd(), port, true, watchMode);
874
+
875
+ // Parse --host (default: 127.0.0.1, safe by default)
876
+ var hostIdx = process.argv.indexOf('--host');
877
+ var host = '127.0.0.1';
878
+ if (hostIdx !== -1 && process.argv[hostIdx + 1] && process.argv[hostIdx + 1].indexOf('--') !== 0) {
879
+ host = process.argv[hostIdx + 1];
880
+ }
881
+
882
+ // Parse --token (required when binding to 0.0.0.0)
883
+ var tokenIdx = process.argv.indexOf('--token');
884
+ var token = '';
885
+ var tokenPath = path.join(process.cwd(), '.sandtable', '.token');
886
+ if (tokenIdx !== -1) {
887
+ var nextArg = process.argv[tokenIdx + 1];
888
+ if (nextArg && nextArg.indexOf('--') !== 0) {
889
+ token = nextArg;
890
+ // Persist user-provided token
891
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
892
+ } else {
893
+ token = require('crypto').randomBytes(16).toString('hex');
894
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
895
+ }
896
+ }
897
+ if ((host === '0.0.0.0' || host === '::') && !token) {
898
+ // Try to load persisted token first
899
+ try { if (fs.existsSync(tokenPath)) token = fs.readFileSync(tokenPath, 'utf-8').trim(); } catch(e) {}
900
+ if (!token) {
901
+ token = require('crypto').randomBytes(16).toString('hex');
902
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
903
+ }
904
+ console.log('⚠ 绑定 ' + host + ' 将暴露服务到网络,token: ' + token);
905
+ }
906
+
907
+ startServer(process.cwd(), port, true, watchMode, host, token);
735
908
  break;
736
909
  }
737
910
  case 'event-log': {
@@ -902,7 +1075,9 @@ switch (command) {
902
1075
 
903
1076
  var slugName = TYPE_SLUG_NAMES[typeId] || '';
904
1077
  console.log('event-log: [' + event.priority + '] ' + event.type + ' — ' + title + (threadId ? ' (thread: ' + threadId + ')' : ''));
905
- console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
1078
+ if (!isNamedMode) {
1079
+ console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
1080
+ }
906
1081
 
907
1082
  // --tokens-in + --tokens-out: auto-log token usage
908
1083
  if (tokensIn > 0 || tokensOut > 0) {
@@ -1060,6 +1235,18 @@ case 'token-log': {
1060
1235
  console.log('主文档中未找到 sandtable 注入标记。');
1061
1236
  }
1062
1237
 
1238
+ // Check for sandtable gitignore block
1239
+ var gitignorePath = path.join(projectRoot, '.gitignore');
1240
+ if (fs.existsSync(gitignorePath)) {
1241
+ var giContent = fs.readFileSync(gitignorePath, 'utf-8');
1242
+ if (giContent.indexOf('# sandtable:begin') !== -1) {
1243
+ if (dryRun) {
1244
+ console.log('');
1245
+ removeFromGitignore(projectRoot, true);
1246
+ }
1247
+ }
1248
+ }
1249
+
1063
1250
  if (dryRun) {
1064
1251
  console.log('');
1065
1252
  console.log('以上为 dry-run 预览。要实际删除,请运行:');
@@ -1067,6 +1254,7 @@ case 'token-log': {
1067
1254
  console.log('');
1068
1255
  console.log('注意:');
1069
1256
  console.log(' - sandtable 注入标记 (<!-- sandtable:begin/end -->) 可被 uninstall --apply 精确擦除');
1257
+ console.log(' - .gitignore 中 sandtable 条目 (# sandtable:begin/end) 也会被移除');
1070
1258
  console.log(' - 如果你在 AGENTS.md/CLAUDE.md 中手动添加了其他 sandtable 内容,请自行删除');
1071
1259
  break;
1072
1260
  }
@@ -1080,6 +1268,10 @@ case 'token-log': {
1080
1268
  }
1081
1269
  }
1082
1270
 
1271
+ // --apply: remove sandtable gitignore block
1272
+ var giRemoved = removeFromGitignore(projectRoot, false);
1273
+ if (giRemoved) console.log(' ✓ 从 .gitignore 移除 sandtable 条目');
1274
+
1083
1275
  // --apply: delete files
1084
1276
  console.log('');
1085
1277
  console.log('正在删除...');
@@ -1132,7 +1324,9 @@ case 'token-log': {
1132
1324
  console.log(' sandtable init [--apply] [--lang zh|en] 扫描环境 + 准备规则文件');
1133
1325
  console.log(' sandtable scan [projectRoot] 扫描文档目录');
1134
1326
  console.log(' sandtable build [projectRoot] 生成 data/*.json');
1135
- console.log(' sandtable serve [port] [--watch] 启动服务 + 打开浏览器');
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 回归测试');
1136
1330
  console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
1137
1331
  console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
1138
1332
  console.log(' sandtable token-log <skill> <in> <out> [...] 追加 token 消耗日志');
@@ -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
+ };