@henryavila/mdprobe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ import yaml from 'js-yaml'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { readFile, writeFile } from 'node:fs/promises'
4
+
5
+ const VALID_TAGS = ['bug', 'question', 'suggestion', 'nitpick']
6
+
7
+ /**
8
+ * Validates that a tag is one of the allowed values.
9
+ * @param {string} tag
10
+ * @throws {Error} if tag is not in VALID_TAGS
11
+ */
12
+ function validateTag(tag) {
13
+ if (!VALID_TAGS.includes(tag)) {
14
+ throw new Error(`Invalid tag "${tag}". Must be one of: ${VALID_TAGS.join(', ')}`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Generates a short unique identifier.
20
+ * @returns {string}
21
+ */
22
+ function generateId() {
23
+ return randomUUID().replace(/-/g, '').slice(0, 8)
24
+ }
25
+
26
+ /**
27
+ * Manages annotation data for a Markdown source file.
28
+ *
29
+ * Supports CRUD operations on annotations, section approval tracking,
30
+ * persistence to/from YAML, and export to JSON and SARIF formats.
31
+ */
32
+ export class AnnotationFile {
33
+ /**
34
+ * @param {object} data - Raw annotation file data
35
+ * @param {number} data.version
36
+ * @param {string} data.source
37
+ * @param {string} data.source_hash
38
+ * @param {Array} data.annotations
39
+ * @param {Array} data.sections
40
+ */
41
+ constructor(data) {
42
+ this.version = data.version
43
+ this.source = data.source
44
+ this.sourceHash = data.source_hash
45
+ this.annotations = data.annotations ?? []
46
+ this.sections = data.sections ?? []
47
+
48
+ // Ensure every annotation has a replies array
49
+ for (const ann of this.annotations) {
50
+ if (!ann.replies) {
51
+ ann.replies = []
52
+ }
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Static factory methods
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Creates a new empty AnnotationFile for a given source.
62
+ * @param {string} source - Source markdown filename
63
+ * @param {string} sourceHash - Hash of the source file (e.g. "sha256:abc123")
64
+ * @returns {AnnotationFile}
65
+ */
66
+ static create(source, sourceHash) {
67
+ return new AnnotationFile({
68
+ version: 1,
69
+ source,
70
+ source_hash: sourceHash,
71
+ annotations: [],
72
+ sections: [],
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Loads an AnnotationFile from a YAML file on disk.
78
+ * @param {string} yamlPath - Absolute path to the YAML file
79
+ * @returns {Promise<AnnotationFile>}
80
+ * @throws {Error} if the file does not exist or contains invalid YAML
81
+ */
82
+ static async load(yamlPath) {
83
+ const content = await readFile(yamlPath, 'utf-8')
84
+
85
+ let data
86
+ try {
87
+ data = yaml.load(content)
88
+ } catch (err) {
89
+ // js-yaml errors include a `mark` with line info; re-throw with line context
90
+ if (err.mark != null) {
91
+ throw new Error(`Invalid YAML at line ${err.mark.line + 1}: ${err.message}`)
92
+ }
93
+ throw new Error(`Invalid YAML (line unknown): ${err.message}`)
94
+ }
95
+
96
+ return new AnnotationFile({
97
+ version: data.version,
98
+ source: data.source,
99
+ source_hash: data.source_hash,
100
+ annotations: data.annotations ?? [],
101
+ sections: data.sections ?? [],
102
+ })
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // CRUD — annotations
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Adds a new annotation.
111
+ * @param {object} opts
112
+ * @param {object} opts.selectors - Position/quote selectors (required)
113
+ * @param {string} opts.comment - Annotation text (required)
114
+ * @param {string} opts.tag - One of VALID_TAGS
115
+ * @param {string} opts.author - Author name
116
+ * @returns {object} the created annotation
117
+ */
118
+ add({ selectors, comment, tag, author }) {
119
+ if (!selectors) {
120
+ throw new Error('selectors is required')
121
+ }
122
+ if (!comment) {
123
+ throw new Error('comment is required')
124
+ }
125
+ validateTag(tag)
126
+
127
+ const now = new Date().toISOString()
128
+ const annotation = {
129
+ id: generateId(),
130
+ selectors,
131
+ comment,
132
+ tag,
133
+ status: 'open',
134
+ author,
135
+ created_at: now,
136
+ updated_at: now,
137
+ replies: [],
138
+ }
139
+
140
+ this.annotations.push(annotation)
141
+ return annotation
142
+ }
143
+
144
+ /**
145
+ * Resolves an annotation by id. Idempotent.
146
+ * @param {string} id
147
+ * @throws {Error} if annotation not found
148
+ */
149
+ resolve(id) {
150
+ const ann = this._findOrThrow(id)
151
+ ann.status = 'resolved'
152
+ ann.updated_at = new Date().toISOString()
153
+ }
154
+
155
+ /**
156
+ * Reopens a previously resolved annotation.
157
+ * @param {string} id
158
+ * @throws {Error} if annotation not found
159
+ */
160
+ reopen(id) {
161
+ const ann = this._findOrThrow(id)
162
+ ann.status = 'open'
163
+ ann.updated_at = new Date().toISOString()
164
+ }
165
+
166
+ /**
167
+ * Updates the comment text of an annotation.
168
+ * @param {string} id
169
+ * @param {string} text - New comment (must be non-empty)
170
+ * @throws {Error} if id not found or text is empty
171
+ */
172
+ updateComment(id, text) {
173
+ if (!text) {
174
+ throw new Error('comment text must not be empty')
175
+ }
176
+ const ann = this._findOrThrow(id)
177
+ ann.comment = text
178
+ ann.updated_at = new Date().toISOString()
179
+ }
180
+
181
+ /**
182
+ * Updates the tag of an annotation.
183
+ * @param {string} id
184
+ * @param {string} tag - New tag (must be valid)
185
+ * @throws {Error} if id not found or tag is invalid
186
+ */
187
+ updateTag(id, tag) {
188
+ validateTag(tag)
189
+ const ann = this._findOrThrow(id)
190
+ ann.tag = tag
191
+ ann.updated_at = new Date().toISOString()
192
+ }
193
+
194
+ /**
195
+ * Deletes an annotation by id.
196
+ * @param {string} id
197
+ * @throws {Error} if annotation not found
198
+ */
199
+ delete(id) {
200
+ const index = this.annotations.findIndex(a => a.id === id)
201
+ if (index === -1) {
202
+ throw new Error(`Annotation "${id}" not found`)
203
+ }
204
+ this.annotations.splice(index, 1)
205
+ }
206
+
207
+ /**
208
+ * Adds a reply to an existing annotation.
209
+ * @param {string} annotationId
210
+ * @param {object} opts
211
+ * @param {string} opts.author
212
+ * @param {string} opts.comment
213
+ * @throws {Error} if annotation not found
214
+ */
215
+ addReply(annotationId, { author, comment }) {
216
+ const ann = this._findOrThrow(annotationId)
217
+ ann.replies.push({
218
+ author,
219
+ comment,
220
+ created_at: new Date().toISOString(),
221
+ })
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Query methods
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /**
229
+ * Retrieves a single annotation by id.
230
+ * @param {string} id
231
+ * @returns {object}
232
+ * @throws {Error} if not found
233
+ */
234
+ getById(id) {
235
+ return this._findOrThrow(id)
236
+ }
237
+
238
+ /**
239
+ * Returns all open annotations.
240
+ * @returns {Array}
241
+ */
242
+ getOpen() {
243
+ return this.annotations.filter(a => a.status === 'open')
244
+ }
245
+
246
+ /**
247
+ * Returns all resolved annotations.
248
+ * @returns {Array}
249
+ */
250
+ getResolved() {
251
+ return this.annotations.filter(a => a.status === 'resolved')
252
+ }
253
+
254
+ /**
255
+ * Returns annotations matching a given tag.
256
+ * @param {string} tag
257
+ * @returns {Array}
258
+ */
259
+ getByTag(tag) {
260
+ return this.annotations.filter(a => a.tag === tag)
261
+ }
262
+
263
+ /**
264
+ * Returns annotations by a given author.
265
+ * @param {string} author
266
+ * @returns {Array}
267
+ */
268
+ getByAuthor(author) {
269
+ return this.annotations.filter(a => a.author === author)
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Section approval
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Sets a section's status to 'approved'.
278
+ * @param {string} heading
279
+ * @throws {Error} if section not found
280
+ */
281
+ approveSection(heading) {
282
+ this._cascadeStatus(heading, 'approved')
283
+ }
284
+
285
+ /**
286
+ * Set status on a section and cascade to all descendants.
287
+ * @param {string} heading
288
+ * @param {string} status
289
+ */
290
+ _cascadeStatus(heading, status) {
291
+ const target = this._findSectionOrThrow(heading)
292
+ target.status = status
293
+ if (target.level != null) {
294
+ const idx = this.sections.indexOf(target)
295
+ for (let i = idx + 1; i < this.sections.length; i++) {
296
+ if (this.sections[i].level == null || this.sections[i].level <= target.level) break
297
+ this.sections[i].status = status
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Compute effective status for each section considering children.
304
+ * Returns a new array with { heading, level, status, computed } where
305
+ * `computed` is 'indeterminate' when children have mixed statuses.
306
+ */
307
+ computeStatus() {
308
+ return computeSectionStatus(this.sections)
309
+ }
310
+
311
+ /**
312
+ * Sets a section's status to 'rejected'. Cascades to descendants.
313
+ * @param {string} heading
314
+ * @throws {Error} if section not found
315
+ */
316
+ rejectSection(heading) {
317
+ this._cascadeStatus(heading, 'rejected')
318
+ }
319
+
320
+ /**
321
+ * Resets a section's status to 'pending'. Cascades to descendants.
322
+ * @param {string} heading
323
+ * @throws {Error} if section not found
324
+ */
325
+ resetSection(heading) {
326
+ this._cascadeStatus(heading, 'pending')
327
+ }
328
+
329
+ /**
330
+ * Sets all sections to 'approved'.
331
+ */
332
+ approveAll() {
333
+ for (const section of this.sections) {
334
+ section.status = 'approved'
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Sets all sections to 'pending'.
340
+ */
341
+ clearAll() {
342
+ for (const section of this.sections) {
343
+ section.status = 'pending'
344
+ }
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Persistence & export
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Saves the annotation file to disk as human-readable YAML.
353
+ * @param {string} yamlPath - Absolute path for the output file
354
+ * @returns {Promise<void>}
355
+ */
356
+ async save(yamlPath) {
357
+ const data = this.toJSON()
358
+ const content = yaml.dump(data, {
359
+ lineWidth: -1,
360
+ noRefs: true,
361
+ sortKeys: false,
362
+ })
363
+ await writeFile(yamlPath, content, 'utf-8')
364
+ }
365
+
366
+ /**
367
+ * Returns a plain JSON-serializable object representation.
368
+ * Uses underscore `source_hash` per spec.
369
+ * @returns {object}
370
+ */
371
+ toJSON() {
372
+ return {
373
+ version: this.version,
374
+ source: this.source,
375
+ source_hash: this.sourceHash,
376
+ sections: this.sections,
377
+ annotations: this.annotations.map(ann => ({
378
+ id: ann.id,
379
+ selectors: ann.selectors,
380
+ comment: ann.comment,
381
+ tag: ann.tag,
382
+ status: ann.status,
383
+ author: ann.author,
384
+ created_at: ann.created_at,
385
+ updated_at: ann.updated_at,
386
+ replies: ann.replies ?? [],
387
+ })),
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Returns a SARIF 2.1.0 object with open annotations as results.
393
+ * @returns {object}
394
+ */
395
+ toSARIF() {
396
+ const openAnnotations = this.getOpen()
397
+
398
+ return {
399
+ $schema:
400
+ 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
401
+ version: '2.1.0',
402
+ runs: [
403
+ {
404
+ tool: {
405
+ driver: {
406
+ name: 'mdprobe',
407
+ version: '0.1.0',
408
+ informationUri: 'https://github.com/henryavila/mdprobe',
409
+ },
410
+ },
411
+ results: openAnnotations.map(ann => {
412
+ const result = {
413
+ ruleId: ann.tag,
414
+ level: ann.tag === 'bug' ? 'error' : 'note',
415
+ message: { text: ann.comment },
416
+ locations: [],
417
+ }
418
+
419
+ if (ann.selectors?.position) {
420
+ const pos = ann.selectors.position
421
+ result.locations.push({
422
+ physicalLocation: {
423
+ artifactLocation: { uri: this.source },
424
+ region: {
425
+ startLine: pos.startLine,
426
+ ...(pos.startColumn != null && { startColumn: pos.startColumn }),
427
+ ...(pos.endLine != null && { endLine: pos.endLine }),
428
+ ...(pos.endColumn != null && { endColumn: pos.endColumn }),
429
+ },
430
+ },
431
+ })
432
+ }
433
+
434
+ return result
435
+ }),
436
+ },
437
+ ],
438
+ }
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Internal helpers
443
+ // ---------------------------------------------------------------------------
444
+
445
+ /**
446
+ * Finds an annotation by id or throws.
447
+ * @param {string} id
448
+ * @returns {object}
449
+ * @private
450
+ */
451
+ _findOrThrow(id) {
452
+ const ann = this.annotations.find(a => a.id === id)
453
+ if (!ann) {
454
+ throw new Error(`Annotation "${id}" not found`)
455
+ }
456
+ return ann
457
+ }
458
+
459
+ /**
460
+ * Finds a section by heading or throws.
461
+ * @param {string} heading
462
+ * @returns {object}
463
+ * @private
464
+ */
465
+ _findSectionOrThrow(heading) {
466
+ const section = this.sections.find(s => s.heading === heading)
467
+ if (!section) {
468
+ throw new Error(`Section "${heading}" not found`)
469
+ }
470
+ return section
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Compute effective status for each section considering children.
476
+ * Walks bottom-up so children are resolved before parents.
477
+ * @param {Array<{heading: string, level?: number, status: string}>} sections
478
+ * @returns {Array<{heading: string, level?: number, status: string, computed: string}>}
479
+ */
480
+ export function computeSectionStatus(sections) {
481
+ const result = sections.map(s => ({ ...s, computed: s.status }))
482
+
483
+ for (let i = result.length - 1; i >= 0; i--) {
484
+ const section = result[i]
485
+ if (section.level == null) continue
486
+
487
+ const children = []
488
+ for (let j = i + 1; j < result.length; j++) {
489
+ if (result[j].level == null || result[j].level <= section.level) break
490
+ children.push(result[j])
491
+ }
492
+
493
+ if (children.length === 0) continue
494
+
495
+ const statuses = new Set(children.map(c => c.computed))
496
+ if (statuses.size === 1) {
497
+ section.computed = [...statuses][0]
498
+ } else {
499
+ section.computed = 'indeterminate'
500
+ }
501
+ }
502
+
503
+ return result
504
+ }
@@ -0,0 +1,58 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join, extname } from 'node:path'
3
+
4
+ /**
5
+ * Find .md files in a directory (recursive, synchronous).
6
+ * Returns an empty array if directory doesn't exist or can't be read.
7
+ *
8
+ * @param {string} dir - Absolute directory path
9
+ * @returns {string[]} Sorted absolute paths
10
+ */
11
+ export function findMarkdownFiles(dir) {
12
+ try {
13
+ const entries = readdirSync(dir, { withFileTypes: true, recursive: true })
14
+ return entries
15
+ .filter((e) => e.isFile() && extname(e.name).toLowerCase() === '.md')
16
+ .map((e) => join(e.parentPath || e.path, e.name))
17
+ .sort()
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Extract a flag and its value from an args array.
25
+ * Mutates the array by removing the flag (and its value if present).
26
+ *
27
+ * @param {string[]} args - Mutable args array
28
+ * @param {string} flag - Flag name (e.g., '--port')
29
+ * @returns {string|true|undefined} The value, `true` if flag has no value, `undefined` if absent
30
+ */
31
+ export function extractFlag(args, flag) {
32
+ const idx = args.indexOf(flag)
33
+ if (idx === -1) return undefined
34
+ args.splice(idx, 1)
35
+ if (idx < args.length && !args[idx]?.startsWith('-')) {
36
+ return args.splice(idx, 1)[0]
37
+ }
38
+ return true
39
+ }
40
+
41
+ /**
42
+ * Check if any of the given flags exist in the args array.
43
+ * Removes the first match found. Mutates the array.
44
+ *
45
+ * @param {string[]} args - Mutable args array
46
+ * @param {...string} flags - Flag names to check
47
+ * @returns {boolean}
48
+ */
49
+ export function hasFlag(args, ...flags) {
50
+ for (const flag of flags) {
51
+ const idx = args.indexOf(flag)
52
+ if (idx !== -1) {
53
+ args.splice(idx, 1)
54
+ return true
55
+ }
56
+ }
57
+ return false
58
+ }
package/src/config.js ADDED
@@ -0,0 +1,76 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const DEFAULT_CONFIG_PATH = join(homedir(), '.mdprobe.json')
6
+
7
+ /**
8
+ * Read and parse the config file. Returns {} if file does not exist.
9
+ * Throws on malformed JSON.
10
+ *
11
+ * @param {string} [configPath]
12
+ * @returns {Promise<object>}
13
+ */
14
+ export async function getConfig(configPath = DEFAULT_CONFIG_PATH) {
15
+ let raw
16
+ try {
17
+ raw = await readFile(configPath, 'utf8')
18
+ } catch (err) {
19
+ if (err.code === 'ENOENT') return {}
20
+ throw err
21
+ }
22
+
23
+ try {
24
+ return JSON.parse(raw)
25
+ } catch (err) {
26
+ throw new Error(`Failed to parse JSON in ${configPath}: ${err.message}`)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Set a single key in the config file (read-modify-write).
32
+ * Creates the file and parent directories if they don't exist.
33
+ *
34
+ * @param {string} key
35
+ * @param {*} value
36
+ * @param {string} [configPath]
37
+ * @returns {Promise<void>}
38
+ */
39
+ export async function setConfig(key, value, configPath = DEFAULT_CONFIG_PATH) {
40
+ await mkdir(dirname(configPath), { recursive: true })
41
+
42
+ let config = {}
43
+ try {
44
+ const raw = await readFile(configPath, 'utf8')
45
+ config = JSON.parse(raw)
46
+ } catch {
47
+ // File missing or unreadable — start fresh
48
+ }
49
+
50
+ config[key] = value
51
+ await writeFile(configPath, JSON.stringify(config), 'utf8')
52
+ }
53
+
54
+ /**
55
+ * Get the configured author name.
56
+ * Returns "anonymous" when the config file is missing, or the author
57
+ * key is absent, empty, or null.
58
+ *
59
+ * @param {string} [configPath]
60
+ * @returns {Promise<string>}
61
+ */
62
+ export async function getAuthor(configPath = DEFAULT_CONFIG_PATH) {
63
+ let config
64
+ try {
65
+ config = await getConfig(configPath)
66
+ } catch {
67
+ return 'anonymous'
68
+ }
69
+
70
+ const author = config.author
71
+ if (author == null || typeof author !== 'string' || author.trim() === '') {
72
+ return 'anonymous'
73
+ }
74
+
75
+ return author.trim()
76
+ }