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
package/tools/check_scope.py
CHANGED
|
@@ -11,29 +11,43 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
import pathlib
|
|
13
13
|
import re
|
|
14
|
-
import subprocess
|
|
15
14
|
import sys
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
16
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
17
|
+
# loaded by audit.py through importlib.
|
|
18
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
if _TOOLS_DIR not in sys.path:
|
|
20
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
21
|
+
import _changes # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# A path with an extension, optionally starting with a dot
|
|
25
|
+
# (src/a.js, .clckernel.yaml, .github/workflows/ci.yml).
|
|
26
|
+
_PATH = r"\.?[A-Za-z_][\w./-]+\.\w+"
|
|
27
|
+
# A dot-path without extension (.gitignore, .githooks/pre-commit): only
|
|
28
|
+
# recognised in backticks, so prose such as "- .NET support" stays prose.
|
|
29
|
+
_DOT_PATH = r"\.[A-Za-z_][\w./-]*"
|
|
30
|
+
_BULLET_TICKED = re.compile(rf"\s*[-*]\s+`({_PATH}|{_DOT_PATH})`")
|
|
31
|
+
_BULLET_BARE = re.compile(rf"\s*[-*]\s+({_PATH})(?=[.:,;)]?(?:\s|$))")
|
|
32
|
+
_CELL_TICKED = re.compile(rf"`({_PATH}|{_DOT_PATH})`")
|
|
33
|
+
_CELL_BARE = re.compile(_PATH)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _declared_in_line(line: str) -> list[str]:
|
|
37
|
+
"""Paths one SDD line declares: the first token of a bullet, or every
|
|
38
|
+
table cell whose whole content is a path. Sentences declare nothing."""
|
|
39
|
+
m = _BULLET_TICKED.match(line) or _BULLET_BARE.match(line)
|
|
40
|
+
if m:
|
|
41
|
+
return [m.group(1)]
|
|
42
|
+
if not line.lstrip().startswith("|"):
|
|
43
|
+
return []
|
|
44
|
+
found = []
|
|
45
|
+
for cell in line.strip().strip("|").split("|"):
|
|
46
|
+
cell = cell.strip()
|
|
47
|
+
m = _CELL_TICKED.fullmatch(cell) or _CELL_BARE.fullmatch(cell)
|
|
48
|
+
if m:
|
|
49
|
+
found.append(m.group(1) if m.groups() else m.group(0))
|
|
50
|
+
return found
|
|
37
51
|
|
|
38
52
|
|
|
39
53
|
def _find_sdd_declared_files(target_dir: str) -> list[str] | None:
|
|
@@ -69,14 +83,7 @@ def _find_sdd_declared_files(target_dir: str) -> list[str] | None:
|
|
|
69
83
|
# Look for file references in common SDD patterns
|
|
70
84
|
# Pattern: `- path/to/file` or `| path/to/file |`
|
|
71
85
|
for line in content.splitlines():
|
|
72
|
-
|
|
73
|
-
m = re.match(r"\s*[-*]\s+`?([a-zA-Z_][\w./-]+\.\w+)`?", line)
|
|
74
|
-
if m:
|
|
75
|
-
declared.append(m.group(1))
|
|
76
|
-
# Match table cell file paths
|
|
77
|
-
m = re.match(r"\s*\|[^|]*`?([a-zA-Z_][\w./-]+\.\w+)`?[^|]*\|", line)
|
|
78
|
-
if m:
|
|
79
|
-
declared.append(m.group(1))
|
|
86
|
+
declared.extend(_declared_in_line(line))
|
|
80
87
|
|
|
81
88
|
# Deduplicate while preserving order
|
|
82
89
|
seen: set[str] = set()
|
|
@@ -99,8 +106,10 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
99
106
|
"""
|
|
100
107
|
dir_path = target_dir or os.getcwd()
|
|
101
108
|
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
110
|
+
changes = _changes.changed_files(dir_path)
|
|
111
|
+
changed = changes["files"]
|
|
112
|
+
if changes["mode"] is None:
|
|
104
113
|
return {
|
|
105
114
|
"exit_code": 0, "skipped": True, "name": "check_scope",
|
|
106
115
|
"data": {
|
package/tools/check_seo.js
CHANGED
|
@@ -13,28 +13,15 @@
|
|
|
13
13
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
14
14
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
15
15
|
*/
|
|
16
|
-
const
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const { execSync } = require('child_process');
|
|
16
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
20
17
|
|
|
18
|
+
const check = async function checkSeo(targetDir) {
|
|
21
19
|
const dir = targetDir || process.cwd();
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
});
|
|
28
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html)$/.test(f.trim()));
|
|
29
|
-
} catch {
|
|
30
|
-
try {
|
|
31
|
-
const out = execSync('git diff --name-only --cached', {
|
|
32
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
33
|
-
});
|
|
34
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html)$/.test(f.trim()));
|
|
35
|
-
} catch {
|
|
36
|
-
return { exitCode: 0, skipped: true, name: 'check_seo', data: { violations: {}, message: 'No git diff available' } };
|
|
37
|
-
}
|
|
21
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
22
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.tsx', '.jsx', '.vue', '.astro', '.html'] });
|
|
23
|
+
if (mode === null) {
|
|
24
|
+
return { exitCode: 0, skipped: true, name: 'check_seo', data: { violations: {}, message: 'No git diff available' } };
|
|
38
25
|
}
|
|
39
26
|
|
|
40
27
|
if (!files || files.length === 0) {
|
|
@@ -44,15 +31,8 @@ const check = async function checkSeo(targetDir) {
|
|
|
44
31
|
const violations = {};
|
|
45
32
|
|
|
46
33
|
for (const file of files) {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
let content;
|
|
51
|
-
try {
|
|
52
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
53
|
-
} catch {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
34
|
+
const content = readFile(dir, file, mode);
|
|
35
|
+
if (content === null) continue;
|
|
56
36
|
|
|
57
37
|
const lines = content.split('\n');
|
|
58
38
|
const fileViolations = [];
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Skill Frontmatter Guard
|
|
4
|
+
* Every SKILL.md changed in the snapshot must open with a YAML frontmatter
|
|
5
|
+
* block holding a non-empty `name` (lowercase letters and digits joined by
|
|
6
|
+
* hyphens or underscores) and a non-empty `description` of at most 1024
|
|
7
|
+
* characters. The `name` must be unique among all the skills of the
|
|
8
|
+
* repository (read from the same snapshot). A name that differs from the
|
|
9
|
+
* skill's folder is reported as a warning only.
|
|
10
|
+
* Skills the harness installs (clckernel_cli, files in the registry) 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_skills';
|
|
20
|
+
const SKILL_FILE = 'SKILL.md';
|
|
21
|
+
const MANAGED_REGISTRY = 'tools/.clckernel-managed.json';
|
|
22
|
+
const HARNESS_SKILLS = new Set(['clckernel_cli', 'clc_forge_cli']);
|
|
23
|
+
const NAME_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
24
|
+
const MAX_DESCRIPTION = 1024;
|
|
25
|
+
|
|
26
|
+
/** True for a SKILL.md path that does not belong to a skill the harness installs. */
|
|
27
|
+
function isUserSkill(file) {
|
|
28
|
+
if (path.posix.basename(file) !== SKILL_FILE) return false;
|
|
29
|
+
return !file.split('/').some(segment => HARNESS_SKILLS.has(segment));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Removes matching surrounding quotes of a YAML scalar. */
|
|
33
|
+
function unquote(value) {
|
|
34
|
+
const v = value.trim();
|
|
35
|
+
if (v.length >= 2 && ((v[0] === '"' && v.endsWith('"')) || (v[0] === "'" && v.endsWith("'")))) {
|
|
36
|
+
return v.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parses the top-level scalar keys of a frontmatter block: `key: value`,
|
|
43
|
+
* quoted values, block scalars (`|`, `>`, with chomping indicators) and plain
|
|
44
|
+
* values continued on indented lines.
|
|
45
|
+
* @param {string} text - Content of the SKILL.md
|
|
46
|
+
* @returns {Record<string, string>|null} null when there is no frontmatter block
|
|
47
|
+
*/
|
|
48
|
+
function parseFrontmatter(text) {
|
|
49
|
+
const lines = text.replace(/^/, '').split(/\r?\n/);
|
|
50
|
+
if (lines[0].trim() !== '---') return null;
|
|
51
|
+
const end = lines.findIndex((line, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(line));
|
|
52
|
+
if (end < 0) return null;
|
|
53
|
+
|
|
54
|
+
const fields = {};
|
|
55
|
+
const body = lines.slice(1, end);
|
|
56
|
+
for (let i = 0; i < body.length; i++) {
|
|
57
|
+
const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(body[i]);
|
|
58
|
+
if (!match) continue;
|
|
59
|
+
const [, key, rest] = match;
|
|
60
|
+
const continuation = [];
|
|
61
|
+
while (i + 1 < body.length && (/^\s+\S/.test(body[i + 1]) || body[i + 1].trim() === '')) {
|
|
62
|
+
continuation.push(body[++i]);
|
|
63
|
+
}
|
|
64
|
+
const head = rest.trim();
|
|
65
|
+
if (/^[|>][+-]?$/.test(head)) {
|
|
66
|
+
const joiner = head[0] === '|' ? '\n' : ' ';
|
|
67
|
+
fields[key] = continuation.map(l => l.trim()).filter(Boolean).join(joiner);
|
|
68
|
+
} else {
|
|
69
|
+
fields[key] = [unquote(head), ...continuation.map(l => l.trim())].filter(Boolean).join(' ');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return fields;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const check = async function checkSkills(targetDir) {
|
|
76
|
+
const dir = targetDir || process.cwd();
|
|
77
|
+
const { mode, files } = changedFiles(dir);
|
|
78
|
+
const changed = files.filter(isUserSkill);
|
|
79
|
+
|
|
80
|
+
if (changed.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
exitCode: 0, skipped: true, name: NAME,
|
|
83
|
+
data: { violations: [], warnings: [], message: 'No changed SKILL.md files' },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Every skill of the snapshot, for the uniqueness check (registry entries excluded).
|
|
88
|
+
const registry = new Set();
|
|
89
|
+
try {
|
|
90
|
+
const listed = JSON.parse(readFile(dir, MANAGED_REGISTRY, mode) || '[]');
|
|
91
|
+
if (Array.isArray(listed)) listed.forEach(f => registry.add(f));
|
|
92
|
+
} catch {
|
|
93
|
+
// Malformed registry: exclude nothing.
|
|
94
|
+
}
|
|
95
|
+
const names = new Map();
|
|
96
|
+
for (const file of listFiles(dir, mode).filter(f => isUserSkill(f) && !registry.has(f))) {
|
|
97
|
+
const fields = parseFrontmatter(readFile(dir, file, mode) || '');
|
|
98
|
+
const name = fields && fields.name;
|
|
99
|
+
if (!name) continue;
|
|
100
|
+
if (!names.has(name)) names.set(name, []);
|
|
101
|
+
names.get(name).push(file);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const violations = [];
|
|
105
|
+
const warnings = [];
|
|
106
|
+
for (const file of changed) {
|
|
107
|
+
const fail = message => violations.push({ file, message });
|
|
108
|
+
const fields = parseFrontmatter(readFile(dir, file, mode) || '');
|
|
109
|
+
if (!fields) {
|
|
110
|
+
fail('Missing YAML frontmatter (a leading --- block with name and description)');
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const name = fields.name || '';
|
|
115
|
+
const description = fields.description || '';
|
|
116
|
+
if (!name) {
|
|
117
|
+
fail('Frontmatter `name` is missing or empty');
|
|
118
|
+
} else if (!NAME_RE.test(name)) {
|
|
119
|
+
fail(`Frontmatter name "${name}" must be lowercase letters/digits joined by hyphens or underscores`);
|
|
120
|
+
}
|
|
121
|
+
if (!description) {
|
|
122
|
+
fail('Frontmatter `description` is missing or empty');
|
|
123
|
+
} else if (description.length > MAX_DESCRIPTION) {
|
|
124
|
+
fail(`Frontmatter description is ${description.length} characters (max ${MAX_DESCRIPTION})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (name) {
|
|
128
|
+
const others = (names.get(name) || []).filter(f => f !== file);
|
|
129
|
+
if (others.length > 0) fail(`Duplicate skill name "${name}": already used by ${others.join(', ')}`);
|
|
130
|
+
const folder = path.posix.basename(path.posix.dirname(file));
|
|
131
|
+
if (folder !== '.' && folder !== name) {
|
|
132
|
+
warnings.push({ file, message: `Skill name "${name}" differs from its folder "${folder}"` });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
exitCode: violations.length > 0 ? 1 : 0,
|
|
139
|
+
skipped: false,
|
|
140
|
+
name: NAME,
|
|
141
|
+
data: {
|
|
142
|
+
violations,
|
|
143
|
+
warnings,
|
|
144
|
+
message: violations.length > 0
|
|
145
|
+
? `${violations.length} SKILL.md frontmatter problem(s) found`
|
|
146
|
+
: `${changed.length} changed SKILL.md file(s) have valid frontmatter`,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
module.exports = check;
|
|
152
|
+
|
|
153
|
+
if (require.main === module) {
|
|
154
|
+
check(process.argv[2]).then(result => {
|
|
155
|
+
console.log(JSON.stringify(result, null, 2));
|
|
156
|
+
process.exit(result.exitCode);
|
|
157
|
+
});
|
|
158
|
+
}
|
package/tools/check_storybook.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
8
8
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
9
9
|
*/
|
|
10
|
+
const { changedFiles, exists } = require('./_changes');
|
|
11
|
+
|
|
10
12
|
const check = async function checkStorybook(targetDir) {
|
|
11
13
|
const fs = require('fs');
|
|
12
14
|
const path = require('path');
|
|
13
|
-
const { execSync } = require('child_process');
|
|
14
15
|
|
|
15
16
|
const dir = targetDir || process.cwd();
|
|
16
17
|
|
|
@@ -23,22 +24,10 @@ const check = async function checkStorybook(targetDir) {
|
|
|
23
24
|
return { exitCode: 0, skipped: true, name: 'check_storybook', data: { violations: {}, message: 'Storybook not configured — skipping' } };
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
31
|
-
});
|
|
32
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
|
|
33
|
-
} catch {
|
|
34
|
-
try {
|
|
35
|
-
const out = execSync('git diff --name-only --cached', {
|
|
36
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
37
|
-
});
|
|
38
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
|
|
39
|
-
} catch {
|
|
40
|
-
return { exitCode: 0, skipped: true, name: 'check_storybook', data: { violations: {}, message: 'No git diff available' } };
|
|
41
|
-
}
|
|
27
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
28
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.tsx', '.jsx'] });
|
|
29
|
+
if (mode === null) {
|
|
30
|
+
return { exitCode: 0, skipped: true, name: 'check_storybook', data: { violations: {}, message: 'No git diff available' } };
|
|
42
31
|
}
|
|
43
32
|
|
|
44
33
|
if (!files || files.length === 0) {
|
|
@@ -63,9 +52,6 @@ const check = async function checkStorybook(targetDir) {
|
|
|
63
52
|
const violations = {};
|
|
64
53
|
|
|
65
54
|
for (const file of componentFiles) {
|
|
66
|
-
const filePath = path.join(dir, file);
|
|
67
|
-
if (!fs.existsSync(filePath)) continue;
|
|
68
|
-
|
|
69
55
|
// Check if a corresponding .stories.tsx file exists
|
|
70
56
|
const basename = path.basename(file, path.extname(file));
|
|
71
57
|
const ext = path.extname(file);
|
|
@@ -74,7 +60,8 @@ const check = async function checkStorybook(targetDir) {
|
|
|
74
60
|
// Also check co-located stories directory pattern
|
|
75
61
|
const storiesDir = path.join(path.dirname(file), '__stories__', `${basename}.stories${ext}`);
|
|
76
62
|
|
|
77
|
-
|
|
63
|
+
// The story must be part of the snapshot too: one only in the working copy does not count.
|
|
64
|
+
const hasStory = exists(dir, storyFile, mode) || exists(dir, storiesDir, mode);
|
|
78
65
|
|
|
79
66
|
if (!hasStory) {
|
|
80
67
|
violations[file] = [{
|
package/tools/check_symlinks.js
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Symlink & AI IDE Interoperability Guard
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every AI agent rule mirror that exists (CLAUDE.md, GEMINI.md, .cursorrules,
|
|
5
|
+
* .windsurfrules) must point to AGENTS.md: a symlink that resolves to
|
|
6
|
+
* AGENTS.md, or a regular file whose content is identical to it. Broken
|
|
7
|
+
* symlinks, symlinks to another file and diverging copies fail. A missing
|
|
8
|
+
* mirror is not an error. Mirrors are gitignored, so the working copy is read.
|
|
9
|
+
*
|
|
10
|
+
* The project config (.clckernel.yaml) can change both: `mirrors.list` is
|
|
11
|
+
* the list of mirrors to verify, and `mirrors.required: true` makes a listed
|
|
12
|
+
* mirror that is missing a violation.
|
|
13
|
+
* Self-contained: uses only Node.js built-ins (and the sibling _config.js
|
|
14
|
+
* when the project has a config file).
|
|
7
15
|
*
|
|
8
16
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
9
17
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
10
18
|
*/
|
|
19
|
+
const DEFAULT_MIRRORS = ['CLAUDE.md', 'GEMINI.md', '.cursorrules', '.windsurfrules'];
|
|
20
|
+
|
|
11
21
|
const check = async function checkSymlinks(targetDir) {
|
|
12
22
|
const fs = require('fs');
|
|
13
23
|
const path = require('path');
|
|
24
|
+
const { projectConfig } = require('./_changes');
|
|
14
25
|
|
|
15
26
|
const dir = targetDir || process.cwd();
|
|
16
|
-
const
|
|
27
|
+
const config = projectConfig(dir);
|
|
28
|
+
const rules = config && config.mirrors.list ? config.mirrors.list : DEFAULT_MIRRORS;
|
|
29
|
+
const required = Boolean(config && config.mirrors.required);
|
|
17
30
|
const violations = [];
|
|
18
31
|
|
|
19
32
|
const agentsPath = path.join(dir, 'AGENTS.md');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!hasAgents) {
|
|
33
|
+
if (!fs.existsSync(agentsPath)) {
|
|
23
34
|
return {
|
|
24
35
|
exitCode: 0,
|
|
25
36
|
skipped: true,
|
|
@@ -27,28 +38,35 @@ const check = async function checkSymlinks(targetDir) {
|
|
|
27
38
|
data: { violations: [], message: 'No AGENTS.md present in project' }
|
|
28
39
|
};
|
|
29
40
|
}
|
|
41
|
+
const agentsReal = fs.realpathSync(agentsPath);
|
|
30
42
|
|
|
31
43
|
for (const rule of rules) {
|
|
32
44
|
const filePath = path.join(dir, rule);
|
|
45
|
+
let lstat;
|
|
46
|
+
try {
|
|
47
|
+
lstat = fs.lstatSync(filePath);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.code !== 'ENOENT') {
|
|
50
|
+
violations.push({ file: rule, message: `Error reading mirror: ${err.message}` });
|
|
51
|
+
} else if (required) {
|
|
52
|
+
violations.push({ file: rule, message: 'Missing mirror: mirrors.required is true; create it as a symlink to AGENTS.md' });
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
try {
|
|
34
|
-
const lstat = fs.lstatSync(filePath);
|
|
35
58
|
if (lstat.isSymbolicLink()) {
|
|
36
59
|
const target = fs.readlinkSync(filePath);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
message: `Broken symlink: points to non-existent target "${target}"`
|
|
42
|
-
});
|
|
60
|
+
if (!fs.existsSync(filePath)) {
|
|
61
|
+
violations.push({ file: rule, message: `Broken symlink: points to non-existent target "${target}"` });
|
|
62
|
+
} else if (fs.realpathSync(filePath) !== agentsReal) {
|
|
63
|
+
violations.push({ file: rule, message: `Symlink points to "${target}" instead of AGENTS.md` });
|
|
43
64
|
}
|
|
65
|
+
} else if (!fs.readFileSync(filePath).equals(fs.readFileSync(agentsPath))) {
|
|
66
|
+
violations.push({ file: rule, message: 'Regular file differs from AGENTS.md: make it a symlink to AGENTS.md or an identical copy' });
|
|
44
67
|
}
|
|
45
68
|
} catch (err) {
|
|
46
|
-
|
|
47
|
-
violations.push({
|
|
48
|
-
file: rule,
|
|
49
|
-
message: `Error reading symlink: ${err.message}`
|
|
50
|
-
});
|
|
51
|
-
}
|
|
69
|
+
violations.push({ file: rule, message: `Error reading mirror: ${err.message}` });
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
|
|
@@ -60,8 +78,8 @@ const check = async function checkSymlinks(targetDir) {
|
|
|
60
78
|
data: {
|
|
61
79
|
violations,
|
|
62
80
|
message: violations.length > 0
|
|
63
|
-
? `${violations.length}
|
|
64
|
-
: 'All AI IDE rule mirrors
|
|
81
|
+
? `${violations.length} AI IDE rule mirror(s) do not point to AGENTS.md`
|
|
82
|
+
: 'All existing AI IDE rule mirrors point to AGENTS.md'
|
|
65
83
|
}
|
|
66
84
|
};
|
|
67
85
|
};
|
package/tools/check_ui_reuse.js
CHANGED
|
@@ -7,46 +7,34 @@
|
|
|
7
7
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
8
8
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
9
9
|
*/
|
|
10
|
-
const
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { execSync } = require('child_process');
|
|
10
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
14
11
|
|
|
12
|
+
const check = async function checkUiReuse(targetDir) {
|
|
15
13
|
const dir = targetDir || process.cwd();
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
|
|
23
|
-
} catch {
|
|
24
|
-
try {
|
|
25
|
-
const out = execSync('git diff --name-only --cached', {
|
|
26
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
27
|
-
});
|
|
28
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
|
|
29
|
-
} catch {
|
|
30
|
-
return { exitCode: 0, skipped: true, name: 'check_ui_reuse', data: { violations: {}, message: 'No git diff available' } };
|
|
31
|
-
}
|
|
15
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
16
|
+
const { mode, files: changed } = changedFiles(dir, { extensions: ['.tsx', '.jsx', '.ts', '.js'] });
|
|
17
|
+
if (mode === null) {
|
|
18
|
+
return { exitCode: 0, skipped: true, name: 'check_ui_reuse', data: { violations: {}, message: 'No git diff available' } };
|
|
32
19
|
}
|
|
20
|
+
let files = changed;
|
|
21
|
+
|
|
22
|
+
// tools/ is owned by the harness: guard sources mention raw elements as
|
|
23
|
+
// pattern strings, which are not rendered UI. Test files render raw
|
|
24
|
+
// elements as fixtures, so they are not product UI either.
|
|
25
|
+
const isTestFile = f =>
|
|
26
|
+
/(^|\/)(test|tests|__tests__)\//.test(f) || /\.(test|spec)\.[jt]sx?$/.test(f);
|
|
27
|
+
files = (files || []).map(f => f.trim()).filter(f => !f.startsWith('tools/') && !isTestFile(f));
|
|
33
28
|
|
|
34
|
-
if (
|
|
29
|
+
if (files.length === 0) {
|
|
35
30
|
return { exitCode: 0, skipped: false, name: 'check_ui_reuse', data: { violations: {}, message: 'No files changed' } };
|
|
36
31
|
}
|
|
37
32
|
|
|
38
33
|
const violations = {};
|
|
39
34
|
|
|
40
35
|
for (const file of files) {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
let content;
|
|
45
|
-
try {
|
|
46
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
47
|
-
} catch {
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
36
|
+
const content = readFile(dir, file, mode);
|
|
37
|
+
if (content === null) continue;
|
|
50
38
|
|
|
51
39
|
const fileViolations = [];
|
|
52
40
|
|
package/tools/docker_guard.py
CHANGED
|
@@ -9,43 +9,23 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
-
import subprocess
|
|
13
12
|
import sys
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
args = ["git", "diff", "--name-only"]
|
|
22
|
-
if ref == "--cached":
|
|
23
|
-
args.append("--cached")
|
|
24
|
-
else:
|
|
25
|
-
args.append(ref)
|
|
26
|
-
out = subprocess.check_output(
|
|
27
|
-
args, cwd=target_dir,
|
|
28
|
-
stderr=subprocess.DEVNULL, text=True,
|
|
29
|
-
)
|
|
30
|
-
files = [
|
|
31
|
-
f.strip() for f in out.splitlines()
|
|
32
|
-
if f.strip().endswith(('Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'))
|
|
33
|
-
or 'Dockerfile' in f
|
|
34
|
-
or 'docker-compose' in f
|
|
35
|
-
]
|
|
36
|
-
if files:
|
|
37
|
-
return files
|
|
38
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
39
|
-
continue
|
|
40
|
-
return None
|
|
13
|
+
|
|
14
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
15
|
+
# loaded by audit.py through importlib.
|
|
16
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
if _TOOLS_DIR not in sys.path:
|
|
18
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
19
|
+
import _changes # noqa: E402
|
|
41
20
|
|
|
42
21
|
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
22
|
+
def _is_docker_file(path: str) -> bool:
|
|
23
|
+
"""True for Dockerfile and docker-compose paths."""
|
|
24
|
+
return (
|
|
25
|
+
path.endswith(('Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'))
|
|
26
|
+
or 'Dockerfile' in path
|
|
27
|
+
or 'docker-compose' in path
|
|
28
|
+
)
|
|
49
29
|
|
|
50
30
|
|
|
51
31
|
def _check_dockerfile(content: str, filepath: str) -> list[dict]:
|
|
@@ -161,8 +141,10 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
161
141
|
"""
|
|
162
142
|
dir_path = target_dir or os.getcwd()
|
|
163
143
|
|
|
164
|
-
|
|
165
|
-
|
|
144
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
145
|
+
changes = _changes.changed_files(dir_path)
|
|
146
|
+
mode, files = changes["mode"], [f for f in changes["files"] if _is_docker_file(f)]
|
|
147
|
+
if mode is None:
|
|
166
148
|
return {
|
|
167
149
|
"exit_code": 0, "skipped": True, "name": "docker_guard",
|
|
168
150
|
"data": {"findings": [], "message": "No git diff available"},
|
|
@@ -177,11 +159,7 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
177
159
|
all_violations: list[dict] = []
|
|
178
160
|
|
|
179
161
|
for filepath in files:
|
|
180
|
-
|
|
181
|
-
if not full_path.exists():
|
|
182
|
-
continue
|
|
183
|
-
|
|
184
|
-
content = _read_file_content(full_path)
|
|
162
|
+
content = _changes.read_file(dir_path, filepath, mode)
|
|
185
163
|
if content is None:
|
|
186
164
|
continue
|
|
187
165
|
|