@ok-ggwp/git-gloss 0.3.0 → 0.5.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 +72 -4
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- 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/ary.js +3 -3
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +58 -5
- package/dist/commands/show.js +56 -20
- package/dist/commands/signals.js +89 -0
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +248 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +29 -3
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1032 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +310 -164
- package/dist/loader.js +7 -3
- package/dist/merge.js +5 -0
- package/dist/plain.js +9 -0
- package/dist/render.js +55 -5
- package/dist/reviews.js +98 -4
- package/dist/safe-match.js +30 -0
- package/dist/schema.js +55 -4
- package/dist/selection.js +21 -0
- package/dist/signals.js +302 -0
- package/dist/validate.js +10 -1
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +42 -2
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/dist/config.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { git, GitError, 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
|
+
// Git failing (a broken repo, an unreadable object, no permission) is not the
|
|
139
|
+
// same as "no base" or "no file": the repo's own, perhaps stricter, config
|
|
140
|
+
// would silently not apply. It is not fatal either (the built-in defaults
|
|
141
|
+
// still give a floor, and refusing to rate anything would make every command
|
|
142
|
+
// fail in a damaged repo), so it is warned about on stderr, once per message,
|
|
143
|
+
// and the defaults are used.
|
|
144
|
+
const warned = new Set();
|
|
145
|
+
function warnOnce(msg) {
|
|
146
|
+
if (warned.has(msg))
|
|
147
|
+
return;
|
|
148
|
+
warned.add(msg);
|
|
149
|
+
console.error(`warning: ${msg}`);
|
|
150
|
+
}
|
|
151
|
+
// git's output, or null when it exits 1 with nothing to say (its way of
|
|
152
|
+
// answering "no"); any other failure is thrown.
|
|
153
|
+
function gitAnswer(args) {
|
|
154
|
+
try {
|
|
155
|
+
return git(args);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err instanceof GitError && err.status === 1 && /failed$/.test(err.message))
|
|
159
|
+
return null;
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// The config is read from the base branch, not the working tree: a change under
|
|
164
|
+
// review must not be able to reclassify its own paths or add patterns that
|
|
165
|
+
// lower its own floor. The base is the merge base of the checked-out HEAD (not
|
|
166
|
+
// the commit under review) and the first of origin/main, main, origin/master,
|
|
167
|
+
// master that shares history with it, so a long-lived branch sees the config as
|
|
168
|
+
// of its fork point. With none of them there
|
|
169
|
+
// is no trusted base, so the built-in defaults apply (with a warning), never
|
|
170
|
+
// the change's own config. Editing the file in a change is itself flagged: it
|
|
171
|
+
// is always a `sensitive` path.
|
|
172
|
+
function trustedRef() {
|
|
173
|
+
// No commits yet: no base, and nothing failed.
|
|
174
|
+
if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
|
|
175
|
+
return noBase();
|
|
176
|
+
let failed = false;
|
|
177
|
+
// Full ref names, so a tag called "main" cannot stand in for the branch.
|
|
178
|
+
for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
|
|
179
|
+
try {
|
|
180
|
+
if (gitAnswer(['rev-parse', '--verify', '-q', b]) === null)
|
|
181
|
+
continue;
|
|
182
|
+
const mb = gitAnswer(['merge-base', 'HEAD', b])?.trim();
|
|
183
|
+
if (mb)
|
|
184
|
+
return mb;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// A broken candidate does not hide the ones after it.
|
|
188
|
+
failed = true;
|
|
189
|
+
warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Some candidate failed: git's warning above is the reason, not an absent branch.
|
|
193
|
+
return failed ? (console.error(`warning: no working base branch, so ${CONFIG_FILE} is not read; using the built-in defaults`), null) : noBase();
|
|
194
|
+
}
|
|
195
|
+
function noBase() {
|
|
196
|
+
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');
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
// The config file at `ref`: null when the file is not there (the usual case),
|
|
200
|
+
// a warning and null when git could not tell or could not read it. Presence
|
|
201
|
+
// is asked of ls-tree (empty output: absent), not read off an error message.
|
|
202
|
+
function configAt(ref) {
|
|
203
|
+
try {
|
|
204
|
+
if (!git(['ls-tree', '--full-tree', '--name-only', ref, '--', CONFIG_FILE]).trim())
|
|
205
|
+
return null;
|
|
206
|
+
return git(['show', `${ref}:${CONFIG_FILE}`]);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
warnOnce(`${CONFIG_FILE} could not be read from the base branch (${err.message}); using the built-in defaults, so the repo's own patterns do not apply`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function readRepoConfig() {
|
|
214
|
+
const ref = trustedRef();
|
|
215
|
+
const text = ref ? configAt(ref) : null;
|
|
216
|
+
if (text === null)
|
|
217
|
+
return undefined;
|
|
218
|
+
let json;
|
|
219
|
+
try {
|
|
220
|
+
json = JSON.parse(text);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
const parsed = RepoConfig.safeParse(json);
|
|
226
|
+
if (!parsed.success)
|
|
227
|
+
throw new ConfigError(describe(CONFIG_FILE, parsed.error));
|
|
228
|
+
return parsed.data;
|
|
229
|
+
}
|
|
230
|
+
// Per working directory, so a command over many commits resolves the repo once.
|
|
231
|
+
const cache = new Map();
|
|
232
|
+
export const resetConfigCache = () => cache.clear();
|
|
233
|
+
// The effective config for the repository the process runs in (the repo file
|
|
234
|
+
// as of the base branch, see trustedRef). Throws ConfigError (with the file
|
|
235
|
+
// and key) for a malformed repo config or git setting. Memoised per working
|
|
236
|
+
// directory.
|
|
237
|
+
export function loadConfig() {
|
|
238
|
+
const hit = cache.get(process.cwd());
|
|
239
|
+
if (hit)
|
|
240
|
+
return hit;
|
|
241
|
+
const raw = readRepoConfig();
|
|
242
|
+
const config = mergeConfig(raw);
|
|
243
|
+
const files = gitInt('largeChangeFiles');
|
|
244
|
+
if (files !== undefined)
|
|
245
|
+
config.attention.largeChangeFiles = files;
|
|
246
|
+
cache.set(process.cwd(), config);
|
|
247
|
+
return config;
|
|
248
|
+
}
|
package/dist/floor.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ConfigError, loadConfig } from './config.js';
|
|
2
|
+
import { attentionForImpact, attentionRank, impactRank, maxAttention, maxImpact, raise } from './attention.js';
|
|
3
|
+
import { servesOf } from './schema.js';
|
|
4
|
+
import { analyze, CONFIG_EDIT_NOTE, partFloor, timedOutNotes } from './signals.js';
|
|
5
|
+
import { changedCount, selections } from './selection.js';
|
|
6
|
+
// The groups of a loaded gloss with their current hunks (a hunk that is gone is skipped).
|
|
7
|
+
export function groupInputs(gloss) {
|
|
8
|
+
return gloss.groups.map((g) => ({
|
|
9
|
+
group: g.group,
|
|
10
|
+
parts: g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])),
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
// The groups of an annotation over hunks of the diff (as `add` has them).
|
|
14
|
+
export function groupInputsFor(a, hunks) {
|
|
15
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
16
|
+
return a.groups.map((group) => ({
|
|
17
|
+
group,
|
|
18
|
+
parts: [...selections(group.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
|
|
19
|
+
.flatMap(([id, lines]) => { const hunk = byId.get(id); return hunk ? [{ hunk, lines }] : []; }),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
// Group floors, and the derivation of the attention for when the author declares none:
|
|
23
|
+
//
|
|
24
|
+
// start from the highest group impact (declared, else the group's floor):
|
|
25
|
+
// local skim, module look, shared and contract careful, sensitive must-read;
|
|
26
|
+
// raise it ONE step (capped at must-read), however many of these apply:
|
|
27
|
+
// - a behaviour-changing group no test-only group serves (see GroupFloor);
|
|
28
|
+
// - a speculative or defensive group that is not made only of test hunks;
|
|
29
|
+
// - more than attention.largeChangeFiles files changed;
|
|
30
|
+
// never below the change's floor (which is the highest hunk floor, and at
|
|
31
|
+
// least careful for a large change).
|
|
32
|
+
export function analyzeGroups(hunks, groups, cfg) {
|
|
33
|
+
const signals = analyze(hunks, cfg);
|
|
34
|
+
const sigOf = new Map(signals.hunks.map((s) => [s.id, s]));
|
|
35
|
+
const floors = new Map();
|
|
36
|
+
for (const { group, parts } of groups) {
|
|
37
|
+
let floor = 'local';
|
|
38
|
+
const found = [];
|
|
39
|
+
for (const { hunk, lines } of parts) {
|
|
40
|
+
const s = sigOf.get(hunk.id);
|
|
41
|
+
if (!s)
|
|
42
|
+
continue;
|
|
43
|
+
const p = partFloor(s, lines);
|
|
44
|
+
floor = maxImpact(floor, p.floor);
|
|
45
|
+
found.push({ floor: p.floor, why: p.why.map((w) => `${hunk.file}: ${w}`) });
|
|
46
|
+
}
|
|
47
|
+
const sigs = parts.map((p) => sigOf.get(p.hunk.id)).filter((s) => !!s);
|
|
48
|
+
floors.set(group.id, {
|
|
49
|
+
floor,
|
|
50
|
+
why: [...new Set(found.filter((f) => f.floor === floor).flatMap((f) => f.why))].slice(0, 3),
|
|
51
|
+
behaviour: group.necessity !== 'mechanical' && sigs.some((s) => !s.inert),
|
|
52
|
+
testsOnly: sigs.length > 0 && sigs.every((s) => s.test),
|
|
53
|
+
tested: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// A test-only group serves what it names, and what those groups serve in turn.
|
|
57
|
+
const byId = new Map(groups.map((g) => [g.group.id, g.group]));
|
|
58
|
+
for (const { group } of groups) {
|
|
59
|
+
if (!floors.get(group.id).testsOnly)
|
|
60
|
+
continue;
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const queue = servesOf(group).filter((t) => t !== 'task');
|
|
63
|
+
while (queue.length) {
|
|
64
|
+
const id = queue.shift();
|
|
65
|
+
if (seen.has(id))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(id);
|
|
68
|
+
const f = floors.get(id);
|
|
69
|
+
if (f)
|
|
70
|
+
f.tested = true;
|
|
71
|
+
const g = byId.get(id);
|
|
72
|
+
if (g)
|
|
73
|
+
queue.push(...servesOf(g).filter((t) => t !== 'task'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Derivation.
|
|
77
|
+
let top = { impact: undefined, id: '', declared: false };
|
|
78
|
+
for (const { group } of groups) {
|
|
79
|
+
const impact = group.impact ?? floors.get(group.id).floor;
|
|
80
|
+
if (impactRank(impact) > impactRank(top.impact))
|
|
81
|
+
top = { impact, id: group.id, declared: !!group.impact };
|
|
82
|
+
}
|
|
83
|
+
const reasons = [];
|
|
84
|
+
let level = top.impact ? attentionForImpact(top.impact) : signals.floor.attention;
|
|
85
|
+
if (top.impact)
|
|
86
|
+
reasons.push(`highest group impact: ${top.impact} (${top.id}, ${top.declared ? 'declared' : 'from the diff'})`);
|
|
87
|
+
const raises = [];
|
|
88
|
+
const untested = groups.filter((g) => floors.get(g.group.id).behaviour && !floors.get(g.group.id).tested).map((g) => g.group.id);
|
|
89
|
+
if (untested.length)
|
|
90
|
+
raises.push(`behaviour change without a test serving it: ${untested.join(', ')}`);
|
|
91
|
+
const hedged = groups.filter((g) => (g.group.necessity === 'speculative' || g.group.necessity === 'defensive') && !floors.get(g.group.id).testsOnly);
|
|
92
|
+
if (hedged.length)
|
|
93
|
+
raises.push(`speculative or defensive code: ${hedged.map((g) => g.group.id).join(', ')}`);
|
|
94
|
+
if (signals.stats.files > cfg.attention.largeChangeFiles)
|
|
95
|
+
raises.push(`${signals.stats.files} files changed (more than ${cfg.attention.largeChangeFiles})`);
|
|
96
|
+
if (raises.length) {
|
|
97
|
+
level = raise(level);
|
|
98
|
+
reasons.push(...raises.map((r) => `raised one step: ${r}`));
|
|
99
|
+
}
|
|
100
|
+
if (attentionRank(signals.floor.attention) > attentionRank(level)) {
|
|
101
|
+
level = maxAttention(level, signals.floor.attention);
|
|
102
|
+
reasons.push(`floor ${signals.floor.attention}: ${signals.floor.reasons[0] ?? signals.floor.impact}`);
|
|
103
|
+
}
|
|
104
|
+
return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons }, timedOut: timedOutNotes(signals.hunks) };
|
|
105
|
+
}
|
|
106
|
+
export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
|
|
107
|
+
export function ratingProblems(a, an) {
|
|
108
|
+
const warnings = [];
|
|
109
|
+
if (an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
110
|
+
warnings.push(CONFIG_EDIT_NOTE);
|
|
111
|
+
for (const g of a.groups) {
|
|
112
|
+
const f = an.groups.get(g.id);
|
|
113
|
+
// A floor of plain "module" is the baseline for source files, not a signal
|
|
114
|
+
// to warn about: a private helper may honestly be `local`.
|
|
115
|
+
if (!g.impact || !f || impactRank(f.floor) <= impactRank('module') || impactRank(g.impact) >= impactRank(f.floor))
|
|
116
|
+
continue;
|
|
117
|
+
warnings.push(`group "${g.id}" is rated impact "${g.impact}" but its floor is "${f.floor}" (${f.why.join('; ') || 'computed from the diff'}); raise it, or say why in impact_reason`);
|
|
118
|
+
}
|
|
119
|
+
let underrated = null;
|
|
120
|
+
const att = a.attention;
|
|
121
|
+
if (att && attentionRank(att.level) < attentionRank(an.floor) && !att.override_reason) {
|
|
122
|
+
const why = an.signals.floor.reasons.length ? an.signals.floor.reasons.join('; ') : `${an.signals.floor.impact}: ordinary source changes`;
|
|
123
|
+
underrated = `attention.level "${att.level}" is below the computed floor "${an.floor}" (${why}); raise it, or explain with attention.override_reason`;
|
|
124
|
+
}
|
|
125
|
+
return { warnings, underrated };
|
|
126
|
+
}
|
|
127
|
+
let warned = false;
|
|
128
|
+
// Run something that needs the signals' config; null when the config is
|
|
129
|
+
// malformed (say why once on stderr), so callers fall back to what is declared.
|
|
130
|
+
export function withSignals(fn) {
|
|
131
|
+
try {
|
|
132
|
+
return fn();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (!(err instanceof ConfigError))
|
|
136
|
+
throw err;
|
|
137
|
+
if (!warned)
|
|
138
|
+
console.error(`warning: signals unavailable: ${err.message}`);
|
|
139
|
+
warned = true;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// The analysis of a loaded gloss, or null when the signals cannot be computed.
|
|
144
|
+
export const tryAnalyze = (gloss) => withSignals(() => analyzeGloss(gloss, loadConfig()));
|
|
145
|
+
// Whether a gloss's groups get an impact column (show) or badges (html): some
|
|
146
|
+
// group declares an impact, or the diff puts a group's floor above the module
|
|
147
|
+
// baseline. Then undeclared groups show their floor as a derived impact.
|
|
148
|
+
export const showsImpact = (groups, an) => groups.some((g) => g.impact || impactRank(an?.groups.get(g.id)?.floor) > impactRank('module'));
|
package/dist/git.js
CHANGED
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { Annotation, NOTE_FORMAT } from './schema.js';
|
|
6
6
|
import { parsePath, unquote } from './diff.js';
|
|
7
|
+
import { plain } from './plain.js';
|
|
7
8
|
export const NOTES_REF = 'refs/notes/gloss';
|
|
8
9
|
// Glosses of uncommitted work (wip snapshots) live in their own notes ref,
|
|
9
10
|
// which sync never pushes, and the latest snapshot in a private ref that
|
|
@@ -18,6 +19,8 @@ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
|
18
19
|
export class GlossError extends Error {
|
|
19
20
|
}
|
|
20
21
|
export class GitError extends GlossError {
|
|
22
|
+
// git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
|
|
23
|
+
status;
|
|
21
24
|
}
|
|
22
25
|
export function git(args, input, env = {}) {
|
|
23
26
|
try {
|
|
@@ -32,7 +35,9 @@ export function git(args, input, env = {}) {
|
|
|
32
35
|
}
|
|
33
36
|
catch (err) {
|
|
34
37
|
const stderr = err.stderr?.trim();
|
|
35
|
-
|
|
38
|
+
const e = new GitError(stderr || `git ${args.join(' ')} failed`);
|
|
39
|
+
e.status = typeof err.status === 'number' ? err.status : undefined;
|
|
40
|
+
throw e;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
export function gitTry(args, input) {
|
|
@@ -142,6 +147,16 @@ export function wipStaleness(sha) {
|
|
|
142
147
|
return 'the working tree has changed since this snapshot was taken';
|
|
143
148
|
return null;
|
|
144
149
|
}
|
|
150
|
+
// The diff of uncommitted work against HEAD, as a snapshot would have it,
|
|
151
|
+
// without taking a snapshot: no ref is written (only objects). Hunk ids match
|
|
152
|
+
// those of `hunks wip` for the same working tree.
|
|
153
|
+
export function workingDiff() {
|
|
154
|
+
const { tree, excluded } = workingTree();
|
|
155
|
+
const head = headSha();
|
|
156
|
+
if (head && git(['rev-parse', `${head}^{tree}`]).trim() === tree)
|
|
157
|
+
throw new GlossError('there are no uncommitted changes');
|
|
158
|
+
return { diff: diffBetween(head ?? EMPTY_TREE, tree), excluded };
|
|
159
|
+
}
|
|
145
160
|
export function resolveCommit(rev) {
|
|
146
161
|
// "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
|
|
147
162
|
// branch or tag named wip still works when there is no snapshot, and can be
|
|
@@ -214,6 +229,15 @@ function rawNote(ref, sha) {
|
|
|
214
229
|
throw err;
|
|
215
230
|
}
|
|
216
231
|
}
|
|
232
|
+
function cleanStrings(v) {
|
|
233
|
+
if (typeof v === 'string')
|
|
234
|
+
return plain(v, true);
|
|
235
|
+
if (Array.isArray(v))
|
|
236
|
+
return v.map(cleanStrings);
|
|
237
|
+
if (v && typeof v === 'object')
|
|
238
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
|
|
239
|
+
return v;
|
|
240
|
+
}
|
|
217
241
|
export function readNote(sha) {
|
|
218
242
|
// A commit's gloss, or a wip snapshot's (they are never the same object).
|
|
219
243
|
const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
|
|
@@ -236,10 +260,12 @@ export function readNote(sha) {
|
|
|
236
260
|
throw new GlossError(`${at} was written by a newer git-gloss (format ${data.gloss}); upgrade to read it`);
|
|
237
261
|
}
|
|
238
262
|
const annotation = Annotation.safeParse(data?.annotation);
|
|
239
|
-
if (![1, 2, 3].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
|
|
263
|
+
if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
|
|
240
264
|
throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
|
|
241
265
|
}
|
|
242
|
-
|
|
266
|
+
// The gloss's text is shown in a terminal, and a note can come from anyone
|
|
267
|
+
// who can push to the remote: no escape sequences or control characters get through.
|
|
268
|
+
return { ...data, annotation: cleanStrings(annotation.data) };
|
|
243
269
|
}
|
|
244
270
|
export function writeNote(sha, note, ref = NOTES_REF) {
|
|
245
271
|
git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A small glob matcher for repo paths (always "/"-separated, relative to the
|
|
2
|
+
// repo root). Supports `**` (any number of directories, including none when
|
|
3
|
+
// written as `**/` or `/**`), `*` (anything but "/"), `?` (one character but
|
|
4
|
+
// "/"), `{a,b}` alternatives (nestable) and `\` to escape. A pattern without a
|
|
5
|
+
// "/" matches the file name at any depth, as in .gitignore ("Jenkinsfile",
|
|
6
|
+
// "*.lock"). Node's path.matchesGlob is not used: it is missing before Node 22.
|
|
7
|
+
const SPECIAL = /[.+^$()|[\]\\]/;
|
|
8
|
+
export class GlobError extends Error {
|
|
9
|
+
}
|
|
10
|
+
function translate(glob) {
|
|
11
|
+
let out = '';
|
|
12
|
+
let depth = 0;
|
|
13
|
+
for (let i = 0; i < glob.length; i++) {
|
|
14
|
+
const c = glob[i];
|
|
15
|
+
if (c === '\\') {
|
|
16
|
+
if (i + 1 >= glob.length)
|
|
17
|
+
throw new GlobError('trailing backslash');
|
|
18
|
+
out += glob[++i].replace(/[^a-zA-Z0-9]/, '\\$&');
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (c === '*') {
|
|
22
|
+
if (glob[i + 1] === '*') {
|
|
23
|
+
let j = i + 1;
|
|
24
|
+
while (glob[j + 1] === '*')
|
|
25
|
+
j++;
|
|
26
|
+
const atStart = i === 0 || glob[i - 1] === '/';
|
|
27
|
+
if (glob[j + 1] === '/' && atStart) {
|
|
28
|
+
out += '(?:.*/)?';
|
|
29
|
+
i = j + 1;
|
|
30
|
+
} // "**/": any directories, or none
|
|
31
|
+
else {
|
|
32
|
+
out += '.*';
|
|
33
|
+
i = j;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
out += '[^/]*';
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (c === '?') {
|
|
41
|
+
out += '[^/]';
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '{') {
|
|
45
|
+
depth++;
|
|
46
|
+
out += '(?:';
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === ',' && depth > 0) {
|
|
50
|
+
out += '|';
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === '}' && depth > 0) {
|
|
54
|
+
depth--;
|
|
55
|
+
out += ')';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out += SPECIAL.test(c) ? '\\' + c : c;
|
|
59
|
+
}
|
|
60
|
+
if (depth > 0)
|
|
61
|
+
throw new GlobError('unclosed "{"');
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const cache = new Map();
|
|
65
|
+
// The regular expression for a glob; throws GlobError if it is malformed.
|
|
66
|
+
export function globToRegExp(glob) {
|
|
67
|
+
let re = cache.get(glob);
|
|
68
|
+
if (!re) {
|
|
69
|
+
const body = translate(glob);
|
|
70
|
+
// Without a "/", match the last path component at any depth.
|
|
71
|
+
re = new RegExp('^' + (glob.includes('/') ? '' : '(?:.*/)?') + body + '$');
|
|
72
|
+
cache.set(glob, re);
|
|
73
|
+
}
|
|
74
|
+
return re;
|
|
75
|
+
}
|
|
76
|
+
export const matchGlob = (glob, path) => globToRegExp(glob).test(path);
|