@sdd-method/verify-cli 0.1.1 → 0.2.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.
@@ -0,0 +1,112 @@
1
+ // YAML parse gate — every tracked YAML source must parse STRICTLY.
2
+ //
3
+ // The catalogue builders read YAML file by file and, for most sources, log
4
+ // `invalid YAML` and move on: the file becomes wholly invisible to the
5
+ // catalogue and nothing ever fails. Measured: 19 unparseable files across 4
6
+ // SDDs, 16 in one repo, accumulated that way.
7
+ //
8
+ // THE LOADER IS THE GATE. PyYAML (and any last-key-wins loader) accepts a
9
+ // duplicate key and silently keeps the second value, so a parse-only check
10
+ // with it certifies exactly the corruption a line-surgery transformer
11
+ // produces. `yaml` v2 refuses duplicate keys (DUPLICATE_KEY) and the other
12
+ // malformations a lenient parse glosses — e.g. a `: ` inside an unquoted
13
+ // scalar (`path: (none — tel: URL)`), which no pattern rule catches.
14
+ //
15
+ // Scope: the YAML files git TRACKS (`*.yaml`, `*.yml`) — the repo's sources,
16
+ // not build output or dependencies. Outside a git work tree there is no
17
+ // honest scope, so the check reports a SKIP rather than a pass.
18
+ //
19
+ // A TEMPLATE is not YAML until rendered (Helm/Go `{{- … -}}`, Handlebars).
20
+ // A file that fails to parse AND carries a template directive is listed as
21
+ // templated and not failed — UNLESS one of its errors is a duplicate key: a
22
+ // duplicate key is a duplicate however the file is produced, and exempting it
23
+ // would let any `{{ }}` in a value hide exactly the defect this gate exists for.
24
+ import { execFile } from "node:child_process";
25
+ import { readFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { promisify } from "node:util";
28
+ import { parseAllDocuments } from "yaml";
29
+ const execFileAsync = promisify(execFile);
30
+ const TEMPLATE_DIRECTIVE_RE = /\{\{[\s\S]*?\}\}/;
31
+ /** Strict-parse one YAML text; returns the errors (empty = parses). */
32
+ export function strictYamlErrors(text) {
33
+ return parseAllDocuments(text).flatMap((doc) => doc.errors.map((e) => ({
34
+ code: e.code,
35
+ message: e.message.split("\n")[0] ?? e.message,
36
+ })));
37
+ }
38
+ async function trackedYamlFiles(repo) {
39
+ try {
40
+ const { stdout } = await execFileAsync("git", ["-C", repo, "ls-files", "-z", "--", "*.yaml", "*.yml"], { maxBuffer: 64 * 1024 * 1024 });
41
+ return stdout.split("\0").filter((p) => p !== "");
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export async function checkYamlParse(targetRepo) {
48
+ const files = await trackedYamlFiles(targetRepo);
49
+ if (files === null) {
50
+ return {
51
+ exitCode: 0,
52
+ stdout: "",
53
+ skipped: "not a git work tree — no tracked-file scope to check",
54
+ };
55
+ }
56
+ const failures = [];
57
+ const templated = [];
58
+ let read = 0;
59
+ for (const file of files) {
60
+ let text;
61
+ try {
62
+ text = await readFile(join(targetRepo, file), "utf-8");
63
+ }
64
+ catch {
65
+ continue; // tracked but deleted in the work tree: not a parse question
66
+ }
67
+ read += 1;
68
+ const errors = strictYamlErrors(text);
69
+ if (errors.length > 0 &&
70
+ !errors.some((e) => e.code === "DUPLICATE_KEY") &&
71
+ TEMPLATE_DIRECTIVE_RE.test(text)) {
72
+ templated.push(file);
73
+ continue;
74
+ }
75
+ for (const e of errors) {
76
+ failures.push({ file, code: e.code, message: e.message });
77
+ }
78
+ }
79
+ const checked = read - templated.length;
80
+ const templatedNote = templated.length === 0
81
+ ? ""
82
+ : `[INFO] yaml-parse: ${templated.length} templated file(s) not checked — they do not parse ` +
83
+ `until rendered and carry a {{ … }} directive: ${templated.join(", ")}\n`;
84
+ if (failures.length === 0 && checked === 0) {
85
+ // Zero files checked is absence of evidence, never a pass.
86
+ return {
87
+ exitCode: 0,
88
+ stdout: templatedNote,
89
+ skipped: "no tracked YAML files to parse — nothing to check",
90
+ };
91
+ }
92
+ if (failures.length === 0) {
93
+ return {
94
+ exitCode: 0,
95
+ stdout: templatedNote +
96
+ `[PASS] yaml-parse: ${checked} tracked YAML file(s) parse strictly (duplicate keys refused)\n`,
97
+ };
98
+ }
99
+ const failedFiles = new Set(failures.map((f) => f.file)).size;
100
+ const byCode = new Map();
101
+ for (const f of failures)
102
+ byCode.set(f.code, (byCode.get(f.code) ?? 0) + 1);
103
+ const lines = failures.map((f) => `[FAIL] yaml-parse: ${f.file}: ${f.code}: ${f.message}\n`);
104
+ if (templatedNote !== "")
105
+ lines.unshift(templatedNote);
106
+ lines.push(`[FAIL] yaml-parse: ${failedFiles} of ${checked} tracked YAML file(s) do not parse strictly ` +
107
+ `(${[...byCode].map(([c, n]) => `${c} ×${n}`).join(", ")}). ` +
108
+ `A file that does not parse is invisible to the catalogue; a duplicate key keeps only one ` +
109
+ `of its values under a lenient loader. Fix the file — do not relax the loader.\n`);
110
+ return { exitCode: 1, stdout: lines.join("") };
111
+ }
112
+ //# sourceMappingURL=yaml-parse.js.map
@@ -57,7 +57,7 @@ export function registerCatalogueImpact(catalogue) {
57
57
  .description("Change-impact closure of a node (M-ADR 0206): each relationship is traversed per its declared impact-flow from the rubric-coverage-map — dependency edges reach their consumers, membership edges propagate part-to-whole only, xref/lineage records are inert — computed by recursive SQL on the embedded engine, with the co-directionality invariant asserted on every run")
58
58
  .requiredOption("--of <node-id>", 'Node to compute impact of, in the projection\'s "<catalogue>/<id>" form (e.g. contracts/orders-api)')
59
59
  .option("--sdd-path <path>", "Path to the SDD repo root (defaults to cwd)")
60
- .option("--catalogues-dir <dir>", "Catalogues directory relative to the SDD root (default catalogues)")
60
+ .option("--catalogues-dir <dir>", "Catalogues directory relative to the SDD root (default: catalogues, or catalogues-aggregated at --tier aggregate)")
61
61
  .option("--tier <tier>", "Tier, explicit never inferred (member | aggregate; default member)")
62
62
  .action(async (opts) => {
63
63
  if (opts.tier !== undefined &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdd-method/verify-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -65,6 +65,9 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
65
65
  log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
66
66
  log_fail() { echo -e "${RED}[FAIL]${NC} $1"; }
67
67
  log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
68
+ # R1: an empty population is "measured nothing", not a pass — the [N/A] tag
69
+ # must start the line (no leading colour escape) so callers can detect it.
70
+ log_na() { echo -e "[N/A] $1"; }
68
71
  log_section() {
69
72
  echo -e "\n${BLUE}═══════════════════════════════════════════════════════════${NC}"
70
73
  echo -e "${BLUE} $1${NC}"
@@ -224,22 +227,38 @@ UNRESOLVED_SERVICE_IDS=0
224
227
  UNRESOLVED_CONTRACT_PATHS=0
225
228
 
226
229
  # ──────────────────────────────────────────────────────────────────────────────
230
+ # R1: with zero declared dependencies the run measures nothing, so the
231
+ # prerequisite checks report as [INFO] rather than [PASS] — a [PASS] line would
232
+ # make the empty run read as a pass. Counted up front, quietly; the real count
233
+ # (with parse-failure reporting) happens in main.
234
+ PRE_DEP_COUNT=0
235
+ log_prereq() {
236
+ if [[ "$PRE_DEP_COUNT" -gt 0 ]]; then log_pass "$1"; else log_info "$1"; fi
237
+ }
238
+
227
239
  check_prerequisites() {
228
240
  log_section "Prerequisites"
229
241
 
242
+ if command -v yq &>/dev/null; then
243
+ local _src
244
+ for _src in "${DEP_SOURCES[@]+"${DEP_SOURCES[@]}"}"; do
245
+ PRE_DEP_COUNT=$((PRE_DEP_COUNT + $(quiet_dep_count "$_src")))
246
+ done
247
+ fi
248
+
230
249
  if [[ ${#DEP_SOURCES[@]} -eq 0 ]]; then
231
250
  log_fail "no dependency source found: neither $APP_DEPS_FILE nor any reverse-evidence/**/feature-grounding-evidence.yaml"
232
251
  exit 1
233
252
  fi
234
253
  for src in "${DEP_SOURCES[@]}"; do
235
- log_pass "dependency source: ${src#"$APP_ROOT"/}"
254
+ log_prereq "dependency source: ${src#"$APP_ROOT"/}"
236
255
  done
237
256
 
238
257
  if ! command -v yq &>/dev/null; then
239
258
  log_fail "yq is required but not installed. Install with: brew install yq"
240
259
  exit 1
241
260
  fi
242
- log_pass "yq available"
261
+ log_prereq "yq available"
243
262
 
244
263
  if [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
245
264
  PLATFORM_SERVICES_CSV=""
@@ -249,7 +268,7 @@ check_prerequisites() {
249
268
  fi
250
269
 
251
270
  if [[ -f "$PLATFORM_SERVICES_CSV" ]]; then
252
- log_pass "Platform services catalogue found (canonical service_id source)"
271
+ log_prereq "Platform services catalogue found (canonical service_id source)"
253
272
  else
254
273
  PLATFORM_SERVICES_CSV=""
255
274
  fi
@@ -258,7 +277,7 @@ check_prerequisites() {
258
277
  log_warn "Platform service-architecture.md not found: $PLATFORM_SERVICE_ARCH"
259
278
  PLATFORM_SERVICE_ARCH=""
260
279
  else
261
- log_pass "Platform service-architecture.md found"
280
+ log_prereq "Platform service-architecture.md found"
262
281
  fi
263
282
 
264
283
  if [[ -z "$PLATFORM_SERVICES_CSV" && -z "$PLATFORM_SERVICE_ARCH" ]]; then
@@ -600,6 +619,7 @@ main() {
600
619
  fi
601
620
  log_warn "No platform_dependencies entries found in ${#DEP_SOURCES[@]} source(s)"
602
621
  log_info "If this app has no platform deps, this is acceptable."
622
+ log_na "App dependencies: 0 platform_dependencies entries found — nothing to check"
603
623
  exit 0
604
624
  fi
605
625
 
@@ -34,6 +34,9 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
34
34
  log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
35
35
  log_fail() { echo -e "${RED}[FAIL]${NC} $1"; }
36
36
  log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
37
+ # R1: an empty population is "measured nothing", not a pass — the [N/A] tag
38
+ # must start the line (no leading colour escape) so callers can detect it.
39
+ log_na() { echo -e "[N/A] $1"; }
37
40
  log_section() {
38
41
  echo -e "\n${BLUE}═══════════════════════════════════════════════════════════${NC}"
39
42
  echo -e "${BLUE} $1${NC}"
@@ -66,6 +69,14 @@ fi
66
69
 
67
70
  FAILURES=0
68
71
 
72
+ # R1: the feature specs are the population this validator measures. With none,
73
+ # the Group 1 file-presence checks are reported as [INFO] rather than [PASS] so
74
+ # the run reads as "measured nothing", not as a pass.
75
+ FEATURE_SPEC_COUNT="$( (find "$APP_ROOT/features" -type f -name "*.md" 2>/dev/null || true) | wc -l | tr -d ' ')"
76
+ log_found() {
77
+ if [[ "$FEATURE_SPEC_COUNT" -gt 0 ]]; then log_pass "$1"; else log_info "$1"; fi
78
+ }
79
+
69
80
  # ──────────────────────────────────────────────────────────────────────────────
70
81
  check_required_files() {
71
82
  log_section "Group 1: Required App Repo Files"
@@ -85,7 +96,7 @@ check_required_files() {
85
96
  log_fail "service-architecture.md not found (checked docs/service-architecture.md and service-architecture.md)"
86
97
  checks_failed=$((checks_failed + 1))
87
98
  else
88
- log_pass "service-architecture.md found: ${service_arch#$APP_ROOT/}"
99
+ log_found "service-architecture.md found: ${service_arch#$APP_ROOT/}"
89
100
  checks_passed=$((checks_passed + 1))
90
101
  fi
91
102
 
@@ -103,13 +114,13 @@ check_required_files() {
103
114
  log_warn "feature-tree.md not found (checked docs/platform/, docs/, and repo root) — create when features are classified"
104
115
  # warn only; not a hard fail at exploration stage
105
116
  else
106
- log_pass "feature-tree.md found: ${feature_tree#$APP_ROOT/}"
117
+ log_found "feature-tree.md found: ${feature_tree#$APP_ROOT/}"
107
118
  checks_passed=$((checks_passed + 1))
108
119
  fi
109
120
 
110
121
  # 1c. app-platform-dependencies.yaml
111
122
  if [[ -f "$APP_ROOT/app-platform-dependencies.yaml" ]]; then
112
- log_pass "app-platform-dependencies.yaml found"
123
+ log_found "app-platform-dependencies.yaml found"
113
124
  checks_passed=$((checks_passed + 1))
114
125
  else
115
126
  log_fail "app-platform-dependencies.yaml not found in repo root"
@@ -173,7 +184,10 @@ main() {
173
184
 
174
185
  log_section "Summary"
175
186
 
176
- if [[ "$FAILURES" -eq 0 ]]; then
187
+ if [[ "$FAILURES" -eq 0 && "$FEATURE_SPEC_COUNT" -eq 0 ]]; then
188
+ log_na "App documentation: required files present, but 0 feature specs found under features/ — nothing to check"
189
+ exit 0
190
+ elif [[ "$FAILURES" -eq 0 ]]; then
177
191
  log_pass "All app documentation checks passed."
178
192
  exit 0
179
193
  else