@deftai/directive-core 0.66.2 → 0.68.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.
Files changed (147) hide show
  1. package/dist/agents-md-advisory/evaluate.d.ts +44 -0
  2. package/dist/agents-md-advisory/evaluate.js +115 -0
  3. package/dist/agents-md-advisory/index.d.ts +2 -0
  4. package/dist/agents-md-advisory/index.js +2 -0
  5. package/dist/agents-md-budget/evaluate.d.ts +38 -0
  6. package/dist/agents-md-budget/evaluate.js +152 -0
  7. package/dist/agents-md-budget/index.d.ts +2 -0
  8. package/dist/agents-md-budget/index.js +2 -0
  9. package/dist/check/index.d.ts +1 -1
  10. package/dist/check/index.js +1 -1
  11. package/dist/check/orchestrator.d.ts +14 -4
  12. package/dist/check/orchestrator.js +30 -6
  13. package/dist/codebase/map.d.ts +1 -0
  14. package/dist/codebase/map.js +5 -2
  15. package/dist/doctor/checks.js +11 -11
  16. package/dist/doctor/constants.d.ts +7 -2
  17. package/dist/doctor/constants.js +17 -1
  18. package/dist/doctor/flags.js +5 -1
  19. package/dist/doctor/main.js +99 -8
  20. package/dist/doctor/payload-staleness.js +15 -6
  21. package/dist/doctor/types.d.ts +7 -0
  22. package/dist/forward-coverage/evaluate.d.ts +55 -0
  23. package/dist/forward-coverage/evaluate.js +254 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/init-deposit/constants.d.ts +14 -0
  27. package/dist/init-deposit/constants.js +10 -0
  28. package/dist/init-deposit/hygiene.d.ts +50 -0
  29. package/dist/init-deposit/hygiene.js +176 -0
  30. package/dist/init-deposit/init-deposit.d.ts +1 -0
  31. package/dist/init-deposit/init-deposit.js +7 -1
  32. package/dist/init-deposit/refresh.d.ts +2 -0
  33. package/dist/init-deposit/refresh.js +48 -30
  34. package/dist/init-deposit/scaffold.d.ts +3 -4
  35. package/dist/init-deposit/scaffold.js +3 -24
  36. package/dist/intake/github-auth-modes.d.ts +1 -0
  37. package/dist/intake/github-auth-modes.js +1 -0
  38. package/dist/intake/issue-ingest.d.ts +6 -2
  39. package/dist/intake/issue-ingest.js +65 -9
  40. package/dist/integration-e2e/helpers.d.ts +0 -1
  41. package/dist/integration-e2e/helpers.js +3 -7
  42. package/dist/layout/lifecycle-ref.d.ts +15 -0
  43. package/dist/layout/lifecycle-ref.js +55 -0
  44. package/dist/platform/agents-consumer-header.d.ts +13 -0
  45. package/dist/platform/agents-consumer-header.js +57 -0
  46. package/dist/platform/agents-md.d.ts +28 -0
  47. package/dist/platform/agents-md.js +38 -1
  48. package/dist/platform/index.d.ts +1 -0
  49. package/dist/platform/index.js +1 -0
  50. package/dist/policy/agents-md-advisory.d.ts +52 -0
  51. package/dist/policy/agents-md-advisory.js +63 -0
  52. package/dist/policy/agents-md-budget.d.ts +24 -0
  53. package/dist/policy/agents-md-budget.js +89 -0
  54. package/dist/policy/index.d.ts +1 -0
  55. package/dist/policy/index.js +1 -0
  56. package/dist/policy/resolve.js +56 -46
  57. package/dist/pr-merge-readiness/ci-gate.d.ts +28 -0
  58. package/dist/pr-merge-readiness/ci-gate.js +79 -0
  59. package/dist/pr-merge-readiness/compute.d.ts +15 -1
  60. package/dist/pr-merge-readiness/compute.js +129 -9
  61. package/dist/pr-merge-readiness/gh.d.ts +8 -0
  62. package/dist/pr-merge-readiness/gh.js +30 -5
  63. package/dist/pr-merge-readiness/index.d.ts +5 -1
  64. package/dist/pr-merge-readiness/index.js +3 -0
  65. package/dist/pr-merge-readiness/main.d.ts +3 -0
  66. package/dist/pr-merge-readiness/main.js +73 -6
  67. package/dist/pr-merge-readiness/mergeability.d.ts +43 -0
  68. package/dist/pr-merge-readiness/mergeability.js +105 -0
  69. package/dist/pr-merge-readiness/output.js +20 -0
  70. package/dist/pr-merge-readiness/slizard-gate.d.ts +47 -0
  71. package/dist/pr-merge-readiness/slizard-gate.js +119 -0
  72. package/dist/pr-monitor/index.d.ts +1 -1
  73. package/dist/pr-monitor/index.js +1 -1
  74. package/dist/pr-monitor/monitor.d.ts +8 -2
  75. package/dist/pr-monitor/monitor.js +35 -6
  76. package/dist/pr-wait-mergeable/wrappers.d.ts +12 -1
  77. package/dist/pr-wait-mergeable/wrappers.js +13 -5
  78. package/dist/pr-watch/constants.d.ts +33 -0
  79. package/dist/pr-watch/constants.js +33 -0
  80. package/dist/pr-watch/index.d.ts +6 -0
  81. package/dist/pr-watch/index.js +6 -0
  82. package/dist/pr-watch/main.d.ts +21 -0
  83. package/dist/pr-watch/main.js +206 -0
  84. package/dist/pr-watch/probe.d.ts +13 -0
  85. package/dist/pr-watch/probe.js +100 -0
  86. package/dist/pr-watch/types.d.ts +47 -0
  87. package/dist/pr-watch/types.js +2 -0
  88. package/dist/pr-watch/watch.d.ts +12 -0
  89. package/dist/pr-watch/watch.js +122 -0
  90. package/dist/preflight-cache/evaluate.d.ts +1 -0
  91. package/dist/preflight-cache/evaluate.js +17 -10
  92. package/dist/render/project-render.d.ts +16 -2
  93. package/dist/render/project-render.js +155 -97
  94. package/dist/scope/capacity-stamp.d.ts +24 -2
  95. package/dist/scope/capacity-stamp.js +80 -28
  96. package/dist/scope/decomposed-refs.js +2 -2
  97. package/dist/scope/project-definition-sync.js +3 -3
  98. package/dist/scope/vbrief-ref.d.ts +2 -1
  99. package/dist/scope/vbrief-ref.js +3 -2
  100. package/dist/spec-authority/constants.d.ts +6 -0
  101. package/dist/spec-authority/constants.js +11 -0
  102. package/dist/spec-authority/resolver.js +3 -3
  103. package/dist/swarm/finalize-cohort-cli.d.ts +5 -0
  104. package/dist/swarm/finalize-cohort-cli.js +100 -0
  105. package/dist/swarm/finalize-cohort.d.ts +39 -0
  106. package/dist/swarm/finalize-cohort.js +589 -0
  107. package/dist/swarm/index.d.ts +2 -0
  108. package/dist/swarm/index.js +2 -0
  109. package/dist/swarm/launch.d.ts +13 -0
  110. package/dist/swarm/launch.js +19 -7
  111. package/dist/swarm/routing-verify.d.ts +1 -1
  112. package/dist/swarm/routing-verify.js +11 -10
  113. package/dist/swarm/routing.d.ts +9 -0
  114. package/dist/swarm/routing.js +46 -0
  115. package/dist/swarm/verify-review-clean-cli.js +25 -1
  116. package/dist/swarm/verify-review-clean.d.ts +9 -1
  117. package/dist/swarm/verify-review-clean.js +76 -3
  118. package/dist/triage/queue/index.d.ts +1 -0
  119. package/dist/triage/queue/index.js +1 -0
  120. package/dist/triage/queue/project.d.ts +6 -1
  121. package/dist/triage/queue/project.js +9 -4
  122. package/dist/triage/queue/reconcile-live-state.d.ts +42 -0
  123. package/dist/triage/queue/reconcile-live-state.js +54 -0
  124. package/dist/triage/scope/mutations-core.js +91 -81
  125. package/dist/triage/scope/resolve.js +4 -3
  126. package/dist/triage/scope-drift/add-ignore.js +33 -32
  127. package/dist/triage/scope-drift/scope-rules.js +3 -3
  128. package/dist/triage/subscribe/index.js +51 -45
  129. package/dist/triage/welcome/writers.js +79 -64
  130. package/dist/vbrief-build/project-definition-io.js +8 -3
  131. package/dist/vbrief-validate/paths.js +8 -4
  132. package/dist/vbrief-validate/precutover.d.ts +1 -1
  133. package/dist/vbrief-validate/precutover.js +12 -10
  134. package/dist/verify-source/biome-config.d.ts +41 -0
  135. package/dist/verify-source/biome-config.js +97 -0
  136. package/dist/verify-source/index.d.ts +1 -0
  137. package/dist/verify-source/index.js +1 -0
  138. package/dist/xbrief-migrate/agents-header.d.ts +69 -0
  139. package/dist/xbrief-migrate/agents-header.js +179 -0
  140. package/dist/xbrief-migrate/fs-helpers.d.ts +3 -0
  141. package/dist/xbrief-migrate/fs-helpers.js +11 -0
  142. package/dist/xbrief-migrate/index.d.ts +1 -0
  143. package/dist/xbrief-migrate/index.js +1 -0
  144. package/dist/xbrief-migrate/migrate-project.js +29 -10
  145. package/package.json +15 -7
  146. package/dist/install-upgrade/index.d.ts +0 -13
  147. package/dist/install-upgrade/index.js +0 -251
