clckernel 1.2.19 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +250 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/deprecated.js +13 -0
  16. package/package.json +14 -4
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +23 -38
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +734 -302
  32. package/src/gitignore.js +60 -14
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -38
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -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
- def _get_changed_files(target_dir: str) -> list[str] | None:
19
- """Return list of changed files from git diff, or None on failure."""
20
- for ref in ("HEAD~1", "--cached"):
21
- try:
22
- args = ["git", "diff", "--name-only"]
23
- if ref == "--cached":
24
- args.append("--cached")
25
- else:
26
- args.append(ref)
27
- out = subprocess.check_output(
28
- args, cwd=target_dir,
29
- stderr=subprocess.DEVNULL, text=True,
30
- )
31
- files = [f.strip() for f in out.splitlines() if f.strip()]
32
- if files:
33
- return files
34
- except (subprocess.CalledProcessError, FileNotFoundError):
35
- continue
36
- return None
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
- # Match bullet-point file paths
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
- changed = _get_changed_files(dir_path)
103
- if changed is None:
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": {
@@ -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 check = async function checkSeo(targetDir) {
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
- let files;
24
- try {
25
- const out = execSync('git diff --name-only HEAD~1', {
26
- cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
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 filePath = path.join(dir, file);
48
- if (!fs.existsSync(filePath)) continue;
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
+ }
@@ -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
- // Collect changed component files
27
- let files;
28
- try {
29
- const out = execSync('git diff --name-only HEAD~1', {
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
- const hasStory = fs.existsSync(path.join(dir, storyFile)) || fs.existsSync(path.join(dir, storiesDir));
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] = [{
@@ -1,25 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Symlink & AI IDE Interoperability Guard
4
- * Verifies that AI agent rule mirrors (CLAUDE.md, GEMINI.md, .cursorrules, .windsurfrules)
5
- * exist and resolve to valid files without broken symlinks.
6
- * Self-contained: uses only Node.js built-ins.
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 rules = ['CLAUDE.md', 'GEMINI.md', '.cursorrules', '.windsurfrules'];
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
- const hasAgents = fs.existsSync(agentsPath);
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
- const resolved = path.isAbsolute(target) ? target : path.resolve(dir, target);
38
- if (!fs.existsSync(resolved)) {
39
- violations.push({
40
- file: rule,
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
- if (err.code !== 'ENOENT') {
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} broken or invalid symlink(s) detected`
64
- : 'All AI IDE rule mirrors & symlinks verified'
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
  };
@@ -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 check = async function checkUiReuse(targetDir) {
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
- let files;
18
- try {
19
- const out = execSync('git diff --name-only HEAD~1', {
20
- cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
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 (!files || files.length === 0) {
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 filePath = path.join(dir, file);
42
- if (!fs.existsSync(filePath)) continue;
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
 
@@ -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
- from pathlib import Path
15
-
16
-
17
- def _get_changed_dockerfiles(target_dir: str) -> list[str] | None:
18
- """Return list of changed Docker-related files from git diff, or None on failure."""
19
- for ref in ("HEAD~1", "--cached"):
20
- try:
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 _read_file_content(full_path: Path) -> str | None:
44
- """Read file content safely, return None on failure."""
45
- try:
46
- return full_path.read_text(encoding="utf-8", errors="replace")
47
- except (OSError, PermissionError):
48
- return None
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
- files = _get_changed_dockerfiles(dir_path)
165
- if files is None:
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
- full_path = Path(dir_path) / filepath
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