cclaw-cli 0.23.1 → 0.24.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/dist/cli.js CHANGED
@@ -58,7 +58,7 @@ Commands:
58
58
  Flags: --stage=<id> Limit to one flow stage (${FLOW_STAGES.join("|")}).
59
59
  --tier=<A|B|C> Fidelity tier (A=single-shot, B=tools, C=workflow).
60
60
  --schema-only Run only structural verifiers (default).
61
- --rules Run structural + rule verifiers (not wired yet).
61
+ --rules Also run rule-based verifiers (keywords, regex, counts, uniqueness, traceability).
62
62
  --judge Include LLM judging (not wired yet; requires API key).
63
63
  --dry-run Validate config + corpus, print summary, do not execute.
64
64
  --json Emit machine-readable JSON on stdout.
@@ -17,3 +17,14 @@ export declare function fixturePathFor(projectRoot: string, caseEntry: EvalCase)
17
17
  * the case but not on disk — structural fixtures ship alongside cases.
18
18
  */
19
19
  export declare function readFixtureArtifact(projectRoot: string, caseEntry: EvalCase): Promise<string | undefined>;
20
+ /**
21
+ * Resolve an entry from `extraFixtures` to an absolute filesystem path,
22
+ * relative to the case's stage directory (same convention as `fixture`).
23
+ */
24
+ export declare function extraFixturePath(projectRoot: string, caseEntry: EvalCase, label: string): string | undefined;
25
+ /**
26
+ * Read every declared extra fixture for a case into a `{ label → text }`
27
+ * map. Missing files throw so authoring mistakes surface immediately rather
28
+ * than being silently skipped by cross-artifact verifiers.
29
+ */
30
+ export declare function readExtraFixtures(projectRoot: string, caseEntry: EvalCase): Promise<Record<string, string>>;
@@ -58,6 +58,128 @@ function parseStructural(filePath, raw) {
58
58
  structural.maxChars = maxChars;
59
59
  return structural;
60
60
  }
