mandrel-platform 0.17.2 → 0.19.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.
- package/README.md +254 -34
- package/config/commitlint.base.mjs +36 -0
- package/config/edge-security/rate-limit.mjs +103 -20
- package/config/repo-settings.schema.json +78 -0
- package/default.json +4 -19
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -54,10 +54,23 @@ import { join, resolve } from "node:path";
|
|
|
54
54
|
|
|
55
55
|
export const VALID_METRICS = ["lines", "statements", "functions", "branches"];
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Assert that a value-taking flag at index `i` is actually followed by a
|
|
59
|
+
* value token. Throws otherwise so a trailing/valueless flag fails loudly
|
|
60
|
+
* instead of falling through to a silent default (e.g. a bare `--threshold`
|
|
61
|
+
* must not leave the gate disabled at threshold 0).
|
|
62
|
+
*/
|
|
63
|
+
export function requireValue(flag, argv, i) {
|
|
64
|
+
if (argv[i + 1] === undefined) {
|
|
65
|
+
throw new Error(`flag "${flag}" requires a value`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
/**
|
|
58
70
|
* Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
|
|
59
|
-
* Throws on a malformed numeric threshold
|
|
60
|
-
*
|
|
71
|
+
* Throws on a malformed numeric threshold, an unknown metric, an unknown
|
|
72
|
+
* flag, or a valueless value-taking flag so the gate fails loudly rather
|
|
73
|
+
* than silently mis-reading its own configuration.
|
|
61
74
|
*/
|
|
62
75
|
export function parseArgs(argv) {
|
|
63
76
|
const opts = {
|
|
@@ -66,11 +79,25 @@ export function parseArgs(argv) {
|
|
|
66
79
|
coverageDirs: [],
|
|
67
80
|
cwd: process.cwd(),
|
|
68
81
|
};
|
|
82
|
+
// Value-taking flags. Each MUST be followed by a value token; a trailing
|
|
83
|
+
// (valueless) occurrence is a hard error rather than a silent skip — a
|
|
84
|
+
// valueless `--threshold` must never leave the gate at its disabled default.
|
|
85
|
+
const VALUE_FLAGS = new Set([
|
|
86
|
+
"--threshold",
|
|
87
|
+
"-t",
|
|
88
|
+
"--metric",
|
|
89
|
+
"-m",
|
|
90
|
+
"--coverage-dir",
|
|
91
|
+
"--cwd",
|
|
92
|
+
]);
|
|
93
|
+
|
|
69
94
|
for (let i = 0; i < argv.length; i++) {
|
|
70
95
|
const arg = argv[i];
|
|
71
|
-
if (
|
|
96
|
+
if (arg === "--threshold" || arg === "-t") {
|
|
97
|
+
requireValue(arg, argv, i);
|
|
72
98
|
opts.threshold = parseThreshold(argv[++i]);
|
|
73
|
-
} else if (
|
|
99
|
+
} else if (arg === "--metric" || arg === "-m") {
|
|
100
|
+
requireValue(arg, argv, i);
|
|
74
101
|
const metric = String(argv[++i]).trim().toLowerCase();
|
|
75
102
|
if (!VALID_METRICS.includes(metric)) {
|
|
76
103
|
throw new Error(
|
|
@@ -78,10 +105,21 @@ export function parseArgs(argv) {
|
|
|
78
105
|
);
|
|
79
106
|
}
|
|
80
107
|
opts.metric = metric;
|
|
81
|
-
} else if (arg === "--coverage-dir"
|
|
108
|
+
} else if (arg === "--coverage-dir") {
|
|
109
|
+
requireValue(arg, argv, i);
|
|
82
110
|
opts.coverageDirs.push(String(argv[++i]));
|
|
83
|
-
} else if (arg === "--cwd"
|
|
111
|
+
} else if (arg === "--cwd") {
|
|
112
|
+
requireValue(arg, argv, i);
|
|
84
113
|
opts.cwd = String(argv[++i]);
|
|
114
|
+
} else if (arg.startsWith("-")) {
|
|
115
|
+
// An unknown flag (e.g. a typo'd `--threshhold`) MUST fail loudly. Left
|
|
116
|
+
// unhandled it would be silently ignored, leaving `--threshold` at its
|
|
117
|
+
// 0 default and disabling the gate — the exact fail-open we forbid.
|
|
118
|
+
throw new Error(
|
|
119
|
+
`unknown flag "${arg}" (expected one of: ${[...VALUE_FLAGS].join(", ")})`
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
throw new Error(`unexpected positional argument "${arg}"`);
|
|
85
123
|
}
|
|
86
124
|
}
|
|
87
125
|
return opts;
|
|
@@ -121,6 +121,49 @@ test("parseArgs rejects an unknown --metric", () => {
|
|
|
121
121
|
assert.throws(() => parseArgs(["--metric", "nonsense"]), /unknown --metric/);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("parseArgs rejects a valueless --threshold (must not silently disable the gate)", () => {
|
|
125
|
+
// A bare trailing `--threshold` previously fell through and left the
|
|
126
|
+
// threshold at its 0 default — silently turning the gate OFF. It MUST now
|
|
127
|
+
// throw instead.
|
|
128
|
+
assert.throws(() => parseArgs(["--threshold"]), /requires a value/);
|
|
129
|
+
assert.throws(() => parseArgs(["-t"]), /requires a value/);
|
|
130
|
+
// Same for the other value-taking flags.
|
|
131
|
+
assert.throws(() => parseArgs(["--metric"]), /requires a value/);
|
|
132
|
+
assert.throws(() => parseArgs(["--coverage-dir"]), /requires a value/);
|
|
133
|
+
assert.throws(() => parseArgs(["--cwd"]), /requires a value/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("parseArgs rejects a mistyped/unknown flag rather than ignoring it", () => {
|
|
137
|
+
// A typo like `--threshhold 80` used to be silently dropped, disabling the
|
|
138
|
+
// gate. It MUST now fail loudly.
|
|
139
|
+
assert.throws(() => parseArgs(["--threshhold", "80"]), /unknown flag/);
|
|
140
|
+
assert.throws(() => parseArgs(["--nope"]), /unknown flag/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("parseArgs rejects unexpected positional arguments", () => {
|
|
144
|
+
assert.throws(() => parseArgs(["80"]), /unexpected positional argument/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("runCli: a mistyped threshold flag exits non-zero instead of passing silently", () => {
|
|
148
|
+
const out = [];
|
|
149
|
+
const code = runCli(["--threshhold", "80"], {
|
|
150
|
+
log: (m) => out.push(m),
|
|
151
|
+
err: (m) => out.push(m),
|
|
152
|
+
});
|
|
153
|
+
assert.equal(code, 1);
|
|
154
|
+
assert.ok(out.some((l) => /unknown flag/.test(l)));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("runCli: a valueless threshold flag exits non-zero instead of passing silently", () => {
|
|
158
|
+
const out = [];
|
|
159
|
+
const code = runCli(["--threshold"], {
|
|
160
|
+
log: (m) => out.push(m),
|
|
161
|
+
err: (m) => out.push(m),
|
|
162
|
+
});
|
|
163
|
+
assert.equal(code, 1);
|
|
164
|
+
assert.ok(out.some((l) => /requires a value/.test(l)));
|
|
165
|
+
});
|
|
166
|
+
|
|
124
167
|
test("VALID_METRICS covers the four Istanbul totals", () => {
|
|
125
168
|
assert.deepEqual(VALID_METRICS, [
|
|
126
169
|
"lines",
|
|
@@ -23,43 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
-
import { join, relative, extname } from 'node:path';
|
|
26
|
+
import { join, relative, extname, resolve } from 'node:path';
|
|
27
27
|
import { parseArgs } from 'node:util';
|
|
28
28
|
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// CLI argument parsing
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
const { values: argv } = parseArgs({
|
|
34
|
-
options: {
|
|
35
|
-
dir: { type: 'string', default: 'docs' },
|
|
36
|
-
'warn-only': { type: 'boolean', default: false },
|
|
37
|
-
quiet: { type: 'boolean', default: false },
|
|
38
|
-
help: { type: 'boolean', default: false },
|
|
39
|
-
},
|
|
40
|
-
strict: false,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
if (argv.help) {
|
|
44
|
-
console.log(`
|
|
45
|
-
check-docs-staleness.mjs — docs staleness lint for mandrel-platform consumers
|
|
46
|
-
|
|
47
|
-
Usage:
|
|
48
|
-
node scripts/check-docs-staleness.mjs [options]
|
|
49
|
-
|
|
50
|
-
Options:
|
|
51
|
-
--dir <path> Directory to scan (default: docs/)
|
|
52
|
-
--warn-only Exit 0 even when issues are found
|
|
53
|
-
--quiet Suppress per-file output; only print summary
|
|
54
|
-
--help Print this help and exit
|
|
55
|
-
`);
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const SCAN_DIR = argv.dir ?? 'docs';
|
|
60
|
-
const WARN_ONLY = argv['warn-only'] ?? false;
|
|
61
|
-
const QUIET = argv.quiet ?? false;
|
|
62
|
-
|
|
63
29
|
// ---------------------------------------------------------------------------
|
|
64
30
|
// Staleness patterns
|
|
65
31
|
//
|
|
@@ -74,7 +40,7 @@ const QUIET = argv.quiet ?? false;
|
|
|
74
40
|
// flagged text to suppress a specific rule for that occurrence.
|
|
75
41
|
// ---------------------------------------------------------------------------
|
|
76
42
|
|
|
77
|
-
const RULES = [
|
|
43
|
+
export const RULES = [
|
|
78
44
|
{
|
|
79
45
|
id: 'pages-deploy-command',
|
|
80
46
|
description: 'References `wrangler pages deploy` — may be stale if the project has migrated web to a Worker',
|
|
@@ -130,12 +96,50 @@ const RULES = [
|
|
|
130
96
|
{
|
|
131
97
|
id: 'expired-placeholder',
|
|
132
98
|
description: 'Placeholder date that has passed (YYYY-MM-DD pattern in an expiry/todo context)',
|
|
133
|
-
// Matches explicit expiry dates like "expires: 2025-01-01"
|
|
134
|
-
|
|
99
|
+
// Matches explicit expiry dates like "expires: 2025-01-01" for ANY 20xx
|
|
100
|
+
// year. The hardcoded 2020–2024 window meant an expiry that lapsed in
|
|
101
|
+
// 2025/2026 (or any later year) sailed through the gate. We now match any
|
|
102
|
+
// 4-digit 20xx year and defer the "is it actually in the past?" decision
|
|
103
|
+
// to `matchFilter`, so the rule stays correct as the calendar advances and
|
|
104
|
+
// never flags a still-valid FUTURE expiry.
|
|
105
|
+
pattern: /expires[:\s]+(20\d{2}-\d{2}-\d{2})/gi,
|
|
135
106
|
severity: 'error',
|
|
107
|
+
// Only flag when the captured date is strictly before today (UTC). Future
|
|
108
|
+
// expiries are still valid and must not be reported.
|
|
109
|
+
matchFilter: (match, { now = new Date() } = {}) => isExpiredDate(match, now),
|
|
136
110
|
},
|
|
137
111
|
];
|
|
138
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Date helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Given a full regex match like "expires: 2025-03-01", extract the YYYY-MM-DD
|
|
119
|
+
* date and return true when it is strictly before `now` (i.e. it has expired).
|
|
120
|
+
* Malformed / unparseable dates return false (nothing to flag).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} matchText The full matched substring (e.g. "expires: 2025-03-01").
|
|
123
|
+
* @param {Date} now Reference "today" (defaults to the current date).
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
export function isExpiredDate(matchText, now = new Date()) {
|
|
127
|
+
const m = /(\d{4})-(\d{2})-(\d{2})/.exec(String(matchText));
|
|
128
|
+
if (!m) return false;
|
|
129
|
+
const [, y, mo, d] = m;
|
|
130
|
+
// Parse as a UTC calendar date to avoid local-timezone drift.
|
|
131
|
+
const dateMs = Date.UTC(Number(y), Number(mo) - 1, Number(d));
|
|
132
|
+
if (Number.isNaN(dateMs)) return false;
|
|
133
|
+
// Compare against today's UTC calendar date (midnight), so an expiry dated
|
|
134
|
+
// strictly earlier than today counts as expired regardless of clock time.
|
|
135
|
+
const todayMs = Date.UTC(
|
|
136
|
+
now.getUTCFullYear(),
|
|
137
|
+
now.getUTCMonth(),
|
|
138
|
+
now.getUTCDate(),
|
|
139
|
+
);
|
|
140
|
+
return dateMs < todayMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
139
143
|
// ---------------------------------------------------------------------------
|
|
140
144
|
// File walker
|
|
141
145
|
// ---------------------------------------------------------------------------
|
|
@@ -145,7 +149,7 @@ const RULES = [
|
|
|
145
149
|
* @param {string} dir
|
|
146
150
|
* @returns {string[]}
|
|
147
151
|
*/
|
|
148
|
-
function walkDir(dir) {
|
|
152
|
+
export function walkDir(dir) {
|
|
149
153
|
const results = [];
|
|
150
154
|
let entries;
|
|
151
155
|
try {
|
|
@@ -179,7 +183,7 @@ function walkDir(dir) {
|
|
|
179
183
|
* @param {string} filePath
|
|
180
184
|
* @returns {Finding[]}
|
|
181
185
|
*/
|
|
182
|
-
function lintFile(filePath) {
|
|
186
|
+
export function lintFile(filePath) {
|
|
183
187
|
const findings = [];
|
|
184
188
|
let content;
|
|
185
189
|
try {
|
|
@@ -205,6 +209,12 @@ function lintFile(filePath) {
|
|
|
205
209
|
rule.pattern.lastIndex = 0;
|
|
206
210
|
let match;
|
|
207
211
|
while ((match = rule.pattern.exec(line)) !== null) {
|
|
212
|
+
// A rule may declare a `matchFilter` predicate to decide, per match,
|
|
213
|
+
// whether the hit is actually a finding (e.g. the expired-placeholder
|
|
214
|
+
// rule only fires when the captured date is in the past).
|
|
215
|
+
if (typeof rule.matchFilter === 'function' && !rule.matchFilter(match[0])) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
208
218
|
findings.push({
|
|
209
219
|
file: filePath,
|
|
210
220
|
line: i + 1,
|
|
@@ -219,59 +229,98 @@ function lintFile(filePath) {
|
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
// ---------------------------------------------------------------------------
|
|
222
|
-
//
|
|
232
|
+
// CLI entrypoint (guarded so `node --test` imports don't run the scan)
|
|
223
233
|
// ---------------------------------------------------------------------------
|
|
224
234
|
|
|
225
|
-
|
|
235
|
+
function main() {
|
|
236
|
+
const { values: argv } = parseArgs({
|
|
237
|
+
options: {
|
|
238
|
+
dir: { type: 'string', default: 'docs' },
|
|
239
|
+
'warn-only': { type: 'boolean', default: false },
|
|
240
|
+
quiet: { type: 'boolean', default: false },
|
|
241
|
+
help: { type: 'boolean', default: false },
|
|
242
|
+
},
|
|
243
|
+
strict: false,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (argv.help) {
|
|
247
|
+
console.log(`
|
|
248
|
+
check-docs-staleness.mjs — docs staleness lint for mandrel-platform consumers
|
|
226
249
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
process.exit(0);
|
|
230
|
-
}
|
|
250
|
+
Usage:
|
|
251
|
+
node scripts/check-docs-staleness.mjs [options]
|
|
231
252
|
|
|
232
|
-
|
|
233
|
-
|
|
253
|
+
Options:
|
|
254
|
+
--dir <path> Directory to scan (default: docs/)
|
|
255
|
+
--warn-only Exit 0 even when issues are found
|
|
256
|
+
--quiet Suppress per-file output; only print summary
|
|
257
|
+
--help Print this help and exit
|
|
258
|
+
`);
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
234
261
|
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
}
|
|
262
|
+
const SCAN_DIR = argv.dir ?? 'docs';
|
|
263
|
+
const WARN_ONLY = argv['warn-only'] ?? false;
|
|
264
|
+
const QUIET = argv.quiet ?? false;
|
|
239
265
|
|
|
240
|
-
|
|
241
|
-
const byFile = new Map();
|
|
242
|
-
for (const finding of allFindings) {
|
|
243
|
-
const key = finding.file;
|
|
244
|
-
if (!byFile.has(key)) byFile.set(key, []);
|
|
245
|
-
byFile.get(key).push(finding);
|
|
246
|
-
}
|
|
266
|
+
const files = walkDir(SCAN_DIR);
|
|
247
267
|
|
|
248
|
-
|
|
249
|
-
|
|
268
|
+
if (files.length === 0) {
|
|
269
|
+
console.log(`[docs-staleness] No files found under '${SCAN_DIR}' — nothing to check.`);
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
250
272
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
273
|
+
/** @type {Finding[]} */
|
|
274
|
+
const allFindings = [];
|
|
275
|
+
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const findings = lintFile(file);
|
|
278
|
+
allFindings.push(...findings);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Group findings by file for readable output
|
|
282
|
+
const byFile = new Map();
|
|
283
|
+
for (const finding of allFindings) {
|
|
284
|
+
const key = finding.file;
|
|
285
|
+
if (!byFile.has(key)) byFile.set(key, []);
|
|
286
|
+
byFile.get(key).push(finding);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const errors = allFindings.filter((f) => f.rule.severity === 'error');
|
|
290
|
+
const warnings = allFindings.filter((f) => f.rule.severity === 'warning');
|
|
291
|
+
|
|
292
|
+
if (!QUIET) {
|
|
293
|
+
for (const [file, findings] of byFile.entries()) {
|
|
294
|
+
const relPath = relative(process.cwd(), file);
|
|
295
|
+
for (const f of findings) {
|
|
296
|
+
const sev = f.rule.severity === 'error' ? 'ERR ' : 'WARN';
|
|
297
|
+
console.log(`[${sev}] ${relPath}:${f.line} — ${f.rule.id}: ${f.rule.description}`);
|
|
298
|
+
console.log(` matched: ${JSON.stringify(f.match)}`);
|
|
299
|
+
}
|
|
258
300
|
}
|
|
259
301
|
}
|
|
260
|
-
}
|
|
261
302
|
|
|
262
|
-
console.log(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
);
|
|
303
|
+
console.log(
|
|
304
|
+
`\n[docs-staleness] Scanned ${files.length} file(s). ` +
|
|
305
|
+
`Found ${errors.length} error(s), ${warnings.length} warning(s).`,
|
|
306
|
+
);
|
|
266
307
|
|
|
267
|
-
if (allFindings.length > 0) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
308
|
+
if (allFindings.length > 0) {
|
|
309
|
+
console.log(`\nTo suppress a specific rule occurrence, add this comment on the line above:`);
|
|
310
|
+
console.log(` <!-- staleness-ignore: <rule-id> -->`);
|
|
311
|
+
console.log(`\nAvailable rule IDs: ${RULES.map((r) => r.id).join(', ')}`);
|
|
312
|
+
}
|
|
272
313
|
|
|
273
|
-
if (errors.length > 0 && !WARN_ONLY) {
|
|
274
|
-
|
|
314
|
+
if (errors.length > 0 && !WARN_ONLY) {
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return 0;
|
|
275
319
|
}
|
|
276
320
|
|
|
277
|
-
|
|
321
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
322
|
+
const invokedDirectly =
|
|
323
|
+
process.argv[1] && resolve(process.argv[1]).endsWith('check-docs-staleness.mjs');
|
|
324
|
+
if (invokedDirectly) {
|
|
325
|
+
process.exit(main());
|
|
326
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-docs-staleness.test.mjs — node:test suite for the docs-staleness lint
|
|
4
|
+
* (Story #197).
|
|
5
|
+
*
|
|
6
|
+
* Focus: the `expired-placeholder` rule. The rule previously hardcoded the
|
|
7
|
+
* years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry that
|
|
8
|
+
* lapsed in 2025, 2026, or any later year sailed through the gate — a
|
|
9
|
+
* fail-open. The fix broadens the pattern to any 20xx year and defers the
|
|
10
|
+
* "is it actually in the past?" decision to `isExpiredDate`, so the rule stays
|
|
11
|
+
* correct as the calendar advances and never flags a still-valid future date.
|
|
12
|
+
*
|
|
13
|
+
* These tests exercise the year fix directly (`isExpiredDate`) and end-to-end
|
|
14
|
+
* (`lintFile` against a real fixture file), pinning "today" via a fixed clock
|
|
15
|
+
* so they are deterministic.
|
|
16
|
+
*
|
|
17
|
+
* Run: node --test scripts/check-docs-staleness.test.mjs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
|
|
26
|
+
import { RULES, lintFile, isExpiredDate } from './check-docs-staleness.mjs';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// isExpiredDate — the year fix, tested directly with a pinned clock.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
// A fixed reference "today" so the tests never depend on the wall clock.
|
|
33
|
+
const NOW = new Date('2026-07-02T12:00:00Z');
|
|
34
|
+
|
|
35
|
+
test('isExpiredDate flags an expiry in the previously-hardcoded window (2020–2024)', () => {
|
|
36
|
+
assert.equal(isExpiredDate('expires: 2020-01-01', NOW), true);
|
|
37
|
+
assert.equal(isExpiredDate('expires: 2024-12-31', NOW), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('isExpiredDate flags an expiry in a recent year OUTSIDE the old window (2025, 2026)', () => {
|
|
41
|
+
// These are the exact dates the old 202[0-4] regex missed.
|
|
42
|
+
assert.equal(isExpiredDate('expires: 2025-01-01', NOW), true);
|
|
43
|
+
assert.equal(isExpiredDate('expires: 2025-12-31', NOW), true);
|
|
44
|
+
assert.equal(isExpiredDate('expires: 2026-01-01', NOW), true);
|
|
45
|
+
assert.equal(isExpiredDate('expires: 2026-07-01', NOW), true); // yesterday
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('isExpiredDate does NOT flag today or a future expiry', () => {
|
|
49
|
+
assert.equal(isExpiredDate('expires: 2026-07-02', NOW), false); // today
|
|
50
|
+
assert.equal(isExpiredDate('expires: 2026-07-03', NOW), false); // tomorrow
|
|
51
|
+
assert.equal(isExpiredDate('expires: 2027-01-01', NOW), false);
|
|
52
|
+
assert.equal(isExpiredDate('expires: 2099-01-01', NOW), false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('isExpiredDate returns false for malformed / dateless input', () => {
|
|
56
|
+
assert.equal(isExpiredDate('expires: soon', NOW), false);
|
|
57
|
+
assert.equal(isExpiredDate('', NOW), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// RULES wiring — the expired-placeholder pattern now matches any 20xx year.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
test('expired-placeholder pattern matches any 20xx year (not just 2020–2024)', () => {
|
|
65
|
+
const rule = RULES.find((r) => r.id === 'expired-placeholder');
|
|
66
|
+
assert.ok(rule, 'expired-placeholder rule must exist');
|
|
67
|
+
for (const line of [
|
|
68
|
+
'expires: 2024-01-01',
|
|
69
|
+
'expires: 2025-06-15',
|
|
70
|
+
'expires: 2026-01-01',
|
|
71
|
+
'expires: 2031-01-01',
|
|
72
|
+
]) {
|
|
73
|
+
rule.pattern.lastIndex = 0;
|
|
74
|
+
assert.ok(
|
|
75
|
+
rule.pattern.test(line),
|
|
76
|
+
`pattern should match "${line}"`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
// A carve-out the fix must preserve: the rule is scoped to 20xx expiry dates.
|
|
80
|
+
rule.pattern.lastIndex = 0;
|
|
81
|
+
assert.equal(rule.pattern.test('expires: 1999-01-01'), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// lintFile — end-to-end against a real fixture file.
|
|
86
|
+
//
|
|
87
|
+
// lintFile's matchFilter uses the real `new Date()` clock, so the fixtures use
|
|
88
|
+
// a clearly-past year (2025) and a clearly-future year to stay deterministic
|
|
89
|
+
// for any run date at or after mid-2026 (this suite ships in 2026+).
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function withTempDoc(contents, fn) {
|
|
93
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-staleness-'));
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
96
|
+
const file = join(root, 'docs', 'note.md');
|
|
97
|
+
writeFileSync(file, contents);
|
|
98
|
+
return fn(file);
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(root, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('lintFile flags an expired 2025 placeholder (missed by the old 2020–2024 rule)', () => {
|
|
105
|
+
withTempDoc('Token rotation.\nexpires: 2025-01-01\nEnd.\n', (file) => {
|
|
106
|
+
const findings = lintFile(file);
|
|
107
|
+
const expired = findings.filter((f) => f.rule.id === 'expired-placeholder');
|
|
108
|
+
assert.equal(expired.length, 1);
|
|
109
|
+
assert.match(expired[0].match, /2025-01-01/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('lintFile does NOT flag a far-future placeholder', () => {
|
|
114
|
+
withTempDoc('Long-lived.\nexpires: 2099-12-31\nDone.\n', (file) => {
|
|
115
|
+
const findings = lintFile(file);
|
|
116
|
+
const expired = findings.filter((f) => f.rule.id === 'expired-placeholder');
|
|
117
|
+
assert.equal(expired.length, 0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('lintFile honours the staleness-ignore suppression comment for the year rule', () => {
|
|
122
|
+
withTempDoc(
|
|
123
|
+
'<!-- staleness-ignore: expired-placeholder -->\nexpires: 2025-01-01\n',
|
|
124
|
+
(file) => {
|
|
125
|
+
const findings = lintFile(file);
|
|
126
|
+
const expired = findings.filter((f) => f.rule.id === 'expired-placeholder');
|
|
127
|
+
assert.equal(expired.length, 0);
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
});
|