spec-ctx 0.1.2 → 0.3.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 +46 -30
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +6 -3
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +78 -2
- package/dist/check/evidence.js +26 -14
- package/dist/check/hints.js +11 -2
- package/dist/cli/args.js +2 -2
- package/dist/cli/commands.js +50 -9
- package/dist/cli/gate.js +33 -8
- package/dist/cli/help.js +10 -9
- package/dist/cli/status.js +11 -36
- package/dist/cli/workflow.js +47 -41
- package/dist/config.js +55 -4
- package/dist/handoff/handoff.js +57 -24
- package/dist/handoff/questions.js +101 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +8 -6
- package/dist/init/init.js +298 -36
- package/dist/loop/fails.js +102 -0
- package/dist/loop/next.js +193 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +9 -4
- package/dist/tasks/evidence.js +5 -2
- package/dist/verify/parse.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schemas/spec-driven-ids/README.md +12 -12
- package/schemas/spec-driven-ids/config-fragment.yaml +3 -2
- package/skills/specctx-bootstrap/SKILL.md +1 -22
- package/skills/specctx-done/SKILL.md +1 -20
- package/skills/specctx-next/SKILL.md +1 -18
- package/skills/specctx-run/SKILL.md +37 -0
- package/schemas/spec-driven-ids/schema.yaml +0 -301
package/dist/config.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parse } from "smol-toml";
|
|
5
|
+
/** REQ-DIST-002 the command that gate runs for openspec validate (018/D1). */
|
|
6
|
+
export const DEFAULT_OPENSPEC = "npx -y @fission-ai/openspec@latest";
|
|
5
7
|
export const CONFIG_FILE = "spec-ctx.toml";
|
|
6
8
|
export function defaults() {
|
|
7
9
|
return {
|
|
@@ -13,10 +15,12 @@ export function defaults() {
|
|
|
13
15
|
tests: ["tests/**"],
|
|
14
16
|
ignore: [],
|
|
15
17
|
},
|
|
16
|
-
ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
|
|
18
|
+
ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {}, brackets: false },
|
|
17
19
|
bundle: { budget: 8000, depth: 1 },
|
|
18
20
|
verify: { timeout_s: 600 },
|
|
19
|
-
gate
|
|
21
|
+
// REQ-DIST-002 the gate lints nothing unless the project opts in (022/D1, ADR-006 item 9)
|
|
22
|
+
gate: { markdown: [], openspec: DEFAULT_OPENSPEC, base: "origin/main" },
|
|
23
|
+
loop: { max_attempts: 3, archive: "ask" },
|
|
20
24
|
};
|
|
21
25
|
}
|
|
22
26
|
export class ConfigError extends Error {
|
|
@@ -65,6 +69,12 @@ export function loadConfig(root) {
|
|
|
65
69
|
}
|
|
66
70
|
cfg.ids.paths = { ...v };
|
|
67
71
|
}
|
|
72
|
+
// REQ-ADOPT-010 the header form that adopt and id new write (027/D2)
|
|
73
|
+
if ("brackets" in ids) {
|
|
74
|
+
if (typeof ids.brackets !== "boolean")
|
|
75
|
+
throw new ConfigError(`${CONFIG_FILE}: ids.brackets must be true or false.`);
|
|
76
|
+
cfg.ids.brackets = ids.brackets;
|
|
77
|
+
}
|
|
68
78
|
const bundle = sec("bundle");
|
|
69
79
|
for (const k of ["budget", "depth"]) {
|
|
70
80
|
if (k in bundle) {
|
|
@@ -86,6 +96,32 @@ export function loadConfig(root) {
|
|
|
86
96
|
const gate = sec("gate");
|
|
87
97
|
if ("markdown" in gate)
|
|
88
98
|
cfg.gate.markdown = strList(gate.markdown, "gate.markdown");
|
|
99
|
+
// REQ-DIST-002 the OpenSpec command. An empty string would run " validate", so it is an error too (018/D1).
|
|
100
|
+
if ("openspec" in gate) {
|
|
101
|
+
if (typeof gate.openspec !== "string" || !gate.openspec.trim())
|
|
102
|
+
throw new ConfigError(`${CONFIG_FILE}: gate.openspec must be a string that is not empty.`);
|
|
103
|
+
cfg.gate.openspec = gate.openspec;
|
|
104
|
+
}
|
|
105
|
+
// REQ-ADOPT-008 the ref whose merge base with HEAD holds the baseline that gate compares with (020/D5)
|
|
106
|
+
if ("base" in gate) {
|
|
107
|
+
if (typeof gate.base !== "string" || !gate.base.trim())
|
|
108
|
+
throw new ConfigError(`${CONFIG_FILE}: gate.base must be a string that is not empty.`);
|
|
109
|
+
cfg.gate.base = gate.base;
|
|
110
|
+
}
|
|
111
|
+
// REQ-LOOP-003 the loop keys (024/D6)
|
|
112
|
+
const loop = sec("loop");
|
|
113
|
+
if ("max_attempts" in loop) {
|
|
114
|
+
const v = loop.max_attempts;
|
|
115
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1)
|
|
116
|
+
throw new ConfigError(`${CONFIG_FILE}: loop.max_attempts must be an integer of 1 or more.`);
|
|
117
|
+
cfg.loop.max_attempts = v;
|
|
118
|
+
}
|
|
119
|
+
if ("archive" in loop) {
|
|
120
|
+
const v = loop.archive;
|
|
121
|
+
if (v !== "ask" && v !== "auto")
|
|
122
|
+
throw new ConfigError(`${CONFIG_FILE}: loop.archive must be "ask" or "auto".`);
|
|
123
|
+
cfg.loop.archive = v;
|
|
124
|
+
}
|
|
89
125
|
return cfg;
|
|
90
126
|
}
|
|
91
127
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|
|
@@ -111,6 +147,9 @@ ignore = []
|
|
|
111
147
|
caps = []
|
|
112
148
|
# Pattern of the task number in a tasks.md checkbox.
|
|
113
149
|
task_pattern = '\\d+\\.\\d+'
|
|
150
|
+
# true makes adopt and id new write the ID in square brackets, [REQ-UA-001], as OpenSpec PR #1979 reads it.
|
|
151
|
+
# false writes the bare form, REQ-UA-001. The scan reads both forms.
|
|
152
|
+
brackets = false
|
|
114
153
|
|
|
115
154
|
[bundle]
|
|
116
155
|
# Default character budget of a bundle.
|
|
@@ -123,6 +162,18 @@ depth = 1
|
|
|
123
162
|
timeout_s = 600
|
|
124
163
|
|
|
125
164
|
[gate]
|
|
126
|
-
# Markdown globs that gate lints
|
|
127
|
-
markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]
|
|
165
|
+
# Markdown globs that gate lints with the tells and STE linters. Empty means no lint.
|
|
166
|
+
# To turn it on, set markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]. The spec-ctx repository opts in.
|
|
167
|
+
markdown = []
|
|
168
|
+
# The command that gate runs for openspec validate. Set it to "openspec" for a global install or a fork,
|
|
169
|
+
# or pin a version. The env var SPECCTX_OPENSPEC overrides it for one run.
|
|
170
|
+
openspec = "npx -y @fission-ai/openspec@latest"
|
|
171
|
+
# The ref whose merge base with HEAD holds the baseline that gate compares with. New entries fail the gate.
|
|
172
|
+
base = "origin/main"
|
|
173
|
+
|
|
174
|
+
[loop]
|
|
175
|
+
# Verify failures on one task before task done writes a question and the loop stops.
|
|
176
|
+
max_attempts = 3
|
|
177
|
+
# "ask" stops before archive for a human. "auto" lets the loop archive when the gate passes.
|
|
178
|
+
archive = "ask"
|
|
128
179
|
`;
|
package/dist/handoff/handoff.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { UsageExit } from "../cli/errors.js";
|
|
5
|
-
import { changeKey } from "../index/scan.js";
|
|
5
|
+
import { changeKey, matchesChange } from "../index/scan.js";
|
|
6
|
+
import { activeFolders } from "../loop/next.js";
|
|
6
7
|
import { listEvidence } from "../tasks/evidence.js";
|
|
7
8
|
import { activeChanges, nextTask } from "../tasks/tasks.js";
|
|
8
9
|
import { cmpStr, splitLines } from "../util/pystr.js";
|
|
9
|
-
import { isoNow
|
|
10
|
+
import { isoNow } from "../util/time.js";
|
|
11
|
+
import { addQuestion, fromHandoffItems, hasQuestions, readQuestions, renderQuestions, resolveQuestion, writeQuestions } from "./questions.js";
|
|
10
12
|
export const HANDOFF_PATH = ".ctx/handoff.md";
|
|
11
13
|
export const CURRENT_PATH = ".ctx/current.json";
|
|
12
14
|
const SCALARS = ["change", "current", "base", "as_of"];
|
|
@@ -86,9 +88,24 @@ function evidenceRows(root, active) {
|
|
|
86
88
|
return rows;
|
|
87
89
|
}
|
|
88
90
|
const AT = / \(at (\S+)\)$/;
|
|
91
|
+
/**
|
|
92
|
+
* REQ-HND-002 move the questions of an old handoff.md into questions.json once (022/D8).
|
|
93
|
+
* Returns the count, 0 when the file exists or the handoff has no question.
|
|
94
|
+
*/
|
|
95
|
+
function migrateQuestions(root, prev) {
|
|
96
|
+
if (!prev || hasQuestions(root))
|
|
97
|
+
return 0;
|
|
98
|
+
if (!prev.open_questions.length && !prev.resolved.length)
|
|
99
|
+
return 0;
|
|
100
|
+
const qs = fromHandoffItems(prev.open_questions, prev.resolved);
|
|
101
|
+
writeQuestions(root, qs);
|
|
102
|
+
return qs.length;
|
|
103
|
+
}
|
|
89
104
|
/** Generate the handoff. Two runs with no change in between write the same bytes. */
|
|
90
105
|
export function writeHandoff(root, entries) {
|
|
91
106
|
const prev = readHandoff(root);
|
|
107
|
+
const migrated = migrateQuestions(root, prev);
|
|
108
|
+
const lists = renderQuestions(readQuestions(root));
|
|
92
109
|
const active = activeChanges(entries);
|
|
93
110
|
const next = nextTask(root, entries);
|
|
94
111
|
const rows = evidenceRows(root, active);
|
|
@@ -108,41 +125,57 @@ export function writeHandoff(root, entries) {
|
|
|
108
125
|
const m = AT.exec(item.split("\n")[0]);
|
|
109
126
|
return !m || after(m[1]);
|
|
110
127
|
}),
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
// REQ-HND-001 both lists come from questions.json, never from the previous file (022/D7)
|
|
129
|
+
open_questions: lists.open_questions,
|
|
130
|
+
resolved: lists.resolved,
|
|
113
131
|
next: next ? `${next.id} ${next.text}` : "no open task",
|
|
114
132
|
evidence: `${rows.length} task(s), ${rows.filter((r) => r.kind === "command").length} command, ${rows.filter((r) => r.kind === "manual").length} manual`,
|
|
115
133
|
};
|
|
116
134
|
save(root, h);
|
|
135
|
+
if (migrated)
|
|
136
|
+
h.migrated = migrated;
|
|
117
137
|
return h;
|
|
118
138
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
throw new UsageExit("handoff ask needs a question");
|
|
126
|
-
const h = ensure(root, entries);
|
|
127
|
-
h.open_questions.push(`${q} (task ${currentTask(root)}, ${today()})`);
|
|
139
|
+
/** The handoff with its two question lists rendered again from questions.json. The other sections stay. */
|
|
140
|
+
function refresh(root, entries) {
|
|
141
|
+
const h = readHandoff(root) ?? writeHandoff(root, entries);
|
|
142
|
+
const lists = renderQuestions(readQuestions(root));
|
|
143
|
+
h.open_questions = lists.open_questions;
|
|
144
|
+
h.resolved = lists.resolved;
|
|
128
145
|
save(root, h);
|
|
129
146
|
return h;
|
|
130
147
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
/**
|
|
149
|
+
* REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6).
|
|
150
|
+
* REQ-LOOP-005 with a change, the question carries the active change folder, so next blocks the change (029/D1).
|
|
151
|
+
*/
|
|
152
|
+
export function ask(root, entries, text, change) {
|
|
153
|
+
let task = currentTask(root);
|
|
154
|
+
let stage;
|
|
155
|
+
if (change !== undefined) {
|
|
156
|
+
const folder = activeFolders(root).find((f) => matchesChange(f, change));
|
|
157
|
+
if (folder === undefined)
|
|
158
|
+
throw new UsageExit(`no active change ${change}`);
|
|
159
|
+
task = folder;
|
|
160
|
+
stage = stageOf(entries, folder);
|
|
135
161
|
}
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const [item] = h.open_questions.splice(n - 1, 1);
|
|
140
|
-
h.resolved.push(`${item.split("\n")[0]} answer: ${a}`);
|
|
141
|
-
save(root, h);
|
|
162
|
+
const q = addQuestion(root, task, text, stage);
|
|
163
|
+
const h = refresh(root, entries);
|
|
164
|
+
h.asked = q.n;
|
|
142
165
|
return h;
|
|
143
166
|
}
|
|
167
|
+
/** REQ-LOOP-007 archive when the change has a task and every task is [x], plan otherwise. The text plays no part (031/D1). */
|
|
168
|
+
export function stageOf(entries, folder) {
|
|
169
|
+
const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
|
|
170
|
+
return tasks.length > 0 && tasks.every((t) => t.status === "done") ? "archive" : "plan";
|
|
171
|
+
}
|
|
172
|
+
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
173
|
+
export function resolve(root, entries, n, answer) {
|
|
174
|
+
resolveQuestion(root, n, answer);
|
|
175
|
+
return refresh(root, entries);
|
|
176
|
+
}
|
|
144
177
|
export function addReopened(root, entries, task, reason) {
|
|
145
|
-
const h =
|
|
178
|
+
const h = readHandoff(root) ?? writeHandoff(root, entries);
|
|
146
179
|
h.reopened.push(`${task} ${reason.replace(/\s*\n\s*/g, " ").trim()} (at ${isoNow()})`);
|
|
147
180
|
save(root, h);
|
|
148
181
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// REQ-HND-002 the open and resolved questions in .ctx/questions.json (022/D6, ADR-006 item 1).
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UsageExit } from "../cli/errors.js";
|
|
5
|
+
import { isoNow } from "../util/time.js";
|
|
6
|
+
export const QUESTIONS_PATH = ".ctx/questions.json";
|
|
7
|
+
/** The questions sorted by n. An absent file is an empty list. A damaged file is a usage error, not a crash. */
|
|
8
|
+
export function readQuestions(root) {
|
|
9
|
+
const p = join(root, QUESTIONS_PATH);
|
|
10
|
+
if (!existsSync(p))
|
|
11
|
+
return [];
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(readFileSync(p, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new UsageExit(`${QUESTIONS_PATH} is not valid JSON`);
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(raw))
|
|
20
|
+
throw new UsageExit(`${QUESTIONS_PATH} must hold a JSON list`);
|
|
21
|
+
return raw.slice().sort((a, b) => a.n - b.n);
|
|
22
|
+
}
|
|
23
|
+
export function hasQuestions(root) {
|
|
24
|
+
return existsSync(join(root, QUESTIONS_PATH));
|
|
25
|
+
}
|
|
26
|
+
export function writeQuestions(root, qs) {
|
|
27
|
+
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
28
|
+
const sorted = qs.slice().sort((a, b) => a.n - b.n);
|
|
29
|
+
writeFileSync(join(root, QUESTIONS_PATH), JSON.stringify(sorted, null, 2) + "\n");
|
|
30
|
+
}
|
|
31
|
+
/** One line of text. A question or an answer never spans lines in the file. */
|
|
32
|
+
export function oneLine(text) {
|
|
33
|
+
return text.replace(/\s*\n\s*/g, " ").trim();
|
|
34
|
+
}
|
|
35
|
+
/** REQ-HND-002 append an open question with the next n. Returns the new question. */
|
|
36
|
+
export function addQuestion(root, task, text, stage) {
|
|
37
|
+
const q = oneLine(text);
|
|
38
|
+
if (!q)
|
|
39
|
+
throw new UsageExit("handoff ask needs a question");
|
|
40
|
+
const qs = readQuestions(root);
|
|
41
|
+
const n = qs.reduce((m, x) => Math.max(m, x.n), 0) + 1;
|
|
42
|
+
const item = { n, asked_at: isoNow(), task, text: q, status: "open" };
|
|
43
|
+
if (stage !== undefined)
|
|
44
|
+
item.stage = stage;
|
|
45
|
+
qs.push(item);
|
|
46
|
+
writeQuestions(root, qs);
|
|
47
|
+
return item;
|
|
48
|
+
}
|
|
49
|
+
/** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
|
|
50
|
+
export function resolveQuestion(root, n, answer) {
|
|
51
|
+
const qs = readQuestions(root);
|
|
52
|
+
const item = qs.find((x) => x.n === n && x.status === "open");
|
|
53
|
+
if (!Number.isInteger(n) || !item) {
|
|
54
|
+
const open = qs.filter((x) => x.status === "open").length;
|
|
55
|
+
throw new UsageExit(`no open question ${n}. The file has ${open} open question(s)`);
|
|
56
|
+
}
|
|
57
|
+
const a = oneLine(answer);
|
|
58
|
+
if (!a)
|
|
59
|
+
throw new UsageExit("handoff resolve needs an answer");
|
|
60
|
+
item.status = "resolved";
|
|
61
|
+
item.answer = a;
|
|
62
|
+
item.resolved_at = isoNow();
|
|
63
|
+
writeQuestions(root, qs);
|
|
64
|
+
return item;
|
|
65
|
+
}
|
|
66
|
+
const ITEM = /^(.*) \(task (\S+), (\d{4}-\d{2}-\d{2})\)(?: answer: (.*))?$/s;
|
|
67
|
+
/**
|
|
68
|
+
* REQ-HND-002 the questions of an old handoff.md as objects (022/D8). Resolved items come first, then open ones,
|
|
69
|
+
* with n from 1 in that order. An item that does not match the old pattern keeps its whole text and task none.
|
|
70
|
+
*/
|
|
71
|
+
export function fromHandoffItems(open, resolved) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const push = (item, status) => {
|
|
74
|
+
const m = ITEM.exec(item);
|
|
75
|
+
const q = m
|
|
76
|
+
? { n: out.length + 1, asked_at: m[3], task: m[2], text: oneLine(m[1]), status }
|
|
77
|
+
: { n: out.length + 1, asked_at: "none", task: "none", text: oneLine(item), status };
|
|
78
|
+
if (status === "resolved")
|
|
79
|
+
q.answer = m?.[4] !== undefined ? oneLine(m[4]) : "";
|
|
80
|
+
out.push(q);
|
|
81
|
+
};
|
|
82
|
+
for (const item of resolved)
|
|
83
|
+
push(item, "resolved");
|
|
84
|
+
for (const item of open)
|
|
85
|
+
push(item, "open");
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** The last 10 resolved entries render. The rest stay in the file. */
|
|
89
|
+
export const RESOLVED_SHOWN = 10;
|
|
90
|
+
/** REQ-HND-001 the two lists of handoff.md as rendered item text (022/D7). */
|
|
91
|
+
export function renderQuestions(qs) {
|
|
92
|
+
const date = (q) => q.asked_at.slice(0, 10);
|
|
93
|
+
const head = (q) => `Q${q.n} ${q.text} (task ${q.task}, ${date(q)})`;
|
|
94
|
+
const open = qs.filter((q) => q.status === "open").map(head);
|
|
95
|
+
const done = qs.filter((q) => q.status === "resolved");
|
|
96
|
+
const shown = done.slice(-RESOLVED_SHOWN).map((q) => `${head(q)} answer: ${q.answer ?? ""}`);
|
|
97
|
+
const more = done.length - Math.min(done.length, RESOLVED_SHOWN);
|
|
98
|
+
if (more > 0)
|
|
99
|
+
shown.push(`and ${more} more in ${QUESTIONS_PATH}`);
|
|
100
|
+
return { open_questions: open, resolved: shown };
|
|
101
|
+
}
|
package/dist/ids/ids.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// REQ-IDS-001 REQ-IDS-002 REQ-IDS-003 ID allocation and retirement.
|
|
1
|
+
// REQ-IDS-001 REQ-IDS-002 REQ-IDS-003 REQ-IDS-004 ID allocation and retirement.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { UsageExit } from "../cli/errors.js";
|
|
@@ -48,23 +48,28 @@ export function newIds(root, cfg, entries, o) {
|
|
|
48
48
|
return cap;
|
|
49
49
|
};
|
|
50
50
|
const seq = (start, fmt) => Array.from({ length: o.count }, (_, i) => fmt(start + i));
|
|
51
|
+
// REQ-IDS-004 a REQ or SCN ID goes into a header, so it takes the configured form (027/D5)
|
|
52
|
+
const form = (ids) => (cfg.ids.brackets ? ids.map((id) => `[${id}]`) : ids);
|
|
51
53
|
switch (kind) {
|
|
52
54
|
case "REQ": {
|
|
53
55
|
const cap = checkCap(o.cap);
|
|
54
56
|
const start = next(new RegExp(`^REQ-${cap}-(\\d{3})$`));
|
|
55
57
|
if (start + o.count - 1 > 999)
|
|
56
58
|
throw new UsageExit(`no free REQ number for ${cap}`);
|
|
57
|
-
return seq(start, (n) => `REQ-${cap}-${pad(n)}`);
|
|
59
|
+
return form(seq(start, (n) => `REQ-${cap}-${pad(n)}`));
|
|
58
60
|
}
|
|
59
61
|
case "SCN": {
|
|
60
62
|
const cap = checkCap(o.cap);
|
|
61
63
|
if (!o.forReq)
|
|
62
64
|
throw new UsageExit("id new SCN needs --for <REQ-ID>");
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
// REQ-IDS-004 --for takes the bare or the bracketed form
|
|
66
|
+
const m = /^(?:REQ-([A-Z]{2,6})-(\d{3})|\[REQ-([A-Z]{2,6})-(\d{3})\])$/.exec(o.forReq);
|
|
67
|
+
const forCap = m?.[1] ?? m?.[3];
|
|
68
|
+
const forNum = m?.[2] ?? m?.[4];
|
|
69
|
+
if (!m || forCap !== cap)
|
|
65
70
|
throw new UsageExit(`--for ${o.forReq} is not a REQ ID of ${cap}`);
|
|
66
|
-
const start = next(new RegExp(`^SCN-${cap}-${
|
|
67
|
-
return seq(start, (n) => `SCN-${cap}-${
|
|
71
|
+
const start = next(new RegExp(`^SCN-${cap}-${forNum}-(\\d+)$`));
|
|
72
|
+
return form(seq(start, (n) => `SCN-${cap}-${forNum}-${n}`));
|
|
68
73
|
}
|
|
69
74
|
case "ADR": {
|
|
70
75
|
const start = next(/^ADR-(\d{3})$/);
|
package/dist/index/scan.js
CHANGED
|
@@ -8,8 +8,10 @@ const S = "[ \\t\\n\\r\\f\\v]";
|
|
|
8
8
|
export const ID_RE = /\b(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})\b/g;
|
|
9
9
|
const ID_FULL = /^(?:REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})$/;
|
|
10
10
|
const SCN_RE = /\bSCN-[A-Z]{2,6}-\d{3}-\d+\b/g;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// REQ-IDX-006 the ID is bare or in square brackets, the form OpenSpec reads since PR #1979 (027/D1).
|
|
12
|
+
// Group 1 is the bare ID, group 2 the bracketed ID, group 3 the title.
|
|
13
|
+
const REQ_HDR = new RegExp(`^### Requirement:${S}*(?:(REQ-[A-Z]{2,6}-\\d{3})|\\[(REQ-[A-Z]{2,6}-\\d{3})\\](?=${S}|$))?${S}*(.*)$`, "s");
|
|
14
|
+
const SCN_HDR = new RegExp(`^#### Scenario:${S}*(?:(SCN-[A-Z]{2,6}-\\d{3}-\\d+)|\\[(SCN-[A-Z]{2,6}-\\d{3}-\\d+)\\](?=${S}|$))?${S}*(.*)$`, "s");
|
|
13
15
|
const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
|
|
14
16
|
const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
|
|
15
17
|
const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
|
|
@@ -219,8 +221,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
219
221
|
}
|
|
220
222
|
let m = REQ_HDR.exec(line);
|
|
221
223
|
if (m) {
|
|
222
|
-
const rid = m[1];
|
|
223
|
-
const title = pyStrip(m[
|
|
224
|
+
const rid = m[1] ?? m[2];
|
|
225
|
+
const title = pyStrip(m[3]);
|
|
224
226
|
if (!rid) {
|
|
225
227
|
// REQ-CHK-001 an archived header without an ID is history. adopt never writes it.
|
|
226
228
|
if (status !== "archived")
|
|
@@ -245,8 +247,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
245
247
|
}
|
|
246
248
|
m = SCN_HDR.exec(line);
|
|
247
249
|
if (m) {
|
|
248
|
-
const sid = m[1];
|
|
249
|
-
const title = pyStrip(m[
|
|
250
|
+
const sid = m[1] ?? m[2];
|
|
251
|
+
const title = pyStrip(m[3]);
|
|
250
252
|
if (!sid) {
|
|
251
253
|
if (status !== "archived")
|
|
252
254
|
problems.push([rpath, i, "missing-id", title]);
|