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,400 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { doctrinaHome } from "./workspace-locate.js";
|
|
6
|
+
|
|
7
|
+
// THE AGENT THE CONSOLE TALKS TO (change 0257, ADR 0032).
|
|
8
|
+
//
|
|
9
|
+
// The CLI never calls a model (ADR 0005) and still does not. What a human
|
|
10
|
+
// types into the console as a request goes to the AI agent they already
|
|
11
|
+
// use, through that agent's own non-interactive mode: the same binary, the
|
|
12
|
+
// same account, the same rules and hooks the IDE session reads. Doctrina
|
|
13
|
+
// starts it with the prompt, relays what it prints, and remembers the
|
|
14
|
+
// session id so the next prompt continues the conversation.
|
|
15
|
+
//
|
|
16
|
+
// It is the same agent, not the same window: there is no public door into a
|
|
17
|
+
// chat pane that is already open in an IDE, so the console's conversation
|
|
18
|
+
// is its own session of that agent, in the same repository.
|
|
19
|
+
//
|
|
20
|
+
// Each agent is described by what its documentation states: the binary, how
|
|
21
|
+
// a prompt is passed, the output it streams, how a session resumes. Nothing
|
|
22
|
+
// here widens what the agent may do. No flag that skips its approvals is
|
|
23
|
+
// ever passed; the person adds flags of their own in DOCTRINA_AGENT_ARGS.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ type: "session", id: string }
|
|
27
|
+
* | { type: "text", text: string }
|
|
28
|
+
* | { type: "tool", name: string, detail: string }
|
|
29
|
+
* | { type: "result", ok: boolean, turns: number | null, ms: number | null, denials: string[], error: string }
|
|
30
|
+
* | { type: "model", name: string }
|
|
31
|
+
* | { type: "raw", text: string }} AgentEvent
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* id: string, label: string, bins: string[], exercised: boolean,
|
|
37
|
+
* stdin: boolean,
|
|
38
|
+
* about: string,
|
|
39
|
+
* model: string[] | null,
|
|
40
|
+
* models: string[],
|
|
41
|
+
* listing: string,
|
|
42
|
+
* custom?: boolean,
|
|
43
|
+
* args: (prompt: string, opts: { session: string | null, extra: string[] }) => string[],
|
|
44
|
+
* parse: (line: string) => AgentEvent[],
|
|
45
|
+
* }} AgentSpec
|
|
46
|
+
*
|
|
47
|
+
* `about` says which product the binary belongs to; `model` is how a model
|
|
48
|
+
* is named on its command line (`{model}` is replaced), or null when it
|
|
49
|
+
* takes none; `models` are names it is known to accept — examples for the
|
|
50
|
+
* list `/model` completes from, never a limit on what may be typed;
|
|
51
|
+
* `listing` is how the agent itself shows the models an account has.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const clip = (/** @type {unknown} */ v, n = 160) => {
|
|
55
|
+
const s = String(v ?? "").replace(/\s+/g, " ").trim();
|
|
56
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** One line that says what a tool call does, from its input. */
|
|
60
|
+
function toolDetail(input) {
|
|
61
|
+
if (input === null || typeof input !== "object") return clip(input);
|
|
62
|
+
const o = /** @type {Record<string, unknown>} */ (input);
|
|
63
|
+
for (const key of ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"]) {
|
|
64
|
+
if (typeof o[key] === "string" && o[key] !== "") return clip(o[key]);
|
|
65
|
+
}
|
|
66
|
+
const first = Object.values(o).find((v) => typeof v === "string" && v !== "");
|
|
67
|
+
return first ? clip(first) : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function json(line) {
|
|
71
|
+
const text = line.trim();
|
|
72
|
+
if (!text.startsWith("{")) return null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(text);
|
|
75
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rawOf = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("raw"), text: line.replace(/\s+$/, "") }]);
|
|
82
|
+
|
|
83
|
+
/** Claude Code's and Cursor's `stream-json`: one event per line. */
|
|
84
|
+
function parseStreamJson(line) {
|
|
85
|
+
const e = json(line);
|
|
86
|
+
if (!e) return rawOf(line);
|
|
87
|
+
/** @type {AgentEvent[]} */
|
|
88
|
+
const out = [];
|
|
89
|
+
if (e.type === "system" && e.subtype === "init" && typeof e.session_id === "string") {
|
|
90
|
+
out.push({ type: "session", id: e.session_id });
|
|
91
|
+
// The model the agent says it is running: shown, never assumed.
|
|
92
|
+
if (typeof e.model === "string" && e.model !== "") out.push({ type: "model", name: e.model });
|
|
93
|
+
} else if (e.type === "assistant") {
|
|
94
|
+
for (const part of Array.isArray(e.message?.content) ? e.message.content : []) {
|
|
95
|
+
if (part?.type === "text" && typeof part.text === "string" && part.text.trim() !== "") out.push({ type: "text", text: part.text });
|
|
96
|
+
else if (part?.type === "tool_use") out.push({ type: "tool", name: String(part.name ?? "tool"), detail: toolDetail(part.input) });
|
|
97
|
+
}
|
|
98
|
+
} else if (e.type === "tool_call" && e.subtype === "started") {
|
|
99
|
+
// Cursor names the tool by the key it nests the call under.
|
|
100
|
+
const call = e.tool_call && typeof e.tool_call === "object" ? e.tool_call : {};
|
|
101
|
+
const key = Object.keys(call)[0] ?? "tool";
|
|
102
|
+
const body = call[key] ?? {};
|
|
103
|
+
const name = key === "function" ? String(body.name ?? "tool") : key.replace(/ToolCall$/, "").replace(/^./, (ch) => ch.toUpperCase());
|
|
104
|
+
out.push({ type: "tool", name, detail: toolDetail(key === "function" ? body.arguments : body.args) });
|
|
105
|
+
} else if (e.type === "result") {
|
|
106
|
+
if (typeof e.session_id === "string") out.push({ type: "session", id: e.session_id });
|
|
107
|
+
const denials = (Array.isArray(e.permission_denials) ? e.permission_denials : [])
|
|
108
|
+
.map((/** @type {any} */ d) => `${d?.tool_name ?? "tool"}(${toolDetail(d?.tool_input)})`);
|
|
109
|
+
const ok = e.is_error !== true && (e.subtype === undefined || e.subtype === "success");
|
|
110
|
+
out.push({
|
|
111
|
+
type: "result", ok,
|
|
112
|
+
turns: typeof e.num_turns === "number" ? e.num_turns : null,
|
|
113
|
+
ms: typeof e.duration_ms === "number" ? e.duration_ms : null,
|
|
114
|
+
denials: [...new Set(denials)],
|
|
115
|
+
error: ok ? "" : clip(e.result ?? e.subtype ?? "the agent reported an error", 300),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `codex exec --json`: JSON Lines of thread, turn and item events. */
|
|
122
|
+
function parseCodex(line) {
|
|
123
|
+
const e = json(line);
|
|
124
|
+
if (!e) return rawOf(line);
|
|
125
|
+
/** @type {AgentEvent[]} */
|
|
126
|
+
const out = [];
|
|
127
|
+
if (e.type === "thread.started" && typeof e.thread_id === "string") out.push({ type: "session", id: e.thread_id });
|
|
128
|
+
else if (e.type === "item.completed" && e.item && typeof e.item === "object") {
|
|
129
|
+
const kind = String(e.item.type ?? "");
|
|
130
|
+
if (/agent_?message/i.test(kind) && typeof e.item.text === "string") out.push({ type: "text", text: e.item.text });
|
|
131
|
+
else if (/command/i.test(kind)) out.push({ type: "tool", name: "Shell", detail: clip(e.item.command) });
|
|
132
|
+
else if (/file/i.test(kind)) out.push({ type: "tool", name: "Edit", detail: toolDetail(e.item) });
|
|
133
|
+
} else if (e.type === "turn.completed") out.push({ type: "result", ok: true, turns: null, ms: null, denials: [], error: "" });
|
|
134
|
+
else if (e.type === "turn.failed" || e.type === "error") {
|
|
135
|
+
out.push({ type: "result", ok: false, turns: null, ms: null, denials: [], error: clip(e.error?.message ?? e.message ?? "the agent reported an error", 300) });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Plain text: every line is something the agent said. */
|
|
141
|
+
const parseText = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("text"), text: line.replace(/\s+$/, "") }]);
|
|
142
|
+
|
|
143
|
+
/** @type {AgentSpec[]} */
|
|
144
|
+
export const AGENTS = [
|
|
145
|
+
{ id: "claude", label: "Claude Code", bins: ["claude"], exercised: true, stdin: true,
|
|
146
|
+
about: "also what the Claude extensions for VS Code and JetBrains run",
|
|
147
|
+
model: ["--model", "{model}"], models: ["opus", "sonnet", "haiku"], listing: "`/model` inside `claude`",
|
|
148
|
+
// The prompt goes in on stdin: no length limit, and nothing a person
|
|
149
|
+
// typed is ever an argument. Edits are accepted and `doctrina` may run —
|
|
150
|
+
// the cycle needs both; every other command still asks, which in print
|
|
151
|
+
// mode means it is refused and reported.
|
|
152
|
+
args: (_prompt, { session, extra }) => ["-p", "--output-format", "stream-json", "--verbose",
|
|
153
|
+
"--permission-mode", "acceptEdits", "--allowedTools", "Bash(doctrina:*)",
|
|
154
|
+
...(session ? ["--resume", session] : []), ...extra],
|
|
155
|
+
parse: parseStreamJson },
|
|
156
|
+
{ id: "cursor", label: "Cursor", bins: ["cursor-agent", "agent"], exercised: false, stdin: false,
|
|
157
|
+
about: "the agent of the Cursor IDE",
|
|
158
|
+
model: ["--model", "{model}"], models: [], listing: "`agent models`",
|
|
159
|
+
args: (prompt, { session, extra }) => ["-p", "--output-format", "stream-json",
|
|
160
|
+
...(session ? ["--resume", session] : []), ...extra, prompt],
|
|
161
|
+
parse: parseStreamJson },
|
|
162
|
+
{ id: "copilot", label: "GitHub Copilot", bins: ["copilot"], exercised: false, stdin: false,
|
|
163
|
+
about: "the agent of GitHub Copilot, as in VS Code",
|
|
164
|
+
model: ["--model={model}"], models: [], listing: "`/model` inside `copilot`",
|
|
165
|
+
// Its programmatic mode documents no session to resume: each request
|
|
166
|
+
// starts a conversation. It may write files and run `doctrina`.
|
|
167
|
+
args: (prompt, { extra }) => ["-p", prompt, "-s", "--allow-tool=write, shell(doctrina:*)", ...extra],
|
|
168
|
+
parse: parseText },
|
|
169
|
+
{ id: "codex", label: "Codex", bins: ["codex"], exercised: false, stdin: false,
|
|
170
|
+
about: "also what the Codex extension for VS Code runs",
|
|
171
|
+
model: ["--model", "{model}"], models: [], listing: "`codex debug models`",
|
|
172
|
+
args: (prompt, { session, extra }) => ["exec", ...(session ? ["resume", session] : []),
|
|
173
|
+
"--json", "--sandbox", "workspace-write", ...extra, prompt],
|
|
174
|
+
parse: parseCodex },
|
|
175
|
+
{ id: "gemini", label: "Gemini CLI", bins: ["gemini"], exercised: false, stdin: true,
|
|
176
|
+
about: "also behind Gemini Code Assist in VS Code",
|
|
177
|
+
model: ["--model", "{model}"], models: ["auto", "pro", "flash", "flash-lite"], listing: "`/model` inside `gemini`",
|
|
178
|
+
// Plain text out carries no session id, so each request starts a conversation.
|
|
179
|
+
args: (_prompt, { extra }) => ["--approval-mode", "auto_edit", ...extra],
|
|
180
|
+
parse: parseText },
|
|
181
|
+
{ id: "opencode", label: "opencode", bins: ["opencode"], exercised: false, stdin: false,
|
|
182
|
+
about: "",
|
|
183
|
+
// Only `opencode run "<prompt>"` is on the page Doctrina was built from:
|
|
184
|
+
// a model goes in DOCTRINA_AGENT_ARGS, as that CLI names it.
|
|
185
|
+
model: null, models: [], listing: "",
|
|
186
|
+
args: (prompt, { extra }) => ["run", ...extra, prompt],
|
|
187
|
+
parse: parseText },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// AN AGENT OF THE PERSON'S OWN (change 0258).
|
|
191
|
+
//
|
|
192
|
+
// The catalog above is the agents Doctrina knows how to start. A person who
|
|
193
|
+
// uses another — or a wrapper of their own — declares it once, in
|
|
194
|
+
// `$DOCTRINA_HOME/agents.json`, as the command line they would type:
|
|
195
|
+
//
|
|
196
|
+
// { "agents": [ { "id": "mine", "label": "My agent", "command": "mycli",
|
|
197
|
+
// "args": ["run", "{prompt}"], "model": ["--model", "{model}"],
|
|
198
|
+
// "resume": ["--session", "{session}"], "models": ["small", "large"] } ] }
|
|
199
|
+
//
|
|
200
|
+
// `{prompt}` is where the request goes; with no `{prompt}` it goes in on
|
|
201
|
+
// stdin. Its output is read as plain text, or as `stream-json` when
|
|
202
|
+
// `"output"` says so. It is the person's file, never the project's.
|
|
203
|
+
export const CUSTOM_AGENTS_FILE = "agents.json";
|
|
204
|
+
|
|
205
|
+
const isStrings = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The agents `$DOCTRINA_HOME/agents.json` declares, and what is wrong with it.
|
|
209
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
210
|
+
* @returns {{ specs: AgentSpec[], errors: string[] }}
|
|
211
|
+
*/
|
|
212
|
+
export function customAgents(env = process.env) {
|
|
213
|
+
const file = path.join(doctrinaHome(env), CUSTOM_AGENTS_FILE);
|
|
214
|
+
/** @type {AgentSpec[]} */
|
|
215
|
+
const specs = [];
|
|
216
|
+
/** @type {string[]} */
|
|
217
|
+
const errors = [];
|
|
218
|
+
if (!isFile(file)) return { specs, errors };
|
|
219
|
+
let data;
|
|
220
|
+
try {
|
|
221
|
+
data = JSON.parse(read(file));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return { specs, errors: [`${file} is not valid JSON: ${/** @type {Error} */ (err).message}`] };
|
|
224
|
+
}
|
|
225
|
+
const list = Array.isArray(data?.agents) ? data.agents : null;
|
|
226
|
+
if (!list) return { specs, errors: [`${file}: expected { "agents": [ … ] }`] };
|
|
227
|
+
list.forEach((/** @type {any} */ a, /** @type {number} */ i) => {
|
|
228
|
+
const where = `${file}: agents[${i}]`;
|
|
229
|
+
if (!a || typeof a !== "object") return errors.push(`${where} is not an object`);
|
|
230
|
+
if (typeof a.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(a.id)) return errors.push(`${where}.id must be lowercase letters, digits and hyphens`);
|
|
231
|
+
if (AGENTS.some((b) => b.id === a.id) || specs.some((b) => b.id === a.id)) return errors.push(`${where}.id "${a.id}" is already an agent`);
|
|
232
|
+
if (typeof a.command !== "string" || a.command.trim() === "") return errors.push(`${where}.command names the binary to run`);
|
|
233
|
+
for (const key of ["args", "model", "resume", "models"]) {
|
|
234
|
+
if (a[key] !== undefined && !isStrings(a[key])) return errors.push(`${where}.${key} must be an array of strings`);
|
|
235
|
+
}
|
|
236
|
+
/** @type {string[]} */
|
|
237
|
+
const args = a.args ?? [];
|
|
238
|
+
/** @type {string[]} */
|
|
239
|
+
const resume = a.resume ?? [];
|
|
240
|
+
const inArgs = args.some((x) => x.includes("{prompt}"));
|
|
241
|
+
return specs.push({
|
|
242
|
+
id: a.id, label: typeof a.label === "string" && a.label.trim() !== "" ? a.label : a.id,
|
|
243
|
+
bins: [a.command], exercised: false, stdin: !inArgs, custom: true,
|
|
244
|
+
about: "your own, from agents.json",
|
|
245
|
+
model: a.model ?? null, models: a.models ?? [], listing: "",
|
|
246
|
+
args: (prompt, { session, extra }) => [
|
|
247
|
+
...args.map((x) => x.replaceAll("{prompt}", prompt)),
|
|
248
|
+
...(session ? resume.map((x) => x.replaceAll("{session}", session)) : []),
|
|
249
|
+
...extra,
|
|
250
|
+
],
|
|
251
|
+
parse: a.output === "stream-json" ? parseStreamJson : parseText,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
return { specs, errors };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every agent the console can start: the catalog, then the person's own. */
|
|
258
|
+
export const agentSpecs = (/** @type {NodeJS.ProcessEnv} */ env = process.env) => [...AGENTS, ...customAgents(env).specs];
|
|
259
|
+
|
|
260
|
+
export const agentById = (/** @type {string} */ id, /** @type {NodeJS.ProcessEnv} */ env = process.env) => agentSpecs(env).find((a) => a.id === id) ?? null;
|
|
261
|
+
|
|
262
|
+
/** The file a command name resolves to on PATH, or null. */
|
|
263
|
+
export function findOnPath(bin, env = process.env, platform = process.platform) {
|
|
264
|
+
const exts = platform === "win32" ? ["", ...String(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
265
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter)) {
|
|
266
|
+
if (dir === "") continue;
|
|
267
|
+
for (const ext of exts) {
|
|
268
|
+
const file = path.join(dir, bin + ext);
|
|
269
|
+
try {
|
|
270
|
+
if (!statSync(file).isFile()) continue;
|
|
271
|
+
if (platform !== "win32") accessSync(file, constants.X_OK);
|
|
272
|
+
return file;
|
|
273
|
+
} catch { /* not here */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every agent the console knows, installed or not, the one it would use
|
|
281
|
+
* first on top. The order is the person's: `DOCTRINA_AGENT`, then the IDE
|
|
282
|
+
* whose terminal this is, then the adapters the project installed, then the
|
|
283
|
+
* catalog's; what is not installed comes last.
|
|
284
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
|
|
285
|
+
* @returns {{ id: string, label: string, file: string | null, bins: string[], exercised: boolean, about: string, models: string[], listing: string, takesModel: boolean, custom: boolean }[]}
|
|
286
|
+
*/
|
|
287
|
+
export function agentCatalog({ env = process.env, cwd = process.cwd(), platform = process.platform } = {}) {
|
|
288
|
+
const pinned = String(env.DOCTRINA_AGENT ?? "").trim().toLowerCase();
|
|
289
|
+
const inCursor = Boolean(env.CURSOR_TRACE_ID) || /cursor/i.test(String(env.VSCODE_GIT_ASKPASS_MAIN ?? ""));
|
|
290
|
+
const hasCursor = isDir(path.join(cwd, ".cursor"));
|
|
291
|
+
const all = agentSpecs(env).map((spec) => {
|
|
292
|
+
// `agent` is too common a name to trust on its own: it counts as Cursor's
|
|
293
|
+
// only where Cursor is in use.
|
|
294
|
+
const bins = spec.id === "cursor" && !(inCursor || hasCursor) ? spec.bins.filter((b) => b !== "agent") : spec.bins;
|
|
295
|
+
const file = bins.map((b) => (/[\\/]/.test(b) ? (isFile(b) ? b : null) : findOnPath(b, env, platform))).find(Boolean) ?? null;
|
|
296
|
+
return { id: spec.id, label: spec.label, file, bins: spec.bins, exercised: spec.exercised, about: spec.about, models: spec.models, listing: spec.listing, takesModel: spec.model !== null, custom: spec.custom === true };
|
|
297
|
+
});
|
|
298
|
+
const rank = (/** @type {{ id: string, file: string | null }} */ a) => {
|
|
299
|
+
if (!a.file) return 9;
|
|
300
|
+
if (a.id === pinned) return 0;
|
|
301
|
+
if (a.id === "cursor" && inCursor) return 1;
|
|
302
|
+
if (a.id === "claude" && env.CLAUDECODE) return 1;
|
|
303
|
+
if (a.id === "cursor" && hasCursor) return 2;
|
|
304
|
+
if (a.id === "claude" && (isDir(path.join(cwd, ".claude")) || isFile(path.join(cwd, "CLAUDE.md")))) return 2;
|
|
305
|
+
if (a.id === "copilot" && isFile(path.join(cwd, ".github", "copilot-instructions.md"))) return 2;
|
|
306
|
+
if (a.id === "gemini" && isFile(path.join(cwd, "GEMINI.md"))) return 2;
|
|
307
|
+
return 3;
|
|
308
|
+
};
|
|
309
|
+
return all.map((a, i) => ({ a, i })).sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i).map((x) => x.a);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The agents installed on this machine, the one the console would use first.
|
|
314
|
+
* `DOCTRINA_AGENT=none` finds none.
|
|
315
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
|
|
316
|
+
* @returns {{ id: string, label: string, file: string, exercised: boolean }[]}
|
|
317
|
+
*/
|
|
318
|
+
export function detectAgents(opts = {}) {
|
|
319
|
+
const pinned = String((opts.env ?? process.env).DOCTRINA_AGENT ?? "").trim().toLowerCase();
|
|
320
|
+
if (pinned === "none" || pinned === "off") return [];
|
|
321
|
+
return agentCatalog(opts).flatMap((a) => (a.file ? [{ id: a.id, label: a.label, file: a.file, exercised: a.exercised }] : []));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Split DOCTRINA_AGENT_ARGS the way a shell would, quotes included. */
|
|
325
|
+
export function splitArgs(text) {
|
|
326
|
+
const out = [];
|
|
327
|
+
let cur = "";
|
|
328
|
+
let quote = "";
|
|
329
|
+
let open = false;
|
|
330
|
+
for (const ch of String(text ?? "")) {
|
|
331
|
+
if (quote) {
|
|
332
|
+
if (ch === quote) quote = "";
|
|
333
|
+
else cur += ch;
|
|
334
|
+
} else if (ch === '"' || ch === "'") {
|
|
335
|
+
quote = ch;
|
|
336
|
+
open = true;
|
|
337
|
+
} else if (/\s/.test(ch)) {
|
|
338
|
+
if (cur !== "" || open) out.push(cur);
|
|
339
|
+
cur = "";
|
|
340
|
+
open = false;
|
|
341
|
+
} else cur += ch;
|
|
342
|
+
}
|
|
343
|
+
if (cur !== "" || open) out.push(cur);
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* How to start an agent with a prompt: the file, its arguments, and what to
|
|
349
|
+
* write to its stdin. `refused` says why it cannot be started safely.
|
|
350
|
+
* @param {{ id: string, file: string }} agent
|
|
351
|
+
* @param {string} prompt
|
|
352
|
+
* @param {{ session?: string | null, model?: string | null, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [opts]
|
|
353
|
+
*/
|
|
354
|
+
export function agentInvocation(agent, prompt, { session = null, model = null, env = process.env, platform = process.platform } = {}) {
|
|
355
|
+
const spec = agentById(agent.id, env);
|
|
356
|
+
if (!spec) return { refused: `unknown agent "${agent.id}"`, file: "", args: /** @type {string[]} */ ([]), stdin: null, shell: false };
|
|
357
|
+
// The model the person chose, the way this agent names one; then their own flags.
|
|
358
|
+
const chosen = model && spec.model ? spec.model.map((x) => x.replaceAll("{model}", model)) : [];
|
|
359
|
+
const extra = [...chosen, ...splitArgs(env.DOCTRINA_AGENT_ARGS)];
|
|
360
|
+
const args = spec.args(prompt, { session, extra });
|
|
361
|
+
// A .cmd launcher only runs through cmd.exe, which would read a prompt as
|
|
362
|
+
// commands. With the prompt on stdin nothing typed reaches it.
|
|
363
|
+
const shim = platform === "win32" && /\.(cmd|bat)$/i.test(agent.file);
|
|
364
|
+
if (shim && !spec.stdin) {
|
|
365
|
+
return { refused: `${spec.label}'s launcher is a ${path.extname(agent.file)} script, and a prompt is not passed through cmd.exe — put its .exe first on PATH`, file: agent.file, args, stdin: null, shell: false };
|
|
366
|
+
}
|
|
367
|
+
return { refused: "", file: agent.file, args, stdin: spec.stdin ? prompt : null, shell: shim };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The prompt an agent receives: what was typed, where each mentioned
|
|
372
|
+
* artifact lives — so `@spec:auth` is a file the agent opens and not a
|
|
373
|
+
* guess — and what it needs to know about the other consoles: that it may
|
|
374
|
+
* hand one a task, or that this request came from one and its last message
|
|
375
|
+
* is the report.
|
|
376
|
+
* @param {string} text
|
|
377
|
+
* @param {{ kind: string, id: string, path: string }[]} mentions
|
|
378
|
+
* @param {{ peers?: { name: string, busy: boolean, listening: boolean }[], origin?: string | null }} [room]
|
|
379
|
+
*/
|
|
380
|
+
export function agentPrompt(text, mentions, { peers = [], origin = null } = {}) {
|
|
381
|
+
const named = [];
|
|
382
|
+
for (const m of String(text).matchAll(/(?<![\w@])@([a-z]+):([A-Za-z0-9._-]+)/g)) {
|
|
383
|
+
const hit = mentions.find((x) => x.kind === m[1] && x.id === m[2]);
|
|
384
|
+
if (hit && !named.includes(hit)) named.push(hit);
|
|
385
|
+
}
|
|
386
|
+
let out = text;
|
|
387
|
+
if (named.length > 0) {
|
|
388
|
+
out += `\n\nDoctrina artifacts mentioned above:\n${named.map((m) => `- @${m.kind}:${m.id} — ${m.path}${m.kind === "skill" ? " (a project skill: follow its procedure)" : ""}`).join("\n")}`;
|
|
389
|
+
}
|
|
390
|
+
if (origin) {
|
|
391
|
+
out += `\n\nThis request was sent by the Doctrina console "${origin}", open in another terminal. Do the work here, in this repository; do not hand it to another console. Your last message is sent back to "${origin}" as the report: make it say what you did and what is left.`;
|
|
392
|
+
} else {
|
|
393
|
+
const open = peers.filter((p) => p.listening);
|
|
394
|
+
if (open.length > 0) {
|
|
395
|
+
out += `\n\nOther Doctrina consoles are open in other terminals: ${open.map((p) => `${p.name} (${p.busy ? "busy" : "idle"})`).join(", ")}. `
|
|
396
|
+
+ "To hand one a task and wait for its report, run: doctrina ui --send <name> \"<the task, self-contained>\". Its agent works in its own repository; only do that when the task belongs there.";
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { eolOf } from "./fs-ops.js";
|
|
5
|
+
import { extractOps } from "./spec-ops.js";
|
|
6
|
+
|
|
7
|
+
// THE RECORD OF WHICH DELTAS A CHANGE'S `applied` STAMP COVERS (change 0247).
|
|
8
|
+
//
|
|
9
|
+
// One line at the end of proposal.md, in a comment so no reader takes it for
|
|
10
|
+
// content:
|
|
11
|
+
//
|
|
12
|
+
// <!-- doctrina:applied-deltas specs/auth/delta.md=3f1c9a7e,91bd02c4 -->
|
|
13
|
+
//
|
|
14
|
+
// One fingerprint per op of a delta's `ops` block, because the usual repair
|
|
15
|
+
// after a close that stopped is `doctrina change delta`, which adds ops to
|
|
16
|
+
// the delta that is already there. A delta without ops (ADDED, REMOVED) has
|
|
17
|
+
// one fingerprint, of its whole text. `none` when the change was applied
|
|
18
|
+
// with no delta at all — which is exactly the change one gets added to.
|
|
19
|
+
|
|
20
|
+
const RECORD = /<!-- doctrina:applied-deltas ([^>]*?) -->/;
|
|
21
|
+
const short = (/** @type {string} */ text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
22
|
+
|
|
23
|
+
/** A delta's path inside its change, with forward slashes. */
|
|
24
|
+
export function deltaKey(changeDir, deltaPath) {
|
|
25
|
+
return path.relative(changeDir, deltaPath).split(path.sep).join("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a delta holds: one fingerprint per op, or one of its whole text when
|
|
30
|
+
* it carries no ops block. Line endings do not count.
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function deltaFingerprint(text) {
|
|
34
|
+
const ops = extractOps(text);
|
|
35
|
+
if (ops.length > 0) return ops.map((op) => short(JSON.stringify(op)));
|
|
36
|
+
return [`text:${short(String(text).replace(/\r\n/g, "\n"))}`];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The deltas the stamp covers, or null when the proposal carries no record
|
|
41
|
+
* (a change applied by a CLI that did not write one).
|
|
42
|
+
* @returns {Map<string, string[]> | null}
|
|
43
|
+
*/
|
|
44
|
+
export function appliedDeltas(proposal) {
|
|
45
|
+
const m = String(proposal).match(RECORD);
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
const out = new Map();
|
|
48
|
+
for (const pair of m[1].trim().split(/\s+/)) {
|
|
49
|
+
const at = pair.lastIndexOf("=");
|
|
50
|
+
if (at > 0) out.set(pair.slice(0, at), pair.slice(at + 1).split(",").filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which of a delta's ops the stamp does not cover yet. `gone` is true when an
|
|
57
|
+
* op that WAS applied is no longer in the block as it was — changed or
|
|
58
|
+
* removed — which nothing can apply on top of.
|
|
59
|
+
* @param {string[]} recorded
|
|
60
|
+
* @param {string[]} current
|
|
61
|
+
* @returns {{ fresh: number[], gone: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function pendingOps(recorded, current) {
|
|
64
|
+
const left = new Map();
|
|
65
|
+
for (const h of recorded) left.set(h, (left.get(h) ?? 0) + 1);
|
|
66
|
+
const fresh = [];
|
|
67
|
+
current.forEach((h, i) => {
|
|
68
|
+
const n = left.get(h) ?? 0;
|
|
69
|
+
if (n > 0) left.set(h, n - 1);
|
|
70
|
+
else fresh.push(i);
|
|
71
|
+
});
|
|
72
|
+
return { fresh, gone: [...left.values()].some((n) => n > 0) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The proposal with its record replaced, or appended as the last line. */
|
|
76
|
+
export function recordAppliedDeltas(proposal, entries) {
|
|
77
|
+
const line = `<!-- doctrina:applied-deltas ${entries.length ? entries.map(([k, h]) => `${k}=${h.join(",")}`).join(" ") : "none"} -->`;
|
|
78
|
+
const text = String(proposal);
|
|
79
|
+
if (RECORD.test(text)) return text.replace(RECORD, line);
|
|
80
|
+
const eol = eolOf(text);
|
|
81
|
+
return text.replace(/\s*$/, "") + eol + eol + line + eol;
|
|
82
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -123,7 +123,7 @@ export function flagAll(flags, name) {
|
|
|
123
123
|
return typeof one === "string" ? [one] : [];
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export function flagString(flags, name, fallback = undefined) {
|
|
126
|
+
export function flagString(flags, name, fallback = /** @type {string | undefined} */ (undefined)) {
|
|
127
127
|
const v = flags.get(name);
|
|
128
128
|
if (v === undefined || v === true) return fallback;
|
|
129
129
|
return String(v);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, move, read, walk, write } from "./fs-ops.js";
|
|
5
|
+
import { parseLedger } from "./ledger.js";
|
|
6
|
+
import { git, GIT_STATE } from "./git.js";
|
|
7
|
+
|
|
8
|
+
// A CHANGE'S NUMBER IS THE PROJECT'S, NOT THE CHECKOUT'S (change 0242).
|
|
9
|
+
//
|
|
10
|
+
// The next number was the highest one on disk plus one. Two people on two
|
|
11
|
+
// branches read the same disk, so both opened `0042-…`, and the merge that
|
|
12
|
+
// brought them together held two changes answering to one number — which is
|
|
13
|
+
// what a commit message, a task card and `doctrina close 0042` all name.
|
|
14
|
+
//
|
|
15
|
+
// The numbers already taken are therefore read from everywhere this clone
|
|
16
|
+
// can see them: the folders, the ledger (an abandoned change leaves only its
|
|
17
|
+
// line — third audit, change 0234), and the branches git knows about. A
|
|
18
|
+
// branch nobody fetched is still invisible, so the merge is checked too:
|
|
19
|
+
// `validate` names an open change that shares its number, and
|
|
20
|
+
// `doctrina change renumber` gives it the next free one.
|
|
21
|
+
|
|
22
|
+
const DATED = /^\d{4}-\d{2}-\d{2}-/;
|
|
23
|
+
const numberOf = (/** @type {string} */ id) => id.match(/^(\d{1,4})(?:-|$)/)?.[1]?.padStart(4, "0") ?? null;
|
|
24
|
+
|
|
25
|
+
/** How far back the other branches are read: a branch older than this was merged or forgotten. */
|
|
26
|
+
export const BRANCH_WINDOW_DAYS = 180;
|
|
27
|
+
|
|
28
|
+
/** Ids of the changes other branches added and this checkout does not hold. */
|
|
29
|
+
function idsOnOtherBranches(changesDir) {
|
|
30
|
+
if (!isDir(changesDir)) return [];
|
|
31
|
+
const log = git(changesDir, ["log", "--all", `--since=${BRANCH_WINDOW_DAYS}.days`, "--diff-filter=A",
|
|
32
|
+
"--name-only", "--format=", "--", "."]);
|
|
33
|
+
if (log.state !== GIT_STATE.OK) return [];
|
|
34
|
+
const ids = new Set();
|
|
35
|
+
for (const line of log.lines) {
|
|
36
|
+
const m = line.match(/(?:^|\/)changes\/(?:archive\/\d{4}-\d{2}-\d{2}-)?(\d{1,4}-[^/]+)\/proposal\.md$/);
|
|
37
|
+
if (m) ids.add(m[1]);
|
|
38
|
+
}
|
|
39
|
+
return [...ids];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every change id this clone knows of, by where it was found.
|
|
44
|
+
* @returns {{ open: string[], archived: string[], ledger: string[], branches: string[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function knownChangeIds(projectRoot, { branches = true } = {}) {
|
|
47
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
48
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
49
|
+
const folders = (/** @type {string} */ dir) => (isDir(dir)
|
|
50
|
+
? readdirSync(dir).filter((e) => !e.startsWith(".") && e !== "archive" && isDir(path.join(dir, e)))
|
|
51
|
+
: []);
|
|
52
|
+
const ledgerFile = path.join(archiveDir, "LEDGER.md");
|
|
53
|
+
return {
|
|
54
|
+
open: folders(changesDir),
|
|
55
|
+
archived: folders(archiveDir).map((e) => e.replace(DATED, "")),
|
|
56
|
+
ledger: isFile(ledgerFile) ? parseLedger(read(ledgerFile)).entries.map((e) => e.id) : [],
|
|
57
|
+
branches: branches ? idsOnOtherBranches(changesDir) : [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The next free change number, four digits. */
|
|
62
|
+
export function nextChangeNumber(projectRoot) {
|
|
63
|
+
const known = knownChangeIds(projectRoot);
|
|
64
|
+
let max = 0;
|
|
65
|
+
for (const id of [...known.open, ...known.archived, ...known.ledger, ...known.branches]) {
|
|
66
|
+
const n = numberOf(id);
|
|
67
|
+
if (n) max = Math.max(max, Number(n));
|
|
68
|
+
}
|
|
69
|
+
return String(max + 1).padStart(4, "0");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Open changes whose number another change also carries. An archived pair is
|
|
74
|
+
* history and stays as it is; an open one can still be given its own number.
|
|
75
|
+
* @returns {{ id: string, shares: string }[]}
|
|
76
|
+
*/
|
|
77
|
+
export function sharedNumbers(projectRoot) {
|
|
78
|
+
const known = knownChangeIds(projectRoot, { branches: false });
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const id of known.open) {
|
|
81
|
+
const n = numberOf(id);
|
|
82
|
+
if (!n) continue;
|
|
83
|
+
const other = [...known.open, ...known.archived, ...known.ledger].find((o) => o !== id && numberOf(o) === n);
|
|
84
|
+
// Of two open changes that share a number, the one that sorts last moves:
|
|
85
|
+
// one stable answer, whichever of the two a reader runs `validate` from.
|
|
86
|
+
if (other && !(known.open.includes(other) && other > id)) out.push({ id, shares: other });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Give an open change the next free number: the folder moves, and the id is
|
|
93
|
+
* rewritten in the change's own files. The caller re-derives the index.
|
|
94
|
+
* @returns {{ to: string, rewritten: string[] }}
|
|
95
|
+
*/
|
|
96
|
+
export function renumberChange(projectRoot, id) {
|
|
97
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
98
|
+
const to = `${nextChangeNumber(projectRoot)}-${id.replace(/^\d{1,4}-/, "")}`;
|
|
99
|
+
const dir = path.join(changesDir, to);
|
|
100
|
+
move(path.join(changesDir, id), dir);
|
|
101
|
+
const rewritten = [];
|
|
102
|
+
for (const file of walk(dir)) {
|
|
103
|
+
if (!file.endsWith(".md")) continue;
|
|
104
|
+
const text = read(file);
|
|
105
|
+
// The id as a whole, never as the start of a longer one: renumbering
|
|
106
|
+
// `0001-fix` turned a mention of `0001-fix-login` into `0002-fix-login`,
|
|
107
|
+
// a change that does not exist (change 0250).
|
|
108
|
+
const whole = new RegExp(`(?<![A-Za-z0-9-])${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9-])`, "g");
|
|
109
|
+
const next = text.replace(whole, to);
|
|
110
|
+
if (next === text) continue;
|
|
111
|
+
write(file, next, { force: true });
|
|
112
|
+
rewritten.push(path.relative(dir, file).split(path.sep).join("/"));
|
|
113
|
+
}
|
|
114
|
+
return { to, rewritten };
|
|
115
|
+
}
|
package/src/lib/change-ops.js
CHANGED
|
@@ -158,11 +158,11 @@ export function reindexChange(projectRoot, id, date = today()) {
|
|
|
158
158
|
export function addDeltaOps(projectRoot, id, capability, lines) {
|
|
159
159
|
if (lines.length === 0) {
|
|
160
160
|
return { code: EXIT.USAGE, error: "nothing to add",
|
|
161
|
-
hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
|
|
161
|
+
hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --property \"<text>\", --bump <level> or an op line" };
|
|
162
162
|
}
|
|
163
163
|
const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
|
|
164
164
|
if (bad.length > 0) {
|
|
165
|
-
return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
|
|
165
|
+
return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => String(x.op.error)) };
|
|
166
166
|
}
|
|
167
167
|
const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
168
168
|
if (!isFile(specPath)) {
|
package/src/lib/clarify-scan.js
CHANGED
|
@@ -96,7 +96,7 @@ function detectLang(text) {
|
|
|
96
96
|
return detectLanguage(text);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
export function rulesFor(projectRoot, text, forcedLang = null) {
|
|
99
|
+
export function rulesFor(projectRoot, text, forcedLang = /** @type {string | null} */ (null)) {
|
|
100
100
|
const lang = forcedLang ?? projectLanguage(projectRoot);
|
|
101
101
|
if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
|
|
102
102
|
// Detected per file, the document is scanned with BOTH lexicons (change
|
|
@@ -113,7 +113,7 @@ export function rulesFor(projectRoot, text, forcedLang = null) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/** The smells of a file on disk. */
|
|
116
|
-
export function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
116
|
+
export function scanFile(fullPath, projectRoot = process.cwd(), lang = /** @type {string | null} */ (null)) {
|
|
117
117
|
return scanText(read(fullPath), projectRoot, lang);
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -121,7 +121,7 @@ export function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
|
121
121
|
* The smells `clarify` reports for a text, line by line.
|
|
122
122
|
* @returns {Array<{ line: number, rule: string, token: string, hint: string }>}
|
|
123
123
|
*/
|
|
124
|
-
export function scanText(text, projectRoot = process.cwd(), lang = null) {
|
|
124
|
+
export function scanText(text, projectRoot = process.cwd(), lang = /** @type {string | null} */ (null)) {
|
|
125
125
|
const skippedRanges = collectSkippedRanges(text);
|
|
126
126
|
const rules = rulesFor(projectRoot, text, lang);
|
|
127
127
|
const smells = [];
|