@topogram/cli 0.3.72 → 0.3.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +24 -195
  2. package/package.json +1 -1
  3. package/src/adoption/plan/index.js +2 -1
  4. package/src/agent-brief.js +46 -2
  5. package/src/archive/archive.js +1 -1
  6. package/src/archive/jsonl.js +18 -8
  7. package/src/archive/resolver-bridge.js +34 -1
  8. package/src/archive/schema.js +1 -1
  9. package/src/archive/unarchive.js +26 -0
  10. package/src/cli/command-parsers/sdlc.js +66 -0
  11. package/src/cli/commands/import/help.js +1 -0
  12. package/src/cli/commands/import/plan.js +9 -0
  13. package/src/cli/commands/import/workspace.js +3 -0
  14. package/src/cli/commands/query/definitions.js +11 -10
  15. package/src/cli/commands/query/workspace.js +23 -2
  16. package/src/cli/commands/release-rollout.js +191 -10
  17. package/src/cli/commands/release-shared.js +51 -2
  18. package/src/cli/commands/release.js +16 -3
  19. package/src/cli/commands/sdlc.js +213 -5
  20. package/src/cli/dispatcher.js +8 -0
  21. package/src/cli/help.js +15 -3
  22. package/src/cli/options.js +1 -0
  23. package/src/generator/context/shared/domain-sdlc.js +27 -0
  24. package/src/generator/context/shared/relationships.js +2 -1
  25. package/src/generator/context/shared/types.d.ts +1 -0
  26. package/src/generator/context/shared.d.ts +2 -0
  27. package/src/generator/context/shared.js +2 -0
  28. package/src/generator/context/slice/core.js +3 -0
  29. package/src/generator/context/slice/sdlc.js +57 -2
  30. package/src/generator/context/task-mode.js +7 -0
  31. package/src/generator/sdlc/board.js +2 -0
  32. package/src/generator/sdlc/traceability-matrix.js +5 -1
  33. package/src/import/core/context.js +1 -1
  34. package/src/import/core/contracts.js +3 -3
  35. package/src/import/core/registry.js +3 -0
  36. package/src/import/core/runner/candidates.js +7 -0
  37. package/src/import/core/runner/reports.js +9 -1
  38. package/src/import/core/runner/tracks.js +3 -0
  39. package/src/import/extractors/cli/generic.js +340 -0
  40. package/src/new-project/project-files.js +10 -3
  41. package/src/resolver/enrich/task.js +3 -1
  42. package/src/resolver/index.js +6 -0
  43. package/src/resolver/normalize.js +31 -0
  44. package/src/resolver/projections-cli.js +158 -0
  45. package/src/sdlc/adopt.js +4 -1
  46. package/src/sdlc/check.js +24 -2
  47. package/src/sdlc/complete.js +47 -0
  48. package/src/sdlc/dod/index.js +2 -0
  49. package/src/sdlc/dod/plan.js +15 -0
  50. package/src/sdlc/dod/task.js +7 -3
  51. package/src/sdlc/explain.js +53 -1
  52. package/src/sdlc/gate.js +352 -0
  53. package/src/sdlc/history.d.ts +7 -0
  54. package/src/sdlc/history.js +50 -5
  55. package/src/sdlc/link.js +172 -0
  56. package/src/sdlc/paths.d.ts +4 -0
  57. package/src/sdlc/paths.js +8 -0
  58. package/src/sdlc/plan-steps.js +71 -0
  59. package/src/sdlc/plan.js +245 -0
  60. package/src/sdlc/policy.js +249 -0
  61. package/src/sdlc/prep.js +186 -0
  62. package/src/sdlc/scaffold.js +4 -2
  63. package/src/sdlc/status-filter.js +2 -0
  64. package/src/sdlc/transitions/index.js +3 -0
  65. package/src/sdlc/transitions/plan.js +32 -0
  66. package/src/validator/common.js +25 -4
  67. package/src/validator/index.js +10 -0
  68. package/src/validator/kinds.d.ts +7 -0
  69. package/src/validator/kinds.js +32 -0
  70. package/src/validator/per-kind/plan.js +128 -0
  71. package/src/validator/per-kind/task.js +19 -0
  72. package/src/validator/projections/cli.js +267 -0
  73. package/src/validator.d.ts +1 -0
  74. package/src/workflows/import-app/shared.js +1 -1
  75. package/src/workflows/reconcile/adoption-plan/build.js +3 -1
  76. package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
  77. package/src/workflows/reconcile/bundle-core/index.js +3 -0
  78. package/src/workflows/reconcile/candidate-model.js +15 -0
  79. package/src/workflows/reconcile/gap-report.js +4 -2
  80. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  81. package/src/workflows/reconcile/renderers.js +82 -0
  82. package/src/workflows/reconcile/summary.js +4 -0
  83. package/src/workflows/reconcile/workflow.js +2 -1
  84. package/src/workspace-paths.js +26 -2
@@ -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;
@@ -1,6 +1,6 @@
1
1
  // SDLC history sidecar.
2
2
  //
3
- // Stored at `<topogram-root>/.topogram-sdlc-history.json` as a
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
- const file = historyPath(workspaceRoot);
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
 
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export const SDLC_RECORD_ROOT: string;
2
+ export function topogramRootForSdlc(...args: any[]): any;
3
+ export function sdlcRootForSdlc(...args: any[]): any;
4
+ export function projectRootForSdlc(...args: any[]): any;
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
  }