@dommaker/harness 0.16.0 → 0.16.2

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.
Files changed (87) hide show
  1. package/README.md +1 -0
  2. package/bin/harness.js +73 -1
  3. package/dist/cli/commands/doc-freshness-check.d.ts +53 -0
  4. package/dist/cli/commands/doc-freshness-check.d.ts.map +1 -0
  5. package/dist/cli/commands/doc-freshness-check.js +366 -0
  6. package/dist/cli/commands/doc-freshness-check.js.map +1 -0
  7. package/dist/cli/commands/index.d.ts +4 -1
  8. package/dist/cli/commands/index.d.ts.map +1 -1
  9. package/dist/cli/commands/index.js +13 -1
  10. package/dist/cli/commands/index.js.map +1 -1
  11. package/dist/cli/commands/knowledge.d.ts +12 -0
  12. package/dist/cli/commands/knowledge.d.ts.map +1 -1
  13. package/dist/cli/commands/knowledge.js +134 -0
  14. package/dist/cli/commands/knowledge.js.map +1 -1
  15. package/dist/cli/commands/release.d.ts +11 -0
  16. package/dist/cli/commands/release.d.ts.map +1 -1
  17. package/dist/cli/commands/release.js +43 -12
  18. package/dist/cli/commands/release.js.map +1 -1
  19. package/dist/cli/commands/sdd.d.ts +8 -0
  20. package/dist/cli/commands/sdd.d.ts.map +1 -0
  21. package/dist/cli/commands/sdd.js +68 -0
  22. package/dist/cli/commands/sdd.js.map +1 -0
  23. package/dist/cli/commands/spec-baseline-check.d.ts +30 -0
  24. package/dist/cli/commands/spec-baseline-check.d.ts.map +1 -0
  25. package/dist/cli/commands/spec-baseline-check.js +366 -0
  26. package/dist/cli/commands/spec-baseline-check.js.map +1 -0
  27. package/dist/cli/commands/sync-docs.d.ts +2 -0
  28. package/dist/cli/commands/sync-docs.d.ts.map +1 -1
  29. package/dist/cli/commands/sync-docs.js +320 -1
  30. package/dist/cli/commands/sync-docs.js.map +1 -1
  31. package/dist/knowledge/audit.d.ts +1 -1
  32. package/dist/knowledge/audit.d.ts.map +1 -1
  33. package/dist/knowledge/audit.js +59 -7
  34. package/dist/knowledge/audit.js.map +1 -1
  35. package/dist/knowledge/index-generator.d.ts +36 -0
  36. package/dist/knowledge/index-generator.d.ts.map +1 -0
  37. package/dist/knowledge/index-generator.js +241 -0
  38. package/dist/knowledge/index-generator.js.map +1 -0
  39. package/dist/knowledge/index.d.ts +2 -0
  40. package/dist/knowledge/index.d.ts.map +1 -1
  41. package/dist/knowledge/index.js +3 -1
  42. package/dist/knowledge/index.js.map +1 -1
  43. package/dist/knowledge/ingest.js +2 -2
  44. package/dist/knowledge/ingest.js.map +1 -1
  45. package/dist/knowledge/lifecycle.d.ts +15 -0
  46. package/dist/knowledge/lifecycle.d.ts.map +1 -1
  47. package/dist/knowledge/lifecycle.js +70 -0
  48. package/dist/knowledge/lifecycle.js.map +1 -1
  49. package/dist/knowledge/lint.js +3 -3
  50. package/dist/knowledge/lint.js.map +1 -1
  51. package/dist/knowledge/primitives/code-structure.d.ts +37 -0
  52. package/dist/knowledge/primitives/code-structure.d.ts.map +1 -0
  53. package/dist/knowledge/primitives/code-structure.js +168 -0
  54. package/dist/knowledge/primitives/code-structure.js.map +1 -0
  55. package/dist/knowledge/types.d.ts +3 -1
  56. package/dist/knowledge/types.d.ts.map +1 -1
  57. package/dist/knowledge/types.js.map +1 -1
  58. package/dist/sdd/index-generator.d.ts +22 -0
  59. package/dist/sdd/index-generator.d.ts.map +1 -0
  60. package/dist/sdd/index-generator.js +118 -0
  61. package/dist/sdd/index-generator.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/cli/commands/CONTEXT.md +3 -3
  64. package/src/cli/commands/__tests__/doc-freshness-check.test.ts +168 -0
  65. package/src/cli/commands/__tests__/knowledge.test.ts +69 -1
  66. package/src/cli/commands/__tests__/spec-baseline-check.test.ts +235 -0
  67. package/src/cli/commands/__tests__/sync-docs-agents.test.ts +226 -0
  68. package/src/cli/commands/doc-freshness-check.ts +397 -0
  69. package/src/cli/commands/index.ts +4 -1
  70. package/src/cli/commands/knowledge.ts +150 -0
  71. package/src/cli/commands/release.ts +48 -12
  72. package/src/cli/commands/sdd.ts +44 -0
  73. package/src/cli/commands/spec-baseline-check.ts +384 -0
  74. package/src/cli/commands/sync-docs.ts +355 -1
  75. package/src/knowledge/__tests__/audit.test.ts +56 -0
  76. package/src/knowledge/__tests__/index-generator.test.ts +335 -0
  77. package/src/knowledge/audit.ts +64 -8
  78. package/src/knowledge/index-generator.ts +233 -0
  79. package/src/knowledge/index.ts +2 -0
  80. package/src/knowledge/ingest.ts +2 -2
  81. package/src/knowledge/lifecycle.ts +71 -0
  82. package/src/knowledge/lint.ts +3 -3
  83. package/src/knowledge/primitives/__tests__/code-structure.test.ts +120 -0
  84. package/src/knowledge/primitives/code-structure.ts +161 -0
  85. package/src/knowledge/types.ts +4 -1
  86. package/src/sdd/__tests__/index-generator.test.ts +150 -0
  87. package/src/sdd/index-generator.ts +109 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Knowledge Index Generator
