@nan0web/release 3.2.0 → 3.4.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 (69) hide show
  1. package/bin/nan0release.js +33 -2
  2. package/bin/serve.js +73 -0
  3. package/package.json +45 -17
  4. package/src/Release/Document.js +1 -0
  5. package/src/Release/Scanner.js +3 -2
  6. package/src/Release.js +7 -7
  7. package/src/domain/App.js +31 -2
  8. package/src/domain/BumpCommand.js +10 -3
  9. package/src/domain/CheckCommand.js +504 -146
  10. package/src/domain/CheckCommand.story.js +92 -0
  11. package/src/domain/CloseCommand.js +13 -9
  12. package/src/domain/Dashboard/Dashboard.story.js +155 -0
  13. package/src/domain/Dashboard/DashboardModel.js +308 -0
  14. package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
  15. package/src/domain/Dashboard/ReleaseParser.js +226 -0
  16. package/src/domain/Dashboard/index.js +19 -0
  17. package/src/domain/DepsCommand.js +5 -2
  18. package/src/domain/InitCommand.js +18 -16
  19. package/src/domain/ListCommand.js +45 -0
  20. package/src/domain/PreviewCommand.js +39 -0
  21. package/src/domain/PublishCommand.js +7 -3
  22. package/src/domain/Registry/Registry.story.js +82 -0
  23. package/src/domain/Registry/RegistryModel.js +124 -0
  24. package/src/domain/ServeCommand.js +165 -0
  25. package/src/domain/ServeCommand.story.js +88 -0
  26. package/src/domain/SpecCommand.js +3 -1
  27. package/src/domain/StatusCommand.js +249 -3
  28. package/src/domain/ViewCommand.js +99 -0
  29. package/src/domain/WebCommand.js +140 -0
  30. package/src/domain/inspect/ReleaseAuditor.js +508 -0
  31. package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
  32. package/src/domain/templates/release.html +34 -0
  33. package/src/domain/templates/release.html.template.js +34 -0
  34. package/src/index.js +6 -0
  35. package/src/inspectors/inspect-js.js +32 -0
  36. package/src/releases/1/0/v1.0.3/task.test.js +1 -1
  37. package/src/utils/nodejs/GitTelemetry.js +154 -0
  38. package/types/Release/Document.d.ts +4 -0
  39. package/types/Release/Scanner.d.ts +2 -2
  40. package/types/domain/App.d.ts +19 -3
  41. package/types/domain/BumpCommand.d.ts +9 -3
  42. package/types/domain/CheckCommand.d.ts +127 -15
  43. package/types/domain/CheckCommand.story.d.ts +1 -0
  44. package/types/domain/CloseCommand.d.ts +2 -1
  45. package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
  46. package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
  47. package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
  48. package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
  49. package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
  50. package/types/domain/Dashboard/index.d.ts +3 -0
  51. package/types/domain/DepsCommand.d.ts +4 -2
  52. package/types/domain/InitCommand.d.ts +2 -1
  53. package/types/domain/ListCommand.d.ts +18 -0
  54. package/types/domain/PreviewCommand.d.ts +6 -0
  55. package/types/domain/PublishCommand.d.ts +6 -3
  56. package/types/domain/Registry/Registry.story.d.ts +1 -0
  57. package/types/domain/Registry/RegistryModel.d.ts +61 -0
  58. package/types/domain/ServeCommand.d.ts +59 -0
  59. package/types/domain/ServeCommand.story.d.ts +1 -0
  60. package/types/domain/SpecCommand.d.ts +2 -1
  61. package/types/domain/StatusCommand.d.ts +39 -0
  62. package/types/domain/ViewCommand.d.ts +27 -0
  63. package/types/domain/WebCommand.d.ts +47 -0
  64. package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
  65. package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
  66. package/types/domain/templates/release.html.template.d.ts +2 -0
  67. package/types/index.d.ts +5 -0
  68. package/types/inspectors/inspect-js.d.ts +2 -0
  69. package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
