@konvert7/klint 0.33.0 → 0.33.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.
package/README.md CHANGED
@@ -108,7 +108,7 @@ Rules give agents room to move quickly because the unsafe moves are blocked stru
108
108
 
109
109
  ```sh
110
110
  klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]
111
- klint install-skill [--agents <list>] [--symlink | --copy]
111
+ klint install-skill [--agents <list>] [--symlink | --copy] [--force]
112
112
  ```
113
113
 
114
114
  | Flag | Description |
@@ -122,9 +122,45 @@ klint install-skill [--agents <list>] [--symlink | --copy]
122
122
  `klint install-skill` installs the bundled rule-authoring skill for agent environments:
123
123
 
124
124
  ```sh
125
- klint install-skill --agents claude,opencode,cursor,codex --copy
125
+ klint install-skill
126
126
  ```
127
127
 
128
+ The skill is embedded in the native engine, so this works from every distribution — the npm package, the PyPI wheel, the NuGet tool, and the standalone binary — not just Node projects.
129
+
130
+ ### Shared layout (default)
131
+
132
+ By default the skill lives in exactly one place and every other agent directory points at it:
133
+
134
+ ```
135
+ .agents/skills/klint-rules/ real skill + .klint-skill.json receipt
136
+ .claude/skills/klint-rules -> ../../.agents/skills/klint-rules
137
+ .cursor/skills/klint-rules -> ../../.agents/skills/klint-rules
138
+ ```
139
+
140
+ `.agents/skills` is where opencode and Codex already read from, so it doubles as the hub. One source of truth means one file to diff, one receipt, and one warning when it drifts. The links are relative and stay inside the repo — nothing points into `node_modules`, so reinstalling dependencies can never break them.
141
+
142
+ Pass `--copy` instead to give every agent directory its own independent copy and receipt.
143
+
144
+ ### Staleness and legacy links
145
+
146
+ Each installed skill carries a `.klint-skill.json` receipt recording the klint version and the SHA-256 of the skill it came from. Every later run compares that hash against the skill the installed klint ships:
147
+
148
+ ```
149
+ .agents/skills/klint-rules/SKILL.md:1 warn klint/skill-stale
150
+ this klint-rules skill was installed from klint 0.32.0 and no longer matches klint 0.33.0 — reinstall it with: klint install-skill
151
+ ```
152
+
153
+ In the shared layout this fires once, not once per symlink. Hand-copied or edited skills have no receipt, so they are never flagged — and `install-skill` refuses to overwrite them unless you pass `--force`.
154
+
155
+ Earlier versions symlinked into `node_modules/@konvert7/klint`, which breaks the moment dependencies are reinstalled. klint now reports those:
156
+
157
+ ```
158
+ .claude/skills/klint-rules:1 warn klint/skill-legacy-link
159
+ this skill is a symlink into node_modules (../../node_modules/@konvert7/klint/skill/klint-rules), which breaks as soon as dependencies are reinstalled — run: klint install-skill
160
+ ```
161
+
162
+ Running `klint install-skill` replaces those links with the shared layout, including links whose target is already gone. No `--force` needed — a symlink holds no content to lose.
163
+
128
164
  ## Engines
129
165
 
130
166
  klint currently ships a TypeScript engine and an experimental Rust engine. The Rust path is built for portable architecture checks and syntax-local rules; type-aware checks stay in TypeScript for now.
