cclaw-cli 5.0.0 → 6.1.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.
@@ -86,15 +86,70 @@ function detectStopSignal(rows) {
86
86
  return false;
87
87
  }
88
88
  /**
89
- * Extract forcing-question topics from a stage's checklist. Looks for
90
- * the canonical `**<Stage> forcing questions (must be covered or
91
- * explicitly waived)** <topic1>, <topic2>, ...` row and tokenizes the
92
- * comma-separated topic list. Returns trimmed topic strings stripped of
93
- * leading question words (`what`/`who`/`where`/`which`/`how`/`is`/`do`/`does`).
89
+ * Validate the kebab-case ASCII shape of a forcing-question topic ID.
90
+ * Wave 24 enforces that IDs are short, language-neutral identifiers
91
+ * authors can paste into a `[topic:<id>]` tag without typos.
92
+ */
93
+ const TOPIC_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
94
+ function isValidTopicId(id) {
95
+ return TOPIC_ID_PATTERN.test(id);
96
+ }
97
+ /**
98
+ * Parse a single checklist row into the list of forcing-question topic
99
+ * descriptors it declares. Returns `null` when the row is not a
100
+ * forcing-questions header. Throws when the header is found but its
101
+ * body does not match the Wave 24 `id: topic; id: topic; ...` syntax
102
+ * — authors fix the stage definition rather than silently ship
103
+ * un-coverable topics.
104
+ *
105
+ * Exposed for unit tests that exercise the parser without depending on
106
+ * the live stage schema.
107
+ */
108
+ export function parseForcingQuestionsRow(row, context = "row") {
109
+ const headerMatch = /\*\*\s*[A-Za-z]+\s+forcing\s+questions\s*\([^)]*\)\s*\*\*\s*(?:[—\-–:]+)?\s*(.+)/iu.exec(row);
110
+ if (!headerMatch)
111
+ return null;
112
+ const body = (headerMatch[1] ?? "").trim();
113
+ if (body.length === 0)
114
+ return [];
115
+ // Take everything up to the first sentence-ending `.` followed by a
116
+ // space + capital letter. We split on `;` only; commas are part of
117
+ // human labels. Authors stop the list with `.` so the trailing
118
+ // prose ("Tag the matching ...") is excluded.
119
+ const listSection = body.split(/\.\s+(?=[A-Z])/u)[0] ?? body;
120
+ const segments = listSection
121
+ .split(/;\s*/u)
122
+ .map((segment) => segment.trim())
123
+ .filter((segment) => segment.length > 0);
124
+ const topics = [];
125
+ for (const segment of segments) {
126
+ const match = /^[`*_]?\s*([A-Za-z0-9][A-Za-z0-9-]*)\s*[`*_]?\s*:\s*(.+?)\s*$/u.exec(segment);
127
+ if (!match) {
128
+ throw new Error(`parseForcingQuestionsRow(${context}): segment "${segment}" does not match required \`id: topic\` syntax. Wave 24 (v6.0.0) requires \`id: topic; id: topic; ...\` form.`);
129
+ }
130
+ const id = (match[1] ?? "").toLowerCase();
131
+ const topic = (match[2] ?? "").replace(/[`*_]+$/u, "").trim();
132
+ if (!isValidTopicId(id)) {
133
+ throw new Error(`parseForcingQuestionsRow(${context}): invalid topic id "${id}" in segment "${segment}". IDs must match ${TOPIC_ID_PATTERN.source}.`);
134
+ }
135
+ if (topic.length === 0) {
136
+ throw new Error(`parseForcingQuestionsRow(${context}): empty topic label after id "${id}" in segment "${segment}".`);
137
+ }
138
+ topics.push({ id, topic });
139
+ }
140
+ return topics;
141
+ }
142
+ /**
143
+ * Extract forcing-question topics from a stage's checklist.
144
+ *
145
+ * Wave 24 (v6.0.0): only the new `id: topic; id: topic; ...` syntax is
146
+ * accepted. Throws when the syntax is malformed so authors fix the
147
+ * stage definition rather than silently shipping un-coverable topics.
94
148
  *
95
149
  * Returns empty array when no forcing-questions row is present (caller
96
- * should treat absence as "no forcing requirement" — convergence falls
97
- * back to the no-new-decisions / stop-signal detectors).
150
+ * treats absence as "no forcing requirement" — convergence falls back
151
+ * to the no-new-decisions / stop-signal detectors). Returning [] when
152
+ * the row exists but lists no segments is also legal.
98
153
  */
99
154
  export function extractForcingQuestions(stage) {
100
155
  let checklist;
@@ -105,61 +160,29 @@ export function extractForcingQuestions(stage) {
105
160
  return [];
106
161
  }
107
162
  for (const row of checklist) {
108
- const headerMatch = /\*\*\s*[A-Za-z]+\s+forcing\s+questions\s*\([^)]*\)\s*\*\*\s*(?:[—\-–:]+)?\s*(.+)/iu.exec(row);
109
- if (!headerMatch)
163
+ const parsed = parseForcingQuestionsRow(row, `stage=${stage}`);
164
+ if (parsed === null)
110
165
  continue;
111
- const body = (headerMatch[1] ?? "")
112
- .replace(/\.$/u, "")
113
- .trim();
114
- if (body.length === 0)
115
- return [];
116
- return body
117
- .split(/,\s*(?:and\s+)?|\s+and\s+/iu)
118
- .map((topic) => topic.trim())
119
- .filter((topic) => topic.length > 0)
120
- .map((topic) => topic
121
- .replace(/^[*_`]+|[*_`]+$/gu, "")
122
- .replace(/^(?:what|who|where|which|how|is|are|do|does|did|can|will|would|could|should|may|might)\s+/iu, "")
123
- .replace(/\?+$/u, "")
124
- .trim())
125
- .filter((topic) => topic.length > 0);
166
+ return parsed;
126
167
  }
127
168
  return [];
128
169
  }
129
170
  /**
130
- * Build a salient-keyword set for a forcing-question topic. Splits on
131
- * whitespace, drops short/stop words, lowercases. Used for fuzzy
132
- * substring match against Q&A Log row content.
171
+ * Detect whether a Q&A Log row carries an explicit `[topic:<id>]` tag
172
+ * for the requested forcing-topic id. Matching is case-insensitive on
173
+ * the id, ASCII-only on the tag boundary. NO keyword fallback: the user
174
+ * must stamp the tag in any cell of the row.
133
175
  */
134
- function topicKeywords(topic) {
135
- const STOP_WORDS = new Set([
136
- "the", "a", "an", "is", "are", "was", "were", "be", "to", "of", "in", "on", "at",
137
- "for", "and", "or", "but", "if", "then", "else", "with", "without", "by", "as",
138
- "we", "us", "our", "they", "them", "their", "you", "your", "i", "me", "my",
139
- "this", "that", "these", "those", "it", "its", "do", "does", "did", "can",
140
- "will", "would", "should", "could", "may", "might", "any", "some", "no", "not",
141
- "from", "into", "onto", "upon", "than", "very", "much", "many", "more", "most",
142
- "must", "have", "has", "had", "been", "being", "where", "when", "while",
143
- "what", "which", "who", "whose", "whom", "why", "how", "non"
144
- ]);
145
- return topic
146
- .toLowerCase()
147
- .split(/[\s\-/.,;:()\[\]{}'"`*_]+/u)
148
- .map((token) => token.replace(/[^\p{L}\p{N}-]/gu, ""))
149
- .filter((token) => token.length >= 3 && !STOP_WORDS.has(token));
150
- }
151
- function isTopicAddressed(topic, rows) {
152
- const keywords = topicKeywords(topic);
153
- if (keywords.length === 0)
154
- return true;
155
- const minHits = keywords.length === 1 ? 1 : Math.min(2, keywords.length);
176
+ function isTopicAddressed(id, rows) {
177
+ const needle = id.toLowerCase();
178
+ const tagPattern = /\[topic:\s*([A-Za-z0-9][A-Za-z0-9-]*)\s*\]/giu;
156
179
  for (const row of rows) {
157
- const haystack = row.join(" | ").toLowerCase();
158
- let hits = 0;
159
- for (const keyword of keywords) {
160
- if (haystack.includes(keyword))
161
- hits += 1;
162
- if (hits >= minHits)
180
+ const haystack = row.join(" | ");
181
+ tagPattern.lastIndex = 0;
182
+ let match;
183
+ while ((match = tagPattern.exec(haystack)) !== null) {
184
+ const candidate = (match[1] ?? "").toLowerCase();
185
+ if (candidate === needle)
163
186
  return true;
164
187
  }
165
188
  }
@@ -210,18 +233,21 @@ export function evaluateQaLogFloor(qaLogBody, track, stage, options = {}) {
210
233
  const count = substantiveRows.length;
211
234
  const hasStopSignal = detectStopSignal(rows);
212
235
  const skipQuestionsAdvisory = options.skipQuestions === true;
213
- const forcingTopics = options.forcingQuestions ?? extractForcingQuestions(stage);
236
+ const forcingTopics = (options.forcingQuestions ?? extractForcingQuestions(stage)).map((entry) => (typeof entry === "string" ? { id: entry, topic: entry } : entry));
214
237
  const forcingCovered = [];
215
238
  const forcingPending = [];
216
239
  for (const topic of forcingTopics) {
217
- if (isTopicAddressed(topic, rows))
218
- forcingCovered.push(topic);
240
+ if (isTopicAddressed(topic.id, rows))
241
+ forcingCovered.push(topic.id);
219
242
  else
220
- forcingPending.push(topic);
243
+ forcingPending.push(topic.id);
221
244
  }
222
245
  const noNewDecisions = lastTwoRowsAllNoDecision(substantiveRows);
223
246
  const allForcingCovered = forcingTopics.length > 0 ? forcingPending.length === 0 : count >= 1;
224
247
  const ok = allForcingCovered || noNewDecisions || hasStopSignal;
248
+ const pendingIdsBracket = forcingPending.length > 0
249
+ ? `[${forcingPending.join(", ")}]`
250
+ : "[none]";
225
251
  let details;
226
252
  if (ok) {
227
253
  if (allForcingCovered && forcingTopics.length > 0) {
@@ -232,7 +258,7 @@ export function evaluateQaLogFloor(qaLogBody, track, stage, options = {}) {
232
258
  }
233
259
  else if (noNewDecisions) {
234
260
  const remaining = forcingPending.length > 0
235
- ? ` ${forcingPending.length} forcing topic(s) still pending but last 2 rows produced no decision changes (Ralph-Loop convergence).`
261
+ ? ` ${forcingPending.length} forcing topic IDs still pending: ${pendingIdsBracket} (Ralph-Loop convergence overrode coverage).`
236
262
  : " Ralph-Loop convergence detector says no new decision-changing rows in the last 2 turns.";
237
263
  details = `Q&A Log converged via no-new-decisions detector at ${count} row(s).${remaining}`;
238
264
  }
@@ -241,10 +267,10 @@ export function evaluateQaLogFloor(qaLogBody, track, stage, options = {}) {
241
267
  }
242
268
  }
243
269
  else if (skipQuestionsAdvisory) {
244
- details = `Q&A Log unconverged at ${count} row(s); --skip-questions flag downgraded the finding to advisory. Pending forcing topic(s): ${forcingPending.length > 0 ? forcingPending.join("; ") : "(none extracted)"}.`;
270
+ details = `Q&A Log unconverged at ${count} row(s); --skip-questions flag downgraded the finding to advisory. Forcing topic IDs pending: ${pendingIdsBracket}.`;
245
271
  }
246
272
  else {
247
- details = `Q&A Log unconverged at ${count} row(s). Continue the elicitation loop until forcing-question topics are addressed (${forcingPending.length > 0 ? forcingPending.join("; ") : "no forcing topics extracted"}), the last 2 rows record no-decision impact, or an explicit user stop-signal row is appended.`;
273
+ details = `Q&A Log unconverged at ${count} row(s). Forcing topic IDs pending: ${pendingIdsBracket}. Tag each Q&A row with \`[topic:<id>]\` to mark coverage, append a no-new-decisions pair, or record an explicit user stop-signal row.`;
248
274
  }
249
275
  // Surface advisory budget hint for harness UI without re-introducing a
250
276
  // blocking count. `recommended` is the soft budget per track/stage.
@@ -1019,16 +1045,46 @@ export const INTERACTION_EDGE_CASE_REQUIREMENTS = [
1019
1045
  },
1020
1046
  { label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
1021
1047
  ];
1022
- export function validateInteractionEdgeCaseMatrix(sectionBody) {
1048
+ const INTERACTION_EDGE_CASE_NA_PATTERN = /^\s*n\s*\/\s*a\b/iu;
1049
+ const INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN = /^\s*n\s*\/\s*a\s*[—–\-:]\s*\S/iu;
1050
+ const INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS = new Set([
1051
+ "nav-away-mid-request",
1052
+ "10K-result dataset",
1053
+ "background-job abandonment",
1054
+ "zombie connection"
1055
+ ]);
1056
+ function shouldRelaxNetworkDependentEdgeCases(context) {
1057
+ if (!context.liteTier)
1058
+ return false;
1059
+ const sections = context.sections ?? null;
1060
+ if (!sections)
1061
+ return true;
1062
+ const diagramBody = sectionBodyByName(sections, "Architecture Diagram");
1063
+ const failureModeBody = sectionBodyByName(sections, "Failure Mode Table");
1064
+ const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
1065
+ if (failureModeRowCount > 0)
1066
+ return false;
1067
+ if (diagramBody && DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
1068
+ return false;
1069
+ return true;
1070
+ }
1071
+ export function validateInteractionEdgeCaseMatrix(sectionBody, context = {}) {
1023
1072
  const rows = getMarkdownTableRows(sectionBody);
1073
+ const relaxNetworkRows = shouldRelaxNetworkDependentEdgeCases(context);
1024
1074
  if (rows.length === 0) {
1075
+ if (relaxNetworkRows) {
1076
+ return {
1077
+ ok: true,
1078
+ details: "Data Flow Interaction Edge Case matrix is advisory for lite-tier no-network designs (no Failure Mode Table rows and no external-dependency nodes detected)."
1079
+ };
1080
+ }
1025
1081
  return {
1026
1082
  ok: false,
1027
1083
  details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
1028
1084
  };
1029
1085
  }
1030
1086
  const seen = new Map();
1031
- for (const [index, row] of rows.entries()) {
1087
+ for (const [, row] of rows.entries()) {
1032
1088
  const labelCell = (row[0] ?? "").trim();
1033
1089
  if (!labelCell)
1034
1090
  continue;
@@ -1041,15 +1097,31 @@ export function validateInteractionEdgeCaseMatrix(sectionBody) {
1041
1097
  details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
1042
1098
  };
1043
1099
  }
1044
- const handled = parseBinaryFlag((row[1] ?? "").trim());
1100
+ const handledRaw = (row[1] ?? "").trim();
1101
+ const handled = parseBinaryFlag(handledRaw);
1045
1102
  const response = (row[2] ?? "").trim();
1046
1103
  const deferred = (row[3] ?? "").trim();
1047
- if (handled === "unknown") {
1104
+ const isNA = INTERACTION_EDGE_CASE_NA_PATTERN.test(handledRaw);
1105
+ if (handled === "unknown" && !isNA) {
1048
1106
  return {
1049
1107
  ok: false,
1050
- details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no.`
1108
+ details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no, or write \`N/A — <reason>\` (em-dash + free-text reason) when the case does not apply.`
1051
1109
  };
1052
1110
  }
1111
+ if (isNA) {
1112
+ // Wave 25: `N/A — <reason>` short-circuits both the "must mark
1113
+ // yes/no" rule and the "must reference a deferred item id"
1114
+ // rule. The reason satisfies justification.
1115
+ const hasReason = INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN.test(handledRaw) || response.length > 0;
1116
+ if (!hasReason) {
1117
+ return {
1118
+ ok: false,
1119
+ details: `Interaction Edge Case row "${requirement.label}" marked N/A but missing reason. Use \`N/A — <reason>\` (em-dash + free-text reason) in the Handled? cell or fill the Design response cell.`
1120
+ };
1121
+ }
1122
+ seen.set(requirement.label, true);
1123
+ continue;
1124
+ }
1053
1125
  if (!response) {
1054
1126
  return {
1055
1127
  ok: false,
@@ -1059,7 +1131,7 @@ export function validateInteractionEdgeCaseMatrix(sectionBody) {
1059
1131
  if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
1060
1132
  return {
1061
1133
  ok: false,
1062
- details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12).`
1134
+ details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12) or mark Handled? as \`N/A — <reason>\`.`
1063
1135
  };
1064
1136
  }
1065
1137
  seen.set(requirement.label, true);
@@ -1067,15 +1139,27 @@ export function validateInteractionEdgeCaseMatrix(sectionBody) {
1067
1139
  const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
1068
1140
  .map((requirement) => requirement.label)
1069
1141
  .filter((label) => !seen.has(label));
1070
- if (missing.length > 0) {
1142
+ const stillMissing = relaxNetworkRows
1143
+ ? missing.filter((label) => !INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
1144
+ : missing;
1145
+ const advisoryMissing = relaxNetworkRows
1146
+ ? missing.filter((label) => INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
1147
+ : [];
1148
+ if (stillMissing.length > 0) {
1149
+ const advisoryNote = advisoryMissing.length > 0
1150
+ ? ` (${advisoryMissing.length} network-dependent row(s) demoted to advisory by lite-tier no-network detection: ${advisoryMissing.join(", ")})`
1151
+ : "";
1071
1152
  return {
1072
1153
  ok: false,
1073
- details: `Interaction Edge Case matrix is missing required row(s): ${missing.join(", ")}.`
1154
+ details: `Interaction Edge Case matrix is missing required row(s): ${stillMissing.join(", ")}${advisoryNote}.`
1074
1155
  };
1075
1156
  }
1157
+ const advisoryNote = advisoryMissing.length > 0
1158
+ ? ` (${advisoryMissing.length} network-dependent row(s) advisory under lite-tier no-network: ${advisoryMissing.join(", ")})`
1159
+ : "";
1076
1160
  return {
1077
1161
  ok: true,
1078
- details: "Interaction Edge Case matrix contains all required rows with handled/deferred status."
1162
+ details: `Interaction Edge Case matrix contains all required rows with handled/deferred status${advisoryNote}.`
1079
1163
  };
1080
1164
  }
1081
1165
  export const PRE_SCOPE_AUDIT_SIGNALS = [
@@ -1102,9 +1186,23 @@ export function validatePreScopeSystemAudit(sectionBody) {
1102
1186
  details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
1103
1187
  };
1104
1188
  }
1105
- export const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦)/u;
1189
+ export const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦|={2,}>|-{3,}>|\.{3,}>|-(?:\s-){1,}\s?->)/u;
1106
1190
  export const DIAGRAM_FAILURE_EDGE_PATTERN = /\b(fail(?:ed|ure)?|error|timeout|fallback|degrad(?:e|ed|ation)|retry|backoff|circuit|unavailable|recover(?:y)?|rescue|mitigat(?:e|ion)|rollback|exception|abort|dead[\s-]?letter|dlq)\b/iu;
1107
1191
  export const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
1192
+ /**
1193
+ * Wave 25 (v6.1.0) — external-dependency keywords that trigger the
1194
+ * failure-edge requirement. The architecture diagram is allowed to
1195
+ * omit failure edges only when ALL of:
1196
+ * - Failure Mode Table has zero rows.
1197
+ * - The diagram body mentions no external-dependency keyword.
1198
+ *
1199
+ * Static landing pages (3 HTML/CSS/JS files, no network) match this:
1200
+ * no failure modes to map, no external systems to fail. The previous
1201
+ * blanket "must include at least one failure-edge" rule produced
1202
+ * ceremony-only failures that the agent worked around with fake
1203
+ * `(timeout)` annotations, defeating the spirit of the rule.
1204
+ */
1205
+ export const DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN = /\b(http|https|api|rest|grpc|graphql|websocket|socket|tcp|udp|rpc|fetch|request|database|db|sql|postgres|mysql|sqlite|mongo|redis|cache|queue|kafka|rabbitmq|sqs|sns|s3|cdn|external|upstream|downstream|third[\s-]?party|webhook|cloud|service[\s-]?bus|event[\s-]?bus|broker|stream|topic)\b/iu;
1108
1206
  export const TEST_COMMAND_MARKER_PATTERN = /\b(?:npm|pnpm|yarn|bun|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
1109
1207
  export const RED_FAILURE_MARKER_PATTERN = /\b(?:fail|failed|failing|assertionerror|cannot find|exception|error|exit code\s*[:=]?\s*[1-9])\b/iu;
1110
1208
  export const GREEN_SUCCESS_MARKER_PATTERN = /\b(?:pass|passed|green|ok|0 failed|exit code\s*[:=]?\s*0)\b/iu;
@@ -1129,18 +1227,158 @@ export function hasFailureEdgeInDiagram(sectionBody) {
1129
1227
  export function hasLabeledDiagramArrow(lines) {
1130
1228
  return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
1131
1229
  }
1230
+ /**
1231
+ * Wave 25 (v6.1.0) — accepted async edge patterns. Returns true when
1232
+ * a line carries any of:
1233
+ *
1234
+ * - `-.->`, `-->>`, `~~>` (mermaid dotted/messaging arrows)
1235
+ * - `- - ->` (loose dotted ASCII arrow with optional spaces)
1236
+ * - `.....>` (3-or-more dots followed by `>`)
1237
+ * - `\basync\b` text token (label-based)
1238
+ * - `[async]` bracketed label, `async:` prefix, `async:` cell content
1239
+ *
1240
+ * The error message printed when this fails (see
1241
+ * `validateArchitectureDiagram`) lists every accepted pattern
1242
+ * verbatim so the agent does not have to guess.
1243
+ */
1132
1244
  export function hasAsyncDiagramEdge(lines) {
1133
- return lines.some((line) => /-\.->|-->>|~~>|\basync\b/iu.test(line));
1245
+ return lines.some((line) => {
1246
+ if (/-\.->|-->>|~~>/u.test(line))
1247
+ return true;
1248
+ if (/-(?:\s-){1,}\s?->/u.test(line))
1249
+ return true;
1250
+ if (/\.{3,}\s*>/u.test(line))
1251
+ return true;
1252
+ if (/\basync\b/iu.test(line))
1253
+ return true;
1254
+ if (/\[\s*async\s*\]/iu.test(line))
1255
+ return true;
1256
+ if (/(?:^|[\s|:])async\s*:/iu.test(line))
1257
+ return true;
1258
+ return false;
1259
+ });
1134
1260
  }
1261
+ /**
1262
+ * Wave 25 (v6.1.0) — accepted sync edge patterns. Returns true when a
1263
+ * line carries any of:
1264
+ *
1265
+ * - `\bsync\b` text token (label-based)
1266
+ * - `[sync]` bracketed label, `sync:` prefix, `sync:` cell content
1267
+ * - Solid `-->`, `->`, `=>`, `→`, `⟶`, `↦` arrow that is NOT a known
1268
+ * dotted/async variant (`-.->`, `-->>`, `~~>`)
1269
+ * - `===>` (3+ `=` then `>`) and `--->` (3+ `-` then `>`) heavy solid
1270
+ * arrows
1271
+ */
1135
1272
  export function hasSyncDiagramEdge(lines) {
1136
1273
  return lines.some((line) => {
1137
- if (/\bsync\b/iu.test(line))
1274
+ if (/\bsync\b/iu.test(line) && !/\basync\b/iu.test(line))
1275
+ return true;
1276
+ if (/\[\s*sync\s*\]/iu.test(line))
1277
+ return true;
1278
+ if (/(?:^|[\s|:])sync\s*:/iu.test(line))
1279
+ return true;
1280
+ if (/={2,}>/u.test(line))
1281
+ return true;
1282
+ if (/-{3,}>/u.test(line))
1138
1283
  return true;
1139
1284
  if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
1140
1285
  return false;
1141
- return !/-\.->|-->>|~~>/u.test(line);
1286
+ if (/-\.->|-->>|~~>/u.test(line))
1287
+ return false;
1288
+ if (/-(?:\s-){1,}\s?->/u.test(line))
1289
+ return false;
1290
+ return true;
1142
1291
  });
1143
1292
  }
1293
+ /**
1294
+ * Wave 25 (v6.1.0) — exact accepted-pattern list shown in the error
1295
+ * message when sync/async distinction fails. Keep in sync with
1296
+ * `hasAsyncDiagramEdge` / `hasSyncDiagramEdge` above.
1297
+ */
1298
+ export const DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS = [
1299
+ "Solid arrows: `-->`, `->`, `===>`, `--->`, `=>`, `→`, `⟶`, `↦`",
1300
+ "Dotted/async arrows: `-.->`, `-->>`, `~~>`, `- - ->`, `.....>`",
1301
+ "Text labels on the same line: `sync` / `async`",
1302
+ "Bracket labels: `[sync]` / `[async]`",
1303
+ "Cell-prefix labels: `sync:` / `async:` (e.g. `A -->|sync: persist| B`)"
1304
+ ];
1305
+ /**
1306
+ * Wave 25 (v6.1.0) — Architecture Diagram structural check.
1307
+ *
1308
+ * Promoted out of `validateSectionBody` so it can take a `sections`
1309
+ * map and conditionally enforce the failure-edge rule based on
1310
+ * cross-section context (Failure Mode Table presence + diagram body
1311
+ * mentioning external-dependency keywords).
1312
+ */
1313
+ export function validateArchitectureDiagram(sectionBody, context = {}) {
1314
+ const edgeLines = diagramEdgeLines(sectionBody);
1315
+ if (edgeLines.length === 0) {
1316
+ return {
1317
+ ok: false,
1318
+ details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
1319
+ };
1320
+ }
1321
+ if (!hasLabeledDiagramArrow(edgeLines)) {
1322
+ return {
1323
+ ok: false,
1324
+ details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
1325
+ };
1326
+ }
1327
+ const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
1328
+ if (genericLine) {
1329
+ return {
1330
+ ok: false,
1331
+ details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
1332
+ };
1333
+ }
1334
+ if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
1335
+ const acceptedList = DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS.map((line) => ` - ${line}`).join("\n");
1336
+ return {
1337
+ ok: false,
1338
+ details: `Architecture Diagram must distinguish sync vs async edges. Accepted patterns:\n${acceptedList}\nExample line that satisfies both: \`Browser -->|sync: render| App\` plus \`App -.->|async: log| Telemetry\`.`
1339
+ };
1340
+ }
1341
+ if (!shouldEnforceFailureEdge(sectionBody, context)) {
1342
+ return {
1343
+ ok: true,
1344
+ details: "Architecture Diagram includes labeled directional edges with sync/async distinction; failure-edge enforcement skipped (no failure-mode rows and no external-dependency nodes detected)."
1345
+ };
1346
+ }
1347
+ if (!hasFailureEdgeInDiagram(sectionBody)) {
1348
+ return {
1349
+ ok: false,
1350
+ details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry). Mark a failure path in the diagram (e.g. `App -->|timeout| FallbackCache`)."
1351
+ };
1352
+ }
1353
+ return {
1354
+ ok: true,
1355
+ details: "Architecture Diagram contains labeled edges, sync/async distinction, and a failure-edge."
1356
+ };
1357
+ }
1358
+ /**
1359
+ * Wave 25 (v6.1.0) — decide whether the failure-edge enforcement
1360
+ * should fire for the given Architecture Diagram body. Returns
1361
+ * `false` (skip the rule) when BOTH:
1362
+ * - The artifact's `## Failure Mode Table` (if present) has zero
1363
+ * data rows OR is absent entirely.
1364
+ * - The architecture diagram body mentions NO known external-
1365
+ * dependency keyword (network, db, queue, …).
1366
+ *
1367
+ * Static landing pages (no network, no failure modes) hit this
1368
+ * path. Designs with even one Failure Mode row OR one external
1369
+ * dependency keyword in the diagram fall through to the legacy
1370
+ * blanket failure-edge requirement.
1371
+ */
1372
+ function shouldEnforceFailureEdge(diagramBody, context) {
1373
+ const sections = context.sections ?? null;
1374
+ const failureModeBody = sections ? sectionBodyByName(sections, "Failure Mode Table") : null;
1375
+ const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
1376
+ if (failureModeRowCount > 0)
1377
+ return true;
1378
+ if (DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
1379
+ return true;
1380
+ return false;
1381
+ }
1144
1382
  export function validateTddRedEvidence(sectionBody) {
1145
1383
  const meaningful = meaningfulLineCount(sectionBody);
1146
1384
  if (meaningful < 2) {
@@ -1543,7 +1781,7 @@ export function collectPatternHits(text, patterns) {
1543
1781
  }
1544
1782
  return hits;
1545
1783
  }
1546
- export function validateSectionBody(sectionBody, rule, sectionName) {
1784
+ export function validateSectionBody(sectionBody, rule, sectionName, context = {}) {
1547
1785
  const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
1548
1786
  const meaningful = meaningfulLineCount(sectionBody);
1549
1787
  if (meaningful === 0) {
@@ -1650,41 +1888,13 @@ export function validateSectionBody(sectionBody, rule, sectionName) {
1650
1888
  return validateRequirementsTaxonomy(sectionBody);
1651
1889
  }
1652
1890
  if (sectionNameNormalized === "data flow") {
1653
- return validateInteractionEdgeCaseMatrix(sectionBody);
1891
+ return validateInteractionEdgeCaseMatrix(sectionBody, {
1892
+ sections: context.sections ?? null,
1893
+ liteTier: context.liteTier ?? false
1894
+ });
1654
1895
  }
1655
1896
  if (sectionNameNormalized === "architecture diagram") {
1656
- const edgeLines = diagramEdgeLines(sectionBody);
1657
- if (edgeLines.length === 0) {
1658
- return {
1659
- ok: false,
1660
- details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
1661
- };
1662
- }
1663
- if (!hasLabeledDiagramArrow(edgeLines)) {
1664
- return {
1665
- ok: false,
1666
- details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
1667
- };
1668
- }
1669
- const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
1670
- if (genericLine) {
1671
- return {
1672
- ok: false,
1673
- details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
1674
- };
1675
- }
1676
- if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
1677
- return {
1678
- ok: false,
1679
- details: "Architecture Diagram must distinguish sync vs async edges (for example solid + dotted arrows, or `sync:` and `async:` labels)."
1680
- };
1681
- }
1682
- if (!hasFailureEdgeInDiagram(sectionBody)) {
1683
- return {
1684
- ok: false,
1685
- details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry)."
1686
- };
1687
- }
1897
+ return validateArchitectureDiagram(sectionBody, { sections: context.sections ?? null });
1688
1898
  }
1689
1899
  if (sectionNameNormalized === "acceptance criteria" &&
1690
1900
  /observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {