@esneiderbravo/speclaw 0.2.1 → 0.3.1
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/dist/modules/compass/db.js +10 -1
- package/dist/modules/compass/git-history-cache.js +65 -0
- package/dist/modules/foundation/assets/docs/standards/lawbook.template.md +3 -1
- package/dist/modules/foundation/assets/docs/standards/testing-standards.template.md +12 -3
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +78 -0
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +5 -3
- package/dist/modules/lawbook/assets/skills/build/SKILL.md +15 -3
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +5 -2
- package/dist/modules/lawbook/engine.js +1 -1
- package/dist/shared/git-history.js +204 -0
- package/package.json +1 -1
|
@@ -47,9 +47,17 @@ CREATE TABLE IF NOT EXISTS node_embeddings (
|
|
|
47
47
|
model TEXT NOT NULL,
|
|
48
48
|
vec BLOB NOT NULL
|
|
49
49
|
);
|
|
50
|
+
-- git_history_cache: memoized results of the expensive git-history scans
|
|
51
|
+
-- (churn, co-change), keyed by query and invalidated when HEAD moves.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS git_history_cache (
|
|
53
|
+
query_key TEXT PRIMARY KEY,
|
|
54
|
+
head_sha TEXT NOT NULL,
|
|
55
|
+
payload TEXT NOT NULL,
|
|
56
|
+
computed_at INTEGER NOT NULL
|
|
57
|
+
);
|
|
50
58
|
`;
|
|
51
59
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
52
|
-
export const SCHEMA_VERSION = "
|
|
60
|
+
export const SCHEMA_VERSION = "4";
|
|
53
61
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
54
62
|
function readSchemaVersion(db) {
|
|
55
63
|
try {
|
|
@@ -81,6 +89,7 @@ function isStale(db) {
|
|
|
81
89
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
82
90
|
function resetSchema(db) {
|
|
83
91
|
db.exec(`
|
|
92
|
+
DROP TABLE IF EXISTS git_history_cache;
|
|
84
93
|
DROP TABLE IF EXISTS node_embeddings;
|
|
85
94
|
DROP TABLE IF EXISTS edges;
|
|
86
95
|
DROP TABLE IF EXISTS nodes;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { churn, coChanges, headSha, } from "../../shared/git-history.js";
|
|
2
|
+
import { openDb } from "./db.js";
|
|
3
|
+
/**
|
|
4
|
+
* Look up a cached payload valid at the current HEAD, or compute it and store it.
|
|
5
|
+
*
|
|
6
|
+
* When `head` is `null` (no commits / not a repo) the cache is bypassed entirely
|
|
7
|
+
* and `compute()` runs directly, so an empty repo never poisons the cache.
|
|
8
|
+
*
|
|
9
|
+
* @param projectPath - Project root, whose `.speclaw/index.db` holds the cache.
|
|
10
|
+
* @param head - The current HEAD SHA, or `null` when there is none.
|
|
11
|
+
* @param queryKey - Stable key identifying this query (function + options).
|
|
12
|
+
* @param compute - Produces the fresh result on a miss.
|
|
13
|
+
* @param serialize - Turns the result into a JSON-safe payload string.
|
|
14
|
+
* @param deserialize - Rebuilds the result from a stored payload string.
|
|
15
|
+
* @returns The cached-or-freshly-computed result.
|
|
16
|
+
*/
|
|
17
|
+
function readThrough(projectPath, head, queryKey, compute, serialize, deserialize) {
|
|
18
|
+
if (head === null)
|
|
19
|
+
return compute();
|
|
20
|
+
const db = openDb(projectPath);
|
|
21
|
+
try {
|
|
22
|
+
const row = db
|
|
23
|
+
.prepare("SELECT head_sha, payload FROM git_history_cache WHERE query_key = ?")
|
|
24
|
+
.get(queryKey);
|
|
25
|
+
if (row && row.head_sha === head) {
|
|
26
|
+
return deserialize(row.payload);
|
|
27
|
+
}
|
|
28
|
+
const value = compute();
|
|
29
|
+
db.prepare(`INSERT INTO git_history_cache(query_key, head_sha, payload, computed_at)
|
|
30
|
+
VALUES (?, ?, ?, 0)
|
|
31
|
+
ON CONFLICT(query_key) DO UPDATE SET
|
|
32
|
+
head_sha = excluded.head_sha,
|
|
33
|
+
payload = excluded.payload,
|
|
34
|
+
computed_at = excluded.computed_at`).run(queryKey, head, serialize(value));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* {@link churn}, memoized in the Compass index until `HEAD` moves.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Project root to query.
|
|
45
|
+
* @param opts - Same options as {@link churn}.
|
|
46
|
+
* @returns Per-path change counts and the shallow marker, cached per HEAD.
|
|
47
|
+
*/
|
|
48
|
+
export function cachedChurn(projectPath, opts = {}) {
|
|
49
|
+
const key = `churn:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
|
|
50
|
+
return readThrough(projectPath, headSha(projectPath), key, () => churn(projectPath, opts), (value) => JSON.stringify({ shallow: value.shallow, byPath: [...value.byPath] }), (payload) => {
|
|
51
|
+
const parsed = JSON.parse(payload);
|
|
52
|
+
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link coChanges}, memoized in the Compass index until `HEAD` moves.
|
|
57
|
+
*
|
|
58
|
+
* @param projectPath - Project root to query.
|
|
59
|
+
* @param opts - Same options as {@link coChanges}.
|
|
60
|
+
* @returns The co-change pairs and the shallow marker, cached per HEAD.
|
|
61
|
+
*/
|
|
62
|
+
export function cachedCoChanges(projectPath, opts = {}) {
|
|
63
|
+
const key = `coChanges:${JSON.stringify({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
|
|
64
|
+
return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
|
|
65
|
+
}
|
|
@@ -32,7 +32,9 @@ itself — never delegates it.
|
|
|
32
32
|
## Reports
|
|
33
33
|
|
|
34
34
|
Every change carries a `reports/` folder. `build` writes one report per
|
|
35
|
-
discipline it touched (`backend.md`,
|
|
35
|
+
discipline it touched, named for that discipline — an open set (`backend.md`,
|
|
36
|
+
`frontend.md`, `api.md`, `database.md`, `infra.md`, … — `api.md` required
|
|
37
|
+
whenever the change touches an API surface) recording what was tested
|
|
36
38
|
and the real results — unit, integration, and end-to-end as applicable — with
|
|
37
39
|
the commands run and their output. It is evidence of testing that travels with
|
|
38
40
|
the change; the archive is blocked until at least one discipline report exists.
|
|
@@ -45,9 +45,18 @@ suppressing a linter or deleting a test.
|
|
|
45
45
|
## Reports — evidence travels with the change
|
|
46
46
|
|
|
47
47
|
Every change records its testing under `lawbook/changes/<name>/reports/`, one
|
|
48
|
-
file per discipline it touched
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
file per discipline it touched, named for that discipline. The set is open, not
|
|
49
|
+
fixed: `backend.md`, `frontend.md`, and `api.md` are the common ones, but write
|
|
50
|
+
`database.md`, `infra.md`, `security.md`, `performance.md`, `e2e.md`, etc. when
|
|
51
|
+
the change exercises those concerns. `build` produces them; archiving is blocked
|
|
52
|
+
until the change has at least one discipline report.
|
|
53
|
+
|
|
54
|
+
`api.md` is **mandatory whenever the change touches an API surface** — a new or
|
|
55
|
+
modified endpoint, its contract, its status codes, or its auth/permission or
|
|
56
|
+
ordering guarantees — and a `backend.md` unit report does not substitute for it.
|
|
57
|
+
It documents the method and path, the auth/permissions, the response shape and
|
|
58
|
+
every status code the change governs, any ordering guarantee, and how the
|
|
59
|
+
contract was exercised (test client and/or `curl`) kept isolated from live data.
|
|
51
60
|
|
|
52
61
|
Each report MUST follow a fixed structure, so the evidence is reproducible rather
|
|
53
62
|
than improvised:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Require one test report per discipline a change touches (an open set — backend, frontend, api, database, infra, security, performance, e2e, …), with the api report mandatory whenever the change touches an API surface.
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Spec Reports: Discipline Coverage
|
|
7
|
+
|
|
8
|
+
A change's evidence of testing lives under `lawbook/changes/<name>/reports/`, as
|
|
9
|
+
**one file per discipline the change actually touched**, named for that
|
|
10
|
+
discipline (`<discipline>.md`) — never a single lumped report.
|
|
11
|
+
|
|
12
|
+
## 1. The set of disciplines is open — name what the change touched
|
|
13
|
+
|
|
14
|
+
There is no fixed list. Write a report for each area of concern the change
|
|
15
|
+
exercised, and omit the ones it did not. Common disciplines include, but are
|
|
16
|
+
**not limited to**:
|
|
17
|
+
|
|
18
|
+
- **`backend.md`** — domain/service logic, persistence, jobs.
|
|
19
|
+
- **`frontend.md`** — UI flows, components, client state.
|
|
20
|
+
- **`api.md`** — the endpoint contract itself (see §2).
|
|
21
|
+
- **`database.md`** — schema changes, migrations, data integrity.
|
|
22
|
+
- **`infra.md`** — IaC, CI/CD pipelines, deployment/runtime config.
|
|
23
|
+
- **`security.md`** — authn/authz, secrets, attack surface.
|
|
24
|
+
- **`performance.md`** — benchmarks, load, latency budgets.
|
|
25
|
+
- **`e2e.md`** — full cross-service or cross-layer journeys.
|
|
26
|
+
- **`mobile.md`**, **`contract.md`**, **`data.md`**, **`accessibility.md`**, …
|
|
27
|
+
|
|
28
|
+
When a change touches a concern none of these names fit, coin a clear
|
|
29
|
+
`<discipline>.md` for it rather than folding it into an ill-fitting bucket. The
|
|
30
|
+
goal is that the evidence a reviewer needs for each concern is where they expect
|
|
31
|
+
it — not that reports match a checklist.
|
|
32
|
+
|
|
33
|
+
## 2. The API report is mandatory whenever an API surface is touched
|
|
34
|
+
|
|
35
|
+
`api` is called out because it is the discipline most often (wrongly) absorbed
|
|
36
|
+
into `backend` and lost. When a change **adds or modifies an API surface** — a
|
|
37
|
+
new or changed endpoint, its request/response contract, its status codes, or its
|
|
38
|
+
auth/permission or ordering guarantees — you MUST write `api.md`. This is not
|
|
39
|
+
optional:
|
|
40
|
+
|
|
41
|
+
- A `backend.md` unit report does **not** substitute for it — the contract
|
|
42
|
+
(shape, status codes, auth, ordering) is a distinct concern a service-unit
|
|
43
|
+
report does not capture.
|
|
44
|
+
- A `frontend.md` report does **not** substitute for it either.
|
|
45
|
+
|
|
46
|
+
A change that touches no endpoint or contract MAY omit `api.md`.
|
|
47
|
+
|
|
48
|
+
`api.md` MUST document the contract: the method and path, the auth and
|
|
49
|
+
permissions required, the response shape and **every** status code the change
|
|
50
|
+
governs (e.g. `200`/`401`/`403`/`404`), and any ordering or consistency
|
|
51
|
+
guarantee. It MUST record how the contract was exercised — a test client and/or a
|
|
52
|
+
live request such as `curl` — and, per the verification-safety rule, how that
|
|
53
|
+
exercise stayed isolated from any live data store (read-only, ephemeral store, or
|
|
54
|
+
rolled-back transaction — never a write to real data without authorization).
|
|
55
|
+
|
|
56
|
+
## 3. Every report follows the required structure
|
|
57
|
+
|
|
58
|
+
Regardless of discipline, each report keeps the fixed structure so evidence is
|
|
59
|
+
reproducible, not improvised: title + header (discipline · change · date ·
|
|
60
|
+
branch · cwd) → gates-and-results table (each check, exact command, real result
|
|
61
|
+
with pass/fail counts) → tests added/updated → spec-scenario coverage table
|
|
62
|
+
(every `#### Scenario` mapped to how it was verified) → pre-existing/unrelated
|
|
63
|
+
failures (with proof, or "none") → pending manual steps (or "none") → one-line
|
|
64
|
+
verdict. See the `build` skill (Step 5) and
|
|
65
|
+
`docs/standards/testing-standards.md`.
|
|
66
|
+
|
|
67
|
+
When a test kind does not yet apply (e.g. no unit runner), the report says so in
|
|
68
|
+
place of that evidence and records the gates and manual verification that stood
|
|
69
|
+
in.
|
|
70
|
+
|
|
71
|
+
## 4. Reports gate the archive
|
|
72
|
+
|
|
73
|
+
`lawbook_archive` refuses to archive while `reports/` holds no discipline report
|
|
74
|
+
(the `reports/README.md` scaffold does not count). Which disciplines a change
|
|
75
|
+
touched — and therefore which reports are owed, including `api.md` for any
|
|
76
|
+
API-touching change — is the agent's responsibility to judge and satisfy before
|
|
77
|
+
archiving; the engine gate counts files but cannot infer the set of concerns a
|
|
78
|
+
change exercised.
|
|
@@ -21,9 +21,11 @@ steps, branch convention, and testing/documentation requirements.
|
|
|
21
21
|
(see `docs/standards/testing-standards.md`).
|
|
22
22
|
- Perform manual verification of the behavior — **the agent executes this
|
|
23
23
|
itself, never the user.**
|
|
24
|
-
- Produce the discipline reports under `reports/`
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
- Produce the discipline reports under `reports/` — one per discipline the change
|
|
25
|
+
touched, from an open set (`backend.md`, `frontend.md`, `api.md`, `database.md`,
|
|
26
|
+
`infra.md`, … — `api.md` is required whenever the change touches an API surface;
|
|
27
|
+
see the `spec-reports-disciplines` rule) with the unit/integration/e2e results
|
|
28
|
+
for what the feature touched.
|
|
27
29
|
- Update the technical documentation the change touches.
|
|
28
30
|
- Archive the change within the same PR (the `archive` command / `lawbook_archive`
|
|
29
31
|
tool).
|
|
@@ -62,9 +62,21 @@ authorization you obtained).
|
|
|
62
62
|
## Step 5 — Write the discipline reports (mandatory)
|
|
63
63
|
|
|
64
64
|
Record the evidence of testing under `lawbook/changes/<name>/reports/`, one file
|
|
65
|
-
per discipline the change touched
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
per discipline the change touched, named for that discipline. The set is **open,
|
|
66
|
+
not a fixed list** — `backend.md`, `frontend.md`, and `api.md` are the common
|
|
67
|
+
ones, but write `database.md`, `infra.md`, `security.md`, `performance.md`,
|
|
68
|
+
`e2e.md`, etc. when the change exercises those concerns, and coin a clear
|
|
69
|
+
`<discipline>.md` for anything none of them fit. Omit disciplines the change did
|
|
70
|
+
not touch; the archive is blocked until at least one discipline report exists.
|
|
71
|
+
|
|
72
|
+
**`api.md` is mandatory whenever the change touches an API surface** — a new or
|
|
73
|
+
modified endpoint, its request/response contract, its status codes, or its
|
|
74
|
+
auth/permission or ordering guarantees. A `backend.md` unit report does not
|
|
75
|
+
substitute for it: the contract is a distinct concern. In `api.md` document the
|
|
76
|
+
method and path, the auth/permissions, the response shape and every status code
|
|
77
|
+
the change governs (e.g. `200`/`401`/`403`/`404`), any ordering guarantee, and
|
|
78
|
+
how the contract was exercised (test client and/or `curl`) — kept isolated from
|
|
79
|
+
any live data store per Step 4.
|
|
68
80
|
|
|
69
81
|
Each report MUST follow this structure, in order — the fixed shape is what makes
|
|
70
82
|
the evidence trustworthy and reproducible, rather than left to improvisation:
|
|
@@ -68,8 +68,11 @@ Create under `lawbook/changes/<name>/`:
|
|
|
68
68
|
manual verification executed by the agent; discipline reports produced; docs
|
|
69
69
|
updated; archive within the PR).
|
|
70
70
|
- **reports/** — create the folder with a short `reports/README.md` naming the
|
|
71
|
-
discipline reports
|
|
72
|
-
|
|
71
|
+
discipline reports the change will need — one per discipline it touches, from an
|
|
72
|
+
open set (`backend.md`, `frontend.md`, `api.md`, `database.md`, `infra.md`,
|
|
73
|
+
`security.md`, … — and `api.md` is required when the change touches any API
|
|
74
|
+
surface) that `build` will fill, following the required report structure
|
|
75
|
+
(header · gates table ·
|
|
73
76
|
tests added · spec-scenario coverage · pre-existing failures · pending manual ·
|
|
74
77
|
verdict — see the `build` skill, Step 5). Every change ships this folder;
|
|
75
78
|
archive is blocked until it holds at least one discipline report.
|
|
@@ -26,7 +26,7 @@ mandatory_task_steps:
|
|
|
26
26
|
- "Review and update the affected tests."
|
|
27
27
|
- "Run the quality gates and verify they pass (see docs/standards/testing-standards.md)."
|
|
28
28
|
- "Perform manual verification of the behavior — the agent executes this itself, never the user."
|
|
29
|
-
- "Produce the discipline reports under reports/ (unit/integration/e2e results for what the feature touched
|
|
29
|
+
- "Produce the discipline reports under reports/ — one per discipline touched, from an open set (e.g. backend.md, frontend.md, api.md, database.md, infra.md, security.md; api.md is required whenever the change touches an API surface) — with the unit/integration/e2e results for what the feature touched."
|
|
30
30
|
- "Update the technical documentation touched by the change."
|
|
31
31
|
- "Archive the change within the same PR (lawbook:archive)."
|
|
32
32
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** ASCII NUL — the record/field separator git emits with `-z` and we request via `%x00`. */
|
|
3
|
+
const NUL = "\0";
|
|
4
|
+
/**
|
|
5
|
+
* Run git in `projectPath` and return stdout, or `null` on any failure.
|
|
6
|
+
*
|
|
7
|
+
* Best-effort like {@link isGitRepo}: git missing, not a repo, or a non-zero
|
|
8
|
+
* exit all yield `null` so callers can fail soft rather than throw.
|
|
9
|
+
*/
|
|
10
|
+
function git(projectPath, args) {
|
|
11
|
+
// `core.quotePath=false` keeps non-ASCII paths as raw UTF-8 instead of git's
|
|
12
|
+
// default octal-escaped, double-quoted form — so our path parsing stays exact.
|
|
13
|
+
const res = spawnSync("git", ["-C", projectPath, "-c", "core.quotePath=false", ...args], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
if (res.status !== 0 || typeof res.stdout !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return res.stdout;
|
|
20
|
+
}
|
|
21
|
+
/** Parse a `--numstat` count field: `-` (binary) becomes `0`, anything non-numeric too. */
|
|
22
|
+
function numstat(field) {
|
|
23
|
+
if (field === "-")
|
|
24
|
+
return 0;
|
|
25
|
+
const n = Number.parseInt(field, 10);
|
|
26
|
+
return Number.isFinite(n) ? n : 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The current `HEAD` commit SHA, or `null` when the repo has no commits yet
|
|
30
|
+
* (or is not a repo / git is unavailable).
|
|
31
|
+
*
|
|
32
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
33
|
+
* @returns The 40-char SHA, or `null`.
|
|
34
|
+
*/
|
|
35
|
+
export function headSha(projectPath) {
|
|
36
|
+
const out = git(projectPath, ["rev-parse", "HEAD"]);
|
|
37
|
+
const sha = out?.trim();
|
|
38
|
+
return sha ? sha : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether `projectPath` is inside a shallow clone (e.g. CI's `--depth=1`), where
|
|
42
|
+
* history is truncated and change/coupling counts would be misleadingly low.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
45
|
+
* @returns `true` only when git reports the repository is shallow.
|
|
46
|
+
*/
|
|
47
|
+
export function isShallowRepo(projectPath) {
|
|
48
|
+
const out = git(projectPath, ["rev-parse", "--is-shallow-repository"]);
|
|
49
|
+
return out?.trim() === "true";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The commits that touched `relPath`, most-recent first, with the line churn
|
|
53
|
+
* each introduced there.
|
|
54
|
+
*
|
|
55
|
+
* Fail-soft: a path with no history, a repo with no commits, or an unavailable
|
|
56
|
+
* git binary all yield an empty list. Records are parsed over NUL separators, so
|
|
57
|
+
* paths containing spaces or unicode are handled correctly.
|
|
58
|
+
*
|
|
59
|
+
* `since`/`until` bound the history as a **revision range** (`since..until`,
|
|
60
|
+
* `until` defaulting to `HEAD`) — the exact, deterministic form drift needs
|
|
61
|
+
* (`<archived-sha>..HEAD`), not an approximate date window. `since` is exclusive.
|
|
62
|
+
*
|
|
63
|
+
* @param projectPath - Project root to query.
|
|
64
|
+
* @param relPath - Project-relative path whose history to read.
|
|
65
|
+
* @param opts - Optional revision bounds: `since` (exclusive lower bound) and
|
|
66
|
+
* `until` (upper bound, default `HEAD`) — any revision git accepts, e.g. a SHA.
|
|
67
|
+
* @returns The touching commits, newest first; empty when there is no history.
|
|
68
|
+
*/
|
|
69
|
+
export function logForPath(projectPath, relPath, opts = {}) {
|
|
70
|
+
const range = [];
|
|
71
|
+
if (opts.since)
|
|
72
|
+
range.push(`${opts.since}..${opts.until ?? "HEAD"}`);
|
|
73
|
+
else if (opts.until)
|
|
74
|
+
range.push(opts.until);
|
|
75
|
+
// Per commit: <sha>\0<ts>\0 then one numstat line per file it touched.
|
|
76
|
+
const out = git(projectPath, [
|
|
77
|
+
"log",
|
|
78
|
+
"--format=%x00%H%x00%ct%x00",
|
|
79
|
+
"--numstat",
|
|
80
|
+
...range,
|
|
81
|
+
"--",
|
|
82
|
+
relPath,
|
|
83
|
+
]);
|
|
84
|
+
if (out === null)
|
|
85
|
+
return [];
|
|
86
|
+
const touches = [];
|
|
87
|
+
// The stream is a sequence of "\0<sha>\0<ts>\0<numstat lines>" per commit.
|
|
88
|
+
const records = out.split(NUL);
|
|
89
|
+
// records[0] is empty (leading NUL); then repeating [sha, ts, tail...] where
|
|
90
|
+
// `tail` holds the numstat lines for that commit up to the next leading NUL.
|
|
91
|
+
for (let i = 1; i + 1 < records.length; i += 3) {
|
|
92
|
+
const sha = records[i]?.trim();
|
|
93
|
+
const ts = Number.parseInt(records[i + 1] ?? "", 10);
|
|
94
|
+
const tail = records[i + 2] ?? "";
|
|
95
|
+
if (!sha || !Number.isFinite(ts))
|
|
96
|
+
continue;
|
|
97
|
+
let added = 0;
|
|
98
|
+
let deleted = 0;
|
|
99
|
+
for (const line of tail.split("\n")) {
|
|
100
|
+
const cols = line.split("\t");
|
|
101
|
+
if (cols.length < 3)
|
|
102
|
+
continue;
|
|
103
|
+
added += numstat(cols[0]);
|
|
104
|
+
deleted += numstat(cols[1]);
|
|
105
|
+
}
|
|
106
|
+
touches.push({ sha, ts, added, deleted });
|
|
107
|
+
}
|
|
108
|
+
return touches;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* How many commits touched each file in the window, summed from `--numstat`.
|
|
112
|
+
*
|
|
113
|
+
* Fail-soft: yields an empty map on any git failure. Does not follow renames —
|
|
114
|
+
* a renamed file is counted under its path as it appears in each commit (a safe
|
|
115
|
+
* superset, not a precise lineage). The result carries the {@link isShallowRepo}
|
|
116
|
+
* marker so consumers can degrade to "insufficient data" on a shallow clone.
|
|
117
|
+
*
|
|
118
|
+
* @param projectPath - Project root to query.
|
|
119
|
+
* @param opts - Optional `since` window (any date/revision git accepts) and a
|
|
120
|
+
* `pathspec` list to restrict which paths are considered.
|
|
121
|
+
* @returns Per-path change counts and the shallow marker.
|
|
122
|
+
*/
|
|
123
|
+
export function churn(projectPath, opts = {}) {
|
|
124
|
+
const shallow = isShallowRepo(projectPath);
|
|
125
|
+
const args = ["log", "--numstat", "--format=%x00"];
|
|
126
|
+
if (opts.since)
|
|
127
|
+
args.push(`--since=${opts.since}`);
|
|
128
|
+
if (opts.pathspec && opts.pathspec.length > 0)
|
|
129
|
+
args.push("--", ...opts.pathspec);
|
|
130
|
+
const out = git(projectPath, args);
|
|
131
|
+
const byPath = new Map();
|
|
132
|
+
if (out === null)
|
|
133
|
+
return { shallow, byPath };
|
|
134
|
+
for (const line of out.split("\n")) {
|
|
135
|
+
// Numstat rows are "<added>\t<deleted>\t<path>"; the %x00 format lines and
|
|
136
|
+
// blank lines have no tabs and are skipped.
|
|
137
|
+
const cols = line.split("\t");
|
|
138
|
+
if (cols.length < 3)
|
|
139
|
+
continue;
|
|
140
|
+
const path = cols[2].replace(/^\0+/, "").trim();
|
|
141
|
+
if (!path)
|
|
142
|
+
continue;
|
|
143
|
+
byPath.set(path, (byPath.get(path) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
return { shallow, byPath };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* For every pair of files that changed together, how many commits touched both.
|
|
149
|
+
*
|
|
150
|
+
* Groups each commit's changed files and emits a count per unordered pair. Pairs
|
|
151
|
+
* with fewer than `minSupport` shared commits are omitted. Fail-soft (empty on
|
|
152
|
+
* git failure) and does not follow renames. Carries the shallow marker.
|
|
153
|
+
*
|
|
154
|
+
* @param projectPath - Project root to query.
|
|
155
|
+
* @param opts - Optional `since` window and `minSupport` threshold (default `1`).
|
|
156
|
+
* @returns The qualifying co-change pairs and the shallow marker.
|
|
157
|
+
*/
|
|
158
|
+
export function coChanges(projectPath, opts = {}) {
|
|
159
|
+
const shallow = isShallowRepo(projectPath);
|
|
160
|
+
const minSupport = opts.minSupport ?? 1;
|
|
161
|
+
const args = ["log", "--name-only", "--format=%x00"];
|
|
162
|
+
if (opts.since)
|
|
163
|
+
args.push(`--since=${opts.since}`);
|
|
164
|
+
const out = git(projectPath, args);
|
|
165
|
+
if (out === null)
|
|
166
|
+
return { shallow, pairs: [] };
|
|
167
|
+
const counts = new Map();
|
|
168
|
+
// Each commit's file list is the run of lines between two %x00 markers.
|
|
169
|
+
for (const commitBlock of out.split(NUL)) {
|
|
170
|
+
const files = commitBlock
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((l) => l.trim())
|
|
173
|
+
.filter((l) => l.length > 0);
|
|
174
|
+
const unique = [...new Set(files)].sort();
|
|
175
|
+
for (let i = 0; i < unique.length; i++) {
|
|
176
|
+
for (let j = i + 1; j < unique.length; j++) {
|
|
177
|
+
const key = `${unique[i]}\t${unique[j]}`;
|
|
178
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const pairs = [];
|
|
183
|
+
for (const [key, count] of counts) {
|
|
184
|
+
if (count < minSupport)
|
|
185
|
+
continue;
|
|
186
|
+
const [a, b] = key.split("\t");
|
|
187
|
+
pairs.push({ a: a, b: b, count });
|
|
188
|
+
}
|
|
189
|
+
pairs.sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
|
|
190
|
+
return { shallow, pairs };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The SHA of the most recent commit that touched `relPath`, or `null` when the
|
|
194
|
+
* path has no history (or on any git failure).
|
|
195
|
+
*
|
|
196
|
+
* @param projectPath - Project root to query.
|
|
197
|
+
* @param relPath - Project-relative path.
|
|
198
|
+
* @returns The last-touching commit SHA, or `null`.
|
|
199
|
+
*/
|
|
200
|
+
export function lastTouch(projectPath, relPath) {
|
|
201
|
+
const out = git(projectPath, ["log", "-1", "--format=%H", "--", relPath]);
|
|
202
|
+
const sha = out?.trim();
|
|
203
|
+
return sha ? sha : null;
|
|
204
|
+
}
|