@unrdf/diataxis-kit 26.4.2

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.
Files changed (49) hide show
  1. package/README.md +425 -0
  2. package/bin/report.mjs +529 -0
  3. package/bin/run.mjs +114 -0
  4. package/bin/verify.mjs +356 -0
  5. package/capability-map.md +92 -0
  6. package/package.json +42 -0
  7. package/src/classify.mjs +584 -0
  8. package/src/diataxis-schema.mjs +425 -0
  9. package/src/evidence.mjs +268 -0
  10. package/src/hash.mjs +37 -0
  11. package/src/inventory.mjs +280 -0
  12. package/src/reference-extractor.mjs +324 -0
  13. package/src/scaffold.mjs +458 -0
  14. package/src/stable-json.mjs +113 -0
  15. package/src/verify-implementation.mjs +131 -0
  16. package/test/determinism.test.mjs +321 -0
  17. package/test/evidence.test.mjs +145 -0
  18. package/test/fixtures/scaffold-det1/explanation/explanation.md +35 -0
  19. package/test/fixtures/scaffold-det1/index.md +29 -0
  20. package/test/fixtures/scaffold-det1/reference/reference.md +34 -0
  21. package/test/fixtures/scaffold-det1/tutorials/tutorial-test-tutorial.md +37 -0
  22. package/test/fixtures/scaffold-det2/explanation/explanation.md +35 -0
  23. package/test/fixtures/scaffold-det2/index.md +29 -0
  24. package/test/fixtures/scaffold-det2/reference/reference.md +34 -0
  25. package/test/fixtures/scaffold-det2/tutorials/tutorial-test-tutorial.md +37 -0
  26. package/test/fixtures/scaffold-empty/explanation/explanation.md +35 -0
  27. package/test/fixtures/scaffold-empty/index.md +25 -0
  28. package/test/fixtures/scaffold-empty/reference/reference.md +34 -0
  29. package/test/fixtures/scaffold-escape/explanation/explanation.md +35 -0
  30. package/test/fixtures/scaffold-escape/index.md +29 -0
  31. package/test/fixtures/scaffold-escape/reference/reference.md +36 -0
  32. package/test/fixtures/scaffold-output/explanation/explanation.md +39 -0
  33. package/test/fixtures/scaffold-output/how-to/howto-configure-options.md +39 -0
  34. package/test/fixtures/scaffold-output/index.md +41 -0
  35. package/test/fixtures/scaffold-output/reference/reference.md +36 -0
  36. package/test/fixtures/scaffold-output/tutorials/tutorial-getting-started.md +41 -0
  37. package/test/fixtures/test-artifacts/ARTIFACTS/diataxis/test-pkg-1.inventory.json +115 -0
  38. package/test/fixtures/test-artifacts/ARTIFACTS/diataxis/test-pkg-2.inventory.json +93 -0
  39. package/test/fixtures/test-artifacts/ARTIFACTS/diataxis/test-pkg-3.inventory.json +97 -0
  40. package/test/fixtures/test-package/LICENSE +1 -0
  41. package/test/fixtures/test-package/README.md +15 -0
  42. package/test/fixtures/test-package/docs/guide.md +3 -0
  43. package/test/fixtures/test-package/examples/basic.mjs +3 -0
  44. package/test/fixtures/test-package/src/index.mjs +3 -0
  45. package/test/inventory.test.mjs +199 -0
  46. package/test/reference-extractor.test.mjs +187 -0
  47. package/test/report.test.mjs +503 -0
  48. package/test/scaffold.test.mjs +242 -0
  49. package/test/verify-gate.test.mjs +634 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * @file Diátaxis schema definitions and validation
3
+ * @module diataxis-schema
4
+ */
5
+
6
+ import { hashString } from './hash.mjs';
7
+
8
+ /**
9
+ * @typedef {Object} DiataxisConfidence
10
+ * @property {number} tutorials - Confidence score 0-1
11
+ * @property {number} howtos - Confidence score 0-1
12
+ * @property {number} reference - Confidence score 0-1
13
+ * @property {number} explanation - Confidence score 0-1
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} Tutorial
18
+ * @property {string} id - Generated from title
19
+ * @property {string} title - Tutorial title
20
+ * @property {string} goal - Learning goal
21
+ * @property {string[]} prerequisites - Required knowledge
22
+ * @property {string[]} stepsOutline - Step-by-step outline
23
+ * @property {number} confidenceScore - 0-1 confidence
24
+ * @property {string[]} source - Evidence sources
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} HowTo
29
+ * @property {string} id - Generated from title
30
+ * @property {string} title - How-to title
31
+ * @property {string} task - Task description
32
+ * @property {string} context - When to use this
33
+ * @property {string[]} steps - Action steps
34
+ * @property {number} confidenceScore - 0-1 confidence
35
+ * @property {string[]} source - Evidence sources
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} ReferenceItem
40
+ * @property {string} name - Item name
41
+ * @property {'export'|'bin'|'env'|'option'|'unknown'} type - Reference type
42
+ * @property {string} description - Item description
43
+ * @property {string|null} example - Usage example
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} Reference
48
+ * @property {string} id - Generated identifier
49
+ * @property {string} title - Reference title
50
+ * @property {ReferenceItem[]} items - Reference items
51
+ * @property {number} confidenceScore - 0-1 confidence
52
+ * @property {string[]} source - Evidence sources
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} Explanation
57
+ * @property {string} id - Generated identifier
58
+ * @property {string} title - Explanation title
59
+ * @property {string[]} concepts - Key concepts
60
+ * @property {string} architecture - Architecture overview
61
+ * @property {string[]} tradeoffs - Design tradeoffs
62
+ * @property {number} confidenceScore - 0-1 confidence
63
+ * @property {string[]} source - Evidence sources
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} DiataxisEvidence
68
+ * @property {string[]} readmeHeadings - Headings from README
69
+ * @property {string[]} docsFiles - Documentation files found
70
+ * @property {string[]} examplesFiles - Example files found
71
+ * @property {string} fingerprint - Content fingerprint hash
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} DiataxisEntry
76
+ * @property {string} packageName - Package name
77
+ * @property {string} version - Package version
78
+ * @property {string} generatedAt - ISO-8601 timestamp
79
+ * @property {DiataxisConfidence} confidence - Confidence scores
80
+ * @property {Tutorial[]} tutorials - Tutorial entries
81
+ * @property {HowTo[]} howtos - How-to entries
82
+ * @property {Reference} reference - Reference documentation
83
+ * @property {Explanation} explanation - Explanation documentation
84
+ * @property {DiataxisEvidence} evidence - Evidence metadata
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} ValidationResult
89
+ * @property {boolean} valid - Whether entry is valid
90
+ * @property {string[]} errors - Validation errors
91
+ */
92
+
93
+ /**
94
+ * Generate a stable ID from a title string
95
+ * @param {string} title - Title to convert
96
+ * @returns {string} Kebab-case ID
97
+ */
98
+ function generateId(title) {
99
+ return title
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]+/g, '-')
102
+ .replace(/^-+|-+$/g, '');
103
+ }
104
+
105
+ /**
106
+ * Get current timestamp, respecting DETERMINISTIC mode
107
+ * @returns {string} ISO-8601 timestamp
108
+ */
109
+ function getTimestamp() {
110
+ if (process.env.DETERMINISTIC === '1') {
111
+ return '2000-01-01T00:00:00.000Z';
112
+ }
113
+ return new Date().toISOString();
114
+ }
115
+
116
+ /**
117
+ * Create a validated Diátaxis entry
118
+ * @param {string} packageName - Package name
119
+ * @param {string} version - Package version
120
+ * @param {Object} evidence - Evidence object
121
+ * @param {string[]} [evidence.readmeHeadings=[]] - README headings
122
+ * @param {string[]} [evidence.docsFiles=[]] - Docs files
123
+ * @param {string[]} [evidence.examplesFiles=[]] - Example files
124
+ * @param {Tutorial[]} [evidence.tutorials=[]] - Tutorial entries
125
+ * @param {HowTo[]} [evidence.howtos=[]] - How-to entries
126
+ * @param {Reference} [evidence.reference] - Reference entry
127
+ * @param {Explanation} [evidence.explanation] - Explanation entry
128
+ * @param {DiataxisConfidence} [evidence.confidence] - Confidence scores
129
+ * @returns {DiataxisEntry} Validated entry
130
+ */
131
+ export function createDiataxisEntry(packageName, version, evidence = {}) {
132
+ const {
133
+ readmeHeadings = [],
134
+ docsFiles = [],
135
+ examplesFiles = [],
136
+ tutorials = [],
137
+ howtos = [],
138
+ reference = null,
139
+ explanation = null,
140
+ confidence = {
141
+ tutorials: 0,
142
+ howtos: 0,
143
+ reference: 0,
144
+ explanation: 0
145
+ }
146
+ } = evidence;
147
+
148
+ // Generate fingerprint from evidence
149
+ const fingerprintInput = JSON.stringify({
150
+ readmeHeadings: [...readmeHeadings].sort(),
151
+ docsFiles: [...docsFiles].sort(),
152
+ examplesFiles: [...examplesFiles].sort()
153
+ });
154
+ const fingerprint = hashString(fingerprintInput);
155
+
156
+ // Ensure IDs on all items
157
+ const tutorialsWithIds = tutorials.map(t => ({
158
+ ...t,
159
+ id: t.id || generateId(t.title)
160
+ }));
161
+
162
+ const howtosWithIds = howtos.map(h => ({
163
+ ...h,
164
+ id: h.id || generateId(h.title)
165
+ }));
166
+
167
+ const referenceWithId = reference ? {
168
+ ...reference,
169
+ id: reference.id || generateId(reference.title)
170
+ } : {
171
+ id: 'reference',
172
+ title: `${packageName} Reference`,
173
+ items: [],
174
+ confidenceScore: 0,
175
+ source: []
176
+ };
177
+
178
+ const explanationWithId = explanation ? {
179
+ ...explanation,
180
+ id: explanation.id || generateId(explanation.title)
181
+ } : {
182
+ id: 'explanation',
183
+ title: `${packageName} Explanation`,
184
+ concepts: [],
185
+ architecture: '',
186
+ tradeoffs: [],
187
+ confidenceScore: 0,
188
+ source: []
189
+ };
190
+
191
+ /** @type {DiataxisEntry} */
192
+ const entry = {
193
+ packageName,
194
+ version,
195
+ generatedAt: getTimestamp(),
196
+ confidence: {
197
+ tutorials: confidence.tutorials ?? 0,
198
+ howtos: confidence.howtos ?? 0,
199
+ reference: confidence.reference ?? 0,
200
+ explanation: confidence.explanation ?? 0
201
+ },
202
+ tutorials: tutorialsWithIds,
203
+ howtos: howtosWithIds,
204
+ reference: referenceWithId,
205
+ explanation: explanationWithId,
206
+ evidence: {
207
+ readmeHeadings,
208
+ docsFiles,
209
+ examplesFiles,
210
+ fingerprint
211
+ }
212
+ };
213
+
214
+ return entry;
215
+ }
216
+
217
+ /**
218
+ * Validate a Diátaxis entry
219
+ * @param {any} entry - Entry to validate
220
+ * @returns {ValidationResult} Validation result
221
+ */
222
+ export function validateDiataxisEntry(entry) {
223
+ /** @type {string[]} */
224
+ const errors = [];
225
+
226
+ // Check required top-level fields
227
+ if (!entry || typeof entry !== 'object') {
228
+ errors.push('Entry must be an object');
229
+ return { valid: false, errors };
230
+ }
231
+
232
+ if (typeof entry.packageName !== 'string' || entry.packageName === '') {
233
+ errors.push('packageName must be a non-empty string');
234
+ }
235
+
236
+ if (typeof entry.version !== 'string' || entry.version === '') {
237
+ errors.push('version must be a non-empty string');
238
+ }
239
+
240
+ if (typeof entry.generatedAt !== 'string') {
241
+ errors.push('generatedAt must be an ISO-8601 string');
242
+ }
243
+
244
+ // Validate confidence
245
+ if (!entry.confidence || typeof entry.confidence !== 'object') {
246
+ errors.push('confidence must be an object');
247
+ } else {
248
+ const confidenceKeys = ['tutorials', 'howtos', 'reference', 'explanation'];
249
+ for (const key of confidenceKeys) {
250
+ const val = entry.confidence[key];
251
+ if (typeof val !== 'number' || val < 0 || val > 1) {
252
+ errors.push(`confidence.${key} must be a number between 0 and 1`);
253
+ }
254
+ }
255
+ }
256
+
257
+ // Validate tutorials
258
+ if (!Array.isArray(entry.tutorials)) {
259
+ errors.push('tutorials must be an array');
260
+ } else {
261
+ entry.tutorials.forEach((t, i) => {
262
+ if (!t.id || typeof t.id !== 'string') {
263
+ errors.push(`tutorials[${i}].id must be a string`);
264
+ }
265
+ if (!t.title || typeof t.title !== 'string') {
266
+ errors.push(`tutorials[${i}].title must be a string`);
267
+ }
268
+ if (typeof t.confidenceScore !== 'number') {
269
+ errors.push(`tutorials[${i}].confidenceScore must be a number`);
270
+ }
271
+ if (!Array.isArray(t.source)) {
272
+ errors.push(`tutorials[${i}].source must be an array`);
273
+ }
274
+ });
275
+ }
276
+
277
+ // Validate howtos
278
+ if (!Array.isArray(entry.howtos)) {
279
+ errors.push('howtos must be an array');
280
+ } else {
281
+ entry.howtos.forEach((h, i) => {
282
+ if (!h.id || typeof h.id !== 'string') {
283
+ errors.push(`howtos[${i}].id must be a string`);
284
+ }
285
+ if (!h.title || typeof h.title !== 'string') {
286
+ errors.push(`howtos[${i}].title must be a string`);
287
+ }
288
+ if (typeof h.confidenceScore !== 'number') {
289
+ errors.push(`howtos[${i}].confidenceScore must be a number`);
290
+ }
291
+ if (!Array.isArray(h.source)) {
292
+ errors.push(`howtos[${i}].source must be an array`);
293
+ }
294
+ });
295
+ }
296
+
297
+ // Validate reference
298
+ if (!entry.reference || typeof entry.reference !== 'object') {
299
+ errors.push('reference must be an object');
300
+ } else {
301
+ if (!entry.reference.id || typeof entry.reference.id !== 'string') {
302
+ errors.push('reference.id must be a string');
303
+ }
304
+ if (!entry.reference.title || typeof entry.reference.title !== 'string') {
305
+ errors.push('reference.title must be a string');
306
+ }
307
+ if (!Array.isArray(entry.reference.items)) {
308
+ errors.push('reference.items must be an array');
309
+ }
310
+ if (typeof entry.reference.confidenceScore !== 'number') {
311
+ errors.push('reference.confidenceScore must be a number');
312
+ }
313
+ if (!Array.isArray(entry.reference.source)) {
314
+ errors.push('reference.source must be an array');
315
+ }
316
+ }
317
+
318
+ // Validate explanation
319
+ if (!entry.explanation || typeof entry.explanation !== 'object') {
320
+ errors.push('explanation must be an object');
321
+ } else {
322
+ if (!entry.explanation.id || typeof entry.explanation.id !== 'string') {
323
+ errors.push('explanation.id must be a string');
324
+ }
325
+ if (!entry.explanation.title || typeof entry.explanation.title !== 'string') {
326
+ errors.push('explanation.title must be a string');
327
+ }
328
+ if (!Array.isArray(entry.explanation.concepts)) {
329
+ errors.push('explanation.concepts must be an array');
330
+ }
331
+ if (typeof entry.explanation.architecture !== 'string') {
332
+ errors.push('explanation.architecture must be a string');
333
+ }
334
+ if (!Array.isArray(entry.explanation.tradeoffs)) {
335
+ errors.push('explanation.tradeoffs must be an array');
336
+ }
337
+ if (typeof entry.explanation.confidenceScore !== 'number') {
338
+ errors.push('explanation.confidenceScore must be a number');
339
+ }
340
+ if (!Array.isArray(entry.explanation.source)) {
341
+ errors.push('explanation.source must be an array');
342
+ }
343
+ }
344
+
345
+ // Validate evidence
346
+ if (!entry.evidence || typeof entry.evidence !== 'object') {
347
+ errors.push('evidence must be an object');
348
+ } else {
349
+ if (!Array.isArray(entry.evidence.readmeHeadings)) {
350
+ errors.push('evidence.readmeHeadings must be an array');
351
+ }
352
+ if (!Array.isArray(entry.evidence.docsFiles)) {
353
+ errors.push('evidence.docsFiles must be an array');
354
+ }
355
+ if (!Array.isArray(entry.evidence.examplesFiles)) {
356
+ errors.push('evidence.examplesFiles must be an array');
357
+ }
358
+ if (typeof entry.evidence.fingerprint !== 'string') {
359
+ errors.push('evidence.fingerprint must be a string');
360
+ }
361
+ }
362
+
363
+ return {
364
+ valid: errors.length === 0,
365
+ errors
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Ensure entry has minimum Diátaxis structure (empty stubs if needed)
371
+ * @param {DiataxisEntry} entry - Entry to ensure
372
+ * @returns {DiataxisEntry} Entry with guaranteed structure
373
+ */
374
+ export function ensureMinimumDiataxis(entry) {
375
+ // Ensure tutorials array exists
376
+ if (!Array.isArray(entry.tutorials) || entry.tutorials.length === 0) {
377
+ entry.tutorials = [];
378
+ entry.confidence.tutorials = 0;
379
+ }
380
+
381
+ // Ensure howtos array exists
382
+ if (!Array.isArray(entry.howtos) || entry.howtos.length === 0) {
383
+ entry.howtos = [];
384
+ entry.confidence.howtos = 0;
385
+ }
386
+
387
+ // Ensure reference exists
388
+ if (!entry.reference || typeof entry.reference !== 'object') {
389
+ entry.reference = {
390
+ id: 'reference',
391
+ title: `${entry.packageName} Reference`,
392
+ items: [],
393
+ confidenceScore: 0,
394
+ source: []
395
+ };
396
+ entry.confidence.reference = 0;
397
+ } else if (!Array.isArray(entry.reference.items) || entry.reference.items.length === 0) {
398
+ entry.reference.items = [];
399
+ entry.reference.confidenceScore = 0;
400
+ entry.confidence.reference = 0;
401
+ }
402
+
403
+ // Ensure explanation exists
404
+ if (!entry.explanation || typeof entry.explanation !== 'object') {
405
+ entry.explanation = {
406
+ id: 'explanation',
407
+ title: `${entry.packageName} Explanation`,
408
+ concepts: [],
409
+ architecture: '',
410
+ tradeoffs: [],
411
+ confidenceScore: 0,
412
+ source: []
413
+ };
414
+ entry.confidence.explanation = 0;
415
+ } else if (
416
+ (!Array.isArray(entry.explanation.concepts) || entry.explanation.concepts.length === 0) &&
417
+ (!entry.explanation.architecture || entry.explanation.architecture === '') &&
418
+ (!Array.isArray(entry.explanation.tradeoffs) || entry.explanation.tradeoffs.length === 0)
419
+ ) {
420
+ entry.explanation.confidenceScore = 0;
421
+ entry.confidence.explanation = 0;
422
+ }
423
+
424
+ return entry;
425
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @file evidence.mjs
3
+ * @description Collect evidence from package files to guide Diátaxis classification
4
+ */
5
+
6
+ import { readFile, readdir, access } from 'node:fs/promises';
7
+ import { join, extname } from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+
10
+ /**
11
+ * @typedef {Object} EvidenceSnapshot
12
+ * @property {string|null} readmeContent - Full README.md content or null if missing
13
+ * @property {string[]} readmeHeadings - Extracted markdown headings (## level)
14
+ * @property {string[]} examplesFiles - Files in examples/ directory
15
+ * @property {Record<string, string>} examplesSnippets - First 200 chars of sample files
16
+ * @property {string[]} docsFiles - Files in docs/ directory
17
+ * @property {Record<string, string>} docsSnippets - First 200 chars of sample files
18
+ * @property {string[]} srcFiles - Top-level .mjs/.js/.ts files from src/
19
+ * @property {number} testFileCount - Count of test files
20
+ * @property {Record<string, string>} binEntries - bin field from package.json
21
+ * @property {Record<string, string>} exportSurface - exports field from package.json
22
+ * @property {string[]} keywords - keywords from package.json
23
+ * @property {boolean} hasLicense - LICENSE file presence
24
+ * @property {boolean} hasTsConfig - tsconfig.json presence
25
+ * @property {string} fingerprint - SHA256 hash of concatenated evidence
26
+ */
27
+
28
+ /**
29
+ * Extract markdown headings (## level) from content
30
+ * @param {string} content - Markdown content
31
+ * @returns {string[]} Array of heading text (without ## prefix)
32
+ */
33
+ function extractHeadings(content) {
34
+ const headingRegex = /^##\s+(.+)$/gm;
35
+ const headings = [];
36
+ let match;
37
+ while ((match = headingRegex.exec(content)) !== null) {
38
+ headings.push(match[1].trim());
39
+ }
40
+ return headings;
41
+ }
42
+
43
+ /**
44
+ * Read first N chars from a file safely
45
+ * @param {string} filePath - Path to file
46
+ * @param {number} maxChars - Maximum characters to read
47
+ * @returns {Promise<string>} File content snippet
48
+ */
49
+ async function readSnippet(filePath, maxChars = 200) {
50
+ try {
51
+ const content = await readFile(filePath, 'utf-8');
52
+ return content.slice(0, maxChars);
53
+ } catch (error) {
54
+ console.warn(`Failed to read ${filePath}: ${error.message}`);
55
+ return '';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if a file exists
61
+ * @param {string} filePath - Path to check
62
+ * @returns {Promise<boolean>} True if file exists
63
+ */
64
+ async function fileExists(filePath) {
65
+ try {
66
+ await access(filePath);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * List files in a directory (non-recursive)
75
+ * @param {string} dirPath - Directory path
76
+ * @returns {Promise<string[]>} Sorted array of filenames
77
+ */
78
+ async function listFiles(dirPath) {
79
+ try {
80
+ const entries = await readdir(dirPath, { withFileTypes: true });
81
+ return entries
82
+ .filter(entry => entry.isFile())
83
+ .map(entry => entry.name)
84
+ .sort();
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Sample first N files from directory and extract snippets
92
+ * Only processes .mjs, .js, .md, .ts files
93
+ * @param {string} dirPath - Directory path
94
+ * @param {number} maxSamples - Maximum files to sample
95
+ * @returns {Promise<Record<string, string>>} Map of filename to snippet
96
+ */
97
+ async function sampleSnippets(dirPath, maxSamples = 10) {
98
+ const files = await listFiles(dirPath);
99
+ const snippets = {};
100
+
101
+ // Filter to relevant extensions and take first N
102
+ const sampleFiles = files
103
+ .filter(file => {
104
+ const ext = extname(file);
105
+ return ['.mjs', '.js', '.md', '.ts'].includes(ext);
106
+ })
107
+ .slice(0, maxSamples);
108
+
109
+ for (const file of sampleFiles) {
110
+ const filePath = join(dirPath, file);
111
+ snippets[file] = await readSnippet(filePath);
112
+ }
113
+
114
+ return snippets;
115
+ }
116
+
117
+ /**
118
+ * Count files in test directory
119
+ * @param {string} packageDir - Package directory path
120
+ * @returns {Promise<number>} Count of test files
121
+ */
122
+ async function countTestFiles(packageDir) {
123
+ const testDir = join(packageDir, 'test');
124
+ const files = await listFiles(testDir);
125
+ return files.length;
126
+ }
127
+
128
+ /**
129
+ * Compute SHA256 fingerprint from evidence components
130
+ * @param {string[]} readmeHeadings - README headings
131
+ * @param {string[]} examplesFiles - Examples file list
132
+ * @param {string[]} docsFiles - Docs file list
133
+ * @param {string[]} srcFiles - Source file list
134
+ * @param {string[]} keywords - Package keywords
135
+ * @returns {string} Hex-encoded SHA256 hash
136
+ */
137
+ function computeFingerprint(readmeHeadings, examplesFiles, docsFiles, srcFiles, keywords) {
138
+ const combined = [
139
+ readmeHeadings.join('|'),
140
+ examplesFiles.join('|'),
141
+ docsFiles.join('|'),
142
+ srcFiles.join('|'),
143
+ keywords.join('|')
144
+ ].join('|');
145
+
146
+ return createHash('sha256').update(combined).digest('hex');
147
+ }
148
+
149
+ /**
150
+ * Collect evidence from package files to guide Diátaxis classification
151
+ * @param {string} packageDir - Absolute path to package directory
152
+ * @param {Object} packageJson - Parsed package.json object
153
+ * @returns {Promise<EvidenceSnapshot>} Evidence snapshot
154
+ * @throws {Error} If package.json is invalid or cannot be processed
155
+ */
156
+ export async function collectEvidence(packageDir, packageJson) {
157
+ // Validate inputs
158
+ if (!packageJson || typeof packageJson !== 'object') {
159
+ throw new Error('Invalid package.json: must be a valid object');
160
+ }
161
+
162
+ // Read README
163
+ let readmeContent = null;
164
+ let readmeHeadings = [];
165
+ const readmePath = join(packageDir, 'README.md');
166
+ if (await fileExists(readmePath)) {
167
+ try {
168
+ readmeContent = await readFile(readmePath, 'utf-8');
169
+ readmeHeadings = extractHeadings(readmeContent);
170
+ } catch (error) {
171
+ console.warn(`Failed to read README.md: ${error.message}`);
172
+ }
173
+ }
174
+
175
+ // List and sample examples/
176
+ const examplesDir = join(packageDir, 'examples');
177
+ const examplesFiles = await listFiles(examplesDir);
178
+ const examplesSnippets = await sampleSnippets(examplesDir);
179
+
180
+ // List and sample docs/
181
+ const docsDir = join(packageDir, 'docs');
182
+ const docsFiles = await listFiles(docsDir);
183
+ const docsSnippets = await sampleSnippets(docsDir);
184
+
185
+ // List src/ top-level .mjs/.js/.ts files
186
+ const srcDir = join(packageDir, 'src');
187
+ const allSrcFiles = await listFiles(srcDir);
188
+ const srcFiles = allSrcFiles.filter(file => {
189
+ const ext = extname(file);
190
+ return ['.mjs', '.js', '.ts'].includes(ext);
191
+ });
192
+
193
+ // Count test files
194
+ const testFileCount = await countTestFiles(packageDir);
195
+
196
+ // Extract from package.json
197
+ const binEntries = typeof packageJson.bin === 'object' ? packageJson.bin : {};
198
+ const exportSurface = typeof packageJson.exports === 'object' ? packageJson.exports : {};
199
+ const keywords = Array.isArray(packageJson.keywords) ? packageJson.keywords : [];
200
+
201
+ // Check file presence
202
+ const hasLicense = await fileExists(join(packageDir, 'LICENSE'));
203
+ const hasTsConfig = await fileExists(join(packageDir, 'tsconfig.json'));
204
+
205
+ // Compute fingerprint
206
+ const fingerprint = computeFingerprint(
207
+ readmeHeadings,
208
+ examplesFiles,
209
+ docsFiles,
210
+ srcFiles,
211
+ keywords
212
+ );
213
+
214
+ return {
215
+ readmeContent,
216
+ readmeHeadings,
217
+ examplesFiles,
218
+ examplesSnippets,
219
+ docsFiles,
220
+ docsSnippets,
221
+ srcFiles,
222
+ testFileCount,
223
+ binEntries,
224
+ exportSurface,
225
+ keywords,
226
+ hasLicense,
227
+ hasTsConfig,
228
+ fingerprint
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Hash all fields in evidence snapshot to detect changes
234
+ * Creates deterministic hash from all evidence fields
235
+ * @param {EvidenceSnapshot} evidenceSnapshot - Evidence snapshot to hash
236
+ * @returns {string} Hex-encoded SHA256 hash
237
+ */
238
+ export function hashEvidence(evidenceSnapshot) {
239
+ // Create stable string representation of all fields
240
+ const parts = [
241
+ evidenceSnapshot.readmeContent || '',
242
+ evidenceSnapshot.readmeHeadings.join('|'),
243
+ evidenceSnapshot.examplesFiles.join('|'),
244
+ Object.entries(evidenceSnapshot.examplesSnippets)
245
+ .sort(([a], [b]) => a.localeCompare(b))
246
+ .map(([k, v]) => `${k}:${v}`)
247
+ .join('|'),
248
+ evidenceSnapshot.docsFiles.join('|'),
249
+ Object.entries(evidenceSnapshot.docsSnippets)
250
+ .sort(([a], [b]) => a.localeCompare(b))
251
+ .map(([k, v]) => `${k}:${v}`)
252
+ .join('|'),
253
+ evidenceSnapshot.srcFiles.join('|'),
254
+ String(evidenceSnapshot.testFileCount),
255
+ Object.entries(evidenceSnapshot.binEntries)
256
+ .sort(([a], [b]) => a.localeCompare(b))
257
+ .map(([k, v]) => `${k}:${v}`)
258
+ .join('|'),
259
+ JSON.stringify(evidenceSnapshot.exportSurface),
260
+ evidenceSnapshot.keywords.join('|'),
261
+ String(evidenceSnapshot.hasLicense),
262
+ String(evidenceSnapshot.hasTsConfig),
263
+ evidenceSnapshot.fingerprint
264
+ ];
265
+
266
+ const combined = parts.join('||');
267
+ return createHash('sha256').update(combined).digest('hex');
268
+ }