@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.
- package/bin/nan0release.js +33 -2
- package/bin/serve.js +73 -0
- package/package.json +45 -17
- package/src/Release/Document.js +1 -0
- package/src/Release/Scanner.js +3 -2
- package/src/Release.js +7 -7
- package/src/domain/App.js +31 -2
- package/src/domain/BumpCommand.js +10 -3
- package/src/domain/CheckCommand.js +504 -146
- package/src/domain/CheckCommand.story.js +92 -0
- package/src/domain/CloseCommand.js +13 -9
- package/src/domain/Dashboard/Dashboard.story.js +155 -0
- package/src/domain/Dashboard/DashboardModel.js +308 -0
- package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
- package/src/domain/Dashboard/ReleaseParser.js +226 -0
- package/src/domain/Dashboard/index.js +19 -0
- package/src/domain/DepsCommand.js +5 -2
- package/src/domain/InitCommand.js +18 -16
- package/src/domain/ListCommand.js +45 -0
- package/src/domain/PreviewCommand.js +39 -0
- package/src/domain/PublishCommand.js +7 -3
- package/src/domain/Registry/Registry.story.js +82 -0
- package/src/domain/Registry/RegistryModel.js +124 -0
- package/src/domain/ServeCommand.js +165 -0
- package/src/domain/ServeCommand.story.js +88 -0
- package/src/domain/SpecCommand.js +3 -1
- package/src/domain/StatusCommand.js +249 -3
- package/src/domain/ViewCommand.js +99 -0
- package/src/domain/WebCommand.js +140 -0
- package/src/domain/inspect/ReleaseAuditor.js +508 -0
- package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
- package/src/domain/templates/release.html +34 -0
- package/src/domain/templates/release.html.template.js +34 -0
- package/src/index.js +6 -0
- package/src/inspectors/inspect-js.js +32 -0
- package/src/releases/1/0/v1.0.3/task.test.js +1 -1
- package/src/utils/nodejs/GitTelemetry.js +154 -0
- package/types/Release/Document.d.ts +4 -0
- package/types/Release/Scanner.d.ts +2 -2
- package/types/domain/App.d.ts +19 -3
- package/types/domain/BumpCommand.d.ts +9 -3
- package/types/domain/CheckCommand.d.ts +127 -15
- package/types/domain/CheckCommand.story.d.ts +1 -0
- package/types/domain/CloseCommand.d.ts +2 -1
- package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
- package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
- package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
- package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
- package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
- package/types/domain/Dashboard/index.d.ts +3 -0
- package/types/domain/DepsCommand.d.ts +4 -2
- package/types/domain/InitCommand.d.ts +2 -1
- package/types/domain/ListCommand.d.ts +18 -0
- package/types/domain/PreviewCommand.d.ts +6 -0
- package/types/domain/PublishCommand.d.ts +6 -3
- package/types/domain/Registry/Registry.story.d.ts +1 -0
- package/types/domain/Registry/RegistryModel.d.ts +61 -0
- package/types/domain/ServeCommand.d.ts +59 -0
- package/types/domain/ServeCommand.story.d.ts +1 -0
- package/types/domain/SpecCommand.d.ts +2 -1
- package/types/domain/StatusCommand.d.ts +39 -0
- package/types/domain/ViewCommand.d.ts +27 -0
- package/types/domain/WebCommand.d.ts +47 -0
- package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
- package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
- package/types/domain/templates/release.html.template.d.ts +2 -0
- package/types/index.d.ts +5 -0
- package/types/inspectors/inspect-js.d.ts +2 -0
- package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { AuditorModel } from '@nan0web/inspect/domain/AuditorModel'
|
|
2
|
+
import { show, progress, result } from '@nan0web/ui'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} ReleaseDiagnostic
|
|
6
|
+
* @property {'error' | 'warning' | 'info'} level
|
|
7
|
+
* @property {string} code
|
|
8
|
+
* @property {string} [check]
|
|
9
|
+
* @property {string} [file]
|
|
10
|
+
* @property {string} error
|
|
11
|
+
* @property {string} [suggestion]
|
|
12
|
+
* @property {string} [fix]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ReleaseAuditor — Architecture auditor and validator for PM-as-Code release structures.
|
|
17
|
+
*/
|
|
18
|
+
export class ReleaseAuditor extends AuditorModel {
|
|
19
|
+
static alias = 'release'
|
|
20
|
+
|
|
21
|
+
static changed = {
|
|
22
|
+
help: 'Only inspect modified workspaces/releases',
|
|
23
|
+
default: false,
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
alias: 'c',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static all = {
|
|
29
|
+
help: 'Audit all registered projects in releases.txt',
|
|
30
|
+
default: false,
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
alias: 'a',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static registryFile = {
|
|
36
|
+
help: 'Path to releases.txt registry file',
|
|
37
|
+
default: 'releases.txt',
|
|
38
|
+
type: 'string',
|
|
39
|
+
alias: 'f',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static UI = {
|
|
43
|
+
title: 'PM-as-Code Release Structure Auditor',
|
|
44
|
+
header: '🔍 Running Release Quality & Compliance Audit for {$dir}...',
|
|
45
|
+
skipped: 'Release audit skipped (capped by active session)',
|
|
46
|
+
missingPackageJson: 'Missing package.json in {$dir}',
|
|
47
|
+
noReleasesDir: 'No release directories found under {$dir}',
|
|
48
|
+
releasesFound: 'Discovered {$count} release(s) in {$dir}',
|
|
49
|
+
releaseHeader: 'Auditing Release [v{$version}] at {$path}',
|
|
50
|
+
releaseDocCompleted: 'Release document valid: {$file} ({$done}/{$total} tasks completed)',
|
|
51
|
+
missingReleaseDoc: 'Missing release.md, task.md, or plan.md in {$path}',
|
|
52
|
+
missingUserDoc: 'Missing user.md metrics document in {$path}',
|
|
53
|
+
incompleteUserMetrics: 'user.md in {$path} is missing Hours, Iterations, or RRS score',
|
|
54
|
+
userMetricsValid: 'user.md metrics valid (Hours: {$hours}h, Iterations: {$iterations}, RRS: {$rrs})',
|
|
55
|
+
ambiguousContracts: 'Both task.spec.js (WIP) and task.test.js (Closed) exist simultaneously in {$path}',
|
|
56
|
+
missingContract: 'Missing contract test in {$path} (task.spec.js or task.test.js required)',
|
|
57
|
+
activeContractWip: 'Active contract present: task.spec.js [WIP]',
|
|
58
|
+
sealedRegressionClosed: 'Sealed regression test present: task.test.js [CLOSED]',
|
|
59
|
+
orphanFile: 'Orphan file found in releases/ root: {$file}',
|
|
60
|
+
summary: 'Release Audit Complete: {$releases} release(s) checked | Status: {$status} | Errors: {$errors} | Warnings: {$warnings}',
|
|
61
|
+
Diagnostics: {
|
|
62
|
+
MISSING_PACKAGE_JSON: {
|
|
63
|
+
level: 'error',
|
|
64
|
+
code: 'MISSING_PACKAGE_JSON',
|
|
65
|
+
message: 'Missing package.json in {$dir}',
|
|
66
|
+
fix: 'Ensure package.json exists with valid name and version.',
|
|
67
|
+
},
|
|
68
|
+
MISSING_RELEASES_DIR: {
|
|
69
|
+
level: 'error',
|
|
70
|
+
code: 'MISSING_RELEASES_DIR',
|
|
71
|
+
message: 'No release directories found under {$dir}',
|
|
72
|
+
fix: 'Initialize release directory: pnpm exec release init v0.1.0',
|
|
73
|
+
},
|
|
74
|
+
MISSING_RELEASE_MD: {
|
|
75
|
+
level: 'error',
|
|
76
|
+
code: 'MISSING_RELEASE_MD',
|
|
77
|
+
message: 'Missing release.md or task.md in {$path}',
|
|
78
|
+
fix: 'Create {$path}/release.md with release scope, user stories, and acceptance checklist.',
|
|
79
|
+
},
|
|
80
|
+
MISSING_USER_MD: {
|
|
81
|
+
level: 'warning',
|
|
82
|
+
code: 'MISSING_USER_MD',
|
|
83
|
+
message: 'Missing user.md metrics document in {$path}',
|
|
84
|
+
fix: 'Create {$path}/user.md containing Execution Metrics (Hours, Iterations, and RRS score).',
|
|
85
|
+
},
|
|
86
|
+
INCOMPLETE_USER_METRICS: {
|
|
87
|
+
level: 'warning',
|
|
88
|
+
code: 'INCOMPLETE_USER_METRICS',
|
|
89
|
+
message: 'user.md in {$path} is missing hours, iterations or RRS score',
|
|
90
|
+
fix: 'Update {$path}/user.md with sections: "Витрачений час розробки (Годин):", "Кількість ітерацій:", "Фінальний бал RRS:".',
|
|
91
|
+
},
|
|
92
|
+
AMBIGUOUS_CONTRACTS: {
|
|
93
|
+
level: 'error',
|
|
94
|
+
code: 'AMBIGUOUS_CONTRACTS',
|
|
95
|
+
message: 'Both task.spec.js and task.test.js exist simultaneously in {$path}',
|
|
96
|
+
fix: 'If release is still active, keep task.spec.js. If release is sealed/closed, keep task.test.js.',
|
|
97
|
+
},
|
|
98
|
+
MISSING_CONTRACT: {
|
|
99
|
+
level: 'error',
|
|
100
|
+
code: 'MISSING_CONTRACT',
|
|
101
|
+
message: 'No contract test found in {$path} (missing task.spec.js or task.test.js)',
|
|
102
|
+
fix: 'Create contract test {$path}/task.spec.js for TDD verification.',
|
|
103
|
+
},
|
|
104
|
+
ORPHAN_RELEASE_FILE: {
|
|
105
|
+
level: 'warning',
|
|
106
|
+
code: 'ORPHAN_RELEASE_FILE',
|
|
107
|
+
message: 'Orphan file "{$file}" located directly in releases/ directory outside of version folders',
|
|
108
|
+
fix: 'Move "{$file}" into the appropriate version directory (e.g. releases/vX.Y.Z/) or backlog.md.',
|
|
109
|
+
},
|
|
110
|
+
PROCEDURAL_RELEASE_ARTIFACT: {
|
|
111
|
+
level: 'warning',
|
|
112
|
+
code: 'PROCEDURAL_RELEASE_ARTIFACT',
|
|
113
|
+
message: 'Procedural artifact "{$file}" found in release {$path}. Releases must be pure Data-Driven.',
|
|
114
|
+
fix: 'Remove "{$file}". Use data-driven *.nan0 documents and multimodal runners (serve releases/) instead of ad-hoc HTML/JS.',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {Partial<ReleaseAuditor> | Record<string, any>} [data={}]
|
|
121
|
+
* @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options={}]
|
|
122
|
+
*/
|
|
123
|
+
constructor(data = {}, options = {}) {
|
|
124
|
+
super(data, options)
|
|
125
|
+
/** @type {boolean} */ this.changed = Boolean(data.changed || (options && 'changed' in options ? /** @type {any} */ (options).changed : false))
|
|
126
|
+
/** @type {boolean} */ this.all = Boolean(data.all || (options && 'all' in options ? /** @type {any} */ (options).all : false))
|
|
127
|
+
/** @type {string} */ this.registryFile = String(data.registryFile || 'releases.txt')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Runs the PM-as-Code release audit.
|
|
132
|
+
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
|
|
133
|
+
*/
|
|
134
|
+
async *run() {
|
|
135
|
+
const db = this._.db
|
|
136
|
+
/** @type {import('@nan0web/i18n').TFunction} */
|
|
137
|
+
const t = this._.t || ((key, params) => {
|
|
138
|
+
let str = String(key || '')
|
|
139
|
+
if (params) {
|
|
140
|
+
for (const [k, v] of Object.entries(params)) {
|
|
141
|
+
str = str.replaceAll(`{$${k}}`, String(v)).replaceAll(`{${k}}`, String(v))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return str
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (await this.isCapped()) {
|
|
148
|
+
yield progress(t(ReleaseAuditor.UI.skipped))
|
|
149
|
+
return result({
|
|
150
|
+
success: true,
|
|
151
|
+
skipped: true,
|
|
152
|
+
errors: [],
|
|
153
|
+
warnings: [],
|
|
154
|
+
checkedReleases: [],
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
yield progress(t(ReleaseAuditor.UI.header, { dir: this.dir }))
|
|
159
|
+
|
|
160
|
+
/** @type {ReleaseDiagnostic[]} */
|
|
161
|
+
const errors = []
|
|
162
|
+
/** @type {ReleaseDiagnostic[]} */
|
|
163
|
+
const warnings = []
|
|
164
|
+
const checkedReleases = []
|
|
165
|
+
|
|
166
|
+
const projectRoot = this.dir === '.' || !this.dir ? '' : `${this.dir.replace(/\/+$/, '')}/`
|
|
167
|
+
|
|
168
|
+
// 1. Check package.json if auditing specific directory
|
|
169
|
+
if (projectRoot) {
|
|
170
|
+
const hasPkg = await this.fileExists('package.json')
|
|
171
|
+
if (!hasPkg) {
|
|
172
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_PACKAGE_JSON
|
|
173
|
+
const err = {
|
|
174
|
+
level: /** @type {'error'} */ ('error'),
|
|
175
|
+
code: diagDef.code,
|
|
176
|
+
check: 'package.json',
|
|
177
|
+
file: `${projectRoot}package.json`,
|
|
178
|
+
error: t(diagDef.message, { dir: this.dir }),
|
|
179
|
+
fix: t(diagDef.fix, { dir: this.dir }),
|
|
180
|
+
}
|
|
181
|
+
errors.push(err)
|
|
182
|
+
yield show(err.error, 'error')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Discover release directories
|
|
187
|
+
const releaseDirs = await this.#discoverReleases(db, projectRoot)
|
|
188
|
+
|
|
189
|
+
if (releaseDirs.length === 0) {
|
|
190
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_RELEASES_DIR
|
|
191
|
+
const err = {
|
|
192
|
+
level: /** @type {'error'} */ ('error'),
|
|
193
|
+
code: diagDef.code,
|
|
194
|
+
check: 'releases/',
|
|
195
|
+
file: `${projectRoot}releases/`,
|
|
196
|
+
error: t(diagDef.message, { dir: this.dir }),
|
|
197
|
+
fix: t(diagDef.fix, { dir: this.dir }),
|
|
198
|
+
}
|
|
199
|
+
errors.push(err)
|
|
200
|
+
yield show(err.error, 'error')
|
|
201
|
+
} else {
|
|
202
|
+
yield show(t(ReleaseAuditor.UI.releasesFound, { count: releaseDirs.length, dir: this.dir }), 'info')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 3. Check each release directory
|
|
206
|
+
for (const rel of releaseDirs) {
|
|
207
|
+
const relPath = rel.path
|
|
208
|
+
yield show(t(ReleaseAuditor.UI.releaseHeader, { version: rel.version, path: relPath }))
|
|
209
|
+
|
|
210
|
+
// A. Check release.md / task.md / plan.md
|
|
211
|
+
let releaseContent = null
|
|
212
|
+
let releaseDocFile = ''
|
|
213
|
+
for (const docFile of ['release.md', 'task.md', 'plan.md']) {
|
|
214
|
+
try {
|
|
215
|
+
const doc = await db.loadDocument(`${relPath}/${docFile}`, null)
|
|
216
|
+
if (doc) {
|
|
217
|
+
releaseContent = String(doc)
|
|
218
|
+
releaseDocFile = `${relPath}/${docFile}`
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!releaseContent) {
|
|
225
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_RELEASE_MD
|
|
226
|
+
const err = {
|
|
227
|
+
level: /** @type {'error'} */ ('error'),
|
|
228
|
+
code: diagDef.code,
|
|
229
|
+
check: 'release.md',
|
|
230
|
+
file: `${relPath}/release.md`,
|
|
231
|
+
error: t(diagDef.message, { path: relPath }),
|
|
232
|
+
fix: t(diagDef.fix, { path: relPath }),
|
|
233
|
+
}
|
|
234
|
+
errors.push(err)
|
|
235
|
+
yield show(err.error, 'error')
|
|
236
|
+
} else {
|
|
237
|
+
const { done, total } = this.#countTasks(releaseContent)
|
|
238
|
+
yield show(
|
|
239
|
+
t(ReleaseAuditor.UI.releaseDocCompleted, {
|
|
240
|
+
file: releaseDocFile.split('/').pop(),
|
|
241
|
+
done,
|
|
242
|
+
total,
|
|
243
|
+
}),
|
|
244
|
+
'success'
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// B. Check user.md metrics
|
|
249
|
+
let userDoc = null
|
|
250
|
+
try {
|
|
251
|
+
userDoc = await db.loadDocument(`${relPath}/user.md`, null)
|
|
252
|
+
} catch {}
|
|
253
|
+
|
|
254
|
+
if (!userDoc) {
|
|
255
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_USER_MD
|
|
256
|
+
const warn = {
|
|
257
|
+
level: /** @type {'warning'} */ ('warning'),
|
|
258
|
+
code: diagDef.code,
|
|
259
|
+
check: 'user.md',
|
|
260
|
+
file: `${relPath}/user.md`,
|
|
261
|
+
error: t(diagDef.message, { path: relPath }),
|
|
262
|
+
fix: t(diagDef.fix, { path: relPath }),
|
|
263
|
+
}
|
|
264
|
+
warnings.push(warn)
|
|
265
|
+
yield show(warn.error, 'warn')
|
|
266
|
+
} else {
|
|
267
|
+
const userStr = typeof userDoc === 'object' ? JSON.stringify(userDoc) : String(userDoc)
|
|
268
|
+
const hasHours =
|
|
269
|
+
/витрачений час/i.test(userStr) ||
|
|
270
|
+
/hours/i.test(userStr) ||
|
|
271
|
+
userDoc.hours !== undefined ||
|
|
272
|
+
userDoc.time !== undefined
|
|
273
|
+
const hasIters =
|
|
274
|
+
/кількість ітерацій/i.test(userStr) ||
|
|
275
|
+
/iterations/i.test(userStr) ||
|
|
276
|
+
userDoc.iterations !== undefined
|
|
277
|
+
const hasRrs =
|
|
278
|
+
/rrs/i.test(userStr) ||
|
|
279
|
+
/score/i.test(userStr) ||
|
|
280
|
+
userDoc.rrs !== undefined
|
|
281
|
+
|
|
282
|
+
if (!hasHours || !hasIters || !hasRrs) {
|
|
283
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.INCOMPLETE_USER_METRICS
|
|
284
|
+
const warn = {
|
|
285
|
+
level: /** @type {'warning'} */ ('warning'),
|
|
286
|
+
code: diagDef.code,
|
|
287
|
+
check: 'user.md',
|
|
288
|
+
file: `${relPath}/user.md`,
|
|
289
|
+
error: t(diagDef.message, { path: relPath }),
|
|
290
|
+
fix: t(diagDef.fix, { path: relPath }),
|
|
291
|
+
}
|
|
292
|
+
warnings.push(warn)
|
|
293
|
+
yield show(warn.error, 'warn')
|
|
294
|
+
} else {
|
|
295
|
+
yield show(t(ReleaseAuditor.UI.userMetricsValid, { hours: 'ok', iterations: 'ok', rrs: 'ok' }), 'success')
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// C. Check contract tests (task.spec.js / task.test.js)
|
|
300
|
+
let hasSpec = false
|
|
301
|
+
let hasTest = false
|
|
302
|
+
try {
|
|
303
|
+
const spec = await db.loadDocument(`${relPath}/task.spec.js`, null)
|
|
304
|
+
if (spec) hasSpec = true
|
|
305
|
+
} catch {}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const test = await db.loadDocument(`${relPath}/task.test.js`, null)
|
|
309
|
+
if (test) hasTest = true
|
|
310
|
+
} catch {}
|
|
311
|
+
|
|
312
|
+
if (hasSpec && hasTest) {
|
|
313
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.AMBIGUOUS_CONTRACTS
|
|
314
|
+
const err = {
|
|
315
|
+
level: /** @type {'error'} */ ('error'),
|
|
316
|
+
code: diagDef.code,
|
|
317
|
+
check: 'task.spec.js',
|
|
318
|
+
file: `${relPath}/task.spec.js`,
|
|
319
|
+
error: t(diagDef.message, { path: relPath }),
|
|
320
|
+
fix: t(diagDef.fix, { path: relPath }),
|
|
321
|
+
}
|
|
322
|
+
errors.push(err)
|
|
323
|
+
yield show(err.error, 'error')
|
|
324
|
+
} else if (!hasSpec && !hasTest) {
|
|
325
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.MISSING_CONTRACT
|
|
326
|
+
const err = {
|
|
327
|
+
level: /** @type {'error'} */ ('error'),
|
|
328
|
+
code: diagDef.code,
|
|
329
|
+
check: 'task.spec.js',
|
|
330
|
+
file: `${relPath}/task.spec.js`,
|
|
331
|
+
error: t(diagDef.message, { path: relPath }),
|
|
332
|
+
fix: t(diagDef.fix, { path: relPath }),
|
|
333
|
+
}
|
|
334
|
+
errors.push(err)
|
|
335
|
+
yield show(err.error, 'error')
|
|
336
|
+
} else if (hasSpec) {
|
|
337
|
+
yield show(t(ReleaseAuditor.UI.activeContractWip), 'success')
|
|
338
|
+
} else if (hasTest) {
|
|
339
|
+
yield show(t(ReleaseAuditor.UI.sealedRegressionClosed), 'success')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// D. Check for prohibited procedural artifacts (index.html, play.js)
|
|
343
|
+
for (const procName of ['index.html', 'play.js']) {
|
|
344
|
+
try {
|
|
345
|
+
const hasProc = await db.loadDocument(`${relPath}/${procName}`, null)
|
|
346
|
+
if (hasProc) {
|
|
347
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.PROCEDURAL_RELEASE_ARTIFACT
|
|
348
|
+
const warn = {
|
|
349
|
+
level: /** @type {'warning'} */ ('warning'),
|
|
350
|
+
code: diagDef.code,
|
|
351
|
+
check: procName,
|
|
352
|
+
file: `${relPath}/${procName}`,
|
|
353
|
+
error: t(diagDef.message, { file: procName, path: relPath }),
|
|
354
|
+
fix: t(diagDef.fix, { file: procName, path: relPath }),
|
|
355
|
+
}
|
|
356
|
+
warnings.push(warn)
|
|
357
|
+
yield show(warn.error, 'warn')
|
|
358
|
+
}
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
checkedReleases.push({
|
|
363
|
+
version: rel.version,
|
|
364
|
+
path: rel.path,
|
|
365
|
+
valid: errors.filter((e) => e.file?.startsWith(relPath)).length === 0,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 4. Check for orphan files in releases/ root
|
|
370
|
+
const orphanFiles = await this.#findOrphanReleaseFiles(db, projectRoot)
|
|
371
|
+
for (const orphan of orphanFiles) {
|
|
372
|
+
const diagDef = ReleaseAuditor.UI.Diagnostics.ORPHAN_RELEASE_FILE
|
|
373
|
+
const warn = {
|
|
374
|
+
level: /** @type {'warning'} */ ('warning'),
|
|
375
|
+
code: diagDef.code,
|
|
376
|
+
check: orphan,
|
|
377
|
+
file: `${projectRoot}releases/${orphan}`,
|
|
378
|
+
error: t(diagDef.message, { file: orphan }),
|
|
379
|
+
fix: t(diagDef.fix, { file: orphan }),
|
|
380
|
+
}
|
|
381
|
+
warnings.push(warn)
|
|
382
|
+
yield show(warn.error, 'warn')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const isSuccess = errors.length === 0
|
|
386
|
+
yield show(
|
|
387
|
+
t(ReleaseAuditor.UI.summary, {
|
|
388
|
+
releases: checkedReleases.length,
|
|
389
|
+
status: isSuccess ? 'PASSED' : 'FAILED',
|
|
390
|
+
errors: errors.length,
|
|
391
|
+
warnings: warnings.length,
|
|
392
|
+
}),
|
|
393
|
+
isSuccess ? 'success' : 'warn'
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return result({
|
|
397
|
+
success: isSuccess,
|
|
398
|
+
errors,
|
|
399
|
+
warnings,
|
|
400
|
+
checkedReleases,
|
|
401
|
+
diagnostics: [...errors, ...warnings],
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Count tasks in markdown content
|
|
407
|
+
* @param {string} content
|
|
408
|
+
* @returns {{ done: number, total: number }}
|
|
409
|
+
*/
|
|
410
|
+
#countTasks(content = '') {
|
|
411
|
+
let total = 0
|
|
412
|
+
let done = 0
|
|
413
|
+
for (const line of content.split('\n')) {
|
|
414
|
+
const m = line.match(/^[-*+]?\s*(?:\[([ xX])\]|\d+\.\s*\[([ xX])\])\s*(.+)$/)
|
|
415
|
+
if (m) {
|
|
416
|
+
total++
|
|
417
|
+
if ((m[1] || m[2] || '').toLowerCase() === 'x') done++
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { done, total }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Discover release version directories using database
|
|
425
|
+
* @param {any} db
|
|
426
|
+
* @param {string} projectRoot
|
|
427
|
+
* @returns {Promise<Array<{ version: string, path: string }>>}
|
|
428
|
+
*/
|
|
429
|
+
async #discoverReleases(db, projectRoot) {
|
|
430
|
+
const results = []
|
|
431
|
+
const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
if (typeof db.listDir === 'function') {
|
|
435
|
+
const queue = [{ uri: releasesUri, depth: 0 }]
|
|
436
|
+
while (queue.length > 0) {
|
|
437
|
+
const item = queue.shift()
|
|
438
|
+
if (!item || item.depth > 3) continue
|
|
439
|
+
let entries = []
|
|
440
|
+
try {
|
|
441
|
+
entries = await db.listDir(item.uri)
|
|
442
|
+
} catch {
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
if (!Array.isArray(entries)) continue
|
|
446
|
+
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
const rawName = entry.name || (entry.path || '').split('/').pop() || ''
|
|
449
|
+
const name = rawName.replace(/\/$/, '')
|
|
450
|
+
const isDir = Boolean(
|
|
451
|
+
entry.stat?.isDirectory ||
|
|
452
|
+
entry.isDirectory ||
|
|
453
|
+
entry.isDir ||
|
|
454
|
+
rawName.endsWith('/')
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
|
|
458
|
+
if (dirMatch) {
|
|
459
|
+
const version = dirMatch[1]
|
|
460
|
+
const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
|
|
461
|
+
const normalizedPath = entryPath.replace(/^\.\//, '')
|
|
462
|
+
if (!results.find((r) => r.path === normalizedPath)) {
|
|
463
|
+
results.push({ version, path: normalizedPath })
|
|
464
|
+
}
|
|
465
|
+
} else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
|
|
466
|
+
const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
|
|
467
|
+
queue.push({ uri: nextUri, depth: item.depth + 1 })
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch {}
|
|
473
|
+
|
|
474
|
+
return results
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Find orphan files inside releases/ directory using db
|
|
479
|
+
* @param {any} db
|
|
480
|
+
* @param {string} projectRoot
|
|
481
|
+
* @returns {Promise<string[]>}
|
|
482
|
+
*/
|
|
483
|
+
async #findOrphanReleaseFiles(db, projectRoot) {
|
|
484
|
+
const orphans = []
|
|
485
|
+
const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
if (typeof db.listDir === 'function') {
|
|
489
|
+
const entries = await db.listDir(releasesUri, { depth: 0 })
|
|
490
|
+
for (const entry of entries) {
|
|
491
|
+
const isFile = !entry.isDirectory && (entry.isFile || !entry.isDir)
|
|
492
|
+
const name = entry.name || (entry.path || '').split('/').pop() || ''
|
|
493
|
+
if (
|
|
494
|
+
isFile &&
|
|
495
|
+
!['index.js', 'current.js', 'README.md', 'backlog.md'].includes(name) &&
|
|
496
|
+
!name.startsWith('.')
|
|
497
|
+
) {
|
|
498
|
+
orphans.push(name)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch {}
|
|
503
|
+
|
|
504
|
+
return orphans
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default ReleaseAuditor
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import DB from '@nan0web/db'
|
|
4
|
+
import { ReleaseAuditor } from './ReleaseAuditor.js'
|
|
5
|
+
import { AuditorModel } from '@nan0web/inspect'
|
|
6
|
+
|
|
7
|
+
describe('ReleaseAuditor OLMUI Scenario & Architecture Auditor', () => {
|
|
8
|
+
it('inherits from AuditorModel and has release alias', () => {
|
|
9
|
+
assert.equal(ReleaseAuditor.alias, 'release')
|
|
10
|
+
assert.ok(Object.prototype.isPrototypeOf.call(AuditorModel, ReleaseAuditor))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('successfully audits a valid release structure without diagnostics errors', async () => {
|
|
14
|
+
const pkgJson = { name: '@nan0web/sample-pkg', version: '1.0.0' }
|
|
15
|
+
const releaseMd = `# v1.0.0 - 2026-09-01
|
|
16
|
+
## Tasks
|
|
17
|
+
- [x] Done task
|
|
18
|
+
`
|
|
19
|
+
const userMd = `---
|
|
20
|
+
name: user-feedback
|
|
21
|
+
version: 1.0.0
|
|
22
|
+
---
|
|
23
|
+
## ⏱ 1. Метрики Виконання
|
|
24
|
+
- **Витрачений час розробки (Годин):** \`4.5h\`
|
|
25
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`10\`
|
|
26
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`324\`
|
|
27
|
+
`
|
|
28
|
+
const taskSpec = `import { describe, it } from 'node:test'
|
|
29
|
+
import assert from 'node:assert/strict'
|
|
30
|
+
describe('contract', () => {
|
|
31
|
+
it('passes', () => assert.ok(true))
|
|
32
|
+
})
|
|
33
|
+
`
|
|
34
|
+
const db = new DB({
|
|
35
|
+
predefined: [
|
|
36
|
+
['packages/sample-pkg/package.json', pkgJson],
|
|
37
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/release.md', releaseMd],
|
|
38
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/user.md', userMd],
|
|
39
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', taskSpec],
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
await db.connect()
|
|
43
|
+
|
|
44
|
+
const auditor = new ReleaseAuditor({ dir: 'packages/sample-pkg' }, { db })
|
|
45
|
+
await auditor.init()
|
|
46
|
+
|
|
47
|
+
const gen = auditor.run()
|
|
48
|
+
let step = await gen.next()
|
|
49
|
+
const intents = []
|
|
50
|
+
while (!step.done) {
|
|
51
|
+
intents.push(step.value)
|
|
52
|
+
step = await gen.next()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res = step.value?.data || step.value
|
|
56
|
+
assert.ok(res.success)
|
|
57
|
+
assert.equal(res.errors.length, 0)
|
|
58
|
+
assert.equal(res.checkedReleases.length, 1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('detects missing release documents, user.md, and contract files with actionable diagnostics', async () => {
|
|
62
|
+
const pkgJson = { name: '@nan0web/broken-pkg', version: '2.0.0' }
|
|
63
|
+
const db = new DB({
|
|
64
|
+
predefined: [
|
|
65
|
+
['packages/broken-pkg/package.json', pkgJson],
|
|
66
|
+
['packages/broken-pkg/releases/2/0/v2.0.0/dummy.txt', 'empty'],
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
await db.connect()
|
|
70
|
+
|
|
71
|
+
const auditor = new ReleaseAuditor({ dir: 'packages/broken-pkg' }, { db })
|
|
72
|
+
await auditor.init()
|
|
73
|
+
|
|
74
|
+
const gen = auditor.run()
|
|
75
|
+
let step = await gen.next()
|
|
76
|
+
while (!step.done) {
|
|
77
|
+
step = await gen.next()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const res = step.value?.data || step.value
|
|
81
|
+
assert.equal(res.success, false)
|
|
82
|
+
assert.ok(res.errors.length >= 2)
|
|
83
|
+
|
|
84
|
+
const codes = res.errors.map((e) => e.code || e.check)
|
|
85
|
+
assert.ok(codes.includes('MISSING_RELEASE_MD') || codes.includes('release.md'))
|
|
86
|
+
assert.ok(codes.includes('MISSING_CONTRACT') || codes.includes('task.spec.js'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('detects ambiguous contracts when both task.spec.js and task.test.js exist', async () => {
|
|
90
|
+
const db = new DB({
|
|
91
|
+
predefined: [
|
|
92
|
+
['packages/ambig/package.json', { name: '@nan0web/ambig', version: '1.0.0' }],
|
|
93
|
+
['packages/ambig/releases/1/0/v1.0.0/release.md', '# Release\n- [ ] Task'],
|
|
94
|
+
['packages/ambig/releases/1/0/v1.0.0/user.md', 'Hours: 1.0h\nIterations: 2\nRRS: 300'],
|
|
95
|
+
['packages/ambig/releases/1/0/v1.0.0/task.spec.js', '// spec'],
|
|
96
|
+
['packages/ambig/releases/1/0/v1.0.0/task.test.js', '// test'],
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
await db.connect()
|
|
100
|
+
|
|
101
|
+
const auditor = new ReleaseAuditor({ dir: 'packages/ambig' }, { db })
|
|
102
|
+
await auditor.init()
|
|
103
|
+
|
|
104
|
+
const gen = auditor.run()
|
|
105
|
+
let step = await gen.next()
|
|
106
|
+
while (!step.done) {
|
|
107
|
+
step = await gen.next()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const res = step.value?.data || step.value
|
|
111
|
+
assert.equal(res.success, false)
|
|
112
|
+
const codes = res.errors.map((e) => e.code || e.check)
|
|
113
|
+
assert.ok(codes.includes('AMBIGUOUS_CONTRACTS') || codes.includes('contracts'))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('skips execution gracefully if auditor is capped by active session', async () => {
|
|
117
|
+
const db = new DB({
|
|
118
|
+
predefined: [
|
|
119
|
+
['.agent/active_session.json', { disabledInspectors: ['release'] }],
|
|
120
|
+
['packages/any/package.json', { name: 'any' }],
|
|
121
|
+
],
|
|
122
|
+
})
|
|
123
|
+
await db.connect()
|
|
124
|
+
|
|
125
|
+
const auditor = new ReleaseAuditor({ dir: 'packages/any' }, { db })
|
|
126
|
+
await auditor.init()
|
|
127
|
+
|
|
128
|
+
const gen = auditor.run()
|
|
129
|
+
let step = await gen.next()
|
|
130
|
+
while (!step.done) {
|
|
131
|
+
step = await gen.next()
|
|
132
|
+
}
|
|
133
|
+
const res = step.value?.data || step.value
|
|
134
|
+
assert.ok(res.skipped)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="uk" data-bs-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Releases Catalog — NaN•Web</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f172a;
|
|
10
|
+
--surface: #1e293b;
|
|
11
|
+
--accent: #38bdf8;
|
|
12
|
+
--text: #f8fafc;
|
|
13
|
+
--text-dim: #94a3b8;
|
|
14
|
+
--border: #334155;
|
|
15
|
+
}
|
|
16
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2.5rem 1.5rem; display: flex; justify-content: center; line-height: 1.6; }
|
|
17
|
+
.container { max-width: 880px; width: 100%; }
|
|
18
|
+
h1, h2, h3 { color: #fff; font-weight: 700; margin-top: 1.5rem; }
|
|
19
|
+
h1 { font-size: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.8rem; }
|
|
20
|
+
a { color: var(--accent); text-decoration: none; }
|
|
21
|
+
a:hover { text-decoration: underline; }
|
|
22
|
+
table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; background: var(--surface); border-radius: 8px; overflow: hidden; }
|
|
23
|
+
th, td { padding: 0.85rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
|
24
|
+
th { background: rgba(0,0,0,0.2); font-weight: 600; color: var(--accent); }
|
|
25
|
+
pre { background: var(--surface); padding: 1rem; border-radius: 8px; border: 1px solid var(--border); overflow-x: auto; color: #a5b4fc; font-family: monospace; }
|
|
26
|
+
blockquote { border-left: 4px solid var(--accent); margin: 1rem 0; padding: 0.5rem 1rem; background: var(--surface); border-radius: 0 8px 8px 0; }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<div class="container">
|
|
31
|
+
{{CONTENT}}
|
|
32
|
+
</div>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|