@deftai/directive-core 0.67.0 → 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 (90) hide show
  1. package/dist/check/index.d.ts +1 -1
  2. package/dist/check/index.js +1 -1
  3. package/dist/check/orchestrator.d.ts +14 -4
  4. package/dist/check/orchestrator.js +30 -6
  5. package/dist/doctor/checks.js +11 -11
  6. package/dist/doctor/constants.d.ts +2 -0
  7. package/dist/doctor/constants.js +2 -0
  8. package/dist/doctor/payload-staleness.js +15 -6
  9. package/dist/forward-coverage/evaluate.d.ts +55 -0
  10. package/dist/forward-coverage/evaluate.js +254 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/init-deposit/constants.d.ts +14 -0
  14. package/dist/init-deposit/constants.js +10 -0
  15. package/dist/init-deposit/hygiene.d.ts +50 -0
  16. package/dist/init-deposit/hygiene.js +176 -0
  17. package/dist/init-deposit/init-deposit.d.ts +1 -0
  18. package/dist/init-deposit/init-deposit.js +7 -1
  19. package/dist/init-deposit/refresh.d.ts +2 -0
  20. package/dist/init-deposit/refresh.js +48 -30
  21. package/dist/init-deposit/scaffold.d.ts +3 -4
  22. package/dist/init-deposit/scaffold.js +3 -24
  23. package/dist/integration-e2e/helpers.d.ts +0 -1
  24. package/dist/integration-e2e/helpers.js +3 -7
  25. package/dist/layout/lifecycle-ref.d.ts +15 -0
  26. package/dist/layout/lifecycle-ref.js +55 -0
  27. package/dist/platform/agents-md.d.ts +28 -0
  28. package/dist/platform/agents-md.js +34 -0
  29. package/dist/policy/resolve.js +56 -46
  30. package/dist/pr-merge-readiness/compute.d.ts +10 -0
  31. package/dist/pr-merge-readiness/compute.js +53 -17
  32. package/dist/pr-merge-readiness/index.d.ts +2 -1
  33. package/dist/pr-merge-readiness/index.js +1 -0
  34. package/dist/pr-merge-readiness/mergeability.d.ts +43 -0
  35. package/dist/pr-merge-readiness/mergeability.js +105 -0
  36. package/dist/pr-monitor/index.d.ts +1 -1
  37. package/dist/pr-monitor/index.js +1 -1
  38. package/dist/pr-monitor/monitor.d.ts +8 -2
  39. package/dist/pr-monitor/monitor.js +35 -6
  40. package/dist/pr-wait-mergeable/wrappers.d.ts +12 -1
  41. package/dist/pr-wait-mergeable/wrappers.js +13 -5
  42. package/dist/pr-watch/constants.d.ts +33 -0
  43. package/dist/pr-watch/constants.js +33 -0
  44. package/dist/pr-watch/index.d.ts +6 -0
  45. package/dist/pr-watch/index.js +6 -0
  46. package/dist/pr-watch/main.d.ts +21 -0
  47. package/dist/pr-watch/main.js +206 -0
  48. package/dist/pr-watch/probe.d.ts +13 -0
  49. package/dist/pr-watch/probe.js +100 -0
  50. package/dist/pr-watch/types.d.ts +47 -0
  51. package/dist/pr-watch/types.js +2 -0
  52. package/dist/pr-watch/watch.d.ts +12 -0
  53. package/dist/pr-watch/watch.js +122 -0
  54. package/dist/render/project-render.js +114 -89
  55. package/dist/scope/capacity-stamp.d.ts +24 -2
  56. package/dist/scope/capacity-stamp.js +80 -28
  57. package/dist/scope/decomposed-refs.js +2 -2
  58. package/dist/scope/project-definition-sync.js +3 -3
  59. package/dist/scope/vbrief-ref.d.ts +2 -1
  60. package/dist/scope/vbrief-ref.js +3 -2
  61. package/dist/spec-authority/constants.d.ts +6 -0
  62. package/dist/spec-authority/constants.js +11 -0
  63. package/dist/spec-authority/resolver.js +3 -3
  64. package/dist/swarm/finalize-cohort-cli.d.ts +5 -0
  65. package/dist/swarm/finalize-cohort-cli.js +100 -0
  66. package/dist/swarm/finalize-cohort.d.ts +39 -0
  67. package/dist/swarm/finalize-cohort.js +589 -0
  68. package/dist/swarm/index.d.ts +2 -0
  69. package/dist/swarm/index.js +2 -0
  70. package/dist/swarm/launch.d.ts +7 -0
  71. package/dist/swarm/launch.js +17 -5
  72. package/dist/triage/queue/index.d.ts +1 -0
  73. package/dist/triage/queue/index.js +1 -0
  74. package/dist/triage/queue/project.d.ts +6 -1
  75. package/dist/triage/queue/project.js +9 -4
  76. package/dist/triage/queue/reconcile-live-state.d.ts +42 -0
  77. package/dist/triage/queue/reconcile-live-state.js +54 -0
  78. package/dist/triage/scope/mutations-core.js +91 -81
  79. package/dist/triage/scope/resolve.js +4 -3
  80. package/dist/triage/scope-drift/add-ignore.js +33 -32
  81. package/dist/triage/scope-drift/scope-rules.js +3 -3
  82. package/dist/triage/subscribe/index.js +51 -45
  83. package/dist/triage/welcome/writers.js +79 -64
  84. package/dist/vbrief-build/project-definition-io.js +8 -3
  85. package/dist/vbrief-validate/paths.js +8 -4
  86. package/dist/vbrief-validate/precutover.d.ts +1 -1
  87. package/dist/vbrief-validate/precutover.js +12 -10
  88. package/package.json +7 -7
  89. package/dist/install-upgrade/index.d.ts +0 -13
  90. package/dist/install-upgrade/index.js +0 -251
