@rowansenne/workflow-run 0.21.2 → 0.22.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.
@@ -3,9 +3,12 @@
3
3
  // wiki 发布记录与 DocNexus 沉淀,供 /workflow view 等直接读取。
4
4
  // 本工具的产出都在项目根的 workflow-run/ 里(需求产物 docs/requirements/、DocNexus 工作区 docnexus/、项目模板 templates/、临时文件 tmp/),
5
5
  // 不往项目的其他地方写。两种用法:
6
- // - 默认:workflow-run/ 随代码提交,需求产物在 workflow-run/docs/requirements/<需求名>/;这个目录写进项目根 .ignore,不参与代码搜索
7
- // - 独立文档仓库:团队文档仓库克隆到 workflow-run/(它自己是 git 仓库,代码仓库忽略它),产物在 workflow-run/docs/requirements/<需求名>/<项目名>/,
8
- // 一个需求横跨多个服务时各项目各占一个子目录;文档仓库单独提交(写完由用户确认,可用 artifacts.commit_prompt 关闭提交提示)
6
+ // 两种用法的目录结构完全一样:产物都在 workflow-run/docs/requirements/<需求名>/<项目名>/,一个需求横跨多个服务时各项目各占一个子目录,
7
+ // 所以两种用法之间切换只是换一个 git 仓库来跟踪 workflow-run/,文件不用挪:
8
+ // - 默认:workflow-run/ 随代码提交;项目名没配置时取 git 远端仓库名(第一次 bind 时写进 profile.json,之后固定)
9
+ // - 独立文档仓库:团队文档仓库克隆到 workflow-run/(它自己是 git 仓库,代码仓库忽略它),项目名要先配置(各服务在同一个仓库里,名字由团队定);
10
+ // 文档仓库单独提交(写完由用户确认,可用 artifacts.commit_prompt 关闭提交提示)
11
+ // 需求目录写进项目根 .ignore,不参与代码搜索。旧版本(随代码提交时没有 <项目名>/ 这一层)的产物由安装器 upgrade 时迁移(migrateLayout)
9
12
  // 需求名由用户在流程开始或第一次落盘时给出;用户不给时按分支名生成。分支 → 需求的绑定记在 meta.json 的 branches。
10
13
  // 说明见 workflow/references/requirements.md。
11
14
  //
@@ -110,13 +113,17 @@ export function artifactsConfig(root) {
110
113
  return { project: c.project ? String(c.project) : '', commit_prompt: c.commit_prompt !== false, profile_dir: dir };
111
114
  }
112
115
 
