@copperbox/why 1.0.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 +168 -0
- package/dist/anchor.d.ts +112 -0
- package/dist/anchor.js +697 -0
- package/dist/anchors.d.ts +101 -0
- package/dist/anchors.js +223 -0
- package/dist/audit.d.ts +120 -0
- package/dist/audit.js +483 -0
- package/dist/blame.d.ts +91 -0
- package/dist/blame.js +294 -0
- package/dist/bundle.d.ts +78 -0
- package/dist/bundle.js +188 -0
- package/dist/capture.d.ts +76 -0
- package/dist/capture.js +462 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +641 -0
- package/dist/dig-state.d.ts +46 -0
- package/dist/dig-state.js +146 -0
- package/dist/dig.d.ts +125 -0
- package/dist/dig.js +380 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.js +47 -0
- package/dist/doctor.d.ts +135 -0
- package/dist/doctor.js +263 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +425 -0
- package/dist/export.d.ts +67 -0
- package/dist/export.js +106 -0
- package/dist/find-symbol.d.ts +19 -0
- package/dist/find-symbol.js +267 -0
- package/dist/git.d.ts +17 -0
- package/dist/git.js +51 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +100 -0
- package/dist/lint.d.ts +40 -0
- package/dist/lint.js +161 -0
- package/dist/serve-assets.d.ts +10 -0
- package/dist/serve-assets.js +55 -0
- package/dist/serve.d.ts +53 -0
- package/dist/serve.js +226 -0
- package/dist/trace-range.d.ts +22 -0
- package/dist/trace-range.js +216 -0
- package/package.json +44 -0
- package/ui/app.js +323 -0
- package/ui/graph.js +164 -0
- package/ui/highlight.js +202 -0
- package/ui/story-panel.d.ts +9 -0
- package/ui/story-panel.js +125 -0
- package/ui/style.css +229 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Incremental dig state (DESIGN.md §6): the
|
|
2
|
+
// high-water mark under <bundle>/.dig-state.json that lets routine
|
|
3
|
+
// `why dig --episodes` runs process only new history. The contract, in
|
|
4
|
+
// priority order:
|
|
5
|
+
//
|
|
6
|
+
// - The mark advances only after a successful episode emission, atomically
|
|
7
|
+
// (write-temp-rename), so a killed or failed run never moves it.
|
|
8
|
+
// - Deleting the file is always safe: extraction is deterministic and the
|
|
9
|
+
// dig skills update rather than duplicate, so absent state just means
|
|
10
|
+
// "re-dig everything".
|
|
11
|
+
// - A file this build cannot read (corrupt, or a newer schema version) is
|
|
12
|
+
// an explicit error and is never overwritten — even under `--full`.
|
|
13
|
+
// - A mark the repository cannot verify (unresolvable, or orphaned by a
|
|
14
|
+
// history rewrite) never yields a range; the error names the ways out.
|
|
15
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { isPlainMap } from "./bundle.js";
|
|
18
|
+
import { git, gitOrThrow } from "./git.js";
|
|
19
|
+
export const DIG_STATE_FILENAME = ".dig-state.json";
|
|
20
|
+
export const DIG_STATE_VERSION = 1;
|
|
21
|
+
export class DigStateError extends Error {
|
|
22
|
+
}
|
|
23
|
+
export function digStatePath(bundleRoot) {
|
|
24
|
+
return join(bundleRoot, DIG_STATE_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
const DELETE_HINT = "delete it to re-dig everything (extraction is deterministic; synthesis dedupes)";
|
|
27
|
+
/** Parse the state file; absent → undefined, unreadable → DigStateError. */
|
|
28
|
+
export async function readDigState(bundleRoot) {
|
|
29
|
+
const path = digStatePath(bundleRoot);
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = await readFile(path, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e.code === "ENOENT")
|
|
36
|
+
return undefined;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new DigStateError(`dig state ${path} is not valid JSON — inspect it, or ${DELETE_HINT}`);
|
|
45
|
+
}
|
|
46
|
+
if (!isPlainMap(parsed)) {
|
|
47
|
+
throw new DigStateError(`dig state ${path} is not a JSON object — inspect it, or ${DELETE_HINT}`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.version !== DIG_STATE_VERSION) {
|
|
50
|
+
throw new DigStateError(`dig state ${path} has schema version ${JSON.stringify(parsed.version)}; this build reads version ` +
|
|
51
|
+
`${DIG_STATE_VERSION} — a newer why may have written it. Inspect it, or ${DELETE_HINT}`);
|
|
52
|
+
}
|
|
53
|
+
if (!isPlainMap(parsed.branches)) {
|
|
54
|
+
throw new DigStateError(`dig state ${path} has no branches map — inspect it, or ${DELETE_HINT}`);
|
|
55
|
+
}
|
|
56
|
+
const branches = {};
|
|
57
|
+
for (const [branch, mark] of Object.entries(parsed.branches)) {
|
|
58
|
+
if (!isPlainMap(mark) || typeof mark.lastProcessed !== "string") {
|
|
59
|
+
throw new DigStateError(`dig state ${path} entry for branch "${branch}" has no lastProcessed sha — inspect it, or ${DELETE_HINT}`);
|
|
60
|
+
}
|
|
61
|
+
branches[branch] = { lastProcessed: mark.lastProcessed };
|
|
62
|
+
}
|
|
63
|
+
return { version: DIG_STATE_VERSION, branches };
|
|
64
|
+
}
|
|
65
|
+
/** Write the state atomically (temp + same-directory rename), branches sorted. */
|
|
66
|
+
export async function writeDigState(bundleRoot, state) {
|
|
67
|
+
const path = digStatePath(bundleRoot);
|
|
68
|
+
const branches = {};
|
|
69
|
+
for (const branch of Object.keys(state.branches).sort()) {
|
|
70
|
+
branches[branch] = state.branches[branch];
|
|
71
|
+
}
|
|
72
|
+
const tmp = `${path}.tmp`;
|
|
73
|
+
await writeFile(tmp, `${JSON.stringify({ version: state.version, branches }, null, 2)}\n`, "utf8");
|
|
74
|
+
await rename(tmp, path); // atomic: readers see the old file or the new, never a partial write
|
|
75
|
+
}
|
|
76
|
+
/** Resolve the range a dig run should process: mark → HEAD unless overridden. */
|
|
77
|
+
export function resolveDigRange(repo, state, overrides = {}) {
|
|
78
|
+
if (overrides.from !== undefined && overrides.full === true) {
|
|
79
|
+
throw new DigStateError("pass --from <rev> or --full, not both");
|
|
80
|
+
}
|
|
81
|
+
if (git(repo, ["rev-parse", "--show-toplevel"]).status !== 0) {
|
|
82
|
+
throw new DigStateError(`${repo} is not inside a git work tree — nothing to dig`);
|
|
83
|
+
}
|
|
84
|
+
const headResult = git(repo, ["rev-parse", "HEAD"]);
|
|
85
|
+
if (headResult.status !== 0) {
|
|
86
|
+
throw new DigStateError(`${repo} has no commits yet — nothing to dig`);
|
|
87
|
+
}
|
|
88
|
+
const head = headResult.stdout.trim();
|
|
89
|
+
const branch = gitOrThrow(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
90
|
+
let from;
|
|
91
|
+
if (overrides.from !== undefined) {
|
|
92
|
+
const r = git(repo, ["rev-parse", "--verify", "--quiet", `${overrides.from}^{commit}`]);
|
|
93
|
+
if (r.status !== 0) {
|
|
94
|
+
throw new DigStateError(`--from "${overrides.from}" does not resolve to a commit`);
|
|
95
|
+
}
|
|
96
|
+
from = r.stdout.trim();
|
|
97
|
+
}
|
|
98
|
+
else if (overrides.full !== true) {
|
|
99
|
+
const mark = state?.branches[branch]?.lastProcessed;
|
|
100
|
+
if (mark !== undefined) {
|
|
101
|
+
const recovery = `pass --full or --from <rev>, or delete ${DIG_STATE_FILENAME} to re-dig everything (synthesis dedupes)`;
|
|
102
|
+
const r = git(repo, ["rev-parse", "--verify", "--quiet", `${mark}^{commit}`]);
|
|
103
|
+
if (r.status !== 0) {
|
|
104
|
+
throw new DigStateError(`dig state records last processed commit ${mark} on branch ${branch}, which does not resolve ` +
|
|
105
|
+
`in this repository — ${recovery}`);
|
|
106
|
+
}
|
|
107
|
+
from = r.stdout.trim();
|
|
108
|
+
if (git(repo, ["merge-base", "--is-ancestor", from, head]).status !== 0) {
|
|
109
|
+
throw new DigStateError(`dig state records last processed commit ${mark} on branch ${branch}, which is not an ancestor ` +
|
|
110
|
+
`of HEAD (history rewritten?) — ${recovery}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const commits = gitOrThrow(repo, [
|
|
115
|
+
"rev-list",
|
|
116
|
+
"--reverse",
|
|
117
|
+
from === undefined ? head : `${from}..${head}`,
|
|
118
|
+
])
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter((line) => line.length > 0);
|
|
121
|
+
const range = { branch, head, commits };
|
|
122
|
+
if (from !== undefined)
|
|
123
|
+
range.from = from;
|
|
124
|
+
return range;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The only-on-success wrapper around an episode emission (the seam
|
|
128
|
+
* `why dig --episodes` extraction plugs into): resolve the range, call `emit`
|
|
129
|
+
* if there is anything in it, and advance the mark to HEAD only after `emit`
|
|
130
|
+
* returns. A throwing `emit` propagates with the state file untouched.
|
|
131
|
+
*/
|
|
132
|
+
export async function withDigState(repo, bundleRoot, overrides, emit) {
|
|
133
|
+
// Read up front even when --full/--from ignore the mark: a state file this
|
|
134
|
+
// build cannot read must fail the run before any work, or the success path
|
|
135
|
+
// below would overwrite it.
|
|
136
|
+
const state = await readDigState(bundleRoot);
|
|
137
|
+
const range = resolveDigRange(repo, state, overrides);
|
|
138
|
+
if (range.commits.length === 0)
|
|
139
|
+
return { ...range, emitted: false };
|
|
140
|
+
await emit(range);
|
|
141
|
+
await writeDigState(bundleRoot, {
|
|
142
|
+
version: DIG_STATE_VERSION,
|
|
143
|
+
branches: { ...state?.branches, [range.branch]: { lastProcessed: range.head } },
|
|
144
|
+
});
|
|
145
|
+
return { ...range, emitted: true };
|
|
146
|
+
}
|
package/dist/dig.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Operational failure (bad repo, bad range) — the CLI reports it and exits 1. */
|
|
2
|
+
export declare class DigError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare const EPISODES_SCHEMA = "why-dig-episodes";
|
|
5
|
+
export declare const EPISODES_SCHEMA_VERSION = 1;
|
|
6
|
+
export interface DigCommit {
|
|
7
|
+
sha: string;
|
|
8
|
+
/** First line of the message. */
|
|
9
|
+
subject: string;
|
|
10
|
+
/** Full commit message, trailing whitespace trimmed. */
|
|
11
|
+
message: string;
|
|
12
|
+
author: {
|
|
13
|
+
name: string;
|
|
14
|
+
email: string;
|
|
15
|
+
};
|
|
16
|
+
/** Author date, strict ISO 8601. */
|
|
17
|
+
date: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FileChurn {
|
|
20
|
+
path: string;
|
|
21
|
+
additions: number;
|
|
22
|
+
deletions: number;
|
|
23
|
+
}
|
|
24
|
+
/** How the episode boundary was found. */
|
|
25
|
+
export type EpisodeKind = "merge" | "squash" | "direct";
|
|
26
|
+
export interface RevertTell {
|
|
27
|
+
sha: string;
|
|
28
|
+
subject: string;
|
|
29
|
+
}
|
|
30
|
+
export interface FixChainTell {
|
|
31
|
+
path: string;
|
|
32
|
+
/** The fix-prefixed commits touching the path, oldest first. */
|
|
33
|
+
shas: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface SuddenChurnTell {
|
|
36
|
+
path: string;
|
|
37
|
+
/** additions + deletions on the path in this episode. */
|
|
38
|
+
churn: number;
|
|
39
|
+
zScore: number;
|
|
40
|
+
}
|
|
41
|
+
export interface CommentTell {
|
|
42
|
+
sha: string;
|
|
43
|
+
path: string;
|
|
44
|
+
/** The added line, `+` stripped and trimmed. */
|
|
45
|
+
line: string;
|
|
46
|
+
}
|
|
47
|
+
export interface EpisodeTells {
|
|
48
|
+
reverts: RevertTell[];
|
|
49
|
+
fixChains: FixChainTell[];
|
|
50
|
+
suddenChurn: SuddenChurnTell[];
|
|
51
|
+
/** `count` is always the true total; `sample` is capped (never silently). */
|
|
52
|
+
commentTells: {
|
|
53
|
+
count: number;
|
|
54
|
+
sample: CommentTell[];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface Episode {
|
|
58
|
+
/** Unique, stable: the episode's newest commit sha, abbreviated to 12. */
|
|
59
|
+
id: string;
|
|
60
|
+
kind: EpisodeKind;
|
|
61
|
+
/** PR number parsed from the merge/squash boundary commit, if any. */
|
|
62
|
+
pr: number | null;
|
|
63
|
+
/** Oldest first; for `merge` episodes the merge commit itself is last. */
|
|
64
|
+
commits: DigCommit[];
|
|
65
|
+
/** Per-path churn summed over the episode's commits, sorted by path. */
|
|
66
|
+
files: FileChurn[];
|
|
67
|
+
/** PR/issue numbers referenced in commit messages, sorted unique. */
|
|
68
|
+
refs: number[];
|
|
69
|
+
/** Min/max author date over the episode's commits. */
|
|
70
|
+
dates: {
|
|
71
|
+
start: string;
|
|
72
|
+
end: string;
|
|
73
|
+
};
|
|
74
|
+
tells: EpisodeTells;
|
|
75
|
+
}
|
|
76
|
+
export interface TellCounts {
|
|
77
|
+
count: number;
|
|
78
|
+
/** Ids of episodes carrying at least one tell of this kind. */
|
|
79
|
+
episodes: string[];
|
|
80
|
+
}
|
|
81
|
+
export interface TellsSummary {
|
|
82
|
+
reverts: TellCounts;
|
|
83
|
+
fixChains: TellCounts;
|
|
84
|
+
suddenChurn: TellCounts;
|
|
85
|
+
commentTells: TellCounts;
|
|
86
|
+
}
|
|
87
|
+
export interface EpisodesReport {
|
|
88
|
+
schema: typeof EPISODES_SCHEMA;
|
|
89
|
+
version: typeof EPISODES_SCHEMA_VERSION;
|
|
90
|
+
/** Repository the walk ran in (git toplevel). */
|
|
91
|
+
repo: string;
|
|
92
|
+
/** Resolved range: commits in `from..to`; from null = full history to `to`. */
|
|
93
|
+
range: {
|
|
94
|
+
from: string | null;
|
|
95
|
+
to: string;
|
|
96
|
+
};
|
|
97
|
+
/** Oldest first (first-parent order) — synthesis wants causal order. */
|
|
98
|
+
episodes: Episode[];
|
|
99
|
+
tells: TellsSummary;
|
|
100
|
+
}
|
|
101
|
+
/** Direct commits by the same author within this window may share an episode. */
|
|
102
|
+
export declare const CLUSTER_WINDOW_MS: number;
|
|
103
|
+
/** Trailing episodes considered when scoring a file's churn. */
|
|
104
|
+
export declare const CHURN_WINDOW = 20;
|
|
105
|
+
/** A file must be at least this many episodes old before churn can be "sudden". */
|
|
106
|
+
export declare const CHURN_MIN_AGE = 5;
|
|
107
|
+
/** z-score at or above which churn on an old-quiet file is flagged. */
|
|
108
|
+
export declare const CHURN_Z_THRESHOLD = 3;
|
|
109
|
+
/** Comment-tell samples kept per episode (count stays exact). */
|
|
110
|
+
export declare const COMMENT_TELL_SAMPLE_CAP = 20;
|
|
111
|
+
/** Added diff lines matching this are comment tells (issue-defined pattern). */
|
|
112
|
+
export declare const COMMENT_TELL_RE: RegExp;
|
|
113
|
+
export interface DigOptions {
|
|
114
|
+
/** Exclusive start of the range; omitted = full history. */
|
|
115
|
+
from?: string;
|
|
116
|
+
/** Inclusive end of the range; default HEAD. */
|
|
117
|
+
to?: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Extract episodes + tells over `from..to` (full history to `to` when `from`
|
|
121
|
+
* is omitted). `repo` must be inside a git work tree with at least one commit.
|
|
122
|
+
*/
|
|
123
|
+
export declare function extractEpisodes(repo: string, options?: DigOptions): EpisodesReport;
|
|
124
|
+
export declare function plural(n: number, noun: string): string;
|
|
125
|
+
export declare function renderEpisodesReport(report: EpisodesReport): string[];
|
package/dist/dig.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// `why dig --episodes` (DESIGN.md §6 step 1): the deterministic half of
|
|
2
|
+
// archaeology. Walks git history over a ref range and clusters commits into
|
|
3
|
+
// candidate episodes — merge/PR boundaries first, then temporal + file-overlap
|
|
4
|
+
// clustering for direct commits — and flags *tells* (reverts, fix-chains,
|
|
5
|
+
// sudden churn on old-quiet files, comment tells) that mark high-value dig
|
|
6
|
+
// sites. Emits a stable JSON report (schema: docs/dig-episodes.md).
|
|
7
|
+
//
|
|
8
|
+
// Everything here is deterministic over (repo state, range): no wall-clock
|
|
9
|
+
// timestamps, stable ordering everywhere — reports get cached and diffed.
|
|
10
|
+
// Judgment (what an episode *means*) belongs to the agent steps, never here.
|
|
11
|
+
import { git, gitOrThrow } from "./git.js";
|
|
12
|
+
/** Operational failure (bad repo, bad range) — the CLI reports it and exits 1. */
|
|
13
|
+
export class DigError extends Error {
|
|
14
|
+
}
|
|
15
|
+
// --- Report shape (the stable JSON surface; see docs/dig-episodes.md) -------
|
|
16
|
+
export const EPISODES_SCHEMA = "why-dig-episodes";
|
|
17
|
+
export const EPISODES_SCHEMA_VERSION = 1;
|
|
18
|
+
// --- Tunables (documented in docs/dig-episodes.md; changing one is a
|
|
19
|
+
// --- schema-version conversation, not a quiet edit) --------------------------
|
|
20
|
+
/** Direct commits by the same author within this window may share an episode. */
|
|
21
|
+
export const CLUSTER_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
22
|
+
/** Trailing episodes considered when scoring a file's churn. */
|
|
23
|
+
export const CHURN_WINDOW = 20;
|
|
24
|
+
/** A file must be at least this many episodes old before churn can be "sudden". */
|
|
25
|
+
export const CHURN_MIN_AGE = 5;
|
|
26
|
+
/** z-score at or above which churn on an old-quiet file is flagged. */
|
|
27
|
+
export const CHURN_Z_THRESHOLD = 3;
|
|
28
|
+
/** Comment-tell samples kept per episode (count stays exact). */
|
|
29
|
+
export const COMMENT_TELL_SAMPLE_CAP = 20;
|
|
30
|
+
const MERGE_PR_RE = /^Merge pull request #(\d+)/;
|
|
31
|
+
const SQUASH_PR_RE = /\(#(\d+)\)\s*$/;
|
|
32
|
+
const REVERT_RE = /^Revert "/;
|
|
33
|
+
const FIX_RE = /^fix/i;
|
|
34
|
+
/** Added diff lines matching this are comment tells (issue-defined pattern). */
|
|
35
|
+
export const COMMENT_TELL_RE = /\b(hack|workaround|do not|don't|because|temporarily)\b/i;
|
|
36
|
+
const REF_RE = /(?:^|[^\w&])#(\d+)\b|(?:issues|pull)\/(\d+)\b/g;
|
|
37
|
+
const C = "\x01"; // commit separator
|
|
38
|
+
const F = "\x1f"; // field separator
|
|
39
|
+
const E = "\x1e"; // end of header
|
|
40
|
+
function resolveCommit(repo, rev, what) {
|
|
41
|
+
const r = git(repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
|
42
|
+
if (r.status !== 0) {
|
|
43
|
+
throw new DigError(`${what} "${rev}" does not resolve to a commit`);
|
|
44
|
+
}
|
|
45
|
+
return r.stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
/** Read every commit in the range: metadata + per-file churn, one git call. */
|
|
48
|
+
function readCommits(repo, range) {
|
|
49
|
+
const out = gitOrThrow(repo, [
|
|
50
|
+
"log",
|
|
51
|
+
"--no-renames",
|
|
52
|
+
"--numstat",
|
|
53
|
+
`--format=${C}%H${F}%an${F}%ae${F}%aI${F}%B${E}`,
|
|
54
|
+
...range,
|
|
55
|
+
]);
|
|
56
|
+
const commits = new Map();
|
|
57
|
+
for (const chunk of out.split(C).slice(1)) {
|
|
58
|
+
const end = chunk.indexOf(E);
|
|
59
|
+
const [sha, name, email, date, message] = chunk.slice(0, end).split(F);
|
|
60
|
+
const files = [];
|
|
61
|
+
for (const line of chunk.slice(end + 1).split("\n")) {
|
|
62
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
63
|
+
if (m === null)
|
|
64
|
+
continue;
|
|
65
|
+
// Binary files numstat as "-"; count their churn as 0 but keep the path.
|
|
66
|
+
files.push({
|
|
67
|
+
path: m[3],
|
|
68
|
+
additions: m[1] === "-" ? 0 : Number(m[1]),
|
|
69
|
+
deletions: m[2] === "-" ? 0 : Number(m[2]),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const trimmed = message.trimEnd();
|
|
73
|
+
commits.set(sha, {
|
|
74
|
+
sha,
|
|
75
|
+
subject: trimmed.split("\n", 1)[0],
|
|
76
|
+
message: trimmed,
|
|
77
|
+
author: { name, email },
|
|
78
|
+
date,
|
|
79
|
+
files,
|
|
80
|
+
commentTells: [],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return commits;
|
|
84
|
+
}
|
|
85
|
+
/** Second pass: scan added diff lines for comment tells. */
|
|
86
|
+
function scanCommentTells(repo, range, commits) {
|
|
87
|
+
const out = gitOrThrow(repo, ["log", "--no-renames", "-p", `--format=${C}%H`, ...range]);
|
|
88
|
+
for (const chunk of out.split(C).slice(1)) {
|
|
89
|
+
const lines = chunk.split("\n");
|
|
90
|
+
const commit = commits.get(lines[0].trim());
|
|
91
|
+
if (commit === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
let path = null;
|
|
94
|
+
for (const line of lines.slice(1)) {
|
|
95
|
+
const target = /^\+\+\+ (?:b\/(.*)|\/dev\/null)$/.exec(line);
|
|
96
|
+
if (target !== null) {
|
|
97
|
+
path = target[1] ?? null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (path === null || !line.startsWith("+") || line.startsWith("+++"))
|
|
101
|
+
continue;
|
|
102
|
+
if (COMMENT_TELL_RE.test(line)) {
|
|
103
|
+
commit.commentTells.push({ sha: commit.sha, path, line: line.slice(1).trim() });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Cluster a contiguous run of direct commits: same author, <48h, shared paths. */
|
|
109
|
+
function clusterDirectRun(run) {
|
|
110
|
+
const clusters = [];
|
|
111
|
+
let current = [];
|
|
112
|
+
let paths = new Set();
|
|
113
|
+
let lastMs = 0;
|
|
114
|
+
const flush = () => {
|
|
115
|
+
if (current.length > 0)
|
|
116
|
+
clusters.push({ kind: "direct", pr: null, commits: current });
|
|
117
|
+
current = [];
|
|
118
|
+
paths = new Set();
|
|
119
|
+
lastMs = 0; // author dates are not monotonic (rebases) — never leak across clusters
|
|
120
|
+
};
|
|
121
|
+
for (const commit of run) {
|
|
122
|
+
const ms = Date.parse(commit.date);
|
|
123
|
+
// abs(): walk order is topological, so a rebased commit can carry an
|
|
124
|
+
// author date older than the cluster tip — "apart" cuts both ways.
|
|
125
|
+
const joins = current.length > 0 &&
|
|
126
|
+
commit.author.email === current[0].author.email &&
|
|
127
|
+
Math.abs(ms - lastMs) < CLUSTER_WINDOW_MS &&
|
|
128
|
+
commit.files.some((f) => paths.has(f.path));
|
|
129
|
+
if (!joins)
|
|
130
|
+
flush();
|
|
131
|
+
current.push(commit);
|
|
132
|
+
for (const f of commit.files)
|
|
133
|
+
paths.add(f.path);
|
|
134
|
+
lastMs = Math.max(lastMs, ms);
|
|
135
|
+
}
|
|
136
|
+
flush();
|
|
137
|
+
return clusters;
|
|
138
|
+
}
|
|
139
|
+
/** Walk the first-parent chain of the range and group commits into episodes. */
|
|
140
|
+
function groupEpisodes(repo, range, commits) {
|
|
141
|
+
const walk = gitOrThrow(repo, ["rev-list", "--first-parent", "--reverse", "--parents", ...range])
|
|
142
|
+
.split("\n")
|
|
143
|
+
.filter((l) => l !== "")
|
|
144
|
+
.map((l) => l.split(" "));
|
|
145
|
+
const episodes = [];
|
|
146
|
+
const assigned = new Set();
|
|
147
|
+
let directRun = [];
|
|
148
|
+
const flushRun = () => {
|
|
149
|
+
episodes.push(...clusterDirectRun(directRun));
|
|
150
|
+
directRun = [];
|
|
151
|
+
};
|
|
152
|
+
const take = (sha) => {
|
|
153
|
+
const c = commits.get(sha);
|
|
154
|
+
if (c === undefined || assigned.has(sha))
|
|
155
|
+
return undefined;
|
|
156
|
+
assigned.add(sha);
|
|
157
|
+
return c;
|
|
158
|
+
};
|
|
159
|
+
for (const [sha, ...parents] of walk) {
|
|
160
|
+
const commit = take(sha);
|
|
161
|
+
if (commit === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
if (parents.length >= 2) {
|
|
164
|
+
flushRun();
|
|
165
|
+
// Side-branch commits: reachable from the merged parents, not mainline.
|
|
166
|
+
const side = gitOrThrow(repo, [
|
|
167
|
+
"rev-list",
|
|
168
|
+
"--reverse",
|
|
169
|
+
...parents.slice(1),
|
|
170
|
+
"--not",
|
|
171
|
+
parents[0],
|
|
172
|
+
])
|
|
173
|
+
.split("\n")
|
|
174
|
+
.filter((s) => s !== "")
|
|
175
|
+
.map(take)
|
|
176
|
+
.filter((c) => c !== undefined);
|
|
177
|
+
const pr = MERGE_PR_RE.exec(commit.subject)?.[1];
|
|
178
|
+
episodes.push({
|
|
179
|
+
kind: "merge",
|
|
180
|
+
pr: pr === undefined ? null : Number(pr),
|
|
181
|
+
commits: [...side, commit],
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const squash = SQUASH_PR_RE.exec(commit.subject)?.[1];
|
|
186
|
+
if (squash !== undefined) {
|
|
187
|
+
flushRun();
|
|
188
|
+
episodes.push({ kind: "squash", pr: Number(squash), commits: [commit] });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
directRun.push(commit);
|
|
192
|
+
}
|
|
193
|
+
flushRun();
|
|
194
|
+
return episodes;
|
|
195
|
+
}
|
|
196
|
+
function extractRefs(commits) {
|
|
197
|
+
const refs = new Set();
|
|
198
|
+
for (const c of commits) {
|
|
199
|
+
for (const m of c.message.matchAll(REF_RE))
|
|
200
|
+
refs.add(Number(m[1] ?? m[2]));
|
|
201
|
+
}
|
|
202
|
+
return [...refs].sort((a, b) => a - b);
|
|
203
|
+
}
|
|
204
|
+
function sumChurn(commits) {
|
|
205
|
+
const byPath = new Map();
|
|
206
|
+
for (const c of commits) {
|
|
207
|
+
for (const f of c.files) {
|
|
208
|
+
const entry = byPath.get(f.path) ?? { path: f.path, additions: 0, deletions: 0 };
|
|
209
|
+
entry.additions += f.additions;
|
|
210
|
+
entry.deletions += f.deletions;
|
|
211
|
+
byPath.set(f.path, entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
215
|
+
}
|
|
216
|
+
function fixChains(commits) {
|
|
217
|
+
const byPath = new Map();
|
|
218
|
+
for (const c of commits) {
|
|
219
|
+
if (!FIX_RE.test(c.subject))
|
|
220
|
+
continue;
|
|
221
|
+
for (const f of c.files) {
|
|
222
|
+
byPath.set(f.path, [...(byPath.get(f.path) ?? []), c.sha]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return [...byPath.entries()]
|
|
226
|
+
.filter(([, shas]) => shas.length >= 2)
|
|
227
|
+
.map(([path, shas]) => ({ path, shas }))
|
|
228
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Sudden churn on old-quiet files: for each (episode, file), z-score the
|
|
232
|
+
* episode's churn against the file's churn over the trailing CHURN_WINDOW
|
|
233
|
+
* episodes (0 when untouched). Only files first seen at least CHURN_MIN_AGE
|
|
234
|
+
* episodes earlier qualify — brand-new files are always "sudden" and never
|
|
235
|
+
* interesting. The stddev floor of 1 keeps a dead-quiet history from making
|
|
236
|
+
* every touch infinitely surprising.
|
|
237
|
+
*/
|
|
238
|
+
function markSuddenChurn(episodes) {
|
|
239
|
+
const firstSeen = new Map();
|
|
240
|
+
const history = [];
|
|
241
|
+
episodes.forEach((ep, i) => {
|
|
242
|
+
const churn = new Map();
|
|
243
|
+
for (const f of ep.files) {
|
|
244
|
+
churn.set(f.path, f.additions + f.deletions);
|
|
245
|
+
if (!firstSeen.has(f.path))
|
|
246
|
+
firstSeen.set(f.path, i);
|
|
247
|
+
}
|
|
248
|
+
history.push(churn);
|
|
249
|
+
});
|
|
250
|
+
episodes.forEach((ep, i) => {
|
|
251
|
+
for (const f of ep.files) {
|
|
252
|
+
if (i - firstSeen.get(f.path) < CHURN_MIN_AGE)
|
|
253
|
+
continue;
|
|
254
|
+
const window = [];
|
|
255
|
+
for (let j = Math.max(0, i - CHURN_WINDOW); j < i; j++) {
|
|
256
|
+
window.push(history[j].get(f.path) ?? 0);
|
|
257
|
+
}
|
|
258
|
+
const mean = window.reduce((a, b) => a + b, 0) / window.length;
|
|
259
|
+
const variance = window.reduce((a, b) => a + (b - mean) ** 2, 0) / window.length;
|
|
260
|
+
const sigma = Math.max(Math.sqrt(variance), 1);
|
|
261
|
+
const churn = f.additions + f.deletions;
|
|
262
|
+
const z = (churn - mean) / sigma;
|
|
263
|
+
if (z >= CHURN_Z_THRESHOLD) {
|
|
264
|
+
ep.tells.suddenChurn.push({ path: f.path, churn, zScore: Number(z.toFixed(2)) });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function buildEpisode(proto) {
|
|
270
|
+
const commits = proto.commits;
|
|
271
|
+
// Chronological, not lexicographic — ISO dates carry varying UTC offsets.
|
|
272
|
+
const dates = commits.map((c) => c.date).sort((a, b) => Date.parse(a) - Date.parse(b));
|
|
273
|
+
const allTells = commits.flatMap((c) => c.commentTells);
|
|
274
|
+
return {
|
|
275
|
+
id: commits[commits.length - 1].sha.slice(0, 12),
|
|
276
|
+
kind: proto.kind,
|
|
277
|
+
pr: proto.pr,
|
|
278
|
+
commits: commits.map(({ sha, subject, message, author, date }) => ({
|
|
279
|
+
sha,
|
|
280
|
+
subject,
|
|
281
|
+
message,
|
|
282
|
+
author,
|
|
283
|
+
date,
|
|
284
|
+
})),
|
|
285
|
+
files: sumChurn(commits),
|
|
286
|
+
refs: extractRefs(commits),
|
|
287
|
+
dates: { start: dates[0], end: dates[dates.length - 1] },
|
|
288
|
+
tells: {
|
|
289
|
+
reverts: commits
|
|
290
|
+
.filter((c) => REVERT_RE.test(c.subject))
|
|
291
|
+
.map((c) => ({ sha: c.sha, subject: c.subject })),
|
|
292
|
+
fixChains: fixChains(commits),
|
|
293
|
+
suddenChurn: [],
|
|
294
|
+
commentTells: { count: allTells.length, sample: allTells.slice(0, COMMENT_TELL_SAMPLE_CAP) },
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function summarizeTells(episodes) {
|
|
299
|
+
const summarize = (count) => ({
|
|
300
|
+
count: episodes.reduce((n, e) => n + count(e), 0),
|
|
301
|
+
episodes: episodes.filter((e) => count(e) > 0).map((e) => e.id),
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
reverts: summarize((e) => e.tells.reverts.length),
|
|
305
|
+
fixChains: summarize((e) => e.tells.fixChains.length),
|
|
306
|
+
suddenChurn: summarize((e) => e.tells.suddenChurn.length),
|
|
307
|
+
commentTells: summarize((e) => e.tells.commentTells.count),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Extract episodes + tells over `from..to` (full history to `to` when `from`
|
|
312
|
+
* is omitted). `repo` must be inside a git work tree with at least one commit.
|
|
313
|
+
*/
|
|
314
|
+
export function extractEpisodes(repo, options = {}) {
|
|
315
|
+
const toplevel = git(repo, ["rev-parse", "--show-toplevel"]);
|
|
316
|
+
if (toplevel.status !== 0) {
|
|
317
|
+
throw new DigError(`${repo} is not inside a git repository`);
|
|
318
|
+
}
|
|
319
|
+
const root = toplevel.stdout.trim();
|
|
320
|
+
const to = resolveCommit(root, options.to ?? "HEAD", "range end");
|
|
321
|
+
let from = null;
|
|
322
|
+
if (options.from !== undefined) {
|
|
323
|
+
from = resolveCommit(root, options.from, "range start");
|
|
324
|
+
if (git(root, ["merge-base", "--is-ancestor", from, to]).status !== 0) {
|
|
325
|
+
throw new DigError(`range start ${options.from} is not an ancestor of ${options.to ?? "HEAD"}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const range = from === null ? [to] : [`${from}..${to}`];
|
|
329
|
+
const commits = readCommits(root, range);
|
|
330
|
+
scanCommentTells(root, range, commits);
|
|
331
|
+
const episodes = groupEpisodes(root, range, commits).map(buildEpisode);
|
|
332
|
+
markSuddenChurn(episodes);
|
|
333
|
+
return {
|
|
334
|
+
schema: EPISODES_SCHEMA,
|
|
335
|
+
version: EPISODES_SCHEMA_VERSION,
|
|
336
|
+
repo: root,
|
|
337
|
+
range: { from, to },
|
|
338
|
+
episodes,
|
|
339
|
+
tells: summarizeTells(episodes),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// --- Rendering ---------------------------------------------------------------
|
|
343
|
+
function day(iso) {
|
|
344
|
+
return iso.slice(0, 10);
|
|
345
|
+
}
|
|
346
|
+
export function plural(n, noun) {
|
|
347
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
348
|
+
}
|
|
349
|
+
function tellBadges(e) {
|
|
350
|
+
const badges = [];
|
|
351
|
+
if (e.tells.reverts.length > 0)
|
|
352
|
+
badges.push(`revert×${e.tells.reverts.length}`);
|
|
353
|
+
if (e.tells.fixChains.length > 0)
|
|
354
|
+
badges.push(`fix-chain×${e.tells.fixChains.length}`);
|
|
355
|
+
if (e.tells.suddenChurn.length > 0)
|
|
356
|
+
badges.push(`sudden-churn×${e.tells.suddenChurn.length}`);
|
|
357
|
+
if (e.tells.commentTells.count > 0)
|
|
358
|
+
badges.push(`comment×${e.tells.commentTells.count}`);
|
|
359
|
+
return badges.length === 0 ? "" : ` tells: ${badges.join(", ")}`;
|
|
360
|
+
}
|
|
361
|
+
export function renderEpisodesReport(report) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
const commits = report.episodes.reduce((n, e) => n + e.commits.length, 0);
|
|
364
|
+
const from = report.range.from === null ? "full history" : `${report.range.from.slice(0, 12)}..`;
|
|
365
|
+
lines.push(`why dig --episodes: ${plural(report.episodes.length, "episode")}, ` +
|
|
366
|
+
`${plural(commits, "commit")} (${from} → ${report.range.to.slice(0, 12)})`);
|
|
367
|
+
lines.push("");
|
|
368
|
+
for (const e of report.episodes) {
|
|
369
|
+
const span = e.dates.start === e.dates.end ? day(e.dates.start) : `${day(e.dates.start)}..${day(e.dates.end)}`;
|
|
370
|
+
const pr = e.pr === null ? "" : ` #${e.pr}`;
|
|
371
|
+
lines.push(` ${e.id} ${e.kind}${pr} ${span} ${plural(e.commits.length, "commit")}, ` +
|
|
372
|
+
`${plural(e.files.length, "file")}${tellBadges(e)}`);
|
|
373
|
+
}
|
|
374
|
+
lines.push("");
|
|
375
|
+
const t = report.tells;
|
|
376
|
+
lines.push(`tells: ${plural(t.reverts.count, "revert")}, ${plural(t.fixChains.count, "fix-chain")}, ` +
|
|
377
|
+
`${t.suddenChurn.count} sudden-churn, ${plural(t.commentTells.count, "comment tell")}`);
|
|
378
|
+
lines.push("(full data: --json or --out <file>; schema: docs/dig-episodes.md)");
|
|
379
|
+
return lines;
|
|
380
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const BUNDLE_DIRNAME = ".why";
|
|
2
|
+
/** A command needed a bundle and none could be found. Rendered, not a crash. */
|
|
3
|
+
export declare class BundleNotFoundError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Walk up from startDir to the filesystem root; nearest `.why/` wins. */
|
|
6
|
+
export declare function findBundleRoot(startDir: string): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The bundle root a command should use: an explicit `--bundle` path (resolved
|
|
9
|
+
* against cwd, must exist) or the nearest `.why/` above cwd.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveBundleRoot(cwd: string, override?: string): string;
|