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,289 @@
|
|
|
1
|
+
// `yad risk-map check|draft [<repo>]` — the risk map of a code repo (E65). The rules live in
|
|
2
|
+
// cli/riskmap.mjs; this finds the repos, lists their files, prints and writes.
|
|
3
|
+
//
|
|
4
|
+
// `draft` only ADDS: an `unset` line for every directory nothing covers. It never changes a line that is
|
|
5
|
+
// already there, so a level a person confirmed is never overwritten. Classifying the `unset` lines is the
|
|
6
|
+
// `yad-connect-repos` skill's job — an AI agent reads the code, which this command cannot do.
|
|
7
|
+
//
|
|
8
|
+
// `check` is the local twin of `checks/risk-map-check.sh`, over the whole repo instead of one change.
|
|
9
|
+
// It is advisory like the CI check: it prints warnings and never sets a failing exit code for them.
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { c, fail, hand, info, log, ok, readJSONStrict, run, warn, emitJSON } from './lib.mjs';
|
|
15
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
16
|
+
import { changeLevel, draftRiskMap, folderPathspec, highTouched, parseRiskMap, pathspecsFor, rankAuthors, recentAuthors, RISK_MAP_FILE, riskMapFindings, touchedFolders } from './riskmap.mjs';
|
|
17
|
+
|
|
18
|
+
// Every file in a code repo, as the map sees it: tracked files plus new files git does not ignore, so a
|
|
19
|
+
// directory someone has just created is asked about before it is committed. null when it is not a git repo.
|
|
20
|
+
export function repoFiles(repoRoot) {
|
|
21
|
+
// Not `run`: it trims stdout, and ` notes/a.md` sorts first, so its leading space would be lost. And a
|
|
22
|
+
// large repo's list is many megabytes; spawnSync's default 1 MiB buffer would fail it as "not a git repo".
|
|
23
|
+
const r = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
24
|
+
if (r.status !== 0) return null;
|
|
25
|
+
return [...new Set(r.stdout.split('\0').filter(Boolean))];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readRiskMap(repoRoot) {
|
|
29
|
+
const file = path.join(repoRoot, RISK_MAP_FILE);
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(file, 'utf8');
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The repos to act on: { list } or { error, hint }. A name from `.sdlc/repos.json` first; else a path to a
|
|
38
|
+
// code repo; with neither, every connected repo — or the directory itself, but ONLY when there is no
|
|
39
|
+
// registry file at all (run inside a code repo). A registry that is empty or does not parse is a Product
|
|
40
|
+
// with no repos to name, and `draft` must never fall back to writing a map into the Product itself.
|
|
41
|
+
// Each entry carries the registry's `platform` when it has one (`yad codeowners check` reads it).
|
|
42
|
+
export function targets(root, name) {
|
|
43
|
+
const regFile = path.join(root, PROJECT_FILES.reposRegistry);
|
|
44
|
+
const hasRegistry = fs.existsSync(regFile);
|
|
45
|
+
let registry = { repos: [] };
|
|
46
|
+
if (hasRegistry) {
|
|
47
|
+
try {
|
|
48
|
+
registry = readJSONStrict(regFile, { repos: [] });
|
|
49
|
+
} catch {
|
|
50
|
+
return { error: `${PROJECT_FILES.reposRegistry} does not parse`, hint: 'fix the JSON (`yad doctor`), or name the path to a code repo' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const repos = Array.isArray(registry?.repos) ? registry.repos.filter((r) => r && typeof r.name === 'string' && typeof r.path === 'string' && r.path) : [];
|
|
54
|
+
if (name) {
|
|
55
|
+
const hit = repos.find((r) => r.name === name);
|
|
56
|
+
if (hit) return { list: [{ name: hit.name, root: path.resolve(root, hit.path), platform: hit.platform || null }] };
|
|
57
|
+
const p = path.resolve(root, name);
|
|
58
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return { list: [{ name, root: p }] };
|
|
59
|
+
return { error: `unknown repo: ${name}`, hint: 'name a repo from .sdlc/repos.json (`yad repo list`) or give the path to a code repo' };
|
|
60
|
+
}
|
|
61
|
+
if (repos.length) return { list: repos.map((r) => ({ name: r.name, root: path.resolve(root, r.path), platform: r.platform || null })) };
|
|
62
|
+
if (hasRegistry) return { error: `no repos in ${PROJECT_FILES.reposRegistry}`, hint: 'connect one (`yad setup`), or name the path to a code repo' };
|
|
63
|
+
return { list: [{ name: path.basename(path.resolve(root)), root: path.resolve(root) }] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// E66 — the level the change on HEAD takes from the map on `baseRef` (the twin of
|
|
67
|
+
// `checks/risk-map-check.sh --level`). The map is read from the BASE, never the working tree, so a change
|
|
68
|
+
// cannot lower its own count; the files are every path changed since HEAD left the base (three dots),
|
|
69
|
+
// deletions and moves included. Returns { base, files, level, step, dirs } from `changeLevel`, or
|
|
70
|
+
// { base, noMap: why } when the base holds no map (nothing adds a step), or { base, unknown: why } when
|
|
71
|
+
// the level cannot be read — which a caller must say, never read as zero.
|
|
72
|
+
export function baseChangeLevel(repoRoot, baseRef) {
|
|
73
|
+
const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
74
|
+
const base = baseRef;
|
|
75
|
+
if (git(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]).status !== 0 || git(['merge-base', baseRef, 'HEAD']).status !== 0) {
|
|
76
|
+
return { base, unknown: `base ref '${baseRef}' not found, or it shares no history with HEAD (a shallow clone?)` };
|
|
77
|
+
}
|
|
78
|
+
// Only a real file is a map: `git show` would print a symlink's target as if it were the text.
|
|
79
|
+
// --full-tree: ls-tree reads a path from the current folder, so from a subfolder it would find no map.
|
|
80
|
+
const entry = git(['ls-tree', '--full-tree', baseRef, '--', RISK_MAP_FILE]).stdout || '';
|
|
81
|
+
if (!entry) return { base, noMap: `'${baseRef}' has no ${RISK_MAP_FILE}` };
|
|
82
|
+
if (!/^100(644|755) blob /.test(entry)) return { base, noMap: `'${baseRef}' holds ${RISK_MAP_FILE}, but not as a file` };
|
|
83
|
+
const text = git(['show', `${baseRef}:${RISK_MAP_FILE}`]);
|
|
84
|
+
const diff = git(['diff', '--name-only', '-z', '--no-renames', `${baseRef}...HEAD`]);
|
|
85
|
+
if (text.status !== 0 || diff.status !== 0) return { base, unknown: `git could not read ${baseRef}` };
|
|
86
|
+
const files = diff.stdout.split('\0').filter(Boolean);
|
|
87
|
+
const parsed = parseRiskMap(text.stdout);
|
|
88
|
+
const got = changeLevel(parsed, files);
|
|
89
|
+
if (got.unsupported) return { base, unknown: `the map on ${baseRef} is written for a newer risk-map version` };
|
|
90
|
+
// `entries` and `changed` are what E67's history query needs next, and reading the map twice could read
|
|
91
|
+
// two different maps (the base can move between the calls).
|
|
92
|
+
return { base, files: files.length, entries: parsed.entries, changed: files, ...got };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The environment for a git call that carries pathspecs. Four variables change how git reads EVERY
|
|
96
|
+
// pathspec: `GIT_LITERAL_PATHSPECS` makes `:(glob)src/*` match nothing (and git still exits 0, so the
|
|
97
|
+
// answer would read as "nobody"), `GIT_ICASE_PATHSPECS` makes `src/` match `SRC/` (the wrong people),
|
|
98
|
+
// and the glob pair changes what `*` means. The pathspecs here say exactly what they mean, so none of
|
|
99
|
+
// the four may reach git.
|
|
100
|
+
export const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'];
|
|
101
|
+
export function gitEnv() {
|
|
102
|
+
const env = { ...process.env };
|
|
103
|
+
for (const k of PATHSPEC_ENV) delete env[k];
|
|
104
|
+
return env;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Git's own words for E67's window (Part 3: expertise is a fixed, tight 30 days).
|
|
108
|
+
export const HISTORY_WINDOW = '30 days ago';
|
|
109
|
+
|
|
110
|
+
// E67 — the people with recent commits in the `high` directories this change touches, read from the BASE
|
|
111
|
+
// branch's history so a change cannot add its own. ONE QUERY PER DIRECTORY, each carrying the pathspecs
|
|
112
|
+
// that describe that directory as the map sees it (`pathspecsFor`), so git applies the cover rule and
|
|
113
|
+
// returns author records only — no file names are read back, which is what let an odd path name the
|
|
114
|
+
// wrong person. `window` is handed to git as written (`--since`), and git filters on the COMMITTER date,
|
|
115
|
+
// so a rebased or squashed commit counts from when it landed.
|
|
116
|
+
// Returns { authors: [{ name, login }] } — the people in map order of the directory they worked in,
|
|
117
|
+
// newest first within each, nobody twice — or { unknown: why }, never an empty list for a history it
|
|
118
|
+
// could not read: a shallow clone holds only the newest commits, and reading that as "nobody has worked
|
|
119
|
+
// here" would drop the ask instead of raising it. The twin is `--level` in checks/risk-map-check.sh.
|
|
120
|
+
export function recentAuthorsFor(repoRoot, baseRef, { entries, changed, window = HISTORY_WINDOW } = {}) {
|
|
121
|
+
const h = historyReader(repoRoot, baseRef);
|
|
122
|
+
if (h.unknown) return h;
|
|
123
|
+
const commits = [];
|
|
124
|
+
for (const e of highTouched(entries, changed)) {
|
|
125
|
+
const got = h.authorRecords(pathspecsFor(entries, e.dir), window);
|
|
126
|
+
if (!got) return { unknown: `git could not read the history of '${e.dir}' on '${baseRef}'` };
|
|
127
|
+
commits.push(...got);
|
|
128
|
+
}
|
|
129
|
+
return { authors: recentAuthors(commits, h.own) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The one git reader behind E67's ask and E68's suggestion. Returns { unknown: why } for a history it
|
|
133
|
+
// cannot read — a shallow clone holds only the newest commits, and reading that as "nobody has worked
|
|
134
|
+
// here" would be a guess — or { own, authorRecords }: the addresses of the change's own authors (left out
|
|
135
|
+
// of every answer: an approval, and a suggestion, have to be someone else), and a function that runs ONE
|
|
136
|
+
// log on the base branch for a set of pathspecs and returns its author records, or null when git fails.
|
|
137
|
+
// `what` names what a shallow clone lacks, for the sentence (E69 reads a whole branch, not directories).
|
|
138
|
+
function historyReader(repoRoot, baseRef, { what = 'those directories' } = {}) {
|
|
139
|
+
const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30, env: gitEnv() });
|
|
140
|
+
if (/true/.test(git(['rev-parse', '--is-shallow-repository']).stdout || '')) {
|
|
141
|
+
return { unknown: `this is a shallow clone — it does not hold the history of ${what}` };
|
|
142
|
+
}
|
|
143
|
+
const own = git(['log', `${baseRef}..HEAD`, '--no-merges', '--format=%ae']);
|
|
144
|
+
if (own.status !== 0) return { unknown: `git could not read the history of '${baseRef}'` };
|
|
145
|
+
const authorRecords = (pathspecs, window) => {
|
|
146
|
+
// --no-merges: a merge commit is nobody's work here. --no-renames: a file moved OUT of the directory
|
|
147
|
+
// is work in it, counted where it was (E66). --full-history: without it git simplifies a
|
|
148
|
+
// path-filtered log and hides a side branch whose merge kept the other side.
|
|
149
|
+
// --no-show-signature: `log.showSignature=true` (common where commits are signed) prints a signature
|
|
150
|
+
// check for each commit into this output, and each such line would read as a person.
|
|
151
|
+
// --no-follow: `log.follow=true` makes git crash on a single `:(glob)dir/*` pathspec (E68's kind; E67's
|
|
152
|
+
// `:(literal)` ones are unaffected), which would read every one-folder suggestion as "not read".
|
|
153
|
+
const r = git(['log', baseRef, '--no-merges', '--no-renames', '--full-history', '--no-show-signature', '--no-follow', `--since=${window}`,
|
|
154
|
+
'--format=%an%x1f%ae', '--', ...pathspecs]);
|
|
155
|
+
if (r.status !== 0) return null;
|
|
156
|
+
return r.stdout.split('\n').filter(Boolean).map((line) => {
|
|
157
|
+
const [name, email] = line.split('\x1f');
|
|
158
|
+
return { name, email };
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
return { own: own.stdout.split('\n').filter(Boolean), authorRecords };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// E68 — the files this change touches, deletions and moves included (E66's range: three dots, from where
|
|
165
|
+
// the branch left its base, `--no-renames`, NUL-separated so an odd name is never quoted). Read WITHOUT the
|
|
166
|
+
// risk map, because most repos have none and a suggestion does not need one. { files } or { unknown }.
|
|
167
|
+
export function changedSince(repoRoot, baseRef) {
|
|
168
|
+
const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
169
|
+
if (git(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]).status !== 0 || git(['merge-base', baseRef, 'HEAD']).status !== 0) {
|
|
170
|
+
return { unknown: `base ref '${baseRef}' not found, or it shares no history with HEAD (a shallow clone?)` };
|
|
171
|
+
}
|
|
172
|
+
const diff = git(['diff', '--name-only', '-z', '--no-renames', `${baseRef}...HEAD`]);
|
|
173
|
+
if (diff.status !== 0) return { unknown: `git could not read the change against ${baseRef}` };
|
|
174
|
+
return { files: diff.stdout.split('\0').filter(Boolean) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// How many folders one suggestion asks git about. Every folder goes into ONE log, so the cost does not
|
|
178
|
+
// grow with the count; the cap only keeps the command line short. A cut is always printed.
|
|
179
|
+
export const SUGGEST_FOLDER_CAP = 200;
|
|
180
|
+
|
|
181
|
+
// E68 — who has committed in the folders this change touches, in the window: a SUGGESTION of who may know
|
|
182
|
+
// the code, never an ask, never a request. The files DIRECTLY in each touched folder (`folderPathspec`),
|
|
183
|
+
// all in one log, so a commit touching two of them is counted once. Returns { authors: [{ name, login,
|
|
184
|
+
// commits }], folders, skipped } or { unknown: why } — never an empty list for a history it could not read.
|
|
185
|
+
export function suggestedAuthorsFor(repoRoot, baseRef, { changed, window = HISTORY_WINDOW, cap = SUGGEST_FOLDER_CAP } = {}) {
|
|
186
|
+
const all = touchedFolders(changed);
|
|
187
|
+
const folders = all.slice(0, cap);
|
|
188
|
+
if (!folders.length) return { authors: [], folders: 0, skipped: 0 };
|
|
189
|
+
const h = historyReader(repoRoot, baseRef);
|
|
190
|
+
if (h.unknown) return h;
|
|
191
|
+
const got = h.authorRecords(folders.map(folderPathspec), window);
|
|
192
|
+
if (!got) return { unknown: `git could not read the history of '${baseRef}'` };
|
|
193
|
+
return { authors: rankAuthors(got, h.own), folders: folders.length, skipped: all.length - folders.length };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// E69's window for the "owner seems inactive" hint: longer than E67's 30 days, so a person on leave or
|
|
197
|
+
// busy in another repo for a month is not named.
|
|
198
|
+
export const OWNER_WINDOW = '90 days ago';
|
|
199
|
+
|
|
200
|
+
// E69 — the platform logins that committed on `ref` in the window, read from noreply commit addresses:
|
|
201
|
+
// [{ login, host }], host 'github' or 'gitlab'. The whole history of `ref` (no pathspec — work anywhere in
|
|
202
|
+
// the repo counts), through the one reader E67 and E68 share, so the shallow-clone refusal and the robot
|
|
203
|
+
// rule are the same code. With `ref` HEAD there is no change of one's own to leave out. { unknown: why }
|
|
204
|
+
// for a history it cannot read, never an empty list.
|
|
205
|
+
export function recentLoginsFor(repoRoot, ref = 'HEAD', { window = OWNER_WINDOW } = {}) {
|
|
206
|
+
const h = historyReader(repoRoot, ref, { what: 'this branch' });
|
|
207
|
+
if (h.unknown) return h;
|
|
208
|
+
const got = h.authorRecords([], window);
|
|
209
|
+
if (!got) return { unknown: `git could not read the history of '${ref}'` };
|
|
210
|
+
return { logins: rankAuthors(got).filter((a) => a.login).map((a) => ({ login: a.login, host: a.loginHost })) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function checkRepo(repoRoot) {
|
|
214
|
+
const text = readRiskMap(repoRoot);
|
|
215
|
+
// No map: nothing to check, so the repo's file list (slow on a large repo) is never read.
|
|
216
|
+
if (text === null) return { git: run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoRoot }).ok, map: false, findings: [] };
|
|
217
|
+
const files = repoFiles(repoRoot);
|
|
218
|
+
if (!files) return { git: false, map: true, findings: [] };
|
|
219
|
+
const parsed = parseRiskMap(text);
|
|
220
|
+
return { git: true, map: true, entries: parsed.entries, findings: riskMapFindings(parsed, { files }) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const findingLine = (f) => `${c.bold(f.code)}${f.target ? ` ${f.target}` : ''}: ${f.message}`;
|
|
224
|
+
|
|
225
|
+
export async function runRiskMap(root, { action = 'check', name, json = false, dryRun = false } = {}) {
|
|
226
|
+
const t = targets(root, name);
|
|
227
|
+
if (t.error) {
|
|
228
|
+
if (json) emitJSON({ ok: false, error: t.error, hint: t.hint });
|
|
229
|
+
else { fail(t.error); hand(t.hint); }
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
return { ok: false };
|
|
232
|
+
}
|
|
233
|
+
const { list } = t;
|
|
234
|
+
|
|
235
|
+
if (action === 'check') {
|
|
236
|
+
const repos = list.map((t) => ({ name: t.name, root: t.root, ...checkRepo(t.root) }));
|
|
237
|
+
if (json) {
|
|
238
|
+
emitJSON({ ok: true, repos: repos.map((r) => ({ name: r.name, git: r.git, map: r.map, findings: r.findings })) });
|
|
239
|
+
return { ok: true, repos };
|
|
240
|
+
}
|
|
241
|
+
for (const r of repos) {
|
|
242
|
+
log(c.bold(`\nrisk map — ${r.name}`));
|
|
243
|
+
if (!r.git) { warn(`${r.root} is not a git repo — skipped`); continue; }
|
|
244
|
+
if (!r.map) { info(`no ${RISK_MAP_FILE} yet — no directory has a risk level`); hand(`draft one with \`yad risk-map draft ${r.name}\``); continue; }
|
|
245
|
+
if (!r.findings.length) { ok('every directory has a confirmed level'); continue; }
|
|
246
|
+
for (const f of r.findings) warn(findingLine(f));
|
|
247
|
+
hand(`fix ${RISK_MAP_FILE} in ${r.name} and commit it through a PR — the warnings are advisory and block nothing`);
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, repos };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (action === 'draft') {
|
|
253
|
+
const results = [];
|
|
254
|
+
for (const t of list) {
|
|
255
|
+
const files = repoFiles(t.root);
|
|
256
|
+
if (!files) { results.push({ name: t.name, git: false, added: [], written: false }); continue; }
|
|
257
|
+
const before = readRiskMap(t.root);
|
|
258
|
+
const d = draftRiskMap(before, files);
|
|
259
|
+
const changed = !d.refused && d.text !== before;
|
|
260
|
+
if (changed && !dryRun) {
|
|
261
|
+
fs.mkdirSync(path.join(t.root, '.sdlc'), { recursive: true });
|
|
262
|
+
fs.writeFileSync(path.join(t.root, RISK_MAP_FILE), d.text);
|
|
263
|
+
}
|
|
264
|
+
results.push({ name: t.name, git: true, added: d.added, unwritable: d.unwritable, refused: d.refused, written: changed && !dryRun });
|
|
265
|
+
}
|
|
266
|
+
if (json) {
|
|
267
|
+
emitJSON({ ok: !results.some((r) => r.refused), dryRun, repos: results });
|
|
268
|
+
} else {
|
|
269
|
+
for (const r of results) {
|
|
270
|
+
log(c.bold(`\nrisk map — ${r.name}`));
|
|
271
|
+
if (!r.git) { warn('not a git repo — skipped'); continue; }
|
|
272
|
+
if (r.refused) { fail(`${RISK_MAP_FILE} is ${r.refused} — nothing was written over it`); continue; }
|
|
273
|
+
if (r.unwritable.length) warn(`not added — a map line cannot hold these names (a space, \`#\`, \`*\`, \`?\`, \`[\` or \`\\\`): ${r.unwritable.join(' ')}; rename them to give them a level`);
|
|
274
|
+
if (!r.added.length) { ok('every directory already has a line — nothing to add'); continue; }
|
|
275
|
+
(r.written ? ok : info)(`${r.written ? 'added' : 'would add'} ${r.added.length} unset line(s): ${r.added.join(' ')}`);
|
|
276
|
+
}
|
|
277
|
+
if (results.some((r) => r.added.length)) {
|
|
278
|
+
hand('classify the `unset` lines: ask your AI agent to run the risk-map step of yad-connect-repos (it reads the code), or set them by hand');
|
|
279
|
+
hand(`then review every \`guessed\` level, change the right ones to \`confirmed\`, and commit ${RISK_MAP_FILE} in the code repo through a PR`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (results.some((r) => r.refused)) process.exitCode = 1;
|
|
283
|
+
return { ok: !results.some((r) => r.refused), repos: results };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fail(`unknown risk-map action: ${action} (check | draft)`);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return { ok: false };
|
|
289
|
+
}
|