@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/vcs-config.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 {
|
|
74
|
+
const { status: cfgStatus, path: cfgPath, config: cfg, error: cfgError } = readVcsConfig();
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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: (
|
|
218
|
+
detail: (err instanceof Error ? err.message : "network error").slice(0, 200),
|
|
145
219
|
};
|
|
146
220
|
}
|
|
147
|
-
|
|
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[]) =>
|
|
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
|
+
}
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
+
return real(p);
|
|
67
159
|
} catch {
|
|
68
|
-
return
|
|
160
|
+
return p;
|
|
69
161
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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());
|