113
- // 产物位置:project = workflow-run/docs/requirements/<需求>/(随代码提交);repo = 同一位置下再分 <项目>/(workflow-run/ 是独立的文档仓库)
116
+ // 产物位置:两种模式都是 workflow-run/docs/requirements/<需求>/<项目>/。
117
+ // mode 只决定谁来提交(project = 随代码提交,repo = workflow-run/ 是独立的文档仓库)、引用怎么写、项目名能不能自动取
114
118
  export function layout(root, cfg = artifactsConfig(root)) {
115
119
  const home = homeDir(root);
116
120
  const base = path.join(root, REQ_DIR);
117
- if (!homeIsRepo(root)) return { mode: 'project', ok: true, base, home, repo: null, project: null, commit_prompt: cfg.commit_prompt, reqDir: (slug) => path.join(base, slug), ref: (abs) => relOf(root, abs) };
121
+ if (!homeIsRepo(root)) {
122
+ const project = cfg.project ? dirName(cfg.project) : suggestProject(root);
123
+ return { mode: 'project', ok: true, base, home, repo: null, project, project_configured: !!cfg.project, commit_prompt: cfg.commit_prompt, reqDir: (slug) => path.join(base, slug, project), ref: (abs) => relOf(root, abs) };
124
+ }
118
125
  const repoName = remoteName(home);
119
- const L = { mode: 'repo', base, home, repo: home, repo_rel: HOME, repo_name: repoName, project: cfg.project ? dirName(cfg.project) : null, commit_prompt: cfg.commit_prompt };
126
+ const L = { mode: 'repo', base, home, repo: home, repo_rel: HOME, repo_name: repoName, project: cfg.project ? dirName(cfg.project) : null, project_configured: !!cfg.project, commit_prompt: cfg.commit_prompt };
120
127
  L.reqDir = (slug) => path.join(base, slug, L.project ?? '_');
121
128
  // 文档里引用产物用「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样
122
129
  L.ref = (abs) => `${repoName}:${toPosix(path.relative(home, abs))}`;
@@ -157,9 +164,10 @@ export function requirements(root, L = layout(root)) {
157
164
  return out;
158
165
  }
159
166
 
160
- // 文档仓库里所有项目的需求(跨服务视图、bind 时找同名需求):{ slug, name, projects:[{ project, abs, dir, meta }] }
167
+ // 所有项目的需求(跨服务视图、bind 时找同名需求):{ slug, name, projects:[{ project, abs, dir, meta }] }。
168
+ // 两种模式结构相同;随代码提交时通常只有本项目一个
161
169
  export function repoRequirements(root, L = layout(root)) {
162
- if (L.mode !== 'repo' || !isDir(L.base)) return [];
170
+ if (!isDir(L.base)) return [];
163
171
  const out = [];
164
172
  for (const d of fs.readdirSync(L.base, { withFileTypes: true })) {
165
173
  if (skipDir(d)) continue;
@@ -178,7 +186,7 @@ export function repoRequirements(root, L = layout(root)) {
178
186
  // 产物文件(view / artifacts.mjs 读):{ abs, rel(相对项目根), cite(文档里引用它的写法), slug, project, kind, version }
179
187
  export function artifactFiles(root, { allProjects = false } = {}) {
180
188
  const L = layout(root);
181
- const dirs = allProjects && L.mode === 'repo'
189
+ const dirs = allProjects
182
190
  ? repoRequirements(root, L).flatMap((r) => r.projects.map((p) => ({ abs: p.abs, slug: r.slug, project: p.project })))
183
191
  : requirements(root, L).map((r) => ({ abs: r.abs, slug: r.slug, project: L.project }));
184
192
  const out = [];
@@ -251,15 +259,15 @@ export function which(root, branch) {
251
259
  const L = layout(root);
252
260
  if (!L.ok) return notReady(L);
253
261
  const r = find(root, branch, L);
254
- const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project' };
262
+ const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project', project: L.project };
255
263
  if (r) return { bound: true, ...where, branch, name: r.name, dir: r.dir, meta: sync(root, r.dir) };
256
264
  const recent = requirements(root, L).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 5).map(({ name, dir, branches, updated }) => ({ name, dir, branches, updated }));
257
- // 文档仓库模式:其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入)
265
+ // 其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入);文档仓库里常见,随代码提交时一般没有
258
266
  const mine = new Set(requirements(root, L).map((x) => x.slug));
259
- const others = L.mode === 'repo' ? repoRequirements(root, L).filter((x) => !mine.has(x.slug)).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 10).map((x) => ({ name: x.name, projects: x.projects.map((p) => p.project), updated: x.updated })) : [];
267
+ const others = repoRequirements(root, L).filter((x) => !mine.has(x.slug)).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 10).map((x) => ({ name: x.name, projects: x.projects.map((p) => p.project), updated: x.updated }));
260
268
  return {
261
- bound: false, ...where, branch, suggested_name: branch ? dirName(branch.replace(/\//g, '-')) : null, recent, ...(L.mode === 'repo' ? { other_projects: others } : {}),
262
- hint: `当前分支还没有绑定需求:问用户这个需求叫什么(给出 suggested_name 作为默认;也可以选 recent 里已有的需求继续${L.mode === 'repo' ? ',或加入 other_projects 里其他服务已有的需求' : ''}),然后运行 bind --name <需求名>;用户不给就运行 bind(按分支名生成)。`,
269
+ bound: false, ...where, branch, suggested_name: branch ? dirName(branch.replace(/\//g, '-')) : null, recent, ...(others.length || L.mode === 'repo' ? { other_projects: others } : {}),
270
+ hint: `当前分支还没有绑定需求:问用户这个需求叫什么(给出 suggested_name 作为默认;也可以选 recent 里已有的需求继续${others.length || L.mode === 'repo' ? ',或加入 other_projects 里其他服务已有的需求' : ''}),然后运行 bind --name <需求名>;用户不给就运行 bind(按分支名生成)。`,
263
271
  };
264
272
  }
265
273
 
@@ -269,25 +277,28 @@ export function bind(root, { name = null, branch, base = null }) {
269
277
  if (!branch) return { ok: false, error: '无法确定当前分支(detached HEAD?),请用 --branch 指定' };
270
278
  const named = name != null && String(name).trim() !== '';
271
279
  const display = named ? String(name).trim() : branch.replace(/\//g, '-');
272
- // 同名需求沿用(文档仓库模式按整个仓库找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
273
- const pool = L.mode === 'repo' ? repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name })) : requirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
280
+ // 同名需求沿用(按所有项目找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
281
+ const pool = repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
274
282
  const target = pool.find((r) => r.name === display) ?? null;
275
283
  let slug = target?.slug;
276
284
  if (!slug) {
277
285
  slug = dirName(display);
278
- for (let i = 2; pool.some((r) => r.slug === slug) || (L.mode === 'project' && isDir(L.reqDir(slug))); i++) slug = `${dirName(display)}-${i}`;
286
+ for (let i = 2; pool.some((r) => r.slug === slug) || isDir(L.reqDir(slug)); i++) slug = `${dirName(display)}-${i}`;
279
287
  }
288
+ // 随代码提交时项目名是自动取的:第一次落盘时写进 profile.json 固定下来,之后改了 git 远端名也不会换目录
289
+ // (只在项目里装过 skills、有 profile.json 时写;直接从安装包运行脚本时不写)
290
+ if (!L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
280
291
  const dir = L.reqDir(slug);
281
292
  const existedHere = isDir(dir);
282
293
  fs.mkdirSync(dir, { recursive: true });
283
294
  const f = path.join(dir, 'meta.json');
284
- const meta = readJson(f) ?? { name: target?.name ?? display, named_by: named ? 'user' : 'branch', ...(L.mode === 'repo' ? { project: L.project } : {}), branches: [], base, created: now() };
295
+ const meta = readJson(f) ?? { name: target?.name ?? display, named_by: named ? 'user' : 'branch', project: L.project, branches: [], base, created: now() };
285
296
  if (!meta.branches.includes(branch)) meta.branches.push(branch);
286
297
  if (base && !meta.base) meta.base = base;
287
298
  meta.updated = now();
288
299
  fs.writeFileSync(f, `${JSON.stringify(meta, null, 2)}\n`);
289
300
  const rel = relOf(root, dir);
290
- return { ok: true, mode: L.mode, created: !existedHere, joined: !!target && !existedHere && L.mode === 'repo', name: meta.name, named_by: meta.named_by, dir: rel, ...(L.mode === 'repo' ? { project: L.project } : {}), branches: sync(root, rel).branches };
301
+ return { ok: true, mode: L.mode, created: !existedHere, joined: !!target && !existedHere, name: meta.name, named_by: meta.named_by, dir: rel, project: L.project, branches: sync(root, rel).branches };
291
302
  }
292
303
 
293
304
  // 本次落盘路径:文件名固定(review.md …),已有时先把当前版复制到 tmp/old-artifacts/<种类>_v<N>.md(previous,复审对照用),
@@ -347,6 +358,55 @@ export function pending(root, branch, dirRel = null) {
347
358
  };
348
359
  }
349
360
 
361
+ // ------------------------------------------------------------------ 旧结构迁移
362
+ // 旧版本随代码提交时没有 <项目名>/ 这一层:文件直接放在 docs/requirements/<需求>/ 下。
363
+ // 列出这样的需求目录(目录里直接有文件的;隐藏文件不算)
364
+ export function legacyRequirements(root) {
365
+ const base = path.join(root, REQ_DIR);
366
+ if (!isDir(base)) return [];
367
+ return fs.readdirSync(base, { withFileTypes: true }).filter((d) => !skipDir(d)).map((d) => d.name)
368
+ .filter((slug) => fs.readdirSync(path.join(base, slug), { withFileTypes: true }).some((e) => e.isFile() && !e.name.startsWith('.')));
369
+ }
370
+
371
+ // 把旧结构的需求目录挪到 <需求>/<项目名>/:被 git 跟踪的用 git mv(保留历史),其余直接移动;
372
+ // tmp/old-artifacts/<需求>/ 下归档的上一版也一起挪,复审对照还能找到。
373
+ // 目标里已有同名文件时这个需求整个跳过,留给用户处理。项目名没配置时按 layout 自动取的写进 profile.json。
374
+ // 返回 { project, moved: [{ from, to }], skipped: [{ dir, reason }] }
375
+ export function migrateLayout(root) {
376
+ const L = layout(root);
377
+ const slugs = legacyRequirements(root);
378
+ if (!slugs.length) return { project: L.project, moved: [], skipped: [] };
379
+ if (!L.project) return { project: null, moved: [], skipped: slugs.map((s) => ({ dir: `${REQ_DIR}/${s}`, reason: '还没配置项目名' })) };
380
+ const repoOf = (abs) => (L.mode === 'repo' ? L.repo : root);
381
+ const tracked = (abs) => git(repoOf(abs), ['ls-files', '--error-unmatch', '--', path.relative(repoOf(abs), abs)]).status === 0;
382
+ const moved = []; const skipped = [];
383
+ const move = (from, to, useGit) => {
384
+ fs.mkdirSync(path.dirname(to), { recursive: true });
385
+ const cwd = repoOf(from);
386
+ if (useGit && tracked(from) && git(cwd, ['mv', '--', path.relative(cwd, from), path.relative(cwd, to)]).status === 0) return;
387
+ fs.renameSync(from, to);
388
+ };
389
+ for (const slug of slugs) {
390
+ const dir = path.join(L.base, slug);
391
+ const files = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name);
392
+ const target = path.join(dir, L.project);
393
+ const clash = files.filter((f) => fs.existsSync(path.join(target, f)));
394
+ if (clash.length) { skipped.push({ dir: relOf(root, dir), reason: `${relOf(root, target)} 里已有 ${clash.join('、')}` }); continue; }
395
+ for (const f of files) {
396
+ move(path.join(dir, f), path.join(target, f), true);
397
+ moved.push({ from: relOf(root, path.join(dir, f)), to: relOf(root, path.join(target, f)) });
398
+ }
399
+ const m = readJson(path.join(target, 'meta.json'));
400
+ if (m && m.project !== L.project) fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify({ ...m, project: L.project }, null, 2)}\n`);
401
+ const old = path.join(root, OLD_DIR, slug);
402
+ if (isDir(old)) {
403
+ for (const e of fs.readdirSync(old, { withFileTypes: true })) if (e.isFile()) move(path.join(old, e.name), path.join(old, L.project, e.name), false);
404
+ }
405
+ }
406
+ if (moved.length && !L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
407
+ return { project: L.project, moved, skipped };
408
+ }
409
+
350
410
  // ------------------------------------------------------------------ 配置
351
411
  // 修改 artifacts 配置:团队共用的写 profile.json,--local 写个人的 profile.local.json(不提交)
352
412
  export function setConfig(root, patch, { local = false } = {}) {
@@ -68,7 +68,8 @@ export function docnexusConnected(root) {
68
68
 
69
69
  export function resolveTemplate(root, skill, id) {
70
70
  const meta = skillMeta(skill);
71
- const t = meta?.templates.find((x) => x.id === id);
71
+ if (!meta) return { ok: false, error: 'unknown', message: `skill「${skill}」没有安装(${rel(root, SKILLS_DIR)} 下找不到 ${skill}/skill.json);重跑安装并在 --skills 里带上它` };
72
+ const t = meta.templates.find((x) => x.id === id);
72
73
  if (!t) return { ok: false, error: 'unknown', message: `skill「${skill}」没有登记模板「${id}」(见 ${skill}/skill.json 的 templates)` };
73
74
  const lib = path.join(docnexusRoot(root), 'docnexus', 'library', 'templates', skill, id);
74
75
  const project = path.join(root, TEMPLATES_DIR, skill, id);
@@ -195,8 +196,8 @@ export function status(root) {
195
196
  categories: Object.fromEntries(skillMetas().map((m) => [m.name, resolveCategory(root, m.name)]).filter(([, c]) => c.ok).map(([n, c]) => [n, c.category])),
196
197
  how: dn ? '缺模板时运行 npx @rowansenne/workflow-run seed-templates' : '重跑 npx @rowansenne/workflow-run 并加 --with-docnexus',
197
198
  },
198
- // 产物位置:项目里(默认)或独立文档仓库(workflow/references/requirements.md)
199
- artifacts: (() => { const L = layout(root); return { mode: L.mode, ok: L.ok, ...(L.mode === 'repo' ? { repo: L.repo_rel, project: L.project, commit_prompt: L.commit_prompt } : { dir: 'workflow-run/docs/requirements' }), how: L.ok ? null : L.hint }; })(),
199
+ // 产物位置:项目里(默认)或独立文档仓库,两种都是 docs/requirements/<需求>/<项目>/(workflow/references/requirements.md)
200
+ artifacts: (() => { const L = layout(root); return { mode: L.mode, ok: L.ok, dir: 'workflow-run/docs/requirements', project: L.project, ...(L.mode === 'repo' ? { repo: L.repo_rel, commit_prompt: L.commit_prompt } : {}), how: L.ok ? null : L.hint }; })(),
200
201
  // 代码图谱:可用时读代码、查调用链与影响面优先用它(见 workflow/references/codegraph.md)
201
202
  codegraph: codegraphStatus(root),
202
203
  database: {
@@ -12,16 +12,17 @@ import path from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { collect, KINDS } from './artifacts.mjs';
15
- import { requirements, repoRequirements, layout, parseDocnexus } from './requirement.mjs';
15
+ import { repoRequirements, layout, parseDocnexus, REQ_DIR } from './requirement.mjs';
16
16
  import { parseReview, parseDelivery, captureState, buildReport } from './report.mjs';
17
17
 
18
18
  const DEFAULT_OUT = 'workflow-run/tmp/view/index.html';
19
19
 
20
20
  // 页面数据:按需求分组
21
21
  export function viewData(root) {
22
- // 文档仓库模式:连同其他项目(服务)的产物一起看,一个需求下按项目区分
22
+ // 连同其他项目(服务)的产物一起看,一个需求下按项目区分(文档仓库里常见;两种模式目录结构相同)
23
23
  const L = layout(root);
24
- const metas = new Map(L.mode === 'repo' ? repoRequirements(root, L).map((r) => [r.slug, { name: r.name, dir: null, branches: [...new Set(r.projects.flatMap((p) => p.meta.branches ?? []))], meta: r.projects.find((p) => p.project === L.project)?.meta ?? r.projects[0]?.meta ?? {} }]) : requirements(root, L).map((r) => [r.slug, r]));
24
+ const reqDirOf = (slug) => (L.mode === 'repo' ? `${L.repo_name}:docs/requirements/${slug}` : `${REQ_DIR}/${slug}`);
25
+ const metas = new Map(repoRequirements(root, L).map((r) => [r.slug, { name: r.name, dir: reqDirOf(r.slug), branches: [...new Set(r.projects.flatMap((p) => p.meta.branches ?? []))], meta: r.projects.find((p) => p.project === L.project)?.meta ?? r.projects[0]?.meta ?? {} }]));
25
26
  const groups = new Map();
26
27
  for (const a of collect(root, { allProjects: true })) {
27
28
  const id = a.requirement ? `req:${a.requirement}` : `branch:${a.branch ?? '(无分支)'}`;
@@ -29,7 +30,7 @@ export function viewData(root) {
29
30
  const m = a.requirement ? metas.get(a.requirement) : null;
30
31
  groups.set(id, {
31
32
  id, name: m?.name ?? (a.requirement ?? a.branch ?? '(无分支)'),
32
- dir: m?.dir ?? (a.requirement && L.mode === 'repo' ? `${L.repo_name}:docs/requirements/${a.requirement}` : null), named_by: m?.meta?.named_by ?? null, branches: new Set(m?.branches ?? []), projects: new Set(), artifacts: [],
33
+ dir: m?.dir ?? (a.requirement ? reqDirOf(a.requirement) : null), named_by: m?.meta?.named_by ?? null, branches: new Set(m?.branches ?? []), projects: new Set(), artifacts: [],
33
34
  });
34
35
  }
35
36
  const g = groups.get(id);