@nan0web/release 3.4.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.
@@ -0,0 +1,152 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import {
4
+ ReleaseModel,
5
+ ReleaseTaskModel,
6
+ ReleaseParityModel,
7
+ ReleaseIssueModel,
8
+ } from './models/index.js'
9
+ import { PMaCParser, resolveTrackerUrl } from './PMaCParser.js'
10
+
11
+ describe('PMaC Parser & Release Models Contract', () => {
12
+ const sampleMarkdown = `---
13
+ status: work
14
+ type: minor
15
+ epic: EPIC-CORE-42
16
+ sprint: Sprint 42
17
+ tracker: https://github.com/nan0web/nan.web/issues/{id}
18
+ ---
19
+
20
+ # v0.1.0 — Core Release Engine & Payload CMS Plugin Integration
21
+
22
+ ## Опис та Мета
23
+ Плагін @nan0web/payload-release для Payload CMS 3.x, що забезпечує автоматичну фіксацію запусків релізів.
24
+
25
+ ## Команда
26
+ - **Lead / Architect:** Олександр Снігірьов (@snigirev)
27
+ - **AI Lead Developer:** Agent Antigravity (@agy)
28
+
29
+ ## Робочий План та Задачі
30
+
31
+ ### 1. Архітектурна ізоляція моделей [#101]
32
+ - [x] [#101-1] Винести базову модель ReleaseModel у пакет @nan0web/release
33
+ - [x] [#101-2] Реалізувати уніфікований AST-парсер специфікацій на базі @nan0web/markdown
34
+ - [ ] [#101-3] Забезпечити 100% покриття контрактними тестами
35
+
36
+ ### 2. Payload CMS Адаптер [#102]
37
+ - [x] [#102-1] Реалізувати колекцію releases з табами
38
+ - [ ] [#102-3] Перегляд вмісту специфікацій через модальне вікно
39
+
40
+ ## Аудит та Паритет Даних
41
+ | Колекція | Очікувано | Фактично | Статус | Коментар |
42
+ | :--- | :---: | :---: | :---: | :--- |
43
+ | \`users\` | 12 | 12 | ✅ OK | Синхронізовано |
44
+ | \`media\` | 45 | 44 | ⚠️ Mismatch | Відсутній аватар для demo |
45
+
46
+ ## Зауваження та Ризики
47
+ - **[WARN-001]** (Performance) Файли розміром > 2MB не повинні відкриватися без пагінації.
48
+ - **[INFO-002]** (Environment) Node.js v20.x є мінімально підтримуваною версією.
49
+
50
+ ## Артефакти
51
+ | Файл | Розмір | SHA-256 | Тип |
52
+ | :--- | :---: | :--- | :--- |
53
+ | \`task.md\` | 6.0 KB | \`e3b0c442...\` | ТЗ / Специфікація |
54
+ `
55
+
56
+ it('ReleaseModel instantiates with schema defaults and validates', () => {
57
+ const release = new ReleaseModel({
58
+ version: 'v0.1.0',
59
+ title: 'Core Release Engine',
60
+ })
61
+
62
+ assert.strictEqual(release.version, 'v0.1.0')
63
+ assert.strictEqual(release.title, 'Core Release Engine')
64
+ assert.strictEqual(release.status, 'todo')
65
+ assert.strictEqual(release.type, 'minor')
66
+ assert.deepStrictEqual(release.tasks, [])
67
+ assert.deepStrictEqual(release.parity, [])
68
+ assert.deepStrictEqual(release.issues, [])
69
+ assert.strictEqual(release.validate(), true)
70
+ })
71
+
72
+ it('PMaCParser extracts version, title and metadata from H1 and frontmatter', () => {
73
+ const release = PMaCParser.parse(sampleMarkdown)
74
+
75
+ assert.strictEqual(release.version, 'v0.1.0')
76
+ assert.strictEqual(release.title, 'Core Release Engine & Payload CMS Plugin Integration')
77
+ assert.strictEqual(release.status, 'work')
78
+ assert.strictEqual(release.type, 'minor')
79
+ assert.strictEqual(release.epic, 'EPIC-CORE-42')
80
+ assert.strictEqual(release.sprint, 'Sprint 42')
81
+ assert.strictEqual(release.tracker, 'https://github.com/nan0web/nan.web/issues/{id}')
82
+ assert.ok(release.summary.includes('Плагін @nan0web/payload-release'))
83
+ })
84
+
85
+ it('PMaCParser parses tasks with status and IDs', () => {
86
+ const release = PMaCParser.parse(sampleMarkdown)
87
+
88
+ assert.strictEqual(release.tasks.length, 5)
89
+ const task1 = release.tasks[0]
90
+ assert.strictEqual(task1.id, '101-1')
91
+ assert.strictEqual(task1.title, 'Винести базову модель ReleaseModel у пакет @nan0web/release')
92
+ assert.strictEqual(task1.status, 'done')
93
+
94
+ const task3 = release.tasks[2]
95
+ assert.strictEqual(task3.id, '101-3')
96
+ assert.strictEqual(task3.status, 'todo')
97
+ })
98
+
99
+ it('PMaCParser parses data parity table into ReleaseParityModel items', () => {
100
+ const release = PMaCParser.parse(sampleMarkdown)
101
+
102
+ assert.strictEqual(release.parity.length, 2)
103
+ const p1 = release.parity[0]
104
+ assert.strictEqual(p1.collection, 'users')
105
+ assert.strictEqual(p1.expected, 12)
106
+ assert.strictEqual(p1.actual, 12)
107
+ assert.strictEqual(p1.synced, true)
108
+
109
+ const p2 = release.parity[1]
110
+ assert.strictEqual(p2.collection, 'media')
111
+ assert.strictEqual(p2.expected, 45)
112
+ assert.strictEqual(p2.actual, 44)
113
+ assert.strictEqual(p2.synced, false)
114
+ })
115
+
116
+ it('PMaCParser parses issues and risks into ReleaseIssueModel items', () => {
117
+ const release = PMaCParser.parse(sampleMarkdown)
118
+
119
+ assert.strictEqual(release.issues.length, 2)
120
+ const issue1 = release.issues[0]
121
+ assert.strictEqual(issue1.code, 'WARN-001')
122
+ assert.strictEqual(issue1.level, 'warning')
123
+ assert.ok(issue1.message.includes('(Performance)'))
124
+
125
+ const issue2 = release.issues[1]
126
+ assert.strictEqual(issue2.code, 'INFO-002')
127
+ assert.strictEqual(issue2.level, 'info')
128
+ })
129
+
130
+ it('PMaCParser parses artifacts table into files array', () => {
131
+ const release = PMaCParser.parse(sampleMarkdown)
132
+
133
+ assert.strictEqual(release.files.length, 1)
134
+ assert.strictEqual(release.files[0].file, 'task.md')
135
+ assert.strictEqual(release.files[0].size, '6.0 KB')
136
+ })
137
+
138
+ it('resolveTrackerUrl resolves templated ticket URLs', () => {
139
+ const url = resolveTrackerUrl('[#142]', 'https://github.com/nan0web/nan.web/issues/{id}')
140
+ assert.strictEqual(url, 'https://github.com/nan0web/nan.web/issues/142')
141
+
142
+ const url2 = resolveTrackerUrl('PROJ-99', 'https://jira.company.com/browse/{id}')
143
+ assert.strictEqual(url2, 'https://jira.company.com/browse/PROJ-99')
144
+ })
145
+
146
+ it('ReleaseModel.addLocaleSections extends recognized synonyms', () => {
147
+ ReleaseModel.addLocaleSections({
148
+ tasks: ['aufgaben', 'arbeitsplan'],
149
+ })
150
+ assert.ok(ReleaseModel.SECTIONS.tasks.includes('aufgaben'))
151
+ })
152
+ })
@@ -0,0 +1,426 @@
1
+ import { ModelAsApp, show, result, progress } from '@nan0web/ui'
2
+ import { NaN0 } from '@nan0web/types'
3
+ import { stringifyCSV } from '@nan0web/csv0'
4
+ import { RegistryModel } from './Registry/RegistryModel.js'
5
+ import { DashboardModel } from './Dashboard/DashboardModel.js'
6
+ import { PMaCParser } from './PMaCParser.js'
7
+ import { ReleaseAuditor } from './inspect/ReleaseAuditor.js'
8
+
9
+ /**
10
+ * ValidateCommand - CLI Release Inspector & Multi-Format Validator.
11
+ * Validates PMaC compliance, tasks, criteria, parity and outputs in text, json, nan0, or csv.
12
+ */
13
+ export class ValidateCommand extends ModelAsApp {
14
+ static alias = 'validate'
15
+
16
+ static UI = {
17
+ title: 'PM-as-Code Release Inspector & Multi-Format Validator',
18
+ header: '🔍 Release Inspector & Validator (nan0release validate)',
19
+ scanningCount: 'Scanning {count} project(s)...',
20
+ projectHeader: '\n📦 Project: {name} ({path})',
21
+ noReleases: ' ⚠️ No releases discovered in {path}',
22
+ savedReport: '💾 Report saved to {path}',
23
+ summary: '\n📊 Validation Summary: {projects} project(s) checked | {releases} release(s) | Status: {status}',
24
+ }
25
+
26
+ static target = {
27
+ help: 'Target path, package name, or release directory to validate',
28
+ default: '',
29
+ type: 'string',
30
+ positional: true,
31
+ }
32
+
33
+ static format = {
34
+ help: 'Output format (text, json, nan0, csv)',
35
+ default: 'text',
36
+ options: ['text', 'json', 'nan0', 'csv'],
37
+ type: 'string',
38
+ alias: 'm',
39
+ }
40
+
41
+ static output = {
42
+ help: 'Path to save output report file',
43
+ default: '',
44
+ type: 'string',
45
+ alias: 'o',
46
+ }
47
+
48
+ static registryFile = {
49
+ help: 'Path to releases.txt registry file',
50
+ default: 'releases.txt',
51
+ type: 'string',
52
+ alias: 'f',
53
+ }
54
+
55
+ static all = {
56
+ help: 'Validate all projects registered in releases.txt',
57
+ default: false,
58
+ type: 'boolean',
59
+ alias: 'a',
60
+ }
61
+
62
+ /**
63
+ * @param {Partial<ValidateCommand>} [data]
64
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options]
65
+ */
66
+ constructor(data = {}, options = {}) {
67
+ super(data, options)
68
+ /** @type {string} */ this.target = String(data.target || '')
69
+ /** @type {'text' | 'json' | 'nan0' | 'csv'} */ this.format = data.format || 'text'
70
+ /** @type {string} */ this.output = String(data.output || '')
71
+ /** @type {string} */ this.registryFile = String(data.registryFile || 'releases.txt')
72
+ /** @type {boolean} */ this.all = Boolean(data.all)
73
+ }
74
+
75
+ /**
76
+ * Execute release validation and format rendering
77
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, { success: boolean, releases: any[], output: string }, any>}
78
+ */
79
+ async *run() {
80
+ const db = this.$db
81
+ const t = this._.t
82
+
83
+ const format = this.format || 'text'
84
+ if (format === 'text') {
85
+ yield show(t(ValidateCommand.UI.header))
86
+ }
87
+
88
+ // 1. Resolve targets
89
+ const targets = await this.#resolveTargets(db)
90
+ if (format === 'text') {
91
+ yield progress(t(ValidateCommand.UI.scanningCount, { count: targets.length }))
92
+ }
93
+
94
+ const parser = new PMaCParser()
95
+ const validatedReleases = []
96
+ let allValid = true
97
+
98
+ for (const target of targets) {
99
+ const projectRoot = target.path === '.' ? '' : `${target.path.replace(/\/+$/, '')}/`
100
+ if (format === 'text') {
101
+ yield show(t(ValidateCommand.UI.projectHeader, { name: target.name, path: target.path }))
102
+ }
103
+
104
+ // Run ReleaseAuditor for project
105
+ const auditor = new ReleaseAuditor({ dir: target.path }, { db })
106
+ await auditor.init()
107
+ const auditGen = auditor.run()
108
+ let aStep = await auditGen.next()
109
+ while (!aStep.done) {
110
+ aStep = await auditGen.next()
111
+ }
112
+ const auditRes = aStep.value?.data || aStep.value
113
+ if (auditRes && auditRes.success === false) {
114
+ allValid = false
115
+ }
116
+
117
+ // Discover release dirs (support both nested and flat)
118
+ const releaseDirs = await this.#discoverReleases(db, projectRoot)
119
+
120
+ if (releaseDirs.length === 0 && format === 'text') {
121
+ yield show(t(ValidateCommand.UI.noReleases, { path: target.path }), 'warn')
122
+ }
123
+
124
+ const projectDiagnostics = auditRes?.diagnostics || [
125
+ ...(auditRes?.errors || []),
126
+ ...(auditRes?.warnings || []),
127
+ ]
128
+
129
+ for (const rel of releaseDirs) {
130
+ const releaseData = await this.#loadAndParseRelease(db, rel, parser)
131
+ releaseData.projectDiagnostics = projectDiagnostics
132
+ const relErrors = (auditRes?.errors || []).filter(
133
+ (e) => !e.file || e.file.startsWith(rel.path) || e.file === `${projectRoot}package.json`
134
+ )
135
+ const relWarnings = (auditRes?.warnings || []).filter(
136
+ (e) => !e.file || e.file.startsWith(rel.path)
137
+ )
138
+ releaseData.errors = relErrors
139
+ releaseData.warnings = relWarnings
140
+ releaseData.valid = relErrors.length === 0
141
+ if (!releaseData.valid) allValid = false
142
+
143
+ validatedReleases.push(releaseData)
144
+ }
145
+ }
146
+
147
+ if (format === 'text') {
148
+ yield progress('')
149
+ }
150
+
151
+ // 2. Render output based on format
152
+ let outputStr = ''
153
+ switch (format) {
154
+ case 'json':
155
+ outputStr = this.#renderJson(allValid, validatedReleases)
156
+ break
157
+ case 'nan0':
158
+ outputStr = this.#renderNan0(allValid, validatedReleases)
159
+ break
160
+ case 'csv':
161
+ outputStr = this.#renderCsv(validatedReleases)
162
+ break
163
+ case 'text':
164
+ default:
165
+ outputStr = this.#renderText(validatedReleases)
166
+ break
167
+ }
168
+
169
+ // 3. Save to file if --output specified
170
+ if (this.output) {
171
+ await db.saveDocument(this.output, outputStr)
172
+ if (format === 'text') {
173
+ yield show(t(ValidateCommand.UI.savedReport, { path: this.output }), 'success')
174
+ }
175
+ } else if (format !== 'text') {
176
+ // For non-text stdout formats
177
+ yield show(outputStr)
178
+ } else {
179
+ yield show(outputStr)
180
+ }
181
+
182
+ if (format === 'text') {
183
+ yield show(
184
+ t(ValidateCommand.UI.summary, {
185
+ projects: targets.length,
186
+ releases: validatedReleases.length,
187
+ status: allValid ? 'PASSED ✅' : 'FAILED ❌',
188
+ }),
189
+ allValid ? 'success' : 'warn'
190
+ )
191
+ }
192
+
193
+ return result({
194
+ success: allValid,
195
+ releases: validatedReleases,
196
+ output: outputStr,
197
+ })
198
+ }
199
+
200
+ async #resolveTargets(db) {
201
+ const targetParam = (this.target || '').trim()
202
+
203
+ // Direct release directory?
204
+ if (targetParam && (targetParam.includes('releases/') || targetParam.endsWith('releases'))) {
205
+ return [{ path: targetParam, name: targetParam, version: '0.0.0', isDirectReleases: true }]
206
+ }
207
+
208
+ const registry = new RegistryModel({ filePath: this.registryFile || 'releases.txt' }, { db })
209
+ let registeredProjects = []
210
+ try {
211
+ registeredProjects = await registry.loadProjects()
212
+ } catch {
213
+ registeredProjects = []
214
+ }
215
+
216
+ if (this.all || (!targetParam && registeredProjects.length > 0)) {
217
+ return registeredProjects
218
+ }
219
+
220
+ if (targetParam) {
221
+ const matched = registeredProjects.find(
222
+ (p) =>
223
+ p.name === targetParam ||
224
+ p.path === targetParam ||
225
+ p.path === targetParam.replace(/\/+$/, '')
226
+ )
227
+ if (matched) return [matched]
228
+ return [{ path: targetParam, name: targetParam, version: '0.0.0' }]
229
+ }
230
+
231
+ return [{ path: '.', name: 'current-project', version: '0.0.0' }]
232
+ }
233
+
234
+ async #discoverReleases(db, projectRoot) {
235
+ const results = []
236
+ // If target was directly releases/ or path contains releases
237
+ let releasesUri = ''
238
+ if (projectRoot.includes('releases/') || projectRoot.endsWith('releases')) {
239
+ releasesUri = projectRoot.replace(/\/+$/, '')
240
+ } else {
241
+ releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
242
+ }
243
+
244
+ try {
245
+ if (typeof db.listDir === 'function') {
246
+ const queue = [{ uri: releasesUri, depth: 0 }]
247
+ while (queue.length > 0) {
248
+ const item = queue.shift()
249
+ if (!item || item.depth > 3) continue
250
+ let entries = []
251
+ try {
252
+ entries = await db.listDir(item.uri)
253
+ } catch {
254
+ continue
255
+ }
256
+ if (!Array.isArray(entries)) continue
257
+
258
+ for (const entry of entries) {
259
+ const rawName = entry.name || (entry.path || '').split('/').pop() || ''
260
+ const name = rawName.replace(/\/$/, '')
261
+ const isDir = Boolean(
262
+ entry.stat?.isDirectory ||
263
+ entry.isDirectory ||
264
+ entry.isDir ||
265
+ rawName.endsWith('/')
266
+ )
267
+
268
+ const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
269
+ if (dirMatch) {
270
+ const version = `v${dirMatch[1]}`
271
+ const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
272
+ const normalizedPath = entryPath.replace(/^\.\//, '')
273
+ if (!results.find((r) => r.path === normalizedPath)) {
274
+ results.push({ version, path: normalizedPath })
275
+ }
276
+ } else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
277
+ const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
278
+ queue.push({ uri: nextUri, depth: item.depth + 1 })
279
+ }
280
+ }
281
+ }
282
+ }
283
+ } catch {}
284
+
285
+ return results
286
+ }
287
+
288
+ async #loadAndParseRelease(db, rel, parser) {
289
+ let releaseContent = ''
290
+ let docName = ''
291
+ for (const candidate of ['release.md', 'task.md', 'plan.md']) {
292
+ try {
293
+ const doc = await db.loadDocument(`${rel.path}/${candidate}`, null)
294
+ if (doc) {
295
+ releaseContent = String(doc)
296
+ docName = candidate
297
+ break
298
+ }
299
+ } catch {}
300
+ }
301
+
302
+ let userContent = ''
303
+ try {
304
+ const u = await db.loadDocument(`${rel.path}/user.md`, null)
305
+ if (u) userContent = typeof u === 'object' ? JSON.stringify(u) : String(u)
306
+ } catch {}
307
+
308
+ const userMetrics = DashboardModel.parseUserMetrics(userContent)
309
+
310
+ let parsed = null
311
+ if (releaseContent) {
312
+ parsed = parser.parse(releaseContent)
313
+ }
314
+
315
+ return {
316
+ version: rel.version,
317
+ path: rel.path,
318
+ doc: docName,
319
+ title: parsed?.title || '',
320
+ status: parsed?.status || 'todo',
321
+ type: parsed?.type || 'minor',
322
+ epic: parsed?.epic || '',
323
+ sprint: parsed?.sprint || '',
324
+ tracker: parsed?.tracker || '',
325
+ team: parsed?.team || [],
326
+ tasks: parsed?.tasks || [],
327
+ criteria: parsed?.criteria || [],
328
+ parity: parsed?.parity || [],
329
+ issues: parsed?.issues || [],
330
+ userMetrics,
331
+ }
332
+ }
333
+
334
+ #renderText(releases) {
335
+ const lines = []
336
+ for (const rel of releases) {
337
+ const statusBadge = rel.valid ? '✅ VALID' : '❌ INVALID'
338
+ lines.push(`\n Release [${rel.version}] ${statusBadge}`)
339
+ lines.push(` Path: ${rel.path}`)
340
+ if (rel.title) lines.push(` Title: ${rel.title}`)
341
+ lines.push(` Status: ${rel.status} | Type: ${rel.type}`)
342
+ if (rel.userMetrics) {
343
+ lines.push(
344
+ ` Metrics: ${rel.userMetrics.hours}h | ${rel.userMetrics.iterations} iters | RRS: ${rel.userMetrics.rrs}`
345
+ )
346
+ }
347
+ const doneTasks = rel.tasks.filter((t) => t.status === 'done').length
348
+ lines.push(` Tasks: ${doneTasks}/${rel.tasks.length} completed`)
349
+ for (const task of rel.tasks) {
350
+ const mark = task.status === 'done' ? '✔' : '○'
351
+ lines.push(` ${mark} [${task.id}] ${task.title}`)
352
+ }
353
+ if (rel.parity?.length > 0) {
354
+ lines.push(` Parity Check (${rel.parity.length} collections):`)
355
+ for (const p of rel.parity) {
356
+ const pMark = p.synced ? '✅' : '⚠️'
357
+ lines.push(` ${pMark} ${p.collection}: ${p.actual}/${p.expected} (${p.comment || ''})`)
358
+ }
359
+ }
360
+ if (rel.errors?.length > 0) {
361
+ lines.push(` Errors / Recommendations (${rel.errors.length}):`)
362
+ for (const err of rel.errors) {
363
+ lines.push(` ❌ [${err.code}] ${err.error || err.message}`)
364
+ if (err.fix) lines.push(` 👉 Fix: ${err.fix}`)
365
+ }
366
+ }
367
+ if (rel.warnings?.length > 0) {
368
+ lines.push(` Warnings (${rel.warnings.length}):`)
369
+ for (const warn of rel.warnings) {
370
+ lines.push(` ⚠️ [${warn.code}] ${warn.error || warn.message}`)
371
+ if (warn.fix) lines.push(` 👉 Fix: ${warn.fix}`)
372
+ }
373
+ }
374
+ }
375
+ return lines.join('\n')
376
+ }
377
+
378
+ #renderJson(allValid, releases) {
379
+ return JSON.stringify(
380
+ {
381
+ success: allValid,
382
+ timestamp: new Date().toISOString(),
383
+ total: releases.length,
384
+ releases,
385
+ },
386
+ null,
387
+ 2
388
+ )
389
+ }
390
+
391
+ #renderNan0(allValid, releases) {
392
+ const payload = {
393
+ success: allValid,
394
+ total: releases.length,
395
+ releases: releases.map((rel) => ({
396
+ version: rel.version,
397
+ path: rel.path,
398
+ status: rel.status,
399
+ type: rel.type,
400
+ title: rel.title,
401
+ valid: rel.valid,
402
+ tasks_total: rel.tasks.length,
403
+ tasks_done: rel.tasks.filter((t) => t.status === 'done').length,
404
+ })),
405
+ }
406
+ return `# nan0 release validation report\n${NaN0.stringify(payload)}`
407
+ }
408
+
409
+ #renderCsv(releases) {
410
+ const rows = []
411
+ for (const rel of releases) {
412
+ for (const task of rel.tasks) {
413
+ rows.push({
414
+ Version: rel.version,
415
+ 'Task ID': task.id,
416
+ Title: task.title || '',
417
+ Status: task.status || 'todo',
418
+ Notes: task.notes || '',
419
+ })
420
+ }
421
+ }
422
+ return stringifyCSV(rows)
423
+ }
424
+ }
425
+
426
+ export default ValidateCommand
@@ -0,0 +1,92 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import DB from '@nan0web/db'
4
+ import { ValidateCommand } from './ValidateCommand.js'
5
+
6
+ describe('Scenario: ValidateCommand Multi-Format Inspection with In-Memory DB', () => {
7
+ it('executes full scan and renders formatted report', async () => {
8
+ const releaseMd = `---
9
+ status: work
10
+ type: minor
11
+ epic: EPIC-CORE
12
+ sprint: Sprint 1
13
+ tracker: https://github.com/nan0web/nan.web/issues/{id}
14
+ ---
15
+
16
+ # v1.0.0 — Core Release Inspector
17
+
18
+ ## Опис та Мета
19
+ Створення CLI інструменту інспекції релізів.
20
+
21
+ ## Команда
22
+ - **Lead / Architect:** Олександр Снігірьов (@snigirev)
23
+ - **AI Lead Developer:** Agent Antigravity (@agy)
24
+
25
+ ## Робочий План та Задачі
26
+ - [x] [#101] Core inspection logic
27
+ - [ ] [#102] Multi-format reporter
28
+
29
+ ## Паритет Даних
30
+ | Колекція | Очікувано | Фактично | Статус | Коментар |
31
+ | :--- | :---: | :---: | :---: | :--- |
32
+ | releases | 1 | 1 | ✅ OK | Synced |
33
+ `
34
+ const userMd = `---
35
+ hours: 1.5
36
+ iterations: 4
37
+ rrs: 330
38
+ ---
39
+ # Metrics
40
+ Hours: 1.5h
41
+ Iterations: 4
42
+ RRS: 330
43
+ `
44
+
45
+ const db = new DB({
46
+ predefined: [
47
+ ['package.json', { name: '@nan0web/sample', version: '1.0.0' }],
48
+ ['releases/1/0/v1.0.0/release.md', releaseMd],
49
+ ['releases/1/0/v1.0.0/user.md', userMd],
50
+ ['releases/1/0/v1.0.0/task.spec.js', '// spec'],
51
+ ],
52
+ })
53
+ await db.connect()
54
+
55
+ // 1. Text format
56
+ const cmdText = new ValidateCommand({ target: '.' }, { db })
57
+ const genText = cmdText.run()
58
+ let step = await genText.next()
59
+ while (!step.done) {
60
+ step = await genText.next()
61
+ }
62
+ const resText = step.value?.data || step.value
63
+ assert.ok(resText.success)
64
+ assert.equal(resText.releases.length, 1)
65
+ assert.match(resText.output, /Release \[v1\.0\.0\] ✅ VALID/)
66
+ assert.match(resText.output, /Core inspection logic/)
67
+
68
+ // 2. JSON format
69
+ const cmdJson = new ValidateCommand({ target: '.', format: 'json' }, { db })
70
+ const genJson = cmdJson.run()
71
+ step = await genJson.next()
72
+ while (!step.done) {
73
+ step = await genJson.next()
74
+ }
75
+ const resJson = step.value?.data || step.value
76
+ assert.ok(resJson.success)
77
+ const parsed = JSON.parse(resJson.output)
78
+ assert.equal(parsed.releases[0].tasks.length, 2)
79
+ assert.equal(parsed.releases[0].parity.length, 1)
80
+
81
+ // 3. CSV format with file export
82
+ const cmdCsv = new ValidateCommand({ target: '.', format: 'csv', output: 'export.csv' }, { db })
83
+ const genCsv = cmdCsv.run()
84
+ step = await genCsv.next()
85
+ while (!step.done) {
86
+ step = await genCsv.next()
87
+ }
88
+ const savedCsv = await db.loadDocument('export.csv', null)
89
+ assert.ok(savedCsv)
90
+ assert.match(savedCsv, /v1\.0\.0,101,Core inspection logic,done/)
91
+ })
92
+ })
@@ -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
+ }