@nan0web/release 3.3.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 (73) 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 +24 -27
  39. package/types/Release/MarkdownToTest.d.ts +1 -1
  40. package/types/Release/Person.d.ts +9 -7
  41. package/types/Release/Scanner.d.ts +41 -41
  42. package/types/Release.d.ts +42 -42
  43. package/types/architecture/ProjectManagementAsCode.d.ts +59 -64
  44. package/types/domain/App.d.ts +24 -7
  45. package/types/domain/BumpCommand.d.ts +11 -6
  46. package/types/domain/CheckCommand.d.ts +128 -16
  47. package/types/domain/CheckCommand.story.d.ts +1 -0
  48. package/types/domain/CloseCommand.d.ts +4 -6
  49. package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
  50. package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
  51. package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
  52. package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
  53. package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
  54. package/types/domain/Dashboard/index.d.ts +3 -0
  55. package/types/domain/DepsCommand.d.ts +6 -6
  56. package/types/domain/InitCommand.d.ts +4 -6
  57. package/types/domain/ListCommand.d.ts +18 -0
  58. package/types/domain/PreviewCommand.d.ts +6 -0
  59. package/types/domain/PublishCommand.d.ts +8 -6
  60. package/types/domain/Registry/Registry.story.d.ts +1 -0
  61. package/types/domain/Registry/RegistryModel.d.ts +61 -0
  62. package/types/domain/ServeCommand.d.ts +59 -0
  63. package/types/domain/ServeCommand.story.d.ts +1 -0
  64. package/types/domain/SpecCommand.d.ts +4 -6
  65. package/types/domain/StatusCommand.d.ts +38 -3
  66. package/types/domain/ViewCommand.d.ts +27 -0
  67. package/types/domain/WebCommand.d.ts +47 -0
  68. package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
  69. package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
  70. package/types/domain/templates/release.html.template.d.ts +2 -0
  71. package/types/index.d.ts +18 -10
  72. package/types/inspectors/inspect-js.d.ts +2 -0
  73. package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
