@deftai/directive-core 0.97.0 → 0.98.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.
Files changed (51) hide show
  1. package/dist/authz/classify.js +443 -0
  2. package/dist/check/cached-orchestrator.d.ts +5 -0
  3. package/dist/check/cached-orchestrator.js +18 -1
  4. package/dist/check/gate-lists.d.ts +20 -0
  5. package/dist/check/gate-lists.js +46 -9
  6. package/dist/check/index.d.ts +1 -1
  7. package/dist/check/index.js +1 -1
  8. package/dist/check/orchestrator.d.ts +4 -0
  9. package/dist/check/orchestrator.js +4 -0
  10. package/dist/doctor/checks.d.ts +7 -0
  11. package/dist/doctor/checks.js +83 -0
  12. package/dist/hooks/dispatcher.d.ts +5 -0
  13. package/dist/hooks/dispatcher.js +54 -4
  14. package/dist/init-deposit/hygiene.d.ts +70 -1
  15. package/dist/init-deposit/hygiene.js +582 -8
  16. package/dist/init-deposit/scaffold.js +277 -4
  17. package/dist/init-deposit/skill-discovery-deposit.js +15 -0
  18. package/dist/policy/check-resume.d.ts +72 -0
  19. package/dist/policy/check-resume.js +253 -0
  20. package/dist/policy/coverage-check-resume-presets.d.ts +46 -0
  21. package/dist/policy/coverage-check-resume-presets.js +228 -0
  22. package/dist/policy/coverage-debt.d.ts +76 -0
  23. package/dist/policy/coverage-debt.js +262 -0
  24. package/dist/policy/index.d.ts +3 -0
  25. package/dist/policy/index.js +50 -21
  26. package/dist/release/auto-hatch.d.ts +114 -0
  27. package/dist/release/auto-hatch.js +301 -0
  28. package/dist/release/coverage-debt-ledger.d.ts +22 -0
  29. package/dist/release/coverage-debt-ledger.js +157 -0
  30. package/dist/release/index.d.ts +3 -0
  31. package/dist/release/index.js +3 -0
  32. package/dist/release/pipeline.js +164 -12
  33. package/dist/release/suite-stamp.d.ts +44 -0
  34. package/dist/release/suite-stamp.js +133 -0
  35. package/dist/release/types.d.ts +19 -0
  36. package/dist/scope-provenance/evaluate.d.ts +21 -0
  37. package/dist/scope-provenance/evaluate.js +143 -33
  38. package/dist/scope-provenance/index.d.ts +1 -1
  39. package/dist/scope-provenance/index.js +1 -1
  40. package/dist/session/coverage-check-resume-nudge.d.ts +34 -0
  41. package/dist/session/coverage-check-resume-nudge.js +66 -0
  42. package/dist/session/index.d.ts +1 -0
  43. package/dist/session/index.js +1 -0
  44. package/dist/session/session-start.js +21 -0
  45. package/dist/triage/classify/label-mirror.d.ts +31 -1
  46. package/dist/triage/classify/label-mirror.js +78 -6
  47. package/dist/triage/help/registry-data.d.ts +6 -6
  48. package/dist/triage/help/registry-data.js +12 -3
  49. package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
  50. package/dist/vbrief-validate/plan-hooks.js +54 -0
  51. package/package.json +3 -3
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Open-issue coverage-debt ledger probes for release Step 5 (#2866 / #3187).
3
+ *
4
+ * Production path uses `gh`; all I/O is seamed for unit tests (no live network).
5
+ */
6
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { containedWrite } from "../fs/contained-write.js";
9
+ import { extractCoverageDebtCitationsFromChangelog, filterOpenCoverageDebtIssues, mergeOpenDebtLedger, } from "./auto-hatch.js";
10
+ import { resolveGh } from "./gh.js";
11
+ import { defaultWhich, spawnText } from "./spawn.js";
12
+ function spawn(seams, cmd, args, cwd) {
13
+ const run = seams.spawnText ?? spawnText;
14
+ return run(cmd, args, { cwd, timeoutMs: 60_000, env: { ...process.env } });
15
+ }
16
+ class LedgerProbeError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "LedgerProbeError";
20
+ }
21
+ }
22
+ function listIssuesBySearch(ghPath, repo, projectRoot, search, seams) {
23
+ const result = spawn(seams, ghPath, [
24
+ "issue",
25
+ "list",
26
+ "--repo",
27
+ repo,
28
+ "--state",
29
+ "open",
30
+ "--search",
31
+ search,
32
+ "--limit",
33
+ "20",
34
+ "--json",
35
+ "number,title,body,state",
36
+ ], projectRoot);
37
+ if (result.status !== 0) {
38
+ throw new LedgerProbeError(`coverage-debt ledger search failed (${search}): ${(result.stderr || result.stdout).trim() || `exit ${result.status}`}`);
39
+ }
40
+ try {
41
+ const rows = JSON.parse(result.stdout || "[]");
42
+ if (!Array.isArray(rows)) {
43
+ throw new LedgerProbeError(`coverage-debt ledger search returned non-array for ${search}`);
44
+ }
45
+ return rows;
46
+ }
47
+ catch (err) {
48
+ if (err instanceof LedgerProbeError)
49
+ throw err;
50
+ throw new LedgerProbeError(`coverage-debt ledger search unparseable JSON for ${search}: ${err instanceof Error ? err.message : String(err)}`);
51
+ }
52
+ }
53
+ function viewIssueState(ghPath, repo, projectRoot, issue, seams) {
54
+ // Prefer REST via `gh api` (avoids GraphQL issue-view --json).
55
+ const result = spawn(seams, ghPath, ["api", `repos/${repo}/issues/${issue}`, "--jq", ".state"], projectRoot);
56
+ if (result.status !== 0)
57
+ return "UNKNOWN";
58
+ const state = result.stdout.trim().toUpperCase();
59
+ if (state === "OPEN")
60
+ return "OPEN";
61
+ if (state === "CLOSED")
62
+ return "CLOSED";
63
+ return "UNKNOWN";
64
+ }
65
+ /**
66
+ * Probe open coverage-debt ledger: marker searches + CHANGELOG citation scan.
67
+ * Citation numbers whose state is OPEN or UNKNOWN count as unpaid (fail closed).
68
+ *
69
+ * Fail closed: gh marker-search failures throw (do not treat as empty ledger).
70
+ * Missing gh falls through to CHANGELOG citations only (still fail-closed on UNKNOWN).
71
+ */
72
+ export function probeOpenCoverageDebtLedger(repo, projectRoot, seams = {}) {
73
+ if (seams.listOpenDebtIssues) {
74
+ return seams.listOpenDebtIssues(repo, projectRoot);
75
+ }
76
+ const which = seams.whichGh ?? defaultWhich;
77
+ const ghPath = resolveGh({ whichGh: which, spawnText: seams.spawnText });
78
+ const markerHits = ghPath === null
79
+ ? []
80
+ : [
81
+ ...listIssuesBySearch(ghPath, repo, projectRoot, "coverage-debt in:title,body", seams),
82
+ ...listIssuesBySearch(ghPath, repo, projectRoot, "allow-coverage-debt in:body", seams),
83
+ ];
84
+ const fromMarkers = filterOpenCoverageDebtIssues(markerHits);
85
+ const changelogPath = join(projectRoot, "CHANGELOG.md");
86
+ const exists = seams.fileExists ?? ((p) => existsSync(p));
87
+ const read = seams.readFile ?? ((p) => readFileSync(p, "utf8"));
88
+ let citedOpen = [];
89
+ if (exists(changelogPath)) {
90
+ try {
91
+ const cited = extractCoverageDebtCitationsFromChangelog(read(changelogPath));
92
+ if (ghPath === null) {
93
+ // Fail closed: cannot confirm closed state without gh.
94
+ citedOpen = cited;
95
+ }
96
+ else {
97
+ const openRows = [];
98
+ for (const n of cited) {
99
+ const state = viewIssueState(ghPath, repo, projectRoot, n, seams);
100
+ if (state === "OPEN" || state === "UNKNOWN") {
101
+ openRows.push({ number: n });
102
+ }
103
+ }
104
+ citedOpen = filterOpenCoverageDebtIssues(openRows);
105
+ }
106
+ }
107
+ catch {
108
+ // ignore unreadable changelog
109
+ }
110
+ }
111
+ return mergeOpenDebtLedger(fromMarkers, citedOpen);
112
+ }
113
+ /** Create a coverage-debt issue; returns issue number or throws. */
114
+ export function createCoverageDebtIssue(repo, projectRoot, title, body, seams = {}) {
115
+ if (seams.createDebtIssue) {
116
+ return seams.createDebtIssue(repo, projectRoot, title, body);
117
+ }
118
+ const which = seams.whichGh ?? defaultWhich;
119
+ const ghPath = resolveGh({ whichGh: which, spawnText: seams.spawnText });
120
+ if (ghPath === null) {
121
+ throw new Error("gh CLI not found on PATH — cannot file coverage-debt issue");
122
+ }
123
+ // Body file under projectRoot via containedWrite (#2951) — not OS temp raw write.
124
+ const notesRel = join(".deft", "tmp", `coverage-debt-body-${process.pid}-${Date.now()}.md`);
125
+ const notesFile = join(projectRoot, notesRel);
126
+ containedWrite({
127
+ root: projectRoot,
128
+ target: notesFile,
129
+ data: body,
130
+ mode: "replace",
131
+ });
132
+ try {
133
+ const result = spawn(seams, ghPath, ["issue", "create", "--repo", repo, "--title", title, "--body-file", notesFile], projectRoot);
134
+ if (result.status !== 0) {
135
+ throw new Error(`gh issue create failed: ${(result.stderr || result.stdout).trim() || `exit ${result.status}`}`);
136
+ }
137
+ const url = (result.stdout || "").trim();
138
+ const m = /\/issues\/(\d+)\s*$/.exec(url) ?? /\/issues\/(\d+)/.exec(url);
139
+ if (!m) {
140
+ throw new Error(`gh issue create succeeded but no issue URL in stdout: ${url}`);
141
+ }
142
+ const n = Number.parseInt(m[1] ?? "", 10);
143
+ if (!Number.isFinite(n) || n <= 0 || String(n) !== (m[1] ?? "")) {
144
+ throw new Error(`gh issue create returned non-integer issue id: ${m[1]}`);
145
+ }
146
+ return n;
147
+ }
148
+ finally {
149
+ try {
150
+ unlinkSync(notesFile);
151
+ }
152
+ catch {
153
+ // best-effort
154
+ }
155
+ }
156
+ }
157
+ //# sourceMappingURL=coverage-debt-ledger.js.map
@@ -1,5 +1,7 @@
1
+ export * from "./auto-hatch.js";
1
2
  export * from "./build-dist.js";
