Part 11 — Diagnostics micro-DSL: validators from constraints, severity, code actions
Diagnostics are the editor's flag-and-fix surface. A validator inspects an AST node, decides if there is a problem, and reports it as a Diagnostic with severity, range, message, and optional code actions. The Diagnostics micro-DSL declares validators per Concept and, where applicable, auto-derives them from @Property({ constraint }) declarations on the kernel — the article 04 mass-production property at full strength: one declarative fact in the kernel produces a validator for free.
The micro-DSL closes the loop on what can be a problem: anything the kernel constrains (regex, enum, range), anything the kernel requires (cardinality), anything a custom predicate names (cross-aggregate uniqueness, dependency cycles, missing satisfactions). The first two come for free; the third requires explicit declaration. Each diagnostic optionally carries one or more code actions — repair lambdas the user can apply with one keystroke — which mutate the AST through the PatchBus and so participate in undo/redo coherently.
Concern
The author writes readonly id = 'FEAT-156' — an id that does not match the declared ^FEATURE-\d+$ constraint. The editor should immediately surface a red squiggle, the Problems panel should list the violation, and the editor should offer a quick-fix that proposes FEATURE-156. The author writes a Feature with no acceptance children — a violation of the 1..* cardinality. Same expectation: red squiggle, Problems panel entry, optional quick-fix to insert a placeholder AC.
Producing this from scratch requires writing an LSP diagnostics handler, walking the AST, comparing each @Property value against its constraint, building per-violation Diagnostic objects, registering code actions, wiring the textDocument/codeAction handler to surface them. The Diagnostics micro-DSL collapses this into auto-derivation plus optional @Diagnostic decorators.
The Surface
import { Diagnostic, CodeAction } from '@frenchexdev/ide-dsl-diagnostics';
import { Concept, Property, ChildLink, PatchBus } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
id!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
acceptance!: AcceptanceCriterion[];
// Auto-derived: one diagnostic per @Property({ constraint }), one per cardinality minimum.
// No declaration needed for the regex check or the 1..* check.
// Custom diagnostic with code action.
@Diagnostic({ severity: 'warning', code: 'FEAT-NOT-SATISFIED' })
static checkSatisfaction(self: Feature, ctx) {
const refs = ctx.workspace.findReferences(self.id, '@Satisfies');
if (refs.length === 0) {
return {
message: `Feature ${self.id} is declared but not satisfied by any test.`,
range: ctx.rangeOf(self, 'id'),
actions: [
CodeAction.create('Add @FeatureTest', async (patch: PatchBus) => {
await patch.insertChild(self.parentDocument.id, 'tests', {
concept: 'cmf.req.FeatureTest',
properties: { satisfies: self.id },
});
}),
],
};
}
return null;
}
}import { Diagnostic, CodeAction } from '@frenchexdev/ide-dsl-diagnostics';
import { Concept, Property, ChildLink, PatchBus } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
id!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
acceptance!: AcceptanceCriterion[];
// Auto-derived: one diagnostic per @Property({ constraint }), one per cardinality minimum.
// No declaration needed for the regex check or the 1..* check.
// Custom diagnostic with code action.
@Diagnostic({ severity: 'warning', code: 'FEAT-NOT-SATISFIED' })
static checkSatisfaction(self: Feature, ctx) {
const refs = ctx.workspace.findReferences(self.id, '@Satisfies');
if (refs.length === 0) {
return {
message: `Feature ${self.id} is declared but not satisfied by any test.`,
range: ctx.rangeOf(self, 'id'),
actions: [
CodeAction.create('Add @FeatureTest', async (patch: PatchBus) => {
await patch.insertChild(self.parentDocument.id, 'tests', {
concept: 'cmf.req.FeatureTest',
properties: { satisfies: self.id },
});
}),
],
};
}
return null;
}
}Two surfaces. The implicit one — @Property({ constraint }) — produces a validator with default severity error and a default message templated from the constraint. The explicit one — @Diagnostic — declares a method that runs against each Concept instance and returns a diagnostic descriptor (or null). Code actions attached to the diagnostic are lambda over the PatchBus from article 05; the kernel's mutation contract carries the repair through the standard channel.
The code field is a stable identifier that other tooling (the Diagnostics micro-DSL's own settings UI, third-party rule disablers, the compliance reporter) can use to reference the rule. Codes follow the convention <DSL-PREFIX>-<NN> and are documented in the suite's diagnostic reference.
Kernel boundary
Reads:
- The Structure Model — to enumerate
@Property({ constraint })declarations for auto-derivation. - The current document AST — for per-document validation passes.
- The workspace AST — for cross-aggregate validation (
checkSatisfactionwalks references across files).
Writes:
- Through PatchBus, only in code-action callbacks. The validator itself is read-only; the repair, when applied, mutates through the standard channel.
The two-mode boundary is important. Validators run on every change (often), and must be cheap and side-effect-free. Code actions run only on user invocation (rare), and may be expensive and side-effecting; they go through PatchBus so the EditLog records them and undo/redo works.
Emitted artefacts
// LSP server registration (generated, in server/handlers/diagnostics.generated.ts)
import { Diagnostic } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { diagnosticRegistry } from './_registry.generated';
export async function publishDiagnostics(uri: string): Promise<Diagnostic[]> {
const doc = kernel.getDocument(uri);
if (!doc) return [];
const violations = await diagnosticRegistry.validate(doc);
return violations.map(toLspDiagnostic);
}
// LSP code action handler (generated)
export async function handleCodeAction(params): Promise<CodeAction[]> {
// ... routes to diagnosticRegistry.actionsFor(diagnostic) ...
}// LSP server registration (generated, in server/handlers/diagnostics.generated.ts)
import { Diagnostic } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { diagnosticRegistry } from './_registry.generated';
export async function publishDiagnostics(uri: string): Promise<Diagnostic[]> {
const doc = kernel.getDocument(uri);
if (!doc) return [];
const violations = await diagnosticRegistry.validate(doc);
return violations.map(toLspDiagnostic);
}
// LSP code action handler (generated)
export async function handleCodeAction(params): Promise<CodeAction[]> {
// ... routes to diagnosticRegistry.actionsFor(diagnostic) ...
}Plus a contribution declaration so the LSP host registers the dynamic registration of textDocument/publishDiagnostics and textDocument/codeAction.
Composition with peers
- Refactoring (article 17) — refactorings are larger structural transforms; code actions are local repairs. Both mutate through PatchBus; the boundary is scope (one node vs. several files).
- Hover (article 10) — orthogonal at the same position; both attach metadata to a token but in different UI surfaces.
- CodeLens (article 12) — both can offer actions on a node; CodeLens is always-visible above the node, code actions appear on demand (lightbulb).
- LSP host (article 20) — routes
textDocument/publishDiagnosticsandtextDocument/codeAction.
MPS aspect referent
MPS Constraints aspect. MPS attaches constraints to Concepts and runs them against AST nodes; violations surface in MPS' equivalent of the Problems panel; MPS intentions (the equivalent of code actions) repair them. We adopt the per-Concept declarative shape for explicit diagnostics; we add the auto-derivation from kernel @Property({ constraint }) (which MPS does not have because its M2 does not carry constraints in the same shape). The integration with PatchBus for repair is the TypeScript-shaped translation of MPS' intention-applies-via-the-typed-mutation-API mechanism.
Boundary justification
Why not in the kernel? The validators consume kernel constraints and produce LSP-shaped output. The output side is VSCode-coupled (Diagnostic, CodeAction, severity enums, range types) — putting it in the kernel would violate criterion 3 from article 02. The validation logic itself could in principle live in the kernel, but separating what is constrained (kernel) from how violations are surfaced (Diagnostics micro-DSL) keeps the kernel small and lets a different host (a CLI lint tool, a CI check) consume the same constraints with its own surfacing.
Why not in Hover? Already addressed in article 10's Boundary justification.
Why not in Refactoring? Code actions are local repairs to a single diagnostic; refactorings are user-initiated structural transforms. The user's mental model is "this thing is wrong, fix it locally" (code action) versus "I want to restructure" (refactoring). The micro-DSLs respect that distinction.
Requirements
FEAT-MICRODSL-11 in assets/features.ts:
- validatorsDerivedFromKernelPropertyConstraints — the Surface section names the auto-derivation, the Kernel boundary section confirms reading
@Property({ constraint }). - severityMappingStated — the Surface section shows the explicit
severity: 'warning'and the defaulterrorfor auto-derived; the LSP host uses these directly. - codeActionsAsRepairLambdas — the worked example shows
CodeAction.createwith a(patch: PatchBus) => ...callback. - boundaryAgainstHoverJustified — the Boundary justification section repeats the severity / surface / panel argument from article 10.
Article 12 picks up with CodeLens, the at-a-glance annotation surface.