yadflow 1.0.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/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +559 -0
- package/bin/sdlc.mjs +135 -0
- package/cli/commit.mjs +81 -0
- package/cli/epic-state.mjs +220 -0
- package/cli/gate.mjs +456 -0
- package/cli/lib.mjs +142 -0
- package/cli/manifest.mjs +119 -0
- package/cli/openpr.mjs +65 -0
- package/cli/plan.mjs +127 -0
- package/cli/platform.mjs +151 -0
- package/cli/reconcile.mjs +83 -0
- package/cli/repo.mjs +61 -0
- package/cli/setup.mjs +208 -0
- package/package.json +51 -0
- package/skills/sdlc/config.yaml +156 -0
- package/skills/sdlc/install.sh +51 -0
- package/skills/sdlc/module-help.csv +17 -0
- package/skills/sdlc-author-analysis/SKILL.md +136 -0
- package/skills/sdlc-author-architecture/SKILL.md +180 -0
- package/skills/sdlc-author-architecture/references/contract-format.md +72 -0
- package/skills/sdlc-author-epic/SKILL.md +154 -0
- package/skills/sdlc-author-epic/references/state-schema.md +187 -0
- package/skills/sdlc-author-stories/SKILL.md +109 -0
- package/skills/sdlc-author-stories/references/story-schema.md +46 -0
- package/skills/sdlc-author-ui/SKILL.md +113 -0
- package/skills/sdlc-backfill/SKILL.md +91 -0
- package/skills/sdlc-backfill/references/backfill.md +66 -0
- package/skills/sdlc-backfill/templates/checks/backfill-check.sh +42 -0
- package/skills/sdlc-checks/SKILL.md +138 -0
- package/skills/sdlc-checks/references/check-gates.md +168 -0
- package/skills/sdlc-checks/templates/checks/build-test-lint.sh +14 -0
- package/skills/sdlc-checks/templates/checks/contract-check.sh +62 -0
- package/skills/sdlc-checks/templates/checks/spec-link.sh +38 -0
- package/skills/sdlc-checks/templates/checks/verified-commits.sh +120 -0
- package/skills/sdlc-checks/templates/github/sdlc-checks.yml +45 -0
- package/skills/sdlc-checks/templates/github/sdlc-verified-commits.yml +22 -0
- package/skills/sdlc-checks/templates/gitlab/.gitlab-ci.yml +40 -0
- package/skills/sdlc-checks/templates/gitlab/gitlab-ci.include-root.yml +7 -0
- package/skills/sdlc-checks/templates/gitlab/sdlc-checks.gitlab-ci.yml +47 -0
- package/skills/sdlc-checks/templates/gitlab/sdlc-verified-commits.gitlab-ci.yml +21 -0
- package/skills/sdlc-connect-repos/SKILL.md +159 -0
- package/skills/sdlc-connect-repos/references/code-context.md +92 -0
- package/skills/sdlc-connect-repos/references/hub-config.md +77 -0
- package/skills/sdlc-connect-repos/references/repos-registry.md +62 -0
- package/skills/sdlc-hub-bridge/SKILL.md +119 -0
- package/skills/sdlc-hub-bridge/references/bridge.md +136 -0
- package/skills/sdlc-hub-bridge/references/login-roster.md +42 -0
- package/skills/sdlc-hub-bridge/templates/checks/hub-route.sh +50 -0
- package/skills/sdlc-hub-bridge/templates/github/sdlc-gate-sync.yml +63 -0
- package/skills/sdlc-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +7 -0
- package/skills/sdlc-hub-bridge/templates/gitlab/sdlc-gate-sync.gitlab-ci.yml +64 -0
- package/skills/sdlc-implement/SKILL.md +143 -0
- package/skills/sdlc-implement/references/implement-conventions.md +103 -0
- package/skills/sdlc-implement/templates/.gitmessage +17 -0
- package/skills/sdlc-pr-template/SKILL.md +86 -0
- package/skills/sdlc-pr-template/references/risk-routing.md +54 -0
- package/skills/sdlc-pr-template/templates/checks/risk-route.sh +44 -0
- package/skills/sdlc-pr-template/templates/github/pull_request_template.md +30 -0
- package/skills/sdlc-pr-template/templates/gitlab/merge_request_templates/Default.md +32 -0
- package/skills/sdlc-pr-template/templates/hub/github/pull_request_template.md +36 -0
- package/skills/sdlc-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +37 -0
- package/skills/sdlc-review-comments/SKILL.md +63 -0
- package/skills/sdlc-review-comments/references/comment-conventions.md +55 -0
- package/skills/sdlc-review-comments/templates/github/REVIEW_COMMENTS.md +49 -0
- package/skills/sdlc-review-comments/templates/gitlab/REVIEW_COMMENTS.md +49 -0
- package/skills/sdlc-review-gate/SKILL.md +196 -0
- package/skills/sdlc-review-gate/references/gating.md +79 -0
- package/skills/sdlc-run/SKILL.md +109 -0
- package/skills/sdlc-run/references/run-loop.md +121 -0
- package/skills/sdlc-ship/SKILL.md +86 -0
- package/skills/sdlc-ship/references/ship-and-record.md +67 -0
- package/skills/sdlc-ship/templates/.coderabbit.yaml +19 -0
- package/skills/sdlc-spec/SKILL.md +119 -0
- package/skills/sdlc-spec/references/spec-handoff.md +101 -0
- package/skills/sdlc-status/SKILL.md +92 -0
package/bin/sdlc.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `sdlc` — setup/maintenance + the PR-driven review gate + build helpers for the SDLC module.
|
|
3
|
+
import { VERSION } from '../cli/manifest.mjs';
|
|
4
|
+
import { c, log, closePrompts } from '../cli/lib.mjs';
|
|
5
|
+
import { runSetup } from '../cli/setup.mjs';
|
|
6
|
+
import { reconcile } from '../cli/reconcile.mjs';
|
|
7
|
+
import { gateOpen, gateSync, gateComments, gateStatus, gateCi } from '../cli/gate.mjs';
|
|
8
|
+
import { isValidEpicId } from '../cli/epic-state.mjs';
|
|
9
|
+
import { runCommit } from '../cli/commit.mjs';
|
|
10
|
+
import { runOpenPr } from '../cli/openpr.mjs';
|
|
11
|
+
import { runRepo } from '../cli/repo.mjs';
|
|
12
|
+
|
|
13
|
+
const HELP = `${c.bold('sdlc')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
14
|
+
|
|
15
|
+
${c.bold('Setup & maintenance')}
|
|
16
|
+
sdlc setup Guided first-run setup (install module, connect & wire repos)
|
|
17
|
+
sdlc check Report what is missing / drifted / stale (read-only)
|
|
18
|
+
sdlc check --fix Reconcile: fill what is missing, update what changed
|
|
19
|
+
sdlc update Apply drift only (alias for: check --fix --scope=changed)
|
|
20
|
+
|
|
21
|
+
${c.bold('Review gate (front half)')}
|
|
22
|
+
sdlc gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
|
|
23
|
+
sdlc gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
|
|
24
|
+
sdlc gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
25
|
+
sdlc gate status <epic> Show each review step + approvals
|
|
26
|
+
sdlc gate ci [--branch <head>] [--pr <n>]
|
|
27
|
+
CI entry (hub workflow): derive epic/artifact from the review branch,
|
|
28
|
+
sync, commit the ledger to the default branch (sweep all PRs if no --branch)
|
|
29
|
+
|
|
30
|
+
${c.bold('Build helpers')}
|
|
31
|
+
sdlc commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
|
|
32
|
+
sdlc open-pr [--repo <name>] Open a code-repo task PR/MR from the template
|
|
33
|
+
sdlc repo list Show connected repos (fresh / stale)
|
|
34
|
+
sdlc repo refresh [name] Re-pack a stale repo (a human decision)
|
|
35
|
+
|
|
36
|
+
${c.bold('Options')}
|
|
37
|
+
--dir <path> Target project root (default: cwd)
|
|
38
|
+
--type <t> commit: feat|fix|docs|refactor|test|perf|build|ci|chore|revert
|
|
39
|
+
-m, --message <s> commit: subject / PR title
|
|
40
|
+
--task <id> commit: Task trailer (else derived from the branch)
|
|
41
|
+
--ai <id> commit: co-author — claude|copilot|cursor|coderabbit|none (default none)
|
|
42
|
+
--contract-change commit/open-pr: mark the contract surface touched
|
|
43
|
+
--risk <level> open-pr: low|medium|high (default low)
|
|
44
|
+
--repo <name> open-pr: target a registered repo by name
|
|
45
|
+
--dry-run commit: print the message, do not commit
|
|
46
|
+
--force commit: bypass the atomic-file guard / re-copy unchanged files
|
|
47
|
+
--branch <head> gate ci: the review PR/MR head branch (review/EP-<slug>/<artifact>)
|
|
48
|
+
--pr <n> gate ci: the PR/MR number from the CI event
|
|
49
|
+
--no-push gate ci: commit the ledger but do not push
|
|
50
|
+
-h, --help Show this help
|
|
51
|
+
-v, --version Print version`;
|
|
52
|
+
|
|
53
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr']);
|
|
54
|
+
|
|
55
|
+
function parseArgs(argv) {
|
|
56
|
+
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === '--fix') o.fix = true;
|
|
60
|
+
else if (a === '--force') o.force = true;
|
|
61
|
+
else if (a === '--contract-change') o.contractChange = true;
|
|
62
|
+
else if (a === '--no-push') o.noPush = true;
|
|
63
|
+
else if (a === '--dry-run') o.dryRun = true;
|
|
64
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
65
|
+
else if (a === '-v' || a === '--version') o.version = true;
|
|
66
|
+
else if (a.startsWith('--scope=')) o.scope = a.slice('--scope='.length);
|
|
67
|
+
else if (a === '-m' || a === '--message') o.message = takeValue(argv, ++i, a);
|
|
68
|
+
else if (VALUE_FLAGS.has(a)) o[a.replace(/^--/, '')] = takeValue(argv, ++i, a);
|
|
69
|
+
else o._.push(a);
|
|
70
|
+
}
|
|
71
|
+
return o;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A value flag must be followed by a token; erroring beats silently passing `undefined` downstream.
|
|
75
|
+
function takeValue(argv, i, flag) {
|
|
76
|
+
const v = argv[i];
|
|
77
|
+
if (v === undefined || v.startsWith('-')) throw new Error(`${flag} expects a value`);
|
|
78
|
+
return v;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
const o = parseArgs(process.argv.slice(2));
|
|
83
|
+
const cmd = o._[0];
|
|
84
|
+
if (o.version) return log(VERSION);
|
|
85
|
+
if (o.help || !cmd) return log(HELP);
|
|
86
|
+
|
|
87
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
88
|
+
switch (cmd) {
|
|
89
|
+
case 'setup':
|
|
90
|
+
await runSetup(o.dir, { today, force: o.force });
|
|
91
|
+
break;
|
|
92
|
+
case 'check':
|
|
93
|
+
await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, today });
|
|
94
|
+
break;
|
|
95
|
+
case 'update':
|
|
96
|
+
await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, today });
|
|
97
|
+
break;
|
|
98
|
+
case 'gate': {
|
|
99
|
+
const [, action, epic, artifact] = o._;
|
|
100
|
+
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
|
101
|
+
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, push: !o.noPush, today }); break; }
|
|
102
|
+
if (!epic) { log(c.red('usage: sdlc gate <open|sync|comments|status|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
103
|
+
// The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
|
|
104
|
+
if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
105
|
+
if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
|
|
106
|
+
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today });
|
|
107
|
+
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
108
|
+
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
109
|
+
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|ci)`)); process.exitCode = 1; }
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'commit':
|
|
113
|
+
await runCommit(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force });
|
|
114
|
+
break;
|
|
115
|
+
case 'open-pr':
|
|
116
|
+
await runOpenPr(o.dir, { repo: o.repo, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
|
|
117
|
+
break;
|
|
118
|
+
case 'repo': {
|
|
119
|
+
const [, action, name] = o._;
|
|
120
|
+
await runRepo(o.dir, { action: action || 'list', name, today });
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
log(c.red(`unknown command: ${cmd}`));
|
|
125
|
+
log(HELP);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main()
|
|
131
|
+
.catch((err) => {
|
|
132
|
+
log(c.red(`\nsdlc failed: ${err?.message || err}`));
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
})
|
|
135
|
+
.finally(closePrompts);
|
package/cli/commit.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `sdlc commit` — commit by the SDLC conventions (CONTRIBUTING.md / config.yaml build).
|
|
2
|
+
// Subject is Conventional Commits; trailers are emitted in the fixed order
|
|
3
|
+
// Task -> Contract-Change -> Co-Authored-By. The human git author OWNS the commit; the AI is only a
|
|
4
|
+
// co-author (flagged with --ai, or `none` for human-only). An atomic-commit guard keeps diffs small.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { c, log, ok, info, warn, fail, run, exists } from './lib.mjs';
|
|
8
|
+
import {
|
|
9
|
+
COMMIT_TYPES, AI_COAUTHORS, ATOMIC_FILE_LIMIT,
|
|
10
|
+
TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER,
|
|
11
|
+
} from './manifest.mjs';
|
|
12
|
+
|
|
13
|
+
// PURE — unit tested directly. Build the full commit message text.
|
|
14
|
+
export function buildCommitMessage({ type, subject, task, contractChange = false, ai = 'none', body = '' }) {
|
|
15
|
+
if (!COMMIT_TYPES.includes(type)) throw new Error(`invalid commit type "${type}" (one of: ${COMMIT_TYPES.join(', ')})`);
|
|
16
|
+
if (!subject || !subject.trim()) throw new Error('commit subject is required');
|
|
17
|
+
if (!(ai in AI_COAUTHORS)) throw new Error(`unknown --ai "${ai}" (one of: ${Object.keys(AI_COAUTHORS).join(', ')})`);
|
|
18
|
+
if (/\.$/.test(subject.trim())) throw new Error('subject must not end with a period');
|
|
19
|
+
|
|
20
|
+
const trailers = [];
|
|
21
|
+
if (task) trailers.push(`${TASK_TRAILER}: ${task}`);
|
|
22
|
+
if (contractChange) trailers.push(`${CONTRACT_CHANGE_TRAILER}: yes`);
|
|
23
|
+
const co = AI_COAUTHORS[ai];
|
|
24
|
+
if (co) trailers.push(`${COAUTHOR_TRAILER}: ${co.name} <${co.email}>`);
|
|
25
|
+
|
|
26
|
+
const parts = [`${type}: ${subject.trim()}`];
|
|
27
|
+
if (body?.trim()) parts.push('', body.trim());
|
|
28
|
+
if (trailers.length) parts.push('', trailers.join('\n'));
|
|
29
|
+
return parts.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// feat/EP-istifta-inquiries-S01-T01-create-inquiry -> EP-istifta-inquiries-S01-T01
|
|
33
|
+
export function taskFromBranch(branch = '') {
|
|
34
|
+
const m = branch.match(/(.+-S\d+-T\d+)(?:-|$)/i);
|
|
35
|
+
return m ? m[1].replace(/^[a-z]+\//i, '') : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runCommit(root, opts = {}) {
|
|
39
|
+
log(c.bold('\nsdlc commit'));
|
|
40
|
+
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
41
|
+
|
|
42
|
+
const staged = run('git', ['diff', '--cached', '--name-only'], { cwd: root }).stdout.split('\n').filter(Boolean);
|
|
43
|
+
if (!staged.length) { fail('nothing staged — `git add` your atomic change first'); process.exitCode = 1; return; }
|
|
44
|
+
|
|
45
|
+
if (staged.length > ATOMIC_FILE_LIMIT && !opts.force) {
|
|
46
|
+
warn(`${staged.length} files staged (atomic guard: ≤${ATOMIC_FILE_LIMIT}). Split the change, or pass --force.`);
|
|
47
|
+
for (const f of staged) info(f);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root }).stdout;
|
|
53
|
+
const task = opts.task || taskFromBranch(branch);
|
|
54
|
+
if (!task) warn('no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
|
|
55
|
+
|
|
56
|
+
let message;
|
|
57
|
+
try {
|
|
58
|
+
message = buildCommitMessage({
|
|
59
|
+
type: opts.type, subject: opts.message, task,
|
|
60
|
+
contractChange: !!opts.contractChange, ai: opts.ai || 'none',
|
|
61
|
+
});
|
|
62
|
+
} catch (e) { fail(e.message); process.exitCode = 1; return; }
|
|
63
|
+
|
|
64
|
+
if (opts.dryRun) { log('\n' + c.dim(message) + '\n'); info('dry run — not committed'); return { message }; }
|
|
65
|
+
|
|
66
|
+
const r = run('git', ['commit', '-m', message], { cwd: root });
|
|
67
|
+
if (!r.ok) { fail(`git commit failed — ${r.stderr.split('\n')[0] || r.code}`); process.exitCode = 1; return { message }; }
|
|
68
|
+
ok(`committed ${staged.length} file(s)${task ? ` for ${task}` : ''}`);
|
|
69
|
+
if (opts.contractChange) warn('Contract-Change: yes — this routes back to the architecture gate');
|
|
70
|
+
return { message };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// installed by sdlc-implement, but offer it here too for convenience.
|
|
74
|
+
export function ensureGitMessage(repoRoot, templateSrc) {
|
|
75
|
+
const dest = path.join(repoRoot, '.gitmessage');
|
|
76
|
+
if (exists(dest)) return false;
|
|
77
|
+
if (!templateSrc || !exists(templateSrc)) return false;
|
|
78
|
+
fs.copyFileSync(templateSrc, dest);
|
|
79
|
+
run('git', ['config', 'commit.template', '.gitmessage'], { cwd: repoRoot });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Per-epic file ledger + the gate predicate. The file ledger (epics/<epic>/.sdlc/*.json) is the
|
|
2
|
+
// source of truth; the platform PR/MR is only an input path. Everything here is pure / filesystem —
|
|
3
|
+
// no gh/glab — so the predicate is unit-testable without a network. Node built-ins only.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { readJSONStrict, writeJSON, fileSha } from './lib.mjs';
|
|
8
|
+
import { epicFiles } from './manifest.mjs';
|
|
9
|
+
|
|
10
|
+
const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
|
|
11
|
+
|
|
12
|
+
// Epic ids are EP-<slug> with [a-z0-9-] only — anything else (uppercase, dots, slashes) is
|
|
13
|
+
// rejected before it can become a path segment under epics/.
|
|
14
|
+
export const isValidEpicId = (epic) => /^EP-[a-z0-9-]+$/.test(epic || '');
|
|
15
|
+
|
|
16
|
+
export const epicRoot = (root, epic) => path.join(root, 'epics', epic);
|
|
17
|
+
|
|
18
|
+
// epic.md -> "epic"; architecture.md -> "architecture"; stories/ -> "stories";
|
|
19
|
+
// stories/EP-x-S01.md -> "stories-S01".
|
|
20
|
+
export function artifactBase(artifact) {
|
|
21
|
+
const a = artifact.replace(/\/$/, '');
|
|
22
|
+
if (a === 'stories' || a === 'stories/') return 'stories';
|
|
23
|
+
const m = a.match(/stories\/.*?(S\d+)\.md$/i);
|
|
24
|
+
if (m) return `stories-${m[1]}`;
|
|
25
|
+
return path.basename(a).replace(/\.md$/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `review/EP-<slug>/<artifact-base>` -> { epic, base } — the branch convention `gate open` creates.
|
|
29
|
+
// Null for any other branch: the guard CI uses to no-op on non-review branches.
|
|
30
|
+
export function parseReviewBranch(branch = '') {
|
|
31
|
+
const m = branch.match(/^review\/(EP-[a-z0-9-]+)\/(.+)$/);
|
|
32
|
+
return m ? { epic: m[1], base: m[2] } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The reverse of artifactBase: an artifact-base back to the ledger's artifact path. A single-story
|
|
36
|
+
// base (stories-S01) still maps to stories/ — the stories gate is ONE step over the whole set
|
|
37
|
+
// (storiesHash fingerprints the directory), so any story branch syncs the same review step.
|
|
38
|
+
export function artifactFromBase(base) {
|
|
39
|
+
if (base === 'stories' || /^stories-S\d+$/i.test(base)) return 'stories/';
|
|
40
|
+
return `${base}.md`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The files (relative to the epic dir) a review of this artifact covers — what `gate open` commits
|
|
44
|
+
// on the review branch, and what the CI overlay checks out from the head ref (and never commits).
|
|
45
|
+
// Architecture mirrors artifactHash(): the approval is bound to the locked contract surface too.
|
|
46
|
+
export function artifactPaths(base) {
|
|
47
|
+
if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
|
|
48
|
+
if (base === 'stories') return ['stories'];
|
|
49
|
+
return [`${base}.md`];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
|
|
53
|
+
export function upsertHubPr(hubPrs = [], rec) {
|
|
54
|
+
return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SHA-256 of the contract surface block (architecture only). Mirrors
|
|
58
|
+
// sdlc-author-architecture/references/contract-format.md (awk markers + sha256).
|
|
59
|
+
// Line endings are normalized to LF so the same surface hashes identically across
|
|
60
|
+
// platforms (a CRLF re-save must not revoke approvals). A BEGIN without an END is
|
|
61
|
+
// malformed and yields null — never a silent hash of everything to end-of-file.
|
|
62
|
+
export function contractSurfaceHash(epicDir) {
|
|
63
|
+
const file = path.join(epicDir, 'contract.md');
|
|
64
|
+
if (!fs.existsSync(file)) return null;
|
|
65
|
+
const lines = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
|
|
66
|
+
let inside = false;
|
|
67
|
+
let terminated = true;
|
|
68
|
+
const body = [];
|
|
69
|
+
for (const ln of lines) {
|
|
70
|
+
if (/CONTRACT-SURFACE:BEGIN/.test(ln)) { inside = true; terminated = false; continue; }
|
|
71
|
+
if (/CONTRACT-SURFACE:END/.test(ln)) { inside = false; terminated = true; continue; }
|
|
72
|
+
if (inside) body.push(ln);
|
|
73
|
+
}
|
|
74
|
+
if (!terminated || !body.length) return null;
|
|
75
|
+
return 'sha256:' + createHash('sha256').update(body.join('\n')).digest('hex');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Deterministic fingerprint of the whole stories/ set: hash each story file, sort, combine. Lets an
|
|
79
|
+
// edit to any story revoke prior stories-review approvals (the escalated, per-repo gate).
|
|
80
|
+
export function storiesHash(epicDir) {
|
|
81
|
+
const dir = path.join(epicDir, 'stories');
|
|
82
|
+
if (!fs.existsSync(dir)) return null;
|
|
83
|
+
const parts = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
|
|
84
|
+
.map((f) => `${f}:${fileSha(path.join(dir, f))}`);
|
|
85
|
+
if (!parts.length) return null;
|
|
86
|
+
return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The content fingerprint an approval is bound to. For architecture the fingerprint is the locked
|
|
90
|
+
// contract surface (a re-lock => stale); for stories it is the whole stories/ set; for every other
|
|
91
|
+
// artifact it is the file's bytes.
|
|
92
|
+
export function artifactHash(epicDir, artifact) {
|
|
93
|
+
const b = artifactBase(artifact);
|
|
94
|
+
if (b === 'architecture') return contractSurfaceHash(epicDir);
|
|
95
|
+
if (b === 'stories') return storiesHash(epicDir);
|
|
96
|
+
return fileSha(path.join(epicDir, artifact.replace(/\/$/, '')));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Shape checks for the ledger files. Fail fast with the exact file named — a wrong-shape ledger
|
|
100
|
+
// silently treated as a default would be rewritten by the next sync, destroying the real data.
|
|
101
|
+
const badShape = (file, what) => new Error(`${file}: ${what} — fix the file or restore it from git`);
|
|
102
|
+
function requireArray(v, file) {
|
|
103
|
+
if (!Array.isArray(v)) throw badShape(file, 'expected a JSON array');
|
|
104
|
+
return v;
|
|
105
|
+
}
|
|
106
|
+
function validateState(state, file) {
|
|
107
|
+
if (state === null) return null; // missing state.json = epic not seeded yet, a normal state
|
|
108
|
+
if (typeof state !== 'object' || Array.isArray(state)) throw badShape(file, 'expected a JSON object');
|
|
109
|
+
if (!Array.isArray(state.steps) || !state.steps.length) throw badShape(file, 'expected a non-empty `steps` array');
|
|
110
|
+
for (const s of state.steps) {
|
|
111
|
+
if (!s || typeof s.id !== 'string' || typeof s.type !== 'string' || typeof s.status !== 'string') {
|
|
112
|
+
throw badShape(file, 'every step needs string `id`, `type` and `status`');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (typeof state.currentStep !== 'string') throw badShape(file, 'expected a string `currentStep`');
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function loadLedger(epicDir) {
|
|
120
|
+
const f = epicFiles(epicDir);
|
|
121
|
+
return {
|
|
122
|
+
files: f,
|
|
123
|
+
state: validateState(readJSONStrict(f.state, null), f.state),
|
|
124
|
+
approvals: requireArray(readJSONStrict(f.approvals, []), f.approvals),
|
|
125
|
+
comments: requireArray(readJSONStrict(f.comments, []), f.comments),
|
|
126
|
+
hubPrs: requireArray(readJSONStrict(f.hubPrs, []), f.hubPrs),
|
|
127
|
+
contractLock: readJSONStrict(f.contractLock, null),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The review+approve step for an artifact (or the current step if it is a review step).
|
|
132
|
+
export function findReviewStep(state, artifact) {
|
|
133
|
+
if (!state?.steps) return null;
|
|
134
|
+
const base = artifactBase(artifact);
|
|
135
|
+
return state.steps.find(
|
|
136
|
+
(s) => s.type === 'review+approve' && artifactBase(s.artifact) === base,
|
|
137
|
+
) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const isEscalated = (step) =>
|
|
141
|
+
(step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t)) || step?.id === 'stories-review';
|
|
142
|
+
|
|
143
|
+
const uniqueBy = (arr, key) => {
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
return arr.filter((x) => (seen.has(x[key]) ? false : seen.add(x[key])));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// PURE gate predicate. Given the step, its approvals, the current content hash, the PR thread/merge
|
|
149
|
+
// state and the touched domains, decide whether the gate passes — and exactly what is missing.
|
|
150
|
+
// `currentHash` drops any approval bound to a different hash (revoke-on-change). `merged` /
|
|
151
|
+
// `threadsResolved` come from the platform; with no bridge they default to the "advance" intent.
|
|
152
|
+
export function gatePredicate({
|
|
153
|
+
step,
|
|
154
|
+
approvals,
|
|
155
|
+
currentHash = null,
|
|
156
|
+
touchedDomains = [],
|
|
157
|
+
defaultReviewers = 1,
|
|
158
|
+
threadsResolved = true,
|
|
159
|
+
merged = true,
|
|
160
|
+
}) {
|
|
161
|
+
const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
|
|
162
|
+
// Revoke-on-change: an approval bound to a stale content hash no longer counts.
|
|
163
|
+
const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
|
|
164
|
+
const live = forStep.filter((a) => !stale.includes(a));
|
|
165
|
+
|
|
166
|
+
const owners = uniqueBy(live.filter((a) => a.role === 'owner'), 'approver');
|
|
167
|
+
const reviewers = uniqueBy(live.filter((a) => a.role === 'reviewer'), 'approver');
|
|
168
|
+
const domainOwners = live.filter((a) => a.role === 'domain-owner');
|
|
169
|
+
|
|
170
|
+
const missing = [];
|
|
171
|
+
if (owners.length < 1) missing.push('1 owner approval');
|
|
172
|
+
if (reviewers.length < defaultReviewers) {
|
|
173
|
+
missing.push(`${defaultReviewers - reviewers.length} reviewer approval(s)`);
|
|
174
|
+
}
|
|
175
|
+
const escalate = isEscalated(step);
|
|
176
|
+
if (escalate) {
|
|
177
|
+
for (const d of touchedDomains) {
|
|
178
|
+
if (!domainOwners.some((a) => a.domain === d)) missing.push(`domain-owner for ${d}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const approvalsSatisfied = missing.length === 0;
|
|
182
|
+
if (stale.length) missing.unshift(`${stale.length} approval(s) revoked — artifact changed; re-approve`);
|
|
183
|
+
if (!threadsResolved) missing.push('unresolved review comments');
|
|
184
|
+
if (!merged) missing.push('review PR/MR not merged');
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
approvalsSatisfied,
|
|
188
|
+
threadsResolved,
|
|
189
|
+
merged,
|
|
190
|
+
staleDropped: stale.length,
|
|
191
|
+
passed: approvalsSatisfied && threadsResolved && merged,
|
|
192
|
+
missing,
|
|
193
|
+
rule: escalate ? (step.id === 'stories-review' ? 'per-repo' : 'escalated') : 'base',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Advance the step in state.json once the predicate passes. Mirrors sdlc-review-gate Step 3:
|
|
198
|
+
// mark this review step done, unblock the next step, or set `ready-for-build` for the last one.
|
|
199
|
+
export function advanceState(state, step) {
|
|
200
|
+
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
201
|
+
state.steps[i] = { ...state.steps[i], status: 'done' };
|
|
202
|
+
const next = state.steps[i + 1];
|
|
203
|
+
if (next) {
|
|
204
|
+
next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
|
|
205
|
+
state.currentStep = next.id;
|
|
206
|
+
} else {
|
|
207
|
+
state.currentStep = 'ready-for-build';
|
|
208
|
+
}
|
|
209
|
+
return state;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Mark a step in-review (idempotent) and point currentStep at it.
|
|
213
|
+
export function markInReview(state, step) {
|
|
214
|
+
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
215
|
+
if (state.steps[i].status !== 'done') state.steps[i].status = 'in_review';
|
|
216
|
+
state.currentStep = step.id;
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { writeJSON };
|