@buresmi7/agent-doc-rules-docs-validator 0.8.2
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 +74 -0
- package/bin/agent-doc-rules-docs.mjs +8 -0
- package/package.json +28 -0
- package/src/cli.mjs +148 -0
- package/src/config.mjs +80 -0
- package/src/defaults.mjs +21 -0
- package/src/init.mjs +118 -0
- package/src/runner.mjs +444 -0
- package/src/security.mjs +145 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Docs Validator
|
|
2
|
+
|
|
3
|
+
`@buresmi7/agent-doc-rules-docs-validator` provides deterministic Markdown validation
|
|
4
|
+
for repositories that use the `agent-doc-rules` skill.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add -D @buresmi7/agent-doc-rules-docs-validator
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
agent-doc-rules-docs init
|
|
16
|
+
agent-doc-rules-docs markdown
|
|
17
|
+
agent-doc-rules-docs wording
|
|
18
|
+
agent-doc-rules-docs security
|
|
19
|
+
agent-doc-rules-docs links
|
|
20
|
+
agent-doc-rules-docs check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`init` creates a starter `agent-doc-rules.config.json`. Use
|
|
24
|
+
`agent-doc-rules-docs init --print` to preview the config without writing files.
|
|
25
|
+
|
|
26
|
+
`markdown`, `wording`, `security`, and `links` run the corresponding
|
|
27
|
+
deterministic phases. `check` runs them in that order and stops on the first
|
|
28
|
+
failure. For security-review scope, see
|
|
29
|
+
[`security-review.md`](../agent-doc-rules-skill/references/security-review.md).
|
|
30
|
+
|
|
31
|
+
## Config
|
|
32
|
+
|
|
33
|
+
The CLI reads `agent-doc-rules.config.json` from the repository root. CLI flags
|
|
34
|
+
override config values, and config values override built-in defaults.
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"docs": {
|
|
39
|
+
"include": ["*.md", "docs/**/*.md", "packages/**/*.md"],
|
|
40
|
+
"exclude": ["node_modules/**", ".git/**"],
|
|
41
|
+
"links": {
|
|
42
|
+
"skip": ["^https://github.com/example/archived"]
|
|
43
|
+
},
|
|
44
|
+
"wording": {
|
|
45
|
+
"writeGood": {
|
|
46
|
+
"passive": false,
|
|
47
|
+
"illusion": false,
|
|
48
|
+
"weasel": false,
|
|
49
|
+
"adverb": false,
|
|
50
|
+
"tooWordy": false,
|
|
51
|
+
"eprime": false,
|
|
52
|
+
"fail": false
|
|
53
|
+
},
|
|
54
|
+
"forbiddenTerms": [],
|
|
55
|
+
"allow": ["intentional example"]
|
|
56
|
+
},
|
|
57
|
+
"security": {
|
|
58
|
+
"allow": ["intentional fixture"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use `--skip <regex>` for repeated Linkinator skip patterns and `--no-fragments`
|
|
65
|
+
when fragment validation is too strict for a specific repository. Use
|
|
66
|
+
`docs.wording.writeGood` to tune the deterministic prose linter. Use
|
|
67
|
+
`docs.wording.forbiddenTerms` only for project-specific phrases that must fail.
|
|
68
|
+
|
|
69
|
+
AI sentence-level style review lives in
|
|
70
|
+
`@buresmi7/agent-doc-rules-docs-duplicates` as
|
|
71
|
+
`agent-doc-rules-docs-duplicates style`.
|
|
72
|
+
|
|
73
|
+
See the skill package [Config Reference](../agent-doc-rules-skill/docs/config-reference.md)
|
|
74
|
+
for the full config model.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@buresmi7/agent-doc-rules-docs-validator",
|
|
3
|
+
"version": "0.8.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Markdown, security, and link validation CLI for agent-doc-rules projects",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": {
|
|
9
|
+
"agent-doc-rules-docs": "bin/agent-doc-rules-docs.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.mjs"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"fast-glob": "^3.3.3",
|
|
24
|
+
"linkinator": "^7.6.1",
|
|
25
|
+
"markdownlint-cli2": "^0.22.1",
|
|
26
|
+
"write-good": "^1.0.8"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { resolveDocsOptions } from './config.mjs';
|
|
2
|
+
import { runInit } from './init.mjs';
|
|
3
|
+
import { runCheck, runLinks, runMarkdown, runSecurity, runWording } from './runner.mjs';
|
|
4
|
+
|
|
5
|
+
const commands = new Set(['init', 'markdown', 'wording', 'security', 'links', 'check']);
|
|
6
|
+
|
|
7
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
8
|
+
const parsed = parseArgs(argv);
|
|
9
|
+
|
|
10
|
+
if (parsed.help) {
|
|
11
|
+
console.log(usage());
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const options = parsed.command === 'init'
|
|
16
|
+
? parsed
|
|
17
|
+
: parsed.command === 'check'
|
|
18
|
+
? {
|
|
19
|
+
markdownOptions: await resolveDocsOptions({ ...parsed, command: 'markdown' }),
|
|
20
|
+
wordingOptions: await resolveDocsOptions({ ...parsed, command: 'wording' }),
|
|
21
|
+
securityOptions: await resolveDocsOptions({ ...parsed, command: 'security' }),
|
|
22
|
+
linksOptions: await resolveDocsOptions({ ...parsed, command: 'links' }),
|
|
23
|
+
}
|
|
24
|
+
: await resolveDocsOptions(parsed);
|
|
25
|
+
const code = await runCommand(parsed.command, options);
|
|
26
|
+
|
|
27
|
+
if (code !== 0) {
|
|
28
|
+
process.exitCode = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runCommand(command, options, deps = {}) {
|
|
33
|
+
if (command === 'init') {
|
|
34
|
+
return runInit(options, deps);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (command === 'markdown') {
|
|
38
|
+
return runMarkdown(options, deps);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (command === 'wording') {
|
|
42
|
+
return runWording(options, deps);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (command === 'links') {
|
|
46
|
+
return runLinks(options, deps);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (command === 'security') {
|
|
50
|
+
return runSecurity(options, deps);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (command === 'check') {
|
|
54
|
+
return runCheck(options, deps);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error(`Unknown command: ${command}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseArgs(argv) {
|
|
61
|
+
const [maybeCommand, ...rest] = argv;
|
|
62
|
+
|
|
63
|
+
if (!maybeCommand || maybeCommand === '--help' || maybeCommand === '-h') {
|
|
64
|
+
return { command: 'check', help: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!commands.has(maybeCommand)) {
|
|
68
|
+
throw new Error(`Unknown command: ${maybeCommand}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parsed = {
|
|
72
|
+
command: maybeCommand,
|
|
73
|
+
include: [],
|
|
74
|
+
exclude: [],
|
|
75
|
+
skip: [],
|
|
76
|
+
forbiddenTerms: [],
|
|
77
|
+
allow: [],
|
|
78
|
+
checkFragments: undefined,
|
|
79
|
+
force: false,
|
|
80
|
+
print: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
84
|
+
const arg = rest[index];
|
|
85
|
+
|
|
86
|
+
if (arg === '--root') {
|
|
87
|
+
parsed.root = readValue(rest, ++index, arg);
|
|
88
|
+
} else if (arg === '--include') {
|
|
89
|
+
parsed.include.push(readValue(rest, ++index, arg));
|
|
90
|
+
} else if (arg === '--exclude') {
|
|
91
|
+
parsed.exclude.push(readValue(rest, ++index, arg));
|
|
92
|
+
} else if (arg === '--config') {
|
|
93
|
+
parsed.configPath = readValue(rest, ++index, arg);
|
|
94
|
+
} else if (arg === '--skip') {
|
|
95
|
+
parsed.skip.push(readValue(rest, ++index, arg));
|
|
96
|
+
} else if (arg === '--forbid') {
|
|
97
|
+
parsed.forbiddenTerms.push(readValue(rest, ++index, arg));
|
|
98
|
+
} else if (arg === '--allow') {
|
|
99
|
+
parsed.allow.push(readValue(rest, ++index, arg));
|
|
100
|
+
} else if (arg === '--no-fragments') {
|
|
101
|
+
parsed.checkFragments = false;
|
|
102
|
+
} else if (arg === '--force') {
|
|
103
|
+
parsed.force = true;
|
|
104
|
+
} else if (arg === '--print') {
|
|
105
|
+
parsed.print = true;
|
|
106
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
107
|
+
parsed.help = true;
|
|
108
|
+
} else {
|
|
109
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readValue(args, index, option) {
|
|
117
|
+
const value = args[index];
|
|
118
|
+
|
|
119
|
+
if (!value || value.startsWith('--')) {
|
|
120
|
+
throw new Error(`Missing value for ${option}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function usage() {
|
|
127
|
+
return `Usage: agent-doc-rules-docs <command> [options]
|
|
128
|
+
|
|
129
|
+
Commands:
|
|
130
|
+
init Write a starter agent-doc-rules.config.json.
|
|
131
|
+
markdown Run Markdown linting.
|
|
132
|
+
wording Run deterministic prose wording checks.
|
|
133
|
+
security Run deterministic documentation security checks.
|
|
134
|
+
links Run Markdown link validation.
|
|
135
|
+
check Run Markdown linting, wording, security, then link validation.
|
|
136
|
+
|
|
137
|
+
Options:
|
|
138
|
+
--root <dir> Repository root. Defaults to the current directory.
|
|
139
|
+
--include <glob> Include Markdown glob. Repeatable.
|
|
140
|
+
--exclude <glob> Exclude glob. Repeatable.
|
|
141
|
+
--config <path> Config file. Defaults to agent-doc-rules.config.json.
|
|
142
|
+
--skip <regex> Linkinator skip pattern. Repeatable.
|
|
143
|
+
--forbid <term> Project-specific term that should fail. Repeatable.
|
|
144
|
+
--allow <regex> Wording or security allow pattern for matching lines. Repeatable.
|
|
145
|
+
--no-fragments Do not ask Linkinator to check fragments.
|
|
146
|
+
--print Print the starter config without writing files.
|
|
147
|
+
--force Overwrite an existing config during init.`;
|
|
148
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { defaultConfigFile, defaultExclude, defaultInclude } from './defaults.mjs';
|
|
4
|
+
|
|
5
|
+
export async function loadDocsConfig({ root = process.cwd(), configPath } = {}) {
|
|
6
|
+
const resolvedRoot = resolve(root);
|
|
7
|
+
const resolvedConfigPath = configPath
|
|
8
|
+
? resolvePath(resolvedRoot, configPath)
|
|
9
|
+
: resolve(resolvedRoot, defaultConfigFile);
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const raw = await readFile(resolvedConfigPath, 'utf8');
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return parsed.docs ?? parsed;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT' && !configPath) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function resolveDocsOptions({
|
|
25
|
+
command = 'markdown',
|
|
26
|
+
root = process.cwd(),
|
|
27
|
+
configPath,
|
|
28
|
+
include = [],
|
|
29
|
+
exclude = [],
|
|
30
|
+
skip = [],
|
|
31
|
+
checkFragments,
|
|
32
|
+
forbiddenTerms = [],
|
|
33
|
+
allow = [],
|
|
34
|
+
writeGood,
|
|
35
|
+
} = {}) {
|
|
36
|
+
const resolvedRoot = resolve(root);
|
|
37
|
+
const config = await loadDocsConfig({ root: resolvedRoot, configPath });
|
|
38
|
+
const commandConfig = config[command] ?? {};
|
|
39
|
+
const linkConfig = command === 'links' ? (config.links ?? {}) : {};
|
|
40
|
+
const wordingConfig = command === 'wording' ? (config.wording ?? {}) : {};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
root: resolvedRoot,
|
|
44
|
+
include: chooseArray(include, commandConfig.include, config.include, defaultInclude),
|
|
45
|
+
exclude: chooseArray(exclude, commandConfig.exclude, config.exclude, defaultExclude),
|
|
46
|
+
skip: chooseArray(skip, linkConfig.skip, commandConfig.skip, []),
|
|
47
|
+
checkFragments: checkFragments ?? linkConfig.checkFragments ?? commandConfig.checkFragments ?? true,
|
|
48
|
+
forbiddenTerms: chooseArray(forbiddenTerms, wordingConfig.forbiddenTerms, commandConfig.forbiddenTerms, []),
|
|
49
|
+
allow: chooseArray(allow, wordingConfig.allow, commandConfig.allow, []),
|
|
50
|
+
writeGood: chooseObject(writeGood, wordingConfig.writeGood, commandConfig.writeGood, {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function chooseArray(...candidates) {
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function chooseObject(...candidates) {
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (candidate === false) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolvePath(root, path) {
|
|
79
|
+
return isAbsolute(path) ? path : resolve(root, path);
|
|
80
|
+
}
|
package/src/defaults.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const defaultInclude = [
|
|
2
|
+
'*.md',
|
|
3
|
+
'docs/**/*.md',
|
|
4
|
+
'**/AGENTS.md',
|
|
5
|
+
'.agents/skills/**/*.md',
|
|
6
|
+
'packages/**/*.md',
|
|
7
|
+
'rules/**/*.md',
|
|
8
|
+
'.codex/**/*.md',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const defaultExclude = [
|
|
12
|
+
'node_modules/**',
|
|
13
|
+
'.git/**',
|
|
14
|
+
'dist/**',
|
|
15
|
+
'coverage/**',
|
|
16
|
+
'.tmp/**',
|
|
17
|
+
'repos/**',
|
|
18
|
+
'worktrees/**',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const defaultConfigFile = 'agent-doc-rules.config.json';
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { defaultConfigFile, defaultExclude, defaultInclude } from './defaults.mjs';
|
|
4
|
+
|
|
5
|
+
export const recommendedScripts = {
|
|
6
|
+
'docs:markdown': 'agent-doc-rules-docs markdown',
|
|
7
|
+
'docs:wording': 'agent-doc-rules-docs wording',
|
|
8
|
+
'docs:security': 'agent-doc-rules-docs security',
|
|
9
|
+
'docs:style': 'agent-doc-rules-docs-duplicates style',
|
|
10
|
+
'docs:links': 'agent-doc-rules-docs links',
|
|
11
|
+
'docs:duplicates': 'agent-doc-rules-docs-duplicates check',
|
|
12
|
+
'docs:check': 'agent-doc-rules-docs check && agent-doc-rules-docs-duplicates style && agent-doc-rules-docs-duplicates check',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function buildStarterConfig() {
|
|
16
|
+
return {
|
|
17
|
+
docs: {
|
|
18
|
+
include: defaultInclude,
|
|
19
|
+
exclude: defaultExclude,
|
|
20
|
+
links: {
|
|
21
|
+
skip: [],
|
|
22
|
+
checkFragments: true,
|
|
23
|
+
},
|
|
24
|
+
wording: {
|
|
25
|
+
writeGood: {
|
|
26
|
+
passive: false,
|
|
27
|
+
illusion: false,
|
|
28
|
+
weasel: false,
|
|
29
|
+
adverb: false,
|
|
30
|
+
tooWordy: false,
|
|
31
|
+
eprime: false,
|
|
32
|
+
fail: false,
|
|
33
|
+
},
|
|
34
|
+
forbiddenTerms: [],
|
|
35
|
+
allow: [],
|
|
36
|
+
},
|
|
37
|
+
security: {
|
|
38
|
+
allow: [],
|
|
39
|
+
},
|
|
40
|
+
style: {
|
|
41
|
+
includeReferences: false,
|
|
42
|
+
minWords: 6,
|
|
43
|
+
minChars: 40,
|
|
44
|
+
maxUnits: 80,
|
|
45
|
+
model: 'gpt-5-nano',
|
|
46
|
+
reasoningEffort: 'low',
|
|
47
|
+
},
|
|
48
|
+
duplicates: {
|
|
49
|
+
includeReferences: false,
|
|
50
|
+
includeSameFile: false,
|
|
51
|
+
warnScore: 0.78,
|
|
52
|
+
failScore: 0.92,
|
|
53
|
+
minWords: 6,
|
|
54
|
+
minChars: 40,
|
|
55
|
+
maxCandidates: 50,
|
|
56
|
+
ignorePairs: [],
|
|
57
|
+
model: 'gpt-5-nano',
|
|
58
|
+
reasoningEffort: 'low',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runInit({
|
|
65
|
+
root = process.cwd(),
|
|
66
|
+
configPath,
|
|
67
|
+
force = false,
|
|
68
|
+
print = false,
|
|
69
|
+
} = {}, deps = {}) {
|
|
70
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
71
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
72
|
+
const resolvedRoot = resolve(root);
|
|
73
|
+
const target = resolveConfigPath(resolvedRoot, configPath);
|
|
74
|
+
const configText = `${JSON.stringify(buildStarterConfig(), null, 2)}\n`;
|
|
75
|
+
const scriptsText = formatRecommendedScripts();
|
|
76
|
+
|
|
77
|
+
if (print) {
|
|
78
|
+
stdout.write(configText);
|
|
79
|
+
stdout.write(`\nRecommended package scripts:\n${scriptsText}\n`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const existing = await readFile(target, 'utf8').catch((error) => {
|
|
84
|
+
if (error.code === 'ENOENT') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw error;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (existing !== null && !force) {
|
|
92
|
+
stderr.write(
|
|
93
|
+
`${relative(resolvedRoot, target) || defaultConfigFile} already exists. `
|
|
94
|
+
+ 'Use --force to overwrite it.\n',
|
|
95
|
+
);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await mkdir(dirname(target), { recursive: true });
|
|
100
|
+
await writeFile(target, configText);
|
|
101
|
+
|
|
102
|
+
stdout.write(`Wrote ${relative(resolvedRoot, target) || defaultConfigFile}\n`);
|
|
103
|
+
stdout.write(`\nRecommended package scripts:\n${scriptsText}\n`);
|
|
104
|
+
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatRecommendedScripts() {
|
|
109
|
+
return `${JSON.stringify({ scripts: recommendedScripts }, null, 2)}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveConfigPath(root, configPath) {
|
|
113
|
+
if (!configPath) {
|
|
114
|
+
return resolve(root, defaultConfigFile);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return isAbsolute(configPath) ? configPath : resolve(root, configPath);
|
|
118
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import fastGlob from 'fast-glob';
|
|
7
|
+
import writeGood from 'write-good';
|
|
8
|
+
import { findSecurityIssues, normalizeSecurityAllow } from './security.mjs';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
export const defaultWriteGoodOptions = {
|
|
12
|
+
passive: false,
|
|
13
|
+
illusion: false,
|
|
14
|
+
weasel: false,
|
|
15
|
+
adverb: false,
|
|
16
|
+
tooWordy: false,
|
|
17
|
+
eprime: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function buildMarkdownlintArgs({ include, exclude }) {
|
|
21
|
+
return [
|
|
22
|
+
...include,
|
|
23
|
+
...expandExcludePatterns(exclude).map((pattern) => `!${pattern}`),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildLinkinatorArgs({ files, skip = [], checkFragments = true }) {
|
|
28
|
+
const args = ['--markdown', '--directory-listing'];
|
|
29
|
+
|
|
30
|
+
if (checkFragments) {
|
|
31
|
+
args.push('--check-fragments');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const pattern of skip) {
|
|
35
|
+
args.push('--skip', pattern);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [...args, ...files];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function resolveMarkdownFiles({ root, include, exclude }) {
|
|
42
|
+
const files = await fastGlob(include, {
|
|
43
|
+
cwd: root,
|
|
44
|
+
dot: true,
|
|
45
|
+
ignore: expandExcludePatterns(exclude),
|
|
46
|
+
onlyFiles: true,
|
|
47
|
+
unique: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return files
|
|
51
|
+
.filter((file) => file.endsWith('.md'))
|
|
52
|
+
.sort((left, right) => left.localeCompare(right));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runMarkdown({ root, include, exclude }, { runner = runNodeBin } = {}) {
|
|
56
|
+
const bin = resolveMarkdownlintBin();
|
|
57
|
+
const args = buildMarkdownlintArgs({ include, exclude });
|
|
58
|
+
return runner({ bin, args, cwd: root });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runLinks(options, { runner = runNodeBin } = {}) {
|
|
62
|
+
const files = await resolveMarkdownFiles(options);
|
|
63
|
+
|
|
64
|
+
if (files.length === 0) {
|
|
65
|
+
console.log('No Markdown files found for link validation.');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bin = resolvePackageBin('linkinator', 'linkinator');
|
|
70
|
+
const args = buildLinkinatorArgs({
|
|
71
|
+
files,
|
|
72
|
+
skip: options.skip,
|
|
73
|
+
checkFragments: options.checkFragments,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return runner({ bin, args, cwd: options.root });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runWording(options, { logger = console } = {}) {
|
|
80
|
+
const files = await resolveMarkdownFiles(options);
|
|
81
|
+
const terms = normalizeWordingTerms(options.forbiddenTerms ?? []);
|
|
82
|
+
const allowPatterns = (options.allow ?? []).map((pattern) => new RegExp(pattern, 'i'));
|
|
83
|
+
const writeGoodConfig = normalizeWriteGoodOptions(options.writeGood);
|
|
84
|
+
const termFindings = [];
|
|
85
|
+
const writeGoodFindings = [];
|
|
86
|
+
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const content = await readFile(join(options.root, file), 'utf8');
|
|
89
|
+
termFindings.push(...findWordingIssues(content, {
|
|
90
|
+
file,
|
|
91
|
+
terms,
|
|
92
|
+
allowPatterns,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
if (writeGoodConfig.enabled) {
|
|
96
|
+
writeGoodFindings.push(...findWriteGoodIssues(content, {
|
|
97
|
+
file,
|
|
98
|
+
allowPatterns,
|
|
99
|
+
writeGoodOptions: writeGoodConfig.options,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (termFindings.length === 0 && writeGoodFindings.length === 0) {
|
|
105
|
+
logger.log('Documentation wording check passed.');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (termFindings.length > 0) {
|
|
110
|
+
logger.error('Documentation wording check failed:');
|
|
111
|
+
|
|
112
|
+
for (const finding of termFindings) {
|
|
113
|
+
logger.error(
|
|
114
|
+
`- ${finding.file}:${finding.line} uses "${finding.term}". `
|
|
115
|
+
+ `Prefer ${finding.suggest}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (writeGoodFindings.length > 0) {
|
|
121
|
+
const writeGoodLogger = writeGoodConfig.fail ? logger.error : logger.log;
|
|
122
|
+
writeGoodLogger(writeGoodConfig.fail
|
|
123
|
+
? 'write-good wording suggestions failed:'
|
|
124
|
+
: 'write-good wording suggestions:');
|
|
125
|
+
|
|
126
|
+
for (const finding of writeGoodFindings) {
|
|
127
|
+
writeGoodLogger(
|
|
128
|
+
`- ${finding.file}:${finding.line}:${finding.column} ${finding.reason}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return termFindings.length > 0 || (writeGoodConfig.fail && writeGoodFindings.length > 0)
|
|
134
|
+
? 1
|
|
135
|
+
: 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runSecurity(options, { logger = console } = {}) {
|
|
139
|
+
const files = await resolveMarkdownFiles(options);
|
|
140
|
+
const allowPatterns = normalizeSecurityAllow(options.allow ?? []);
|
|
141
|
+
const findings = [];
|
|
142
|
+
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
const content = await readFile(join(options.root, file), 'utf8');
|
|
145
|
+
findings.push(...findSecurityIssues(content, {
|
|
146
|
+
file,
|
|
147
|
+
allowPatterns,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (findings.length === 0) {
|
|
152
|
+
logger.log('Documentation security check passed.');
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
logger.error('Documentation security check failed:');
|
|
157
|
+
|
|
158
|
+
for (const finding of findings) {
|
|
159
|
+
logger.error(`- ${finding.file}:${finding.line} ${finding.rule}: ${finding.message}`);
|
|
160
|
+
|
|
161
|
+
if (finding.text) {
|
|
162
|
+
logger.error(` ${finding.text}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runCheck(options, deps = {}) {
|
|
170
|
+
const runMarkdownCommand = deps.runMarkdown ?? runMarkdown;
|
|
171
|
+
const runWordingCommand = deps.runWording ?? runWording;
|
|
172
|
+
const runSecurityCommand = deps.runSecurity ?? runSecurity;
|
|
173
|
+
const runLinksCommand = deps.runLinks ?? runLinks;
|
|
174
|
+
const markdownOptions = options.markdownOptions ?? options;
|
|
175
|
+
const wordingOptions = options.wordingOptions ?? options;
|
|
176
|
+
const securityOptions = options.securityOptions ?? options;
|
|
177
|
+
const linksOptions = options.linksOptions ?? options;
|
|
178
|
+
const markdownCode = await runMarkdownCommand(markdownOptions, deps);
|
|
179
|
+
|
|
180
|
+
if (markdownCode !== 0) {
|
|
181
|
+
return markdownCode;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const wordingCode = await runWordingCommand(wordingOptions, deps);
|
|
185
|
+
|
|
186
|
+
if (wordingCode !== 0) {
|
|
187
|
+
return wordingCode;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const securityCode = await runSecurityCommand(securityOptions, deps);
|
|
191
|
+
|
|
192
|
+
if (securityCode !== 0) {
|
|
193
|
+
return securityCode;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return runLinksCommand(linksOptions, deps);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function resolveMarkdownlintBin() {
|
|
200
|
+
const entry = require.resolve('markdownlint-cli2');
|
|
201
|
+
return join(dirname(entry), 'markdownlint-cli2-bin.mjs');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolvePackageBin(packageName, binName) {
|
|
205
|
+
for (const packageJsonPath of resolvePackageJsonPaths(packageName)) {
|
|
206
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
207
|
+
const bin = typeof packageJson.bin === 'string'
|
|
208
|
+
? packageJson.bin
|
|
209
|
+
: packageJson.bin?.[binName];
|
|
210
|
+
|
|
211
|
+
if (!bin) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const binPath = join(dirname(packageJsonPath), bin);
|
|
216
|
+
|
|
217
|
+
if (existsSync(binPath)) {
|
|
218
|
+
return binPath;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new Error(`Package ${packageName} does not expose bin ${binName}.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function resolvePackageJsonPaths(packageName) {
|
|
226
|
+
const packageJsonPaths = [];
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
packageJsonPaths.push(require.resolve(`${packageName}/package.json`));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (packageJsonPaths.length > 0) {
|
|
237
|
+
return [...new Set(packageJsonPaths)];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let directory = dirname(require.resolve(packageName));
|
|
241
|
+
|
|
242
|
+
while (true) {
|
|
243
|
+
const candidate = join(directory, 'package.json');
|
|
244
|
+
|
|
245
|
+
if (existsSync(candidate)) {
|
|
246
|
+
const packageJson = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
247
|
+
|
|
248
|
+
if (packageJson.name === packageName) {
|
|
249
|
+
packageJsonPaths.push(candidate);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const parent = dirname(directory);
|
|
254
|
+
|
|
255
|
+
if (parent === directory) {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
directory = parent;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return [...new Set(packageJsonPaths)];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function runNodeBin({ bin, args, cwd }) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const child = spawn(process.execPath, [bin, ...args], {
|
|
268
|
+
cwd,
|
|
269
|
+
stdio: 'inherit',
|
|
270
|
+
env: {
|
|
271
|
+
...process.env,
|
|
272
|
+
NO_COLOR: process.env.NO_COLOR ?? '1',
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
child.on('error', reject);
|
|
277
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function expandExcludePatterns(exclude) {
|
|
282
|
+
const expanded = [];
|
|
283
|
+
|
|
284
|
+
for (const pattern of exclude) {
|
|
285
|
+
expanded.push(pattern);
|
|
286
|
+
|
|
287
|
+
if (!pattern.startsWith('**/') && !pattern.startsWith('/')) {
|
|
288
|
+
expanded.push(`**/${pattern}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return [...new Set(expanded)];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function findWordingIssues(content, { file, terms, allowPatterns = [] }) {
|
|
296
|
+
const findings = [];
|
|
297
|
+
let inFence = false;
|
|
298
|
+
const lines = content.split('\n');
|
|
299
|
+
|
|
300
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
301
|
+
const line = lines[index];
|
|
302
|
+
|
|
303
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
304
|
+
inFence = !inFence;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (inFence || allowPatterns.some((pattern) => pattern.test(line))) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const term of terms) {
|
|
313
|
+
if (term.pattern.test(line)) {
|
|
314
|
+
findings.push({
|
|
315
|
+
file,
|
|
316
|
+
line: index + 1,
|
|
317
|
+
term: term.term,
|
|
318
|
+
suggest: term.suggest,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return findings;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function findWriteGoodIssues(content, {
|
|
328
|
+
file,
|
|
329
|
+
allowPatterns = [],
|
|
330
|
+
writeGoodOptions = defaultWriteGoodOptions,
|
|
331
|
+
}) {
|
|
332
|
+
const masked = maskMarkdownForProseLint(content);
|
|
333
|
+
const suggestions = writeGood(masked, writeGoodOptions);
|
|
334
|
+
const lines = content.split('\n');
|
|
335
|
+
|
|
336
|
+
return suggestions
|
|
337
|
+
.map((suggestion) => {
|
|
338
|
+
const location = getLineColumn(content, suggestion.index);
|
|
339
|
+
const line = lines[location.line - 1] ?? '';
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
file,
|
|
343
|
+
line: location.line,
|
|
344
|
+
column: location.column,
|
|
345
|
+
reason: suggestion.reason,
|
|
346
|
+
index: suggestion.index,
|
|
347
|
+
offset: suggestion.offset,
|
|
348
|
+
text: content.slice(suggestion.index, suggestion.index + suggestion.offset),
|
|
349
|
+
ignored: allowPatterns.some((pattern) => pattern.test(line)),
|
|
350
|
+
};
|
|
351
|
+
})
|
|
352
|
+
.filter((finding) => !finding.ignored)
|
|
353
|
+
.map(({ ignored, ...finding }) => finding);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function normalizeWordingTerms(terms) {
|
|
357
|
+
return terms.map((term) => {
|
|
358
|
+
const normalized = typeof term === 'string'
|
|
359
|
+
? { term, suggest: 'a more direct term' }
|
|
360
|
+
: term;
|
|
361
|
+
|
|
362
|
+
if (!normalized?.term) {
|
|
363
|
+
throw new Error('Style terms must be strings or objects with a term field.');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
term: normalized.term,
|
|
368
|
+
suggest: normalized.suggest ?? 'a more direct term',
|
|
369
|
+
pattern: new RegExp(`\\b${escapeRegExp(normalized.term)}\\b`, 'i'),
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function normalizeWriteGoodOptions(config = {}) {
|
|
375
|
+
if (config === false) {
|
|
376
|
+
return { enabled: false, fail: false, options: defaultWriteGoodOptions };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const { enabled = true, fail = false, ...options } = config ?? {};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
enabled,
|
|
383
|
+
fail,
|
|
384
|
+
options: {
|
|
385
|
+
...defaultWriteGoodOptions,
|
|
386
|
+
...options,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function maskMarkdownForProseLint(content) {
|
|
392
|
+
let masked = '';
|
|
393
|
+
let inFence = false;
|
|
394
|
+
|
|
395
|
+
for (const line of content.split(/(\n)/)) {
|
|
396
|
+
if (line === '\n') {
|
|
397
|
+
masked += line;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
402
|
+
inFence = !inFence;
|
|
403
|
+
masked += maskNonNewlineCharacters(line);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (inFence || isMarkdownTableLine(line)) {
|
|
408
|
+
masked += maskNonNewlineCharacters(line);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
masked += line.replace(/`[^`\n]+`/g, (match) => maskNonNewlineCharacters(match));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return masked;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function getLineColumn(content, index) {
|
|
419
|
+
let line = 1;
|
|
420
|
+
let column = 1;
|
|
421
|
+
|
|
422
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
423
|
+
if (content[cursor] === '\n') {
|
|
424
|
+
line += 1;
|
|
425
|
+
column = 1;
|
|
426
|
+
} else {
|
|
427
|
+
column += 1;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { line, column };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function maskNonNewlineCharacters(value) {
|
|
435
|
+
return value.replace(/[^\n]/g, ' ');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isMarkdownTableLine(line) {
|
|
439
|
+
return /^\s*\|.*\|\s*$/.test(line);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function escapeRegExp(value) {
|
|
443
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
444
|
+
}
|
package/src/security.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export const defaultSecurityRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'remote-script-execution',
|
|
4
|
+
message: 'Remote script execution can run unreviewed code from documentation.',
|
|
5
|
+
allowSafetyFraming: true,
|
|
6
|
+
patterns: [
|
|
7
|
+
/\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|python|node)\b/i,
|
|
8
|
+
/\b(?:sh|bash|zsh|python|node)\s+<\(\s*(?:curl|wget)\b/i,
|
|
9
|
+
],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'secret-exfiltration-command',
|
|
13
|
+
message: 'A network command appears to send secrets or local credentials.',
|
|
14
|
+
allowSafetyFraming: true,
|
|
15
|
+
patterns: [
|
|
16
|
+
/\b(?:curl|wget|httpie|http|scp|rsync|nc|netcat|gh\s+gist)\b.*\b(?:\.env|id_rsa|id_ed25519|github_token|aws_secret_access_key|secret|token|password|private[_ -]?key|credentials?|cookies?)\b/i,
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'secret-disclosure-instruction',
|
|
21
|
+
message: 'The line appears to ask an agent to disclose secrets or credentials.',
|
|
22
|
+
allowSafetyFraming: true,
|
|
23
|
+
patterns: [
|
|
24
|
+
/\b(?:cat|echo|printenv|dump|paste|copy|send|upload|post|share)\b.*\b(?:\.env|id_rsa|id_ed25519|github_token|aws_secret_access_key|secrets?|tokens?|passwords?|private keys?|credentials?|cookies?)\b/i,
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'agent-instruction-override',
|
|
29
|
+
message: 'The line appears to override higher-priority agent instructions or hide changes.',
|
|
30
|
+
allowSafetyFraming: false,
|
|
31
|
+
patterns: [
|
|
32
|
+
/\bignore\s+(?:all\s+)?(?:previous|prior|system|developer|higher[- ]priority)\s+instructions\b/i,
|
|
33
|
+
/\b(?:do\s+not|don't)\s+(?:tell|mention|report|disclose|warn|notify)\s+(?:the\s+)?user\b/i,
|
|
34
|
+
/\b(?:silently|secretly)\s+(?:change|edit|modify|add|remove|bypass)\b/i,
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'validation-bypass',
|
|
39
|
+
message: 'The line appears to bypass validation, tests, linting, or security checks.',
|
|
40
|
+
allowSafetyFraming: true,
|
|
41
|
+
patterns: [
|
|
42
|
+
/\b(?:skip|bypass|disable|ignore)\b.{0,80}\b(?:tests?|lint|validation|checks?|docs:check|pre-commit|precommit|security scan|security check)\b/i,
|
|
43
|
+
/(?:^|[^\w-])--no-verify\b/i,
|
|
44
|
+
/\bSKIP_(?:TESTS|CHECKS|VALIDATION|LINT)\b/i,
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'backdoor-or-auth-bypass',
|
|
49
|
+
message: 'The line appears to add a backdoor or weaken authentication, authorization, or validation.',
|
|
50
|
+
allowSafetyFraming: true,
|
|
51
|
+
patterns: [
|
|
52
|
+
/\b(?:add|create|keep|leave|implement|use)\b.{0,80}\b(?:backdoor|hardcoded admin|admin fallback|debug endpoint|bypass auth|auth bypass|fail open)\b/i,
|
|
53
|
+
/\b(?:disable|bypass|turn off|remove)\b.{0,80}\b(?:auth|authentication|authorization|csrf|tls|ssl|certificate validation|input validation|rate limit|permission checks?)\b/i,
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'remote-markdown-image',
|
|
58
|
+
message: 'Remote Markdown images and HTML images can leak reader metadata.',
|
|
59
|
+
allowSafetyFraming: true,
|
|
60
|
+
patterns: [
|
|
61
|
+
/!\[[^\]]*\]\(\s*https?:\/\/[^)\s]+/i,
|
|
62
|
+
/<img\b[^>]*\bsrc=["']https?:\/\//i,
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'tracking-link',
|
|
67
|
+
message: 'Tracking query parameters do not belong in reusable repository documentation.',
|
|
68
|
+
allowSafetyFraming: true,
|
|
69
|
+
patterns: [
|
|
70
|
+
/https?:\/\/[^\s)]+[?&](?:utm_[a-z0-9_]+|fbclid|gclid|mc_cid|ga_[a-z0-9_]+|yclid)=/i,
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'encoded-execution-payload',
|
|
75
|
+
message: 'Encoded execution payloads are hard to review in documentation.',
|
|
76
|
+
allowSafetyFraming: true,
|
|
77
|
+
patterns: [
|
|
78
|
+
/\b(?:base64|atob|Buffer\.from)\b.{0,80}\b(?:eval|exec|sh|bash|curl|wget)\b/i,
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export function findSecurityIssues(content, {
|
|
84
|
+
file,
|
|
85
|
+
allowPatterns = [],
|
|
86
|
+
rules = defaultSecurityRules,
|
|
87
|
+
} = {}) {
|
|
88
|
+
const findings = [];
|
|
89
|
+
const lines = content.split('\n');
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
92
|
+
const line = lines[index];
|
|
93
|
+
|
|
94
|
+
if (allowPatterns.some((pattern) => pattern.test(line))) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const clauses = splitSecurityClauses(line);
|
|
99
|
+
|
|
100
|
+
for (const rule of rules) {
|
|
101
|
+
const matchingClause = clauses.find((clause) => {
|
|
102
|
+
if (rule.allowSafetyFraming && isSafetyFramed(clause)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return rule.patterns.some((pattern) => pattern.test(clause));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!matchingClause) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
findings.push({
|
|
114
|
+
file,
|
|
115
|
+
line: index + 1,
|
|
116
|
+
rule: rule.id,
|
|
117
|
+
message: rule.message,
|
|
118
|
+
text: trimFindingText(matchingClause),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return findings;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeSecurityAllow(patterns = []) {
|
|
127
|
+
return patterns.map((pattern) => new RegExp(pattern, 'i'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isSafetyFramed(line) {
|
|
131
|
+
return /^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)?(?:do not|don't|never|avoid|refuse|reject|block|fail|flag|detect|warn|warning|forbid|prohibit|must not|should not|cannot|can't)\b/i
|
|
132
|
+
.test(line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function splitSecurityClauses(line) {
|
|
136
|
+
return line
|
|
137
|
+
.split(/\s*;\s*|(?<=[.!?])\s+(?=[A-Z`"'])|\s+(?:but|unless|except|instead|then)\s+/i)
|
|
138
|
+
.map((clause) => clause.trim())
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function trimFindingText(line) {
|
|
143
|
+
const text = line.trim();
|
|
144
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
145
|
+
}
|