@@ -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 CheckCommand from './CheckCommand.js'
5
+
6
+ describe('CheckCommand OLMUI Scenario & Validator', () => {
7
+ it('validates a compliant project release structure without errors', async () => {
8
+ const pkgJson = { name: '@nan0web/sample-pkg', version: '1.0.0' }
9
+ const releaseMd = `# v1.0.0 - 2026-09-01
10
+ ## Tasks
11
+ - [x] Implement initial feature
12
+ `
13
+ const userMd = `---
14
+ name: user-feedback
15
+ version: 1.0.0
16
+ ---
17
+ ## ⏱ 1. Метрики Виконання
18
+ - **Витрачений час розробки (Годин):** \`4.5h\`
19
+ - **Кількість ітерацій (Test Runs / Refactors):** \`10\`
20
+ - **Фінальний бал RRS (Release Readiness Score):** \`324\`
21
+ `
22
+ const taskSpec = `import { describe, it } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+ describe('sample contract', () => {
25
+ it('works', () => assert.ok(true))
26
+ })
27
+ `
28
+ const db = new DB({
29
+ predefined: [
30
+ ['packages/sample-pkg/package.json', pkgJson],
31
+ ['packages/sample-pkg/releases/1/0/v1.0.0/release.md', releaseMd],
32
+ ['packages/sample-pkg/releases/1/0/v1.0.0/user.md', userMd],
33
+ ['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', taskSpec],
34
+ ],
35
+ })
36
+ await db.connect()
37
+
38
+ const cmd = new CheckCommand({ target: 'packages/sample-pkg' }, { db })
39
+ const gen = cmd.run()
40
+
41
+ let step = await gen.next()
42
+ const outputs = []
43
+ while (!step.done) {
44
+ outputs.push(step.value)
45
+ step = await gen.next()
46
+ }
47
+
48
+ const res = step.value?.data || step.value
49
+ assert.ok(res.success)
50
+ assert.equal(res.diagnostics.length, 0)
51
+ assert.equal(res.checkedProjects.length, 1)
52
+ })
53
+
54
+ it('detects missing user.md metrics and missing contracts with actionable recommendations', async () => {
55
+ const pkgJson = { name: '@nan0web/broken-pkg', version: '2.0.0' }
56
+ const releaseMd = `# v2.0.0 - 2026-09-01
57
+ ## Tasks
58
+ - [ ] Incomplete task
59
+ `
60
+ const db = new DB({
61
+ predefined: [
62
+ ['packages/broken-pkg/package.json', pkgJson],
63
+ ['packages/broken-pkg/releases/2/0/v2.0.0/release.md', releaseMd],
64
+ ],
65
+ })
66
+ await db.connect()
67
+
68
+ const cmd = new CheckCommand({ target: 'packages/broken-pkg' }, { db })
69
+ const gen = cmd.run()
70
+
71
+ let step = await gen.next()
72
+ const outputs = []
73
+ while (!step.done) {
74
+ outputs.push(step.value)
75
+ step = await gen.next()
76
+ }
77
+
78
+ const res = step.value?.data || step.value
79
+ assert.equal(res.success, false)
80
+ assert.ok(res.diagnostics.length >= 2)
81
+
82
+ // Check for missing user.md diagnostic
83
+ const userDiag = res.diagnostics.find((d) => d.code === 'MISSING_USER_MD' || d.type === 'missing_user_md')
84
+ assert.ok(userDiag)
85
+ assert.ok(userDiag.fix.includes('user.md'))
86
+
87
+ // Check for missing contract diagnostic
88
+ const contractDiag = res.diagnostics.find((d) => d.code === 'MISSING_CONTRACT' || d.type === 'missing_contract')
89
+ assert.ok(contractDiag)
90
+ assert.ok(contractDiag.fix.includes('task.spec.js'))
91
+ })
92
+ })
@@ -3,6 +3,9 @@ import { runSpawn } from '@nan0web/test'
3
3
  import Scanner from '../Release/Scanner.js'
