frontend-harness 0.7.9 → 0.7.10
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/CHANGELOG.md +29 -0
- package/README.md +3 -0
- package/dist/cli/index.js +20 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/runtime/clean.js +24 -3
- package/dist/runtime/clean.js.map +1 -1
- package/dist/runtime/context.js +49 -5
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/evidence.js +63 -8
- package/dist/runtime/evidence.js.map +1 -1
- package/dist/runtime/graph.js +11 -4
- package/dist/runtime/graph.js.map +1 -1
- package/dist/runtime/knowledge/constants.d.ts +9 -0
- package/dist/runtime/knowledge/constants.js +24 -0
- package/dist/runtime/knowledge/constants.js.map +1 -0
- package/dist/runtime/knowledge/core.d.ts +19 -0
- package/dist/runtime/knowledge/core.js +1264 -0
- package/dist/runtime/knowledge/core.js.map +1 -0
- package/dist/runtime/knowledge/types.d.ts +172 -0
- package/dist/runtime/knowledge/types.js +2 -0
- package/dist/runtime/knowledge/types.js.map +1 -0
- package/dist/runtime/knowledge.d.ts +2 -188
- package/dist/runtime/knowledge.js +1 -1126
- package/dist/runtime/knowledge.js.map +1 -1
- package/dist/runtime/plan/component-resolver.js +17 -6
- package/dist/runtime/plan/component-resolver.js.map +1 -1
- package/dist/runtime/plan/proposal.js +9 -0
- package/dist/runtime/plan/proposal.js.map +1 -1
- package/dist/runtime/plan.js +6 -4
- package/dist/runtime/plan.js.map +1 -1
- package/dist/runtime/policy-provenance.js +43 -5
- package/dist/runtime/policy-provenance.js.map +1 -1
- package/dist/runtime/project-discovery.js +32 -17
- package/dist/runtime/project-discovery.js.map +1 -1
- package/dist/runtime/project-paths.js +7 -1
- package/dist/runtime/project-paths.js.map +1 -1
- package/dist/runtime/protocol-init.js +105 -19
- package/dist/runtime/protocol-init.js.map +1 -1
- package/dist/runtime/repair-decision.js +76 -16
- package/dist/runtime/repair-decision.js.map +1 -1
- package/dist/runtime/repair-packet.js +55 -16
- package/dist/runtime/repair-packet.js.map +1 -1
- package/dist/runtime/scaffold/vue-template.js +1 -1
- package/dist/runtime/scaffold/vue-template.js.map +1 -1
- package/dist/runtime/scaffold.js +44 -8
- package/dist/runtime/scaffold.js.map +1 -1
- package/dist/runtime/skills.js +120 -18
- package/dist/runtime/skills.js.map +1 -1
- package/dist/runtime/state.js +49 -8
- package/dist/runtime/state.js.map +1 -1
- package/dist/runtime/units.js +11 -4
- package/dist/runtime/units.js.map +1 -1
- package/dist/runtime/verification-commands.js +95 -21
- package/dist/runtime/verification-commands.js.map +1 -1
- package/dist/runtime/verify.js +64 -18
- package/dist/runtime/verify.js.map +1 -1
- package/dist/runtime/visual-compare.js +104 -12
- package/dist/runtime/visual-compare.js.map +1 -1
- package/dist/schemas/validation.js +222 -6
- package/dist/schemas/validation.js.map +1 -1
- package/dist/storage/json.d.ts +9 -1
- package/dist/storage/json.js +106 -8
- package/dist/storage/json.js.map +1 -1
- package/docs/RELEASE.md +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDirectoryWithoutSymlink, isRegularFileWithoutSymlink, writeJson, writeText } from "../../storage/json.js";
|
|
4
|
+
import { harnessPath, relativeHarnessPath } from "../../storage/paths.js";
|
|
5
|
+
import { normalizeProjectRelativePath } from "../project-paths.js";
|
|
6
|
+
import { kindTypeMap, knowledgeKinds, knowledgeStabilities, knowledgeStatuses, knowledgeTypes, MAX_KNOWLEDGE_DIRECTORY_DEPTH, MAX_KNOWLEDGE_MARKDOWN_BYTES, MAX_KNOWLEDGE_MARKDOWN_FILES } from "./constants.js";
|
|
7
|
+
export function promoteKnowledge(projectRoot, input) {
|
|
8
|
+
const title = input.title?.trim();
|
|
9
|
+
const body = input.body?.trim();
|
|
10
|
+
if (!title) {
|
|
11
|
+
throw new Error("knowledge promote requires --title");
|
|
12
|
+
}
|
|
13
|
+
if (!body) {
|
|
14
|
+
throw new Error("knowledge promote requires --body");
|
|
15
|
+
}
|
|
16
|
+
const type = parseKnowledgeType(input.type ?? "prd_semantics", "knowledge promote --type");
|
|
17
|
+
const status = parseKnowledgeStatus(input.status ?? "active", "knowledge promote --status");
|
|
18
|
+
const stability = parseKnowledgeStability(input.stability ?? "stable", "knowledge promote --stability");
|
|
19
|
+
const createdAt = new Date().toISOString();
|
|
20
|
+
const id = slugify(title) || `knowledge-${formatKnowledgeStamp(createdAt).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
21
|
+
const fileName = `${formatKnowledgeStamp(createdAt)}-${id}.md`;
|
|
22
|
+
const relativePath = relativeHarnessPath("knowledge", fileName);
|
|
23
|
+
const content = renderKnowledge({
|
|
24
|
+
id,
|
|
25
|
+
title,
|
|
26
|
+
body,
|
|
27
|
+
type,
|
|
28
|
+
status,
|
|
29
|
+
stability,
|
|
30
|
+
createdAt,
|
|
31
|
+
scope: splitList(input.scope),
|
|
32
|
+
tags: splitList(input.tags),
|
|
33
|
+
sourcePaths: parseKnowledgeSourcePaths(input.source, "knowledge promote --source")
|
|
34
|
+
});
|
|
35
|
+
writeText(path.join(projectRoot, relativePath), content);
|
|
36
|
+
return {
|
|
37
|
+
path: relativePath,
|
|
38
|
+
id,
|
|
39
|
+
title,
|
|
40
|
+
type,
|
|
41
|
+
status,
|
|
42
|
+
stability,
|
|
43
|
+
createdAt
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function createModuleKnowledge(projectRoot, input) {
|
|
47
|
+
const title = input.title?.trim();
|
|
48
|
+
const summary = input.summary?.trim();
|
|
49
|
+
const body = input.body?.trim();
|
|
50
|
+
if (!title) {
|
|
51
|
+
throw new Error("knowledge module requires --title");
|
|
52
|
+
}
|
|
53
|
+
if (!summary) {
|
|
54
|
+
throw new Error("knowledge module requires --summary");
|
|
55
|
+
}
|
|
56
|
+
if (!body) {
|
|
57
|
+
throw new Error("knowledge module requires --body");
|
|
58
|
+
}
|
|
59
|
+
const status = parseKnowledgeStatus(input.status ?? "active", "knowledge module --status");
|
|
60
|
+
const stability = parseKnowledgeStability(input.stability ?? "stable", "knowledge module --stability");
|
|
61
|
+
const createdAt = new Date().toISOString();
|
|
62
|
+
const id = input.id?.trim() || slugify(title);
|
|
63
|
+
if (!id || !/^[a-z0-9][a-z0-9-]{2,80}$/.test(id)) {
|
|
64
|
+
throw new Error("knowledge module --id must be kebab-case when provided");
|
|
65
|
+
}
|
|
66
|
+
const relativePath = relativeHarnessPath("knowledge", "modules", `${id}.md`);
|
|
67
|
+
const fullPath = path.join(projectRoot, relativePath);
|
|
68
|
+
if (fs.existsSync(fullPath)) {
|
|
69
|
+
throw new Error(`knowledge module already exists: ${relativePath}`);
|
|
70
|
+
}
|
|
71
|
+
const content = renderKnowledgeFromMetadata({
|
|
72
|
+
id,
|
|
73
|
+
title,
|
|
74
|
+
summary,
|
|
75
|
+
type: "module_summary",
|
|
76
|
+
status,
|
|
77
|
+
stability,
|
|
78
|
+
updatedAt: createdAt,
|
|
79
|
+
scope: splitList(input.scope),
|
|
80
|
+
tags: splitList(input.tags),
|
|
81
|
+
verification: splitList(input.verification),
|
|
82
|
+
coverage: splitList(input.coverage),
|
|
83
|
+
prdAnchors: splitList(input.anchor),
|
|
84
|
+
sourcePaths: parseKnowledgeSourcePaths(input.source, "knowledge module --source"),
|
|
85
|
+
related: splitList(input.related)
|
|
86
|
+
}, body);
|
|
87
|
+
writeText(fullPath, content);
|
|
88
|
+
return {
|
|
89
|
+
path: relativePath,
|
|
90
|
+
id,
|
|
91
|
+
title,
|
|
92
|
+
type: "module_summary",
|
|
93
|
+
status,
|
|
94
|
+
stability,
|
|
95
|
+
createdAt
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function addKnowledge(projectRoot, input) {
|
|
99
|
+
const kind = parseKnowledgeKind(input.kind ?? "", "knowledge add --kind");
|
|
100
|
+
const subject = input.subject?.trim();
|
|
101
|
+
if (!subject) {
|
|
102
|
+
throw new Error("knowledge add requires --subject");
|
|
103
|
+
}
|
|
104
|
+
const content = atomicContent(kind, input);
|
|
105
|
+
if (!content) {
|
|
106
|
+
throw new Error(`knowledge add requires --${contentFlagForKind(kind)} or --note`);
|
|
107
|
+
}
|
|
108
|
+
const status = parseKnowledgeStatus(input.status ?? "active", "knowledge add --status");
|
|
109
|
+
const stability = parseKnowledgeStability(input.stability ?? "stable", "knowledge add --stability");
|
|
110
|
+
const createdAt = new Date().toISOString();
|
|
111
|
+
const id = input.id?.trim() || slugify(`${subject}-${kind}-${content}`);
|
|
112
|
+
if (!id || !/^[a-z0-9][a-z0-9-]{2,80}$/.test(id)) {
|
|
113
|
+
throw new Error("knowledge add --id must be kebab-case when provided");
|
|
114
|
+
}
|
|
115
|
+
const relativePath = relativeHarnessPath("knowledge", kind, `${id}.md`);
|
|
116
|
+
const fullPath = path.join(projectRoot, relativePath);
|
|
117
|
+
if (fs.existsSync(fullPath)) {
|
|
118
|
+
throw new Error(`knowledge card already exists: ${relativePath}`);
|
|
119
|
+
}
|
|
120
|
+
const title = `${subject} ${titleCase(kind)}`;
|
|
121
|
+
const contentFields = atomicContentFields(kind, input);
|
|
122
|
+
const inputSummary = input.summary?.trim();
|
|
123
|
+
const contentSummary = inputSummary || summarizeAtomicContent(kind, subject, contentFields);
|
|
124
|
+
const rendered = renderAtomicKnowledge({
|
|
125
|
+
id,
|
|
126
|
+
title,
|
|
127
|
+
summary: contentSummary,
|
|
128
|
+
type: kindTypeMap[kind],
|
|
129
|
+
kind,
|
|
130
|
+
subject,
|
|
131
|
+
status,
|
|
132
|
+
stability,
|
|
133
|
+
createdAt,
|
|
134
|
+
scope: splitList(input.scope),
|
|
135
|
+
tags: splitList(input.tags),
|
|
136
|
+
sourcePaths: parseKnowledgeSourcePaths(input.source, "knowledge add --source"),
|
|
137
|
+
verification: splitList(input.verification),
|
|
138
|
+
coverage: splitList(input.coverage),
|
|
139
|
+
prdAnchors: splitList(input.anchor),
|
|
140
|
+
contentFields
|
|
141
|
+
});
|
|
142
|
+
writeText(fullPath, rendered);
|
|
143
|
+
return {
|
|
144
|
+
path: relativePath,
|
|
145
|
+
id,
|
|
146
|
+
title,
|
|
147
|
+
type: kindTypeMap[kind],
|
|
148
|
+
status,
|
|
149
|
+
stability,
|
|
150
|
+
createdAt
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function updateKnowledge(projectRoot, input) {
|
|
154
|
+
const target = input.id?.trim() || input.path?.trim();
|
|
155
|
+
if (!target) {
|
|
156
|
+
throw new Error("knowledge update requires --id or --path");
|
|
157
|
+
}
|
|
158
|
+
const cards = discoverKnowledgeCards(projectRoot);
|
|
159
|
+
const card = resolveKnowledgeCardReference(cards, target);
|
|
160
|
+
if (!card) {
|
|
161
|
+
throw new Error(`knowledge card not found: ${target}`);
|
|
162
|
+
}
|
|
163
|
+
const fullPath = path.join(projectRoot, card.path);
|
|
164
|
+
const content = safeRead(fullPath);
|
|
165
|
+
if (content === null) {
|
|
166
|
+
throw new Error(`knowledge card cannot be read: ${card.path}`);
|
|
167
|
+
}
|
|
168
|
+
const parsed = parseKnowledgeFrontmatter(content);
|
|
169
|
+
if (!parsed.hasFrontmatter) {
|
|
170
|
+
throw new Error(`${card.path} must include YAML frontmatter.`);
|
|
171
|
+
}
|
|
172
|
+
const metadata = parsed.metadata;
|
|
173
|
+
if (!metadata.id || !metadata.title || !metadata.summary || !metadata.type || !metadata.status || !metadata.stability) {
|
|
174
|
+
throw new Error(`${card.path} has incomplete knowledge metadata.`);
|
|
175
|
+
}
|
|
176
|
+
const nextStatus = input.status ? parseKnowledgeStatus(input.status, "knowledge update --status") : metadata.status;
|
|
177
|
+
const nextStability = input.stability ? parseKnowledgeStability(input.stability, "knowledge update --stability") : metadata.stability;
|
|
178
|
+
const updatedAt = new Date().toISOString();
|
|
179
|
+
const nextMetadata = {
|
|
180
|
+
...metadata,
|
|
181
|
+
id: metadata.id,
|
|
182
|
+
title: input.title?.trim() || metadata.title,
|
|
183
|
+
summary: input.summary?.trim() || metadata.summary,
|
|
184
|
+
type: metadata.type,
|
|
185
|
+
status: nextStatus,
|
|
186
|
+
stability: nextStability,
|
|
187
|
+
updatedAt,
|
|
188
|
+
scope: mergeList(metadata.scope, input.scope),
|
|
189
|
+
tags: mergeList(metadata.tags, input.tags),
|
|
190
|
+
verification: mergeList(metadata.verification, input.verification),
|
|
191
|
+
coverage: mergeList(metadata.coverage, input.coverage),
|
|
192
|
+
prdAnchors: mergeList(metadata.prdAnchors, input.anchor),
|
|
193
|
+
sourcePaths: mergeKnowledgeSourcePaths(metadata.sourcePaths, input.source, "knowledge update --source"),
|
|
194
|
+
related: mergeList(metadata.related, input.related)
|
|
195
|
+
};
|
|
196
|
+
for (const key of ["rule", "workflow", "permission", "term", "note"]) {
|
|
197
|
+
const value = input[key]?.trim();
|
|
198
|
+
if (value) {
|
|
199
|
+
nextMetadata[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const body = input.body?.trim() || extractMarkdownBody(content);
|
|
203
|
+
const errors = validateKnowledgeMetadata(card.path, nextMetadata);
|
|
204
|
+
if (errors.length) {
|
|
205
|
+
throw new Error(errors.join(" "));
|
|
206
|
+
}
|
|
207
|
+
writeText(fullPath, renderKnowledgeFromMetadata(nextMetadata, body));
|
|
208
|
+
return {
|
|
209
|
+
path: card.path,
|
|
210
|
+
id: nextMetadata.id,
|
|
211
|
+
title: nextMetadata.title,
|
|
212
|
+
type: nextMetadata.type,
|
|
213
|
+
status: nextStatus,
|
|
214
|
+
stability: nextStability,
|
|
215
|
+
createdAt: updatedAt
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function discoverKnowledgeCards(projectRoot) {
|
|
219
|
+
const knowledgeRoot = harnessPath(projectRoot, "knowledge");
|
|
220
|
+
if (!isReadableDirectory(knowledgeRoot)) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
return listMarkdownFiles(knowledgeRoot)
|
|
224
|
+
.flatMap((fullPath) => {
|
|
225
|
+
const card = describeKnowledgeCard(projectRoot, fullPath);
|
|
226
|
+
return card ? [card] : [];
|
|
227
|
+
})
|
|
228
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
229
|
+
}
|
|
230
|
+
export function indexKnowledge(projectRoot) {
|
|
231
|
+
const cards = discoverKnowledgeCards(projectRoot);
|
|
232
|
+
const prdSources = summarizePrdSources(projectRoot, cards);
|
|
233
|
+
const moduleSummaries = summarizeModules(cards);
|
|
234
|
+
const result = {
|
|
235
|
+
artifactPath: relativeHarnessPath("knowledge", "index.json"),
|
|
236
|
+
cardCount: cards.length,
|
|
237
|
+
activeCardCount: cards.filter((card) => card.status === "active").length,
|
|
238
|
+
deprecatedCardCount: cards.filter((card) => card.status === "deprecated").length,
|
|
239
|
+
prdSourceCount: prdSources.length,
|
|
240
|
+
uncoveredPrdSourceCount: prdSources.filter((source) => source.activeCardCount === 0).length,
|
|
241
|
+
byType: countByType(cards),
|
|
242
|
+
byKind: countByKind(cards),
|
|
243
|
+
moduleSummaries,
|
|
244
|
+
prdSources,
|
|
245
|
+
cards: cards.map(stripSearchable)
|
|
246
|
+
};
|
|
247
|
+
writeJson(path.join(projectRoot, result.artifactPath), result);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
export function checkKnowledgeCoverage(projectRoot, options = {}) {
|
|
251
|
+
const cards = discoverKnowledgeCards(projectRoot);
|
|
252
|
+
if (!isReadableDirectory(harnessPath(projectRoot, "knowledge"))) {
|
|
253
|
+
return {
|
|
254
|
+
status: "not_configured",
|
|
255
|
+
prdSourceCount: 0,
|
|
256
|
+
coveredPrdSourceCount: 0,
|
|
257
|
+
uncoveredPrdSources: [],
|
|
258
|
+
prdSourcesWithoutCoverageItems: [],
|
|
259
|
+
missingSourcePaths: [],
|
|
260
|
+
cardsWithoutSource: [],
|
|
261
|
+
cardsWithoutCoverage: [],
|
|
262
|
+
cardsWithBroadCoverage: [],
|
|
263
|
+
cardsWithTraceabilityDrift: [],
|
|
264
|
+
warnings: ["Create .frontend-harness/knowledge before checking project knowledge coverage."],
|
|
265
|
+
errors: []
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const prdSources = summarizePrdSources(projectRoot, cards, options.requiredPrdSources ?? []);
|
|
269
|
+
const uncoveredPrdSources = prdSources
|
|
270
|
+
.filter((source) => source.activeCardCount === 0)
|
|
271
|
+
.map((source) => source.path);
|
|
272
|
+
const missingSourcePaths = [...new Set(cards.flatMap((card) => card.sourcePaths)
|
|
273
|
+
.filter((sourcePath) => sourcePath && !isExternalReference(sourcePath) && !isKnowledgeSourceFile(projectRoot, sourcePath)))].sort();
|
|
274
|
+
const cardsWithoutSource = cards
|
|
275
|
+
.filter((card) => card.status === "active" && card.type === "prd_semantics" && card.sourcePaths.length === 0)
|
|
276
|
+
.map((card) => card.path);
|
|
277
|
+
const cardsWithoutCoverage = cards
|
|
278
|
+
.filter((card) => card.status === "active" && isPrdTraceableCard(card) && card.coverage.length === 0)
|
|
279
|
+
.map((card) => card.path);
|
|
280
|
+
const cardsWithBroadCoverage = cards
|
|
281
|
+
.filter((card) => card.status === "active" && isPrdTraceableCard(card) && card.coverage.some(isBroadCoverageItem))
|
|
282
|
+
.map((card) => card.path);
|
|
283
|
+
const cardsWithTraceabilityDrift = cards
|
|
284
|
+
.filter((card) => card.status === "active" && isPrdTraceableCard(card) && hasCoverageBodyDrift(projectRoot, card))
|
|
285
|
+
.map((card) => card.path);
|
|
286
|
+
const prdSourcesWithoutCoverageItems = prdSources
|
|
287
|
+
.filter((source) => source.activeCardCount > 0 && source.coverage.length === 0)
|
|
288
|
+
.map((source) => source.path);
|
|
289
|
+
const warnings = [
|
|
290
|
+
...cardsWithoutSource.map((cardPath) => `${cardPath} has no source_paths for PRD traceability.`),
|
|
291
|
+
...cardsWithoutCoverage.map((cardPath) => `${cardPath} has no coverage items for PRD block traceability.`),
|
|
292
|
+
...cardsWithBroadCoverage.map((cardPath) => `${cardPath} uses broad PRD coverage; split PRD semantics into atomic cards with precise section, table, rule, or acceptance-block coverage.`),
|
|
293
|
+
...cardsWithTraceabilityDrift.map((cardPath) => `${cardPath} frontmatter coverage disagrees with body traceability.`),
|
|
294
|
+
...prdSourcesWithoutCoverageItems.map((sourcePath) => `PRD source has cards but no coverage items: ${sourcePath}`),
|
|
295
|
+
...missingSourcePaths.map((sourcePath) => `source path does not exist: ${sourcePath}`)
|
|
296
|
+
];
|
|
297
|
+
const errors = [
|
|
298
|
+
...uncoveredPrdSources.map((sourcePath) => `PRD source has no active knowledge cards: ${sourcePath}`),
|
|
299
|
+
...prdSourcesWithoutCoverageItems.map((sourcePath) => `PRD source has no coverage items: ${sourcePath}`),
|
|
300
|
+
...cardsWithBroadCoverage.map((cardPath) => `${cardPath} uses broad PRD coverage instead of atomic traceability.`),
|
|
301
|
+
...cardsWithTraceabilityDrift.map((cardPath) => `${cardPath} has frontmatter coverage but body traceability says coverage is not specified.`),
|
|
302
|
+
...missingSourcePaths.map((sourcePath) => `source path does not exist: ${sourcePath}`)
|
|
303
|
+
];
|
|
304
|
+
return {
|
|
305
|
+
status: errors.length || cardsWithoutSource.length || cardsWithoutCoverage.length ? "failed" : "passed",
|
|
306
|
+
prdSourceCount: prdSources.length,
|
|
307
|
+
coveredPrdSourceCount: prdSources.filter((source) => source.activeCardCount > 0).length,
|
|
308
|
+
uncoveredPrdSources,
|
|
309
|
+
prdSourcesWithoutCoverageItems,
|
|
310
|
+
missingSourcePaths,
|
|
311
|
+
cardsWithoutSource,
|
|
312
|
+
cardsWithoutCoverage,
|
|
313
|
+
cardsWithBroadCoverage,
|
|
314
|
+
cardsWithTraceabilityDrift,
|
|
315
|
+
warnings,
|
|
316
|
+
errors
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export function listKnowledge(projectRoot, options = {}) {
|
|
320
|
+
const cards = discoverKnowledgeCards(projectRoot)
|
|
321
|
+
.filter((card) => options.includeDeprecated || card.status === "active")
|
|
322
|
+
.map(stripSearchable);
|
|
323
|
+
return { cards };
|
|
324
|
+
}
|
|
325
|
+
export function searchKnowledge(projectRoot, query, options = {}) {
|
|
326
|
+
const normalizedQuery = normalize(query ?? "");
|
|
327
|
+
if (!normalizedQuery) {
|
|
328
|
+
throw new Error("knowledge search requires --query");
|
|
329
|
+
}
|
|
330
|
+
const limit = options.limit && options.limit > 0 ? options.limit : 8;
|
|
331
|
+
const cards = discoverKnowledgeCards(projectRoot)
|
|
332
|
+
.filter((card) => options.includeDeprecated || card.status === "active")
|
|
333
|
+
.map((card) => ({
|
|
334
|
+
card,
|
|
335
|
+
score: scoreCardSearch(card, normalizedQuery)
|
|
336
|
+
}))
|
|
337
|
+
.filter((item) => item.score > 0)
|
|
338
|
+
.sort((left, right) => {
|
|
339
|
+
if (right.score !== left.score) {
|
|
340
|
+
return right.score - left.score;
|
|
341
|
+
}
|
|
342
|
+
return left.card.path.localeCompare(right.card.path);
|
|
343
|
+
})
|
|
344
|
+
.slice(0, limit)
|
|
345
|
+
.map(({ card, score }) => ({
|
|
346
|
+
...stripSearchable(card),
|
|
347
|
+
score
|
|
348
|
+
}));
|
|
349
|
+
return { query: query ?? "", cards };
|
|
350
|
+
}
|
|
351
|
+
export function showKnowledge(projectRoot, idOrPath) {
|
|
352
|
+
const target = idOrPath?.trim();
|
|
353
|
+
if (!target) {
|
|
354
|
+
throw new Error("knowledge show requires --id or --path");
|
|
355
|
+
}
|
|
356
|
+
const cards = discoverKnowledgeCards(projectRoot);
|
|
357
|
+
const card = resolveKnowledgeCardReference(cards, target);
|
|
358
|
+
if (!card) {
|
|
359
|
+
throw new Error(`knowledge card not found: ${target}`);
|
|
360
|
+
}
|
|
361
|
+
const fullPath = path.join(projectRoot, card.path);
|
|
362
|
+
const content = safeRead(fullPath);
|
|
363
|
+
if (content === null) {
|
|
364
|
+
throw new Error(`knowledge card cannot be read: ${card.path}`);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
card: stripSearchable(card),
|
|
368
|
+
body: extractMarkdownBody(content)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
export function checkKnowledge(projectRoot) {
|
|
372
|
+
const knowledgeRoot = harnessPath(projectRoot, "knowledge");
|
|
373
|
+
const relativeRoot = path.relative(projectRoot, knowledgeRoot).split(path.sep).join(path.posix.sep);
|
|
374
|
+
if (!fs.existsSync(knowledgeRoot)) {
|
|
375
|
+
return {
|
|
376
|
+
status: "not_configured",
|
|
377
|
+
knowledgeRoot: relativeRoot,
|
|
378
|
+
cardCount: 0,
|
|
379
|
+
activeCardCount: 0,
|
|
380
|
+
deprecatedCardCount: 0,
|
|
381
|
+
errors: [],
|
|
382
|
+
warnings: ["Create .frontend-harness/knowledge before checking project knowledge."]
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const errors = [];
|
|
386
|
+
const warnings = [];
|
|
387
|
+
if (!isReadableDirectory(knowledgeRoot)) {
|
|
388
|
+
errors.push(".frontend-harness/knowledge must be a readable directory.");
|
|
389
|
+
}
|
|
390
|
+
const files = isReadableDirectory(knowledgeRoot) ? listMarkdownFiles(knowledgeRoot, errors) : [];
|
|
391
|
+
const ids = new Set();
|
|
392
|
+
const duplicateIds = new Set();
|
|
393
|
+
let activeCardCount = 0;
|
|
394
|
+
let deprecatedCardCount = 0;
|
|
395
|
+
for (const fullPath of files) {
|
|
396
|
+
const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join(path.posix.sep);
|
|
397
|
+
const content = safeRead(fullPath);
|
|
398
|
+
if (content === null) {
|
|
399
|
+
errors.push(`${relativePath} cannot be read.`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const parsed = parseKnowledgeFrontmatter(content);
|
|
403
|
+
if (!parsed.hasFrontmatter) {
|
|
404
|
+
errors.push(`${relativePath} must include YAML frontmatter.`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const cardErrors = validateKnowledgeMetadata(relativePath, parsed.metadata);
|
|
408
|
+
errors.push(...parsed.syntaxErrors.map((error) => `${relativePath} ${error}.`));
|
|
409
|
+
errors.push(...cardErrors);
|
|
410
|
+
const id = parsed.metadata.id;
|
|
411
|
+
if (id) {
|
|
412
|
+
if (ids.has(id)) {
|
|
413
|
+
duplicateIds.add(id);
|
|
414
|
+
}
|
|
415
|
+
ids.add(id);
|
|
416
|
+
}
|
|
417
|
+
if (parsed.metadata.status === "active") {
|
|
418
|
+
activeCardCount += 1;
|
|
419
|
+
}
|
|
420
|
+
if (parsed.metadata.status === "deprecated") {
|
|
421
|
+
deprecatedCardCount += 1;
|
|
422
|
+
if (!parsed.metadata.related.length) {
|
|
423
|
+
warnings.push(`${relativePath} is deprecated without a related replacement or explanation.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if ((parsed.metadata.summary?.length ?? 0) > 180) {
|
|
427
|
+
warnings.push(`${relativePath} summary should stay concise for context display.`);
|
|
428
|
+
}
|
|
429
|
+
if (parsed.metadata.kind) {
|
|
430
|
+
const contentValue = primaryAtomicValue(parsed.metadata);
|
|
431
|
+
if (contentValue && normalize(contentValue) === normalize(parsed.metadata.summary ?? "")) {
|
|
432
|
+
warnings.push(`${relativePath} summary duplicates the primary atomic content; use summary for context and the primary field for the durable rule.`);
|
|
433
|
+
}
|
|
434
|
+
if (parsed.metadata.sourcePaths.some(isPrdSourcePath) && parsed.metadata.coverage.length === 0) {
|
|
435
|
+
warnings.push(`${relativePath} should include coverage items for PRD block traceability.`);
|
|
436
|
+
}
|
|
437
|
+
if (parsed.metadata.sourcePaths.some(isPrdSourcePath) && parsed.metadata.coverage.some(isBroadCoverageItem)) {
|
|
438
|
+
warnings.push(`${relativePath} uses broad PRD coverage; split PRD semantics into smaller atomic cards with precise section, table, rule, or acceptance-block coverage.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (parsed.metadata.coverage.length > 0 && /\bCoverage:\s*Not specified\./i.test(extractMarkdownBody(content))) {
|
|
442
|
+
warnings.push(`${relativePath} frontmatter coverage disagrees with body traceability.`);
|
|
443
|
+
}
|
|
444
|
+
for (const sourcePath of parsed.metadata.sourcePaths) {
|
|
445
|
+
if (sourcePath && !isExternalReference(sourcePath) && !isKnowledgeSourceFile(projectRoot, sourcePath)) {
|
|
446
|
+
warnings.push(`${relativePath} source path is missing or unsafe: ${sourcePath}.`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const duplicateId of duplicateIds) {
|
|
451
|
+
errors.push(`Duplicate knowledge id: ${duplicateId}.`);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
status: errors.length ? "failed" : "passed",
|
|
455
|
+
knowledgeRoot: relativeRoot,
|
|
456
|
+
cardCount: files.length,
|
|
457
|
+
activeCardCount,
|
|
458
|
+
deprecatedCardCount,
|
|
459
|
+
errors,
|
|
460
|
+
warnings
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function describeKnowledgeCard(projectRoot, fullPath) {
|
|
464
|
+
const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join(path.posix.sep);
|
|
465
|
+
const content = safeRead(fullPath);
|
|
466
|
+
if (content === null) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const parsed = parseKnowledgeFrontmatter(content);
|
|
470
|
+
if (!parsed.hasFrontmatter || parsed.syntaxErrors.length || validateKnowledgeMetadata(relativePath, parsed.metadata).length) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const metadata = parsed.metadata;
|
|
474
|
+
const body = extractMarkdownBody(content);
|
|
475
|
+
const title = metadata.title;
|
|
476
|
+
const summary = metadata.summary;
|
|
477
|
+
const type = metadata.type;
|
|
478
|
+
const kind = metadata.kind;
|
|
479
|
+
const status = metadata.status;
|
|
480
|
+
const stability = metadata.stability;
|
|
481
|
+
const scope = metadata.scope;
|
|
482
|
+
const tags = metadata.tags;
|
|
483
|
+
const sourcePaths = metadata.sourcePaths;
|
|
484
|
+
const related = metadata.related;
|
|
485
|
+
const updatedAt = metadata.updatedAt;
|
|
486
|
+
return {
|
|
487
|
+
id: metadata.id,
|
|
488
|
+
path: relativePath,
|
|
489
|
+
title,
|
|
490
|
+
summary,
|
|
491
|
+
type,
|
|
492
|
+
...(kind ? { kind } : {}),
|
|
493
|
+
...(metadata.subject ? { subject: metadata.subject } : {}),
|
|
494
|
+
...(metadata.rule ? { rule: metadata.rule } : {}),
|
|
495
|
+
...(metadata.workflow ? { workflow: metadata.workflow } : {}),
|
|
496
|
+
...(metadata.permission ? { permission: metadata.permission } : {}),
|
|
497
|
+
...(metadata.term ? { term: metadata.term } : {}),
|
|
498
|
+
...(metadata.note ? { note: metadata.note } : {}),
|
|
499
|
+
verification: metadata.verification,
|
|
500
|
+
coverage: metadata.coverage,
|
|
501
|
+
prdAnchors: metadata.prdAnchors,
|
|
502
|
+
status,
|
|
503
|
+
stability,
|
|
504
|
+
scope,
|
|
505
|
+
tags,
|
|
506
|
+
sourcePaths,
|
|
507
|
+
related,
|
|
508
|
+
updatedAt,
|
|
509
|
+
searchable: normalize([
|
|
510
|
+
relativePath,
|
|
511
|
+
metadata.id,
|
|
512
|
+
title,
|
|
513
|
+
summary,
|
|
514
|
+
type,
|
|
515
|
+
kind ?? "",
|
|
516
|
+
stability,
|
|
517
|
+
metadata.subject ?? "",
|
|
518
|
+
metadata.rule ?? "",
|
|
519
|
+
metadata.workflow ?? "",
|
|
520
|
+
metadata.permission ?? "",
|
|
521
|
+
metadata.term ?? "",
|
|
522
|
+
metadata.note ?? "",
|
|
523
|
+
...metadata.verification,
|
|
524
|
+
...metadata.coverage,
|
|
525
|
+
...metadata.prdAnchors,
|
|
526
|
+
...scope,
|
|
527
|
+
...tags,
|
|
528
|
+
...sourcePaths,
|
|
529
|
+
...related,
|
|
530
|
+
body
|
|
531
|
+
].join(" "))
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function stripSearchable(card) {
|
|
535
|
+
const { searchable, ...publicCard } = card;
|
|
536
|
+
void searchable;
|
|
537
|
+
return publicCard;
|
|
538
|
+
}
|
|
539
|
+
function resolveKnowledgeCardReference(cards, target) {
|
|
540
|
+
const exactPath = cards.find((item) => item.path === target);
|
|
541
|
+
if (exactPath) {
|
|
542
|
+
return exactPath;
|
|
543
|
+
}
|
|
544
|
+
const idMatches = cards.filter((item) => item.id === target);
|
|
545
|
+
if (idMatches.length === 1) {
|
|
546
|
+
return idMatches[0] ?? null;
|
|
547
|
+
}
|
|
548
|
+
if (idMatches.length > 1) {
|
|
549
|
+
throw new Error(`knowledge card reference is ambiguous: ${target}. Use --path with one of: ${idMatches.map((item) => item.path).join(", ")}.`);
|
|
550
|
+
}
|
|
551
|
+
const pathSuffixMatches = cards.filter((item) => item.path.endsWith(`/${target}`));
|
|
552
|
+
if (pathSuffixMatches.length === 1) {
|
|
553
|
+
return pathSuffixMatches[0] ?? null;
|
|
554
|
+
}
|
|
555
|
+
if (pathSuffixMatches.length > 1) {
|
|
556
|
+
throw new Error(`knowledge card reference is ambiguous: ${target}. Use --path with one of: ${pathSuffixMatches.map((item) => item.path).join(", ")}.`);
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
function parseKnowledgeFrontmatter(content) {
|
|
561
|
+
const metadata = {
|
|
562
|
+
scope: [],
|
|
563
|
+
tags: [],
|
|
564
|
+
verification: [],
|
|
565
|
+
coverage: [],
|
|
566
|
+
prdAnchors: [],
|
|
567
|
+
sourcePaths: [],
|
|
568
|
+
related: []
|
|
569
|
+
};
|
|
570
|
+
const syntaxErrors = [];
|
|
571
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
572
|
+
return { hasFrontmatter: false, syntaxErrors, metadata };
|
|
573
|
+
}
|
|
574
|
+
const endIndex = content.search(/\r?\n---(?:\r?\n|$)/);
|
|
575
|
+
if (endIndex < 0) {
|
|
576
|
+
syntaxErrors.push("frontmatter must be closed with ---");
|
|
577
|
+
return { hasFrontmatter: true, syntaxErrors, metadata };
|
|
578
|
+
}
|
|
579
|
+
const listKeys = new Set(["scope", "tags", "verification", "coverage", "prd_anchors", "source_paths", "related"]);
|
|
580
|
+
let currentList = null;
|
|
581
|
+
const startIndex = content.startsWith("---\r\n") ? 5 : 4;
|
|
582
|
+
for (const [lineIndex, line] of content.slice(startIndex, endIndex).split(/\r?\n/).entries()) {
|
|
583
|
+
const trimmed = line.trim();
|
|
584
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const leadingWhitespace = line.match(/^\s*/)?.[0] ?? "";
|
|
588
|
+
if (leadingWhitespace.length > 0 && !trimmed.startsWith("-") && /^[A-Za-z][A-Za-z0-9_-]*:\s*/.test(trimmed)) {
|
|
589
|
+
syntaxErrors.push(`frontmatter line ${lineIndex + 2} has an indented mapping key: ${trimmed}`);
|
|
590
|
+
}
|
|
591
|
+
if (trimmed === "[]" && currentList) {
|
|
592
|
+
currentList = null;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const listItem = trimmed.match(/^-\s+(.+)$/);
|
|
596
|
+
if (listItem) {
|
|
597
|
+
if (!currentList) {
|
|
598
|
+
syntaxErrors.push(`frontmatter line ${lineIndex + 2} has a list item without a list key`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const value = listItem[1];
|
|
602
|
+
if (value) {
|
|
603
|
+
metadata[currentList].push(unquoteScalar(value));
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const pair = trimmed.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
608
|
+
if (!pair) {
|
|
609
|
+
currentList = null;
|
|
610
|
+
syntaxErrors.push(`frontmatter line ${lineIndex + 2} must be a key/value pair or list item`);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const key = pair[1] ?? "";
|
|
614
|
+
const value = (pair[2] ?? "").trim();
|
|
615
|
+
if (isKnowledgeScalarKey(key)) {
|
|
616
|
+
metadata[key] = unquoteScalar(value);
|
|
617
|
+
currentList = null;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (key === "updated_at") {
|
|
621
|
+
metadata.updatedAt = unquoteScalar(value);
|
|
622
|
+
currentList = null;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (listKeys.has(key)) {
|
|
626
|
+
const mappedKey = key === "source_paths"
|
|
627
|
+
? "sourcePaths"
|
|
628
|
+
: key === "prd_anchors"
|
|
629
|
+
? "prdAnchors"
|
|
630
|
+
: key;
|
|
631
|
+
metadata[mappedKey] = parseListValue(value);
|
|
632
|
+
currentList = value ? null : mappedKey;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
currentList = null;
|
|
636
|
+
syntaxErrors.push(`frontmatter line ${lineIndex + 2} uses unsupported field: ${key}`);
|
|
637
|
+
}
|
|
638
|
+
return { hasFrontmatter: true, syntaxErrors, metadata };
|
|
639
|
+
}
|
|
640
|
+
function isKnowledgeScalarKey(key) {
|
|
641
|
+
return key === "id"
|
|
642
|
+
|| key === "title"
|
|
643
|
+
|| key === "summary"
|
|
644
|
+
|| key === "type"
|
|
645
|
+
|| key === "kind"
|
|
646
|
+
|| key === "subject"
|
|
647
|
+
|| key === "rule"
|
|
648
|
+
|| key === "workflow"
|
|
649
|
+
|| key === "permission"
|
|
650
|
+
|| key === "term"
|
|
651
|
+
|| key === "note"
|
|
652
|
+
|| key === "status"
|
|
653
|
+
|| key === "stability";
|
|
654
|
+
}
|
|
655
|
+
function validateKnowledgeMetadata(relativePath, metadata) {
|
|
656
|
+
const errors = [];
|
|
657
|
+
if (!metadata.id || !/^[a-z0-9][a-z0-9-]{2,80}$/.test(metadata.id)) {
|
|
658
|
+
errors.push(`${relativePath} must include a kebab-case id.`);
|
|
659
|
+
}
|
|
660
|
+
if (!metadata.title) {
|
|
661
|
+
errors.push(`${relativePath} must include a non-empty title.`);
|
|
662
|
+
}
|
|
663
|
+
if (!metadata.summary) {
|
|
664
|
+
errors.push(`${relativePath} must include a non-empty summary.`);
|
|
665
|
+
}
|
|
666
|
+
if (!metadata.type || !knowledgeTypes.has(metadata.type)) {
|
|
667
|
+
errors.push(`${relativePath} type must be one of: ${[...knowledgeTypes].join(", ")}.`);
|
|
668
|
+
}
|
|
669
|
+
if (metadata.kind) {
|
|
670
|
+
if (!knowledgeKinds.has(metadata.kind)) {
|
|
671
|
+
errors.push(`${relativePath} kind must be one of: ${[...knowledgeKinds].join(", ")}.`);
|
|
672
|
+
}
|
|
673
|
+
if (!metadata.subject) {
|
|
674
|
+
errors.push(`${relativePath} atomic knowledge must include subject.`);
|
|
675
|
+
}
|
|
676
|
+
if (!metadata.rule && !metadata.workflow && !metadata.permission && !metadata.term && !metadata.note) {
|
|
677
|
+
errors.push(`${relativePath} atomic knowledge must include rule, workflow, permission, term, or note.`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (!metadata.status || !knowledgeStatuses.has(metadata.status)) {
|
|
681
|
+
errors.push(`${relativePath} status must be one of: ${[...knowledgeStatuses].join(", ")}.`);
|
|
682
|
+
}
|
|
683
|
+
if (!metadata.stability || !knowledgeStabilities.has(metadata.stability)) {
|
|
684
|
+
errors.push(`${relativePath} stability must be one of: ${[...knowledgeStabilities].join(", ")}.`);
|
|
685
|
+
}
|
|
686
|
+
if (!metadata.updatedAt || !/^\d{4}-\d{2}-\d{2}([T ][0-9:.+-Z]+)?$/.test(metadata.updatedAt)) {
|
|
687
|
+
errors.push(`${relativePath} must include updated_at as YYYY-MM-DD or ISO timestamp.`);
|
|
688
|
+
}
|
|
689
|
+
for (const sourcePath of metadata.sourcePaths) {
|
|
690
|
+
const sourceError = validateKnowledgeSourcePath(sourcePath);
|
|
691
|
+
if (sourceError) {
|
|
692
|
+
errors.push(`${relativePath} source_paths must contain safe project-relative paths or external references: ${sourcePath}.`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return errors;
|
|
696
|
+
}
|
|
697
|
+
function renderKnowledgeFromMetadata(metadata, body) {
|
|
698
|
+
const renderedBody = renderBodyWithTraceability(body, metadata.coverage ?? [], metadata.prdAnchors ?? [], metadata.verification ?? []);
|
|
699
|
+
return `---
|
|
700
|
+
id: ${yamlScalar(metadata.id)}
|
|
701
|
+
type: ${yamlScalar(metadata.type)}
|
|
702
|
+
${renderOptionalScalar("kind", metadata.kind)}title: ${yamlScalar(metadata.title)}
|
|
703
|
+
summary: ${yamlScalar(metadata.summary)}
|
|
704
|
+
${renderOptionalScalar("subject", metadata.subject)}${renderOptionalScalar("rule", metadata.rule)}${renderOptionalScalar("workflow", metadata.workflow)}${renderOptionalScalar("permission", metadata.permission)}${renderOptionalScalar("term", metadata.term)}${renderOptionalScalar("note", metadata.note)}scope:
|
|
705
|
+
${renderList(metadata.scope)}
|
|
706
|
+
tags:
|
|
707
|
+
${renderList(metadata.tags)}
|
|
708
|
+
verification:
|
|
709
|
+
${renderList(metadata.verification)}
|
|
710
|
+
coverage:
|
|
711
|
+
${renderList(metadata.coverage ?? [])}
|
|
712
|
+
prd_anchors:
|
|
713
|
+
${renderList(metadata.prdAnchors ?? [])}
|
|
714
|
+
status: ${yamlScalar(metadata.status)}
|
|
715
|
+
stability: ${yamlScalar(metadata.stability)}
|
|
716
|
+
updated_at: ${yamlScalar(metadata.updatedAt)}
|
|
717
|
+
source_paths:
|
|
718
|
+
${renderList(metadata.sourcePaths)}
|
|
719
|
+
related:
|
|
720
|
+
${renderList(metadata.related)}
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
${renderedBody}
|
|
724
|
+
`;
|
|
725
|
+
}
|
|
726
|
+
function renderBodyWithTraceability(body, coverage, prdAnchors, verification) {
|
|
727
|
+
const sections = [body.trim()];
|
|
728
|
+
if ((coverage.length || prdAnchors.length) && !/^## Traceability\b/m.test(body)) {
|
|
729
|
+
sections.push([
|
|
730
|
+
"## Traceability",
|
|
731
|
+
"",
|
|
732
|
+
...(coverage.length ? coverage.map((item) => `- Coverage: ${item}`) : ["- Coverage: Not specified."]),
|
|
733
|
+
...(prdAnchors.length ? prdAnchors.map((item) => `- Anchor: ${item}`) : [])
|
|
734
|
+
].join("\n"));
|
|
735
|
+
}
|
|
736
|
+
if (verification.length && !/^## Verification\b/m.test(body)) {
|
|
737
|
+
sections.push([
|
|
738
|
+
"## Verification",
|
|
739
|
+
"",
|
|
740
|
+
...verification.map((item) => `- ${item}`)
|
|
741
|
+
].join("\n"));
|
|
742
|
+
}
|
|
743
|
+
return sections.join("\n\n");
|
|
744
|
+
}
|
|
745
|
+
function renderAtomicKnowledge(input) {
|
|
746
|
+
return `---
|
|
747
|
+
id: ${yamlScalar(input.id)}
|
|
748
|
+
type: ${yamlScalar(input.type)}
|
|
749
|
+
kind: ${yamlScalar(input.kind)}
|
|
750
|
+
title: ${yamlScalar(input.title)}
|
|
751
|
+
summary: ${yamlScalar(input.summary)}
|
|
752
|
+
subject: ${yamlScalar(input.subject)}
|
|
753
|
+
${renderOptionalScalar("rule", input.contentFields.rule)}${renderOptionalScalar("workflow", input.contentFields.workflow)}${renderOptionalScalar("permission", input.contentFields.permission)}${renderOptionalScalar("term", input.contentFields.term)}${renderOptionalScalar("note", input.contentFields.note)}scope:
|
|
754
|
+
${renderList(input.scope)}
|
|
755
|
+
tags:
|
|
756
|
+
${renderList(input.tags)}
|
|
757
|
+
verification:
|
|
758
|
+
${renderList(input.verification)}
|
|
759
|
+
coverage:
|
|
760
|
+
${renderList(input.coverage)}
|
|
761
|
+
prd_anchors:
|
|
762
|
+
${renderList(input.prdAnchors)}
|
|
763
|
+
status: ${yamlScalar(input.status)}
|
|
764
|
+
stability: ${yamlScalar(input.stability)}
|
|
765
|
+
updated_at: ${yamlScalar(input.createdAt)}
|
|
766
|
+
source_paths:
|
|
767
|
+
${renderList(input.sourcePaths)}
|
|
768
|
+
related: []
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
# ${input.title}
|
|
772
|
+
|
|
773
|
+
${renderAtomicBody(input.kind, input.subject, input.summary, input.contentFields, input.coverage, input.prdAnchors, input.verification)}
|
|
774
|
+
`;
|
|
775
|
+
}
|
|
776
|
+
function renderOptionalScalar(key, value) {
|
|
777
|
+
return value ? `${key}: ${yamlScalar(value)}\n` : "";
|
|
778
|
+
}
|
|
779
|
+
function renderAtomicBody(kind, subject, summary, contentFields, coverage, prdAnchors, verification) {
|
|
780
|
+
const contentEntries = Object.entries(contentFields).filter(([, value]) => Boolean(value?.trim()));
|
|
781
|
+
const primaryLabel = titleCase(contentKeyForKind(kind));
|
|
782
|
+
const primaryValue = contentFields[contentKeyForKind(kind)] ?? contentEntries[0]?.[1];
|
|
783
|
+
const sections = [
|
|
784
|
+
"# " + titleCase(kind) + ": " + subject,
|
|
785
|
+
"",
|
|
786
|
+
"## Context",
|
|
787
|
+
"",
|
|
788
|
+
summary,
|
|
789
|
+
"",
|
|
790
|
+
"## Durable Knowledge",
|
|
791
|
+
"",
|
|
792
|
+
...(primaryValue ? [`**${primaryLabel}**`, "", primaryValue] : [subject])
|
|
793
|
+
];
|
|
794
|
+
const note = contentFields.note;
|
|
795
|
+
if (note && contentKeyForKind(kind) !== "note") {
|
|
796
|
+
sections.push("", "## Notes", "", note);
|
|
797
|
+
}
|
|
798
|
+
sections.push("", "## Traceability", "", ...(coverage.length ? coverage.map((item) => `- Coverage: ${item}`) : ["- Coverage: Not specified."]), ...(prdAnchors.length ? prdAnchors.map((item) => `- Anchor: ${item}`) : []));
|
|
799
|
+
sections.push("", "## Verification", "", ...(verification.length ? verification.map((item) => `- ${item}`) : ["- Not specified."]));
|
|
800
|
+
return [
|
|
801
|
+
...sections
|
|
802
|
+
].join("\n");
|
|
803
|
+
}
|
|
804
|
+
function renderKnowledge(input) {
|
|
805
|
+
const summary = firstBodyLine(input.body) ?? input.title;
|
|
806
|
+
return `---
|
|
807
|
+
id: ${yamlScalar(input.id)}
|
|
808
|
+
type: ${yamlScalar(input.type)}
|
|
809
|
+
title: ${yamlScalar(input.title)}
|
|
810
|
+
summary: ${yamlScalar(summary)}
|
|
811
|
+
scope:
|
|
812
|
+
${renderList(input.scope)}
|
|
813
|
+
tags:
|
|
814
|
+
${renderList(input.tags)}
|
|
815
|
+
status: ${yamlScalar(input.status)}
|
|
816
|
+
stability: ${yamlScalar(input.stability)}
|
|
817
|
+
updated_at: ${yamlScalar(input.createdAt)}
|
|
818
|
+
source_paths:
|
|
819
|
+
${renderList(input.sourcePaths)}
|
|
820
|
+
related: []
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
# ${input.title}
|
|
824
|
+
|
|
825
|
+
${input.body}
|
|
826
|
+
`;
|
|
827
|
+
}
|
|
828
|
+
function renderList(items) {
|
|
829
|
+
return items.length ? items.map((item) => ` - ${yamlScalar(item)}`).join("\n") : " []";
|
|
830
|
+
}
|
|
831
|
+
function firstBodyLine(body) {
|
|
832
|
+
return body
|
|
833
|
+
.split("\n")
|
|
834
|
+
.map((line) => line.trim())
|
|
835
|
+
.find((line) => line && !line.startsWith("#") && !line.startsWith("- ")) ?? null;
|
|
836
|
+
}
|
|
837
|
+
function parseKnowledgeType(value, label) {
|
|
838
|
+
if (knowledgeTypes.has(value)) {
|
|
839
|
+
return value;
|
|
840
|
+
}
|
|
841
|
+
throw new Error(`${label} must be one of: ${[...knowledgeTypes].join(", ")}`);
|
|
842
|
+
}
|
|
843
|
+
function parseKnowledgeStatus(value, label) {
|
|
844
|
+
if (knowledgeStatuses.has(value)) {
|
|
845
|
+
return value;
|
|
846
|
+
}
|
|
847
|
+
throw new Error(`${label} must be one of: ${[...knowledgeStatuses].join(", ")}`);
|
|
848
|
+
}
|
|
849
|
+
function parseKnowledgeStability(value, label) {
|
|
850
|
+
if (knowledgeStabilities.has(value)) {
|
|
851
|
+
return value;
|
|
852
|
+
}
|
|
853
|
+
throw new Error(`${label} must be one of: ${[...knowledgeStabilities].join(", ")}`);
|
|
854
|
+
}
|
|
855
|
+
function parseKnowledgeKind(value, label) {
|
|
856
|
+
if (knowledgeKinds.has(value)) {
|
|
857
|
+
return value;
|
|
858
|
+
}
|
|
859
|
+
throw new Error(`${label} must be one of: ${[...knowledgeKinds].join(", ")}`);
|
|
860
|
+
}
|
|
861
|
+
function atomicContent(kind, input) {
|
|
862
|
+
return atomicContentFields(kind, input)[contentKeyForKind(kind)] ?? input.note?.trim() ?? null;
|
|
863
|
+
}
|
|
864
|
+
function atomicContentFields(kind, input) {
|
|
865
|
+
const fields = {};
|
|
866
|
+
const preferredKey = contentKeyForKind(kind);
|
|
867
|
+
const preferredValue = input[preferredKey]?.trim();
|
|
868
|
+
if (preferredValue) {
|
|
869
|
+
fields[preferredKey] = preferredValue;
|
|
870
|
+
}
|
|
871
|
+
const note = input.note?.trim();
|
|
872
|
+
if (note && preferredKey !== "note") {
|
|
873
|
+
fields.note = note;
|
|
874
|
+
}
|
|
875
|
+
return fields;
|
|
876
|
+
}
|
|
877
|
+
function summarizeAtomicContent(kind, subject, contentFields) {
|
|
878
|
+
const primary = contentFields[contentKeyForKind(kind)] ?? contentFields.note ?? subject;
|
|
879
|
+
const concise = primary.length > 140 ? `${primary.slice(0, 137)}...` : primary;
|
|
880
|
+
return `${subject}: ${concise}`;
|
|
881
|
+
}
|
|
882
|
+
function contentKeyForKind(kind) {
|
|
883
|
+
if (kind === "workflow") {
|
|
884
|
+
return "workflow";
|
|
885
|
+
}
|
|
886
|
+
if (kind === "permission") {
|
|
887
|
+
return "permission";
|
|
888
|
+
}
|
|
889
|
+
if (kind === "term") {
|
|
890
|
+
return "term";
|
|
891
|
+
}
|
|
892
|
+
if (kind === "pitfall" || kind === "decision" || kind === "convention") {
|
|
893
|
+
return "note";
|
|
894
|
+
}
|
|
895
|
+
return "rule";
|
|
896
|
+
}
|
|
897
|
+
function primaryAtomicValue(metadata) {
|
|
898
|
+
const kind = metadata.kind;
|
|
899
|
+
if (!kind || !knowledgeKinds.has(kind)) {
|
|
900
|
+
return undefined;
|
|
901
|
+
}
|
|
902
|
+
return metadata[contentKeyForKind(kind)] ?? metadata.note;
|
|
903
|
+
}
|
|
904
|
+
function contentFlagForKind(kind) {
|
|
905
|
+
return contentKeyForKind(kind);
|
|
906
|
+
}
|
|
907
|
+
function splitList(value) {
|
|
908
|
+
return value
|
|
909
|
+
? value.split(",").map((item) => item.trim()).filter(Boolean)
|
|
910
|
+
: [];
|
|
911
|
+
}
|
|
912
|
+
function parseKnowledgeSourcePaths(value, label) {
|
|
913
|
+
const sourcePaths = splitList(value);
|
|
914
|
+
const invalid = sourcePaths.find(validateKnowledgeSourcePath);
|
|
915
|
+
if (invalid) {
|
|
916
|
+
throw new Error(`${label} must contain safe project-relative paths or external references: ${invalid}`);
|
|
917
|
+
}
|
|
918
|
+
return sourcePaths;
|
|
919
|
+
}
|
|
920
|
+
function mergeKnowledgeSourcePaths(existing, next, label) {
|
|
921
|
+
if (next === undefined) {
|
|
922
|
+
return existing;
|
|
923
|
+
}
|
|
924
|
+
const merged = [...new Set([...existing, ...splitList(next)])];
|
|
925
|
+
const invalid = merged.find(validateKnowledgeSourcePath);
|
|
926
|
+
if (invalid) {
|
|
927
|
+
throw new Error(`${label} must contain safe project-relative paths or external references: ${invalid}`);
|
|
928
|
+
}
|
|
929
|
+
return merged;
|
|
930
|
+
}
|
|
931
|
+
function validateKnowledgeSourcePath(value) {
|
|
932
|
+
if (!value.trim()) {
|
|
933
|
+
return "empty";
|
|
934
|
+
}
|
|
935
|
+
if (isExternalReference(value)) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
normalizeProjectRelativePath(value, "knowledge source path");
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return "unsafe";
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function isKnowledgeSourceFile(projectRoot, sourcePath) {
|
|
947
|
+
return isRegularFileWithoutSymlink(path.join(projectRoot, sourcePath));
|
|
948
|
+
}
|
|
949
|
+
function mergeList(existing, next) {
|
|
950
|
+
if (next === undefined) {
|
|
951
|
+
return existing;
|
|
952
|
+
}
|
|
953
|
+
return [...new Set([...existing, ...splitList(next)])];
|
|
954
|
+
}
|
|
955
|
+
function parseListValue(value) {
|
|
956
|
+
if (!value || value === "[]") {
|
|
957
|
+
return [];
|
|
958
|
+
}
|
|
959
|
+
const inline = value.match(/^\[(.*)\]$/);
|
|
960
|
+
if (!inline) {
|
|
961
|
+
return [unquoteScalar(value)];
|
|
962
|
+
}
|
|
963
|
+
return splitInlineListItems(inline[1] ?? "")
|
|
964
|
+
.map((item) => unquoteScalar(item.trim()))
|
|
965
|
+
.filter(Boolean);
|
|
966
|
+
}
|
|
967
|
+
function splitInlineListItems(value) {
|
|
968
|
+
const items = [];
|
|
969
|
+
let current = "";
|
|
970
|
+
let quote = null;
|
|
971
|
+
let escaped = false;
|
|
972
|
+
for (const char of value) {
|
|
973
|
+
if (escaped) {
|
|
974
|
+
current += char;
|
|
975
|
+
escaped = false;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (char === "\\" && quote === "\"") {
|
|
979
|
+
current += char;
|
|
980
|
+
escaped = true;
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (quote) {
|
|
984
|
+
current += char;
|
|
985
|
+
if (char === quote) {
|
|
986
|
+
quote = null;
|
|
987
|
+
}
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (char === "\"" || char === "'") {
|
|
991
|
+
current += char;
|
|
992
|
+
quote = char;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (char === ",") {
|
|
996
|
+
items.push(current);
|
|
997
|
+
current = "";
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
current += char;
|
|
1001
|
+
}
|
|
1002
|
+
items.push(current);
|
|
1003
|
+
return items;
|
|
1004
|
+
}
|
|
1005
|
+
function unquoteScalar(value) {
|
|
1006
|
+
const trimmed = value.trim();
|
|
1007
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
|
1008
|
+
try {
|
|
1009
|
+
const parsed = JSON.parse(trimmed);
|
|
1010
|
+
return typeof parsed === "string" ? parsed : trimmed.slice(1, -1);
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
return trimmed.slice(1, -1);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
1017
|
+
return trimmed.slice(1, -1).replace(/''/g, "'");
|
|
1018
|
+
}
|
|
1019
|
+
return trimmed;
|
|
1020
|
+
}
|
|
1021
|
+
function slugify(value) {
|
|
1022
|
+
return value
|
|
1023
|
+
.toLowerCase()
|
|
1024
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1025
|
+
.replace(/^-+|-+$/g, "")
|
|
1026
|
+
.slice(0, 80);
|
|
1027
|
+
}
|
|
1028
|
+
function titleCase(value) {
|
|
1029
|
+
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
|
1030
|
+
}
|
|
1031
|
+
function formatKnowledgeStamp(createdAt) {
|
|
1032
|
+
return createdAt
|
|
1033
|
+
.replace(/[-:]/g, "")
|
|
1034
|
+
.replace(/\.(\d{3})Z$/, "$1Z");
|
|
1035
|
+
}
|
|
1036
|
+
function yamlScalar(value) {
|
|
1037
|
+
return JSON.stringify(value);
|
|
1038
|
+
}
|
|
1039
|
+
function safeRead(filePath) {
|
|
1040
|
+
if (!isRegularFileWithoutSymlink(filePath)) {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
const stat = fs.statSync(filePath);
|
|
1045
|
+
if (stat.size > MAX_KNOWLEDGE_MARKDOWN_BYTES) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
return fs.readFileSync(filePath, "utf8");
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function isReadableDirectory(directory) {
|
|
1055
|
+
return isDirectoryWithoutSymlink(directory);
|
|
1056
|
+
}
|
|
1057
|
+
function listMarkdownFiles(directory, errors, depth = 0, state = { count: 0, stopped: false }) {
|
|
1058
|
+
if (state.stopped) {
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
if (depth > MAX_KNOWLEDGE_DIRECTORY_DEPTH) {
|
|
1062
|
+
errors?.push(`Knowledge markdown scan exceeded maximum directory depth at ${displayScanPath(directory)}.`);
|
|
1063
|
+
return [];
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
1067
|
+
const fullPath = path.join(directory, entry.name);
|
|
1068
|
+
if (entry.isSymbolicLink()) {
|
|
1069
|
+
return [];
|
|
1070
|
+
}
|
|
1071
|
+
if (entry.isDirectory()) {
|
|
1072
|
+
if (entry.name.startsWith(".")) {
|
|
1073
|
+
return [];
|
|
1074
|
+
}
|
|
1075
|
+
return listMarkdownFiles(fullPath, errors, depth + 1, state);
|
|
1076
|
+
}
|
|
1077
|
+
if (!entry.name.endsWith(".md") || !isRegularFileWithoutSymlink(fullPath)) {
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
state.count += 1;
|
|
1081
|
+
if (state.count > MAX_KNOWLEDGE_MARKDOWN_FILES) {
|
|
1082
|
+
if (!state.stopped) {
|
|
1083
|
+
errors?.push(`Knowledge markdown scan exceeded ${MAX_KNOWLEDGE_MARKDOWN_FILES} files.`);
|
|
1084
|
+
}
|
|
1085
|
+
state.stopped = true;
|
|
1086
|
+
return [];
|
|
1087
|
+
}
|
|
1088
|
+
const stat = fs.statSync(fullPath, { throwIfNoEntry: false });
|
|
1089
|
+
if (stat && stat.size > MAX_KNOWLEDGE_MARKDOWN_BYTES) {
|
|
1090
|
+
errors?.push(`${displayScanPath(fullPath)} exceeds ${MAX_KNOWLEDGE_MARKDOWN_BYTES} bytes.`);
|
|
1091
|
+
return [];
|
|
1092
|
+
}
|
|
1093
|
+
return [fullPath];
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (errors) {
|
|
1098
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1099
|
+
errors.push(`Cannot read .frontend-harness/knowledge: ${message}`);
|
|
1100
|
+
}
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function displayScanPath(filePath) {
|
|
1105
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
1106
|
+
if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
|
|
1107
|
+
return relativePath.split(path.sep).join(path.posix.sep);
|
|
1108
|
+
}
|
|
1109
|
+
return filePath;
|
|
1110
|
+
}
|
|
1111
|
+
function normalize(value) {
|
|
1112
|
+
return value.toLowerCase().replace(/[^\p{L}\p{N}-]+/gu, " ").trim();
|
|
1113
|
+
}
|
|
1114
|
+
function scoreCardSearch(card, normalizedQuery) {
|
|
1115
|
+
const queryTokens = tokens(normalizedQuery);
|
|
1116
|
+
if (!queryTokens.length) {
|
|
1117
|
+
return card.searchable.includes(normalizedQuery) ? 1 : 0;
|
|
1118
|
+
}
|
|
1119
|
+
const searchableTokens = new Set(tokens(card.searchable));
|
|
1120
|
+
let score = 0;
|
|
1121
|
+
for (const token of queryTokens) {
|
|
1122
|
+
if (searchableTokens.has(token)) {
|
|
1123
|
+
score += 2;
|
|
1124
|
+
}
|
|
1125
|
+
else if (card.searchable.includes(token)) {
|
|
1126
|
+
score += 1;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (card.id === normalizedQuery || normalize(card.title) === normalizedQuery) {
|
|
1130
|
+
score += 4;
|
|
1131
|
+
}
|
|
1132
|
+
return score;
|
|
1133
|
+
}
|
|
1134
|
+
function tokens(value) {
|
|
1135
|
+
return value.split(/\s+/).filter((token) => token.length >= 2);
|
|
1136
|
+
}
|
|
1137
|
+
function extractMarkdownBody(content) {
|
|
1138
|
+
const endIndex = content.search(/\r?\n---(?:\r?\n|$)/);
|
|
1139
|
+
if (!content.startsWith("---") || endIndex < 0) {
|
|
1140
|
+
return content.trim();
|
|
1141
|
+
}
|
|
1142
|
+
return content.slice(endIndex).replace(/^\r?\n---\r?\n?/, "").trim();
|
|
1143
|
+
}
|
|
1144
|
+
function isExternalReference(value) {
|
|
1145
|
+
if (/^[A-Za-z]:/.test(value)) {
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(value);
|
|
1149
|
+
}
|
|
1150
|
+
function isBroadCoverageItem(value) {
|
|
1151
|
+
const normalized = value.trim().toLowerCase();
|
|
1152
|
+
if (!normalized) {
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
if (/\bsections?\s+\d+(?:\.\d+)?\s*[-–—~]\s*\d+(?:\.\d+)?\b/.test(normalized)) {
|
|
1156
|
+
return true;
|
|
1157
|
+
}
|
|
1158
|
+
if (/(?:^|[#\s/])\d+(?:\.\d+)?\s*[-–—~至到]\s*\d+(?:\.\d+)?(?:\D|$)/.test(normalized)) {
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
return /\b(chapter|section)\b.*\b(full|entire|whole|all)\b/.test(normalized);
|
|
1162
|
+
}
|
|
1163
|
+
function hasCoverageBodyDrift(projectRoot, card) {
|
|
1164
|
+
if (card.coverage.length === 0) {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
const content = safeRead(path.join(projectRoot, card.path));
|
|
1168
|
+
return content !== null && /\bCoverage:\s*Not specified\./i.test(extractMarkdownBody(content));
|
|
1169
|
+
}
|
|
1170
|
+
function summarizePrdSources(projectRoot, cards, requiredSources = []) {
|
|
1171
|
+
const grouped = new Map();
|
|
1172
|
+
for (const sourcePath of discoverPrdSourceFiles(projectRoot)) {
|
|
1173
|
+
grouped.set(sourcePath, []);
|
|
1174
|
+
}
|
|
1175
|
+
for (const sourcePath of requiredSources.filter(isPrdSourcePath)) {
|
|
1176
|
+
grouped.set(sourcePath, grouped.get(sourcePath) ?? []);
|
|
1177
|
+
}
|
|
1178
|
+
for (const card of cards) {
|
|
1179
|
+
for (const sourcePath of card.sourcePaths.filter(isPrdSourcePath)) {
|
|
1180
|
+
grouped.set(sourcePath, [...(grouped.get(sourcePath) ?? []), card]);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return [...grouped.entries()]
|
|
1184
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1185
|
+
.map(([sourcePath, sourceCards]) => ({
|
|
1186
|
+
path: sourcePath,
|
|
1187
|
+
cardCount: sourceCards.length,
|
|
1188
|
+
activeCardCount: sourceCards.filter((card) => card.status === "active").length,
|
|
1189
|
+
cardIds: sourceCards.map((card) => card.id).sort(),
|
|
1190
|
+
scopes: [...new Set(sourceCards.flatMap((card) => card.scope))].sort(),
|
|
1191
|
+
tags: [...new Set(sourceCards.flatMap((card) => card.tags))].sort(),
|
|
1192
|
+
coverage: [...new Set(sourceCards.flatMap((card) => card.coverage))].sort(),
|
|
1193
|
+
prdAnchors: [...new Set(sourceCards.flatMap((card) => card.prdAnchors))].sort()
|
|
1194
|
+
}));
|
|
1195
|
+
}
|
|
1196
|
+
function summarizeModules(cards) {
|
|
1197
|
+
const atomicCards = cards.filter((card) => card.kind && card.status === "active");
|
|
1198
|
+
return cards
|
|
1199
|
+
.filter((card) => card.type === "module_summary" && card.status === "active")
|
|
1200
|
+
.map((moduleCard) => {
|
|
1201
|
+
const moduleSources = new Set(moduleCard.sourcePaths);
|
|
1202
|
+
const relatedIds = new Set(moduleCard.related);
|
|
1203
|
+
const relatedAtomicCards = atomicCards.filter((card) => relatedIds.has(card.id) ||
|
|
1204
|
+
card.related.includes(moduleCard.id) ||
|
|
1205
|
+
card.sourcePaths.some((sourcePath) => moduleSources.has(sourcePath)) ||
|
|
1206
|
+
card.scope.some((scope) => moduleCard.scope.includes(scope)));
|
|
1207
|
+
return {
|
|
1208
|
+
id: moduleCard.id,
|
|
1209
|
+
path: moduleCard.path,
|
|
1210
|
+
title: moduleCard.title,
|
|
1211
|
+
summary: moduleCard.summary,
|
|
1212
|
+
sourcePaths: moduleCard.sourcePaths,
|
|
1213
|
+
scopes: moduleCard.scope,
|
|
1214
|
+
tags: moduleCard.tags,
|
|
1215
|
+
related: moduleCard.related,
|
|
1216
|
+
atomicCardCount: relatedAtomicCards.length,
|
|
1217
|
+
atomicCardIds: relatedAtomicCards.map((card) => card.id).sort()
|
|
1218
|
+
};
|
|
1219
|
+
})
|
|
1220
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
1221
|
+
}
|
|
1222
|
+
function discoverPrdSourceFiles(projectRoot) {
|
|
1223
|
+
const roots = ["docs/prd", "docs/requirements", "prd", "requirements"];
|
|
1224
|
+
const rootSources = roots.flatMap((root) => {
|
|
1225
|
+
const fullPath = path.join(projectRoot, root);
|
|
1226
|
+
return isReadableDirectory(fullPath)
|
|
1227
|
+
? listMarkdownFiles(fullPath).map((filePath) => path.relative(projectRoot, filePath).replace(/\\/g, "/"))
|
|
1228
|
+
: [];
|
|
1229
|
+
});
|
|
1230
|
+
const docsRoot = path.join(projectRoot, "docs");
|
|
1231
|
+
const docsNamedSources = isReadableDirectory(docsRoot)
|
|
1232
|
+
? listMarkdownFiles(docsRoot)
|
|
1233
|
+
.map((filePath) => path.relative(projectRoot, filePath).replace(/\\/g, "/"))
|
|
1234
|
+
.filter(isPrdSourcePath)
|
|
1235
|
+
: [];
|
|
1236
|
+
return [...new Set([...rootSources, ...docsNamedSources])].sort();
|
|
1237
|
+
}
|
|
1238
|
+
function isPrdSourcePath(sourcePath) {
|
|
1239
|
+
const normalized = sourcePath.replace(/\\/g, "/");
|
|
1240
|
+
if (/(^|\/)(prd|requirements?)(\/|[-_.])|(^|\/)docs\/(prd|requirements?)(\/|$)/i.test(normalized)) {
|
|
1241
|
+
return true;
|
|
1242
|
+
}
|
|
1243
|
+
return /(^|\/)docs\/[^/]*(?:prd|requirement|需求)[^/]*\.md$/i.test(normalized);
|
|
1244
|
+
}
|
|
1245
|
+
function isPrdTraceableCard(card) {
|
|
1246
|
+
return card.type === "prd_semantics" || card.type === "module_summary" || card.type === "pitfall" || card.type === "glossary";
|
|
1247
|
+
}
|
|
1248
|
+
function countByType(cards) {
|
|
1249
|
+
const counts = Object.fromEntries([...knowledgeTypes].map((type) => [type, 0]));
|
|
1250
|
+
for (const card of cards) {
|
|
1251
|
+
counts[card.type] += 1;
|
|
1252
|
+
}
|
|
1253
|
+
return counts;
|
|
1254
|
+
}
|
|
1255
|
+
function countByKind(cards) {
|
|
1256
|
+
const counts = {};
|
|
1257
|
+
for (const card of cards) {
|
|
1258
|
+
if (card.kind) {
|
|
1259
|
+
counts[card.kind] = (counts[card.kind] ?? 0) + 1;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return counts;
|
|
1263
|
+
}
|
|
1264
|
+
//# sourceMappingURL=core.js.map
|