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.
@@ -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
- // ---- Built-in patterns: two-tier (primary vs secondary) ----
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 视角 — 9 大分类
49
- // Primary-only 类别 (roadmap, decision) 不出现在 filterType checkbox 中
50
- const DISPLAY_CATEGORIES = {
51
- roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog', 'optimization', 'todo'] },
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', 'todo']);
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
- // ---- Pattern-based classification ----
298
- function classifyByPattern(filename) {
299
- for (const pattern of PATTERNS) {
300
- if (pattern.test(filename)) {
301
- return pattern.classify();
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 { kind: 'secondary', elementType: 'unknown', category: 'unknown' };
119
+ return result;
305
120
  }
306
121
 
307
122
  // ---- Main scan ----
308
123
  function scan(projectRoot) {
309
- const config = loadConfig(projectRoot);
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 && config.exclude, projectRoot);
132
+ // Apply exclusion list
133
+ fileEntries = applyExclusions(fileEntries, config._exclude, projectRoot);
320
134
 
321
- const files = fileEntries.map(fe => {
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
- // Step 1: classify by filename pattern
330
- let { kind, elementType, category } = classifyByPattern(filename);
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
- // Step 4: SUMMARY block as fallback correction (only if auto-detection returned 'unknown')
347
- const corrected = applySummaryCorrection(summary, { kind, elementType, category, _pathRefined: meta._pathRefined });
348
- kind = corrected.kind;
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
- return {
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, // v2: 'primary' | 'secondary'
375
- elementType, // v2: phase|milestone|subtask|conclusion|decision|journal|...
376
- type: elementType, // backward compat alias
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, // v2: best-guess date
381
- dateSource: timeInfo.dateSource,// v2: how date was determined
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, // v2
396
- secondaryCount, // v2
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))], // v2
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, classifyByPattern, refineByPath, refineByContent,
406
- extractSummary, extractTimeInfo, loadConfig,
407
- applySummaryCorrection, applyExclusions, normalizeCategory, DISPLAY_CATEGORIES,
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) {