yadflow 3.18.1 → 4.0.0-next.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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// CODEOWNERS as a HINT (E68). Part 3: "CODEOWNERS is a hint, never an authority — in practice these files
|
|
2
|
+
// rot." So nothing here decides anything: it never holds a gate, never counts toward an approval, never
|
|
3
|
+
// requests a reviewer. `yad open-pr` prints what the file on the BASE branch lists for the files a change
|
|
4
|
+
// touches, on its own line, beside (never merged with) the people git history names.
|
|
5
|
+
//
|
|
6
|
+
// THE RULES ARE THE PLATFORMS' OWN, taken from their docs (checked 2026-09-22), never from memory:
|
|
7
|
+
//
|
|
8
|
+
// GitHub (docs.github.com, "About code owners"):
|
|
9
|
+
// - the first of `.github/CODEOWNERS`, `CODEOWNERS`, `docs/CODEOWNERS`; a file of 3 MB or more is not loaded;
|
|
10
|
+
// - gitignore patterns, EXCEPT that `\` escaping, `!` negation and `[ ]` ranges do not work;
|
|
11
|
+
// - the LAST matching line decides, and a line with no owners leaves the path with none;
|
|
12
|
+
// - owners are `@user`, `@org/team` or an e-mail address; `#` starts a comment, also after a pattern;
|
|
13
|
+
// - "If any line … contains invalid syntax, that line will be skipped."
|
|
14
|
+
// GitLab (docs.gitlab.com, "Code Owners" and "Syntax of CODEOWNERS file"):
|
|
15
|
+
// - the first of `CODEOWNERS`, `docs/CODEOWNERS`, `.gitlab/CODEOWNERS`;
|
|
16
|
+
// - a path WITHOUT a leading `/` matches at any depth, even with a `/` inside (`internal/README.md`
|
|
17
|
+
// matches `/docs/internal/README.md`) — unlike gitignore, which anchors such a path at the root;
|
|
18
|
+
// - `[Section]`, `^[Optional]`, `[Section][2]`, and default owners after the heading; names are
|
|
19
|
+
// case-insensitive and duplicates combine (required if any copy is); a heading it cannot parse is
|
|
20
|
+
// read as an entry; the unnamed section holds everything before the first heading;
|
|
21
|
+
// - within a section the last matching entry decides; every section answers for itself;
|
|
22
|
+
// - `!path` excludes within its section, and a later entry cannot include it again;
|
|
23
|
+
// - owners are `@user`, `@group`, `@group/subgroup`, `@@role` or an e-mail address; a malformed owner
|
|
24
|
+
// is ignored; "Inline comments are unsupported. Any Code Owners listed in a comment are parsed";
|
|
25
|
+
// - a space in a path is written `\ `.
|
|
26
|
+
//
|
|
27
|
+
// WHAT IS NOT GUESSED. A pattern that uses a form its platform's docs do not describe (`?` or `**` on
|
|
28
|
+
// GitLab outside `/**/`, any `\` on GitHub, `[`) is not matched, and the line is reported as not read — a
|
|
29
|
+
// wrong match would print the wrong person, which is the whole class of bug E67 paid for eight times.
|
|
30
|
+
// One reading is ours, from both platforms' examples: a pattern whose LAST segment holds no wildcard also
|
|
31
|
+
// covers everything under a directory of that name (`apps/`, `**/logs`, GitLab's `docs`), while one whose
|
|
32
|
+
// last segment has a wildcard matches files only (`docs/*` never reaches `docs/build-app/x.md`).
|
|
33
|
+
//
|
|
34
|
+
// NO E-MAIL ADDRESS IS EVER PRINTED (E67's rule): an address owner is kept as "an e-mail address".
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import { gitEnv } from './riskmap-command.mjs';
|
|
40
|
+
|
|
41
|
+
export const CODEOWNERS_PATHS = {
|
|
42
|
+
github: ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'],
|
|
43
|
+
gitlab: ['CODEOWNERS', 'docs/CODEOWNERS', '.gitlab/CODEOWNERS'],
|
|
44
|
+
};
|
|
45
|
+
// GitHub: "CODEOWNERS files must be under 3 MB in size." Read as 3 000 000 bytes: the smaller reading, so
|
|
46
|
+
// a file GitHub may not load is never quietly used here.
|
|
47
|
+
export const GITHUB_MAX_BYTES = 3_000_000;
|
|
48
|
+
|
|
49
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
50
|
+
|
|
51
|
+
// One owner word → { kind, text }, or null when it is not an owner on this platform.
|
|
52
|
+
export function ownerOf(word, platform) {
|
|
53
|
+
// `key` tells two addresses apart for counting; it stays inside this module and is never printed.
|
|
54
|
+
if (EMAIL.test(word)) return { kind: 'email', text: 'an e-mail address', key: word.toLowerCase() };
|
|
55
|
+
if (platform === 'gitlab') {
|
|
56
|
+
if (/^@@[A-Za-z]+$/.test(word)) return { kind: 'role', text: word };
|
|
57
|
+
if (/^@[^\s@/]+(\/[^\s@/]+)+$/.test(word)) return { kind: 'group', text: word };
|
|
58
|
+
if (/^@[^\s@/]+$/.test(word)) return { kind: 'name', text: word };
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (/^@[^\s@/]+\/[^\s@/]+$/.test(word)) return { kind: 'team', text: word };
|
|
62
|
+
if (/^@[^\s@/]+$/.test(word)) return { kind: 'user', text: word };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Split a GitLab line on spaces and tabs, where `\ ` is a space inside a word. Any other `\` is a form the
|
|
67
|
+
// docs do not describe: { bad } says so.
|
|
68
|
+
function gitlabWords(line) {
|
|
69
|
+
const words = [];
|
|
70
|
+
let cur = '';
|
|
71
|
+
for (let i = 0; i < line.length; i++) {
|
|
72
|
+
const ch = line[i];
|
|
73
|
+
if (ch === '\\') {
|
|
74
|
+
if (line[i + 1] === ' ') { cur += ' '; i++; continue; }
|
|
75
|
+
return { bad: 'a `\\` that is not `\\ ` (GitLab\'s docs describe only an escaped space)' };
|
|
76
|
+
}
|
|
77
|
+
if (ch === ' ' || ch === '\t') { if (cur) words.push(cur); cur = ''; continue; }
|
|
78
|
+
cur += ch;
|
|
79
|
+
}
|
|
80
|
+
if (cur) words.push(cur);
|
|
81
|
+
return { words };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A word from the file, quoted for a `not read` reason — unless it holds an `@` after its first character:
|
|
85
|
+
// a pattern can be a mistyped address (`alice@corp.com docs/`), and no address is ever printed.
|
|
86
|
+
const quoted = (w) => (w.indexOf('@', 1) >= 0 ? 'an address-like word' : `\`${w}\``);
|
|
87
|
+
|
|
88
|
+
const escRe = (s) => s.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
|
|
89
|
+
|
|
90
|
+
// A pattern → { re, dirs } or { bad: why }. `re` is tested against a file path and, when `dirs`, against
|
|
91
|
+
// each directory above it (see WHAT IS NOT GUESSED).
|
|
92
|
+
export function patternOf(raw, platform) {
|
|
93
|
+
if (raw.includes('[')) return { bad: `${quoted(raw)} uses \`[\`, which ${platform === 'gitlab' ? 'GitLab\'s docs do not describe' : 'GitHub does not support'}` };
|
|
94
|
+
if (platform !== 'gitlab' && raw.includes('\\')) return { bad: `${quoted(raw)} uses \`\\\`, which GitHub does not support in CODEOWNERS` };
|
|
95
|
+
if (platform === 'gitlab' && raw.includes('?')) return { bad: `${quoted(raw)} uses \`?\`, which GitLab's docs do not describe` };
|
|
96
|
+
let p = raw;
|
|
97
|
+
const dirOnly = p.endsWith('/');
|
|
98
|
+
if (dirOnly) p = p.replace(/\/+$/, '');
|
|
99
|
+
const rooted = p.startsWith('/');
|
|
100
|
+
if (rooted) p = p.replace(/^\/+/, '');
|
|
101
|
+
if (!p) return { re: /^/, dirs: true, all: true };
|
|
102
|
+
const segs = p.split('/');
|
|
103
|
+
// GitHub follows gitignore: a `/` at the start or in the middle anchors the pattern at the root.
|
|
104
|
+
// GitLab anchors only on a leading `/`.
|
|
105
|
+
const anchored = rooted || (platform !== 'gitlab' && segs.length > 1);
|
|
106
|
+
let body = '';
|
|
107
|
+
for (let i = 0; i < segs.length; i++) {
|
|
108
|
+
const seg = segs[i];
|
|
109
|
+
const last = i === segs.length - 1;
|
|
110
|
+
if (seg === '**') {
|
|
111
|
+
if (!last) { body += '(?:.*/)?'; continue; }
|
|
112
|
+
if (platform === 'gitlab') return { bad: `${quoted(raw)} ends in \`/**\`, which GitLab's docs do not describe (they say to end a directory with \`/\`)` };
|
|
113
|
+
if (i === 0) { body += '.*'; continue; }
|
|
114
|
+
body = body.replace(/\/$/, '') + '/.+';
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (seg.includes('**') && platform === 'gitlab') return { bad: `${quoted(raw)} uses \`**\` inside a name, which GitLab's docs do not describe` };
|
|
118
|
+
body += seg.split('').map((ch) => (ch === '*' ? '[^/]*' : ch === '?' ? '[^/]' : escRe(ch))).join('').replace(/(\[\^\/\]\*)+/g, '[^/]*');
|
|
119
|
+
if (!last) body += '/';
|
|
120
|
+
}
|
|
121
|
+
const lastSeg = segs[segs.length - 1];
|
|
122
|
+
// `s`: a folder name can hold a line break (git lists it), and `.` must cross it like any character.
|
|
123
|
+
return { re: new RegExp(`^${anchored ? '' : '(?:.*/)?'}${body}$`, 's'), dirs: dirOnly || !/[*?]/.test(lastSeg), fileToo: !dirOnly };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function matches(pat, file) {
|
|
127
|
+
if (pat.all) return true;
|
|
128
|
+
if (pat.fileToo && pat.re.test(file)) return true;
|
|
129
|
+
if (!pat.dirs) return false;
|
|
130
|
+
const segs = file.split('/');
|
|
131
|
+
for (let i = 1; i < segs.length; i++) if (pat.re.test(segs.slice(0, i).join('/'))) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The file's text → { rules, skipped }. A rule is { line, section, negate, pattern, pat, owners }; `pattern`
|
|
136
|
+
// is the path as written (without a GitLab `!`), `section` the lower-cased name ('' for the unnamed one)
|
|
137
|
+
// and `optional` a per-section fact kept in `sections`. `skipped` is every line this reader did not use,
|
|
138
|
+
// with why — printed, never dropped quietly.
|
|
139
|
+
export function parseCodeowners(text, platform) {
|
|
140
|
+
const rules = [];
|
|
141
|
+
const skipped = [];
|
|
142
|
+
const sections = new Map([['', { optional: false }]]);
|
|
143
|
+
let section = '';
|
|
144
|
+
let defaults = [];
|
|
145
|
+
const lines = String(text).replace(/^\uFEFF/, '').split('\n');
|
|
146
|
+
lines.forEach((rawLine, i) => {
|
|
147
|
+
const line = rawLine.replace(/\r$/, '');
|
|
148
|
+
const n = i + 1;
|
|
149
|
+
if (!line.trim() || /^[ \t]*#/.test(line)) return;
|
|
150
|
+
if (platform === 'gitlab') {
|
|
151
|
+
const h = line.match(/^(\^)?\[([^\]]+)\](?:\[(\d+)\])?(?:[ \t]+(.*))?$/);
|
|
152
|
+
if (h && h[2].trim()) {
|
|
153
|
+
section = h[2].trim().toLowerCase();
|
|
154
|
+
const prev = sections.get(section);
|
|
155
|
+
// "If a section is duplicated … and one of them is marked as optional and the other isn't, the
|
|
156
|
+
// section is required."
|
|
157
|
+
sections.set(section, { optional: prev ? prev.optional && !!h[1] : !!h[1] });
|
|
158
|
+
const w = gitlabWords(h[4] || '');
|
|
159
|
+
// Default owners this reader cannot split are reported, never read as "no defaults".
|
|
160
|
+
if (w.bad) skipped.push({ line: n, why: w.bad });
|
|
161
|
+
defaults = (w.words || []).map((x) => ownerOf(x, platform)).filter(Boolean);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const w = gitlabWords(line.replace(/^[ \t]+/, ''));
|
|
165
|
+
if (w.bad) { skipped.push({ line: n, why: w.bad }); return; }
|
|
166
|
+
let [pattern, ...rest] = w.words;
|
|
167
|
+
const negate = pattern.startsWith('!');
|
|
168
|
+
if (negate) pattern = pattern.slice(1);
|
|
169
|
+
const pat = patternOf(pattern, platform);
|
|
170
|
+
if (pat.bad) { skipped.push({ line: n, why: pat.bad }); return; }
|
|
171
|
+
// GitLab ignores a malformed owner and keeps the rest. The section's defaults apply only to an entry
|
|
172
|
+
// that WRITES no owner at all: one whose words are all unreadable (`docs/ bob`, `docs/ # note`) has
|
|
173
|
+
// no owner, never the defaults — printing a default owner there would name the wrong person. It is
|
|
174
|
+
// also reported, so the answer is hedged.
|
|
175
|
+
const own = rest.map((x) => ownerOf(x, platform)).filter(Boolean);
|
|
176
|
+
// An exclusion takes no owners, so words after it are nothing to report.
|
|
177
|
+
if (!negate && rest.length && !own.length) {
|
|
178
|
+
skipped.push({ line: n, why: `${quoted(pattern)} is read as having no owner: none of its owner words is one this reader can read${defaults.length ? ', and the section\'s default owners do not apply to it' : ''}` });
|
|
179
|
+
}
|
|
180
|
+
rules.push({ line: n, section, negate, pattern, pat, owners: rest.length ? own : defaults });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const words = line.trim().split(/[ \t]+/);
|
|
184
|
+
const cut = words.findIndex((x, j) => j > 0 && x.startsWith('#'));
|
|
185
|
+
const [pattern, ...rest] = cut < 0 ? words : words.slice(0, cut);
|
|
186
|
+
if (pattern.startsWith('!')) { skipped.push({ line: n, why: `${quoted(pattern)} is a \`!\` pattern, which GitHub does not support` }); return; }
|
|
187
|
+
const pat = patternOf(pattern, platform);
|
|
188
|
+
if (pat.bad) { skipped.push({ line: n, why: pat.bad }); return; }
|
|
189
|
+
const owners = rest.map((x) => ownerOf(x, platform));
|
|
190
|
+
const badAt = owners.indexOf(null);
|
|
191
|
+
if (badAt >= 0) {
|
|
192
|
+
// A word with an `@` anywhere after its first character may be a mistyped address (`bob@corp`,
|
|
193
|
+
// `@alice@corp.com`), so it is never printed as written (`quoted`).
|
|
194
|
+
skipped.push({ line: n, why: `${quoted(rest[badAt])} is not an owner (@user, @org/team or an e-mail address)` });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
rules.push({ line: n, section: '', negate: false, pattern, pat, owners });
|
|
198
|
+
});
|
|
199
|
+
return { rules, skipped, sections };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The owners of the files a change touches. Per file: GitHub takes the last matching line; GitLab takes,
|
|
203
|
+
// in every section, the last matching entry unless an exclusion in that section matches. Returns
|
|
204
|
+
// { owners: [{ kind, text, files, optional }], matched, unmatched } — `files` is how many touched files
|
|
205
|
+
// list that owner, `optional` true when every such listing is in a GitLab `^[optional]` section, and
|
|
206
|
+
// `unmatched` how many touched files no line gives an owner.
|
|
207
|
+
export function ownersFor(parsed, files) {
|
|
208
|
+
const by = new Map();
|
|
209
|
+
let matched = 0;
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
const got = [];
|
|
212
|
+
for (const [name, meta] of parsed.sections) {
|
|
213
|
+
const inSec = parsed.rules.filter((r) => r.section === name);
|
|
214
|
+
if (inSec.some((r) => r.negate && matches(r.pat, f))) continue;
|
|
215
|
+
let last = null;
|
|
216
|
+
for (const r of inSec) if (!r.negate && matches(r.pat, f)) last = r;
|
|
217
|
+
if (last) for (const o of last.owners) got.push({ ...o, optional: meta.optional });
|
|
218
|
+
}
|
|
219
|
+
if (got.length) matched++;
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
for (const o of got) {
|
|
222
|
+
const k = `${o.kind}\0${o.key || o.text}`;
|
|
223
|
+
const row = by.get(k) || { kind: o.kind, text: o.text, files: 0, optional: true };
|
|
224
|
+
if (!seen.has(k)) row.files++;
|
|
225
|
+
seen.add(k);
|
|
226
|
+
row.optional = row.optional && o.optional;
|
|
227
|
+
by.set(k, row);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Every e-mail address prints the same words, so they are one row.
|
|
231
|
+
const rows = [...by.values()];
|
|
232
|
+
const mails = rows.filter((o) => o.kind === 'email');
|
|
233
|
+
const out = rows.filter((o) => o.kind !== 'email');
|
|
234
|
+
if (mails.length) out.push({ kind: 'email', text: mails.length > 1 ? `${mails.length} e-mail addresses` : 'an e-mail address', files: Math.max(...mails.map((m) => m.files)), optional: mails.every((m) => m.optional) });
|
|
235
|
+
return { owners: out.sort((a, b) => b.files - a.files), matched, unmatched: files.length - matched };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The CODEOWNERS file on `baseRef` — the base, like the risk map, so a change cannot rewrite the hint it
|
|
239
|
+
// is shown. { path, text } for the first location the platform reads, { none } when there is none, or
|
|
240
|
+
// { unknown: why } — git failing is never "none". Only a regular file is read (a symlink's `git show` is
|
|
241
|
+
// its target's name).
|
|
242
|
+
export function baseCodeowners(repoRoot, baseRef, platform) {
|
|
243
|
+
// gitEnv: a pathspec variable in the caller's environment makes `ls-tree -- <path>` fail outright.
|
|
244
|
+
const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30, env: gitEnv() });
|
|
245
|
+
const paths = CODEOWNERS_PATHS[platform];
|
|
246
|
+
if (!paths) return { unknown: `no CODEOWNERS locations are known for platform '${platform}'` };
|
|
247
|
+
for (const p of paths) {
|
|
248
|
+
const ls = git(['ls-tree', '--full-tree', baseRef, '--', p]);
|
|
249
|
+
if (ls.status !== 0) return { unknown: `git could not read '${baseRef}'` };
|
|
250
|
+
if (!ls.stdout) continue;
|
|
251
|
+
if (!/^100(644|755) blob /.test(ls.stdout)) return { unknown: `${p} on ${baseRef} is not a regular file` };
|
|
252
|
+
const show = spawnSync('git', ['show', `${baseRef}:${p}`], { cwd: repoRoot, maxBuffer: 1 << 30 });
|
|
253
|
+
if (show.status !== 0) return { unknown: `git could not read ${p} on ${baseRef}` };
|
|
254
|
+
if (platform === 'github' && show.stdout.length >= GITHUB_MAX_BYTES) {
|
|
255
|
+
return { unknown: `${p} on ${baseRef} is 3 MB or more, and GitHub does not load a file that large` };
|
|
256
|
+
}
|
|
257
|
+
return { path: p, text: show.stdout.toString('utf8') };
|
|
258
|
+
}
|
|
259
|
+
return { none: `no CODEOWNERS on ${baseRef} (${paths.join(', ')})` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// E69 — the lines that match no file: a fact about the file list, never a guess about a person. A GitLab
|
|
263
|
+
// `!` line that excludes nothing is one too. `files` is every file the repo holds; `submodules` the paths
|
|
264
|
+
// of its submodules, whose contents are not in that list — so a line that could reach into one is never
|
|
265
|
+
// called dead (the probe name is a NUL, which no pattern this reader accepts can spell but `*` matches).
|
|
266
|
+
// Returns [{ line, pattern, negate }] in file order.
|
|
267
|
+
export function deadLines(parsed, files, { submodules = [] } = {}) {
|
|
268
|
+
const names = [...files, ...submodules.map((g) => `${g}/\u0000`)];
|
|
269
|
+
// `matches` asks about a file and every folder above it, and a dead line is asked about every name — the
|
|
270
|
+
// E69 reviews measured 300 000 files × 1 000 dead lines at minutes. So each folder is asked about ONCE,
|
|
271
|
+
// and each rule takes the fastest road that gives exactly `matches`' answer (tests hold them equal): a
|
|
272
|
+
// plain part no name has settles it at once; plain text is a set lookup; an "at any depth" pattern that
|
|
273
|
+
// cannot match a `/` tests the distinct last k parts; a pattern anchored at the top tests only the names
|
|
274
|
+
// that start with its plain head. Anything else scans every name.
|
|
275
|
+
const dirs = new Set();
|
|
276
|
+
for (const f of names) for (let i = f.indexOf('/'); i >= 0; i = f.indexOf('/', i + 1)) dirs.add(f.slice(0, i));
|
|
277
|
+
const dirList = [...dirs];
|
|
278
|
+
const nameSet = new Set(names);
|
|
279
|
+
const partSet = new Set(names.flatMap((f) => f.split('/')));
|
|
280
|
+
const sortedNames = [...names].sort();
|
|
281
|
+
const sortedDirs = [...dirList].sort();
|
|
282
|
+
// The distinct last `k` parts of every name, built once per `k`.
|
|
283
|
+
const tails = new Map();
|
|
284
|
+
const tailsOf = (k) => {
|
|
285
|
+
if (!tails.has(k)) {
|
|
286
|
+
// A name with fewer than k parts gives itself, which holds fewer than k-1 slashes and so never matches.
|
|
287
|
+
const cut = (list) => new Set(list.map((x) => x.split('/').slice(-k).join('/')));
|
|
288
|
+
const files = cut(names);
|
|
289
|
+
const folders = cut(dirList);
|
|
290
|
+
tails.set(k, { files, dirs: folders, fileList: [...files], dirList: [...folders] });
|
|
291
|
+
}
|
|
292
|
+
return tails.get(k);
|
|
293
|
+
};
|
|
294
|
+
// The names in a sorted list that start with `prefix`.
|
|
295
|
+
const under = (sorted, prefix) => {
|
|
296
|
+
let lo = 0;
|
|
297
|
+
let hi = sorted.length;
|
|
298
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] < prefix) lo = mid + 1; else hi = mid; }
|
|
299
|
+
const out = [];
|
|
300
|
+
for (let i = lo; i < sorted.length && sorted[i].startsWith(prefix); i++) out.push(sorted[i]);
|
|
301
|
+
return out;
|
|
302
|
+
};
|
|
303
|
+
const test = (pat, fileList, folderList) => (pat.fileToo && fileList.some((f) => pat.re.test(f))) || (pat.dirs && folderList.some((d) => pat.re.test(d)));
|
|
304
|
+
|
|
305
|
+
const live = (pat) => {
|
|
306
|
+
if (pat.all) return names.length > 0;
|
|
307
|
+
// `patternOf` writes `^`, then `(?:.*\/)?` for "at any depth" (once per leading `**/`), then the rest,
|
|
308
|
+
// then `$`. The rest is read piece by piece: a character, `\` + a character that is not a letter or a
|
|
309
|
+
// digit (V8 writes a carriage return in `source` as `\r`, which is NOT the letter `r`), `[^/]` with or
|
|
310
|
+
// without `*`, or anything else, which is not read here. Reading a piece as "anything else" is always
|
|
311
|
+
// safe: it only sends the rule down a slower road to the same answer.
|
|
312
|
+
const m = pat.re.source.match(/^\^((?:\(\?:\.\*\\\/\)\?)*)(.*)\$$/);
|
|
313
|
+
if (!m) return test(pat, names, dirList);
|
|
314
|
+
const [, anyDepth, rest] = m;
|
|
315
|
+
const parts = [];
|
|
316
|
+
for (let i = 0; i < rest.length;) {
|
|
317
|
+
if (rest.startsWith('[^/]', i)) { parts.push({ cls: true }); i += rest[i + 4] === '*' ? 5 : 4; continue; }
|
|
318
|
+
const ch = rest[i];
|
|
319
|
+
if (ch === '\\' && i + 1 < rest.length && !/[A-Za-z0-9]/.test(rest[i + 1])) { parts.push({ text: rest[i + 1] }); i += 2; continue; }
|
|
320
|
+
if (/[\\.*+?()[\]{}|^$]/.test(ch)) { parts.push({ other: true }); i++; continue; }
|
|
321
|
+
parts.push({ text: ch });
|
|
322
|
+
i++;
|
|
323
|
+
}
|
|
324
|
+
// Every part of the pattern that is plain text between two real slashes (or an end) must be some
|
|
325
|
+
// name's part, or nothing can match — true for every shape, and it settles most dead lines at once.
|
|
326
|
+
// (A slash inside `(?:.*\/)?` sits next to `(` or `)`, so the parts beside it are never plain.)
|
|
327
|
+
let seg = [];
|
|
328
|
+
for (const p of [...parts, { text: '/' }]) {
|
|
329
|
+
if (p.text !== '/') { seg.push(p); continue; }
|
|
330
|
+
if (seg.length && seg.every((q) => 'text' in q) && !partSet.has(seg.map((q) => q.text).join(''))) return false;
|
|
331
|
+
seg = [];
|
|
332
|
+
}
|
|
333
|
+
const plain = parts.every((p) => 'text' in p);
|
|
334
|
+
const text = plain ? parts.map((p) => p.text).join('') : '';
|
|
335
|
+
if (plain && !anyDepth) return (pat.fileToo && nameSet.has(text)) || (pat.dirs && dirs.has(text));
|
|
336
|
+
if (anyDepth && !parts.some((p) => p.other)) {
|
|
337
|
+
// "At any depth", and the rest holds exactly k-1 slashes and nothing that can match one: only a
|
|
338
|
+
// name's last k parts can match it, so the distinct last k parts are tested instead of every path.
|
|
339
|
+
const t = tailsOf(parts.filter((p) => p.text === '/').length + 1);
|
|
340
|
+
if (plain) return (pat.fileToo && t.files.has(text)) || (pat.dirs && t.dirs.has(text));
|
|
341
|
+
const last = new RegExp(`^${rest}$`, 's');
|
|
342
|
+
return (pat.fileToo && t.fileList.some((x) => last.test(x))) || (pat.dirs && t.dirList.some((x) => last.test(x)));
|
|
343
|
+
}
|
|
344
|
+
if (!anyDepth) {
|
|
345
|
+
// Anchored at the top: every match starts with the plain text before the first wildcard, so only
|
|
346
|
+
// the names that start with it can match.
|
|
347
|
+
let head = '';
|
|
348
|
+
for (const p of parts) { if (!('text' in p)) break; head += p.text; }
|
|
349
|
+
if (head) return test(pat, under(sortedNames, head), under(sortedDirs, head));
|
|
350
|
+
}
|
|
351
|
+
return test(pat, names, dirList);
|
|
352
|
+
};
|
|
353
|
+
return parsed.rules.filter((r) => !live(r.pat)).map((r) => ({ line: r.line, pattern: r.pattern, negate: r.negate }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Does `rel` exist under `root` with exactly this spelling? A macOS or Windows disk ignores case, so a
|
|
357
|
+
// plain stat finds `.github/codeowners` for `.github/CODEOWNERS` — but git, and so the platform, stores the
|
|
358
|
+
// name as written (E69 review, round 1). Each part is looked for in its folder's listing. Throws what
|
|
359
|
+
// reading a folder throws.
|
|
360
|
+
function exactName(root, rel) {
|
|
361
|
+
let dir = root;
|
|
362
|
+
for (const part of rel.split('/')) {
|
|
363
|
+
if (!fs.readdirSync(dir).includes(part)) return false;
|
|
364
|
+
dir = path.join(dir, part);
|
|
365
|
+
}
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// E69 — the CODEOWNERS file on DISK (the working tree, like `yad risk-map check`), as its platform would
|
|
370
|
+
// pick it: the first of its locations that exists. Returns { path, text, ignored } — `ignored` the other
|
|
371
|
+
// locations that hold a file the platform never reads — or { path, tooBig, ignored } for a GitHub file of
|
|
372
|
+
// 3 MB or more, { none } when there is none, or { unknown: why }. A first location that is not a regular
|
|
373
|
+
// file is `unknown`: the platform reads the blob git stores, and a symlink's blob is its target's name.
|
|
374
|
+
export function diskCodeowners(repoRoot, platform) {
|
|
375
|
+
const paths = CODEOWNERS_PATHS[platform];
|
|
376
|
+
if (!paths) return { unknown: `no CODEOWNERS locations are known for platform '${platform}'` };
|
|
377
|
+
const found = [];
|
|
378
|
+
for (const p of paths) {
|
|
379
|
+
try {
|
|
380
|
+
if (!exactName(repoRoot, p)) continue;
|
|
381
|
+
found.push({ p, st: fs.lstatSync(path.join(repoRoot, p)) });
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') continue;
|
|
384
|
+
return { unknown: `${p} could not be read (${e.code})` };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!found.length) return { none: `no CODEOWNERS (${paths.join(', ')})` };
|
|
388
|
+
const [first, ...rest] = found;
|
|
389
|
+
if (!first.st.isFile()) return { unknown: `${first.p} is not a regular file` };
|
|
390
|
+
const ignored = rest.filter((x) => !x.st.isDirectory()).map((x) => x.p);
|
|
391
|
+
if (platform === 'github' && first.st.size >= GITHUB_MAX_BYTES) return { path: first.p, tooBig: true, ignored };
|
|
392
|
+
try {
|
|
393
|
+
return { path: first.p, text: fs.readFileSync(path.join(repoRoot, first.p), 'utf8'), ignored };
|
|
394
|
+
} catch (e) {
|
|
395
|
+
return { unknown: `${first.p} could not be read (${e.code})` };
|
|
396
|
+
}
|
|
397
|
+
}
|
package/cli/commit.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import fs from 'node:fs';
|
|
|
9
9
|
import { c, log, ok, info, warn, fail, run, exists } from './lib.mjs';
|
|
10
10
|
import {
|
|
11
11
|
COMMIT_TYPES, AI_COAUTHORS, ATOMIC_FILE_LIMIT,
|
|
12
|
-
TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER,
|
|
12
|
+
TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER, TASK_ID_RE,
|
|
13
|
+
productConfigPath,
|
|
13
14
|
} from './manifest.mjs';
|
|
14
15
|
|
|
15
16
|
// PURE — unit tested directly. Build the full commit message text.
|
|
@@ -52,7 +53,7 @@ export async function runCommit(root, opts = {}) {
|
|
|
52
53
|
if (!staged.length) { fail('nothing staged — `git add` your atomic change first'); process.exitCode = 1; return; }
|
|
53
54
|
|
|
54
55
|
if (staged.length > ATOMIC_FILE_LIMIT && !opts.force) {
|
|
55
|
-
|
|
56
|
+
fail(`${staged.length} files staged (atomic guard: ≤${ATOMIC_FILE_LIMIT}). Split the change, or pass --force.`);
|
|
56
57
|
for (const f of staged) info(f);
|
|
57
58
|
process.exitCode = 1;
|
|
58
59
|
return;
|
|
@@ -61,11 +62,11 @@ export async function runCommit(root, opts = {}) {
|
|
|
61
62
|
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root }).stdout;
|
|
62
63
|
const task = opts.task || taskFromBranch(branch);
|
|
63
64
|
if (!task) {
|
|
64
|
-
// spec-link is a code-repo gate (REPO_WIRING.common), not a
|
|
65
|
-
// is expected on a
|
|
66
|
-
const onHub = exists(
|
|
65
|
+
// spec-link is a code-repo gate (REPO_WIRING.common), not a Product gate — so a missing Task trailer
|
|
66
|
+
// is expected on a Product PR (Shape artifact review or Product tooling) and only matters on a repo.
|
|
67
|
+
const onHub = exists(productConfigPath(root));
|
|
67
68
|
warn(onHub
|
|
68
|
-
? 'no Task trailer (none given and branch has no -S0N-T0N) — fine for a
|
|
69
|
+
? 'no Task trailer (none given and branch has no -S0N-T0N) — fine for a Product PR; required on code-repo tasks (spec-link gate)'
|
|
69
70
|
: 'no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -77,13 +78,16 @@ export async function runCommit(root, opts = {}) {
|
|
|
77
78
|
});
|
|
78
79
|
} catch (e) { fail(e.message); process.exitCode = 1; return; }
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
// The --json answer (E1): what was (or would be) committed.
|
|
82
|
+
const answer = { message, task: task || null, files: staged, contractChange: !!opts.contractChange };
|
|
83
|
+
if (opts.dryRun) { log('\n' + c.dim(message) + '\n'); info('dry run — not committed'); return { ...answer, committed: false, dryRun: true }; }
|
|
81
84
|
|
|
82
85
|
const r = run('git', ['commit', '-m', message], { cwd: root });
|
|
83
|
-
if (!r.ok) { fail(`git commit failed — ${r.stderr.split('\n')[0] || r.code}`); process.exitCode = 1; return {
|
|
86
|
+
if (!r.ok) { fail(`git commit failed — ${r.stderr.split('\n')[0] || r.code}`); process.exitCode = 1; return { ...answer, committed: false, dryRun: false }; }
|
|
84
87
|
ok(`committed ${staged.length} file(s)${task ? ` for ${task}` : ''}`);
|
|
85
88
|
if (opts.contractChange) warn('Contract-Change: yes — this routes back to the architecture gate');
|
|
86
|
-
|
|
89
|
+
const sha = run('git', ['rev-parse', 'HEAD'], { cwd: root });
|
|
90
|
+
return { ...answer, committed: true, dryRun: false, commit: sha.ok ? sha.stdout : null };
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
// installed by yad-implement, but offer it here too for convenience.
|
package/cli/companion.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// cli/companion.mjs — pure, perspective-neutral helpers for the Review Companion, shared by
|
|
2
|
-
//
|
|
1
|
+
// cli/companion.mjs — pure, perspective-neutral helpers for the Review Companion, shared by Shape
|
|
2
|
+
// (yad gate …) and Build (yad review …). The CLI never calls an LLM — generation happens
|
|
3
3
|
// in the skill/harness layer (like yad-learn/yad-docs). This module owns the platform MARKERS, the
|
|
4
4
|
// engagement parsing, the trailer-block upsert, and the message text the skill and the gate share.
|
|
5
5
|
//
|
package/cli/dial.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// `yad dial` / `yad kill` / `yad unkill` — the advance dial, set freely, and the kill switch (E34).
|
|
2
|
+
//
|
|
3
|
+
// Automation used to be earned: the `yad-run` skill refused `advance: auto` until a step's trust log
|
|
4
|
+
// cleared a threshold, and `yad-status` nudged about steps that had cleared it and stayed manual. E34
|
|
5
|
+
// deletes both. The team sets the dial; this command shows the run record beside it as ADVICE and never
|
|
6
|
+
// refuses on it. The rules live in epic-state.mjs (`planShapeDial`, `planLaneDial`, `planKill`,
|
|
7
|
+
// `effectiveAdvance`); this reads the files, prints the advice and writes the result.
|
|
8
|
+
//
|
|
9
|
+
// What it never does: set a gate to `auto` (rule 1), write over an `automation.json` it cannot read, or
|
|
10
|
+
// write a lane step that `yad-run` has not put on the lane yet.
|
|
11
|
+
//
|
|
12
|
+
// READING IS A CONTRACT. The `yad-run` skill asks `yad dial … --json` for the dial of every step it walks,
|
|
13
|
+
// the merge gate included, and uses `advance`. So a read never refuses a gate — it answers `human`, `why:
|
|
14
|
+
// gate` — and never fails because the run record beside the dial cannot be read.
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { c, fail, hand, info, ok, readJSONStrict, warn, writeJSON, emitJSON } from './lib.mjs';
|
|
18
|
+
import {
|
|
19
|
+
effectiveAdvance, epicRel, epicRoot, epicStories, isGateStep, killSwitchOn, loadAutomation, loadLedger, planKill,
|
|
20
|
+
planLaneDial, planShapeDial, serializeAutomation, stepDef,
|
|
21
|
+
} from './epic-state.mjs';
|
|
22
|
+
import { epicFiles, PROJECT_FILES } from './manifest.mjs';
|
|
23
|
+
import { readTrustRuns } from './ledger.mjs';
|
|
24
|
+
import { recordActor } from './skip.mjs';
|
|
25
|
+
|
|
26
|
+
const makeBail = (json) => (message, hint) => {
|
|
27
|
+
if (json) emitJSON({ ok: false, error: message, hint: hint || null });
|
|
28
|
+
else { fail(message); if (hint) hand(hint); }
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const killLine = (kill) => {
|
|
33
|
+
const who = [kill?.by ? `by ${kill.by}` : '', kill?.date ? `on ${kill.date}` : ''].filter(Boolean).join(' ');
|
|
34
|
+
return `the kill switch is ON${who ? ` (${who})` : ''}${kill?.reason ? `: ${kill.reason}` : ''}`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A broken `automation.json` holds every step at human; say that it is the FILE, not somebody's switch.
|
|
38
|
+
const heldLine = (automation) => (automation.error
|
|
39
|
+
? `${PROJECT_FILES.automationConfig} ${automation.error} — every step is held at advance: human until it is fixed (yad doctor)`
|
|
40
|
+
: killLine(automation.kill));
|
|
41
|
+
// Only when there is one, so a healthy project's JSON keeps exactly the keys it had.
|
|
42
|
+
const errorKey = (automation) => (automation.error ? { automationError: automation.error } : {});
|
|
43
|
+
|
|
44
|
+
// `yad dial <step> [--to auto|human]` for a Shape author step, or
|
|
45
|
+
// `yad dial <epic> <story> --repo <name> <step> [--to auto|human]` for a Build lane step. No `--to` reads.
|
|
46
|
+
export async function runDial(root, { epic = null, story = null, repo = null, step, to = null, json = false } = {}) {
|
|
47
|
+
const bail = makeBail(json);
|
|
48
|
+
if (!step) return bail('usage: yad dial <step> [--to auto|human] | yad dial <epic> <story> --repo <name> <step> [--to auto|human]');
|
|
49
|
+
const automation = loadAutomation(root);
|
|
50
|
+
return epic
|
|
51
|
+
? dialLane(root, { epic, story, repo, step, to, json, bail, automation })
|
|
52
|
+
: dialShape(root, { step, to, json, bail, automation });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The answer for a gate asked with no `--to`: always a person. Not a refusal (see the header).
|
|
56
|
+
function gateAnswer({ json, automation, scope, fields, label }) {
|
|
57
|
+
if (json) {
|
|
58
|
+
return emitJSON({
|
|
59
|
+
ok: true, scope, ...fields, set: 'human', advance: 'human', why: 'gate', changed: false,
|
|
60
|
+
kill: automation.kill, trust: null, ...errorKey(automation),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
ok(`${label} — advance: human (a review gate: always a person)`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dialShape(root, { step, to, json, bail, automation }) {
|
|
67
|
+
if (!to && stepDef(step)?.kind === 'review' && stepDef(step)?.phase !== 'build') {
|
|
68
|
+
return gateAnswer({ json, automation, scope: 'shape', fields: { step }, label: c.bold(step) });
|
|
69
|
+
}
|
|
70
|
+
// Nothing is written over a file that cannot be read — it may hold a kill switch somebody set. This is
|
|
71
|
+
// the only path that writes `automation.json`, so it is the only one that refuses on it.
|
|
72
|
+
if (to && automation.error) {
|
|
73
|
+
return bail(`${PROJECT_FILES.automationConfig} ${automation.error} — nothing is written over it, and every step is held at advance: human until it is fixed`,
|
|
74
|
+
'fix the JSON, or delete the file to go back to the defaults (kill switch off, every Shape step human)');
|
|
75
|
+
}
|
|
76
|
+
const plan = planShapeDial(automation, { step, to: to || 'human' });
|
|
77
|
+
if (!plan.ok) return bail(plan.message, plan.hint);
|
|
78
|
+
const after = to ? plan.automation : automation;
|
|
79
|
+
if (to && plan.changed) writeJSON(path.join(root, PROJECT_FILES.automationConfig), serializeAutomation(plan.automation));
|
|
80
|
+
const eff = effectiveAdvance({ id: step }, { ...after, error: automation.error });
|
|
81
|
+
if (json) {
|
|
82
|
+
return emitJSON({
|
|
83
|
+
ok: true, scope: 'shape', step, set: eff.set, advance: eff.advance, why: eff.why,
|
|
84
|
+
changed: !!to && plan.changed, kill: automation.kill, trust: null, ...errorKey(automation),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const label = c.bold(step);
|
|
88
|
+
if (!to) ok(`${label} — advance: ${eff.set}${eff.why === 'kill' ? ' (held at human)' : ''}`);
|
|
89
|
+
else if (plan.changed) ok(`${label} set to advance: ${to} for this project`);
|
|
90
|
+
else ok(`${label} was already advance: ${to} — nothing changed`);
|
|
91
|
+
if (eff.set === 'auto') {
|
|
92
|
+
info('no run record exists for a Shape step — the yad-run skill records Build steps only');
|
|
93
|
+
info(`recorded, not acted on yet: nothing drives a Shape step on its own until the engine runs agents (E26), so ${step} still waits for its author. Its review gate is always a person`);
|
|
94
|
+
}
|
|
95
|
+
if (killSwitchOn(automation)) warn(heldLine(automation));
|
|
96
|
+
if (to && plan.changed) hand(`commit ${PROJECT_FILES.automationConfig} (reverse with \`yad dial ${step} --to ${to === 'auto' ? 'human' : 'auto'}\`)`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dialLane(root, { epic, story, repo, step, to, json, bail, automation }) {
|
|
100
|
+
if (!story || !repo) return bail(`usage: yad dial ${epic} <story> --repo <name> ${step} [--to auto|human]`);
|
|
101
|
+
const epicDir = epicRoot(root, epic);
|
|
102
|
+
const ledger = loadLedger(epicDir);
|
|
103
|
+
if (!ledger.state) return bail(`no epic state at ${epicRel(epic)} — seed the epic first`);
|
|
104
|
+
const entry = epicStories(epicDir).find((st) => st.id === story);
|
|
105
|
+
if (!entry) return bail(`no story ${story} under ${epicRel(epic)}/stories/`);
|
|
106
|
+
const label = `${c.cyan(story)} / ${c.bold(repo)} ${c.bold(step)}`;
|
|
107
|
+
if (!to && stepDef(step)?.phase === 'build' && isGateStep({ id: step })) {
|
|
108
|
+
return gateAnswer({ json, automation, scope: 'lane', fields: { epic, story, repo, step }, label });
|
|
109
|
+
}
|
|
110
|
+
const file = path.join(epicFiles(epicDir).buildStateDir, `${story}.json`);
|
|
111
|
+
// Strict: a read-modify-write against a file that will not parse would delete what is in it.
|
|
112
|
+
const current = readJSONStrict(file, null);
|
|
113
|
+
const plan = planLaneDial(current, { story, repo, step, to: to || 'human', declared: entry.repos });
|
|
114
|
+
if (!plan.ok) return bail(plan.message, plan.hint);
|
|
115
|
+
|
|
116
|
+
// The advice, read BEFORE anything is written: every recorded run of this step in this repo. A record
|
|
117
|
+
// that cannot be read is reported and set aside — advice never blocks the dial it sits beside.
|
|
118
|
+
let trust = null;
|
|
119
|
+
let trustError = null;
|
|
120
|
+
try {
|
|
121
|
+
const runs = readTrustRuns(epicDir).filter((e) => e && e.step === step && e.repo === repo);
|
|
122
|
+
trust = { runs: runs.length, approvedUnchanged: runs.filter((e) => e.verdict === 'approved-unchanged').length };
|
|
123
|
+
} catch (e) {
|
|
124
|
+
trustError = e.message;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const state = to ? plan.buildState : current;
|
|
128
|
+
if (to && plan.changed) writeJSON(file, plan.buildState);
|
|
129
|
+
const row = state.repos[repo].steps.find((x) => x && x.id === step);
|
|
130
|
+
const eff = effectiveAdvance(row, automation);
|
|
131
|
+
|
|
132
|
+
if (json) {
|
|
133
|
+
return emitJSON({
|
|
134
|
+
ok: true, scope: 'lane', epic, story, repo, step, set: eff.set, advance: eff.advance, why: eff.why,
|
|
135
|
+
changed: !!to && plan.changed, kill: automation.kill, trust, ...errorKey(automation),
|
|
136
|
+
...(trustError ? { trustError } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!to) ok(`${label} — advance: ${eff.set}${eff.why === 'kill' ? ' (held at human)' : ''}`);
|
|
140
|
+
else if (plan.changed && plan.before === to) ok(`${label} was already advance: ${to} — the new dial name was added beside the old one`);
|
|
141
|
+
else if (plan.changed) ok(`${label} set to advance: ${to}`);
|
|
142
|
+
else ok(`${label} was already advance: ${to} — nothing changed`);
|
|
143
|
+
if (trustError) warn(`the run record cannot be read (${trustError}) — the dial is not affected; run \`yad doctor\``);
|
|
144
|
+
else {
|
|
145
|
+
info(trust.runs
|
|
146
|
+
? `run record for ${step} in ${repo}: ${trust.runs} run(s), ${Math.round((trust.approvedUnchanged / trust.runs) * 100)}% approved unchanged`
|
|
147
|
+
: `nothing has run ${step} in ${repo} yet — no evidence either way`);
|
|
148
|
+
info(c.dim('advice, not a rule: the team decides (E34)'));
|
|
149
|
+
}
|
|
150
|
+
if (eff.set === 'auto') {
|
|
151
|
+
info(`the yad-run skill moves past ${step} on its own after a clean run. A failed check, a scope overrun or a contract touch still stops it, and the merge is always a person`);
|
|
152
|
+
}
|
|
153
|
+
if (killSwitchOn(automation)) warn(heldLine(automation));
|
|
154
|
+
if (to && plan.changed) hand(`commit it with \`yad checkpoint --push\` (reverse with \`yad dial ${epic} ${story} --repo ${repo} ${step} --to ${to === 'auto' ? 'human' : 'auto'}\`)`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `yad kill --reason "<why>"` / `yad unkill [--reason "<why>"]`.
|
|
158
|
+
export async function runKill(root, { on, reason = null, json = false, today = null } = {}) {
|
|
159
|
+
const bail = makeBail(json);
|
|
160
|
+
const automation = loadAutomation(root);
|
|
161
|
+
if (automation.error) {
|
|
162
|
+
return bail(`${PROJECT_FILES.automationConfig} ${automation.error} — nothing is written over it`,
|
|
163
|
+
'fix the JSON, or delete the file (kill switch off, every Shape step human), then run this again');
|
|
164
|
+
}
|
|
165
|
+
const plan = planKill(automation, { on, reason, by: recordActor(root), date: today });
|
|
166
|
+
if (!plan.ok) return bail(plan.message, plan.hint);
|
|
167
|
+
if (!plan.already) writeJSON(path.join(root, PROJECT_FILES.automationConfig), serializeAutomation(plan.automation));
|
|
168
|
+
const kill = plan.automation.kill;
|
|
169
|
+
if (json) return emitJSON({ ok: true, kill, changed: !plan.already });
|
|
170
|
+
if (plan.already) {
|
|
171
|
+
ok(`the kill switch was already ${on ? 'on' : 'off'} — nothing changed`);
|
|
172
|
+
if (on) info(killLine(kill));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (on) {
|
|
176
|
+
ok('kill switch ON — every step is held at advance: human');
|
|
177
|
+
info(`reason: ${kill.reason}`);
|
|
178
|
+
} else {
|
|
179
|
+
ok('kill switch off — each step follows its own dial again');
|
|
180
|
+
if (kill.reason) info(`reason: ${kill.reason}`);
|
|
181
|
+
}
|
|
182
|
+
hand(`commit ${PROJECT_FILES.automationConfig} so every machine and CI run sees it (reverse with \`yad ${on ? 'unkill' : 'kill --reason "<why>"'}\`)`);
|
|
183
|
+
}
|