@momentiq/dark-factory-cli 1.0.0 → 2.0.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 +121 -40
- package/dist/adapters/codex-sdk.d.ts +66 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -1
- package/dist/adapters/codex-sdk.js +321 -4
- package/dist/adapters/codex-sdk.js.map +1 -1
- package/dist/adapters/critic-result-schema.d.ts +15 -10
- package/dist/adapters/critic-result-schema.d.ts.map +1 -1
- package/dist/adapters/critic-result-schema.js +10 -0
- package/dist/adapters/critic-result-schema.js.map +1 -1
- package/dist/adapters/cursor-cli.d.ts +19 -1
- package/dist/adapters/cursor-cli.d.ts.map +1 -1
- package/dist/adapters/cursor-cli.js +96 -14
- package/dist/adapters/cursor-cli.js.map +1 -1
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/static-schema-lint.d.ts +68 -0
- package/dist/adapters/static-schema-lint.d.ts.map +1 -0
- package/dist/adapters/static-schema-lint.js +813 -0
- package/dist/adapters/static-schema-lint.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +2 -2
- package/dist/cli.d.ts +17 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +414 -113
- package/dist/cli.js.map +1 -1
- package/dist/commands/findings.d.ts +6 -0
- package/dist/commands/findings.d.ts.map +1 -0
- package/dist/commands/findings.js +196 -0
- package/dist/commands/findings.js.map +1 -0
- package/dist/commands/show.d.ts +11 -0
- package/dist/commands/show.d.ts.map +1 -0
- package/dist/commands/show.js +78 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/skills.d.ts +6 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +248 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +85 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/compact/index.d.ts +2 -0
- package/dist/compact/index.d.ts.map +1 -0
- package/dist/compact/index.js +3 -0
- package/dist/compact/index.js.map +1 -0
- package/dist/compact/lockfile.d.ts +53 -0
- package/dist/compact/lockfile.d.ts.map +1 -0
- package/dist/compact/lockfile.js +626 -0
- package/dist/compact/lockfile.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +39 -9
- package/dist/doctor.d.ts +51 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +497 -1
- package/dist/doctor.js.map +1 -1
- package/dist/evidence/docker-build.d.ts +6 -0
- package/dist/evidence/docker-build.d.ts.map +1 -0
- package/dist/evidence/docker-build.js +199 -0
- package/dist/evidence/docker-build.js.map +1 -0
- package/dist/evidence/index.d.ts +1 -0
- package/dist/evidence/index.d.ts.map +1 -1
- package/dist/evidence/index.js +6 -0
- package/dist/evidence/index.js.map +1 -1
- package/dist/handoff/handoff-verb.d.ts.map +1 -1
- package/dist/handoff/handoff-verb.js +6 -3
- package/dist/handoff/handoff-verb.js.map +1 -1
- package/dist/handoff/ports.d.ts +8 -0
- package/dist/handoff/ports.d.ts.map +1 -1
- package/dist/handoff/real-clients.js +1 -1
- package/dist/handoff/real-clients.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/show-status-core.d.ts +63 -0
- package/dist/lib/show-status-core.d.ts.map +1 -0
- package/dist/lib/show-status-core.js +156 -0
- package/dist/lib/show-status-core.js.map +1 -0
- package/dist/mcp/prompts.d.ts.map +1 -1
- package/dist/mcp/prompts.js +113 -78
- package/dist/mcp/prompts.js.map +1 -1
- package/dist/mcp/resources.js +1 -1
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/doctor.d.ts.map +1 -1
- package/dist/mcp/tools/doctor.js +5 -0
- package/dist/mcp/tools/doctor.js.map +1 -1
- package/dist/mcp/tools/findings.d.ts +0 -22
- package/dist/mcp/tools/findings.d.ts.map +1 -1
- package/dist/mcp/tools/findings.js +5 -73
- package/dist/mcp/tools/findings.js.map +1 -1
- package/dist/mcp/tools/handoff.d.ts.map +1 -1
- package/dist/mcp/tools/handoff.js +10 -17
- package/dist/mcp/tools/handoff.js.map +1 -1
- package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
- package/dist/mcp/tools/review-bypass.js +22 -1
- package/dist/mcp/tools/review-bypass.js.map +1 -1
- package/dist/mcp/tools/skills-install.d.ts +6 -0
- package/dist/mcp/tools/skills-install.d.ts.map +1 -0
- package/dist/mcp/tools/skills-install.js +260 -0
- package/dist/mcp/tools/skills-install.js.map +1 -0
- package/dist/mcp/tools/stats-gate.d.ts.map +1 -1
- package/dist/mcp/tools/stats-gate.js +63 -15
- package/dist/mcp/tools/stats-gate.js.map +1 -1
- package/dist/policy/baseline.d.ts.map +1 -1
- package/dist/policy/baseline.js +11 -3
- package/dist/policy/baseline.js.map +1 -1
- package/dist/policy/gate.d.ts +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +96 -12
- package/dist/policy/gate.js.map +1 -1
- package/dist/prompt.d.ts +1 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +123 -4
- package/dist/prompt.js.map +1 -1
- package/dist/report.d.ts +130 -3
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +367 -10
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts +6 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +213 -4
- package/dist/runner.js.map +1 -1
- package/dist/self-consistency.d.ts +144 -0
- package/dist/self-consistency.d.ts.map +1 -0
- package/dist/self-consistency.js +368 -0
- package/dist/self-consistency.js.map +1 -0
- package/dist/skills/config.d.ts +176 -0
- package/dist/skills/config.d.ts.map +1 -0
- package/dist/skills/config.js +251 -0
- package/dist/skills/config.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +8 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/install.d.ts +62 -0
- package/dist/skills/install.d.ts.map +1 -0
- package/dist/skills/install.js +315 -0
- package/dist/skills/install.js.map +1 -0
- package/dist/skills/template.d.ts +42 -0
- package/dist/skills/template.d.ts.map +1 -0
- package/dist/skills/template.js +95 -0
- package/dist/skills/template.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -1
- package/dist/trusted-surface/rebind.js +199 -2
- package/dist/trusted-surface/rebind.js.map +1 -1
- package/package.json +36 -2
- package/skills/README.md +89 -0
- package/skills/chief-engineer-blitz/SKILL.md.tmpl +443 -0
- package/skills/chief-engineer-blitz/skill.json +53 -0
- package/skills/chief-engineer-review/SKILL.md.tmpl +184 -0
- package/skills/chief-engineer-review/references/review-examples.md.tmpl +130 -0
- package/skills/chief-engineer-review/skill.json +67 -0
- package/skills/chief-engineer-review/templates/code-review-prompt.md.tmpl +111 -0
- package/skills/chief-engineer-review/templates/escalation-prompt.md.tmpl +56 -0
- package/skills/chief-engineer-review/templates/plan-review-prompt.md.tmpl +74 -0
- package/skills/skill-schema.json +73 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
// Consumer dark-factory-platform#107 — deterministic schema-lint adapter.
|
|
2
|
+
//
|
|
3
|
+
// Why a sixth adapter (consumer DFP #107 incident): on PR #105 of the
|
|
4
|
+
// consumer hosted control-plane, the local-profile quorum (cursor-cli +
|
|
5
|
+
// codex, quorum 2) returned APPROVED on a `~/.claude/settings.json`
|
|
6
|
+
// example documenting `effortLevel: "max"`. The cloud-profile cursor
|
|
7
|
+
// adapter (via `cursor-sdk`, different prompt envelope) caught it as a
|
|
8
|
+
// high-severity schema finding: the persisted `effortLevel` schema only
|
|
9
|
+
// permits `low|medium|high|xhigh`; `"max"` is silently ignored on
|
|
10
|
+
// session start (the `max` tier exists only as a session-scoped
|
|
11
|
+
// `/effort max` command / `--effort max` flag). Same vendor, different
|
|
12
|
+
// adapter, different verdict.
|
|
13
|
+
//
|
|
14
|
+
// The structural fix: a deterministic critic that does NOT depend on
|
|
15
|
+
// LLM judgement for this class of finding. The adapter scans changed
|
|
16
|
+
// markdown / config files for fenced code blocks annotated with a
|
|
17
|
+
// `// schema: <name>` (or `# schema: <name>` for YAML / `<!-- schema:
|
|
18
|
+
// <name> -->` for HTML / `/* schema: <name> */` for JSONC-block style)
|
|
19
|
+
// marker, looks up the named schema in a built-in registry, and runs
|
|
20
|
+
// `ajv` JSON-Schema validation against the parsed payload. Violations
|
|
21
|
+
// surface as `severity: high` findings; the PR #105 `effortLevel: "max"`
|
|
22
|
+
// example fails in <50ms.
|
|
23
|
+
//
|
|
24
|
+
// Posture:
|
|
25
|
+
// - `requiredEnvVars: []` — no auth, no network, no LLM call.
|
|
26
|
+
// - `runtime: local` (and cloud — belt-and-suspenders; deterministic
|
|
27
|
+
// so the same result either side).
|
|
28
|
+
// - `required: false` in the consumer config; its job is to surface
|
|
29
|
+
// blocking-severity findings, which veto regardless of quorum (the
|
|
30
|
+
// existing single-critic-veto pattern under min-complete-quorum).
|
|
31
|
+
// - Pure function over `packet.changedFiles` — same input → same
|
|
32
|
+
// output, every time. No retries, no flake, no calibration window.
|
|
33
|
+
//
|
|
34
|
+
// What it DOES validate:
|
|
35
|
+
// - Fenced code blocks annotated with a recognized schema marker.
|
|
36
|
+
// - Built-in schemas: `claude-code-settings`, `df-agent-review-config`.
|
|
37
|
+
// The registry is extensible by call sites that supply additional
|
|
38
|
+
// schemas via constructor options (a future cycle can wire
|
|
39
|
+
// `~/.claude/settings.json` schema from the upstream Claude Code
|
|
40
|
+
// project once it stabilizes; for now the registry is local + minimal).
|
|
41
|
+
//
|
|
42
|
+
// What it does NOT do:
|
|
43
|
+
// - It does NOT auto-detect schemas from the file path (a code block
|
|
44
|
+
// in CLAUDE.md showing a `~/.claude/settings.json` example needs the
|
|
45
|
+
// opt-in `// schema: claude-code-settings` marker). The opt-in is
|
|
46
|
+
// intentional: false positives in the local quorum are more harmful
|
|
47
|
+
// than false negatives because they erode trust in the gate. A doc
|
|
48
|
+
// author who forgets the marker gets a missed finding (caught by
|
|
49
|
+
// the cloud cursor anyway); a marker mismatch produces a clear
|
|
50
|
+
// finding the author can correct.
|
|
51
|
+
// - It does NOT validate the entire markdown document as JSON. Only
|
|
52
|
+
// fenced code blocks marked with the schema annotation are subject
|
|
53
|
+
// to lint.
|
|
54
|
+
// - It does NOT call any LLM, network endpoint, or filesystem outside
|
|
55
|
+
// `packet.changedFiles`. The adapter is pure-deterministic.
|
|
56
|
+
//
|
|
57
|
+
// Where it RUNS in the gate:
|
|
58
|
+
// - Local profile: paired with `cursor-cli` + `codex-sdk`. Adds a
|
|
59
|
+
// deterministic backstop without touching quorum (it is veto-only via
|
|
60
|
+
// `blockingSeverities`).
|
|
61
|
+
// - Cloud profile: same wiring; deterministic so identical verdict.
|
|
62
|
+
//
|
|
63
|
+
// Drift control: the built-in schemas live in `STATIC_SCHEMAS` below.
|
|
64
|
+
// When the Claude Code settings schema evolves upstream, this registry
|
|
65
|
+
// needs the corresponding update. The boundary is intentional — we don't
|
|
66
|
+
// chase a moving upstream schema without explicit review.
|
|
67
|
+
// Ajv ships a CJS default + named-types pattern. Under NodeNext ESM the
|
|
68
|
+
// default-import lands as the namespace; the constructor is on `.default`.
|
|
69
|
+
// Probe both shapes so the adapter survives ajv minor-version churn (the
|
|
70
|
+
// shape moved between 8.x patch releases historically).
|
|
71
|
+
import AjvImport from "ajv";
|
|
72
|
+
import addFormatsImport from "ajv-formats";
|
|
73
|
+
import { parse as parseYaml } from "yaml";
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
const AjvCtor = AjvImport.default
|
|
76
|
+
? AjvImport.default
|
|
77
|
+
: AjvImport;
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
const addFormats = typeof addFormatsImport === "function"
|
|
80
|
+
? addFormatsImport
|
|
81
|
+
: addFormatsImport
|
|
82
|
+
.default;
|
|
83
|
+
export const STATIC_SCHEMA_LINT_ADAPTER_ID = "static-schema-lint";
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Built-in schema registry.
|
|
86
|
+
//
|
|
87
|
+
// Each entry is a JSON Schema (draft-07-compatible) the adapter validates
|
|
88
|
+
// extracted code blocks against. The schemas are intentionally MINIMAL —
|
|
89
|
+
// they encode only the fields whose schema-violation is a real defect-class
|
|
90
|
+
// observed in the consumer incident (consumer DFP #107) or its near
|
|
91
|
+
// neighbors. A schema that tries to validate every field of an upstream
|
|
92
|
+
// settings file becomes brittle the moment the upstream adds a field, so
|
|
93
|
+
// the adapter prefers a smaller surface that catches the named regressions
|
|
94
|
+
// over a larger surface that breaks on every upstream addition.
|
|
95
|
+
/**
|
|
96
|
+
* Schema for `~/.claude/settings.json` examples.
|
|
97
|
+
*
|
|
98
|
+
* The PRIMARY field this schema is designed to gate is `effortLevel`: the
|
|
99
|
+
* persisted-config tier is `low|medium|high|xhigh` only; `"max"` is a
|
|
100
|
+
* session-scoped value (only accepted by `/effort max` and `--effort
|
|
101
|
+
* max`). The consumer DFP PR #105 incident: a CLAUDE.md example
|
|
102
|
+
* documenting `effortLevel: "max"` as a recommended persisted default
|
|
103
|
+
* sailed past the local quorum and was caught by the cloud cursor
|
|
104
|
+
* adapter as a high-severity schema finding.
|
|
105
|
+
*
|
|
106
|
+
* `additionalProperties: true` because the upstream Claude Code settings
|
|
107
|
+
* file accepts many more fields than we want to encode here. The schema
|
|
108
|
+
* is OPT-IN: doc authors mark a code block with `// schema:
|
|
109
|
+
* claude-code-settings` only when they're showing a settings example.
|
|
110
|
+
*/
|
|
111
|
+
const CLAUDE_CODE_SETTINGS_SCHEMA = {
|
|
112
|
+
$id: "claude-code-settings",
|
|
113
|
+
type: "object",
|
|
114
|
+
additionalProperties: true,
|
|
115
|
+
properties: {
|
|
116
|
+
model: { type: "string", minLength: 1 },
|
|
117
|
+
effortLevel: {
|
|
118
|
+
type: "string",
|
|
119
|
+
enum: ["low", "medium", "high", "xhigh"],
|
|
120
|
+
},
|
|
121
|
+
theme: { type: "string" },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Schema for `.agent-review/config.json` examples.
|
|
126
|
+
*
|
|
127
|
+
* Mirrors a SUBSET of `parseAgentReviewConfig` (the canonical runtime
|
|
128
|
+
* validator in `@momentiq/dark-factory-schemas`). The mirror is bounded
|
|
129
|
+
* to the high-churn fields where a doc-example regression is most likely
|
|
130
|
+
* to mislead a consumer:
|
|
131
|
+
* - top-level `version` enum (the parser rejects unknown versions)
|
|
132
|
+
* - `aggregation.policy` enum
|
|
133
|
+
* - `aggregation.blockingSeverities` enum-array
|
|
134
|
+
* - `aggregation.quorum` integer constraints (>= 2 when policy is
|
|
135
|
+
* `min-complete-quorum`)
|
|
136
|
+
* - `critics[].runtime` ("local" | "cloud")
|
|
137
|
+
* - `critics[].adapter` (known adapter id)
|
|
138
|
+
*
|
|
139
|
+
* The narrower fields (per-adapter params, profiles, validation routes)
|
|
140
|
+
* are NOT validated here — the runtime parser does that, and chasing every
|
|
141
|
+
* field at the doc-lint layer would make this schema a maintenance
|
|
142
|
+
* burden disproportionate to the catch rate. The doc-lint surface is the
|
|
143
|
+
* fields whose typo is most likely to ship and most expensive to debug.
|
|
144
|
+
*/
|
|
145
|
+
const DF_AGENT_REVIEW_CONFIG_SCHEMA = {
|
|
146
|
+
$id: "df-agent-review-config",
|
|
147
|
+
type: "object",
|
|
148
|
+
additionalProperties: true,
|
|
149
|
+
properties: {
|
|
150
|
+
version: { type: "integer", enum: [1, 2] },
|
|
151
|
+
aggregation: {
|
|
152
|
+
type: "object",
|
|
153
|
+
additionalProperties: true,
|
|
154
|
+
properties: {
|
|
155
|
+
policy: {
|
|
156
|
+
type: "string",
|
|
157
|
+
enum: ["block-if-any", "min-complete-quorum"],
|
|
158
|
+
},
|
|
159
|
+
blockingSeverities: {
|
|
160
|
+
type: "array",
|
|
161
|
+
items: {
|
|
162
|
+
type: "string",
|
|
163
|
+
enum: ["blocker", "high", "medium", "low", "note"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
quorum: { type: "integer", minimum: 2 },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
critics: {
|
|
170
|
+
type: "array",
|
|
171
|
+
items: {
|
|
172
|
+
type: "object",
|
|
173
|
+
additionalProperties: true,
|
|
174
|
+
properties: {
|
|
175
|
+
id: { type: "string", minLength: 1 },
|
|
176
|
+
name: { type: "string", minLength: 1 },
|
|
177
|
+
adapter: {
|
|
178
|
+
type: "string",
|
|
179
|
+
enum: [
|
|
180
|
+
"cursor-sdk",
|
|
181
|
+
"cursor-cli",
|
|
182
|
+
"codex-sdk",
|
|
183
|
+
"gemini-sdk",
|
|
184
|
+
"grok-direct-sdk",
|
|
185
|
+
"static-schema-lint",
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
required: { type: "boolean" },
|
|
189
|
+
runtime: { type: "string", enum: ["local", "cloud"] },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* The built-in schema registry. Maps a schema name (the value of the
|
|
197
|
+
* `// schema: <name>` annotation) to the JSON Schema definition the
|
|
198
|
+
* adapter validates against.
|
|
199
|
+
*/
|
|
200
|
+
export const STATIC_SCHEMAS = Object.freeze({
|
|
201
|
+
"claude-code-settings": CLAUDE_CODE_SETTINGS_SCHEMA,
|
|
202
|
+
"df-agent-review-config": DF_AGENT_REVIEW_CONFIG_SCHEMA,
|
|
203
|
+
});
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Code-block extraction.
|
|
206
|
+
//
|
|
207
|
+
// The extractor scans a markdown source for fenced code blocks annotated
|
|
208
|
+
// with a recognized schema marker. The marker forms accepted (per the
|
|
209
|
+
// spec's "opt-in via annotation"):
|
|
210
|
+
//
|
|
211
|
+
// ```jsonc
|
|
212
|
+
// // schema: claude-code-settings
|
|
213
|
+
// { "effortLevel": "max" }
|
|
214
|
+
// ```
|
|
215
|
+
//
|
|
216
|
+
// ```yaml
|
|
217
|
+
// # schema: github-actions-workflow
|
|
218
|
+
// on: push
|
|
219
|
+
// ```
|
|
220
|
+
//
|
|
221
|
+
// ```json
|
|
222
|
+
// /* schema: df-agent-review-config */
|
|
223
|
+
// { "version": 2, ... }
|
|
224
|
+
// ```
|
|
225
|
+
//
|
|
226
|
+
// Plus an HTML-comment form for fences that wrap JSON proper (no
|
|
227
|
+
// comment-prefix inside the JSON itself, because JSON forbids comments):
|
|
228
|
+
//
|
|
229
|
+
// <!-- schema: claude-code-settings -->
|
|
230
|
+
// ```json
|
|
231
|
+
// { "model": "opus", "effortLevel": "max" }
|
|
232
|
+
// ```
|
|
233
|
+
//
|
|
234
|
+
// The HTML-comment form is the only way to annotate a strict-JSON block;
|
|
235
|
+
// `// schema:` and `# schema:` produce invalid JSON if placed inside a
|
|
236
|
+
// `json` fence. The extractor handles both: an HTML comment IMMEDIATELY
|
|
237
|
+
// preceding a fence (separated by at most blank lines) attaches its
|
|
238
|
+
// schema to that fence.
|
|
239
|
+
const FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*?)\n```/g;
|
|
240
|
+
const HTML_SCHEMA_RE = /<!--\s*schema:\s*([A-Za-z0-9_\-./]+)\s*-->/g;
|
|
241
|
+
const INLINE_SCHEMA_RES = [
|
|
242
|
+
// // schema: name (JS/TS/JSONC comment style)
|
|
243
|
+
/(?:^|\n)\s*\/\/\s*schema:\s*([A-Za-z0-9_\-./]+)\s*(?:\n|$)/,
|
|
244
|
+
// # schema: name (YAML / shell comment style)
|
|
245
|
+
/(?:^|\n)\s*#\s*schema:\s*([A-Za-z0-9_\-./]+)\s*(?:\n|$)/,
|
|
246
|
+
// /* schema: name */ (block comment style)
|
|
247
|
+
/\/\*\s*schema:\s*([A-Za-z0-9_\-./]+)\s*\*\//,
|
|
248
|
+
];
|
|
249
|
+
/**
|
|
250
|
+
* Strip JSONC / JSON5-style comments AND trailing commas from a JSON-ish
|
|
251
|
+
* payload so `JSON.parse` succeeds on JSONC examples. Single-pass state
|
|
252
|
+
* machine — comment stripping AND trailing-comma removal both honor
|
|
253
|
+
* string-literal boundaries, so a comma-brace sequence inside a string
|
|
254
|
+
* value (`",}"`) is preserved verbatim. Designed for the limited surface
|
|
255
|
+
* of config-file examples in docs, not as a general JSON5 parser.
|
|
256
|
+
*
|
|
257
|
+
* Exported for unit-testability.
|
|
258
|
+
*/
|
|
259
|
+
export function stripJsoncSyntax(text) {
|
|
260
|
+
// Single pass: walk the text character-by-character and emit chars to
|
|
261
|
+
// the output buffer. State tracked: whether we're inside a string
|
|
262
|
+
// literal, and the index of the most recent comma emitted at the
|
|
263
|
+
// top-of-output (used to retroactively drop trailing commas when the
|
|
264
|
+
// next non-whitespace, non-comment, non-string token is `}` or `]`).
|
|
265
|
+
const out = [];
|
|
266
|
+
let i = 0;
|
|
267
|
+
let inString = null;
|
|
268
|
+
let escape = false;
|
|
269
|
+
// pendingCommaIndex: index into `out` of the last emitted ',' that has
|
|
270
|
+
// only seen whitespace / comments since. Reset to -1 once the comma is
|
|
271
|
+
// either kept (any other char emitted) or dropped (closer encountered).
|
|
272
|
+
let pendingCommaIndex = -1;
|
|
273
|
+
while (i < text.length) {
|
|
274
|
+
const c = text[i] ?? "";
|
|
275
|
+
if (inString !== null) {
|
|
276
|
+
out.push(c);
|
|
277
|
+
pendingCommaIndex = -1;
|
|
278
|
+
if (escape) {
|
|
279
|
+
escape = false;
|
|
280
|
+
}
|
|
281
|
+
else if (c === "\\") {
|
|
282
|
+
escape = true;
|
|
283
|
+
}
|
|
284
|
+
else if (c === inString) {
|
|
285
|
+
inString = null;
|
|
286
|
+
}
|
|
287
|
+
i++;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// Not inside a string.
|
|
291
|
+
if (c === '"' || c === "'") {
|
|
292
|
+
inString = c;
|
|
293
|
+
out.push(c);
|
|
294
|
+
pendingCommaIndex = -1;
|
|
295
|
+
i++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
299
|
+
// Line comment — skip to end-of-line (the newline is consumed
|
|
300
|
+
// outside this branch on the next loop iteration). pendingCommaIndex
|
|
301
|
+
// is preserved across the comment.
|
|
302
|
+
while (i < text.length && text[i] !== "\n")
|
|
303
|
+
i++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
307
|
+
// Block comment — skip to closing */ (pendingCommaIndex preserved).
|
|
308
|
+
i += 2;
|
|
309
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
310
|
+
i++;
|
|
311
|
+
i += 2;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (c === ",") {
|
|
315
|
+
out.push(c);
|
|
316
|
+
pendingCommaIndex = out.length - 1;
|
|
317
|
+
i++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (c === "}" || c === "]") {
|
|
321
|
+
// Trailing-comma case: if the most-recent non-whitespace token
|
|
322
|
+
// emitted was ',', drop it. Splicing the array preserves the
|
|
323
|
+
// single-pass invariant without an extra string-rewrite step.
|
|
324
|
+
if (pendingCommaIndex !== -1) {
|
|
325
|
+
out.splice(pendingCommaIndex, 1);
|
|
326
|
+
}
|
|
327
|
+
out.push(c);
|
|
328
|
+
pendingCommaIndex = -1;
|
|
329
|
+
i++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
333
|
+
// Whitespace does NOT close the trailing-comma window.
|
|
334
|
+
out.push(c);
|
|
335
|
+
i++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// Any other character commits the pending comma (it's a real
|
|
339
|
+
// separator, not trailing).
|
|
340
|
+
out.push(c);
|
|
341
|
+
pendingCommaIndex = -1;
|
|
342
|
+
i++;
|
|
343
|
+
}
|
|
344
|
+
return out.join("");
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Extract schema-annotated code blocks from a markdown source. Returns
|
|
348
|
+
* an empty array when the source contains no annotated blocks.
|
|
349
|
+
*
|
|
350
|
+
* The schema annotation may be embedded INSIDE the code block — three
|
|
351
|
+
* forms: JSONC line comment, YAML / shell line comment, or block
|
|
352
|
+
* comment — or as an HTML comment IMMEDIATELY before the fence (the
|
|
353
|
+
* only form compatible with strict JSON blocks, since JSON forbids
|
|
354
|
+
* inline comments).
|
|
355
|
+
*/
|
|
356
|
+
export function extractSchemaBlocks(source) {
|
|
357
|
+
const blocks = [];
|
|
358
|
+
// First pass: collect every HTML-comment schema annotation with its
|
|
359
|
+
// position so we can attach it to the next fence that follows.
|
|
360
|
+
const htmlAnnotations = [];
|
|
361
|
+
HTML_SCHEMA_RE.lastIndex = 0;
|
|
362
|
+
let m;
|
|
363
|
+
while ((m = HTML_SCHEMA_RE.exec(source)) !== null) {
|
|
364
|
+
htmlAnnotations.push({ name: m[1] ?? "", index: m.index });
|
|
365
|
+
}
|
|
366
|
+
// Second pass: walk fences. For each, look for an INSIDE annotation
|
|
367
|
+
// first; if none, search backwards for the nearest HTML annotation
|
|
368
|
+
// that precedes the fence with only whitespace / blank lines between.
|
|
369
|
+
FENCE_RE.lastIndex = 0;
|
|
370
|
+
while ((m = FENCE_RE.exec(source)) !== null) {
|
|
371
|
+
const leading = m[1] ?? "";
|
|
372
|
+
const language = (m[2] ?? "").trim().split(/\s+/)[0] ?? "";
|
|
373
|
+
const body = m[3] ?? "";
|
|
374
|
+
const fenceIndex = m.index + leading.length;
|
|
375
|
+
// INSIDE annotation
|
|
376
|
+
let schemaName;
|
|
377
|
+
let strippedBody = body;
|
|
378
|
+
for (const re of INLINE_SCHEMA_RES) {
|
|
379
|
+
const inner = re.exec(body);
|
|
380
|
+
if (inner) {
|
|
381
|
+
schemaName = inner[1];
|
|
382
|
+
// Remove the matched line(s) from the body so JSON.parse won't trip.
|
|
383
|
+
strippedBody = body.replace(re, "\n").replace(/^\s*\n/, "");
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!schemaName) {
|
|
388
|
+
// HTML-comment annotation IMMEDIATELY before the fence — find the
|
|
389
|
+
// nearest one whose index is < fenceIndex and whose intervening
|
|
390
|
+
// text is only whitespace.
|
|
391
|
+
for (let h = htmlAnnotations.length - 1; h >= 0; h--) {
|
|
392
|
+
const ann = htmlAnnotations[h];
|
|
393
|
+
if (!ann || ann.index >= fenceIndex)
|
|
394
|
+
continue;
|
|
395
|
+
const between = source.slice(ann.index, fenceIndex);
|
|
396
|
+
// Allow the closing `-->`, whitespace, and at most one blank line.
|
|
397
|
+
const trimmed = between.replace(/<!--\s*schema:\s*[A-Za-z0-9_\-./]+\s*-->/, "");
|
|
398
|
+
if (/^[\s\n]*$/.test(trimmed)) {
|
|
399
|
+
schemaName = ann.name;
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!schemaName)
|
|
405
|
+
continue;
|
|
406
|
+
// Compute 1-based start line of the fence body for diagnostics.
|
|
407
|
+
const startLine = source.slice(0, fenceIndex).split("\n").length + 1;
|
|
408
|
+
blocks.push({
|
|
409
|
+
schemaName,
|
|
410
|
+
body: strippedBody,
|
|
411
|
+
language,
|
|
412
|
+
startLine,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return blocks;
|
|
416
|
+
}
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Parser dispatch.
|
|
419
|
+
//
|
|
420
|
+
// `parseBlockBody` selects between JSON/JSONC and YAML parsing based on
|
|
421
|
+
// the fence language tag captured during extraction. The YAML branch
|
|
422
|
+
// runs `yaml.parse` directly (no comment-stripping pass — YAML's syntax
|
|
423
|
+
// already permits `#` line comments, which the parser handles natively).
|
|
424
|
+
// JSON / JSONC / strict-JSON fences go through `stripJsoncSyntax` so
|
|
425
|
+
// `// ...` / `/* ... */` comments and trailing commas don't trip
|
|
426
|
+
// `JSON.parse` on doc examples.
|
|
427
|
+
//
|
|
428
|
+
// Language tags recognized:
|
|
429
|
+
// - "yaml", "yml" → YAML parser
|
|
430
|
+
// - "" (no tag), "json", "jsonc", "json5" → JSON-with-JSONC-strip path
|
|
431
|
+
//
|
|
432
|
+
// An unknown / non-empty language tag falls back to the JSON path. The
|
|
433
|
+
// extractor enforces opt-in via the schema annotation, so this only
|
|
434
|
+
// runs against blocks the author explicitly opted in to.
|
|
435
|
+
const YAML_LANGUAGES = new Set(["yaml", "yml"]);
|
|
436
|
+
function parseBlockBody(block) {
|
|
437
|
+
const lang = block.language.toLowerCase();
|
|
438
|
+
if (YAML_LANGUAGES.has(lang)) {
|
|
439
|
+
return parseYaml(block.body);
|
|
440
|
+
}
|
|
441
|
+
const normalized = stripJsoncSyntax(block.body);
|
|
442
|
+
return JSON.parse(normalized);
|
|
443
|
+
}
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Diff fallback — reconstruct added markdown content from packet.diff.
|
|
446
|
+
//
|
|
447
|
+
// When `context.includeFullChangedFiles: false`, `git.ts:changedFiles`
|
|
448
|
+
// skips the `git show <sha>:<path>` content read and emits each entry
|
|
449
|
+
// without a `content` field. The adapter would otherwise scan zero
|
|
450
|
+
// files and silently APPROVE every markdown-only PR. The fallback
|
|
451
|
+
// extracts the `+` lines from each file's hunks in the unified diff,
|
|
452
|
+
// producing a best-effort reconstruction of the ADDED content the
|
|
453
|
+
// adapter can scan.
|
|
454
|
+
//
|
|
455
|
+
// Caveats (documented in CONSUMER-ADOPTION §4.1):
|
|
456
|
+
// - Only hunks that ADD at least one `+` line contribute — a pure
|
|
457
|
+
// deletion hunk (the consumer fixed the violation) is correctly NOT
|
|
458
|
+
// reported as a new finding.
|
|
459
|
+
// - Within a contributing hunk, BOTH `+` (added) and ` ` (context) lines
|
|
460
|
+
// are preserved so the surrounding fenced-block boundary and the
|
|
461
|
+
// `<!-- schema: ... -->` / `// schema: ...` / `# schema: ...`
|
|
462
|
+
// annotation survive the reconstruction. Without context preservation,
|
|
463
|
+
// a PR that modifies only the payload line inside an existing
|
|
464
|
+
// annotated fenced block produces a body without a fence or schema
|
|
465
|
+
// marker, `extractSchemaBlocks` returns nothing, and the gate
|
|
466
|
+
// silently APPROVES the violation (codex critic #116 — fail-open
|
|
467
|
+
// contract breach for the deterministic backstop).
|
|
468
|
+
// - Deletion (`-`) lines are still dropped — they are NOT current
|
|
469
|
+
// repo state.
|
|
470
|
+
// - Hunks across the same file accumulate; each contributing hunk
|
|
471
|
+
// is appended with a blank line separator so distinct hunks do not
|
|
472
|
+
// fuse into one virtual fenced block.
|
|
473
|
+
// - If the diff itself is truncated (`packet.diffTruncated: true`),
|
|
474
|
+
// fallback coverage is partial. The adapter emits a BLOCKING-
|
|
475
|
+
// severity finding when diffFallbacks were used on any scanned file
|
|
476
|
+
// and the diff was truncated (cursor critic #116) — see review()
|
|
477
|
+
// below.
|
|
478
|
+
export function reconstructAddedContentFromDiff(diff) {
|
|
479
|
+
const out = new Map();
|
|
480
|
+
if (!diff)
|
|
481
|
+
return out;
|
|
482
|
+
const lines = diff.split("\n");
|
|
483
|
+
let currentPath = null;
|
|
484
|
+
// Per-hunk buffers: lines accumulate into the CURRENT hunk's buffer;
|
|
485
|
+
// when the hunk ends (next `@@` header, file boundary, or end of diff)
|
|
486
|
+
// it flushes into the file's content map IFF it contained at least one
|
|
487
|
+
// `+` (added) line. Pure context-only hunks (impossible in normal git
|
|
488
|
+
// diff output but defended against here) and pure deletion-only hunks
|
|
489
|
+
// contribute nothing — matching the cycle's "regressions introduced by
|
|
490
|
+
// this PR" charter.
|
|
491
|
+
let hunkBuffer = [];
|
|
492
|
+
let hunkHasAdd = false;
|
|
493
|
+
const flushHunk = () => {
|
|
494
|
+
if (currentPath !== null && hunkHasAdd && hunkBuffer.length > 0) {
|
|
495
|
+
const prior = out.get(currentPath);
|
|
496
|
+
const joined = hunkBuffer.join("\n");
|
|
497
|
+
// Separate distinct hunks with a blank line so an unclosed fence in
|
|
498
|
+
// one hunk does not fuse into a fenced block from a later hunk.
|
|
499
|
+
out.set(currentPath, prior ? `${prior}\n\n${joined}` : joined);
|
|
500
|
+
}
|
|
501
|
+
hunkBuffer = [];
|
|
502
|
+
hunkHasAdd = false;
|
|
503
|
+
};
|
|
504
|
+
for (const line of lines) {
|
|
505
|
+
// `+++ b/<path>` — header for the destination file in a hunk header.
|
|
506
|
+
// The leading `b/` is the standard `git diff` prefix; strip it if
|
|
507
|
+
// present, fall back to the raw path otherwise.
|
|
508
|
+
if (line.startsWith("+++ ")) {
|
|
509
|
+
flushHunk();
|
|
510
|
+
const rest = line.slice(4).trim();
|
|
511
|
+
if (rest === "/dev/null") {
|
|
512
|
+
currentPath = null;
|
|
513
|
+
}
|
|
514
|
+
else if (rest.startsWith("b/")) {
|
|
515
|
+
currentPath = rest.slice(2);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
currentPath = rest;
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (line.startsWith("--- ") || line.startsWith("diff --git ")) {
|
|
523
|
+
// File-level headers — DO NOT contribute content, DO NOT switch
|
|
524
|
+
// path (the `+++` line is the authoritative path source).
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (line.startsWith("@@")) {
|
|
528
|
+
// Hunk boundary. Flush whatever the previous hunk gathered.
|
|
529
|
+
flushHunk();
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (currentPath === null)
|
|
533
|
+
continue;
|
|
534
|
+
if (line.startsWith("+")) {
|
|
535
|
+
hunkBuffer.push(line.slice(1));
|
|
536
|
+
hunkHasAdd = true;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (line.startsWith("-")) {
|
|
540
|
+
// Deletion — NOT current state. Drop.
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (line.startsWith(" ")) {
|
|
544
|
+
// Context line. Preserve it (strip the single leading space) so
|
|
545
|
+
// surrounding fence + schema annotations survive when the PR
|
|
546
|
+
// edits only the payload line inside an existing annotated block.
|
|
547
|
+
hunkBuffer.push(line.slice(1));
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (line === "\") {
|
|
551
|
+
// Standard git diff marker. Skip.
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
// Anything else (blank line between hunks, etc.) — drop.
|
|
555
|
+
}
|
|
556
|
+
flushHunk();
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
function buildRegistry(schemas) {
|
|
560
|
+
const ajv = new AjvCtor({ allErrors: true, strict: false });
|
|
561
|
+
addFormats(ajv);
|
|
562
|
+
const compiled = new Map();
|
|
563
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
564
|
+
compiled.set(name, ajv.compile(schema));
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
get: (name) => compiled.get(name),
|
|
568
|
+
names: () => [...compiled.keys()],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Finding construction.
|
|
573
|
+
//
|
|
574
|
+
// Each Ajv `ErrorObject` produces ONE `ReviewFinding`. The mapping
|
|
575
|
+
// preserves the schema path (`instancePath`) and the constraint message so
|
|
576
|
+
// the doc author can locate and correct the violation without re-running
|
|
577
|
+
// Ajv themselves. `severity: high` matches the spec's recommendation:
|
|
578
|
+
// schema-shape regressions in code examples are a defect the gate should
|
|
579
|
+
// block on, not a stylistic note.
|
|
580
|
+
function findingsForAjvErrors(args) {
|
|
581
|
+
const { errors, filePath, schemaName, startLine, blockBody } = args;
|
|
582
|
+
return errors.map((err) => {
|
|
583
|
+
const path = err.instancePath || "(root)";
|
|
584
|
+
const params = JSON.stringify(err.params);
|
|
585
|
+
const evidence = `${schemaName}${path}: ${err.message ?? "schema violation"} (constraint params=${params})`;
|
|
586
|
+
// Best-effort line offset within the block. ajv doesn't track source
|
|
587
|
+
// lines (it operates on parsed JS values), so we surface the fence
|
|
588
|
+
// start line and let the author scan the local context.
|
|
589
|
+
return {
|
|
590
|
+
severity: "high",
|
|
591
|
+
category: "schema",
|
|
592
|
+
file: filePath,
|
|
593
|
+
line: startLine,
|
|
594
|
+
evidence,
|
|
595
|
+
impact: `Code example in ${filePath} violates JSON Schema \`${schemaName}\`. A consumer copy-pasting this example would produce a silently-invalid config; the violation is identical in pattern to consumer dark-factory-platform#107 (the \`effortLevel: "max"\` regression that the local LLM quorum missed).`,
|
|
596
|
+
requiredFix: `Correct the example so it satisfies the schema, or remove the schema annotation if the example is intentionally illustrating an invalid shape. Block body excerpt:\n${blockBody.slice(0, 200)}`,
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const DEFAULT_SCANNED_FILE_PATTERNS = [
|
|
601
|
+
/\.md$/i,
|
|
602
|
+
/\.mdx$/i,
|
|
603
|
+
/CLAUDE\.md$/i,
|
|
604
|
+
/AGENTS\.md$/i,
|
|
605
|
+
/GEMINI\.md$/i,
|
|
606
|
+
];
|
|
607
|
+
export class StaticSchemaLintAdapter {
|
|
608
|
+
id = STATIC_SCHEMA_LINT_ADAPTER_ID;
|
|
609
|
+
requiredEnvVars = [];
|
|
610
|
+
registry;
|
|
611
|
+
scannedFilePatterns;
|
|
612
|
+
constructor(options = {}) {
|
|
613
|
+
const mergedSchemas = { ...STATIC_SCHEMAS, ...(options.schemas ?? {}) };
|
|
614
|
+
this.registry = buildRegistry(mergedSchemas);
|
|
615
|
+
this.scannedFilePatterns = options.scannedFilePatterns ?? DEFAULT_SCANNED_FILE_PATTERNS;
|
|
616
|
+
}
|
|
617
|
+
async review(packet, critic, options) {
|
|
618
|
+
const findings = [];
|
|
619
|
+
const startMs = Date.now();
|
|
620
|
+
options.emit?.({
|
|
621
|
+
ts: new Date().toISOString(),
|
|
622
|
+
event: "critic_run_started",
|
|
623
|
+
commit: packet.commit.sha,
|
|
624
|
+
criticId: critic.id,
|
|
625
|
+
adapter: this.id,
|
|
626
|
+
model: critic.model.id,
|
|
627
|
+
});
|
|
628
|
+
let scannedFiles = 0;
|
|
629
|
+
let extractedBlocks = 0;
|
|
630
|
+
let parseFailures = 0;
|
|
631
|
+
// Track which scanned files relied on the diff-fallback path so we
|
|
632
|
+
// can emit a blocking finding when `packet.diffTruncated === true`
|
|
633
|
+
// (cursor critic #116 — on large PRs with includeFullChangedFiles:
|
|
634
|
+
// false, the truncation cap in rebind.ts can cut a violating hunk
|
|
635
|
+
// before it reaches the adapter, and the gate would otherwise
|
|
636
|
+
// silently APPROVE).
|
|
637
|
+
const diffFallbackFiles = [];
|
|
638
|
+
// Fallback-source map: when a changed file body is not loaded into
|
|
639
|
+
// `changedFiles[].content` (the consumer set
|
|
640
|
+
// `context.includeFullChangedFiles: false`, so git.ts skips the
|
|
641
|
+
// `git show <sha>:<path>` read), we reconstruct the added markdown
|
|
642
|
+
// content from `packet.diff` so the adapter is not silently starved.
|
|
643
|
+
// This keeps the deterministic backstop on for the DFP #107 fixture
|
|
644
|
+
// without requiring every consumer to flip a context flag.
|
|
645
|
+
const diffFallbacks = reconstructAddedContentFromDiff(packet.diff);
|
|
646
|
+
for (const file of packet.changedFiles) {
|
|
647
|
+
if (!this.scannedFilePatterns.some((re) => re.test(file.path)))
|
|
648
|
+
continue;
|
|
649
|
+
if (file.omittedReason)
|
|
650
|
+
continue;
|
|
651
|
+
const direct = file.content ?? file.compactedContent;
|
|
652
|
+
const fallback = direct === undefined ? diffFallbacks.get(file.path) : undefined;
|
|
653
|
+
const content = direct ?? fallback;
|
|
654
|
+
if (!content)
|
|
655
|
+
continue;
|
|
656
|
+
if (fallback !== undefined) {
|
|
657
|
+
diffFallbackFiles.push(file.path);
|
|
658
|
+
}
|
|
659
|
+
scannedFiles++;
|
|
660
|
+
const blocks = extractSchemaBlocks(content);
|
|
661
|
+
for (const block of blocks) {
|
|
662
|
+
extractedBlocks++;
|
|
663
|
+
const validator = this.registry.get(block.schemaName);
|
|
664
|
+
if (!validator) {
|
|
665
|
+
// Unknown schema — `high` severity by default (blocks merge).
|
|
666
|
+
// A typo in the annotation (e.g. `claude-code-setting` instead
|
|
667
|
+
// of `claude-code-settings`) would otherwise silently disable
|
|
668
|
+
// validation for the exact class of doc examples this adapter
|
|
669
|
+
// is meant to gate. Treat unknown schemas as a configuration
|
|
670
|
+
// error the author must resolve before merge.
|
|
671
|
+
findings.push({
|
|
672
|
+
severity: "high",
|
|
673
|
+
category: "schema",
|
|
674
|
+
file: file.path,
|
|
675
|
+
line: block.startLine,
|
|
676
|
+
evidence: `Unknown schema name "${block.schemaName}" in fenced code-block annotation (registered: ${this.registry.names().join(", ") || "(none)"}).`,
|
|
677
|
+
impact: "The schema annotation is present but the named schema is not in the adapter registry, so this block is NOT being validated. A one-character typo here disables the deterministic backstop for that block; the gate must surface it rather than fail open.",
|
|
678
|
+
requiredFix: `Use one of the registered schema names (${this.registry.names().join(", ") || "(none)"}), OR register a new schema by extending \`STATIC_SCHEMAS\` in \`packages/cli/src/adapters/static-schema-lint.ts\` (or via the \`schemas\` constructor option for repo-local adapters), OR remove the schema annotation if the block is intentionally not subject to schema lint.`,
|
|
679
|
+
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
// Parse the block body. Dispatch on the fence language: YAML
|
|
683
|
+
// fences (annotated with `# schema:`) parse via the `yaml`
|
|
684
|
+
// package; JSON / JSONC / strict-JSON fences parse via the
|
|
685
|
+
// JSONC-stripping JSON.parse path.
|
|
686
|
+
let parsed;
|
|
687
|
+
try {
|
|
688
|
+
parsed = parseBlockBody(block);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
parseFailures++;
|
|
692
|
+
findings.push({
|
|
693
|
+
severity: "high",
|
|
694
|
+
category: "schema",
|
|
695
|
+
file: file.path,
|
|
696
|
+
line: block.startLine,
|
|
697
|
+
evidence: `Schema-annotated block (\`${block.schemaName}\`, language=\`${block.language || "(none)"}\`) is not parseable: ${err.message}`,
|
|
698
|
+
impact: "Doc examples that claim a schema MUST be valid in their declared format (JSON/JSONC/YAML); an unparseable block produces a silent regression for any consumer copy-pasting the example.",
|
|
699
|
+
requiredFix: "Fix the syntax error in the code block, or remove the schema annotation if the block is intentionally illustrating malformed input.",
|
|
700
|
+
});
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const ok = validator(parsed);
|
|
704
|
+
if (!ok) {
|
|
705
|
+
findings.push(...findingsForAjvErrors({
|
|
706
|
+
errors: validator.errors ?? [],
|
|
707
|
+
filePath: file.path,
|
|
708
|
+
schemaName: block.schemaName,
|
|
709
|
+
startLine: block.startLine,
|
|
710
|
+
blockBody: block.body,
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// cursor critic #116 — diff-truncation fail-open guard. If ANY scanned
|
|
716
|
+
// file used the diff-fallback path AND packet.diffTruncated is true,
|
|
717
|
+
// emit a blocking finding: the truncation cap in rebind.ts can cut a
|
|
718
|
+
// violating hunk before it reaches this adapter, so the deterministic
|
|
719
|
+
// backstop's silence is NOT meaningful evidence of correctness for
|
|
720
|
+
// this PR. The consumer must either flip `context.includeFullChangedFiles:
|
|
721
|
+
// true` (so this adapter reads file bodies directly, bypassing the
|
|
722
|
+
// truncated diff) or shrink the changeset.
|
|
723
|
+
if (packet.diffTruncated && diffFallbackFiles.length > 0) {
|
|
724
|
+
findings.push({
|
|
725
|
+
severity: "high",
|
|
726
|
+
category: "schema",
|
|
727
|
+
file: diffFallbackFiles[0],
|
|
728
|
+
line: 1,
|
|
729
|
+
evidence: `packet.diffTruncated=true AND ${diffFallbackFiles.length} scanned file(s) relied on diff-fallback content reconstruction (no file.content / file.compactedContent): ${diffFallbackFiles.join(", ")}. The unified diff was capped at the rebind.ts DEFAULT_DIFF_BUDGET (1_500_000 bytes); annotations in the truncated tail are NOT visible to the adapter.`,
|
|
730
|
+
impact: "Under `context.includeFullChangedFiles: false`, the deterministic schema-lint backstop can silently APPROVE schema-invalid doc examples (the same fail-open class as consumer DFP #107) when the violating hunk falls outside the truncated diff budget. The adapter cannot prove the absence of violations in the truncated tail, so silence here is NOT evidence of correctness.",
|
|
731
|
+
requiredFix: "Set `context.includeFullChangedFiles: true` in the consumer `.agent-review/config.json` (the adapter then reads file bodies directly via `git show <sha>:<path>` and bypasses the truncated diff), OR shrink the PR so the unified diff fits inside `DEFAULT_DIFF_BUDGET` (1.5MB). See docs/CONSUMER-ADOPTION.md §4.1 for the full mitigation matrix.",
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
const verdict = findings.some((f) => options.blockingSeverities.includes(f.severity))
|
|
735
|
+
? "CHANGES_REQUESTED"
|
|
736
|
+
: "APPROVED";
|
|
737
|
+
const durationMs = Date.now() - startMs;
|
|
738
|
+
options.emit?.({
|
|
739
|
+
ts: new Date().toISOString(),
|
|
740
|
+
event: "critic_run_finished",
|
|
741
|
+
commit: packet.commit.sha,
|
|
742
|
+
criticId: critic.id,
|
|
743
|
+
adapter: this.id,
|
|
744
|
+
model: critic.model.id,
|
|
745
|
+
durationMs,
|
|
746
|
+
verdict,
|
|
747
|
+
});
|
|
748
|
+
return {
|
|
749
|
+
criticId: critic.id,
|
|
750
|
+
status: "complete",
|
|
751
|
+
verdict,
|
|
752
|
+
requiresHumanJudgment: false,
|
|
753
|
+
reviewer: {
|
|
754
|
+
name: critic.name,
|
|
755
|
+
adapter: critic.adapter,
|
|
756
|
+
model: critic.model,
|
|
757
|
+
runtime: critic.runtime,
|
|
758
|
+
},
|
|
759
|
+
summary: verdict === "APPROVED"
|
|
760
|
+
? `static-schema-lint: ${scannedFiles} file(s) scanned, ${extractedBlocks} schema-annotated block(s), 0 violations.`
|
|
761
|
+
: `static-schema-lint: ${scannedFiles} file(s) scanned, ${extractedBlocks} schema-annotated block(s), ${findings.length} finding(s)${parseFailures > 0 ? ` (${parseFailures} parse failure(s))` : ""}.`,
|
|
762
|
+
findings,
|
|
763
|
+
validation: { qualityGateResults: [], qualityGatesMissing: [] },
|
|
764
|
+
confidence: "high",
|
|
765
|
+
durationMs,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
async doctor(_critic) {
|
|
769
|
+
// The adapter is dependency-light: ajv is bundled at build time, no
|
|
770
|
+
// env vars, no subprocess, no network. The doctor probes exist so
|
|
771
|
+
// operators can confirm the registry compiled correctly on this
|
|
772
|
+
// host (catches a malformed schema literal at adapter-load time).
|
|
773
|
+
const checks = [];
|
|
774
|
+
const names = this.registry.names();
|
|
775
|
+
checks.push({
|
|
776
|
+
name: "static_schema_lint_registry",
|
|
777
|
+
passed: names.length > 0,
|
|
778
|
+
detail: `${names.length} schema(s) registered: ${names.join(", ") || "(none)"}`,
|
|
779
|
+
...(names.length > 0
|
|
780
|
+
? {}
|
|
781
|
+
: { remediation: "Extend STATIC_SCHEMAS in packages/cli/src/adapters/static-schema-lint.ts" }),
|
|
782
|
+
});
|
|
783
|
+
// Smoke-validate a known-good payload against the canonical schema
|
|
784
|
+
// so a packaging error (e.g. ajv tree-shaken away) surfaces in
|
|
785
|
+
// doctor instead of at review time.
|
|
786
|
+
try {
|
|
787
|
+
const validator = this.registry.get("claude-code-settings");
|
|
788
|
+
const smokeOk = validator
|
|
789
|
+
? Boolean(validator({ model: "opus", effortLevel: "high" }))
|
|
790
|
+
: false;
|
|
791
|
+
checks.push({
|
|
792
|
+
name: "static_schema_lint_smoke",
|
|
793
|
+
passed: smokeOk,
|
|
794
|
+
detail: smokeOk
|
|
795
|
+
? "ajv compiled the claude-code-settings schema and validated a known-good payload."
|
|
796
|
+
: `ajv smoke test failed (validator present: ${Boolean(validator)})`,
|
|
797
|
+
...(smokeOk
|
|
798
|
+
? {}
|
|
799
|
+
: { remediation: "Re-install ajv: `npm ci --workspace=@momentiq/dark-factory-cli`" }),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
checks.push({
|
|
804
|
+
name: "static_schema_lint_smoke",
|
|
805
|
+
passed: false,
|
|
806
|
+
detail: `ajv threw during smoke validation: ${err.message}`,
|
|
807
|
+
remediation: "Re-install ajv: `npm ci --workspace=@momentiq/dark-factory-cli`",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return checks;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
//# sourceMappingURL=static-schema-lint.js.map
|