@ok-ggwp/git-gloss 0.2.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.
@@ -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
+ }
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 } 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 } };
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
@@ -142,11 +142,22 @@ export function wipStaleness(sha) {
142
142
  return 'the working tree has changed since this snapshot was taken';
143
143
  return null;
144
144
  }
145
+ // The diff of uncommitted work against HEAD, as a snapshot would have it,
146
+ // without taking a snapshot: no ref is written (only objects). Hunk ids match
147
+ // those of `hunks wip` for the same working tree.
148
+ export function workingDiff() {
149
+ const { tree, excluded } = workingTree();
150
+ const head = headSha();
151
+ if (head && git(['rev-parse', `${head}^{tree}`]).trim() === tree)
152
+ throw new GlossError('there are no uncommitted changes');
153
+ return { diff: diffBetween(head ?? EMPTY_TREE, tree), excluded };
154
+ }
145
155
  export function resolveCommit(rev) {
146
156
  // "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
147
157
  // branch or tag named wip still works when there is no snapshot, and can be
148
158
  // reached as heads/wip or tags/wip when there is.
149
- const named = gitTry(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`])?.trim();
159
+ // --end-of-options: the revision is whatever the user typed, never a flag.
160
+ const named = gitTry(['rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`])?.trim();
150
161
  if (rev === 'wip') {
151
162
  const sha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
152
163
  if (sha) {
@@ -235,7 +246,7 @@ export function readNote(sha) {
235
246
  throw new GlossError(`${at} was written by a newer git-gloss (format ${data.gloss}); upgrade to read it`);
236
247
  }
237
248
  const annotation = Annotation.safeParse(data?.annotation);
238
- if (![1, 2, 3].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
249
+ if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
239
250
  throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
240
251
  }
241
252
  return { ...data, annotation: annotation.data };
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);