cclaw-cli 7.7.0 → 8.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.
- package/README.md +210 -134
- package/dist/artifact-frontmatter.d.ts +51 -0
- package/dist/artifact-frontmatter.js +131 -0
- package/dist/artifact-paths.d.ts +7 -27
- package/dist/artifact-paths.js +20 -249
- package/dist/cancel.d.ts +16 -0
- package/dist/cancel.js +66 -0
- package/dist/cli.d.ts +2 -27
- package/dist/cli.js +90 -508
- package/dist/compound.d.ts +26 -0
- package/dist/compound.js +96 -0
- package/dist/config.d.ts +14 -51
- package/dist/config.js +23 -359
- package/dist/constants.d.ts +11 -18
- package/dist/constants.js +19 -106
- package/dist/content/antipatterns.d.ts +1 -0
- package/dist/content/antipatterns.js +109 -0
- package/dist/content/artifact-templates.d.ts +10 -0
- package/dist/content/artifact-templates.js +550 -0
- package/dist/content/cancel-command.d.ts +2 -2
- package/dist/content/cancel-command.js +25 -17
- package/dist/content/core-agents.d.ts +9 -233
- package/dist/content/core-agents.js +39 -766
- package/dist/content/decision-protocol.d.ts +1 -12
- package/dist/content/decision-protocol.js +27 -20
- package/dist/content/examples.d.ts +8 -42
- package/dist/content/examples.js +293 -425
- package/dist/content/idea-command.d.ts +2 -0
- package/dist/content/idea-command.js +38 -0
- package/dist/content/iron-laws.d.ts +4 -138
- package/dist/content/iron-laws.js +18 -197
- package/dist/content/meta-skill.d.ts +1 -3
- package/dist/content/meta-skill.js +57 -132
- package/dist/content/node-hooks.d.ts +12 -8
- package/dist/content/node-hooks.js +188 -838
- package/dist/content/recovery.d.ts +8 -0
- package/dist/content/recovery.js +179 -0
- package/dist/content/reference-patterns.d.ts +4 -13
- package/dist/content/reference-patterns.js +260 -389
- package/dist/content/research-playbooks.d.ts +8 -8
- package/dist/content/research-playbooks.js +108 -121
- package/dist/content/review-loop.d.ts +6 -192
- package/dist/content/review-loop.js +29 -731
- package/dist/content/skills.d.ts +8 -38
- package/dist/content/skills.js +681 -732
- package/dist/content/specialist-prompts/architect.d.ts +1 -0
- package/dist/content/specialist-prompts/architect.js +225 -0
- package/dist/content/specialist-prompts/brainstormer.d.ts +1 -0
- package/dist/content/specialist-prompts/brainstormer.js +168 -0
- package/dist/content/specialist-prompts/index.d.ts +2 -0
- package/dist/content/specialist-prompts/index.js +14 -0
- package/dist/content/specialist-prompts/planner.d.ts +1 -0
- package/dist/content/specialist-prompts/planner.js +182 -0
- package/dist/content/specialist-prompts/reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/reviewer.js +193 -0
- package/dist/content/specialist-prompts/security-reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/security-reviewer.js +133 -0
- package/dist/content/specialist-prompts/slice-builder.d.ts +1 -0
- package/dist/content/specialist-prompts/slice-builder.js +232 -0
- package/dist/content/stage-playbooks.d.ts +8 -0
- package/dist/content/stage-playbooks.js +404 -0
- package/dist/content/start-command.d.ts +2 -12
- package/dist/content/start-command.js +221 -207
- package/dist/flow-state.d.ts +21 -178
- package/dist/flow-state.js +67 -170
- package/dist/fs-utils.d.ts +6 -26
- package/dist/fs-utils.js +29 -162
- package/dist/gitignore.d.ts +2 -1
- package/dist/gitignore.js +51 -34
- package/dist/harness-detect.d.ts +10 -0
- package/dist/harness-detect.js +29 -0
- package/dist/install.d.ts +27 -15
- package/dist/install.js +230 -1342
- package/dist/knowledge-store.d.ts +19 -163
- package/dist/knowledge-store.js +56 -590
- package/dist/logger.d.ts +8 -3
- package/dist/logger.js +13 -4
- package/dist/orchestrator-routing.d.ts +29 -0
- package/dist/orchestrator-routing.js +156 -0
- package/dist/run-persistence.d.ts +7 -118
- package/dist/run-persistence.js +29 -845
- package/dist/runtime/run-hook.entry.d.ts +1 -3
- package/dist/runtime/run-hook.entry.js +19 -4
- package/dist/runtime/run-hook.mjs +13 -1024
- package/dist/types.d.ts +25 -261
- package/dist/types.js +8 -36
- package/package.json +6 -3
- package/dist/artifact-linter/brainstorm.d.ts +0 -2
- package/dist/artifact-linter/brainstorm.js +0 -353
- package/dist/artifact-linter/design.d.ts +0 -18
- package/dist/artifact-linter/design.js +0 -444
- package/dist/artifact-linter/findings-dedup.d.ts +0 -56
- package/dist/artifact-linter/findings-dedup.js +0 -232
- package/dist/artifact-linter/plan.d.ts +0 -2
- package/dist/artifact-linter/plan.js +0 -826
- package/dist/artifact-linter/review-army.d.ts +0 -49
- package/dist/artifact-linter/review-army.js +0 -520
- package/dist/artifact-linter/review.d.ts +0 -2
- package/dist/artifact-linter/review.js +0 -113
- package/dist/artifact-linter/scope.d.ts +0 -2
- package/dist/artifact-linter/scope.js +0 -158
- package/dist/artifact-linter/shared.d.ts +0 -637
- package/dist/artifact-linter/shared.js +0 -2163
- package/dist/artifact-linter/ship.d.ts +0 -2
- package/dist/artifact-linter/ship.js +0 -250
- package/dist/artifact-linter/spec.d.ts +0 -2
- package/dist/artifact-linter/spec.js +0 -176
- package/dist/artifact-linter/tdd.d.ts +0 -118
- package/dist/artifact-linter/tdd.js +0 -1404
- package/dist/artifact-linter.d.ts +0 -15
- package/dist/artifact-linter.js +0 -517
- package/dist/codex-feature-flag.d.ts +0 -58
- package/dist/codex-feature-flag.js +0 -193
- package/dist/content/closeout-guidance.d.ts +0 -14
- package/dist/content/closeout-guidance.js +0 -44
- package/dist/content/diff-command.d.ts +0 -1
- package/dist/content/diff-command.js +0 -43
- package/dist/content/harness-doc.d.ts +0 -1
- package/dist/content/harness-doc.js +0 -65
- package/dist/content/hook-events.d.ts +0 -9
- package/dist/content/hook-events.js +0 -23
- package/dist/content/hook-manifest.d.ts +0 -81
- package/dist/content/hook-manifest.js +0 -156
- package/dist/content/hooks.d.ts +0 -11
- package/dist/content/hooks.js +0 -1972
- package/dist/content/idea.d.ts +0 -60
- package/dist/content/idea.js +0 -416
- package/dist/content/language-policy.d.ts +0 -2
- package/dist/content/language-policy.js +0 -13
- package/dist/content/learnings.d.ts +0 -6
- package/dist/content/learnings.js +0 -141
- package/dist/content/observe.d.ts +0 -19
- package/dist/content/observe.js +0 -86
- package/dist/content/opencode-plugin.d.ts +0 -1
- package/dist/content/opencode-plugin.js +0 -635
- package/dist/content/review-prompts.d.ts +0 -1
- package/dist/content/review-prompts.js +0 -104
- package/dist/content/runtime-shared-snippets.d.ts +0 -8
- package/dist/content/runtime-shared-snippets.js +0 -80
- package/dist/content/session-hooks.d.ts +0 -7
- package/dist/content/session-hooks.js +0 -107
- package/dist/content/skills-elicitation.d.ts +0 -1
- package/dist/content/skills-elicitation.js +0 -167
- package/dist/content/stage-command.d.ts +0 -2
- package/dist/content/stage-command.js +0 -17
- package/dist/content/stage-schema.d.ts +0 -117
- package/dist/content/stage-schema.js +0 -955
- package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
- package/dist/content/stages/_lint-metadata/index.js +0 -97
- package/dist/content/stages/brainstorm.d.ts +0 -2
- package/dist/content/stages/brainstorm.js +0 -184
- package/dist/content/stages/design.d.ts +0 -2
- package/dist/content/stages/design.js +0 -288
- package/dist/content/stages/index.d.ts +0 -8
- package/dist/content/stages/index.js +0 -11
- package/dist/content/stages/plan.d.ts +0 -2
- package/dist/content/stages/plan.js +0 -191
- package/dist/content/stages/review.d.ts +0 -2
- package/dist/content/stages/review.js +0 -240
- package/dist/content/stages/schema-types.d.ts +0 -203
- package/dist/content/stages/schema-types.js +0 -1
- package/dist/content/stages/scope.d.ts +0 -2
- package/dist/content/stages/scope.js +0 -254
- package/dist/content/stages/ship.d.ts +0 -2
- package/dist/content/stages/ship.js +0 -159
- package/dist/content/stages/spec.d.ts +0 -2
- package/dist/content/stages/spec.js +0 -170
- package/dist/content/stages/tdd.d.ts +0 -4
- package/dist/content/stages/tdd.js +0 -273
- package/dist/content/state-contracts.d.ts +0 -1
- package/dist/content/state-contracts.js +0 -63
- package/dist/content/status-command.d.ts +0 -4
- package/dist/content/status-command.js +0 -109
- package/dist/content/subagent-context-skills.d.ts +0 -4
- package/dist/content/subagent-context-skills.js +0 -279
- package/dist/content/subagents.d.ts +0 -3
- package/dist/content/subagents.js +0 -997
- package/dist/content/templates.d.ts +0 -26
- package/dist/content/templates.js +0 -1692
- package/dist/content/track-render-context.d.ts +0 -18
- package/dist/content/track-render-context.js +0 -53
- package/dist/content/tree-command.d.ts +0 -1
- package/dist/content/tree-command.js +0 -64
- package/dist/content/utility-skills.d.ts +0 -30
- package/dist/content/utility-skills.js +0 -160
- package/dist/content/view-command.d.ts +0 -2
- package/dist/content/view-command.js +0 -92
- package/dist/delegation.d.ts +0 -649
- package/dist/delegation.js +0 -1539
- package/dist/early-loop.d.ts +0 -70
- package/dist/early-loop.js +0 -302
- package/dist/execution-topology.d.ts +0 -36
- package/dist/execution-topology.js +0 -73
- package/dist/gate-evidence.d.ts +0 -85
- package/dist/gate-evidence.js +0 -631
- package/dist/harness-adapters.d.ts +0 -151
- package/dist/harness-adapters.js +0 -756
- package/dist/harness-selection.d.ts +0 -31
- package/dist/harness-selection.js +0 -214
- package/dist/hook-schema.d.ts +0 -6
- package/dist/hook-schema.js +0 -114
- package/dist/hook-schemas/claude-hooks.v1.json +0 -10
- package/dist/hook-schemas/codex-hooks.v1.json +0 -10
- package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
- package/dist/init-detect.d.ts +0 -2
- package/dist/init-detect.js +0 -50
- package/dist/internal/advance-stage/advance.d.ts +0 -89
- package/dist/internal/advance-stage/advance.js +0 -655
- package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
- package/dist/internal/advance-stage/cancel-run.js +0 -19
- package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
- package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
- package/dist/internal/advance-stage/helpers.d.ts +0 -14
- package/dist/internal/advance-stage/helpers.js +0 -145
- package/dist/internal/advance-stage/hook.d.ts +0 -8
- package/dist/internal/advance-stage/hook.js +0 -40
- package/dist/internal/advance-stage/parsers.d.ts +0 -72
- package/dist/internal/advance-stage/parsers.js +0 -357
- package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
- package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
- package/dist/internal/advance-stage/review-loop.d.ts +0 -16
- package/dist/internal/advance-stage/review-loop.js +0 -199
- package/dist/internal/advance-stage/rewind.d.ts +0 -14
- package/dist/internal/advance-stage/rewind.js +0 -108
- package/dist/internal/advance-stage/start-flow.d.ts +0 -13
- package/dist/internal/advance-stage/start-flow.js +0 -241
- package/dist/internal/advance-stage/verify.d.ts +0 -21
- package/dist/internal/advance-stage/verify.js +0 -185
- package/dist/internal/advance-stage.d.ts +0 -7
- package/dist/internal/advance-stage.js +0 -138
- package/dist/internal/cohesion-contract-stub.d.ts +0 -24
- package/dist/internal/cohesion-contract-stub.js +0 -148
- package/dist/internal/compound-readiness.d.ts +0 -23
- package/dist/internal/compound-readiness.js +0 -102
- package/dist/internal/detect-public-api-changes.d.ts +0 -5
- package/dist/internal/detect-public-api-changes.js +0 -45
- package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
- package/dist/internal/detect-supply-chain-changes.js +0 -138
- package/dist/internal/early-loop-status.d.ts +0 -7
- package/dist/internal/early-loop-status.js +0 -93
- package/dist/internal/envelope-validate.d.ts +0 -7
- package/dist/internal/envelope-validate.js +0 -66
- package/dist/internal/flow-state-repair.d.ts +0 -20
- package/dist/internal/flow-state-repair.js +0 -104
- package/dist/internal/plan-split-waves.d.ts +0 -190
- package/dist/internal/plan-split-waves.js +0 -764
- package/dist/internal/runtime-integrity.d.ts +0 -7
- package/dist/internal/runtime-integrity.js +0 -268
- package/dist/internal/slice-commit.d.ts +0 -7
- package/dist/internal/slice-commit.js +0 -619
- package/dist/internal/tdd-loop-status.d.ts +0 -14
- package/dist/internal/tdd-loop-status.js +0 -68
- package/dist/internal/tdd-red-evidence.d.ts +0 -7
- package/dist/internal/tdd-red-evidence.js +0 -153
- package/dist/internal/waiver-grant.d.ts +0 -62
- package/dist/internal/waiver-grant.js +0 -294
- package/dist/internal/wave-status.d.ts +0 -63
- package/dist/internal/wave-status.js +0 -450
- package/dist/managed-resources.d.ts +0 -53
- package/dist/managed-resources.js +0 -313
- package/dist/policy.d.ts +0 -10
- package/dist/policy.js +0 -167
- package/dist/retro-gate.d.ts +0 -9
- package/dist/retro-gate.js +0 -47
- package/dist/run-archive.d.ts +0 -61
- package/dist/run-archive.js +0 -391
- package/dist/runs.d.ts +0 -2
- package/dist/runs.js +0 -2
- package/dist/stack-detection.d.ts +0 -116
- package/dist/stack-detection.js +0 -489
- package/dist/streaming/event-stream.d.ts +0 -31
- package/dist/streaming/event-stream.js +0 -114
- package/dist/tdd-cycle.d.ts +0 -107
- package/dist/tdd-cycle.js +0 -289
- package/dist/tdd-verification-evidence.d.ts +0 -17
- package/dist/tdd-verification-evidence.js +0 -122
- package/dist/track-heuristics.d.ts +0 -27
- package/dist/track-heuristics.js +0 -154
- package/dist/util/slice-id.d.ts +0 -58
- package/dist/util/slice-id.js +0 -89
- package/dist/worktree-manager.d.ts +0 -20
- package/dist/worktree-manager.js +0 -108
|
@@ -1,2163 +0,0 @@
|
|
|
1
|
-
import { SHIP_FINALIZATION_MODES } from "../constants.js";
|
|
2
|
-
import { questionBudgetHint } from "../track-heuristics.js";
|
|
3
|
-
import { FLOW_STAGES } from "../types.js";
|
|
4
|
-
import { stageSchema } from "../content/stage-schema.js";
|
|
5
|
-
/**
|
|
6
|
-
* Recognized stop-signal phrases that satisfy the Q&A floor escape hatch
|
|
7
|
-
* when recorded as a Q&A Log row. Mirrors `Stop Signals (Natural Language)`
|
|
8
|
-
* in `adaptive-elicitation/SKILL.md`.
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* Stop-signal phrases. ASCII tokens use `\b` word boundaries; non-ASCII
|
|
12
|
-
* (RU/UA) tokens use Unicode-aware boundaries built from `\p{L}` so cyrillic
|
|
13
|
-
* characters around the phrase prevent partial matches without breaking on
|
|
14
|
-
* `\b`'s ASCII-only boundary semantics.
|
|
15
|
-
*/
|
|
16
|
-
const QA_LOG_STOP_SIGNAL_PATTERNS = [
|
|
17
|
-
/\bstop[-\s]?signal\b/iu,
|
|
18
|
-
/\bachieved\s+enough\b/iu,
|
|
19
|
-
/\benough\b/iu,
|
|
20
|
-
/\bskip\b/iu,
|
|
21
|
-
/\bjust\s+draft\s+it\b/iu,
|
|
22
|
-
/\bstop\s+asking\b/iu,
|
|
23
|
-
/\bmove\s+on\b/iu,
|
|
24
|
-
/\bno\s+more\s+questions\b/iu,
|
|
25
|
-
/(?<![\p{L}\p{N}_])достаточно(?![\p{L}\p{N}_])/iu,
|
|
26
|
-
/(?<![\p{L}\p{N}_])хватит(?![\p{L}\p{N}_])/iu,
|
|
27
|
-
/(?<![\p{L}\p{N}_])давай\s+драфт(?![\p{L}\p{N}_])/iu,
|
|
28
|
-
/(?<![\p{L}\p{N}_])досить(?![\p{L}\p{N}_])/iu,
|
|
29
|
-
/(?<![\p{L}\p{N}_])вистачить(?![\p{L}\p{N}_])/iu,
|
|
30
|
-
/(?<![\p{L}\p{N}_])рухаємось\s+далі(?![\p{L}\p{N}_])/iu
|
|
31
|
-
];
|
|
32
|
-
/**
|
|
33
|
-
* Stages that run adaptive elicitation. The `qa_log_unconverged` rule
|
|
34
|
-
* only fires for these. Other stages may still record a Q&A Log but no
|
|
35
|
-
* convergence floor is enforced.
|
|
36
|
-
*/
|
|
37
|
-
export const ELICITATION_STAGES = new Set([
|
|
38
|
-
"brainstorm",
|
|
39
|
-
"scope",
|
|
40
|
-
"design"
|
|
41
|
-
]);
|
|
42
|
-
/**
|
|
43
|
-
* Phrases that mark a Q&A Log row as "no new decision" — used by the
|
|
44
|
-
* Ralph-Loop convergence detector. When the last 2 substantive rows have
|
|
45
|
-
* a Decision impact tagged with one of these phrases, convergence has
|
|
46
|
-
* been reached even if not every forcing question was explicitly
|
|
47
|
-
* addressed.
|
|
48
|
-
*/
|
|
49
|
-
const QA_LOG_NO_DECISION_TOKENS = [
|
|
50
|
-
/\bskip(?:ped)?\b/iu,
|
|
51
|
-
/\bcontinue\b/iu,
|
|
52
|
-
/\bno[-\s]?change\b/iu,
|
|
53
|
-
/\bno[-\s]?decision\b/iu,
|
|
54
|
-
/\bno[-\s]?op\b/iu,
|
|
55
|
-
/\bnoop\b/iu,
|
|
56
|
-
/\bdone\b/iu,
|
|
57
|
-
/\bsame\b/iu,
|
|
58
|
-
/\bok\b/iu
|
|
59
|
-
];
|
|
60
|
-
/**
|
|
61
|
-
* Decide whether a Q&A Log row counts as a "substantive" entry. Rows
|
|
62
|
-
* whose decision_impact column reads `skipped` / `waived` only do not
|
|
63
|
-
* count.
|
|
64
|
-
*/
|
|
65
|
-
function isSubstantiveQaRow(cells) {
|
|
66
|
-
if (cells.length === 0)
|
|
67
|
-
return false;
|
|
68
|
-
const last = cells[cells.length - 1] ?? "";
|
|
69
|
-
const normalized = last.toLowerCase();
|
|
70
|
-
if (/^\s*(?:skipped|waived)\b/u.test(normalized))
|
|
71
|
-
return false;
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Detect a stop-signal row in the Q&A Log. Pattern is matched across
|
|
76
|
-
* all cells of any row so the user's quote can live in any column.
|
|
77
|
-
*/
|
|
78
|
-
function detectStopSignal(rows) {
|
|
79
|
-
for (const row of rows) {
|
|
80
|
-
const joined = row.join(" | ");
|
|
81
|
-
for (const pattern of QA_LOG_STOP_SIGNAL_PATTERNS) {
|
|
82
|
-
if (pattern.test(joined))
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Validate the kebab-case ASCII shape of a forcing-question topic ID.
|
|
90
|
-
* IDs are short, language-neutral identifiers authors can paste into a
|
|
91
|
-
* `[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 `id: topic; id: topic; ...` syntax — authors
|
|
102
|
-
* fix the stage definition rather than silently ship un-coverable
|
|
103
|
-
* 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. Use \`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
|
-
* Only the `id: topic; id: topic; ...` syntax is accepted. Throws when
|
|
146
|
-
* the syntax is malformed so authors fix the stage definition rather
|
|
147
|
-
* than silently shipping un-coverable topics.
|
|
148
|
-
*
|
|
149
|
-
* Returns empty array when no forcing-questions row is present (caller
|
|
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.
|
|
153
|
-
*/
|
|
154
|
-
export function extractForcingQuestions(stage) {
|
|
155
|
-
let checklist;
|
|
156
|
-
try {
|
|
157
|
-
checklist = stageSchema(stage).executionModel.checklist;
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return [];
|
|
161
|
-
}
|
|
162
|
-
for (const row of checklist) {
|
|
163
|
-
const parsed = parseForcingQuestionsRow(row, `stage=${stage}`);
|
|
164
|
-
if (parsed === null)
|
|
165
|
-
continue;
|
|
166
|
-
return parsed;
|
|
167
|
-
}
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
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.
|
|
175
|
-
*/
|
|
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;
|
|
179
|
-
for (const row of rows) {
|
|
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)
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
function lastTwoRowsAllNoDecision(substantiveRows) {
|
|
192
|
-
if (substantiveRows.length < 2)
|
|
193
|
-
return false;
|
|
194
|
-
const tail = substantiveRows.slice(-2);
|
|
195
|
-
for (const row of tail) {
|
|
196
|
-
const decisionImpact = (row[row.length - 1] ?? "").trim();
|
|
197
|
-
if (decisionImpact.length === 0)
|
|
198
|
-
return false;
|
|
199
|
-
const matched = QA_LOG_NO_DECISION_TOKENS.some((pattern) => pattern.test(decisionImpact));
|
|
200
|
-
if (!matched)
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Evaluate the Q&A Log convergence floor for a brainstorm / scope /
|
|
207
|
-
* design artifact. Returns ok=true when convergence is reached or any
|
|
208
|
-
* escape hatch fires.
|
|
209
|
-
*
|
|
210
|
-
* Convergence sources (any one can set ok=true — see also
|
|
211
|
-
* `adaptiveElicitationSkillMarkdown`):
|
|
212
|
-
* - Every forcing-question topic id from the stage checklist is tagged
|
|
213
|
-
* `[topic:<id>]` on at least one `## Q&A Log` row.
|
|
214
|
-
* - Ralph-Loop path: last 2 substantive rows read as no-new-decisions,
|
|
215
|
-
* substantive count ≥ max(2, questionBudgetHint(discoveryMode, stage).min),
|
|
216
|
-
* and not (guided/deep discovery with pending forcing-topic ids).
|
|
217
|
-
* - Stop-signal row (`QA_LOG_STOP_SIGNAL_PATTERNS`).
|
|
218
|
-
* - `--skip-questions` (`options.skipQuestions`): ok remains false but
|
|
219
|
-
* `skipQuestionsAdvisory` is true (linter treats as non-blocking).
|
|
220
|
-
* - No forcing-questions row in the checklist and ≥1 substantive row.
|
|
221
|
-
*
|
|
222
|
-
* `[topic:<id>]` is the sole topic-coverage signal. The `min` and
|
|
223
|
-
* `liteShortCircuit` fields stay for harness compatibility (min is
|
|
224
|
-
* always 0; liteShortCircuit false).
|
|
225
|
-
*/
|
|
226
|
-
export function evaluateQaLogFloor(qaLogBody, track, stage, options = {}) {
|
|
227
|
-
const rows = qaLogBody !== null ? getMarkdownTableRows(qaLogBody) : [];
|
|
228
|
-
const substantiveRows = rows.filter(isSubstantiveQaRow);
|
|
229
|
-
const count = substantiveRows.length;
|
|
230
|
-
const hasStopSignal = detectStopSignal(rows);
|
|
231
|
-
const skipQuestionsAdvisory = options.skipQuestions === true;
|
|
232
|
-
const discoveryMode = options.discoveryMode ?? (track === "quick" ? "lean" : "guided");
|
|
233
|
-
const forcingTopics = (options.forcingQuestions ?? extractForcingQuestions(stage)).map((entry) => (typeof entry === "string" ? { id: entry, topic: entry } : entry));
|
|
234
|
-
const forcingCovered = [];
|
|
235
|
-
const forcingPending = [];
|
|
236
|
-
for (const topic of forcingTopics) {
|
|
237
|
-
if (isTopicAddressed(topic.id, rows))
|
|
238
|
-
forcingCovered.push(topic.id);
|
|
239
|
-
else
|
|
240
|
-
forcingPending.push(topic.id);
|
|
241
|
-
}
|
|
242
|
-
const budget = questionBudgetHint(discoveryMode, stage);
|
|
243
|
-
const noNewDecisions = lastTwoRowsAllNoDecision(substantiveRows);
|
|
244
|
-
const allForcingCovered = forcingTopics.length > 0 ? forcingPending.length === 0 : count >= 1;
|
|
245
|
-
const minimumRowsReached = count >= Math.max(2, budget.min);
|
|
246
|
-
const riskEscalationNeeded = forcingPending.length > 0 && /^(guided|deep)$/u.test(discoveryMode);
|
|
247
|
-
const noNewDecisionConverged = noNewDecisions && minimumRowsReached && !riskEscalationNeeded;
|
|
248
|
-
const ok = allForcingCovered || noNewDecisionConverged || hasStopSignal;
|
|
249
|
-
const pendingIdsBracket = forcingPending.length > 0
|
|
250
|
-
? `[${forcingPending.join(", ")}]`
|
|
251
|
-
: "[none]";
|
|
252
|
-
let details;
|
|
253
|
-
if (ok) {
|
|
254
|
-
if (allForcingCovered && forcingTopics.length > 0) {
|
|
255
|
-
details = `Q&A Log converged: all ${forcingTopics.length} forcing-question topic(s) addressed across ${count} substantive row(s).`;
|
|
256
|
-
}
|
|
257
|
-
else if (allForcingCovered) {
|
|
258
|
-
details = `Q&A Log converged: stage exposes no forcing-questions row and ${count} substantive entry recorded.`;
|
|
259
|
-
}
|
|
260
|
-
else if (noNewDecisionConverged) {
|
|
261
|
-
const remaining = forcingPending.length > 0
|
|
262
|
-
? ` ${forcingPending.length} forcing topic IDs still pending: ${pendingIdsBracket} after the minimum ${budget.min}-row discovery pass.`
|
|
263
|
-
: ` Ralph-Loop convergence detector says no new decision-changing rows in the last 2 turns after the minimum ${budget.min}-row discovery pass.`;
|
|
264
|
-
details = `Q&A Log converged via no-new-decisions detector at ${count} row(s).${remaining}`;
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
details = `Q&A Log converged: explicit user stop-signal row recorded at ${count} row(s).`;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
else if (skipQuestionsAdvisory) {
|
|
271
|
-
details = `Q&A Log unconverged at ${count} row(s); --skip-questions flag downgraded the finding to advisory. Forcing topic IDs pending: ${pendingIdsBracket}.`;
|
|
272
|
-
}
|
|
273
|
-
else if (noNewDecisions && !minimumRowsReached) {
|
|
274
|
-
details = `Q&A Log still below the minimum ${budget.min}-row ${discoveryMode} discovery pass (${count} substantive row(s)). Forcing topic IDs pending: ${pendingIdsBracket}. Continue asking decision-changing questions before drafting.`;
|
|
275
|
-
}
|
|
276
|
-
else if (riskEscalationNeeded && noNewDecisions) {
|
|
277
|
-
details = `Q&A Log cannot converge via Ralph-Loop yet because ${discoveryMode} mode keeps pending forcing topic IDs blocking: ${pendingIdsBracket}. Cover the remaining topics or record an explicit stop-signal row.`;
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
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, complete the minimum ${budget.min}-row ${discoveryMode} discovery pass, or record an explicit user stop-signal row.`;
|
|
281
|
-
}
|
|
282
|
-
const advisoryBudget = budget.recommended;
|
|
283
|
-
return {
|
|
284
|
-
ok,
|
|
285
|
-
count,
|
|
286
|
-
min: 0,
|
|
287
|
-
hasStopSignal,
|
|
288
|
-
liteShortCircuit: false,
|
|
289
|
-
skipQuestionsAdvisory,
|
|
290
|
-
forcingCovered,
|
|
291
|
-
forcingPending,
|
|
292
|
-
noNewDecisions: noNewDecisionConverged,
|
|
293
|
-
details: advisoryBudget > 0
|
|
294
|
-
? `${details} (advisory budget for ${discoveryMode}/${stage}: ~${advisoryBudget} Q&A turns)`
|
|
295
|
-
: details
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
export function normalizeHeadingTitle(title) {
|
|
299
|
-
return title.trim().replace(/\s+/g, " ");
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Collect H2 sections and body content (`## Section Name`).
|
|
303
|
-
*
|
|
304
|
-
* - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
|
|
305
|
-
* commented `## Approaches` inside an example doesn't open a phantom
|
|
306
|
-
* section and swallow real content.
|
|
307
|
-
* - When the same heading appears more than once at the top level we
|
|
308
|
-
* concatenate the bodies rather than silently overwriting the earlier
|
|
309
|
-
* occurrence. This keeps lint rules honest when authors split a section
|
|
310
|
-
* into multiple passes.
|
|
311
|
-
*/
|
|
312
|
-
export function extractH2Sections(markdown) {
|
|
313
|
-
const sections = new Map();
|
|
314
|
-
const lines = markdown.split(/\r?\n/);
|
|
315
|
-
let currentHeading = null;
|
|
316
|
-
let buffer = [];
|
|
317
|
-
let fenced = null;
|
|
318
|
-
const flush = () => {
|
|
319
|
-
if (currentHeading === null)
|
|
320
|
-
return;
|
|
321
|
-
const existing = sections.get(currentHeading);
|
|
322
|
-
const body = buffer.join("\n");
|
|
323
|
-
sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
|
|
324
|
-
};
|
|
325
|
-
for (const line of lines) {
|
|
326
|
-
const fenceMatch = /^(```|~~~)/u.exec(line);
|
|
327
|
-
if (fenceMatch) {
|
|
328
|
-
if (fenced === null) {
|
|
329
|
-
fenced = fenceMatch[1] ?? null;
|
|
330
|
-
}
|
|
331
|
-
else if (line.startsWith(fenced)) {
|
|
332
|
-
fenced = null;
|
|
333
|
-
}
|
|
334
|
-
if (currentHeading !== null)
|
|
335
|
-
buffer.push(line);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (fenced === null) {
|
|
339
|
-
const match = /^##\s+(.+)$/u.exec(line);
|
|
340
|
-
if (match) {
|
|
341
|
-
flush();
|
|
342
|
-
currentHeading = normalizeHeadingTitle(match[1] ?? "");
|
|
343
|
-
buffer = [];
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (currentHeading !== null) {
|
|
348
|
-
buffer.push(line);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
flush();
|
|
352
|
-
return sections;
|
|
353
|
-
}
|
|
354
|
-
export function duplicateH2Headings(markdown) {
|
|
355
|
-
const lines = markdown.split(/\r?\n/);
|
|
356
|
-
let fenced = null;
|
|
357
|
-
const counts = new Map();
|
|
358
|
-
const displayHeading = new Map();
|
|
359
|
-
for (const line of lines) {
|
|
360
|
-
const fenceMatch = /^(```|~~~)/u.exec(line);
|
|
361
|
-
if (fenceMatch) {
|
|
362
|
-
if (fenced === null) {
|
|
363
|
-
fenced = fenceMatch[1] ?? null;
|
|
364
|
-
}
|
|
365
|
-
else if (line.startsWith(fenced)) {
|
|
366
|
-
fenced = null;
|
|
367
|
-
}
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
if (fenced !== null)
|
|
371
|
-
continue;
|
|
372
|
-
const match = /^##\s+(.+)$/u.exec(line);
|
|
373
|
-
if (!match)
|
|
374
|
-
continue;
|
|
375
|
-
const heading = normalizeHeadingTitle(match[1] ?? "");
|
|
376
|
-
const key = heading.toLowerCase();
|
|
377
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
378
|
-
if (!displayHeading.has(key)) {
|
|
379
|
-
displayHeading.set(key, heading);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
return [...counts.entries()]
|
|
383
|
-
.filter(([, count]) => count > 1)
|
|
384
|
-
.map(([key]) => displayHeading.get(key) ?? key);
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Return the author-authored prose of an artifact, stripping linter meta
|
|
388
|
-
* regions so free-text scans (placeholder tokens, scope-reduction phrases,
|
|
389
|
-
* investigation trigger words) don't self-cannibalize by matching the
|
|
390
|
-
* linter's own templated meta-phrases.
|
|
391
|
-
*
|
|
392
|
-
* Stripping rules (in order):
|
|
393
|
-
* 1. `<!-- linter-meta --> ... <!-- /linter-meta -->` paired blocks.
|
|
394
|
-
* Both markers must appear on their own line; unterminated openings
|
|
395
|
-
* are left as-is so a malformed artifact cannot hide arbitrary
|
|
396
|
-
* content by omitting the closing marker.
|
|
397
|
-
* 2. Every other HTML comment (`<!-- ... -->`, possibly multi-line).
|
|
398
|
-
* 3. Fenced code blocks that are tagged `linter-rule` (e.g.
|
|
399
|
-
* ```` ```linter-rule ````). Plain fenced code blocks are preserved
|
|
400
|
-
* because many stages quote code samples that the linter should
|
|
401
|
-
* still see.
|
|
402
|
-
*
|
|
403
|
-
* The function guarantees the returned string is a strict subset of the
|
|
404
|
-
* original: no characters are synthesized, and line offsets are
|
|
405
|
-
* preserved for any surviving line (blank lines stand in for stripped
|
|
406
|
-
* regions). This keeps regex-based linter checks stable when authors
|
|
407
|
-
* add or remove linter-meta blocks between runs.
|
|
408
|
-
*/
|
|
409
|
-
export function extractAuthoredBody(rawArtifact) {
|
|
410
|
-
if (typeof rawArtifact !== "string" || rawArtifact.length === 0) {
|
|
411
|
-
return "";
|
|
412
|
-
}
|
|
413
|
-
const linterMetaBlock = /^[ \t]*<!--\s*linter-meta\s*-->[\s\S]*?^[ \t]*<!--\s*\/linter-meta\s*-->[ \t]*$/gmu;
|
|
414
|
-
let body = rawArtifact.replace(linterMetaBlock, (match) => match.replace(/[^\n]/gu, ""));
|
|
415
|
-
const htmlComment = /<!--[\s\S]*?-->/gu;
|
|
416
|
-
body = body.replace(htmlComment, (match) => match.replace(/[^\n]/gu, ""));
|
|
417
|
-
const linterRuleFence = /^([ \t]*)(`{3,}|~{3,})\s*linter-rule\b[^\n]*\n[\s\S]*?\n\1\2[ \t]*$/gmu;
|
|
418
|
-
body = body.replace(linterRuleFence, (match) => match.replace(/[^\n]/gu, ""));
|
|
419
|
-
return body;
|
|
420
|
-
}
|
|
421
|
-
export function headingPresent(sections, section) {
|
|
422
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
423
|
-
for (const h of sections.keys()) {
|
|
424
|
-
if (h.toLowerCase() === want) {
|
|
425
|
-
return true;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
return false;
|
|
429
|
-
}
|
|
430
|
-
export function sectionBodyByName(sections, section) {
|
|
431
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
432
|
-
for (const [heading, body] of sections.entries()) {
|
|
433
|
-
if (heading.toLowerCase() === want) {
|
|
434
|
-
return body;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
export function sectionBodyByAnyName(sections, sectionNames) {
|
|
440
|
-
const bodies = sectionNames.flatMap((section) => {
|
|
441
|
-
const body = sectionBodyByName(sections, section);
|
|
442
|
-
return body === null ? [] : [`### ${section}\n${body}`];
|
|
443
|
-
});
|
|
444
|
-
if (bodies.length === 0)
|
|
445
|
-
return null;
|
|
446
|
-
return bodies.join("\n");
|
|
447
|
-
}
|
|
448
|
-
export function sectionBodyByHeadingPrefix(sections, prefix) {
|
|
449
|
-
const want = normalizeHeadingTitle(prefix).toLowerCase();
|
|
450
|
-
for (const [heading, body] of sections.entries()) {
|
|
451
|
-
if (heading.toLowerCase().startsWith(want)) {
|
|
452
|
-
return body;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
export function checkCriticPredictionsContract(sections) {
|
|
458
|
-
const criticFindingsBody = sectionBodyByName(sections, "Critic Findings");
|
|
459
|
-
const layeredReviewBody = sectionBodyByHeadingPrefix(sections, "Layered review");
|
|
460
|
-
const layeredReviewMentionsCritic = layeredReviewBody !== null && /\bcritic\b/iu.test(layeredReviewBody);
|
|
461
|
-
const sourceBody = criticFindingsBody ?? (layeredReviewMentionsCritic ? layeredReviewBody : null);
|
|
462
|
-
if (sourceBody === null)
|
|
463
|
-
return null;
|
|
464
|
-
const predictionsMatch = /(?:^|\n)#{3,4}\s*Pre-commitment predictions\b([\s\S]*?)(?=\n#{2,4}\s+|$)/iu.exec(sourceBody);
|
|
465
|
-
const predictionsCount = predictionsMatch ? countListItems(predictionsMatch[1] ?? "") : 0;
|
|
466
|
-
const hasPredictions = predictionsCount >= 1;
|
|
467
|
-
const hasValidated = /(?:^|\n)#{3,4}\s*Validated\s*\/\s*Disproven\b/iu.test(sourceBody);
|
|
468
|
-
const hasOpenQuestions = /(?:^|\n)#{3,4}\s*Open Questions\b/iu.test(sourceBody);
|
|
469
|
-
const missing = [];
|
|
470
|
-
if (!hasPredictions) {
|
|
471
|
-
missing.push("`Pre-commitment predictions` subsection is missing or has no list items");
|
|
472
|
-
}
|
|
473
|
-
if (!hasValidated) {
|
|
474
|
-
missing.push("`Validated / Disproven` subsection is missing");
|
|
475
|
-
}
|
|
476
|
-
if (!hasOpenQuestions) {
|
|
477
|
-
missing.push("`Open Questions` subsection is missing");
|
|
478
|
-
}
|
|
479
|
-
return {
|
|
480
|
-
found: missing.length === 0,
|
|
481
|
-
details: missing.length === 0
|
|
482
|
-
? "Critic pre-commitment predictions contract is present (predictions, validated/disproven mapping, open questions)."
|
|
483
|
-
: missing.join("; ")
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
const DOCUMENT_REVIEWER_NAMES = [
|
|
487
|
-
"coherence-reviewer",
|
|
488
|
-
"scope-guardian-reviewer",
|
|
489
|
-
"feasibility-reviewer"
|
|
490
|
-
];
|
|
491
|
-
export function evaluateLayeredDocumentReviewStatus(sections, confidenceFindingRegexSource) {
|
|
492
|
-
const layeredReviewBody = sectionBodyByHeadingPrefix(sections, "Layered review");
|
|
493
|
-
if (layeredReviewBody === null)
|
|
494
|
-
return null;
|
|
495
|
-
const triggeredReviewers = DOCUMENT_REVIEWER_NAMES.filter((reviewer) => new RegExp(`\\b${reviewer}\\b`, "iu").test(layeredReviewBody));
|
|
496
|
-
if (triggeredReviewers.length === 0)
|
|
497
|
-
return null;
|
|
498
|
-
const findingRegex = new RegExp(confidenceFindingRegexSource, "iu");
|
|
499
|
-
const hasCalibratedFinding = findingRegex.test(layeredReviewBody);
|
|
500
|
-
const missingStructured = [];
|
|
501
|
-
const failOrPartialWithoutWaiver = [];
|
|
502
|
-
const waiverRegex = /(?:explicit\s+waiver|waiver\s*:|waived\s*:|accepted[-\s]?risk)/iu;
|
|
503
|
-
for (const reviewer of triggeredReviewers) {
|
|
504
|
-
const escaped = reviewer.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
505
|
-
const subsectionMatch = new RegExp(`(?:^|\\n)#{3,4}\\s*${escaped}\\b([\\s\\S]*?)(?=\\n#{2,4}\\s+|$)`, "iu")
|
|
506
|
-
.exec(layeredReviewBody);
|
|
507
|
-
const reviewerBlock = subsectionMatch?.[1] ?? layeredReviewBody;
|
|
508
|
-
const statusMatch = /\b(?:Status|Result|Verdict)\s*:\s*(PASS|PASS_WITH_GAPS|FAIL|PARTIAL|BLOCKED)\b/iu
|
|
509
|
-
.exec(reviewerBlock);
|
|
510
|
-
const inlineStatusMatch = new RegExp(`${escaped}[\\s\\S]{0,120}\\b(PASS|PASS_WITH_GAPS|FAIL|PARTIAL|BLOCKED)\\b`, "iu")
|
|
511
|
-
.exec(layeredReviewBody);
|
|
512
|
-
const status = (statusMatch?.[1] ?? inlineStatusMatch?.[1] ?? "").toUpperCase();
|
|
513
|
-
if (!hasCalibratedFinding || status.length === 0) {
|
|
514
|
-
missingStructured.push(reviewer);
|
|
515
|
-
}
|
|
516
|
-
if ((status === "FAIL" || status === "PARTIAL") && !waiverRegex.test(reviewerBlock) && !waiverRegex.test(layeredReviewBody)) {
|
|
517
|
-
failOrPartialWithoutWaiver.push(`${reviewer}:${status}`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
return {
|
|
521
|
-
triggeredReviewers,
|
|
522
|
-
missingStructured,
|
|
523
|
-
failOrPartialWithoutWaiver
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* Build a regex that matches `<field>: <value>` even when the field name
|
|
528
|
-
* and/or value are wrapped in markdown emphasis (`*`, `**`, `_`, `__`).
|
|
529
|
-
*
|
|
530
|
-
* The shipped templates render fields as `- **Field name:** value`, so any
|
|
531
|
-
* structural check that searches for `Field:\s*token` against the rendered
|
|
532
|
-
* artifact must tolerate the closing `**` between the colon and the value.
|
|
533
|
-
*
|
|
534
|
-
* `field` is treated as literal text (regex meta-characters are escaped).
|
|
535
|
-
* `value` is inserted verbatim so callers can pass alternation
|
|
536
|
-
* (`STARTUP|BUILDER|...`). `flags` defaults to case-insensitive Unicode.
|
|
537
|
-
*/
|
|
538
|
-
export function markdownFieldRegex(field, value, flags = "iu") {
|
|
539
|
-
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
540
|
-
const emph = "[*_]{0,2}";
|
|
541
|
-
const source = `(?:^|[\\s>])${emph}\\s*${escapedField}\\s*${emph}\\s*:\\s*${emph}\\s*(?:${value})\\b`;
|
|
542
|
-
return new RegExp(source, flags);
|
|
543
|
-
}
|
|
544
|
-
export function extractMarkdownSectionBody(markdown, section) {
|
|
545
|
-
return sectionBodyByName(extractH2Sections(markdown), section);
|
|
546
|
-
}
|
|
547
|
-
export function headingLineIndex(markdown, section) {
|
|
548
|
-
const want = normalizeHeadingTitle(section).toLowerCase();
|
|
549
|
-
const lines = markdown.split(/\r?\n/);
|
|
550
|
-
let fenced = null;
|
|
551
|
-
for (let i = 0; i < lines.length; i++) {
|
|
552
|
-
const line = lines[i];
|
|
553
|
-
const fence = /^\s*(```+|~~~+)\s*([A-Za-z0-9_-]+)?\s*$/u.exec(line);
|
|
554
|
-
if (fence) {
|
|
555
|
-
const marker = fence[1];
|
|
556
|
-
if (fenced === null) {
|
|
557
|
-
fenced = marker;
|
|
558
|
-
}
|
|
559
|
-
else if (fenced === marker) {
|
|
560
|
-
fenced = null;
|
|
561
|
-
}
|
|
562
|
-
continue;
|
|
563
|
-
}
|
|
564
|
-
if (fenced !== null)
|
|
565
|
-
continue;
|
|
566
|
-
const heading = /^##\s+(.+)$/u.exec(line);
|
|
567
|
-
if (!heading)
|
|
568
|
-
continue;
|
|
569
|
-
if (normalizeHeadingTitle(heading[1] ?? "").toLowerCase() === want) {
|
|
570
|
-
return i;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return -1;
|
|
574
|
-
}
|
|
575
|
-
export function parseShortCircuitStatus(sectionBody) {
|
|
576
|
-
if (!sectionBody)
|
|
577
|
-
return "";
|
|
578
|
-
const lines = sectionBody.split(/\r?\n/u);
|
|
579
|
-
return lines
|
|
580
|
-
.map((line) => line.replace(/[*_`]/gu, "").trim())
|
|
581
|
-
.map((line) => /^[-*]?\s*status\s*:\s*(.+)$/iu.exec(line)?.[1] ?? "")
|
|
582
|
-
.find((value) => value.trim().length > 0)?.trim().toLowerCase() ?? "";
|
|
583
|
-
}
|
|
584
|
-
export function isShortCircuitActivated(sectionBody) {
|
|
585
|
-
const statusValue = parseShortCircuitStatus(sectionBody);
|
|
586
|
-
return /^(?:activated|yes|true)$/u.test(statusValue) || /\bactivated\b/iu.test(statusValue);
|
|
587
|
-
}
|
|
588
|
-
export function meaningfulLineCount(sectionBody) {
|
|
589
|
-
return sectionBody
|
|
590
|
-
.split(/\r?\n/)
|
|
591
|
-
.map((line) => line.trim())
|
|
592
|
-
.filter((line) => line.length > 0)
|
|
593
|
-
.filter((line) => !line.startsWith("<!--"))
|
|
594
|
-
.filter((line) => !/^[-:| ]+$/u.test(line))
|
|
595
|
-
.filter((line) => /[\p{L}\p{N}]/u.test(line))
|
|
596
|
-
.length;
|
|
597
|
-
}
|
|
598
|
-
export function lineHasToken(line, token) {
|
|
599
|
-
return new RegExp(`\\b${token}\\b`, "u").test(line);
|
|
600
|
-
}
|
|
601
|
-
export function countListItems(sectionBody) {
|
|
602
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
603
|
-
const bullets = lines.filter((line) => /^[-*]\s+\S+/u.test(line)).length;
|
|
604
|
-
const tableRows = lines.filter((line) => /^\|.*\|$/u.test(line) && !/^\|[-:| ]+\|$/u.test(line));
|
|
605
|
-
const tableDataRows = tableRows.length > 0 ? Math.max(0, tableRows.length - 1) : 0;
|
|
606
|
-
return Math.max(bullets, tableDataRows);
|
|
607
|
-
}
|
|
608
|
-
export function parseMarkdownTableRow(line) {
|
|
609
|
-
return line
|
|
610
|
-
.trim()
|
|
611
|
-
.split("|")
|
|
612
|
-
.map((cell) => cell.trim())
|
|
613
|
-
.filter((cell) => cell.length > 0);
|
|
614
|
-
}
|
|
615
|
-
export function tableHeaderCells(sectionBody) {
|
|
616
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
617
|
-
const headerIndex = lines.findIndex((line) => /^\|.*\|$/u.test(line));
|
|
618
|
-
if (headerIndex < 0)
|
|
619
|
-
return null;
|
|
620
|
-
const separator = lines[headerIndex + 1];
|
|
621
|
-
if (!separator || !/^\|[-:| ]+\|$/u.test(separator)) {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
return parseMarkdownTableRow(lines[headerIndex]);
|
|
625
|
-
}
|
|
626
|
-
export function extractMinItemsFromRule(rule) {
|
|
627
|
-
const match = /at least\s+(\d+)/iu.exec(rule);
|
|
628
|
-
if (!match)
|
|
629
|
-
return null;
|
|
630
|
-
const parsed = Number.parseInt(match[1] ?? "", 10);
|
|
631
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
632
|
-
}
|
|
633
|
-
export function tokensFromRule(rule) {
|
|
634
|
-
const allCaps = rule.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [];
|
|
635
|
-
if (allCaps.length > 0) {
|
|
636
|
-
return [...new Set(allCaps)];
|
|
637
|
-
}
|
|
638
|
-
if (/finalization enum token/iu.test(rule)) {
|
|
639
|
-
return [...SHIP_FINALIZATION_MODES];
|
|
640
|
-
}
|
|
641
|
-
if (/final verdict/iu.test(rule)) {
|
|
642
|
-
return ["APPROVED", "APPROVED_WITH_CONCERNS", "BLOCKED"];
|
|
643
|
-
}
|
|
644
|
-
return [];
|
|
645
|
-
}
|
|
646
|
-
export const VAGUE_AC_ADJECTIVES = [
|
|
647
|
-
"fast",
|
|
648
|
-
"quick",
|
|
649
|
-
"slow",
|
|
650
|
-
"fast enough",
|
|
651
|
-
"quickly",
|
|
652
|
-
"intuitive",
|
|
653
|
-
"robust",
|
|
654
|
-
"reliable",
|
|
655
|
-
"scalable",
|
|
656
|
-
"simple",
|
|
657
|
-
"easy",
|
|
658
|
-
"user-friendly",
|
|
659
|
-
"user friendly",
|
|
660
|
-
"nice",
|
|
661
|
-
"good",
|
|
662
|
-
"clean",
|
|
663
|
-
"secure enough",
|
|
664
|
-
"responsive",
|
|
665
|
-
"efficient",
|
|
666
|
-
"performant",
|
|
667
|
-
"smooth",
|
|
668
|
-
"seamless",
|
|
669
|
-
"modern"
|
|
670
|
-
];
|
|
671
|
-
export function isSeparatorRow(line) {
|
|
672
|
-
return /^\|[-:| ]+\|$/u.test(line);
|
|
673
|
-
}
|
|
674
|
-
export function getMarkdownTableRows(sectionBody) {
|
|
675
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
676
|
-
const rows = [];
|
|
677
|
-
let sawSeparator = false;
|
|
678
|
-
for (const line of lines) {
|
|
679
|
-
if (!/^\|.*\|$/u.test(line))
|
|
680
|
-
continue;
|
|
681
|
-
if (isSeparatorRow(line)) {
|
|
682
|
-
sawSeparator = true;
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
if (!sawSeparator)
|
|
686
|
-
continue;
|
|
687
|
-
rows.push(parseMarkdownTableRow(line));
|
|
688
|
-
}
|
|
689
|
-
return rows;
|
|
690
|
-
}
|
|
691
|
-
export function parseBinaryFlag(value) {
|
|
692
|
-
const normalized = value.trim().toLowerCase();
|
|
693
|
-
if (/^(?:y|yes|true|1)$/u.test(normalized))
|
|
694
|
-
return "yes";
|
|
695
|
-
if (/^(?:n|no|false|0|none)$/u.test(normalized))
|
|
696
|
-
return "no";
|
|
697
|
-
return "unknown";
|
|
698
|
-
}
|
|
699
|
-
export function parseKeyedBinaryFlag(value, key) {
|
|
700
|
-
const match = new RegExp(`${key}\\s*=\\s*(y|yes|true|1|n|no|false|0)`, "iu").exec(value);
|
|
701
|
-
if (!match)
|
|
702
|
-
return "unknown";
|
|
703
|
-
return /^(?:y|yes|true|1)$/iu.test(match[1] ?? "") ? "yes" : "no";
|
|
704
|
-
}
|
|
705
|
-
export function parseFailureModeRescueFlag(rescueCell) {
|
|
706
|
-
const keyed = parseKeyedBinaryFlag(rescueCell, "rescued");
|
|
707
|
-
if (keyed !== "unknown")
|
|
708
|
-
return keyed;
|
|
709
|
-
const direct = parseBinaryFlag(rescueCell);
|
|
710
|
-
if (direct !== "unknown")
|
|
711
|
-
return direct;
|
|
712
|
-
if (/\b(?:no rescue|without rescue|unrescued|no fallback|none|absent)\b/iu.test(rescueCell)) {
|
|
713
|
-
return "no";
|
|
714
|
-
}
|
|
715
|
-
if (/\b(?:fallback|retry|degrade|recover|rescue|mitigat)\b/iu.test(rescueCell)) {
|
|
716
|
-
return "yes";
|
|
717
|
-
}
|
|
718
|
-
return "unknown";
|
|
719
|
-
}
|
|
720
|
-
export function parseFailureModeTestFlag(rowText) {
|
|
721
|
-
const keyed = parseKeyedBinaryFlag(rowText, "test");
|
|
722
|
-
if (keyed !== "unknown")
|
|
723
|
-
return keyed;
|
|
724
|
-
if (/\b(?:no tests?|untested|without tests?)\b/iu.test(rowText)) {
|
|
725
|
-
return "no";
|
|
726
|
-
}
|
|
727
|
-
if (/\b(?:tested|has tests?|with tests?|covered by tests?)\b/iu.test(rowText)) {
|
|
728
|
-
return "yes";
|
|
729
|
-
}
|
|
730
|
-
return "unknown";
|
|
731
|
-
}
|
|
732
|
-
export function validateFailureModeTable(sectionBody) {
|
|
733
|
-
const header = tableHeaderCells(sectionBody);
|
|
734
|
-
if (!header) {
|
|
735
|
-
return {
|
|
736
|
-
ok: false,
|
|
737
|
-
details: "Failure Mode Table must include a markdown header row and separator."
|
|
738
|
-
};
|
|
739
|
-
}
|
|
740
|
-
const expectedHeader = ["Method", "Exception", "Rescue", "UserSees"];
|
|
741
|
-
const normalizedHeader = header.map((cell) => cell.toLowerCase());
|
|
742
|
-
const normalizedExpected = expectedHeader.map((cell) => cell.toLowerCase());
|
|
743
|
-
const headerMatches = normalizedHeader.length === normalizedExpected.length &&
|
|
744
|
-
normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
|
|
745
|
-
if (!headerMatches) {
|
|
746
|
-
return {
|
|
747
|
-
ok: false,
|
|
748
|
-
details: `Failure Mode Table header must be exactly: ${expectedHeader.join(" | ")}.`
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
752
|
-
if (rows.length === 0) {
|
|
753
|
-
return {
|
|
754
|
-
ok: false,
|
|
755
|
-
details: "Failure Mode Table must include at least one data row."
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
for (const [index, row] of rows.entries()) {
|
|
759
|
-
if (row.length < 4) {
|
|
760
|
-
return {
|
|
761
|
-
ok: false,
|
|
762
|
-
details: `Failure Mode Table row ${index + 1} must provide 4 columns (Method, Exception, Rescue, UserSees).`
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
const method = (row[0] ?? "").trim();
|
|
766
|
-
const exception = (row[1] ?? "").trim();
|
|
767
|
-
const rescue = (row[2] ?? "").trim();
|
|
768
|
-
const userSees = (row[3] ?? "").trim();
|
|
769
|
-
if (!method || !exception || !rescue || !userSees) {
|
|
770
|
-
return {
|
|
771
|
-
ok: false,
|
|
772
|
-
details: `Failure Mode Table row ${index + 1} must populate all columns (Method, Exception, Rescue, UserSees).`
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
const rescueFlag = parseFailureModeRescueFlag(rescue);
|
|
776
|
-
const testFlag = parseFailureModeTestFlag(`${method} ${exception} ${rescue} ${userSees}`);
|
|
777
|
-
const userSilent = /\bsilent\b/iu.test(userSees);
|
|
778
|
-
if (rescueFlag === "no" && testFlag === "no" && userSilent) {
|
|
779
|
-
return {
|
|
780
|
-
ok: false,
|
|
781
|
-
details: `Failure Mode Table CRITICAL row ${index + 1} (${method}): RESCUED=N + TEST=N + UserSees=Silent. Add rescue path, add test coverage, or make user impact explicit.`
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
return {
|
|
786
|
-
ok: true,
|
|
787
|
-
details: "Failure Mode Table header and critical-risk checks passed."
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
// Canonical scope mode tokens (gstack CEO review). The four mode names live in
|
|
791
|
-
// the scope skill, the artifact template, and downstream traces. Requiring one
|
|
792
|
-
// of them in Scope Summary is **structural** — not free-form English keyword
|
|
793
|
-
// matching on user prose. Authors may also use the canonical short form on a
|
|
794
|
-
// `Mode:` / `Selected mode:` line (e.g. `Selected mode: hold`) as a courtesy.
|
|
795
|
-
export const SCOPE_MODE_FULL_TOKENS = [
|
|
796
|
-
"SCOPE EXPANSION",
|
|
797
|
-
"SELECTIVE EXPANSION",
|
|
798
|
-
"HOLD SCOPE",
|
|
799
|
-
"SCOPE REDUCTION"
|
|
800
|
-
];
|
|
801
|
-
// Short-form synonyms accepted only when stamped on an explicit `Mode:` /
|
|
802
|
-
// `Selected mode:` / `Scope mode:` line. Plain prose with the same word does
|
|
803
|
-
// not count, so `strict` / `broad` / `narrow` / similar non-mode adjectives
|
|
804
|
-
// remain rejected.
|
|
805
|
-
export const SCOPE_MODE_LINE_REGEX = /(?:^|\n)\s*[-*]?\s*\**\s*(?:Selected\s+|Scope\s+)?Mode\**\s*:\s*\**\s*([^\n]+)/iu;
|
|
806
|
-
export const SCOPE_MODE_SHORT_TOKEN_REGEX = /\b(?:hold(?:[\s_-]?scope)?|selective(?:[\s_-]?expansion)?|scope[\s_-]?expansion|expansion|scope[\s_-]?reduction|reduction|expand|reduce)\b/iu;
|
|
807
|
-
export const SPEC_MAX_MODULES = 5;
|
|
808
|
-
// Next-stage handoff token. We only enforce the canonical machine-surface stage
|
|
809
|
-
// IDs (`design`, `spec`) plus stable handoff phrases. The surrounding prose may
|
|
810
|
-
// be written in any language — this guards the downstream cross-stage trace,
|
|
811
|
-
// not the wording of the rationale.
|
|
812
|
-
export const NEXT_STAGE_HANDOFF_REGEX = /(?:`(?:design|spec)`|\bdesign\b|\bspec\b|next[-\s_]stage|next stage|handoff|hand[-\s]off)/iu;
|
|
813
|
-
export function hasCanonicalScopeMode(body) {
|
|
814
|
-
return extractCanonicalScopeMode(body) !== null;
|
|
815
|
-
}
|
|
816
|
-
export function canonicalModesInText(text) {
|
|
817
|
-
const normalized = text
|
|
818
|
-
.toUpperCase()
|
|
819
|
-
.replace(/[_-]+/gu, " ")
|
|
820
|
-
.replace(/\s+/gu, " ")
|
|
821
|
-
.trim();
|
|
822
|
-
const hits = [];
|
|
823
|
-
if (/\bSCOPE EXPANSION\b/u.test(normalized))
|
|
824
|
-
hits.push("SCOPE EXPANSION");
|
|
825
|
-
if (/\bSELECTIVE EXPANSION\b/u.test(normalized))
|
|
826
|
-
hits.push("SELECTIVE EXPANSION");
|
|
827
|
-
if (/\bHOLD SCOPE\b/u.test(normalized))
|
|
828
|
-
hits.push("HOLD SCOPE");
|
|
829
|
-
if (/\bSCOPE REDUCTION\b/u.test(normalized))
|
|
830
|
-
hits.push("SCOPE REDUCTION");
|
|
831
|
-
return hits;
|
|
832
|
-
}
|
|
833
|
-
export function shortModeToCanonical(text) {
|
|
834
|
-
if (!SCOPE_MODE_SHORT_TOKEN_REGEX.test(text))
|
|
835
|
-
return null;
|
|
836
|
-
const normalized = text
|
|
837
|
-
.toLowerCase()
|
|
838
|
-
.replace(/[_-]+/gu, " ")
|
|
839
|
-
.replace(/\s+/gu, " ");
|
|
840
|
-
if (/\bselective(?:\s+expansion)?\b/u.test(normalized))
|
|
841
|
-
return "SELECTIVE EXPANSION";
|
|
842
|
-
if (/\bhold(?:\s+scope)?\b/u.test(normalized))
|
|
843
|
-
return "HOLD SCOPE";
|
|
844
|
-
if (/\b(?:scope\s+reduction|reduction|reduce)\b/u.test(normalized))
|
|
845
|
-
return "SCOPE REDUCTION";
|
|
846
|
-
if (/\b(?:scope\s+expansion|expansion|expand)\b/u.test(normalized))
|
|
847
|
-
return "SCOPE EXPANSION";
|
|
848
|
-
return null;
|
|
849
|
-
}
|
|
850
|
-
export function canonicalModeFromCandidate(candidate) {
|
|
851
|
-
const canonicalHits = canonicalModesInText(candidate);
|
|
852
|
-
if (canonicalHits.length === 1)
|
|
853
|
-
return canonicalHits[0];
|
|
854
|
-
if (canonicalHits.length > 1)
|
|
855
|
-
return null;
|
|
856
|
-
return shortModeToCanonical(candidate);
|
|
857
|
-
}
|
|
858
|
-
export function extractCanonicalScopeMode(body) {
|
|
859
|
-
// Strict: a Mode: / Selected mode: line that picks exactly ONE canonical mode
|
|
860
|
-
// is the strongest signal. The template scaffolding contains all four mode
|
|
861
|
-
// tokens inside an instructional `(one of ...)` placeholder; we ignore that
|
|
862
|
-
// line so authors who never replace the scaffolding still fail validation.
|
|
863
|
-
for (const match of body.matchAll(new RegExp(SCOPE_MODE_LINE_REGEX, "giu"))) {
|
|
864
|
-
const raw = (match[1] ?? "").trim();
|
|
865
|
-
const sanitized = raw.replace(/\(.*?\)/gu, "").trim();
|
|
866
|
-
if (sanitized.length === 0)
|
|
867
|
-
continue;
|
|
868
|
-
const mode = canonicalModeFromCandidate(sanitized);
|
|
869
|
-
if (mode)
|
|
870
|
-
return mode;
|
|
871
|
-
}
|
|
872
|
-
// Fallback: any line outside an instructional `(one of ...)` placeholder
|
|
873
|
-
// names exactly one mode. Block lines that list multiple modes (the
|
|
874
|
-
// unfilled template) or are wrapped in an instructional parenthetical.
|
|
875
|
-
for (const rawLine of body.split(/\r?\n/u)) {
|
|
876
|
-
const line = rawLine.trim();
|
|
877
|
-
if (line.length === 0)
|
|
878
|
-
continue;
|
|
879
|
-
if (/\(\s*one\s+of\b/iu.test(line))
|
|
880
|
-
continue;
|
|
881
|
-
const sanitized = line.replace(/\(.*?\)/gu, "");
|
|
882
|
-
const mode = canonicalModeFromCandidate(sanitized);
|
|
883
|
-
if (mode)
|
|
884
|
-
return mode;
|
|
885
|
-
}
|
|
886
|
-
return null;
|
|
887
|
-
}
|
|
888
|
-
// Premise challenge is owned solely by brainstorm (`## Premise Check`);
|
|
889
|
-
// scope only records `## Premise Drift` when scope-stage Q&A surfaces
|
|
890
|
-
// new evidence that materially changes the brainstorm answer. The
|
|
891
|
-
// drift section is optional and structural-only via the default
|
|
892
|
-
// `validateSectionBody` path (no specialized validator required).
|
|
893
|
-
export function validateScopeSummary(sectionBody) {
|
|
894
|
-
const meaningfulLines = sectionBody
|
|
895
|
-
.split(/\r?\n/)
|
|
896
|
-
.map((line) => line.trim())
|
|
897
|
-
.filter((line) => line.length > 0 && /[\p{L}\p{N}]/u.test(line));
|
|
898
|
-
if (meaningfulLines.length < 2) {
|
|
899
|
-
return {
|
|
900
|
-
ok: false,
|
|
901
|
-
details: "Scope Summary must list at least 2 substantive lines covering the selected mode and the next-stage handoff."
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
if (!hasCanonicalScopeMode(sectionBody)) {
|
|
905
|
-
return {
|
|
906
|
-
ok: false,
|
|
907
|
-
details: "Scope Summary must name the selected mode using a canonical token (SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, SCOPE REDUCTION) or a short form on a `Mode:` line (hold, selective, expansion, reduction)."
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
if (!NEXT_STAGE_HANDOFF_REGEX.test(sectionBody)) {
|
|
911
|
-
return {
|
|
912
|
-
ok: false,
|
|
913
|
-
details: "Scope Summary must record the track-aware next-stage handoff (mention `design` for standard, `spec` for medium, or include a `Next-stage handoff:` line)."
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
return {
|
|
917
|
-
ok: true,
|
|
918
|
-
details: "Scope Summary names the selected mode and the next-stage handoff."
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
export const APPROACH_ROLE_VALUES = ["baseline", "challenger", "wild-card"];
|
|
922
|
-
export const APPROACH_UPSIDE_VALUES = ["low", "modest", "high", "higher"];
|
|
923
|
-
export const REQUIREMENT_PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "DROPPED"];
|
|
924
|
-
export function normalizeTableToken(value) {
|
|
925
|
-
return value
|
|
926
|
-
.replace(/[`*_]/gu, "")
|
|
927
|
-
.trim()
|
|
928
|
-
.toLowerCase()
|
|
929
|
-
.replace(/\s+/gu, "-");
|
|
930
|
-
}
|
|
931
|
-
export function columnIndex(header, expected) {
|
|
932
|
-
return header.findIndex((cell) => normalizeTableToken(cell) === expected);
|
|
933
|
-
}
|
|
934
|
-
export function validateApproachesTaxonomy(sectionBody) {
|
|
935
|
-
const header = tableHeaderCells(sectionBody);
|
|
936
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
937
|
-
if (!header) {
|
|
938
|
-
return {
|
|
939
|
-
rowCount: 0,
|
|
940
|
-
roleUpsideOk: false,
|
|
941
|
-
challengerOk: false,
|
|
942
|
-
details: "Approaches must be a markdown table with canonical Role and Upside columns."
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
const roleIndex = columnIndex(header, "role");
|
|
946
|
-
const upsideIndex = columnIndex(header, "upside");
|
|
947
|
-
if (roleIndex < 0 || upsideIndex < 0) {
|
|
948
|
-
const firstColumnTokens = rows.map((row) => normalizeTableToken(row[0] ?? ""));
|
|
949
|
-
const appearsTransposed = firstColumnTokens.includes("role") || firstColumnTokens.includes("upside");
|
|
950
|
-
return {
|
|
951
|
-
rowCount: rows.length,
|
|
952
|
-
roleUpsideOk: false,
|
|
953
|
-
challengerOk: false,
|
|
954
|
-
details: appearsTransposed
|
|
955
|
-
? "Approaches table appears transposed: `Role`/`Upside` are rows, but must be columns. Use `| Approach | Role | Upside | ... |` with one approach per row."
|
|
956
|
-
: "Approaches table must include canonical `Role` and `Upside` columns (Role: baseline | challenger | wild-card; Upside: low | modest | high | higher)."
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
let challengerRows = 0;
|
|
960
|
-
let challengerHasHighUpside = false;
|
|
961
|
-
for (const [index, row] of rows.entries()) {
|
|
962
|
-
const role = normalizeTableToken(row[roleIndex] ?? "");
|
|
963
|
-
const upside = normalizeTableToken(row[upsideIndex] ?? "");
|
|
964
|
-
if (!APPROACH_ROLE_VALUES.includes(role)) {
|
|
965
|
-
return {
|
|
966
|
-
rowCount: rows.length,
|
|
967
|
-
roleUpsideOk: false,
|
|
968
|
-
challengerOk: false,
|
|
969
|
-
details: `Approaches row ${index + 1} has invalid Role "${row[roleIndex] ?? ""}". Expected one of: ${APPROACH_ROLE_VALUES.join(", ")}.`
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
if (!APPROACH_UPSIDE_VALUES.includes(upside)) {
|
|
973
|
-
return {
|
|
974
|
-
rowCount: rows.length,
|
|
975
|
-
roleUpsideOk: false,
|
|
976
|
-
challengerOk: false,
|
|
977
|
-
details: `Approaches row ${index + 1} has invalid Upside "${row[upsideIndex] ?? ""}". Expected one of: ${APPROACH_UPSIDE_VALUES.join(", ")}.`
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
if (role === "challenger") {
|
|
981
|
-
challengerRows += 1;
|
|
982
|
-
if (upside === "high" || upside === "higher") {
|
|
983
|
-
challengerHasHighUpside = true;
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
const challengerOk = challengerRows === 1 && challengerHasHighUpside;
|
|
988
|
-
return {
|
|
989
|
-
rowCount: rows.length,
|
|
990
|
-
roleUpsideOk: true,
|
|
991
|
-
challengerOk,
|
|
992
|
-
details: challengerOk
|
|
993
|
-
? "Approaches table uses canonical Role/Upside values and exactly one high/higher-upside challenger."
|
|
994
|
-
: `Approaches table must include exactly one challenger row with Upside high or higher. Found ${challengerRows} challenger row(s).`
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
export function validateCalibratedSelfReview(sectionBody) {
|
|
998
|
-
const statusLineMatch = /^\s*-\s*Status:\s*(.*)$/imu.exec(sectionBody);
|
|
999
|
-
const statusValue = statusLineMatch ? statusLineMatch[1].trim() : "";
|
|
1000
|
-
const mentionsApproved = /\bApproved\b/iu.test(statusValue);
|
|
1001
|
-
const mentionsIssuesFound = /\bIssues Found\b/iu.test(statusValue);
|
|
1002
|
-
const statusPickedExactlyOne = statusLineMatch !== null && (mentionsApproved !== mentionsIssuesFound);
|
|
1003
|
-
const hasPatchesHeader = /^\s*-\s*Patches applied:/imu.test(sectionBody);
|
|
1004
|
-
const hasConcernsHeader = /^\s*-\s*Remaining concerns:/imu.test(sectionBody);
|
|
1005
|
-
if (statusPickedExactlyOne && hasPatchesHeader && hasConcernsHeader) {
|
|
1006
|
-
return {
|
|
1007
|
-
ok: true,
|
|
1008
|
-
details: "Self-Review Notes use the calibrated review prompt format."
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
const problems = [];
|
|
1012
|
-
if (!statusLineMatch) {
|
|
1013
|
-
problems.push("missing `- Status:` line");
|
|
1014
|
-
}
|
|
1015
|
-
else if (!mentionsApproved && !mentionsIssuesFound) {
|
|
1016
|
-
problems.push("`- Status:` must include `Approved` or `Issues Found`");
|
|
1017
|
-
}
|
|
1018
|
-
else if (mentionsApproved && mentionsIssuesFound) {
|
|
1019
|
-
problems.push("`- Status:` must pick exactly one of `Approved` or `Issues Found` (the placeholder `Approved | Issues Found` is not a decision)");
|
|
1020
|
-
}
|
|
1021
|
-
if (!hasPatchesHeader)
|
|
1022
|
-
problems.push("missing `- Patches applied:` line");
|
|
1023
|
-
if (!hasConcernsHeader)
|
|
1024
|
-
problems.push("missing `- Remaining concerns:` line");
|
|
1025
|
-
return {
|
|
1026
|
-
ok: false,
|
|
1027
|
-
details: "Self-Review Notes must use the calibrated review prompt format: `- Status: Approved` (or `Issues Found`), `- Patches applied:` (inline note or sub-bullets), and `- Remaining concerns:` (inline note or sub-bullets). Issues: " +
|
|
1028
|
-
problems.join("; ") +
|
|
1029
|
-
"."
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
export function validateRequirementsTaxonomy(sectionBody) {
|
|
1033
|
-
const header = tableHeaderCells(sectionBody);
|
|
1034
|
-
if (!header) {
|
|
1035
|
-
return {
|
|
1036
|
-
ok: false,
|
|
1037
|
-
details: "Requirements must be a markdown table with a Priority column."
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
const priorityIndex = columnIndex(header, "priority");
|
|
1041
|
-
if (priorityIndex < 0) {
|
|
1042
|
-
return {
|
|
1043
|
-
ok: false,
|
|
1044
|
-
details: "Requirements table must include a canonical `Priority` column."
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
1048
|
-
if (rows.length === 0) {
|
|
1049
|
-
return {
|
|
1050
|
-
ok: false,
|
|
1051
|
-
details: "Requirements table must include at least one requirement row."
|
|
1052
|
-
};
|
|
1053
|
-
}
|
|
1054
|
-
for (const [index, row] of rows.entries()) {
|
|
1055
|
-
const rawPriority = (row[priorityIndex] ?? "").replace(/[`*_]/gu, "").trim().toUpperCase();
|
|
1056
|
-
if (!REQUIREMENT_PRIORITY_VALUES.includes(rawPriority)) {
|
|
1057
|
-
return {
|
|
1058
|
-
ok: false,
|
|
1059
|
-
details: `Requirements row ${index + 1} has invalid Priority "${row[priorityIndex] ?? ""}". Expected one of: ${REQUIREMENT_PRIORITY_VALUES.join(", ")}.`
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
return {
|
|
1064
|
-
ok: true,
|
|
1065
|
-
details: "Requirements table uses canonical Priority values."
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
export const INTERACTION_EDGE_CASE_REQUIREMENTS = [
|
|
1069
|
-
{ label: "double-click", pattern: /\bdouble[\s-]?click\b/iu },
|
|
1070
|
-
{
|
|
1071
|
-
label: "nav-away-mid-request",
|
|
1072
|
-
pattern: /\b(?:nav(?:igate)?[\s-]?away(?:[\s-]?mid[\s-]?request)?|leave\s+(?:page|view|screen).*(?:request|save|submit)|close\s+tab.*(?:request|save|submit))\b/iu
|
|
1073
|
-
},
|
|
1074
|
-
{
|
|
1075
|
-
label: "10K-result dataset",
|
|
1076
|
-
pattern: /\b(?:10k(?:[\s-]?result)?|10,?000|large[\s-]?result(?:[\s-]?dataset)?)\b/iu
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
label: "background-job abandonment",
|
|
1080
|
-
pattern: /\b(?:background[\s-]?job.*abandon(?:ed|ment)?|abandon(?:ed|ment)?.*background[\s-]?job)\b/iu
|
|
1081
|
-
},
|
|
1082
|
-
{ label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
|
|
1083
|
-
];
|
|
1084
|
-
const INTERACTION_EDGE_CASE_NA_PATTERN = /^\s*n\s*\/\s*a\b/iu;
|
|
1085
|
-
const INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN = /^\s*n\s*\/\s*a\s*[—–\-:]\s*\S/iu;
|
|
1086
|
-
const INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS = new Set([
|
|
1087
|
-
"nav-away-mid-request",
|
|
1088
|
-
"10K-result dataset",
|
|
1089
|
-
"background-job abandonment",
|
|
1090
|
-
"zombie connection"
|
|
1091
|
-
]);
|
|
1092
|
-
function shouldRelaxNetworkDependentEdgeCases(context) {
|
|
1093
|
-
if (!context.liteTier)
|
|
1094
|
-
return false;
|
|
1095
|
-
const sections = context.sections ?? null;
|
|
1096
|
-
if (!sections)
|
|
1097
|
-
return true;
|
|
1098
|
-
const diagramBody = sectionBodyByName(sections, "Architecture Diagram");
|
|
1099
|
-
const failureModeBody = sectionBodyByName(sections, "Failure Mode Table");
|
|
1100
|
-
const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
|
|
1101
|
-
if (failureModeRowCount > 0)
|
|
1102
|
-
return false;
|
|
1103
|
-
if (diagramBody && DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
|
|
1104
|
-
return false;
|
|
1105
|
-
return true;
|
|
1106
|
-
}
|
|
1107
|
-
export function validateInteractionEdgeCaseMatrix(sectionBody, context = {}) {
|
|
1108
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
1109
|
-
const relaxNetworkRows = shouldRelaxNetworkDependentEdgeCases(context);
|
|
1110
|
-
if (rows.length === 0) {
|
|
1111
|
-
if (relaxNetworkRows) {
|
|
1112
|
-
return {
|
|
1113
|
-
ok: true,
|
|
1114
|
-
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)."
|
|
1115
|
-
};
|
|
1116
|
-
}
|
|
1117
|
-
return {
|
|
1118
|
-
ok: false,
|
|
1119
|
-
details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
const seen = new Map();
|
|
1123
|
-
for (const [, row] of rows.entries()) {
|
|
1124
|
-
const labelCell = (row[0] ?? "").trim();
|
|
1125
|
-
if (!labelCell)
|
|
1126
|
-
continue;
|
|
1127
|
-
const requirement = INTERACTION_EDGE_CASE_REQUIREMENTS.find((candidate) => candidate.pattern.test(labelCell));
|
|
1128
|
-
if (!requirement)
|
|
1129
|
-
continue;
|
|
1130
|
-
if (row.length < 4) {
|
|
1131
|
-
return {
|
|
1132
|
-
ok: false,
|
|
1133
|
-
details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
const handledRaw = (row[1] ?? "").trim();
|
|
1137
|
-
const handled = parseBinaryFlag(handledRaw);
|
|
1138
|
-
const response = (row[2] ?? "").trim();
|
|
1139
|
-
const deferred = (row[3] ?? "").trim();
|
|
1140
|
-
const isNA = INTERACTION_EDGE_CASE_NA_PATTERN.test(handledRaw);
|
|
1141
|
-
if (handled === "unknown" && !isNA) {
|
|
1142
|
-
return {
|
|
1143
|
-
ok: false,
|
|
1144
|
-
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.`
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
if (isNA) {
|
|
1148
|
-
// `N/A — <reason>` short-circuits both the "must mark yes/no"
|
|
1149
|
-
// rule and the "must reference a deferred item id" rule. The
|
|
1150
|
-
// reason satisfies justification.
|
|
1151
|
-
const hasReason = INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN.test(handledRaw) || response.length > 0;
|
|
1152
|
-
if (!hasReason) {
|
|
1153
|
-
return {
|
|
1154
|
-
ok: false,
|
|
1155
|
-
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.`
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
seen.set(requirement.label, true);
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
if (!response) {
|
|
1162
|
-
return {
|
|
1163
|
-
ok: false,
|
|
1164
|
-
details: `Interaction Edge Case row "${requirement.label}" must describe the design response.`
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
|
|
1168
|
-
return {
|
|
1169
|
-
ok: false,
|
|
1170
|
-
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>\`.`
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
seen.set(requirement.label, true);
|
|
1174
|
-
}
|
|
1175
|
-
const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
|
|
1176
|
-
.map((requirement) => requirement.label)
|
|
1177
|
-
.filter((label) => !seen.has(label));
|
|
1178
|
-
const stillMissing = relaxNetworkRows
|
|
1179
|
-
? missing.filter((label) => !INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
|
|
1180
|
-
: missing;
|
|
1181
|
-
const advisoryMissing = relaxNetworkRows
|
|
1182
|
-
? missing.filter((label) => INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
|
|
1183
|
-
: [];
|
|
1184
|
-
if (stillMissing.length > 0) {
|
|
1185
|
-
const advisoryNote = advisoryMissing.length > 0
|
|
1186
|
-
? ` (${advisoryMissing.length} network-dependent row(s) demoted to advisory by lite-tier no-network detection: ${advisoryMissing.join(", ")})`
|
|
1187
|
-
: "";
|
|
1188
|
-
return {
|
|
1189
|
-
ok: false,
|
|
1190
|
-
details: `Interaction Edge Case matrix is missing required row(s): ${stillMissing.join(", ")}${advisoryNote}.`
|
|
1191
|
-
};
|
|
1192
|
-
}
|
|
1193
|
-
const advisoryNote = advisoryMissing.length > 0
|
|
1194
|
-
? ` (${advisoryMissing.length} network-dependent row(s) advisory under lite-tier no-network: ${advisoryMissing.join(", ")})`
|
|
1195
|
-
: "";
|
|
1196
|
-
return {
|
|
1197
|
-
ok: true,
|
|
1198
|
-
details: `Interaction Edge Case matrix contains all required rows with handled/deferred status${advisoryNote}.`
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
export const PRE_SCOPE_AUDIT_SIGNALS = [
|
|
1202
|
-
{ label: "git log -30 --oneline", pattern: /\bgit\s+log\b[^\n]*-30[^\n]*\boneline\b/iu },
|
|
1203
|
-
{ label: "git diff --stat", pattern: /\bgit\s+diff\b[^\n]*--stat\b/iu },
|
|
1204
|
-
{ label: "git stash list", pattern: /\bgit\s+stash\s+list\b/iu },
|
|
1205
|
-
{
|
|
1206
|
-
label: "debt marker scan (TODO|FIXME|XXX|HACK)",
|
|
1207
|
-
pattern: /\b(?:rg|ripgrep)\b[^\n]*(?:TODO|FIXME|XXX|HACK)|\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b/iu
|
|
1208
|
-
}
|
|
1209
|
-
];
|
|
1210
|
-
export function validatePreScopeSystemAudit(sectionBody) {
|
|
1211
|
-
const missing = PRE_SCOPE_AUDIT_SIGNALS
|
|
1212
|
-
.filter((signal) => !signal.pattern.test(sectionBody))
|
|
1213
|
-
.map((signal) => signal.label);
|
|
1214
|
-
if (missing.length > 0) {
|
|
1215
|
-
return {
|
|
1216
|
-
ok: false,
|
|
1217
|
-
details: `Pre-Scope System Audit is missing required signal(s): ${missing.join(", ")}.`
|
|
1218
|
-
};
|
|
1219
|
-
}
|
|
1220
|
-
return {
|
|
1221
|
-
ok: true,
|
|
1222
|
-
details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
|
|
1223
|
-
};
|
|
1224
|
-
}
|
|
1225
|
-
export const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦|={2,}>|-{3,}>|\.{3,}>|-(?:\s-){1,}\s?->)/u;
|
|
1226
|
-
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;
|
|
1227
|
-
export const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
|
|
1228
|
-
/**
|
|
1229
|
-
* external-dependency keywords that trigger the
|
|
1230
|
-
* failure-edge requirement. The architecture diagram is allowed to
|
|
1231
|
-
* omit failure edges only when ALL of:
|
|
1232
|
-
* - Failure Mode Table has zero rows.
|
|
1233
|
-
* - The diagram body mentions no external-dependency keyword.
|
|
1234
|
-
*
|
|
1235
|
-
* Static landing pages (3 HTML/CSS/JS files, no network) match this:
|
|
1236
|
-
* no failure modes to map, no external systems to fail. The previous
|
|
1237
|
-
* blanket "must include at least one failure-edge" rule produced
|
|
1238
|
-
* ceremony-only failures that the agent worked around with fake
|
|
1239
|
-
* `(timeout)` annotations, defeating the spirit of the rule.
|
|
1240
|
-
*/
|
|
1241
|
-
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;
|
|
1242
|
-
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;
|
|
1243
|
-
export const RED_FAILURE_MARKER_PATTERN = /\b(?:fail|failed|failing|assertionerror|cannot find|exception|error|exit code\s*[:=]?\s*[1-9])\b/iu;
|
|
1244
|
-
export const GREEN_SUCCESS_MARKER_PATTERN = /\b(?:pass|passed|green|ok|0 failed|exit code\s*[:=]?\s*0)\b/iu;
|
|
1245
|
-
export function diagramEdgeLines(sectionBody) {
|
|
1246
|
-
return sectionBody
|
|
1247
|
-
.split(/\r?\n/)
|
|
1248
|
-
.map((line) => line.trim())
|
|
1249
|
-
.filter((line) => line.length > 0)
|
|
1250
|
-
.filter((line) => !line.startsWith("```"))
|
|
1251
|
-
.filter((line) => !line.startsWith("%%"))
|
|
1252
|
-
.filter((line) => DIAGRAM_ARROW_PATTERN.test(line));
|
|
1253
|
-
}
|
|
1254
|
-
export function hasFailureEdgeInDiagram(sectionBody) {
|
|
1255
|
-
const lines = diagramEdgeLines(sectionBody);
|
|
1256
|
-
for (const line of lines) {
|
|
1257
|
-
if (DIAGRAM_ARROW_PATTERN.test(line) && DIAGRAM_FAILURE_EDGE_PATTERN.test(line)) {
|
|
1258
|
-
return true;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return false;
|
|
1262
|
-
}
|
|
1263
|
-
export function hasLabeledDiagramArrow(lines) {
|
|
1264
|
-
return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
|
|
1265
|
-
}
|
|
1266
|
-
/**
|
|
1267
|
-
* accepted async edge patterns. Returns true when
|
|
1268
|
-
* a line carries any of:
|
|
1269
|
-
*
|
|
1270
|
-
* - `-.->`, `-->>`, `~~>` (mermaid dotted/messaging arrows)
|
|
1271
|
-
* - `- - ->` (loose dotted ASCII arrow with optional spaces)
|
|
1272
|
-
* - `.....>` (3-or-more dots followed by `>`)
|
|
1273
|
-
* - `\basync\b` text token (label-based)
|
|
1274
|
-
* - `[async]` bracketed label, `async:` prefix, `async:` cell content
|
|
1275
|
-
*
|
|
1276
|
-
* The error message printed when this fails (see
|
|
1277
|
-
* `validateArchitectureDiagram`) lists every accepted pattern
|
|
1278
|
-
* verbatim so the agent does not have to guess.
|
|
1279
|
-
*/
|
|
1280
|
-
export function hasAsyncDiagramEdge(lines) {
|
|
1281
|
-
return lines.some((line) => {
|
|
1282
|
-
if (/-\.->|-->>|~~>/u.test(line))
|
|
1283
|
-
return true;
|
|
1284
|
-
if (/-(?:\s-){1,}\s?->/u.test(line))
|
|
1285
|
-
return true;
|
|
1286
|
-
if (/\.{3,}\s*>/u.test(line))
|
|
1287
|
-
return true;
|
|
1288
|
-
if (/\basync\b/iu.test(line))
|
|
1289
|
-
return true;
|
|
1290
|
-
if (/\[\s*async\s*\]/iu.test(line))
|
|
1291
|
-
return true;
|
|
1292
|
-
if (/(?:^|[\s|:])async\s*:/iu.test(line))
|
|
1293
|
-
return true;
|
|
1294
|
-
return false;
|
|
1295
|
-
});
|
|
1296
|
-
}
|
|
1297
|
-
/**
|
|
1298
|
-
* accepted sync edge patterns. Returns true when a
|
|
1299
|
-
* line carries any of:
|
|
1300
|
-
*
|
|
1301
|
-
* - `\bsync\b` text token (label-based)
|
|
1302
|
-
* - `[sync]` bracketed label, `sync:` prefix, `sync:` cell content
|
|
1303
|
-
* - Solid `-->`, `->`, `=>`, `→`, `⟶`, `↦` arrow that is NOT a known
|
|
1304
|
-
* dotted/async variant (`-.->`, `-->>`, `~~>`)
|
|
1305
|
-
* - `===>` (3+ `=` then `>`) and `--->` (3+ `-` then `>`) heavy solid
|
|
1306
|
-
* arrows
|
|
1307
|
-
*/
|
|
1308
|
-
export function hasSyncDiagramEdge(lines) {
|
|
1309
|
-
return lines.some((line) => {
|
|
1310
|
-
if (/\bsync\b/iu.test(line) && !/\basync\b/iu.test(line))
|
|
1311
|
-
return true;
|
|
1312
|
-
if (/\[\s*sync\s*\]/iu.test(line))
|
|
1313
|
-
return true;
|
|
1314
|
-
if (/(?:^|[\s|:])sync\s*:/iu.test(line))
|
|
1315
|
-
return true;
|
|
1316
|
-
if (/={2,}>/u.test(line))
|
|
1317
|
-
return true;
|
|
1318
|
-
if (/-{3,}>/u.test(line))
|
|
1319
|
-
return true;
|
|
1320
|
-
if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
|
|
1321
|
-
return false;
|
|
1322
|
-
if (/-\.->|-->>|~~>/u.test(line))
|
|
1323
|
-
return false;
|
|
1324
|
-
if (/-(?:\s-){1,}\s?->/u.test(line))
|
|
1325
|
-
return false;
|
|
1326
|
-
return true;
|
|
1327
|
-
});
|
|
1328
|
-
}
|
|
1329
|
-
/**
|
|
1330
|
-
* exact accepted-pattern list shown in the error
|
|
1331
|
-
* message when sync/async distinction fails. Keep in sync with
|
|
1332
|
-
* `hasAsyncDiagramEdge` / `hasSyncDiagramEdge` above.
|
|
1333
|
-
*/
|
|
1334
|
-
export const DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS = [
|
|
1335
|
-
"Solid arrows: `-->`, `->`, `===>`, `--->`, `=>`, `→`, `⟶`, `↦`",
|
|
1336
|
-
"Dotted/async arrows: `-.->`, `-->>`, `~~>`, `- - ->`, `.....>`",
|
|
1337
|
-
"Text labels on the same line: `sync` / `async`",
|
|
1338
|
-
"Bracket labels: `[sync]` / `[async]`",
|
|
1339
|
-
"Cell-prefix labels: `sync:` / `async:` (e.g. `A -->|sync: persist| B`)"
|
|
1340
|
-
];
|
|
1341
|
-
/**
|
|
1342
|
-
* Architecture Diagram structural check.
|
|
1343
|
-
*
|
|
1344
|
-
* Promoted out of `validateSectionBody` so it can take a `sections`
|
|
1345
|
-
* map and conditionally enforce the failure-edge rule based on
|
|
1346
|
-
* cross-section context (Failure Mode Table presence + diagram body
|
|
1347
|
-
* mentioning external-dependency keywords).
|
|
1348
|
-
*/
|
|
1349
|
-
export function validateArchitectureDiagram(sectionBody, context = {}) {
|
|
1350
|
-
const edgeLines = diagramEdgeLines(sectionBody);
|
|
1351
|
-
if (edgeLines.length === 0) {
|
|
1352
|
-
return {
|
|
1353
|
-
ok: false,
|
|
1354
|
-
details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1357
|
-
if (!hasLabeledDiagramArrow(edgeLines)) {
|
|
1358
|
-
return {
|
|
1359
|
-
ok: false,
|
|
1360
|
-
details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
|
|
1361
|
-
};
|
|
1362
|
-
}
|
|
1363
|
-
const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
|
|
1364
|
-
if (genericLine) {
|
|
1365
|
-
return {
|
|
1366
|
-
ok: false,
|
|
1367
|
-
details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
|
|
1368
|
-
};
|
|
1369
|
-
}
|
|
1370
|
-
if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
|
|
1371
|
-
const acceptedList = DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS.map((line) => ` - ${line}`).join("\n");
|
|
1372
|
-
return {
|
|
1373
|
-
ok: false,
|
|
1374
|
-
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\`.`
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1377
|
-
if (!shouldEnforceFailureEdge(sectionBody, context)) {
|
|
1378
|
-
return {
|
|
1379
|
-
ok: true,
|
|
1380
|
-
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)."
|
|
1381
|
-
};
|
|
1382
|
-
}
|
|
1383
|
-
if (!hasFailureEdgeInDiagram(sectionBody)) {
|
|
1384
|
-
return {
|
|
1385
|
-
ok: false,
|
|
1386
|
-
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`)."
|
|
1387
|
-
};
|
|
1388
|
-
}
|
|
1389
|
-
return {
|
|
1390
|
-
ok: true,
|
|
1391
|
-
details: "Architecture Diagram contains labeled edges, sync/async distinction, and a failure-edge."
|
|
1392
|
-
};
|
|
1393
|
-
}
|
|
1394
|
-
/**
|
|
1395
|
-
* decide whether the failure-edge enforcement
|
|
1396
|
-
* should fire for the given Architecture Diagram body. Returns
|
|
1397
|
-
* `false` (skip the rule) when BOTH:
|
|
1398
|
-
* - The artifact's `## Failure Mode Table` (if present) has zero
|
|
1399
|
-
* data rows OR is absent entirely.
|
|
1400
|
-
* - The architecture diagram body mentions NO known external-
|
|
1401
|
-
* dependency keyword (network, db, queue, …).
|
|
1402
|
-
*
|
|
1403
|
-
* Static landing pages (no network, no failure modes) hit this
|
|
1404
|
-
* path. Designs with even one Failure Mode row OR one external
|
|
1405
|
-
* dependency keyword in the diagram fall through to the legacy
|
|
1406
|
-
* blanket failure-edge requirement.
|
|
1407
|
-
*/
|
|
1408
|
-
function shouldEnforceFailureEdge(diagramBody, context) {
|
|
1409
|
-
const sections = context.sections ?? null;
|
|
1410
|
-
const failureModeBody = sections ? sectionBodyByName(sections, "Failure Mode Table") : null;
|
|
1411
|
-
const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
|
|
1412
|
-
if (failureModeRowCount > 0)
|
|
1413
|
-
return true;
|
|
1414
|
-
if (DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
|
|
1415
|
-
return true;
|
|
1416
|
-
return false;
|
|
1417
|
-
}
|
|
1418
|
-
/**
|
|
1419
|
-
* Sync helper that scans for `Evidence:` lines in a section body and
|
|
1420
|
-
* returns the trimmed value of each. Used by the lint pipeline to
|
|
1421
|
-
* pre-resolve pointers (filesystem path-existence or delegation ledger
|
|
1422
|
-
* spanId match) before invoking the validators.
|
|
1423
|
-
*
|
|
1424
|
-
* Recognised forms:
|
|
1425
|
-
* Evidence: <path>
|
|
1426
|
-
* Evidence: spanId:<id>
|
|
1427
|
-
* - Evidence: <path>
|
|
1428
|
-
*/
|
|
1429
|
-
export function extractEvidencePointers(sectionBody) {
|
|
1430
|
-
const pointers = [];
|
|
1431
|
-
const pattern = /^\s*-?\s*evidence\s*:\s*(.+?)\s*$/imu;
|
|
1432
|
-
for (const line of sectionBody.split(/\r?\n/u)) {
|
|
1433
|
-
const match = pattern.exec(line);
|
|
1434
|
-
if (match && match[1] !== undefined) {
|
|
1435
|
-
const value = match[1].trim();
|
|
1436
|
-
if (value.length > 0)
|
|
1437
|
-
pointers.push(value);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
return pointers;
|
|
1441
|
-
}
|
|
1442
|
-
export function validateTddRedEvidence(sectionBody, opts = {}) {
|
|
1443
|
-
if (opts.phaseEventsSatisfied) {
|
|
1444
|
-
return {
|
|
1445
|
-
ok: true,
|
|
1446
|
-
details: "RED Evidence auto-satisfied: delegation-events.jsonl carries a phase=red row with non-empty evidenceRefs for the active run."
|
|
1447
|
-
};
|
|
1448
|
-
}
|
|
1449
|
-
if (opts.pointerSatisfied) {
|
|
1450
|
-
return {
|
|
1451
|
-
ok: true,
|
|
1452
|
-
details: "RED Evidence satisfied via `Evidence: <path|spanId:...>` pointer (resolved to an existing artifact or delegation span)."
|
|
1453
|
-
};
|
|
1454
|
-
}
|
|
1455
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
1456
|
-
if (meaningful < 2) {
|
|
1457
|
-
return {
|
|
1458
|
-
ok: false,
|
|
1459
|
-
details: "RED Evidence must include at least 2 meaningful lines (command plus failing output context)."
|
|
1460
|
-
};
|
|
1461
|
-
}
|
|
1462
|
-
if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
|
|
1463
|
-
return {
|
|
1464
|
-
ok: false,
|
|
1465
|
-
details: "RED Evidence must include the test command that produced the failure."
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
if (!RED_FAILURE_MARKER_PATTERN.test(sectionBody)) {
|
|
1469
|
-
return {
|
|
1470
|
-
ok: false,
|
|
1471
|
-
details: "RED Evidence must include explicit failing output markers (FAIL/FAILED/AssertionError/exit code != 0)."
|
|
1472
|
-
};
|
|
1473
|
-
}
|
|
1474
|
-
return {
|
|
1475
|
-
ok: true,
|
|
1476
|
-
details: "RED Evidence includes command + failing output markers."
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
export function validateTddGreenEvidence(sectionBody, opts = {}) {
|
|
1480
|
-
if (opts.phaseEventsSatisfied) {
|
|
1481
|
-
return {
|
|
1482
|
-
ok: true,
|
|
1483
|
-
details: "GREEN Evidence auto-satisfied: delegation-events.jsonl carries a phase=green row with non-empty evidenceRefs for the active run."
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
if (opts.pointerSatisfied) {
|
|
1487
|
-
return {
|
|
1488
|
-
ok: true,
|
|
1489
|
-
details: "GREEN Evidence satisfied via `Evidence: <path|spanId:...>` pointer (resolved to an existing artifact or delegation span)."
|
|
1490
|
-
};
|
|
1491
|
-
}
|
|
1492
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
1493
|
-
if (meaningful < 2) {
|
|
1494
|
-
return {
|
|
1495
|
-
ok: false,
|
|
1496
|
-
details: "GREEN Evidence must include at least 2 meaningful lines (command and passing result)."
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
|
|
1500
|
-
return {
|
|
1501
|
-
ok: false,
|
|
1502
|
-
details: "GREEN Evidence must include the full-suite test command."
|
|
1503
|
-
};
|
|
1504
|
-
}
|
|
1505
|
-
if (!GREEN_SUCCESS_MARKER_PATTERN.test(sectionBody)) {
|
|
1506
|
-
return {
|
|
1507
|
-
ok: false,
|
|
1508
|
-
details: "GREEN Evidence must include explicit passing markers (PASS/PASSED/OK/exit code 0)."
|
|
1509
|
-
};
|
|
1510
|
-
}
|
|
1511
|
-
return {
|
|
1512
|
-
ok: true,
|
|
1513
|
-
details: "GREEN Evidence includes command + passing output markers."
|
|
1514
|
-
};
|
|
1515
|
-
}
|
|
1516
|
-
export function validateVerificationLadder(sectionBody) {
|
|
1517
|
-
const hasTextLine = /highest tier reached/iu.test(sectionBody);
|
|
1518
|
-
const hasCanonicalTable = hasVerificationLadderTableRow(sectionBody);
|
|
1519
|
-
if (!hasTextLine && !hasCanonicalTable) {
|
|
1520
|
-
return {
|
|
1521
|
-
ok: false,
|
|
1522
|
-
details: "Verification Ladder must include either a 'Highest tier reached' line or a canonical table row (Slice | Tier reached | Evidence) with non-empty tier and evidence."
|
|
1523
|
-
};
|
|
1524
|
-
}
|
|
1525
|
-
if (!/\b(static|command|behavioral|human)\b/iu.test(sectionBody)) {
|
|
1526
|
-
return {
|
|
1527
|
-
ok: false,
|
|
1528
|
-
details: "Verification Ladder must name a tier (static | command | behavioral | human)."
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
|
-
if (!/\b(evidence|command|sha|commit)\b/iu.test(sectionBody)) {
|
|
1532
|
-
return {
|
|
1533
|
-
ok: false,
|
|
1534
|
-
details: "Verification Ladder must include evidence details (command output or commit SHA)."
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
return {
|
|
1538
|
-
ok: true,
|
|
1539
|
-
details: "Verification Ladder includes tier + evidence fields."
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
export function hasVerificationLadderTableRow(sectionBody) {
|
|
1543
|
-
const lines = sectionBody.split(/\r?\n/u);
|
|
1544
|
-
let sawHeader = false;
|
|
1545
|
-
let sawSeparator = false;
|
|
1546
|
-
for (const line of lines) {
|
|
1547
|
-
const trimmed = line.trim();
|
|
1548
|
-
if (!trimmed.startsWith("|")) {
|
|
1549
|
-
sawHeader = false;
|
|
1550
|
-
sawSeparator = false;
|
|
1551
|
-
continue;
|
|
1552
|
-
}
|
|
1553
|
-
const cells = trimmed
|
|
1554
|
-
.replace(/^\|/u, "")
|
|
1555
|
-
.replace(/\|$/u, "")
|
|
1556
|
-
.split("|")
|
|
1557
|
-
.map((cell) => cell.trim());
|
|
1558
|
-
if (!sawHeader) {
|
|
1559
|
-
const lowered = cells.map((cell) => cell.toLowerCase());
|
|
1560
|
-
const hasTierColumn = lowered.some((cell) => /tier(?:\s+reached)?/u.test(cell));
|
|
1561
|
-
const hasEvidenceColumn = lowered.some((cell) => cell.includes("evidence"));
|
|
1562
|
-
if (hasTierColumn && hasEvidenceColumn) {
|
|
1563
|
-
sawHeader = true;
|
|
1564
|
-
continue;
|
|
1565
|
-
}
|
|
1566
|
-
continue;
|
|
1567
|
-
}
|
|
1568
|
-
if (!sawSeparator) {
|
|
1569
|
-
if (cells.every((cell) => /^[:\-\s]+$/u.test(cell))) {
|
|
1570
|
-
sawSeparator = true;
|
|
1571
|
-
continue;
|
|
1572
|
-
}
|
|
1573
|
-
sawHeader = false;
|
|
1574
|
-
continue;
|
|
1575
|
-
}
|
|
1576
|
-
if (cells.length >= 2 && cells.some((cell) => /\b(static|command|behavioral|human)\b/iu.test(cell))) {
|
|
1577
|
-
const evidenceCellHasContent = cells.some((cell) => cell.length > 0 && !/^\s*$/u.test(cell) && !/^[:\-\s]+$/u.test(cell));
|
|
1578
|
-
if (evidenceCellHasContent) {
|
|
1579
|
-
return true;
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
return false;
|
|
1584
|
-
}
|
|
1585
|
-
/** Multiline block used by linter + learnings harvest stderr (identical text). */
|
|
1586
|
-
export function formatLearningsErrorsBullets(errors) {
|
|
1587
|
-
if (errors.length === 0) {
|
|
1588
|
-
return "Errors:\n - Learnings section could not be parsed.";
|
|
1589
|
-
}
|
|
1590
|
-
return `Errors:\n${errors.map((error) => ` - ${error}`).join("\n")}`;
|
|
1591
|
-
}
|
|
1592
|
-
export function learningsParseFailureHumanSummary(artifactRelPath, errors) {
|
|
1593
|
-
return `learnings harvest failed for \`${artifactRelPath}\`.\n${formatLearningsErrorsBullets(errors)}`;
|
|
1594
|
-
}
|
|
1595
|
-
export const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
|
|
1596
|
-
export const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
|
|
1597
|
-
export const LEARNING_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
|
|
1598
|
-
export const LEARNING_SOURCE_SET = new Set([
|
|
1599
|
-
"stage",
|
|
1600
|
-
"retro",
|
|
1601
|
-
"compound",
|
|
1602
|
-
"idea",
|
|
1603
|
-
"manual"
|
|
1604
|
-
]);
|
|
1605
|
-
export const FLOW_STAGE_SET = new Set(FLOW_STAGES);
|
|
1606
|
-
export const LEARNING_ALLOWED_KEYS = new Set([
|
|
1607
|
-
"type",
|
|
1608
|
-
"trigger",
|
|
1609
|
-
"action",
|
|
1610
|
-
"confidence",
|
|
1611
|
-
"severity",
|
|
1612
|
-
"stage",
|
|
1613
|
-
"origin_stage",
|
|
1614
|
-
"frequency",
|
|
1615
|
-
"created",
|
|
1616
|
-
"first_seen_ts",
|
|
1617
|
-
"last_seen_ts",
|
|
1618
|
-
"project",
|
|
1619
|
-
"source"
|
|
1620
|
-
]);
|
|
1621
|
-
export function isIsoUtcTimestamp(value) {
|
|
1622
|
-
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
|
|
1623
|
-
}
|
|
1624
|
-
export function isNullableString(value) {
|
|
1625
|
-
return value === null || typeof value === "string";
|
|
1626
|
-
}
|
|
1627
|
-
export function isNullableStage(value) {
|
|
1628
|
-
return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
|
|
1629
|
-
}
|
|
1630
|
-
export function parseLearningSeedEntry(raw, index) {
|
|
1631
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1632
|
-
return { ok: false, error: `Learnings bullet #${index} must be a JSON object.` };
|
|
1633
|
-
}
|
|
1634
|
-
const obj = raw;
|
|
1635
|
-
for (const key of Object.keys(obj)) {
|
|
1636
|
-
if (!LEARNING_ALLOWED_KEYS.has(key)) {
|
|
1637
|
-
return {
|
|
1638
|
-
ok: false,
|
|
1639
|
-
error: `Learnings bullet #${index} includes unknown key "${key}" (allowed keys mirror knowledge JSONL fields).`
|
|
1640
|
-
};
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
|
|
1644
|
-
if (!LEARNING_TYPE_SET.has(type)) {
|
|
1645
|
-
return {
|
|
1646
|
-
ok: false,
|
|
1647
|
-
error: `Learnings bullet #${index} must set type to one of: rule, pattern, lesson, compound.`
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
const trigger = typeof obj.trigger === "string" ? obj.trigger.trim() : "";
|
|
1651
|
-
if (trigger.length === 0) {
|
|
1652
|
-
return {
|
|
1653
|
-
ok: false,
|
|
1654
|
-
error: `Learnings bullet #${index} must include non-empty "trigger".`
|
|
1655
|
-
};
|
|
1656
|
-
}
|
|
1657
|
-
const action = typeof obj.action === "string" ? obj.action.trim() : "";
|
|
1658
|
-
if (action.length === 0) {
|
|
1659
|
-
return {
|
|
1660
|
-
ok: false,
|
|
1661
|
-
error: `Learnings bullet #${index} must include non-empty "action".`
|
|
1662
|
-
};
|
|
1663
|
-
}
|
|
1664
|
-
const confidence = typeof obj.confidence === "string" ? obj.confidence.toLowerCase() : "";
|
|
1665
|
-
if (!LEARNING_CONFIDENCE_SET.has(confidence)) {
|
|
1666
|
-
return {
|
|
1667
|
-
ok: false,
|
|
1668
|
-
error: `Learnings bullet #${index} must set confidence to high|medium|low.`
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1671
|
-
const severity = typeof obj.severity === "string" ? obj.severity.toLowerCase() : undefined;
|
|
1672
|
-
if (severity !== undefined && !LEARNING_SEVERITY_SET.has(severity)) {
|
|
1673
|
-
return {
|
|
1674
|
-
ok: false,
|
|
1675
|
-
error: `Learnings bullet #${index} field "severity" must be critical|important|suggestion.`
|
|
1676
|
-
};
|
|
1677
|
-
}
|
|
1678
|
-
if (obj.stage !== undefined && !isNullableStage(obj.stage)) {
|
|
1679
|
-
return {
|
|
1680
|
-
ok: false,
|
|
1681
|
-
error: `Learnings bullet #${index} field "stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
1682
|
-
};
|
|
1683
|
-
}
|
|
1684
|
-
if (obj.origin_stage !== undefined && !isNullableStage(obj.origin_stage)) {
|
|
1685
|
-
return {
|
|
1686
|
-
ok: false,
|
|
1687
|
-
error: `Learnings bullet #${index} field "origin_stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
1688
|
-
};
|
|
1689
|
-
}
|
|
1690
|
-
if (obj.project !== undefined && !isNullableString(obj.project)) {
|
|
1691
|
-
return { ok: false, error: `Learnings bullet #${index} field "project" must be string or null.` };
|
|
1692
|
-
}
|
|
1693
|
-
if (obj.source !== undefined &&
|
|
1694
|
-
obj.source !== null &&
|
|
1695
|
-
(typeof obj.source !== "string" || !LEARNING_SOURCE_SET.has(obj.source))) {
|
|
1696
|
-
return {
|
|
1697
|
-
ok: false,
|
|
1698
|
-
error: `Learnings bullet #${index} field "source" must be stage|retro|compound|idea|manual or null.`
|
|
1699
|
-
};
|
|
1700
|
-
}
|
|
1701
|
-
if (obj.frequency !== undefined &&
|
|
1702
|
-
(typeof obj.frequency !== "number" || !Number.isInteger(obj.frequency) || obj.frequency < 1)) {
|
|
1703
|
-
return { ok: false, error: `Learnings bullet #${index} field "frequency" must be an integer >= 1.` };
|
|
1704
|
-
}
|
|
1705
|
-
for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
|
|
1706
|
-
const value = obj[timestampField];
|
|
1707
|
-
if (value === undefined)
|
|
1708
|
-
continue;
|
|
1709
|
-
if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
|
|
1710
|
-
return {
|
|
1711
|
-
ok: false,
|
|
1712
|
-
error: `Learnings bullet #${index} field "${timestampField}" must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`
|
|
1713
|
-
};
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
return {
|
|
1717
|
-
ok: true,
|
|
1718
|
-
entry: {
|
|
1719
|
-
...obj,
|
|
1720
|
-
type: type,
|
|
1721
|
-
trigger,
|
|
1722
|
-
action,
|
|
1723
|
-
confidence: confidence,
|
|
1724
|
-
...(severity ? { severity: severity } : {})
|
|
1725
|
-
}
|
|
1726
|
-
};
|
|
1727
|
-
}
|
|
1728
|
-
export function parseLearningsSection(sectionBody) {
|
|
1729
|
-
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
1730
|
-
const nonEmpty = lines.filter((line) => line.length > 0);
|
|
1731
|
-
const bullets = nonEmpty.filter((line) => /^-\s+\S+/u.test(line));
|
|
1732
|
-
if (bullets.length === 0) {
|
|
1733
|
-
return {
|
|
1734
|
-
ok: false,
|
|
1735
|
-
none: false,
|
|
1736
|
-
entries: [],
|
|
1737
|
-
errors: ["Learnings section must contain bullet entries."],
|
|
1738
|
-
details: "Learnings section must contain bullet entries."
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
const nonBulletContent = nonEmpty.filter((line) => !/^-\s+\S+/u.test(line));
|
|
1742
|
-
if (nonBulletContent.length > 0) {
|
|
1743
|
-
return {
|
|
1744
|
-
ok: false,
|
|
1745
|
-
none: false,
|
|
1746
|
-
entries: [],
|
|
1747
|
-
errors: ["Learnings section must only contain bullet lines (one bullet per learning)."],
|
|
1748
|
-
details: "Learnings section must only contain bullet lines (one bullet per learning)."
|
|
1749
|
-
};
|
|
1750
|
-
}
|
|
1751
|
-
if (bullets.length === 1) {
|
|
1752
|
-
const payload = bullets[0].replace(/^-\s+/u, "").trim();
|
|
1753
|
-
if (/^none this stage\.?$/iu.test(payload)) {
|
|
1754
|
-
return {
|
|
1755
|
-
ok: true,
|
|
1756
|
-
none: true,
|
|
1757
|
-
entries: [],
|
|
1758
|
-
errors: [],
|
|
1759
|
-
details: "Learnings section explicitly marked as none."
|
|
1760
|
-
};
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
const entries = [];
|
|
1764
|
-
const errors = [];
|
|
1765
|
-
for (let i = 0; i < bullets.length; i += 1) {
|
|
1766
|
-
const payload = bullets[i].replace(/^-\s+/u, "").trim();
|
|
1767
|
-
let parsed;
|
|
1768
|
-
try {
|
|
1769
|
-
parsed = JSON.parse(payload);
|
|
1770
|
-
}
|
|
1771
|
-
catch (err) {
|
|
1772
|
-
errors.push(`Learnings bullet #${i + 1} must be valid JSON object or "None this stage.": ${err instanceof Error ? err.message : String(err)}`);
|
|
1773
|
-
continue;
|
|
1774
|
-
}
|
|
1775
|
-
const parsedEntry = parseLearningSeedEntry(parsed, i + 1);
|
|
1776
|
-
if (!parsedEntry.ok || !parsedEntry.entry) {
|
|
1777
|
-
errors.push(parsedEntry.error ?? `Learnings bullet #${i + 1} is invalid.`);
|
|
1778
|
-
continue;
|
|
1779
|
-
}
|
|
1780
|
-
entries.push(parsedEntry.entry);
|
|
1781
|
-
}
|
|
1782
|
-
if (errors.length > 0) {
|
|
1783
|
-
return {
|
|
1784
|
-
ok: false,
|
|
1785
|
-
none: false,
|
|
1786
|
-
entries: [],
|
|
1787
|
-
errors,
|
|
1788
|
-
details: errors.join(" | ")
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
return {
|
|
1792
|
-
ok: true,
|
|
1793
|
-
none: false,
|
|
1794
|
-
entries,
|
|
1795
|
-
errors: [],
|
|
1796
|
-
details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
|
|
1797
|
-
};
|
|
1798
|
-
}
|
|
1799
|
-
/**
|
|
1800
|
-
* file-path / reference detector for the
|
|
1801
|
-
* `investigation_path_first_missing` advisory rule.
|
|
1802
|
-
*
|
|
1803
|
-
* The detector is intentionally permissive: it only needs to recognize
|
|
1804
|
-
* "the author wrote down a path or ref" — the linter does NOT validate
|
|
1805
|
-
* the path resolves on disk. Patterns matched (any one is enough):
|
|
1806
|
-
* - TS/JS/MD/JSON/YAML path with extension
|
|
1807
|
-
* (`src/foo/bar.ts`, `tests/spec.test.ts`, `docs/quality-gates.md`).
|
|
1808
|
-
* - Slash-bearing path under a known repo root prefix
|
|
1809
|
-
* (`src/...`, `tests/...`, `docs/...`, `scripts/...`,
|
|
1810
|
-
* `.cclaw/...`, `.cursor/...`, `node_modules/...`,
|
|
1811
|
-
* `examples/...`, `e2e/...`).
|
|
1812
|
-
* - GitHub-style ref (`owner/repo#123`, `org/repo@sha`,
|
|
1813
|
-
* `path:line`, `path:line-line`).
|
|
1814
|
-
* - Explicit `path:` / `paths:` / `ref:` / `refs:` marker.
|
|
1815
|
-
* - Stable cclaw IDs (`R1`, `D-12`, `AC-3`, `T-4`, `S-2`, `DD-5`,
|
|
1816
|
-
* `ADR-1`, `R-1`, `F-1`, `CR-1`, `I-1`, `QS-1`).
|
|
1817
|
-
* - Backticked path-like token containing a slash.
|
|
1818
|
-
*
|
|
1819
|
-
* Exposed for unit tests (`tests/unit/investigation-trace-evaluator.test.ts`).
|
|
1820
|
-
*/
|
|
1821
|
-
export const INVESTIGATION_TRACE_PATH_PATTERNS = [
|
|
1822
|
-
/(?:^|[\s`(\[])(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|mdx|json|yaml|yml|toml|sh|py|rs|go|java|kt|swift|rb|css|scss|html)\b/iu,
|
|
1823
|
-
/(?:^|[\s`(\[])(?:src|tests?|docs?|scripts?|e2e|examples?|packages?|apps?|cmd|internal|pkg|lib|app|server|client|backend|frontend|\.cclaw|\.cursor|\.github|node_modules)\/[A-Za-z0-9_./-]+/iu,
|
|
1824
|
-
/\b[A-Za-z0-9_./-]+(?:\.[A-Za-z0-9]+)?:\d+(?:[-:]\d+)?\b/u,
|
|
1825
|
-
/\b[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#\d+|@[0-9a-f]{6,40})\b/iu,
|
|
1826
|
-
/(?:^|\s)(?:paths?|refs?|file|files|cite|citation)\s*:\s*\S/iu,
|
|
1827
|
-
/\b(?:R|D|AC|T|S|DD|ADR|F|CR|I|QS)-?\d+\b/u,
|
|
1828
|
-
/`[^`]*\/[^`]+`/u
|
|
1829
|
-
];
|
|
1830
|
-
const INVESTIGATION_TRACE_PLACEHOLDER_PATTERN = /^(?:none|none\.|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u;
|
|
1831
|
-
const INVESTIGATION_TRACE_ID_ONLY_CELL = /^[A-Z]{1,4}-?\d+$/u;
|
|
1832
|
-
function isInvestigationTracePlaceholderCell(cell) {
|
|
1833
|
-
const stripped = cell.replace(/[`*_>#]/gu, "").trim();
|
|
1834
|
-
if (stripped.length === 0)
|
|
1835
|
-
return true;
|
|
1836
|
-
if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(stripped.toLowerCase()))
|
|
1837
|
-
return true;
|
|
1838
|
-
return false;
|
|
1839
|
-
}
|
|
1840
|
-
function isInvestigationTracePlaceholderProseLine(line) {
|
|
1841
|
-
const stripped = line.replace(/[`*_>#-]/gu, "").trim();
|
|
1842
|
-
if (stripped.length === 0)
|
|
1843
|
-
return true;
|
|
1844
|
-
const lower = stripped.toLowerCase();
|
|
1845
|
-
if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(lower))
|
|
1846
|
-
return true;
|
|
1847
|
-
if (/^\(\s*(?:none|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u.test(lower)) {
|
|
1848
|
-
return true;
|
|
1849
|
-
}
|
|
1850
|
-
return false;
|
|
1851
|
-
}
|
|
1852
|
-
/**
|
|
1853
|
-
* Internal core that does NOT depend on `StageLintContext`. Returned
|
|
1854
|
-
* shape is consumed by `evaluateInvestigationTrace` (which pushes a
|
|
1855
|
-
* finding into the context) and by unit tests that exercise the
|
|
1856
|
-
* detector directly.
|
|
1857
|
-
*
|
|
1858
|
-
* Returns `null` for sections that are missing, empty, or contain only
|
|
1859
|
-
* template scaffolding (table headers, separators, placeholder rows
|
|
1860
|
-
* with empty cells, lone `- None.` lines). Callers treat `null` as
|
|
1861
|
-
* silent — no finding is emitted.
|
|
1862
|
-
*/
|
|
1863
|
-
export function checkInvestigationTrace(sectionBody) {
|
|
1864
|
-
if (sectionBody === null)
|
|
1865
|
-
return null;
|
|
1866
|
-
const lines = sectionBody.split(/\r?\n/u);
|
|
1867
|
-
const candidates = [];
|
|
1868
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
1869
|
-
const raw = lines[index] ?? "";
|
|
1870
|
-
const trimmed = raw.trim();
|
|
1871
|
-
if (trimmed.length === 0)
|
|
1872
|
-
continue;
|
|
1873
|
-
if (trimmed.startsWith("<!--"))
|
|
1874
|
-
continue;
|
|
1875
|
-
const isTableLine = /^\|.*\|$/u.test(trimmed);
|
|
1876
|
-
if (isTableLine) {
|
|
1877
|
-
if (/^\|[-:| ]+\|$/u.test(trimmed))
|
|
1878
|
-
continue; // separator row
|
|
1879
|
-
const next = (lines[index + 1] ?? "").trim();
|
|
1880
|
-
if (/^\|[-:| ]+\|$/u.test(next))
|
|
1881
|
-
continue; // header row (followed by separator)
|
|
1882
|
-
const cells = trimmed
|
|
1883
|
-
.split("|")
|
|
1884
|
-
.slice(1, -1)
|
|
1885
|
-
.map((cell) => cell.trim());
|
|
1886
|
-
const substantive = cells.filter((cell) => !isInvestigationTracePlaceholderCell(cell));
|
|
1887
|
-
if (substantive.length === 0)
|
|
1888
|
-
continue;
|
|
1889
|
-
if (substantive.length === 1 && INVESTIGATION_TRACE_ID_ONLY_CELL.test(substantive[0])) {
|
|
1890
|
-
continue;
|
|
1891
|
-
}
|
|
1892
|
-
candidates.push(substantive.join(" "));
|
|
1893
|
-
continue;
|
|
1894
|
-
}
|
|
1895
|
-
if (isInvestigationTracePlaceholderProseLine(trimmed))
|
|
1896
|
-
continue;
|
|
1897
|
-
candidates.push(trimmed);
|
|
1898
|
-
}
|
|
1899
|
-
if (candidates.length === 0)
|
|
1900
|
-
return null;
|
|
1901
|
-
const sample = candidates.slice(0, Math.min(5, candidates.length));
|
|
1902
|
-
const detectorMatched = sample.some((line) => INVESTIGATION_TRACE_PATH_PATTERNS.some((pattern) => pattern.test(line)));
|
|
1903
|
-
if (detectorMatched) {
|
|
1904
|
-
return {
|
|
1905
|
-
ok: true,
|
|
1906
|
-
details: "Investigation trace cites file paths or refs in the first non-empty row(s)."
|
|
1907
|
-
};
|
|
1908
|
-
}
|
|
1909
|
-
return {
|
|
1910
|
-
ok: false,
|
|
1911
|
-
details: "Investigation trace has prose-only content in its first row(s). Pass paths and refs, not pasted file contents (e.g. `src/foo/bar.ts:42`, `D-12`, `AC-3`)."
|
|
1912
|
-
};
|
|
1913
|
-
}
|
|
1914
|
-
/**
|
|
1915
|
-
* advisory rule wired into the brainstorm / scope /
|
|
1916
|
-
* design / tdd / plan / review linters.
|
|
1917
|
-
*
|
|
1918
|
-
* Behavior contract:
|
|
1919
|
-
* - Section missing or empty / placeholder-only: silent (no finding).
|
|
1920
|
-
* - Section has substantive content with a recognizable file path /
|
|
1921
|
-
* ref / explicit `path:`-style marker in the first non-empty rows:
|
|
1922
|
-
* advisory pass (no finding).
|
|
1923
|
-
* - Section has substantive content but no path/ref signal: advisory
|
|
1924
|
-
* FAIL finding with ruleId `investigation_path_first_missing`.
|
|
1925
|
-
*
|
|
1926
|
-
* The rule is `required: false` so it never blocks `stage-complete`.
|
|
1927
|
-
*/
|
|
1928
|
-
export function evaluateInvestigationTrace(ctx, sectionName) {
|
|
1929
|
-
const body = sectionBodyByName(ctx.sections, sectionName);
|
|
1930
|
-
const authoredBody = body === null ? null : extractAuthoredBody(body);
|
|
1931
|
-
const result = checkInvestigationTrace(authoredBody);
|
|
1932
|
-
if (result === null)
|
|
1933
|
-
return;
|
|
1934
|
-
ctx.findings.push({
|
|
1935
|
-
section: "investigation_path_first_missing",
|
|
1936
|
-
required: false,
|
|
1937
|
-
rule: `[P3] investigation_path_first_missing — \`## ${sectionName}\` should cite paths and refs in the first non-empty row(s); pass paths and refs, not content.`,
|
|
1938
|
-
found: result.ok,
|
|
1939
|
-
details: result.details
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
|
-
export function lineContainsVagueAdjective(text) {
|
|
1943
|
-
const lower = text.toLowerCase();
|
|
1944
|
-
for (const adjective of VAGUE_AC_ADJECTIVES) {
|
|
1945
|
-
const pattern = new RegExp(`(?:^|[^A-Za-z])${adjective.replace(/ /g, "\\s+")}(?:[^A-Za-z]|$)`, "iu");
|
|
1946
|
-
if (pattern.test(lower))
|
|
1947
|
-
return adjective;
|
|
1948
|
-
}
|
|
1949
|
-
return null;
|
|
1950
|
-
}
|
|
1951
|
-
export const FRONTMATTER_REQUIRED_KEYS = [
|
|
1952
|
-
"stage",
|
|
1953
|
-
"schema_version",
|
|
1954
|
-
"version",
|
|
1955
|
-
"locked_decisions",
|
|
1956
|
-
"inputs_hash"
|
|
1957
|
-
];
|
|
1958
|
-
export const PLACEHOLDER_PATTERNS = [
|
|
1959
|
-
{ label: "TODO", regex: /\bTODO\b/iu },
|
|
1960
|
-
{ label: "TBD", regex: /\bTBD\b/iu },
|
|
1961
|
-
{ label: "FIXME", regex: /\bFIXME\b/iu },
|
|
1962
|
-
{ label: "<fill-in>", regex: /<fill-in>/iu },
|
|
1963
|
-
{ label: "<your-*-here>", regex: /<your-[^>]*-here>/iu },
|
|
1964
|
-
{ label: "xxx", regex: /\bxxx\b/iu },
|
|
1965
|
-
{ label: "ellipsis", regex: /\.{3}/u }
|
|
1966
|
-
];
|
|
1967
|
-
export const SCOPE_REDUCTION_PATTERNS = [
|
|
1968
|
-
{ label: "v1", regex: /\bv1\b/iu },
|
|
1969
|
-
{ label: "for now", regex: /\bfor now\b/iu },
|
|
1970
|
-
{ label: "later", regex: /\blater\b/iu },
|
|
1971
|
-
{ label: "temporary", regex: /\btemporary\b/iu },
|
|
1972
|
-
{ label: "placeholder", regex: /\bplaceholder\b/iu },
|
|
1973
|
-
{ label: "mock for now", regex: /\bmock for now\b/iu },
|
|
1974
|
-
{ label: "hardcoded for now", regex: /\bhardcoded for now\b/iu },
|
|
1975
|
-
{ label: "will improve later", regex: /\bwill improve later\b/iu }
|
|
1976
|
-
];
|
|
1977
|
-
export function parseFrontmatter(markdown) {
|
|
1978
|
-
const lines = markdown.split(/\r?\n/);
|
|
1979
|
-
if (lines[0]?.trim() !== "---") {
|
|
1980
|
-
return { hasFrontmatter: false, values: {} };
|
|
1981
|
-
}
|
|
1982
|
-
const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
1983
|
-
if (endIndex < 0) {
|
|
1984
|
-
return { hasFrontmatter: false, values: {} };
|
|
1985
|
-
}
|
|
1986
|
-
const values = {};
|
|
1987
|
-
for (const line of lines.slice(1, endIndex)) {
|
|
1988
|
-
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
|
|
1989
|
-
if (!match)
|
|
1990
|
-
continue;
|
|
1991
|
-
const key = match[1];
|
|
1992
|
-
const value = match[2].trim();
|
|
1993
|
-
values[key] = value;
|
|
1994
|
-
}
|
|
1995
|
-
return { hasFrontmatter: true, values };
|
|
1996
|
-
}
|
|
1997
|
-
export function extractDecisionIds(text) {
|
|
1998
|
-
const ids = text.match(/\bD-\d+\b/gu) ?? [];
|
|
1999
|
-
return [...new Set(ids)];
|
|
2000
|
-
}
|
|
2001
|
-
export function extractRequirementIdsFromMarkdown(text) {
|
|
2002
|
-
const ids = text.match(/\bR\d+\b/gu) ?? [];
|
|
2003
|
-
return [...new Set(ids)];
|
|
2004
|
-
}
|
|
2005
|
-
export function extractAcceptanceCriterionIdsFromMarkdown(text) {
|
|
2006
|
-
const ids = text.match(/\bAC-\d+\b/giu) ?? [];
|
|
2007
|
-
const normalized = ids.map((id) => id.toUpperCase());
|
|
2008
|
-
return [...new Set(normalized)];
|
|
2009
|
-
}
|
|
2010
|
-
// Cross-stage decision traceability uses stable D-XX IDs which the
|
|
2011
|
-
// agent can edit safely without recomputing content hashes.
|
|
2012
|
-
export function collectPatternHits(text, patterns) {
|
|
2013
|
-
const hits = [];
|
|
2014
|
-
for (const pattern of patterns) {
|
|
2015
|
-
if (pattern.regex.test(text)) {
|
|
2016
|
-
hits.push(pattern.label);
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
return hits;
|
|
2020
|
-
}
|
|
2021
|
-
export function validateSectionBody(sectionBody, rule, sectionName, context = {}) {
|
|
2022
|
-
const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
2023
|
-
const meaningful = meaningfulLineCount(sectionBody);
|
|
2024
|
-
if (meaningful === 0) {
|
|
2025
|
-
return {
|
|
2026
|
-
ok: false,
|
|
2027
|
-
details: "Section exists but has no meaningful content yet."
|
|
2028
|
-
};
|
|
2029
|
-
}
|
|
2030
|
-
const minItems = extractMinItemsFromRule(rule);
|
|
2031
|
-
if (minItems !== null) {
|
|
2032
|
-
const count = countListItems(sectionBody);
|
|
2033
|
-
if (count < minItems) {
|
|
2034
|
-
return {
|
|
2035
|
-
ok: false,
|
|
2036
|
-
details: `Rule expects at least ${minItems} item(s), found ${count}.`
|
|
2037
|
-
};
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
if (/table must use 4 columns/iu.test(rule)) {
|
|
2041
|
-
const header = tableHeaderCells(sectionBody);
|
|
2042
|
-
if (!header) {
|
|
2043
|
-
return {
|
|
2044
|
-
ok: false,
|
|
2045
|
-
details: "Rule expects a markdown table header with a separator row."
|
|
2046
|
-
};
|
|
2047
|
-
}
|
|
2048
|
-
const expected = ["Category", "Question asked", "User answer", "Evidence note"];
|
|
2049
|
-
const normalizedHeader = header.map((cell) => cell.toLowerCase());
|
|
2050
|
-
const normalizedExpected = expected.map((cell) => cell.toLowerCase());
|
|
2051
|
-
const matches = normalizedHeader.length === normalizedExpected.length &&
|
|
2052
|
-
normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
|
|
2053
|
-
if (!matches) {
|
|
2054
|
-
return {
|
|
2055
|
-
ok: false,
|
|
2056
|
-
details: `Rule expects Clarification Log header: ${expected.join(" | ")}.`
|
|
2057
|
-
};
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
if (/exactly one/iu.test(rule)) {
|
|
2061
|
-
const tokens = tokensFromRule(rule);
|
|
2062
|
-
if (tokens.length > 0) {
|
|
2063
|
-
const selected = new Set();
|
|
2064
|
-
const tokenLines = [];
|
|
2065
|
-
for (const line of bodyLines) {
|
|
2066
|
-
if (!line)
|
|
2067
|
-
continue;
|
|
2068
|
-
for (const token of tokens) {
|
|
2069
|
-
if (!lineHasToken(line, token))
|
|
2070
|
-
continue;
|
|
2071
|
-
tokenLines.push({ line, token });
|
|
2072
|
-
if (/\[x\]/iu.test(line) || /selected|verdict|enum|execution result|status/iu.test(line)) {
|
|
2073
|
-
selected.add(token);
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
if (selected.size === 0 && tokenLines.length === 1 && !tokenLines[0].line.includes("|")) {
|
|
2078
|
-
selected.add(tokenLines[0].token);
|
|
2079
|
-
}
|
|
2080
|
-
if (selected.size !== 1) {
|
|
2081
|
-
return {
|
|
2082
|
-
ok: false,
|
|
2083
|
-
details: `Rule expects exactly one selected token (${tokens.join(", ")}); found ${selected.size}.`
|
|
2084
|
-
};
|
|
2085
|
-
}
|
|
2086
|
-
return { ok: true, details: "Exactly one token selected as expected." };
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
if (/Status:\s*pending\s+until/iu.test(rule)) {
|
|
2090
|
-
const statusLine = bodyLines.find((l) => /^\s*-?\s*Status\s*:/iu.test(l));
|
|
2091
|
-
if (!statusLine) {
|
|
2092
|
-
return { ok: false, details: "WAIT_FOR_CONFIRM section must contain a 'Status:' line." };
|
|
2093
|
-
}
|
|
2094
|
-
const validStatuses = ["pending", "approved"];
|
|
2095
|
-
const statusMatch = /Status\s*:\s*(\S+)/iu.exec(statusLine);
|
|
2096
|
-
const statusValue = statusMatch?.[1]?.toLowerCase();
|
|
2097
|
-
if (!statusValue || !validStatuses.includes(statusValue)) {
|
|
2098
|
-
const foundLabel = statusValue || "(empty)";
|
|
2099
|
-
return {
|
|
2100
|
-
ok: false,
|
|
2101
|
-
details: "WAIT_FOR_CONFIRM Status must be exactly one of: " + validStatuses.join(", ") + ". Found: " + foundLabel + "."
|
|
2102
|
-
};
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
const sectionNameNormalized = normalizeHeadingTitle(sectionName).toLowerCase();
|
|
2106
|
-
if (sectionNameNormalized === "red evidence") {
|
|
2107
|
-
return validateTddRedEvidence(sectionBody, context.tddEvidence?.red ?? {});
|
|
2108
|
-
}
|
|
2109
|
-
if (sectionNameNormalized === "green evidence") {
|
|
2110
|
-
return validateTddGreenEvidence(sectionBody, context.tddEvidence?.green ?? {});
|
|
2111
|
-
}
|
|
2112
|
-
if (sectionNameNormalized === "verification ladder") {
|
|
2113
|
-
return validateVerificationLadder(sectionBody);
|
|
2114
|
-
}
|
|
2115
|
-
if (sectionNameNormalized === "failure mode table") {
|
|
2116
|
-
return validateFailureModeTable(sectionBody);
|
|
2117
|
-
}
|
|
2118
|
-
if (sectionNameNormalized === "pre-scope system audit") {
|
|
2119
|
-
return validatePreScopeSystemAudit(sectionBody);
|
|
2120
|
-
}
|
|
2121
|
-
if (sectionNameNormalized === "scope summary") {
|
|
2122
|
-
return validateScopeSummary(sectionBody);
|
|
2123
|
-
}
|
|
2124
|
-
if (sectionNameNormalized.startsWith("requirements")) {
|
|
2125
|
-
return validateRequirementsTaxonomy(sectionBody);
|
|
2126
|
-
}
|
|
2127
|
-
if (sectionNameNormalized === "data flow") {
|
|
2128
|
-
return validateInteractionEdgeCaseMatrix(sectionBody, {
|
|
2129
|
-
sections: context.sections ?? null,
|
|
2130
|
-
liteTier: context.liteTier ?? false
|
|
2131
|
-
});
|
|
2132
|
-
}
|
|
2133
|
-
if (sectionNameNormalized === "architecture diagram") {
|
|
2134
|
-
return validateArchitectureDiagram(sectionBody, { sections: context.sections ?? null });
|
|
2135
|
-
}
|
|
2136
|
-
if (sectionNameNormalized === "acceptance criteria" &&
|
|
2137
|
-
/observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
|
|
2138
|
-
const rows = getMarkdownTableRows(sectionBody);
|
|
2139
|
-
for (const row of rows) {
|
|
2140
|
-
const criterionText = row[1] ?? row[0] ?? "";
|
|
2141
|
-
const adjective = lineContainsVagueAdjective(criterionText);
|
|
2142
|
-
if (adjective) {
|
|
2143
|
-
return {
|
|
2144
|
-
ok: false,
|
|
2145
|
-
details: `Acceptance criterion uses vague adjective "${adjective}" without a measurable predicate: "${criterionText.slice(0, 140)}". Rewrite with a numeric threshold or boolean outcome.`
|
|
2146
|
-
};
|
|
2147
|
-
}
|
|
2148
|
-
const hasDigit = /\d/u.test(criterionText);
|
|
2149
|
-
const hasMeasurableVerb = /\b(blocks?|rejects?|returns?|matches?|equals?|emits?|succeeds?|fails?|publishes?|logs?|persists?|reads?|writes?|creates?|deletes?|throws?|contains?|restores?|exceeds?|responds?|warns?|quarantines?|includes?|raises?|passes?|denies|refuses|exits|succeeds|completes|prevents|allows|maps|points|signals|surfaces|records|produces|accepts|requires)\b/iu.test(criterionText);
|
|
2150
|
-
const hasMeaningfulText = /[A-Za-z]/u.test(criterionText) && criterionText.trim().length >= 12;
|
|
2151
|
-
if (hasMeaningfulText && !hasDigit && !hasMeasurableVerb) {
|
|
2152
|
-
return {
|
|
2153
|
-
ok: false,
|
|
2154
|
-
details: `Acceptance criterion lacks a measurable predicate (no numeric threshold, no observable verb like blocks/returns/publishes/matches): "${criterionText.slice(0, 140)}". Rewrite so the criterion is falsifiable by a single test.`
|
|
2155
|
-
};
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
return {
|
|
2160
|
-
ok: true,
|
|
2161
|
-
details: "Section heading and content satisfy lint heuristics."
|
|
2162
|
-
};
|
|
2163
|
-
}
|