61
+ function parseRegexRule(filePath, context, value) {
62
+ if (typeof value === "string") {
63
+ return { pattern: value };
64
+ }
65
+ if (!isRecord(value)) {
66
+ throw corpusError(filePath, `"${context}" entries must be either a string or a mapping with "pattern"`);
67
+ }
68
+ const pattern = value.pattern;
69
+ if (typeof pattern !== "string" || pattern.length === 0) {
70
+ throw corpusError(filePath, `"${context}" mapping entry must include a non-empty "pattern" string`);
71
+ }
72
+ const flags = value.flags;
73
+ if (flags !== undefined && typeof flags !== "string") {
74
+ throw corpusError(filePath, `"${context}" flags must be a string`);
75
+ }
76
+ const description = value.description;
77
+ if (description !== undefined && typeof description !== "string") {
78
+ throw corpusError(filePath, `"${context}" description must be a string`);
79
+ }
80
+ const rule = { pattern };
81
+ if (flags !== undefined)
82
+ rule.flags = flags;
83
+ if (description !== undefined)
84
+ rule.description = description;
85
+ return rule;
86
+ }
87
+ function parseRegexRules(filePath, context, value) {
88
+ if (value === undefined)
89
+ return undefined;
90
+ if (!Array.isArray(value)) {
91
+ throw corpusError(filePath, `"${context}" must be an array`);
92
+ }
93
+ return value.map((entry, index) => parseRegexRule(filePath, `${context}[${index}]`, entry));
94
+ }
95
+ function parseOccurrenceBounds(filePath, context, value) {
96
+ if (value === undefined)
97
+ return undefined;
98
+ if (!isRecord(value)) {
99
+ throw corpusError(filePath, `"${context}" must be a mapping of phrase → integer`);
100
+ }
101
+ const out = {};
102
+ for (const [phrase, count] of Object.entries(value)) {
103
+ if (typeof count !== "number" || !Number.isFinite(count) || !Number.isInteger(count) || count < 0) {
104
+ throw corpusError(filePath, `"${context}.${phrase}" must be a non-negative integer`);
105
+ }
106
+ out[phrase] = count;
107
+ }
108
+ return out;
109
+ }
110
+ function parseRules(filePath, raw) {
111
+ if (raw === undefined)
112
+ return undefined;
113
+ if (!isRecord(raw)) {
114
+ throw corpusError(filePath, `"expected.rules" must be a mapping`);
115
+ }
116
+ const mustContain = readStringArray(filePath, "expected.rules.must_contain", raw.must_contain ?? raw.mustContain);
117
+ const mustNotContain = readStringArray(filePath, "expected.rules.must_not_contain", raw.must_not_contain ?? raw.mustNotContain);
118
+ const regexRequired = parseRegexRules(filePath, "expected.rules.regex_required", raw.regex_required ?? raw.regexRequired);
119
+ const regexForbidden = parseRegexRules(filePath, "expected.rules.regex_forbidden", raw.regex_forbidden ?? raw.regexForbidden);
120
+ const minOccurrences = parseOccurrenceBounds(filePath, "expected.rules.min_occurrences", raw.min_occurrences ?? raw.minOccurrences);
121
+ const maxOccurrences = parseOccurrenceBounds(filePath, "expected.rules.max_occurrences", raw.max_occurrences ?? raw.maxOccurrences);
122
+ const uniqueBulletsInSection = readStringArray(filePath, "expected.rules.unique_bullets_in_section", raw.unique_bullets_in_section ?? raw.uniqueBulletsInSection);
123
+ const rules = {};
124
+ if (mustContain)
125
+ rules.mustContain = mustContain;
126
+ if (mustNotContain)
127
+ rules.mustNotContain = mustNotContain;
128
+ if (regexRequired)
129
+ rules.regexRequired = regexRequired;
130
+ if (regexForbidden)
131
+ rules.regexForbidden = regexForbidden;
132
+ if (minOccurrences)
133
+ rules.minOccurrences = minOccurrences;
134
+ if (maxOccurrences)
135
+ rules.maxOccurrences = maxOccurrences;
136
+ if (uniqueBulletsInSection)
137
+ rules.uniqueBulletsInSection = uniqueBulletsInSection;
138
+ return Object.keys(rules).length === 0 ? undefined : rules;
139
+ }
140
+ function parseTraceability(filePath, raw) {
141
+ if (raw === undefined)
142
+ return undefined;
143
+ if (!isRecord(raw)) {
144
+ throw corpusError(filePath, `"expected.traceability" must be a mapping`);
145
+ }
146
+ const idPattern = raw.id_pattern ?? raw.idPattern;
147
+ if (typeof idPattern !== "string" || idPattern.length === 0) {
148
+ throw corpusError(filePath, `"expected.traceability.id_pattern" must be a non-empty regex source`);
149
+ }
150
+ const idFlags = raw.id_flags ?? raw.idFlags;
151
+ if (idFlags !== undefined && typeof idFlags !== "string") {
152
+ throw corpusError(filePath, `"expected.traceability.id_flags" must be a string`);
153
+ }
154
+ const source = raw.source;
155
+ if (typeof source !== "string" || source.length === 0) {
156
+ throw corpusError(filePath, `"expected.traceability.source" must be "self" or an extra_fixtures label`);
157
+ }
158
+ const requireInRaw = raw.require_in ?? raw.requireIn;
159
+ const requireIn = readStringArray(filePath, "expected.traceability.require_in", requireInRaw);
160
+ if (!requireIn || requireIn.length === 0) {
161
+ throw corpusError(filePath, `"expected.traceability.require_in" must be a non-empty array`);
162
+ }
163
+ const out = { idPattern, source, requireIn };
164
+ if (idFlags !== undefined)
165
+ out.idFlags = idFlags;
166
+ return out;
167
+ }
168
+ function parseExtraFixtures(filePath, raw) {
169
+ if (raw === undefined)
170
+ return undefined;
171
+ if (!isRecord(raw)) {
172
+ throw corpusError(filePath, `"extra_fixtures" must be a mapping of label → path`);
173
+ }
174
+ const out = {};
175
+ for (const [label, value] of Object.entries(raw)) {
176
+ if (typeof value !== "string" || value.length === 0) {
177
+ throw corpusError(filePath, `"extra_fixtures.${label}" must be a non-empty path string`);
178
+ }
179
+ out[label] = value;
180
+ }
181
+ return Object.keys(out).length === 0 ? undefined : out;
182
+ }
61
183
  function parseExpected(filePath, raw) {
62
184
  if (raw === undefined)
63
185
  return undefined;
@@ -68,12 +190,12 @@ function parseExpected(filePath, raw) {
68
190
  const structural = parseStructural(filePath, raw.structural);
69
191
  if (structural)
70
192
  shape.structural = structural;
71
- if (raw.rules !== undefined) {
72
- if (!isRecord(raw.rules)) {
73
- throw corpusError(filePath, `"expected.rules" must be a mapping`);
74
- }
75
- shape.rules = raw.rules;
76
- }
193
+ const rules = parseRules(filePath, raw.rules);
194
+ if (rules)
195
+ shape.rules = rules;
196
+ const traceability = parseTraceability(filePath, raw.traceability);
197
+ if (traceability)
198
+ shape.traceability = traceability;
77
199
  if (raw.judge !== undefined) {
78
200
  if (!isRecord(raw.judge)) {
79
201
  throw corpusError(filePath, `"expected.judge" must be a mapping`);
@@ -101,13 +223,15 @@ function validateCase(filePath, raw) {
101
223
  const contextFiles = readStringArray(filePath, "context_files", raw.context_files ?? raw.contextFiles);
102
224
  const expected = parseExpected(filePath, raw.expected);
103
225
  const fixture = typeof raw.fixture === "string" ? raw.fixture : undefined;
226
+ const extraFixtures = parseExtraFixtures(filePath, raw.extra_fixtures ?? raw.extraFixtures);
104
227
  return {
105
228
  id: id.trim(),
106
229
  stage: stageRaw,
107
230
  inputPrompt: inputPrompt.trim(),
108
231
  contextFiles,
109
232
  expected,
110
- fixture
233
+ fixture,
234
+ extraFixtures
111
235
  };
112
236
  }
113
237
  /**
@@ -173,3 +297,34 @@ export async function readFixtureArtifact(projectRoot, caseEntry) {
173
297
  }
174
298
  return fs.readFile(fixturePath, "utf8");
175
299
  }
300
+ /**
301
+ * Resolve an entry from `extraFixtures` to an absolute filesystem path,
302
+ * relative to the case's stage directory (same convention as `fixture`).
303
+ */
304
+ export function extraFixturePath(projectRoot, caseEntry, label) {
305
+ const value = caseEntry.extraFixtures?.[label];
306
+ if (!value)
307
+ return undefined;
308
+ return path.resolve(projectRoot, EVALS_ROOT, "corpus", caseEntry.stage, value);
309
+ }
310
+ /**
311
+ * Read every declared extra fixture for a case into a `{ label → text }`
312
+ * map. Missing files throw so authoring mistakes surface immediately rather
313
+ * than being silently skipped by cross-artifact verifiers.
314
+ */
315
+ export async function readExtraFixtures(projectRoot, caseEntry) {
316
+ const out = {};
317
+ if (!caseEntry.extraFixtures)
318
+ return out;
319
+ for (const label of Object.keys(caseEntry.extraFixtures)) {
320
+ const filePath = extraFixturePath(projectRoot, caseEntry, label);
321
+ if (!filePath)
322
+ continue;
323
+ if (!(await exists(filePath))) {
324
+ throw new Error(`Extra fixture missing for ${caseEntry.stage}/${caseEntry.id} ` +
325
+ `(label="${label}"): ${filePath}`);
326
+ }
327
+ out[label] = await fs.readFile(filePath, "utf8");
328
+ }
329
+ return out;
330
+ }
@@ -2,9 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { CCLAW_VERSION } from "../constants.js";
3
3
  import { FLOW_STAGES } from "../types.js";
4
4
  import { compareAgainstBaselines, loadBaselinesByStage } from "./baseline.js";
5
- import { loadCorpus, readFixtureArtifact } from "./corpus.js";
5
+ import { loadCorpus, readExtraFixtures, readFixtureArtifact } from "./corpus.js";
6
6
  import { loadEvalConfig } from "./config-loader.js";
7
+ import { verifyRules } from "./verifiers/rules.js";
7
8
  import { verifyStructural } from "./verifiers/structural.js";
9
+ import { verifyTraceability } from "./verifiers/traceability.js";
8
10
  function groupByStage(cases) {
9
11
  return cases.reduce((acc, item) => {
10
12
  acc[item.stage] = (acc[item.stage] ?? 0) + 1;
@@ -21,33 +23,65 @@ function skeletonVerifierResult(message, details) {
21
23
  ...(details !== undefined ? { details } : {})
22
24
  };
23
25
  }
24
- async function runCaseStructural(projectRoot, caseEntry, plannedTier) {
26
+ /**
27
+ * --schema-only narrows to structural. --rules opens up rules + traceability
28
+ * on top of structural (traceability is a rule-family verifier even though
29
+ * it lives in its own module). Default (no flag) matches --schema-only for
30
+ * backwards compatibility with the Step 1 gate.
31
+ */
32
+ function resolveRunFlags(options) {
33
+ const rulesRequested = options.rules === true;
34
+ const schemaOnly = options.schemaOnly === true;
35
+ return {
36
+ runStructural: true,
37
+ runRules: rulesRequested && !schemaOnly,
38
+ runTraceability: rulesRequested && !schemaOnly
39
+ };
40
+ }
41
+ async function loadArtifactOrRecord(projectRoot, caseEntry, verifierResults) {
42
+ try {
43
+ return await readFixtureArtifact(projectRoot, caseEntry);
44
+ }
45
+ catch (err) {
46
+ verifierResults.push({
47
+ kind: "structural",
48
+ id: "structural:fixture:missing",
49
+ ok: false,
50
+ score: 0,
51
+ message: err instanceof Error ? err.message : String(err),
52
+ details: { fixture: caseEntry.fixture }
53
+ });
54
+ return undefined;
55
+ }
56
+ }
57
+ async function runCase(projectRoot, caseEntry, plannedTier, flags) {
25
58
  const started = Date.now();
26
- const structuralExpected = caseEntry.expected?.structural;
27
59
  const verifierResults = [];
28
- if (!structuralExpected || Object.keys(structuralExpected).length === 0) {
29
- // No structural expectations declared case is treated as "N/A" for this
30
- // verifier kind; a placeholder pass keeps downstream math simple while
31
- // making the situation visible in the report.
32
- verifierResults.push(skeletonVerifierResult("No structural expectations declared for this case; structural verifier skipped.", { skipped: true }));
33
- }
34
- else {
35
- let artifact;
36
- try {
37
- artifact = await readFixtureArtifact(projectRoot, caseEntry);
38
- }
39
- catch (err) {
60
+ const expected = caseEntry.expected;
61
+ const hasStructural = !!expected?.structural && Object.keys(expected.structural).length > 0;
62
+ const hasRules = flags.runRules && !!expected?.rules && Object.keys(expected.rules).length > 0;
63
+ const hasTraceability = flags.runTraceability && !!expected?.traceability;
64
+ const needsArtifact = hasStructural || hasRules || hasTraceability;
65
+ let artifact;
66
+ if (needsArtifact) {
67
+ artifact = await loadArtifactOrRecord(projectRoot, caseEntry, verifierResults);
68
+ if (artifact === undefined && verifierResults.length === 0) {
40
69
  verifierResults.push({
41
70
  kind: "structural",
42
- id: "structural:fixture:missing",
71
+ id: "structural:fixture:absent",
43
72
  ok: false,
44
73
  score: 0,
45
- message: err instanceof Error ? err.message : String(err),
46
- details: { fixture: caseEntry.fixture }
74
+ message: "Expectations declared but no fixture path provided. Add `fixture: ./<id>/fixture.md`.",
75
+ details: { fixtureProvided: false }
47
76
  });
48
77
  }
49
- if (artifact !== undefined) {
50
- const results = verifyStructural(artifact, structuralExpected);
78
+ }
79
+ if (flags.runStructural) {
80
+ if (!hasStructural) {
81
+ verifierResults.push(skeletonVerifierResult("No structural expectations declared for this case; structural verifier skipped.", { skipped: true }));
82
+ }
83
+ else if (artifact !== undefined) {
84
+ const results = verifyStructural(artifact, expected.structural);
51
85
  if (results.length === 0) {
52
86
  verifierResults.push(skeletonVerifierResult("Structural expectations parsed but produced zero checks.", { skipped: true }));
53
87
  }
@@ -55,18 +89,32 @@ async function runCaseStructural(projectRoot, caseEntry, plannedTier) {
55
89
  verifierResults.push(...results);
56
90
  }
57
91
  }
58
- else if (verifierResults.length === 0) {
92
+ }
93
+ if (hasRules && artifact !== undefined) {
94
+ const results = verifyRules(artifact, expected.rules);
95
+ verifierResults.push(...results);
96
+ }
97
+ if (hasTraceability && artifact !== undefined) {
98
+ try {
99
+ const extras = await readExtraFixtures(projectRoot, caseEntry);
100
+ const results = verifyTraceability(artifact, extras, expected.traceability);
101
+ verifierResults.push(...results);
102
+ }
103
+ catch (err) {
59
104
  verifierResults.push({
60
- kind: "structural",
61
- id: "structural:fixture:absent",
105
+ kind: "rules",
106
+ id: "traceability:fixture:missing",
62
107
  ok: false,
63
108
  score: 0,
64
- message: "Structural expectations declared but no fixture path provided. Add `fixture: ./<id>/fixture.md`.",
65
- details: { fixtureProvided: false }
109
+ message: err instanceof Error ? err.message : String(err),
110
+ details: { extraFixtures: Object.keys(caseEntry.extraFixtures ?? {}) }
66
111
  });
67
112
  }
68
113
  }
69
- const allOk = verifierResults.every((r) => r.ok);
114
+ const nonSkippedResults = verifierResults.filter((r) => r.details?.skipped !== true);
115
+ const allOk = nonSkippedResults.length === 0
116
+ ? verifierResults.every((r) => r.ok)
117
+ : nonSkippedResults.every((r) => r.ok);
70
118
  return {
71
119
  caseId: caseEntry.id,
72
120
  stage: caseEntry.stage,
@@ -125,12 +173,10 @@ export async function runEval(options) {
125
173
  if (corpus.length === 0) {
126
174
  notes.push("Corpus is empty. Seed cases live under `.cclaw/evals/corpus/<stage>/*.yaml`.");
127
175
  }
128
- if (options.rules) {
129
- notes.push("--rules is accepted; rule verifiers are not wired yet.");
130
- }
131
176
  if (options.judge) {
132
177
  notes.push("--judge is accepted; LLM judging is not wired yet.");
133
178
  }
179
+ const flags = resolveRunFlags(options);
134
180
  if (options.dryRun === true) {
135
181
  const summary = {
136
182
  kind: "dry-run",
@@ -142,8 +188,8 @@ export async function runEval(options) {
142
188
  },
143
189
  plannedTier,
144
190
  verifiersAvailable: {
145
- structural: true,
146
- rules: false,
191
+ structural: flags.runStructural,
192
+ rules: flags.runRules,
147
193
  judge: false,
148
194
  workflow: false
149
195
  },
@@ -154,7 +200,7 @@ export async function runEval(options) {
154
200
  const now = new Date().toISOString();
155
201
  const caseResults = [];
156
202
  for (const item of corpus) {
157
- caseResults.push(await runCaseStructural(options.projectRoot, item, plannedTier));
203
+ caseResults.push(await runCase(options.projectRoot, item, plannedTier, flags));
158
204
  }
159
205
  const stages = stagesInResults(caseResults);
160
206
  const baselines = await loadBaselinesByStage(options.projectRoot, stages);
@@ -58,11 +58,69 @@ export interface StructuralExpected {
58
58
  */
59
59
  requiredFrontmatterKeys?: string[];
60
60
  }
61
- /** Superset of per-verifier expectation shapes. Only `structural` is wired in Step 1. */
61
+ /**
62
+ * Rule-based expectations — zero-LLM content checks that are richer than
63
+ * structural (regex, numeric bounds, uniqueness). Introduced in Step 2.
64
+ *
65
+ * Every array field is optional; an empty `RulesExpected` produces zero
66
+ * verifier results so authors can enable rules incrementally.
67
+ */
68
+ export interface RulesExpected {
69
+ /** Case-insensitive substrings the body must include at least once. */
70
+ mustContain?: string[];
71
+ /** Case-insensitive substrings the body must NOT include. */
72
+ mustNotContain?: string[];
73
+ /** Regex patterns that must match the body at least once. */
74
+ regexRequired?: RuleRegex[];
75
+ /** Regex patterns that must NOT match the body. */
76
+ regexForbidden?: RuleRegex[];
77
+ /** For each substring key, the body must contain at least N occurrences. */
78
+ minOccurrences?: Record<string, number>;
79
+ /** For each substring key, the body must contain at most N occurrences. */
80
+ maxOccurrences?: Record<string, number>;
81
+ /**
82
+ * For each named section (case-insensitive heading substring), every bullet
83
+ * (`- ...`) directly under the section must be unique. Catches duplicated
84
+ * decisions or repeated risks.
85
+ */
86
+ uniqueBulletsInSection?: string[];
87
+ }
88
+ export interface RuleRegex {
89
+ /** Source of the regex. Parsed with `new RegExp(pattern, flags)`. */
90
+ pattern: string;
91
+ /** Optional regex flags; defaults to `"i"` for case-insensitive matching. */
92
+ flags?: string;
93
+ /** Human-readable label rendered in verifier messages and slugged into the id. */
94
+ description?: string;
95
+ }
96
+ /**
97
+ * Cross-stage traceability expectations — assert every ID extracted from
98
+ * `source` also appears in `self` and/or named `extra_fixtures`. Introduced
99
+ * in Step 2.
100
+ */
101
+ export interface TraceabilityExpected {
102
+ /** Regex applied to the `source` fixture to collect the authoritative ID set. */
103
+ idPattern: string;
104
+ /** Optional regex flags (defaults to `"g"`). */
105
+ idFlags?: string;
106
+ /**
107
+ * Where to read the authoritative ID set from. Either `"self"` (the case's
108
+ * primary `fixture`) or a label present in the case's `extraFixtures` map.
109
+ */
110
+ source: string;
111
+ /**
112
+ * Where every source ID must also appear. Each entry is `"self"` or an
113
+ * `extraFixtures` label. Order is preserved for deterministic result ids.
114
+ */
115
+ requireIn: string[];
116
+ }
117
+ /** Superset of per-verifier expectation shapes. */
62
118
  export interface ExpectedShape {
63
119
  structural?: StructuralExpected;
64
- /** Rule-based (keyword/regex/traceability) checks — Step 2. */
65
- rules?: Record<string, unknown>;
120
+ /** Rule-based (keyword/regex/count/uniqueness) checks — Step 2. */
121
+ rules?: RulesExpected;
122
+ /** Cross-stage ID propagation checks — Step 2. */
123
+ traceability?: TraceabilityExpected;
66
124
  /** LLM-judge rubrics — Step 3. */
67
125
  judge?: Record<string, unknown>;
68
126
  }
@@ -89,6 +147,13 @@ export interface EvalCase {
89
147
  * Step 1 development aid.
90
148
  */
91
149
  fixture?: string;
150
+ /**
151
+ * Additional fixture paths loaded alongside the primary `fixture`, keyed
152
+ * by a free-form label. Consumed by cross-artifact verifiers (e.g.,
153
+ * traceability) introduced in Step 2. Paths are resolved relative to the
154
+ * case's stage directory, just like `fixture`.
155
+ */
156
+ extraFixtures?: Record<string, string>;
92
157
  }
93
158
  /** Result of one verifier applied to one case. */
94
159
  export interface VerifierResult {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Rule-based verifier: deterministic, zero-LLM checks that are richer than
3
+ * structural heading/length assertions. Each rule produces exactly one
4
+ * `VerifierResult` so baselines diff at the check level, and authoring a
5
+ * rule sideways in YAML never silently skips.
6
+ *
7
+ * Semantics:
8
+ *
9
+ * - All substring matching is case-insensitive. Regex matching uses the
10
+ * flags declared on the rule (default `"i"`).
11
+ * - Rules operate on the artifact BODY (frontmatter stripped), mirroring
12
+ * the structural verifier so min/max counts and length checks agree on
13
+ * what "body" means.
14
+ * - `uniqueBulletsInSection` scans every section (heading, case-insensitive
15
+ * substring match) and flags duplicate top-level bullets ("- item"). The
16
+ * search stops at the next heading of equal or lower depth.
17
+ */
18
+ import type { RulesExpected, VerifierResult } from "../types.js";
19
+ /**
20
+ * Run every configured rule check against the artifact body. Returns `[]`
21
+ * when `expected` is undefined or empty so the runner can distinguish
22
+ * "no rules declared" from "all rules passed".
23
+ */
24
+ export declare function verifyRules(artifact: string, expected: RulesExpected | undefined): VerifierResult[];
@@ -0,0 +1,218 @@
1
+ import { splitFrontmatter } from "./structural.js";
2
+ function slugify(input) {
3
+ return (input
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/(^-|-$)/g, "")
7
+ .slice(0, 64) || "rule");
8
+ }
9
+ function result(id, ok, message, details) {
10
+ return {
11
+ kind: "rules",
12
+ id,
13
+ ok,
14
+ score: ok ? 1 : 0,
15
+ message,
16
+ ...(details !== undefined ? { details } : {})
17
+ };
18
+ }
19
+ function countOccurrences(haystack, needle) {
20
+ if (needle.length === 0)
21
+ return 0;
22
+ let index = 0;
23
+ let count = 0;
24
+ while (true) {
25
+ const at = haystack.indexOf(needle, index);
26
+ if (at < 0)
27
+ return count;
28
+ count += 1;
29
+ index = at + needle.length;
30
+ }
31
+ }
32
+ function compileRegex(rule) {
33
+ const flags = rule.flags ?? "i";
34
+ try {
35
+ return new RegExp(rule.pattern, flags);
36
+ }
37
+ catch (err) {
38
+ throw new Error(`Invalid regex for rule "${rule.description ?? rule.pattern}" ` +
39
+ `(pattern=${JSON.stringify(rule.pattern)}, flags=${JSON.stringify(flags)}): ` +
40
+ (err instanceof Error ? err.message : String(err)));
41
+ }
42
+ }
43
+ function ruleLabel(rule) {
44
+ return rule.description?.trim() || rule.pattern;
45
+ }
46
+ function checkMustContain(needles, body) {
47
+ const bodyLower = body.toLowerCase();
48
+ return needles.map((needle) => {
49
+ const found = bodyLower.includes(needle.toLowerCase());
50
+ return result(`rules:contains:${slugify(needle)}`, found, found
51
+ ? `Required phrase "${needle}" present.`
52
+ : `Required phrase "${needle}" missing from body.`, { phrase: needle });
53
+ });
54
+ }
55
+ function checkMustNotContain(needles, body) {
56
+ const bodyLower = body.toLowerCase();
57
+ return needles.map((needle) => {
58
+ const lowered = needle.toLowerCase();
59
+ const occurrences = countOccurrences(bodyLower, lowered);
60
+ const ok = occurrences === 0;
61
+ return result(`rules:not-contains:${slugify(needle)}`, ok, ok
62
+ ? `Forbidden phrase "${needle}" absent (as required).`
63
+ : `Forbidden phrase "${needle}" appears ${occurrences} time(s).`, { phrase: needle, occurrences });
64
+ });
65
+ }
66
+ function checkRegexRequired(rules, body) {
67
+ return rules.map((rule) => {
68
+ const label = ruleLabel(rule);
69
+ const regex = compileRegex(rule);
70
+ const matches = body.match(new RegExp(regex.source, withGlobal(regex.flags)));
71
+ const count = matches ? matches.length : 0;
72
+ const ok = count > 0;
73
+ return result(`rules:regex-required:${slugify(label)}`, ok, ok
74
+ ? `Required pattern /${rule.pattern}/ matched ${count} time(s).`
75
+ : `Required pattern /${rule.pattern}/ did not match.`, { pattern: rule.pattern, flags: rule.flags ?? "i", matches: count });
76
+ });
77
+ }
78
+ function checkRegexForbidden(rules, body) {
79
+ return rules.map((rule) => {
80
+ const label = ruleLabel(rule);
81
+ const regex = compileRegex(rule);
82
+ const matches = body.match(new RegExp(regex.source, withGlobal(regex.flags)));
83
+ const count = matches ? matches.length : 0;
84
+ const ok = count === 0;
85
+ return result(`rules:regex-forbidden:${slugify(label)}`, ok, ok
86
+ ? `Forbidden pattern /${rule.pattern}/ absent.`
87
+ : `Forbidden pattern /${rule.pattern}/ matched ${count} time(s).`, { pattern: rule.pattern, flags: rule.flags ?? "i", matches: count });
88
+ });
89
+ }
90
+ function withGlobal(flags) {
91
+ return flags.includes("g") ? flags : `${flags}g`;
92
+ }
93
+ function checkMinOccurrences(bounds, body) {
94
+ const bodyLower = body.toLowerCase();
95
+ return Object.entries(bounds).map(([needle, min]) => {
96
+ const occurrences = countOccurrences(bodyLower, needle.toLowerCase());
97
+ const ok = occurrences >= min;
98
+ return result(`rules:min-occurrences:${slugify(needle)}`, ok, ok
99
+ ? `Phrase "${needle}" appears ${occurrences} time(s) (>= ${min}).`
100
+ : `Phrase "${needle}" appears ${occurrences} time(s); expected at least ${min}.`, { phrase: needle, occurrences, min });
101
+ });
102
+ }
103
+ function checkMaxOccurrences(bounds, body) {
104
+ const bodyLower = body.toLowerCase();
105
+ return Object.entries(bounds).map(([needle, max]) => {
106
+ const occurrences = countOccurrences(bodyLower, needle.toLowerCase());
107
+ const ok = occurrences <= max;
108
+ return result(`rules:max-occurrences:${slugify(needle)}`, ok, ok
109
+ ? `Phrase "${needle}" appears ${occurrences} time(s) (<= ${max}).`
110
+ : `Phrase "${needle}" appears ${occurrences} time(s); expected at most ${max}.`, { phrase: needle, occurrences, max });
111
+ });
112
+ }
113
+ function sliceBySection(body) {
114
+ const lines = body.split(/\r?\n/);
115
+ const slices = [];
116
+ let current = null;
117
+ for (const rawLine of lines) {
118
+ const line = rawLine.trimStart();
119
+ const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
120
+ if (match) {
121
+ if (current) {
122
+ slices.push({
123
+ heading: current.heading,
124
+ depth: current.depth,
125
+ body: current.body.join("\n")
126
+ });
127
+ }
128
+ current = { heading: match[2].trim(), depth: match[1].length, body: [] };
129
+ }
130
+ else if (current) {
131
+ current.body.push(rawLine);
132
+ }
133
+ }
134
+ if (current) {
135
+ slices.push({
136
+ heading: current.heading,
137
+ depth: current.depth,
138
+ body: current.body.join("\n")
139
+ });
140
+ }
141
+ return slices;
142
+ }
143
+ function extractTopLevelBullets(sectionBody) {
144
+ const bullets = [];
145
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
146
+ const line = rawLine.replace(/\s+$/, "");
147
+ const leading = line.match(/^(\s*)[-*]\s+(.+)$/);
148
+ if (!leading)
149
+ continue;
150
+ if (leading[1].length > 0)
151
+ continue;
152
+ bullets.push(leading[2].trim());
153
+ }
154
+ return bullets;
155
+ }
156
+ function checkUniqueBulletsInSection(sections, body) {
157
+ const slices = sliceBySection(body);
158
+ return sections.map((needle) => {
159
+ const lowerNeedle = needle.toLowerCase();
160
+ const slice = slices.find((s) => s.heading.toLowerCase().includes(lowerNeedle));
161
+ if (!slice) {
162
+ return result(`rules:unique-in-section:${slugify(needle)}`, false, `Section matching "${needle}" not found; cannot check uniqueness.`, { section: needle, found: false });
163
+ }
164
+ const bullets = extractTopLevelBullets(slice.body);
165
+ const seen = new Map();
166
+ for (const bullet of bullets) {
167
+ const key = bullet.toLowerCase();
168
+ seen.set(key, (seen.get(key) ?? 0) + 1);
169
+ }
170
+ const duplicates = [...seen.entries()]
171
+ .filter(([, count]) => count > 1)
172
+ .map(([entry, count]) => ({ entry, count }));
173
+ const ok = duplicates.length === 0;
174
+ return result(`rules:unique-in-section:${slugify(needle)}`, ok, ok
175
+ ? `Section "${slice.heading}" has ${bullets.length} unique bullet(s).`
176
+ : `Section "${slice.heading}" has duplicate bullet(s): ${duplicates
177
+ .map((d) => `"${d.entry}" x${d.count}`)
178
+ .join(", ")}.`, {
179
+ section: slice.heading,
180
+ bullets: bullets.length,
181
+ duplicates
182
+ });
183
+ });
184
+ }
185
+ /**
186
+ * Run every configured rule check against the artifact body. Returns `[]`
187
+ * when `expected` is undefined or empty so the runner can distinguish
188
+ * "no rules declared" from "all rules passed".
189
+ */
190
+ export function verifyRules(artifact, expected) {
191
+ if (!expected)
192
+ return [];
193
+ const split = splitFrontmatter(artifact);
194
+ const body = split.body;
195
+ const results = [];
196
+ if (expected.mustContain?.length) {
197
+ results.push(...checkMustContain(expected.mustContain, body));
198
+ }
199
+ if (expected.mustNotContain?.length) {
200
+ results.push(...checkMustNotContain(expected.mustNotContain, body));
201
+ }
202
+ if (expected.regexRequired?.length) {
203
+ results.push(...checkRegexRequired(expected.regexRequired, body));
204
+ }
205
+ if (expected.regexForbidden?.length) {
206
+ results.push(...checkRegexForbidden(expected.regexForbidden, body));
207
+ }
208
+ if (expected.minOccurrences && Object.keys(expected.minOccurrences).length) {
209
+ results.push(...checkMinOccurrences(expected.minOccurrences, body));
210
+ }
211
+ if (expected.maxOccurrences && Object.keys(expected.maxOccurrences).length) {
212
+ results.push(...checkMaxOccurrences(expected.maxOccurrences, body));
213
+ }
214
+ if (expected.uniqueBulletsInSection?.length) {
215
+ results.push(...checkUniqueBulletsInSection(expected.uniqueBulletsInSection, body));
216
+ }
217
+ return results;
218
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Cross-stage traceability verifier: extract a set of IDs from a source
3
+ * fixture (e.g. `D-\d+` decisions declared during scope) and assert every
4
+ * ID appears in the artifact-under-test and/or in other linked fixtures.
5
+ *
6
+ * The verifier is intentionally source-agnostic: the caller passes the
7
+ * primary artifact plus a label → text map for any extra fixtures declared
8
+ * on the case. `source` and entries in `requireIn` are either the string
9
+ * `"self"` (the primary artifact) or labels present in the extras map.
10
+ *
11
+ * Result ids follow `traceability:<source>->:<target>:<reason>` so baselines
12
+ * diff at the per-link granularity. A missing link produces one result with
13
+ * a list of missing IDs in its `details` payload.
14
+ */
15
+ import type { TraceabilityExpected, VerifierResult } from "../types.js";
16
+ export declare const SELF_LABEL = "self";
17
+ /**
18
+ * Run traceability checks. Returns `[]` when expectations are undefined.
19
+ * Emits a single "source-missing" result when the declared source fixture
20
+ * has zero IDs (authoring error), and one result per `requireIn` target
21
+ * listing any IDs absent in that fixture.
22
+ */
23
+ export declare function verifyTraceability(primaryArtifact: string, extraFixtures: Record<string, string>, expected: TraceabilityExpected | undefined): VerifierResult[];
@@ -0,0 +1,84 @@
1
+ import { splitFrontmatter } from "./structural.js";
2
+ export const SELF_LABEL = "self";
3
+ function result(id, ok, message, details) {
4
+ return {
5
+ kind: "rules",
6
+ id,
7
+ ok,
8
+ score: ok ? 1 : 0,
9
+ message,
10
+ ...(details !== undefined ? { details } : {})
11
+ };
12
+ }
13
+ function compileIdRegex(expected) {
14
+ const flags = expected.idFlags ?? "g";
15
+ const normalized = flags.includes("g") ? flags : `${flags}g`;
16
+ try {
17
+ return new RegExp(expected.idPattern, normalized);
18
+ }
19
+ catch (err) {
20
+ throw new Error(`Invalid traceability id_pattern ${JSON.stringify(expected.idPattern)} ` +
21
+ `(flags=${JSON.stringify(normalized)}): ` +
22
+ (err instanceof Error ? err.message : String(err)));
23
+ }
24
+ }
25
+ function bodyOf(text) {
26
+ return splitFrontmatter(text).body;
27
+ }
28
+ function extractIds(text, regex) {
29
+ const body = bodyOf(text);
30
+ const found = new Set();
31
+ for (const match of body.matchAll(regex)) {
32
+ found.add(match[0]);
33
+ }
34
+ return [...found].sort();
35
+ }
36
+ function resolveFixture(label, primary, extraFixtures) {
37
+ if (label === SELF_LABEL)
38
+ return primary;
39
+ return extraFixtures[label];
40
+ }
41
+ /**
42
+ * Run traceability checks. Returns `[]` when expectations are undefined.
43
+ * Emits a single "source-missing" result when the declared source fixture
44
+ * has zero IDs (authoring error), and one result per `requireIn` target
45
+ * listing any IDs absent in that fixture.
46
+ */
47
+ export function verifyTraceability(primaryArtifact, extraFixtures, expected) {
48
+ if (!expected)
49
+ return [];
50
+ const regex = compileIdRegex(expected);
51
+ const sourceText = resolveFixture(expected.source, primaryArtifact, extraFixtures);
52
+ if (sourceText === undefined) {
53
+ return [
54
+ result(`traceability:source:${expected.source}:missing`, false, `Traceability source fixture "${expected.source}" not loaded.`, { source: expected.source })
55
+ ];
56
+ }
57
+ const sourceIds = extractIds(sourceText, regex);
58
+ if (sourceIds.length === 0) {
59
+ return [
60
+ result(`traceability:source:${expected.source}:empty`, false, `Source "${expected.source}" yielded zero ids for pattern /${expected.idPattern}/.`, { source: expected.source, pattern: expected.idPattern })
61
+ ];
62
+ }
63
+ const results = [];
64
+ for (const target of expected.requireIn) {
65
+ const targetText = resolveFixture(target, primaryArtifact, extraFixtures);
66
+ if (targetText === undefined) {
67
+ results.push(result(`traceability:target:${target}:missing`, false, `Traceability target fixture "${target}" not loaded.`, { target }));
68
+ continue;
69
+ }
70
+ const targetBody = bodyOf(targetText);
71
+ const missing = sourceIds.filter((id) => !targetBody.includes(id));
72
+ const ok = missing.length === 0;
73
+ results.push(result(`traceability:${expected.source}->${target}`, ok, ok
74
+ ? `Every id (${sourceIds.length}) from "${expected.source}" appears in "${target}".`
75
+ : `Target "${target}" is missing ${missing.length}/${sourceIds.length} id(s): ${missing.join(", ")}.`, {
76
+ source: expected.source,
77
+ target,
78
+ sourceIds,
79
+ missing,
80
+ pattern: expected.idPattern
81
+ }));
82
+ }
83
+ return results;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {