@nan0web/release 3.3.0 → 3.5.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.
Files changed (94) hide show
  1. package/README.md +0 -35
  2. package/bin/nan0release.js +33 -2
  3. package/bin/serve.js +73 -0
  4. package/package.json +46 -17
  5. package/src/Release/Document.js +1 -0
  6. package/src/Release/Scanner.js +3 -2
  7. package/src/Release.js +7 -7
  8. package/src/domain/App.js +33 -2
  9. package/src/domain/BumpCommand.js +10 -3
  10. package/src/domain/CheckCommand.js +504 -146
  11. package/src/domain/CheckCommand.story.js +92 -0
  12. package/src/domain/CloseCommand.js +13 -9
  13. package/src/domain/Dashboard/Dashboard.story.js +155 -0
  14. package/src/domain/Dashboard/DashboardModel.js +308 -0
  15. package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
  16. package/src/domain/Dashboard/ReleaseParser.js +226 -0
  17. package/src/domain/Dashboard/index.js +19 -0
  18. package/src/domain/DepsCommand.js +5 -2
  19. package/src/domain/InitCommand.js +18 -16
  20. package/src/domain/ListCommand.js +45 -0
  21. package/src/domain/PMaCParser.js +418 -0
  22. package/src/domain/PMaCParser.story.js +152 -0
  23. package/src/domain/PreviewCommand.js +39 -0
  24. package/src/domain/PublishCommand.js +7 -3
  25. package/src/domain/Registry/Registry.story.js +82 -0
  26. package/src/domain/Registry/RegistryModel.js +124 -0
  27. package/src/domain/ServeCommand.js +165 -0
  28. package/src/domain/ServeCommand.story.js +88 -0
  29. package/src/domain/SpecCommand.js +3 -1
  30. package/src/domain/StatusCommand.js +249 -3
  31. package/src/domain/ValidateCommand.js +426 -0
  32. package/src/domain/ValidateCommand.story.js +92 -0
  33. package/src/domain/ViewCommand.js +99 -0
  34. package/src/domain/WebCommand.js +140 -0
  35. package/src/domain/inspect/ReleaseAuditor.js +508 -0
  36. package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
  37. package/src/domain/models/ReleaseIssueModel.js +27 -0
  38. package/src/domain/models/ReleaseMemberModel.js +21 -0
  39. package/src/domain/models/ReleaseModel.js +191 -0
  40. package/src/domain/models/ReleaseParityModel.js +27 -0
  41. package/src/domain/models/ReleaseTaskModel.js +33 -0
  42. package/src/domain/models/index.js +6 -0
  43. package/src/domain/templates/release.html +34 -0
  44. package/src/domain/templates/release.html.template.js +34 -0
  45. package/src/index.js +15 -0
  46. package/src/inspectors/inspect-js.js +32 -0
  47. package/src/releases/1/0/v1.0.3/task.test.js +1 -1
  48. package/src/utils/nodejs/GitTelemetry.js +154 -0
  49. package/types/Release/Document.d.ts +24 -27
  50. package/types/Release/MarkdownToTest.d.ts +1 -1
  51. package/types/Release/Person.d.ts +9 -7
  52. package/types/Release/Scanner.d.ts +41 -41
  53. package/types/Release.d.ts +42 -42
  54. package/types/architecture/ProjectManagementAsCode.d.ts +59 -64
  55. package/types/domain/App.d.ts +25 -7
  56. package/types/domain/BumpCommand.d.ts +11 -6
  57. package/types/domain/CheckCommand.d.ts +128 -16
  58. package/types/domain/CheckCommand.story.d.ts +1 -0
  59. package/types/domain/CloseCommand.d.ts +4 -6
  60. package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
  61. package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
  62. package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
  63. package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
  64. package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
  65. package/types/domain/Dashboard/index.d.ts +3 -0
  66. package/types/domain/DepsCommand.d.ts +6 -6
  67. package/types/domain/InitCommand.d.ts +4 -6
  68. package/types/domain/ListCommand.d.ts +18 -0
  69. package/types/domain/PMaCParser.d.ts +64 -0
  70. package/types/domain/PMaCParser.story.d.ts +1 -0
  71. package/types/domain/PreviewCommand.d.ts +6 -0
  72. package/types/domain/PublishCommand.d.ts +8 -6
  73. package/types/domain/Registry/Registry.story.d.ts +1 -0
  74. package/types/domain/Registry/RegistryModel.d.ts +61 -0
  75. package/types/domain/ServeCommand.d.ts +59 -0
  76. package/types/domain/ServeCommand.story.d.ts +1 -0
  77. package/types/domain/SpecCommand.d.ts +4 -6
  78. package/types/domain/StatusCommand.d.ts +38 -3
  79. package/types/domain/ValidateCommand.d.ts +69 -0
  80. package/types/domain/ValidateCommand.story.d.ts +1 -0
  81. package/types/domain/ViewCommand.d.ts +27 -0
  82. package/types/domain/WebCommand.d.ts +47 -0
  83. package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
  84. package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
  85. package/types/domain/models/ReleaseIssueModel.d.ts +35 -0
  86. package/types/domain/models/ReleaseMemberModel.d.ts +25 -0
  87. package/types/domain/models/ReleaseModel.d.ts +122 -0
  88. package/types/domain/models/ReleaseParityModel.d.ts +37 -0
  89. package/types/domain/models/ReleaseTaskModel.d.ts +46 -0
  90. package/types/domain/models/index.d.ts +5 -0
  91. package/types/domain/templates/release.html.template.d.ts +2 -0
  92. package/types/index.d.ts +21 -10
  93. package/types/inspectors/inspect-js.d.ts +2 -0
  94. package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
