nikos 0.0.0-stage → 0.0.1

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/dist/cli.js +60 -0
  4. package/dist/commands/bet.js +97 -0
  5. package/dist/commands/clear.js +100 -0
  6. package/dist/commands/decide.js +60 -0
  7. package/dist/commands/decisions.js +71 -0
  8. package/dist/commands/init.js +72 -0
  9. package/dist/commands/next.js +35 -0
  10. package/dist/commands/plan.js +64 -0
  11. package/dist/commands/show.js +82 -0
  12. package/dist/commands/state.js +104 -0
  13. package/dist/commands/validate.js +206 -0
  14. package/dist/commands/verify.js +95 -0
  15. package/dist/lib/project.js +42 -0
  16. package/dist/lint/constitution.js +40 -0
  17. package/dist/lint/gherkin.js +99 -0
  18. package/dist/lint/portable.js +66 -0
  19. package/dist/verify/detect.js +21 -0
  20. package/docs/format.md +296 -0
  21. package/docs/gherkin.md +81 -0
  22. package/docs/install.md +72 -0
  23. package/docs/intentdocs-mapping.md +117 -0
  24. package/package.json +44 -4
  25. package/schemas/bet.schema.json +23 -0
  26. package/schemas/state.schema.json +34 -0
  27. package/schemas/storymap.schema.json +43 -0
  28. package/skills/clear/SKILL.md +55 -0
  29. package/skills/clear-review/SKILL.md +45 -0
  30. package/skills/decisions/SKILL.md +45 -0
  31. package/skills/intent/SKILL.md +39 -0
  32. package/skills/nikos-mode/SKILL.md +61 -0
  33. package/skills/nikos-mode/playbooks/answer.md +9 -0
  34. package/skills/nikos-mode/playbooks/bet.md +14 -0
  35. package/skills/nikos-mode/playbooks/build-a-story.md +14 -0
  36. package/skills/nikos-mode/playbooks/change.md +12 -0
  37. package/skills/nikos-mode/playbooks/idea-to-blueprint.md +16 -0
  38. package/skills/nikos-mode/playbooks/index.md +13 -0
  39. package/skills/nikos-mode/playbooks/validate.md +17 -0
  40. package/skills/nikos-mode/principles/index.md +14 -0
  41. package/skills/verify/SKILL.md +49 -0
  42. package/templates/intent/constitution.md +19 -0
  43. package/templates/intent/decisions.md +12 -0
  44. package/templates/intent/features/getting-started/first-story.feature +12 -0
  45. package/templates/intent/plans/.gitkeep +0 -0
  46. package/templates/intent/state.yaml +4 -0
  47. package/templates/intent/storymap.yaml +10 -0
  48. package/templates/plan.md +13 -0
  49. package/templates/verify/js/cucumber.mjs +6 -0
  50. package/templates/verify/js/test/steps/first-story.steps.mjs +16 -0
  51. package/templates/verify/python/behave.ini +4 -0
  52. package/templates/verify/python/steps/first_story_steps.py +19 -0
  53. package/templates/verify/ts/cucumber.mjs +6 -0
  54. package/templates/verify/ts/test/steps/first-story.steps.ts +16 -0
