openspecpm 0.1.0-alpha.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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +352 -0
- package/cli/bin/openspecpm.js +198 -0
- package/cli/src/adapters/azure.js +230 -0
- package/cli/src/adapters/base.js +86 -0
- package/cli/src/adapters/github.js +224 -0
- package/cli/src/adapters/gitlab.js +170 -0
- package/cli/src/adapters/index.js +54 -0
- package/cli/src/adapters/jira.js +228 -0
- package/cli/src/adapters/linear.js +261 -0
- package/cli/src/audit.js +78 -0
- package/cli/src/bdd/linter.js +183 -0
- package/cli/src/bdd/templates.js +172 -0
- package/cli/src/commands/assign.js +78 -0
- package/cli/src/commands/blocked.js +17 -0
- package/cli/src/commands/bug-report.js +78 -0
- package/cli/src/commands/bulk.js +67 -0
- package/cli/src/commands/comment.js +83 -0
- package/cli/src/commands/decompose.js +106 -0
- package/cli/src/commands/doctor.js +61 -0
- package/cli/src/commands/fan-out.js +79 -0
- package/cli/src/commands/help.js +69 -0
- package/cli/src/commands/init.js +111 -0
- package/cli/src/commands/next.js +18 -0
- package/cli/src/commands/propose.js +67 -0
- package/cli/src/commands/reconcile.js +74 -0
- package/cli/src/commands/search.js +52 -0
- package/cli/src/commands/ship.js +79 -0
- package/cli/src/commands/standup.js +42 -0
- package/cli/src/commands/status.js +29 -0
- package/cli/src/commands/sync.js +128 -0
- package/cli/src/commands/validate.js +79 -0
- package/cli/src/commands/watch.js +67 -0
- package/cli/src/config.js +43 -0
- package/cli/src/frontmatter.js +22 -0
- package/cli/src/http.js +92 -0
- package/cli/src/install-hints.js +44 -0
- package/cli/src/notify.js +56 -0
- package/cli/src/openspec-bridge.js +64 -0
- package/cli/src/ratelimit.js +50 -0
- package/cli/src/telemetry.js +45 -0
- package/cli/src/tracking.js +197 -0
- package/package.json +60 -0
- package/skill/openspecpm/SKILL.md +74 -0
- package/skill/openspecpm/references/conventions.md +105 -0
- package/skill/openspecpm/references/execute.md +62 -0
- package/skill/openspecpm/references/plan.md +47 -0
- package/skill/openspecpm/references/structure.md +52 -0
- package/skill/openspecpm/references/sync.md +56 -0
- package/skill/openspecpm/references/track.md +55 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Adapter, AdapterError } from './base.js';
|
|
2
|
+
import { TokenBucket } from '../ratelimit.js';
|
|
3
|
+
|
|
4
|
+
const ENDPOINT = 'https://api.linear.app/graphql';
|
|
5
|
+
|
|
6
|
+
const STATE_TO_NORMALIZED = (typeName) => {
|
|
7
|
+
const v = (typeName ?? '').toLowerCase();
|
|
8
|
+
if (v === 'completed' || v === 'canceled') return 'closed';
|
|
9
|
+
if (v === 'started') return 'in_progress';
|
|
10
|
+
if (v === 'unstarted' || v === 'backlog' || v === 'triage') return 'open';
|
|
11
|
+
return 'open';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class LinearAdapter extends Adapter {
|
|
15
|
+
#fetch;
|
|
16
|
+
#bucket;
|
|
17
|
+
|
|
18
|
+
constructor(config = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
19
|
+
super(config);
|
|
20
|
+
this.#fetch = fetchImpl;
|
|
21
|
+
// Linear publishes 1500 req/hour soft limit on personal API keys.
|
|
22
|
+
this.#bucket = new TokenBucket({ capacity: 30, refillPerSec: 0.4 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get name() {
|
|
26
|
+
return 'linear';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
capabilities() {
|
|
30
|
+
return {
|
|
31
|
+
hierarchyDepth: 2,
|
|
32
|
+
supportsSubIssues: true,
|
|
33
|
+
supportsSprints: true, // Cycles
|
|
34
|
+
supportsLabels: true,
|
|
35
|
+
fieldMap: { epic: 'Project', task: 'Issue', sprint: 'Cycle' },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#apiKey() {
|
|
40
|
+
const key = process.env.LINEAR_API_KEY;
|
|
41
|
+
if (!key) {
|
|
42
|
+
throw new AdapterError('LINEAR_API_KEY not set.', {
|
|
43
|
+
remediation: 'Create a Personal API Key at linear.app/settings/api and export LINEAR_API_KEY.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return key;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#teamId() {
|
|
50
|
+
const id = this.config.teamId;
|
|
51
|
+
if (!id) {
|
|
52
|
+
throw new AdapterError('Linear adapter requires config.teamId.', {
|
|
53
|
+
remediation: 'Re-run `openspecpm init`; team ID is shown in your Linear team settings URL.',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async #gql(query, variables = {}) {
|
|
60
|
+
await this.#bucket.take();
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await this.#fetch(ENDPOINT, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: this.#apiKey(),
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ query, variables }),
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new AdapterError(`Linear network error: ${err.message}`, {
|
|
73
|
+
remediation: 'Check connectivity and LINEAR_API_KEY scope.',
|
|
74
|
+
cause: err,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
let payload;
|
|
79
|
+
try { payload = JSON.parse(text); } catch { payload = { raw: text }; }
|
|
80
|
+
if (!res.ok || payload.errors) {
|
|
81
|
+
const msg = payload.errors?.map((e) => e.message).join('; ') || `${res.status} ${res.statusText}`;
|
|
82
|
+
throw new AdapterError(`Linear GraphQL error: ${msg}`, {
|
|
83
|
+
remediation: res.status === 401 || res.status === 403
|
|
84
|
+
? 'Verify LINEAR_API_KEY has full scope (read+write).'
|
|
85
|
+
: 'Inspect the GraphQL error and adjust the request.',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return payload.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async doctor() {
|
|
92
|
+
if (!process.env.LINEAR_API_KEY) {
|
|
93
|
+
return [{ ok: false, msg: 'LINEAR_API_KEY not set.', remediation: 'Create a key at linear.app/settings/api.' }];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const data = await this.#gql(`query { viewer { id name } }`);
|
|
97
|
+
return [{ ok: true, msg: `Authenticated as ${data.viewer?.name ?? data.viewer?.id}` }];
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return [{ ok: false, msg: `Auth failed: ${err.message}`, remediation: err.remediation }];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async init() {
|
|
104
|
+
this.#apiKey();
|
|
105
|
+
this.#teamId();
|
|
106
|
+
return this.capabilities();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async createEpic(feature) {
|
|
110
|
+
// Linear models "epic" as a Project.
|
|
111
|
+
const data = await this.#gql(
|
|
112
|
+
`mutation($input: ProjectCreateInput!) {
|
|
113
|
+
projectCreate(input: $input) {
|
|
114
|
+
success
|
|
115
|
+
project { id name url }
|
|
116
|
+
}
|
|
117
|
+
}`,
|
|
118
|
+
{
|
|
119
|
+
input: {
|
|
120
|
+
name: feature.name,
|
|
121
|
+
description: feature.summary ?? '',
|
|
122
|
+
teamIds: [this.#teamId()],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
if (!data.projectCreate?.success) {
|
|
127
|
+
throw new AdapterError('Linear projectCreate returned success=false.');
|
|
128
|
+
}
|
|
129
|
+
const p = data.projectCreate.project;
|
|
130
|
+
return { adapter: 'linear', id: p.id, url: p.url };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async createWorkItem(epic, task /*, opts */) {
|
|
134
|
+
const data = await this.#gql(
|
|
135
|
+
`mutation($input: IssueCreateInput!) {
|
|
136
|
+
issueCreate(input: $input) {
|
|
137
|
+
success
|
|
138
|
+
issue { id identifier url }
|
|
139
|
+
}
|
|
140
|
+
}`,
|
|
141
|
+
{
|
|
142
|
+
input: {
|
|
143
|
+
teamId: this.#teamId(),
|
|
144
|
+
title: task.title,
|
|
145
|
+
description: task.body ?? '',
|
|
146
|
+
projectId: epic?.id,
|
|
147
|
+
labelIds: this.config.openspecLabelId ? [this.config.openspecLabelId] : undefined,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
if (!data.issueCreate?.success) {
|
|
152
|
+
throw new AdapterError('Linear issueCreate returned success=false.');
|
|
153
|
+
}
|
|
154
|
+
const i = data.issueCreate.issue;
|
|
155
|
+
return { adapter: 'linear', id: i.identifier, url: i.url };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async linkWorkItems(parent, child /*, type */) {
|
|
159
|
+
// Linear supports parent/child via Issue.parent. Sub-issue mapping.
|
|
160
|
+
await this.#gql(
|
|
161
|
+
`mutation($id: String!, $parentId: String!) {
|
|
162
|
+
issueUpdate(id: $id, input: { parentId: $parentId }) {
|
|
163
|
+
success
|
|
164
|
+
}
|
|
165
|
+
}`,
|
|
166
|
+
{ id: child.id, parentId: parent.id },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async addProgressComment(item, body) {
|
|
171
|
+
await this.#gql(
|
|
172
|
+
`mutation($input: CommentCreateInput!) {
|
|
173
|
+
commentCreate(input: $input) { success }
|
|
174
|
+
}`,
|
|
175
|
+
{ input: { issueId: item.id, body } },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async updateWorkItem(item, patch) {
|
|
180
|
+
const input = {};
|
|
181
|
+
if (patch.title) input.title = patch.title;
|
|
182
|
+
if (patch.description) input.description = patch.description;
|
|
183
|
+
if (patch.assignee) input.assigneeId = patch.assignee;
|
|
184
|
+
if (patch.stateId) input.stateId = patch.stateId;
|
|
185
|
+
if (patch.estimate !== undefined) input.estimate = patch.estimate; // story points
|
|
186
|
+
if (patch.cycleId) input.cycleId = patch.cycleId; // sprint
|
|
187
|
+
if (!Object.keys(input).length) return;
|
|
188
|
+
await this.#gql(
|
|
189
|
+
`mutation($id: String!, $input: IssueUpdateInput!) {
|
|
190
|
+
issueUpdate(id: $id, input: $input) { success }
|
|
191
|
+
}`,
|
|
192
|
+
{ id: item.id, input },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async closeWorkItem(item, resolution) {
|
|
197
|
+
// Find a "Done"-like workflow state for the team.
|
|
198
|
+
const data = await this.#gql(
|
|
199
|
+
`query($teamId: String!) {
|
|
200
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
201
|
+
nodes { id name type }
|
|
202
|
+
}
|
|
203
|
+
}`,
|
|
204
|
+
{ teamId: this.#teamId() },
|
|
205
|
+
);
|
|
206
|
+
const done = (data.workflowStates?.nodes ?? []).find((s) => s.type === 'completed');
|
|
207
|
+
if (!done) {
|
|
208
|
+
throw new AdapterError('No "completed" workflow state found for the team.', {
|
|
209
|
+
remediation: 'Add a Done state in Linear team settings.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
await this.updateWorkItem(item, { stateId: done.id });
|
|
213
|
+
if (resolution) await this.addProgressComment(item, resolution);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async getWorkItem(item) {
|
|
217
|
+
const data = await this.#gql(
|
|
218
|
+
`query($id: String!) {
|
|
219
|
+
issue(id: $id) {
|
|
220
|
+
id identifier url title
|
|
221
|
+
state { type }
|
|
222
|
+
labels { nodes { name } }
|
|
223
|
+
assignee { id name email }
|
|
224
|
+
}
|
|
225
|
+
}`,
|
|
226
|
+
{ id: item.id },
|
|
227
|
+
);
|
|
228
|
+
const i = data.issue;
|
|
229
|
+
if (!i) throw new AdapterError(`Linear issue ${item.id} not found.`);
|
|
230
|
+
return {
|
|
231
|
+
ref: { adapter: 'linear', id: i.identifier, url: i.url },
|
|
232
|
+
title: i.title,
|
|
233
|
+
status: STATE_TO_NORMALIZED(i.state?.type),
|
|
234
|
+
labels: (i.labels?.nodes ?? []).map((l) => l.name),
|
|
235
|
+
assignee: i.assignee?.email ?? i.assignee?.name ?? null,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async listWorkItems(query = {}) {
|
|
240
|
+
const data = await this.#gql(
|
|
241
|
+
`query($teamId: String!, $first: Int!) {
|
|
242
|
+
issues(filter: { team: { id: { eq: $teamId } } }, first: $first) {
|
|
243
|
+
nodes {
|
|
244
|
+
identifier url title
|
|
245
|
+
state { type }
|
|
246
|
+
labels { nodes { name } }
|
|
247
|
+
assignee { email name }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}`,
|
|
251
|
+
{ teamId: this.#teamId(), first: query.limit ?? 50 },
|
|
252
|
+
);
|
|
253
|
+
return (data.issues?.nodes ?? []).map((i) => ({
|
|
254
|
+
ref: { adapter: 'linear', id: i.identifier, url: i.url },
|
|
255
|
+
title: i.title,
|
|
256
|
+
status: STATE_TO_NORMALIZED(i.state?.type),
|
|
257
|
+
labels: (i.labels?.nodes ?? []).map((l) => l.name),
|
|
258
|
+
assignee: i.assignee?.email ?? i.assignee?.name ?? null,
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
}
|
package/cli/src/audit.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DIR = '.openspecpm';
|
|
6
|
+
const FILE = 'audit.log';
|
|
7
|
+
|
|
8
|
+
export function auditPath(cwd = process.cwd()) {
|
|
9
|
+
return join(cwd, DIR, FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function record({ command, args = {}, result = null, error = null, cwd = process.cwd() } = {}) {
|
|
13
|
+
if (!command) return;
|
|
14
|
+
const path = auditPath(cwd);
|
|
15
|
+
await mkdir(dirname(path), { recursive: true });
|
|
16
|
+
const entry = {
|
|
17
|
+
ts: new Date().toISOString(),
|
|
18
|
+
command,
|
|
19
|
+
args: scrub(args),
|
|
20
|
+
result: result ? truncate(result, 500) : null,
|
|
21
|
+
error: error ? truncate(typeof error === 'string' ? error : error.message ?? String(error), 500) : null,
|
|
22
|
+
};
|
|
23
|
+
await appendFile(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function tail(n = 50, cwd = process.cwd()) {
|
|
27
|
+
const path = auditPath(cwd);
|
|
28
|
+
if (!existsSync(path)) return [];
|
|
29
|
+
const raw = await readFile(path, 'utf8');
|
|
30
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
31
|
+
return lines.slice(-n).map((l) => {
|
|
32
|
+
try { return JSON.parse(l); } catch { return { raw: l }; }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SECRET_KEYS = /token|secret|password|pat|api[_-]?key|auth|credential/i;
|
|
37
|
+
|
|
38
|
+
function scrub(obj) {
|
|
39
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
40
|
+
if (Array.isArray(obj)) return obj.map(scrub);
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
43
|
+
if (SECRET_KEYS.test(k)) {
|
|
44
|
+
out[k] = '<redacted>';
|
|
45
|
+
} else if (v && typeof v === 'object') {
|
|
46
|
+
out[k] = scrub(v);
|
|
47
|
+
} else if (typeof v === 'string' && v.length > 200) {
|
|
48
|
+
out[k] = v.slice(0, 200) + '…';
|
|
49
|
+
} else {
|
|
50
|
+
out[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function truncate(s, max) {
|
|
57
|
+
if (s == null) return s;
|
|
58
|
+
const str = String(s);
|
|
59
|
+
return str.length > max ? str.slice(0, max) + '…' : str;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wrap a command runner so it auto-records to the audit log.
|
|
64
|
+
* Captures success/failure but never throws *from* the audit layer.
|
|
65
|
+
*/
|
|
66
|
+
export function audited(command, fn) {
|
|
67
|
+
return async (args) => {
|
|
68
|
+
const safeArgs = args ?? {};
|
|
69
|
+
try {
|
|
70
|
+
const result = await fn(safeArgs);
|
|
71
|
+
try { await record({ command, args: safeArgs, result: result === undefined ? 'ok' : 'ok' }); } catch { /* never break the command */ }
|
|
72
|
+
return result;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
try { await record({ command, args: safeArgs, error: err }); } catch { /* same */ }
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const OBSERVABLE_VERBS = [
|
|
6
|
+
'displays', 'shows', 'returns', 'stores', 'persists', 'rejects', 'allows',
|
|
7
|
+
'emails', 'sends', 'creates', 'updates', 'deletes', 'renders', 'redirects',
|
|
8
|
+
'logs', 'records', 'enqueues', 'publishes', 'fails', 'succeeds', 'navigates',
|
|
9
|
+
'saves', 'loads', 'increments', 'decrements', 'enables', 'disables', 'closes',
|
|
10
|
+
'opens', 'highlights', 'hides', 'reveals', 'archives', 'restores', 'cancels',
|
|
11
|
+
'rolls', 'commits', 'broadcasts', 'notifies', 'flags', 'tags', 'untags',
|
|
12
|
+
'matches', 'contains', 'equals', 'exceeds', 'is', 'are', 'has', 'have',
|
|
13
|
+
'becomes', 'remains',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const DENY_PHRASES = [
|
|
17
|
+
'should work',
|
|
18
|
+
'should be correct',
|
|
19
|
+
'is successful',
|
|
20
|
+
'is fine',
|
|
21
|
+
'works correctly',
|
|
22
|
+
'works as expected',
|
|
23
|
+
'is good',
|
|
24
|
+
'is okay',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} LintFinding
|
|
29
|
+
* @property {'error'|'warning'} severity
|
|
30
|
+
* @property {string} file
|
|
31
|
+
* @property {number} [line]
|
|
32
|
+
* @property {string} scenario
|
|
33
|
+
* @property {string} rule
|
|
34
|
+
* @property {string} message
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const SCENARIO_RE = /^\s*Scenario:\s*(.+?)\s*$/i;
|
|
38
|
+
const STEP_RE = /^\s*(Given|When|Then|And|But)\s+(.+?)\s*$/i;
|
|
39
|
+
|
|
40
|
+
export function parseScenarios(source) {
|
|
41
|
+
const lines = source.split(/\r?\n/);
|
|
42
|
+
const scenarios = [];
|
|
43
|
+
let current = null;
|
|
44
|
+
let lineNo = 0;
|
|
45
|
+
for (const raw of lines) {
|
|
46
|
+
lineNo++;
|
|
47
|
+
const sMatch = raw.match(SCENARIO_RE);
|
|
48
|
+
if (sMatch) {
|
|
49
|
+
if (current) scenarios.push(current);
|
|
50
|
+
current = { title: sMatch[1], line: lineNo, steps: [] };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!current) continue;
|
|
54
|
+
const stepMatch = raw.match(STEP_RE);
|
|
55
|
+
if (stepMatch) {
|
|
56
|
+
current.steps.push({ keyword: titleCase(stepMatch[1]), text: stepMatch[2], line: lineNo });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (current) scenarios.push(current);
|
|
60
|
+
return scenarios;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function titleCase(s) {
|
|
64
|
+
return s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function lintScenario(scenario, { file = '<input>' } = {}) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
const counts = { Given: 0, When: 0, Then: 0 };
|
|
70
|
+
let lastPrimary = null;
|
|
71
|
+
for (const step of scenario.steps) {
|
|
72
|
+
if (['Given', 'When', 'Then'].includes(step.keyword)) {
|
|
73
|
+
counts[step.keyword]++;
|
|
74
|
+
lastPrimary = step.keyword;
|
|
75
|
+
} else if (step.keyword === 'And' || step.keyword === 'But') {
|
|
76
|
+
if (!lastPrimary) {
|
|
77
|
+
findings.push(finding('error', file, step.line, scenario.title, 'bdd/orphan-and', `${step.keyword} step appears before any Given/When/Then.`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const k of ['Given', 'When', 'Then']) {
|
|
82
|
+
if (counts[k] === 0) {
|
|
83
|
+
findings.push(finding('error', file, scenario.line, scenario.title, 'bdd/missing-step', `Scenario is missing a ${k} step.`));
|
|
84
|
+
}
|
|
85
|
+
if (counts[k] > 1) {
|
|
86
|
+
findings.push(finding('warning', file, scenario.line, scenario.title, 'bdd/multiple-primary', `Scenario has ${counts[k]} ${k} steps — prefer one ${k} plus And-chained steps.`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const whens = scenario.steps.filter((s) => s.keyword === 'When');
|
|
91
|
+
for (const w of whens) {
|
|
92
|
+
const first = w.text.trim().split(/\s+/)[0]?.toLowerCase();
|
|
93
|
+
if (!first || /^(is|are|was|were|will)$/.test(first)) {
|
|
94
|
+
findings.push(finding('warning', file, w.line, scenario.title, 'bdd/weak-when', `When clause "${w.text}" should start with an action verb (clicks, submits, requests, …), not a state verb.`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const thens = scenario.steps.filter((s) => s.keyword === 'Then');
|
|
99
|
+
for (const t of thens) {
|
|
100
|
+
const lower = t.text.toLowerCase();
|
|
101
|
+
for (const deny of DENY_PHRASES) {
|
|
102
|
+
if (lower.includes(deny)) {
|
|
103
|
+
findings.push(finding('error', file, t.line, scenario.title, 'bdd/vague-then', `Then predicate uses denied phrase "${deny}". State an observable outcome instead.`));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!OBSERVABLE_VERBS.some((v) => new RegExp(`\\b${v}\\b`, 'i').test(t.text))) {
|
|
107
|
+
findings.push(finding('warning', file, t.line, scenario.title, 'bdd/non-observable-then', `Then "${t.text}" lacks an observable verb. Consider: ${OBSERVABLE_VERBS.slice(0, 6).join(', ')}, …`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Tautology check: When and Then with high similarity.
|
|
112
|
+
for (const w of whens) {
|
|
113
|
+
for (const t of thens) {
|
|
114
|
+
if (similarity(w.text, t.text) > 0.8) {
|
|
115
|
+
findings.push(finding('error', file, t.line, scenario.title, 'bdd/tautological-then', `Then "${t.text}" closely paraphrases When "${w.text}". State an outcome distinct from the action.`));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function lintSource(source, { file = '<input>' } = {}) {
|
|
124
|
+
const scenarios = parseScenarios(source);
|
|
125
|
+
if (!scenarios.length) return [];
|
|
126
|
+
const findings = [];
|
|
127
|
+
for (const s of scenarios) findings.push(...lintScenario(s, { file }));
|
|
128
|
+
return findings;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function lintChange(featureDir) {
|
|
132
|
+
const specsDir = join(featureDir, 'specs');
|
|
133
|
+
if (!existsSync(specsDir)) return [];
|
|
134
|
+
const files = await readdir(specsDir);
|
|
135
|
+
const findings = [];
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
if (!f.endsWith('.md')) continue;
|
|
138
|
+
const full = join(specsDir, f);
|
|
139
|
+
const src = await readFile(full, 'utf8');
|
|
140
|
+
findings.push(...lintSource(src, { file: full }));
|
|
141
|
+
}
|
|
142
|
+
return findings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function summarize(findings) {
|
|
146
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
147
|
+
const warnings = findings.filter((f) => f.severity === 'warning').length;
|
|
148
|
+
return { errors, warnings, total: findings.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formatFindings(findings) {
|
|
152
|
+
if (!findings.length) return ' No BDD findings.\n';
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const f of findings) {
|
|
155
|
+
const sigil = f.severity === 'error' ? '✖' : '⚠';
|
|
156
|
+
lines.push(` ${sigil} ${f.file}:${f.line ?? '?'} [${f.rule}] ${f.scenario}: ${f.message}`);
|
|
157
|
+
}
|
|
158
|
+
return lines.join('\n') + '\n';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function finding(severity, file, line, scenario, rule, message) {
|
|
162
|
+
return { severity, file, line, scenario, rule, message };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Dice coefficient on word bigrams — small, dependency-free, good enough for paraphrase detection.
|
|
166
|
+
function similarity(a, b) {
|
|
167
|
+
const A = bigrams(a.toLowerCase());
|
|
168
|
+
const B = bigrams(b.toLowerCase());
|
|
169
|
+
if (!A.size || !B.size) return 0;
|
|
170
|
+
let overlap = 0;
|
|
171
|
+
for (const g of A) if (B.has(g)) overlap++;
|
|
172
|
+
return (2 * overlap) / (A.size + B.size);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bigrams(s) {
|
|
176
|
+
const words = s.split(/\s+/).filter(Boolean);
|
|
177
|
+
const out = new Set();
|
|
178
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
179
|
+
out.add(words[i] + ' ' + words[i + 1]);
|
|
180
|
+
}
|
|
181
|
+
if (words.length === 1) out.add(words[0]);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export const STARTER_SPEC = `# Scenarios
|
|
2
|
+
|
|
3
|
+
Document each user-visible behavior as a Scenario with Given/When/Then.
|
|
4
|
+
Aim for one Given, one When, one Then per scenario, with optional And/But chains.
|
|
5
|
+
|
|
6
|
+
Scenario: Replace this title with a one-sentence behavior
|
|
7
|
+
Given <pre-condition>
|
|
8
|
+
And <another pre-condition (optional)>
|
|
9
|
+
When <the user or system performs an action>
|
|
10
|
+
Then <an observable outcome>
|
|
11
|
+
And <another observable outcome (optional)>
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
export const STARTER_TASKS = `---
|
|
15
|
+
schema_version: 1
|
|
16
|
+
items: []
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Tasks
|
|
20
|
+
|
|
21
|
+
Add one entry per work item to the \`items:\` frontmatter list. The frontmatter
|
|
22
|
+
is authoritative; this body is informational only.
|
|
23
|
+
|
|
24
|
+
Schema (per item):
|
|
25
|
+
|
|
26
|
+
\`\`\`yaml
|
|
27
|
+
- title: "Implement X"
|
|
28
|
+
sync_state: pending
|
|
29
|
+
depends_on: []
|
|
30
|
+
parallel: true
|
|
31
|
+
effort_hours: 3
|
|
32
|
+
\`\`\`
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export const CHANGE_TYPES = ['feature', 'bug', 'refactor', 'incident'];
|
|
36
|
+
|
|
37
|
+
const PROPOSAL_TEMPLATES = {
|
|
38
|
+
feature: (name) => `---
|
|
39
|
+
name: ${name}
|
|
40
|
+
type: feature
|
|
41
|
+
status: draft
|
|
42
|
+
schema_version: 1
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Feature: ${name}
|
|
46
|
+
|
|
47
|
+
## Problem
|
|
48
|
+
What can't users do today? Who feels it?
|
|
49
|
+
|
|
50
|
+
## Proposed solution
|
|
51
|
+
One paragraph describing the user-visible behavior.
|
|
52
|
+
|
|
53
|
+
## Success criteria
|
|
54
|
+
- Observable outcome 1
|
|
55
|
+
- Observable outcome 2
|
|
56
|
+
|
|
57
|
+
## Out of scope
|
|
58
|
+
- Things we are deliberately not doing in this change
|
|
59
|
+
`,
|
|
60
|
+
|
|
61
|
+
bug: (name) => `---
|
|
62
|
+
name: ${name}
|
|
63
|
+
type: bug
|
|
64
|
+
status: draft
|
|
65
|
+
schema_version: 1
|
|
66
|
+
severity: low | medium | high | critical
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
# Bug: ${name}
|
|
70
|
+
|
|
71
|
+
## Symptom
|
|
72
|
+
What does the user see?
|
|
73
|
+
|
|
74
|
+
## Reproduce
|
|
75
|
+
1. Step
|
|
76
|
+
2. Step
|
|
77
|
+
|
|
78
|
+
## Root cause (after investigation)
|
|
79
|
+
Fill in once known.
|
|
80
|
+
|
|
81
|
+
## Fix approach
|
|
82
|
+
One paragraph.
|
|
83
|
+
|
|
84
|
+
## Regression test
|
|
85
|
+
Which scenarios in specs/ exercise this path?
|
|
86
|
+
`,
|
|
87
|
+
|
|
88
|
+
refactor: (name) => `---
|
|
89
|
+
name: ${name}
|
|
90
|
+
type: refactor
|
|
91
|
+
status: draft
|
|
92
|
+
schema_version: 1
|
|
93
|
+
behavior_change: none
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
# Refactor: ${name}
|
|
97
|
+
|
|
98
|
+
## What's being changed
|
|
99
|
+
Files / modules / patterns affected.
|
|
100
|
+
|
|
101
|
+
## Why now
|
|
102
|
+
The motivating constraint or smell.
|
|
103
|
+
|
|
104
|
+
## Behavior contract
|
|
105
|
+
This refactor MUST NOT change observable behavior. List the BDD scenarios
|
|
106
|
+
in specs/ that must continue to pass unchanged.
|
|
107
|
+
|
|
108
|
+
## Risk + rollback
|
|
109
|
+
What's the blast radius if this goes wrong? How do we revert?
|
|
110
|
+
`,
|
|
111
|
+
|
|
112
|
+
incident: (name) => `---
|
|
113
|
+
name: ${name}
|
|
114
|
+
type: incident
|
|
115
|
+
status: draft
|
|
116
|
+
schema_version: 1
|
|
117
|
+
severity: sev1 | sev2 | sev3
|
|
118
|
+
detected_at: <ISO-8601>
|
|
119
|
+
mitigated_at: <ISO-8601>
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
# Incident: ${name}
|
|
123
|
+
|
|
124
|
+
## Impact
|
|
125
|
+
Who was affected, for how long, in what way.
|
|
126
|
+
|
|
127
|
+
## Timeline
|
|
128
|
+
| Time | Event |
|
|
129
|
+
|------|-------|
|
|
130
|
+
| ... | ... |
|
|
131
|
+
|
|
132
|
+
## Root cause
|
|
133
|
+
One paragraph.
|
|
134
|
+
|
|
135
|
+
## Mitigation
|
|
136
|
+
What stopped the bleeding.
|
|
137
|
+
|
|
138
|
+
## Action items
|
|
139
|
+
- [ ] Owner: short description
|
|
140
|
+
`,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const SPECS_TEMPLATES = {
|
|
144
|
+
feature: STARTER_SPEC,
|
|
145
|
+
bug: `# Regression scenarios
|
|
146
|
+
|
|
147
|
+
Each scenario MUST fail without the fix and pass with it.
|
|
148
|
+
|
|
149
|
+
Scenario: <symptom title>
|
|
150
|
+
Given <state at time of bug>
|
|
151
|
+
When <action that triggered it>
|
|
152
|
+
Then <observable correct behavior, not the buggy one>
|
|
153
|
+
`,
|
|
154
|
+
refactor: STARTER_SPEC,
|
|
155
|
+
incident: `# Post-incident verification
|
|
156
|
+
|
|
157
|
+
Scenario: <action that triggered the incident> no longer breaks
|
|
158
|
+
Given <conditions at incident time, reproduced>
|
|
159
|
+
When <triggering action>
|
|
160
|
+
Then <safe behavior occurs>
|
|
161
|
+
And <no alert fires>
|
|
162
|
+
`,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function proposalTemplate(type, name) {
|
|
166
|
+
const t = PROPOSAL_TEMPLATES[type] ?? PROPOSAL_TEMPLATES.feature;
|
|
167
|
+
return t(name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function specsTemplate(type) {
|
|
171
|
+
return SPECS_TEMPLATES[type] ?? STARTER_SPEC;
|
|
172
|
+
}
|