@@ -1,3 +1,3 @@
1
1
  export type { CheckOrchestratorSeams } from "./orchestrator.js";
2
- export { dispatchTaskCheck, isFrameworkSourceContext, resolveCheckTarget } from "./orchestrator.js";
2
+ export { dispatchTaskCheck, isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget, } from "./orchestrator.js";
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,2 @@
1
- export { dispatchTaskCheck, isFrameworkSourceContext, resolveCheckTarget } from "./orchestrator.js";
1
+ export { dispatchTaskCheck, isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget, } from "./orchestrator.js";
2
2
  //# sourceMappingURL=index.js.map
@@ -24,13 +24,23 @@ export interface CheckOrchestratorSeams {
24
24
  error?: Error;
25
25
  };
26
26
  }
27
+ /**
28
+ * True when `path` is the directive framework source checkout root (not a
29
+ * vendored `.deft/core` content deposit). Used to distinguish a maintainer
30
+ * running `task check` from a subdirectory (#2220) from a consumer install.
31
+ */
32
+ export declare function isFrameworkRepoRoot(path: string): boolean;
27
33
  /**
28
34
  * Return true when running in the framework's own source checkout (#1519).
29
35
  *
30
- * Mirrors `is_framework_source_context` from _project_context.py:
31
- * equality of lexical absolute roots is the stable distinction. We do NOT
32
- * resolve symlinks here -- a consumer project may symlink `.deft/core` to a
33
- * local framework checkout and should still run the consumer-safe gate.
36
+ * Mirrors `is_framework_source_context` from _project_context.py with one
37
+ * extension (#2220): when the Taskfile lives at the framework repo root,
38
+ * `task check` may be invoked from a subdirectory (`USER_WORKING_DIR` !=
39
+ * `TASKFILE_DIR`). Those invocations must still route to
40
+ * `check:framework-source` so the biome lane runs. We do NOT resolve
41
+ * symlinks on the framework root -- a consumer project may symlink
42
+ * `.deft/core` to a local framework checkout and should still run the
43
+ * consumer-safe gate (the deposit path lacks `packages/cli`).
34
44
  */
35
45
  export declare function isFrameworkSourceContext(frameworkRoot: string, projectRoot: string): boolean;
36
46
  /**
@@ -12,17 +12,41 @@
12
12
  * 2 -- config error (missing args, task spawn error, etc.)
13
13
  */
14
14
  import { spawnSync } from "node:child_process";
15
- import { join, resolve } from "node:path";
15
+ import { existsSync } from "node:fs";
16
+ import { join, resolve, sep } from "node:path";
17
+ /**
18
+ * True when `path` is the directive framework source checkout root (not a
19
+ * vendored `.deft/core` content deposit). Used to distinguish a maintainer
20
+ * running `task check` from a subdirectory (#2220) from a consumer install.
21
+ */
22
+ export function isFrameworkRepoRoot(path) {
23
+ const root = resolve(path);
24
+ return (existsSync(join(root, "packages", "cli", "package.json")) &&
25
+ existsSync(join(root, "biome.json")) &&
26
+ existsSync(join(root, "Taskfile.yml")));
27
+ }
16
28
  /**
17
29
  * Return true when running in the framework's own source checkout (#1519).
18
30
  *
19
- * Mirrors `is_framework_source_context` from _project_context.py:
20
- * equality of lexical absolute roots is the stable distinction. We do NOT
21
- * resolve symlinks here -- a consumer project may symlink `.deft/core` to a
22
- * local framework checkout and should still run the consumer-safe gate.
31
+ * Mirrors `is_framework_source_context` from _project_context.py with one
32
+ * extension (#2220): when the Taskfile lives at the framework repo root,
33
+ * `task check` may be invoked from a subdirectory (`USER_WORKING_DIR` !=
34
+ * `TASKFILE_DIR`). Those invocations must still route to
35
+ * `check:framework-source` so the biome lane runs. We do NOT resolve
36
+ * symlinks on the framework root -- a consumer project may symlink
37
+ * `.deft/core` to a local framework checkout and should still run the
38
+ * consumer-safe gate (the deposit path lacks `packages/cli`).
23
39
  */
