doctrina-cli 0.21.0 → 0.22.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 +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +4 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/kiro/README.md +22 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { paletteOf } from "./console-model.js";
|
|
3
|
+
|
|
4
|
+
// THE CONSOLE, AS A SCREEN (change 0257).
|
|
5
|
+
//
|
|
6
|
+
// One function turns the state into the lines of a frame and the place the
|
|
7
|
+
// cursor sits. It writes nothing: the command module paints what this
|
|
8
|
+
// returns, and the tests read it.
|
|
9
|
+
//
|
|
10
|
+
// The layout is the project's own shape. The gates run across the top,
|
|
11
|
+
// because they are what every command ends on; the conversation is the
|
|
12
|
+
// body; the panel beside it is the tree — what is open, at which stage,
|
|
13
|
+
// and the next step — and the line a human types on is at the bottom, where
|
|
14
|
+
// a terminal has always had it. Narrower than 84 columns the panel folds
|
|
15
|
+
// into one strip under the gates, so a split pane or a phone still works.
|
|
16
|
+
|
|
17
|
+
/** @typedef {import("./console-model.js").State} State */
|
|
18
|
+
/** @typedef {import("./console-model.js").Entry} Entry */
|
|
19
|
+
/** @typedef {{ t: string, s?: string }} Seg text and a style name */
|
|
20
|
+
|
|
21
|
+
const STYLES = /** @type {Record<string, string>} */ ({
|
|
22
|
+
brand: "38;5;179", brandBold: "1;38;5;179", bold: "1", dim: "2", gray: "90",
|
|
23
|
+
cyan: "36", green: "32", yellow: "33", red: "31", inverse: "7", selected: "1;38;5;179",
|
|
24
|
+
});
|
|
25
|
+
// Every glyph the console draws is one a plain monospace font carries: box
|
|
26
|
+
// drawing, ◆ ● ○ ■ □ ►. The spinner is braille where the terminal's font
|
|
27
|
+
// has it, and four ASCII strokes in the legacy Windows console.
|
|
28
|
+
const SPINNER = process.platform === "win32" && !process.env.WT_SESSION
|
|
29
|
+
? ["-", "\\", "|", "/"]
|
|
30
|
+
: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
31
|
+
const PALETTE_ROWS = 8;
|
|
32
|
+
const INPUT_ROWS = 4;
|
|
33
|
+
|
|
34
|
+
/** Columns a character takes: 0 for a combining mark, 2 for wide East Asian and emoji. */
|
|
35
|
+
function charWidth(ch) {
|
|
36
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
37
|
+
if (cp === 0x200d || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0;
|
|
38
|
+
if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
39
|
+
(cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
|
40
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)) return 2;
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The columns a plain string takes. */
|
|
45
|
+
export function textWidth(s) {
|
|
46
|
+
let w = 0;
|
|
47
|
+
for (const ch of String(s)) w += charWidth(ch);
|
|
48
|
+
return w;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const segsWidth = (/** @type {Seg[]} */ segs) => segs.reduce((n, g) => n + textWidth(g.t), 0);
|
|
52
|
+
|
|
53
|
+
/** Cut segments to `width` columns (an ellipsis marks the cut) and pad to it. */
|
|
54
|
+
function fit(segs, width, color) {
|
|
55
|
+
let left = width;
|
|
56
|
+
let out = "";
|
|
57
|
+
const total = segsWidth(segs);
|
|
58
|
+
for (const g of segs) {
|
|
59
|
+
if (left <= 0) break;
|
|
60
|
+
let t = "";
|
|
61
|
+
let w = 0;
|
|
62
|
+
for (const ch of g.t) {
|
|
63
|
+
const cw = charWidth(ch);
|
|
64
|
+
if (w + cw > left) break;
|
|
65
|
+
t += ch;
|
|
66
|
+
w += cw;
|
|
67
|
+
}
|
|
68
|
+
if (total > width && w === left && w > 0) {
|
|
69
|
+
// The last column of a cut line is the ellipsis.
|
|
70
|
+
const chars = Array.from(t);
|
|
71
|
+
let drop = 0;
|
|
72
|
+
while (chars.length > 0 && drop < 1) drop += charWidth(chars.pop() ?? "");
|
|
73
|
+
t = `${chars.join("")}…${" ".repeat(Math.max(0, drop - 1))}`;
|
|
74
|
+
}
|
|
75
|
+
left -= w;
|
|
76
|
+
out += color && g.s && STYLES[g.s] ? `\x1b[${STYLES[g.s]}m${t}\x1b[0m` : t;
|
|
77
|
+
}
|
|
78
|
+
return out + " ".repeat(Math.max(0, left));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wrap segments at word boundaries. A word longer than the line is split.
|
|
83
|
+
* @param {Seg[]} segs
|
|
84
|
+
* @param {number} width
|
|
85
|
+
* @param {{ first?: Seg[], rest?: Seg[] }} [lead] what each line starts with
|
|
86
|
+
* @returns {Seg[][]}
|
|
87
|
+
*/
|
|
88
|
+
function wrap(segs, width, { first = [], rest = [] } = {}) {
|
|
89
|
+
/** @type {Seg[][]} */
|
|
90
|
+
const lines = [];
|
|
91
|
+
let line = [...first];
|
|
92
|
+
let used = segsWidth(first);
|
|
93
|
+
const lead = segsWidth(rest);
|
|
94
|
+
const room = Math.max(4, width - lead);
|
|
95
|
+
const newline = () => {
|
|
96
|
+
lines.push(line);
|
|
97
|
+
line = [...rest];
|
|
98
|
+
used = lead;
|
|
99
|
+
};
|
|
100
|
+
for (const g of segs) {
|
|
101
|
+
for (const token of g.t.split(/(\s+)/)) {
|
|
102
|
+
if (token === "") continue;
|
|
103
|
+
const w = textWidth(token);
|
|
104
|
+
if (/^\s+$/.test(token)) {
|
|
105
|
+
// Kept even when it lands on the last column: the word after it then
|
|
106
|
+
// starts the next line instead of joining this one.
|
|
107
|
+
if (used > (lines.length === 0 ? segsWidth(first) : lead) && used + w <= width) {
|
|
108
|
+
line.push({ t: token, s: g.s });
|
|
109
|
+
used += w;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (used + w > width && used > (lines.length === 0 ? segsWidth(first) : lead)) newline();
|
|
114
|
+
if (w <= room) {
|
|
115
|
+
line.push({ t: token, s: g.s });
|
|
116
|
+
used += w;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Longer than a line: cut it, after a hyphen or a slash where one is near.
|
|
120
|
+
let piece = "";
|
|
121
|
+
let pw = 0;
|
|
122
|
+
for (const ch of token) {
|
|
123
|
+
const cw = charWidth(ch);
|
|
124
|
+
if (used + pw + cw > width) {
|
|
125
|
+
const soft = Math.max(piece.lastIndexOf("-"), piece.lastIndexOf("/"), piece.lastIndexOf("_"));
|
|
126
|
+
const keep = soft >= piece.length - 14 && soft > 0 ? piece.slice(0, soft + 1) : piece;
|
|
127
|
+
const carry = piece.slice(keep.length);
|
|
128
|
+
line.push({ t: keep, s: g.s });
|
|
129
|
+
newline();
|
|
130
|
+
piece = carry;
|
|
131
|
+
pw = textWidth(carry);
|
|
132
|
+
}
|
|
133
|
+
piece += ch;
|
|
134
|
+
pw += cw;
|
|
135
|
+
}
|
|
136
|
+
line.push({ t: piece, s: g.s });
|
|
137
|
+
used += pw;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
lines.push(line);
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `**bold**` and `` `code` `` in what an agent wrote. */
|
|
145
|
+
function inline(text, base) {
|
|
146
|
+
/** @type {Seg[]} */
|
|
147
|
+
const out = [];
|
|
148
|
+
let rest = String(text);
|
|
149
|
+
const re = /\*\*([^*]+)\*\*|`([^`]+)`/;
|
|
150
|
+
while (rest !== "") {
|
|
151
|
+
const m = re.exec(rest);
|
|
152
|
+
if (!m) {
|
|
153
|
+
out.push({ t: rest, s: base });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (m.index > 0) out.push({ t: rest.slice(0, m.index), s: base });
|
|
157
|
+
out.push(m[1] !== undefined ? { t: m[1].replace(/`/g, ""), s: "bold" } : { t: m[2], s: "cyan" });
|
|
158
|
+
rest = rest.slice(m.index + m[0].length);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const LEAD = /^(\s*)(ok\b|✓|✗|error:|fail\b|warn:?|hold:|note:|direct:|created\b|updated\b|opened\b|added\b|converted\b|routed\b|ticked\b|rebuilt\b)/;
|
|
164
|
+
const LEAD_STYLE = /** @type {Record<string, string>} */ ({ ok: "green", "✓": "green", "✗": "red", "error:": "red", fail: "red", "warn:": "yellow", warn: "yellow", "hold:": "yellow", "note:": "yellow", "direct:": "green" });
|
|
165
|
+
|
|
166
|
+
/** A line a command printed, with the word it leads with in its colour. */
|
|
167
|
+
function outputSegs(text) {
|
|
168
|
+
if (/^\s*[─═-]{4,}/.test(text) || /^\s*<!--/.test(text)) return [{ t: text, s: "gray" }];
|
|
169
|
+
if (/^#+\s/.test(text)) return [{ t: text.replace(/^#+\s/, ""), s: "bold" }];
|
|
170
|
+
const m = LEAD.exec(text);
|
|
171
|
+
if (!m) return inline(text, undefined);
|
|
172
|
+
return [{ t: m[1] }, { t: m[2], s: LEAD_STYLE[m[2]] ?? "green" }, ...inline(text.slice(m[0].length), undefined)];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What the console opens on. */
|
|
176
|
+
function banner(width) {
|
|
177
|
+
const lead = { first: [{ t: " " }], rest: [{ t: " " }] };
|
|
178
|
+
return [
|
|
179
|
+
[{ t: " ◆ ", s: "brandBold" }, { t: "Doctrina", s: "bold" }],
|
|
180
|
+
...wrap([{ t: "specs are the truth · changes go through the gates", s: "gray" }], width, lead),
|
|
181
|
+
[],
|
|
182
|
+
...wrap([{ t: "Say what you want changed", s: "bold" }, { t: " and the agent opens the change, writes the delta, implements it and closes it. Every step lands in this window." }], width, lead),
|
|
183
|
+
[],
|
|
184
|
+
...(width >= 62
|
|
185
|
+
? [
|
|
186
|
+
[{ t: " /", s: "brandBold" }, { t: " a command " }, { t: "/next /status /work \"…\" /close <id>", s: "gray" }],
|
|
187
|
+
[{ t: " @", s: "brandBold" }, { t: " an artifact " }, { t: "@spec:… @change:… @skill:… @adr:…", s: "gray" }],
|
|
188
|
+
]
|
|
189
|
+
: [
|
|
190
|
+
[{ t: " /", s: "brandBold" }, { t: " a command " }, { t: "/next /work \"…\"", s: "gray" }],
|
|
191
|
+
[{ t: " @", s: "brandBold" }, { t: " an artifact " }, { t: "@spec:… @change:…", s: "gray" }],
|
|
192
|
+
]),
|
|
193
|
+
[{ t: " ?", s: "brandBold" }, { t: " help" }],
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The conversation as wrapped lines.
|
|
199
|
+
* @param {Entry[]} transcript
|
|
200
|
+
* @param {number} width
|
|
201
|
+
* @returns {Seg[][]}
|
|
202
|
+
*/
|
|
203
|
+
function transcriptLines(transcript, width) {
|
|
204
|
+
/** @type {Seg[][]} */
|
|
205
|
+
const out = [];
|
|
206
|
+
let fenced = false;
|
|
207
|
+
for (const e of transcript) {
|
|
208
|
+
switch (e.kind) {
|
|
209
|
+
case "banner":
|
|
210
|
+
out.push(...banner(width));
|
|
211
|
+
break;
|
|
212
|
+
case "blank":
|
|
213
|
+
if (out.length > 0 && out[out.length - 1].length > 0) out.push([]);
|
|
214
|
+
break;
|
|
215
|
+
case "user":
|
|
216
|
+
out.push(...wrap([{ t: e.text, s: "bold" }], width, { first: [{ t: " › ", s: "brandBold" }], rest: [{ t: " " }] }));
|
|
217
|
+
break;
|
|
218
|
+
case "agent":
|
|
219
|
+
for (const [i, para] of e.text.replace(/\s+$/, "").split("\n").entries()) {
|
|
220
|
+
if (/^\s*```/.test(para)) {
|
|
221
|
+
fenced = !fenced;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const lead = { first: [i === 0 ? { t: " ◆ ", s: "brand" } : { t: " " }], rest: [{ t: " " }] };
|
|
225
|
+
if (fenced) out.push(...wrap([{ t: para, s: "cyan" }], width, lead));
|
|
226
|
+
else if (/^#+\s/.test(para)) out.push(...wrap([{ t: para.replace(/^#+\s/, ""), s: "bold" }], width, lead));
|
|
227
|
+
else out.push(...wrap(inline(para, undefined), width, lead));
|
|
228
|
+
}
|
|
229
|
+
fenced = false;
|
|
230
|
+
break;
|
|
231
|
+
case "tool": {
|
|
232
|
+
const [name, ...detail] = e.text.split(" ");
|
|
233
|
+
out.push(...wrap([{ t: name, s: "cyan" }, { t: detail.length ? ` ${detail.join(" ")}` : "", s: "gray" }], width, { first: [{ t: " └ ", s: "gray" }], rest: [{ t: " " }] }));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "note":
|
|
237
|
+
out.push(...wrap([{ t: e.text, s: "gray" }], width, { first: [{ t: " " }], rest: [{ t: " " }] }));
|
|
238
|
+
break;
|
|
239
|
+
case "peer": {
|
|
240
|
+
// What crossed between consoles: who, then what.
|
|
241
|
+
const [head, ...body] = e.text.split(" ");
|
|
242
|
+
out.push(...wrap([{ t: head, s: "brandBold" }, { t: body.length ? ` ${body.join(" ")}` : "", s: "bold" }], width, { first: [{ t: " ↔ ", s: "brandBold" }], rest: [{ t: " " }] }));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "quote":
|
|
246
|
+
for (const para of e.text.replace(/\s+$/, "").split("\n")) {
|
|
247
|
+
if (/^\s*```/.test(para)) continue;
|
|
248
|
+
out.push(...wrap(inline(para.replace(/^#+\s/, ""), undefined), width, { first: [{ t: " │ ", s: "gray" }], rest: [{ t: " │ ", s: "gray" }] }));
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
case "ok":
|
|
252
|
+
out.push([{ t: " ✓ ", s: "green" }, { t: e.text, s: "gray" }]);
|
|
253
|
+
break;
|
|
254
|
+
case "fail":
|
|
255
|
+
out.push(...wrap([{ t: e.text, s: "red" }], width, { first: [{ t: " ✗ ", s: "red" }], rest: [{ t: " " }] }));
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
out.push(...wrap(outputSegs(e.text), width, { first: [{ t: " " }], rest: [{ t: " " }] }));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const LIGHT = /** @type {Record<string, [string, string]>} */ ({ ok: ["✓", "green"], warn: ["!", "yellow"], fail: ["✗", "red"], off: ["·", "gray"] });
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `doctrina next` as it is typed here: `/next`. A command that belongs to
|
|
268
|
+
* another repository is shown as it is run there, with the repository named.
|
|
269
|
+
*/
|
|
270
|
+
function asTyped(command) {
|
|
271
|
+
const text = command ?? "";
|
|
272
|
+
const elsewhere = /^cd\s+(\S+)\s+&&\s+(.+)$/.exec(text);
|
|
273
|
+
if (elsewhere) return `in ${elsewhere[1].replace(/[\\/]+$/, "").split(/[\\/]/).pop()}: ${elsewhere[2]}`;
|
|
274
|
+
return text.replace(/^doctrina\s+/, "/");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const STAGES = ["plan", "build", "close"];
|
|
278
|
+
|
|
279
|
+
/** The stage track of one open change: ● plan ─ ○ build ─ ○ close. */
|
|
280
|
+
function stageTrack(stage) {
|
|
281
|
+
const at = STAGES.indexOf(stage);
|
|
282
|
+
/** @type {Seg[]} */
|
|
283
|
+
const out = [];
|
|
284
|
+
STAGES.forEach((name, i) => {
|
|
285
|
+
if (i > 0) out.push({ t: " ─ ", s: "gray" });
|
|
286
|
+
out.push(i < at ? { t: `✓ ${name}`, s: "green" } : (i === at ? { t: `● ${name}`, s: "brandBold" } : { t: `○ ${name}`, s: "gray" }));
|
|
287
|
+
});
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function taskBar(done, total, cells = 10) {
|
|
292
|
+
const filled = total > 0 ? Math.round((done / total) * cells) : 0;
|
|
293
|
+
return [{ t: "■".repeat(filled), s: "brand" }, { t: "□".repeat(cells - filled), s: "gray" }, { t: ` ${done}/${total}`, s: "gray" }];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The first `n` lines of a wrapped block; an ellipsis says there was more. */
|
|
297
|
+
function cut(lines, n) {
|
|
298
|
+
if (lines.length <= n) return lines;
|
|
299
|
+
const kept = lines.slice(0, n);
|
|
300
|
+
kept[n - 1] = [...kept[n - 1], { t: "…", s: "gray" }];
|
|
301
|
+
return kept;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The panel: what is open, at which stage, and what comes next.
|
|
306
|
+
* @param {State} s
|
|
307
|
+
* @param {number} width
|
|
308
|
+
* @returns {Seg[][]}
|
|
309
|
+
*/
|
|
310
|
+
function panelLines(s, width) {
|
|
311
|
+
const p = s.panel;
|
|
312
|
+
/** @type {Seg[][]} */
|
|
313
|
+
const out = [];
|
|
314
|
+
const head = (/** @type {string} */ t) => out.push([{ t: ` ${t}`, s: "gray" }]);
|
|
315
|
+
if (!p) return [[{ t: " reading the project…", s: "gray" }]];
|
|
316
|
+
const inner = width - 2;
|
|
317
|
+
if (p.kind === "none") {
|
|
318
|
+
head("NO PROJECT HERE");
|
|
319
|
+
out.push(...wrap([{ t: "This directory has no .doctrina/ yet." }], inner, { first: [{ t: " " }], rest: [{ t: " " }] }));
|
|
320
|
+
}
|
|
321
|
+
if (p.kind === "workspace") {
|
|
322
|
+
head("PROJECTS");
|
|
323
|
+
for (const pr of p.projects) {
|
|
324
|
+
const [glyph, style] = pr.state === "ready" ? LIGHT.ok : (pr.state === "missing" ? LIGHT.fail : LIGHT.warn);
|
|
325
|
+
out.push([{ t: ` ${glyph} `, s: style }, { t: pr.name, s: "bold" }, { t: ` ${pr.state === "ready" ? pr.reason : pr.state}`, s: "gray" }]);
|
|
326
|
+
}
|
|
327
|
+
out.push([]);
|
|
328
|
+
}
|
|
329
|
+
// The consoles open in other terminals, when there are any.
|
|
330
|
+
if (s.peers.length > 0) {
|
|
331
|
+
head("CONSOLES");
|
|
332
|
+
for (const peer of s.peers.slice(0, 5)) {
|
|
333
|
+
out.push([{ t: " ↔ ", s: "brand" }, { t: peer.name, s: "bold" }, { t: ` ${peer.busy ? "busy" : "idle"}${peer.agent ? ` · ${peer.agent}` : " · no agent"}${peer.listening ? "" : " · deaf"}`, s: "gray" }]);
|
|
334
|
+
}
|
|
335
|
+
if (s.peers.length > 5) out.push([{ t: ` … and ${s.peers.length - 5} more — /peers`, s: "gray" }]);
|
|
336
|
+
out.push([]);
|
|
337
|
+
}
|
|
338
|
+
if (p.kind !== "none") {
|
|
339
|
+
head(p.kind === "workspace" ? "OPEN DELIVERIES" : "OPEN WORK");
|
|
340
|
+
if (p.open.length === 0) out.push([{ t: " nothing open", s: "gray" }]);
|
|
341
|
+
for (const ch of p.open.slice(0, 3)) {
|
|
342
|
+
out.push([{ t: ` ${ch.id}`, s: "bold" }]);
|
|
343
|
+
if (p.kind === "project") {
|
|
344
|
+
out.push([{ t: " " }, ...stageTrack(ch.stage)]);
|
|
345
|
+
out.push([{ t: " " }, ...taskBar(ch.done, ch.total), { t: " tasks", s: "gray" }]);
|
|
346
|
+
} else {
|
|
347
|
+
out.push([{ t: " " }, ...taskBar(ch.done, ch.total), { t: " changes closed", s: "gray" }]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (p.open.length > 3) out.push([{ t: ` … and ${p.open.length - 3} more`, s: "gray" }]);
|
|
351
|
+
out.push([]);
|
|
352
|
+
}
|
|
353
|
+
head("NEXT");
|
|
354
|
+
if (p.next.length === 0) out.push([{ t: " nothing pending", s: "gray" }]);
|
|
355
|
+
p.next.slice(0, 3).forEach((a, i) => {
|
|
356
|
+
out.push(...cut(wrap([{ t: a.text }], inner, { first: [{ t: ` ${i + 1} `, s: "brand" }], rest: [{ t: " " }] }), 3));
|
|
357
|
+
if (a.command) out.push(...cut(wrap([{ t: asTyped(a.command), s: "cyan" }], inner, { first: [{ t: " " }], rest: [{ t: " " }] }), 2));
|
|
358
|
+
});
|
|
359
|
+
if (p.recent.length > 0) {
|
|
360
|
+
out.push([]);
|
|
361
|
+
head("RECENTLY CLOSED");
|
|
362
|
+
for (const r of p.recent.slice(0, 3)) out.push([{ t: " ✓ ", s: "green" }, { t: r.id, s: "gray" }]);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The gates, as lights: index ✓ coverage ✓ 100% … */
|
|
368
|
+
function gateSegs(s, roomy) {
|
|
369
|
+
const p = s.panel;
|
|
370
|
+
/** @type {Seg[]} */
|
|
371
|
+
const out = [{ t: " " }];
|
|
372
|
+
if (!p) return [{ t: " reading the project…", s: "gray" }];
|
|
373
|
+
if (p.kind === "none") return [{ t: " not a Doctrina project — ", s: "gray" }, { t: "/init", s: "cyan" }, { t: " sets it up, ", s: "gray" }, { t: "/onboard", s: "cyan" }, { t: " walks you through", s: "gray" }];
|
|
374
|
+
p.gates.forEach((g, i) => {
|
|
375
|
+
const [glyph, style] = LIGHT[g.state];
|
|
376
|
+
if (i > 0) out.push({ t: " " });
|
|
377
|
+
out.push({ t: `${g.id} `, s: g.state === "off" ? "gray" : undefined }, { t: glyph, s: style });
|
|
378
|
+
if (roomy && g.id !== "index") out.push({ t: ` ${g.text}`, s: "gray" });
|
|
379
|
+
});
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function countsText(p) {
|
|
384
|
+
if (!p || p.kind !== "project") return "";
|
|
385
|
+
return `${p.counts.specs} spec${p.counts.specs === 1 ? "" : "s"} · ${p.counts.decisions} ADR${p.counts.decisions === 1 ? "" : "s"} · ${p.counts.closed} closed`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Two groups on one line: the left one, and the right one flush right. */
|
|
389
|
+
function spread(left, right, width, color) {
|
|
390
|
+
const rw = segsWidth(right);
|
|
391
|
+
if (rw === 0 || rw + 2 > width) return fit(left, width, color);
|
|
392
|
+
return fit(left, width - rw, color) + fit(right, rw, color);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* One frame.
|
|
397
|
+
* @param {State} s
|
|
398
|
+
* @param {{ cols: number, rows: number, color?: boolean, now?: number }} size
|
|
399
|
+
* @returns {{ lines: string[], cursor: { row: number, col: number } }}
|
|
400
|
+
*/
|
|
401
|
+
export function renderFrame(s, { cols, rows, color = true, now = Date.now() }) {
|
|
402
|
+
if (cols < 30 || rows < 10) {
|
|
403
|
+
const lines = Array.from({ length: rows }, (_, i) => (i === 0 ? fit([{ t: "Doctrina — the window is too small" }], cols, color) : " ".repeat(cols)));
|
|
404
|
+
return { lines, cursor: { row: 0, col: 0 } };
|
|
405
|
+
}
|
|
406
|
+
const p = s.panel;
|
|
407
|
+
const agent = s.agents.find((a) => a.id === s.agent) ?? null;
|
|
408
|
+
const panelW = s.showPanel ? (cols >= 104 ? 38 : (cols >= 84 ? 32 : 0)) : 0;
|
|
409
|
+
|
|
410
|
+
// Header: the mark, the project, and who answers.
|
|
411
|
+
/** @type {string[]} */
|
|
412
|
+
const top = [];
|
|
413
|
+
const name = p?.project ? [{ t: " │ ", s: "gray" }, { t: p.project, s: "bold" }, ...(p.kind === "workspace" ? [{ t: " workspace", s: "gray" }] : [])] : [];
|
|
414
|
+
// The model a person chose, or the one the agent said it is running.
|
|
415
|
+
const model = agent ? (s.models[agent.id] ?? s.reported[agent.id] ?? "") : "";
|
|
416
|
+
// How many other consoles are in the room, when any is.
|
|
417
|
+
const room = s.peers.length > 0 ? [{ t: `↔ ${s.peers.length} `, s: "brand" }] : [];
|
|
418
|
+
const who = agent
|
|
419
|
+
? [...room, { t: "● ", s: "green" }, { t: agent.id }, ...(model && cols >= 72 ? [{ t: ` · ${model}`, s: "gray" }] : []), { t: ` v${p?.cli ?? ""} `, s: "gray" }]
|
|
420
|
+
: [...room, { t: "○ no agent", s: "gray" }, { t: ` v${p?.cli ?? ""} `, s: "gray" }];
|
|
421
|
+
top.push(spread([{ t: " ◆ ", s: "brandBold" }, { t: "Doctrina", s: "bold" }, ...name], who, cols, color));
|
|
422
|
+
const counts = countsText(p);
|
|
423
|
+
top.push(spread(gateSegs(s, cols >= 72), cols >= 96 && counts ? [{ t: `${counts} `, s: "gray" }] : [], cols, color));
|
|
424
|
+
// Without room for the panel, its first facts ride one strip.
|
|
425
|
+
if (panelW === 0 && s.showPanel && p && p.kind !== "none") {
|
|
426
|
+
const first = p.open[0];
|
|
427
|
+
const step = p.next[0];
|
|
428
|
+
/** @type {Seg[]} */
|
|
429
|
+
const strip = [{ t: " " }];
|
|
430
|
+
if (first) strip.push({ t: "► ", s: "brand" }, { t: first.id, s: "bold" }, { t: ` ${first.stage} ${first.done}/${first.total}`, s: "gray" });
|
|
431
|
+
else strip.push({ t: "nothing open", s: "gray" });
|
|
432
|
+
if (step) strip.push({ t: " next ", s: "gray" }, step.command ? { t: asTyped(step.command), s: "cyan" } : { t: step.text });
|
|
433
|
+
top.push(fit(strip, cols, color));
|
|
434
|
+
}
|
|
435
|
+
top.push(fit([{ t: "─".repeat(cols), s: "gray" }], cols, color));
|
|
436
|
+
|
|
437
|
+
// The line being typed, wrapped.
|
|
438
|
+
const typed = s.input.join("");
|
|
439
|
+
const boxInner = cols - 4;
|
|
440
|
+
const inputSegs = typed === ""
|
|
441
|
+
? [{ t: agent ? `Ask ${agent.label} for a change, or type / for commands` : "Type / for commands — no agent is connected", s: "gray" }]
|
|
442
|
+
: [{ t: typed }];
|
|
443
|
+
const allInput = wrapInput(s.input, s.cursor, boxInner - 2);
|
|
444
|
+
// The box grows with the line, but never takes the conversation's last row.
|
|
445
|
+
const inputRows = Math.max(1, Math.min(INPUT_ROWS, rows - top.length - 4));
|
|
446
|
+
const firstShown = Math.max(0, Math.min(allInput.cursorLine - inputRows + 1, allInput.lines.length - inputRows));
|
|
447
|
+
const shown = typed === "" ? [""] : allInput.lines.slice(firstShown, firstShown + inputRows);
|
|
448
|
+
|
|
449
|
+
// The box's top edge says what is running.
|
|
450
|
+
const spin = SPINNER[Math.floor(now / 90) % SPINNER.length];
|
|
451
|
+
const label = s.busy
|
|
452
|
+
? [{ t: ` ${spin} `, s: "brand" }, { t: `${s.busy.label} `, s: "bold" },
|
|
453
|
+
{ t: `${Math.max(0, Math.round((now - s.busy.since) / 1000))}s${s.serving ? ` · for ${s.serving.from.name}` : ""} · esc interrupts `, s: "gray" }]
|
|
454
|
+
: (agent
|
|
455
|
+
? [{ t: ` to ${agent.label}${model ? ` · ${model}` : ""}${s.session ? " · same conversation" : ""} `, s: "gray" }]
|
|
456
|
+
: [{ t: " no agent — /agent chooses one ", s: "gray" }]);
|
|
457
|
+
const edge = (/** @type {string} */ l, /** @type {string} */ r, /** @type {Seg[]} */ mid) => {
|
|
458
|
+
const w = segsWidth(mid);
|
|
459
|
+
const fill = Math.max(0, cols - 4 - w);
|
|
460
|
+
return fit([{ t: `${l}─`, s: "gray" }, ...mid, { t: `${"─".repeat(fill)}─${r}`, s: "gray" }], cols, color);
|
|
461
|
+
};
|
|
462
|
+
/** @type {string[]} */
|
|
463
|
+
const bottom = [edge("╭", "╮", label)];
|
|
464
|
+
shown.forEach((line, i) => {
|
|
465
|
+
const lead = i === 0 && firstShown === 0 ? [{ t: "› ", s: "brandBold" }] : [{ t: " " }];
|
|
466
|
+
const body = typed === "" ? inputSegs : [{ t: line }];
|
|
467
|
+
bottom.push(fit([{ t: "│ ", s: "gray" }, ...lead, ...body], cols - 2, color) + fit([{ t: " │", s: "gray" }], 2, color));
|
|
468
|
+
});
|
|
469
|
+
bottom.push(edge("╰", "╯", []));
|
|
470
|
+
const palette = paletteOf(s);
|
|
471
|
+
const hints = s.busy ? "esc interrupt · keep typing your next request"
|
|
472
|
+
: (palette ? "↑↓ choose · tab complete · enter run · esc close"
|
|
473
|
+
: (cols >= 80 ? "enter send · / commands · @ mention · ↑↓ history · pgup/pgdn scroll · ? help" : "enter send · / commands · @ mention · ? help"));
|
|
474
|
+
// One column short: a character in the last cell of the last row scrolls some terminals.
|
|
475
|
+
bottom.push(spread([{ t: ` ${hints}`, s: "gray" }], s.notice ? [{ t: `${s.notice} `, s: "yellow" }] : [], cols - 1, color));
|
|
476
|
+
|
|
477
|
+
// The body: the conversation, and the panel beside it.
|
|
478
|
+
const bodyH = Math.max(1, rows - top.length - bottom.length);
|
|
479
|
+
const leftW = panelW > 0 ? cols - panelW - 1 : cols;
|
|
480
|
+
const all = transcriptLines(s.transcript, leftW - 1);
|
|
481
|
+
const maxScroll = Math.max(0, all.length - bodyH);
|
|
482
|
+
const scroll = Math.min(s.scroll, maxScroll);
|
|
483
|
+
// The opening screen is read from its first line; a conversation from its last.
|
|
484
|
+
const start = s.said ? Math.max(0, all.length - bodyH - scroll) : 0;
|
|
485
|
+
const visible = all.slice(start, start + bodyH);
|
|
486
|
+
/** @type {Seg[][]} */
|
|
487
|
+
const left = [...visible];
|
|
488
|
+
while (left.length < bodyH) left.push([]);
|
|
489
|
+
if (scroll > 0) left[bodyH - 1] = [{ t: ` ↓ ${scroll} more line${scroll === 1 ? "" : "s"} below — pgdn`, s: "yellow" }];
|
|
490
|
+
|
|
491
|
+
// The palette covers the bottom of the conversation.
|
|
492
|
+
if (palette) {
|
|
493
|
+
const n = palette.items.length;
|
|
494
|
+
const at = Math.min(s.paletteAt, n - 1);
|
|
495
|
+
const rowsShown = Math.min(PALETTE_ROWS, n, bodyH - 1);
|
|
496
|
+
const from = Math.max(0, Math.min(at - Math.floor(rowsShown / 2), n - rowsShown));
|
|
497
|
+
// The names share one column, as wide as the longest shown — up to half the line.
|
|
498
|
+
const nameW = Math.min(Math.floor(leftW / 2), Math.max(...palette.items.slice(from, from + rowsShown).map((it) => textWidth(it.value))) + 2);
|
|
499
|
+
const base = bodyH - rowsShown - 1;
|
|
500
|
+
left[base] = [{ t: ` ${palette.kind === "slash" ? "commands" : "artifacts"} `, s: "gray" }, { t: "─".repeat(Math.max(0, leftW - 14)), s: "gray" },
|
|
501
|
+
{ t: n > rowsShown ? ` ${at + 1}/${n}` : "", s: "gray" }];
|
|
502
|
+
for (let i = 0; i < rowsShown; i++) {
|
|
503
|
+
const it = palette.items[from + i];
|
|
504
|
+
const sel = from + i === at;
|
|
505
|
+
const value = textWidth(it.value) > nameW - 2 ? `${Array.from(it.value).slice(0, nameW - 3).join("")}… ` : it.value.padEnd(nameW);
|
|
506
|
+
left[base + 1 + i] = [{ t: sel ? " ► " : " ", s: "brandBold" }, { t: value, s: sel ? "selected" : "cyan" }, { t: it.label, s: sel ? undefined : "gray" }];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const right = panelW > 0 ? panelLines(s, panelW) : [];
|
|
511
|
+
const body = left.map((segs, i) => (panelW > 0
|
|
512
|
+
? fit(segs, leftW, color) + fit([{ t: "│", s: "gray" }], 1, color) + fit(right[i] ?? [], panelW, color)
|
|
513
|
+
: fit(segs, cols, color)));
|
|
514
|
+
|
|
515
|
+
const lines = [...top, ...body, ...bottom];
|
|
516
|
+
const cursorRow = top.length + bodyH + 1 + (typed === "" ? 0 : allInput.cursorLine - firstShown);
|
|
517
|
+
const cursorCol = 4 + (typed === "" ? 0 : allInput.cursorCol);
|
|
518
|
+
return { lines: lines.slice(0, rows), cursor: { row: Math.min(cursorRow, rows - 1), col: Math.min(cursorCol, cols - 1) } };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The typed line cut into rows of `width` columns, and where the cursor falls.
|
|
523
|
+
* @param {string[]} chars
|
|
524
|
+
* @param {number} cursor
|
|
525
|
+
* @param {number} width
|
|
526
|
+
*/
|
|
527
|
+
function wrapInput(chars, cursor, width) {
|
|
528
|
+
const w = Math.max(4, width);
|
|
529
|
+
/** @type {string[]} */
|
|
530
|
+
const lines = [""];
|
|
531
|
+
let used = 0;
|
|
532
|
+
let cursorLine = 0;
|
|
533
|
+
let cursorCol = 0;
|
|
534
|
+
chars.forEach((ch, i) => {
|
|
535
|
+
const cw = charWidth(ch);
|
|
536
|
+
if (used + cw > w) {
|
|
537
|
+
lines.push("");
|
|
538
|
+
used = 0;
|
|
539
|
+
}
|
|
540
|
+
if (i === cursor) {
|
|
541
|
+
cursorLine = lines.length - 1;
|
|
542
|
+
cursorCol = used;
|
|
543
|
+
}
|
|
544
|
+
lines[lines.length - 1] += ch;
|
|
545
|
+
used += cw;
|
|
546
|
+
});
|
|
547
|
+
if (cursor >= chars.length) {
|
|
548
|
+
// The cursor sits after the last character, on a new row when that one is full.
|
|
549
|
+
if (used >= w) {
|
|
550
|
+
lines.push("");
|
|
551
|
+
used = 0;
|
|
552
|
+
}
|
|
553
|
+
cursorLine = lines.length - 1;
|
|
554
|
+
cursorCol = used;
|
|
555
|
+
}
|
|
556
|
+
return { lines, cursorLine, cursorCol };
|
|
557
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { PROPERTY_MARKER, propertyLibraryIn } from "./properties.js";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { exists, isDir, isFile, read } from "./fs-ops.js";
|
|
5
6
|
import { specHeader } from "./scan.js";
|
|
@@ -27,9 +28,11 @@ import { parseAcceptanceCriteria } from "./criteria.js";
|
|
|
27
28
|
// never a --strict failure. Declared debt and hidden debt stop being punished
|
|
28
29
|
// identically (0.11.0 field review item 4: one deferred capability poisoned
|
|
29
30
|
// the close of every unrelated change).
|
|
30
|
-
export function collect(projectRoot, { only = null } = {}) {
|
|
31
|
+
export function collect(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
31
32
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
32
33
|
const reports = [];
|
|
34
|
+
/** @type {Map<string, boolean>} evidence file → is its suite skipped */
|
|
35
|
+
const skippedOf = new Map();
|
|
33
36
|
if (isDir(specsDir)) {
|
|
34
37
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
35
38
|
if (only && !only.has(cap)) continue;
|
|
@@ -40,7 +43,7 @@ export function collect(projectRoot, { only = null } = {}) {
|
|
|
40
43
|
if (criteria.length === 0) continue;
|
|
41
44
|
const deferred = isDeclaredDeferral(text);
|
|
42
45
|
const rows = criteria.map((crit, i) => {
|
|
43
|
-
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
|
|
46
|
+
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath), skippedOf);
|
|
44
47
|
if (deferred && row.kind !== "covered") {
|
|
45
48
|
return { ...row, kind: "deferred", was: row.kind };
|
|
46
49
|
}
|
|
@@ -145,7 +148,7 @@ export function deriveImplementation(row) {
|
|
|
145
148
|
*
|
|
146
149
|
* @returns {Map<string, {derived: string, covered: number, total: number, deferred: boolean, unverified: number}>}
|
|
147
150
|
*/
|
|
148
|
-
export function derivedImplementations(projectRoot, { only = null } = {}) {
|
|
151
|
+
export function derivedImplementations(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
149
152
|
const out = new Map();
|
|
150
153
|
for (const rep of collect(projectRoot, { only })) {
|
|
151
154
|
const row = {
|
|
@@ -301,7 +304,7 @@ function evidenceClause(criterion) {
|
|
|
301
304
|
return m ? criterion.slice(m.index) : null;
|
|
302
305
|
}
|
|
303
306
|
|
|
304
|
-
function classify(criterion, n, projectRoot, specDir) {
|
|
307
|
+
function classify(criterion, n, projectRoot, specDir, skippedOf = /** @type {Map<string, boolean>} */ (new Map())) {
|
|
305
308
|
// An orchestration criterion is judged on its GUARD, not on whether a
|
|
306
309
|
// cited file exists — the whole point is that existence proves nothing here.
|
|
307
310
|
const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
|
|
@@ -349,10 +352,30 @@ function classify(criterion, n, projectRoot, specDir) {
|
|
|
349
352
|
continue;
|
|
350
353
|
}
|
|
351
354
|
const isTest = looksLikeTestFile(token);
|
|
352
|
-
|
|
353
|
-
|
|
355
|
+
// One read per evidence file, not one per criterion that cites it
|
|
356
|
+
// (change 0249): a suite that proves forty criteria was read and
|
|
357
|
+
// scanned forty times, on every `prime`, `status` and `validate`.
|
|
358
|
+
let skipped = false;
|
|
359
|
+
if (isTest && isFile(hit)) {
|
|
360
|
+
if (!skippedOf.has(hit)) skippedOf.set(hit, testSuiteIsSkipped(read(hit)));
|
|
361
|
+
skipped = skippedOf.get(hit) === true;
|
|
362
|
+
}
|
|
363
|
+
resolved.push({ token, isTest, skipped, hit });
|
|
354
364
|
}
|
|
355
365
|
if (resolved.length === 0) return { kind: "dangling", n, missing };
|
|
366
|
+
// A `[property]` criterion claims something of EVERY input (change 0254):
|
|
367
|
+
// the test it cites must be one that generates its inputs. An example
|
|
368
|
+
// test proves three cases; calling that proof of a universal claim is the
|
|
369
|
+
// false confidence this gate exists against.
|
|
370
|
+
if (PROPERTY_MARKER.test(marker)) {
|
|
371
|
+
const generating = resolved.find((r) => r.isTest && !r.skipped && propertyLibraryIn(read(r.hit)) !== null);
|
|
372
|
+
if (!generating) {
|
|
373
|
+
return {
|
|
374
|
+
kind: "unguarded", n, label: "property claim is proven by no property",
|
|
375
|
+
reason: `${resolved.map((r) => `\`${r.token}\``).join(", ")} generates no input — a [property] criterion is proven by a property-based test (Hypothesis \`@given\`, fast-check \`fc.property\`, jqwik \`@Property\`, proptest), or it is an example and its mark is [verified]`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
356
379
|
// Runnable evidence: the resolving test-shaped citations, recorded so
|
|
357
380
|
// `coverage --run` can execute them (existence → passing proof).
|
|
358
381
|
const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
|
package/src/lib/delivery.js
CHANGED
|
@@ -126,11 +126,11 @@ export function deliveryChanges(wsDir, ws, delivery) {
|
|
|
126
126
|
for (const name of delivery.projects) {
|
|
127
127
|
const project = ws.projects.find((p) => p.name === name);
|
|
128
128
|
if (!project) {
|
|
129
|
-
out.push({ project: name, missing: "not registered in the workspace", changes: [] });
|
|
129
|
+
out.push({ project: name, missing: "not registered in the workspace", changes: /** @type {any[]} */ ([]) });
|
|
130
130
|
continue;
|
|
131
131
|
}
|
|
132
132
|
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
133
|
-
const changes = [];
|
|
133
|
+
const changes = /** @type {any[]} */ ([]);
|
|
134
134
|
for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
|
|
135
135
|
if (!isDir(dir)) continue;
|
|
136
136
|
for (const entry of readdirSync(dir).sort()) {
|
package/src/lib/docs-impact.js
CHANGED
|
@@ -123,7 +123,7 @@ function withoutVerification(text) {
|
|
|
123
123
|
return out.join("\n");
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
126
|
+
export function documentedSurfaceSignals(changeDir, projectRoot = /** @type {string | null} */ (null)) {
|
|
127
127
|
const signals = [];
|
|
128
128
|
const known = new Set(COMMAND_NAMES);
|
|
129
129
|
|