@aprimediet/codewalker 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Review helpers for codewalker v1.4.
3
+ *
4
+ * Pure/lightweight helpers for the agent-driven best-practice review workflow:
5
+ * - `validateReviewPath()` — ensures path is provided
6
+ * - `checkReviewCap()` — enforces the review cap guardrail
7
+ * - `selectFilesForReview()` — selects files under a path prefix with cap
8
+ * - `formatReviewWorklist()` — formats worklist items for agent display
9
+ *
10
+ * Mirrors the shape of enrich.ts.
11
+ */
12
+
13
+ /** Default maximum files per review command. */
14
+ export const DEFAULT_REVIEW_CAP = 25;
15
+
16
+ /** Result of validateReviewPath. */
17
+ export interface PathValidation {
18
+ valid: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ /** Result of checkReviewCap. */
23
+ export interface CapCheck {
24
+ ok: boolean;
25
+ count: number;
26
+ /** Number of files over the cap (only when !ok). */
27
+ skipped: number;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Validate that a review path was provided.
33
+ */
34
+ export function validateReviewPath(path: string | undefined | null): PathValidation {
35
+ const trimmed = (path ?? "").trim();
36
+ if (!trimmed) {
37
+ return {
38
+ valid: false,
39
+ error: 'Specify a path, e.g. src/auth. Bare /codewalker review with no path is not allowed.',
40
+ };
41
+ }
42
+ return { valid: true };
43
+ }
44
+
45
+ /**
46
+ * Check whether a count of files exceeds the cap.
47
+ * If ok is false, the caller should refuse and tell the user to narrow the path.
48
+ *
49
+ * @param count - Number of files found.
50
+ * @param cap - Maximum allowed (default: DEFAULT_REVIEW_CAP = 25).
51
+ */
52
+ export function checkReviewCap(count: number, cap: number = DEFAULT_REVIEW_CAP): CapCheck {
53
+ if (count > cap) {
54
+ const skipped = count - cap;
55
+ return {
56
+ ok: false,
57
+ count,
58
+ skipped,
59
+ error: `Refusing to review ${count} files (cap ${cap}). ` +
60
+ `Narrow your path or raise the cap with --max=${count}.`,
61
+ };
62
+ }
63
+ return { ok: true, count, skipped: 0 };
64
+ }
65
+
66
+ /**
67
+ * Select files under a path prefix, respecting the cap.
68
+ * Files not matching the prefix are excluded.
69
+ *
70
+ * @param files - Full list of source file paths.
71
+ * @param pathPrefix - The path prefix to filter by (empty string = all files).
72
+ * @param cap - Maximum number to select.
73
+ * @returns Array of file paths matching the prefix, up to cap.
74
+ */
75
+ export function selectFilesForReview(
76
+ files: string[],
77
+ pathPrefix: string,
78
+ cap: number,
79
+ ): string[] {
80
+ const matching = pathPrefix
81
+ ? files.filter(f => f.startsWith(pathPrefix))
82
+ : files;
83
+ return matching.slice(0, cap);
84
+ }
85
+
86
+ /**
87
+ * Format a review worklist for the agent to process.
88
+ * Each line shows one file to review. Includes instructions about grounding
89
+ * findings in the project's own conventions and decisions.
90
+ */
91
+ export function formatReviewWorklist(
92
+ files: string[],
93
+ pathPrefix: string,
94
+ ): string {
95
+ if (files.length === 0) {
96
+ return `No files found under "${pathPrefix}" to review.`;
97
+ }
98
+
99
+ const lines: string[] = [
100
+ `Found ${files.length} file(s) under "${pathPrefix}" selected for review:`,
101
+ "",
102
+ ];
103
+
104
+ for (const file of files) {
105
+ lines.push(` ${file}`);
106
+ }
107
+
108
+ lines.push(
109
+ "",
110
+ "Instructions:",
111
+ ` For each file above, read its content and judge it against the project's`,
112
+ ` conventions and decisions. Before starting, query for relevant context:`,
113
+ ` codewalker_query source:'all' query:'conventions'`,
114
+ ` codewalker_query source:'all' query:'decisions'`,
115
+ "",
116
+ ` For each issue found, call \`codewalker_finding\` with kind='practice',`,
117
+ ` an appropriate severity, and a body grounded in a specific convention or`,
118
+ ` decision. Avoid generic style nits — only flag what the project's own`,
119
+ ` conventions would flag.`,
120
+ "",
121
+ ` Example: \`codewalker_finding { kind: "practice", title: "...",`,
122
+ ` file: "src/auth/token.ts", severity: "warn",`,
123
+ ` body: "Convention X says Y, but this code does Z." }\``,
124
+ );
125
+
126
+ return lines.join("\n");
127
+ }
package/src/cards.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { renderCard, parseCard, cardHead } from './cards.ts';
2
+ import { renderCard, parseCard, cardHead, updateCardSummary } from './cards.ts';
3
3
  import type { Symbol } from './types.ts';
4
4
 
5
5
  function makeSymbol(overrides: Partial<Symbol> = {}): Symbol {
@@ -71,6 +71,128 @@ describe('parseCard', () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe('updateCardSummary', () => {
75
+ it('replaces frontmatter summary line and appends ## What it does section', () => {
76
+ const card = `---
77
+ name: probeCompat
78
+ kind: function
79
+ signature: (cwd: string) => CompatResult
80
+ location: compat.ts:201-243
81
+ summary: Old summary
82
+ ---
83
+
84
+ # probeCompat
85
+
86
+ Some body text here.
87
+ `;
88
+
89
+ const updated = updateCardSummary(card, 'Detect whether minion & memory are active.');
90
+
91
+ // Frontmatter summary updated
92
+ expect(updated).toContain('summary: Detect whether minion & memory are active.');
93
+ expect(updated).not.toContain('summary: Old summary');
94
+
95
+ // ## What it does section added
96
+ expect(updated).toContain('## What it does');
97
+ expect(updated).toContain('Detect whether minion & memory are active.');
98
+ });
99
+
100
+ it('is idempotent — second apply does not stack duplicates', () => {
101
+ const card = `---
102
+ name: probeCompat
103
+ kind: function
104
+ location: compat.ts:201-243
105
+ summary: Old
106
+ ---
107
+
108
+ # probeCompat
109
+ `;
110
+
111
+ const once = updateCardSummary(card, 'First summary.');
112
+ const twice = updateCardSummary(once, 'Second summary.');
113
+
114
+ // Has the new summary
115
+ expect(twice).toContain('summary: Second summary.');
116
+ expect(twice).not.toContain('summary: First summary.');
117
+
118
+ // Only one ## What it does section
119
+ const matches = twice.match(/## What it does/g);
120
+ expect(matches).toHaveLength(1);
121
+
122
+ // Only one summary in frontmatter
123
+ const summaryMatches = twice.match(/^summary:/gm);
124
+ expect(summaryMatches).toHaveLength(1);
125
+ });
126
+
127
+ it('handles empty summary in input', () => {
128
+ const card = `---
129
+ name: foo
130
+ kind: function
131
+ location: foo.ts:1-10
132
+ summary:
133
+ ---
134
+
135
+ # foo
136
+ `;
137
+
138
+ const updated = updateCardSummary(card, 'New summary.');
139
+ expect(updated).toContain('summary: New summary.');
140
+ expect(updated).toContain('## What it does');
141
+ });
142
+
143
+ it('handles card with no existing body', () => {
144
+ const card = `---
145
+ name: bar
146
+ kind: const
147
+ location: bar.ts:5-5
148
+ summary:
149
+ ---
150
+ `;
151
+
152
+ const updated = updateCardSummary(card, 'Just a constant.');
153
+ expect(updated).toContain('summary: Just a constant.');
154
+ expect(updated).toContain('## What it does');
155
+ });
156
+
157
+ it('does not break frontmatter for cards with tags field', () => {
158
+ const card = `---
159
+ name: myFunc
160
+ kind: function
161
+ location: a.ts:1-10
162
+ tags: alpha, beta
163
+ summary:
164
+ ---
165
+
166
+ # myFunc
167
+ `;
168
+
169
+ const updated = updateCardSummary(card, 'A function that does something.');
170
+ const parsed = parseCard(updated);
171
+ expect(parsed).not.toBeNull();
172
+ expect(parsed!.head.name).toBe('myFunc');
173
+ expect(parsed!.head.summary).toBe('A function that does something.');
174
+ expect(parsed!.head.tags).toEqual(['alpha', 'beta']);
175
+ });
176
+
177
+ it('round-trips through parseCard', () => {
178
+ const card = `---
179
+ name: probeCompat
180
+ kind: function
181
+ location: compat.ts:201-243
182
+ summary: Old
183
+ ---
184
+
185
+ # probeCompat
186
+ `;
187
+
188
+ const updated = updateCardSummary(card, 'A function to detect integrations.');
189
+ const parsed = parseCard(updated);
190
+ expect(parsed).not.toBeNull();
191
+ expect(parsed!.head.summary).toBe('A function to detect integrations.');
192
+ expect(parsed!.body).toContain('A function to detect integrations.');
193
+ });
194
+ });
195
+
74
196
  describe('cardHead', () => {
75
197
  it('returns only the frontmatter head from a card', () => {
76
198
  const sym = makeSymbol();
package/src/cards.ts CHANGED
@@ -76,6 +76,59 @@ export function parseCard(text: string): { head: CardHead; body: string } | null
76
76
  };
77
77
  }
78
78
 
79
+ /**
80
+ * Pure: rewrite a card's frontmatter summary: line and upsert a ## What it does body section.
81
+ * Idempotent — enriching twice yields the same card (replaces the section, doesn't stack duplicates).
82
+ */
83
+ export function updateCardSummary(cardText: string, summary: string): string {
84
+ const trimmed = cardText.trim();
85
+
86
+ // Split into frontmatter and body
87
+ const endOfFm = trimmed.indexOf("\n---", 3);
88
+ if (endOfFm === -1) return cardText; // invalid card, return as-is
89
+
90
+ const fmRaw = trimmed.slice(3, endOfFm).trim();
91
+ const bodyRaw = trimmed.slice(endOfFm + 4).trim();
92
+
93
+ // Rebuild frontmatter, replacing summary: line
94
+ const fmLines = fmRaw.split("\n");
95
+ const newFmLines: string[] = [];
96
+ let summaryReplaced = false;
97
+
98
+ for (const line of fmLines) {
99
+ const sep = line.indexOf(":");
100
+ if (sep > 0) {
101
+ const key = line.slice(0, sep).trim();
102
+ if (key === "summary") {
103
+ newFmLines.push(`summary: ${summary}`);
104
+ summaryReplaced = true;
105
+ continue;
106
+ }
107
+ }
108
+ newFmLines.push(line);
109
+ }
110
+
111
+ if (!summaryReplaced) {
112
+ newFmLines.push(`summary: ${summary}`);
113
+ }
114
+
115
+ const newFrontmatter = `---\n${newFmLines.join("\n")}\n---`;
116
+
117
+ // Build body — replace existing ## What it does section if present
118
+ let body = bodyRaw;
119
+ const whatItDoesRegex = /## What it does[\s\S]*?(?=\n## |$)/;
120
+ const whatItDoesSection = `## What it does\n\n${summary}`;
121
+
122
+ if (whatItDoesRegex.test(body)) {
123
+ body = body.replace(whatItDoesRegex, whatItDoesSection);
124
+ } else {
125
+ // Append after existing body (or replace empty body)
126
+ body = body ? `${body}\n\n${whatItDoesSection}` : whatItDoesSection;
127
+ }
128
+
129
+ return `${newFrontmatter}\n\n${body}\n`;
130
+ }
131
+
79
132
  /**
80
133
  * Extract only the frontmatter head from a card — the compact, agent-cheap view.
81
134
  * Returns null if the card is invalid.