doctrina-cli 0.20.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 +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- 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 +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -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/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -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 +5 -2
- package/src/lib/json-out.js +9 -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/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- 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 +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- 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/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
|
@@ -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)) {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { read } from "./fs-ops.js";
|
|
4
|
+
import { detectLanguage } from "./lexicon.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
// The clarify SCANNER (change 0232): the lexicons and the line scanner that
|
|
8
|
+
// `doctrina clarify` renders, in lib so a second caller can ask the same
|
|
9
|
+
// question. `change check` scans the spec a delta WILL write, before apply:
|
|
10
|
+
// a weasel word in a delta used to surface only after the close had merged
|
|
11
|
+
// it, when the repository's own living-documents check went red mid-close.
|
|
12
|
+
|
|
13
|
+
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
14
|
+
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
15
|
+
// "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
|
|
16
|
+
// the gate). The language comes from `.doctrina/config.json` ("language":
|
|
17
|
+
// "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
|
|
18
|
+
// the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
|
|
19
|
+
// ("TODO:") since bare TODO is the pronoun.
|
|
20
|
+
const RULES_EN = [
|
|
21
|
+
{
|
|
22
|
+
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
23
|
+
// uses "the system may ..." legitimately, and a clarify pass that
|
|
24
|
+
// flags every EARS Optional is unusable on Doctrina-style specs.
|
|
25
|
+
// The remaining weasel terms are unambiguous.
|
|
26
|
+
name: "weasel",
|
|
27
|
+
re: /\b(might|could|should\s+probably|perhaps|maybe|approximately|roughly)\b/gi,
|
|
28
|
+
hint: "state explicitly with a definite verb",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "vague",
|
|
32
|
+
// Two exclusions, both structural rather than a vocabulary loosening.
|
|
33
|
+
// The lookahead was already here: `many 5` quantifies. The lookbehind is
|
|
34
|
+
// change 0079: `how many` is INTERROGATIVE, and a requirement that says
|
|
35
|
+
// "shall report how many contracts declared no rows" names exactly the
|
|
36
|
+
// number the command must print — the opposite of vague. Five of this
|
|
37
|
+
// repository's seventeen smells were that phrase, and the noise is what
|
|
38
|
+
// hid the one real finding among them ("name some of them", which said
|
|
39
|
+
// nothing about how many). A gate that cries wolf teaches people to
|
|
40
|
+
// ignore it.
|
|
41
|
+
//
|
|
42
|
+
// The quality adjectives sit here for the same reason the quantifiers do:
|
|
43
|
+
// "robust" names no threshold, so it cannot be met or missed. They were
|
|
44
|
+
// in the Portuguese lexicon and nowhere in this one, so the SAME sentence
|
|
45
|
+
// translated came back with three smells in one language and none in the
|
|
46
|
+
// other — and this project keeps its own pages in both. A smell test that
|
|
47
|
+
// answers differently per language is not testing the writing.
|
|
48
|
+
re: /(?<!\bhow\s+)\b(many|few|some|several|adequate|robust|scalable)\b(?!\s+\d)/gi,
|
|
49
|
+
hint: "quantify (use a number or a precise scope)",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "placeholder",
|
|
53
|
+
re: /\b(TBD|TODO|FIXME|XXX|\?\?\?)\b/g,
|
|
54
|
+
hint: "resolve before applying",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// How much of the previous line a rule may look back into. Long enough for
|
|
59
|
+
// a wrapped two-word phrase, short enough that the probe stays cheap.
|
|
60
|
+
const LOOKBEHIND_TAIL = 40;
|
|
61
|
+
|
|
62
|
+
const RULES_PT = [
|
|
63
|
+
{
|
|
64
|
+
name: "weasel",
|
|
65
|
+
re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
|
|
66
|
+
hint: "afirme explicitamente, com um verbo definido",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
// ASCII-folded variants included so "varios"/"vários" both match.
|
|
70
|
+
name: "vague",
|
|
71
|
+
re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
|
|
72
|
+
hint: "quantifique (um número ou um escopo preciso)",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
// Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
|
|
76
|
+
name: "placeholder",
|
|
77
|
+
re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
|
|
78
|
+
hint: "resolva antes de aplicar",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Language for a file: the project-declared `.doctrina/config.json`
|
|
83
|
+
// ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
|
|
84
|
+
// stopword count over the file text decides. Never semantic (ADR 0005).
|
|
85
|
+
|
|
86
|
+
function projectLanguage(projectRoot) {
|
|
87
|
+
// One configuration reader (lib/config.js, change 0047): the key still
|
|
88
|
+
// lives in .doctrina/config.json, but `doctor` can now show what it
|
|
89
|
+
// resolved to, which is what a pt-BR project sitting red needed.
|
|
90
|
+
return loadConfig(projectRoot).language;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Which lexicon a document is written in — the shared detector (change
|
|
94
|
+
// 0040), so `clarify` and any future consumer count the same grammar words.
|
|
95
|
+
function detectLang(text) {
|
|
96
|
+
return detectLanguage(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function rulesFor(projectRoot, text, forcedLang = /** @type {string | null} */ (null)) {
|
|
100
|
+
const lang = forcedLang ?? projectLanguage(projectRoot);
|
|
101
|
+
if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
|
|
102
|
+
// Detected per file, the document is scanned with BOTH lexicons (change
|
|
103
|
+
// 0107). A bilingual spec — Portuguese purpose, English EARS, or the
|
|
104
|
+
// reverse — detected as one language hid every smell written in the
|
|
105
|
+
// other: `clarify --all` found nothing where `--lang pt` found two. The
|
|
106
|
+
// two vocabularies do not overlap, so a smell in either is a smell; the
|
|
107
|
+
// detected language only decides which hint text the reader gets first.
|
|
108
|
+
const detected = detectLang(text);
|
|
109
|
+
const [first, second] = detected === "pt" ? [RULES_PT, RULES_EN] : [RULES_EN, RULES_PT];
|
|
110
|
+
// The `placeholder` rule is language-neutral and lives in both lists:
|
|
111
|
+
// one copy, or TBD is reported twice.
|
|
112
|
+
return [...first, ...second.filter((r) => r.name !== "placeholder")];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The smells of a file on disk. */
|
|
116
|
+
export function scanFile(fullPath, projectRoot = process.cwd(), lang = /** @type {string | null} */ (null)) {
|
|
117
|
+
return scanText(read(fullPath), projectRoot, lang);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The smells `clarify` reports for a text, line by line.
|
|
122
|
+
* @returns {Array<{ line: number, rule: string, token: string, hint: string }>}
|
|
123
|
+
*/
|
|
124
|
+
export function scanText(text, projectRoot = process.cwd(), lang = /** @type {string | null} */ (null)) {
|
|
125
|
+
const skippedRanges = collectSkippedRanges(text);
|
|
126
|
+
const rules = rulesFor(projectRoot, text, lang);
|
|
127
|
+
const smells = [];
|
|
128
|
+
|
|
129
|
+
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
130
|
+
// inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
|
|
131
|
+
// by the author — skip it entirely (the escape hatch for a false positive
|
|
132
|
+
// the lexicon cannot know about; "block, never imprison").
|
|
133
|
+
const lines = text.split("\n");
|
|
134
|
+
let cursor = 0;
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i];
|
|
137
|
+
const lineStart = cursor;
|
|
138
|
+
const lineEnd = cursor + line.length;
|
|
139
|
+
if (line.includes("clarify:ok")) {
|
|
140
|
+
cursor = lineEnd + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// A rule reads the previous line as CONTEXT, never as content: a match
|
|
144
|
+
// is only reported when it starts inside this line. Prose wraps, and
|
|
145
|
+
// "report per spec how / many criteria..." split the interrogative
|
|
146
|
+
// across a line break — which a line-at-a-time scanner cannot see, so
|
|
147
|
+
// the phrase came back as a smell on one line of the same document and
|
|
148
|
+
// not on the next (change 0079). The prefix is trimmed to the tail of
|
|
149
|
+
// the previous line: enough for a lookbehind, cheap on every line.
|
|
150
|
+
const prev = i > 0 ? lines[i - 1].slice(-LOOKBEHIND_TAIL) : "";
|
|
151
|
+
const probe = prev === "" ? line : `${prev}\n${line}`;
|
|
152
|
+
const offset = probe.length - line.length;
|
|
153
|
+
for (const rule of rules) {
|
|
154
|
+
rule.re.lastIndex = 0;
|
|
155
|
+
let m;
|
|
156
|
+
while ((m = rule.re.exec(probe))) {
|
|
157
|
+
if (m.index < offset) continue; // the match belongs to the previous line
|
|
158
|
+
const absoluteStart = lineStart + m.index - offset;
|
|
159
|
+
if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
|
|
160
|
+
smells.push({
|
|
161
|
+
line: i + 1,
|
|
162
|
+
rule: rule.name,
|
|
163
|
+
token: m[0],
|
|
164
|
+
hint: rule.hint,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
cursor = lineEnd + 1; // +1 for the newline
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Section rule: empty Acceptance criteria.
|
|
172
|
+
if (hasEmptyAcceptanceCriteria(text)) {
|
|
173
|
+
smells.push({
|
|
174
|
+
line: findAcceptanceLine(text),
|
|
175
|
+
rule: "empty-section",
|
|
176
|
+
token: "Acceptance criteria",
|
|
177
|
+
hint: "fill in observable, verifiable signals",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
smells.sort((a, b) => a.line - b.line);
|
|
182
|
+
return smells;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
|
|
186
|
+
function collectSkippedRanges(text) {
|
|
187
|
+
const ranges = [];
|
|
188
|
+
// Fenced code blocks (``` ... ```)
|
|
189
|
+
for (const m of text.matchAll(/```[\s\S]*?```/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
190
|
+
// HTML comments (<!-- ... -->)
|
|
191
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
192
|
+
// Inline backtick spans (`...`)
|
|
193
|
+
for (const m of text.matchAll(/`[^`\n]+`/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
194
|
+
return ranges;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isInSkippedRange(offset, ranges) {
|
|
198
|
+
for (const [a, b] of ranges) if (offset >= a && offset < b) return true;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hasEmptyAcceptanceCriteria(text) {
|
|
203
|
+
const headingRe = /^##\s+Acceptance criteria\s*$/m;
|
|
204
|
+
const m = headingRe.exec(text);
|
|
205
|
+
if (!m) return false;
|
|
206
|
+
const after = text.slice(m.index + m[0].length);
|
|
207
|
+
// Find the body between this heading and the next heading of equal level.
|
|
208
|
+
const nextHeadingMatch = after.match(/^##\s+/m);
|
|
209
|
+
const body = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
|
|
210
|
+
// Strip HTML comments before checking emptiness.
|
|
211
|
+
const stripped = body.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
212
|
+
return stripped.length === 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findAcceptanceLine(text) {
|
|
216
|
+
const lines = text.split("\n");
|
|
217
|
+
for (let i = 0; i < lines.length; i++) {
|
|
218
|
+
if (/^##\s+Acceptance criteria\s*$/.test(lines[i])) return i + 1;
|
|
219
|
+
}
|
|
220
|
+
return 1;
|
|
221
|
+
}
|