@@ -0,0 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { STORY_ID, findIntentDir, read, rel, walk } from "../lib/project.js";
5
+ import { parseFeature, scenariosOf } from "../lint/gherkin.js";
6
+ import { featureFor } from "./clear.js";
7
+ import { planVersions } from "./plan.js";
8
+ import { parseBet } from "./bet.js";
9
+ import { decisionEntries } from "./decisions.js";
10
+ const yamlFile = (path) => (existsSync(path) ? parse(read(path)) : undefined);
11
+ /** Everything the intent/ folder says about one story, in plain text. */
12
+ export function show(story, dir) {
13
+ if (!STORY_ID.test(story))
14
+ throw new Error(`'${story}' is not a story id (expected e.g. HT-1)`);
15
+ const intentDir = findIntentDir(dir);
16
+ const file = featureFor(story, dir);
17
+ if (!file)
18
+ throw new Error(`No feature file is tagged @${story}`);
19
+ const feature = parseFeature(read(file));
20
+ if (!feature)
21
+ throw new Error(`${rel(intentDir, file)} has no Feature`);
22
+ const tags = feature.tags.map((t) => t.name.slice(1));
23
+ const priority = tags.find((t) => ["must", "should", "could", "wont"].includes(t));
24
+ const effort = tags.find((t) => t.startsWith("effort-"))?.slice(7);
25
+ const map = yamlFile(join(intentDir, "storymap.yaml"));
26
+ const release = (map?.releases ?? []).find((r) => (r?.stories ?? []).includes(story));
27
+ const entry = yamlFile(join(intentDir, "state.yaml"))?.stories?.[story];
28
+ const out = [`${story} — ${feature.name}`];
29
+ if (entry) {
30
+ const extra = [entry.claimed_by && `claimed by ${entry.claimed_by}`, entry.updated && `updated ${entry.updated}`].filter(Boolean);
31
+ out.push(`Status: ${entry.status}${extra.length ? ` (${extra.join(", ")})` : ""}`);
32
+ if (entry.blockers?.length)
33
+ out.push(`Blockers: ${entry.blockers.join("; ")}`);
34
+ }
35
+ else {
36
+ out.push("Status: not in state.yaml");
37
+ }
38
+ out.push([release ? `Release ${release.number}${release.title ? ` (${release.title})` : ""}` : "Not in a release", priority, effort && `effort ${effort}`]
39
+ .filter(Boolean)
40
+ .join(" · "));
41
+ out.push("", `Feature: intent/${rel(intentDir, file)}`);
42
+ for (const line of feature.description.split("\n").map((l) => l.trim()).filter(Boolean))
43
+ out.push(` ${line}`);
44
+ out.push("", "Scenarios:");
45
+ for (const s of scenariosOf(feature))
46
+ out.push(` ${s.name}`);
47
+ const versions = planVersions(intentDir, story);
48
+ out.push("");
49
+ if (versions.length) {
50
+ const v = versions.at(-1);
51
+ const plan = parseBet(read(join(intentDir, "plans", `${story}.v${v}.md`)));
52
+ out.push(`Plan: v${v} (intent/plans/${story}.v${v}.md)${plan?.meta?.summary ? ` — ${plan.meta.summary}` : ""}`);
53
+ }
54
+ else {
55
+ out.push("Plan: none yet");
56
+ }
57
+ const bets = walk(join(intentDir, "bets"), ".md")
58
+ .map((p) => parseBet(read(p))?.meta)
59
+ .filter((m) => Array.isArray(m?.stories) && m.stories.includes(story));
60
+ out.push(bets.length ? `Bets: ${bets.map((b) => `${b.id} (${b.status}, ends ${b.time_box}, intent/bets/${b.id}.md)`).join(", ")}` : "Bets: none");
61
+ const decisions = decisionEntries(dir).filter((e) => e.title.includes(`(${story})`));
62
+ out.push(decisions.length ? "Decisions:" : "Decisions: none recorded for this story");
63
+ for (const d of decisions)
64
+ out.push(` intent/decisions.md:${d.start} ${d.title}`);
65
+ return out.join("\n");
66
+ }
67
+ export function runShow(args) {
68
+ const [story, ...rest] = args;
69
+ if (!story) {
70
+ console.error("Usage: nikos show <story-id> [--dir <path>]");
71
+ return 1;
72
+ }
73
+ const i = rest.indexOf("--dir");
74
+ try {
75
+ console.log(show(story, i >= 0 ? rest[i + 1] : process.cwd()));
76
+ return 0;
77
+ }
78
+ catch (err) {
79
+ console.error(`✗ ${err.message}`);
80
+ return 1;
81
+ }
82
+ }
@@ -0,0 +1,104 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parseDocument } from "yaml";
4
+ import { STORY_ID, findIntentDir, read } from "../lib/project.js";
5
+ import { formatResult, validate } from "./validate.js";
6
+ import { verify } from "./verify.js";
7
+ /** The moves docs/format.md allows. `done` also needs the story's scenarios to pass. */
8
+ const MOVES = {
9
+ todo: ["in_progress"],
10
+ in_progress: ["needs_rework", "done"],
11
+ needs_rework: ["in_progress"],
12
+ done: ["needs_rework"],
13
+ };
14
+ /** Scenario names from a cucumber-js or behave run that didn't pass. */
15
+ export function failingScenarios(output) {
16
+ const names = new Set();
17
+ // cucumber-js: "1) Scenario: AC1 — Title # intent/features/…"
18
+ for (const m of output.matchAll(/^\s*\d+\) Scenario: (.+?)\s+#/gm))
19
+ names.add(m[1]);
20
+ // behave: "Failing scenarios:" then " intent/features/…:8 AC1 — Title"
21
+ const behave = /Failing scenarios:\n((?:\s+\S+:\d+\s+.+\n?)+)/.exec(output);
22
+ if (behave)
23
+ for (const line of behave[1].split("\n")) {
24
+ const m = /^\s+\S+:\d+\s+(.+?)\s*$/.exec(line);
25
+ if (m)
26
+ names.add(m[1]);
27
+ }
28
+ return [...names];
29
+ }
30
+ export class StateError extends Error {
31
+ result;
32
+ constructor(message, result) {
33
+ super(message);
34
+ this.result = result;
35
+ }
36
+ }
37
+ export function setState(story, to, opts = {}) {
38
+ if (!STORY_ID.test(story))
39
+ throw new StateError(`'${story}' is not a story id (expected e.g. HT-1)`);
40
+ if (!(to in MOVES))
41
+ throw new StateError(`'${to}' is not a status (todo, in_progress, needs_rework, done)`);
42
+ const intentDir = findIntentDir(opts.dir ?? process.cwd());
43
+ const path = join(intentDir, "state.yaml");
44
+ // A Document keeps comments and the order of everything we don't touch.
45
+ const doc = parseDocument(read(path));
46
+ const from = doc.getIn(["stories", story, "status"]);
47
+ if (!from)
48
+ throw new StateError(`${story} has no entry in state.yaml`);
49
+ if (!MOVES[from]?.includes(to)) {
50
+ throw new StateError(`${story} can't move from ${from} to ${to}. From ${from} it can move to: ${MOVES[from].join(", ")}`);
51
+ }
52
+ const result = { story, from, to, failing: [] };
53
+ if (to === "done") {
54
+ let run;
55
+ try {
56
+ run = verify(dirname(intentDir), story);
57
+ }
58
+ catch (err) {
59
+ throw new StateError(`${story} stays ${from}: its scenarios couldn't run. ${err.message}`, result);
60
+ }
61
+ if (run.status !== 0) {
62
+ result.failing = failingScenarios(run.output);
63
+ result.output = run.output;
64
+ const list = result.failing.length ? `:\n${result.failing.map((s) => ` ✗ ${s}`).join("\n")}` : ". See the runner output.";
65
+ throw new StateError(`${story} stays ${from}: its scenarios don't all pass${list}`, result);
66
+ }
67
+ result.output = run.output;
68
+ }
69
+ doc.setIn(["stories", story, "status"], to);
70
+ if (opts.by && to === "in_progress")
71
+ doc.setIn(["stories", story, "claimed_by"], opts.by);
72
+ doc.setIn(["stories", story, "updated"], opts.date ?? new Date().toISOString().slice(0, 10));
73
+ // No padding keeps "[A-1, A-2]" as written instead of "[ A-1, A-2 ]".
74
+ writeFileSync(path, doc.toString({ flowCollectionPadding: false }));
75
+ return result;
76
+ }
77
+ export function runState(args) {
78
+ const [sub, story, to, ...rest] = args;
79
+ if (sub !== "set" || !story || !to) {
80
+ console.error("Usage: nikos state set <story-id> <todo|in_progress|needs_rework|done> [--by <name>] [--dir <path>]");
81
+ return 1;
82
+ }
83
+ const opt = (n) => {
84
+ const i = rest.indexOf(`--${n}`);
85
+ return i >= 0 ? rest[i + 1] : undefined;
86
+ };
87
+ try {
88
+ const r = setState(story, to, { dir: opt("dir"), by: opt("by") });
89
+ console.log(`${r.story}: ${r.from} → ${r.to}`);
90
+ }
91
+ catch (err) {
92
+ const e = err;
93
+ if (e.result?.output && !e.result.failing.length)
94
+ console.error(e.result.output);
95
+ console.error(`✗ ${e.message}`);
96
+ return 1;
97
+ }
98
+ const v = validate(opt("dir") ?? process.cwd());
99
+ if (v.errors.length) {
100
+ console.error(formatResult(v));
101
+ return 1;
102
+ }
103
+ return 0;
104
+ }
@@ -0,0 +1,206 @@
1
+ import Ajv2020Module from "ajv/dist/2020.js";
2
+ const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
3
+ import { existsSync } from "node:fs";
4
+ import { join, dirname, basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parse } from "yaml";
7
+ import { lintFeature } from "../lint/gherkin.js";
8
+ import { lintConstitution } from "../lint/constitution.js";
9
+ import { parseBet } from "./bet.js";
10
+ import { featureStoryIds, findIntentDir, read, rel, walk } from "../lib/project.js";
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ // Works from src/ (tests) and dist/ (published): schemas sit at the package root.
13
+ const schemaDir = [join(here, "../../schemas"), join(here, "../schemas")].find((d) => existsSync(d));
14
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
15
+ const checkStorymap = ajv.compile(JSON.parse(read(join(schemaDir, "storymap.schema.json"))));
16
+ const checkState = ajv.compile(JSON.parse(read(join(schemaDir, "state.schema.json"))));
17
+ const checkBet = ajv.compile(JSON.parse(read(join(schemaDir, "bet.schema.json"))));
18
+ function pointer(e) {
19
+ return e.instancePath.replace(/^\//, "").split("/").filter(Boolean).join(".") || "(root)";
20
+ }
21
+ function describe(e, data) {
22
+ const at = pointer(e);
23
+ const value = e.instancePath
24
+ .split("/")
25
+ .filter(Boolean)
26
+ .reduce((v, k) => (v == null ? v : v[k]), data);
27
+ switch (e.keyword) {
28
+ case "enum":
29
+ return `${at} '${value}' is not one of ${e.params.allowedValues.join(", ")}`;
30
+ case "const":
31
+ return `${at} must be ${JSON.stringify(e.params.allowedValue)}`;
32
+ case "required":
33
+ return `${at === "(root)" ? "" : at + ": "}missing required field '${e.params.missingProperty}'`;
34
+ case "additionalProperties":
35
+ return `${at === "(root)" ? "" : at + ": "}unknown field '${e.params.additionalProperty}'`;
36
+ case "pattern":
37
+ return `${at} '${value}' is not in the expected format`;
38
+ case "propertyNames":
39
+ return `${at}: '${e.params.propertyName}' is not a valid story id (expected e.g. HT-1)`;
40
+ default:
41
+ return `${at} ${e.message}`;
42
+ }
43
+ }
44
+ function loadYaml(dir, name, issues) {
45
+ const path = join(dir, name);
46
+ if (!existsSync(path)) {
47
+ issues.push({ file: name, message: "file is missing", hint: "run `nikos init` to create it" });
48
+ return undefined;
49
+ }
50
+ try {
51
+ return parse(read(path));
52
+ }
53
+ catch (err) {
54
+ issues.push({ file: name, message: `is not valid YAML: ${err.message.split("\n")[0]}` });
55
+ return undefined;
56
+ }
57
+ }
58
+ export function validate(start) {
59
+ const dir = findIntentDir(start);
60
+ const errors = [];
61
+ const warnings = [];
62
+ const storymap = loadYaml(dir, "storymap.yaml", errors);
63
+ const state = loadYaml(dir, "state.yaml", errors);
64
+ if (storymap !== undefined && !checkStorymap(storymap)) {
65
+ for (const e of checkStorymap.errors ?? []) {
66
+ errors.push({ file: "storymap.yaml", message: describe(e, storymap) });
67
+ }
68
+ }
69
+ if (state !== undefined && !checkState(state)) {
70
+ for (const e of checkState.errors ?? []) {
71
+ // propertyNames also reports the inner pattern failure; the propertyNames message covers it.
72
+ if (e.keyword === "pattern" && e.propertyName)
73
+ continue;
74
+ errors.push({ file: "state.yaml", message: describe(e, state) });
75
+ }
76
+ }
77
+ // Cross-file checks only when both files parsed into objects.
78
+ const mapStories = new Map();
79
+ const activityIds = new Set();
80
+ if (storymap && typeof storymap === "object") {
81
+ for (const a of storymap.activities ?? [])
82
+ activityIds.add(a?.id);
83
+ const numbers = [];
84
+ for (const r of storymap.releases ?? []) {
85
+ numbers.push(r?.number);
86
+ for (const id of r?.stories ?? []) {
87
+ if (mapStories.has(id)) {
88
+ errors.push({ file: "storymap.yaml", message: `${id} appears in more than one release`, hint: "keep each story in exactly one release" });
89
+ }
90
+ mapStories.set(id, r.number);
91
+ if (storymap.key && typeof id === "string" && !id.startsWith(`${storymap.key}-`)) {
92
+ errors.push({ file: "storymap.yaml", message: `${id} doesn't use the project key '${storymap.key}'` });
93
+ }
94
+ }
95
+ }
96
+ const sorted = [...numbers].filter((n) => typeof n === "number").sort((a, b) => a - b);
97
+ if (sorted.some((n, i) => n !== i + 1)) {
98
+ errors.push({ file: "storymap.yaml", message: `release numbers must start at 1 with no gaps (found ${sorted.join(", ")})` });
99
+ }
100
+ }
101
+ const constitutionPath = join(dir, "constitution.md");
102
+ if (existsSync(constitutionPath)) {
103
+ for (const issue of lintConstitution(read(constitutionPath))) {
104
+ (issue.level === "error" ? errors : warnings).push({ file: "constitution.md", message: issue.message, hint: issue.hint });
105
+ }
106
+ }
107
+ // Features
108
+ const featureIds = new Map();
109
+ for (const path of walk(join(dir, "features"), ".feature")) {
110
+ const file = rel(dir, path);
111
+ const text = read(path);
112
+ for (const issue of lintFeature(text)) {
113
+ (issue.level === "error" ? errors : warnings).push({ file, message: issue.message, hint: issue.hint });
114
+ }
115
+ const ids = featureStoryIds(text);
116
+ if (ids.length === 0) {
117
+ errors.push({ file, message: "has no story id tag", hint: "add a tag like @HT-1 above `Feature:`" });
118
+ continue;
119
+ }
120
+ if (ids.length > 1) {
121
+ errors.push({ file, message: `has more than one story id tag (${ids.join(", ")})`, hint: "one feature file per story" });
122
+ }
123
+ const id = ids[0];
124
+ if (featureIds.has(id)) {
125
+ errors.push({ file, message: `${id} already has a feature file (${featureIds.get(id)})` });
126
+ }
127
+ featureIds.set(id, file);
128
+ const activity = basename(dirname(path));
129
+ if (activityIds.size && !activityIds.has(activity)) {
130
+ errors.push({ file, message: `is in folder '${activity}', which is not an activity in storymap.yaml` });
131
+ }
132
+ if (storymap && !mapStories.has(id)) {
133
+ errors.push({ file, message: `${id} is not in any release in storymap.yaml` });
134
+ }
135
+ }
136
+ const stateStories = (state && typeof state === "object" && state.stories) || {};
137
+ if (storymap && state) {
138
+ for (const id of mapStories.keys()) {
139
+ if (!featureIds.has(id)) {
140
+ errors.push({ file: "storymap.yaml", message: `${id} has no feature file`, hint: `add intent/features/<activity>/<slug>.feature tagged @${id}` });
141
+ }
142
+ if (!(id in stateStories)) {
143
+ errors.push({ file: "state.yaml", message: `${id} is in storymap.yaml but has no entry under stories`, hint: `add "${id}: { status: todo }"` });
144
+ }
145
+ }
146
+ for (const id of Object.keys(stateStories)) {
147
+ if (!mapStories.has(id)) {
148
+ errors.push({ file: "state.yaml", message: `stories.${id} is an unknown story id`, hint: "add it to a release in storymap.yaml or remove it" });
149
+ }
150
+ }
151
+ for (const id of state.run?.queue ?? []) {
152
+ if (!mapStories.has(id))
153
+ errors.push({ file: "state.yaml", message: `run.queue has unknown story id ${id}` });
154
+ }
155
+ }
156
+ // Bets: schema, id matches the file name, and every linked story is in the story map.
157
+ for (const path of walk(join(dir, "bets"), ".md")) {
158
+ const file = rel(dir, path);
159
+ let bet;
160
+ try {
161
+ bet = parseBet(read(path));
162
+ }
163
+ catch (err) {
164
+ errors.push({ file, message: `front matter isn't valid YAML: ${err.message.split("\n")[0]}` });
165
+ continue;
166
+ }
167
+ if (!bet) {
168
+ errors.push({ file, message: "has no front matter", hint: "create bets with `nikos bet new`" });
169
+ continue;
170
+ }
171
+ if (!checkBet(bet.meta)) {
172
+ for (const e of checkBet.errors ?? [])
173
+ errors.push({ file, message: describe(e, bet.meta) });
174
+ continue;
175
+ }
176
+ if (bet.meta.id !== basename(path, ".md")) {
177
+ errors.push({ file, message: `id '${bet.meta.id}' doesn't match the file name`, hint: `rename the file to bets/${bet.meta.id}.md` });
178
+ }
179
+ if (storymap) {
180
+ for (const id of bet.meta.stories) {
181
+ if (!mapStories.has(id))
182
+ errors.push({ file, message: `links ${id}, which is not in storymap.yaml` });
183
+ }
184
+ }
185
+ }
186
+ return { dir, errors, warnings, stories: mapStories.size, releases: storymap?.releases?.length ?? 0 };
187
+ }
188
+ export function formatResult(r) {
189
+ const lines = [];
190
+ for (const e of r.errors)
191
+ lines.push(`✗ ${e.file}: ${e.message}${e.hint ? `\n → ${e.hint}` : ""}`);
192
+ for (const w of r.warnings)
193
+ lines.push(`! ${w.file}: ${w.message}${w.hint ? `\n → ${w.hint}` : ""}`);
194
+ if (r.errors.length === 0) {
195
+ lines.push(`✓ intent/ is valid — ${r.stories} ${r.stories === 1 ? "story" : "stories"}, ${r.releases} ${r.releases === 1 ? "release" : "releases"}`);
196
+ }
197
+ else {
198
+ lines.push(`\n${r.errors.length} ${r.errors.length === 1 ? "problem" : "problems"} found.`);
199
+ }
200
+ return lines.join("\n");
201
+ }
202
+ export function runValidate(args) {
203
+ const r = validate(args[0] ?? process.cwd());
204
+ console.log(formatResult(r));
205
+ return r.errors.length ? 1 : 0;
206
+ }
@@ -0,0 +1,95 @@
1
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { walk } from "../lib/project.js";
6
+ import { detectStack } from "../verify/detect.js";
7
+ import { featureFor } from "./clear.js";
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const templateDir = [join(here, "../../templates/verify"), join(here, "../templates/verify")].find((d) => existsSync(d));
10
+ const INSTALL = {
11
+ js: "npm install --save-dev @cucumber/cucumber@^12",
12
+ ts: "npm install --save-dev @cucumber/cucumber@^12 tsx",
13
+ python: "pip install behave",
14
+ };
15
+ const CONFIG = { js: "cucumber.mjs", ts: "cucumber.mjs", python: "behave.ini" };
16
+ function stackOrThrow(root) {
17
+ const stack = detectStack(root);
18
+ if (!stack)
19
+ throw new Error("Couldn't tell the stack: no package.json, pyproject.toml or requirements.txt. Ask which runner to use.");
20
+ return stack;
21
+ }
22
+ /** Write the runner config and step stubs for the repo's stack. Never overwrites a file. */
23
+ export function setupVerify(root) {
24
+ const stack = stackOrThrow(root);
25
+ const src = join(templateDir, stack === "python" ? "python" : stack);
26
+ const created = [];
27
+ const skipped = [];
28
+ // Step stubs are for the example story `nikos init` creates; skip them once it's gone.
29
+ const hasExample = existsSync(join(root, "intent/features/getting-started/first-story.feature"));
30
+ for (const file of walk(src)) {
31
+ const rel = relative(src, file);
32
+ if (!hasExample && /first[-_]story/.test(rel))
33
+ continue;
34
+ const dest = join(root, rel);
35
+ if (existsSync(dest)) {
36
+ skipped.push(rel);
37
+ continue;
38
+ }
39
+ mkdirSync(dirname(dest), { recursive: true });
40
+ copyFileSync(file, dest);
41
+ created.push(rel);
42
+ }
43
+ return { stack, created, skipped, install: INSTALL[stack] };
44
+ }
45
+ /** Run the story's scenarios (or all of them) with the repo's Cucumber runner. */
46
+ export function verify(root, story, opts = {}) {
47
+ const stack = stackOrThrow(root);
48
+ if (!existsSync(join(root, CONFIG[stack])))
49
+ throw new Error(`No ${CONFIG[stack]} yet. Run \`nikos verify setup\` first.`);
50
+ // cucumber-js and behave both run zero scenarios and exit 0 for a tag that matches nothing.
51
+ if (story && !featureFor(story, root))
52
+ throw new Error(`No feature file is tagged @${story}`);
53
+ let cmd;
54
+ let args;
55
+ const env = { ...process.env };
56
+ if (stack === "python") {
57
+ cmd = process.env.PYTHON ?? "python3";
58
+ args = ["-m", "behave", ...(story ? [`--tags=@${story}`] : [])];
59
+ }
60
+ else {
61
+ cmd = join(root, "node_modules/.bin/cucumber-js");
62
+ if (!existsSync(cmd))
63
+ throw new Error(`cucumber-js isn't installed. Run: ${INSTALL[stack]}`);
64
+ args = story ? ["--tags", `@${story}`] : [];
65
+ // tsx lets Node versions without built-in type stripping load .ts step files.
66
+ if (stack === "ts")
67
+ env.NODE_OPTIONS = `${env.NODE_OPTIONS ?? ""} --import tsx`.trim();
68
+ }
69
+ const r = spawnSync(cmd, args, { cwd: root, env, encoding: "utf8", stdio: opts.inherit ? "inherit" : "pipe" });
70
+ if (r.error)
71
+ throw new Error(`Couldn't run ${cmd}: ${r.error.message}. Run: ${INSTALL[stack]}`);
72
+ return { stack, command: [cmd, ...args].join(" "), status: r.status ?? 1, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
73
+ }
74
+ export function runVerify(args) {
75
+ const i = args.indexOf("--dir");
76
+ const root = i >= 0 ? args[i + 1] : process.cwd();
77
+ const positional = args.filter((a, n) => a !== "--dir" && (i < 0 || n !== i + 1));
78
+ try {
79
+ if (positional[0] === "setup") {
80
+ const r = setupVerify(root);
81
+ console.log(`Stack: ${r.stack}`);
82
+ if (r.created.length)
83
+ console.log(`Created:\n${r.created.map((f) => ` + ${f}`).join("\n")}`);
84
+ if (r.skipped.length)
85
+ console.log(`Skipped (already there):\n${r.skipped.map((f) => ` = ${f}`).join("\n")}`);
86
+ console.log(`\nNext: ${r.install}\nThen: nikos verify <story-id>`);
87
+ return 0;
88
+ }
89
+ return verify(root, positional[0], { inherit: true }).status;
90
+ }
91
+ catch (err) {
92
+ console.error(`✗ ${err.message}`);
93
+ return 1;
94
+ }
95
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ export const STORY_ID = /^[A-Z][A-Z0-9]{0,9}-[0-9]+$/;
4
+ /** Resolve the intent/ folder from a repo root or the folder itself. */
5
+ export function findIntentDir(start) {
6
+ if (existsSync(join(start, "storymap.yaml")))
7
+ return start;
8
+ return join(start, "intent");
9
+ }
10
+ export function walk(dir, ext) {
11
+ if (!existsSync(dir))
12
+ return [];
13
+ return readdirSync(dir).flatMap((f) => {
14
+ const p = join(dir, f);
15
+ if (statSync(p).isDirectory())
16
+ return walk(p, ext);
17
+ return !ext || p.endsWith(ext) ? [p] : [];
18
+ });
19
+ }
20
+ export function read(path) {
21
+ return readFileSync(path, "utf8");
22
+ }
23
+ export function rel(dir, path) {
24
+ return relative(dir, path) || path;
25
+ }
26
+ /** Story id tags on the lines above `Feature:`. */
27
+ export function featureStoryIds(text) {
28
+ const ids = [];
29
+ for (const line of text.split("\n")) {
30
+ const t = line.trim();
31
+ if (t.startsWith("Feature:"))
32
+ break;
33
+ if (!t.startsWith("@"))
34
+ continue;
35
+ for (const tag of t.split(/\s+/)) {
36
+ const name = tag.replace(/^@/, "");
37
+ if (STORY_ID.test(name))
38
+ ids.push(name);
39
+ }
40
+ }
41
+ return ids;
42
+ }
@@ -0,0 +1,40 @@
1
+ import { parse } from "yaml";
2
+ /** The five vision sections every constitution has, in this order (docs/format.md). */
3
+ export const VISION_SECTIONS = ["Users", "Problem", "Switch", "First", "Proof"];
4
+ /** Check constitution.md has its front matter and the five vision sections, in order and filled in. */
5
+ export function lintConstitution(text) {
6
+ const issues = [];
7
+ const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
8
+ let body = text;
9
+ if (!m) {
10
+ issues.push({ level: "error", message: "has no front matter", hint: "start with ---, name: and idea:, then ---" });
11
+ }
12
+ else {
13
+ body = text.slice(m[0].length);
14
+ let meta = {};
15
+ try {
16
+ meta = parse(m[1]) ?? {};
17
+ }
18
+ catch (err) {
19
+ issues.push({ level: "error", message: `front matter isn't valid YAML: ${err.message.split("\n")[0]}` });
20
+ }
21
+ for (const key of ["name", "idea"]) {
22
+ if (typeof meta[key] !== "string" || !meta[key].trim())
23
+ issues.push({ level: "error", message: `front matter is missing '${key}'` });
24
+ }
25
+ }
26
+ // Split into "## Heading" sections; anything before the first heading is ignored.
27
+ const sections = [...body.matchAll(/^## (.+)\n([\s\S]*?)(?=^## |(?![\s\S]))/gm)].map((s) => ({ title: s[1].trim(), text: s[2].trim() }));
28
+ const titles = sections.map((s) => s.title);
29
+ const missing = VISION_SECTIONS.filter((t) => !titles.includes(t));
30
+ if (missing.length) {
31
+ issues.push({ level: "error", message: `is missing ${missing.map((t) => `## ${t}`).join(", ")}`, hint: `the five vision sections are ${VISION_SECTIONS.join(", ")}` });
32
+ }
33
+ else if (VISION_SECTIONS.some((t, i) => titles[i] !== t)) {
34
+ issues.push({ level: "error", message: `vision sections are out of order (${titles.slice(0, 5).join(", ")})`, hint: `put ${VISION_SECTIONS.join(", ")} first, in that order; extra sections go after` });
35
+ }
36
+ for (const s of sections.filter((s) => VISION_SECTIONS.includes(s.title) && !s.text)) {
37
+ issues.push({ level: "error", message: `## ${s.title} is empty` });
38
+ }
39
+ return issues;
40
+ }
@@ -0,0 +1,99 @@
1
+ import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
2
+ import { IdGenerator } from "@cucumber/messages";
3
+ import { STORY_ID } from "../lib/project.js";
4
+ /** Tags that describe progress, which belongs in state.yaml, not the spec. */
5
+ const STATUS_TAGS = /^@(todo|done|wip|in[-_]?progress|doing|blocked|needs[-_]?rework|ready|review|approved|complete[d]?|skip|pending)$/i;
6
+ const PRIORITY = /^@(must|should|could)$/;
7
+ const AC_TAG = /^@AC\d+$/;
8
+ const KNOWN_FEATURE_TAG = [STORY_ID, /^release-\d+$/, /^(must|should|could)$/, /^activity-[a-z0-9-]+$/, /^effort-(XS|S|M|L|XL)$/];
9
+ function parser() {
10
+ return new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher());
11
+ }
12
+ /** Parse a feature file. Throws on invalid Gherkin. */
13
+ export function parseFeature(text) {
14
+ return parser().parse(text).feature;
15
+ }
16
+ /** Every scenario in a feature, including those inside Rules. */
17
+ export function scenariosOf(feature) {
18
+ return feature.children.flatMap((c) => (c.scenario ? [c.scenario] : c.rule ? c.rule.children.flatMap((rc) => (rc.scenario ? [rc.scenario] : [])) : []));
19
+ }
20
+ export function lintFeature(text) {
21
+ const issues = [];
22
+ const featureLines = text.split("\n").filter((l) => /^\s*Feature:/.test(l)).length;
23
+ if (featureLines > 1) {
24
+ issues.push({ level: "error", message: `has ${featureLines} Features`, hint: "one Feature per file, one file per story" });
25
+ }
26
+ let doc;
27
+ try {
28
+ doc = parser().parse(text);
29
+ }
30
+ catch (err) {
31
+ issues.push({ level: "error", message: `isn't valid Gherkin: ${err.message.split("\n")[0]}` });
32
+ return issues;
33
+ }
34
+ const feature = doc.feature;
35
+ if (!feature) {
36
+ issues.push({ level: "error", message: "has no Feature", hint: "start the file with a story id tag and `Feature: <title>`" });
37
+ return issues;
38
+ }
39
+ const tags = feature.tags.map((t) => t.name);
40
+ for (const tag of tags) {
41
+ if (STATUS_TAGS.test(tag)) {
42
+ issues.push({ level: "error", message: `uses status tag ${tag}`, hint: "status lives in state.yaml, not in the feature" });
43
+ }
44
+ else if (!KNOWN_FEATURE_TAG.some((re) => re.test(tag.slice(1)))) {
45
+ issues.push({ level: "warning", message: `has unrecognised tag ${tag}`, hint: "allowed: story id, @release-N, @must/@should/@could, @activity-<id>, @effort-<size>" });
46
+ }
47
+ }
48
+ if (tags.filter((t) => PRIORITY.test(t)).length !== 1) {
49
+ issues.push({ level: "error", message: "needs exactly one priority tag on the Feature", hint: "add @must, @should or @could" });
50
+ }
51
+ if (!tags.some((t) => /^@release-\d+$/.test(t))) {
52
+ issues.push({ level: "error", message: "has no @release-N tag" });
53
+ }
54
+ const desc = feature.description ?? "";
55
+ // "As a", "As an" and "As the" all name a role; I want and So that are fixed.
56
+ const parts = [["As a", /^\s*As (?:an?|the)\s+\S/im], ["I want", /^\s*I want\b/im], ["So that", /^\s*So that\b/im]];
57
+ const missing = parts.filter(([, re]) => !re.test(desc)).map(([label]) => label);
58
+ if (missing.length) {
59
+ issues.push({
60
+ level: "error",
61
+ message: `user story is missing ${missing.map((m) => `"${m}"`).join(", ")}`,
62
+ hint: "put As a … / I want … / So that … on separate lines under Feature:",
63
+ });
64
+ }
65
+ const scenarios = scenariosOf(feature);
66
+ if (scenarios.length === 0) {
67
+ issues.push({ level: "warning", message: "has no scenarios yet", hint: "add one Scenario per acceptance criterion" });
68
+ }
69
+ const labels = new Set();
70
+ for (const s of scenarios) {
71
+ const sTags = s.tags.map((t) => t.name);
72
+ const ac = sTags.find((t) => AC_TAG.test(t));
73
+ for (const tag of sTags) {
74
+ if (STATUS_TAGS.test(tag)) {
75
+ issues.push({ level: "error", message: `scenario "${s.name}" uses status tag ${tag}`, hint: "status lives in state.yaml, not in the feature" });
76
+ }
77
+ }
78
+ if (!ac) {
79
+ issues.push({ level: "error", message: `scenario "${s.name}" has no @AC<n> tag`, hint: "tag each scenario with its criterion label, e.g. @AC1" });
80
+ continue;
81
+ }
82
+ if (labels.has(ac))
83
+ issues.push({ level: "error", message: `${ac} is used by more than one scenario` });
84
+ labels.add(ac);
85
+ if (!s.name.startsWith(`${ac.slice(1)} `)) {
86
+ issues.push({ level: "warning", message: `scenario "${s.name}" should start with its label`, hint: `e.g. "${ac.slice(1)} — ${s.name}"` });
87
+ }
88
+ if (sTags.filter((t) => PRIORITY.test(t)).length > 1) {
89
+ issues.push({ level: "error", message: `scenario "${s.name}" has more than one priority tag` });
90
+ }
91
+ // Given is optional (a scenario can start from nothing); When and Then are not.
92
+ const keywords = new Set(s.steps.map((st) => st.keyword.trim()));
93
+ for (const k of ["When", "Then"]) {
94
+ if (!keywords.has(k))
95
+ issues.push({ level: "error", message: `scenario "${s.name}" has no ${k} step` });
96
+ }
97
+ }
98
+ return issues;
99
+ }