@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,101 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { GlossError, WIP_REF, gitTry, resolveCommits } from '../git.js';
4
+ import { buildPageData } from '../html/data.js';
5
+ import { escapeHtml, renderPage } from '../html/page.js';
6
+ import { listLog, listRefs } from './browse.js';
7
+ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
8
+ // A range page loads and lays out every commit in it, so a huge one would hang
9
+ // the browser. Narrowing it is a better answer than a spinner.
10
+ export const MAX_RANGE_COMMITS = 200;
11
+ // The shell page loads only its own script and style, and may frame only
12
+ // /view pages. Those carry their own policy: an inline script with a nonce.
13
+ const SHELL_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'";
14
+ class BadRequest extends GlossError {
15
+ }
16
+ // A revision, branch or range typed by the user or taken from the sidebar. An
17
+ // option-looking one is refused so it can never be read by git as a flag.
18
+ function revParam(url, name, required = true) {
19
+ const v = url.searchParams.get(name);
20
+ if (!v) {
21
+ if (required)
22
+ throw new BadRequest(`missing ?${name}=`);
23
+ return null;
24
+ }
25
+ if (v.startsWith('-') || v.length > 500 || v.includes('\0'))
26
+ throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
27
+ return v;
28
+ }
29
+ const intParam = (url, name, fallback, min, max) => {
30
+ const n = Number(url.searchParams.get(name) ?? fallback);
31
+ return Number.isInteger(n) && n >= min ? Math.min(n, max) : fallback;
32
+ };
33
+ function send(res, status, type, body, headers = {}) {
34
+ res.writeHead(status, {
35
+ 'content-type': `${type}; charset=utf-8`,
36
+ // Everything reflects the repo as it is now.
37
+ 'cache-control': 'no-store',
38
+ 'x-content-type-options': 'nosniff',
39
+ 'referrer-policy': 'no-referrer',
40
+ ...headers,
41
+ });
42
+ res.end(body);
43
+ }
44
+ const sendJson = (res, data) => send(res, 200, 'application/json', JSON.stringify(data));
45
+ function viewPage(url) {
46
+ const rev = revParam(url, 'rev');
47
+ // "wip" is the latest snapshot of uncommitted work. The server never takes
48
+ // one (that is `git gloss hunks wip`): browsing doesn't change anything.
49
+ const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
50
+ if (!target)
51
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
52
+ const { shas, merges, range } = resolveCommits(target);
53
+ if (shas.length > MAX_RANGE_COMMITS) {
54
+ throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
55
+ }
56
+ return renderPage(buildPageData(shas, range, merges));
57
+ }
58
+ function route(req, res, url) {
59
+ const shell = (name, type) => send(res, 200, type, asset(name), type === 'text/html' ? { 'content-security-policy': SHELL_CSP } : {});
60
+ switch (url.pathname) {
61
+ case '/': return shell('./index.html', 'text/html');
62
+ case '/ary.js': return shell('./client.js', 'text/javascript');
63
+ case '/ary.css': return shell('./style.css', 'text/css');
64
+ case '/api/refs': return sendJson(res, listRefs());
65
+ case '/api/log':
66
+ return sendJson(res, listLog(revParam(url, 'head'), revParam(url, 'base', false), intParam(url, 'limit', 100, 1, 500), intParam(url, 'skip', 0, 0, 1e6), (url.searchParams.get('q') ?? '').slice(0, 200)));
67
+ case '/view':
68
+ // The page may only be framed by the shell, not by another site.
69
+ return send(res, 200, 'text/html', viewPage(url), { 'content-security-policy': "frame-ancestors 'self'" });
70
+ default: return send(res, 404, 'text/plain', 'not found');
71
+ }
72
+ }
73
+ export function createAry(port) {
74
+ return createServer((req, res) => {
75
+ // A web page on another site can make the browser send requests here, and
76
+ // with DNS rebinding even read the answers. Only our own origin's host
77
+ // names get through; the server is read-only besides.
78
+ const allowed = [`127.0.0.1:${port()}`, `localhost:${port()}`];
79
+ if (!allowed.includes((req.headers.host ?? '').toLowerCase()))
80
+ return send(res, 403, 'text/plain', `forbidden host: this server answers to ${allowed.join(' and ')} (it listens on this machine only; through an SSH tunnel, forward the same port number)`);
81
+ if (req.method !== 'GET' && req.method !== 'HEAD')
82
+ return send(res, 405, 'text/plain', 'read-only', { allow: 'GET, HEAD' });
83
+ const url = new URL(req.url ?? '/', 'http://localhost');
84
+ try {
85
+ route(req, res, url);
86
+ }
87
+ catch (err) {
88
+ if (!(err instanceof GlossError)) {
89
+ console.error(err);
90
+ return send(res, 500, 'text/plain', 'internal error');
91
+ }
92
+ const status = err instanceof BadRequest ? 400 : 422;
93
+ if (url.pathname === '/view') {
94
+ send(res, status, 'text/html', `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><pre style="white-space:pre-wrap;font:14px/1.5 system-ui;padding:16px">${escapeHtml(err.message)}</pre>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'" });
95
+ }
96
+ else {
97
+ send(res, status, 'application/json', JSON.stringify({ error: err.message }));
98
+ }
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,115 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --surface-2: #f1f1ee;
5
+ --border: #e2e2dc;
6
+ --text: #1d1d1b;
7
+ --muted: #6b6b66;
8
+ --accent: #3b5bdb;
9
+ --ok: #1a7f37;
10
+ --warn-bg: #fff4e5;
11
+ --warn-border: #f0b35b;
12
+ --warn-text: #7a4300;
13
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
14
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
+ color-scheme: light;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #151514;
20
+ --surface: #1e1e1c;
21
+ --surface-2: #262624;
22
+ --border: #34342f;
23
+ --text: #e8e8e3;
24
+ --muted: #9a9a92;
25
+ --accent: #8ea2ff;
26
+ --ok: #57c47a;
27
+ --warn-bg: #33270f;
28
+ --warn-border: #a2701e;
29
+ --warn-text: #f3c77a;
30
+ color-scheme: dark;
31
+ }
32
+ }
33
+
34
+ * { box-sizing: border-box; }
35
+ html, body, #app { height: 100%; }
36
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); }
37
+ button, input, select { font: inherit; color: inherit; }
38
+
39
+ .shell { display: grid; grid-template-columns: 340px minmax(0, 1fr); height: 100%; }
40
+ @media (max-width: 800px) {
41
+ .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 45%) minmax(0, 1fr); }
42
+ }
43
+
44
+ /* Sidebar */
45
+ .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); background: var(--surface); }
46
+ .side-top { padding: 12px; border-bottom: 1px solid var(--border); display: grid; gap: 10px; }
47
+ .title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
48
+ .title strong { font-size: 15px; }
49
+ .title .repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
+ label.field, div.field { display: grid; gap: 3px; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; }
51
+ label.field > input, .rev-row input, .combo-btn, .combo-panel input {
52
+ width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
53
+ background: var(--bg); text-transform: none; letter-spacing: 0; font-size: 13px;
54
+ }
55
+ .combo { position: relative; }
56
+ .combo-btn { text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 24px; position: relative; }
57
+ .combo-btn::after { content: "▾"; position: absolute; right: 8px; color: var(--muted); }
58
+ .combo-panel {
59
+ position: absolute; z-index: 10; left: 0; right: 0; top: calc(100% + 4px);
60
+ display: grid; gap: 6px; padding: 6px; border: 1px solid var(--border); border-radius: 8px;
61
+ background: var(--surface); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
62
+ }
63
+ .combo-panel[hidden] { display: none; }
64
+ .combo-list { max-height: min(50vh, 320px); overflow: auto; }
65
+ .combo-group {
66
+ margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--border);
67
+ font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text);
68
+ }
69
+ .combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
70
+ .combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
71
+ .combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 5px; padding: 4px 8px 4px 20px; cursor: pointer; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .combo-item:hover, .combo-item.active { background: var(--surface-2); }
73
+ .combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
74
+ .combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
75
+ .rev-row { display: flex; gap: 6px; }
76
+ .rev-row input { font-family: var(--mono); font-size: 12.5px; }
77
+ .btn {
78
+ border: 1px solid var(--border); background: var(--surface); border-radius: 6px;
79
+ padding: 4px 10px; cursor: pointer; white-space: nowrap;
80
+ }
81
+ .btn:hover { background: var(--surface-2); }
82
+ .error { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text); font-size: 12.5px; }
83
+
84
+ .list { flex: 1; min-height: 0; overflow: auto; padding: 6px; }
85
+ .list h2 { margin: 10px 8px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
86
+ .row {
87
+ display: block; width: 100%; text-align: left; cursor: pointer;
88
+ border: 1px solid transparent; background: none; border-radius: 6px; padding: 6px 8px;
89
+ }
90
+ .row:hover { background: var(--surface-2); }
91
+ .row[aria-current="true"] { background: var(--surface-2); border-color: var(--accent); }
92
+ .row .subject {
93
+ /* the whole point of the list: enough of the subject to tell commits apart */
94
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
95
+ overflow: hidden; overflow-wrap: anywhere;
96
+ }
97
+ .row .meta { display: flex; flex-wrap: wrap; align-items: baseline; margin-top: 2px; color: var(--muted); font-size: 12px; }
98
+ .row .marks { margin-left: auto; padding-left: 10px; display: inline-flex; gap: 10px; white-space: nowrap; }
99
+ .sha { font-family: var(--mono); font-size: 12px; }
100
+ .mark.glossed { color: var(--ok); }
101
+ .mark.reviewed { color: var(--accent); }
102
+ .mark.warn { color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 5px; }
103
+ .search-bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
104
+ .search-bar input { width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); font-size: 13px; }
105
+ .empty { color: var(--muted); padding: 8px; }
106
+ .more { margin: 6px 8px; }
107
+
108
+ /* Viewer */
109
+ .viewer { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
110
+ .viewer-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface); }
111
+ .viewer-bar .what { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .viewer-bar a { color: var(--accent); text-decoration: none; }
113
+ .viewer-bar a:hover { text-decoration: underline; }
114
+ iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: var(--bg); }
115
+ .placeholder { flex: 1; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; }
@@ -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
- return { annotation: { ...a, groups }, kept: groups.length, total: a.groups.length, dropped, gaps: uncovered(current, claims), partialMatch };
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
@@ -1,6 +1,7 @@
1
1
  import { GlossError } from './git.js';
2
2
  import * as add from './commands/add.js';
3
3
  import * as adopt from './commands/adopt.js';
4
+ import * as ary from './commands/ary.js';
4
5
  import * as carry from './commands/carry.js';
5
6
  import * as html from './commands/html.js';
6
7
  import * as hunks from './commands/hunks.js';
@@ -9,11 +10,12 @@ import * as review from './commands/review.js';
9
10
  import * as reviews from './commands/reviews.js';
10
11
  import * as schema from './commands/schema.js';
11
12
  import * as show from './commands/show.js';
13
+ import * as signals from './commands/signals.js';
12
14
  import * as sync from './commands/sync.js';
13
15
  import * as versions from './commands/versions.js';
14
16
  import * as why from './commands/why.js';
15
17
  const COMMANDS = {
16
- init, hunks, add, adopt, carry, show, html, why, review, reviews, sync, versions, schema,
18
+ init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
17
19
  };
18
20
  function help() {
19
21
  return 'usage: git gloss <command> [options]\n\n' +
@@ -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
- export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--dry-run] attach an annotation (JSON from file or stdin)';
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"`);
@@ -0,0 +1,35 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { createAry } from '../ary/server.js';
3
+ import { GlossError, git } from '../git.js';
4
+ import { openInBrowser } from './html.js';
5
+ export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page';
6
+ // Starts a read-only server on this machine and keeps running until stopped.
7
+ // The browser page lists branches and commits and shows each selection with
8
+ // the same view as `git gloss html`.
9
+ export function run(argv) {
10
+ const { values } = parseArgs({
11
+ args: argv,
12
+ options: { port: { type: 'string' }, 'no-open': { type: 'boolean' } },
13
+ });
14
+ // Number('') and Number(' ') are 0, which would quietly mean "any port".
15
+ const port = values.port === undefined ? 0 : /^\d+$/.test(values.port) ? Number(values.port) : NaN;
16
+ if (!Number.isInteger(port) || port > 65535)
17
+ throw new GlossError(`not a port: ${values.port}`);
18
+ git(['rev-parse', '--git-dir']); // fail early outside a repository
19
+ let boundPort = port;
20
+ const server = createAry(() => boundPort);
21
+ server.on('error', (err) => {
22
+ console.error(`error: ${err.code === 'EADDRINUSE' ? `port ${port} is in use` : err.message}`);
23
+ process.exitCode = 1;
24
+ });
25
+ // Loopback only: the view includes the code of your commits.
26
+ server.listen(port, '127.0.0.1', () => {
27
+ boundPort = server.address().port;
28
+ const url = `http://127.0.0.1:${boundPort}/`;
29
+ console.log(url);
30
+ console.error('serving glosses read-only; press Ctrl-C to stop');
31
+ if (!values['no-open'])
32
+ openInBrowser(url);
33
+ });
34
+ return 0;
35
+ }
@@ -7,7 +7,7 @@ import { resolveCommits } from '../git.js';
7
7
  import { buildPageData } from '../html/data.js';
8
8
  import { renderPage } from '../html/page.js';
9
9
  export const usage = 'html [<commit> | <range>] [-o <file>] [--open] write a self-contained HTML view of a gloss, or of every commit in a range';
10
- function openInBrowser(file) {
10
+ export function openInBrowser(file) {
11
11
  const [cmd, args] = process.platform === 'darwin' ? ['open', [file]]
12
12
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', file]]
13
13
  : ['xdg-open', [file]];
@@ -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
- page(hunks.map((h) => hunkLabel(h) + style('dim', ` (${changedCount(h)} changed)`) + (values.stat ? '' : '\n' + numbered(h) + '\n')).join('\n'));
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
  }
@@ -122,7 +122,12 @@ export function run(argv = []) {
122
122
  // The hooks run `git gloss`; a git without it on PATH (a GUI client, a
123
123
  // different shell) would print an error after every commit and push.
124
124
  if (spawnSync('sh', ['-c', 'command -v git-gloss'], { stdio: 'ignore' }).status !== 0) {
125
- console.log('! git-gloss is not on PATH here; the hooks need it wherever git runs (see README: npm link)');
125
+ // Without it, every commit and push prints an error and the gloss isn't
126
+ // moved, synced or carried, so say that plainly.
127
+ console.log('! git-gloss is not on PATH in this shell, so the hooks will fail on every commit and push.');
128
+ console.log(' Their errors are only warnings, and glosses are not adopted, carried or synced until it is fixed.');
129
+ console.log(' Either install it globally (npm install -g @ok-ggwp/git-gloss), or, for a project install,');
130
+ console.log(' put its node_modules/.bin first on PATH in each hook (see README: "Project installs").');
126
131
  }
127
132
  installHook('pre-push', HOOK, '`git push` now syncs glosses with the remote it pushes to', [
128
133
  'git remote | grep -qxF -- "$1" && { GIT_TERMINAL_PROMPT=0 git gloss sync "$1" --pushing >&2 ||',
@@ -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 { NECESSITY } from '../schema.js';
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 only = values.only?.split(',').map((s) => s.trim());
32
- const unknown = only?.filter((n) => !NECESSITY.includes(n));
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
- out.push(reviewsLine(sha));
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) => !only || only.includes(g.group.necessity))
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 (values.group || values.full || values.diff) {
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 = sorted.map((g) => {
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
- const intentCol = 2 + 11 + 2 + idW + 2;
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));