@diagc/core 0.1.0 → 0.2.1

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/validate.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { BUILTIN_NOTATIONS, EDGE_LABEL_SIDES, FONT_SCALES, LEGEND_POSITIONS, LEGEND_SECTIONS, RELATION_LINES, RELATION_MARKERS, RELATION_SHAPES, RESERVED_NODE_ID, SIDES, STRIDE, TEXT_ALIGNS, THREAT_SEVERITIES, THREAT_STATUSES, } from './types.js';
2
2
  import { childrenOf } from './children.js';
3
+ import { isIsoDate } from './dates.js';
3
4
  import { FB_CATEGORY_TYPE, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_NOTATION, fishboneParents, fishboneTree, isFishboneNode } from './fishbone.js';
4
5
  import { GIT_NOTATION, GIT_STAGE_TYPE, gitGraph, isGitKind, stageCommit } from './git.js';
6
+ import { PLAN_NOTATION, atOf, dayOf, isPlanEvent, isPlanRole, isPlanZone, planGraph, spanOf } from './plan.js';
5
7
  import { SECOND_ORDER_NOTATION, SO_DECISION_TYPE, consequenceOrders, isSecondOrderNode } from './second-order.js';
6
8
  import { TM_BOUNDARY_TYPE, TM_FLOW_KIND, TM_NOTATION } from './threat-model.js';
