@vibecompass/vibecompass 0.1.0
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/LICENSE +21 -0
- package/README.md +329 -0
- package/package.json +42 -0
- package/src/cli.js +572 -0
- package/src/frontmatter.js +28 -0
- package/src/generators/agent-files/agents-md.js +13 -0
- package/src/generators/agent-files/claude-md.js +13 -0
- package/src/generators/agent-files/copilot-instructions.js +13 -0
- package/src/generators/agent-files/cursor-rules.js +13 -0
- package/src/generators/agent-files/index.js +147 -0
- package/src/generators/agent-files/markers.js +49 -0
- package/src/generators/agent-files/template.js +75 -0
- package/src/hash.js +29 -0
- package/src/index.js +15 -0
- package/src/init.js +227 -0
- package/src/manifest.js +75 -0
- package/src/project-memory.js +678 -0
- package/src/project-yaml.js +107 -0
- package/src/read-model.js +557 -0
- package/src/scaffold.js +404 -0
- package/src/session.js +553 -0
- package/src/setup.js +719 -0
- package/src/simple-yaml.js +227 -0
- package/src/workflow.js +120 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
4
|
+
import { sha256Text } from './hash.js';
|
|
5
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
6
|
+
|
|
7
|
+
const PROJECT_FILE = 'project.yaml';
|
|
8
|
+
const NON_CANONICAL_DECISION_FILES = new Set(['INDEX.md', 'README.md']);
|
|
9
|
+
const NON_CANONICAL_SESSION_FILES = new Set(['wip.md', 'handoff.md', 'README.md']);
|
|
10
|
+
const NON_CANONICAL_ARCHITECTURE_FILES = new Set(['README.md']);
|
|
11
|
+
const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
|
|
12
|
+
const RECOMMENDED_ARCHITECTURE_SECTIONS = [
|
|
13
|
+
'Description',
|
|
14
|
+
'Details',
|
|
15
|
+
'Next steps',
|
|
16
|
+
'Involved files',
|
|
17
|
+
];
|
|
18
|
+
const RECOMMENDED_SESSION_SECTIONS = [
|
|
19
|
+
'What we worked on',
|
|
20
|
+
'Completed',
|
|
21
|
+
'Decisions made',
|
|
22
|
+
'Models used',
|
|
23
|
+
'Blockers / open questions',
|
|
24
|
+
'Next session should start with',
|
|
25
|
+
];
|
|
26
|
+
const KNOWN_PROJECT_FIELDS = new Set([
|
|
27
|
+
'format_version',
|
|
28
|
+
'name',
|
|
29
|
+
'slug',
|
|
30
|
+
'description',
|
|
31
|
+
'mode',
|
|
32
|
+
'repos',
|
|
33
|
+
'default_branch',
|
|
34
|
+
'sync',
|
|
35
|
+
'metadata',
|
|
36
|
+
]);
|
|
37
|
+
const KNOWN_REPO_FIELDS = new Set(['id', 'remote', 'default_branch']);
|
|
38
|
+
const KNOWN_SYNC_FIELDS = new Set([
|
|
39
|
+
'provider',
|
|
40
|
+
'api_url',
|
|
41
|
+
'project_id',
|
|
42
|
+
'credential_source',
|
|
43
|
+
'credential_env_var',
|
|
44
|
+
]);
|
|
45
|
+
const KNOWN_ARCHITECTURE_STATUSES = new Set([
|
|
46
|
+
'Not started',
|
|
47
|
+
'In progress',
|
|
48
|
+
'Complete',
|
|
49
|
+
'Blocked',
|
|
50
|
+
]);
|
|
51
|
+
const SECRET_BEARING_SYNC_FIELDS = [
|
|
52
|
+
'token',
|
|
53
|
+
'secret',
|
|
54
|
+
'api_key',
|
|
55
|
+
'apikey',
|
|
56
|
+
'credential',
|
|
57
|
+
'credential_value',
|
|
58
|
+
'access_token',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export async function scanProjectMemory(rootDir) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
const documents = [];
|
|
64
|
+
|
|
65
|
+
const projectPath = path.join(rootDir, PROJECT_FILE);
|
|
66
|
+
const projectDocument = await parseProjectDocument(rootDir, projectPath);
|
|
67
|
+
documents.push(projectDocument);
|
|
68
|
+
errors.push(...projectDocument.errors);
|
|
69
|
+
|
|
70
|
+
const declaredRepoIds = new Set(projectDocument.extracted?.repo_ids ?? []);
|
|
71
|
+
|
|
72
|
+
const architectureDocuments = await parseMarkdownTree({
|
|
73
|
+
rootDir,
|
|
74
|
+
directoryName: 'architecture',
|
|
75
|
+
shouldInclude: (filename) => !NON_CANONICAL_ARCHITECTURE_FILES.has(filename),
|
|
76
|
+
parseDocument: (documentPath, relativePath, content) =>
|
|
77
|
+
parseArchitectureDocument({
|
|
78
|
+
content,
|
|
79
|
+
relativePath,
|
|
80
|
+
repoIds: declaredRepoIds,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const decisionDocuments = await parseMarkdownTree({
|
|
85
|
+
rootDir,
|
|
86
|
+
directoryName: 'decisions',
|
|
87
|
+
shouldInclude: (filename) => !NON_CANONICAL_DECISION_FILES.has(filename),
|
|
88
|
+
parseDocument: (documentPath, relativePath, content) =>
|
|
89
|
+
parseDecisionDocument({
|
|
90
|
+
content,
|
|
91
|
+
relativePath,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const sessionDocuments = await parseMarkdownTree({
|
|
96
|
+
rootDir,
|
|
97
|
+
directoryName: 'sessions',
|
|
98
|
+
shouldInclude: (filename) => !NON_CANONICAL_SESSION_FILES.has(filename),
|
|
99
|
+
parseDocument: (documentPath, relativePath, content) =>
|
|
100
|
+
parseSessionDocument({
|
|
101
|
+
content,
|
|
102
|
+
relativePath,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
for (const document of [...architectureDocuments, ...decisionDocuments, ...sessionDocuments]) {
|
|
107
|
+
documents.push(document);
|
|
108
|
+
errors.push(...document.errors);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
errors.push(...findDuplicateDecisionErrors(decisionDocuments));
|
|
112
|
+
|
|
113
|
+
documents.sort((left, right) => left.path.localeCompare(right.path));
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
rootDir,
|
|
117
|
+
documents,
|
|
118
|
+
errors,
|
|
119
|
+
warnings: documents.flatMap((document) => document.warnings.map((warning) => ({ ...warning, path: document.path }))),
|
|
120
|
+
project: projectDocument,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function parseProjectDocument(rootDir, filePath) {
|
|
125
|
+
const relativePath = PROJECT_FILE;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const content = await readFile(filePath, 'utf8');
|
|
129
|
+
const data = parseSimpleYaml(content, { sourceName: relativePath });
|
|
130
|
+
return finalizeDocument({
|
|
131
|
+
path: relativePath,
|
|
132
|
+
kind: 'project',
|
|
133
|
+
content,
|
|
134
|
+
...validateProjectData(data, relativePath),
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return finalizeDocument({
|
|
138
|
+
path: relativePath,
|
|
139
|
+
kind: 'project',
|
|
140
|
+
content: '',
|
|
141
|
+
extracted: null,
|
|
142
|
+
warnings: [],
|
|
143
|
+
errors: [
|
|
144
|
+
createError(
|
|
145
|
+
relativePath,
|
|
146
|
+
'project-parse-failed',
|
|
147
|
+
error instanceof Error ? error.message : 'Failed to read project.yaml.',
|
|
148
|
+
),
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function parseMarkdownTree(options) {
|
|
155
|
+
const directoryPath = path.join(options.rootDir, options.directoryName);
|
|
156
|
+
const relativePaths = await listMarkdownFiles(directoryPath, options.directoryName, options.shouldInclude);
|
|
157
|
+
const documents = [];
|
|
158
|
+
|
|
159
|
+
for (const relativePath of relativePaths) {
|
|
160
|
+
const documentPath = path.join(options.rootDir, relativePath);
|
|
161
|
+
const content = await readFile(documentPath, 'utf8');
|
|
162
|
+
const parsed = options.parseDocument(documentPath, relativePath, content);
|
|
163
|
+
|
|
164
|
+
documents.push(
|
|
165
|
+
finalizeDocument({
|
|
166
|
+
path: relativePath,
|
|
167
|
+
kind: parsed.kind,
|
|
168
|
+
content,
|
|
169
|
+
extracted: parsed.extracted,
|
|
170
|
+
warnings: parsed.warnings,
|
|
171
|
+
errors: parsed.errors,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return documents;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function listMarkdownFiles(directoryPath, relativeRoot, shouldInclude) {
|
|
180
|
+
try {
|
|
181
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
182
|
+
const paths = [];
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (entry.name.startsWith('.')) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const absoluteChildPath = path.join(directoryPath, entry.name);
|
|
190
|
+
const relativeChildPath = toPosix(path.join(relativeRoot, entry.name));
|
|
191
|
+
|
|
192
|
+
if (entry.isDirectory()) {
|
|
193
|
+
paths.push(...(await listMarkdownFiles(absoluteChildPath, relativeChildPath, shouldInclude)));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!shouldInclude(entry.name, relativeChildPath)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
paths.push(relativeChildPath);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return paths.sort((left, right) => left.localeCompare(right));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateProjectData(data, relativePath) {
|
|
219
|
+
const warnings = [];
|
|
220
|
+
const errors = [];
|
|
221
|
+
|
|
222
|
+
if (!isPlainObject(data)) {
|
|
223
|
+
return {
|
|
224
|
+
extracted: null,
|
|
225
|
+
warnings,
|
|
226
|
+
errors: [createError(relativePath, 'project-invalid-root', 'project.yaml must contain a top-level mapping.')],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const key of Object.keys(data)) {
|
|
231
|
+
if (!KNOWN_PROJECT_FIELDS.has(key)) {
|
|
232
|
+
warnings.push(createWarning('project-unknown-field', `Unknown project.yaml field "${key}".`));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!Number.isInteger(data.format_version)) {
|
|
237
|
+
errors.push(createError(relativePath, 'project-missing-format-version', 'project.yaml requires integer field "format_version".'));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof data.name !== 'string' || data.name.trim() === '') {
|
|
241
|
+
errors.push(createError(relativePath, 'project-missing-name', 'project.yaml requires non-empty field "name".'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (typeof data.mode !== 'string' || !VALID_MODES.has(data.mode)) {
|
|
245
|
+
errors.push(
|
|
246
|
+
createError(
|
|
247
|
+
relativePath,
|
|
248
|
+
'project-invalid-mode',
|
|
249
|
+
'project.yaml requires "mode" to be one of local-only, local-primary, or hosted-only.',
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!Array.isArray(data.repos) || data.repos.length === 0) {
|
|
255
|
+
errors.push(createError(relativePath, 'project-invalid-repos', 'project.yaml requires a non-empty repos array.'));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const repoIds = [];
|
|
259
|
+
if (Array.isArray(data.repos)) {
|
|
260
|
+
for (const [index, repo] of data.repos.entries()) {
|
|
261
|
+
const prefix = `repos[${index}]`;
|
|
262
|
+
if (!isPlainObject(repo)) {
|
|
263
|
+
errors.push(createError(relativePath, 'project-invalid-repo', `${prefix} must be a mapping.`));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const key of Object.keys(repo)) {
|
|
268
|
+
if (!KNOWN_REPO_FIELDS.has(key)) {
|
|
269
|
+
warnings.push(createWarning('project-unknown-repo-field', `Unknown repo field "${prefix}.${key}".`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (typeof repo.id !== 'string' || repo.id.trim() === '') {
|
|
274
|
+
errors.push(createError(relativePath, 'project-invalid-repo-id', `${prefix}.id must be a non-empty string.`));
|
|
275
|
+
} else if (repoIds.includes(repo.id)) {
|
|
276
|
+
errors.push(createError(relativePath, 'project-duplicate-repo-id', `Duplicate repo id "${repo.id}" in project.yaml.`));
|
|
277
|
+
} else {
|
|
278
|
+
repoIds.push(repo.id);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (typeof repo.remote !== 'string' || repo.remote.trim() === '') {
|
|
282
|
+
errors.push(createError(relativePath, 'project-invalid-repo-remote', `${prefix}.remote must be a non-empty string.`));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (data.sync !== undefined) {
|
|
288
|
+
if (!isPlainObject(data.sync)) {
|
|
289
|
+
errors.push(createError(relativePath, 'project-invalid-sync', 'project.yaml field "sync" must be a mapping when present.'));
|
|
290
|
+
} else {
|
|
291
|
+
for (const key of Object.keys(data.sync)) {
|
|
292
|
+
if (!KNOWN_SYNC_FIELDS.has(key)) {
|
|
293
|
+
warnings.push(createWarning('project-unknown-sync-field', `Unknown sync field "sync.${key}".`));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const normalizedKey = key.toLowerCase();
|
|
297
|
+
if (SECRET_BEARING_SYNC_FIELDS.includes(normalizedKey)) {
|
|
298
|
+
errors.push(
|
|
299
|
+
createError(
|
|
300
|
+
relativePath,
|
|
301
|
+
'project-inline-secret',
|
|
302
|
+
`sync.${key} looks like a secret-bearing field and must not be stored in project.yaml.`,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (data.sync.credential_source === 'env' && typeof data.sync.credential_env_var !== 'string') {
|
|
309
|
+
errors.push(
|
|
310
|
+
createError(
|
|
311
|
+
relativePath,
|
|
312
|
+
'project-missing-credential-env-var',
|
|
313
|
+
'sync.credential_source "env" requires sync.credential_env_var.',
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (data.metadata !== undefined && JSON.stringify(data.metadata).length > 500) {
|
|
321
|
+
warnings.push(
|
|
322
|
+
createWarning(
|
|
323
|
+
'project-metadata-too-large',
|
|
324
|
+
'project.yaml metadata is unusually large and may be drifting into narrative content.',
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
extracted: {
|
|
331
|
+
format_version: Number.isInteger(data.format_version) ? data.format_version : null,
|
|
332
|
+
name: typeof data.name === 'string' ? data.name : null,
|
|
333
|
+
slug: typeof data.slug === 'string' ? data.slug : null,
|
|
334
|
+
description: typeof data.description === 'string' ? data.description : null,
|
|
335
|
+
mode: typeof data.mode === 'string' ? data.mode : null,
|
|
336
|
+
repo_ids: repoIds,
|
|
337
|
+
repos: Array.isArray(data.repos)
|
|
338
|
+
? data.repos
|
|
339
|
+
.filter((repo) => isPlainObject(repo) && typeof repo.id === 'string' && typeof repo.remote === 'string')
|
|
340
|
+
.map((repo) => ({
|
|
341
|
+
id: repo.id,
|
|
342
|
+
remote: repo.remote,
|
|
343
|
+
default_branch: typeof repo.default_branch === 'string' ? repo.default_branch : null,
|
|
344
|
+
}))
|
|
345
|
+
: [],
|
|
346
|
+
},
|
|
347
|
+
warnings,
|
|
348
|
+
errors,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseArchitectureDocument(options) {
|
|
353
|
+
const warnings = [];
|
|
354
|
+
const errors = [];
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const frontmatter = parseFrontmatter(options.content, { sourceName: options.relativePath });
|
|
358
|
+
|
|
359
|
+
if (!frontmatter.hasFrontmatter || !isPlainObject(frontmatter.data)) {
|
|
360
|
+
errors.push(
|
|
361
|
+
createError(options.relativePath, 'architecture-missing-frontmatter', 'Architecture docs require YAML frontmatter.'),
|
|
362
|
+
);
|
|
363
|
+
return { kind: 'architecture', extracted: null, warnings, errors };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const data = frontmatter.data;
|
|
367
|
+
const requiredFields = ['domain', 'feature', 'component', 'status'];
|
|
368
|
+
|
|
369
|
+
for (const field of requiredFields) {
|
|
370
|
+
if (typeof data[field] !== 'string' || data[field].trim() === '') {
|
|
371
|
+
errors.push(
|
|
372
|
+
createError(
|
|
373
|
+
options.relativePath,
|
|
374
|
+
'architecture-missing-field',
|
|
375
|
+
`Architecture doc frontmatter requires non-empty field "${field}".`,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (data.repo !== undefined && data.repos !== undefined) {
|
|
382
|
+
errors.push(
|
|
383
|
+
createError(options.relativePath, 'architecture-repo-and-repos', 'Use either repo or repos, not both.'),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const repoIds = [];
|
|
388
|
+
if (typeof data.repo === 'string' && data.repo.trim() !== '') {
|
|
389
|
+
repoIds.push(data.repo);
|
|
390
|
+
} else if (Array.isArray(data.repos)) {
|
|
391
|
+
for (const repoId of data.repos) {
|
|
392
|
+
if (typeof repoId === 'string' && repoId.trim() !== '') {
|
|
393
|
+
repoIds.push(repoId);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const repoId of repoIds) {
|
|
399
|
+
if (!options.repoIds.has(repoId)) {
|
|
400
|
+
errors.push(
|
|
401
|
+
createError(
|
|
402
|
+
options.relativePath,
|
|
403
|
+
'architecture-unknown-repo',
|
|
404
|
+
`Architecture doc references repo "${repoId}" which is not declared in project.yaml.`,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (typeof data.status === 'string' && !KNOWN_ARCHITECTURE_STATUSES.has(data.status)) {
|
|
411
|
+
warnings.push(
|
|
412
|
+
createWarning('architecture-unknown-status', `Unknown architecture status "${data.status}".`),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const sections = extractLevelTwoSections(frontmatter.body);
|
|
417
|
+
for (const section of RECOMMENDED_ARCHITECTURE_SECTIONS) {
|
|
418
|
+
if (!sections.has(section.toLowerCase())) {
|
|
419
|
+
warnings.push(
|
|
420
|
+
createWarning(
|
|
421
|
+
'architecture-missing-section',
|
|
422
|
+
`Architecture doc is missing recommended section "## ${section}".`,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (repoIds.length === 0 && /##\s+Involved files/i.test(frontmatter.body) && /`[^`]+\/[^`]+`/.test(frontmatter.body)) {
|
|
429
|
+
warnings.push(
|
|
430
|
+
createWarning(
|
|
431
|
+
'architecture-missing-repo-scope',
|
|
432
|
+
'Architecture doc appears to describe implementation files but does not declare repo or repos.',
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
kind: 'architecture',
|
|
439
|
+
extracted: {
|
|
440
|
+
domain: typeof data.domain === 'string' ? data.domain : null,
|
|
441
|
+
feature: typeof data.feature === 'string' ? data.feature : null,
|
|
442
|
+
component: typeof data.component === 'string' ? data.component : null,
|
|
443
|
+
status: typeof data.status === 'string' ? data.status : null,
|
|
444
|
+
repo_ids: repoIds,
|
|
445
|
+
},
|
|
446
|
+
warnings,
|
|
447
|
+
errors,
|
|
448
|
+
};
|
|
449
|
+
} catch (error) {
|
|
450
|
+
errors.push(
|
|
451
|
+
createError(
|
|
452
|
+
options.relativePath,
|
|
453
|
+
'architecture-parse-failed',
|
|
454
|
+
error instanceof Error ? error.message : 'Failed to parse architecture document.',
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
kind: 'architecture',
|
|
460
|
+
extracted: null,
|
|
461
|
+
warnings,
|
|
462
|
+
errors,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseDecisionDocument(options) {
|
|
468
|
+
const warnings = [];
|
|
469
|
+
const errors = [];
|
|
470
|
+
const entries = extractDecisionEntries(options.content);
|
|
471
|
+
|
|
472
|
+
if (entries.length === 0) {
|
|
473
|
+
errors.push(
|
|
474
|
+
createError(options.relativePath, 'decision-missing-entries', 'Decision files must contain at least one resolvable decision entry.'),
|
|
475
|
+
);
|
|
476
|
+
return { kind: 'decision', extracted: null, warnings, errors };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const decisionIds = [];
|
|
480
|
+
for (const entry of entries) {
|
|
481
|
+
if (!entry.decisionId || entry.decisionId <= 0) {
|
|
482
|
+
errors.push(createError(options.relativePath, 'decision-invalid-id', 'Decision heading must include D-<positive integer>.'));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
decisionIds.push(entry.decisionId);
|
|
487
|
+
|
|
488
|
+
if (!/\*\*Timestamp:\*\*\s+.+/m.test(entry.body)) {
|
|
489
|
+
errors.push(
|
|
490
|
+
createError(
|
|
491
|
+
options.relativePath,
|
|
492
|
+
'decision-missing-timestamp',
|
|
493
|
+
`Decision D-${entry.decisionId} is missing "**Timestamp:**".`,
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!/\*\*Decision:\*\*\s+.+/m.test(entry.body)) {
|
|
499
|
+
errors.push(
|
|
500
|
+
createError(
|
|
501
|
+
options.relativePath,
|
|
502
|
+
'decision-missing-decision',
|
|
503
|
+
`Decision D-${entry.decisionId} is missing "**Decision:**".`,
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!/\*\*Rationale:\*\*\s+.+/m.test(entry.body)) {
|
|
509
|
+
warnings.push(
|
|
510
|
+
createWarning('decision-missing-rationale', `Decision D-${entry.decisionId} is missing "**Rationale:**".`),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
decisionIds.sort((left, right) => left - right);
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
kind: 'decision',
|
|
519
|
+
extracted: {
|
|
520
|
+
decision_ids: decisionIds,
|
|
521
|
+
},
|
|
522
|
+
warnings,
|
|
523
|
+
errors,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function parseSessionDocument(options) {
|
|
528
|
+
const warnings = [];
|
|
529
|
+
const errors = [];
|
|
530
|
+
const filenameMatch = options.relativePath.match(
|
|
531
|
+
/^sessions\/(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i,
|
|
532
|
+
);
|
|
533
|
+
const h1Match = options.content.match(/^#\s+Session(?:\s+—\s+(.+))?$/m);
|
|
534
|
+
|
|
535
|
+
if (!filenameMatch && !h1Match) {
|
|
536
|
+
errors.push(
|
|
537
|
+
createError(
|
|
538
|
+
options.relativePath,
|
|
539
|
+
'session-unrecognized',
|
|
540
|
+
'Session note must use the session filename pattern or a "# Session" heading.',
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
return { kind: 'session', extracted: null, warnings, errors };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!filenameMatch) {
|
|
547
|
+
warnings.push(
|
|
548
|
+
createWarning(
|
|
549
|
+
'session-filename-mismatch',
|
|
550
|
+
'Session note filename does not match the recommended YYYY-MM-DD-N-display-title.md pattern.',
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const sections = extractLevelTwoSections(options.content);
|
|
556
|
+
for (const section of RECOMMENDED_SESSION_SECTIONS) {
|
|
557
|
+
if (!sections.has(section.toLowerCase())) {
|
|
558
|
+
warnings.push(
|
|
559
|
+
createWarning('session-missing-section', `Session note is missing recommended section "## ${section}".`),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const title = extractSessionTitle(options.content, filenameMatch);
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
kind: 'session',
|
|
568
|
+
extracted: {
|
|
569
|
+
title,
|
|
570
|
+
session_date: filenameMatch ? filenameMatch[1] : null,
|
|
571
|
+
session_number: filenameMatch ? Number(filenameMatch[2]) : null,
|
|
572
|
+
},
|
|
573
|
+
warnings,
|
|
574
|
+
errors,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function extractDecisionEntries(content) {
|
|
579
|
+
const matches = [...content.matchAll(/^###\s+D-(\d+)\b.*$/gm)];
|
|
580
|
+
|
|
581
|
+
return matches.map((match, index) => {
|
|
582
|
+
const bodyStart = match.index ?? 0;
|
|
583
|
+
const nextMatch = matches[index + 1];
|
|
584
|
+
const bodyEnd = nextMatch?.index ?? content.length;
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
decisionId: Number(match[1]),
|
|
588
|
+
body: content.slice(bodyStart, bodyEnd),
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function findDuplicateDecisionErrors(documents) {
|
|
594
|
+
const seen = new Map();
|
|
595
|
+
const errors = [];
|
|
596
|
+
|
|
597
|
+
for (const document of documents) {
|
|
598
|
+
const decisionIds = document.extracted?.decision_ids ?? [];
|
|
599
|
+
for (const decisionId of decisionIds) {
|
|
600
|
+
const firstPath = seen.get(decisionId);
|
|
601
|
+
if (!firstPath) {
|
|
602
|
+
seen.set(decisionId, document.path);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
errors.push(
|
|
607
|
+
createError(
|
|
608
|
+
document.path,
|
|
609
|
+
'duplicate-decision-id',
|
|
610
|
+
`Decision D-${decisionId} is duplicated in both ${firstPath} and ${document.path}.`,
|
|
611
|
+
),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return errors;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function extractLevelTwoSections(content) {
|
|
620
|
+
const sections = new Set();
|
|
621
|
+
for (const match of content.matchAll(/^##\s+(.+)$/gm)) {
|
|
622
|
+
sections.add(match[1].trim().toLowerCase());
|
|
623
|
+
}
|
|
624
|
+
return sections;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function extractSessionTitle(content, filenameMatch) {
|
|
628
|
+
const h1WithNumber = content.match(/^#\s+Session\s+—\s+\d{4}-\d{2}-\d{2}-\d+\s+—\s+(.+)$/m);
|
|
629
|
+
if (h1WithNumber) {
|
|
630
|
+
return h1WithNumber[1].trim();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const simpleH1 = content.match(/^#\s+Session\s+—\s+(.+)$/m);
|
|
634
|
+
if (simpleH1) {
|
|
635
|
+
return simpleH1[1].trim();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!filenameMatch) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return filenameMatch[3]
|
|
643
|
+
.split('-')
|
|
644
|
+
.filter(Boolean)
|
|
645
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
646
|
+
.join(' ');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function finalizeDocument(document) {
|
|
650
|
+
const byteLength = Buffer.byteLength(document.content, 'utf8');
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
path: document.path,
|
|
654
|
+
kind: document.kind,
|
|
655
|
+
content: document.content,
|
|
656
|
+
contentHash: sha256Text(document.content),
|
|
657
|
+
byteLength,
|
|
658
|
+
extracted: document.extracted,
|
|
659
|
+
warnings: document.warnings,
|
|
660
|
+
errors: document.errors,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function createWarning(code, message) {
|
|
665
|
+
return { code, message, severity: 'warning' };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function createError(path, code, message) {
|
|
669
|
+
return { path, code, message, severity: 'error' };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function isPlainObject(value) {
|
|
673
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function toPosix(value) {
|
|
677
|
+
return value.split(path.sep).join('/');
|
|
678
|
+
}
|