@@ -0,0 +1,508 @@
1
+ import { AuditorModel } from '@nan0web/inspect/domain/AuditorModel'
2
+ import { show, progress, result } from '@nan0web/ui'
3
+
4
+ /**
5
+ * @typedef {Object} ReleaseDiagnostic
6
+ * @property {'error' | 'warning' | 'info'} level
7
+ * @property {string} code
8
+ * @property {string} [check]
9
+ * @property {string} [file]
10
+ * @property {string} error
11
+ * @property {string} [suggestion]
12
+ * @property {string} [fix]
13
+ */
14
+
15
+ /**
16
+ * ReleaseAuditor — Architecture auditor and validator for PM-as-Code release structures.
17
+ */
18
+ export class ReleaseAuditor extends AuditorModel {
19
+ static alias = 'release'
20
+
21
+ static changed = {
22
+ help: 'Only inspect modified workspaces/releases',
23
+ default: false,
24
+ type: 'boolean',
25
+ alias: 'c',
26
+ }
27
+
28
+ static all = {
29
+ help: 'Audit all registered projects in releases.txt',
30
+ default: false,
31
+ type: 'boolean',
32
+ alias: 'a',
33
+ }
34
+
35
+ static registryFile = {
36
+ help: 'Path to releases.txt registry file',
37
+ default: 'releases.txt',
38
+ type: 'string',
39
+ alias: 'f',
40
+ }
41
+
42
+ static UI = {
43
+ title: 'PM-as-Code Release Structure Auditor',
44
+ header: '🔍 Running Release Quality & Compliance Audit for {$dir}...',
45
+ skipped: 'Release audit skipped (capped by active session)',
46
+ missingPackageJson: 'Missing package.json in {$dir}',
47
+ noReleasesDir: 'No release directories found under {$dir}',
48
+ releasesFound: 'Discovered {$count} release(s) in {$dir}',
49
+ releaseHeader: 'Auditing Release [v{$version}] at {$path}',
50
+ releaseDocCompleted: 'Release document valid: {$file} ({$done}/{$total} tasks completed)',
51
+ missingReleaseDoc: 'Missing release.md, task.md, or plan.md in {$path}',
52
+ missingUserDoc: 'Missing user.md metrics document in {$path}',
53
+ incompleteUserMetrics: 'user.md in {$path} is missing Hours, Iterations, or RRS score',
54
+ userMetricsValid: 'user.md metrics valid (Hours: {$hours}h, Iterations: {$iterations}, RRS: {$rrs})',
55
+ ambiguousContracts: 'Both task.spec.js (WIP) and task.test.js (Closed) exist simultaneously in {$path}',
56
+ missingContract: 'Missing contract test in {$path} (task.spec.js or task.test.js required)',
57
+ activeContractWip: 'Active contract present: task.spec.js [WIP]',
58
+ sealedRegressionClosed: 'Sealed regression test present: task.test.js [CLOSED]',
59
+ orphanFile: 'Orphan file found in releases/ root: {$file}',
60
+ summary: 'Release Audit Complete: {$releases} release(s) checked | Status: {$status} | Errors: {$errors} | Warnings: {$warnings}',
61
+ Diagnostics: {
62
+ MISSING_PACKAGE_JSON: {
63
+ level: 'error',
64
+ code: 'MISSING_PACKAGE_JSON',
65
+ message: 'Missing package.json in {$dir}',
66
+ fix: 'Ensure package.json exists with valid name and version.',
67
+ },
68
+ MISSING_RELEASES_DIR: {
69
+ level: 'error',
70
+ code: 'MISSING_RELEASES_DIR',
71
+ message: 'No release directories found under {$dir}',
72
+ fix: 'Initialize release directory: pnpm exec release init v0.1.0',
73
+ },
74
+ MISSING_RELEASE_MD: {
75
+ level: 'error',
76
+ code: 'MISSING_RELEASE_MD',
77
+ message: 'Missing release.md or task.md in {$path}',
78
+ fix: 'Create {$path}/release.md with release scope, user stories, and acceptance checklist.',
79
+ },
80
+ MISSING_USER_MD: {
81
+ level: 'warning',
82
+ code: 'MISSING_USER_MD',
83
+ message: 'Missing user.md metrics document in {$path}',
84
+ fix: 'Create {$path}/user.md containing Execution Metrics (Hours, Iterations, and RRS score).',
85
+ },
86
+ INCOMPLETE_USER_METRICS: {
87
+ level: 'warning',
88
+ code: 'INCOMPLETE_USER_METRICS',
89
+ message: 'user.md in {$path} is missing hours, iterations or RRS score',
90
+ fix: 'Update {$path}/user.md with sections: "Витрачений час розробки (Годин):", "Кількість ітерацій:", "Фінальний бал RRS:".',
91
+ },
92
+ AMBIGUOUS_CONTRACTS: {
93
+ level: 'error',
94
+ code: 'AMBIGUOUS_CONTRACTS',
95
+ message: 'Both task.spec.js and task.test.js exist simultaneously in {$path}',
96
+ fix: 'If release is still active, keep task.spec.js. If release is sealed/closed, keep task.test.js.',
97
+ },
98
+ MISSING_CONTRACT: {
99
+ level: 'error',
100
+ code: 'MISSING_CONTRACT',
101
+ message: 'No contract test found in {$path} (missing task.spec.js or task.test.js)',
102
+ fix: 'Create contract test {$path}/task.spec.js for TDD verification.',
103
+ },
104
+ ORPHAN_RELEASE_FILE: {
105
+ level: 'warning',
106
+ code: 'ORPHAN_RELEASE_FILE',
107
+ message: 'Orphan file "{$file}" located directly in releases/ directory outside of version folders',
108
+ fix: 'Move "{$file}" into the appropriate version directory (e.g. releases/vX.Y.Z/) or backlog.md.',
109
+ },
110
+ PROCEDURAL_RELEASE_ARTIFACT: {
111
+ level: 'warning',
112
+ code: 'PROCEDURAL_RELEASE_ARTIFACT',
113
+ message: 'Procedural artifact "{$file}" found in release {$path}. Releases must be pure Data-Driven.',
114
+ fix: 'Remove "{$file}". Use data-driven *.nan0 documents and multimodal runners (serve releases/) instead of ad-hoc HTML/JS.',
115
+ },
116
+ },
117
+ }
118
+
119
+ /**
120
+ * @param {Partial<ReleaseAuditor> | Record<string, any>} [data={}]
121
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options={}]
122
+ */
123
+ constructor(data = {}, options = {}) {
124
+ super(data, options)
125
+ /** @type {boolean} */ this.changed = Boolean(data.changed || (options && 'changed' in options ? /** @type {any} */ (options).changed : false))
126
+ /** @type {boolean} */ this.all = Boolean(data.all || (options && 'all' in options ? /** @type {any} */ (options).all : false))
127
+ /** @type {string} */ this.registryFile = String(data.registryFile || 'releases.txt')
128
+ }
129
+
130
+ /**
131
+ * Runs the PM-as-Code release audit.
132
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
133
+ */
134
+ async *run() {
135
+ const db = this._.db
136
+ /** @type {import('@nan0web/i18n').TFunction} */
137
+ const t = this._.t || ((key, params) => {
138
+ let str = String(key || '')
139
+ if (params) {
140
+ for (const [k, v] of Object.entries(params)) {
141
+ str = str.replaceAll(`{$${k}}`, String(v)).replaceAll(`{${k}}`, String(v))
142
+ }
143
+ }
144
+ return str
145
+ })
146
+
147
+ if (await this.isCapped()) {
148
+ yield progress(t(ReleaseAuditor.UI.skipped))
149
+ return result({
150
+ success: true,
151
+ skipped: true,
152
+ errors: [],
153
+ warnings: [],
154
+ checkedReleases: [],
155
+ })
156
+ }
157
+
158
+ yield progress(t(ReleaseAuditor.UI.header, { dir: this.dir }))
159
+
160
+ /** @type {ReleaseDiagnostic[]} */
161
+ const errors = []
162
+ /** @type {ReleaseDiagnostic[]} */
163
+ const warnings = []
164
+ const checkedReleases = []
165
+
166
+ const projectRoot = this.dir === '.' || !this.dir ? '' : `${this.dir.replace(/\/+$/, '')}/`
167
+
168
+ // 1. Check package.json if auditing specific directory
169
+ if (projectRoot) {
170
+ const hasPkg = await this.fileExists('package.json')
171
+ if (!hasPkg) {
172
+ const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_PACKAGE_JSON
173
+ const err = {
174
+ level: /** @type {'error'} */ ('error'),
175
+ code: diagDef.code,
176
+ check: 'package.json',
177
+ file: `${projectRoot}package.json`,
178
+ error: t(diagDef.message, { dir: this.dir }),
179
+ fix: t(diagDef.fix, { dir: this.dir }),
180
+ }
181
+ errors.push(err)
182
+ yield show(err.error, 'error')
183
+ }
184
+ }
185
+
186
+ // 2. Discover release directories
187
+ const releaseDirs = await this.#discoverReleases(db, projectRoot)
188
+
189
+ if (releaseDirs.length === 0) {
190
+ const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_RELEASES_DIR
191
+ const err = {
192
+ level: /** @type {'error'} */ ('error'),
193
+ code: diagDef.code,
194
+ check: 'releases/',
195
+ file: `${projectRoot}releases/`,
196
+ error: t(diagDef.message, { dir: this.dir }),
197
+ fix: t(diagDef.fix, { dir: this.dir }),
198
+ }
199
+ errors.push(err)
200
+ yield show(err.error, 'error')
201
+ } else {
202
+ yield show(t(ReleaseAuditor.UI.releasesFound, { count: releaseDirs.length, dir: this.dir }), 'info')
203
+ }
204
+
205
+ // 3. Check each release directory
206
+ for (const rel of releaseDirs) {
207
+ const relPath = rel.path
208
+ yield show(t(ReleaseAuditor.UI.releaseHeader, { version: rel.version, path: relPath }))
209
+
210
+ // A. Check release.md / task.md / plan.md
211
+ let releaseContent = null
212
+ let releaseDocFile = ''
213
+ for (const docFile of ['release.md', 'task.md', 'plan.md']) {
214
+ try {
215
+ const doc = await db.loadDocument(`${relPath}/${docFile}`, null)
216
+ if (doc) {
217
+ releaseContent = String(doc)
218
+ releaseDocFile = `${relPath}/${docFile}`
219
+ break
220
+ }
221
+ } catch {}
222
+ }
223
+
224
+ if (!releaseContent) {
225
+ const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_RELEASE_MD
226
+ const err = {
227
+ level: /** @type {'error'} */ ('error'),
228
+ code: diagDef.code,
229
+ check: 'release.md',
230
+ file: `${relPath}/release.md`,
231
+ error: t(diagDef.message, { path: relPath }),
232
+ fix: t(diagDef.fix, { path: relPath }),
233
+ }
234
+ errors.push(err)
235
+ yield show(err.error, 'error')
236
+ } else {
237
+ const { done, total } = this.#countTasks(releaseContent)
238
+ yield show(
239
+ t(ReleaseAuditor.UI.releaseDocCompleted, {
240
+ file: releaseDocFile.split('/').pop(),
241
+ done,
242
+ total,
243
+ }),
244
+ 'success'
245
+ )
246
+ }
247
+
248
+ // B. Check user.md metrics
249
+ let userDoc = null
250
+ try {
251
+ userDoc = await db.loadDocument(`${relPath}/user.md`, null)
252
+ } catch {}
253
+
254
+ if (!userDoc) {
255
+ const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_USER_MD
256
+ const warn = {
257
+ level: /** @type {'warning'} */ ('warning'),
258
+ code: diagDef.code,
259
+ check: 'user.md',
260
+ file: `${relPath}/user.md`,
261
+ error: t(diagDef.message, { path: relPath }),
262
+ fix: t(diagDef.fix, { path: relPath }),
263
+ }
264
+ warnings.push(warn)
265
+ yield show(warn.error, 'warn')
266
+ } else {
267
+ const userStr = typeof userDoc === 'object' ? JSON.stringify(userDoc) : String(userDoc)
268
+ const hasHours =
269
+ /витрачений час/i.test(userStr) ||
270
+ /hours/i.test(userStr) ||
271
+ userDoc.hours !== undefined ||
272
+ userDoc.time !== undefined
273
+ const hasIters =
274
+ /кількість ітерацій/i.test(userStr) ||
275
+ /iterations/i.test(userStr) ||
276
+ userDoc.iterations !== undefined
277
+ const hasRrs =
278
+ /rrs/i.test(userStr) ||
279
+ /score/i.test(userStr) ||
280
+ userDoc.rrs !== undefined
281
+
282
+ if (!hasHours || !hasIters || !hasRrs) {
283
+ const diagDef = ReleaseAuditor.UI.Diagnostics.INCOMPLETE_USER_METRICS
284
+ const warn = {
285
+ level: /** @type {'warning'} */ ('warning'),
286
+ code: diagDef.code,
287
+ check: 'user.md',
288
+ file: `${relPath}/user.md`,
289
+ error: t(diagDef.message, { path: relPath }),
290
+ fix: t(diagDef.fix, { path: relPath }),
291
+ }
292
+ warnings.push(warn)
293
+ yield show(warn.error, 'warn')
294
+ } else {
295
+ yield show(t(ReleaseAuditor.UI.userMetricsValid, { hours: 'ok', iterations: 'ok', rrs: 'ok' }), 'success')
296
+ }
297
+ }
298
+
299
+ // C. Check contract tests (task.spec.js / task.test.js)
300
+ let hasSpec = false
301
+ let hasTest = false
302
+ try {
303
+ const spec = await db.loadDocument(`${relPath}/task.spec.js`, null)
304
+ if (spec) hasSpec = true
305
+ } catch {}
306
+
307
+ try {
308
+ const test = await db.loadDocument(`${relPath}/task.test.js`, null)
309
+ if (test) hasTest = true
310
+ } catch {}
311
+
312
+ if (hasSpec && hasTest) {
313
+ const diagDef = ReleaseAuditor.UI.Diagnostics.AMBIGUOUS_CONTRACTS
314
+ const err = {
315
+ level: /** @type {'error'} */ ('error'),
316
+ code: diagDef.code,
317
+ check: 'task.spec.js',
318
+ file: `${relPath}/task.spec.js`,
319
+ error: t(diagDef.message, { path: relPath }),
320
+ fix: t(diagDef.fix, { path: relPath }),
321
+ }
322
+ errors.push(err)
323
+ yield show(err.error, 'error')
324
+ } else if (!hasSpec && !hasTest) {
325
+ const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_CONTRACT
326
+ const err = {
327
+ level: /** @type {'error'} */ ('error'),
328
+ code: diagDef.code,
329
+ check: 'task.spec.js',
330
+ file: `${relPath}/task.spec.js`,
331
+ error: t(diagDef.message, { path: relPath }),
332
+ fix: t(diagDef.fix, { path: relPath }),
333
+ }
334
+ errors.push(err)
335
+ yield show(err.error, 'error')
336
+ } else if (hasSpec) {
337
+ yield show(t(ReleaseAuditor.UI.activeContractWip), 'success')
338
+ } else if (hasTest) {
339
+ yield show(t(ReleaseAuditor.UI.sealedRegressionClosed), 'success')
340
+ }
341
+
342
+ // D. Check for prohibited procedural artifacts (index.html, play.js)
343
+ for (const procName of ['index.html', 'play.js']) {
344
+ try {
345
+ const hasProc = await db.loadDocument(`${relPath}/${procName}`, null)
346
+ if (hasProc) {
347
+ const diagDef = ReleaseAuditor.UI.Diagnostics.PROCEDURAL_RELEASE_ARTIFACT
348
+ const warn = {
349
+ level: /** @type {'warning'} */ ('warning'),
350
+ code: diagDef.code,
351
+ check: procName,
352
+ file: `${relPath}/${procName}`,
353
+ error: t(diagDef.message, { file: procName, path: relPath }),
354
+ fix: t(diagDef.fix, { file: procName, path: relPath }),
355
+ }
356
+ warnings.push(warn)
357
+ yield show(warn.error, 'warn')
358
+ }
359
+ } catch {}
360
+ }
361
+
362
+ checkedReleases.push({
363
+ version: rel.version,
364
+ path: rel.path,
365
+ valid: errors.filter((e) => e.file?.startsWith(relPath)).length === 0,
366
+ })
367
+ }
368
+
369
+ // 4. Check for orphan files in releases/ root
370
+ const orphanFiles = await this.#findOrphanReleaseFiles(db, projectRoot)
371
+ for (const orphan of orphanFiles) {
372
+ const diagDef = ReleaseAuditor.UI.Diagnostics.ORPHAN_RELEASE_FILE
373
+ const warn = {
374
+ level: /** @type {'warning'} */ ('warning'),
375
+ code: diagDef.code,
376
+ check: orphan,
377
+ file: `${projectRoot}releases/${orphan}`,
378
+ error: t(diagDef.message, { file: orphan }),
379
+ fix: t(diagDef.fix, { file: orphan }),
380
+ }
381
+ warnings.push(warn)
382
+ yield show(warn.error, 'warn')
383
+ }
384
+
385
+ const isSuccess = errors.length === 0
386
+ yield show(
387
+ t(ReleaseAuditor.UI.summary, {
388
+ releases: checkedReleases.length,
389
+ status: isSuccess ? 'PASSED' : 'FAILED',
390
+ errors: errors.length,
391
+ warnings: warnings.length,
392
+ }),
393
+ isSuccess ? 'success' : 'warn'
394
+ )
395
+
396
+ return result({
397
+ success: isSuccess,
398
+ errors,
399
+ warnings,
400
+ checkedReleases,
401
+ diagnostics: [...errors, ...warnings],
402
+ })
403
+ }
404
+
405
+ /**
406
+ * Count tasks in markdown content
407
+ * @param {string} content
408
+ * @returns {{ done: number, total: number }}
409
+ */
410
+ #countTasks(content = '') {
411
+ let total = 0
412
+ let done = 0
413
+ for (const line of content.split('\n')) {
414
+ const m = line.match(/^[-*+]?\s*(?:\[([ xX])\]|\d+\.\s*\[([ xX])\])\s*(.+)$/)
415
+ if (m) {
416
+ total++
417
+ if ((m[1] || m[2] || '').toLowerCase() === 'x') done++
418
+ }
419
+ }
420
+ return { done, total }
421
+ }
422
+
423
+ /**
424
+ * Discover release version directories using database
425
+ * @param {any} db
426
+ * @param {string} projectRoot
427
+ * @returns {Promise<Array<{ version: string, path: string }>>}
428
+ */
429
+ async #discoverReleases(db, projectRoot) {
430
+ const results = []
431
+ const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
432
+
433
+ try {
434
+ if (typeof db.listDir === 'function') {
435
+ const queue = [{ uri: releasesUri, depth: 0 }]
436
+ while (queue.length > 0) {
437
+ const item = queue.shift()
438
+ if (!item || item.depth > 3) continue
439
+ let entries = []
440
+ try {
441
+ entries = await db.listDir(item.uri)
442
+ } catch {
443
+ continue
444
+ }
445
+ if (!Array.isArray(entries)) continue
446
+
447
+ for (const entry of entries) {
448
+ const rawName = entry.name || (entry.path || '').split('/').pop() || ''
449
+ const name = rawName.replace(/\/$/, '')
450
+ const isDir = Boolean(
451
+ entry.stat?.isDirectory ||
452
+ entry.isDirectory ||
453
+ entry.isDir ||
454
+ rawName.endsWith('/')
455
+ )
456
+
457
+ const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
458
+ if (dirMatch) {
459
+ const version = dirMatch[1]
460
+ const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
461
+ const normalizedPath = entryPath.replace(/^\.\//, '')
462
+ if (!results.find((r) => r.path === normalizedPath)) {
463
+ results.push({ version, path: normalizedPath })
464
+ }
465
+ } else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
466
+ const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
467
+ queue.push({ uri: nextUri, depth: item.depth + 1 })
468
+ }
469
+ }
470
+ }
471
+ }
472
+ } catch {}
473
+
474
+ return results
475
+ }
476
+
477
+ /**
478
+ * Find orphan files inside releases/ directory using db
479
+ * @param {any} db
480
+ * @param {string} projectRoot
481
+ * @returns {Promise<string[]>}
482
+ */
483
+ async #findOrphanReleaseFiles(db, projectRoot) {
484
+ const orphans = []
485
+ const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
486
+
487
+ try {
488
+ if (typeof db.listDir === 'function') {
489
+ const entries = await db.listDir(releasesUri, { depth: 0 })
490
+ for (const entry of entries) {
491
+ const isFile = !entry.isDirectory && (entry.isFile || !entry.isDir)
492
+ const name = entry.name || (entry.path || '').split('/').pop() || ''
493
+ if (
494
+ isFile &&
495
+ !['index.js', 'current.js', 'README.md', 'backlog.md'].includes(name) &&
496
+ !name.startsWith('.')
497
+ ) {
498
+ orphans.push(name)
499
+ }
500
+ }
501
+ }
502
+ } catch {}
503
+
504
+ return orphans
505
+ }
506
+ }
507
+
508
+ export default ReleaseAuditor
@@ -0,0 +1,136 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import DB from '@nan0web/db'
4
+ import { ReleaseAuditor } from './ReleaseAuditor.js'
5
+ import { AuditorModel } from '@nan0web/inspect'
6
+
7
+ describe('ReleaseAuditor OLMUI Scenario & Architecture Auditor', () => {
8
+ it('inherits from AuditorModel and has release alias', () => {
9
+ assert.equal(ReleaseAuditor.alias, 'release')
10
+ assert.ok(Object.prototype.isPrototypeOf.call(AuditorModel, ReleaseAuditor))
11
+ })
12
+
13
+ it('successfully audits a valid release structure without diagnostics errors', async () => {
14
+ const pkgJson = { name: '@nan0web/sample-pkg', version: '1.0.0' }
15
+ const releaseMd = `# v1.0.0 - 2026-09-01
16
+ ## Tasks
17
+ - [x] Done task
18
+ `
19
+ const userMd = `---
20
+ name: user-feedback
21
+ version: 1.0.0
22
+ ---
23
+ ## ⏱ 1. Метрики Виконання
24
+ - **Витрачений час розробки (Годин):** \`4.5h\`
25
+ - **Кількість ітерацій (Test Runs / Refactors):** \`10\`
26
+ - **Фінальний бал RRS (Release Readiness Score):** \`324\`
27
+ `
28
+ const taskSpec = `import { describe, it } from 'node:test'
29
+ import assert from 'node:assert/strict'
30
+ describe('contract', () => {
31
+ it('passes', () => assert.ok(true))
32
+ })
33
+ `
34
+ const db = new DB({
35
+ predefined: [
36
+ ['packages/sample-pkg/package.json', pkgJson],
37
+ ['packages/sample-pkg/releases/1/0/v1.0.0/release.md', releaseMd],
38
+ ['packages/sample-pkg/releases/1/0/v1.0.0/user.md', userMd],
39
+ ['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', taskSpec],
40
+ ],
41
+ })
42
+ await db.connect()
43
+
44
+ const auditor = new ReleaseAuditor({ dir: 'packages/sample-pkg' }, { db })
45
+ await auditor.init()
46
+
47
+ const gen = auditor.run()
48
+ let step = await gen.next()
49
+ const intents = []
50
+ while (!step.done) {
51
+ intents.push(step.value)
52
+ step = await gen.next()
53
+ }
54
+
55
+ const res = step.value?.data || step.value
56
+ assert.ok(res.success)
57
+ assert.equal(res.errors.length, 0)
58
+ assert.equal(res.checkedReleases.length, 1)
59
+ })
60
+
61
+ it('detects missing release documents, user.md, and contract files with actionable diagnostics', async () => {
62
+ const pkgJson = { name: '@nan0web/broken-pkg', version: '2.0.0' }
63
+ const db = new DB({
64
+ predefined: [
65
+ ['packages/broken-pkg/package.json', pkgJson],
66
+ ['packages/broken-pkg/releases/2/0/v2.0.0/dummy.txt', 'empty'],
67
+ ],
68
+ })
69
+ await db.connect()
70
+
71
+ const auditor = new ReleaseAuditor({ dir: 'packages/broken-pkg' }, { db })
72
+ await auditor.init()
73
+
74
+ const gen = auditor.run()
75
+ let step = await gen.next()
76
+ while (!step.done) {
77
+ step = await gen.next()
78
+ }
79
+
80
+ const res = step.value?.data || step.value
81
+ assert.equal(res.success, false)
82
+ assert.ok(res.errors.length >= 2)
83
+
84
+ const codes = res.errors.map((e) => e.code || e.check)
85
+ assert.ok(codes.includes('MISSING_RELEASE_MD') || codes.includes('release.md'))
86
+ assert.ok(codes.includes('MISSING_CONTRACT') || codes.includes('task.spec.js'))
87
+ })
88
+
89
+ it('detects ambiguous contracts when both task.spec.js and task.test.js exist', async () => {
90
+ const db = new DB({
91
+ predefined: [
92
+ ['packages/ambig/package.json', { name: '@nan0web/ambig', version: '1.0.0' }],
93
+ ['packages/ambig/releases/1/0/v1.0.0/release.md', '# Release\n- [ ] Task'],
94
+ ['packages/ambig/releases/1/0/v1.0.0/user.md', 'Hours: 1.0h\nIterations: 2\nRRS: 300'],
95
+ ['packages/ambig/releases/1/0/v1.0.0/task.spec.js', '// spec'],
96
+ ['packages/ambig/releases/1/0/v1.0.0/task.test.js', '// test'],
97
+ ],
98
+ })
99
+ await db.connect()
100
+
101
+ const auditor = new ReleaseAuditor({ dir: 'packages/ambig' }, { db })
102
+ await auditor.init()
103
+
104
+ const gen = auditor.run()
105
+ let step = await gen.next()
106
+ while (!step.done) {
107
+ step = await gen.next()
108
+ }
109
+
110
+ const res = step.value?.data || step.value
111
+ assert.equal(res.success, false)
112
+ const codes = res.errors.map((e) => e.code || e.check)
113
+ assert.ok(codes.includes('AMBIGUOUS_CONTRACTS') || codes.includes('contracts'))
114
+ })
115
+
116
+ it('skips execution gracefully if auditor is capped by active session', async () => {
117
+ const db = new DB({
118
+ predefined: [
119
+ ['.agent/active_session.json', { disabledInspectors: ['release'] }],
120
+ ['packages/any/package.json', { name: 'any' }],
121
+ ],
122
+ })
123
+ await db.connect()
124
+
125
+ const auditor = new ReleaseAuditor({ dir: 'packages/any' }, { db })
126
+ await auditor.init()
127
+
128
+ const gen = auditor.run()
129
+ let step = await gen.next()
130
+ while (!step.done) {
131
+ step = await gen.next()
132
+ }
133
+ const res = step.value?.data || step.value
134
+ assert.ok(res.skipped)
135
+ })
136
+ })
@@ -0,0 +1,27 @@
1
+ import { Model } from '@nan0web/types'
2
+
3
+ export class ReleaseIssueModel extends Model {
4
+ static level = { type: 'string', default: 'warning' } // 'error' | 'warning' | 'info'
5
+ static code = { type: 'string' }
6
+ static message = { type: 'string', required: true }
7
+ static source = { type: 'string' }
8
+ static file = { type: 'string' }
9
+
10
+ /**
11
+ * @param {Partial<ReleaseIssueModel>} [data={}]
12
+ * @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
13
+ */
14
+ constructor(data = {}, options = {}) {
15
+ super(data, options)
16
+ /** @type { 'error' | 'warning' | 'info' } Level of issue */
17
+ this.level
18
+ /** @type {string} Issue code */
19
+ this.code
20
+ /** @type {string} Issue message */
21
+ this.message
22
+ /** @type {string} Issue source */
23
+ this.source
24
+ /** @type {string} Issue file */
25
+ this.file
26
+ }
27
+ }
@@ -0,0 +1,21 @@
1
+ import { Model } from '@nan0web/types'
2
+
3
+ export class ReleaseMemberModel extends Model {
4
+ static role = { type: 'string', required: true }
5
+ static name = { type: 'string', required: true }
6
+ static handle = { type: 'string' }
7
+
8
+ /**
9
+ * @param {Partial<ReleaseMemberModel>} [data={}]
10
+ * @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
11
+ */
12
+ constructor(data = {}, options = {}) {
13
+ super(data, options)
14
+ /** @type {string} Role / Position (Посада) */
15
+ this.role
16
+ /** @type {string} Full Name (Ім'я) */
17
+ this.name
18
+ /** @type {string} Login / Handle (Логін без @) */
19
+ this.handle
20
+ }
21
+ }