4
4
 
5
5
  export default class CloseCommand extends ModelAsApp {
6
+ /** @type {string} Release version */
7
+ version = ''
8
+
6
9
  static version = {
7
10
  help: 'Release version',
8
11
  default: '',
@@ -18,16 +21,13 @@ export default class CloseCommand extends ModelAsApp {
18
21
  */
19
22
  constructor(data = {}, options = {}) {
20
23
  super(data, options)
21
- /** @type {string} Release version */ this.version
22
24
  }
23
25
 
24
26
  /**
25
27
  * @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
26
28
  */
27
29
  async *run() {
28
- const { renameSync, mkdirSync } = await import('node:fs')
29
- const { join, dirname } = await import('node:path')
30
-
30
+ const db = this._.db
31
31
  const v = this.version || ''
32
32
  yield show(`🛜 nan0release close ${v || 'all'}`)
33
33
 
@@ -51,18 +51,22 @@ export default class CloseCommand extends ModelAsApp {
51
51
 
52
52
  let movedCount = 0
53
53
  for (const spec of specs) {
54
- const relativePath = spec.path.substring(process.cwd().length + 1)
54
+ const relativePath = spec.path.replace(/^.*?releases\//, 'releases/')
55
55
  const destRelative = relativePath
56
56
  .replace(/^releases\//, 'src/releases/')
57
57
  .replace('.spec.js', '.test.js')
58
- const destPath = join(process.cwd(), destRelative)
59
58
 
60
- mkdirSync(dirname(destPath), { recursive: true })
61
- renameSync(spec.path, destPath)
59
+ if (db) {
60
+ const content = await db.loadDocument(relativePath, null)
61
+ if (content !== null) {
62
+ await db.saveDocument(destRelative, content)
63
+ await db.dropDocument(relativePath)
64
+ }
65
+ }
62
66
  movedCount++
63
67
  }
64
68
 
65
69
  yield show(`✅ Successfully closed ${movedCount} specs and moved to src/releases/`, 'success')
66
- return result({})
70
+ return result({ movedCount })
67
71
  }
68
72
  }
@@ -0,0 +1,155 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import DB from '@nan0web/db'
4
+ import { DashboardModel } from './DashboardModel.js'
5
+
6
+ describe('DashboardModel OLMUI Scenario', () => {
7
+ it('aggregates projects, computes summary metrics and RRS readiness', async () => {
8
+ const rawReleasesTxt = `# Registry
9
+ packages/sample-pkg
10
+ apps/sample-app
11
+ `
12
+ const samplePkgUserMd = `---
13
+ name: user-feedback
14
+ version: 1.0.0
15
+ ---
16
+ ## ⏱ 1. Метрики Виконання
17
+ - **Витрачений час розробки (Годин):** \`4.5h\`
18
+ - **Кількість ітерацій (Test Runs / Refactors):** \`10\`
19
+ - **Фінальний бал RRS (Release Readiness Score):** \`324\`
20
+ `
21
+ const sampleAppUserMd = `---
22
+ name: user-feedback
23
+ version: 0.1.0
24
+ ---
25
+ ## ⏱ 1. Метрики Виконання
26
+ - **Витрачений час розробки (Годин):** \`2.0h\`
27
+ - **Кількість ітерацій (Test Runs / Refactors):** \`5\`
28
+ - **Фінальний бал RRS (Release Readiness Score):** \`300\`
29
+ `
30
+ const samplePkgReleaseMd = `# v1.0.0 - 2026-09-01
31
+ ## Tasks
32
+ ### Done **Feature A** [feat.a]
33
+ ### InProgress **Feature B** [feat.b]
34
+ `
35
+ const db = new DB({
36
+ predefined: [
37
+ ['releases.txt', rawReleasesTxt],
38
+ ['packages/sample-pkg/package.json', { name: '@nan0web/sample-pkg', version: '1.0.0' }],
39
+ ['packages/sample-pkg/releases/1/0/v1.0.0/user.md', samplePkgUserMd],
40
+ ['packages/sample-pkg/releases/1/0/v1.0.0/release.md', samplePkgReleaseMd],
41
+ ['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', '/* spec contract */'],
42
+ ['apps/sample-app/package.json', { name: 'sample-app', version: '0.1.0' }],
43
+ ['apps/sample-app/releases/0/1/v0.1.0/user.md', sampleAppUserMd],
44
+ ['apps/sample-app/releases/0/1/v0.1.0/task.test.js', '/* closed test */'],
45
+ ],
46
+ })
47
+ await db.connect()
48
+
49
+ const dashboard = new DashboardModel({ registryFile: 'releases.txt' }, { db })
50
+ const data = await dashboard.aggregate()
51
+
52
+ assert.equal(data.projects.length, 2)
53
+
54
+ // First project check
55
+ const p1 = data.projects.find((p) => p.name === '@nan0web/sample-pkg')
56
+ assert.ok(p1)
57
+ assert.equal(p1.hours, 4.5)
58
+ assert.equal(p1.iterations, 10)
59
+ assert.equal(p1.rrs, 324)
60
+ assert.equal(p1.status, 'wip') // has task.spec.js
61
+ assert.equal(p1.tasks.length, 2)
62
+ assert.equal(p1.tasks[0].slug, 'feat.a')
63
+
64
+ // Second project check
65
+ const p2 = data.projects.find((p) => p.name === 'sample-app')
66
+ assert.ok(p2)
67
+ assert.equal(p2.hours, 2.0)
68
+ assert.equal(p2.iterations, 5)
69
+ assert.equal(p2.rrs, 300)
70
+ assert.equal(p2.status, 'closed') // only task.test.js
71
+
72
+ // Summary check
73
+ assert.equal(data.summary.totalProjects, 2)
74
+ assert.equal(data.summary.totalHours, 6.5)
75
+ assert.equal(data.summary.totalIterations, 15)
76
+ assert.equal(data.summary.wipCount, 1)
77
+ assert.equal(data.summary.closedCount, 1)
78
+ })
79
+
80
+ it('yields progress and show intents when executed via async *run() generator', async () => {
81
+ const rawReleasesTxt = `packages/sample-pkg\n`
82
+ const samplePkgUserMd = `---
83
+ name: user-feedback
84
+ version: 1.0.0
85
+ ---
86
+ - **Витрачений час розробки (Годин):** \`3.0h\`
87
+ - **Кількість ітерацій (Test Runs / Refactors):** \`4\`
88
+ - **Фінальний бал RRS (Release Readiness Score):** \`200\`
89
+ `
90
+ const db = new DB({
91
+ predefined: [
92
+ ['releases.txt', rawReleasesTxt],
93
+ ['packages/sample-pkg/package.json', { name: '@nan0web/sample-pkg', version: '1.0.0' }],
94
+ ['packages/sample-pkg/releases/1/0/v1.0.0/user.md', samplePkgUserMd],
95
+ ['packages/sample-pkg/releases/1/0/v1.0.0/release.md', '# v1.0.0\n### Done Init [init]\n'],
96
+ ['packages/sample-pkg/releases/1/0/v1.0.0/task.test.js', '/* closed */'],
97
+ ],
98
+ })
99
+ await db.connect()
100
+
101
+ const dashboard = new DashboardModel({ registryFile: 'releases.txt' }, { db })
102
+ const intents = []
103
+ const gen = dashboard.run()
104
+
105
+ let next = await gen.next()
106
+ while (!next.done) {
107
+ intents.push(next.value)
108
+ next = await gen.next()
109
+ }
110
+ const finalResult = next.value
111
+
112
+ assert.ok(intents.length >= 2, 'Expected progress and show intents to be yielded')
113
+ assert.ok(intents.some((i) => i.type === 'progress'))
114
+ assert.ok(intents.some((i) => i.type === 'show'))
115
+ assert.equal(finalResult.type, 'result')
116
+ assert.equal(finalResult.data.projects.length, 1)
117
+ assert.equal(finalResult.data.projects[0].name, '@nan0web/sample-pkg')
118
+ })
119
+
120
+ it('supports injected gitTelemetry adapter', async () => {
121
+ const rawReleasesTxt = `packages/no-rel\n`
122
+ const db = new DB({
123
+ predefined: [
124
+ ['releases.txt', rawReleasesTxt],
125
+ ['packages/no-rel/package.json', { name: '@nan0web/no-rel', version: '0.0.1' }],
126
+ ],
127
+ })
128
+ await db.connect()
129
+
130
+ const mockGit = {
131
+ getGitInfo: () => ({ branch: 'feature/zen', gitStatus: 'dirty' }),
132
+ getGitCommitTelemetry: () => ({ hours: 0, iterations: 0 }),
133
+ }
134
+
135
+ const dashboard = new DashboardModel(
136
+ { registryFile: 'releases.txt' },
137
+ { db, gitTelemetry: mockGit }
138
+ )
139
+ const data = await dashboard.aggregate()
140
+ assert.equal(data.projects[0].branch, 'feature/zen')
141
+ assert.equal(data.projects[0].gitStatus, 'dirty')
142
+ })
143
+
144
+ it('parses user.md metrics correctly with static parseUserMetrics', () => {
145
+ const text = `
146
+ - **Витрачений час розробки (Годин):** \`12.5h\`
147
+ - **Кількість ітерацій (Test Runs / Refactors):** \`42\`
148
+ - **Фінальний бал RRS (Release Readiness Score):** \`350+\`
149
+ `
150
+ const metrics = DashboardModel.parseUserMetrics(text)
151
+ assert.equal(metrics.hours, 12.5)
152
+ assert.equal(metrics.iterations, 42)
153
+ assert.equal(metrics.rrs, 350)
154
+ })
155
+ })
@@ -0,0 +1,308 @@
1
+ import { ModelAsApp, progress, show, result } from '@nan0web/ui'
2
+ import { RegistryModel } from '../Registry/RegistryModel.js'
3
+ import {
4
+ parseFrontmatter,
5
+ calculateDaysRemaining,
6
+ parseUserMetrics,
7
+ normalizePriority,
8
+ resolvePriority,
9
+ sortTasks,
10
+ determineReleaseStatus,
11
+ } from './ReleaseParser.js'
12
+ import {
13
+ versionToPath,
14
+ discoverReleases,
15
+ documentExists,
16
+ loadReleaseDoc,
17
+ } from './ReleaseDiscovery.js'
18
+
19
+ /**
20
+ * @typedef {import('./ReleaseParser.js').TaskItem} TaskItem
21
+ * @typedef {Object} ProjectDashboardEntry
22
+ * @property {string} path - Relative project path
23
+ * @property {string} name - Project name
24
+ * @property {string} version - Current release version
25
+ * @property {number} hours - Development hours spent
26
+ * @property {number} iterations - Test runs / refactor iterations
27
+ * @property {number} rrs - Release Readiness Score
28
+ * @property {'wip' | 'closed' | 'unknown'} status - Release state
29
+ * @property {TaskItem[]} tasks - Parsed tasks from release.md
30
+ * @property {string} branch - Git branch name
31
+ * @property {'clean' | 'dirty'} gitStatus - Git workspace status
32
+ * @property {string} userContent - Raw user.md content
33
+ * @property {string} releaseContent - Raw release.md content
34
+ * @property {string} sourceDoc - Relative path to the parsed release document
35
+ * @property {string} sourceUserDoc - Relative path to user.md
36
+ * @property {string?} deadline - Project release deadline
37
+ * @property {number?} daysRemaining - Days remaining until deadline
38
+ * @property {string?} priority - Priority (0 | 1 | 2 | 3)
39
+ * @property {number} [releasesCount] - Number of releases found
40
+ * @property {number} [totalHours] - Total hours across all releases
41
+ * @property {number} [totalIterations] - Total iterations across all releases
42
+ * @property {Array<any>} [allReleases] - List of all releases for project
43
+ *
44
+ * @typedef {Object} DashboardSummary
45
+ * @property {number} totalProjects - Total tracked projects
46
+ * @property {number} totalHours - Sum of hours across projects
47
+ * @property {number} totalIterations - Sum of iterations
48
+ * @property {number} wipCount - Active WIP releases
49
+ * @property {number} closedCount - Closed releases
50
+ * @property {number} [totalReleases] - Total releases counted
51
+ */
52
+
53
+ /**
54
+ * DashboardModel - Aggregates release telemetry, tasks and readiness across projects.
55
+ */
56
+ export class DashboardModel extends ModelAsApp {
57
+ static registryFile = {
58
+ help: 'Path to releases.txt registry file',
59
+ default: 'releases.txt',
60
+ type: 'string',
61
+ }
62
+ static summary = {
63
+ help: 'Aggregated metrics summary',
64
+ default: { totalProjects: 0, totalHours: 0, totalIterations: 0, wipCount: 0, closedCount: 0 },
65
+ type: 'object',
66
+ }
67
+ static projects = { help: 'List of projects with metrics', default: () => [], type: 'array' }
68
+ static UI = {
69
+ aggregating: 'Aggregating release metrics for all projects...',
70
+ done: 'Dashboard aggregation complete: {$total} projects scanned',
71
+ }
72
+
73
+ /**
74
+ * @param {Partial<DashboardModel>} [data={}]
75
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions & { gitTelemetry?: any }>} [options={}]
76
+ */
77
+ constructor(data = {}, options = {}) {
78
+ super(data, options)
79
+ /** @type {string} Path to releases.txt registry file */ this.registryFile
80
+ /** @type {DashboardSummary} Aggregated metrics summary */ this.summary
81
+ /** @type {ProjectDashboardEntry[]} List of projects with metrics */ this.projects
82
+ /** @type {any} Optional git telemetry adapter */
83
+ this._gitTelemetry = options.gitTelemetry ?? null
84
+ }
85
+
86
+ static parseUserMetrics = parseUserMetrics
87
+ static normalizePriority = normalizePriority
88
+ static sortTasks = sortTasks
89
+ static versionToPath = versionToPath
90
+
91
+ /**
92
+ * Canonical OLMUI Generator: Yields progress, show, and returns result.
93
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
94
+ */
95
+ async *run() {
96
+ yield progress(this.constructor.UI.aggregating)
97
+ for await (const p of this.aggregateStream()) {
98
+ yield progress(`${p.project} (${p.current}/${p.total})`)
99
+ }
100
+ const t = this._?.t
101
+ const doneMsg = t
102
+ ? t(this.constructor.UI.done, { total: this.projects.length })
103
+ : this.constructor.UI.done.replace('{$total}', String(this.projects.length))
104
+ yield show(doneMsg, 'success')
105
+ return result({ summary: this.summary, projects: this.projects })
106
+ }
107
+
108
+ /**
109
+ * Programmatic aggregation helper
110
+ * @param {Function} [onProgress]
111
+ * @returns {Promise<{ summary: DashboardSummary, projects: ProjectDashboardEntry[] }>}
112
+ */
113
+ async aggregate(onProgress) {
114
+ for await (const p of this.aggregateStream()) {
115
+ if (typeof onProgress === 'function') onProgress(p.current, p.total, p.project)
116
+ }
117
+ return { summary: this.summary, projects: this.projects }
118
+ }
119
+
120
+ async *aggregateStream() {
121
+ const db = this.$db
122
+ const gitTelem = this._gitTelemetry
123
+
124
+ const registry = new RegistryModel({ filePath: this.registryFile }, { db })
125
+ const registeredProjects = await registry.loadProjects()
126
+
127
+ const projectEntries = []
128
+ let totalHours = 0,
129
+ totalIterations = 0,
130
+ wipCount = 0,
131
+ closedCount = 0
132
+ const totalProjects = registeredProjects.length
133
+ let currentIdx = 0
134
+
135
+ for (const proj of registeredProjects) {
136
+ currentIdx++
137
+ yield { current: currentIdx, total: totalProjects, project: proj.name || proj.path }
138
+
139
+ const projectRoot = proj.path === '.' ? '' : `${proj.path.replace(/\/+$/, '')}/`
140
+ const discovered = await discoverReleases(db, projectRoot)
141
+
142
+ if (discovered.length === 0) {
143
+ const gitInfo = gitTelem?.getGitInfo ? gitTelem.getGitInfo(proj.path) : { branch: 'main', gitStatus: 'clean' }
144
+ projectEntries.push(this.#createEmptyEntry(proj, gitInfo))
145
+ continue
146
+ }
147
+
148
+ const primaryRel = discovered[0]
149
+ const effectiveVersion = primaryRel.version
150
+ const matchedReleaseDir = primaryRel.path
151
+
152
+ let userContent = ''
153
+ const matchedUserDoc = `${matchedReleaseDir}/user.md`
154
+ try {
155
+ userContent = (await db.loadDocument(matchedUserDoc, '')) || ''
156
+ } catch {}
157
+
158
+ const metrics = parseUserMetrics(String(userContent || ''))
159
+ if (metrics.hours === 0 && metrics.iterations === 0 && gitTelem?.getGitCommitTelemetry) {
160
+ const telem = gitTelem.getGitCommitTelemetry(proj.path, matchedReleaseDir)
161
+ if (telem?.hours > 0) {
162
+ metrics.hours = telem.hours
163
+ metrics.iterations = telem.iterations
164
+ }
165
+ }
166
+
167
+ const { releaseContent, sourceDoc, tasks } = await loadReleaseDoc(db, matchedReleaseDir)
168
+ const fm = parseFrontmatter(releaseContent || userContent)
169
+ const deadline = fm.deadline || null
170
+ const daysRemaining = calculateDaysRemaining(deadline)
171
+ const priority = resolvePriority(fm, daysRemaining)
172
+
173
+ const hasSpec = await documentExists(db, `${matchedReleaseDir}/task.spec.js`)
174
+ const hasTest = await documentExists(db, `${matchedReleaseDir}/task.test.js`)
175
+ const status = determineReleaseStatus({
176
+ fmStatus: fm.status,
177
+ hasSpec,
178
+ hasTest,
179
+ tasks,
180
+ userStr: String(userContent || ''),
181
+ metrics,
182
+ })
183
+
184
+ if (status === 'wip') wipCount++
185
+ else if (status === 'closed') closedCount++
186
+
187
+ const { allReleases, projectTotalHours, projectTotalIterations } =
188
+ await this.#scanAllReleases(db, discovered, metrics)
189
+ totalHours += projectTotalHours
190
+ totalIterations += projectTotalIterations
191
+
192
+ const gitInfo = gitTelem?.getGitInfo ? gitTelem.getGitInfo(proj.path) : { branch: 'main', gitStatus: 'clean' }
193
+ projectEntries.push({
194
+ path: proj.path,
195
+ name: proj.name,
196
+ version: effectiveVersion,
197
+ hours: metrics.hours,
198
+ iterations: metrics.iterations,
199
+ totalHours: Math.round(projectTotalHours * 10) / 10,
200
+ totalIterations: projectTotalIterations,
201
+ releasesCount: Math.max(discovered.length, 1),
202
+ allReleases,
203
+ rrs: metrics.rrs,
204
+ status,
205
+ tasks,
206
+ branch: gitInfo.branch,
207
+ gitStatus: gitInfo.gitStatus,
208
+ userContent: String(userContent || ''),
209
+ releaseContent: String(releaseContent || ''),
210
+ sourceDoc:
211
+ sourceDoc || `${projectRoot}releases/${versionToPath(effectiveVersion)}/release.md`,
212
+ sourceUserDoc:
213
+ matchedUserDoc || `${projectRoot}releases/${versionToPath(effectiveVersion)}/user.md`,
214
+ deadline,
215
+ daysRemaining,
216
+ priority,
217
+ })
218
+ }
219
+
220
+ this.summary = {
221
+ totalProjects: registeredProjects.length,
222
+ totalReleases: projectEntries.reduce((sum, p) => sum + (p.releasesCount || 1), 0),
223
+ totalHours: Math.round(totalHours * 10) / 10,
224
+ totalIterations,
225
+ wipCount,
226
+ closedCount,
227
+ }
228
+ this.projects = projectEntries
229
+ return { summary: this.summary, projects: projectEntries }
230
+ }
231
+
232
+ #createEmptyEntry(proj, gitInfo) {
233
+ return {
234
+ name: proj.name,
235
+ version: proj.version || '0.0.0',
236
+ path: proj.path,
237
+ status: 'unknown',
238
+ hours: 0,
239
+ iterations: 0,
240
+ rrs: 0,
241
+ branch: gitInfo.branch,
242
+ gitStatus: gitInfo.gitStatus,
243
+ tasks: [],
244
+ deadline: null,
245
+ daysRemaining: null,
246
+ priority: '1',
247
+ releasesCount: 0,
248
+ totalHours: 0,
249
+ totalIterations: 0,
250
+ allReleases: [],
251
+ sourceDoc: '',
252
+ sourceUserDoc: '',
253
+ userContent: '',
254
+ releaseContent: '',
255
+ }
256
+ }
257
+
258
+ async #scanAllReleases(db, discovered, primaryMetrics) {
259
+ let projectTotalHours = 0,
260
+ projectTotalIterations = 0
261
+ const allReleases = []
262
+
263
+ for (const rel of discovered) {
264
+ let relUser = ''
265
+ try {
266
+ relUser = (await db.loadDocument(`${rel.path}/user.md`, '')) || ''
267
+ } catch {}
268
+ const rm = parseUserMetrics(String(relUser || ''))
269
+ projectTotalHours += rm.hours
270
+ projectTotalIterations += rm.iterations
271
+
272
+ const {
273
+ releaseContent: relDocContent,
274
+ sourceDoc: relDocPath,
275
+ tasks: relTasks,
276
+ } = await loadReleaseDoc(db, rel.path)
277
+ const relFm = parseFrontmatter(relDocContent || relUser)
278
+ const hasRelSpec = await documentExists(db, `${rel.path}/task.spec.js`)
279
+ const hasRelTest = await documentExists(db, `${rel.path}/task.test.js`)
280
+
281
+ const relStatus = determineReleaseStatus({
282
+ fmStatus: relFm.status,
283
+ hasSpec: hasRelSpec,
284
+ hasTest: hasRelTest,
285
+ tasks: relTasks,
286
+ userStr: String(relUser || ''),
287
+ metrics: rm,
288
+ })
289
+
290
+ allReleases.push({
291
+ version: rel.version,
292
+ path: rel.path,
293
+ hours: rm.hours,
294
+ iterations: rm.iterations,
295
+ rrs: rm.rrs,
296
+ status: relStatus,
297
+ tasks: relTasks,
298
+ sourceDoc: relDocPath,
299
+ })
300
+ }
301
+
302
+ if (projectTotalHours === 0 && primaryMetrics.hours > 0)
303
+ projectTotalHours = primaryMetrics.hours
304
+ if (projectTotalIterations === 0 && primaryMetrics.iterations > 0)
305
+ projectTotalIterations = primaryMetrics.iterations
306
+ return { allReleases, projectTotalHours, projectTotalIterations }
307
+ }
308
+ }