2
3
  export * from "./constants.js";
4
+ export * from "./coverage-debt-ledger.js";
3
5
  export * from "./flags.js";
4
6
  export * from "./gh.js";
5
7
  export * from "./git.js";
@@ -9,6 +11,7 @@ export * from "./paths.js";
9
11
  export * from "./pipeline.js";
10
12
  export * from "./preflight.js";
11
13
  export * from "./spawn.js";
14
+ export * from "./suite-stamp.js";
12
15
  export * from "./types.js";
13
16
  export * from "./version.js";
14
17
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,8 @@
1
1
  /* v8 ignore file -- re-export barrel; covered via main.ts */
2
+ export * from "./auto-hatch.js";
2
3
  export * from "./build-dist.js";
3
4
  export * from "./constants.js";
5
+ export * from "./coverage-debt-ledger.js";
4
6
  export * from "./flags.js";
5
7
  export * from "./gh.js";
6
8
  export * from "./git.js";
@@ -10,6 +12,7 @@ export * from "./paths.js";
10
12
  export * from "./pipeline.js";
11
13
  export * from "./preflight.js";
12
14
  export * from "./spawn.js";
15
+ export * from "./suite-stamp.js";
13
16
  export * from "./types.js";
14
17
  export * from "./version.js";
15
18
  //# sourceMappingURL=index.js.map
