@astrosheep/keiyaku 0.1.13 → 0.1.15

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.
@@ -0,0 +1,542 @@
1
+ import { FlowError } from "../common/errors.js";
2
+ const BASE_CONSTRAINTS_SOURCE = ".keiyaku/base-constraints.md";
3
+ function countLeadingSpaces(line) {
4
+ let idx = 0;
5
+ while (idx < line.length && line[idx] === " ") {
6
+ idx += 1;
7
+ }
8
+ return idx;
9
+ }
10
+ function parseFence(trimmedLine) {
11
+ if (!trimmedLine.startsWith("```"))
12
+ return null;
13
+ let idx = 0;
14
+ while (idx < trimmedLine.length && trimmedLine[idx] === "`") {
15
+ idx += 1;
16
+ }
17
+ if (idx < 3)
18
+ return null;
19
+ return {
20
+ length: idx,
21
+ info: trimmedLine.slice(idx).trim(),
22
+ };
23
+ }
24
+ function isAsciiDigit(char) {
25
+ return char !== undefined && char >= "0" && char <= "9";
26
+ }
27
+ function isSpaceOrTab(char) {
28
+ return char === " " || char === "\t";
29
+ }
30
+ function parseHeader(trimmedLine) {
31
+ let level = 0;
32
+ while (level < trimmedLine.length && trimmedLine[level] === "#") {
33
+ level += 1;
34
+ }
35
+ if (level === 0)
36
+ return null;
37
+ if (!isSpaceOrTab(trimmedLine[level]))
38
+ return null;
39
+ const text = trimmedLine.slice(level).trim();
40
+ if (!text)
41
+ return null;
42
+ return { level, text };
43
+ }
44
+ function stripUtf8Bom(content) {
45
+ if (!content.startsWith("\uFEFF"))
46
+ return content;
47
+ return content.slice(1);
48
+ }
49
+ function parseListMarker(line) {
50
+ let indent = 0;
51
+ while (indent < line.length && line[indent] === " ") {
52
+ indent += 1;
53
+ }
54
+ const markerStart = indent;
55
+ const markerChar = line[markerStart];
56
+ let markerEnd = markerStart;
57
+ let ordered = false;
58
+ if (markerChar === "-" || markerChar === "*" || markerChar === "+") {
59
+ markerEnd += 1;
60
+ }
61
+ else if (isAsciiDigit(markerChar)) {
62
+ while (isAsciiDigit(line[markerEnd])) {
63
+ markerEnd += 1;
64
+ }
65
+ const orderedSuffix = line[markerEnd];
66
+ if (orderedSuffix !== "." && orderedSuffix !== ")")
67
+ return null;
68
+ markerEnd += 1;
69
+ ordered = true;
70
+ }
71
+ else {
72
+ return null;
73
+ }
74
+ if (!isSpaceOrTab(line[markerEnd]))
75
+ return null;
76
+ let contentStart = markerEnd;
77
+ while (isSpaceOrTab(line[contentStart])) {
78
+ contentStart += 1;
79
+ }
80
+ return {
81
+ indent,
82
+ ordered,
83
+ marker: line.slice(markerStart, markerEnd),
84
+ body: line.slice(contentStart),
85
+ };
86
+ }
87
+ function isBlankLine(line) {
88
+ return line.trim().length === 0;
89
+ }
90
+ function stripTrailingBlankLines(lines) {
91
+ const trimmed = [...lines];
92
+ while (trimmed.length > 0 && isBlankLine(trimmed[trimmed.length - 1])) {
93
+ trimmed.pop();
94
+ }
95
+ return trimmed;
96
+ }
97
+ function normalizeSectionTitle(title) {
98
+ return title.trim().toLowerCase().replace(/\s+/g, " ");
99
+ }
100
+ function isSectionHeaderToken(token) {
101
+ return token.type === "header" && (token.level === 1 || token.level === 2);
102
+ }
103
+ function isFenceClosingToken(token, fence) {
104
+ return token.type === "fence" && token.length >= fence.length;
105
+ }
106
+ function lexMarkdown(content) {
107
+ const lines = stripUtf8Bom(content).split(/\r?\n/);
108
+ const tokens = [];
109
+ for (const line of lines) {
110
+ const leadingSpaces = countLeadingSpaces(line);
111
+ if (leadingSpaces <= 3) {
112
+ const trimmedLine = line.trimStart();
113
+ const fence = parseFence(trimmedLine);
114
+ if (fence) {
115
+ tokens.push({
116
+ type: "fence",
117
+ raw: line,
118
+ leadingSpaces,
119
+ length: fence.length,
120
+ info: fence.info,
121
+ });
122
+ continue;
123
+ }
124
+ const header = parseHeader(trimmedLine);
125
+ if (header) {
126
+ tokens.push({
127
+ type: "header",
128
+ raw: line,
129
+ leadingSpaces,
130
+ level: header.level,
131
+ text: header.text,
132
+ });
133
+ continue;
134
+ }
135
+ }
136
+ const marker = parseListMarker(line);
137
+ if (marker) {
138
+ tokens.push({
139
+ type: "list_marker",
140
+ raw: line,
141
+ leadingSpaces,
142
+ indent: marker.indent,
143
+ ordered: marker.ordered,
144
+ marker: marker.marker,
145
+ body: marker.body,
146
+ });
147
+ continue;
148
+ }
149
+ tokens.push({
150
+ type: "text",
151
+ raw: line,
152
+ leadingSpaces,
153
+ });
154
+ }
155
+ return tokens;
156
+ }
157
+ class MarkdownParser {
158
+ tokens;
159
+ options;
160
+ index = 0;
161
+ constructor(tokens, options) {
162
+ this.tokens = tokens;
163
+ this.options = options;
164
+ }
165
+ parseDocument() {
166
+ return {
167
+ type: "document",
168
+ children: this.parseBlocks(() => false),
169
+ };
170
+ }
171
+ parseBlocks(shouldStop) {
172
+ const blocks = [];
173
+ while (!this.isEof()) {
174
+ if (shouldStop())
175
+ break;
176
+ const token = this.peek();
177
+ if (!token)
178
+ break;
179
+ if (this.options.allowSections && isSectionHeaderToken(token)) {
180
+ blocks.push(this.parseSection());
181
+ continue;
182
+ }
183
+ if (token.type === "fence") {
184
+ blocks.push(this.parseCodeBlock());
185
+ continue;
186
+ }
187
+ if (token.type === "header") {
188
+ blocks.push(this.parseHeading());
189
+ continue;
190
+ }
191
+ if (token.type === "list_marker" && token.indent <= 3) {
192
+ blocks.push(this.parseList(token.indent, token.ordered));
193
+ continue;
194
+ }
195
+ blocks.push(this.parseText(shouldStop));
196
+ }
197
+ return blocks;
198
+ }
199
+ parseSection() {
200
+ const header = this.consume();
201
+ if (!header || !isSectionHeaderToken(header)) {
202
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", "invalid markdown parse state: expected section header");
203
+ }
204
+ const children = this.parseBlocks(() => {
205
+ const next = this.peek();
206
+ return next ? this.options.allowSections && isSectionHeaderToken(next) : false;
207
+ });
208
+ return {
209
+ type: "section",
210
+ level: header.level,
211
+ title: header.text,
212
+ children,
213
+ };
214
+ }
215
+ parseCodeBlock() {
216
+ const opener = this.consume();
217
+ if (!opener || opener.type !== "fence") {
218
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", "invalid markdown parse state: expected code fence");
219
+ }
220
+ const lines = [opener.raw];
221
+ const fence = { length: opener.length };
222
+ while (!this.isEof()) {
223
+ const token = this.consume();
224
+ if (!token)
225
+ break;
226
+ lines.push(token.raw);
227
+ if (isFenceClosingToken(token, fence)) {
228
+ break;
229
+ }
230
+ }
231
+ return {
232
+ type: "code_block",
233
+ fenceLength: opener.length,
234
+ info: opener.info,
235
+ lines,
236
+ };
237
+ }
238
+ parseHeading() {
239
+ const header = this.consume();
240
+ if (!header || header.type !== "header") {
241
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", "invalid markdown parse state: expected heading");
242
+ }
243
+ return {
244
+ type: "heading",
245
+ level: header.level,
246
+ text: header.text,
247
+ };
248
+ }
249
+ parseList(indent, ordered) {
250
+ const items = [];
251
+ while (!this.isEof()) {
252
+ const token = this.peek();
253
+ if (!token || token.type !== "list_marker")
254
+ break;
255
+ if (token.indent !== indent)
256
+ break;
257
+ if (token.ordered !== ordered)
258
+ break;
259
+ this.consume();
260
+ items.push(this.parseListItem(token, indent));
261
+ }
262
+ return {
263
+ type: "list",
264
+ ordered,
265
+ indent,
266
+ items,
267
+ };
268
+ }
269
+ parseListItem(markerToken, listIndent) {
270
+ const lines = [markerToken.body];
271
+ let itemFence = null;
272
+ while (!this.isEof()) {
273
+ const token = this.peek();
274
+ if (!token)
275
+ break;
276
+ if (itemFence) {
277
+ const consumed = this.consume();
278
+ if (!consumed)
279
+ break;
280
+ lines.push(consumed.raw);
281
+ if (isFenceClosingToken(consumed, itemFence)) {
282
+ itemFence = null;
283
+ }
284
+ continue;
285
+ }
286
+ if (this.options.allowSections && isSectionHeaderToken(token)) {
287
+ break;
288
+ }
289
+ if (token.type === "header" && token.leadingSpaces <= listIndent) {
290
+ break;
291
+ }
292
+ if (token.type === "fence" && token.leadingSpaces <= listIndent) {
293
+ break;
294
+ }
295
+ if (token.type === "list_marker") {
296
+ if (token.indent < listIndent) {
297
+ break;
298
+ }
299
+ if (token.indent === listIndent) {
300
+ if (token.ordered !== markerToken.ordered) {
301
+ break;
302
+ }
303
+ break;
304
+ }
305
+ }
306
+ const consumed = this.consume();
307
+ if (!consumed)
308
+ break;
309
+ lines.push(consumed.raw);
310
+ if (consumed.type === "fence") {
311
+ itemFence = { length: consumed.length };
312
+ }
313
+ }
314
+ const normalizedLines = stripTrailingBlankLines(lines);
315
+ const body = normalizedLines.join("\n");
316
+ const children = parseToAST(body, { allowSections: false }).children;
317
+ return {
318
+ type: "list_item",
319
+ marker: markerToken.marker,
320
+ indent: markerToken.indent,
321
+ children,
322
+ };
323
+ }
324
+ parseText(shouldStop) {
325
+ const lines = [];
326
+ while (!this.isEof()) {
327
+ if (shouldStop())
328
+ break;
329
+ const token = this.peek();
330
+ if (!token)
331
+ break;
332
+ if (token.type === "fence")
333
+ break;
334
+ if (token.type === "header")
335
+ break;
336
+ if (token.type === "list_marker" && token.indent <= 3)
337
+ break;
338
+ if (this.options.allowSections && isSectionHeaderToken(token))
339
+ break;
340
+ lines.push(token.raw);
341
+ this.consume();
342
+ }
343
+ if (lines.length === 0) {
344
+ const fallback = this.consume();
345
+ if (fallback) {
346
+ lines.push(fallback.raw);
347
+ }
348
+ }
349
+ return {
350
+ type: "text",
351
+ lines,
352
+ value: lines.join("\n"),
353
+ };
354
+ }
355
+ peek() {
356
+ return this.tokens[this.index];
357
+ }
358
+ consume() {
359
+ const token = this.tokens[this.index];
360
+ this.index += 1;
361
+ return token;
362
+ }
363
+ isEof() {
364
+ return this.index >= this.tokens.length;
365
+ }
366
+ }
367
+ function renderListItemFirstLine(item) {
368
+ const content = renderNodeContent(item);
369
+ if (!content) {
370
+ return `${" ".repeat(item.indent)}${item.marker}`;
371
+ }
372
+ const [firstLine, ...rest] = content.split("\n");
373
+ const prefix = `${" ".repeat(item.indent)}${item.marker}${firstLine.length > 0 ? " " : ""}${firstLine}`;
374
+ if (rest.length === 0) {
375
+ return prefix;
376
+ }
377
+ return [prefix, ...rest].join("\n");
378
+ }
379
+ function renderBlock(node) {
380
+ switch (node.type) {
381
+ case "section": {
382
+ const header = `${"#".repeat(node.level)} ${node.title}`;
383
+ const body = renderNodeContent(node);
384
+ return body ? `${header}\n${body}` : header;
385
+ }
386
+ case "list":
387
+ return node.items.map((item) => renderListItemFirstLine(item)).join("\n");
388
+ case "code_block":
389
+ return node.lines.join("\n");
390
+ case "text":
391
+ return node.value;
392
+ case "heading":
393
+ return `${"#".repeat(node.level)} ${node.text}`;
394
+ }
395
+ }
396
+ export function parseToAST(content, options = {}) {
397
+ const parser = new MarkdownParser(lexMarkdown(content), {
398
+ allowSections: options.allowSections ?? true,
399
+ });
400
+ return parser.parseDocument();
401
+ }
402
+ export function renderNodeContent(node) {
403
+ switch (node.type) {
404
+ case "document":
405
+ return node.children.map((child) => renderBlock(child)).join("\n");
406
+ case "section":
407
+ return node.children.map((child) => renderBlock(child)).join("\n");
408
+ case "list":
409
+ return renderBlock(node);
410
+ case "list_item":
411
+ return node.children.map((child) => renderBlock(child)).join("\n");
412
+ case "code_block":
413
+ return node.lines.join("\n");
414
+ case "text":
415
+ return node.value;
416
+ case "heading":
417
+ return `${"#".repeat(node.level)} ${node.text}`;
418
+ }
419
+ }
420
+ export function renderSectionContent(sectionNode) {
421
+ return sectionNode.children.map((child) => renderBlock(child)).join("\n");
422
+ }
423
+ export function extractListItems(sectionNode) {
424
+ const items = [];
425
+ for (const child of sectionNode.children) {
426
+ if (child.type !== "list")
427
+ continue;
428
+ for (const item of child.items) {
429
+ const rendered = renderNodeContent(item).trimEnd();
430
+ if (rendered.trim().length > 0) {
431
+ items.push(rendered);
432
+ }
433
+ }
434
+ }
435
+ return items;
436
+ }
437
+ function splitByHeadingBlocks(sectionNode) {
438
+ const groups = [];
439
+ let currentGroup = [];
440
+ const commitGroup = () => {
441
+ if (currentGroup.length === 0)
442
+ return;
443
+ const rendered = currentGroup.map((node) => renderBlock(node)).join("\n").trim();
444
+ if (rendered.length > 0) {
445
+ groups.push(rendered);
446
+ }
447
+ currentGroup = [];
448
+ };
449
+ for (const child of sectionNode.children) {
450
+ if (child.type === "heading" && child.level >= 2) {
451
+ commitGroup();
452
+ currentGroup = [child];
453
+ continue;
454
+ }
455
+ currentGroup.push(child);
456
+ }
457
+ commitGroup();
458
+ return groups;
459
+ }
460
+ export function renderMarkdownSections(items) {
461
+ return items
462
+ .map((item) => {
463
+ validateMarkdownHeaders(item);
464
+ const trimmed = item.trimStart();
465
+ if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
466
+ return item;
467
+ }
468
+ return `- ${item}`;
469
+ })
470
+ .join("\n\n");
471
+ }
472
+ export function validateMarkdownHeaders(text) {
473
+ if (/^(#|##)\s/m.test(text)) {
474
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", "Constraints/Criteria cannot contain H1/H2 headers (#/##). Use H3 (###) or list items.");
475
+ }
476
+ }
477
+ export function parseMarkdownListSection(text) {
478
+ const ast = parseToAST(text, { allowSections: false });
479
+ const pseudoSection = {
480
+ type: "section",
481
+ level: 2,
482
+ title: "",
483
+ children: ast.children,
484
+ };
485
+ const listItems = extractListItems(pseudoSection);
486
+ if (listItems.length > 0) {
487
+ return listItems;
488
+ }
489
+ const nonHeadingSection = {
490
+ type: "section",
491
+ level: 2,
492
+ title: "",
493
+ children: pseudoSection.children.filter((child) => child.type !== "heading"),
494
+ };
495
+ const fallback = renderSectionContent(nonHeadingSection).trim();
496
+ return fallback ? [fallback] : [];
497
+ }
498
+ export function parseMarkdownSections(text) {
499
+ const ast = parseToAST(text, { allowSections: false });
500
+ const pseudoSection = {
501
+ type: "section",
502
+ level: 2,
503
+ title: "",
504
+ children: ast.children,
505
+ };
506
+ return splitByHeadingBlocks(pseudoSection);
507
+ }
508
+ export function parseMarkdownStructure(content) {
509
+ const ast = parseToAST(content);
510
+ const sections = new Map();
511
+ let title;
512
+ for (const node of ast.children) {
513
+ if (node.type !== "section")
514
+ continue;
515
+ if (node.level === 1 && !title) {
516
+ title = node.title.trim() || undefined;
517
+ continue;
518
+ }
519
+ if (node.level !== 2)
520
+ continue;
521
+ const normalizedSection = normalizeSectionTitle(node.title);
522
+ if (sections.has(normalizedSection)) {
523
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", `invalid keiyaku draft: duplicate section header '${normalizedSection}'`);
524
+ }
525
+ const body = renderSectionContent(node).trim();
526
+ sections.set(normalizedSection, body ? body.split("\n") : []);
527
+ }
528
+ return { title, sections };
529
+ }
530
+ export function renderKeiyaku(title, goal, context, baseConstraints, taskConstraints, taskCriteria) {
531
+ validateMarkdownHeaders(goal);
532
+ validateMarkdownHeaders(context);
533
+ let content = `# ${title}\n\n## Context\n${context}\n\n## Goal\n${goal}`;
534
+ content += "\n\n## Constraints";
535
+ if (baseConstraints.length > 0) {
536
+ content += `\n\n### Project Constraints\nLoaded from: \`${BASE_CONSTRAINTS_SOURCE}\`\n\n${renderMarkdownSections(baseConstraints)}`;
537
+ }
538
+ content += `\n\n### Task Constraints\n${renderMarkdownSections(taskConstraints)}`;
539
+ content += "\n\n## Acceptance Criteria";
540
+ content += `\n\n### Task Criteria\n${renderMarkdownSections(taskCriteria)}`;
541
+ return content;
542
+ }