@mainahq/core 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ai/__tests__/delegation.test.ts +55 -1
- package/src/ai/delegation.ts +5 -3
- package/src/context/__tests__/budget.test.ts +29 -6
- package/src/context/__tests__/engine.test.ts +1 -0
- package/src/context/__tests__/selector.test.ts +23 -3
- package/src/context/__tests__/wiki.test.ts +349 -0
- package/src/context/budget.ts +12 -8
- package/src/context/engine.ts +37 -0
- package/src/context/selector.ts +30 -4
- package/src/context/wiki.ts +296 -0
- package/src/db/index.ts +12 -0
- package/src/feedback/__tests__/capture.test.ts +166 -0
- package/src/feedback/__tests__/signals.test.ts +144 -0
- package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
- package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
- package/src/feedback/capture.ts +102 -0
- package/src/feedback/signals.ts +68 -0
- package/src/index.ts +104 -0
- package/src/init/__tests__/init.test.ts +400 -3
- package/src/init/index.ts +368 -12
- package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
- package/src/prompts/defaults/index.ts +3 -1
- package/src/prompts/defaults/wiki-compile.md +20 -0
- package/src/prompts/defaults/wiki-query.md +18 -0
- package/src/stats/__tests__/tool-usage.test.ts +133 -0
- package/src/stats/tracker.ts +92 -0
- package/src/verify/__tests__/pipeline.test.ts +11 -8
- package/src/verify/pipeline.ts +13 -1
- package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
- package/src/verify/tools/wiki-lint-runner.ts +38 -0
- package/src/verify/tools/wiki-lint.ts +898 -0
- package/src/wiki/__tests__/compiler.test.ts +389 -0
- package/src/wiki/__tests__/extractors/code.test.ts +99 -0
- package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
- package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
- package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
- package/src/wiki/__tests__/graph.test.ts +344 -0
- package/src/wiki/__tests__/hooks.test.ts +119 -0
- package/src/wiki/__tests__/indexer.test.ts +285 -0
- package/src/wiki/__tests__/linker.test.ts +230 -0
- package/src/wiki/__tests__/louvain.test.ts +229 -0
- package/src/wiki/__tests__/query.test.ts +316 -0
- package/src/wiki/__tests__/schema.test.ts +114 -0
- package/src/wiki/__tests__/signals.test.ts +474 -0
- package/src/wiki/__tests__/state.test.ts +168 -0
- package/src/wiki/__tests__/tracking.test.ts +118 -0
- package/src/wiki/__tests__/types.test.ts +387 -0
- package/src/wiki/compiler.ts +1075 -0
- package/src/wiki/extractors/code.ts +90 -0
- package/src/wiki/extractors/decision.ts +217 -0
- package/src/wiki/extractors/feature.ts +206 -0
- package/src/wiki/extractors/workflow.ts +112 -0
- package/src/wiki/graph.ts +445 -0
- package/src/wiki/hooks.ts +49 -0
- package/src/wiki/indexer.ts +105 -0
- package/src/wiki/linker.ts +117 -0
- package/src/wiki/louvain.ts +190 -0
- package/src/wiki/prompts/compile-architecture.md +59 -0
- package/src/wiki/prompts/compile-decision.md +66 -0
- package/src/wiki/prompts/compile-entity.md +56 -0
- package/src/wiki/prompts/compile-feature.md +60 -0
- package/src/wiki/prompts/compile-module.md +42 -0
- package/src/wiki/prompts/wiki-query.md +25 -0
- package/src/wiki/query.ts +338 -0
- package/src/wiki/schema.ts +111 -0
- package/src/wiki/signals.ts +368 -0
- package/src/wiki/state.ts +89 -0
- package/src/wiki/tracking.ts +30 -0
- package/src/wiki/types.ts +169 -0
- package/src/workflow/context.ts +26 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Query — AI-powered question answering over wiki articles.
|
|
3
|
+
*
|
|
4
|
+
* Loads relevant wiki articles, scores them by keyword relevance,
|
|
5
|
+
* then uses AI to synthesize a coherent answer citing sources.
|
|
6
|
+
* Falls back to keyword excerpts when AI is unavailable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { Result } from "../db/index";
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface WikiQueryResult {
|
|
16
|
+
answer: string;
|
|
17
|
+
sources: string[];
|
|
18
|
+
cached: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WikiQueryOptions {
|
|
22
|
+
wikiDir: string;
|
|
23
|
+
question: string;
|
|
24
|
+
maxArticles?: number;
|
|
25
|
+
repoRoot?: string;
|
|
26
|
+
/** Optional override for AI generation (used in tests). */
|
|
27
|
+
_aiGenerate?: (
|
|
28
|
+
task: string,
|
|
29
|
+
mainaDir: string,
|
|
30
|
+
variables: Record<string, string>,
|
|
31
|
+
userPrompt: string,
|
|
32
|
+
) => Promise<{ text: string | null; fromAI: boolean }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LoadedArticle {
|
|
36
|
+
path: string;
|
|
37
|
+
content: string;
|
|
38
|
+
title: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ScoredArticle extends LoadedArticle {
|
|
42
|
+
score: number;
|
|
43
|
+
excerpt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Constants ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const DEFAULT_MAX_ARTICLES = 10;
|
|
49
|
+
|
|
50
|
+
const ARTICLE_SUBDIRS = [
|
|
51
|
+
"modules",
|
|
52
|
+
"entities",
|
|
53
|
+
"features",
|
|
54
|
+
"decisions",
|
|
55
|
+
"architecture",
|
|
56
|
+
"raw",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Tokenize text into lowercase words, removing punctuation and short words.
|
|
63
|
+
*/
|
|
64
|
+
function tokenize(text: string): string[] {
|
|
65
|
+
return text
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.filter((w) => w.length > 2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract the first heading from markdown content.
|
|
74
|
+
*/
|
|
75
|
+
function extractTitle(content: string): string {
|
|
76
|
+
const firstLine = content.split("\n")[0] ?? "";
|
|
77
|
+
return firstLine.replace(/^#+\s*/, "").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract the most relevant excerpt from content given query tokens.
|
|
82
|
+
*/
|
|
83
|
+
function extractExcerpt(
|
|
84
|
+
content: string,
|
|
85
|
+
queryTokens: string[],
|
|
86
|
+
maxLength = 200,
|
|
87
|
+
): string {
|
|
88
|
+
const paragraphs = content.split(/\n\n+/).filter((p) => p.trim().length > 0);
|
|
89
|
+
|
|
90
|
+
let bestParagraph = "";
|
|
91
|
+
let bestScore = -1;
|
|
92
|
+
|
|
93
|
+
for (const paragraph of paragraphs) {
|
|
94
|
+
if (paragraph.trim().startsWith("#") && !paragraph.includes("\n")) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const words = tokenize(paragraph);
|
|
99
|
+
const matchCount = queryTokens.filter((qt) =>
|
|
100
|
+
words.some((w) => w.includes(qt)),
|
|
101
|
+
).length;
|
|
102
|
+
|
|
103
|
+
if (matchCount > bestScore) {
|
|
104
|
+
bestScore = matchCount;
|
|
105
|
+
bestParagraph = paragraph;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cleaned = bestParagraph.replace(/\n/g, " ").trim();
|
|
110
|
+
if (cleaned.length > maxLength) {
|
|
111
|
+
return `${cleaned.slice(0, maxLength)}...`;
|
|
112
|
+
}
|
|
113
|
+
return cleaned;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load all markdown articles from wiki subdirectories.
|
|
118
|
+
*/
|
|
119
|
+
function loadArticles(wikiDir: string): LoadedArticle[] {
|
|
120
|
+
const articles: LoadedArticle[] = [];
|
|
121
|
+
|
|
122
|
+
for (const subdir of ARTICLE_SUBDIRS) {
|
|
123
|
+
const dir = join(wikiDir, subdir);
|
|
124
|
+
if (!existsSync(dir)) continue;
|
|
125
|
+
|
|
126
|
+
let entries: string[];
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(dir);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.endsWith(".md")) continue;
|
|
135
|
+
const fullPath = join(dir, entry);
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
138
|
+
const title = extractTitle(content);
|
|
139
|
+
articles.push({ path: `${subdir}/${entry}`, content, title });
|
|
140
|
+
} catch {
|
|
141
|
+
// skip unreadable files
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return articles;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Score articles by keyword relevance to the question.
|
|
151
|
+
*/
|
|
152
|
+
function scoreArticles(
|
|
153
|
+
articles: LoadedArticle[],
|
|
154
|
+
question: string,
|
|
155
|
+
): ScoredArticle[] {
|
|
156
|
+
const queryTokens = tokenize(question);
|
|
157
|
+
if (queryTokens.length === 0) return [];
|
|
158
|
+
|
|
159
|
+
const scored: ScoredArticle[] = [];
|
|
160
|
+
|
|
161
|
+
for (const article of articles) {
|
|
162
|
+
const contentTokens = tokenize(article.content);
|
|
163
|
+
const titleTokens = tokenize(article.title);
|
|
164
|
+
|
|
165
|
+
let matchCount = 0;
|
|
166
|
+
for (const qt of queryTokens) {
|
|
167
|
+
if (contentTokens.some((ct) => ct.includes(qt))) {
|
|
168
|
+
matchCount++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bonus for title matches
|
|
173
|
+
for (const qt of queryTokens) {
|
|
174
|
+
if (titleTokens.some((tt) => tt.includes(qt))) {
|
|
175
|
+
matchCount += 2;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (matchCount > 0) {
|
|
180
|
+
const score = matchCount / queryTokens.length;
|
|
181
|
+
const excerpt = extractExcerpt(article.content, queryTokens);
|
|
182
|
+
scored.push({ ...article, score, excerpt });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
scored.sort((a, b) => b.score - a.score);
|
|
187
|
+
return scored;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Format article contents for the AI prompt context window.
|
|
192
|
+
*/
|
|
193
|
+
function formatArticlesForPrompt(articles: ScoredArticle[]): string {
|
|
194
|
+
return articles.map((a) => `## ${a.path}\n${a.content}\n---`).join("\n\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a fallback answer from keyword-scored articles (no AI).
|
|
199
|
+
*/
|
|
200
|
+
function buildFallbackAnswer(
|
|
201
|
+
scored: ScoredArticle[],
|
|
202
|
+
maxArticles: number,
|
|
203
|
+
): WikiQueryResult {
|
|
204
|
+
const topResults = scored.slice(0, maxArticles);
|
|
205
|
+
|
|
206
|
+
if (topResults.length === 0) {
|
|
207
|
+
return {
|
|
208
|
+
answer: "No relevant articles found.",
|
|
209
|
+
sources: [],
|
|
210
|
+
cached: false,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parts: string[] = [];
|
|
215
|
+
parts.push(
|
|
216
|
+
`Found ${scored.length} relevant article(s) (keyword match, AI unavailable):\n`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < topResults.length; i++) {
|
|
220
|
+
const result = topResults[i];
|
|
221
|
+
if (!result) continue;
|
|
222
|
+
parts.push(
|
|
223
|
+
`${i + 1}. **${result.title}** (\`${result.path}\`, score: ${result.score.toFixed(2)})`,
|
|
224
|
+
);
|
|
225
|
+
if (result.excerpt) {
|
|
226
|
+
parts.push(` ${result.excerpt}`);
|
|
227
|
+
}
|
|
228
|
+
parts.push("");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
answer: parts.join("\n"),
|
|
233
|
+
sources: topResults.map((r) => r.path),
|
|
234
|
+
cached: false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Main ────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Query the wiki with a natural-language question.
|
|
242
|
+
*
|
|
243
|
+
* 1. Loads all wiki articles
|
|
244
|
+
* 2. Scores by keyword relevance
|
|
245
|
+
* 3. Takes top N articles as context
|
|
246
|
+
* 4. Calls AI to synthesize an answer citing sources
|
|
247
|
+
* 5. Falls back to keyword excerpts when AI is unavailable
|
|
248
|
+
*/
|
|
249
|
+
export async function queryWiki(
|
|
250
|
+
options: WikiQueryOptions,
|
|
251
|
+
): Promise<Result<WikiQueryResult>> {
|
|
252
|
+
const { wikiDir, question, maxArticles = DEFAULT_MAX_ARTICLES } = options;
|
|
253
|
+
|
|
254
|
+
// Check wiki exists
|
|
255
|
+
if (!existsSync(wikiDir)) {
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
value: {
|
|
259
|
+
answer: "Wiki not initialized. Run `maina wiki init` first.",
|
|
260
|
+
sources: [],
|
|
261
|
+
cached: false,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Load articles
|
|
267
|
+
const articles = loadArticles(wikiDir);
|
|
268
|
+
if (articles.length === 0) {
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
value: {
|
|
272
|
+
answer: "Wiki is empty. Run `maina wiki compile` to generate articles.",
|
|
273
|
+
sources: [],
|
|
274
|
+
cached: false,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Score and rank
|
|
280
|
+
const scored = scoreArticles(articles, question);
|
|
281
|
+
if (scored.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
value: {
|
|
285
|
+
answer: `No articles match the query: "${question}"`,
|
|
286
|
+
sources: [],
|
|
287
|
+
cached: false,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const topArticles = scored.slice(0, maxArticles);
|
|
293
|
+
const sources = topArticles.map((a) => a.path);
|
|
294
|
+
|
|
295
|
+
// Try AI synthesis
|
|
296
|
+
try {
|
|
297
|
+
const aiGenerate =
|
|
298
|
+
options._aiGenerate ?? (await import("../ai/try-generate")).tryAIGenerate;
|
|
299
|
+
const mainaDir = options.repoRoot
|
|
300
|
+
? join(options.repoRoot, ".maina")
|
|
301
|
+
: join(wikiDir, "..");
|
|
302
|
+
|
|
303
|
+
const articlesContext = formatArticlesForPrompt(topArticles);
|
|
304
|
+
const userPrompt = [
|
|
305
|
+
`Question: ${question}`,
|
|
306
|
+
"",
|
|
307
|
+
"Here are relevant wiki articles:",
|
|
308
|
+
"",
|
|
309
|
+
articlesContext,
|
|
310
|
+
].join("\n");
|
|
311
|
+
|
|
312
|
+
const aiResult = await aiGenerate(
|
|
313
|
+
"wiki-query",
|
|
314
|
+
mainaDir,
|
|
315
|
+
{ question },
|
|
316
|
+
userPrompt,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (aiResult.text) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
value: {
|
|
323
|
+
answer: aiResult.text,
|
|
324
|
+
sources,
|
|
325
|
+
cached: false,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// AI unavailable — fall through to fallback
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Fallback: keyword-based excerpts
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
value: buildFallbackAnswer(scored, maxArticles),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Schema — defines article structure, max lengths, and linking conventions.
|
|
3
|
+
*
|
|
4
|
+
* The schema co-evolves with compilation prompts. It defines what valid
|
|
5
|
+
* wiki articles look like for each article type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ArticleType } from "./types";
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface ArticleRule {
|
|
13
|
+
maxLength: number;
|
|
14
|
+
requiredSections: string[];
|
|
15
|
+
linkPrefix: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WikiSchema {
|
|
19
|
+
version: string;
|
|
20
|
+
articleRules: Record<ArticleType, ArticleRule>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ValidationResult {
|
|
24
|
+
valid: boolean;
|
|
25
|
+
issues: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Default Schema ──────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_SCHEMA: WikiSchema = {
|
|
31
|
+
version: "1.0.0",
|
|
32
|
+
articleRules: {
|
|
33
|
+
module: {
|
|
34
|
+
maxLength: 10_000,
|
|
35
|
+
requiredSections: [],
|
|
36
|
+
linkPrefix: "module",
|
|
37
|
+
},
|
|
38
|
+
entity: {
|
|
39
|
+
maxLength: 5_000,
|
|
40
|
+
requiredSections: [],
|
|
41
|
+
linkPrefix: "entity",
|
|
42
|
+
},
|
|
43
|
+
feature: {
|
|
44
|
+
maxLength: 8_000,
|
|
45
|
+
requiredSections: [],
|
|
46
|
+
linkPrefix: "feature",
|
|
47
|
+
},
|
|
48
|
+
decision: {
|
|
49
|
+
maxLength: 8_000,
|
|
50
|
+
requiredSections: [],
|
|
51
|
+
linkPrefix: "decision",
|
|
52
|
+
},
|
|
53
|
+
architecture: {
|
|
54
|
+
maxLength: 10_000,
|
|
55
|
+
requiredSections: [],
|
|
56
|
+
linkPrefix: "architecture",
|
|
57
|
+
},
|
|
58
|
+
raw: {
|
|
59
|
+
maxLength: 10_000,
|
|
60
|
+
requiredSections: [],
|
|
61
|
+
linkPrefix: "raw",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function getArticleMaxLength(type: ArticleType): number {
|
|
69
|
+
return DEFAULT_SCHEMA.articleRules[type].maxLength;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getLinkSyntax(type: ArticleType, id: string): string {
|
|
73
|
+
const prefix = DEFAULT_SCHEMA.articleRules[type].linkPrefix;
|
|
74
|
+
return `[[${prefix}:${id}]]`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate an article's structure against the schema rules for its type.
|
|
79
|
+
* Returns validation result with any issues found.
|
|
80
|
+
*/
|
|
81
|
+
export function validateArticleStructure(
|
|
82
|
+
type: ArticleType,
|
|
83
|
+
content: string,
|
|
84
|
+
): ValidationResult {
|
|
85
|
+
const issues: string[] = [];
|
|
86
|
+
const rule = DEFAULT_SCHEMA.articleRules[type];
|
|
87
|
+
|
|
88
|
+
// Check for title (must start with # heading)
|
|
89
|
+
if (!content.trimStart().startsWith("#")) {
|
|
90
|
+
issues.push("Article must start with a markdown heading (# Title)");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check max length
|
|
94
|
+
if (content.length > rule.maxLength) {
|
|
95
|
+
issues.push(
|
|
96
|
+
`Article exceeds max length: ${content.length} > ${rule.maxLength}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check required sections
|
|
101
|
+
for (const section of rule.requiredSections) {
|
|
102
|
+
if (!content.includes(`## ${section}`)) {
|
|
103
|
+
issues.push(`Missing required section: ## ${section}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
valid: issues.length === 0,
|
|
109
|
+
issues,
|
|
110
|
+
};
|
|
111
|
+
}
|