monecromanci 0.0.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.
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Step 06 — Build summary.
5
+ *
6
+ * Reads the context manifest plus the Jest/Cobertura reports left by the quality
7
+ * control step and renders a markdown summary (pipeline context, package
8
+ * versions, test/coverage results and build outputs) that is attached to the
9
+ * Azure DevOps run. Always runs, even when earlier steps failed.
10
+ */
11
+
12
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import process from 'node:process'
15
+ import { banner, log, readJsonSafe, runSafe, shellEscape, uploadSummary } from './lib/_h.mjs'
16
+ import { loadContext } from './lib/context.mjs'
17
+
18
+ const ROOT_DIR = process.cwd()
19
+ const SUMMARY_FILE_NAME = 'summary.md'
20
+
21
+ /**
22
+ * Escapes markdown table cell content.
23
+ *
24
+ * @param {string | number | boolean | undefined | null} value The cell value.
25
+ * @returns {string} Returns the escaped value.
26
+ */
27
+ function escapeCell (value) {
28
+ return String(value ?? '')
29
+ .replace(/\|/g, '\\|')
30
+ .replace(/\r?\n/g, '<br/>')
31
+ }
32
+
33
+ /**
34
+ * Safely reads a UTF-8 text file.
35
+ *
36
+ * @param {string} filePath The file path.
37
+ * @returns {string} Returns the file content or an empty string.
38
+ */
39
+ function readTextSafe (filePath) {
40
+ if (!existsSync(filePath)) {
41
+ return ''
42
+ }
43
+
44
+ try {
45
+ return readFileSync(filePath, 'utf8')
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Parses a numeric value from a string safely.
53
+ *
54
+ * @param {string | undefined} value The value to parse.
55
+ * @returns {number | null} Returns the parsed number or null.
56
+ */
57
+ function toNumber (value) {
58
+ if (typeof value !== 'string' || value.trim() === '') {
59
+ return null
60
+ }
61
+
62
+ const parsed = Number(value)
63
+
64
+ return Number.isFinite(parsed) ? parsed : null
65
+ }
66
+
67
+ /**
68
+ * Extracts attributes from the first matching XML tag.
69
+ *
70
+ * @param {string} xml The XML source.
71
+ * @param {string} tagName The tag name.
72
+ * @returns {Record<string, string>} Returns the attributes map.
73
+ */
74
+ function parseXmlAttributes (xml, tagName) {
75
+ const tagMatch = xml.match(new RegExp(`<${tagName}\\s+([^>]+)>`, 'i'))
76
+ if (!tagMatch) {
77
+ return {}
78
+ }
79
+
80
+ const attributes = {}
81
+ const attributeRegex = /(\w[\w-]*)="([^"]*)"/g
82
+ let match = attributeRegex.exec(tagMatch[1])
83
+
84
+ while (match) {
85
+ attributes[match[1]] = match[2]
86
+ match = attributeRegex.exec(tagMatch[1])
87
+ }
88
+
89
+ return attributes
90
+ }
91
+
92
+ /**
93
+ * Parses a Jest JUnit report summary.
94
+ *
95
+ * @param {string} reportPath The report path.
96
+ * @returns {{tests: number, failures: number, errors: number, time: number, exists: boolean}} Returns the parsed summary.
97
+ */
98
+ function readJunitSummary (reportPath) {
99
+ const xml = readTextSafe(reportPath)
100
+ if (!xml) {
101
+ return { tests: 0, failures: 0, errors: 0, time: 0, exists: false }
102
+ }
103
+
104
+ const attributes = parseXmlAttributes(xml, 'testsuites')
105
+
106
+ return {
107
+ tests: toNumber(attributes.tests) || 0,
108
+ failures: toNumber(attributes.failures) || 0,
109
+ errors: toNumber(attributes.errors) || 0,
110
+ time: toNumber(attributes.time) || 0,
111
+ exists: true,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Parses a Cobertura coverage report summary.
117
+ *
118
+ * @param {string} reportPath The report path.
119
+ * @returns {{lineRate: number | null, branchRate: number | null, linesCovered: number, linesValid: number, exists: boolean}} Returns the parsed summary.
120
+ */
121
+ function readCoverageSummary (reportPath) {
122
+ const xml = readTextSafe(reportPath)
123
+ if (!xml) {
124
+ return { lineRate: null, branchRate: null, linesCovered: 0, linesValid: 0, exists: false }
125
+ }
126
+
127
+ const attributes = parseXmlAttributes(xml, 'coverage')
128
+
129
+ return {
130
+ branchRate: attributes['branch-rate'] ? toNumber(attributes['branch-rate']) : null,
131
+ exists: true,
132
+ lineRate: attributes['line-rate'] ? toNumber(attributes['line-rate']) : null,
133
+ linesCovered: toNumber(attributes['lines-covered']) || 0,
134
+ linesValid: toNumber(attributes['lines-valid']) || 0,
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Formats a duration in seconds.
140
+ *
141
+ * @param {number} seconds The duration in seconds.
142
+ * @returns {string} Returns the formatted duration.
143
+ */
144
+ function formatSeconds (seconds) {
145
+ if (!Number.isFinite(seconds) || seconds <= 0) {
146
+ return 'n/a'
147
+ }
148
+
149
+ if (seconds < 60) {
150
+ return `${seconds.toFixed(2)}s`
151
+ }
152
+
153
+ const minutes = Math.floor(seconds / 60)
154
+ const remainingSeconds = (seconds - (minutes * 60)).toFixed(2).padStart(5, '0')
155
+
156
+ return `${minutes}m ${remainingSeconds}s`
157
+ }
158
+
159
+ /**
160
+ * Formats a coverage percentage from a decimal rate.
161
+ *
162
+ * @param {number | null} rate The decimal rate.
163
+ * @returns {string} Returns the formatted percentage.
164
+ */
165
+ function formatRate (rate) {
166
+ if (rate === null || !Number.isFinite(rate)) {
167
+ return 'n/a'
168
+ }
169
+
170
+ return `${(rate * 100).toFixed(2)}%`
171
+ }
172
+
173
+ /**
174
+ * Renders an ASCII progress bar for a decimal rate.
175
+ *
176
+ * @param {number | null} rate The decimal rate.
177
+ * @returns {string} Returns the bar string.
178
+ */
179
+ function formatRateBar (rate) {
180
+ if (rate === null || !Number.isFinite(rate)) {
181
+ return 'n/a'
182
+ }
183
+
184
+ const totalBlocks = 12
185
+ const filledBlocks = Math.round(Math.max(0, Math.min(1, rate)) * totalBlocks)
186
+
187
+ return `[${'#'.repeat(filledBlocks)}${'-'.repeat(totalBlocks - filledBlocks)}]`
188
+ }
189
+
190
+ /**
191
+ * Resolves the UTC modification timestamp for a path.
192
+ *
193
+ * @param {string} targetPath The file or directory path.
194
+ * @returns {string} Returns the UTC timestamp or n/a.
195
+ */
196
+ function getUtcLastWriteTime (targetPath) {
197
+ if (!existsSync(targetPath)) {
198
+ return 'n/a'
199
+ }
200
+
201
+ try {
202
+ return statSync(targetPath).mtime.toISOString().replace('T', ' ').replace('Z', ' UTC')
203
+ } catch {
204
+ return 'n/a'
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Creates markdown table lines.
210
+ *
211
+ * @param {string[]} headers The table headers.
212
+ * @param {Array<Array<string | number | boolean>>} rows The table rows.
213
+ * @returns {string[]} Returns the markdown table lines.
214
+ */
215
+ function createTable (headers, rows) {
216
+ const lines = [
217
+ `| ${headers.map(escapeCell).join(' | ')} |`,
218
+ `| ${headers.map(() => '---').join(' | ')} |`,
219
+ ]
220
+
221
+ for (const row of rows) {
222
+ lines.push(`| ${row.map(escapeCell).join(' | ')} |`)
223
+ }
224
+
225
+ return lines
226
+ }
227
+
228
+ /**
229
+ * Resolves the first parent ref for the current HEAD.
230
+ *
231
+ * @returns {string | null} Returns the previous commit ref or null.
232
+ */
233
+ function getPreviousCommitRef () {
234
+ return runSafe('git rev-parse HEAD~1') || null
235
+ }
236
+
237
+ /**
238
+ * Reads JSON content from a git ref.
239
+ *
240
+ * @param {string} gitRef The git ref to read from.
241
+ * @param {string} relativePath The workspace-relative file path.
242
+ * @returns {Record<string, any> | null} Returns parsed JSON or null.
243
+ */
244
+ function readJsonFromGitRef (gitRef, relativePath) {
245
+ const content = runSafe(`git show ${shellEscape(`${gitRef}:${relativePath}`)}`)
246
+ if (!content) {
247
+ return null
248
+ }
249
+
250
+ try {
251
+ return JSON.parse(content)
252
+ } catch {
253
+ return null
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Formats a project version change against the previous commit.
259
+ *
260
+ * @param {string} projectRoot The workspace-relative project root.
261
+ * @param {string} currentVersion The current package version.
262
+ * @param {string | null} previousCommitRef The previous commit ref.
263
+ * @returns {string} Returns the version transition or "not updated".
264
+ */
265
+ function formatVersionChange (projectRoot, currentVersion, previousCommitRef) {
266
+ if (!previousCommitRef) {
267
+ return 'not updated'
268
+ }
269
+
270
+ const previousPackageJson = readJsonFromGitRef(previousCommitRef, `${projectRoot}/package.json`)
271
+ const previousVersion = typeof previousPackageJson?.version === 'string' ? previousPackageJson.version : null
272
+
273
+ if (!previousVersion || previousVersion === currentVersion) {
274
+ return 'not updated'
275
+ }
276
+
277
+ return `${previousVersion} -> ${currentVersion}`
278
+ }
279
+
280
+ /**
281
+ * Resolves the expected build outputs for a context project.
282
+ *
283
+ * @param {Record<string, any>} project The context project data.
284
+ * @returns {string[]} Returns the output directories.
285
+ */
286
+ function getBuildOutputs (project) {
287
+ if (project.type?.reactApp && Array.isArray(project.reactBuild?.distDirs)) {
288
+ return project.reactBuild.distDirs.map(output => `${project.root}/${output}`)
289
+ }
290
+
291
+ if (Array.isArray(project.buildOutputs)) {
292
+ return project.buildOutputs.map(output => `${project.root}/${output}`)
293
+ }
294
+
295
+ return []
296
+ }
297
+
298
+ /**
299
+ * Builds a test and coverage report for a context project.
300
+ *
301
+ * @param {Record<string, any>} project The context project data.
302
+ * @param {string | null} previousCommitRef The previous commit ref.
303
+ * @returns {Record<string, any>} Returns the merged workspace report.
304
+ */
305
+ function createWorkspaceReport (project, previousCommitRef) {
306
+ const projectRoot = String(project.root || '')
307
+ const junit = readJunitSummary(path.join(ROOT_DIR, projectRoot, 'coverage', 'test-results.xml'))
308
+ const coverage = readCoverageSummary(path.join(ROOT_DIR, projectRoot, 'coverage', 'cobertura-coverage.xml'))
309
+
310
+ return {
311
+ branchRate: coverage.branchRate,
312
+ errors: junit.errors,
313
+ failures: junit.failures,
314
+ hasCoverage: coverage.exists,
315
+ hasJunit: junit.exists,
316
+ lineRate: coverage.lineRate,
317
+ linesCovered: coverage.linesCovered,
318
+ linesValid: coverage.linesValid,
319
+ packageName: String(project.packageName || project.name || 'n/a'),
320
+ projectName: String(project.name || 'unknown'),
321
+ projectRoot,
322
+ tests: junit.tests,
323
+ time: junit.time,
324
+ version: String(project.version || 'n/a'),
325
+ versionChange: formatVersionChange(projectRoot, String(project.version || 'n/a'), previousCommitRef),
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Writes the markdown summary and emits the Azure upload command.
331
+ *
332
+ * @param {string[]} markdownLines The markdown lines.
333
+ */
334
+ function publishSummary (markdownLines) {
335
+ const summaryPath = path.join(ROOT_DIR, SUMMARY_FILE_NAME)
336
+ writeFileSync(summaryPath, `${markdownLines.join('\n')}\n`, 'utf8')
337
+ uploadSummary(summaryPath)
338
+ log(`Summary generated: ${summaryPath}`)
339
+ }
340
+
341
+ /**
342
+ * Runs the summary generator.
343
+ */
344
+ function main () {
345
+ banner('[06] Build summary')
346
+
347
+ const now = new Date()
348
+ const context = loadContext()
349
+ const projects = (Array.isArray(context.projects) ? context.projects : []).filter(project => !project.ignored)
350
+ const affectedProjects = new Set(Array.isArray(context.affectedProjects) ? context.affectedProjects : [])
351
+ const previousCommitRef = getPreviousCommitRef()
352
+ const workspaceDisplayName = String(readJsonSafe(path.join(ROOT_DIR, 'package.json')).name || 'Workspace')
353
+
354
+ const reports = [...projects]
355
+ .sort((left, right) => String(left.root).localeCompare(String(right.root)))
356
+ .map(project => createWorkspaceReport(project, previousCommitRef))
357
+
358
+ const totalTests = reports.reduce((sum, report) => sum + report.tests, 0)
359
+ const totalFailures = reports.reduce((sum, report) => sum + report.failures, 0)
360
+ const totalErrors = reports.reduce((sum, report) => sum + report.errors, 0)
361
+ const totalTime = reports.reduce((sum, report) => sum + report.time, 0)
362
+ const coveredLines = reports.reduce((sum, report) => sum + report.linesCovered, 0)
363
+ const validLines = reports.reduce((sum, report) => sum + report.linesValid, 0)
364
+ const globalLineRate = validLines > 0 ? coveredLines / validLines : null
365
+
366
+ const pipelineContextRows = [
367
+ ['Build reason', process.env.BUILD_REASON || 'n/a'],
368
+ ['Branch', process.env.BUILD_SOURCEBRANCHNAME || context.branchName || 'n/a'],
369
+ ['PR target', process.env.SYSTEM_PULLREQUEST_TARGETBRANCH || 'n/a'],
370
+ ['Commit', process.env.BUILD_SOURCEVERSION || context.headCommit || 'n/a'],
371
+ ['NX base', process.env.NX_BASE || context.baseCommit || 'n/a'],
372
+ ['NX head', process.env.NX_HEAD || context.headCommit || 'n/a'],
373
+ ['Affected projects', [...affectedProjects].join(',') || 'none'],
374
+ ['Function apps', (context.groups?.functionApps || []).join(',') || 'none'],
375
+ ['React apps', (context.groups?.reactApps || []).join(',') || 'none'],
376
+ ['Internal packages', (context.groups?.internalPackages || []).join(',') || 'none'],
377
+ ['External packages', (context.groups?.externalPackages || []).join(',') || 'none'],
378
+ ['Generated at (UTC)', now.toISOString().replace('T', ' ').replace('Z', ' UTC')],
379
+ ]
380
+
381
+ const packageRows = reports.map(report => [
382
+ report.projectName,
383
+ report.packageName,
384
+ report.version,
385
+ report.versionChange,
386
+ report.projectRoot,
387
+ ])
388
+
389
+ const qaRows = reports.map(report => {
390
+ const status = (report.failures + report.errors) > 0
391
+ ? 'FAILED'
392
+ : (report.hasJunit ? 'PASSED' : 'NO REPORT')
393
+
394
+ return [
395
+ report.projectName,
396
+ status,
397
+ String(report.tests),
398
+ String(report.failures),
399
+ String(report.errors),
400
+ formatSeconds(report.time),
401
+ `${formatRate(report.lineRate)} ${formatRateBar(report.lineRate)}`,
402
+ `${formatRate(report.branchRate)} ${formatRateBar(report.branchRate)}`,
403
+ report.hasJunit ? 'yes' : 'no',
404
+ report.hasCoverage ? 'yes' : 'no',
405
+ ]
406
+ })
407
+
408
+ const buildRows = projects.map(project => {
409
+ const outputPaths = getBuildOutputs(project)
410
+ const existingOutputs = outputPaths.filter(outputPath => existsSync(path.join(ROOT_DIR, outputPath)))
411
+ const latestOutputTime = outputPaths.length > 0
412
+ ? outputPaths.map(outputPath => getUtcLastWriteTime(path.join(ROOT_DIR, outputPath))).find(timestamp => timestamp !== 'n/a') || 'n/a'
413
+ : 'n/a'
414
+
415
+ return [
416
+ project.name,
417
+ affectedProjects.has(project.name) ? 'yes' : 'no',
418
+ outputPaths.length > 0 ? outputPaths.join('<br/>') : 'n/a',
419
+ existingOutputs.length > 0 ? existingOutputs.join('<br/>') : 'none',
420
+ latestOutputTime,
421
+ ]
422
+ })
423
+
424
+ const markdownLines = [
425
+ `# ${workspaceDisplayName} Build Summary`,
426
+ '',
427
+ '## Snapshot',
428
+ '',
429
+ `- Projects discovered: ${reports.length}`,
430
+ `- Total tests: ${totalTests}`,
431
+ `- Failures: ${totalFailures}`,
432
+ `- Errors: ${totalErrors}`,
433
+ `- Total test time: ${formatSeconds(totalTime)}`,
434
+ `- Global line coverage: ${formatRate(globalLineRate)} ${formatRateBar(globalLineRate)}`,
435
+ '',
436
+ '## Pipeline Context',
437
+ '',
438
+ ...createTable(['Key', 'Value'], pipelineContextRows),
439
+ '',
440
+ '## Workspace Packages',
441
+ '',
442
+ ...createTable(['Project', 'Package', 'Version', 'Version update', 'Path'], packageRows),
443
+ '',
444
+ '## Test and Coverage Results',
445
+ '',
446
+ ...createTable(
447
+ ['Project', 'Result', 'Tests', 'Failures', 'Errors', 'Time', 'Line coverage', 'Branch coverage', 'JUnit', 'Cobertura'],
448
+ qaRows,
449
+ ),
450
+ '',
451
+ '## Build Outputs',
452
+ '',
453
+ ...createTable(
454
+ ['Project', 'Expected to build', 'Configured outputs', 'Detected outputs', 'Last output update (UTC)'],
455
+ buildRows,
456
+ ),
457
+ ]
458
+
459
+ publishSummary(markdownLines)
460
+ banner('[06] Build summary complete')
461
+ }
462
+
463
+ main()
@@ -0,0 +1,4 @@
1
+ steps:
2
+ - script: node .build-templates/06-summary.mjs
3
+ displayName: "[06] Publish build summary"
4
+ condition: succeededOrFailed()
@@ -0,0 +1,69 @@
1
+ # Reusable NX monorepo build templates
2
+
3
+ A zero-configuration Azure DevOps pipeline for NX monorepos. It detects the
4
+ *affected* projects, classifies them from their NX **tags**, then builds + zips +
5
+ drops apps and publishes libraries. Versions are managed manually in each
6
+ `package.json`. Designed to be copied verbatim into any NX monorepo — the only
7
+ per-project input is a tag.
8
+
9
+ ## How a project is classified
10
+
11
+ Classification is driven entirely by NX tags (set in each `project.json`). Use one
12
+ canonical tag per project; legacy descriptive tags are still recognised through the
13
+ alias table in [`lib/context.mjs`](lib/context.mjs).
14
+
15
+ | Canonical tag | Category | What the pipeline does when affected |
16
+ |---|---|---|
17
+ | `type:function-app` | Azure Function app | `nx build` → generate runtime `package.json` (vendoring internal libs) → prod install → zip → drop |
18
+ | `type:react-app` | Frontend app | branch-aware build → zip each produced output dir → drop |
19
+ | `type:publishable-lib` | npm package | publish at the `package.json` version (skip if already published) → docs |
20
+ | `type:internal-lib` | Internal/vendored lib | docs only; vendored into apps that import it |
21
+ | `ci:ignore` | Excluded | skipped everywhere (wins over any other tag) |
22
+
23
+ > Versioning is manual: set the `version` in each project's `package.json`. The
24
+ > pipeline never bumps versions, tags, or commits — it just publishes whatever
25
+ > version is on disk (and skips versions already on the registry). Apps use their
26
+ > `package.json` version for the build number and the function-app runtime manifest.
27
+
28
+ ## Pipeline steps
29
+
30
+ | Step | File | Responsibility |
31
+ |---|---|---|
32
+ | 01 | `01-preparation.{yml,mjs}` | `npm ci` (first), resolve git range + affected set, classify, write `01-preparation.context.json`, print the **execution plan**, emit gating variables |
33
+ | 02 | `02-quality-control.yml` | `nx affected -t lint,test,build`; publish test + coverage |
34
+ | 03 | `03-package-apps.{yml,mjs}` | build/zip/drop affected function apps and React apps |
35
+ | 04 | `04-publish-libs.{yml,mjs}` | publish affected publishable libs at their `package.json` version (master/main, non-PR) |
36
+ | 05 | `05-publish-documentation.{yml,mjs}` | build + upload TypeDoc for affected libs |
37
+ | 06 | `06-summary.{yml,mjs}` | render the markdown build summary (always runs) |
38
+
39
+ `lib/` holds the shared modules: `_h.mjs` (logging, shelling out, Azure variables,
40
+ JSON), `nx.mjs` (local-binary NX wrapper + git base/head), `context.mjs`
41
+ (classification + the persisted context model + `selectAffected` filters).
42
+
43
+ ## Key reliability decisions
44
+
45
+ - **`npm ci` runs before affected detection** so NX computes the project graph with
46
+ the workspace's pinned version. NX is always invoked via the local
47
+ `node_modules/.bin/nx` (never `npx --yes`, which can download a different version).
48
+ - **Windows-safe shelling.** Commands are full strings run through the platform
49
+ shell with explicit escaping; git ranges use `~1` (never `^`, a `cmd` escape char);
50
+ zip destinations are passed to PowerShell via an env var to avoid nested quoting.
51
+ - **Idempotent publish** — step 04 skips any version already on the registry, so
52
+ re-runs are safe and publishing only happens when you bump a `package.json` version.
53
+
54
+ ## Debugging without a push (run locally after `npm ci`)
55
+
56
+ - `npm run pipeline:plan` — runs step 01 locally, prints the execution plan and
57
+ writes `01-preparation.context.json`. Use it to confirm classification + affected
58
+ detection before pushing.
59
+ - `npm run pipeline:package` — runs step 03 against `./.pipeline-out` (skips the
60
+ registry prod-install) to exercise build/zip/drop with full logs.
61
+
62
+ ## Reusing in another repo
63
+
64
+ See [`../APPLY.md`](../APPLY.md) for the full guide. In short:
65
+
66
+ 1. Copy `.build-templates/` and `azure-pipelines.yml`.
67
+ 2. Tag each `project.json` with one `type:*` (or `ci:ignore`) tag, and set a real
68
+ `version` in every project's `package.json`.
69
+ 3. Add `.npmrc` and the pipeline secrets (`NODE_AUTH_TOKEN`, optional `saDevConnectionString`).