@defra/fcp-sfd-frontend-engine 0.2.7 → 0.2.9

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/README.md CHANGED
@@ -45,4 +45,31 @@ Docker-based local development support will be used so engine changes can be pic
45
45
  - prefer shared reusable modules over duplication
46
46
  - avoid premature abstraction
47
47
  - maintain clear ownership boundaries between the engine and applications
48
- - evolve incrementally
48
+ - evolve incrementally
49
+
50
+ ## SonarQube Cloud scan
51
+
52
+ Run a local scan against [SonarCloud](https://sonarcloud.io/project/overview?id=defra_fcp-sfd-frontend-engine) for the current git branch. See the [DEFRA SonarCloud guide](https://github.com/DEFRA/cdp-documentation/blob/main/how-to/sonarcloud.md) for organisation access and CI setup.
53
+
54
+ ### Setup
55
+
56
+ 1. Log in to [SonarQube Cloud](https://sonarcloud.io) with your DEFRA GitHub account
57
+ 2. Go to **My Account → Security → Generate Tokens** and create a personal token
58
+ 3. Add `SONAR_TOKEN=<your-token>` to your `.env` file
59
+
60
+ ### Run
61
+
62
+ Generate test coverage first, then scan:
63
+
64
+ ```bash
65
+ npm test
66
+ npm run sonar
67
+ ```
68
+
69
+ The script uploads results for the current branch and prints:
70
+
71
+ - Quality gate pass/fail and failed conditions
72
+ - Open issues on new code (when the gate fails)
73
+ - **Accepted / false-positive issues without comment** — DEFRA quality gates require a justification comment on each suppressed issue; add comments in SonarCloud under the issue **Activity** tab
74
+
75
+ Exit code is `0` when the gate passes and all suppressed issues are commented, `1` otherwise.
package/dist/index.cjs CHANGED
@@ -72,9 +72,23 @@ var businessSchemas = {
72
72
  sbi: businessSbiSchema
73
73
  };
74
74
 
75
+ // src/schemas/customer/customer-crn-schema.js
76
+ var import_joi2 = __toESM(require("joi"), 1);
77
+ var customerCrnSchema = import_joi2.default.object({
78
+ crn: import_joi2.default.string().pattern(/^\d{10}$/).allow("").optional().messages({
79
+ "string.pattern.base": "Enter the full CRN"
80
+ })
81
+ });
82
+
83
+ // src/schemas/customer/customer-schemas.js
84
+ var customerSchemas = {
85
+ crn: customerCrnSchema
86
+ };
87
+
75
88
  // src/schemas/schemas.js
76
89
  var schemas = {
77
- business: businessSchemas
90
+ business: businessSchemas,
91
+ customer: customerSchemas
78
92
  };
79
93
 
80
94
  // src/utils/format-validation-errors.js
package/dist/index.js CHANGED
@@ -35,9 +35,23 @@ var businessSchemas = {
35
35
  sbi: businessSbiSchema
36
36
  };
37
37
 
38
+ // src/schemas/customer/customer-crn-schema.js
39
+ import Joi2 from "joi";
40
+ var customerCrnSchema = Joi2.object({
41
+ crn: Joi2.string().pattern(/^\d{10}$/).allow("").optional().messages({
42
+ "string.pattern.base": "Enter the full CRN"
43
+ })
44
+ });
45
+
46
+ // src/schemas/customer/customer-schemas.js
47
+ var customerSchemas = {
48
+ crn: customerCrnSchema
49
+ };
50
+
38
51
  // src/schemas/schemas.js
39
52
  var schemas = {
40
- business: businessSchemas
53
+ business: businessSchemas,
54
+ customer: customerSchemas
41
55
  };
42
56
 
43
57
  // src/utils/format-validation-errors.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/fcp-sfd-frontend-engine",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Shared frontend engine used by both the internal and external frontend applications.",
5
5
  "main": "./dist/index.cjs",
6
6
  "exports": {
@@ -14,7 +14,8 @@
14
14
  "lint:fix": "eslint --fix",
15
15
  "build": "tsup",
16
16
  "test": "npm run build && vitest run --coverage",
17
- "test:watch": "vitest"
17
+ "test:watch": "vitest",
18
+ "sonar": "node scripts/sonar-scan.js"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -0,0 +1,552 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { execFileSync, spawn } from 'node:child_process'
3
+ import { resolve } from 'node:path'
4
+
5
+ const SONARCLOUD_BASE_URL = 'https://sonarcloud.io'
6
+ const BORDER = '═'.repeat(51)
7
+ const THIN_BORDER = '─'.repeat(51)
8
+ const MAX_ISSUES_DISPLAYED = 30
9
+
10
+ const SEVERITY_ORDER = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO']
11
+ const SEVERITY_ICONS = {
12
+ BLOCKER: '🔴',
13
+ CRITICAL: '🟠',
14
+ MAJOR: '🟡',
15
+ MINOR: '🔵',
16
+ INFO: '⚪'
17
+ }
18
+
19
+ const HOTSPOT_ICONS = {
20
+ HIGH: '🔴',
21
+ MEDIUM: '🟠',
22
+ LOW: '🟡'
23
+ }
24
+
25
+ const METRIC_LABELS = {
26
+ new_reliability_rating: 'Reliability Rating',
27
+ new_security_rating: 'Security Rating',
28
+ new_maintainability_rating: 'Maintainability Rating',
29
+ new_coverage: 'Coverage on New Code',
30
+ new_duplicated_lines_density: 'Duplication on New Code',
31
+ new_violations: 'New Issues',
32
+ new_security_hotspots_reviewed: 'Security Hotspots Reviewed',
33
+ new_blocker_violations: 'Blocker Issues',
34
+ new_critical_violations: 'Critical Issues',
35
+ false_positive_issues_without_comment: 'False Positives without Comment',
36
+ accepted_issues_without_comment: 'Accepted Issues without Comment'
37
+ }
38
+
39
+ const ACCEPTED_ISSUE_STATUSES = ['FALSE_POSITIVE', 'ACCEPTED']
40
+ const CHANGELOG_FETCH_CONCURRENCY = 8
41
+
42
+ const COMPARATOR_SYMBOLS = {
43
+ GT: '>',
44
+ LT: '<',
45
+ EQ: '=',
46
+ NE: '≠'
47
+ }
48
+
49
+ const parseKeyValue = (content) =>
50
+ Object.fromEntries(
51
+ content
52
+ .split('\n')
53
+ .map((line) => line.trim())
54
+ .filter((line) => line && !line.startsWith('#'))
55
+ .map((line) => {
56
+ const idx = line.indexOf('=')
57
+ return idx > 0 ? [line.slice(0, idx).trim(), line.slice(idx + 1).trim()] : null
58
+ })
59
+ .filter(Boolean)
60
+ )
61
+
62
+ const getCurrentBranch = () =>
63
+ execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim()
64
+
65
+ const runScanner = (sonarToken, cwd, branch) =>
66
+ new Promise((resolve, reject) => {
67
+ const args = [
68
+ 'run',
69
+ '--rm',
70
+ '--name',
71
+ 'sonar-scan',
72
+ '-v',
73
+ `${cwd}:/usr/src`,
74
+ '-e',
75
+ `SONAR_TOKEN=${sonarToken}`,
76
+ 'sonarsource/sonar-scanner-cli',
77
+ '-Dsonar.issuesReport.console.enable=true',
78
+ '-Dsonar.qualitygate.wait=true',
79
+ `-Dsonar.branch.name=${branch}`,
80
+ '-Dsonar.verbose=true'
81
+ ]
82
+
83
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
84
+ const message =
85
+ 'Code scan in progress using sonar-scanner-cli (to view logs in real-time a Docker client can be used e.g. Docker Desktop)'
86
+ let i = 0
87
+
88
+ const spinner = setInterval(() => {
89
+ process.stdout.write(`\r ${frames[i++ % frames.length]} ${message}`)
90
+ }, 80)
91
+
92
+ const child = spawn('docker', args, { stdio: 'ignore' })
93
+
94
+ child.on('error', (err) => {
95
+ clearInterval(spinner)
96
+ process.stdout.write('\r')
97
+ reject(err)
98
+ })
99
+
100
+ // Always resolve with the exit code — a non-zero exit may simply mean the
101
+ // quality gate failed (analysis was still uploaded). We check the gate
102
+ // status via the API after the scan and exit accordingly.
103
+ child.on('close', (code) => {
104
+ clearInterval(spinner)
105
+ process.stdout.write(`\r ${message}\n`)
106
+ console.log('\n ✔ Code scan complete. See below for the results.\n')
107
+ resolve(code)
108
+ })
109
+ })
110
+
111
+ const sonarcloudFetch = async (path, sonarToken) => {
112
+ const url = `${SONARCLOUD_BASE_URL}${path}`
113
+
114
+ const response = await fetch(url, {
115
+ headers: {
116
+ Authorization: `Bearer ${sonarToken}`
117
+ }
118
+ })
119
+
120
+ if (!response.ok) {
121
+ const body = await response.text()
122
+ throw new Error(`SonarCloud API error ${response.status}: ${response.statusText} (${url})${body ? ` — ${body}` : ''}`)
123
+ }
124
+
125
+ return response.json()
126
+ }
127
+
128
+ const fetchQualityGate = (projectKey, sonarToken, branch) =>
129
+ sonarcloudFetch(
130
+ `/api/qualitygates/project_status?projectKey=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}`,
131
+ sonarToken
132
+ )
133
+
134
+ const fetchMeasures = (projectKey, sonarToken, branch) =>
135
+ sonarcloudFetch(
136
+ `/api/measures/component?component=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&metricKeys=new_violations,accepted_issues,security_hotspots,new_coverage,new_duplicated_lines_density`,
137
+ sonarToken
138
+ )
139
+
140
+ const fetchIssues = (projectKey, sonarToken, branch) =>
141
+ sonarcloudFetch(
142
+ `/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&resolved=false&inNewCodePeriod=true&ps=500&statuses=OPEN,CONFIRMED,REOPENED`,
143
+ sonarToken
144
+ )
145
+
146
+ const fetchSecurityHotspots = (projectKey, sonarToken, branch) =>
147
+ sonarcloudFetch(
148
+ `/api/hotspots/search?projectKey=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&inNewCodePeriod=true&ps=500&status=TO_REVIEW`,
149
+ sonarToken
150
+ )
151
+
152
+ const fetchAcceptedIssues = async (projectKey, sonarToken) => {
153
+ const issueStatuses = ACCEPTED_ISSUE_STATUSES.join(',')
154
+ const pageSize = 500
155
+ let page = 1
156
+ const issues = []
157
+
158
+ while (true) {
159
+ const response = await sonarcloudFetch(
160
+ `/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&issueStatuses=${issueStatuses}&ps=${pageSize}&p=${page}`,
161
+ sonarToken
162
+ )
163
+
164
+ issues.push(...(response.issues ?? []))
165
+
166
+ const total = response.total ?? issues.length
167
+ if (issues.length >= total || (response.issues ?? []).length < pageSize) break
168
+ page++
169
+ }
170
+
171
+ return issues
172
+ }
173
+
174
+ const fetchIssueChangelog = (issueKey, sonarToken) =>
175
+ sonarcloudFetch(`/api/issues/changelog?issue=${encodeURIComponent(issueKey)}`, sonarToken)
176
+
177
+ const issueHasComment = (changelogResponse) => {
178
+ const entries = changelogResponse?.changelog ?? []
179
+
180
+ return entries.some((entry) =>
181
+ (entry.items ?? []).some((item) => {
182
+ if (item.field !== 'comment') return false
183
+
184
+ const value = item.newValue ?? item.newString ?? ''
185
+ return String(value).trim().length > 0
186
+ })
187
+ )
188
+ }
189
+
190
+ const mapConcurrent = async (items, fn, concurrency = CHANGELOG_FETCH_CONCURRENCY) => {
191
+ const results = new Array(items.length)
192
+ let nextIndex = 0
193
+
194
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
195
+ while (nextIndex < items.length) {
196
+ const index = nextIndex++
197
+ results[index] = await fn(items[index], index)
198
+ }
199
+ })
200
+
201
+ await Promise.all(workers)
202
+ return results
203
+ }
204
+
205
+ const findAcceptedIssuesWithoutComment = async (projectKey, sonarToken) => {
206
+ const acceptedIssues = await fetchAcceptedIssues(projectKey, sonarToken)
207
+ if (acceptedIssues.length === 0) return []
208
+
209
+ const issuesWithCommentFlags = await mapConcurrent(acceptedIssues, async (issue) => {
210
+ const changelog = await fetchIssueChangelog(issue.key, sonarToken)
211
+ return { issue, hasComment: issueHasComment(changelog) }
212
+ })
213
+
214
+ return issuesWithCommentFlags.filter(({ hasComment }) => !hasComment)
215
+ }
216
+
217
+ const getMeasureValue = (measures, key) => {
218
+ const measure = measures.find((m) => m.metric === key)
219
+ if (!measure) return 'N/A'
220
+
221
+ return measure.value ?? measure.periods?.[0]?.value ?? 'N/A'
222
+ }
223
+
224
+ const formatPercent = (value) => (value === 'N/A' ? 'N/A' : `${parseFloat(value).toFixed(1)}%`)
225
+
226
+ const row = (label, value) => ` ${` ${label}`.padEnd(28)}${value}`
227
+
228
+ const extractFilePath = (component, projectKey) => {
229
+ const prefix = `${projectKey}:`
230
+ return component.startsWith(prefix) ? component.slice(prefix.length) : component
231
+ }
232
+
233
+ const printFailedConditions = (qualityGate) => {
234
+ const conditions = qualityGate.projectStatus?.conditions ?? []
235
+ const failed = conditions.filter((c) => c.status === 'ERROR')
236
+
237
+ if (failed.length === 0) return
238
+
239
+ console.log(THIN_BORDER)
240
+ console.log(' ⛔ Failed Conditions')
241
+
242
+ for (const condition of failed) {
243
+ const label = METRIC_LABELS[condition.metricKey] ?? condition.metricKey
244
+ const comparator = COMPARATOR_SYMBOLS[condition.comparator] ?? condition.comparator
245
+
246
+ const actual =
247
+ condition.metricKey.includes('coverage') || condition.metricKey.includes('duplicat')
248
+ ? formatPercent(condition.actualValue)
249
+ : condition.actualValue
250
+
251
+ const threshold =
252
+ condition.metricKey.includes('coverage') || condition.metricKey.includes('duplicat')
253
+ ? formatPercent(condition.errorThreshold)
254
+ : condition.errorThreshold
255
+
256
+ console.log(` ${label}: ${actual} (threshold ${comparator} ${threshold})`)
257
+ }
258
+ }
259
+
260
+ const printIssues = (issuesResponse, projectKey) => {
261
+ const issues = issuesResponse?.issues ?? []
262
+ const total = issuesResponse?.total ?? issues.length
263
+
264
+ if (issues.length === 0) return
265
+
266
+ // Sort by severity
267
+ issues.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity))
268
+
269
+ // Group by file
270
+ const byFile = new Map()
271
+
272
+ for (const issue of issues) {
273
+ const filePath = extractFilePath(issue.component, projectKey)
274
+ if (!byFile.has(filePath)) byFile.set(filePath, [])
275
+ byFile.get(filePath).push(issue)
276
+ }
277
+
278
+ const issuesUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&resolved=false&inNewCodePeriod=true`
279
+
280
+ console.log(`\n${BORDER}`)
281
+ console.log(` 🐛 Issues (${total} total)`)
282
+ console.log(BORDER)
283
+
284
+ let displayed = 0
285
+
286
+ for (const [filePath, fileIssues] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
287
+ if (displayed >= MAX_ISSUES_DISPLAYED) break
288
+
289
+ console.log(`\n 📄 ${filePath}`)
290
+
291
+ for (const issue of fileIssues) {
292
+ if (displayed >= MAX_ISSUES_DISPLAYED) break
293
+
294
+ const icon = SEVERITY_ICONS[issue.severity] ?? '⚪'
295
+ const rule = issue.rule ? ` (${issue.rule})` : ''
296
+
297
+ console.log(` ${icon} L${issue.line ?? '?'} ${issue.message}${rule}`)
298
+
299
+ const issueUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&open=${encodeURIComponent(issue.key)}`
300
+ console.log(` ${issueUrl}`)
301
+
302
+ displayed++
303
+ }
304
+ }
305
+
306
+ if (total > MAX_ISSUES_DISPLAYED) {
307
+ console.log(`\n ... and ${total - MAX_ISSUES_DISPLAYED} more`)
308
+ }
309
+
310
+ console.log(THIN_BORDER)
311
+ console.log(` 🔗 ${issuesUrl}`)
312
+ console.log(`${BORDER}\n`)
313
+ }
314
+
315
+ const formatIssueStatus = (status) => {
316
+ if (status === 'FALSE_POSITIVE') return 'False positive'
317
+ if (status === 'ACCEPTED') return 'Accepted'
318
+ return status ?? 'Unknown'
319
+ }
320
+
321
+ const printAcceptedIssuesWithoutComment = (issuesWithoutComment, projectKey) => {
322
+ if (issuesWithoutComment.length === 0) return false
323
+
324
+ const issuesUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&issueStatuses=${ACCEPTED_ISSUE_STATUSES.join(',')}`
325
+
326
+ console.log(`\n${BORDER}`)
327
+ console.log(` 💬 Accepted / False Positive Issues without Comment (${issuesWithoutComment.length})`)
328
+ console.log(BORDER)
329
+ console.log(' DEFRA quality gates require a justification comment on each accepted issue.')
330
+ console.log(' Add a comment in SonarCloud under the issue Activity tab.\n')
331
+
332
+ const byFile = new Map()
333
+
334
+ for (const { issue } of issuesWithoutComment) {
335
+ const filePath = extractFilePath(issue.component, projectKey)
336
+ if (!byFile.has(filePath)) byFile.set(filePath, [])
337
+ byFile.get(filePath).push(issue)
338
+ }
339
+
340
+ let displayed = 0
341
+
342
+ for (const [filePath, fileIssues] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
343
+ if (displayed >= MAX_ISSUES_DISPLAYED) break
344
+
345
+ console.log(`\n 📄 ${filePath}`)
346
+
347
+ for (const issue of fileIssues) {
348
+ if (displayed >= MAX_ISSUES_DISPLAYED) break
349
+
350
+ const icon = SEVERITY_ICONS[issue.severity] ?? '⚪'
351
+ const rule = issue.rule ? ` (${issue.rule})` : ''
352
+ const status = formatIssueStatus(issue.issueStatus ?? issue.status)
353
+
354
+ console.log(` ${icon} [${status}] L${issue.line ?? '?'} ${issue.message}${rule}`)
355
+
356
+ const issueUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&open=${encodeURIComponent(issue.key)}`
357
+ console.log(` ${issueUrl}`)
358
+
359
+ displayed++
360
+ }
361
+ }
362
+
363
+ if (issuesWithoutComment.length > MAX_ISSUES_DISPLAYED) {
364
+ console.log(`\n ... and ${issuesWithoutComment.length - MAX_ISSUES_DISPLAYED} more`)
365
+ }
366
+
367
+ console.log(THIN_BORDER)
368
+ console.log(` 🔗 ${issuesUrl}`)
369
+ console.log(`${BORDER}\n`)
370
+
371
+ return true
372
+ }
373
+
374
+ const printHotspots = (hotspotsResponse, projectKey) => {
375
+ const hotspots = hotspotsResponse?.hotspots ?? []
376
+
377
+ if (hotspots.length === 0) return
378
+
379
+ const total = hotspotsResponse?.paging?.total ?? hotspots.length
380
+
381
+ console.log(`\n${BORDER}`)
382
+ console.log(` 🔥 Security Hotspots (${total} to review)`)
383
+ console.log(BORDER)
384
+
385
+ let displayed = 0
386
+
387
+ for (const hotspot of hotspots) {
388
+ if (displayed >= MAX_ISSUES_DISPLAYED) break
389
+
390
+ const filePath = extractFilePath(hotspot.component, projectKey)
391
+ const icon = HOTSPOT_ICONS[hotspot.vulnerabilityProbability] ?? '🟡'
392
+ const probability = hotspot.vulnerabilityProbability ?? 'UNKNOWN'
393
+
394
+ console.log(`\n 📄 ${filePath}`)
395
+ console.log(` ${icon} [${probability}] L${hotspot.line ?? '?'} ${hotspot.message}`)
396
+
397
+ const hotspotUrl = `${SONARCLOUD_BASE_URL}/security_hotspots?id=${encodeURIComponent(projectKey)}&hotspots=${encodeURIComponent(hotspot.key)}`
398
+ console.log(` ${hotspotUrl}`)
399
+
400
+ displayed++
401
+ }
402
+
403
+ if (total > MAX_ISSUES_DISPLAYED) {
404
+ console.log(`\n ... and ${total - MAX_ISSUES_DISPLAYED} more`)
405
+ }
406
+
407
+ const hotspotsUrl = `${SONARCLOUD_BASE_URL}/security_hotspots?id=${encodeURIComponent(projectKey)}&inNewCodePeriod=true`
408
+ console.log(THIN_BORDER)
409
+ console.log(` 🔗 ${hotspotsUrl}`)
410
+ console.log(`${BORDER}\n`)
411
+ }
412
+
413
+ const printSummary = (qualityGate, measuresResponse, projectKey, branch) => {
414
+ const measures = measuresResponse.component?.measures ?? []
415
+ const status = qualityGate.projectStatus?.status
416
+
417
+ const passed = status === 'OK'
418
+ const statusLabel = passed ? '✅ PASSED' : status === 'WARN' ? '⚠️ WARN' : '❌ FAILED'
419
+
420
+ // Issues
421
+ const newIssues = getMeasureValue(measures, 'new_violations')
422
+ const acceptedIssues = getMeasureValue(measures, 'accepted_issues')
423
+
424
+ // Measures
425
+ const securityHotspots = getMeasureValue(measures, 'security_hotspots')
426
+ const coverageOnNew = formatPercent(getMeasureValue(measures, 'new_coverage'))
427
+ const duplicationOnNew = formatPercent(getMeasureValue(measures, 'new_duplicated_lines_density'))
428
+
429
+ const dashboardUrl = `${SONARCLOUD_BASE_URL}/summary/overall?id=${encodeURIComponent(projectKey)}`
430
+
431
+ console.log(`\n${BORDER}`)
432
+ console.log(` SonarCloud Quality Gate: ${statusLabel}`)
433
+ console.log(BORDER)
434
+ console.log(' Issues')
435
+ console.log(row('New Issues:', newIssues))
436
+ console.log(row('Accepted Issues:', acceptedIssues))
437
+ console.log(' Measures')
438
+ console.log(row('Security Hotspots:', securityHotspots))
439
+ console.log(row('Coverage on New Code:', coverageOnNew))
440
+ console.log(row('Duplication on New Code:', duplicationOnNew))
441
+
442
+ if (!passed) {
443
+ printFailedConditions(qualityGate)
444
+ }
445
+
446
+ console.log(BORDER)
447
+ console.log(` 🔀 Branch: ${branch}`)
448
+ console.log(` 🔗 ${dashboardUrl}`)
449
+ console.log(`${BORDER}\n`)
450
+
451
+ return passed || status === 'WARN'
452
+ }
453
+
454
+ const sonarScan = async () => {
455
+ const cwd = resolve('.')
456
+
457
+ // Load .env if present (mirrors `source .env` from the old npm script)
458
+ try {
459
+ const envVars = parseKeyValue(readFileSync(resolve(cwd, '.env'), 'utf8'))
460
+ for (const [key, value] of Object.entries(envVars)) {
461
+ process.env[key] ??= value
462
+ }
463
+ } catch {
464
+ // .env file is optional — SONAR_TOKEN may already be in the environment
465
+ }
466
+
467
+ const sonarToken = process.env.SONAR_TOKEN
468
+
469
+ if (!sonarToken) {
470
+ console.error('Error: SONAR_TOKEN is not set. Add it to your .env file.')
471
+ process.exit(1)
472
+ }
473
+
474
+ // Read project config from sonar-project.properties
475
+ const propsPath = resolve(cwd, 'sonar-project.properties')
476
+ const props = parseKeyValue(readFileSync(propsPath, 'utf8'))
477
+ const projectKey = props['sonar.projectKey']
478
+
479
+ if (!projectKey) {
480
+ console.error('Error: sonar.projectKey not found in sonar-project.properties')
481
+ process.exit(1)
482
+ }
483
+
484
+ // Detect current branch so the scan targets it on SonarCloud
485
+ const branch = getCurrentBranch()
486
+ console.log(`\n🔀 Branch: ${branch}\n`)
487
+
488
+ // Run the scanner — resolves with exit code (0 = success, non-zero = quality
489
+ // gate failed or scan error). We always attempt to fetch the summary.
490
+ const scanCode = await runScanner(sonarToken, cwd, branch)
491
+
492
+ // Fetch quality gate + metrics and print summary
493
+ let qualityGate, measuresResponse
494
+
495
+ try {
496
+ ;[qualityGate, measuresResponse] = await Promise.all([
497
+ fetchQualityGate(projectKey, sonarToken, branch),
498
+ fetchMeasures(projectKey, sonarToken, branch)
499
+ ])
500
+ } catch (apiErr) {
501
+ // API fetch failed — the scan likely didn't upload (e.g. auth error, network)
502
+ if (scanCode !== 0) {
503
+ console.error(`\nSonar scanner exited with code ${scanCode}. No results to display.`)
504
+ process.exit(scanCode)
505
+ }
506
+
507
+ throw apiErr
508
+ }
509
+
510
+ const passed = printSummary(qualityGate, measuresResponse, projectKey, branch)
511
+
512
+ let hasAcceptedIssuesWithoutComment = false
513
+
514
+ try {
515
+ const issuesWithoutComment = await findAcceptedIssuesWithoutComment(projectKey, sonarToken)
516
+ hasAcceptedIssuesWithoutComment = printAcceptedIssuesWithoutComment(issuesWithoutComment, projectKey)
517
+ } catch (acceptedErr) {
518
+ console.error(`\nCould not fetch accepted/false-positive issue comments: ${acceptedErr.message}`)
519
+ }
520
+
521
+ if (!passed || hasAcceptedIssuesWithoutComment) {
522
+ // Fetch detailed issues and hotspots to help developers fix problems locally
523
+ try {
524
+ const measures = measuresResponse.component?.measures ?? []
525
+ const hotspotCount = getMeasureValue(measures, 'security_hotspots')
526
+ const shouldFetchHotspots = hotspotCount !== 'N/A' && parseInt(hotspotCount, 10) > 0
527
+
528
+ const fetches = [fetchIssues(projectKey, sonarToken, branch)]
529
+
530
+ if (shouldFetchHotspots) {
531
+ fetches.push(fetchSecurityHotspots(projectKey, sonarToken, branch))
532
+ }
533
+
534
+ const [issuesResponse, hotspotsResponse] = await Promise.all(fetches)
535
+
536
+ printIssues(issuesResponse, projectKey)
537
+
538
+ if (hotspotsResponse) {
539
+ printHotspots(hotspotsResponse, projectKey)
540
+ }
541
+ } catch (detailErr) {
542
+ console.error(`\nCould not fetch issue details: ${detailErr.message}`)
543
+ }
544
+
545
+ process.exit(1)
546
+ }
547
+ }
548
+
549
+ sonarScan().catch((err) => {
550
+ console.error(`\nSonar scan failed: ${err.message}`)
551
+ process.exit(1)
552
+ })
@@ -0,0 +1,11 @@
1
+ import Joi from 'joi'
2
+
3
+ export const customerCrnSchema = Joi.object({
4
+ crn: Joi.string()
5
+ .pattern(/^\d{10}$/)
6
+ .allow('')
7
+ .optional()
8
+ .messages({
9
+ 'string.pattern.base': 'Enter the full CRN'
10
+ })
11
+ })
@@ -0,0 +1,5 @@
1
+ import { customerCrnSchema } from './customer-crn-schema.js'
2
+
3
+ export const customerSchemas = {
4
+ crn: customerCrnSchema
5
+ }
@@ -1,5 +1,7 @@
1
1
  import { businessSchemas } from './business/business-schemas.js'
2
+ import { customerSchemas } from './customer/customer-schemas.js'
2
3
 
3
4
  export const schemas = {
4
- business: businessSchemas
5
+ business: businessSchemas,
6
+ customer: customerSchemas
5
7
  }