@@ -5,6 +5,7 @@ export function parseDoctorFlags(args) {
5
5
  let json = false;
6
6
  let quiet = false;
7
7
  let full = false;
8
+ let network = false;
8
9
  let help = false;
9
10
  let projectRoot = null;
10
11
  const unknown = [];
@@ -26,6 +27,9 @@ export function parseDoctorFlags(args) {
26
27
  else if (token === "--full") {
27
28
  full = true;
28
29
  }
30
+ else if (token === "--network") {
31
+ network = true;
32
+ }
29
33
  else if (token === "-h" || token === "--help") {
30
34
  help = true;
31
35
  }
@@ -52,7 +56,7 @@ export function parseDoctorFlags(args) {
52
56
  }
53
57
  i += 1;
54
58
  }
55
- return { session, fix, json, quiet, full, help, projectRoot, unknown };
59
+ return { session, fix, json, quiet, full, network, help, projectRoot, unknown };
56
60
  }
57
61
  export function formatUnknownFlagsError(unknown) {
58
62
  return `Unknown flag(s): ${unknown.join(", ")}`;
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
3
4
  import { contentRoot } from "../content-root.js";
4
5
  import { agentsRefreshPlan, hasV3ManagedMarker } from "./agents-md.js";
5
6
  import { runChecks } from "./checks.js";
6
- import { CONSUMER_FRAMEWORK_DIRS, EXPECTED_CONTENT_DIRS, EXPECTED_FRAMEWORK_DIRS, TASKFILE_INCLUDE_SNIPPET, UV_INSTALL_URL, } from "./constants.js";
7
+ import { CONSUMER_FRAMEWORK_DIRS, EXPECTED_CONTENT_DIRS, EXPECTED_FRAMEWORK_DIRS, NETWORK_DISCLOSURE_LINE, PAYLOAD_STALENESS_OFFLINE_SKIP_MESSAGE, TASKFILE_INCLUDE_SNIPPET, UV_INSTALL_URL, } from "./constants.js";
7
8
  import { decideThrottle, formatIsoZ, readState, renderDoctorStatusLine, writeState, } from "./doctor-state.js";
8
9
  import { formatAllowedFlagsHint, formatUnknownFlagsError, parseDoctorFlags } from "./flags.js";
9
10
  import { pythonJsonDump } from "./json.js";
@@ -130,13 +131,38 @@ export function cmdDoctor(args, seams = {}) {
130
131
  if (!jsonMode) {
131
132
  sink.blank();
132
133
  }
133
- sink.info("Checking payload staleness from install manifest...");
134
- runPayloadStalenessCheck(projectRoot, sink, addFinding, {
135
- frameworkRoot,
136
- readText: seams.readText,
137
- isFile: seams.isFile,
138
- runGitLsRemote: seams.runGitLsRemote,
139
- });
134
+ sink.info("Checking AGENTS.md legibility (advisory)...");
135
+ runAgentsMdAdvisoryCheck(projectRoot, sink, addFinding, seams);
136
+ if (!jsonMode) {
137
+ sink.blank();
138
+ }
139
+ // #2182: payload-staleness is the only doctor check that can reach a
140
+ // registry (git remote, then npm as a fallback). It stays in the OFFLINE
141
+ // tier (skipped) unless the operator explicitly opts into the NETWORK tier
142
+ // via `--network`, and the tool + registry class is disclosed BEFORE the
143
+ // check runs -- never silently, never as a side effect of a read-only
144
+ // `deft doctor` invocation (e.g. from the gated session-ritual step).
145
+ if (flags.network) {
146
+ sink.info(NETWORK_DISCLOSURE_LINE);
147
+ sink.info("Checking payload staleness from install manifest...");
148
+ runPayloadStalenessCheck(projectRoot, sink, addFinding, {
149
+ frameworkRoot,
150
+ readText: seams.readText,
151
+ isFile: seams.isFile,
152
+ runGitLsRemote: seams.runGitLsRemote,
153
+ runNpmViewVersion: seams.runNpmViewVersion,
154
+ });
155
+ }
156
+ else {
157
+ sink.info(`payload-staleness: ${PAYLOAD_STALENESS_OFFLINE_SKIP_MESSAGE}`);
158
+ addFinding({
159
+ severity: "skip",
160
+ message: PAYLOAD_STALENESS_OFFLINE_SKIP_MESSAGE,
161
+ check: "payload-staleness",
162
+ status: "skip",
163
+ reason: "offline-tier",
164
+ });
165
+ }
140
166
  if (!jsonMode) {
141
167
  sink.blank();
142
168
  }
@@ -346,6 +372,71 @@ function runAgentsMdFreshnessCheck(projectRoot, sink, addFinding, seams) {
346
372
  addFinding({ severity: "warning", message, check: checkName });
347
373
  }
348
374
  }
375
+ /**
376
+ * Advisory (never-failing) consumer AGENTS.md legibility signal (#2155).
377
+ *
378
+ * Reports the unmanaged (project-authored) region size against the soft budget
379
+ * (`plan.policy.agentsMdAdvisory.unmanagedSoftMaxLines`, generous by default).
380
+ * The managed section is EXCLUDED. This is the consumer companion to the
381
+ * maintainer-only #645 ratchet and follows the advise -> observe -> enforce
382
+ * posture (#1419): it emits at most a `warning` finding, NEVER an `error`, so
383
+ * `deft doctor` (and the `check:consumer` aggregate that depends on it) can
384
+ * never fail-close on a judgment call about the consumer's own file.
385
+ */
386
+ function runAgentsMdAdvisoryCheck(projectRoot, sink, addFinding, seams) {
387
+ const checkName = "agents-md-advisory";
388
+ // Only meaningful for consumer installs (managed markers present). In the
389
+ // maintainer repo the #645 ratchet owns this file, so skip -- mirrors the
390
+ // freshness check's guard above.
391
+ if (runningInsideDeftRepo(projectRoot, seams) ||
392
+ !hasV3ManagedMarker(projectRoot, seams.readText)) {
393
+ const skipReason = "no managed-section markers (likely maintainer repo)";
394
+ sink.info(`${checkName}: skip -- ${skipReason}`);
395
+ addFinding({ severity: "skip", message: skipReason, check: checkName, status: "skip" });
396
+ return;
397
+ }
398
+ try {
399
+ const evalFn = seams.agentsMdAdvisoryEvaluate ?? ((root) => evaluateAgentsMdAdvisory(root));
400
+ const result = evalFn(projectRoot);
401
+ if (result.over && result.counts !== null) {
402
+ const message = `AGENTS.md unmanaged (project-authored) region is ${result.counts.unmanaged} lines, ` +
403
+ `over the soft budget of ${result.softMaxLines} (advisory only -- your build is NOT ` +
404
+ "affected). AGENTS.md is a map, not a manual (#1882): push detail into a reference doc " +
405
+ "and leave a pointer (see content/docs/good-agents-md.md). Raise " +
406
+ "plan.policy.agentsMdAdvisory.unmanagedSoftMaxLines to accept the growth and silence this.";
407
+ sink.warn(message);
408
+ addFinding({
409
+ severity: "warning",
410
+ message,
411
+ check: checkName,
412
+ status: "over-soft-budget",
413
+ suggestion: "content/docs/good-agents-md.md",
414
+ });
415
+ return;
416
+ }
417
+ if (result.counts !== null) {
418
+ sink.success(`${checkName}: unmanaged region ${result.counts.unmanaged}/${result.softMaxLines} lines (within soft budget)`);
419
+ return;
420
+ }
421
+ // No counts (missing / unreadable / malformed AGENTS.md): advisory stays
422
+ // silent-but-informational; never a doctor error.
423
+ sink.info(`${checkName}: skip -- AGENTS.md not measurable`);
424
+ addFinding({
425
+ severity: "skip",
426
+ message: "AGENTS.md not measurable",
427
+ check: checkName,
428
+ status: "skip",
429
+ });
430
+ }
431
+ catch (exc) {
432
+ // Sanitize newlines so an error string can't break out of the markdown
433
+ // bullet when findings are rendered (CWE-116).
434
+ const detail = `${exc instanceof Error ? exc.name : "Error"}: ${exc}`.replace(/\r?\n/g, " ");
435
+ const message = `${checkName}: probe failed -- ${detail}`;
436
+ sink.warn(message);
437
+ addFinding({ severity: "warning", message, check: checkName });
438
+ }
439
+ }
349
440
  function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams) {
350
441
  if (runningInsideDeftRepo(projectRoot, seams)) {
351
442
  sink.info("Skipping Taskfile include check -- running inside the deft framework repo (the repo's own Taskfile.yml is the surface).");
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { dirname, join } from "node:path";
3
- import { CANONICAL_UPGRADE_COMMAND, NPM_PACKAGE_NAME } from "./constants.js";
3
+ import { detectCanonicalVendoredManifest, isNpmManaged } from "../init-deposit/migrate.js";
4
+ import { CANONICAL_UPGRADE_COMMAND, NPM_PACKAGE_NAME, VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND, } from "./constants.js";
4
5
  import { locateManifest, parseInstallManifest } from "./manifest.js";
5
6
  import { readTextSafe, resolveDefaultFrameworkRoot } from "./paths.js";
6
7
  function isDeftFrameworkRepo(projectRoot, readText = readTextSafe) {
@@ -91,9 +92,16 @@ function emitUnverified(checkName, reason, sink, addFinding) {
91
92
  status: "unverified",
92
93
  });
93
94
  }
94
- function emitStale(checkName, installedLabel, remoteLabel, ref, sink, addFinding, extras = {}, behindWord = "remote") {
95
+ function resolveUpgradeCommand(projectRoot, manifest, isFile) {
96
+ const vendoredManifest = detectCanonicalVendoredManifest(projectRoot, isFile);
97
+ if (vendoredManifest !== null && isNpmManaged(manifest)) {
98
+ return VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND;
99
+ }
100
+ return CANONICAL_UPGRADE_COMMAND;
101
+ }
102
+ function emitStale(checkName, installedLabel, remoteLabel, ref, upgradeCommand, sink, addFinding, extras = {}, behindWord = "remote") {
95
103
  const msg = `Framework payload is stale (installed ${installedLabel} behind ${behindWord} ${remoteLabel} for ref '${ref}'). ` +
96
- `Recommendation: run \`${CANONICAL_UPGRADE_COMMAND}\` from any shell with Node ≥ 20.`;
104
+ `Recommendation: run \`${upgradeCommand}\` from any shell with Node ≥ 20.`;
97
105
  sink.warn(msg);
98
106
  addFinding({
99
107
  severity: "warning",
@@ -101,7 +109,7 @@ function emitStale(checkName, installedLabel, remoteLabel, ref, sink, addFinding
101
109
  check: checkName,
102
110
  status: "stale",
103
111
  ref,
104
- suggestion: CANONICAL_UPGRADE_COMMAND,
112
+ suggestion: upgradeCommand,
105
113
  ...extras,
106
114
  });
107
115
  }
@@ -155,6 +163,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
155
163
  return;
156
164
  }
157
165
  const manifest = parseInstallManifest(text);
166
+ const upgradeCommand = resolveUpgradeCommand(projectRoot, manifest, isFile);
158
167
  const installedSha = (manifest.sha ?? "").trim();
159
168
  const ref = (manifest.ref ?? manifest.tag ?? "").trim();
160
169
  const tag = (manifest.tag ?? "").trim();
@@ -204,7 +213,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
204
213
  sink.info(`${checkName}: current (sha matches remote)`);
205
214
  return;
206
215
  }
207
- emitStale(checkName, `sha ${installedSha.slice(0, 8)}...`, `sha ${remoteSha.slice(0, 8)}...`, ref, sink, addFinding, { installed_sha: installedSha, remote_sha: remoteSha, resolver: "git-ls-remote" });
216
+ emitStale(checkName, `sha ${installedSha.slice(0, 8)}...`, `sha ${remoteSha.slice(0, 8)}...`, ref, upgradeCommand, sink, addFinding, { installed_sha: installedSha, remote_sha: remoteSha, resolver: "git-ls-remote" });
208
217
  return;
209
218
  }
