Repository navigation
Expand file tree
/
Copy pathnumber-decisions.js
More file actions
139 lines (122 loc) · 6 KB
/
Copy pathnumber-decisions.js
File metadata and controls
139 lines (122 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// <copyright>
// Copyright by the Spark Development Network
//
// Licensed under the Rock Community License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.rockrms.com/license
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>
//
/*
Gives placeholder decisions their real numbers. See AGENTS.md and docs/maintaining.md.
On a branch, a new decision in docs/decisions.md gets a placeholder in its # column, such as
D-example-1, and is cited by that placeholder everywhere ("decision D-example-1"). Branches never take
real numbers, so two branches cannot claim the same one. When a branch merges into main, run this
script: it numbers the placeholders from the next free number, in the order they appear in the
decision log, and replaces every mention in the repository's tracked text files, of any type.
Usage:
node docs/number-decisions.js Numbers every placeholder and rewrites the files.
node docs/number-decisions.js --dry-run Shows what it would do, and changes nothing.
node docs/number-decisions.js --check Exits with 1 if any placeholder is left anywhere (for main).
--check looks at every tracked text file, not only the decision log, so a stale mention of a
placeholder elsewhere fails too. The samples in the docs use the reserved slug "example"
(D-example-1), which --check ignores and the script never numbers.
*/
const fs = require( "fs" );
const path = require( "path" );
const { execFileSync } = require( "child_process" );
const root = path.join( __dirname, ".." );
const decisionsPath = path.join( __dirname, "decisions.md" );
const isDryRun = process.argv.includes( "--dry-run" );
const isCheck = process.argv.includes( "--check" );
// A placeholder: "D-", a lowercase slug, "-", and a number, such as D-example-1 or D-example-teams-12.
const PLACEHOLDER = /D-[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*-\d+/;
const ROW = new RegExp( `^\\| (\\d+|${PLACEHOLDER.source}) \\|`, "gm" );
// Every tracked text file is searched, whatever its type, so a mention in a new kind of file (a workflow's
// .yml, a site's .css) is never missed. Binary files are skipped by type, and by a null byte for any other.
const BINARY_EXTENSIONS = new Set( [ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".ico", ".ttf", ".otf", ".woff", ".woff2", ".xlsx", ".zip", ".pdf", ".bak" ] );
const decisions = fs.readFileSync( decisionsPath, "utf8" );
const numbers = [];
const placeholders = [];
for ( const match of decisions.matchAll( ROW ) ) {
if ( /^\d+$/.test( match[ 1 ] ) ) {
numbers.push( Number( match[ 1 ] ) );
}
else if ( !placeholders.includes( match[ 1 ] ) ) {
placeholders.push( match[ 1 ] );
}
}
if ( isCheck ) {
if ( placeholders.length ) {
console.error( `Decisions still have placeholders: ${placeholders.join( ", " )}. Run node docs/number-decisions.js.` );
process.exit( 1 );
}
// A placeholder mentioned anywhere else, outside the reserved samples, was missed or is stale.
const mention = new RegExp( `(?<![A-Za-z0-9-])${PLACEHOLDER.source}(?![A-Za-z0-9])`, "g" );
const stale = [];
for ( const { file, text } of textFiles() ) {
const found = [ ...new Set( ( text.match( mention ) || [] ).filter( p => !p.startsWith( "D-example-" ) ) ) ];
if ( found.length ) {
stale.push( `${file}: ${found.join( ", " )}` );
}
}
if ( stale.length ) {
console.error( `Placeholders are still mentioned outside the decision log, so they were never numbered:\n ${stale.join( "\n " )}` );
process.exit( 1 );
}
console.log( "Every decision has a number." );
process.exit( 0 );
}
if ( !placeholders.length ) {
console.log( "No placeholder decisions to number." );
process.exit( 0 );
}
const duplicates = numbers.filter( ( n, i ) => numbers.indexOf( n ) !== i );
if ( duplicates.length ) {
console.error( `docs/decisions.md uses these numbers twice: ${[ ...new Set( duplicates ) ].join( ", " )}. Fix them by hand first.` );
process.exit( 1 );
}
let next = Math.max( 0, ...numbers ) + 1;
const assigned = new Map( placeholders.map( p => [ p, String( next++ ) ] ) );
for ( const [ placeholder, number ] of assigned ) {
console.log( `${placeholder} -> ${number}` );
}
// Longest first, so D-example-12 is never rewritten as D-example-1 followed by "2".
const pattern = new RegExp( `(?<![A-Za-z0-9-])(${[ ...assigned.keys() ].sort( ( a, b ) => b.length - a.length ).join( "|" )})(?![A-Za-z0-9])`, "g" );
let changedFiles = 0;
for ( const { file, full, text } of textFiles() ) {
const updated = text.replace( pattern, p => assigned.get( p ) );
if ( updated !== text ) {
changedFiles++;
console.log( ` ${isDryRun ? "would update" : "updated"} ${file}` );
if ( !isDryRun ) {
fs.writeFileSync( full, updated );
}
}
}
console.log( `${assigned.size} decision${assigned.size === 1 ? "" : "s"} numbered in ${changedFiles} file${changedFiles === 1 ? "" : "s"}${isDryRun ? " (dry run, nothing changed)" : ""}.` );
/**
* Every tracked text file, with its text. Binary files are skipped by type, and by a null byte for any other.
*/
function* textFiles() {
const files = execFileSync( "git", [ "ls-files" ], { cwd: root, encoding: "utf8" } )
.split( /\r?\n/ )
.filter( f => f && !BINARY_EXTENSIONS.has( path.extname( f ).toLowerCase() ) );
for ( const file of files ) {
const full = path.join( root, file );
if ( !fs.existsSync( full ) ) {
continue;
}
const bytes = fs.readFileSync( full );
if ( !bytes.includes( 0 ) ) {
yield { file, full, text: bytes.toString( "utf8" ) };
}
}
}