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
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Backend Database Efficiency & N+1 Performance Guard
4
- AST scanner detecting:
5
- 1. Database queries executed inside loops (N+1 query pattern in SQLAlchemy, Django ORM, SQLModel)
6
- 2. Blocking time.sleep() calls inside async route handlers/coroutines
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
- QUERY_CALLERS = {
27
- "db", "session", "objects", "cursor", "connection", "repo", "repository"
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 self._loop_depth > 0:
88
- if isinstance(node.func, ast.Attribute):
89
- attr_name = node.func.attr
90
- val = node.func.value
91
-
92
- # Match session.execute(), db.query(), Model.objects.filter(), etc.
93
- is_db_call = False
94
- caller_name = ""
95
-
96
- if isinstance(val, ast.Name):
97
- caller_name = val.id.lower()
98
- if caller_name in QUERY_CALLERS or attr_name in QUERY_METHODS:
99
- is_db_call = True
100
- elif isinstance(val, ast.Attribute):
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
- # Get changed python files
119
- files: list[str] = []
120
- try:
121
- out = subprocess.check_output(
122
- ["git", "diff", "--name-only", "HEAD~1"],
123
- cwd=dir_path,
124
- stderr=subprocess.PIPE,
125
- text=True
126
- )
127
- files = [f.strip() for f in out.splitlines() if f.strip().endswith(".py")]
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
- file_path = dir_path / file_rel
157
- if not file_path.is_file():
173
+ source = _changes.read_file(str(dir_path), file_rel, mode)
174
+ if source is None:
158
175
  continue
159
-
160
176
  try:
161
- source = file_path.read_text(encoding="utf-8")
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
 
@@ -1,97 +1,140 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Human-in-the-Loop (HITL) Checkpoint Guard
4
- * Ensures subagent playbooks and workflows in skills/ or .agents/skills/
5
- * define mandatory user review or approval checkpoints before final output.
6
- * Self-contained: uses only Node.js built-ins.
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
- * @param {string} targetDir - Directory to scan (defaults to cwd)
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 check = async function checkHitl(targetDir) {
12
- const fs = require('fs');
13
- const path = require('path');
28
+ const { changedFiles, readFile, projectConfig, globMatcher } = require('./_changes');
14
29
 
15
- const dir = targetDir || process.cwd();
16
- const searchDirs = [
17
- path.join(dir, 'skills'),
18
- path.join(dir, '.agents', 'skills')
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
- const skillFiles = [];
22
- for (const sDir of searchDirs) {
23
- if (fs.existsSync(sDir)) {
24
- try {
25
- const entries = fs.readdirSync(sDir, { withFileTypes: true });
26
- for (const entry of entries) {
27
- if (entry.isFile() && entry.name.endsWith('.md')) {
28
- skillFiles.push(path.join(sDir, entry.name));
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
- if (skillFiles.length === 0) {
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: 'check_hitl',
52
- data: { violations: [], message: 'No skills/ or subagent playbook files found' }
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 skillFiles) {
71
- try {
72
- const content = fs.readFileSync(file, 'utf-8');
73
- const hasHitl = hitlKeywords.some(kw => kw.test(content));
74
-
75
- if (!hasHitl) {
76
- violations.push({
77
- file: path.relative(dir, file),
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: 'check_hitl',
131
+ name: NAME,
89
132
  data: {
90
133
  violations,
91
134
  message: violations.length > 0
92
- ? `${violations.length} skill(s) missing mandatory Human-in-the-Loop checkpoints`
93
- : 'All subagent skills include mandatory Human-in-the-Loop (HITL) checkpoints'
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 `![alt](path)`) 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") / ![alt](<target with spaces>)
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
+ }