@erclx/aitk 0.8.0 → 0.10.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/README.md +17 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/REQUIREMENT.md +39 -0
- package/claude/skills/claude-address-review/SKILL.md +3 -4
- package/claude/skills/claude-autoship/REQUIREMENT.md +41 -0
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-diagram/SKILL.md +70 -40
- package/claude/skills/claude-docs/REQUIREMENT.md +39 -0
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +1 -1
- package/claude/skills/claude-pr-review/REQUIREMENT.md +38 -0
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-review/REQUIREMENT.md +39 -0
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/claude-ui-test/SKILL.md +1 -1
- package/claude/skills/claude-ux-audit/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +2 -2
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -6
- package/claude/skills/git-pr/SKILL.md +1 -6
- package/claude/skills/git-split/REQUIREMENT.md +2 -1
- package/claude/skills/git-split/SKILL.md +2 -0
- package/docs/agents.md +26 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +2 -2
- package/governance/rules/claude/560-diagrams.md +10 -2
- package/governance/rules/claude/570-skill.md +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/claude/seeds.ts +7 -1
- package/src/cli.ts +4 -0
- package/src/commands/comments.ts +234 -0
- package/src/comments/scan.ts +338 -0
- package/src/comments/trend.ts +207 -0
- package/src/comments/vocabulary.ts +85 -0
- package/src/git-env.ts +36 -0
- package/src/git-ignore.ts +46 -0
- package/src/indexes/walk.ts +1 -29
- package/standards/diagrams.md +65 -15
- package/standards/index.md +2 -2
- package/standards/prose.md +14 -1
- package/standards/readme.md +15 -1
- package/standards/skill.md +14 -0
- package/tooling/claude/reference.md +5 -0
- package/tooling/claude/seeds/.claude/diagrams/index.md +8 -0
|
@@ -34,10 +34,10 @@ The directory test separates the defect from a genuinely bare repository, which
|
|
|
34
34
|
|
|
35
35
|
Try each source in order. Stop at the first match.
|
|
36
36
|
|
|
37
|
-
1. **Plan matched to current branch.**
|
|
37
|
+
1. **Plan matched to current branch.** Derive `<slug>` per Deriving the branch slug in `.claude/standards/skill.md`, or `${CLAUDE_SKILL_DIR}/../../standards/skill.md` when the project does not have it. An empty result falls through to the next source here rather than becoming `latest`, since the slug is one candidate among several. If `<main-root>/.claude/plans/feature-<slug>.md` exists, use `<slug>`.
|
|
38
38
|
2. **Single plan file.** List `<main-root>/.claude/plans/feature-*.md`. If exactly one match, derive `<slug>` from the filename.
|
|
39
39
|
3. **Multiple plan files, no branch match.** Ask the user which plan. Show the candidate slugs as a numbered list. Do not pick.
|
|
40
|
-
4. **Current branch.** When no plan exists, use the
|
|
40
|
+
4. **Current branch.** When no plan exists, use the `<slug>` from step 1 if it is not `main` or `master`.
|
|
41
41
|
4b. **Session context.** When on `main` or `master` with no matching plan, read the current conversation to infer a kebab slug from the topic being discussed. Propose it: `Infer: <slug>. Confirm or rename?` Do not enter the worktree until the user confirms or provides a corrected name.
|
|
42
42
|
5. **Ask.** None of the above applies. Ask the user for a name. Do not invent one.
|
|
43
43
|
|
|
@@ -24,7 +24,7 @@ its own reply, so skip the comment in step 6. The push and body sync still run.
|
|
|
24
24
|
3. Invoke `aitk:git-commit` to generate one conventional commit from the staged diff
|
|
25
25
|
4. Run `git push` to the tracking branch
|
|
26
26
|
5. Check for existing review comments: `gh api 'repos/{owner}/{repo}/pulls/<number>/comments' --jq 'length'`, resolving `<number>` from `gh pr view --json number`.
|
|
27
|
-
6. When invoked with `reply-owned`, skip this step's comment: the caller posts the reply. Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first
|
|
27
|
+
6. When invoked with `reply-owned`, skip this step's comment: the caller posts the reply. Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first running the banned-character scan in `.claude/standards/prose.md` against it, or `${CLAUDE_SKILL_DIR}/../../standards/prose.md` when the project does not have it, since the hook does not see an inline comment body. If it is zero, run `gh pr view --json url,title,body` and update the body with `gh pr edit --body` when the new commit changes scope, and the title with `gh pr edit --title` when the scope shifted enough to make it inaccurate.
|
|
28
28
|
|
|
29
29
|
## After completion
|
|
30
30
|
|
|
@@ -40,12 +40,7 @@ After outputting the preview, execute the final command immediately. Claude Code
|
|
|
40
40
|
|
|
41
41
|
### Pre-publish scan
|
|
42
42
|
|
|
43
|
-
Before running the final command, scan the title and body
|
|
44
|
-
|
|
45
|
-
- `—` (em dash): split into two sentences or use a comma
|
|
46
|
-
- `;` (semicolon): split into two sentences
|
|
47
|
-
|
|
48
|
-
These bans come from `.claude/standards/prose.md`. Reading it is not enough. The scan is an explicit step.
|
|
43
|
+
Before running the final command, run the banned-character scan in `.claude/standards/prose.md` against the title and body. The title and body go straight to the remote with nothing checking them on the way, so this scan is the only gate. It applies on top of the banned phrases in `${CLAUDE_SKILL_DIR}/references/issue.md`.
|
|
49
44
|
|
|
50
45
|
### Final command
|
|
51
46
|
|
|
@@ -69,12 +69,7 @@ Leave a box unchecked only for the human-only cases the reference defines, and n
|
|
|
69
69
|
|
|
70
70
|
### Pre-publish scan
|
|
71
71
|
|
|
72
|
-
Before running the final command, scan the PR title and body
|
|
73
|
-
|
|
74
|
-
- `—` (em dash): split into two sentences or use a comma
|
|
75
|
-
- `;` (semicolon): split into two sentences
|
|
76
|
-
|
|
77
|
-
These bans come from `.claude/standards/prose.md` and apply to PR text on top of `${CLAUDE_SKILL_DIR}/references/pr.md`. Reading `prose.md` is not enough. The scan is an explicit step.
|
|
72
|
+
Before running the final command, run the banned-character scan in `.claude/standards/prose.md` against the PR title and body. The title and body go straight to the remote with nothing checking them on the way, so this scan is the only gate. It applies on top of the banned phrases in `${CLAUDE_SKILL_DIR}/references/pr.md`.
|
|
78
73
|
|
|
79
74
|
### Final command
|
|
80
75
|
|
|
@@ -7,7 +7,7 @@ description: What branch splitting is for, the gaps it closes, and how it differ
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, a branch carrying unrelated commits ships as one pull request, which a reviewer reads by skimming because no single concern holds it together. Split by hand, commits get cherry-picked in an order that leaves a branch missing something it depends on. Merge order is the sharpest failure: merging the wrong branch first lands every other branch's content on main and leaves the remaining pull requests empty after rebase, with no signal that anything went wrong.
|
|
10
|
+
Without this skill, a branch carrying unrelated commits ships as one pull request, which a reviewer reads by skimming because no single concern holds it together. Split by hand, commits get cherry-picked in an order that leaves a branch missing something it depends on. Merge order is the sharpest failure: merging the wrong branch first lands every other branch's content on main and leaves the remaining pull requests empty after rebase, with no signal that anything went wrong. Every body it writes stages under `.claude/.tmp/` and leaves through `gh`, neither of which the audit hook watches, so a banned character survives into a published pull request and has to be edited on the remote.
|
|
11
11
|
|
|
12
12
|
## Must
|
|
13
13
|
|
|
@@ -17,6 +17,7 @@ Without this skill, a branch carrying unrelated commits ships as one pull reques
|
|
|
17
17
|
- Account for every commit ahead of main in the preview
|
|
18
18
|
- State the merge order and the reason behind it, since the order is not recoverable from reading the pull requests afterward
|
|
19
19
|
- Carry the restack loop for stacked mode, where each branch is rebased onto main once the one below it merges
|
|
20
|
+
- Scan every generated title and body for banned characters as an explicit step before opening a pull request
|
|
20
21
|
|
|
21
22
|
## Must not
|
|
22
23
|
|
|
@@ -69,6 +69,8 @@ If Mode is Independent, append this line to the preview:
|
|
|
69
69
|
|
|
70
70
|
After outputting the preview, execute the final commands immediately. Claude Code's tool permission dialog is the confirmation gate. Do not wait for user input.
|
|
71
71
|
|
|
72
|
+
Before running them, run the banned-character scan in `.claude/standards/prose.md` against every title and body, or `${CLAUDE_SKILL_DIR}/../../standards/prose.md` when the project does not have it. Each body stages under `.claude/.tmp/` and leaves through `gh`, so the hook sees neither and this scan is the only gate.
|
|
73
|
+
|
|
72
74
|
### Final commands
|
|
73
75
|
|
|
74
76
|
For independent mode, base every branch on `main`:
|
package/docs/agents.md
CHANGED
|
@@ -79,6 +79,7 @@ Full help: `aitk <command> --help`.
|
|
|
79
79
|
| `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github` |
|
|
80
80
|
| `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
81
81
|
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
|
|
82
|
+
| `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
82
83
|
|
|
83
84
|
### Domain commands
|
|
84
85
|
|
|
@@ -95,6 +96,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
95
96
|
| `design` | `render` |
|
|
96
97
|
| `slides` | `render`, `list` |
|
|
97
98
|
| `tasks` | `archive` |
|
|
99
|
+
| `comments` | `scan` |
|
|
98
100
|
|
|
99
101
|
Common patterns:
|
|
100
102
|
|
|
@@ -285,6 +287,30 @@ aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .re
|
|
|
285
287
|
|
|
286
288
|
For the board format, the `Pull request:` line, and the archive rules, see `.claude/standards/tasks.md`.
|
|
287
289
|
|
|
290
|
+
## Comments
|
|
291
|
+
|
|
292
|
+
`aitk comments scan [path]` reports comment density for a tree, split by language and by comment kind. It is the only command that parses the target's own source, so its scope stays deliberately narrow: TypeScript and bash, line-oriented, no AST.
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
aitk comments scan
|
|
296
|
+
aitk comments scan src --json
|
|
297
|
+
aitk comments scan --since v0.5.0
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
| Option | Behavior |
|
|
301
|
+
| -------------------- | --------------------------------------------------------------- |
|
|
302
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
303
|
+
| `--since <rev>` | Report the trend from this revision instead of a snapshot alone |
|
|
304
|
+
| `--languages <list>` | Comma-separated subset of `ts,sh` (default: both) |
|
|
305
|
+
|
|
306
|
+
A line counts as a comment when its first non-whitespace token opens one, which is what keeps a URL in a string literal from reading as a `//` comment without a parser. Density is `commentLines / lines`, reported and never graded. The command produces the number and a rule produces the judgment.
|
|
307
|
+
|
|
308
|
+
Two exclusions are structural rather than tuning. Heredoc bodies are dropped from both the numerator and the denominator, because a scenario script carrying markdown inside one has `#` opening a heading rather than a comment, which inflated a measured 112 comment lines to 427. Fixture trees are pruned by path segment for the same reason. The line-1 shebang is not a comment, since every script has one and counting it puts a floor under density that reports the file count.
|
|
309
|
+
|
|
310
|
+
`--since` recomputes each point from git via `ls-tree` and `cat-file --batch`, checking nothing out. No ledger is written or read. Six points spread evenly across the window by default, and the boundary revision is always included so the series keeps the reading it is measured against. This works only because density is a pure function of a tree. Which author or session wrote a comment is not recoverable from git and does not belong here.
|
|
311
|
+
|
|
312
|
+
The degradation sweep reads its vocabulary from whichever rule publishes a `## Degradation vocabulary` heading, preferring `.claude/rules/` over `governance/rules/`, so one definition serves the toolkit and every target. Discovery anchors on the heading rather than a filename, because a renumbered rule would otherwise empty the vocabulary while the sweep still reported clean. With no such rule the sweep reports **skipped** rather than zero hits, since finding nothing and looking for nothing mean opposite things.
|
|
313
|
+
|
|
288
314
|
## Runtime catalogs
|
|
289
315
|
|
|
290
316
|
Use these to discover what's available instead of hardcoding names.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -131,7 +131,7 @@ Before the first feature session on a UI-heavy project, pick a design tier. The
|
|
|
131
131
|
| `aitk:claude-feature` | Before implementation, scan for conflicts and ambiguities |
|
|
132
132
|
| `aitk:claude-roadmap` | Sequence MVP scope into ordered versions in `.claude/ROADMAP.md` |
|
|
133
133
|
| `aitk:claude-orchestrate` | Assert the orchestrator role, refill the ready queue, and dispatch the roadmap, feature, and review skills |
|
|
134
|
-
| `aitk:claude-diagram` | Draft `.claude/
|
|
134
|
+
| `aitk:claude-diagram` | Draft per-kind mermaid entries under `.claude/diagrams/` from architecture and code, then verify each rendered image |
|
|
135
135
|
| `aitk:claude-design-propose` | Day one on a UI project, draft `.claude/DESIGN.md` from requirements. Use `claude-design-extract` if UI already exists |
|
|
136
136
|
| `aitk:claude-review` | In a fresh session, review all changes since main |
|
|
137
137
|
| `aitk:claude-pr-review` | Review an open PR from an independent session, then close the review out against the commits added since |
|
package/docs/target-projects.md
CHANGED
|
@@ -57,7 +57,7 @@ Scaffold installs tooling and seeds. It does not fill the planning docs or the d
|
|
|
57
57
|
|
|
58
58
|
1. Fill `.claude/REQUIREMENTS.md` and `.claude/ARCHITECTURE.md`. The seed provides the files, the scope and decisions are yours to write.
|
|
59
59
|
2. For a UI project, invoke `aitk:claude-design-propose` to draft `.claude/DESIGN.md` from the requirements and a `## Personality` section. Skip for non-UI projects.
|
|
60
|
-
3. Optionally invoke `aitk:claude-diagram` to draft `.claude/
|
|
60
|
+
3. Optionally invoke `aitk:claude-diagram` to draft entries under `.claude/diagrams/` from the architecture and the requirements. One file per diagram kind, so a later refresh of one kind leaves the others untouched. It renders each diagram it writes to verify the layout, which downloads the Mermaid CLI on first use and takes about 15 seconds. A machine without a renderer still gets the diagrams and is told which check was skipped.
|
|
61
61
|
4. Start the feature loop. See [AI workflow](ai-workflow.md) for the per-feature sequence.
|
|
62
62
|
|
|
63
63
|
### Stack decision
|
|
@@ -77,7 +77,7 @@ Run `aitk tooling list --json` and `aitk gov list --json` to see the current cat
|
|
|
77
77
|
`governance`, `standards`, and `wiki` are skippable:
|
|
78
78
|
|
|
79
79
|
- `--skip governance`: leave `.claude/rules/` empty. Standards still install, so `.claude/standards/prose.md` lands with nothing pointing at it and no coding standard loads on a file match. The preview names any `--add` rules the skip drops, and the run prints the `aitk gov install <stack> <path>` command to add rules afterward, carrying those extras so one paste restores what the skip declined.
|
|
80
|
-
- `--skip standards`: leave standards out. The governance rules still reference `.claude/standards/`, so their authority lines resolve to nothing. Toolkit skills are unaffected, since each falls back to the copy in its own plugin root.
|
|
80
|
+
- `--skip standards`: leave standards out. The governance rules still reference `.claude/standards/`, so their authority lines resolve to nothing. Toolkit skills are unaffected, since each falls back to the copy in its own plugin root. That fallback now carries runtime behavior rather than reference prose alone, because the banned-character scan and the branch-slug transform are defined in the standards and cited by the skills that run them.
|
|
81
81
|
- `--skip wiki`: skip the `.claude/wiki/` scaffold. A target that already carries a root `wiki/` keeps it, since the verb reports that folder rather than migrating it.
|
|
82
82
|
|
|
83
83
|
## Add a domain later
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Route .claude/
|
|
2
|
+
description: Route .claude/diagrams edits to the diagrams standard for Mermaid conventions
|
|
3
3
|
paths:
|
|
4
|
+
- '.claude/diagrams/**'
|
|
4
5
|
- '.claude/DIAGRAMS.md'
|
|
5
6
|
---
|
|
6
7
|
|
|
@@ -8,4 +9,11 @@ paths:
|
|
|
8
9
|
|
|
9
10
|
## Authority
|
|
10
11
|
|
|
11
|
-
- Follow `.claude/standards/diagrams.md` for Mermaid diagram layout,
|
|
12
|
+
- Follow `.claude/standards/diagrams.md` for Mermaid diagram layout, budgets, accessibility, verification, and explanation prose. It is the single source.
|
|
13
|
+
- A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.
|
|
14
|
+
|
|
15
|
+
## Scope
|
|
16
|
+
|
|
17
|
+
- Write a new diagram to `.claude/diagrams/<kind>.md`, never to `.claude/DIAGRAMS.md`
|
|
18
|
+
- Convert a `.claude/DIAGRAMS.md` left by an older install into per-kind entries before editing it
|
|
19
|
+
- Refresh only the entries whose source signal moved. Do not rewrite the folder wholesale.
|
|
@@ -15,4 +15,4 @@ paths:
|
|
|
15
15
|
|
|
16
16
|
## Authority
|
|
17
17
|
|
|
18
|
-
- Follow `.claude/standards/skill.md` for skill structure, frontmatter fields, and
|
|
18
|
+
- Follow `.claude/standards/skill.md` for skill structure, frontmatter fields, invocation rules, and the shape a `REQUIREMENT.md` states. It is the single source.
|
package/package.json
CHANGED
|
@@ -75,7 +75,7 @@ log_step "Assert scaffold"
|
|
|
75
75
|
# no assertion can truncate silently: init still exits 0 because run_domain
|
|
76
76
|
# catches a failed domain, and the gate stays green while the target is
|
|
77
77
|
# missing everything that domain provides.
|
|
78
|
-
for path in "CLAUDE.md" ".claude/snippets/compact-summary.md" ".claude/standards/prose.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" \
|
|
78
|
+
for path in "CLAUDE.md" ".claude/snippets/compact-summary.md" ".claude/standards/prose.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".claude/diagrams/index.md" \
|
|
79
79
|
".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
|
|
80
80
|
".claude/rules/core/000-constitution.md"; do
|
|
81
81
|
if [ ! -e "$TARGET_DIR/$path" ]; then
|
package/src/claude/seeds.ts
CHANGED
|
@@ -7,7 +7,13 @@ const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
|
7
7
|
const CLAUDE_DIR = '.claude'
|
|
8
8
|
const CLAUDE_MD = 'CLAUDE.md'
|
|
9
9
|
const HOOKS = 'hooks'
|
|
10
|
-
const SUBDIRS: readonly string[] = [
|
|
10
|
+
const SUBDIRS: readonly string[] = [
|
|
11
|
+
HOOKS,
|
|
12
|
+
'context',
|
|
13
|
+
'diagrams',
|
|
14
|
+
'tasks',
|
|
15
|
+
'wireframes',
|
|
16
|
+
]
|
|
11
17
|
|
|
12
18
|
export type SeedScope = 'claude' | 'root'
|
|
13
19
|
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { register as slides } from '@/commands/slides'
|
|
|
17
17
|
import { register as feedback } from '@/commands/feedback'
|
|
18
18
|
import { register as transcripts } from '@/commands/transcripts'
|
|
19
19
|
import { register as tasks } from '@/commands/tasks'
|
|
20
|
+
import { register as comments } from '@/commands/comments'
|
|
20
21
|
|
|
21
22
|
const GREY = '\x1b[0;90m'
|
|
22
23
|
const WHITE = '\x1b[1;37m'
|
|
@@ -44,6 +45,7 @@ function showHelp(): void {
|
|
|
44
45
|
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/${NC}`,
|
|
45
46
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
46
47
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
48
|
+
`${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
|
|
47
49
|
`${GREY}│${NC}`,
|
|
48
50
|
`${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
|
|
49
51
|
`${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
|
|
@@ -73,6 +75,7 @@ function showHelp(): void {
|
|
|
73
75
|
`${GREY}│${NC} pbpaste | aitk feedback`,
|
|
74
76
|
`${GREY}│${NC} aitk transcripts https://youtu.be/VIDEO_ID`,
|
|
75
77
|
`${GREY}│${NC} aitk tasks archive --pull-request 673 --json`,
|
|
78
|
+
`${GREY}│${NC} aitk comments scan src --json`,
|
|
76
79
|
`${GREY}└${NC}`,
|
|
77
80
|
]
|
|
78
81
|
console.log(lines.join('\n'))
|
|
@@ -107,5 +110,6 @@ slides(program)
|
|
|
107
110
|
feedback(program)
|
|
108
111
|
transcripts(program)
|
|
109
112
|
tasks(program)
|
|
113
|
+
comments(program)
|
|
110
114
|
|
|
111
115
|
program.parse()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
density,
|
|
5
|
+
type Language,
|
|
6
|
+
LANGUAGES,
|
|
7
|
+
type LanguageCount,
|
|
8
|
+
scanTree,
|
|
9
|
+
} from '@/comments/scan'
|
|
10
|
+
import { type TrendPoint, trend } from '@/comments/trend'
|
|
11
|
+
import { loadVocabulary, type Vocabulary } from '@/comments/vocabulary'
|
|
12
|
+
import { intro, logInfo, logStep, logWarn, outro, pipeOutput } from '@/ui'
|
|
13
|
+
|
|
14
|
+
interface ScanCommandOptions {
|
|
15
|
+
readonly json?: boolean
|
|
16
|
+
readonly since?: string
|
|
17
|
+
readonly languages?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LABELS: Record<Language, string> = {
|
|
21
|
+
ts: 'TypeScript',
|
|
22
|
+
sh: 'Bash',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function register(program: Command): void {
|
|
26
|
+
const comments = program
|
|
27
|
+
.command('comments')
|
|
28
|
+
.description('Measure comment density and trend across a source tree')
|
|
29
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
30
|
+
|
|
31
|
+
comments
|
|
32
|
+
.command('scan')
|
|
33
|
+
.description('Report comment density by language and by comment kind')
|
|
34
|
+
.argument('[path]', 'Tree to scan, defaulting to the current directory')
|
|
35
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
36
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
37
|
+
.option('--since <rev>', 'Report the trend from this revision instead')
|
|
38
|
+
.option('--languages <list>', 'Comma-separated subset of ts,sh')
|
|
39
|
+
.addHelpText(
|
|
40
|
+
'after',
|
|
41
|
+
[
|
|
42
|
+
'',
|
|
43
|
+
'Exit codes:',
|
|
44
|
+
' 0 the scan completed',
|
|
45
|
+
' 1 refused, with the reason on stderr',
|
|
46
|
+
'',
|
|
47
|
+
'Examples:',
|
|
48
|
+
' aitk comments scan',
|
|
49
|
+
' aitk comments scan src --json',
|
|
50
|
+
' aitk comments scan --since v0.5.0',
|
|
51
|
+
'',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
)
|
|
54
|
+
.action(async (path: string | undefined, opts: ScanCommandOptions) => {
|
|
55
|
+
process.exitCode = await runScan(path, opts)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseLanguages(list: string | undefined): Language[] | string {
|
|
60
|
+
if (!list) return [...LANGUAGES]
|
|
61
|
+
|
|
62
|
+
const requested = list
|
|
63
|
+
.split(',')
|
|
64
|
+
.map((entry) => entry.trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
|
|
67
|
+
const unknown = requested.filter(
|
|
68
|
+
(entry) => !LANGUAGES.includes(entry as Language),
|
|
69
|
+
)
|
|
70
|
+
if (unknown.length > 0) {
|
|
71
|
+
return `Unknown language: ${unknown.join(', ')}. Known: ${LANGUAGES.join(', ')}.`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return requested as Language[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function runScan(
|
|
78
|
+
path: string | undefined,
|
|
79
|
+
opts: ScanCommandOptions,
|
|
80
|
+
): Promise<number> {
|
|
81
|
+
const root = resolve(path ?? process.cwd())
|
|
82
|
+
const emitJson = opts.json ?? false
|
|
83
|
+
const languages = parseLanguages(opts.languages)
|
|
84
|
+
|
|
85
|
+
intro('aitk comments scan')
|
|
86
|
+
|
|
87
|
+
if (typeof languages === 'string') {
|
|
88
|
+
logStep('Refused')
|
|
89
|
+
logWarn(languages)
|
|
90
|
+
outro()
|
|
91
|
+
return 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const vocabulary = await loadVocabulary(root)
|
|
95
|
+
const scanOptions = {
|
|
96
|
+
languages,
|
|
97
|
+
vocabulary: vocabulary.kind === 'loaded' ? vocabulary.terms : [],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const snapshot = await scanTree(root, scanOptions)
|
|
101
|
+
reportSnapshot(snapshot)
|
|
102
|
+
|
|
103
|
+
const points = opts.since
|
|
104
|
+
? await trend(root, { ...scanOptions, since: opts.since })
|
|
105
|
+
: []
|
|
106
|
+
|
|
107
|
+
if (opts.since) reportTrend(points, languages)
|
|
108
|
+
|
|
109
|
+
reportVocabulary(vocabulary, snapshot)
|
|
110
|
+
outro()
|
|
111
|
+
|
|
112
|
+
if (emitJson) {
|
|
113
|
+
process.stdout.write(
|
|
114
|
+
`${JSON.stringify({
|
|
115
|
+
path: root,
|
|
116
|
+
vocabulary:
|
|
117
|
+
vocabulary.kind === 'loaded'
|
|
118
|
+
? { source: vocabulary.source, terms: vocabulary.terms }
|
|
119
|
+
: null,
|
|
120
|
+
snapshot: snapshot.map(record),
|
|
121
|
+
trend: points.map((point) => ({
|
|
122
|
+
rev: point.rev,
|
|
123
|
+
date: point.date,
|
|
124
|
+
languages: point.languages.map(record),
|
|
125
|
+
})),
|
|
126
|
+
})}\n`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function record(count: LanguageCount): Record<string, unknown> {
|
|
134
|
+
return {
|
|
135
|
+
language: count.language,
|
|
136
|
+
files: count.files,
|
|
137
|
+
lines: count.lines,
|
|
138
|
+
commentLines: count.commentLines,
|
|
139
|
+
density: Number(density(count).toFixed(4)),
|
|
140
|
+
docBlocks: count.docBlocks,
|
|
141
|
+
inlineComments: count.inlineComments,
|
|
142
|
+
degradationHits: count.degradationHits,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function percent(count: LanguageCount): string {
|
|
147
|
+
return `${(density(count) * 100).toFixed(1)}%`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function reportSnapshot(snapshot: readonly LanguageCount[]): void {
|
|
151
|
+
logStep('Snapshot')
|
|
152
|
+
|
|
153
|
+
for (const count of snapshot) {
|
|
154
|
+
if (count.files === 0) {
|
|
155
|
+
logInfo(`${LABELS[count.language]}: no files`)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logInfo(
|
|
160
|
+
`${LABELS[count.language]}: ${count.commentLines} comment lines in ${count.lines} (${percent(count)}), ${count.files} files`,
|
|
161
|
+
)
|
|
162
|
+
logInfo(
|
|
163
|
+
` ${count.docBlocks} doc blocks, ${count.inlineComments} inline comments`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function reportTrend(
|
|
169
|
+
points: readonly TrendPoint[],
|
|
170
|
+
languages: readonly Language[],
|
|
171
|
+
): void {
|
|
172
|
+
logStep('Trend')
|
|
173
|
+
|
|
174
|
+
if (points.length === 0) {
|
|
175
|
+
logWarn('No commits in range. Check the revision passed to --since.')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const language of languages) {
|
|
180
|
+
const rows = points
|
|
181
|
+
.map((point) => {
|
|
182
|
+
const count = point.languages.find(
|
|
183
|
+
(entry) => entry.language === language,
|
|
184
|
+
)
|
|
185
|
+
if (!count) return undefined
|
|
186
|
+
return `${point.rev.slice(0, 8)} ${point.date} ${String(count.lines).padStart(6)} lines ${String(count.commentLines).padStart(5)} comments ${percent(count).padStart(6)}`
|
|
187
|
+
})
|
|
188
|
+
.filter((row): row is string => row !== undefined)
|
|
189
|
+
|
|
190
|
+
// A language the tree does not carry would otherwise print a column of
|
|
191
|
+
// zeros, which reads as a measured decline rather than an absence.
|
|
192
|
+
const measured = points.some((point) =>
|
|
193
|
+
point.languages.some(
|
|
194
|
+
(entry) => entry.language === language && entry.files > 0,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
if (rows.length === 0 || !measured) continue
|
|
198
|
+
|
|
199
|
+
logInfo(LABELS[language])
|
|
200
|
+
pipeOutput(rows.join('\n'))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reports the sweep as skipped when no rule publishes a vocabulary.
|
|
206
|
+
*
|
|
207
|
+
* Zero hits against an empty vocabulary is indistinguishable from a clean
|
|
208
|
+
* codebase in the output, and the two mean opposite things, so the absent case
|
|
209
|
+
* says so rather than printing a count nobody looked for.
|
|
210
|
+
*/
|
|
211
|
+
function reportVocabulary(
|
|
212
|
+
vocabulary: Vocabulary,
|
|
213
|
+
snapshot: readonly LanguageCount[],
|
|
214
|
+
): void {
|
|
215
|
+
logStep('Degradation sweep')
|
|
216
|
+
|
|
217
|
+
if (vocabulary.kind === 'absent') {
|
|
218
|
+
logWarn('Skipped. No rule publishes a "Degradation vocabulary" heading.')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hits = snapshot.flatMap((count) => count.degradationHits)
|
|
223
|
+
logInfo(`${vocabulary.terms.length} terms from ${vocabulary.source}`)
|
|
224
|
+
|
|
225
|
+
if (hits.length === 0) {
|
|
226
|
+
logInfo('No hits.')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
logWarn(`${hits.length} hits`)
|
|
231
|
+
pipeOutput(
|
|
232
|
+
hits.map((hit) => `${hit.file}:${hit.line} ${hit.term}`).join('\n'),
|
|
233
|
+
)
|
|
234
|
+
}
|