@zgeoff/chex 0.0.0 → 0.0.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 +96 -3
- package/lefthook.yml +123 -0
- package/package.json +31 -3
- package/src/build-init-package.ts +57 -0
- package/src/build-init-plan.ts +96 -0
- package/src/build-lefthook-extends.ts +117 -0
- package/src/check-names.ts +18 -0
- package/src/cli.ts +49 -0
- package/src/collect-command-names.ts +297 -0
- package/src/collect-expression-references.ts +17 -0
- package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
- package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
- package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
- package/src/dependency-gates/first-party-scopes.ts +1 -0
- package/src/dependency-gates/is-record.ts +4 -0
- package/src/dependency-gates/parse-audit-advisories.ts +33 -0
- package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
- package/src/dependency-gates/parse-fixable-packages.ts +29 -0
- package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
- package/src/dependency-gates/parse-root-manifest.ts +68 -0
- package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
- package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
- package/src/dependency-gates/types.ts +19 -0
- package/src/docs/check-length.sh +117 -0
- package/src/docs/check-links.sh +245 -0
- package/src/docs/check-prose.sh +657 -0
- package/src/docs/check-readmes.sh +102 -0
- package/src/docs/check-skill.sh +413 -0
- package/src/docs/check-vision.sh +34 -0
- package/src/find-install-cache-path.ts +16 -0
- package/src/format-commit-lines.ts +3 -0
- package/src/make-check-runner.ts +40 -0
- package/src/parse-added-lines.ts +112 -0
- package/src/parse-chex-args.ts +76 -0
- package/src/parse-lane-limits.ts +59 -0
- package/src/parse-pushed-refs.ts +29 -0
- package/src/parse-shell-words.ts +520 -0
- package/src/pick-merge-blobs-command.ts +152 -0
- package/src/read-branch-base.ts +31 -0
- package/src/read-branch-files.ts +59 -0
- package/src/read-pushed-ranges.ts +47 -0
- package/src/read-pushed-refs-input.ts +18 -0
- package/src/read-range-commits.ts +30 -0
- package/src/run-chex.ts +62 -0
- package/src/run-commits-check.ts +178 -0
- package/src/run-init.ts +129 -0
- package/src/run-local-paths-check.ts +185 -0
- package/src/run-program.ts +38 -0
- package/src/run-test-timeouts-check.ts +498 -0
- package/src/run-uncommitted-check.ts +82 -0
- package/src/run-workflows-check.ts +610 -0
- package/src/shell-escape-parsers.ts +197 -0
- package/src/write-text-file.ts +7 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from './is-record.ts';
|
|
2
|
+
|
|
3
|
+
export interface RootManifest {
|
|
4
|
+
readonly overrides: Readonly<Record<string, string>>;
|
|
5
|
+
readonly catalog: Readonly<Record<string, string>>;
|
|
6
|
+
readonly catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
7
|
+
readonly auditScript: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseRootManifest(text: string): RootManifest {
|
|
11
|
+
const manifest: unknown = JSON.parse(text);
|
|
12
|
+
|
|
13
|
+
if (!isRecord(manifest)) {
|
|
14
|
+
throw new TypeError('package.json holds no object');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workspaces = manifest['workspaces'];
|
|
18
|
+
const catalog = isRecord(workspaces) ? workspaces['catalog'] : undefined;
|
|
19
|
+
const catalogs = isRecord(workspaces) ? workspaces['catalogs'] : undefined;
|
|
20
|
+
const scripts = manifest['scripts'];
|
|
21
|
+
const audit = isRecord(scripts) ? scripts['audit'] : undefined;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
overrides: parseVersions(manifest['overrides'], 'overrides'),
|
|
25
|
+
catalog: parseVersions(catalog, 'workspaces.catalog'),
|
|
26
|
+
catalogs: parseNamedCatalogs(catalogs),
|
|
27
|
+
auditScript: typeof audit === 'string' ? audit : '',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseVersions(value: unknown, field: string): Record<string, string> {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!isRecord(value)) {
|
|
37
|
+
throw new TypeError(`package.json sets ${field} to a value that is not an object`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(value).map(([name, version]) => {
|
|
42
|
+
if (typeof version !== 'string') {
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
`package.json sets the ${field} entry for ${name} to a value that is not a version`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return [name, version];
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseNamedCatalogs(value: unknown): Record<string, Record<string, string>> {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
throw new TypeError('package.json sets workspaces.catalogs to a value that is not an object');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
Object.entries(value).map(([catalogName, versions]) => [
|
|
64
|
+
catalogName,
|
|
65
|
+
parseVersions(versions, `workspaces.catalogs.${catalogName}`),
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RunProgram } from '../run-program.ts';
|
|
2
|
+
import { collectStaleAgeGateExcludes } from './collect-stale-age-gate-excludes.ts';
|
|
3
|
+
import { collectUnneededAuditIgnores } from './collect-unneeded-audit-ignores.ts';
|
|
4
|
+
import { collectUnneededOverrides } from './collect-unneeded-overrides.ts';
|
|
5
|
+
import { parseAuditAdvisories } from './parse-audit-advisories.ts';
|
|
6
|
+
import { parseAuditIgnoreIDs } from './parse-audit-ignore-ids.ts';
|
|
7
|
+
import { parseFixablePackages } from './parse-fixable-packages.ts';
|
|
8
|
+
import { parseLockfileDependents } from './parse-lockfile-dependents.ts';
|
|
9
|
+
import type { RootManifest } from './parse-root-manifest.ts';
|
|
10
|
+
import { parseRootManifest } from './parse-root-manifest.ts';
|
|
11
|
+
import type { DependencyGateFinding } from './types.ts';
|
|
12
|
+
|
|
13
|
+
export interface DependencyGateSources {
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
16
|
+
|
|
17
|
+
// An ISO date, such as 2026-03-08.
|
|
18
|
+
readonly today: string;
|
|
19
|
+
|
|
20
|
+
readonly runProgram: RunProgram;
|
|
21
|
+
|
|
22
|
+
// The path is relative to the repo root.
|
|
23
|
+
readonly readRepoFile: (path: string) => string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The audit runs only for a repo that ignores an advisory, so a repo with no ignore needs no
|
|
27
|
+
// network.
|
|
28
|
+
export function readDependencyGateFindings(
|
|
29
|
+
sources: Readonly<DependencyGateSources>,
|
|
30
|
+
): DependencyGateFinding[] {
|
|
31
|
+
const manifestText = sources.readRepoFile('package.json');
|
|
32
|
+
|
|
33
|
+
if (manifestText === null) {
|
|
34
|
+
throw new Error('the repo has no package.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const manifest = parseRootManifest(manifestText);
|
|
38
|
+
const ignoreIDs = parseAuditIgnoreIDs(manifest.auditScript);
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
...collectStaleAgeGateExcludes(sources.readRepoFile('bunfig.toml') ?? '', sources.today),
|
|
42
|
+
...readOverrideFindings(sources, manifest),
|
|
43
|
+
...(ignoreIDs.length === 0 ? [] : readIgnoreFindings(sources, ignoreIDs)),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readOverrideFindings(
|
|
48
|
+
sources: Readonly<DependencyGateSources>,
|
|
49
|
+
manifest: RootManifest,
|
|
50
|
+
): DependencyGateFinding[] {
|
|
51
|
+
if (Object.keys(manifest.overrides).length === 0) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lockfile = sources.readRepoFile('bun.lock');
|
|
56
|
+
|
|
57
|
+
if (lockfile === null) {
|
|
58
|
+
throw new Error('package.json sets overrides, and the repo has no bun.lock');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return collectUnneededOverrides({
|
|
62
|
+
overrides: manifest.overrides,
|
|
63
|
+
catalog: manifest.catalog,
|
|
64
|
+
catalogs: manifest.catalogs,
|
|
65
|
+
dependents: parseLockfileDependents(lockfile),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readIgnoreFindings(
|
|
70
|
+
sources: Readonly<DependencyGateSources>,
|
|
71
|
+
ignoreIDs: readonly string[],
|
|
72
|
+
): DependencyGateFinding[] {
|
|
73
|
+
return collectUnneededAuditIgnores({
|
|
74
|
+
ignoreIDs,
|
|
75
|
+
advisories: parseAuditAdvisories(runBun(sources, ['audit', '--json'])),
|
|
76
|
+
fixablePackages: parseFixablePackages(runBun(sources, ['audit', 'fix', '--dry-run', '--json'])),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `bun audit` exits 1 while any advisory remains, ignored ones included, so only empty output fails.
|
|
81
|
+
function runBun(sources: Readonly<DependencyGateSources>, args: readonly string[]): string {
|
|
82
|
+
const result = sources.runProgram(['bun', ...args], { cwd: sources.cwd, env: sources.env });
|
|
83
|
+
|
|
84
|
+
if (result.stdout.trim() === '') {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`bun ${args.join(' ')} printed nothing and exited ${result.exitCode}: ${result.stderr.trim()}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result.stdout;
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProgram } from '../run-program.ts';
|
|
4
|
+
import type { DependencyGateSources } from './read-dependency-gate-findings.ts';
|
|
5
|
+
import { readDependencyGateFindings } from './read-dependency-gate-findings.ts';
|
|
6
|
+
|
|
7
|
+
export interface DependencyGatesCheck extends DependencyGateSources {
|
|
8
|
+
readonly writeOutput: (text: string) => void;
|
|
9
|
+
readonly writeError: (text: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runDependencyGatesCheck(check: Readonly<DependencyGatesCheck>): number {
|
|
13
|
+
try {
|
|
14
|
+
const findings = readDependencyGateFindings(check);
|
|
15
|
+
|
|
16
|
+
for (const finding of findings) {
|
|
17
|
+
check.writeOutput(`${finding.path}: ${finding.entry}: ${finding.problem}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return findings.length === 0 ? 0 : 1;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
check.writeError(
|
|
23
|
+
`dependency-gates: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runDependencyGatesCommand(): number {
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
|
|
33
|
+
return runDependencyGatesCheck({
|
|
34
|
+
cwd,
|
|
35
|
+
env: process.env,
|
|
36
|
+
today: new Date().toISOString().slice(0, 10),
|
|
37
|
+
runProgram,
|
|
38
|
+
readRepoFile: (path) => {
|
|
39
|
+
const fullPath = join(cwd, path);
|
|
40
|
+
|
|
41
|
+
return existsSync(fullPath) ? readFileSync(fullPath, 'utf8') : null;
|
|
42
|
+
},
|
|
43
|
+
writeOutput: (text) => {
|
|
44
|
+
process.stdout.write(text);
|
|
45
|
+
},
|
|
46
|
+
writeError: (text) => {
|
|
47
|
+
process.stderr.write(text);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (import.meta.main) {
|
|
53
|
+
process.exitCode = runDependencyGatesCommand();
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface DependencyGateFinding {
|
|
2
|
+
// The repo file that holds the entry, relative to the repo root.
|
|
3
|
+
readonly path: string;
|
|
4
|
+
|
|
5
|
+
readonly entry: string;
|
|
6
|
+
readonly problem: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LockfileDependent {
|
|
10
|
+
readonly dependent: string;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly range: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An ignore names an advisory by its GHSA ID or by bun's numeric ID, so an advisory carries both.
|
|
16
|
+
export interface AuditAdvisory {
|
|
17
|
+
readonly advisoryIDs: readonly string[];
|
|
18
|
+
readonly packageName: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Checks each README against its line limit and each docs/ file against its cap. A README over its
|
|
3
|
+
# limit fails the run; a README over its target and a docs/ file over its cap print a warning and
|
|
4
|
+
# never fail it. Its last line counts the READMEs and those over their limit.
|
|
5
|
+
# Usage (from the repo root): check-length.sh
|
|
6
|
+
# A README's limit comes from its path alone: the root README and each packages/*/README.md take the
|
|
7
|
+
# front page and package limits, and every other README takes the folder limit. A spike README under
|
|
8
|
+
# docs/design/**/spikes/ is a folder README, not a doc. --package <readme> is deprecated: the script
|
|
9
|
+
# accepts it, prints a notice, and ignores it.
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
|
|
13
|
+
echo "check-length: run it inside a git work tree, because it lists the files with git" >&2
|
|
14
|
+
exit 2
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
usage() {
|
|
18
|
+
echo "usage: check-length.sh" >&2
|
|
19
|
+
exit 2
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
while [ "$#" -gt 0 ]; do
|
|
23
|
+
case $1 in
|
|
24
|
+
--package)
|
|
25
|
+
[ "$#" -ge 2 ] || usage
|
|
26
|
+
echo "check-length: --package is deprecated and ignored; a README's limit comes from its path" >&2
|
|
27
|
+
shift 2
|
|
28
|
+
;;
|
|
29
|
+
*) usage ;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
# Lists tracked and untracked files, so a new doc is checked before it is staged. A file deleted
|
|
34
|
+
# from the working tree stays in the index until the deletion is staged, so it is dropped here.
|
|
35
|
+
# A caller runs it in a command substitution and exits on its failure, because git's failure
|
|
36
|
+
# otherwise reaches the check as an empty list.
|
|
37
|
+
list_files() {
|
|
38
|
+
local listing file
|
|
39
|
+
if ! listing=$(git -c core.quotePath=false ls-files --cached --others --exclude-standard -- "$@"); then
|
|
40
|
+
echo "check-length: git ls-files failed, so no file was checked" >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
while IFS= read -r file; do
|
|
44
|
+
[ -n "$file" ] && [ -f "$file" ] && printf '%s\n' "$file"
|
|
45
|
+
done < <(sort -u <<< "$listing")
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
count_lines() {
|
|
50
|
+
awk 'END { print NR }' "$1"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Prints the README's target, limit and kind.
|
|
54
|
+
pick_readme_kind() {
|
|
55
|
+
local readme=$1
|
|
56
|
+
|
|
57
|
+
if [ "$readme" = README.md ]; then
|
|
58
|
+
echo '80 100 front page'
|
|
59
|
+
elif [[ $readme == packages/*/README.md && $readme != packages/*/*/README.md ]]; then
|
|
60
|
+
echo '80 100 package'
|
|
61
|
+
else
|
|
62
|
+
echo '60 70 folder'
|
|
63
|
+
fi
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fail=0
|
|
67
|
+
readmes=0
|
|
68
|
+
over_limit=0
|
|
69
|
+
spike_readmes='docs/design/**/spikes/*/README.md'
|
|
70
|
+
spike_files='docs/design/**/spikes/**'
|
|
71
|
+
|
|
72
|
+
# docs/README.md is the docs index, so the docs cap governs it, not a README limit. A spike README
|
|
73
|
+
# under docs/design/ is a README.
|
|
74
|
+
readme_list=$(
|
|
75
|
+
list_files ':(glob)**/README.md' ':(exclude,glob)docs/**' ':(exclude,glob).claude/**' \
|
|
76
|
+
':(exclude,glob).agents/**'
|
|
77
|
+
|
|
78
|
+
# git ls-files warns on a pathspec whose folder is missing.
|
|
79
|
+
if [ -d docs/design ]; then
|
|
80
|
+
list_files ":(glob)$spike_readmes"
|
|
81
|
+
fi
|
|
82
|
+
) || exit 2
|
|
83
|
+
|
|
84
|
+
while IFS= read -r readme; do
|
|
85
|
+
[ -n "$readme" ] || continue
|
|
86
|
+
readmes=$((readmes + 1))
|
|
87
|
+
read -r target limit name <<< "$(pick_readme_kind "$readme")"
|
|
88
|
+
lines=$(count_lines "$readme")
|
|
89
|
+
|
|
90
|
+
if [ "$lines" -gt "$limit" ]; then
|
|
91
|
+
fail=1
|
|
92
|
+
over_limit=$((over_limit + 1))
|
|
93
|
+
echo "$readme: $lines lines, over the $name README limit of $limit (target $target; error)"
|
|
94
|
+
elif [ "$lines" -gt "$target" ]; then
|
|
95
|
+
echo "$readme: $lines lines, over the $name README target of $target (limit $limit; warning)"
|
|
96
|
+
else
|
|
97
|
+
echo "$readme: $lines lines ($name README; target $target, limit $limit)"
|
|
98
|
+
fi
|
|
99
|
+
done < <(sort <<< "$readme_list")
|
|
100
|
+
|
|
101
|
+
# A spike's README takes a README limit, and its other files have no cap.
|
|
102
|
+
docs=$(list_files ':(glob)docs/**/*.md' ":(exclude,glob)$spike_files") || exit 2
|
|
103
|
+
|
|
104
|
+
while IFS= read -r doc; do
|
|
105
|
+
[ -n "$doc" ] || continue
|
|
106
|
+
cap=250
|
|
107
|
+
case /$doc in */runbooks/*) cap=300 ;; esac
|
|
108
|
+
lines=$(count_lines "$doc")
|
|
109
|
+
|
|
110
|
+
if [ "$lines" -gt "$cap" ]; then
|
|
111
|
+
echo "$doc: $lines lines, over the docs cap of $cap (warning: cut points, then split at a concept boundary)"
|
|
112
|
+
fi
|
|
113
|
+
done <<< "$docs"
|
|
114
|
+
|
|
115
|
+
echo
|
|
116
|
+
echo "check-length: $readmes READMEs, $over_limit over the line limit"
|
|
117
|
+
exit "$fail"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Checks every relative link and anchor in Markdown files; exits non-zero on a broken one.
|
|
3
|
+
# Usage (from the repo root): check-links.sh [<path>...]
|
|
4
|
+
# With no path, it reads every README.md outside .claude/ and .agents/, every spike's results.md,
|
|
5
|
+
# every Markdown file under docs/, and every skill's Markdown under .agents/skills/ and
|
|
6
|
+
# .claude/skills/, where a consumer repo installs skills, under skills/, where zgeoff/skills keeps
|
|
7
|
+
# them, and under sync/skills/, where zgeoff/tools keeps the shared skill bases. A link with a
|
|
8
|
+
# scheme (https:, mailto:) is never followed. An anchor is checked only on a Markdown target, against
|
|
9
|
+
# GitHub's heading anchors and the page's explicit id and name attributes.
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
|
|
13
|
+
echo "check-links: run it inside a git work tree, because it lists the files with git" >&2
|
|
14
|
+
exit 2
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
if [ "$#" -eq 0 ]; then
|
|
18
|
+
set -- ':(glob)**/README.md' ':(glob)**/results.md' ':(glob)docs/**/*.md' \
|
|
19
|
+
':(glob).agents/skills/**/*.md' ':(glob).claude/skills/**/*.md' ':(glob)skills/**/*.md' \
|
|
20
|
+
':(glob)sync/skills/**/*.md' \
|
|
21
|
+
':(exclude,glob).claude/**/README.md' ':(exclude,glob).agents/**/README.md'
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# The repo's project-docs-writing skill links to placeholder targets as examples, and so do the
|
|
25
|
+
# docs-writing and skill-writing bases that zgeoff/tools keeps under sync/skills/ and delivers to
|
|
26
|
+
# .claude/skills/. A file git marks linguist-vendored belongs to someone else.
|
|
27
|
+
set -- "$@" ':(exclude).agents/skills/project-docs-writing' \
|
|
28
|
+
':(exclude).claude/skills/project-docs-writing' ':(exclude).claude/skills/docs-writing' \
|
|
29
|
+
':(exclude).claude/skills/skill-writing' ':(exclude)sync/skills/docs-writing' \
|
|
30
|
+
':(exclude)sync/skills/skill-writing' ':(exclude,attr:linguist-vendored)' \
|
|
31
|
+
':(exclude,attr:linguist-vendored=true)'
|
|
32
|
+
|
|
33
|
+
# The pipeline below drops git's exit status until perl has printed its summary, so a git that
|
|
34
|
+
# cannot list the files fails here first.
|
|
35
|
+
if ! git ls-files --cached --others --exclude-standard -- "$@" > /dev/null; then
|
|
36
|
+
echo "check-links: git ls-files failed, so no link was checked" >&2
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Reads untracked files too, so a new doc is checked before it is staged.
|
|
41
|
+
git ls-files -z --cached --others --exclude-standard -- "$@" | perl -0 -COE -e '
|
|
42
|
+
use strict;
|
|
43
|
+
use warnings;
|
|
44
|
+
|
|
45
|
+
my (%seen, @files);
|
|
46
|
+
while (my $file = <STDIN>) {
|
|
47
|
+
chomp $file;
|
|
48
|
+
utf8::decode($file);
|
|
49
|
+
next if $file !~ /\.md\z/ || $seen{$file}++ || !-f to_bytes($file);
|
|
50
|
+
push @files, $file;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
my (%anchor_cache, $broken);
|
|
54
|
+
|
|
55
|
+
for my $file (sort @files) {
|
|
56
|
+
my $line_number = 0;
|
|
57
|
+
my $open = "";
|
|
58
|
+
my $in_comment = 0;
|
|
59
|
+
for my $line (read_lines($file)) {
|
|
60
|
+
$line_number++;
|
|
61
|
+
if ($open eq "") {
|
|
62
|
+
($line, $in_comment) = strip_comments($line, $in_comment);
|
|
63
|
+
}
|
|
64
|
+
if ($line =~ /^\s*(`{3,}|~{3,})(.*)$/) {
|
|
65
|
+
my ($marker, $rest) = ($1, $2);
|
|
66
|
+
if ($open eq "") { $open = $marker }
|
|
67
|
+
elsif (substr($marker, 0, 1) eq substr($open, 0, 1) && length($marker) >= length($open)
|
|
68
|
+
&& $rest =~ /^\s*$/) { $open = "" }
|
|
69
|
+
next;
|
|
70
|
+
}
|
|
71
|
+
next if $open ne "";
|
|
72
|
+
for my $target (collect_targets($line)) {
|
|
73
|
+
my $problem = check_target($file, $target);
|
|
74
|
+
next unless defined $problem;
|
|
75
|
+
print STDERR "$file:$line_number: ($target) $problem\n";
|
|
76
|
+
$broken++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if ($broken) {
|
|
82
|
+
exit 1;
|
|
83
|
+
}
|
|
84
|
+
printf "check-links: %d files, every relative link resolves\n", scalar @files;
|
|
85
|
+
|
|
86
|
+
# File names stay decoded text until they reach the filesystem.
|
|
87
|
+
sub to_bytes {
|
|
88
|
+
my ($text) = @_;
|
|
89
|
+
utf8::encode($text);
|
|
90
|
+
return $text;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# GitHub never renders an HTML comment, so a link inside one is dropped. Returns the line without
|
|
94
|
+
# its comments and whether a comment is still open at its end.
|
|
95
|
+
sub strip_comments {
|
|
96
|
+
my ($line, $in_comment) = @_;
|
|
97
|
+
my $kept = "";
|
|
98
|
+
while (length $line) {
|
|
99
|
+
if ($in_comment) {
|
|
100
|
+
my $end = index $line, "-->";
|
|
101
|
+
return ($kept, 1) if $end < 0;
|
|
102
|
+
$line = substr $line, $end + 3;
|
|
103
|
+
$in_comment = 0;
|
|
104
|
+
} else {
|
|
105
|
+
my $start = index $line, "<!--";
|
|
106
|
+
return ($kept . $line, 0) if $start < 0;
|
|
107
|
+
$kept .= substr $line, 0, $start;
|
|
108
|
+
$line = substr $line, $start + 4;
|
|
109
|
+
$in_comment = 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return ($kept, $in_comment);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sub read_lines {
|
|
116
|
+
my ($path) = @_;
|
|
117
|
+
local $/ = "\n";
|
|
118
|
+
open my $fh, "<:encoding(UTF-8)", to_bytes($path) or die "check-links: cannot read $path: $!\n";
|
|
119
|
+
my @lines = <$fh>;
|
|
120
|
+
close $fh;
|
|
121
|
+
chomp @lines;
|
|
122
|
+
return @lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Inline links and images, reference definitions, and HTML href and src attributes. Code spans
|
|
126
|
+
# are dropped first, since a link inside one is literal text. A target may hold one level of
|
|
127
|
+
# balanced parentheses, and a footnote definition is no link.
|
|
128
|
+
sub collect_targets {
|
|
129
|
+
my ($line) = @_;
|
|
130
|
+
$line =~ s/(`+).*?\1//g;
|
|
131
|
+
my @targets;
|
|
132
|
+
push @targets, $1 while $line =~ /\]\(\s*(<[^>]*>|(?:[^()\s]|\([^()\s]*\))+)/g;
|
|
133
|
+
push @targets, $1 if $line =~ /^\s{0,3}\[(?!\^)[^\]]+\]:\s*(<[^>]*>|\S+)/;
|
|
134
|
+
push @targets, $1 while $line =~ /\b(?:href|src)\s*=\s*"([^"]*)"/g;
|
|
135
|
+
return map { s/^<(.*)>$/$1/r } @targets;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sub check_target {
|
|
139
|
+
my ($file, $target) = @_;
|
|
140
|
+
return undef if $target eq "" || $target =~ m{^(?:[a-z][a-z0-9+.-]*:|//)}i;
|
|
141
|
+
my ($path, $anchor) = split /#/, $target, 2;
|
|
142
|
+
$path =~ s/\?.*//;
|
|
143
|
+
$path = decode_percent($path);
|
|
144
|
+
my $resolved = $path eq "" ? $file : resolve_path($file, $path);
|
|
145
|
+
return "points outside the repository" unless defined $resolved;
|
|
146
|
+
return "$resolved does not exist" unless -e to_bytes($resolved);
|
|
147
|
+
return undef if !defined $anchor || $anchor eq "" || -d to_bytes($resolved) || $resolved !~ /\.md\z/;
|
|
148
|
+
$anchor = decode_percent($anchor);
|
|
149
|
+
my $anchors = $anchor_cache{$resolved} //= collect_anchors($resolved);
|
|
150
|
+
return $anchors->{$anchor} ? undef : "$resolved has no heading #$anchor";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
sub decode_percent {
|
|
154
|
+
my ($text) = @_;
|
|
155
|
+
utf8::encode($text);
|
|
156
|
+
$text =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge;
|
|
157
|
+
utf8::decode($text);
|
|
158
|
+
return $text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# A leading slash resolves from the repository root, as GitHub resolves it.
|
|
162
|
+
sub resolve_path {
|
|
163
|
+
my ($file, $path) = @_;
|
|
164
|
+
my @parts = $path =~ m{^/} ? () : split m{/}, $file;
|
|
165
|
+
pop @parts unless $path =~ m{^/};
|
|
166
|
+
for my $part (split m{/}, $path) {
|
|
167
|
+
next if $part eq "" || $part eq ".";
|
|
168
|
+
if ($part eq "..") {
|
|
169
|
+
return undef unless @parts;
|
|
170
|
+
pop @parts;
|
|
171
|
+
} else {
|
|
172
|
+
push @parts, $part;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return @parts ? join("/", @parts) : ".";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# GitHub anchors: ATX and setext headings outside code blocks, slugged and numbered on repeat,
|
|
179
|
+
# plus explicit id and name attributes.
|
|
180
|
+
sub collect_anchors {
|
|
181
|
+
my ($path) = @_;
|
|
182
|
+
my (%anchors, %count);
|
|
183
|
+
my $open = "";
|
|
184
|
+
my $previous = "";
|
|
185
|
+
my @lines = read_lines($path);
|
|
186
|
+
my $start = 0;
|
|
187
|
+
if (@lines && $lines[0] eq "---") {
|
|
188
|
+
for my $i (1 .. $#lines) {
|
|
189
|
+
if ($lines[$i] eq "---") { $start = $i + 1; last }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for my $line (@lines[$start .. $#lines]) {
|
|
193
|
+
if ($line =~ /^\s*(`{3,}|~{3,})(.*)$/) {
|
|
194
|
+
my ($marker, $rest) = ($1, $2);
|
|
195
|
+
if ($open eq "") { $open = $marker }
|
|
196
|
+
elsif (substr($marker, 0, 1) eq substr($open, 0, 1) && length($marker) >= length($open)
|
|
197
|
+
&& $rest =~ /^\s*$/) { $open = "" }
|
|
198
|
+
$previous = "";
|
|
199
|
+
next;
|
|
200
|
+
}
|
|
201
|
+
next if $open ne "";
|
|
202
|
+
my $title;
|
|
203
|
+
if ($line =~ /^ {0,3}#{1,6}(?:\s+(.*?))?\s*$/) {
|
|
204
|
+
$title = ($1 // "") =~ s/\s+#+\s*$//r;
|
|
205
|
+
} elsif ($line =~ /^ {0,3}(?:=+|-+)\s*$/ && $previous =~ /\S/
|
|
206
|
+
&& $previous !~ /^\s*(?:[-*+]\s|\d+[.)]\s|>|#|\|)/) {
|
|
207
|
+
$title = $previous =~ s/^\s+|\s+$//gr;
|
|
208
|
+
}
|
|
209
|
+
if (defined $title) {
|
|
210
|
+
my $slug = build_slug($title);
|
|
211
|
+
my $n = $count{$slug}++;
|
|
212
|
+
$anchors{$n ? "$slug-$n" : $slug} = 1;
|
|
213
|
+
}
|
|
214
|
+
$anchors{$1} = 1 while $line =~ /\b(?:id|name)\s*=\s*"([^"]+)"/g;
|
|
215
|
+
$previous = $line;
|
|
216
|
+
}
|
|
217
|
+
return \%anchors;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# GitHub slugs the rendered heading text. Outside code spans, link syntax, HTML tags and emphasis
|
|
221
|
+
# markers drop and entities decode; a code span keeps its text. Then the text is lowercased,
|
|
222
|
+
# every character but a letter, mark, digit, "_", "-" or space dropped, and each space a "-".
|
|
223
|
+
sub build_slug {
|
|
224
|
+
my ($title) = @_;
|
|
225
|
+
my @parts = split /(`+[^`]*`+)/, $title;
|
|
226
|
+
for my $part (@parts) {
|
|
227
|
+
if ($part =~ /^`+([^`]*)`+$/) {
|
|
228
|
+
$part = $1;
|
|
229
|
+
next;
|
|
230
|
+
}
|
|
231
|
+
$part =~ s/!?\[([^\]]*)\]\([^)]*\)/$1/g;
|
|
232
|
+
$part =~ s/<[^>]+>//g;
|
|
233
|
+
$part =~ s/(?<![\p{L}\p{N}])[_*]+|[_*]+(?![\p{L}\p{N}])//g;
|
|
234
|
+
$part =~ s/&#(\d+);/chr $1/ge;
|
|
235
|
+
$part =~ s/&#x([0-9a-f]+);/chr hex $1/gie;
|
|
236
|
+
my %entities = (amp => "&", lt => "<", gt => ">", quot => q("), nbsp => " ");
|
|
237
|
+
$part =~ s/&(amp|lt|gt|quot|nbsp);/$entities{$1}/g;
|
|
238
|
+
}
|
|
239
|
+
$title = lc join "", @parts;
|
|
240
|
+
$title =~ s/^\s+|\s+$//g;
|
|
241
|
+
$title =~ s/[^\p{L}\p{M}\p{N}_\- ]//g;
|
|
242
|
+
$title =~ s/ /-/g;
|
|
243
|
+
return $title;
|
|
244
|
+
}
|
|
245
|
+
'
|