11import { readFileSync , writeFileSync } from "node:fs" ;
22import { join } from "node:path" ;
33import { err , ok , Result , safeTry } from "neverthrow" ;
4- import { RUN_RESULTS , type RunOutcome } from "../../src/engine/outcome.js" ;
4+ import { RUN_RESULTS } from "../../src/engine/outcome.js" ;
55import {
66 cellFault ,
77 renderTable ,
8- roundTrip ,
8+ tableBody ,
99 tableFault ,
10- tableRoundTrip ,
1110 tableRows ,
1211} from "../../src/report/markdown.js" ;
1312import type { CoverageRow , SectionDocs } from "../../src/sections/contract/docs.js" ;
@@ -21,10 +20,17 @@ import { RESOURCE_SLUGS } from "../../src/sections/contract/permissions.js";
2120import { DOCS } from "../../src/sections/docs-registry.js" ;
2221import { SECTIONS } from "../../src/sections/registry.js" ;
2322import type { UndeclaredPolicy } from "../../src/types.js" ;
24- import { readArchitecture , renderArchitectureMermaid } from "./arch-lint.js" ;
23+ import { type Architecture , readArchitecture , renderArchitectureMermaid } from "./arch-lint.js" ;
2524import { COVERAGE_DATA , type CoverageData } from "./coverage-data.js" ;
2625import { ENDPOINT_ANCHORS , type EndpointAnchors } from "./endpoint-docs.js" ;
27- import { type GeneratedRegion , regenerateRegions , regionBounds } from "./lib/generated-regions.js" ;
26+ import {
27+ block ,
28+ blockLine ,
29+ blockLines ,
30+ GeneratedRegion ,
31+ regenerateRegions ,
32+ regionBounds ,
33+ } from "./lib/generated-regions.js" ;
2834
2935const ROOT = join ( import . meta. dir , ".." , ".." ) ;
3036export const COVERAGE_PATH = "docs/reference/coverage.md" ;
@@ -121,18 +127,21 @@ export function sectionsCells(
121127}
122128
123129// The generator's boundary: the table rule is the author's to meet, so a faulty row stops the build naming it.
124- export function renderSectionsTable (
125- sections : readonly SectionsTableRow [ ] ,
126- docs : Readonly < Record < string , Pick < SectionDocs , "sections_table" > > > ,
127- ) : string {
128- const cells = sectionsCells ( sections , docs ) ;
130+ export function renderSectionsRows ( cells : ReadonlyArray < readonly string [ ] > ) : string {
129131 const fault = tableFault ( SECTIONS_TABLE_HEADER , cells , "keyed" ) ;
130132 if ( fault !== undefined ) {
131133 throw new Error ( fault ) ;
132134 }
133135 return renderTable ( SECTIONS_TABLE_HEADER , cells ) ;
134136}
135137
138+ export function renderSectionsTable (
139+ sections : readonly SectionsTableRow [ ] ,
140+ docs : Readonly < Record < string , Pick < SectionDocs , "sections_table" > > > ,
141+ ) : string {
142+ return renderSectionsRows ( sectionsCells ( sections , docs ) ) ;
143+ }
144+
136145/**
137146 * A cell of authored prose, refused rather than escaped: an escape would silently change the rendered text.
138147 * `where` names the cell as its author knows it.
@@ -622,14 +631,26 @@ function parseCoveragePage(body: string): Result<CoveragePage, string> {
622631 } ) ;
623632}
624633
625- /** The prose after the enumeration; the region's body regex admits exactly this tail . */
634+ /** The prose after the enumeration, read back as the one tail the guard admits . */
626635const RESULT_TAIL =
627636 ", worst first across the run's targets; the exit code is 1 exactly when it is `failed`, or `drift` in mode: check" ;
628637
629- export function renderOutputsList ( results : readonly RunOutcome [ ] ) : string {
638+ export function renderOutputsList ( results : readonly string [ ] ) : string {
630639 return `${ results . map ( ( value ) => `\`${ value } \`` ) . join ( " / " ) } ${ RESULT_TAIL } ` ;
631640}
632641
642+ const OUTPUTS_LIST = new RegExp (
643+ String . raw `^(\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*)${ RegExp . escape ( RESULT_TAIL ) } $` ,
644+ ) ;
645+
646+ /** The outcome words of an inline outputs list, which sits on the BEGIN marker's own line. */
647+ function parseOutputsList ( body : string ) : Result < readonly string [ ] , string > {
648+ const list = OUTPUTS_LIST . exec ( body ) ?. [ 1 ] ;
649+ return list === undefined
650+ ? err ( "line 0 is not the outcome words in code spans followed by the tail" )
651+ : ok ( list . split ( " / " ) . map ( ( span ) => span . slice ( 1 , - 1 ) ) ) ;
652+ }
653+
633654export type TaggedOperation = Pick < SectionOperation , "role" | "grade" | "permission" > & {
634655 readonly section : string ;
635656} ;
@@ -700,46 +721,99 @@ function sectionsRow(cells: readonly string[]): Result<readonly string[], string
700721}
701722
702723function sectionsTableRegion ( name : string , heading : string ) : GeneratedRegion {
703- return {
724+ return GeneratedRegion . of ( {
704725 name,
705726 placement : { kind : "under-heading" , heading } ,
706- body : tableRoundTrip ( SECTIONS_TABLE_HEADER , "keyed" , sectionsRow , ( rows ) =>
707- renderTable ( SECTIONS_TABLE_HEADER , rows ) ,
708- ) ,
709- render : ( ) => `\n${ renderSectionsTable ( SECTIONS , DOCS ) } \n` ,
710- } ;
727+ data : ( ) => sectionsCells ( SECTIONS , DOCS ) ,
728+ render : block ( renderSectionsRows ) ,
729+ parse : tableBody ( SECTIONS_TABLE_HEADER , "keyed" , sectionsRow ) ,
730+ } ) ;
711731}
712732
713733function outputsListRegion ( name : string , heading : string ) : GeneratedRegion {
714- return {
734+ return GeneratedRegion . of < readonly string [ ] > ( {
715735 name,
716736 placement : { kind : "under-heading" , heading } ,
717- body : new RegExp (
718- String . raw `^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${ RegExp . escape ( RESULT_TAIL ) } )?$` ,
719- ) ,
720- render : ( ) => renderOutputsList ( RUN_RESULTS ) ,
721- } ;
737+ data : ( ) => RUN_RESULTS ,
738+ render : renderOutputsList ,
739+ parse : parseOutputsList ,
740+ } ) ;
722741}
723742
743+ const PAT_FORM_DEFINITION = new RegExp ( String . raw `^\[${ RegExp . escape ( PAT_FORM_LABEL ) } \]: (\S+)$` ) ;
744+
724745/** The page's `[...][pat-form]` reference resolves through this tail definition. */
725746function patUrlRegion ( name : string ) : GeneratedRegion {
726- return {
747+ return GeneratedRegion . of ( {
727748 name,
728749 placement : { kind : "tail" } ,
729- body : new RegExp ( String . raw `^\n(?:\[${ RegExp . escape ( PAT_FORM_LABEL ) } \]: \S+\n)?$` ) ,
730- render : ( ) => `\n[${ PAT_FORM_LABEL } ]: ${ patFormUrl ( ) } \n` ,
731- } ;
750+ data : patFormUrl ,
751+ render : block ( ( url : string ) => `[${ PAT_FORM_LABEL } ]: ${ url } ` ) ,
752+ parse : ( body ) =>
753+ blockLine ( body ) . andThen ( ( line ) => {
754+ const url = PAT_FORM_DEFINITION . exec ( line ) ?. [ 1 ] ;
755+ return url === undefined
756+ ? err ( `line 1 is not the [${ PAT_FORM_LABEL } ] definition` )
757+ : ok ( url ) ;
758+ } ) ,
759+ } ) ;
760+ }
761+
762+ const MERMAID_OPEN = "```mermaid" ;
763+ const FENCE = "```" ;
764+ const GRAPH_LINE = "graph TD" ;
765+ // A node id is whatever the renderer makes of a layer name (only "-" is rewritten), so the byte compare judges it.
766+ const NODE_LINE = / ^ { 2 } ( \S + ) \[ " ( [ ^ " ] * ) " \] $ / ;
767+ const EDGE_LINE = / ^ { 2 } ( \S + ) - - > ( \S + ) $ / ;
768+
769+ /** What the module map draws: the layers with their paths and the import edges; the lint's exclusions are not drawn. */
770+ type ArchitectureMap = Pick < Architecture , "layers" | "edges" > ;
771+
772+ /**
773+ * The diagram read back into the layers and edges it draws, under the node ids the renderer writes; a line that is
774+ * neither a node nor an edge is authored. Entries are collected before the records are built, so an id such as
775+ * `__proto__` is a key and not a prototype write.
776+ */
777+ function parseArchitectureMap ( body : string ) : Result < ArchitectureMap , string > {
778+ return blockLines ( body ) . andThen ( ( lines ) => {
779+ if ( lines [ 0 ] !== MERMAID_OPEN ) {
780+ return err ( "line 1 does not open the mermaid fence" ) ;
781+ }
782+ if ( lines [ 1 ] !== GRAPH_LINE ) {
783+ return err ( `line 2 is not the "${ GRAPH_LINE } " line` ) ;
784+ }
785+ if ( lines . length < 3 || lines . at ( - 1 ) !== FENCE ) {
786+ return err ( `line ${ lines . length } does not close the mermaid fence` ) ;
787+ }
788+ const layers : Array < [ string , readonly string [ ] ] > = [ ] ;
789+ const edges = new Map < string , string [ ] > ( ) ;
790+ for ( const [ index , line ] of lines . slice ( 2 , - 1 ) . entries ( ) ) {
791+ const node = NODE_LINE . exec ( line ) ;
792+ const edge = EDGE_LINE . exec ( line ) ;
793+ if ( node !== null ) {
794+ layers . push ( [ node [ 1 ] ?? "" , ( node [ 2 ] ?? "" ) . split ( "<br>" ) ] ) ;
795+ } else if ( edge !== null ) {
796+ const from = edge [ 1 ] ?? "" ;
797+ edges . set ( from , [ ...( edges . get ( from ) ?? [ ] ) , edge [ 2 ] ?? "" ] ) ;
798+ } else {
799+ return err ( `line ${ index + 3 } is neither a layer node nor an import edge` ) ;
800+ }
801+ }
802+ return ok ( { layers : Object . fromEntries ( layers ) , edges : Object . fromEntries ( edges ) } ) ;
803+ } ) ;
732804}
733805
734806// Rendered from architecture.yml, which the lint keeps equal to the tree.
735807function architectureMapRegion ( name : string , heading : string ) : GeneratedRegion {
736- return {
808+ return GeneratedRegion . of < ArchitectureMap > ( {
737809 name,
738810 placement : { kind : "under-heading" , heading } ,
739- body : / ^ \n (?: \x60 { 3 } m e r m a i d \n (?: [ ^ \x60 \n ] [ ^ \n ] * \n ) * \x60 { 3 } \n ) ? $ / ,
740- render : ( ) =>
741- `\n\x60\x60\x60mermaid\n${ renderArchitectureMermaid ( readArchitecture ( ROOT ) ) } \n\x60\x60\x60\n` ,
742- } ;
811+ data : ( ) => readArchitecture ( ROOT ) ,
812+ render : block (
813+ ( map ) => `${ MERMAID_OPEN } \n${ renderArchitectureMermaid ( { ...map , exclude : [ ] } ) } \n${ FENCE } ` ,
814+ ) ,
815+ parse : parseArchitectureMap ,
816+ } ) ;
743817}
744818
745819export const PAGE_REGIONS : Readonly < Record < string , readonly GeneratedRegion [ ] > > = {
@@ -779,12 +853,13 @@ export function renderPage(path: string, text: string): string {
779853// The one region closes the file and holds everything below the title (or an empty body between fresh markers), so
780854// a marker moved over authored prose fails instead of erasing it.
781855const COVERAGE_REGIONS : readonly GeneratedRegion [ ] = [
782- {
856+ GeneratedRegion . of ( {
783857 name : "coverage" ,
784858 placement : { kind : "tail" } ,
785- body : roundTrip ( parseCoveragePage , renderCoveragePage ) ,
786- render : ( ) => `\n${ renderCoverage ( SECTIONS , DOCS , COVERAGE_DATA , ENDPOINT_ANCHORS ) } \n` ,
787- } ,
859+ data : ( ) => coveragePage ( SECTIONS , DOCS , COVERAGE_DATA , ENDPOINT_ANCHORS ) ,
860+ render : block ( renderCoveragePage ) ,
861+ parse : parseCoveragePage ,
862+ } ) ,
788863] ;
789864
790865/** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */
0 commit comments