@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
@@ -0,0 +1,418 @@
1
+ import Markdown, {
2
+ MDHeading1,
3
+ MDHeading2,
4
+ MDHeading3,
5
+ MDTaskList,
6
+ MDTable,
7
+ MDTableRow,
8
+ MDList,
9
+ MDParagraph,
10
+ } from '@nan0web/markdown'
11
+ import {
12
+ ReleaseModel,
13
+ ReleaseMemberModel,
14
+ ReleaseTaskModel,
15
+ ReleaseParityModel,
16
+ ReleaseIssueModel,
17
+ } from './models/index.js'
18
+
19
+ /**
20
+ * Clean text from emoji, symbols, markdown formatting and trim.
21
+ * @param {string} text
22
+ * @returns {string}
23
+ */
24
+ export function cleanSectionTitle(text) {
25
+ return String(text || '')
26
+ .replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
27
+ .replace(/^[#\s*_-]+|[#\s*_-]+$/g, '')
28
+ .trim()
29
+ .toLowerCase()
30
+ }
31
+
32
+ /**
33
+ * Match section title against known synonyms in ReleaseModel.SECTIONS.
34
+ * @param {string} headingText
35
+ * @param {typeof ReleaseModel} ModelClass
36
+ * @returns {string | null} Recognized section key or null
37
+ */
38
+ export function identifySection(headingText, ModelClass = ReleaseModel) {
39
+ const cleaned = cleanSectionTitle(headingText)
40
+ if (!cleaned) return null
41
+
42
+ for (const [sectionKey, synonyms] of Object.entries(ModelClass.SECTIONS)) {
43
+ for (const synonym of synonyms) {
44
+ const synCleaned = synonym.trim().toLowerCase()
45
+ if (cleaned === synCleaned || cleaned.startsWith(synCleaned) || cleaned.endsWith(synCleaned)) {
46
+ return sectionKey
47
+ }
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * Resolve external tracker URL for a task or issue ID using template URL.
55
+ * Example: trackerUrl = "https://github.com/org/repo/issues/{id}", rawId = "[#142]" -> "https://github.com/org/repo/issues/142"
56
+ * @param {string} id
57
+ * @param {string} trackerTemplate
58
+ * @returns {string}
59
+ */
60
+ export function resolveTrackerUrl(id, trackerTemplate) {
61
+ if (!trackerTemplate || !id) return ''
62
+ const cleanId = String(id).replace(/^\[#?|\]$/g, '').trim()
63
+ return trackerTemplate.replace(/\{id\}/g, cleanId)
64
+ }
65
+
66
+ /**
67
+ * PMaCParser - Parses PMaC Release/Task specification documents into ReleaseModel instances.
68
+ */
69
+ export class PMaCParser {
70
+ /**
71
+ * @param {typeof ReleaseModel} [modelClass]
72
+ */
73
+ constructor(modelClass = ReleaseModel) {
74
+ this.ModelClass = modelClass
75
+ }
76
+
77
+ /**
78
+ * Parse markdown content into a ReleaseModel instance.
79
+ * @param {string} markdownContent
80
+ * @returns {ReleaseModel}
81
+ */
82
+ parse(markdownContent) {
83
+ const md = new Markdown(markdownContent)
84
+ const frontmatter = md.vars || {}
85
+ const elements = md.document?.children || []
86
+
87
+ // 1. Identify H1 for version and title
88
+ let version = ''
89
+ let title = ''
90
+
91
+ const h1 = elements.find((el) => el instanceof MDHeading1)
92
+ if (h1) {
93
+ const rawTitle = h1.content?.trim() || ''
94
+ // Matches formats like: "# v0.1.0 — Core Release Engine" or "# v0.1.0 - Core Release" or "# v1.0.0"
95
+ const h1Match = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)\s*(?:[—–-]\s*(.+))?$/i)
96
+ if (h1Match) {
97
+ version = h1Match[1]
98
+ title = (h1Match[2] || '').trim()
99
+ } else {
100
+ // Fallback: check if starts with version
101
+ const vMatch = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)/i)
102
+ if (vMatch) {
103
+ version = vMatch[1]
104
+ title = rawTitle.slice(vMatch[0].length).replace(/^[—–-\s]+/, '').trim()
105
+ } else {
106
+ title = rawTitle
107
+ }
108
+ }
109
+ }
110
+
111
+ const trackerTemplate = frontmatter.tracker || ''
112
+
113
+ // Build release data
114
+ const releaseData = {
115
+ version: frontmatter.version || version,
116
+ title: frontmatter.title || title,
117
+ status: frontmatter.status || 'todo',
118
+ type: frontmatter.type || 'minor',
119
+ epic: frontmatter.epic,
120
+ sprint: frontmatter.sprint,
121
+ tracker: trackerTemplate,
122
+ summary: '',
123
+ team: [],
124
+ tasks: [],
125
+ criteria: [],
126
+ parity: [],
127
+ issues: [],
128
+ files: [],
129
+ meta: { ...frontmatter },
130
+ }
131
+
132
+ // 2. Iterate through sections delimited by MDHeading2
133
+ let currentSection = null
134
+ let currentTaskGroup = ''
135
+
136
+ for (const el of elements) {
137
+ if (el instanceof MDHeading2) {
138
+ currentSection = identifySection(el.content, this.ModelClass)
139
+ currentTaskGroup = ''
140
+ continue
141
+ }
142
+
143
+ if (el instanceof MDHeading3) {
144
+ currentTaskGroup = el.content?.trim() || ''
145
+ continue
146
+ }
147
+
148
+ if (!currentSection) continue
149
+
150
+ if (currentSection === 'summary') {
151
+ if (el instanceof MDParagraph) {
152
+ const text = el.content?.trim()
153
+ if (text) {
154
+ releaseData.summary = releaseData.summary
155
+ ? `${releaseData.summary}\n\n${text}`
156
+ : text
157
+ }
158
+ }
159
+ } else if (currentSection === 'team') {
160
+ this._parseTeamSection(el, releaseData.team)
161
+ } else if (currentSection === 'tasks') {
162
+ this._parseTasksSection(el, releaseData.tasks, currentTaskGroup, trackerTemplate)
163
+ } else if (currentSection === 'criteria') {
164
+ this._parseTasksSection(el, releaseData.criteria, currentTaskGroup, trackerTemplate)
165
+ } else if (currentSection === 'parity') {
166
+ this._parseParitySection(el, releaseData.parity)
167
+ } else if (currentSection === 'issues') {
168
+ this._parseIssuesSection(el, releaseData.issues)
169
+ } else if (currentSection === 'artifacts') {
170
+ this._parseArtifactsSection(el, releaseData.files)
171
+ }
172
+ }
173
+
174
+ return new this.ModelClass(releaseData)
175
+ }
176
+
177
+ /**
178
+ * @private
179
+ */
180
+ _parseTeamSection(el, teamList) {
181
+ const items = []
182
+ if (el instanceof MDList) {
183
+ for (const child of el.children) {
184
+ items.push(child.content?.trim() || '')
185
+ }
186
+ } else if (el.content) {
187
+ const lines = el.content.split('\n')
188
+ for (const line of lines) {
189
+ const trimmed = line.trim()
190
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
191
+ items.push(trimmed.slice(2).trim())
192
+ }
193
+ }
194
+ }
195
+
196
+ for (const item of items) {
197
+ // Matches: "**Lead / Architect:** Олександр Снігірьов (@snigirev)"
198
+ // or "**Посада:** Ім'я (@handle)"
199
+ const match = item.match(/^\*\*([^*]+)\*\*\s*(.*)$/)
200
+ if (match) {
201
+ const role = match[1].replace(/:$/, '').trim()
202
+ const rest = match[2].trim()
203
+ const handleMatch = rest.match(/\(@([^)]+)\)/)
204
+ const handle = handleMatch ? handleMatch[1].trim() : ''
205
+ const name = rest.replace(/\(@[^)]+\)/, '').trim()
206
+ teamList.push(new ReleaseMemberModel({ role, name, handle }))
207
+ } else {
208
+ teamList.push(new ReleaseMemberModel({ role: '', name: item, handle: '' }))
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @private
215
+ */
216
+ _parseTasksSection(el, tasksList, taskGroup, trackerTemplate) {
217
+ const items = []
218
+ if (el instanceof MDTaskList || el instanceof MDList) {
219
+ for (const child of el.children) {
220
+ items.push(child.content?.trim() || '')
221
+ }
222
+ } else if (el.content && el.content.includes('- [')) {
223
+ const lines = el.content.split('\n')
224
+ for (const line of lines) {
225
+ const trimmed = line.trim()
226
+ if (trimmed.startsWith('- [')) {
227
+ items.push(trimmed.slice(2).trim())
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const rawItem of items) {
233
+ // e.g. "[x] [#101-1] Винести базову модель ReleaseModel у пакет @nan0web/release"
234
+ // or "- [x] [#101-1] ..." or "[ ] [#101-3] ..."
235
+ let line = rawItem
236
+ if (line.startsWith('- ')) line = line.slice(2).trim()
237
+
238
+ const boxMatch = line.match(/^\[([ xX])\]\s*(.*)$/)
239
+ let isDone = false
240
+ let text = line
241
+
242
+ if (boxMatch) {
243
+ isDone = boxMatch[1].toLowerCase() === 'x'
244
+ text = boxMatch[2].trim()
245
+ }
246
+
247
+ // Extract task ID like [#101-1] or [101-1] or [CORE-101]
248
+ let id = ''
249
+ const idMatch = text.match(/^\[#?([A-Za-z0-9_-]+)\]\s*(.*)$/)
250
+ if (idMatch) {
251
+ id = idMatch[1]
252
+ text = idMatch[2].trim()
253
+ } else {
254
+ // Fallback: derive ID from group or random
255
+ id = `task-${tasksList.length + 1}`
256
+ }
257
+
258
+ // Check for notes or assignee or hours in parens/brackets
259
+ let notes = ''
260
+ if (taskGroup) {
261
+ notes = taskGroup
262
+ }
263
+
264
+ tasksList.push(
265
+ new ReleaseTaskModel({
266
+ id,
267
+ title: text,
268
+ status: isDone ? 'done' : 'todo',
269
+ notes,
270
+ })
271
+ )
272
+ }
273
+ }
274
+
275
+ /**
276
+ * @private
277
+ */
278
+ _parseParitySection(el, parityList) {
279
+ let rows = []
280
+ if (el instanceof MDTable) {
281
+ rows = el.children || []
282
+ } else if (el instanceof MDTableRow) {
283
+ rows = [el]
284
+ }
285
+ if (rows.length === 0) return
286
+
287
+ for (const row of rows) {
288
+ const rowContent = row.content?.trim() || ''
289
+ // Skip separator rows like | :--- | :---: |
290
+ if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
291
+
292
+ const cells = rowContent
293
+ .split('|')
294
+ .map((c) => c.trim())
295
+ .filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
296
+
297
+ // Skip header row
298
+ if (cells.length >= 3) {
299
+ if (cells[0].toLowerCase() === 'колекція' || cells[0].toLowerCase() === 'collection') {
300
+ continue
301
+ }
302
+ const collection = cells[0].replace(/`/g, '')
303
+ const expected = parseInt(cells[1], 10) || 0
304
+ const actual = parseInt(cells[2], 10) || 0
305
+ const statusText = cells[3] || ''
306
+ const comment = cells[4] || ''
307
+ const synced = statusText.includes('OK') || statusText.includes('✅') || expected === actual
308
+
309
+ parityList.push(
310
+ new ReleaseParityModel({
311
+ collection,
312
+ expected,
313
+ actual,
314
+ synced,
315
+ comment,
316
+ })
317
+ )
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * @private
324
+ */
325
+ _parseIssuesSection(el, issuesList) {
326
+ const items = []
327
+ if (el instanceof MDList) {
328
+ for (const child of el.children) {
329
+ items.push(child.content?.trim() || '')
330
+ }
331
+ } else if (el.content) {
332
+ const lines = el.content.split('\n')
333
+ for (const line of lines) {
334
+ const trimmed = line.trim()
335
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
336
+ items.push(trimmed.slice(2).trim())
337
+ }
338
+ }
339
+ }
340
+
341
+ for (const item of items) {
342
+ // Check if this is a sub-detail line (e.g. "Джерело: `@nan0web/payload-release/admin.jsx:L85`" or "Source: ...")
343
+ const sourceMatch = item.match(/^(?:Джерело|Source|File):\s*`?([^`]+)`?/i)
344
+ if (sourceMatch && issuesList.length > 0) {
345
+ const lastIssue = issuesList[issuesList.length - 1]
346
+ lastIssue.source = sourceMatch[1].trim()
347
+ lastIssue.file = sourceMatch[1].split(':')[0].trim()
348
+ continue
349
+ }
350
+
351
+ // Example: "**[WARN-001]** (Performance) Файли розміром > 2MB не повинні відкриватися"
352
+ const codeMatch = item.match(/^\*\*\[?([A-Za-z0-9_-]+)\]?\*\*\s*(.*)$/)
353
+ let code = ''
354
+ let rest = item
355
+ if (codeMatch) {
356
+ code = codeMatch[1]
357
+ rest = codeMatch[2].trim()
358
+ }
359
+
360
+ let level = 'warning'
361
+ const upperCode = code.toUpperCase()
362
+ if (upperCode.includes('ERR') || upperCode.includes('BLOCK')) level = 'error'
363
+ else if (upperCode.includes('INFO')) level = 'info'
364
+
365
+ issuesList.push(
366
+ new ReleaseIssueModel({
367
+ code,
368
+ message: rest,
369
+ level,
370
+ })
371
+ )
372
+ }
373
+ }
374
+
375
+ /**
376
+ * @private
377
+ */
378
+ _parseArtifactsSection(el, filesList) {
379
+ let rows = []
380
+ if (el instanceof MDTable) {
381
+ rows = el.children || []
382
+ } else if (el instanceof MDTableRow) {
383
+ rows = [el]
384
+ }
385
+ if (rows.length === 0) return
386
+
387
+ for (const row of rows) {
388
+ const rowContent = row.content?.trim() || ''
389
+ if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
390
+
391
+ const cells = rowContent
392
+ .split('|')
393
+ .map((c) => c.trim())
394
+ .filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
395
+
396
+ if (cells.length >= 1) {
397
+ if (cells[0].toLowerCase() === 'файл' || cells[0].toLowerCase() === 'file') {
398
+ continue
399
+ }
400
+ const file = cells[0].replace(/`/g, '')
401
+ const size = cells[1] || ''
402
+ const hash = cells[2] ? cells[2].replace(/`/g, '') : ''
403
+ const type = cells[3] || ''
404
+ filesList.push({ file, size, hash, type })
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Static shortcut to parse specification
411
+ * @param {string} markdownContent
412
+ * @returns {ReleaseModel}
413
+ */
414
+ static parse(markdownContent) {
415
+ return new PMaCParser().parse(markdownContent)
416
+ }
417
+ }
418
+
@@ -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,39 @@
1
+ import WebCommand from './WebCommand.js'
2
+
3
+ /**
4
+ * PreviewCommand - ModelAsApp Subcommand to generate, serve and preview PM-as-Code Web Dashboard.
5
+ */
6
+ export default class PreviewCommand extends WebCommand {
7
+ static alias = 'preview'
8
+
9
+ static UI = {
10
+ title: 'preview',
11
+ help: 'Generate, serve and preview PM-as-Code Web Dashboard',
12
+ loading: 'Aggregating release telemetry and preparing Web Dashboard preview...',
13
+ generated: '✨ Web Dashboard успішно згенеровано: {$path}',
14
+ serving: '🌐 Web Dashboard запущено: http://localhost:{$port} (Натисніть Ctrl+C для зупинки)',
15
+ }
16
+
17
+ static port = {
18
+ help: 'Port to serve HTML dashboard over HTTP',
19
+ default: 3333,
20
+ type: 'number',
21
+ alias: 'p',
22
+ }
23
+
24
+ static open = {
25
+ help: 'Automatically open in browser',
26
+ default: true,
27
+ type: 'boolean',
28
+ }
29
+
30
+ /**
31
+ * @param {Partial<PreviewCommand>} [data]
32
+ * @param {import('@nan0web/types').ModelOptions} [options]
33
+ */
34
+ constructor(data = {}, options = {}) {
35
+ super({ port: 3333, open: true, ...data }, options)
36
+ }
37
+ }
38
+
39
+ export { PreviewCommand }
@@ -2,6 +2,13 @@ import { ModelAsApp, result, show } from '@nan0web/ui'
2
2
  import { runSpawn } from '@nan0web/test'
3
3
 
4
4
  export default class PublishCommand extends ModelAsApp {
5
+ /** @type {boolean} Bump major version */
6
+ major = false
7
+ /** @type {boolean} Bump minor version */
8
+ minor = false
9
+ /** @type {boolean} Bump patch version */
10
+ patch = false
11
+
5
12
  static major = {
6
13
  help: 'Bump major version',
7
14
  default: false,
@@ -25,9 +32,6 @@ export default class PublishCommand extends ModelAsApp {
25
32
  */
26
33
  constructor(data = {}, options = {}) {
27
34
  super(data, options)
28
- /** @type {boolean} Bump major version */ this.major
29
- /** @type {boolean} Bump minor version */ this.minor
30
- /** @type {boolean} Bump patch version */ this.patch
31
35
  }
32
36
 
33
37
  /**