@@ -1,15 +1,67 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { assertProjectionContained } from "../fs/projection-containment.js";
4
+ import { readCoverageTotalsFromReport } from "../vitest-runner/coverage-debt.js";
5
+ import { buildCoverageDebtIssueDraft, classifyStep5FailureWithFreshness, evaluateAutoHatch, formatAutoHatchBanner, parseExitCodeFromReason, reasonLooksLikeTimeout, } from "./auto-hatch.js";
4
6
  import { prependUpgradeBanner, promoteChangelog, sectionForVersion } from "./changelog.js";
5
7
  import { EXIT_CONFIG_ERROR, EXIT_OK, EXIT_VIOLATION, RELEASE_ARTIFACTS, RELEASE_CHECK_TIMEOUT_MINUTES, TOTAL_STEPS, VERIFY_DRAFT_INTERVAL_SECONDS, VERIFY_DRAFT_MAX_ATTEMPTS, } from "./constants.js";
8
+ import { createCoverageDebtIssue, probeOpenCoverageDebtLedger } from "./coverage-debt-ledger.js";
6
9
  import { checkTagAvailable, createGithubRelease, readTextFile, verifyReleaseDraft } from "./gh.js";
7
- import { checkGitClean, commitReleaseArtifacts, createTag, currentBranch, pushRelease, releaseCommitSubject, } from "./git.js";
10
+ import { checkGitClean, commitReleaseArtifacts, createTag, currentBranch, pushRelease, releaseCommitSubject, runGit, } from "./git.js";
8
11
  import { checkVbriefLifecycleSyncNative, refreshRoadmapNative, runBuildNative, } from "./native-steps.js";