3
+ *
4
+ * 扫描知识库目录,生成 grep 友好的单文件索引。
5
+ * 每行一条,包含 filename/id/type/title/maturity/tags/headings。
6
+ *
7
+ * 目标:Agent grep 索引替代 grep 全库,减少 ~96% 输出量。
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as yaml from 'js-yaml';
13
+
14
+ const EXCLUDED_DIRS = ['.archive', 'archived', '.snapshots', 'resolutions'];
15
+ const INDEX_FILENAME = '_index.md';
16
+
17
+ interface IndexEntry {
18
+ filename: string;
19
+ id: string;
20
+ type: string;
21
+ title: string;
22
+ maturity: string;
23
+ tags: string[];
24
+ headings: string[];
25
+ }
26
+
27
+ export class KnowledgeIndexGenerator {
28
+ private baseDir: string;
29
+
30
+ constructor(baseDir: string) {
31
+ this.baseDir = baseDir;
32
+ }
33
+
34
+ /**
35
+ * 生成索引并写入 _index.md
36
+ */
37
+ regenerate(): string {
38
+ const lines = this.generateIndexLines();
39
+ const output = this.generate();
40
+ fs.mkdirSync(this.baseDir, { recursive: true });
41
+ fs.writeFileSync(path.join(this.baseDir, INDEX_FILENAME), output, 'utf-8');
42
+ return output;
43
+ }
44
+
45
+ /**
46
+ * 生成完整索引内容(含 header)
47
+ */
48
+ generate(): string {
49
+ const lines = this.generateIndexLines();
50
+ const byType: Record<string, number> = {};
51
+ for (const line of lines) {
52
+ const type = line.split('|')[2];
53
+ byType[type] = (byType[type] || 0) + 1;
54
+ }
55
+
56
+ const header = [
57
+ '# Knowledge Base Index',
58
+ `# Auto-generated — run \`harness knowledge index\` to rebuild`,
59
+ `# Total: ${lines.length} entries`,
60
+ `# Types: ${Object.entries(byType).map(([t, n]) => `${t}:${n}`).join(' ')}`,
61
+ `#`,
62
+ `# Usage:`,
63
+ `# grep "<keyword>" ~/.studio/knowledge/_index.md`,
64
+ `# Then Read the matching file for full content.`,
65
+ `#`,
66
+ `# filename|id|type|title|maturity|tags|terms`,
67
+ ].join('\n');
68
+
69
+ return header + '\n' + lines.join('\n') + '\n';
70
+ }
71
+
72
+ /**
73
+ * 扫描目录,返回索引数据行(无 header)
74
+ */
75
+ generateIndexLines(): string[] {
76
+ const files = this.scanFiles(this.baseDir);
77
+ const entries: IndexEntry[] = [];
78
+
79
+ for (const filePath of files) {
80
+ const entry = this.parseFile(filePath);
81
+ if (entry) entries.push(entry);
82
+ }
83
+
84
+ // 按 type 分组排序:architecture > decision > guideline > pitfall > process > other
85
+ const typeOrder: Record<string, number> = {
86
+ architecture: 0, decision: 1, guideline: 2, pitfall: 3, process: 4, model: 5, pattern: 6, skill: 7,
87
+ };
88
+ entries.sort((a, b) => {
89
+ const oa = typeOrder[a.type] ?? 9;
90
+ const ob = typeOrder[b.type] ?? 9;
91
+ if (oa !== ob) return oa - ob;
92
+ return a.filename.localeCompare(b.filename);
93
+ });
94
+
95
+ return entries.map(e => this.formatLine(e));
96
+ }
97
+
98
+ // ── Internal ───────────────────────────────────────────────────────────────
99
+
100
+ private scanFiles(dir: string, relative: string = ''): string[] {
101
+ const results: string[] = [];
102
+
103
+ let items: fs.Dirent[];
104
+ try {
105
+ items = fs.readdirSync(dir, { withFileTypes: true });
106
+ } catch {
107
+ return results;
108
+ }
109
+
110
+ for (const item of items) {
111
+ const name = item.name;
112
+ const fullPath = path.join(dir, name);
113
+ const relPath = relative ? path.join(relative, name) : name;
114
+
115
+ if (item.isDirectory()) {
116
+ if (EXCLUDED_DIRS.includes(name)) continue;
117
+ if (name.startsWith('.')) continue;
118
+ results.push(...this.scanFiles(fullPath, relPath));
119
+ } else if (name.endsWith('.md') && name !== INDEX_FILENAME) {
120
+ results.push(fullPath);
121
+ }
122
+ }
123
+
124
+ return results;
125
+ }
126
+
127
+ private parseFile(filePath: string): IndexEntry | null {
128
+ try {
129
+ const raw = fs.readFileSync(filePath, 'utf-8');
130
+ const filename = path.relative(this.baseDir, filePath);
131
+
132
+ // 跳过 ghost 文件(文件名为 .md)
133
+ if (path.basename(filename) === '.md') return null;
134
+
135
+ // 尝试解析 YAML frontmatter
136
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
137
+ if (fmMatch) {
138
+ const meta = yaml.load(fmMatch[1]) as Record<string, unknown>;
139
+ const body = fmMatch[2];
140
+ const headings = this.extractHeadings(body);
141
+
142
+ // 检测 skill schema(name/description 而非 id/type)
143
+ const type = meta?.type
144
+ ? String(meta.type)
145
+ : meta?.name
146
+ ? 'skill'
147
+ : this.inferType(filename);
148
+
149
+ return {
150
+ filename,
151
+ id: String(meta?.id ?? meta?.name ?? path.basename(filePath, '.md')),
152
+ type,
153
+ title: String(meta?.title ?? meta?.description ?? ''),
154
+ maturity: String(meta?.maturity ?? 'unknown'),
155
+ tags: Array.isArray(meta?.tags) ? (meta.tags as unknown[]).map(String) : [],
156
+ headings,
157
+ };
158
+ }
159
+
160
+ // 无 frontmatter — best effort
161
+ const headings = this.extractHeadings(raw);
162
+ const h1Match = raw.match(/^#\s+(.+)$/m);
163
+
164
+ return {
165
+ filename,
166
+ id: path.basename(filePath, '.md'),
167
+ type: this.inferType(filename),
168
+ title: h1Match ? h1Match[1].trim() : '',
169
+ maturity: 'unknown',
170
+ tags: [],
171
+ headings,
172
+ };
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 从文件路径推断 type。优先级:目录名 > 特殊前缀 > 文件前缀 > 后缀模式
180
+ */
181
+ private inferType(filename: string): string {
182
+ const base = path.basename(filename);
183
+ const dir = path.dirname(filename);
184
+
185
+ // 1. 目录推断
186
+ if (dir === 'skills') return 'skill';
187
+ if (dir === 'arch-patterns') return 'architecture';
188
+
189
+ // 2. 特殊复合前缀(必须在通用前缀匹配前检查)
190
+ if (/^(agent-network|superpowers)-/.test(base)) return 'architecture';
191
+ if (/^AS-\d+/.test(base)) return 'spec';
192
+
193
+ // 3. 文件名前缀(architecture-ARC-011.md → architecture)
194
+ const prefixMatch = base.match(/^([a-z]+)-/);
195
+ const knownPrefixes = ['architecture', 'decision', 'guideline', 'pitfall', 'process', 'pattern', 'model', 'skill', 'agent'];
196
+ if (prefixMatch && knownPrefixes.includes(prefixMatch[1])) {
197
+ return prefixMatch[1];
198
+ }
199
+
200
+ // 4. 后缀模式(*-pattern.md → pattern)
201
+ if (/-pattern\.md$/.test(base)) return 'pattern';
202
+
203
+ return 'unknown';
204
+ }
205
+
206
+ /**
207
+ * 提取正文 H2 标题作为搜索词(最多 5 个)
208
+ */
209
+ private extractHeadings(body: string): string[] {
210
+ const matches = body.match(/^##\s+(.+)$/gm);
211
+ if (!matches) return [];
212
+
213
+ return matches
214
+ .slice(0, 5)
215
+ .map(h => h.replace(/^##\s+/, '').replace(/[*_`#]/g, '').trim())
216
+ .filter(h => h.length > 0 && h.length < 60);
217
+ }
218
+
219
+ private formatLine(entry: IndexEntry): string {
220
+ const sanitize = (s: string) => s.replace(/[\n\r]/g, ' ').replace(/\|/g, '/').replace(/\s+/g, ' ').trim();
221
+ const tags = entry.tags.join(';');
222
+ const terms = entry.headings.join(';');
223
+ return [
224
+ sanitize(entry.filename),
225
+ sanitize(entry.id),
226
+ sanitize(entry.type),
227
+ sanitize(entry.title),
228
+ sanitize(entry.maturity),
229
+ sanitize(tags),
230
+ sanitize(terms),
231
+ ].join('|');
232
+ }
233
+ }
@@ -19,3 +19,5 @@ export { KnowledgeLifecycleHooks } from './lifecycle-hooks';
19
19
  export { KnowledgeAudit } from './audit';
20
20
  export type { AuditRuleName, AuditAction, AuditIssue, AuditReport, AuditOptions } from './audit';
21
21
  export { migrateKnowledgeEntries } from './migration';
22
+ export { extractCodeStructure } from './primitives/code-structure';
23
+ export type { CodeStructure, DeclarationInfo, ImportInfo } from './primitives/code-structure';
@@ -194,7 +194,7 @@ export class KnowledgeIngest {
194
194
  const all = this.store.readEntriesFromDisk().filter(e => e.type === type);
195
195
 
196
196
  // Exact match (case-insensitive)
197
- const exact = all.find(e => e.title.toLowerCase() === title.toLowerCase());
197
+ const exact = all.find(e => (e.title || '').toLowerCase() === title.toLowerCase());
198
198
  if (exact) return exact;
199
199
 
200
200
  // Semantic dedup: content prefix + title substring + keyword overlap
@@ -236,7 +236,7 @@ export class KnowledgeIngest {
236
236
 
237
237
  /** Strip [prefix] tags and normalize for comparison */
238
238
  private normalizeForDedup(title: string): string {
239
- const t = title.replace(/^\[.*?\]\s*/g, '').trim();
239
+ const t = (title || '').replace(/^\[.*?\]\s*/g, '').trim();
240
240
  // Keep spaces between character types (Latin/Chinese boundary) for tokenization
241
241
  return t.replace(/[,。、:;!?]/g, '').toLowerCase();
242
242
  }
@@ -194,9 +194,80 @@ export class KnowledgeLifecycle {
194
194
  reason: `Promotion: ${entry.maturity} → ${target}`,
195
195
  };
196
196
  this.store.update(entryId, { maturity: target });
197
+
198
+ // AC-8a: After promotion, check skillCandidate eligibility
199
+ if (target === 'proven') {
200
+ this.checkSkillCandidate(entryId);
201
+ }
202
+
197
203
  return change;
198
204
  }
199
205
 
206
+ /**
207
+ * AC-8a: Check if a proven entry qualifies as a skillCandidate.
208
+ * Marks with 'skillCandidate' tag and emits event if eligible.
209
+ * Returns true if newly marked.
210
+ */
211
+ checkSkillCandidate(entryId: string): boolean {
212
+ const entry = this.store.get(entryId);
213
+ if (!entry) return false;
214
+ if (!this.isSkillCandidate(entry)) return false;
215
+
216
+ // Mark as skillCandidate
217
+ this.store.update(entryId, {
218
+ tags: [...entry.tags, 'skillCandidate'],
219
+ });
220
+
221
+ // Emit event for downstream consumers
222
+ const now = new Date().toISOString();
223
+ for (const cb of this.onReferenceCallbacks) {
224
+ try {
225
+ cb({
226
+ entryId,
227
+ contributor: 'lifecycle',
228
+ timestamp: now,
229
+ context: 'skillCandidate:marked',
230
+ });
231
+ } catch { /* non-blocking */ }
232
+ }
233
+
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * AC-8a: Check if an entry meets skillCandidate criteria.
239
+ */
240
+ private isSkillCandidate(entry: KnowledgeEntry): boolean {
241
+ if (entry.maturity !== 'proven') return false;
242
+ if (entry.tags.includes('skillCandidate')) return false;
243
+ if (entry.contributors.length < 3) return false;
244
+ if ((entry.executionResults?.length || 0) < 5) return false;
245
+ if (entry.content.length < 200) return false;
246
+
247
+ const execRate = this.getExecutionSuccessRate(entry.id);
248
+ if (!execRate || execRate.rate < 0.7) return false;
249
+
250
+ return true;
251
+ }
252
+
253
+ /**
254
+ * AC-8c: Remove skillCandidate tag if success rate drops below 50%.
255
+ * Called after execution writeback.
256
+ */
257
+ checkSkillCandidateRevocation(entryId: string): boolean {
258
+ const entry = this.store.get(entryId);
259
+ if (!entry || !entry.tags.includes('skillCandidate')) return false;
260
+
261
+ const execRate = this.getExecutionSuccessRate(entryId);
262
+ if (execRate && execRate.total >= 5 && execRate.rate < 0.5) {
263
+ this.store.update(entryId, {
264
+ tags: entry.tags.filter(t => t !== 'skillCandidate'),
265
+ });
266
+ return true;
267
+ }
268
+ return false;
269
+ }
270
+
200
271
  /**
201
272
  * Check if a source should auto-promote to verified on ingest.
202
273
  * Used by KnowledgeBus to decide initial maturity level.
@@ -181,7 +181,7 @@ export class KnowledgeLinter {
181
181
  if (entry.maturity === 'archived') continue;
182
182
 
183
183
  // 基于 title + type 去重(大小写不敏感)
184
- const key = `${entry.type}:${entry.title.toLowerCase().trim()}`;
184
+ const key = `${entry.type}:${(entry.title || '').toLowerCase().trim()}`;
185
185
  const existing = seen.get(key) || [];
186
186
  existing.push(entry);
187
187
  seen.set(key, existing);
@@ -309,8 +309,8 @@ export class KnowledgeLinter {
309
309
  const allEntries = this.store.list({ excludeArchived: false });
310
310
  for (const existing of allEntries) {
311
311
  if (existing.id === entry.title) continue; // not same entry (entry doesn't have id yet)
312
- const existingTitle = existing.title.toLowerCase();
313
- const newTitle = entry.title.toLowerCase();
312
+ const existingTitle = (existing.title || '').toLowerCase();
313
+ const newTitle = (entry.title || '').toLowerCase();
314
314
  if (existingTitle === newTitle) {
315
315
  issues.push({
316
316
  type: 'duplicate',
@@ -0,0 +1,120 @@
1
+ /**
2
+ * extractCodeStructure — Layer 1 代码结构提取
3
+ *
4
+ * AC:
5
+ * P5a-1: 从 .ts 文件提取函数/类/接口/类型声明
6
+ * P5a-2: 从多文件目录提取,包含 import 依赖图
7
+ * P5a-3: 空目录返回空结构
8
+ * P5a-4: 非 .ts 文件忽略
9
+ * P5a-5: JSDoc 注释关联到声明
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { extractCodeStructure } from '../code-structure';
15
+
16
+ // Use real fs for these tests (no mock) — testing actual TS parsing
17
+
18
+ describe('extractCodeStructure', () => {
19
+ const tmpDir = path.join(__dirname, '__tmp__');
20
+
21
+ beforeEach(() => {
22
+ fs.mkdirSync(tmpDir, { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ it('P5a-1: extracts functions, classes, interfaces, types from .ts file', () => {
30
+ const code = `
31
+ /** Helper function */
32
+ export function add(a: number, b: number): number { return a + b; }
33
+
34
+ /** A class */
35
+ export class Calculator {
36
+ compute(x: number): number { return x * 2; }
37
+ }
38
+
39
+ export interface Config { debug: boolean; timeout: number; }
40
+
41
+ export type Result = { ok: boolean; data?: string };
42
+ `;
43
+ fs.writeFileSync(path.join(tmpDir, 'math.ts'), code);
44
+
45
+ const result = extractCodeStructure(tmpDir);
46
+
47
+ expect(result.files).toContain('math.ts');
48
+ expect(result.functions).toEqual(
49
+ expect.arrayContaining([
50
+ expect.objectContaining({ name: 'add' }),
51
+ ]),
52
+ );
53
+ expect(result.classes).toEqual(
54
+ expect.arrayContaining([
55
+ expect.objectContaining({ name: 'Calculator' }),
56
+ ]),
57
+ );
58
+ expect(result.interfaces).toEqual(
59
+ expect.arrayContaining([
60
+ expect.objectContaining({ name: 'Config' }),
61
+ ]),
62
+ );
63
+ expect(result.types).toEqual(
64
+ expect.arrayContaining([
65
+ expect.objectContaining({ name: 'Result' }),
66
+ ]),
67
+ );
68
+ });
69
+
70
+ it('P5a-2: extracts imports and builds dependency graph', () => {
71
+ const codeA = `import { add } from './math'; export const sum = add(1, 2);`;
72
+ const codeB = `export function add(a: number, b: number): number { return a + b; }`;
73
+ fs.writeFileSync(path.join(tmpDir, 'app.ts'), codeA);
74
+ fs.writeFileSync(path.join(tmpDir, 'math.ts'), codeB);
75
+
76
+ const result = extractCodeStructure(tmpDir);
77
+
78
+ expect(result.imports).toEqual(
79
+ expect.arrayContaining([
80
+ expect.objectContaining({ source: './math' }),
81
+ ]),
82
+ );
83
+ });
84
+
85
+ it('P5a-3: empty directory returns empty structure', () => {
86
+ const emptyDir = path.join(tmpDir, 'empty');
87
+ fs.mkdirSync(emptyDir);
88
+
89
+ const result = extractCodeStructure(emptyDir);
90
+
91
+ expect(result.files).toEqual([]);
92
+ expect(result.functions).toEqual([]);
93
+ expect(result.classes).toEqual([]);
94
+ expect(result.interfaces).toEqual([]);
95
+ expect(result.types).toEqual([]);
96
+ });
97
+
98
+ it('P5a-4: non-.ts files are ignored', () => {
99
+ fs.writeFileSync(path.join(tmpDir, 'readme.md'), '# Hello');
100
+ fs.writeFileSync(path.join(tmpDir, 'data.json'), '{}');
101
+ fs.writeFileSync(path.join(tmpDir, 'code.ts'), 'export const x = 1;');
102
+
103
+ const result = extractCodeStructure(tmpDir);
104
+
105
+ expect(result.files).toEqual(['code.ts']);
106
+ });
107
+
108
+ it('P5a-5: JSDoc comments are associated with declarations', () => {
109
+ const code = `
110
+ /** Adds two numbers together */
111
+ export function add(a: number, b: number): number { return a + b; }
112
+ `;
113
+ fs.writeFileSync(path.join(tmpDir, 'math.ts'), code);
114
+
115
+ const result = extractCodeStructure(tmpDir);
116
+
117
+ const fn = result.functions.find(f => f.name === 'add');
118
+ expect(fn?.jsdoc).toContain('Adds two numbers');
119
+ });
120
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * extractCodeStructure — Layer 1 代码结构提取(纯代码,零 LLM)
3
+ *
4
+ * 使用 TypeScript Compiler API 从 .ts 文件提取:
5
+ * - 函数声明
6
+ * - 类声明
7
+ * - 接口声明
8
+ * - 类型别名
9
+ * - import 语句
10
+ *
11
+ * 每个声明附带 JSDoc 注释(如果有)。
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import * as ts from 'typescript';
17
+
18
+ export interface DeclarationInfo {
19
+ name: string;
20
+ signature: string;
21
+ jsdoc?: string;
22
+ }
23
+
24
+ export interface ImportInfo {
25
+ source: string;
26
+ names: string[];
27
+ }
28
+
29
+ export interface CodeStructure {
30
+ files: string[];
31
+ functions: DeclarationInfo[];
32
+ classes: DeclarationInfo[];
33
+ interfaces: DeclarationInfo[];
34
+ types: DeclarationInfo[];
35
+ imports: ImportInfo[];
36
+ }
37
+
38
+ /**
39
+ * 从目录中所有 .ts 文件提取代码结构。
40
+ *
41
+ * @param dir 目录路径
42
+ * @returns CodeStructure JSON
43
+ */
44
+ export function extractCodeStructure(dir: string): CodeStructure {
45
+ const result: CodeStructure = {
46
+ files: [],
47
+ functions: [],
48
+ classes: [],
49
+ interfaces: [],
50
+ types: [],
51
+ imports: [],
52
+ };
53
+
54
+ let entries: fs.Dirent[];
55
+ try {
56
+ entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ } catch {
58
+ return result;
59
+ }
60
+
61
+ const tsFiles = entries
62
+ .filter(e => e.isFile() && (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) && !e.name.endsWith('.d.ts'))
63
+ .map(e => e.name)
64
+ .sort();
65
+
66
+ result.files = tsFiles;
67
+
68
+ for (const fileName of tsFiles) {
69
+ const filePath = path.join(dir, fileName);
70
+ const sourceText = fs.readFileSync(filePath, 'utf-8');
71
+ const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
72
+
73
+ extractFromFile(sourceFile, result);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ function extractFromFile(sourceFile: ts.SourceFile, result: CodeStructure): void {
80
+ ts.forEachChild(sourceFile, node => {
81
+ if (ts.isFunctionDeclaration(node)) {
82
+ const info = extractDeclaration(node, sourceFile);
83
+ if (info) result.functions.push(info);
84
+ } else if (ts.isClassDeclaration(node)) {
85
+ const info = extractDeclaration(node, sourceFile);
86
+ if (info) result.classes.push(info);
87
+ } else if (ts.isInterfaceDeclaration(node)) {
88
+ const info = extractDeclaration(node, sourceFile);
89
+ if (info) result.interfaces.push(info);
90
+ } else if (ts.isTypeAliasDeclaration(node)) {
91
+ const info = extractDeclaration(node, sourceFile);
92
+ if (info) result.types.push(info);
93
+ } else if (ts.isImportDeclaration(node)) {
94
+ const importInfo = extractImport(node, sourceFile);
95
+ if (importInfo) result.imports.push(importInfo);
96
+ }
97
+ });
98
+ }
99
+
100
+ function extractDeclaration(
101
+ node: ts.FunctionDeclaration | ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration,
102
+ sourceFile: ts.SourceFile,
103
+ ): DeclarationInfo | undefined {
104
+ const name = node.name?.getText(sourceFile);
105
+ if (!name) return undefined;
106
+
107
+ const signature = node.getText(sourceFile).split('\n')[0].trim();
108
+ const jsdoc = extractJSDoc(node, sourceFile);
109
+
110
+ return { name, signature, jsdoc };
111
+ }
112
+
113
+ function extractImport(node: ts.ImportDeclaration, sourceFile: ts.SourceFile): ImportInfo | undefined {
114
+ const moduleSpecifier = node.moduleSpecifier;
115
+ if (!ts.isStringLiteral(moduleSpecifier)) return undefined;
116
+
117
+ const source = moduleSpecifier.text;
118
+ const names: string[] = [];
119
+
120
+ if (node.importClause) {
121
+ if (node.importClause.name) {
122
+ names.push(node.importClause.name.getText(sourceFile));
123
+ }
124
+ if (node.importClause.namedBindings) {
125
+ if (ts.isNamedImports(node.importClause.namedBindings)) {
126
+ for (const element of node.importClause.namedBindings.elements) {
127
+ names.push(element.name.getText(sourceFile));
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ return { source, names };
134
+ }
135
+
136
+ function extractJSDoc(node: ts.Node, sourceFile: ts.SourceFile): string | undefined {
137
+ const jsDocTags = ts.getJSDocTags(node);
138
+ const fullText = sourceFile.getFullText();
139
+ const nodeStart = node.getFullStart();
140
+
141
+ // Look backwards for JSDoc comment
142
+ const leadingCommentRanges = ts.getLeadingCommentRanges(fullText, nodeStart);
143
+ if (!leadingCommentRanges) return undefined;
144
+
145
+ for (const range of leadingCommentRanges) {
146
+ const commentText = fullText.substring(range.pos, range.end);
147
+ if (commentText.startsWith('/**')) {
148
+ // Strip /** and */ and clean up
149
+ return commentText
150
+ .replace(/^\/\*\*\s*/, '')
151
+ .replace(/\s*\*\/$/, '')
152
+ .split('\n')
153
+ .map(line => line.replace(/^\s*\*\s?/, ''))
154
+ .filter(line => line.trim().length > 0)
155
+ .join(' ')
156
+ .trim();
157
+ }
158
+ }
159
+
160
+ return undefined;
161
+ }
@@ -9,7 +9,10 @@
9
9
 
10
10
  // ── Dimensions ──────────────────────────────────────────────
11
11
 
12
- export type KnowledgeType = 'model' | 'decision' | 'guideline' | 'pitfall' | 'process' | 'architecture';
12
+ export type KnowledgeSubsystem = 'model' | 'decision' | 'guideline' | 'pitfall' | 'process' | 'architecture';
13
+
14
+ /** @deprecated Use KnowledgeSubsystem */
15
+ export type KnowledgeType = KnowledgeSubsystem;
13
16
 
14
17
  export type MaturityLevel = 'draft' | 'verified' | 'proven' | 'archived' | 'active' | 'deprecated';
15
18