24
40
  export function isFrameworkSourceContext(frameworkRoot, projectRoot) {
25
- return resolve(frameworkRoot) === resolve(projectRoot);
41
+ const fw = resolve(frameworkRoot);
42
+ const pr = resolve(projectRoot);
43
+ if (fw === pr) {
44
+ return true;
45
+ }
46
+ if (!isFrameworkRepoRoot(fw)) {
47
+ return false;
48
+ }
49
+ return pr.startsWith(`${fw}${sep}`);
26
50
  }
27
51
  /**
28
52
  * Select the Taskfile target for the given context.
@@ -34,14 +34,14 @@ export function checkQuickStartResolves(projectRoot, installRoot, seams = {}) {
34
34
  `install root is '${installRoot}' but the file is missing. ` +
35
35
  "Run `.deft/core/run agents:refresh` (Unix) / " +
36
36
  "`.deft\\core\\run agents:refresh` (Windows) to align AGENTS.md " +
37
- "with the on-disk install root, OR run `task upgrade` to " +
37
+ "with the on-disk install root, OR run `deft update` to " +
38
38
  "re-pull the framework if the on-disk install is missing. " +
39
39
  "See UPGRADING.md for the canonical drift-repair walkthrough.",
40
40
  data: {
41
41
  path: qsPath,
42
42
  install_root: installRoot,
43
43
  suggested_fix: ".deft/core/run agents:refresh",
44
- suggested_fix_alt: "task upgrade",
44
+ suggested_fix_alt: "deft update",
45
45
  },
46
46
  };
47
47
  }
@@ -89,13 +89,13 @@ export function checkSkillPathsResolve(projectRoot, agentsMdText, seams = {}) {
89
89
  status: "fail",
90
90
  detail: `${missing.length} skill path(s) do not resolve; ${redirectStubs.length} stub redirect(s). ` +
91
91
  `${parts.join("; ")}. Run \`.deft/core/run agents:refresh\` (Unix) / ` +
92
- "`.deft\\core\\run agents:refresh` (Windows) to rewrite the managed AGENTS.md block so skill paths match the on-disk framework, OR run `task upgrade` if the on-disk skills are missing entirely. See UPGRADING.md for the drift-repair walkthrough.",
92
+ "`.deft\\core\\run agents:refresh` (Windows) to rewrite the managed AGENTS.md block so skill paths match the on-disk framework, OR run `deft update` if the on-disk skills are missing entirely. See UPGRADING.md for the drift-repair walkthrough.",
93
93
  data: {
94
94
  referenced,
95
95
  missing,
96
96
  redirect_stubs: redirectStubs,
97
97
  suggested_fix: ".deft/core/run agents:refresh",
98
- suggested_fix_alt: "task upgrade",
98
+ suggested_fix_alt: "deft update",
99
99
  },
100
100
  };
101
101
  }
@@ -111,7 +111,7 @@ export function checkManifestAgreement(projectRoot, installRoot, seams = {}) {
111
111
  return {
112
112
  name: "manifest-agreement",
113
113
  status: "fail",
114
- detail: `Two install manifests disagree: .deft/core/VERSION (tag='${coreVer}') vs legacy .deft/VERSION (tag='${legacyVer}'). The canonical manifest is .deft/core/VERSION -- run \`task upgrade\` to migrate the stale .deft/VERSION (backed up as .deft/VERSION.premigrate). See UPGRADING.md for the canonical drift-repair walkthrough.`,
114
+ detail: `Two install manifests disagree: .deft/core/VERSION (tag='${coreVer}') vs legacy .deft/VERSION (tag='${legacyVer}'). The canonical manifest is .deft/core/VERSION -- run \`deft update\` to migrate the stale .deft/VERSION (backed up as .deft/VERSION.premigrate). See UPGRADING.md for the canonical drift-repair walkthrough.`,
115
115
  data: {
116
116
  dual_manifest_drift: true,
117
117
  core_manifest_path: coreManifest,
@@ -119,7 +119,7 @@ export function checkManifestAgreement(projectRoot, installRoot, seams = {}) {
119
119
  core_version: coreVer,
120
120
  legacy_version: legacyVer,
121
121
  authoritative: "manifest",
122
- suggested_fix: "task upgrade",
122
+ suggested_fix: "deft update",
123
123
  },
124
124
  };
125
125
  }
@@ -149,13 +149,13 @@ export function checkManifestAgreement(projectRoot, installRoot, seams = {}) {
149
149
  return {
150
150
  name: "manifest-agreement",
151
151
  status: "fail",
152
- detail: `Bare .deft-version exists at ${barePath} but YAML manifest is missing at ${expectedManifestPath}. Run \`task upgrade\` to write the canonical manifest (#1046 PR-B AC-4). See UPGRADING.md for the v0.27.x -> v0.28 transition walkthrough.`,
152
+ detail: `Bare .deft-version exists at ${barePath} but YAML manifest is missing at ${expectedManifestPath}. Run \`deft update\` to write the canonical manifest (#1046 PR-B AC-4). See UPGRADING.md for the v0.27.x -> v0.28 transition walkthrough.`,
153
153
  data: {
154
154
  manifest_path: manifestPath,
155
155
  expected_manifest_path: expectedManifestPath,
156
156
  bare_path: barePath,
157
157
  bare_value: bareText?.trim() ?? null,
158
- suggested_fix: "task upgrade",
158
+ suggested_fix: "deft update",
159
159
  },
160
160
  };
161
161
  }
@@ -165,7 +165,7 @@ export function checkManifestAgreement(projectRoot, installRoot, seams = {}) {
165
165
  return {
166
166
  name: "manifest-agreement",
167
167
  status: "pass",
168
- detail: `YAML manifest at ${manifestPath} present; bare .deft-version absent (derived value: '${derived}' from manifest tag). Run \`task upgrade\` to regenerate the derivative.`,
168
+ detail: `YAML manifest at ${manifestPath} present; bare .deft-version absent (derived value: '${derived}' from manifest tag). Run \`deft update\` to regenerate the derivative.`,
169
169
  data: {
170
170
  manifest_path: manifestPath,
171
171
  manifest,
@@ -205,14 +205,14 @@ export function checkManifestAgreement(projectRoot, installRoot, seams = {}) {
205
205
  return {
206
206
  name: "manifest-agreement",
207
207
  status: "fail",
208
- detail: `Drift detected: YAML manifest tag='${derived}' does NOT agree with bare .deft-version='${bareValue}'. Per #1046 PR-B AC-4 the YAML manifest is the canonical source -- run \`task upgrade\` to regenerate the bare derivative from the manifest, OR manually update ${manifestPath} if the bare value is correct. See UPGRADING.md for the canonical drift-repair walkthrough.`,
208
+ detail: `Drift detected: YAML manifest tag='${derived}' does NOT agree with bare .deft-version='${bareValue}'. Per #1046 PR-B AC-4 the YAML manifest is the canonical source -- run \`deft update\` to regenerate the bare derivative from the manifest, OR manually update ${manifestPath} if the bare value is correct. See UPGRADING.md for the canonical drift-repair walkthrough.`,
209
209
  data: {
210
210
  manifest_path: manifestPath,
211
211
  bare_path: barePath,
212
212
  derived_version: derived,
213
213
  bare_value: bareValue,
214
214
  authoritative: "manifest",
215
- suggested_fix: "task upgrade",
215
+ suggested_fix: "deft update",
216
216
  },
217
217
  };
218
218
  }
@@ -18,6 +18,8 @@ export declare const DEFT_REPO_POSITIVE_MARKERS: readonly ["content/templates/ag
18
18
  export declare const EXPECTED_CONTENT_DIRS: readonly ["languages", "strategies", "skills", "templates"];
19
19
  /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
20
20
  export declare const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
21
+ /** Vendored npm-managed deposit: global bump plus in-place `.deft/core/` refresh (#2115). */
22
+ export declare const VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest && deft update";
21
23
  export declare const CLEAN_WINDOW_HOURS = 24;
22
24
  export declare const DIRTY_WINDOW_HOURS = 4;
23
25
  export declare const ENV_STATE_PATH = "DEFT_DOCTOR_STATE_PATH";
@@ -60,6 +60,8 @@ export const DEFT_REPO_POSITIVE_MARKERS = [
60
60
  export const EXPECTED_CONTENT_DIRS = ["languages", "strategies", "skills", "templates"];
61
61
  /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
62
62
  export const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
63
+ /** Vendored npm-managed deposit: global bump plus in-place `.deft/core/` refresh (#2115). */
64
+ export const VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND = `${CANONICAL_UPGRADE_COMMAND} && deft update`;
63
65
  export const CLEAN_WINDOW_HOURS = 24;
64
66
  export const DIRTY_WINDOW_HOURS = 4;
65
67
  export const ENV_STATE_PATH = "DEFT_DOCTOR_STATE_PATH";
@@ -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",
@@ -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