@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/README.md +3 -3
- package/dist/builder.d.ts +88 -2
- package/dist/builder.js +146 -1
- package/dist/commands.d.ts +25 -2
- package/dist/commands.js +25 -7
- package/dist/comments.d.ts +24 -0
- package/dist/comments.js +25 -0
- package/dist/dates.d.ts +1 -0
- package/dist/dates.js +12 -0
- package/dist/eject.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/mutate.d.ts +27 -2
- package/dist/mutate.js +115 -17
- package/dist/plan.d.ts +105 -0
- package/dist/plan.js +172 -0
- package/dist/types.d.ts +31 -3
- package/dist/types.js +4 -2
- package/dist/validate.d.ts +1 -1
- package/dist/validate.js +121 -0
- package/package.json +1 -1
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) {
|