clckernel 1.2.19 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +734 -302
- package/src/gitignore.js +60 -14
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -38
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Backend Database Efficiency & N+1
|
|
4
|
-
AST scanner detecting:
|
|
5
|
-
1.
|
|
6
|
-
2. Blocking time.sleep() calls inside async
|
|
7
|
-
3. Unbounded .all() queries missing pagination/limits in API routes
|
|
8
|
-
4. Missing eager loading patterns (select_related, prefetch_related, selectinload, joinedload)
|
|
3
|
+
Backend Database Efficiency & N+1 Heuristic
|
|
4
|
+
Python AST scanner detecting:
|
|
5
|
+
1. ORM-like query calls inside loops/comprehensions (N+1 heuristic for SQLAlchemy, Django ORM, SQLModel)
|
|
6
|
+
2. Blocking time.sleep() calls inside async functions
|
|
9
7
|
|
|
10
8
|
Self-contained: uses only Python standard library (ast, subprocess, pathlib).
|
|
11
9
|
"""
|
|
@@ -14,18 +12,61 @@ from __future__ import annotations
|
|
|
14
12
|
import ast
|
|
15
13
|
import os
|
|
16
14
|
import pathlib
|
|
17
|
-
import subprocess
|
|
18
15
|
import sys
|
|
19
16
|
|
|
17
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
18
|
+
# loaded by audit.py through importlib.
|
|
19
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
if _TOOLS_DIR not in sys.path:
|
|
21
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
22
|
+
import _changes # noqa: E402
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
QUERY_METHODS = {
|
|
22
26
|
"filter", "get", "all", "first", "execute", "scalars", "scalar",
|
|
23
27
|
"fetch", "fetchall", "fetchone", "query", "raw"
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
30
|
+
# Receiver names (case-insensitive) that hold an ORM session / connection:
|
|
31
|
+
# session.get(...), db.execute(...), self.session.scalars(...).
|
|
32
|
+
SESSION_NAMES = {"session", "db", "db_session"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_orm_receiver(node: ast.AST) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
True when a method call on `node` looks like an ORM query:
|
|
38
|
+
- the attribute chain contains `.objects` (Django manager),
|
|
39
|
+
- it ends in a session-like name (session, db, db_session; self.session),
|
|
40
|
+
- it is the result of a `.query(...)` call (SQLAlchemy Query chain),
|
|
41
|
+
- it is `Model.query` (Flask-SQLAlchemy).
|
|
42
|
+
Anything else (dict.get, requests.get, config.get) is not a query.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(node, ast.Name):
|
|
45
|
+
return node.id.lower() in SESSION_NAMES
|
|
46
|
+
if isinstance(node, ast.Attribute):
|
|
47
|
+
if node.attr == "objects" or node.attr == "query":
|
|
48
|
+
return True
|
|
49
|
+
if node.attr.lower() in SESSION_NAMES:
|
|
50
|
+
# Django's request.session is a dict-like store, not an ORM session.
|
|
51
|
+
return not (isinstance(node.value, ast.Name) and node.value.id == "request")
|
|
52
|
+
return _is_orm_receiver(node.value)
|
|
53
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
54
|
+
if node.func.attr == "query":
|
|
55
|
+
return True
|
|
56
|
+
return _is_orm_receiver(node.func.value)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _chain_calls(node: ast.AST) -> list[ast.Call]:
|
|
61
|
+
"""Calls inside the receiver chain of `node` (x.query(...).filter(...) -> both calls)."""
|
|
62
|
+
calls: list[ast.Call] = []
|
|
63
|
+
while isinstance(node, (ast.Attribute, ast.Call)):
|
|
64
|
+
if isinstance(node, ast.Call):
|
|
65
|
+
calls.append(node)
|
|
66
|
+
node = node.func
|
|
67
|
+
else:
|
|
68
|
+
node = node.value
|
|
69
|
+
return calls
|
|
29
70
|
|
|
30
71
|
|
|
31
72
|
class LoopQueryVisitor(ast.NodeVisitor):
|
|
@@ -34,6 +75,8 @@ class LoopQueryVisitor(ast.NodeVisitor):
|
|
|
34
75
|
self.violations: list[dict] = []
|
|
35
76
|
self._loop_depth = 0
|
|
36
77
|
self._is_async_func = False
|
|
78
|
+
# Calls already covered by an enclosing query chain (one finding per chain).
|
|
79
|
+
self._reported: set[int] = set()
|
|
37
80
|
|
|
38
81
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
39
82
|
prev = self._is_async_func
|
|
@@ -84,30 +127,20 @@ class LoopQueryVisitor(ast.NodeVisitor):
|
|
|
84
127
|
})
|
|
85
128
|
|
|
86
129
|
# 2. Detect DB queries inside loops (N+1 query hazard)
|
|
87
|
-
if
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
caller_name = val.attr.lower()
|
|
102
|
-
if caller_name in QUERY_CALLERS or attr_name in QUERY_METHODS:
|
|
103
|
-
is_db_call = True
|
|
104
|
-
|
|
105
|
-
if is_db_call and attr_name in QUERY_METHODS:
|
|
106
|
-
self.violations.append({
|
|
107
|
-
"lineNo": node.lineno,
|
|
108
|
-
"rule": "db-query-in-loop",
|
|
109
|
-
"message": f"Database query call '.{attr_name}()' detected inside loop/comprehension. Causes N+1 performance bottleneck. Use bulk queries or eager loading (selectinload/joinedload/select_related)."
|
|
110
|
-
})
|
|
130
|
+
if (
|
|
131
|
+
self._loop_depth > 0
|
|
132
|
+
and id(node) not in self._reported
|
|
133
|
+
and isinstance(node.func, ast.Attribute)
|
|
134
|
+
and node.func.attr in QUERY_METHODS
|
|
135
|
+
and _is_orm_receiver(node.func.value)
|
|
136
|
+
):
|
|
137
|
+
attr_name = node.func.attr
|
|
138
|
+
self._reported.update(id(c) for c in _chain_calls(node.func.value))
|
|
139
|
+
self.violations.append({
|
|
140
|
+
"lineNo": node.lineno,
|
|
141
|
+
"rule": "db-query-in-loop",
|
|
142
|
+
"message": f"Database query call '.{attr_name}()' detected inside loop/comprehension. Causes N+1 performance bottleneck. Use bulk queries or eager loading (selectinload/joinedload/select_related)."
|
|
143
|
+
})
|
|
111
144
|
|
|
112
145
|
self.generic_visit(node)
|
|
113
146
|
|
|
@@ -115,32 +148,16 @@ class LoopQueryVisitor(ast.NodeVisitor):
|
|
|
115
148
|
def check(target_dir: str | None = None) -> dict:
|
|
116
149
|
dir_path = pathlib.Path(target_dir or os.getcwd()).resolve()
|
|
117
150
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
except Exception:
|
|
129
|
-
try:
|
|
130
|
-
out = subprocess.check_output(
|
|
131
|
-
["git", "diff", "--name-only", "--cached"],
|
|
132
|
-
cwd=dir_path,
|
|
133
|
-
stderr=subprocess.PIPE,
|
|
134
|
-
text=True
|
|
135
|
-
)
|
|
136
|
-
files = [f.strip() for f in out.splitlines() if f.strip().endswith(".py")]
|
|
137
|
-
except Exception:
|
|
138
|
-
return {
|
|
139
|
-
"exit_code": 0,
|
|
140
|
-
"skipped": True,
|
|
141
|
-
"name": "check_db_efficiency",
|
|
142
|
-
"data": {"message": "No git diff available"}
|
|
143
|
-
}
|
|
151
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
152
|
+
changes = _changes.changed_files(str(dir_path), extensions=[".py"])
|
|
153
|
+
mode, files = changes["mode"], changes["files"]
|
|
154
|
+
if mode is None:
|
|
155
|
+
return {
|
|
156
|
+
"exit_code": 0,
|
|
157
|
+
"skipped": True,
|
|
158
|
+
"name": "check_db_efficiency",
|
|
159
|
+
"data": {"message": "No git diff available"}
|
|
160
|
+
}
|
|
144
161
|
|
|
145
162
|
if not files:
|
|
146
163
|
return {
|
|
@@ -153,13 +170,11 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
153
170
|
violations: dict[str, list[dict]] = {}
|
|
154
171
|
|
|
155
172
|
for file_rel in files:
|
|
156
|
-
|
|
157
|
-
if
|
|
173
|
+
source = _changes.read_file(str(dir_path), file_rel, mode)
|
|
174
|
+
if source is None:
|
|
158
175
|
continue
|
|
159
|
-
|
|
160
176
|
try:
|
|
161
|
-
|
|
162
|
-
tree = ast.parse(source, filename=str(file_path))
|
|
177
|
+
tree = ast.parse(source, filename=file_rel)
|
|
163
178
|
except Exception:
|
|
164
179
|
continue
|
|
165
180
|
|
package/tools/check_hitl.js
CHANGED
|
@@ -1,97 +1,140 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Human-in-the-Loop (HITL) Checkpoint Guard
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every skill and playbook changed in the snapshot must declare its human
|
|
5
|
+
* checkpoint with an explicit marker:
|
|
6
|
+
* - a line starting with `HITL:` (list, quote and bold markup allowed), or
|
|
7
|
+
* - a heading `## Human checkpoint` or `## HITL` (any level from 2 to 6).
|
|
8
|
+
* A word such as "review" or "confirm" is not a marker. Markers inside fenced
|
|
9
|
+
* code blocks do not count.
|
|
10
|
+
* Checked files: SKILL.md under skills/, .agents/skills/ or .claude/skills/,
|
|
11
|
+
* flat skills/*.md files, and Markdown under playbooks/ (README.md excluded).
|
|
12
|
+
* The kernel's own skill (clckernel_cli) is checked like any other skill, even
|
|
13
|
+
* though the harness registry lists it (other guards skip registry files).
|
|
14
|
+
* Ignored: the retired kernel skill (clc_forge_cli, removed by `update`) and
|
|
15
|
+
* skills vendored by third-party tool plugins (impeccable).
|
|
7
16
|
*
|
|
8
|
-
*
|
|
17
|
+
* The project config (.clckernel.yaml) can change all three choices:
|
|
18
|
+
* hitl.markers regexes (case-insensitive, one line, outside code fences)
|
|
19
|
+
* that replace the two markers above
|
|
20
|
+
* hitl.paths globs of the changed files to check, replacing the selection above
|
|
21
|
+
* hitl.optOut { key, value }: a file whose frontmatter has `key: value` is exempt
|
|
22
|
+
* Self-contained: uses only Node.js built-ins (and the sibling _config.js
|
|
23
|
+
* when the project has a config file).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} targetDir - Repository root (defaults to cwd)
|
|
9
26
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
10
27
|
*/
|
|
11
|
-
const
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
28
|
+
const { changedFiles, readFile, projectConfig, globMatcher } = require('./_changes');
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
const NAME = 'check_hitl';
|
|
31
|
+
const SKILL_ROOTS = ['skills/', '.agents/skills/', '.claude/skills/'];
|
|
32
|
+
const EXCLUDED_SKILLS = new Set(['clc_forge_cli', 'impeccable']);
|
|
33
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
34
|
+
const INLINE_MARKER_RE = /^\s*(?:(?:[-*+]|\d+[.)]|>)\s+)*[*_]{0,2}HITL[*_]{0,2}\s*:/i;
|
|
35
|
+
const HEADING_MARKER_RE = /^\s{0,3}#{2,6}\s+(?:Human[- ]checkpoints?|HITL)\b/i;
|
|
36
|
+
|
|
37
|
+
/** True for a changed file that must carry a HITL marker. */
|
|
38
|
+
function needsMarker(file) {
|
|
39
|
+
if (!file.endsWith('.md')) return false;
|
|
40
|
+
const parts = file.split('/');
|
|
41
|
+
const base = parts[parts.length - 1];
|
|
42
|
+
if (base.toLowerCase() === 'readme.md') return false;
|
|
43
|
+
if (parts.some(segment => EXCLUDED_SKILLS.has(segment))) return false;
|
|
44
|
+
if (file.startsWith('playbooks/')) return true;
|
|
45
|
+
const root = SKILL_ROOTS.find(r => file.startsWith(r));
|
|
46
|
+
if (!root) return false;
|
|
47
|
+
const rest = file.slice(root.length).split('/');
|
|
48
|
+
// skills/<name>/SKILL.md, or a flat skills/<name>.md file.
|
|
49
|
+
return base === 'SKILL.md' || rest.length === 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MARKERS = [INLINE_MARKER_RE, HEADING_MARKER_RE];
|
|
20
53
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} else if (entry.isDirectory()) {
|
|
30
|
-
// Exclude framework CLI scaffolding and tool plugins
|
|
31
|
-
if (entry.name === 'clckernel_cli' || entry.name === 'clc_forge_cli' || entry.name === 'impeccable') {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const subDirPath = path.join(sDir, entry.name);
|
|
35
|
-
const subEntries = fs.readdirSync(subDirPath, { withFileTypes: true });
|
|
36
|
-
for (const sub of subEntries) {
|
|
37
|
-
if (sub.isFile() && sub.name.endsWith('.md')) {
|
|
38
|
-
skillFiles.push(path.join(subDirPath, sub.name));
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
} catch {}
|
|
54
|
+
/** True when the Markdown text holds an explicit HITL marker outside code fences. */
|
|
55
|
+
function hasMarker(text, markers = DEFAULT_MARKERS) {
|
|
56
|
+
let fence = null;
|
|
57
|
+
for (const line of text.split(/\r?\n/)) {
|
|
58
|
+
const open = FENCE_RE.exec(line);
|
|
59
|
+
if (fence) {
|
|
60
|
+
if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null;
|
|
61
|
+
continue;
|
|
44
62
|
}
|
|
63
|
+
if (open) {
|
|
64
|
+
fence = open[1];
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (markers.some(re => re.test(line))) return true;
|
|
45
68
|
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
46
71
|
|
|
47
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Value of a top-level `key:` line of the file's YAML frontmatter, unquoted;
|
|
74
|
+
* null without frontmatter or key.
|
|
75
|
+
*/
|
|
76
|
+
function frontmatterValue(text, key) {
|
|
77
|
+
const lines = text.split(/\r?\n/);
|
|
78
|
+
if (lines[0].trim() !== '---') return null;
|
|
79
|
+
for (let i = 1; i < lines.length; i++) {
|
|
80
|
+
if (lines[i].trim() === '---' || lines[i].trim() === '...') return null;
|
|
81
|
+
const sep = lines[i].indexOf(':');
|
|
82
|
+
if (sep <= 0 || lines[i].slice(0, sep) !== key) continue;
|
|
83
|
+
const value = lines[i].slice(sep + 1).replace(/\s+#.*$/, '').trim();
|
|
84
|
+
const quoted = /^(['"])(.*)\1$/.exec(value);
|
|
85
|
+
return quoted ? quoted[2] : value;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The selection, markers and opt-out of this project: config or defaults. */
|
|
91
|
+
function settings(dir) {
|
|
92
|
+
const config = projectConfig(dir);
|
|
93
|
+
const hitl = config ? config.hitl : { markers: null, paths: null, optOut: null };
|
|
94
|
+
return {
|
|
95
|
+
select: hitl.paths ? globMatcher(hitl.paths) : needsMarker,
|
|
96
|
+
extensions: hitl.paths ? undefined : ['.md'],
|
|
97
|
+
markers: hitl.markers ? hitl.markers.map(source => new RegExp(source, 'i')) : DEFAULT_MARKERS,
|
|
98
|
+
optOut: hitl.optOut,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const check = async function checkHitl(targetDir) {
|
|
103
|
+
const dir = targetDir || process.cwd();
|
|
104
|
+
const { select, extensions, markers, optOut } = settings(dir);
|
|
105
|
+
const { mode, files } = changedFiles(dir, { extensions, includeManaged: true });
|
|
106
|
+
const targets = files.filter(select);
|
|
107
|
+
|
|
108
|
+
if (targets.length === 0) {
|
|
48
109
|
return {
|
|
49
110
|
exitCode: 0,
|
|
50
111
|
skipped: true,
|
|
51
|
-
name:
|
|
52
|
-
data: { violations: [], message: 'No skills
|
|
112
|
+
name: NAME,
|
|
113
|
+
data: { violations: [], message: 'No changed skills or playbooks' },
|
|
53
114
|
};
|
|
54
115
|
}
|
|
55
116
|
|
|
56
|
-
const hitlKeywords = [
|
|
57
|
-
/human[- ]in[- ]the[- ]loop/i,
|
|
58
|
-
/hitl/i,
|
|
59
|
-
/checkpoint/i,
|
|
60
|
-
/approval/i,
|
|
61
|
-
/confirm/i,
|
|
62
|
-
/review/i,
|
|
63
|
-
/pausa/i,
|
|
64
|
-
/aprobaci[oó]n/i,
|
|
65
|
-
/esperar/i,
|
|
66
|
-
/validaci[oó]n/i
|
|
67
|
-
];
|
|
68
|
-
|
|
69
117
|
const violations = [];
|
|
70
|
-
for (const file of
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
message: 'Skill/Playbook missing mandatory Human-in-the-Loop (HITL) review checkpoint'
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
} catch {}
|
|
118
|
+
for (const file of targets) {
|
|
119
|
+
const text = readFile(dir, file, mode);
|
|
120
|
+
if (text === null || hasMarker(text, markers)) continue;
|
|
121
|
+
if (optOut && frontmatterValue(text, optOut.key) === optOut.value) continue;
|
|
122
|
+
violations.push({
|
|
123
|
+
file,
|
|
124
|
+
message: 'Missing explicit Human-in-the-Loop checkpoint: add a `HITL:` line or a `## Human checkpoint` section',
|
|
125
|
+
});
|
|
82
126
|
}
|
|
83
127
|
|
|
84
|
-
const exitCode = violations.length > 0 ? 1 : 0;
|
|
85
128
|
return {
|
|
86
|
-
exitCode,
|
|
129
|
+
exitCode: violations.length > 0 ? 1 : 0,
|
|
87
130
|
skipped: false,
|
|
88
|
-
name:
|
|
131
|
+
name: NAME,
|
|
89
132
|
data: {
|
|
90
133
|
violations,
|
|
91
134
|
message: violations.length > 0
|
|
92
|
-
? `${violations.length} skill(s) missing
|
|
93
|
-
:
|
|
94
|
-
}
|
|
135
|
+
? `${violations.length} skill(s)/playbook(s) missing an explicit HITL checkpoint marker`
|
|
136
|
+
: `All ${targets.length} changed skill(s)/playbook(s) declare a HITL checkpoint`,
|
|
137
|
+
},
|
|
95
138
|
};
|
|
96
139
|
};
|
|
97
140
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Relative Link Guard
|
|
4
|
+
* In every Markdown file changed in the snapshot, relative links
|
|
5
|
+
* (`[text](path)` and ``) must point to a file or folder present
|
|
6
|
+
* in the same snapshot (the index on a pre-commit, HEAD on a manual run).
|
|
7
|
+
* URLs and other schemes (https:, mailto:, ...), pure anchors (#section) and
|
|
8
|
+
* links inside code spans or fenced code blocks are ignored; the #anchor and
|
|
9
|
+
* ?query parts of a path are not checked.
|
|
10
|
+
* Skills the harness installs (clckernel_cli) and registry files are ignored.
|
|
11
|
+
* Self-contained: uses only Node.js built-ins.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} targetDir - Repository root (defaults to cwd)
|
|
14
|
+
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
15
|
+
*/
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { changedFiles, readFile, listFiles } = require('./_changes');
|
|
18
|
+
|
|
19
|
+
const NAME = 'check_links';
|
|
20
|
+
const HARNESS_SKILLS = new Set(['clckernel_cli', 'clc_forge_cli']);
|
|
21
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
22
|
+
// [text](target "title") / 
|
|
23
|
+
const LINK_RE = /!?\[(?:[^\]\\]|\\.)*\]\(\s*(<[^>\n]*>|[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
|
24
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
25
|
+
|
|
26
|
+
function isHarnessFile(file) {
|
|
27
|
+
return file.split('/').some(segment => HARNESS_SKILLS.has(segment));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Markdown lines with fenced code blocks blanked and code spans removed, so
|
|
32
|
+
* line numbers stay aligned with the file.
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
function proseLines(text) {
|
|
37
|
+
let fence = null;
|
|
38
|
+
return text.split(/\r?\n/).map(line => {
|
|
39
|
+
const open = FENCE_RE.exec(line);
|
|
40
|
+
if (fence) {
|
|
41
|
+
if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null;
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
if (open) {
|
|
45
|
+
fence = open[1];
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
return line.replace(/(`+)[^`]*?\1/g, '');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Repository path a relative link resolves to, or null when it is not checked.
|
|
54
|
+
* @returns {string|null} '' for a target outside the repository
|
|
55
|
+
*/
|
|
56
|
+
function resolveTarget(file, raw) {
|
|
57
|
+
let target = raw.startsWith('<') ? raw.slice(1, -1) : raw;
|
|
58
|
+
if (!target || target.startsWith('#') || target.startsWith('//') || SCHEME_RE.test(target)) return null;
|
|
59
|
+
target = target.split('#')[0].split('?')[0];
|
|
60
|
+
if (!target) return null;
|
|
61
|
+
try {
|
|
62
|
+
target = decodeURI(target);
|
|
63
|
+
} catch {
|
|
64
|
+
// Keep the raw spelling when it is not valid percent-encoding.
|
|
65
|
+
}
|
|
66
|
+
const joined = target.startsWith('/')
|
|
67
|
+
? path.posix.normalize(target.slice(1))
|
|
68
|
+
: path.posix.join(path.posix.dirname(file), target);
|
|
69
|
+
const normalized = path.posix.normalize(joined).replace(/\/+$/, '');
|
|
70
|
+
if (normalized === '..' || normalized.startsWith('../')) return '';
|
|
71
|
+
return normalized === '.' ? '.' : normalized;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const check = async function checkLinks(targetDir) {
|
|
75
|
+
const dir = targetDir || process.cwd();
|
|
76
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.md'] });
|
|
77
|
+
const markdown = files.filter(f => !isHarnessFile(f));
|
|
78
|
+
|
|
79
|
+
if (markdown.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
exitCode: 0, skipped: true, name: NAME,
|
|
82
|
+
data: { violations: [], message: 'No changed Markdown files' },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const snapshot = new Set(listFiles(dir, mode));
|
|
87
|
+
const folders = new Set(['.']);
|
|
88
|
+
for (const file of snapshot) {
|
|
89
|
+
let parent = path.posix.dirname(file);
|
|
90
|
+
while (parent !== '.' && !folders.has(parent)) {
|
|
91
|
+
folders.add(parent);
|
|
92
|
+
parent = path.posix.dirname(parent);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const present = target => snapshot.has(target) || folders.has(target);
|
|
96
|
+
|
|
97
|
+
const violations = [];
|
|
98
|
+
for (const file of markdown) {
|
|
99
|
+
const text = readFile(dir, file, mode);
|
|
100
|
+
if (text === null) continue;
|
|
101
|
+
proseLines(text).forEach((line, index) => {
|
|
102
|
+
for (const match of line.matchAll(LINK_RE)) {
|
|
103
|
+
const target = resolveTarget(file, match[1]);
|
|
104
|
+
if (target === null) continue;
|
|
105
|
+
if (target === '' || !present(target)) {
|
|
106
|
+
violations.push({
|
|
107
|
+
file,
|
|
108
|
+
line: index + 1,
|
|
109
|
+
message: target === ''
|
|
110
|
+
? `Link "${match[1]}" points outside the repository`
|
|
111
|
+
: `Broken link "${match[1]}": ${target} is not in the commit`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
exitCode: violations.length > 0 ? 1 : 0,
|
|
120
|
+
skipped: false,
|
|
121
|
+
name: NAME,
|
|
122
|
+
data: {
|
|
123
|
+
violations,
|
|
124
|
+
message: violations.length > 0
|
|
125
|
+
? `${violations.length} broken relative link(s) found`
|
|
126
|
+
: `All relative links in ${markdown.length} changed Markdown file(s) resolve`,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
module.exports = check;
|
|
132
|
+
|
|
133
|
+
if (require.main === module) {
|
|
134
|
+
check(process.argv[2]).then(result => {
|
|
135
|
+
console.log(JSON.stringify(result, null, 2));
|
|
136
|
+
process.exit(result.exitCode);
|
|
137
|
+
});
|
|
138
|
+
}
|