@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Signals, and what confirms one
|
|
2
|
+
|
|
3
|
+
Each row is a reason to look, never a finding. A signal becomes a candidate only when
|
|
4
|
+
the evidence column has something in it — a path with line numbers, a counted
|
|
5
|
+
duplicate, a measured size, an import edge, or a named test gap. If you cannot point
|
|
6
|
+
at it, drop it and write why in the rejected list.
|
|
7
|
+
|
|
8
|
+
## The table
|
|
9
|
+
|
|
10
|
+
| Signal | What to look for | Evidence that confirms it |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Duplication | The same body or a near-identical block in several places; copy-paste with small edits; a helper that already exists in a shared location | Paths and line ranges of each copy, and the shared location it should use |
|
|
13
|
+
| Size and responsibility | A module or function past the project's stated limit, or a relative outlier; unrelated exports; deep nesting; long parameter lists | The measured size or the responsibility list, and the seams where it could split |
|
|
14
|
+
| Coupling | Circular or layer-crossing imports, high fan-in or fan-out, feature envy, repeated type switches | The import edge or the repeated branch, with both sides named |
|
|
15
|
+
| Cohesion and drift | A grab-bag utility module; a name that no longer matches behavior; dead exports; commented-out code; two implementations of one concept | The mismatch or unused export, with its callers |
|
|
16
|
+
| Change pressure | High recent churn combined with complexity or weak tests | The churn count over a stated window, plus the complexity or the test gap |
|
|
17
|
+
| Shotgun surgery | One concept edited in several files every time it changes — a new field added in five places, a new enum member handled in four | Two or more recent commits touching the same set of files for one change, named by commit |
|
|
18
|
+
| Test friction | Behavior with no direct test; tests that mock the unit heavily or assert internals; duplicated setup that hides a missing seam | The test path, and the missing or brittle assertion |
|
|
19
|
+
| Boundary erosion | Leaking internal types, unchecked casts, mode-selecting boolean parameters, null-heavy returns | The boundary location and the caller that must know the internals |
|
|
20
|
+
| Doc-versus-code drift | A README, guide, or instruction file that promises something the code no longer does | The doc line and the code line that disagree; the change is a promise, not a preference |
|
|
21
|
+
|
|
22
|
+
## What the inventory script measures, and what it does not
|
|
23
|
+
|
|
24
|
+
`scripts/inventory.ts` prints, for the scope: file and code-line counts, the size
|
|
25
|
+
distribution and its p90, exports, fan-in from resolvable relative imports, churn over
|
|
26
|
+
a stated window, test proximity by convention, and the files above p90 in any of those
|
|
27
|
+
columns. That is the cheap half.
|
|
28
|
+
|
|
29
|
+
It does **not** measure anything per symbol: nesting depth, parameter counts, function
|
|
30
|
+
length, or complexity are not in the report. Read the outlier files for those, and say
|
|
31
|
+
in the report which ones you read.
|
|
32
|
+
|
|
33
|
+
It reads imports and exports for TypeScript, JavaScript, Python, Go, and Rust. Any
|
|
34
|
+
other language is counted in the `skipped` column rather than guessed at — so in a
|
|
35
|
+
Ruby, PHP, Java, Kotlin, Swift, C, or C# tree, fan-in and export counts are not
|
|
36
|
+
evidence and belong in "not examined" unless you measured them by hand.
|
|
37
|
+
|
|
38
|
+
Two more honest limits, both worth repeating in the report when they apply:
|
|
39
|
+
|
|
40
|
+
- **A shallow clone has no churn history.** The script says `no history` rather than
|
|
41
|
+
zero, because zero and unknown rank a file very differently.
|
|
42
|
+
- **An unresolved specifier is not a missing import.** It means the audit could not
|
|
43
|
+
resolve it — a path alias, a generated module, a language the scanner does not read.
|
|
44
|
+
Name the count, do not treat it as an orphan.
|
|
45
|
+
|
|
46
|
+
## Confirm before keeping
|
|
47
|
+
|
|
48
|
+
Read the callers and the tests, not just the definition. Duplication may be deliberate
|
|
49
|
+
for a protocol version; a large module may be cohesive; an "unused" export may be
|
|
50
|
+
public API; a hot file may be hot because it is the only place that already works.
|
|
51
|
+
|
|
52
|
+
Keep pure refactors separate from behavior changes and bugs. Report a bug as a bug.
|
|
53
|
+
Prefer a candidate with a test that would catch a regression; when the behavior is
|
|
54
|
+
untested, mark the candidate "tests first" instead of dropping it.
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codebase inventory, for the `refactor` skill.
|
|
3
|
+
*
|
|
4
|
+
* The skill's judgement is the model's; the measuring is not. The audit used to ask
|
|
5
|
+
* for "file and function sizes, nesting depth, parameter counts, export counts,
|
|
6
|
+
* fan-in and fan-out" in prose, which no one can do honestly by hand past three files.
|
|
7
|
+
* This prints those facts for one scope, so a candidate's evidence is a number with a
|
|
8
|
+
* source instead of an impression.
|
|
9
|
+
*
|
|
10
|
+
* It prints facts, never a verdict and never a score: a numeric score would be this
|
|
11
|
+
* script inventing the threshold the skill tells the model not to invent. The
|
|
12
|
+
* "outliers" list is a rule, not a ranking — a file is there because it is above the
|
|
13
|
+
* 90th percentile in lines, fan-in, or churn, or among the largest with no test — and
|
|
14
|
+
* every column is printed so the reader can see which rule put it there.
|
|
15
|
+
*
|
|
16
|
+
* What it cannot measure, it says: a language it cannot read is `skipped` by
|
|
17
|
+
* extension, a specifier it cannot resolve is counted, and a shallow clone has no
|
|
18
|
+
* churn history rather than a churn of zero.
|
|
19
|
+
*
|
|
20
|
+
* It ships inside the skill and resolves against the skill's own directory, so it
|
|
21
|
+
* imports nothing from the package's `lib/`: an installed copy has `skills/` and
|
|
22
|
+
* nothing else.
|
|
23
|
+
*
|
|
24
|
+
* Usage: `bun <skill>/scripts/inventory.ts [path ...] [--churn-days N] [--limit N]`
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
28
|
+
import { join, posix, relative, sep } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Extensions this script measures. Everything else is reported as `skipped`. */
|
|
31
|
+
export const SOURCE_LANGUAGES = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs"] as const;
|
|
32
|
+
|
|
33
|
+
/** Directories a walk outside a repository never descends into. */
|
|
34
|
+
export const SKIP_DIRS = [
|
|
35
|
+
".git",
|
|
36
|
+
"node_modules",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
"out",
|
|
40
|
+
"target",
|
|
41
|
+
"vendor",
|
|
42
|
+
".venv",
|
|
43
|
+
"venv",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
".next",
|
|
46
|
+
"coverage",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Defaults the report states, so the audit's budget is visible rather than implied. */
|
|
50
|
+
export const DEFAULT_CHURN_DAYS = 90;
|
|
51
|
+
export const DEFAULT_LIMIT = 8;
|
|
52
|
+
|
|
53
|
+
/** One source file, measured. `path` is relative to the scope root, forward slashes. */
|
|
54
|
+
export interface FileFacts {
|
|
55
|
+
path: string;
|
|
56
|
+
language: string;
|
|
57
|
+
/** Physical lines. */
|
|
58
|
+
lines: number;
|
|
59
|
+
/** Lines that are neither blank nor a comment. */
|
|
60
|
+
codeLines: number;
|
|
61
|
+
exports: number;
|
|
62
|
+
/** Specifiers as written, before resolution. */
|
|
63
|
+
imports: string[];
|
|
64
|
+
/** TODO and FIXME markers, which are drift the store reports rather than fixes. */
|
|
65
|
+
markers: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What one file's history says: how often it changed, and how much of it. */
|
|
69
|
+
export interface Churn {
|
|
70
|
+
commits: number;
|
|
71
|
+
insertions: number;
|
|
72
|
+
deletions: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How the scope was measured: sizes, edges, history, and tests. */
|
|
76
|
+
export interface Report {
|
|
77
|
+
languages: { name: string; count: number }[];
|
|
78
|
+
skipped: { name: string; count: number }[];
|
|
79
|
+
sizes: { median: number; p90: number; max: { path: string; lines: number } };
|
|
80
|
+
fanIn: { max: { path: string; count: number }; external: number; unresolved: number };
|
|
81
|
+
churn: {
|
|
82
|
+
available: boolean;
|
|
83
|
+
window: number;
|
|
84
|
+
charged: number;
|
|
85
|
+
median: number;
|
|
86
|
+
p90: number;
|
|
87
|
+
max?: { path: string; commits: number };
|
|
88
|
+
};
|
|
89
|
+
tests: { sourceFiles: number; coLocated: number; largestUntested: string[] };
|
|
90
|
+
/** Above the 90th percentile in one column, or among the largest with no test. */
|
|
91
|
+
outliers: { path: string; lines: number; exports: number; fanIn: number; commits: number; test: string }[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Options a caller may override; the report prints both defaults. */
|
|
95
|
+
export interface Options {
|
|
96
|
+
churnDays?: number;
|
|
97
|
+
limit?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The extension without its dot, lowercased; the language key every table uses. */
|
|
101
|
+
export function languageOf(path: string): string {
|
|
102
|
+
const name = path.split("/").pop() ?? path;
|
|
103
|
+
const dot = name.lastIndexOf(".");
|
|
104
|
+
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether this script measures the language, by extension alone. */
|
|
108
|
+
function measured(language: string): boolean {
|
|
109
|
+
return (SOURCE_LANGUAGES as readonly string[]).includes(language);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A comment line in any of the measured languages; `*` continues a block. */
|
|
113
|
+
const COMMENT = /^\s*(?:\/\/|\/\*|\*|#)/;
|
|
114
|
+
/** An exported declaration, per language family. */
|
|
115
|
+
const EXPORTS: Record<string, RegExp> = {
|
|
116
|
+
es: /^\s*(?:export\s|(?:module\.)?exports[.\s=])/,
|
|
117
|
+
py: /^(?:def|class)\s+\w/,
|
|
118
|
+
go: /^(?:func\s+[A-Z]|type\s+[A-Z])/,
|
|
119
|
+
rs: /^\s*pub\s+(?:fn|struct|enum|trait|mod|const|static|type)\b/,
|
|
120
|
+
};
|
|
121
|
+
const MARKER = /\b(?:TODO|FIXME|XXX)\b/;
|
|
122
|
+
|
|
123
|
+
/** The export/import family a language belongs to, which is what the scanners key on. */
|
|
124
|
+
function familyOf(language: string): "es" | "py" | "go" | "rs" | undefined {
|
|
125
|
+
if (["ts", "tsx", "js", "jsx", "mjs", "cjs"].includes(language)) return "es";
|
|
126
|
+
if (language === "py") return "py";
|
|
127
|
+
if (language === "go") return "go";
|
|
128
|
+
if (language === "rs") return "rs";
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every import specifier on one line, for the line's family. */
|
|
133
|
+
function specifiersOf(line: string, family: string): string[] {
|
|
134
|
+
if (family === "es") {
|
|
135
|
+
return [
|
|
136
|
+
...line.matchAll(/(?:from|require\(|import\()\s*["']([^"']+)["']/g),
|
|
137
|
+
...line.matchAll(/^\s*import\s+["']([^"']+)["']/g),
|
|
138
|
+
].map((match) => match[1]);
|
|
139
|
+
}
|
|
140
|
+
if (family === "py") {
|
|
141
|
+
const from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line);
|
|
142
|
+
if (from !== null) return [from[1]];
|
|
143
|
+
const plain = /^\s*import\s+([\w.,\s]+)$/.exec(line);
|
|
144
|
+
return plain === null ? [] : plain[1].split(",").map((part) => part.trim().split(/\s+/)[0]);
|
|
145
|
+
}
|
|
146
|
+
if (family === "rs") {
|
|
147
|
+
const use = /^\s*use\s+([\w:]+)/.exec(line);
|
|
148
|
+
return use === null ? [] : [use[1]];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Measure one file's text. A language this script cannot read still gets a row, so the
|
|
154
|
+
* report can name what it skipped instead of dropping it silently. */
|
|
155
|
+
export function scanFile(path: string, text: string): FileFacts {
|
|
156
|
+
const language = languageOf(path);
|
|
157
|
+
const family = familyOf(language);
|
|
158
|
+
const lines = text.split("\n");
|
|
159
|
+
const facts: FileFacts = { path, language, lines: lines.length, codeLines: 0, exports: 0, imports: [], markers: 0 };
|
|
160
|
+
if (lines[lines.length - 1] === "") facts.lines--;
|
|
161
|
+
if (family === undefined) return facts;
|
|
162
|
+
|
|
163
|
+
let inGoImport = false;
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
const blank = line.trim() === "";
|
|
166
|
+
if (!blank && !COMMENT.test(line)) facts.codeLines++;
|
|
167
|
+
if (MARKER.test(line)) facts.markers++;
|
|
168
|
+
if (EXPORTS[family].test(line)) facts.exports++;
|
|
169
|
+
|
|
170
|
+
if (family === "go") {
|
|
171
|
+
if (/^\s*import\s*\(/.test(line)) {
|
|
172
|
+
inGoImport = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (inGoImport && /^\s*\)/.test(line)) {
|
|
176
|
+
inGoImport = false;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (inGoImport) {
|
|
180
|
+
const quoted = /"([^"]+)"/.exec(line);
|
|
181
|
+
if (quoted !== null) facts.imports.push(quoted[1]);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const single = /^\s*import\s+(?:[\w.]+\s+)?"([^"]+)"/.exec(line);
|
|
185
|
+
if (single !== null) facts.imports.push(single[1]);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
facts.imports.push(...specifiersOf(line, family));
|
|
189
|
+
}
|
|
190
|
+
return facts;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Extensions a relative specifier may omit, per family. */
|
|
194
|
+
const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
195
|
+
|
|
196
|
+
/** Candidate files a specifier could mean, most specific first. */
|
|
197
|
+
function candidatesOf(from: string, specifier: string): string[] {
|
|
198
|
+
const base = posix.join(posix.dirname(from), specifier);
|
|
199
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) {
|
|
200
|
+
const stripped = base.replace(/\.(?:js|jsx|mjs|cjs)$/, "");
|
|
201
|
+
return [
|
|
202
|
+
base,
|
|
203
|
+
...EXTENSIONS.map((extension) => `${stripped}${extension}`),
|
|
204
|
+
...EXTENSIONS.map((extension) => posix.join(base, `index${extension}`)),
|
|
205
|
+
posix.join(base, "index.ts"),
|
|
206
|
+
`${base}.py`,
|
|
207
|
+
posix.join(base, "__init__.py"),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* In-edges from relative imports that resolve to a file in the scope, plus what could
|
|
215
|
+
* not be measured: `external` package specifiers and `unresolved` relative ones.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveImports(
|
|
218
|
+
files: readonly FileFacts[],
|
|
219
|
+
knownPaths?: Iterable<string>,
|
|
220
|
+
): {
|
|
221
|
+
fanIn: Map<string, number>;
|
|
222
|
+
external: number;
|
|
223
|
+
unresolved: number;
|
|
224
|
+
} {
|
|
225
|
+
const known = new Set(knownPaths ?? files.map((file) => file.path));
|
|
226
|
+
const fanIn = new Map<string, number>();
|
|
227
|
+
let external = 0;
|
|
228
|
+
let unresolved = 0;
|
|
229
|
+
|
|
230
|
+
for (const file of files) {
|
|
231
|
+
for (const specifier of file.imports) {
|
|
232
|
+
if (!specifier.startsWith(".")) {
|
|
233
|
+
external++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const target = candidatesOf(file.path, specifier).find((candidate) => known.has(candidate));
|
|
237
|
+
if (target === undefined) {
|
|
238
|
+
unresolved++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
fanIn.set(target, (fanIn.get(target) ?? 0) + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { fanIn, external, unresolved };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Test files a source file's convention implies, in the order the convention is tried. */
|
|
248
|
+
function testCandidatesOf(path: string): string[] {
|
|
249
|
+
const directory = posix.dirname(path);
|
|
250
|
+
const base = posix.basename(path).replace(/\.[^.]+$/, "");
|
|
251
|
+
const up = posix.join(directory, "..");
|
|
252
|
+
return [
|
|
253
|
+
posix.join(directory, `${base}.test.ts`),
|
|
254
|
+
posix.join(directory, `${base}.test.tsx`),
|
|
255
|
+
posix.join(directory, `${base}.test.js`),
|
|
256
|
+
posix.join(directory, `${base}.spec.ts`),
|
|
257
|
+
posix.join(directory, `${base}.spec.js`),
|
|
258
|
+
posix.join(directory, `${base}_test.go`),
|
|
259
|
+
posix.join(directory, `${base}_test.rs`),
|
|
260
|
+
posix.join(directory, `test_${base}.py`),
|
|
261
|
+
posix.join(directory, `${base}_test.py`),
|
|
262
|
+
posix.join(directory, "tests", `test_${base}.py`),
|
|
263
|
+
posix.join(up, "test", `${base}.test.ts`),
|
|
264
|
+
posix.join(up, "tests", `${base}_test.go`),
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Whether a path is itself a test, by the same conventions proximity looks for. */
|
|
269
|
+
export function isTestFile(path: string): boolean {
|
|
270
|
+
const name = path.split("/").pop() ?? path;
|
|
271
|
+
return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(name) || /_test\.(?:go|rs|py)$/.test(name) || /^test_.*\.py$/.test(name);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The test file each source file finds by convention, or none. */
|
|
275
|
+
export function testProximity(files: readonly FileFacts[]): Map<string, string> {
|
|
276
|
+
const known = new Set(files.map((file) => file.path));
|
|
277
|
+
const found = new Map<string, string>();
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
const test = testCandidatesOf(file.path).find((candidate) => known.has(candidate));
|
|
280
|
+
if (test !== undefined) found.set(file.path, test);
|
|
281
|
+
}
|
|
282
|
+
return found;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A rename's destination, so history aggregates on the path a reader will open. */
|
|
286
|
+
function numstatPath(path: string): string {
|
|
287
|
+
const arrow = path.indexOf(" => ");
|
|
288
|
+
if (arrow === -1) return path;
|
|
289
|
+
const prefix = path.slice(0, path.lastIndexOf("/", arrow) + 1) || "";
|
|
290
|
+
const destination = path.slice(arrow + 4);
|
|
291
|
+
return `${prefix}${destination.replace(/}$/, "")}`.replace(/[{}]/g, "");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `git log --numstat` output into per-file churn. Any line that is not a numstat row is
|
|
296
|
+
* a commit boundary, so the caller's `--format` needs no shared constant; a binary
|
|
297
|
+
* change prints `-` for its counts, so it counts as a commit and no lines.
|
|
298
|
+
*/
|
|
299
|
+
export function parseNumstat(text: string): Map<string, Churn> {
|
|
300
|
+
const churn = new Map<string, Churn>();
|
|
301
|
+
const touched = new Set<string>();
|
|
302
|
+
const close = (): void => {
|
|
303
|
+
for (const path of touched) churn.get(path)!.commits++;
|
|
304
|
+
touched.clear();
|
|
305
|
+
};
|
|
306
|
+
for (const line of text.split("\n")) {
|
|
307
|
+
if (line.trim() === "") continue;
|
|
308
|
+
const columns = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
309
|
+
if (columns === null) {
|
|
310
|
+
close();
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const path = numstatPath(columns[3]);
|
|
314
|
+
const entry = churn.get(path) ?? { commits: 0, insertions: 0, deletions: 0 };
|
|
315
|
+
if (columns[1] !== "-") entry.insertions += Number(columns[1]);
|
|
316
|
+
if (columns[2] !== "-") entry.deletions += Number(columns[2]);
|
|
317
|
+
churn.set(path, entry);
|
|
318
|
+
touched.add(path);
|
|
319
|
+
}
|
|
320
|
+
close();
|
|
321
|
+
return churn;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The value at the given percentile, nearest-rank, over a sorted copy. */
|
|
325
|
+
export function percentile(values: readonly number[], rank: number): number {
|
|
326
|
+
if (values.length === 0) return 0;
|
|
327
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
328
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((rank / 100) * sorted.length) - 1));
|
|
329
|
+
return sorted[index];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The top `limit` entries by count, with a stable tie-break on path. */
|
|
333
|
+
function head(entries: readonly { path: string; count: number }[], limit: number): { path: string; count: number }[] {
|
|
334
|
+
return [...entries]
|
|
335
|
+
.sort((left, right) => right.count - left.count || left.path.localeCompare(right.path))
|
|
336
|
+
.slice(0, limit);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** What `summarize` reads: the scope, the graph universe, the history, and the budget. */
|
|
340
|
+
export interface Input {
|
|
341
|
+
/** In scope, measured or not — an unmeasured language is named in `skipped`. */
|
|
342
|
+
files: readonly FileFacts[];
|
|
343
|
+
/** Repo-wide, for the import graph, so an edge leaving the scope still resolves. */
|
|
344
|
+
universe?: readonly FileFacts[];
|
|
345
|
+
/** Every file the project lists, so a specifier to a non-source neighbour resolves. */
|
|
346
|
+
knownPaths?: readonly string[];
|
|
347
|
+
churn?: Map<string, Churn>;
|
|
348
|
+
options?: Options;
|
|
349
|
+
/** False when no history was read, which is not the same as a churn of zero. */
|
|
350
|
+
history?: boolean;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Summarize a scope: distribution, the maxima, the churn window, test proximity, and
|
|
355
|
+
* the outlier rule. A test file is measured but never a candidate: its size is code
|
|
356
|
+
* volume, and "it has no test" would be a tautology.
|
|
357
|
+
*/
|
|
358
|
+
export function summarize(input: Input): Report {
|
|
359
|
+
const { files, universe = files, knownPaths, churn = new Map<string, Churn>(), history = true } = input;
|
|
360
|
+
const options = input.options ?? {};
|
|
361
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
362
|
+
const churnDays = options.churnDays ?? DEFAULT_CHURN_DAYS;
|
|
363
|
+
const measuredFiles = files.filter((file) => measured(file.language));
|
|
364
|
+
const candidates = measuredFiles.filter((file) => !isTestFile(file.path));
|
|
365
|
+
// Fan-in is repo-wide, because a file's dependents do not live in the scope; the
|
|
366
|
+
// external and unresolved counts are the scope's own, because they are its edges.
|
|
367
|
+
const { fanIn } = resolveImports(
|
|
368
|
+
universe.filter((file) => measured(file.language)),
|
|
369
|
+
knownPaths,
|
|
370
|
+
);
|
|
371
|
+
const { external, unresolved } = resolveImports(measuredFiles, knownPaths);
|
|
372
|
+
const tests = testProximity(files);
|
|
373
|
+
const fanInOf = (path: string): number => fanIn.get(path) ?? 0;
|
|
374
|
+
const commitsOf = (path: string): number => churn.get(path)?.commits ?? 0;
|
|
375
|
+
|
|
376
|
+
const languages = [...new Set(measuredFiles.map((file) => file.language))]
|
|
377
|
+
.map((name) => ({ name, count: measuredFiles.filter((file) => file.language === name).length }))
|
|
378
|
+
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
379
|
+
const skipped = [...new Set(files.filter((file) => !measured(file.language)).map((file) => file.language))]
|
|
380
|
+
.map((name) => ({ name, count: files.filter((file) => file.language === name).length }))
|
|
381
|
+
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
382
|
+
|
|
383
|
+
const lines = measuredFiles.map((file) => file.lines);
|
|
384
|
+
const largest = [...measuredFiles].sort(
|
|
385
|
+
(left, right) => right.lines - left.lines || left.path.localeCompare(right.path),
|
|
386
|
+
)[0];
|
|
387
|
+
const busiest = [...measuredFiles].sort(
|
|
388
|
+
(left, right) => fanInOf(right.path) - fanInOf(left.path) || left.path.localeCompare(right.path),
|
|
389
|
+
)[0];
|
|
390
|
+
const charged = [...churn.entries()].filter(([path]) => measuredFiles.some((file) => file.path === path));
|
|
391
|
+
const busiestChurn = [...charged].sort((left, right) => right[1].commits - left[1].commits)[0];
|
|
392
|
+
|
|
393
|
+
const linesBar = percentile(
|
|
394
|
+
candidates.map((file) => file.lines),
|
|
395
|
+
90,
|
|
396
|
+
);
|
|
397
|
+
const fanInBar = percentile(
|
|
398
|
+
candidates.map((file) => fanInOf(file.path)),
|
|
399
|
+
90,
|
|
400
|
+
);
|
|
401
|
+
const churnBar = percentile(
|
|
402
|
+
candidates.map((file) => commitsOf(file.path)),
|
|
403
|
+
90,
|
|
404
|
+
);
|
|
405
|
+
const largestUntested = head(
|
|
406
|
+
candidates.filter((file) => !tests.has(file.path)).map((file) => ({ path: file.path, count: file.lines })),
|
|
407
|
+
3,
|
|
408
|
+
).map((entry) => entry.path);
|
|
409
|
+
|
|
410
|
+
const outliers = candidates
|
|
411
|
+
.filter((file) => file.lines >= linesBar || fanInOf(file.path) >= fanInBar || commitsOf(file.path) >= churnBar)
|
|
412
|
+
.filter(() => linesBar > 0 || fanInBar > 0 || churnBar > 0)
|
|
413
|
+
.sort((left, right) => right.lines - left.lines || left.path.localeCompare(right.path))
|
|
414
|
+
.slice(0, limit)
|
|
415
|
+
.map((file) => ({
|
|
416
|
+
path: file.path,
|
|
417
|
+
lines: file.lines,
|
|
418
|
+
exports: file.exports,
|
|
419
|
+
fanIn: fanInOf(file.path),
|
|
420
|
+
commits: commitsOf(file.path),
|
|
421
|
+
test: tests.has(file.path) ? "test" : "no test",
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
languages,
|
|
426
|
+
skipped,
|
|
427
|
+
sizes: {
|
|
428
|
+
median: percentile(lines, 50),
|
|
429
|
+
p90: percentile(lines, 90),
|
|
430
|
+
max: largest === undefined ? { path: "none", lines: 0 } : { path: largest.path, lines: largest.lines },
|
|
431
|
+
},
|
|
432
|
+
fanIn: {
|
|
433
|
+
max: busiest === undefined ? { path: "none", count: 0 } : { path: busiest.path, count: fanInOf(busiest.path) },
|
|
434
|
+
external,
|
|
435
|
+
unresolved,
|
|
436
|
+
},
|
|
437
|
+
churn: {
|
|
438
|
+
available: history,
|
|
439
|
+
window: churnDays,
|
|
440
|
+
charged: charged.length,
|
|
441
|
+
median: percentile(
|
|
442
|
+
charged.map(([, entry]) => entry.commits),
|
|
443
|
+
50,
|
|
444
|
+
),
|
|
445
|
+
p90: percentile(
|
|
446
|
+
charged.map(([, entry]) => entry.commits),
|
|
447
|
+
90,
|
|
448
|
+
),
|
|
449
|
+
...(busiestChurn === undefined ? {} : { max: { path: busiestChurn[0], commits: busiestChurn[1].commits } }),
|
|
450
|
+
},
|
|
451
|
+
tests: {
|
|
452
|
+
sourceFiles: candidates.length,
|
|
453
|
+
coLocated: candidates.filter((file) => tests.has(file.path)).length,
|
|
454
|
+
largestUntested,
|
|
455
|
+
},
|
|
456
|
+
outliers,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A trim with an ellipsis, so one long path cannot wrap the report. */
|
|
461
|
+
function clip(text: string, width: number): string {
|
|
462
|
+
return text.length > width ? `${text.slice(0, width - 1)}…` : text;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The report. Every line is a fact with its scope, in the order an audit uses them. */
|
|
466
|
+
export function formatReport(scope: string, files: readonly FileFacts[], report: Report, note?: string): string {
|
|
467
|
+
const out: string[] = [];
|
|
468
|
+
const skipped = report.skipped.map((entry) => `${entry.count} ${entry.name}`).join(" · ");
|
|
469
|
+
out.push(`scope ${scope} · ${files.length} files${skipped === "" ? "" : ` · skipped ${skipped}`}`);
|
|
470
|
+
if (note !== undefined) out.push(`note ${note}`);
|
|
471
|
+
out.push(
|
|
472
|
+
`languages ${report.languages.map((entry) => `${entry.name} ${entry.count}`).join(" · ") || "none measured"}`,
|
|
473
|
+
);
|
|
474
|
+
out.push(
|
|
475
|
+
`sizes median ${report.sizes.median} lines · p90 ${report.sizes.p90} · max ${report.sizes.max.lines} ${report.sizes.max.path}`,
|
|
476
|
+
);
|
|
477
|
+
out.push(
|
|
478
|
+
`fan-in max ${report.fanIn.max.count} ${report.fanIn.max.path} · external ${report.fanIn.external} · unresolved ${report.fanIn.unresolved}`,
|
|
479
|
+
);
|
|
480
|
+
out.push(
|
|
481
|
+
report.churn.available
|
|
482
|
+
? `churn ${report.churn.window}d charged ${report.churn.charged} ${report.churn.charged === 1 ? "file" : "files"} · median ${report.churn.median} · p90 ${report.churn.p90}${
|
|
483
|
+
report.churn.max === undefined ? "" : ` · max ${report.churn.max.commits} ${report.churn.max.path}`
|
|
484
|
+
}`
|
|
485
|
+
: `churn ${report.churn.window}d no history: a shallow clone or a directory outside git`,
|
|
486
|
+
);
|
|
487
|
+
out.push(
|
|
488
|
+
`tests co-located ${report.tests.coLocated}/${report.tests.sourceFiles} source files · largest without one ${
|
|
489
|
+
report.tests.largestUntested[0] ?? "none"
|
|
490
|
+
}`,
|
|
491
|
+
);
|
|
492
|
+
out.push("outliers above p90 in lines, fan-in, or churn");
|
|
493
|
+
for (const entry of report.outliers) {
|
|
494
|
+
out.push(
|
|
495
|
+
` ${clip(entry.path, 60).padEnd(60)} ${String(entry.lines).padStart(6)} lines · ${String(entry.exports).padStart(3)} exports · fan-in ${entry.fanIn} · ${entry.commits} commits · ${entry.test}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (report.outliers.length === 0) out.push(" none");
|
|
499
|
+
return `${out.join("\n")}\n`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Every file under `root` in a directory outside git, with the tree's own noise skipped. */
|
|
503
|
+
function walk(root: string): string[] {
|
|
504
|
+
const found: string[] = [];
|
|
505
|
+
const visit = (current: string): void => {
|
|
506
|
+
for (const name of readdirSync(current)) {
|
|
507
|
+
const path = join(current, name);
|
|
508
|
+
const stats = statSync(path);
|
|
509
|
+
if (stats.isDirectory()) {
|
|
510
|
+
if (!SKIP_DIRS.includes(name)) visit(path);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
found.push(relative(root, path).split(sep).join("/"));
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
visit(root);
|
|
517
|
+
return found;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Run one git command, returning its stdout or a reason it produced none. */
|
|
521
|
+
function git(args: readonly string[], cwd: string): { text?: string; error?: string } {
|
|
522
|
+
const result = Bun.spawnSync({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" });
|
|
523
|
+
if (result.exitCode !== 0) return { error: result.stderr.toString().trim() || `git ${args[0]} failed` };
|
|
524
|
+
return { text: result.stdout.toString() };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The paths under any of `targets`: a file by name, a directory by prefix, `.` for all. */
|
|
528
|
+
function within(path: string, targets: readonly string[]): boolean {
|
|
529
|
+
if (targets.length === 0) return true;
|
|
530
|
+
return targets.some((target) => target === "." || path === target || path.startsWith(`${target}/`));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Arguments: paths, a churn window, and a cap. A flag that needs a number says so. */
|
|
534
|
+
export function parseArgs(argv: readonly string[]): { targets: string[]; options: Options } {
|
|
535
|
+
const targets: string[] = [];
|
|
536
|
+
const options: Options = {};
|
|
537
|
+
for (let index = 0; index < argv.length; index++) {
|
|
538
|
+
const argument = argv[index];
|
|
539
|
+
const name = argument.startsWith("--churn-days")
|
|
540
|
+
? "churnDays"
|
|
541
|
+
: argument.startsWith("--limit")
|
|
542
|
+
? "limit"
|
|
543
|
+
: undefined;
|
|
544
|
+
if (name === undefined) {
|
|
545
|
+
targets.push(argument.replace(/\/+$/, ""));
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const inline = argument.split("=")[1];
|
|
549
|
+
const raw = inline ?? argv[++index];
|
|
550
|
+
const value = Number(raw);
|
|
551
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${argument.split("=")[0]} needs a positive number`);
|
|
552
|
+
options[name] = Math.floor(value);
|
|
553
|
+
}
|
|
554
|
+
return { targets: targets.length === 0 ? ["."] : targets, options };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (import.meta.main) {
|
|
558
|
+
const { targets, options } = parseArgs(process.argv.slice(2));
|
|
559
|
+
const cwd = process.cwd();
|
|
560
|
+
const inside = git(["rev-parse", "--is-inside-work-tree"], cwd).text?.trim() === "true";
|
|
561
|
+
const shallow = inside && git(["rev-parse", "--is-shallow-repository"], cwd).text?.trim() === "true";
|
|
562
|
+
const listed = inside ? git(["ls-files", "--cached", "--others", "--exclude-standard"], cwd) : undefined;
|
|
563
|
+
const note = inside
|
|
564
|
+
? shallow
|
|
565
|
+
? "shallow clone: churn history is partial"
|
|
566
|
+
: undefined
|
|
567
|
+
: "no git: ignore rules not applied, and no churn history";
|
|
568
|
+
|
|
569
|
+
const paths = (listed?.text?.split("\n") ?? walk(cwd))
|
|
570
|
+
.map((path) => path.trim())
|
|
571
|
+
.filter((path) => path !== "" && existsSync(join(cwd, ...path.split("/"))));
|
|
572
|
+
const read = (path: string): FileFacts => scanFile(path, readFileSync(join(cwd, ...path.split("/")), "utf-8"));
|
|
573
|
+
|
|
574
|
+
// The graph is repo-wide, so an edge that leaves the scope still resolves; the
|
|
575
|
+
// report itself is scoped, so a neighbour never becomes a candidate.
|
|
576
|
+
const universe = paths.filter((path) => measured(languageOf(path))).map(read);
|
|
577
|
+
const files = paths.filter((path) => within(path, targets)).map(read);
|
|
578
|
+
const window = options.churnDays ?? DEFAULT_CHURN_DAYS;
|
|
579
|
+
const log =
|
|
580
|
+
inside && !shallow
|
|
581
|
+
? git(["log", `--since=${window} days ago`, "--numstat", "--format=%H", "--", ...targets], cwd)
|
|
582
|
+
: undefined;
|
|
583
|
+
const churn = log?.text === undefined ? new Map<string, Churn>() : parseNumstat(log.text);
|
|
584
|
+
|
|
585
|
+
const report = summarize({ files, universe, knownPaths: paths, churn, options, history: log?.text !== undefined });
|
|
586
|
+
process.stdout.write(formatReport(targets.join(" "), files, report, note));
|
|
587
|
+
}
|