@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.
- package/README.md +0 -35
- package/bin/nan0release.js +2 -2
- package/package.json +9 -8
- package/src/domain/App.js +2 -0
- package/src/domain/PMaCParser.js +418 -0
- package/src/domain/PMaCParser.story.js +152 -0
- package/src/domain/ValidateCommand.js +426 -0
- package/src/domain/ValidateCommand.story.js +92 -0
- package/src/domain/models/ReleaseIssueModel.js +27 -0
- package/src/domain/models/ReleaseMemberModel.js +21 -0
- package/src/domain/models/ReleaseModel.js +191 -0
- package/src/domain/models/ReleaseParityModel.js +27 -0
- package/src/domain/models/ReleaseTaskModel.js +33 -0
- package/src/domain/models/index.js +6 -0
- package/src/index.js +9 -0
- package/types/domain/App.d.ts +2 -1
- package/types/domain/PMaCParser.d.ts +64 -0
- package/types/domain/PMaCParser.story.d.ts +1 -0
- package/types/domain/ValidateCommand.d.ts +69 -0
- package/types/domain/ValidateCommand.story.d.ts +1 -0
- package/types/domain/models/ReleaseIssueModel.d.ts +35 -0
- package/types/domain/models/ReleaseMemberModel.d.ts +25 -0
- package/types/domain/models/ReleaseModel.d.ts +122 -0
- package/types/domain/models/ReleaseParityModel.d.ts +37 -0
- package/types/domain/models/ReleaseTaskModel.d.ts +46 -0
- package/types/domain/models/index.d.ts +5 -0
- package/types/index.d.ts +3 -0
|
@@ -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
|
+
}
|