@@ -1,193 +1,551 @@
1
- import { ModelAsApp, result, show } from '@nan0web/ui'
2
- import { runSpawn } from '@nan0web/test'
3
- import { join } from 'node:path'
4
- import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
1
+ import { ModelAsApp, show, result, progress } from '@nan0web/ui'
2
+ import { RegistryModel } from './Registry/RegistryModel.js'
3
+ import { DashboardModel } from './Dashboard/DashboardModel.js'
4
+
5
+ /**
6
+ * @typedef {Object} DiagnosticItem
7
+ * @property {'error' | 'warning' | 'info'} level
8
+ * @property {string} code
9
+ * @property {string} project
10
+ * @property {string} message
11
+ * @property {string} file
12
+ * @property {string} fix
13
+ */
14
+
15
+ /**
16
+ * CheckCommand - PM-as-Code Release Structure & Compliance Validator.
17
+ */
18
+ export class CheckCommand extends ModelAsApp {
19
+ static alias = 'check'
5
20
 
6
- export default class CheckCommand extends ModelAsApp {
7
21
  static UI = {
8
- title: 'Integrity Auditor',
9
- icon: '🔍',
10
- description: 'Compares local source files with the version published on NPM.',
22
+ errorDbRequired: 'Database instance ({ db }) is required to execute CheckCommand',
23
+ title: 'PM-as-Code Structure & Compliance Validator',
24
+ header: '🔍 PM-as-Code Release Structure Validator (nan0release check)',
25
+ scanningCount: 'Scanning {$count} project(s)...',
26
+ projectHeader: '\n📦 Project: {$name} ({$path})',
27
+ missingPackageJson: ' ❌ Missing package.json',
28
+ packageJsonValid: ' ✅ package.json (v{$version})',
29
+ noReleasesDir: ' ❌ No release directories found in {$path}',
30
+ releasesDirFound: ' ✅ releases/ directory located ({$count} release(s) found)',
31
+ releaseHeader: ' 📂 Release [v{$version}] at {$path}:',
32
+ releaseDocCompleted: ' ✅ {$file} ({$done}/{$total} tasks completed)',
33
+ missingReleaseDoc: ' ❌ Missing release.md or task.md',
34
+ missingUserDoc: ' ⚠️ Missing user.md metrics document',
35
+ incompleteUserMetrics:
36
+ ' ⚠️ user.md present but metrics are incomplete (Hours: 0h, RRS: 0)',
37
+ userMetricsValid: ' ✅ user.md (Hours: {$hours}h, Iterations: {$iterations}, RRS: {$rrs})',
38
+ ambiguousContracts: ' ❌ Both task.spec.js and task.test.js exist simultaneously',
39
+ missingContract: ' ❌ Missing contract test (task.spec.js or task.test.js)',
40
+ activeContractWip: ' ✅ Active contract: task.spec.js [WIP]',
41
+ sealedRegressionClosed: ' ✅ Sealed regression: task.test.js [CLOSED]',
42
+ orphanFile: ' ⚠️ Orphan file in releases/: {$file}',
43
+ recommendationsHeader: '\n📋 Actionable Recommendations & Agent Instructions ({$count}):',
44
+ recommendationItem:
45
+ '\n[{$idx}] {$badge} [{$code}] {$message}\n File: {$file}\n 👉 Fix Instruction (Human / AI Agent):\n {$fix}',
46
+ summary:
47
+ '\n📊 Summary: {$projects} project(s) checked | Passed: {$passed} | Errors: {$errors} | Warnings: {$warnings}',
48
+ Diagnostics: {
49
+ MISSING_PACKAGE_JSON: {
50
+ level: 'error',
51
+ code: 'MISSING_PACKAGE_JSON',
52
+ message: 'Missing package.json in {$path}',
53
+ fix: 'Ensure {$projectRoot}package.json exists with valid "name" and "version" fields.',
54
+ },
55
+ MISSING_RELEASES_DIR: {
56
+ level: 'error',
57
+ code: 'MISSING_RELEASES_DIR',
58
+ message: 'No release directories found under {$path}',
59
+ fix: 'Initialize release directory: `pnpm exec release init v{$version}`',
60
+ },
61
+ MISSING_RELEASE_MD: {
62
+ level: 'error',
63
+ code: 'MISSING_RELEASE_MD',
64
+ message: 'Missing release.md or task.md in {$path}',
65
+ fix: 'Create {$path}/release.md with release notes, tasks checklist, and acceptance criteria.',
66
+ },
67
+ MISSING_USER_MD: {
68
+ level: 'warning',
69
+ code: 'MISSING_USER_MD',
70
+ message: 'Missing user.md metrics document in {$path}',
71
+ fix: 'Create {$path}/user.md containing Execution Metrics (Hours, Iterations, and RRS score).',
72
+ },
73
+ INCOMPLETE_USER_METRICS: {
74
+ level: 'warning',
75
+ code: 'INCOMPLETE_USER_METRICS',
76
+ message: 'user.md in {$path} is missing hours, iterations or RRS score',
77
+ fix: 'Update {$path}/user.md with sections: "Витрачений час розробки (Годин):", "Кількість ітерацій:", "Фінальний бал RRS:".',
78
+ },
79
+ AMBIGUOUS_CONTRACTS: {
80
+ level: 'error',
81
+ code: 'AMBIGUOUS_CONTRACTS',
82
+ message:
83
+ 'Both task.spec.js (WIP) and task.test.js (Closed) exist simultaneously in {$path}',
84
+ fix: 'If release is still in progress, delete task.test.js. If closed, delete task.spec.js.',
85
+ },
86
+ MISSING_CONTRACT: {
87
+ level: 'error',
88
+ code: 'MISSING_CONTRACT',
89
+ message: 'No contract test found in {$path} (missing task.spec.js or task.test.js)',
90
+ fix: 'Create contract test {$path}/task.spec.js for active development, or rename to task.test.js upon release sealing.',
91
+ },
92
+ ORPHAN_RELEASE_FILE: {
93
+ level: 'warning',
94
+ code: 'ORPHAN_RELEASE_FILE',
95
+ message:
96
+ 'Orphan file "{$file}" located directly in releases/ directory outside of version folders',
97
+ fix: 'Move "{$file}" into the appropriate version directory (e.g. releases/vX.Y.Z/) or remove it.',
98
+ },
99
+ },
11
100
  }
12
101
 
13
- static name = 'check'
102
+ static target = {
103
+ help: 'Project path, package name, or "." to validate',
104
+ default: '',
105
+ type: 'string',
106
+ positional: true,
107
+ }
108
+
109
+ static registryFile = {
110
+ help: 'Path to releases.txt registry file',
111
+ default: 'releases.txt',
112
+ type: 'string',
113
+ alias: 'f',
114
+ }
115
+
116
+ static all = {
117
+ help: 'Validate all projects registered in releases.txt',
118
+ default: false,
119
+ type: 'boolean',
120
+ alias: 'a',
121
+ }
14
122
 
15
123
  /**
16
- * @returns {AsyncGenerator<any, any, any>}
124
+ * @param {Partial<CheckCommand>} [data]
125
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options]
17
126
  */
18
- async *run() {
19
- const { t, db = /** @type {*} */ (this._.db) } = this._
20
- const pkgPath = '@app/package.json'
21
- const pkg = await db.loadDocument(pkgPath, {})
127
+ constructor(data = {}, options = {}) {
128
+ super(data, options)
129
+ /** @type {string} */ this.target
130
+ /** @type {string} */ this.registryFile
131
+ /** @type {boolean} */ this.all
132
+ }
22
133
 
23
- if (!pkg || !pkg.name) {
24
- yield show(t('Could not identify package name in {path}', { path: pkgPath }), 'error')
25
- return result({ success: false })
134
+ /**
135
+ * Execute release structure check
136
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, { success: boolean, diagnostics: DiagnosticItem[], checkedProjects: any[] }, any>}
137
+ */
138
+ async *run() {
139
+ const rawT = this._.t
140
+ const db = this._.db
141
+ const t = (template, params = {}) => {
142
+ let str = (rawT ? rawT(template, params) : template) || template || ''
143
+ if (params && typeof params === 'object') {
144
+ for (const [k, v] of Object.entries(params)) {
145
+ str = str.replaceAll(`{$${k}}`, String(v)).replaceAll(`{${k}}`, String(v))
146
+ }
147
+ }
148
+ return str
149
+ }
150
+ if (!db) {
151
+ throw new Error(t(CheckCommand.UI.errorDbRequired))
26
152
  }
27
153
 
28
- const name = pkg.name
29
- const version = pkg.version
30
- yield show(t('Auditing {name}@{version} vs NPM...', { name, version }), 'info')
154
+ yield show(t(CheckCommand.UI.header))
31
155
 
32
- const appAbsDir = db.location('@app')
33
- yield show(t('Local path: {path}', { path: appAbsDir }))
156
+ // 1. Resolve list of projects to check
157
+ const registry = new RegistryModel({ filePath: this.registryFile || 'releases.txt' }, { db })
158
+ let registeredProjects = []
159
+ try {
160
+ registeredProjects = await registry.loadProjects()
161
+ } catch {
162
+ registeredProjects = []
163
+ }
34
164
 
35
- // 1. Create temporary directory
36
- const tmpDir = join(process.cwd(), '.nan0web_check_tmp')
37
- if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
38
- mkdirSync(tmpDir, { recursive: true })
165
+ let targetsToCheck = []
166
+ const targetParam = (this.target || '').trim()
167
+
168
+ if (
169
+ this.all ||
170
+ targetParam === '--all' ||
171
+ targetParam === '-a' ||
172
+ (!targetParam && registeredProjects.length > 0)
173
+ ) {
174
+ targetsToCheck = registeredProjects
175
+ } else if (targetParam) {
176
+ const targetNum = parseInt(targetParam, 10)
177
+ if (!isNaN(targetNum) && targetNum >= 1 && targetNum <= registeredProjects.length) {
178
+ targetsToCheck = [registeredProjects[targetNum - 1]]
179
+ } else {
180
+ const matched = registeredProjects.find(
181
+ (p) =>
182
+ p.name === targetParam ||
183
+ p.path === targetParam ||
184
+ p.path === targetParam.replace(/\/+$/, '')
185
+ )
186
+ if (matched) {
187
+ targetsToCheck = [matched]
188
+ } else {
189
+ targetsToCheck = [{ path: targetParam, name: targetParam, version: '0.0.0' }]
190
+ }
191
+ }
192
+ } else {
193
+ targetsToCheck = [{ path: '.', name: 'current-project', version: '0.0.0' }]
194
+ }
39
195
 
40
- try {
41
- // 2. Download from NPM
42
- yield show(t('Downloading {name} from NPM registry...', { name }))
43
- const packRes = await runSpawn('npm', ['pack', name], { cwd: tmpDir })
44
- if (packRes.code !== 0) throw new Error(packRes.error || 'Failed to run npm pack')
45
-
46
- const tgzFile = packRes.text.trim().split('\n').pop()
47
- if (!tgzFile) throw new Error('Failed to find tarball name from npm pack output')
48
- const tgzPath = join(tmpDir, tgzFile)
49
-
50
- // 3. Unpack
51
- yield show(t('Unpacking {file}...', { file: tgzFile }))
52
- const tarRes = await runSpawn('tar', ['-xzf', tgzFile], { cwd: tmpDir })
53
- if (tarRes.code !== 0) throw new Error(tarRes.error || 'Failed to unpack tarball')
54
-
55
- const npmSourceDir = join(tmpDir, 'package')
56
-
57
- // 4. Scan Local Files
58
- yield show(t('Scanning local files...'))
59
- const localFiles = await this._scanLocal(appAbsDir)
60
-
61
- // 5. Compare
62
- const diff = {
63
- /** @type {string[]} */
64
- modified: [],
65
- /** @type {string[]} */
66
- added: [],
67
- /** @type {string[]} */
68
- deleted: [],
69
- matched: 0,
196
+ yield progress(t(CheckCommand.UI.scanningCount, { count: targetsToCheck.length }))
197
+
198
+ /** @type {DiagnosticItem[]} */
199
+ const diagnostics = []
200
+ const checkedProjects = []
201
+
202
+ for (const proj of targetsToCheck) {
203
+ const projectRoot = proj.path === '.' ? '' : `${proj.path.replace(/\/+$/, '')}/`
204
+
205
+ yield show(t(CheckCommand.UI.projectHeader, { name: proj.name, path: proj.path }))
206
+
207
+ // A. Validate package.json via db
208
+ let pkg = null
209
+ try {
210
+ pkg = await db.loadDocument(`${projectRoot}package.json`, null)
211
+ } catch {}
212
+
213
+ if (!pkg) {
214
+ const diagDef = CheckCommand.UI.Diagnostics.MISSING_PACKAGE_JSON
215
+ diagnostics.push({
216
+ level: diagDef.level,
217
+ code: diagDef.code,
218
+ project: proj.name,
219
+ file: `${projectRoot}package.json`,
220
+ message: t(diagDef.message, { path: proj.path }),
221
+ fix: t(diagDef.fix, { projectRoot }),
222
+ })
223
+ yield show(t(CheckCommand.UI.missingPackageJson), 'error')
224
+ } else {
225
+ yield show(
226
+ t(CheckCommand.UI.packageJsonValid, { version: pkg.version || '0.0.0' }),
227
+ 'success'
228
+ )
70
229
  }
71
230
 
72
- for (const relPath of localFiles) {
73
- const localAbs = join(appAbsDir, relPath)
74
- const npmAbs = join(npmSourceDir, relPath)
231
+ // B. Discover release directories via db
232
+ const releaseDirs = await this.#discoverReleases(db, projectRoot)
233
+
234
+ if (releaseDirs.length === 0) {
235
+ const diagDef = CheckCommand.UI.Diagnostics.MISSING_RELEASES_DIR
236
+ diagnostics.push({
237
+ level: diagDef.level,
238
+ code: diagDef.code,
239
+ project: proj.name,
240
+ file: `${projectRoot}releases/`,
241
+ message: t(diagDef.message, { path: `${projectRoot}releases/` }),
242
+ fix: t(diagDef.fix, { version: pkg?.version || '0.1.0' }),
243
+ })
244
+ yield show(
245
+ t(CheckCommand.UI.noReleasesDir, { path: `${projectRoot}releases/` }),
246
+ 'error'
247
+ )
248
+ checkedProjects.push({ name: proj.name, path: proj.path, valid: false })
249
+ continue
250
+ }
75
251
 
76
- if (!existsSync(npmAbs)) {
77
- diff.added.push(relPath)
78
- continue
252
+ yield show(
253
+ t(CheckCommand.UI.releasesDirFound, { count: releaseDirs.length }),
254
+ 'success'
255
+ )
256
+
257
+ // C. Check each release directory
258
+ for (const rel of releaseDirs) {
259
+ const relPath = rel.path
260
+ yield show(t(CheckCommand.UI.releaseHeader, { version: rel.version, path: relPath }))
261
+
262
+ // 1. Check release.md / task.md
263
+ let releaseContent = null
264
+ let releaseDocFile = ''
265
+ for (const docFile of ['release.md', 'task.md', 'plan.md']) {
266
+ try {
267
+ const doc = await db.loadDocument(`${relPath}/${docFile}`, null)
268
+ if (doc) {
269
+ releaseContent = String(doc)
270
+ releaseDocFile = `${relPath}/${docFile}`
271
+ break
272
+ }
273
+ } catch {}
79
274
  }
80
275
 
81
- const localContent = readFileSync(localAbs, 'utf8')
82
- const npmContent = readFileSync(npmAbs, 'utf8')
276
+ if (!releaseContent) {
277
+ const diagDef = CheckCommand.UI.Diagnostics.MISSING_RELEASE_MD
278
+ diagnostics.push({
279
+ level: diagDef.level,
280
+ code: diagDef.code,
281
+ project: proj.name,
282
+ file: `${relPath}/release.md`,
283
+ message: t(diagDef.message, { path: relPath }),
284
+ fix: t(diagDef.fix, { path: relPath }),
285
+ })
286
+ yield show(t(CheckCommand.UI.missingReleaseDoc), 'error')
287
+ } else {
288
+ const tasks = this.#countTasks(releaseContent)
289
+ yield show(
290
+ t(CheckCommand.UI.releaseDocCompleted, {
291
+ file: releaseDocFile.split('/').pop(),
292
+ done: tasks.done,
293
+ total: tasks.total,
294
+ }),
295
+ 'success'
296
+ )
297
+ }
83
298
 
84
- if (localContent !== npmContent) {
85
- diff.modified.push(relPath)
299
+ // 2. Check user.md
300
+ let userContent = null
301
+ try {
302
+ const u = await db.loadDocument(`${relPath}/user.md`, null)
303
+ if (u) userContent = String(u)
304
+ } catch {}
305
+
306
+ if (!userContent) {
307
+ const diagDef = CheckCommand.UI.Diagnostics.MISSING_USER_MD
308
+ diagnostics.push({
309
+ level: diagDef.level,
310
+ code: diagDef.code,
311
+ project: proj.name,
312
+ file: `${relPath}/user.md`,
313
+ message: t(diagDef.message, { path: relPath }),
314
+ fix: t(diagDef.fix, { path: relPath }),
315
+ })
316
+ yield show(t(CheckCommand.UI.missingUserDoc), 'warn')
86
317
  } else {
87
- diff.matched++
318
+ const metrics = DashboardModel.parseUserMetrics(userContent)
319
+ if (metrics.hours === 0 && metrics.rrs === 0) {
320
+ const diagDef = CheckCommand.UI.Diagnostics.INCOMPLETE_USER_METRICS
321
+ diagnostics.push({
322
+ level: diagDef.level,
323
+ code: diagDef.code,
324
+ project: proj.name,
325
+ file: `${relPath}/user.md`,
326
+ message: t(diagDef.message, { path: relPath }),
327
+ fix: t(diagDef.fix, { path: relPath }),
328
+ })
329
+ yield show(t(CheckCommand.UI.incompleteUserMetrics), 'warn')
330
+ } else {
331
+ yield show(
332
+ t(CheckCommand.UI.userMetricsValid, {
333
+ hours: metrics.hours,
334
+ iterations: metrics.iterations,
335
+ rrs: metrics.rrs,
336
+ }),
337
+ 'success'
338
+ )
339
+ }
88
340
  }
89
- }
90
341
 
91
- // Check for deleted files
92
- const npmFiles = await this._scanDirRecursive(npmSourceDir)
93
- for (const relPath of npmFiles) {
94
- if (!localFiles.includes(relPath)) {
95
- diff.deleted.push(relPath)
342
+ // 3. Check contract tests (task.spec.js / task.test.js)
343
+ let hasSpec = false
344
+ let hasTest = false
345
+ try {
346
+ hasSpec = (await db.loadDocument(`${relPath}/task.spec.js`, null)) !== null
347
+ } catch {}
348
+ try {
349
+ hasTest = (await db.loadDocument(`${relPath}/task.test.js`, null)) !== null
350
+ } catch {}
351
+
352
+ if (hasSpec && hasTest) {
353
+ const diagDef = CheckCommand.UI.Diagnostics.AMBIGUOUS_CONTRACTS
354
+ diagnostics.push({
355
+ level: diagDef.level,
356
+ code: diagDef.code,
357
+ project: proj.name,
358
+ file: relPath,
359
+ message: t(diagDef.message, { path: relPath }),
360
+ fix: t(diagDef.fix),
361
+ })
362
+ yield show(t(CheckCommand.UI.ambiguousContracts), 'error')
363
+ } else if (!hasSpec && !hasTest) {
364
+ const diagDef = CheckCommand.UI.Diagnostics.MISSING_CONTRACT
365
+ diagnostics.push({
366
+ level: diagDef.level,
367
+ code: diagDef.code,
368
+ project: proj.name,
369
+ file: `${relPath}/task.spec.js`,
370
+ message: t(diagDef.message, { path: relPath }),
371
+ fix: t(diagDef.fix, { path: relPath }),
372
+ })
373
+ yield show(t(CheckCommand.UI.missingContract), 'error')
374
+ } else if (hasSpec) {
375
+ yield show(t(CheckCommand.UI.activeContractWip), 'success')
376
+ } else if (hasTest) {
377
+ yield show(t(CheckCommand.UI.sealedRegressionClosed), 'success')
96
378
  }
97
379
  }
98
380
 
99
- // 6. Report
100
- if (diff.added.length) {
101
- yield show(t('New files (locally only):'), 'info')
102
- for (const f of diff.added) yield show(` + ${f}`, 'success')
381
+ // D. Check for orphan files inside releases/ via db
382
+ const orphanFiles = await this.#findOrphanReleaseFiles(db, projectRoot)
383
+ if (orphanFiles.length > 0) {
384
+ const diagDef = CheckCommand.UI.Diagnostics.ORPHAN_RELEASE_FILE
385
+ for (const orphan of orphanFiles) {
386
+ diagnostics.push({
387
+ level: diagDef.level,
388
+ code: diagDef.code,
389
+ project: proj.name,
390
+ file: `${projectRoot}releases/${orphan}`,
391
+ message: t(diagDef.message, { file: orphan }),
392
+ fix: t(diagDef.fix, { file: orphan }),
393
+ })
394
+ yield show(t(CheckCommand.UI.orphanFile, { file: orphan }), 'warn')
395
+ }
103
396
  }
104
397
 
105
- if (diff.modified.length) {
106
- yield show(t('Modified files (different from NPM):'), 'info')
107
- for (const f of diff.modified) yield show(` * ${f}`, 'warn')
108
- }
398
+ checkedProjects.push({
399
+ name: proj.name,
400
+ path: proj.path,
401
+ releases: releaseDirs.map((r) => r.version),
402
+ })
403
+ }
109
404
 
110
- if (diff.deleted.length) {
111
- yield show(t('Deleted files (missing locally):'), 'info')
112
- for (const f of diff.deleted) yield show(` - ${f}`, 'error')
405
+ // Clear active progress spinner before rendering diagnostic summary
406
+ yield progress('')
407
+
408
+ // 3. Print Actionable Diagnostic Fix Summary
409
+ if (diagnostics.length > 0) {
410
+ yield show(t(CheckCommand.UI.recommendationsHeader, { count: diagnostics.length }))
411
+ let idx = 1
412
+ for (const diag of diagnostics) {
413
+ const badge = diag.level === 'error' ? '❌ ERROR' : '⚠️ WARN'
414
+ yield show(
415
+ t(CheckCommand.UI.recommendationItem, {
416
+ idx: idx++,
417
+ badge,
418
+ code: diag.code,
419
+ message: diag.message,
420
+ file: diag.file,
421
+ fix: diag.fix,
422
+ })
423
+ )
113
424
  }
425
+ }
114
426
 
115
- if (!diff.added.length && !diff.modified.length && !diff.deleted.length) {
116
- yield show(t('✅ Integrity check passed! All {count} files match NPM.', { count: diff.matched }), 'success')
117
- } else {
118
- yield show(t('Summary: {matched} matched, {mod} modified, {add} added, {del} deleted.', {
119
- matched: diff.matched,
120
- mod: diff.modified.length,
121
- add: diff.added.length,
122
- del: diff.deleted.length
123
- }), 'info')
124
- }
427
+ const errorCount = diagnostics.filter((d) => d.level === 'error').length
428
+ const warnCount = diagnostics.filter((d) => d.level === 'warning').length
429
+ const isSuccess = errorCount === 0
430
+
431
+ yield show(
432
+ t(CheckCommand.UI.summary, {
433
+ projects: checkedProjects.length,
434
+ passed: isSuccess ? 'YES' : 'NO',
435
+ errors: errorCount,
436
+ warnings: warnCount,
437
+ }),
438
+ isSuccess ? 'success' : 'warn'
439
+ )
440
+
441
+ return result({
442
+ success: isSuccess,
443
+ diagnostics,
444
+ checkedProjects,
445
+ })
446
+ }
125
447
 
126
- } catch (/** @type {any} */ err) {
127
- yield show(err.message, 'error')
128
- return result({ success: false, error: err.message })
129
- } finally {
130
- if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
448
+ /**
449
+ * Count tasks in markdown content
450
+ * @param {string} content
451
+ * @returns {{ done: number, total: number }}
452
+ */
453
+ #countTasks(content = '') {
454
+ let total = 0
455
+ let done = 0
456
+ for (const line of content.split('\n')) {
457
+ const m = line.match(/^[-*+]?\s*(?:\[([ xX])\]|\d+\.\s*\[([ xX])\])\s*(.+)$/)
458
+ if (m) {
459
+ total++
460
+ if ((m[1] || m[2] || '').toLowerCase() === 'x') done++
461
+ }
131
462
  }
132
-
133
- return result({ success: true })
463
+ return { done, total }
134
464
  }
135
465
 
136
466
  /**
137
- * @param {string} absDir
138
- * @returns {Promise<string[]>}
467
+ * Discover release version directories using database
468
+ * @param {any} db
469
+ * @param {string} projectRoot
470
+ * @returns {Promise<Array<{ version: string, path: string }>>}
139
471
  */
140
- async _scanLocal(absDir) {
141
- const { readdirSync, statSync } = await import('node:fs')
142
- const { join, relative } = await import('node:path')
143
-
144
- const files = []
145
- const ignore = ['.git', 'node_modules', 'releases', 'dist', 'play', '.nan0web_check_tmp']
146
-
147
- const scan = (dir) => {
148
- const list = readdirSync(dir)
149
- for (const name of list) {
150
- if (ignore.includes(name)) continue
151
- if (name.startsWith('.')) continue
152
-
153
- const abs = join(dir, name)
154
- const stat = statSync(abs)
155
- if (stat.isDirectory()) {
156
- scan(abs)
157
- } else {
158
- files.push(relative(absDir, abs))
472
+ async #discoverReleases(db, projectRoot) {
473
+ const results = []
474
+ const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
475
+
476
+ try {
477
+ if (typeof db.listDir === 'function') {
478
+ const queue = [{ uri: releasesUri, depth: 0 }]
479
+ while (queue.length > 0) {
480
+ const item = queue.shift()
481
+ if (!item || item.depth > 3) continue
482
+ let entries = []
483
+ try {
484
+ entries = await db.listDir(item.uri)
485
+ } catch {
486
+ continue
487
+ }
488
+ if (!Array.isArray(entries)) continue
489
+
490
+ for (const entry of entries) {
491
+ const rawName = entry.name || (entry.path || '').split('/').pop() || ''
492
+ const name = rawName.replace(/\/$/, '')
493
+ const isDir = Boolean(
494
+ entry.stat?.isDirectory ||
495
+ entry.isDirectory ||
496
+ entry.isDir ||
497
+ rawName.endsWith('/')
498
+ )
499
+
500
+ const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
501
+ if (dirMatch) {
502
+ const version = dirMatch[1]
503
+ const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
504
+ const normalizedPath = entryPath.replace(/^\.\//, '')
505
+ if (!results.find((r) => r.path === normalizedPath)) {
506
+ results.push({ version, path: normalizedPath })
507
+ }
508
+ } else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
509
+ const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
510
+ queue.push({ uri: nextUri, depth: item.depth + 1 })
511
+ }
512
+ }
159
513
  }
160
514
  }
161
- }
515
+ } catch {}
162
516
 
163
- scan(absDir)
164
- return files
517
+ return results
165
518
  }
166
519
 
167
-
168
520
  /**
169
- * @param {string} base
170
- * @param {string} [dir]
521
+ * Find orphan files inside releases/ directory using db
522
+ * @param {any} db
523
+ * @param {string} projectRoot
171
524
  * @returns {Promise<string[]>}
172
525
  */
173
- async _scanDirRecursive(base, dir = '') {
174
- const { readdirSync, statSync } = await import('node:fs')
175
- const { join } = await import('node:path')
176
-
177
- let results = []
178
- const absDir = join(base, dir)
179
- const list = readdirSync(absDir)
180
-
181
- for (const file of list) {
182
- const rel = join(dir, file)
183
- const abs = join(base, rel)
184
- const stat = statSync(abs)
185
- if (stat.isDirectory()) {
186
- results = results.concat(await this._scanDirRecursive(base, rel))
187
- } else {
188
- results.push(rel)
526
+ async #findOrphanReleaseFiles(db, projectRoot) {
527
+ const orphans = []
528
+ const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
529
+
530
+ try {
531
+ if (typeof db.listDir === 'function') {
532
+ const entries = await db.listDir(releasesUri, { depth: 0 })
533
+ for (const entry of entries) {
534
+ const isFile = !entry.isDirectory && (entry.isFile || !entry.isDir)
535
+ const name = entry.name || (entry.path || '').split('/').pop() || ''
536
+ if (
537
+ isFile &&
538
+ !['index.js', 'current.js', 'README.md', 'backlog.md'].includes(name) &&
539
+ !name.startsWith('.')
540
+ ) {
541
+ orphans.push(name)
542
+ }
543
+ }
189
544
  }
190
- }
191
- return results
545
+ } catch {}
546
+
547
+ return orphans
192
548
  }
193
549
  }
550
+
551
+ export default CheckCommand