@erclx/aitk 3.8.0 → 3.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.8.0",
4
+ "version": "3.10.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -11,9 +11,12 @@ Without this skill, a new skill lands in the wrong shape and the wrong place. A
11
11
 
12
12
  A skill born without its requirement is the second failure, and it surfaces much later. Coverage of the corpus is what the operator reads to decide whether a skill should exist, so every skill created without the sibling decays that reading, and the sweep that closes the gap has to reconstruct what the skill was for from the body it already shipped.
13
13
 
14
+ A third failure is a folder created for a moment a rule, a verb, or an existing skill already reaches, or for a procedure no session would get wrong from first principles. Nothing caught that before the folder existed, so the corpus grew by a skill an inward audit later had to argue for removing, with only a commit body recording the argument.
15
+
14
16
  ## Must
15
17
 
16
18
  - Read the authoring standard and the prose standard before drafting, so the draft starts conformant rather than getting corrected into shape
19
+ - Answer, in writing, whether a rule, a verb, or an existing skill already reaches this moment, and whether the body carries a procedure a session would get wrong from first principles. A folder that fails either belongs to the surface that already covers it, not to a new skill.
17
20
  - Draft the sibling `REQUIREMENT.md` beside the body, from what the skill is for rather than from the drafted body
18
21
  - Confirm the name and both files with the user before writing. The name is the routing key and a folder that disagrees with its frontmatter fails silently.
19
22
  - Write to the conventional skills path, so discovery finds it without configuration
@@ -23,6 +26,7 @@ A skill born without its requirement is the second failure, and it surfaces much
23
26
  - Auto-trigger. Creation is a deliberate act and a skill invented from an ambiguous request is worse than none.
24
27
  - Write either file before the user has seen it
25
28
  - Derive the requirement from the drafted body, which records the draft's overfitting as the requirement
29
+ - Gate creation on whether anything will invoke the new skill beyond the author typing its name. That question has no answer yet.
26
30
 
27
31
  ## Guards
28
32
 
@@ -31,4 +35,4 @@ A skill born without its requirement is the second failure, and it surfaces much
31
35
  ## Out of scope
32
36
 
33
37
  - Editing an existing skill, which the authoring standard and the skill's own requirement govern
34
- - Judging whether the skill is warranted. A skill nobody needed is an audit finding, not a creation-time refusal.
38
+ - Whether anything ends up invoking the new skill: the usage census in `.claude/context/claude-plugin/skill-strategy.md`, read once the skill has run
@@ -18,9 +18,15 @@ Read these files in parallel:
18
18
 
19
19
  ## Steps
20
20
 
21
- 1. Draft the full `SKILL.md` from the user's description
22
- 2. Draft the sibling `REQUIREMENT.md` from what the skill is for, in the shape the standard states. Write the gaps from the user's description rather than from the drafted body, since a requirement derived from the body records whatever the draft overfitted to.
23
- 3. Confirm the skill name and both files with the user before writing
24
- 4. Write to `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>/REQUIREMENT.md`
21
+ 1. Answer three questions in writing before drafting anything. Tell the user and stop if a rule, a verb, or an existing skill already reaches this moment, or if the body carries no procedure a session would get wrong from first principles:
22
+ - Does a rule, a verb, or an existing skill already reach this moment? A folder that only wraps something already reachable duplicates it rather than adding to the catalog.
23
+ - Does the body carry a procedure a session would get wrong from first principles? A one-line wrapper around a single command needs no skill.
24
+ - Will anything invoke it other than the author typing its name? Carry this one into the drafted `REQUIREMENT.md`'s `Must not` section as a review criterion instead, since nothing can answer it before the skill has run.
25
+ 2. Draft the full `SKILL.md` from the user's description
26
+ 3. Draft the sibling `REQUIREMENT.md` from what the skill is for, in the shape the standard states. Write the gaps from the user's description rather than from the drafted body, since a requirement derived from the body records whatever the draft overfitted to.
27
+ 4. Confirm the skill name and both files with the user before writing
28
+ 5. Write to `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>/REQUIREMENT.md`
25
29
 
