@opengsd/gsd-core 1.6.0-rc.2 → 1.6.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/.claude-plugin/plugin.json +2 -1
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +2 -0
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-doc-classifier.md +2 -0
- package/agents/gsd-doc-synthesizer.md +2 -0
- package/agents/gsd-domain-researcher.md +2 -0
- package/agents/gsd-eval-auditor.md +6 -9
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-planner.md +8 -57
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-security-auditor.md +37 -18
- package/agents/gsd-ui-researcher.md +2 -0
- package/bin/install.js +370 -18
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +46 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
- package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
- package/gsd-core/bin/lib/capability-registry.cjs +96 -83
- package/gsd-core/bin/lib/capability-validator.cjs +22 -0
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
- package/gsd-core/bin/lib/command-aliases.cjs +10 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
- package/gsd-core/bin/lib/config-schema.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +67 -24
- package/gsd-core/bin/lib/coverage.cjs +464 -0
- package/gsd-core/bin/lib/decisions.cjs +27 -0
- package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
- package/gsd-core/bin/lib/eval.cjs +60 -0
- package/gsd-core/bin/lib/frontmatter.cjs +132 -13
- package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
- package/gsd-core/bin/lib/init.cjs +139 -30
- package/gsd-core/bin/lib/install-profiles.cjs +6 -3
- package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
- package/gsd-core/bin/lib/io.cjs +1 -0
- package/gsd-core/bin/lib/phase.cjs +16 -2
- package/gsd-core/bin/lib/plan-scan.cjs +2 -2
- package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
- package/gsd-core/bin/lib/profile-output.cjs +18 -6
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +10 -0
- package/gsd-core/bin/lib/state.cjs +398 -60
- package/gsd-core/bin/lib/surface.cjs +42 -10
- package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
- package/gsd-core/bin/lib/update-context.cjs +2 -2
- package/gsd-core/bin/lib/verification.cjs +67 -6
- package/gsd-core/bin/lib/verify.cjs +8 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/references/planner-guidance.md +66 -0
- package/gsd-core/references/planning-config.md +2 -2
- package/gsd-core/references/security-asvs-levels.md +27 -0
- package/gsd-core/references/untrusted-input-boundary.md +13 -0
- package/gsd-core/templates/SECURITY.md +6 -4
- package/gsd-core/templates/summary-complex.md +4 -0
- package/gsd-core/templates/summary-minimal.md +3 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +41 -0
- package/gsd-core/workflows/autonomous.md +53 -46
- package/gsd-core/workflows/complete-milestone.md +27 -8
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +5 -0
- package/gsd-core/workflows/manager.md +17 -7
- package/gsd-core/workflows/new-project.md +82 -16
- package/gsd-core/workflows/plan-phase.md +15 -0
- package/gsd-core/workflows/profile-user.md +6 -2
- package/gsd-core/workflows/progress.md +37 -4
- package/gsd-core/workflows/quick.md +3 -1
- package/gsd-core/workflows/secure-phase.md +13 -7
- package/gsd-core/workflows/ship.md +3 -1
- package/gsd-core/workflows/spec-phase.md +3 -1
- package/gsd-core/workflows/transition.md +14 -12
- package/gsd-core/workflows/ui-review.md +2 -6
- package/gsd-core/workflows/verify-work.md +74 -1
- package/hooks/dist/gsd-read-injection-scanner.js +49 -25
- package/hooks/gsd-read-injection-scanner.js +49 -25
- package/hooks/hooks.json +1 -1
- package/package.json +4 -2
- package/scripts/check-alias-drift.cjs +5 -0
- package/scripts/gen-plugin-skills.cjs +117 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/prompt-injection-scan.sh +9 -0
- package/scripts/release-notes/conventional-title.cjs +88 -0
- package/scripts/release-notes/format-github-release-notes.cjs +4 -3
- package/skills/gsd-add-tests/SKILL.md +38 -0
- package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
- package/skills/gsd-audit-fix/SKILL.md +33 -0
- package/skills/gsd-audit-milestone/SKILL.md +37 -0
- package/skills/gsd-audit-uat/SKILL.md +25 -0
- package/skills/gsd-autonomous/SKILL.md +51 -0
- package/skills/gsd-capture/SKILL.md +67 -0
- package/skills/gsd-cleanup/SKILL.md +24 -0
- package/skills/gsd-code-review/SKILL.md +59 -0
- package/skills/gsd-complete-milestone/SKILL.md +142 -0
- package/skills/gsd-config/SKILL.md +56 -0
- package/skills/gsd-debug/SKILL.md +53 -0
- package/skills/gsd-discuss-phase/SKILL.md +77 -0
- package/skills/gsd-docs-update/SKILL.md +49 -0
- package/skills/gsd-eval-review/SKILL.md +33 -0
- package/skills/gsd-execute-phase/SKILL.md +65 -0
- package/skills/gsd-explore/SKILL.md +28 -0
- package/skills/gsd-extract-learnings/SKILL.md +22 -0
- package/skills/gsd-fast/SKILL.md +31 -0
- package/skills/gsd-forensics/SKILL.md +56 -0
- package/skills/gsd-graphify/SKILL.md +204 -0
- package/skills/gsd-health/SKILL.md +31 -0
- package/skills/gsd-help/SKILL.md +29 -0
- package/skills/gsd-import/SKILL.md +46 -0
- package/skills/gsd-inbox/SKILL.md +39 -0
- package/skills/gsd-ingest-docs/SKILL.md +43 -0
- package/skills/gsd-manager/SKILL.md +45 -0
- package/skills/gsd-map-codebase/SKILL.md +83 -0
- package/skills/gsd-mempalace-capture/SKILL.md +71 -0
- package/skills/gsd-mempalace-recall/SKILL.md +102 -0
- package/skills/gsd-milestone-summary/SKILL.md +51 -0
- package/skills/gsd-mvp-phase/SKILL.md +45 -0
- package/skills/gsd-new-milestone/SKILL.md +45 -0
- package/skills/gsd-new-project/SKILL.md +47 -0
- package/skills/gsd-ns-context/SKILL.md +24 -0
- package/skills/gsd-ns-ideate/SKILL.md +23 -0
- package/skills/gsd-ns-manage/SKILL.md +35 -0
- package/skills/gsd-ns-project/SKILL.md +26 -0
- package/skills/gsd-ns-review/SKILL.md +28 -0
- package/skills/gsd-ns-workflow/SKILL.md +33 -0
- package/skills/gsd-pause-work/SKILL.md +43 -0
- package/skills/gsd-phase/SKILL.md +57 -0
- package/skills/gsd-plan-phase/SKILL.md +63 -0
- package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
- package/skills/gsd-pr-branch/SKILL.md +26 -0
- package/skills/gsd-profile-user/SKILL.md +47 -0
- package/skills/gsd-progress/SKILL.md +49 -0
- package/skills/gsd-quick/SKILL.md +174 -0
- package/skills/gsd-resume-work/SKILL.md +31 -0
- package/skills/gsd-review/SKILL.md +42 -0
- package/skills/gsd-review-backlog/SKILL.md +63 -0
- package/skills/gsd-secure-phase/SKILL.md +36 -0
- package/skills/gsd-settings/SKILL.md +29 -0
- package/skills/gsd-ship/SKILL.md +24 -0
- package/skills/gsd-sketch/SKILL.md +60 -0
- package/skills/gsd-spec-phase/SKILL.md +63 -0
- package/skills/gsd-spike/SKILL.md +57 -0
- package/skills/gsd-stats/SKILL.md +20 -0
- package/skills/gsd-surface/SKILL.md +162 -0
- package/skills/gsd-thread/SKILL.md +24 -0
- package/skills/gsd-ui-phase/SKILL.md +35 -0
- package/skills/gsd-ui-review/SKILL.md +33 -0
- package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
- package/skills/gsd-undo/SKILL.md +35 -0
- package/skills/gsd-update/SKILL.md +50 -0
- package/skills/gsd-validate-phase/SKILL.md +36 -0
- package/skills/gsd-verify-work/SKILL.md +39 -0
- package/skills/gsd-workspace/SKILL.md +53 -0
- package/skills/gsd-workstreams/SKILL.md +70 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Coverage metadata — deterministic UAT routing (#1602)
|
|
4
|
+
*
|
|
5
|
+
* Parses the optional `coverage:` block in a SUMMARY.md frontmatter, validates
|
|
6
|
+
* each deliverable entry against the coverage schema, and classifies each into
|
|
7
|
+
* `auto_passed` (deterministically covered — no human prompt) or `present`
|
|
8
|
+
* (a human UAT checkpoint is required).
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (see issue #1602, plus the Postel/Goodhart/Hyrum analysis):
|
|
11
|
+
* - Lenient parse, strict auto-pass. The parser NEVER throws on malformed
|
|
12
|
+
* input; a structurally surprising entry degrades to `present` + an error.
|
|
13
|
+
* - Fail-safe asymmetry. Auto-pass is the narrow, fully-proven case
|
|
14
|
+
* (strict-boolean `human_judgment:false` AND non-empty all-`pass`
|
|
15
|
+
* verification AND zero validation errors). Everything else is presented to
|
|
16
|
+
* the human. A false-negative is a redundant prompt (the status quo); a
|
|
17
|
+
* false-positive ships a bug UAT existed to catch.
|
|
18
|
+
* - Absent block ≠ empty block. No `coverage:` key → `mode: legacy` so the
|
|
19
|
+
* caller falls through to today's prose-based extraction (byte-identical for
|
|
20
|
+
* un-migrated phases). `coverage: []` → `mode: coverage`, zero entries.
|
|
21
|
+
*
|
|
22
|
+
* The classifier is deterministic code, not a prompt heuristic — the issue's
|
|
23
|
+
* central thesis. Tests assert on the frozen typed-IR surface below, not prose.
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
|
+
const io = require("./io.cjs");
|
|
32
|
+
const { output, error } = io;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
const coreUtils = require("./core-utils.cjs");
|
|
35
|
+
const { toPosixPath } = coreUtils;
|
|
36
|
+
const security_cjs_1 = require("./security.cjs");
|
|
37
|
+
// ─── Frozen typed-IR surface ────────────────────────────────────────────────
|
|
38
|
+
const MODE = Object.freeze({
|
|
39
|
+
COVERAGE: 'coverage',
|
|
40
|
+
LEGACY: 'legacy',
|
|
41
|
+
});
|
|
42
|
+
/** Why an entry was routed to the human path. Order of precedence below. */
|
|
43
|
+
const PRESENT_REASON = Object.freeze({
|
|
44
|
+
VALIDATION_FAILED: 'validation_failed',
|
|
45
|
+
HUMAN_JUDGMENT: 'human_judgment',
|
|
46
|
+
NO_VERIFICATION: 'no_verification',
|
|
47
|
+
VERIFICATION_NOT_PASSING: 'verification_not_passing',
|
|
48
|
+
});
|
|
49
|
+
/** Per-entry validation error codes. */
|
|
50
|
+
const ERROR_CODE = Object.freeze({
|
|
51
|
+
MISSING_ID: 'missing_id',
|
|
52
|
+
MISSING_DESCRIPTION: 'missing_description',
|
|
53
|
+
MISSING_HUMAN_JUDGMENT: 'missing_human_judgment',
|
|
54
|
+
INVALID_HUMAN_JUDGMENT: 'invalid_human_judgment',
|
|
55
|
+
MISSING_RATIONALE: 'missing_rationale',
|
|
56
|
+
DUPLICATE_ID: 'duplicate_id',
|
|
57
|
+
VERIFICATION_NOT_LIST: 'verification_not_list',
|
|
58
|
+
INVALID_KIND: 'invalid_kind',
|
|
59
|
+
INVALID_STATUS: 'invalid_status',
|
|
60
|
+
MISSING_REF: 'missing_ref',
|
|
61
|
+
MALFORMED_ENTRY: 'malformed_entry',
|
|
62
|
+
MALFORMED_BLOCK: 'malformed_block',
|
|
63
|
+
});
|
|
64
|
+
const VALID_KINDS = Object.freeze([
|
|
65
|
+
'unit', 'integration', 'e2e', 'automated_ui', 'manual_procedural', 'other',
|
|
66
|
+
]);
|
|
67
|
+
const VALID_STATUSES = Object.freeze(['pass', 'fail', 'unknown']);
|
|
68
|
+
// ─── YAML-subset block parser (scoped to the coverage schema) ────────────────
|
|
69
|
+
//
|
|
70
|
+
// `extractFrontmatter` (src/frontmatter.cts) flattens `- ` list items to
|
|
71
|
+
// scalars and cannot represent the coverage schema's list-of-maps-with-nested-
|
|
72
|
+
// list-of-maps. `parseMustHavesBlock` is the existing precedent for hand-rolling
|
|
73
|
+
// a focused parser for one schema; this is the same approach, one level deeper.
|
|
74
|
+
// We deliberately do NOT pull in a general YAML engine (no external deps in
|
|
75
|
+
// core; Greenspun's-tenth restraint).
|
|
76
|
+
function lineIndent(line) {
|
|
77
|
+
const m = /^( *)/.exec(line);
|
|
78
|
+
return m ? m[1].length : 0;
|
|
79
|
+
}
|
|
80
|
+
function isSignificant(line) {
|
|
81
|
+
return line.trim() !== '';
|
|
82
|
+
}
|
|
83
|
+
function parseScalar(raw) {
|
|
84
|
+
const t = raw.trim();
|
|
85
|
+
if (t === '')
|
|
86
|
+
return '';
|
|
87
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
88
|
+
return t.slice(1, -1);
|
|
89
|
+
}
|
|
90
|
+
if (t === 'true')
|
|
91
|
+
return true;
|
|
92
|
+
if (t === 'false')
|
|
93
|
+
return false;
|
|
94
|
+
if (t === 'null' || t === '~')
|
|
95
|
+
return null;
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
/** Parse a block of lines (all indented ≥ `indent`) into a value. */
|
|
99
|
+
function parseNode(lines, indent) {
|
|
100
|
+
const firstSig = lines.find(isSignificant);
|
|
101
|
+
if (firstSig === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
if (lineIndent(firstSig) === indent && /^ *-(?: |$)/.test(firstSig)) {
|
|
104
|
+
return parseSequence(lines, indent);
|
|
105
|
+
}
|
|
106
|
+
return parseMapping(lines, indent);
|
|
107
|
+
}
|
|
108
|
+
function parseSequence(lines, indent) {
|
|
109
|
+
const items = [];
|
|
110
|
+
// Item-start lines: at exactly `indent`, beginning with a dash.
|
|
111
|
+
const starts = [];
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
if (!isSignificant(lines[i]))
|
|
114
|
+
continue;
|
|
115
|
+
if (lineIndent(lines[i]) === indent && /^ *-(?: |$)/.test(lines[i]))
|
|
116
|
+
starts.push(i);
|
|
117
|
+
}
|
|
118
|
+
for (let k = 0; k < starts.length; k++) {
|
|
119
|
+
const start = starts[k];
|
|
120
|
+
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
|
121
|
+
const itemLines = lines.slice(start, end);
|
|
122
|
+
// Re-base the dash line: replace the `indent` + "- " prefix with spaces so
|
|
123
|
+
// the inline content aligns at `indent + 2` and parses as a normal node.
|
|
124
|
+
itemLines[0] = ' '.repeat(indent + 2) + itemLines[0].slice(indent + 2);
|
|
125
|
+
const itemFirst = itemLines.find(isSignificant);
|
|
126
|
+
const head = itemFirst ? itemFirst.trim() : '';
|
|
127
|
+
if (/^[\w-]+:(?: |$)/.test(head)) {
|
|
128
|
+
items.push(parseMapping(itemLines, indent + 2));
|
|
129
|
+
}
|
|
130
|
+
else if (head === '') {
|
|
131
|
+
items.push(null);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
items.push(parseScalar(head));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return items;
|
|
138
|
+
}
|
|
139
|
+
function parseMapping(lines, indent) {
|
|
140
|
+
const map = {};
|
|
141
|
+
let i = 0;
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
const line = lines[i];
|
|
144
|
+
if (!isSignificant(line) || lineIndent(line) !== indent) {
|
|
145
|
+
i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const km = /^[\w-]+:\s*(.*)$/.exec(line.trim());
|
|
149
|
+
if (!km) {
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const key = /^([\w-]+):/.exec(line.trim())[1];
|
|
154
|
+
const inlineVal = km[1];
|
|
155
|
+
if (inlineVal === '[]') {
|
|
156
|
+
setKey(map, key, []);
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
else if (inlineVal === '') {
|
|
160
|
+
// Nested block: following lines indented deeper than `indent`.
|
|
161
|
+
let j = i + 1;
|
|
162
|
+
while (j < lines.length && (!isSignificant(lines[j]) || lineIndent(lines[j]) > indent))
|
|
163
|
+
j++;
|
|
164
|
+
const block = lines.slice(i + 1, j);
|
|
165
|
+
const blockFirst = block.find(isSignificant);
|
|
166
|
+
if (blockFirst === undefined) {
|
|
167
|
+
setKey(map, key, null);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
setKey(map, key, parseNode(block, lineIndent(blockFirst)));
|
|
171
|
+
}
|
|
172
|
+
i = j;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
setKey(map, key, parseScalar(inlineVal));
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return map;
|
|
180
|
+
}
|
|
181
|
+
// Prototype-pollution-safe assignment (CodeQL js/prototype-pollution-utility:
|
|
182
|
+
// inline literal key guard at the write site).
|
|
183
|
+
function setKey(obj, key, value) {
|
|
184
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
185
|
+
return;
|
|
186
|
+
obj[key] = value;
|
|
187
|
+
}
|
|
188
|
+
// ─── Frontmatter region helpers ──────────────────────────────────────────────
|
|
189
|
+
function getFrontmatterYaml(content) {
|
|
190
|
+
const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
|
|
191
|
+
if (headerEnd === -1)
|
|
192
|
+
return null;
|
|
193
|
+
const closingLineStart = content.indexOf('\n---', headerEnd);
|
|
194
|
+
if (closingLineStart === -1)
|
|
195
|
+
return null;
|
|
196
|
+
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
197
|
+
return content.slice(headerEnd, yamlEnd);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Locate and parse the top-level `coverage:` block from a SUMMARY document.
|
|
201
|
+
* `malformed` is true when a `coverage:` key IS present with body content that
|
|
202
|
+
* does NOT parse into a non-empty sequence of entries — a distinct, fail-safe
|
|
203
|
+
* signal so a broken block can never masquerade as "all covered" (the caller
|
|
204
|
+
* falls back to prose extraction and surfaces the error). Distinct from
|
|
205
|
+
* `coverage: []` / an empty body, which is the legitimate zero-entry case.
|
|
206
|
+
*/
|
|
207
|
+
function parseCoverage(content) {
|
|
208
|
+
const yaml = getFrontmatterYaml(content);
|
|
209
|
+
if (yaml === null)
|
|
210
|
+
return { found: false, entries: [], malformed: false };
|
|
211
|
+
const lines = yaml.split(/\r?\n/);
|
|
212
|
+
let covIdx = -1;
|
|
213
|
+
for (let i = 0; i < lines.length; i++) {
|
|
214
|
+
if (/^coverage:(?:\s|$)/.test(lines[i])) {
|
|
215
|
+
covIdx = i;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (covIdx === -1)
|
|
220
|
+
return { found: false, entries: [], malformed: false };
|
|
221
|
+
// Strip a trailing YAML comment from the header value. The `coverage:` header
|
|
222
|
+
// only ever carries `[]` or a comment — refs (which legitimately contain `#`)
|
|
223
|
+
// live in quoted scalars on deeper lines, never on this line.
|
|
224
|
+
const rawInline = /^coverage:\s*(.*)$/.exec(lines[covIdx])[1];
|
|
225
|
+
const inline = rawInline.replace(/\s*#.*$/, '').trim();
|
|
226
|
+
if (inline === '[]')
|
|
227
|
+
return { found: true, entries: [], malformed: false };
|
|
228
|
+
if (inline !== '') {
|
|
229
|
+
// A non-empty, non-`[]` inline scalar where a block was expected is malformed.
|
|
230
|
+
return { found: true, entries: [], malformed: true };
|
|
231
|
+
}
|
|
232
|
+
// Gather the block body: every line after the header up to the next top-level
|
|
233
|
+
// frontmatter key (a `key:` at column 0) or end of frontmatter. Mis-indented
|
|
234
|
+
// lines (tabs, wrong column) are INCLUDED so they surface as a malformed block
|
|
235
|
+
// rather than being silently excluded and the block read as falsely empty.
|
|
236
|
+
let j = covIdx + 1;
|
|
237
|
+
while (j < lines.length) {
|
|
238
|
+
const l = lines[j];
|
|
239
|
+
if (l.trim() === '') {
|
|
240
|
+
j++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (/^[A-Za-z0-9_-]+:(?:\s|$)/.test(l))
|
|
244
|
+
break; // next top-level key
|
|
245
|
+
j++;
|
|
246
|
+
}
|
|
247
|
+
const block = lines.slice(covIdx + 1, j);
|
|
248
|
+
const blockFirst = block.find(isSignificant);
|
|
249
|
+
if (blockFirst === undefined)
|
|
250
|
+
return { found: true, entries: [], malformed: false }; // empty body == coverage: []
|
|
251
|
+
const node = parseNode(block, lineIndent(blockFirst));
|
|
252
|
+
if (!Array.isArray(node) || node.length === 0) {
|
|
253
|
+
// Body had content but did not parse into a sequence of entries → malformed.
|
|
254
|
+
return { found: true, entries: [], malformed: true };
|
|
255
|
+
}
|
|
256
|
+
return { found: true, entries: node, malformed: false };
|
|
257
|
+
}
|
|
258
|
+
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
259
|
+
function isPlainObject(v) {
|
|
260
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
261
|
+
}
|
|
262
|
+
function validateEntry(entry, index, seenIds) {
|
|
263
|
+
const errors = [];
|
|
264
|
+
// Object-check FIRST — before any property access — so a `null`/scalar
|
|
265
|
+
// sequence item (e.g. a bare `-` or `- "string"`) can never throw.
|
|
266
|
+
if (!isPlainObject(entry)) {
|
|
267
|
+
errors.push({ index, id: null, code: ERROR_CODE.MALFORMED_ENTRY, message: 'coverage entry is not a mapping' });
|
|
268
|
+
return errors;
|
|
269
|
+
}
|
|
270
|
+
const id = typeof entry.id === 'string' ? entry.id : null;
|
|
271
|
+
const push = (code, message, field) => {
|
|
272
|
+
errors.push({ index, id, code, field, message });
|
|
273
|
+
};
|
|
274
|
+
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
|
|
275
|
+
push(ERROR_CODE.MISSING_ID, 'entry is missing a non-empty id', 'id');
|
|
276
|
+
}
|
|
277
|
+
else if (seenIds.has(entry.id)) {
|
|
278
|
+
push(ERROR_CODE.DUPLICATE_ID, `duplicate coverage id "${entry.id}"`, 'id');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
seenIds.add(entry.id);
|
|
282
|
+
}
|
|
283
|
+
if (typeof entry.description !== 'string' || entry.description.trim() === '') {
|
|
284
|
+
push(ERROR_CODE.MISSING_DESCRIPTION, 'entry is missing a non-empty description', 'description');
|
|
285
|
+
}
|
|
286
|
+
if (!('human_judgment' in entry)) {
|
|
287
|
+
push(ERROR_CODE.MISSING_HUMAN_JUDGMENT, 'entry is missing the required human_judgment flag', 'human_judgment');
|
|
288
|
+
}
|
|
289
|
+
else if (typeof entry.human_judgment !== 'boolean') {
|
|
290
|
+
push(ERROR_CODE.INVALID_HUMAN_JUDGMENT, 'human_judgment must be a boolean (true|false)', 'human_judgment');
|
|
291
|
+
}
|
|
292
|
+
if (entry.human_judgment === true && (typeof entry.rationale !== 'string' || entry.rationale.trim() === '')) {
|
|
293
|
+
push(ERROR_CODE.MISSING_RATIONALE, 'rationale is required when human_judgment is true', 'rationale');
|
|
294
|
+
}
|
|
295
|
+
const v = entry.verification;
|
|
296
|
+
if (v !== undefined && !Array.isArray(v)) {
|
|
297
|
+
push(ERROR_CODE.VERIFICATION_NOT_LIST, 'verification must be a list', 'verification');
|
|
298
|
+
}
|
|
299
|
+
else if (Array.isArray(v)) {
|
|
300
|
+
v.forEach((ve, vi) => {
|
|
301
|
+
if (!isPlainObject(ve)) {
|
|
302
|
+
push(ERROR_CODE.MALFORMED_ENTRY, 'verification item is not a mapping', `verification[${vi}]`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (typeof ve.kind !== 'string' || !VALID_KINDS.includes(ve.kind)) {
|
|
306
|
+
push(ERROR_CODE.INVALID_KIND, `verification kind must be one of ${VALID_KINDS.join(', ')}`, `verification[${vi}].kind`);
|
|
307
|
+
}
|
|
308
|
+
if (typeof ve.status !== 'string' || !VALID_STATUSES.includes(ve.status)) {
|
|
309
|
+
push(ERROR_CODE.INVALID_STATUS, `verification status must be one of ${VALID_STATUSES.join(', ')}`, `verification[${vi}].status`);
|
|
310
|
+
}
|
|
311
|
+
if (typeof ve.ref !== 'string' || ve.ref.trim() === '') {
|
|
312
|
+
push(ERROR_CODE.MISSING_REF, 'verification entry is missing a non-empty ref', `verification[${vi}].ref`);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return errors;
|
|
317
|
+
}
|
|
318
|
+
// ─── Classification ───────────────────────────────────────────────────────────
|
|
319
|
+
function verificationList(entry) {
|
|
320
|
+
return Array.isArray(entry.verification) ? entry.verification : [];
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Auto-pass is the narrow, fully-proven case:
|
|
324
|
+
* - zero validation errors, AND
|
|
325
|
+
* - human_judgment is the strict boolean `false`, AND
|
|
326
|
+
* - verification is a NON-EMPTY list, AND
|
|
327
|
+
* - every verification entry has status === 'pass'.
|
|
328
|
+
* The non-empty guard defeats the vacuous-`every` trap; the strict-boolean
|
|
329
|
+
* guard defeats a gamed string flag; the zero-errors guard means a malformed
|
|
330
|
+
* entry can never auto-pass.
|
|
331
|
+
*/
|
|
332
|
+
function isAutoPass(entry, errors) {
|
|
333
|
+
if (errors.length > 0)
|
|
334
|
+
return false;
|
|
335
|
+
if (entry.human_judgment !== false)
|
|
336
|
+
return false;
|
|
337
|
+
const v = verificationList(entry);
|
|
338
|
+
if (v.length === 0)
|
|
339
|
+
return false;
|
|
340
|
+
return v.every((ve) => isPlainObject(ve) && ve.status === 'pass');
|
|
341
|
+
}
|
|
342
|
+
function presentReason(entry, errors) {
|
|
343
|
+
if (errors.length > 0)
|
|
344
|
+
return PRESENT_REASON.VALIDATION_FAILED;
|
|
345
|
+
if (entry.human_judgment === true)
|
|
346
|
+
return PRESENT_REASON.HUMAN_JUDGMENT;
|
|
347
|
+
const v = verificationList(entry);
|
|
348
|
+
if (v.length === 0)
|
|
349
|
+
return PRESENT_REASON.NO_VERIFICATION;
|
|
350
|
+
return PRESENT_REASON.VERIFICATION_NOT_PASSING;
|
|
351
|
+
}
|
|
352
|
+
function san(value) {
|
|
353
|
+
return typeof value === 'string' ? (0, security_cjs_1.sanitizeForDisplay)(value) : null;
|
|
354
|
+
}
|
|
355
|
+
function entryView(entry) {
|
|
356
|
+
// Null-safe: a malformed (non-object) entry still gets a minimal view so it
|
|
357
|
+
// can be presented to the human rather than dropped or throwing.
|
|
358
|
+
if (!isPlainObject(entry)) {
|
|
359
|
+
return { id: null, description: null, verification: [], human_judgment: null };
|
|
360
|
+
}
|
|
361
|
+
const verification = verificationList(entry).map((ve) => ({
|
|
362
|
+
kind: isPlainObject(ve) && typeof ve.kind === 'string' ? ve.kind : null,
|
|
363
|
+
ref: isPlainObject(ve) ? san(ve.ref) : null,
|
|
364
|
+
status: isPlainObject(ve) && typeof ve.status === 'string' ? ve.status : null,
|
|
365
|
+
}));
|
|
366
|
+
const view = {
|
|
367
|
+
id: san(entry.id),
|
|
368
|
+
description: san(entry.description),
|
|
369
|
+
verification,
|
|
370
|
+
human_judgment: typeof entry.human_judgment === 'boolean' ? entry.human_judgment : null,
|
|
371
|
+
};
|
|
372
|
+
if (typeof entry.requirement === 'string')
|
|
373
|
+
view.requirement = (0, security_cjs_1.sanitizeForDisplay)(entry.requirement);
|
|
374
|
+
if (typeof entry.rationale === 'string')
|
|
375
|
+
view.rationale = (0, security_cjs_1.sanitizeForDisplay)(entry.rationale);
|
|
376
|
+
return view;
|
|
377
|
+
}
|
|
378
|
+
function legacyResult(summaryFile, errors) {
|
|
379
|
+
return {
|
|
380
|
+
mode: MODE.LEGACY,
|
|
381
|
+
summary_file: summaryFile,
|
|
382
|
+
total: 0,
|
|
383
|
+
all_auto_covered: false,
|
|
384
|
+
auto_passed: [],
|
|
385
|
+
present: [],
|
|
386
|
+
errors,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/** Pure classification core — no I/O. Testable in isolation. */
|
|
390
|
+
function classifyContent(content, summaryFile) {
|
|
391
|
+
const { found, entries, malformed } = parseCoverage(content);
|
|
392
|
+
if (!found)
|
|
393
|
+
return legacyResult(summaryFile, []);
|
|
394
|
+
if (malformed) {
|
|
395
|
+
// A coverage block is present but unparseable. Fail-safe: fall back to the
|
|
396
|
+
// prose `## Accomplishments` path (the human still gets UAT) and surface the
|
|
397
|
+
// error so the author can fix the block. NEVER report all_auto_covered here.
|
|
398
|
+
return legacyResult(summaryFile, [{
|
|
399
|
+
index: -1,
|
|
400
|
+
id: null,
|
|
401
|
+
code: ERROR_CODE.MALFORMED_BLOCK,
|
|
402
|
+
message: 'coverage block is present but could not be parsed into entries; falling back to prose extraction',
|
|
403
|
+
}]);
|
|
404
|
+
}
|
|
405
|
+
const seenIds = new Set();
|
|
406
|
+
const autoPassed = [];
|
|
407
|
+
const present = [];
|
|
408
|
+
const allErrors = [];
|
|
409
|
+
entries.forEach((entry, index) => {
|
|
410
|
+
const errs = validateEntry(entry, index, seenIds);
|
|
411
|
+
allErrors.push(...errs);
|
|
412
|
+
const view = entryView(entry);
|
|
413
|
+
if (isAutoPass(entry, errs)) {
|
|
414
|
+
autoPassed.push({ ...view, source: 'automated' });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
present.push({ ...view, reason: presentReason(entry, errs) });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
return {
|
|
421
|
+
mode: MODE.COVERAGE,
|
|
422
|
+
summary_file: summaryFile,
|
|
423
|
+
total: entries.length,
|
|
424
|
+
all_auto_covered: present.length === 0,
|
|
425
|
+
auto_passed: autoPassed,
|
|
426
|
+
present,
|
|
427
|
+
errors: allErrors,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// ─── CLI command ────────────────────────────────────────────────────────────
|
|
431
|
+
function cmdClassify(cwd, options = {}, raw) {
|
|
432
|
+
const filePath = options.summary || options.file;
|
|
433
|
+
if (!filePath) {
|
|
434
|
+
error('SUMMARY file required: use uat classify-coverage --summary <path>');
|
|
435
|
+
}
|
|
436
|
+
let resolvedPath;
|
|
437
|
+
try {
|
|
438
|
+
resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'SUMMARY file', { allowAbsolute: true });
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
// Emit a structured command error instead of leaking a raw stack trace.
|
|
442
|
+
error(`Invalid SUMMARY path: ${e instanceof Error ? e.message : 'unsafe path'}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (!node_fs_1.default.existsSync(resolvedPath)) {
|
|
446
|
+
error(`SUMMARY file not found: ${filePath}`);
|
|
447
|
+
}
|
|
448
|
+
const content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
|
|
449
|
+
const result = classifyContent(content, toPosixPath(node_path_1.default.relative(cwd, resolvedPath)));
|
|
450
|
+
output(result, raw, undefined);
|
|
451
|
+
}
|
|
452
|
+
module.exports = {
|
|
453
|
+
cmdClassify,
|
|
454
|
+
classifyContent,
|
|
455
|
+
parseCoverage,
|
|
456
|
+
validateEntry,
|
|
457
|
+
isAutoPass,
|
|
458
|
+
presentReason,
|
|
459
|
+
MODE,
|
|
460
|
+
PRESENT_REASON,
|
|
461
|
+
ERROR_CODE,
|
|
462
|
+
VALID_KINDS,
|
|
463
|
+
VALID_STATUSES,
|
|
464
|
+
};
|
|
@@ -42,6 +42,18 @@ const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)
|
|
|
42
42
|
* Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
|
|
43
43
|
*/
|
|
44
44
|
const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
|
|
45
|
+
/**
|
|
46
|
+
* Titled-colon form: `- **D-NN[ [tags]]: Title.** body`
|
|
47
|
+
* A title sits between the colon and the closing `**` (so the `:**` anchor of
|
|
48
|
+
* bulletColonRe fails, and there is no em-dash for bulletEmDashRe). This is a strict
|
|
49
|
+
* superset of the colon-immediate form, so it MUST be checked AFTER bulletColonRe and
|
|
50
|
+
* bulletEmDashRe — it only catches bullets those two miss. The title run is `[^:*]*` (no
|
|
51
|
+
* colon, no `*`) so a genuinely-malformed bullet with a colon in the pre-separator run
|
|
52
|
+
* (e.g. `D-07 ratio 3:1:**`) still fails the anchor and falls through to the parse-miss
|
|
53
|
+
* guard — matching bulletColonRe's `[^:*]*` discipline that the separator colon is the
|
|
54
|
+
* only colon permitted before `**`. (#1639)
|
|
55
|
+
*/
|
|
56
|
+
const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/;
|
|
45
57
|
/**
|
|
46
58
|
* Parse decision lines from a block of text (the inner text of a <decisions>
|
|
47
59
|
* or markdown-header section body). Returns the extracted decisions and a count
|
|
@@ -110,6 +122,21 @@ function parseDecisionLines(block) {
|
|
|
110
122
|
current = { id, text: emDashMatch[3] || '', category, tags, trackable };
|
|
111
123
|
continue;
|
|
112
124
|
}
|
|
125
|
+
// Titled-colon form: `- **D-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
|
|
126
|
+
// a strict superset of bulletColonRe, so it only catches bullets the colon-immediate
|
|
127
|
+
// and em-dash forms missed (minimal blast radius). id + [tags] trackability honored;
|
|
128
|
+
// the body after the closing bold run is reported as text.
|
|
129
|
+
const titledColonMatch = line.match(bulletTitledColonRe);
|
|
130
|
+
if (titledColonMatch) {
|
|
131
|
+
flush();
|
|
132
|
+
const id = `D-${titledColonMatch[1]}`;
|
|
133
|
+
const tags = titledColonMatch[2]
|
|
134
|
+
? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
135
|
+
: [];
|
|
136
|
+
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
|
137
|
+
current = { id, text: titledColonMatch[3] || '', category, tags, trackable };
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
113
140
|
// Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
|
|
114
141
|
// bullet but failed both patterns — flush, warn, and record the miss.
|
|
115
142
|
// parseMisses > 0 forces could-not-parse even when other decisions parsed.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Manifest-backed eval subcommand router (#10).
|
|
4
|
+
*/
|
|
5
|
+
const command_aliases_cjs_1 = require("./command-aliases.cjs");
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
|
+
const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
|
|
8
|
+
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
|
|
9
|
+
function routeEvalCommand({ evalMod, args, cwd, raw, error }) {
|
|
10
|
+
routeCjsCommandFamily({
|
|
11
|
+
args,
|
|
12
|
+
subcommands: command_aliases_cjs_1.EVAL_SUBCOMMANDS,
|
|
13
|
+
unsupported: {},
|
|
14
|
+
error,
|
|
15
|
+
unknownMessage: (_s, available) => `Unknown eval subcommand. Available: ${available.join(', ')}`,
|
|
16
|
+
handlers: {
|
|
17
|
+
score: () => evalMod.cmdEvalScore(cwd, args, raw),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
module.exports = { routeEvalCommand };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic eval scoring verb (#10).
|
|
4
|
+
* Moves coverage/infra/overall arithmetic out of the gsd-eval-auditor prompt
|
|
5
|
+
* into code, per the framework's code-delegation discipline.
|
|
6
|
+
*/
|
|
7
|
+
function parseFlag(args, flag) {
|
|
8
|
+
const i = args.indexOf(flag);
|
|
9
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
10
|
+
}
|
|
11
|
+
const INFRA_VALUE = { ok: 1, partial: 0.5, missing: 0 };
|
|
12
|
+
const INFRA_TOKENS = new Set(Object.keys(INFRA_VALUE));
|
|
13
|
+
function computeEvalScore(covered, total, infra) {
|
|
14
|
+
const coverage = total > 0 ? (covered / total) * 100 : 0;
|
|
15
|
+
// unknown/typo tokens are treated as `missing` (score 0) by design — upstream agent only passes ok|partial|missing
|
|
16
|
+
const infraSum = infra.reduce((acc, s) => acc + (INFRA_VALUE[s.trim().toLowerCase()] ?? 0), 0);
|
|
17
|
+
const infraScore = (infraSum / 5) * 100;
|
|
18
|
+
const overall = coverage * 0.6 + infraScore * 0.4;
|
|
19
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
20
|
+
const o = round(overall);
|
|
21
|
+
const verdict = o >= 80 ? 'PRODUCTION READY' :
|
|
22
|
+
o >= 60 ? 'NEEDS WORK' :
|
|
23
|
+
o >= 40 ? 'SIGNIFICANT GAPS' : 'NOT IMPLEMENTED';
|
|
24
|
+
return { coverage_score: round(coverage), infra_score: round(infraScore), overall_score: o, verdict };
|
|
25
|
+
}
|
|
26
|
+
function cmdEvalScore(_cwd, args, raw) {
|
|
27
|
+
const coveredRaw = parseFlag(args, '--covered');
|
|
28
|
+
const totalRaw = parseFlag(args, '--total');
|
|
29
|
+
const infraRaw = parseFlag(args, '--infra') || '';
|
|
30
|
+
const infra = infraRaw ? infraRaw.split(',').map((s) => s.trim().toLowerCase()) : [];
|
|
31
|
+
const covered = Number(coveredRaw);
|
|
32
|
+
const total = Number(totalRaw);
|
|
33
|
+
if (coveredRaw === undefined || coveredRaw.trim() === '' ||
|
|
34
|
+
totalRaw === undefined || totalRaw.trim() === '' ||
|
|
35
|
+
!Number.isFinite(covered) || !Number.isFinite(total) ||
|
|
36
|
+
infra.length !== 5) {
|
|
37
|
+
process.stderr.write('Usage: gsd-tools query eval.score --covered N --total N --infra a,b,c,d,e (each ok|partial|missing)\n');
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Domain validation: this is a public CLI verb, so reject out-of-domain inputs
|
|
42
|
+
// rather than emit nonsense (covered>total -> coverage_score>100; negatives ->
|
|
43
|
+
// negative scores). Counts must be non-negative integers and covered cannot
|
|
44
|
+
// exceed total; infra tokens must match the documented ok|partial|missing set.
|
|
45
|
+
if (!Number.isInteger(covered) || !Number.isInteger(total) || covered < 0 || total < 0 || covered > total) {
|
|
46
|
+
process.stderr.write('Invalid eval.score domain: require integer counts with 0 <= covered <= total.\n');
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const invalidInfra = infra.find((s) => !INFRA_TOKENS.has(s));
|
|
51
|
+
if (invalidInfra !== undefined) {
|
|
52
|
+
process.stderr.write(`Invalid eval.score infra token: ${invalidInfra || '<empty>'}. Expected ok|partial|missing.\n`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const result = computeEvalScore(covered, total, infra);
|
|
57
|
+
process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
|
58
|
+
process.stdout.write('\n');
|
|
59
|
+
}
|
|
60
|
+
module.exports = { cmdEvalScore, computeEvalScore };
|