@planu/cli 5.2.0 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/dist/config/environment-schema.json +161 -21
- package/dist/config/hook-templates/planu-session-safeguard.sh +14 -4
- package/dist/config/release-policy.json +0 -8
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/session-safeguard/autopush.js +8 -2
- package/dist/engine/validation/validation-freshness.d.ts +20 -1
- package/dist/engine/validation/validation-freshness.js +181 -21
- package/dist/engine/validator/analyzer.d.ts +5 -2
- package/dist/engine/validator/analyzer.js +81 -23
- package/dist/engine/validator/deep-code-checker.d.ts +10 -0
- package/dist/engine/validator/deep-code-checker.js +33 -0
- package/dist/engine/validator/test-evidence-grounding.d.ts +56 -0
- package/dist/engine/validator/test-evidence-grounding.js +187 -0
- package/dist/engine/validator.js +15 -4
- package/dist/tools/reconcile-session-safeguard-hook.js +14 -3
- package/dist/tools/validate.js +6 -2
- package/dist/types/analysis.d.ts +2 -0
- package/dist/types/validation-receipt.d.ts +7 -0
- package/package.json +20 -20
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.1",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.1",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "121e0cde6094d4812a1003f5e8d271c73180f87253d242c0ef2a3ce020114fb2"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.1",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.
|
|
4
|
-
"engineVersion": "5.
|
|
5
|
-
"buildId": "1-5.
|
|
3
|
+
"cliVersion": "5.3.1",
|
|
4
|
+
"engineVersion": "5.3.1",
|
|
5
|
+
"buildId": "1-5.3.1-x86_64-unknown-linux-musl",
|
|
6
6
|
"protocolAbi": 1,
|
|
7
7
|
"capabilityAbi": 2,
|
|
8
8
|
"nodeApiAbi": 4,
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"platform": "linux",
|
|
17
17
|
"arch": "x64",
|
|
18
18
|
"libc": "musl",
|
|
19
|
-
"artifactSha256": "
|
|
19
|
+
"artifactSha256": "8bca20fd7b4458f07f9993babe7ae9417bbce0f8dadac3cd6e723bfe5162e405",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.linux-x64-musl.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "d6638d7152102d2ba32851fd2df269e4e2a2a2ddc5fc788f4602c0dd22c0ae42"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
|
|
28
28
|
"sourceTreeDirty": false,
|
|
29
29
|
"sourceDateEpoch": 1,
|
|
30
30
|
"rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"signature": {
|
|
34
34
|
"algorithm": "Ed25519",
|
|
35
35
|
"keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
|
|
36
|
-
"value": "
|
|
36
|
+
"value": "vZ9UA5RzHSuz+/P18RorLLyH92erubxrJwq4nW766XwepyzlM0m8ol3QYfyrvYvHy4oP9ptee40YvsmexCQcBg=="
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.1",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.1",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "8bca20fd7b4458f07f9993babe7ae9417bbce0f8dadac3cd6e723bfe5162e405"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.1",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.
|
|
4
|
-
"engineVersion": "5.
|
|
5
|
-
"buildId": "1-5.
|
|
3
|
+
"cliVersion": "5.3.1",
|
|
4
|
+
"engineVersion": "5.3.1",
|
|
5
|
+
"buildId": "1-5.3.1-aarch64-pc-windows-msvc",
|
|
6
6
|
"protocolAbi": 1,
|
|
7
7
|
"capabilityAbi": 2,
|
|
8
8
|
"nodeApiAbi": 4,
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"platform": "win32",
|
|
17
17
|
"arch": "arm64",
|
|
18
18
|
"libc": null,
|
|
19
|
-
"artifactSha256": "
|
|
19
|
+
"artifactSha256": "dbc7bc2092e9280e9cbd9c18d472acc57b171fb51b34ac403d36858e460a5558",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-arm64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "ac0dfa26e7466da91c35ffff0b44eddfe0e871fb4ba4e2527d74b7e6e4ad2166"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
|
|
28
28
|
"sourceTreeDirty": false,
|
|
29
29
|
"sourceDateEpoch": 1,
|
|
30
30
|
"rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"signature": {
|
|
34
34
|
"algorithm": "Ed25519",
|
|
35
35
|
"keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
|
|
36
|
-
"value": "
|
|
36
|
+
"value": "dXDAyNUV0maapcjOspyRQByaq39viQeMYq7iypA8UC9cUqcvOa33hojUp056RIkG+9dtu4wTFGO1GzF2oPDNCQ=="
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.1",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.1",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "dbc7bc2092e9280e9cbd9c18d472acc57b171fb51b34ac403d36858e460a5558"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.1",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.
|
|
4
|
-
"engineVersion": "5.
|
|
5
|
-
"buildId": "1-5.
|
|
3
|
+
"cliVersion": "5.3.1",
|
|
4
|
+
"engineVersion": "5.3.1",
|
|
5
|
+
"buildId": "1-5.3.1-x86_64-pc-windows-msvc",
|
|
6
6
|
"protocolAbi": 1,
|
|
7
7
|
"capabilityAbi": 2,
|
|
8
8
|
"nodeApiAbi": 4,
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"platform": "win32",
|
|
17
17
|
"arch": "x64",
|
|
18
18
|
"libc": null,
|
|
19
|
-
"artifactSha256": "
|
|
19
|
+
"artifactSha256": "5d65e9be51ed1f1a391064fbf8892d3ac570c774854b36487d430ee7e7b0b5c8",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-x64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "6563cbe0310464b49325b14f1e7e17a94e476496dd9bd1f522dafabac5da5d18"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
|
|
28
28
|
"sourceTreeDirty": false,
|
|
29
29
|
"sourceDateEpoch": 1,
|
|
30
30
|
"rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"signature": {
|
|
34
34
|
"algorithm": "Ed25519",
|
|
35
35
|
"keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
|
|
36
|
-
"value": "
|
|
36
|
+
"value": "z2Hd9RD31bHF9HmmWsymR4XVqxLPiNSrdCvtWBUXEEBlKdk8rEde8ZPd3BEJCeEsdX0akECDyN7LenOYuFirAQ=="
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.1",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.1",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "5d65e9be51ed1f1a391064fbf8892d3ac570c774854b36487d430ee7e7b0b5c8"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.1",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
const execFileAsync = promisify(execFile);
|
|
5
|
-
|
|
5
|
+
// SPEC-1379: unified with DEFAULT_PROTECTED_BRANCHES (git-helpers.ts) plus the
|
|
6
|
+
// `release` mirror branch; the shell hook template mirrors this same set.
|
|
7
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'release', 'production']);
|
|
6
8
|
/** Resolve the current branch name for the given projectPath. */
|
|
7
9
|
async function getCurrentBranch(projectPath) {
|
|
8
10
|
try {
|
|
@@ -88,7 +90,11 @@ export async function safeAutopush(projectPath, allowedPatterns = ['feat/*', 'fi
|
|
|
88
90
|
};
|
|
89
91
|
}
|
|
90
92
|
try {
|
|
91
|
-
|
|
93
|
+
// No --force (git push has no --ff-only flag): the receiving remote
|
|
94
|
+
// rejects any non-fast-forward push, which is the ff-only guarantee.
|
|
95
|
+
await execFileAsync('git', ['push', '--end-of-options', 'origin', branch], {
|
|
96
|
+
cwd: projectPath,
|
|
97
|
+
});
|
|
92
98
|
return { pushed: true, commitsPushed: commitCount, ref: `origin/${branch}` };
|
|
93
99
|
}
|
|
94
100
|
catch (err) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ValidationFreshnessLease, ValidationReceiptBindings } from '../../types/validation-receipt.js';
|
|
1
|
+
import type { ValidationFreshnessLease, ValidationReceiptBindings, ValidationWatchEventClassification } from '../../types/validation-receipt.js';
|
|
2
2
|
export type { ValidationFreshnessLease } from '../../types/validation-receipt.js';
|
|
3
3
|
/**
|
|
4
4
|
* Invalidate the active validation lease for a spec.
|
|
@@ -10,6 +10,25 @@ export declare function invalidateValidationFreshness(input: {
|
|
|
10
10
|
projectId: string;
|
|
11
11
|
specId: string;
|
|
12
12
|
}): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Classify one raw watcher event as a direct relevant/ignored child path, a
|
|
15
|
+
* synthetic root event (macOS emits these for the watched directory itself),
|
|
16
|
+
* or an ambiguous filename-less event that needs authoritative verification
|
|
17
|
+
* before it can be trusted to mean real byte drift.
|
|
18
|
+
*/
|
|
19
|
+
export declare function classifyValidationWatchEventForTests(input: {
|
|
20
|
+
filename: string | null;
|
|
21
|
+
watchedRoot: string;
|
|
22
|
+
relevant: (filename: string) => boolean;
|
|
23
|
+
}): ValidationWatchEventClassification;
|
|
24
|
+
/** Inject one classified watcher event without relying on host-specific fs.watch timing. */
|
|
25
|
+
export declare function injectValidationWatchEventForTests(input: {
|
|
26
|
+
lease: Pick<ValidationFreshnessLease, 'projectId' | 'specId' | 'epoch'>;
|
|
27
|
+
scope: 'project' | 'handoff';
|
|
28
|
+
filename: string | null;
|
|
29
|
+
}): ValidationWatchEventClassification | undefined;
|
|
30
|
+
/** Pause or observe authoritative verification turns without changing watcher topology. */
|
|
31
|
+
export declare function setValidationFreshnessVerificationHookForTests(hook: (() => Promise<void>) | undefined): void;
|
|
13
32
|
/** Start both watchers before snapshotting and invalidate any older receipt immediately. */
|
|
14
33
|
export declare function beginValidationFreshnessBarrier(input: {
|
|
15
34
|
projectId: string;
|
|
@@ -67,6 +67,7 @@ const FINGERPRINT_CHUNK_BYTES = 64 * 1024;
|
|
|
67
67
|
const EVIDENCE_FILE = /^(?:discovery|task-plan|traceability-matrix|contract-validation-(?:api|graphql|event|ui|mcp))\.json$/u;
|
|
68
68
|
const active = new Map();
|
|
69
69
|
const beginQueues = new Map();
|
|
70
|
+
let verificationHookForTests;
|
|
70
71
|
function key(projectId, specId) {
|
|
71
72
|
return `${projectId}:${specId}`;
|
|
72
73
|
}
|
|
@@ -149,18 +150,57 @@ export function invalidateValidationFreshness(input) {
|
|
|
149
150
|
runInvalidationTurn(input.projectId, input.specId, state);
|
|
150
151
|
return true;
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Invalidate only if `expected` is still the exact live state for this
|
|
155
|
+
* identity. `invalidateValidationFreshness` mutates whatever is currently
|
|
156
|
+
* active for a projectId/specId regardless of epoch — callers holding a
|
|
157
|
+
* lease captured before an `await` must use this guard so a stale lease can
|
|
158
|
+
* never invalidate the epoch that replaced it.
|
|
159
|
+
*/
|
|
160
|
+
function invalidateExactEpoch(lease, expected) {
|
|
161
|
+
if (active.get(key(lease.projectId, lease.specId)) !== expected) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return invalidateValidationFreshness(lease);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Classify one raw watcher event as a direct relevant/ignored child path, a
|
|
168
|
+
* synthetic root event (macOS emits these for the watched directory itself),
|
|
169
|
+
* or an ambiguous filename-less event that needs authoritative verification
|
|
170
|
+
* before it can be trusted to mean real byte drift.
|
|
171
|
+
*/
|
|
172
|
+
export function classifyValidationWatchEventForTests(input) {
|
|
173
|
+
if (!input.filename) {
|
|
174
|
+
return 'ambiguous';
|
|
175
|
+
}
|
|
176
|
+
const normalized = input.filename.replaceAll('\\', '/');
|
|
177
|
+
if (normalized === input.watchedRoot ||
|
|
178
|
+
normalized === `${input.watchedRoot}/` ||
|
|
179
|
+
normalized === '.' ||
|
|
180
|
+
normalized === './') {
|
|
181
|
+
return 'synthetic';
|
|
182
|
+
}
|
|
183
|
+
const rootPrefix = `${input.watchedRoot}/`;
|
|
184
|
+
const relative = normalized.startsWith(rootPrefix)
|
|
185
|
+
? normalized.slice(rootPrefix.length)
|
|
186
|
+
: normalized;
|
|
187
|
+
return input.relevant(relative) ? 'direct-relevant' : 'ignored';
|
|
188
|
+
}
|
|
189
|
+
function startWatch(path, relevant, onRelevantChange, onAmbiguousChange, onError) {
|
|
153
190
|
try {
|
|
154
191
|
const watchedRoot = basename(path);
|
|
155
|
-
const watcher = watch(path, { recursive: true }, (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (
|
|
192
|
+
const watcher = watch(path, { recursive: true }, (_event, filename) => {
|
|
193
|
+
const classification = classifyValidationWatchEventForTests({
|
|
194
|
+
filename,
|
|
195
|
+
watchedRoot,
|
|
196
|
+
relevant,
|
|
197
|
+
});
|
|
198
|
+
if (classification === 'direct-relevant') {
|
|
162
199
|
onRelevantChange();
|
|
163
200
|
}
|
|
201
|
+
else if (classification === 'synthetic' || classification === 'ambiguous') {
|
|
202
|
+
onAmbiguousChange();
|
|
203
|
+
}
|
|
164
204
|
});
|
|
165
205
|
watcher.on('error', onError);
|
|
166
206
|
watcher.unref();
|
|
@@ -172,6 +212,111 @@ function startWatch(path, relevant, onRelevantChange, onError) {
|
|
|
172
212
|
return undefined;
|
|
173
213
|
}
|
|
174
214
|
}
|
|
215
|
+
/** Inject one classified watcher event without relying on host-specific fs.watch timing. */
|
|
216
|
+
export function injectValidationWatchEventForTests(input) {
|
|
217
|
+
const state = active.get(key(input.lease.projectId, input.lease.specId));
|
|
218
|
+
if (state?.epoch !== input.lease.epoch || !state.accepting) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const projectScope = input.scope === 'project';
|
|
222
|
+
const classification = classifyValidationWatchEventForTests({
|
|
223
|
+
filename: input.filename,
|
|
224
|
+
watchedRoot: basename(projectScope ? state.projectPath : state.handoffPath),
|
|
225
|
+
relevant: projectScope ? isValidationSourcePath : (filename) => EVIDENCE_FILE.test(filename),
|
|
226
|
+
});
|
|
227
|
+
if (classification === 'direct-relevant') {
|
|
228
|
+
invalidateValidationFreshness(input.lease);
|
|
229
|
+
}
|
|
230
|
+
else if (classification === 'synthetic' || classification === 'ambiguous') {
|
|
231
|
+
noteAmbiguousEvent(state);
|
|
232
|
+
}
|
|
233
|
+
return classification;
|
|
234
|
+
}
|
|
235
|
+
/** Pause or observe authoritative verification turns without changing watcher topology. */
|
|
236
|
+
export function setValidationFreshnessVerificationHookForTests(hook) {
|
|
237
|
+
verificationHookForTests = hook;
|
|
238
|
+
}
|
|
239
|
+
function noteAmbiguousEvent(state) {
|
|
240
|
+
// Every synthetic or filename-less event counts, even while a cycle is
|
|
241
|
+
// already in flight: the in-flight cycle's target snapshot was captured
|
|
242
|
+
// before this event arrived, so skipping it here would let a mutation
|
|
243
|
+
// signaled only by this event slip past with no follow-up verification
|
|
244
|
+
// turn. The drain loop still bounds the total work to one leading and one
|
|
245
|
+
// trailing turn regardless of how many events land during a cycle.
|
|
246
|
+
state.ambiguousEvents += 1;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Run one authoritative verification turn confirming the exact epoch, source
|
|
250
|
+
* digest, and evidence fingerprint are unchanged as of `targetEvent`.
|
|
251
|
+
*/
|
|
252
|
+
async function runAmbiguousVerificationTurn(lease, state, bindings, targetEvent) {
|
|
253
|
+
const generationBefore = state.generation;
|
|
254
|
+
try {
|
|
255
|
+
await verificationHookForTests?.();
|
|
256
|
+
const fingerprintBefore = await evidenceFingerprint(state.handoffPath);
|
|
257
|
+
const sourceDigest = await gitSourceTreeDigest(state.projectPath);
|
|
258
|
+
const fingerprintAfter = await evidenceFingerprint(state.handoffPath);
|
|
259
|
+
const confirmed = active.get(key(lease.projectId, lease.specId));
|
|
260
|
+
if (confirmed !== state ||
|
|
261
|
+
confirmed.epoch !== lease.epoch ||
|
|
262
|
+
!confirmed.healthy ||
|
|
263
|
+
confirmed.generation !== generationBefore ||
|
|
264
|
+
sourceDigest !== bindings.sourceDigest ||
|
|
265
|
+
fingerprintBefore !== state.evidenceFingerprint ||
|
|
266
|
+
fingerprintAfter !== fingerprintBefore) {
|
|
267
|
+
invalidateExactEpoch(lease, state);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
state.verifiedAmbiguousEvents = Math.max(state.verifiedAmbiguousEvents, targetEvent);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
|
|
275
|
+
invalidateExactEpoch(lease, state);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run the bounded leading-plus-trailing verification cycle for every
|
|
281
|
+
* ambiguous event noted so far. Every concurrent caller shares the SAME
|
|
282
|
+
* cycle promise for its full duration — joining mid-cycle can never start an
|
|
283
|
+
* independent third turn, which keeps AC4's "at most one leading and one
|
|
284
|
+
* trailing turn" bound even when callers interleave with new events.
|
|
285
|
+
*/
|
|
286
|
+
function runAmbiguousDrainCycle(lease, state, bindings) {
|
|
287
|
+
const cycle = (async () => {
|
|
288
|
+
for (let turnIndex = 0; turnIndex < 2; turnIndex += 1) {
|
|
289
|
+
if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
if (!bindings) {
|
|
293
|
+
invalidateExactEpoch(lease, state);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
const targetEvent = state.ambiguousEvents;
|
|
297
|
+
if (!(await runAmbiguousVerificationTurn(lease, state, bindings, targetEvent))) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
invalidateExactEpoch(lease, state);
|
|
305
|
+
return false;
|
|
306
|
+
})().finally(() => {
|
|
307
|
+
if (state.ambiguousDrainCycle === cycle) {
|
|
308
|
+
state.ambiguousDrainCycle = undefined;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return cycle;
|
|
312
|
+
}
|
|
313
|
+
async function drainAmbiguousEvents(lease, state, bindings) {
|
|
314
|
+
if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
state.ambiguousDrainCycle ??= runAmbiguousDrainCycle(lease, state, bindings);
|
|
318
|
+
return state.ambiguousDrainCycle;
|
|
319
|
+
}
|
|
175
320
|
async function evidenceFingerprint(path) {
|
|
176
321
|
const names = (await readdir(path, { withFileTypes: true }))
|
|
177
322
|
.filter((entry) => EVIDENCE_FILE.test(entry.name))
|
|
@@ -263,6 +408,8 @@ export async function beginValidationFreshnessBarrier(input) {
|
|
|
263
408
|
epoch: randomUUID(),
|
|
264
409
|
expiresAt: Date.now() + policy.freshnessLeaseTtlMs,
|
|
265
410
|
generation: 0,
|
|
411
|
+
ambiguousEvents: 0,
|
|
412
|
+
verifiedAmbiguousEvents: 0,
|
|
266
413
|
healthy: true,
|
|
267
414
|
accepting: true,
|
|
268
415
|
projectPath: input.projectPath,
|
|
@@ -279,14 +426,19 @@ export async function beginValidationFreshnessBarrier(input) {
|
|
|
279
426
|
invalidateValidationFreshness(input);
|
|
280
427
|
}
|
|
281
428
|
};
|
|
429
|
+
const ambiguous = () => {
|
|
430
|
+
if (active.get(identity)?.epoch === watchedEpoch && state.accepting) {
|
|
431
|
+
noteAmbiguousEvent(state);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
282
434
|
const failed = () => {
|
|
283
435
|
if (active.get(identity)?.epoch === watchedEpoch && state.accepting) {
|
|
284
436
|
state.healthy = false;
|
|
285
437
|
changed();
|
|
286
438
|
}
|
|
287
439
|
};
|
|
288
|
-
const projectWatcher = startWatch(input.projectPath, isValidationSourcePath, changed, failed);
|
|
289
|
-
const handoffWatcher = startWatch(input.handoffPath, (filename) => EVIDENCE_FILE.test(filename), changed, failed);
|
|
440
|
+
const projectWatcher = startWatch(input.projectPath, isValidationSourcePath, changed, ambiguous, failed);
|
|
441
|
+
const handoffWatcher = startWatch(input.handoffPath, (filename) => EVIDENCE_FILE.test(filename), changed, ambiguous, failed);
|
|
290
442
|
if (!projectWatcher || !handoffWatcher) {
|
|
291
443
|
projectWatcher?.close();
|
|
292
444
|
handoffWatcher?.close();
|
|
@@ -346,10 +498,11 @@ export async function closeValidationFreshnessLease(lease) {
|
|
|
346
498
|
return;
|
|
347
499
|
}
|
|
348
500
|
state.closing ??= (async () => {
|
|
349
|
-
state.accepting = false;
|
|
350
501
|
state.watchers.splice(0).forEach((watcher) => {
|
|
351
502
|
watcher.close();
|
|
352
503
|
});
|
|
504
|
+
await drainAmbiguousEvents(lease, state, state.bindings);
|
|
505
|
+
state.accepting = false;
|
|
353
506
|
if (state.ttlTimer) {
|
|
354
507
|
clearTimeout(state.ttlTimer);
|
|
355
508
|
}
|
|
@@ -386,27 +539,30 @@ export async function bindValidationFreshnessLease(lease, bindings) {
|
|
|
386
539
|
Date.now() >= state.expiresAt) {
|
|
387
540
|
return false;
|
|
388
541
|
}
|
|
542
|
+
if (!(await drainAmbiguousEvents(lease, state, bindings))) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
389
545
|
let fingerprintBefore;
|
|
390
546
|
try {
|
|
391
547
|
fingerprintBefore = await evidenceFingerprint(state.handoffPath);
|
|
392
548
|
if ((await gitSourceTreeDigest(state.projectPath)) !== bindings.sourceDigest) {
|
|
393
|
-
|
|
549
|
+
invalidateExactEpoch(lease, state);
|
|
394
550
|
return false;
|
|
395
551
|
}
|
|
396
552
|
}
|
|
397
553
|
catch (error) {
|
|
398
554
|
reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
|
|
399
|
-
|
|
555
|
+
invalidateExactEpoch(lease, state);
|
|
400
556
|
return false;
|
|
401
557
|
}
|
|
402
558
|
if (fingerprintBefore !== state.evidenceFingerprint) {
|
|
403
|
-
|
|
559
|
+
invalidateExactEpoch(lease, state);
|
|
404
560
|
return false;
|
|
405
561
|
}
|
|
406
562
|
const confirmed = active.get(key(lease.projectId, lease.specId));
|
|
407
563
|
if (!confirmed?.healthy ||
|
|
408
564
|
confirmed.epoch !== state.epoch ||
|
|
409
|
-
confirmed.generation !==
|
|
565
|
+
confirmed.generation !== lease.generation) {
|
|
410
566
|
return false;
|
|
411
567
|
}
|
|
412
568
|
confirmed.bindings = structuredClone(bindings);
|
|
@@ -443,29 +599,32 @@ export async function assertValidationFreshnessBarrier(lease, bindings) {
|
|
|
443
599
|
candidate.generation !== lease.generation) {
|
|
444
600
|
return false;
|
|
445
601
|
}
|
|
602
|
+
if (!(await drainAmbiguousEvents(lease, candidate, candidate.bindings))) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
446
605
|
let fingerprintBefore;
|
|
447
606
|
try {
|
|
448
607
|
fingerprintBefore = await evidenceFingerprint(candidate.handoffPath);
|
|
449
608
|
}
|
|
450
609
|
catch (error) {
|
|
451
610
|
reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
|
|
452
|
-
|
|
611
|
+
invalidateExactEpoch(lease, candidate);
|
|
453
612
|
return false;
|
|
454
613
|
}
|
|
455
614
|
if (fingerprintBefore !== candidate.evidenceFingerprint) {
|
|
456
|
-
|
|
615
|
+
invalidateExactEpoch(lease, candidate);
|
|
457
616
|
return false;
|
|
458
617
|
}
|
|
459
618
|
try {
|
|
460
619
|
if (candidate.bindings &&
|
|
461
620
|
(await gitSourceTreeDigest(candidate.projectPath)) !== candidate.bindings.sourceDigest) {
|
|
462
|
-
|
|
621
|
+
invalidateExactEpoch(lease, candidate);
|
|
463
622
|
return false;
|
|
464
623
|
}
|
|
465
624
|
}
|
|
466
625
|
catch (error) {
|
|
467
626
|
reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
|
|
468
|
-
|
|
627
|
+
invalidateExactEpoch(lease, candidate);
|
|
469
628
|
return false;
|
|
470
629
|
}
|
|
471
630
|
// A generation change invalidates the lease synchronously. Do not wait for
|
|
@@ -481,7 +640,7 @@ export async function assertValidationFreshnessBarrier(lease, bindings) {
|
|
|
481
640
|
fingerprintAfter = undefined;
|
|
482
641
|
}
|
|
483
642
|
if (!state || fingerprintAfter !== fingerprintBefore) {
|
|
484
|
-
|
|
643
|
+
invalidateExactEpoch(lease, candidate);
|
|
485
644
|
return false;
|
|
486
645
|
}
|
|
487
646
|
if (state.generation !== lease.generation) {
|
|
@@ -518,7 +677,7 @@ export async function rebaselineValidationFreshnessLeaseAfterRecompute(input) {
|
|
|
518
677
|
}
|
|
519
678
|
catch (error) {
|
|
520
679
|
reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
|
|
521
|
-
|
|
680
|
+
invalidateExactEpoch(input.lease, before);
|
|
522
681
|
return undefined;
|
|
523
682
|
}
|
|
524
683
|
const after = await readStableState(input.lease);
|
|
@@ -549,6 +708,7 @@ export async function resetValidationFreshnessForTests() {
|
|
|
549
708
|
});
|
|
550
709
|
await Promise.all(leases.map((lease) => closeValidationFreshnessLease(lease)));
|
|
551
710
|
beginQueues.clear();
|
|
711
|
+
verificationHookForTests = undefined;
|
|
552
712
|
}
|
|
553
713
|
/** Count-only process diagnostic; intentionally absent from the package entrypoint. */
|
|
554
714
|
export function getValidationFreshnessDiagnosticsForTests() {
|
|
@@ -12,8 +12,11 @@ export declare function scanCodeForSpec(spec: Spec, projectPath: string): Promis
|
|
|
12
12
|
* buildFileContentsCache from criterion-matcher) to avoid a redundant
|
|
13
13
|
* glob+readFile pass when checking multiple criteria in the same call.
|
|
14
14
|
*/
|
|
15
|
-
export declare function checkCriterionEvidence(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string
|
|
16
|
-
|
|
15
|
+
export declare function checkCriterionEvidence(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>, declaredTestPaths?: string[], probeCache?: Map<string, {
|
|
16
|
+
passed: boolean;
|
|
17
|
+
evidence: string;
|
|
18
|
+
}>): Promise<CriterionEvidenceResult>;
|
|
19
|
+
export declare function checkCriterion(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>, declaredTestPaths?: string[]): Promise<boolean>;
|
|
17
20
|
/**
|
|
18
21
|
* Classify the severity of a missing criterion based on its content.
|
|
19
22
|
* Keywords and severity levels are loaded from src/config/drift-severity.json (SPEC-209).
|