@brainervirus/workit-core 0.6.1 → 0.7.1
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/package.json +3 -7
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +19 -21
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +30 -16
- package/src/core/config.ts +193 -31
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +11 -9
- package/src/core/docs-validate.ts +18 -6
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -141
- package/src/core/git.ts +22 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
- package/src/core/hygiene.ts +26 -12
- package/src/core/init.ts +43 -11
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +1 -1
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +116 -21
- package/src/core/registration.ts +215 -0
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tools.ts +23 -0
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -44
- package/src/core/sdd.ts +45 -28
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +2 -2
- package/src/core/vcs-config.ts +107 -37
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +125 -67
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +4 -3
- package/scripts/_shared/common.sh +0 -173
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -51
- package/src/tools/flow.ts +0 -99
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -49
- package/src/tools/repo.ts +0 -490
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -216
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -423
package/src/core/branch.ts
CHANGED
|
@@ -3,13 +3,23 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { gitContext } from "./git";
|
|
5
5
|
import { readConfig, resolveBranchPolicy } from "./config";
|
|
6
|
+
import { resolveWorkspace } from "./workspaces";
|
|
6
7
|
import { vcsConfig } from "./vcs-config";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/** CA-09: the one policy resolver every consumer calls. */
|
|
10
|
+
export const resolveBranchPolicyFor = (workspaceRoot: string) =>
|
|
11
|
+
resolveBranchPolicy(readConfig(), resolveWorkspace(workspaceRoot));
|
|
12
|
+
|
|
13
|
+
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
14
|
+
const allowedBranch = (root: string, name: string) =>
|
|
15
|
+
policy(root).allowed.some((r) => r.test(name));
|
|
16
|
+
const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
|
|
17
|
+
// RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
|
|
18
|
+
const baseBranch = (cwd: string): { base: string } | { error: string } => {
|
|
19
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
20
|
+
if (resolved.ok === false) return { error: String(resolved.error) };
|
|
21
|
+
return { base: String(resolved.defaultTargetBranch ?? "develop") };
|
|
22
|
+
};
|
|
13
23
|
const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
|
|
14
24
|
const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
|
|
15
25
|
const readSafe = (p: string): string | null => {
|
|
@@ -20,10 +30,10 @@ const readSafe = (p: string): string | null => {
|
|
|
20
30
|
}
|
|
21
31
|
};
|
|
22
32
|
|
|
23
|
-
const normalizeBranch = (name: string): string | null => {
|
|
33
|
+
const normalizeBranch = (root: string, name: string): string | null => {
|
|
24
34
|
const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
|
|
25
|
-
if (isProtected(n)) return null;
|
|
26
|
-
if (!allowedBranch(n)) return null;
|
|
35
|
+
if (isProtected(root, n)) return null;
|
|
36
|
+
if (!allowedBranch(root, n)) return null;
|
|
27
37
|
const parts = n
|
|
28
38
|
.toLowerCase()
|
|
29
39
|
.split("/")
|
|
@@ -94,14 +104,14 @@ export const resolveBranch = ({
|
|
|
94
104
|
const text = readSafe(file);
|
|
95
105
|
if (!text) continue;
|
|
96
106
|
if (USE_CURRENT_RE.test(text)) {
|
|
97
|
-
if (!current || !allowedBranch(current) || isProtected(current)) {
|
|
107
|
+
if (!current || !allowedBranch(cwd, current) || isProtected(cwd, current)) {
|
|
98
108
|
return { error: `use-current but HEAD ${current} is not an allowed branch` };
|
|
99
109
|
}
|
|
100
110
|
return finish(current, "use-current");
|
|
101
111
|
}
|
|
102
112
|
}
|
|
103
113
|
|
|
104
|
-
if (current && allowedBranch(current) && !isProtected(current))
|
|
114
|
+
if (current && allowedBranch(cwd, current) && !isProtected(cwd, current))
|
|
105
115
|
return finish(current, "keep-current");
|
|
106
116
|
|
|
107
117
|
let declaredButInvalid: string | null = null;
|
|
@@ -109,7 +119,7 @@ export const resolveBranch = ({
|
|
|
109
119
|
const text = readSafe(file);
|
|
110
120
|
if (!text) continue;
|
|
111
121
|
for (const match of text.matchAll(DECLARE_RE)) {
|
|
112
|
-
const normalized = normalizeBranch(match[1]);
|
|
122
|
+
const normalized = normalizeBranch(cwd, match[1]);
|
|
113
123
|
if (normalized) return finish(normalized, file === spec ? "spec" : "plan");
|
|
114
124
|
declaredButInvalid ??= match[1];
|
|
115
125
|
}
|
|
@@ -140,7 +150,9 @@ export const docsBranch = ({
|
|
|
140
150
|
const git = gitContext(cwd);
|
|
141
151
|
const current = git.branch;
|
|
142
152
|
const kindArg = (kind ?? "feature").toLowerCase();
|
|
143
|
-
const
|
|
153
|
+
const baseResolved = baseBranch(cwd);
|
|
154
|
+
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
155
|
+
const base = baseResolved.base;
|
|
144
156
|
|
|
145
157
|
if (current === base || current === "main" || current === "master" || current === "develop") {
|
|
146
158
|
let slug = "";
|
|
@@ -162,7 +174,7 @@ export const docsBranch = ({
|
|
|
162
174
|
dirty: Boolean(git.status_short.trim()),
|
|
163
175
|
};
|
|
164
176
|
}
|
|
165
|
-
if (current && allowedBranch(current) && !isProtected(current)) {
|
|
177
|
+
if (current && allowedBranch(cwd, current) && !isProtected(cwd, current)) {
|
|
166
178
|
return {
|
|
167
179
|
branch: current,
|
|
168
180
|
action: "keep",
|
|
@@ -280,8 +292,8 @@ export const branchSetup = ({
|
|
|
280
292
|
|
|
281
293
|
const target = target_branch ?? "";
|
|
282
294
|
if (!target) return { error: "target branch required" };
|
|
283
|
-
if (isProtected(target)) return { error: `protected branch ${target}` };
|
|
284
|
-
if (!allowedBranch(target))
|
|
295
|
+
if (isProtected(cwd, target)) return { error: `protected branch ${target}` };
|
|
296
|
+
if (!allowedBranch(cwd, target))
|
|
285
297
|
return { error: `target branch ${target} is not allowed by the branch policy` };
|
|
286
298
|
|
|
287
299
|
let stash_ref: string | undefined;
|
|
@@ -311,7 +323,9 @@ export const branchSetup = ({
|
|
|
311
323
|
};
|
|
312
324
|
}
|
|
313
325
|
try {
|
|
314
|
-
const
|
|
326
|
+
const baseResolved = baseBranch(cwd);
|
|
327
|
+
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
328
|
+
const baseResult = ensureBaseBranch(cwd, baseResolved.base);
|
|
315
329
|
if (!baseResult.ok) return { error: baseResult.error };
|
|
316
330
|
exec(["checkout", "-b", target]);
|
|
317
331
|
} catch (createError) {
|
package/src/core/config.ts
CHANGED
|
@@ -9,6 +9,16 @@ import {
|
|
|
9
9
|
} from "node:fs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import type { Logger } from "./logger";
|
|
13
|
+
import { EVENT, errorDetail } from "./boundary";
|
|
14
|
+
|
|
15
|
+
// Optional diagnostic seam: adapters install their host logger so config
|
|
16
|
+
// migration and provenance events land in the same sanitized log stream.
|
|
17
|
+
let diagnosticLogger: Logger | undefined;
|
|
18
|
+
export const setDiagnosticLogger = (logger: Logger | undefined): void => {
|
|
19
|
+
diagnosticLogger = logger;
|
|
20
|
+
};
|
|
21
|
+
export const getDiagnosticLogger = (): Logger | undefined => diagnosticLogger;
|
|
12
22
|
|
|
13
23
|
export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
|
|
14
24
|
|
|
@@ -29,6 +39,28 @@ export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: strin
|
|
|
29
39
|
custom: { allowed: [], protected: [] },
|
|
30
40
|
};
|
|
31
41
|
|
|
42
|
+
// RL-02: one shared preset merge helper. Changing the preset resets every
|
|
43
|
+
// derived policy field from PRESETS; only `custom` carries explicit values
|
|
44
|
+
// (falling back to the current policy when none are given).
|
|
45
|
+
export const mergePreset = (
|
|
46
|
+
preset: BranchPreset,
|
|
47
|
+
input: { allowed?: string[]; protectedNames?: string[] } = {},
|
|
48
|
+
current: { branchPolicy: ToolkitConfig["branchPolicy"] } = {
|
|
49
|
+
branchPolicy: { preset, allowed: [], protected: [] },
|
|
50
|
+
},
|
|
51
|
+
): ToolkitConfig["branchPolicy"] => {
|
|
52
|
+
const defs = PRESETS[preset];
|
|
53
|
+
return {
|
|
54
|
+
preset,
|
|
55
|
+
allowed:
|
|
56
|
+
preset === "custom" ? (input.allowed ?? current.branchPolicy.allowed) : [...defs.allowed],
|
|
57
|
+
protected:
|
|
58
|
+
preset === "custom"
|
|
59
|
+
? (input.protectedNames ?? current.branchPolicy.protected)
|
|
60
|
+
: [...defs.protected],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
32
64
|
export const resolveConfigDir = (): string =>
|
|
33
65
|
process.env.WORKFLOW_TOOLKIT_CONFIG ??
|
|
34
66
|
process.env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
|
|
@@ -66,6 +98,7 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
66
98
|
}
|
|
67
99
|
migrationFailed = false;
|
|
68
100
|
mkdirSync(dir, { recursive: true });
|
|
101
|
+
diagnosticLogger?.info(EVENT.migration, { from: legacy, to: dir });
|
|
69
102
|
for (const entry of readdirSync(legacy, { withFileTypes: true })) {
|
|
70
103
|
const src = path.join(legacy, entry.name);
|
|
71
104
|
const dest = path.join(dir, entry.name);
|
|
@@ -75,7 +108,12 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
75
108
|
else if (entry.isFile()) copyFileSync(src, dest);
|
|
76
109
|
} catch (err) {
|
|
77
110
|
migrationFailed = true;
|
|
78
|
-
|
|
111
|
+
diagnosticLogger?.warn(EVENT.migration, { from: src, ok: false, ...errorDetail(err) });
|
|
112
|
+
// Task 10 advisory: never leak the raw legacy source path to the terminal —
|
|
113
|
+
// the structured warn above carries it through the sanitized logger.
|
|
114
|
+
console.warn(
|
|
115
|
+
`[workit] config migration: a file could not be copied to ${dir}; it will be retried on the next run`,
|
|
116
|
+
);
|
|
79
117
|
}
|
|
80
118
|
}
|
|
81
119
|
if (!migrationFailed) migratedDir = dir;
|
|
@@ -105,49 +143,173 @@ const readSafe = (p: string): string | null => {
|
|
|
105
143
|
}
|
|
106
144
|
};
|
|
107
145
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
// RL-01: typed reader contract. Every config reader distinguishes missing from
|
|
147
|
+
// valid from malformed and reports the exact file path; risky consumers stop on
|
|
148
|
+
// malformed instead of silently falling back to defaults.
|
|
149
|
+
export type ReaderStatus = "missing" | "valid" | "malformed";
|
|
150
|
+
|
|
151
|
+
export type ReaderResult<T> = {
|
|
152
|
+
status: ReaderStatus;
|
|
153
|
+
path: string;
|
|
154
|
+
config?: T;
|
|
155
|
+
error?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// AR-07/CA-37: the one shared fail-closed shape rule for every object-config
|
|
159
|
+
// reader (config, setup-state, workspaces, doctor). A parseable non-object
|
|
160
|
+
// (null, scalar, array) is malformed, never defaults.
|
|
161
|
+
export const isConfigObject = (value: unknown): boolean =>
|
|
162
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
163
|
+
|
|
164
|
+
const parseConfigResult = (raw: string | null, file: string): ReaderResult<ToolkitConfig> => {
|
|
165
|
+
if (raw === null) return { status: "missing", path: file };
|
|
166
|
+
let parsed: unknown;
|
|
111
167
|
try {
|
|
112
|
-
|
|
113
|
-
const locale = LOCALE_RE.test(String(parsed.locale ?? ""))
|
|
114
|
-
? (parsed.locale as string)
|
|
115
|
-
: DEFAULTS.locale;
|
|
116
|
-
const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
|
|
117
|
-
const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
|
|
118
|
-
const presetDefs = PRESETS[presetOk];
|
|
119
|
-
return {
|
|
120
|
-
locale,
|
|
121
|
-
localeOptions: Array.isArray(parsed.localeOptions)
|
|
122
|
-
? parsed.localeOptions
|
|
123
|
-
: DEFAULTS.localeOptions,
|
|
124
|
-
timezone: parsed.timezone ?? DEFAULTS.timezone,
|
|
125
|
-
branchPolicy: {
|
|
126
|
-
preset: presetOk,
|
|
127
|
-
allowed: Array.isArray(parsed.branchPolicy?.allowed)
|
|
128
|
-
? parsed.branchPolicy.allowed
|
|
129
|
-
: presetDefs.allowed,
|
|
130
|
-
protected: Array.isArray(parsed.branchPolicy?.protected)
|
|
131
|
-
? parsed.branchPolicy.protected
|
|
132
|
-
: presetDefs.protected,
|
|
133
|
-
},
|
|
134
|
-
};
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
135
169
|
} catch {
|
|
136
|
-
return
|
|
170
|
+
return { status: "malformed", path: file, error: `${file} is not valid JSON` };
|
|
137
171
|
}
|
|
172
|
+
if (!isConfigObject(parsed)) {
|
|
173
|
+
return { status: "malformed", path: file, error: `${file} is not a JSON object` };
|
|
174
|
+
}
|
|
175
|
+
const input = parsed as Partial<ToolkitConfig>;
|
|
176
|
+
const locale = LOCALE_RE.test(String(input.locale ?? ""))
|
|
177
|
+
? (input.locale as string)
|
|
178
|
+
: DEFAULTS.locale;
|
|
179
|
+
const preset = (
|
|
180
|
+
Object.hasOwn(PRESETS, input.branchPolicy?.preset as string)
|
|
181
|
+
? input.branchPolicy?.preset
|
|
182
|
+
: "gitflow"
|
|
183
|
+
) as BranchPreset;
|
|
184
|
+
return {
|
|
185
|
+
status: "valid",
|
|
186
|
+
path: file,
|
|
187
|
+
config: {
|
|
188
|
+
locale,
|
|
189
|
+
localeOptions: Array.isArray(input.localeOptions)
|
|
190
|
+
? input.localeOptions
|
|
191
|
+
: DEFAULTS.localeOptions,
|
|
192
|
+
timezone: input.timezone ?? DEFAULTS.timezone,
|
|
193
|
+
// RL-02/CA-23: the persisted preset is authoritative; derived allowed /
|
|
194
|
+
// protected fields always reset from PRESETS via the one shared merge.
|
|
195
|
+
branchPolicy: mergePreset(
|
|
196
|
+
preset,
|
|
197
|
+
{
|
|
198
|
+
allowed: Array.isArray(input.branchPolicy?.allowed)
|
|
199
|
+
? input.branchPolicy.allowed
|
|
200
|
+
: undefined,
|
|
201
|
+
protectedNames: Array.isArray(input.branchPolicy?.protected)
|
|
202
|
+
? input.branchPolicy.protected
|
|
203
|
+
: undefined,
|
|
204
|
+
},
|
|
205
|
+
DEFAULTS,
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
|
|
212
|
+
const file = path.join(dir ?? configDir(), "config.json");
|
|
213
|
+
return parseConfigResult(readSafe(file), file);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// RL-01: no silent fallback on malformed config — every consumer gets an
|
|
217
|
+
// exact-path diagnostic instead of defaults. Missing config still defaults.
|
|
218
|
+
export const readConfig = (): ToolkitConfig => {
|
|
219
|
+
const result = readConfigTyped();
|
|
220
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
221
|
+
return result.config ?? DEFAULTS;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const readConfigFromDir = (dir: string): ToolkitConfig => {
|
|
225
|
+
const result = readConfigTyped(dir);
|
|
226
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
227
|
+
return result.config ?? DEFAULTS;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export type ConfigInput = {
|
|
231
|
+
locale?: string;
|
|
232
|
+
localeOptions?: string[];
|
|
233
|
+
timezone?: string;
|
|
234
|
+
preset?: BranchPreset;
|
|
235
|
+
allowed?: string[];
|
|
236
|
+
protectedNames?: string[];
|
|
138
237
|
};
|
|
139
238
|
|
|
239
|
+
// RL-02: the single authoritative ToolkitConfig merge. CLI, OpenCode, and Cursor
|
|
240
|
+
// adapters all route their `config` writes through this so a preset switch
|
|
241
|
+
// resets every derived policy field identically everywhere.
|
|
242
|
+
export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
|
|
243
|
+
locale: input.locale ?? current.locale,
|
|
244
|
+
localeOptions: input.localeOptions ?? current.localeOptions,
|
|
245
|
+
timezone: input.timezone ?? current.timezone,
|
|
246
|
+
branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
|
|
247
|
+
});
|
|
248
|
+
|
|
140
249
|
export const writeConfig = (config: ToolkitConfig): void => {
|
|
141
250
|
const dir = configDir();
|
|
142
251
|
mkdirSync(dir, { recursive: true });
|
|
143
252
|
writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
144
253
|
};
|
|
145
254
|
|
|
255
|
+
// Configuration provenance for startup diagnostics: where config.json came from
|
|
256
|
+
// and whether it parsed. Only paths + a malformed flag — never the file body.
|
|
257
|
+
export const describeConfigSource = (
|
|
258
|
+
dir: string = resolveConfigDir(),
|
|
259
|
+
): { source: string; config_dir: string; malformed: boolean } => {
|
|
260
|
+
const file = path.join(dir, "config.json");
|
|
261
|
+
if (!existsSync(file)) return { source: "defaults", config_dir: dir, malformed: false };
|
|
262
|
+
const raw = readSafe(file);
|
|
263
|
+
if (raw === null) return { source: "unreadable", config_dir: dir, malformed: true };
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(raw);
|
|
266
|
+
if (!isConfigObject(parsed)) {
|
|
267
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
268
|
+
}
|
|
269
|
+
return { source: "file", config_dir: dir, malformed: false };
|
|
270
|
+
} catch {
|
|
271
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
146
275
|
export const resolveBranchPolicy = (
|
|
147
276
|
config: ToolkitConfig,
|
|
148
|
-
|
|
149
|
-
|
|
277
|
+
workspace?: { branchPolicy?: Record<string, any> } | null,
|
|
278
|
+
): {
|
|
279
|
+
preset: BranchPreset;
|
|
280
|
+
allowed: RegExp[];
|
|
281
|
+
protected: Set<string>;
|
|
282
|
+
integration: "pr" | "merge";
|
|
283
|
+
defaultTargetBranch: string;
|
|
284
|
+
} => {
|
|
285
|
+
const wp = (workspace?.branchPolicy ?? {}) as Record<string, any>;
|
|
286
|
+
// An invalid workspace preset (e.g. a typo) falls back to the global preset,
|
|
287
|
+
// preserving resolution order workspace > global > preset, instead of
|
|
288
|
+
// crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
|
|
289
|
+
const preset = (
|
|
290
|
+
Object.hasOwn(PRESETS, wp.preset) ? wp.preset : (config.branchPolicy?.preset ?? "gitflow")
|
|
291
|
+
) as BranchPreset;
|
|
292
|
+
// RL-02: the preset is authoritative — allowed/protected re-derive from the
|
|
293
|
+
// workspace's own values or the preset table, never the global config's, and
|
|
294
|
+
// the current config remains the `custom` fallback when no values are given.
|
|
295
|
+
const merged = mergePreset(
|
|
296
|
+
preset,
|
|
297
|
+
{
|
|
298
|
+
allowed: wp.allowed,
|
|
299
|
+
protectedNames: wp.protected,
|
|
300
|
+
},
|
|
301
|
+
config,
|
|
302
|
+
);
|
|
303
|
+
const allowed = merged.allowed.map(
|
|
150
304
|
(p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"),
|
|
151
305
|
);
|
|
152
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
preset,
|
|
308
|
+
allowed,
|
|
309
|
+
protected: new Set(merged.protected.map((p) => p.toLowerCase())),
|
|
310
|
+
integration: wp.integration === "merge" ? "merge" : "pr",
|
|
311
|
+
// CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
|
|
312
|
+
defaultTargetBranch:
|
|
313
|
+
preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
|
|
314
|
+
};
|
|
153
315
|
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// One canonical document path contract (DC-01, DC-02, DC-04, DC-14): workspace
|
|
5
|
+
// root, slug, and spec/plan pair resolution all funnel through here so every
|
|
6
|
+
// document/flow/SDD consumer on both hosts enforces the same containment rules.
|
|
7
|
+
|
|
8
|
+
export type CanonicalLayout = {
|
|
9
|
+
/** Canonical (realpath) workspace root. */
|
|
10
|
+
workspace: string;
|
|
11
|
+
/** Validated slug. */
|
|
12
|
+
slug: string;
|
|
13
|
+
/** Canonical path of docs/. */
|
|
14
|
+
docs: string;
|
|
15
|
+
/** Canonical path of docs/<slug>/. */
|
|
16
|
+
dir: string;
|
|
17
|
+
/** Canonical path of docs/<slug>/spec.md. */
|
|
18
|
+
spec: string;
|
|
19
|
+
/** Canonical path of docs/<slug>/plan.md. */
|
|
20
|
+
plan: string;
|
|
21
|
+
/** Canonical path of docs/<slug>/sdd/. */
|
|
22
|
+
sdd: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LayoutResult = { ok: true; layout: CanonicalLayout } | { ok: false; error: string };
|
|
26
|
+
|
|
27
|
+
export type LegacyProbe = {
|
|
28
|
+
/** `.superpowers/sdd` exists. */
|
|
29
|
+
legacy_sdd: boolean;
|
|
30
|
+
/** `docs/superpowers/` exists. */
|
|
31
|
+
superpowers_dir: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PrepareResult =
|
|
35
|
+
| { ok: true; layout: CanonicalLayout; created: string[]; legacy: LegacyProbe }
|
|
36
|
+
| { ok: false; error: string };
|
|
37
|
+
|
|
38
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
39
|
+
|
|
40
|
+
/** Reserved legacy root (DC-05): never resolve or prepare a slug under it. */
|
|
41
|
+
const LEGACY_SLUG = "superpowers";
|
|
42
|
+
|
|
43
|
+
const posix = (p: string) => p.split(path.sep).join("/");
|
|
44
|
+
|
|
45
|
+
// Realpath the nearest existing ancestor of `candidate` and reject the result
|
|
46
|
+
// when it escapes `base` (base must already be canonical). The returned path is
|
|
47
|
+
// canonical where it exists and joined for the non-existent tail.
|
|
48
|
+
const canonicalize = (base: string, candidate: string): string => {
|
|
49
|
+
const abs = path.resolve(base, candidate);
|
|
50
|
+
let ancestor = abs;
|
|
51
|
+
while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
52
|
+
const real = realpathSync(ancestor);
|
|
53
|
+
if (real !== base && !real.startsWith(base + path.sep)) {
|
|
54
|
+
throw new Error(`path must stay inside repository root: ${candidate}`);
|
|
55
|
+
}
|
|
56
|
+
return path.join(real, path.relative(ancestor, abs));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const buildLayout = (workspace: string, slug: string): CanonicalLayout => {
|
|
60
|
+
const docs = canonicalize(workspace, "docs");
|
|
61
|
+
const dir = canonicalize(workspace, path.join(docs, slug));
|
|
62
|
+
return {
|
|
63
|
+
workspace,
|
|
64
|
+
slug,
|
|
65
|
+
docs,
|
|
66
|
+
dir,
|
|
67
|
+
spec: path.join(dir, "spec.md"),
|
|
68
|
+
plan: path.join(dir, "plan.md"),
|
|
69
|
+
sdd: path.join(dir, "sdd"),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Read-only legacy detection (DC-04): never mutates legacy state. */
|
|
74
|
+
export const probeLegacyDocs = (workspace: string): LegacyProbe => ({
|
|
75
|
+
legacy_sdd: existsSync(path.join(workspace, ".superpowers", "sdd")),
|
|
76
|
+
superpowers_dir: existsSync(path.join(workspace, "docs", LEGACY_SLUG)),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one canonical workspace/slug/pair resolver. Given a slug or any spec/plan
|
|
81
|
+
* pair path, returns canonical paths for the pair. Rejects absolute paths,
|
|
82
|
+
* traversal, symlink escapes, cross-slug pairs, wrong basenames, and arbitrary
|
|
83
|
+
* or legacy locations (DC-01, DC-02). Creates nothing.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveCanonicalLayout = (input: {
|
|
86
|
+
workspace_root: string;
|
|
87
|
+
slug?: string;
|
|
88
|
+
spec_path?: string;
|
|
89
|
+
plan_path?: string;
|
|
90
|
+
}): LayoutResult => {
|
|
91
|
+
const { workspace_root, slug, spec_path, plan_path } = input;
|
|
92
|
+
if (!workspace_root) return { ok: false, error: "workspace_root required" };
|
|
93
|
+
let workspace: string;
|
|
94
|
+
try {
|
|
95
|
+
workspace = realpathSync(path.resolve(workspace_root));
|
|
96
|
+
} catch {
|
|
97
|
+
return { ok: false, error: `workspace root not found: ${workspace_root}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let derived: string | null = null;
|
|
101
|
+
for (const [candidate, kind] of [
|
|
102
|
+
[spec_path, "spec"],
|
|
103
|
+
[plan_path, "plan"],
|
|
104
|
+
] as const) {
|
|
105
|
+
if (!candidate) continue;
|
|
106
|
+
if (path.isAbsolute(candidate)) {
|
|
107
|
+
return { ok: false, error: `absolute path not allowed: ${candidate}` };
|
|
108
|
+
}
|
|
109
|
+
let abs: string;
|
|
110
|
+
try {
|
|
111
|
+
abs = canonicalize(workspace, candidate);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
const rel = posix(path.relative(workspace, abs));
|
|
116
|
+
const match = rel.match(/^docs\/([^/]+)\/(spec|plan)\.md$/);
|
|
117
|
+
if (!match) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `path must be docs/<slug>/(spec|plan).md inside workspace_root: ${candidate}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const pathSlug = match[1];
|
|
124
|
+
if (pathSlug === LEGACY_SLUG) {
|
|
125
|
+
return { ok: false, error: `legacy path not allowed: ${candidate}` };
|
|
126
|
+
}
|
|
127
|
+
if (!SLUG_RE.test(pathSlug)) {
|
|
128
|
+
return { ok: false, error: `invalid slug derived from path: ${JSON.stringify(pathSlug)}` };
|
|
129
|
+
}
|
|
130
|
+
if (match[2] !== kind) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `wrong basename for ${kind}: expected ${kind}.md, got ${path.basename(candidate)}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (derived && derived !== pathSlug) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: "cross-slug pair: spec_path and plan_path must share the same docs/<slug>/",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
derived = pathSlug;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let resolvedSlug = slug;
|
|
146
|
+
if (resolvedSlug !== undefined) {
|
|
147
|
+
if (!SLUG_RE.test(resolvedSlug)) {
|
|
148
|
+
return { ok: false, error: `invalid slug: ${JSON.stringify(resolvedSlug)}` };
|
|
149
|
+
}
|
|
150
|
+
if (resolvedSlug === LEGACY_SLUG) {
|
|
151
|
+
return { ok: false, error: `reserved slug: ${LEGACY_SLUG}` };
|
|
152
|
+
}
|
|
153
|
+
if (derived && derived !== resolvedSlug) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `slug ${JSON.stringify(resolvedSlug)} does not match docs path ${JSON.stringify(derived)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} else if (derived) {
|
|
160
|
+
resolvedSlug = derived;
|
|
161
|
+
} else {
|
|
162
|
+
return { ok: false, error: "slug or spec_path/plan_path required" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return { ok: true, layout: buildLayout(workspace, resolvedSlug) };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Prepare the canonical layout (DC-04): create only missing `docs/` and
|
|
174
|
+
* `docs/<slug>/`, return canonical (realpath) paths, and probe legacy state
|
|
175
|
+
* read-only. Never creates sdd/, spec.md, or plan.md.
|
|
176
|
+
*/
|
|
177
|
+
export const prepareDocsLayout = (input: {
|
|
178
|
+
workspace_root: string;
|
|
179
|
+
slug?: string;
|
|
180
|
+
spec_path?: string;
|
|
181
|
+
plan_path?: string;
|
|
182
|
+
}): PrepareResult => {
|
|
183
|
+
const resolved = resolveCanonicalLayout(input);
|
|
184
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
185
|
+
const { layout } = resolved;
|
|
186
|
+
const created: string[] = [];
|
|
187
|
+
const ensure = (dir: string) => {
|
|
188
|
+
if (!existsSync(dir)) {
|
|
189
|
+
mkdirSync(dir, { recursive: true });
|
|
190
|
+
const rel = posix(path.relative(layout.workspace, dir));
|
|
191
|
+
created.push(rel || ".");
|
|
192
|
+
} else if (!statSync(dir).isDirectory()) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`path exists but is not a directory: ${posix(path.relative(layout.workspace, dir))}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
ensure(layout.docs);
|
|
200
|
+
ensure(layout.dir);
|
|
201
|
+
// Re-canonicalize after creation so a symlinked docs/<slug> that escapes
|
|
202
|
+
// the workspace is rejected, not silently accepted (DC-02).
|
|
203
|
+
const docs = canonicalize(layout.workspace, "docs");
|
|
204
|
+
const dir = canonicalize(layout.workspace, path.join(docs, layout.slug));
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
layout: { ...layout, docs, dir },
|
|
208
|
+
created,
|
|
209
|
+
legacy: probeLegacyDocs(layout.workspace),
|
|
210
|
+
};
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Containment check for paths that must live inside the workspace docs tree
|
|
218
|
+
* (sdd dirs, progress files, plan links). Rejects absolute paths, escapes, and
|
|
219
|
+
* the reserved legacy root `docs/superpowers/`.
|
|
220
|
+
*/
|
|
221
|
+
export const resolveDocsPath = (input: {
|
|
222
|
+
workspace_root: string;
|
|
223
|
+
path: string;
|
|
224
|
+
}): { ok: true; path: string; relative: string; base: string } | { ok: false; error: string } => {
|
|
225
|
+
if (path.isAbsolute(input.path)) {
|
|
226
|
+
return { ok: false, error: `absolute path not allowed: ${input.path}` };
|
|
227
|
+
}
|
|
228
|
+
let workspace: string;
|
|
229
|
+
try {
|
|
230
|
+
workspace = realpathSync(path.resolve(input.workspace_root));
|
|
231
|
+
} catch {
|
|
232
|
+
return { ok: false, error: `workspace root not found: ${input.workspace_root}` };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const abs = canonicalize(workspace, input.path);
|
|
236
|
+
const relative = posix(path.relative(workspace, abs));
|
|
237
|
+
if (
|
|
238
|
+
!relative.startsWith("docs/") ||
|
|
239
|
+
relative === `docs/${LEGACY_SLUG}` ||
|
|
240
|
+
relative.startsWith(`docs/${LEGACY_SLUG}/`)
|
|
241
|
+
) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `path must live under docs/ and not under docs/${LEGACY_SLUG}/: ${input.path}`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, path: abs, relative, base: workspace };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
250
|
+
}
|
|
251
|
+
};
|