7
9
  export class DiagramValidationError extends Error {
@@ -336,6 +338,53 @@ function validateThreats(ctx) {
336
338
  for (const r of m.relations)
337
339
  check(r.id, r.threats);
338
340
  }
341
+ /** `comments` (nodes and relations) and `links` (nodes) are generic fields:
342
+ * checked for shape wherever they appear, the element as `ref` — the same
343
+ * contract as validateThreats, for the same reason (the bubble reads them
344
+ * unguarded). */
345
+ function validateComments(ctx) {
346
+ const { issues, m } = ctx;
347
+ const comments = (ref, list) => {
348
+ if (list === undefined)
349
+ return;
350
+ if (!Array.isArray(list) || list.some((c) => c === null || typeof c !== 'object')) {
351
+ report(issues, 'invalid-comments', `'comments' on '${ref}' must be a list of comments`, ref);
352
+ return;
353
+ }
354
+ const seen = new Set();
355
+ for (const c of list) {
356
+ const id = c.id;
357
+ if (typeof id !== 'string' || id === '')
358
+ report(issues, 'comment-id', `A comment on '${ref}' has no id`, ref);
359
+ else if (seen.has(id))
360
+ report(issues, 'comment-id', `Comment id '${id}' repeats on '${ref}'`, ref);
361
+ else
362
+ seen.add(id);
363
+ if (typeof c.text !== 'string' || c.text === '')
364
+ report(issues, 'comment-text', `Comment '${String(id)}' on '${ref}' has no text`, ref);
365
+ if (c.at !== undefined && !isIsoDate(c.at))
366
+ report(issues, 'comment-at', `Comment '${String(id)}' on '${ref}': 'at' must be a YYYY-MM-DD date`, ref);
367
+ }
368
+ };
369
+ const links = (ref, list) => {
370
+ if (list === undefined)
371
+ return;
372
+ if (!Array.isArray(list) || list.some((l) => l === null || typeof l !== 'object')) {
373
+ report(issues, 'invalid-links', `'links' on '${ref}' must be a list of { label, url }`, ref);
374
+ return;
375
+ }
376
+ for (const l of list) {
377
+ if (typeof l.label !== 'string' || l.label === '' || typeof l.url !== 'string' || l.url === '')
378
+ report(issues, 'invalid-links', `A link on '${ref}' needs a non-empty label and url`, ref);
379
+ }
380
+ };
381
+ for (const n of m.nodes) {
382
+ comments(n.id, n.comments);
383
+ links(n.id, n.links);
384
+ }
385
+ for (const r of m.relations)
386
+ comments(r.id, r.comments);
387
+ }
339
388
  /** Containment cycles are checked per plane — an edge pair spanning two planes
340
389
  * is legal. Emits one `containment-cycle` issue per offending plane. */
341
390
  function validateCycles(ctx) {
@@ -628,6 +677,76 @@ function validateThreatModel(ctx) {
628
677
  report(issues, 'tm-flow-boundary', `Data flow '${r.id}' touches the trust boundary '${end}'; flows connect elements, a boundary only surrounds them`, r.id);
629
678
  }
630
679
  }
680
+ /**
681
+ * Plan conventions. Dates are checked wherever they are (a plan-zone on any
682
+ * notation is still a zone, like a threat on any element); nesting is checked
683
+ * against the plan plane's containment when a plane declares the notation,
684
+ * else the default plane's. Only `plan-role-target` is notation-gated, the
685
+ * same plane pick as validateThreatModel — outside a plan, `owns` is just a
686
+ * relation kind somebody chose.
687
+ */
688
+ function validatePlan(ctx) {
689
+ const { issues, m } = ctx;
690
+ const dateOr = (n, key) => {
691
+ const raw = n.metadata?.[key];
692
+ if (raw === undefined) {
693
+ report(issues, 'plan-missing', `'${n.id}' (${n.type}) has no '${key}' date`, n.id);
694
+ return undefined;
695
+ }
696
+ if (!isIsoDate(raw)) {
697
+ report(issues, 'plan-date', `'${n.id}': '${key}' must be a real YYYY-MM-DD date, got ${JSON.stringify(raw)}`, n.id);
698
+ return undefined;
699
+ }
700
+ return dayOf(raw);
701
+ };
702
+ for (const n of m.nodes) {
703
+ if (isPlanZone(n)) {
704
+ const start = dateOr(n, 'start');
705
+ const end = dateOr(n, 'end');
706
+ if (start !== undefined && end !== undefined && end < start) {
707
+ report(issues, 'plan-span', `'${n.id}': end ${String(n.metadata?.end)} is before start ${String(n.metadata?.start)}`, n.id);
708
+ }
709
+ }
710
+ else if (isPlanEvent(n)) {
711
+ dateOr(n, 'at');
712
+ }
713
+ }
714
+ const plane = ctx.planes.find((p) => (p.notation ?? m.notation) === PLAN_NOTATION);
715
+ const byId = new Map(m.nodes.map((n) => [n.id, n]));
716
+ // `planGraph` is a full `buildHierarchy`, and this function runs on EVERY
717
+ // model — validateGit and validateFishbone return before their derivations,
718
+ // but this one cannot, because dates are checked whatever the notation. So
719
+ // the graph is paid for only where there is something to nest; the date loop
720
+ // above and the role-target loop below never touch it.
721
+ if (m.nodes.some((n) => isPlanZone(n) || isPlanEvent(n))) {
722
+ const g = planGraph(m, plane?.id);
723
+ for (const [child, parentId] of g.parent) {
724
+ const outer = spanOf(byId.get(parentId));
725
+ const node = byId.get(child);
726
+ if (outer === undefined)
727
+ continue;
728
+ const inner = spanOf(node) ?? (atOf(node) !== undefined ? { start: atOf(node), end: atOf(node) } : undefined);
729
+ if (inner === undefined)
730
+ continue;
731
+ if (inner.start < outer.start || inner.end > outer.end) {
732
+ report(issues, 'plan-nested', `'${child}' lies outside its zone '${parentId}' (${String(byId.get(parentId).metadata?.start)} … ${String(byId.get(parentId).metadata?.end)})`, child);
733
+ }
734
+ }
735
+ }
736
+ const modelLevel = plane === undefined && ctx.planes.length === 0 && m.notation === PLAN_NOTATION;
737
+ if (plane === undefined && !modelLevel)
738
+ return;
739
+ for (const r of m.relations) {
740
+ if (!isPlanRole(r.kind))
741
+ continue;
742
+ if (!ctx.nodeIds.has(r.from) || !ctx.nodeIds.has(r.to))
743
+ continue; // dangling ends are validateRelations' finding
744
+ const target = byId.get(r.to);
745
+ if (target !== undefined && !isPlanZone(target)) {
746
+ report(issues, 'plan-role-target', `Relation '${r.id}' (${r.kind}) must point at a plan-zone; '${r.to}' is ${target.type ?? 'untyped'}`, r.id);
747
+ }
748
+ }
749
+ }
631
750
  export function validate(m) {
632
751
  const ctx = {
633
752
  issues: [],
@@ -649,12 +768,14 @@ export function validate(m) {
649
768
  validateContainment(ctx);
650
769
  validateRelations(ctx);
651
770
  validateThreats(ctx);
771
+ validateComments(ctx);
652
772
  validateCycles(ctx);
653
773
  validateGit(ctx);
654
774
  validateActivity(ctx);
655
775
  validateSecondOrder(ctx);
656
776
  validateFishbone(ctx);
657
777
  validateThreatModel(ctx);
778
+ validatePlan(ctx);
658
779
  return ctx.issues;
659
780
  }
660
781
  function findContainmentCycle(edges) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagc/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Diagram model, validator, and builder DSL for diagc — no React, no filesystem.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Bogdan Frankovskyi",