@ok-ggwp/git-gloss 0.3.0 → 0.4.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 +47 -2
- package/dist/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/show.js +44 -16
- package/dist/commands/signals.js +88 -0
- package/dist/config.js +192 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +11 -1
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1016 -296
- package/dist/html/data.js +26 -1
- package/dist/html/style.css +284 -164
- package/dist/loader.js +1 -1
- package/dist/merge.js +5 -0
- package/dist/render.js +51 -3
- package/dist/safe-match.js +28 -0
- package/dist/schema.js +46 -2
- package/dist/signals.js +292 -0
- package/dist/validate.js +6 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +27 -1
package/README.md
CHANGED
|
@@ -12,8 +12,11 @@ git gloss init [--hooks] follow amends/rebases; fetch origin's glos
|
|
|
12
12
|
git gloss sync [<remote>] fetch, merge and push glosses (default: origin)
|
|
13
13
|
git gloss hunks [<commit>|wip] list hunks, numbering changed lines for h3:2-5 references;
|
|
14
14
|
wip snapshots your uncommitted work instead
|
|
15
|
+
git gloss signals [<commit>|<range>|wip] [--json]
|
|
16
|
+
what the diff itself says about attention: path classes, pattern
|
|
17
|
+
hits, dependency bumps and the computed floor
|
|
15
18
|
git gloss adopt [<commit>] move the gloss of uncommitted work onto the commit made from it
|
|
16
|
-
git gloss add [<commit>] -f a.json attach an annotation (validated against the diff)
|
|
19
|
+
git gloss add [<commit>] -f a.json attach an annotation (validated against the diff; --allow-underrate)
|
|
17
20
|
git gloss carry <source>... [--to <c>] gloss a commit from the glosses of the commits its changes came from
|
|
18
21
|
(a squash, cherry-pick or rebase): git gloss carry main..feature --to <squash>
|
|
19
22
|
--find [--squash] [<commit>...] ...working out which commits those are
|
|
@@ -21,7 +24,8 @@ git gloss show [<commit>|<range>] summary: one line per group, alternatives,
|
|
|
21
24
|
-g <group> ...expand groups, with their code
|
|
22
25
|
--full ...every group's details
|
|
23
26
|
--diff ...every group's details and code
|
|
24
|
-
--only speculative,incidental ...just the parts you might cut
|
|
27
|
+
--only speculative,incidental ...just the parts you might cut (impact values work too: --only contract,sensitive; naming both kinds needs both to match)
|
|
28
|
+
--sort impact ...most consequential groups first
|
|
25
29
|
git gloss html [<commit>|<range>] self-contained HTML view, of one commit or a branch (--open, -o <file>)
|
|
26
30
|
git gloss ary [--port <n>] [--no-open] browse branches, commits and wip in a local web page (see "Browsing")
|
|
27
31
|
git gloss why <file>:<line>[-<end>] explain why code exists (via blame), looking past mechanical edits
|
|
@@ -157,6 +161,47 @@ A review says that something was checked, not that it passed. Each review ends w
|
|
|
157
161
|
|
|
158
162
|
Like glosses, reviews follow rebased commits in repos set up with `git gloss init`. Reviews are shared by `git gloss sync` (and so by every push with the hooks) like glosses, for commits the remote has. They never conflict: everyone's reviews are kept. Other tools can record reviews too: `git gloss reviews --stamp <rev>` before the review, then `git gloss reviews --record` with the stamp and the report as JSON on stdin.
|
|
159
163
|
|
|
164
|
+
## Impact and attention
|
|
165
|
+
|
|
166
|
+
Necessity and consequence are separate: a `required` one-line fix to a login helper matters more than a `required` rename in a leaf component. Two optional fields say so. Each group may have `impact` (`local < module < shared < contract < sensitive`, the farthest its effect reaches if it is wrong) and an `impact_reason` of up to 120 characters. The annotation may have `attention`: `level` (`skim`, `look`, `careful` or `must-read`), one to three `focus` group ids to read first, `reasons`, and an optional `override_reason`. `git gloss schema` lists them all.
|
|
167
|
+
|
|
168
|
+
`show` leads with `attention: CAREFUL (declared) · floor: careful · focus: a, b` and the reasons, and badges each group with its impact; `html` shows the same as a banner whose focus links jump to the groups. Where nothing is declared, `show` and `html` show a value computed from the diff and mark it, as in `attention: LOOK (derived)` (a group's derived impact is in parentheses); a gloss reads as `attention: unrated` only where no signals can be computed. When glosses are combined (`carry`, a squash, a rebase) the attention level is the highest of them and the focus groups are the most impactful three, with the reasons joined; if any of them declared none, the combined gloss declares none and shows a derived one.
|
|
169
|
+
|
|
170
|
+
Notes using these fields are format 4; a git-gloss that predates them refuses such a note ("written by a newer git-gloss") rather than silently dropping the fields. A gloss that doesn't use them is still written in its old format. Derived values and floors are computed on every read and never stored in a note.
|
|
171
|
+
|
|
172
|
+
### Computed signals and the floor
|
|
173
|
+
|
|
174
|
+
`git gloss signals [<commit>|<range>|wip] [--json]` reports, per hunk, file and change: the path classes (`sensitive`, `shared`, `contract`, `generated`, `test`, `docs`, plus `lockfile` and `manifest`), pattern hits, stats (files, hunks, lines, test files, share of hunks in tests), dependency changes, and the floor. `wip` is the working tree as it is now (no snapshot is taken). `hunks` ends with a short summary of the same, so read it before writing a gloss. Signals are cheap heuristics from globs and regexes, not a security scanner.
|
|
175
|
+
|
|
176
|
+
- **Pattern hits** are regexes (case-insensitive unless the pattern sets `flags`) matched against the *added and removed* lines of a hunk only: never unchanged context lines, never the `@@` header. In test, docs and lockfile paths they are *damped*: still reported, but they don't count towards the floor (a `password` in a test file is not sensitive).
|
|
177
|
+
- **Dependencies.** Edits to manifests and lockfiles are listed. `package-lock.json` and `pnpm-lock.yaml` are parsed from their changed lines for version bumps, and a bump of a package that no `package.json` change names is flagged as transitive. Other lockfiles are `contract` with no version detail.
|
|
178
|
+
- **Floor of a hunk**: the highest of its path class (`sensitive`, `contract`, `shared`; lockfiles and manifests are at least `contract`; otherwise `module`) and its undamped pattern hits. Test, docs and generated files cap at `local` (a pattern hit in generated code still counts). A group's floor is the highest floor of its hunks; for a group that claims only some lines of a hunk, only the hits on those lines count.
|
|
179
|
+
- **Floor of the change**: skim for `local`, look for `module`, careful for `shared` and `contract`, must-read for `sensitive`, and at least careful when more than `attention.largeChangeFiles` (default 50) files changed.
|
|
180
|
+
- **Derived attention** (when the gloss declares none): start from the highest group impact (declared, else the group's floor) with the same mapping, then raise it one step (capped at must-read, once however many apply) for a behaviour-changing group that no test group serves, for a speculative or defensive group outside tests, or for more than `largeChangeFiles` files; never below the floor. A group is *behaviour-changing* when its necessity is not `mechanical` and not all its hunks are tests, docs, generated files or lockfiles. It is *served by tests* when a group made only of test-file hunks names it in `serves`, directly or through groups it serves; a test group serving `"task"` doesn't count.
|
|
181
|
+
|
|
182
|
+
`add` checks a gloss against the floor. A group whose declared `impact` is below its floor (above plain `module`) is a warning naming the signal. A declared `attention.level` below the floor is an error unless `attention.override_reason` says why; `--allow-underrate` turns that error into a warning. Leaving `impact` or `attention` out is always allowed. An `override_reason` is shown by `show` and `html` next to the floor, flagged as a mismatch.
|
|
183
|
+
|
|
184
|
+
### Config
|
|
185
|
+
|
|
186
|
+
The built-in defaults name no language, framework or tool, only common file names. A repo adds to them with `.gloss/config.json`, committed in that repo. The file is read from the base branch (the merge base of the checked-out `HEAD` and `origin/main`, `main`, `origin/master` or `master`, so a long-lived branch sees the config as of where it forked; a commit on the base branch itself reads its own config, flagged `sensitive` if it edits the file), never from the working tree, so a change cannot reclassify its own paths; a config edit only takes effect once it is on the base branch. With none of those branches the defaults apply, with a warning. Editing the file is itself always a `sensitive` path. A pattern that takes over 250 ms on a hunk is cut off and from then on counts as matching every changed line (with a warning), so a slow or padded line raises the floor and never lowers it:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"attention": { "largeChangeFiles": 80 },
|
|
191
|
+
"paths": {
|
|
192
|
+
"shared": ["packages/shared/**", "tsconfig.base.json"],
|
|
193
|
+
"sensitive": ["src/devices/**"],
|
|
194
|
+
"test": { "extends": false, "globs": ["**/*.spec.*", "e2e/**"] }
|
|
195
|
+
},
|
|
196
|
+
"patterns": {
|
|
197
|
+
"sensitive": [{ "regex": "\\bdevice\\.write\\(", "name": "device write" }],
|
|
198
|
+
"contract": ["<regex for the repo's public-API markers>"]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A class is a list that extends the default list, or `{ "extends": false, "globs": [...] }` (`"regexes"` for patterns) to replace it. Path classes: `sensitive`, `shared`, `contract`, `generated`, `test`, `docs`, `lockfile`, `manifest`; pattern classes: `sensitive`, `contract`, `shared`. Globs support `**`, `*`, `?` and `{a,b}`; a glob without a `/` matches that name at any depth. `git config gloss.attention.largeChangeFiles <n>` overrides the file. A malformed config is an error naming the file and key (`add` and `signals` stop; `show` falls back to declared values and "unrated").
|
|
204
|
+
|
|
160
205
|
## HTML view
|
|
161
206
|
|
|
162
207
|
`git gloss html --open` writes a single HTML file (no network access, works offline) and opens it. It shows the task and summary, a bar of how the changed lines split across necessity categories, and a card per group with its rationale; code is collapsed until you ask for it. Click categories to filter, use the sidebar to jump between groups, or press `j`/`k` to move, `o` to show code, and `e`/`c` to expand or collapse everything. `#g-<group>` in the URL opens that group.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ATTENTION, IMPACT } from './schema.js';
|
|
2
|
+
// Rank of a group's impact; a group with none is below every level.
|
|
3
|
+
export const impactRank = (i) => (i ? IMPACT.indexOf(i) : -1);
|
|
4
|
+
export const maxImpact = (a, b) => (impactRank(b) > impactRank(a) ? b : a);
|
|
5
|
+
export const attentionRank = (a) => (a ? ATTENTION.indexOf(a) : -1);
|
|
6
|
+
export const maxAttention = (a, b) => (attentionRank(b) > attentionRank(a) ? b : a);
|
|
7
|
+
// One step up, capped at must-read.
|
|
8
|
+
export const raise = (a) => ATTENTION[Math.min(ATTENTION.length - 1, ATTENTION.indexOf(a) + 1)];
|
|
9
|
+
// The attention a given impact calls for, before any raising.
|
|
10
|
+
const ATTENTION_FOR_IMPACT = {
|
|
11
|
+
local: 'skim', module: 'look', shared: 'careful', contract: 'careful', sensitive: 'must-read',
|
|
12
|
+
};
|
|
13
|
+
export const attentionForImpact = (i) => ATTENTION_FOR_IMPACT[i];
|
|
14
|
+
// What the reviewer should do when glosses are combined (a squash, a rebase):
|
|
15
|
+
// the highest level of any of them. Focus groups of all of them, renamed to
|
|
16
|
+
// their new ids (`rename` returns undefined for a group that is gone), most
|
|
17
|
+
// impactful first, without repeats, at most three. Reasons are unioned.
|
|
18
|
+
export function combineAttention(parts, rename, groups) {
|
|
19
|
+
if (!parts.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
const level = parts.map((p) => p.level).reduce((a, b) => (ATTENTION.indexOf(b) > ATTENTION.indexOf(a) ? b : a));
|
|
22
|
+
const impactOf = new Map(groups.map((g) => [g.id, g.impact]));
|
|
23
|
+
const focus = [...new Set(parts.flatMap((p, i) => (p.focus ?? []).flatMap((id) => rename(i, id) ?? [])))]
|
|
24
|
+
.sort((a, b) => impactRank(impactOf.get(b)) - impactRank(impactOf.get(a)))
|
|
25
|
+
.slice(0, 3);
|
|
26
|
+
const reasons = [...new Set(parts.flatMap((p) => p.reasons ?? []))];
|
|
27
|
+
const overrides = [...new Set(parts.flatMap((p) => (p.override_reason && p.level === level ? [p.override_reason] : [])))];
|
|
28
|
+
return {
|
|
29
|
+
level,
|
|
30
|
+
...(focus.length ? { focus } : {}),
|
|
31
|
+
...(reasons.length ? { reasons } : {}),
|
|
32
|
+
...(overrides.length ? { override_reason: overrides.join('; ') } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/carry.js
CHANGED
|
@@ -52,5 +52,8 @@ export function carryGloss(note, current, previous = []) {
|
|
|
52
52
|
const byId = new Map(current.map((h) => [h.id, h]));
|
|
53
53
|
const claims = groups.flatMap((g) => [...selections(g.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
|
|
54
54
|
.flatMap(([id, lines]) => { const h = byId.get(id); return h ? [{ hunk: h, lines }] : []; }));
|
|
55
|
-
|
|
55
|
+
// Focus groups that were left behind can't be pointed at.
|
|
56
|
+
const focus = a.attention?.focus?.filter((id) => keptIds.has(id));
|
|
57
|
+
const attention = a.attention && { ...a.attention, focus: focus?.length ? focus : undefined };
|
|
58
|
+
return { annotation: { ...a, groups, ...(attention ? { attention } : {}) }, kept: groups.length, total: a.groups.length, dropped, gaps: uncovered(current, claims), partialMatch };
|
|
56
59
|
}
|
package/dist/cli.js
CHANGED
|
@@ -10,11 +10,12 @@ import * as review from './commands/review.js';
|
|
|
10
10
|
import * as reviews from './commands/reviews.js';
|
|
11
11
|
import * as schema from './commands/schema.js';
|
|
12
12
|
import * as show from './commands/show.js';
|
|
13
|
+
import * as signals from './commands/signals.js';
|
|
13
14
|
import * as sync from './commands/sync.js';
|
|
14
15
|
import * as versions from './commands/versions.js';
|
|
15
16
|
import * as why from './commands/why.js';
|
|
16
17
|
const COMMANDS = {
|
|
17
|
-
init, hunks, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
18
|
+
init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
18
19
|
};
|
|
19
20
|
function help() {
|
|
20
21
|
return 'usage: git gloss <command> [options]\n\n' +
|
package/dist/commands/add.js
CHANGED
|
@@ -5,7 +5,9 @@ import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTE
|
|
|
5
5
|
import { location, parseDiff } from '../diff.js';
|
|
6
6
|
import { Annotation, noteFormat } from '../schema.js';
|
|
7
7
|
import { validate } from '../validate.js';
|
|
8
|
-
|
|
8
|
+
import { loadConfig } from '../config.js';
|
|
9
|
+
import { analyzeGroups, groupInputsFor, ratingProblems } from '../floor.js';
|
|
10
|
+
export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--allow-underrate] [--dry-run] attach an annotation (JSON from file or stdin)';
|
|
9
11
|
export function run(argv) {
|
|
10
12
|
const { values, positionals } = parseArgs({
|
|
11
13
|
args: argv,
|
|
@@ -14,6 +16,7 @@ export function run(argv) {
|
|
|
14
16
|
file: { type: 'string', short: 'f' },
|
|
15
17
|
force: { type: 'boolean' },
|
|
16
18
|
partial: { type: 'boolean' },
|
|
19
|
+
'allow-underrate': { type: 'boolean' },
|
|
17
20
|
'dry-run': { type: 'boolean' },
|
|
18
21
|
},
|
|
19
22
|
});
|
|
@@ -41,10 +44,23 @@ export function run(argv) {
|
|
|
41
44
|
if (uncovered.length && !values.partial) {
|
|
42
45
|
errors.push(`hunks not assigned to any group: ${uncovered.join(', ')} (every hunk needs a group, or pass --partial)`);
|
|
43
46
|
}
|
|
47
|
+
// Computed signals: group impact below its floor is a warning; attention
|
|
48
|
+
// below the floor is an error unless explained (override_reason) or allowed.
|
|
49
|
+
// A malformed config stops here with its own message.
|
|
50
|
+
const warnings = [];
|
|
51
|
+
if (!errors.length) {
|
|
52
|
+
const analysis = analyzeGroups(hunks, groupInputsFor(parsed.data, hunks), loadConfig());
|
|
53
|
+
const problems = ratingProblems(parsed.data, analysis);
|
|
54
|
+
warnings.push(...problems.warnings);
|
|
55
|
+
if (problems.underrated)
|
|
56
|
+
(values['allow-underrate'] ? warnings : errors).push(problems.underrated);
|
|
57
|
+
}
|
|
44
58
|
if (errors.length) {
|
|
45
59
|
console.error('error: annotation is inconsistent with the commit:\n' + errors.map((e) => ` ✖ ${e}`).join('\n'));
|
|
46
60
|
return 1;
|
|
47
61
|
}
|
|
62
|
+
for (const w of warnings)
|
|
63
|
+
console.error(`warning: ${w}`);
|
|
48
64
|
// Not an error: long intents still work, but they make the summary rows tall.
|
|
49
65
|
for (const g of parsed.data.groups.filter((g) => g.intent.length > 100)) {
|
|
50
66
|
console.error(`note: group "${g.id}" has a ${g.intent.length}-character intent; keep it under ~80 and move detail to "notes"`);
|
package/dist/commands/hunks.js
CHANGED
|
@@ -6,6 +6,10 @@ import { location, parseDiff } from '../diff.js';
|
|
|
6
6
|
import { noteFormat } from '../schema.js';
|
|
7
7
|
import { colorDiff, hunkLabel, page, style } from '../render.js';
|
|
8
8
|
import { bodyLines, changedCount } from '../selection.js';
|
|
9
|
+
import { loadConfig } from '../config.js';
|
|
10
|
+
import { withSignals } from '../floor.js';
|
|
11
|
+
import { analyze } from '../signals.js';
|
|
12
|
+
import { summaryLines } from './signals.js';
|
|
9
13
|
export const usage = 'hunks [<commit> | wip] [--stat] [--json] list a commit\'s hunks (or snapshot uncommitted work with wip), numbering changed lines';
|
|
10
14
|
export function run(argv) {
|
|
11
15
|
const { values, positionals } = parseArgs({
|
|
@@ -59,6 +63,9 @@ export function run(argv) {
|
|
|
59
63
|
return colorDiff(h.text, ' ');
|
|
60
64
|
return [colorDiff(header, ' '), ...body.map((l) => style('dim', (l.idx === null ? '' : String(l.idx)).padStart(5)) + ' ' + colorDiff(l.raw))].join('\n');
|
|
61
65
|
};
|
|
62
|
-
|
|
66
|
+
// What the diff itself says about attention, for the author to read before glossing.
|
|
67
|
+
const summary = withSignals(() => summaryLines(analyze(hunks, loadConfig())));
|
|
68
|
+
const signals = summary ? ['', ...summary] : [];
|
|
69
|
+
page([hunks.map((h) => hunkLabel(h) + style('dim', ` (${changedCount(h)} changed)`) + (values.stat ? '' : '\n' + numbered(h) + '\n')).join('\n'), ...signals].join('\n'));
|
|
63
70
|
return 0;
|
|
64
71
|
}
|
package/dist/commands/show.js
CHANGED
|
@@ -2,10 +2,12 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
|
|
3
3
|
import { refLabel, sliceStats, sliceText } from '../selection.js';
|
|
4
4
|
import { loadGloss } from '../loader.js';
|
|
5
|
-
import {
|
|
5
|
+
import { impactRank } from '../attention.js';
|
|
6
|
+
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
|
+
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
6
8
|
import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
7
|
-
import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
8
|
-
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
|
|
9
|
+
import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
10
|
+
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
|
|
9
11
|
// Counts over the lines each part selects, not whole hunks, so groups that
|
|
10
12
|
// split a hunk don't both count all of it.
|
|
11
13
|
function stats(parts) {
|
|
@@ -26,12 +28,18 @@ export function run(argv) {
|
|
|
26
28
|
full: { type: 'boolean' },
|
|
27
29
|
diff: { type: 'boolean' },
|
|
28
30
|
only: { type: 'string' },
|
|
31
|
+
sort: { type: 'string' },
|
|
29
32
|
},
|
|
30
33
|
});
|
|
31
|
-
const
|
|
32
|
-
const unknown =
|
|
34
|
+
const names = values.only?.split(',').map((s) => s.trim());
|
|
35
|
+
const unknown = names?.filter((n) => !isNecessity(n) && !isImpact(n));
|
|
33
36
|
if (unknown?.length) {
|
|
34
|
-
console.error(`error: unknown necessity ${unknown.join(', ')}; expected ${NECESSITY.join(', ')}`);
|
|
37
|
+
console.error(`error: unknown necessity or impact ${unknown.join(', ')}; expected ${NECESSITY.join(', ')} or ${IMPACT.join(', ')}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
const only = names && { names, necessities: names.filter(isNecessity), impacts: names.filter(isImpact) };
|
|
41
|
+
if (values.sort !== undefined && values.sort !== 'impact') {
|
|
42
|
+
console.error(`error: unknown sort ${values.sort}; expected impact`);
|
|
35
43
|
return 2;
|
|
36
44
|
}
|
|
37
45
|
const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
@@ -63,7 +71,7 @@ export function run(argv) {
|
|
|
63
71
|
}
|
|
64
72
|
const out = renderCommit(sha, gloss, values, only, true);
|
|
65
73
|
if (!out) {
|
|
66
|
-
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
74
|
+
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.names.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
67
75
|
return 1;
|
|
68
76
|
}
|
|
69
77
|
page(out.join('\n'));
|
|
@@ -99,13 +107,15 @@ export function run(argv) {
|
|
|
99
107
|
}
|
|
100
108
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
|
101
109
|
out.push(...(renderCommit(sha, gloss, values, only, false) ??
|
|
102
|
-
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.join('/')} groups`)]));
|
|
110
|
+
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
103
111
|
});
|
|
104
112
|
if (glossed)
|
|
105
113
|
out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
|
|
106
114
|
page(out.join('\n'));
|
|
107
115
|
return 0;
|
|
108
116
|
}
|
|
117
|
+
const matchesOnly = (g, only) => !only ||
|
|
118
|
+
((!only.necessities.length || only.necessities.includes(g.necessity)) && (!only.impacts.length || (!!g.impact && only.impacts.includes(g.impact))));
|
|
109
119
|
// One line on whether the commit as it is now was reviewed, per stage.
|
|
110
120
|
function reviewsLine(sha) {
|
|
111
121
|
const statuses = reviewStatus(sha);
|
|
@@ -126,15 +136,25 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
126
136
|
out.push(...field('summary:', a.summary, 0));
|
|
127
137
|
for (const w of gloss.warnings)
|
|
128
138
|
out.push(...field('⚠', w, 0).map((l) => style('red', l)));
|
|
129
|
-
|
|
139
|
+
const an = tryAnalyze(gloss);
|
|
140
|
+
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
|
|
130
141
|
const groups = gloss.groups
|
|
131
|
-
.filter((g) =>
|
|
142
|
+
.filter((g) => matchesOnly(g.group, only))
|
|
132
143
|
.filter((g) => !values.group || values.group.includes(g.group.id));
|
|
133
144
|
if (!groups.length)
|
|
134
145
|
return null;
|
|
146
|
+
// --sort impact: the most consequential first, then by necessity as usual.
|
|
147
|
+
const byNecessity = (x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity);
|
|
148
|
+
const byImpact = (x, y) => impactRank(y.group.impact) - impactRank(x.group.impact) || byNecessity(x, y);
|
|
149
|
+
const detail = values.group || values.full || values.diff;
|
|
150
|
+
// The summary lists required first; detail views keep the gloss's order unless asked.
|
|
151
|
+
if (values.sort === 'impact')
|
|
152
|
+
groups.sort(byImpact);
|
|
153
|
+
else if (!detail)
|
|
154
|
+
groups.sort(byNecessity);
|
|
135
155
|
// -g: chosen groups with their code. --full: every group's details, plus
|
|
136
156
|
// alternatives and risks. --diff: --full plus code.
|
|
137
|
-
if (
|
|
157
|
+
if (detail) {
|
|
138
158
|
const withCode = values.diff || !!values.group;
|
|
139
159
|
for (const g of groups) {
|
|
140
160
|
out.push('', ...groupHeader(g.group));
|
|
@@ -163,13 +183,12 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
163
183
|
return out;
|
|
164
184
|
}
|
|
165
185
|
// Summary: one line per group.
|
|
166
|
-
const sorted = [...groups].sort((x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity));
|
|
167
186
|
const tally = NECESSITY.map((n) => [n, gloss.groups.filter((g) => g.group.necessity === n).length])
|
|
168
187
|
.filter(([, c]) => c > 0)
|
|
169
188
|
.map(([n, c]) => `${c} ${necessityWord(n)}`);
|
|
170
|
-
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.join(',')})`) : '';
|
|
189
|
+
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.names.join(',')})`) : '';
|
|
171
190
|
out.push('', `${style('bold', `${gloss.groups.length} groups:`)} ${tally.join(style('dim', ' · '))}${showing}`, '');
|
|
172
|
-
const rows =
|
|
191
|
+
const rows = groups.map((g) => {
|
|
173
192
|
const s = stats(currentParts(g));
|
|
174
193
|
const stale = g.hunks.filter((h) => h.status !== 'ok').length;
|
|
175
194
|
return {
|
|
@@ -182,14 +201,21 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
182
201
|
const statW = Math.max(...rows.map((r) => r.stat.length), un.count ? 8 : 0);
|
|
183
202
|
const addW = Math.max(...rows.map((r) => r.added.length));
|
|
184
203
|
const remW = Math.max(...rows.map((r) => r.removed.length));
|
|
185
|
-
|
|
204
|
+
// An impact column only when some group declares one or the diff puts one
|
|
205
|
+
// above the baseline (module), so plain glosses look as before. Undeclared
|
|
206
|
+
// ones show the group's floor in parentheses.
|
|
207
|
+
const floorOf = (g) => an?.groups.get(g.group.id)?.floor;
|
|
208
|
+
const showImpact = showsImpact(gloss.groups.map((g) => g.group), an);
|
|
209
|
+
const impactW = showImpact ? IMPACT_W + 2 : 0;
|
|
210
|
+
const anyDerived = showImpact && gloss.groups.some((g) => !g.group.impact && floorOf(g));
|
|
211
|
+
const intentCol = 2 + 11 + 2 + impactW + idW + 2;
|
|
186
212
|
const statsW = 2 + statW + 1 + addW + 1 + remW;
|
|
187
213
|
const intentW = Math.max(20, termWidth() - intentCol - statsW);
|
|
188
214
|
for (const r of rows) {
|
|
189
215
|
// Intents wrap within their column rather than being cut off; the stats
|
|
190
216
|
// stay on the first line so the numbers still line up.
|
|
191
217
|
const [first, ...rest] = wrap(r.g.group.intent, intentW);
|
|
192
|
-
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
218
|
+
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${impactW ? (r.g.group.impact ? impactTag(r.g.group.impact, IMPACT_W) : floorOf(r.g) ? derivedImpactTag(floorOf(r.g), IMPACT_W) : ' '.repeat(IMPACT_W)) + ' ' : ''}${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
193
219
|
`${first.padEnd(intentW)} ` +
|
|
194
220
|
style('dim', r.stat.padStart(statW)) + ' ' + style('green', r.added.padStart(addW)) + ' ' + style('red', r.removed.padStart(remW)), ...rest.map((l) => ' '.repeat(intentCol) + l));
|
|
195
221
|
if (r.stale) {
|
|
@@ -200,6 +226,8 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
200
226
|
const where = gloss.unannotated.map((u) => refLabel(u.hunk.id, u.lines)).join(', ');
|
|
201
227
|
out.push(` ${style(['bold', 'red'], 'unannotated'.padEnd(11))} ${style('red', `+${un.added} -${un.removed} not in any group: ${where}`)}`);
|
|
202
228
|
}
|
|
229
|
+
if (anyDerived)
|
|
230
|
+
out.push('', style('dim', '(impact in parentheses is derived from the diff, not declared)'));
|
|
203
231
|
// Rejected alternatives and risks: short, and often what a reader most
|
|
204
232
|
// lacks, so they're in the summary rather than only behind --full.
|
|
205
233
|
out.push(...noteFooter(gloss.note));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
|
|
4
|
+
import { parseDiff } from '../diff.js';
|
|
5
|
+
import { analyze, CONFIG_EDIT_NOTE } from '../signals.js';
|
|
6
|
+
import { impactRank } from '../attention.js';
|
|
7
|
+
import { ATTENTION_STYLE, impactTag, page, style } from '../render.js';
|
|
8
|
+
export const usage = 'signals [<commit> | <range> | wip] [--json] what the diff itself says about attention: path classes, pattern hits, dependency bumps and the computed floor';
|
|
9
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
10
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
11
|
+
const statsLine = (s) => `${plural(s.files, 'file')}, ${plural(s.hunks, 'hunk')}, +${s.added} -${s.removed}, ${plural(s.testFiles, 'test file')}` +
|
|
12
|
+
(s.hunks ? ` (${pct(s.testHunkShare)} of hunks in tests)` : '');
|
|
13
|
+
// The short form `hunks` prints at its end: the floor, the files that set it,
|
|
14
|
+
// and the stats.
|
|
15
|
+
export function summaryLines(sig, max = 6) {
|
|
16
|
+
const out = [style('bold', 'signals:') + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}`];
|
|
17
|
+
const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
|
|
18
|
+
.sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
|
|
19
|
+
for (const f of files.slice(0, max))
|
|
20
|
+
out.push(` ${(f.floor + ':').padEnd(10)} ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
|
|
21
|
+
if (files.length > max)
|
|
22
|
+
out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
|
|
23
|
+
for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
|
|
24
|
+
out.push(` large: ${r}`);
|
|
25
|
+
if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
26
|
+
out.push(` note: ${CONFIG_EDIT_NOTE}`);
|
|
27
|
+
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function detailLines(sig, label) {
|
|
31
|
+
const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
|
|
32
|
+
style('dim', ` (floor impact: ${sig.floor.impact})`)];
|
|
33
|
+
for (const r of sig.floor.reasons)
|
|
34
|
+
out.push(` why: ${r}`);
|
|
35
|
+
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
36
|
+
const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
|
|
37
|
+
if (notable.length)
|
|
38
|
+
out.push('', style('bold', 'hunks with signals'));
|
|
39
|
+
for (const h of notable) {
|
|
40
|
+
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${h.file}` +
|
|
41
|
+
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + h.why.join('; ') : ''}`));
|
|
42
|
+
for (const x of h.hits.slice(0, 8)) {
|
|
43
|
+
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', x.text)}`);
|
|
44
|
+
}
|
|
45
|
+
if (h.hits.length > 8)
|
|
46
|
+
out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
|
|
47
|
+
}
|
|
48
|
+
const quiet = sig.hunks.length - notable.length;
|
|
49
|
+
if (quiet)
|
|
50
|
+
out.push(style('dim', ` (${plural(quiet, 'other hunk')} at module or below, no pattern hits)`));
|
|
51
|
+
if (sig.dependencies.length)
|
|
52
|
+
out.push('', style('bold', 'dependency changes'));
|
|
53
|
+
for (const d of sig.dependencies) {
|
|
54
|
+
out.push(` ${d.file} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
55
|
+
for (const b of d.bumps.slice(0, 20)) {
|
|
56
|
+
const what = b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`;
|
|
57
|
+
out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
|
|
58
|
+
}
|
|
59
|
+
if (d.bumps.length > 20)
|
|
60
|
+
out.push(style('dim', ` ... ${d.bumps.length - 20} more (--json)`));
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
// The diff to analyse: wip is the working tree as it is now (no snapshot is taken).
|
|
65
|
+
function target(arg) {
|
|
66
|
+
if (arg === 'wip')
|
|
67
|
+
return { diff: workingDiff().diff, label: 'uncommitted changes' };
|
|
68
|
+
if (arg.includes('...'))
|
|
69
|
+
throw new GitError(`${arg}: use A..B for the commits on B since A`);
|
|
70
|
+
if (arg.includes('..')) {
|
|
71
|
+
const [a, b] = arg.split('..');
|
|
72
|
+
const base = git(['merge-base', '--end-of-options', a || 'HEAD', b || 'HEAD']).trim();
|
|
73
|
+
return { diff: diffBetween(base, resolveCommit(b || 'HEAD')), label: arg };
|
|
74
|
+
}
|
|
75
|
+
const sha = resolveCommit(arg);
|
|
76
|
+
return { diff: commitDiff(sha), label: sha.slice(0, 10) };
|
|
77
|
+
}
|
|
78
|
+
export function run(argv) {
|
|
79
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { json: { type: 'boolean' } } });
|
|
80
|
+
const { diff, label } = target(positionals[0] ?? 'HEAD');
|
|
81
|
+
const sig = analyze(parseDiff(diff), loadConfig());
|
|
82
|
+
if (values.json) {
|
|
83
|
+
console.log(JSON.stringify({ target: label, ...sig }, null, 2));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
page(detailLines(sig, label).join('\n'));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { GlossError, gitTry } from './git.js';
|
|
3
|
+
import { globToRegExp } from './glob.js';
|
|
4
|
+
// Settings for the computed signals, in three layers (later wins for scalars,
|
|
5
|
+
// repo config extends the lists of the one before):
|
|
6
|
+
// 1. DEFAULTS below: only conventions true almost everywhere, no language,
|
|
7
|
+
// framework or build tool beyond well-known file names.
|
|
8
|
+
// 2. `.gloss/config.json`, committed in the repo that uses git-gloss.
|
|
9
|
+
// 3. `git config gloss.attention.<key>`, for the scalars.
|
|
10
|
+
// A path or pattern class in the repo config is a list that is added to the
|
|
11
|
+
// default list, or `{ "extends": false, "globs": [...] }` (`"regexes"` for a
|
|
12
|
+
// pattern class) to replace the default list instead.
|
|
13
|
+
export const CONFIG_FILE = '.gloss/config.json';
|
|
14
|
+
export class ConfigError extends GlossError {
|
|
15
|
+
}
|
|
16
|
+
// Classes whose paths decide a hunk's floor, and the ones that only mark it.
|
|
17
|
+
export const PATH_CLASSES = ['sensitive', 'shared', 'contract', 'generated', 'test', 'docs', 'lockfile', 'manifest'];
|
|
18
|
+
// Impact a pattern hit raises a hunk to.
|
|
19
|
+
export const PATTERN_CLASSES = ['sensitive', 'contract', 'shared'];
|
|
20
|
+
export const DEFAULTS = {
|
|
21
|
+
attention: { largeChangeFiles: 50 },
|
|
22
|
+
paths: {
|
|
23
|
+
sensitive: ['**/auth/**', '**/*crypto*', '**/*secret*', '**/*password*', '**/permissions/**', '**/migrations/**'],
|
|
24
|
+
shared: [],
|
|
25
|
+
contract: [
|
|
26
|
+
'**/package.json', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock',
|
|
27
|
+
'**/Cargo.toml', '**/Cargo.lock', '**/go.mod', '**/go.sum',
|
|
28
|
+
'**/pyproject.toml', '**/poetry.lock', '**/requirements*.txt',
|
|
29
|
+
'.github/workflows/**', '.gitlab-ci.yml', 'azure-pipelines*.yml', 'Jenkinsfile',
|
|
30
|
+
'**/Dockerfile', '**/*.proto', '**/openapi*.{yml,yaml,json}',
|
|
31
|
+
],
|
|
32
|
+
generated: ['**/*.lock', '**/*-lock.*', '**/*.snap', '**/__snapshots__/**'],
|
|
33
|
+
test: ['**/*.{spec,test}.*', '**/test/**', '**/tests/**', '**/__tests__/**'],
|
|
34
|
+
docs: ['**/*.md', '**/docs/**'],
|
|
35
|
+
// Dependency files: always at least `contract`; changes to them are listed
|
|
36
|
+
// as dependency changes, and package-lock.json and pnpm-lock.yaml are
|
|
37
|
+
// parsed for version bumps.
|
|
38
|
+
lockfile: ['**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/Cargo.lock', '**/go.sum', '**/poetry.lock', '**/*.lock'],
|
|
39
|
+
manifest: ['**/package.json', '**/Cargo.toml', '**/go.mod', '**/pyproject.toml', '**/requirements*.txt'],
|
|
40
|
+
},
|
|
41
|
+
patterns: {
|
|
42
|
+
sensitive: [
|
|
43
|
+
{ regex: '\\b(password|secret|private[_-]?key|api[_-]?key)\\b', name: 'secret or credential' },
|
|
44
|
+
{ regex: '\\b(encrypt|decrypt)\\b', name: 'encryption' },
|
|
45
|
+
{ regex: '\\b(DROP|TRUNCATE|ALTER)\\s+TABLE\\b', name: 'schema change' },
|
|
46
|
+
],
|
|
47
|
+
contract: [],
|
|
48
|
+
shared: [],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const Glob = z.string().min(1).superRefine((g, ctx) => {
|
|
52
|
+
try {
|
|
53
|
+
globToRegExp(g);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
ctx.addIssue({ code: 'custom', message: `invalid glob "${g}": ${e.message}` });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const Globs = z.union([
|
|
60
|
+
z.array(Glob),
|
|
61
|
+
z.object({ extends: z.boolean().optional(), globs: z.array(Glob) }).strict(),
|
|
62
|
+
]);
|
|
63
|
+
// One place for how a pattern entry reads: the source, its flags (case-insensitive
|
|
64
|
+
// unless it sets some) and the name shown for its hits.
|
|
65
|
+
function normalise(p) {
|
|
66
|
+
if (typeof p === 'string')
|
|
67
|
+
return { source: p, flags: 'i', name: p };
|
|
68
|
+
// g and y make a RegExp remember where it last matched, which would skip hits
|
|
69
|
+
// on the next line; each line is tested on its own, so they are dropped.
|
|
70
|
+
return { source: p.regex, flags: (p.flags ?? 'i').replace(/[gy]/g, ''), name: p.name ?? p.regex };
|
|
71
|
+
}
|
|
72
|
+
const Regex = z.union([
|
|
73
|
+
z.string().min(1),
|
|
74
|
+
z.object({ regex: z.string().min(1), name: z.string().optional(), flags: z.string().optional() }).strict(),
|
|
75
|
+
]).superRefine((p, ctx) => {
|
|
76
|
+
const { source, flags } = normalise(p);
|
|
77
|
+
try {
|
|
78
|
+
new RegExp(source, flags);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
ctx.addIssue({ code: 'custom', message: `invalid regular expression ${JSON.stringify(source)}: ${e.message}` });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const Regexes = z.union([
|
|
85
|
+
z.array(Regex),
|
|
86
|
+
z.object({ extends: z.boolean().optional(), regexes: z.array(Regex) }).strict(),
|
|
87
|
+
]);
|
|
88
|
+
const shape = (keys, v) => z.object(Object.fromEntries(keys.map((k) => [k, v.optional()]))).strict();
|
|
89
|
+
export const RepoConfig = z.object({
|
|
90
|
+
attention: z.object({
|
|
91
|
+
largeChangeFiles: z.number().int().min(1).optional(),
|
|
92
|
+
}).strict().optional(),
|
|
93
|
+
paths: shape(PATH_CLASSES, Globs).optional(),
|
|
94
|
+
patterns: shape(PATTERN_CLASSES, Regexes).optional(),
|
|
95
|
+
}).strict();
|
|
96
|
+
const compile = (p) => {
|
|
97
|
+
const n = normalise(p);
|
|
98
|
+
return { ...n, re: new RegExp(n.source, n.flags) };
|
|
99
|
+
};
|
|
100
|
+
function describe(file, err) {
|
|
101
|
+
const lines = err.issues.map((i) => {
|
|
102
|
+
const where = i.path.length ? i.path.map(String).join('.') : '(top level)';
|
|
103
|
+
// Name the unknown key rather than "Unrecognized key".
|
|
104
|
+
const extra = i.code === 'unrecognized_keys' ? ` ${i.keys.map((k) => `"${k}"`).join(', ')}` : '';
|
|
105
|
+
return ` ${where}: ${i.message}${extra}`;
|
|
106
|
+
});
|
|
107
|
+
return `${file} is not a valid git-gloss config:\n${lines.join('\n')}`;
|
|
108
|
+
}
|
|
109
|
+
// Merge a repo config over the defaults. Pure, so tests can call it directly.
|
|
110
|
+
export function mergeConfig(raw) {
|
|
111
|
+
const list = (base, over, key) => {
|
|
112
|
+
if (!over)
|
|
113
|
+
return base;
|
|
114
|
+
if (Array.isArray(over))
|
|
115
|
+
return [...base, ...over];
|
|
116
|
+
const items = over[key];
|
|
117
|
+
return over.extends === false ? items : [...base, ...items];
|
|
118
|
+
};
|
|
119
|
+
const paths = {};
|
|
120
|
+
for (const k of PATH_CLASSES)
|
|
121
|
+
paths[k] = list(DEFAULTS.paths[k], raw?.paths?.[k], 'globs');
|
|
122
|
+
const patterns = {};
|
|
123
|
+
for (const k of PATTERN_CLASSES)
|
|
124
|
+
patterns[k] = list(DEFAULTS.patterns[k], raw?.patterns?.[k], 'regexes').map(compile);
|
|
125
|
+
// Editing the config changes how every later change is rated; no repo config can drop this.
|
|
126
|
+
paths.sensitive = [...paths.sensitive, CONFIG_FILE];
|
|
127
|
+
return { attention: { ...DEFAULTS.attention, ...raw?.attention }, paths, patterns };
|
|
128
|
+
}
|
|
129
|
+
// `git config gloss.attention.<key>` as a positive integer; throws naming the key.
|
|
130
|
+
function gitInt(key) {
|
|
131
|
+
const v = gitTry(['config', '--get', `gloss.attention.${key}`])?.trim();
|
|
132
|
+
if (v === undefined || v === '')
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!/^[1-9]\d*$/.test(v))
|
|
135
|
+
throw new ConfigError(`git config gloss.attention.${key} must be a positive integer, got "${v}"`);
|
|
136
|
+
return Number(v);
|
|
137
|
+
}
|
|
138
|
+
// The config is read from the base branch, not the working tree: a change under
|
|
139
|
+
// review must not be able to reclassify its own paths or add patterns that
|
|
140
|
+
// lower its own floor. The base is the merge base of the checked-out HEAD (not
|
|
141
|
+
// the commit under review) and the first of origin/main, main, origin/master,
|
|
142
|
+
// master that shares history with it, so a long-lived branch sees the config as
|
|
143
|
+
// of its fork point. With none of them there
|
|
144
|
+
// is no trusted base, so the built-in defaults apply (with a warning), never
|
|
145
|
+
// the change's own config. Editing the file in a change is itself flagged: it
|
|
146
|
+
// is always a `sensitive` path.
|
|
147
|
+
function trustedRef() {
|
|
148
|
+
// Full ref names, so a tag called "main" cannot stand in for the branch.
|
|
149
|
+
for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
|
|
150
|
+
const mb = gitTry(['merge-base', 'HEAD', b])?.trim();
|
|
151
|
+
if (mb)
|
|
152
|
+
return mb;
|
|
153
|
+
}
|
|
154
|
+
console.error('warning: no base branch (origin/main, main, origin/master, master) found, so .gloss/config.json is not read; using the built-in defaults');
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function readRepoConfig() {
|
|
158
|
+
const ref = trustedRef();
|
|
159
|
+
const text = ref ? gitTry(['show', `${ref}:${CONFIG_FILE}`]) : null;
|
|
160
|
+
if (text === null)
|
|
161
|
+
return undefined;
|
|
162
|
+
let json;
|
|
163
|
+
try {
|
|
164
|
+
json = JSON.parse(text);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
const parsed = RepoConfig.safeParse(json);
|
|
170
|
+
if (!parsed.success)
|
|
171
|
+
throw new ConfigError(describe(CONFIG_FILE, parsed.error));
|
|
172
|
+
return parsed.data;
|
|
173
|
+
}
|
|
174
|
+
// Per working directory, so a command over many commits resolves the repo once.
|
|
175
|
+
const cache = new Map();
|
|
176
|
+
export const resetConfigCache = () => cache.clear();
|
|
177
|
+
// The effective config for the repository the process runs in (the repo file
|
|
178
|
+
// as of the base branch, see trustedRef). Throws ConfigError (with the file
|
|
179
|
+
// and key) for a malformed repo config or git setting. Memoised per working
|
|
180
|
+
// directory.
|
|
181
|
+
export function loadConfig() {
|
|
182
|
+
const hit = cache.get(process.cwd());
|
|
183
|
+
if (hit)
|
|
184
|
+
return hit;
|
|
185
|
+
const raw = readRepoConfig();
|
|
186
|
+
const config = mergeConfig(raw);
|
|
187
|
+
const files = gitInt('largeChangeFiles');
|
|
188
|
+
if (files !== undefined)
|
|
189
|
+
config.attention.largeChangeFiles = files;
|
|
190
|
+
cache.set(process.cwd(), config);
|
|
191
|
+
return config;
|
|
192
|
+
}
|