package/cli/help.ts CHANGED
@@ -13,7 +13,7 @@ export function printHelp(): void {
13
13
  "klint — agent harness for TypeScript architecture rules",
14
14
  "",
15
15
  "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
16
- " klint install-skill [--agents <list>] [--symlink | --copy]",
16
+ " klint install-skill [--agents <list>] [--symlink | --copy] [--force]",
17
17
  "",
18
18
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
19
19
  " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
@@ -24,8 +24,14 @@ export function printHelp(): void {
24
24
  "",
25
25
  " install-skill install the rule-authoring skill into agent config directories",
26
26
  " --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
27
- " --symlink install as symlink (stays in sync with updates)",
28
- " --copy install as copy (default in non-TTY)",
27
+ " --symlink keep one skill in .agents/skills and symlink the",
28
+ " other agents to it (default)",
29
+ " --copy give every agent directory its own copy",
30
+ " --force replace a skill directory klint did not install",
31
+ "",
32
+ " Installed skills record the version and hash they came from. klint warns with",
33
+ " klint/skill-stale once the shipped skill moves on, and with",
34
+ " klint/skill-legacy-link for old symlinks that point into node_modules.",
29
35
  "",
30
36
  `Built-in rules (${standaloneRules.length}):`,
31
37
  ...standaloneRules.map((r) => ` ${r}`),
@@ -1,102 +1,264 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, mkdir, rm, symlink } from "node:fs/promises";
2
+ import { cp, lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
4
  import * as clack from "@clack/prompts";
5
- import { PACKAGE_ROOT } from "./paths";
5
+ import {
6
+ AGENT_SKILL_DIRS,
7
+ type AgentKey,
8
+ agentSkillDirs,
9
+ CANONICAL_SKILL_DIR,
10
+ RECEIPT_FILE_NAME,
11
+ SKILL_DIR_NAME,
12
+ SKILL_FILE_NAME,
13
+ type SkillReceipt,
14
+ skillHash,
15
+ } from "../core/skill";
16
+ import { installedVersion, PACKAGE_ROOT } from "./paths";
6
17
 
7
18
  const AGENT_TARGETS = [
8
19
  { value: "claude", label: "Claude Code" },
9
20
  { value: "opencode", label: "opencode" },
10
21
  { value: "cursor", label: "Cursor" },
11
22
  { value: "codex", label: "Codex" },
12
- ] as const;
23
+ ] as const satisfies ReadonlyArray<{ value: AgentKey; label: string }>;
13
24
 
14
- type AgentKey = (typeof AGENT_TARGETS)[number]["value"];
15
-
16
- const AGENT_DIRS: Record<AgentKey, string> = {
17
- claude: ".claude/skills",
18
- opencode: ".agents/skills",
19
- cursor: ".cursor/skills",
20
- codex: ".agents/skills",
21
- };
25
+ interface InstallPlan {
26
+ agents: AgentKey[];
27
+ shared: boolean;
28
+ force: boolean;
29
+ }
22
30
 
23
31
  export async function installSkill(args: string[]): Promise<void> {
24
- const skillSrc = join(PACKAGE_ROOT, "skill", "klint-rules");
32
+ const skillSrc = join(PACKAGE_ROOT, "skill", SKILL_DIR_NAME);
25
33
  if (!existsSync(skillSrc)) {
26
34
  process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
27
35
  process.exit(1);
28
36
  }
29
37
 
30
- // Parse non-interactive flags
31
- let flagAgents: AgentKey[] | undefined;
32
- let flagSymlink: boolean | undefined;
33
- for (let i = 0; i < args.length; i++) {
34
- if (args[i] === "--agents" && args[i + 1])
35
- flagAgents = args[++i].split(",") as AgentKey[];
36
- else if (args[i] === "--symlink") flagSymlink = true;
37
- else if (args[i] === "--copy") flagSymlink = false;
38
+ const plan = await resolvePlan(args);
39
+ const dirs = agentSkillDirs(plan.agents);
40
+
41
+ try {
42
+ const report = plan.shared
43
+ ? await installShared({ dirs, force: plan.force, skillSrc })
44
+ : await installCopies({ dirs, force: plan.force, skillSrc });
45
+ for (const line of report) process.stdout.write(`klint: ${line}\n`);
46
+ } catch (error) {
47
+ process.stderr.write(`${(error as Error).message}\n`);
48
+ process.exit(1);
38
49
  }
39
50
 
40
- let selectedAgents: AgentKey[];
41
- let useSymlink: boolean;
51
+ if (process.stdin.isTTY) {
52
+ clack.outro("Done.");
53
+ }
54
+ }
42
55
 
43
- if (!process.stdin.isTTY || flagAgents !== undefined || flagSymlink !== undefined) {
44
- selectedAgents = flagAgents ?? (AGENT_TARGETS.map((a) => a.value) as AgentKey[]);
45
- useSymlink = flagSymlink ?? false;
46
- } else {
47
- clack.intro("klint install-skill");
56
+ async function installShared({
57
+ dirs,
58
+ force,
59
+ skillSrc,
60
+ }: {
61
+ dirs: string[];
62
+ force: boolean;
63
+ skillSrc: string;
64
+ }): Promise<string[]> {
65
+ const hub = join(CANONICAL_SKILL_DIR, SKILL_DIR_NAME);
66
+ const report = [await installCopy(hub, skillSrc, force)];
48
67
 
49
- const agents = await clack.multiselect<AgentKey>({
50
- message: "Which agents should the skill be installed for?",
51
- options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
52
- initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
53
- });
54
- if (clack.isCancel(agents)) {
55
- clack.cancel("Cancelled.");
56
- process.exit(0);
57
- }
58
- selectedAgents = agents as AgentKey[];
59
-
60
- const mode = await clack.select<"symlink" | "copy">({
61
- message: "Install as symlink or copy?",
62
- options: [
63
- {
64
- value: "symlink",
65
- label: "Symlink",
66
- hint: "stays in sync when klint updates",
67
- },
68
- {
69
- value: "copy",
70
- label: "Copy",
71
- hint: "one-time snapshot, no ongoing dependency",
72
- },
73
- ],
74
- });
75
- if (clack.isCancel(mode)) {
76
- clack.cancel("Cancelled.");
77
- process.exit(0);
78
- }
79
- useSymlink = mode === "symlink";
68
+ for (const dir of dirs) {
69
+ if (dir === CANONICAL_SKILL_DIR) continue;
70
+ report.push(await linkToHub(join(dir, SKILL_DIR_NAME), hub, skillSrc, force));
80
71
  }
72
+ return report;
73
+ }
81
74
 
82
- const cwd = process.cwd();
83
- const linkType = process.platform === "win32" ? "junction" : "dir";
84
- for (const key of selectedAgents) {
85
- const dest = resolve(cwd, AGENT_DIRS[key], "klint-rules");
86
- await mkdir(dirname(dest), { recursive: true });
87
- try {
88
- await rm(dest, { recursive: true, force: true });
89
- } catch {
90
- /* already gone */
91
- }
92
- if (useSymlink) {
93
- await symlink(relative(dirname(dest), skillSrc), dest, linkType);
94
- } else {
95
- await cp(skillSrc, dest, { recursive: true });
75
+ async function installCopies({
76
+ dirs,
77
+ force,
78
+ skillSrc,
79
+ }: {
80
+ dirs: string[];
81
+ force: boolean;
82
+ skillSrc: string;
83
+ }): Promise<string[]> {
84
+ const report: string[] = [];
85
+ for (const dir of dirs) {
86
+ report.push(await installCopy(join(dir, SKILL_DIR_NAME), skillSrc, force));
87
+ }
88
+ return report;
89
+ }
90
+
91
+ async function installCopy(
92
+ target: string,
93
+ skillSrc: string,
94
+ force: boolean
95
+ ): Promise<string> {
96
+ const dest = resolve(process.cwd(), target);
97
+ await prepareDestination(dest, target, force);
98
+ await cp(skillSrc, dest, { recursive: true });
99
+ await writeReceipt(dest, skillSrc);
100
+ return `installed ${target}`;
101
+ }
102
+
103
+ async function linkToHub(
104
+ target: string,
105
+ hub: string,
106
+ skillSrc: string,
107
+ force: boolean
108
+ ): Promise<string> {
109
+ const dest = resolve(process.cwd(), target);
110
+ await prepareDestination(dest, target, force);
111
+
112
+ const absoluteHub = resolve(process.cwd(), hub);
113
+ const linkPath = relative(dirname(dest), absoluteHub);
114
+ if (await linkResolves(linkPath, dest, "dir")) return `linked ${target} -> ${linkPath}`;
115
+ if (await linkResolves(absoluteHub, dest, "junction"))
116
+ return `linked ${target} -> ${hub}`;
117
+
118
+ await cp(skillSrc, dest, { recursive: true });
119
+ await writeReceipt(dest, skillSrc);
120
+ return `installed ${target} (symlinks unavailable here)`;
121
+ }
122
+
123
+ async function linkResolves(
124
+ linkTarget: string,
125
+ dest: string,
126
+ type: "dir" | "junction"
127
+ ): Promise<boolean> {
128
+ try {
129
+ await symlink(linkTarget, dest, type);
130
+ if (existsSync(join(dest, SKILL_FILE_NAME))) return true;
131
+ } catch {
132
+ /* try the next strategy */
133
+ }
134
+ await rm(dest, { force: true, recursive: true });
135
+ return false;
136
+ }
137
+
138
+ async function prepareDestination(
139
+ dest: string,
140
+ target: string,
141
+ force: boolean
142
+ ): Promise<void> {
143
+ await mkdir(dirname(dest), { recursive: true });
144
+
145
+ const existing = await lstat(dest).catch(() => undefined);
146
+ if (existing === undefined) return;
147
+
148
+ if (!existing.isDirectory() || existsSync(join(dest, RECEIPT_FILE_NAME)) || force) {
149
+ await rm(dest, { force: true, recursive: true });
150
+ return;
151
+ }
152
+
153
+ throw new Error(
154
+ `klint: ${target} exists and was not installed by klint — pass --force to replace it`
155
+ );
156
+ }
157
+
158
+ async function writeReceipt(dest: string, skillSrc: string): Promise<void> {
159
+ const receipt: SkillReceipt = {
160
+ version: installedVersion(),
161
+ sha256: skillHash(await readFile(join(skillSrc, SKILL_FILE_NAME))),
162
+ };
163
+ await writeFile(join(dest, RECEIPT_FILE_NAME), `${JSON.stringify(receipt, null, 2)}\n`);
164
+ }
165
+
166
+ async function resolvePlan(args: string[]): Promise<InstallPlan> {
167
+ const flags = parseFlags(args);
168
+ const decided = flags.agents !== undefined || flags.shared !== undefined;
169
+
170
+ if (!process.stdin.isTTY || decided) {
171
+ return {
172
+ agents: flags.agents ?? everyAgent(),
173
+ shared: flags.shared ?? true,
174
+ force: flags.force,
175
+ };
176
+ }
177
+
178
+ clack.intro("klint install-skill");
179
+ return {
180
+ agents: await promptForAgents(),
181
+ shared: await promptForShared(),
182
+ force: flags.force,
183
+ };
184
+ }
185
+
186
+ function parseFlags(args: string[]): {
187
+ agents?: AgentKey[];
188
+ shared?: boolean;
189
+ force: boolean;
190
+ } {
191
+ let agents: AgentKey[] | undefined;
192
+ let shared: boolean | undefined;
193
+ let force = false;
194
+
195
+ for (let i = 0; i < args.length; i++) {
196
+ if (args[i] === "--agents" && args[i + 1]) agents = parseAgents(args[++i]);
197
+ else if (args[i] === "--symlink" || args[i] === "--shared") shared = true;
198
+ else if (args[i] === "--copy") shared = false;
199
+ else if (args[i] === "--force") force = true;
200
+ else {
201
+ process.stderr.write(`klint: unknown install-skill argument "${args[i]}"\n`);
202
+ process.exit(1);
96
203
  }
97
204
  }
98
205
 
99
- if (process.stdin.isTTY) {
100
- clack.outro("Done.");
206
+ return { agents, force, shared };
207
+ }
208
+
209
+ function parseAgents(value: string): AgentKey[] {
210
+ return value
211
+ .split(",")
212
+ .map((agent) => agent.trim())
213
+ .filter((agent) => agent.length > 0)
214
+ .map((agent) => {
215
+ if (!(agent in AGENT_SKILL_DIRS)) {
216
+ process.stderr.write(
217
+ `klint: unknown agent "${agent}" (expected one of: ${everyAgent().join(", ")})\n`
218
+ );
219
+ process.exit(1);
220
+ }
221
+ return agent as AgentKey;
222
+ });
223
+ }
224
+
225
+ function everyAgent(): AgentKey[] {
226
+ return AGENT_TARGETS.map((target) => target.value);
227
+ }
228
+
229
+ async function promptForAgents(): Promise<AgentKey[]> {
230
+ const agents = await clack.multiselect<AgentKey>({
231
+ message: "Which agents should the skill be installed for?",
232
+ options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
233
+ initialValues: everyAgent(),
234
+ });
235
+ if (clack.isCancel(agents)) {
236
+ clack.cancel("Cancelled.");
237
+ process.exit(0);
238
+ }
239
+ return agents as AgentKey[];
240
+ }
241
+
242
+ async function promptForShared(): Promise<boolean> {
243
+ const mode = await clack.select<"shared" | "copy">({
244
+ message: "How should the skill be stored?",
245
+ initialValue: "shared",
246
+ options: [
247
+ {
248
+ value: "shared",
249
+ label: "Shared (recommended)",
250
+ hint: `one skill in ${CANONICAL_SKILL_DIR}, other agents symlink to it`,
251
+ },
252
+ {
253
+ value: "copy",
254
+ label: "Separate copies",
255
+ hint: "an independent copy in every agent directory",
256
+ },
257
+ ],
258
+ });
259
+ if (clack.isCancel(mode)) {
260
+ clack.cancel("Cancelled.");
261
+ process.exit(0);
101
262
  }
263
+ return mode === "shared";
102
264
  }
package/cli/paths.ts CHANGED
@@ -1,3 +1,20 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { resolve } from "node:path";
2
3
 
3
4
  export const PACKAGE_ROOT = resolve(import.meta.dir, "..");
5
+
6
+ export const SHIPPED_SKILL_PATH = resolve(
7
+ PACKAGE_ROOT,
8
+ "skill",
9
+ "klint-rules",
10
+ "SKILL.md"
11
+ );
12
+
13
+ export function installedVersion(): string {
14
+ try {
15
+ const manifest = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
16
+ return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
17
+ } catch {
18
+ return "0.0.0";
19
+ }
20
+ }
package/cli.ts CHANGED
@@ -12,22 +12,14 @@ import {
12
12
  writeDebugEvent,
13
13
  writeTextOutput,
14
14
  } from "./cli/output";
15
- import { PACKAGE_ROOT } from "./cli/paths";
15
+ import { installedVersion, SHIPPED_SKILL_PATH } from "./cli/paths";
16
16
  import { runAutoEngine, runCompareEngine, runRustEngine } from "./cli/rust-engine";
17
17
  import { applyFixes } from "./core/fixer";
18
18
  import { runKlint } from "./core/runner";
19
19
  import { schemaVersionAdvisory } from "./core/schema-version";
20
+ import { skillAdvisories } from "./core/skill";
20
21
  import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
21
22
 
22
- async function installedVersion(): Promise<string> {
23
- const manifest = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
24
- try {
25
- return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
26
- } catch {
27
- return "0.0.0";
28
- }
29
- }
30
-
31
23
  interface CliOptions {
32
24
  configDir?: string;
33
25
  rulesFile?: string;
@@ -42,8 +34,9 @@ export async function main(opts: CliOptions = {}): Promise<void> {
42
34
  process.exit(0);
43
35
  }
44
36
 
45
- if (args[0] === "install-skill") {
46
- await installSkill(args.slice(1));
37
+ const subcommand = args.indexOf("install-skill");
38
+ if (subcommand !== -1) {
39
+ await installSkill(args.slice(subcommand + 1));
47
40
  return;
48
41
  }
49
42
 
@@ -65,8 +58,11 @@ export async function main(opts: CliOptions = {}): Promise<void> {
65
58
  printHelp();
66
59
  process.exit(0);
67
60
  } else if (args[i] === "--version" || args[i] === "-V") {
68
- process.stdout.write(`klint ${await installedVersion()}\n`);
61
+ process.stdout.write(`klint ${installedVersion()}\n`);
69
62
  process.exit(0);
63
+ } else {
64
+ process.stderr.write(`klint: unknown argument "${args[i]}"\n`);
65
+ process.exit(1);
70
66
  }
71
67
  }
72
68
 
@@ -103,12 +99,20 @@ export async function main(opts: CliOptions = {}): Promise<void> {
103
99
  }
104
100
  const root = resolve(configDir, raw.root ?? ".");
105
101
 
106
- const advisories = schemaVersionAdvisory({
107
- schema: raw.$schema,
108
- installed: await installedVersion(),
109
- configFile: usingYaml ? "klint.yaml" : "klint.config.json",
110
- configText,
111
- });
102
+ const installed = installedVersion();
103
+ const advisories = [
104
+ ...schemaVersionAdvisory({
105
+ schema: raw.$schema,
106
+ installed,
107
+ configFile: usingYaml ? "klint.yaml" : "klint.config.json",
108
+ configText,
109
+ }),
110
+ ...skillAdvisories({
111
+ configDir,
112
+ installed,
113
+ shippedSkillPath: SHIPPED_SKILL_PATH,
114
+ }),
115
+ ];
112
116
 
113
117
  if (engine === "rust") {
114
118
  runRustEngine({ advisories, fix, json, raw, root, rulesFile, startedAt });
package/core/skill.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { Violation } from "./types";
5
+
6
+ export const AGENT_SKILL_DIRS = {
7
+ claude: ".claude/skills",
8
+ codex: ".agents/skills",
9
+ cursor: ".cursor/skills",
10
+ opencode: ".agents/skills",
11
+ } as const;
12
+
13
+ export type AgentKey = keyof typeof AGENT_SKILL_DIRS;
14
+
15
+ export const CANONICAL_SKILL_DIR = ".agents/skills";
16
+ export const SKILL_DIR_NAME = "klint-rules";
17
+ export const SKILL_FILE_NAME = "SKILL.md";
18
+ export const RECEIPT_FILE_NAME = ".klint-skill.json";
19
+
20
+ const STALE_RULE = "klint/skill-stale";
21
+ const LEGACY_RULE = "klint/skill-legacy-link";
22
+ const LEGACY_TARGET = "node_modules";
23
+
24
+ export interface SkillReceipt {
25
+ version: string;
26
+ sha256: string;
27
+ }
28
+
29
+ export function skillHash(contents: Buffer | string): string {
30
+ return createHash("sha256").update(contents).digest("hex");
31
+ }
32
+
33
+ export function agentSkillDirs(agents?: readonly AgentKey[]): string[] {
34
+ const selected = (agents ?? (Object.keys(AGENT_SKILL_DIRS) as AgentKey[])).map(
35
+ (agent) => AGENT_SKILL_DIRS[agent]
36
+ );
37
+ return [...new Set(selected)];
38
+ }
39
+
40
+ export function skillAdvisories({
41
+ configDir,
42
+ installed,
43
+ shippedSkillPath,
44
+ }: {
45
+ configDir: string;
46
+ installed: string;
47
+ shippedSkillPath: string;
48
+ }): Array<Omit<Violation, "fix">> {
49
+ return [
50
+ ...legacyLinkAdvisories(configDir),
51
+ ...stalenessAdvisories({ configDir, installed, shippedSkillPath }),
52
+ ];
53
+ }
54
+
55
+ function legacyLinkAdvisories(configDir: string): Array<Omit<Violation, "fix">> {
56
+ return agentSkillDirs().flatMap((dir) => {
57
+ const target = symlinkTarget(join(configDir, dir, SKILL_DIR_NAME));
58
+ if (target === undefined || !target.includes(LEGACY_TARGET)) return [];
59
+ return [
60
+ {
61
+ file: `${dir}/${SKILL_DIR_NAME}`,
62
+ line: 1,
63
+ rule: LEGACY_RULE,
64
+ severity: "warn",
65
+ message: `this skill is a symlink into ${LEGACY_TARGET} (${target}), which breaks as soon as dependencies are reinstalled — run: klint install-skill`,
66
+ },
67
+ ];
68
+ });
69
+ }
70
+
71
+ function stalenessAdvisories({
72
+ configDir,
73
+ installed,
74
+ shippedSkillPath,
75
+ }: {
76
+ configDir: string;
77
+ installed: string;
78
+ shippedSkillPath: string;
79
+ }): Array<Omit<Violation, "fix">> {
80
+ const shipped = readShippedHash(shippedSkillPath);
81
+ if (shipped === undefined) return [];
82
+
83
+ const reported = new Set<string>();
84
+ return realSkillDirsFirst(configDir).flatMap((dir) => {
85
+ const skillDir = join(configDir, dir, SKILL_DIR_NAME);
86
+ const canonical = resolveSkillDir(skillDir);
87
+ if (canonical === undefined || reported.has(canonical)) return [];
88
+
89
+ const receipt = readReceipt(join(skillDir, RECEIPT_FILE_NAME));
90
+ if (receipt === undefined || receipt.sha256 === shipped) return [];
91
+
92
+ reported.add(canonical);
93
+ return [staleAdvisory(dir, receipt.version, installed)];
94
+ });
95
+ }
96
+
97
+ function staleAdvisory(
98
+ dir: string,
99
+ installedFrom: string,
100
+ installed: string
101
+ ): Omit<Violation, "fix"> {
102
+ return {
103
+ file: `${dir}/${SKILL_DIR_NAME}/${SKILL_FILE_NAME}`,
104
+ line: 1,
105
+ rule: STALE_RULE,
106
+ severity: "warn",
107
+ message: `this klint-rules skill was installed from klint ${installedFrom} and no longer matches klint ${installed} — reinstall it with: klint install-skill`,
108
+ };
109
+ }
110
+
111
+ function realSkillDirsFirst(configDir: string): string[] {
112
+ const dirs = agentSkillDirs();
113
+ const linked = new Set(
114
+ dirs.filter(
115
+ (dir) => symlinkTarget(join(configDir, dir, SKILL_DIR_NAME)) !== undefined
116
+ )
117
+ );
118
+ return [...dirs.filter((dir) => !linked.has(dir)), ...linked];
119
+ }
120
+
121
+ function symlinkTarget(path: string): string | undefined {
122
+ try {
123
+ if (!lstatSync(path).isSymbolicLink()) return undefined;
124
+ return readlinkSync(path);
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ function resolveSkillDir(path: string): string | undefined {
131
+ try {
132
+ return realpathSync(path);
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ function readShippedHash(shippedSkillPath: string): string | undefined {
139
+ try {
140
+ return skillHash(readFileSync(shippedSkillPath));
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function readReceipt(path: string): SkillReceipt | undefined {
147
+ let text: string;
148
+ try {
149
+ text = readFileSync(path, "utf-8");
150
+ } catch {
151
+ return undefined;
152
+ }
153
+
154
+ try {
155
+ const parsed = JSON.parse(text) as Partial<SkillReceipt>;
156
+ if (typeof parsed.version !== "string" || typeof parsed.sha256 !== "string") {
157
+ return undefined;
158
+ }
159
+ return { version: parsed.version, sha256: parsed.sha256 };
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
package/klint.schema.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.0/klint.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.1/klint.schema.json",
4
4
  "type": "object",
5
5
  "properties": {
6
6
  "$schema": {
package/klint.schema.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  $schema: http://json-schema.org/draft-07/schema#
2
- $id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.0/klint.schema.json
2
+ $id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.1/klint.schema.json
3
3
  type: object
4
4
  properties:
5
5
  $schema:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Architecture-as-Code linter for TypeScript. Enforce layer boundaries, import rules, and singleton patterns in YAML.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,10 +70,10 @@
70
70
  "zod": "^4.4.3"
71
71
  },
72
72
  "optionalDependencies": {
73
- "@konvert7/klint-darwin-arm64": "0.33.0",
74
- "@konvert7/klint-darwin-x64": "0.33.0",
75
- "@konvert7/klint-linux-x64": "0.33.0",
76
- "@konvert7/klint-win32-x64": "0.33.0"
73
+ "@konvert7/klint-darwin-arm64": "0.33.1",
74
+ "@konvert7/klint-darwin-x64": "0.33.1",
75
+ "@konvert7/klint-linux-x64": "0.33.1",
76
+ "@konvert7/klint-win32-x64": "0.33.1"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@biomejs/biome": "2.4.7",