explorbot 0.4.8 → 0.4.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/boat/api-tester/src/cli.ts +1 -1
- package/boat/api-tester/src/config.ts +34 -26
- package/dist/boat/api-tester/src/cli.js +1 -1
- package/dist/boat/api-tester/src/config.js +32 -26
- package/dist/package.json +1 -1
- package/dist/src/action.js +4 -2
- package/dist/src/ai/fisherman/tools.js +10 -4
- package/dist/src/ai/navigator.d.ts +0 -1
- package/dist/src/ai/navigator.js +11 -24
- package/dist/src/ai/pilot.js +21 -10
- package/dist/src/ai/rerunner.js +7 -0
- package/dist/src/ai/tools.js +8 -1
- package/dist/src/api/xhr-capture.js +2 -1
- package/dist/src/commands/config-command.d.ts +2 -0
- package/dist/src/commands/config-command.js +8 -2
- package/dist/src/commands/init-command.js +6 -26
- package/dist/src/commands/sites-command.js +6 -1
- package/dist/src/config.d.ts +5 -4
- package/dist/src/config.js +25 -23
- package/dist/src/explorer.js +2 -3
- package/dist/src/global-config.d.ts +6 -0
- package/dist/src/global-config.js +82 -7
- package/dist/src/reporter.js +8 -4
- package/dist/src/utils/html.js +6 -0
- package/dist/src/utils/logger.js +1 -1
- package/dist/src/utils/merge.d.ts +1 -0
- package/dist/src/utils/merge.js +11 -0
- package/dist/src/utils/step-analyzer.d.ts +3 -0
- package/dist/src/utils/step-analyzer.js +7 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +7 -0
- package/docs/reference/commands.md +10 -2
- package/docs/reference/configuration.md +37 -4
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
- package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
- package/package.json +1 -1
- package/src/action.ts +4 -2
- package/src/ai/fisherman/tools.ts +10 -4
- package/src/ai/navigator.ts +9 -23
- package/src/ai/pilot.ts +21 -10
- package/src/ai/rerunner.ts +4 -0
- package/src/ai/tools.ts +7 -1
- package/src/api/xhr-capture.ts +2 -1
- package/src/commands/config-command.ts +9 -2
- package/src/commands/init-command.ts +6 -27
- package/src/commands/sites-command.ts +6 -1
- package/src/config.ts +28 -25
- package/src/explorer.ts +2 -2
- package/src/global-config.ts +81 -6
- package/src/reporter.ts +8 -4
- package/src/utils/html.ts +6 -0
- package/src/utils/logger.ts +1 -1
- package/src/utils/merge.ts +13 -0
- package/src/utils/step-analyzer.ts +8 -0
- package/src/utils/url-matcher.ts +7 -0
|
@@ -0,0 +1,2029 @@
|
|
|
1
|
+
# mdq Package Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Extract `src/utils/markdown-query.ts` into `src/utils/mdq/` as a publish-ready package that can both query and update markdown, then add a jq-like CLI.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two classes in two files. `query.ts` owns the selector grammar, the token index, `MarkdownDoc` and `Selection`. `edit.ts` holds pure functions that take source text plus ranges and return new source text; it imports types from `query.ts` type-only and never touches a class value, which keeps the split acyclic. Reads narrow to a `Selection`; writes return a `MarkdownDoc`, so edits chain.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Bun, `marked` ^16.2.0 (markdown), `yaml` ^2.8.3 (frontmatter), `vitest` API via `bun test`, Commander (CLI only).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-14-mdq-package-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- **Two dependencies only.** `marked` and `yaml`. No imports from anywhere else in explorbot — not `src/utils/`, not `src/commands/`, nothing. This is what makes the package extractable.
|
|
16
|
+
- **Repo style rules** (from `CLAUDE.md`, all enforced in review):
|
|
17
|
+
- No comments unless explicitly requested.
|
|
18
|
+
- No ternaries. No `...(cond ? {k:v} : {})` spread.
|
|
19
|
+
- Prefer early return over `if/else`.
|
|
20
|
+
- Types and interfaces at the **end** of the file.
|
|
21
|
+
- Private methods after public methods.
|
|
22
|
+
- Use `?.` rather than chained `&&`.
|
|
23
|
+
- `bun run format` after each code change; `bun run lint:fix` after each task.
|
|
24
|
+
- **Never run the regression workflow** and never add the `regression` label.
|
|
25
|
+
- Bun only. Never Node.
|
|
26
|
+
|
|
27
|
+
## Baseline facts (measured 2026-09-15, do not re-derive)
|
|
28
|
+
|
|
29
|
+
- `bun test tests/unit/markdown-query.test.ts` → **110 pass, 0 fail**. This suite is the regression net for all 54 call sites; it must stay green at every task boundary.
|
|
30
|
+
- `bunx tsc -p tsconfig.json --noEmit` → **1004 errors repo-wide**. A clean `tsc` is not achievable and is not the goal. Only the scoped check in Task 3 matters.
|
|
31
|
+
- Of those 1004, the files this plan touches own exactly **2**, both pre-existing and unrelated to mdq:
|
|
32
|
+
```
|
|
33
|
+
src/ai/researcher/locators.ts(247,41): error TS2339: Property 'playwrightLocatorCount' does not exist on type 'Explorer'.
|
|
34
|
+
src/ai/researcher/locators.ts(247,65): error TS7006: Parameter 'page' implicitly has an 'any' type.
|
|
35
|
+
```
|
|
36
|
+
These two are the expected output of the Task 3 scoped check. **Three or more means the migration is incomplete.**
|
|
37
|
+
- CI runs `tsc` with `--noCheck`. A green CI proves nothing about types here.
|
|
38
|
+
|
|
39
|
+
## File Structure
|
|
40
|
+
|
|
41
|
+
| File | Responsibility |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `src/utils/mdq/query.ts` | Selector grammar, token index (frontmatter-aware), `MarkdownDoc`, `Selection`, sugar layer, error classes |
|
|
44
|
+
| `src/utils/mdq/edit.ts` | Pure edits over `(source, ranges)`: splicing, whitespace normalization, table/list renderers, entry and frontmatter rewriting |
|
|
45
|
+
| `src/utils/mdq/cli.ts` | CLI argument handling and output formatting (Task 10) |
|
|
46
|
+
| `src/utils/mdq/README.md` | Public documentation (Task 9) |
|
|
47
|
+
| `src/utils/markdown-query.ts` | Re-export shim so all 54 existing call sites keep working |
|
|
48
|
+
| `bin/mdq.ts` | Thin CLI entry delegating to `cli.ts` (Task 10) |
|
|
49
|
+
| `tests/unit/mdq/*.test.ts` | Test suites, one per concern |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### Task 1: Scaffold the package with a frontmatter-aware token index
|
|
54
|
+
|
|
55
|
+
Move the parser to its new home and teach it the one thing it gets wrong today: a leading `---` block is frontmatter, not a setext heading.
|
|
56
|
+
|
|
57
|
+
**Files:**
|
|
58
|
+
- Create: `src/utils/mdq/query.ts`
|
|
59
|
+
- Create: `src/utils/mdq/edit.ts`
|
|
60
|
+
- Create: `tests/unit/mdq/frontmatter.test.ts`
|
|
61
|
+
|
|
62
|
+
**Interfaces:**
|
|
63
|
+
- Consumes: nothing (first task)
|
|
64
|
+
- Produces:
|
|
65
|
+
- `buildTokenIndex(source: string): MatchedRange[]` — ranges are absolute offsets into `source`, frontmatter excluded
|
|
66
|
+
- `splitFrontmatter(source: string): { raw: string; body: string; offset: number }`
|
|
67
|
+
- `interface MatchedRange { token: Token; start: number; length: number; trailing?: { start: number; length: number }; innerTokens?: MatchedRange[] }`
|
|
68
|
+
|
|
69
|
+
The `trailing` field is new and load-bearing: `marked` emits `space` tokens as siblings (a `paragraph` raw is `"para"` with no newline, followed by a separate `space` raw of `"\n\n"`), so every write verb needs to know where a node's separator lives.
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 1: Write the failing test**
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// tests/unit/mdq/frontmatter.test.ts
|
|
75
|
+
import { describe, expect, it } from 'vitest';
|
|
76
|
+
import { splitFrontmatter } from '../../../src/utils/mdq/edit.ts';
|
|
77
|
+
import { buildTokenIndex } from '../../../src/utils/mdq/query.ts';
|
|
78
|
+
|
|
79
|
+
describe('splitFrontmatter', () => {
|
|
80
|
+
it('splits a leading yaml block from the body', () => {
|
|
81
|
+
const src = '---\nurl: /login\nwait: 1000\n---\n\n# Title\n';
|
|
82
|
+
const fm = splitFrontmatter(src);
|
|
83
|
+
expect(fm.raw).toBe('url: /login\nwait: 1000');
|
|
84
|
+
expect(fm.body).toBe('\n# Title\n');
|
|
85
|
+
expect(fm.offset).toBe(src.length - fm.body.length);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns no frontmatter when the document does not open with ---', () => {
|
|
89
|
+
const fm = splitFrontmatter('# Title\n\n---\n');
|
|
90
|
+
expect(fm.raw).toBe('');
|
|
91
|
+
expect(fm.offset).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('treats an unterminated --- as body, not frontmatter', () => {
|
|
95
|
+
const fm = splitFrontmatter('---\nnot closed\n');
|
|
96
|
+
expect(fm.raw).toBe('');
|
|
97
|
+
expect(fm.offset).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('buildTokenIndex', () => {
|
|
102
|
+
it('excludes frontmatter so it is never lexed as a setext heading', () => {
|
|
103
|
+
const ranges = buildTokenIndex('---\nurl: /login\n---\n\n# Title\n');
|
|
104
|
+
expect(ranges.filter((r) => r.token.type === 'heading')).toHaveLength(1);
|
|
105
|
+
expect(ranges.every((r) => r.start >= 20)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps offsets absolute so slicing the original source works', () => {
|
|
109
|
+
const src = '---\nurl: /x\n---\n\n# Title\n';
|
|
110
|
+
const ranges = buildTokenIndex(src);
|
|
111
|
+
const heading = ranges.find((r) => r.token.type === 'heading');
|
|
112
|
+
expect(src.slice(heading.start, heading.start + heading.length)).toBe('# Title\n');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('records the trailing space token of a paragraph', () => {
|
|
116
|
+
const src = 'para\n\n# Next\n';
|
|
117
|
+
const ranges = buildTokenIndex(src);
|
|
118
|
+
const para = ranges.find((r) => r.token.type === 'paragraph');
|
|
119
|
+
expect(para.length).toBe(4);
|
|
120
|
+
expect(para.trailing).toEqual({ start: 4, length: 2 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('leaves trailing undefined for a node with no following space', () => {
|
|
124
|
+
const ranges = buildTokenIndex('# Only\n');
|
|
125
|
+
expect(ranges[0].trailing).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
131
|
+
|
|
132
|
+
Run: `bun test tests/unit/mdq/frontmatter.test.ts`
|
|
133
|
+
Expected: FAIL — cannot resolve `src/utils/mdq/query.ts`
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 3: Create query.ts with the index**
|
|
136
|
+
|
|
137
|
+
Copy `src/utils/markdown-query.ts` to `src/utils/mdq/query.ts` verbatim first, then apply these three changes.
|
|
138
|
+
|
|
139
|
+
Create `src/utils/mdq/edit.ts` holding the frontmatter grammar. It lives here from the
|
|
140
|
+
start so that later tasks add to this file rather than moving code between the two —
|
|
141
|
+
`query.ts` imports the value, `edit.ts` only ever imports types back, so the split stays
|
|
142
|
+
acyclic:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
export function splitFrontmatter(source: string): FrontmatterSplit {
|
|
146
|
+
if (!source.startsWith('---')) return { raw: '', body: source, offset: 0 };
|
|
147
|
+
const match = source.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
148
|
+
if (!match) return { raw: '', body: source, offset: 0 };
|
|
149
|
+
return { raw: match[1], body: source.slice(match[0].length), offset: match[0].length };
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Replace the body of `buildTokenIndex`:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
export function buildTokenIndex(source: string): MatchedRange[] {
|
|
157
|
+
const { body, offset } = splitFrontmatter(source);
|
|
158
|
+
const tokens = marked.lexer(body);
|
|
159
|
+
const ranges: MatchedRange[] = [];
|
|
160
|
+
let cursor = offset;
|
|
161
|
+
|
|
162
|
+
for (const token of tokens) {
|
|
163
|
+
const raw = (token as any).raw || '';
|
|
164
|
+
if (token.type === 'space') {
|
|
165
|
+
const previous = ranges[ranges.length - 1];
|
|
166
|
+
if (previous) previous.trailing = { start: cursor, length: raw.length };
|
|
167
|
+
cursor += raw.length;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
ranges.push({ token, start: cursor, length: raw.length });
|
|
171
|
+
cursor += raw.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return ranges;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Note this also stops `space` tokens from appearing as matchable ranges, which they never should have been.
|
|
179
|
+
|
|
180
|
+
`query.ts` imports it and re-exports for convenience:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { splitFrontmatter } from './edit.ts';
|
|
184
|
+
|
|
185
|
+
export { splitFrontmatter };
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Add to the type block at the **end** of `edit.ts`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
export interface FrontmatterSplit {
|
|
192
|
+
raw: string;
|
|
193
|
+
body: string;
|
|
194
|
+
offset: number;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
and extend the existing `MatchedRange` at the end of `query.ts`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
export interface MatchedRange {
|
|
202
|
+
token: Token;
|
|
203
|
+
start: number;
|
|
204
|
+
length: number;
|
|
205
|
+
trailing?: { start: number; length: number };
|
|
206
|
+
innerTokens?: MatchedRange[];
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
211
|
+
|
|
212
|
+
Run: `bun test tests/unit/mdq/frontmatter.test.ts`
|
|
213
|
+
Expected: PASS (8 tests)
|
|
214
|
+
|
|
215
|
+
- [ ] **Step 5: Format, lint and commit**
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
bun run format && bun run lint:fix
|
|
219
|
+
git add src/utils/mdq/query.ts src/utils/mdq/edit.ts tests/unit/mdq/frontmatter.test.ts
|
|
220
|
+
git commit -m "feat(mdq): frontmatter-aware token index"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### Task 2: Port the public API behind a shim, behaviour unchanged
|
|
226
|
+
|
|
227
|
+
Get every existing call site running against the new file with **zero behaviour change**. Writes still return `string` here — flipping them is Task 3. This task is the safety net for everything after it.
|
|
228
|
+
|
|
229
|
+
**Files:**
|
|
230
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
231
|
+
- Modify: `src/utils/markdown-query.ts` (becomes a shim)
|
|
232
|
+
- Create: `tests/unit/mdq/query.test.ts` (moved from `tests/unit/markdown-query.test.ts`)
|
|
233
|
+
- Delete: `tests/unit/markdown-query.test.ts`
|
|
234
|
+
|
|
235
|
+
**Interfaces:**
|
|
236
|
+
- Consumes: `buildTokenIndex`, `splitFrontmatter`, `MatchedRange` from Task 1
|
|
237
|
+
- Produces: `mdq(source: string): MarkdownQuery`, class `MarkdownQuery`, `parseQuery`, all existing methods unchanged
|
|
238
|
+
|
|
239
|
+
- [ ] **Step 1: Move the test file and repoint its import**
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
git mv tests/unit/markdown-query.test.ts tests/unit/mdq/query.test.ts
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Change line 2 of the moved file from:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { mdq, parseQuery } from '../../src/utils/markdown-query.ts';
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
to:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { mdq, parseQuery } from '../../../src/utils/mdq/query.ts';
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 2: Run the suite to verify it fails**
|
|
258
|
+
|
|
259
|
+
Run: `bun test tests/unit/mdq/query.test.ts`
|
|
260
|
+
Expected: FAIL — `mdq` / `parseQuery` are not yet exported from `query.ts`, or section tests fail because `expandSectionRanges` still assumes `space` tokens are present
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 3: Restore the full API in query.ts**
|
|
263
|
+
|
|
264
|
+
Everything from the original `markdown-query.ts` below `buildTokenIndex` — `matchText`, `entryKey`, `getTokenText`, `getHeadingDepth`, `isSectionSelector`, `getSectionDepth`, `selectorToTokenType`, `computeSections`, `extractListItems`, `applyIndexSlice`, `expandSectionRanges`, `executeSegments`, `class MarkdownQuery`, `mdq` — carries over unchanged, except:
|
|
265
|
+
|
|
266
|
+
`computeSections` must extend a section's range to include the last inner node's trailing space, since `space` tokens are no longer separate ranges:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
270
|
+
const nextRange = candidates[j];
|
|
271
|
+
if (nextRange.token.type === 'heading' && (nextRange.token as Tokens.Heading).depth <= depth) break;
|
|
272
|
+
innerTokens.push(nextRange);
|
|
273
|
+
endOffset = nextRange.start + nextRange.length;
|
|
274
|
+
if (nextRange.trailing) endOffset = nextRange.trailing.start + nextRange.trailing.length;
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Move every `export interface` / `export type` to the end of the file, and replace the ternaries at the original lines 30, 34, 129-130 and 295 with early returns.
|
|
279
|
+
|
|
280
|
+
- [ ] **Step 4: Run the suite to verify it passes**
|
|
281
|
+
|
|
282
|
+
Run: `bun test tests/unit/mdq/query.test.ts`
|
|
283
|
+
Expected: PASS — **110 tests**, the same count as before the move
|
|
284
|
+
|
|
285
|
+
- [ ] **Step 5: Replace markdown-query.ts with a shim**
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
export * from './mdq/query.ts';
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- [ ] **Step 6: Verify every existing call site still works**
|
|
292
|
+
|
|
293
|
+
Run: `bun test tests/unit/`
|
|
294
|
+
Expected: PASS, no new failures versus the pre-task run
|
|
295
|
+
|
|
296
|
+
- [ ] **Step 7: Format, lint and commit**
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
bun run format && bun run lint:fix
|
|
300
|
+
git add -A src/utils tests/unit
|
|
301
|
+
git commit -m "refactor(mdq): move markdown-query into src/utils/mdq behind a shim"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
### Task 3: Flip write verbs to return MarkdownDoc and migrate every call site
|
|
307
|
+
|
|
308
|
+
The one risky task. It ends with the repo green and every break fixed.
|
|
309
|
+
|
|
310
|
+
**Files:**
|
|
311
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
312
|
+
- Modify: `src/experience-tracker.ts:265`, `:289`
|
|
313
|
+
- Modify: `src/ai/planner.ts:304`, `:322`
|
|
314
|
+
- Modify: `src/ai/researcher/deep-analysis.ts:129`
|
|
315
|
+
- Modify: `src/ai/researcher/locators.ts:307`, `:309`
|
|
316
|
+
- Modify: `src/ai/researcher/pagination.ts:61`
|
|
317
|
+
- Modify: `src/ai/researcher/research-result.ts:56`, `:57`, `:58`
|
|
318
|
+
- Modify: `src/ai/researcher.ts:316`
|
|
319
|
+
- Modify: `tests/unit/mdq/query.test.ts`
|
|
320
|
+
|
|
321
|
+
**Interfaces:**
|
|
322
|
+
- Consumes: `MarkdownQuery`, `mdq` from Task 2
|
|
323
|
+
- Produces:
|
|
324
|
+
- `class MarkdownDoc` — `query()`, `toString()`, `valueOf()`
|
|
325
|
+
- `class Selection` — all reads, plus writes returning `MarkdownDoc`
|
|
326
|
+
- `type Markdown = string | MarkdownDoc`
|
|
327
|
+
- `mdq(source: Markdown): MarkdownDoc`
|
|
328
|
+
- Deprecated alias `MarkdownQuery = Selection`
|
|
329
|
+
|
|
330
|
+
- [ ] **Step 1: Write the failing test**
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// append to tests/unit/mdq/query.test.ts
|
|
334
|
+
describe('MarkdownDoc chaining', () => {
|
|
335
|
+
const md = '# T\n\n## A\n\npara\n\n## B\n\nother\n';
|
|
336
|
+
|
|
337
|
+
it('returns a MarkdownDoc from a write so edits chain', () => {
|
|
338
|
+
const out = mdq(md).query('h2("A")').replace('## Z\n\n').query('h2').count();
|
|
339
|
+
expect(out).toBe(2);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('stringifies to the full document', () => {
|
|
343
|
+
expect(mdq(md).query('h2("A")').replace('## Z\n\n').toString()).toContain('## Z');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('accepts a MarkdownDoc as a source', () => {
|
|
347
|
+
const doc = mdq(md).query('h2("A")').replace('## Z\n\n');
|
|
348
|
+
expect(mdq(doc).query('h2').count()).toBe(2);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('accepts a MarkdownDoc returned from a replaceEach callback', () => {
|
|
352
|
+
const out = mdq(md)
|
|
353
|
+
.query('h2')
|
|
354
|
+
.replaceEach((section) => mdq(section.text()).query('h2').replace('### x\n\n'))
|
|
355
|
+
.toString();
|
|
356
|
+
expect(out).toContain('### x');
|
|
357
|
+
expect(out).not.toContain('## A');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
363
|
+
|
|
364
|
+
Run: `bun test tests/unit/mdq/query.test.ts -t 'MarkdownDoc chaining'`
|
|
365
|
+
Expected: FAIL — `.query is not a function` on the string returned by `replace`
|
|
366
|
+
|
|
367
|
+
- [ ] **Step 3: Split MarkdownQuery into MarkdownDoc and Selection**
|
|
368
|
+
|
|
369
|
+
`MarkdownDoc` holds the source. `Selection` holds source plus matches. Every write on `Selection` ends by wrapping its result:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
export class MarkdownDoc {
|
|
373
|
+
private source: string;
|
|
374
|
+
|
|
375
|
+
constructor(source: string) {
|
|
376
|
+
this.source = source;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
query(selector: string): Selection {
|
|
380
|
+
const segments = parseQuery(selector);
|
|
381
|
+
const candidates = expandSectionRanges(buildTokenIndex(this.source));
|
|
382
|
+
return new Selection(this.source, executeSegments(candidates, segments));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
toString(): string {
|
|
386
|
+
return this.source;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
valueOf(): string {
|
|
390
|
+
return this.source;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
In `Selection`, each write returns `new MarkdownDoc(...)` instead of a raw string. `replaceEach` accepts `Markdown` back from its callback:
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
replaceEach(replacer: (match: Selection, index: number) => Markdown): MarkdownDoc {
|
|
399
|
+
// ... unchanged range logic ...
|
|
400
|
+
const replacements = kept.map((range, index) => String(replacer(new Selection(this.source, [range]), index)));
|
|
401
|
+
// ... unchanged splice loop ...
|
|
402
|
+
return new MarkdownDoc(result);
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`mdq` accepts either:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
export function mdq(source: Markdown): MarkdownDoc {
|
|
410
|
+
return new MarkdownDoc(String(source));
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
At the end of the file:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
export type Markdown = string | MarkdownDoc;
|
|
418
|
+
|
|
419
|
+
/** @deprecated Use Selection. */
|
|
420
|
+
export const MarkdownQuery = Selection;
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
424
|
+
|
|
425
|
+
Run: `bun test tests/unit/mdq/query.test.ts -t 'MarkdownDoc chaining'`
|
|
426
|
+
Expected: PASS (4 tests)
|
|
427
|
+
|
|
428
|
+
- [ ] **Step 5: Fix the write assertions in the ported suite**
|
|
429
|
+
|
|
430
|
+
The existing `replace` tests assert against a string. Wrap each in `String(...)`, for example:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
it('should replace matched content', () => {
|
|
434
|
+
const result = String(mdq(sampleMarkdown).query('heading("FAQ")').replace('## Questions\n'));
|
|
435
|
+
expect(result).toContain('## Questions');
|
|
436
|
+
expect(result).not.toContain('## FAQ');
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Apply the same to every assertion in the `replace`, `setKeyValue` and `edge cases` describes that compares a write result to a string.
|
|
441
|
+
|
|
442
|
+
Run: `bun test tests/unit/mdq/query.test.ts`
|
|
443
|
+
Expected: PASS — 114 tests
|
|
444
|
+
|
|
445
|
+
- [ ] **Step 6: Migrate the four breakage classes**
|
|
446
|
+
|
|
447
|
+
**(a) Assignment into a `string`-typed target** — append `.toString()`:
|
|
448
|
+
|
|
449
|
+
| File:line | Change |
|
|
450
|
+
|---|---|
|
|
451
|
+
| `src/experience-tracker.ts:265` | `content = sections[sections.length - 1].replace('').toString();` |
|
|
452
|
+
| `src/experience-tracker.ts:289` | `combined = mdq(combined).query('code').replace('').toString();` |
|
|
453
|
+
| `src/ai/researcher/deep-analysis.ts:129` | `updated = extQuery.replace(\`${existing}\n\n${sectionMarkdown}\n\`).toString();` |
|
|
454
|
+
| `src/ai/researcher/locators.ts:307` | `result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', \`'${newCss}'\`).toString();` |
|
|
455
|
+
| `src/ai/researcher/locators.ts:309` | `result.text = sectionQuery.query('blockquote[0]').replace('').toString();` |
|
|
456
|
+
| `src/ai/researcher/pagination.ts:61` | `result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy).toString();` |
|
|
457
|
+
| `src/ai/researcher/research-result.ts:57` | `section.rawMarkdown = mdq(section.rawMarkdown).query('table').replace(\`${newTable.trimEnd()}\n\`).toString();` |
|
|
458
|
+
|
|
459
|
+
**(b) A string method called on the result:**
|
|
460
|
+
|
|
461
|
+
`src/ai/planner.ts:304`:
|
|
462
|
+
```ts
|
|
463
|
+
const body = mdq(withoutHeadings).query('hr').replace('').toString().trim();
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`src/ai/planner.ts:322`:
|
|
467
|
+
```ts
|
|
468
|
+
const kept = mdq(section.text()).query('blockquote[10:]').replace('').toString();
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**(c) Returned from a `replaceEach` callback** — `deep-analysis.ts:542` needs **no change**. The callback signature accepts `Markdown`.
|
|
472
|
+
|
|
473
|
+
**(d) Compared against a string — read this one carefully.** `research-result.ts:55-58` currently reads:
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
const updated = sectionQuery.query('table').replace(`${newTable.trimEnd()}\n`);
|
|
477
|
+
if (updated === this.text) return;
|
|
478
|
+
section.rawMarkdown = mdq(section.rawMarkdown).query('table').replace(`${newTable.trimEnd()}\n`);
|
|
479
|
+
this.text = updated;
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`updated` is now a `MarkdownDoc`, so `updated === this.text` is **always false** — the guard silently stops firing and the method starts doing work it used to skip. Convert once, at the top:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
const updated = sectionQuery.query('table').replace(`${newTable.trimEnd()}\n`).toString();
|
|
486
|
+
if (updated === this.text) return;
|
|
487
|
+
section.rawMarkdown = mdq(section.rawMarkdown).query('table').replace(`${newTable.trimEnd()}\n`).toString();
|
|
488
|
+
this.text = updated;
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Leave alone** — these already work because `mdq()` accepts a `MarkdownDoc`: `planner.ts:303`, `planner.ts:405`.
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 7: Fix the one regex call site**
|
|
494
|
+
|
|
495
|
+
`src/ai/researcher.ts:316` relies on regex matching being implicitly case-insensitive. Task 4 removes that. Make the flag explicit now so the two changes never overlap:
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
const summaryText = mdq(result.text).query('section2(/^summary/i)').query('paragraph[0]').text().trim();
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
- [ ] **Step 8: Verify with the scoped type check**
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
bunx tsc -p tsconfig.json --noEmit 2>&1 | grep -E "^(src/utils/mdq/|src/utils/markdown-query|src/experience-tracker|src/ai/planner|src/ai/researcher)"
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Expected: **exactly these two lines and nothing else.**
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
src/ai/researcher/locators.ts(247,41): error TS2339: Property 'playwrightLocatorCount' does not exist on type 'Explorer'.
|
|
511
|
+
src/ai/researcher/locators.ts(247,65): error TS7006: Parameter 'page' implicitly has an 'any' type.
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Any third line is an unmigrated call site. Fix it before continuing — CI will not catch it, because CI runs `tsc --noCheck`.
|
|
515
|
+
|
|
516
|
+
- [ ] **Step 9: Run the full unit suite**
|
|
517
|
+
|
|
518
|
+
Run: `bun test tests/unit/`
|
|
519
|
+
Expected: PASS, no new failures
|
|
520
|
+
|
|
521
|
+
- [ ] **Step 10: Format, lint and commit**
|
|
522
|
+
|
|
523
|
+
```bash
|
|
524
|
+
bun run format && bun run lint:fix
|
|
525
|
+
git add -A src tests
|
|
526
|
+
git commit -m "feat(mdq): writes return MarkdownDoc so edits chain"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
### Task 4: Selector additions — comment, html, honest regex flags, loud failures
|
|
532
|
+
|
|
533
|
+
Four grammar changes, all additive now that Task 3 pre-fixed the one regex call site.
|
|
534
|
+
|
|
535
|
+
**Files:**
|
|
536
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
537
|
+
- Create: `tests/unit/mdq/selectors.test.ts`
|
|
538
|
+
|
|
539
|
+
**Interfaces:**
|
|
540
|
+
- Consumes: `parseQuery`, `getTokenText`, `selectorToTokenType`, `matchText` from Task 2
|
|
541
|
+
- Produces: `class MdqError extends Error`, `class MdqSelectorError extends MdqError` (with `index: number`), selectors `comment` and `html`
|
|
542
|
+
|
|
543
|
+
- [ ] **Step 1: Write the failing test**
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
// tests/unit/mdq/selectors.test.ts
|
|
547
|
+
import { describe, expect, it } from 'vitest';
|
|
548
|
+
import { MdqSelectorError, mdq } from '../../../src/utils/mdq/query.ts';
|
|
549
|
+
|
|
550
|
+
const doc = `<!-- suite -->
|
|
551
|
+
|
|
552
|
+
## Plan
|
|
553
|
+
|
|
554
|
+
<!-- test priority=critical
|
|
555
|
+
style=bdd -->
|
|
556
|
+
|
|
557
|
+
para with <!-- inline --> comment
|
|
558
|
+
|
|
559
|
+
<div>a block</div>
|
|
560
|
+
|
|
561
|
+
| Method | Path |
|
|
562
|
+
|--------|------|
|
|
563
|
+
| GET | /users |
|
|
564
|
+
`;
|
|
565
|
+
|
|
566
|
+
describe('comment selector', () => {
|
|
567
|
+
it('matches block comments and not other html', () => {
|
|
568
|
+
expect(mdq(doc).query('comment').count()).toBe(2);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('matches on the inner body so anchored patterns work', () => {
|
|
572
|
+
expect(mdq(doc).query('comment(/^test/)').count()).toBe(1);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('exposes the inner body as node text, without the markers', () => {
|
|
576
|
+
expect(mdq(doc).query('comment[0]').nodes()[0].text).toBe('suite');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('keeps newlines inside a multi-line comment', () => {
|
|
580
|
+
expect(mdq(doc).query('comment(/^test/)').nodes()[0].text).toContain('\n');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('does not reach comments inline in a paragraph', () => {
|
|
584
|
+
expect(mdq(doc).query('comment(~"inline")').count()).toBe(0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('matches an exact single-line comment body', () => {
|
|
588
|
+
expect(mdq(doc).query('comment("suite")').count()).toBe(1);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe('html selector', () => {
|
|
593
|
+
it('matches every html block including comments', () => {
|
|
594
|
+
expect(mdq(doc).query('html').count()).toBe(3);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('matches on raw text', () => {
|
|
598
|
+
expect(mdq(doc).query('html(~"<div")').count()).toBe(1);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe('regex flags', () => {
|
|
603
|
+
it('honors an explicit i flag', () => {
|
|
604
|
+
expect(mdq('## Summary\n').query('h2(/^summary/i)').count()).toBe(1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('is case sensitive without the i flag', () => {
|
|
608
|
+
expect(mdq('## Summary\n').query('h2(/^summary/)').count()).toBe(0);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe('table text matching', () => {
|
|
613
|
+
it('matches cell content, not only headers', () => {
|
|
614
|
+
expect(mdq(doc).query('table(~"/users")').count()).toBe(1);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('still matches header content', () => {
|
|
618
|
+
expect(mdq(doc).query('table(~"Method")').count()).toBe(1);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('selector errors', () => {
|
|
623
|
+
it('throws on an unknown selector rather than matching nothing', () => {
|
|
624
|
+
expect(() => mdq(doc).query('secton("A")')).toThrow(MdqSelectorError);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('reports where the problem is', () => {
|
|
628
|
+
try {
|
|
629
|
+
mdq(doc).query('h2("A") secton("B")');
|
|
630
|
+
expect.unreachable();
|
|
631
|
+
} catch (error) {
|
|
632
|
+
expect(error.index).toBe(7);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('accepts a leading dot for jq muscle memory', () => {
|
|
637
|
+
expect(mdq('## A\n').query('.h2').count()).toBe(1);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
643
|
+
|
|
644
|
+
Run: `bun test tests/unit/mdq/selectors.test.ts`
|
|
645
|
+
Expected: FAIL — `MdqSelectorError` is not exported
|
|
646
|
+
|
|
647
|
+
- [ ] **Step 3: Implement the four changes**
|
|
648
|
+
|
|
649
|
+
Error classes, at the top of the class section:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
export class MdqError extends Error {}
|
|
653
|
+
|
|
654
|
+
export class MdqSelectorError extends MdqError {
|
|
655
|
+
index: number;
|
|
656
|
+
|
|
657
|
+
constructor(message: string, index: number) {
|
|
658
|
+
super(message);
|
|
659
|
+
this.name = 'MdqSelectorError';
|
|
660
|
+
this.index = index;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
In `parseQuery`, skip one leading `.` per segment and throw on an unknown identifier. Replace the silent `pos++; continue;`:
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
if (peek() === '.') advance();
|
|
669
|
+
const selectorStart = pos;
|
|
670
|
+
const selector = readIdentifier();
|
|
671
|
+
if (!selector) throw new MdqSelectorError(`Unexpected character "${input[pos]}" in selector`, pos);
|
|
672
|
+
if (!isKnownSelector(selector)) throw new MdqSelectorError(`Unknown selector "${selector}"`, selectorStart);
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Add the vocabulary check and the two new token mappings:
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
const SELECTORS = new Set(['section', 'heading', 'paragraph', 'table', 'list', 'item', 'code', 'blockquote', 'hr', 'html', 'comment']);
|
|
679
|
+
|
|
680
|
+
function isKnownSelector(selector: string): boolean {
|
|
681
|
+
if (/^h[1-6]$/.test(selector)) return true;
|
|
682
|
+
if (/^section[1-6]?$/.test(selector)) return true;
|
|
683
|
+
return SELECTORS.has(selector);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function isCommentToken(token: Token): boolean {
|
|
687
|
+
if (token.type !== 'html') return false;
|
|
688
|
+
return ((token as any).raw || '').trimStart().startsWith('<!--');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function commentBody(token: Token): string {
|
|
692
|
+
const raw = ((token as any).raw || '').trim();
|
|
693
|
+
return raw.replace(/^<!--/, '').replace(/-->$/, '').trim();
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
In `selectorToTokenType`, map `html` to `'html'`. Handle `comment` in `executeSegments` before the generic branch, mirroring how `item` is handled:
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
if (segment.selector === 'comment') {
|
|
701
|
+
let comments = candidates.filter((r) => isCommentToken(r.token));
|
|
702
|
+
if (segment.textMatch) comments = comments.filter((r) => matchText(commentBody(r.token), segment.textMatch!));
|
|
703
|
+
return executeSegments(applyIndexSlice(comments, segment), remaining);
|
|
704
|
+
}
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
In `getTokenText`, return the comment body for comment tokens, the raw for other html, and widen tables:
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
case 'html':
|
|
711
|
+
if (isCommentToken(token)) return commentBody(token);
|
|
712
|
+
return t.raw || '';
|
|
713
|
+
case 'table':
|
|
714
|
+
return [...(t.header || []).map((h: any) => h.text), ...(t.rows || []).flatMap((row: any) => row.map((cell: any) => cell.text))].join(', ');
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
In `parseTextMatcher`, capture the flags instead of discarding them:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
if (peek() === '/') {
|
|
721
|
+
advance();
|
|
722
|
+
let value = '';
|
|
723
|
+
while (pos < input.length && input[pos] !== '/') {
|
|
724
|
+
value += input[pos];
|
|
725
|
+
pos++;
|
|
726
|
+
}
|
|
727
|
+
if (pos < input.length) pos++;
|
|
728
|
+
const flagStart = pos;
|
|
729
|
+
while (pos < input.length && /[gimsuy]/.test(input[pos])) pos++;
|
|
730
|
+
return { mode: 'regex', value, negated, flags: input.slice(flagStart, pos) };
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
In `matchText`, use them:
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
case 'regex':
|
|
738
|
+
result = new RegExp(matcher.value, matcher.flags || '').test(text);
|
|
739
|
+
break;
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Add `flags?: string` to `TextMatcher` in the type block at the end of the file.
|
|
743
|
+
|
|
744
|
+
Rename `meta()` to `nodes()` in the same pass, since the test above calls `nodes()`, and
|
|
745
|
+
keep `meta` as a deprecated alias. The return type gets a name now that it is public:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
nodes(): NodeInfo[] {
|
|
749
|
+
return this.matches.map((range) => {
|
|
750
|
+
const token = range.token as any;
|
|
751
|
+
if (token.type !== 'heading') return { type: token.type, depth: null, text: getTokenText(range.token) };
|
|
752
|
+
return { type: token.type, depth: token.depth, text: getTokenText(range.token) };
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** @deprecated Use nodes(). */
|
|
757
|
+
meta(): NodeInfo[] {
|
|
758
|
+
return this.nodes();
|
|
759
|
+
}
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
and at the end of the file:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
export interface NodeInfo {
|
|
766
|
+
type: string;
|
|
767
|
+
depth: number | null;
|
|
768
|
+
text: string;
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
773
|
+
|
|
774
|
+
Run: `bun test tests/unit/mdq/selectors.test.ts`
|
|
775
|
+
Expected: PASS (13 tests)
|
|
776
|
+
|
|
777
|
+
- [ ] **Step 5: Verify nothing regressed**
|
|
778
|
+
|
|
779
|
+
Run: `bun test tests/unit/`
|
|
780
|
+
Expected: PASS — in particular `query.test.ts` still at 114, since Task 3 already fixed `researcher.ts:316`
|
|
781
|
+
|
|
782
|
+
- [ ] **Step 6: Format, lint and commit**
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
bun run format && bun run lint:fix
|
|
786
|
+
git add -A src/utils/mdq tests/unit/mdq
|
|
787
|
+
git commit -m "feat(mdq): comment and html selectors, honest regex flags, loud selector errors"
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
---
|
|
791
|
+
|
|
792
|
+
### Task 5: Matchers as JS values, plus the sugar layer
|
|
793
|
+
|
|
794
|
+
Removes the hand-escaping wart: `section.name.replace(/"/g, '\\"')` at `researcher/focus.ts:77` exists only because a matcher had to be embedded in a string.
|
|
795
|
+
|
|
796
|
+
**Files:**
|
|
797
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
798
|
+
- Create: `tests/unit/mdq/sugar.test.ts`
|
|
799
|
+
|
|
800
|
+
**Interfaces:**
|
|
801
|
+
- Consumes: `MarkdownDoc`, `Selection` from Task 3; `MdqSelectorError` from Task 4
|
|
802
|
+
- Produces:
|
|
803
|
+
- `type Matcher = string | RegExp | ((text: string) => boolean)`
|
|
804
|
+
- `interface SelectorOptions { depth?: 1 | 2 | 3 | 4 | 5 | 6 }`
|
|
805
|
+
- `query(selector: string, matcher?: Matcher): Selection` on both classes
|
|
806
|
+
- Sugar on both classes: `section` `heading` `paragraph` `table` `list` `item` `code` `blockquote` `comment` `html` `hr`
|
|
807
|
+
- `at(n: number): Selection` and `slice(from?: number, to?: number): Selection` on `Selection`
|
|
808
|
+
|
|
809
|
+
- [ ] **Step 1: Write the failing test**
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
// tests/unit/mdq/sugar.test.ts
|
|
813
|
+
import { describe, expect, it } from 'vitest';
|
|
814
|
+
import { mdq } from '../../../src/utils/mdq/query.ts';
|
|
815
|
+
|
|
816
|
+
const doc = `## API "v2"
|
|
817
|
+
|
|
818
|
+
| Method | Path |
|
|
819
|
+
|--------|------|
|
|
820
|
+
| GET | /users |
|
|
821
|
+
|
|
822
|
+
## Settings
|
|
823
|
+
|
|
824
|
+
- Option A
|
|
825
|
+
- Option B
|
|
826
|
+
|
|
827
|
+
<!-- test id=1 -->
|
|
828
|
+
`;
|
|
829
|
+
|
|
830
|
+
describe('matchers as values', () => {
|
|
831
|
+
it('matches a string exactly', () => {
|
|
832
|
+
expect(mdq(doc).query('h2', 'Settings').count()).toBe(1);
|
|
833
|
+
expect(mdq(doc).query('h2', 'Setting').count()).toBe(0);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('matches a RegExp honoring its flags', () => {
|
|
837
|
+
expect(mdq(doc).query('h2', /^settings$/i).count()).toBe(1);
|
|
838
|
+
expect(mdq(doc).query('h2', /^settings$/).count()).toBe(0);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('matches a predicate', () => {
|
|
842
|
+
expect(mdq(doc).query('h2', (t) => t.startsWith('API')).count()).toBe(1);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('needs no escaping for a value containing quotes', () => {
|
|
846
|
+
expect(mdq(doc).query('h2', 'API "v2"').count()).toBe(1);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
describe('sugar', () => {
|
|
851
|
+
it('is equivalent to the query form', () => {
|
|
852
|
+
expect(mdq(doc).heading('Settings').text()).toBe(mdq(doc).query('heading', 'Settings').text());
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('takes a depth option', () => {
|
|
856
|
+
expect(mdq(doc).section('Settings', { depth: 2 }).text()).toBe(mdq(doc).query('section2("Settings")').text());
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('reads comments', () => {
|
|
860
|
+
expect(mdq(doc).comment(/^test/).count()).toBe(1);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('chains from a Selection', () => {
|
|
864
|
+
expect(mdq(doc).section('API "v2"').table().rows()[0].Path).toBe('/users');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('takes no matcher', () => {
|
|
868
|
+
expect(mdq(doc).table().count()).toBe(1);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe('at and slice', () => {
|
|
873
|
+
it('selects by index like the DSL', () => {
|
|
874
|
+
expect(mdq(doc).heading().at(0).text()).toBe(mdq(doc).query('heading[0]').text());
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('supports a negative index', () => {
|
|
878
|
+
expect(mdq(doc).heading().at(-1).text()).toContain('Settings');
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('returns nothing for an out-of-bounds index', () => {
|
|
882
|
+
expect(mdq(doc).heading().at(99).count()).toBe(0);
|
|
883
|
+
expect(mdq(doc).heading().at(-99).count()).toBe(0);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('slices like the DSL', () => {
|
|
887
|
+
expect(mdq(doc).item().slice(1).count()).toBe(1);
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe('exists', () => {
|
|
892
|
+
it('is true when something matched', () => {
|
|
893
|
+
expect(mdq(doc).heading('Settings').exists()).toBe(true);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('is false when nothing matched', () => {
|
|
897
|
+
expect(mdq(doc).heading('Nope').exists()).toBe(false);
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe('canonical read names', () => {
|
|
902
|
+
it('rows matches the deprecated toJson', () => {
|
|
903
|
+
expect(mdq(doc).table().rows()).toEqual(mdq(doc).table().toJson());
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('preceding matches the deprecated before', () => {
|
|
907
|
+
expect(mdq(doc).heading('Settings').preceding().text()).toBe(mdq(doc).query('heading("Settings")').before().text());
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('following matches the deprecated after', () => {
|
|
911
|
+
expect(mdq(doc).heading('API "v2"').following().text()).toBe(mdq(doc).query('heading(~"API")').after().text());
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('entries matches the deprecated keyValue', () => {
|
|
915
|
+
const block = mdq('> Container: .x\n').query('blockquote[0]');
|
|
916
|
+
expect(block.entries()).toEqual(block.keyValue());
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
922
|
+
|
|
923
|
+
Run: `bun test tests/unit/mdq/sugar.test.ts`
|
|
924
|
+
Expected: FAIL — `mdq(...).heading is not a function`
|
|
925
|
+
|
|
926
|
+
- [ ] **Step 3: Implement matchers and the shared sugar base**
|
|
927
|
+
|
|
928
|
+
A value matcher bypasses the grammar entirely, so it needs its own `TextMatcher` mode:
|
|
929
|
+
|
|
930
|
+
```ts
|
|
931
|
+
function toTextMatcher(matcher: Matcher): TextMatcher {
|
|
932
|
+
if (typeof matcher === 'function') return { mode: 'predicate', value: '', negated: false, predicate: matcher };
|
|
933
|
+
if (matcher instanceof RegExp) return { mode: 'regex', value: matcher.source, negated: false, flags: matcher.flags };
|
|
934
|
+
return { mode: 'exact', value: matcher, negated: false };
|
|
935
|
+
}
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
In `matchText`, add the branch:
|
|
939
|
+
|
|
940
|
+
```ts
|
|
941
|
+
case 'predicate':
|
|
942
|
+
result = matcher.predicate!(text);
|
|
943
|
+
break;
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
Both classes share the sugar through one abstract base. Write the eleven methods out explicitly rather than generating them on the prototype — generated methods lose their types, and a typed surface is the point of a public package:
|
|
947
|
+
|
|
948
|
+
```ts
|
|
949
|
+
abstract class Queryable {
|
|
950
|
+
abstract query(selector: string, matcher?: Matcher): Selection;
|
|
951
|
+
|
|
952
|
+
section(matcher?: Matcher, options?: SelectorOptions): Selection {
|
|
953
|
+
return this.query(`section${options?.depth || ''}`, matcher);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
heading(matcher?: Matcher, options?: SelectorOptions): Selection {
|
|
957
|
+
if (options?.depth) return this.query(`h${options.depth}`, matcher);
|
|
958
|
+
return this.query('heading', matcher);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
paragraph(matcher?: Matcher): Selection {
|
|
962
|
+
return this.query('paragraph', matcher);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
table(matcher?: Matcher): Selection {
|
|
966
|
+
return this.query('table', matcher);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
list(matcher?: Matcher): Selection {
|
|
970
|
+
return this.query('list', matcher);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
item(matcher?: Matcher): Selection {
|
|
974
|
+
return this.query('item', matcher);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
code(matcher?: Matcher): Selection {
|
|
978
|
+
return this.query('code', matcher);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
blockquote(matcher?: Matcher): Selection {
|
|
982
|
+
return this.query('blockquote', matcher);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
comment(matcher?: Matcher): Selection {
|
|
986
|
+
return this.query('comment', matcher);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
html(matcher?: Matcher): Selection {
|
|
990
|
+
return this.query('html', matcher);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
hr(): Selection {
|
|
994
|
+
return this.query('hr');
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
`MarkdownDoc extends Queryable` and `Selection extends Queryable`. Each `query` applies the matcher to the last parsed segment:
|
|
1000
|
+
|
|
1001
|
+
```ts
|
|
1002
|
+
query(selector: string, matcher?: Matcher): Selection {
|
|
1003
|
+
const segments = parseQuery(selector);
|
|
1004
|
+
if (matcher !== undefined && segments.length > 0) segments[segments.length - 1].textMatch = toTextMatcher(matcher);
|
|
1005
|
+
const candidates = expandSectionRanges(buildTokenIndex(this.source));
|
|
1006
|
+
return new Selection(this.source, executeSegments(candidates, segments));
|
|
1007
|
+
}
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
On `Selection`, add:
|
|
1011
|
+
|
|
1012
|
+
```ts
|
|
1013
|
+
at(index: number): Selection {
|
|
1014
|
+
const resolved = index < 0 ? this.matches.length + index : index;
|
|
1015
|
+
if (resolved < 0 || resolved >= this.matches.length) return new Selection(this.source, []);
|
|
1016
|
+
return new Selection(this.source, [this.matches[resolved]]);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
slice(from?: number, to?: number): Selection {
|
|
1020
|
+
return new Selection(this.source, this.matches.slice(from, to));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
exists(): boolean {
|
|
1024
|
+
return this.matches.length > 0;
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
At the end of the file add `Matcher`, `SelectorOptions`, `predicate?: (text: string) => boolean` on `TextMatcher`, and widen its `mode` to include `'predicate'`.
|
|
1029
|
+
|
|
1030
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1031
|
+
|
|
1032
|
+
Run: `bun test tests/unit/mdq/sugar.test.ts`
|
|
1033
|
+
Expected: PASS (21 tests)
|
|
1034
|
+
|
|
1035
|
+
The `canonical read names` block fails until Step 5 adds the renames — that is expected.
|
|
1036
|
+
Run Step 5 before treating those four as real failures.
|
|
1037
|
+
|
|
1038
|
+
- [ ] **Step 5: Add the read renames and their deprecated aliases**
|
|
1039
|
+
|
|
1040
|
+
Canonical names, with the old ones kept and marked:
|
|
1041
|
+
|
|
1042
|
+
| Canonical | Deprecated alias |
|
|
1043
|
+
|---|---|
|
|
1044
|
+
| `text()` | `get()` |
|
|
1045
|
+
| `rows()` | `toJson()` |
|
|
1046
|
+
| `entries()` | `keyValue()` |
|
|
1047
|
+
| `nodes()` | `meta()` (already added in Task 4) |
|
|
1048
|
+
| `preceding()` | `before()` |
|
|
1049
|
+
| `following()` | `after()` |
|
|
1050
|
+
|
|
1051
|
+
Each alias is one line, for example:
|
|
1052
|
+
|
|
1053
|
+
```ts
|
|
1054
|
+
/** @deprecated Use rows(). */
|
|
1055
|
+
toJson(): Record<string, string>[] {
|
|
1056
|
+
return this.rows();
|
|
1057
|
+
}
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
Run: `bun test tests/unit/`
|
|
1061
|
+
Expected: PASS — the ported suite still calls the deprecated names and must keep working
|
|
1062
|
+
|
|
1063
|
+
- [ ] **Step 6: Format, lint and commit**
|
|
1064
|
+
|
|
1065
|
+
```bash
|
|
1066
|
+
bun run format && bun run lint:fix
|
|
1067
|
+
git add -A src/utils/mdq tests/unit/mdq
|
|
1068
|
+
git commit -m "feat(mdq): value matchers, sugar layer, at/slice, canonical read names"
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
|
|
1073
|
+
### Task 6: edit.ts — remove and insert, with the whitespace invariant
|
|
1074
|
+
|
|
1075
|
+
The first task in `edit.ts`, and the one most likely to produce subtly wrong output. The governing rule, from the spec:
|
|
1076
|
+
|
|
1077
|
+
> **mdq never leaves zero blank lines between blocks, and never more than one.**
|
|
1078
|
+
|
|
1079
|
+
This matters because `marked` separators are uneven: a `heading` raw is `"# A\n\n"` with its blank line baked in, while a `paragraph` raw is `"para"` with no newline at all and a sibling `space` token holding the `"\n\n"`. Task 1 recorded that sibling as `range.trailing`; every verb here uses it.
|
|
1080
|
+
|
|
1081
|
+
**Files:**
|
|
1082
|
+
- Create: `src/utils/mdq/edit.ts`
|
|
1083
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
1084
|
+
- Create: `tests/unit/mdq/edit.test.ts`
|
|
1085
|
+
|
|
1086
|
+
**Interfaces:**
|
|
1087
|
+
- Consumes: `MatchedRange` (type-only) from Task 1; `MarkdownDoc`, `Selection` from Task 3
|
|
1088
|
+
- Produces, all in `edit.ts`:
|
|
1089
|
+
- `spliceRanges(source: string, ranges: MatchedRange[], render: (range: MatchedRange, index: number) => string): string`
|
|
1090
|
+
- `removeRanges(source: string, ranges: MatchedRange[]): string`
|
|
1091
|
+
- `insertAt(source: string, offset: number, markdown: string): string`
|
|
1092
|
+
- `blockEnd(range: MatchedRange): number` and `blockStart(range: MatchedRange): number`
|
|
1093
|
+
- Produces, on `Selection`: `remove()`, `insertBefore(md)`, `insertAfter(md)`, `prepend(md)`, `append(md)`
|
|
1094
|
+
- Produces, on `MarkdownDoc`: `append(md)`, `prepend(md)`
|
|
1095
|
+
|
|
1096
|
+
- [ ] **Step 1: Write the failing test**
|
|
1097
|
+
|
|
1098
|
+
```ts
|
|
1099
|
+
// tests/unit/mdq/edit.test.ts
|
|
1100
|
+
import { describe, expect, it } from 'vitest';
|
|
1101
|
+
import { mdq } from '../../../src/utils/mdq/query.ts';
|
|
1102
|
+
|
|
1103
|
+
describe('remove', () => {
|
|
1104
|
+
it('takes a paragraph and its separator, leaving no crater', () => {
|
|
1105
|
+
expect(mdq('# A\n\nfirst\n\nsecond\n').query('paragraph("first")').remove().toString()).toBe('# A\n\nsecond\n');
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('takes a heading with its baked-in separator', () => {
|
|
1109
|
+
expect(mdq('# A\n\n## B\n\ntext\n').query('h2').remove().toString()).toBe('# A\n\ntext\n');
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('takes the leading separator when the node is last', () => {
|
|
1113
|
+
expect(mdq('# A\n\nlast\n').query('paragraph').remove().toString()).toBe('# A\n');
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('removes a whole section including its children', () => {
|
|
1117
|
+
expect(mdq('## A\n\nx\n\n## B\n\ny\n').query('section("A")').remove().toString()).toBe('## B\n\ny\n');
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('removes every match', () => {
|
|
1121
|
+
expect(mdq('# T\n\n```js\na\n```\n\ntext\n\n```js\nb\n```\n').query('code').remove().toString()).toBe('# T\n\ntext\n');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it('returns the document unchanged when nothing matches', () => {
|
|
1125
|
+
const src = '# A\n\ntext\n';
|
|
1126
|
+
expect(mdq(src).query('h5').remove().toString()).toBe(src);
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
describe('insertBefore and insertAfter', () => {
|
|
1131
|
+
it('inserts a sibling before a node', () => {
|
|
1132
|
+
expect(mdq('## B\n\ntext\n').query('h2').insertBefore('## A\n').toString()).toBe('## A\n\n## B\n\ntext\n');
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('inserts a sibling after a node', () => {
|
|
1136
|
+
expect(mdq('## A\n\ntext\n').query('h2').insertAfter('## B\n').toString()).toBe('## A\n\n## B\n\ntext\n');
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it('normalizes an insert that already ends with blank lines', () => {
|
|
1140
|
+
expect(mdq('## A\n\ntext\n').query('h2').insertAfter('## B\n\n\n\n').toString()).toBe('## A\n\n## B\n\ntext\n');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('normalizes an insert with no trailing newline', () => {
|
|
1144
|
+
expect(mdq('## A\n\ntext\n').query('h2').insertAfter('## B').toString()).toBe('## A\n\n## B\n\ntext\n');
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('accepts a MarkdownDoc', () => {
|
|
1148
|
+
const fragment = mdq('## B\n');
|
|
1149
|
+
expect(mdq('## A\n\ntext\n').query('h2').insertAfter(fragment).toString()).toContain('## B');
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
describe('prepend and append on a section', () => {
|
|
1154
|
+
const src = '## A\n\nfirst\n\n## B\n\nother\n';
|
|
1155
|
+
|
|
1156
|
+
it('appends inside the section, before the next same-depth heading', () => {
|
|
1157
|
+
expect(mdq(src).query('section("A")').append('last\n').toString()).toBe('## A\n\nfirst\n\nlast\n\n## B\n\nother\n');
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('prepends directly after the section heading', () => {
|
|
1161
|
+
expect(mdq(src).query('section("A")').prepend('intro\n').toString()).toBe('## A\n\nintro\n\nfirst\n\n## B\n\nother\n');
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it('appends at the end of the document when the section is last', () => {
|
|
1165
|
+
expect(mdq(src).query('section("B")').append('tail\n').toString()).toBe('## A\n\nfirst\n\n## B\n\nother\n\ntail\n');
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it('throws when applied to a leaf node', () => {
|
|
1169
|
+
expect(() => mdq(src).query('paragraph[0]').append('x\n')).toThrow();
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
describe('document-level append and prepend', () => {
|
|
1174
|
+
it('appends a block at the end', () => {
|
|
1175
|
+
expect(mdq('# A\n\ntext\n').append('## New\n').toString()).toBe('# A\n\ntext\n\n## New\n');
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('prepends a block at the start', () => {
|
|
1179
|
+
expect(mdq('# A\n\ntext\n').prepend('> note\n').toString()).toBe('> note\n\n# A\n\ntext\n');
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it('prepends after frontmatter, never before it', () => {
|
|
1183
|
+
const out = mdq('---\nurl: /x\n---\n\n# A\n').prepend('> note\n').toString();
|
|
1184
|
+
expect(out.startsWith('---\nurl: /x\n---\n')).toBe(true);
|
|
1185
|
+
expect(out).toContain('> note');
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
describe('chained edits', () => {
|
|
1190
|
+
it('composes several writes in one expression', () => {
|
|
1191
|
+
const out = mdq('## A\n\nfirst\n\n## B\n\nother\n')
|
|
1192
|
+
.query('section("A")')
|
|
1193
|
+
.append('added\n')
|
|
1194
|
+
.query('paragraph("other")')
|
|
1195
|
+
.remove()
|
|
1196
|
+
.toString();
|
|
1197
|
+
expect(out).toBe('## A\n\nfirst\n\nadded\n\n## B\n');
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1203
|
+
|
|
1204
|
+
Run: `bun test tests/unit/mdq/edit.test.ts`
|
|
1205
|
+
Expected: FAIL — `.remove is not a function`
|
|
1206
|
+
|
|
1207
|
+
- [ ] **Step 3: Write edit.ts**
|
|
1208
|
+
|
|
1209
|
+
```ts
|
|
1210
|
+
import type { MatchedRange } from './query.ts';
|
|
1211
|
+
|
|
1212
|
+
export function blockStart(range: MatchedRange): number {
|
|
1213
|
+
return range.start;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function blockEnd(range: MatchedRange): number {
|
|
1217
|
+
if (range.trailing) return range.trailing.start + range.trailing.length;
|
|
1218
|
+
return range.start + range.length;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
export function normalizeBlock(markdown: string): string {
|
|
1222
|
+
return `${markdown.replace(/\s+$/, '')}\n`;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export function spliceRanges(source: string, ranges: MatchedRange[], render: (range: MatchedRange, index: number) => string): string {
|
|
1226
|
+
const ordered = dedupeRanges(ranges);
|
|
1227
|
+
const rendered = ordered.map(render);
|
|
1228
|
+
let result = source;
|
|
1229
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
1230
|
+
const range = ordered[i];
|
|
1231
|
+
result = result.slice(0, range.start) + rendered[i] + result.slice(range.start + range.length);
|
|
1232
|
+
}
|
|
1233
|
+
return result;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
export function removeRanges(source: string, ranges: MatchedRange[]): string {
|
|
1237
|
+
const ordered = dedupeRanges(ranges);
|
|
1238
|
+
let result = source;
|
|
1239
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
1240
|
+
const range = ordered[i];
|
|
1241
|
+
const end = blockEnd(range);
|
|
1242
|
+
let start = range.start;
|
|
1243
|
+
if (!range.trailing) start = trimPrecedingBlankLine(result, start);
|
|
1244
|
+
result = result.slice(0, start) + result.slice(end);
|
|
1245
|
+
}
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function insertAt(source: string, offset: number, markdown: string): string {
|
|
1250
|
+
const block = normalizeBlock(markdown);
|
|
1251
|
+
const before = source.slice(0, offset);
|
|
1252
|
+
const after = source.slice(offset);
|
|
1253
|
+
if (!after) return `${before}${before.endsWith('\n') ? '' : '\n'}\n${block}`.replace(/\n{3,}/g, '\n\n');
|
|
1254
|
+
return `${before}${block}\n${after}`.replace(/\n{3,}/g, '\n\n');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function dedupeRanges(ranges: MatchedRange[]): MatchedRange[] {
|
|
1258
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
1259
|
+
const kept: MatchedRange[] = [];
|
|
1260
|
+
let lastEnd = -1;
|
|
1261
|
+
for (const range of sorted) {
|
|
1262
|
+
if (range.start < lastEnd) continue;
|
|
1263
|
+
kept.push(range);
|
|
1264
|
+
lastEnd = range.start + range.length;
|
|
1265
|
+
}
|
|
1266
|
+
return kept;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function trimPrecedingBlankLine(source: string, start: number): number {
|
|
1270
|
+
let cursor = start;
|
|
1271
|
+
while (cursor > 0 && source[cursor - 1] === '\n') cursor--;
|
|
1272
|
+
if (cursor === 0) return 0;
|
|
1273
|
+
return cursor + 1;
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
Note `insertAt` collapses any run of three or more newlines to exactly two. That single rule is what enforces the invariant across every insert path, rather than each verb reasoning about separators itself.
|
|
1278
|
+
|
|
1279
|
+
- [ ] **Step 4: Wire the verbs onto Selection and MarkdownDoc**
|
|
1280
|
+
|
|
1281
|
+
On `Selection`, four public verbs delegating to one private helper:
|
|
1282
|
+
|
|
1283
|
+
```ts
|
|
1284
|
+
remove(): MarkdownDoc {
|
|
1285
|
+
return new MarkdownDoc(removeRanges(this.source, this.matches));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
insertBefore(markdown: Markdown): MarkdownDoc {
|
|
1289
|
+
return this.insertEach((range) => blockStart(range), markdown);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
insertAfter(markdown: Markdown): MarkdownDoc {
|
|
1293
|
+
return this.insertEach((range) => blockEnd(range), markdown);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
prepend(markdown: Markdown): MarkdownDoc {
|
|
1297
|
+
return this.insertEach((range) => this.containerStart(range), markdown);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
append(markdown: Markdown): MarkdownDoc {
|
|
1301
|
+
return this.insertEach((range) => this.containerEnd(range), markdown);
|
|
1302
|
+
}
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
`insertEach`, `containerStart` and `containerEnd` are private and placed after the public
|
|
1306
|
+
methods. Inserts run back-to-front so earlier offsets stay valid:
|
|
1307
|
+
|
|
1308
|
+
```ts
|
|
1309
|
+
private insertEach(offsetOf: (range: MatchedRange) => number, markdown: Markdown): MarkdownDoc {
|
|
1310
|
+
const offsets = this.matches.map(offsetOf).sort((a, b) => a - b);
|
|
1311
|
+
let result = this.source;
|
|
1312
|
+
for (let i = offsets.length - 1; i >= 0; i--) {
|
|
1313
|
+
result = insertAt(result, offsets[i], String(markdown));
|
|
1314
|
+
}
|
|
1315
|
+
return new MarkdownDoc(result);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private containerStart(range: MatchedRange): number {
|
|
1319
|
+
if (!range.innerTokens) throw new MdqOperationError(`prepend needs a section or list, got ${range.token.type}`);
|
|
1320
|
+
return range.start + ((range.token as any).raw || '').length;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private containerEnd(range: MatchedRange): number {
|
|
1324
|
+
if (!range.innerTokens) throw new MdqOperationError(`append needs a section or list, got ${range.token.type}`);
|
|
1325
|
+
const last = range.innerTokens[range.innerTokens.length - 1];
|
|
1326
|
+
if (!last) return this.containerStart(range);
|
|
1327
|
+
return blockEnd(last);
|
|
1328
|
+
}
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
`containerStart` and `containerEnd` throw eagerly, which is why the "throws when applied to
|
|
1332
|
+
a leaf node" test asserts on the `query(...).append(...)` call itself rather than on
|
|
1333
|
+
`.toString()`.
|
|
1334
|
+
|
|
1335
|
+
On `MarkdownDoc`:
|
|
1336
|
+
|
|
1337
|
+
```ts
|
|
1338
|
+
append(markdown: Markdown): MarkdownDoc {
|
|
1339
|
+
return new MarkdownDoc(insertAt(this.source, this.source.length, String(markdown)));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
prepend(markdown: Markdown): MarkdownDoc {
|
|
1343
|
+
return new MarkdownDoc(insertAt(this.source, splitFrontmatter(this.source).offset, String(markdown)));
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
Add `MdqOperationError`:
|
|
1348
|
+
|
|
1349
|
+
```ts
|
|
1350
|
+
export class MdqOperationError extends MdqError {
|
|
1351
|
+
constructor(message: string) {
|
|
1352
|
+
super(message);
|
|
1353
|
+
this.name = 'MdqOperationError';
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
1359
|
+
|
|
1360
|
+
Run: `bun test tests/unit/mdq/edit.test.ts`
|
|
1361
|
+
Expected: PASS (19 tests)
|
|
1362
|
+
|
|
1363
|
+
If a whitespace assertion fails, print the actual output with `JSON.stringify` before changing anything — the difference is almost always one newline, and guessing at it will break a different case.
|
|
1364
|
+
|
|
1365
|
+
- [ ] **Step 6: Run the whole suite and commit**
|
|
1366
|
+
|
|
1367
|
+
```bash
|
|
1368
|
+
bun test tests/unit/
|
|
1369
|
+
bun run format && bun run lint:fix
|
|
1370
|
+
git add -A src/utils/mdq tests/unit/mdq
|
|
1371
|
+
git commit -m "feat(mdq): remove and insert verbs with whitespace normalization"
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
### Task 7: Structural inserts — addRow and addItem
|
|
1377
|
+
|
|
1378
|
+
**Files:**
|
|
1379
|
+
- Modify: `src/utils/mdq/edit.ts`
|
|
1380
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
1381
|
+
- Create: `tests/unit/mdq/structural.test.ts`
|
|
1382
|
+
|
|
1383
|
+
**Interfaces:**
|
|
1384
|
+
- Consumes: `spliceRanges` from Task 6; `MdqOperationError` from Task 6
|
|
1385
|
+
- Produces in `edit.ts`: `renderTable(headers: string[], rows: string[][], align: (string | null)[]): string`, `renderItem(listRaw: string, text: string): string`
|
|
1386
|
+
- Produces on `Selection`: `addRow(row: Record<string, string>): MarkdownDoc`, `addItem(text: string): MarkdownDoc`
|
|
1387
|
+
|
|
1388
|
+
- [ ] **Step 1: Write the failing test**
|
|
1389
|
+
|
|
1390
|
+
```ts
|
|
1391
|
+
// tests/unit/mdq/structural.test.ts
|
|
1392
|
+
import { describe, expect, it } from 'vitest';
|
|
1393
|
+
import { mdq } from '../../../src/utils/mdq/query.ts';
|
|
1394
|
+
|
|
1395
|
+
const table = `| Method | Path |
|
|
1396
|
+
|--------|------|
|
|
1397
|
+
| GET | /users |
|
|
1398
|
+
`;
|
|
1399
|
+
|
|
1400
|
+
describe('addRow', () => {
|
|
1401
|
+
it('appends a row and re-aligns every column', () => {
|
|
1402
|
+
expect(mdq(table).query('table').addRow({ Method: 'POST', Path: '/sessions' }).toString()).toBe(
|
|
1403
|
+
['| Method | Path |', '| ------ | --------- |', '| GET | /users |', '| POST | /sessions |', ''].join('\n')
|
|
1404
|
+
);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
it('round-trips through rows()', () => {
|
|
1408
|
+
const out = mdq(table).query('table').addRow({ Method: 'POST', Path: '/sessions' });
|
|
1409
|
+
expect(mdq(out).query('table').rows()).toEqual([
|
|
1410
|
+
{ Method: 'GET', Path: '/users' },
|
|
1411
|
+
{ Method: 'POST', Path: '/sessions' },
|
|
1412
|
+
]);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('leaves a column blank when the object omits it', () => {
|
|
1416
|
+
const out = mdq(table).query('table').addRow({ Method: 'PUT' });
|
|
1417
|
+
expect(mdq(out).query('table').rows()[1]).toEqual({ Method: 'PUT', Path: '' });
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('ignores keys that are not columns', () => {
|
|
1421
|
+
const out = mdq(table).query('table').addRow({ Method: 'PUT', Nope: 'x' });
|
|
1422
|
+
expect(mdq(out).query('table').rows()[1].Method).toBe('PUT');
|
|
1423
|
+
expect(mdq(out).query('table').text()).not.toContain('Nope');
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it('throws on a non-table node', () => {
|
|
1427
|
+
expect(() => mdq('para\n').query('paragraph').addRow({ a: 'b' })).toThrow();
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
describe('addItem', () => {
|
|
1432
|
+
it('copies a dash marker', () => {
|
|
1433
|
+
expect(mdq('- a\n- b\n').query('list').addItem('c').toString()).toBe('- a\n- b\n- c\n');
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('copies a star marker', () => {
|
|
1437
|
+
expect(mdq('* a\n* b\n').query('list').addItem('c').toString()).toBe('* a\n* b\n* c\n');
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it('continues an ordered list', () => {
|
|
1441
|
+
expect(mdq('1. a\n2. b\n').query('list').addItem('c').toString()).toBe('1. a\n2. b\n3. c\n');
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('preserves indentation', () => {
|
|
1445
|
+
expect(mdq(' - a\n - b\n').query('list').addItem('c').toString()).toBe(' - a\n - b\n - c\n');
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it('throws on a non-list node', () => {
|
|
1449
|
+
expect(() => mdq('para\n').query('paragraph').addItem('x')).toThrow();
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1455
|
+
|
|
1456
|
+
Run: `bun test tests/unit/mdq/structural.test.ts`
|
|
1457
|
+
Expected: FAIL — `.addRow is not a function`
|
|
1458
|
+
|
|
1459
|
+
- [ ] **Step 3: Add the renderers to edit.ts**
|
|
1460
|
+
|
|
1461
|
+
```ts
|
|
1462
|
+
export function renderTable(headers: string[], rows: string[][], align: (string | null)[]): string {
|
|
1463
|
+
const widths = headers.map((header, index) => Math.max(header.length, 3, ...rows.map((row) => (row[index] || '').length)));
|
|
1464
|
+
const line = (cells: string[]) => `| ${cells.map((cell, index) => (cell || '').padEnd(widths[index])).join(' | ')} |`;
|
|
1465
|
+
const divider = `| ${widths.map((width, index) => dashes(align[index], width)).join(' | ')} |`;
|
|
1466
|
+
return `${[line(headers), divider, ...rows.map(line)].join('\n')}\n`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
export function renderItem(listRaw: string, text: string): string {
|
|
1470
|
+
const lines = listRaw.split('\n').filter((line) => line.trim());
|
|
1471
|
+
const last = lines[lines.length - 1] || '- x';
|
|
1472
|
+
const match = last.match(/^(\s*)(\d+)([.)])\s/);
|
|
1473
|
+
if (match) return `${match[1]}${Number.parseInt(match[2], 10) + 1}${match[3]} ${text}`;
|
|
1474
|
+
const bullet = last.match(/^(\s*)([-*+])\s/);
|
|
1475
|
+
if (!bullet) return `- ${text}`;
|
|
1476
|
+
return `${bullet[1]}${bullet[2]} ${text}`;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function dashes(alignment: string | null, width: number): string {
|
|
1480
|
+
if (alignment === 'center') return `:${'-'.repeat(Math.max(width - 2, 1))}:`;
|
|
1481
|
+
if (alignment === 'left') return `:${'-'.repeat(Math.max(width - 1, 1))}`;
|
|
1482
|
+
if (alignment === 'right') return `${'-'.repeat(Math.max(width - 1, 1))}:`;
|
|
1483
|
+
return '-'.repeat(width);
|
|
1484
|
+
}
|
|
1485
|
+
```
|
|
1486
|
+
|
|
1487
|
+
- [ ] **Step 4: Wire the verbs onto Selection**
|
|
1488
|
+
|
|
1489
|
+
```ts
|
|
1490
|
+
addRow(row: Record<string, string>): MarkdownDoc {
|
|
1491
|
+
return new MarkdownDoc(
|
|
1492
|
+
spliceRanges(this.source, this.matches, (range) => {
|
|
1493
|
+
if (range.token.type !== 'table') throw new MdqOperationError(`addRow needs a table, got ${range.token.type}`);
|
|
1494
|
+
const table = range.token as Tokens.Table;
|
|
1495
|
+
const headers = table.header.map((cell) => cell.text);
|
|
1496
|
+
const existing = table.rows.map((cells) => headers.map((_, index) => cells[index]?.text || ''));
|
|
1497
|
+
return renderTable(headers, [...existing, headers.map((header) => row[header] || '')], table.align);
|
|
1498
|
+
})
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
addItem(text: string): MarkdownDoc {
|
|
1503
|
+
return new MarkdownDoc(
|
|
1504
|
+
spliceRanges(this.source, this.matches, (range) => {
|
|
1505
|
+
if (range.token.type !== 'list') throw new MdqOperationError(`addItem needs a list, got ${range.token.type}`);
|
|
1506
|
+
const raw = ((range.token as any).raw || '').replace(/\s+$/, '');
|
|
1507
|
+
return `${raw}\n${renderItem(raw, text)}\n`;
|
|
1508
|
+
})
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
```
|
|
1512
|
+
|
|
1513
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
1514
|
+
|
|
1515
|
+
Run: `bun test tests/unit/mdq/structural.test.ts`
|
|
1516
|
+
Expected: PASS (10 tests)
|
|
1517
|
+
|
|
1518
|
+
- [ ] **Step 6: Run the whole suite and commit**
|
|
1519
|
+
|
|
1520
|
+
```bash
|
|
1521
|
+
bun test tests/unit/
|
|
1522
|
+
bun run format && bun run lint:fix
|
|
1523
|
+
git add -A src/utils/mdq tests/unit/mdq
|
|
1524
|
+
git commit -m "feat(mdq): addRow and addItem structural inserts"
|
|
1525
|
+
```
|
|
1526
|
+
|
|
1527
|
+
---
|
|
1528
|
+
|
|
1529
|
+
### Task 8: setEntry and the frontmatter API
|
|
1530
|
+
|
|
1531
|
+
**Files:**
|
|
1532
|
+
- Modify: `src/utils/mdq/edit.ts`
|
|
1533
|
+
- Modify: `src/utils/mdq/query.ts`
|
|
1534
|
+
- Modify: `tests/unit/mdq/frontmatter.test.ts`
|
|
1535
|
+
|
|
1536
|
+
**Interfaces:**
|
|
1537
|
+
- Consumes: `splitFrontmatter` from Task 1; `spliceRanges` from Task 6
|
|
1538
|
+
- Produces in `edit.ts`: `rewriteEntries(tokenText: string, isBlockquote: boolean, key: string, value: string | null): string`, `writeFrontmatter(source: string, key: string, value: unknown): string`
|
|
1539
|
+
- Produces on `Selection`: `entries()`, `setEntry(key, value)`
|
|
1540
|
+
- Produces on `MarkdownDoc`: `frontmatter(): Record<string, unknown>`, `setFrontmatter(key: string, value: unknown): MarkdownDoc`
|
|
1541
|
+
|
|
1542
|
+
Reading and writing both go through `yaml`'s **Document API** (`YAML.parseDocument`), never `parse`/`stringify`. That is what preserves comments through a write — verified behaviour, not an assumption.
|
|
1543
|
+
|
|
1544
|
+
- [ ] **Step 1: Write the failing test**
|
|
1545
|
+
|
|
1546
|
+
```ts
|
|
1547
|
+
// append to tests/unit/mdq/frontmatter.test.ts
|
|
1548
|
+
import { mdq } from '../../../src/utils/mdq/query.ts';
|
|
1549
|
+
|
|
1550
|
+
describe('frontmatter API', () => {
|
|
1551
|
+
const src = '---\n# a leading comment\nurl: /login\nwait: 1000\ntags:\n - auth\n - smoke\n---\n\n# Title\n';
|
|
1552
|
+
|
|
1553
|
+
it('reads typed scalars, lists and nested maps', () => {
|
|
1554
|
+
expect(mdq(src).frontmatter()).toEqual({ url: '/login', wait: 1000, tags: ['auth', 'smoke'] });
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('returns an empty object when there is no frontmatter', () => {
|
|
1558
|
+
expect(mdq('# Title\n').frontmatter()).toEqual({});
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it('updates a key in place', () => {
|
|
1562
|
+
expect(mdq(src).setFrontmatter('wait', 2000).frontmatter().wait).toBe(2000);
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
it('preserves comments through a write', () => {
|
|
1566
|
+
expect(mdq(src).setFrontmatter('wait', 2000).toString()).toContain('# a leading comment');
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
it('preserves the body exactly', () => {
|
|
1570
|
+
expect(mdq(src).setFrontmatter('wait', 2000).toString()).toContain('# Title');
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it('adds a key that was not there', () => {
|
|
1574
|
+
expect(mdq(src).setFrontmatter('region', 'sidebar').frontmatter().region).toBe('sidebar');
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('deletes a key when the value is null', () => {
|
|
1578
|
+
expect(mdq(src).setFrontmatter('wait', null).frontmatter().wait).toBeUndefined();
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
it('creates a frontmatter block on a document that has none', () => {
|
|
1582
|
+
const out = mdq('# Title\n').setFrontmatter('url', '/x');
|
|
1583
|
+
expect(out.frontmatter()).toEqual({ url: '/x' });
|
|
1584
|
+
expect(out.toString()).toContain('# Title');
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it('keeps body queries blind to frontmatter after a write', () => {
|
|
1588
|
+
expect(mdq(src).setFrontmatter('wait', 2000).query('h2').count()).toBe(0);
|
|
1589
|
+
});
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
describe('entries and setEntry', () => {
|
|
1593
|
+
const block = "## S\n\n> Container: '.old'\n> Pagination: controls\n\ntext\n";
|
|
1594
|
+
|
|
1595
|
+
it('reads every entry of a blockquote without its markers', () => {
|
|
1596
|
+
expect(mdq(block).query('blockquote[0]').entries()).toEqual({ container: "'.old'", pagination: 'controls' });
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it('replaces an entry in place and keeps the others', () => {
|
|
1600
|
+
expect(mdq(block).query('blockquote[0]').setEntry('Container', "'.new'").toString()).toBe("## S\n\n> Container: '.new'\n> Pagination: controls\n\ntext\n");
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it('appends an entry that was not there', () => {
|
|
1604
|
+
const out = mdq(block).query('blockquote[0]').setEntry('Region', 'sidebar');
|
|
1605
|
+
expect(mdq(out).query('blockquote[0]').entries().region).toBe('sidebar');
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
it('removes an entry when the value is null', () => {
|
|
1609
|
+
const out = mdq(block).query('blockquote[0]').setEntry('Pagination', null);
|
|
1610
|
+
expect(mdq(out).query('blockquote[0]').entries()).toEqual({ container: "'.old'" });
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1616
|
+
|
|
1617
|
+
Run: `bun test tests/unit/mdq/frontmatter.test.ts`
|
|
1618
|
+
Expected: FAIL — `.frontmatter is not a function`
|
|
1619
|
+
|
|
1620
|
+
- [ ] **Step 3: Implement in edit.ts**
|
|
1621
|
+
|
|
1622
|
+
```ts
|
|
1623
|
+
import YAML from 'yaml';
|
|
1624
|
+
|
|
1625
|
+
export function writeFrontmatter(source: string, key: string, value: unknown): string {
|
|
1626
|
+
const { raw, body, offset } = splitFrontmatter(source);
|
|
1627
|
+
const document = YAML.parseDocument(raw || '');
|
|
1628
|
+
if (value === null) document.delete(key);
|
|
1629
|
+
if (value !== null) document.set(key, value);
|
|
1630
|
+
const rendered = document.toString().replace(/\s+$/, '');
|
|
1631
|
+
if (!offset) return `---\n${rendered}\n---\n\n${source}`;
|
|
1632
|
+
return `---\n${rendered}\n---\n${body}`;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
export function readFrontmatter(source: string): Record<string, unknown> {
|
|
1636
|
+
const { raw } = splitFrontmatter(source);
|
|
1637
|
+
if (!raw) return {};
|
|
1638
|
+
return (YAML.parseDocument(raw).toJS() as Record<string, unknown>) || {};
|
|
1639
|
+
}
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
`splitFrontmatter` already lives in `edit.ts` from Task 1, so these functions sit beside it.
|
|
1643
|
+
|
|
1644
|
+
`rewriteEntries` is the existing `setKeyValue` body, lifted out of the class:
|
|
1645
|
+
|
|
1646
|
+
```ts
|
|
1647
|
+
export function rewriteEntries(tokenText: string, isBlockquote: boolean, key: string, value: string | null): string {
|
|
1648
|
+
const lines = tokenText
|
|
1649
|
+
.split('\n')
|
|
1650
|
+
.map((line) => line.trim())
|
|
1651
|
+
.filter(Boolean);
|
|
1652
|
+
|
|
1653
|
+
const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
|
|
1654
|
+
if (index < 0 && value) lines.push(`${key}: ${value}`);
|
|
1655
|
+
if (index >= 0 && value) lines[index] = `${key}: ${value}`;
|
|
1656
|
+
if (index >= 0 && !value) lines.splice(index, 1);
|
|
1657
|
+
|
|
1658
|
+
if (!isBlockquote) return lines.join('\n');
|
|
1659
|
+
return lines.map((line) => `> ${line}`).join('\n');
|
|
1660
|
+
}
|
|
1661
|
+
```
|
|
1662
|
+
|
|
1663
|
+
`entryKey` moves to `edit.ts` alongside it.
|
|
1664
|
+
|
|
1665
|
+
- [ ] **Step 4: Wire onto the classes**
|
|
1666
|
+
|
|
1667
|
+
```ts
|
|
1668
|
+
frontmatter(): Record<string, unknown> {
|
|
1669
|
+
return readFrontmatter(this.source);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
setFrontmatter(key: string, value: unknown): MarkdownDoc {
|
|
1673
|
+
return new MarkdownDoc(writeFrontmatter(this.source, key, value));
|
|
1674
|
+
}
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
```ts
|
|
1678
|
+
setEntry(key: string, value: string | null): MarkdownDoc {
|
|
1679
|
+
return new MarkdownDoc(spliceRanges(this.source, this.matches, (range) => rewriteEntries(getTokenText(range.token), range.token.type === 'blockquote', key, value)));
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** @deprecated Use setEntry(). */
|
|
1683
|
+
setKeyValue(key: string, value: string | null): MarkdownDoc {
|
|
1684
|
+
return this.setEntry(key, value);
|
|
1685
|
+
}
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
1689
|
+
|
|
1690
|
+
Run: `bun test tests/unit/mdq/frontmatter.test.ts`
|
|
1691
|
+
Expected: PASS (21 tests)
|
|
1692
|
+
|
|
1693
|
+
- [ ] **Step 6: Confirm knowledge and experience files now parse correctly**
|
|
1694
|
+
|
|
1695
|
+
This is the real-world check that motivated the feature:
|
|
1696
|
+
|
|
1697
|
+
```bash
|
|
1698
|
+
bun -e '
|
|
1699
|
+
import { mdq } from "./src/utils/mdq/query.ts";
|
|
1700
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
1701
|
+
for (const dir of ["knowledge", "experience"]) {
|
|
1702
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).slice(0, 5)) {
|
|
1703
|
+
const doc = mdq(readFileSync(`${dir}/${file}`, "utf8"));
|
|
1704
|
+
console.log(file, JSON.stringify(doc.frontmatter()), "headings:", doc.query("heading").count());
|
|
1705
|
+
}
|
|
1706
|
+
}'
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
Expected: frontmatter parsed as an object on each file, and **no heading whose text looks like `url: ...`**. A `url:` heading means frontmatter is leaking into the token index.
|
|
1710
|
+
|
|
1711
|
+
If either directory is empty, skip this step and note it.
|
|
1712
|
+
|
|
1713
|
+
- [ ] **Step 7: Run the whole suite and commit**
|
|
1714
|
+
|
|
1715
|
+
```bash
|
|
1716
|
+
bun test tests/unit/
|
|
1717
|
+
bun run format && bun run lint:fix
|
|
1718
|
+
git add -A src/utils/mdq tests/unit/mdq
|
|
1719
|
+
git commit -m "feat(mdq): frontmatter read/write via yaml Document API, setEntry"
|
|
1720
|
+
```
|
|
1721
|
+
|
|
1722
|
+
---
|
|
1723
|
+
|
|
1724
|
+
### Task 9: README
|
|
1725
|
+
|
|
1726
|
+
The package is publish-ready only if someone can use it without reading the source.
|
|
1727
|
+
|
|
1728
|
+
**Files:**
|
|
1729
|
+
- Create: `src/utils/mdq/README.md`
|
|
1730
|
+
|
|
1731
|
+
**Interfaces:**
|
|
1732
|
+
- Consumes: the complete API from Tasks 3-8
|
|
1733
|
+
- Produces: nothing code depends on
|
|
1734
|
+
|
|
1735
|
+
- [ ] **Step 1: Write the README**
|
|
1736
|
+
|
|
1737
|
+
Cover, in this order:
|
|
1738
|
+
|
|
1739
|
+
1. One-paragraph pitch: query and edit markdown with a selector language, like jq for markdown.
|
|
1740
|
+
2. Install and import.
|
|
1741
|
+
3. **The one rule**, stated early and plainly: *reads narrow, writes return the document.*
|
|
1742
|
+
4. Selector grammar table: `section` `section1-6` `h1-h6` `heading` `paragraph` `table` `list` `item` `code` `blockquote` `hr` `html` `comment`, with text matchers (`"exact"`, `~"contains"`, `/regex/flags`, `!` to negate), `[index]`, `[from:to]`, and compound paths.
|
|
1743
|
+
5. Matchers as values: `string` exact, `RegExp` with its own flags, predicate function.
|
|
1744
|
+
6. Read methods table, write methods table.
|
|
1745
|
+
7. Frontmatter section, noting comment preservation.
|
|
1746
|
+
8. A worked example using a chained multi-edit.
|
|
1747
|
+
9. Limitations, stated honestly: block-level comments only (inline comments live inside paragraph tokens); no row- or item-level selectors, so `addRow` has no `removeRow` partner; YAML frontmatter only.
|
|
1748
|
+
|
|
1749
|
+
Do **not** document the deprecated aliases (`get` `toJson` `keyValue` `setKeyValue` `meta` `before` `after`). They exist for in-repo callers; the published surface should read clean.
|
|
1750
|
+
|
|
1751
|
+
Follow the repo docs style: show each format example once, and do not close with a "Why this matters" section.
|
|
1752
|
+
|
|
1753
|
+
- [ ] **Step 2: Verify every example in the README actually runs**
|
|
1754
|
+
|
|
1755
|
+
Extract each fenced `js` block and execute it. Any example that throws or prints something other than what the README claims is a documentation bug — fix the README, not the test.
|
|
1756
|
+
|
|
1757
|
+
```bash
|
|
1758
|
+
bun -e '
|
|
1759
|
+
import { mdq } from "./src/utils/mdq/query.ts";
|
|
1760
|
+
// paste each README example here and assert its stated output
|
|
1761
|
+
'
|
|
1762
|
+
```
|
|
1763
|
+
|
|
1764
|
+
- [ ] **Step 3: Commit**
|
|
1765
|
+
|
|
1766
|
+
```bash
|
|
1767
|
+
git add src/utils/mdq/README.md
|
|
1768
|
+
git commit -m "docs(mdq): package README"
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
---
|
|
1772
|
+
|
|
1773
|
+
### Task 10: The CLI
|
|
1774
|
+
|
|
1775
|
+
**Files:**
|
|
1776
|
+
- Create: `src/utils/mdq/cli.ts`
|
|
1777
|
+
- Create: `bin/mdq.ts`
|
|
1778
|
+
- Create: `tests/unit/mdq/cli.test.ts`
|
|
1779
|
+
- Modify: `package.json` (add the `mdq` bin entry)
|
|
1780
|
+
|
|
1781
|
+
**Interfaces:**
|
|
1782
|
+
- Consumes: the full library API from Tasks 3-8
|
|
1783
|
+
- Produces: `runMdq(argv: string[], stdin: string): Promise<{ output: string; code: number }>`
|
|
1784
|
+
|
|
1785
|
+
`runMdq` returns its result rather than writing to stdout or calling `process.exit`, which is what makes it testable. `bin/mdq.ts` is the only place that touches the process.
|
|
1786
|
+
|
|
1787
|
+
Note a deliberate deviation from `CLAUDE.md`: command logic normally lives in `src/commands/`, but mdq must not import from anywhere in explorbot. Its CLI ships with the package.
|
|
1788
|
+
|
|
1789
|
+
- [ ] **Step 1: Write the failing test**
|
|
1790
|
+
|
|
1791
|
+
```ts
|
|
1792
|
+
// tests/unit/mdq/cli.test.ts
|
|
1793
|
+
import { describe, expect, it } from 'vitest';
|
|
1794
|
+
import { runMdq } from '../../../src/utils/mdq/cli.ts';
|
|
1795
|
+
|
|
1796
|
+
const doc = `# Title
|
|
1797
|
+
|
|
1798
|
+
## API
|
|
1799
|
+
|
|
1800
|
+
| Method | Path |
|
|
1801
|
+
|--------|------|
|
|
1802
|
+
| GET | /users |
|
|
1803
|
+
|
|
1804
|
+
## FAQ
|
|
1805
|
+
|
|
1806
|
+
question?
|
|
1807
|
+
`;
|
|
1808
|
+
|
|
1809
|
+
describe('reads', () => {
|
|
1810
|
+
it('prints matched markdown', async () => {
|
|
1811
|
+
const result = await runMdq(['h2'], doc);
|
|
1812
|
+
expect(result.output).toContain('## API');
|
|
1813
|
+
expect(result.code).toBe(0);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
it('accepts a leading dot like jq', async () => {
|
|
1817
|
+
expect((await runMdq(['.h2'], doc)).output).toContain('## API');
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
it('prints rows as json', async () => {
|
|
1821
|
+
const result = await runMdq(['section("API") table', '--json'], doc);
|
|
1822
|
+
expect(JSON.parse(result.output)).toEqual([{ Method: 'GET', Path: '/users' }]);
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
it('prints a count', async () => {
|
|
1826
|
+
expect((await runMdq(['h2', '--count'], doc)).output.trim()).toBe('2');
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it('prints unwrapped text', async () => {
|
|
1830
|
+
expect((await runMdq(['h2', '--text'], doc)).output).not.toContain('##');
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
it('prints frontmatter as json', async () => {
|
|
1834
|
+
const result = await runMdq(['--frontmatter'], '---\nurl: /x\n---\n\n# T\n');
|
|
1835
|
+
expect(JSON.parse(result.output)).toEqual({ url: '/x' });
|
|
1836
|
+
});
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
describe('edits', () => {
|
|
1840
|
+
it('removes and prints the whole document', async () => {
|
|
1841
|
+
const result = await runMdq(['section("FAQ")', '--remove'], doc);
|
|
1842
|
+
expect(result.output).not.toContain('## FAQ');
|
|
1843
|
+
expect(result.output).toContain('## API');
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
it('appends into a section', async () => {
|
|
1847
|
+
expect((await runMdq(['section("FAQ")', '--append', 'answer!'], doc)).output).toContain('answer!');
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('adds a table row from json', async () => {
|
|
1851
|
+
const result = await runMdq(['table', '--add-row', '{"Method":"POST","Path":"/s"}'], doc);
|
|
1852
|
+
expect(result.output).toContain('POST');
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
it('sets an entry', async () => {
|
|
1856
|
+
expect((await runMdq(['blockquote', '--set', 'Container=.x'], '> Container: .old\n')).output).toContain('.x');
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
describe('exit codes', () => {
|
|
1861
|
+
it('returns 1 when nothing matches', async () => {
|
|
1862
|
+
expect((await runMdq(['h5'], doc)).code).toBe(1);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
it('returns 2 on an unknown selector', async () => {
|
|
1866
|
+
const result = await runMdq(['secton("A")'], doc);
|
|
1867
|
+
expect(result.code).toBe(2);
|
|
1868
|
+
expect(result.output).toContain('Unknown selector');
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
it('returns 0 when an edit matched', async () => {
|
|
1872
|
+
expect((await runMdq(['h2', '--remove'], doc)).code).toBe(0);
|
|
1873
|
+
});
|
|
1874
|
+
});
|
|
1875
|
+
```
|
|
1876
|
+
|
|
1877
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1878
|
+
|
|
1879
|
+
Run: `bun test tests/unit/mdq/cli.test.ts`
|
|
1880
|
+
Expected: FAIL — cannot resolve `cli.ts`
|
|
1881
|
+
|
|
1882
|
+
- [ ] **Step 3: Implement cli.ts**
|
|
1883
|
+
|
|
1884
|
+
Use Commander with `exitOverride()` and `.configureOutput()` so a parse failure surfaces as a return value rather than killing the process. Shape:
|
|
1885
|
+
|
|
1886
|
+
```ts
|
|
1887
|
+
export async function runMdq(argv: string[], stdin: string): Promise<CliResult> {
|
|
1888
|
+
const program = new Command();
|
|
1889
|
+
program
|
|
1890
|
+
.argument('[selector]', 'markdown selector')
|
|
1891
|
+
.argument('[file]', 'file to read; stdin when omitted')
|
|
1892
|
+
.option('-j, --json', 'output rows as JSON')
|
|
1893
|
+
.option('-c, --count', 'print the number of matches')
|
|
1894
|
+
.option('-t, --text', 'print unwrapped text')
|
|
1895
|
+
.option('--frontmatter', 'print frontmatter as JSON')
|
|
1896
|
+
.option('-i, --in-place', 'write the result back to the file')
|
|
1897
|
+
.option('--remove', 'delete matched blocks')
|
|
1898
|
+
.option('--replace <markdown>', 'replace matched blocks')
|
|
1899
|
+
.option('--insert-before <markdown>', 'insert before each match')
|
|
1900
|
+
.option('--insert-after <markdown>', 'insert after each match')
|
|
1901
|
+
.option('--prepend <markdown>', 'insert at the start of each match')
|
|
1902
|
+
.option('--append <markdown>', 'insert at the end of each match')
|
|
1903
|
+
.option('--add-row <json>', 'append a table row')
|
|
1904
|
+
.option('--add-item <text>', 'append a list item')
|
|
1905
|
+
.option('--set <key=value>', 'set an entry; omit the value to delete it')
|
|
1906
|
+
.exitOverride();
|
|
1907
|
+
// ... parse, read source, build doc, apply exactly one edit or one read, return { output, code }
|
|
1908
|
+
}
|
|
1909
|
+
```
|
|
1910
|
+
|
|
1911
|
+
Rules to implement:
|
|
1912
|
+
|
|
1913
|
+
- No selector plus `--frontmatter` prints the frontmatter and returns 0.
|
|
1914
|
+
- An edit flag with no matches returns 1 and prints the document unchanged.
|
|
1915
|
+
- `MdqSelectorError` returns 2 with the message as output.
|
|
1916
|
+
- `--in-place` writes `output` to the file and returns an empty `output`.
|
|
1917
|
+
- More than one edit flag returns 2 with `Only one edit at a time`.
|
|
1918
|
+
- `--set k=v` splits on the **first** `=`; `--set k=` deletes.
|
|
1919
|
+
|
|
1920
|
+
At the end of the file:
|
|
1921
|
+
|
|
1922
|
+
```ts
|
|
1923
|
+
export interface CliResult {
|
|
1924
|
+
output: string;
|
|
1925
|
+
code: number;
|
|
1926
|
+
}
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1930
|
+
|
|
1931
|
+
Run: `bun test tests/unit/mdq/cli.test.ts`
|
|
1932
|
+
Expected: PASS (13 tests)
|
|
1933
|
+
|
|
1934
|
+
- [ ] **Step 5: Add the thin bin entry**
|
|
1935
|
+
|
|
1936
|
+
```ts
|
|
1937
|
+
#!/usr/bin/env bun
|
|
1938
|
+
import { readFileSync } from 'node:fs';
|
|
1939
|
+
import { runMdq } from '../src/utils/mdq/cli.ts';
|
|
1940
|
+
|
|
1941
|
+
const stdin = process.stdin.isTTY ? '' : readFileSync(0, 'utf8');
|
|
1942
|
+
const result = await runMdq(process.argv.slice(2), stdin);
|
|
1943
|
+
if (result.output) process.stdout.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
|
|
1944
|
+
process.exit(result.code);
|
|
1945
|
+
```
|
|
1946
|
+
|
|
1947
|
+
Add to `package.json` `bin`:
|
|
1948
|
+
|
|
1949
|
+
```json
|
|
1950
|
+
"mdq": "./dist/bin/mdq.js"
|
|
1951
|
+
```
|
|
1952
|
+
|
|
1953
|
+
- [ ] **Step 6: Smoke-test the real binary**
|
|
1954
|
+
|
|
1955
|
+
```bash
|
|
1956
|
+
echo '# A
|
|
1957
|
+
|
|
1958
|
+
## B
|
|
1959
|
+
|
|
1960
|
+
text' | bun run bin/mdq.ts 'h2'
|
|
1961
|
+
```
|
|
1962
|
+
Expected: `## B`
|
|
1963
|
+
|
|
1964
|
+
```bash
|
|
1965
|
+
bun run bin/mdq.ts 'section("Data Envelope Formats") table' --json CLAUDE.md | head -5
|
|
1966
|
+
```
|
|
1967
|
+
Expected: JSON array of that section's table rows
|
|
1968
|
+
|
|
1969
|
+
```bash
|
|
1970
|
+
bun run bin/mdq.ts 'nonsense' CLAUDE.md; echo "exit=$?"
|
|
1971
|
+
```
|
|
1972
|
+
Expected: `Unknown selector "nonsense"` and `exit=2`
|
|
1973
|
+
|
|
1974
|
+
- [ ] **Step 7: Run the whole suite and commit**
|
|
1975
|
+
|
|
1976
|
+
```bash
|
|
1977
|
+
bun test tests/unit/
|
|
1978
|
+
bun run format && bun run lint:fix
|
|
1979
|
+
git add -A src/utils/mdq bin/mdq.ts tests/unit/mdq package.json
|
|
1980
|
+
git commit -m "feat(mdq): jq-like CLI"
|
|
1981
|
+
```
|
|
1982
|
+
|
|
1983
|
+
---
|
|
1984
|
+
|
|
1985
|
+
### Task 11: Changelog and final verification
|
|
1986
|
+
|
|
1987
|
+
**Files:**
|
|
1988
|
+
- Modify: `CHANGELOG.md`
|
|
1989
|
+
|
|
1990
|
+
- [ ] **Step 1: Run the full unit suite**
|
|
1991
|
+
|
|
1992
|
+
Run: `bun test tests/unit/`
|
|
1993
|
+
Expected: PASS, no failures
|
|
1994
|
+
|
|
1995
|
+
- [ ] **Step 2: Re-run the scoped type check**
|
|
1996
|
+
|
|
1997
|
+
```bash
|
|
1998
|
+
bunx tsc -p tsconfig.json --noEmit 2>&1 | grep -E "^(src/utils/mdq/|src/utils/markdown-query|src/experience-tracker|src/ai/planner|src/ai/researcher|bin/mdq)"
|
|
1999
|
+
```
|
|
2000
|
+
|
|
2001
|
+
Expected: **exactly the two known `locators.ts(247,...)` lines.** Anything else is a real defect that CI will not catch.
|
|
2002
|
+
|
|
2003
|
+
- [ ] **Step 3: Confirm the package has no explorbot imports**
|
|
2004
|
+
|
|
2005
|
+
```bash
|
|
2006
|
+
grep -rn "^import\|from '" src/utils/mdq/*.ts | grep -v "'marked'" | grep -v "'yaml'" | grep -v "'commander'" | grep -v "'./"
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
Expected: **no output.** Any line here breaks extractability, which is the whole point of the package.
|
|
2010
|
+
|
|
2011
|
+
- [ ] **Step 4: Update the changelog**
|
|
2012
|
+
|
|
2013
|
+
Use the `/changelog` skill, per `CLAUDE.md`.
|
|
2014
|
+
|
|
2015
|
+
- [ ] **Step 5: Commit**
|
|
2016
|
+
|
|
2017
|
+
```bash
|
|
2018
|
+
git add CHANGELOG.md
|
|
2019
|
+
git commit -m "docs: changelog for mdq package"
|
|
2020
|
+
```
|
|
2021
|
+
|
|
2022
|
+
---
|
|
2023
|
+
|
|
2024
|
+
## Notes for the executor
|
|
2025
|
+
|
|
2026
|
+
- **Never run the regression workflow.** Do not add the `regression` label, do not `gh workflow run regression.yml`, do not re-run its jobs. If a change needs regression coverage, say so and let the user decide.
|
|
2027
|
+
- **`.claude/worktrees/**` holds four stale copies** of `markdown-query.ts` and its consumers. Exclude that path from every grep, sed and sweep. A migration that "finds" 200 call sites has picked up worktrees.
|
|
2028
|
+
- **CI type-checking is a mirage.** `tsc` runs with `--noCheck`, so a green build says nothing about the return-type change in Task 3. The scoped check in Task 3 Step 8 and Task 11 Step 2 is the only real gate.
|
|
2029
|
+
- **Whitespace failures are one newline.** When an edit test fails, print the actual string with `JSON.stringify` before touching the implementation. Adjusting the normalizer by guesswork fixes one case and breaks two.
|