spec-ctx 0.1.1 → 0.2.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 +44 -29
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +35 -9
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +50 -2
- package/dist/check/hints.js +7 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands.js +36 -12
- package/dist/cli/gate.js +51 -33
- package/dist/cli/help.js +12 -12
- package/dist/cli/status.js +5 -21
- package/dist/cli/workflow.js +50 -41
- package/dist/config.js +61 -1
- package/dist/graph/query.js +4 -1
- package/dist/handoff/handoff.js +38 -23
- package/dist/handoff/questions.js +99 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +13 -8
- package/dist/init/init.js +283 -35
- package/dist/lint/counts.js +60 -0
- package/dist/loop/fails.js +91 -0
- package/dist/loop/next.js +173 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +3 -1
- package/dist/tasks/evidence.js +5 -2
- 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,9 +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 },
|
|
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" },
|
|
19
24
|
};
|
|
20
25
|
}
|
|
21
26
|
export class ConfigError extends Error {
|
|
@@ -64,6 +69,12 @@ export function loadConfig(root) {
|
|
|
64
69
|
}
|
|
65
70
|
cfg.ids.paths = { ...v };
|
|
66
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
|
+
}
|
|
67
78
|
const bundle = sec("bundle");
|
|
68
79
|
for (const k of ["budget", "depth"]) {
|
|
69
80
|
if (k in bundle) {
|
|
@@ -81,6 +92,36 @@ export function loadConfig(root) {
|
|
|
81
92
|
throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
|
|
82
93
|
cfg.verify.timeout_s = v;
|
|
83
94
|
}
|
|
95
|
+
// REQ-DIST-002 the globs that gate lints
|
|
96
|
+
const gate = sec("gate");
|
|
97
|
+
if ("markdown" in gate)
|
|
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
|
+
}
|
|
84
125
|
return cfg;
|
|
85
126
|
}
|
|
86
127
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|
|
@@ -106,6 +147,9 @@ ignore = []
|
|
|
106
147
|
caps = []
|
|
107
148
|
# Pattern of the task number in a tasks.md checkbox.
|
|
108
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
|
|
109
153
|
|
|
110
154
|
[bundle]
|
|
111
155
|
# Default character budget of a bundle.
|
|
@@ -116,4 +160,20 @@ depth = 1
|
|
|
116
160
|
[verify]
|
|
117
161
|
# Seconds before task done stops a Verify command.
|
|
118
162
|
timeout_s = 600
|
|
163
|
+
|
|
164
|
+
[gate]
|
|
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"
|
|
119
179
|
`;
|
package/dist/graph/query.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
|
|
2
|
+
// With no term and a change filter, every entry of the change matches with score 0 (017/D6).
|
|
2
3
|
import { poolOf } from "../index/canonical.js";
|
|
3
4
|
import { matchesChange } from "../index/scan.js";
|
|
4
5
|
import { cmpTuple, pySplit } from "../util/pystr.js";
|
|
5
6
|
export function query(entries, args, o = {}) {
|
|
6
7
|
const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
|
|
8
|
+
// REQ-QRY-001 the index stores kinds in lower case. The user may type the kind as the ID shows it.
|
|
9
|
+
const kind = o.kind?.toLowerCase();
|
|
7
10
|
const hits = [];
|
|
8
11
|
for (const e of poolOf(entries, !!o.all)) {
|
|
9
|
-
if ((
|
|
12
|
+
if ((kind && e.kind !== kind) || (o.change && !matchesChange(e.change, o.change)))
|
|
10
13
|
continue;
|
|
11
14
|
// REQ-IDX-005 --change takes a key or a full folder name
|
|
12
15
|
const idl = e.id.toLowerCase();
|
package/dist/handoff/handoff.js
CHANGED
|
@@ -6,7 +6,8 @@ import { changeKey } from "../index/scan.js";
|
|
|
6
6
|
import { listEvidence } from "../tasks/evidence.js";
|
|
7
7
|
import { activeChanges, nextTask } from "../tasks/tasks.js";
|
|
8
8
|
import { cmpStr, splitLines } from "../util/pystr.js";
|
|
9
|
-
import { isoNow
|
|
9
|
+
import { isoNow } from "../util/time.js";
|
|
10
|
+
import { addQuestion, fromHandoffItems, hasQuestions, readQuestions, renderQuestions, resolveQuestion, writeQuestions } from "./questions.js";
|
|
10
11
|
export const HANDOFF_PATH = ".ctx/handoff.md";
|
|
11
12
|
export const CURRENT_PATH = ".ctx/current.json";
|
|
12
13
|
const SCALARS = ["change", "current", "base", "as_of"];
|
|
@@ -86,9 +87,24 @@ function evidenceRows(root, active) {
|
|
|
86
87
|
return rows;
|
|
87
88
|
}
|
|
88
89
|
const AT = / \(at (\S+)\)$/;
|
|
90
|
+
/**
|
|
91
|
+
* REQ-HND-002 move the questions of an old handoff.md into questions.json once (022/D8).
|
|
92
|
+
* Returns the count, 0 when the file exists or the handoff has no question.
|
|
93
|
+
*/
|
|
94
|
+
function migrateQuestions(root, prev) {
|
|
95
|
+
if (!prev || hasQuestions(root))
|
|
96
|
+
return 0;
|
|
97
|
+
if (!prev.open_questions.length && !prev.resolved.length)
|
|
98
|
+
return 0;
|
|
99
|
+
const qs = fromHandoffItems(prev.open_questions, prev.resolved);
|
|
100
|
+
writeQuestions(root, qs);
|
|
101
|
+
return qs.length;
|
|
102
|
+
}
|
|
89
103
|
/** Generate the handoff. Two runs with no change in between write the same bytes. */
|
|
90
104
|
export function writeHandoff(root, entries) {
|
|
91
105
|
const prev = readHandoff(root);
|
|
106
|
+
const migrated = migrateQuestions(root, prev);
|
|
107
|
+
const lists = renderQuestions(readQuestions(root));
|
|
92
108
|
const active = activeChanges(entries);
|
|
93
109
|
const next = nextTask(root, entries);
|
|
94
110
|
const rows = evidenceRows(root, active);
|
|
@@ -108,41 +124,40 @@ export function writeHandoff(root, entries) {
|
|
|
108
124
|
const m = AT.exec(item.split("\n")[0]);
|
|
109
125
|
return !m || after(m[1]);
|
|
110
126
|
}),
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
// REQ-HND-001 both lists come from questions.json, never from the previous file (022/D7)
|
|
128
|
+
open_questions: lists.open_questions,
|
|
129
|
+
resolved: lists.resolved,
|
|
113
130
|
next: next ? `${next.id} ${next.text}` : "no open task",
|
|
114
131
|
evidence: `${rows.length} task(s), ${rows.filter((r) => r.kind === "command").length} command, ${rows.filter((r) => r.kind === "manual").length} manual`,
|
|
115
132
|
};
|
|
116
133
|
save(root, h);
|
|
134
|
+
if (migrated)
|
|
135
|
+
h.migrated = migrated;
|
|
117
136
|
return h;
|
|
118
137
|
}
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
/** The handoff with its two question lists rendered again from questions.json. The other sections stay. */
|
|
139
|
+
function refresh(root, entries) {
|
|
140
|
+
const h = readHandoff(root) ?? writeHandoff(root, entries);
|
|
141
|
+
const lists = renderQuestions(readQuestions(root));
|
|
142
|
+
h.open_questions = lists.open_questions;
|
|
143
|
+
h.resolved = lists.resolved;
|
|
144
|
+
save(root, h);
|
|
145
|
+
return h;
|
|
121
146
|
}
|
|
147
|
+
/** REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6). */
|
|
122
148
|
export function ask(root, entries, text) {
|
|
123
|
-
const q =
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const h = ensure(root, entries);
|
|
127
|
-
h.open_questions.push(`${q} (task ${currentTask(root)}, ${today()})`);
|
|
128
|
-
save(root, h);
|
|
149
|
+
const q = addQuestion(root, currentTask(root), text);
|
|
150
|
+
const h = refresh(root, entries);
|
|
151
|
+
h.asked = q.n;
|
|
129
152
|
return h;
|
|
130
153
|
}
|
|
154
|
+
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
131
155
|
export function resolve(root, entries, n, answer) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new UsageExit(`no open question ${n}. The handoff has ${h.open_questions.length}`);
|
|
135
|
-
}
|
|
136
|
-
const a = answer.replace(/\s*\n\s*/g, " ").trim();
|
|
137
|
-
if (!a)
|
|
138
|
-
throw new UsageExit("handoff resolve needs an answer");
|
|
139
|
-
const [item] = h.open_questions.splice(n - 1, 1);
|
|
140
|
-
h.resolved.push(`${item.split("\n")[0]} answer: ${a}`);
|
|
141
|
-
save(root, h);
|
|
142
|
-
return h;
|
|
156
|
+
resolveQuestion(root, n, answer);
|
|
157
|
+
return refresh(root, entries);
|
|
143
158
|
}
|
|
144
159
|
export function addReopened(root, entries, task, reason) {
|
|
145
|
-
const h =
|
|
160
|
+
const h = readHandoff(root) ?? writeHandoff(root, entries);
|
|
146
161
|
h.reopened.push(`${task} ${reason.replace(/\s*\n\s*/g, " ").trim()} (at ${isoNow()})`);
|
|
147
162
|
save(root, h);
|
|
148
163
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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) {
|
|
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
|
+
qs.push(item);
|
|
44
|
+
writeQuestions(root, qs);
|
|
45
|
+
return item;
|
|
46
|
+
}
|
|
47
|
+
/** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
|
|
48
|
+
export function resolveQuestion(root, n, answer) {
|
|
49
|
+
const qs = readQuestions(root);
|
|
50
|
+
const item = qs.find((x) => x.n === n && x.status === "open");
|
|
51
|
+
if (!Number.isInteger(n) || !item) {
|
|
52
|
+
const open = qs.filter((x) => x.status === "open").length;
|
|
53
|
+
throw new UsageExit(`no open question ${n}. The file has ${open} open question(s)`);
|
|
54
|
+
}
|
|
55
|
+
const a = oneLine(answer);
|
|
56
|
+
if (!a)
|
|
57
|
+
throw new UsageExit("handoff resolve needs an answer");
|
|
58
|
+
item.status = "resolved";
|
|
59
|
+
item.answer = a;
|
|
60
|
+
item.resolved_at = isoNow();
|
|
61
|
+
writeQuestions(root, qs);
|
|
62
|
+
return item;
|
|
63
|
+
}
|
|
64
|
+
const ITEM = /^(.*) \(task (\S+), (\d{4}-\d{2}-\d{2})\)(?: answer: (.*))?$/s;
|
|
65
|
+
/**
|
|
66
|
+
* REQ-HND-002 the questions of an old handoff.md as objects (022/D8). Resolved items come first, then open ones,
|
|
67
|
+
* with n from 1 in that order. An item that does not match the old pattern keeps its whole text and task none.
|
|
68
|
+
*/
|
|
69
|
+
export function fromHandoffItems(open, resolved) {
|
|
70
|
+
const out = [];
|
|
71
|
+
const push = (item, status) => {
|
|
72
|
+
const m = ITEM.exec(item);
|
|
73
|
+
const q = m
|
|
74
|
+
? { n: out.length + 1, asked_at: m[3], task: m[2], text: oneLine(m[1]), status }
|
|
75
|
+
: { n: out.length + 1, asked_at: "none", task: "none", text: oneLine(item), status };
|
|
76
|
+
if (status === "resolved")
|
|
77
|
+
q.answer = m?.[4] !== undefined ? oneLine(m[4]) : "";
|
|
78
|
+
out.push(q);
|
|
79
|
+
};
|
|
80
|
+
for (const item of resolved)
|
|
81
|
+
push(item, "resolved");
|
|
82
|
+
for (const item of open)
|
|
83
|
+
push(item, "open");
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** The last 10 resolved entries render. The rest stay in the file. */
|
|
87
|
+
export const RESOLVED_SHOWN = 10;
|
|
88
|
+
/** REQ-HND-001 the two lists of handoff.md as rendered item text (022/D7). */
|
|
89
|
+
export function renderQuestions(qs) {
|
|
90
|
+
const date = (q) => q.asked_at.slice(0, 10);
|
|
91
|
+
const head = (q) => `Q${q.n} ${q.text} (task ${q.task}, ${date(q)})`;
|
|
92
|
+
const open = qs.filter((q) => q.status === "open").map(head);
|
|
93
|
+
const done = qs.filter((q) => q.status === "resolved");
|
|
94
|
+
const shown = done.slice(-RESOLVED_SHOWN).map((q) => `${head(q)} answer: ${q.answer ?? ""}`);
|
|
95
|
+
const more = done.length - Math.min(done.length, RESOLVED_SHOWN);
|
|
96
|
+
if (more > 0)
|
|
97
|
+
shown.push(`and ${more} more in ${QUESTIONS_PATH}`);
|
|
98
|
+
return { open_questions: open, resolved: shown };
|
|
99
|
+
}
|
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,10 +221,12 @@ 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.
|
|
228
|
+
if (status !== "archived")
|
|
229
|
+
problems.push([rpath, i, "missing-id", title]);
|
|
226
230
|
curReq = null;
|
|
227
231
|
curEntry = null;
|
|
228
232
|
continue;
|
|
@@ -243,10 +247,11 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
243
247
|
}
|
|
244
248
|
m = SCN_HDR.exec(line);
|
|
245
249
|
if (m) {
|
|
246
|
-
const sid = m[1];
|
|
247
|
-
const title = pyStrip(m[
|
|
250
|
+
const sid = m[1] ?? m[2];
|
|
251
|
+
const title = pyStrip(m[3]);
|
|
248
252
|
if (!sid) {
|
|
249
|
-
|
|
253
|
+
if (status !== "archived")
|
|
254
|
+
problems.push([rpath, i, "missing-id", title]);
|
|
250
255
|
continue;
|
|
251
256
|
}
|
|
252
257
|
const st = curEntry ? curEntry.status : status;
|