11#!/usr/bin/env node
2- // Consolidates per-skill review JSON files into the data file consumed by render-review.js.
3- // Run with --help for flags.
42
53const fs = require ( 'fs' ) ;
64const path = require ( 'path' ) ;
75
8- if ( process . argv . includes ( '--help' ) ) {
9- process . stdout . write ( `build-review-data.js — Consolidate per-skill JSON into a single review data file.
6+ const REQUIRED_FLAGS = [
7+ 'reportName' ,
8+ 'inputDir' ,
9+ 'siteName' ,
10+ 'goalLabel' ,
11+ 'scopeLabel' ,
12+ 'output' ,
13+ ] ;
14+
15+ const SECTION_MAP = {
16+ 'scan-site.json' : { id : 'site-scan' , label : 'Live Site Scan' , icon : '◐' } ,
17+ 'manage-headers.json' : { id : 'headers' , label : 'Browser Headers' , icon : '◑' } ,
18+ 'manage-firewall.json' : {
19+ id : 'firewall' ,
20+ label : 'Web Application Firewall' ,
21+ icon : '◆' ,
22+ } ,
23+ 'audit-permissions.json' : { id : 'permissions' , label : 'Roles & Permissions' , icon : '◇' } ,
24+ 'setup-auth.json' : { id : 'auth' , label : 'Access & Identity' , icon : '◈' } ,
25+ } ;
26+
27+ // Every severity a finding may carry. `pass` is shown as a stat but excluded
28+ // from the issue count by the report template.
29+ const SEVERITIES = [ 'critical' , 'high' , 'warning' , 'medium' , 'info' , 'low' , 'pass' ] ;
30+
31+ const HELP = `build-review-data.js — Consolidate per-skill JSON into a single review data file.
1032
1133Usage:
1234 node build-review-data.js --reportName <name> --inputDir <dir> --siteName <name> --goalLabel <label> --scopeLabel <label> --output <path> [--summary <text>] [--nextStepsFile <path>]
@@ -25,143 +47,148 @@ Flags:
2547Exit codes:
2648 0 Success (data file written; status JSON on stdout)
2749 1 Invocation error (missing flag or unreadable input dir)
28-
29- Examples:
30- node build-review-data.js --reportName "<report-name>" --inputDir <input-dir> --siteName "<site-name>" --goalLabel "<goal-label>" --scopeLabel "<scope-label>" --output <output-file>
31- node build-review-data.js --reportName "<report-name>" --inputDir <input-dir> --siteName "<site-name>" --goalLabel "<goal-label>" --scopeLabel "<scope-label>" --summary "<summary-text>" --nextStepsFile <next-steps-file> --output <output-file>
32- ` ) ;
33- process . exit ( 0 ) ;
34- }
50+ ` ;
3551
3652function getArg ( name , fallback = null ) {
3753 const idx = process . argv . indexOf ( '--' + name ) ;
3854 return idx !== - 1 && idx + 1 < process . argv . length ? process . argv [ idx + 1 ] : fallback ;
3955}
4056
41- const reportName = getArg ( 'reportName' ) ;
42- const inputDir = getArg ( 'inputDir' ) ;
43- const siteName = getArg ( 'siteName' ) ;
44- const goalLabel = getArg ( 'goalLabel' ) ;
45- const scopeLabel = getArg ( 'scopeLabel' ) ;
46- const outputPath = getArg ( 'output' ) ;
47- const summaryArg = getArg ( 'summary' , '' ) ;
48- const nextStepsFile = getArg ( 'nextStepsFile' ) ;
49-
50- for ( const [ name , value ] of [ [ 'reportName' , reportName ] , [ 'inputDir' , inputDir ] , [ 'siteName' , siteName ] , [ 'goalLabel' , goalLabel ] , [ 'scopeLabel' , scopeLabel ] , [ 'output' , outputPath ] ] ) {
51- if ( ! value ) {
52- process . stderr . write ( `Missing required flag: --${ name } \n` ) ;
53- process . exit ( 1 ) ;
57+ function readNextSteps ( filePath ) {
58+ try {
59+ const parsed = JSON . parse ( fs . readFileSync ( filePath , 'utf8' ) ) ;
60+ return Array . isArray ( parsed ) ? parsed . filter ( ( x ) => typeof x === 'string' ) : [ ] ;
61+ } catch ( err ) {
62+ process . stderr . write ( `Could not read next-steps file: ${ err . message } \n` ) ;
63+ return [ ] ;
5464 }
5565}
5666
57- if ( ! fs . existsSync ( inputDir ) ) {
58- process . stderr . write ( `Input dir not found: ${ inputDir } \n` ) ;
59- process . exit ( 1 ) ;
67+ function formatGeneratedAt ( now ) {
68+ const pad = ( n ) => String ( n ) . padStart ( 2 , '0' ) ;
69+ // Intl returns "GMT+5:30" on some platforms; keep only the short abbreviation when present.
70+ const tzName =
71+ new Intl . DateTimeFormat ( undefined , { timeZoneName : 'short' } )
72+ . formatToParts ( now )
73+ . find ( ( p ) => p . type === 'timeZoneName' ) ?. value || '' ;
74+ const stamp =
75+ `${ now . getFullYear ( ) } -${ pad ( now . getMonth ( ) + 1 ) } -${ pad ( now . getDate ( ) ) } ` +
76+ `${ pad ( now . getHours ( ) ) } :${ pad ( now . getMinutes ( ) ) } :${ pad ( now . getSeconds ( ) ) } ` ;
77+ return tzName ? `${ stamp } ${ tzName } ` : stamp ;
6078}
6179
62- const SECTION_MAP = {
63- 'scan-site.json' : { id : 'site-scan' , label : 'Live Site Scan' , icon : '◐' } ,
64- 'manage-headers.json' : { id : 'headers' , label : 'Browser Headers' , icon : '◑' } ,
65- 'manage-firewall.json' : { id : 'firewall' , label : 'Web Application Firewall' , icon : '◆' } ,
66- 'audit-permissions.json' : { id : 'permissions' , label : 'Roles & Permissions' , icon : '◇' } ,
67- 'setup-auth.json' : { id : 'auth' , label : 'Access & Identity' , icon : '◈' } ,
68- } ;
69-
70- // Severities that may appear on findings — ordered by precedence (most severe first).
71- // pass is excluded from the "issue" count but still shown as its own stat.
72- const SEVERITIES = [ 'critical' , 'high' , 'warning' , 'medium' , 'info' , 'low' , 'pass' ] ;
73-
74- const sections = [ ] ;
75- const totals = Object . fromEntries ( SEVERITIES . map ( s => [ s , 0 ] ) ) ;
76-
77- for ( const fileName of fs . readdirSync ( inputDir ) . sort ( ) ) {
78- if ( ! fileName . endsWith ( '.json' ) ) continue ;
79- if ( fileName === path . basename ( outputPath ) ) continue ;
80- const filePath = path . join ( inputDir , fileName ) ;
81-
82- let raw ;
83- try {
84- raw = JSON . parse ( fs . readFileSync ( filePath , 'utf8' ) ) ;
85- } catch ( err ) {
86- process . stderr . write ( `Skipping ${ fileName } : ${ err . message } \n` ) ;
87- continue ;
88- }
89-
90- // Skip files that aren't recognized sections (intermediate tool outputs, helper files like next-steps).
91- if ( ! SECTION_MAP [ fileName ] ) continue ;
92- const meta = SECTION_MAP [ fileName ] ;
80+ function skippedSection ( meta , reason ) {
81+ return {
82+ id : meta . id ,
83+ icon : meta . icon ,
84+ label : meta . label ,
85+ description : '' ,
86+ findings : [
87+ {
88+ id : `${ meta . id } -skipped` ,
89+ severity : 'info' ,
90+ title : `${ meta . label } check was skipped` ,
91+ details : reason || 'No additional detail.' ,
92+ } ,
93+ ] ,
94+ details : { } ,
95+ } ;
96+ }
9397
94- if ( raw && raw . status === 'skipped' ) {
98+ function buildSections ( inputDir , outputBasename ) {
99+ const sections = [ ] ;
100+ const totals = Object . fromEntries ( SEVERITIES . map ( ( s ) => [ s , 0 ] ) ) ;
101+
102+ for ( const fileName of fs . readdirSync ( inputDir ) . sort ( ) ) {
103+ if ( ! fileName . endsWith ( '.json' ) ) continue ;
104+ if ( fileName === outputBasename ) continue ;
105+ const meta = SECTION_MAP [ fileName ] ;
106+ if ( ! meta ) continue ;
107+
108+ const filePath = path . join ( inputDir , fileName ) ;
109+ let raw ;
110+ try {
111+ raw = JSON . parse ( fs . readFileSync ( filePath , 'utf8' ) ) ;
112+ } catch ( err ) {
113+ process . stderr . write ( `Skipping ${ fileName } : ${ err . message } \n` ) ;
114+ continue ;
115+ }
116+
117+ if ( raw ?. status === 'skipped' ) {
118+ sections . push ( skippedSection ( meta , raw . reason ) ) ;
119+ totals . info += 1 ;
120+ continue ;
121+ }
122+
123+ const findings = Array . isArray ( raw ?. findings ) ? raw . findings : [ ] ;
95124 sections . push ( {
96125 id : meta . id ,
97126 icon : meta . icon ,
98127 label : meta . label ,
99128 description : '' ,
100- findings : [ {
101- id : `${ meta . id } -skipped` ,
102- severity : 'info' ,
103- title : `${ meta . label } check was skipped` ,
104- details : raw . reason || 'No additional detail.' ,
105- } ] ,
106- details : { } ,
129+ findings,
130+ details : raw ?. details || { } ,
107131 } ) ;
108- totals . info += 1 ;
109- continue ;
132+
133+ for ( const f of findings ) {
134+ if ( f . severity && totals [ f . severity ] !== undefined ) totals [ f . severity ] += 1 ;
135+ }
110136 }
111137
112- const findings = Array . isArray ( raw ?. findings ) ? raw . findings : [ ] ;
113- const details = raw ?. details || { } ;
138+ return { sections , totals } ;
139+ }
114140
115- sections . push ( {
116- id : meta . id ,
117- icon : meta . icon ,
118- label : meta . label ,
119- description : '' ,
120- findings,
121- details,
122- } ) ;
141+ function main ( ) {
142+ if ( process . argv . includes ( '--help' ) ) {
143+ process . stdout . write ( HELP ) ;
144+ return ;
145+ }
123146
124- for ( const f of findings ) {
125- if ( f . severity && totals [ f . severity ] !== undefined ) totals [ f . severity ] += 1 ;
147+ const values = Object . fromEntries ( REQUIRED_FLAGS . map ( ( flag ) => [ flag , getArg ( flag ) ] ) ) ;
148+ for ( const flag of REQUIRED_FLAGS ) {
149+ if ( ! values [ flag ] ) {
150+ process . stderr . write ( `Missing required flag: --${ flag } \n` ) ;
151+ process . exit ( 1 ) ;
152+ }
126153 }
127- }
128154
129- let nextSteps = [ ] ;
130- if ( nextStepsFile ) {
131- try {
132- const ns = JSON . parse ( fs . readFileSync ( nextStepsFile , 'utf8' ) ) ;
133- if ( Array . isArray ( ns ) ) nextSteps = ns . filter ( x => typeof x === 'string' ) ;
134- } catch ( err ) {
135- process . stderr . write ( `Could not read next-steps file: ${ err . message } \n` ) ;
155+ const inputDir = values . inputDir ;
156+ const outputPath = values . output ;
157+ if ( ! fs . existsSync ( inputDir ) ) {
158+ process . stderr . write ( `Input dir not found: ${ inputDir } \n` ) ;
159+ process . exit ( 1 ) ;
136160 }
137- }
138161
139- const now = new Date ( ) ;
140- const pad = ( n ) => String ( n ) . padStart ( 2 , '0' ) ;
141- // Local-timezone short name (e.g. "IST", "PST"). Intl gives "GMT+5:30" on some platforms — strip to just the abbreviation when present.
142- const tzName = new Intl . DateTimeFormat ( undefined , { timeZoneName : 'short' } ) . formatToParts ( now ) . find ( p => p . type === 'timeZoneName' ) ?. value || '' ;
143- const generatedAt = `${ now . getFullYear ( ) } -${ pad ( now . getMonth ( ) + 1 ) } -${ pad ( now . getDate ( ) ) } ${ pad ( now . getHours ( ) ) } :${ pad ( now . getMinutes ( ) ) } :${ pad ( now . getSeconds ( ) ) } ${ tzName ? ' ' + tzName : '' } ` ;
144-
145- const payload = {
146- REPORT_NAME : reportName ,
147- SITE_NAME : siteName ,
148- GOAL_LABEL : goalLabel ,
149- SCOPE_LABEL : scopeLabel ,
150- GENERATED_AT : generatedAt ,
151- REVIEW_DATA : {
152- summary : summaryArg || '' ,
153- totals,
154- sections,
155- nextSteps,
156- } ,
157- } ;
162+ const summary = getArg ( 'summary' , '' ) ;
163+ const nextStepsFile = getArg ( 'nextStepsFile' ) ;
164+ const nextSteps = nextStepsFile ? readNextSteps ( nextStepsFile ) : [ ] ;
165+
166+ const { sections, totals } = buildSections ( inputDir , path . basename ( outputPath ) ) ;
167+
168+ const payload = {
169+ REPORT_NAME : values . reportName ,
170+ SITE_NAME : values . siteName ,
171+ GOAL_LABEL : values . goalLabel ,
172+ SCOPE_LABEL : values . scopeLabel ,
173+ GENERATED_AT : formatGeneratedAt ( new Date ( ) ) ,
174+ REVIEW_DATA : { summary : summary || '' , totals, sections, nextSteps } ,
175+ } ;
176+
177+ fs . mkdirSync ( path . dirname ( outputPath ) , { recursive : true } ) ;
178+ fs . writeFileSync ( outputPath , JSON . stringify ( payload , null , 2 ) ) ;
179+
180+ process . stdout . write (
181+ JSON . stringify ( {
182+ status : 'ok' ,
183+ outputPath,
184+ totals,
185+ sectionsCount : sections . length ,
186+ } ) + '\n'
187+ ) ;
188+ }
158189
159- fs . mkdirSync ( path . dirname ( outputPath ) , { recursive : true } ) ;
160- fs . writeFileSync ( outputPath , JSON . stringify ( payload , null , 2 ) ) ;
190+ if ( require . main === module ) {
191+ main ( ) ;
192+ }
161193
162- process . stdout . write ( JSON . stringify ( {
163- status : 'ok' ,
164- outputPath,
165- totals,
166- sectionsCount : sections . length ,
167- } ) + '\n' ) ;
194+ module . exports = { buildSections, formatGeneratedAt, SECTION_MAP , SEVERITIES } ;
0 commit comments