26
30
  Every skill carries a requirement. A skill created without one is a gap someone closes in a later sweep, and the sweep has to reconstruct what the skill was for from the body it already shipped.
31
+
32
+ The two-question check has a shipped precedent. Pull request `#516` dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
@@ -52,8 +52,6 @@ Run `aitk tooling sync <stack> <target> --check` for the list resolved against a
52
52
  - `.vscode/extensions.json`
53
53
  - `.vscode/settings.json`
54
54
  - `commitlint.config.js`
55
- - `scripts/clean.sh`
56
- - `scripts/update.sh`
57
55
  - `scripts/verify.sh`
58
56
 
59
57
  ### python
@@ -22,11 +22,11 @@ aitk audits list --json
22
22
 
23
23
  ## What it runs
24
24
 
25
- Sixteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
25
+ Eighteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
26
26
 
27
27
  The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
28
28
 
29
- Thirteen of the sixteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
29
+ Fifteen of the eighteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads three such trees against each other and the second counts every file in one. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
30
30
 
31
31
  Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
32
32
 
@@ -0,0 +1,23 @@
1
+ ---
2
+ title: Census
3
+ description: Tracked-plus-untracked file count, a breakdown by extension, and a line total that skips whatever reads as binary
4
+ ---
5
+
6
+ # Census
7
+
8
+ `aitk census [path]` reports how large a tree is: the tracked-plus-untracked file count, a breakdown by extension, and a line total. It reads `listRepositoryFiles`, the same corpus the citation check, the markdown corpus, and the secret scan already read, so this is not a fourth definition of what counts.
9
+
10
+ ```bash
11
+ aitk census
12
+ aitk census src --json
13
+ ```
14
+
15
+ | Option | Behavior |
16
+ | -------- | ---------------------------------------------------------- |
17
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
18
+
19
+ Grouping is by extension rather than by a named language category. A config file and a source file land in separate buckets and two source files sharing an extension land in one, and a file with no extension, such as `Dockerfile`, lands under `no-extension`.
20
+
21
+ Line counts are text-only. A file that reads as binary, and a file git lists that will not open, such as a symlink leaving the tree, are counted toward the file total and their extension's file count, and left out of every line count. The record's `skipped` field states that gap rather than leaving a reader to infer it from the difference between the file count and the summed extension line counts. It carries both causes as one number: on a healthy tree the two are indistinguishable, so a `skipped` count above what the tree's own binary files explain is itself the signal that something in the corpus will not open.
22
+
23
+ The command reports a snapshot rather than a delta. It registers in the audit catalog with an empty gating set, so `aitk audits run --record` is what turns two snapshots into the growth series a hand count used to approximate: 481 files on one date and 965 five weeks later, with nothing between them because nobody had counted again.
@@ -50,9 +50,11 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
50
50
  | `aitk claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
51
51
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
52
52
  | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
53
+ | `aitk gov restated` | Report every instruction the always-loaded file shares with the seed or a shipped skill body, classed and with its anchors named (`--json`) |
53
54
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
54
55
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
55
56
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
57
+ | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
56
58
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
57
59
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
58
60
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
@@ -10,6 +10,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
10
10
 
11
11
  - [Audits](audits.md): Running every health check as one set, what the single verdict means, the exit code each outcome takes, the retained baseline and the delta it reports, and which corpora are kept out of the record
12
12
  - [Capture](capture.md): Rendering committed HTML sources to PNG, what the command asserts about fonts, and why it is toolkit-only
13
+ - [Census](census.md): Tracked-plus-untracked file count, a breakdown by extension, and a line total that skips whatever reads as binary
13
14
  - [Command catalog](commands.md): Every project-level command and every domain subcommand, plus the shape each domain exposes
14
15
  - [Comments](comments.md): Comment density by language and kind, the two structural exclusions, and how the degradation sweep finds its vocabulary
15
16
  - [Context audit checks](context-audit-checks.md): What each non-gating check reports, the unit each checkpoint is measured in, the architecture record's length gate and claim coverage, which folders each check reaches, and what moved to the attribute tier
@@ -24,6 +25,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
24
25
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
25
26
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
26
27
  - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
28
+ - [Restated instructions](restated.md): Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
27
29
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
28
30
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
29
31
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
@@ -0,0 +1,78 @@
1
+ ---
2
+ title: Restated instructions
3
+ description: Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
4
+ ---
5
+
6
+ # Restated instructions
7
+
8
+ `aitk gov restated` reports every instruction the always-loaded file states that a second surface states too. It answers a question nothing else here could: one rule was found written in three places, and it was found by accident when a shipped skill deleted a file the rule said to keep.
9
+
10
+ ```bash
11
+ aitk gov restated
12
+ aitk gov restated --json
13
+ aitk gov restated --root ../my-app
14
+ ```
15
+
16
+ | Option | Behavior |
17
+ | --------------- | ---------------------------------------------------------- |
18
+ | `--root <path>` | Tree to read, defaulting to the current directory |
19
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
+
21
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
22
+
23
+ ## The corpus it reads
24
+
25
+ Three surfaces, and they are not read symmetrically. Every top-level bullet in `CLAUDE.md` is a subject, and the other two are searched:
26
+
27
+ - `tooling/claude/seeds/CLAUDE.md`, read as bullets, since the seed carries the same shape as the file it is authored from
28
+ - `claude/skills/*/SKILL.md`, read as every prose line and bullet, since the motivating case was stated in a body as a paragraph rather than a list item
29
+
30
+ Frontmatter, headings, tables, and fenced blocks are read past. A heading names a section instead of stating a rule, a fenced block is an example whose words belong to the prose around it, and a body's `description` restates that skill's own purpose, so sweeping it would match every subject naming its domain.
31
+
32
+ A rule stated in two skill bodies and never in `CLAUDE.md` is outside this reading. The bullets there are the subjects, and a subject is what the search runs from.
33
+
34
+ ## How a match is decided
35
+
36
+ Matching is recall-first and keyed on shared distinctive tokens rather than on a phrase two surfaces spell the same way. The case this exists for was one rule written three different ways, so a near-exact matcher would ship a cheap report blind to the defect it was built for.
37
+
38
+ A token is distinctive when it appears in at most 20 of the corpus's statements, which is under one percent of them. `.claude/plans/` sits at 14 and is the anchor the motivating case turns on, while `file` sits at 371 and would match most of the tree.
39
+
40
+ A backticked token counts double. An author marking a span as code named an identifier rather than describing one, so `.claude/plans/archive/` says more about what a statement governs than any two prose words do. Two statements are one rule when their shared anchors reach a weight of 3.
41
+
42
+ Every record names the anchors its match rested on, so a reader can weigh a finding instead of taking it.
43
+
44
+ ## The three classes
45
+
46
+ - **Mirror.** Both files sit on a declared path pair whose duplication is deliberate. `CLAUDE.md` and the seed are the one pair, since the seed is authored from it and `claude-seed-sync` exists to reconcile the two. Excluding by pair rather than by content is the point: the duplication is a location fact this repository already records, and a content test would rediscover it on every run.
47
+ - **Repetition.** Two surfaces state one rule and neither is declared a copy of the other.
48
+ - **Contradiction.** The prohibition falls on one surface alone, on a match strong enough to read that as a disagreement. This is a polarity reading rather than a judgment about meaning, so weigh each against the surfaces it names.
49
+
50
+ The contradiction floor sits above the match floor deliberately. A thin match says two statements touch the same subject, which is not enough to claim one forbids what the other prescribes, so a weak pair reports as a repetition and the loudest class is reserved for a pair sharing real identity.
51
+
52
+ Two further rules decide where a prohibition counts, and both came out of false positives rather than from reasoning ahead of the corpus.
53
+
54
+ Polarity is read off the clause the anchors landed in, and off the densest such clause rather than every one carrying an anchor. A statement states one rule across several clauses, so a union answers true whenever any clause anywhere carries a marker, which is the whole statement again under another name.
55
+
56
+ The marker also has to open its clause, because an instruction leads with its verb. `Never delete a task file` prohibits where `a fallback never fires` reports, and no test reading the marker anywhere in the clause tells those apart. What that costs is a prohibition written mid-clause, which now reads as description and lands the pair in the repetition class, so both surfaces still reach the report and only the label is weaker.
57
+
58
+ A mirror that disagrees stays a finding. The exclusion reaches a repetition alone, because the two files on a declared pair are meant to agree.
59
+
60
+ ## Which surface is authoritative
61
+
62
+ Each restatement names where a later edit starts.
63
+
64
+ - `claude-md` for a seed match, since the always-loaded file is authored first and the seed carries it to a target
65
+ - `skill-body` where the subject names that skill, which is the content-ownership rule that behavior triggered only when editing one domain belongs to that domain's skill
66
+ - `unknown` everywhere else, which is a first-class answer rather than a gap
67
+
68
+ The ownership table assigns a cross-domain rule and a domain-triggered one, and reaches nothing stated in a skill body the always-loaded file never names. Guessing there would put a reader on a surface nobody decided.
69
+
70
+ ## Exit codes
71
+
72
+ Exit codes are `0` when no instruction is restated outside a declared mirror, `1` for a refusal, and `2` for at least one restatement outside one. Mirrors move no exit code.
73
+
74
+ Nothing wires this into `bun run check` or into a hook. A restatement is legitimate more often than not, and gating a measure whose ordinary result is a finding is what teaches contributors to route around the stage. `aitk gov test-order` and `aitk labels audit` are the siblings.
75
+
76
+ Both refusals are absences rather than breaks. A target holds neither the seed nor a shipped skills tree, so `aitk audits run` reads `no-instructions` and `no-surfaces` as a corpus that is not there rather than a verb that failed.
77
+
78
+ An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `counts` rather than the exit when a skill consumes this.
@@ -12,6 +12,8 @@ paths:
12
12
  ## Before editing
13
13
 
14
14
  - Read the skill's sibling `REQUIREMENT.md` when one exists. If the change closes no gap it states, change the requirement first or drop the change.
15
+ - Confirm `create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whether drafted by hand, by another skill, or by `create-skill` itself. Carry the third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
16
+ - Report it rather than proceeding silently when `create-skill` does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
15
17
 
16
18
  ## After editing
17
19
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.8.0",
4
+ "version": "3.10.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import type { ReachRefusal } from '@/claude/skills-reach'
2
2
  import type { AuditRefusal } from '@/deps/audit'
3
+ import type { RestatedRefusal } from '@/gov/restated'
3
4
  import type { LabelAuditRefusal } from '@/labels/audit'
4
5
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
5
6
  import type { ScanRefusal } from '@/secrets/scan'
@@ -356,6 +357,33 @@ function labelCoverageCounts(
356
357
  return allOf({ uncovered: lengthOf(root.uncovered) })
357
358
  }
358
359
 
360
+ /**
361
+ * Reads the two classes that are findings, leaving the declared mirrors out.
362
+ *
363
+ * A mirror is an authoring root and its consumed copy, which duplicate on
364
+ * purpose, so folding them in would report a corpus getting worse every time a
365
+ * seed is kept in step with the file it is authored from. Same reasoning the
366
+ * label audit drops its declined rows on.
367
+ *
368
+ * The reach count is left out for a different reason. It counts how many
369
+ * surfaces an instruction reached rather than whether anything is wrong, and it
370
+ * moves in lockstep with the two retained here, so a baseline carrying it would
371
+ * report one movement twice.
372
+ */
373
+ function restatedCounts(record: unknown): Record<string, number> | undefined {
374
+ const counts = asObject(asObject(record)?.counts)
375
+ if (counts === undefined) return undefined
376
+
377
+ return allOf({
378
+ contradictions:
379
+ typeof counts.contradictions === 'number'
380
+ ? counts.contradictions
381
+ : undefined,
382
+ repetitions:
383
+ typeof counts.repetitions === 'number' ? counts.repetitions : undefined,
384
+ })
385
+ }
386
+
359
387
  function findingsOnly(record: unknown): Record<string, number> | undefined {
360
388
  const root = asObject(record)
361
389
  if (root === undefined) return undefined
@@ -377,6 +405,23 @@ function commentCounts(record: unknown): Record<string, number> | undefined {
377
405
  return { degradationHits }
378
406
  }
379
407
 
408
+ /**
409
+ * Reads the census's three headline totals, leaving the extension breakdown
410
+ * out. The breakdown is what a caller reads for its own sake, and folding
411
+ * every extension into the retained baseline would grow the recorded key set
412
+ * with every language the tree ever picks up.
413
+ */
414
+ function censusCounts(record: unknown): Record<string, number> | undefined {
415
+ const root = asObject(record)
416
+ if (root === undefined || !Array.isArray(root.byExtension)) return undefined
417
+
418
+ return allOf({
419
+ files: typeof root.files === 'number' ? root.files : undefined,
420
+ skipped: typeof root.skipped === 'number' ? root.skipped : undefined,
421
+ lines: typeof root.lines === 'number' ? root.lines : undefined,
422
+ })
423
+ }
424
+
380
425
  function testOrderCounts(record: unknown): Record<string, number> | undefined {
381
426
  const root = asObject(record)
382
427
  if (root === undefined) return undefined
@@ -548,6 +593,38 @@ export const AUDITS: readonly AuditSpec[] = [
548
593
  absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
549
594
  counts: labelCoverageCounts,
550
595
  },
596
+ {
597
+ id: 'restated',
598
+ label: 'Restated instructions',
599
+ argv: ['gov', 'restated', '--json'],
600
+ // Reports rather than gates, on the split this file already draws. Whether
601
+ // a rule stated on two surfaces should be stated on one is a judgment the
602
+ // person owning the surface takes, and most restatements here are correct,
603
+ // so a push failing on one would fail on the ordinary case.
604
+ gatingExits: [],
605
+ corpus: 'tracked',
606
+ // Both reasons the sweep refuses for, and each is an absence rather than a
607
+ // break. A target holds neither the seed nor a shipped skills tree, so
608
+ // without the allowance every project installing this CLI reports the verb
609
+ // unmeasured on every run and never changes, which is the permanent signal
610
+ // the per-machine allowance exists against. Same shape as the reach verb.
611
+ absentReasons: [
612
+ 'no-instructions',
613
+ 'no-surfaces',
614
+ ] satisfies RestatedRefusal[],
615
+ counts: restatedCounts,
616
+ },
617
+ {
618
+ id: 'census',
619
+ label: 'Codebase census',
620
+ argv: ['census', '--json'],
621
+ // Reports rather than gates. A file count is a measure, not a judgment, so
622
+ // registering it here exists to retain growth against the baseline rather
623
+ // than to fail a push on a number moving.
624
+ gatingExits: [],
625
+ corpus: 'tracked',
626
+ counts: censusCounts,
627
+ },
551
628
  {
552
629
  id: 'deps',
553
630
  label: 'Dependency advisories',
@@ -0,0 +1,113 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { extname, join } from 'node:path'
3
+ import { isBinary } from '@/binary'
4
+ import { listRepositoryFiles } from '@/git-files'
5
+
6
+ export type CensusRefusal = 'no-git'
7
+
8
+ export interface ExtensionCount {
9
+ readonly extension: string
10
+ readonly files: number
11
+ readonly lines: number
12
+ }
13
+
14
+ export interface CensusResult {
15
+ readonly kind: 'measured'
16
+ readonly files: number
17
+ /** Binary or unreadable, counted toward `files` and excluded from `lines`. */
18
+ readonly skipped: number
19
+ readonly lines: number
20
+ readonly byExtension: readonly ExtensionCount[]
21
+ }
22
+
23
+ export interface CensusFailure {
24
+ readonly kind: 'refused'
25
+ readonly reason: CensusRefusal
26
+ }
27
+
28
+ /**
29
+ * Open descriptors allowed at once while reading the tree, matching
30
+ * `src/comments/scan.ts`. A single `Promise.all` over every path opens one
31
+ * descriptor per file, which exhausts a default 1024 limit on a large
32
+ * repository and fails the whole census with EMFILE.
33
+ */
34
+ const CONCURRENT_READS = 64
35
+
36
+ interface ReadOutcome {
37
+ readonly path: string
38
+ /** Absent when the read failed: a symlink leaving the tree, or a file git
39
+ * listed and the working tree no longer holds. */
40
+ readonly text?: string
41
+ }
42
+
43
+ async function readOne(root: string, path: string): Promise<ReadOutcome> {
44
+ try {
45
+ return { path, text: await readFile(join(root, path), 'utf8') }
46
+ } catch {
47
+ return { path }
48
+ }
49
+ }
50
+
51
+ function extensionFor(path: string): string {
52
+ const ext = extname(path)
53
+ return ext === '' ? 'no-extension' : ext.slice(1).toLowerCase()
54
+ }
55
+
56
+ /**
57
+ * A trailing newline is the file's terminator rather than an empty final line,
58
+ * which is what keeps a file ending `a\nb\n` at two lines rather than three.
59
+ */
60
+ function countLines(text: string): number {
61
+ if (text === '') return 0
62
+ const lines = text.split('\n')
63
+ return text.endsWith('\n') ? lines.length - 1 : lines.length
64
+ }
65
+
66
+ /**
67
+ * Censuses the tracked-plus-untracked tree under `root`: a file count, a
68
+ * breakdown by extension, and a line total that skips whatever reads as
69
+ * binary.
70
+ *
71
+ * Reads `listRepositoryFiles`, the same corpus the citation check, the
72
+ * markdown corpus, and the secret scan already share, so this is not a
73
+ * fourth definition of what counts.
74
+ */
75
+ export async function census(
76
+ root: string,
77
+ ): Promise<CensusResult | CensusFailure> {
78
+ const listed = await listRepositoryFiles(root)
79
+ if (listed === undefined) return { kind: 'refused', reason: 'no-git' }
80
+
81
+ const totals = new Map<string, { files: number; lines: number }>()
82
+ let skipped = 0
83
+ let lines = 0
84
+
85
+ for (let start = 0; start < listed.length; start += CONCURRENT_READS) {
86
+ const batch = listed.slice(start, start + CONCURRENT_READS)
87
+ const outcomes = await Promise.all(batch.map((path) => readOne(root, path)))
88
+
89
+ for (const outcome of outcomes) {
90
+ const extension = extensionFor(outcome.path)
91
+ const entry = totals.get(extension) ?? { files: 0, lines: 0 }
92
+ entry.files += 1
93
+
94
+ // A read that failed and a file that read as binary are counted the
95
+ // same way: toward `files`, excluded from every line count.
96
+ if (outcome.text === undefined || isBinary(outcome.text)) {
97
+ skipped += 1
98
+ } else {
99
+ const counted = countLines(outcome.text)
100
+ entry.lines += counted
101
+ lines += counted
102
+ }
103
+
104
+ totals.set(extension, entry)
105
+ }
106
+ }
107
+
108
+ const byExtension = [...totals.entries()]
109
+ .map(([extension, counts]) => ({ extension, ...counts }))
110
+ .sort((a, b) => b.files - a.files || a.extension.localeCompare(b.extension))
111
+
112
+ return { kind: 'measured', files: listed.length, skipped, lines, byExtension }
113
+ }
package/src/cli.ts CHANGED
@@ -30,6 +30,7 @@ import { register as audits } from '@/commands/audits'
30
30
  import { register as secrets } from '@/commands/secrets'
31
31
  import { register as deps } from '@/commands/deps'
32
32
  import { register as labels } from '@/commands/labels'
33
+ import { register as census } from '@/commands/census'
33
34
  import { register as upgrade } from '@/commands/upgrade'
34
35
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
35
36
  import { palette } from '@/ui'
@@ -71,6 +72,7 @@ function showHelp(): void {
71
72
  `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
72
73
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
73
74
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
75
+ `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
74
76
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
75
77
  `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
76
78
  `${GREY}│${NC}`,
@@ -85,6 +87,7 @@ function showHelp(): void {
85
87
  `${GREY}│${NC} aitk sandbox git:commit`,
86
88
  `${GREY}│${NC} aitk gov install react`,
87
89
  `${GREY}│${NC} aitk gov sync ../my-app`,
90
+ `${GREY}│${NC} aitk gov restated --json`,
88
91
  `${GREY}│${NC} aitk standards markdown`,
89
92
  `${GREY}│${NC} aitk snippets install base ../my-app`,
90
93
  `${GREY}│${NC} aitk snippets sync ../my-app`,
@@ -115,6 +118,7 @@ function showHelp(): void {
115
118
  `${GREY}│${NC} aitk secrets scan --json`,
116
119
  `${GREY}│${NC} aitk deps audit --json`,
117
120
  `${GREY}│${NC} aitk labels audit --json`,
121
+ `${GREY}│${NC} aitk census --json`,
118
122
  `${GREY}│${NC} aitk audits run --json`,
119
123
  `${GREY}│${NC} aitk upgrade --json`,
120
124
  `${GREY}└${NC}`,
@@ -163,6 +167,7 @@ sessions(program)
163
167
  secrets(program)
164
168
  deps(program)
165
169
  labels(program)
170
+ census(program)
166
171
  audits(program)
167
172
  upgrade(program)
168
173
 
@@ -0,0 +1,105 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { census, type CensusRefusal } from '@/census/count'
4
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
5
+
6
+ interface CensusCommandOptions {
7
+ readonly json?: boolean
8
+ }
9
+
10
+ const REFUSALS: Record<CensusRefusal, string> = {
11
+ 'no-git': 'git could not list this tree, so the corpus is unknown.',
12
+ }
13
+
14
+ export function register(program: Command): void {
15
+ program
16
+ .command('census')
17
+ .description(
18
+ 'Report tracked file count, a breakdown by extension, and line totals',
19
+ )
20
+ .argument('[path]', 'Tree to census, defaulting to the current directory')
21
+ .helpOption('-h, --help', 'Show this help message')
22
+ .option('--json', 'Add a machine-readable record on stdout')
23
+ .addHelpText(
24
+ 'after',
25
+ [
26
+ '',
27
+ 'Scope:',
28
+ ' Tracked files plus untracked files git does not ignore, the same',
29
+ ' corpus the citation check, the markdown corpus, and the secret',
30
+ ' scan already read. A line total skips whatever reads as binary.',
31
+ '',
32
+ 'Exit codes:',
33
+ ' 0 the census completed',
34
+ ' 1 refused, with the reason on stderr',
35
+ '',
36
+ 'Examples:',
37
+ ' aitk census',
38
+ ' aitk census src --json',
39
+ '',
40
+ ].join('\n'),
41
+ )
42
+ .action(async (path: string | undefined, opts: CensusCommandOptions) => {
43
+ process.exitCode = await runCensus(path, opts)
44
+ })
45
+ }
46
+
47
+ async function runCensus(
48
+ path: string | undefined,
49
+ opts: CensusCommandOptions,
50
+ ): Promise<number> {
51
+ const root = resolve(path ?? process.cwd())
52
+ const emitJson = opts.json ?? false
53
+
54
+ intro('aitk census')
55
+
56
+ const result = await census(root)
57
+
58
+ if (result.kind === 'refused') {
59
+ logStep('Refused')
60
+ logWarn(REFUSALS[result.reason])
61
+ outro()
62
+
63
+ if (emitJson) {
64
+ process.stdout.write(
65
+ `${JSON.stringify({
66
+ root,
67
+ reason: result.reason,
68
+ message: REFUSALS[result.reason],
69
+ })}\n`,
70
+ )
71
+ }
72
+ return 1
73
+ }
74
+
75
+ logStep('Files')
76
+ logInfo(
77
+ `${plural(result.files, 'file')}, ${result.skipped} skipped as binary or unreadable`,
78
+ )
79
+
80
+ logStep('By extension')
81
+ for (const entry of result.byExtension) {
82
+ logInfo(
83
+ `${entry.extension}: ${plural(entry.files, 'file')}, ${entry.lines} lines`,
84
+ )
85
+ }
86
+
87
+ logStep('Lines')
88
+ logInfo(`${result.lines} counted, skipping binary or unreadable files`)
89
+
90
+ outro()
91
+
92
+ if (emitJson) {
93
+ process.stdout.write(
94
+ `${JSON.stringify({
95
+ root,
96
+ files: result.files,
97
+ skipped: result.skipped,
98
+ lines: result.lines,
99
+ byExtension: result.byExtension,
100
+ })}\n`,
101
+ )
102
+ }
103
+
104
+ return 0
105
+ }