@planu/cli 5.1.1 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/cli/commands/doctor.d.ts +22 -0
- package/dist/cli/commands/doctor.js +176 -2
- package/dist/config/environment-schema.json +161 -21
- package/dist/config/release-policy.json +0 -8
- package/dist/engine/autopilot/bootstrap.js +27 -0
- package/dist/engine/core-bridge.d.ts +28 -0
- package/dist/engine/core-bridge.js +67 -0
- package/dist/engine/drift-monitor.js +16 -18
- package/dist/engine/living-spec/hash-tracker.js +26 -28
- 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/spec-language/english-only.d.ts +14 -0
- package/dist/engine/spec-language/english-only.js +58 -0
- package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
- package/dist/engine/spec-migrator/drift-detector.js +10 -12
- 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/engine/vector-store/tfidf.d.ts +13 -9
- package/dist/engine/vector-store/tfidf.js +26 -0
- package/dist/engine/worker-config-loader.d.ts +1 -1
- package/dist/engine/worker-config-loader.js +1 -11
- package/dist/engine/workers/schema.d.ts +0 -8
- package/dist/engine/workers/schema.js +0 -1
- package/dist/i18n/index.d.ts +18 -0
- package/dist/i18n/index.js +40 -1
- package/dist/storage/global-store.d.ts +9 -0
- package/dist/storage/global-store.js +23 -0
- package/dist/storage/semantic-index-store.d.ts +23 -0
- package/dist/storage/semantic-index-store.js +105 -0
- package/dist/storage/status-store/self-healing.js +15 -17
- package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
- package/dist/tools/create-spec.js +60 -15
- package/dist/tools/init-project/handler.js +78 -25
- package/dist/tools/learn.js +10 -8
- package/dist/tools/registry/auth.js +1 -11
- package/dist/tools/semantic-search-handler.js +5 -6
- package/dist/tools/status-handler.js +19 -6
- package/dist/tools/validate.js +6 -2
- package/dist/tools/validation-loop-handler.js +16 -15
- package/dist/types/analysis.d.ts +2 -0
- package/dist/types/spec-language-translation.d.ts +18 -0
- package/dist/types/spec-language-translation.js +5 -0
- package/dist/types/spec-registry.d.ts +0 -2
- package/dist/types/status.d.ts +2 -0
- package/dist/types/validation-receipt.d.ts +7 -0
- package/dist/types/vector-store.d.ts +18 -0
- package/dist/types/workers.d.ts +0 -3
- package/package.json +20 -20
- package/planu-native.json +8 -29
- package/planu-plugin.json +1 -1
- package/dist/engine/security/cve-refresher.d.ts +0 -12
- package/dist/engine/security/cve-refresher.js +0 -128
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.
|
|
4
|
-
"engineVersion": "5.
|
|
5
|
-
"buildId": "1-5.
|
|
3
|
+
"cliVersion": "5.3.0",
|
|
4
|
+
"engineVersion": "5.3.0",
|
|
5
|
+
"buildId": "1-5.3.0-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": "01422e8f53405b0cfffac1b978d4a07c7972c4cfa0071e3f7cf38d7f8b2513c3",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-arm64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "23055aaffab71649b3fc5882ee65539d8f3db05b0e35d008cd43f807c3c55d93"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "0bfc16bd342cfaa8ee48da5d9a1484df65bca782",
|
|
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": "Cwm9qVClAtTaV/h0ZVGCGk7QXtV1GStNz+b82ZZdsJjCKFVGv+RJybsHCZkcVakUQjSGmN4ssaJ1bvZq1paMBA=="
|
|
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.0",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.0",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "01422e8f53405b0cfffac1b978d4a07c7972c4cfa0071e3f7cf38d7f8b2513c3"
|
|
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.0",
|
|
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.0",
|
|
4
|
+
"engineVersion": "5.3.0",
|
|
5
|
+
"buildId": "1-5.3.0-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": "742b98dcbef119cd3f8b7f52c7e7508882312e40ffe6900558f054718054cb4e",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-x64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "172c6101641c982c1bfc77907de80a963e550dea73dbd0db6e2abe332a87a88a"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "0bfc16bd342cfaa8ee48da5d9a1484df65bca782",
|
|
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": "rKiBtPEqL5BY1WRzouHSNnhfASCK+jUqnpGRpS2e7N/dpGootewvevQRZVJJixuQL0rlmoDZtiFmXwbfJ8yYAA=="
|
|
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.0",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.3.0",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "742b98dcbef119cd3f8b7f52c7e7508882312e40ffe6900558f054718054cb4e"
|
|
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.0",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EnglishOnlyArtifactKind, EnglishOnlyValidationResult } from '../../types/spec-language.js';
|
|
2
|
+
import type { EnglishOnlyGateResolution } from '../../types/spec-language-translation.js';
|
|
2
3
|
/**
|
|
3
4
|
* Validate that a Planu-owned persisted artifact is authored in English.
|
|
4
5
|
*
|
|
@@ -12,5 +13,18 @@ export declare function validateEnglishOnlyArtifactText(text: string, kind?: Eng
|
|
|
12
13
|
* Backward-compatible spec-specific entrypoint used by create_spec/update_status gates.
|
|
13
14
|
*/
|
|
14
15
|
export declare function validateEnglishOnlySpecText(text: string): EnglishOnlyValidationResult;
|
|
16
|
+
/** clarificationAnswers key carrying the LLM-provided English translation of the spec title. */
|
|
17
|
+
export declare const TRANSLATED_TITLE_ANSWER_KEY = "Title-EN";
|
|
18
|
+
/** clarificationAnswers key carrying the LLM-provided English translation of the description. */
|
|
19
|
+
export declare const TRANSLATED_DESCRIPTION_ANSWER_KEY = "Desc-EN";
|
|
20
|
+
/**
|
|
21
|
+
* SPEC-1342: Resolve the English-only gate for create_spec's title/description.
|
|
22
|
+
*
|
|
23
|
+
* Instead of hard-rejecting non-English input, this offers one clarification round:
|
|
24
|
+
* the calling LLM is asked (via the existing clarificationAnswers mechanism) to supply
|
|
25
|
+
* the English translation inline. Only rejects if that translation is missing or itself
|
|
26
|
+
* still non-English after the round.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveEnglishOnlySpecGate(title: string, description: string, clarificationAnswers?: Record<string, string>): EnglishOnlyGateResolution;
|
|
15
29
|
export declare function assertEnglishOnlyArtifactText(text: string, kind: EnglishOnlyArtifactKind): void;
|
|
16
30
|
//# sourceMappingURL=english-only.d.ts.map
|
|
@@ -119,6 +119,64 @@ export function validateEnglishOnlyArtifactText(text, kind = 'spec') {
|
|
|
119
119
|
export function validateEnglishOnlySpecText(text) {
|
|
120
120
|
return validateEnglishOnlyArtifactText(text, 'spec');
|
|
121
121
|
}
|
|
122
|
+
/** clarificationAnswers key carrying the LLM-provided English translation of the spec title. */
|
|
123
|
+
export const TRANSLATED_TITLE_ANSWER_KEY = 'Title-EN';
|
|
124
|
+
/** clarificationAnswers key carrying the LLM-provided English translation of the description. */
|
|
125
|
+
export const TRANSLATED_DESCRIPTION_ANSWER_KEY = 'Desc-EN';
|
|
126
|
+
/**
|
|
127
|
+
* SPEC-1342: Resolve the English-only gate for create_spec's title/description.
|
|
128
|
+
*
|
|
129
|
+
* Instead of hard-rejecting non-English input, this offers one clarification round:
|
|
130
|
+
* the calling LLM is asked (via the existing clarificationAnswers mechanism) to supply
|
|
131
|
+
* the English translation inline. Only rejects if that translation is missing or itself
|
|
132
|
+
* still non-English after the round.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveEnglishOnlySpecGate(title, description, clarificationAnswers) {
|
|
135
|
+
const validation = validateEnglishOnlySpecText(`${title}\n\n${description}`);
|
|
136
|
+
if (validation.ok) {
|
|
137
|
+
return { action: 'ok' };
|
|
138
|
+
}
|
|
139
|
+
const originalLanguage = validation.detectedLanguage === 'pt' ? 'pt' : 'es';
|
|
140
|
+
const titleAnswer = clarificationAnswers?.[TRANSLATED_TITLE_ANSWER_KEY]?.trim();
|
|
141
|
+
const descriptionAnswer = clarificationAnswers?.[TRANSLATED_DESCRIPTION_ANSWER_KEY]?.trim();
|
|
142
|
+
if (!titleAnswer || !descriptionAnswer) {
|
|
143
|
+
return {
|
|
144
|
+
action: 'ask',
|
|
145
|
+
originalLanguage,
|
|
146
|
+
questions: buildTranslationRequestQuestions(title),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const translatedValidation = validateEnglishOnlySpecText(`${titleAnswer}\n\n${descriptionAnswer}`);
|
|
150
|
+
if (!translatedValidation.ok) {
|
|
151
|
+
return { action: 'reject', originalLanguage, reason: validation.reason };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
action: 'translated',
|
|
155
|
+
originalLanguage,
|
|
156
|
+
englishTitle: titleAnswer,
|
|
157
|
+
englishDescription: descriptionAnswer,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function buildTranslationRequestQuestions(title) {
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
question: `The spec title "${title}" is not in English. What is the English translation of the title?`,
|
|
164
|
+
header: TRANSLATED_TITLE_ANSWER_KEY,
|
|
165
|
+
options: [
|
|
166
|
+
{ label: 'Provide translation', description: 'Type the English translation as free text.' },
|
|
167
|
+
],
|
|
168
|
+
multiSelect: false,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
question: 'What is the English translation of the spec description?',
|
|
172
|
+
header: TRANSLATED_DESCRIPTION_ANSWER_KEY,
|
|
173
|
+
options: [
|
|
174
|
+
{ label: 'Provide translation', description: 'Type the English translation as free text.' },
|
|
175
|
+
],
|
|
176
|
+
multiSelect: false,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
}
|
|
122
180
|
export function assertEnglishOnlyArtifactText(text, kind) {
|
|
123
181
|
const validation = validateEnglishOnlyArtifactText(text, kind);
|
|
124
182
|
if (!validation.ok) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { checkCriteriaQuality } from '../criteria-quality-checker.js';
|
|
5
|
-
import {
|
|
5
|
+
import { fastScanSpecsAsync } from '../core-bridge.js';
|
|
6
6
|
const SPEC_DIR_RE = /^SPEC-\d+/;
|
|
7
7
|
/** Extract YAML frontmatter content (between first and second ---). */
|
|
8
8
|
function extractFrontmatterAndBody(content) {
|
|
@@ -43,18 +43,16 @@ function extractField(frontmatter, field) {
|
|
|
43
43
|
* Returns an array of ambiguous criteria with context for LLM rewriting.
|
|
44
44
|
*/
|
|
45
45
|
export async function scanForAmbiguousCriteria(projectPath) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})));
|
|
57
|
-
}
|
|
46
|
+
const { value: briefs } = await fastScanSpecsAsync(projectPath);
|
|
47
|
+
if (briefs) {
|
|
48
|
+
return briefs.flatMap((b) => b.ambiguousCriteria.map((a) => ({
|
|
49
|
+
specId: a.specId,
|
|
50
|
+
specTitle: a.specTitle,
|
|
51
|
+
specContext: '', // Native scanner doesn't return full body context yet
|
|
52
|
+
criterionText: a.criterionText,
|
|
53
|
+
score: a.score,
|
|
54
|
+
reason: a.reason,
|
|
55
|
+
})));
|
|
58
56
|
}
|
|
59
57
|
const ambiguous = [];
|
|
60
58
|
const specsDir = join(projectPath, 'planu/specs');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readdir, stat, access, readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isOldFormat } from './lean-migration.js';
|
|
5
|
-
import {
|
|
5
|
+
import { fastScanSpecsAsync } from '../core-bridge.js';
|
|
6
6
|
import { isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalSpecFile, } from './planu-canonical-policy.js';
|
|
7
7
|
async function scanPlanuRoot(projectPath) {
|
|
8
8
|
const rootFiles = [];
|
|
@@ -82,22 +82,20 @@ export async function detectDrift(projectPath) {
|
|
|
82
82
|
const specsDir = join(projectPath, 'planu', 'specs');
|
|
83
83
|
const unifiedSpecs = [];
|
|
84
84
|
const leanSpecs = [];
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
leanSpecs.push(b.id);
|
|
94
|
-
}
|
|
85
|
+
const { value: briefs } = await fastScanSpecsAsync(projectPath);
|
|
86
|
+
if (briefs) {
|
|
87
|
+
for (const b of briefs) {
|
|
88
|
+
if (b.needsUnified) {
|
|
89
|
+
unifiedSpecs.push(b.id);
|
|
90
|
+
}
|
|
91
|
+
if (b.needsLean) {
|
|
92
|
+
leanSpecs.push(b.id);
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
}
|
|
98
96
|
const rootFiles = await scanPlanuRoot(projectPath);
|
|
99
97
|
const specDirEntries = await readdir(specsDir).catch(() => []);
|
|
100
|
-
if (
|
|
98
|
+
if (briefs) {
|
|
101
99
|
for (const entry of specDirEntries) {
|
|
102
100
|
const specPath = join(specsDir, entry);
|
|
103
101
|
try {
|
|
@@ -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() {
|