@hyuga/carrylint 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/action.yml +36 -0
- package/package.json +45 -0
- package/rules.json +36 -0
- package/src/check.mjs +405 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hyuga611
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# carrylint
|
|
2
|
+
|
|
3
|
+
**Your skill works on your machine. Does it work on your teammate's — or in a different agent?**
|
|
4
|
+
`carrylint` is a zero-dependency, model-agnostic linter that fails your PR when a `SKILL.md`, `AGENTS.md`, or slash-command has your machine or your model baked in — absolute paths, undeclared external CLIs, unresolved placeholders. It runs **no LLM and needs no API key**: pure static analysis.
|
|
5
|
+
|
|
6
|
+
**あなたのスキル、自分のマシンでは動く。でも "次の人の環境・別のエージェント" で動きますか?**
|
|
7
|
+
`carrylint` は、`SKILL.md` / `AGENTS.md` / スラッシュコマンドに**作った本人の環境・モデル前提が焼き込まれていないか**を CI で落とす、依存ゼロ・モデル非依存のリンタ。絶対パス・未宣言の外部CLI・未解決プレースホルダを検出します。**実行時に LLM も API キーも使いません**(純静的解析)。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why / なぜ
|
|
12
|
+
|
|
13
|
+
[Agent Skills](https://agentskills.io) became an open standard in Dec 2025 — one `SKILL.md` runs across 20+ agents (Claude Code, Codex, Gemini CLI, Cursor, Copilot …). The **format** is portable now. But the **content** still isn't: a skill that shells out to `codex` you never installed, writes to `C:\Users\you\Downloads`, or hardcodes `gpt-image-2` silently breaks the moment someone else installs it.
|
|
14
|
+
|
|
15
|
+
Other skill linters check your skill is **spec-valid**. carrylint checks it **actually runs when someone else installs it**.
|
|
16
|
+
|
|
17
|
+
> オープン標準化で "形式" は可搬になった。carrylint は "中身が実際に動くか" を見る側です。
|
|
18
|
+
|
|
19
|
+
## What it checks / 何を見るか
|
|
20
|
+
|
|
21
|
+
| kind | severity | 説明 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `abs-path` | **error** | マシン固有の絶対パス(`C:\Users\…` / `/Users/…` / `/home/…` / `$HOME` / `%USERPROFILE%`) |
|
|
24
|
+
| `placeholder` | **error** | 配布物に残った未解決プレースホルダ(`<FILL_ME>` / `YOUR_API_KEY` / `path/to/your…`) |
|
|
25
|
+
| `undeclared-cli` | **error** | 外部/プロバイダCLI(`codex` `gemini` `ollama` …)を叩くのに install 手順も `requires` 宣言もない |
|
|
26
|
+
| `home-path` | warn | `~/…` ホーム相対パス(ユーザー前提) |
|
|
27
|
+
| `provider-env` | warn | プロバイダ固有 API キー env の生参照(`OPENAI_API_KEY` …) |
|
|
28
|
+
| `todo` | warn | 配布物に残った `TODO:` / `FIXME:` |
|
|
29
|
+
| `model-id` | opt-in | モデルID直書き(`claude-*` / `gpt-*` / `gemini-*`)※`--model-ids` で有効化 |
|
|
30
|
+
|
|
31
|
+
Low-noise by design: only the unambiguous breakers are **error** (fail the PR); the rest are **warn**; intentional model pinning is **off** unless you ask for it.
|
|
32
|
+
|
|
33
|
+
> 誤検知=狼少年化が唯一の死因なので、曖昧さゼロのものだけ error にしています。
|
|
34
|
+
|
|
35
|
+
## Use as a GitHub Action / CIで使う(定着の本体)
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
# .github/workflows/carrylint.yml
|
|
39
|
+
name: carrylint
|
|
40
|
+
on: [push, pull_request]
|
|
41
|
+
jobs:
|
|
42
|
+
carrylint:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
- uses: hyuga611/carrylint@v0
|
|
47
|
+
with:
|
|
48
|
+
paths: . # optional; default = repo
|
|
49
|
+
# strict: 'true' # warnings also fail
|
|
50
|
+
# model-ids: 'true' # enable the opt-in model-id rule
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Findings appear as inline PR annotations and the job fails (exit 1) on any **error**, so a non-portable skill can't be merged.
|
|
54
|
+
|
|
55
|
+
## Use as a CLI / ローカルで使う
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx @hyuga/carrylint # scan the repo
|
|
59
|
+
npx @hyuga/carrylint path/to/skills # scan a dir/file
|
|
60
|
+
npx @hyuga/carrylint --allow codex,gemini # I depend on these on purpose
|
|
61
|
+
npx @hyuga/carrylint --strict # warnings fail too
|
|
62
|
+
npx @hyuga/carrylint --model-ids # enable the model-id rule
|
|
63
|
+
npx @hyuga/carrylint --format json # machine-readable (VS Code / tooling)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Suppress a line / 行単位の無効化
|
|
67
|
+
|
|
68
|
+
```md
|
|
69
|
+
Save to `C:\tools\out.png` <!-- carry-ignore -->
|
|
70
|
+
|
|
71
|
+
<!-- carry-ignore-next -->
|
|
72
|
+
This model is pinned on purpose: `claude-opus-4-8`
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Config / 設定
|
|
76
|
+
|
|
77
|
+
The provider-CLI / model-ID / env dictionary lives in `rules.json` (data-driven — edit it, no code change). CLI flags also read env: `CARRYLINT_STRICT=1`, `CARRYLINT_MODEL_IDS=1`, `CARRYLINT_FORMAT=json`.
|
|
78
|
+
|
|
79
|
+
## Model-agnostic / モデル非依存
|
|
80
|
+
|
|
81
|
+
carrylint treats `codex` / `gemini` / `claude` / `ollama` / `aider` … **equally** as provider lock-in — it never favors one vendor. The check itself is "are you locked to one machine or one model?", so it works the same whether you author with Claude, Codex, or Gemini. No LLM at runtime.
|
|
82
|
+
|
|
83
|
+
## How it differs / 既存との違い
|
|
84
|
+
|
|
85
|
+
- **reflint** — does the reference *exist*? (referential integrity)
|
|
86
|
+
- **skills-lint** — do two skills *collide*? is the frontmatter valid?
|
|
87
|
+
- **carrylint** — does the reference *resolve on someone else's machine / another agent*? (runtime portability)
|
|
88
|
+
|
|
89
|
+
Part of a small family of zero-dependency, language-agnostic, CI-resident linters for AI-native artifacts.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT © hyuga611
|
package/action.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: 'carrylint runtime-portability linter'
|
|
2
|
+
description: 'Fail CI when a skill/command hardcodes machine- or model-specific assumptions: absolute paths, undeclared CLIs, unresolved placeholders. Zero-dep, model-agnostic.'
|
|
3
|
+
author: 'hyuga611'
|
|
4
|
+
branding:
|
|
5
|
+
icon: 'package'
|
|
6
|
+
color: 'blue'
|
|
7
|
+
inputs:
|
|
8
|
+
paths:
|
|
9
|
+
description: 'Space-separated files or dirs to scan. Default: the repo (SKILL.md / AGENTS.md / CLAUDE.md / GEMINI.md / .claude/commands …).'
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
strict:
|
|
13
|
+
description: 'Treat warnings as failures (exit 1).'
|
|
14
|
+
required: false
|
|
15
|
+
default: 'false'
|
|
16
|
+
model-ids:
|
|
17
|
+
description: 'Enable the opt-in hardcoded model-ID rule.'
|
|
18
|
+
required: false
|
|
19
|
+
default: 'false'
|
|
20
|
+
runs:
|
|
21
|
+
using: 'composite'
|
|
22
|
+
steps:
|
|
23
|
+
- name: Run carrylint
|
|
24
|
+
shell: bash
|
|
25
|
+
# Caller inputs are passed via env (NOT ${{ }} inlined into the script) to avoid
|
|
26
|
+
# template/shell injection. $PATHS is intentionally unquoted so multiple space-separated
|
|
27
|
+
# paths split into separate argv; the flag array is quote-safe.
|
|
28
|
+
env:
|
|
29
|
+
PATHS: ${{ inputs.paths }}
|
|
30
|
+
STRICT: ${{ inputs.strict }}
|
|
31
|
+
MODEL_IDS: ${{ inputs.model-ids }}
|
|
32
|
+
run: |
|
|
33
|
+
args=()
|
|
34
|
+
[ "$STRICT" = "true" ] && args+=(--strict)
|
|
35
|
+
[ "$MODEL_IDS" = "true" ] && args+=(--model-ids)
|
|
36
|
+
node "$GITHUB_ACTION_PATH/src/check.mjs" "${args[@]}" $PATHS
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyuga/carrylint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime-portability linter for agent skills & commands: fail CI when a SKILL.md / AGENTS.md / slash-command hardcodes machine- or model-specific assumptions (absolute paths, undeclared CLIs, unresolved placeholders). Zero-dependency, model-agnostic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"carrylint": "src/check.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"rules.json",
|
|
12
|
+
"action.yml"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"poc": "node src/check.mjs examples/bad",
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"skill.md",
|
|
20
|
+
"agent-skills",
|
|
21
|
+
"claude",
|
|
22
|
+
"codex",
|
|
23
|
+
"gemini",
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"linter",
|
|
27
|
+
"ci",
|
|
28
|
+
"portability",
|
|
29
|
+
"model-agnostic",
|
|
30
|
+
"cross-agent"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "hyuga611",
|
|
34
|
+
"homepage": "https://github.com/hyuga611/carrylint",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/hyuga611/carrylint.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/hyuga611/carrylint/issues"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/rules.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "carrylint のデータ駆動ルール辞書。ここを編集すれば scan() の判定が変わる(コード変更不要)。src/check.mjs の DEFAULT_RULES が同梱フォールバック。",
|
|
3
|
+
|
|
4
|
+
"providerClis": [
|
|
5
|
+
"codex", "gemini", "ollama", "claude", "aider", "cursor-agent",
|
|
6
|
+
"llm", "sgpt", "mods", "copilot", "goose"
|
|
7
|
+
],
|
|
8
|
+
|
|
9
|
+
"packageBinaries": {
|
|
10
|
+
"@openai/codex": "codex",
|
|
11
|
+
"@google/gemini-cli": "gemini",
|
|
12
|
+
"@anthropic-ai/claude-code": "claude",
|
|
13
|
+
"@githubnext/github-copilot-cli": "copilot",
|
|
14
|
+
"@block/goose": "goose",
|
|
15
|
+
"aider-chat": "aider",
|
|
16
|
+
"aider-install": "aider",
|
|
17
|
+
"llm": "llm",
|
|
18
|
+
"shell-gpt": "sgpt"
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
"modelIdPatterns": [
|
|
22
|
+
"claude-[a-z0-9][a-z0-9.\\-]*",
|
|
23
|
+
"gpt-[0-9][a-z0-9.\\-]*",
|
|
24
|
+
"gpt-image-[0-9]",
|
|
25
|
+
"o[0-9]-[a-z0-9\\-]+",
|
|
26
|
+
"gemini-[0-9][a-z0-9.\\-]*",
|
|
27
|
+
"dall-e-[0-9]",
|
|
28
|
+
"text-embedding-[a-z0-9\\-]+"
|
|
29
|
+
],
|
|
30
|
+
|
|
31
|
+
"providerEnv": [
|
|
32
|
+
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
|
33
|
+
"AZURE_OPENAI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "COHERE_API_KEY",
|
|
34
|
+
"OLLAMA_HOST", "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// carrylint — 「そのスキル/コマンド、他人のマシン・別のエージェントで本当に動く?」を CI で落とす。
|
|
3
|
+
//
|
|
4
|
+
// SKILL.md / .claude/commands / AGENTS.md / CLAUDE.md / GEMINI.md / .cursor/rules などの本文から、
|
|
5
|
+
// 作った本人の環境・モデル前提が焼き込まれた「実行時に可搬でない」記述を検出する。
|
|
6
|
+
//
|
|
7
|
+
// ・abs-path : マシン固有の絶対パス (C:\… /Users/… /home/… $HOME %USERPROFILE%) [ERROR]
|
|
8
|
+
// ・placeholder : 配布物に残った未解決プレースホルダ (<FILL_ME> YOUR_API_KEY path/to/…) [ERROR]
|
|
9
|
+
// ・undeclared-cli : 本文が外部/プロバイダCLIを叩くのに宣言もインストール手順もない [ERROR]
|
|
10
|
+
// ・home-path : ~/… ホーム相対パス (ユーザー前提) [WARN]
|
|
11
|
+
// ・provider-env : プロバイダ固有の API キー env の生参照 [WARN]
|
|
12
|
+
// ・todo : 配布物に残った TODO:/FIXME:/XXX: [WARN]
|
|
13
|
+
// ・model-id : モデルID直書き (claude-* gpt-* gemini-*) ← 既定OFF・--model-ids で有効 [opt-in]
|
|
14
|
+
//
|
|
15
|
+
// Agent Skills はオープン標準として "形式" は 20+ エージェントで可搬になった。carrylint は
|
|
16
|
+
// "中身が実際に動くか" を見る側。実行時に LLM も API キーも使わない純静的解析(依存ゼロ)。
|
|
17
|
+
//
|
|
18
|
+
// node src/check.mjs [path ...] # path 省略時はカレント配下の対象ファイルを自動探索
|
|
19
|
+
// --strict WARN も exit 1 にする
|
|
20
|
+
// --model-ids model-id ルールを有効化
|
|
21
|
+
// --allow a,b 意図的に依存する CLI を許可(undeclared-cli を抑制)
|
|
22
|
+
// --format json 機械可読 JSON 出力(VS Code 拡張向け)
|
|
23
|
+
//
|
|
24
|
+
// 行内無効化: 行末に <!-- carry-ignore --> でその行を無視。単独行 <!-- carry-ignore-next --> で次行を無視。
|
|
25
|
+
|
|
26
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { resolve, join, dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
|
|
30
|
+
// ---------------- ルール辞書(既定・rules.json で上書き可能) ----------------
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_RULES = {
|
|
33
|
+
// コマンド位置で叩かれたら「そのエージェント/モデルに固定」とみなす外部CLI群。
|
|
34
|
+
providerClis: [
|
|
35
|
+
'codex', 'gemini', 'ollama', 'claude', 'aider', 'cursor-agent',
|
|
36
|
+
'llm', 'sgpt', 'mods', 'copilot', 'goose',
|
|
37
|
+
],
|
|
38
|
+
// 「npm i -g <package>」等が提供するバイナリ名の別名(宣言判定を正確にする)。
|
|
39
|
+
packageBinaries: {
|
|
40
|
+
'@openai/codex': 'codex',
|
|
41
|
+
'@google/gemini-cli': 'gemini',
|
|
42
|
+
'@anthropic-ai/claude-code': 'claude',
|
|
43
|
+
'@githubnext/github-copilot-cli': 'copilot',
|
|
44
|
+
'@block/goose': 'goose',
|
|
45
|
+
'aider-chat': 'aider',
|
|
46
|
+
'aider-install': 'aider',
|
|
47
|
+
'llm': 'llm',
|
|
48
|
+
'shell-gpt': 'sgpt',
|
|
49
|
+
},
|
|
50
|
+
// プロバイダ固有のモデルID(opt-in)。
|
|
51
|
+
modelIdPatterns: [
|
|
52
|
+
'claude-[a-z0-9][a-z0-9.\\-]*',
|
|
53
|
+
'gpt-[0-9][a-z0-9.\\-]*',
|
|
54
|
+
'gpt-image-[0-9]',
|
|
55
|
+
'o[0-9]-[a-z0-9\\-]+',
|
|
56
|
+
'gemini-[0-9][a-z0-9.\\-]*',
|
|
57
|
+
'dall-e-[0-9]',
|
|
58
|
+
'text-embedding-[a-z0-9\\-]+',
|
|
59
|
+
],
|
|
60
|
+
// プロバイダ固有の API キー等 env(生参照は環境前提=WARN)。
|
|
61
|
+
providerEnv: [
|
|
62
|
+
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
|
|
63
|
+
'AZURE_OPENAI_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'COHERE_API_KEY',
|
|
64
|
+
'OLLAMA_HOST', 'OPENROUTER_API_KEY', 'PERPLEXITY_API_KEY',
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 配布物に残っていたら「未完成」を示す、曖昧さの小さいプレースホルダ(ERROR)。
|
|
69
|
+
// 汎用の <name> 等は意図的なテンプレなので拾わない(skills-lint と同じ思想)。
|
|
70
|
+
const PLACEHOLDER_ERROR = [
|
|
71
|
+
/<fill[_\- ]?me>?/i,
|
|
72
|
+
/\bfill[_\-]me\b/i,
|
|
73
|
+
/\breplace[_\-]?me\b/i,
|
|
74
|
+
/\bchange[_\-]?me\b/i,
|
|
75
|
+
/\byour[_\-](?:api[_\-]?key|token|secret|password)\b/i,
|
|
76
|
+
/<your-[^>]{1,40}>/i,
|
|
77
|
+
/<insert[ _\-][^>]{1,40}>/i,
|
|
78
|
+
/\bpath\/to\/your\b/i,
|
|
79
|
+
/(?:^|[\s`"'(])\/path\/to\//,
|
|
80
|
+
/\bpath\/to\/\.{3}/,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ---------------- パターン(純粋・textスキャン) ----------------
|
|
84
|
+
|
|
85
|
+
const DRIVE_ABS = /(?<![\w.])[A-Za-z]:\\[^\s`"')<>|]+/g; // C:\Users\atlan\…
|
|
86
|
+
const UNIX_HOME_ABS = /(?<![\w.\-\/])\/(?:Users|home)\/[^\s`"')<>|]+/g; // /Users/atlan/… /home/foo/…
|
|
87
|
+
const ENV_HOME = /(?:\$\{?HOME\b\}?|%USERPROFILE%)/gi; // $HOME ${HOME} %USERPROFILE%
|
|
88
|
+
const TILDE_HOME = /(?<![\w.])~\/[^\s`"')<>|]*/g; // ~/foo …(WARN)
|
|
89
|
+
const TODO_MARK = /(?:\b(?:TODO|FIXME|HACK|XXX)\b\s*[::]|<!--\s*(?:TODO|FIXME)\b)/;
|
|
90
|
+
|
|
91
|
+
function push(findings, ln, kind, severity, msg) {
|
|
92
|
+
findings.push({ ln, kind, severity, msg });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** frontmatter と本文中の install/宣言行から「宣言済みツール名」を集める。 */
|
|
96
|
+
function declaredTools(text, rules) {
|
|
97
|
+
const declared = new Set();
|
|
98
|
+
const add = (raw) => {
|
|
99
|
+
if (!raw) return;
|
|
100
|
+
let name = String(raw).trim().toLowerCase();
|
|
101
|
+
if (!name) return;
|
|
102
|
+
if (rules.packageBinaries[name]) {
|
|
103
|
+
declared.add(rules.packageBinaries[name]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
name = name.replace(/^@[\w.\-]+\//, '').replace(/@[\w.\-]+$/, '').replace(/[^\w.\-]/g, '');
|
|
107
|
+
if (name) declared.add(name);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// frontmatter の allowed-tools / requires
|
|
111
|
+
const fm = text.match(/^?---\r?\n([\s\S]*?)\r?\n---/);
|
|
112
|
+
if (fm) {
|
|
113
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
114
|
+
const m = line.match(/^\s*(?:allowed-tools|requires|tools|dependencies)\s*:\s*(.+)$/i);
|
|
115
|
+
if (m) m[1].replace(/[\[\]"']/g, '').split(/[,\s]+/).forEach(add);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 本文の install 手順(npm i -g … / brew install … / pip install … 等)
|
|
120
|
+
const installRe = /\b(?:npm\s+(?:i|install)(?:\s+-g|\s+--global)?|pnpm\s+add(?:\s+-g)?|yarn\s+(?:global\s+)?add|brew\s+install(?:\s+--cask)?|pipx?\s+install|pip3?\s+install|cargo\s+install|go\s+install|apt(?:-get)?\s+install|choco\s+install|scoop\s+install|uv\s+tool\s+install)\s+([^\n`|&;]+)/gi;
|
|
121
|
+
for (const m of text.matchAll(installRe)) {
|
|
122
|
+
for (const tok of m[1].split(/[\s,]+/)) {
|
|
123
|
+
if (!tok || tok.startsWith('-')) continue;
|
|
124
|
+
add(tok);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return declared;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** コマンドセグメントの先頭コマンド語を返す(sudo / 環境代入 / $ プロンプトを剥がす)。 */
|
|
131
|
+
function firstCommandWord(seg) {
|
|
132
|
+
let s = seg.trim();
|
|
133
|
+
s = s.replace(/^[!$#>]\s*/, ''); // プロンプト記号 / Claude の !`…`
|
|
134
|
+
s = s.replace(/^sudo\s+/, '');
|
|
135
|
+
s = s.replace(/^(?:[A-Za-z_][\w]*=[^\s]*\s+)+/, ''); // FOO=bar cmd
|
|
136
|
+
const m = s.match(/^([A-Za-z][\w.-]*)/);
|
|
137
|
+
return m ? m[1].toLowerCase() : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 行から providerCLI の「呼び出し」を集める。コード文脈(fence行 or backtickスパン中身)で、
|
|
142
|
+
* かつツール名の後ろに引数/サブコマンドがあるものだけを呼び出しとみなす。
|
|
143
|
+
* 裸の言及(`codex` だけ等)は呼び出しではない=誤検知しない。
|
|
144
|
+
*/
|
|
145
|
+
function cliInvocations(line, inFence, rules) {
|
|
146
|
+
const hits = new Set();
|
|
147
|
+
const providers = new Set(rules.providerClis);
|
|
148
|
+
const segments = [];
|
|
149
|
+
if (inFence) segments.push(line);
|
|
150
|
+
for (const m of line.matchAll(/`([^`]+)`/g)) segments.push(m[1]);
|
|
151
|
+
for (const seg of segments) {
|
|
152
|
+
for (const sub of seg.split(/[\n;|&]+|\$\(/)) {
|
|
153
|
+
const w = firstCommandWord(sub);
|
|
154
|
+
if (!w || !providers.has(w)) continue;
|
|
155
|
+
const rest = sub.trim()
|
|
156
|
+
.replace(/^[!$#>]\s*/, '')
|
|
157
|
+
.replace(/^sudo\s+/, '')
|
|
158
|
+
.replace(/^(?:[A-Za-z_][\w]*=[^\s]*\s+)+/, '');
|
|
159
|
+
if (new RegExp('^' + w + '\\b\\s+\\S', 'i').test(rest)) hits.add(w);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 本文を走査して可搬性エラーを返す(純粋関数・テスト可能)。
|
|
167
|
+
* @param text ファイル本文
|
|
168
|
+
* @param opts { rules, allow:Set|string[], modelIds:boolean }
|
|
169
|
+
*/
|
|
170
|
+
export function scan(text, opts = {}) {
|
|
171
|
+
const rules = opts.rules || DEFAULT_RULES;
|
|
172
|
+
const allow = opts.allow instanceof Set ? opts.allow
|
|
173
|
+
: new Set((opts.allow || []).map((s) => String(s).toLowerCase()));
|
|
174
|
+
const modelIds = !!opts.modelIds;
|
|
175
|
+
const declared = declaredTools(text, rules);
|
|
176
|
+
const modelRe = new RegExp('\\b(?:' + rules.modelIdPatterns.join('|') + ')\\b', 'gi');
|
|
177
|
+
const envRe = new RegExp('\\b(?:' + rules.providerEnv.join('|') + ')\\b');
|
|
178
|
+
|
|
179
|
+
const findings = [];
|
|
180
|
+
const lines = String(text).split(/\r?\n/);
|
|
181
|
+
|
|
182
|
+
// 無効化コメントの行集合を先に作る。
|
|
183
|
+
const ignored = new Set();
|
|
184
|
+
lines.forEach((line, i) => {
|
|
185
|
+
if (/<!--\s*carry-ignore\s*-->/.test(line)) ignored.add(i + 1);
|
|
186
|
+
if (/<!--\s*carry-ignore-next\s*-->/.test(line)) ignored.add(i + 2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let inFence = false;
|
|
190
|
+
lines.forEach((line, i) => {
|
|
191
|
+
const ln = i + 1;
|
|
192
|
+
|
|
193
|
+
// フェンス(``` / ~~~)開閉。マーカ行自体は走査しない。
|
|
194
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
195
|
+
inFence = !inFence;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (ignored.has(ln)) return;
|
|
199
|
+
|
|
200
|
+
// 1) マシン固有の絶対パス(ERROR)
|
|
201
|
+
for (const re of [DRIVE_ABS, UNIX_HOME_ABS, ENV_HOME]) {
|
|
202
|
+
re.lastIndex = 0;
|
|
203
|
+
for (const m of line.matchAll(re)) {
|
|
204
|
+
push(findings, ln, 'abs-path', 'error', `マシン固有の絶対パス \`${m[0].trim()}\` — 他人の環境で解決しません(相対パスや {baseDir} に)`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ~/ ホーム相対(WARN)
|
|
208
|
+
for (const m of line.matchAll(TILDE_HOME)) {
|
|
209
|
+
push(findings, ln, 'home-path', 'warn', `ホーム相対パス \`${m[0].trim()}\` — ユーザーのホーム前提です`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2) 未解決プレースホルダ(ERROR)
|
|
213
|
+
for (const re of PLACEHOLDER_ERROR) {
|
|
214
|
+
const m = line.match(re);
|
|
215
|
+
if (m) {
|
|
216
|
+
push(findings, ln, 'placeholder', 'error', `未解決のプレースホルダ \`${m[0].trim()}\` が配布物に残っています`);
|
|
217
|
+
break; // 1行1件に留める
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 3) provider CLI 呼び出し(宣言なし→ERROR)
|
|
222
|
+
for (const cli of cliInvocations(line, inFence, rules)) {
|
|
223
|
+
if (allow.has(cli) || declared.has(cli)) continue;
|
|
224
|
+
push(findings, ln, 'undeclared-cli', 'error', `\`${cli}\` を呼んでいますが、インストール手順も requires 宣言もありません(他環境で黙って失敗します)`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 4) プロバイダ固有 env の生参照(WARN)
|
|
228
|
+
{
|
|
229
|
+
const m = line.match(envRe);
|
|
230
|
+
if (m) push(findings, ln, 'provider-env', 'warn', `\`${m[0]}\` を前提にしています — .env.example に記載し未設定時の案内を`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 5) TODO/FIXME 残り(WARN)
|
|
234
|
+
if (TODO_MARK.test(line)) {
|
|
235
|
+
push(findings, ln, 'todo', 'warn', '配布物に TODO/FIXME マーカーが残っています');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 6) モデルID直書き(opt-in)
|
|
239
|
+
if (modelIds) {
|
|
240
|
+
modelRe.lastIndex = 0;
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
for (const m of line.matchAll(modelRe)) {
|
|
243
|
+
if (seen.has(m[0])) continue;
|
|
244
|
+
seen.add(m[0]);
|
|
245
|
+
push(findings, ln, 'model-id', 'warn', `モデルID \`${m[0]}\` を直書きしています — 固定は意図的なら --allow / carry-ignore を`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return findings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------- ファイル探索 ----------------
|
|
254
|
+
|
|
255
|
+
const DEFAULT_NAMES = new Set(['SKILL.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
|
|
256
|
+
const DEFAULT_DIRS = [['.claude', 'commands'], ['.codex', 'prompts'], ['.cursor', 'rules'], ['.github', 'prompts']];
|
|
257
|
+
|
|
258
|
+
function isTargetName(name) {
|
|
259
|
+
return DEFAULT_NAMES.has(name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** paths(ファイル/ディレクトリ)から対象ファイルを集める。 */
|
|
263
|
+
export function findFiles(paths) {
|
|
264
|
+
const out = [];
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const add = (p) => {
|
|
267
|
+
const r = p.replace(/\\/g, '/');
|
|
268
|
+
if (!seen.has(r)) { seen.add(r); out.push(r); }
|
|
269
|
+
};
|
|
270
|
+
const inCommandDir = (dir) => DEFAULT_DIRS.some((seg) => dir.replace(/\\/g, '/').includes(seg.join('/')));
|
|
271
|
+
const walk = (dir, depth) => {
|
|
272
|
+
if (depth > 8) return;
|
|
273
|
+
let entries;
|
|
274
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
275
|
+
for (const e of entries) {
|
|
276
|
+
if (e.isDirectory()) {
|
|
277
|
+
if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
|
|
278
|
+
walk(join(dir, e.name), depth + 1);
|
|
279
|
+
} else if (isTargetName(e.name) || (/\.(md|txt|mdc)$/i.test(e.name) && inCommandDir(dir))) {
|
|
280
|
+
add(join(dir, e.name));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
for (const p of paths) {
|
|
285
|
+
let st;
|
|
286
|
+
try { st = statSync(p); } catch { continue; }
|
|
287
|
+
if (st.isDirectory()) walk(p, 0);
|
|
288
|
+
else add(p);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------- CLI ----------------
|
|
294
|
+
|
|
295
|
+
/** argv から オプションを取り出し、残りをパスとして返す。 */
|
|
296
|
+
export function parseArgs(argv) {
|
|
297
|
+
const paths = [];
|
|
298
|
+
const allow = new Set();
|
|
299
|
+
let strict = process.env.CARRYLINT_STRICT === '1';
|
|
300
|
+
let modelIds = process.env.CARRYLINT_MODEL_IDS === '1';
|
|
301
|
+
let asJson = process.env.CARRYLINT_FORMAT === 'json';
|
|
302
|
+
const addAllow = (s) => (s || '').split(',').forEach((n) => n.trim() && allow.add(n.trim().toLowerCase()));
|
|
303
|
+
for (let i = 0; i < argv.length; i++) {
|
|
304
|
+
const a = argv[i];
|
|
305
|
+
if (a === '--') continue;
|
|
306
|
+
else if (a === '--strict') strict = true;
|
|
307
|
+
else if (a === '--model-ids') modelIds = true;
|
|
308
|
+
else if (a === '--json') asJson = true;
|
|
309
|
+
else if (a === '--format') { if (argv[i + 1] === 'json') asJson = true; i++; }
|
|
310
|
+
else if (a.startsWith('--format=')) { if (a.slice(9) === 'json') asJson = true; }
|
|
311
|
+
else if (a === '--allow') addAllow(argv[++i]);
|
|
312
|
+
else if (a.startsWith('--allow=')) addAllow(a.slice(8));
|
|
313
|
+
else paths.push(a);
|
|
314
|
+
}
|
|
315
|
+
return { paths, allow, strict, modelIds, asJson };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** rules.json をモジュール同梱位置から読む(無ければ既定)。 */
|
|
319
|
+
function loadRules() {
|
|
320
|
+
try {
|
|
321
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
322
|
+
const raw = JSON.parse(readFileSync(join(here, '..', 'rules.json'), 'utf8'));
|
|
323
|
+
return { ...DEFAULT_RULES, ...raw };
|
|
324
|
+
} catch {
|
|
325
|
+
return DEFAULT_RULES;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function defaultTargets() {
|
|
330
|
+
return ['.'];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** results([{file, findings}])を機械可読な JSON 形へ(純粋・テスト可能)。 */
|
|
334
|
+
export function toJson(results) {
|
|
335
|
+
const findings = results.flatMap(({ file, findings }) =>
|
|
336
|
+
findings.map((f) => ({ file, line: f.ln || 1, kind: f.kind, severity: f.severity, message: f.msg })),
|
|
337
|
+
);
|
|
338
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
339
|
+
return { ok: errors === 0, count: findings.length, errors, warnings: findings.length - errors, findings };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function main(argv) {
|
|
343
|
+
const inActions = process.env.GITHUB_ACTIONS === 'true';
|
|
344
|
+
const { paths, allow, strict, modelIds, asJson } = parseArgs(argv);
|
|
345
|
+
const rules = loadRules();
|
|
346
|
+
const files = findFiles(paths.length ? paths : defaultTargets());
|
|
347
|
+
|
|
348
|
+
if (files.length === 0) {
|
|
349
|
+
if (asJson) console.log(JSON.stringify({ ok: true, count: 0, errors: 0, warnings: 0, findings: [] }, null, 2));
|
|
350
|
+
else console.log('carrylint: 対象ファイルなし(SKILL.md / AGENTS.md / CLAUDE.md / GEMINI.md / .claude/commands 等)。スキップ。');
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const results = [];
|
|
355
|
+
for (const file of files) {
|
|
356
|
+
let text;
|
|
357
|
+
try { text = readFileSync(file, 'utf8'); }
|
|
358
|
+
catch {
|
|
359
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: `cannot read ${file}` }, null, 2)); return 2; }
|
|
360
|
+
console.error(`carrylint: ${file} を読めません`);
|
|
361
|
+
return 2;
|
|
362
|
+
}
|
|
363
|
+
results.push({ file, findings: scan(text, { rules, allow, modelIds }) });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (asJson) {
|
|
367
|
+
const out = toJson(results);
|
|
368
|
+
console.log(JSON.stringify(out, null, 2));
|
|
369
|
+
return out.errors > 0 || (strict && out.count > 0) ? 1 : 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let errors = 0;
|
|
373
|
+
let warnings = 0;
|
|
374
|
+
for (const { file, findings } of results) {
|
|
375
|
+
if (findings.length === 0) { console.log(`✓ ${file}`); continue; }
|
|
376
|
+
const errs = findings.filter((f) => f.severity === 'error').length;
|
|
377
|
+
console.error(`✗ ${file} — error ${errs} / warn ${findings.length - errs}`);
|
|
378
|
+
for (const f of findings) {
|
|
379
|
+
const ln = f.ln || 1;
|
|
380
|
+
const tag = f.severity === 'error' ? 'ERROR' : 'warn ';
|
|
381
|
+
console.error(` ${f.severity === 'error' ? '✗' : '•'} ${file}:${ln}\t[${f.kind}] ${f.msg}`);
|
|
382
|
+
if (inActions) {
|
|
383
|
+
const level = f.severity === 'error' ? 'error' : 'warning';
|
|
384
|
+
console.log(`::${level} file=${file},line=${ln}::[${f.kind}] ${f.msg.replace(/\r?\n/g, ' ')}`);
|
|
385
|
+
}
|
|
386
|
+
if (f.severity === 'error') errors++; else warnings++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (errors > 0 || (strict && warnings > 0)) {
|
|
391
|
+
console.error(`\ncarrylint: error ${errors} / warn ${warnings}${strict ? '(--strict: warn も失敗)' : ''}`);
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
394
|
+
if (warnings > 0) {
|
|
395
|
+
console.error(`\ncarrylint: error 0 / warn ${warnings}(warn は exit 0。--strict で失敗させられます)`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
console.log(`carrylint: ${files.length} ファイル、すべて可搬OK`);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 直接実行された時だけ CLI として動く(import 時は関数だけ公開)。
|
|
403
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
404
|
+
process.exit(main(process.argv.slice(2)));
|
|
405
|
+
}
|