mandrel-platform 0.2.1
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 +138 -0
- package/config/biome.base.json +18 -0
- package/config/main-protection.schema.json +63 -0
- package/config/renovate.json +5 -0
- package/config/tsconfig.base.json +17 -0
- package/default.json +106 -0
- package/package.json +33 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/audit-check.mjs +287 -0
- package/scripts/check-docs-staleness.mjs +277 -0
- package/scripts/check-required-contexts.mjs +247 -0
- package/templates/runbooks/.gitkeep +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# mandrel-platform
|
|
2
|
+
|
|
3
|
+
Shared CI/deploy workflows, composite toolchain action, npm config package,
|
|
4
|
+
Renovate preset, and operator runbook templates for the Mandrel platform.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Shared Composite Actions
|
|
9
|
+
|
|
10
|
+
### `setup-toolchain`
|
|
11
|
+
|
|
12
|
+
Installs pnpm (version sourced from the consuming repo's `packageManager` field), Node.js (version sourced from `.nvmrc`), and project dependencies via `pnpm install --frozen-lockfile`.
|
|
13
|
+
|
|
14
|
+
**Reference by SHA** to pin an exact version:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
- name: Setup toolchain
|
|
18
|
+
uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@<sha>
|
|
19
|
+
with:
|
|
20
|
+
cache: 'true' # omit or pass 'false' on self-hosted runners with a warm pnpm store
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Inputs:**
|
|
24
|
+
|
|
25
|
+
| Input | Required | Default | Description |
|
|
26
|
+
| ------- | -------- | ------- | -------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `cache` | No | `true` | Enable pnpm store caching via `actions/setup-node`. Pass `false` on self-hosted runners. |
|
|
28
|
+
|
|
29
|
+
**When to pass `cache: 'false'`:** Self-hosted runners that maintain their own warm pnpm store do not need the `actions/setup-node` pnpm cache layer. Ubuntu runners on `ubuntu-latest` benefit from the default `cache: 'true'`.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Published npm package
|
|
34
|
+
|
|
35
|
+
The `mandrel-platform` npm package exports shared configuration baselines and
|
|
36
|
+
utility scripts so all consumer repos extend the same SSOT instead of
|
|
37
|
+
hand-syncing copies.
|
|
38
|
+
|
|
39
|
+
### `tsconfig.base.json`
|
|
40
|
+
|
|
41
|
+
A strict TypeScript base config — `strict`, `noUncheckedIndexedAccess`,
|
|
42
|
+
`noImplicitOverride`, `verbatimModuleSyntax`, `isolatedModules`,
|
|
43
|
+
`moduleResolution: Bundler`, `target: ES2022` — intended to be extended by
|
|
44
|
+
every consumer.
|
|
45
|
+
|
|
46
|
+
**Consumer usage (`tsconfig.json`):**
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"extends": "mandrel-platform/tsconfig.base.json",
|
|
51
|
+
"compilerOptions": {
|
|
52
|
+
// repo-specific overrides only
|
|
53
|
+
"outDir": "dist"
|
|
54
|
+
},
|
|
55
|
+
"include": ["src"]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `biome.base.json`
|
|
60
|
+
|
|
61
|
+
A Biome base config with the recommended linter rule set, import organizer,
|
|
62
|
+
and standard formatter defaults (2-space indent, 100-char line width).
|
|
63
|
+
|
|
64
|
+
**Consumer usage (`biome.json`):**
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
{
|
|
68
|
+
"extends": ["mandrel-platform/biome.base.json"],
|
|
69
|
+
"files": {
|
|
70
|
+
"ignore": ["dist/", ".wrangler/"]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `scripts/audit-check.mjs`
|
|
76
|
+
|
|
77
|
+
CVE gate script. Runs `pnpm audit --prod` and blocks on any **unsuppressed**
|
|
78
|
+
High or Critical vulnerability in the production dependency graph. This is the
|
|
79
|
+
stricter athportal/swarm-os policy: all unsuppressed High/Critical are
|
|
80
|
+
blocking, not just fixable ones.
|
|
81
|
+
|
|
82
|
+
Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
|
|
83
|
+
(`audit-allowlist.json` in the project root). Expired entries are treated as
|
|
84
|
+
un-suppressed and cause the script to exit non-zero — forcing teams to
|
|
85
|
+
periodically re-evaluate accepted risk.
|
|
86
|
+
|
|
87
|
+
**Consumer usage (`package.json`):**
|
|
88
|
+
|
|
89
|
+
```jsonc
|
|
90
|
+
{
|
|
91
|
+
"scripts": {
|
|
92
|
+
"audit:check": "node node_modules/mandrel-platform/scripts/audit-check.mjs"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Or copy the script into your repo's `scripts/` directory when you need
|
|
98
|
+
local customization (and pin a semver range on `mandrel-platform` so drift
|
|
99
|
+
is detected by Renovate).
|
|
100
|
+
|
|
101
|
+
**Allowlist format (`audit-allowlist.json`):**
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
[
|
|
105
|
+
{
|
|
106
|
+
"id": "GHSA-xxxx-xxxx-xxxx",
|
|
107
|
+
"reason": "No fix available; mitigated by X",
|
|
108
|
+
"expires": "2026-12-31"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- `id` — GitHub Security Advisory ID (`GHSA-*`) or CVE ID (`CVE-*`).
|
|
114
|
+
- `reason` — Human-readable explanation of why this CVE is accepted.
|
|
115
|
+
- `expires` — ISO 8601 date (`YYYY-MM-DD`). **Required.** Entries whose
|
|
116
|
+
expiry date is in the past cause the script to exit non-zero.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Install dependencies
|
|
124
|
+
pnpm install
|
|
125
|
+
|
|
126
|
+
# Bootstrap agent scaffolding
|
|
127
|
+
pnpm run bootstrap
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Package exports
|
|
133
|
+
|
|
134
|
+
| Export | Path |
|
|
135
|
+
| ------------------------------------- | --------------------------- |
|
|
136
|
+
| `mandrel-platform/tsconfig.base.json` | `config/tsconfig.base.json` |
|
|
137
|
+
| `mandrel-platform/biome.base.json` | `config/biome.base.json` |
|
|
138
|
+
| `mandrel-platform/scripts/*` | `scripts/*` |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
|
3
|
+
"organizeImports": {
|
|
4
|
+
"enabled": true
|
|
5
|
+
},
|
|
6
|
+
"linter": {
|
|
7
|
+
"enabled": true,
|
|
8
|
+
"rules": {
|
|
9
|
+
"recommended": true
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"formatter": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"indentStyle": "space",
|
|
15
|
+
"indentWidth": 2,
|
|
16
|
+
"lineWidth": 100
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/dsj1984/mandrel-platform/blob/main/config/main-protection.schema.json",
|
|
4
|
+
"title": "Main Branch Protection Contract",
|
|
5
|
+
"description": "Defines the required status checks and branch protection settings for the main branch. Validated by scripts/check-required-contexts.mjs to prevent phantom-check drift.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["branch", "requiredStatusChecks"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "JSON Schema reference."
|
|
13
|
+
},
|
|
14
|
+
"branch": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "The protected branch name.",
|
|
17
|
+
"default": "main"
|
|
18
|
+
},
|
|
19
|
+
"requiredStatusChecks": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"description": "The exact check context names that must pass before a PR can merge. Keep this list as short as possible — ideally one aggregator context (e.g. ci-required).",
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"minItems": 1
|
|
26
|
+
},
|
|
27
|
+
"aggregatorJob": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "The workflow job whose result is the single required branch-protection context. Must match the first entry in requiredStatusChecks."
|
|
30
|
+
},
|
|
31
|
+
"upstreamJobs": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"description": "The workflow job names that the aggregatorJob depends on. scripts/check-required-contexts.mjs validates these names exist in the workflow files.",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "string"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"enforceAdmins": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "Whether branch protection rules apply to repository administrators.",
|
|
41
|
+
"default": false
|
|
42
|
+
},
|
|
43
|
+
"requireLinearHistory": {
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"description": "Require a linear commit history (no merge commits).",
|
|
46
|
+
"default": false
|
|
47
|
+
},
|
|
48
|
+
"allowForcePushes": {
|
|
49
|
+
"type": "boolean",
|
|
50
|
+
"description": "Allow force pushes to the protected branch.",
|
|
51
|
+
"default": false
|
|
52
|
+
},
|
|
53
|
+
"allowDeletions": {
|
|
54
|
+
"type": "boolean",
|
|
55
|
+
"description": "Allow the protected branch to be deleted.",
|
|
56
|
+
"default": false
|
|
57
|
+
},
|
|
58
|
+
"_note": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Human-readable note for maintainers."
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true,
|
|
5
|
+
"noUncheckedIndexedAccess": true,
|
|
6
|
+
"noImplicitOverride": true,
|
|
7
|
+
"verbatimModuleSyntax": true,
|
|
8
|
+
"isolatedModules": true,
|
|
9
|
+
"moduleResolution": "Bundler",
|
|
10
|
+
"target": "ES2022",
|
|
11
|
+
"lib": ["ES2022"],
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
}
|
|
17
|
+
}
|
package/default.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"description": "Shared Renovate preset for mandrel-platform consumers. Consume as: github>dsj1984/mandrel-platform.",
|
|
4
|
+
"schedule": ["before 9am on Monday"],
|
|
5
|
+
"timezone": "America/New_York",
|
|
6
|
+
"minimumReleaseAge": "3 days",
|
|
7
|
+
"platformAutomerge": true,
|
|
8
|
+
"dependencyDashboard": true,
|
|
9
|
+
"lockFileMaintenance": {
|
|
10
|
+
"enabled": true,
|
|
11
|
+
"schedule": ["before 5am on Monday"]
|
|
12
|
+
},
|
|
13
|
+
"packageRules": [
|
|
14
|
+
{
|
|
15
|
+
"description": "Automerge patch and minor updates",
|
|
16
|
+
"matchUpdateTypes": ["patch", "minor"],
|
|
17
|
+
"automerge": true,
|
|
18
|
+
"automergeType": "pr",
|
|
19
|
+
"platformAutomerge": true
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"description": "Gate major updates behind dependency-dashboard approval",
|
|
23
|
+
"matchUpdateTypes": ["major"],
|
|
24
|
+
"dependencyDashboardApproval": true,
|
|
25
|
+
"automerge": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"description": "Group Astro packages",
|
|
29
|
+
"matchPackagePatterns": ["^astro$", "^@astrojs/"],
|
|
30
|
+
"groupName": "Astro packages",
|
|
31
|
+
"groupSlug": "astro"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"description": "Group Sentry packages",
|
|
35
|
+
"matchPackagePatterns": ["^@sentry/"],
|
|
36
|
+
"groupName": "Sentry packages",
|
|
37
|
+
"groupSlug": "sentry"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"description": "Group ESLint packages",
|
|
41
|
+
"matchPackagePatterns": [
|
|
42
|
+
"^eslint$",
|
|
43
|
+
"^@eslint/",
|
|
44
|
+
"^eslint-config-",
|
|
45
|
+
"^eslint-plugin-",
|
|
46
|
+
"^@typescript-eslint/"
|
|
47
|
+
],
|
|
48
|
+
"groupName": "ESLint packages",
|
|
49
|
+
"groupSlug": "eslint"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"description": "Group Vitest packages",
|
|
53
|
+
"matchPackagePatterns": ["^vitest$", "^@vitest/"],
|
|
54
|
+
"groupName": "Vitest packages",
|
|
55
|
+
"groupSlug": "vitest"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"description": "Group Playwright packages",
|
|
59
|
+
"matchPackagePatterns": ["^playwright$", "^@playwright/"],
|
|
60
|
+
"groupName": "Playwright packages",
|
|
61
|
+
"groupSlug": "playwright"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"description": "Group Cloudflare packages",
|
|
65
|
+
"matchPackagePatterns": ["^wrangler$", "^@cloudflare/", "^miniflare$"],
|
|
66
|
+
"groupName": "Cloudflare packages",
|
|
67
|
+
"groupSlug": "cloudflare"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"description": "Group Clerk packages",
|
|
71
|
+
"matchPackagePatterns": ["^@clerk/"],
|
|
72
|
+
"groupName": "Clerk packages",
|
|
73
|
+
"groupSlug": "clerk"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"description": "Keep .nvmrc and engines.node in lockstep — bump both when Node.js advances",
|
|
77
|
+
"matchManagers": ["nvm"],
|
|
78
|
+
"matchPackageNames": ["node"],
|
|
79
|
+
"postUpdateOptions": ["nodeToolchainFile"],
|
|
80
|
+
"postUpgradeTasks": {
|
|
81
|
+
"commands": [
|
|
82
|
+
"node -e \"const fs=require('fs'),path=require('path');const ver=fs.readFileSync('.nvmrc','utf8').trim().replace(/^v/,'');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&e.name==='package.json'?[p]:[]});}walk('.').forEach(f=>{try{const pkg=JSON.parse(fs.readFileSync(f,'utf8'));if(pkg.engines&&pkg.engines.node){pkg.engines.node=ver;fs.writeFileSync(f,JSON.stringify(pkg,null,2)+'\\n','utf8');console.log('Updated engines.node to '+ver+' in '+f);}}catch(e){}});\""
|
|
83
|
+
],
|
|
84
|
+
"fileFilters": ["**/.nvmrc", "**/package.json"]
|
|
85
|
+
},
|
|
86
|
+
"automerge": true,
|
|
87
|
+
"automergeType": "pr",
|
|
88
|
+
"platformAutomerge": true
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"description": "Advance wrangler compatibility_date to today when wrangler is bumped",
|
|
92
|
+
"matchManagers": ["npm"],
|
|
93
|
+
"matchPackageNames": ["wrangler"],
|
|
94
|
+
"postUpgradeTasks": {
|
|
95
|
+
"commands": [
|
|
96
|
+
"node -e \"const fs=require('fs'),path=require('path');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&/wrangler\\.(json|jsonc|toml)$/.test(e.name)?[p]:[]});}const today=new Date().toISOString().slice(0,10);walk('.').forEach(f=>{let c=fs.readFileSync(f,'utf8');const u=c.replace(/compatibility_date\\s*=\\s*\\\"[0-9-]+\\\"/g,'compatibility_date = \\\"'+today+'\\\"').replace(/\\\"compatibility_date\\\"\\s*:\\s*\\\"[0-9-]+\\\"/g,'\\\"compatibility_date\\\": \\\"'+today+'\\\"');if(u!==c){fs.writeFileSync(f,u,'utf8');console.log('Updated compatibility_date in '+f);}});\""
|
|
97
|
+
],
|
|
98
|
+
"fileFilters": [
|
|
99
|
+
"**/wrangler.json",
|
|
100
|
+
"**/wrangler.jsonc",
|
|
101
|
+
"**/wrangler.toml"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mandrel-platform",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "24.16.0",
|
|
8
|
+
"pnpm": ">=11.5.2"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./tsconfig.base.json": "./config/tsconfig.base.json",
|
|
12
|
+
"./biome.base.json": "./config/biome.base.json",
|
|
13
|
+
"./scripts/*": "./scripts/*"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"config/",
|
|
17
|
+
"default.json",
|
|
18
|
+
"scripts/",
|
|
19
|
+
"templates/"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"mandrel": "^1.78.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
26
|
+
"lint": "node --input-type=module --eval 'process.exit(0)'",
|
|
27
|
+
"test": "node --input-type=module --eval 'process.exit(0)'",
|
|
28
|
+
"sync:commands": "node .agents/scripts/sync-claude-commands.js",
|
|
29
|
+
"bootstrap": "node .agents/scripts/bootstrap.js",
|
|
30
|
+
"quality:preview": "node .agents/scripts/quality-preview.js --changed-since HEAD",
|
|
31
|
+
"quality:watch": "node .agents/scripts/quality-watch.js"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/scripts/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* audit-check.mjs
|
|
4
|
+
*
|
|
5
|
+
* CVE gate for the mandrel-platform npm package.
|
|
6
|
+
*
|
|
7
|
+
* Policy (athportal/swarm-os stricter variant):
|
|
8
|
+
* Block ALL unsuppressed High and Critical vulnerabilities in the
|
|
9
|
+
* production dependency graph. A self-expiring allowlist lets teams
|
|
10
|
+
* record known, accepted CVEs with a required expiry date — entries
|
|
11
|
+
* whose expiry has passed are treated as un-suppressed and will cause
|
|
12
|
+
* the script to exit non-zero.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/audit-check.mjs
|
|
16
|
+
* node scripts/audit-check.mjs --allowlist path/to/allowlist.json
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 — no blocking vulnerabilities (all High/Critical suppressed with
|
|
20
|
+
* valid, non-expired allowlist entries, or none found)
|
|
21
|
+
* 1 — one or more unsuppressed High/Critical CVEs, or expired allowlist
|
|
22
|
+
* entries were encountered
|
|
23
|
+
*
|
|
24
|
+
* Allowlist format (JSON):
|
|
25
|
+
* [
|
|
26
|
+
* {
|
|
27
|
+
* "id": "GHSA-xxxx-xxxx-xxxx", // GitHub Advisory ID or CVE ID
|
|
28
|
+
* "reason": "No fix available; mitigated by X",
|
|
29
|
+
* "expires": "2026-12-31" // ISO 8601 date — REQUIRED
|
|
30
|
+
* }
|
|
31
|
+
* ]
|
|
32
|
+
*
|
|
33
|
+
* The allowlist file path defaults to `audit-allowlist.json` in the
|
|
34
|
+
* directory from which this script is invoked (i.e. the project root).
|
|
35
|
+
* Override with `--allowlist <path>`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execSync } from "node:child_process";
|
|
39
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
40
|
+
import { resolve } from "node:path";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// CLI arg parsing
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
let allowlistPath = null;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
if (args[i] === "--allowlist" && args[i + 1]) {
|
|
51
|
+
allowlistPath = resolve(process.cwd(), args[i + 1]);
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (allowlistPath === null) {
|
|
57
|
+
allowlistPath = resolve(process.cwd(), "audit-allowlist.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Allowlist loading and validation
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {{ id: string; reason: string; expires: string }} AllowlistEntry
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** @type {AllowlistEntry[]} */
|
|
69
|
+
let allowlist = [];
|
|
70
|
+
|
|
71
|
+
if (existsSync(allowlistPath)) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(allowlistPath, "utf8");
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
|
|
76
|
+
if (!Array.isArray(parsed)) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[audit-check] ERROR: Allowlist at ${allowlistPath} must be a JSON array.`,
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
allowlist = parsed;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(
|
|
86
|
+
`[audit-check] ERROR: Failed to parse allowlist at ${allowlistPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
93
|
+
|
|
94
|
+
/** @type {Set<string>} Active (non-expired) suppressed advisory IDs */
|
|
95
|
+
const suppressed = new Set();
|
|
96
|
+
/** @type {AllowlistEntry[]} */
|
|
97
|
+
const expiredEntries = [];
|
|
98
|
+
|
|
99
|
+
for (const entry of allowlist) {
|
|
100
|
+
if (!entry.id || !entry.expires) {
|
|
101
|
+
console.error(
|
|
102
|
+
`[audit-check] ERROR: Allowlist entry missing required "id" or "expires" field: ${JSON.stringify(entry)}`,
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.expires < today) {
|
|
108
|
+
expiredEntries.push(entry);
|
|
109
|
+
} else {
|
|
110
|
+
suppressed.add(entry.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (expiredEntries.length > 0) {
|
|
115
|
+
console.error("[audit-check] EXPIRED allowlist entries detected:");
|
|
116
|
+
for (const entry of expiredEntries) {
|
|
117
|
+
console.error(
|
|
118
|
+
` - ${entry.id} (expired ${entry.expires}): ${entry.reason ?? "no reason recorded"}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
console.error(
|
|
122
|
+
"[audit-check] Renew or remove expired entries to proceed. Exit 1.",
|
|
123
|
+
);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Run pnpm audit (production graph only)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
console.log("[audit-check] Running pnpm audit --prod --json ...");
|
|
132
|
+
|
|
133
|
+
let auditOutput = "";
|
|
134
|
+
let auditExitCode = 0;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
auditOutput = execSync("pnpm audit --prod --json 2>/dev/null", {
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// pnpm audit exits non-zero when vulnerabilities are found.
|
|
142
|
+
// We want the JSON regardless of the exit code.
|
|
143
|
+
const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
|
|
144
|
+
auditOutput = execError.stdout ?? "";
|
|
145
|
+
auditExitCode = execError.status ?? 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Parse audit JSON
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** @type {unknown} */
|
|
153
|
+
let report;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
report = JSON.parse(auditOutput);
|
|
157
|
+
} catch {
|
|
158
|
+
if (auditExitCode === 0) {
|
|
159
|
+
// No JSON means nothing to audit — clean.
|
|
160
|
+
console.log("[audit-check] No vulnerabilities found. Exit 0.");
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
console.error(
|
|
164
|
+
"[audit-check] ERROR: pnpm audit produced non-JSON output (exit code " +
|
|
165
|
+
auditExitCode +
|
|
166
|
+
").",
|
|
167
|
+
);
|
|
168
|
+
console.error(auditOutput.slice(0, 2000));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Extract advisories
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* pnpm audit --json shape:
|
|
178
|
+
* {
|
|
179
|
+
* "advisories": {
|
|
180
|
+
* "<id>": {
|
|
181
|
+
* "ghsa_id": "GHSA-xxxx",
|
|
182
|
+
* "cve": ["CVE-xxxx"],
|
|
183
|
+
* "severity": "high" | "critical" | "moderate" | "low" | "info",
|
|
184
|
+
* "title": "...",
|
|
185
|
+
* "url": "...",
|
|
186
|
+
* ...
|
|
187
|
+
* }
|
|
188
|
+
* },
|
|
189
|
+
* "metadata": { ... }
|
|
190
|
+
* }
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
|
|
194
|
+
|
|
195
|
+
/** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
|
|
196
|
+
const blocking = [];
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
report !== null &&
|
|
200
|
+
typeof report === "object" &&
|
|
201
|
+
"advisories" in report &&
|
|
202
|
+
report.advisories !== null &&
|
|
203
|
+
typeof report.advisories === "object"
|
|
204
|
+
) {
|
|
205
|
+
const advisories = /** @type {Record<string, unknown>} */ (
|
|
206
|
+
report.advisories
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
for (const [, advisory] of Object.entries(advisories)) {
|
|
210
|
+
if (
|
|
211
|
+
advisory === null ||
|
|
212
|
+
typeof advisory !== "object" ||
|
|
213
|
+
!("severity" in advisory)
|
|
214
|
+
) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const adv = /** @type {Record<string, unknown>} */ (advisory);
|
|
219
|
+
const severity = String(adv["severity"] ?? "").toLowerCase();
|
|
220
|
+
|
|
221
|
+
if (!BLOCKING_SEVERITIES.has(severity)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Collect all IDs this advisory is known by for allowlist matching.
|
|
226
|
+
const ghsaId = String(adv["ghsa_id"] ?? "");
|
|
227
|
+
const cveIds = Array.isArray(adv["cve"])
|
|
228
|
+
? adv["cve"].map((c) => String(c))
|
|
229
|
+
: [];
|
|
230
|
+
const allIds = [ghsaId, ...cveIds].filter(Boolean);
|
|
231
|
+
|
|
232
|
+
const isSuppressed = allIds.some((id) => suppressed.has(id));
|
|
233
|
+
|
|
234
|
+
if (!isSuppressed) {
|
|
235
|
+
blocking.push({
|
|
236
|
+
id: ghsaId || cveIds[0] || "(unknown)",
|
|
237
|
+
severity,
|
|
238
|
+
title: String(adv["title"] ?? "(no title)"),
|
|
239
|
+
url: String(adv["url"] ?? ""),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Report and exit
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
if (blocking.length === 0) {
|
|
250
|
+
console.log(
|
|
251
|
+
`[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph. Exit 0.`,
|
|
252
|
+
);
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.error(
|
|
257
|
+
`[audit-check] ${blocking.length} unsuppressed High/Critical CVE(s) found in prod dependency graph:`,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
for (const vuln of blocking) {
|
|
261
|
+
console.error(` [${vuln.severity.toUpperCase()}] ${vuln.id}: ${vuln.title}`);
|
|
262
|
+
if (vuln.url) {
|
|
263
|
+
console.error(` → ${vuln.url}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.error(
|
|
268
|
+
"\n[audit-check] To suppress a known/accepted CVE, add a dated entry to audit-allowlist.json:",
|
|
269
|
+
);
|
|
270
|
+
console.error(
|
|
271
|
+
JSON.stringify(
|
|
272
|
+
[
|
|
273
|
+
{
|
|
274
|
+
id: blocking[0]?.id ?? "GHSA-xxxx-xxxx-xxxx",
|
|
275
|
+
reason: "Describe why this is accepted and any mitigations in place",
|
|
276
|
+
expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
|
|
277
|
+
.toISOString()
|
|
278
|
+
.slice(0, 10),
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
null,
|
|
282
|
+
2,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
console.error("\n[audit-check] Exit 1.");
|
|
287
|
+
process.exit(1);
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-docs-staleness.mjs
|
|
4
|
+
*
|
|
5
|
+
* Flags retired-product references and known staleness patterns in documentation.
|
|
6
|
+
*
|
|
7
|
+
* Designed to be run in mandrel-platform consumers as a CI lint step, or from
|
|
8
|
+
* the mandrel-platform repo itself against its own docs.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/check-docs-staleness.mjs [options]
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --dir <path> Directory to scan (default: docs/)
|
|
15
|
+
* --warn-only Exit 0 even when issues are found (print warnings, don't fail CI)
|
|
16
|
+
* --quiet Suppress per-file output; only print summary
|
|
17
|
+
* --help Print this help and exit
|
|
18
|
+
*
|
|
19
|
+
* Examples:
|
|
20
|
+
* node scripts/check-docs-staleness.mjs
|
|
21
|
+
* node scripts/check-docs-staleness.mjs --dir docs/ --warn-only
|
|
22
|
+
* node node_modules/mandrel-platform/scripts/check-docs-staleness.mjs --dir docs/
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
+
import { join, relative, extname } from 'node:path';
|
|
27
|
+
import { parseArgs } from 'node:util';
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Staleness patterns
|
|
65
|
+
//
|
|
66
|
+
// Each rule has:
|
|
67
|
+
// id — unique identifier (used in suppression comments)
|
|
68
|
+
// description — human-readable explanation shown in lint output
|
|
69
|
+
// pattern — regex to search for in file content
|
|
70
|
+
// severity — 'error' | 'warning'
|
|
71
|
+
// fileGlob — optional: only apply to files matching this regex
|
|
72
|
+
//
|
|
73
|
+
// Suppression: add `<!-- staleness-ignore: <id> -->` on the line above the
|
|
74
|
+
// flagged text to suppress a specific rule for that occurrence.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const RULES = [
|
|
78
|
+
{
|
|
79
|
+
id: 'pages-deploy-command',
|
|
80
|
+
description: 'References `wrangler pages deploy` — may be stale if the project has migrated web to a Worker',
|
|
81
|
+
pattern: /wrangler pages deploy/g,
|
|
82
|
+
severity: 'error',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'pages-dev-url',
|
|
86
|
+
description: 'References `*.pages.dev` URL — may be stale if the project has migrated off Cloudflare Pages',
|
|
87
|
+
pattern: /[a-z0-9-]+\.pages\.dev/g,
|
|
88
|
+
severity: 'error',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'pages-dashboard-link',
|
|
92
|
+
description: 'References the Cloudflare Pages dashboard (`dash.cloudflare.com/pages`) — may be stale after Worker migration',
|
|
93
|
+
pattern: /dash\.cloudflare\.com\/[a-z0-9]+\/pages/g,
|
|
94
|
+
severity: 'error',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'pages-rollback',
|
|
98
|
+
description: 'References `wrangler pages deployment rollback` — may be stale if the project has migrated web to a Worker',
|
|
99
|
+
pattern: /wrangler pages deployment rollback/g,
|
|
100
|
+
severity: 'warning',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'hardcoded-worker-name',
|
|
104
|
+
description: 'Possible hardcoded project-specific worker name in a common runbook (expected only in project-local docs)',
|
|
105
|
+
// Detects patterns like `my-app-staging` or `my-app-production` but not generic `<worker-name>`
|
|
106
|
+
pattern: /--name\s+[a-z][a-z0-9-]+-(staging|production)\b(?!\s*>)/g,
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
fileGlob: /docs\/runbooks\//,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'hardcoded-url',
|
|
112
|
+
description: 'Possible hardcoded non-placeholder URL (use `<PLACEHOLDER>` style in common runbooks)',
|
|
113
|
+
// Detects https:// URLs that are not placeholders (< >) and not github.com/cloudflare docs links
|
|
114
|
+
pattern: /https:\/\/(?!github\.com|docs\.cloudflare\.com|api\.cloudflare\.com|uptime\.betterstack\.com)[a-z0-9][a-z0-9.-]+\.[a-z]{2,}\/[^\s)"'>]*/g,
|
|
115
|
+
severity: 'warning',
|
|
116
|
+
fileGlob: /docs\/runbooks\//,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'stale-github-runbooks-ref',
|
|
120
|
+
description: 'References `.github/RUNBOOKS/` — this is the stale duplicate directory pattern; canonical runbooks live in `docs/runbooks/`',
|
|
121
|
+
pattern: /\.github\/RUNBOOKS\//g,
|
|
122
|
+
severity: 'error',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'quality-yml-ref',
|
|
126
|
+
description: 'References `quality.yml` — verify this file exists in the project (swarm-os ships `ci.yml` instead)',
|
|
127
|
+
pattern: /quality\.yml/g,
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'expired-placeholder',
|
|
132
|
+
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" that are in the past
|
|
134
|
+
pattern: /expires[:\s]+202[0-4]-\d{2}-\d{2}/gi,
|
|
135
|
+
severity: 'error',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// File walker
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Recursively collect all .md and .json files under dir.
|
|
145
|
+
* @param {string} dir
|
|
146
|
+
* @returns {string[]}
|
|
147
|
+
*/
|
|
148
|
+
function walkDir(dir) {
|
|
149
|
+
const results = [];
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = readdirSync(dir);
|
|
153
|
+
} catch {
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
const fullPath = join(dir, entry);
|
|
158
|
+
const stat = statSync(fullPath);
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
if (entry === 'node_modules' || entry === '.git') continue;
|
|
161
|
+
results.push(...walkDir(fullPath));
|
|
162
|
+
} else if (['.md', '.json'].includes(extname(entry))) {
|
|
163
|
+
results.push(fullPath);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Lint a single file
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @typedef {{ file: string, line: number, rule: typeof RULES[0], match: string }} Finding
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Lint a file against all rules.
|
|
179
|
+
* @param {string} filePath
|
|
180
|
+
* @returns {Finding[]}
|
|
181
|
+
*/
|
|
182
|
+
function lintFile(filePath) {
|
|
183
|
+
const findings = [];
|
|
184
|
+
let content;
|
|
185
|
+
try {
|
|
186
|
+
content = readFileSync(filePath, 'utf8');
|
|
187
|
+
} catch {
|
|
188
|
+
return findings;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lines = content.split('\n');
|
|
192
|
+
|
|
193
|
+
for (const rule of RULES) {
|
|
194
|
+
// Skip if this rule has a fileGlob and the file doesn't match
|
|
195
|
+
if (rule.fileGlob && !rule.fileGlob.test(filePath)) continue;
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const line = lines[i];
|
|
199
|
+
const prevLine = i > 0 ? lines[i - 1] : '';
|
|
200
|
+
|
|
201
|
+
// Check for suppression comment on the preceding line
|
|
202
|
+
if (prevLine.includes(`staleness-ignore: ${rule.id}`)) continue;
|
|
203
|
+
|
|
204
|
+
// Reset regex state for global patterns
|
|
205
|
+
rule.pattern.lastIndex = 0;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = rule.pattern.exec(line)) !== null) {
|
|
208
|
+
findings.push({
|
|
209
|
+
file: filePath,
|
|
210
|
+
line: i + 1,
|
|
211
|
+
rule,
|
|
212
|
+
match: match[0],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return findings;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Main
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const files = walkDir(SCAN_DIR);
|
|
226
|
+
|
|
227
|
+
if (files.length === 0) {
|
|
228
|
+
console.log(`[docs-staleness] No files found under '${SCAN_DIR}' — nothing to check.`);
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @type {Finding[]} */
|
|
233
|
+
const allFindings = [];
|
|
234
|
+
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
const findings = lintFile(file);
|
|
237
|
+
allFindings.push(...findings);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Group findings by file for readable output
|
|
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
|
+
}
|
|
247
|
+
|
|
248
|
+
const errors = allFindings.filter((f) => f.rule.severity === 'error');
|
|
249
|
+
const warnings = allFindings.filter((f) => f.rule.severity === 'warning');
|
|
250
|
+
|
|
251
|
+
if (!QUIET) {
|
|
252
|
+
for (const [file, findings] of byFile.entries()) {
|
|
253
|
+
const relPath = relative(process.cwd(), file);
|
|
254
|
+
for (const f of findings) {
|
|
255
|
+
const sev = f.rule.severity === 'error' ? 'ERR ' : 'WARN';
|
|
256
|
+
console.log(`[${sev}] ${relPath}:${f.line} — ${f.rule.id}: ${f.rule.description}`);
|
|
257
|
+
console.log(` matched: ${JSON.stringify(f.match)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(
|
|
263
|
+
`\n[docs-staleness] Scanned ${files.length} file(s). ` +
|
|
264
|
+
`Found ${errors.length} error(s), ${warnings.length} warning(s).`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (allFindings.length > 0) {
|
|
268
|
+
console.log(`\nTo suppress a specific rule occurrence, add this comment on the line above:`);
|
|
269
|
+
console.log(` <!-- staleness-ignore: <rule-id> -->`);
|
|
270
|
+
console.log(`\nAvailable rule IDs: ${RULES.map((r) => r.id).join(', ')}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (errors.length > 0 && !WARN_ONLY) {
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
process.exit(0);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-required-contexts.mjs
|
|
4
|
+
*
|
|
5
|
+
* Branch-protection context lint for mandrel-platform consumers.
|
|
6
|
+
*
|
|
7
|
+
* Reads `docs/runbooks/main-protection.json` and asserts that every context
|
|
8
|
+
* listed in `requiredStatusChecks` — and every job listed in `upstreamJobs` —
|
|
9
|
+
* is actually emitted by a job in the `.github/workflows/` directory.
|
|
10
|
+
*
|
|
11
|
+
* This prevents the "phantom required check" failure mode where a check is
|
|
12
|
+
* registered in branch protection but no CI job ever reports it, leaving
|
|
13
|
+
* every PR blocked indefinitely on a `pending` status that never resolves.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node scripts/check-required-contexts.mjs
|
|
17
|
+
* node scripts/check-required-contexts.mjs --contract path/to/main-protection.json
|
|
18
|
+
* node scripts/check-required-contexts.mjs --workflows-dir .github/workflows
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 — all required contexts are emitted by at least one workflow job
|
|
22
|
+
* 1 — one or more phantom contexts detected (named in stderr)
|
|
23
|
+
*
|
|
24
|
+
* Consumer adoption:
|
|
25
|
+
* Copy this script into your project's `scripts/` directory, then wire it
|
|
26
|
+
* into your PR-quality workflow:
|
|
27
|
+
*
|
|
28
|
+
* - name: Lint branch-protection contract
|
|
29
|
+
* run: node scripts/check-required-contexts.mjs
|
|
30
|
+
*
|
|
31
|
+
* Keep `docs/runbooks/main-protection.json` up to date whenever you add,
|
|
32
|
+
* rename, or remove workflow jobs so the lint stays accurate.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
36
|
+
import { resolve, join, relative } from "node:path";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Arg parsing
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
let contractPath = null;
|
|
44
|
+
let workflowsDir = null;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
if ((args[i] === "--contract" || args[i] === "-c") && args[i + 1]) {
|
|
48
|
+
contractPath = args[++i];
|
|
49
|
+
} else if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
|
|
50
|
+
workflowsDir = args[++i];
|
|
51
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
52
|
+
process.stdout.write(
|
|
53
|
+
"Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
|
|
54
|
+
);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve paths relative to the repo root (cwd when invoked from CI or locally).
|
|
60
|
+
const repoRoot = process.cwd();
|
|
61
|
+
const resolvedContract = contractPath
|
|
62
|
+
? resolve(contractPath)
|
|
63
|
+
: resolve(repoRoot, "docs/runbooks/main-protection.json");
|
|
64
|
+
const resolvedWorkflowsDir = workflowsDir
|
|
65
|
+
? resolve(workflowsDir)
|
|
66
|
+
: resolve(repoRoot, ".github/workflows");
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Load contract
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
let contract;
|
|
73
|
+
try {
|
|
74
|
+
const raw = readFileSync(resolvedContract, "utf8");
|
|
75
|
+
contract = JSON.parse(raw);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const requiredContexts = Array.isArray(contract.requiredStatusChecks)
|
|
84
|
+
? contract.requiredStatusChecks
|
|
85
|
+
: [];
|
|
86
|
+
const upstreamJobs = Array.isArray(contract.upstreamJobs)
|
|
87
|
+
? contract.upstreamJobs
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
if (requiredContexts.length === 0) {
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
"[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
|
|
93
|
+
);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Collect job names from all workflow files
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract all job IDs from a YAML workflow file using a simple line-by-line
|
|
103
|
+
* parser. We intentionally avoid a full YAML parser to keep this script
|
|
104
|
+
* dependency-free — the job ID pattern is stable enough to parse with a regex.
|
|
105
|
+
*
|
|
106
|
+
* GitHub Actions job IDs appear as top-level keys under the `jobs:` map:
|
|
107
|
+
*
|
|
108
|
+
* jobs:
|
|
109
|
+
* lint: ← job ID = "lint"
|
|
110
|
+
* name: Lint & format
|
|
111
|
+
* ...
|
|
112
|
+
* ci-required: ← job ID = "ci-required"
|
|
113
|
+
* ...
|
|
114
|
+
*
|
|
115
|
+
* The job ID line is indented by exactly 2 spaces and ends with a colon.
|
|
116
|
+
*/
|
|
117
|
+
function extractJobIds(yamlContent) {
|
|
118
|
+
const ids = new Set();
|
|
119
|
+
let inJobsBlock = false;
|
|
120
|
+
|
|
121
|
+
for (const line of yamlContent.split("\n")) {
|
|
122
|
+
// Detect the `jobs:` top-level key (zero indentation).
|
|
123
|
+
if (/^jobs:\s*$/.test(line)) {
|
|
124
|
+
inJobsBlock = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!inJobsBlock) continue;
|
|
129
|
+
|
|
130
|
+
// A top-level key at zero indentation that is NOT `jobs:` ends the block.
|
|
131
|
+
if (/^[a-zA-Z0-9_-]/.test(line) && !/^jobs:\s*$/.test(line)) {
|
|
132
|
+
inJobsBlock = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Job ID lines: exactly 2-space indent, identifier, colon, optional spaces.
|
|
137
|
+
const jobMatch = line.match(/^ ([a-zA-Z0-9_-]+):\s*$/);
|
|
138
|
+
if (jobMatch) {
|
|
139
|
+
ids.add(jobMatch[1]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return ids;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let workflowFiles;
|
|
147
|
+
try {
|
|
148
|
+
workflowFiles = readdirSync(resolvedWorkflowsDir).filter(
|
|
149
|
+
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
|
|
150
|
+
);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
process.stderr.write(
|
|
153
|
+
`[check-required-contexts] ERROR: Cannot read workflows directory at ${relative(repoRoot, resolvedWorkflowsDir)}: ${err.message}\n`
|
|
154
|
+
);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (workflowFiles.length === 0) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`[check-required-contexts] ERROR: No workflow files found in ${relative(repoRoot, resolvedWorkflowsDir)}\n`
|
|
161
|
+
);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Map from workflow filename → Set<jobId> */
|
|
166
|
+
const workflowJobMap = new Map();
|
|
167
|
+
/** Flat set of all job IDs across all workflows */
|
|
168
|
+
const allJobIds = new Set();
|
|
169
|
+
|
|
170
|
+
for (const file of workflowFiles) {
|
|
171
|
+
const filePath = join(resolvedWorkflowsDir, file);
|
|
172
|
+
let content;
|
|
173
|
+
try {
|
|
174
|
+
content = readFileSync(filePath, "utf8");
|
|
175
|
+
} catch (err) {
|
|
176
|
+
process.stderr.write(
|
|
177
|
+
`[check-required-contexts] WARN: Cannot read ${file}: ${err.message} — skipping.\n`
|
|
178
|
+
);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const ids = extractJobIds(content);
|
|
182
|
+
workflowJobMap.set(file, ids);
|
|
183
|
+
for (const id of ids) {
|
|
184
|
+
allJobIds.add(id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Validate required contexts
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const phantomContexts = requiredContexts.filter((ctx) => !allJobIds.has(ctx));
|
|
193
|
+
const phantomUpstream = upstreamJobs.filter((job) => !allJobIds.has(job));
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Report
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
const contractRel = relative(repoRoot, resolvedContract);
|
|
200
|
+
const workflowsRel = relative(repoRoot, resolvedWorkflowsDir);
|
|
201
|
+
|
|
202
|
+
process.stdout.write(
|
|
203
|
+
`[check-required-contexts] Contract : ${contractRel}\n` +
|
|
204
|
+
`[check-required-contexts] Workflows: ${workflowsRel}/ (${workflowFiles.length} file${workflowFiles.length === 1 ? "" : "s"})\n` +
|
|
205
|
+
`[check-required-contexts] Emitted job IDs: ${[...allJobIds].sort().join(", ")}\n`
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (phantomContexts.length > 0) {
|
|
209
|
+
process.stderr.write(
|
|
210
|
+
`\n[check-required-contexts] ❌ PHANTOM required contexts detected!\n` +
|
|
211
|
+
` These contexts are listed in requiredStatusChecks but no workflow job emits them:\n`
|
|
212
|
+
);
|
|
213
|
+
for (const ctx of phantomContexts) {
|
|
214
|
+
process.stderr.write(` • "${ctx}"\n`);
|
|
215
|
+
}
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
`\n A phantom context will block every PR indefinitely on a "pending" status\n` +
|
|
218
|
+
` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
|
|
219
|
+
` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (phantomUpstream.length > 0) {
|
|
224
|
+
process.stderr.write(
|
|
225
|
+
`\n[check-required-contexts] ❌ PHANTOM upstream jobs detected!\n` +
|
|
226
|
+
` These jobs are listed in upstreamJobs but no workflow defines them:\n`
|
|
227
|
+
);
|
|
228
|
+
for (const job of phantomUpstream) {
|
|
229
|
+
process.stderr.write(` • "${job}"\n`);
|
|
230
|
+
}
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`\n Fix: either add the missing job to a workflow, or remove it from\n` +
|
|
233
|
+
` upstreamJobs in ${contractRel}.\n\n`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hasError = phantomContexts.length > 0 || phantomUpstream.length > 0;
|
|
238
|
+
|
|
239
|
+
if (!hasError) {
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`[check-required-contexts] ✅ All required contexts and upstream jobs are emitted by CI.\n` +
|
|
242
|
+
` requiredStatusChecks : ${requiredContexts.join(", ")}\n` +
|
|
243
|
+
` upstreamJobs : ${upstreamJobs.length > 0 ? upstreamJobs.join(", ") : "(none listed)"}\n`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
process.exit(hasError ? 1 : 0);
|
|
File without changes
|