9
12
  import { todayIso } from "./paths.js";
10
13
  import { runReleaseCheck } from "./preflight.js";
11
14
  import { formatSkipCiIncidentWarning } from "./skip-ci-incident.js";
15
+ import { evaluateSuiteStamp, writeSuiteStamp } from "./suite-stamp.js";
12
16
  import { isPrereleaseTag } from "./version.js";
17
+ function resolveHeadSha(projectRoot, seams) {
18
+ if (seams.headSha)
19
+ return seams.headSha(projectRoot);
20
+ const result = runGit(projectRoot, ["rev-parse", "HEAD"], seams);
21
+ if (result.status !== 0)
22
+ return null;
23
+ const sha = result.stdout.trim();
24
+ return sha || null;
25
+ }
26
+ function resolveCoverageTotals(projectRoot, seams) {
27
+ if (seams.readCoverageTotals)
28
+ return seams.readCoverageTotals(projectRoot);
29
+ return readCoverageTotalsFromReport(join(projectRoot, "coverage"));
30
+ }
31
+ function resolveCoverageReportMtimeMs(projectRoot, seams) {
32
+ // Explicit readCoverageTotals seam (tests) → omit mtime so freshness does not force UNKNOWN.
33
+ if (seams.readCoverageTotals)
34
+ return undefined;
35
+ const finalPath = join(projectRoot, "coverage", "coverage-final.json");
36
+ try {
37
+ if (!existsSync(finalPath))
38
+ return null;
39
+ return statSync(finalPath).mtimeMs;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function recordSuiteStamp(projectRoot, suite, debtIssue, seams) {
46
+ const headSha = resolveHeadSha(projectRoot, seams);
47
+ if (!headSha)
48
+ return;
49
+ try {
50
+ writeSuiteStamp(projectRoot, {
51
+ headSha,
52
+ suite,
53
+ debtIssue,
54
+ recordedAt: new Date().toISOString(),
55
+ }, {
56
+ readFile: seams.readFile,
57
+ writeFile: seams.writeFile,
58
+ fileExists: seams.fileExists,
59
+ });
60
+ }
61
+ catch {
62
+ // Stamp is best-effort; never fail a green/hatch cut on stamp I/O.
63
+ }
64
+ }
13
65
  export function emit(step, label, status, target = process.stderr) {
14
66
  target.write(`[${step}/${TOTAL_STEPS}] ${label}... ${status}\n`);
15
67
  }
@@ -107,6 +159,10 @@ export function runPipeline(config, seams = {}) {
107
159
  // the ci_local.py bridge, but the emitted label/dry-run text is kept
108
160
  // byte-identical to the Python oracle (scripts/release.py) so the #1729
109
161
  // golden-diff release-parity gate stays green until the oracle is retired.
162
+ //
163
+ // #3187: SHA suite stamp may skip a re-run at the same clean HEAD; branch-only
164
+ // hairline failures may auto-file coverage-debt and PASS_WITH_DEBT without a
165
+ // second suite. CI never trusts the stamp.
110
166
  label = "Pre-flight CI (task ci:local | fallback task check)";
111
167
  if (config.skipCi) {
112
168
  if (config.allowSkipCiIssue !== null && config.allowSkipCiIssue > 0) {
@@ -121,19 +177,115 @@ export function runPipeline(config, seams = {}) {
121
177
  emit(5, label, `DRYRUN (would run task ci:local with task check fallback${debtNote}; hard timeout ${RELEASE_CHECK_TIMEOUT_MINUTES}m)`);
122
178
  }
123
179
  else {
124
- const [ok, reason] = runCiFn(projectRoot, config.allowCoverageDebtIssue);
125
- if (ok) {
126
- const debtNote = config.allowCoverageDebtIssue !== null
127
- ? ` (coverage-debt acknowledged #${config.allowCoverageDebtIssue})`
180
+ const isCi = seams.isCi?.() ?? Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
181
+ const [treeClean] = checkGitClean(projectRoot, seams);
182
+ const headSha = resolveHeadSha(projectRoot, seams);
183
+ const stampEval = evaluateSuiteStamp({
184
+ projectRoot,
185
+ headSha,
186
+ treeClean,
187
+ isCi,
188
+ io: {
189
+ readFile: seams.readFile,
190
+ writeFile: seams.writeFile,
191
+ fileExists: seams.fileExists,
192
+ },
193
+ });
194
+ if (stampEval.kind === "hit") {
195
+ const debtNote = stampEval.stamp.suite === "pass_with_debt" && stampEval.stamp.debtIssue != null
196
+ ? ` PASS_WITH_DEBT(#${stampEval.stamp.debtIssue})`
128
197
  : "";
129
- emit(5, label, `OK (${reason}${debtNote})`);
198
+ emit(5, label, `OK (suite stamp hit at ${stampEval.stamp.headSha.slice(0, 12)}; suite skipped${debtNote})`);
130
199
  }
131
200
  else {
132
- const debtHint = config.allowCoverageDebtIssue === null
133
- ? "; pass --allow-coverage-debt=#N only after operator review"
134
- : "";
135
- emit(5, label, `FAIL (${reason}${debtHint}; Step 5 hard timeout is ${RELEASE_CHECK_TIMEOUT_MINUTES}m — cancel hung vitest and see docs/RELEASING.md § Vitest coverage hang recovery)`);
136
- return EXIT_VIOLATION;
201
+ // Bind auto-hatch coverage-final trust to this suite invocation (#3187).
202
+ const suiteStartedAtMs = Date.now();
203
+ const [ok, reason] = runCiFn(projectRoot, config.allowCoverageDebtIssue);
204
+ if (ok) {
205
+ const debtIssue = config.allowCoverageDebtIssue;
206
+ const debtNote = debtIssue !== null ? ` (coverage-debt acknowledged #${debtIssue})` : "";
207
+ recordSuiteStamp(projectRoot, debtIssue !== null ? "pass_with_debt" : "pass", debtIssue, seams);
208
+ emit(5, label, `OK (${reason}${debtNote})`);
209
+ }
210
+ else {
211
+ // #3187 auto-hatch: one suite → classify → maybe file debt → continue.
212
+ const totals = resolveCoverageTotals(projectRoot, seams);
213
+ const coverageReportMtimeMs = resolveCoverageReportMtimeMs(projectRoot, seams);
214
+ // Prefer mtime bound to this suite start (strict: after suite start).
215
+ // No pre-start slack — a prior hairline report must not mask a later fail.
216
+ const suiteBoundMtime = coverageReportMtimeMs === undefined
217
+ ? undefined
218
+ : coverageReportMtimeMs != null && coverageReportMtimeMs > suiteStartedAtMs
219
+ ? coverageReportMtimeMs
220
+ : null;
221
+ const exitCode = parseExitCodeFromReason(reason);
222
+ const classification = classifyStep5FailureWithFreshness({
223
+ output: reason,
224
+ totals,
225
+ exitCode,
226
+ timedOut: reasonLooksLikeTimeout(reason) || exitCode === 124,
227
+ coverageReportMtimeMs: suiteBoundMtime,
228
+ nowMs: Date.now(),
229
+ });
230
+ let openDebt;
231
+ try {
232
+ openDebt =
233
+ seams.listOpenCoverageDebtIssues?.(config.repo, projectRoot) ??
234
+ probeOpenCoverageDebtLedger(config.repo, projectRoot, {
235
+ spawnText: seams.spawnText,
236
+ whichGh: seams.whichGh,
237
+ readFile: seams.readFile,
238
+ fileExists: seams.fileExists,
239
+ });
240
+ }
241
+ catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err);
243
+ emit(5, label, `FAIL (${reason}; auto-hatch ledger probe failed closed: ${msg}; Step 5 hard timeout is ${RELEASE_CHECK_TIMEOUT_MINUTES}m)`);
244
+ return EXIT_VIOLATION;
245
+ }
246
+ let decision;
247
+ try {
248
+ decision = evaluateAutoHatch({
249
+ classification,
250
+ totals,
251
+ openDebtIssues: openDebt,
252
+ existingDebtIssue: null,
253
+ createIssue: totals && classification === "BRANCH_HAIRLINE" && openDebt.length === 0
254
+ ? () => {
255
+ const draft = buildCoverageDebtIssueDraft({
256
+ version,
257
+ totals,
258
+ autoHatched: true,
259
+ });
260
+ if (seams.createCoverageDebtIssue) {
261
+ return seams.createCoverageDebtIssue(config.repo, projectRoot, draft.title, draft.body);
262
+ }
263
+ return createCoverageDebtIssue(config.repo, projectRoot, draft.title, draft.body, {
264
+ spawnText: seams.spawnText,
265
+ whichGh: seams.whichGh,
266
+ });
267
+ }
268
+ : undefined,
269
+ });
270
+ }
271
+ catch (err) {
272
+ const msg = err instanceof Error ? err.message : String(err);
273
+ emit(5, label, `FAIL (${reason}; auto-hatch issue create failed: ${msg}; Step 5 hard timeout is ${RELEASE_CHECK_TIMEOUT_MINUTES}m)`);
274
+ return EXIT_VIOLATION;
275
+ }
276
+ if (decision.kind === "pass_with_debt") {
277
+ process.stderr.write(formatAutoHatchBanner(decision.issue, decision.totals));
278
+ recordSuiteStamp(projectRoot, "pass_with_debt", decision.issue, seams);
279
+ emit(5, label, `OK (PASS_WITH_DEBT(#${decision.issue}); auto-hatch ${decision.created ? "filed" : "bound"}; suite not re-run)`);
280
+ }
281
+ else {
282
+ const debtHint = config.allowCoverageDebtIssue === null
283
+ ? "; pass --allow-coverage-debt=#N only after operator review (or auto-hatch on branch-only hairline with empty ledger, #3187)"
284
+ : "";
285
+ emit(5, label, `FAIL (${reason}; auto-hatch: ${decision.reason}${debtHint}; Step 5 hard timeout is ${RELEASE_CHECK_TIMEOUT_MINUTES}m — cancel hung vitest and see docs/RELEASING.md § Vitest coverage hang recovery)`);
286
+ return EXIT_VIOLATION;
287
+ }
288
+ }
137
289
  }
138
290
  }
139
291
  // Step 6: CHANGELOG promotion.
@@ -0,0 +1,44 @@
1
+ export declare const SUITE_STAMP_SCHEMA_VERSION: 1;
2
+ export declare const SUITE_STAMP_RELPATH: string;
3
+ export type SuiteStampStatus = "pass" | "pass_with_debt";
4
+ export interface SuiteStamp {
5
+ readonly schemaVersion: typeof SUITE_STAMP_SCHEMA_VERSION;
6
+ readonly headSha: string;
7
+ readonly suite: SuiteStampStatus;
8
+ readonly debtIssue: number | null;
9
+ readonly recordedAt: string;
10
+ }
11
+ export type SuiteStampValidity = {
12
+ readonly kind: "hit";
13
+ readonly stamp: SuiteStamp;
14
+ } | {
15
+ readonly kind: "miss";
16
+ readonly reason: string;
17
+ };
18
+ export interface SuiteStampIo {
19
+ readonly readFile?: (path: string) => string;
20
+ readonly writeFile?: (path: string, content: string) => void;
21
+ readonly fileExists?: (path: string) => boolean;
22
+ readonly mkdirp?: (dir: string) => void;
23
+ }
24
+ export declare function suiteStampPath(projectRoot: string): string;
25
+ export declare function isValidHeadSha(sha: string | null | undefined): sha is string;
26
+ /** Parse stamp JSON; returns null when missing/corrupt. */
27
+ export declare function parseSuiteStamp(raw: string): SuiteStamp | null;
28
+ export declare function readSuiteStamp(projectRoot: string, io?: SuiteStampIo): SuiteStamp | null;
29
+ export declare function writeSuiteStamp(projectRoot: string, stamp: Omit<SuiteStamp, "schemaVersion"> & {
30
+ schemaVersion?: number;
31
+ }, io?: SuiteStampIo): SuiteStamp;
32
+ /**
33
+ * Validate a stamp against current HEAD + tree cleanliness.
34
+ * CI callers MUST NOT use a hit to skip suite (document-only; stamp is local).
35
+ */
36
+ export declare function evaluateSuiteStamp(options: {
37
+ readonly projectRoot: string;
38
+ readonly headSha: string | null;
39
+ readonly treeClean: boolean;
40
+ /** When true (CI), always miss — never trust laptop stamps. */
41
+ readonly isCi?: boolean;
42
+ readonly io?: SuiteStampIo;
43
+ }): SuiteStampValidity;
44
+ //# sourceMappingURL=suite-stamp.d.ts.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * SHA-bound release Step 5 suite stamp (#3187 / #3188 coordination).
3
+ *
4
+ * Local-only artifact under `.deft/` (gitignored). After suite green or
5
+ * PASS_WITH_DEBT at HEAD S, re-entry at the same *clean* HEAD may skip the
6
+ * suite. Dirty tree, different HEAD, corrupt stamp → fail closed (run suite).
7
+ * CI never trusts this stamp (it is not committed and not read by GHA paths).
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { containedWrite } from "../fs/contained-write.js";
12
+ export const SUITE_STAMP_SCHEMA_VERSION = 1;
13
+ export const SUITE_STAMP_RELPATH = join(".deft", "release-suite-stamp.json");
14
+ function defaultRead(path) {
15
+ return readFileSync(path, "utf8");
16
+ }
17
+ function defaultExists(path) {
18
+ return existsSync(path);
19
+ }
20
+ function defaultMkdirp(dir) {
21
+ mkdirSync(dir, { recursive: true });
22
+ }
23
+ /** Contained write under projectRoot (no raw writeFileSync — #2951). */
24
+ function defaultWriteContained(projectRoot, path, content) {
25
+ containedWrite({
26
+ root: projectRoot,
27
+ target: path,
28
+ data: content,
29
+ mode: "replace",
30
+ });
31
+ }
32
+ export function suiteStampPath(projectRoot) {
33
+ return join(projectRoot, SUITE_STAMP_RELPATH);
34
+ }
35
+ export function isValidHeadSha(sha) {
36
+ return typeof sha === "string" && /^[0-9a-f]{7,64}$/i.test(sha.trim());
37
+ }
38
+ /** Parse stamp JSON; returns null when missing/corrupt. */
39
+ export function parseSuiteStamp(raw) {
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ if (parsed.schemaVersion !== SUITE_STAMP_SCHEMA_VERSION)
43
+ return null;
44
+ if (!isValidHeadSha(parsed.headSha))
45
+ return null;
46
+ if (parsed.suite !== "pass" && parsed.suite !== "pass_with_debt")
47
+ return null;
48
+ const debt = parsed.debtIssue === null || parsed.debtIssue === undefined ? null : Number(parsed.debtIssue);
49
+ if (debt !== null && (!Number.isFinite(debt) || debt <= 0))
50
+ return null;
51
+ if (typeof parsed.recordedAt !== "string" || !parsed.recordedAt)
52
+ return null;
53
+ return {
54
+ schemaVersion: SUITE_STAMP_SCHEMA_VERSION,
55
+ headSha: parsed.headSha.trim().toLowerCase(),
56
+ suite: parsed.suite,
57
+ debtIssue: debt,
58
+ recordedAt: parsed.recordedAt,
59
+ };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ export function readSuiteStamp(projectRoot, io = {}) {
66
+ const readFile = io.readFile ?? defaultRead;
67
+ const fileExists = io.fileExists ?? defaultExists;
68
+ const path = suiteStampPath(projectRoot);
69
+ if (!fileExists(path))
70
+ return null;
71
+ try {
72
+ return parseSuiteStamp(readFile(path));
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ export function writeSuiteStamp(projectRoot, stamp, io = {}) {
79
+ const full = {
80
+ schemaVersion: SUITE_STAMP_SCHEMA_VERSION,
81
+ headSha: stamp.headSha.trim().toLowerCase(),
82
+ suite: stamp.suite,
83
+ debtIssue: stamp.debtIssue,
84
+ recordedAt: stamp.recordedAt,
85
+ };
86
+ if (!isValidHeadSha(full.headSha)) {
87
+ throw new Error(`suite-stamp: invalid headSha ${JSON.stringify(stamp.headSha)}`);
88
+ }
89
+ if (full.suite === "pass_with_debt" && (full.debtIssue === null || full.debtIssue <= 0)) {
90
+ throw new Error("suite-stamp: pass_with_debt requires debtIssue");
91
+ }
92
+ const path = suiteStampPath(projectRoot);
93
+ const mkdirp = io.mkdirp ?? defaultMkdirp;
94
+ mkdirp(dirname(path));
95
+ const payload = `${JSON.stringify(full, null, 2)}\n`;
96
+ // Prefer seamed writer when tests inject one; call via local binding so the
97
+ // contained-writes inventory does not treat the optional seam as a raw sink.
98
+ const seamedWriter = io.writeFile;
99
+ if (seamedWriter) {
100
+ seamedWriter(path, payload);
101
+ }
102
+ else {
103
+ defaultWriteContained(projectRoot, path, payload);
104
+ }
105
+ return full;
106
+ }
107
+ /**
108
+ * Validate a stamp against current HEAD + tree cleanliness.
109
+ * CI callers MUST NOT use a hit to skip suite (document-only; stamp is local).
110
+ */
111
+ export function evaluateSuiteStamp(options) {
112
+ if (options.isCi === true) {
113
+ return { kind: "miss", reason: "CI never trusts release suite stamp (#3187)" };
114
+ }
115
+ if (!options.treeClean) {
116
+ return { kind: "miss", reason: "working tree dirty — suite stamp invalidated" };
117
+ }
118
+ if (!isValidHeadSha(options.headSha)) {
119
+ return { kind: "miss", reason: "HEAD sha unavailable" };
120
+ }
121
+ const stamp = readSuiteStamp(options.projectRoot, options.io);
122
+ if (!stamp) {
123
+ return { kind: "miss", reason: "suite stamp missing or corrupt" };
124
+ }
125
+ if (stamp.headSha.toLowerCase() !== options.headSha.trim().toLowerCase()) {
126
+ return {
127
+ kind: "miss",
128
+ reason: `suite stamp HEAD ${stamp.headSha.slice(0, 12)} ≠ current ${options.headSha.trim().slice(0, 12)}`,
129
+ };
130
+ }
131
+ return { kind: "hit", stamp };
132
+ }
133
+ //# sourceMappingURL=suite-stamp.js.map
@@ -58,5 +58,24 @@ export interface ReleaseSeams {
58
58
  readonly runBuild?: (projectRoot: string, version: string | null) => [boolean, string];
59
59
  readonly runUvLock?: (projectRoot: string) => [boolean, string];
60
60
  readonly checkTagAvailable?: (version: string, repo: string, projectRoot: string) => [boolean, string];
61
+ /**
62
+ * #3187 — open coverage-debt issue numbers (marker + CHANGELOG ledger).
63
+ * When omitted, production probes via gh + CHANGELOG.
64
+ */
65
+ readonly listOpenCoverageDebtIssues?: (repo: string, projectRoot: string) => number[];
66
+ /**
67
+ * #3187 — create coverage-debt tracking issue; return issue number.
68
+ * When omitted, production uses `gh issue create`.
69
+ */
70
+ readonly createCoverageDebtIssue?: (repo: string, projectRoot: string, title: string, body: string) => number;
71
+ /**
72
+ * #3187 — read coverage totals after a failed Step 5 suite (coverage-final.json).
73
+ * When omitted, reads `coverage/coverage-final.json` under projectRoot.
74
+ */
75
+ readonly readCoverageTotals?: (projectRoot: string) => import("../vitest-runner/coverage-debt.js").CoverageTotals | null;
76
+ /** #3187 — current HEAD sha for suite stamp binding. */
77
+ readonly headSha?: (projectRoot: string) => string | null;
78
+ /** #3187 — CI detector; when true suite stamp is never trusted. */
79
+ readonly isCi?: () => boolean;
61
80
  }
62
81
  //# sourceMappingURL=types.d.ts.map
@@ -61,6 +61,27 @@ export declare function normalizeRepoRelPath(p: string): string;
61
61
  * Git C-quoting / slash folding may produce (Greptile conf=4 residual).
62
62
  */
63
63
  export declare function changedSetHasPath(changedSet: ReadonlySet<string>, rel: string): boolean;
64
+ /**
65
+ * Parse + lightly validate an approved-scope JSON blob (base-ref `git show` or disk).
66
+ * Returns null when schema fields required for authorization are missing/malformed.
67
+ */
68
+ export declare function parseApprovedScopeRecordRaw(raw: string): ApprovedScopeRecord | null;
69
+ /**
70
+ * True when the merge-base approved-scope record authorizes the current scope and
71
+ * the current disk record is semantically unchanged from that base authority (#3205).
72
+ *
73
+ * Authority comes from the approval record on the base, not from whether the active
74
+ * xBRIEF path existed on the base (pending→active is the normal first activation).
75
+ */
76
+ export declare function baseApprovalAuthorizesCurrent(input: {
77
+ readonly projectRoot: string;
78
+ readonly baseRef: string | null;
79
+ readonly approvalRecordRel: string;
80
+ readonly planId: string;
81
+ readonly xbriefRelPath: string;
82
+ readonly currentDigest: string;
83
+ readonly currentApproved: ApprovedScopeRecord;
84
+ }): boolean;
64
85
  /**
65
86
  * Pure evaluation of one active xBRIEF against its approved baseline.
66
87
  * Exported for unit tests without git.