@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
@@ -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
- * @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
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