yadflow 4.0.0-next.3 → 4.0.0
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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/epic-state.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import fs from 'node:fs';
|
|
|
7
7
|
import { isPlainObject, readJSON, readJSONStrict, writeJSON, fileSha } from './lib.mjs';
|
|
8
8
|
import { err } from './errors.mjs';
|
|
9
9
|
import {
|
|
10
|
-
ADVANCE_FROM_AUTOMATION, AUTOMATION_FROM_ADVANCE, DRIVER_FROM_ASSISTANCE, epicFiles, preferring,
|
|
11
|
-
PROJECT_FILES, SCHEMA_VERSION, stepAdvance,
|
|
10
|
+
ADVANCE_FROM_AUTOMATION, AUTOMATION_FROM_ADVANCE, DRIVER_FROM_ASSISTANCE, epicFiles, mirrorDrift, preferring,
|
|
11
|
+
productDrift, PROJECT_FILES, SCHEMA_VERSION, stepAdvance,
|
|
12
12
|
} from './manifest.mjs';
|
|
13
13
|
|
|
14
14
|
const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
|
|
@@ -37,7 +37,7 @@ const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
|
|
|
37
37
|
// `short` is measured against the capped ask and printed, and never enforced.
|
|
38
38
|
//
|
|
39
39
|
// WHEN THE COUNT IS UNKNOWN (`active: null`) no cap is computed at all: an unknown is never a small
|
|
40
|
-
// number (E71). Product CI is usually that case — it checks out only the
|
|
40
|
+
// number (E71). Product CI is usually that case — it checks out only the Product, so connected repos are
|
|
41
41
|
// not on disk — and the surfaces say so.
|
|
42
42
|
//
|
|
43
43
|
// Until E62 the base was not what held a gate either: a ROLE rule read from the roster did (1 owner,
|
|
@@ -263,10 +263,10 @@ export const gateRuleEnforced = (rule, cap = null) => {
|
|
|
263
263
|
//
|
|
264
264
|
// It sat in cli/gate.mjs until E71, which needs it in the active-people reader that gate.mjs prints —
|
|
265
265
|
// a cycle. It is pure and data-only, so it belongs here beside `gateRuleFor`; gate.mjs re-exports it.
|
|
266
|
-
export function legacyLogins(
|
|
266
|
+
export function legacyLogins(productConfig) {
|
|
267
267
|
const out = new Map();
|
|
268
268
|
const clash = new Set();
|
|
269
|
-
for (const e of Array.isArray(
|
|
269
|
+
for (const e of Array.isArray(productConfig?.roster) ? productConfig.roster : []) {
|
|
270
270
|
if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
|
|
271
271
|
if (out.has(e.name) && out.get(e.name) !== e.login) clash.add(e.name);
|
|
272
272
|
out.set(e.name, e.login);
|
|
@@ -347,6 +347,29 @@ export function epicIds(root) {
|
|
|
347
347
|
return ids.sort();
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// E122: a renamed file whose two names say different things (MIRRORED_FILES, cli/manifest.mjs). No
|
|
351
|
+
// command picks one of them, so each refuses with this — `yad doctor` and `yad migrate` excepted: one
|
|
352
|
+
// reports the drift, the other ends it. `pairs` is what `productDrift` found, project-relative.
|
|
353
|
+
export const driftError = (pairs) => err(
|
|
354
|
+
'YAD-STATE-008',
|
|
355
|
+
`${pairs.map((p) => `${p.canonical} and ${p.legacy}`).join('; ')} say different things — they are one file under two names until v5, so yad reads neither`,
|
|
356
|
+
'run `yad migrate` to see the difference, then `yad migrate --apply` to choose the copy to keep (or `--apply --keep product` / `--keep hub`)',
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// The drift under a Product root, as the error to throw, or null. Listing `epics/` can fail on a broken
|
|
360
|
+
// tree; that is left to the command, which reports it in its own words.
|
|
361
|
+
export function productDriftError(root) {
|
|
362
|
+
const pairs = productDriftPairs(root);
|
|
363
|
+
return pairs.length ? driftError(pairs) : null;
|
|
364
|
+
}
|
|
365
|
+
// The drifted pairs themselves: the one lookup the refusal, `yad doctor` and `yad migrate` all use, so what
|
|
366
|
+
// doctor names and what the commands refuse on cannot differ (E124 follow-up review).
|
|
367
|
+
export function productDriftPairs(root) {
|
|
368
|
+
let dirs = [];
|
|
369
|
+
try { dirs = epicIds(root).map(epicRel); } catch { /* the settings file is still checked */ }
|
|
370
|
+
return productDrift(root, dirs);
|
|
371
|
+
}
|
|
372
|
+
|
|
350
373
|
// The folders under `epics/` that hold a `.sdlc/` but that `epicIds` did not name: a name that is not a
|
|
351
374
|
// valid id, a symlinked epic (`Dirent.isDirectory()` is false for a symlink), and `epics/EP-foundation`.
|
|
352
375
|
// That guard is right for an enumerator that turns a name into a path segment, and wrong for anything
|
|
@@ -455,12 +478,12 @@ export const canonicalApprovals = (approvals = []) => canonical(approvals, (a) =
|
|
|
455
478
|
export const canonicalComments = (comments = []) => canonical(comments, (cm) =>
|
|
456
479
|
[cm.step, String(cm.round ?? '').padStart(6, '0'), cm.commenter, cm.role, cm.date].map(field).join('|'));
|
|
457
480
|
|
|
458
|
-
export const
|
|
481
|
+
export const canonicalProductPrs = (productPrs = []) => canonical(productPrs, (p) =>
|
|
459
482
|
[p.artifact, p.step].map(field).join('|'));
|
|
460
483
|
|
|
461
484
|
// Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
|
|
462
|
-
export function
|
|
463
|
-
return
|
|
485
|
+
export function upsertProductPr(productPrs = [], rec) {
|
|
486
|
+
return canonicalProductPrs([...productPrs.filter((p) => p.artifact !== rec.artifact), rec]);
|
|
464
487
|
}
|
|
465
488
|
|
|
466
489
|
// SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
|
|
@@ -1444,12 +1467,12 @@ export function loadLedger(epicDir) {
|
|
|
1444
1467
|
state: validateState(readJSONStrict(f.state, null), f.state),
|
|
1445
1468
|
approvals: requireArray(readJSONStrict(f.approvals, []), f.approvals),
|
|
1446
1469
|
comments: requireArray(readJSONStrict(f.comments, []), f.comments),
|
|
1447
|
-
// Read the
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const f2 = preferring(f.productPrs, f.
|
|
1470
|
+
// Read the new name when it exists, the old one otherwise — the same rule as the settings file
|
|
1471
|
+
// (`preferring`, cli/manifest.mjs). Both are written on every save (see gateSync / gateOpen), so
|
|
1472
|
+
// they agree unless something outside yad edited one; then neither is read (E122).
|
|
1473
|
+
productPrs: (() => {
|
|
1474
|
+
if (mirrorDrift(f.productPrs, f.productPrsLegacy)) throw driftError([{ canonical: f.productPrs, legacy: f.productPrsLegacy }]);
|
|
1475
|
+
const f2 = preferring(f.productPrs, f.productPrsLegacy);
|
|
1453
1476
|
return requireArray(readJSONStrict(f2, []), f2);
|
|
1454
1477
|
})(),
|
|
1455
1478
|
contractLock: readJSONStrict(f.contractLock, null),
|
|
@@ -1691,7 +1714,7 @@ export function gatePredicate({
|
|
|
1691
1714
|
const stale = forStep.filter((a) => isStaleHash(a.artifactHash, accepted));
|
|
1692
1715
|
const live = forStep.filter((a) => !stale.includes(a));
|
|
1693
1716
|
|
|
1694
|
-
// requireEngagement (config `
|
|
1717
|
+
// requireEngagement (config `review.requireEngagement` in the Product settings, soft-off by default): only an approval
|
|
1695
1718
|
// carrying a verified engagement signal counts. The signal is gameable by design — this raises the
|
|
1696
1719
|
// cost of a bare rubber-stamp, it does not claim to prove a human read the artifact.
|
|
1697
1720
|
const counted = requireEngagement ? live.filter((a) => a.engagement === 'verified') : live;
|
|
@@ -3328,7 +3351,7 @@ function reopenedLanes(ledger, { epicId, currentStep, bindings }) {
|
|
|
3328
3351
|
.map((s) => {
|
|
3329
3352
|
if (stepStatus(s) === 'blocked') return { step: s.id, kind: 'blocked', status: 'blocked', record: s.record || null };
|
|
3330
3353
|
if (s.type === 'author') return { step: s.id, kind: 'author', status: s.status, ...skillFields(stepSkills(s.id, bindings)), artifact: s.artifact };
|
|
3331
|
-
const pr = (ledger.
|
|
3354
|
+
const pr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(s.artifact));
|
|
3332
3355
|
return { step: s.id, kind: pr ? 'review-sync' : 'review-open', status: s.status, artifact: s.artifact, pr: pr ? pr.number : null,
|
|
3333
3356
|
command: `yad gate ${pr ? 'sync' : 'open'} ${epicId} ${s.artifact}` };
|
|
3334
3357
|
});
|
|
@@ -3366,7 +3389,7 @@ function shapeNextAction(ledger, { epic, bindings = null } = {}) {
|
|
|
3366
3389
|
...skillFields(stepSkills(dstep.id, bindings)), artifact: dstep.artifact,
|
|
3367
3390
|
why: `${dstep.id} is ${dstep.status} — author ${dstep.artifact}` };
|
|
3368
3391
|
}
|
|
3369
|
-
const dpr = (ledger.
|
|
3392
|
+
const dpr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(dstep.artifact));
|
|
3370
3393
|
const dverb = dpr ? 'sync' : 'open';
|
|
3371
3394
|
return { epicId, kind: dpr ? 'review-sync' : 'review-open', step: dstep.id, status: dstep.status,
|
|
3372
3395
|
artifact: dstep.artifact, pr: dpr ? dpr.number : null,
|
|
@@ -3450,7 +3473,7 @@ function shapeNextAction(ledger, { epic, bindings = null } = {}) {
|
|
|
3450
3473
|
}
|
|
3451
3474
|
|
|
3452
3475
|
// review+approve: open the review PR if none is recorded yet, else sync the open one.
|
|
3453
|
-
const pr = (ledger.
|
|
3476
|
+
const pr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(step.artifact));
|
|
3454
3477
|
const verb = pr ? 'sync' : 'open';
|
|
3455
3478
|
return { epicId, kind: pr ? 'review-sync' : 'review-open', step: step.id, status: step.status,
|
|
3456
3479
|
artifact: step.artifact, pr: pr ? pr.number : null, parallel,
|
package/cli/errors.mjs
CHANGED
|
@@ -23,11 +23,12 @@ export const CODES = {
|
|
|
23
23
|
'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
|
|
24
24
|
'YAD-STATE-006': 'a Build ledger is locked by another yad process that is writing it',
|
|
25
25
|
'YAD-STATE-007': 'an epic cannot be seeded — the profile, the type or the existing ledger refuses it',
|
|
26
|
-
'YAD-
|
|
26
|
+
'YAD-STATE-008': 'a file kept under two names (product.json + hub.json, or product-prs.json + hub-prs.json) says different things under each',
|
|
27
|
+
'YAD-CFG-001': 'the Product settings (product.json, or hub.json on a Product that has only that name) name an unknown platform (expected github, gitlab, or null)',
|
|
27
28
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
28
29
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
|
29
30
|
'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
|
|
30
|
-
'YAD-CFG-005': 'hub.json
|
|
31
|
+
'YAD-CFG-005': 'the Product settings (product.json, or hub.json on a Product that has only that name) set a platform but are missing git_url (required to scope auth + open PRs)',
|
|
31
32
|
'YAD-CFG-006': 'skills.json binds a step to something that is not a skill name (expected a string, or a non-empty list of strings)',
|
|
32
33
|
'YAD-CLI-001': 'a --json run needed an answer that only a prompt could give',
|
|
33
34
|
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Find the Product from wherever yad runs (E80).
|
|
2
|
+
//
|
|
3
|
+
// A WORKSPACE is the folder that holds the Product and the code repos side by side (E79):
|
|
4
|
+
//
|
|
5
|
+
// acme/
|
|
6
|
+
// .yad-workspace.json { "version": 1, "product": "product" }
|
|
7
|
+
// product/ <- the Product
|
|
8
|
+
// backend/ web/ <- code repos
|
|
9
|
+
//
|
|
10
|
+
// `findProduct(start)` walks up from `start` and stops at the FIRST of:
|
|
11
|
+
// - a folder that is a Product (it has `.sdlc/hub.json` or `.sdlc/product.json`) — so any subfolder of
|
|
12
|
+
// the Product finds it;
|
|
13
|
+
// - a folder holding `.yad-workspace.json` — its `product` names the Product folder beside the repos.
|
|
14
|
+
// It is used ONLY when `start` is inside a repo that Product registers in its `repos.json`. A
|
|
15
|
+
// workspace can be a shared folder (`~/Projects`, holding many teams' repos — setup always allowed
|
|
16
|
+
// that), and a file there must not send an unrelated repo's `yad epic new` or `yad kill` to this
|
|
17
|
+
// Product (E80 review 1). Anywhere else in the workspace there is no Product.
|
|
18
|
+
//
|
|
19
|
+
// The workspace file is per machine: the workspace folder is not a git repo (`yad new`, `init` and `join`
|
|
20
|
+
// refuse to make one inside a repo). So a `.yad-workspace.json` found INSIDE a git work tree is not used
|
|
21
|
+
// — a code repo could commit one, pointing yad at a Product folder of its own making — nor one in the
|
|
22
|
+
// home or temp folder. The walk STOPS there with the reason, rather than going on up to whatever
|
|
23
|
+
// Product lies above.
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { exists, readJSON, writeJSON } from './lib.mjs';
|
|
28
|
+
import { productConfigPath, PROJECT_FILES } from './manifest.mjs';
|
|
29
|
+
|
|
30
|
+
export const WORKSPACE_FILE = '.yad-workspace.json';
|
|
31
|
+
export const WORKSPACE_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
// The Product folder a workspace file may name: one folder, beside the file — never a path.
|
|
34
|
+
const PRODUCT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
35
|
+
|
|
36
|
+
// Is `dir`, or any folder above it, a git work tree? A `.git` folder or file (a worktree, a submodule)
|
|
37
|
+
// is enough — no git run needed.
|
|
38
|
+
function insideGitTree(dir) {
|
|
39
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
40
|
+
if (fs.existsSync(path.join(d, '.git'))) return true;
|
|
41
|
+
if (path.dirname(d) === d) return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// On disk, as the disk spells it: `.native` returns the stored case (APFS and NTFS ignore case, and the JS
|
|
46
|
+
// realpath keeps whatever case it was given) and Windows' long names for 8.3 short ones — the same rule
|
|
47
|
+
// as `samePath` in lib.mjs. Windows compares without case, too.
|
|
48
|
+
const real = (p) => { try { return fs.realpathSync.native(p); } catch { return null; } };
|
|
49
|
+
const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
50
|
+
const within = (child, parent) => fold(child) === fold(parent) || fold(child).startsWith(fold(parent) + path.sep);
|
|
51
|
+
|
|
52
|
+
// Read one workspace file. `{ product }` (an absolute Product root) or `{ problem }`.
|
|
53
|
+
export function readWorkspace(workspaceDir) {
|
|
54
|
+
const file = path.join(workspaceDir, WORKSPACE_FILE);
|
|
55
|
+
const rec = readJSON(file, null);
|
|
56
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return { problem: `${file} cannot be read` };
|
|
57
|
+
if (rec.version !== WORKSPACE_VERSION) return { problem: `${file} is version ${JSON.stringify(rec.version ?? null)}, and this yadflow reads version ${WORKSPACE_VERSION}` };
|
|
58
|
+
if (typeof rec.product !== 'string' || !PRODUCT_NAME_RE.test(rec.product)) return { problem: `${file}: "product" must be one folder name beside it` };
|
|
59
|
+
const product = path.join(workspaceDir, rec.product);
|
|
60
|
+
if (!exists(productConfigPath(product))) return { problem: `${file} names ${rec.product}/, which is not a Product` };
|
|
61
|
+
return { product };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const hasProduct = (dir) => exists(productConfigPath(dir));
|
|
65
|
+
|
|
66
|
+
// The Product a folder's own workspace file names, when the file is there and usable; else null.
|
|
67
|
+
export function workspaceProduct(dir) {
|
|
68
|
+
if (!fs.existsSync(path.join(dir, WORKSPACE_FILE)) || insideGitTree(dir)) return null;
|
|
69
|
+
return readWorkspace(dir).product || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// → { root, via: 'here' | 'above' | 'workspace', workspace?, repo? } | { problem } | { elsewhere } | null
|
|
73
|
+
// `repo` the registry entry of the code repo `start` is in, when there is one;
|
|
74
|
+
// `elsewhere` a workspace's Product, found from a folder that is not in one of its repos (the workspace
|
|
75
|
+
// folder itself, say) — not used, but worth naming to the person.
|
|
76
|
+
export function findProduct(start) {
|
|
77
|
+
const from = path.resolve(start);
|
|
78
|
+
// Crossing a git repo's top on the way up: the Product above is some OTHER repo's (E80 review 2). It is
|
|
79
|
+
// used only when it registers the repo `start` is in — the same rule as the workspace file's, so an
|
|
80
|
+
// unrelated repo nested in the Product's folder (`vendor/lib`) never writes to it.
|
|
81
|
+
let crossedRepo = false;
|
|
82
|
+
for (let d = from; ; d = path.dirname(d)) {
|
|
83
|
+
if (exists(productConfigPath(d))) {
|
|
84
|
+
if (d === from) return { root: d, via: 'here' };
|
|
85
|
+
const repo = registeredRepoHolding(d, from);
|
|
86
|
+
if (crossedRepo && !repo) return null;
|
|
87
|
+
return { root: d, via: 'above', ...(repo ? { repo } : {}) };
|
|
88
|
+
}
|
|
89
|
+
if (fs.existsSync(path.join(d, WORKSPACE_FILE))) {
|
|
90
|
+
const file = path.join(d, WORKSPACE_FILE);
|
|
91
|
+
if (insideGitTree(d)) return { problem: `${file} is inside a git repo, so it is not used (a repo could commit one)` };
|
|
92
|
+
if (sharedFolder(d)) return { problem: `${file} is in your home or temp folder, so it is not used` };
|
|
93
|
+
const ws = readWorkspace(d);
|
|
94
|
+
if (ws.problem) return { problem: ws.problem };
|
|
95
|
+
// Only for a repo this Product registers — never for any folder that happens to sit beside it.
|
|
96
|
+
const repo = registeredRepoHolding(ws.product, from);
|
|
97
|
+
return repo ? { root: ws.product, via: 'workspace', workspace: d, repo } : { elsewhere: ws.product };
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync(path.join(d, '.git'))) crossedRepo = true;
|
|
100
|
+
if (path.dirname(d) === d) return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The registry entry whose folder holds `dir` — the repo itself or any folder inside it — compared on
|
|
105
|
+
// disk (a link or a different spelling of the same folder still matches). The deepest one wins; the
|
|
106
|
+
// Product's own entry (a monorepo, `.`) never does.
|
|
107
|
+
export function registeredRepoHolding(productRoot, dir) {
|
|
108
|
+
const at = real(dir);
|
|
109
|
+
const productAt = real(productRoot);
|
|
110
|
+
if (!at || !productAt) return null;
|
|
111
|
+
const reg = readJSON(path.join(productRoot, PROJECT_FILES.reposRegistry), null);
|
|
112
|
+
let best = null;
|
|
113
|
+
let bestAt = '';
|
|
114
|
+
for (const r of Array.isArray(reg?.repos) ? reg.repos : []) {
|
|
115
|
+
if (typeof r?.path !== 'string') continue;
|
|
116
|
+
const repoAt = real(path.resolve(productRoot, r.path));
|
|
117
|
+
if (!repoAt || fold(repoAt) === fold(productAt) || !within(at, repoAt) || repoAt.length <= bestAt.length) continue;
|
|
118
|
+
best = r;
|
|
119
|
+
bestAt = repoAt;
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Folders a workspace file is never written into: the home folder, the temp folder and the top of a disk.
|
|
125
|
+
// A file there would be found from every folder below it — every project, every test's temp folder.
|
|
126
|
+
function sharedFolder(dir) {
|
|
127
|
+
const onDisk = (p) => fold(real(p) ?? path.resolve(p));
|
|
128
|
+
const d = onDisk(dir);
|
|
129
|
+
return path.dirname(d) === d || d === onDisk(os.homedir()) || d === onDisk(os.tmpdir());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Does a registered repo live BESIDE the Product (not inside it)? Only then does a workspace file help
|
|
133
|
+
// from a code repo — `yad check --fix` writes one only for such a Product (E80).
|
|
134
|
+
export function hasSiblingRepo(productRoot) {
|
|
135
|
+
const root = path.resolve(productRoot);
|
|
136
|
+
const workspace = path.dirname(root);
|
|
137
|
+
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), null);
|
|
138
|
+
return (Array.isArray(reg?.repos) ? reg.repos : []).some((r) => {
|
|
139
|
+
if (typeof r?.path !== 'string') return false;
|
|
140
|
+
const at = path.resolve(root, r.path);
|
|
141
|
+
// …and is on this machine: a teammate who cloned the Product alone gains nothing from the file.
|
|
142
|
+
return !(at === root || at.startsWith(root + path.sep)) && at.startsWith(workspace + path.sep) && fs.existsSync(at);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The workspace file for this Product: 'ok' | 'missing' | 'other: <why>' (another file there, or a
|
|
147
|
+
// folder a file is never written into). Read by `yad doctor` and `yad check`.
|
|
148
|
+
export function workspaceFileState(productRoot) {
|
|
149
|
+
const root = path.resolve(productRoot);
|
|
150
|
+
const workspace = path.dirname(root);
|
|
151
|
+
const name = path.basename(root);
|
|
152
|
+
if (path.dirname(root) === root || sharedFolder(workspace)) return 'other: the folder above the Product is your home folder, the temp folder or the top of the disk — move the Product into a folder of its own';
|
|
153
|
+
if (!PRODUCT_NAME_RE.test(name)) return `other: the Product folder's name (${name}) is not one a workspace file can hold`;
|
|
154
|
+
if (insideGitTree(workspace)) return 'other: the folder above the Product is inside a git repo';
|
|
155
|
+
const cur = readJSON(path.join(workspace, WORKSPACE_FILE), null);
|
|
156
|
+
if (cur && cur.product === name && cur.version === WORKSPACE_VERSION) return 'ok';
|
|
157
|
+
if (cur && cur.product === name) return `other: ${WORKSPACE_FILE} there is version ${JSON.stringify(cur.version ?? null)}, not ${WORKSPACE_VERSION}`;
|
|
158
|
+
if (cur) return `other: ${WORKSPACE_FILE} there names ${typeof cur.product === 'string' ? `${cur.product}/` : 'no Product'}`;
|
|
159
|
+
return 'missing';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Write the workspace file beside the Product: `{ version, product }`, the Product folder's own name.
|
|
163
|
+
// Only where `workspaceFileState` says it is missing — never over another file, never inside a git work
|
|
164
|
+
// tree (it would be committed to someone's repo, and is ignored there — see above), never into a shared
|
|
165
|
+
// folder. Returns 'written' | 'ok' | 'skipped: <why>'.
|
|
166
|
+
export function writeWorkspaceFile(productRoot) {
|
|
167
|
+
const state = workspaceFileState(productRoot);
|
|
168
|
+
if (state === 'ok') return 'ok';
|
|
169
|
+
if (state !== 'missing') return `skipped: ${state.replace(/^other: /, '')}`;
|
|
170
|
+
const root = path.resolve(productRoot);
|
|
171
|
+
writeJSON(path.join(path.dirname(root), WORKSPACE_FILE), { version: WORKSPACE_VERSION, product: path.basename(root) });
|
|
172
|
+
return 'written';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The top of the git checkout `dir` is in: the nearest folder holding `.git` (a folder, or the file a
|
|
176
|
+
// worktree or submodule has). Null outside any checkout.
|
|
177
|
+
export function gitTopOf(dir) {
|
|
178
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
179
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
180
|
+
if (path.dirname(d) === d) return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The registry entry for the code repo a code-repo command runs in (E80), with the folder to work in.
|
|
185
|
+
// It is the CHECKOUT that must be the registered repo — not merely a folder inside it: a worktree or a
|
|
186
|
+
// repo nested in `backend/` is another checkout, on another branch, and must never be swapped for
|
|
187
|
+
// backend's own (E80 review 3). → { meta, top } or null. Never the Product's own entry.
|
|
188
|
+
export function registryEntryFor(productRoot, dir) {
|
|
189
|
+
if (!productRoot) return null;
|
|
190
|
+
const top = gitTopOf(dir);
|
|
191
|
+
if (!top) return null;
|
|
192
|
+
const meta = registeredRepoHolding(productRoot, top);
|
|
193
|
+
if (!meta) return null;
|
|
194
|
+
const at = real(path.resolve(productRoot, meta.path));
|
|
195
|
+
const topAt = real(top);
|
|
196
|
+
return at && topAt && fold(at) === fold(topAt) ? { meta, top } : null;
|
|
197
|
+
}
|
package/cli/fold.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { ok, info, warn, fail, hand, readJSON } from './lib.mjs';
|
|
|
32
32
|
import { productConfigPath, isVerifiedLedger } from './manifest.mjs';
|
|
33
33
|
import { STEPS, FOUNDATION_EPIC, FOUNDATION_DIR, DISCOVERY_EPIC, artifactBase, artifactPaths, epicRoot } from './epic-state.mjs';
|
|
34
34
|
import { capturedEpic, gitIn, isOwnerPath, statusEntries, wipName, wipBranch, runCapture } from './capture.mjs';
|
|
35
|
-
import { resolveDefaultBranch, preflightGuardReadiness } from './
|
|
35
|
+
import { resolveDefaultBranch, preflightGuardReadiness } from './productcommit.mjs';
|
|
36
36
|
|
|
37
37
|
// The authoring steps a fold can close: every catalogue step of kind `author` that writes a Shape artifact.
|
|
38
38
|
// The Build steps (spec, tasks, implement, checks) write code in another repo and have no artifact here.
|
|
@@ -125,8 +125,8 @@ export async function runFold(root, { epic, step, env = process.env, capture = r
|
|
|
125
125
|
if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/hub.json or product.json here)', 'run it from the Product root, or pass --dir');
|
|
126
126
|
if (!fs.existsSync(epicRoot(root, epic))) return refuse(`no ${epicRel(epic)}/ in this Product`);
|
|
127
127
|
|
|
128
|
-
const
|
|
129
|
-
const verified = isVerifiedLedger(
|
|
128
|
+
const productConfig = readJSON(productConfigPath(root), {}) || {};
|
|
129
|
+
const verified = isVerifiedLedger(productConfig);
|
|
130
130
|
const branchR = git(['symbolic-ref', '--short', '-q', 'HEAD']);
|
|
131
131
|
const branch = branchR.ok ? branchR.out.trim() : null;
|
|
132
132
|
if (verified && !branch) {
|
|
@@ -140,7 +140,7 @@ export async function runFold(root, { epic, step, env = process.env, capture = r
|
|
|
140
140
|
.find((f) => { const r = git(['rev-parse', '--git-path', f]); return r.ok && fs.existsSync(path.resolve(root, r.out.trim())); });
|
|
141
141
|
if (busy) return refuse(`a ${IN_PROGRESS[busy]} is in progress — finish or abort it first, then run yad fold again`);
|
|
142
142
|
if (verified) {
|
|
143
|
-
const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; },
|
|
143
|
+
const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, productConfig);
|
|
144
144
|
if (branch === def) {
|
|
145
145
|
return refuse(`on the default branch '${def}' — with a verified ledger, artifacts reach it only through the review PR`,
|
|
146
146
|
`switch to the step's authoring branch first (git switch -c ${step}/${epic}), then run yad fold again`);
|
package/cli/gate-local.mjs
CHANGED
|
@@ -43,12 +43,12 @@ export function reviewerName(v) {
|
|
|
43
43
|
function localGate(root, { epic, artifact, verb }) {
|
|
44
44
|
const usage = `yad gate ${verb} <epic> <artifact>${verb === 'advance' ? '' : ' --by <name>'}`;
|
|
45
45
|
if (!artifact) { fail(`usage: ${usage}`); process.exitCode = 1; return null; }
|
|
46
|
-
const {
|
|
46
|
+
const { productConfig } = loadProduct(root);
|
|
47
47
|
// One check covers both refusals: a verified ledger needs a platform (`isVerifiedLedger`), so any
|
|
48
48
|
// platform at all means the approvals live on the PR/MR, not here.
|
|
49
|
-
if (
|
|
50
|
-
fail(`\`yad gate ${verb}\` is for a Product with no platform — this one reviews on ${printable(String(
|
|
51
|
-
if (isVerifiedLedger(
|
|
49
|
+
if (productConfig?.platform) {
|
|
50
|
+
fail(`\`yad gate ${verb}\` is for a Product with no platform — this one reviews on ${printable(String(productConfig.platform)) ?? 'a platform'}, so nothing is written`);
|
|
51
|
+
if (isVerifiedLedger(productConfig)) hand('CI owns this ledger: approvals, comments and the advance land from the review PR/MR when it merges (`yad gate ci`)');
|
|
52
52
|
else hand(`review on the PR/MR, then run \`yad gate sync ${epic} ${artifact}\` — it reads the approvals and advances the step when the PR merges`);
|
|
53
53
|
process.exitCode = 1;
|
|
54
54
|
return null;
|
|
@@ -58,7 +58,7 @@ function localGate(root, { epic, artifact, verb }) {
|
|
|
58
58
|
if (!ledger.state) { fail(`no epic state at ${epicRel(epic)}/.sdlc/state.json`); process.exitCode = 1; return null; }
|
|
59
59
|
const step = findReviewStep(ledger.state, artifact);
|
|
60
60
|
if (!step) { fail(`${epic} has no review step for ${artifact}`); process.exitCode = 1; return null; }
|
|
61
|
-
return {
|
|
61
|
+
return { productConfig, epicDir, ledger, step, solo: isSolo(productConfig), reqEng: requireEngagement(productConfig) };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// A gate is recorded against only while it is OPEN. A step not reached yet has no review to approve:
|
|
@@ -98,8 +98,8 @@ const peopleOnStep = (g) => [
|
|
|
98
98
|
|
|
99
99
|
// The one count every verb reports, read once per command (E71) — or handed in by a test, which must not
|
|
100
100
|
// walk git from inside a fixture.
|
|
101
|
-
function countPeople(root,
|
|
102
|
-
return headCount || activePeople(root, { today: today || undefined, aliases: legacyLogins(
|
|
101
|
+
function countPeople(root, productConfig, today, headCount) {
|
|
102
|
+
return headCount || activePeople(root, { today: today || undefined, aliases: legacyLogins(productConfig) });
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function judge(g, epicDir, people) {
|
|
@@ -162,7 +162,7 @@ export async function gateApprove(root, { epic, artifact, by, engagement = null,
|
|
|
162
162
|
const { epicDir, ledger, step } = g;
|
|
163
163
|
const hash = artifactHash(epicDir, step.artifact);
|
|
164
164
|
const eng = engagement || 'none';
|
|
165
|
-
// Filter-then-push, keyed on (step, approver), as `
|
|
165
|
+
// Filter-then-push, keyed on (step, approver), as `upsertProductPr` does: one record per person. A bridge
|
|
166
166
|
// record (from a platform the Product once had) is left alone — it is another source's history.
|
|
167
167
|
// The same person approving the same content again keeps the record as it was, date included, so a
|
|
168
168
|
// repeat is a byte-identical no-op rather than a new line in the ledger's history.
|
|
@@ -180,12 +180,12 @@ export async function gateApprove(root, { epic, artifact, by, engagement = null,
|
|
|
180
180
|
// approvers, commenters, what is still required): writing the short `gate sync` list here would erase
|
|
181
181
|
// it on every approval of the day (E112 review).
|
|
182
182
|
writeJSON(ledger.files.approvals, g.ledger.approvals);
|
|
183
|
-
refreshIndexAfterWrite(root, g.
|
|
183
|
+
refreshIndexAfterWrite(root, g.productConfig);
|
|
184
184
|
}
|
|
185
185
|
if (same) info(`${name} had already approved this content of ${step.artifact} — nothing changed`);
|
|
186
186
|
else ok(`${name} approved ${step.artifact} (${step.id})${was ? ' — their earlier approval is replaced' : ''}`);
|
|
187
187
|
|
|
188
|
-
const people = countPeople(root, g.
|
|
188
|
+
const people = countPeople(root, g.productConfig, today, headCount);
|
|
189
189
|
log(` ${c.dim(activeSum(people))}`);
|
|
190
190
|
note(c.dim(activeBasis(people)));
|
|
191
191
|
const pred = judge(g, epicDir, people);
|
|
@@ -247,7 +247,7 @@ export async function gateComment(root, { epic, artifact, by, count = null, newR
|
|
|
247
247
|
ledger.comments = canonicalComments([...ledger.comments.filter((cm) => !mine(cm)), record]);
|
|
248
248
|
if (!same) {
|
|
249
249
|
writeJSON(ledger.files.comments, ledger.comments);
|
|
250
|
-
refreshIndexAfterWrite(root, g.
|
|
250
|
+
refreshIndexAfterWrite(root, g.productConfig);
|
|
251
251
|
}
|
|
252
252
|
if (same) info(`${name}'s round ${round} on ${step.artifact} already says ${n} comment(s) — nothing changed`);
|
|
253
253
|
else ok(`${name}: ${n} comment(s) on ${step.artifact}, round ${round}${was ? ` (was ${was.count})` : ''}`);
|
|
@@ -270,7 +270,7 @@ export async function gateAdvance(root, { epic, artifact, today, headCount = nul
|
|
|
270
270
|
return;
|
|
271
271
|
}
|
|
272
272
|
if (refuseUnopened(g, { epic, artifact, verb: 'advance' })) return;
|
|
273
|
-
const people = countPeople(root, g.
|
|
273
|
+
const people = countPeople(root, g.productConfig, today, headCount);
|
|
274
274
|
log(` ${c.dim(activeSum(people))}`);
|
|
275
275
|
note(c.dim(activeBasis(people)));
|
|
276
276
|
const pred = judge(g, epicDir, people);
|
|
@@ -290,7 +290,7 @@ export async function gateAdvance(root, { epic, artifact, today, headCount = nul
|
|
|
290
290
|
capped: pred.rule === 'count' && pred.cap?.capped ? { needed: pred.gateRule.needed, to: pred.cap.to, active: pred.cap.active } : null,
|
|
291
291
|
});
|
|
292
292
|
writeState(ledger.files.state, state);
|
|
293
|
-
refreshIndexAfterWrite(root, g.
|
|
293
|
+
refreshIndexAfterWrite(root, g.productConfig);
|
|
294
294
|
ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
|
|
295
295
|
return { epic, advanced: true, currentStep: state.currentStep ?? null, gate };
|
|
296
296
|
}
|