project-tiny-context-harness 0.2.70 → 0.2.72
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/README.md +31 -21
- package/assets/README.md +26 -18
- package/assets/README.zh-CN.md +9 -3
- package/assets/agents/AGENTS_CORE.md +37 -30
- package/assets/skills/context_development_engineer/SKILL.md +14 -9
- package/assets/skills/context_product_plan/SKILL.md +13 -8
- package/assets/skills/context_surface_contract/SKILL.md +27 -19
- package/assets/skills/context_uiux_design/SKILL.md +13 -8
- package/assets/skills/superpowers-long-task/SKILL.md +113 -25
- package/dist/commands/index.js +9 -3
- package/dist/commands/validate.js +1 -1
- package/dist/lib/plan-acceptance-evidence.d.ts +1 -0
- package/dist/lib/plan-acceptance-evidence.js +68 -0
- package/dist/lib/plan-acceptance-json.d.ts +15 -0
- package/dist/lib/plan-acceptance-json.js +129 -0
- package/dist/lib/plan-acceptance-validator.d.ts +2 -0
- package/dist/lib/plan-acceptance-validator.js +190 -0
- package/dist/lib/plan-contract-validator.d.ts +2 -0
- package/dist/lib/plan-contract-validator.js +127 -0
- package/dist/lib/plan-validator-common.d.ts +24 -0
- package/dist/lib/plan-validator-common.js +196 -0
- package/dist/lib/validators.d.ts +1 -1
- package/dist/lib/validators.js +8 -4
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isOutOfScope } from "./plan-acceptance-json.js";
|
|
2
|
+
import { isBlankish, primitiveText, valuesAsArray } from "./plan-validator-common.js";
|
|
3
|
+
const BLOCKING_AUDITOR_STATUSES = new Set(["partial", "blocked", "invalidated"]);
|
|
4
|
+
export function assertExternalReviewerFields(label, row, evidenceText, errors) {
|
|
5
|
+
if (hasUnresolvedMissingRequiredLayers(row)) {
|
|
6
|
+
errors.push(`${label} is complete but missing_required_layers is not empty`);
|
|
7
|
+
}
|
|
8
|
+
const driftSeverity = String(row.drift_severity ?? row.driftSeverity ?? "").trim().toLowerCase();
|
|
9
|
+
if (driftSeverity === "material" || driftSeverity === "critical") {
|
|
10
|
+
errors.push(`${label} is complete but drift_severity is ${driftSeverity}`);
|
|
11
|
+
}
|
|
12
|
+
if (siblingSubstitutionUsed(row) && !hasSiblingSubstitutionApproval(row)) {
|
|
13
|
+
errors.push(`${label} is complete but sibling_substitution_used without approval`);
|
|
14
|
+
}
|
|
15
|
+
const auditorStatus = String(row.auditor_status ?? row.auditorStatus ?? "").trim().toLowerCase();
|
|
16
|
+
if (BLOCKING_AUDITOR_STATUSES.has(auditorStatus)) {
|
|
17
|
+
errors.push(`${label} is complete but auditor_status is ${auditorStatus}`);
|
|
18
|
+
}
|
|
19
|
+
if (hasOnlySelfCertifyingEvidence(evidenceText)) {
|
|
20
|
+
errors.push(`${label} is complete but fresh_evidence contains only summary or self-certifying evidence`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function hasUnresolvedMissingRequiredLayers(row) {
|
|
24
|
+
const value = row.missing_required_layers ?? row.missingRequiredLayers ?? row.missing_proof_layers;
|
|
25
|
+
if (isBlankish(value)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.some((item) => !isBlankish(item) && !isStructuredLayerNa(item));
|
|
30
|
+
}
|
|
31
|
+
return !isStructuredLayerNa(value);
|
|
32
|
+
}
|
|
33
|
+
function isStructuredLayerNa(value) {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const object = value;
|
|
38
|
+
const text = primitiveText(object);
|
|
39
|
+
const hasNaStatus = isOutOfScope(object) || /\bout[_ -]?of[_ -]?scope[_ -]?NA\b/i.test(text);
|
|
40
|
+
const hasSource = !isBlankish(object.scope_source) ||
|
|
41
|
+
!isBlankish(object.approval_source) ||
|
|
42
|
+
!isBlankish(object.source_reference) ||
|
|
43
|
+
!isBlankish(object.sourceReference);
|
|
44
|
+
return hasNaStatus && hasSource;
|
|
45
|
+
}
|
|
46
|
+
function siblingSubstitutionUsed(row) {
|
|
47
|
+
const value = row.sibling_substitution_used ?? row.siblingSubstitutionUsed;
|
|
48
|
+
if (value === true) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const text = primitiveText(value);
|
|
52
|
+
if (/\b(false|no|none|not used|not_used)\b/i.test(text)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return /\b(true|yes|used|present)\b/i.test(text);
|
|
56
|
+
}
|
|
57
|
+
function hasSiblingSubstitutionApproval(row) {
|
|
58
|
+
return (!isBlankish(row.sibling_substitution_approval_source) ||
|
|
59
|
+
!isBlankish(row.substitution_approval_source) ||
|
|
60
|
+
/\b(approved|explicitly allowed|out[_ -]?of[_ -]?scope[_ -]?NA)\b/i.test(primitiveText([row.sibling_substitution_approved, row.substitution_approval])));
|
|
61
|
+
}
|
|
62
|
+
function hasOnlySelfCertifyingEvidence(text) {
|
|
63
|
+
const entries = valuesAsArray(text);
|
|
64
|
+
return entries.length > 0 && entries.every(isSelfCertifyingEvidence);
|
|
65
|
+
}
|
|
66
|
+
function isSelfCertifyingEvidence(text) {
|
|
67
|
+
return /\b(local audit|audit says|plan[- ]conformance(?: matrix)?|matrix row|final[- ]acceptance[- ]verdict|final verdict|verdict row|subagent summary|agent summary|reviewer summary|validator pass|validate-plan-acceptance(?: passed| pass)?|green check|final result card)\b/i.test(text);
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const MATRIX_STATUSES: Set<string>;
|
|
2
|
+
export declare const AC_STATUSES: Set<string>;
|
|
3
|
+
export declare const NON_COMPLETE_MATRIX: Set<string>;
|
|
4
|
+
export declare const NON_COMPLETE_AC: Set<string>;
|
|
5
|
+
export declare function findJsonFile(targetDir: string, marker: string): Promise<string | undefined>;
|
|
6
|
+
export declare function readJson(file: string, errors: string[]): Promise<unknown>;
|
|
7
|
+
export declare function findRows(value: unknown, preferredKeys: string[]): Record<string, unknown>[];
|
|
8
|
+
export declare function overallStatus(value: unknown): string;
|
|
9
|
+
export declare function statusOf(row: Record<string, unknown>): string;
|
|
10
|
+
export declare function isOutOfScope(row: Record<string, unknown>): boolean;
|
|
11
|
+
export declare function contextDeltaRequired(value: unknown): boolean;
|
|
12
|
+
export declare function isSurfaceConformanceRow(row: Record<string, unknown>): boolean;
|
|
13
|
+
export declare function hasExplicitNoTestScope(row: Record<string, unknown>): boolean;
|
|
14
|
+
export declare function assertSurfaceConformance(row: Record<string, unknown>, label: string, errors: string[]): void;
|
|
15
|
+
export declare function assertStructuredNa(row: Record<string, unknown>, label: string, errors: string[]): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readText } from "./fs.js";
|
|
4
|
+
import { isBlankish, primitiveText, valuesAsArray } from "./plan-validator-common.js";
|
|
5
|
+
export const MATRIX_STATUSES = new Set([
|
|
6
|
+
"complete",
|
|
7
|
+
"partial",
|
|
8
|
+
"sampled_only",
|
|
9
|
+
"not_implemented",
|
|
10
|
+
"blocked",
|
|
11
|
+
"scope_changed_requires_user_approval",
|
|
12
|
+
"contradicted_by_current_state",
|
|
13
|
+
"out_of_scope_NA"
|
|
14
|
+
]);
|
|
15
|
+
export const AC_STATUSES = new Set(["complete", "partial", "blocked", "not_run", "invalidated", "out_of_scope_NA"]);
|
|
16
|
+
export const NON_COMPLETE_MATRIX = new Set([
|
|
17
|
+
"partial",
|
|
18
|
+
"sampled_only",
|
|
19
|
+
"not_implemented",
|
|
20
|
+
"blocked",
|
|
21
|
+
"scope_changed_requires_user_approval",
|
|
22
|
+
"contradicted_by_current_state"
|
|
23
|
+
]);
|
|
24
|
+
export const NON_COMPLETE_AC = new Set(["partial", "blocked", "not_run", "invalidated"]);
|
|
25
|
+
export async function findJsonFile(targetDir, marker) {
|
|
26
|
+
const entries = await fs.readdir(targetDir, { withFileTypes: true });
|
|
27
|
+
return entries
|
|
28
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name.includes(marker))
|
|
29
|
+
.map((entry) => path.join(targetDir, entry.name))
|
|
30
|
+
.sort()[0];
|
|
31
|
+
}
|
|
32
|
+
export async function readJson(file, errors) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(await readText(file));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
errors.push(`${file} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function findRows(value, preferredKeys) {
|
|
42
|
+
if (Array.isArray(value) && value.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const object = value;
|
|
49
|
+
for (const key of preferredKeys) {
|
|
50
|
+
const rows = findRows(object[key], []);
|
|
51
|
+
if (rows.length > 0) {
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Object.values(object).map((item) => findRows(item, [])).sort((a, b) => b.length - a.length)[0] ?? [];
|
|
56
|
+
}
|
|
57
|
+
export function overallStatus(value) {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
const object = value;
|
|
62
|
+
return String(object.overall_status ?? object.overallStatus ?? object.status ?? "").trim();
|
|
63
|
+
}
|
|
64
|
+
export function statusOf(row) {
|
|
65
|
+
return String(row.status ?? "").trim();
|
|
66
|
+
}
|
|
67
|
+
export function isOutOfScope(row) {
|
|
68
|
+
return statusOf(row) === "out_of_scope_NA" || /out[_ -]?of[_ -]?scope|n\/a|not applicable/i.test(primitiveText(row));
|
|
69
|
+
}
|
|
70
|
+
export function contextDeltaRequired(value) {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const object = value;
|
|
75
|
+
return [
|
|
76
|
+
object.context_delta,
|
|
77
|
+
object.contextDelta,
|
|
78
|
+
object.product_context_delta,
|
|
79
|
+
object.productContextDelta,
|
|
80
|
+
object.technical_context_delta,
|
|
81
|
+
object.technicalContextDelta
|
|
82
|
+
].some((item) => /\brequired\b/i.test(primitiveText(item)));
|
|
83
|
+
}
|
|
84
|
+
export function isSurfaceConformanceRow(row) {
|
|
85
|
+
return (/\b(product[_ -]?surface|surface|ia|information[_ -]?architecture|architecture[_ -]?migration|ui)\b/i.test(primitiveText(row.conformance_type)) ||
|
|
86
|
+
!isBlankish(row.owner_surface) ||
|
|
87
|
+
!isBlankish(row.forbidden_primary_surfaces) ||
|
|
88
|
+
!isBlankish(row.required_user_paths));
|
|
89
|
+
}
|
|
90
|
+
export function hasExplicitNoTestScope(row) {
|
|
91
|
+
return /\b(no[- ]?test|no automated test|test out of scope|tests? not required)\b/i.test(primitiveText([row.test_scope, row.no_test_scope, row.tests]));
|
|
92
|
+
}
|
|
93
|
+
export function assertSurfaceConformance(row, label, errors) {
|
|
94
|
+
if (isBlankish(row.owner_surface)) {
|
|
95
|
+
errors.push(`${label} is surface/architecture conformance but owner_surface is empty`);
|
|
96
|
+
}
|
|
97
|
+
if (isBlankish(row.required_user_paths)) {
|
|
98
|
+
errors.push(`${label} is surface/architecture conformance but required_user_paths is empty`);
|
|
99
|
+
}
|
|
100
|
+
if (isBlankish(row.real_page_evidence)) {
|
|
101
|
+
errors.push(`${label} is surface/architecture conformance but real_page_evidence is empty`);
|
|
102
|
+
}
|
|
103
|
+
if (isBlankish(row.context_fact_refs)) {
|
|
104
|
+
errors.push(`${label} is surface/architecture conformance but context_fact_refs is empty`);
|
|
105
|
+
}
|
|
106
|
+
if (!isBlankish(row.forbidden_primary_surfaces) && isBlankish(row.negative_surface_checks)) {
|
|
107
|
+
errors.push(`${label} declares forbidden_primary_surfaces but negative_surface_checks is empty`);
|
|
108
|
+
}
|
|
109
|
+
const userPathText = primitiveText([row.required_user_paths, row.primary_user_paths]);
|
|
110
|
+
for (const forbiddenSurface of valuesAsArray(row.forbidden_primary_surfaces)) {
|
|
111
|
+
if (userPathText.toLowerCase().includes(forbiddenSurface.toLowerCase())) {
|
|
112
|
+
errors.push(`${label} routes a required/primary user path through forbidden surface: ${forbiddenSurface}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (row.default_visibility_required === true && !mentionsDefaultVisibility(primitiveText(row.real_page_evidence))) {
|
|
116
|
+
errors.push(`${label} requires default visibility but real_page_evidence does not record default-visible proof`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function assertStructuredNa(row, label, errors) {
|
|
120
|
+
if (isBlankish(row.na_reason) && isBlankish(row.out_of_scope_reason)) {
|
|
121
|
+
errors.push(`${label} is out_of_scope_NA but lacks na_reason or out_of_scope_reason`);
|
|
122
|
+
}
|
|
123
|
+
if (isBlankish(row.scope_source) && isBlankish(row.approval_source) && isBlankish(row.source_reference)) {
|
|
124
|
+
errors.push(`${label} is out_of_scope_NA but lacks scope_source, approval_source or source_reference`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function mentionsDefaultVisibility(value) {
|
|
128
|
+
return /\b(default[- ]?visible|visible by default|primary entry|first-level|top-level|main entry)\b/i.test(value);
|
|
129
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { pathExists } from "./fs.js";
|
|
2
|
+
import { assertExternalReviewerFields } from "./plan-acceptance-evidence.js";
|
|
3
|
+
import { AC_STATUSES, MATRIX_STATUSES, NON_COMPLETE_AC, NON_COMPLETE_MATRIX, assertStructuredNa, assertSurfaceConformance, contextDeltaRequired, findJsonFile, findRows, hasExplicitNoTestScope, isOutOfScope, isSurfaceConformanceRow, overallStatus, readJson, statusOf } from "./plan-acceptance-json.js";
|
|
4
|
+
import { assertReferencedPathsExist, hasRealPageEvidence, isBlankish, isUiFacing, primitiveText, repoRelative, resolveInputDir, valuesAsArray, weakProofHit } from "./plan-validator-common.js";
|
|
5
|
+
export async function validatePlanAcceptance(projectRoot, args = []) {
|
|
6
|
+
const info = [];
|
|
7
|
+
const errors = [];
|
|
8
|
+
const targetDir = await resolveInputDir(projectRoot, args[0], "tmp/ty-context/plan-acceptance");
|
|
9
|
+
if (!(await pathExists(targetDir))) {
|
|
10
|
+
return { info, errors: [`plan acceptance directory is missing: ${repoRelative(projectRoot, targetDir)}`] };
|
|
11
|
+
}
|
|
12
|
+
const matrixFile = await findJsonFile(targetDir, "plan-conformance-matrix");
|
|
13
|
+
const verdictFile = await findJsonFile(targetDir, "final-acceptance-verdict");
|
|
14
|
+
if (!matrixFile) {
|
|
15
|
+
errors.push(`plan acceptance directory is missing *-plan-conformance-matrix.json`);
|
|
16
|
+
}
|
|
17
|
+
if (!verdictFile) {
|
|
18
|
+
errors.push(`plan acceptance directory is missing *-final-acceptance-verdict.json`);
|
|
19
|
+
}
|
|
20
|
+
if (!matrixFile || !verdictFile) {
|
|
21
|
+
return { info, errors };
|
|
22
|
+
}
|
|
23
|
+
const matrix = await readJson(matrixFile, errors);
|
|
24
|
+
const verdict = await readJson(verdictFile, errors);
|
|
25
|
+
if (matrix === undefined || verdict === undefined) {
|
|
26
|
+
return { info, errors };
|
|
27
|
+
}
|
|
28
|
+
const matrixRows = findRows(matrix, ["plan_items", "items", "matrix", "entries", "plan_conformance"]);
|
|
29
|
+
const verdictRows = findRows(verdict, ["acceptance_items", "ac_verdicts", "verdicts", "items", "entries", "acs"]);
|
|
30
|
+
await validateMatrixRows(projectRoot, matrixRows, overallStatus(matrix), errors);
|
|
31
|
+
await validateVerdictRows(projectRoot, verdictRows, overallStatus(verdict), errors);
|
|
32
|
+
validateCrossReferences(matrixRows, verdictRows, errors);
|
|
33
|
+
validateContextFactReferences(matrix, verdict, matrixRows, verdictRows, errors);
|
|
34
|
+
info.push(`checked plan acceptance ${repoRelative(projectRoot, targetDir)} matrix_rows=${matrixRows.length} verdict_rows=${verdictRows.length}`);
|
|
35
|
+
if (errors.length === 0) {
|
|
36
|
+
info.push("Plan acceptance artifact consistency passed");
|
|
37
|
+
}
|
|
38
|
+
return { info, errors };
|
|
39
|
+
}
|
|
40
|
+
async function validateMatrixRows(projectRoot, rows, overall, errors) {
|
|
41
|
+
if (rows.length === 0) {
|
|
42
|
+
errors.push("plan-conformance matrix has no trace rows");
|
|
43
|
+
}
|
|
44
|
+
for (const [index, row] of rows.entries()) {
|
|
45
|
+
const label = `plan-conformance matrix row ${index + 1}`;
|
|
46
|
+
const status = statusOf(row);
|
|
47
|
+
if (!MATRIX_STATUSES.has(status)) {
|
|
48
|
+
errors.push(`${label} has unsupported status: ${status || "<empty>"}`);
|
|
49
|
+
}
|
|
50
|
+
if (status === "out_of_scope_NA") {
|
|
51
|
+
assertStructuredNa(row, label, errors);
|
|
52
|
+
}
|
|
53
|
+
if (overall === "complete" && NON_COMPLETE_MATRIX.has(status)) {
|
|
54
|
+
errors.push(`${label} is ${status} but overall_status is complete`);
|
|
55
|
+
}
|
|
56
|
+
if (status === "complete" && !isBlankish(row.missing_paths)) {
|
|
57
|
+
errors.push(`${label} is complete but missing_paths is not empty`);
|
|
58
|
+
}
|
|
59
|
+
if (status === "complete") {
|
|
60
|
+
if (isBlankish(row.plan_requirement)) {
|
|
61
|
+
errors.push(`${label} is complete but plan_requirement is empty`);
|
|
62
|
+
}
|
|
63
|
+
if (isBlankish(row.expected_surfaces)) {
|
|
64
|
+
errors.push(`${label} is complete but expected_surfaces is empty`);
|
|
65
|
+
}
|
|
66
|
+
if (isBlankish(row.implemented_paths)) {
|
|
67
|
+
errors.push(`${label} is complete but implemented_paths is empty`);
|
|
68
|
+
}
|
|
69
|
+
if (isBlankish(row.tests) && !hasExplicitNoTestScope(row)) {
|
|
70
|
+
errors.push(`${label} is complete but tests is empty and no explicit no-test scope is recorded`);
|
|
71
|
+
}
|
|
72
|
+
if (isBlankish(row.runtime_evidence) && isBlankish(row.artifact_evidence) && isBlankish(row.real_page_evidence)) {
|
|
73
|
+
errors.push(`${label} is complete but has no runtime, artifact or real-page evidence`);
|
|
74
|
+
}
|
|
75
|
+
if (isBlankish(row.scope_assessment)) {
|
|
76
|
+
errors.push(`${label} is complete but scope_assessment is empty`);
|
|
77
|
+
}
|
|
78
|
+
if (isBlankish(row.drift)) {
|
|
79
|
+
errors.push(`${label} is complete but drift is empty`);
|
|
80
|
+
}
|
|
81
|
+
assertExternalReviewerFields(label, row, primitiveText([row.runtime_evidence, row.artifact_evidence, row.real_page_evidence, row.fresh_evidence]), errors);
|
|
82
|
+
const weak = weakProofHit(primitiveText(row));
|
|
83
|
+
if (weak) {
|
|
84
|
+
errors.push(`${label} is complete but contains weak-proof language matching /${weak}/`);
|
|
85
|
+
}
|
|
86
|
+
if (isUiFacing(primitiveText([row.expected_surfaces, row.plan_requirement, row.conformance_type]))) {
|
|
87
|
+
const realPageEvidence = primitiveText([
|
|
88
|
+
row.real_page_evidence,
|
|
89
|
+
row.user_path_evidence,
|
|
90
|
+
row.fresh_evidence,
|
|
91
|
+
row.runtime_evidence,
|
|
92
|
+
row.artifact_evidence
|
|
93
|
+
]);
|
|
94
|
+
if (!hasRealPageEvidence(realPageEvidence)) {
|
|
95
|
+
errors.push(`${label} is UI/surface-facing but lacks real_page_evidence`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (isSurfaceConformanceRow(row)) {
|
|
99
|
+
assertSurfaceConformance(row, label, errors);
|
|
100
|
+
}
|
|
101
|
+
await assertReferencedPathsExist(projectRoot, label, primitiveText([
|
|
102
|
+
row.implemented_paths,
|
|
103
|
+
row.tests,
|
|
104
|
+
row.runtime_evidence,
|
|
105
|
+
row.artifact_evidence,
|
|
106
|
+
row.real_page_evidence,
|
|
107
|
+
row.negative_surface_checks,
|
|
108
|
+
row.context_fact_refs
|
|
109
|
+
]), errors);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function validateVerdictRows(projectRoot, rows, overall, errors) {
|
|
114
|
+
if (rows.length === 0) {
|
|
115
|
+
errors.push("final acceptance verdict has no AC rows");
|
|
116
|
+
}
|
|
117
|
+
for (const [index, row] of rows.entries()) {
|
|
118
|
+
const label = `final acceptance verdict row ${index + 1}`;
|
|
119
|
+
const status = statusOf(row);
|
|
120
|
+
if (!AC_STATUSES.has(status)) {
|
|
121
|
+
errors.push(`${label} has unsupported status: ${status || "<empty>"}`);
|
|
122
|
+
}
|
|
123
|
+
if (status === "out_of_scope_NA") {
|
|
124
|
+
assertStructuredNa(row, label, errors);
|
|
125
|
+
}
|
|
126
|
+
if (overall === "complete" && NON_COMPLETE_AC.has(status)) {
|
|
127
|
+
errors.push(`${label} is ${status} but overall_status is complete`);
|
|
128
|
+
}
|
|
129
|
+
if (status === "complete" && isBlankish(row.fresh_evidence)) {
|
|
130
|
+
errors.push(`${label} is complete but fresh_evidence is empty`);
|
|
131
|
+
}
|
|
132
|
+
if (status === "complete" && isBlankish(row.required_evidence)) {
|
|
133
|
+
errors.push(`${label} is complete but required_evidence is empty`);
|
|
134
|
+
}
|
|
135
|
+
if (status === "complete" && isBlankish(row.decision)) {
|
|
136
|
+
errors.push(`${label} is complete but decision is empty`);
|
|
137
|
+
}
|
|
138
|
+
if (status === "complete" && !isBlankish(row.missing_evidence)) {
|
|
139
|
+
errors.push(`${label} is complete but missing_evidence is not empty`);
|
|
140
|
+
}
|
|
141
|
+
if (status === "complete" && !isBlankish(row.contradictions)) {
|
|
142
|
+
errors.push(`${label} is complete but contradictions is not empty`);
|
|
143
|
+
}
|
|
144
|
+
if (status === "complete") {
|
|
145
|
+
const text = primitiveText(row);
|
|
146
|
+
assertExternalReviewerFields(label, row, primitiveText(row.fresh_evidence), errors);
|
|
147
|
+
const weak = weakProofHit(text);
|
|
148
|
+
if (weak) {
|
|
149
|
+
errors.push(`${label} is complete but contains weak-proof language matching /${weak}/`);
|
|
150
|
+
}
|
|
151
|
+
if (isUiFacing(text) && !isOutOfScope(row) && !hasRealPageEvidence(primitiveText(row.fresh_evidence))) {
|
|
152
|
+
errors.push(`${label} is UI-facing but lacks fresh real-page evidence or explicit N/A`);
|
|
153
|
+
}
|
|
154
|
+
await assertReferencedPathsExist(projectRoot, label, primitiveText([row.fresh_evidence, row.context_fact_refs]), errors);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateCrossReferences(matrixRows, verdictRows, errors) {
|
|
159
|
+
const planIds = new Set(matrixRows.map((row) => String(row.plan_item_id ?? row.id ?? "")).filter(Boolean));
|
|
160
|
+
const acIds = new Set(verdictRows.map((row) => String(row.ac_id ?? row.id ?? row.acceptance_item ?? "")).filter(Boolean));
|
|
161
|
+
let checked = 0;
|
|
162
|
+
for (const [index, row] of matrixRows.entries()) {
|
|
163
|
+
for (const acId of valuesAsArray(row.acceptance_ids ?? row.ac_ids)) {
|
|
164
|
+
checked += 1;
|
|
165
|
+
if (!acIds.has(acId)) {
|
|
166
|
+
errors.push(`plan-conformance matrix row ${index + 1} references unknown AC id: ${acId}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [index, row] of verdictRows.entries()) {
|
|
171
|
+
for (const planId of valuesAsArray(row.related_plan_item_ids ?? row.plan_item_ids)) {
|
|
172
|
+
checked += 1;
|
|
173
|
+
if (!planIds.has(planId)) {
|
|
174
|
+
errors.push(`final acceptance verdict row ${index + 1} references unknown plan item id: ${planId}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (matrixRows.length > 0 && verdictRows.length > 0 && checked === 0) {
|
|
179
|
+
errors.push("plan acceptance artifacts must include acceptance_ids or related_plan_item_ids cross references");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function validateContextFactReferences(matrix, verdict, matrixRows, verdictRows, errors) {
|
|
183
|
+
if (!contextDeltaRequired(matrix) && !contextDeltaRequired(verdict)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const rows = [...matrixRows, ...verdictRows];
|
|
187
|
+
if (!rows.some((row) => !isBlankish(row.context_fact_refs))) {
|
|
188
|
+
errors.push("Context Delta is required but matrix/verdict rows do not cite context_fact_refs");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { cell, hasRealPageEvidence, isBlankish, isRuntimeFacing, isUiFacing, parseRequiredTable, readRequiredFile, repoRelative, resolveInputFile, weakProofHit, assertReferencedPathsExist } from "./plan-validator-common.js";
|
|
2
|
+
const SOURCE_HEADERS = [
|
|
3
|
+
"Source item",
|
|
4
|
+
"Durable constraint",
|
|
5
|
+
"Type",
|
|
6
|
+
"Existing Context Hit",
|
|
7
|
+
"Context action",
|
|
8
|
+
"Owning Context",
|
|
9
|
+
"Coverage status"
|
|
10
|
+
];
|
|
11
|
+
const BINDING_HEADERS = [
|
|
12
|
+
"Context fact",
|
|
13
|
+
"Implementation obligation",
|
|
14
|
+
"Expected surfaces",
|
|
15
|
+
"Implemented paths",
|
|
16
|
+
"Forbidden shortcuts",
|
|
17
|
+
"Verification path",
|
|
18
|
+
"Binding status"
|
|
19
|
+
];
|
|
20
|
+
const SOURCE_STATUSES = new Set([
|
|
21
|
+
"covered",
|
|
22
|
+
"new_context_required",
|
|
23
|
+
"context_updated",
|
|
24
|
+
"task_local_only",
|
|
25
|
+
"out_of_scope_explicit",
|
|
26
|
+
"needs_user_decision",
|
|
27
|
+
"under_scoped"
|
|
28
|
+
]);
|
|
29
|
+
const BINDING_STATUSES = new Set([
|
|
30
|
+
"bound",
|
|
31
|
+
"partial",
|
|
32
|
+
"missing",
|
|
33
|
+
"blocked",
|
|
34
|
+
"out_of_scope_explicit",
|
|
35
|
+
"needs_user_decision",
|
|
36
|
+
"contradicted_by_current_state"
|
|
37
|
+
]);
|
|
38
|
+
const UNRESOLVED_SOURCE = new Set(["new_context_required", "needs_user_decision", "under_scoped"]);
|
|
39
|
+
const NON_BOUND = new Set(["partial", "missing", "blocked", "needs_user_decision", "contradicted_by_current_state"]);
|
|
40
|
+
export async function validatePlanContract(projectRoot, args = []) {
|
|
41
|
+
const info = [];
|
|
42
|
+
const errors = [];
|
|
43
|
+
const planPath = await resolveInputFile(projectRoot, args[0], "plan.md");
|
|
44
|
+
const content = await readRequiredFile(projectRoot, planPath, "plan contract", errors);
|
|
45
|
+
if (content === undefined) {
|
|
46
|
+
return { info, errors };
|
|
47
|
+
}
|
|
48
|
+
const sourceTable = parseRequiredTable(content, "Source-to-Context Coverage", SOURCE_HEADERS, "Source-to-Context Coverage", errors);
|
|
49
|
+
if (sourceTable?.headers.includes("implementation constraint")) {
|
|
50
|
+
errors.push("Source-to-Context Coverage must not include Implementation constraint; use Context-to-Implementation Binding instead");
|
|
51
|
+
}
|
|
52
|
+
const bindingTable = parseRequiredTable(content, "Context-to-Implementation Binding", BINDING_HEADERS, "Context-to-Implementation Binding", errors);
|
|
53
|
+
if (sourceTable) {
|
|
54
|
+
for (const row of sourceTable.rows) {
|
|
55
|
+
const status = cell(row, "Coverage status");
|
|
56
|
+
const label = `Source-to-Context Coverage row ${row.index}`;
|
|
57
|
+
if (!SOURCE_STATUSES.has(status)) {
|
|
58
|
+
errors.push(`${label} has unsupported Coverage status: ${status || "<empty>"}`);
|
|
59
|
+
}
|
|
60
|
+
if (UNRESOLVED_SOURCE.has(status)) {
|
|
61
|
+
errors.push(`${label} is unresolved (${status}) and cannot pass final plan-contract validation`);
|
|
62
|
+
}
|
|
63
|
+
if (status === "covered" && isBlankish(cell(row, "Existing Context Hit"))) {
|
|
64
|
+
errors.push(`${label} is covered but has no Existing Context Hit`);
|
|
65
|
+
}
|
|
66
|
+
if (status === "context_updated" && isBlankish(cell(row, "Owning Context"))) {
|
|
67
|
+
errors.push(`${label} is context_updated but has no Owning Context`);
|
|
68
|
+
}
|
|
69
|
+
await assertReferencedPathsExist(projectRoot, label, `${cell(row, "Existing Context Hit")} ${cell(row, "Owning Context")}`, errors);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (bindingTable) {
|
|
73
|
+
for (const row of bindingTable.rows) {
|
|
74
|
+
const status = cell(row, "Binding status");
|
|
75
|
+
const label = `Context-to-Implementation Binding row ${row.index}`;
|
|
76
|
+
const expectedSurfaces = cell(row, "Expected surfaces");
|
|
77
|
+
const implementedPaths = cell(row, "Implemented paths");
|
|
78
|
+
const verificationPath = cell(row, "Verification path");
|
|
79
|
+
const evidenceText = `${implementedPaths} ${verificationPath}`;
|
|
80
|
+
if (!BINDING_STATUSES.has(status)) {
|
|
81
|
+
errors.push(`${label} has unsupported Binding status: ${status || "<empty>"}`);
|
|
82
|
+
}
|
|
83
|
+
if (NON_BOUND.has(status)) {
|
|
84
|
+
errors.push(`${label} is ${status} and cannot pass final plan-contract validation`);
|
|
85
|
+
}
|
|
86
|
+
if (status === "bound") {
|
|
87
|
+
if (isBlankish(expectedSurfaces)) {
|
|
88
|
+
errors.push(`${label} is bound but has no Expected surfaces`);
|
|
89
|
+
}
|
|
90
|
+
if (isBlankish(implementedPaths)) {
|
|
91
|
+
errors.push(`${label} is bound but has no Implemented paths`);
|
|
92
|
+
}
|
|
93
|
+
if (isBlankish(verificationPath)) {
|
|
94
|
+
errors.push(`${label} is bound but has no Verification path`);
|
|
95
|
+
}
|
|
96
|
+
const weak = weakProofHit(`${cell(row, "Context fact")} ${cell(row, "Implementation obligation")} ${expectedSurfaces} ${evidenceText}`);
|
|
97
|
+
if (weak) {
|
|
98
|
+
errors.push(`${label} is bound but contains weak-proof language matching /${weak}/`);
|
|
99
|
+
}
|
|
100
|
+
if (isUiFacing(`${expectedSurfaces} ${cell(row, "Implementation obligation")}`) && !hasRealPageEvidence(evidenceText)) {
|
|
101
|
+
errors.push(`${label} is UI/surface-facing but lacks real page, route, browser or screenshot evidence`);
|
|
102
|
+
}
|
|
103
|
+
if (isRuntimeFacing(expectedSurfaces) && !isRuntimeFacing(evidenceText)) {
|
|
104
|
+
errors.push(`${label} expects runtime/API/worker coverage but implemented evidence does not name that surface`);
|
|
105
|
+
}
|
|
106
|
+
assertForbiddenShortcutsNotUsed(row, evidenceText, label, errors);
|
|
107
|
+
}
|
|
108
|
+
await assertReferencedPathsExist(projectRoot, label, `${cell(row, "Context fact")} ${implementedPaths} ${verificationPath}`, errors);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
info.push(`checked plan contract ${repoRelative(projectRoot, planPath)} source_rows=${sourceTable?.rows.length ?? 0} binding_rows=${bindingTable?.rows.length ?? 0}`);
|
|
112
|
+
if (errors.length === 0) {
|
|
113
|
+
info.push("Plan contract validation passed");
|
|
114
|
+
}
|
|
115
|
+
return { info, errors };
|
|
116
|
+
}
|
|
117
|
+
function assertForbiddenShortcutsNotUsed(row, evidenceText, label, errors) {
|
|
118
|
+
const shortcuts = cell(row, "Forbidden shortcuts")
|
|
119
|
+
.split(/[,;\n]/)
|
|
120
|
+
.map((item) => item.trim())
|
|
121
|
+
.filter((item) => item && !isBlankish(item));
|
|
122
|
+
for (const shortcut of shortcuts) {
|
|
123
|
+
if (evidenceText.toLowerCase().includes(shortcut.toLowerCase())) {
|
|
124
|
+
errors.push(`${label} is bound but its evidence uses forbidden shortcut: ${shortcut}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface PlanTableRow {
|
|
2
|
+
index: number;
|
|
3
|
+
line: number;
|
|
4
|
+
cells: Record<string, string>;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedPlanTable {
|
|
8
|
+
rows: PlanTableRow[];
|
|
9
|
+
headers: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function resolveInputFile(projectRoot: string, value: string | undefined, defaultPath: string): Promise<string>;
|
|
12
|
+
export declare function resolveInputDir(projectRoot: string, value: string | undefined, defaultPath: string): Promise<string>;
|
|
13
|
+
export declare function repoRelative(projectRoot: string, file: string): string;
|
|
14
|
+
export declare function readRequiredFile(projectRoot: string, file: string, label: string, errors: string[]): Promise<string | undefined>;
|
|
15
|
+
export declare function parseRequiredTable(content: string, heading: string, expectedHeaders: string[], label: string, errors: string[]): ParsedPlanTable | undefined;
|
|
16
|
+
export declare function cell(row: PlanTableRow, header: string): string;
|
|
17
|
+
export declare function isBlankish(value: unknown): boolean;
|
|
18
|
+
export declare function weakProofHit(text: string): string | undefined;
|
|
19
|
+
export declare function assertReferencedPathsExist(projectRoot: string, label: string, text: string, errors: string[]): Promise<void>;
|
|
20
|
+
export declare function primitiveText(value: unknown): string;
|
|
21
|
+
export declare function valuesAsArray(value: unknown): string[];
|
|
22
|
+
export declare function hasRealPageEvidence(text: string): boolean;
|
|
23
|
+
export declare function isUiFacing(text: string): boolean;
|
|
24
|
+
export declare function isRuntimeFacing(text: string): boolean;
|