@topogram/cli 0.3.72 → 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/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 -0
- package/src/cli/help.js +14 -2
- 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/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 +26 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { blockEntries } from "../validator.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* id: string|null,
|
|
8
|
+
* status: string|null,
|
|
9
|
+
* description: string|null,
|
|
10
|
+
* notes: string|null,
|
|
11
|
+
* outcome: string|null,
|
|
12
|
+
* raw: string[],
|
|
13
|
+
* loc: any
|
|
14
|
+
* }} PlanStep
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {any} token
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
function tokenValue(token) {
|
|
22
|
+
return token && (token.type === "symbol" || token.type === "string") ? token.value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {any[]} items
|
|
27
|
+
* @param {string} key
|
|
28
|
+
* @returns {string|null}
|
|
29
|
+
*/
|
|
30
|
+
function keyedValue(items, key) {
|
|
31
|
+
for (let index = 2; index < items.length - 1; index += 2) {
|
|
32
|
+
if (tokenValue(items[index]) === key) {
|
|
33
|
+
return tokenValue(items[index + 1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {any} entry
|
|
41
|
+
* @returns {PlanStep}
|
|
42
|
+
*/
|
|
43
|
+
export function parsePlanStepEntry(entry) {
|
|
44
|
+
const items = entry?.items || [];
|
|
45
|
+
return {
|
|
46
|
+
id: tokenValue(items[1]),
|
|
47
|
+
status: keyedValue(items, "status"),
|
|
48
|
+
description: keyedValue(items, "description"),
|
|
49
|
+
notes: keyedValue(items, "notes"),
|
|
50
|
+
outcome: keyedValue(items, "outcome"),
|
|
51
|
+
raw: items.map(/** @param {any} item */ (item) => tokenValue(item)).filter(/** @param {string|null} value */ (value) => value !== null),
|
|
52
|
+
loc: entry?.loc || null
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {any} value
|
|
58
|
+
* @returns {PlanStep[]}
|
|
59
|
+
*/
|
|
60
|
+
export function parsePlanSteps(value) {
|
|
61
|
+
return blockEntries(value).map((entry) => parsePlanStepEntry(entry));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} planId
|
|
66
|
+
* @param {string} stepId
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function planStepHistoryId(planId, stepId) {
|
|
70
|
+
return `${planId}#${stepId}`;
|
|
71
|
+
}
|
package/src/sdlc/plan.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { parsePath } from "../parser.js";
|
|
7
|
+
import { resolveWorkspace } from "../resolver.js";
|
|
8
|
+
import { PLAN_STEP_STATUSES } from "../validator/kinds.js";
|
|
9
|
+
import { appendTransition, lastTransition, readHistory } from "./history.js";
|
|
10
|
+
import { sdlcRootForSdlc } from "./paths.js";
|
|
11
|
+
import { parsePlanStepEntry, planStepHistoryId } from "./plan-steps.js";
|
|
12
|
+
|
|
13
|
+
/** @type {Record<string, string[]>} */
|
|
14
|
+
const STEP_TRANSITIONS = {
|
|
15
|
+
pending: ["in-progress", "blocked", "done", "skipped"],
|
|
16
|
+
"in-progress": ["done", "blocked", "pending", "skipped"],
|
|
17
|
+
blocked: ["pending", "in-progress", "skipped"],
|
|
18
|
+
done: [],
|
|
19
|
+
skipped: []
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} slug
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function humanize(slug) {
|
|
27
|
+
return slug
|
|
28
|
+
.replace(/[_-]+/g, " ")
|
|
29
|
+
.replace(/\b\w/g, (m) => m.toUpperCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {any} ast
|
|
34
|
+
* @param {string} id
|
|
35
|
+
* @returns {{file: any, statement: any}|null}
|
|
36
|
+
*/
|
|
37
|
+
function findAstStatement(ast, id) {
|
|
38
|
+
for (const file of ast.files) {
|
|
39
|
+
for (const statement of file.statements) {
|
|
40
|
+
if (statement.id === id) {
|
|
41
|
+
return { file, statement };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {any} statement
|
|
50
|
+
* @param {string} stepId
|
|
51
|
+
* @returns {{entry: any, parsed: import("./plan-steps.js").PlanStep, statusToken: any}|null}
|
|
52
|
+
*/
|
|
53
|
+
function findStepEntry(statement, stepId) {
|
|
54
|
+
const stepsField = statement.fields.find(/** @param {any} field */ (field) => field.key === "steps");
|
|
55
|
+
if (!stepsField || stepsField.value.type !== "block") return null;
|
|
56
|
+
for (const entry of stepsField.value.entries) {
|
|
57
|
+
const parsed = parsePlanStepEntry(entry);
|
|
58
|
+
if (parsed.id !== stepId) continue;
|
|
59
|
+
for (let index = 2; index < entry.items.length - 1; index += 2) {
|
|
60
|
+
if (entry.items[index]?.type === "symbol" && entry.items[index].value === "status") {
|
|
61
|
+
return { entry, parsed, statusToken: entry.items[index + 1] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { entry, parsed, statusToken: null };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} source
|
|
71
|
+
* @param {any} token
|
|
72
|
+
* @param {string} status
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function rewriteToken(source, token, status) {
|
|
76
|
+
return source.slice(0, token.loc.start.offset) + status + source.slice(token.loc.end.offset);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} workspaceRoot
|
|
81
|
+
* @param {string} taskId
|
|
82
|
+
* @param {string} slug
|
|
83
|
+
* @param {{write?: boolean}} [options]
|
|
84
|
+
* @returns {any}
|
|
85
|
+
*/
|
|
86
|
+
export function createPlan(workspaceRoot, taskId, slug, options = {}) {
|
|
87
|
+
if (!taskId || !/^task_[a-z][a-z0-9_]*$/.test(taskId)) {
|
|
88
|
+
return { ok: false, error: `Invalid task id '${taskId}'` };
|
|
89
|
+
}
|
|
90
|
+
if (!slug || !/^[a-z][a-z0-9_]*$/.test(slug)) {
|
|
91
|
+
return { ok: false, error: `Invalid slug '${slug}' — must match /^[a-z][a-z0-9_]*$/` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ast = parsePath(workspaceRoot);
|
|
95
|
+
const resolved = resolveWorkspace(ast);
|
|
96
|
+
if (!resolved.ok) {
|
|
97
|
+
return { ok: false, error: "workspace failed validation; cannot create plan", validation: resolved.validation };
|
|
98
|
+
}
|
|
99
|
+
const task = resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === taskId && statement.kind === "task");
|
|
100
|
+
if (!task) {
|
|
101
|
+
return { ok: false, error: `Task '${taskId}' not found` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const planId = `plan_${slug}`;
|
|
105
|
+
if (resolved.graph.statements.some(/** @param {any} statement */ (statement) => statement.id === planId)) {
|
|
106
|
+
return { ok: false, error: `Statement '${planId}' already exists` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const targetDir = path.join(sdlcRootForSdlc(workspaceRoot), "plans");
|
|
110
|
+
const targetFile = path.join(targetDir, `${slug}.tg`);
|
|
111
|
+
const content = `plan ${planId} {
|
|
112
|
+
name "${humanize(slug)}"
|
|
113
|
+
description "Implementation plan for ${taskId}."
|
|
114
|
+
task ${taskId}
|
|
115
|
+
priority medium
|
|
116
|
+
notes "Record approach notes here."
|
|
117
|
+
outcome "Record what worked, what did not, and what to repeat later."
|
|
118
|
+
steps {
|
|
119
|
+
step inspect_current_state status pending description "Inspect current behavior and constraints."
|
|
120
|
+
step implement_changes status pending description "Implement the planned changes."
|
|
121
|
+
step verify_results status pending description "Run focused checks and record verification."
|
|
122
|
+
}
|
|
123
|
+
status draft
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
if (!options.write) {
|
|
128
|
+
return { ok: true, dryRun: true, id: planId, task: taskId, file: targetFile, content };
|
|
129
|
+
}
|
|
130
|
+
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
|
131
|
+
if (fs.existsSync(targetFile)) {
|
|
132
|
+
return { ok: false, error: `Refusing to overwrite '${targetFile}'` };
|
|
133
|
+
}
|
|
134
|
+
fs.writeFileSync(targetFile, content, "utf8");
|
|
135
|
+
return { ok: true, dryRun: false, id: planId, task: taskId, file: targetFile };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} workspaceRoot
|
|
140
|
+
* @param {string} planId
|
|
141
|
+
* @returns {any}
|
|
142
|
+
*/
|
|
143
|
+
export function explainPlan(workspaceRoot, planId) {
|
|
144
|
+
const ast = parsePath(workspaceRoot);
|
|
145
|
+
const resolved = resolveWorkspace(ast);
|
|
146
|
+
if (!resolved.ok) {
|
|
147
|
+
return { ok: false, error: "workspace failed validation; cannot explain plan", validation: resolved.validation };
|
|
148
|
+
}
|
|
149
|
+
const plan = resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === planId && statement.kind === "plan");
|
|
150
|
+
if (!plan) {
|
|
151
|
+
return { ok: false, error: `Plan '${planId}' not found` };
|
|
152
|
+
}
|
|
153
|
+
const history = readHistory(workspaceRoot);
|
|
154
|
+
const steps = (plan.steps || []).map(/** @param {any} step */ (step) => ({
|
|
155
|
+
...step,
|
|
156
|
+
last_transition: lastTransition(history, planStepHistoryId(plan.id, step.id))
|
|
157
|
+
}));
|
|
158
|
+
const nextStep = steps.find(/** @param {any} step */ (step) => step.status !== "done" && step.status !== "skipped") || null;
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
type: "sdlc_plan_explain",
|
|
162
|
+
id: plan.id,
|
|
163
|
+
status: plan.status,
|
|
164
|
+
task: plan.task?.id || null,
|
|
165
|
+
summary: {
|
|
166
|
+
name: plan.name,
|
|
167
|
+
description: plan.description,
|
|
168
|
+
notes: plan.notes || null,
|
|
169
|
+
outcome: plan.outcome || null
|
|
170
|
+
},
|
|
171
|
+
steps,
|
|
172
|
+
next_step: nextStep,
|
|
173
|
+
next_action: nextStep
|
|
174
|
+
? { kind: "work", step: nextStep.id, reason: `next incomplete step is '${nextStep.id}'` }
|
|
175
|
+
: { kind: "none", reason: "all steps are done or skipped" }
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} workspaceRoot
|
|
181
|
+
* @param {string} planId
|
|
182
|
+
* @param {string} stepId
|
|
183
|
+
* @param {string} targetStatus
|
|
184
|
+
* @param {{write?: boolean, dryRun?: boolean, actor?: string|null, note?: string|null}} [options]
|
|
185
|
+
* @returns {any}
|
|
186
|
+
*/
|
|
187
|
+
export function transitionPlanStep(workspaceRoot, planId, stepId, targetStatus, options = {}) {
|
|
188
|
+
if (!PLAN_STEP_STATUSES.has(targetStatus)) {
|
|
189
|
+
return { ok: false, error: `Invalid plan step status '${targetStatus}'` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ast = parsePath(workspaceRoot);
|
|
193
|
+
const resolved = resolveWorkspace(ast);
|
|
194
|
+
if (!resolved.ok) {
|
|
195
|
+
return { ok: false, error: "workspace failed validation; cannot transition plan step", validation: resolved.validation };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const located = findAstStatement(ast, planId);
|
|
199
|
+
if (!located || located.statement.kind !== "plan") {
|
|
200
|
+
return { ok: false, error: `Plan '${planId}' not found` };
|
|
201
|
+
}
|
|
202
|
+
const step = findStepEntry(located.statement, stepId);
|
|
203
|
+
if (!step) {
|
|
204
|
+
return { ok: false, error: `Step '${stepId}' not found on plan '${planId}'` };
|
|
205
|
+
}
|
|
206
|
+
if (!step.statusToken) {
|
|
207
|
+
return { ok: false, error: `Step '${stepId}' on plan '${planId}' has no status field to rewrite` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fromStatus = step.parsed.status;
|
|
211
|
+
const allowed = STEP_TRANSITIONS[fromStatus || ""];
|
|
212
|
+
if (!allowed) {
|
|
213
|
+
return { ok: false, error: `Unknown plan step status '${fromStatus}'` };
|
|
214
|
+
}
|
|
215
|
+
if (!allowed.includes(targetStatus)) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `Plan step cannot transition from '${fromStatus}' to '${targetStatus}' — allowed: ${allowed.join(", ") || "(terminal)"}`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sourcePath = located.statement.loc.file;
|
|
223
|
+
const original = fs.readFileSync(sourcePath, "utf8");
|
|
224
|
+
const rewritten = rewriteToken(original, step.statusToken, targetStatus);
|
|
225
|
+
const shouldWrite = options.write === true && options.dryRun !== true;
|
|
226
|
+
if (shouldWrite) {
|
|
227
|
+
fs.writeFileSync(sourcePath, rewritten, "utf8");
|
|
228
|
+
appendTransition(workspaceRoot, planStepHistoryId(planId, stepId), {
|
|
229
|
+
from: fromStatus,
|
|
230
|
+
to: targetStatus,
|
|
231
|
+
by: options.actor || null,
|
|
232
|
+
note: options.note || null
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
id: planId,
|
|
239
|
+
step: stepId,
|
|
240
|
+
from: fromStatus,
|
|
241
|
+
to: targetStatus,
|
|
242
|
+
file: sourcePath,
|
|
243
|
+
dryRun: !shouldWrite
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { resolveWorkspaceContext } from "../workspace-paths.js";
|
|
7
|
+
|
|
8
|
+
export const SDLC_POLICY_FILE = "topogram.sdlc-policy.json";
|
|
9
|
+
export const SDLC_POLICY_VERSION = "1";
|
|
10
|
+
|
|
11
|
+
export const SDLC_POLICY_STATUSES = new Set(["adopted", "not_adopted"]);
|
|
12
|
+
export const SDLC_POLICY_MODES = new Set(["advisory", "enforced"]);
|
|
13
|
+
export const SDLC_POLICY_ITEM_KINDS = new Set(["task", "bug", "requirement", "pitch"]);
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PROTECTED_PATHS = [
|
|
16
|
+
"engine/**",
|
|
17
|
+
"docs/**",
|
|
18
|
+
"scripts/**",
|
|
19
|
+
".github/**",
|
|
20
|
+
"topo/**",
|
|
21
|
+
"AGENTS.md",
|
|
22
|
+
"CONTRIBUTING.md",
|
|
23
|
+
"README.md",
|
|
24
|
+
"package.json"
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_REQUIRED_ITEM_KINDS = ["task", "bug", "requirement", "pitch"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} SdlcPolicy
|
|
31
|
+
* @property {string} version
|
|
32
|
+
* @property {"adopted"|"not_adopted"} status
|
|
33
|
+
* @property {"advisory"|"enforced"} mode
|
|
34
|
+
* @property {string[]} protectedPaths
|
|
35
|
+
* @property {string[]} requiredItemKinds
|
|
36
|
+
* @property {boolean} allowExemptions
|
|
37
|
+
* @property {boolean} releaseNotes
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} SdlcPolicyInfo
|
|
42
|
+
* @property {boolean} exists
|
|
43
|
+
* @property {string} path
|
|
44
|
+
* @property {SdlcPolicy|null} policy
|
|
45
|
+
* @property {"adopted"|"not_adopted"} status
|
|
46
|
+
* @property {"advisory"|"enforced"|null} mode
|
|
47
|
+
* @property {Array<{ severity: "error"|"warning", message: string }>} diagnostics
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @returns {SdlcPolicy}
|
|
52
|
+
*/
|
|
53
|
+
export function defaultSdlcPolicy() {
|
|
54
|
+
return {
|
|
55
|
+
version: SDLC_POLICY_VERSION,
|
|
56
|
+
status: "adopted",
|
|
57
|
+
mode: "enforced",
|
|
58
|
+
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
59
|
+
requiredItemKinds: [...DEFAULT_REQUIRED_ITEM_KINDS],
|
|
60
|
+
allowExemptions: true,
|
|
61
|
+
releaseNotes: true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} inputPath
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function policyProjectRoot(inputPath = ".") {
|
|
70
|
+
return resolveWorkspaceContext(inputPath || ".").projectRoot;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} value
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function isSafeRelativePattern(value) {
|
|
78
|
+
const normalized = value.replace(/\\/g, "/").trim();
|
|
79
|
+
return Boolean(normalized) &&
|
|
80
|
+
!path.isAbsolute(normalized) &&
|
|
81
|
+
normalized !== ".." &&
|
|
82
|
+
!normalized.startsWith("../") &&
|
|
83
|
+
!normalized.includes("/../") &&
|
|
84
|
+
!normalized.endsWith("/..");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {unknown} value
|
|
89
|
+
* @returns {string[]}
|
|
90
|
+
*/
|
|
91
|
+
function stringArray(value) {
|
|
92
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @returns {{ ok: boolean, policy: SdlcPolicy|null, diagnostics: SdlcPolicyInfo["diagnostics"] }}
|
|
98
|
+
*/
|
|
99
|
+
export function validateSdlcPolicy(value) {
|
|
100
|
+
/** @type {SdlcPolicyInfo["diagnostics"]} */
|
|
101
|
+
const diagnostics = [];
|
|
102
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
policy: null,
|
|
106
|
+
diagnostics: [{ severity: "error", message: `${SDLC_POLICY_FILE} must contain a JSON object.` }]
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
111
|
+
if (record.version !== SDLC_POLICY_VERSION) {
|
|
112
|
+
diagnostics.push({ severity: "error", message: `SDLC policy version must be "${SDLC_POLICY_VERSION}".` });
|
|
113
|
+
}
|
|
114
|
+
if (typeof record.status !== "string" || !SDLC_POLICY_STATUSES.has(record.status)) {
|
|
115
|
+
diagnostics.push({ severity: "error", message: "SDLC policy status must be adopted or not_adopted." });
|
|
116
|
+
}
|
|
117
|
+
if (typeof record.mode !== "string" || !SDLC_POLICY_MODES.has(record.mode)) {
|
|
118
|
+
diagnostics.push({ severity: "error", message: "SDLC policy mode must be advisory or enforced." });
|
|
119
|
+
}
|
|
120
|
+
if (record.status === "not_adopted" && record.mode === "enforced") {
|
|
121
|
+
diagnostics.push({ severity: "error", message: "SDLC policy cannot be not_adopted and enforced." });
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(record.protectedPaths) || record.protectedPaths.length === 0) {
|
|
124
|
+
diagnostics.push({ severity: "error", message: "SDLC policy protectedPaths must be a non-empty string array." });
|
|
125
|
+
} else {
|
|
126
|
+
for (const item of record.protectedPaths) {
|
|
127
|
+
if (typeof item !== "string" || !isSafeRelativePattern(item)) {
|
|
128
|
+
diagnostics.push({ severity: "error", message: `Invalid protected path '${String(item)}'. Paths must be relative and must not escape the project root.` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(record.requiredItemKinds) || record.requiredItemKinds.length === 0) {
|
|
133
|
+
diagnostics.push({ severity: "error", message: "SDLC policy requiredItemKinds must be a non-empty string array." });
|
|
134
|
+
} else {
|
|
135
|
+
for (const item of record.requiredItemKinds) {
|
|
136
|
+
if (typeof item !== "string" || !SDLC_POLICY_ITEM_KINDS.has(item)) {
|
|
137
|
+
diagnostics.push({ severity: "error", message: `Invalid required SDLC item kind '${String(item)}'.` });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (typeof record.allowExemptions !== "boolean") {
|
|
142
|
+
diagnostics.push({ severity: "error", message: "SDLC policy allowExemptions must be a boolean." });
|
|
143
|
+
}
|
|
144
|
+
if (typeof record.releaseNotes !== "boolean") {
|
|
145
|
+
diagnostics.push({ severity: "error", message: "SDLC policy releaseNotes must be a boolean." });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ok = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
149
|
+
if (!ok) {
|
|
150
|
+
return { ok, policy: null, diagnostics };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok,
|
|
155
|
+
policy: {
|
|
156
|
+
version: String(record.version),
|
|
157
|
+
status: /** @type {"adopted"|"not_adopted"} */ (record.status),
|
|
158
|
+
mode: /** @type {"advisory"|"enforced"} */ (record.mode),
|
|
159
|
+
protectedPaths: stringArray(record.protectedPaths),
|
|
160
|
+
requiredItemKinds: stringArray(record.requiredItemKinds),
|
|
161
|
+
allowExemptions: Boolean(record.allowExemptions),
|
|
162
|
+
releaseNotes: Boolean(record.releaseNotes)
|
|
163
|
+
},
|
|
164
|
+
diagnostics
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} projectRoot
|
|
170
|
+
* @returns {SdlcPolicyInfo}
|
|
171
|
+
*/
|
|
172
|
+
export function loadSdlcPolicy(projectRoot) {
|
|
173
|
+
const policyPath = path.join(projectRoot, SDLC_POLICY_FILE);
|
|
174
|
+
if (!fs.existsSync(policyPath)) {
|
|
175
|
+
return {
|
|
176
|
+
exists: false,
|
|
177
|
+
path: policyPath,
|
|
178
|
+
policy: null,
|
|
179
|
+
status: "not_adopted",
|
|
180
|
+
mode: null,
|
|
181
|
+
diagnostics: []
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(fs.readFileSync(policyPath, "utf8"));
|
|
186
|
+
const validation = validateSdlcPolicy(parsed);
|
|
187
|
+
return {
|
|
188
|
+
exists: true,
|
|
189
|
+
path: policyPath,
|
|
190
|
+
policy: validation.policy,
|
|
191
|
+
status: validation.policy?.status || "not_adopted",
|
|
192
|
+
mode: validation.policy?.mode || null,
|
|
193
|
+
diagnostics: validation.diagnostics
|
|
194
|
+
};
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return {
|
|
197
|
+
exists: true,
|
|
198
|
+
path: policyPath,
|
|
199
|
+
policy: null,
|
|
200
|
+
status: "not_adopted",
|
|
201
|
+
mode: null,
|
|
202
|
+
diagnostics: [{ severity: "error", message: `Could not read ${SDLC_POLICY_FILE}: ${error instanceof Error ? error.message : String(error)}` }]
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} projectRoot
|
|
209
|
+
* @returns {{ ok: boolean, file: string, policy: SdlcPolicy }}
|
|
210
|
+
*/
|
|
211
|
+
export function writeDefaultSdlcPolicy(projectRoot) {
|
|
212
|
+
const file = path.join(projectRoot, SDLC_POLICY_FILE);
|
|
213
|
+
if (fs.existsSync(file)) {
|
|
214
|
+
throw new Error(`${SDLC_POLICY_FILE} already exists.`);
|
|
215
|
+
}
|
|
216
|
+
const policy = defaultSdlcPolicy();
|
|
217
|
+
fs.writeFileSync(file, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
|
|
218
|
+
return { ok: true, file, policy };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {string} projectRoot
|
|
223
|
+
* @returns {Record<string, any>}
|
|
224
|
+
*/
|
|
225
|
+
export function explainSdlcPolicy(projectRoot) {
|
|
226
|
+
const info = loadSdlcPolicy(projectRoot);
|
|
227
|
+
return {
|
|
228
|
+
type: "sdlc_policy_explain",
|
|
229
|
+
version: "1",
|
|
230
|
+
ok: info.diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
|
|
231
|
+
policy: {
|
|
232
|
+
exists: info.exists,
|
|
233
|
+
path: info.path,
|
|
234
|
+
status: info.status,
|
|
235
|
+
mode: info.mode,
|
|
236
|
+
protectedPaths: info.policy?.protectedPaths || [],
|
|
237
|
+
requiredItemKinds: info.policy?.requiredItemKinds || [],
|
|
238
|
+
allowExemptions: info.policy?.allowExemptions ?? false,
|
|
239
|
+
releaseNotes: info.policy?.releaseNotes ?? false
|
|
240
|
+
},
|
|
241
|
+
diagnostics: info.diagnostics,
|
|
242
|
+
enforcement: info.status === "adopted"
|
|
243
|
+
? (info.mode === "enforced" ? "Protected changes require SDLC linkage or an allowed exemption." : "Gaps are reported without failing.")
|
|
244
|
+
: "Project has not adopted enforced SDLC.",
|
|
245
|
+
nextCommands: info.exists
|
|
246
|
+
? ["topogram sdlc policy check --json", "topogram sdlc gate . --require-adopted --json"]
|
|
247
|
+
: ["topogram sdlc policy init .", "topogram sdlc policy check --json"]
|
|
248
|
+
};
|
|
249
|
+
}
|