210
219
  }
@@ -212,7 +221,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
212
221
  const installedVersion = manifestVersion(ref, tag);
213
222
  if (npmResult.ok && installedVersion) {
214
223
  if (semverLessThan(installedVersion, npmResult.version)) {
215
- emitStale(checkName, `v${installedVersion}`, `v${npmResult.version}`, ref, sink, addFinding, {
224
+ emitStale(checkName, `v${installedVersion}`, `v${npmResult.version}`, ref, upgradeCommand, sink, addFinding, {
216
225
  installed_version: installedVersion,
217
226
  remote_version: npmResult.version,
218
227
  resolver: "npm-view",
@@ -1,3 +1,4 @@
1
+ import type { AdvisoryEvaluateResult } from "../agents-md-advisory/evaluate.js";
1
2
  export declare const EXIT_CLEAN = 0;
2
3
  export declare const EXIT_DRIFT = 1;
3
4
  export declare const EXIT_CONFIG_ERROR = 2;
@@ -30,6 +31,7 @@ export interface DoctorFlags {
30
31
  readonly json: boolean;
31
32
  readonly quiet: boolean;
32
33
  readonly full: boolean;
34
+ readonly network: boolean;
33
35
  readonly help: boolean;
34
36
  readonly projectRoot: string | null;
35
37
  readonly unknown: readonly string[];
@@ -60,7 +62,12 @@ export interface DoctorSeams {
60
62
  ok: boolean;
61
63
  stdout: string;
62
64
  };
65
+ readonly runNpmViewVersion?: () => {
66
+ ok: boolean;
67
+ version: string;
68
+ };
63
69
  readonly agentsRefreshPlan?: (projectRoot: string) => Record<string, unknown>;
70
+ readonly agentsMdAdvisoryEvaluate?: (projectRoot: string) => AdvisoryEvaluateResult;
64
71
  readonly readState?: (projectRoot: string) => DoctorState | null;
65
72
  readonly writeState?: (projectRoot: string, payload: {
66
73
  exitCode: number;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * forward-coverage/evaluate.ts -- deterministic forward-coverage gate (#1310).
3
+ *
4
+ * Migrates the prose-tier "new source files MUST include corresponding test
5
+ * files in the same PR" rule (AGENTS.md) to deterministic enforcement,
6
+ * mirroring `verify:encoding` (#798) and `verify:branch` (#747).
7
+ *
8
+ * Behaviour (v1 = NEW-file granularity only, to keep false positives near
9
+ * zero): read the staged (`--staged`) or HEAD-relative diff; for each NEW
10
+ * source file (`*.py` / `*.go` / `*.ts` / `*.tsx`, excluding test files and
11
+ * `*.d.ts`), assert a corresponding test file was added in the SAME diff.
12
+ * Three-state exit: 0 clean / 1 missing forward coverage / 2 config error.
13
+ */
14
+ /** Diff scope: `staged` = index vs HEAD; `head` = working tree + index vs HEAD. */
15
+ export type ForwardCoverageMode = "head" | "staged";
16
+ /** A new source file that has no corresponding test file in the same diff. */
17
+ export interface MissingCoverage {
18
+ /** POSIX-form repo-relative path of the uncovered new source file. */
19
+ readonly path: string;
20
+ /** Candidate test-file basenames the gate searched the diff for. */
21
+ readonly expectedTests: string[];
22
+ }
23
+ /** Result of a forward-coverage evaluation; mirrors the encoding-gate tuple. */
24
+ export interface ForwardCoverageResult {
25
+ readonly exitCode: 0 | 1 | 2;
26
+ readonly missing: MissingCoverage[];
27
+ readonly message: string;
28
+ }
29
+ export interface ForwardCoverageOptions {
30
+ readonly mode?: ForwardCoverageMode;
31
+ readonly allowListPath?: string | null;
32
+ }
33
+ /**
34
+ * True when `relPath` is a test file (excluded from the "needs coverage" set
35
+ * AND counted as available coverage). Covers the co-located TS/TSX
36
+ * `.test` / `.spec` convention, Go `*_test.go`, and Python `test_*.py` /
37
+ * `*_test.py`.
38
+ */
39
+ export declare function isTestFile(relPath: string): boolean;
40
+ /** True when `relPath` is an in-scope, non-test, non-`.d.ts` source file. */
41
+ export declare function isSourceFile(relPath: string): boolean;
42
+ /**
43
+ * Candidate test-file basenames that would satisfy forward coverage for a
44
+ * given source file, keyed on its extension + stem. Matched by basename
45
+ * (directory-agnostic) so co-located, `tests/`, and `__tests__/` layouts all
46
+ * count -- keeping the false-positive rate near zero for v1.
47
+ */
48
+ export declare function expectedTestBasenames(sourcePath: string): string[];
49
+ /**
50
+ * Pure evaluation returning `{ exitCode, missing, message }`. Three-state exit
51
+ * (0 clean / 1 missing forward coverage / 2 config error). Mirrors the shape of
52
+ * `encoding.evaluate` (#798) so both gates read the same way.
53
+ */
54
+ export declare function evaluateForwardCoverage(projectRoot: string, options?: ForwardCoverageOptions): ForwardCoverageResult;
55
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1,254 @@
1
+ /**
2
+ * forward-coverage/evaluate.ts -- deterministic forward-coverage gate (#1310).
3
+ *
4
+ * Migrates the prose-tier "new source files MUST include corresponding test
5
+ * files in the same PR" rule (AGENTS.md) to deterministic enforcement,
6
+ * mirroring `verify:encoding` (#798) and `verify:branch` (#747).
7
+ *
8
+ * Behaviour (v1 = NEW-file granularity only, to keep false positives near
9
+ * zero): read the staged (`--staged`) or HEAD-relative diff; for each NEW
10
+ * source file (`*.py` / `*.go` / `*.ts` / `*.tsx`, excluding test files and
11
+ * `*.d.ts`), assert a corresponding test file was added in the SAME diff.
12
+ * Three-state exit: 0 clean / 1 missing forward coverage / 2 config error.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
16
+ import { basename } from "node:path";
17
+ import { GitCommandError, GitNotFoundError } from "../encoding/git.js";
18
+ import { fnmatchCase } from "../encoding/text.js";
19
+ /** Source-file extensions in scope for v1. */
20
+ const SOURCE_EXTENSIONS = new Set([".py", ".go", ".ts", ".tsx"]);
21
+ /** Raised by `loadAllowList` when the path does not exist. */
22
+ class AllowListNotFoundError extends Error {
23
+ }
24
+ /** Return the final `.ext` (lowercased) of a path, or "" when none. */
25
+ function extOf(pathStr) {
26
+ const b = basename(pathStr);
27
+ const dot = b.lastIndexOf(".");
28
+ return dot > 0 ? b.slice(dot).toLowerCase() : "";
29
+ }
30
+ /**
31
+ * True when `relPath` is a test file (excluded from the "needs coverage" set
32
+ * AND counted as available coverage). Covers the co-located TS/TSX
33
+ * `.test` / `.spec` convention, Go `*_test.go`, and Python `test_*.py` /
34
+ * `*_test.py`.
35
+ */
36
+ export function isTestFile(relPath) {
37
+ const b = basename(relPath).toLowerCase();
38
+ if (b.endsWith(".test.ts") ||
39
+ b.endsWith(".test.tsx") ||
40
+ b.endsWith(".spec.ts") ||
41
+ b.endsWith(".spec.tsx")) {
42
+ return true;
43
+ }
44
+ if (b.endsWith("_test.go")) {
45
+ return true;
46
+ }
47
+ if (b.endsWith(".py") && (b.startsWith("test_") || b.endsWith("_test.py"))) {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ /** True when `relPath` is an in-scope, non-test, non-`.d.ts` source file. */
53
+ export function isSourceFile(relPath) {
54
+ const b = basename(relPath).toLowerCase();
55
+ if (b.endsWith(".d.ts")) {
56
+ return false;
57
+ }
58
+ if (!SOURCE_EXTENSIONS.has(extOf(b))) {
59
+ return false;
60
+ }
61
+ return !isTestFile(relPath);
62
+ }
63
+ /**
64
+ * Candidate test-file basenames that would satisfy forward coverage for a
65
+ * given source file, keyed on its extension + stem. Matched by basename
66
+ * (directory-agnostic) so co-located, `tests/`, and `__tests__/` layouts all
67
+ * count -- keeping the false-positive rate near zero for v1.
68
+ */
69
+ export function expectedTestBasenames(sourcePath) {
70
+ const b = basename(sourcePath);
71
+ const ext = extOf(b);
72
+ const stem = b.slice(0, b.length - ext.length);
73
+ switch (ext) {
74
+ case ".ts":
75
+ return [`${stem}.test.ts`, `${stem}.spec.ts`];
76
+ case ".tsx":
77
+ return [`${stem}.test.tsx`, `${stem}.spec.tsx`, `${stem}.test.ts`, `${stem}.spec.ts`];
78
+ case ".py":
79
+ return [`test_${stem}.py`, `${stem}_test.py`];
80
+ case ".go":
81
+ return [`${stem}_test.go`];
82
+ default:
83
+ return [];
84
+ }
85
+ }
86
+ function loadAllowList(path) {
87
+ if (path === null || path === undefined) {
88
+ return [];
89
+ }
90
+ let raw;
91
+ try {
92
+ raw = readFileSync(path, "utf8");
93
+ }
94
+ catch (err) {
95
+ const e = err;
96
+ if (e.code === "ENOENT") {
97
+ throw new AllowListNotFoundError(path);
98
+ }
99
+ throw err;
100
+ }
101
+ const out = [];
102
+ for (const line of raw.split(/\r\n|[\n\r]/)) {
103
+ const stripped = line.trim();
104
+ if (stripped.length === 0 || stripped.startsWith("#")) {
105
+ continue;
106
+ }
107
+ out.push(stripped);
108
+ }
109
+ return out;
110
+ }
111
+ function isAllowListed(relPath, patterns) {
112
+ return patterns.some((pat) => fnmatchCase(relPath, pat));
113
+ }
114
+ /** Run `git` and return its status + stdout, mapping ENOENT to GitNotFoundError. */
115
+ function git(args, projectRoot) {
116
+ const result = spawnSync("git", args, {
117
+ cwd: projectRoot,
118
+ encoding: "utf8",
119
+ stdio: ["ignore", "pipe", "pipe"],
120
+ });
121
+ if (result.error !== undefined) {
122
+ const e = result.error;
123
+ if (e.code === "ENOENT") {
124
+ throw new GitNotFoundError("'git' executable not found on PATH");
125
+ }
126
+ throw new GitCommandError(`git ${args.join(" ")} failed: ${String(e.message)}`);
127
+ }
128
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
129
+ }
130
+ /** Run `git`, throwing GitCommandError on any non-zero exit; returns stdout. */
131
+ function gitOrThrow(args, projectRoot) {
132
+ const { status, stdout } = git(args, projectRoot);
133
+ if (status !== 0) {
134
+ throw new GitCommandError(`git ${args.join(" ")} exited ${status}`);
135
+ }
136
+ return stdout;
137
+ }
138
+ function toLines(stdout) {
139
+ return stdout
140
+ .split("\n")
141
+ .map((l) => l.replace(/\r$/, ""))
142
+ .filter((l) => l.trim().length > 0);
143
+ }
144
+ /**
145
+ * Enumerate files ADDED in the diff scope, as POSIX-form rel paths.
146
+ *
147
+ * - `staged`: `git diff --cached --diff-filter=A` (index vs HEAD).
148
+ * - `head`: files added vs HEAD (`git diff --diff-filter=A HEAD`) plus
149
+ * untracked-but-not-ignored files (`git ls-files --others`). When the repo
150
+ * has no commits yet, every tracked file counts as added.
151
+ */
152
+ function addedFiles(projectRoot, mode) {
153
+ // Confirm we are inside a work tree; a non-repo path raises exit 2 upstream.
154
+ const inside = git(["rev-parse", "--is-inside-work-tree"], projectRoot);
155
+ if (inside.status !== 0) {
156
+ throw new GitCommandError("not a git working tree");
157
+ }
158
+ if (mode === "staged") {
159
+ return toLines(gitOrThrow(["diff", "--cached", "--name-only", "--diff-filter=A"], projectRoot));
160
+ }
161
+ const added = new Set();
162
+ const hasHead = git(["rev-parse", "--verify", "-q", "HEAD"], projectRoot).status === 0;
163
+ if (hasHead) {
164
+ for (const f of toLines(gitOrThrow(["diff", "--name-only", "--diff-filter=A", "HEAD"], projectRoot))) {
165
+ added.add(f);
166
+ }
167
+ }
168
+ else {
169
+ for (const f of toLines(gitOrThrow(["ls-files"], projectRoot))) {
170
+ added.add(f);
171
+ }
172
+ }
173
+ for (const f of toLines(gitOrThrow(["ls-files", "--others", "--exclude-standard"], projectRoot))) {
174
+ added.add(f);
175
+ }
176
+ return [...added];
177
+ }
178
+ function configError(message) {
179
+ return { exitCode: 2, missing: [], message };
180
+ }
181
+ /**
182
+ * Pure evaluation returning `{ exitCode, missing, message }`. Three-state exit
183
+ * (0 clean / 1 missing forward coverage / 2 config error). Mirrors the shape of
184
+ * `encoding.evaluate` (#798) so both gates read the same way.
185
+ */
186
+ export function evaluateForwardCoverage(projectRoot, options = {}) {
187
+ const mode = options.mode ?? "head";
188
+ if (mode !== "head" && mode !== "staged") {
189
+ return configError(`verify_forward_coverage: unrecognised mode '${mode}' (expected 'head' or 'staged').`);
190
+ }
191
+ let allowGlobs;
192
+ try {
193
+ allowGlobs = loadAllowList(options.allowListPath);
194
+ }
195
+ catch (err) {
196
+ if (err instanceof AllowListNotFoundError) {
197
+ return configError(`verify_forward_coverage: --allow-list file not found: ${err.message}\n` +
198
+ " Recovery: pass an existing path or omit the flag.");
199
+ }
200
+ return configError(`verify_forward_coverage: --allow-list unreadable: ${String(err.message)}\n` +
201
+ " Recovery: check file permissions.");
202
+ }
203
+ let added;
204
+ try {
205
+ added = addedFiles(projectRoot, mode);
206
+ }
207
+ catch (err) {
208
+ if (err instanceof GitNotFoundError) {
209
+ return configError("verify_forward_coverage: 'git' executable not found on PATH.\n" +
210
+ " Recovery: install git or run inside a git working tree.");
211
+ }
212
+ if (err instanceof GitCommandError) {
213
+ return configError(`verify_forward_coverage: git failed -- ${err.message}\n` +
214
+ " Recovery: ensure --project-root points at a git working tree.");
215
+ }
216
+ throw err;
217
+ }
218
+ const posixAdded = added.map((p) => p.replace(/\\/g, "/"));
219
+ const testBasenames = new Set(posixAdded.filter((p) => isTestFile(p)).map((p) => basename(p)));
220
+ const missing = [];
221
+ let checked = 0;
222
+ for (const rel of posixAdded) {
223
+ if (!isSourceFile(rel)) {
224
+ continue;
225
+ }
226
+ if (isAllowListed(rel, allowGlobs)) {
227
+ continue;
228
+ }
229
+ checked += 1;
230
+ const expected = expectedTestBasenames(rel);
231
+ const covered = expected.some((name) => testBasenames.has(name));
232
+ if (!covered) {
233
+ missing.push({ path: rel, expectedTests: expected });
234
+ }
235
+ }
236
+ if (missing.length > 0) {
237
+ const header = `verify_forward_coverage: ${missing.length} new source file(s) added without a ` +
238
+ "corresponding test in the same diff (#1310).\n" +
239
+ " Rule: a new source file MUST ship with a test in the SAME diff -- running existing\n" +
240
+ " tests alone does not satisfy forward coverage. Add a co-located test (e.g. <name>.test.ts,\n" +
241
+ " <name>_test.go, test_<name>.py) or allow-list a documented exception via --allow-list <path>.";
242
+ const body = missing
243
+ .map((m) => ` ${m.path}\n expected one of: ${m.expectedTests.join(", ")}`)
244
+ .join("\n");
245
+ return { exitCode: 1, missing, message: `${header}\n${body}` };
246
+ }
247
+ return {
248
+ exitCode: 0,
249
+ missing,
250
+ message: `verify_forward_coverage: ${checked} new source file(s) checked -- ` +
251
+ "all have forward coverage in the diff (#1310).",
252
+ };
253
+ }
254
+ //# sourceMappingURL=evaluate.js.map
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * as capacity from "./capacity/index.js";
15
15
  export * as codebase from "./codebase/index.js";
16
16
  export * as doctor from "./doctor/index.js";
17
17
  export * from "./encoding/index.js";
18
+ export * from "./forward-coverage/evaluate.js";
18
19
  export * as intake from "./intake/index.js";
19
20
  export * as layout from "./layout/index.js";
20
21
  export * as legacyBridge from "./legacy-bridge/index.js";
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * as capacity from "./capacity/index.js";
15
15
  export * as codebase from "./codebase/index.js";
16
16
  export * as doctor from "./doctor/index.js";
17
17
  export * from "./encoding/index.js";
18
+ export * from "./forward-coverage/evaluate.js";
18
19
  export * as intake from "./intake/index.js";
19
20
  export * as layout from "./layout/index.js";
20
21
  export * as legacyBridge from "./legacy-bridge/index.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Neutral leaf constants for the init-deposit surface.
3
+ *
4
+ * Kept dependency-free so both `scaffold.ts` and `hygiene.ts` can import the
5
+ * canonical install root without completing a module dependency cycle
6
+ * (scaffold needs hygiene's allowlist ERE; hygiene needs the install root).
7
+ */
8
+ /** Canonical vendored framework install root, relative to the project root. */
9
+ export declare const CANONICAL_INSTALL_ROOT = ".deft/core";
10
+ /** Minimal output sink shared by the init-deposit surface. */
11
+ export interface InitDepositIo {
12
+ printf: (text: string) => void;
13
+ }
14
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Neutral leaf constants for the init-deposit surface.
3
+ *
4
+ * Kept dependency-free so both `scaffold.ts` and `hygiene.ts` can import the
5
+ * canonical install root without completing a module dependency cycle
6
+ * (scaffold needs hygiene's allowlist ERE; hygiene needs the install root).
7
+ */
8
+ /** Canonical vendored framework install root, relative to the project root. */
9
+ export const CANONICAL_INSTALL_ROOT = ".deft/core";
10
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Scoped staging + installer-managed allowlist for TS-native init/update (#1453).
3
+ *
4
+ * Mirrors cmd/deft-install/hygiene.go + deposit.go installerManagedMatchers.
5
+ * Refs #1576, #1453, #1430.
6
+ */
7
+ import { type InitDepositIo } from "./constants.js";
8
+ export declare const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
9
+ export declare const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
10
+ export interface InstallerManagedMatcher {
11
+ readonly exact?: string;
12
+ readonly prefix?: string;
13
+ }
14
+ /** Single source of truth for installer-managed paths (#1440 / #1576). */
15
+ export declare function installerManagedMatchers(): InstallerManagedMatcher[];
16
+ /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
17
+ export declare function installerManagedGuardEre(): string;
18
+ export declare function isInstallerManagedPath(path: string): boolean;
19
+ export interface FrameworkStagePathsOptions {
20
+ /**
21
+ * Include the project-root ``Taskfile.yml`` in the stage set. Defaults to
22
+ * ``false``: unlike the other allowlisted paths, ``Taskfile.yml`` is a
23
+ * consumer-owned file that merely *contains* an installer-managed include
24
+ * block, so it must only be staged when the installer actually wired that
25
+ * block this run -- otherwise an unrelated user edit would be silently
26
+ * ``git add``ed (#1576 review).
27
+ */
28
+ readonly includeTaskfile?: boolean;
29
+ }
30
+ export declare function frameworkStagePaths(projectDir: string, deftDir: string, options?: FrameworkStagePathsOptions): string[];
31
+ export interface StageFrameworkPathsSeams {
32
+ gitPorcelain?: (projectRoot: string) => string | null;
33
+ runGitAdd?: (projectDir: string, paths: readonly string[]) => void;
34
+ }
35
+ /** Best-effort scoped `git add` — never fails the install/update (#1453 Layer 2b). */
36
+ export declare function stageFrameworkPaths(projectDir: string, paths: readonly string[], seams?: StageFrameworkPathsSeams): {
37
+ staged: boolean;
38
+ error: Error | null;
39
+ };
40
+ export declare const COMMIT_HYGIENE_BRANCH_NAME = "chore/deft-framework-upgrade";
41
+ export declare function printCommitGuidance(io: InitDepositIo, paths: readonly string[], staged: boolean): void;
42
+ export interface DepositStagePathsOptions extends StageFrameworkPathsSeams, FrameworkStagePathsOptions {
43
+ readCachedNames?: (projectDir: string) => string[];
44
+ }
45
+ export declare function depositStagePaths(projectDir: string, options?: DepositStagePathsOptions): {
46
+ stagePaths: string[];
47
+ staged: boolean;
48
+ stagedPaths: string[];
49
+ };
50
+ //# sourceMappingURL=hygiene.d.ts.map