@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.
- package/README.md +0 -35
- package/bin/nan0release.js +33 -2
- package/bin/serve.js +73 -0
- package/package.json +46 -17
- package/src/Release/Document.js +1 -0
- package/src/Release/Scanner.js +3 -2
- package/src/Release.js +7 -7
- package/src/domain/App.js +33 -2
- package/src/domain/BumpCommand.js +10 -3
- package/src/domain/CheckCommand.js +504 -146
- package/src/domain/CheckCommand.story.js +92 -0
- package/src/domain/CloseCommand.js +13 -9
- package/src/domain/Dashboard/Dashboard.story.js +155 -0
- package/src/domain/Dashboard/DashboardModel.js +308 -0
- package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
- package/src/domain/Dashboard/ReleaseParser.js +226 -0
- package/src/domain/Dashboard/index.js +19 -0
- package/src/domain/DepsCommand.js +5 -2
- package/src/domain/InitCommand.js +18 -16
- package/src/domain/ListCommand.js +45 -0
- package/src/domain/PMaCParser.js +418 -0
- package/src/domain/PMaCParser.story.js +152 -0
- package/src/domain/PreviewCommand.js +39 -0
- package/src/domain/PublishCommand.js +7 -3
- package/src/domain/Registry/Registry.story.js +82 -0
- package/src/domain/Registry/RegistryModel.js +124 -0
- package/src/domain/ServeCommand.js +165 -0
- package/src/domain/ServeCommand.story.js +88 -0
- package/src/domain/SpecCommand.js +3 -1
- package/src/domain/StatusCommand.js +249 -3
- package/src/domain/ValidateCommand.js +426 -0
- package/src/domain/ValidateCommand.story.js +92 -0
- package/src/domain/ViewCommand.js +99 -0
- package/src/domain/WebCommand.js +140 -0
- package/src/domain/inspect/ReleaseAuditor.js +508 -0
- package/src/domain/inspect/ReleaseAuditor.story.js +136 -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/domain/templates/release.html +34 -0
- package/src/domain/templates/release.html.template.js +34 -0
- package/src/index.js +15 -0
- package/src/inspectors/inspect-js.js +32 -0
- package/src/releases/1/0/v1.0.3/task.test.js +1 -1
- package/src/utils/nodejs/GitTelemetry.js +154 -0
- package/types/Release/Document.d.ts +24 -27
- package/types/Release/MarkdownToTest.d.ts +1 -1
- package/types/Release/Person.d.ts +9 -7
- package/types/Release/Scanner.d.ts +41 -41
- package/types/Release.d.ts +42 -42
- package/types/architecture/ProjectManagementAsCode.d.ts +59 -64
- package/types/domain/App.d.ts +25 -7
- package/types/domain/BumpCommand.d.ts +11 -6
- package/types/domain/CheckCommand.d.ts +128 -16
- package/types/domain/CheckCommand.story.d.ts +1 -0
- package/types/domain/CloseCommand.d.ts +4 -6
- package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
- package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
- package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
- package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
- package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
- package/types/domain/Dashboard/index.d.ts +3 -0
- package/types/domain/DepsCommand.d.ts +6 -6
- package/types/domain/InitCommand.d.ts +4 -6
- package/types/domain/ListCommand.d.ts +18 -0
- package/types/domain/PMaCParser.d.ts +64 -0
- package/types/domain/PMaCParser.story.d.ts +1 -0
- package/types/domain/PreviewCommand.d.ts +6 -0
- package/types/domain/PublishCommand.d.ts +8 -6
- package/types/domain/Registry/Registry.story.d.ts +1 -0
- package/types/domain/Registry/RegistryModel.d.ts +61 -0
- package/types/domain/ServeCommand.d.ts +59 -0
- package/types/domain/ServeCommand.story.d.ts +1 -0
- package/types/domain/SpecCommand.d.ts +4 -6
- package/types/domain/StatusCommand.d.ts +38 -3
- package/types/domain/ValidateCommand.d.ts +69 -0
- package/types/domain/ValidateCommand.story.d.ts +1 -0
- package/types/domain/ViewCommand.d.ts +27 -0
- package/types/domain/WebCommand.d.ts +47 -0
- package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
- package/types/domain/inspect/ReleaseAuditor.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/domain/templates/release.html.template.d.ts +2 -0
- package/types/index.d.ts +21 -10
- package/types/inspectors/inspect-js.d.ts +2 -0
- package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
|
@@ -1,16 +1,262 @@
|
|
|
1
1
|
import { ModelAsApp, result, show } from '@nan0web/ui'
|
|
2
|
+
import { DashboardModel } from './Dashboard/DashboardModel.js'
|
|
2
3
|
import Scanner from '../Release/Scanner.js'
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* StatusCommand - ModelAsApp Subcommand to display PM-as-Code dashboard and project inspector.
|
|
7
|
+
*/
|
|
4
8
|
export default class StatusCommand extends ModelAsApp {
|
|
9
|
+
static alias = 'status'
|
|
10
|
+
|
|
5
11
|
static UI = {
|
|
6
12
|
title: 'status',
|
|
7
|
-
help: 'Show release status',
|
|
13
|
+
help: 'Show release status dashboard or project inspector',
|
|
14
|
+
header: '📊 Global PM-as-Code Release Dashboard ({$file})',
|
|
15
|
+
summaryHeader: '📈 Summary:',
|
|
16
|
+
summaryRow1: ' Total Projects: {$total} | Active (WIP): {$wip} | Closed: {$closed}',
|
|
17
|
+
summaryRow2: ' Total Hours: {$hours}h | Total Iterations: {$iterations}',
|
|
18
|
+
noProjects: 'No projects registered in {$file}',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static registryFile = {
|
|
22
|
+
help: 'Path to releases.txt registry file',
|
|
23
|
+
default: 'releases.txt',
|
|
24
|
+
type: 'string',
|
|
25
|
+
alias: 'f',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static target = {
|
|
29
|
+
help: 'Target project # or name for detailed inspection',
|
|
30
|
+
default: '',
|
|
31
|
+
positional: true,
|
|
32
|
+
type: 'string',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static sort = {
|
|
36
|
+
help: 'Sort projects by column (hours, rrs, iters, state, name)',
|
|
37
|
+
default: '',
|
|
38
|
+
type: 'string',
|
|
39
|
+
alias: 's',
|
|
8
40
|
}
|
|
9
41
|
|
|
10
42
|
/**
|
|
11
|
-
* @
|
|
43
|
+
* @param {Partial<StatusCommand>} [data]
|
|
44
|
+
* @param {import('@nan0web/types').ModelOptions} [options]
|
|
45
|
+
*/
|
|
46
|
+
constructor(data = {}, options = {}) {
|
|
47
|
+
super(data, options)
|
|
48
|
+
/** @type {string} */ this.registryFile
|
|
49
|
+
/** @type {string} */ this.target
|
|
50
|
+
/** @type {string} */ this.sort
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
|
|
12
55
|
*/
|
|
13
56
|
async *run() {
|
|
57
|
+
const db = this._.db
|
|
58
|
+
if (!db) {
|
|
59
|
+
throw new Error('Database instance is required in options ({ db })')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let hasRegistry = false
|
|
63
|
+
try {
|
|
64
|
+
const regDoc = await db.loadDocument(this.registryFile || 'releases.txt', null)
|
|
65
|
+
hasRegistry = regDoc !== null
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
if (hasRegistry || this.target || this.sort) {
|
|
69
|
+
let gitTelemetry = null
|
|
70
|
+
try {
|
|
71
|
+
gitTelemetry = await import('../utils/nodejs/GitTelemetry.js')
|
|
72
|
+
} catch {}
|
|
73
|
+
const dashboard = new DashboardModel(
|
|
74
|
+
{ registryFile: this.registryFile || 'releases.txt' },
|
|
75
|
+
{ db, gitTelemetry }
|
|
76
|
+
)
|
|
77
|
+
let { summary, projects } = await dashboard.aggregate()
|
|
78
|
+
|
|
79
|
+
// 1. Sorting if requested
|
|
80
|
+
if (this.sort) {
|
|
81
|
+
const s = String(this.sort).toLowerCase()
|
|
82
|
+
if (s === 'hours') {
|
|
83
|
+
projects = [...projects].sort((a, b) => (b.hours || 0) - (a.hours || 0))
|
|
84
|
+
} else if (s === 'rrs') {
|
|
85
|
+
projects = [...projects].sort((a, b) => (b.rrs || 0) - (a.rrs || 0))
|
|
86
|
+
} else if (s === 'iters' || s === 'iterations') {
|
|
87
|
+
projects = [...projects].sort((a, b) => (b.iterations || 0) - (a.iterations || 0))
|
|
88
|
+
} else if (s === 'name') {
|
|
89
|
+
projects = [...projects].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
90
|
+
} else if (s === 'state' || s === 'status') {
|
|
91
|
+
const priority = { wip: 1, closed: 2, unknown: 3 }
|
|
92
|
+
projects = [...projects].sort((a, b) => (priority[a.status] || 99) - (priority[b.status] || 99))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Inspector mode if target is specified
|
|
97
|
+
if (this.target) {
|
|
98
|
+
const targetStr = String(this.target).trim()
|
|
99
|
+
let matchedIndex = -1
|
|
100
|
+
let matchedProj = null
|
|
101
|
+
|
|
102
|
+
const targetNum = parseInt(targetStr, 10)
|
|
103
|
+
if (!isNaN(targetNum) && targetNum >= 1 && targetNum <= projects.length) {
|
|
104
|
+
matchedIndex = targetNum - 1
|
|
105
|
+
matchedProj = projects[matchedIndex]
|
|
106
|
+
} else {
|
|
107
|
+
matchedIndex = projects.findIndex(
|
|
108
|
+
(p) =>
|
|
109
|
+
p.name.toLowerCase() === targetStr.toLowerCase() ||
|
|
110
|
+
p.path.toLowerCase() === targetStr.toLowerCase() ||
|
|
111
|
+
p.name.toLowerCase().includes(targetStr.toLowerCase()) ||
|
|
112
|
+
p.path.toLowerCase().includes(targetStr.toLowerCase()),
|
|
113
|
+
)
|
|
114
|
+
if (matchedIndex !== -1) {
|
|
115
|
+
matchedProj = projects[matchedIndex]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (matchedProj) {
|
|
120
|
+
yield show(`🔍 Project Inspector: #${matchedIndex + 1} ${matchedProj.name}@${matchedProj.version}`, 'info')
|
|
121
|
+
yield show(
|
|
122
|
+
` • Path: ${matchedProj.path} | Branch: ${matchedProj.branch || 'main'} (${matchedProj.gitStatus || 'clean'})`,
|
|
123
|
+
'info',
|
|
124
|
+
)
|
|
125
|
+
yield show(
|
|
126
|
+
` • State: [${(matchedProj.status || 'unknown').toUpperCase()}] | Hours: ${matchedProj.hours}h | Iterations: ${matchedProj.iterations} | RRS: ${matchedProj.rrs}`,
|
|
127
|
+
'info',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
yield show(`📋 Tasks & Checklist:`, 'info')
|
|
131
|
+
let renderedTasksCount = 0
|
|
132
|
+
if (matchedProj.releaseContent) {
|
|
133
|
+
const lines = matchedProj.releaseContent.split('\n')
|
|
134
|
+
for (const rawLine of lines) {
|
|
135
|
+
const l = rawLine.trim()
|
|
136
|
+
if (l.startsWith('- [x]') || l.startsWith('- [ ]') || l.startsWith('* [x]') || l.startsWith('* [ ]')) {
|
|
137
|
+
yield show(` ${l}`, 'info')
|
|
138
|
+
renderedTasksCount++
|
|
139
|
+
} else if (
|
|
140
|
+
l.startsWith('### Done') ||
|
|
141
|
+
l.startsWith('### InProgress') ||
|
|
142
|
+
l.startsWith('### Todo') ||
|
|
143
|
+
l.startsWith('### Draft')
|
|
144
|
+
) {
|
|
145
|
+
const icon = l.startsWith('### Done') ? '[x]' : '[ ]'
|
|
146
|
+
const desc = l.replace(/^###\s+(?:Done|InProgress|Todo|Draft)\s+/, '')
|
|
147
|
+
yield show(` - ${icon} ${desc}`, 'info')
|
|
148
|
+
renderedTasksCount++
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (renderedTasksCount === 0 && Array.isArray(matchedProj.tasks) && matchedProj.tasks.length > 0) {
|
|
154
|
+
for (const t of matchedProj.tasks) {
|
|
155
|
+
const icon = t.status === 'Done' ? '[x]' : '[ ]'
|
|
156
|
+
yield show(` - ${icon} ${t.content || t.slug}`, 'info')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchedProj.userContent) {
|
|
161
|
+
yield show(`📝 User Metrics & Feedback (user.md):`, 'info')
|
|
162
|
+
const userLines = matchedProj.userContent
|
|
163
|
+
.split('\n')
|
|
164
|
+
.map((l) => l.trim())
|
|
165
|
+
.filter(
|
|
166
|
+
(l) =>
|
|
167
|
+
l.length > 0 &&
|
|
168
|
+
!l.startsWith('---') &&
|
|
169
|
+
!l.startsWith('name:') &&
|
|
170
|
+
!l.startsWith('version:') &&
|
|
171
|
+
!l.startsWith('locale:'),
|
|
172
|
+
)
|
|
173
|
+
for (const ul of userLines) {
|
|
174
|
+
yield show(` ${ul}`, 'info')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
yield show(`❌ Project matching target "${this.target}" not found`, 'warn')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result({
|
|
182
|
+
status: 'ok',
|
|
183
|
+
summary,
|
|
184
|
+
projects,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3. Normal Table mode
|
|
189
|
+
yield show(StatusCommand.UI.header.replace('{$file}', this.registryFile || 'releases.txt'), 'info')
|
|
190
|
+
|
|
191
|
+
if (projects.length === 0) {
|
|
192
|
+
yield show(StatusCommand.UI.noProjects.replace('{$file}', this.registryFile || 'releases.txt'), 'warn')
|
|
193
|
+
} else {
|
|
194
|
+
const colNum = '#'.padEnd(3)
|
|
195
|
+
const colProj = 'Project'.padEnd(25)
|
|
196
|
+
const colVer = 'Version'.padEnd(9)
|
|
197
|
+
const colBranch = 'Branch'.padEnd(12)
|
|
198
|
+
const colGit = 'Git'.padEnd(7)
|
|
199
|
+
const colState = 'State'.padEnd(10)
|
|
200
|
+
const colHours = 'Hours'.padEnd(7)
|
|
201
|
+
const colIters = 'Iters'.padEnd(7)
|
|
202
|
+
const colRRS = 'RRS'.padEnd(5)
|
|
203
|
+
const colTasks = 'Tasks'
|
|
204
|
+
|
|
205
|
+
const tableHeader = `┌─────┬───────────────────────────┬───────────┬──────────────┬─────────┬────────────┬─────────┬─────────┬───────┬───────┐`
|
|
206
|
+
const headerRow = `│ ${colNum} │ ${colProj} │ ${colVer} │ ${colBranch} │ ${colGit} │ ${colState} │ ${colHours} │ ${colIters} │ ${colRRS} │ ${colTasks.padEnd(5)} │`
|
|
207
|
+
const separator = `├─────┼───────────────────────────┼───────────┼──────────────┼─────────┼────────────┼─────────┼─────────┼───────┼───────┤`
|
|
208
|
+
const tableFooter = `└─────┴───────────────────────────┴───────────┴──────────────┴─────────┴────────────┴─────────┴─────────┴───────┴───────┘`
|
|
209
|
+
|
|
210
|
+
yield show(tableHeader, 'info')
|
|
211
|
+
yield show(headerRow, 'info')
|
|
212
|
+
yield show(separator, 'info')
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < projects.length; i++) {
|
|
215
|
+
const proj = projects[i]
|
|
216
|
+
const statusTag = proj.status === 'wip' ? '[WIP]' : proj.status === 'closed' ? '[CLOSED]' : '[UNKNOWN]'
|
|
217
|
+
const rowNum = String(i + 1).padEnd(3)
|
|
218
|
+
const rowName = (proj.name || '').padEnd(25).slice(0, 25)
|
|
219
|
+
const rowVer = (proj.version || '').padEnd(9).slice(0, 9)
|
|
220
|
+
const rowBranch = (proj.branch || 'main').padEnd(12).slice(0, 12)
|
|
221
|
+
const rowGit = (proj.gitStatus || 'clean').padEnd(7).slice(0, 7)
|
|
222
|
+
const rowState = statusTag.padEnd(10)
|
|
223
|
+
const rowHours = `${proj.hours}h`.padEnd(7)
|
|
224
|
+
const rowIters = String(proj.iterations).padEnd(7)
|
|
225
|
+
const rowRRS = String(proj.rrs).padEnd(5)
|
|
226
|
+
const doneTasks = Array.isArray(proj.tasks) ? proj.tasks.filter((t) => t.status === 'Done').length : 0
|
|
227
|
+
const totalTasks = Array.isArray(proj.tasks) ? proj.tasks.length : 0
|
|
228
|
+
const rowTasks = `${doneTasks}/${totalTasks}`.padEnd(5)
|
|
229
|
+
|
|
230
|
+
const rowStr = `│ ${rowNum} │ ${rowName} │ ${rowVer} │ ${rowBranch} │ ${rowGit} │ ${rowState} │ ${rowHours} │ ${rowIters} │ ${rowRRS} │ ${rowTasks} │`
|
|
231
|
+
yield show(rowStr, proj.status === 'wip' ? 'warn' : 'info')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
yield show(tableFooter, 'info')
|
|
235
|
+
|
|
236
|
+
yield show(StatusCommand.UI.summaryHeader, 'info')
|
|
237
|
+
yield show(
|
|
238
|
+
StatusCommand.UI.summaryRow1
|
|
239
|
+
.replace('{$total}', String(summary.totalProjects))
|
|
240
|
+
.replace('{$wip}', String(summary.wipCount))
|
|
241
|
+
.replace('{$closed}', String(summary.closedCount)),
|
|
242
|
+
'info',
|
|
243
|
+
)
|
|
244
|
+
yield show(
|
|
245
|
+
StatusCommand.UI.summaryRow2
|
|
246
|
+
.replace('{$hours}', String(summary.totalHours))
|
|
247
|
+
.replace('{$iterations}', String(summary.totalIterations)),
|
|
248
|
+
'info',
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result({
|
|
253
|
+
status: 'ok',
|
|
254
|
+
summary,
|
|
255
|
+
projects,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Local package fallback
|
|
14
260
|
const scanner = new Scanner(process.cwd())
|
|
15
261
|
const statuses = scanner.status()
|
|
16
262
|
|
|
@@ -20,6 +266,6 @@ export default class StatusCommand extends ModelAsApp {
|
|
|
20
266
|
`${st.version.padEnd(10)} | ${st.state.padEnd(6)} | Specs: ${st.specs.length}, Tests: ${st.tests.length}`,
|
|
21
267
|
)
|
|
22
268
|
}
|
|
23
|
-
return result({})
|
|
269
|
+
return result({ statuses })
|
|
24
270
|
}
|
|
25
271
|
}
|
|
@@ -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
|