@topogram/cli 0.3.71 → 0.3.73
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 +24 -195
- package/package.json +1 -1
- package/src/adoption/plan/index.js +2 -1
- package/src/agent-brief.js +46 -2
- package/src/archive/archive.js +1 -1
- package/src/archive/jsonl.js +18 -8
- package/src/archive/resolver-bridge.js +34 -1
- package/src/archive/schema.js +1 -1
- package/src/archive/unarchive.js +26 -0
- package/src/cli/command-parsers/project.js +0 -3
- package/src/cli/command-parsers/sdlc.js +66 -0
- package/src/cli/commands/import/help.js +1 -0
- package/src/cli/commands/import/plan.js +9 -0
- package/src/cli/commands/import/workspace.js +3 -0
- package/src/cli/commands/query/definitions.js +11 -10
- package/src/cli/commands/query/workspace.js +23 -2
- package/src/cli/commands/sdlc.js +213 -5
- package/src/cli/dispatcher.js +8 -5
- package/src/cli/help.js +14 -3
- package/src/cli/migration-guidance.js +3 -0
- package/src/cli/options.js +1 -0
- package/src/generator/context/shared/domain-sdlc.js +27 -0
- package/src/generator/context/shared/relationships.js +2 -1
- package/src/generator/context/shared/types.d.ts +1 -0
- package/src/generator/context/shared.d.ts +2 -0
- package/src/generator/context/shared.js +2 -0
- package/src/generator/context/slice/core.js +3 -0
- package/src/generator/context/slice/sdlc.js +57 -2
- package/src/generator/context/task-mode.js +7 -0
- package/src/generator/sdlc/board.js +2 -0
- package/src/generator/sdlc/traceability-matrix.js +5 -1
- package/src/generator/surfaces/databases/lifecycle-shared.js +2 -2
- package/src/import/core/context.js +1 -1
- package/src/import/core/contracts.js +3 -3
- package/src/import/core/registry.js +3 -0
- package/src/import/core/runner/candidates.js +7 -0
- package/src/import/core/runner/reports.js +9 -1
- package/src/import/core/runner/tracks.js +3 -0
- package/src/import/extractors/cli/generic.js +340 -0
- package/src/new-project/project-files.js +10 -3
- package/src/resolver/enrich/task.js +3 -1
- package/src/resolver/index.js +6 -0
- package/src/resolver/normalize.js +31 -0
- package/src/resolver/projections-cli.js +158 -0
- package/src/sdlc/adopt.js +4 -1
- package/src/sdlc/check.js +24 -2
- package/src/sdlc/complete.js +47 -0
- package/src/sdlc/dod/index.js +2 -0
- package/src/sdlc/dod/plan.js +15 -0
- package/src/sdlc/dod/task.js +7 -3
- package/src/sdlc/explain.js +53 -1
- package/src/sdlc/gate.js +352 -0
- package/src/sdlc/history.d.ts +7 -0
- package/src/sdlc/history.js +50 -5
- package/src/sdlc/link.js +172 -0
- package/src/sdlc/paths.d.ts +4 -0
- package/src/sdlc/paths.js +8 -0
- package/src/sdlc/plan-steps.js +71 -0
- package/src/sdlc/plan.js +245 -0
- package/src/sdlc/policy.js +249 -0
- package/src/sdlc/prep.js +186 -0
- package/src/sdlc/scaffold.js +4 -2
- package/src/sdlc/status-filter.js +2 -0
- package/src/sdlc/transitions/index.js +3 -0
- package/src/sdlc/transitions/plan.js +32 -0
- package/src/validator/common.js +25 -4
- package/src/validator/index.js +10 -0
- package/src/validator/kinds.d.ts +7 -0
- package/src/validator/kinds.js +32 -0
- package/src/validator/per-kind/plan.js +128 -0
- package/src/validator/per-kind/task.js +19 -0
- package/src/validator/projections/cli.js +267 -0
- package/src/validator.d.ts +1 -0
- package/src/workflows/import-app/shared.js +1 -1
- package/src/workflows/reconcile/adoption-plan/build.js +3 -1
- package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
- package/src/workflows/reconcile/bundle-core/index.js +3 -0
- package/src/workflows/reconcile/candidate-model.js +15 -0
- package/src/workflows/reconcile/gap-report.js +4 -2
- package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
- package/src/workflows/reconcile/renderers.js +82 -0
- package/src/workflows/reconcile/summary.js +4 -0
- package/src/workflows/reconcile/workflow.js +2 -1
- package/src/workspace-paths.js +34 -3
- package/src/cli/commands/migrate.js +0 -153
package/src/sdlc/gate.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import childProcess from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { loadImplementationProvider } from "../example-implementation.js";
|
|
8
|
+
import { parsePath } from "../parser.js";
|
|
9
|
+
import {
|
|
10
|
+
loadProjectConfig,
|
|
11
|
+
projectConfigOrDefault,
|
|
12
|
+
validateProjectConfig,
|
|
13
|
+
validateProjectOutputOwnership
|
|
14
|
+
} from "../project-config.js";
|
|
15
|
+
import { resolveWorkspace } from "../resolver.js";
|
|
16
|
+
import { validateProjectImplementationTrust } from "../template-trust.js";
|
|
17
|
+
import { resolveTopoRoot, resolveWorkspaceContext } from "../workspace-paths.js";
|
|
18
|
+
import { checkWorkspace as checkSdlcWorkspace } from "./check.js";
|
|
19
|
+
import { loadSdlcPolicy } from "./policy.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} SdlcGateOptions
|
|
23
|
+
* @property {string|null} [base]
|
|
24
|
+
* @property {string|null} [head]
|
|
25
|
+
* @property {string[]} [sdlcIds]
|
|
26
|
+
* @property {string|null} [exemption]
|
|
27
|
+
* @property {boolean} [requireAdopted]
|
|
28
|
+
* @property {string[]} [changedFiles]
|
|
29
|
+
* @property {string|null} [prBody]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} flag
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function envValue(flag) {
|
|
37
|
+
return process.env[flag] || "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} pattern
|
|
42
|
+
* @returns {RegExp}
|
|
43
|
+
*/
|
|
44
|
+
function globPatternToRegex(pattern) {
|
|
45
|
+
const escaped = pattern
|
|
46
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
47
|
+
.replace(/\*\*/g, "\u0000")
|
|
48
|
+
.replace(/\*/g, "[^/]*")
|
|
49
|
+
.replace(/\u0000/g, ".*");
|
|
50
|
+
return new RegExp(`^${escaped}$`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} file
|
|
55
|
+
* @param {string[]} patterns
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function matchesAnyProtectedPath(file, patterns) {
|
|
59
|
+
const normalized = file.replace(/\\/g, "/");
|
|
60
|
+
return patterns.some((pattern) => globPatternToRegex(pattern).test(normalized));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} projectRoot
|
|
65
|
+
* @param {string|null|undefined} base
|
|
66
|
+
* @param {string|null|undefined} head
|
|
67
|
+
* @returns {{ ok: true, files: string[] } | { ok: false, error: string }}
|
|
68
|
+
*/
|
|
69
|
+
function gitChangedFiles(projectRoot, base, head) {
|
|
70
|
+
if (!base || !head) {
|
|
71
|
+
return { ok: true, files: [] };
|
|
72
|
+
}
|
|
73
|
+
const result = childProcess.spawnSync("git", ["diff", "--name-only", `${base}...${head}`], {
|
|
74
|
+
cwd: projectRoot,
|
|
75
|
+
encoding: "utf8"
|
|
76
|
+
});
|
|
77
|
+
if (result.status !== 0) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: (result.stderr || result.stdout || `git diff failed with status ${result.status}`).trim()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
files: result.stdout.split(/\r?\n/).map(/** @param {string} line */ (line) => line.trim()).filter(Boolean)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
function githubPullRequestBody() {
|
|
93
|
+
const eventPath = envValue("GITHUB_EVENT_PATH");
|
|
94
|
+
if (!eventPath || !fs.existsSync(eventPath)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
|
99
|
+
return typeof event?.pull_request?.body === "string" ? event.pull_request.body : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string|null|undefined} body
|
|
107
|
+
* @returns {string[]}
|
|
108
|
+
*/
|
|
109
|
+
function sdlcIdsFromText(body) {
|
|
110
|
+
if (!body) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const matches = body.match(/\b(?:task|bug|req|pitch)_[a-z][a-z0-9_]*\b/g) || [];
|
|
114
|
+
return [...new Set(matches)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string|null|undefined} body
|
|
119
|
+
* @returns {string|null}
|
|
120
|
+
*/
|
|
121
|
+
function exemptionFromText(body) {
|
|
122
|
+
if (!body) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const match = body.match(/exemption\s+reason\s*:\s*(.+)/i);
|
|
126
|
+
const value = match?.[1]?.trim();
|
|
127
|
+
return value && value.toLowerCase() !== "none" && value !== "N/A" ? value : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} file
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function isSdlcRecordChange(file) {
|
|
135
|
+
const normalized = file.replace(/\\/g, "/");
|
|
136
|
+
if (normalized === "topogram.sdlc-policy.json") {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
"topo/sdlc/pitches/",
|
|
141
|
+
"topo/sdlc/requirements/",
|
|
142
|
+
"topo/sdlc/acceptance_criteria/",
|
|
143
|
+
"topo/sdlc/tasks/",
|
|
144
|
+
"topo/sdlc/plans/",
|
|
145
|
+
"topo/sdlc/bugs/",
|
|
146
|
+
"topo/sdlc/decisions/",
|
|
147
|
+
"topo/sdlc/docs/",
|
|
148
|
+
"topo/pitches/",
|
|
149
|
+
"topo/requirements/",
|
|
150
|
+
"topo/acceptance_criteria/",
|
|
151
|
+
"topo/tasks/",
|
|
152
|
+
"topo/plans/",
|
|
153
|
+
"topo/bugs/",
|
|
154
|
+
"topo/docs/"
|
|
155
|
+
].some((prefix) => normalized.startsWith(prefix));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} topogramPath
|
|
160
|
+
* @returns {Promise<Record<string, any>>}
|
|
161
|
+
*/
|
|
162
|
+
async function runTopogramCheckPayload(topogramPath) {
|
|
163
|
+
const ast = parsePath(topogramPath);
|
|
164
|
+
const resolved = resolveWorkspace(ast);
|
|
165
|
+
const implementation = await loadImplementationProvider(topogramPath).catch(() => null);
|
|
166
|
+
const explicitProjectConfig = loadProjectConfig(topogramPath);
|
|
167
|
+
const projectConfigInfo = explicitProjectConfig ||
|
|
168
|
+
(implementation ? projectConfigOrDefault(topogramPath, resolved.ok ? resolved.graph : null, implementation) : null);
|
|
169
|
+
const projectValidation = projectConfigInfo
|
|
170
|
+
? {
|
|
171
|
+
ok: [
|
|
172
|
+
validateProjectConfig(projectConfigInfo.config, resolved.ok ? resolved.graph : null, { configDir: projectConfigInfo.configDir }),
|
|
173
|
+
validateProjectOutputOwnership(projectConfigInfo),
|
|
174
|
+
validateProjectImplementationTrust(projectConfigInfo)
|
|
175
|
+
].every((result) => result.ok),
|
|
176
|
+
errors: [
|
|
177
|
+
...validateProjectConfig(projectConfigInfo.config, resolved.ok ? resolved.graph : null, { configDir: projectConfigInfo.configDir }).errors,
|
|
178
|
+
...validateProjectOutputOwnership(projectConfigInfo).errors,
|
|
179
|
+
...validateProjectImplementationTrust(projectConfigInfo).errors
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
: { ok: false, errors: [{ message: "Missing topogram.project.json or compatible topogram.implementation.json", loc: null }] };
|
|
183
|
+
return {
|
|
184
|
+
ok: Boolean(resolved.ok && projectValidation.ok),
|
|
185
|
+
topogram: resolved.ok,
|
|
186
|
+
project: projectValidation.ok,
|
|
187
|
+
errors: [
|
|
188
|
+
...(!resolved.ok ? resolved.validation.errors.map(/** @param {any} error */ (error) => ({ source: "topogram", message: error.message })) : []),
|
|
189
|
+
...projectValidation.errors.map((error) => ({ source: "project", message: error.message }))
|
|
190
|
+
]
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {Record<string, any>} graph
|
|
196
|
+
* @param {string[]} ids
|
|
197
|
+
* @param {string[]} requiredKinds
|
|
198
|
+
* @returns {{ valid: string[], invalid: string[] }}
|
|
199
|
+
*/
|
|
200
|
+
function validateSdlcIds(graph, ids, requiredKinds) {
|
|
201
|
+
const byId = new Map((graph?.statements || []).map(/** @param {any} statement */ (statement) => [statement.id, statement]));
|
|
202
|
+
/** @type {string[]} */
|
|
203
|
+
const valid = [];
|
|
204
|
+
/** @type {string[]} */
|
|
205
|
+
const invalid = [];
|
|
206
|
+
for (const id of ids) {
|
|
207
|
+
const statement = byId.get(id);
|
|
208
|
+
if (statement && requiredKinds.includes(String(statement.kind))) {
|
|
209
|
+
valid.push(id);
|
|
210
|
+
} else {
|
|
211
|
+
invalid.push(id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { valid, invalid };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {string} inputPath
|
|
219
|
+
* @param {SdlcGateOptions} [options]
|
|
220
|
+
* @returns {Promise<Record<string, any>>}
|
|
221
|
+
*/
|
|
222
|
+
export async function runSdlcGate(inputPath = ".", options = {}) {
|
|
223
|
+
const context = resolveWorkspaceContext(inputPath || ".");
|
|
224
|
+
const projectRoot = context.projectRoot;
|
|
225
|
+
const topogramRoot = resolveTopoRoot(inputPath || ".");
|
|
226
|
+
const policyInfo = loadSdlcPolicy(projectRoot);
|
|
227
|
+
const policy = policyInfo.policy;
|
|
228
|
+
/** @type {string[]} */
|
|
229
|
+
const errors = [];
|
|
230
|
+
/** @type {string[]} */
|
|
231
|
+
const warnings = [];
|
|
232
|
+
|
|
233
|
+
if (policyInfo.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
234
|
+
errors.push(...policyInfo.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!policyInfo.exists || policyInfo.status !== "adopted") {
|
|
238
|
+
if (options.requireAdopted) {
|
|
239
|
+
errors.push("SDLC policy is not adopted; required because --require-adopted was passed.");
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
type: "sdlc_gate",
|
|
243
|
+
version: "1",
|
|
244
|
+
ok: errors.length === 0,
|
|
245
|
+
projectRoot,
|
|
246
|
+
topogramRoot,
|
|
247
|
+
policy: {
|
|
248
|
+
exists: policyInfo.exists,
|
|
249
|
+
path: policyInfo.path,
|
|
250
|
+
status: policyInfo.status,
|
|
251
|
+
mode: policyInfo.mode
|
|
252
|
+
},
|
|
253
|
+
changedFiles: [],
|
|
254
|
+
protectedChanges: [],
|
|
255
|
+
sdlcIds: [],
|
|
256
|
+
validSdlcIds: [],
|
|
257
|
+
invalidSdlcIds: [],
|
|
258
|
+
sdlcRecordChanges: [],
|
|
259
|
+
exemption: null,
|
|
260
|
+
checks: [],
|
|
261
|
+
warnings,
|
|
262
|
+
errors,
|
|
263
|
+
nextCommands: policyInfo.exists ? ["topogram sdlc policy check --json"] : ["topogram sdlc policy init ."]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!policy) {
|
|
268
|
+
errors.push("SDLC policy could not be loaded.");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const checkPayload = await runTopogramCheckPayload(topogramRoot);
|
|
272
|
+
if (!checkPayload.ok) {
|
|
273
|
+
errors.push("topogram check failed.");
|
|
274
|
+
}
|
|
275
|
+
const ast = parsePath(topogramRoot);
|
|
276
|
+
const resolved = resolveWorkspace(ast);
|
|
277
|
+
let sdlcCheck = { ok: false, errors: [{ message: "workspace did not resolve" }], warnings: [] };
|
|
278
|
+
if (resolved.ok) {
|
|
279
|
+
sdlcCheck = checkSdlcWorkspace(topogramRoot, resolved);
|
|
280
|
+
if (!sdlcCheck.ok || sdlcCheck.warnings.length > 0) {
|
|
281
|
+
errors.push("topogram sdlc check --strict failed.");
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
errors.push("workspace resolution failed before SDLC check.");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @type {{ ok: true, files: string[] } | { ok: false, error: string }} */
|
|
288
|
+
const diffResult = options.changedFiles
|
|
289
|
+
? { ok: true, files: options.changedFiles }
|
|
290
|
+
: gitChangedFiles(projectRoot, options.base || null, options.head || null);
|
|
291
|
+
if (!diffResult.ok) {
|
|
292
|
+
errors.push(`Could not compute changed files: ${diffResult.error}`);
|
|
293
|
+
}
|
|
294
|
+
const changedFiles = diffResult.ok ? diffResult.files : [];
|
|
295
|
+
const protectedChanges = changedFiles.filter((file) => matchesAnyProtectedPath(file, policy?.protectedPaths || []));
|
|
296
|
+
const sdlcRecordChanges = changedFiles.filter(isSdlcRecordChange);
|
|
297
|
+
const prBody = options.prBody ?? githubPullRequestBody();
|
|
298
|
+
const explicitIds = options.sdlcIds || [];
|
|
299
|
+
const sdlcIds = [...new Set([...explicitIds, ...sdlcIdsFromText(prBody)])];
|
|
300
|
+
const idValidation = resolved.ok
|
|
301
|
+
? validateSdlcIds(resolved.graph, sdlcIds, policy?.requiredItemKinds || [])
|
|
302
|
+
: { valid: [], invalid: sdlcIds };
|
|
303
|
+
const exemption = options.exemption || exemptionFromText(prBody);
|
|
304
|
+
const hasAllowedExemption = Boolean(exemption && policy?.allowExemptions);
|
|
305
|
+
const hasSdlcLinkage = idValidation.valid.length > 0 || sdlcRecordChanges.length > 0 || hasAllowedExemption;
|
|
306
|
+
|
|
307
|
+
if (idValidation.invalid.length > 0) {
|
|
308
|
+
warnings.push(`Ignored invalid SDLC id(s): ${idValidation.invalid.join(", ")}`);
|
|
309
|
+
}
|
|
310
|
+
if (protectedChanges.length > 0 && !hasSdlcLinkage) {
|
|
311
|
+
const message = "Protected changes require a valid SDLC item, a topo/*.tg SDLC record change, or an allowed exemption.";
|
|
312
|
+
if (policy?.mode === "enforced") {
|
|
313
|
+
errors.push(message);
|
|
314
|
+
} else {
|
|
315
|
+
warnings.push(message);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
type: "sdlc_gate",
|
|
321
|
+
version: "1",
|
|
322
|
+
ok: errors.length === 0,
|
|
323
|
+
projectRoot,
|
|
324
|
+
topogramRoot,
|
|
325
|
+
policy: {
|
|
326
|
+
exists: policyInfo.exists,
|
|
327
|
+
path: policyInfo.path,
|
|
328
|
+
status: policyInfo.status,
|
|
329
|
+
mode: policyInfo.mode,
|
|
330
|
+
protectedPaths: policy?.protectedPaths || [],
|
|
331
|
+
requiredItemKinds: policy?.requiredItemKinds || [],
|
|
332
|
+
allowExemptions: policy?.allowExemptions ?? false
|
|
333
|
+
},
|
|
334
|
+
changedFiles,
|
|
335
|
+
protectedChanges,
|
|
336
|
+
sdlcIds,
|
|
337
|
+
validSdlcIds: idValidation.valid,
|
|
338
|
+
invalidSdlcIds: idValidation.invalid,
|
|
339
|
+
sdlcRecordChanges,
|
|
340
|
+
exemption: exemption || null,
|
|
341
|
+
checks: [
|
|
342
|
+
{ command: "topogram check", ok: checkPayload.ok, errors: checkPayload.errors || [] },
|
|
343
|
+
{ command: "topogram sdlc check --strict", ok: Boolean(sdlcCheck.ok && sdlcCheck.warnings.length === 0), errors: sdlcCheck.errors || [], warnings: sdlcCheck.warnings || [] }
|
|
344
|
+
],
|
|
345
|
+
warnings,
|
|
346
|
+
errors,
|
|
347
|
+
nextCommands: [
|
|
348
|
+
"topogram sdlc explain <task-id> --json",
|
|
349
|
+
"topogram query single-agent-plan . --mode modeling --capability <cap-id> --json"
|
|
350
|
+
]
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function historyPath(...args: any[]): any;
|
|
2
|
+
export function readHistory(...args: any[]): any;
|
|
3
|
+
export function validateHistory(...args: any[]): any;
|
|
4
|
+
export function writeHistory(...args: any[]): any;
|
|
5
|
+
export function appendTransition(...args: any[]): any;
|
|
6
|
+
export function lastTransition(...args: any[]): any;
|
|
7
|
+
export function detectDriftedStatus(...args: any[]): any;
|
package/src/sdlc/history.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SDLC history sidecar.
|
|
2
2
|
//
|
|
3
|
-
// Stored at `<topogram-root
|
|
3
|
+
// Stored at `<topogram-root>/sdlc/.topogram-sdlc-history.json` as a
|
|
4
4
|
// JSON object keyed by statement id. Each entry is an append-only array of
|
|
5
5
|
// transition records:
|
|
6
6
|
//
|
|
@@ -15,18 +15,28 @@
|
|
|
15
15
|
// CLI" warnings when an artifact's current status doesn't match the last
|
|
16
16
|
// recorded transition.
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { topogramRootForSdlc } from "./paths.js";
|
|
20
|
+
import { sdlcRootForSdlc, topogramRootForSdlc } from "./paths.js";
|
|
21
21
|
|
|
22
22
|
const HISTORY_FILENAME = ".topogram-sdlc-history.json";
|
|
23
23
|
|
|
24
24
|
export function historyPath(workspaceRoot) {
|
|
25
|
+
return path.join(sdlcRootForSdlc(workspaceRoot), HISTORY_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function legacyHistoryPath(workspaceRoot) {
|
|
25
29
|
return path.join(topogramRootForSdlc(workspaceRoot), HISTORY_FILENAME);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function readHistory(workspaceRoot) {
|
|
29
|
-
|
|
33
|
+
let file = historyPath(workspaceRoot);
|
|
34
|
+
if (!existsSync(file)) {
|
|
35
|
+
const legacyFile = legacyHistoryPath(workspaceRoot);
|
|
36
|
+
if (existsSync(legacyFile)) {
|
|
37
|
+
file = legacyFile;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
30
40
|
if (!existsSync(file)) return {};
|
|
31
41
|
try {
|
|
32
42
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -35,8 +45,43 @@ export function readHistory(workspaceRoot) {
|
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
export function validateHistory(history) {
|
|
49
|
+
const warnings = [];
|
|
50
|
+
if (!history || typeof history !== "object" || Array.isArray(history)) {
|
|
51
|
+
return [{ message: "SDLC history sidecar must be a JSON object keyed by statement id" }];
|
|
52
|
+
}
|
|
53
|
+
for (const [id, entries] of Object.entries(history)) {
|
|
54
|
+
if (id === "__error") continue;
|
|
55
|
+
if (!Array.isArray(entries)) {
|
|
56
|
+
warnings.push({ id, message: `SDLC history entry '${id}' must be an array of transition records` });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
entries.forEach((entry, index) => {
|
|
60
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
61
|
+
warnings.push({ id, message: `SDLC history entry '${id}' transition ${index + 1} must be an object` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const key of ["from", "to", "at"]) {
|
|
65
|
+
if (typeof entry[key] !== "string" || entry[key].trim() === "") {
|
|
66
|
+
warnings.push({ id, message: `SDLC history entry '${id}' transition ${index + 1} must include string '${key}'` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const key of ["by", "note"]) {
|
|
70
|
+
if (entry[key] !== null && entry[key] !== undefined && typeof entry[key] !== "string") {
|
|
71
|
+
warnings.push({ id, message: `SDLC history entry '${id}' transition ${index + 1} field '${key}' must be a string or null` });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return warnings;
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
export function writeHistory(workspaceRoot, history) {
|
|
39
80
|
const file = historyPath(workspaceRoot);
|
|
81
|
+
const dir = path.dirname(file);
|
|
82
|
+
if (!existsSync(dir)) {
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
}
|
|
40
85
|
writeFileSync(file, JSON.stringify(history, null, 2) + "\n", "utf8");
|
|
41
86
|
}
|
|
42
87
|
|
|
@@ -59,7 +104,7 @@ export function appendTransition(workspaceRoot, id, record) {
|
|
|
59
104
|
|
|
60
105
|
export function lastTransition(history, id) {
|
|
61
106
|
const entries = history[id];
|
|
62
|
-
if (!entries || entries.length === 0) return null;
|
|
107
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
63
108
|
return entries[entries.length - 1];
|
|
64
109
|
}
|
|
65
110
|
|
package/src/sdlc/link.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
import { parsePath } from "../parser.js";
|
|
6
|
+
import { resolveWorkspace } from "../resolver.js";
|
|
7
|
+
import { resolveTopoRoot } from "../workspace-paths.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ field: string, property: string, mode: "list"|"single" }} LinkRule
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @type {Map<string, LinkRule>} */
|
|
14
|
+
const LINK_RULES = new Map([
|
|
15
|
+
["task:requirement", { field: "satisfies", property: "satisfies", mode: "list" }],
|
|
16
|
+
["task:acceptance_criterion", { field: "acceptance_refs", property: "acceptanceRefs", mode: "list" }],
|
|
17
|
+
["task:verification", { field: "verification_refs", property: "verificationRefs", mode: "list" }],
|
|
18
|
+
["bug:task", { field: "fixed_in", property: "fixedIn", mode: "list" }],
|
|
19
|
+
["bug:verification", { field: "fixed_in_verification", property: "fixedInVerification", mode: "list" }],
|
|
20
|
+
["acceptance_criterion:requirement", { field: "requirement", property: "requirement", mode: "single" }]
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Record<string, any>} workspaceAst
|
|
25
|
+
* @param {string} id
|
|
26
|
+
* @returns {Record<string, any>|null}
|
|
27
|
+
*/
|
|
28
|
+
function findAstStatement(workspaceAst, id) {
|
|
29
|
+
for (const file of workspaceAst.files || []) {
|
|
30
|
+
for (const statement of file.statements || []) {
|
|
31
|
+
if (statement.id === id) {
|
|
32
|
+
return statement;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Record<string, any>} statement
|
|
41
|
+
* @param {string} key
|
|
42
|
+
* @returns {Record<string, any>|null}
|
|
43
|
+
*/
|
|
44
|
+
function findAstField(statement, key) {
|
|
45
|
+
return (statement.fields || []).find(/** @param {any} field */ (field) => field.key === key) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {Record<string, any>|null|undefined} ref
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
function refId(ref) {
|
|
53
|
+
return typeof ref === "string" ? ref : (typeof ref?.id === "string" ? ref.id : null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Record<string, any>} statement
|
|
58
|
+
* @param {LinkRule} rule
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
function currentLinkIds(statement, rule) {
|
|
62
|
+
const value = statement[rule.property];
|
|
63
|
+
if (rule.mode === "single") {
|
|
64
|
+
const id = refId(value);
|
|
65
|
+
return id ? [id] : [];
|
|
66
|
+
}
|
|
67
|
+
return Array.isArray(value)
|
|
68
|
+
? value.map(refId).filter((id) => typeof id === "string")
|
|
69
|
+
: [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} source
|
|
74
|
+
* @param {number} offset
|
|
75
|
+
* @returns {{ start: number, end: number }}
|
|
76
|
+
*/
|
|
77
|
+
function lineRangeForOffset(source, offset) {
|
|
78
|
+
const start = source.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
79
|
+
const newline = source.indexOf("\n", offset);
|
|
80
|
+
const end = newline >= 0 ? newline + 1 : source.length;
|
|
81
|
+
return { start, end };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Record<string, any>} statement
|
|
86
|
+
* @returns {number}
|
|
87
|
+
*/
|
|
88
|
+
function insertionOffset(statement) {
|
|
89
|
+
const statusField = findAstField(statement, "status");
|
|
90
|
+
if (statusField) {
|
|
91
|
+
return statusField.loc.start.offset;
|
|
92
|
+
}
|
|
93
|
+
return Math.max(statement.loc.end.offset - 2, statement.loc.start.offset);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} source
|
|
98
|
+
* @param {Record<string, any>} astStatement
|
|
99
|
+
* @param {LinkRule} rule
|
|
100
|
+
* @param {string[]} ids
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function rewriteLinkField(source, astStatement, rule, ids) {
|
|
104
|
+
const value = rule.mode === "single" ? ids[0] : `[${ids.join(" ")}]`;
|
|
105
|
+
const replacement = ` ${rule.field} ${value}\n`;
|
|
106
|
+
const existing = findAstField(astStatement, rule.field);
|
|
107
|
+
if (existing) {
|
|
108
|
+
const range = lineRangeForOffset(source, existing.loc.start.offset);
|
|
109
|
+
return `${source.slice(0, range.start)}${replacement}${source.slice(range.end)}`;
|
|
110
|
+
}
|
|
111
|
+
const offset = insertionOffset(astStatement);
|
|
112
|
+
const range = lineRangeForOffset(source, offset);
|
|
113
|
+
return `${source.slice(0, range.start)}${replacement}${source.slice(range.start)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} workspaceRoot
|
|
118
|
+
* @param {string} fromId
|
|
119
|
+
* @param {string} toId
|
|
120
|
+
* @param {{ write?: boolean }} [options]
|
|
121
|
+
* @returns {Record<string, any>}
|
|
122
|
+
*/
|
|
123
|
+
export function linkSdlcRecord(workspaceRoot, fromId, toId, options = {}) {
|
|
124
|
+
const sdlcRoot = resolveTopoRoot(workspaceRoot || ".");
|
|
125
|
+
const ast = parsePath(sdlcRoot);
|
|
126
|
+
const resolved = resolveWorkspace(ast);
|
|
127
|
+
if (!resolved.ok) {
|
|
128
|
+
return { ok: false, error: "workspace failed validation; cannot link SDLC records", validation: resolved.validation };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const from = resolved.graph.byId?.get(fromId) || resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === fromId);
|
|
132
|
+
const to = resolved.graph.byId?.get(toId) || resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === toId);
|
|
133
|
+
if (!from) {
|
|
134
|
+
return { ok: false, error: `Statement '${fromId}' not found` };
|
|
135
|
+
}
|
|
136
|
+
if (!to) {
|
|
137
|
+
return { ok: false, error: `Statement '${toId}' not found` };
|
|
138
|
+
}
|
|
139
|
+
const rule = LINK_RULES.get(`${from.kind}:${to.kind}`);
|
|
140
|
+
if (!rule) {
|
|
141
|
+
return { ok: false, error: `No supported SDLC link from ${from.kind} to ${to.kind}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const current = currentLinkIds(from, rule);
|
|
145
|
+
const next = rule.mode === "single" ? [toId] : [...new Set([...current, toId])];
|
|
146
|
+
if (rule.mode === "single" && current[0] && current[0] !== toId) {
|
|
147
|
+
return { ok: false, error: `${fromId} already links ${rule.field} to ${current[0]}` };
|
|
148
|
+
}
|
|
149
|
+
const astStatement = findAstStatement(ast, fromId);
|
|
150
|
+
if (!astStatement) {
|
|
151
|
+
return { ok: false, error: `Source statement '${fromId}' not found` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const file = astStatement.loc.file;
|
|
155
|
+
const original = fs.readFileSync(file, "utf8");
|
|
156
|
+
const rewritten = rewriteLinkField(original, astStatement, rule, next);
|
|
157
|
+
if (options.write) {
|
|
158
|
+
fs.writeFileSync(file, rewritten, "utf8");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
from: { id: fromId, kind: from.kind },
|
|
164
|
+
to: { id: toId, kind: to.kind },
|
|
165
|
+
field: rule.field,
|
|
166
|
+
file,
|
|
167
|
+
before: current,
|
|
168
|
+
after: next,
|
|
169
|
+
dryRun: !options.write,
|
|
170
|
+
written: Boolean(options.write)
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/sdlc/paths.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
import { resolveTopoRoot, resolveWorkspaceContext } from "../workspace-paths.js";
|
|
2
4
|
|
|
5
|
+
export const SDLC_RECORD_ROOT = "sdlc";
|
|
6
|
+
|
|
3
7
|
export function topogramRootForSdlc(inputPath) {
|
|
4
8
|
return resolveTopoRoot(inputPath);
|
|
5
9
|
}
|
|
6
10
|
|
|
11
|
+
export function sdlcRootForSdlc(inputPath) {
|
|
12
|
+
return path.join(topogramRootForSdlc(inputPath), SDLC_RECORD_ROOT);
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
export function projectRootForSdlc(inputPath) {
|
|
8
16
|
return resolveWorkspaceContext(inputPath).projectRoot;
|
|
9
17
|
}
|