@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.
Files changed (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { configDir } from "./config";
5
5
  import { resolveWorkspace } from "./workspaces";
6
-
6
+ import { resolveBranchPolicyFor } from "./branch";
7
7
  // Ports of scripts/vcs/config.sh + verify-token.sh + token-create-urls.sh + merged-style.sh.
8
8
  // Token never printed.
9
9
 
@@ -17,22 +17,52 @@ const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
17
17
  const vcsCwd = (cwd?: string): string =>
18
18
  process.env.WORKFLOW_WORKSPACE_ROOT ?? cwd ?? process.cwd();
19
19
 
20
- function readVcsJson(): { config: Record<string, any>; path: string; ok: boolean } {
21
- const cfgPath = vcsConfigPath();
22
- let config: Record<string, any> = {};
23
- let ok = false;
24
- if (fs.existsSync(cfgPath)) {
25
- try {
26
- const parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as unknown;
27
- if (parsed && typeof parsed === "object") {
28
- config = parsed as Record<string, any>;
29
- ok = true;
30
- }
31
- } catch {
32
- /* missing or invalid */
33
- }
20
+ // RL-03b: the origin remote is the ground truth for PR creation. A stale global
21
+ // provider (e.g. gitlab from another repo) must not drive glab on a
22
+ // github.com-hosted checkout. Recognized hosts only — custom/self-hosted
23
+ // remotes keep the configured provider, and a missing origin is untouched.
24
+ const remoteProvider = (cwd: string): string | null => {
25
+ const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
26
+ if (r.status !== 0) return null;
27
+ const url = (r.stdout ?? "").trim();
28
+ if (/github\.com[:/]/.test(url)) return "github";
29
+ if (/gitlab\.com[:/]/.test(url)) return "gitlab";
30
+ return null;
31
+ };
32
+
33
+ // RL-01: typed vcs.json reader. Missing is a legitimate unconfigured state;
34
+ // malformed (parse failure or a non-object) is reported with the exact path so
35
+ // risky consumers stop instead of silently reading defaults.
36
+ export type VcsConfigResult = {
37
+ status: "missing" | "valid" | "malformed";
38
+ path: string;
39
+ config: Record<string, any>;
40
+ error?: string;
41
+ };
42
+
43
+ export const readVcsConfig = (): VcsConfigResult => {
44
+ const file = vcsConfigPath();
45
+ let raw: string;
46
+ try {
47
+ raw = fs.readFileSync(file, "utf8");
48
+ } catch {
49
+ return { status: "missing", path: file, config: {} };
50
+ }
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return { status: "malformed", path: file, config: {}, error: `${file} is not valid JSON` };
34
56
  }
35
- return { config, path: cfgPath, ok };
57
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
58
+ return { status: "valid", path: file, config: parsed as Record<string, any> };
59
+ }
60
+ return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
61
+ };
62
+
63
+ function readVcsJson(): { config: Record<string, any>; path: string; ok: boolean } {
64
+ const { status, path: cfgPath, config } = readVcsConfig();
65
+ return { config, path: cfgPath, ok: status === "valid" };
36
66
  }
37
67
 
38
68
  /** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
@@ -41,10 +71,31 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
41
71
  const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
42
72
  const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
43
73
  const wsIssues = (ws?.issues ?? {}) as Record<string, any>;
44
- const { config: cfg, path: cfgPath, ok: cfgOk } = readVcsJson();
74
+ const { status: cfgStatus, path: cfgPath, config: cfg, error: cfgError } = readVcsConfig();
45
75
 
46
- const provider = String(wsVcs.provider ?? cfg.provider ?? "gitlab").toLowerCase();
47
- const defaultTarget = String(wsVcs.defaultTargetBranch ?? cfg.defaultTargetBranch ?? "develop");
76
+ // Explicit workspace vcs.provider is the user's per-repo intent and wins;
77
+ // otherwise, with a determinate repo root (explicit cwd or host-provided
78
+ // WORKFLOW_WORKSPACE_ROOT), the origin remote is authoritative over the
79
+ // global default (RL-03b). Ambient process.cwd() callers keep the legacy
80
+ // config-only contract (CA-06 parity), so a stale global default cannot
81
+ // contaminate host-agnostic reads.
82
+ const root = vcsCwd(cwd);
83
+ const determinateRoot = cwd !== undefined || process.env.WORKFLOW_WORKSPACE_ROOT !== undefined;
84
+ const provider = String(
85
+ wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? "gitlab",
86
+ ).toLowerCase();
87
+ // CA-05: workspace/global explicit target wins; when unset the resolved
88
+ // branch-policy preset supplies the default (gitflow->develop,
89
+ // github-flow->main, trunk-based->master, custom->develop). Shared by load
90
+ // and resolve so both surfaces stay consistent.
91
+ // CA-09: the one resolver wrapper — same policy resolution every consumer
92
+ // uses, so the tightened gate in branch-policy-resolver.test.ts stays green.
93
+ const defaultTarget = String(
94
+ wsVcs.defaultTargetBranch ??
95
+ cfg.defaultTargetBranch ??
96
+ resolveBranchPolicyFor(root).defaultTargetBranch ??
97
+ "develop",
98
+ );
48
99
  const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
49
100
  const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
50
101
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
@@ -60,6 +111,10 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
60
111
  }
61
112
 
62
113
  if (mode === "resolve") {
114
+ // RL-01: malformed vcs.json blocks resolution with an exact-path diagnostic
115
+ // instead of silently resolving defaults; a missing file is still a
116
+ // legitimate unconfigured state that falls back to defaults.
117
+ if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
63
118
  return {
64
119
  ok: true,
65
120
  workspace_name: ws?.name ?? null,
@@ -72,7 +127,12 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
72
127
  };
73
128
  }
74
129
 
75
- if (!cfgOk) return { ok: false, error: "missing or invalid vcs.json" };
130
+ if (cfgStatus === "malformed") {
131
+ return { ok: false, error: cfgError, configPath: cfgPath };
132
+ }
133
+ if (cfgStatus === "missing") {
134
+ return { ok: false, error: `vcs.json is missing: ${cfgPath}`, configPath: cfgPath };
135
+ }
76
136
 
77
137
  const prov = (cfg[provider] ?? {}) as Record<string, any>;
78
138
  const tokenFile = String(prov.tokenFile ?? path.join(configDir(), `${provider}.token`));
@@ -113,7 +173,7 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
113
173
  }
114
174
 
115
175
  /** Port of scripts/vcs/verify-token.sh — soft-fail JSON out, always exit 0. */
116
- export function vcsVerifyToken(): Record<string, any> {
176
+ export async function vcsVerifyToken(): Promise<Record<string, any>> {
117
177
  const cfg = vcsConfig("load");
118
178
  if (!cfg.ok) return { ok: false, error: cfg.error ?? "vcs config not ready" };
119
179
  const provider = cfg.provider as string;
@@ -133,23 +193,32 @@ export function vcsVerifyToken(): Record<string, any> {
133
193
  /\/+$/,
134
194
  "",
135
195
  );
136
- const result = spawnSync("curl", ["-fsS", "-H", `PRIVATE-TOKEN: ${token}`, `${api}/user`], {
137
- encoding: "utf8",
138
- });
139
- if (result.status !== 0) {
196
+ let user: Record<string, any>;
197
+ try {
198
+ const res = await fetch(`${api}/user`, {
199
+ headers: { "PRIVATE-TOKEN": token },
200
+ });
201
+ if (!res.ok) {
202
+ return {
203
+ ok: false,
204
+ provider,
205
+ error: "GitLab API rejected token",
206
+ detail: (await res.text()).slice(0, 200),
207
+ };
208
+ }
209
+ user = JSON.parse(await res.text()) as Record<string, any>;
210
+ } catch (err) {
211
+ if (err instanceof SyntaxError) {
212
+ return { ok: false, provider, error: "invalid JSON from GitLab /user" };
213
+ }
140
214
  return {
141
215
  ok: false,
142
216
  provider,
143
217
  error: "GitLab API rejected token",
144
- detail: (result.stderr ?? result.stdout ?? "").slice(0, 200),
218
+ detail: (err instanceof Error ? err.message : "network error").slice(0, 200),
145
219
  };
146
220
  }
147
- try {
148
- const user = JSON.parse(result.stdout ?? "") as Record<string, any>;
149
- return { ok: true, provider, username: user.username ?? user.login, name: user.name };
150
- } catch {
151
- return { ok: false, provider, error: "invalid JSON from GitLab /user" };
152
- }
221
+ return { ok: true, provider, username: user.username ?? user.login, name: user.name };
153
222
  }
154
223
  if (provider === "github") {
155
224
  const result = spawnSync("gh", ["api", "user"], {
@@ -240,8 +309,8 @@ export function vcsTokenCreateUrls(): Record<string, any> {
240
309
  }
241
310
 
242
311
  /** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
243
- export function mergedPrStyle(limit = 6): Record<string, any> {
244
- const cfg = vcsConfig("load");
312
+ export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
313
+ const cfg = vcsConfig("load", cwd);
245
314
  if (!cfg.ok || !cfg.tokenReady) return { ok: false, error: "vcs not configured" };
246
315
  const provider = cfg.provider as string;
247
316
  const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
@@ -257,14 +326,15 @@ export function mergedPrStyle(limit = 6): Record<string, any> {
257
326
  });
258
327
 
259
328
  if (provider === "gitlab") {
260
- const remote = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
329
+ const remote = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
261
330
  if (remote.status !== 0) return { ok: false, error: "no origin remote" };
262
331
  const url = (remote.stdout ?? "").trim();
263
332
  const m = /gitlab\.com[:/](.+?)(?:\.git)?$/.exec(url);
264
333
  if (!m) return { ok: false, error: "not a gitlab.com origin" };
265
334
  const project = m[1];
266
335
  const env = { ...process.env, GITLAB_TOKEN: token };
267
- const run = (args: string[]) => spawnSync("glab", ["api", ...args], { encoding: "utf8", env });
336
+ const run = (args: string[]) =>
337
+ spawnSync("glab", ["api", ...args], { cwd, encoding: "utf8", env });
268
338
  let r = run([
269
339
  `projects/${project.replaceAll("/", "%2F")}/merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`,
270
340
  ]);
@@ -285,7 +355,7 @@ export function mergedPrStyle(limit = 6): Record<string, any> {
285
355
  const r = spawnSync(
286
356
  "gh",
287
357
  ["pr", "list", "--state", "merged", "--limit", String(limit), "--json", "title,url,body"],
288
- { encoding: "utf8", env: { ...process.env, GH_TOKEN: token } },
358
+ { cwd, encoding: "utf8", env: { ...process.env, GH_TOKEN: token } },
289
359
  );
290
360
  if (r.status !== 0) return { ok: false, error: "could not list pull requests" };
291
361
  for (const pr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
@@ -0,0 +1,181 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { repoRoot } from "./repo-context";
5
+
6
+ // Port of scripts/verify-project.sh — discover + run project verification checks
7
+ // (npm/pnpm/yarn, cargo, pytest/ruff, CHANGELOG format). Output is parsed by
8
+ // verify-parse.ts and keeps the same ## section text shape.
9
+
10
+ export type VerifyResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
11
+
12
+ const commandOnPath = (name: string): boolean => {
13
+ const dirs = (process.env.PATH ?? "").split(path.delimiter);
14
+ // win32 executables carry an .exe suffix (pytest.exe), so probe both names.
15
+ const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
16
+ for (const dir of dirs) {
17
+ if (!dir) continue;
18
+ for (const candidateName of names) {
19
+ const candidate = path.join(dir, candidateName);
20
+ try {
21
+ statSync(candidate);
22
+ if (process.platform !== "win32") {
23
+ const mode = statSync(candidate).mode & 0o111;
24
+ if (mode === 0) continue;
25
+ }
26
+ return true;
27
+ } catch {
28
+ /* keep scanning */
29
+ }
30
+ }
31
+ }
32
+ return false;
33
+ };
34
+
35
+ const hasScript = (cwd: string, name: string): boolean => {
36
+ const pkgPath = path.join(cwd, "package.json");
37
+ if (!existsSync(pkgPath)) return false;
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
40
+ return Boolean(pkg.scripts && typeof pkg.scripts[name] === "string");
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ const packageRunner = (cwd: string): string => {
47
+ if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
48
+ const pkgPath = path.join(cwd, "package.json");
49
+ if (existsSync(pkgPath)) {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { packageManager?: string };
52
+ if (typeof pkg.packageManager === "string" && pkg.packageManager.startsWith("pnpm")) {
53
+ return "pnpm";
54
+ }
55
+ } catch {
56
+ /* fall through */
57
+ }
58
+ }
59
+ if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
60
+ return "npm";
61
+ };
62
+
63
+ export function runVerifyProject(root: string, dryRun = false): VerifyResult {
64
+ // Shell parity: verify-project.sh did `root=$(git rev-parse --show-toplevel || pwd); cd "$root"`,
65
+ // so a run from a repo subdirectory still checks the repo root.
66
+ const cwd = repoRoot(root);
67
+ let passed = 0;
68
+ let failed = 0;
69
+ let skipped = 0;
70
+ const lines: string[] = [];
71
+ const stderrParts: string[] = [];
72
+
73
+ const runCheck = (label: string, cmd: string, args: string[]) => {
74
+ lines.push(`\n## ${label}\n\n`);
75
+ lines.push(`command: ${[cmd, ...args].join(" ")}\n\n`);
76
+ if (dryRun) {
77
+ lines.push("status: skipped (dry run)\n");
78
+ skipped += 1;
79
+ return;
80
+ }
81
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8" });
82
+ const so = (r.stdout ?? "").trimEnd();
83
+ if (so) lines.push(so + "\n");
84
+ const se = (r.stderr ?? "").trimEnd();
85
+ if (se) stderrParts.push(se);
86
+ if (r.status === 0) {
87
+ lines.push("\nstatus: pass\n");
88
+ passed += 1;
89
+ } else {
90
+ lines.push(`\nstatus: fail (exit ${r.status})\n`);
91
+ failed += 1;
92
+ }
93
+ };
94
+
95
+ const skipCheck = (label: string, reason: string) => {
96
+ lines.push(`\n## ${label}\n\nstatus: skipped (${reason})\n`);
97
+ skipped += 1;
98
+ };
99
+
100
+ lines.push("# Verification Context\n");
101
+ lines.push(`\nroot: ${cwd}\n`);
102
+ lines.push(`dry_run: ${String(dryRun)}\n`);
103
+
104
+ const pkgPath = path.join(cwd, "package.json");
105
+ if (existsSync(pkgPath)) {
106
+ const runner = packageRunner(cwd);
107
+ for (const script of ["lint", "format:check", "test", "build"]) {
108
+ if (hasScript(cwd, script)) {
109
+ if (runner === "npm") runCheck(script, "npm", ["run", script]);
110
+ else runCheck(script, runner, [script]);
111
+ } else {
112
+ skipCheck(script, `package.json has no ${script} script`);
113
+ }
114
+ }
115
+ } else {
116
+ skipCheck("javascript", "package.json not found");
117
+ }
118
+
119
+ const cargoManifest = existsSync(path.join(cwd, "Cargo.toml"))
120
+ ? "Cargo.toml"
121
+ : existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
122
+ ? "src-tauri/Cargo.toml"
123
+ : "";
124
+ if (cargoManifest) {
125
+ runCheck("cargo fmt", "cargo", ["fmt", "--manifest-path", cargoManifest, "--", "--check"]);
126
+ runCheck("cargo clippy", "cargo", [
127
+ "clippy",
128
+ "--manifest-path",
129
+ cargoManifest,
130
+ "--all-targets",
131
+ "--",
132
+ "-D",
133
+ "warnings",
134
+ ]);
135
+ runCheck("cargo test", "cargo", ["test", "--manifest-path", cargoManifest, "--all-targets"]);
136
+ } else {
137
+ skipCheck("rust", "Cargo.toml not found");
138
+ }
139
+
140
+ if (
141
+ existsSync(path.join(cwd, "pyproject.toml")) ||
142
+ existsSync(path.join(cwd, "pytest.ini")) ||
143
+ existsSync(path.join(cwd, "tests"))
144
+ ) {
145
+ if (commandOnPath("pytest")) runCheck("pytest", "pytest", []);
146
+ else skipCheck("pytest", "pytest not available");
147
+ if (commandOnPath("ruff")) runCheck("ruff check", "ruff", ["check", "."]);
148
+ else skipCheck("ruff check", "ruff not available");
149
+ }
150
+
151
+ lines.push("\n## CHANGELOG.md format\n\n");
152
+ lines.push('command: grep -qi "## [Unreleased]" CHANGELOG.md\n\n');
153
+ const changelogPath = path.join(cwd, "CHANGELOG.md");
154
+ if (dryRun) {
155
+ lines.push("status: skipped (dry run)\n");
156
+ skipped += 1;
157
+ } else if (existsSync(changelogPath)) {
158
+ if (/## \[Unreleased\]/i.test(readFileSync(changelogPath, "utf8"))) {
159
+ lines.push("status: pass\n");
160
+ passed += 1;
161
+ } else {
162
+ lines.push("status: fail (missing ## [Unreleased])\n");
163
+ failed += 1;
164
+ }
165
+ } else {
166
+ lines.push("status: fail (missing CHANGELOG.md)\n");
167
+ failed += 1;
168
+ }
169
+
170
+ lines.push("\n# Summary\n\n");
171
+ lines.push(`passed: ${passed}\n`);
172
+ lines.push(`failed: ${failed}\n`);
173
+ lines.push(`skipped: ${skipped}\n`);
174
+
175
+ return {
176
+ stdout: lines.join(""),
177
+ stderr: stderrParts.join("\n"),
178
+ exitCode: failed > 0 ? 1 : 0,
179
+ cwd,
180
+ };
181
+ }
@@ -1,19 +1,70 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { configDir } from "./config";
3
+ import { configDir, isConfigObject, type BranchPreset } from "./config";
4
4
 
5
5
  export type VcsProvider = "gitlab" | "github";
6
6
 
7
+ export type IntegrationMode = "pr" | "merge";
8
+
9
+ export type WorkspaceBranchPolicy = {
10
+ preset: BranchPreset;
11
+ developBranch?: string;
12
+ prefixes?: { feature: string; bugfix: string; release: string; hotfix: string };
13
+ allowed?: string[];
14
+ protected?: string[];
15
+ integration: IntegrationMode;
16
+ };
17
+
7
18
  export type WorkspaceConfig = {
8
19
  name: string;
9
20
  glob: string;
10
21
  vcs?: { provider: VcsProvider; defaultTargetBranch?: string };
11
22
  youtrack?: { baseUrl?: string; link_issues?: boolean };
12
23
  issues?: { provider?: "github"; link_on_pr?: boolean };
24
+ branchPolicy?: WorkspaceBranchPolicy;
13
25
  };
14
26
 
15
27
  export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
16
28
 
29
+ // RL-01: typed workspaces reader. Missing is a legitimate unconfigured state
30
+ // (empty list); malformed JSON is reported with the exact path so risky
31
+ // consumers (wizard/installer/doctor) can block instead of silently resetting.
32
+ export type WorkspacesResult = {
33
+ status: "missing" | "valid" | "malformed";
34
+ path: string;
35
+ entries: WorkspaceConfig[];
36
+ error?: string;
37
+ };
38
+
39
+ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
40
+ const file = path.join(dir, "workspaces.json");
41
+ let raw: string;
42
+ try {
43
+ raw = readFileSync(file, "utf8");
44
+ } catch {
45
+ return { status: "missing", path: file, entries: [] };
46
+ }
47
+ let parsed: unknown;
48
+ try {
49
+ parsed = JSON.parse(raw);
50
+ } catch {
51
+ return { status: "malformed", path: file, entries: [], error: `${file} is not valid JSON` };
52
+ }
53
+ if (!isConfigObject(parsed)) {
54
+ return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
55
+ }
56
+ const list = (parsed as { workspaces?: unknown }).workspaces;
57
+ return {
58
+ status: "valid",
59
+ path: file,
60
+ entries: Array.isArray(list) ? (list as WorkspaceConfig[]) : [],
61
+ };
62
+ };
63
+
64
+ /** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
65
+ export const loadWorkspacesFrom = (dir: string): WorkspaceConfig[] =>
66
+ readWorkspacesResult(dir).entries;
67
+
17
68
  // ponytail: only globstar (`**`) is supported; if more minimatch parity is needed
18
69
  // (char classes, braces, `?`), swap this matcher for the minimatch dependency.
19
70
  const globToRegExp = (glob: string): RegExp => {
@@ -54,28 +105,96 @@ const globToRegExp = (glob: string): RegExp => {
54
105
  return new RegExp(`^${out}$`);
55
106
  };
56
107
 
57
- export const resolveWorkspace = (cwd: string): WorkspaceConfig | null => {
58
- let raw: string;
59
- try {
60
- raw = readFileSync(workspacesPath(), "utf8");
61
- } catch {
62
- return null;
108
+ /** Shared authoritative workspace matcher (WZ-12): the wizard's pattern
109
+ * preview and resolveWorkspaceFrom both route through it. */
110
+ export const matchWorkspace = (glob: string, target: string): boolean =>
111
+ globToRegExp(glob.replaceAll("\\", "/")).test(target.replaceAll("\\", "/"));
112
+
113
+ // RL-08: the matcher above implements `*` and `**` only. Classic glob
114
+ // metacharacters it would otherwise store as literals (char classes, `?`,
115
+ // brace expansion) are rejected at write time so a saved pattern never
116
+ // silently matches nothing (Task 15 advisory: unsupported patterns were
117
+ // accepted and shown as "no match").
118
+ const UNSUPPORTED_GLOB = /[?[\]{}]/;
119
+
120
+ // B3: `!`-negation and extglob prefixes (@(...), +(...), *(...)) are the same
121
+ // no-match trap — the matcher stores them as literals, so `!**` or `@(a|b)`
122
+ // silently matches nothing. Reject them at write time too; the Task 15 match
123
+ // preview still routes through matchWorkspace (literals -> no match) and is
124
+ // unchanged.
125
+ const UNSUPPORTED_EXTGLOB = /[@+*]\(|!/;
126
+
127
+ export type GlobValidation = { ok: true } | { ok: false; error: string };
128
+
129
+ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
130
+ const trimmed = glob.trim();
131
+ if (!trimmed) return { ok: false, error: "workspace pattern is required" };
132
+ const m = UNSUPPORTED_GLOB.exec(trimmed);
133
+ if (m) {
134
+ return {
135
+ ok: false,
136
+ error: `unsupported glob character ${JSON.stringify(m[0])} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
137
+ };
63
138
  }
64
- let parsed: unknown;
139
+ const ext = UNSUPPORTED_EXTGLOB.exec(trimmed);
140
+ if (ext) {
141
+ const token = ext[0];
142
+ const kind = token === "!" ? "negation (!)" : `extglob (${token})`;
143
+ return {
144
+ ok: false,
145
+ error: `unsupported glob ${kind} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
146
+ };
147
+ }
148
+ return { ok: true };
149
+ };
150
+
151
+ const realpathOf = (p: string): string => {
152
+ // realpathSync.native goes through libuv, which expands Windows 8.3 short
153
+ // names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
154
+ // keeps them, so a git-derived long path would miss a glob written with the
155
+ // short form (GitHub runner TMP uses the short name).
156
+ const real = realpathSync.native ?? realpathSync;
65
157
  try {
66
- parsed = JSON.parse(raw);
158
+ return real(p);
67
159
  } catch {
68
- return null;
160
+ return p;
69
161
  }
70
- if (!parsed || typeof parsed !== "object") return null;
71
- const list = (parsed as { workspaces?: unknown }).workspaces;
72
- if (!Array.isArray(list)) return null;
73
- const cwdPosix = cwd.replaceAll("\\", "/");
74
- for (const entry of list) {
162
+ };
163
+
164
+ // Rebuild a glob with the realpath of its static prefix, so a config glob
165
+ // written with the logical path also matches a canonical target (and vice
166
+ // versa). The glob metacharacters themselves are left untouched.
167
+ const canonicalGlob = (glob: string): string => {
168
+ const m = /[*?[\]{]/.exec(glob);
169
+ const prefix = m ? glob.slice(0, m.index) : glob;
170
+ const rest = m ? glob.slice(m.index) : "";
171
+ if (!prefix) return glob;
172
+ const real = realpathOf(prefix.replace(/\/+$/, "") || "/");
173
+ return real + (prefix.endsWith("/") ? "/" : "") + rest;
174
+ };
175
+
176
+ /** Match a cwd against the workspaces.json under an explicit config dir. */
177
+ export const resolveWorkspaceFrom = (cwd: string, dir: string): WorkspaceConfig | null => {
178
+ // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
179
+ // --show-toplevel returns the realpath while config globs are usually
180
+ // written with the logical path, so a workspace would silently stop
181
+ // matching on macOS. Match both forms on each side — same class as the
182
+ // docs-migration escape-guard realpath comparison; on Linux both forms
183
+ // are identical so behavior is unchanged.
184
+ const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
185
+ for (const entry of loadWorkspacesFrom(dir)) {
75
186
  if (!entry || typeof entry !== "object") continue;
76
187
  const ws = entry as WorkspaceConfig;
77
188
  if (typeof ws.glob !== "string" || !ws.glob) continue;
78
- if (globToRegExp(ws.glob.replaceAll("\\", "/")).test(cwdPosix)) return ws;
189
+ const glob = ws.glob.replaceAll("\\", "/");
190
+ const canonical = canonicalGlob(glob);
191
+ for (const target of targets) {
192
+ if (matchWorkspace(glob, target)) return ws;
193
+ if (canonical !== glob && matchWorkspace(canonical, target)) return ws;
194
+ }
79
195
  }
80
196
  return null;
81
197
  };
198
+
199
+ export const resolveWorkspace = (cwd: string): WorkspaceConfig | null =>
200
+ resolveWorkspaceFrom(cwd, configDir());